From bde638e99a5d7e04b16c874186e8822d140b35b6 Mon Sep 17 00:00:00 2001 From: mm Date: Mon, 21 Sep 2026 17:09:20 -0400 Subject: [PATCH] Add organization package registry commands to Rust and Bun --- cli/README.md | 74 +- cli/bun/src/cli.ts | 6 +- cli/bun/src/core/args.ts | 8 + cli/bun/src/core/package-args.ts | 40 + cli/bun/src/core/package-files.ts | 162 ++ cli/bun/src/core/package-format.ts | 113 ++ cli/bun/src/core/package-registry.ts | 93 ++ cli/bun/src/core/service-account.ts | 66 +- cli/bun/src/core/types.ts | 3 +- cli/bun/test/package-registry.test.ts | 131 ++ cli/ci/check_architecture.py | 7 + cli/ci/run_local.py | 6 + cli/ci/test_run_local.py | 9 + .../cases/fixtures/runner-help.txt | 18 +- cli/conformance/runner/README.md | 35 + cli/conformance/runner/registry_service.py | 87 ++ cli/protocol/OWNERSHIP.md | 10 + cli/protocol/decisions/imp034-registry-cli.md | 82 + cli/rust/crates/prose-cli/tests/registry.rs | 238 +++ cli/rust/crates/prose-runner-core/Cargo.toml | 2 +- .../prose-runner-core/src/invocation.rs | 13 +- cli/rust/crates/prose-runner-core/src/lib.rs | 1 + .../crates/prose-runner-core/src/registry.rs | 1375 +++++++++++++++++ .../crates/prose-runner-core/src/runner.rs | 2 +- .../prose-runner-core/src/service_account.rs | 215 ++- cli/shared/fixtures/registry/FORMAT.md | 80 + .../registry/directory.canonical.json | 1 + cli/shared/fixtures/registry/directory.json | 1 + .../fixtures/registry/directory.receipt.json | 23 + .../fixtures/registry/hash-vectors.json | 33 + cli/shared/fixtures/registry/invalid.json | 1 + .../registry/single-file.canonical.json | 1 + cli/shared/fixtures/registry/single-file.json | 1 + .../registry/single-file.receipt.json | 18 + cli/shared/schemas/README.md | 15 + .../schemas/package-operation.schema.json | 279 ++++ cli/shared/tests/test_contracts.py | 14 + 37 files changed, 3241 insertions(+), 22 deletions(-) create mode 100644 cli/bun/src/core/package-args.ts create mode 100644 cli/bun/src/core/package-files.ts create mode 100644 cli/bun/src/core/package-format.ts create mode 100644 cli/bun/src/core/package-registry.ts create mode 100644 cli/bun/test/package-registry.test.ts create mode 100644 cli/conformance/runner/registry_service.py create mode 100644 cli/protocol/decisions/imp034-registry-cli.md create mode 100644 cli/rust/crates/prose-cli/tests/registry.rs create mode 100644 cli/rust/crates/prose-runner-core/src/registry.rs create mode 100644 cli/shared/fixtures/registry/FORMAT.md create mode 100644 cli/shared/fixtures/registry/directory.canonical.json create mode 100644 cli/shared/fixtures/registry/directory.json create mode 100644 cli/shared/fixtures/registry/directory.receipt.json create mode 100644 cli/shared/fixtures/registry/hash-vectors.json create mode 100644 cli/shared/fixtures/registry/invalid.json create mode 100644 cli/shared/fixtures/registry/single-file.canonical.json create mode 100644 cli/shared/fixtures/registry/single-file.json create mode 100644 cli/shared/fixtures/registry/single-file.receipt.json create mode 100644 cli/shared/schemas/package-operation.schema.json diff --git a/cli/README.md b/cli/README.md index 66fd769..5747249 100644 --- a/cli/README.md +++ b/cli/README.md @@ -29,7 +29,79 @@ Release operators use the [functional-alpha readiness contract](release/ALPHA_READINESS.md) to distinguish candidate, promotion, and post-publication authority. -For the candidate service-account connection, see [Connect the CLI to staging](../docs/staging-account.md). This does not enable registry publishing or hosted execution. +For the candidate service-account connection, see [Connect the CLI to staging](../docs/staging-account.md). The candidate registry commands below reuse that account connection; they do not enable hosted execution. Source implementation and hermetic fixtures do not establish deployment, release availability, or live qualification. + +## Registry package commands (candidate) + +Package commands move bounded, data-only files through the selected OpenProse +account service. Production is the default. Select staging persistently when +working with its separate credentials, then inspect the selection: + +```sh +prose cli environment use staging +prose cli environment show +prose cli auth login +prose cli package publish ./hello.md --organization example --name hello --version 1.0.0 +prose cli package list example --json +prose cli package fetch example/hello@1.0.0 --output-dir ./hello-copy +prose cli package withdraw example/hello@1.0.0 +prose cli environment reset +``` + +A single file uses its basename as the default export and has no dependencies. +Publishing is private unless `--public` is explicit. `list` returns one page of +public receipts, so the private `hello` version above will not appear there. +It returns an optional `nextCursor`; pass that exact value back with +`--cursor`. The cursor is opaque, ASCII, and at most 210 characters. `withdraw` +removes discovery only; an authorized pinned fetch remains available. Versions +are exact SemVer strings, including build metadata. Existing versions cannot be +overwritten, and the client never retries a publication automatically. + +For a directory, create `prose-package.json` with precisely the files to include: + +```json +{ + "schema": "prose-package-directory-v1", + "files": ["README.md", "docs/guide.md"], + "exports": {"default": "README.md", "guide": "docs/guide.md"}, + "dependencies": {} +} +``` + +Then publish that directory with the same identity flags. The CLI reads only the +manifest and listed regular files; it never scans or uploads unlisted files. +Duplicate manifest keys, symlinks and symlink ancestors, unsafe or sensitive +included paths, and invalid metadata are rejected. Limits are 128 files, +256 KiB per file, 1 MiB total decoded content, 64 exports, 64 dependencies, and +2 MiB per service payload. Dependencies are exact hash-pinned metadata and are +neither downloaded nor executed. File contents are not scanned for secrets. +See the [package byte format](shared/fixtures/registry/FORMAT.md) for exact path, +encoding, hash, and receipt rules. + +Fetch obtains a receipt and canonical artifact from the same selected service, +then verifies artifact and file hashes, manifest, inventory, and canonical bytes +before writing. Supply `--sha256 <64-lowercase-hex-digest>` to require a known +artifact identity. The output directory must be fresh, its parent must exist, +and its ancestors must not be symlinks. Existing destinations are never replaced. +The receipt is written as `.prose-package-receipt.json`. + +Rust installs with a no-replace directory operation on supported platforms. Bun +reserves the destination exclusively and writes verified files, then the receipt; +that directory is visible while being populated. A crash can leave an incomplete +directory without a receipt. There is no implicit resume or overwrite: choose a +fresh destination, or inspect and remove the incomplete directory yourself. +Cleanup preserves content whose ownership no longer matches the operation. +These checks do not sandbox another process running with the same filesystem +privileges. Native platform qualification remains separate from fixture results. + +All four commands accept trailing `--json` or global `--output json`. Reports +include the selected environment; staging human output is labeled. Use global +`--service-environment production|staging` before `cli` for a one-command override. +Only user configuration can persist the selection. Production and staging use +separate OS credentials and `OPENPROSE_API_KEY` / `OPENPROSE_STAGING_API_KEY` +respectively; switching never copies credentials. Public reads can be anonymous +when no credential is available, but malformed selected credentials fail closed. +Publish and withdraw require a credential. No command starts a model or harness. ## First five minutes diff --git a/cli/bun/src/cli.ts b/cli/bun/src/cli.ts index be6211a..247510c 100644 --- a/cli/bun/src/cli.ts +++ b/cli/bun/src/cli.ts @@ -1,3 +1,4 @@ +import { runPackageCommand } from "./core/package-registry"; import { runServiceAccount } from "./core/service-account"; import { runWeaveHost, writeHostBytes, stopHostOutput } from "./core/weave-host"; import { PUBLISHED_KERNEL_STARTUP } from "./core/build"; @@ -91,9 +92,9 @@ export async function runCli(args: readonly string[], dependencies: CliDependenc const parsed = parseEntrypoint(args); if (parsed.global.serviceEnvironment !== undefined) { mode = parsed.kind === "operation" && parsed.json ? "json" : parsed.global.output ?? "human"; - if (parsed.kind !== "operation" || !["auth-status", "auth-login", "auth-logout", "org-list"].includes(parsed.operation) || Object.keys(parsed.global).some((key) => !["serviceEnvironment", "output", "color", "verbose"].includes(key))) throw failure("INVOCATION_INVALID"); + if (parsed.kind !== "operation" || !["auth-status", "auth-login", "auth-logout", "org-list", "package"].includes(parsed.operation) || Object.keys(parsed.global).some((key) => !["serviceEnvironment", "output", "color", "verbose"].includes(key))) throw failure("INVOCATION_INVALID"); } - if (parsed.kind === "operation" && (["auth-status", "auth-login", "auth-logout", "org-list"].includes(parsed.operation) || parsed.operation.startsWith("environment-"))) { + if (parsed.kind === "operation" && (["auth-status", "auth-login", "auth-logout", "org-list", "package"].includes(parsed.operation) || parsed.operation.startsWith("environment-"))) { mode = parsed.json ? "json" : parsed.global.output ?? "human"; if (Object.keys(parsed.global).some((key) => !["serviceEnvironment", "output", "color", "verbose"].includes(key))) throw failure("INVOCATION_INVALID"); let selected = await resolveServiceEnvironment(dependencies); @@ -106,6 +107,7 @@ export async function runCli(args: readonly string[], dependencies: CliDependenc dependencies.writeStdout(mode === "human" ? `OpenProse ${selected.environment} environment (${selected.source})\n` : jsonLine(report)); return 0; } + if (parsed.operation === "package") return await runPackageCommand(parsed.packageCommand!, mode, dependencies, parsed.global.serviceEnvironment ?? selected.environment); return await runServiceAccount(parsed.operation, mode, dependencies, parsed.global.serviceEnvironment ?? selected.environment); } if (parsed.kind === "weave") return await runWeaveHost(parsed.argv, parsed.global, dependencies); diff --git a/cli/bun/src/core/args.ts b/cli/bun/src/core/args.ts index 2ea5908..9717899 100644 --- a/cli/bun/src/core/args.ts +++ b/cli/bun/src/core/args.ts @@ -1,3 +1,4 @@ +import { parsePackageCommand } from "./package-args"; import { invocationFailure } from "./errors"; import type { GlobalFlags, OutputMode, ParsedEntrypoint } from "./types"; @@ -126,6 +127,7 @@ function parseOperation(global: GlobalFlags, args: readonly string[]): ParsedEnt withoutJson.pop(); json = true; } + if (withoutJson[0] === "package") return { kind: "operation", global, operation: "package", json, packageCommand: parsePackageCommand(withoutJson.slice(1)) }; const key = withoutJson.join(" "); if (withoutJson.length === 3 && withoutJson[0] === "cleanup" && withoutJson[1] === "prime") { if (json) invalid("Prime cleanup uses the global `--output json` option before `cli`."); @@ -195,6 +197,11 @@ function knownRunnerHelpPath(args: readonly string[]): boolean { "environment reset --help", "environment use staging --help", "environment use production --help", + "package --help", + "package publish --help", + "package fetch --help", + "package list --help", + "package withdraw --help", "org --help", "org list --help", "auth --help", @@ -205,6 +212,7 @@ function knownRunnerHelpPath(args: readonly string[]): boolean { return args.length === 4 && ( (args[0] === "harness" && args[1] === "use") + || (args[0] === "package" && ["publish", "fetch", "list", "withdraw"].includes(args[1]!)) || (args[0] === "cleanup" && args[1] === "prime") ) && args[2]!.length > 0 diff --git a/cli/bun/src/core/package-args.ts b/cli/bun/src/core/package-args.ts new file mode 100644 index 0000000..07bd7b7 --- /dev/null +++ b/cli/bun/src/core/package-args.ts @@ -0,0 +1,40 @@ +import { invocationFailure } from "./errors"; +export interface PackageCommand { + operation: "publish" | "fetch" | "list" | "withdraw"; + input: string; + organization?: string; + name?: string; + version?: string; + public?: boolean; + outputDir?: string; + sha256?: string; + cursor?: string; +} +export function parsePackageCommand(args: readonly string[]): PackageCommand { + const invalid = (): never => { throw invocationFailure("Invalid package command or options."); }; + const operation = args[0]; + if (operation !== "publish" && operation !== "fetch" && operation !== "list" && operation !== "withdraw") return invalid(); + const input = args[1]; + if (input === undefined || input.length === 0 || input.startsWith("--")) return invalid(); + const command: PackageCommand = { operation, input }; + const allowed = operation === "publish" ? ["organization", "name", "version", "public"] : operation === "fetch" ? ["output-dir", "sha256"] : operation === "list" ? ["cursor"] : []; + const seen = new Set(); + for (let index = 2; index < args.length; index++) { + const raw = args[index]!; + const equals = raw.indexOf("="); + const option = (equals < 0 ? raw : raw.slice(0, equals)).slice(2); + if (!raw.startsWith("--") || !allowed.includes(option) || seen.has(option)) return invalid(); + seen.add(option); + if (option === "public") { + if (equals >= 0) return invalid(); + command.public = true; + continue; + } + const value = equals < 0 ? args[++index] : raw.slice(equals + 1); + if (value === undefined || value.length === 0 || value.startsWith("--")) return invalid(); + Object.assign(command, { [option === "output-dir" ? "outputDir" : option]: value }); + } + if (operation === "publish" && (!command.organization || !command.name || !command.version)) return invalid(); + if (operation === "fetch" && !command.outputDir) return invalid(); + return command; +} diff --git a/cli/bun/src/core/package-files.ts b/cli/bun/src/core/package-files.ts new file mode 100644 index 0000000..fe09dae --- /dev/null +++ b/cli/bun/src/core/package-files.ts @@ -0,0 +1,162 @@ +import { constants } from "node:fs"; +import { lstat, mkdir, mkdtemp, open, rmdir, unlink, type FileHandle } from "node:fs/promises"; +import { basename, dirname, join, parse, resolve } from "node:path"; +import { closed, PACKAGE_LIMITS, packagePath, parsePackageJSON, preparePackage, canonicalPackageJSON, type PreparedPackage, type PackageReceipt, invalidPackage } from "./package-format"; +import type { PackageCommand } from "./package-args"; + +async function safeAncestors(path: string): Promise { + const absolute = resolve(path), root = parse(absolute).root; + let current = root; + for (const segment of absolute.slice(root.length).split("/").filter(Boolean)) { + current = join(current, segment); + const info = await lstat(current); + if (!info.isDirectory() || info.isSymbolicLink()) invalidPackage(); + } +} +async function boundedRegularFile(path: string, limit: number): Promise { + await safeAncestors(dirname(path)); + const before = await lstat(path); + if (!before.isFile() || before.isSymbolicLink() || before.size > limit) return invalidPackage(); + let handle: FileHandle | undefined; + try { + handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW); + const opened = await handle.stat(); + if (!opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino || opened.size > limit) return invalidPackage(); + const buffer = Buffer.alloc(limit + 1); + let offset = 0; + while (offset < buffer.length) { + const read = await handle.read(buffer, offset, buffer.length - offset, offset); + if (!read.bytesRead) break; + offset += read.bytesRead; + } + const after = await handle.stat(); + if (offset > limit || after.size !== opened.size || after.mtimeMs !== opened.mtimeMs || after.ctimeMs !== opened.ctimeMs) return invalidPackage(); + await safeAncestors(dirname(path)); + return buffer.subarray(0, offset); + } finally { await handle?.close(); } +} +// Directory manifests are authored locally: unlike network JSON, duplicate +// member names must never silently replace the user's explicit file selection. +export function parseDirectoryManifest(bytes: Uint8Array): unknown { + const parsed = parsePackageJSON(bytes); + const source = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + let index = 0; + const whitespace = () => { while (/\s/u.test(source[index] ?? "") && index < source.length) index++; }; + const string = (): string => { + const start = index++; + while (index < source.length) { + const character = source[index++]; + if (character === "\\") index++; + else if (character === '\"') return JSON.parse(source.slice(start, index)) as string; + } + return invalidPackage(); + }; + const value = (depth: number): void => { + if (depth > 128) invalidPackage(); + whitespace(); + if (source[index] === '\"') { string(); return; } + if (source[index] === "{") { + index++; whitespace(); + const names = new Set(); + if (source[index] === "}") { index++; return; } + for (;;) { + whitespace(); + const name = string(); + if (names.has(name)) invalidPackage(); + names.add(name); whitespace(); index++; + value(depth + 1); whitespace(); + if (source[index++] === "}") return; + } + } + if (source[index] === "[") { + index++; whitespace(); + if (source[index] === "]") { index++; return; } + for (;;) { value(depth + 1); whitespace(); if (source[index++] === "]") return; } + } + while (index < source.length && !/[\s,}\]]/u.test(source[index]!)) index++; + }; + value(0); + return parsed; +} +export async function prepareSource(command: PackageCommand, cwd: string): Promise { + const source = resolve(cwd, command.input); + await safeAncestors(dirname(source)); + const info = await lstat(source); + if (info.isSymbolicLink()) return invalidPackage(); + let paths: string[], exports: unknown, dependencies: unknown; + if (info.isFile()) { + paths = [packagePath(basename(source))]; exports = { default: paths[0] }; dependencies = {}; + } else if (info.isDirectory()) { + const manifest = closed(parseDirectoryManifest(await boundedRegularFile(join(source, "prose-package.json"), PACKAGE_LIMITS.request)), ["schema", "files", "exports", "dependencies"]); + if (manifest.schema !== "prose-package-directory-v1" || !Array.isArray(manifest.files) || !manifest.files.length || manifest.files.length > PACKAGE_LIMITS.files) return invalidPackage(); + paths = manifest.files.map(packagePath); exports = manifest.exports; dependencies = manifest.dependencies; + } else return invalidPackage(); + const files = []; + let total = 0; + for (const path of paths) { + const bytes = await boundedRegularFile(info.isFile() ? source : join(source, path), PACKAGE_LIMITS.file); + total += bytes.length; + if (total > PACKAGE_LIMITS.total) return invalidPackage(); + files.push({ path, encoding: "base64", content: Buffer.from(bytes).toString("base64") }); + } + return preparePackage({ schema: "prose-package-v1", manifest: { organization: command.organization, package: command.name, version: command.version, visibility: command.public ? "public" : "private", exports, dependencies }, files }); +} +interface Owned { path: string; dev: number; ino: number; directory: boolean } +async function identity(path: string, directory: boolean): Promise { + const info = await lstat(path); + if (info.isSymbolicLink() || (directory ? !info.isDirectory() : !info.isFile())) return invalidPackage(); + return { path, dev: info.dev, ino: info.ino, directory }; +} +async function stillOwned(owned: Owned): Promise { + try { const info = await lstat(owned.path); return info.dev === owned.dev && info.ino === owned.ino && !info.isSymbolicLink(); } catch { return false; } +} +async function cleanupOwned(entries: Owned[]): Promise { + for (const entry of [...entries].reverse()) { + const ancestors = entries.filter(other => other.directory && entry.path.startsWith(`${other.path}/`)); + if (!(await stillOwned(entry)) || (await Promise.all(ancestors.map(stillOwned))).some(owned => !owned)) continue; + try { if (entry.directory) await rmdir(entry.path); else await unlink(entry.path); } catch { /* Preserve concurrent additions and substitutions. */ } + } +} +export async function materializePackage(prepared: PreparedPackage, receipt: PackageReceipt, outputDir: string, cwd: string): Promise { + if (!outputDir || outputDir.includes("\0")) return invalidPackage(); + const destination = resolve(cwd, outputDir), parent = dirname(destination); + await safeAncestors(parent); + try { await lstat(destination); return invalidPackage(); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } + const staging = await mkdtemp(join(parent, ".prose-package-")); + const staged: Owned[] = [await identity(staging, true)]; + const reserved: Owned[] = []; + const paths = [...prepared.files.map(file => ({ path: file.path, bytes: file.bytes })), { path: ".prose-package-receipt.json", bytes: new TextEncoder().encode(`${canonicalPackageJSON(receipt)}\n`) }]; + const writeOwned = async (root: string, entries: Owned[], path: string, bytes: Uint8Array) => { + let directory = root; + const segments = path.split("/"); + for (const part of segments.slice(0, -1)) { + directory = join(directory, part); + const known = entries.find(entry => entry.path === directory); + if (known !== undefined) { if (!(await stillOwned(known))) return invalidPackage(); } + else { await mkdir(directory, { mode: 0o700 }); entries.push(await identity(directory, true)); } + } + // Authenticate each owned ancestor immediately before the exclusive create. + for (const entry of entries.filter(entry => entry.directory)) if (!(await stillOwned(entry))) return invalidPackage(); + await safeAncestors(directory); + const target = join(root, path); + const handle = await open(target, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600); + try { + const info = await handle.stat(); + entries.push({ path: target, dev: info.dev, ino: info.ino, directory: false }); + await handle.writeFile(bytes); + } finally { await handle.close(); } + }; + try { + for (const file of paths) await writeOwned(staging, staged, file.path, file.bytes); + await safeAncestors(parent); + // Exclusive reservation never replaces even an empty destination. Visibility is + // deliberately non-atomic on hosts lacking an exposed NOREPLACE directory rename. + await mkdir(destination, { mode: 0o700 }); + reserved.push(await identity(destination, true)); + for (const file of paths) await writeOwned(destination, reserved, file.path, file.bytes); + for (const entry of reserved) if (!(await stillOwned(entry))) return invalidPackage(); + } catch (error) { + await cleanupOwned(reserved); + throw error; + } finally { await cleanupOwned(staged); } +} diff --git a/cli/bun/src/core/package-format.ts b/cli/bun/src/core/package-format.ts new file mode 100644 index 0000000..ca20c9f --- /dev/null +++ b/cli/bun/src/core/package-format.ts @@ -0,0 +1,113 @@ +import { createHash } from "node:crypto"; +export const PACKAGE_LIMITS = { request: 2 * 1024 * 1024, file: 256 * 1024, total: 1024 * 1024, files: 128 }; +type RecordValue = Record; +export interface PackageReference { organization: string; package: string; version: string; sha256: string } +export interface InventoryEntry { path: string; size: number; sha256: string } +export interface PackageReceipt { schema: "prose-publication-v1"; organizationId: string; reference: PackageReference; visibility: "private" | "public"; inventory: InventoryEntry[] } +export interface PreparedPackage { artifact: unknown; bytes: Uint8Array; reference: PackageReference; inventory: InventoryEntry[]; visibility: "private" | "public"; files: Array<{ path: string; bytes: Uint8Array }> } +export function invalidPackage(): never { throw new Error("Invalid package data"); } +export function record(value: unknown): RecordValue { + if (value === null || typeof value !== "object" || Array.isArray(value)) return invalidPackage(); + return value as RecordValue; +} +export function closed(value: unknown, keys: string[], required = keys): RecordValue { + const object = record(value); + if (Object.keys(object).some(key => !keys.includes(key)) || required.some(key => !Object.hasOwn(object, key))) return invalidPackage(); + return object; +} +export function canonicalPackageJSON(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(canonicalPackageJSON).join(",")}]`; + if (value !== null && typeof value === "object") return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonicalPackageJSON((value as RecordValue)[key])}`).join(",")}}`; + if (value === null || typeof value === "string" || typeof value === "boolean" || (typeof value === "number" && Number.isSafeInteger(value) && value >= 0)) return JSON.stringify(value); + return invalidPackage(); +} +export function packageHash(bytes: Uint8Array): string { return createHash("sha256").update(bytes).digest("hex"); } +export function identity(value: unknown): string { + if (typeof value !== "string" || /\s/u.test(value) || !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/u.test(value)) return invalidPackage(); + return value; +} +export function version(value: unknown): string { + if (typeof value !== "string" || value.length > 128 || /\s/u.test(value) || !/^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/u.test(value)) return invalidPackage(); + const pre = value.split("+")[0]!.split("-").slice(1).join("-"); + if (pre.split(".").some(part => /^0[0-9]+$/u.test(part))) return invalidPackage(); + return value; +} +export function digest(value: unknown): string { + if (typeof value !== "string" || value.length !== 64 || !/^[0-9a-f]{64}$/u.test(value)) return invalidPackage(); + return value; +} +export function packagePath(value: unknown): string { + if (typeof value !== "string" || value.length > 240 || /\s/u.test(value) || !/^[A-Za-z0-9_][A-Za-z0-9._/-]*$/u.test(value)) return invalidPackage(); + if (value.split("/").some(part => !part || part === "." || part === ".." || part.startsWith(".") || part.endsWith(".") || /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/iu.test(part) || /^(node_modules|credentials|secrets?)(?:\.|$)/iu.test(part) || /\.(pem|key|p12|pfx)$/iu.test(part))) return invalidPackage(); + return value; +} +export function reference(value: unknown): PackageReference { + const ref = closed(value, ["organization", "package", "version", "sha256"]); + return { organization: identity(ref.organization), package: identity(ref.package), version: version(ref.version), sha256: digest(ref.sha256) }; +} +function noCollisions(paths: string[]): void { + const folded = paths.map(path => path.toLowerCase()); + if (new Set(folded).size !== paths.length || folded.some(path => folded.some(other => other.startsWith(`${path}/`)))) invalidPackage(); +} +export function parsePackageJSON(bytes: Uint8Array): unknown { + if (bytes.length > PACKAGE_LIMITS.request) return invalidPackage(); + return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)); +} +export function preparePackage(input: unknown): PreparedPackage { + const request = closed(input, ["schema", "manifest", "files"]); + if (request.schema !== "prose-package-v1" || Buffer.byteLength(canonicalPackageJSON(input)) > PACKAGE_LIMITS.request) return invalidPackage(); + const manifest = closed(request.manifest, ["organization", "package", "version", "visibility", "exports", "dependencies"], ["organization", "package", "version", "exports", "dependencies"]); + const organization = identity(manifest.organization), name = identity(manifest.package), release = version(manifest.version); + const visibility = Object.hasOwn(manifest, "visibility") ? manifest.visibility : "private"; + if (visibility !== "public" && visibility !== "private") return invalidPackage(); + const exports = record(manifest.exports), dependencies = record(manifest.dependencies); + if (!Object.keys(exports).length || Object.keys(exports).length > 64 || Object.keys(dependencies).length > 64) return invalidPackage(); + for (const [alias, ref] of Object.entries(dependencies)) { identity(alias); reference(ref); } + if (!Array.isArray(request.files) || !request.files.length || request.files.length > PACKAGE_LIMITS.files) return invalidPackage(); + let total = 0; + const files = request.files.map(raw => { + const file = closed(raw, ["path", "encoding", "content"]), path = packagePath(file.path); + if (typeof file.content !== "string" || file.content.length > PACKAGE_LIMITS.request) return invalidPackage(); + let bytes: Uint8Array; + if (file.encoding === "utf8") { + if (/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? PACKAGE_LIMITS.file || total > PACKAGE_LIMITS.total) return invalidPackage(); + return { path, bytes }; + }).sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0); + noCollisions(files.map(file => file.path)); + for (const [name, path] of Object.entries(exports)) { + if (/\s/u.test(name) || !/^[A-Za-z][A-Za-z0-9_-]{0,63}$/u.test(name) || ["__proto__", "prototype", "constructor"].includes(name)) return invalidPackage(); + packagePath(path); + if (!files.some(file => file.path === path)) return invalidPackage(); + } + const artifact = { schema: "prose-package-v1", manifest: { ...manifest, visibility }, files: files.map(file => ({ path: file.path, encoding: "base64", content: Buffer.from(file.bytes).toString("base64") })) }; + const bytes = new TextEncoder().encode(`${canonicalPackageJSON(artifact)}\n`); + if (bytes.length > PACKAGE_LIMITS.request) return invalidPackage(); + return { artifact, bytes, reference: { organization, package: name, version: release, sha256: packageHash(bytes) }, visibility, files, inventory: files.map(file => ({ path: file.path, size: file.bytes.length, sha256: packageHash(file.bytes) })) }; +} +export function receipt(value: unknown): PackageReceipt { + const result = closed(value, ["schema", "organizationId", "reference", "visibility", "inventory"]); + if (result.schema !== "prose-publication-v1" || typeof result.organizationId !== "string" || result.organizationId.length !== 36 || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u.test(result.organizationId) || (result.visibility !== "public" && result.visibility !== "private")) return invalidPackage(); + const ref = reference(result.reference); + if (!Array.isArray(result.inventory) || !result.inventory.length || result.inventory.length > PACKAGE_LIMITS.files) return invalidPackage(); + let total = 0, previous = ""; + const inventory = result.inventory.map(raw => { + const entry = closed(raw, ["path", "size", "sha256"]), path = packagePath(entry.path); + if (path <= previous || typeof entry.size !== "number" || !Number.isSafeInteger(entry.size) || entry.size < 0 || entry.size > PACKAGE_LIMITS.file) return invalidPackage(); + total += entry.size; previous = path; + if (total > PACKAGE_LIMITS.total) return invalidPackage(); + return { path, size: entry.size, sha256: digest(entry.sha256) }; + }); + noCollisions(inventory.map(entry => entry.path)); + return { schema: "prose-publication-v1", organizationId: result.organizationId, reference: ref, visibility: result.visibility, inventory }; +} +export function matchReceipt(value: PackageReceipt, prepared: PreparedPackage): void { + if (canonicalPackageJSON(value.reference) !== canonicalPackageJSON(prepared.reference) || value.visibility !== prepared.visibility || canonicalPackageJSON(value.inventory) !== canonicalPackageJSON(prepared.inventory)) invalidPackage(); +} diff --git a/cli/bun/src/core/package-registry.ts b/cli/bun/src/core/package-registry.ts new file mode 100644 index 0000000..515c7fb --- /dev/null +++ b/cli/bun/src/core/package-registry.ts @@ -0,0 +1,93 @@ +import { failure } from "./errors"; +import { RunnerFailure, type OutputMode } from "./types"; +import { jsonLine, humanSafeScalar } from "./output"; +import { Service, fixtureFor, type Dependencies } from "./service-account"; +import type { PackageCommand } from "./package-args"; +import { canonicalPackageJSON, closed, digest, identity, invalidPackage, matchReceipt, packageHash, parsePackageJSON, preparePackage, receipt, version, type PackageReceipt } from "./package-format"; +import { materializePackage, prepareSource } from "./package-files"; +function referenceInput(input: string): { organization: string; package: string; version: string } { + const match = /^([^/]+)\/([^/@]+)@([^/]+)$/u.exec(input); + if (match === null) return invalidPackage(); + return { organization: identity(match[1]), package: identity(match[2]), version: version(match[3]) }; +} +function cursor(input: unknown): string { + if (typeof input !== "string" || !input.length || input.length > 210 || !/^public:[a-z0-9-]+:[0-9A-Za-z.+-]+$/u.test(input)) return invalidPackage(); + return input; +} +function checkStatus(status: number, allowed = [200]): void { + if (status === 401 || status === 403) throw failure("SERVICE_AUTH_REQUIRED"); + if (status === 409) throw failure("SERVICE_PROTOCOL_INVALID"); + if (!allowed.includes(status)) throw failure("SERVICE_UNAVAILABLE"); +} +function matchesIdentity(value: PackageReceipt, expected: { organization: string; package: string; version: string }): void { + if (value.reference.organization !== expected.organization || value.reference.package !== expected.package || value.reference.version !== expected.version) invalidPackage(); +} +export async function runPackageCommand(command: PackageCommand, mode: OutputMode, deps: Dependencies & { processCwd: string }, environment: "production" | "staging"): Promise { + const report: { schema: string; environment: string; operation: string; result: unknown; problem: unknown } = { schema: "openprose.package-operation/1", environment, operation: command.operation, result: null, problem: null }; + if (mode === "human" && environment === "staging") deps.writeStderr("OpenProse staging environment\n"); + let exitCode = 0, remote = false; + let selectedCredential: string | undefined; + try { + let prepared: ReturnType | undefined; + let expected: ReturnType | undefined; + let organization: string; + if (command.operation === "publish") { prepared = await prepareSource(command, deps.processCwd); organization = prepared.reference.organization; } + else if (command.operation === "list") { organization = identity(command.input); if (command.cursor !== undefined) cursor(command.cursor); } + else { expected = referenceInput(command.input); organization = expected.organization; if (command.sha256 !== undefined) digest(command.sha256); } + const prefix = `/registry/v1/organizations/${organization}/packages`; + const service = new Service(deps, environment, await fixtureFor(deps)); + selectedCredential = await service.registryCredential(command.operation === "publish" || command.operation === "withdraw"); + remote = true; + if (command.operation === "publish") { + const response = await service.registryRequest("POST", `${prefix}/${prepared!.reference.package}/versions`, selectedCredential, prepared!.bytes); + checkStatus(response.status, [200, 201]); + const value = receipt(parsePackageJSON(response.bytes)); matchReceipt(value, prepared!); report.result = value; + } else if (command.operation === "list") { + const response = await service.registryRequest("GET", `${prefix}${command.cursor === undefined ? "" : `?cursor=${encodeURIComponent(command.cursor)}`}`, selectedCredential); + checkStatus(response.status); + const page = closed(parsePackageJSON(response.bytes), ["packages", "nextCursor"]); + if (!Array.isArray(page.packages) || page.packages.length > 25) invalidPackage(); + const packages = (page.packages as unknown[]).map(receipt); + if (packages.some(value => value.reference.organization !== organization || value.visibility !== "public")) invalidPackage(); + if (page.nextCursor !== null) cursor(page.nextCursor); + report.result = { packages, nextCursor: page.nextCursor }; + } else { + const path = `${prefix}/${expected!.package}/versions/${expected!.version}`; + if (command.operation === "withdraw") { + const response = await service.registryRequest("POST", `${path}/withdraw`, selectedCredential); checkStatus(response.status); + const result = closed(parsePackageJSON(response.bytes), ["receipt", "withdrawn"]); + if (result.withdrawn !== true) invalidPackage(); + const value = receipt(result.receipt); matchesIdentity(value, expected!); report.result = { receipt: value, withdrawn: true }; + } else { + const response = await service.registryRequest("GET", path, selectedCredential); checkStatus(response.status); + const value = receipt(parsePackageJSON(response.bytes)); matchesIdentity(value, expected!); + if (command.sha256 !== undefined && command.sha256 !== value.reference.sha256) invalidPackage(); + const artifact = await service.registryRequest("GET", `${path}/artifact`, selectedCredential); checkStatus(artifact.status); + if (packageHash(artifact.bytes) !== value.reference.sha256) invalidPackage(); + const contents = preparePackage(parsePackageJSON(artifact.bytes)); + if (!Buffer.from(contents.bytes).equals(Buffer.from(artifact.bytes))) invalidPackage(); + matchReceipt(value, contents); + if (selectedCredential !== undefined && canonicalPackageJSON(value).includes(selectedCredential)) throw failure("SERVICE_PROTOCOL_INVALID"); + remote = false; + await materializePackage(contents, value, command.outputDir!, deps.processCwd); report.result = value; + } + } + if (selectedCredential !== undefined && canonicalPackageJSON(report.result).includes(selectedCredential)) throw failure("SERVICE_PROTOCOL_INVALID"); + } catch (caught) { + const error = caught instanceof RunnerFailure ? caught : failure(remote ? "SERVICE_PROTOCOL_INVALID" : "CONFIG_INVALID"); + report.result = null; report.problem = error.toJSON(); exitCode = error.exitCode; + } + if (mode !== "human") deps.writeStdout(jsonLine(report)); + else if (report.problem !== null) { + const problem = report.problem as { code: string; message: string; action: string }; + deps.writeStderr(`${problem.code}: ${problem.message}\n${problem.action}\n`); + } else if (command.operation === "list") { + const result = report.result as { packages: PackageReceipt[]; nextCursor: string | null }; + for (const value of result.packages) deps.writeStdout(`${value.reference.organization}/${value.reference.package}@${value.reference.version}\n`); + if (result.nextCursor !== null) deps.writeStdout(`Next cursor: ${humanSafeScalar(result.nextCursor)}\n`); + } else { + const value = command.operation === "withdraw" ? (report.result as { receipt: PackageReceipt }).receipt : report.result as PackageReceipt; + deps.writeStdout(`OpenProse ${environment} package ${command.operation}: ${value.reference.organization}/${value.reference.package}@${value.reference.version}\n`); + } + return exitCode; +} diff --git a/cli/bun/src/core/service-account.ts b/cli/bun/src/core/service-account.ts index af829ec..02df1bb 100644 --- a/cli/bun/src/core/service-account.ts +++ b/cli/bun/src/core/service-account.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { readFile } from "node:fs/promises"; import { TEST_SEAMS_ENABLED } from "./build"; import { failure } from "./errors"; @@ -8,8 +9,8 @@ import { RunnerFailure, type OutputMode, type RunnerOperation } from "./types"; type ServiceEnvironment = "production" | "staging"; const MAX_BYTES = 65_536; type RecordValue = Record; -interface Fixture { environment?: ServiceEnvironment; credentials?: Partial>; credential: string | null; storeAvailable: boolean; exchanges: Array<{method: string; path: string; status: number; body: unknown; origin?: string}>; cancelBeforePoll?: boolean } -interface Dependencies { +interface Fixture { environment?: ServiceEnvironment; credentials?: Partial>; credential: string | null; storeAvailable: boolean; exchanges: Array<{method: string; path: string; status: number; body: unknown; origin?: string; expectedBody?: unknown; expectedSha256?: string}>; cancelBeforePoll?: boolean } +export interface Dependencies { env: Readonly>; cancellationSignal?: AbortSignal; writeStdout(text: string): void; @@ -23,7 +24,7 @@ function text(value: unknown, max = 4096): string { if (typeof value !== "string" || value.length === 0 || Array.from(value).length > max || /[\u0000-\u001f\u007f\ud800-\udfff]/u.test(value)) throw failure("SERVICE_PROTOCOL_INVALID"); return value; } -function token(value: unknown): string { +export function token(value: unknown): string { const result = text(value); if (!/^rr_test_[0-9a-f]{32}$/u.test(result)) throw failure("SERVICE_PROTOCOL_INVALID"); return result; @@ -32,18 +33,18 @@ function integer(value: unknown, low: number, high: number): number { if (typeof value !== "number" || !Number.isInteger(value) || value < low || value > high) throw failure("SERVICE_PROTOCOL_INVALID"); return value; } -async function fixtureFor(deps: Dependencies): Promise { +export async function fixtureFor(deps: Dependencies): Promise { const path = TEST_SEAMS_ENABLED ? deps.env.PROSE_TEST_SERVICE_FIXTURE : undefined; if (path === undefined) return undefined; try { const bytes = await readFile(path); - if (bytes.length > 1_048_576) throw new Error(); + if (bytes.length > 16_777_216) throw new Error(); const value = object(JSON.parse(bytes.toString("utf8"))); if (!(value.credentials !== undefined || value.credential === null || typeof value.credential === "string") || typeof value.storeAvailable !== "boolean" || !Array.isArray(value.exchanges) || value.exchanges.length > 182) throw new Error(); return value as unknown as Fixture; } catch { throw failure("SERVICE_PROTOCOL_INVALID"); } } -class Service { +export class Service { elapsed = 0; constructor(readonly deps: Dependencies, readonly environment: ServiceEnvironment, readonly fixture?: Fixture) { if (fixture?.environment !== undefined && fixture.environment !== environment) throw failure("SERVICE_PROTOCOL_INVALID"); @@ -79,6 +80,59 @@ class Service { } finally { if (timer !== undefined) clearTimeout(timer); } } catch { throw failure("CREDENTIAL_STORE_UNAVAILABLE"); } } + async registryCredential(required: boolean): Promise { + const fromEnvironment = this.environmentToken; + if (fromEnvironment !== undefined && fromEnvironment !== "") return token(fromEnvironment); + let stored: string | null; + try { stored = await this.store("get"); } + catch (error) { + if (!required && error instanceof RunnerFailure && error.code === "CREDENTIAL_STORE_UNAVAILABLE") return undefined; + throw error; + } + if (stored !== null) return token(stored); + if (required) throw failure("SERVICE_AUTH_REQUIRED"); + return undefined; + } + async registryRequest(method: string, path: string, credential: string | undefined, body?: Uint8Array): Promise<{ status: number; bytes: Uint8Array }> { + this.checkCancel(); + const maximum = 2 * 1024 * 1024; + if (!path.startsWith("/registry/v1/organizations/") || (body !== undefined && body.length > maximum)) throw failure("SERVICE_PROTOCOL_INVALID"); + try { + if (this.fixture !== undefined) { + const expected = this.environmentToken || (this.fixture.storeAvailable ? this.fixtureCredential : null) || undefined; + if (credential !== expected) throw failure("SERVICE_PROTOCOL_INVALID"); + const exchange = this.fixture.exchanges.shift(); + if (exchange === undefined || exchange.method !== method || exchange.path !== path || (exchange.origin !== undefined && exchange.origin !== this.origin)) throw failure("SERVICE_PROTOCOL_INVALID"); + if (exchange.expectedBody !== undefined && (body === undefined || new TextDecoder().decode(body) !== (typeof exchange.expectedBody === "string" ? exchange.expectedBody : JSON.stringify(exchange.expectedBody)))) throw failure("SERVICE_PROTOCOL_INVALID"); + if (exchange.expectedSha256 !== undefined && (body === undefined || createHash("sha256").update(body).digest("hex") !== exchange.expectedSha256)) throw failure("SERVICE_PROTOCOL_INVALID"); + const bytes = new TextEncoder().encode(typeof exchange.body === "string" ? exchange.body : JSON.stringify(exchange.body)); + if (bytes.length > maximum) throw failure("SERVICE_PROTOCOL_INVALID"); + return { status: integer(exchange.status, 100, 599), bytes }; + } + const signal = this.deps.cancellationSignal === undefined ? AbortSignal.timeout(10_000) : AbortSignal.any([this.deps.cancellationSignal, AbortSignal.timeout(10_000)]); + const response = await fetch(`${this.origin}${path}`, { method, redirect: "error", signal, headers: { Accept: "application/json", ...(credential === undefined ? {} : { Authorization: `Bearer ${credential}` }), ...(body === undefined ? {} : { "Content-Type": "application/json" }) }, ...(body === undefined ? {} : { body: Buffer.from(body) }) }); + if (response.status === 401 || response.status === 403) { await response.body?.cancel(); throw failure("SERVICE_AUTH_REQUIRED"); } + if (response.status >= 500 || response.status === 429) { await response.body?.cancel(); throw failure("SERVICE_UNAVAILABLE"); } + if (response.body === null) throw failure("SERVICE_PROTOCOL_INVALID"); + const chunks: Uint8Array[] = []; + let length = 0; + const reader = response.body.getReader(); + try { + for (;;) { + const chunk = await reader.read(); + if (chunk.done) break; + length += chunk.value.length; + if (length > maximum) throw failure("SERVICE_PROTOCOL_INVALID"); + chunks.push(chunk.value); + } + } finally { await reader.cancel().catch(() => {}); } + return { status: response.status, bytes: Buffer.concat(chunks) }; + } catch (error) { + this.checkCancel(); + if (error instanceof RunnerFailure) throw error; + throw failure("SERVICE_UNAVAILABLE"); + } + } async sleep(seconds: number): Promise { this.checkCancel(); if (this.fixture?.cancelBeforePoll) throw failure("CANCELLED"); diff --git a/cli/bun/src/core/types.ts b/cli/bun/src/core/types.ts index 1a9dd65..299d3f1 100644 --- a/cli/bun/src/core/types.ts +++ b/cli/bun/src/core/types.ts @@ -63,6 +63,7 @@ export interface EffectiveConfiguration { } export type RunnerOperation = + | "package" | "environment-show" | "environment-use" | "environment-reset" @@ -80,7 +81,7 @@ export type ParsedEntrypoint = | { kind: "weave"; global: GlobalFlags; argv: string[] } | { kind: "help"; global: GlobalFlags } | { kind: "version"; global: GlobalFlags } - | { kind: "operation"; global: GlobalFlags; operation: RunnerOperation; json: boolean; value?: string } + | { kind: "operation"; global: GlobalFlags; operation: RunnerOperation; json: boolean; value?: string; packageCommand?: import("./package-args").PackageCommand } | { kind: "language"; global: GlobalFlags; argv: string[] }; export interface TaskEnvelope { diff --git a/cli/bun/test/package-registry.test.ts b/cli/bun/test/package-registry.test.ts new file mode 100644 index 0000000..f20d7a2 --- /dev/null +++ b/cli/bun/test/package-registry.test.ts @@ -0,0 +1,131 @@ +import { expect, test } from "bun:test"; +import { mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { runCli } from "../src/cli"; +import { preparePackage, receipt, matchReceipt, parsePackageJSON, packagePath, version } from "../src/core/package-format"; +import { parsePackageCommand } from "../src/core/package-args"; +const fixtures = new URL("../../shared/fixtures/registry/", import.meta.url); +const token = `rr_test_${"1".repeat(32)}`; +const base = "/registry/v1/organizations/example/packages"; +const data = async (name: string) => JSON.parse(await readFile(new URL(name, fixtures), "utf8")); +const raw = async (name: string) => readFile(new URL(name, fixtures), "utf8"); +async function workspace(fn: (root: string, invoke: (args: string[], fixture?: unknown, env?: Record) => Promise<{ code: number; stdout: string; stderr: string; report: any }>) => Promise) { + const root = await mkdtemp("/private/tmp/prose-registry-test-"); + const invoke = async (args: string[], fixture: unknown = { credential: null, storeAvailable: true, exchanges: [] }, env: Record = {}) => { + const path = join(root, "transport.json"); await writeFile(path, JSON.stringify(fixture)); + let stdout = "", stderr = ""; + const code = await runCli(args, { processCwd: root, userConfigPath: join(root, "cli.toml"), env: { PROSE_TEST_SERVICE_FIXTURE: path, ...env }, clock: { now: () => "2026-01-01T00:00:00Z", monotonicMs: () => 0 }, ids: { invocationId: () => "package-test" }, writeStdout: value => { stdout += value; }, writeStderr: value => { stderr += value; }, observeMockInvocation: () => { throw new Error("Harness must never start"); } }); + expect(stdout + stderr).not.toContain(token); + return { code, stdout, stderr, report: stdout.startsWith("{") ? JSON.parse(stdout) : null }; + }; + try { await fn(root, invoke); } finally { await rm(root, { recursive: true, force: true }); } +} +const transport = (exchanges: unknown[], extra: Record = {}) => ({ credential: token, storeAvailable: true, exchanges, ...extra }); +const publishArgs = ["cli", "package", "publish", "hello.md", "--organization", "example", "--name", "hello", "--version", "1.0.0", "--json"]; +const fetchArgs = ["cli", "package", "fetch", "example/hello@1.0.0", "--output-dir", "download", "--json"]; +async function fetchExchanges(artifact?: string) { return [{ method: "GET", path: `${base}/hello/versions/1.0.0`, status: 200, body: await data("single-file.receipt.json") }, { method: "GET", path: `${base}/hello/versions/1.0.0/artifact`, status: 200, body: artifact ?? await raw("single-file.canonical.json") }]; } +test("Bun independently reproduces normative canonical byte/hash vectors", async () => { + for (const vector of await data("hash-vectors.json")) { + const prepared = preparePackage(await data(vector.fixture)); + expect(Buffer.from(prepared.bytes).toString()).toBe(await raw(vector.canonical)); + expect(prepared.bytes.length).toBe(vector.artifactBytes); + expect(prepared.reference.sha256).toBe(vector.sha256); + expect(prepared.inventory).toEqual(vector.inventory); + matchReceipt(receipt(await data(vector.fixture.replace(".json", ".receipt.json"))), prepared); + } +}); +test("strict format rejects collisions, unsafe paths, encodings, unknown fields and unpinned metadata", async () => { + for (const path of ["../file", "/file", "a\\file", ".env", "a/.key", "a/secret.txt", "a/node_modules/x", "CON.txt", "a/key.pem", "a."]) expect(() => packagePath(path)).toThrow(); + for (const bad of ["^1.0.0", "1.0.0-01", "1.0", "1.0.0\n"]) expect(() => version(bad)).toThrow(); + const source = await data("single-file.json"); + for (const mutate of [ + (value: any) => { value.extra = true; }, + (value: any) => { value.files.push({ ...value.files[0], path: "HELLO.md" }); }, + (value: any) => { value.files.push({ ...value.files[0], path: "hello.md/a" }); }, + (value: any) => { value.files[0].content = "\ud800"; }, + (value: any) => { value.files[0].encoding = "base64"; value.files[0].content = "Zh=="; }, + (value: any) => { value.manifest.exports.default = "missing.md"; }, + (value: any) => { value.manifest.dependencies.x = { organization: "example", package: "x", version: "^1.0.0", sha256: "a".repeat(64) }; }, + ]) { const changed = structuredClone(source); mutate(changed); expect(() => preparePackage(changed)).toThrow(); } +}); +test("package grammar rejects unknown duplicate and misplaced flags", () => { + for (const args of [["list", "example", "--public"], ["withdraw", "example/a@1.0.0", "--cursor", "x"], ["fetch", "example/a@1.0.0"], ["publish", "a", "--organization", "example", "--organization", "example"], ["list", "example", "--cursor", "x", "--cursor", "y"]]) expect(() => parsePackageCommand(args)).toThrow(); +}); +test("single publish uploads exact canonical bytes and hash to production account origin", async () => workspace(async (root, invoke) => { + await writeFile(join(root, "hello.md"), "# Hello\n"); + const result = await invoke(publishArgs, transport([{ method: "POST", path: `${base}/hello/versions`, origin: "https://run-prose-production.openprose.workers.dev", status: 201, expectedBody: await raw("single-file.canonical.json"), expectedSha256: (await data("hash-vectors.json"))[0].sha256, body: await data("single-file.receipt.json") }])); + expect(result.code).toBe(0); expect(result.report.environment).toBe("production"); expect(result.stderr).toBe(""); +})); +test("directory publish explicitly selects bytes and retains exact build metadata", async () => workspace(async (root, invoke) => { + const source = await data("directory.json"); + await mkdir(join(root, "pkg", "docs"), { recursive: true }); + await writeFile(join(root, "pkg", "prose-package.json"), JSON.stringify({ schema: "prose-package-directory-v1", files: source.files.map((f: any) => f.path), exports: source.manifest.exports, dependencies: source.manifest.dependencies })); + for (const file of source.files) await writeFile(join(root, "pkg", file.path), file.encoding === "base64" ? Buffer.from(file.content, "base64") : file.content); + await writeFile(join(root, "pkg", ".env"), "unlisted-must-not-read"); + const result = await invoke(["cli", "package", "publish", "pkg", "--organization", "example", "--name", "kit", "--version", "2.0.0-beta.1+build.4", "--public", "--json"], transport([{ method: "POST", path: `${base}/kit/versions`, status: 200, expectedBody: await raw("directory.canonical.json"), body: await data("directory.receipt.json") }])); + expect(result.code).toBe(0); + const withdrawn = await invoke(["cli", "package", "withdraw", "example/kit@2.0.0-beta.1+build.4", "--json"], transport([{ method: "POST", path: `${base}/kit/versions/2.0.0-beta.1+build.4/withdraw`, status: 200, body: { receipt: await data("directory.receipt.json"), withdrawn: true } }])); + expect(withdrawn.code).toBe(0); +})); +test("fetch verifies all bytes then installs fresh exact files and receipt", async () => workspace(async (root, invoke) => { + const result = await invoke(fetchArgs, transport(await fetchExchanges())); + expect(result.code).toBe(0); expect(await readFile(join(root, "download", "hello.md"), "utf8")).toBe("# Hello\n"); + expect(JSON.parse(await readFile(join(root, "download", ".prose-package-receipt.json"), "utf8"))).toEqual(await data("single-file.receipt.json")); + expect((await readdir(root)).some(name => name.startsWith(".prose-package-"))).toBe(false); + const existing = await invoke(fetchArgs, transport(await fetchExchanges())); + expect(existing.report.problem.code).toBe("CONFIG_INVALID"); expect(await readFile(join(root, "download", "hello.md"), "utf8")).toBe("# Hello\n"); +})); +test("tampered/noncanonical artifacts and mismatched receipts never create destination", async () => workspace(async (root, invoke) => { + for (const artifact of [`${await raw("single-file.canonical.json")} `, await raw("single-file.json")]) { + const result = await invoke(fetchArgs, transport(await fetchExchanges(artifact))); + expect(result.report.problem.code).toBe("SERVICE_PROTOCOL_INVALID"); expect(await readdir(root)).not.toContain("download"); + } + const result = await invoke([...fetchArgs.slice(0, -1), "--sha256", "0".repeat(64), "--json"], transport(await fetchExchanges())); + expect(result.report.problem.code).toBe("SERVICE_PROTOCOL_INVALID"); expect(await readdir(root)).not.toContain("download"); +})); +test("source and destination symlinks fail closed without overwriting targets", async () => workspace(async (root, invoke) => { + await mkdir(join(root, "actual")); await writeFile(join(root, "actual", "hello.md"), "# Hello\n"); + await symlink(join(root, "actual", "hello.md"), join(root, "hello.md")); + expect((await invoke(publishArgs)).report.problem.code).toBe("CONFIG_INVALID"); + await symlink(join(root, "actual"), join(root, "alias")); + const args = [...publishArgs]; args[3] = "alias/hello.md"; + expect((await invoke(args)).report.problem.code).toBe("CONFIG_INVALID"); + await symlink(join(root, "actual"), join(root, "download")); + expect((await invoke(fetchArgs, transport(await fetchExchanges()))).report.problem.code).toBe("CONFIG_INVALID"); + expect(await readdir(join(root, "actual"))).toEqual(["hello.md"]); +})); +test("pagination allows public receipts only and reports opaque cursor", async () => workspace(async (_root, invoke) => { + const result = await invoke(["cli", "package", "list", "example", "--cursor", "public:kit:2.0.0+build.4", "--json"], transport([{ method: "GET", path: `${base}?cursor=public%3Akit%3A2.0.0%2Bbuild.4`, status: 200, body: { packages: [await data("directory.receipt.json")], nextCursor: "public:kit:2.0.0+build.4" } }])); + expect(result.code).toBe(0); expect(result.report.result.nextCursor).toBe("public:kit:2.0.0+build.4"); + const bad = await invoke(["cli", "package", "list", "example", "--json"], transport([{ method: "GET", path: base, status: 200, body: { packages: [await data("single-file.receipt.json")], nextCursor: null } }])); + expect(bad.report.problem.code).toBe("SERVICE_PROTOCOL_INVALID"); +})); +test("anonymous read allowed on unavailable store; write requires credentials; staging persists", async () => workspace(async (root, invoke) => { + const list = [{ method: "GET", path: base, status: 200, body: { packages: [], nextCursor: null } }]; + expect((await invoke(["cli", "package", "list", "example", "--json"], transport(list, { storeAvailable: false }))).code).toBe(0); + await writeFile(join(root, "hello.md"), "# Hello\n"); + expect((await invoke(publishArgs)).report.problem.code).toBe("SERVICE_AUTH_REQUIRED"); + await invoke(["cli", "environment", "use", "staging", "--json"]); + const result = await invoke(["cli", "package", "list", "example", "--json"], transport(list, { environment: "staging", credentials: { staging: token, production: null } }), { OPENPROSE_API_KEY: "malformed" }); + expect(result.report.environment).toBe("staging"); expect(result.code).toBe(0); expect(result.stderr).toBe(""); + const human = await invoke(["cli", "package", "list", "example"], transport(list, { environment: "staging" })); expect(human.stderr).toContain("OpenProse staging"); +})); +test("selected malformed credential and exhausted fixture fail closed without response reflection", async () => workspace(async (_root, invoke) => { + const result = await invoke(["cli", "package", "list", "example", "--json"], transport([]), { OPENPROSE_API_KEY: "bad-secret-value" }); + expect(result.report.problem.code).toBe("SERVICE_PROTOCOL_INVALID"); expect(result.stdout).not.toContain("bad-secret-value"); + expect((await invoke(["cli", "package", "list", "example", "--json"], transport([]))).report.problem.code).toBe("SERVICE_PROTOCOL_INVALID"); +})); + +test("local directory manifests reject duplicate and escaped-equivalent keys", async () => workspace(async (root, invoke) => { + await mkdir(join(root, "pkg")); + await writeFile(join(root, "pkg", "hello.md"), "# Hello\n"); + for (const contents of [ + '{"schema":"prose-package-directory-v1","files":["hello.md"],"files":["hello.md"],"exports":{"default":"hello.md"},"dependencies":{}}', + '{"schema":"prose-package-directory-v1","files":["hello.md"],"exports":{"default":"hello.md","default":"hello.md"},"dependencies":{}}', + '{"schema":"prose-package-directory-v1","files":["hello.md"],"exports":{"default":"hello.md","\\u0064efault":"hello.md"},"dependencies":{}}', + ]) { + await writeFile(join(root, "pkg", "prose-package.json"), contents); + const args = [...publishArgs]; args[3] = "pkg"; + expect((await invoke(args)).report.problem.code).toBe("CONFIG_INVALID"); + } +})); diff --git a/cli/ci/check_architecture.py b/cli/ci/check_architecture.py index 3dbf9c8..3993be7 100644 --- a/cli/ci/check_architecture.py +++ b/cli/ci/check_architecture.py @@ -106,6 +106,13 @@ "cli/shared/fixtures/adapters/tool-lifecycle/prime-turn-transition.json", "cli/shared/fixtures/adapters/tool-lifecycle/prime.json", "cli/shared/fixtures/config/optional-reporting.json", + "cli/shared/fixtures/registry/hash-vectors.json", + "cli/shared/fixtures/registry/single-file.json", + "cli/shared/fixtures/registry/single-file.canonical.json", + "cli/shared/fixtures/registry/single-file.receipt.json", + "cli/shared/fixtures/registry/directory.json", + "cli/shared/fixtures/registry/directory.canonical.json", + "cli/shared/fixtures/registry/directory.receipt.json", "cli/shared/fixtures/kernel-startup/release.json", "cli/shared/fixtures/native-output-budget.json", "cli/shared/fixtures/transport-diagnostics.json", diff --git a/cli/ci/run_local.py b/cli/ci/run_local.py index dbbc6fe..4b6cc70 100644 --- a/cli/ci/run_local.py +++ b/cli/ci/run_local.py @@ -419,6 +419,12 @@ def gates() -> tuple[Gate, ...]: Gate("service-environment-bun", REPOSITORY_ROOT, (python, "cli/conformance/runner/service_environment.py", "--", str(CLI_ROOT / "bun" / "dist" / "prose-test"))), + Gate("registry-service-rust", REPOSITORY_ROOT, + (python, "cli/conformance/runner/registry_service.py", "--", + str(CLI_ROOT / "rust" / "target" / "debug" / "prose"))), + Gate("registry-service-bun", REPOSITORY_ROOT, + (python, "cli/conformance/runner/registry_service.py", "--", + str(CLI_ROOT / "bun" / "dist" / "prose-test"))), Gate( "conformance-host", REPOSITORY_ROOT, diff --git a/cli/ci/test_run_local.py b/cli/ci/test_run_local.py index 3e242ca..60e1e58 100644 --- a/cli/ci/test_run_local.py +++ b/cli/ci/test_run_local.py @@ -86,6 +86,15 @@ def test_default_plan_covers_every_provider_free_authority_in_order(self) -> Non "bun-build", "adapter-product-adversary", "differential-conformance", + "staging-service-corpus", + "staging-service-rust-build", + "staging-service-rust", + "staging-service-bun-build", + "staging-service-bun", + "service-environment-rust", + "service-environment-bun", + "registry-service-rust", + "registry-service-bun", "conformance-host", "package-local", "alpha-package-admission", diff --git a/cli/conformance/cases/fixtures/runner-help.txt b/cli/conformance/cases/fixtures/runner-help.txt index b57e472..c649537 100644 --- a/cli/conformance/cases/fixtures/runner-help.txt +++ b/cli/conformance/cases/fixtures/runner-help.txt @@ -12,6 +12,9 @@ Runner commands: cli cleanup prime Settle one failed owned Prime service cli config explain Explain effective runner configuration cli auth Manage only the OpenProse account + cli org list List organizations for the selected account + cli environment Show, select, or reset the user service environment + cli package Publish, fetch, list, or withdraw registry files Runner options: --harness Select a harness (default: openprose) @@ -54,8 +57,21 @@ OpenProse service commands (production by default; no hosted execution): prose cli environment reset [--json] Environment selection persists in user configuration. Reset returns to production. -Account commands also accept an ephemeral --service-environment override before cli. +Account and package commands accept --service-environment production|staging before cli. Staging service output is labeled; JSON reports include the selected environment. Login stores credentials separately per environment in the OS credential store. For CI, use OPENPROSE_API_KEY or OPENPROSE_STAGING_API_KEY for the selected service. Logout removes only the selected local credential. + +Registry package commands (candidate; no source execution): + prose cli package publish --organization --name --version [--public] [--json] + prose cli package fetch /@ --output-dir [--sha256 ] [--json] + prose cli package list [--cursor ] [--json] + prose cli package withdraw /@ [--json] + +A directory requires prose-package.json with explicitly listed files, exports, +and pinned dependencies. Private is the publish default; list shows public +packages one page at a time. Withdraw removes discovery, not pinned retrieval. +Fetch verifies canonical bytes and receipt hashes before creating a fresh target; +it never overwrites an existing directory. Bun writes the receipt last: a target +without that receipt after interruption is incomplete; there is no automatic resume. diff --git a/cli/conformance/runner/README.md b/cli/conformance/runner/README.md index 37ee604..24c21d8 100644 --- a/cli/conformance/runner/README.md +++ b/cli/conformance/runner/README.md @@ -168,3 +168,38 @@ evidence. The matrix is candidate-reported, provider spend is unverified, reliability is not measured, and semantic status is `not-applicable`. It does not change this runner's nonsemantic, non-release claims, and no artifact from this implementation has been published. + + +## Registry and service environment fixtures + +The account and registry lanes use compiled test seams with temporary user +configuration, synthetic credentials, and exact ordered transport exchanges. +They never contact an account service or start a harness. Ordinary binaries +ignore the fixture environment variable. Build and run from the repository root: + +```sh +(cd cli/rust && cargo build --locked --features test-seams --bin prose) +(cd cli/bun && bun run build:test) +python3 cli/conformance/runner/service_environment.py -- cli/rust/target/debug/prose +python3 cli/conformance/runner/service_environment.py -- cli/bun/dist/prose-test +python3 cli/conformance/runner/registry_service.py -- cli/rust/target/debug/prose +python3 cli/conformance/runner/registry_service.py -- cli/bun/dist/prose-test +``` + +Use the repository-pinned Bun 1.3.5 on PATH, including for child build scripts. +`run_local.py` includes `registry-service-rust` and `registry-service-bun` after +its test-seam builds. The registry oracle checks both canonical byte fixtures in +production and staging across publish, fetch, public listing, withdraw, and +artifact tampering, HTTP error classification, duplicate manifest keys and human +output. It asserts exact receipt results, exact posted bytes/hash, +selected fixed origin and credential isolation without retaining credentials. +The normative vectors live in `cli/shared/fixtures/registry/`; neither port +imports the other's implementation. + +Local directory-manifest tests reject duplicate JSON keys; network artifacts +retain the protocol's parsed-object behavior and must also match canonical bytes +on fetch. Filesystem checks cover symlinks, unsafe paths, existing destinations, +and receipt-last Bun materialization. A Bun fetch may expose an incomplete +reserved directory during writes; no automatic resume or overwrite is promised. +Fixture success is not deployment, publication, native platform admission, or +live authorization evidence. diff --git a/cli/conformance/runner/registry_service.py b/cli/conformance/runner/registry_service.py new file mode 100644 index 0000000..5eb84bf --- /dev/null +++ b/cli/conformance/runner/registry_service.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Exercise both public CLI implementations against exact registry byte fixtures.""" +import base64 +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile + +FIXTURES = Path(__file__).resolve().parents[2] / 'shared' / 'fixtures' / 'registry' +TOKEN = 'rr_test_' + '1' * 32 + + +def run(command): + count = 0 + for name in ('single-file', 'directory'): + source = json.loads((FIXTURES / (name + '.json')).read_text()) + receipt = json.loads((FIXTURES / (name + '.receipt.json')).read_text()) + canonical = (FIXTURES / (name + '.canonical.json')).read_text() + m = source['manifest'] + prefix = '/registry/v1/organizations/' + m['organization'] + '/packages/' + m['package'] + exact = prefix + '/versions/' + m['version'] + for selected in ('production', 'staging'): + with tempfile.TemporaryDirectory(prefix='prose-registry-oracle-') as directory: + root = Path(directory).resolve() + directory = str(root) + package = root / 'source'; package.mkdir() + for file in source['files']: + path = package / file['path']; path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(file['content'].encode() if file['encoding'] == 'utf8' else base64.b64decode(file['content'])) + source_path = package / source['files'][0]['path'] + if name == 'directory': + (package / 'prose-package.json').write_text(json.dumps({'schema':'prose-package-directory-v1','files':[f['path'] for f in source['files']], 'exports':m['exports'], 'dependencies':m['dependencies']})) + source_path = package + env = {'PATH':os.environ.get('PATH','/usr/bin:/bin'),'HOME':directory,'XDG_CONFIG_HOME':str(root/'config'),'TMPDIR':directory,'HTTP_PROXY':'http://127.0.0.1:9','HTTPS_PROXY':'http://127.0.0.1:9','ALL_PROXY':'http://127.0.0.1:9','NO_PROXY':''} + fixture = root / 'service.json'; env['PROSE_TEST_SERVICE_FIXTURE'] = str(fixture) + def invoke(args, exchanges, expected_code=0, human=False): + fixture.write_text(json.dumps({'environment':selected,'credentials':{'production':TOKEN if selected=='production' else None,'staging':TOKEN if selected=='staging' else None},'storeAvailable':True,'exchanges':exchanges})) + p = subprocess.run([*command,'--service-environment',selected,'--output','human' if human else 'json','cli','package',*args],cwd=root,env=env,capture_output=True,timeout=15) + assert p.returncode == expected_code, (args,p.returncode,p.stdout.decode(),p.stderr.decode()) + if human: + assert p.stderr.decode() == ("OpenProse staging environment\n" if selected == "staging" else "") + return p.stdout.decode() + report = json.loads(p.stdout) + assert report['schema']=='openprose.package-operation/1' and report['environment']==selected + assert TOKEN.encode() not in p.stdout+p.stderr and not p.stderr + assert set(report)=={'schema','environment','operation','result','problem'} + if expected_code==0: assert report['problem'] is None + return report + post={'method':'POST','path':prefix+'/versions','status':201,'body':receipt,'expectedBody':canonical,'expectedSha256':receipt['reference']['sha256']} + args=['publish',str(source_path),'--organization',m['organization'],'--name',m['package'],'--version',m['version']] + if m.get('visibility')=='public': args+=['--public'] + assert invoke(args,[post])['result']==receipt; count+=1 + ref=m['organization']+'/'+m['package']+'@'+m['version'] + reads=[{'method':'GET','path':exact,'status':200,'body':receipt},{'method':'GET','path':exact+'/artifact','status':200,'body':canonical}] + dest=root/'installed' + assert invoke(['fetch',ref,'--output-dir',str(dest)],reads)['result']==receipt + for file in source['files']: + assert (dest/file['path']).read_bytes()==(package/file['path']).read_bytes() + assert json.loads((dest/'.prose-package-receipt.json').read_text())==receipt;count+=1 + listing={'packages':[receipt] if receipt['visibility']=='public' else [],'nextCursor':None} + assert invoke(['list',m['organization']],[{'method':'GET','path':'/registry/v1/organizations/'+m['organization']+'/packages','status':200,'body':listing}])['result']==listing;count+=1 + result={'receipt':receipt,'withdrawn':True} + assert invoke(['withdraw',ref],[{'method':'POST','path':exact+'/withdraw','status':200,'body':result}])['result']==result;count+=1 + tampered=[reads[0],{**reads[1],'body':canonical.replace('prose-package-v1','prose-package-v2')}] + bad=root/'tampered';assert invoke(['fetch',ref,'--output-dir',str(bad)],tampered,10)['result'] is None;assert not bad.exists();count+=1 + for status in (404, 413, 429): + absent=root/('absent-'+str(status)) + error=invoke(['fetch',ref,'--output-dir',str(absent)],[{'method':'GET','path':exact,'status':status,'body':{'message':'untrusted upstream details'}}],10) + assert error['problem']['code']=='SERVICE_UNAVAILABLE' and not absent.exists();count+=1 + assert invoke(['withdraw',ref],[{'method':'POST','path':exact+'/withdraw','status':200,'body':result}],human=True)==f'OpenProse {selected} package withdraw: {ref}\n';count+=1 + expected_list=ref+'\n' if receipt['visibility']=='public' else '' + assert invoke(['list',m['organization']],[{'method':'GET','path':'/registry/v1/organizations/'+m['organization']+'/packages','status':200,'body':listing}],human=True)==expected_list;count+=1 + if name=='directory': + manifest=package/'prose-package.json' + text=manifest.read_text();manifest.write_text(text.replace('"schema":', '"schema":"duplicate", "schema":',1)) + error=invoke(args,[],2) + assert error['problem']['code']=='CONFIG_INVALID';count+=1 + print('PASS',name,selected,'registry publication, integrity, HTTP errors and human output') + print('PASS',count,'registry process cases') + +if __name__=='__main__': + args=sys.argv[1:] + if args[:1]==['--']:args=args[1:] + if not args:raise SystemExit('Supply a test-seam CLI after --') + run(args) diff --git a/cli/protocol/OWNERSHIP.md b/cli/protocol/OWNERSHIP.md index f6a9fc3..fda8d9c 100644 --- a/cli/protocol/OWNERSHIP.md +++ b/cli/protocol/OWNERSHIP.md @@ -508,3 +508,13 @@ Root additionally authorizes `repository_gate_diagnosis` repository URL substitu User authorized production-default account operations with a persistent user-only staging selection in PR #8. Root owns shared spec, schemas, corpus, help fixtures, integration and Git. `environment_bun` owns `cli/bun/src/` and `cli/bun/test/`; `environment_rust` owns `cli/rust/crates/prose-runner-core/src/` and `cli/rust/crates/prose-cli/src/` plus their tests. Both wait for shared contract/cases before implementation. `environment_review` is read-only architecture/security/UX review. No backend deployment, real credential mutation or production requests. Existing leases in these paths are superseded for this phase. Root integration also owns `docs/staging-account.md`, `cli/conformance/runner/service_environment.py`, `cli/conformance/cases/operations/auth-status-unavailable.json`, and `cli/ci/run_local.py` for environment qualification and hermetic no-flag auth admission. + +## IMP-034 registry CLI implementation + +Root owns Git, shared fixtures/spec/protocol/schemas/corpus, docs and integration on codex/imp-034-registry-cli (stacked on environment candidate). `environment_bun` owns `cli/bun/src/` and `cli/bun/test/`; `environment_rust` owns Rust runner-core/prose-cli source and tests, for package publish/fetch/list/withdraw per `protocol/decisions/imp034-registry-cli.md`. No dependency changes without root coordination. No live credentials, paid calls, publication or deployment. Existing environment implementation is the starting point and must remain intact. + +Root adds the fs feature to already pinned rustix in runner-core Cargo.toml for safe no-clobber materialization; no new dependency/version. + +Root additionally assigns `environment_bun` `cli/README.md`, `cli/conformance/runner/README.md`, `cli/conformance/cases/fixtures/runner-help.txt`, `cli/ci/check_architecture.py` (exact module builtin admissions only), `cli/ci/run_local.py` (registry oracle gates), and `cli/shared/schemas/README.md` for registry developer documentation and shared integration gates. Rust help/source changes remain coordinated with `environment_rust`; root retains Git and schema/taxonomy validation. + +Root extends the integration lease to `cli/ci/test_run_local.py` for the explicit service/environment/registry gate inventory assertion. diff --git a/cli/protocol/decisions/imp034-registry-cli.md b/cli/protocol/decisions/imp034-registry-cli.md new file mode 100644 index 0000000..ea95591 --- /dev/null +++ b/cli/protocol/decisions/imp034-registry-cli.md @@ -0,0 +1,82 @@ +# IMP-034 registry transport contract + +Rust and Bun implement this protocol independently. The normative package format, +canonical bytes and hashes are in `shared/fixtures/registry/`. Neither implementation +interprets Markdown or executes package contents. + +All requests use the selected account service origin and the fixed `/registry/v1/` +prefix. The existing environment selection and credential stores apply. Redirects, +arbitrary upload hosts and cross-environment credential fallback are prohibited. + +## Commands + +All commands accept trailing `--json` or the global `--output json` option. + +- `cli package publish --organization --name --version [--public]` + publishes one regular file or an explicitly described directory. A single file + uses its basename as the default export and has no dependencies. A directory + requires a regular `prose-package.json` containing exactly `schema`, `files`, + `exports` and `dependencies`; the schema is `prose-package-directory-v1`. + Only listed files are read. Unknown or duplicate flags and manifest keys are + rejected. Publication is private unless `--public` is supplied. The CLI posts + canonical package JSON and accepts HTTP 200 or 201 only with a receipt matching + the local identity, hash, inventory and visibility. It does not retry automatically. +- `cli package fetch /@ --output-dir [--sha256 ]` + retrieves a receipt and then the artifact. It verifies the closed receipt shape, + artifact hash, optional caller hash, canonical bytes and inventory before writing + files. The destination must not exist. Exact file bytes and a + `.prose-package-receipt.json` are written. Dependencies remain pinned metadata; + this operation does not resolve or execute them. +- `cli package list [--cursor ]` retrieves one page of public + receipts. The response contains `packages` and `nextCursor`. A cursor is at most + 210 ASCII characters and matches `public:[a-z0-9-]+:[0-9A-Za-z.+-]+`; it is encoded + as a query value. Private packages remain available by authorized exact reference. +- `cli package withdraw /@` requires a credential + and validates the returned receipt and `withdrawn: true`. Withdrawal removes + discovery entries; it does not delete content or recall public downloads. + +The ephemeral `--service-environment` option applies to these commands. Read +operations may proceed anonymously when no credential is available. Malformed +explicit or stored credentials fail rather than falling back to another identity. + +## Filesystem guarantees + +Paths, file counts and byte limits follow the normative package format. Source +symlinks, including ancestor symlinks, are rejected. No recursive upload, glob +expansion or implicit inclusion occurs. Unlisted files are ignored. + +Rust installs a verified sibling staging directory with an atomic no-replace +rename on supported platforms. Bun reserves the destination with exclusive +`mkdir`, creates verified files exclusively, and writes the receipt last. The Bun +destination can be visible while it is populated; an interrupted destination +without a receipt is incomplete. Neither implementation overwrites an existing +destination. Cleanup verifies the identity of created entries and preserves +replaced or unexpected content. These checks do not provide a sandbox against a +malicious process with the same filesystem privileges. + +## Results and errors + +JSON output follows `package-operation.schema.json`: `schema`, `environment`, +`operation`, `result` and `problem`, with no extra fields. Successful fetch returns +only the receipt, not local paths. Human staging output includes an explicit +stderr indicator; structured output has no banner. Publishing and fetching do not +start a harness. + +Invalid invocation and local configuration failures exit with code 2. Service +failures exit with code 10. HTTP 401/403 require authentication; 400/404/429 and +unavailable service responses report `SERVICE_UNAVAILABLE`; 409 reports +`SERVICE_PROTOCOL_INVALID`. Raw response bodies and credentials are never printed. +Requests and responses are bounded to 2 MiB with a ten-second client deadline. + +## Test transport + +Test builds support `PROSE_TEST_SERVICE_FIXTURE`. Fixtures specify the selected +environment, credentials and ordered exchanges containing method, path, status +and body. Artifact bodies are raw UTF-8 strings. Optional expected request bytes +and hashes verify publication. Unknown or exhausted exchanges fail without a +network fallback. Ordinary binaries ignore this fixture setting. + +Shared process tests exercise both implementations using the same single-file +and directory vectors. Backend tests separately cover authorization, concurrent +publication, idempotent retries, conflicts and withdrawal. Hermetic tests do not +establish deployed Cloudflare behavior or live credential-store interoperability. diff --git a/cli/rust/crates/prose-cli/tests/registry.rs b/cli/rust/crates/prose-cli/tests/registry.rs new file mode 100644 index 0000000..7a777e5 --- /dev/null +++ b/cli/rust/crates/prose-cli/tests/registry.rs @@ -0,0 +1,238 @@ +#![cfg(feature = "test-seams")] +use serde_json::{Value, json}; +use std::{ + fs, + path::Path, + process::{Command, Output}, +}; +fn receipt() -> Value { + serde_json::from_slice(include_bytes!( + "../../../../shared/fixtures/registry/single-file.receipt.json" + )) + .unwrap() +} +fn invoke(root: &Path, args: &[&str], fixture: Value, environment: &[(&str, &str)]) -> Output { + let fixture_path = root.join("fixture.json"); + fs::write(&fixture_path, fixture.to_string()).unwrap(); + let mut command = Command::new(env!("CARGO_BIN_EXE_prose")); + command + .args(args) + .current_dir(root) + .env_clear() + .env("HOME", root.join("home")) + .env("XDG_CONFIG_HOME", root.join("xdg")) + .env("PROSE_TEST_SERVICE_FIXTURE", fixture_path); + for (key, value) in environment { + command.env(key, value); + } + command.output().unwrap() +} +fn fixture(exchanges: Value) -> Value { + json!({"environment":"production","credential":null,"storeAvailable":true,"exchanges":exchanges}) +} +fn report(output: &Output) -> Value { + assert!( + output.stderr.is_empty(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + serde_json::from_slice(&output.stdout).unwrap() +} +#[test] +fn registry_plus_build_path_and_selected_credentials_are_exact() { + let root = tempfile::tempdir().unwrap(); + let root = fs::canonicalize(root.path()).unwrap(); + let mut receipt = receipt(); + receipt["reference"]["version"] = json!("1.0.0+build"); + let value = json!({"receipt":receipt,"withdrawn":true}); + let output = invoke( + &root, + &[ + "cli", + "package", + "withdraw", + "example/hello@1.0.0+build", + "--json", + ], + fixture( + json!([{"method":"POST","path":"/registry/v1/organizations/example/packages/hello/versions/1.0.0+build/withdraw","origin":"https://run-prose-production.openprose.workers.dev","status":200,"body":value}]), + ), + &[ + ( + "OPENPROSE_API_KEY", + "rr_test_11111111111111111111111111111111", + ), + ("OPENPROSE_STAGING_API_KEY", "invalid-other-environment"), + ], + ); + assert_eq!(output.status.code(), Some(0)); + assert_eq!(report(&output)["result"], value); + let output = invoke( + &root, + &[ + "cli", + "package", + "withdraw", + "example/hello@1.0.0", + "--json", + ], + fixture(json!([])), + &[( + "OPENPROSE_STAGING_API_KEY", + "rr_test_11111111111111111111111111111111", + )], + ); + assert_eq!(report(&output)["problem"]["code"], "SERVICE_AUTH_REQUIRED"); +} +#[test] +fn anonymous_public_listing_supports_cursor_and_unavailable_store_only() { + let root = tempfile::tempdir().unwrap(); + let root = fs::canonicalize(root.path()).unwrap(); + let listing = json!({"packages":[],"nextCursor":"public:hello:1.0.0+build"}); + let fixture = json!({"environment":"production","credential":null,"storeAvailable":false,"exchanges":[{"method":"GET","path":"/registry/v1/organizations/example/packages?cursor=public%3Ahello%3A1.0.0%2Bbuild","status":200,"body":listing}]}); + let args = [ + "cli", + "package", + "list", + "example", + "--cursor", + "public:hello:1.0.0+build", + "--json", + ]; + let output = invoke(&root, &args, fixture.clone(), &[]); + assert_eq!(output.status.code(), Some(0)); + assert_eq!(report(&output)["result"], listing); + let output = invoke(&root, &args, fixture, &[("OPENPROSE_API_KEY", "bad-token")]); + assert_eq!( + report(&output)["problem"]["code"], + "SERVICE_PROTOCOL_INVALID" + ); + let output = invoke( + &root, + &["cli", "package", "list", "example", "--json"], + json!({"environment":"production","credential":"bad-stored-token","storeAvailable":true,"exchanges":[]}), + &[], + ); + assert_eq!( + report(&output)["problem"]["code"], + "SERVICE_PROTOCOL_INVALID" + ); +} +#[test] +fn fetch_rejects_existing_targets_wrong_hash_and_noncanonical_artifacts() { + let root = tempfile::tempdir().unwrap(); + let root = fs::canonicalize(root.path()).unwrap(); + let receipt = receipt(); + let canonical = + include_str!("../../../../shared/fixtures/registry/single-file.canonical.json").to_owned(); + let exchanges = json!([{"method":"GET","path":"/registry/v1/organizations/example/packages/hello/versions/1.0.0","status":200,"body":receipt},{"method":"GET","path":"/registry/v1/organizations/example/packages/hello/versions/1.0.0/artifact","status":200,"body":canonical}]); + fs::create_dir(root.join("existing")).unwrap(); + let output = invoke( + &root, + &[ + "cli", + "package", + "fetch", + "example/hello@1.0.0", + "--output-dir", + "existing", + "--json", + ], + fixture(exchanges.clone()), + &[], + ); + assert_eq!(output.status.code(), Some(2)); + assert!( + fs::read_dir(root.join("existing")) + .unwrap() + .next() + .is_none() + ); + let output = invoke( + &root, + &[ + "cli", + "package", + "fetch", + "example/hello@1.0.0", + "--output-dir", + "bad", + "--sha256", + "0000000000000000000000000000000000000000000000000000000000000000", + "--json", + ], + fixture(exchanges.clone()), + &[], + ); + assert_eq!(output.status.code(), Some(10)); + assert!(!root.join("bad").exists()); + let mut tampered = exchanges; + tampered[1]["body"] = json!(format!(" {canonical}")); + let output = invoke( + &root, + &[ + "cli", + "package", + "fetch", + "example/hello@1.0.0", + "--output-dir", + "tampered", + "--json", + ], + fixture(tampered), + &[], + ); + assert_eq!( + report(&output)["problem"]["code"], + "SERVICE_PROTOCOL_INVALID" + ); + assert!(!root.join("tampered").exists()); +} +#[test] +fn publish_refuses_wrong_upload_expectation_and_does_not_echo_raw_service_errors() { + let root = tempfile::tempdir().unwrap(); + let root = fs::canonicalize(root.path()).unwrap(); + fs::write(root.join("hello.md"), b"# Hello\n").unwrap(); + let args = [ + "cli", + "package", + "publish", + "hello.md", + "--organization", + "example", + "--name", + "hello", + "--version", + "1.0.0", + "--json", + ]; + let exchange = json!({"method":"POST","path":"/registry/v1/organizations/example/packages/hello/versions","status":201,"body":receipt(),"expectedBody":"wrong"}); + let output = invoke( + &root, + &args, + fixture(json!([exchange])), + &[( + "OPENPROSE_API_KEY", + "rr_test_11111111111111111111111111111111", + )], + ); + assert_eq!( + report(&output)["problem"]["code"], + "SERVICE_PROTOCOL_INVALID" + ); + let exchange = json!({"method":"POST","path":"/registry/v1/organizations/example/packages/hello/versions","status":409,"body":{"error":"do-not-echo-service-body"}}); + let output = invoke( + &root, + &args, + fixture(json!([exchange])), + &[( + "OPENPROSE_API_KEY", + "rr_test_11111111111111111111111111111111", + )], + ); + assert_eq!( + report(&output)["problem"]["code"], + "SERVICE_PROTOCOL_INVALID" + ); + assert!(!String::from_utf8_lossy(&output.stdout).contains("do-not-echo")); +} diff --git a/cli/rust/crates/prose-runner-core/Cargo.toml b/cli/rust/crates/prose-runner-core/Cargo.toml index 9cb3db2..7b9ef26 100644 --- a/cli/rust/crates/prose-runner-core/Cargo.toml +++ b/cli/rust/crates/prose-runner-core/Cargo.toml @@ -21,7 +21,7 @@ toml.workspace = true uuid.workspace = true [target.'cfg(unix)'.dependencies] -rustix = { workspace = true, features = ["event", "net"] } +rustix = { workspace = true, features = ["event", "net", "fs"] } [dev-dependencies] tempfile.workspace = true diff --git a/cli/rust/crates/prose-runner-core/src/invocation.rs b/cli/rust/crates/prose-runner-core/src/invocation.rs index e19f992..68b51e4 100644 --- a/cli/rust/crates/prose-runner-core/src/invocation.rs +++ b/cli/rust/crates/prose-runner-core/src/invocation.rs @@ -68,6 +68,7 @@ pub enum RunnerCommand { EnvironmentShow, EnvironmentUse(String), EnvironmentReset, + Package(crate::registry::PackageCommand), } #[derive(Debug, Clone, PartialEq, Eq)] @@ -107,7 +108,7 @@ pub fn parse_invocation( let mut other = parsed.globals.clone(); other.service_environment = None; other.output = None; other.no_color = false; other.verbose = false; if other != GlobalFlags::default() || !matches!(parsed.action, - Action::Runner { command: RunnerCommand::AuthStatus | RunnerCommand::AuthLogin | RunnerCommand::AuthLogout | RunnerCommand::OrgList, .. }) { + Action::Runner { command: RunnerCommand::AuthStatus | RunnerCommand::AuthLogin | RunnerCommand::AuthLogout | RunnerCommand::OrgList | RunnerCommand::Package(_), .. }) { return Err(RunnerError::invocation("service environment requires an account or organization command")); } } @@ -311,6 +312,12 @@ fn parse_runner_command(args: &[String], globals: &mut GlobalFlags) -> Result (RunnerCommand::Doctor, tail), [harness, list, tail @ ..] if harness == "harness" && list == "list" => { @@ -424,7 +431,7 @@ fn known_runner_help_path(args: &[String]) -> bool { values.as_slice(), ["--help"] | [ - "doctor" | "harness" | "cleanup" | "config" | "auth" | "org" | "environment", + "doctor" | "harness" | "cleanup" | "config" | "auth" | "org" | "environment" | "package", "--help" ] | ["harness", "list" | "use", "--help"] @@ -434,6 +441,8 @@ fn known_runner_help_path(args: &[String]) -> bool { | ["config", "explain", "--help"] | ["auth", "status" | "login" | "logout", "--help"] | ["org", "list", "--help"] + | ["package", "publish" | "fetch" | "list" | "withdraw", "--help"] + | ["package", "publish" | "fetch" | "list" | "withdraw", _, "--help"] | ["environment", "show" | "reset" | "use", "--help"] | ["environment", "use", _, "--help"] ) diff --git a/cli/rust/crates/prose-runner-core/src/lib.rs b/cli/rust/crates/prose-runner-core/src/lib.rs index 91ed3aa..60aaf2d 100644 --- a/cli/rust/crates/prose-runner-core/src/lib.rs +++ b/cli/rust/crates/prose-runner-core/src/lib.rs @@ -29,3 +29,4 @@ pub use runtime::{Clock, IdSource, SystemClock, SystemIdSource}; pub mod kernel_startup; pub mod service_account; +pub mod registry; diff --git a/cli/rust/crates/prose-runner-core/src/registry.rs b/cli/rust/crates/prose-runner-core/src/registry.rs new file mode 100644 index 0000000..346fc60 --- /dev/null +++ b/cli/rust/crates/prose-runner-core/src/registry.rs @@ -0,0 +1,1375 @@ +//! Data-only package preparation and verified registry consumption. +use crate::error::ErrorCode; +use crate::output::CommandOutcome; +use crate::service_account::{ServiceEnvironment, Session}; +use crate::{CancellationToken, OutputMode, RunnerError}; +use serde::de::{self, Deserialize, Deserializer, MapAccess, SeqAccess, Visitor}; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, BTreeSet}; +use std::fs::File; +use std::io::{Read, Write}; +use std::path::{Component, Path, PathBuf}; + +pub(crate) const LIMIT: usize = 2 * 1024 * 1024; +const FILE_LIMIT: usize = 256 * 1024; +const TOTAL_LIMIT: usize = 1024 * 1024; +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PackageCommand { + Publish { + source: String, + organization: String, + name: String, + version: String, + public: bool, + }, + Fetch { + reference: String, + destination: String, + sha256: Option, + }, + List { + organization: String, + cursor: Option, + }, + Withdraw { + reference: String, + }, +} +fn invalid() -> RunnerError { + RunnerError::config("Invalid or unsafe package input.") +} +fn protocol() -> RunnerError { + RunnerError::catalog(ErrorCode::ServiceProtocolInvalid) +} +fn sha(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} +fn text(value: &Value) -> Result<&str, RunnerError> { + value.as_str().ok_or_else(invalid) +} +fn fields(value: &Value, allowed: &[&str], required: &[&str]) -> Result<(), RunnerError> { + let map = value.as_object().ok_or_else(invalid)?; + if map.keys().any(|key| !allowed.contains(&key.as_str())) + || required.iter().any(|key| !map.contains_key(*key)) + { + return Err(invalid()); + } + Ok(()) +} +fn exact(value: &Value, names: &[&str]) -> Result<(), RunnerError> { + fields(value, names, names) +} +fn slug(value: &str) -> bool { + !value.is_empty() + && value.len() <= 63 + && value + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') + && !value.starts_with('-') + && !value.ends_with('-') +} +fn digits(value: &str) -> bool { + !value.is_empty() + && value.bytes().all(|b| b.is_ascii_digit()) + && (value == "0" || !value.starts_with('0')) +} +fn version(value: &str) -> bool { + if value.len() > 128 { + return false; + } + let (base, build) = value + .split_once('+') + .map_or((value, None), |(a, b)| (a, Some(b))); + let identifiers = |s: &str| { + s.split('.').all(|part| { + !part.is_empty() && part.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-') + }) + }; + if build.is_some_and(|b| !identifiers(b)) { + return false; + } + let (core, pre) = base + .split_once('-') + .map_or((base, None), |(a, b)| (a, Some(b))); + if pre.is_some_and(|p| { + !identifiers(p) + || p.split('.') + .any(|p| p.bytes().all(|b| b.is_ascii_digit()) && !digits(p)) + }) { + return false; + } + let parts: Vec<_> = core.split('.').collect(); + parts.len() == 3 && parts.iter().all(|v| digits(v)) +} +fn digest(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) +} +fn package_path(value: &str) -> bool { + if value.is_empty() + || value.len() > 240 + || !value.as_bytes()[0].is_ascii_alphanumeric() && value.as_bytes()[0] != b'_' + || !value + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"._/-".contains(&b)) + { + return false; + } + value.split('/').all(|part| { + let lower = part.to_ascii_lowercase(); + let stem = lower.split('.').next().unwrap_or(""); + !part.is_empty() + && !part.starts_with('.') + && !part.ends_with('.') + && !matches!( + stem, + "con" + | "prn" + | "aux" + | "nul" + | "node_modules" + | "credentials" + | "secret" + | "secrets" + ) + && !(stem.len() == 4 + && (stem.starts_with("com") || stem.starts_with("lpt")) + && (b'1'..=b'9').contains(&stem.as_bytes()[3])) + && ![".pem", ".key", ".p12", ".pfx"] + .iter() + .any(|suffix| lower.ends_with(suffix)) + }) +} +fn validate_reference(value: &Value) -> Result<(), RunnerError> { + exact(value, &["organization", "package", "version", "sha256"])?; + if !slug(text(&value["organization"])?) + || !slug(text(&value["package"])?) + || !version(text(&value["version"])?) + || !digest(text(&value["sha256"])?) + { + return Err(invalid()); + } + Ok(()) +} +fn reference_parts(value: &str) -> Result<(&str, &str, &str), RunnerError> { + let (organization, rest) = value.split_once('/').ok_or_else(invalid)?; + let (package, version_value) = rest.split_once('@').ok_or_else(invalid)?; + if !slug(organization) || !slug(package) || !version(version_value) { + return Err(invalid()); + } + Ok((organization, package, version_value)) +} +fn reference_matches(receipt: &Value, organization: &str, package: &str, version: &str) -> bool { + receipt["reference"]["organization"] == organization + && receipt["reference"]["package"] == package + && receipt["reference"]["version"] == version +} +fn base64(bytes: &[u8]) -> String { + const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut output = String::with_capacity(bytes.len().div_ceil(3) * 4); + for part in bytes.chunks(3) { + let a = part[0]; + let b = *part.get(1).unwrap_or(&0); + let c = *part.get(2).unwrap_or(&0); + for index in [a >> 2, (a & 3) << 4 | b >> 4] { + output.push(char::from(ALPHABET[index as usize])); + } + output.push(if part.len() > 1 { + char::from(ALPHABET[((b & 15) << 2 | c >> 6) as usize]) + } else { + '=' + }); + output.push(if part.len() > 2 { + char::from(ALPHABET[(c & 63) as usize]) + } else { + '=' + }); + } + output +} +fn decode_base64(value: &str) -> Result, RunnerError> { + if value.len() % 4 != 0 { + return Err(invalid()); + } + let mut output = Vec::new(); + for group in value.as_bytes().chunks(4) { + let mut n = [0_u8; 4]; + for (index, b) in group.iter().enumerate() { + n[index] = match b { + b'A'..=b'Z' => b - b'A', + b'a'..=b'z' => b - b'a' + 26, + b'0'..=b'9' => b - b'0' + 52, + b'+' => 62, + b'/' => 63, + b'=' if index >= 2 => 0, + _ => return Err(invalid()), + }; + } + output.push(n[0] << 2 | n[1] >> 4); + if group[2] != b'=' { + output.push(n[1] << 4 | n[2] >> 2); + } + if group[3] != b'=' { + output.push(n[2] << 6 | n[3]); + } + } + if base64(&output) != value { + return Err(invalid()); + } + Ok(output) +} +// Sender-side directory manifests reject duplicate keys, unlike network JSON consumers. +struct Unique(Value); +impl<'de> Deserialize<'de> for Unique { + fn deserialize>(deserializer: D) -> Result { + struct UniqueVisitor; + impl<'de> Visitor<'de> for UniqueVisitor { + type Value = Unique; + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("JSON with unique object keys") + } + fn visit_map>(self, mut map: M) -> Result { + let mut values = serde_json::Map::new(); + while let Some(key) = map.next_key::()? { + if values.contains_key(&key) { + return Err(de::Error::custom("duplicate object key")); + } + values.insert(key, map.next_value::()?.0); + } + Ok(Unique(Value::Object(values))) + } + fn visit_seq>(self, mut seq: S) -> Result { + let mut values = Vec::new(); + while let Some(v) = seq.next_element::()? { + values.push(v.0); + } + Ok(Unique(Value::Array(values))) + } + fn visit_str(self, v: &str) -> Result { + Ok(Unique(json!(v))) + } + fn visit_string(self, v: String) -> Result { + Ok(Unique(json!(v))) + } + fn visit_bool(self, v: bool) -> Result { + Ok(Unique(json!(v))) + } + fn visit_u64(self, v: u64) -> Result { + Ok(Unique(json!(v))) + } + fn visit_i64(self, v: i64) -> Result { + Ok(Unique(json!(v))) + } + fn visit_f64(self, v: f64) -> Result { + Ok(Unique(json!(v))) + } + fn visit_unit(self) -> Result { + Ok(Unique(Value::Null)) + } + fn visit_none(self) -> Result { + Ok(Unique(Value::Null)) + } + } + deserializer.deserialize_any(UniqueVisitor) + } +} +#[derive(Debug)] +struct Prepared { + bytes: Vec, + inventory: Value, + reference: Value, + visibility: String, + files: Vec<(String, Vec)>, +} +fn prepare(value: &Value) -> Result { + exact(value, &["schema", "manifest", "files"])?; + if value["schema"] != "prose-package-v1" + || serde_json::to_vec(value).map_err(|_| invalid())?.len() > LIMIT + { + return Err(invalid()); + } + let manifest = &value["manifest"]; + fields( + manifest, + &[ + "organization", + "package", + "version", + "exports", + "dependencies", + "visibility", + ], + &[ + "organization", + "package", + "version", + "exports", + "dependencies", + ], + )?; + if !slug(text(&manifest["organization"])?) + || !slug(text(&manifest["package"])?) + || !version(text(&manifest["version"])?) + { + return Err(invalid()); + } + let visibility = manifest + .get("visibility") + .map(text) + .transpose()? + .unwrap_or("private"); + if !matches!(visibility, "public" | "private") { + return Err(invalid()); + } + let exports = manifest["exports"].as_object().ok_or_else(invalid)?; + let dependencies = manifest["dependencies"].as_object().ok_or_else(invalid)?; + if exports.is_empty() || exports.len() > 64 || dependencies.len() > 64 { + return Err(invalid()); + } + for (alias, reference) in dependencies { + if !slug(alias) { + return Err(invalid()); + } + validate_reference(reference)?; + } + let input_files = value["files"] + .as_array() + .filter(|v| !v.is_empty() && v.len() <= 128) + .ok_or_else(invalid)?; + let mut files = BTreeMap::new(); + let mut folded = BTreeSet::new(); + let mut total = 0; + for file in input_files { + exact(file, &["path", "encoding", "content"])?; + let path = text(&file["path"])?; + let content = text(&file["content"])?; + if !package_path(path) || content.len() > LIMIT || !folded.insert(path.to_ascii_lowercase()) + { + return Err(invalid()); + } + let bytes = match text(&file["encoding"])? { + "utf8" => content.as_bytes().to_vec(), + "base64" => decode_base64(content)?, + _ => return Err(invalid()), + }; + total += bytes.len(); + if bytes.len() > FILE_LIMIT || total > TOTAL_LIMIT { + return Err(invalid()); + } + files.insert(path.to_owned(), bytes); + } + for path in &folded { + if folded + .iter() + .any(|other| other.starts_with(&format!("{path}/"))) + { + return Err(invalid()); + } + } + for (name, path) in exports { + let name_ok = !name.is_empty() + && name.len() <= 64 + && name.as_bytes()[0].is_ascii_alphabetic() + && name + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"_-".contains(&b)) + && !matches!(name.as_str(), "prototype" | "constructor" | "__proto__"); + if !name_ok || !files.contains_key(text(path)?) { + return Err(invalid()); + } + } + let mut manifest = manifest.clone(); + manifest["visibility"] = json!(visibility); + let inventory: Vec = files + .iter() + .map(|(path, bytes)| json!({"path":path,"size":bytes.len(),"sha256":sha(bytes)})) + .collect(); + let artifact = json!({"schema":"prose-package-v1","manifest":manifest,"files":files.iter().map(|(path,bytes)|json!({"path":path,"encoding":"base64","content":base64(bytes)})).collect::>()}); + let mut bytes = serde_json::to_vec(&artifact).map_err(|_| invalid())?; + bytes.push(b'\n'); + if bytes.len() > LIMIT { + return Err(invalid()); + } + let reference = json!({"organization":manifest["organization"],"package":manifest["package"],"version":manifest["version"],"sha256":sha(&bytes)}); + Ok(Prepared { + bytes, + inventory: json!(inventory), + reference, + visibility: visibility.into(), + files: files.into_iter().collect(), + }) +} +// JSON's integer-valued numeric spellings (8, 8.0, 8e0) denote the same size. +#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] +fn receipt_size(value: &Value) -> Option { + let limit = f64::from(u32::try_from(FILE_LIMIT).ok()?); + value + .as_f64() + .filter(|size| *size >= 0.0 && *size <= limit && size.fract() == 0.0) + .map(|size| size as u64) +} +fn receipt(value: &Value) -> Result<(), RunnerError> { + exact( + value, + &[ + "schema", + "organizationId", + "reference", + "visibility", + "inventory", + ], + )?; + let uuid = text(&value["organizationId"])?; + if uuid.len() != 36 + || !uuid.bytes().enumerate().all(|(i, b)| { + if [8, 13, 18, 23].contains(&i) { + b == b'-' + } else { + b.is_ascii_digit() || (b'a'..=b'f').contains(&b) + } + }) + || value["schema"] != "prose-publication-v1" + || !matches!(text(&value["visibility"])?, "private" | "public") + { + return Err(invalid()); + } + validate_reference(&value["reference"])?; + let inventory = value["inventory"] + .as_array() + .filter(|v| !v.is_empty() && v.len() <= 128) + .ok_or_else(invalid)?; + let mut previous = ""; + let mut folded = BTreeSet::new(); + let mut total = 0_u64; + for file in inventory { + exact(file, &["path", "size", "sha256"])?; + let path = text(&file["path"])?; + let size = receipt_size(&file["size"]).ok_or_else(invalid)?; + total = total.checked_add(size).ok_or_else(invalid)?; + if !package_path(path) + || path <= previous + || !folded.insert(path.to_ascii_lowercase()) + || size > FILE_LIMIT as u64 + || total > TOTAL_LIMIT as u64 + || !digest(text(&file["sha256"])?) + { + return Err(invalid()); + } + previous = path; + } + for path in &folded { + if folded + .iter() + .any(|other| other.starts_with(&format!("{path}/"))) + { + return Err(invalid()); + } + } + Ok(()) +} +fn agrees(value: &Value, prepared: &Prepared) -> bool { + value["reference"] == prepared.reference + && value["visibility"] == prepared.visibility + && value["inventory"].as_array().is_some_and(|inventory| { + let expected = prepared.inventory.as_array().expect("prepared inventory"); + inventory.len() == expected.len() + && inventory.iter().zip(expected).all(|(left, right)| { + left["path"] == right["path"] + && left["sha256"] == right["sha256"] + && receipt_size(&left["size"]) == receipt_size(&right["size"]) + }) + }) +} + +pub(crate) fn parse(args: &[String]) -> Result<(PackageCommand, bool), RunnerError> { + let fail = || RunnerError::invocation("Invalid package command or options."); + let [operation, target, tail @ ..] = args else { + return Err(fail()); + }; + if target.is_empty() || target.starts_with('-') { + return Err(fail()); + } + let mut options = BTreeMap::new(); + let mut public = false; + let mut json = false; + let mut index = 0; + while index < tail.len() { + let name = tail[index].as_str(); + if name == "--json" { + if json || index + 1 != tail.len() { + return Err(fail()); + } + json = true; + index += 1; + continue; + } + if name == "--public" { + if public || operation != "publish" { + return Err(fail()); + } + public = true; + index += 1; + continue; + } + let allowed = match operation.as_str() { + "publish" => matches!(name, "--organization" | "--name" | "--version"), + "fetch" => matches!(name, "--output-dir" | "--sha256"), + "list" => name == "--cursor", + _ => false, + }; + if !allowed + || index + 1 >= tail.len() + || tail[index + 1].is_empty() + || tail[index + 1].starts_with('-') + || options.insert(name, tail[index + 1].clone()).is_some() + { + return Err(fail()); + } + index += 2; + } + let mut required = |key| options.remove(key).ok_or_else(fail); + let command = match operation.as_str() { + "publish" => PackageCommand::Publish { + source: target.clone(), + organization: required("--organization")?, + name: required("--name")?, + version: required("--version")?, + public, + }, + "fetch" => { + let destination = required("--output-dir")?; + PackageCommand::Fetch { + reference: target.clone(), + destination, + sha256: options.remove("--sha256"), + } + } + "list" => PackageCommand::List { + organization: target.clone(), + cursor: options.remove("--cursor"), + }, + "withdraw" => PackageCommand::Withdraw { + reference: target.clone(), + }, + _ => return Err(fail()), + }; + let valid = match &command { + PackageCommand::Publish { + organization, + name, + version: v, + .. + } => slug(organization) && slug(name) && version(v), + PackageCommand::Fetch { + reference, sha256, .. + } => reference_parts(reference).is_ok() && sha256.as_deref().is_none_or(digest), + PackageCommand::List { + organization, + cursor, + } => slug(organization) && cursor.as_deref().is_none_or(valid_cursor), + PackageCommand::Withdraw { reference } => reference_parts(reference).is_ok(), + }; + if !valid { + return Err(fail()); + } + Ok((command, json)) +} +fn valid_cursor(value: &str) -> bool { + if value.len() > 210 { + return false; + } + let Some(rest) = value.strip_prefix("public:") else { + return false; + }; + let Some((package, version_value)) = rest.split_once(':') else { + return false; + }; + !package.is_empty() + && package + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') + && !version_value.is_empty() + && version_value + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b".+-".contains(&b)) +} +fn encode_query(value: &str) -> String { + value + .bytes() + .map(|b| { + if b.is_ascii_alphanumeric() || b"-._~".contains(&b) { + char::from(b).to_string() + } else { + format!("%{b:02X}") + } + }) + .collect() +} +fn absolute(path: &Path, cwd: &Path) -> Result { + let value = if path.is_absolute() { + path.to_owned() + } else { + cwd.join(path) + }; + if value + .components() + .any(|c| matches!(c, Component::ParentDir | Component::Prefix(_))) + { + return Err(invalid()); + } + Ok(value) +} +#[cfg(unix)] +fn open_safe(path: &Path, directory: bool) -> Result { + use rustix::fs::{CWD, Mode, OFlags, openat}; + let mut file = File::from( + openat( + CWD, + "/", + OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC, + Mode::empty(), + ) + .map_err(|_| invalid())?, + ); + let parts: Vec<_> = path + .components() + .filter_map(|c| match c { + Component::Normal(v) => Some(v), + _ => None, + }) + .collect(); + for (i, part) in parts.iter().enumerate() { + let is_dir = i + 1 < parts.len() || directory; + let flags = OFlags::RDONLY + | OFlags::NOFOLLOW + | OFlags::CLOEXEC + | OFlags::NONBLOCK + | if is_dir { + OFlags::DIRECTORY + } else { + OFlags::empty() + }; + file = File::from(openat(&file, *part, flags, Mode::empty()).map_err(|_| invalid())?); + } + Ok(file) +} +#[cfg(not(unix))] +fn open_safe(_: &Path, _: bool) -> Result { + Err(RunnerError::config( + "Safe package filesystem access is unavailable on this platform.", + )) +} +fn bounded_file(path: &Path, limit: usize) -> Result, RunnerError> { + let file = open_safe(path, false)?; + let before = file.metadata().map_err(|_| invalid())?; + if !before.is_file() || before.len() > limit as u64 { + return Err(invalid()); + } + let mut bytes = Vec::new(); + (&file) + .take(limit as u64 + 1) + .read_to_end(&mut bytes) + .map_err(|_| invalid())?; + let after = file.metadata().map_err(|_| invalid())?; + if bytes.len() > limit + || before.len() != after.len() + || after.len() != bytes.len() as u64 + || before.modified().ok() != after.modified().ok() + { + return Err(invalid()); + } + Ok(bytes) +} +fn source_package( + source: &str, + organization: &str, + name: &str, + version: &str, + public: bool, + cwd: &Path, +) -> Result { + let source = absolute(Path::new(source), cwd)?; + let meta = open_safe(&source, false)? + .metadata() + .map_err(|_| invalid())?; + let mut files = Vec::new(); + let (exports, dependencies) = if meta.is_dir() { + let bytes = bounded_file(&source.join("prose-package.json"), LIMIT)?; + let manifest = serde_json::from_slice::(&bytes) + .map_err(|_| invalid())? + .0; + exact(&manifest, &["schema", "files", "exports", "dependencies"])?; + if manifest["schema"] != "prose-package-directory-v1" { + return Err(invalid()); + } + let paths = manifest["files"] + .as_array() + .filter(|v| !v.is_empty() && v.len() <= 128) + .ok_or_else(invalid)?; + let mut total = 0; + for path in paths { + let path = text(path)?; + if !package_path(path) { + return Err(invalid()); + } + let bytes = bounded_file(&source.join(path), FILE_LIMIT)?; + total += bytes.len(); + if total > TOTAL_LIMIT { + return Err(invalid()); + } + files.push(json!({"path":path,"encoding":"base64","content":base64(&bytes)})); + } + ( + manifest["exports"].clone(), + manifest["dependencies"].clone(), + ) + } else if meta.is_file() { + let name = source + .file_name() + .and_then(|v| v.to_str()) + .filter(|v| package_path(v)) + .ok_or_else(invalid)?; + let bytes = bounded_file(&source, FILE_LIMIT)?; + files.push(json!({"path":name,"encoding":"base64","content":base64(&bytes)})); + (json!({"default":name}), json!({})) + } else { + return Err(invalid()); + }; + prepare( + &json!({"schema":"prose-package-v1","manifest":{"organization":organization,"package":name,"version":version,"visibility":if public{"public"}else{"private"},"exports":exports,"dependencies":dependencies},"files":files}), + ) +} + +#[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))] +fn materialize( + destination: &Path, + prepared: &Prepared, + receipt: &Value, +) -> Result<(), RunnerError> { + use rustix::fs::{AtFlags, Mode, OFlags, RenameFlags, mkdirat, openat, renameat_with}; + use std::os::unix::fs::MetadataExt as _; + let parent = destination.parent().ok_or_else(invalid)?; + let name = destination.file_name().ok_or_else(invalid)?; + let parent_fd = open_safe(parent, true)?; + let temporary = format!(".prose-package-{}", uuid::Uuid::now_v7()); + mkdirat( + &parent_fd, + temporary.as_str(), + Mode::RUSR | Mode::WUSR | Mode::XUSR, + ) + .map_err(|_| invalid())?; + let temp = File::from( + openat( + &parent_fd, + temporary.as_str(), + OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::empty(), + ) + .map_err(|_| invalid())?, + ); + let mut written: Vec<(File, String, rustix::fs::Stat)> = Vec::new(); + let mut created_directories: Vec<(File, String, rustix::fs::Stat)> = Vec::new(); + let temp_identity = rustix::fs::fstat(&temp).map_err(|_| invalid())?; + let mut directories = BTreeSet::new(); + let result = (|| { + let mut all = prepared.files.clone(); + all.push(( + ".prose-package-receipt.json".into(), + serde_json::to_vec(receipt).map_err(|_| invalid())?, + )); + for (path, bytes) in all { + let mut current = temp.try_clone().map_err(|_| invalid())?; + let components: Vec<_> = path.split('/').collect(); + let mut prefix = String::new(); + for part in &components[..components.len() - 1] { + if !prefix.is_empty() { + prefix.push('/'); + } + prefix.push_str(part); + if directories.insert(prefix.clone()) { + let owner = current.try_clone().map_err(|_| invalid())?; + mkdirat(¤t, *part, Mode::RUSR | Mode::WUSR | Mode::XUSR) + .map_err(|_| invalid())?; + let identity = rustix::fs::statat(&owner, *part, AtFlags::SYMLINK_NOFOLLOW) + .map_err(|_| invalid())?; + created_directories.push((owner, (*part).to_owned(), identity)); + } + current = File::from( + openat( + ¤t, + *part, + OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::empty(), + ) + .map_err(|_| invalid())?, + ); + } + let mut output = File::from( + openat( + ¤t, + *components.last().ok_or_else(invalid)?, + OFlags::WRONLY + | OFlags::CREATE + | OFlags::EXCL + | OFlags::NOFOLLOW + | OFlags::CLOEXEC, + Mode::RUSR | Mode::WUSR, + ) + .map_err(|_| invalid())?, + ); + written.push(( + current, + (*components.last().ok_or_else(invalid)?).to_owned(), + rustix::fs::fstat(&output).map_err(|_| invalid())?, + )); + output.write_all(&bytes).map_err(|_| invalid())?; + output.sync_all().map_err(|_| invalid())?; + } + // Reauthenticate the private directory name immediately before publication. + let named = File::from( + openat( + &parent_fd, + temporary.as_str(), + OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::empty(), + ) + .map_err(|_| invalid())?, + ); + let expected = temp.metadata().map_err(|_| invalid())?; + let observed = named.metadata().map_err(|_| invalid())?; + if (expected.dev(), expected.ino()) != (observed.dev(), observed.ino()) { + return Err(invalid()); + } + // NOREPLACE is the publication boundary: a concurrent target is never replaced. + renameat_with( + &parent_fd, + temporary.as_str(), + &parent_fd, + name, + RenameFlags::NOREPLACE, + ) + .map_err(|_| invalid())?; + Ok(()) + })(); + if result.is_err() { + for (parent, name, identity) in written.iter().rev() { + remove_owned_entry(parent, name, identity, AtFlags::empty()); + } + for (parent, name, identity) in created_directories.iter().rev() { + remove_owned_entry(parent, name, identity, AtFlags::REMOVEDIR); + } + remove_owned_entry( + &parent_fd, + temporary.as_str(), + &temp_identity, + AtFlags::REMOVEDIR, + ); + } + result +} +#[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))] +fn remove_owned_entry( + parent: &File, + name: &str, + identity: &rustix::fs::Stat, + flags: rustix::fs::AtFlags, +) { + use rustix::fs::{AtFlags, statat, unlinkat}; + if statat(parent, name, AtFlags::SYMLINK_NOFOLLOW).is_ok_and(|current| { + current.st_dev == identity.st_dev + && current.st_ino == identity.st_ino + && current.st_mode == identity.st_mode + }) { + let _ = unlinkat(parent, name, flags); + } +} + +#[cfg(not(any(target_os = "linux", target_os = "android", target_vendor = "apple")))] +fn materialize(_: &Path, _: &Prepared, _: &Value) -> Result<(), RunnerError> { + Err(RunnerError::config( + "Atomic package materialization is unavailable on this platform.", + )) +} + +pub(crate) fn execute( + command: &PackageCommand, + environment: ServiceEnvironment, + cwd: &Path, + mode: OutputMode, + cancellation: &CancellationToken, +) -> CommandOutcome { + let operation = match command { + PackageCommand::Publish { .. } => "publish", + PackageCommand::Fetch { .. } => "fetch", + PackageCommand::List { .. } => "list", + PackageCommand::Withdraw { .. } => "withdraw", + }; + let result = (|| -> Result { + // Validate and collect local publication bytes before accessing credentials. + let prepared = if let PackageCommand::Publish { + source, + organization, + name, + version, + public, + } = command + { + Some(source_package( + source, + organization, + name, + version, + *public, + cwd, + )?) + } else { + None + }; + let destination = if let PackageCommand::Fetch { destination, .. } = command { + let path = absolute(Path::new(destination), cwd)?; + open_safe(path.parent().ok_or_else(invalid)?, true)?; + match std::fs::symlink_metadata(&path) { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + _ => return Err(invalid()), + } + Some(path) + } else { + None + }; + let mut session = Session::registry(cancellation, environment)?; + let required = matches!( + command, + PackageCommand::Publish { .. } | PackageCommand::Withdraw { .. } + ); + let token = session.registry_credential(required)?; + let output: Value = (match command { + PackageCommand::Publish { + organization, name, .. + } => { + let prepared = prepared.ok_or_else(invalid)?; + let path = + format!("/registry/v1/organizations/{organization}/packages/{name}/versions"); + let bytes = session.registry_request( + "POST", + &path, + token.as_deref(), + Some(&prepared.bytes), + &[200, 201], + )?; + let value: Value = serde_json::from_slice(&bytes).map_err(|_| protocol())?; + receipt(&value).map_err(|_| protocol())?; + if !agrees(&value, &prepared) { + return Err(protocol()); + } + Ok(value) + } + PackageCommand::Fetch { + reference, sha256, .. + } => { + let (organization, package, version) = reference_parts(reference)?; + let path = format!( + "/registry/v1/organizations/{organization}/packages/{package}/versions/{version}" + ); + let bytes = + session.registry_request("GET", &path, token.as_deref(), None, &[200])?; + let value: Value = serde_json::from_slice(&bytes).map_err(|_| protocol())?; + receipt(&value).map_err(|_| protocol())?; + if !reference_matches(&value, organization, package, version) + || sha256 + .as_ref() + .is_some_and(|hash| value["reference"]["sha256"] != *hash) + { + return Err(protocol()); + } + let bytes = session.registry_request( + "GET", + &format!("{path}/artifact"), + token.as_deref(), + None, + &[200], + )?; + if value["reference"]["sha256"] != sha(&bytes) { + return Err(protocol()); + } + let artifact: Value = serde_json::from_slice(&bytes).map_err(|_| protocol())?; + let prepared = prepare(&artifact).map_err(|_| protocol())?; + if prepared.bytes != bytes || !agrees(&value, &prepared) { + return Err(protocol()); + } + if token + .as_ref() + .is_some_and(|token| value.to_string().contains(token)) + { + return Err(protocol()); + } + cancellation_check(cancellation)?; + materialize(&destination.ok_or_else(invalid)?, &prepared, &value)?; + Ok(value) + } + PackageCommand::List { + organization, + cursor, + } => { + let path = format!( + "/registry/v1/organizations/{organization}/packages{}", + cursor + .as_ref() + .map_or(String::new(), |v| format!("?cursor={}", encode_query(v))) + ); + let bytes = + session.registry_request("GET", &path, token.as_deref(), None, &[200])?; + let value: Value = serde_json::from_slice(&bytes).map_err(|_| protocol())?; + exact(&value, &["packages", "nextCursor"]).map_err(|_| protocol())?; + let packages = value["packages"] + .as_array() + .filter(|v| v.len() <= 25) + .ok_or_else(protocol)?; + for item in packages { + receipt(item).map_err(|_| protocol())?; + if item["visibility"] != "public" + || item["reference"]["organization"] != *organization + { + return Err(protocol()); + } + } + if !value["nextCursor"].is_null() + && !value["nextCursor"].as_str().is_some_and(valid_cursor) + { + return Err(protocol()); + } + Ok(value) + } + PackageCommand::Withdraw { reference } => { + let (organization, package, version) = reference_parts(reference)?; + let path = format!( + "/registry/v1/organizations/{organization}/packages/{package}/versions/{version}/withdraw" + ); + let bytes = session.registry_request( + "POST", + &path, + token.as_deref(), + Some(b"{}"), + &[200], + )?; + let value: Value = serde_json::from_slice(&bytes).map_err(|_| protocol())?; + exact(&value, &["receipt", "withdrawn"]).map_err(|_| protocol())?; + receipt(&value["receipt"]).map_err(|_| protocol())?; + if value["withdrawn"] != true + || !reference_matches(&value["receipt"], organization, package, version) + { + return Err(protocol()); + } + Ok(value) + } + })?; + if token + .as_ref() + .is_some_and(|token| output.to_string().contains(token)) + { + return Err(protocol()); + } + Ok(output) + })(); + let (value, error) = match result { + Ok(value) => (value, None), + Err(error) => (Value::Null, Some(error)), + }; + let exit = error.as_ref().map_or(0, |e| e.exit_code); + if mode == OutputMode::Human { + let marker = if environment == ServiceEnvironment::Staging { + "OpenProse staging environment\n" + } else { + "" + }; + if let Some(error) = error { + CommandOutcome::human( + "", + format!( + "{marker}{}: {}\n{}\n", + error.code, + error.message, + error.human_action() + ), + exit, + ) + } else { + let format_reference = |receipt: &Value| { + format!( + "{}/{}@{}", + receipt["reference"]["organization"].as_str().unwrap_or(""), + receipt["reference"]["package"].as_str().unwrap_or(""), + receipt["reference"]["version"].as_str().unwrap_or("") + ) + }; + let stdout = if operation == "list" { + let mut text = String::new(); + for receipt in value["packages"].as_array().unwrap() { + text.push_str(&format_reference(receipt)); + text.push('\n'); + } + if let Some(cursor) = value["nextCursor"].as_str() { + text.push_str("Next cursor: "); + text.push_str(cursor); + text.push('\n'); + } + text + } else { + let receipt = if operation == "withdraw" { + &value["receipt"] + } else { + &value + }; + format!( + "OpenProse {} package {operation}: {}\n", + environment.name(), + format_reference(receipt) + ) + }; + CommandOutcome::human(stdout, marker, 0) + } + } else { + CommandOutcome::json( + json!({"schema":"openprose.package-operation/1","environment":environment.name(),"operation":operation,"result":value,"problem":error}), + exit, + ) + } +} +fn cancellation_check(cancellation: &CancellationToken) -> Result<(), RunnerError> { + if cancellation.is_cancelled() { + Err(RunnerError::catalog(ErrorCode::Cancelled)) + } else { + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + fn fixture_bytes(name: &str) -> &'static [u8] { + match name { + "hash-vectors.json" => { + include_bytes!("../../../../shared/fixtures/registry/hash-vectors.json") + } + "single-file.json" => { + include_bytes!("../../../../shared/fixtures/registry/single-file.json") + } + "single-file.canonical.json" => { + include_bytes!("../../../../shared/fixtures/registry/single-file.canonical.json") + } + "single-file.receipt.json" => { + include_bytes!("../../../../shared/fixtures/registry/single-file.receipt.json") + } + "directory.json" => { + include_bytes!("../../../../shared/fixtures/registry/directory.json") + } + "directory.canonical.json" => { + include_bytes!("../../../../shared/fixtures/registry/directory.canonical.json") + } + "directory.receipt.json" => { + include_bytes!("../../../../shared/fixtures/registry/directory.receipt.json") + } + _ => panic!("unknown registry fixture"), + } + } + fn fixture(name: &str) -> Value { + serde_json::from_slice(fixture_bytes(name)).unwrap() + } + #[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))] + #[test] + fn cleanup_leaves_replacement_files_directories_and_symlinks_untouched() { + use rustix::fs::{AtFlags, statat}; + use std::os::unix::fs::symlink; + let root = tempfile::tempdir().unwrap(); + let cwd = std::fs::canonicalize(root.path()).unwrap(); + let parent = open_safe(&cwd, true).unwrap(); + std::fs::write(cwd.join("owned"), b"owned").unwrap(); + let identity = statat(&parent, "owned", AtFlags::SYMLINK_NOFOLLOW).unwrap(); + std::fs::rename(cwd.join("owned"), cwd.join("retained-original")).unwrap(); + std::fs::write(cwd.join("owned"), b"replacement").unwrap(); + remove_owned_entry(&parent, "owned", &identity, AtFlags::empty()); + assert_eq!(std::fs::read(cwd.join("owned")).unwrap(), b"replacement"); + std::fs::remove_file(cwd.join("owned")).unwrap(); + symlink(cwd.join("retained-original"), cwd.join("owned")).unwrap(); + remove_owned_entry(&parent, "owned", &identity, AtFlags::empty()); + assert!( + std::fs::symlink_metadata(cwd.join("owned")) + .unwrap() + .file_type() + .is_symlink() + ); + std::fs::create_dir(cwd.join("directory")).unwrap(); + let identity = statat(&parent, "directory", AtFlags::SYMLINK_NOFOLLOW).unwrap(); + std::fs::rename(cwd.join("directory"), cwd.join("retained-directory")).unwrap(); + std::fs::create_dir(cwd.join("directory")).unwrap(); + remove_owned_entry(&parent, "directory", &identity, AtFlags::REMOVEDIR); + assert!(cwd.join("directory").is_dir()); + } + + #[test] + fn receipt_integer_spelling_matches_javascript_contract() { + let prepared = prepare(&fixture("single-file.json")).unwrap(); + let mut value = fixture("single-file.receipt.json"); + value["inventory"][0]["size"] = serde_json::from_str("8.0").unwrap(); + receipt(&value).unwrap(); + assert!(agrees(&value, &prepared)); + value["inventory"][0]["size"] = json!(8.5); + assert!(receipt(&value).is_err()); + } + #[test] + fn normative_canonical_bytes_hashes_inventory_and_receipts() { + for vector in fixture("hash-vectors.json").as_array().unwrap() { + let prepared = prepare(&fixture(vector["fixture"].as_str().unwrap())).unwrap(); + assert_eq!( + prepared.bytes, + fixture_bytes(vector["canonical"].as_str().unwrap()) + ); + assert_eq!( + prepared.bytes.len() as u64, + vector["artifactBytes"].as_u64().unwrap() + ); + assert_eq!(prepared.reference["sha256"], vector["sha256"]); + assert_eq!(prepared.inventory, vector["inventory"]); + let receipt_value = fixture( + &vector["fixture"] + .as_str() + .unwrap() + .replace(".json", ".receipt.json"), + ); + receipt(&receipt_value).unwrap(); + assert!(agrees(&receipt_value, &prepared)); + } + } + #[test] + fn rejects_hostile_paths_fields_collisions_content_and_unpinned_dependencies() { + for path in [ + "../a", + "a/../b", + "a/.git/x", + "a\\b", + "/a", + "a/", + "a//b", + "a.", + "con.md", + "a/NUL.txt", + "secrets.json", + "a.key", + "node_modules/a", + "a b", + "é.md", + ] { + let mut value = fixture("single-file.json"); + value["files"][0]["path"] = json!(path); + value["manifest"]["exports"]["default"] = json!(path); + assert!(prepare(&value).is_err(), "{path}"); + } + for (key, value) in [ + ("encoding", json!("zip")), + ("content", json!(null)), + ("unknown", json!(true)), + ] { + let mut source = fixture("single-file.json"); + source["files"][0][key] = value; + assert!(prepare(&source).is_err()); + } + for content in ["Zg=", "Zh==", "Zg==AAAA", "Zg===", "????"] { + assert!(decode_base64(content).is_err(), "{content}"); + } + for paths in [["a", "A"], ["a", "a/b"]] { + let mut source = fixture("single-file.json"); + source["files"] = json!([{"path":paths[0],"encoding":"utf8","content":""},{"path":paths[1],"encoding":"utf8","content":""}]); + source["manifest"]["exports"] = json!({"default":paths[0]}); + assert!(prepare(&source).is_err()); + } + let mut value = fixture("directory.json"); + value["manifest"]["dependencies"]["hello"]["version"] = json!("^1.0.0"); + assert!(prepare(&value).is_err()); + let mut value = fixture("single-file.receipt.json"); + value["inventory"][0]["size"] = json!(FILE_LIMIT + 1); + assert!(receipt(&value).is_err()); + } + #[test] + fn canonical_base64_preserves_binary_and_semver_is_exact() { + let bytes: Vec<_> = (0..=255).collect(); + assert_eq!(decode_base64(&base64(&bytes)).unwrap(), bytes); + for value in ["0.0.0", "1.0.0+build", "1.2.3-alpha-1.0+build.01"] { + assert!(version(value), "{value}"); + } + for value in [ + "1.0", + "01.0.0", + "1.0.0-01", + "1.0.0+", + "1.0.0+foo+bar", + "1.0.0\n", + "^1.0.0", + ] { + assert!(!version(value), "{value}"); + } + } + #[test] + fn local_directory_manifest_is_explicit_and_rejects_duplicate_keys() { + let root = tempfile::tempdir().unwrap(); + let cwd = std::fs::canonicalize(root.path()).unwrap(); + std::fs::write(cwd.join("hello.md"), b"# Hello\n").unwrap(); + std::fs::write(cwd.join("prose-package.json"),r#"{"schema":"prose-package-directory-v1","files":["hello.md"],"exports":{"default":"hello.md"},"dependencies":{}}"#).unwrap(); + std::fs::write(cwd.join("not-included.secret"), b"ignore").unwrap(); + let result = source_package(".", "example", "hello", "1.0.0", false, &cwd).unwrap(); + assert_eq!( + result.reference["sha256"], + "fac5e538f24818de99c563a5d8c547a5fae1fbb7047e68dbe3cca9ef732ec61e" + ); + std::fs::write(cwd.join("prose-package.json"),r#"{"schema":"prose-package-directory-v1","files":["hello.md"],"exports":{"default":"hello.md","default":"hello.md"},"dependencies":{}}"#).unwrap(); + assert!(source_package(".", "example", "hello", "1.0.0", false, &cwd).is_err()); + } + #[cfg(unix)] + #[test] + fn rejects_source_and_ancestor_symlinks_and_nonregular_files() { + use std::os::unix::fs::symlink; + let root = tempfile::tempdir().unwrap(); + let cwd = std::fs::canonicalize(root.path()).unwrap(); + std::fs::create_dir(cwd.join("real")).unwrap(); + std::fs::write(cwd.join("real/hello.md"), b"hi").unwrap(); + symlink(cwd.join("real/hello.md"), cwd.join("link.md")).unwrap(); + symlink(cwd.join("real"), cwd.join("linked")).unwrap(); + assert!(source_package("link.md", "example", "hello", "1.0.0", false, &cwd).is_err()); + assert!( + source_package("linked/hello.md", "example", "hello", "1.0.0", false, &cwd).is_err() + ); + } + #[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))] + #[test] + fn materialization_is_exact_and_never_replaces_existing_directory() { + let root = tempfile::tempdir().unwrap(); + let cwd = std::fs::canonicalize(root.path()).unwrap(); + let prepared = prepare(&fixture("directory.json")).unwrap(); + let receipt = fixture("directory.receipt.json"); + let target = cwd.join("fetched"); + materialize(&target, &prepared, &receipt).unwrap(); + for (path, bytes) in &prepared.files { + assert_eq!(std::fs::read(target.join(path)).unwrap(), *bytes); + } + assert_eq!( + serde_json::from_slice::( + &std::fs::read(target.join(".prose-package-receipt.json")).unwrap() + ) + .unwrap(), + receipt + ); + assert!(materialize(&target, &prepared, &receipt).is_err()); + let empty = cwd.join("empty"); + std::fs::create_dir(&empty).unwrap(); + assert!(materialize(&empty, &prepared, &receipt).is_err()); + assert!(std::fs::read_dir(empty).unwrap().next().is_none()); + assert!(std::fs::read_dir(&cwd).unwrap().all(|entry| { + !entry + .unwrap() + .file_name() + .to_string_lossy() + .starts_with(".prose-package-") + })); + } +} diff --git a/cli/rust/crates/prose-runner-core/src/runner.rs b/cli/rust/crates/prose-runner-core/src/runner.rs index 4c5b9eb..a27815c 100644 --- a/cli/rust/crates/prose-runner-core/src/runner.rs +++ b/cli/rust/crates/prose-runner-core/src/runner.rs @@ -600,7 +600,7 @@ fn execute_runner_command( RunnerCommand::CleanupPrime(handle) => { execute_prime_cleanup(&handle, mode, clock, ids, &std::env::temp_dir()) } - RunnerCommand::AuthStatus | RunnerCommand::AuthLogin | RunnerCommand::AuthLogout | RunnerCommand::OrgList | RunnerCommand::EnvironmentShow | RunnerCommand::EnvironmentUse(_) | RunnerCommand::EnvironmentReset => { + RunnerCommand::AuthStatus | RunnerCommand::AuthLogin | RunnerCommand::AuthLogout | RunnerCommand::OrgList | RunnerCommand::EnvironmentShow | RunnerCommand::EnvironmentUse(_) | RunnerCommand::EnvironmentReset | RunnerCommand::Package(_) => { unreachable!("service commands are dispatched before harness operations") } } diff --git a/cli/rust/crates/prose-runner-core/src/service_account.rs b/cli/rust/crates/prose-runner-core/src/service_account.rs index e856582..bddba29 100644 --- a/cli/rust/crates/prose-runner-core/src/service_account.rs +++ b/cli/rust/crates/prose-runner-core/src/service_account.rs @@ -19,7 +19,7 @@ impl ServiceEnvironment { Self::Production } } - fn name(self) -> &'static str { + pub(crate) fn name(self) -> &'static str { match self { Self::Production => "production", Self::Staging => "staging", @@ -52,7 +52,7 @@ fn valid_token(token: &str) -> bool { }) } -struct Session { +pub(crate) struct Session { environment: ServiceEnvironment, fixture: Option, next: usize, @@ -65,6 +65,22 @@ impl Session { cancellation: &CancellationToken, environment: ServiceEnvironment, ) -> Result { + Self::bounded(cancellation, environment, LIMIT) + } + + pub(crate) fn registry( + cancellation: &CancellationToken, + environment: ServiceEnvironment, + ) -> Result { + Self::bounded(cancellation, environment, 16 * 1024 * 1024) + } + + fn bounded( + cancellation: &CancellationToken, + environment: ServiceEnvironment, + fixture_limit: u64, + ) -> Result { + let _ = fixture_limit; #[allow(unused_mut)] let mut fixture: Option = None; #[cfg(feature = "test-seams")] @@ -72,10 +88,10 @@ impl Session { let mut bytes = Vec::new(); std::fs::File::open(path) .map_err(|_| problem(ErrorCode::ServiceProtocolInvalid))? - .take(LIMIT + 1) + .take(fixture_limit + 1) .read_to_end(&mut bytes) .map_err(|_| problem(ErrorCode::ServiceProtocolInvalid))?; - if bytes.len() as u64 > LIMIT { + if bytes.len() as u64 > fixture_limit { return Err(problem(ErrorCode::ServiceProtocolInvalid)); } fixture = Some( @@ -620,11 +636,11 @@ fn native_store( return Err(problem(ErrorCode::CredentialStoreUnavailable)); } if operation == "get" { - let output = String::from_utf8(stdout) - .map_err(|_| problem(ErrorCode::CredentialStoreUnavailable))?; + let output = + String::from_utf8(stdout).map_err(|_| problem(ErrorCode::ServiceProtocolInvalid))?; let token = output.trim(); if !valid_token(token) { - return Err(problem(ErrorCode::CredentialStoreUnavailable)); + return Err(problem(ErrorCode::ServiceProtocolInvalid)); } Ok(Some(token.to_owned())) } else { @@ -642,6 +658,7 @@ pub fn is_service_command(command: &RunnerCommand) -> bool { | RunnerCommand::EnvironmentShow | RunnerCommand::EnvironmentUse(_) | RunnerCommand::EnvironmentReset + | RunnerCommand::Package(_) ) } @@ -697,6 +714,15 @@ pub fn execute_user_command( .as_deref() .unwrap_or(&selection.environment), ); + if let RunnerCommand::Package(command) = command { + return Ok(crate::registry::execute( + command, + selected, + &system.current_dir, + mode, + cancellation, + )); + } Ok(execute(command, selected, mode, cancellation)) } @@ -912,3 +938,178 @@ mod tests { assert!(parsed.globals.service_environment.is_none()); } } + +impl Session { + pub(crate) fn registry_credential( + &mut self, + required: bool, + ) -> Result, RunnerError> { + self.check()?; + let token = match std::env::var(self.environment.variable()) { + Ok(value) => { + if value.is_empty() { + None + } else { + Some(value) + } + } + Err(std::env::VarError::NotPresent) => None, + Err(std::env::VarError::NotUnicode(_)) => { + return Err(problem(ErrorCode::ServiceProtocolInvalid)); + } + }; + let token = match token { + Some(token) => Some(token), + None => match self.store("get", None) { + Ok(token) => token, + Err(error) if !required && error.code == ErrorCode::CredentialStoreUnavailable => { + None + } + Err(error) => return Err(error), + }, + }; + if token.as_deref().is_some_and(|token| !valid_token(token)) { + return Err(problem(ErrorCode::ServiceProtocolInvalid)); + } + if required && token.is_none() { + return Err(problem(ErrorCode::ServiceAuthRequired)); + } + Ok(token) + } + + pub(crate) fn registry_request( + &mut self, + method: &str, + path: &str, + token: Option<&str>, + body: Option<&[u8]>, + accepted: &[u16], + ) -> Result, RunnerError> { + use sha2::{Digest, Sha256}; + self.check()?; + if !path.starts_with("/registry/v1/organizations/") + || body.is_some_and(|value| value.len() > crate::registry::LIMIT) + { + return Err(problem(ErrorCode::ServiceProtocolInvalid)); + } + let (status, bytes) = if let Some(fixture) = &self.fixture { + let expected = std::env::var(self.environment.variable()) + .ok() + .filter(|v| !v.is_empty()) + .or_else(|| { + if fixture["storeAvailable"] == false { + return None; + } + let value = if fixture.get("credentials").is_some() { + &fixture["credentials"][self.environment.name()] + } else { + &fixture["credential"] + }; + value.as_str().map(str::to_owned) + }); + if token != expected.as_deref() { + return Err(problem(ErrorCode::ServiceProtocolInvalid)); + } + let exchange = fixture["exchanges"] + .get(self.next) + .ok_or_else(|| problem(ErrorCode::ServiceProtocolInvalid))?; + self.next += 1; + if exchange["method"] != method + || exchange["path"] != path + || exchange + .get("origin") + .is_some_and(|v| v != self.environment.origin()) + { + return Err(problem(ErrorCode::ServiceProtocolInvalid)); + } + let sent = body.unwrap_or_default(); + for key in ["expectBody", "expectedBody"] { + if let Some(value) = exchange.get(key) { + if value.as_str().map(str::as_bytes) != Some(sent) { + return Err(problem(ErrorCode::ServiceProtocolInvalid)); + } + } + } + for key in ["expectBodySha256", "expectedSha256"] { + if exchange.get(key).is_some_and(|v| { + v.as_str() != Some(format!("{:x}", Sha256::digest(sent)).as_str()) + }) { + return Err(problem(ErrorCode::ServiceProtocolInvalid)); + } + } + let status = exchange["status"] + .as_u64() + .and_then(|v| u16::try_from(v).ok()) + .unwrap_or(0); + if !accepted.contains(&status) { + return Err(registry_status(status)); + } + let bytes = if path.ends_with("/artifact") { + exchange["body"] + .as_str() + .ok_or_else(|| problem(ErrorCode::ServiceProtocolInvalid))? + .as_bytes() + .to_vec() + } else { + serde_json::to_vec(&exchange["body"]) + .map_err(|_| problem(ErrorCode::ServiceProtocolInvalid))? + }; + ( + exchange["status"] + .as_u64() + .and_then(|v| u16::try_from(v).ok()) + .unwrap_or(0), + bytes, + ) + } else { + let agent = ureq::AgentBuilder::new() + .timeout(Duration::from_secs(10)) + .redirects(0) + .build(); + let mut request = agent + .request(method, &format!("{}{path}", self.environment.origin())) + .set("Accept", "application/json"); + if let Some(token) = token { + request = request.set("Authorization", &format!("Bearer {token}")); + } + let response = if let Some(body) = body { + request + .set("Content-Type", "application/json") + .send_bytes(body) + } else { + request.call() + }; + let (Ok(response) | Err(ureq::Error::Status(_, response))) = response else { + return Err(self.transport_failure()); + }; + self.check()?; + let status = response.status(); + // Do not retain or emit service error bodies. + if !accepted.contains(&status) { + return Err(registry_status(status)); + } + let mut bytes = Vec::new(); + response + .into_reader() + .take(crate::registry::LIMIT as u64 + 1) + .read_to_end(&mut bytes) + .map_err(|_| self.transport_failure())?; + (status, bytes) + }; + self.check()?; + if !accepted.contains(&status) { + return Err(registry_status(status)); + } + if bytes.len() > crate::registry::LIMIT { + return Err(problem(ErrorCode::ServiceProtocolInvalid)); + } + Ok(bytes) + } +} +fn registry_status(status: u16) -> RunnerError { + problem(match status { + 401 | 403 => ErrorCode::ServiceAuthRequired, + 409 => ErrorCode::ServiceProtocolInvalid, + _ => ErrorCode::ServiceUnavailable, + }) +} diff --git a/cli/shared/fixtures/registry/FORMAT.md b/cli/shared/fixtures/registry/FORMAT.md new file mode 100644 index 0000000..c29060a --- /dev/null +++ b/cli/shared/fixtures/registry/FORMAT.md @@ -0,0 +1,80 @@ +# Registry package protocol v1 + +This independent registry does not change the operator kernel release format. +A publication is one bounded JSON envelope (`prose-package-v1`); no archives, +source evaluation, Markdown interpretation, link following, or dependency solving. +`fixtures/single-file.json` and `directory.json` are normative request examples. +`*.receipt.json` and `hash-vectors.json` provide pinned interoperability examples. + +The request has exactly `schema`, `manifest`, and `files`. The manifest has +`organization`, `package`, `version`, `exports`, `dependencies`, and optional +`visibility` (omission means `private`; only `private`/`public` accepted). Names +are lowercase ASCII alphanumeric/hyphen slugs, 1–63 characters with no terminal +hyphen. Versions are exact SemVer 2.0, including prerelease/build metadata. The +complete version string is the immutable version key; there is no range/latest +resolution or build-metadata equivalence. Visibility is immutable per published +version; neither a retry nor a later request changes existing bytes or visibility. + +Exports are a nonempty object of `default` and/or named export keys to exact, +case-sensitive included paths. Names match `[A-Za-z][A-Za-z0-9_-]{0,63}` excluding +prototype control names. Dependencies map slug aliases to exact references: +`{organization,package,version,sha256}`. SHA-256 is 64 lowercase hexadecimal +characters. References pin the whole package artifact, not an individual file. +The package contains only explicitly included files; there is no implicit glob, +recursive walk, ignore-file interpretation, or directory discovery. + +Each file is exactly `{path,encoding,content}`, with `encoding` `utf8` or canonical +RFC 4648 padded `base64`. UTF-8 rejects unpaired surrogates. Paths are ASCII, +relative slash-separated file paths, at most 240 characters. Absolute paths, +backslashes, empty/dot/dot-dot segments, hidden segments, trailing dots, Windows +reserved device names, duplicate/case-colliding paths, file/directory prefix +collisions, credential/secret names, node_modules, and key/certificate suffixes +are rejected. These conservative rules reduce accidental inclusion; they do not +scan file contents for secrets. Filesystem clients must explicitly select regular +files and reject symlinks (including ancestor directory symlinks) before reading. +The envelope cannot represent symlinks, modes, device files, or filesystem links. + +Limits: 2 MiB request UTF-8 JSON, 128 files, 256 KiB decoded per file, 1 MiB decoded +total, 64 exports, and 64 dependencies. Unknown fields fail closed at every schema +level. JSON object member names must be unique at the sender; consumers use the +parsed JSON value (duplicate JSON object members have the platform's usual last +member semantics). Duplicate file entries always fail. Authorization must run +before publication and byte retrieval; these data validators confer no access. + +## Canonical bytes and hashes + +Normalize omitted visibility to `private`. Sort files by ASCII path. Convert all +file contents to canonical padded base64, preserving decoded bytes exactly (no +newline or Unicode normalization). Recursively sort all JSON object keys by ASCII +(all schema keys and user keys are ASCII); use compact JSON with no whitespace, +JSON standard string escaping and unescaped `/`, and UTF-8 encoding. Append one +LF byte. This yields the canonical envelope saved as `*.canonical.json`. +SHA-256 of these exact bytes is the package artifact identity. It binds manifest, +organization/package/version, dependencies, exports, visibility and every payload +byte. Changing transport encoding, input key order, file order, or explicitly +specifying default private visibility does not change it. No timestamp, credential, +receipt, mutable discovery state, or stable organization UUID enters the artifact. + +An inventory is an ASCII-path-sorted array of `{path,size,sha256}`; each file hash +covers exactly its decoded bytes and size is its byte length. Inventory is derived, +not supplied by publishers. `hash-vectors.json` provides artifact byte lengths, +hashes and file hashes for Rust/Bun/Worker/Node implementations to test against; +canonical bytes are checked in, not reconstructed from prose alone. + +## Publication receipt and consumption + +A receipt is exactly `{schema:"prose-publication-v1",organizationId,reference, +visibility,inventory}`. The registry supplies a stable lowercase UUID +`organizationId`; the manifest organization's slug is an immutable routing name. +The reference has `{organization,package,version,sha256}`. The receipt is metadata, +not an independent claim of authenticity. Clients must obtain it over their trusted +registry connection, download bounded artifact bytes, compare the artifact hash, +validate the package, and require its reference/visibility/inventory to agree with +the receipt before materializing files. Dependency references follow the same rule. +Withdrawal only removes discovery: pinned authorized retrieval remains available. + +`contract.mjs` exports `LIMITS`, `parsePublication`, `preparePublication`, +`validateIdentity`, `validateVersion`, `validatePath`, `validateReference`, +`validateReceipt`, `canonicalJSON`, and `sha256`. `preparePublication` returns +`{manifest,inventory,artifact,bytes,sha256,reference}` and is async for Web Crypto. +It performs no I/O or execution. Node 22+, Bun and Workers expose required Web APIs. diff --git a/cli/shared/fixtures/registry/directory.canonical.json b/cli/shared/fixtures/registry/directory.canonical.json new file mode 100644 index 0000000..e1c88a4 --- /dev/null +++ b/cli/shared/fixtures/registry/directory.canonical.json @@ -0,0 +1 @@ +{"files":[{"content":"IyBLaXQK","encoding":"base64","path":"README.md"},{"content":"R3VpZGVcbiI=","encoding":"base64","path":"docs/guide.md"}],"manifest":{"dependencies":{"hello":{"organization":"example","package":"hello","sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","version":"1.0.0"}},"exports":{"default":"README.md","guide":"docs/guide.md"},"organization":"example","package":"kit","version":"2.0.0-beta.1+build.4","visibility":"public"},"schema":"prose-package-v1"} diff --git a/cli/shared/fixtures/registry/directory.json b/cli/shared/fixtures/registry/directory.json new file mode 100644 index 0000000..f0a32fe --- /dev/null +++ b/cli/shared/fixtures/registry/directory.json @@ -0,0 +1 @@ +{"schema":"prose-package-v1","manifest":{"organization":"example","package":"kit","version":"2.0.0-beta.1+build.4","visibility":"public","exports":{"default":"README.md","guide":"docs/guide.md"},"dependencies":{"hello":{"organization":"example","package":"hello","version":"1.0.0","sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}},"files":[{"path":"docs/guide.md","encoding":"base64","content":"R3VpZGVcbiI="},{"path":"README.md","encoding":"utf8","content":"# Kit\n"}]} diff --git a/cli/shared/fixtures/registry/directory.receipt.json b/cli/shared/fixtures/registry/directory.receipt.json new file mode 100644 index 0000000..5524ff4 --- /dev/null +++ b/cli/shared/fixtures/registry/directory.receipt.json @@ -0,0 +1,23 @@ +{ + "schema": "prose-publication-v1", + "organizationId": "00000000-0000-4000-8000-000000000001", + "reference": { + "organization": "example", + "package": "kit", + "version": "2.0.0-beta.1+build.4", + "sha256": "1293bc4e33eaafce6aa4402378eb8ae39e12724ba258d8132c512fc06c42caeb" + }, + "visibility": "public", + "inventory": [ + { + "path": "README.md", + "size": 6, + "sha256": "4845d7f12918cd6dcf4447ffe305204ae7d76334c486ebe0e319f9cb95abe182" + }, + { + "path": "docs/guide.md", + "size": 8, + "sha256": "da4026b007db2d686d91c23fedea520d5c4d343376e0a018351e60686d062247" + } + ] +} diff --git a/cli/shared/fixtures/registry/hash-vectors.json b/cli/shared/fixtures/registry/hash-vectors.json new file mode 100644 index 0000000..bc93b01 --- /dev/null +++ b/cli/shared/fixtures/registry/hash-vectors.json @@ -0,0 +1,33 @@ +[ + { + "fixture": "single-file.json", + "canonical": "single-file.canonical.json", + "artifactBytes": 253, + "sha256": "fac5e538f24818de99c563a5d8c547a5fae1fbb7047e68dbe3cca9ef732ec61e", + "inventory": [ + { + "path": "hello.md", + "size": 8, + "sha256": "90f8ec5669cd34183b9b0fdf8b94f5efb4c3672876330f4aa76088c2b4ad17be" + } + ] + }, + { + "fixture": "directory.json", + "canonical": "directory.canonical.json", + "artifactBytes": 503, + "sha256": "1293bc4e33eaafce6aa4402378eb8ae39e12724ba258d8132c512fc06c42caeb", + "inventory": [ + { + "path": "README.md", + "size": 6, + "sha256": "4845d7f12918cd6dcf4447ffe305204ae7d76334c486ebe0e319f9cb95abe182" + }, + { + "path": "docs/guide.md", + "size": 8, + "sha256": "da4026b007db2d686d91c23fedea520d5c4d343376e0a018351e60686d062247" + } + ] + } +] diff --git a/cli/shared/fixtures/registry/invalid.json b/cli/shared/fixtures/registry/invalid.json new file mode 100644 index 0000000..f3c40fb --- /dev/null +++ b/cli/shared/fixtures/registry/invalid.json @@ -0,0 +1 @@ +[{"name":"traversal","filePath":"../secret.md"},{"name":"absolute","filePath":"/secret.md"},{"name":"backslash","filePath":"a\\secret.md"},{"name":"sensitive","filePath":".env"},{"name":"unpinned","dependencyVersion":"^1.0.0"},{"name":"missing-export","exportPath":"missing.md"}] diff --git a/cli/shared/fixtures/registry/single-file.canonical.json b/cli/shared/fixtures/registry/single-file.canonical.json new file mode 100644 index 0000000..0cb9837 --- /dev/null +++ b/cli/shared/fixtures/registry/single-file.canonical.json @@ -0,0 +1 @@ +{"files":[{"content":"IyBIZWxsbwo=","encoding":"base64","path":"hello.md"}],"manifest":{"dependencies":{},"exports":{"default":"hello.md"},"organization":"example","package":"hello","version":"1.0.0","visibility":"private"},"schema":"prose-package-v1"} diff --git a/cli/shared/fixtures/registry/single-file.json b/cli/shared/fixtures/registry/single-file.json new file mode 100644 index 0000000..278f2ec --- /dev/null +++ b/cli/shared/fixtures/registry/single-file.json @@ -0,0 +1 @@ +{"schema":"prose-package-v1","manifest":{"organization":"example","package":"hello","version":"1.0.0","exports":{"default":"hello.md"},"dependencies":{}},"files":[{"path":"hello.md","encoding":"utf8","content":"# Hello\n"}]} diff --git a/cli/shared/fixtures/registry/single-file.receipt.json b/cli/shared/fixtures/registry/single-file.receipt.json new file mode 100644 index 0000000..1df2bb7 --- /dev/null +++ b/cli/shared/fixtures/registry/single-file.receipt.json @@ -0,0 +1,18 @@ +{ + "schema": "prose-publication-v1", + "organizationId": "00000000-0000-4000-8000-000000000001", + "reference": { + "organization": "example", + "package": "hello", + "version": "1.0.0", + "sha256": "fac5e538f24818de99c563a5d8c547a5fae1fbb7047e68dbe3cca9ef732ec61e" + }, + "visibility": "private", + "inventory": [ + { + "path": "hello.md", + "size": 8, + "sha256": "90f8ec5669cd34183b9b0fdf8b94f5efb4c3672876330f4aa76088c2b4ad17be" + } + ] +} diff --git a/cli/shared/schemas/README.md b/cli/shared/schemas/README.md index 16e722c..254979e 100644 --- a/cli/shared/schemas/README.md +++ b/cli/shared/schemas/README.md @@ -16,3 +16,18 @@ python3 cli/shared/tests/test_contracts.py ``` The test dependency is pinned in `../requirements-test.txt`. + + +Service and registry reports include `environment: production|staging` without +human banners in machine output. `service-environment.schema.json` describes the +persisted user selection; `package-operation.schema.json` describes publish, +fetch, list, and withdraw reports. Package results contain validated publication +receipts or one public listing page, never local destination paths or credentials. +The bounded listing cursor is opaque to callers and at most 210 ASCII characters. + +Package envelope, canonical bytes, hashes, and receipts are defined by +[`../fixtures/registry/FORMAT.md`](../fixtures/registry/FORMAT.md) and its checked-in +vectors. A schema match alone does not establish artifact integrity: fetch also +requires hash, canonical-byte, reference, visibility, and inventory agreement. +Directory authoring manifests reject unknown and duplicate keys before upload. +These contracts do not establish backend deployment or permission to publish. diff --git a/cli/shared/schemas/package-operation.schema.json b/cli/shared/schemas/package-operation.schema.json new file mode 100644 index 0000000..eaf480a --- /dev/null +++ b/cli/shared/schemas/package-operation.schema.json @@ -0,0 +1,279 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.openprose.org/cli/v1/package-operation.schema.json", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "environment", + "operation", + "result", + "problem" + ], + "properties": { + "schema": { + "const": "openprose.package-operation/1" + }, + "environment": { + "enum": [ + "production", + "staging" + ] + }, + "operation": { + "enum": [ + "publish", + "fetch", + "list", + "withdraw" + ] + }, + "result": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/receipt" + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "packages", + "nextCursor" + ], + "properties": { + "packages": { + "type": "array", + "maxItems": 25, + "items": { + "$ref": "#/$defs/receipt" + } + }, + "nextCursor": { + "type": [ + "null", + "string" + ], + "maxLength": 210, + "pattern": "^public:[a-z0-9-]+:[0-9A-Za-z.+-]+$" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "receipt", + "withdrawn" + ], + "properties": { + "receipt": { + "$ref": "#/$defs/receipt" + }, + "withdrawn": { + "const": true + } + } + } + ] + }, + "problem": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "runner-error.schema.json" + } + ] + } + }, + "$defs": { + "receipt": { + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "organizationId", + "reference", + "visibility", + "inventory" + ], + "properties": { + "schema": { + "const": "prose-publication-v1" + }, + "organizationId": { + "type": "string", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, + "reference": { + "type": "object", + "additionalProperties": false, + "required": [ + "organization", + "package", + "version", + "sha256" + ], + "properties": { + "organization": { + "type": "string", + "minLength": 1, + "maxLength": 63, + "pattern": "^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$" + }, + "package": { + "type": "string", + "minLength": 1, + "maxLength": 63, + "pattern": "^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$" + }, + "version": { + "type": "string", + "minLength": 5, + "maxLength": 128 + }, + "sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + } + } + }, + "visibility": { + "enum": [ + "private", + "public" + ] + }, + "inventory": { + "type": "array", + "minItems": 1, + "maxItems": 128, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "path", + "size", + "sha256" + ], + "properties": { + "path": { + "type": "string", + "minLength": 1, + "maxLength": 240 + }, + "size": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + }, + "sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + } + } + } + } + } + } + }, + "allOf": [ + { + "if": { + "properties": { + "problem": { + "type": "null" + } + } + }, + "then": { + "properties": { + "result": { + "not": { + "type": "null" + } + } + } + }, + "else": { + "properties": { + "result": { + "type": "null" + } + } + } + }, + { + "if": { + "properties": { + "problem": { + "type": "null" + }, + "operation": { + "enum": [ + "publish", + "fetch" + ] + } + } + }, + "then": { + "properties": { + "result": { + "$ref": "#/$defs/receipt" + } + } + } + }, + { + "if": { + "properties": { + "problem": { + "type": "null" + }, + "operation": { + "const": "list" + } + } + }, + "then": { + "properties": { + "result": { + "type": "object", + "required": [ + "packages", + "nextCursor" + ] + } + } + } + }, + { + "if": { + "properties": { + "problem": { + "type": "null" + }, + "operation": { + "const": "withdraw" + } + } + }, + "then": { + "properties": { + "result": { + "type": "object", + "required": [ + "receipt", + "withdrawn" + ] + } + } + } + } + ] +} diff --git a/cli/shared/tests/test_contracts.py b/cli/shared/tests/test_contracts.py index d589c3c..8071dce 100755 --- a/cli/shared/tests/test_contracts.py +++ b/cli/shared/tests/test_contracts.py @@ -64,6 +64,20 @@ def assert_valid(self, schema_name: str, instance) -> None: errors = sorted(self.validator(schema_name).iter_errors(instance), key=lambda error: list(error.path)) self.assertEqual([], [f"{list(error.path)}: {error.message}" for error in errors]) + def test_registry_reports_bind_operation_and_failure(self): + receipt = load_json(FIXTURES / "registry/directory.receipt.json") + base = {"schema": "openprose.package-operation/1", "environment": "staging", "operation": "publish", "result": receipt, "problem": None} + self.assert_valid("package-operation.schema.json", base) + for operation, result in (("fetch", receipt), ("list", {"packages": [receipt], "nextCursor": None}), ("withdraw", {"receipt": receipt, "withdrawn": True})): + self.assert_valid("package-operation.schema.json", {**base, "operation": operation, "result": result}) + taxonomy = load_json(SHARED / "errors/taxonomy.v1.json") + records = taxonomy["errors"] + failure = {"schema": "openprose.runner-error/1", **next(item for item in records if item["code"] == "SERVICE_UNAVAILABLE")} + self.assert_valid("package-operation.schema.json", {**base, "result": None, "problem": failure}) + for changes in ({"result": None}, {"operation": "list"}, {"problem": failure}, {"token": "secret"}, {"environment": "custom"}): + self.assertTrue(list(self.validator("package-operation.schema.json").iter_errors({**base, **changes}))) + self.assertEqual(receipt["reference"]["sha256"], sha256((FIXTURES / "registry/directory.canonical.json").read_bytes())) + def test_kernel_startup_instruction_recipes_and_provider_routes(self): fixture = load_json(FIXTURES / "adapters/kernel-startup.json") recipes = SHARED / "capabilities/adapters/recipes"