Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions docs/reference/native-config-files.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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
Expand Down
25 changes: 19 additions & 6 deletions src/lib/native-compose-file-bytes.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -171,29 +176,37 @@ export async function holdNativeComposeFile(opts: {
export async function writeNativeComposeFile(opts: {
readonly path: string;
readonly bytes: Uint8Array;
readonly mode?: NativeComposeFileMode;
}): Promise<NativeComposeFileAnchor> {
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();
Expand All @@ -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 {
Expand Down
39 changes: 34 additions & 5 deletions src/lib/native-compose-file-owner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
refuseNativeComposeFile,
writeNativeComposeFile,
} from "./native-compose-file-bytes.ts";
import { nativeComposeFileMode } from "./native-compose-file-permissions.ts";
import {
assertNativeComposeFileSources,
closeNativeComposeFileSources,
Expand Down Expand Up @@ -137,9 +138,12 @@ function snapshotPath(reference: NativeComposeFileReference): string {
);
}
function headerFor(
reference: Pick<NativeComposeFileReference, "generationId" | "snapshotToken">
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: {
Expand Down Expand Up @@ -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
Expand All @@ -415,7 +438,7 @@ async function memberPresent(
}
const held = await holdNativeComposeFile({
path,
modes: [0o444],
modes: [member.file.mode],
limit: member.file.size,
});
try {
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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,
Expand All @@ -855,7 +884,7 @@ export function createNativeComposeFileOwner(opts: {
header
);
const manifest: NativeComposeFileManifest = {
version: 1,
version,
kind: "native-compose-file-material",
reference: referenceBase,
creation: binding,
Expand Down
32 changes: 32 additions & 0 deletions src/lib/native-compose-file-permissions.ts
Original file line number Diff line number Diff line change
@@ -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;
}
36 changes: 24 additions & 12 deletions src/lib/native-compose-file-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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<NativeComposeFileReference, "manifest">;
readonly creation: NativeComposeMaterialBinding;
Expand Down Expand Up @@ -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" &&
Expand All @@ -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,
Expand All @@ -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) &&
Expand All @@ -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;
Expand Down Expand Up @@ -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) &&
Expand All @@ -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();
Expand All @@ -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, {
Expand Down
6 changes: 4 additions & 2 deletions src/lib/native-compose-file-subset.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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")
) {
Expand Down
Loading
Loading