diff --git a/docs/reference/native-config-files.md b/docs/reference/native-config-files.md index 7fcce5e9..1ea83baa 100644 --- a/docs/reference/native-config-files.md +++ b/docs/reference/native-config-files.md @@ -60,8 +60,8 @@ managed owner and exact material projection. The native graph adapter continues refuse raw file namespace presence, including empty definitions and inactive grants, before private value copies. Neither path falls back to another backend. -The first private owner subset is read-only mode `0444` with no UID/GID override; -custom permissions, writable access, one-off `run`, and projects combining builds +The private owner supports read-only modes `0444`, `0400` and `0600` with no UID/GID +override. Other permissions, writable access, one-off `run`, and projects combining builds with file inputs remain outside that subset. File-backed Compose config/secret mounts do not implement portable ownership remapping, so emitting ignored attributes would not satisfy this contract. See the [Compose long-syntax contract](https://docs.docker.com/reference/compose-file/services/#secrets). @@ -84,9 +84,17 @@ managed owner before reading file bytes. Hooks may create the selected source fi changing selection or unsupported permission intent refuses delivery. All authored build/file combinations, including inactive workloads, refuse before private reads. -Snapshots use owned 0700 directories outside the checkout, exclusive 0444 files, -0600 metadata and exact read-only binds with `create_host_path: false`. A private +Snapshots use owned 0700 directories outside the checkout, exclusive files with +the exact selected `0444`, `0400` or `0600` mode, and 0600 metadata and exact read-only binds with `create_host_path: false`. A private generated extension anchors the root receipt, snapshot, manifest and file identities. +Snapshot reference/manifest/journal version 1 remains the exact `0444` contract. +Version 2 records protected `0400`/`0600` members, with the requested mode bound by +the selected compiler grant and each immutable file anchor. Older clients refuse +version 2. Host source permissions are observed and never changed to satisfy a +grant; the selected mode applies only to Hack's exclusive private copy. Effective +guest ownership is not remapped or inferred from an image user. This source +contract does not establish application access or retained Compose bind parity; +those need separate live ownership and permission acceptance. The bind projection encodes literal dollar signs once for Compose interpolation; filesystem paths and the stored reference remain raw. Saved document checks require those exact encoded binds and reject interpolation in additional mounts, including diff --git a/src/lib/native-compose-file-bytes.ts b/src/lib/native-compose-file-bytes.ts index 04c9fb6e..8e5ab1aa 100644 --- a/src/lib/native-compose-file-bytes.ts +++ b/src/lib/native-compose-file-bytes.ts @@ -1,6 +1,11 @@ import { createHash } from "node:crypto"; import { constants, type Stats } from "node:fs"; import { type FileHandle, lstat, open } from "node:fs/promises"; +import { + type NativeComposeFileMode, + nativeComposeFileMode, + nativeComposeFileModeBits, +} from "./native-compose-file-permissions.ts"; import { NativeComposeGenerationError, sameFile, @@ -171,29 +176,37 @@ export async function holdNativeComposeFile(opts: { export async function writeNativeComposeFile(opts: { readonly path: string; readonly bytes: Uint8Array; + readonly mode?: NativeComposeFileMode; }): Promise { + const path = opts.path; + const mode = nativeComposeFileMode( + opts.mode === undefined ? "0444" : opts.mode + ); + if (!mode) { + return refuseNativeComposeFile(); + } + const bits = nativeComposeFileModeBits(mode); const bytes = Buffer.from(opts.bytes); if (bytes.length > NATIVE_COMPOSE_FILE_BYTES_LIMIT) { bytes.fill(0); return refuseNativeComposeFile(); } const file = await open( - opts.path, + path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, - 0o444 + bits ); try { await file.writeFile(bytes); - await file.chmod(0o444); + await file.chmod(bits); await file.sync(); const info = await file.stat(); if ( !( - allowed(info, [0o444], bytes.length) && - sameFile(info, await lstat(opts.path)) + allowed(info, [bits], bytes.length) && sameFile(info, await lstat(path)) ) ) { return refuseNativeComposeFile(); @@ -202,7 +215,7 @@ export async function writeNativeComposeFile(opts: { dev: info.dev, ino: info.ino, size: bytes.length, - mode: 0o444, + mode: bits, digest: nativeComposeFileDigest(bytes), }); } finally { diff --git a/src/lib/native-compose-file-owner.ts b/src/lib/native-compose-file-owner.ts index 591f8dc3..4382cff0 100644 --- a/src/lib/native-compose-file-owner.ts +++ b/src/lib/native-compose-file-owner.ts @@ -8,6 +8,7 @@ import { refuseNativeComposeFile, writeNativeComposeFile, } from "./native-compose-file-bytes.ts"; +import { nativeComposeFileMode } from "./native-compose-file-permissions.ts"; import { assertNativeComposeFileSources, closeNativeComposeFileSources, @@ -137,9 +138,12 @@ function snapshotPath(reference: NativeComposeFileReference): string { ); } function headerFor( - reference: Pick + reference: Pick< + NativeComposeFileReference, + "generationId" | "snapshotToken" | "version" + > ): string { - return `${JSON.stringify({ version: 1, kind: "native-compose-file-journal", generationId: reference.generationId, snapshotToken: reference.snapshotToken })}\n`; + return `${JSON.stringify({ version: reference.version, kind: "native-compose-file-journal", generationId: reference.generationId, snapshotToken: reference.snapshotToken })}\n`; } function checkText( read: { @@ -400,6 +404,25 @@ function matchVolume(opts: { } } +function snapshotVersion(sources: NativeComposeFileSources): 1 | 2 { + const plan = sources.result.file_plan; + if (!plan?.complete) { + return refuseNativeComposeFile(); + } + let version: 1 | 2 = 1; + for (const bindings of Object.values(plan.workloads)) { + for (const binding of bindings) { + const mode = nativeComposeFileMode(binding.mode); + if (!mode) { + return refuseNativeComposeFile(); + } + if (mode !== "0444") { + version = 2; + } + } + } + return version; +} async function memberPresent( snapshot: Snapshot, member: NativeComposeFileMember @@ -415,7 +438,7 @@ async function memberPresent( } const held = await holdNativeComposeFile({ path, - modes: [0o444], + modes: [member.file.mode], limit: member.file.size, }); try { @@ -793,13 +816,14 @@ export function createNativeComposeFileOwner(opts: { reservation, sources, }); + const version = snapshotVersion(sources); const initialized = await initializeRoot(root); let directory: HeldDirectory | undefined; try { await checkAuthority(selection); const snapshotToken = token(); const base = { - version: 1 as const, + version, root, rootToken: initialized.rootToken, rootDirectory: { @@ -834,9 +858,14 @@ export function createNativeComposeFileOwner(opts: { ...(directory ? [directory] : []), ]); const id = token(); + const mode = nativeComposeFileMode(member.binding.mode); + if (!mode) { + return refuseNativeComposeFile(); + } const file = await writeNativeComposeFile({ path: join(path, id), bytes: member.bytes, + mode, }); members.push({ id, @@ -855,7 +884,7 @@ export function createNativeComposeFileOwner(opts: { header ); const manifest: NativeComposeFileManifest = { - version: 1, + version, kind: "native-compose-file-material", reference: referenceBase, creation: binding, diff --git a/src/lib/native-compose-file-permissions.ts b/src/lib/native-compose-file-permissions.ts new file mode 100644 index 00000000..baf6253d --- /dev/null +++ b/src/lib/native-compose-file-permissions.ts @@ -0,0 +1,32 @@ +/** Closed backend permission subset; the compiler preserves broader intent separately. */ +export type NativeComposeFileMode = "0444" | "0400" | "0600"; +export type NativeComposeFileModeBits = 0o444 | 0o400 | 0o600; +export function nativeComposeFileMode( + value: unknown +): NativeComposeFileMode | undefined { + return value === "0444" || value === "0400" || value === "0600" + ? value + : undefined; +} +export function nativeComposeFileModeBits( + value: NativeComposeFileMode +): NativeComposeFileModeBits; +export function nativeComposeFileModeBits( + value: unknown +): NativeComposeFileModeBits | undefined; +export function nativeComposeFileModeBits( + value: unknown +): NativeComposeFileModeBits | undefined { + if (value === "0444") { + return 0o444; + } + if (value === "0400") { + return 0o400; + } + return value === "0600" ? 0o600 : undefined; +} +export function nativeComposeFileModeBitsValid( + value: unknown +): value is NativeComposeFileModeBits { + return value === 0o444 || value === 0o400 || value === 0o600; +} diff --git a/src/lib/native-compose-file-state.ts b/src/lib/native-compose-file-state.ts index 0172fe65..bad9c641 100644 --- a/src/lib/native-compose-file-state.ts +++ b/src/lib/native-compose-file-state.ts @@ -3,6 +3,7 @@ import { type NativeComposeFileAnchor, refuseNativeComposeFile, } from "./native-compose-file-bytes.ts"; +import { nativeComposeFileModeBitsValid } from "./native-compose-file-permissions.ts"; import type { NativeComposeMaterialBinding } from "./native-compose-generation.ts"; import { keys, parsePrivateJson } from "./native-compose-private-state.ts"; export const NATIVE_COMPOSE_FILE_STATE_LIMIT = 1024 * 1024; @@ -14,7 +15,7 @@ const TARGET_FORBIDDEN = /[\\\0]/; export type FileIdentity = { readonly dev: number; readonly ino: number }; export type StateAnchor = FileIdentity & { readonly digest: string }; export type NativeComposeFileReference = { - readonly version: 1; + readonly version: 1 | 2; readonly root: string; readonly rootToken: string; readonly rootDirectory: FileIdentity; @@ -31,7 +32,7 @@ export type NativeComposeFileMember = { readonly file: NativeComposeFileAnchor; }; export type NativeComposeFileManifest = { - readonly version: 1; + readonly version: 1 | 2; readonly kind: "native-compose-file-material"; readonly reference: Omit; readonly creation: NativeComposeMaterialBinding; @@ -136,7 +137,7 @@ export function parseNativeComposeFileReference( value, "generationId,manifest,root,rootDirectory,rootReceipt,rootToken,snapshotDirectory,snapshotToken,version" ) && - value.version === 1 && + (value.version === 1 || value.version === 2) && typeof value.root === "string" && value.root.startsWith("/") && typeof value.rootToken === "string" && @@ -154,7 +155,7 @@ export function parseNativeComposeFileReference( return refuseNativeComposeFile(); } const result: NativeComposeFileReference = { - version: 1, + version: value.version, root: value.root, rootToken: value.rootToken, rootDirectory: value.rootDirectory, @@ -167,19 +168,27 @@ export function parseNativeComposeFileReference( freezeNativeComposeFileState(result); return result; } -function fileAnchor(value: unknown): value is NativeComposeFileAnchor { +function fileAnchor( + value: unknown, + version: 1 | 2 +): value is NativeComposeFileAnchor { return ( isRecord(value) && keys(value, "dev,digest,ino,mode,size") && anchor({ dev: value.dev, ino: value.ino, digest: value.digest }) && - value.mode === 0o444 && + (version === 1 + ? value.mode === 0o444 + : nativeComposeFileModeBitsValid(value.mode)) && typeof value.size === "number" && Number.isSafeInteger(value.size) && value.size >= 0 && value.size <= NATIVE_COMPOSE_FILE_STATE_LIMIT ); } -function member(value: unknown): value is NativeComposeFileMember { +function member( + value: unknown, + version: 1 | 2 +): value is NativeComposeFileMember { if ( !( isRecord(value) && @@ -192,7 +201,7 @@ function member(value: unknown): value is NativeComposeFileMember { value.target.startsWith("/") && value.target !== "/" && !TARGET_FORBIDDEN.test(value.target) && - fileAnchor(value.file) + fileAnchor(value.file, version) ) ) { return false; @@ -277,7 +286,8 @@ export function parseNativeComposeFileManifest(opts: { !( isRecord(value) && keys(value, "creation,journal,kind,members,reference,version") && - value.version === 1 && + (value.version === 1 || value.version === 2) && + value.version === opts.reference.version && value.kind === "native-compose-file-material" && sameNativeComposeFileState(value.reference, reference) && isRecord(value.creation) && @@ -290,7 +300,7 @@ export function parseNativeComposeFileManifest(opts: { value.creation.documentHash === null && anchor(value.journal) && Array.isArray(value.members) && - value.members.every(member) + value.members.every((entry) => member(entry, opts.reference.version)) ) ) { return refuseNativeComposeFile(); @@ -301,14 +311,16 @@ export function parseNativeComposeFileManifest(opts: { ); if ( ids.size !== value.members.length || - targets.size !== value.members.length + targets.size !== value.members.length || + (opts.reference.version === 2 && + !value.members.some((entry) => entry.file.mode !== 0o444)) ) { return refuseNativeComposeFile(); } // Creation bindings are produced by the same opaque authority. Saved identities // select immutable material; they never grant a live completion capability. const result: NativeComposeFileManifest = { - version: 1, + version: opts.reference.version, kind: "native-compose-file-material", reference, creation: parseBinding(value.creation, { diff --git a/src/lib/native-compose-file-subset.ts b/src/lib/native-compose-file-subset.ts index 1146769d..49f2d0f2 100644 --- a/src/lib/native-compose-file-subset.ts +++ b/src/lib/native-compose-file-subset.ts @@ -1,12 +1,13 @@ import { isRecord } from "./guards.ts"; import { refuseNativeComposeFile } from "./native-compose-file-bytes.ts"; +import { nativeComposeFileMode } from "./native-compose-file-permissions.ts"; import { NativeConfigCompilerError } from "./native-config-compiler.ts"; import { authoredFilePlanningRequired } from "./native-file-plan-protocol.ts"; function unsupported(): never { throw new NativeConfigCompilerError( "E_NATIVE_PROJECT_UNSUPPORTED", - "Native file delivery requires read-only mode 0444, no UID/GID override and no builds. Values omitted." + "Native file delivery requires read-only mode 0444, 0400 or 0600, no UID/GID override and no builds. Values omitted." ); } function assertWorkloadSubset(workload: unknown): void { @@ -28,9 +29,10 @@ function assertWorkloadSubset(workload: unknown): void { ) { continue; } + const mode = Object.hasOwn(mount, "mode") ? mount.mode : undefined; if ( mount.access !== "read-only" || - (mount.mode !== undefined && mount.mode !== "0444") || + (mode !== undefined && nativeComposeFileMode(mode) === undefined) || Object.hasOwn(mount, "uid") || Object.hasOwn(mount, "gid") ) { diff --git a/src/lib/native-compose-renderer.ts b/src/lib/native-compose-renderer.ts index 66369dd2..5326fb61 100644 --- a/src/lib/native-compose-renderer.ts +++ b/src/lib/native-compose-renderer.ts @@ -10,6 +10,7 @@ import { type NativeComposeFileProjection, nativeComposeFileProjectionMatches, } from "./native-compose-file-owner.ts"; +import { nativeComposeFileMode } from "./native-compose-file-permissions.ts"; import { NATIVE_COMPOSE_FILES_EXTENSION } from "./native-compose-file-state.ts"; import { selectNativeComposeBeforeHooks } from "./native-compose-host-contract.ts"; import { @@ -836,7 +837,9 @@ function renderMounts(opts: { const kind = Object.hasOwn(mount, "config") ? "config" : "secret"; closed(mount, [kind, "target", "access", "mode"]); assert( - context.files && mount.access === "read-only" && mount.mode === "0444", + context.files && + mount.access === "read-only" && + nativeComposeFileMode(mount.mode) !== undefined, "E_COMPOSE_FILE_OWNER" ); assert( @@ -845,7 +848,7 @@ function renderMounts(opts: { file.kind === kind && file.name === mount[kind] && file.target === mount.target && - file.mode === "0444" && + file.mode === mount.mode && file.uid === undefined && file.gid === undefined ), diff --git a/tests/native-compose-file-owner.test.ts b/tests/native-compose-file-owner.test.ts index f704c748..97c558b8 100644 --- a/tests/native-compose-file-owner.test.ts +++ b/tests/native-compose-file-owner.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; import { + chmod, lstat, mkdir, mkdtemp, @@ -25,9 +26,14 @@ import { closeNativeComposeFileSources, type NativeComposeFileSources, } from "../src/lib/native-compose-file-sources.ts"; -import { NATIVE_COMPOSE_FILES_EXTENSION } from "../src/lib/native-compose-file-state.ts"; +import { + NATIVE_COMPOSE_FILES_EXTENSION, + parseNativeComposeFileManifest, + parseNativeComposeFileReference, +} from "../src/lib/native-compose-file-state.ts"; import { type NativeComposeGenerationStore, + type NativeComposeMaterialBinding, type NativeComposeMutation, openNativeComposeGenerationStore, } from "../src/lib/native-compose-generation.ts"; @@ -367,6 +373,7 @@ test("actual acquisition stages binary and empty 0444 files outside checkout", a const selected = await staged(mutation); expect(JSON.stringify(selected.attempt)).toBe("{}"); const paths = memberPaths(selected.projection); + expect(selected.projection.reference.version).toBe(1); expect(paths.every((path) => !path.startsWith(root))).toBe(true); expect(await readFile(paths[0] ?? "")).toEqual(BYTES); expect(await readFile(paths[1] ?? "")).toEqual(Buffer.alloc(0)); @@ -1419,3 +1426,248 @@ test("known reaped readiness failure retains pending material until a separate v await expectAbsent(memberPaths(selected.projection)); }); }, 30_000); + +test.each([ + ["0400", 0o400], + ["0600", 0o600], +] as const)("protected %s delivery preserves original permissions and binds exact private snapshot2 modes", async (mode, bits) => { + const secretPath = join(root, "protected.bin"); + await writeFile(secretPath, BYTES, { mode: bits }); + const original = await lstat(secretPath); + await writeFile( + join(root, ".hack/hack.project.json"), + JSON.stringify({ + ...SOURCE, + secrets: { empty: { file: "protected.bin" } }, + services: { + reader: { + ...SOURCE.services.reader, + mounts: [ + SOURCE.services.reader.mounts[0], + { + secret: "empty", + target: "/run/empty", + access: "read-only", + mode, + }, + ], + }, + }, + }) + ); + const selected = await store.withMutation(running); + expect(selected.projection.reference.version).toBe(2); + const granted = selected.projection.workloads.reader; + const secret = granted?.find((grant) => grant.target === "/run/empty"); + const config = granted?.find((grant) => grant.target === "/etc/settings"); + if (!(secret && config)) { + throw new Error("Missing selected file grants"); + } + expect(await readFile(secret.source)).toEqual(BYTES); + expect((await lstat(secret.source)).mode & 0o777).toBe(bits); + expect((await lstat(config.source)).mode & 0o777).toBe(0o444); + const after = await lstat(secretPath); + expect({ + dev: after.dev, + ino: after.ino, + mode: after.mode, + uid: after.uid, + gid: after.gid, + }).toEqual({ + dev: original.dev, + ino: original.ino, + mode: original.mode, + uid: original.uid, + gid: original.gid, + }); + const reference = selected.projection.reference; + const text = await readFile( + join( + reference.root, + `${reference.generationId}-${reference.snapshotToken}`, + "manifest.json" + ), + "utf8" + ); + const raw: unknown = JSON.parse(text); + if (!(isRecord(raw) && isRecord(raw.creation))) { + throw new Error("Missing owned material binding"); + } + // This binding comes from the genuine owner-produced private manifest; parsing + // below verifies it and no external effect authority is obtained from this fixture. + const binding = raw.creation as NativeComposeMaterialBinding; + const manifest = parseNativeComposeFileManifest({ text, reference, binding }); + expect(manifest.version).toBe(2); + expect( + manifest.members.find((member) => member.target === "/run/empty")?.file.mode + ).toBe(bits); + for (const changed of [0o000, 0o644, 0o777, "0400", null]) { + const forged = structuredClone(manifest); + const originalMembers = forged.members.map((member) => ({ + ...member, + file: { ...member.file }, + })); + const member = originalMembers.find( + (entry) => entry.target === "/run/empty" + ); + if (!member) { + throw new Error("Missing owned secret member"); + } + const altered = { + ...forged, + members: originalMembers.map((entry) => + entry === member + ? { ...entry, file: { ...entry.file, mode: changed } } + : entry + ), + }; + expect(() => + parseNativeComposeFileManifest({ + text: JSON.stringify(altered), + reference, + binding, + }) + ).toThrow(); + } + const legacy = { + ...manifest, + version: 1, + reference: { ...manifest.reference, version: 1 }, + }; + expect(() => + parseNativeComposeFileManifest({ + text: JSON.stringify(legacy), + reference: { ...reference, version: 1 }, + binding, + }) + ).toThrow(); + const allPublic = { + ...manifest, + members: manifest.members.map((member) => ({ + ...member, + file: { ...member.file, mode: 0o444 }, + })), + }; + expect(() => + parseNativeComposeFileManifest({ + text: JSON.stringify(allPublic), + reference, + binding, + }) + ).toThrow(); + expect(() => + parseNativeComposeFileReference({ ...reference, version: 3 }) + ).toThrow(); + const unknownVersion = { + ...manifest, + version: 3, + reference: { ...manifest.reference, version: 3 }, + }; + expect(() => + Reflect.apply(parseNativeComposeFileManifest, undefined, [ + { + text: JSON.stringify(unknownVersion), + reference: { ...reference, version: 3 }, + binding, + }, + ]) + ).toThrow(); + await store.withMutation(async (mutation) => { + await ownerFor(mutation).assertSavedReady(selected.generation); + }); + expect(JSON.stringify(selected.attempt)).toBe("{}"); +}); + +test("protected snapshot mode drift refuses saved readiness while preserving owned stop recovery", async () => { + await writeFile(join(root, "protected.bin"), BYTES, { mode: 0o600 }); + await writeFile( + join(root, ".hack/hack.project.json"), + JSON.stringify({ + ...SOURCE, + secrets: { empty: { file: "protected.bin" } }, + services: { + reader: { + ...SOURCE.services.reader, + mounts: [ + SOURCE.services.reader.mounts[0], + { + secret: "empty", + target: "/run/empty", + access: "read-only", + mode: "0400", + }, + ], + }, + }, + }) + ); + const selected = await store.withMutation(running); + const secret = selected.projection.workloads.reader?.find( + (grant) => grant.target === "/run/empty" + ); + if (!secret) { + throw new Error("Missing selected secret grant"); + } + await chmod(secret.source, 0o600); + let children = 0; + let readinessChecks = 0; + await expect( + store.withMutation(async (mutation) => { + const owner = ownerFor(mutation); + await mutation.runEffect({ + generation: selected.generation, + operation: "up", + // This control isolates the saved-material guard, not source admission; + // input freshness is synthetic and no expired first-mutation authority is reused. + assertFresh: async () => {}, + assertOwned: async () => {}, + effect: async () => { + readinessChecks++; + await owner.assertSavedReady(selected.generation); + children++; + return { outcome: "complete", value: 0 }; + }, + }); + }) + ).rejects.toMatchObject({ code: "E_NATIVE_COMPOSE_UNCERTAIN" }); + expect(readinessChecks).toBe(1); + expect(children).toBe(0); + expect((await store.loadPending())?.generationId).toBe( + selected.generation.generationId + ); + await expect( + store.withMutation(async (mutation) => { + const owner = ownerFor(mutation); + await mutation.runEffect({ + generation: selected.generation, + operation: "down", + recoverPending: true, + assertOwned: async () => {}, + effect: async () => { + const attempt = await owner.armStop(selected.generation); + if (!attempt) { + throw new Error("Missing known owned stop attempt"); + } + children++; + await owner.recordStopReaped({ + attempt, + assertReaped: async () => {}, + }); + return { outcome: "complete", value: 0 }; + }, + beforeComplete: async () => { + await owner.retire({ + generation: selected.generation, + assertAbsent: async () => {}, + }); + }, + }); + }) + ).rejects.toMatchObject({ code: "E_NATIVE_COMPOSE_UNCERTAIN" }); + expect(children).toBe(1); + expect((await store.loadPending())?.generationId).toBe( + selected.generation.generationId + ); + expect((await lstat(secret.source)).mode & 0o777).toBe(0o600); + expect((await lstat(join(root, "protected.bin"))).mode & 0o777).toBe(0o600); +}); diff --git a/tests/native-compose-file-permissions.test.ts b/tests/native-compose-file-permissions.test.ts new file mode 100644 index 00000000..31e3b3c9 --- /dev/null +++ b/tests/native-compose-file-permissions.test.ts @@ -0,0 +1,122 @@ +import { expect, test } from "bun:test"; +import { lstat, mkdtemp, readFile, realpath, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { writeNativeComposeFile } from "../src/lib/native-compose-file-bytes.ts"; +import { + nativeComposeFileMode, + nativeComposeFileModeBits, +} from "../src/lib/native-compose-file-permissions.ts"; +import { assertNativeComposeFileSubset } from "../src/lib/native-compose-file-subset.ts"; + +test.each([ + ["0444", 0o444], + ["0400", 0o400], + ["0600", 0o600], +] as const)("exclusive private writer delivers exact %s permission and binary bytes", async (mode, bits) => { + const root = await realpath( + await mkdtemp(join(tmpdir(), "native-file-permission-")) + ); + try { + const path = join(root, "copy"); + const bytes = new Uint8Array([0, 255, 10]); + const anchor = await writeNativeComposeFile({ path, bytes, mode }); + expect(anchor.mode).toBe(bits); + expect((await lstat(path)).mode & 0o777).toBe(bits); + expect(await readFile(path)).toEqual(Buffer.from(bytes)); + await expect( + writeNativeComposeFile({ path, bytes, mode }) + ).rejects.toMatchObject({ code: "EEXIST" }); + expect((await lstat(path)).ino).toBe(anchor.ino); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test.each( + ["0000", "0644", "0777", "400", "600", 0o400, 0o600, null, false, {}, []].map( + (mode) => ({ mode }) + ) +)("closed native permission subset refuses unsupported %j even in inactive workloads", ({ + mode, +}) => { + expect(nativeComposeFileMode(mode)).toBeUndefined(); + expect(nativeComposeFileModeBits(mode)).toBeUndefined(); + const input = new TextEncoder().encode( + JSON.stringify({ + schema_version: 1, + name: "fixture", + configs: { settings: { file: "missing" } }, + services: { + inactive: { + image: "fixture", + profiles: ["later"], + mounts: [ + { + config: "settings", + target: "/settings", + access: "read-only", + mode, + }, + ], + }, + }, + }) + ); + expect(() => assertNativeComposeFileSubset(input)).toThrow(); +}); + +test("untrusted writer permission refuses before publishing a private member", async () => { + const root = await realpath( + await mkdtemp(join(tmpdir(), "native-file-invalid-permission-")) + ); + try { + const path = join(root, "copy"); + await expect( + Reflect.apply(writeNativeComposeFile, undefined, [ + { + path, + bytes: new Uint8Array([0, 255]), + mode: "0644", + }, + ]) + ).rejects.toMatchObject({ code: "E_NATIVE_COMPOSE_STATE" }); + await expect(lstat(path)).rejects.toMatchObject({ code: "ENOENT" }); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test.each([ + "0444", + "0400", + "0600", +])("supported mode %s preserves whole-input no-build/no-UID policy", (mode) => { + const workload = { + image: "fixture", + profiles: ["later"], + mounts: [{ secret: "token", target: "/token", access: "read-only", mode }], + }; + const input = (selected: unknown) => + new TextEncoder().encode( + JSON.stringify({ + schema_version: 1, + name: "fixture", + secrets: { token: { file: "missing" } }, + services: { inactive: selected }, + }) + ); + expect(() => assertNativeComposeFileSubset(input(workload))).not.toThrow(); + expect(() => + assertNativeComposeFileSubset( + input({ ...workload, build: { context: "." } }) + ) + ).toThrow(); + for (const extra of [{ uid: 0 }, { gid: 0 }, { access: "read-write" }]) { + expect(() => + assertNativeComposeFileSubset( + input({ ...workload, mounts: [{ ...workload.mounts[0], ...extra }] }) + ) + ).toThrow(); + } +}); diff --git a/tests/native-compose-file-sources.test.ts b/tests/native-compose-file-sources.test.ts index 5709e3cd..35df5c81 100644 --- a/tests/native-compose-file-sources.test.ts +++ b/tests/native-compose-file-sources.test.ts @@ -367,7 +367,7 @@ test.each([ raw.services.off = { build: { context: "." }, profiles: ["inactive"] }; } else { raw.services.reader.mounts[0][kind] = - kind === "uid" ? 0 : kind === "mode" ? "0600" : "read-write"; + kind === "uid" ? 0 : kind === "mode" ? "0644" : "read-write"; } await writeFile(join(root, ".hack/hack.project.json"), JSON.stringify(raw)); await writeFile(join(root, ".hack/hack.env.json"), CANARY);