From c29373d9a4dddcf8958643d0bcb9320e83e06588 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kova=C4=BE?= Date: Sat, 30 May 2026 17:58:33 +0200 Subject: [PATCH 01/10] feat(config-core): manifest structural schema + Issue types --- src/config-core/manifest/schema.test.ts | 64 ++++++++++++++ src/config-core/manifest/schema.ts | 109 ++++++++++++++++++++++++ src/config-core/types.ts | 27 ++++++ 3 files changed, 200 insertions(+) create mode 100644 src/config-core/manifest/schema.test.ts create mode 100644 src/config-core/manifest/schema.ts create mode 100644 src/config-core/types.ts diff --git a/src/config-core/manifest/schema.test.ts b/src/config-core/manifest/schema.test.ts new file mode 100644 index 0000000..e203593 --- /dev/null +++ b/src/config-core/manifest/schema.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "bun:test"; +import { validateManifestSchema } from "./schema"; + +describe("validateManifestSchema", () => { + it("accepts an empty manifest", () => { + expect(validateManifestSchema({})).toEqual([]); + }); + + it("rejects a non-mapping root", () => { + const issues = validateManifestSchema([]); + expect(issues[0]?.message).toMatch(/must be a mapping/); + expect(issues[0]?.severity).toBe("error"); + expect(issues[0]?.file).toBe("config.yaml"); + }); + + it("rejects an unknown top-level key", () => { + const issues = validateManifestSchema({ caps: ["js"] }); + expect(issues).toHaveLength(1); + expect(issues[0]?.path).toBe("caps"); + expect(issues[0]?.message).toMatch(/unknown key "caps"/); + }); + + it("rejects mounts that are not a list", () => { + expect(validateManifestSchema({ mounts: {} })[0]?.message).toMatch(/'mounts' must be a list/); + }); + + it("rejects a mount with an unknown type", () => { + const issues = validateManifestSchema({ + mounts: [{ source: "s", target: "/sandbox/.openlock/x", type: "nope" }], + }); + expect(issues[0]?.message).toMatch(/unknown type 'nope'/); + }); + + it("rejects readOnly on a non-bind mount", () => { + const issues = validateManifestSchema({ + mounts: [{ source: "s", target: "/sandbox/.openlock/x", type: "copy-once", readOnly: true }], + }); + expect(issues[0]?.message).toMatch(/readOnly is only valid on type: bind/); + }); + + it("rejects a non-boolean readOnly", () => { + const issues = validateManifestSchema({ + mounts: [{ source: "s", target: "/sandbox/.openlock/x", type: "bind", readOnly: "yes" }], + }); + expect(issues[0]?.message).toMatch(/readOnly must be a boolean/); + }); + + it("collects errors across multiple mounts", () => { + const issues = validateManifestSchema({ + mounts: [ + { source: "s", target: "/x", type: "bad1" }, + { source: "s", target: "/y", type: "bad2" }, + ], + }); + expect(issues).toHaveLength(2); + }); + + it("rejects non-string args entries and non-string env values", () => { + expect(validateManifestSchema({ args: [1] })[0]?.message).toMatch( + /'args' must contain only strings/, + ); + expect(validateManifestSchema({ env: { A: 1 } })[0]?.message).toMatch(/must be a string/); + }); +}); diff --git a/src/config-core/manifest/schema.ts b/src/config-core/manifest/schema.ts new file mode 100644 index 0000000..2968857 --- /dev/null +++ b/src/config-core/manifest/schema.ts @@ -0,0 +1,109 @@ +import type { Issue, MountType } from "../types"; + +const MANIFEST_KEYS = new Set(["mounts", "args", "env"]); +const MOUNT_ENTRY_KEYS = new Set(["source", "target", "type", "readOnly"]); +const MOUNT_TYPES: readonly MountType[] = ["copy-once", "copy-refresh", "bind", "git-bundle"]; + +function err(path: string, message: string, fix?: string): Issue { + return fix === undefined + ? { file: "config.yaml", severity: "error", path, message } + : { file: "config.yaml", severity: "error", path, message, fix }; +} + +function isPlainObject(v: unknown): v is Record { + return v !== null && typeof v === "object" && !Array.isArray(v); +} + +function validateMountEntry(raw: unknown, i: number, issues: Issue[]): void { + const where = `mounts[${i}]`; + if (!isPlainObject(raw)) { + issues.push(err(where, "mount entry must be a mapping")); + return; + } + for (const key of Object.keys(raw)) { + if (!MOUNT_ENTRY_KEYS.has(key)) { + issues.push( + err(`${where}.${key}`, `unknown field "${key}"`, "remove it or fix the spelling"), + ); + } + } + if (typeof raw.source !== "string" || raw.source.length === 0) { + issues.push(err(`${where}.source`, "'source' must be a non-empty string")); + } + if (typeof raw.target !== "string" || raw.target.length === 0) { + issues.push(err(`${where}.target`, "'target' must be a non-empty string")); + } + if (typeof raw.type !== "string" || !MOUNT_TYPES.includes(raw.type as MountType)) { + issues.push( + err( + `${where}.type`, + `unknown type '${String(raw.type)}' (allowed: ${MOUNT_TYPES.join(", ")})`, + ), + ); + } + if (raw.readOnly !== undefined) { + if (typeof raw.readOnly !== "boolean") { + issues.push(err(`${where}.readOnly`, "readOnly must be a boolean")); + } else if (raw.type !== "bind") { + issues.push(err(`${where}.readOnly`, "readOnly is only valid on type: bind")); + } + } +} + +function validateMounts(doc: Record, issues: Issue[]): void { + if (doc.mounts === undefined || doc.mounts === null) return; + if (!Array.isArray(doc.mounts)) { + issues.push(err("mounts", "'mounts' must be a list")); + return; + } + for (let i = 0; i < doc.mounts.length; i++) { + validateMountEntry(doc.mounts[i], i, issues); + } +} + +function validateArgs(doc: Record, issues: Issue[]): void { + if (doc.args === undefined || doc.args === null) return; + if (!Array.isArray(doc.args)) { + issues.push(err("args", "'args' must be a list")); + return; + } + for (let i = 0; i < doc.args.length; i++) { + if (typeof doc.args[i] !== "string") + issues.push(err(`args[${i}]`, "'args' must contain only strings")); + } +} + +function validateEnv(doc: Record, issues: Issue[]): void { + if (doc.env === undefined || doc.env === null) return; + if (!isPlainObject(doc.env)) { + issues.push(err("env", "'env' must be a mapping")); + return; + } + for (const [k, v] of Object.entries(doc.env)) { + if (typeof v !== "string") + issues.push(err(`env.${k}`, `env value for '${k}' must be a string`)); + } +} + +export function validateManifestSchema(doc: unknown): Issue[] { + const issues: Issue[] = []; + if (!isPlainObject(doc)) { + issues.push(err("", "config.yaml must be a mapping")); + return issues; + } + for (const key of Object.keys(doc)) { + if (!MANIFEST_KEYS.has(key)) { + issues.push( + err( + key, + `unknown key "${key}"`, + `remove "${key}" — allowed keys: ${[...MANIFEST_KEYS].join(", ")}`, + ), + ); + } + } + validateMounts(doc, issues); + validateArgs(doc, issues); + validateEnv(doc, issues); + return issues; +} diff --git a/src/config-core/types.ts b/src/config-core/types.ts new file mode 100644 index 0000000..c437a8d --- /dev/null +++ b/src/config-core/types.ts @@ -0,0 +1,27 @@ +export type Severity = "error" | "filesystem"; +export type ConfigFile = "config.yaml" | "policy.yaml"; + +export interface Issue { + file: ConfigFile; + severity: Severity; + path: string; + message: string; + fix?: string; +} + +export type MountType = "copy-once" | "copy-refresh" | "bind" | "git-bundle"; + +export interface Mount { + source: string; + target: string; + type: MountType; + readOnly?: boolean; +} + +export interface ManifestConfig { + mounts: Mount[]; + args: string[]; + env: Record; +} + +export const SANDBOX_OPENLOCK_PREFIX = "/sandbox/.openlock/"; From 111f0a18b58312a99cd6f3592e20a72ee1ecf067 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kova=C4=BE?= Date: Sat, 30 May 2026 18:03:25 +0200 Subject: [PATCH 02/10] feat(config-core): manifest semantic mount rules --- src/config-core/manifest/semantic.test.ts | 84 +++++++++++++++++ src/config-core/manifest/semantic.ts | 109 ++++++++++++++++++++++ 2 files changed, 193 insertions(+) create mode 100644 src/config-core/manifest/semantic.test.ts create mode 100644 src/config-core/manifest/semantic.ts diff --git a/src/config-core/manifest/semantic.test.ts b/src/config-core/manifest/semantic.test.ts new file mode 100644 index 0000000..1d8235b --- /dev/null +++ b/src/config-core/manifest/semantic.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "bun:test"; +import { validateManifestSemantics } from "./semantic"; + +function msgs(doc: Record): string[] { + return validateManifestSemantics(doc).map((i) => i.message); +} + +describe("validateManifestSemantics", () => { + it("passes a valid copy-once mount", () => { + expect( + validateManifestSemantics({ + mounts: [{ source: "s", target: "/sandbox/.openlock/x", type: "copy-once" }], + }), + ).toEqual([]); + }); + + it("rejects a non-absolute target", () => { + expect(msgs({ mounts: [{ source: "s", target: "sandbox/x", type: "copy-once" }] })[0]).toMatch( + /must be absolute/, + ); + }); + + it("rejects a '..' segment in target", () => { + expect(msgs({ mounts: [{ source: "s", target: "/sandbox/../etc", type: "bind" }] })[0]).toMatch( + /must not contain '\.\.'/, + ); + }); + + it("rejects a reserved openlock-internal target name", () => { + expect( + msgs({ + mounts: [{ source: "s", target: "/sandbox/.openlock/bundles", type: "copy-once" }], + })[0], + ).toMatch(/conflicts with openlock-internal name 'bundles'/); + }); + + it("rejects copy-once targeting /sandbox/repo", () => { + expect( + msgs({ mounts: [{ source: "s", target: "/sandbox/repo", type: "copy-once" }] })[0], + ).toMatch(/\/sandbox\/repo not supported with type 'copy-once'/); + }); + + it("rejects copy-once outside /sandbox/.openlock/", () => { + expect( + msgs({ mounts: [{ source: "s", target: "/etc/passwd", type: "copy-once" }] })[0], + ).toMatch(/under \/sandbox\/\.openlock\//); + }); + + it("rejects git-bundle under /sandbox/.openlock/", () => { + expect( + msgs({ mounts: [{ source: "s", target: "/sandbox/.openlock/repo", type: "git-bundle" }] })[0], + ).toMatch(/git-bundle target must not be under/); + }); + + it("allows bind anywhere outside reserved names", () => { + expect( + validateManifestSemantics({ + mounts: [{ source: "s", target: "/sandbox/extras", type: "bind" }], + }), + ).toEqual([]); + }); + + it("rejects duplicate targets", () => { + expect( + msgs({ + mounts: [ + { source: "a", target: "/sandbox/.openlock/x", type: "copy-once" }, + { source: "b", target: "/sandbox/.openlock/x", type: "copy-refresh" }, + ], + }), + ).toContain("duplicate target /sandbox/.openlock/x"); + }); + + it("rejects colliding git-bundle source basenames", () => { + expect( + msgs({ + mounts: [ + { source: "outer/app", target: "/sandbox/repo", type: "git-bundle" }, + { source: "inner/app", target: "/sandbox/extra-repo", type: "git-bundle" }, + ], + }).some((m) => /source basename 'app' collides between git-bundle mounts/.test(m)), + ).toBe(true); + }); +}); diff --git a/src/config-core/manifest/semantic.ts b/src/config-core/manifest/semantic.ts new file mode 100644 index 0000000..146c46c --- /dev/null +++ b/src/config-core/manifest/semantic.ts @@ -0,0 +1,109 @@ +import { basename } from "node:path"; +import type { Issue, MountType } from "../types"; +import { SANDBOX_OPENLOCK_PREFIX } from "../types"; + +const RESERVED_MOUNT_NAMES: ReadonlySet = new Set([".gitconfig", "bundles"]); + +function err(path: string, message: string): Issue { + return { file: "config.yaml", severity: "error", path, message }; +} + +// Mirrors the runtime mount rules (formerly mounts.ts commonTargetChecks + +// validateTargetForType). Returns at most one issue per target, in the same +// order the runtime threw, so parse-throw mode preserves prior error messages. +function commonTargetIssue(target: string, where: string): Issue[] { + if (!target.startsWith("/")) { + return [err(`${where}.target`, `mount target must be absolute: ${target}`)]; + } + if (target.split("/").includes("..")) { + return [err(`${where}.target`, `mount target must not contain '..' segments: ${target}`)]; + } + if (target.startsWith(SANDBOX_OPENLOCK_PREFIX)) { + const top = target.slice(SANDBOX_OPENLOCK_PREFIX.length).split("/")[0]; + if (top !== undefined && RESERVED_MOUNT_NAMES.has(top)) { + return [ + err( + `${where}.target`, + `mount target conflicts with openlock-internal name '${top}': ${target}`, + ), + ]; + } + } + return []; +} + +function copyTargetIssue(target: string, type: MountType, where: string): Issue[] { + if (target === "/sandbox/repo") { + return [ + err( + `${where}.target`, + `target /sandbox/repo not supported with type '${type}'; use git-bundle (host repo bundled in) or bind (live host sync), or omit the workdir mount`, + ), + ]; + } + if ( + !target.startsWith(SANDBOX_OPENLOCK_PREFIX) || + target.length <= SANDBOX_OPENLOCK_PREFIX.length + ) { + return [ + err( + `${where}.target`, + `mount target must be under /sandbox/.openlock/ for type '${type}': ${target}`, + ), + ]; + } + return []; +} + +function targetIssues(target: string, type: MountType, where: string): Issue[] { + const common = commonTargetIssue(target, where); + if (common.length > 0) return common; + if (type === "copy-once" || type === "copy-refresh") return copyTargetIssue(target, type, where); + if (type === "git-bundle" && target.startsWith(SANDBOX_OPENLOCK_PREFIX)) { + return [ + err(`${where}.target`, `git-bundle target must not be under /sandbox/.openlock/: ${target}`), + ]; + } + return []; +} + +interface RawMount { + source?: unknown; + target?: unknown; + type?: unknown; + readOnly?: unknown; +} + +// Runs only after validateManifestSchema passes, so source/target/type are valid. +export function validateManifestSemantics(doc: Record): Issue[] { + const issues: Issue[] = []; + const mounts = Array.isArray(doc.mounts) ? (doc.mounts as RawMount[]) : []; + const targets = new Set(); + const bundleBasenames = new Map(); + mounts.forEach((m, i) => { + const where = `mounts[${i}]`; + const target = m.target as string; + const type = m.type as MountType; + issues.push(...targetIssues(target, type, where)); + if (targets.has(target)) { + issues.push(err(`${where}.target`, `duplicate target ${target}`)); + } else { + targets.add(target); + } + if (type === "git-bundle") { + const base = basename(m.source as string); + const prev = bundleBasenames.get(base); + if (prev !== undefined) { + issues.push( + err( + where, + `source basename '${base}' collides between git-bundle mounts (already used by mounts[${prev}])`, + ), + ); + } else { + bundleBasenames.set(base, i); + } + } + }); + return issues; +} From 27d331cbe71570ecbece8d01884d6a91562e2921 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kova=C4=BE?= Date: Sat, 30 May 2026 18:15:40 +0200 Subject: [PATCH 03/10] feat(config-core): manifest filesystem source checks --- src/config-core/manifest/filesystem.test.ts | 67 +++++++++++++++++++++ src/config-core/manifest/filesystem.ts | 56 +++++++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 src/config-core/manifest/filesystem.test.ts create mode 100644 src/config-core/manifest/filesystem.ts diff --git a/src/config-core/manifest/filesystem.test.ts b/src/config-core/manifest/filesystem.test.ts new file mode 100644 index 0000000..96beb59 --- /dev/null +++ b/src/config-core/manifest/filesystem.test.ts @@ -0,0 +1,67 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { homedir, tmpdir } from "node:os"; +import { join } from "node:path"; +import { resolveSource, validateManifestFilesystem } from "./filesystem"; + +let root: string; +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "openlock-fs-test-")); +}); +afterEach(() => { + rmSync(root, { recursive: true, force: true }); +}); + +describe("validateManifestFilesystem", () => { + it("reports a missing source as a filesystem issue", () => { + const issues = validateManifestFilesystem( + { mounts: [{ source: "nope", target: "/sandbox/.openlock/x", type: "copy-once" }] }, + root, + ); + expect(issues[0]?.severity).toBe("filesystem"); + expect(issues[0]?.message).toMatch(/does not exist/); + }); + + it("reports a file source for a directory-requiring type", () => { + const f = join(root, "file"); + writeFileSync(f, "x"); + const issues = validateManifestFilesystem( + { mounts: [{ source: f, target: "/sandbox/.openlock/x", type: "copy-once" }] }, + root, + ); + expect(issues[0]?.message).toMatch(/not a directory/); + }); + + it("reports a non-git source for git-bundle", () => { + const d = join(root, "d"); + mkdirSync(d); + const issues = validateManifestFilesystem( + { mounts: [{ source: d, target: "/sandbox/repo", type: "git-bundle" }] }, + root, + ); + expect(issues[0]?.message).toMatch(/not a git working tree/); + }); + + it("passes when sources exist and match the type", () => { + const d = join(root, "ok"); + mkdirSync(d); + expect( + validateManifestFilesystem( + { mounts: [{ source: "ok", target: "/sandbox/.openlock/x", type: "copy-once" }] }, + root, + ), + ).toEqual([]); + }); +}); + +describe("resolveSource", () => { + it("resolves a relative source against projectRoot", () => { + expect(resolveSource("/proj", "sub")).toBe("/proj/sub"); + }); + it("keeps an absolute source", () => { + expect(resolveSource("/proj", "/abs")).toBe("/abs"); + }); + it("expands ~ to homedir", () => { + expect(resolveSource("/proj", "~/foo")).toBe(join(homedir(), "foo")); + }); +}); diff --git a/src/config-core/manifest/filesystem.ts b/src/config-core/manifest/filesystem.ts new file mode 100644 index 0000000..93aa52b --- /dev/null +++ b/src/config-core/manifest/filesystem.ts @@ -0,0 +1,56 @@ +import { existsSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { isAbsolute, join, resolve } from "node:path"; +import type { Issue, MountType } from "../types"; + +function fsIssue(path: string, message: string): Issue { + return { file: "config.yaml", severity: "filesystem", path, message }; +} + +function expandHome(p: string): string { + if (p === "~") return homedir(); + if (p.startsWith("~/")) return resolve(homedir(), p.slice(2)); + return p; +} + +export function resolveSource(projectRoot: string, raw: string): string { + const expanded = expandHome(raw); + return isAbsolute(expanded) ? expanded : resolve(projectRoot, expanded); +} + +interface RawMount { + source?: unknown; + target?: unknown; + type?: unknown; + readOnly?: unknown; +} + +// Runs only after schema passes, so source/type are valid. Each mount yields at +// most one issue, in the runtime's order (existence -> kind -> git-tree). +export function validateManifestFilesystem( + doc: Record, + projectRoot: string, +): Issue[] { + const issues: Issue[] = []; + const mounts = Array.isArray(doc.mounts) ? (doc.mounts as RawMount[]) : []; + mounts.forEach((m, i) => { + const where = `mounts[${i}]`; + const type = m.type as MountType; + const source = resolveSource(projectRoot, m.source as string); + if (!existsSync(source)) { + issues.push(fsIssue(`${where}.source`, `source ${source} does not exist`)); + return; + } + const isDir = statSync(source).isDirectory(); + if ((type === "copy-once" || type === "copy-refresh" || type === "git-bundle") && !isDir) { + issues.push(fsIssue(`${where}.source`, `source ${source} is not a directory`)); + return; + } + if (type === "git-bundle" && !existsSync(join(source, ".git"))) { + issues.push( + fsIssue(`${where}.source`, `source ${source} is not a git working tree (missing .git)`), + ); + } + }); + return issues; +} From 32f894c883b1d628415e22f6cbf15c14afc0241b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kova=C4=BE?= Date: Sat, 30 May 2026 18:26:35 +0200 Subject: [PATCH 04/10] feat(config-core): lintManifest + parseManifest (single rule source) --- src/config-core/manifest/index.test.ts | 683 +++++++++++++++++++++++++ src/config-core/manifest/index.ts | 71 +++ 2 files changed, 754 insertions(+) create mode 100644 src/config-core/manifest/index.test.ts create mode 100644 src/config-core/manifest/index.ts diff --git a/src/config-core/manifest/index.test.ts b/src/config-core/manifest/index.test.ts new file mode 100644 index 0000000..8989b42 --- /dev/null +++ b/src/config-core/manifest/index.test.ts @@ -0,0 +1,683 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { homedir, tmpdir } from "node:os"; +import { join } from "node:path"; +import { lintManifest, parseManifest } from "./index"; + +let root: string; +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "openlock-manifest-test-")); +}); +afterEach(() => { + rmSync(root, { recursive: true, force: true }); +}); + +describe("lintManifest", () => { + it("accepts an empty manifest", () => { + expect(lintManifest({}, root, { offline: true })).toEqual([]); + }); + + it("reports a YAML parse error when given a bad string", () => { + const issues = lintManifest("a: b: c\n", root, { offline: true }); + expect(issues[0]?.message).toMatch(/YAML parse error/); + }); + + it("flags a leftover caps key as an unknown-key error", () => { + const issues = lintManifest("caps: [js]\n", root, { offline: true }); + expect(issues).toHaveLength(1); + expect(issues[0]?.severity).toBe("error"); + expect(issues[0]?.message).toMatch(/unknown key "caps"/); + }); + + it("short-circuits: a schema error suppresses semantic/filesystem", () => { + const issues = lintManifest({ mounts: [{ source: "s", target: "/x", type: "bad" }] }, root, { + offline: false, + }); + expect(issues).toHaveLength(1); + expect(issues[0]?.message).toMatch(/unknown type 'bad'/); + }); + + it("offline:true skips filesystem checks", () => { + const doc = { + mounts: [{ source: "nope", target: "/sandbox/.openlock/x", type: "copy-once" }], + }; + expect(lintManifest(doc, root, { offline: true })).toEqual([]); + expect(lintManifest(doc, root, { offline: false })[0]?.severity).toBe("filesystem"); + }); + + it("collects semantic + filesystem issues together (offline:false)", () => { + const doc = { mounts: [{ source: "nope", target: "/etc/x", type: "copy-once" }] }; + const issues = lintManifest(doc, root, { offline: false }); + expect(issues.map((i) => i.severity).sort()).toEqual(["error", "filesystem"]); + }); + + it("accepts a valid YAML string manifest", () => { + expect(lintManifest("mounts: []\nargs: []\n", root, { offline: true })).toEqual([]); + }); +}); + +describe("parseManifest", () => { + it("returns a typed config with resolved sources", () => { + mkdirSync(join(root, "seed")); + const cfg = parseManifest( + { + mounts: [{ source: "seed", target: "/sandbox/.openlock/x", type: "copy-once" }], + args: ["--x"], + env: { A: "1" }, + }, + root, + ); + expect(cfg.mounts[0]?.source).toBe(join(root, "seed")); + expect(cfg.args).toEqual(["--x"]); + expect(cfg.env).toEqual({ A: "1" }); + }); + + it("defaults to empty arrays/object for an empty manifest", () => { + expect(parseManifest({}, root)).toEqual({ mounts: [], args: [], env: {} }); + }); + + it("throws on an unknown key (caps)", () => { + expect(() => parseManifest({ caps: ["js"] }, root)).toThrow(/unknown key "caps"/); + }); + + it("throws on a missing source (filesystem)", () => { + expect(() => + parseManifest( + { mounts: [{ source: "nope", target: "/sandbox/.openlock/x", type: "copy-once" }] }, + root, + ), + ).toThrow(/does not exist/); + }); + + it("throws a bad-target message matching the prior runtime", () => { + mkdirSync(join(root, "s")); + expect(() => + parseManifest({ mounts: [{ source: "s", target: "/etc/passwd", type: "copy-once" }] }, root), + ).toThrow(/under \/sandbox\/\.openlock\//); + }); + + it("builds config from a YAML string input", () => { + const cfg = parseManifest("args:\n - --x\nenv:\n A: '1'\n", root); + expect(cfg).toEqual({ mounts: [], args: ["--x"], env: { A: "1" } }); + }); +}); + +describe("parseManifest (ported parseMounts cases)", () => { + it("[ported] returns [] when raw is undefined", () => { + expect(parseManifest({}, root).mounts).toEqual([]); + }); + + it("[ported] returns [] when raw is an empty list", () => { + expect(parseManifest({ mounts: [] }, root).mounts).toEqual([]); + }); + + it("[ported] throws when raw is not a list", () => { + expect(() => parseManifest({ mounts: {} }, root)).toThrow(/'mounts' must be a list/); + }); + + it("[ported] resolves a relative source path against projectRoot", () => { + const src = join(root, "seeds"); + mkdirSync(src); + const [m] = parseManifest( + { mounts: [{ source: "seeds", target: "/sandbox/.openlock/x", type: "copy-once" }] }, + root, + ).mounts; + expect(m?.source).toBe(src); + }); + + it("[ported] expands ~ in source", () => { + const testDir = join(homedir(), ".openlock-mounts-test-tmp"); + mkdirSync(testDir, { recursive: true }); + try { + const [m] = parseManifest( + { + mounts: [ + { + source: "~/.openlock-mounts-test-tmp", + target: "/sandbox/.openlock/x", + type: "copy-once", + }, + ], + }, + root, + ).mounts; + expect(m?.source).toBe(testDir); + } finally { + rmSync(testDir, { recursive: true, force: true }); + } + }); + + it("[ported] accepts absolute source", () => { + const src = join(root, "abs"); + mkdirSync(src); + const [m] = parseManifest( + { mounts: [{ source: src, target: "/sandbox/.openlock/x", type: "copy-once" }] }, + root, + ).mounts; + expect(m?.source).toBe(src); + }); + + it("[ported] throws when source is missing or not a string", () => { + expect(() => + parseManifest({ mounts: [{ target: "/sandbox/.openlock/x", type: "copy-once" }] }, root), + ).toThrow(/source/); + }); + + it("[ported] throws when source does not exist", () => { + expect(() => + parseManifest( + { + mounts: [ + { + source: "/nope/does/not/exist", + target: "/sandbox/.openlock/x", + type: "copy-once", + }, + ], + }, + root, + ), + ).toThrow(/source.*does not exist/); + }); + + it("[ported] throws when source is a file, not a directory", () => { + const f = join(root, "file"); + writeFileSync(f, "x"); + expect(() => + parseManifest( + { mounts: [{ source: f, target: "/sandbox/.openlock/x", type: "copy-once" }] }, + root, + ), + ).toThrow(/source.*not a directory/); + }); + + it("[ported] throws when target is missing", () => { + const src = join(root, "s"); + mkdirSync(src); + expect(() => parseManifest({ mounts: [{ source: src, type: "copy-once" }] }, root)).toThrow( + /target/, + ); + }); + + it("[ported] throws when target is not absolute", () => { + const src = join(root, "s"); + mkdirSync(src); + expect(() => + parseManifest({ mounts: [{ source: src, target: "sandbox/x", type: "copy-once" }] }, root), + ).toThrow(/absolute/); + }); + + it("[ported] throws when target is not under /sandbox/.openlock/", () => { + const src = join(root, "s"); + mkdirSync(src); + expect(() => + parseManifest({ mounts: [{ source: src, target: "/etc/passwd", type: "copy-once" }] }, root), + ).toThrow(/under \/sandbox\/\.openlock\//); + }); + + it("[ported] throws when target is under /sandbox/ but not under /sandbox/.openlock/", () => { + const src = join(root, "s"); + mkdirSync(src); + expect(() => + parseManifest( + { mounts: [{ source: src, target: "/sandbox/scratch", type: "copy-once" }] }, + root, + ), + ).toThrow(/under \/sandbox\/\.openlock\//); + }); + + it("[ported] throws when target equals /sandbox/.openlock or /sandbox/.openlock/", () => { + const src = join(root, "s"); + mkdirSync(src); + expect(() => + parseManifest( + { mounts: [{ source: src, target: "/sandbox/.openlock", type: "copy-once" }] }, + root, + ), + ).toThrow(/under \/sandbox\/\.openlock\//); + }); + + it("[ported] throws when target contains a .. segment", () => { + const src = join(root, "s"); + mkdirSync(src); + expect(() => + parseManifest( + { mounts: [{ source: src, target: "/sandbox/../etc/passwd", type: "copy-once" }] }, + root, + ), + ).toThrow(/must not contain '\.\.'/); + }); + + it("[ported] rejects /sandbox/.openlock/../etc/passwd (prefix-bypass via ..)", () => { + const src = join(root, "s"); + mkdirSync(src); + expect(() => + parseManifest( + { + mounts: [{ source: src, target: "/sandbox/.openlock/../etc/passwd", type: "copy-once" }], + }, + root, + ), + ).toThrow(/must not contain '\.\.'/); + }); + + it("[ported] throws when target's top segment collides with openlock-internal name (.gitconfig)", () => { + const src = join(root, "s"); + mkdirSync(src); + expect(() => + parseManifest( + { + mounts: [{ source: src, target: "/sandbox/.openlock/.gitconfig", type: "copy-once" }], + }, + root, + ), + ).toThrow(/conflicts with openlock-internal name '\.gitconfig'/); + }); + + it("[ported] also catches collision when reserved name is the prefix of a deeper path", () => { + const src = join(root, "s"); + mkdirSync(src); + expect(() => + parseManifest( + { + mounts: [{ source: src, target: "/sandbox/.openlock/bundles/sub", type: "copy-once" }], + }, + root, + ), + ).toThrow(/conflicts with openlock-internal name 'bundles'/); + }); + + it("[ported] throws when target's top segment is reserved 'bundles' (copy-once)", () => { + const src = join(root, "s"); + mkdirSync(src); + expect(() => + parseManifest( + { + mounts: [{ source: src, target: "/sandbox/.openlock/bundles", type: "copy-once" }], + }, + root, + ), + ).toThrow(/conflicts with openlock-internal name 'bundles'/); + }); + + it("[ported] throws when two mounts share a target", () => { + const a = join(root, "a"); + const b = join(root, "b"); + mkdirSync(a); + mkdirSync(b); + expect(() => + parseManifest( + { + mounts: [ + { source: a, target: "/sandbox/.openlock/x", type: "copy-once" }, + { source: b, target: "/sandbox/.openlock/x", type: "copy-refresh" }, + ], + }, + root, + ), + ).toThrow(/duplicate target/); + }); + + it("[ported] throws when type is missing", () => { + const src = join(root, "s"); + mkdirSync(src); + expect(() => + parseManifest({ mounts: [{ source: src, target: "/sandbox/.openlock/x" }] }, root), + ).toThrow(/type/); + }); + + it("[ported] throws when type is unknown", () => { + const src = join(root, "s"); + mkdirSync(src); + expect(() => + parseManifest( + { mounts: [{ source: src, target: "/sandbox/.openlock/x", type: "no-such-type" }] }, + root, + ), + ).toThrow(/unknown type/); + }); + + it("[ported] accepts type copy-once and copy-refresh", () => { + const a = join(root, "a"); + const b = join(root, "b"); + mkdirSync(a); + mkdirSync(b); + const ms = parseManifest( + { + mounts: [ + { source: a, target: "/sandbox/.openlock/a", type: "copy-once" }, + { source: b, target: "/sandbox/.openlock/b", type: "copy-refresh" }, + ], + }, + root, + ).mounts; + expect(ms.map((m) => m.type)).toEqual(["copy-once", "copy-refresh"]); + }); + + it("[ported] accepts type: bind with a directory source", () => { + const src = join(root, "bind-dir"); + mkdirSync(src); + const [m] = parseManifest( + { mounts: [{ source: src, target: "/sandbox/.openlock/bound", type: "bind" }] }, + root, + ).mounts; + expect(m?.type).toBe("bind"); + expect(m?.source).toBe(src); + }); + + it("[ported] accepts type: bind with a file source", () => { + const f = join(root, "bind-file"); + writeFileSync(f, "hello"); + const [m] = parseManifest( + { mounts: [{ source: f, target: "/sandbox/.openlock/file", type: "bind" }] }, + root, + ).mounts; + expect(m?.type).toBe("bind"); + expect(m?.source).toBe(f); + }); + + it("[ported] accepts type: git-bundle with a git working tree source", () => { + const src = join(root, "repo"); + mkdirSync(src); + mkdirSync(join(src, ".git")); + writeFileSync(join(src, ".git/HEAD"), "ref: refs/heads/main\n"); + const [m] = parseManifest( + { mounts: [{ source: src, target: "/sandbox/repo", type: "git-bundle" }] }, + root, + ).mounts; + expect(m?.type).toBe("git-bundle"); + expect(m?.source).toBe(src); + }); + + it("[ported] rejects type: git-bundle with non-git directory source", () => { + const src = join(root, "not-a-repo"); + mkdirSync(src); + expect(() => + parseManifest( + { mounts: [{ source: src, target: "/sandbox/repo", type: "git-bundle" }] }, + root, + ), + ).toThrow(/not a git working tree/); + }); + + it("[ported] rejects type: git-bundle with file source", () => { + const f = join(root, "file"); + writeFileSync(f, ""); + expect(() => + parseManifest({ mounts: [{ source: f, target: "/sandbox/repo", type: "git-bundle" }] }, root), + ).toThrow(/not a directory/); + }); + + it("[ported] accepts readOnly: true on type: bind", () => { + const src = join(root, "bind-ro"); + mkdirSync(src); + const [m] = parseManifest( + { + mounts: [{ source: src, target: "/sandbox/.openlock/ro", type: "bind", readOnly: true }], + }, + root, + ).mounts; + expect(m?.readOnly).toBe(true); + }); + + it("[ported] accepts readOnly: false on type: bind (or absent)", () => { + const src = join(root, "bind-rw"); + mkdirSync(src); + const ms = parseManifest( + { + mounts: [ + { source: src, target: "/sandbox/.openlock/a", type: "bind", readOnly: false }, + { source: src, target: "/sandbox/.openlock/b", type: "bind" }, + ], + }, + root, + ).mounts; + expect(ms[0]?.readOnly).toBe(false); + expect(ms[1]?.readOnly).toBeUndefined(); + }); + + it("[ported] rejects readOnly on type: copy-once", () => { + const src = join(root, "co"); + mkdirSync(src); + expect(() => + parseManifest( + { + mounts: [ + { + source: src, + target: "/sandbox/.openlock/x", + type: "copy-once", + readOnly: true, + }, + ], + }, + root, + ), + ).toThrow(/readOnly is only valid on type: bind/); + }); + + it("[ported] rejects readOnly on type: copy-refresh", () => { + const src = join(root, "cr"); + mkdirSync(src); + expect(() => + parseManifest( + { + mounts: [ + { + source: src, + target: "/sandbox/.openlock/x", + type: "copy-refresh", + readOnly: true, + }, + ], + }, + root, + ), + ).toThrow(/readOnly is only valid on type: bind/); + }); + + it("[ported] rejects readOnly on type: git-bundle", () => { + const src = join(root, "gb"); + mkdirSync(src); + mkdirSync(join(src, ".git")); + writeFileSync(join(src, ".git/HEAD"), "ref: refs/heads/main\n"); + expect(() => + parseManifest( + { + mounts: [ + { + source: src, + target: "/sandbox/repo", + type: "git-bundle", + readOnly: true, + }, + ], + }, + root, + ), + ).toThrow(/readOnly is only valid on type: bind/); + }); + + it("[ported] rejects non-boolean readOnly", () => { + const src = join(root, "bind"); + mkdirSync(src); + expect(() => + parseManifest( + { + mounts: [ + { + source: src, + target: "/sandbox/.openlock/x", + type: "bind", + // biome-ignore lint/suspicious/noExplicitAny: testing invalid input + readOnly: "yes" as any, + }, + ], + }, + root, + ), + ).toThrow(/readOnly must be a boolean/); + }); + + it("[ported] bind: accepts target outside /sandbox/.openlock/", () => { + const src = join(root, "bind-out"); + mkdirSync(src); + const [m] = parseManifest( + { mounts: [{ source: src, target: "/sandbox/extras", type: "bind" }] }, + root, + ).mounts; + expect(m?.target).toBe("/sandbox/extras"); + expect(m?.type).toBe("bind"); + }); + + it("[ported] bind: accepts target /sandbox/repo (workdir override)", () => { + const src = join(root, "bind-repo"); + mkdirSync(src); + const [m] = parseManifest( + { mounts: [{ source: src, target: "/sandbox/repo", type: "bind" }] }, + root, + ).mounts; + expect(m?.target).toBe("/sandbox/repo"); + expect(m?.type).toBe("bind"); + }); + + it("[ported] bind: rejects target reserved .gitconfig", () => { + const src = join(root, "bind-gc"); + mkdirSync(src); + expect(() => + parseManifest( + { + mounts: [{ source: src, target: "/sandbox/.openlock/.gitconfig", type: "bind" }], + }, + root, + ), + ).toThrow(/conflicts with openlock-internal name '\.gitconfig'/); + }); + + it("[ported] bind: rejects target reserved bundles", () => { + const src = join(root, "bind-bn"); + mkdirSync(src); + expect(() => + parseManifest( + { mounts: [{ source: src, target: "/sandbox/.openlock/bundles", type: "bind" }] }, + root, + ), + ).toThrow(/conflicts with openlock-internal name 'bundles'/); + }); + + it("[ported] bind: rejects target with '..' segment", () => { + const src = join(root, "bind-dd"); + mkdirSync(src); + expect(() => + parseManifest({ mounts: [{ source: src, target: "/sandbox/../etc", type: "bind" }] }, root), + ).toThrow(/must not contain '\.\.'/); + }); + + it("[ported] bind: rejects non-absolute target", () => { + const src = join(root, "bind-rel"); + mkdirSync(src); + expect(() => + parseManifest({ mounts: [{ source: src, target: "sandbox/extras", type: "bind" }] }, root), + ).toThrow(/must be absolute/); + }); + + it("[ported] git-bundle: accepts target /sandbox/repo", () => { + const src = join(root, "gb-repo"); + mkdirSync(src); + mkdirSync(join(src, ".git")); + writeFileSync(join(src, ".git/HEAD"), "ref: refs/heads/main\n"); + const [m] = parseManifest( + { mounts: [{ source: src, target: "/sandbox/repo", type: "git-bundle" }] }, + root, + ).mounts; + expect(m?.target).toBe("/sandbox/repo"); + }); + + it("[ported] git-bundle: accepts target /sandbox/extra-repo", () => { + const src = join(root, "gb-extra"); + mkdirSync(src); + mkdirSync(join(src, ".git")); + writeFileSync(join(src, ".git/HEAD"), "ref: refs/heads/main\n"); + const [m] = parseManifest( + { mounts: [{ source: src, target: "/sandbox/extra-repo", type: "git-bundle" }] }, + root, + ).mounts; + expect(m?.target).toBe("/sandbox/extra-repo"); + }); + + it("[ported] git-bundle: rejects target under /sandbox/.openlock/", () => { + const src = join(root, "gb-bad"); + mkdirSync(src); + mkdirSync(join(src, ".git")); + writeFileSync(join(src, ".git/HEAD"), "ref: refs/heads/main\n"); + expect(() => + parseManifest( + { + mounts: [ + { + source: src, + target: "/sandbox/.openlock/some-repo", + type: "git-bundle", + }, + ], + }, + root, + ), + ).toThrow(/git-bundle target must not be under \/sandbox\/\.openlock\//); + }); + + it("[ported] rejects copy-once targeting /sandbox/repo", () => { + const src = join(root, "co-repo"); + mkdirSync(src); + expect(() => + parseManifest( + { mounts: [{ source: src, target: "/sandbox/repo", type: "copy-once" }] }, + root, + ), + ).toThrow(/\/sandbox\/repo not supported with type 'copy-once'/); + }); + + it("[ported] rejects copy-refresh targeting /sandbox/repo", () => { + const src = join(root, "cr-repo"); + mkdirSync(src); + expect(() => + parseManifest( + { mounts: [{ source: src, target: "/sandbox/repo", type: "copy-refresh" }] }, + root, + ), + ).toThrow(/\/sandbox\/repo not supported with type 'copy-refresh'/); + }); + + it("[ported] accepts zero workdir mounts (no mount targets /sandbox/repo)", () => { + const src = join(root, "no-wd"); + mkdirSync(src); + const ms = parseManifest( + { mounts: [{ source: src, target: "/sandbox/.openlock/x", type: "copy-once" }] }, + root, + ).mounts; + expect(ms).toHaveLength(1); + expect(ms.find((m) => m.target === "/sandbox/repo")).toBeUndefined(); + }); + + it("[ported] rejects two git-bundle mounts whose source basenames collide", () => { + const a = join(root, "outer/app"); + const b = join(root, "inner/app"); + mkdirSync(a, { recursive: true }); + mkdirSync(b, { recursive: true }); + mkdirSync(join(a, ".git")); + mkdirSync(join(b, ".git")); + writeFileSync(join(a, ".git/HEAD"), "ref: refs/heads/main\n"); + writeFileSync(join(b, ".git/HEAD"), "ref: refs/heads/main\n"); + expect(() => + parseManifest( + { + mounts: [ + { source: a, target: "/sandbox/repo", type: "git-bundle" }, + { source: b, target: "/sandbox/extra-repo", type: "git-bundle" }, + ], + }, + root, + ), + ).toThrow(/source basename 'app' collides between git-bundle mounts/); + }); +}); diff --git a/src/config-core/manifest/index.ts b/src/config-core/manifest/index.ts new file mode 100644 index 0000000..1e40517 --- /dev/null +++ b/src/config-core/manifest/index.ts @@ -0,0 +1,71 @@ +import yaml from "js-yaml"; +import type { Issue, ManifestConfig, Mount, MountType } from "../types"; +import { resolveSource, validateManifestFilesystem } from "./filesystem"; +import { validateManifestSchema } from "./schema"; +import { validateManifestSemantics } from "./semantic"; + +interface ParsedDoc { + doc: unknown; + parseError?: Issue; +} + +/** Normalize raw input (a YAML string or an already-parsed value) to a doc. + * A YAML syntax error is returned as a collectible Issue, not thrown. */ +function parseDoc(raw: unknown): ParsedDoc { + if (typeof raw !== "string") return { doc: raw ?? {} }; + try { + return { doc: yaml.load(raw) ?? {} }; + } catch (e) { + return { + doc: {}, + parseError: { + file: "config.yaml", + severity: "error", + path: "", + message: `YAML parse error: ${(e as Error).message}`, + }, + }; + } +} + +/** Collect-all validation. Accepts either a YAML string or an already-parsed + * object. Schema errors short-circuit semantic/filesystem (a structurally + * broken doc can't be meaningfully cross-checked). */ +export function lintManifest( + raw: unknown, + projectRoot: string, + opts: { offline: boolean }, +): Issue[] { + const { doc, parseError } = parseDoc(raw); + if (parseError) return [parseError]; + const schema = validateManifestSchema(doc); + if (schema.length > 0) return schema; + const obj = doc as Record; + const semantic = validateManifestSemantics(obj); + const filesystem = opts.offline ? [] : validateManifestFilesystem(obj, projectRoot); + return [...semantic, ...filesystem]; +} + +/** Strict parse for the runtime launch path. Throws the first blocking issue + * (error or filesystem). The message body matches the validator's message; the + * `mounts[i]` location the old runtime prefixed now lives in the issue's path. */ +export function parseManifest(raw: unknown, projectRoot: string): ManifestConfig { + const issues = lintManifest(raw, projectRoot, { offline: false }); + const blocking = issues[0]; + if (blocking) throw new Error(blocking.message); + const obj = parseDoc(raw).doc as Record; + const rawMounts = Array.isArray(obj.mounts) ? obj.mounts : []; + const mounts: Mount[] = rawMounts.map((m) => { + const rm = m as { source: string; target: string; type: MountType; readOnly?: boolean }; + const mount: Mount = { + source: resolveSource(projectRoot, rm.source), + target: rm.target, + type: rm.type, + }; + if (rm.readOnly !== undefined) mount.readOnly = rm.readOnly; + return mount; + }); + const args = Array.isArray(obj.args) ? (obj.args as string[]) : []; + const env = (obj.env ?? {}) as Record; + return { mounts, args, env }; +} From a04a39751f2548029a32087e08bf6a7ae1b8aa85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kova=C4=BE?= Date: Sat, 30 May 2026 18:41:14 +0200 Subject: [PATCH 05/10] refactor(config-core): fold policy validator in; drop validate-policy command --- src/cli.ts | 6 ---- src/cli/_commands.test.ts | 1 - src/cli/_commands.ts | 2 -- src/cli/_descriptions.ts | 1 - src/cli/validate-policy.test.ts | 8 ----- src/cli/validate-policy.ts | 35 ------------------- .../policy}/index.test.ts | 0 .../policy}/index.ts | 12 +++++++ src/config-core/policy/lint-policy.test.ts | 14 ++++++++ .../policy}/schema.test.ts | 0 .../policy}/schema.ts | 0 .../policy}/semantic.test.ts | 0 .../policy}/semantic.ts | 0 .../policy}/types.ts | 0 14 files changed, 26 insertions(+), 53 deletions(-) delete mode 100644 src/cli/validate-policy.test.ts delete mode 100644 src/cli/validate-policy.ts rename src/{validate-policy => config-core/policy}/index.test.ts (100%) rename src/{validate-policy => config-core/policy}/index.ts (79%) create mode 100644 src/config-core/policy/lint-policy.test.ts rename src/{validate-policy => config-core/policy}/schema.test.ts (100%) rename src/{validate-policy => config-core/policy}/schema.ts (100%) rename src/{validate-policy => config-core/policy}/semantic.test.ts (100%) rename src/{validate-policy => config-core/policy}/semantic.ts (100%) rename src/{validate-policy => config-core/policy}/types.ts (100%) diff --git a/src/cli.ts b/src/cli.ts index 73c3b45..66bc425 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -20,7 +20,6 @@ Session lifecycle: Other: cred-refresh Start the credential refresh service - validate-policy Validate a sandbox policy YAML file login Authenticate with the gateway logout Remove stored provider credentials providers List configured providers @@ -96,11 +95,6 @@ function main(): void { case "cred-refresh": import("./cli/cred-refresh").then(({ credRefreshCmd }) => credRefreshCmd(args.slice(1))); return; - case "validate-policy": - import("./cli/validate-policy").then(({ validatePolicyCmd }) => - validatePolicyCmd(args.slice(1)), - ); - return; case "echo-server": console.error("echo-server not yet implemented"); process.exit(1); diff --git a/src/cli/_commands.test.ts b/src/cli/_commands.test.ts index 0a49622..5c2f992 100644 --- a/src/cli/_commands.test.ts +++ b/src/cli/_commands.test.ts @@ -13,7 +13,6 @@ describe("COMMAND_FLAGS", () => { "shell", "exec", "cred-refresh", - "validate-policy", "login", "logout", "providers", diff --git a/src/cli/_commands.ts b/src/cli/_commands.ts index ea59a40..26f1a83 100644 --- a/src/cli/_commands.ts +++ b/src/cli/_commands.ts @@ -17,7 +17,6 @@ import { flagSchema as shellFlags } from "./shell"; import { flagSchema as statusFlags } from "./status"; import { flagSchema as stopFlags } from "./stop"; import { flagSchema as updateImagesFlags } from "./update-images"; -import { flagSchema as validatePolicyFlags } from "./validate-policy"; export const COMMAND_FLAGS = { sandbox: sandboxFlags, @@ -29,7 +28,6 @@ export const COMMAND_FLAGS = { shell: shellFlags, exec: execFlags, "cred-refresh": credRefreshFlags, - "validate-policy": validatePolicyFlags, login: loginFlags, logout: logoutFlags, providers: providersFlags, diff --git a/src/cli/_descriptions.ts b/src/cli/_descriptions.ts index be57e28..0fafc4b 100644 --- a/src/cli/_descriptions.ts +++ b/src/cli/_descriptions.ts @@ -17,7 +17,6 @@ export const COMMAND_DESCRIPTIONS = { shell: "Open bash inside the session container", exec: "Run a command inside the session container", "cred-refresh": "Start the credential refresh service", - "validate-policy": "Validate a sandbox policy YAML file", login: "Authenticate with the gateway", logout: "Remove stored provider credentials", providers: "List configured providers", diff --git a/src/cli/validate-policy.test.ts b/src/cli/validate-policy.test.ts deleted file mode 100644 index 7dbe13e..0000000 --- a/src/cli/validate-policy.test.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import { flagSchema } from "./validate-policy"; - -describe("validate-policy flagSchema", () => { - it("declares only --help/-h", () => { - expect(Object.keys(flagSchema).sort()).toEqual(["help"]); - }); -}); diff --git a/src/cli/validate-policy.ts b/src/cli/validate-policy.ts deleted file mode 100644 index 6a110f2..0000000 --- a/src/cli/validate-policy.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { ParseArgsOptionsConfig } from "node:util"; -import { parseArgs } from "node:util"; -import { formatErrors, validatePolicyFile } from "../validate-policy"; -import { printCmdHelp } from "./_help"; - -export const flagSchema = { - help: { type: "boolean", short: "h" }, -} as const satisfies ParseArgsOptionsConfig; - -export function validatePolicyCmd(args: string[]): void { - const { values, positionals } = parseArgs({ args, options: flagSchema, allowPositionals: true }); - if (values.help === true) { - printCmdHelp("validate-policy", flagSchema, "..."); - return; - } - const files = positionals; - if (files.length === 0) { - console.error("[validate-policy] no files specified"); - console.error("Usage: openlock validate-policy [file2.yaml ...]"); - process.exit(1); - } - - let hasErrors = false; - for (const file of files) { - const errors = validatePolicyFile(file); - if (errors.length > 0) { - hasErrors = true; - console.error(formatErrors(errors, file)); - } else { - console.log(` ${file}: valid`); - } - } - - process.exit(hasErrors ? 1 : 0); -} diff --git a/src/validate-policy/index.test.ts b/src/config-core/policy/index.test.ts similarity index 100% rename from src/validate-policy/index.test.ts rename to src/config-core/policy/index.test.ts diff --git a/src/validate-policy/index.ts b/src/config-core/policy/index.ts similarity index 79% rename from src/validate-policy/index.ts rename to src/config-core/policy/index.ts index 9a4ed5a..29fe2c3 100644 --- a/src/validate-policy/index.ts +++ b/src/config-core/policy/index.ts @@ -1,5 +1,6 @@ import { readFileSync } from "node:fs"; import * as yaml from "js-yaml"; +import type { Issue } from "../types"; import { type ValidationError, validateSchema } from "./schema"; import { validateSemantics } from "./semantic"; import type { PolicyFile } from "./types"; @@ -45,3 +46,14 @@ export function formatErrors(errors: ValidationError[], filePath?: string): stri }) .join("\n"); } + +/** Adapt the policy validator's single-severity errors to the unified Issue + * shape consumed by lintFolder. */ +export function lintPolicy(content: string): Issue[] { + return validatePolicyYaml(content).map((e) => ({ + file: "policy.yaml" as const, + severity: "error" as const, + path: e.path, + message: e.message, + })); +} diff --git a/src/config-core/policy/lint-policy.test.ts b/src/config-core/policy/lint-policy.test.ts new file mode 100644 index 0000000..9b803b3 --- /dev/null +++ b/src/config-core/policy/lint-policy.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "bun:test"; +import { lintPolicy } from "./index"; + +describe("lintPolicy", () => { + it("returns [] for a valid minimal policy", () => { + expect(lintPolicy("version: 1\n")).toEqual([]); + }); + + it("tags policy issues with file=policy.yaml and severity=error", () => { + const issues = lintPolicy("filesystem_policy: {}\n"); // missing required version + expect(issues.length).toBeGreaterThan(0); + expect(issues.every((i) => i.file === "policy.yaml" && i.severity === "error")).toBe(true); + }); +}); diff --git a/src/validate-policy/schema.test.ts b/src/config-core/policy/schema.test.ts similarity index 100% rename from src/validate-policy/schema.test.ts rename to src/config-core/policy/schema.test.ts diff --git a/src/validate-policy/schema.ts b/src/config-core/policy/schema.ts similarity index 100% rename from src/validate-policy/schema.ts rename to src/config-core/policy/schema.ts diff --git a/src/validate-policy/semantic.test.ts b/src/config-core/policy/semantic.test.ts similarity index 100% rename from src/validate-policy/semantic.test.ts rename to src/config-core/policy/semantic.test.ts diff --git a/src/validate-policy/semantic.ts b/src/config-core/policy/semantic.ts similarity index 100% rename from src/validate-policy/semantic.ts rename to src/config-core/policy/semantic.ts diff --git a/src/validate-policy/types.ts b/src/config-core/policy/types.ts similarity index 100% rename from src/validate-policy/types.ts rename to src/config-core/policy/types.ts From ba38f738f21d3d718af4d51c3176840690064209 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kova=C4=BE?= Date: Sat, 30 May 2026 18:47:35 +0200 Subject: [PATCH 06/10] feat(config-core): lintFolder public entry point --- src/config-core/index.test.ts | 70 +++++++++++++++++++++++++++++++++++ src/config-core/index.ts | 48 ++++++++++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 src/config-core/index.test.ts create mode 100644 src/config-core/index.ts diff --git a/src/config-core/index.test.ts b/src/config-core/index.test.ts new file mode 100644 index 0000000..0b4580f --- /dev/null +++ b/src/config-core/index.test.ts @@ -0,0 +1,70 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { lintFolder } from "./index"; + +let root: string; +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "openlock-folder-lint-")); +}); +afterEach(() => { + rmSync(root, { recursive: true, force: true }); +}); + +function writeFolder(config: string, policy: string): void { + mkdirSync(join(root, ".openlock"), { recursive: true }); + writeFileSync(join(root, ".openlock/config.yaml"), config); + writeFileSync(join(root, ".openlock/policy.yaml"), policy); +} + +describe("lintFolder", () => { + it("errors for both files (with init hint) when .openlock/ is missing", () => { + const issues = lintFolder(root, { offline: false }); + expect(issues).toHaveLength(2); + expect(issues.map((i) => i.file).sort()).toEqual(["config.yaml", "policy.yaml"]); + expect(issues[0]?.message).toMatch(/no \.openlock\/ directory/); + expect(issues.every((i) => /openlock init/.test(i.fix ?? ""))).toBe(true); + }); + + it("flags a missing policy.yaml while still linting config.yaml", () => { + mkdirSync(join(root, ".openlock"), { recursive: true }); + writeFileSync(join(root, ".openlock/config.yaml"), "args: []\n"); + const issues = lintFolder(root, { offline: false }); + expect(issues).toHaveLength(1); + expect(issues[0]?.file).toBe("policy.yaml"); + expect(issues[0]?.message).toMatch(/policy\.yaml not found/); + }); + + it("flags a missing config.yaml while still linting policy.yaml", () => { + mkdirSync(join(root, ".openlock"), { recursive: true }); + writeFileSync(join(root, ".openlock/policy.yaml"), "version: 1\n"); + const issues = lintFolder(root, { offline: false }); + expect(issues).toHaveLength(1); + expect(issues[0]?.file).toBe("config.yaml"); + expect(issues[0]?.message).toMatch(/config\.yaml not found/); + }); + + it("returns [] for a valid folder", () => { + writeFolder("args: []\n", "version: 1\n"); + expect(lintFolder(root, { offline: false })).toEqual([]); + }); + + it("reports config and policy issues together, tagged by file", () => { + writeFolder("caps: [js]\n", "filesystem_policy: {}\n"); + const issues = lintFolder(root, { offline: false }); + expect(issues.some((i) => i.file === "config.yaml")).toBe(true); + expect(issues.some((i) => i.file === "policy.yaml")).toBe(true); + }); + + it("offline:true suppresses a missing-source filesystem issue", () => { + writeFolder( + "mounts:\n - source: nope\n target: /sandbox/.openlock/x\n type: copy-once\n", + "version: 1\n", + ); + expect(lintFolder(root, { offline: true })).toEqual([]); + expect(lintFolder(root, { offline: false }).some((i) => i.severity === "filesystem")).toBe( + true, + ); + }); +}); diff --git a/src/config-core/index.ts b/src/config-core/index.ts new file mode 100644 index 0000000..2927432 --- /dev/null +++ b/src/config-core/index.ts @@ -0,0 +1,48 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { lintManifest } from "./manifest/index"; +import { lintPolicy } from "./policy/index"; +import type { Issue } from "./types"; + +export { parseManifest } from "./manifest/index"; +export type { ConfigFile, Issue, ManifestConfig, Mount, MountType, Severity } from "./types"; + +/** Validate the whole .openlock/ folder (manifest + policy). Collect-all, + * never throws. Each issue is tagged with its source file. */ +export function lintFolder(projectDir: string, opts: { offline: boolean }): Issue[] { + const folder = join(projectDir, ".openlock"); + const fix = "run `openlock init` to scaffold it"; + if (!existsSync(folder)) { + const message = `no .openlock/ directory found in ${projectDir}`; + return [ + { file: "config.yaml", severity: "error", path: "", message, fix }, + { file: "policy.yaml", severity: "error", path: "", message, fix }, + ]; + } + const issues: Issue[] = []; + const configPath = join(folder, "config.yaml"); + if (existsSync(configPath)) { + issues.push(...lintManifest(readFileSync(configPath, "utf-8"), projectDir, opts)); + } else { + issues.push({ + file: "config.yaml", + severity: "error", + path: "", + message: "config.yaml not found", + fix, + }); + } + const policyPath = join(folder, "policy.yaml"); + if (existsSync(policyPath)) { + issues.push(...lintPolicy(readFileSync(policyPath, "utf-8"))); + } else { + issues.push({ + file: "policy.yaml", + severity: "error", + path: "", + message: "policy.yaml not found", + fix, + }); + } + return issues; +} From ab9f0745d27a24cd4b4b3e0437c8e999485521ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kova=C4=BE?= Date: Sat, 30 May 2026 18:54:45 +0200 Subject: [PATCH 07/10] feat(cli): openlock validate (config + policy) via config-core --- src/cli.ts | 4 +++ src/cli/_commands.test.ts | 1 + src/cli/_commands.ts | 2 ++ src/cli/_descriptions.ts | 1 + src/cli/validate.test.ts | 39 ++++++++++++++++++++++++++++ src/cli/validate.ts | 53 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 100 insertions(+) create mode 100644 src/cli/validate.test.ts create mode 100644 src/cli/validate.ts diff --git a/src/cli.ts b/src/cli.ts index 66bc425..4bc61b0 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -29,6 +29,7 @@ Other: update-base Rewrite .openlock/Containerfile FROM to current base hash prune-images Remove stale openlock images (use --legacy for pre-M5) refs Inspect and promote sandbox commits to real branches + validate Validate .openlock/ config + policy report Collect diagnostic bundle for bug reports complete Print shell completion script (bash|zsh|fish) @@ -150,6 +151,9 @@ function main(): void { completeCmd(args.slice(1)).then(processExit), ); return; + case "validate": + import("./cli/validate").then(({ validateCmd }) => validateCmd(args.slice(1))); + return; case "__list-sessions": import("./sandbox/session-store").then(({ listAllSessions, sessionsDir }) => { for (const m of listAllSessions(sessionsDir())) console.log(m.name); diff --git a/src/cli/_commands.test.ts b/src/cli/_commands.test.ts index 5c2f992..0439d40 100644 --- a/src/cli/_commands.test.ts +++ b/src/cli/_commands.test.ts @@ -22,6 +22,7 @@ describe("COMMAND_FLAGS", () => { "complete", "refs", "report", + "validate", ].sort(); expect(Object.keys(COMMAND_FLAGS).sort()).toEqual(expected); }); diff --git a/src/cli/_commands.ts b/src/cli/_commands.ts index 26f1a83..1be1ca9 100644 --- a/src/cli/_commands.ts +++ b/src/cli/_commands.ts @@ -17,6 +17,7 @@ import { flagSchema as shellFlags } from "./shell"; import { flagSchema as statusFlags } from "./status"; import { flagSchema as stopFlags } from "./stop"; import { flagSchema as updateImagesFlags } from "./update-images"; +import { flagSchema as validateFlags } from "./validate"; export const COMMAND_FLAGS = { sandbox: sandboxFlags, @@ -37,6 +38,7 @@ export const COMMAND_FLAGS = { complete: completeFlags, refs: refsFlags, report: reportFlags, + validate: validateFlags, } as const satisfies Record; export type CommandName = keyof typeof COMMAND_FLAGS; diff --git a/src/cli/_descriptions.ts b/src/cli/_descriptions.ts index 0fafc4b..8d6e98f 100644 --- a/src/cli/_descriptions.ts +++ b/src/cli/_descriptions.ts @@ -26,6 +26,7 @@ export const COMMAND_DESCRIPTIONS = { complete: "Print shell completion script", refs: "Inspect and promote sandbox commits to real branches", report: "Collect diagnostic bundle for bug reports", + validate: "Validate .openlock/ config + policy", } as const; export type CommandName = keyof typeof COMMAND_DESCRIPTIONS; diff --git a/src/cli/validate.test.ts b/src/cli/validate.test.ts new file mode 100644 index 0000000..308c365 --- /dev/null +++ b/src/cli/validate.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "bun:test"; +import type { Issue } from "../config-core"; +import { flagSchema, renderIssues } from "./validate"; + +describe("validate flagSchema", () => { + it("declares --offline and --help", () => { + expect(Object.keys(flagSchema).sort()).toEqual(["help", "offline"]); + }); +}); + +describe("renderIssues", () => { + it("prints ok per file when there are no issues", () => { + expect(renderIssues([])).toEqual([" config.yaml: ok", " policy.yaml: ok"]); + }); + + it("groups issues by file and tier with fix lines", () => { + const issues: Issue[] = [ + { + file: "config.yaml", + severity: "error", + path: "caps", + message: 'unknown key "caps"', + fix: 'remove "caps"', + }, + { + file: "config.yaml", + severity: "filesystem", + path: "mounts[0].source", + message: "source /x does not exist", + }, + ]; + const lines = renderIssues(issues); + expect(lines).toContain(" config.yaml:"); + expect(lines).toContain(' caps: unknown key "caps"'); + expect(lines).toContain(' fix: remove "caps"'); + expect(lines.some((l) => l.includes("[fs] mounts[0].source"))).toBe(true); + expect(lines).toContain(" policy.yaml: ok"); + }); +}); diff --git a/src/cli/validate.ts b/src/cli/validate.ts new file mode 100644 index 0000000..a96a5eb --- /dev/null +++ b/src/cli/validate.ts @@ -0,0 +1,53 @@ +import type { ParseArgsOptionsConfig } from "node:util"; +import { parseArgs } from "node:util"; +import type { ConfigFile, Issue, Severity } from "../config-core"; +import { lintFolder } from "../config-core"; +import { printCmdHelp } from "./_help"; + +export const flagSchema = { + offline: { type: "boolean" }, + help: { type: "boolean", short: "h" }, +} as const satisfies ParseArgsOptionsConfig; + +const FILE_ORDER: ConfigFile[] = ["config.yaml", "policy.yaml"]; +const SEVERITY_ORDER: Severity[] = ["error", "filesystem"]; + +function renderFile(file: ConfigFile, issues: Issue[]): string[] { + const lines: string[] = []; + if (issues.length === 0) { + lines.push(` ${file}: ok`); + return lines; + } + lines.push(` ${file}:`); + for (const sev of SEVERITY_ORDER) { + for (const issue of issues.filter((i) => i.severity === sev)) { + const loc = issue.path ? `${issue.path}: ` : ""; + const tag = sev === "filesystem" ? "[fs] " : ""; + lines.push(` ${tag}${loc}${issue.message}`); + if (issue.fix) lines.push(` fix: ${issue.fix}`); + } + } + return lines; +} + +export function renderIssues(issues: Issue[]): string[] { + const lines: string[] = []; + for (const file of FILE_ORDER) { + const forFile = issues.filter((i) => i.file === file); + lines.push(...renderFile(file, forFile)); + } + return lines; +} + +export function validateCmd(args: string[]): void { + const { values, positionals } = parseArgs({ args, options: flagSchema, allowPositionals: true }); + if (values.help === true) { + printCmdHelp("validate", flagSchema, "[path]"); + return; + } + const projectDir = positionals[0] ?? process.cwd(); + const issues = lintFolder(projectDir, { offline: values.offline === true }); + for (const line of renderIssues(issues)) console.log(line); + const blocking = issues.some((i) => i.severity === "error" || i.severity === "filesystem"); + process.exit(blocking ? 1 : 0); +} From 96a34e30e1369dfcb8dc3b82b0ca84a22d297640 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kova=C4=BE?= Date: Sat, 30 May 2026 19:05:10 +0200 Subject: [PATCH 08/10] refactor(sandbox): parse manifest via config-core; remove caps handling Route the runtime config-read path through parseManifest from config-core, deleting the duplicate parseMounts/parseArgs/parseEnv validators from mounts.ts and openlock-folder.ts. Stale caps keys now throw instead of emitting a deprecation warning. --- src/config-core/index.ts | 1 + src/sandbox/mounts.test.ts | 547 ++-------------------------- src/sandbox/mounts.ts | 182 +-------- src/sandbox/openlock-folder.test.ts | 7 +- src/sandbox/openlock-folder.ts | 61 +--- src/sandbox/session.ts | 6 - 6 files changed, 46 insertions(+), 758 deletions(-) diff --git a/src/config-core/index.ts b/src/config-core/index.ts index 2927432..3d4edeb 100644 --- a/src/config-core/index.ts +++ b/src/config-core/index.ts @@ -6,6 +6,7 @@ import type { Issue } from "./types"; export { parseManifest } from "./manifest/index"; export type { ConfigFile, Issue, ManifestConfig, Mount, MountType, Severity } from "./types"; +export { SANDBOX_OPENLOCK_PREFIX } from "./types"; /** Validate the whole .openlock/ folder (manifest + policy). Collect-all, * never throws. Each issue is tagged with its source file. */ diff --git a/src/sandbox/mounts.test.ts b/src/sandbox/mounts.test.ts index 700f3f2..d7e185e 100644 --- a/src/sandbox/mounts.test.ts +++ b/src/sandbox/mounts.test.ts @@ -9,18 +9,21 @@ import { symlinkSync, writeFileSync, } from "node:fs"; -import { homedir, tmpdir } from "node:os"; +import { tmpdir } from "node:os"; import { join } from "node:path"; +import { type Mount, parseManifest } from "../config-core"; import { bindMountArgs, gitBundleMounts, - type Mount, - parseMounts, stageMounts, stagingPathFor, workdirMount, } from "./mounts"; +function mk(raw: unknown[]): Mount[] { + return parseManifest({ mounts: raw }, projectRoot).mounts; +} + let projectRoot: string; beforeEach(() => { projectRoot = mkdtempSync(join(tmpdir(), "openlock-mounts-test-")); @@ -29,480 +32,6 @@ afterEach(() => { rmSync(projectRoot, { recursive: true, force: true }); }); -describe("parseMounts", () => { - it("returns [] when raw is undefined", () => { - expect(parseMounts(undefined, projectRoot)).toEqual([]); - }); - - it("returns [] when raw is an empty list", () => { - expect(parseMounts([], projectRoot)).toEqual([]); - }); - - it("throws when raw is not a list", () => { - expect(() => parseMounts({}, projectRoot)).toThrow(/'mounts' must be a list/); - }); - - it("resolves a relative source path against projectRoot", () => { - const src = join(projectRoot, "seeds"); - mkdirSync(src); - const [m] = parseMounts( - [{ source: "seeds", target: "/sandbox/.openlock/x", type: "copy-once" }], - projectRoot, - ); - expect(m?.source).toBe(src); - }); - - it("expands ~ in source", () => { - const testDir = join(homedir(), ".openlock-mounts-test-tmp"); - mkdirSync(testDir, { recursive: true }); - try { - const [m] = parseMounts( - [ - { - source: "~/.openlock-mounts-test-tmp", - target: "/sandbox/.openlock/x", - type: "copy-once", - }, - ], - projectRoot, - ); - expect(m?.source).toBe(testDir); - } finally { - rmSync(testDir, { recursive: true, force: true }); - } - }); - - it("accepts absolute source", () => { - const src = join(projectRoot, "abs"); - mkdirSync(src); - const [m] = parseMounts( - [{ source: src, target: "/sandbox/.openlock/x", type: "copy-once" }], - projectRoot, - ); - expect(m?.source).toBe(src); - }); - - it("throws when source is missing or not a string", () => { - expect(() => - parseMounts([{ target: "/sandbox/.openlock/x", type: "copy-once" }], projectRoot), - ).toThrow(/source/); - }); - - it("throws when source does not exist", () => { - expect(() => - parseMounts( - [{ source: "/nope/does/not/exist", target: "/sandbox/.openlock/x", type: "copy-once" }], - projectRoot, - ), - ).toThrow(/source.*does not exist/); - }); - - it("throws when source is a file, not a directory", () => { - const f = join(projectRoot, "file"); - writeFileSync(f, "x"); - expect(() => - parseMounts([{ source: f, target: "/sandbox/.openlock/x", type: "copy-once" }], projectRoot), - ).toThrow(/source.*not a directory/); - }); - - it("throws when target is missing", () => { - const src = join(projectRoot, "s"); - mkdirSync(src); - expect(() => parseMounts([{ source: src, type: "copy-once" }], projectRoot)).toThrow(/target/); - }); - - it("throws when target is not absolute", () => { - const src = join(projectRoot, "s"); - mkdirSync(src); - expect(() => - parseMounts([{ source: src, target: "sandbox/x", type: "copy-once" }], projectRoot), - ).toThrow(/absolute/); - }); - - it("throws when target is not under /sandbox/.openlock/", () => { - const src = join(projectRoot, "s"); - mkdirSync(src); - expect(() => - parseMounts([{ source: src, target: "/etc/passwd", type: "copy-once" }], projectRoot), - ).toThrow(/under \/sandbox\/\.openlock\//); - }); - - it("throws when target is under /sandbox/ but not under /sandbox/.openlock/", () => { - const src = join(projectRoot, "s"); - mkdirSync(src); - expect(() => - parseMounts([{ source: src, target: "/sandbox/scratch", type: "copy-once" }], projectRoot), - ).toThrow(/under \/sandbox\/\.openlock\//); - }); - - it("throws when target equals /sandbox/.openlock or /sandbox/.openlock/", () => { - const src = join(projectRoot, "s"); - mkdirSync(src); - expect(() => - parseMounts([{ source: src, target: "/sandbox/.openlock", type: "copy-once" }], projectRoot), - ).toThrow(/under \/sandbox\/\.openlock\//); - }); - - it("throws when target contains a .. segment", () => { - const src = join(projectRoot, "s"); - mkdirSync(src); - expect(() => - parseMounts( - [{ source: src, target: "/sandbox/../etc/passwd", type: "copy-once" }], - projectRoot, - ), - ).toThrow(/must not contain '\.\.'/); - }); - - it("rejects /sandbox/.openlock/../etc/passwd (prefix-bypass via ..)", () => { - const src = join(projectRoot, "s"); - mkdirSync(src); - expect(() => - parseMounts( - [{ source: src, target: "/sandbox/.openlock/../etc/passwd", type: "copy-once" }], - projectRoot, - ), - ).toThrow(/must not contain '\.\.'/); - }); - - it("throws when target's top segment collides with openlock-internal name (.gitconfig)", () => { - const src = join(projectRoot, "s"); - mkdirSync(src); - expect(() => - parseMounts( - [{ source: src, target: "/sandbox/.openlock/.gitconfig", type: "copy-once" }], - projectRoot, - ), - ).toThrow(/conflicts with openlock-internal name '\.gitconfig'/); - }); - - it("also catches collision when reserved name is the prefix of a deeper path", () => { - const src = join(projectRoot, "s"); - mkdirSync(src); - expect(() => - parseMounts( - [{ source: src, target: "/sandbox/.openlock/bundles/sub", type: "copy-once" }], - projectRoot, - ), - ).toThrow(/conflicts with openlock-internal name 'bundles'/); - }); - - it("throws when target's top segment is reserved 'bundles' (copy-once)", () => { - const src = join(projectRoot, "s"); - mkdirSync(src); - expect(() => - parseMounts( - [{ source: src, target: "/sandbox/.openlock/bundles", type: "copy-once" }], - projectRoot, - ), - ).toThrow(/conflicts with openlock-internal name 'bundles'/); - }); - - it("throws when target nests under reserved 'bundles' (copy-once)", () => { - const src = join(projectRoot, "s"); - mkdirSync(src); - expect(() => - parseMounts( - [{ source: src, target: "/sandbox/.openlock/bundles/sub", type: "copy-once" }], - projectRoot, - ), - ).toThrow(/conflicts with openlock-internal name 'bundles'/); - }); - - it("throws when two mounts share a target", () => { - const a = join(projectRoot, "a"); - const b = join(projectRoot, "b"); - mkdirSync(a); - mkdirSync(b); - expect(() => - parseMounts( - [ - { source: a, target: "/sandbox/.openlock/x", type: "copy-once" }, - { source: b, target: "/sandbox/.openlock/x", type: "copy-refresh" }, - ], - projectRoot, - ), - ).toThrow(/duplicate target/); - }); - - it("throws when type is missing", () => { - const src = join(projectRoot, "s"); - mkdirSync(src); - expect(() => - parseMounts([{ source: src, target: "/sandbox/.openlock/x" }], projectRoot), - ).toThrow(/type/); - }); - - it("throws when type is unknown", () => { - const src = join(projectRoot, "s"); - mkdirSync(src); - expect(() => - parseMounts( - [{ source: src, target: "/sandbox/.openlock/x", type: "no-such-type" }], - projectRoot, - ), - ).toThrow(/unknown type/); - }); - - it("accepts type copy-once and copy-refresh", () => { - const a = join(projectRoot, "a"); - const b = join(projectRoot, "b"); - mkdirSync(a); - mkdirSync(b); - const ms = parseMounts( - [ - { source: a, target: "/sandbox/.openlock/a", type: "copy-once" }, - { source: b, target: "/sandbox/.openlock/b", type: "copy-refresh" }, - ], - projectRoot, - ); - expect(ms.map((m) => m.type)).toEqual(["copy-once", "copy-refresh"]); - }); - - it("accepts type: bind with a directory source", () => { - const src = join(projectRoot, "bind-dir"); - mkdirSync(src); - const [m] = parseMounts( - [{ source: src, target: "/sandbox/.openlock/bound", type: "bind" }], - projectRoot, - ); - expect(m?.type).toBe("bind"); - expect(m?.source).toBe(src); - }); - - it("accepts type: bind with a file source", () => { - const f = join(projectRoot, "bind-file"); - writeFileSync(f, "hello"); - const [m] = parseMounts( - [{ source: f, target: "/sandbox/.openlock/file", type: "bind" }], - projectRoot, - ); - expect(m?.type).toBe("bind"); - expect(m?.source).toBe(f); - }); - - it("accepts type: git-bundle with a git working tree source", () => { - const src = join(projectRoot, "repo"); - mkdirSync(src); - mkdirSync(join(src, ".git")); - writeFileSync(join(src, ".git/HEAD"), "ref: refs/heads/main\n"); - const [m] = parseMounts( - [{ source: src, target: "/sandbox/repo", type: "git-bundle" }], - projectRoot, - ); - expect(m?.type).toBe("git-bundle"); - expect(m?.source).toBe(src); - }); - - it("rejects type: git-bundle with non-git directory source", () => { - const src = join(projectRoot, "not-a-repo"); - mkdirSync(src); - expect(() => - parseMounts([{ source: src, target: "/sandbox/repo", type: "git-bundle" }], projectRoot), - ).toThrow(/not a git working tree/); - }); - - it("rejects type: git-bundle with file source", () => { - const f = join(projectRoot, "file"); - writeFileSync(f, ""); - expect(() => - parseMounts([{ source: f, target: "/sandbox/repo", type: "git-bundle" }], projectRoot), - ).toThrow(/not a directory/); - }); - - it("accepts readOnly: true on type: bind", () => { - const src = join(projectRoot, "bind-ro"); - mkdirSync(src); - const [m] = parseMounts( - [{ source: src, target: "/sandbox/.openlock/ro", type: "bind", readOnly: true }], - projectRoot, - ); - expect(m?.readOnly).toBe(true); - }); - - it("accepts readOnly: false on type: bind (or absent)", () => { - const src = join(projectRoot, "bind-rw"); - mkdirSync(src); - const ms = parseMounts( - [ - { source: src, target: "/sandbox/.openlock/a", type: "bind", readOnly: false }, - { source: src, target: "/sandbox/.openlock/b", type: "bind" }, - ], - projectRoot, - ); - expect(ms[0]?.readOnly).toBe(false); - expect(ms[1]?.readOnly).toBeUndefined(); - }); - - it("rejects readOnly on type: copy-once", () => { - const src = join(projectRoot, "co"); - mkdirSync(src); - expect(() => - parseMounts( - [{ source: src, target: "/sandbox/.openlock/x", type: "copy-once", readOnly: true }], - projectRoot, - ), - ).toThrow(/readOnly is only valid on type: bind/); - }); - - it("rejects readOnly on type: copy-refresh", () => { - const src = join(projectRoot, "cr"); - mkdirSync(src); - expect(() => - parseMounts( - [{ source: src, target: "/sandbox/.openlock/x", type: "copy-refresh", readOnly: true }], - projectRoot, - ), - ).toThrow(/readOnly is only valid on type: bind/); - }); - - it("rejects readOnly on type: git-bundle", () => { - const src = join(projectRoot, "gb"); - mkdirSync(src); - mkdirSync(join(src, ".git")); - writeFileSync(join(src, ".git/HEAD"), "ref: refs/heads/main\n"); - expect(() => - parseMounts( - [{ source: src, target: "/sandbox/repo", type: "git-bundle", readOnly: true }], - projectRoot, - ), - ).toThrow(/readOnly is only valid on type: bind/); - }); - - it("rejects non-boolean readOnly", () => { - const src = join(projectRoot, "bind"); - mkdirSync(src); - expect(() => - parseMounts( - // biome-ignore lint/suspicious/noExplicitAny: testing invalid input - [{ source: src, target: "/sandbox/.openlock/x", type: "bind", readOnly: "yes" as any }], - projectRoot, - ), - ).toThrow(/readOnly must be a boolean/); - }); - - it("bind: accepts target outside /sandbox/.openlock/", () => { - const src = join(projectRoot, "bind-out"); - mkdirSync(src); - const [m] = parseMounts( - [{ source: src, target: "/sandbox/extras", type: "bind" }], - projectRoot, - ); - expect(m?.target).toBe("/sandbox/extras"); - expect(m?.type).toBe("bind"); - }); - - it("bind: accepts target /sandbox/repo (workdir override)", () => { - const src = join(projectRoot, "bind-repo"); - mkdirSync(src); - const [m] = parseMounts([{ source: src, target: "/sandbox/repo", type: "bind" }], projectRoot); - expect(m?.target).toBe("/sandbox/repo"); - expect(m?.type).toBe("bind"); - }); - - it("bind: rejects target reserved .gitconfig", () => { - const src = join(projectRoot, "bind-gc"); - mkdirSync(src); - expect(() => - parseMounts( - [{ source: src, target: "/sandbox/.openlock/.gitconfig", type: "bind" }], - projectRoot, - ), - ).toThrow(/conflicts with openlock-internal name '\.gitconfig'/); - }); - - it("bind: rejects target reserved bundles", () => { - const src = join(projectRoot, "bind-bn"); - mkdirSync(src); - expect(() => - parseMounts( - [{ source: src, target: "/sandbox/.openlock/bundles", type: "bind" }], - projectRoot, - ), - ).toThrow(/conflicts with openlock-internal name 'bundles'/); - }); - - it("bind: rejects target with '..' segment", () => { - const src = join(projectRoot, "bind-dd"); - mkdirSync(src); - expect(() => - parseMounts([{ source: src, target: "/sandbox/../etc", type: "bind" }], projectRoot), - ).toThrow(/must not contain '\.\.'/); - }); - - it("bind: rejects non-absolute target", () => { - const src = join(projectRoot, "bind-rel"); - mkdirSync(src); - expect(() => - parseMounts([{ source: src, target: "sandbox/extras", type: "bind" }], projectRoot), - ).toThrow(/must be absolute/); - }); - - it("git-bundle: accepts target /sandbox/repo", () => { - const src = join(projectRoot, "gb-repo"); - mkdirSync(src); - mkdirSync(join(src, ".git")); - writeFileSync(join(src, ".git/HEAD"), "ref: refs/heads/main\n"); - const [m] = parseMounts( - [{ source: src, target: "/sandbox/repo", type: "git-bundle" }], - projectRoot, - ); - expect(m?.target).toBe("/sandbox/repo"); - }); - - it("git-bundle: accepts target /sandbox/extra-repo", () => { - const src = join(projectRoot, "gb-extra"); - mkdirSync(src); - mkdirSync(join(src, ".git")); - writeFileSync(join(src, ".git/HEAD"), "ref: refs/heads/main\n"); - const [m] = parseMounts( - [{ source: src, target: "/sandbox/extra-repo", type: "git-bundle" }], - projectRoot, - ); - expect(m?.target).toBe("/sandbox/extra-repo"); - }); - - it("git-bundle: rejects target under /sandbox/.openlock/", () => { - const src = join(projectRoot, "gb-bad"); - mkdirSync(src); - mkdirSync(join(src, ".git")); - writeFileSync(join(src, ".git/HEAD"), "ref: refs/heads/main\n"); - expect(() => - parseMounts( - [{ source: src, target: "/sandbox/.openlock/some-repo", type: "git-bundle" }], - projectRoot, - ), - ).toThrow(/git-bundle target must not be under \/sandbox\/\.openlock\//); - }); - - it("rejects copy-once targeting /sandbox/repo", () => { - const src = join(projectRoot, "co-repo"); - mkdirSync(src); - expect(() => - parseMounts([{ source: src, target: "/sandbox/repo", type: "copy-once" }], projectRoot), - ).toThrow(/\/sandbox\/repo not supported with type 'copy-once'/); - }); - - it("rejects copy-refresh targeting /sandbox/repo", () => { - const src = join(projectRoot, "cr-repo"); - mkdirSync(src); - expect(() => - parseMounts([{ source: src, target: "/sandbox/repo", type: "copy-refresh" }], projectRoot), - ).toThrow(/\/sandbox\/repo not supported with type 'copy-refresh'/); - }); - - it("accepts zero workdir mounts (no mount targets /sandbox/repo)", () => { - const src = join(projectRoot, "no-wd"); - mkdirSync(src); - const ms = parseMounts( - [{ source: src, target: "/sandbox/.openlock/x", type: "copy-once" }], - projectRoot, - ); - expect(ms).toHaveLength(1); - expect(ms.find((m) => m.target === "/sandbox/repo")).toBeUndefined(); - }); -}); - describe("workdirMount", () => { it("returns undefined when no mount targets /sandbox/repo", () => { const src = join(projectRoot, "x"); @@ -624,30 +153,21 @@ describe("bindMountArgs", () => { it("returns [] when no bind mounts", () => { const src = join(projectRoot, "x"); mkdirSync(src); - const ms = parseMounts( - [{ source: src, target: "/sandbox/.openlock/x", type: "copy-once" }], - projectRoot, - ); + const ms = mk([{ source: src, target: "/sandbox/.openlock/x", type: "copy-once" }]); expect(bindMountArgs(ms)).toEqual([]); }); it("emits --volume host:container for one bind without readOnly", () => { const src = join(projectRoot, "x"); mkdirSync(src); - const ms = parseMounts( - [{ source: src, target: "/sandbox/.openlock/x", type: "bind" }], - projectRoot, - ); + const ms = mk([{ source: src, target: "/sandbox/.openlock/x", type: "bind" }]); expect(bindMountArgs(ms)).toEqual(["--volume", `${src}:/sandbox/.openlock/x`]); }); it("emits --volume host:container:ro for one bind with readOnly: true", () => { const src = join(projectRoot, "x"); mkdirSync(src); - const ms = parseMounts( - [{ source: src, target: "/sandbox/.openlock/x", type: "bind", readOnly: true }], - projectRoot, - ); + const ms = mk([{ source: src, target: "/sandbox/.openlock/x", type: "bind", readOnly: true }]); expect(bindMountArgs(ms)).toEqual(["--volume", `${src}:/sandbox/.openlock/x:ro`]); }); @@ -656,13 +176,10 @@ describe("bindMountArgs", () => { const b = join(projectRoot, "b"); mkdirSync(a); mkdirSync(b); - const ms = parseMounts( - [ - { source: a, target: "/sandbox/.openlock/a", type: "bind" }, - { source: b, target: "/home/sandbox/b", type: "bind", readOnly: true }, - ], - projectRoot, - ); + const ms = mk([ + { source: a, target: "/sandbox/.openlock/a", type: "bind" }, + { source: b, target: "/home/sandbox/b", type: "bind", readOnly: true }, + ]); expect(bindMountArgs(ms)).toEqual([ "--volume", `${a}:/sandbox/.openlock/a`, @@ -676,13 +193,10 @@ describe("bindMountArgs", () => { const b = join(projectRoot, "b"); mkdirSync(a); mkdirSync(b); - const ms = parseMounts( - [ - { source: a, target: "/sandbox/.openlock/a", type: "copy-once" }, - { source: b, target: "/sandbox/.openlock/b", type: "bind" }, - ], - projectRoot, - ); + const ms = mk([ + { source: a, target: "/sandbox/.openlock/a", type: "copy-once" }, + { source: b, target: "/sandbox/.openlock/b", type: "bind" }, + ]); expect(bindMountArgs(ms)).toEqual(["--volume", `${b}:/sandbox/.openlock/b`]); }); }); @@ -701,10 +215,7 @@ describe("gitBundleMounts", () => { it("returns the workdir git-bundle mount", () => { const src = join(projectRoot, "repo"); makeGitRepo(src); - const ms = parseMounts( - [{ source: src, target: "/sandbox/repo", type: "git-bundle" }], - projectRoot, - ); + const ms = mk([{ source: src, target: "/sandbox/repo", type: "git-bundle" }]); expect(gitBundleMounts(ms).map((m) => m.target)).toEqual(["/sandbox/repo"]); }); @@ -713,13 +224,10 @@ describe("gitBundleMounts", () => { const b = join(projectRoot, "beta"); makeGitRepo(a); makeGitRepo(b); - const ms = parseMounts( - [ - { source: a, target: "/sandbox/repo", type: "git-bundle" }, - { source: b, target: "/sandbox/extra-repo", type: "git-bundle" }, - ], - projectRoot, - ); + const ms = mk([ + { source: a, target: "/sandbox/repo", type: "git-bundle" }, + { source: b, target: "/sandbox/extra-repo", type: "git-bundle" }, + ]); expect(gitBundleMounts(ms).map((m) => m.target)).toEqual([ "/sandbox/repo", "/sandbox/extra-repo", @@ -736,13 +244,10 @@ describe("gitBundleMounts", () => { writeFileSync(join(a, ".git/HEAD"), "ref: refs/heads/main\n"); writeFileSync(join(b, ".git/HEAD"), "ref: refs/heads/main\n"); expect(() => - parseMounts( - [ - { source: a, target: "/sandbox/repo", type: "git-bundle" }, - { source: b, target: "/sandbox/extra-repo", type: "git-bundle" }, - ], - projectRoot, - ), + mk([ + { source: a, target: "/sandbox/repo", type: "git-bundle" }, + { source: b, target: "/sandbox/extra-repo", type: "git-bundle" }, + ]), ).toThrow(/source basename 'app' collides between git-bundle mounts/); }); }); diff --git a/src/sandbox/mounts.ts b/src/sandbox/mounts.ts index 9b1fb22..006d3a1 100644 --- a/src/sandbox/mounts.ts +++ b/src/sandbox/mounts.ts @@ -1,88 +1,19 @@ -import { cpSync, existsSync, mkdirSync, mkdtempSync, rmSync, statSync } from "node:fs"; -import { homedir, tmpdir } from "node:os"; -import { basename, dirname, isAbsolute, join, resolve } from "node:path"; +import { cpSync, mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, dirname, join } from "node:path"; +import type { Mount } from "../config-core"; +import { SANDBOX_OPENLOCK_PREFIX } from "../config-core"; import { execAsRoot, uploadToSandbox } from "./container"; -type MountType = "copy-once" | "copy-refresh" | "bind" | "git-bundle"; +export type { Mount } from "../config-core"; -export interface Mount { - source: string; - target: string; - type: MountType; - readOnly?: boolean; -} - -const SANDBOX_OPENLOCK_PREFIX = "/sandbox/.openlock/"; - -const RESERVED_MOUNT_NAMES: ReadonlySet = new Set([".gitconfig", "bundles"]); - -function expandHome(p: string): string { - if (p === "~") return homedir(); - if (p.startsWith("~/")) return resolve(homedir(), p.slice(2)); - return p; -} - -function resolveSource(projectRoot: string, raw: string): string { - const expanded = expandHome(raw); - return isAbsolute(expanded) ? expanded : resolve(projectRoot, expanded); -} - -function commonTargetChecks(target: string, where: string): void { - if (!isAbsolute(target)) { - throw new Error(`${where}: mount target must be absolute: ${target}`); +export function stagingPathFor(target: string): string { + if (!target.startsWith("/")) { + throw new Error(`stagingPathFor: mount target must be absolute: ${target}`); } if (target.split("/").includes("..")) { - throw new Error(`${where}: mount target must not contain '..' segments: ${target}`); - } - if (target.startsWith(SANDBOX_OPENLOCK_PREFIX)) { - const rel = target.slice(SANDBOX_OPENLOCK_PREFIX.length); - const topSegment = rel.split("/")[0]; - if (topSegment !== undefined && RESERVED_MOUNT_NAMES.has(topSegment)) { - throw new Error( - `${where}: mount target conflicts with openlock-internal name '${topSegment}': ${target}`, - ); - } - } -} - -function validateTargetForType(target: string, type: MountType, where: string): void { - commonTargetChecks(target, where); - if (type === "copy-once" || type === "copy-refresh") { - if (target === "/sandbox/repo") { - throw new Error( - `${where}: target /sandbox/repo not supported with type '${type}'; use git-bundle (host repo bundled in) or bind (live host sync), or omit the workdir mount`, - ); - } - if ( - !target.startsWith(SANDBOX_OPENLOCK_PREFIX) || - target.length <= SANDBOX_OPENLOCK_PREFIX.length - ) { - throw new Error( - `${where}: mount target must be under /sandbox/.openlock/ for type '${type}': ${target}`, - ); - } - return; - } - if (type === "git-bundle") { - if (target.startsWith(SANDBOX_OPENLOCK_PREFIX)) { - throw new Error( - `${where}: git-bundle target must not be under /sandbox/.openlock/: ${target}`, - ); - } - return; - } - // type === "bind": no further restriction -} - -function assertGitWorkingTree(source: string, where: string): void { - const dotGit = join(source, ".git"); - if (!existsSync(dotGit)) { - throw new Error(`${where}: source ${source} is not a git working tree (missing .git)`); + throw new Error(`stagingPathFor: mount target must not contain '..' segments: ${target}`); } -} - -export function stagingPathFor(target: string): string { - commonTargetChecks(target, "stagingPathFor"); if ( !target.startsWith(SANDBOX_OPENLOCK_PREFIX) || target.length <= SANDBOX_OPENLOCK_PREFIX.length @@ -92,99 +23,6 @@ export function stagingPathFor(target: string): string { return target.slice(SANDBOX_OPENLOCK_PREFIX.length); } -interface RawMount { - source?: unknown; - target?: unknown; - type?: unknown; - readOnly?: unknown; -} - -function parseRawType(raw: RawMount, where: string): MountType { - if (typeof raw.type !== "string") { - throw new Error(`${where}: 'type' must be one of copy-once, copy-refresh, bind, git-bundle`); - } - const type = raw.type; - if (type !== "copy-once" && type !== "copy-refresh" && type !== "bind" && type !== "git-bundle") { - throw new Error( - `${where}: unknown type '${type}' (allowed: copy-once, copy-refresh, bind, git-bundle)`, - ); - } - return type; -} - -function parseRawReadOnly(raw: RawMount, type: MountType, where: string): boolean | undefined { - if (raw.readOnly === undefined) return undefined; - if (typeof raw.readOnly !== "boolean") { - throw new Error(`${where}: readOnly must be a boolean`); - } - if (type !== "bind") { - throw new Error(`${where}: readOnly is only valid on type: bind`); - } - return raw.readOnly; -} - -function validateSource(source: string, type: MountType, where: string): void { - if (!existsSync(source)) { - throw new Error(`${where}: source ${source} does not exist`); - } - const isDir = statSync(source).isDirectory(); - if ((type === "copy-once" || type === "copy-refresh" || type === "git-bundle") && !isDir) { - throw new Error(`${where}: source ${source} is not a directory`); - } - if (type === "git-bundle") { - assertGitWorkingTree(source, where); - } -} - -function parseOne(raw: RawMount, projectRoot: string, index: number): Mount { - const where = `mounts[${index}]`; - if (typeof raw.source !== "string" || raw.source.length === 0) { - throw new Error(`${where}: 'source' must be a non-empty string`); - } - if (typeof raw.target !== "string" || raw.target.length === 0) { - throw new Error(`${where}: 'target' must be a non-empty string`); - } - const type = parseRawType(raw, where); - const readOnly = parseRawReadOnly(raw, type, where); - validateTargetForType(raw.target, type, where); - const source = resolveSource(projectRoot, raw.source); - validateSource(source, type, where); - return readOnly !== undefined - ? { source, target: raw.target, type, readOnly } - : { source, target: raw.target, type }; -} - -export function parseMounts(raw: unknown, projectRoot: string): Mount[] { - if (raw === undefined || raw === null) return []; - if (!Array.isArray(raw)) { - throw new Error("'mounts' must be a list"); - } - const out: Mount[] = []; - const targets = new Set(); - for (let i = 0; i < raw.length; i++) { - const m = parseOne(raw[i] as RawMount, projectRoot, i); - if (targets.has(m.target)) { - throw new Error(`mounts[${i}]: duplicate target ${m.target}`); - } - targets.add(m.target); - out.push(m); - } - const bundleBasenames = new Map(); - for (let i = 0; i < out.length; i++) { - const m = out[i]!; - if (m.type !== "git-bundle") continue; - const base = basename(m.source); - const prev = bundleBasenames.get(base); - if (prev !== undefined) { - throw new Error( - `mounts[${i}]: source basename '${base}' collides between git-bundle mounts (already used by mounts[${prev}])`, - ); - } - bundleBasenames.set(base, i); - } - return out; -} - export function stageMounts(stagingDir: string, mounts: readonly Mount[]): void { for (const m of mounts) { if (m.type === "bind" || m.type === "git-bundle") continue; diff --git a/src/sandbox/openlock-folder.test.ts b/src/sandbox/openlock-folder.test.ts index 642c227..aa90c74 100644 --- a/src/sandbox/openlock-folder.test.ts +++ b/src/sandbox/openlock-folder.test.ts @@ -37,14 +37,13 @@ describe("resolveOpenlockFolder", () => { } }); - it("deprecation warning when config.yaml has caps field", () => { + it("rejects a config.yaml with a leftover caps key", () => { const dir = makeProject(); try { mkdirSync(join(dir, ".openlock")); writeFileSync(join(dir, ".openlock/config.yaml"), "caps: [js]\n"); - writeFileSync(join(dir, ".openlock/policy.yaml"), "# test\n"); - const out = resolveOpenlockFolder(dir); - expect(out.deprecations).toContain("caps"); + writeFileSync(join(dir, ".openlock/policy.yaml"), "version: 1\n"); + expect(() => resolveOpenlockFolder(dir)).toThrow(/unknown key "caps"/); } finally { rmSync(dir, { recursive: true, force: true }); } diff --git a/src/sandbox/openlock-folder.ts b/src/sandbox/openlock-folder.ts index bf3c573..28a3ce5 100644 --- a/src/sandbox/openlock-folder.ts +++ b/src/sandbox/openlock-folder.ts @@ -1,11 +1,12 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import yaml from "js-yaml"; +import type { Mount } from "../config-core"; +import { type ManifestConfig, parseManifest } from "../config-core"; import { defaultPolicyContent } from "./default-policies"; import { computeBaseTag, GHCR_BASE_PREFIX } from "./ensure-base"; import { resolveHarness } from "./harness"; import { BASE_CONTAINERFILE } from "./image-build"; -import { type Mount, parseMounts } from "./mounts"; import { seedContainerfile } from "./seed-containerfile"; const FOLDER_NAME = ".openlock"; @@ -13,12 +14,7 @@ const CONFIG_FILENAME = "config.yaml"; const POLICY_FILENAME = "policy.yaml"; const CONTAINERFILE_FILENAME = "Containerfile"; -interface OpenlockFolderConfig { - mounts: Mount[]; - args: string[]; - env: Record; - deprecations: string[]; -} +type OpenlockFolderConfig = ManifestConfig; function configPath(folderPath: string): string { return join(folderPath, CONFIG_FILENAME); @@ -30,34 +26,6 @@ function containerfilePath(folderPath: string): string { return join(folderPath, CONTAINERFILE_FILENAME); } -function parseArgs(raw: unknown, where: string): string[] { - if (raw === undefined || raw === null) return []; - if (!Array.isArray(raw)) { - throw new Error(`Invalid config.yaml: 'args' must be a list at ${where}`); - } - for (const v of raw) { - if (typeof v !== "string") { - throw new Error(`Invalid config.yaml: 'args' must contain only strings at ${where}`); - } - } - return raw as string[]; -} - -function parseEnv(raw: unknown, where: string): Record { - if (raw === undefined || raw === null) return {}; - if (typeof raw !== "object" || Array.isArray(raw)) { - throw new Error(`Invalid config.yaml: 'env' must be a mapping at ${where}`); - } - const out: Record = {}; - for (const [k, v] of Object.entries(raw as Record)) { - if (typeof v !== "string") { - throw new Error(`Invalid config.yaml: env value for '${k}' must be a string at ${where}`); - } - out[k] = v; - } - return out; -} - function readConfig(folderPath: string): OpenlockFolderConfig { const path = configPath(folderPath); let raw: string; @@ -66,23 +34,8 @@ function readConfig(folderPath: string): OpenlockFolderConfig { } catch { throw new Error(`config.yaml not found at ${path}`); } - - const doc = (yaml.load(raw) ?? {}) as Record; - if (typeof doc !== "object" || Array.isArray(doc)) { - throw new Error(`Invalid config.yaml: expected mapping at ${path}`); - } - - const deprecations: string[] = []; - if (doc.caps !== undefined) { - deprecations.push("caps"); - } - - const projectRoot = dirname(folderPath); - const mounts = parseMounts(doc.mounts, projectRoot); - const args = parseArgs(doc.args, path); - const env = parseEnv(doc.env, path); - - return { mounts, args, env, deprecations }; + const doc = yaml.load(raw) ?? {}; + return parseManifest(doc, dirname(folderPath)); } function writeConfig(folderPath: string): void { @@ -127,14 +80,13 @@ export interface ResolveResult { mounts: Mount[]; args: string[]; env: Record; - deprecations: string[]; } function folderPathFor(projectPath: string): string { return join(projectPath, FOLDER_NAME); } -const EMPTY_CFG: OpenlockFolderConfig = { mounts: [], args: [], env: {}, deprecations: [] }; +const EMPTY_CFG: OpenlockFolderConfig = { mounts: [], args: [], env: {} }; function buildResult( folder: string, @@ -147,7 +99,6 @@ function buildResult( mounts: cfg.mounts, args: cfg.args, env: cfg.env, - deprecations: cfg.deprecations, }; } diff --git a/src/sandbox/session.ts b/src/sandbox/session.ts index 2297b2c..99a15b1 100644 --- a/src/sandbox/session.ts +++ b/src/sandbox/session.ts @@ -104,12 +104,6 @@ function resolveRepoPolicyAndCaps(projectPath: string, policyOverride?: string): } else if (folder.origin === "restored-containerfile") { console.log("Restored .openlock/Containerfile from seed."); } - if (folder.deprecations.includes("caps")) { - console.warn( - "warning: config.yaml has deprecated 'caps' field; ignored. " + - "Run `openlock validate --fix` (coming in v0.9.x) to remove it.", - ); - } return { policy: folder.policyPath, mounts: folder.mounts, From 008ac11914bed5dc5ef70f17a8a69f5bfaee8895 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kova=C4=BE?= Date: Sat, 30 May 2026 19:11:03 +0200 Subject: [PATCH 09/10] refactor(config-core): rename stale helper, trim unused export, comment staging guard --- src/config-core/index.ts | 2 +- src/sandbox/mounts.ts | 3 +++ src/sandbox/session.ts | 4 ++-- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/config-core/index.ts b/src/config-core/index.ts index 3d4edeb..c84c7e5 100644 --- a/src/config-core/index.ts +++ b/src/config-core/index.ts @@ -5,7 +5,7 @@ import { lintPolicy } from "./policy/index"; import type { Issue } from "./types"; export { parseManifest } from "./manifest/index"; -export type { ConfigFile, Issue, ManifestConfig, Mount, MountType, Severity } from "./types"; +export type { ConfigFile, Issue, ManifestConfig, Mount, Severity } from "./types"; export { SANDBOX_OPENLOCK_PREFIX } from "./types"; /** Validate the whole .openlock/ folder (manifest + policy). Collect-all, diff --git a/src/sandbox/mounts.ts b/src/sandbox/mounts.ts index 006d3a1..2414dff 100644 --- a/src/sandbox/mounts.ts +++ b/src/sandbox/mounts.ts @@ -7,6 +7,9 @@ import { execAsRoot, uploadToSandbox } from "./container"; export type { Mount } from "../config-core"; +// Defensive guards, intentionally self-contained: this runs only on Mount.target +// values already validated by parseManifest, so it deliberately does not delegate +// to config-core's semantic rules. export function stagingPathFor(target: string): string { if (!target.startsWith("/")) { throw new Error(`stagingPathFor: mount target must be absolute: ${target}`); diff --git a/src/sandbox/session.ts b/src/sandbox/session.ts index 99a15b1..033e4a5 100644 --- a/src/sandbox/session.ts +++ b/src/sandbox/session.ts @@ -85,7 +85,7 @@ interface ResolvedRepo { env: Record; } -function resolveRepoPolicyAndCaps(projectPath: string, policyOverride?: string): ResolvedRepo { +function resolveRepoPolicy(projectPath: string, policyOverride?: string): ResolvedRepo { if (policyOverride) { return { policy: resolve(policyOverride), @@ -615,7 +615,7 @@ export async function runSandbox(opts: SandboxOpts): Promise { exitOnPreflightFailure(await preflight({ tty, deps: realPreflightDeps(runtime) })); const repoResult = await ensureRepoIsGit(projectPath); announceRepoAction(repoResult.action, projectPath); - const resolved = resolveRepoPolicyAndCaps(projectPath, opts.policy); + const resolved = resolveRepoPolicy(projectPath, opts.policy); const branchErr = validateBranchFlagAgainstWorkdir(opts.branch, workdirMount(resolved.mounts)); if (branchErr !== null) { From 1be868ae98981807e1644ecf36ad59f85afc2f76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kova=C4=BE?= Date: Sat, 30 May 2026 19:19:10 +0200 Subject: [PATCH 10/10] feat(cli): add per-file summary line to validate output --- src/cli/validate.test.ts | 17 ++++++++++++++++- src/cli/validate.ts | 9 +++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/cli/validate.test.ts b/src/cli/validate.test.ts index 308c365..8cd79fc 100644 --- a/src/cli/validate.test.ts +++ b/src/cli/validate.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; import type { Issue } from "../config-core"; -import { flagSchema, renderIssues } from "./validate"; +import { flagSchema, renderIssues, summaryLine } from "./validate"; describe("validate flagSchema", () => { it("declares --offline and --help", () => { @@ -37,3 +37,18 @@ describe("renderIssues", () => { expect(lines).toContain(" policy.yaml: ok"); }); }); + +describe("summaryLine", () => { + it("reports ok per file when clean", () => { + expect(summaryLine([])).toBe("config.yaml: ok · policy.yaml: ok"); + }); + + it("counts issues per file", () => { + const issues: Issue[] = [ + { file: "config.yaml", severity: "error", path: "a", message: "x" }, + { file: "config.yaml", severity: "filesystem", path: "b", message: "y" }, + { file: "policy.yaml", severity: "error", path: "c", message: "z" }, + ]; + expect(summaryLine(issues)).toBe("config.yaml: 2 issues · policy.yaml: 1 issue"); + }); +}); diff --git a/src/cli/validate.ts b/src/cli/validate.ts index a96a5eb..ecfa90e 100644 --- a/src/cli/validate.ts +++ b/src/cli/validate.ts @@ -39,6 +39,14 @@ export function renderIssues(issues: Issue[]): string[] { return lines; } +export function summaryLine(issues: Issue[]): string { + const parts = FILE_ORDER.map((file) => { + const n = issues.filter((i) => i.file === file).length; + return n === 0 ? `${file}: ok` : `${file}: ${n} issue${n === 1 ? "" : "s"}`; + }); + return parts.join(" · "); +} + export function validateCmd(args: string[]): void { const { values, positionals } = parseArgs({ args, options: flagSchema, allowPositionals: true }); if (values.help === true) { @@ -48,6 +56,7 @@ export function validateCmd(args: string[]): void { const projectDir = positionals[0] ?? process.cwd(); const issues = lintFolder(projectDir, { offline: values.offline === true }); for (const line of renderIssues(issues)) console.log(line); + console.log(summaryLine(issues)); const blocking = issues.some((i) => i.severity === "error" || i.severity === "filesystem"); process.exit(blocking ? 1 : 0); }