diff --git a/cmd/shuttle/main.go b/cmd/shuttle/main.go index a10849e..f89dc3f 100644 --- a/cmd/shuttle/main.go +++ b/cmd/shuttle/main.go @@ -375,7 +375,7 @@ func executeRun(ctx context.Context, cli cliFlags) error { logger.Info(fmt.Sprintf("pruned %d old log file(s)", pruneDeleted)) } - promptForPassword(logger) + rclonePass := resolveRclonePassword(logger) // In quiet mode, the per-job spinner and status lines are suppressed so // the terminal stays silent unless something fails. @@ -387,7 +387,15 @@ func executeRun(ctx context.Context, cli cliFlags) error { } pw := engine.NewProgressWriter(progressOut, progressInteractive, useColor) - runner := engine.NewRunner(cfg, configPath, logger, pw, opts.DryRun, logPath) + runner := engine.NewRunner(engine.RunnerConfig{ + Cfg: cfg, + ConfigPath: configPath, + Logger: logger, + Progress: pw, + DryRun: opts.DryRun, + LogFile: logPath, + RclonePassword: rclonePass, + }) summary, err := runner.Run(ctx, opts) if err != nil { return err @@ -430,24 +438,27 @@ func validateRemoteNames(selected, configured []string) error { return nil } -// promptForPassword checks for RCLONE_CONFIG_PASS and prompts interactively -// when it is absent and stdin is a TTY. The password is set in the environment -// for rclone to pick up automatically. -func promptForPassword(logger *log.Logger) { +// resolveRclonePassword returns the rclone config password to inject into +// rclone child processes, or "" when none should be injected. It returns "" +// when RCLONE_CONFIG_PASS is already in the environment (rclone inherits it), +// when stdin is not a TTY, or when the prompt is empty/aborted. Unlike the +// previous implementation it does not mutate the process environment, so the +// prompted secret never reaches sibling children such as rsync. +func resolveRclonePassword(logger *log.Logger) string { if os.Getenv("RCLONE_CONFIG_PASS") != "" { - return + return "" } if !term.IsTerminal(int(os.Stdin.Fd())) { logger.Warn("RCLONE_CONFIG_PASS not set and stdin is not a terminal.") - return + return "" } fmt.Print("Enter rclone config password (or press Enter if none): ") pass, err := term.ReadPassword(int(os.Stdin.Fd())) fmt.Println() if err != nil || len(pass) == 0 { - return + return "" } - os.Setenv("RCLONE_CONFIG_PASS", string(pass)) //nolint:errcheck // setting env cannot fail in practice + return string(pass) } // logDirectory returns the path for log files, respecting XDG_STATE_HOME. diff --git a/cmd/shuttle/main_test.go b/cmd/shuttle/main_test.go index 24d2447..0c0f7d8 100644 --- a/cmd/shuttle/main_test.go +++ b/cmd/shuttle/main_test.go @@ -861,3 +861,19 @@ optional = true t.Errorf("stdout should not mention 'failed' when only optional-missing:\n%s", res.stdout) } } + +func TestResolveRclonePassword_EnvPreset_ReturnsEmpty(t *testing.T) { + t.Setenv("RCLONE_CONFIG_PASS", "already-set") + logger, err := log.NewWithWriter(&bytes.Buffer{}, filepath.Join(t.TempDir(), "t.log"), false, log.VerbosityNormal) + if err != nil { + t.Fatalf("logger: %v", err) + } + defer logger.Close() + + if got := resolveRclonePassword(logger); got != "" { + t.Errorf("resolveRclonePassword() = %q, want \"\" when env is preset", got) + } + if v := os.Getenv("RCLONE_CONFIG_PASS"); v != "already-set" { + t.Errorf("RCLONE_CONFIG_PASS = %q, want unchanged 'already-set'", v) + } +} diff --git a/internal/engine/progress.go b/internal/engine/progress.go index 44a6c55..7505b19 100644 --- a/internal/engine/progress.go +++ b/internal/engine/progress.go @@ -4,14 +4,29 @@ import ( "context" "fmt" "io" + "strings" "sync" "time" + "unicode" ) const spinnerInterval = 80 * time.Millisecond var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} +// sanitizeProgress removes control characters (C0, C1, DEL) from progress text +// so attacker-influenceable bytes from tool output (e.g. a filename with +// embedded ANSI/OSC escapes) cannot drive the terminal. Shuttle's own color +// codes are added after sanitization in renderSpinner, so they are unaffected. +func sanitizeProgress(s string) string { + return strings.Map(func(r rune) rune { + if unicode.IsControl(r) { + return -1 + } + return r + }, s) +} + // ProgressWriter manages the live terminal display during job execution. // In interactive mode, it shows a spinner on the active job line and replaces // it with a compact status line when the job finishes. In non-interactive mode @@ -81,13 +96,15 @@ func (pw *ProgressWriter) StartJob(ctx context.Context, label string) { // UpdateProgress sets the progress text displayed beside the spinner. // Replaces the elapsed-time default when non-empty. +// Control characters (C0, C1, DEL) are stripped before storage so that +// attacker-influenceable filenames in tool output cannot inject terminal escapes. // In non-interactive mode, this is a no-op. func (pw *ProgressWriter) UpdateProgress(text string) { if !pw.interactive { return } pw.mu.Lock() - pw.currentProgress = text + pw.currentProgress = sanitizeProgress(text) pw.mu.Unlock() } diff --git a/internal/engine/progress_test.go b/internal/engine/progress_test.go index 8533bb1..bf9faff 100644 --- a/internal/engine/progress_test.go +++ b/internal/engine/progress_test.go @@ -175,3 +175,36 @@ func TestProgressWriter_Interactive_MultipleJobs(t *testing.T) { t.Error("missing failure status") } } + +func TestSanitizeProgress(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {"plain stats unchanged", "604 KiB / 1 MiB, 59%, 206 KiB/s, ETA 2s", "604 KiB / 1 MiB, 59%, 206 KiB/s, ETA 2s"}, + {"bare ESC stripped", "evil\x1bxstats", "evilxstats"}, + {"CSI clear-screen stripped", "a\x1b[2Jb", "a[2Jb"}, + {"OSC title stripped", "x\x1b]0;pwned\x07y", "x]0;pwnedy"}, + {"DEL stripped", "a\x7fb", "ab"}, + {"newline and tab stripped", "a\nb\tc", "abc"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := sanitizeProgress(tt.in); got != tt.want { + t.Errorf("sanitizeProgress(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} + +func TestUpdateProgress_StoresSanitizedText(t *testing.T) { + pw := NewProgressWriter(&bytes.Buffer{}, true, false) // interactive so UpdateProgress stores + pw.UpdateProgress("danger\x1b]0;x\x07 100%") + pw.mu.Lock() + got := pw.currentProgress + pw.mu.Unlock() + if strings.ContainsAny(got, "\x1b\x07") { + t.Errorf("currentProgress retains control bytes: %q", got) + } +} diff --git a/internal/engine/rclone.go b/internal/engine/rclone.go index b57a317..3195f1b 100644 --- a/internal/engine/rclone.go +++ b/internal/engine/rclone.go @@ -20,14 +20,16 @@ import ( // argument lists from the runner (built by BuildRcloneArgs) and handles command // execution and stats parsing from the shared log file. type RcloneExecutor struct { - logger *log.Logger - logFile string + logger *log.Logger + logFile string + configPass string // rclone config password; injected per-command, empty means none } -// NewRcloneExecutor returns a configured RcloneExecutor. -// logFile is the path to the shared log file used for stats parsing. -func NewRcloneExecutor(logger *log.Logger, logFile string) *RcloneExecutor { - return &RcloneExecutor{logger: logger, logFile: logFile} +// NewRcloneExecutor returns a configured RcloneExecutor. logFile is the shared +// log file used for stats parsing; configPass, when non-empty, is injected as +// RCLONE_CONFIG_PASS into each rclone command's environment (and nowhere else). +func NewRcloneExecutor(logger *log.Logger, logFile, configPass string) *RcloneExecutor { + return &RcloneExecutor{logger: logger, logFile: logFile, configPass: configPass} } // rcloneProgressTracker extracts transfer progress from rclone -P stdout. @@ -107,6 +109,19 @@ func splitOnCROrLF(data []byte, atEOF bool) (advance int, token []byte, err erro return 0, nil, nil } +// rcloneCommand builds an rclone *exec.Cmd, injecting the config password into +// this command's environment only when one was supplied. When configPass is +// empty the command inherits the ambient environment unchanged (covering both +// the no-password case and a user-exported RCLONE_CONFIG_PASS). Callers choose +// their own execution method on the returned command. +func (e *RcloneExecutor) rcloneCommand(ctx context.Context, args ...string) *exec.Cmd { + cmd := exec.CommandContext(ctx, "rclone", args...) + if e.configPass != "" { + cmd.Env = append(os.Environ(), "RCLONE_CONFIG_PASS="+e.configPass) + } + return cmd +} + // Exec runs rclone with the given pre-assembled argument list. // Stdout is piped to a goroutine that parses -P progress output. Stats are // parsed from the log file section written during this call. @@ -125,7 +140,7 @@ func (e *RcloneExecutor) Exec(ctx context.Context, args []string, onProgress fun start := time.Now() - cmd := exec.CommandContext(ctx, "rclone", args...) + cmd := e.rcloneCommand(ctx, args...) var stderrBuf bytes.Buffer cmd.Stderr = &stderrBuf @@ -235,7 +250,7 @@ func (e *RcloneExecutor) CleanupArchives(ctx context.Context, remoteName, backup if e.logFile != "" { lsdArgs = append(lsdArgs, "--log-file", e.logFile, "--log-level", "INFO") } - output, err := exec.CommandContext(ctx, "rclone", lsdArgs...).Output() + output, err := e.rcloneCommand(ctx, lsdArgs...).Output() if err != nil { e.logger.Info(fmt.Sprintf("no archive directory on %s (nothing to clean)", remoteName)) return nil @@ -266,7 +281,7 @@ func (e *RcloneExecutor) CleanupArchives(ctx context.Context, remoteName, backup if e.logFile != "" { purgeArgs = append(purgeArgs, "--log-file", e.logFile, "--log-level", "INFO") } - if purgeErr := exec.CommandContext(ctx, "rclone", purgeArgs...).Run(); purgeErr != nil { + if purgeErr := e.rcloneCommand(ctx, purgeArgs...).Run(); purgeErr != nil { e.logger.Warn(fmt.Sprintf("failed to purge %s: %v", target, purgeErr)) } else { purged++ diff --git a/internal/engine/rclone_test.go b/internal/engine/rclone_test.go index e9326ee..52214c2 100644 --- a/internal/engine/rclone_test.go +++ b/internal/engine/rclone_test.go @@ -267,7 +267,7 @@ func newRcloneTestExecutor(t *testing.T) (*RcloneExecutor, string) { t.Helper() logPath := filepath.Join(t.TempDir(), "rclone-test.log") logger := newTestLogger(t) - return NewRcloneExecutor(logger, logPath), logPath + return NewRcloneExecutor(logger, logPath, ""), logPath } func TestRcloneExec_CopyFile_Succeeds(t *testing.T) { @@ -509,3 +509,29 @@ func TestRcloneExec_ExpiredContext_ReturnsTimedOut(t *testing.T) { t.Errorf("Status = %q, want %q", result.Status, StatusTimedOut) } } + +func TestRcloneCommand_InjectsPasswordOnlyWhenSet(t *testing.T) { + logger := newTestLogger(t) + + withPass := NewRcloneExecutor(logger, "", "s3cr3t") + cmd := withPass.rcloneCommand(context.Background(), "version") + found := false + for _, e := range cmd.Env { + if e == "RCLONE_CONFIG_PASS=s3cr3t" { + found = true + } + } + if !found { + t.Error("expected RCLONE_CONFIG_PASS in cmd.Env when configPass set") + } + for _, a := range cmd.Args { + if strings.Contains(a, "s3cr3t") { + t.Errorf("password must never appear in argv, found in %q", a) + } + } + + noPass := NewRcloneExecutor(logger, "", "") + if cmd2 := noPass.rcloneCommand(context.Background(), "version"); cmd2.Env != nil { + t.Errorf("cmd.Env = %v, want nil (inherit) when configPass empty", cmd2.Env) + } +} diff --git a/internal/engine/runner.go b/internal/engine/runner.go index 86856fe..24c1f41 100644 --- a/internal/engine/runner.go +++ b/internal/engine/runner.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "io" + "io/fs" "os" "os/exec" "path/filepath" @@ -41,23 +42,35 @@ type Runner struct { lockFile *os.File // held open to maintain flock; released on process exit } -// NewRunner creates a Runner for the given config. configPath is the -// absolute path to the config file (used for per-config locking). -// pw controls live terminal progress display. If nil, a non-interactive -// writer is created that prints plain status lines to io.Discard. -func NewRunner(cfg *config.Config, configPath string, logger *log.Logger, pw *ProgressWriter, dryRun bool, logFile string) *Runner { +// RunnerConfig carries the inputs needed to construct a Runner. Grouped into +// a struct so the constructor stays within the project's argument-count +// budget and so each call site names the field it sets at the boundary. +type RunnerConfig struct { + Cfg *config.Config + ConfigPath string // absolute path to the config file (used for per-config locking) + Logger *log.Logger + Progress *ProgressWriter // live terminal display; nil yields a discard writer + DryRun bool + LogFile string + RclonePassword string // injected into rclone child processes only; empty means none +} + +// NewRunner creates a Runner from rc. If rc.Progress is nil, a non-interactive +// writer that prints plain status lines to io.Discard is used. +func NewRunner(rc RunnerConfig) *Runner { + pw := rc.Progress if pw == nil { pw = NewProgressWriter(io.Discard, false, false) } return &Runner{ - cfg: cfg, - configPath: configPath, - logger: logger, + cfg: rc.Cfg, + configPath: rc.ConfigPath, + logger: rc.Logger, pw: pw, - rsync: NewRsyncExecutor(logger), - rclone: NewRcloneExecutor(logger, logFile), - dryRun: dryRun, - logFile: logFile, + rsync: NewRsyncExecutor(rc.Logger), + rclone: NewRcloneExecutor(rc.Logger, rc.LogFile, rc.RclonePassword), + dryRun: rc.DryRun, + logFile: rc.LogFile, } } @@ -462,12 +475,65 @@ func (r *Runner) collectFilterFiles(opts RunOptions) []string { }) } +// lockDirPerm is the owner-only mode for the per-user lock directory: no group +// or other access, so a different local user cannot read or plant entries in it. +const lockDirPerm os.FileMode = 0o700 + +// lockDir returns the directory for the per-config lock file. It prefers +// XDG_RUNTIME_DIR; absent that, a per-user "shuttle-" subdirectory under +// os.TempDir(). Both paths are run through ensureSecureDir, which creates a +// missing directory mode 0700 and fails closed on a symlink, non-directory, +// group/other-accessible, or foreign-owned one. XDG_RUNTIME_DIR is per-user +// mode 0700 by the XDG spec, but it is an environment value, so it is verified +// to the same bar rather than trusted blind; ensureSecureDir is a no-op on an +// already-valid directory. +func lockDir() (string, error) { + dir := os.Getenv("XDG_RUNTIME_DIR") + if dir == "" { + dir = filepath.Join(os.TempDir(), fmt.Sprintf("shuttle-%d", os.Getuid())) + } + if err := ensureSecureDir(dir); err != nil { + return "", err + } + return dir, nil +} + +// ensureSecureDir creates dir mode 0700 if absent, or verifies an existing dir +// is a real directory (not a symlink), owned by the current user, with no +// group/other access. It fails closed: an insecure pre-existing path is an +// error, never silently reused. +func ensureSecureDir(dir string) error { + fi, err := os.Lstat(dir) // Lstat so a planted symlink is detected, not followed + if errors.Is(err, fs.ErrNotExist) { + return os.Mkdir(dir, lockDirPerm) // parent TempDir already exists; create the leaf only + } + if err != nil { + return fmt.Errorf("checking lock dir %s: %w", dir, err) + } + if fi.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("lock dir %s is a symlink", dir) + } + if !fi.IsDir() { + return fmt.Errorf("lock dir %s is not a directory", dir) + } + if fi.Mode().Perm()&0o077 != 0 { + return fmt.Errorf("lock dir %s is group/other-accessible (%o)", dir, fi.Mode().Perm()) + } + if st, ok := fi.Sys().(*syscall.Stat_t); ok && int(st.Uid) != os.Getuid() { + return fmt.Errorf("lock dir %s not owned by current user", dir) + } + return nil +} + // acquireLock obtains an exclusive, non-blocking file lock via syscall.Flock. -// The lock file path is derived from the config file path so that different -// configs can run concurrently. +// The lock file lives in a per-user runtime directory (see lockDir) and is +// opened O_NOFOLLOW so a symlink planted at the path cannot redirect the open. func (r *Runner) acquireLock() error { - lockPath := r.lockFilePath() - f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o644) + lockPath, err := r.lockFilePath() + if err != nil { + return fmt.Errorf("resolving lock path: %w", err) + } + f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR|syscall.O_NOFOLLOW, 0o600) if err != nil { return fmt.Errorf("opening lock file: %w", err) } @@ -479,11 +545,16 @@ func (r *Runner) acquireLock() error { return nil } -// lockFilePath returns /shuttle-.lock where hash is the first -// 8 hex characters of SHA-256 of the absolute config file path. -func (r *Runner) lockFilePath() string { +// lockFilePath returns /shuttle-.lock where hash is the first +// 8 hex characters of SHA-256 of the absolute config file path, keeping the +// lock per-config while the directory is per-user. +func (r *Runner) lockFilePath() (string, error) { + dir, err := lockDir() + if err != nil { + return "", err + } h := sha256.Sum256([]byte(r.configPath)) - return filepath.Join(os.TempDir(), fmt.Sprintf("shuttle-%x.lock", h[:4])) + return filepath.Join(dir, fmt.Sprintf("shuttle-%x.lock", h[:4])), nil } // targetRemotes returns the intersection of the job's remotes and the diff --git a/internal/engine/runner_integration_test.go b/internal/engine/runner_integration_test.go index 2c04b05..49eefda 100644 --- a/internal/engine/runner_integration_test.go +++ b/internal/engine/runner_integration_test.go @@ -67,7 +67,7 @@ func runPipeline(t *testing.T, cfg *config.Config, configPath string, opts RunOp t.Fatalf("creating logger: %v", err) } pw := NewProgressWriter(io.Discard, false, false) - runner := NewRunner(cfg, configPath, logger, pw, opts.DryRun, logFile) + runner := NewRunner(RunnerConfig{Cfg: cfg, ConfigPath: configPath, Logger: logger, Progress: pw, DryRun: opts.DryRun, LogFile: logFile}) summary, err := runner.Run(context.Background(), opts) if err != nil { t.Fatalf("Run returned error: %v", err) @@ -268,7 +268,7 @@ func TestPipeline_LockContention_SecondRunRejected(t *testing.T) { if err != nil { t.Fatalf("creating logger %s: %v", tag, err) } - return NewRunner(cfg, configPath, logger, NewProgressWriter(io.Discard, false, false), false, logFile) + return NewRunner(RunnerConfig{Cfg: cfg, ConfigPath: configPath, Logger: logger, Progress: NewProgressWriter(io.Discard, false, false), LogFile: logFile}) } run1 := newRunner("run1") diff --git a/internal/engine/runner_test.go b/internal/engine/runner_test.go index 5ccf5bd..98baa95 100644 --- a/internal/engine/runner_test.go +++ b/internal/engine/runner_test.go @@ -65,20 +65,50 @@ func TestShouldRunJob_SkipLogic(t *testing.T) { } } +// xdgRuntimeDir returns a fresh 0700 directory and points XDG_RUNTIME_DIR at it +// for the test's duration. t.TempDir() yields a 0755 directory on some +// platforms, which lockDir now rejects (it verifies XDG_RUNTIME_DIR to the same +// fail-closed bar as the fallback, matching the real XDG 0700 contract), so the +// perms are tightened explicitly here. +func xdgRuntimeDir(t *testing.T) string { + t.Helper() + dir := t.TempDir() + if err := os.Chmod(dir, 0o700); err != nil { + t.Fatalf("chmod runtime dir: %v", err) + } + t.Setenv("XDG_RUNTIME_DIR", dir) + return dir +} + func TestLockFilePath_DifferentConfigs(t *testing.T) { + runtimeDir := xdgRuntimeDir(t) + r1 := &Runner{configPath: "/home/user/.config/shuttle/config.toml"} r2 := &Runner{configPath: "/home/user/alt/shuttle/config.toml"} r3 := &Runner{configPath: "/home/user/.config/shuttle/config.toml"} - if r1.lockFilePath() == r2.lockFilePath() { + p1, err := r1.lockFilePath() + if err != nil { + t.Fatalf("lockFilePath r1: %v", err) + } + p2, err := r2.lockFilePath() + if err != nil { + t.Fatalf("lockFilePath r2: %v", err) + } + p3, err := r3.lockFilePath() + if err != nil { + t.Fatalf("lockFilePath r3: %v", err) + } + + if p1 == p2 { t.Error("different config paths should produce different lock paths") } - if r1.lockFilePath() != r3.lockFilePath() { + if p1 != p3 { t.Error("same config path should produce same lock path") } - wantPrefix := filepath.Join(os.TempDir(), "shuttle-") - if !strings.HasPrefix(r1.lockFilePath(), wantPrefix) { - t.Errorf("lock path should start with %q, got %q", wantPrefix, r1.lockFilePath()) + wantPrefix := filepath.Join(runtimeDir, "shuttle-") + if !strings.HasPrefix(p1, wantPrefix) { + t.Errorf("lock path should start with %q, got %q", wantPrefix, p1) } } @@ -120,7 +150,7 @@ func newTestRunner(t *testing.T, termBuf *bytes.Buffer) *Runner { t.Fatalf("creating logger: %v", err) } pw := NewProgressWriter(io.Discard, false, false) - return NewRunner(&config.Config{}, "", logger, pw, false, logFile) + return NewRunner(RunnerConfig{Cfg: &config.Config{}, Logger: logger, Progress: pw, LogFile: logFile}) } func TestRunRsyncJob_Optional_MissingSource_MarksOptionalMissing(t *testing.T) { @@ -472,3 +502,104 @@ func TestClassifyExitStatus(t *testing.T) { }) } } + +func TestLockDir_HonorsXDGRuntimeDir(t *testing.T) { + runtimeDir := xdgRuntimeDir(t) + got, err := lockDir() + if err != nil { + t.Fatalf("lockDir: %v", err) + } + if got != runtimeDir { + t.Errorf("lockDir() = %q, want %q", got, runtimeDir) + } +} + +func TestLockDir_RejectsInsecureXDGRuntimeDir(t *testing.T) { + insecure := filepath.Join(t.TempDir(), "loose") + if err := os.Mkdir(insecure, 0o700); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.Chmod(insecure, 0o777); err != nil { // chmod defeats umask so group/other bits are actually set + t.Fatalf("chmod: %v", err) + } + t.Setenv("XDG_RUNTIME_DIR", insecure) + if _, err := lockDir(); err == nil { + t.Error("lockDir should reject a group/other-accessible XDG_RUNTIME_DIR") + } +} + +func TestLockDir_FallbackCreatesPrivateSubdir(t *testing.T) { + t.Setenv("XDG_RUNTIME_DIR", "") + t.Setenv("TMPDIR", t.TempDir()) // os.TempDir() reads $TMPDIR + got, err := lockDir() + if err != nil { + t.Fatalf("lockDir: %v", err) + } + info, err := os.Lstat(got) + if err != nil { + t.Fatalf("stat lock dir: %v", err) + } + if !info.IsDir() { + t.Error("lock dir is not a directory") + } + if info.Mode().Perm() != 0o700 { + t.Errorf("lock dir perm = %o, want 700", info.Mode().Perm()) + } +} + +func TestEnsureSecureDir_RejectsInsecureDirs(t *testing.T) { + t.Run("fresh dir created 0700", func(t *testing.T) { + dir := filepath.Join(t.TempDir(), "lock") + if err := ensureSecureDir(dir); err != nil { + t.Fatalf("ensureSecureDir: %v", err) + } + info, _ := os.Lstat(dir) + if info.Mode().Perm() != 0o700 { + t.Errorf("perm = %o, want 700", info.Mode().Perm()) + } + }) + t.Run("group/other-accessible rejected", func(t *testing.T) { + dir := filepath.Join(t.TempDir(), "loose") + if err := os.Mkdir(dir, 0o777); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := ensureSecureDir(dir); err == nil { + t.Error("expected error for 0777 dir") + } + }) + t.Run("symlink rejected", func(t *testing.T) { + target := t.TempDir() + link := filepath.Join(t.TempDir(), "link") + if err := os.Symlink(target, link); err != nil { + t.Fatalf("symlink: %v", err) + } + if err := ensureSecureDir(link); err == nil { + t.Error("expected error for symlinked dir") + } + }) + t.Run("non-directory rejected", func(t *testing.T) { + file := filepath.Join(t.TempDir(), "file") + if err := os.WriteFile(file, []byte("x"), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + if err := ensureSecureDir(file); err == nil { + t.Error("expected error for non-directory") + } + }) +} + +func TestAcquireLock_RejectsSymlinkedLockPath(t *testing.T) { + xdgRuntimeDir(t) + r := &Runner{configPath: "/some/config.toml"} + lockPath, err := r.lockFilePath() + if err != nil { + t.Fatalf("lockFilePath: %v", err) + } + if err := os.Symlink(filepath.Join(t.TempDir(), "elsewhere"), lockPath); err != nil { + t.Fatalf("symlink: %v", err) + } + if err := r.acquireLock(); err == nil { + t.Error("acquireLock should reject a symlinked lock path (O_NOFOLLOW)") + _ = r.lockFile.Close() + } +}