daemon: rewrite git identity sync + file_sync on ext4 toolkit

ensureGitIdentityOnWorkDisk, writeGitIdentity, runFileSync, and
copyHostDir all dropped their mount + sudo install/mkdir/chmod/chown
scaffolding. Every write now goes through MkdirExt4,
WriteExt4FileOwned, ReadExt4File, and the new MkdirAllExt4 helper —
all sudoless against user-owned ext4 images.

Net effect with the prior two commits: ensureWorkDisk, authsync, image
seeding, git identity sync, and file_sync no longer mount the work
disk or spawn sudo mkdir/chmod/chown/cat/install. Only the
image-build path (which legitimately produces root-owned artifacts)
still touches MountTempDir.

The filesystemRunner test harness grew a small debugfs/e2cp/e2rm
emulator so the WorkspaceService tests keep exercising their real
code paths without a live ext4 image. The mock is deliberately
dumb — it only implements the subset runFileSync and writeGitIdentity
drive.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Thales Maciel 2026-04-23 18:29:30 -03:00
parent f0685366ec
commit 6ab1a2b844
No known key found for this signature in database
GPG key ID: 33112E6833C34679
3 changed files with 253 additions and 74 deletions

View file

@ -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>` (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) {

View file

@ -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 "<cmd>" <image> (read-only)
// debugfs -w -R "<cmd>" <image> (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 <path-or-<2>> <field> <value>
// 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 <src> <dst>
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.