// # Safety Interposer // // The platformtest binary is a multi-personality binary (busybox-style). // When invoked as "zfs" or "zpool" (via argv[0]), it acts as a safety // interposer that validates commands and delegates to the real binary // via sudo. // // SetupInterposerPath creates a temp directory with symlinks and replaces PATH: // - zfs/zpool → the platformtest binary itself (interposer mode) // - zfs.real/zpool.real → actual binaries (for delegation via sudo) // - sudo/bash → real binaries (so they remain accessible) // // When invoked as zfs or zpool, the binary validates that all commands // reference the test pool "zreplplatformtest" before delegating through // sudo. The validation rule is simple: the pool name must appear in the // command args. Special cases: // - Resume tokens ("zfs send -t "): decoded via the real binary // to extract and verify the toname field // - The -a (all) flag is blocked without an explicit pool reference // - Bare subcommands (feature detection) are allowed // - Mountpoint paths are validated against traversal on post-create chmod // // With -no-interposer, zfs/zpool symlink directly to the real binaries // (no sudo, no validation). Use this when running as root. package platformtest import ( "fmt" "os" "os/exec" "path/filepath" "strings" ) // SetupInterposerPath creates a temp directory with zfs/zpool symlinks // and replaces PATH so that all zfs/zpool invocations go through it. // // If `explicitDir` is non-empty, the PATH is constructed in that directory // instead of a temp directory, and the caller is responsible for cleanup. // // `direct` controls whether to set up the interposer (false) or to symlink // zfs/zpool directly to the real binaries (true, no interposer, no sudo). // // Returns the directory path and a cleanup function. func SetupInterposerPath(explicitDir string, direct bool) (string, func(), error) { // Resolve real binary paths while original PATH is still intact. lookupNames := []string{"zfs", "zpool", "bash"} if !direct { lookupNames = append(lookupNames, "sudo") } realBinaries := make(map[string]string) for _, name := range lookupNames { p, err := exec.LookPath(name) if err != nil { return "", nil, fmt.Errorf("find real %s binary: %w", name, err) } realBinaries[name] = p } var dir string var cleanup func() if explicitDir != "" { dir = explicitDir cleanup = func() {} // caller manages lifecycle } else { var err error dir, err = os.MkdirTemp("", "zrepl-platformtest-*") if err != nil { return "", nil, fmt.Errorf("create interposer dir: %w", err) } cleanup = func() { os.RemoveAll(dir) } } if direct { // Direct mode: symlink zfs/zpool to real binaries (no interposer). for _, name := range []string{"zfs", "zpool", "bash"} { link := filepath.Join(dir, name) os.Remove(link) if err := os.Symlink(realBinaries[name], link); err != nil { cleanup() return "", nil, fmt.Errorf("create symlink %s: %w", name, err) } } } else { // Interposer mode: symlink zfs/zpool to self, .real to actual binaries. self, err := os.Executable() if err != nil { cleanup() return "", nil, fmt.Errorf("get executable path: %w", err) } for _, name := range []string{"zfs", "zpool"} { link := filepath.Join(dir, name) os.Remove(link) if err := os.Symlink(self, link); err != nil { cleanup() return "", nil, fmt.Errorf("create symlink %s: %w", name, err) } realLink := filepath.Join(dir, name+".real") os.Remove(realLink) if err := os.Symlink(realBinaries[name], realLink); err != nil { cleanup() return "", nil, fmt.Errorf("create symlink %s.real: %w", name, err) } } // Symlink other required binaries so they remain accessible // after PATH replacement. for _, name := range []string{"sudo", "bash"} { link := filepath.Join(dir, name) os.Remove(link) if err := os.Symlink(realBinaries[name], link); err != nil { cleanup() return "", nil, fmt.Errorf("create symlink %s: %w", name, err) } } } // Replace PATH entirely so ALL zfs/zpool calls go through this directory. os.Setenv("PATH", dir) return dir, cleanup, nil } // RunInterposer handles the zfs/zpool interposer mode. // It validates that the command references the test pool, // then executes the real binary via sudo using the .real // symlink placed by SetupInterposerPath. func RunInterposer(command string) int { args := os.Args[1:] if err := validatePoolName(command, args, PlatformTestPoolName); err != nil { fmt.Fprintf(os.Stderr, "SAFETY: %v\n", err) return 1 } // The .real symlink in PATH points to the actual binary. // sudo follows the symlink, so no explicit resolution needed. realBinary := filepath.Join(os.Getenv("PATH"), command+".real") sudoArgs := append([]string{realBinary}, args...) cmd := exec.Command("sudo", sudoArgs...) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr err := cmd.Run() // After a successful create, chmod the mountpoint so the // unprivileged test user can operate on it. if err == nil && isCreateOperation(args) { if mountpoint := extractCreateMountpoint(command, args); mountpoint != "" { chmodMountpoint(mountpoint) } } if err != nil { if exitErr, ok := err.(*exec.ExitError); ok { return exitErr.ExitCode() } fmt.Fprintf(os.Stderr, "interposer: exec error: %v\n", err) return 1 } return 0 } func validatePoolName(command string, args []string, poolName string) error { if len(args) == 0 { return nil } // Check if any non-flag argument references the pool name. for _, arg := range args { if strings.HasPrefix(arg, "-") { continue } if arg == poolName || strings.HasPrefix(arg, poolName+"/") || strings.HasPrefix(arg, poolName+"@") || strings.HasPrefix(arg, poolName+"#") { return nil } } // Special case: "zfs send -t " (or -nvt, etc.) — the pool // name is encoded in the resume token, not visible in the args. // Decode the token via the real binary and verify toname. if command == "zfs" { if token := extractResumeToken(args); token != "" { return validateResumeTokenPool(token, poolName) } } // Block -a (all) flag without pool reference. Commands like // "zfs unmount -a" or "zpool export -a" affect all pools. for _, arg := range args { if !strings.HasPrefix(arg, "-") { continue } if arg == "-a" || (len(arg) > 2 && arg[0] == '-' && arg[1] != '-' && strings.ContainsRune(arg[1:], 'a')) { return fmt.Errorf("%s command rejected: -a flag not allowed without explicit pool reference", command) } } // If only one non-flag arg is present (the subcommand itself), // this is feature detection (e.g., bare "zfs send", "zfs receive"). nonFlagArgs := 0 for _, arg := range args { if !strings.HasPrefix(arg, "-") { nonFlagArgs++ } } if nonFlagArgs <= 1 { return nil } return fmt.Errorf("%s command rejected: args %v do not reference pool %q", command, args, poolName) } // extractResumeToken returns the resume token from "zfs send -t " // or combined forms like "zfs send -nvt ". Returns "" if not found. func extractResumeToken(args []string) string { if len(args) == 0 || args[0] != "send" { return "" } for i, arg := range args { if !strings.HasPrefix(arg, "-") { continue } // -t standalone, or combined flag containing t (e.g., -nvt) if arg == "-t" || (len(arg) > 2 && arg[0] == '-' && arg[1] != '-' && strings.ContainsRune(arg[1:], 't')) { if i+1 < len(args) { return args[i+1] } } } return "" } // validateResumeTokenPool decodes a resume token by calling the real zfs // binary directly (bypassing the interposer) and checks that the toname // field references the expected pool. func validateResumeTokenPool(token, poolName string) error { realBinary := filepath.Join(os.Getenv("PATH"), "zfs.real") cmd := exec.Command("sudo", realBinary, "send", "-nvt", token) output, err := cmd.CombinedOutput() if err != nil { // zfs send -nvt may exit non-zero if the dataset no longer exists, // but it still prints the token contents. Only fail on exec errors. if _, ok := err.(*exec.ExitError); !ok { return fmt.Errorf("decode resume token: %w", err) } } for _, line := range strings.Split(string(output), "\n") { line = strings.TrimSpace(line) if strings.HasPrefix(line, "toname = ") { toname := strings.TrimPrefix(line, "toname = ") if toname == poolName || strings.HasPrefix(toname, poolName+"/") || strings.HasPrefix(toname, poolName+"@") { return nil } return fmt.Errorf("resume token rejected: toname %q does not reference pool %q", toname, poolName) } } return fmt.Errorf("resume token rejected: could not extract toname from token") } func isCreateOperation(args []string) bool { return len(args) > 0 && args[0] == "create" } func extractCreateMountpoint(command string, args []string) string { switch command { case "zpool": // zpool create ... -O mountpoint= ... for i, arg := range args { if arg == "-O" && i+1 < len(args) { if strings.HasPrefix(args[i+1], "mountpoint=") { return strings.TrimPrefix(args[i+1], "mountpoint=") } } } case "zfs": // zfs create [-p] [-o ...] // Dataset is the last non-flag argument. Its mountpoint is // derived from the pool mountpoint + dataset path suffix. dataset := "" skipNext := false for _, arg := range args[1:] { // skip "create" if skipNext { skipNext = false continue } if arg == "-o" { skipNext = true continue } if strings.HasPrefix(arg, "-") { continue } dataset = arg } if dataset != "" && strings.HasPrefix(dataset, PlatformTestPoolName+"/") { suffix := strings.TrimPrefix(dataset, PlatformTestPoolName) return PlatformTestMountpoint + suffix } } return "" } func chmodMountpoint(mountpoint string) { // Validate: only chmod paths under the test mountpoint to prevent // path traversal (e.g., dataset "pool/../../etc" -> mountpoint "/etc"). cleaned := filepath.Clean(mountpoint) if cleaned != PlatformTestMountpoint && !strings.HasPrefix(cleaned, PlatformTestMountpoint+"/") { fmt.Fprintf(os.Stderr, "interposer: refusing to chmod %q (not under %s)\n", mountpoint, PlatformTestMountpoint) return } // Best-effort: make mountpoint accessible to unprivileged user. exec.Command("sudo", "chmod", "0777", cleaned).Run() //nolint:errcheck }