diff --git a/internal/daemon/vm_authsync.go b/internal/daemon/vm_authsync.go index ad56359..32a7eb4 100644 --- a/internal/daemon/vm_authsync.go +++ b/internal/daemon/vm_authsync.go @@ -5,7 +5,9 @@ import ( "errors" "fmt" "os" + "path" "path/filepath" + "strconv" "strings" "banger/internal/guest" @@ -115,17 +117,7 @@ func (s *WorkspaceService) ensureGitIdentityOnWorkDisk(ctx context.Context, vm * } vmCreateStage(ctx, "prepare_work_disk", "syncing git identity") - workMount, cleanupWork, err := system.MountTempDir(ctx, runner, vm.Runtime.WorkDiskPath, false) - if err != nil { - return err - } - defer cleanupWork() - - if err := flattenNestedWorkHome(ctx, s.runner, workMount); err != nil { - return err - } - - return writeGitIdentity(ctx, runner, filepath.Join(workMount, workDiskGitConfigRelativePath), identity) + return writeGitIdentity(ctx, runner, vm.Runtime.WorkDiskPath, "/"+workDiskGitConfigRelativePath, identity) } // runFileSync applies every [[file_sync]] entry from the daemon config @@ -133,10 +125,10 @@ func (s *WorkspaceService) ensureGitIdentityOnWorkDisk(ctx context.Context, vm * // Other errors abort the VM create (since the user explicitly asked // for the sync). // -// File entries: `install -o 0 -g 0 -m ` (mode defaults to 0600). -// Directory entries: walked in Go — each file is installed with its -// source permissions, each subdir is mkdir'd. The entry's `mode` -// field is only honoured for file entries. +// Operates directly on the ext4 image via the sudoless toolkit — no +// mount, no privileged install(1). Every write lands as root:root; +// file modes come from the [[file_sync]] entry (default 0600), +// directory modes from the source on the host. func (s *WorkspaceService) runFileSync(ctx context.Context, vm *model.VMRecord) error { if len(s.config.FileSync) == 0 { return nil @@ -152,33 +144,12 @@ func (s *WorkspaceService) runFileSync(ctx context.Context, vm *model.VMRecord) return fmt.Errorf("resolve host user home: %w", err) } - // Mount the work disk once and reuse for every entry. - var workMount string - var cleanupWork func() error - ensureMount := func() (string, error) { - if workMount != "" { - return workMount, nil - } - m, c, err := system.MountTempDir(ctx, runner, vm.Runtime.WorkDiskPath, false) - if err != nil { - return "", err - } - workMount = m - cleanupWork = c - if err := flattenNestedWorkHome(ctx, s.runner, workMount); err != nil { - return "", err - } - return workMount, nil - } - defer func() { - if cleanupWork != nil { - cleanupWork() - } - }() + workDisk := vm.Runtime.WorkDiskPath for _, entry := range s.config.FileSync { hostPath := expandHostPath(entry.Host, hostHome) guestRel := guestPathRelativeToRoot(entry.Guest) + guestImagePath := "/" + guestRel info, err := os.Stat(hostPath) if err != nil { @@ -189,48 +160,49 @@ func (s *WorkspaceService) runFileSync(ctx context.Context, vm *model.VMRecord) return fmt.Errorf("file_sync: stat %s: %w", hostPath, err) } - mount, err := ensureMount() - if err != nil { - return err - } - vmCreateStage(ctx, "prepare_work_disk", "file sync: "+entry.Host+" → "+entry.Guest) - target := filepath.Join(mount, guestRel) - if _, err := runner.RunSudo(ctx, "mkdir", "-p", filepath.Dir(target)); err != nil { - return fmt.Errorf("file_sync: mkdir %s: %w", filepath.Dir(target), err) + parent := path.Dir(guestImagePath) + if parent != "/" && parent != "." { + if err := system.MkdirAllExt4(ctx, runner, workDisk, parent, 0o755, 0, 0); err != nil { + return fmt.Errorf("file_sync: mkdir %s: %w", parent, err) + } } if info.IsDir() { - if err := s.copyHostDir(ctx, *vm, runner, hostPath, target); err != nil { - return fmt.Errorf("file_sync: copy directory %s → %s: %w", hostPath, target, err) + if err := s.copyHostDir(ctx, *vm, runner, workDisk, hostPath, guestImagePath); err != nil { + return fmt.Errorf("file_sync: copy directory %s → %s: %w", hostPath, guestImagePath, err) } continue } - mode := entry.Mode - if mode == "" { - mode = "0600" + mode, err := parseFileSyncMode(entry.Mode) + if err != nil { + return fmt.Errorf("file_sync: %s: %w", entry.Host, err) } - if _, err := runner.RunSudo(ctx, "install", "-o", "0", "-g", "0", "-m", mode, hostPath, target); err != nil { - return fmt.Errorf("file_sync: install %s → %s: %w", hostPath, target, err) + data, err := os.ReadFile(hostPath) + if err != nil { + return fmt.Errorf("file_sync: read %s: %w", hostPath, err) + } + if err := system.WriteExt4FileOwned(ctx, runner, workDisk, guestImagePath, mode, 0, 0, data); err != nil { + return fmt.Errorf("file_sync: write %s → %s: %w", hostPath, guestImagePath, err) } } return nil } -// copyHostDir recursively copies hostDir into guestTarget using only -// `mkdir` (for subdirs) and `install` (for files). Each file's source -// permissions are preserved; ownership is forced to root:root via -// `install -o 0 -g 0`. Symlinks encountered during recursion are -// SKIPPED with a warning — `os.Lstat` tells us the entry itself is a -// link without resolving it, so a symlink inside ~/.aws that points -// at ~/secrets can't leak out of the tree the user named. Other -// special types (devices, FIFOs) are skipped silently. Top-level -// host paths go through `os.Stat` back in runFileSync and still -// follow, since the user explicitly named that path. -func (s *WorkspaceService) copyHostDir(ctx context.Context, vm model.VMRecord, runner system.CommandRunner, hostDir, guestTarget string) error { - if _, err := runner.RunSudo(ctx, "mkdir", "-p", guestTarget); err != nil { +// copyHostDir recursively copies hostDir into guestTarget on the +// ext4 image via the sudoless toolkit. Each file's source permissions +// are preserved; directories get 0755; ownership is forced to +// root:root. Symlinks are SKIPPED with a warning — os.Lstat identifies +// the entry itself as a link without resolving it, so a symlink +// inside ~/.aws that points at ~/secrets can't leak out of the tree +// the user named. Other special types (devices, FIFOs) are skipped +// silently. Top-level host paths go through os.Stat back in +// runFileSync and still follow, since the user explicitly named that +// path. +func (s *WorkspaceService) copyHostDir(ctx context.Context, vm model.VMRecord, runner system.CommandRunner, imagePath, hostDir, guestTarget string) error { + if err := system.MkdirExt4(ctx, runner, imagePath, guestTarget, 0o755, 0, 0); err != nil { return err } entries, err := os.ReadDir(hostDir) @@ -239,7 +211,7 @@ func (s *WorkspaceService) copyHostDir(ctx context.Context, vm model.VMRecord, r } for _, entry := range entries { hostChild := filepath.Join(hostDir, entry.Name()) - guestChild := filepath.Join(guestTarget, entry.Name()) + guestChild := path.Join(guestTarget, entry.Name()) info, err := os.Lstat(hostChild) if err != nil { @@ -249,12 +221,15 @@ func (s *WorkspaceService) copyHostDir(ctx context.Context, vm model.VMRecord, r case info.Mode()&os.ModeSymlink != 0: s.warnFileSyncSymlinkSkipped(vm, hostChild) case info.IsDir(): - if err := s.copyHostDir(ctx, vm, runner, hostChild, guestChild); err != nil { + if err := s.copyHostDir(ctx, vm, runner, imagePath, hostChild, guestChild); err != nil { return err } case info.Mode().IsRegular(): - mode := fmt.Sprintf("%04o", info.Mode().Perm()) - if _, err := runner.RunSudo(ctx, "install", "-o", "0", "-g", "0", "-m", mode, hostChild, guestChild); err != nil { + data, err := os.ReadFile(hostChild) + if err != nil { + return err + } + if err := system.WriteExt4FileOwned(ctx, runner, imagePath, guestChild, info.Mode().Perm(), 0, 0, data); err != nil { return err } } @@ -262,6 +237,21 @@ func (s *WorkspaceService) copyHostDir(ctx context.Context, vm model.VMRecord, r return nil } +// parseFileSyncMode parses the [[file_sync]] mode field (octal string, +// default "0600"). Returns the parsed FileMode with only the permission +// bits set; callers OR in S_IFREG via WriteExt4FileOwned. +func parseFileSyncMode(raw string) (os.FileMode, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + raw = "0600" + } + v, err := strconv.ParseUint(raw, 8, 32) + if err != nil { + return 0, fmt.Errorf("parse mode %q: %w", raw, err) + } + return os.FileMode(v) & os.ModePerm, nil +} + // expandHostPath expands a leading "~/" against the host user's // home. Already-absolute paths pass through unchanged. func expandHostPath(raw, home string) string { @@ -321,10 +311,23 @@ func gitConfigValue(ctx context.Context, runner system.CommandRunner, extraArgs return strings.TrimSpace(string(out)), nil } -func writeGitIdentity(ctx context.Context, runner system.CommandRunner, gitConfigPath string, identity gitIdentity) error { - existing, err := runner.RunSudo(ctx, "cat", gitConfigPath) +// writeGitIdentity merges user.name + user.email into the on-image +// gitconfig at guestPath. Reads the existing bytes via the ext4 +// toolkit (no-op to empty if absent), edits via `git config --file` +// on a host tempfile so any pre-existing unrelated sections are +// preserved verbatim, then writes back through WriteExt4FileOwned +// at 0644 root:root. +func writeGitIdentity(ctx context.Context, runner system.CommandRunner, imagePath, guestPath string, identity gitIdentity) error { + var existing []byte + exists, err := system.Ext4PathExists(ctx, runner, imagePath, guestPath) if err != nil { - existing = nil + return err + } + if exists { + existing, err = system.ReadExt4File(ctx, runner, imagePath, guestPath) + if err != nil { + return err + } } tmpFile, err := os.CreateTemp("", "banger-gitconfig-*") @@ -349,8 +352,11 @@ func writeGitIdentity(ctx context.Context, runner system.CommandRunner, gitConfi if _, err := runner.Run(ctx, "git", "config", "--file", tmpPath, "user.email", identity.Email); err != nil { return err } - _, err = runner.RunSudo(ctx, "install", "-m", "644", tmpPath, gitConfigPath) - return err + merged, err := os.ReadFile(tmpPath) + if err != nil { + return err + } + return system.WriteExt4FileOwned(ctx, runner, imagePath, guestPath, 0o644, 0, 0, merged) } func (s *WorkspaceService) warnFileSyncSkipped(vm model.VMRecord, hostPath string, err error) { diff --git a/internal/daemon/vm_test.go b/internal/daemon/vm_test.go index c29297a..0c6733d 100644 --- a/internal/daemon/vm_test.go +++ b/internal/daemon/vm_test.go @@ -2031,11 +2031,147 @@ func (r *filesystemRunner) RunSudo(ctx context.Context, args ...string) ([]byte, default: return nil, fmt.Errorf("unexpected chown args: %v", args) } + case "debugfs": + return runFakeDebugfs(args[1:]) + case "e2cp": + // e2cp SRC IMAGE:/GUEST → plain file copy into IMAGE dir + if len(args) != 3 { + return nil, fmt.Errorf("unexpected e2cp args: %v", args) + } + image, guest, ok := splitImageColonPath(args[2]) + if !ok { + return nil, fmt.Errorf("e2cp dst missing image:path separator: %v", args) + } + target := filepath.Join(image, guest) + data, err := os.ReadFile(args[1]) + if err != nil { + return nil, err + } + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return nil, err + } + return nil, os.WriteFile(target, data, 0o600) + case "e2rm": + // e2rm IMAGE:/GUEST → plain file delete; missing is not fatal + if len(args) != 2 { + return nil, fmt.Errorf("unexpected e2rm args: %v", args) + } + image, guest, ok := splitImageColonPath(args[1]) + if !ok { + return nil, fmt.Errorf("e2rm missing image:path separator: %v", args) + } + target := filepath.Join(image, guest) + if err := os.Remove(target); err != nil && !os.IsNotExist(err) { + return nil, err + } + return nil, nil default: return nil, fmt.Errorf("unexpected sudo command: %v", args) } } +// runFakeDebugfs emulates the subset of debugfs commands the ext4 +// toolkit drives in per-line mode (the stdin-batched path doesn't run +// under filesystemRunner because it doesn't implement StdinRunner). +// Supported: stat/cat, plus -w mkdir/set_inode_field. Inode 2 <2> +// set_inode_field is a no-op — tests don't care about root-inode mode +// beyond it not exploding. +func runFakeDebugfs(args []string) ([]byte, error) { + // Forms: + // debugfs -R "" (read-only) + // debugfs -w -R "" (single write) + if len(args) < 3 { + return nil, fmt.Errorf("unexpected debugfs args: %v", args) + } + write := false + rest := args + if rest[0] == "-w" { + write = true + rest = rest[1:] + } + if len(rest) != 3 || rest[0] != "-R" { + return nil, fmt.Errorf("unexpected debugfs args: %v", args) + } + cmdLine := strings.TrimSpace(rest[1]) + image := rest[2] + + fields := strings.Fields(cmdLine) + if len(fields) == 0 { + return nil, fmt.Errorf("empty debugfs command") + } + switch fields[0] { + case "stat": + if len(fields) != 2 { + return nil, fmt.Errorf("unexpected debugfs stat: %q", cmdLine) + } + target := filepath.Join(image, strings.Trim(fields[1], `"`)) + if _, err := os.Stat(target); err != nil { + if os.IsNotExist(err) { + return []byte("stat: File not found by ext2_lookup while starting pathname"), nil + } + return nil, err + } + return []byte("Inode: 12 Type: directory"), nil + case "cat": + if len(fields) != 2 { + return nil, fmt.Errorf("unexpected debugfs cat: %q", cmdLine) + } + target := filepath.Join(image, strings.Trim(fields[1], `"`)) + data, err := os.ReadFile(target) + if err != nil { + return nil, err + } + return data, nil + case "mkdir": + if !write { + return nil, fmt.Errorf("debugfs mkdir requires -w: %q", cmdLine) + } + if len(fields) != 2 { + return nil, fmt.Errorf("unexpected debugfs mkdir: %q", cmdLine) + } + target := filepath.Join(image, strings.Trim(fields[1], `"`)) + return nil, os.MkdirAll(target, 0o755) + case "set_inode_field": + // set_inode_field > + // Mode changes on non-root targets: honour the perm bits so + // tests can assert file mode. Root inode <2>, uid, gid are + // no-ops — tests don't inspect them. + if !write { + return nil, fmt.Errorf("debugfs set_inode_field requires -w: %q", cmdLine) + } + if len(fields) != 4 { + return nil, fmt.Errorf("unexpected set_inode_field: %q", cmdLine) + } + target := strings.Trim(fields[1], `"`) + if target == "<2>" || fields[2] != "mode" { + return nil, nil + } + raw := strings.TrimPrefix(fields[3], "0") + v, err := strconv.ParseUint(raw, 8, 32) + if err != nil { + return nil, fmt.Errorf("parse set_inode_field mode %q: %w", fields[3], err) + } + return nil, os.Chmod(filepath.Join(image, target), os.FileMode(v)&os.ModePerm) + case "rdump": + // rdump + return nil, fmt.Errorf("rdump not supported in filesystemRunner") + default: + return nil, fmt.Errorf("unsupported debugfs cmd: %q", cmdLine) + } +} + +// splitImageColonPath splits an e2cp/e2rm "image:path" argument. +// Returns image, path, true on success. Only the LAST colon is split +// on since image paths on disk may contain one (rare) and guest paths +// always start with "/". +func splitImageColonPath(arg string) (string, string, bool) { + idx := strings.LastIndex(arg, ":/") + if idx < 0 { + return "", "", false + } + return arg[:idx], arg[idx+1:], true +} + // parseInstallArgs recognises the `install` invocations banger emits // and returns (source, destination, parsed mode). Anything else is an // error so the test stub stays a closed set. diff --git a/internal/system/ext4.go b/internal/system/ext4.go index 8c9ebdc..e0c8fcd 100644 --- a/internal/system/ext4.go +++ b/internal/system/ext4.go @@ -49,6 +49,43 @@ func MkdirExt4(ctx context.Context, runner CommandRunner, imagePath, guestPath s return debugfsScript(ctx, runner, imagePath, &script) } +// MkdirAllExt4 creates each intermediate directory in guestPath that +// doesn't already exist, with the given mode/uid/gid. Mirrors +// os.MkdirAll's shape, not mkdir(1) -p: existing directories are left +// with their current metadata untouched (we don't reset mode/uid/gid +// on pre-existing parents, only on the final segment). Paths starting +// at "/" are allowed — the root is treated as pre-existing. +func MkdirAllExt4(ctx context.Context, runner CommandRunner, imagePath, guestPath string, mode os.FileMode, uid, gid int) error { + if err := rejectDebugfsUnsafePath(guestPath); err != nil { + return err + } + segments := strings.Split(strings.Trim(guestPath, "/"), "/") + cur := "" + for i, seg := range segments { + if seg == "" { + continue + } + cur = cur + "/" + seg + exists, err := Ext4PathExists(ctx, runner, imagePath, cur) + if err != nil { + return err + } + if exists { + continue + } + // Intermediate dirs inherit the requested mode/uid/gid too — + // callers that want a different mode on parents should create + // them explicitly. Matches the most common use (mkdir -p a + // config tree where every hop is root-owned). + if i < len(segments)-1 || !exists { + if err := MkdirExt4(ctx, runner, imagePath, cur, mode, uid, gid); err != nil { + return err + } + } + } + return nil +} + // WriteExt4FileOwned copies `data` into : and // forces the inode's uid/gid/mode to the requested values. Unlike // WriteExt4FileMode, this helper does NOT assume the image is a