diff --git a/CHANGELOG.md b/CHANGELOG.md index c7442ed..d1a867f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # Unreleased +## Breaking changes +- Config: the guest user is now configured as `guestUser: { uid, gid }` (numeric) + instead of the `user: ""` string. It must currently be + `{ uid: 0, gid: 0 }` (root). Update configs that set `user`. +- The guest shell is now `/bin/bash`, previously was root's default shell (= ash + in Gondolin's Alpine base image). Using bash matches the default command + in Gondolin's `VM.shell()`. +- Config: the top-level `guestHomeDir` moved into `guestUser` as + `guestUser.homedir`. Update configs that set `guestHomeDir`. +- Mounts and volumes are now presented to the guest as owned by the guest user + (root, `0:0`) by default rather than showing the raw host uid/gid. This avoids + ownership-sensitive tooling tripping up (e.g. git's "detected dubious + ownership") when host files are owned by a non-root user. Ownership can be + adjusted through a new mount/volume-level `owner` option, see below. + +## Features +- Config: Mounts and volumes now accept an `owner: { uid?, gid? }` option to + control the uid/gid presented to the guest for that mount/volume's entries + (defaults to `guestUser`). Note that this does not change on-host ownership, + and files the guest creates still land on the host owned by the Tuor process + user. As before, invoking `chown` on mounted files & directories remains a + no-op. + # 0.4.0 (2026-07-19) diff --git a/README.md b/README.md index 7377044..1577ef3 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,9 @@ Tuor exposes a wide number of Gondolin's features: the guest (read-only or read/write). - **Hide host files**: Within a mounted directory, hide select files (e.g. `.envrc` files with credentials) from the VM guest. +- **Custom file ownership**: Control the uid/gid a mount or volume is presented + as owned by in the guest (defaults to the guest user), independent of the + files' real on-host ownership. - **Network control**: Restrict network egress to HTTP and specific hosts. DNS is provided by the sandbox, so as to prevent data exfiltration through UDP 53. - **Secret injection**: Prevent the guest from seeing your auth tokens & diff --git a/docs/Configuration.md b/docs/Configuration.md index c0fa9bd..711126a 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -91,7 +91,11 @@ that is not set on the host is an error. "ignore": [".env", "secret.key", ".tuor"], // Files to read list of ignored files from (think .gitignore). Paths are // either host paths or mount-relative paths. - "ignoreFileRefs": ["host:./tuorignore", "mount:.tuorignore"] + "ignoreFileRefs": ["host:./tuorignore", "mount:.tuorignore"], + // Optional: uid/gid presented to the guest for this mount's entries + // (defaults to guestUser). Display-only: does not change host-side + // ownership. Either field may be omitted to inherit from guestUser. + "owner": { "uid": 0, "gid": 0 } } ], // VM resource sizing. Any field left unset falls back to Gondolin's default @@ -106,10 +110,14 @@ that is not set on the host is an error. // volumes, instead!) "rootfsSize": "2G" }, - // Constraint: Guest user must currently be root - "user": "root", + // Guest user (numeric uid/gid) the shell runs under and that mounted + // directories are presented as owned by. `homedir` (optional, default /root) + // is the guest home directory used for `~` expansion in guest paths. + // Constraint: uid/gid must currently be root ({ uid: 0, gid: 0 }). + "guestUser": { "uid": 0, "gid": 0, "homedir": "/root" }, // Persistent guest directories without a host backing directory ( - // similar to Docker volumes) + // similar to Docker volumes). Like mounts, they accept an optional `owner`, + // defaults to guestUser). "volumes": [ { "guestPath": "~/.claude" } // Persist Claude Code state ], diff --git a/src/cli/init.ts b/src/cli/init.ts index 9fe397c..4b46619 100644 --- a/src/cli/init.ts +++ b/src/cli/init.ts @@ -34,7 +34,7 @@ export const command = buildCommand({ allowedHosts: [], allowedInternalHosts: [], }, - user: "root", + guestUser: { uid: 0, gid: 0 }, workdir: { hostPath: "..", mode: workspaceMode, diff --git a/src/cli/show-config.test.ts b/src/cli/show-config.test.ts index 8005292..4e1f922 100644 --- a/src/cli/show-config.test.ts +++ b/src/cli/show-config.test.ts @@ -3,9 +3,8 @@ import type { DefaultedConfig } from "../config/defaults.ts"; import { redactSecrets } from "./show-config.ts"; const baseConfig: DefaultedConfig = { - user: "root", + guestUser: { uid: 0, gid: 0, homedir: "/root" }, workdir: "/workspace", - guestHomeDir: "/root", network: { mode: "restricted", allowedHosts: [], allowedInternalHosts: [] }, }; diff --git a/src/config/defaults.test.ts b/src/config/defaults.test.ts index 792d770..515863d 100644 --- a/src/config/defaults.test.ts +++ b/src/config/defaults.test.ts @@ -3,34 +3,35 @@ import { applyConfigDefaults } from "./defaults.ts"; import type { TuorConfig } from "./schema.ts"; /** - * A merged config as it reaches applyConfigDefaults: user/workdir are optional - * (no schema default) and only present when a layer set them explicitly. + * A merged config as it reaches applyConfigDefaults: guestUser/workdir are + * optional (no schema default) and only present when a layer set them + * explicitly. */ function config(overrides: Partial = {}): TuorConfig { return { ...overrides }; } describe("applyConfigDefaults", () => { - describe("user / workdir", () => { - test("defaults user to root when omitted", () => { - expect(applyConfigDefaults(config()).user).toBe("root"); + describe("guestUser / workdir", () => { + test("defaults guestUser to root (uid/gid 0, homedir /root) when omitted", () => { + expect(applyConfigDefaults(config()).guestUser).toEqual({ + uid: 0, + gid: 0, + homedir: "/root", + }); }); test("defaults workdir to / when omitted", () => { expect(applyConfigDefaults(config()).workdir).toBe("/"); }); - test("preserves an explicit user and workdir", () => { + test("preserves an explicit guestUser and workdir", () => { const result = applyConfigDefaults( - config({ user: "dev", workdir: "/w" }), + config({ guestUser: { uid: 0, gid: 0 }, workdir: "/w" }), ); - expect(result.user).toBe("dev"); + expect(result.guestUser).toEqual({ uid: 0, gid: 0, homedir: "/root" }); expect(result.workdir).toBe("/w"); }); - - test("infers guestHomeDir from the defaulted user when user is omitted", () => { - expect(applyConfigDefaults(config()).guestHomeDir).toBe("/root"); - }); }); describe("network", () => { @@ -60,33 +61,28 @@ describe("applyConfigDefaults", () => { }); }); - describe("guestHomeDir", () => { - test("infers /root for the root user when omitted", () => { - const result = applyConfigDefaults(config({ user: "root" })); - expect(result.guestHomeDir).toBe("/root"); - }); - - test("infers /home/ for a non-root user when omitted", () => { - const result = applyConfigDefaults(config({ user: "dev" })); - expect(result.guestHomeDir).toBe("/home/dev"); + describe("guestUser.homedir", () => { + test("defaults to /root when omitted", () => { + const result = applyConfigDefaults(config()); + expect(result.guestUser.homedir).toBe("/root"); }); - test("preserves an explicit guestHomeDir", () => { + test("preserves an explicit guestUser.homedir", () => { const result = applyConfigDefaults( - config({ guestHomeDir: "/custom/home" }), + config({ guestUser: { uid: 0, gid: 0, homedir: "/custom/home" } }), ); - expect(result.guestHomeDir).toBe("/custom/home"); + expect(result.guestUser.homedir).toBe("/custom/home"); }); }); test("leaves other fields untouched", () => { const input = config({ - user: "dev", + guestUser: { uid: 0, gid: 0 }, workdir: "/work", resources: { rootfsSize: "2G" }, }); const result = applyConfigDefaults(input); - expect(result.user).toBe("dev"); + expect(result.guestUser).toEqual({ uid: 0, gid: 0, homedir: "/root" }); expect(result.workdir).toBe("/work"); expect(result.resources).toEqual({ rootfsSize: "2G" }); }); @@ -95,6 +91,6 @@ describe("applyConfigDefaults", () => { const input = config(); applyConfigDefaults(input); expect(input.network).toBeUndefined(); - expect(input.guestHomeDir).toBeUndefined(); + expect(input.guestUser).toBeUndefined(); }); }); diff --git a/src/config/defaults.ts b/src/config/defaults.ts index 0217ca4..cbd598a 100644 --- a/src/config/defaults.ts +++ b/src/config/defaults.ts @@ -1,14 +1,22 @@ import type { NetworkSpec } from "../core/session.ts"; -import { inferGuestHomeDir } from "./homedir.ts"; -import type { TuorConfig, WorkdirConfig } from "./schema.ts"; +import type { GuestUserConfig, TuorConfig, WorkdirConfig } from "./schema.ts"; // --- Types --- -/** Guest user assumed when no config layer sets one. */ -export const DEFAULT_USER = "root"; +/** Guest user assumed when no config layer sets one (root). */ +export const DEFAULT_GUEST_USER: GuestUserConfig = { uid: 0, gid: 0 }; +/** + * Guest home directory assumed when no config layer sets `guestUser.homedir`. + * Since the guest user is enforced to be root (i.e. root is not just the + * default), we can hard-code /root here. + */ +export const DEFAULT_GUEST_HOME_DIR = "/root"; /** Guest working directory assumed when no config layer sets one. */ export const DEFAULT_WORKDIR: WorkdirConfig = "/"; +/** A {@link GuestUserConfig} with its `homedir` default materialized. */ +export type DefaultedGuestUser = GuestUserConfig & { homedir: string }; + /** * A {@link TuorConfig} with all *config-level* defaults materialized. This is * the "effective config" a user reasons about: same shape as `config.json` @@ -22,11 +30,10 @@ export const DEFAULT_WORKDIR: WorkdirConfig = "/"; */ export type DefaultedConfig = Omit< TuorConfig, - "network" | "guestHomeDir" | "user" | "workdir" + "network" | "guestUser" | "workdir" > & { network: NetworkSpec; - guestHomeDir: string; - user: string; + guestUser: DefaultedGuestUser; workdir: WorkdirConfig; }; @@ -34,10 +41,9 @@ export type DefaultedConfig = Omit< /** * Fill the config-level defaults that don't come from the arktype schema: - * `user`, `workdir`, `network` (block-all when omitted) and `guestHomeDir` - * (inferred from `user`). + * `guestUser`, `workdir`, and `network` (block-all when omitted). * - * `user`/`workdir` are defaulted *here* rather than in the schema so that a + * `guestUser`/`workdir` are defaulted *here* rather than in the schema so that a * child config layer that omits them doesn't clobber a value inherited from a * parent layer during merge (their "omitted" would otherwise be indistinguish- * able from an explicit default). This must run after `mergeConfigs`. @@ -48,13 +54,15 @@ export type DefaultedConfig = Omit< * defaults and stay in {@link createSessionSpecFromConfig}. */ export function applyConfigDefaults(config: TuorConfig): DefaultedConfig { - const user = config.user ?? DEFAULT_USER; + const guestUser = config.guestUser ?? DEFAULT_GUEST_USER; return { ...config, - user, + guestUser: { + ...guestUser, + homedir: guestUser.homedir ?? DEFAULT_GUEST_HOME_DIR, + }, workdir: config.workdir ?? DEFAULT_WORKDIR, network: defaultNetwork(config.network), - guestHomeDir: config.guestHomeDir ?? inferGuestHomeDir(user), }; } diff --git a/src/config/homedir.test.ts b/src/config/homedir.test.ts index efd3ebd..ed9a11c 100644 --- a/src/config/homedir.test.ts +++ b/src/config/homedir.test.ts @@ -1,15 +1,5 @@ import { describe, expect, test } from "vitest"; -import { expandTilde, inferGuestHomeDir } from "./homedir.ts"; - -describe("inferGuestHomeDir", () => { - test("returns /root for root user", () => { - expect(inferGuestHomeDir("root")).toBe("/root"); - }); - - test("returns /home/$user for non-root user", () => { - expect(inferGuestHomeDir("dev")).toBe("/home/dev"); - }); -}); +import { expandTilde } from "./homedir.ts"; describe("expandTilde", () => { test("expands bare ~ to homeDir", () => { diff --git a/src/config/homedir.ts b/src/config/homedir.ts index 2108684..e2032e1 100644 --- a/src/config/homedir.ts +++ b/src/config/homedir.ts @@ -1,8 +1,3 @@ -/** Derive the conventional home directory for a Linux user. */ -export function inferGuestHomeDir(user: string): string { - return user === "root" ? "/root" : `/home/${user}`; -} - /** * Expand a leading `~` (bare or followed by `/`) to `homeDir`. * Does NOT handle `~otheruser` syntax — only the current user's `~`. diff --git a/src/config/merge.test.ts b/src/config/merge.test.ts index dbd3e1e..fdd6f33 100644 --- a/src/config/merge.test.ts +++ b/src/config/merge.test.ts @@ -83,8 +83,10 @@ describe("findAllConfigDirs", () => { describe("mergeConfigs", () => { test("single layer returns config unchanged (except path pre-resolution)", () => { - const result = mergeConfigs([layer("/project/.tuor", { user: "dev" })]); - expect(result.user).toBe("dev"); + const result = mergeConfigs([ + layer("/project/.tuor", { guestUser: { uid: 0, gid: 0 } }), + ]); + expect(result.guestUser).toEqual({ uid: 0, gid: 0 }); }); test("throws on empty layers", () => { @@ -92,12 +94,12 @@ describe("mergeConfigs", () => { }); describe("scalar fields: child wins", () => { - test("user", () => { + test("guestUser: carried through when set (only root is valid)", () => { const result = mergeConfigs([ - layer("/a", { user: "parent" }), - layer("/b", { user: "child" }), + layer("/a", { guestUser: { uid: 0, gid: 0 } }), + layer("/b", { guestUser: { uid: 0, gid: 0 } }), ]); - expect(result.user).toBe("child"); + expect(result.guestUser).toEqual({ uid: 0, gid: 0 }); }); test("workdir string", () => { @@ -108,17 +110,17 @@ describe("mergeConfigs", () => { expect(result.workdir).toBe("/child-dir"); }); - // Regression: a child layer that omits user/workdir must inherit the + // Regression: a child layer that omits guestUser/workdir must inherit the // parent's value rather than clobbering it. Previously these fields carried // a schema default ("root"/"/"), so a child that never set them still // overrode the parent — e.g. a project .tuor/config.json silently reset a // workdir configured in ~/.config/tuor/config.json. - test("user: parent used when child omits", () => { + test("guestUser: parent used when child omits", () => { const result = mergeConfigs([ - layer("/a", { user: "parent" }), + layer("/a", { guestUser: { uid: 0, gid: 0 } }), layer("/b"), ]); - expect(result.user).toBe("parent"); + expect(result.guestUser).toEqual({ uid: 0, gid: 0 }); }); test("workdir: parent used when child omits", () => { @@ -129,12 +131,20 @@ describe("mergeConfigs", () => { expect(result.workdir).toBe("/parent-dir"); }); - test("guestHomeDir: child overrides parent", () => { + test("guestUser.homedir: child overrides parent", () => { + const result = mergeConfigs([ + layer("/a", { guestUser: { uid: 0, gid: 0, homedir: "/old" } }), + layer("/b", { guestUser: { uid: 0, gid: 0, homedir: "/new" } }), + ]); + expect(result.guestUser?.homedir).toBe("/new"); + }); + + test("guestUser.homedir: child inherits parent's when it omits homedir", () => { const result = mergeConfigs([ - layer("/a", { guestHomeDir: "/old" }), - layer("/b", { guestHomeDir: "/new" }), + layer("/a", { guestUser: { uid: 0, gid: 0, homedir: "/parent-home" } }), + layer("/b", { guestUser: { uid: 0, gid: 0 } }), ]); - expect(result.guestHomeDir).toBe("/new"); + expect(result.guestUser?.homedir).toBe("/parent-home"); }); test("nix: child overrides parent", () => { @@ -450,12 +460,12 @@ describe("mergeConfigs", () => { test("most specific wins for scalars, arrays concatenate across all layers", () => { const result = mergeConfigs([ layer("/home/.config/tuor", { - user: "home-user", + workdir: "/home-wd", env: { EDITOR: "vim" }, mounts: [mount({ hostPath: "/global-tools" })], }), layer("/projects/.tuor", { - user: "project-user", + workdir: "/project-wd", env: { EDITOR: "nano", PROJECT: "myproj" }, mounts: [mount({ hostPath: "/project-data" })], }), @@ -466,8 +476,8 @@ describe("mergeConfigs", () => { ]); // Scalars: most specific layer that *sets* the field wins. The closest - // layer omits user, so it falls through to the middle layer. - expect(result.user).toBe("project-user"); + // layer omits workdir, so it falls through to the middle layer. + expect(result.workdir).toBe("/project-wd"); // Env: shallow merge expect(result.env).toEqual({ diff --git a/src/config/merge.ts b/src/config/merge.ts index c3b7752..fc61fa3 100644 --- a/src/config/merge.ts +++ b/src/config/merge.ts @@ -2,6 +2,7 @@ import { existsSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { DEFAULT_IGNORE_FILE_REFS } from "./ignore-files.ts"; import type { + GuestUserConfig, MountConfig, NetworkConfig, QemuConfig, @@ -88,15 +89,17 @@ export function mergeConfigs(layers: ConfigLayer[]): TuorConfig { function mergeTwoConfigs(parent: TuorConfig, child: TuorConfig): TuorConfig { return { // Scalars: child wins, falling back to parent when the child omits the - // field. user/workdir carry no schema default anymore, so "omitted" is + // field. guestUser/workdir carry no schema default anymore, so "omitted" is // genuinely undefined here — otherwise a child layer's silently-defaulted // value would clobber a value inherited from a parent layer. Their defaults // are applied post-merge in applyConfigDefaults. - ...lastDefined(child.user, parent.user, "user"), ...lastDefined(child.workdir, parent.workdir, "workdir"), - ...lastDefined(child.guestHomeDir, parent.guestHomeDir, "guestHomeDir"), ...lastDefined(child.nix, parent.nix, "nix"), + // GuestUser: deep-merge each field (uid/gid/homedir), child field wins — so + // a child that omits homedir keeps the parent's. + ...mergeGuestUser(parent.guestUser, child.guestUser), + // Qemu: deep-merge each variant, child field wins ...mergeQemu(parent.qemu, child.qemu), @@ -175,6 +178,16 @@ function mergeNetwork( }; } +function mergeGuestUser( + parentUser: GuestUserConfig | undefined, + childUser: GuestUserConfig | undefined, +): { guestUser: GuestUserConfig } | Record { + if (!parentUser && !childUser) return {} as Record; + // Shallow merge: each field (uid/gid/homedir) is a scalar, child wins. A child + // that omits `homedir` leaves the key absent, so the parent's survives. + return { guestUser: { ...parentUser, ...childUser } as GuestUserConfig }; +} + function mergeQemu( parentQemu: QemuConfig | undefined, childQemu: QemuConfig | undefined, diff --git a/src/config/nix.ts b/src/config/nix.ts index 247777e..c183f40 100644 --- a/src/config/nix.ts +++ b/src/config/nix.ts @@ -1,5 +1,5 @@ import { existsSync, realpathSync } from "node:fs"; -import type { MountSpec } from "../core/mounts.ts"; +import type { MountSpec, Owner } from "../core/mounts.ts"; import type { NixConfig } from "./schema.ts"; // --- Types --- @@ -23,6 +23,7 @@ export type NixDeps = { export function resolveNixSetup( config: NixConfig, deps: NixDeps = defaultNixDeps, + defaultOwner: Owner = { uid: 0, gid: 0 }, ): NixSetup { if (!deps.nixExists()) { throw new Error("/nix does not exist on the host. Is Nix installed?"); @@ -38,7 +39,7 @@ export function resolveNixSetup( } return { - mounts: buildMounts(config, deps), + mounts: buildMounts(config, defaultOwner, deps), env: buildEnv(profiles, deps.hostEnv, deps.realpath, deps.warn), }; } @@ -80,13 +81,18 @@ function resolveExplicitProfiles(profiles: string[], deps: NixDeps): string[] { }); } -function buildMounts(config: NixConfig, deps: NixDeps): MountSpec[] { +function buildMounts( + config: NixConfig, + defaultOwner: Owner, + deps: NixDeps, +): MountSpec[] { const mounts: MountSpec[] = [ { hostPath: "/nix", guestPath: "/nix", mode: "readonly", shadowPatterns: [], + owner: defaultOwner, }, ]; @@ -102,6 +108,7 @@ function buildMounts(config: NixConfig, deps: NixDeps): MountSpec[] { guestPath: "/lib64", mode: "readonly", shadowPatterns: [], + owner: defaultOwner, }); } diff --git a/src/config/resolve.test.ts b/src/config/resolve.test.ts index 68b1ba9..4f599e8 100644 --- a/src/config/resolve.test.ts +++ b/src/config/resolve.test.ts @@ -33,7 +33,11 @@ function resolve( configDir = "/home/user/.tuor", deps = validDeps, ) { - const full: TuorConfig = { user: "root", workdir: "/", ...config }; + const full: TuorConfig = { + guestUser: { uid: 0, gid: 0 }, + workdir: "/", + ...config, + }; return createSessionSpecFromConfig( applyConfigDefaults(full), configDir, @@ -103,7 +107,7 @@ describe("createSessionSpecFromConfig", () => { test("expands ~ in guestPath using guest home dir", () => { const spec = resolve({ - user: "bob", + guestUser: { uid: 0, gid: 0, homedir: "/home/bob" }, mounts: [ { hostPath: "/opt/data", guestPath: "~/data", mode: "readonly" }, ], @@ -254,7 +258,7 @@ describe("createSessionSpecFromConfig", () => { test("expands ~ in MountConfig workdir guestPath using guest home dir", () => { const spec = resolve({ - user: "bob", + guestUser: { uid: 0, gid: 0, homedir: "/home/bob" }, workdir: { hostPath: "/host/project", guestPath: "~/project", @@ -272,13 +276,14 @@ describe("createSessionSpecFromConfig", () => { { guestPath: "/cache", stateDir: "/home/user/.tuor/.state/overlays/cache", + owner: { uid: 0, gid: 0 }, }, ]); }); test("expands tilde in guestPath using guest home dir", () => { const spec = resolve({ - user: "bob", + guestUser: { uid: 0, gid: 0, homedir: "/home/bob" }, volumes: [{ guestPath: "~/data" }], }); expect(spec.volumes![0]).toMatchObject({ @@ -387,29 +392,63 @@ describe("createSessionSpecFromConfig", () => { }); describe("other fields", () => { - test("passes through user", () => { - const spec = resolve({ user: "dev" }); - expect(spec.user).toBe("dev"); - }); - - test("infers guestHomeDir from user for tilde expansion", () => { + test("defaults guestUser.homedir to /root for tilde expansion", () => { const spec = resolve({ - user: "alice", mounts: [{ hostPath: "/data", guestPath: "~/data", mode: "readonly" }], }); - expect(spec.mounts[0]!.guestPath).toBe("/home/alice/data"); + expect(spec.mounts[0]!.guestPath).toBe("/root/data"); }); - test("uses explicit guestHomeDir override for tilde expansion", () => { + test("uses explicit guestUser.homedir override for tilde expansion", () => { const spec = resolve({ - user: "alice", - guestHomeDir: "/custom/home", + guestUser: { uid: 0, gid: 0, homedir: "/custom/home" }, mounts: [{ hostPath: "/data", guestPath: "~/data", mode: "readonly" }], }); expect(spec.mounts[0]!.guestPath).toBe("/custom/home/data"); }); }); + describe("ownership", () => { + test("mount owner defaults to the guest user's uid/gid", () => { + const spec = resolve({ + mounts: [{ hostPath: "/data", mode: "readonly" }], + }); + expect(spec.mounts[0]!.owner).toEqual({ uid: 0, gid: 0 }); + }); + + test("volume owner defaults to the guest user's uid/gid", () => { + const spec = resolve({ volumes: [{ guestPath: "/cache" }] }); + expect(spec.volumes![0]!.owner).toEqual({ uid: 0, gid: 0 }); + }); + + test("per-mount owner overrides the default", () => { + const spec = resolve({ + mounts: [ + { + hostPath: "/data", + mode: "readonly", + owner: { uid: 1000, gid: 1000 }, + }, + ], + }); + expect(spec.mounts[0]!.owner).toEqual({ uid: 1000, gid: 1000 }); + }); + + test("a partial per-mount owner inherits the missing field from the default", () => { + const spec = resolve({ + mounts: [{ hostPath: "/data", mode: "readonly", owner: { uid: 1000 } }], + }); + expect(spec.mounts[0]!.owner).toEqual({ uid: 1000, gid: 0 }); + }); + + test("per-volume owner overrides the default", () => { + const spec = resolve({ + volumes: [{ guestPath: "/cache", owner: { uid: 1000, gid: 1000 } }], + }); + expect(spec.volumes![0]!.owner).toEqual({ uid: 1000, gid: 1000 }); + }); + }); + describe("qemu resolution", () => { test("omits qemu when nothing is configured", () => { const spec = resolve({}); diff --git a/src/config/resolve.ts b/src/config/resolve.ts index 325bb8a..8688cd2 100644 --- a/src/config/resolve.ts +++ b/src/config/resolve.ts @@ -3,6 +3,7 @@ import { join, resolve } from "node:path"; import type { MountSpec, MountValidationDeps, + Owner, VolumeSpec, } from "../core/mounts.ts"; import { validateMounts } from "../core/mounts.ts"; @@ -57,7 +58,13 @@ export function createSessionSpecFromConfig( hostHomeDir: string, deps: ResolveDeps = defaultResolveDeps, ): SessionSpec { - const guestHomeDir = config.guestHomeDir; + const guestHomeDir = config.guestUser.homedir; + // Default owner for mounts/volumes: the guest user's uid/gid (per-mount and + // per-volume `owner` configs override individual fields). + const defaultOwner: Owner = { + uid: config.guestUser.uid, + gid: config.guestUser.gid, + }; // Resolve workdir const { guestWorkdir, workdirMount } = resolveWorkdir( @@ -80,20 +87,21 @@ export function createSessionSpecFromConfig( configDir, hostHomeDir, guestHomeDir, + defaultOwner, deps.ignoreFile, ), ); // Resolve nix const nixSetup = config.nix - ? resolveNixSetup(config.nix, deps.nix) + ? resolveNixSetup(config.nix, deps.nix, defaultOwner) : undefined; const allMounts = [...(nixSetup?.mounts ?? []), ...userMounts]; // Resolve volumes const volumes = (config.volumes ?? []).map((v) => - resolveVolumeConfig(v, configDir, guestHomeDir), + resolveVolumeConfig(v, configDir, guestHomeDir, defaultOwner), ); validateMounts(allMounts, volumes, deps.mountValidation); @@ -111,7 +119,6 @@ export function createSessionSpecFromConfig( const resources = resolveResources(config.resources); return { - user: config.user, workdir: guestWorkdir, network: config.network, mounts: allMounts, @@ -133,6 +140,7 @@ function resolveMountConfig( configDir: string, hostHomeDir: string, guestHomeDir: string, + defaultOwner: Owner, ignoreFileDeps: IgnoreFileDeps, ): MountSpec { // When guestPath is omitted, we use the expanded *host* path — even if @@ -165,6 +173,7 @@ function resolveMountConfig( guestPath, mode: m.mode, shadowPatterns, + owner: { ...defaultOwner, ...m.owner }, ...(m.mode === "overlay" ? { overlayStateDir: _getOverlayStateDir(configDir, guestPath) } : {}), @@ -267,11 +276,13 @@ function resolveVolumeConfig( v: VolumeConfig, configDir: string, guestHomeDir: string, + defaultOwner: Owner, ): VolumeSpec { const guestPath = expandTilde(v.guestPath, guestHomeDir); return { guestPath, stateDir: _getOverlayStateDir(configDir, guestPath), + owner: { ...defaultOwner, ...v.owner }, }; } diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index b26c59d..55501ad 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -21,7 +21,7 @@ describe("findConfigDir", () => { describe("parseConfig", () => { test("parses a fully-populated config with correct defaults", () => { const raw = { - user: "dev", + guestUser: { uid: 0, gid: 0 }, network: { mode: "restricted", allowedHosts: ["*.github.com"] }, workdir: { hostPath: "/host/project", guestPath: "/workspace" }, mounts: [ @@ -35,7 +35,7 @@ describe("parseConfig", () => { }; const config = parseConfig(raw); expect(config).toMatchObject({ - user: "dev", + guestUser: { uid: 0, gid: 0 }, network: { mode: "restricted", allowedHosts: ["*.github.com"] }, workdir: { hostPath: "/host/project", @@ -54,10 +54,10 @@ describe("parseConfig", () => { }); test("leaves optional fields unset for minimal config", () => { - // user/workdir carry no schema default (they're defaulted post-merge in + // guestUser/workdir carry no schema default (they're defaulted post-merge in // applyConfigDefaults), so parseConfig leaves them undefined here. const config = parseConfig({}); - expect(config.user).toBeUndefined(); + expect(config.guestUser).toBeUndefined(); expect(config.workdir).toBeUndefined(); expect(config.network).toBeUndefined(); expect(config.mounts).toBeUndefined(); @@ -86,14 +86,47 @@ describe("parseConfig", () => { expect(config.mounts![0]!.hostPath).toBe("~/projects"); }); - test("accepts guestHomeDir override", () => { - const config = parseConfig({ guestHomeDir: "/custom/home" }); - expect(config.guestHomeDir).toBe("/custom/home"); + test("accepts guestUser { uid: 0, gid: 0 }", () => { + const config = parseConfig({ guestUser: { uid: 0, gid: 0 } }); + expect(config.guestUser).toEqual({ uid: 0, gid: 0 }); }); - test("omits guestHomeDir when not specified", () => { - const config = parseConfig({}); - expect(config.guestHomeDir).toBeUndefined(); + test("accepts a guestUser.homedir override", () => { + const config = parseConfig({ + guestUser: { uid: 0, gid: 0, homedir: "/custom/home" }, + }); + expect(config.guestUser?.homedir).toBe("/custom/home"); + }); + + test("omits guestUser.homedir when not specified", () => { + const config = parseConfig({ guestUser: { uid: 0, gid: 0 } }); + expect(config.guestUser?.homedir).toBeUndefined(); + }); + + test("rejects a non-root guestUser", () => { + expect(() => + parseConfig({ guestUser: { uid: 1000, gid: 1000 } }), + ).toThrow(); + }); + + test("accepts a per-mount owner", () => { + const config = parseConfig({ + mounts: [{ hostPath: "/data", owner: { uid: 1000, gid: 1000 } }], + }); + expect(config.mounts![0]!.owner).toEqual({ uid: 1000, gid: 1000 }); + }); + + test("accepts a per-volume owner", () => { + const config = parseConfig({ + volumes: [{ guestPath: "/cache", owner: { uid: 1000 } }], + }); + expect(config.volumes![0]!.owner).toEqual({ uid: 1000 }); + }); + + test("rejects a negative owner uid", () => { + expect(() => + parseConfig({ mounts: [{ hostPath: "/data", owner: { uid: -1 } }] }), + ).toThrow(); }); test("parses mount with ignore list", () => { diff --git a/src/config/schema.ts b/src/config/schema.ts index 23af674..76607f4 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -25,13 +25,6 @@ const types = scope({ /** Environment variables to set in the guest. */ "env?": { "[string]": "EnvValue" }, - /** - * Override the assumed guest user home directory (used for ~ expansion in - * guestPaths). Defaults to /root when config.user = root, /home/$user - * otherwise. - */ - "guestHomeDir?": "AbsolutePath", - /** * Mount existing host directories in the VM guest's file system. */ @@ -58,15 +51,9 @@ const types = scope({ "resources?": "ResourcesConfig", /** - * The user to open the shell under and to make mounted directories - * available for. Must currently be root due to Gondolin-related - * constraints. - * - * Optional (no schema default): the "root" default is applied post-merge in - * applyConfigDefaults, so an inherited value isn't clobbered by a child - * layer that merely omitted the field. + * The user (as numeric uid/gid) that the guest shell runs under. */ - "user?": "string > 0", + "guestUser?": "GuestUserConfig", /** * Volumes are host-backed, initially empty directories that the VM guest @@ -115,6 +102,39 @@ const types = scope({ /** An env var value: a literal/interpolated string, host-sourced, or a secret. */ EnvValue: "string | EnvSecret | EnvFromHost", + // -------------------------------------------------------------------------- + // Guest user + // -------------------------------------------------------------------------- + + /** + * The user the guest shell runs under, as numeric uid/gid. Enforced to root + * (`{ uid: 0, gid: 0 }`) for now. + */ + GuestUserConfig: { + "+": "reject", + uid: "0", + gid: "0", + /** + * Override the guest user's home directory (used for `~` expansion in guest + * paths). Defaults to /root (only root is supported for now). + */ + "homedir?": "AbsolutePath", + }, + + /** + * Ownership (numeric uid/gid) presented to the guest for a mount's or volume's + * entries. Each field is optional and falls back to the guest user's uid/gid + * (`guestUser`). + * + * This is display-only: it changes what the guest sees via stat(); it does not + * change on-host ownership. + */ + OwnerConfig: { + "+": "reject", + "uid?": "number.integer >= 0", + "gid?": "number.integer >= 0", + }, + // -------------------------------------------------------------------------- // Mounting & volumes, working directory // -------------------------------------------------------------------------- @@ -147,10 +167,20 @@ const types = scope({ * Loaded once at boot; changes require VM restart. */ "ignoreFileRefs?": "(string > 0)[]", + /** + * Ownership (uid/gid) presented to the guest for this mount's entries. + * Defaults to the guest user (`guestUser`). Display-only — see OwnerConfig. + */ + "owner?": "OwnerConfig", }, VolumeConfig: { "+": "reject", guestPath: "AbsolutePath | TildePath", + /** + * Ownership (uid/gid) presented to the guest for this volume's entries. + * Defaults to the guest user (`guestUser`). Display-only — see OwnerConfig. + */ + "owner?": "OwnerConfig", }, /** * Working directory inside the guest. Either just a guest path (string) to cd @@ -163,7 +193,7 @@ const types = scope({ AbsolutePath: type("string > 0").matching(/^\//), /** * Path starting with ~ (bare "~" or "~/…"), expanded either on the host or on - * the guest (see config.guestHomeDir). + * the guest (see config.guestUser.homedir). */ TildePath: type("string > 0").matching(/^~(\/|$)/), @@ -295,6 +325,8 @@ const types = scope({ }, }).export(); +export type GuestUserConfig = typeof types.GuestUserConfig.infer; +export type OwnerConfig = typeof types.OwnerConfig.infer; export type VolumeConfig = typeof types.VolumeConfig.infer; export type MountConfig = typeof types.MountConfig.infer; export type NixConfig = typeof types.NixConfig.infer; diff --git a/src/core/mounts.test.ts b/src/core/mounts.test.ts index 3c10d31..b9e4ac4 100644 --- a/src/core/mounts.test.ts +++ b/src/core/mounts.test.ts @@ -1,7 +1,11 @@ import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { ReadonlyProvider, RealFSProvider } from "@earendil-works/gondolin"; +import { + ReadonlyProvider, + RealFSProvider, + type VirtualProvider, +} from "@earendil-works/gondolin"; import { describe, expect, test } from "vitest"; import { buildVfsMounts, @@ -12,6 +16,17 @@ import { validateMounts, } from "./mounts.ts"; import { OverlayProvider } from "./overlay-provider.ts"; +import { OwnershipProvider } from "./ownership-provider.ts"; + +const OWNER = { uid: 0, gid: 0 }; + +/** + * buildVfsMounts/buildVfsVolumes wrap every provider in an OwnershipProvider + * (outermost). Reach past it to assert on the underlying mode-specific provider. + */ +function inner(provider: VirtualProvider | undefined): VirtualProvider { + return (provider as unknown as { backend: VirtualProvider }).backend; +} describe("validateMounts", () => { const validDeps: MountValidationDeps = { @@ -26,6 +41,7 @@ describe("validateMounts", () => { guestPath: "/data", mode: "readwrite", shadowPatterns: [], + owner: OWNER, }, ]; expect(() => validateMounts(mounts, [], validDeps)).not.toThrow(); @@ -42,6 +58,7 @@ describe("validateMounts", () => { guestPath: "/data", mode: "readwrite", shadowPatterns: [], + owner: OWNER, }, ]; expect(() => validateMounts(mounts, [], deps)).toThrow( @@ -60,6 +77,7 @@ describe("validateMounts", () => { guestPath: "/data", mode: "readwrite", shadowPatterns: [], + owner: OWNER, }, ]; expect(() => validateMounts(mounts, [], deps)).toThrow( @@ -74,12 +92,14 @@ describe("validateMounts", () => { guestPath: "/data", mode: "readwrite", shadowPatterns: [], + owner: OWNER, }, { hostPath: "/b", guestPath: "/data", mode: "readwrite", shadowPatterns: [], + owner: OWNER, }, ]; expect(() => validateMounts(mounts, [], validDeps)).toThrow( @@ -89,43 +109,82 @@ describe("validateMounts", () => { }); describe("buildVfsMounts", () => { - test("readwrite mode creates RealFSProvider", () => { + test("wraps every mount in an OwnershipProvider", () => { const mounts: MountSpec[] = [ { hostPath: "/tmp", guestPath: "/data", mode: "readwrite", shadowPatterns: [], + owner: OWNER, }, ]; const result = buildVfsMounts(mounts); - expect(result["/data"]).toBeInstanceOf(RealFSProvider); + expect(result["/data"]).toBeInstanceOf(OwnershipProvider); }); - test("readonly mode creates ReadonlyProvider", () => { + test("readwrite mode wraps a RealFSProvider", () => { + const mounts: MountSpec[] = [ + { + hostPath: "/tmp", + guestPath: "/data", + mode: "readwrite", + shadowPatterns: [], + owner: OWNER, + }, + ]; + const result = buildVfsMounts(mounts); + expect(inner(result["/data"])).toBeInstanceOf(RealFSProvider); + }); + + test("readonly mode wraps a ReadonlyProvider", () => { const mounts: MountSpec[] = [ { hostPath: "/tmp", guestPath: "/data", mode: "readonly", shadowPatterns: [], + owner: OWNER, }, ]; const result = buildVfsMounts(mounts); - expect(result["/data"]).toBeInstanceOf(ReadonlyProvider); + expect(inner(result["/data"])).toBeInstanceOf(ReadonlyProvider); }); - test("overlay-tmpfs mode creates OverlayProvider", () => { + test("overlay-tmpfs mode wraps an OverlayProvider", () => { const mounts: MountSpec[] = [ { hostPath: "/tmp", guestPath: "/data", mode: "overlay-tmpfs", shadowPatterns: [], + owner: OWNER, }, ]; const result = buildVfsMounts(mounts); - expect(result["/data"]).toBeInstanceOf(OverlayProvider); + expect(inner(result["/data"])).toBeInstanceOf(OverlayProvider); + }); + + test("presents the configured owner via stat", async () => { + const hostDir = mkdtempSync(`${tmpdir()}/tuor-test-`); + try { + writeFileSync(join(hostDir, "file.txt"), "hello"); + const mounts: MountSpec[] = [ + { + hostPath: hostDir, + guestPath: "/data", + mode: "readwrite", + shadowPatterns: [], + owner: { uid: 1234, gid: 5678 }, + }, + ]; + const provider = buildVfsMounts(mounts)["/data"]!; + const stats = await provider.stat("/file.txt"); + expect(stats.uid).toBe(1234); + expect(stats.gid).toBe(5678); + } finally { + rmSync(hostDir, { recursive: true }); + } }); test("shadow patterns wrap backend with ShadowProvider, hiding specified files", async () => { @@ -139,6 +198,7 @@ describe("buildVfsMounts", () => { guestPath: "/data", mode: "readonly", shadowPatterns: [{ pattern: ".env", scope: "/" }], + owner: OWNER, }, ]; const result = buildVfsMounts(mounts); @@ -158,20 +218,7 @@ describe("buildVfsMounts", () => { } }); - test("no shadow patterns does not wrap with ShadowProvider", () => { - const mounts: MountSpec[] = [ - { - hostPath: "/tmp", - guestPath: "/data", - mode: "readwrite", - shadowPatterns: [], - }, - ]; - const result = buildVfsMounts(mounts); - expect(result["/data"]).toBeInstanceOf(RealFSProvider); - }); - - test("overlay mode creates OverlayProvider and state directory", () => { + test("overlay mode wraps an OverlayProvider and creates the state directory", () => { const configDir = mkdtempSync(`${tmpdir()}/tuor-test-`); try { const stateDir = `${configDir}/.state/overlays/workspace`; @@ -181,11 +228,12 @@ describe("buildVfsMounts", () => { guestPath: "/workspace", mode: "overlay", shadowPatterns: [], + owner: OWNER, overlayStateDir: stateDir, }, ]; const result = buildVfsMounts(mounts); - expect(result["/workspace"]).toBeInstanceOf(OverlayProvider); + expect(inner(result["/workspace"])).toBeInstanceOf(OverlayProvider); expect(existsSync(stateDir)).toBe(true); } finally { rmSync(configDir, { recursive: true }); @@ -199,6 +247,7 @@ describe("buildVfsMounts", () => { guestPath: "/workspace", mode: "overlay", shadowPatterns: [], + owner: OWNER, }, ]; expect(() => buildVfsMounts(mounts)).toThrow(/missing overlayStateDir/); @@ -218,10 +267,11 @@ describe("validateMounts with volumes", () => { guestPath: "/data", mode: "readwrite", shadowPatterns: [], + owner: OWNER, }, ]; const volumes: VolumeSpec[] = [ - { guestPath: "/cache", stateDir: "/tmp/state/cache" }, + { guestPath: "/cache", stateDir: "/tmp/state/cache", owner: OWNER }, ]; expect(() => validateMounts(mounts, volumes, validDeps)).not.toThrow(); }); @@ -233,10 +283,11 @@ describe("validateMounts with volumes", () => { guestPath: "/data", mode: "readwrite", shadowPatterns: [], + owner: OWNER, }, ]; const volumes: VolumeSpec[] = [ - { guestPath: "/data", stateDir: "/tmp/state/data" }, + { guestPath: "/data", stateDir: "/tmp/state/data", owner: OWNER }, ]; expect(() => validateMounts(mounts, volumes, validDeps)).toThrow( "Duplicate guest mount path: /data", @@ -245,8 +296,8 @@ describe("validateMounts with volumes", () => { test("throws when two volumes have the same guestPath", () => { const volumes: VolumeSpec[] = [ - { guestPath: "/cache", stateDir: "/tmp/state/cache1" }, - { guestPath: "/cache", stateDir: "/tmp/state/cache2" }, + { guestPath: "/cache", stateDir: "/tmp/state/cache1", owner: OWNER }, + { guestPath: "/cache", stateDir: "/tmp/state/cache2", owner: OWNER }, ]; expect(() => validateMounts([], volumes, validDeps)).toThrow( "Duplicate guest mount path: /cache", @@ -255,12 +306,15 @@ describe("validateMounts with volumes", () => { }); describe("buildVfsVolumes", () => { - test("creates RealFSProvider backed by state directory", () => { + test("wraps a RealFSProvider (backed by the state directory) in ownership", () => { const stateDir = mkdtempSync(`${tmpdir()}/tuor-test-`); try { - const volumes: VolumeSpec[] = [{ guestPath: "/cache", stateDir }]; + const volumes: VolumeSpec[] = [ + { guestPath: "/cache", stateDir, owner: OWNER }, + ]; const result = buildVfsVolumes(volumes); - expect(result["/cache"]).toBeInstanceOf(RealFSProvider); + expect(result["/cache"]).toBeInstanceOf(OwnershipProvider); + expect(inner(result["/cache"])).toBeInstanceOf(RealFSProvider); } finally { rmSync(stateDir, { recursive: true }); } @@ -270,7 +324,9 @@ describe("buildVfsVolumes", () => { const base = mkdtempSync(`${tmpdir()}/tuor-test-`); try { const stateDir = join(base, "nested", "volume"); - const volumes: VolumeSpec[] = [{ guestPath: "/data", stateDir }]; + const volumes: VolumeSpec[] = [ + { guestPath: "/data", stateDir, owner: OWNER }, + ]; buildVfsVolumes(volumes); expect(existsSync(stateDir)).toBe(true); } finally { diff --git a/src/core/mounts.ts b/src/core/mounts.ts index 92b9502..710197c 100644 --- a/src/core/mounts.ts +++ b/src/core/mounts.ts @@ -7,8 +7,11 @@ import { type VirtualProvider, } from "@earendil-works/gondolin"; import { OverlayProvider } from "./overlay-provider.ts"; +import { type Owner, OwnershipProvider } from "./ownership-provider.ts"; import { buildShadowPredicate, type ScopedPattern } from "./shadow.ts"; +export type { Owner }; + // --- Types --- export const MOUNT_MODES = [ @@ -24,6 +27,8 @@ export type VolumeSpec = { guestPath: string; /** On-host directory where the volume's data is stored. */ stateDir: string; + /** Ownership (uid/gid) presented to the guest for this volume's entries. */ + owner: Owner; }; /** Core's input contract for a single mount — fully resolved, no optionals. */ @@ -32,6 +37,8 @@ export type MountSpec = { guestPath: string; mode: MountMode; shadowPatterns: ScopedPattern[]; + /** Ownership (uid/gid) presented to the guest for this mount's entries. */ + owner: Owner; /** Pre-computed path for persistent overlay state. Only for 'overlay' mode. */ overlayStateDir?: string; }; @@ -80,7 +87,10 @@ export function buildVfsVolumes( const result: Record = {}; for (const vol of volumes) { mkdirSync(vol.stateDir, { recursive: true }); - result[vol.guestPath] = new RealFSProvider(vol.stateDir); + result[vol.guestPath] = new OwnershipProvider( + new RealFSProvider(vol.stateDir), + vol.owner, + ); } return result; } @@ -100,18 +110,16 @@ export function buildVfsMounts( }); } + let provider: VirtualProvider; switch (mount.mode) { case "readwrite": - result[mount.guestPath] = backend; + provider = backend; break; case "readonly": - result[mount.guestPath] = new ReadonlyProvider(backend); + provider = new ReadonlyProvider(backend); break; case "overlay-tmpfs": - result[mount.guestPath] = new OverlayProvider( - backend, - new MemoryProvider(), - ); + provider = new OverlayProvider(backend, new MemoryProvider()); break; case "overlay": { const stateDir = mount.overlayStateDir; @@ -121,13 +129,14 @@ export function buildVfsMounts( ); } mkdirSync(stateDir, { recursive: true }); - result[mount.guestPath] = new OverlayProvider( - backend, - new RealFSProvider(stateDir), - ); + provider = new OverlayProvider(backend, new RealFSProvider(stateDir)); break; } } + + // Rewrite ownership outermost, so it's consistent across all mount modes + // (incl. overlay upper-layer and guest-created files). + result[mount.guestPath] = new OwnershipProvider(provider, mount.owner); } return result; } diff --git a/src/core/overlay-provider.ts b/src/core/overlay-provider.ts index 96e1514..69e2961 100644 --- a/src/core/overlay-provider.ts +++ b/src/core/overlay-provider.ts @@ -10,6 +10,7 @@ import { type VirtualProvider, VirtualProviderClass, } from "@earendil-works/gondolin"; +import type { MaybeClosable } from "./vfs-types.ts"; // --- Constants --- @@ -30,10 +31,13 @@ export class OverlayProvider extends (VirtualProviderClass as new () => Record) implements VirtualProvider { - private readonly lower: VirtualProvider; - private readonly upper: VirtualProvider; + private readonly lower: VirtualProvider & MaybeClosable; + private readonly upper: VirtualProvider & MaybeClosable; - constructor(lower: VirtualProvider, upper: VirtualProvider) { + constructor( + lower: VirtualProvider & MaybeClosable, + upper: VirtualProvider & MaybeClosable, + ) { super(); this.lower = lower; this.upper = upper; @@ -433,12 +437,8 @@ export class OverlayProvider } async close() { - const upperClose = (this.upper as { close?: () => Promise | void }) - .close; - const lowerClose = (this.lower as { close?: () => Promise | void }) - .close; - if (upperClose) await upperClose.call(this.upper); - if (lowerClose) await lowerClose.call(this.lower); + if (this.upper.close) await this.upper.close(); + if (this.lower.close) await this.lower.close(); } // --- Private: open helpers --- diff --git a/src/core/ownership-provider.test.ts b/src/core/ownership-provider.test.ts new file mode 100644 index 0000000..25e0a3d --- /dev/null +++ b/src/core/ownership-provider.test.ts @@ -0,0 +1,103 @@ +import { + mkdirSync, + mkdtempSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { RealFSProvider } from "@earendil-works/gondolin"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { OwnershipProvider } from "./ownership-provider.ts"; + +const OWNER = { uid: 4242, gid: 4243 }; + +describe("OwnershipProvider", () => { + let dir: string; + let backend: RealFSProvider; + let provider: OwnershipProvider; + + beforeEach(() => { + dir = mkdtempSync(`${tmpdir()}/tuor-ownership-`); + writeFileSync(join(dir, "file.txt"), "hello"); + mkdirSync(join(dir, "subdir")); + symlinkSync("file.txt", join(dir, "link")); + backend = new RealFSProvider(dir); + provider = new OwnershipProvider(backend, OWNER); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + test("stat reports the configured owner", async () => { + const stats = await provider.stat("/file.txt"); + expect(stats.uid).toBe(OWNER.uid); + expect(stats.gid).toBe(OWNER.gid); + }); + + test("statSync reports the configured owner", () => { + const stats = provider.statSync("/file.txt"); + expect(stats.uid).toBe(OWNER.uid); + expect(stats.gid).toBe(OWNER.gid); + }); + + test("lstat reports the configured owner", async () => { + const stats = await provider.lstat("/link"); + expect(stats.uid).toBe(OWNER.uid); + expect(stats.gid).toBe(OWNER.gid); + }); + + test("preserves Stats methods through the clone", async () => { + const dirStats = await provider.stat("/subdir"); + expect(dirStats.isDirectory()).toBe(true); + expect(dirStats.isFile()).toBe(false); + expect(dirStats.uid).toBe(OWNER.uid); + + const fileStats = await provider.stat("/file.txt"); + expect(fileStats.isFile()).toBe(true); + + const linkStats = await provider.lstat("/link"); + expect(linkStats.isSymbolicLink()).toBe(true); + }); + + test("an open handle's stat reports the configured owner", async () => { + const handle = await provider.open("/file.txt", "r"); + try { + const stats = await handle.stat(); + expect(stats.uid).toBe(OWNER.uid); + expect(stats.gid).toBe(OWNER.gid); + } finally { + await handle.close(); + } + }); + + test("preserves the file size (other fields pass through)", async () => { + const stats = await provider.stat("/file.txt"); + expect(stats.size).toBe("hello".length); + }); + + test("readdir passes through unchanged", async () => { + const entries = await provider.readdir("/"); + const names = entries.map((e) => (typeof e === "string" ? e : e.name)); + expect(names.sort()).toEqual(["file.txt", "link", "subdir"]); + }); + + test("reads file content through the wrapper", async () => { + const handle = await provider.open("/file.txt", "r"); + try { + const content = await handle.readFile({ encoding: "utf-8" }); + expect(content).toBe("hello"); + } finally { + await handle.close(); + } + }); + + test("does not mutate the backend's Stats object", async () => { + // The backend keeps reporting the real on-host owner; only the wrapper + // rewrites it. (Confirms we clone rather than mutate in place.) + const real = await backend.stat("/file.txt"); + expect(real.uid).not.toBe(OWNER.uid); + }); +}); diff --git a/src/core/ownership-provider.ts b/src/core/ownership-provider.ts new file mode 100644 index 0000000..d5d2751 --- /dev/null +++ b/src/core/ownership-provider.ts @@ -0,0 +1,377 @@ +import type { Stats } from "node:fs"; +import { + type VfsStatfs, + type VirtualFileHandle, + type VirtualProvider, + VirtualProviderClass, +} from "@earendil-works/gondolin"; +import type { MaybeClosable } from "./vfs-types.ts"; + +// --- Public API --- + +/** Numeric owner (uid/gid) presented to the guest for a mount's entries. */ +export type Owner = { uid: number; gid: number }; + +/** + * Wraps a VirtualProvider and rewrites the ownership (uid/gid) reported to the + * guest, so mounted host files appear owned by `owner` regardless of their real + * on-host ownership. + * + * This is display-only: it changes what `stat`/`lstat` (and an open handle's + * `stat`) report; it does not change on-host ownership, and files the guest + * creates still land on the host owned by the Tuor process user. + * + * Everything other than the ownership fields is forwarded verbatim to the + * backing provider. + */ +export class OwnershipProvider + extends VirtualProviderClass + implements VirtualProvider +{ + private readonly backend: VirtualProvider & MaybeClosable; + private readonly owner: Owner; + + constructor(backend: VirtualProvider & MaybeClosable, owner: Owner) { + super(); + this.backend = backend; + this.owner = owner; + } + + get readonly() { + return this.backend.readonly; + } + + get supportsSymlinks() { + return this.backend.supportsSymlinks; + } + + get supportsWatch() { + return this.backend.supportsWatch; + } + + // --- stat / lstat: rewrite ownership --- + + async stat(path: string, options?: object) { + return withOwner(await this.backend.stat(path, options), this.owner); + } + + statSync(path: string, options?: object) { + return withOwner(this.backend.statSync(path, options), this.owner); + } + + async lstat(path: string, options?: object) { + return withOwner(await this.backend.lstat(path, options), this.owner); + } + + lstatSync(path: string, options?: object) { + return withOwner(this.backend.lstatSync(path, options), this.owner); + } + + // --- open: wrap the handle so its stat rewrites ownership too --- + + async open(path: string, flags: string, mode?: number) { + return new OwnershipFileHandle( + await this.backend.open(path, flags, mode), + this.owner, + ); + } + + openSync(path: string, flags: string, mode?: number) { + return new OwnershipFileHandle( + this.backend.openSync(path, flags, mode), + this.owner, + ); + } + + // --- everything else: forward verbatim --- + + async readdir(path: string, options?: object) { + return this.backend.readdir(path, options); + } + + readdirSync(path: string, options?: object) { + return this.backend.readdirSync(path, options); + } + + async mkdir(path: string, options?: object) { + return this.backend.mkdir(path, options); + } + + mkdirSync(path: string, options?: object) { + return this.backend.mkdirSync(path, options); + } + + async rmdir(path: string) { + return this.backend.rmdir(path); + } + + rmdirSync(path: string) { + return this.backend.rmdirSync(path); + } + + async unlink(path: string) { + return this.backend.unlink(path); + } + + unlinkSync(path: string) { + return this.backend.unlinkSync(path); + } + + async rename(oldPath: string, newPath: string) { + return this.backend.rename(oldPath, newPath); + } + + renameSync(oldPath: string, newPath: string) { + return this.backend.renameSync(oldPath, newPath); + } + + async link(existingPath: string, newPath: string) { + if (this.backend.link) { + return this.backend.link(existingPath, newPath); + } + return super.link(existingPath, newPath); + } + + linkSync(existingPath: string, newPath: string) { + if (this.backend.linkSync) { + return this.backend.linkSync(existingPath, newPath); + } + return super.linkSync(existingPath, newPath); + } + + async readlink(path: string, options?: object) { + if (this.backend.readlink) { + return this.backend.readlink(path, options); + } + return super.readlink(path, options); + } + + readlinkSync(path: string, options?: object) { + if (this.backend.readlinkSync) { + return this.backend.readlinkSync(path, options); + } + return super.readlinkSync(path, options); + } + + async symlink(target: string, path: string, type?: string) { + if (this.backend.symlink) { + return this.backend.symlink(target, path, type); + } + return super.symlink(target, path, type); + } + + symlinkSync(target: string, path: string, type?: string) { + if (this.backend.symlinkSync) { + return this.backend.symlinkSync(target, path, type); + } + return super.symlinkSync(target, path, type); + } + + async realpath(path: string, options?: object) { + if (this.backend.realpath) { + return this.backend.realpath(path, options); + } + return super.realpath(path, options); + } + + realpathSync(path: string, options?: object) { + if (this.backend.realpathSync) { + return this.backend.realpathSync(path, options); + } + return super.realpathSync(path, options); + } + + async access(path: string, mode?: number) { + if (this.backend.access) { + return this.backend.access(path, mode); + } + return super.access(path, mode); + } + + accessSync(path: string, mode?: number) { + if (this.backend.accessSync) { + return this.backend.accessSync(path, mode); + } + return super.accessSync(path, mode); + } + + async statfs(path: string): Promise { + if (this.backend.statfs) { + return this.backend.statfs(path); + } + return super.statfs(path); + } + + watch(path: string, options?: object) { + if (this.backend.watch) { + return this.backend.watch(path, options); + } + return super.watch(path, options); + } + + watchAsync(path: string, options?: object) { + if (this.backend.watchAsync) { + return this.backend.watchAsync(path, options); + } + return super.watchAsync(path, options); + } + + watchFile( + path: string, + options?: object, + listener?: (...args: unknown[]) => void, + ) { + if (this.backend.watchFile) { + return this.backend.watchFile(path, options, listener); + } + return super.watchFile(path, options); + } + + unwatchFile(path: string, listener?: (...args: unknown[]) => void) { + if (this.backend.unwatchFile) { + return this.backend.unwatchFile(path, listener); + } + return super.unwatchFile(path, listener); + } + + async close() { + if (this.backend.close) { + await this.backend.close(); + } + } +} + +// --- Internals --- + +/** + * Wraps a VirtualFileHandle, rewriting the ownership reported by `stat`/ + * `statSync` (used by the create + fstat RPC paths). All other operations + * forward to the inner handle. + */ +class OwnershipFileHandle implements VirtualFileHandle { + private readonly inner: VirtualFileHandle; + private readonly owner: Owner; + + constructor(inner: VirtualFileHandle, owner: Owner) { + this.inner = inner; + this.owner = owner; + } + + get path() { + return this.inner.path; + } + + get flags() { + return this.inner.flags; + } + + get mode() { + return this.inner.mode; + } + + get position() { + return this.inner.position; + } + + get closed() { + return this.inner.closed; + } + + read( + buffer: Buffer, + offset: number, + length: number, + position?: number | null, + ) { + return this.inner.read(buffer, offset, length, position); + } + + readSync( + buffer: Buffer, + offset: number, + length: number, + position?: number | null, + ) { + return this.inner.readSync(buffer, offset, length, position); + } + + write( + buffer: Buffer, + offset: number, + length: number, + position?: number | null, + ) { + return this.inner.write(buffer, offset, length, position); + } + + writeSync( + buffer: Buffer, + offset: number, + length: number, + position?: number | null, + ) { + return this.inner.writeSync(buffer, offset, length, position); + } + + readFile(options?: { encoding?: BufferEncoding } | BufferEncoding) { + return this.inner.readFile(options); + } + + readFileSync(options?: { encoding?: BufferEncoding } | BufferEncoding) { + return this.inner.readFileSync(options); + } + + writeFile(data: Buffer | string, options?: { encoding?: BufferEncoding }) { + return this.inner.writeFile(data, options); + } + + writeFileSync( + data: Buffer | string, + options?: { encoding?: BufferEncoding }, + ) { + return this.inner.writeFileSync(data, options); + } + + async stat(options?: object) { + return withOwner(await this.inner.stat(options), this.owner); + } + + statSync(options?: object) { + return withOwner(this.inner.statSync(options), this.owner); + } + + truncate(len?: number) { + return this.inner.truncate(len); + } + + truncateSync(len?: number) { + return this.inner.truncateSync(len); + } + + close() { + return this.inner.close(); + } + + closeSync() { + return this.inner.closeSync(); + } +} + +/** + * Return a copy of `stats` with uid/gid replaced by `owner`. + * + * We clone (rather than mutate) so the backend's object is untouched, and we + * preserve the prototype so methods like `isDirectory()` (which read + * `this.mode`) keep working. `uid`/`gid` are plain writable data properties on + * both Node's `fs.Stats` and Gondolin's virtual Stats, so overriding them on the + * clone is enough. + */ +function withOwner(stats: Stats, owner: Owner): Stats { + const clone = Object.create( + Object.getPrototypeOf(stats), + Object.getOwnPropertyDescriptors(stats), + ) as Stats; + clone.uid = owner.uid; + clone.gid = owner.gid; + return clone; +} diff --git a/src/core/session.ts b/src/core/session.ts index e007d2c..54850a3 100644 --- a/src/core/session.ts +++ b/src/core/session.ts @@ -45,7 +45,6 @@ export type ResourcesSpec = { /** Core's top-level input contract — everything the session needs to run. */ export type SessionSpec = { - user: string; workdir: string; network: NetworkSpec; mounts: MountSpec[]; @@ -107,12 +106,14 @@ export async function runSession( } else { console.log("Spawning shell…"); } - // `su myuser` gives us an interactive non-login shell (`su - myuser` would - // give us a login shell but would also cd into the user's home dir, messing - // with the cwd we configure below.) + // Run directly as root — Gondolin's default exec user — with the guest init + // environment (HOME=/root, PATH, …). Only root is supported for now, so + // there's no user to `su` into; $USER/$LOGNAME are left unset (`id`/`whoami` + // still report root). cwd is set explicitly below. We use bash, matching + // Gondolin's own default command (["/bin/bash", "-i"]). const shellCommand = command - ? ["su", spec.user, "-c", command.join(" ")] - : ["su", spec.user]; + ? ["/bin/bash", "-c", command.join(" ")] + : ["/bin/bash", "-i"]; await vm.shell({ attach: true, command: shellCommand, diff --git a/src/core/vfs-types.ts b/src/core/vfs-types.ts new file mode 100644 index 0000000..3d46b97 --- /dev/null +++ b/src/core/vfs-types.ts @@ -0,0 +1,12 @@ +/** + * A provider that optionally exposes a `close` lifecycle method for releasing + * resources. + * + * Gondolin's `VirtualProvider` type does not declare `close`, yet several + * concrete providers (e.g. `SandboxVfsProvider`, `ShadowProvider`) implement one + * for cleanup. Wrappers intersect this with `VirtualProvider` on the backends + * they accept so they can forward `close` when present — without casting at the + * call site. `close` stays optional, so any plain `VirtualProvider` still + * satisfies the intersection. + */ +export type MaybeClosable = { close?: () => Promise | void };