/* Proot Backend — Container runtime for Android and non-systemd Linux platforms. Uses proot (ptrace-based root emulation) for filesystem isolation, modeled after the ACE (Android Container Engine) runtime. No root required, no cgroups, no namespaces — runs containers in user-space via syscall interception. Key design decisions from ACE: - proot -r -0 -w / -k 5.15.0 -b /dev -b /proc -b /sys - Entrypoint auto-detection: /init → nginx → docker-entrypoint.sh → /bin/sh - Container state persisted as JSON files - Logs captured via redirected stdout/stderr - Port remapping via sed-based config modification (no iptables) */ package proot import ( "bufio" "encoding/json" "fmt" "io" "os" "os/exec" "path/filepath" "runtime" "strconv" "strings" "syscall" "time" "github.com/armoredgate/volt/pkg/backend" "gopkg.in/yaml.v3" ) // containerState represents the runtime state persisted to state.json. type containerState struct { Name string `json:"name"` Status string `json:"status"` // created, running, stopped PID int `json:"pid"` CreatedAt time.Time `json:"created_at"` StartedAt time.Time `json:"started_at,omitempty"` StoppedAt time.Time `json:"stopped_at,omitempty"` } // containerConfig represents the container configuration persisted to config.yaml. type containerConfig struct { Name string `yaml:"name"` Image string `yaml:"image,omitempty"` RootFS string `yaml:"rootfs"` Memory string `yaml:"memory,omitempty"` CPU int `yaml:"cpu,omitempty"` Env []string `yaml:"env,omitempty"` Ports []backend.PortMapping `yaml:"ports,omitempty"` Volumes []backend.VolumeMount `yaml:"volumes,omitempty"` Network string `yaml:"network,omitempty"` } func init() { backend.Register("proot", func() backend.ContainerBackend { return New() }) } // Backend implements backend.ContainerBackend using proot. type Backend struct { dataDir string prootPath string } // New creates a new proot backend instance. func New() *Backend { return &Backend{} } // ────────────────────────────────────────────────────────────────────────────── // Interface: Identity & Availability // ────────────────────────────────────────────────────────────────────────────── func (b *Backend) Name() string { return "proot" } // Available returns true if a usable proot binary can be found. func (b *Backend) Available() bool { return b.findProot() != "" } // findProot locates the proot binary, checking PATH first, then common // Android locations. func (b *Backend) findProot() string { // Already resolved if b.prootPath != "" { if _, err := os.Stat(b.prootPath); err == nil { return b.prootPath } } // Standard PATH lookup if p, err := exec.LookPath("proot"); err == nil { return p } // Android-specific locations androidPaths := []string{ "/data/local/tmp/proot", "/data/data/com.termux/files/usr/bin/proot", } // Also check app native lib dirs (ACE pattern) if home := os.Getenv("HOME"); home != "" { androidPaths = append(androidPaths, filepath.Join(home, "proot")) } for _, p := range androidPaths { if info, err := os.Stat(p); err == nil && !info.IsDir() { return p } } return "" } // ────────────────────────────────────────────────────────────────────────────── // Interface: Init // ────────────────────────────────────────────────────────────────────────────── // Init creates the backend directory structure and resolves the proot binary. func (b *Backend) Init(dataDir string) error { b.dataDir = dataDir b.prootPath = b.findProot() dirs := []string{ filepath.Join(dataDir, "containers"), filepath.Join(dataDir, "images"), filepath.Join(dataDir, "tmp"), } for _, d := range dirs { if err := os.MkdirAll(d, 0755); err != nil { return fmt.Errorf("proot init: failed to create %s: %w", d, err) } } // Set permissions on tmp directory (ACE pattern — proot needs a writable tmp) if err := os.Chmod(filepath.Join(dataDir, "tmp"), 0777); err != nil { return fmt.Errorf("proot init: failed to chmod tmp: %w", err) } return nil } // ────────────────────────────────────────────────────────────────────────────── // Interface: Create // ────────────────────────────────────────────────────────────────────────────── func (b *Backend) Create(opts backend.CreateOptions) error { cDir := b.containerDir(opts.Name) // Check for duplicates if _, err := os.Stat(cDir); err == nil { return fmt.Errorf("container %q already exists", opts.Name) } // Create directory structure subdirs := []string{ filepath.Join(cDir, "rootfs"), filepath.Join(cDir, "logs"), } for _, d := range subdirs { if err := os.MkdirAll(d, 0755); err != nil { return fmt.Errorf("create: mkdir %s: %w", d, err) } } rootfsDir := filepath.Join(cDir, "rootfs") // Populate rootfs if opts.RootFS != "" { // Use provided rootfs directory — symlink or copy srcInfo, err := os.Stat(opts.RootFS) if err != nil { return fmt.Errorf("create: rootfs path %q not found: %w", opts.RootFS, err) } if !srcInfo.IsDir() { return fmt.Errorf("create: rootfs path %q is not a directory", opts.RootFS) } // Copy the rootfs contents if err := copyDir(opts.RootFS, rootfsDir); err != nil { return fmt.Errorf("create: copy rootfs: %w", err) } } else if opts.Image != "" { // Check if image already exists as an extracted rootfs in images dir imagePath := b.resolveImage(opts.Image) if imagePath != "" { if err := copyDir(imagePath, rootfsDir); err != nil { return fmt.Errorf("create: copy image rootfs: %w", err) } } else { // Try debootstrap for base Debian/Ubuntu images if isDebootstrapImage(opts.Image) { if err := b.debootstrap(opts.Image, rootfsDir); err != nil { return fmt.Errorf("create: debootstrap failed: %w", err) } } else { // Create minimal rootfs structure for manual population for _, d := range []string{"bin", "etc", "home", "root", "tmp", "usr/bin", "usr/sbin", "var/log"} { os.MkdirAll(filepath.Join(rootfsDir, d), 0755) } } } } // Write config.yaml cfg := containerConfig{ Name: opts.Name, Image: opts.Image, RootFS: rootfsDir, Memory: opts.Memory, CPU: opts.CPU, Env: opts.Env, Ports: opts.Ports, Volumes: opts.Volumes, Network: opts.Network, } if err := b.writeConfig(opts.Name, &cfg); err != nil { // Clean up on failure os.RemoveAll(cDir) return fmt.Errorf("create: write config: %w", err) } // Write initial state.json state := containerState{ Name: opts.Name, Status: "created", PID: 0, CreatedAt: time.Now(), } if err := b.writeState(opts.Name, &state); err != nil { os.RemoveAll(cDir) return fmt.Errorf("create: write state: %w", err) } // Auto-start if requested if opts.Start { return b.Start(opts.Name) } return nil } // ────────────────────────────────────────────────────────────────────────────── // Interface: Start // ────────────────────────────────────────────────────────────────────────────── func (b *Backend) Start(name string) error { state, err := b.readState(name) if err != nil { return fmt.Errorf("start: %w", err) } if state.Status == "running" { // Check if the PID is actually alive if state.PID > 0 && processAlive(state.PID) { return fmt.Errorf("container %q is already running (pid %d)", name, state.PID) } // Stale state — process died, update and continue state.Status = "stopped" } if state.Status != "created" && state.Status != "stopped" { return fmt.Errorf("container %q is in state %q, cannot start", name, state.Status) } cfg, err := b.readConfig(name) if err != nil { return fmt.Errorf("start: %w", err) } if b.prootPath == "" { return fmt.Errorf("start: proot binary not found — install proot or set PATH") } rootfsDir := filepath.Join(b.containerDir(name), "rootfs") // Detect entrypoint (ACE priority order) entrypoint, entrypointArgs := b.detectEntrypoint(rootfsDir, cfg) // Build proot command arguments args := []string{ "-r", rootfsDir, "-0", // Fake root (uid 0 emulation) "-w", "/", // Working directory inside container "-k", "5.15.0", // Fake kernel version for compatibility "-b", "/dev", // Bind /dev "-b", "/proc", // Bind /proc "-b", "/sys", // Bind /sys "-b", "/dev/urandom:/dev/random", // Fix random device } // Add volume mounts as proot bind mounts for _, vol := range cfg.Volumes { bindArg := vol.HostPath + ":" + vol.ContainerPath args = append(args, "-b", bindArg) } // Add entrypoint args = append(args, entrypoint) args = append(args, entrypointArgs...) cmd := exec.Command(b.prootPath, args...) // Set container environment variables (ACE pattern) env := []string{ "HOME=/root", "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", "TERM=xterm", "CONTAINER_NAME=" + name, "PROOT_NO_SECCOMP=1", "PROOT_TMP_DIR=" + filepath.Join(b.dataDir, "tmp"), "TMPDIR=" + filepath.Join(b.dataDir, "tmp"), } // Add user-specified environment variables env = append(env, cfg.Env...) // Add port mapping info as environment variables for _, p := range cfg.Ports { env = append(env, fmt.Sprintf("PORT_%d=%d", p.ContainerPort, p.HostPort), ) } cmd.Env = env // Create a new session so the child doesn't get signals from our terminal cmd.SysProcAttr = &syscall.SysProcAttr{ Setsid: true, } // Redirect stdout/stderr to log file logDir := filepath.Join(b.containerDir(name), "logs") os.MkdirAll(logDir, 0755) logPath := filepath.Join(logDir, "current.log") logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) if err != nil { return fmt.Errorf("start: open log file: %w", err) } // Write startup header to log fmt.Fprintf(logFile, "[volt] Container %s starting at %s\n", name, time.Now().Format(time.RFC3339)) fmt.Fprintf(logFile, "[volt] proot=%s\n", b.prootPath) fmt.Fprintf(logFile, "[volt] rootfs=%s\n", rootfsDir) fmt.Fprintf(logFile, "[volt] entrypoint=%s %s\n", entrypoint, strings.Join(entrypointArgs, " ")) cmd.Stdout = logFile cmd.Stderr = logFile // Start the process if err := cmd.Start(); err != nil { logFile.Close() return fmt.Errorf("start: exec proot: %w", err) } // Close the log file handle in the parent — the child has its own fd logFile.Close() // Update state state.Status = "running" state.PID = cmd.Process.Pid state.StartedAt = time.Now() if err := b.writeState(name, state); err != nil { // Kill the process if we can't persist state cmd.Process.Signal(syscall.SIGKILL) return fmt.Errorf("start: write state: %w", err) } // Reap the child in a goroutine to avoid zombies go func() { cmd.Wait() // Process exited — update state to stopped if s, err := b.readState(name); err == nil && s.Status == "running" { s.Status = "stopped" s.PID = 0 s.StoppedAt = time.Now() b.writeState(name, s) } }() return nil } // detectEntrypoint determines what to run inside the container. // Follows ACE priority: /init → nginx → docker-entrypoint.sh → /bin/sh func (b *Backend) detectEntrypoint(rootfsDir string, cfg *containerConfig) (string, []string) { // Check for common entrypoints in the rootfs candidates := []struct { path string args []string }{ {"/init", nil}, {"/usr/sbin/nginx", []string{"-g", "daemon off; master_process off;"}}, {"/docker-entrypoint.sh", nil}, {"/usr/local/bin/python3", nil}, {"/usr/bin/python3", nil}, } for _, c := range candidates { fullPath := filepath.Join(rootfsDir, c.path) if info, err := os.Stat(fullPath); err == nil && !info.IsDir() { // For nginx with port mappings, rewrite the listen port via shell wrapper if c.path == "/usr/sbin/nginx" && len(cfg.Ports) > 0 { port := cfg.Ports[0].HostPort shellCmd := fmt.Sprintf( "sed -i 's/listen[[:space:]]*80;/listen %d;/g' /etc/nginx/conf.d/default.conf 2>/dev/null; "+ "sed -i 's/listen[[:space:]]*80;/listen %d;/g' /etc/nginx/nginx.conf 2>/dev/null; "+ "exec /usr/sbin/nginx -g 'daemon off; master_process off;'", port, port, ) return "/bin/sh", []string{"-c", shellCmd} } return c.path, c.args } } // Fallback: /bin/sh return "/bin/sh", nil } // ────────────────────────────────────────────────────────────────────────────── // Interface: Stop // ────────────────────────────────────────────────────────────────────────────── func (b *Backend) Stop(name string) error { state, err := b.readState(name) if err != nil { return fmt.Errorf("stop: %w", err) } if state.Status != "running" || state.PID <= 0 { // Already stopped — make sure state reflects it if state.Status == "running" { state.Status = "stopped" state.PID = 0 b.writeState(name, state) } return nil } proc, err := os.FindProcess(state.PID) if err != nil { // Process doesn't exist — clean up state state.Status = "stopped" state.PID = 0 state.StoppedAt = time.Now() return b.writeState(name, state) } // Send SIGTERM for graceful shutdown (ACE pattern) proc.Signal(syscall.SIGTERM) // Wait briefly for graceful exit done := make(chan struct{}) go func() { // Wait up to 5 seconds for the process to exit for i := 0; i < 50; i++ { if !processAlive(state.PID) { close(done) return } time.Sleep(100 * time.Millisecond) } close(done) }() <-done // If still running, force kill if processAlive(state.PID) { proc.Signal(syscall.SIGKILL) // Give it a moment to die time.Sleep(200 * time.Millisecond) } // Update state state.Status = "stopped" state.PID = 0 state.StoppedAt = time.Now() return b.writeState(name, state) } // ────────────────────────────────────────────────────────────────────────────── // Interface: Delete // ────────────────────────────────────────────────────────────────────────────── func (b *Backend) Delete(name string, force bool) error { state, err := b.readState(name) if err != nil { // If state can't be read but directory exists, allow force delete cDir := b.containerDir(name) if _, statErr := os.Stat(cDir); statErr != nil { return fmt.Errorf("container %q not found", name) } if !force { return fmt.Errorf("delete: cannot read state for %q (use --force): %w", name, err) } // Force remove the whole directory return os.RemoveAll(cDir) } if state.Status == "running" && state.PID > 0 && processAlive(state.PID) { if !force { return fmt.Errorf("container %q is running — stop it first or use --force", name) } // Force stop if err := b.Stop(name); err != nil { // If stop fails, try direct kill if proc, err := os.FindProcess(state.PID); err == nil { proc.Signal(syscall.SIGKILL) time.Sleep(200 * time.Millisecond) } } } // Remove entire container directory cDir := b.containerDir(name) if err := os.RemoveAll(cDir); err != nil { return fmt.Errorf("delete: remove %s: %w", cDir, err) } return nil } // ────────────────────────────────────────────────────────────────────────────── // Interface: Exec // ────────────────────────────────────────────────────────────────────────────── func (b *Backend) Exec(name string, opts backend.ExecOptions) error { state, err := b.readState(name) if err != nil { return fmt.Errorf("exec: %w", err) } if state.Status != "running" || state.PID <= 0 || !processAlive(state.PID) { return fmt.Errorf("container %q is not running", name) } if len(opts.Command) == 0 { opts.Command = []string{"/bin/sh"} } cfg, err := b.readConfig(name) if err != nil { return fmt.Errorf("exec: %w", err) } rootfsDir := filepath.Join(b.containerDir(name), "rootfs") // Build proot command for exec args := []string{ "-r", rootfsDir, "-0", "-w", "/", "-k", "5.15.0", "-b", "/dev", "-b", "/proc", "-b", "/sys", "-b", "/dev/urandom:/dev/random", } // Add volume mounts for _, vol := range cfg.Volumes { args = append(args, "-b", vol.HostPath+":"+vol.ContainerPath) } // Add the command args = append(args, opts.Command...) cmd := exec.Command(b.prootPath, args...) // Set container environment env := []string{ "HOME=/root", "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", "TERM=xterm", "CONTAINER_NAME=" + name, "PROOT_NO_SECCOMP=1", "PROOT_TMP_DIR=" + filepath.Join(b.dataDir, "tmp"), } env = append(env, cfg.Env...) env = append(env, opts.Env...) cmd.Env = env // Attach stdin/stdout/stderr for interactive use cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr return cmd.Run() } // ────────────────────────────────────────────────────────────────────────────── // Interface: Logs // ────────────────────────────────────────────────────────────────────────────── func (b *Backend) Logs(name string, opts backend.LogOptions) (string, error) { logPath := filepath.Join(b.containerDir(name), "logs", "current.log") data, err := os.ReadFile(logPath) if err != nil { if os.IsNotExist(err) { return "[No logs available]", nil } return "", fmt.Errorf("logs: read %s: %w", logPath, err) } content := string(data) if opts.Tail > 0 { lines := strings.Split(content, "\n") if len(lines) > opts.Tail { lines = lines[len(lines)-opts.Tail:] } return strings.Join(lines, "\n"), nil } return content, nil } // ────────────────────────────────────────────────────────────────────────────── // Interface: CopyToContainer / CopyFromContainer // ────────────────────────────────────────────────────────────────────────────── func (b *Backend) CopyToContainer(name string, src string, dst string) error { // Verify container exists cDir := b.containerDir(name) if _, err := os.Stat(cDir); err != nil { return fmt.Errorf("container %q not found", name) } // Destination is relative to rootfs dstPath := filepath.Join(cDir, "rootfs", dst) // Ensure parent directory exists if err := os.MkdirAll(filepath.Dir(dstPath), 0755); err != nil { return fmt.Errorf("copy-to: mkdir: %w", err) } return copyFile(src, dstPath) } func (b *Backend) CopyFromContainer(name string, src string, dst string) error { // Verify container exists cDir := b.containerDir(name) if _, err := os.Stat(cDir); err != nil { return fmt.Errorf("container %q not found", name) } // Source is relative to rootfs srcPath := filepath.Join(cDir, "rootfs", src) // Ensure parent directory of destination exists if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { return fmt.Errorf("copy-from: mkdir: %w", err) } return copyFile(srcPath, dst) } // ────────────────────────────────────────────────────────────────────────────── // Interface: List & Inspect // ────────────────────────────────────────────────────────────────────────────── func (b *Backend) List() ([]backend.ContainerInfo, error) { containersDir := filepath.Join(b.dataDir, "containers") entries, err := os.ReadDir(containersDir) if err != nil { if os.IsNotExist(err) { return nil, nil } return nil, fmt.Errorf("list: read containers dir: %w", err) } var result []backend.ContainerInfo for _, entry := range entries { if !entry.IsDir() { continue } name := entry.Name() info, err := b.Inspect(name) if err != nil { // Skip containers with broken state continue } result = append(result, *info) } return result, nil } func (b *Backend) Inspect(name string) (*backend.ContainerInfo, error) { state, err := b.readState(name) if err != nil { return nil, fmt.Errorf("inspect: %w", err) } cfg, err := b.readConfig(name) if err != nil { return nil, fmt.Errorf("inspect: %w", err) } // Reconcile state: if status says running, verify the PID is alive if state.Status == "running" && state.PID > 0 { if !processAlive(state.PID) { state.Status = "stopped" state.PID = 0 state.StoppedAt = time.Now() b.writeState(name, state) } } // Detect OS from rootfs os-release osName := detectOS(filepath.Join(b.containerDir(name), "rootfs")) info := &backend.ContainerInfo{ Name: name, Image: cfg.Image, Status: state.Status, PID: state.PID, RootFS: cfg.RootFS, Memory: cfg.Memory, CPU: cfg.CPU, CreatedAt: state.CreatedAt, StartedAt: state.StartedAt, IPAddress: "-", // proot shares host network OS: osName, } return info, nil } // ────────────────────────────────────────────────────────────────────────────── // Interface: Platform Capabilities // ────────────────────────────────────────────────────────────────────────────── func (b *Backend) SupportsVMs() bool { return false } func (b *Backend) SupportsServices() bool { return false } func (b *Backend) SupportsNetworking() bool { return true } // basic port forwarding func (b *Backend) SupportsTuning() bool { return false } // ────────────────────────────────────────────────────────────────────────────── // Internal: State & Config persistence // ────────────────────────────────────────────────────────────────────────────── func (b *Backend) containerDir(name string) string { return filepath.Join(b.dataDir, "containers", name) } func (b *Backend) readState(name string) (*containerState, error) { path := filepath.Join(b.containerDir(name), "state.json") data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("read state for %q: %w", name, err) } var state containerState if err := json.Unmarshal(data, &state); err != nil { return nil, fmt.Errorf("parse state for %q: %w", name, err) } return &state, nil } func (b *Backend) writeState(name string, state *containerState) error { path := filepath.Join(b.containerDir(name), "state.json") data, err := json.MarshalIndent(state, "", " ") if err != nil { return fmt.Errorf("marshal state for %q: %w", name, err) } return os.WriteFile(path, data, 0644) } func (b *Backend) readConfig(name string) (*containerConfig, error) { path := filepath.Join(b.containerDir(name), "config.yaml") data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("read config for %q: %w", name, err) } var cfg containerConfig if err := yaml.Unmarshal(data, &cfg); err != nil { return nil, fmt.Errorf("parse config for %q: %w", name, err) } return &cfg, nil } func (b *Backend) writeConfig(name string, cfg *containerConfig) error { path := filepath.Join(b.containerDir(name), "config.yaml") data, err := yaml.Marshal(cfg) if err != nil { return fmt.Errorf("marshal config for %q: %w", name, err) } return os.WriteFile(path, data, 0644) } // ────────────────────────────────────────────────────────────────────────────── // Internal: Image resolution // ────────────────────────────────────────────────────────────────────────────── // resolveImage checks if an image rootfs exists in the images directory. func (b *Backend) resolveImage(image string) string { imagesDir := filepath.Join(b.dataDir, "images") // Try exact name candidate := filepath.Join(imagesDir, image) if info, err := os.Stat(candidate); err == nil && info.IsDir() { return candidate } // Try normalized name (replace : with _) normalized := strings.ReplaceAll(image, ":", "_") normalized = strings.ReplaceAll(normalized, "/", "_") candidate = filepath.Join(imagesDir, normalized) if info, err := os.Stat(candidate); err == nil && info.IsDir() { return candidate } return "" } // isDebootstrapImage checks if the image name is a Debian/Ubuntu variant // that can be bootstrapped with debootstrap. func isDebootstrapImage(image string) bool { base := strings.Split(image, ":")[0] base = strings.Split(base, "/")[len(strings.Split(base, "/"))-1] debootstrapDistros := []string{ "debian", "ubuntu", "bookworm", "bullseye", "buster", "jammy", "focal", "noble", "mantic", } for _, d := range debootstrapDistros { if strings.EqualFold(base, d) { return true } } return false } // debootstrap creates a Debian/Ubuntu rootfs using debootstrap. func (b *Backend) debootstrap(image string, rootfsDir string) error { // Determine the suite (release codename) parts := strings.SplitN(image, ":", 2) base := parts[0] suite := "" if len(parts) == 2 { suite = parts[1] } // Map image names to suites if suite == "" { switch strings.ToLower(base) { case "debian": suite = "bookworm" case "ubuntu": suite = "noble" default: suite = strings.ToLower(base) } } // Check if debootstrap is available debootstrapPath, err := exec.LookPath("debootstrap") if err != nil { return fmt.Errorf("debootstrap not found in PATH — install debootstrap to create base images") } // Determine mirror based on distro mirror := "http://deb.debian.org/debian" if strings.EqualFold(base, "ubuntu") || isUbuntuSuite(suite) { mirror = "http://archive.ubuntu.com/ubuntu" } cmd := exec.Command(debootstrapPath, "--variant=minbase", suite, rootfsDir, mirror) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr return cmd.Run() } func isUbuntuSuite(suite string) bool { ubuntuSuites := []string{"jammy", "focal", "noble", "mantic", "lunar", "kinetic", "bionic", "xenial"} for _, s := range ubuntuSuites { if strings.EqualFold(suite, s) { return true } } return false } // ────────────────────────────────────────────────────────────────────────────── // Internal: Process & OS helpers // ────────────────────────────────────────────────────────────────────────────── // processAlive checks if a process with the given PID is still running. func processAlive(pid int) bool { if pid <= 0 { return false } if runtime.GOOS == "linux" || runtime.GOOS == "android" { // Check /proc/ — most reliable on Linux/Android _, err := os.Stat(filepath.Join("/proc", strconv.Itoa(pid))) return err == nil } // Fallback: signal 0 check proc, err := os.FindProcess(pid) if err != nil { return false } return proc.Signal(syscall.Signal(0)) == nil } // detectOS reads /etc/os-release from a rootfs and returns the PRETTY_NAME. func detectOS(rootfsDir string) string { osReleasePath := filepath.Join(rootfsDir, "etc", "os-release") f, err := os.Open(osReleasePath) if err != nil { return "-" } defer f.Close() scanner := bufio.NewScanner(f) for scanner.Scan() { line := scanner.Text() if strings.HasPrefix(line, "PRETTY_NAME=") { val := strings.TrimPrefix(line, "PRETTY_NAME=") return strings.Trim(val, "\"") } } return "-" } // ────────────────────────────────────────────────────────────────────────────── // Internal: File operations // ────────────────────────────────────────────────────────────────────────────── // copyFile copies a single file from src to dst, preserving permissions. func copyFile(src, dst string) error { srcFile, err := os.Open(src) if err != nil { return fmt.Errorf("open %s: %w", src, err) } defer srcFile.Close() srcInfo, err := srcFile.Stat() if err != nil { return fmt.Errorf("stat %s: %w", src, err) } dstFile, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, srcInfo.Mode()) if err != nil { return fmt.Errorf("create %s: %w", dst, err) } defer dstFile.Close() if _, err := io.Copy(dstFile, srcFile); err != nil { return fmt.Errorf("copy %s → %s: %w", src, dst, err) } return nil } // copyDir recursively copies a directory tree from src to dst using cp -a. // Uses the system cp command for reliability (preserves permissions, symlinks, // hard links, special files) — same approach as the systemd backend. func copyDir(src, dst string) error { // Ensure destination exists if err := os.MkdirAll(dst, 0755); err != nil { return fmt.Errorf("mkdir %s: %w", dst, err) } // Use cp -a for atomic, permission-preserving copy // The trailing /. copies contents into dst rather than creating src as a subdirectory cmd := exec.Command("cp", "-a", src+"/.", dst) out, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("cp -a %s → %s: %s: %w", src, dst, strings.TrimSpace(string(out)), err) } return nil }