diff --git a/.quixos-subtree-source.json b/.quixos-subtree-source.json index e30aeb4..fb594f9 100644 --- a/.quixos-subtree-source.json +++ b/.quixos-subtree-source.json @@ -1,7 +1,7 @@ { "version": 1, "sourceRepo": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos", - "sourceCommit": "a61bdda658be4878403b8d60aecb71fe5a614ac0", + "sourceCommit": "8014b4ba717313e1b494c59a8e2781df58bf9a85", "sourcePath": "quixos-protocol", "exportName": "quixos-protocol", "mirrorRemote": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-protocol.git" diff --git a/nix/checked-candidate.nix b/nix/checked-candidate.nix index d657ca0..4ccb7a6 100644 --- a/nix/checked-candidate.nix +++ b/nix/checked-candidate.nix @@ -6,6 +6,7 @@ kind, generator, contractOnly ? false, + workspaceId ? null, system ? builtins.currentSystem, }: let @@ -164,6 +165,11 @@ let ${queryGeneratorFlags root} \ --checked-resources ${checkedResources node} \ --source-root-commit ${pkgs.lib.escapeShellArg node.commit} \ + ${ + pkgs.lib.optionalString ( + workspaceId != null + ) "--workspace-id ${pkgs.lib.escapeShellArg workspaceId}" + } \ --checkout-root "$TMPDIR/checkouts" --snapshot-map ${snapshots node} \ --graph-out "$out/graph.json" --schemas-out "$out/package-schemas.json" \ ${ @@ -357,6 +363,13 @@ let ) ); in +assert + workspaceId == null + || ( + kind == "workspace" + && builtins.isString workspaceId + && builtins.match "[a-zA-Z0-9][a-zA-Z0-9:._-]{0,255}" workspaceId != null + ); pkgs.runCommand (if contractOnly then "qx-contract" else "qx-checked-candidate") { } '' mkdir -p "$out" cp ${ diff --git a/src/capability-language/authoring-check.ts b/src/capability-language/authoring-check.ts index b378075..fee9370 100644 --- a/src/capability-language/authoring-check.ts +++ b/src/capability-language/authoring-check.ts @@ -97,6 +97,7 @@ export async function checkAuthoring( (phase, ms) => { timings[phase] = ms; }, + resource.kind === "workspace" ? context.workspaceId : undefined, ); } finally { timings.immutableCheckMs = Math.round(performance.now() - buildStarted); diff --git a/src/capability-language/authoring-context.ts b/src/capability-language/authoring-context.ts index 2402292..4a88a40 100644 --- a/src/capability-language/authoring-context.ts +++ b/src/capability-language/authoring-context.ts @@ -20,7 +20,12 @@ export async function authoringContext(start: string) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } if (text !== undefined) { - const graph = JSON.parse(text) as { resources: AuthoringResource[] }; + const graph = JSON.parse(text) as { resources: AuthoringResource[]; workspaceId?: string }; + if ( + graph.workspaceId !== undefined && + (typeof graph.workspaceId !== "string" || !/^[a-zA-Z0-9][a-zA-Z0-9:._-]{0,255}$/.test(graph.workspaceId)) + ) + throw new Error("Managed workbench has an invalid Workspace identity"); if (!Array.isArray(graph.resources)) throw new Error("Managed resource inventory is malformed"); const resources: AuthoringResource[] = [{ kind: "workspace", directory: "root" }]; const identities = new Set(), @@ -52,6 +57,7 @@ export async function authoringContext(start: string) { } return { workbench, + workspaceId: graph.workspaceId, resources, async baseline() { const result = await loadQuixosLock(path.join(workbench, "root/quixos.lock")); diff --git a/src/capability-language/checked-build.ts b/src/capability-language/checked-build.ts index 5fe2122..e7d21a0 100644 --- a/src/capability-language/checked-build.ts +++ b/src/capability-language/checked-build.ts @@ -112,7 +112,10 @@ export async function buildImmutableCandidate( logFile: string, contractOnly = false, observe?: (phase: "nixEvaluationMs" | "nixBuildMs", ms: number) => void, + workspaceId?: string, ): Promise { + if (workspaceId !== undefined && (kind !== "workspace" || !/^[a-zA-Z0-9][a-zA-Z0-9:._-]{0,255}$/.test(workspaceId))) + throw new Error("Instance binding requires a valid Workspace identity and a Workspace root"); if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(source.commit)) throw new Error("An immutable candidate requires an exact commit"); const url = new URL(source.repository); @@ -200,6 +203,7 @@ export async function buildImmutableCandidate( "--arg", "contractOnly", contractOnly ? "true" : "false", + ...(workspaceId === undefined ? [] : ["--argstr", "workspaceId", workspaceId]), "--add-root", gcRoot, "--indirect", diff --git a/src/capability-language/development-sources.ts b/src/capability-language/development-sources.ts new file mode 100644 index 0000000..97e8d26 --- /dev/null +++ b/src/capability-language/development-sources.ts @@ -0,0 +1,93 @@ +import { execFile as callback } from "node:child_process"; +import { promisify } from "node:util"; +import { randomUUID } from "node:crypto"; +import { mkdir, mkdtemp, lstat, rename, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { materializeWorkspaceSources, workspaceSourcePin, type WorkspaceSourcePin } from "./source-closure.js"; + +const exec = promisify(callback); + +/** Developer workflow, not an ordinary Workspace Agent's implicit permission + * to publish against canonical repositories. Creates independent Git/jj copies, + * with instance binding outside committed source. No Graph is started here. + */ +export async function prepareDevelopmentSources(options: { + source: WorkspaceSourcePin; + directory: string; + cacheDirectory: string; + locations?: Record; +}) { + const source = workspaceSourcePin(options.source); + const destination = path.resolve(options.directory); + const cache = path.resolve(options.cacheDirectory); + if (cache === destination || cache.startsWith(destination + path.sep)) + throw Error("The retained source cache must live outside the disposable workbench"); + if ( + await lstat(destination).catch((error: NodeJS.ErrnoException) => { + if (error.code !== "ENOENT") throw error; + return null; + }) + ) + throw Error("Choose a new development workbench directory; existing checkouts are never overwritten"); + const closure = await materializeWorkspaceSources({ + source, + directory: options.cacheDirectory, + locations: options.locations, + }); + const origins = new Set(); + for (const entry of closure.resources) { + const identity = `${entry.kind}:${entry.repository}`; + if (origins.has(identity)) throw Error("Multiple revisions of one repository require explicit editable selection"); + origins.add(identity); + } + const parent = path.dirname(destination); + await mkdir(parent, { recursive: true }); + const staging = await mkdtemp(path.join(parent, ".development-sources-")); + const workspaceId = randomUUID(); + try { + const resources = []; + for (const entry of closure.resources) { + const relative = entry.kind === "workspace" ? "root" : "resources/" + entry.directory; + const checkout = path.join(staging, relative); + await mkdir(path.dirname(checkout), { recursive: true }); + await exec("git", [ + "clone", + "--no-hardlinks", + "--", + path.join(path.resolve(options.cacheDirectory), entry.directory), + checkout, + ]); + await exec("git", ["remote", "set-url", "origin", entry.repository], { cwd: checkout }); + await exec("git", ["checkout", "--detach", entry.commit], { cwd: checkout }); + await exec("jj", ["git", "init", "--colocate"], { + cwd: checkout, + env: { ...process.env, QUIXOS_JJ_NO_CHECKPOINT: "1", QUIXOS_SUBTREE_PUBLISH: "0" }, + }); + await exec("jj", ["describe", "-m", "Develop canonical application source"], { + cwd: checkout, + env: { ...process.env, QUIXOS_JJ_NO_CHECKPOINT: "1", QUIXOS_SUBTREE_PUBLISH: "0" }, + }); + if (entry.kind !== "workspace") + resources.push({ + kind: entry.kind, + directory: relative, + source: { resolver: "git", repository: entry.repository, commit: entry.commit }, + }); + } + await mkdir(path.join(staging, ".quixos"), { mode: 0o700 }); + await writeFile( + path.join(staging, ".quixos/resource-graph.json"), + JSON.stringify({ formatVersion: 1, workspaceId, resources }, null, 2) + "\n", + { mode: 0o600 }, + ); + await writeFile( + path.join(staging, ".quixos/development-source.json"), + JSON.stringify({ source, workspaceId }, null, 2) + "\n", + { mode: 0o600 }, + ); + await rename(staging, destination); + return { directory: destination, source, workspaceId, repositories: closure.resources.length }; + } finally { + await rm(staging, { recursive: true, force: true }); + } +} diff --git a/src/capability-language/source-backup.ts b/src/capability-language/source-backup.ts new file mode 100644 index 0000000..cefa9d3 --- /dev/null +++ b/src/capability-language/source-backup.ts @@ -0,0 +1,117 @@ +import { execFile as callback } from "node:child_process"; +import { promisify } from "node:util"; +import { createHash } from "node:crypto"; +import { createReadStream } from "node:fs"; +import { lstat, mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { retentionTagForCommit } from "../resource-lock/index.js"; +import { materializeWorkspaceSources, workspaceSourcePin, type WorkspaceSourcePin } from "./source-closure.js"; + +const exec = promisify(callback); +type SourceBackup = { + formatVersion: 1; + root: WorkspaceSourcePin; + repositories: Array<{ repository: string; commits: string[]; bundle: string; sha256: string }>; +}; +async function hash(filename: string) { + const digest = createHash("sha256"); + for await (const chunk of createReadStream(filename)) digest.update(chunk); + return digest.digest("hex"); +} + +/** Source-only backup: no Graph state, checkout instructions or credentials. + * Git bundles retain exact commits and their ancestors without needing a forge. + * Storage/retention of the resulting directory belongs to the operator. */ +export async function backupWorkspaceSources(options: { + source: WorkspaceSourcePin; + directory: string; + output: string; + locations?: Record; +}): Promise { + const output = path.resolve(options.output); + if ( + await lstat(output).catch((error: NodeJS.ErrnoException) => { + if (error.code !== "ENOENT") throw error; + return null; + }) + ) + throw Error("Source backup destination must not exist"); + const closure = await materializeWorkspaceSources(options); + const grouped = new Map>(); + for (const entry of closure.resources) { + const commits = grouped.get(entry.repository) ?? new Map(); + commits.set(entry.commit, path.join(path.resolve(options.directory), entry.directory)); + grouped.set(entry.repository, commits); + } + await mkdir(path.dirname(output), { recursive: true }); + const staging = await mkdtemp(path.join(path.dirname(output), ".source-backup-")); + const manifest: SourceBackup = { formatVersion: 1, root: closure.root, repositories: [] }; + try { + for (const [repository, commits] of [...grouped].sort(([a], [b]) => a.localeCompare(b))) { + const bundle = createHash("sha256").update(repository).digest("hex") + ".bundle"; + const gitDirectory = await mkdtemp(path.join(staging, ".git-")); + try { + await exec("git", ["init", "--bare", gitDirectory]); + const refs: string[] = []; + for (const [commit, checkout] of commits) { + const ref = retentionTagForCommit(commit); + await exec("git", ["-C", gitDirectory, "fetch", "--no-tags", "--", checkout, `${ref}:${ref}`]); + refs.push(ref); + } + await exec("git", ["-C", gitDirectory, "bundle", "create", path.join(staging, bundle), ...refs]); + } finally { + await rm(gitDirectory, { recursive: true, force: true }); + } + manifest.repositories.push({ + repository, + commits: [...commits.keys()].sort(), + bundle, + sha256: await hash(path.join(staging, bundle)), + }); + } + await writeFile(path.join(staging, "backup.json"), JSON.stringify(manifest, null, 2) + "\n", { mode: 0o600 }); + await rename(staging, output); + return manifest; + } finally { + await rm(staging, { recursive: true, force: true }); + } +} + +export async function restoreWorkspaceSources(options: { backup: string; directory: string }) { + const backup = path.resolve(options.backup); + const filename = path.join(backup, "backup.json"); + const info = await lstat(filename); + if (!info.isFile() || info.isSymbolicLink() || info.size > 1024 * 1024) throw Error("Invalid source backup manifest"); + const manifest = JSON.parse(await readFile(filename, "utf8")) as SourceBackup; + if ( + manifest.formatVersion !== 1 || + !Array.isArray(manifest.repositories) || + !manifest.repositories.length || + manifest.repositories.length > 256 + ) + throw Error("Invalid source backup manifest"); + const root = workspaceSourcePin(manifest.root); + const locations: Record = {}; + for (const entry of manifest.repositories) { + if (!entry || !Array.isArray(entry.commits) || !entry.commits.length || entry.commits.length > 256) + throw Error("Invalid source backup entry"); + for (const commit of entry.commits) workspaceSourcePin({ repository: entry.repository, commit }); + if (Object.hasOwn(locations, entry.repository)) throw Error("Duplicate source backup repository"); + const expected = createHash("sha256").update(entry.repository).digest("hex") + ".bundle"; + if (entry.bundle !== expected || !/^[a-f0-9]{64}$/.test(entry.sha256)) + throw Error("Invalid source backup bundle identity"); + const bundle = path.join(backup, expected); + const stat = await lstat(bundle); + if (!stat.isFile() || stat.isSymbolicLink() || (await hash(bundle)) !== entry.sha256) + throw Error("Source backup bundle integrity mismatch"); + locations[entry.repository] = pathToFileURL(bundle).href; + } + if ( + !manifest.repositories.some((entry) => entry.repository === root.repository && entry.commits.includes(root.commit)) + ) + throw Error("Source backup does not retain its root commit"); + // Every discovered dependency must use the backup. Never fall through to the + // original forge if a manifest omits a required repository. + return materializeWorkspaceSources({ source: root, directory: options.directory, locations, requireLocations: true }); +} diff --git a/src/capability-language/source-check-driver.ts b/src/capability-language/source-check-driver.ts index 36f6350..4a4169a 100644 --- a/src/capability-language/source-check-driver.ts +++ b/src/capability-language/source-check-driver.ts @@ -2,7 +2,9 @@ import { buildImmutableCandidate } from "./checked-build.js"; // Installed platform driver, run only inside the sealed checker's unprivileged // evaluator. Authored flakes never become the outer evaluator program. -const [repository, commit, log] = process.argv.slice(2); -if (!repository || !commit || !log || process.argv.length !== 5) - throw Error("source-check-driver REPOSITORY COMMIT LOG"); -process.stdout.write((await buildImmutableCandidate({ repository, commit }, "workspace", log)) + "\n"); +const [repository, commit, log, workspaceId] = process.argv.slice(2); +if (!repository || !commit || !log || !workspaceId || process.argv.length !== 6) + throw Error("source-check-driver REPOSITORY COMMIT LOG WORKSPACE_ID"); +process.stdout.write( + (await buildImmutableCandidate({ repository, commit }, "workspace", log, false, undefined, workspaceId)) + "\n", +); diff --git a/src/capability-language/source-closure.ts b/src/capability-language/source-closure.ts new file mode 100644 index 0000000..6f71763 --- /dev/null +++ b/src/capability-language/source-closure.ts @@ -0,0 +1,176 @@ +import { execFile as callback } from "node:child_process"; +import { promisify } from "node:util"; +import { createHash, randomUUID } from "node:crypto"; +import { mkdir, mkdtemp, lstat, realpath, rename, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { loadQuixosLock, retentionTagForCommit } from "../resource-lock/index.js"; + +const exec = promisify(callback); +import { workspaceSourcePin, type WorkspaceSourcePin } from "./source-pin.js"; +export { workspaceSourcePin, type WorkspaceSourcePin } from "./source-pin.js"; +export type SourceClosureEntry = WorkspaceSourcePin & { + kind: "workspace" | "interface" | "package" | "build"; + directory: string; +}; +export type WorkspaceSourceClosure = { + formatVersion: 1; + root: WorkspaceSourcePin; + resources: SourceClosureEntry[]; +}; + +/** Trusted transport substitutions preserve source identity. A mirror or bundle + * is explicitly selected, never tried automatically after an origin failure. + * Git authentication stays in the caller's Git configuration, outside metadata. + */ +export async function materializeWorkspaceSources(options: { + source: WorkspaceSourcePin; + directory: string; + locations?: Record; + concurrency?: number; + maxRepositories?: number; + requireLocations?: boolean; + /** Optional acquisition boundary, enforced before fetching each dependency. + * It limits canonical identity; explicit transport substitutions remain a + * separate trusted operator decision. */ + repositoryPrefix?: string; +}): Promise { + const source = workspaceSourcePin(options.source); + const prefix = options.repositoryPrefix ? new URL(options.repositoryPrefix) : null; + if ( + prefix && + (prefix.protocol !== "https:" || + prefix.username || + prefix.password || + prefix.search || + prefix.hash || + !prefix.pathname.endsWith("/") || + /[%\\\\]/.test(prefix.pathname)) + ) + throw Error("Source repository prefix requires a credential-free HTTPS directory URL"); + const concurrency = options.concurrency ?? 4, + maxRepositories = options.maxRepositories ?? 256; + if ( + !Number.isInteger(concurrency) || + concurrency < 1 || + concurrency > 16 || + !Number.isInteger(maxRepositories) || + maxRepositories < 1 || + maxRepositories > 4096 + ) + throw Error("Invalid source closure limits"); + const locations = new Map(Object.entries(options.locations ?? {})); + for (const [canonical, location] of locations) { + workspaceSourcePin({ repository: canonical, commit: source.commit }); + const url = new URL(location); + if ( + !["https:", "file:"].includes(url.protocol) || + url.username || + url.password || + url.search || + url.hash || + (url.protocol === "file:" && url.hostname !== "") + ) + throw Error("Invalid source transport substitution"); + } + await mkdir(options.directory, { recursive: true, mode: 0o700 }); + const directory = await realpath(options.directory); + const entries = new Map(); + const pending: SourceClosureEntry[] = []; + const enqueue = (pin: WorkspaceSourcePin, kind: SourceClosureEntry["kind"]) => { + pin = workspaceSourcePin(pin); + const repository = new URL(pin.repository); + if ( + prefix && + (repository.origin !== prefix.origin || + !repository.pathname.startsWith(prefix.pathname) || + /[%\\\\]/.test(repository.pathname)) + ) + throw Error(`Source repository is outside the acquisition boundary: ${pin.repository}`); + if (options.requireLocations && !locations.has(pin.repository)) + throw Error(`Source backup omits required repository: ${pin.repository}`); + const key = `${kind}\0${pin.repository}\0${pin.commit}`; + if (entries.has(key)) return; + if (entries.size >= maxRepositories) throw Error("Workspace source closure exceeds repository limit"); + const name = `${kind}-${createHash("sha256").update(key).digest("hex").slice(0, 24)}`; + const entry = { ...pin, kind, directory: name }; + entries.set(key, entry); + pending.push(entry); + }; + enqueue(source, "workspace"); + const git = async (cwd: string, args: string[]) => + ( + await exec("git", args, { + cwd, + env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }, + maxBuffer: 4 * 1024 * 1024, + }) + ).stdout.trim(); + const checkout = async (entry: SourceClosureEntry) => { + const destination = path.join(directory, entry.directory); + const verify = async () => { + const info = await lstat(destination); + if (!info.isDirectory() || info.isSymbolicLink() || (await realpath(destination)) !== destination) + throw Error("Source checkout must be a contained directory"); + if ((await git(destination, ["rev-parse", "HEAD"])) !== entry.commit) + throw Error(`Source commit mismatch: ${entry.repository}`); + if (await git(destination, ["status", "--porcelain", "--untracked-files=all"])) + throw Error(`Source checkout was modified: ${entry.repository}`); + }; + const existing = await lstat(destination).catch((error: NodeJS.ErrnoException) => { + if (error.code !== "ENOENT") throw error; + return null; + }); + if (!existing) { + const staging = await mkdtemp(path.join(directory, ".fetch-")); + try { + await git(staging, ["init"]); + await git(staging, ["remote", "add", "origin", entry.repository]); + const ref = retentionTagForCommit(entry.commit); + // Full history supports normal jj development and independent bundles. + const location = locations.get(entry.repository) ?? entry.repository; + await git(staging, [ + "fetch", + "--no-tags", + "--", + location.startsWith("file:") ? fileURLToPath(location) : location, + `${ref}:${ref}`, + ]); + await git(staging, ["checkout", "--detach", entry.commit]); + try { + await rename(staging, destination); + } catch (error) { + if (!["EEXIST", "ENOTEMPTY"].includes((error as NodeJS.ErrnoException).code ?? "")) throw error; + } + } finally { + await rm(staging, { recursive: true, force: true }); + } + } + await verify(); + const lock = await loadQuixosLock(path.join(destination, "quixos.lock")); + if (!lock.ok) + throw Error(`Invalid source lock in ${entry.repository}: ${lock.diagnostics.map((d) => d.message).join("; ")}`); + return lock.lock.resources; + }; + // Bounded breadth-first batches resolve diamonds once and avoid recursive + // promise deadlocks. Completion order cannot affect the recorded closure. + while (pending.length) { + const batch = pending.splice(0, concurrency); + const results = await Promise.allSettled(batch.map(checkout)); + for (const result of results) { + if (result.status === "rejected") throw result.reason; + for (const resource of result.value) enqueue(resource.source, resource.kind); + } + } + const closure: WorkspaceSourceClosure = { + formatVersion: 1, + root: source, + resources: [...entries.values()].sort((a, b) => + `${a.kind}:${a.repository}@${a.commit}`.localeCompare(`${b.kind}:${b.repository}@${b.commit}`), + ), + }; + const temporary = path.join(directory, `.closure-${randomUUID()}.json`); + await writeFile(temporary, JSON.stringify(closure, null, 2) + "\n", { flag: "wx", mode: 0o600 }); + await rename(temporary, path.join(directory, "sources.json")); + return closure; +} diff --git a/src/capability-language/source-locks.ts b/src/capability-language/source-locks.ts new file mode 100644 index 0000000..049bb68 --- /dev/null +++ b/src/capability-language/source-locks.ts @@ -0,0 +1,48 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { loadQuixosLock, parseQuixosLockDocument, resolveQuixosLock } from "../resource-lock/index.js"; +import { editStructure } from "./structural-edits.js"; +import { workspaceSourcePin, type WorkspaceSourcePin } from "./source-closure.js"; + +/** Inspect/rewrite the root and its declared imports only, using the same lock + * grammar as compilation. Return proposed file contents; publishing them and + * selecting a Graph remain the caller's separate responsibilities. */ +export async function workspaceSourceLocks(directory: string, input: unknown = []) { + if (!Array.isArray(input) || input.length > 256) throw Error("Invalid source lock replacements"); + const replacements = new Map(); + const key = (source: WorkspaceSourcePin) => `${source.repository}\0${source.commit}`; + for (const entry of input) { + if (!entry || typeof entry !== "object") throw Error("Invalid source lock replacement"); + const from = workspaceSourcePin(entry.from), + to = workspaceSourcePin(entry.to); + if (replacements.has(key(from))) throw Error("Duplicate source lock replacement"); + replacements.set(key(from), to); + } + const lock = await loadQuixosLock(path.join(directory, "quixos.lock")); + if (!lock.ok) throw Error(`Invalid source lock: ${lock.diagnostics.map((d) => d.message).join("; ")}`); + const files = new Map(); + for (const filename of lock.lock.sourceFiles!) { + let source = await readFile(path.join(directory, filename), "utf8"); + const parsed = parseQuixosLockDocument(source, filename); + if (!parsed.ok) throw Error("Source lock changed during inspection"); + for (const dependency of parsed.document.resources) { + const replacement = replacements.get(key(dependency.source)); + if (replacement) + source = editStructure(source, { + operation: "dependency", + kind: dependency.kind, + name: dependency.binding, + source: replacement, + }); + } + files.set(filename, source); + } + const checked = await resolveQuixosLock(files.get("quixos.lock")!, async (filename) => { + const source = files.get(filename); + if (source === undefined) throw Error("Source lock import changed during inspection"); + return source; + }); + if (!checked.ok) + throw Error(`Invalid rewritten source locks: ${checked.diagnostics.map((d) => d.message).join("; ")}`); + return { lock: checked.lock, files: [...files].map(([path, source]) => ({ path, source })) }; +} diff --git a/src/capability-language/source-pin.ts b/src/capability-language/source-pin.ts new file mode 100644 index 0000000..501347c --- /dev/null +++ b/src/capability-language/source-pin.ts @@ -0,0 +1,18 @@ +export type WorkspaceSourcePin = { repository: string; commit: string }; + +export function workspaceSourcePin(value: unknown): WorkspaceSourcePin { + if (!value || typeof value !== "object" || Array.isArray(value)) throw Error("Invalid Workspace source pin"); + const pin = value as WorkspaceSourcePin; + const url = new URL(pin.repository); + if ( + url.protocol !== "https:" || + url.username || + url.password || + url.search || + url.hash || + typeof pin.commit !== "string" || + !/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(pin.commit) + ) + throw Error("Workspace source pins require credential-free HTTPS and an exact commit"); + return { repository: pin.repository, commit: pin.commit }; +} diff --git a/src/capability-language/tool-cli.ts b/src/capability-language/tool-cli.ts index 9e85703..41a382d 100644 --- a/src/capability-language/tool-cli.ts +++ b/src/capability-language/tool-cli.ts @@ -54,6 +54,61 @@ const planSummary = (plan: Awaited>) => ({ const main = async () => { const [command, ...args] = process.argv.slice(2); + if (command === "source-locks") { + if (!args[0] || (args.length !== 1 && (args.length !== 3 || args[1] !== "--replacements"))) + throw Error("source-locks DIRECTORY [--replacements JSON_FILE]"); + const { workspaceSourceLocks } = await import("./source-locks.js"); + const replacements = args[2] ? JSON.parse(await readFile(args[2], "utf8")) : []; + process.stdout.write(JSON.stringify(await workspaceSourceLocks(path.resolve(args[0]), replacements)) + "\n"); + return; + } + if (["source-checkout", "source-develop", "source-backup", "source-restore"].includes(command!)) { + const args = ["source", command.slice("source-".length), ...process.argv.slice(3)]; + const { materializeWorkspaceSources } = await import("./source-closure.js"); + if (!args[2]) throw Error("source requires a pinned source JSON file"); + const flags = new Map(); + for (let i = 3; i < args.length; i += 2) { + if ( + ![ + "--dir", + ...(args[1] !== "restore" ? ["--locations"] : []), + ...(args[1] === "checkout" ? ["--repository-prefix"] : []), + ...(args[1] === "develop" ? ["--cache"] : []), + ...(args[1] === "backup" ? ["--output"] : []), + ].includes(args[i]) || + !args[i + 1] || + flags.has(args[i]) + ) + throw Error("Invalid source command options; use qx-workspace help source"); + flags.set(args[i], args[i + 1]); + } + if (!flags.get("--dir")) throw Error("source requires --dir DIRECTORY"); + if (args[1] === "restore") { + const { restoreWorkspaceSources } = await import("./source-backup.js"); + const result = await restoreWorkspaceSources({ backup: args[2], directory: path.resolve(flags.get("--dir")!) }); + process.stdout.write(JSON.stringify(result, null, 2) + "\n"); + return; + } + const locationsFile = flags.get("--locations"); + const options = { + source: JSON.parse(await readFile(args[2], "utf8")), + directory: path.resolve(flags.get("--dir")!), + ...(locationsFile ? { locations: JSON.parse(await readFile(locationsFile, "utf8")) } : {}), + ...(flags.has("--repository-prefix") ? { repositoryPrefix: flags.get("--repository-prefix") } : {}), + }; + let result; + if (args[1] === "develop") { + if (!flags.get("--cache")) throw Error("source develop requires --cache DIRECTORY"); + const { prepareDevelopmentSources } = await import("./development-sources.js"); + result = await prepareDevelopmentSources({ ...options, cacheDirectory: path.resolve(flags.get("--cache")!) }); + } else if (args[1] === "backup") { + if (!flags.get("--output")) throw Error("source backup requires --output DIRECTORY"); + const { backupWorkspaceSources } = await import("./source-backup.js"); + result = await backupWorkspaceSources({ ...options, output: path.resolve(flags.get("--output")!) }); + } else result = await materializeWorkspaceSources(options); + process.stdout.write(JSON.stringify(result, null, 2) + "\n"); + return; + } if (command === "source-manifest") { if (args.length !== 1) throw new Error("source-manifest WORKSPACE_ROOT"); process.stdout.write(JSON.stringify(await qxSourceManifest(args[0])) + "\n"); @@ -197,10 +252,16 @@ const main = async () => { } if (command === "check-committed" && !args.includes("--help")) { const [kind, repository, commit, log, ...extra] = args; - if (!["workspace", "interface", "package", "build"].includes(kind) || !log || extra.length) - throw new Error("usage: quixos-qx check-committed workspace|interface|package|build REPOSITORY COMMIT LOG_FILE"); + if ( + !["workspace", "interface", "package", "build"].includes(kind) || + !log || + (extra.length !== 0 && (kind !== "workspace" || extra.length !== 2 || extra[0] !== "--workspace-id" || !extra[1])) + ) + throw new Error( + "usage: quixos-qx check-committed workspace|interface|package|build REPOSITORY COMMIT LOG_FILE [--workspace-id ID]", + ); process.stdout.write( - `${await buildImmutableCandidate({ repository, commit }, kind as "workspace" | "interface" | "package" | "build", log)}\n`, + `${await buildImmutableCandidate({ repository, commit }, kind as "workspace" | "interface" | "package" | "build", log, false, undefined, extra[1])}\n`, ); return; } diff --git a/src/capability-language/workspace-cli.ts b/src/capability-language/workspace-cli.ts index 37c35c6..11b36b0 100644 --- a/src/capability-language/workspace-cli.ts +++ b/src/capability-language/workspace-cli.ts @@ -17,7 +17,7 @@ import { } from "../capability-model/index.js"; const usage = `usage: quixos-workspace-compile --root DIRECTORY --checkout-root DIRECTORY - [--snapshot-map PATH] [--graph-out PATH] [--workspace-id ID] [--workspace-revision-id ID] + [--snapshot-map PATH] [--graph-out PATH] [--plan-out PATH] [--workspace-id ID] [--workspace-revision-id ID] [--source-root-commit GIT_REV] [--baseline PLAN_JSON] [--evolution-out PATH] [--reviews REVIEW_JSON] [--schemas-out PATH] [--execution-plan-out PATH] [--build-contexts-out PATH] [--query-generator BUILD_MODULE_PATH] [--checked-resources NIX_MAP] @@ -41,6 +41,7 @@ const parseArgs = (args: string[]) => { rootDirectory, checkoutRoot, graphOut: values.get("--graph-out"), + planOut: values.get("--plan-out"), schemasOut: values.get("--schemas-out"), executionPlanOut: values.get("--execution-plan-out"), buildContextsOut: values.get("--build-contexts-out"), @@ -84,6 +85,7 @@ const main = async () => { `${JSON.stringify( { formatVersion: 1, + workspaceId: assembled.workspace.workspaceId, quixos: assembled.lock.quixos, directResources: [...assembled.directResources.entries()].map(([bindingKey, node]) => { const [kind, binding] = bindingKey.split("\0"); @@ -157,7 +159,8 @@ const main = async () => { `${JSON.stringify(planEvolution(baseline, candidate, { reviews }), null, 2)}\n`, ); } - process.stdout.write(`${JSON.stringify(candidate, null, 2)}\n`); + if (options.planOut) await writeFile(options.planOut, `${JSON.stringify(candidate)}\n`); + else process.stdout.write(`${JSON.stringify(candidate, null, 2)}\n`); }; main().catch((error: unknown) => { diff --git a/src/resource-lock/loader.ts b/src/resource-lock/loader.ts index 7f134e9..c565d93 100644 --- a/src/resource-lock/loader.ts +++ b/src/resource-lock/loader.ts @@ -118,6 +118,9 @@ export const loadQuixosLock = async (fileName: string): Promise ({ + directory: directories.get(`${kind}\0${source.repository}\0${source.commit}`)!, + }); + const bound = await Promise.all( + ["workspace:first", "workspace:second"].map((workspaceId) => + compileWorkspaceRepository({ + rootDirectory: root, + resolveResource, + workspaceId, + sourceRootCommit: "5".repeat(40), + }), + ), + ); + assert.notEqual(bound[0]!.workspace.workspaceId, bound[1]!.workspace.workspaceId); + assert.notEqual(bound[0]!.workspace.id, bound[1]!.workspace.id); + for (const instance of bound) { + assert.equal(instance.workspace.sourceRootCommit, "5".repeat(40)); + assert.deepEqual(instance.workspace.packageImports, result.workspace.packageImports); + assert.deepEqual(instance.workspace.interfaceImports, result.workspace.interfaceImports); + assert.deepEqual(instance.resources, result.resources, "binding does not fork resource identities"); + } + const resource = await compileCapabilityResourceRepository({ rootDirectory: runtime, kind: "package", diff --git a/test/source-closure.test.ts b/test/source-closure.test.ts new file mode 100644 index 0000000..4fb8c3e --- /dev/null +++ b/test/source-closure.test.ts @@ -0,0 +1,215 @@ +import assert from "node:assert/strict"; +import { execFile as callback } from "node:child_process"; +import { promisify } from "node:util"; +import { mkdtemp, mkdir, writeFile, readFile, rm, symlink } from "node:fs/promises"; +import path from "node:path"; +import os from "node:os"; +import test from "node:test"; +import { pathToFileURL } from "node:url"; +import { materializeWorkspaceSources, workspaceSourcePin } from "../src/capability-language/source-closure.js"; +import { prepareDevelopmentSources } from "../src/capability-language/development-sources.js"; +import { backupWorkspaceSources, restoreWorkspaceSources } from "../src/capability-language/source-backup.js"; + +const exec = promisify(callback); +const lock = (resources: string) => `quixos-lock version 1 { + quixos source { repository "https://unavailable.test/toolchain.git"; commit "${"a".repeat(40)}"; } + ${resources} +}`; +test("exact source closure recovers through explicitly selected bundles, preserves history and rejects edits", async (context) => { + const temporary = await mkdtemp(path.join(os.tmpdir(), "qx-closure-")); + context.after(() => rm(temporary, { recursive: true, force: true })); + const locations: Record = {}; + const repository = async (name: string, resources: string) => { + const directory = path.join(temporary, name); + await mkdir(directory); + const git = async (...args: string[]) => (await exec("git", args, { cwd: directory })).stdout.trim(); + await git("init"); + await writeFile(path.join(directory, "quixos.lock"), lock("")); + await git("add", "."); + const commit = async () => { + await git("-c", "user.name=Test", "-c", "user.email=test@example.test", "commit", "-am", "Source"); + return await git("rev-parse", "HEAD"); + }; + const parent = await commit(); + await writeFile(path.join(directory, "quixos.lock"), lock(resources)); + await writeFile(path.join(directory, "marker"), name); + await git("add", "."); + const rev = await commit(); + await git("tag", `quixos-reachability/${rev}`); + const bundle = path.join(temporary, `${name}.bundle`); + await git("bundle", "create", bundle, `refs/tags/quixos-reachability/${rev}`); + const source = { repository: `https://unavailable.test/${name}.git`, commit: rev }; + locations[source.repository] = pathToFileURL(bundle).href; + return { ...source, parent }; + }; + const common = await repository("common", ""); + const dependency = `interface Common source { repository "${common.repository}"; commit "${common.commit}"; }`; + const left = await repository("left", dependency), + right = await repository("right", dependency); + const root = await repository( + "root", + ` + package Left source { repository "${left.repository}"; commit "${left.commit}"; } + package Right source { repository "${right.repository}"; commit "${right.commit}"; }`, + ); + const directory = path.join(temporary, "checkout"); + const closure = await materializeWorkspaceSources({ source: root, directory, locations, concurrency: 2 }); + assert.deepEqual( + await materializeWorkspaceSources({ source: root, directory, repositoryPrefix: "https://unavailable.test/" }), + closure, + ); + // Allow this root but not its dependencies: reject them before Git acquisition. + const scopedRoot = { ...root, repository: "https://unavailable.test/selected/root.git" }; + await assert.rejects( + materializeWorkspaceSources({ + source: scopedRoot, + directory: path.join(temporary, "scoped"), + locations: { ...locations, [scopedRoot.repository]: locations[root.repository] }, + repositoryPrefix: "https://unavailable.test/selected/", + }), + /outside the acquisition boundary/, + ); + await assert.rejects( + materializeWorkspaceSources({ source: root, directory, repositoryPrefix: "https://unavailable.test/root.git/" }), + /outside the acquisition boundary/, + ); + assert.equal(closure.resources.length, 4, "diamond dependency is materialized once"); + assert.deepEqual(JSON.parse(await readFile(path.join(directory, "sources.json"), "utf8")), closure); + const rootEntry = closure.resources.find((entry) => entry.kind === "workspace")!; + const rootDirectory = path.join(directory, rootEntry.directory); + const rootLock = path.join(rootDirectory, "quixos.lock"); + const rootBody = await readFile(rootLock, "utf8"); + const outsideLock = path.join(temporary, "outside.lock"); + await writeFile(outsideLock, rootBody); + // Exercise the shared loader directly: committed root symlinks must not let + // privileged source inspection read outside the acquired repository. + const { loadQuixosLock } = await import("../src/resource-lock/index.js"); + const linkedLock = path.join(temporary, "linked.lock"); + await symlink(outsideLock, linkedLock); + await assert.rejects(loadQuixosLock(linkedLock), /root locks must be ordinary/); + const backup = path.join(temporary, "retained"); + const manifest = await backupWorkspaceSources({ source: root, directory, output: backup }); + assert.equal(manifest.repositories.length, 4); + const restored = await restoreWorkspaceSources({ backup, directory: path.join(temporary, "restored") }); + assert.deepEqual(restored, closure, "Source-only recovery retains canonical identity without an available forge"); + await assert.rejects(backupWorkspaceSources({ source: root, directory, output: backup }), /must not exist/); + const missing = { + ...manifest, + repositories: manifest.repositories.filter((entry) => entry.repository !== common.repository), + }; + await writeFile(path.join(backup, "backup.json"), JSON.stringify(missing)); + await assert.rejects( + restoreWorkspaceSources({ backup, directory: path.join(temporary, "incomplete") }), + /omits required repository/, + ); + await writeFile(path.join(backup, "backup.json"), JSON.stringify(manifest)); + await writeFile(path.join(backup, manifest.repositories[0].bundle), "damaged backup"); + await assert.rejects( + restoreWorkspaceSources({ backup, directory: path.join(temporary, "damaged") }), + /integrity mismatch/, + ); + assert.equal((await exec("git", ["rev-parse", "HEAD^"], { cwd: rootDirectory })).stdout.trim(), root.parent); + assert.equal( + (await exec("git", ["remote", "get-url", "origin"], { cwd: rootDirectory })).stdout.trim(), + root.repository, + ); + // Cached recovery does not need the primary or the backup. + assert.deepEqual(await materializeWorkspaceSources({ source: root, directory }), closure); + const pinFile = path.join(temporary, "source.json"); + await writeFile(pinFile, JSON.stringify(root)); + const cli = path.resolve(import.meta.dirname, "../src/capability-language/tool-cli.js"); + const command = await exec(process.execPath, [cli, "source-checkout", pinFile, "--dir", directory]); + assert.deepEqual(JSON.parse(command.stdout), closure, "Protocol CLI preserves exact cached source identity"); + const scoped = await exec(process.execPath, [ + cli, + "source-checkout", + pinFile, + "--dir", + directory, + "--repository-prefix", + "https://unavailable.test/", + ]); + assert.deepEqual(JSON.parse(scoped.stdout), closure); + const cliBackup = path.join(temporary, "cli-backup"); + const backupCommand = await exec(process.execPath, [ + cli, + "source-backup", + pinFile, + "--dir", + directory, + "--output", + cliBackup, + ]); + assert.equal(JSON.parse(backupCommand.stdout).repositories.length, 4); + const restoreCommand = await exec(process.execPath, [ + cli, + "source-restore", + cliBackup, + "--dir", + path.join(temporary, "cli-restore"), + ]); + assert.deepEqual(JSON.parse(restoreCommand.stdout), closure); + const developmentCommand = await exec(process.execPath, [ + cli, + "source-develop", + pinFile, + "--dir", + path.join(temporary, "cli-developer"), + "--cache", + directory, + ]); + const developmentResult = JSON.parse(developmentCommand.stdout); + assert.equal(developmentResult.repositories, 4); + assert.deepEqual(developmentResult.source, closure.root); + const a = await prepareDevelopmentSources({ + source: root, + cacheDirectory: directory, + directory: path.join(temporary, "a"), + }); + const b = await prepareDevelopmentSources({ + source: root, + cacheDirectory: directory, + directory: path.join(temporary, "b"), + }); + assert.notEqual(a.workspaceId, b.workspaceId); + const aRoot = path.join(a.directory, "root"), + bRoot = path.join(b.directory, "root"); + assert.equal((await exec("git", ["remote", "get-url", "origin"], { cwd: aRoot })).stdout.trim(), root.repository); + assert.equal( + await readFile(path.join(aRoot, "quixos.lock"), "utf8"), + await readFile(path.join(rootDirectory, "quixos.lock"), "utf8"), + ); + await exec("git", ["cat-file", "-e", root.parent], { cwd: aRoot }); + await writeFile(path.join(aRoot, "marker"), "Independent developer change"); + assert.equal(await readFile(path.join(bRoot, "marker"), "utf8"), "root"); + assert.equal(await readFile(path.join(rootDirectory, "marker"), "utf8"), "root"); + await assert.rejects( + prepareDevelopmentSources({ source: root, cacheDirectory: directory, directory: a.directory }), + /never overwritten/, + ); + await writeFile(path.join(rootDirectory, "marker"), "edited"); + await assert.rejects(materializeWorkspaceSources({ source: root, directory, locations }), /modified/); + await assert.rejects( + materializeWorkspaceSources({ + source: root, + directory: path.join(temporary, "bounded"), + locations, + maxRepositories: 2, + }), + /repository limit/, + ); + await assert.rejects( + materializeWorkspaceSources({ + source: root, + directory, + locations: { [root.repository]: "https://secret:password@example.test/root.git" }, + }), + /transport substitution/, + ); +}); +test("Workspace pins reject floating revisions and embedded credentials", () => { + assert.throws(() => workspaceSourcePin({ repository: "https://example.test/root.git", commit: "main" })); + assert.throws(() => + workspaceSourcePin({ repository: "https://user:password@example.test/root.git", commit: "a".repeat(40) }), + ); +}); diff --git a/test/source-locks.test.ts b/test/source-locks.test.ts new file mode 100644 index 0000000..d4da479 --- /dev/null +++ b/test/source-locks.test.ts @@ -0,0 +1,46 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { mkdtemp, writeFile, readFile, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { workspaceSourceLocks } from "../src/capability-language/source-locks.js"; + +test("fork lock proposals follow declared fragments and ignore comments and unrelated lock files", async (context) => { + const directory = await mkdtemp(path.join(os.tmpdir(), "qx-lock-fork-")); + context.after(() => rm(directory, { recursive: true, force: true })); + const from = { repository: "https://source.test/app/runtime.git", commit: "b".repeat(40) }; + const to = { repository: "https://source.test/fork/runtime.git", commit: "c".repeat(40) }; + const root = `quixos-lock version 1 { + quixos source { repository "https://source.test/quixos.git"; commit "${"a".repeat(40)}"; } + import "resources.lock"; + // package Fake source { repository "https://unrelated.test/secret.git"; commit "${"d".repeat(40)}"; } + }`; + const fragment = `quixos-lock fragment version 1 { + // Preserve this note: 雨 📚 + package Runtime source { repository "${from.repository}"; commit "${from.commit}"; } + }`; + await writeFile(path.join(directory, "quixos.lock"), root); + await writeFile(path.join(directory, "resources.lock"), fragment); + await writeFile(path.join(directory, "unrelated.lock"), "This is not a Quixos lock."); + const result = await workspaceSourceLocks(directory, [{ from, to }]); + assert.equal(result.lock.resources.length, 1); + assert.deepEqual(result.lock.resources[0].source, { resolver: "git", ...to }); + assert.equal(result.files.find((file) => file.path === "quixos.lock")!.source, root); + assert.match(result.files.find((file) => file.path === "resources.lock")!.source, /Preserve this note: 雨 📚/); + assert.deepEqual(result.files.map((file) => file.path).sort(), ["quixos.lock", "resources.lock"]); + assert.equal( + await readFile(path.join(directory, "resources.lock"), "utf8"), + fragment, + "Proposing a fork does not edit selected source", + ); + await assert.rejects( + workspaceSourceLocks(directory, [ + { from, to }, + { from, to }, + ]), + /Duplicate/, + ); + await assert.rejects(workspaceSourceLocks(directory, [{ from, to: { ...to, commit: "main" } }]), /exact commit/); + await writeFile(path.join(directory, "quixos.lock"), root.replace('"resources.lock"', '"../outside.lock"')); + await assert.rejects(workspaceSourceLocks(directory), /Invalid source lock/); +});