diff --git a/guest/src/sandboxfs/main.zig b/guest/src/sandboxfs/main.zig index 4557c4c3..4646ad65 100644 --- a/guest/src/sandboxfs/main.zig +++ b/guest/src/sandboxfs/main.zig @@ -269,23 +269,60 @@ const DefaultTtls = struct { pub const entry_ms: u64 = 1000; }; +const CreatedOwnership = struct { + ino: u64, + uid: u32, + gid: u32, +}; + const SandboxFs = struct { allocator: std.mem.Allocator, fuse_fd: posix.fd_t, rpc: ?fs_rpc.FsRpcClient, + map_created_ownership: bool, + created_ownership: std.ArrayList(CreatedOwnership) = .empty, unsupported_opcode_logged: [256]bool, - pub fn init(allocator: std.mem.Allocator, fuse_fd: posix.fd_t, rpc: ?fs_rpc.FsRpcClient) SandboxFs { + pub fn init(allocator: std.mem.Allocator, fuse_fd: posix.fd_t, rpc: ?fs_rpc.FsRpcClient, map_created_ownership: bool) SandboxFs { return .{ .allocator = allocator, .fuse_fd = fuse_fd, .rpc = rpc, + .map_created_ownership = map_created_ownership, .unsupported_opcode_logged = @splat(false), }; } pub fn deinit(self: *SandboxFs) void { - _ = self; + self.created_ownership.deinit(self.allocator); + } + + fn rememberCreatedOwnership(self: *SandboxFs, ino: u64, uid: u32, gid: u32) !void { + if (!self.map_created_ownership) return; + for (self.created_ownership.items) |*mapping| { + if (mapping.ino == ino) { + mapping.uid = uid; + mapping.gid = gid; + return; + } + } + try self.created_ownership.append(self.allocator, .{ .ino = ino, .uid = uid, .gid = gid }); + } + + fn applyCreatedOwnership(self: *SandboxFs, attr: *FuseAttr) void { + if (!self.map_created_ownership) return; + for (self.created_ownership.items) |mapping| { + if (mapping.ino == attr.ino) { + attr.uid = mapping.uid; + attr.gid = mapping.gid; + return; + } + } + } + + fn applyCreatedOwnershipToEntry(self: *SandboxFs, entry: *ParsedEntry) void { + self.applyCreatedOwnership(&entry.attr); + entry.out.attr = entry.attr; } pub fn run(self: *SandboxFs) !void { @@ -402,7 +439,8 @@ const SandboxFs = struct { const res_map = response.res orelse return error.InvalidResponse; const entry_val = cbor.getMapValue(res_map, "entry") orelse return error.InvalidResponse; const entry_map = try expectMap(entry_val); - const entry = try parseEntry(entry_map, DefaultTtls.attr_ms, DefaultTtls.entry_ms); + var entry = try parseEntry(entry_map, DefaultTtls.attr_ms, DefaultTtls.entry_ms); + self.applyCreatedOwnershipToEntry(&entry); try sendResponse(self.fuse_fd, header.unique, 0, std.mem.asBytes(&entry.out)); return; @@ -432,7 +470,8 @@ const SandboxFs = struct { const res_map = response.res orelse return error.InvalidResponse; const attr_val = cbor.getMapValue(res_map, "attr") orelse return error.InvalidResponse; const attr_map = try expectMap(attr_val); - const attr = try parseAttr(attr_map, header.nodeid); + var attr = try parseAttr(attr_map, header.nodeid); + self.applyCreatedOwnership(&attr); const attr_ttl_ms = getMapU64(res_map, "attr_ttl_ms") orelse DefaultTtls.attr_ms; const out = buildAttrOut(attr, attr_ttl_ms); @@ -515,7 +554,8 @@ const SandboxFs = struct { const res_map = attr_response.res orelse return error.InvalidResponse; const attr_val = cbor.getMapValue(res_map, "attr") orelse return error.InvalidResponse; const attr_map = try expectMap(attr_val); - const attr = try parseAttr(attr_map, header.nodeid); + var attr = try parseAttr(attr_map, header.nodeid); + self.applyCreatedOwnership(&attr); const attr_ttl_ms = getMapU64(res_map, "attr_ttl_ms") orelse DefaultTtls.attr_ms; const out = buildAttrOut(attr, attr_ttl_ms); @@ -548,7 +588,8 @@ const SandboxFs = struct { const res_map = response.res orelse return error.InvalidResponse; const entry_val = cbor.getMapValue(res_map, "entry") orelse return error.InvalidResponse; const entry_map = try expectMap(entry_val); - const entry = try parseEntry(entry_map, DefaultTtls.attr_ms, DefaultTtls.entry_ms); + var entry = try parseEntry(entry_map, DefaultTtls.attr_ms, DefaultTtls.entry_ms); + self.applyCreatedOwnershipToEntry(&entry); try sendResponse(self.fuse_fd, header.unique, 0, std.mem.asBytes(&entry.out)); } @@ -578,7 +619,9 @@ const SandboxFs = struct { const res_map = response.res orelse return error.InvalidResponse; const entry_val = cbor.getMapValue(res_map, "entry") orelse return error.InvalidResponse; const entry_map = try expectMap(entry_val); - const entry = try parseEntry(entry_map, DefaultTtls.attr_ms, DefaultTtls.entry_ms); + var entry = try parseEntry(entry_map, DefaultTtls.attr_ms, DefaultTtls.entry_ms); + try self.rememberCreatedOwnership(entry.attr.ino, header.uid, header.gid); + self.applyCreatedOwnershipToEntry(&entry); try sendResponse(self.fuse_fd, header.unique, 0, std.mem.asBytes(&entry.out)); } @@ -699,7 +742,8 @@ const SandboxFs = struct { const res_map = response.res orelse return error.InvalidResponse; const entry_val = cbor.getMapValue(res_map, "entry") orelse return error.InvalidResponse; const entry_map = try expectMap(entry_val); - const entry = try parseEntry(entry_map, DefaultTtls.attr_ms, DefaultTtls.entry_ms); + var entry = try parseEntry(entry_map, DefaultTtls.attr_ms, DefaultTtls.entry_ms); + self.applyCreatedOwnershipToEntry(&entry); try sendResponse(self.fuse_fd, header.unique, 0, std.mem.asBytes(&entry.out)); } @@ -914,7 +958,9 @@ const SandboxFs = struct { const res_map = response.res orelse return error.InvalidResponse; const entry_val = cbor.getMapValue(res_map, "entry") orelse return error.InvalidResponse; const entry_map = try expectMap(entry_val); - const entry = try parseEntry(entry_map, DefaultTtls.attr_ms, DefaultTtls.entry_ms); + var entry = try parseEntry(entry_map, DefaultTtls.attr_ms, DefaultTtls.entry_ms); + try self.rememberCreatedOwnership(entry.attr.ino, header.uid, header.gid); + self.applyCreatedOwnershipToEntry(&entry); const fh = getMapU64(res_map, "fh") orelse return error.InvalidResponse; const open_flags = getMapU32(res_map, "open_flags") orelse 0; var buf = std.ArrayList(u8).empty; @@ -1072,6 +1118,7 @@ pub fn main(init: std.process.Init) !void { var mount_point: []const u8 = "/data"; var rpc_path: []const u8 = "/dev/virtio-ports/virtio-fs"; var binds_file: ?[]const u8 = null; + var map_created_ownership = false; var i: usize = 1; while (i < args.len) : (i += 1) { @@ -1090,6 +1137,10 @@ pub fn main(init: std.process.Init) !void { i += 1; continue; } + if (std.mem.eql(u8, args[i], "--map-created-ownership")) { + map_created_ownership = true; + continue; + } } const fuse_fd = try posix.open("/dev/fuse", .{ .ACCMODE = .RDWR, .CLOEXEC = true }, 0); @@ -1117,7 +1168,7 @@ pub fn main(init: std.process.Init) !void { try mountFuse(allocator, fuse_fd, mount_point); log.info("mounted sandboxfs at {s}", .{mount_point}); - var sandboxfs = SandboxFs.init(allocator, fuse_fd, rpc_client); + var sandboxfs = SandboxFs.init(allocator, fuse_fd, rpc_client, map_created_ownership); defer sandboxfs.deinit(); sandboxfs.run() catch |err| { diff --git a/host/bin/gondolin.ts b/host/bin/gondolin.ts index 3a595f76..f7eb5c1a 100644 --- a/host/bin/gondolin.ts +++ b/host/bin/gondolin.ts @@ -276,6 +276,9 @@ function bashUsage() { console.log( " --mount-memfs PATH Create memory-backed mount at path", ); + console.log( + " --map-created-ownership Report guest UID/GID for newly created entries", + ); console.log(); console.log("Network Options:"); console.log( @@ -341,6 +344,9 @@ function bashUsage() { console.log(); console.log("Examples:"); console.log(" gondolin bash --mount-hostfs /home/user/project:/workspace"); + console.log( + " gondolin bash --mount-hostfs $PWD:/workspace --map-created-ownership", + ); console.log( " gondolin bash --mount-hostfs /data:/data:ro --mount-memfs /tmp", ); @@ -419,6 +425,9 @@ function execUsage() { console.log( " --mount-memfs PATH Create memory-backed mount at path", ); + console.log( + " --map-created-ownership Report guest UID/GID for newly created entries", + ); console.log( " --image IMAGE Guest image selector (asset dir, build id, or name:tag)", ); @@ -493,6 +502,7 @@ type SshCredentialSpec = { type CommonOptions = { mounts: MountSpec[]; memoryMounts: string[]; + mapCreatedOwnership?: boolean; allowedHosts: string[]; secrets: SecretSpec[]; @@ -1074,7 +1084,15 @@ function buildVmOptions(common: CommonOptions) { : undefined; const vmOptions: any = { - vfs: Object.keys(mounts).length > 0 ? { mounts } : undefined, + vfs: + Object.keys(mounts).length > 0 || common.mapCreatedOwnership + ? { + ...(Object.keys(mounts).length > 0 ? { mounts } : {}), + ...(common.mapCreatedOwnership + ? { mapCreatedOwnership: true } + : {}), + } + : undefined, httpHooks, dns, ssh: @@ -1181,6 +1199,10 @@ function parseExecArgs(argv: string[]): ExecArgs { args.common.memoryMounts.push(path); return i; } + case "--map-created-ownership": { + args.common.mapCreatedOwnership = true; + return i; + } case "--image": { const image = optionArgs[++i]; if (!image) fail("--image requires an argument"); @@ -1670,6 +1692,10 @@ function parseBashArgs(argv: string[]): BashArgs { args.memoryMounts.push(path); break; } + case "--map-created-ownership": { + args.mapCreatedOwnership = true; + break; + } case "--image": { const image = argv[++i]; if (!image) { diff --git a/host/src/alpine/init-scripts.ts b/host/src/alpine/init-scripts.ts index eaab1d43..b8dd5dc2 100644 --- a/host/src/alpine/init-scripts.ts +++ b/host/src/alpine/init-scripts.ts @@ -269,6 +269,25 @@ if modprobe fuse > /dev/null 2>&1; then log "[init] loaded fuse" fi +sandboxfs_mount="/data" +sandboxfs_binds="" +sandboxfs_map_created_ownership="" + +if [ -r /proc/cmdline ]; then + for arg in $(cat /proc/cmdline); do + case "\${arg}" in + sandboxfs.mount=*) + sandboxfs_mount="\${arg#sandboxfs.mount=}" + ;; + sandboxfs.bind=*) + sandboxfs_binds="\${arg#sandboxfs.bind=}" + ;; + sandboxfs.map-created-ownership=*) + sandboxfs_map_created_ownership="\${arg#sandboxfs.map-created-ownership=}" + ;; + esac + done +fi wait_for_sandboxfs() { for i in $(seq 1 300); do if grep -q " \${sandboxfs_mount} fuse.sandboxfs " /proc/mounts; then @@ -296,7 +315,11 @@ if [ -x /usr/bin/sandboxfs ]; then log "[init] sandboxfs rpc path \${sandboxfs_rpc_path}" sandboxfs_binds_file="/run/sandboxfs.binds" rm -f "\${sandboxfs_binds_file}" - /usr/bin/sandboxfs --mount "\${sandboxfs_mount}" --rpc-path "\${sandboxfs_rpc_path}" --binds-file "\${sandboxfs_binds_file}" > "\${SANDBOXFS_LOG}" 2>&1 & + sandboxfs_ownership_arg="" + if [ "\${sandboxfs_map_created_ownership}" = "1" ]; then + sandboxfs_ownership_arg="--map-created-ownership" + fi + /usr/bin/sandboxfs --mount "\${sandboxfs_mount}" --rpc-path "\${sandboxfs_rpc_path}" --binds-file "\${sandboxfs_binds_file}" \${sandboxfs_ownership_arg} > "\${SANDBOXFS_LOG}" 2>&1 & if wait_for_sandboxfs; then sandboxfs_ready=1 diff --git a/host/src/build/shared.ts b/host/src/build/shared.ts index 2b4659b1..dfbbcc23 100644 --- a/host/src/build/shared.ts +++ b/host/src/build/shared.ts @@ -436,6 +436,12 @@ export async function resolveSandboxBinaryPaths( } } + if (envFlagEnabled(BUILD_SANDBOX_HELPERS_FROM_SOURCE_ENV)) { + const paths = await buildSandboxBinaryPathsFromSource(config.arch, log); + assertSandboxBinaryPathsExist(paths); + return paths; + } + if (options.skipBinaries) { const paths = resolveExistingGuestZigOutBinaryPaths(); assertSandboxBinaryPathsExist(paths); diff --git a/host/src/sandbox/control-protocol.ts b/host/src/sandbox/control-protocol.ts index daec2c93..d6660a81 100644 --- a/host/src/sandbox/control-protocol.ts +++ b/host/src/sandbox/control-protocol.ts @@ -11,7 +11,7 @@ * - pty_resize { type: "pty_resize", id, rows, cols } * - lifecycle { type: "lifecycle", action: "restart" | "shutdown" } * - snapshot { type: "snapshot", id, path } - * - boot { type: "boot", fuseMount?, fuseBinds? } + * - boot { type: "boot", fuseMount?, fuseBinds?, mapCreatedOwnership? } * * Server → Client: * - status { type: "status", state: "starting" | "running" | "stopped" } @@ -88,6 +88,8 @@ export type BootCommandMessage = { fuseMount?: string; /** guest paths to bind into the fuse mount */ fuseBinds?: string[]; + /** ownership view for entries created by guest processes */ + mapCreatedOwnership?: boolean; }; export type ExecWindowCommandMessage = { diff --git a/host/src/sandbox/server-boot-config.ts b/host/src/sandbox/server-boot-config.ts index 8805a065..648b279d 100644 --- a/host/src/sandbox/server-boot-config.ts +++ b/host/src/sandbox/server-boot-config.ts @@ -5,6 +5,7 @@ import type { BootCommandMessage } from "./control-protocol.ts"; export type SandboxFsConfig = { fuseMount: string; fuseBinds: string[]; + mapCreatedOwnership: boolean; }; export function normalizeSandboxFsConfig( @@ -15,9 +16,14 @@ export function normalizeSandboxFsConfig( "fuseMount", ); const fuseBinds = normalizeBindList(message.fuseBinds ?? []); + const mapCreatedOwnership = message.mapCreatedOwnership ?? false; + if (typeof mapCreatedOwnership !== "boolean") { + throw new Error("mapCreatedOwnership must be a boolean"); + } return { fuseMount, fuseBinds, + mapCreatedOwnership, }; } @@ -66,7 +72,7 @@ export function isSameSandboxFsConfig( for (let i = 0; i < left.fuseBinds.length; i += 1) { if (left.fuseBinds[i] !== right.fuseBinds[i]) return false; } - return true; + return left.mapCreatedOwnership === right.mapCreatedOwnership; } /** @@ -111,11 +117,16 @@ export function buildSandboxfsAppend( binds.push(bind); length += extra; } - - if (binds.length === 0) return base; - // parents have to be bound before their children - binds.sort(); - return `${base}${prefix}${binds.join(",")}`; + const pieces = [base]; + if (binds.length > 0) { + // parents have to be bound before their children + binds.sort(); + pieces.push(`${prefix}${binds.join(",")}`); + } + if (config.mapCreatedOwnership) { + pieces.push("sandboxfs.map-created-ownership=1"); + } + return joinCmdline(pieces); } /** Build the result of the sandboxfs `mounts` rpc op */ diff --git a/host/src/vm/core.ts b/host/src/vm/core.ts index 968202f2..45c65cd9 100644 --- a/host/src/vm/core.ts +++ b/host/src/vm/core.ts @@ -262,6 +262,7 @@ export class VM { private vfs: SandboxVfsProvider | null; private readonly fuseMount: string; private readonly fuseBinds: string[]; + private readonly mapCreatedOwnership: boolean; private readonly shortcutBindMounts: string[]; private bootSent = false; private vfsReadyPromise: Promise | null = null; @@ -413,6 +414,7 @@ export class VM { let fuseConfig = resolveFuseConfig(options.vfs, fuseMounts); this.fuseMount = fuseConfig.fuseMount; this.fuseBinds = fuseConfig.fuseBinds; + this.mapCreatedOwnership = options.vfs?.mapCreatedOwnership ?? false; const sandboxOptions: SandboxServerOptions = { ...options.sandbox }; if (sandboxOptions.vfsProvider && options.vfs) { @@ -1602,6 +1604,7 @@ fi type: "boot", fuseMount: this.fuseMount, fuseBinds: this.fuseBinds, + mapCreatedOwnership: this.mapCreatedOwnership, }); } diff --git a/host/src/vm/types.ts b/host/src/vm/types.ts index a0673231..9f5681d9 100644 --- a/host/src/vm/types.ts +++ b/host/src/vm/types.ts @@ -17,6 +17,8 @@ export type VmVfsOptions = { hooks?: VfsHooks; /** guest path for the fuse mount (default: "/data") */ fuseMount?: string; + /** report creating guest process UID/GID for newly created entries (default: false) */ + mapCreatedOwnership?: boolean; }; export type VmRootfsOptions = { diff --git a/host/test/server-boot-config.test.ts b/host/test/server-boot-config.test.ts new file mode 100644 index 00000000..f390c2a9 --- /dev/null +++ b/host/test/server-boot-config.test.ts @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + buildSandboxfsAppend, + isSameSandboxFsConfig, + normalizeSandboxFsConfig, +} from "../src/sandbox/server-boot-config.ts"; + +test("sandboxfs created-ownership mapping is opt-in in the boot config", () => { + const disabled = normalizeSandboxFsConfig({ type: "boot" }); + const enabled = normalizeSandboxFsConfig({ + type: "boot", + mapCreatedOwnership: true, + }); + + assert.equal(disabled.mapCreatedOwnership, false); + assert.equal(enabled.mapCreatedOwnership, true); + assert.equal(isSameSandboxFsConfig(disabled, enabled), false); + assert.equal( + buildSandboxfsAppend("quiet", enabled), + "quiet sandboxfs.mount=/data sandboxfs.map-created-ownership=1", + ); +}); + +test("sandboxfs created-ownership mapping rejects non-boolean values", () => { + assert.throws( + () => + normalizeSandboxFsConfig({ + type: "boot", + mapCreatedOwnership: "yes", + } as never), + /mapCreatedOwnership must be a boolean/, + ); +});