diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 15e2deed..b1478625 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -89,3 +89,38 @@ jobs: ruby -c dist/errand.rb cd dist sha256sum --check checksums.txt + + windows: + name: Check (windows-2025) + runs-on: windows-2025 + timeout-minutes: 20 + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ inputs.ref }} + persist-credentials: false + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + cache: ${{ inputs.ref == '' }} + cache-dependency-path: go.sum + - name: Vet + run: go vet ./... + - name: Build and check binary + run: | + go build -trimpath -o "$RUNNER_TEMP/errand.exe" ./cmd/errand + "$RUNNER_TEMP/errand.exe" version + # Packages are added here as their Windows support lands. + - name: Test Windows-supported packages + run: >- + go test -count=1 -timeout=5m + ./internal/archive ./internal/durable ./internal/filelock ./internal/fsidentity ./internal/fsowner + ./internal/logio ./internal/manifest ./internal/pathpolicy ./internal/placement + ./internal/proctree ./internal/proto ./internal/serviceruntime ./internal/setup + ./internal/unixpeer + # Windows-only tests in packages whose other tests still assume Unix. + - name: Test Windows-specific behavior + run: go test -count=1 -timeout=5m -run 'OnWindows$' ./... diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cfe25c9c..c772cec0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -186,6 +186,9 @@ jobs: tar -xzf "errand_${version}_darwin_${architecture}.tar.gz" -C smoke codesign --verify --strict smoke/errand test "$(smoke/errand version)" = "errand ${version}" + for architecture in amd64 arm64; do + unzip -l "errand_${version}_windows_${architecture}.zip" | grep -q ' errand\.exe$' + done - name: Verify release tag before publishing env: RELEASE_TAG: ${{ needs.resolve.outputs.tag }} @@ -209,7 +212,7 @@ jobs: if [[ "$RELEASE_TAG" == *-* ]]; then options+=(--prerelease) fi - gh release create "$RELEASE_TAG" dist/*.tar.gz dist/checksums.txt dist/errand.rb \ + gh release create "$RELEASE_TAG" dist/*.tar.gz dist/*.zip dist/checksums.txt dist/errand.rb \ --verify-tag --draft --title "$RELEASE_TAG" --generate-notes "${options[@]}" - name: Remove temporary signing credentials if: always() diff --git a/.goreleaser.yaml b/.goreleaser.yaml index e2e9e5d6..b282cc4e 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -7,7 +7,7 @@ builds: binary: errand env: - CGO_ENABLED=0 - goos: [darwin, linux] + goos: [darwin, linux, windows] goarch: [amd64, arm64] flags: [-trimpath] ldflags: @@ -20,6 +20,9 @@ builds: archives: - ids: [errand] formats: [tar.gz] + format_overrides: + - goos: windows + formats: [zip] name_template: "errand_{{ .Version }}_{{ .Os }}_{{ .Arch }}" files: [LICENSE, README.md] diff --git a/README.md b/README.md index b8d0b7db..84114799 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,8 @@ errand -- make test ## Install -Install Errand on both machines. It supports macOS and Linux. +Install Errand on both machines. It supports macOS and Linux. A Windows PC can +be a runner too, reached over Tailscale; see [Windows runner (experimental)](docs/WINDOWS.md). With [Homebrew](https://brew.sh): diff --git a/cmd/errand/console_other.go b/cmd/errand/console_other.go new file mode 100644 index 00000000..65ab4b05 --- /dev/null +++ b/cmd/errand/console_other.go @@ -0,0 +1,10 @@ +//go:build !windows + +package main + +import "os" + +func detachServiceConsole(*os.File) {} + +// Unix service managers stop the runner with SIGTERM and record it. +func logServiceStop() {} diff --git a/cmd/errand/console_windows.go b/cmd/errand/console_windows.go new file mode 100644 index 00000000..8d45512b --- /dev/null +++ b/cmd/errand/console_windows.go @@ -0,0 +1,56 @@ +//go:build windows + +package main + +import ( + "log" + "os" + "os/signal" + "syscall" + "unsafe" + + "golang.org/x/sys/windows" +) + +var ( + kernel32 = windows.NewLazySystemDLL("kernel32.dll") + procGetConsoleProcessList = kernel32.NewProc("GetConsoleProcessList") + procFreeConsole = kernel32.NewProc("FreeConsole") +) + +// Task Scheduler gives a console program its own visible console window. When +// the runner is that console's only process, nobody is reading it, so close +// it. A runner started from a terminal shares the console and keeps it. +// +// Detaching closes the console's handles, but the process's standard handles +// still name them. Those values are soon reused by other objects, so point +// the standard handles at the log and NUL before anything can use them. +func detachServiceConsole(logFile *os.File) { + var pids [2]uint32 + n, _, _ := procGetConsoleProcessList.Call(uintptr(unsafe.Pointer(&pids[0])), uintptr(len(pids))) + if n != 1 { + return + } + nul, err := os.Open(os.DevNull) + if err != nil { + return // keep the console rather than leave stale handles + } + procFreeConsole.Call() + retiredStdio = append(retiredStdio, os.Stdin) + os.Stdin = nul + _ = windows.SetStdHandle(windows.STD_INPUT_HANDLE, windows.Handle(nul.Fd())) + _ = windows.SetStdHandle(windows.STD_OUTPUT_HANDLE, windows.Handle(logFile.Fd())) + _ = windows.SetStdHandle(windows.STD_ERROR_HANDLE, windows.Handle(logFile.Fd())) +} + +// logServiceStop records why the runner stops on a console event (closing +// its window, signing out, shutting down). Unhandled, Go exits with status 2 +// and leaves nothing in the log. +func logServiceStop() { + stop := make(chan os.Signal, 1) + signal.Notify(stop, os.Interrupt, syscall.SIGTERM) + go func() { + log.Printf("errand serve: stopping on %v", <-stop) + os.Exit(1) + }() +} diff --git a/cmd/errand/console_windows_test.go b/cmd/errand/console_windows_test.go new file mode 100644 index 00000000..2b8d6692 --- /dev/null +++ b/cmd/errand/console_windows_test.go @@ -0,0 +1,106 @@ +//go:build windows + +package main + +import ( + "fmt" + "io" + "net" + "os" + "os/exec" + "path/filepath" + "runtime" + "syscall" + "testing" + + "golang.org/x/sys/windows" +) + +// A runner started by Task Scheduler owns a console that nobody reads and +// detaches from it. Its old standard handles must not reach handles opened +// later, which crashed the runner a job or two after it started. The helper +// runs in its own console, detaches, then churns handles, child processes and +// network I/O under constant garbage collection. +func TestDetachedRunnerKeepsWorkingOnWindows(t *testing.T) { + if os.Getenv("ERRAND_DETACH_HELPER") == "1" { + detachedRunnerHelper(t) + return + } + logPath := filepath.Join(t.TempDir(), "errand.log") + cmd := exec.Command(os.Args[0], "-test.run=^TestDetachedRunnerKeepsWorkingOnWindows$") + cmd.Env = append(os.Environ(), "ERRAND_DETACH_HELPER=1", "GOGC=1", "ERRAND_DETACH_LOG="+logPath) + cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: windows.CREATE_NEW_CONSOLE} + if err := cmd.Run(); err != nil { + logged, _ := os.ReadFile(logPath) + t.Fatalf("detached helper failed: %v\n%s", err, logged) + } +} + +func detachedRunnerHelper(t *testing.T) { + f, err := os.OpenFile(os.Getenv("ERRAND_DETACH_LOG"), os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o600) + if err != nil { + t.Fatal(err) + } + // Start as Task Scheduler starts the runner: the standard handles are + // the console's own. (exec gave this process NUL instead.) + for _, std := range []struct { + name string + handle uint32 + file **os.File + }{ + {"CONIN$", windows.STD_INPUT_HANDLE, &os.Stdin}, + {"CONOUT$", windows.STD_OUTPUT_HANDLE, &os.Stdout}, + {"CONOUT$", windows.STD_ERROR_HANDLE, &os.Stderr}, + } { + console, err := os.OpenFile(std.name, os.O_RDWR, 0) + if err != nil { + t.Fatal(err) + } + if err := windows.SetStdHandle(std.handle, windows.Handle(console.Fd())); err != nil { + t.Fatal(err) + } + retiredStdio = append(retiredStdio, *std.file) + *std.file = console + } + fail := func(format string, args ...any) { + fmt.Fprintf(f, format+"\n", args...) + os.Exit(1) + } + useServiceLog(f) + if handle, _ := windows.GetStdHandle(windows.STD_ERROR_HANDLE); handle != windows.Handle(f.Fd()) { + fail("stderr handle %v does not name the log %v", handle, f.Fd()) + } + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + fail("%v", err) + } + defer listener.Close() + go func() { + for { + conn, err := listener.Accept() + if err != nil { + return + } + go func() { _, _ = io.Copy(conn, conn); conn.Close() }() + } + }() + for i := range 20 { + runtime.GC() + if err := exec.Command("cmd", "/d", "/c", "exit 0").Run(); err != nil { + fail("job %d: %v", i, err) + } + conn, err := net.Dial("tcp", listener.Addr().String()) + if err != nil { + fail("dial %d: %v", i, err) + } + want := fmt.Sprintf("round %d", i) + if _, err := io.WriteString(conn, want); err != nil { + fail("%v", err) + } + got := make([]byte, len(want)) + if _, err := io.ReadFull(conn, got); err != nil || string(got) != want { + fail("echo %d = %q, %v", i, got, err) + } + conn.Close() + } +} diff --git a/cmd/errand/fifo_unix_test.go b/cmd/errand/fifo_unix_test.go new file mode 100644 index 00000000..0c97f611 --- /dev/null +++ b/cmd/errand/fifo_unix_test.go @@ -0,0 +1,13 @@ +//go:build unix + +package main + +import ( + "syscall" + "testing" +) + +// requireFIFOs skips tests that need named pipes in the file system. +func requireFIFOs(*testing.T) {} + +func mkfifo(path string) error { return syscall.Mkfifo(path, 0o600) } diff --git a/cmd/errand/fifo_windows_test.go b/cmd/errand/fifo_windows_test.go new file mode 100644 index 00000000..f1fbadf3 --- /dev/null +++ b/cmd/errand/fifo_windows_test.go @@ -0,0 +1,13 @@ +//go:build windows + +package main + +import ( + "errors" + "testing" +) + +// requireFIFOs skips tests that need named pipes in the file system. +func requireFIFOs(t *testing.T) { t.Skip("Windows has no FIFOs in the file system") } + +func mkfifo(string) error { return errors.New("Windows has no FIFOs in the file system") } diff --git a/cmd/errand/main.go b/cmd/errand/main.go index e321de6f..e0fbdc0e 100644 --- a/cmd/errand/main.go +++ b/cmd/errand/main.go @@ -15,11 +15,11 @@ import ( "net/http" "os" "path/filepath" + "runtime/debug" "sort" "strconv" "strings" "sync" - "syscall" "time" "github.com/lydakis/errand/internal/client" @@ -29,6 +29,7 @@ import ( "github.com/lydakis/errand/internal/serviceruntime" "github.com/lydakis/errand/internal/setup" "github.com/lydakis/errand/internal/tailnet" + "github.com/lydakis/errand/internal/unixpeer" "github.com/lydakis/errand/internal/workspace" ) @@ -96,6 +97,10 @@ func runCLI(args []string) int { fmt.Fprintln(os.Stderr, usage) return 2 } + if unsupportedOnThisPlatform(args[0]) { + fmt.Fprintln(os.Stderr, windowsClientUnsupported) + return 2 + } switch args[0] { case "serve": return cmdServe(args[1:]) @@ -632,10 +637,18 @@ func cmdServe(args []string) int { listen := fs.String("listen", "", `listen address ("tailnet:7443" resolves the tailnet IP; "none" disables TCP)`) stateDir := fs.String("state-dir", "", "receipt and job state directory") insecure := fs.Bool("insecure-no-auth", false, "DANGEROUS: skip all authorization (tests only)") + logFile := fs.String("log-file", "", "append the runner log to this file instead of stderr") var allowUsers stringList fs.Var(&allowUsers, "allow-user", "tailnet login allowed to use this runner (repeatable)") setFlagUsage(fs, "errand serve [options]") fs.Parse(args) + if *logFile != "" { + f, err := os.OpenFile(*logFile, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o600) + if err != nil { + log.Fatalf("errand serve: %v", err) + } + useServiceLog(f) + } fileCfg, err := config.LoadDaemon(*cfgPath) if err != nil { @@ -744,6 +757,26 @@ func cmdServe(args []string) int { return 0 } +// retiredStdio keeps replaced standard files reachable. A collected *os.File +// closes its handle, and once a Windows runner detaches its console, that +// handle value can belong to an unrelated object. +var retiredStdio []*os.File + +// useServiceLog sends everything the runner writes to f, the log a service +// manager gives it. +func useServiceLog(f *os.File) { + retiredStdio = append(retiredStdio, os.Stdout, os.Stderr) + os.Stdout, os.Stderr = f, f + log.SetOutput(f) + // The runtime writes fatal errors to the process's own stderr, which + // a service may not have; keep them in the log too. + if err := debug.SetCrashOutput(f, debug.CrashOptions{}); err != nil { + log.Printf("errand serve: crash output stays on stderr: %v", err) + } + logServiceStop() + detachServiceConsole(f) +} + type tailnetDiscoverFunc func(string, string) (tailnet.Provider, error) func resolveServeTransport( @@ -791,7 +824,7 @@ func listenUnixSocket(path string) (net.Listener, error) { conn.Close() return nil, fmt.Errorf("local socket %q already has a live listener", path) } - if !errors.Is(dialErr, syscall.ECONNREFUSED) { + if !unixpeer.ConnectionRefused(dialErr) { return nil, fmt.Errorf("checking existing local socket %q: %w", path, dialErr) } if err := os.Remove(path); err != nil { diff --git a/cmd/errand/platform.go b/cmd/errand/platform.go new file mode 100644 index 00000000..ca3fb267 --- /dev/null +++ b/cmd/errand/platform.go @@ -0,0 +1,19 @@ +package main + +import "runtime" + +const windowsClientUnsupported = "errand: sending jobs from Windows isn't supported yet. This PC can run jobs sent from macOS or Linux; see docs/WINDOWS.md." + +// Windows is runner-only for now. Client commands would otherwise fail later +// on Unix-only checks with errors that don't say why. +func unsupportedOnThisPlatform(command string) bool { + return runtime.GOOS == "windows" && clientCommand(command) +} + +func clientCommand(command string) bool { + switch command { + case "serve", "setup", "config", "access", "doctor", "df", "gc", "version", "--version", "_stdio", "-h", "--help": + return false + } + return true +} diff --git a/cmd/errand/platform_windows_test.go b/cmd/errand/platform_windows_test.go new file mode 100644 index 00000000..8c54a737 --- /dev/null +++ b/cmd/errand/platform_windows_test.go @@ -0,0 +1,18 @@ +//go:build windows + +package main + +import "testing" + +func TestClientCommandsExplainWindowsIsRunnerOnlyOnWindows(t *testing.T) { + for _, args := range [][]string{{"--on", "mac", "--", "ver"}, {"push"}, {"fetch", "mac/01J"}, {"peers"}, {"ps"}, {"workspaces"}} { + if code := runCLI(args); code != 2 { + t.Fatalf("errand %v exit = %d, want 2", args, code) + } + } + for _, command := range []string{"serve", "setup", "access", "doctor", "version"} { + if unsupportedOnThisPlatform(command) { + t.Fatalf("runner command %q is refused on Windows", command) + } + } +} diff --git a/cmd/errand/push_benchmark_test.go b/cmd/errand/push_benchmark_test.go index 5645e731..08c44eec 100644 --- a/cmd/errand/push_benchmark_test.go +++ b/cmd/errand/push_benchmark_test.go @@ -1,3 +1,5 @@ +//go:build unix + package main import ( diff --git a/cmd/errand/push_watch_burst_test.go b/cmd/errand/push_watch_burst_test.go index f8569591..3d113d32 100644 --- a/cmd/errand/push_watch_burst_test.go +++ b/cmd/errand/push_watch_burst_test.go @@ -6,7 +6,6 @@ import ( "os" "path/filepath" "strings" - "syscall" "testing" "time" @@ -68,6 +67,7 @@ func TestPushWatchConvergesAfterActiveWriter(t *testing.T) { } func TestPushWatchReportsInvalidSourceDuringEdits(t *testing.T) { + requireFIFOs(t) root, _, peer, ws := watchFixture(t) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -76,7 +76,7 @@ func TestPushWatchReportsInvalidSourceDuringEdits(t *testing.T) { err := client.WatchPush(ctx, client.PushOptions{PeerURL: peer, Workspace: ws.Name, Root: root, Apply: true}, func(event client.PushWatchEvent) error { if event.State == "watching" && !created { created = true - return syscall.Mkfifo(filepath.Join(root, "unsupported"), 0600) + return mkfifo(filepath.Join(root, "unsupported")) } if event.State == "resampling" { retries++ diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index ad835956..94dff58b 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -46,7 +46,9 @@ migrated before running setup. `--force` does not override that ownership check. After an upgrade, setup verifies that a new service process answers and reports any version difference from the CLI. Updating a binary on disk does not update an already-running daemon. On macOS, setup re-enables its own LaunchAgent before -loading it; it does not adopt or restart custom system LaunchDaemons. +loading it; it does not adopt or restart custom system LaunchDaemons. On +Windows, setup registers a Task Scheduler task that starts at sign-in; see +[Windows runner (experimental)](WINDOWS.md). Generated services retain the absolute entries from the setup shell's `PATH` and add the standard system directories, so runner-installed developer tools diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 4a418941..566de8f1 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -1,6 +1,6 @@ # Releases and Homebrew -The release pipeline builds macOS and Linux binaries for amd64 and arm64, +The release pipeline builds macOS, Linux and Windows binaries for amd64 and arm64, a source archive, SHA-256 checksums, and a Homebrew formula. It creates a **draft** GitHub release after the same macOS/Linux checks used for pull requests pass. Publishing a stable release then triggers a validated Homebrew @@ -35,11 +35,13 @@ The generated `dist/` directory is disposable and ignored by Git. 2. Choose a version and push its tag, for example `v0.1.0`. Stable tags use `vMAJOR.MINOR.PATCH`; prereleases can use `v0.1.0-rc.1`. Do not move an existing release tag. The workflow runs only for pushed `v*` tags. -3. Review the resulting draft on GitHub. It contains four binary archives, +3. Review the resulting draft on GitHub. It contains four macOS/Linux + `.tar.gz` archives, two Windows `.zip` archives, `errand_VERSION_source.tar.gz`, `checksums.txt`, and `errand.rb`. All archives and the formula are covered by `checksums.txt`. Binary archives contain - `errand`, `LICENSE`, and `README.md`; `errand version` reports the tag version - without the leading `v`. + `errand` (`errand.exe` on Windows), `LICENSE`, and `README.md`; `errand + version` reports the tag version without the leading `v`. The Windows + binaries are unsigned, so a browser download shows a SmartScreen warning. 4. Download the draft assets and verify checksums and native binaries. Review the generated release notes, then publish the draft. Prerelease tags are marked as prereleases automatically. diff --git a/docs/WINDOWS.md b/docs/WINDOWS.md new file mode 100644 index 00000000..ceef2f85 --- /dev/null +++ b/docs/WINDOWS.md @@ -0,0 +1,88 @@ +# Windows runner (experimental) + +A Windows PC can be an Errand runner: send jobs to it from a Mac or Linux +machine over Tailscale. Running the Errand client on Windows is not supported +yet. + +## Requirements + +- Windows 10 version 1803 or later, or Windows 11, on amd64 or arm64. +- [Tailscale](https://tailscale.com/download/windows) 1.100 or later, signed in + to the same tailnet as your client. +- The runner runs while you are signed in to Windows. Locking the screen is + fine; signing out stops it until you sign in again. + +## Install + +Download `errand_VERSION_windows_amd64.zip` (or `_arm64`) from +[GitHub Releases](https://github.com/lydakis/errand/releases) and unzip +`errand.exe` somewhere permanent, such as `%LOCALAPPDATA%\Programs\errand`. +The binary is not signed yet, so a browser download shows a SmartScreen +warning. In PowerShell, from that folder: + +```powershell +.\errand.exe setup +``` + +Setup needs no administrator rights. It: + +- writes the runner config to `%USERPROFILE%\.config\errand\errandd.toml`; +- copies `errand.exe` into `%USERPROFILE%\.errand\runtime` and registers a + Task Scheduler task named `errand` that starts that copy when you sign in; +- starts the task and checks that the runner answers. + +New Windows runners listen on the tailnet only (`transport = "tailscale"`), +because SSH callers are not supported on Windows yet. The runner log is +`%LOCALAPPDATA%\errand\errand.log`. + +To upgrade, replace `errand.exe` and run `errand.exe setup` again. The running +task uses its own copy, so replacing the file never fails because the runner +is using it. + +## Send jobs from your Mac + +On the client, add the PC by its tailnet name and run something: + +```sh +errand peers add winpc winpc.example.ts.net +errand --on winpc -- cargo test +errand --on winpc -- pwsh -NoProfile -Command 'Get-ChildItem' +``` + +Jobs get the runner's `PATH`: your user and system `PATH` as Windows stores +them. The task does not inherit the `PATH` of the shell that ran setup, so +sign out and back in after installing a tool that changes `PATH`, or run setup +again. Programs are found the way Windows finds them, with +`PATHEXT`, so `cargo` runs `cargo.exe` and `npm` runs `npm.cmd`. + +## How Windows jobs differ + +- **Shell features.** Errand runs the program directly. For pipes, redirection + or globbing, run a shell yourself: `-- pwsh -c '...'` or `-- cmd /c '...'`. +- **Batch files.** `cmd.exe` reinterprets the arguments of `.bat` and `.cmd` + programs. Errand refuses to run one with an argument containing `"`, `%`, + `^`, `&`, `|`, `<`, `>` or a line break, instead of running a different + command. +- **Stopping.** Each job runs in a Windows Job Object. Ctrl-C on the client or + `errand kill` ends the job's whole process tree at once. Jobs + don't get a chance to shut down cleanly yet. +- **File modes.** NTFS has no POSIX permissions. Files keep the modes they + had on your Mac, so exec bits survive a round trip. New files come back as + `0644`. Only the read-only attribute is carried from Windows. +- **File names.** Windows can't hold some names that are valid on macOS and + Linux, such as `CON`, `aux.c`, `a:b`, or names ending in a dot or space. + Case-only differences (`README` and `readme`) collide. +- **Symlinks.** Creating symlinks needs Developer Mode turned on in Windows + settings. +- **Line endings.** Errand copies bytes exactly. If your repository uses + `core.autocrlf=true`, files on the runner have whatever your checkout has. + +## Not yet supported + +- The Errand client on Windows. +- SSH transport to a Windows runner. +- [Named caches](NAMED_CACHES.md) (`[caches]` in `.errand.toml`); the runner + refuses jobs that declare one. +- Graceful Ctrl-C (a console break before the hard stop). +- Recovering jobs that were running when the runner stopped. +- Running while nobody is signed in. diff --git a/internal/archive/archive.go b/internal/archive/archive.go index f32793f8..d5508bbc 100644 --- a/internal/archive/archive.go +++ b/internal/archive/archive.go @@ -18,6 +18,7 @@ import ( "sort" "strings" + "github.com/lydakis/errand/internal/fslink" "github.com/lydakis/errand/internal/proto" ) @@ -165,11 +166,11 @@ func checkRelPath(p string) error { p == ".." || strings.HasPrefix(p, "../") || strings.Contains(p, "\x00") { return fmt.Errorf("archive: unsafe path %q", p) } - return nil + return checkPlatformPath(p) } func checkSymlinkTarget(link, target string) error { - if target == "" || strings.HasPrefix(target, "/") { + if target == "" || strings.HasPrefix(target, "/") || !platformSymlinkTarget(target) { return fmt.Errorf("archive: symlink %q has unsafe target %q", link, target) } resolved := path.Clean(path.Join(path.Dir(link), target)) @@ -180,7 +181,9 @@ func checkSymlinkTarget(link, target string) error { } type ExtractOptions struct { - ResolveMissing func(dest string, entry proto.ManifestEntry) (bool, error) + // Complete source metadata identifies directory links in partial uploads. + SymlinkManifest *proto.Manifest + ResolveMissing func(dest string, entry proto.ManifestEntry) (bool, error) } var ErrCacheMiss = errors.New("snapshot cache miss") @@ -300,6 +303,8 @@ func ExtractWith(r io.Reader, dest string, m proto.Manifest, maxBytes int64, opt ErrCacheMiss, len(cacheMisses), cacheMisses[0]) } // Symlinks last: no file write can ever traverse one of our links. + var linkLookup fslink.Lookup + var linkRoot *os.Root for _, e := range m.Entries { if e.Type != proto.EntrySymlink { continue @@ -308,6 +313,26 @@ func ExtractWith(r io.Reader, dest string, m proto.Manifest, maxBytes int64, opt if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { return err } + if fslink.NativeTypes { + if linkLookup == nil { + metadata := m + if opts.SymlinkManifest != nil { + metadata = *opts.SymlinkManifest + } + linkLookup = fslink.ManifestLookup(metadata) + var err error + linkRoot, err = os.OpenRoot(dest) + if err != nil { + return err + } + defer linkRoot.Close() + } + directory := fslink.IsDirectory(e, linkLookup) + if err := fslink.Create(linkRoot, e.Target, filepath.FromSlash(e.Path), directory); err != nil { + return err + } + continue + } if err := os.Symlink(e.Target, abs); err != nil { return err } diff --git a/internal/archive/archive_test.go b/internal/archive/archive_test.go index e08899f2..374d2049 100644 --- a/internal/archive/archive_test.go +++ b/internal/archive/archive_test.go @@ -10,7 +10,6 @@ import ( "os" "path/filepath" "strings" - "syscall" "testing" "github.com/lydakis/errand/internal/proto" @@ -213,9 +212,9 @@ func TestExtractRestoresFileModeMaskedByUmask(t *testing.T) { m := proto.Manifest{Entries: []proto.ManifestEntry{e}} buf := tarOf(t, map[string]string{"tool": "hello"}) dest := t.TempDir() - oldUmask := syscall.Umask(0o077) + restore := setUmask(t, 0o077) err := Extract(buf, dest, m, 1<<20) - syscall.Umask(oldUmask) + restore() if err != nil { t.Fatal(err) } diff --git a/internal/archive/directory_links_test.go b/internal/archive/directory_links_test.go new file mode 100644 index 00000000..e76ee809 --- /dev/null +++ b/internal/archive/directory_links_test.go @@ -0,0 +1,43 @@ +package archive + +import ( + "archive/tar" + "bytes" + "os" + "path/filepath" + "testing" + + "github.com/lydakis/errand/internal/proto" +) + +// A workspace push uploads only changed entries. On Windows, a new link to a +// directory that already exists must still be created as a directory link, +// which only the complete source manifest can tell. +func TestExtractWithTypesPartialDirectoryLinks(t *testing.T) { + dest := t.TempDir() + if err := os.MkdirAll(filepath.Join(dest, "target"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dest, "target", "value"), []byte("body"), 0o644); err != nil { + t.Fatal(err) + } + link := proto.ManifestEntry{Path: "nested/alias", Type: proto.EntrySymlink, Target: "../target", Mode: 0o777} + complete := proto.Manifest{Entries: []proto.ManifestEntry{ + {Path: "nested", Type: proto.EntryDir, Mode: 0o755}, + link, + {Path: "target", Type: proto.EntryDir, Mode: 0o755}, + entryFile("target/value", "body"), + }} + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + tw.WriteHeader(&tar.Header{Name: link.Path, Typeflag: tar.TypeSymlink, Linkname: link.Target}) + tw.Close() + + partial := proto.Manifest{Entries: []proto.ManifestEntry{link}} + if err := ExtractWith(&buf, dest, partial, 1<<20, ExtractOptions{SymlinkManifest: &complete}); err != nil { + t.Fatal(err) + } + if body, err := os.ReadFile(filepath.Join(dest, "nested", "alias", "value")); err != nil || string(body) != "body" { + t.Fatalf("read through pushed directory link = %q, %v", body, err) + } +} diff --git a/internal/archive/umask_unix_test.go b/internal/archive/umask_unix_test.go new file mode 100644 index 00000000..c3e3b1e3 --- /dev/null +++ b/internal/archive/umask_unix_test.go @@ -0,0 +1,13 @@ +//go:build unix + +package archive + +import ( + "syscall" + "testing" +) + +func setUmask(_ *testing.T, mask int) (restore func()) { + old := syscall.Umask(mask) + return func() { syscall.Umask(old) } +} diff --git a/internal/archive/umask_windows_test.go b/internal/archive/umask_windows_test.go new file mode 100644 index 00000000..d7d1b740 --- /dev/null +++ b/internal/archive/umask_windows_test.go @@ -0,0 +1,10 @@ +//go:build windows + +package archive + +import "testing" + +func setUmask(t *testing.T, _ int) (restore func()) { + t.Skip("Windows has no umask") + return nil +} diff --git a/internal/archive/windows_names.go b/internal/archive/windows_names.go new file mode 100644 index 00000000..70540fef --- /dev/null +++ b/internal/archive/windows_names.go @@ -0,0 +1,65 @@ +package archive + +import ( + "fmt" + "runtime" + "strings" +) + +// checkPlatformPath rejects paths this runner's file system would read +// differently from the manifest. +func checkPlatformPath(p string) error { + if runtime.GOOS != "windows" { + return nil + } + return checkWindowsPath(p) +} + +// Windows would resolve a backslash or drive in a symlink target, outside the +// escape check that works on slash-separated paths. +func platformSymlinkTarget(target string) bool { + return runtime.GOOS != "windows" || !strings.ContainsAny(target, `\:`) +} + +// Windows treats a backslash as a separator, a colon as a drive or alternate +// stream, and strips trailing dots and spaces, so such names would alias or +// escape other paths. Device names open devices in any directory. GIT~1 is +// the short name of .git. +func checkWindowsPath(p string) error { + for component := range strings.SplitSeq(p, "/") { + if problem := windowsNameProblem(component); problem != "" { + return fmt.Errorf("archive: %q can't be stored on Windows: %s", p, problem) + } + } + return nil +} + +func windowsNameProblem(name string) string { + for _, c := range name { + if c < 0x20 || strings.ContainsRune(`\:*?"<>|`, c) { + return fmt.Sprintf("names can't contain %q", c) + } + } + if strings.HasSuffix(name, ".") || strings.HasSuffix(name, " ") { + return "names can't end with a dot or space" + } + upper := strings.ToUpper(name) + base, _, _ := strings.Cut(upper, ".") + base = strings.TrimRight(base, " ") + switch base { + case "CON", "PRN", "AUX", "NUL", "CONIN$", "CONOUT$": + return base + " is a device name" + } + if len(base) >= 4 && (strings.HasPrefix(base, "COM") || strings.HasPrefix(base, "LPT")) { + // Windows also treats superscript ¹, ² and ³ as device numbers. + number := base[3:] + if (len(number) == 1 && number[0] >= '0' && number[0] <= '9') || + number == "¹" || number == "²" || number == "³" { + return base + " is a device name" + } + } + if strings.HasPrefix(upper, "GIT~") && strings.Trim(upper[4:], "0123456789") == "" && len(upper) > 4 { + return "GIT~N is the short name of .git" + } + return "" +} diff --git a/internal/archive/windows_names_test.go b/internal/archive/windows_names_test.go new file mode 100644 index 00000000..3eb6c04f --- /dev/null +++ b/internal/archive/windows_names_test.go @@ -0,0 +1,38 @@ +package archive + +import "testing" + +func TestWindowsPathRejectsNamesWindowsWouldReadDifferently(t *testing.T) { + for _, bad := range []string{ + `a\..\..\escape`, "C:x", "file.txt:stream", "dir/CON", "aux.c", "src/nul.txt", + "COM1", "lpt9.log", "trailing.", "trailing ", "x/GIT~1/config", "git~12", + "con .txt", "tab\tname", "what?", "star*", `quote"`, "pipe|", "lt<", "gt>", + "COM¹", "COM²", "COM³", "LPT¹", "LPT²", "LPT³", "dir/com¹", "lPt².txt", + } { + if err := checkWindowsPath(bad); err == nil { + t.Errorf("checkWindowsPath(%q) accepted it", bad) + } + } + for _, good := range []string{ + "src/main.go", ".github/workflows/ci.yml", "console.log", "auxiliary/x", "COM10", + "com", "git~", "git~x", "LPT", "a b/c d.txt", "~/tilde", "..hidden", "CONTRIBUTING.md", + "COM¹extra", "LPT²3", "COM⁴", "dir/com¹-not-a-device.txt", + } { + if err := checkWindowsPath(good); err != nil { + t.Errorf("checkWindowsPath(%q) = %v", good, err) + } + } +} + +func BenchmarkWindowsPathValidation(b *testing.B) { + for _, name := range []string{"src/main.go", ".github/workflows/ci.yml", "packages/compiler/internal/parser/expressions.go"} { + b.Run(name, func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + if err := checkWindowsPath(name); err != nil { + b.Fatal(err) + } + } + }) + } +} diff --git a/internal/changes/access.go b/internal/changes/access.go index 84d5994a..2e42b5bc 100644 --- a/internal/changes/access.go +++ b/internal/changes/access.go @@ -13,6 +13,7 @@ import ( "strings" "github.com/lydakis/errand/internal/fsidentity" + "github.com/lydakis/errand/internal/fsmode" "github.com/lydakis/errand/internal/proto" ) @@ -152,7 +153,7 @@ func makeTreeAccessibleAtRootFilteredContext( access.regular += info.Size() } } - mode := info.Mode().Perm() + mode := fs.FileMode(fsmode.Perm(info)) physical := mode if rel != "." { switch { @@ -174,7 +175,7 @@ func makeTreeAccessibleAtRootFilteredContext( } afterIdentity, identityErr := fsidentity.FromInfo(after) if identityErr != nil || afterIdentity != identity || - after.Mode().Type() != info.Mode().Type() || after.Mode().Perm() != physical { + after.Mode().Type() != info.Mode().Type() || !fsmode.Matches(after, uint32(physical)) { return fmt.Errorf("workspace path %q changed while preparing change retention", rel) } } @@ -287,7 +288,7 @@ func makeManifestAccessibleContext(ctx context.Context, rootPath string, manifes } continue } - mode := info.Mode().Perm() + mode := fs.FileMode(fsmode.Perm(info)) physical := mode switch { case info.IsDir(): @@ -308,7 +309,7 @@ func makeManifestAccessibleContext(ctx context.Context, rootPath string, manifes } afterIdentity, identityErr := fsidentity.FromInfo(after) if identityErr != nil || afterIdentity != identity || - after.Mode().Type() != info.Mode().Type() || after.Mode().Perm() != physical { + after.Mode().Type() != info.Mode().Type() || !fsmode.Matches(after, uint32(physical)) { return nil, errors.Join(fmt.Errorf("workspace path %q changed while preparing change retention", rel), access.restore()) } } @@ -330,7 +331,7 @@ func openAccessibleTreeRoot(rootPath string) ( if !info.IsDir() || info.Mode()&fs.ModeSymlink != 0 { return "", nil, fsidentity.Identity{}, 0, 0, false, fmt.Errorf("retained tree root is not a directory") } - original := info.Mode().Perm() + original := fs.FileMode(fsmode.Perm(info)) physical := original | 0o700 widened := physical != original if widened { @@ -338,7 +339,7 @@ func openAccessibleTreeRoot(rootPath string) ( return "", nil, fsidentity.Identity{}, 0, 0, false, err } afterIdentity, after, statErr := fsidentity.Lstat(abs) - if statErr != nil || afterIdentity != rootIdentity || !after.IsDir() || after.Mode().Perm() != physical { + if statErr != nil || afterIdentity != rootIdentity || !after.IsDir() || !fsmode.Matches(after, uint32(physical)) { return "", nil, fsidentity.Identity{}, 0, 0, false, errors.Join( fmt.Errorf("retained tree root changed while preparing change retention"), statErr, restoreTreeRootMode(abs, rootIdentity, original, true), @@ -376,7 +377,7 @@ func restoreTreeRootMode(rootPath string, identity fsidentity.Identity, mode fs. if currentIdentity != identity || !info.IsDir() || info.Mode()&fs.ModeSymlink != 0 { return fmt.Errorf("retained tree root changed while restoring change retention permissions") } - if info.Mode().Perm() == mode { + if fsmode.Matches(info, uint32(mode)) { return nil } return os.Chmod(rootPath, mode) @@ -428,12 +429,12 @@ func (a *treeAccess) restoreWithSync(syncData func(*os.File) error) error { return nil } if syncData == nil { - if info.Mode().Perm() != a.original[rel] { + if !fsmode.Matches(info, uint32(a.original[rel])) { return a.root.Chmod(rel, a.original[rel]) } return nil } - file, err := a.root.Open(rel) + file, err := openRetainedSyncFile(a.root, rel, info) if err != nil { return err } @@ -442,7 +443,7 @@ func (a *treeAccess) restoreWithSync(syncData func(*os.File) error) error { return errors.Join(fmt.Errorf("workspace path %q changed while syncing retained data", rel), err, file.Close()) } var modeErr error - if info.Mode().Perm() != a.original[rel] { + if !fsmode.Matches(info, uint32(a.original[rel])) { modeErr = file.Chmod(a.original[rel]) } return errors.Join(modeErr, syncData(file), file.Close()) @@ -559,6 +560,9 @@ func RemoveTree(rootPath string) error { identityErr, access.restore(), ) } + if err := releaseTreeForRemoval(access, rootPath); err != nil { + return errors.Join(err, access.restore()) + } removeErr := os.RemoveAll(rootPath) if removeErr != nil { return errors.Join(removeErr, access.restore()) diff --git a/internal/changes/apply.go b/internal/changes/apply.go index 3a95c61b..d9fc1c0b 100644 --- a/internal/changes/apply.go +++ b/internal/changes/apply.go @@ -14,7 +14,10 @@ import ( "strings" "github.com/lydakis/errand/internal/archive" + "github.com/lydakis/errand/internal/durable" "github.com/lydakis/errand/internal/fsidentity" + "github.com/lydakis/errand/internal/fslink" + "github.com/lydakis/errand/internal/fsmode" "github.com/lydakis/errand/internal/proto" "github.com/lydakis/errand/internal/snapshot" ) @@ -250,6 +253,10 @@ func ApplyToWorkspace( return ApplyResult{}, fmt.Errorf("materializing local merge input: %w", err) } defer oursAccess.closeWithoutRestore() + // Capture and pack physical modes first: the original journal digest must + // still detect real filesystem changes. Only the merge uses submitted bits + // that Windows cannot store. + inheritBaselineModes(bundle.BaseManifest, &oursManifest) conflicts, err := mergeChangeRoots( context.Background(), filepath.Join(trustedRoot, "base"), @@ -626,7 +633,7 @@ func captureMetadataBaselineAtRoot( if err != nil { return Baseline{}, fsidentity.Identity{}, 0, err } - mode := uint32(info.Mode().Perm()) + mode := fsmode.Perm(info) return metadataBaseline(logicalPath, mode), identity, mode, nil } @@ -666,10 +673,13 @@ func installMetadataChange(root *os.Root, item applyJournalItem) error { if err != nil { return err } - return errors.Join(dir.Sync(), dir.Close()) + return errors.Join(durable.Sync(dir), dir.Close()) } func materializeApplySnapshot(sourceRoot, destinationRoot, tempRoot string, manifest proto.Manifest) (*treeAccess, error) { + if fslink.NativeTypes { + return materializeTypedApplySnapshot(sourceRoot, destinationRoot, manifest, false) + } archiveFile, err := os.CreateTemp(tempRoot, "ours-*.tar") if err != nil { return nil, err @@ -705,6 +715,9 @@ func materializeApplySnapshot(sourceRoot, destinationRoot, tempRoot string, mani } func materializeApplySnapshotStrict(sourceRoot, destinationRoot, tempRoot string, manifest proto.Manifest) (*treeAccess, error) { + if fslink.NativeTypes { + return materializeTypedApplySnapshot(sourceRoot, destinationRoot, manifest, true) + } archiveFile, err := os.CreateTemp(tempRoot, "ours-*.tar") if err != nil { return nil, err @@ -1262,7 +1275,7 @@ func copyMergeScratch(src, dest string) error { if err != nil { return err } - return os.Symlink(target, dest) + return fslink.CreatePath(target, dest, fslink.Directory(info)) default: return fmt.Errorf("unsupported change type %v at %s", info.Mode(), src) } @@ -1324,7 +1337,7 @@ func copyPathToRootWithSync(sourceRoot, sourceRel string, root *os.Root, dest st if err != nil { return err } - if err := root.Symlink(target, dest); err != nil { + if err := fslink.Create(root, target, dest, fslink.Directory(info)); err != nil { return err } return syncApplyRootDirectoryWith(root, path.Dir(dest), synchronize) diff --git a/internal/changes/apply_group.go b/internal/changes/apply_group.go index ba9c3dae..281fec70 100644 --- a/internal/changes/apply_group.go +++ b/internal/changes/apply_group.go @@ -9,6 +9,7 @@ import ( "sort" "github.com/lydakis/errand/internal/fsidentity" + "github.com/lydakis/errand/internal/fsmode" ) // A group changes existing regular files under verified existing parents. Independent @@ -58,7 +59,7 @@ func planApplyFileGroup(destination *applyDestination, journal applyJournal, inp return nil, "non-regular-value" } // Group installation/recovery uses final physical modes directly. - if value.Mode().Perm() != merged.original[item.Path] { + if !fsmode.Matches(value, uint32(merged.original[item.Path])) { return nil, "widened-mode" } } diff --git a/internal/changes/apply_snapshot.go b/internal/changes/apply_snapshot.go new file mode 100644 index 00000000..b9426f65 --- /dev/null +++ b/internal/changes/apply_snapshot.go @@ -0,0 +1,36 @@ +package changes + +import ( + "context" + "os" + + "github.com/lydakis/errand/internal/proto" +) + +// Copy verified inputs directly rather than routing them through tar, which +// carries no Windows directory-link flag. This also avoids a temporary archive +// write and reread. Destination is disposable private merge scratch. +func materializeTypedApplySnapshot(source, destination string, manifest proto.Manifest, strict bool) (*treeAccess, error) { + tree, err := os.OpenRoot(destination) + if err != nil { + return nil, err + } + defer tree.Close() + policy := scratchMaterialization() + policy.permissions = manifestPermissions + ctx := context.Background() + if strict { + root, openErr := os.OpenRoot(source) + if openErr != nil { + return nil, openErr + } + defer root.Close() + err = materializeSourceAtRoot(ctx, root, tree, manifest, nil, policy) + } else { + err = materializeSourceTree(ctx, source, tree, manifest, policy) + } + if err != nil { + return nil, err + } + return makeTreeAccessible(destination) +} diff --git a/internal/changes/apply_sync.go b/internal/changes/apply_sync.go index 974b0d04..6230d52c 100644 --- a/internal/changes/apply_sync.go +++ b/internal/changes/apply_sync.go @@ -48,7 +48,7 @@ func synchronizeApplyBackup(root *os.Root, journal applyJournal, item applyJourn return err } if before.Mode().IsRegular() { - file, err := openSearchSourceFile(root, backup) + file, err := openApplyBackupFile(root, backup, before) if err != nil { return err } diff --git a/internal/changes/base.go b/internal/changes/base.go index 02b2aded..eac0a59e 100644 --- a/internal/changes/base.go +++ b/internal/changes/base.go @@ -55,6 +55,10 @@ func captureWorkspaceBaseContext(ctx context.Context, workspace, jobDir string, if err := ctx.Err(); err != nil { return err } + // Windows refuses to rename a directory while a handle to it is open. + if err := tree.Close(); err != nil { + return err + } if err := os.Rename(tmp, workspaceBasePath(jobDir)); err != nil { return err } diff --git a/internal/changes/changes.go b/internal/changes/changes.go index 3e0b2ffe..1ad43698 100644 --- a/internal/changes/changes.go +++ b/internal/changes/changes.go @@ -18,6 +18,7 @@ import ( "strings" "github.com/lydakis/errand/internal/fsidentity" + "github.com/lydakis/errand/internal/fsmode" "github.com/lydakis/errand/internal/manifest" "github.com/lydakis/errand/internal/pathpolicy" "github.com/lydakis/errand/internal/proto" @@ -249,7 +250,7 @@ func captureManifestAtRootBoundedContext( if err != nil { return proto.Manifest{}, false, 0, 0, err } - entry := proto.ManifestEntry{Path: current, Mode: uint32(info.Mode().Perm())} + entry := proto.ManifestEntry{Path: current, Mode: fsmode.Perm(info)} switch { case info.Mode().IsRegular(): entry.Type = proto.EntryFile @@ -260,6 +261,7 @@ func captureManifestAtRootBoundedContext( case info.Mode()&fs.ModeSymlink != 0: entry.Type = proto.EntrySymlink entry.Target, err = root.Readlink(current) + entry.Target = filepath.ToSlash(entry.Target) default: err = fmt.Errorf("unsupported change type %v at %s", info.Mode(), current) } @@ -397,6 +399,7 @@ func collectAccessibleWorkspaceChangesContext( return proto.ChangeBundle{}, false, err } access.logicalize(¤t) + inheritBaselineModes(baseline, ¤t) bundle, err := workspaceDelta(ctx, baseline, current, maxBytes) if err != nil || len(bundle.Paths) == 0 { return bundle, false, err diff --git a/internal/changes/changes_test.go b/internal/changes/changes_test.go index 61317d94..3d8b1f86 100644 --- a/internal/changes/changes_test.go +++ b/internal/changes/changes_test.go @@ -18,7 +18,6 @@ import ( "github.com/lydakis/errand/internal/fsidentity" "github.com/lydakis/errand/internal/proto" "github.com/lydakis/errand/internal/snapshot" - "golang.org/x/sys/unix" ) type testChangeRoot struct { @@ -766,6 +765,7 @@ func TestWorkspacePathDiscoveryExcludesApplyTransactions(t *testing.T) { } func TestCollectRefusesUnsupportedNodeReplacingSubmittedPath(t *testing.T) { + requireFIFOs(t) workspace := t.TempDir() jobDir := t.TempDir() if err := os.WriteFile(filepath.Join(workspace, "artifact"), []byte("submitted"), 0o600); err != nil { @@ -781,7 +781,7 @@ func TestCollectRefusesUnsupportedNodeReplacingSubmittedPath(t *testing.T) { if err := os.Remove(filepath.Join(workspace, "artifact")); err != nil { t.Fatal(err) } - if err := unix.Mkfifo(filepath.Join(workspace, "artifact"), 0o600); err != nil { + if err := mkfifo(filepath.Join(workspace, "artifact")); err != nil { t.Fatal(err) } if _, _, err := CollectWorkspaceChangesContext(context.Background(), workspace, jobDir, baseline, proto.SelectionPolicy{}, 1<<20); err == nil || @@ -791,13 +791,14 @@ func TestCollectRefusesUnsupportedNodeReplacingSubmittedPath(t *testing.T) { } func TestCollectIgnoresNewUnsupportedNodesWithoutInventingDeletes(t *testing.T) { + requireFIFOs(t) workspace := t.TempDir() jobDir := t.TempDir() baseline := proto.Manifest{} if err := CaptureWorkspaceBaseContext(context.Background(), workspace, jobDir, baseline); err != nil { t.Fatal(err) } - if err := unix.Mkfifo(filepath.Join(workspace, "events"), 0o600); err != nil { + if err := mkfifo(filepath.Join(workspace, "events")); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(workspace, "artifact"), []byte("retained"), 0o600); err != nil { diff --git a/internal/changes/directory_links_test.go b/internal/changes/directory_links_test.go new file mode 100644 index 00000000..1b0c5c48 --- /dev/null +++ b/internal/changes/directory_links_test.go @@ -0,0 +1,94 @@ +package changes + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/lydakis/errand/internal/proto" + "github.com/lydakis/errand/internal/snapshot" +) + +func TestTransferDirectorySymlinksOnWindows(t *testing.T) { + for _, tc := range []struct { + link string + blobs bool + }{{"alias", false}, {"nested/alias", false}, {"alias", true}, {"nested/alias", true}} { + link := tc.link + t.Run(fmt.Sprintf("%s/blobs=%v", link, tc.blobs), func(t *testing.T) { + source, destination := t.TempDir(), t.TempDir() + for _, root := range []string{source, destination} { + if err := os.Mkdir(filepath.Join(root, "target"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(root, "nested"), 0o755); err != nil { + t.Fatal(err) + } + for _, file := range []string{"target/value", "file"} { + if err := os.WriteFile(filepath.Join(root, filepath.FromSlash(file)), []byte(file), 0o644); err != nil { + t.Fatal(err) + } + } + } + paths := []string{"file", "nested", "target", "target/value"} + baseline, err := snapshot.Build(source, paths) + if err != nil { + t.Fatal(err) + } + identity, err := applyWorkspaceIdentity(destination) + if err != nil { + t.Fatal(err) + } + session := TransferSession{Directory: t.TempDir(), Root: destination, RootID: identity, Owner: "owner", SourceID: "sender", MaxSourceBytes: 1 << 20, MaxChangeBytes: 1 << 20} + if err := session.Initialize(t.Context(), source, baseline); err != nil { + t.Fatal(err) + } + for i, target := range []string{"target", "file", "target"} { + name := filepath.Join(source, filepath.FromSlash(link)) + if i != 0 { + if err := os.Remove(name); err != nil { + t.Fatal(err) + } + } + if filepath.Dir(link) != "." { + target = "../" + target + } + if err := os.Symlink(target, name); err != nil { + t.Fatal(err) + } + current, err := snapshot.Build(source, append(paths, link)) + if err != nil { + t.Fatal(err) + } + id := proto.NewULID() + if tc.blobs { + if err := session.Blobs().Retain(t.Context(), source, current); err != nil { + t.Fatal(err) + } + prepared, err := PrepareTransferSource(t.Context(), baseline, current, 1<<20) + if err != nil { + t.Fatal(err) + } + if _, _, err := session.StagePreparedFromBlobs(t.Context(), id, prepared); err != nil { + t.Fatal(err) + } + } else if _, _, err := session.Stage(t.Context(), id, source, current); err != nil { + t.Fatal(err) + } + if _, err := session.Apply(id, nil, false); err != nil { + t.Fatal(err) + } + baseline = current + read, want := link, "file" + if i != 1 { + read += "/value" + want = "target/value" + } + if body, err := os.ReadFile(filepath.Join(destination, filepath.FromSlash(read))); err != nil || string(body) != want { + t.Fatalf("transition %d: installed link = %q, %v; want %q", i, body, err, want) + } + } + }) + } +} diff --git a/internal/changes/export.go b/internal/changes/export.go index 5c606691..8af172c9 100644 --- a/internal/changes/export.go +++ b/internal/changes/export.go @@ -8,6 +8,7 @@ import ( "path/filepath" "strings" + "github.com/lydakis/errand/internal/durable" "github.com/lydakis/errand/internal/proto" ) @@ -79,7 +80,7 @@ func ExportRemote(stagedRoot, destination, requested string, bundle proto.Change if err := renameNoReplace(from, "tree", to, name); err != nil { return fmt.Errorf("publishing export (destination must not exist): %w", err) } - return errors.Join(to.Sync(), guard.verifyPath()) + return errors.Join(durable.Sync(to), guard.verifyPath()) } func exportManifest(bundle proto.ChangeBundle, requested string) (proto.Manifest, error) { diff --git a/internal/changes/fifo_unix_test.go b/internal/changes/fifo_unix_test.go new file mode 100644 index 00000000..28010fc1 --- /dev/null +++ b/internal/changes/fifo_unix_test.go @@ -0,0 +1,13 @@ +//go:build unix + +package changes + +import ( + "syscall" + "testing" +) + +// requireFIFOs skips tests that need named pipes in the file system. +func requireFIFOs(*testing.T) {} + +func mkfifo(path string) error { return syscall.Mkfifo(path, 0o600) } diff --git a/internal/changes/fifo_windows_test.go b/internal/changes/fifo_windows_test.go new file mode 100644 index 00000000..6fcd1650 --- /dev/null +++ b/internal/changes/fifo_windows_test.go @@ -0,0 +1,13 @@ +//go:build windows + +package changes + +import ( + "errors" + "testing" +) + +// requireFIFOs skips tests that need named pipes in the file system. +func requireFIFOs(t *testing.T) { t.Skip("Windows has no FIFOs in the file system") } + +func mkfifo(string) error { return errors.New("Windows has no FIFOs in the file system") } diff --git a/internal/changes/journal.go b/internal/changes/journal.go index 6db49108..4161122e 100644 --- a/internal/changes/journal.go +++ b/internal/changes/journal.go @@ -14,6 +14,7 @@ import ( "sort" "strings" + "github.com/lydakis/errand/internal/durable" "github.com/lydakis/errand/internal/fsidentity" "github.com/lydakis/errand/internal/proto" ) @@ -798,7 +799,7 @@ func rollbackMetadataApplyItemAtRoot(ctx context.Context, root *os.Root, item ap if err != nil { return err } - return errors.Join(dir.Sync(), dir.Close()) + return errors.Join(durable.Sync(dir), dir.Close()) } func quarantineInstalledChange(root *os.Root, changePath, quarantine string, changeParent *os.File) (bool, error) { @@ -823,7 +824,7 @@ func quarantineInstalledChange(root *os.Root, changePath, quarantine string, cha } return false, err } - return true, errors.Join(changeParent.Sync(), quarantineDir.Sync()) + return true, errors.Join(durable.Sync(changeParent), durable.Sync(quarantineDir)) } func renameApplyPathToDirectoryNoReplace(root *os.Root, from, to string, toDir *os.File) error { @@ -837,7 +838,7 @@ func renameApplyPathToDirectoryNoReplace(root *os.Root, from, to string, toDir * ); err != nil { return err } - return errors.Join(fromDir.Sync(), toDir.Sync()) + return errors.Join(durable.Sync(fromDir), durable.Sync(toDir)) } func renameApplyPathNoReplace(root *os.Root, from, to string) error { @@ -856,7 +857,7 @@ func renameApplyPathNoReplace(root *os.Root, from, to string) error { ); err != nil { return err } - return errors.Join(fromDir.Sync(), toDir.Sync()) + return errors.Join(durable.Sync(fromDir), durable.Sync(toDir)) } func renameNoReplacePreservingDirectoryMode( diff --git a/internal/changes/logical_modes.go b/internal/changes/logical_modes.go new file mode 100644 index 00000000..415e683e --- /dev/null +++ b/internal/changes/logical_modes.go @@ -0,0 +1,30 @@ +package changes + +import ( + "github.com/lydakis/errand/internal/fsmode" + "github.com/lydakis/errand/internal/proto" +) + +// inheritBaselineModes keeps each path's submitted mode where the file +// system can't store POSIX modes (Windows). Otherwise every file would come +// back as 0644 and lose its exec bit. New paths keep the logical defaults. +func inheritBaselineModes(baseline proto.Manifest, current *proto.Manifest) { + if !fsmode.Logical { + return + } + recorded := make(map[string]proto.ManifestEntry, len(baseline.Entries)) + for _, entry := range baseline.Entries { + recorded[entry.Path] = entry + } + for i := range current.Entries { + entry := ¤t.Entries[i] + prior, ok := recorded[entry.Path] + switch { + case !ok || prior.Type != entry.Type: + case entry.Type == proto.EntryFile: + entry.Mode = fsmode.Inherit(prior.Mode, entry.Mode) + default: + entry.Mode = prior.Mode + } + } +} diff --git a/internal/changes/logical_modes_test.go b/internal/changes/logical_modes_test.go new file mode 100644 index 00000000..1bda496a --- /dev/null +++ b/internal/changes/logical_modes_test.go @@ -0,0 +1,166 @@ +package changes + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/lydakis/errand/internal/fsmode" + "github.com/lydakis/errand/internal/proto" + "github.com/lydakis/errand/internal/snapshot" +) + +// A Windows runner receives modes from a Unix client but can't store them. +// Results must keep them and report only what the job changed. +func TestCollectionKeepsSubmittedModesOnWindows(t *testing.T) { + if !fsmode.Logical { + t.Skip("file system stores POSIX modes") + } + workspace := t.TempDir() + jobDir := t.TempDir() + files := map[string]string{"run.sh": "echo hi\n", "lib.txt": "v1\n", "bin/tool": "tool\n", "locked.txt": "keep\n"} + if err := os.Mkdir(filepath.Join(workspace, "bin"), 0o755); err != nil { + t.Fatal(err) + } + for name, content := range files { + if err := os.WriteFile(filepath.Join(workspace, filepath.FromSlash(name)), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + locked := filepath.Join(workspace, "locked.txt") + if err := os.Chmod(locked, 0o444); err != nil { + t.Fatal(err) + } + defer os.Chmod(locked, 0o644) + baseline, err := snapshot.Build(workspace, []string{"bin", "bin/tool", "lib.txt", "locked.txt", "run.sh"}) + if err != nil { + t.Fatal(err) + } + submitted := map[string]uint32{"bin": 0o755, "bin/tool": 0o755, "lib.txt": 0o644, "locked.txt": 0o444, "run.sh": 0o755} + for i := range baseline.Entries { + baseline.Entries[i].Mode = submitted[baseline.Entries[i].Path] + } + if err := CaptureWorkspaceBaseContext(context.Background(), workspace, jobDir, baseline); err != nil { + t.Fatal(err) + } + + if err := os.WriteFile(filepath.Join(workspace, "lib.txt"), []byte("v2\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(workspace, "bin", "new.txt"), []byte("new\n"), 0o644); err != nil { + t.Fatal(err) + } + bundle, collected, err := CollectWorkspaceChangesContext(context.Background(), workspace, jobDir, baseline, proto.SelectionPolicy{}, 1<<20) + if err != nil { + t.Fatal(err) + } + if !collected || fmt.Sprint(bundle.Paths) != "[bin/new.txt lib.txt]" { + t.Fatalf("collected = %t, paths = %v", collected, bundle.Paths) + } + want := map[string]uint32{"bin": 0o755, "bin/new.txt": 0o644, "lib.txt": 0o644} + for _, entry := range bundle.RemoteManifest.Entries { + if mode, ok := want[entry.Path]; ok && entry.Mode != mode { + t.Fatalf("remote mode for %s = %#o, want %#o", entry.Path, entry.Mode, mode) + } + } + staged := extractTestBundle(t, jobDir, bundle) + if err := VerifyExtracted(staged, bundle); err != nil { + t.Fatal(err) + } +} + +func TestApplyKeepsSubmittedModesOnWindows(t *testing.T) { + if !fsmode.Logical { + t.Skip("file system stores POSIX modes") + } + for _, tc := range []struct { + name string + delete bool + localContent string + localMode os.FileMode + baseMode uint32 + conflict bool + }{ + {name: "delete-unchanged-executable", delete: true, localContent: "\x00base", localMode: 0o644}, + {name: "update-unchanged-binary", localContent: "\x00base", localMode: 0o644}, + {name: "preserve-local-content", localContent: "\x00local", localMode: 0o644, conflict: true}, + {name: "preserve-local-readonly", delete: true, localContent: "\x00base", localMode: 0o444, conflict: true}, + {name: "delete-unchanged-readonly", delete: true, localContent: "\x00base", localMode: 0o444, baseMode: 0o444}, + } { + t.Run(tc.name, func(t *testing.T) { + ctx := context.Background() + source, job, destination := t.TempDir(), t.TempDir(), t.TempDir() + if err := os.WriteFile(filepath.Join(source, "tool"), []byte("\x00base"), 0o644); err != nil { + t.Fatal(err) + } + baseline, err := snapshot.Build(source, []string{"tool"}) + if err != nil { + t.Fatal(err) + } + baseline.Entries[0].Mode = 0o755 + if tc.baseMode != 0 { + baseline.Entries[0].Mode = tc.baseMode + if err := os.Chmod(filepath.Join(source, "tool"), os.FileMode(tc.baseMode)); err != nil { + t.Fatal(err) + } + } + if err := CaptureWorkspaceBaseContext(ctx, source, job, baseline); err != nil { + t.Fatal(err) + } + if tc.delete { + err = os.Remove(filepath.Join(source, "tool")) + } else { + err = os.WriteFile(filepath.Join(source, "tool"), []byte("\x00remote"), 0o644) + } + if err != nil { + t.Fatal(err) + } + bundle, collected, err := CollectWorkspaceChangesContext(ctx, source, job, baseline, proto.SelectionPolicy{}, 1<<20) + if err != nil || !collected { + t.Fatalf("collect = %v, %v", collected, err) + } + staged := extractTestBundle(t, job, bundle) + // Push staging restores and syncs modes before applying the bundle. + if err := SyncTransferSource(filepath.Join(staged, "base"), bundle.BaseManifest); err != nil { + t.Fatal(err) + } + if err := SyncTransferSource(filepath.Join(staged, "remote"), bundle.RemoteManifest); err != nil { + t.Fatal(err) + } + file := filepath.Join(destination, "tool") + if err := os.WriteFile(file, []byte(tc.localContent), tc.localMode); err != nil { + t.Fatal(err) + } + defer os.Chmod(file, 0o644) + result, err := Apply(staged, destination, bundle, nil, "test-owner", NewApplyTransaction(), ApplyOptions{}) + if tc.conflict { + var conflict *MergeConflictError + if !errors.As(err, &conflict) { + t.Fatalf("apply = %v, want merge conflict", err) + } + content, readErr := os.ReadFile(file) + info, statErr := os.Stat(file) + if readErr != nil || statErr != nil || string(content) != tc.localContent || info.Mode().Perm()&0o200 != tc.localMode&0o200 { + t.Fatalf("conflict changed the local file: %q, %v, %v", content, readErr, statErr) + } + return + } + if err != nil { + t.Fatal(err) + } + if err := CommitApply(destination, result.Transaction); err != nil { + t.Fatal(err) + } + if tc.delete { + if _, err := os.Stat(file); !os.IsNotExist(err) { + t.Fatalf("deleted file stat = %v", err) + } + } else if content, err := os.ReadFile(file); err != nil || string(content) != "\x00remote" { + t.Fatalf("applied content = %q, %v", content, err) + } + }) + } +} diff --git a/internal/changes/merge.go b/internal/changes/merge.go index 2ba8d53a..2c664d38 100644 --- a/internal/changes/merge.go +++ b/internal/changes/merge.go @@ -13,6 +13,8 @@ import ( "sort" "strings" + "github.com/lydakis/errand/internal/fslink" + "github.com/lydakis/errand/internal/nowindow" "github.com/lydakis/errand/internal/proto" ) @@ -271,7 +273,21 @@ func mergeTreePath( if err := os.MkdirAll(filepath.Dir(dest), 0o700); err != nil { return err } - return os.Symlink(target, dest) + directory := false + if fslink.NativeTypes { + // The merged target came from one of these verified inputs. + for _, tree := range []mergeTree{remote, ours, base} { + if tree.entries[name].Target == target { + info, err := os.Lstat(filepath.Join(tree.root, filepath.FromSlash(name))) + if err != nil { + return err + } + directory = fslink.Directory(info) + break + } + } + } + return fslink.CreatePath(target, dest, directory) } conflicts[name] = true if materializeConflicts { @@ -404,6 +420,7 @@ func mergeRegularFile( "-L", "local", "-L", "base", "-L", "remote", ours, base, remote, ) + nowindow.Hide(cmd) cmd.Stdout = out stderr := truncatingBuffer{remaining: 32 << 10} cmd.Stderr = &stderr diff --git a/internal/changes/parent_directory_unix.go b/internal/changes/parent_directory_unix.go new file mode 100644 index 00000000..5fd2c079 --- /dev/null +++ b/internal/changes/parent_directory_unix.go @@ -0,0 +1,17 @@ +//go:build unix + +package changes + +import ( + "os" + + "golang.org/x/sys/unix" +) + +func openParentDirectory(dir *os.File) (*os.File, error) { + fd, err := unix.Openat(int(dir.Fd()), "..", unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0) + if err != nil { + return nil, err + } + return os.NewFile(uintptr(fd), "transfer state ancestor"), nil +} diff --git a/internal/changes/parent_directory_windows.go b/internal/changes/parent_directory_windows.go new file mode 100644 index 00000000..9f77dd49 --- /dev/null +++ b/internal/changes/parent_directory_windows.go @@ -0,0 +1,35 @@ +//go:build windows + +package changes + +import ( + "os" + "path/filepath" + + "golang.org/x/sys/windows" +) + +// Windows has no ".." entry to open relative to a handle. Resolve the opened +// directory's final path instead, so the walk still follows the real object +// rather than the path it was reached by. +func openParentDirectory(dir *os.File) (*os.File, error) { + path, err := finalPath(windows.Handle(dir.Fd())) + if err != nil { + return nil, err + } + return os.Open(filepath.Dir(path)) +} + +func finalPath(handle windows.Handle) (string, error) { + buffer := make([]uint16, windows.MAX_PATH) + for { + n, err := windows.GetFinalPathNameByHandle(handle, &buffer[0], uint32(len(buffer)), 0) // FILE_NAME_NORMALIZED | VOLUME_NAME_DOS + if err != nil { + return "", err + } + if int(n) < len(buffer) { + return windows.UTF16ToString(buffer[:n]), nil + } + buffer = make([]uint16, n) + } +} diff --git a/internal/changes/remove_other.go b/internal/changes/remove_other.go new file mode 100644 index 00000000..f8008660 --- /dev/null +++ b/internal/changes/remove_other.go @@ -0,0 +1,7 @@ +//go:build !windows + +package changes + +// Unix removes a directory while a descriptor to it is open, so the verified +// root stays open until the tree is gone. +func releaseTreeForRemoval(*treeAccess, string) error { return nil } diff --git a/internal/changes/remove_windows.go b/internal/changes/remove_windows.go new file mode 100644 index 00000000..fcb9dd22 --- /dev/null +++ b/internal/changes/remove_windows.go @@ -0,0 +1,48 @@ +//go:build windows + +package changes + +import ( + "fmt" + "io/fs" + + "github.com/lydakis/errand/internal/fsidentity" +) + +// releaseTreeForRemoval empties the tree through its verified root and closes +// that root, because Windows cannot delete a directory while a handle to it is +// open. The caller then removes the empty root by path, which is checked to +// still be the same directory. +func releaseTreeForRemoval(access *treeAccess, rootPath string) error { + dir, err := access.root.Open(".") + if err != nil { + return err + } + names, err := dir.Readdirnames(-1) + if closeErr := dir.Close(); err == nil { + err = closeErr + } + if err != nil { + return err + } + for _, name := range names { + if err := access.root.RemoveAll(name); err != nil { + return err + } + } + if !access.ownsRoot { + return nil + } + if err := access.root.Close(); err != nil { + return err + } + access.ownsRoot = false + identity, info, err := fsidentity.Lstat(rootPath) + if err != nil { + return err + } + if !info.IsDir() || info.Mode()&fs.ModeSymlink != 0 || identity != access.rootIdentity { + return fmt.Errorf("retained tree root changed during removal") + } + return nil +} diff --git a/internal/changes/remove_windows_test.go b/internal/changes/remove_windows_test.go new file mode 100644 index 00000000..8c55f7e1 --- /dev/null +++ b/internal/changes/remove_windows_test.go @@ -0,0 +1,27 @@ +//go:build windows + +package changes + +import ( + "os" + "path/filepath" + "testing" +) + +// Windows refuses to delete a directory while a handle to it is open, so +// removal must release the tree's root before deleting it. +func TestRemoveTreeDeletesItsRootOnWindows(t *testing.T) { + root := filepath.Join(t.TempDir(), ".change-base-partial") + if err := os.MkdirAll(filepath.Join(root, "dir"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "dir", "readonly"), []byte("x"), 0o444); err != nil { + t.Fatal(err) + } + if err := RemoveTree(root); err != nil { + t.Fatal(err) + } + if _, err := os.Lstat(root); !os.IsNotExist(err) { + t.Fatalf("tree root survived removal: %v", err) + } +} diff --git a/internal/changes/rename_noreplace_other.go b/internal/changes/rename_noreplace_other.go index 1b0af2fb..bbf0ddbd 100644 --- a/internal/changes/rename_noreplace_other.go +++ b/internal/changes/rename_noreplace_other.go @@ -1,4 +1,4 @@ -//go:build !darwin && !linux +//go:build !darwin && !linux && !windows package changes diff --git a/internal/changes/rename_noreplace_windows.go b/internal/changes/rename_noreplace_windows.go new file mode 100644 index 00000000..358cddf1 --- /dev/null +++ b/internal/changes/rename_noreplace_windows.go @@ -0,0 +1,91 @@ +//go:build windows + +package changes + +import ( + "errors" + "os" + "unsafe" + + "golang.org/x/sys/windows" +) + +const fileRenameInformationEx = 65 + +// fileRenameInformation is FILE_RENAME_INFORMATION_EX. With Flags zero it is +// also a valid FILE_RENAME_INFORMATION with ReplaceIfExists false. +type fileRenameInformation struct { + Flags uint32 + RootDirectory windows.Handle + FileNameLength uint32 + FileName [1]uint16 +} + +// renameNoReplace renames relative to open directory handles, like renameat2 +// with RENAME_NOREPLACE, so a parent swapped by path cannot redirect it. +func renameNoReplace(fromDir *os.File, from string, toDir *os.File, to string) error { + source, err := openForRename(fromDir, from) + if err != nil { + return &os.LinkError{Op: "rename", Old: from, New: to, Err: err} + } + defer windows.CloseHandle(source) + + name, err := windows.UTF16FromString(to) + if err != nil { + return &os.LinkError{Op: "rename", Old: from, New: to, Err: err} + } + name = name[:len(name)-1] + size := int(unsafe.Offsetof(fileRenameInformation{}.FileName)) + len(name)*2 + buffer := make([]byte, max(size, int(unsafe.Sizeof(fileRenameInformation{})))) + info := (*fileRenameInformation)(unsafe.Pointer(&buffer[0])) + info.RootDirectory = windows.Handle(toDir.Fd()) + info.FileNameLength = uint32(len(name) * 2) + copy(unsafe.Slice(&info.FileName[0], len(name)), name) + + // POSIX semantics let the rename proceed while other handles are open. File + // systems without FILE_RENAME_INFORMATION_EX get the classic request. + info.Flags = windows.FILE_RENAME_POSIX_SEMANTICS + err = windows.NtSetInformationFile(source, new(windows.IO_STATUS_BLOCK), &buffer[0], uint32(len(buffer)), fileRenameInformationEx) + if errors.Is(err, windows.STATUS_INVALID_INFO_CLASS) || errors.Is(err, windows.STATUS_INVALID_PARAMETER) || errors.Is(err, windows.STATUS_NOT_SUPPORTED) { + info.Flags = 0 + err = windows.NtSetInformationFile(source, new(windows.IO_STATUS_BLOCK), &buffer[0], uint32(len(buffer)), windows.FileRenameInformation) + } + if err != nil { + return &os.LinkError{Op: "rename", Old: from, New: to, Err: ntError(err)} + } + return nil +} + +func openForRename(dir *os.File, name string) (windows.Handle, error) { + objectName, err := windows.NewNTUnicodeString(name) + if err != nil { + return 0, err + } + attributes := windows.OBJECT_ATTRIBUTES{RootDirectory: windows.Handle(dir.Fd()), ObjectName: objectName} + attributes.Length = uint32(unsafe.Sizeof(attributes)) + var handle windows.Handle + err = windows.NtCreateFile( + &handle, + windows.SYNCHRONIZE|windows.DELETE, + &attributes, + new(windows.IO_STATUS_BLOCK), + nil, + 0, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + windows.FILE_OPEN, + windows.FILE_OPEN_REPARSE_POINT|windows.FILE_OPEN_FOR_BACKUP_INTENT|windows.FILE_SYNCHRONOUS_IO_NONALERT, + 0, + 0, + ) + if err != nil { + return 0, ntError(err) + } + return handle, nil +} + +func ntError(err error) error { + if status, ok := err.(windows.NTStatus); ok { + return status.Errno() + } + return err +} diff --git a/internal/changes/source_prepared.go b/internal/changes/source_prepared.go index 062b1f9b..b8d75298 100644 --- a/internal/changes/source_prepared.go +++ b/internal/changes/source_prepared.go @@ -5,6 +5,7 @@ import ( "fmt" "slices" + "github.com/lydakis/errand/internal/fslink" "github.com/lydakis/errand/internal/manifest" "github.com/lydakis/errand/internal/proto" ) @@ -117,7 +118,19 @@ func (s *TransferSession) StagePreparedFromBlobs(ctx context.Context, id string, if !p.valid { return "", proto.ChangeBundle{}, fmt.Errorf("transfer source was not prepared") } - return s.stage(ctx, id, s.retainedSource(), proto.Manifest{}, &p) + var lookup fslink.Lookup + if fslink.NativeTypes { + lookup = func(name string) (proto.ManifestEntry, bool) { + if entry, ok := p.snapshot.Lookup(name); ok { + return entry, true + } + // Inspect only the first indexed descendant for implicit directories. + found := false + _ = p.snapshot.Subtree(ctx, name, func(proto.ManifestEntry) bool { found = true; return false }) + return proto.ManifestEntry{Path: name, Type: proto.EntryDir}, found + } + } + return s.stage(ctx, id, s.retainedSource(lookup), proto.Manifest{}, &p) } func (p PreparedTransferSource) validateStageLimits(ctx context.Context, s *TransferSession) error { diff --git a/internal/changes/staging_sync.go b/internal/changes/staging_sync.go index e1bb2e75..c278ee1f 100644 --- a/internal/changes/staging_sync.go +++ b/internal/changes/staging_sync.go @@ -1,10 +1,14 @@ package changes -import "os" +import ( + "os" + + "github.com/lydakis/errand/internal/durable" +) // syncStagingBarrier completes the durability phase after syncStagedData on // members of the same filesystem. On Darwin File.Sync requests F_FULLFSYNC, // draining the device cache for preceding member fsyncs. Other systems use // their normal fsync semantics. Finish this before exposing prepared data under // durable names, then sync the containing directory again after those renames. -func syncStagingBarrier(file *os.File) error { return file.Sync() } +func syncStagingBarrier(file *os.File) error { return durable.Sync(file) } diff --git a/internal/changes/staging_sync_other.go b/internal/changes/staging_sync_other.go index 511d6156..cb95718c 100644 --- a/internal/changes/staging_sync_other.go +++ b/internal/changes/staging_sync_other.go @@ -2,8 +2,13 @@ package changes -import "os" +import ( + "os" + "github.com/lydakis/errand/internal/durable" +) + +// Staged members include directories, which Windows can't flush. func syncStagedData(file *os.File) error { - return file.Sync() + return durable.Sync(file) } diff --git a/internal/changes/symlink_targets_test.go b/internal/changes/symlink_targets_test.go new file mode 100644 index 00000000..8c981d4b --- /dev/null +++ b/internal/changes/symlink_targets_test.go @@ -0,0 +1,87 @@ +package changes + +import ( + "context" + "os" + "path/filepath" + "reflect" + "runtime" + "testing" + + "github.com/lydakis/errand/internal/proto" + "github.com/lydakis/errand/internal/snapshot" +) + +func TestSymlinkTargetsCollectOnWindows(t *testing.T) { + ctx := context.Background() + workspace, job := t.TempDir(), t.TempDir() + if err := os.Mkdir(filepath.Join(workspace, "src"), 0o755); err != nil { + t.Fatal(err) + } + for _, name := range []string{"file", "other"} { + if err := os.WriteFile(filepath.Join(workspace, "src", name), []byte(name), 0o644); err != nil { + t.Fatal(err) + } + } + targets := map[string]string{"link": "src/file", "src/link": "../src/file", "dangling": "missing/file"} + if runtime.GOOS != "windows" { + targets["literal"] = `src\file` + } + paths := []string{"src", "src/file", "src/other"} + for name, target := range targets { + if err := os.Symlink(target, filepath.Join(workspace, filepath.FromSlash(name))); err != nil { + t.Fatal(err) + } + paths = append(paths, name) + } + baseline, err := snapshot.Build(workspace, paths) + if err != nil { + t.Fatal(err) + } + // Use the Unix sender's target spelling even on Windows. + for i := range baseline.Entries { + if target, ok := targets[baseline.Entries[i].Path]; ok { + baseline.Entries[i].Target = target + } + } + if err := CaptureWorkspaceBaseContext(ctx, workspace, job, baseline); err != nil { + t.Fatal(err) + } + if bundle, collected, err := CollectWorkspaceChangesContext(ctx, workspace, job, baseline, proto.SelectionPolicy{}, 1<<20); err != nil || collected { + t.Fatalf("unchanged links: collected = %t, bundle = %+v, err = %v", collected, bundle, err) + } + if err := os.Remove(filepath.Join(workspace, "link")); err != nil { + t.Fatal(err) + } + if err := os.Symlink("src/other", filepath.Join(workspace, "link")); err != nil { + t.Fatal(err) + } + if err := os.Symlink("../src/file", filepath.Join(workspace, "src", "new-link")); err != nil { + t.Fatal(err) + } + bundle, collected, err := CollectWorkspaceChangesContext(ctx, workspace, job, baseline, proto.SelectionPolicy{}, 1<<20) + if err != nil { + t.Fatal(err) + } + if !collected || !reflect.DeepEqual(bundle.Paths, []string{"link", "src/new-link"}) { + t.Fatalf("changed links: collected = %t, paths = %v", collected, bundle.Paths) + } + for _, e := range bundle.RemoteManifest.Entries { + if e.Path == "link" && e.Target != "src/other" || e.Path == "src/new-link" && e.Target != "../src/file" { + t.Fatalf("remote target for %s = %q", e.Path, e.Target) + } + } + root, err := os.OpenRoot(workspace) + if err != nil { + t.Fatal(err) + } + defer root.Close() + captured, _, _, _, err := captureManifestAtRootBoundedContext(ctx, root, "src/new-link", "src/new-link", -1, -1) + if err != nil || len(captured.Entries) != 1 || captured.Entries[0].Target != "../src/file" { + t.Fatalf("captured link = %+v, err = %v", captured, err) + } + staged := extractTestBundle(t, job, bundle) + if err := VerifyExtracted(staged, bundle); err != nil { + t.Fatal(err) + } +} diff --git a/internal/changes/sync_open_other.go b/internal/changes/sync_open_other.go new file mode 100644 index 00000000..13d4fa1f --- /dev/null +++ b/internal/changes/sync_open_other.go @@ -0,0 +1,13 @@ +//go:build !windows + +package changes + +import "os" + +func openApplyBackupFile(root *os.Root, name string, _ os.FileInfo) (*os.File, error) { + return openSearchSourceFile(root, name) +} + +func openRetainedSyncFile(root *os.Root, name string, _ os.FileInfo) (*os.File, error) { + return root.Open(name) +} diff --git a/internal/changes/sync_open_windows.go b/internal/changes/sync_open_windows.go new file mode 100644 index 00000000..90f94cc0 --- /dev/null +++ b/internal/changes/sync_open_windows.go @@ -0,0 +1,69 @@ +//go:build windows + +package changes + +import ( + "errors" + "fmt" + "os" + + "golang.org/x/sys/windows" +) + +var procReOpenFile = windows.NewLazySystemDLL("kernel32.dll").NewProc("ReOpenFile") + +func openApplyBackupFile(root *os.Root, name string, info os.FileInfo) (*os.File, error) { + return openRetainedSyncFile(root, name, info) +} + +// FlushFileBuffers needs write access. These paths are private staging or +// renamed backups; callers verify the opened identity before flushing. Keep +// the ordinary writable-file path to a single open and preserve read-only +// attributes when obtaining a writable handle to an existing read-only file. +func openRetainedSyncFile(root *os.Root, name string, info os.FileInfo) (*os.File, error) { + if info.IsDir() { + return root.Open(name) + } + if info.Mode().Perm()&0o200 != 0 { + return root.OpenFile(name, os.O_RDWR, 0) + } + file, err := root.Open(name) + if err != nil { + return nil, err + } + defer file.Close() + opened, err := file.Stat() + if err != nil || !opened.Mode().IsRegular() || !os.SameFile(info, opened) || opened.Mode() != info.Mode() { + return nil, errors.Join(fmt.Errorf("retained path %q changed while opening for sync", name), err) + } + attributes, err := reopenSyncFile(file, windows.FILE_READ_ATTRIBUTES|windows.FILE_WRITE_ATTRIBUTES) + if err != nil { + return nil, err + } + defer attributes.Close() + mode := info.Mode().Perm() + if err := attributes.Chmod(mode | 0o200); err != nil { + return nil, err + } + writable, openErr := reopenSyncFile(file, windows.GENERIC_READ|windows.GENERIC_WRITE) + // Restore through the pinned metadata handle even if reopening fails. The + // writable handle keeps its granted access after the attribute is restored. + restoreErr := attributes.Chmod(mode) + if err := errors.Join(openErr, restoreErr); err != nil { + if writable != nil { + err = errors.Join(err, writable.Close()) + } + return nil, err + } + return writable, nil +} + +func reopenSyncFile(file *os.File, access uint32) (*os.File, error) { + handle, _, err := procReOpenFile.Call(file.Fd(), uintptr(access), + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + windows.FILE_FLAG_BACKUP_SEMANTICS|windows.FILE_FLAG_OPEN_REPARSE_POINT) + if windows.Handle(handle) == windows.InvalidHandle { + return nil, &os.PathError{Op: "reopen for sync", Path: file.Name(), Err: err} + } + return os.NewFile(handle, file.Name()), nil +} diff --git a/internal/changes/transfer.go b/internal/changes/transfer.go index a3588c33..1c9118fb 100644 --- a/internal/changes/transfer.go +++ b/internal/changes/transfer.go @@ -14,7 +14,6 @@ import ( "github.com/lydakis/errand/internal/fsidentity" "github.com/lydakis/errand/internal/proto" - "golang.org/x/sys/unix" ) // TransferTarget records one immutable apply request in private receiver state. @@ -285,11 +284,10 @@ func transferStorageOutsideWorkspace(storage *os.Root, workspace fsidentity.Iden if id == workspace { return fmt.Errorf("transfer state must be outside the destination workspace") } - fd, err := unix.Openat(int(current.Fd()), "..", unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0) + parent, err := openParentDirectory(current) if err != nil { return err } - parent := os.NewFile(uintptr(fd), "transfer state ancestor") parentInfo, err := parent.Stat() if err != nil { parent.Close() diff --git a/internal/changes/transfer_blob_base.go b/internal/changes/transfer_blob_base.go index 32dd7352..7079a29f 100644 --- a/internal/changes/transfer_blob_base.go +++ b/internal/changes/transfer_blob_base.go @@ -8,6 +8,7 @@ import ( "os" "strings" + "github.com/lydakis/errand/internal/fslink" "github.com/lydakis/errand/internal/proto" ) @@ -75,7 +76,15 @@ func (s TransferBlobStore) MaterializeBase(ctx context.Context, jobDir string, m } func materializeTransferBase(ctx context.Context, storage, tree *os.Root, manifest proto.Manifest) error { - return materializeTransferTree(ctx, tree, manifest, durableMaterialization(func() error { return syncApplyRootDirectory(tree, ".") }), func(e proto.ManifestEntry) (io.ReadCloser, error) { + return materializeTransferBaseWithLinks(ctx, storage, tree, manifest, nil) +} + +func materializeTransferBaseWithLinks(ctx context.Context, storage, tree *os.Root, manifest proto.Manifest, lookup fslink.Lookup) error { + policy := durableMaterialization(func() error { return syncApplyRootDirectory(tree, ".") }) + if fslink.NativeTypes && lookup != nil { + policy.linkDirectory = func(e proto.ManifestEntry) bool { return fslink.IsDirectory(e, lookup) } + } + return materializeTransferTree(ctx, tree, manifest, policy, func(e proto.ManifestEntry) (io.ReadCloser, error) { return openTransferBlob(storage, strings.ToLower(e.SHA256), e) }) } diff --git a/internal/changes/transfer_blob_tree.go b/internal/changes/transfer_blob_tree.go index 17bdb747..34fe14a6 100644 --- a/internal/changes/transfer_blob_tree.go +++ b/internal/changes/transfer_blob_tree.go @@ -5,6 +5,7 @@ import ( "fmt" "os" + "github.com/lydakis/errand/internal/fslink" "github.com/lydakis/errand/internal/proto" ) @@ -30,7 +31,7 @@ func validateTransferMaterialization(manifest proto.Manifest, maxBytes int64) er // materializePrivate builds an unpublished member of a caller-owned transaction. // The caller cleans up on failure and publishes the containing directory only // after all members are durable. No standalone change-base name is published. -func (s TransferBlobStore) materializePrivate(ctx context.Context, dest string, m proto.Manifest, maxBytes int64) error { +func (s TransferBlobStore) materializePrivate(ctx context.Context, dest string, m proto.Manifest, maxBytes int64, lookup fslink.Lookup) error { if err := ctx.Err(); err != nil { return err } @@ -56,7 +57,7 @@ func (s TransferBlobStore) materializePrivate(ctx context.Context, dest string, if err := transferStorageOutsideWorkspace(tree.root, storage.identity); err != nil { return err } - if err := materializeTransferBase(ctx, storage.root, tree.root, m); err != nil { + if err := materializeTransferBaseWithLinks(ctx, storage.root, tree.root, m, lookup); err != nil { return err } return verifyTransferPaths(storage, tree) diff --git a/internal/changes/transfer_materialize.go b/internal/changes/transfer_materialize.go index 1919bdf1..2e5d47d8 100644 --- a/internal/changes/transfer_materialize.go +++ b/internal/changes/transfer_materialize.go @@ -7,6 +7,7 @@ import ( "os" "path" + "github.com/lydakis/errand/internal/fslink" "github.com/lydakis/errand/internal/proto" ) @@ -22,10 +23,11 @@ const ( // materializationPolicy keeps the permission and durability choices together. // Clone support is optional; every path verifies the resulting bytes before sync. type materializationPolicy struct { - permissions treePermissions - cloneFiles bool - syncData func(*os.File) error - barrier func() error + linkDirectory func(proto.ManifestEntry) bool + permissions treePermissions + cloneFiles bool + syncData func(*os.File) error + barrier func() error } type materializedDirectory struct { @@ -112,11 +114,19 @@ func materializeTransferTree(ctx context.Context, tree *os.Root, manifest proto. return err } // No file or implicit directory creation can traverse a link we created. + if fslink.NativeTypes && len(symlinks) != 0 && policy.linkDirectory == nil { + lookup := fslink.ManifestLookup(manifest) + policy.linkDirectory = func(e proto.ManifestEntry) bool { return fslink.IsDirectory(e, lookup) } + } for _, e := range symlinks { if err := ctx.Err(); err != nil { return err } - if err := tree.Symlink(e.Target, e.Path); err != nil { + directory := false + if fslink.NativeTypes { + directory = policy.linkDirectory(e) + } + if err := fslink.Create(tree, e.Target, e.Path, directory); err != nil { return err } } diff --git a/internal/changes/transfer_session.go b/internal/changes/transfer_session.go index 9da30c9e..5adba002 100644 --- a/internal/changes/transfer_session.go +++ b/internal/changes/transfer_session.go @@ -14,6 +14,7 @@ import ( "time" "github.com/lydakis/errand/internal/fsidentity" + "github.com/lydakis/errand/internal/fslink" "github.com/lydakis/errand/internal/proto" ) @@ -172,7 +173,16 @@ func (s *TransferSession) stage(ctx context.Context, id string, source transferM return "", b, err } defer RemoveTree(tmp) - if err := s.materializeStage(ctx, source, tmp, b); err != nil { + var baseLinks fslink.Lookup + if fslink.NativeTypes { + for _, entry := range b.BaseManifest.Entries { + if entry.Type == proto.EntrySymlink { + baseLinks = fslink.ManifestLookup(v.state.Manifest) + break + } + } + } + if err := s.materializeStage(ctx, source, tmp, b, baseLinks); err != nil { return "", b, err } a := TransferAttempt{ID: id, Revision: v.revision(), SourceRoot: sourceRoot, BundleRoot: b.RootHash(), CreatedAt: time.Now().UTC()} diff --git a/internal/changes/transfer_stage.go b/internal/changes/transfer_stage.go index cb0568a5..902c902c 100644 --- a/internal/changes/transfer_stage.go +++ b/internal/changes/transfer_stage.go @@ -8,6 +8,8 @@ import ( "os" "path/filepath" + "github.com/lydakis/errand/internal/fslink" + "github.com/lydakis/errand/internal/fsmode" "github.com/lydakis/errand/internal/proto" ) @@ -21,13 +23,13 @@ func transferDirectorySource(source string) transferMaterializer { } } -func (s *TransferSession) retainedSource() transferMaterializer { +func (s *TransferSession) retainedSource(lookup fslink.Lookup) transferMaterializer { return func(ctx context.Context, dest string, m proto.Manifest) error { - return s.Blobs().materializePrivate(ctx, dest, m, s.MaxSourceBytes) + return s.Blobs().materializePrivate(ctx, dest, m, s.MaxSourceBytes, lookup) } } -func (s *TransferSession) materializeStage(ctx context.Context, source transferMaterializer, dir string, b proto.ChangeBundle) error { +func (s *TransferSession) materializeStage(ctx context.Context, source transferMaterializer, dir string, b proto.ChangeBundle, baseLinks fslink.Lookup) error { metadata, err := marshalBundle(b) if err != nil { return err @@ -35,7 +37,7 @@ func (s *TransferSession) materializeStage(ctx context.Context, source transferM if b.Bytes > s.MaxChangeBytes { return ErrByteLimitExceeded } - if err := s.retainedSource()(ctx, filepath.Join(dir, "base"), b.BaseManifest); err != nil { + if err := s.retainedSource(baseLinks)(ctx, filepath.Join(dir, "base"), b.BaseManifest); err != nil { return err } if err := source(ctx, filepath.Join(dir, "remote"), b.RemoteManifest); err != nil { @@ -82,6 +84,7 @@ func materializeSourceAtRoot(ctx context.Context, source, tree *os.Root, m proto } return physical[e.Path] } + var linkDirectories map[string]bool for _, e := range m.Entries { if err := ctx.Err(); err != nil { return err @@ -91,10 +94,16 @@ func materializeSourceAtRoot(ctx context.Context, source, tree *os.Root, m proto if e.Type == proto.EntryFile { continue } - if err := checkMaterializationSource(&paths, e, mode(e)); err != nil { + if fslink.NativeTypes && e.Type == proto.EntrySymlink && linkDirectories == nil { + linkDirectories = make(map[string]bool) + } + if err := checkMaterializationSource(&paths, e, mode(e), linkDirectories); err != nil { return err } } + if fslink.NativeTypes && linkDirectories != nil { + policy.linkDirectory = func(e proto.ManifestEntry) bool { return linkDirectories[e.Path] } + } return materializeTransferTree(ctx, tree, m, policy, func(e proto.ManifestEntry) (io.ReadCloser, error) { parent, name, lease, err := paths.parent(e.Path) defer paths.release(lease) @@ -117,7 +126,7 @@ func materializeSourceAtRoot(ctx context.Context, source, tree *os.Root, m proto return nil, err } opened, err := f.Stat() - if err != nil || !opened.Mode().IsRegular() || (info != nil && !os.SameFile(info, opened)) || opened.Size() != e.Size || uint32(opened.Mode().Perm()) != mode(e) { + if err != nil || !opened.Mode().IsRegular() || (info != nil && !os.SameFile(info, opened)) || opened.Size() != e.Size || !fsmode.Matches(opened, mode(e)) { return nil, errors.Join(fmt.Errorf("transfer source %q changed while opening", e.Path), f.Close()) } return &transferSourceReader{File: f, entry: e, mode: mode(e)}, nil @@ -132,7 +141,7 @@ type transferSourceReader struct { func (r *transferSourceReader) Close() error { info, err := r.Stat() - if err == nil && (!info.Mode().IsRegular() || info.Size() != r.entry.Size || uint32(info.Mode().Perm()) != r.mode) { + if err == nil && (!info.Mode().IsRegular() || info.Size() != r.entry.Size || !fsmode.Matches(info, r.mode)) { err = fmt.Errorf("transfer source %q changed while copying", r.entry.Path) } return errors.Join(err, r.File.Close()) @@ -142,7 +151,7 @@ func (r *transferSourceReader) cloneTo(tree *os.Root, name string) (*os.File, er return cloneFileInto(r.File, tree, name) } -func checkMaterializationSource(paths *materializationPaths, e proto.ManifestEntry, mode uint32) error { +func checkMaterializationSource(paths *materializationPaths, e proto.ManifestEntry, mode uint32, linkDirectories map[string]bool) error { parent, name, lease, err := paths.parent(e.Path) defer paths.release(lease) var info os.FileInfo @@ -155,7 +164,7 @@ func checkMaterializationSource(paths *materializationPaths, e proto.ManifestEnt if err != nil { return err } - if uint32(info.Mode().Perm()) != mode { + if !fsmode.Matches(info, mode) { return fmt.Errorf("transfer source %q changed mode", e.Path) } switch e.Type { @@ -171,9 +180,12 @@ func checkMaterializationSource(paths *materializationPaths, e proto.ManifestEnt if err != nil { return err } - if target != e.Target { + if filepath.ToSlash(target) != e.Target { return fmt.Errorf("transfer source %q changed target", e.Path) } + if fslink.NativeTypes { + linkDirectories[e.Path] = fslink.Directory(info) + } } return nil } diff --git a/internal/client/automatic_apply.go b/internal/client/automatic_apply.go index 335bde05..eb16a99b 100644 --- a/internal/client/automatic_apply.go +++ b/internal/client/automatic_apply.go @@ -10,7 +10,6 @@ import ( "os" "os/exec" "strings" - "syscall" "time" "github.com/lydakis/errand/internal/proto" @@ -80,7 +79,7 @@ func startAutomaticApplyWorkerProcess(peerURL, jobID string) error { cmd.Stdin = null cmd.Stdout = null cmd.Stderr = null - cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} + detachWorker(cmd) if err := cmd.Start(); err != nil { return err } diff --git a/internal/client/automatic_apply_inspect.go b/internal/client/automatic_apply_inspect.go index 5d782eab..7f3ff971 100644 --- a/internal/client/automatic_apply_inspect.go +++ b/internal/client/automatic_apply_inspect.go @@ -7,8 +7,8 @@ import ( "os" "path/filepath" "strings" - "syscall" + "github.com/lydakis/errand/internal/filelock" "github.com/lydakis/errand/internal/proto" ) @@ -83,10 +83,13 @@ func automaticApplyWorkerActive(peerURL, jobID string) (bool, error) { defer f.Close() // Shared inspection locks are distinguishable from an exclusive worker // lease, so a starting worker waits for readers rather than exiting. - err = syscall.Flock(int(f.Fd()), syscall.LOCK_SH|syscall.LOCK_NB) - if errors.Is(err, syscall.EWOULDBLOCK) || errors.Is(err, syscall.EAGAIN) { + err = filelock.TryRLock(f) + if errors.Is(err, filelock.ErrLocked) { return true, nil } + if err == nil { + err = filelock.Unlock(f) + } return false, err } diff --git a/internal/client/automatic_apply_inspect_test.go b/internal/client/automatic_apply_inspect_test.go index 535c8b32..bc434bbc 100644 --- a/internal/client/automatic_apply_inspect_test.go +++ b/internal/client/automatic_apply_inspect_test.go @@ -3,10 +3,10 @@ package client import ( "os" "path/filepath" - "syscall" "testing" "time" + "github.com/lydakis/errand/internal/filelock" "github.com/lydakis/errand/internal/proto" ) @@ -30,7 +30,7 @@ func TestAutomaticApplyWorkerDoesNotMistakeInspectorForOwner(t *testing.T) { t.Fatal(err) } defer f.Close() - if err := syscall.Flock(int(f.Fd()), syscall.LOCK_SH); err != nil { + if err := filelock.TryRLock(f); err != nil { t.Fatal(err) } type result struct { @@ -50,7 +50,7 @@ func TestAutomaticApplyWorkerDoesNotMistakeInspectorForOwner(t *testing.T) { t.Fatalf("worker gave up while inspector held lock: %+v", result) case <-time.After(20 * time.Millisecond): } - if err := syscall.Flock(int(f.Fd()), syscall.LOCK_UN); err != nil { + if err := filelock.Unlock(f); err != nil { t.Fatal(err) } select { @@ -121,7 +121,7 @@ func TestWorkerLeaseKeepsOneInodeAcrossOwners(t *testing.T) { if err != nil || !os.SameFile(before, after) { t.Fatalf("lease inode changed: %v", err) } - if err := syscall.Flock(int(observer.Fd()), syscall.LOCK_SH|syscall.LOCK_NB); err == nil { + if err := filelock.TryRLock(observer); err == nil { t.Fatal("observer missed the replacement owner") } } diff --git a/internal/client/automatic_apply_unix.go b/internal/client/automatic_apply_unix.go new file mode 100644 index 00000000..32133758 --- /dev/null +++ b/internal/client/automatic_apply_unix.go @@ -0,0 +1,14 @@ +//go:build unix + +package client + +import ( + "os/exec" + "syscall" +) + +// detachWorker starts the worker in its own session, so it survives the +// terminal that launched it. +func detachWorker(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} +} diff --git a/internal/client/automatic_apply_windows.go b/internal/client/automatic_apply_windows.go new file mode 100644 index 00000000..93fc7308 --- /dev/null +++ b/internal/client/automatic_apply_windows.go @@ -0,0 +1,16 @@ +//go:build windows + +package client + +import ( + "os/exec" + "syscall" + + "golang.org/x/sys/windows" +) + +// detachWorker starts the worker without the launching console, so closing +// that console or pressing Ctrl-C there does not stop it. +func detachWorker(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: windows.CREATE_NEW_PROCESS_GROUP | windows.DETACHED_PROCESS} +} diff --git a/internal/client/change_state.go b/internal/client/change_state.go index 57236a3e..2e7478db 100644 --- a/internal/client/change_state.go +++ b/internal/client/change_state.go @@ -11,11 +11,13 @@ import ( "os" "path/filepath" "strings" - "syscall" "time" changeops "github.com/lydakis/errand/internal/changes" + "github.com/lydakis/errand/internal/durable" + "github.com/lydakis/errand/internal/filelock" "github.com/lydakis/errand/internal/fsidentity" + "github.com/lydakis/errand/internal/fsowner" "github.com/lydakis/errand/internal/proto" ) @@ -543,14 +545,14 @@ func acquireChangeFileLockContext(ctx context.Context, f *os.File) (func(), erro f.Close() return nil, err } - err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) + err := filelock.TryLock(f) if err == nil { return func() { - _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN) + _ = filelock.Unlock(f) _ = f.Close() }, nil } - if !errors.Is(err, syscall.EWOULDBLOCK) && !errors.Is(err, syscall.EAGAIN) { + if !errors.Is(err, filelock.ErrLocked) { f.Close() return nil, err } @@ -570,15 +572,15 @@ func tryAcquireLocalChangeLock(name string) (func(), bool, error) { if err != nil { return nil, false, err } - if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { + if err := filelock.TryLock(f); err != nil { f.Close() - if errors.Is(err, syscall.EWOULDBLOCK) || errors.Is(err, syscall.EAGAIN) { + if errors.Is(err, filelock.ErrLocked) { return nil, false, nil } return nil, false, err } return func() { - _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN) + _ = filelock.Unlock(f) _ = f.Close() }, true, nil } @@ -595,15 +597,15 @@ func tryAcquireExistingLocalChangeLock(name string) (func(), bool, error) { if err != nil { return nil, false, err } - if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { + if err := filelock.TryLock(f); err != nil { f.Close() - if errors.Is(err, syscall.EWOULDBLOCK) || errors.Is(err, syscall.EAGAIN) { + if errors.Is(err, filelock.ErrLocked) { return nil, false, nil } return nil, false, err } return func() { - _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN) + _ = filelock.Unlock(f) _ = f.Close() }, true, nil } @@ -617,25 +619,25 @@ func tryAcquireLocalChangeLease(name string) (func(), bool, error) { // probe. Give a descheduled inspector time to close without busy-waiting. deadline := time.Now().Add(15 * time.Second) for { - err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) + err := filelock.TryLock(f) if err == nil { break } - if !errors.Is(err, syscall.EWOULDBLOCK) && !errors.Is(err, syscall.EAGAIN) { + if !errors.Is(err, filelock.ErrLocked) { f.Close() return nil, false, err } // Exclusive contention means another worker owns the job. Shared // contention is only a read-only inspection; let it finish and retry. - err = syscall.Flock(int(f.Fd()), syscall.LOCK_SH|syscall.LOCK_NB) + err = filelock.TryRLock(f) if err != nil { f.Close() - if errors.Is(err, syscall.EWOULDBLOCK) || errors.Is(err, syscall.EAGAIN) { + if errors.Is(err, filelock.ErrLocked) { return nil, false, nil } return nil, false, err } - _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN) + _ = filelock.Unlock(f) if time.Now().After(deadline) { f.Close() return nil, false, context.DeadlineExceeded @@ -645,7 +647,7 @@ func tryAcquireLocalChangeLease(name string) (func(), bool, error) { return func() { // Keep the inode stable: an inspector or a starting worker may already // have this file open. Unlinking would split ownership across inodes. - _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN) + _ = filelock.Unlock(f) _ = f.Close() }, true, nil } @@ -738,11 +740,11 @@ func ensurePrivateLocalDirectoryWithChmod( if dirIdentity != pathIdentity { return fmt.Errorf("local state directory %q changed while it was being validated", path) } - stat, ok := dirInfo.Sys().(*syscall.Stat_t) - if !ok { - return fmt.Errorf("local state directory ownership is unavailable for %q", path) + owned, err := fsowner.OwnedByCurrentUser(dir) + if err != nil { + return fmt.Errorf("local state directory ownership is unavailable for %q: %w", path, err) } - if int(stat.Uid) != os.Geteuid() { + if !owned { return fmt.Errorf("local state directory %q is not owned by the current user", path) } @@ -871,5 +873,5 @@ func syncLocalDirectory(path string) error { return err } defer dir.Close() - return dir.Sync() + return durable.Sync(dir) } diff --git a/internal/daemon/admission_regression_test.go b/internal/daemon/admission_regression_test.go index 6aa2e29c..e9ef807c 100644 --- a/internal/daemon/admission_regression_test.go +++ b/internal/daemon/admission_regression_test.go @@ -735,7 +735,7 @@ func TestExecutableResolutionUsesEffectiveJobPATH(t *testing.T) { if execution.Path != "" { t.Fatalf("execution receipt exposed declared PATH metadata: %+v", execution) } - if _, err := resolveExecutable("sh", "", t.TempDir()); err == nil { + if _, err := resolveExecutable("sh", "", t.TempDir(), ""); err == nil { t.Fatal("effective empty PATH fell back to the daemon's ambient PATH") } } diff --git a/internal/daemon/cache.go b/internal/daemon/cache.go index a7794b9a..32a5a830 100644 --- a/internal/daemon/cache.go +++ b/internal/daemon/cache.go @@ -13,6 +13,7 @@ import ( "strings" "time" + "github.com/lydakis/errand/internal/fsidentity" "github.com/lydakis/errand/internal/proto" ) @@ -161,6 +162,16 @@ func (c *blobCache) Materialize(ctx context.Context, dest string, e proto.Manife return false, nil // miss } defer src.Close() + // The open handle names the blob this copy reads, even if the path is + // replaced meanwhile. + blob, err := src.Stat() + if err != nil { + return false, nil + } + blobIdentity, err := fsidentity.FromInfo(blob) + if err != nil { + return false, nil + } if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { return false, err } @@ -193,7 +204,9 @@ func (c *blobCache) Materialize(ctx context.Context, dest string, e proto.Manife // Cache cleanup is best effort. An unavailable or read-only cache // must still allow the caller to retry with the original file body. // Destination failures and cancellation remain hard errors. - _ = c.removeIfCurrent(ctx, e.SHA256, fi) + // Windows cannot remove a file that is still open. + src.Close() + _ = c.removeIfCurrent(ctx, e.SHA256, blobIdentity) return false, ctx.Err() } if err := ctx.Err(); err != nil { @@ -291,10 +304,12 @@ func (c *blobCache) Insert(ctx context.Context, src, sha string, size int64) err } func (c *blobCache) remove(sha string) { - _ = c.removeIfCurrent(context.Background(), sha, nil) + _ = c.removeIfCurrent(context.Background(), sha, fsidentity.Identity{}) } -func (c *blobCache) removeIfCurrent(ctx context.Context, sha string, expected fs.FileInfo) error { +// removeIfCurrent removes the blob at sha's path. A non-zero expected identity +// limits removal to that file, sparing a replacement published meanwhile. +func (c *blobCache) removeIfCurrent(ctx context.Context, sha string, expected fsidentity.Identity) error { if !validBlobHash(sha) { return fmt.Errorf("cache: invalid blob hash %q", sha) } @@ -303,14 +318,14 @@ func (c *blobCache) removeIfCurrent(ctx context.Context, sha string, expected fs } defer c.mu.Unlock() p := c.path(sha) - fi, err := os.Lstat(p) + identity, fi, err := fsidentity.Lstat(p) if os.IsNotExist(err) { return nil } if err != nil { return err } - if expected != nil && !os.SameFile(expected, fi) { + if !expected.IsZero() && identity != expected { return nil } if err := os.Remove(p); err != nil { diff --git a/internal/daemon/cache_test.go b/internal/daemon/cache_test.go index da7c4ccc..a70ca7f7 100644 --- a/internal/daemon/cache_test.go +++ b/internal/daemon/cache_test.go @@ -9,10 +9,10 @@ import ( "os/exec" "path/filepath" "strings" - "syscall" "testing" "time" + "github.com/lydakis/errand/internal/fsidentity" "github.com/lydakis/errand/internal/proto" ) @@ -49,7 +49,7 @@ func TestCacheInsertMaterializeRoundTrip(t *testing.T) { } return } - defer syscall.Umask(syscall.Umask(0o077)) + defer setUmask(t, 0o077)() c := testCache(t, 1<<20, time.Hour) sha, size := insertContent(t, c, "hello cache") dest := filepath.Join(t.TempDir(), "out.txt") @@ -373,7 +373,7 @@ func TestCacheCorruptionRemovalCanBeCanceledWhileWaiting(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() - if err := c.removeIfCurrent(ctx, sha, nil); !errors.Is(err, context.Canceled) { + if err := c.removeIfCurrent(ctx, sha, fsidentity.Identity{}); !errors.Is(err, context.Canceled) { t.Fatalf("canceled corruption removal error = %v, want context.Canceled", err) } if _, err := os.Lstat(c.path(sha)); err != nil { @@ -385,7 +385,7 @@ func TestCacheCorruptionRemovalPreservesReplacement(t *testing.T) { c := testCache(t, 1<<20, time.Hour) sha, _ := insertContent(t, c, "replacement content") p := c.path(sha) - original, err := os.Lstat(p) + original, _, err := fsidentity.Lstat(p) if err != nil { t.Fatal(err) } diff --git a/internal/daemon/cache_windows_test.go b/internal/daemon/cache_windows_test.go new file mode 100644 index 00000000..793b979c --- /dev/null +++ b/internal/daemon/cache_windows_test.go @@ -0,0 +1,32 @@ +//go:build windows + +package daemon + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/lydakis/errand/internal/proto" +) + +// A corrupt blob is still open for reading when its copy fails verification; +// Windows cannot delete it until that handle is closed. +func TestCorruptBlobIsDeletedOnWindows(t *testing.T) { + c := testCache(t, 1<<20, time.Hour) + sha, size := insertContent(t, c, "pristine content") + if err := os.WriteFile(c.path(sha), []byte("corrupted conten"), 0o600); err != nil { + t.Fatal(err) + } + hit, err := c.Materialize(context.Background(), filepath.Join(t.TempDir(), "f"), proto.ManifestEntry{ + Path: "f", Type: proto.EntryFile, Mode: 0o644, Size: size, SHA256: sha, + }) + if err != nil || hit { + t.Fatalf("corrupt blob materialized: hit=%v err=%v", hit, err) + } + if _, err := os.Lstat(c.path(sha)); !os.IsNotExist(err) { + t.Fatalf("corrupt blob was not deleted: %v", err) + } +} diff --git a/internal/daemon/job.go b/internal/daemon/job.go index 838746b4..77f8da9e 100644 --- a/internal/daemon/job.go +++ b/internal/daemon/job.go @@ -477,7 +477,10 @@ func (j *Job) launch(d *Daemon) error { return fmt.Errorf("consuming queued marker: %w", err) } jobEnv = append(jobEnv, scope.env()) - executable, err := resolveExecutable(j.Spec.Argv[0], envValue(jobEnv, "PATH"), workdir) + executable, err := resolveExecutable(j.Spec.Argv[0], envValue(jobEnv, "PATH"), workdir, jobPATHEXT(jobEnv)) + if err == nil { + err = checkCommandLine(executable, j.Spec.Argv[1:]) + } if err != nil { logw.Close() return err @@ -486,7 +489,7 @@ func (j *Job) launch(d *Daemon) error { cmd.Args[0] = j.Spec.Argv[0] cmd.Dir = workdir cmd.Env = jobEnv - cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} + scope.prepare(cmd) execution := proto.ExecutionContext{Argv: j.Spec.Argv} if _, declaredPATH := j.Spec.Env["PATH"]; !declaredPATH { execution.Path = cmd.Path @@ -528,6 +531,12 @@ func (j *Job) launch(d *Daemon) error { var scopeCaptureErr error var scopePersistenceErr error err = cmd.Start() + if err == nil { + if err = scope.adopt(cmd); err != nil { + j.event("process-scope-setup-failed", err.Error()) + _ = cmd.Wait() + } + } if err == nil { startedAt = time.Now() if sharedWorkspace { @@ -545,6 +554,7 @@ func (j *Job) launch(d *Daemon) error { } j.mu.Unlock() if err != nil { + scope.close() closePipes() logw.Close() return sanitizeProcessStartError(err) @@ -553,7 +563,7 @@ func (j *Job) launch(d *Daemon) error { // The command started, but cannot safely outlive this daemon without a // durable process identity. Stop it and still follow the normal wait path. j.event("process-scope-setup-failed", scopeSetupErr.Error()) - _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) + _, _ = scope.signal(cmd.Process.Pid, syscall.SIGKILL) } stdoutW.Close() stderrW.Close() @@ -618,9 +628,9 @@ func (j *Job) launch(d *Daemon) error { res.ExitCode = &code } else if ee, ok := waitErr.(*exec.ExitError); ok { ws := ee.Sys().(syscall.WaitStatus) - if ws.Signaled() { - res.Signal = ws.Signal().String() - res.SignalNum = int(ws.Signal()) + if sig, signaled := scope.exitSignal(ws); signaled { + res.Signal = sig.String() + res.SignalNum = int(sig) } else { code := ws.ExitStatus() res.ExitCode = &code @@ -806,20 +816,18 @@ func waitForPipeCopies(readers []*os.File, errs <-chan error, timeout time.Durat } func envValue(env []string, key string) string { - prefix := key + "=" for i := len(env) - 1; i >= 0; i-- { - if strings.HasPrefix(env[i], prefix) { - return strings.TrimPrefix(env[i], prefix) + name, value, ok := strings.Cut(env[i], "=") + if ok && envNameEqual(name, key) { + return value } } return "" } -func resolveExecutable(name, pathEnv, workdir string) (string, error) { - check := func(candidate string) (string, bool) { - return candidate, executableFile(candidate) - } - if strings.ContainsRune(name, filepath.Separator) { +func resolveExecutable(name, pathEnv, workdir, pathext string) (string, error) { + check := executableFinder(pathext) + if hasPathSeparator(name) { candidate := name if !filepath.IsAbs(candidate) { candidate = filepath.Join(workdir, candidate) @@ -859,7 +867,7 @@ func (j *Job) cancelledBeforeStart() *proto.Result { // variables. Nothing ambient is forwarded from the caller. func (j *Job) buildEnv() []string { var env []string - for _, key := range []string{"PATH", "HOME", "USER", "LOGNAME", "LANG", "TMPDIR"} { + for _, key := range baseEnvNames { if v, ok := os.LookupEnv(key); ok { env = append(env, key+"="+v) } @@ -875,6 +883,14 @@ func (j *Job) buildEnv() []string { return env } +// signalJobProcesses signals the job leader and every process in its scope. +func signalJobProcesses(scope *processScope, leader int, sig syscall.Signal) (exited bool, err error) { + if scope == nil { + return false, fmt.Errorf("job process scope is unavailable") + } + return scope.signal(leader, sig) +} + // terminate kills the whole process group, recording why. func (j *Job) terminate(reason string, sig syscall.Signal) error { j.mu.Lock() @@ -904,20 +920,12 @@ func (j *Job) terminate(reason string, sig syscall.Signal) error { j.killSignal = sig } j.mu.Unlock() - groupErr := syscall.Kill(-cmd.Process.Pid, sig) - processExited := groupErr == syscall.ESRCH - if processExited { - groupErr = nil - } - var scopeErr error - if scope != nil { - scopeErr = scope.signalEscaped(sig, cmd.Process.Pid) - } + processExited, signalErr := signalJobProcesses(scope, cmd.Process.Pid, sig) if processExited { j.requestChangeCollectionCancellation(reason) } j.event("terminated", reason) - return errors.Join(groupErr, scopeErr) + return signalErr } // Signal forwards a signal to the job's process group. @@ -942,19 +950,11 @@ func (j *Job) Signal(sig syscall.Signal) error { return fmt.Errorf("job %s is not running", j.ID) } j.event("signal", sig.String()) - groupErr := syscall.Kill(-cmd.Process.Pid, sig) - processExited := groupErr == syscall.ESRCH - if processExited { - groupErr = nil - } - var scopeErr error - if scope != nil { - scopeErr = scope.signalEscaped(sig, cmd.Process.Pid) - } + processExited, signalErr := signalJobProcesses(scope, cmd.Process.Pid, sig) if processExited { j.requestChangeCollectionCancellation(sig.String()) } - return errors.Join(groupErr, scopeErr) + return signalErr } func (j *Job) requestChangeCollectionCancellation(reason string) bool { diff --git a/internal/daemon/job_exec_unix.go b/internal/daemon/job_exec_unix.go new file mode 100644 index 00000000..01d8528f --- /dev/null +++ b/internal/daemon/job_exec_unix.go @@ -0,0 +1,22 @@ +//go:build unix + +package daemon + +import ( + "strings" +) + +// baseEnvNames are taken from the daemon's environment into every job. +var baseEnvNames = []string{"PATH", "HOME", "USER", "LOGNAME", "LANG", "TMPDIR"} + +func envNameEqual(a, b string) bool { return a == b } + +func hasPathSeparator(name string) bool { return strings.ContainsRune(name, '/') } + +func jobPATHEXT([]string) string { return "" } + +// checkCommandLine accepts every argument: exec passes argv unchanged. +func checkCommandLine(string, []string) error { return nil } + +// Unix executable lookup does not depend on PATHEXT. +func executableFinder(string) func(string) (string, bool) { return findExecutable } diff --git a/internal/daemon/job_exec_windows.go b/internal/daemon/job_exec_windows.go new file mode 100644 index 00000000..d909722e --- /dev/null +++ b/internal/daemon/job_exec_windows.go @@ -0,0 +1,91 @@ +//go:build windows + +package daemon + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// baseEnvNames are taken from the daemon's environment into every job. Beyond +// the Unix set, Windows programs need their profile, temp and system folders, +// and the shell needs PATHEXT and ComSpec. +var baseEnvNames = []string{ + "PATH", "PATHEXT", "LANG", + "USERPROFILE", "HOMEDRIVE", "HOMEPATH", "USERNAME", "USERDOMAIN", + "APPDATA", "LOCALAPPDATA", "TEMP", "TMP", + "SystemRoot", "SystemDrive", "windir", "ComSpec", "OS", "COMPUTERNAME", + "ProgramData", "ProgramFiles", "ProgramFiles(x86)", "ProgramW6432", + "CommonProgramFiles", "CommonProgramFiles(x86)", "CommonProgramW6432", + "NUMBER_OF_PROCESSORS", "PROCESSOR_ARCHITECTURE", +} + +// Windows environment names are case-insensitive. +func envNameEqual(a, b string) bool { return strings.EqualFold(a, b) } + +func hasPathSeparator(name string) bool { return strings.ContainsAny(name, `/\`) } + +func jobPATHEXT(env []string) string { return envValue(env, "PATHEXT") } + +func executableExtensions(pathext string) []string { + if pathext == "" { + pathext = ".COM;.EXE;.BAT;.CMD" + } + var exts []string + for _, ext := range filepath.SplitList(pathext) { + if strings.HasPrefix(ext, ".") && len(ext) > 1 { + exts = append(exts, strings.ToLower(ext)) + } + } + return exts +} + +func regularFile(path string) bool { + info, err := os.Stat(path) + return err == nil && info.Mode().IsRegular() +} + +// findExecutable finds what Windows would run for path: an existing path with +// an explicit extension, otherwise the first path+extension from PATHEXT. +// So "cargo" finds cargo.exe and "npm" finds npm.cmd. +func findExecutable(path string) (string, bool) { + return executableFinder(os.Getenv("PATHEXT"))(path) +} + +// Parse PATHEXT once for the entire PATH search, using the job's environment. +func executableFinder(pathext string) func(string) (string, bool) { + exts := executableExtensions(pathext) + return func(path string) (string, bool) { + if filepath.Ext(path) != "" && regularFile(path) { + return path, true + } + for _, candidate := range exts { + if regularFile(path + candidate) { + return path + candidate, true + } + } + return "", false + } +} + +// cmd.exe runs .bat and .cmd files and reparses their command line with its +// own rules, so these characters would not reach the script as written. +const batchSpecialCharacters = "\"%^&|<>\r\n" + +// checkCommandLine refuses batch-file arguments that cmd.exe would reinterpret, +// rather than run something other than what was asked. +func checkCommandLine(executable string, args []string) error { + switch strings.ToLower(filepath.Ext(executable)) { + case ".bat", ".cmd": + default: + return nil + } + for _, arg := range args { + if strings.ContainsAny(arg, batchSpecialCharacters) { + return fmt.Errorf("argument %q to %s contains characters cmd.exe would reinterpret; run it through cmd /c yourself to control quoting", arg, filepath.Base(executable)) + } + } + return nil +} diff --git a/internal/daemon/job_exec_windows_test.go b/internal/daemon/job_exec_windows_test.go new file mode 100644 index 00000000..5f02b80d --- /dev/null +++ b/internal/daemon/job_exec_windows_test.go @@ -0,0 +1,154 @@ +//go:build windows + +package daemon + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestFindExecutableAppliesPATHEXTOnWindows(t *testing.T) { + t.Setenv("PATHEXT", ".COM;.EXE;.BAT;.CMD") + dir := t.TempDir() + for _, name := range []string{"tool.exe", "shim.cmd", "notes.txt"} { + if err := os.WriteFile(filepath.Join(dir, name), nil, 0o644); err != nil { + t.Fatal(err) + } + } + for name, want := range map[string]string{ + "tool": "tool.exe", + "tool.exe": "tool.exe", + "shim": "shim.cmd", + "notes.txt": "notes.txt", + "notes": "", + "missing": "", + } { + got, ok := findExecutable(filepath.Join(dir, name)) + if want == "" { + if ok { + t.Errorf("findExecutable(%s) = %s, want none", name, got) + } + continue + } + if !ok || !strings.EqualFold(got, filepath.Join(dir, want)) { + t.Errorf("findExecutable(%s) = %s, %v, want %s", name, got, ok, want) + } + } +} + +func TestExplicitExecutableIgnoresPATHEXTOnWindows(t *testing.T) { + dir := t.TempDir() + // The competing script must never shadow an explicitly named executable. + for _, name := range []string{"tool.exe", "tool.exe.cmd", "fallback.exe.cmd"} { + if err := os.WriteFile(filepath.Join(dir, name), nil, 0o644); err != nil { + t.Fatal(err) + } + } + if err := os.Mkdir(filepath.Join(dir, "directory.exe"), 0o755); err != nil { + t.Fatal(err) + } + const pathext = ".CMD" + for _, tc := range []struct{ name, want string }{ + {name: "tool.exe", want: "tool.exe"}, + {name: "TOOL.EXE", want: "tool.exe"}, + {name: "fallback.exe", want: "fallback.exe.cmd"}, + {name: "directory.exe"}, + {name: "missing.exe"}, + } { + t.Run(tc.name, func(t *testing.T) { + for _, name := range []string{tc.name, "./" + tc.name, filepath.Join(dir, tc.name)} { + got, err := resolveExecutable(name, dir, dir, pathext) + if tc.want == "" { + if err == nil { + t.Fatalf("resolve(%q) = %q; want not found", name, got) + } + continue + } + if err != nil || !strings.EqualFold(got, filepath.Join(dir, tc.want)) { + t.Fatalf("resolve(%q) = %q, %v; want %q", name, got, err, tc.want) + } + } + got := placementTool(tc.name, []string{"PATH=" + dir, "PATHEXT=" + pathext}) + want := "" + if tc.want != "" { + want = filepath.Join(dir, tc.want) + } + if !strings.EqualFold(got, want) { + t.Fatalf("placement(%q) = %q; want %q", tc.name, got, want) + } + }) + } +} + +func TestResolveExecutableSearchesPATHWithExtensionsOnWindows(t *testing.T) { + t.Setenv("PATHEXT", ".EXE;.CMD") + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "npm.cmd"), nil, 0o644); err != nil { + t.Fatal(err) + } + got, err := resolveExecutable("npm", dir, t.TempDir(), os.Getenv("PATHEXT")) + if err != nil { + t.Fatal(err) + } + if !strings.EqualFold(got, filepath.Join(dir, "npm.cmd")) { + t.Fatalf("resolved %s", got) + } + if _, err := resolveExecutable("./npm", dir, dir, os.Getenv("PATHEXT")); err != nil { + t.Fatalf("relative path with forward slash: %v", err) + } +} + +func TestEnvValueIgnoresNameCaseOnWindows(t *testing.T) { + env := []string{"Path=C:\\one", "SYSTEMROOT=C:\\Windows"} + if got := envValue(env, "PATH"); got != `C:\one` { + t.Fatalf("PATH = %q", got) + } + if got := envValue(env, "SystemRoot"); got != `C:\Windows` { + t.Fatalf("SystemRoot = %q", got) + } +} + +func TestExecutableLookupUsesJobPATHEXTOnWindows(t *testing.T) { + t.Setenv("PATHEXT", ".EXE") + dir := t.TempDir() + for _, name := range []string{"tool.exe", "tool.cmd"} { + if err := os.WriteFile(filepath.Join(dir, name), nil, 0o644); err != nil { + t.Fatal(err) + } + } + for _, tc := range []struct{ extensions, want string }{ + {extensions: ".CMD", want: "tool.cmd"}, + {extensions: ".CMD;.EXE", want: "tool.cmd"}, + {extensions: ".EXE;.CMD", want: "tool.exe"}, + } { + t.Run(tc.extensions, func(t *testing.T) { + j := &Job{} + j.Spec.Env = map[string]string{"Path": dir, "PathExt": tc.extensions} + env := j.buildEnv() + got, err := resolveExecutable("tool", envValue(env, "PATH"), dir, envValue(env, "PATHEXT")) + want := filepath.Join(dir, tc.want) + if err != nil || !strings.EqualFold(got, want) { + t.Fatalf("resolve = %q, %v; want %q", got, err, want) + } + if got := placementTool("tool", env); !strings.EqualFold(got, want) { + t.Fatalf("placement = %q; want %q", got, want) + } + }) + } +} + +func TestCheckCommandLineRefusesBatchMetacharactersOnWindows(t *testing.T) { + if err := checkCommandLine(`C:\bin\npm.cmd`, []string{"run", "build", `C:\Program Files (x86)\x`}); err != nil { + t.Fatalf("plain batch arguments refused: %v", err) + } + for _, arg := range []string{"a&b", "a|b", "%PATH%", `say "hi"`, "a^b", "x>y"} { + if err := checkCommandLine(`C:\bin\npm.CMD`, []string{arg}); err == nil { + t.Errorf("batch argument %q accepted", arg) + } + if err := checkCommandLine(`C:\bin\cargo.exe`, []string{arg}); err != nil { + t.Errorf("exe argument %q refused: %v", arg, err) + } + } +} diff --git a/internal/daemon/job_windows_test.go b/internal/daemon/job_windows_test.go new file mode 100644 index 00000000..8cba5228 --- /dev/null +++ b/internal/daemon/job_windows_test.go @@ -0,0 +1,65 @@ +//go:build windows + +package daemon + +import ( + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/lydakis/errand/internal/proto" +) + +// A Mac client's job on a Windows runner: the command is found through +// PATHEXT, its exit code comes back, and the files it wrote are retained. +func TestJobRoundTripOnWindows(t *testing.T) { + d, ts := testDaemon(t) + root := workspaceWith(t, map[string]string{"input.txt": "from the client\n"}) + if err := os.Mkdir(filepath.Join(root, "links"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink("../input.txt", filepath.Join(root, "links", "input")); err != nil { + t.Fatal(err) + } + _, status := submitChangeJob(t, d, ts.URL, root, + []string{"cmd", "/d", "/c", `type links\input && echo remote> report.txt && exit /b 7`}) + result := status.Result + if result == nil || result.StartError != "" || result.ExitCode == nil || *result.ExitCode != 7 { + t.Fatalf("result = %+v", result) + } + if !result.ChangesOK || result.Changes == nil || fmt.Sprint(result.Changes.Paths) != "[report.txt]" { + t.Fatalf("changes = %+v", result.Changes) + } +} + +func TestJobExplicitExecutableWithRestrictedPATHEXTOnWindows(t *testing.T) { + t.Setenv("PATHEXT", ".EXE") + executable, err := exec.LookPath("cmd.exe") + if err != nil { + t.Fatal(err) + } + _, ts := testDaemon(t) + root := workspaceWith(t, nil) + manifest := proto.Manifest{} + id := proto.NewULID() + response := rawSubmitSpec(t, ts.URL, id, root, proto.Spec{ + Argv: []string{executable, "/d", "/c", "exit /b 7"}, + Env: map[string]string{"PATHEXT": ".CMD"}, + EnvSources: map[string]string{"PATHEXT": "literal"}, + ManifestRoot: manifest.RootHash(), Limits: proto.DefaultLimits(), + }, manifest) + if response.StatusCode != http.StatusCreated { + body, _ := io.ReadAll(response.Body) + response.Body.Close() + t.Fatalf("submit = %s: %s", response.Status, body) + } + response.Body.Close() + result := waitTerminal(t, ts.URL, id).Result + if result == nil || !result.Started || result.StartError != "" || result.ExitCode == nil || *result.ExitCode != 7 { + t.Fatalf("result = %+v", result) + } +} diff --git a/internal/daemon/milestone2_test.go b/internal/daemon/milestone2_test.go index 648b3e99..20124f4f 100644 --- a/internal/daemon/milestone2_test.go +++ b/internal/daemon/milestone2_test.go @@ -1,3 +1,5 @@ +//go:build unix + package daemon import ( diff --git a/internal/daemon/peercred.go b/internal/daemon/peercred.go index c00abd60..86a544f0 100644 --- a/internal/daemon/peercred.go +++ b/internal/daemon/peercred.go @@ -28,8 +28,10 @@ func ConnContext(ctx context.Context, conn net.Conn) context.Context { if err != nil { return ctx } - if u, lookupErr := user.LookupId(strconv.FormatUint(uint64(peer.UID), 10)); lookupErr == nil { - peer.User = u.Username + if peer.User == "" { + if u, lookupErr := user.LookupId(strconv.FormatUint(uint64(peer.UID), 10)); lookupErr == nil { + peer.User = u.Username + } } return context.WithValue(ctx, localPeerKey{}, peer) } @@ -42,5 +44,5 @@ func localPeerFromContext(ctx context.Context) (LocalPeer, bool) { func currentUID() uint32 { return unixpeer.CurrentUID() } func peerCredentials(conn *net.UnixConn) (LocalPeer, error) { peer, err := unixpeer.Credentials(conn) - return LocalPeer{UID: peer.UID, GID: peer.GID}, err + return LocalPeer{UID: peer.UID, GID: peer.GID, User: peer.User}, err } diff --git a/internal/daemon/placement.go b/internal/daemon/placement.go index 55489a49..afa635ad 100644 --- a/internal/daemon/placement.go +++ b/internal/daemon/placement.go @@ -7,27 +7,26 @@ import ( "os/exec" "path/filepath" "sync" - "syscall" "time" "github.com/lydakis/errand/internal/placement" "github.com/lydakis/errand/internal/proto" - "golang.org/x/sys/unix" ) func executableFile(path string) bool { info, err := os.Stat(path) - return err == nil && info.Mode().IsRegular() && unix.Faccessat(unix.AT_FDCWD, path, unix.X_OK, unix.AT_EACCESS) == nil + return err == nil && info.Mode().IsRegular() && canExecute(path) } // Relative entries depend on the future workspace, so cannot be attested. func placementTool(tool string, env []string) string { + check := executableFinder(jobPATHEXT(env)) for _, dir := range filepath.SplitList(envValue(env, "PATH")) { if !filepath.IsAbs(dir) { continue } path := filepath.Join(dir, tool) - if executableFile(path) { + if path, ok := check(path); ok { return path } } @@ -91,15 +90,14 @@ func (d *Daemon) probeRuntime(ctx context.Context, path string, env []string) er cmd := exec.CommandContext(ctx, path, "info") cmd.Env = env cmd.Dir = "/" - cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} - cmd.Cancel = func() error { return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) } cmd.WaitDelay = 100 * time.Millisecond - if err := cmd.Start(); err != nil { + probe, err := startProbe(cmd) + if err != nil { return fmt.Errorf("runtime probe could not start") } - err := cmd.Wait() + err = cmd.Wait() // Remove any children retained by a runtime connection helper. - _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) + probe.kill() if ctx.Err() != nil { return fmt.Errorf("runtime probe deadline exceeded") } diff --git a/internal/daemon/placement_test.go b/internal/daemon/placement_test.go index 1cb726de..ce6b52cb 100644 --- a/internal/daemon/placement_test.go +++ b/internal/daemon/placement_test.go @@ -117,7 +117,7 @@ func TestWhereChecksEffectiveExecutePermission(t *testing.T) { if f.Tools["go"] != "" { t.Fatal("accepted tool without effective execute permission") } - if _, err := resolveExecutable("go", dir, dir); err == nil { + if _, err := resolveExecutable("go", dir, dir, ""); err == nil { t.Fatal("job resolver accepted same unexecutable tool") } } diff --git a/internal/daemon/placement_unix.go b/internal/daemon/placement_unix.go new file mode 100644 index 00000000..86b04bde --- /dev/null +++ b/internal/daemon/placement_unix.go @@ -0,0 +1,35 @@ +//go:build unix + +package daemon + +import ( + "os/exec" + "syscall" + + "golang.org/x/sys/unix" +) + +func canExecute(path string) bool { + return unix.Faccessat(unix.AT_FDCWD, path, unix.X_OK, unix.AT_EACCESS) == nil +} + +// findExecutable reports whether path can be executed as named. +func findExecutable(path string) (string, bool) { + return path, executableFile(path) +} + +// probeProcess is a runtime probe running in its own process group. +type probeProcess struct{ pid int } + +func startProbe(cmd *exec.Cmd) (*probeProcess, error) { + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + cmd.Cancel = func() error { return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) } + if err := cmd.Start(); err != nil { + return nil, err + } + return &probeProcess{pid: cmd.Process.Pid}, nil +} + +func (p *probeProcess) kill() { + _ = syscall.Kill(-p.pid, syscall.SIGKILL) +} diff --git a/internal/daemon/placement_windows.go b/internal/daemon/placement_windows.go new file mode 100644 index 00000000..07f6b967 --- /dev/null +++ b/internal/daemon/placement_windows.go @@ -0,0 +1,40 @@ +//go:build windows + +package daemon + +import ( + "os/exec" + + "github.com/lydakis/errand/internal/proctree" +) + +// Windows grants execute through ACLs that the file's owner already holds; +// whether a file runs is decided by its extension (see findExecutable). +func canExecute(string) bool { return true } + +// probeProcess is a runtime probe running in its own Job Object. +type probeProcess struct{ job *proctree.Job } + +func startProbe(cmd *exec.Cmd) (*probeProcess, error) { + job, err := proctree.New() + if err != nil { + return nil, err + } + proctree.Prepare(cmd) + cmd.Cancel = func() error { return job.Terminate(1) } + if err := cmd.Start(); err != nil { + job.Close() + return nil, err + } + if err := job.Adopt(cmd.Process); err != nil { + _ = cmd.Wait() + job.Close() + return nil, err + } + return &probeProcess{job: job}, nil +} + +func (p *probeProcess) kill() { + _ = p.job.Terminate(1) + _ = p.job.Close() +} diff --git a/internal/daemon/process_group_test.go b/internal/daemon/process_group_test.go index 0b0aa411..44c2d3d7 100644 --- a/internal/daemon/process_group_test.go +++ b/internal/daemon/process_group_test.go @@ -1,3 +1,5 @@ +//go:build unix + package daemon import ( diff --git a/internal/daemon/process_group.go b/internal/daemon/process_group_unix.go similarity index 75% rename from internal/daemon/process_group.go rename to internal/daemon/process_group_unix.go index 7b904454..5321446b 100644 --- a/internal/daemon/process_group.go +++ b/internal/daemon/process_group_unix.go @@ -1,17 +1,9 @@ +//go:build unix + package daemon import "fmt" -// A process group supplements the inherited marker for programs whose -// environment cannot be inspected (including macOS platform binaries). -// Birth identifies the original leader, so restart cannot claim a reused PID. -// Boot proves that every process from a previous machine boot is gone. -type processGroupRecord struct { - PID int `json:"pid"` - Birth string `json:"birth"` - Boot string `json:"boot,omitempty"` -} - type processGroupSnapshot struct { pids []int leaderBirth string diff --git a/internal/daemon/process_group_windows.go b/internal/daemon/process_group_windows.go new file mode 100644 index 00000000..c3e0356f --- /dev/null +++ b/internal/daemon/process_group_windows.go @@ -0,0 +1,15 @@ +//go:build windows + +package daemon + +// captureProcessGroup records the job leader for the scope record. Windows +// needs no group identity: the job's Job Object dies with the daemon. +func captureProcessGroup(pid int) (*processGroupRecord, error) { + return &processGroupRecord{PID: pid, Birth: "job-object"}, nil +} + +// members is always empty: processes from a previous daemon were killed with +// that daemon's Job Objects. +func (g *processGroupRecord) members(bool) ([]int, error) { + return nil, nil +} diff --git a/internal/daemon/process_scope.go b/internal/daemon/process_scope.go index a6b07d9c..5dcb8ee7 100644 --- a/internal/daemon/process_scope.go +++ b/internal/daemon/process_scope.go @@ -1,39 +1,13 @@ package daemon import ( - "bytes" "crypto/rand" "encoding/hex" - "errors" "fmt" - "os" - "os/exec" - "path/filepath" - "runtime" - "strconv" - "strings" - "syscall" - "time" ) const processScopeEnv = "ERRAND_PROCESS_SCOPE" -// processScope tags every job descendant with an unguessable inherited -// marker. This lets the daemon find descendants that create a new session or -// process group. It is lifecycle containment for same-user cooperative jobs, -// not a security boundary against a process that deliberately scrubs its -// environment. -type processScope struct { - group *processGroupRecord - groupOwned bool - token string - psPath string - workdir string - lsofPath string - procRoot string - cacheDirs []string -} - // scopeRecord is the persisted form of a job's scope, written to the job // directory before the process starts so a restarted daemon can find and // settle survivors during reconciliation. @@ -71,244 +45,12 @@ func validateProcessScopeToken(token string) error { return nil } -func newProcessScopeWithToken(token, workdir string, cacheDirs ...string) (*processScope, error) { - s := &processScope{token: token, workdir: workdir, procRoot: "/proc", cacheDirs: append([]string(nil), cacheDirs...)} - if runtime.GOOS != "linux" { - psPath, err := exec.LookPath("ps") - if err != nil { - return nil, fmt.Errorf("process scope requires ps: %w", err) - } - s.psPath = psPath - } - if runtime.GOOS == "darwin" && (workdir != "" || len(cacheDirs) != 0) { - lsofPath, err := exec.LookPath("lsof") - if err != nil { - return nil, fmt.Errorf("process scope requires lsof on macOS: %w", err) - } - s.lsofPath = lsofPath - } - if _, err := s.pids(); err != nil { - return nil, fmt.Errorf("inspecting process scope: %w", err) - } - return s, nil -} - -func (s *processScope) env() string { - return processScopeEnv + "=" + s.token -} - -func (s *processScope) pids() ([]int, error) { - pids, err := s.markedPIDs() - if err != nil { - return nil, err - } - if s.group != nil { - members, err := s.group.members(s.groupOwned) - if err != nil { - return nil, err - } - seen := make(map[int]bool, len(pids)) - for _, pid := range pids { - seen[pid] = true - } - for _, pid := range members { - if !seen[pid] { - pids = append(pids, pid) - seen[pid] = true - } - } - } - return pids, nil -} - -func (s *processScope) markedPIDs() ([]int, error) { - if runtime.GOOS == "linux" { - return s.linuxPIDs() - } - // On BSD ps, e includes the environment and ww prevents truncation. - out, err := exec.Command(s.psPath, "eww", "-axo", "pid=,command=").Output() - if err != nil { - return nil, err - } - marker := s.env() - seen := map[int]bool{} - for _, line := range strings.Split(string(out), "\n") { - if !strings.Contains(line, marker) { - continue - } - fields := strings.Fields(line) - if len(fields) == 0 { - continue - } - pid, err := strconv.Atoi(fields[0]) - if err == nil && pid > 1 && pid != os.Getpid() { - seen[pid] = true - } - } - cwdPIDs, err := s.cwdPIDs() - if err != nil { - return nil, err - } - for _, pid := range cwdPIDs { - if pid > 1 && pid != os.Getpid() { - seen[pid] = true - } - } - pids := make([]int, 0, len(seen)) - for pid := range seen { - pids = append(pids, pid) - } - return pids, nil -} - -func (s *processScope) linuxPIDs() ([]int, error) { - procRoot := s.procRoot - if procRoot == "" { - procRoot = "/proc" - } - entries, err := os.ReadDir(procRoot) - if err != nil { - return nil, err - } - marker := []byte(s.env()) - seen := map[int]bool{} - for _, entry := range entries { - pid, err := strconv.Atoi(entry.Name()) - if err != nil || pid <= 1 || pid == os.Getpid() { - continue - } - pidDir := filepath.Join(procRoot, entry.Name()) - if environ, err := os.ReadFile(filepath.Join(pidDir, "environ")); err == nil && hasEnvEntry(environ, marker) { - seen[pid] = true - } - if cwd, err := os.Readlink(filepath.Join(pidDir, "cwd")); err == nil && s.containsCWD(cwd) { - seen[pid] = true - } - } - pids := make([]int, 0, len(seen)) - for pid := range seen { - pids = append(pids, pid) - } - return pids, nil -} - -func hasEnvEntry(environ, want []byte) bool { - for _, entry := range bytes.Split(environ, []byte{0}) { - if bytes.Equal(entry, want) { - return true - } - } - return false -} - -func (s *processScope) containsCWD(cwd string) bool { - if withinDir(s.workdir, cwd) { - return true - } - for _, dir := range s.cacheDirs { - if withinDir(dir, cwd) { - return true - } - } - return false -} - -// cwdPIDs is weaker on macOS than on Linux: lsof matches processes whose -// cwd is exactly the workspace or a leased cache directory, while the Linux -// /proc scan matches any cwd within these roots. A darwin job that chdirs into -// a subdirectory and scrubs the env marker evades this scan where its Linux -// twin would not. Do not assume platform parity for scope coverage. -func (s *processScope) cwdPIDs() ([]int, error) { - if runtime.GOOS != "darwin" || s.lsofPath == "" { - return nil, nil - } - var pids []int - for _, dir := range append([]string{s.workdir}, s.cacheDirs...) { - if dir == "" { - continue - } - out, err := exec.Command(s.lsofPath, "-a", "-d", "cwd", "-Fp", "--", dir).Output() - if err != nil { - if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 { - continue - } - return nil, err - } - for _, line := range strings.Split(string(out), "\n") { - if !strings.HasPrefix(line, "p") { - continue - } - if pid, err := strconv.Atoi(strings.TrimPrefix(line, "p")); err == nil { - pids = append(pids, pid) - } - } - } - return pids, nil -} - -func withinDir(root, candidate string) bool { - if root == "" { - return false - } - rel, err := filepath.Rel(root, candidate) - return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) -} - -func (s *processScope) signalEscaped(sig syscall.Signal, originalPGID int) error { - pids, err := s.pids() - if err != nil { - return err - } - var joined error - for _, pid := range pids { - if pgid, err := syscall.Getpgid(pid); err == nil && pgid == originalPGID { - continue - } - if err := syscall.Kill(pid, sig); err != nil && err != syscall.ESRCH { - joined = errors.Join(joined, fmt.Errorf("signal scoped pid %d: %w", pid, err)) - } - } - return joined -} - -// cleanup SIGKILLs every pid still in scope until none remain or the -// deadline passes. It returns the set of pids it killed so the caller can -// record them in the receipt: the scan can catch an innocent same-user -// process that merely chdir'd into the workspace, and when that happens -// the receipt should explain it. -func (s *processScope) cleanup(timeout time.Duration) ([]int, error) { - deadline := time.Now().Add(timeout) - killed := map[int]bool{} - collect := func() []int { - pids := make([]int, 0, len(killed)) - for pid := range killed { - pids = append(pids, pid) - } - return pids - } - for { - pids, err := s.pids() - if err != nil { - return collect(), err - } - if len(pids) == 0 { - return collect(), nil - } - var joined error - for _, pid := range pids { - err := syscall.Kill(pid, syscall.SIGKILL) - if err == nil || err == syscall.ESRCH { - killed[pid] = true - continue - } - joined = errors.Join(joined, fmt.Errorf("kill scoped pid %d: %w", pid, err)) - } - if joined != nil { - return collect(), joined - } - if time.Now().After(deadline) { - return collect(), fmt.Errorf("process scope still contains pids %v", pids) - } - time.Sleep(20 * time.Millisecond) - } +// A process group supplements the inherited marker for programs whose +// environment cannot be inspected (including macOS platform binaries). +// Birth identifies the original leader, so restart cannot claim a reused PID. +// Boot proves that every process from a previous machine boot is gone. +type processGroupRecord struct { + PID int `json:"pid"` + Birth string `json:"birth"` + Boot string `json:"boot,omitempty"` } diff --git a/internal/daemon/process_scope_test.go b/internal/daemon/process_scope_test.go index 6fdbbf47..0ec7d2d0 100644 --- a/internal/daemon/process_scope_test.go +++ b/internal/daemon/process_scope_test.go @@ -1,3 +1,5 @@ +//go:build unix + package daemon import ( diff --git a/internal/daemon/process_scope_unix.go b/internal/daemon/process_scope_unix.go new file mode 100644 index 00000000..1ea80a78 --- /dev/null +++ b/internal/daemon/process_scope_unix.go @@ -0,0 +1,304 @@ +//go:build unix + +package daemon + +import ( + "bytes" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "syscall" + "time" +) + +// processScope tags every job descendant with an unguessable inherited +// marker. This lets the daemon find descendants that create a new session or +// process group. It is lifecycle containment for same-user cooperative jobs, +// not a security boundary against a process that deliberately scrubs its +// environment. +type processScope struct { + group *processGroupRecord + groupOwned bool + token string + psPath string + workdir string + lsofPath string + procRoot string + cacheDirs []string +} + +func newProcessScopeWithToken(token, workdir string, cacheDirs ...string) (*processScope, error) { + s := &processScope{token: token, workdir: workdir, procRoot: "/proc", cacheDirs: append([]string(nil), cacheDirs...)} + if runtime.GOOS != "linux" { + psPath, err := exec.LookPath("ps") + if err != nil { + return nil, fmt.Errorf("process scope requires ps: %w", err) + } + s.psPath = psPath + } + if runtime.GOOS == "darwin" && (workdir != "" || len(cacheDirs) != 0) { + lsofPath, err := exec.LookPath("lsof") + if err != nil { + return nil, fmt.Errorf("process scope requires lsof on macOS: %w", err) + } + s.lsofPath = lsofPath + } + if _, err := s.pids(); err != nil { + return nil, fmt.Errorf("inspecting process scope: %w", err) + } + return s, nil +} + +func (s *processScope) env() string { + return processScopeEnv + "=" + s.token +} + +func (s *processScope) pids() ([]int, error) { + pids, err := s.markedPIDs() + if err != nil { + return nil, err + } + if s.group != nil { + members, err := s.group.members(s.groupOwned) + if err != nil { + return nil, err + } + seen := make(map[int]bool, len(pids)) + for _, pid := range pids { + seen[pid] = true + } + for _, pid := range members { + if !seen[pid] { + pids = append(pids, pid) + seen[pid] = true + } + } + } + return pids, nil +} + +func (s *processScope) markedPIDs() ([]int, error) { + if runtime.GOOS == "linux" { + return s.linuxPIDs() + } + // On BSD ps, e includes the environment and ww prevents truncation. + out, err := exec.Command(s.psPath, "eww", "-axo", "pid=,command=").Output() + if err != nil { + return nil, err + } + marker := s.env() + seen := map[int]bool{} + for _, line := range strings.Split(string(out), "\n") { + if !strings.Contains(line, marker) { + continue + } + fields := strings.Fields(line) + if len(fields) == 0 { + continue + } + pid, err := strconv.Atoi(fields[0]) + if err == nil && pid > 1 && pid != os.Getpid() { + seen[pid] = true + } + } + cwdPIDs, err := s.cwdPIDs() + if err != nil { + return nil, err + } + for _, pid := range cwdPIDs { + if pid > 1 && pid != os.Getpid() { + seen[pid] = true + } + } + pids := make([]int, 0, len(seen)) + for pid := range seen { + pids = append(pids, pid) + } + return pids, nil +} + +func (s *processScope) linuxPIDs() ([]int, error) { + procRoot := s.procRoot + if procRoot == "" { + procRoot = "/proc" + } + entries, err := os.ReadDir(procRoot) + if err != nil { + return nil, err + } + marker := []byte(s.env()) + seen := map[int]bool{} + for _, entry := range entries { + pid, err := strconv.Atoi(entry.Name()) + if err != nil || pid <= 1 || pid == os.Getpid() { + continue + } + pidDir := filepath.Join(procRoot, entry.Name()) + if environ, err := os.ReadFile(filepath.Join(pidDir, "environ")); err == nil && hasEnvEntry(environ, marker) { + seen[pid] = true + } + if cwd, err := os.Readlink(filepath.Join(pidDir, "cwd")); err == nil && s.containsCWD(cwd) { + seen[pid] = true + } + } + pids := make([]int, 0, len(seen)) + for pid := range seen { + pids = append(pids, pid) + } + return pids, nil +} + +func hasEnvEntry(environ, want []byte) bool { + for _, entry := range bytes.Split(environ, []byte{0}) { + if bytes.Equal(entry, want) { + return true + } + } + return false +} + +func (s *processScope) containsCWD(cwd string) bool { + if withinDir(s.workdir, cwd) { + return true + } + for _, dir := range s.cacheDirs { + if withinDir(dir, cwd) { + return true + } + } + return false +} + +// cwdPIDs is weaker on macOS than on Linux: lsof matches processes whose +// cwd is exactly the workspace or a leased cache directory, while the Linux +// /proc scan matches any cwd within these roots. A darwin job that chdirs into +// a subdirectory and scrubs the env marker evades this scan where its Linux +// twin would not. Do not assume platform parity for scope coverage. +func (s *processScope) cwdPIDs() ([]int, error) { + if runtime.GOOS != "darwin" || s.lsofPath == "" { + return nil, nil + } + var pids []int + for _, dir := range append([]string{s.workdir}, s.cacheDirs...) { + if dir == "" { + continue + } + out, err := exec.Command(s.lsofPath, "-a", "-d", "cwd", "-Fp", "--", dir).Output() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 { + continue + } + return nil, err + } + for _, line := range strings.Split(string(out), "\n") { + if !strings.HasPrefix(line, "p") { + continue + } + if pid, err := strconv.Atoi(strings.TrimPrefix(line, "p")); err == nil { + pids = append(pids, pid) + } + } + } + return pids, nil +} + +func withinDir(root, candidate string) bool { + if root == "" { + return false + } + rel, err := filepath.Rel(root, candidate) + return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) +} + +func (s *processScope) signalEscaped(sig syscall.Signal, originalPGID int) error { + pids, err := s.pids() + if err != nil { + return err + } + var joined error + for _, pid := range pids { + if pgid, err := syscall.Getpgid(pid); err == nil && pgid == originalPGID { + continue + } + if err := syscall.Kill(pid, sig); err != nil && err != syscall.ESRCH { + joined = errors.Join(joined, fmt.Errorf("signal scoped pid %d: %w", pid, err)) + } + } + return joined +} + +// cleanup SIGKILLs every pid still in scope until none remain or the +// deadline passes. It returns the set of pids it killed so the caller can +// record them in the receipt: the scan can catch an innocent same-user +// process that merely chdir'd into the workspace, and when that happens +// the receipt should explain it. +func (s *processScope) cleanup(timeout time.Duration) ([]int, error) { + deadline := time.Now().Add(timeout) + killed := map[int]bool{} + collect := func() []int { + pids := make([]int, 0, len(killed)) + for pid := range killed { + pids = append(pids, pid) + } + return pids + } + for { + pids, err := s.pids() + if err != nil { + return collect(), err + } + if len(pids) == 0 { + return collect(), nil + } + var joined error + for _, pid := range pids { + err := syscall.Kill(pid, syscall.SIGKILL) + if err == nil || err == syscall.ESRCH { + killed[pid] = true + continue + } + joined = errors.Join(joined, fmt.Errorf("kill scoped pid %d: %w", pid, err)) + } + if joined != nil { + return collect(), joined + } + if time.Now().After(deadline) { + return collect(), fmt.Errorf("process scope still contains pids %v", pids) + } + time.Sleep(20 * time.Millisecond) + } +} + +// prepare starts the job leader in its own session, so its process group can +// be signalled as a whole. +func (s *processScope) prepare(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} +} + +// adopt is a no-op on Unix: the scope marker and session are inherited at +// start. +func (s *processScope) adopt(*exec.Cmd) error { return nil } + +// signal delivers sig to the leader's process group and to scoped processes +// that left it. exited reports that the group no longer exists. +func (s *processScope) signal(leader int, sig syscall.Signal) (exited bool, err error) { + groupErr := syscall.Kill(-leader, sig) + exited = groupErr == syscall.ESRCH + if exited { + groupErr = nil + } + return exited, errors.Join(groupErr, s.signalEscaped(sig, leader)) +} + +// close releases OS resources held by the scope. Unix scopes hold none. +func (s *processScope) close() {} + +// exitSignal reports the signal that ended the job leader, if any. +func (s *processScope) exitSignal(ws syscall.WaitStatus) (syscall.Signal, bool) { + return ws.Signal(), ws.Signaled() +} diff --git a/internal/daemon/process_scope_windows.go b/internal/daemon/process_scope_windows.go new file mode 100644 index 00000000..6ecdf949 --- /dev/null +++ b/internal/daemon/process_scope_windows.go @@ -0,0 +1,184 @@ +//go:build windows + +package daemon + +import ( + "errors" + "fmt" + "os/exec" + "sync" + "syscall" + "time" + + "github.com/lydakis/errand/internal/proctree" +) + +// terminatedExitCode is the exit code TerminateJobObject gives processes the +// daemon kills, so the receipt can report the kill as a signal. +const terminatedExitCode = 0xE77A0009 + +// processScope is a Job Object on Windows. Every process the job leader +// starts joins it and cannot break away, so no environment or directory scan +// is needed. The Job Object kills its processes when the daemon exits, so a +// restarted daemon never has survivors to settle. +type processScope struct { + group *processGroupRecord + groupOwned bool + token string + + mu sync.Mutex + job *proctree.Job + killed syscall.Signal +} + +func newProcessScopeWithToken(token, _ string, _ ...string) (*processScope, error) { + return &processScope{token: token}, nil +} + +func (s *processScope) env() string { + return processScopeEnv + "=" + s.token +} + +func (s *processScope) prepare(cmd *exec.Cmd) { + proctree.Prepare(cmd) +} + +func (s *processScope) adopt(cmd *exec.Cmd) error { + job, err := proctree.New() + if err != nil { + _ = cmd.Process.Kill() + return err + } + if err := job.Adopt(cmd.Process); err != nil { + job.Close() + return err + } + s.mu.Lock() + s.job = job + s.mu.Unlock() + return nil +} + +// signal ends the whole job. Windows has no signal a daemon without a console +// can deliver to a process tree, so SIGINT and SIGTERM also terminate it. +func (s *processScope) signal(_ int, sig syscall.Signal) (exited bool, err error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.job == nil { + return true, nil + } + pids, err := s.job.PIDs() + if err != nil { + return false, err + } + if len(pids) == 0 { + return true, nil + } + if s.killed == 0 { + s.killed = sig + } + return false, s.job.Terminate(terminatedExitCode) +} + +func (s *processScope) exitSignal(ws syscall.WaitStatus) (syscall.Signal, bool) { + s.mu.Lock() + defer s.mu.Unlock() + if s.killed != 0 && uint32(ws.ExitStatus()) == terminatedExitCode { + return s.killed, true + } + return 0, false +} + +func (s *processScope) pids() ([]int, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.job == nil { + return nil, nil + } + return s.job.PIDs() +} + +// cleanup terminates whatever the job leader left running, then releases the +// job. It returns the pids it found for the receipt. +func (s *processScope) cleanup(timeout time.Duration) ([]int, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.job == nil { + return nil, nil + } + pids, err := cleanupWindowsJob(s.job, timeout) + s.job = nil + return pids, err +} + +// Keep the native job concrete outside cleanup. This small seam lets tests +// exercise API failures without process-wide hooks or extra system calls. +type scopeCleanupJob interface { + PIDs() ([]int, error) + Terminate(uint32) error + Close() error +} + +func cleanupWindowsJob(job scopeCleanupJob, timeout time.Duration) (pids []int, err error) { + // Releasing the last handle also invokes KILL_ON_JOB_CLOSE. A failed + // query, termination or wait must not strand it on a terminal job. + defer func() { + if closeErr := job.Close(); closeErr != nil { + if err == nil { + err = closeErr + } else { + err = errors.Join(err, closeErr) + } + } + }() + // The console host Windows attaches to the job leader exits on its own + // shortly after the last console client. Give it a moment so the receipt + // lists only processes the job really left behind. + settle := time.Now().Add(250 * time.Millisecond) + for { + var err error + pids, err = job.PIDs() + if err != nil { + return nil, err + } + if len(pids) == 0 { + return nil, nil + } + if time.Now().After(settle) { + break + } + time.Sleep(20 * time.Millisecond) + } + if err := job.Terminate(terminatedExitCode); err != nil { + return pids, err + } + deadline := time.Now().Add(timeout) + for { + remaining, err := job.PIDs() + if err != nil { + return pids, err + } + if len(remaining) == 0 { + return pids, nil + } + if time.Now().After(deadline) { + return pids, fmt.Errorf("process scope still contains pids %v", remaining) + } + time.Sleep(20 * time.Millisecond) + } +} + +func (s *processScope) close() { + s.mu.Lock() + defer s.mu.Unlock() + _ = s.closeLocked() +} + +func (s *processScope) closeLocked() error { + if s.job == nil { + return nil + } + err := s.job.Close() + s.job = nil + return err +} diff --git a/internal/daemon/process_scope_windows_test.go b/internal/daemon/process_scope_windows_test.go new file mode 100644 index 00000000..16800c22 --- /dev/null +++ b/internal/daemon/process_scope_windows_test.go @@ -0,0 +1,126 @@ +//go:build windows + +package daemon + +import ( + "errors" + "slices" + "strings" + "testing" + "time" + + "github.com/lydakis/errand/internal/proctree" +) + +type cleanupTestJob struct { + before, after []int + queryErr, afterQueryErr, terminateErr error + closeErr error + terminated bool + terminations, closes int +} + +func (j *cleanupTestJob) PIDs() ([]int, error) { + if j.terminated { + return j.after, j.afterQueryErr + } + return j.before, j.queryErr +} + +func (j *cleanupTestJob) Terminate(code uint32) error { + if code != terminatedExitCode { + panic("unexpected termination code") + } + j.terminations++ + j.terminated = true + return j.terminateErr +} + +func (j *cleanupTestJob) Close() error { + j.closes++ + return j.closeErr +} + +func TestWindowsJobCleanupClosesEveryOutcomeOnWindows(t *testing.T) { + queryErr, terminateErr, closeErr := errors.New("query failed"), errors.New("termination failed"), errors.New("close failed") + for _, tc := range []struct { + name string + job cleanupTestJob + wantErr error + wantPIDs []int + terminations int + timeout bool + }{ + {name: "empty"}, + {name: "query-error", job: cleanupTestJob{queryErr: queryErr}, wantErr: queryErr}, + {name: "termination-error", job: cleanupTestJob{before: []int{123}, terminateErr: terminateErr}, wantPIDs: []int{123}, wantErr: terminateErr, terminations: 1}, + {name: "query-error-after-termination", job: cleanupTestJob{before: []int{123}, afterQueryErr: queryErr}, wantPIDs: []int{123}, wantErr: queryErr, terminations: 1}, + {name: "timeout", job: cleanupTestJob{before: []int{123}, after: []int{123}}, wantPIDs: []int{123}, timeout: true, terminations: 1}, + {name: "terminated", job: cleanupTestJob{before: []int{123}}, wantPIDs: []int{123}, terminations: 1}, + {name: "close-error", job: cleanupTestJob{closeErr: closeErr}, wantErr: closeErr}, + {name: "query-and-close-errors", job: cleanupTestJob{queryErr: queryErr, closeErr: closeErr}, wantErr: queryErr}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + job := tc.job + pids, err := cleanupWindowsJob(&job, -time.Second) + if job.closes != 1 || job.terminations != tc.terminations { + t.Fatalf("close calls = %d, termination calls = %d; want 1, %d", job.closes, job.terminations, tc.terminations) + } + if !slices.Equal(pids, tc.wantPIDs) { + t.Fatalf("pids = %v, want %v", pids, tc.wantPIDs) + } + if tc.timeout { + if err == nil || !strings.Contains(err.Error(), "process scope still contains pids") { + t.Fatalf("cleanup error = %v; want timeout", err) + } + } else if tc.wantErr != nil { + if !errors.Is(err, tc.wantErr) { + t.Fatalf("cleanup error = %v; want %v", err, tc.wantErr) + } + } else if err != nil { + t.Fatal(err) + } + if job.closeErr != nil && !errors.Is(err, job.closeErr) { + t.Fatalf("cleanup error %v lost close error %v", err, job.closeErr) + } + }) + } +} + +func TestProcessScopeCleanupClearsJobOnWindows(t *testing.T) { + for _, closed := range []bool{false, true} { + job, err := proctree.New() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { job.Close() }) + if closed { + if err := job.Close(); err != nil { + t.Fatal(err) + } + } + scope := &processScope{job: job} + _, err = scope.cleanup(time.Second) + if (err != nil) != closed { + t.Fatalf("closed=%v: cleanup error = %v", closed, err) + } + if scope.job != nil { + t.Fatal("cleanup retained the native job") + } + if _, err := scope.cleanup(time.Second); err != nil { + t.Fatalf("repeated cleanup: %v", err) + } + scope.close() + } +} + +func BenchmarkCleanupEmptyWindowsJob(b *testing.B) { + job := &cleanupTestJob{} + b.ReportAllocs() + for b.Loop() { + if _, err := cleanupWindowsJob(job, time.Second); err != nil { + b.Fatal(err) + } + } +} diff --git a/internal/daemon/server.go b/internal/daemon/server.go index 2732a519..ea216ce1 100644 --- a/internal/daemon/server.go +++ b/internal/daemon/server.go @@ -18,6 +18,7 @@ import ( "os" "path" "path/filepath" + "runtime" "sort" "strconv" "strings" @@ -27,6 +28,8 @@ import ( "github.com/lydakis/errand/internal/archive" changeops "github.com/lydakis/errand/internal/changes" + "github.com/lydakis/errand/internal/durable" + "github.com/lydakis/errand/internal/filelock" "github.com/lydakis/errand/internal/logio" "github.com/lydakis/errand/internal/namedcache" "github.com/lydakis/errand/internal/pathpolicy" @@ -134,6 +137,11 @@ func New(cfg Config) (*Daemon, error) { if cfg.MaxUploadBytes <= cfg.MaxLimits.MaxWorkspaceBytes { return nil, fmt.Errorf("max upload bytes must exceed the workspace byte ceiling") } + if runtime.GOOS == "windows" { + // Named cache trees rely on hard links and POSIX modes that NTFS + // doesn't give them yet. + cfg.NamedCacheDisabled = true + } if cfg.NamedCacheMaxBytes == 0 { cfg.NamedCacheMaxBytes = defaultCacheMaxBytes } @@ -225,7 +233,15 @@ func (d *Daemon) lockStateDir() error { if err != nil { return err } - if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { + err = filelock.TryLock(f) + // Windows releases a stopped process's locks only after it has fully + // exited. A service manager restarting the runner, as setup does on an + // upgrade, can start the new one first, so it waits briefly. + for deadline := time.Now().Add(stateLockWait); errors.Is(err, filelock.ErrLocked) && time.Now().Before(deadline); { + time.Sleep(100 * time.Millisecond) + err = filelock.TryLock(f) + } + if err != nil { _ = f.Close() return fmt.Errorf("state directory %q is already in use: %w", d.cfg.StateDir, err) } @@ -233,6 +249,13 @@ func (d *Daemon) lockStateDir() error { return nil } +var stateLockWait = func() time.Duration { + if runtime.GOOS == "windows" { + return 10 * time.Second + } + return 0 +}() + // Close releases the process-wide ownership of the daemon state directory. func (d *Daemon) Close() error { d.closeOnce.Do(func() { @@ -245,7 +268,7 @@ func (d *Daemon) Close() error { if d.lockFile == nil { return } - if err := syscall.Flock(int(d.lockFile.Fd()), syscall.LOCK_UN); err != nil { + if err := filelock.Unlock(d.lockFile); err != nil { d.closeErr = err } if err := d.lockFile.Close(); err != nil && d.closeErr == nil { @@ -284,7 +307,7 @@ func syncDirectory(path string) error { return err } defer dir.Close() - return dir.Sync() + return durable.Sync(dir) } func ensureChildDirectoryDurable(path string, mode os.FileMode) error { diff --git a/internal/daemon/state_lock_windows_test.go b/internal/daemon/state_lock_windows_test.go new file mode 100644 index 00000000..2e8b4dea --- /dev/null +++ b/internal/daemon/state_lock_windows_test.go @@ -0,0 +1,35 @@ +//go:build windows + +package daemon + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/lydakis/errand/internal/filelock" +) + +// Setup restarts the runner by stopping the old process and starting the new +// one at once; the new runner waits for the old one's state lock. +func TestRunnerWaitsForTheStoppingRunnersLockOnWindows(t *testing.T) { + stateDir := t.TempDir() + old, err := os.OpenFile(filepath.Join(stateDir, ".daemon.lock"), os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + t.Fatal(err) + } + if err := filelock.TryLock(old); err != nil { + t.Fatal(err) + } + go func() { + time.Sleep(500 * time.Millisecond) + _ = filelock.Unlock(old) + _ = old.Close() + }() + d, err := New(Config{StateDir: stateDir, InsecureNoAuth: true}) + if err != nil { + t.Fatal(err) + } + d.Close() +} diff --git a/internal/daemon/umask_unix_test.go b/internal/daemon/umask_unix_test.go new file mode 100644 index 00000000..f31efc3b --- /dev/null +++ b/internal/daemon/umask_unix_test.go @@ -0,0 +1,13 @@ +//go:build unix + +package daemon + +import ( + "syscall" + "testing" +) + +func setUmask(_ *testing.T, mask int) (restore func()) { + old := syscall.Umask(mask) + return func() { syscall.Umask(old) } +} diff --git a/internal/daemon/umask_windows_test.go b/internal/daemon/umask_windows_test.go new file mode 100644 index 00000000..c73f77eb --- /dev/null +++ b/internal/daemon/umask_windows_test.go @@ -0,0 +1,10 @@ +//go:build windows + +package daemon + +import "testing" + +func setUmask(t *testing.T, _ int) (restore func()) { + t.Skip("Windows has no umask") + return nil +} diff --git a/internal/daemon/workspace_concurrent_recovery_test.go b/internal/daemon/workspace_concurrent_recovery_test.go index 8039a26c..5a741642 100644 --- a/internal/daemon/workspace_concurrent_recovery_test.go +++ b/internal/daemon/workspace_concurrent_recovery_test.go @@ -1,3 +1,5 @@ +//go:build unix + package daemon import ( diff --git a/internal/daemon/workspace_leases.go b/internal/daemon/workspace_leases.go index 4a420d05..866bc9eb 100644 --- a/internal/daemon/workspace_leases.go +++ b/internal/daemon/workspace_leases.go @@ -10,6 +10,7 @@ import ( "slices" changeops "github.com/lydakis/errand/internal/changes" + "github.com/lydakis/errand/internal/durable" "github.com/lydakis/errand/internal/proto" ) @@ -258,7 +259,7 @@ func settlePersistentCachePaths(j *Job, workspace string, caches []proto.CacheBi if err != nil { return err } - err = f.Sync() + err = durable.Sync(f) closeErr := f.Close() if err != nil { return err diff --git a/internal/daemon/workspace_links_windows_test.go b/internal/daemon/workspace_links_windows_test.go new file mode 100644 index 00000000..8836df57 --- /dev/null +++ b/internal/daemon/workspace_links_windows_test.go @@ -0,0 +1,137 @@ +//go:build windows + +package daemon + +import ( + "bytes" + "encoding/json" + "mime/multipart" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/lydakis/errand/internal/changes" + "github.com/lydakis/errand/internal/proto" + "github.com/lydakis/errand/internal/snapshot" +) + +func TestWorkspaceDirectorySymlinkPushOnWindows(t *testing.T) { + d, _ := testDaemon(t) + root := workspaceWith(t, map[string]string{"target/value": "body"}) + // The lexical first link points through a link created later on extraction. + for _, link := range []struct{ name, target string }{{"z", "target"}, {"a", "z"}} { + if err := os.Symlink(link.target, filepath.Join(root, link.name)); err != nil { + t.Fatal(err) + } + } + baseline, err := snapshot.Build(root, []string{"a", "target", "target/value", "z"}) + if err != nil { + t.Fatal(err) + } + workspaceID := proto.NewULID() + // Send the runner protocol directly, as a Mac/Linux client would. The + // Windows client itself does not support local workspace state yet. + upload := func(metadata any, manifest proto.Manifest, creation bool) *httptest.ResponseRecorder { + t.Helper() + var body bytes.Buffer + mw := multipart.NewWriter(&body) + part, err := mw.CreateFormField("metadata") + if err != nil { + t.Fatal(err) + } + if err := json.NewEncoder(part).Encode(metadata); err != nil { + t.Fatal(err) + } + if creation { + part, err = mw.CreateFormField("manifest") + if err != nil { + t.Fatal(err) + } + if err := json.NewEncoder(part).Encode(manifest); err != nil { + t.Fatal(err) + } + } + part, err = mw.CreateFormFile("workspace", "workspace.tar") + if err != nil { + t.Fatal(err) + } + if err := snapshot.PackContext(t.Context(), part, root, manifest); err != nil { + t.Fatal(err) + } + if err := mw.Close(); err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodPost, "/", &body) + req.Header.Set("Content-Type", mw.FormDataContentType()) + req.SetPathValue("id", workspaceID) + response := httptest.NewRecorder() + if creation { + d.handleWorkspaceCreate(response, req, Identity{}) + } else { + d.handleWorkspacePush(response, req, Identity{}) + } + if response.Code != http.StatusCreated { + t.Fatalf("upload: %d %s", response.Code, response.Body) + } + return response + } + upload(proto.Workspace{ID: workspaceID, Name: "links"}, baseline, true) + remote := filepath.Join(d.workspaces.dir, workspaceID, "data") + if body, err := os.ReadFile(filepath.Join(remote, "a", "value")); err != nil || string(body) != "body" { + t.Fatalf("initial directory chain = %q, %v", body, err) + } + if err := os.Mkdir(filepath.Join(root, "nested"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink("../target", filepath.Join(root, "nested", "alias")); err != nil { + t.Fatal(err) + } + current, err := snapshot.Build(root, []string{"a", "nested", "nested/alias", "target", "target/value", "z"}) + if err != nil { + t.Fatal(err) + } + delta, err := changes.PrepareSourceDelta(t.Context(), baseline, current, 1<<20) + if err != nil { + t.Fatal(err) + } + const clientID = "0123456789abcdef0123456789abcdef" + transferID := proto.NewULID() + upload(proto.PushRequest{ID: transferID, ClientID: clientID, Delta: &delta, SourceRoot: current.RootHash()}, delta.RemoteManifest, false) + req := httptest.NewRequest(http.MethodPost, "/?client="+clientID, strings.NewReader("{}")) + req.SetPathValue("id", workspaceID) + req.SetPathValue("transfer", transferID) + response := httptest.NewRecorder() + d.handleWorkspacePushApply(response, req, Identity{}) + if response.Code != http.StatusOK { + t.Fatalf("apply: %d %s", response.Code, response.Body) + } + if body, err := os.ReadFile(filepath.Join(remote, "nested", "alias", "value")); err != nil || string(body) != "body" { + t.Fatalf("pushed directory link = %q, %v", body, err) + } +} + +// Directory links reach a Windows job as directory links, including a link +// through another link that is created after it, and one in a subdirectory. +// The Windows client isn't supported yet, so this goes through a job rather +// than a workspace, whose client keeps POSIX-checked local state. +func TestJobDirectoryLinksOnWindows(t *testing.T) { + d, ts := testDaemon(t) + root := workspaceWith(t, map[string]string{"target/value": "body"}) + if err := os.Mkdir(filepath.Join(root, "nested"), 0o755); err != nil { + t.Fatal(err) + } + for _, link := range []struct{ name, target string }{{"z", "target"}, {"a", "z"}, {"nested/alias", "../target"}} { + if err := os.Symlink(link.target, filepath.Join(root, filepath.FromSlash(link.name))); err != nil { + t.Fatal(err) + } + } + _, status := submitChangeJob(t, d, ts.URL, root, + []string{"cmd", "/d", "/c", `type a\value >nul && type nested\alias\value >nul && exit /b 7`}) + result := status.Result + if result == nil || result.StartError != "" || result.ExitCode == nil || *result.ExitCode != 7 { + t.Fatalf("result = %+v", result) + } +} diff --git a/internal/daemon/workspace_push.go b/internal/daemon/workspace_push.go index 8d2d551c..da83ba99 100644 --- a/internal/daemon/workspace_push.go +++ b/internal/daemon/workspace_push.go @@ -149,6 +149,7 @@ func (d *Daemon) handleWorkspacePush(w http.ResponseWriter, r *http.Request, id return } extractOpts, restored := d.snapshotExtractOptions(r.Context()) + extractOpts.SymlinkManifest = &request.Manifest if err := archive.ExtractWith(&contextReader{ctx: r.Context(), r: part}, source, sourceManifest, d.cfg.MaxLimits.MaxWorkspaceBytes, extractOpts); err != nil { if errors.Is(err, archive.ErrCacheMiss) { httpErrorCode(w, http.StatusConflict, proto.ErrorCodeSnapshotCacheMiss, err.Error()) diff --git a/internal/daemon/workspace_transfer_test.go b/internal/daemon/workspace_transfer_test.go index 2e87a1c1..e9acfefb 100644 --- a/internal/daemon/workspace_transfer_test.go +++ b/internal/daemon/workspace_transfer_test.go @@ -6,7 +6,6 @@ import ( "crypto/sha256" "encoding/hex" "errors" - changeops "github.com/lydakis/errand/internal/changes" "net/http" "net/http/httptest" "os" @@ -16,6 +15,8 @@ import ( "testing" "time" + changeops "github.com/lydakis/errand/internal/changes" + "github.com/lydakis/errand/internal/client" "github.com/lydakis/errand/internal/proto" ) diff --git a/internal/daemon/workspace_upload_test.go b/internal/daemon/workspace_upload_test.go index 1cb39deb..ca98d65a 100644 --- a/internal/daemon/workspace_upload_test.go +++ b/internal/daemon/workspace_upload_test.go @@ -10,9 +10,7 @@ import ( "net/http/httptest" "os" "path/filepath" - "strconv" "sync" - "syscall" "testing" "time" @@ -248,126 +246,3 @@ func TestWorkspaceTransferGCReportsPartialFailure(t *testing.T) { t.Fatalf("partial GC: %+v %v", result, err) } } - -func TestWaitingWorkspacePushesHoldNoDataHandle(t *testing.T) { - d, ts := testDaemon(t) - root := workspaceWith(t, map[string]string{"value": "initial\n"}) - ws, err := client.CreateWorkspace(client.RunOptions{PeerURL: ts.URL, Root: root}, "queued") - if err != nil { - t.Fatal(err) - } - body := func() (*bytes.Buffer, string, int) { - var payload bytes.Buffer - mw := multipart.NewWriter(&payload) - part, err := mw.CreateFormField("metadata") - if err != nil { - t.Fatal(err) - } - if err := json.NewEncoder(part).Encode(proto.PushRequest{ID: proto.NewULID(), ClientID: "0123456789abcdef0123456789abcdef", Manifest: ws.Manifest}); err != nil { - t.Fatal(err) - } - part, err = mw.CreateFormFile("workspace", "workspace.tar") - if err != nil { - t.Fatal(err) - } - split := payload.Len() - if err := snapshot.PackPartial(part, root, ws.Manifest, nil); err != nil { - t.Fatal(err) - } - if err := mw.Close(); err != nil { - t.Fatal(err) - } - return &payload, mw.FormDataContentType(), split - } - push := func(r io.Reader, contentType string) (*httptest.ResponseRecorder, chan struct{}) { - req := httptest.NewRequest("POST", "/", r) - req.Header.Set("Content-Type", contentType) - req.SetPathValue("id", ws.ID) - response := httptest.NewRecorder() - done := make(chan struct{}) - go func() { defer close(done); d.handleWorkspacePush(response, req, Identity{}) }() - return response, done - } - data := filepath.Join(d.workspaces.dir, ws.ID, "data") - info, err := os.Stat(data) - if err != nil { - t.Fatal(err) - } - want := info.Sys().(*syscall.Stat_t) - // Count descriptors by fstat: on macOS, stat of a /dev/fd entry does not - // describe the directory it references. - handles := func() int { - entries, err := os.ReadDir("/dev/fd") - if err != nil { - t.Fatal(err) - } - n := 0 - for _, e := range entries { - fd, err := strconv.Atoi(e.Name()) - var st syscall.Stat_t - if err == nil && syscall.Fstat(fd, &st) == nil && st.Dev == want.Dev && st.Ino == want.Ino { - n++ - } - } - return n - } - - payload, contentType, split := body() - active := &pausedWorkspaceUpload{prefix: bytes.NewReader(payload.Bytes()[:split]), rest: bytes.NewReader(payload.Bytes()[split:]), stalled: make(chan struct{}), resume: make(chan struct{})} - var resume sync.Once - release := func() { resume.Do(func() { close(active.resume) }) } - activeResponse, activeDone := push(active, contentType) - var dones []chan struct{} - // Every exit, including a failed assertion, finishes the pushes before - // the test's directories are removed. - defer func() { - release() - <-activeDone - for _, done := range dones { - <-done - } - }() - <-active.stalled - pinned := handles() - if pinned != 1 { - t.Fatalf("admitted push holds %d data directory handles, want 1", pinned) - } - - const queued = 4 - waiting := make(chan string, queued) - d.workspaces.mu.Lock() - d.workspaces.testHookUploadWaiting = func(id string) { - select { - case waiting <- id: - default: - } - } - d.workspaces.mu.Unlock() - var responses []*httptest.ResponseRecorder - for range queued { - payload, contentType, _ := body() - response, done := push(payload, contentType) - responses = append(responses, response) - dones = append(dones, done) - } - for range queued { - <-waiting - } - if got := handles(); got != pinned { - t.Fatalf("%d waiting pushes hold %d data directory handles", queued, got-pinned) - } - - release() - <-activeDone - for _, done := range dones { - <-done - } - for i, response := range append([]*httptest.ResponseRecorder{activeResponse}, responses...) { - if response.Code != http.StatusCreated { - t.Fatalf("push %d: %d %s", i, response.Code, response.Body.String()) - } - } - if got := handles(); got != 0 { - t.Fatalf("finished pushes left %d data directory handles open", got) - } -} diff --git a/internal/daemon/workspace_upload_unix_test.go b/internal/daemon/workspace_upload_unix_test.go new file mode 100644 index 00000000..79d6881d --- /dev/null +++ b/internal/daemon/workspace_upload_unix_test.go @@ -0,0 +1,145 @@ +//go:build unix + +package daemon + +import ( + "bytes" + "encoding/json" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strconv" + "sync" + "syscall" + "testing" + + "github.com/lydakis/errand/internal/client" + "github.com/lydakis/errand/internal/proto" + "github.com/lydakis/errand/internal/snapshot" +) + +func TestWaitingWorkspacePushesHoldNoDataHandle(t *testing.T) { + d, ts := testDaemon(t) + root := workspaceWith(t, map[string]string{"value": "initial\n"}) + ws, err := client.CreateWorkspace(client.RunOptions{PeerURL: ts.URL, Root: root}, "queued") + if err != nil { + t.Fatal(err) + } + body := func() (*bytes.Buffer, string, int) { + var payload bytes.Buffer + mw := multipart.NewWriter(&payload) + part, err := mw.CreateFormField("metadata") + if err != nil { + t.Fatal(err) + } + if err := json.NewEncoder(part).Encode(proto.PushRequest{ID: proto.NewULID(), ClientID: "0123456789abcdef0123456789abcdef", Manifest: ws.Manifest}); err != nil { + t.Fatal(err) + } + part, err = mw.CreateFormFile("workspace", "workspace.tar") + if err != nil { + t.Fatal(err) + } + split := payload.Len() + if err := snapshot.PackPartial(part, root, ws.Manifest, nil); err != nil { + t.Fatal(err) + } + if err := mw.Close(); err != nil { + t.Fatal(err) + } + return &payload, mw.FormDataContentType(), split + } + push := func(r io.Reader, contentType string) (*httptest.ResponseRecorder, chan struct{}) { + req := httptest.NewRequest("POST", "/", r) + req.Header.Set("Content-Type", contentType) + req.SetPathValue("id", ws.ID) + response := httptest.NewRecorder() + done := make(chan struct{}) + go func() { defer close(done); d.handleWorkspacePush(response, req, Identity{}) }() + return response, done + } + data := filepath.Join(d.workspaces.dir, ws.ID, "data") + info, err := os.Stat(data) + if err != nil { + t.Fatal(err) + } + want := info.Sys().(*syscall.Stat_t) + // Count descriptors by fstat: on macOS, stat of a /dev/fd entry does not + // describe the directory it references. + handles := func() int { + entries, err := os.ReadDir("/dev/fd") + if err != nil { + t.Fatal(err) + } + n := 0 + for _, e := range entries { + fd, err := strconv.Atoi(e.Name()) + var st syscall.Stat_t + if err == nil && syscall.Fstat(fd, &st) == nil && st.Dev == want.Dev && st.Ino == want.Ino { + n++ + } + } + return n + } + + payload, contentType, split := body() + active := &pausedWorkspaceUpload{prefix: bytes.NewReader(payload.Bytes()[:split]), rest: bytes.NewReader(payload.Bytes()[split:]), stalled: make(chan struct{}), resume: make(chan struct{})} + var resume sync.Once + release := func() { resume.Do(func() { close(active.resume) }) } + activeResponse, activeDone := push(active, contentType) + var dones []chan struct{} + // Every exit, including a failed assertion, finishes the pushes before + // the test's directories are removed. + defer func() { + release() + <-activeDone + for _, done := range dones { + <-done + } + }() + <-active.stalled + pinned := handles() + if pinned != 1 { + t.Fatalf("admitted push holds %d data directory handles, want 1", pinned) + } + + const queued = 4 + waiting := make(chan string, queued) + d.workspaces.mu.Lock() + d.workspaces.testHookUploadWaiting = func(id string) { + select { + case waiting <- id: + default: + } + } + d.workspaces.mu.Unlock() + var responses []*httptest.ResponseRecorder + for range queued { + payload, contentType, _ := body() + response, done := push(payload, contentType) + responses = append(responses, response) + dones = append(dones, done) + } + for range queued { + <-waiting + } + if got := handles(); got != pinned { + t.Fatalf("%d waiting pushes hold %d data directory handles", queued, got-pinned) + } + + release() + <-activeDone + for _, done := range dones { + <-done + } + for i, response := range append([]*httptest.ResponseRecorder{activeResponse}, responses...) { + if response.Code != http.StatusCreated { + t.Fatalf("push %d: %d %s", i, response.Code, response.Body.String()) + } + } + if got := handles(); got != 0 { + t.Fatalf("finished pushes left %d data directory handles open", got) + } +} diff --git a/internal/durable/durable.go b/internal/durable/durable.go new file mode 100644 index 00000000..124d7ddb --- /dev/null +++ b/internal/durable/durable.go @@ -0,0 +1,7 @@ +// Package durable flushes files and directories to stable storage. +package durable + +import "os" + +// Sync flushes f, which may be a regular file or a directory. +func Sync(f *os.File) error { return syncFile(f) } diff --git a/internal/durable/durable_test.go b/internal/durable/durable_test.go new file mode 100644 index 00000000..b722eb1a --- /dev/null +++ b/internal/durable/durable_test.go @@ -0,0 +1,30 @@ +package durable + +import ( + "os" + "path/filepath" + "testing" +) + +func TestSyncFilesAndDirectories(t *testing.T) { + dir := t.TempDir() + file, err := os.Create(filepath.Join(dir, "file")) + if err != nil { + t.Fatal(err) + } + defer file.Close() + if _, err := file.WriteString("data"); err != nil { + t.Fatal(err) + } + if err := Sync(file); err != nil { + t.Fatalf("sync file: %v", err) + } + directory, err := os.Open(dir) + if err != nil { + t.Fatal(err) + } + defer directory.Close() + if err := Sync(directory); err != nil { + t.Fatalf("sync directory: %v", err) + } +} diff --git a/internal/durable/durable_unix.go b/internal/durable/durable_unix.go new file mode 100644 index 00000000..9878e55c --- /dev/null +++ b/internal/durable/durable_unix.go @@ -0,0 +1,7 @@ +//go:build unix + +package durable + +import "os" + +func syncFile(f *os.File) error { return f.Sync() } diff --git a/internal/durable/durable_windows.go b/internal/durable/durable_windows.go new file mode 100644 index 00000000..0764e9d0 --- /dev/null +++ b/internal/durable/durable_windows.go @@ -0,0 +1,18 @@ +//go:build windows + +package durable + +import "os" + +// Windows can't flush a directory handle opened for reading. NTFS journals +// directory entries itself, so a completed rename needs no further barrier. +func syncFile(f *os.File) error { + info, err := f.Stat() + if err != nil { + return err + } + if info.IsDir() { + return nil + } + return f.Sync() +} diff --git a/internal/filelock/filelock.go b/internal/filelock/filelock.go new file mode 100644 index 00000000..4892a583 --- /dev/null +++ b/internal/filelock/filelock.go @@ -0,0 +1,23 @@ +// Package filelock takes advisory whole-file locks that other processes see. +// +// Locks belong to the open file, not the process. Unlock before closing: +// Windows may release a closed file's locks late. Callers must not hold +// shared and exclusive locks on one open file at the same time. +package filelock + +import ( + "errors" + "os" +) + +// ErrLocked reports that another open file holds a conflicting lock. +var ErrLocked = errors.New("file is locked by another process") + +// TryLock takes an exclusive lock without waiting. +func TryLock(f *os.File) error { return tryLock(f, true) } + +// TryRLock takes a shared lock without waiting. +func TryRLock(f *os.File) error { return tryLock(f, false) } + +// Unlock releases a lock taken on f. +func Unlock(f *os.File) error { return unlock(f) } diff --git a/internal/filelock/filelock_test.go b/internal/filelock/filelock_test.go new file mode 100644 index 00000000..37c5184a --- /dev/null +++ b/internal/filelock/filelock_test.go @@ -0,0 +1,59 @@ +package filelock + +import ( + "errors" + "os" + "path/filepath" + "testing" +) + +func openTwice(t *testing.T) (*os.File, *os.File) { + t.Helper() + path := filepath.Join(t.TempDir(), "lock") + first, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { first.Close() }) + second, err := os.OpenFile(path, os.O_RDWR, 0) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { second.Close() }) + return first, second +} + +func TestExclusiveLockExcludesOtherOpenFiles(t *testing.T) { + first, second := openTwice(t) + if err := TryLock(first); err != nil { + t.Fatal(err) + } + if err := TryLock(second); !errors.Is(err, ErrLocked) { + t.Fatalf("second exclusive lock: %v, want ErrLocked", err) + } + if err := TryRLock(second); !errors.Is(err, ErrLocked) { + t.Fatalf("shared lock under exclusive: %v, want ErrLocked", err) + } + if err := Unlock(first); err != nil { + t.Fatal(err) + } + if err := TryLock(second); err != nil { + t.Fatalf("lock after unlock: %v", err) + } +} + +func TestSharedLocksCoexistButExcludeExclusive(t *testing.T) { + first, second := openTwice(t) + if err := TryRLock(first); err != nil { + t.Fatal(err) + } + if err := TryRLock(second); err != nil { + t.Fatalf("second shared lock: %v", err) + } + if err := Unlock(second); err != nil { + t.Fatal(err) + } + if err := TryLock(second); !errors.Is(err, ErrLocked) { + t.Fatalf("exclusive lock under shared: %v, want ErrLocked", err) + } +} diff --git a/internal/filelock/filelock_unix.go b/internal/filelock/filelock_unix.go new file mode 100644 index 00000000..726dbbc6 --- /dev/null +++ b/internal/filelock/filelock_unix.go @@ -0,0 +1,26 @@ +//go:build unix + +package filelock + +import ( + "errors" + "fmt" + "os" + "syscall" +) + +func tryLock(f *os.File, exclusive bool) error { + how := syscall.LOCK_SH + if exclusive { + how = syscall.LOCK_EX + } + err := syscall.Flock(int(f.Fd()), how|syscall.LOCK_NB) + if errors.Is(err, syscall.EWOULDBLOCK) || errors.Is(err, syscall.EAGAIN) { + return fmt.Errorf("%w: %w", ErrLocked, err) + } + return err +} + +func unlock(f *os.File) error { + return syscall.Flock(int(f.Fd()), syscall.LOCK_UN) +} diff --git a/internal/filelock/filelock_windows.go b/internal/filelock/filelock_windows.go new file mode 100644 index 00000000..0439a4cf --- /dev/null +++ b/internal/filelock/filelock_windows.go @@ -0,0 +1,30 @@ +//go:build windows + +package filelock + +import ( + "errors" + "fmt" + "os" + + "golang.org/x/sys/windows" +) + +// Lock the whole possible range, as the go command's lockedfile package does. +const allBytes = ^uint32(0) + +func tryLock(f *os.File, exclusive bool) error { + flags := uint32(windows.LOCKFILE_FAIL_IMMEDIATELY) + if exclusive { + flags |= windows.LOCKFILE_EXCLUSIVE_LOCK + } + err := windows.LockFileEx(windows.Handle(f.Fd()), flags, 0, allBytes, allBytes, new(windows.Overlapped)) + if errors.Is(err, windows.ERROR_LOCK_VIOLATION) || errors.Is(err, windows.ERROR_IO_PENDING) { + return fmt.Errorf("%w: %w", ErrLocked, err) + } + return err +} + +func unlock(f *os.File) error { + return windows.UnlockFileEx(windows.Handle(f.Fd()), 0, allBytes, allBytes, new(windows.Overlapped)) +} diff --git a/internal/fsidentity/identity.go b/internal/fsidentity/identity.go index 730d50b7..0c9c2e4b 100644 --- a/internal/fsidentity/identity.go +++ b/internal/fsidentity/identity.go @@ -1,11 +1,7 @@ -// Package fsidentity records stable Unix filesystem object identities. +// Package fsidentity records stable filesystem object identities. package fsidentity -import ( - "fmt" - "os" - "syscall" -) +import "os" // Identity distinguishes one filesystem object from another at the same path. type Identity struct { @@ -13,14 +9,6 @@ type Identity struct { Inode uint64 `json:"inode"` } -func FromInfo(info os.FileInfo) (Identity, error) { - stat, ok := info.Sys().(*syscall.Stat_t) - if !ok { - return Identity{}, fmt.Errorf("filesystem identity is unavailable for %q", info.Name()) - } - return Identity{Device: uint64(stat.Dev), Inode: uint64(stat.Ino)}, nil -} - func Lstat(path string) (Identity, os.FileInfo, error) { info, err := os.Lstat(path) if err != nil { diff --git a/internal/fsidentity/identity_test.go b/internal/fsidentity/identity_test.go new file mode 100644 index 00000000..11f16b4d --- /dev/null +++ b/internal/fsidentity/identity_test.go @@ -0,0 +1,88 @@ +package fsidentity + +import ( + "os" + "path/filepath" + "testing" +) + +func TestIdentityDistinguishesFilesAndFollowsHardLinks(t *testing.T) { + dir := t.TempDir() + first := filepath.Join(dir, "first") + second := filepath.Join(dir, "second") + for _, path := range []string{first, second} { + if err := os.WriteFile(path, []byte(path), 0o644); err != nil { + t.Fatal(err) + } + } + firstID, _, err := Lstat(first) + if err != nil { + t.Fatal(err) + } + secondID, _, err := Lstat(second) + if err != nil { + t.Fatal(err) + } + if firstID.IsZero() || secondID.IsZero() { + t.Fatalf("zero identity: %+v %+v", firstID, secondID) + } + if firstID == secondID { + t.Fatalf("distinct files share identity %+v", firstID) + } + + f, err := os.Open(first) + if err != nil { + t.Fatal(err) + } + defer f.Close() + opened, err := f.Stat() + if err != nil { + t.Fatal(err) + } + openedID, err := FromInfo(opened) + if err != nil { + t.Fatal(err) + } + if openedID != firstID { + t.Fatalf("opened identity %+v, path identity %+v", openedID, firstID) + } + + link := filepath.Join(dir, "link") + if err := os.Link(first, link); err != nil { + t.Fatal(err) + } + linkID, _, err := Lstat(link) + if err != nil { + t.Fatal(err) + } + if linkID != firstID { + t.Fatalf("hard link identity %+v, original %+v", linkID, firstID) + } +} + +func TestIdentityThroughRootMatchesPath(t *testing.T) { + dir := t.TempDir() + if err := os.Mkdir(filepath.Join(dir, "child"), 0o755); err != nil { + t.Fatal(err) + } + root, err := os.OpenRoot(dir) + if err != nil { + t.Fatal(err) + } + defer root.Close() + info, err := root.Lstat("child") + if err != nil { + t.Fatal(err) + } + rootID, err := FromInfo(info) + if err != nil { + t.Fatal(err) + } + pathID, _, err := Lstat(filepath.Join(dir, "child")) + if err != nil { + t.Fatal(err) + } + if rootID != pathID { + t.Fatalf("root identity %+v, path identity %+v", rootID, pathID) + } +} diff --git a/internal/fsidentity/identity_unix.go b/internal/fsidentity/identity_unix.go new file mode 100644 index 00000000..a12a3e06 --- /dev/null +++ b/internal/fsidentity/identity_unix.go @@ -0,0 +1,17 @@ +//go:build unix + +package fsidentity + +import ( + "fmt" + "os" + "syscall" +) + +func FromInfo(info os.FileInfo) (Identity, error) { + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return Identity{}, fmt.Errorf("filesystem identity is unavailable for %q", info.Name()) + } + return Identity{Device: uint64(stat.Dev), Inode: uint64(stat.Ino)}, nil +} diff --git a/internal/fsidentity/identity_windows.go b/internal/fsidentity/identity_windows.go new file mode 100644 index 00000000..e63e6290 --- /dev/null +++ b/internal/fsidentity/identity_windows.go @@ -0,0 +1,49 @@ +//go:build windows + +package fsidentity + +import ( + "fmt" + "os" + "reflect" +) + +// FromInfo returns the volume serial number and file index that os.SameFile +// compares. The os package keeps them unexported, so they are read by name +// after os.SameFile has loaded them. +// +// FileInfo from File.Stat or an os.Root lookup already holds the identity +// captured from its handle. FileInfo from os.Lstat or os.Stat loads it from +// the path on first use, as os.SameFile does. +func FromInfo(info os.FileInfo) (Identity, error) { + if info == nil || !os.SameFile(info, info) { + return Identity{}, fmt.Errorf("filesystem identity is unavailable for %q", nameOf(info)) + } + value := reflect.ValueOf(info) + if value.Kind() != reflect.Pointer || value.IsNil() || value.Elem().Kind() != reflect.Struct { + return Identity{}, fmt.Errorf("filesystem identity is unavailable for %q", info.Name()) + } + stat := value.Elem() + volume, volumeOK := uint32Field(stat, "vol") + high, highOK := uint32Field(stat, "idxhi") + low, lowOK := uint32Field(stat, "idxlo") + if !volumeOK || !highOK || !lowOK { + return Identity{}, fmt.Errorf("filesystem identity is unavailable for %q", info.Name()) + } + return Identity{Device: uint64(volume), Inode: uint64(high)<<32 | uint64(low)}, nil +} + +func uint32Field(value reflect.Value, name string) (uint32, bool) { + field := value.FieldByName(name) + if !field.IsValid() || field.Kind() != reflect.Uint32 { + return 0, false + } + return uint32(field.Uint()), true +} + +func nameOf(info os.FileInfo) string { + if info == nil { + return "" + } + return info.Name() +} diff --git a/internal/fslink/fslink.go b/internal/fslink/fslink.go new file mode 100644 index 00000000..5362e721 --- /dev/null +++ b/internal/fslink/fslink.go @@ -0,0 +1,76 @@ +// Package fslink preserves Windows' file/directory symlink distinction when a +// link is recreated in private staging before its referent is present. +package fslink + +import ( + "path" + "slices" + "sort" + "strings" + + "github.com/lydakis/errand/internal/proto" +) + +type Lookup func(string) (proto.ManifestEntry, bool) + +// ManifestLookup borrows sorted metadata without allocating an index. Archive +// callers may supply unsorted manifests; only those need a sorted copy. +func ManifestLookup(m proto.Manifest) Lookup { + entries := m.Entries + compare := func(a, b proto.ManifestEntry) int { return strings.Compare(a.Path, b.Path) } + if !slices.IsSortedFunc(entries, compare) { + entries = slices.Clone(entries) + slices.SortFunc(entries, compare) + } + return func(name string) (proto.ManifestEntry, bool) { + i := sort.Search(len(entries), func(i int) bool { return entries[i].Path >= name }) + if i < len(entries) && entries[i].Path == name { + return entries[i], true + } + // A manifest may omit explicit directory entries. + prefix := name + "/" + i = sort.Search(len(entries), func(i int) bool { return entries[i].Path >= prefix }) + if i < len(entries) && strings.HasPrefix(entries[i].Path, prefix) { + return proto.ManifestEntry{Path: name, Type: proto.EntryDir}, true + } + return proto.ManifestEntry{}, false + } +} + +// IsDirectory follows manifest links, including links in target ancestors. +// Missing referents and cycles use the file-link default. No filesystem reads +// or traversal through untrusted links are needed. +func IsDirectory(e proto.ManifestEntry, lookup Lookup) bool { + name := path.Clean(path.Join(path.Dir(e.Path), e.Target)) + for hops := 0; hops < 40; hops++ { + if name == "." { + return true + } + if name == ".." || strings.HasPrefix(name, "../") || path.IsAbs(name) { + return false + } + if entry, ok := lookup(name); ok { + switch entry.Type { + case proto.EntryDir: + return true + case proto.EntryFile: + return false + case proto.EntrySymlink: + name = path.Clean(path.Join(path.Dir(name), entry.Target)) + continue + } + } + resolved := false + for parent := path.Dir(name); parent != "."; parent = path.Dir(parent) { + if entry, ok := lookup(parent); ok && entry.Type == proto.EntrySymlink { + name = path.Clean(path.Join(path.Dir(parent), entry.Target, strings.TrimPrefix(name, parent+"/"))) + resolved = true + break + } + } + if !resolved { + return false + } + } + return false +} diff --git a/internal/fslink/fslink_test.go b/internal/fslink/fslink_test.go new file mode 100644 index 00000000..4c8a3da2 --- /dev/null +++ b/internal/fslink/fslink_test.go @@ -0,0 +1,43 @@ +package fslink + +import ( + "reflect" + "testing" + + "github.com/lydakis/errand/internal/proto" +) + +func TestManifestDirectoryLinks(t *testing.T) { + m := proto.Manifest{Entries: []proto.ManifestEntry{ + {Path: "z", Type: proto.EntrySymlink, Target: "target"}, + {Path: "target", Type: proto.EntryDir}, + {Path: "target/nested/file", Type: proto.EntryFile}, + {Path: "a", Type: proto.EntrySymlink, Target: "z"}, + {Path: "loop", Type: proto.EntrySymlink, Target: "loop"}, + }} + original := append([]proto.ManifestEntry(nil), m.Entries...) + lookup := ManifestLookup(m) + for _, tc := range []struct { + target string + dir bool + }{ + {target: "target", dir: true}, + {target: "a", dir: true}, + {target: "a/nested", dir: true}, + {target: "target/nested", dir: true}, + {target: "a/nested/file"}, + {target: "missing"}, + {target: "loop"}, + {target: "../outside"}, + {target: ".", dir: true}, + } { + t.Run(tc.target, func(t *testing.T) { + if got := IsDirectory(proto.ManifestEntry{Path: "link", Target: tc.target}, lookup); got != tc.dir { + t.Fatalf("directory = %v, want %v", got, tc.dir) + } + }) + } + if !reflect.DeepEqual(m.Entries, original) { + t.Fatal("lookup reordered the borrowed manifest") + } +} diff --git a/internal/fslink/link_other.go b/internal/fslink/link_other.go new file mode 100644 index 00000000..fafb9cc0 --- /dev/null +++ b/internal/fslink/link_other.go @@ -0,0 +1,15 @@ +//go:build !windows + +package fslink + +import "os" + +const NativeTypes = false + +func Directory(os.FileInfo) bool { return false } + +func Create(root *os.Root, target, name string, _ bool) error { + return root.Symlink(target, name) +} + +func CreatePath(target, name string, _ bool) error { return os.Symlink(target, name) } diff --git a/internal/fslink/link_windows.go b/internal/fslink/link_windows.go new file mode 100644 index 00000000..e0e90f34 --- /dev/null +++ b/internal/fslink/link_windows.go @@ -0,0 +1,133 @@ +//go:build windows + +package fslink + +import ( + "encoding/binary" + "errors" + "os" + "path/filepath" + "runtime" + "syscall" + "unsafe" + + "golang.org/x/sys/windows" +) + +const NativeTypes = true + +func Directory(info os.FileInfo) bool { + data, ok := info.Sys().(*syscall.Win32FileAttributeData) + return ok && data.FileAttributes&windows.FILE_ATTRIBUTE_DIRECTORY != 0 +} + +func CreatePath(target, name string, directory bool) error { + root, err := os.OpenRoot(filepath.Dir(name)) + if err != nil { + return err + } + defer root.Close() + return Create(root, target, filepath.Base(name), directory) +} + +// Create uses the same handle-relative NT operations as os.Root.Symlink, with +// an explicit directory flag. A path-based CreateSymbolicLink call would lose +// the root's protection against renamed or replaced parents. +func Create(root *os.Root, target, name string, directory bool) error { + if !filepath.IsLocal(name) || filepath.VolumeName(target) != "" || filepath.IsAbs(target) || target == "" { + return &os.LinkError{Op: "symlink", Old: target, New: name, Err: windows.ERROR_INVALID_PARAMETER} + } + parent, err := root.Open(filepath.Dir(name)) + if err != nil { + return err + } + defer parent.Close() + create := func() error { + return createRelativeLink(windows.Handle(parent.Fd()), filepath.Base(name), filepath.FromSlash(target), directory) + } + err = create() + if errors.Is(err, windows.ERROR_PRIVILEGE_NOT_HELD) { + // Developer Mode needs no privilege adjustment. Elevated callers may + // instead hold a disabled symlink privilege, like CreateSymbolicLinkW. + err = withSymlinkPrivilege(create) + } + if err != nil { + return &os.LinkError{Op: "symlink", Old: target, New: name, Err: err} + } + return nil +} + +func createRelativeLink(parent windows.Handle, name, target string, directory bool) error { + text, err := windows.UTF16FromString(target) + if err != nil { + return err + } + text = text[:len(text)-1] + const header = 20 // REPARSE_DATA_BUFFER with a SymbolicLinkReparseBuffer + if header+2*len(text) > windows.MAXIMUM_REPARSE_DATA_BUFFER_SIZE { + return windows.ERROR_FILENAME_EXCED_RANGE + } + data := make([]byte, header+2*len(text)) + binary.LittleEndian.PutUint32(data, windows.IO_REPARSE_TAG_SYMLINK) + binary.LittleEndian.PutUint16(data[4:], uint16(len(data)-8)) + binary.LittleEndian.PutUint16(data[10:], uint16(2*len(text))) + binary.LittleEndian.PutUint16(data[14:], uint16(2*len(text))) + binary.LittleEndian.PutUint32(data[16:], 1) // SYMLINK_FLAG_RELATIVE + for i, c := range text { + binary.LittleEndian.PutUint16(data[header+2*i:], c) + } + objectName, err := windows.NewNTUnicodeString(name) + if err != nil { + return err + } + attributes := windows.OBJECT_ATTRIBUTES{RootDirectory: parent, ObjectName: objectName} + attributes.Length = uint32(unsafe.Sizeof(attributes)) + options := uint32(windows.FILE_NON_DIRECTORY_FILE) + if directory { + options = windows.FILE_DIRECTORY_FILE + } + var handle windows.Handle + err = windows.NtCreateFile(&handle, windows.SYNCHRONIZE|windows.FILE_WRITE_ATTRIBUTES|windows.DELETE, + &attributes, new(windows.IO_STATUS_BLOCK), nil, windows.FILE_ATTRIBUTE_NORMAL, 0, windows.FILE_CREATE, + windows.FILE_OPEN_REPARSE_POINT|windows.FILE_OPEN_FOR_BACKUP_INTENT|windows.FILE_SYNCHRONOUS_IO_NONALERT|options, 0, 0) + if err != nil { + return ntError(err) + } + defer windows.CloseHandle(handle) + var returned uint32 + if err := windows.DeviceIoControl(handle, windows.FSCTL_SET_REPARSE_POINT, &data[0], uint32(len(data)), nil, 0, &returned, nil); err != nil { + // Remove the failed placeholder through its pinned handle. + remove := byte(1) + cleanup := windows.NtSetInformationFile(handle, new(windows.IO_STATUS_BLOCK), &remove, 1, windows.FileDispositionInformation) + return errors.Join(err, ntError(cleanup)) + } + return nil +} + +func ntError(err error) error { + if status, ok := err.(windows.NTStatus); ok { + return status.Errno() + } + return err +} + +func withSymlinkPrivilege(create func() error) error { + runtime.LockOSThread() + defer runtime.UnlockOSThread() + if err := windows.ImpersonateSelf(windows.SecurityImpersonation); err != nil { + return create() + } + defer windows.RevertToSelf() + var token windows.Token + if err := windows.OpenThreadToken(windows.CurrentThread(), windows.TOKEN_QUERY|windows.TOKEN_ADJUST_PRIVILEGES, false, &token); err != nil { + return create() + } + defer token.Close() + name, _ := windows.UTF16PtrFromString("SeCreateSymbolicLinkPrivilege") + privilege := windows.Tokenprivileges{PrivilegeCount: 1} + privilege.Privileges[0].Attributes = windows.SE_PRIVILEGE_ENABLED + if err := windows.LookupPrivilegeValue(nil, name, &privilege.Privileges[0].Luid); err == nil { + _ = windows.AdjustTokenPrivileges(token, false, &privilege, 0, nil, nil) + } + return create() +} diff --git a/internal/fslink/link_windows_test.go b/internal/fslink/link_windows_test.go new file mode 100644 index 00000000..a4cf65c8 --- /dev/null +++ b/internal/fslink/link_windows_test.go @@ -0,0 +1,78 @@ +//go:build windows + +package fslink + +import ( + "os" + "path/filepath" + "testing" +) + +func TestTypedSymlinkWithoutReferentOnWindows(t *testing.T) { + for _, directory := range []bool{false, true} { + name := "file" + if directory { + name = "directory" + } + t.Run(name, func(t *testing.T) { + root, err := os.OpenRoot(t.TempDir()) + if err != nil { + t.Fatal(err) + } + defer root.Close() + if err := root.Mkdir("private", 0o700); err != nil { + t.Fatal(err) + } + if err := Create(root, "target 世界", "private/value", directory); err != nil { + t.Fatal(err) + } + info, err := root.Lstat("private/value") + if err != nil || info.Mode()&os.ModeSymlink == 0 || Directory(info) != directory { + t.Fatalf("staged link type: %v, %v", info, err) + } + if err := root.Rename("private/value", "alias"); err != nil { + t.Fatal(err) + } + target := "target 世界" + if directory { + if err := root.Mkdir(target, 0o700); err != nil { + t.Fatal(err) + } + target += "/value" + } + if err := root.WriteFile(target, []byte("body"), 0o600); err != nil { + t.Fatal(err) + } + alias := "alias" + if directory { + alias += "/value" + } + if body, err := root.ReadFile(alias); err != nil || string(body) != "body" { + t.Fatalf("installed link: %q, %v", body, err) + } + if err := Create(root, "other", "alias", directory); err == nil { + t.Fatal("replaced an existing link") + } + }) + } +} + +func TestTypedSymlinkKeepsRootBoundaryOnWindows(t *testing.T) { + dir, outside := t.TempDir(), t.TempDir() + if err := os.Symlink(outside, filepath.Join(dir, "escape")); err != nil { + t.Fatal(err) + } + root, err := os.OpenRoot(dir) + if err != nil { + t.Fatal(err) + } + defer root.Close() + for _, name := range []string{"../outside", "escape/link"} { + if err := Create(root, "target", name, true); err == nil { + t.Fatalf("created link outside the root through %q", name) + } + } + if _, err := os.Lstat(filepath.Join(outside, "link")); !os.IsNotExist(err) { + t.Fatalf("outside path changed: %v", err) + } +} diff --git a/internal/fsmode/fsmode.go b/internal/fsmode/fsmode.go new file mode 100644 index 00000000..a6dd8de4 --- /dev/null +++ b/internal/fsmode/fsmode.go @@ -0,0 +1,38 @@ +// Package fsmode reads the POSIX permission bits errand records for a file. +// +// On Unix they are the file's own bits. Windows has no permission bits, only +// a read-only attribute, so a Windows tree carries modes logically: files read +// as 0644 (0444 when read-only), directories as 0755, and the owner write bit +// is the only one a Windows file can confirm. Callers that know a path's +// recorded mode keep it with Inherit instead of trusting what Windows reports. +package fsmode + +import "io/fs" + +// Inherit returns the recorded mode for a regular file that now reports +// current. It keeps recorded bits Windows can't store, such as exec bits, and +// takes the write bit from the file. +func Inherit(recorded, current uint32) uint32 { + if !Logical { + return current + } + switch { + case current&0o200 == 0: + return recorded &^ 0o222 + case recorded&0o200 == 0: + return recorded | 0o200 + } + return recorded +} + +// Matches reports whether info is consistent with the recorded permission +// bits perm. +func Matches(info fs.FileInfo, perm uint32) bool { + if !Logical { + return Perm(info) == perm + } + if !info.Mode().IsRegular() { + return true + } + return info.Mode().Perm()&0o200 == fs.FileMode(perm)&0o200 +} diff --git a/internal/fsmode/fsmode_logical.go b/internal/fsmode/fsmode_logical.go new file mode 100644 index 00000000..56c93ce5 --- /dev/null +++ b/internal/fsmode/fsmode_logical.go @@ -0,0 +1,23 @@ +//go:build windows || errand_logicalmodes + +package fsmode + +import "io/fs" + +// Logical is true where the file system cannot store POSIX modes. The +// errand_logicalmodes build tag turns it on elsewhere to test that path. +const Logical = true + +// Perm returns the permission bits errand records for info. +func Perm(info fs.FileInfo) uint32 { + switch { + case info.IsDir(): + return 0o755 + case info.Mode()&fs.ModeSymlink != 0: + return 0o777 + case info.Mode().Perm()&0o200 == 0: + return 0o444 + default: + return 0o644 + } +} diff --git a/internal/fsmode/fsmode_unix.go b/internal/fsmode/fsmode_unix.go new file mode 100644 index 00000000..3935a5bb --- /dev/null +++ b/internal/fsmode/fsmode_unix.go @@ -0,0 +1,11 @@ +//go:build !windows && !errand_logicalmodes + +package fsmode + +import "io/fs" + +// Logical is true where the file system cannot store POSIX modes. +const Logical = false + +// Perm returns the permission bits errand records for info. +func Perm(info fs.FileInfo) uint32 { return uint32(info.Mode().Perm()) } diff --git a/internal/fsowner/fsowner.go b/internal/fsowner/fsowner.go new file mode 100644 index 00000000..2b1d87e9 --- /dev/null +++ b/internal/fsowner/fsowner.go @@ -0,0 +1,8 @@ +// Package fsowner checks who owns an open file. +package fsowner + +import "os" + +// OwnedByCurrentUser reports whether f is owned by the user this process runs +// as: the effective uid on Unix, the process token's user SID on Windows. +func OwnedByCurrentUser(f *os.File) (bool, error) { return ownedByCurrentUser(f) } diff --git a/internal/fsowner/fsowner_test.go b/internal/fsowner/fsowner_test.go new file mode 100644 index 00000000..2ffd64b5 --- /dev/null +++ b/internal/fsowner/fsowner_test.go @@ -0,0 +1,21 @@ +package fsowner + +import ( + "os" + "testing" +) + +func TestNewDirectoryIsOwnedByCurrentUser(t *testing.T) { + dir, err := os.Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + defer dir.Close() + owned, err := OwnedByCurrentUser(dir) + if err != nil { + t.Fatal(err) + } + if !owned { + t.Fatal("a directory this process created is not owned by the current user") + } +} diff --git a/internal/fsowner/fsowner_unix.go b/internal/fsowner/fsowner_unix.go new file mode 100644 index 00000000..0aadbbc4 --- /dev/null +++ b/internal/fsowner/fsowner_unix.go @@ -0,0 +1,21 @@ +//go:build unix + +package fsowner + +import ( + "fmt" + "os" + "syscall" +) + +func ownedByCurrentUser(f *os.File) (bool, error) { + info, err := f.Stat() + if err != nil { + return false, err + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return false, fmt.Errorf("ownership is unavailable for %q", f.Name()) + } + return int(stat.Uid) == os.Geteuid(), nil +} diff --git a/internal/fsowner/fsowner_windows.go b/internal/fsowner/fsowner_windows.go new file mode 100644 index 00000000..f3eba82a --- /dev/null +++ b/internal/fsowner/fsowner_windows.go @@ -0,0 +1,56 @@ +//go:build windows + +package fsowner + +import ( + "os" + "unsafe" + + "golang.org/x/sys/windows" +) + +// A file counts as the current user's when its owner is the token's user or +// the token's default owner, the SID Windows assigns to everything this +// process creates. An administrator's token without UAC filtering defaults +// to BUILTIN\Administrators, so errand's own directories carry that owner. +// Accepting it grants nothing: such a token can already act as any member +// of Administrators, including taking ownership of any file. +func ownedByCurrentUser(f *os.File) (bool, error) { + descriptor, err := windows.GetSecurityInfo(windows.Handle(f.Fd()), windows.SE_FILE_OBJECT, windows.OWNER_SECURITY_INFORMATION) + if err != nil { + return false, err + } + owner, _, err := descriptor.Owner() + if err != nil { + return false, err + } + token := windows.GetCurrentProcessToken() + user, err := token.GetTokenUser() + if err != nil { + return false, err + } + if owner.Equals(user.User.Sid) { + return true, nil + } + defaultOwner, err := tokenDefaultOwner(token) + if err != nil { + return false, err + } + return owner.Equals(defaultOwner), nil +} + +// tokenDefaultOwner reads TOKEN_OWNER, which x/sys/windows has no helper for. +func tokenDefaultOwner(token windows.Token) (*windows.SID, error) { + n := uint32(64) + for { + buf := make([]byte, n) + err := windows.GetTokenInformation(token, windows.TokenOwner, &buf[0], uint32(len(buf)), &n) + if err == nil { + // TOKEN_OWNER is a single pointer to a SID stored later in buf. + return (*struct{ Owner *windows.SID })(unsafe.Pointer(&buf[0])).Owner, nil + } + if err != windows.ERROR_INSUFFICIENT_BUFFER || n <= uint32(len(buf)) { + return nil, err + } + } +} diff --git a/internal/namedcache/gc_pressure_test.go b/internal/namedcache/gc_pressure_test.go index df5218d0..a99ce018 100644 --- a/internal/namedcache/gc_pressure_test.go +++ b/internal/namedcache/gc_pressure_test.go @@ -1,3 +1,5 @@ +//go:build unix + package namedcache import ( diff --git a/internal/namedcache/holders.go b/internal/namedcache/holders.go index 678ecb0c..ae59d044 100644 --- a/internal/namedcache/holders.go +++ b/internal/namedcache/holders.go @@ -6,7 +6,6 @@ import ( "io" "os" "slices" - "syscall" "github.com/lydakis/errand/internal/proto" ) @@ -14,7 +13,7 @@ import ( // Each job has a durable empty file. Keeping holders outside record.json // avoids imposing a new concurrency limit through the metadata size bound. func (s *Store) readHolders(name string) ([]string, error) { - dir, err := s.root.OpenFile(name+"/holders", os.O_RDONLY|syscall.O_DIRECTORY|syscall.O_NOFOLLOW, 0) + dir, err := s.root.OpenFile(name+"/holders", os.O_RDONLY|openDirectory|openNoFollow, 0) if err != nil { return nil, err } diff --git a/internal/namedcache/open_flags_unix.go b/internal/namedcache/open_flags_unix.go new file mode 100644 index 00000000..d49c5daa --- /dev/null +++ b/internal/namedcache/open_flags_unix.go @@ -0,0 +1,22 @@ +//go:build unix + +package namedcache + +import ( + "fmt" + "os" + "syscall" +) + +const ( + openDirectory = syscall.O_DIRECTORY + openNoFollow = syscall.O_NOFOLLOW + openNonblock = syscall.O_NONBLOCK +) + +func checkPrivateRoot(_ string, info os.FileInfo) error { + if info.Mode().Perm()&0o077 != 0 { + return fmt.Errorf("named cache root must be private (mode 0700)") + } + return nil +} diff --git a/internal/namedcache/open_flags_windows.go b/internal/namedcache/open_flags_windows.go new file mode 100644 index 00000000..57b4c672 --- /dev/null +++ b/internal/namedcache/open_flags_windows.go @@ -0,0 +1,37 @@ +//go:build windows + +package namedcache + +import ( + "fmt" + "os" + + "github.com/lydakis/errand/internal/fsowner" +) + +// Windows has no equivalent open flags. os.Root still refuses links that +// leave the store. +const ( + openDirectory = 0 + openNoFollow = 0 + openNonblock = 0 +) + +// Windows directories carry no mode bits. The state directory lives in the +// user's profile, whose ACL admits only that user and the system, so private +// means owned by this user. +func checkPrivateRoot(dir string, _ os.FileInfo) error { + f, err := os.Open(dir) + if err != nil { + return err + } + defer f.Close() + owned, err := fsowner.OwnedByCurrentUser(f) + if err != nil { + return err + } + if !owned { + return fmt.Errorf("named cache root must be owned by the current user") + } + return nil +} diff --git a/internal/namedcache/store.go b/internal/namedcache/store.go index b030d527..c1d542e3 100644 --- a/internal/namedcache/store.go +++ b/internal/namedcache/store.go @@ -14,10 +14,11 @@ import ( "path/filepath" "strings" "sync" - "syscall" "time" "unicode/utf8" + "github.com/lydakis/errand/internal/durable" + "github.com/lydakis/errand/internal/filelock" "github.com/lydakis/errand/internal/proto" ) @@ -117,8 +118,8 @@ func Open(dir string, maxBytes int64, ttl time.Duration) (*Store, error) { if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { return nil, fmt.Errorf("named cache root must be a directory") } - if info.Mode().Perm()&0o077 != 0 { - return nil, fmt.Errorf("named cache root must be private (mode 0700)") + if err := checkPrivateRoot(dir, info); err != nil { + return nil, err } root, err := os.OpenRoot(dir) if err != nil { @@ -129,7 +130,7 @@ func Open(dir string, maxBytes int64, ttl time.Duration) (*Store, error) { root.Close() return nil, fmt.Errorf("named cache root changed while opening") } - file, err := root.OpenFile(".lock", os.O_CREATE|os.O_RDWR|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0o600) + file, err := root.OpenFile(".lock", os.O_CREATE|os.O_RDWR|openNoFollow|openNonblock, 0o600) if err != nil { root.Close() return nil, err @@ -140,7 +141,7 @@ func Open(dir string, maxBytes int64, ttl time.Duration) (*Store, error) { root.Close() return nil, fmt.Errorf("named cache lock must be a regular file") } - if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { + if err := filelock.TryLock(file); err != nil { file.Close() root.Close() return nil, fmt.Errorf("named cache store is already open: %w", err) @@ -372,7 +373,7 @@ func (s *Store) readRecord(name string) (record, error) { if !info.IsDir() { return r, fmt.Errorf("named cache entry is not a directory: %s", name) } - f, err := s.root.OpenFile(name+"/record.json", os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0) + f, err := s.root.OpenFile(name+"/record.json", os.O_RDONLY|openNoFollow|openNonblock, 0) if err != nil { return r, err } @@ -470,5 +471,5 @@ func (s *Store) sync(name string) error { if err != nil { return err } - return errors.Join(dir.Sync(), dir.Close()) + return errors.Join(durable.Sync(dir), dir.Close()) } diff --git a/internal/namedcache/tree_files.go b/internal/namedcache/tree_files.go index 659ee7e4..41874942 100644 --- a/internal/namedcache/tree_files.go +++ b/internal/namedcache/tree_files.go @@ -16,6 +16,8 @@ import ( "sync" "sync/atomic" "syscall" + + "github.com/lydakis/errand/internal/fsidentity" ) // A linked tree compares directory entries and inode identities. Shared inode @@ -109,12 +111,12 @@ func hashTreeEntry(h hash.Hash, name string, info fs.FileInfo, target string, li io.WriteString(h, target) case info.Mode().IsRegular(): if linked { - stat, ok := info.Sys().(*syscall.Stat_t) - if !ok { + identity, err := fsidentity.FromInfo(info) + if err != nil { return fmt.Errorf("file identity unavailable: %s", name) } - binary.LittleEndian.PutUint64(numbers[:8], uint64(stat.Dev)) - binary.LittleEndian.PutUint64(numbers[8:16], uint64(stat.Ino)) + binary.LittleEndian.PutUint64(numbers[:8], identity.Device) + binary.LittleEndian.PutUint64(numbers[8:16], identity.Inode) } else { binary.LittleEndian.PutUint64(numbers[:8], uint64(info.Size())) binary.LittleEndian.PutUint64(numbers[8:16], uint64(info.ModTime().UnixNano())) @@ -287,7 +289,7 @@ func copyTreeFile(ctx context.Context, file treeFile, linked bool, cloneUnavaila _ = os.Remove(dest) } - f, err := os.OpenFile(full, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0) + f, err := os.OpenFile(full, os.O_RDONLY|openNoFollow|openNonblock, 0) if err != nil { return err } diff --git a/internal/namedcache/trees.go b/internal/namedcache/trees.go index 7ed6643b..36e08984 100644 --- a/internal/namedcache/trees.go +++ b/internal/namedcache/trees.go @@ -10,6 +10,7 @@ import ( "strings" "syscall" + "github.com/lydakis/errand/internal/durable" "github.com/lydakis/errand/internal/proto" ) @@ -369,5 +370,5 @@ func syncTreeDirectory(path string) error { if err != nil { return err } - return errors.Join(f.Sync(), f.Close()) + return errors.Join(durable.Sync(f), f.Close()) } diff --git a/internal/nowindow/nowindow.go b/internal/nowindow/nowindow.go new file mode 100644 index 00000000..affbb997 --- /dev/null +++ b/internal/nowindow/nowindow.go @@ -0,0 +1,7 @@ +// Package nowindow keeps helper programs the runner starts from opening +// console windows. +// +// A Windows runner has no console of its own, so Windows gives every console +// program it starts a new, visible console window that flashes on the +// desktop. Elsewhere Hide does nothing. +package nowindow diff --git a/internal/nowindow/nowindow_other.go b/internal/nowindow/nowindow_other.go new file mode 100644 index 00000000..990726c6 --- /dev/null +++ b/internal/nowindow/nowindow_other.go @@ -0,0 +1,7 @@ +//go:build !windows + +package nowindow + +import "os/exec" + +func Hide(*exec.Cmd) {} diff --git a/internal/nowindow/nowindow_windows.go b/internal/nowindow/nowindow_windows.go new file mode 100644 index 00000000..9a235524 --- /dev/null +++ b/internal/nowindow/nowindow_windows.go @@ -0,0 +1,18 @@ +//go:build windows + +package nowindow + +import ( + "os/exec" + "syscall" + + "golang.org/x/sys/windows" +) + +// Hide starts cmd's program without a console window. +func Hide(cmd *exec.Cmd) { + if cmd.SysProcAttr == nil { + cmd.SysProcAttr = &syscall.SysProcAttr{} + } + cmd.SysProcAttr.CreationFlags |= windows.CREATE_NO_WINDOW +} diff --git a/internal/nowindow/nowindow_windows_test.go b/internal/nowindow/nowindow_windows_test.go new file mode 100644 index 00000000..f3993015 --- /dev/null +++ b/internal/nowindow/nowindow_windows_test.go @@ -0,0 +1,47 @@ +//go:build windows + +package nowindow + +import ( + "os" + "os/exec" + "strings" + "syscall" + "testing" + + "golang.org/x/sys/windows" +) + +const childEnv = "ERRAND_NOWINDOW_CHILD" + +var procGetConsoleWindow = windows.NewLazySystemDLL("kernel32.dll").NewProc("GetConsoleWindow") + +func TestMain(m *testing.M) { + if os.Getenv(childEnv) == "1" { + // Report whether this process got a console window. + if window, _, _ := procGetConsoleWindow.Call(); window == 0 { + os.Stdout.WriteString("no window") + } else { + os.Stdout.WriteString("window") + } + os.Exit(0) + } + os.Exit(m.Run()) +} + +func TestHideStartsConsoleProgramWithoutWindowOnWindows(t *testing.T) { + cmd := exec.Command(os.Args[0]) + cmd.Env = append(os.Environ(), childEnv+"=1") + cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: windows.CREATE_UNICODE_ENVIRONMENT} + Hide(cmd) + if cmd.SysProcAttr.CreationFlags&windows.CREATE_UNICODE_ENVIRONMENT == 0 { + t.Fatal("Hide dropped existing creation flags") + } + out, err := cmd.Output() + if err != nil { + t.Fatal(err) + } + if got := strings.TrimSpace(string(out)); got != "no window" { + t.Fatalf("child reported %q", got) + } +} diff --git a/internal/pathpolicy/policy_test.go b/internal/pathpolicy/policy_test.go index 8fd7799f..fb836ab4 100644 --- a/internal/pathpolicy/policy_test.go +++ b/internal/pathpolicy/policy_test.go @@ -6,6 +6,7 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "strings" "testing" @@ -64,6 +65,9 @@ func TestMatcherAgreesWithGitCheckIgnore(t *testing.T) { t.Fatalf("Compile(%q): %v", tt.pattern, err) } for _, name := range tt.paths { + if !gitOracleCanCreate(name) { + continue + } directory := tt.directory[name] want := gitCheckIgnored(t, tt.pattern, name, directory) if got := matcher.Ignored(name, directory); got != want { @@ -74,6 +78,17 @@ func TestMatcherAgreesWithGitCheckIgnore(t *testing.T) { } } +// gitOracleCanCreate reports whether the oracle can create name as a file. +// Windows file names can't hold these characters or end in a space or dot, +// and Git for Windows reads a backslash as a separator. The matcher itself +// works on slash-separated manifest paths and is tested on every platform. +func gitOracleCanCreate(name string) bool { + if runtime.GOOS != "windows" { + return true + } + return !strings.ContainsAny(name, `\*?"<>|:`) && !strings.HasSuffix(name, " ") && !strings.HasSuffix(name, ".") +} + func gitCheckIgnored(t *testing.T, pattern, name string, directory bool) bool { return gitCheckIgnoredWithCaseFold(t, pattern, name, directory, false) } diff --git a/internal/proctree/doc.go b/internal/proctree/doc.go new file mode 100644 index 00000000..1bebae20 --- /dev/null +++ b/internal/proctree/doc.go @@ -0,0 +1,4 @@ +// Package proctree contains a started process and every process it starts. +// +// On Windows it uses a Job Object. Unix callers use process groups directly. +package proctree diff --git a/internal/proctree/job_windows.go b/internal/proctree/job_windows.go new file mode 100644 index 00000000..13ff1c19 --- /dev/null +++ b/internal/proctree/job_windows.go @@ -0,0 +1,150 @@ +//go:build windows + +package proctree + +import ( + "errors" + "fmt" + "os" + "os/exec" + "unsafe" + + "github.com/lydakis/errand/internal/nowindow" + "golang.org/x/sys/windows" +) + +// Job is a Job Object. Processes assigned to it, and everything they start, +// stay in it: the job is created without JOB_OBJECT_LIMIT_BREAKAWAY_OK. The +// job kills its processes when its last handle closes, so nothing outlives +// the owning process by accident. +type Job struct { + handle windows.Handle +} + +func New() (*Job, error) { + handle, err := windows.CreateJobObject(nil, nil) + if err != nil { + return nil, fmt.Errorf("creating job object: %w", err) + } + info := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{} + info.BasicLimitInformation.LimitFlags = windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE + if _, err := windows.SetInformationJobObject( + handle, + windows.JobObjectExtendedLimitInformation, + uintptr(unsafe.Pointer(&info)), + uint32(unsafe.Sizeof(info)), + ); err != nil { + windows.CloseHandle(handle) + return nil, fmt.Errorf("configuring job object: %w", err) + } + return &Job{handle: handle}, nil +} + +// Prepare makes cmd start suspended so Adopt can assign it before it runs +// any code or starts a child. Console programs get no console window. +func Prepare(cmd *exec.Cmd) { + nowindow.Hide(cmd) + cmd.SysProcAttr.CreationFlags |= windows.CREATE_SUSPENDED +} + +// Adopt assigns a process started after Prepare to the job, then resumes it. +// If that fails, the still-suspended process is terminated. +func (j *Job) Adopt(p *os.Process) error { + handle, err := windows.OpenProcess(windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE, false, uint32(p.Pid)) + if err != nil { + _ = p.Kill() + return fmt.Errorf("opening process %d: %w", p.Pid, err) + } + defer windows.CloseHandle(handle) + if err := windows.AssignProcessToJobObject(j.handle, handle); err != nil { + _ = windows.TerminateProcess(handle, 1) + return fmt.Errorf("assigning process %d to job object: %w", p.Pid, err) + } + if err := resumeThreads(uint32(p.Pid)); err != nil { + _ = windows.TerminateProcess(handle, 1) + return fmt.Errorf("resuming process %d: %w", p.Pid, err) + } + return nil +} + +func resumeThreads(pid uint32) error { + snapshot, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPTHREAD, 0) + if err != nil { + return err + } + defer windows.CloseHandle(snapshot) + entry := windows.ThreadEntry32{Size: uint32(unsafe.Sizeof(windows.ThreadEntry32{}))} + resumed := 0 + for err = windows.Thread32First(snapshot, &entry); err == nil; err = windows.Thread32Next(snapshot, &entry) { + if entry.OwnerProcessID != pid { + continue + } + thread, err := windows.OpenThread(windows.THREAD_SUSPEND_RESUME, false, entry.ThreadID) + if err != nil { + return err + } + _, err = windows.ResumeThread(thread) + windows.CloseHandle(thread) + if err != nil { + return err + } + resumed++ + } + if !errors.Is(err, windows.ERROR_NO_MORE_FILES) { + return err + } + if resumed == 0 { + return fmt.Errorf("no threads found") + } + return nil +} + +// processIDList is JOBOBJECT_BASIC_PROCESS_ID_LIST. +type processIDList struct { + Assigned uint32 + Listed uint32 + IDs [1]uintptr +} + +// PIDs lists the processes currently in the job. +func (j *Job) PIDs() ([]int, error) { + capacity := 64 + for { + buffer := make([]byte, unsafe.Offsetof(processIDList{}.IDs)+uintptr(capacity)*unsafe.Sizeof(uintptr(0))) + list := (*processIDList)(unsafe.Pointer(&buffer[0])) + err := windows.QueryInformationJobObject( + j.handle, + windows.JobObjectBasicProcessIdList, + uintptr(unsafe.Pointer(list)), + uint32(len(buffer)), + nil, + ) + if err != nil && !errors.Is(err, windows.ERROR_MORE_DATA) { + return nil, err + } + if err == nil && list.Listed >= list.Assigned { + ids := unsafe.Slice(&list.IDs[0], list.Listed) + pids := make([]int, len(ids)) + for i, id := range ids { + pids[i] = int(id) + } + return pids, nil + } + capacity = max(2*capacity, int(list.Assigned)+16) + } +} + +// Terminate ends every process in the job with exitCode. +func (j *Job) Terminate(exitCode uint32) error { + return windows.TerminateJobObject(j.handle, exitCode) +} + +// Close releases the job, killing anything still in it. +func (j *Job) Close() error { + if j.handle == 0 { + return nil + } + err := windows.CloseHandle(j.handle) + j.handle = 0 + return err +} diff --git a/internal/proctree/job_windows_test.go b/internal/proctree/job_windows_test.go new file mode 100644 index 00000000..c0adee92 --- /dev/null +++ b/internal/proctree/job_windows_test.go @@ -0,0 +1,157 @@ +//go:build windows + +package proctree + +import ( + "os" + "os/exec" + "slices" + "testing" + "time" + + "golang.org/x/sys/windows" +) + +// TestMain lets the test binary act as the contained program. +func TestMain(m *testing.M) { + switch os.Getenv("PROCTREE_TEST_ROLE") { + case "parent": + child := exec.Command(os.Args[0]) + child.Env = append(os.Environ(), "PROCTREE_TEST_ROLE=child") + if err := child.Start(); err != nil { + os.Exit(3) + } + os.Stdout.WriteString("started\n") + time.Sleep(time.Minute) + os.Exit(0) + case "child": + time.Sleep(time.Minute) + os.Exit(0) + case "exit7": + os.Exit(7) + } + os.Exit(m.Run()) +} + +func startInJob(t *testing.T, role string) (*Job, *exec.Cmd) { + t.Helper() + job, err := New() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { job.Close() }) + cmd := exec.Command(os.Args[0]) + cmd.Env = append(os.Environ(), "PROCTREE_TEST_ROLE="+role) + Prepare(cmd) + return job, cmd +} + +func TestAdoptedProcessRunsAndExits(t *testing.T) { + job, cmd := startInJob(t, "exit7") + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + if err := job.Adopt(cmd.Process); err != nil { + t.Fatal(err) + } + err := cmd.Wait() + if cmd.ProcessState == nil || cmd.ProcessState.ExitCode() != 7 { + t.Fatalf("wait: %v, exit %v", err, cmd.ProcessState) + } +} + +func TestTerminateKillsDescendants(t *testing.T) { + job, cmd := startInJob(t, "parent") + stdout, err := cmd.StdoutPipe() + if err != nil { + t.Fatal(err) + } + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + if err := job.Adopt(cmd.Process); err != nil { + t.Fatal(err) + } + line := make([]byte, len("started\n")) + if _, err := stdout.Read(line); err != nil { + t.Fatal(err) + } + // The parent, its child, and any console host Windows attaches. + pids := waitForPIDs(t, job, func(n int) bool { return n >= 2 }) + if !slices.Contains(pids, cmd.Process.Pid) { + t.Fatalf("job pids %v do not include parent %d", pids, cmd.Process.Pid) + } + if err := job.Terminate(9); err != nil { + t.Fatal(err) + } + _ = cmd.Wait() + if code := cmd.ProcessState.ExitCode(); code != 9 { + t.Fatalf("exit code %d, want 9", code) + } + waitForPIDs(t, job, func(n int) bool { return n == 0 }) +} + +func TestCloseKillsDescendants(t *testing.T) { + job, cmd := startInJob(t, "parent") + stdout, err := cmd.StdoutPipe() + if err != nil { + t.Fatal(err) + } + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + job.Close() + cmd.Wait() + }) + if err := job.Adopt(cmd.Process); err != nil { + t.Fatal(err) + } + line := make([]byte, len("started\n")) + if _, err := stdout.Read(line); err != nil { + t.Fatal(err) + } + pids := waitForPIDs(t, job, func(n int) bool { return n >= 2 }) + // Pin process handles before closing the job: its PID query is no longer + // available afterward, and a disappeared PID could be reused. + var processes []windows.Handle + for _, pid := range pids { + process, err := windows.OpenProcess(windows.SYNCHRONIZE, false, uint32(pid)) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { windows.CloseHandle(process) }) + processes = append(processes, process) + } + if err := job.Close(); err != nil { + t.Fatal(err) + } + for i, process := range processes { + status, err := windows.WaitForSingleObject(process, 10_000) + if err != nil || status != windows.WAIT_OBJECT_0 { + t.Fatalf("process %d survived job close: wait = %v, %v", pids[i], status, err) + } + } + _ = cmd.Wait() + if err := job.Close(); err != nil { + t.Fatalf("repeated close: %v", err) + } +} + +func waitForPIDs(t *testing.T, job *Job, done func(int) bool) []int { + t.Helper() + deadline := time.Now().Add(10 * time.Second) + for { + pids, err := job.PIDs() + if err != nil { + t.Fatal(err) + } + if done(len(pids)) { + return pids + } + if time.Now().After(deadline) { + t.Fatalf("job still has pids %v", pids) + } + time.Sleep(20 * time.Millisecond) + } +} diff --git a/internal/serviceruntime/runtime.go b/internal/serviceruntime/runtime.go index f934a824..dc99d7a8 100644 --- a/internal/serviceruntime/runtime.go +++ b/internal/serviceruntime/runtime.go @@ -10,7 +10,6 @@ import ( "io" "os" "path/filepath" - "syscall" ) // Reexec moves the current process to a retained runtime before it opens any @@ -32,23 +31,6 @@ func Reexec(stateDir string) error { return execPrepared(source, target) } -func execPrepared(source *os.File, target string) error { - // Keep the opened installation's identity: package cleanup may have - // removed its pathname since we opened it, even after publication. - original, err := source.Stat() - if err != nil { - return err - } - runtime, err := os.Stat(target) - if err != nil { - return err - } - if os.SameFile(original, runtime) { - return nil - } - return syscall.Exec(target, append([]string{target}, os.Args[1:]...), os.Environ()) -} - // Prepare publishes an executable by content hash without replacing any existing // generation. Callers re-execute this path before opening daemon listeners. // No runtime files are collected here: another daemon may still be using them. @@ -61,8 +43,31 @@ func Prepare(executable, stateDir string) (string, error) { return prepare(source, stateDir) } +// Path reports where Prepare would publish an executable, without writing. +func Path(executable, stateDir string) (string, error) { + source, err := os.Open(executable) + if err != nil { + return "", err + } + defer source.Close() + dir, err := Directory(stateDir) + if err != nil { + return "", err + } + hash := sha256.New() + if _, err := io.Copy(hash, source); err != nil { + return "", err + } + return filepath.Join(dir, fmt.Sprintf("%x", hash.Sum(nil)), runtimeName), nil +} + +// Directory is where runtime generations for stateDir live. +func Directory(stateDir string) (string, error) { + return filepath.Abs(filepath.Join(stateDir, "runtime")) +} + func prepare(source *os.File, stateDir string) (string, error) { - dir, err := filepath.Abs(filepath.Join(stateDir, "runtime")) + dir, err := Directory(stateDir) if err != nil { return "", err } @@ -74,7 +79,7 @@ func prepare(source *os.File, stateDir string) (string, error) { return "", err } want := hash.Sum(nil) - target := filepath.Join(dir, fmt.Sprintf("%x", want), "errand") + target := filepath.Join(dir, fmt.Sprintf("%x", want), runtimeName) if err := privateDirectory(filepath.Dir(target)); err != nil { return "", err } @@ -121,26 +126,12 @@ func prepare(source *os.File, stateDir string) (string, error) { return filepath.EvalSymlinks(target) } -func privateDirectory(dir string) error { - if err := os.MkdirAll(dir, 0700); err != nil { - return err - } - info, err := os.Lstat(dir) - if err != nil { - return err - } - if !info.IsDir() || info.Mode().Perm()&0077 != 0 { - return fmt.Errorf("runtime directory must be private and not a symlink: %s", dir) - } - return nil -} - func validateExecutable(target string, want []byte) error { info, err := os.Lstat(target) if err != nil { return err } - if !info.Mode().IsRegular() || info.Mode().Perm() != 0500 { + if !info.Mode().IsRegular() || !runtimeModeOK(info) { return fmt.Errorf("invalid runtime executable: %s", target) } existing, err := os.Open(target) diff --git a/internal/serviceruntime/runtime_test.go b/internal/serviceruntime/runtime_test.go index 724f9811..33166090 100644 --- a/internal/serviceruntime/runtime_test.go +++ b/internal/serviceruntime/runtime_test.go @@ -5,6 +5,7 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "strconv" "sync" "testing" @@ -98,6 +99,9 @@ func TestPrepareConcurrentAndRefusesDamagedRuntime(t *testing.T) { func TestPrepareRefusesSharedOrRedirectedRuntimeDirectory(t *testing.T) { for _, symlink := range []bool{false, true} { t.Run(map[bool]string{false: "shared", true: "symlink"}[symlink], func(t *testing.T) { + if !symlink && runtime.GOOS == "windows" { + t.Skip("Windows directories have no group or other permission bits") + } root := t.TempDir() source := filepath.Join(root, "installed") if err := os.WriteFile(source, []byte("executable"), 0700); err != nil { @@ -152,6 +156,9 @@ func TestPrepareRelativeStateAndReuseWithoutWrites(t *testing.T) { } func TestReexecAfterInstallationRemoval(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows can't replace a running process image; the service runs the runtime copy directly") + } executable, err := os.Executable() if err != nil { t.Fatal(err) diff --git a/internal/serviceruntime/runtime_unix.go b/internal/serviceruntime/runtime_unix.go new file mode 100644 index 00000000..0e634ad9 --- /dev/null +++ b/internal/serviceruntime/runtime_unix.go @@ -0,0 +1,44 @@ +//go:build unix + +package serviceruntime + +import ( + "fmt" + "os" + "syscall" +) + +const runtimeName = "errand" + +func execPrepared(source *os.File, target string) error { + // Keep the opened installation's identity: package cleanup may have + // removed its pathname since we opened it, even after publication. + original, err := source.Stat() + if err != nil { + return err + } + runtime, err := os.Stat(target) + if err != nil { + return err + } + if os.SameFile(original, runtime) { + return nil + } + return syscall.Exec(target, append([]string{target}, os.Args[1:]...), os.Environ()) +} + +func privateDirectory(dir string) error { + if err := os.MkdirAll(dir, 0700); err != nil { + return err + } + info, err := os.Lstat(dir) + if err != nil { + return err + } + if !info.IsDir() || info.Mode().Perm()&0077 != 0 { + return fmt.Errorf("runtime directory must be private and not a symlink: %s", dir) + } + return nil +} + +func runtimeModeOK(info os.FileInfo) bool { return info.Mode().Perm() == 0500 } diff --git a/internal/serviceruntime/runtime_windows.go b/internal/serviceruntime/runtime_windows.go new file mode 100644 index 00000000..55d7b66a --- /dev/null +++ b/internal/serviceruntime/runtime_windows.go @@ -0,0 +1,50 @@ +//go:build windows + +package serviceruntime + +import ( + "fmt" + "os" + + "github.com/lydakis/errand/internal/fsowner" +) + +const runtimeName = "errand.exe" + +// Windows can't replace a running process image. The service is registered +// with the prepared runtime path, so it already runs there; a daemon started +// from elsewhere keeps running where it was started. +func execPrepared(*os.File, string) error { return nil } + +// Windows reports every directory as 0777. Private means owned by this user +// under a profile directory whose ACL admits only that user and the system. +func privateDirectory(dir string) error { + if err := os.MkdirAll(dir, 0700); err != nil { + return err + } + info, err := os.Lstat(dir) + if err != nil { + return err + } + if !info.IsDir() { + return fmt.Errorf("runtime directory must be private and not a symlink: %s", dir) + } + f, err := os.Open(dir) + if err != nil { + return err + } + defer f.Close() + owned, err := fsowner.OwnedByCurrentUser(f) + if err != nil { + return err + } + if !owned { + return fmt.Errorf("runtime directory must be owned by the current user: %s", dir) + } + return nil +} + +// Windows records only a read-only attribute, which hard links share and +// os.Remove clears when it deletes the staging link. The content hash is the +// check that matters. +func runtimeModeOK(os.FileInfo) bool { return true } diff --git a/internal/setup/config_path_test.go b/internal/setup/config_path_test.go index 28dcfd6f..16b24154 100644 --- a/internal/setup/config_path_test.go +++ b/internal/setup/config_path_test.go @@ -1,3 +1,6 @@ +// The fake system models Unix paths; Windows setup decisions run here too. +//go:build unix + package setup import ( diff --git a/internal/setup/config_plan_test.go b/internal/setup/config_plan_test.go index fcb1a0d1..dc562e69 100644 --- a/internal/setup/config_plan_test.go +++ b/internal/setup/config_plan_test.go @@ -1,3 +1,6 @@ +// The fake system models Unix paths; Windows setup decisions run here too. +//go:build unix + package setup import ( diff --git a/internal/setup/diagnose.go b/internal/setup/diagnose.go index 8e0aa051..4e5a91a1 100644 --- a/internal/setup/diagnose.go +++ b/internal/setup/diagnose.go @@ -72,7 +72,7 @@ func Diagnose(ctx context.Context, configPath string, sys DiagnosticSystem) Diag r := Diagnosis{} diagnoseBinary(sys, &r) d, configErr := sys.LoadDaemon(configPath) - serviceCtx, stopService := context.WithTimeout(ctx, 4*time.Second) + serviceCtx, stopService := context.WithTimeout(ctx, 10*time.Second) active, serviceErr := serviceActive(serviceCtx, sys) stopService() definition, definitionErr := diagnosticServiceDefinition(sys) @@ -229,7 +229,7 @@ func diagnosticPathPresent(sys DiagnosticSystem, path string) bool { } func diagnosticServiceDefinition(sys DiagnosticSystem) (bool, error) { - if sys.GOOS() != "linux" && sys.GOOS() != "darwin" { + if !managedServiceOS(sys.GOOS()) { return false, nil } home, err := sys.Home() @@ -237,8 +237,11 @@ func diagnosticServiceDefinition(sys DiagnosticSystem) (bool, error) { return false, err } path := filepath.Join(home, linuxUnitSubdir, DefaultServiceName+".service") - if sys.GOOS() == "darwin" { + switch sys.GOOS() { + case "darwin": path = filepath.Join(home, darwinAgentSubdir, LaunchAgentLabel+".plist") + case "windows": + path = scheduledTaskPath(home) } return diagnosticPathPresent(sys, path), nil } diff --git a/internal/setup/diagnose_test.go b/internal/setup/diagnose_test.go index e6da8d5a..4d07b011 100644 --- a/internal/setup/diagnose_test.go +++ b/internal/setup/diagnose_test.go @@ -1,3 +1,6 @@ +// The fake system models Unix paths; Windows setup decisions run here too. +//go:build unix + package setup import ( diff --git a/internal/setup/executable_test.go b/internal/setup/executable_test.go index 93f38c50..1baa5ea3 100644 --- a/internal/setup/executable_test.go +++ b/internal/setup/executable_test.go @@ -1,3 +1,6 @@ +// The fake system models Unix paths; Windows setup decisions run here too. +//go:build unix + package setup import ( diff --git a/internal/setup/local_test.go b/internal/setup/local_test.go index d7f72cb0..46313325 100644 --- a/internal/setup/local_test.go +++ b/internal/setup/local_test.go @@ -1,3 +1,6 @@ +// The fake system models Unix paths; Windows setup decisions run here too. +//go:build unix + package setup import ( diff --git a/internal/setup/process_unix.go b/internal/setup/process_unix.go new file mode 100644 index 00000000..4527937c --- /dev/null +++ b/internal/setup/process_unix.go @@ -0,0 +1,18 @@ +//go:build !windows + +package setup + +import ( + "errors" + "runtime" +) + +func taskUserID(name string) string { return name } + +func currentUserSID() (string, error) { + return "", errors.New("user SIDs exist only on Windows, not " + runtime.GOOS) +} + +func processImage(int) (string, error) { + return "", errors.New("process images are read only on Windows, not " + runtime.GOOS) +} diff --git a/internal/setup/process_windows.go b/internal/setup/process_windows.go new file mode 100644 index 00000000..fa8ca84b --- /dev/null +++ b/internal/setup/process_windows.go @@ -0,0 +1,43 @@ +//go:build windows + +package setup + +import ( + "golang.org/x/sys/windows" + "strings" +) + +// Task Scheduler exports user IDs as either SIDs or account names. Compare +// identities through the native account lookup without another subprocess. +func taskUserID(name string) string { + if strings.HasPrefix(name, "S-1-") { + return name + } + sid, _, _, err := windows.LookupSID("", name) + if err != nil { + return name + } + return sid.String() +} + +func currentUserSID() (string, error) { + user, err := windows.GetCurrentProcessToken().GetTokenUser() + if err != nil { + return "", err + } + return user.User.Sid.String(), nil +} + +func processImage(pid int) (string, error) { + process, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid)) + if err != nil { + return "", err + } + defer windows.CloseHandle(process) + buf := make([]uint16, windows.MAX_LONG_PATH) + size := uint32(len(buf)) + if err := windows.QueryFullProcessImageName(process, 0, &buf[0], &size); err != nil { + return "", err + } + return windows.UTF16ToString(buf[:size]), nil +} diff --git a/internal/setup/process_windows_test.go b/internal/setup/process_windows_test.go new file mode 100644 index 00000000..1d39257a --- /dev/null +++ b/internal/setup/process_windows_test.go @@ -0,0 +1,26 @@ +//go:build windows + +package setup + +import ( + "os" + "strings" + "testing" +) + +func TestProcessHelpersDescribeThisProcessOnWindows(t *testing.T) { + sid, err := currentUserSID() + if err != nil || !strings.HasPrefix(sid, "S-1-") { + t.Fatalf("currentUserSID = %q, %v", sid, err) + } + image, err := processImage(os.Getpid()) + if err != nil { + t.Fatal(err) + } + if !(RealSystem{}).SameFile(image, os.Args[0]) { + exe, _ := os.Executable() + if !(RealSystem{}).SameFile(image, exe) { + t.Fatalf("processImage = %q, executable %q", image, exe) + } + } +} diff --git a/internal/setup/scheduled_task.go b/internal/setup/scheduled_task.go new file mode 100644 index 00000000..1d58065d --- /dev/null +++ b/internal/setup/scheduled_task.go @@ -0,0 +1,307 @@ +package setup + +import ( + "bytes" + "context" + "encoding/binary" + "encoding/xml" + "errors" + "fmt" + "path/filepath" + "strings" + "unicode/utf16" + + "github.com/lydakis/errand/internal/serviceruntime" +) + +// Windows runs the daemon as a Task Scheduler task for the signed-in user. It +// needs no administrator rights and, like a LaunchAgent, runs while the user +// is logged in. +const scheduledTaskName = DefaultServiceName + +// The task queries go through PowerShell because its state names are not +// localized, unlike schtasks output. +const scheduledTaskQuery = `[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); $t = Get-ScheduledTask -TaskPath '\' -TaskName '` + scheduledTaskName + `' -ErrorAction SilentlyContinue; if ($t) { "$($t.State)"; $t.Actions[0].Execute } else { 'Missing' }` + +const scheduledTaskDefinitionQuery = scheduledTaskQuery + `; if ($t) { Export-ScheduledTask -InputObject $t -ErrorAction Stop }` + +func windowsServiceDir(home string) string { + return filepath.Join(home, "AppData", "Local", "errand") +} + +func scheduledTaskPath(home string) string { + return filepath.Join(windowsServiceDir(home), "errand-task.xml") +} + +type scheduledTaskState struct { + State string + Command string + Definition string +} + +func queryScheduledTask(ctx context.Context, sys serviceSystem) (scheduledTaskState, error) { + return readScheduledTask(ctx, sys, scheduledTaskQuery) +} + +func readScheduledTask(ctx context.Context, sys serviceSystem, query string) (scheduledTaskState, error) { + out, err := sys.Run(ctx, "powershell.exe", "-NoProfile", "-NonInteractive", "-Command", query) + if err != nil { + return scheduledTaskState{}, err + } + lines := strings.SplitN(strings.ReplaceAll(strings.TrimSpace(out), "\r\n", "\n"), "\n", 3) + state := scheduledTaskState{State: strings.TrimSpace(lines[0])} + if len(lines) > 1 { + state.Command = strings.TrimSpace(lines[1]) + } + if len(lines) > 2 { + state.Definition = lines[2] + } + if state.State == "" { + return scheduledTaskState{}, fmt.Errorf("Task Scheduler returned no state for %s", scheduledTaskName) + } + return state, nil +} + +// installScheduledTask registers the task to run the retained runtime copy, so +// replacing errand.exe never fights a running daemon for the file. Each upgrade +// moves the task to the new copy when setup runs again. +func installScheduledTask(ctx context.Context, opts Options, sys System, r *Report, home, exe, configPath, stateDir string) bool { + r.Service = scheduledTaskName + taskPath := scheduledTaskPath(home) + r.ServicePath = taskPath + logPath := filepath.Join(windowsServiceDir(home), "errand.log") + runtimeExe, err := sys.RuntimePath(exe, stateDir, !opts.DryRun) + if err != nil { + r.fail("service", fmt.Errorf("preparing the runner's retained copy of %s: %w", exe, err)) + return false + } + user, err := sys.UserSID() + if err != nil { + r.fail("service", fmt.Errorf("reading the current user's SID: %w", err)) + return false + } + desired := renderScheduledTask(user, runtimeExe, configPath, logPath) + // Inspect the registered task, not just our saved XML. Export its + // definition in the same query so this does not start another PowerShell. + current, err := readScheduledTask(ctx, sys, scheduledTaskDefinitionQuery) + if err != nil { + r.fail("service", fmt.Errorf("cannot query Task Scheduler: %w", err)) + return false + } + if current.State != "Missing" && !opts.Force && !setupRegisteredTask(current.Definition, current.Command, user, configPath, logPath, stateDir) { + r.fail("service", fmt.Errorf("registered task %s differs from what setup would write; inspect it in Task Scheduler and rerun setup --force to replace it", scheduledTaskName)) + return false + } + changed, ok := writeTaskDefinition(sys, r, taskPath, desired, user, configPath, logPath, stateDir, opts) + if !ok { + return false + } + if opts.DryRun { + r.step("service", "would run: schtasks /Create /TN "+scheduledTaskName+" /XML "+taskPath+" /F && schtasks /Run /TN "+scheduledTaskName, true) + return false + } + if current.State == "Running" { + if _, err := sys.Run(ctx, "schtasks", "/End", "/TN", scheduledTaskName); err != nil { + r.fail("service", fmt.Errorf("cannot stop the running task: %w", err)) + return false + } + } + if _, err := sys.Run(ctx, "schtasks", "/Create", "/TN", scheduledTaskName, "/XML", taskPath, "/F"); err != nil { + r.fail("service", err) + return false + } + if _, err := sys.Run(ctx, "schtasks", "/Run", "/TN", scheduledTaskName); err != nil { + r.fail("service", err) + return false + } + if changed || current.State == "Missing" { + r.step("service", "registered and started scheduled task "+scheduledTaskName, true) + } else { + r.step("service", "restarted scheduled task "+scheduledTaskName+" so the preserved config is active", true) + } + r.step("logon", "the task starts when "+sys.Username()+" signs in; the runner stops while nobody is signed in", false) + return true +} + +// writeTaskDefinition is writeDefinition for a task whose command moves to a +// new runtime copy on every upgrade. A definition setup rendered for any +// retained copy is setup's own and is replaced. Anything else is an +// operator's: registering it would restart an unknown command, so setup +// stops unless --force replaces it. +func writeTaskDefinition(sys System, r *Report, path, desired, user, configPath, logPath, stateDir string, opts Options) (changed, ok bool) { + if sys.Exists(path) { + current, err := sys.ReadFile(path) + if err != nil { + r.fail("service", err) + return false, false + } + if string(current) == desired { + r.step("service", "definition unchanged at "+path, false) + return false, true + } + if !opts.Force && !setupRenderedTask(string(current), user, configPath, logPath, stateDir) { + r.fail("service", fmt.Errorf("%s differs from what setup would write; inspect it and rerun setup --force to replace it", path)) + return false, false + } + } + if opts.DryRun { + r.step("service", "would write "+path, true) + return true, true + } + if err := sys.WriteFile(path, []byte(desired), 0o600); err != nil { + r.fail("service", err) + return false, false + } + r.step("service", "wrote "+path, true) + return true, true +} + +func setupRenderedTask(existing, user, configPath, logPath, stateDir string) bool { + command, err := taskCommand(existing) + if err != nil { + return false + } + return retainedTaskCommand(command, stateDir) && existing == renderScheduledTask(user, command, configPath, logPath) +} + +func retainedTaskCommand(command, stateDir string) bool { + runtimeDir, err := serviceruntime.Directory(stateDir) + if err != nil { + return false + } + rel, err := filepath.Rel(runtimeDir, command) + if err != nil || rel == "." || strings.HasPrefix(rel, "..") || filepath.IsAbs(rel) { + return false + } + return true +} + +func taskCommand(definition string) (string, error) { + text, err := decodeUTF16(definition) + if err != nil { + return "", err + } + var task struct { + Command string `xml:"Actions>Exec>Command"` + } + // The declaration names UTF-16, which the decoder has already undone. + text = strings.Replace(text, `encoding="UTF-16"`, `encoding="UTF-8"`, 1) + if err := xml.Unmarshal([]byte(text), &task); err != nil { + return "", err + } + if task.Command == "" { + return "", errors.New("task has no command") + } + return task.Command, nil +} + +// renderScheduledTask is the task definition in the UTF-16 encoding schtasks +// requires for /XML. +func renderScheduledTask(user, executable, configPath, logPath string) string { + return encodeUTF16(renderScheduledTaskText(user, executable, configPath, logPath)) +} + +func renderScheduledTaskText(user, executable, configPath, logPath string) string { + arguments := strings.Join([]string{"serve", "--config", windowsArg(configPath), "--log-file", windowsArg(logPath)}, " ") + // Priority 7 is Task Scheduler's default and runs the daemon, and every + // job it starts, below normal priority. 4 is normal. + text := fmt.Sprintf(` + + + errand runner + \%s + + + + true + %s + + + + + %s + InteractiveToken + LeastPrivilege + + + + IgnoreNew + false + false + true + true + false + + false + false + + true + true + false + false + PT0S + 4 + + PT1M + 999 + + + + + %s + %s + + + +`, xmlText(scheduledTaskName), xmlText(user), xmlText(user), xmlText(executable), xmlText(arguments)) + return strings.ReplaceAll(text, "\n", "\r\n") +} + +func encodeUTF16(text string) string { + var out bytes.Buffer + out.Write([]byte{0xff, 0xfe}) + for _, unit := range utf16.Encode([]rune(text)) { + _ = binary.Write(&out, binary.LittleEndian, unit) + } + return out.String() +} + +func decodeUTF16(data string) (string, error) { + raw := []byte(data) + if len(raw) < 2 || raw[0] != 0xff || raw[1] != 0xfe || len(raw)%2 != 0 { + return "", errors.New("not UTF-16LE text") + } + units := make([]uint16, 0, len(raw)/2-1) + for i := 2; i < len(raw); i += 2 { + units = append(units, binary.LittleEndian.Uint16(raw[i:])) + } + return string(utf16.Decode(units)), nil +} + +// windowsArg quotes one argument the way CommandLineToArgvW reads it back. +func windowsArg(arg string) string { + if arg != "" && !strings.ContainsAny(arg, " \t\"") { + return arg + } + var b strings.Builder + b.WriteByte('"') + slashes := 0 + for _, c := range arg { + switch c { + case '\\': + slashes++ + case '"': + b.WriteString(strings.Repeat(`\`, 2*slashes+1)) + slashes = 0 + default: + b.WriteString(strings.Repeat(`\`, slashes)) + slashes = 0 + } + if c != '\\' { + b.WriteRune(c) + } + } + b.WriteString(strings.Repeat(`\`, 2*slashes)) + b.WriteByte('"') + return b.String() +} diff --git a/internal/setup/scheduled_task_definition.go b/internal/setup/scheduled_task_definition.go new file mode 100644 index 00000000..18e7d768 --- /dev/null +++ b/internal/setup/scheduled_task_definition.go @@ -0,0 +1,108 @@ +package setup + +import ( + "encoding/xml" + "fmt" + "io" + "slices" + "strings" +) + +// Export-ScheduledTask emits UTF-8 through our PowerShell query, although its +// XML declaration still names UTF-16. The saved file really is UTF-16. +func taskXMLDecoder(text string) *xml.Decoder { + decoder := xml.NewDecoder(strings.NewReader(text)) + decoder.CharsetReader = func(charset string, input io.Reader) (io.Reader, error) { + if strings.EqualFold(charset, "utf-16") { + return input, nil + } + return nil, fmt.Errorf("unexpected task XML encoding %q", charset) + } + return decoder +} + +func setupRegisteredTask(definition, command, user, configPath, logPath, stateDir string) bool { + if !retainedTaskCommand(command, stateDir) { + return false + } + return sameTaskDefinition(definition, renderScheduledTaskText(user, command, configPath, logPath)) +} + +type taskXMLNode struct { + XMLName xml.Name + Attributes []xml.Attr `xml:",any,attr"` + Children []taskXMLNode `xml:",any"` + Text string `xml:",chardata"` +} + +// Task Scheduler reorders XML and inserts schema defaults when registering a +// task. Compare all other fields, including unknown ones, rather than matching +// a subset that could discard an operator's settings or extra actions. +func sameTaskDefinition(actual, expected string) bool { + a, err := taskDefinitionFields(actual) + if err != nil { + return false + } + e, err := taskDefinitionFields(expected) + return err == nil && slices.Equal(a, e) +} + +var taskSchemaDefaults = map[string]string{ + "Task/Settings/WakeToRun": "false", + "Task/Settings/UseUnifiedSchedulingEngine": "false", + "Task/Settings/DisallowStartOnRemoteAppSession": "false", + "Task/Settings/Volatile": "false", + "Task/Settings/IdleSettings/Duration": "PT10M", + "Task/Settings/IdleSettings/WaitTimeout": "PT1H", + "Task/Triggers/LogonTrigger/Delay": "PT0S", + "Task/Actions/Exec/WorkingDirectory": "", + "Task/Actions/@Context": "Author", + "Task/RegistrationInfo/SecurityDescriptor": "", +} + +func taskDefinitionFields(text string) ([]string, error) { + var root taskXMLNode + if err := taskXMLDecoder(text).Decode(&root); err != nil { + return nil, err + } + if root.XMLName.Local != "Task" { + return nil, fmt.Errorf("task XML has no Task root") + } + var fields []string + var visit func(taskXMLNode, string) + visit = func(node taskXMLNode, parent string) { + path := node.XMLName.Local + if parent != "" { + path = parent + "/" + path + } + // These registration timestamps and authors are scheduler metadata. + if path == "Task/RegistrationInfo/Date" || path == "Task/RegistrationInfo/Author" { + return + } + for _, attr := range node.Attributes { + if attr.Name.Local == "xmlns" || attr.Name.Space == "xmlns" || (path == "Task" && attr.Name.Local == "version") { + continue // the scheduler updates the XML schema version + } + key := path + "/@" + attr.Name.Local + if value, ok := taskSchemaDefaults[key]; !ok || attr.Value != value { + fields = append(fields, key+"="+attr.Value) + } + } + if len(node.Children) == 0 { + if value, ok := taskSchemaDefaults[path]; !ok || node.Text != value { + text := node.Text + if path == "Task/Principals/Principal/UserId" || path == "Task/Triggers/LogonTrigger/UserId" { + text = taskUserID(text) + } + fields = append(fields, path+"="+text) + } + return + } + for _, child := range node.Children { + visit(child, path) + } + } + visit(root, "") + slices.Sort(fields) + return fields, nil +} diff --git a/internal/setup/scheduled_task_definition_test.go b/internal/setup/scheduled_task_definition_test.go new file mode 100644 index 00000000..a03dacb2 --- /dev/null +++ b/internal/setup/scheduled_task_definition_test.go @@ -0,0 +1,46 @@ +package setup + +import ( + "strings" + "testing" +) + +func TestTaskDefinitionPreservesOperatorSettings(t *testing.T) { + expected, err := decodeUTF16(renderScheduledTask("S-1-5-21-1001", `C:\runtime\errand.exe`, `C:\config.toml`, `C:\log.txt`)) + if err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + name, actual string + same bool + }{ + {name: "unchanged", actual: expected, same: true}, + {name: "scheduler-version-and-context", actual: strings.ReplaceAll(strings.ReplaceAll(expected, + `version="1.2"`, `version="1.3"`), ``, ``), same: true}, + {name: "empty-security-descriptor", actual: strings.ReplaceAll(expected, "", ""), same: true}, + {name: "scheduler-defaults", actual: strings.ReplaceAll(strings.ReplaceAll(expected, + "", "falsefalse"), + "", "PT10MPT1H"), same: true}, + {name: "scheduler-metadata", actual: strings.ReplaceAll(expected, "", "2026-10-01T00:00:00user"), same: true}, + {name: "element-order", actual: strings.ReplaceAll(expected, + "true\r\n S-1-5-21-1001", + "S-1-5-21-1001\r\n true"), same: true}, + {name: "arguments", actual: strings.ReplaceAll(expected, "serve --config", "serve --other --config")}, + {name: "priority", actual: strings.ReplaceAll(expected, "4", "7")}, + {name: "changed-default", actual: strings.ReplaceAll(expected, "", "true")}, + {name: "working-directory", actual: strings.ReplaceAll(expected, "", `C:\tools`)}, + {name: "extra-action", actual: strings.ReplaceAll(expected, "", `C:\tools\wrapper.exe`)}, + {name: "extra-trigger", actual: strings.ReplaceAll(expected, "", "")}, + {name: "unknown-setting", actual: strings.ReplaceAll(expected, "", "")}, + {name: "security-descriptor", actual: strings.ReplaceAll(expected, "", "D:(A;;FA;;;SY)")}, + {name: "action-context", actual: strings.ReplaceAll(expected, `Context="Author"`, `Context="Other"`)}, + {name: "principal-user", actual: strings.ReplaceAll(expected, "S-1-5-21-1001", "S-1-5-21-1002")}, + {name: "malformed", actual: "not XML"}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := sameTaskDefinition(tc.actual, expected); got != tc.same { + t.Fatalf("sameTaskDefinition = %v, want %v", got, tc.same) + } + }) + } +} diff --git a/internal/setup/scheduled_task_test.go b/internal/setup/scheduled_task_test.go new file mode 100644 index 00000000..463a98a5 --- /dev/null +++ b/internal/setup/scheduled_task_test.go @@ -0,0 +1,223 @@ +// The fake system models Unix paths; Windows setup decisions run here too. +//go:build unix + +package setup + +import ( + "context" + "fmt" + "os" + "strings" + "testing" +) + +const testTaskPath = "/home/george/AppData/Local/errand/errand-task.xml" + +func newWindowsFake(t *testing.T) *fakeSystem { + f := newFake(t, "windows") + f.provider = fakeProvider{name: "cli:tailscale.exe", self: f.provider.(fakeProvider).self} + f.quiesceErr = os.ErrNotExist // no runner yet + return f +} + +func TestWindowsSetupRegistersALogonTaskForTheRuntimeCopy(t *testing.T) { + f := newWindowsFake(t) + r, err := Run(context.Background(), Options{Transport: "tailscale"}, f) + if err != nil || r.Failed() { + t.Fatalf("windows setup failed: %v / %+v", err, r.Steps) + } + text, err := decodeUTF16(f.files[testTaskPath]) + if err != nil { + t.Fatalf("task definition is not UTF-16: %v", err) + } + for _, want := range []string{ + "S-1-5-21-1-2-3-1001", + "InteractiveToken", + "/home/george/.errand/runtime/0123abcd/errand.exe", + "serve --config /home/george/.config/errand/errandd.toml --log-file /home/george/AppData/Local/errand/errand.log", + "PT0S", + "4", + } { + if !strings.Contains(text, want) { + t.Fatalf("task definition missing %q:\n%s", want, text) + } + } + if !strings.Contains(text, "\r\n") { + t.Fatal("task definition should use CRLF line endings") + } + if ran(f, "schtasks /End") { + t.Fatalf("stopped a task that did not exist: %v", f.commands) + } + for _, cmd := range []string{"schtasks /Create /TN errand /XML " + testTaskPath + " /F", "schtasks /Run /TN errand"} { + if !ran(f, cmd) { + t.Fatalf("expected %q; ran %v", cmd, f.commands) + } + } + if r.Info == nil || !strings.Contains(stepDetail(r, "logon"), "signs in") { + t.Fatalf("probe/logon = %+v %+v", r.Info, r.Steps) + } +} + +func TestWindowsSetupMovesItsTaskToTheUpgradedRuntime(t *testing.T) { + f := newWindowsFake(t) + old := "/home/george/.errand/runtime/ffff0000/errand.exe" + f.files[testTaskPath] = renderScheduledTask("S-1-5-21-1-2-3-1001", old, + "/home/george/.config/errand/errandd.toml", "/home/george/AppData/Local/errand/errand.log") + f.files["/home/george/.config/errand/errandd.toml"] = "transport = \"tailscale\"\nlisten = \"tailnet:7443\"\nmax_jobs = 1\n" + f.taskState, f.taskCommand, f.processImage = "Running", old, old + f.quiesceErr = nil + + r, err := Run(context.Background(), Options{}, f) + if err != nil || r.Failed() { + t.Fatalf("upgrade failed: %v / %+v", err, r.Steps) + } + if f.taskCommand != "/home/george/.errand/runtime/0123abcd/errand.exe" { + t.Fatalf("task still runs %s", f.taskCommand) + } + endAt, createAt := -1, -1 + for i, cmd := range f.commands { + if strings.HasPrefix(cmd, "schtasks /End ") { + endAt = i + } + if strings.HasPrefix(cmd, "schtasks /Create ") { + createAt = i + } + } + if endAt < 0 || createAt < endAt { + t.Fatalf("expected the running task to stop before re-registering: %v", f.commands) + } +} + +func TestWindowsSetupLeavesAnOperatorTaskAlone(t *testing.T) { + f := newWindowsFake(t) + f.files[testTaskPath] = renderScheduledTask("S-1-5-21-1-2-3-1001", `C:\tools\wrapper.exe`, + "/home/george/.config/errand/errandd.toml", "/home/george/AppData/Local/errand/errand.log") + r, err := Run(context.Background(), Options{Transport: "tailscale"}, f) + if err != nil { + t.Fatal(err) + } + if !r.Failed() || !strings.Contains(stepErrorDetail(r, "service"), "--force") { + t.Fatalf("operator task was not protected: %+v", r.Steps) + } + if ran(f, "schtasks") { + t.Fatalf("changed an operator task: %v", f.commands) + } + + f = newWindowsFake(t) + f.files[testTaskPath] = "operator edit" + r, err = Run(context.Background(), Options{Transport: "tailscale", Force: true}, f) + if err != nil || r.Failed() { + t.Fatalf("forced setup failed: %v / %+v", err, r.Steps) + } + if !ran(f, "schtasks /Create") { + t.Fatalf("--force did not replace the task: %v", f.commands) + } +} + +func TestWindowsSetupProtectsRegisteredTaskEdits(t *testing.T) { + for _, savedXML := range []bool{false, true} { + for _, edit := range []string{"command", "arguments", "settings", "extra-action"} { + for _, force := range []bool{false, true} { + t.Run(fmt.Sprintf("saved=%v/%s/force=%v", savedXML, edit, force), func(t *testing.T) { + f := newWindowsFake(t) + original := renderScheduledTask("S-1-5-21-1-2-3-1001", "/home/george/.errand/runtime/0123abcd/errand.exe", + "/home/george/.config/errand/errandd.toml", "/home/george/AppData/Local/errand/errand.log") + if savedXML { + f.files[testTaskPath] = original + } + f.taskState, f.taskCommand = "Ready", "/home/george/.errand/runtime/0123abcd/errand.exe" + definition, err := decodeUTF16(original) + if err != nil { + t.Fatal(err) + } + switch edit { + case "command": + f.taskCommand = `C:\tools\wrapper.exe` + definition = strings.ReplaceAll(definition, "/home/george/.errand/runtime/0123abcd/errand.exe", f.taskCommand) + case "arguments": + definition = strings.ReplaceAll(definition, "serve --config", "serve --custom --config") + case "settings": + definition = strings.ReplaceAll(definition, "4", "7") + case "extra-action": + definition = strings.ReplaceAll(definition, "", `C:\tools\wrapper.exe`) + } + f.taskDefinition = definition + r, err := Run(context.Background(), Options{Transport: "tailscale", Force: force}, f) + if err != nil { + t.Fatal(err) + } + if force { + if r.Failed() || !ran(f, "schtasks /Create") { + t.Fatalf("forced setup failed: %+v", r.Steps) + } + } else if !r.Failed() || !strings.Contains(stepErrorDetail(r, "service"), "--force") || ran(f, "schtasks") { + t.Fatalf("operator task was not protected: %+v / %v", r.Steps, f.commands) + } + }) + } + } + } +} + +func TestWindowsSetupRefusesARunnerItDidNotStart(t *testing.T) { + f := newWindowsFake(t) + f.taskState, f.taskCommand = "Running", "/home/george/.errand/runtime/ffff0000/errand.exe" + f.processImage = `C:\Users\george\Downloads\errand.exe` + f.quiesceErr = nil + r, err := Run(context.Background(), Options{Transport: "tailscale"}, f) + if err != nil { + t.Fatal(err) + } + if !r.Failed() || !strings.Contains(stepErrorDetail(r, "service"), "Downloads") { + t.Fatalf("foreign runner was not refused: %+v", r.Steps) + } + if ran(f, "schtasks") || len(f.writes) != 0 { + t.Fatalf("setup changed things before refusing: %v %v", f.commands, f.writes) + } +} + +func TestWindowsDryRunPublishesNothing(t *testing.T) { + f := newWindowsFake(t) + r, err := Run(context.Background(), Options{Transport: "tailscale", DryRun: true}, f) + if err != nil || r.Failed() { + t.Fatalf("dry run failed: %v / %+v", err, r.Steps) + } + if len(f.writes) != 0 || len(f.files) != 0 || ran(f, "schtasks") { + t.Fatalf("dry run changed things: writes=%v files=%v commands=%v", f.writes, f.files, f.commands) + } +} + +func TestNewWindowsRunnersUseOnlyTheTailnet(t *testing.T) { + f := newWindowsFake(t) + r, err := Run(context.Background(), Options{}, f) + if err != nil || r.Failed() { + t.Fatalf("windows setup failed: %v / %+v", err, r.Steps) + } + if cfg := f.files["/home/george/.config/errand/errandd.toml"]; !strings.Contains(cfg, `transport = "tailscale"`) { + t.Fatalf("config = %s", cfg) + } + + f = newWindowsFake(t) + r, err = Run(context.Background(), Options{Transport: "both"}, f) + if err != nil || r.Failed() { + t.Fatalf("windows setup failed: %v / %+v", err, r.Steps) + } + if !strings.Contains(stepDetail(r, "path"), "not supported on Windows") || len(f.symlinks) != 0 { + t.Fatalf("path step = %q, symlinks %v", stepDetail(r, "path"), f.symlinks) + } +} + +func TestWindowsArgRoundTripsThroughTheCommandLineParser(t *testing.T) { + for arg, want := range map[string]string{ + `C:\Users\george\errandd.toml`: `C:\Users\george\errandd.toml`, + `C:\Users\George Lydakis\e.toml`: `"C:\Users\George Lydakis\e.toml"`, + `C:\dir with space\`: `"C:\dir with space\\"`, + `say "hi"`: `"say \"hi\""`, + ``: `""`, + `a\\"b`: `"a\\\\\"b"`, + } { + if got := windowsArg(arg); got != want { + t.Errorf("windowsArg(%q) = %s, want %s", arg, got, want) + } + } +} diff --git a/internal/setup/scheduled_task_windows_test.go b/internal/setup/scheduled_task_windows_test.go new file mode 100644 index 00000000..47cb49d1 --- /dev/null +++ b/internal/setup/scheduled_task_windows_test.go @@ -0,0 +1,66 @@ +//go:build windows + +package setup + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/lydakis/errand/internal/proto" +) + +// Compare against Task Scheduler's actual export, including the defaults it +// inserts. Register a unique task without starting it or touching errand's task. +func TestScheduledTaskExportMatchesSetupOnWindows(t *testing.T) { + sys := RealSystem{} + user, err := sys.UserSID() + if err != nil { + t.Fatal(err) + } + dir := t.TempDir() + dir = filepath.Join(dir, "task with spaces 世界") + if err := os.Mkdir(dir, 0o700); err != nil { + t.Fatal(err) + } + definition, err := decodeUTF16(renderScheduledTask(user, + filepath.Join(dir, "runtime", "generation", "errand.exe"), filepath.Join(dir, "config.toml"), filepath.Join(dir, "log.txt"))) + if err != nil { + t.Fatal(err) + } + name := "errand-test-" + proto.NewULID() + definition = strings.ReplaceAll(definition, `\errand`, `\`+name+``) + file := filepath.Join(dir, "task.xml") + if err := os.WriteFile(file, []byte(encodeUTF16(definition)), 0o600); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(t.Context(), time.Minute) + defer cancel() + out, err := sys.Run(ctx, "schtasks", "/Create", "/TN", name, "/XML", file, "/F") + if err != nil { + t.Fatalf("register task: %v: %s", err, out) + } + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + if out, err := sys.Run(ctx, "schtasks", "/Delete", "/TN", name, "/F"); err != nil { + t.Errorf("remove task: %v: %s", err, out) + } + }) + query := strings.Replace(scheduledTaskDefinitionQuery, "-TaskName '"+scheduledTaskName+"'", "-TaskName '"+name+"'", 1) + registered, err := readScheduledTask(ctx, sys, query) + if err != nil { + t.Fatalf("query task: %v", err) + } + if registered.State != "Ready" || registered.Command != filepath.Join(dir, "runtime", "generation", "errand.exe") { + t.Fatalf("registered task state/command = %q / %q", registered.State, registered.Command) + } + if !sameTaskDefinition(registered.Definition, strings.ReplaceAll(definition, "\r\n", "\n")) { + actual, _ := taskDefinitionFields(registered.Definition) + expected, _ := taskDefinitionFields(definition) + t.Fatalf("registered definition differs from setup:\nactual: %v\nexpected: %v", actual, expected) + } +} diff --git a/internal/setup/service_identity.go b/internal/setup/service_identity.go index a40d6263..35f0c44a 100644 --- a/internal/setup/service_identity.go +++ b/internal/setup/service_identity.go @@ -43,6 +43,9 @@ func parseServicePID(value string) (int, error) { // A responsive socket alone is not proof that setup owns that daemon. Refuse // to change its config or install a competing service, even with --force. func verifyServiceOwner(ctx context.Context, sys System, socket string, pid int) error { + if sys.GOOS() == "windows" { + return verifyScheduledTaskOwner(ctx, sys, socket, pid) + } managedPID, err := managedServicePID(ctx, sys) if err != nil { return fmt.Errorf("runner PID %d already owns %s, but its setup-managed service cannot be verified: %w; use the existing service manager or explicitly migrate the runner before rerunning setup", pid, socket, err) @@ -52,3 +55,23 @@ func verifyServiceOwner(ctx context.Context, sys System, socket string, pid int) } return nil } + +// Task Scheduler does not report a task's PID. The runner is setup's when the +// task is running and the socket's process runs the task's command. +func verifyScheduledTaskOwner(ctx context.Context, sys System, socket string, pid int) error { + task, err := queryScheduledTask(ctx, sys) + if err == nil && task.State != "Running" { + err = fmt.Errorf("scheduled task %s is %s", scheduledTaskName, strings.ToLower(task.State)) + } + var image string + if err == nil { + image, err = sys.ProcessImage(pid) + } + if err != nil { + return fmt.Errorf("runner PID %d already owns %s, but its setup-managed service cannot be verified: %w; use the existing service manager or explicitly migrate the runner before rerunning setup", pid, socket, err) + } + if !sys.SameFile(image, task.Command) { + return fmt.Errorf("runner PID %d owns %s and runs %s, but scheduled task %s runs %s; use the existing service manager or explicitly migrate the runner before rerunning setup", pid, socket, image, scheduledTaskName, task.Command) + } + return nil +} diff --git a/internal/setup/service_identity_test.go b/internal/setup/service_identity_test.go index 3452beea..e125c85d 100644 --- a/internal/setup/service_identity_test.go +++ b/internal/setup/service_identity_test.go @@ -1,3 +1,6 @@ +// The fake system models Unix paths; Windows setup decisions run here too. +//go:build unix + package setup import ( diff --git a/internal/setup/setup.go b/internal/setup/setup.go index 4eadc50e..5e786dff 100644 --- a/internal/setup/setup.go +++ b/internal/setup/setup.go @@ -8,7 +8,6 @@ import ( "os" "path/filepath" "strings" - "syscall" "time" "github.com/BurntSushi/toml" @@ -16,6 +15,7 @@ import ( "github.com/lydakis/errand/internal/config" "github.com/lydakis/errand/internal/proto" "github.com/lydakis/errand/internal/tailnet" + "github.com/lydakis/errand/internal/unixpeer" ) const ( @@ -24,6 +24,7 @@ const ( LaunchAgentLabel = "dev.lydakis.errand" pathSymlinkDir = "/usr/local/bin" probeTimeout = 8 * time.Second + serviceQueryTimeout = 30 * time.Second probeInterval = 250 * time.Millisecond linuxUnitSubdir = ".config/systemd/user" darwinAgentSubdir = "Library/LaunchAgents" @@ -160,6 +161,11 @@ func Run(ctx context.Context, opts Options, sys System) (*Report, error) { // 1. Resolve transport availability and plan the configuration before // acquiring a restart lease, writing files, or changing the service. + if sys.GOOS() == "windows" && opts.Transport == "" && saved.daemon == nil { + // Windows runners don't serve SSH callers yet, so a new one is + // reachable over the tailnet only. + opts.Transport = config.TransportTailscale + } transport, err := resolveTransport(ctx, opts, sys, saved, r) if err != nil { return r, err @@ -184,11 +190,11 @@ func Run(ctx context.Context, opts Options, sys System) (*Report, error) { var leaseToken string previousPID := 0 - if !opts.DryRun && (sys.GOOS() == "linux" || sys.GOOS() == "darwin") { + if !opts.DryRun && managedServiceOS(sys.GOOS()) { pidCtx, cancel := context.WithTimeout(ctx, 2*time.Second) previousPID, err = sys.SocketPID(pidCtx, restartSocketPath) cancel() - if err != nil && !errors.Is(err, os.ErrNotExist) && !errors.Is(err, syscall.ECONNREFUSED) { + if err != nil && !errors.Is(err, os.ErrNotExist) && !unixpeer.ConnectionRefused(err) { r.fail("service", fmt.Errorf("cannot inspect runner process at %s: %w", restartSocketPath, err)) return r, nil } @@ -259,6 +265,8 @@ func Run(ctx context.Context, opts Options, sys System) (*Report, error) { installSystemd(ctx, opts, sys, r, home, exe, configPath, runnerPath) case "darwin": installLaunchAgent(ctx, opts, sys, r, home, exe, configPath, runnerPath) + case "windows": + installScheduledTask(ctx, opts, sys, r, home, exe, configPath, effective.StateDir) default: r.step("service", "no service manager integration for "+sys.GOOS()+"; run `"+exe+" serve` yourself", false) } @@ -312,11 +320,22 @@ func serviceActive(ctx context.Context, sys serviceSystem) (bool, error) { return false, nil } return false, err + case "windows": + task, err := queryScheduledTask(ctx, sys) + if err != nil { + return false, err + } + return task.State == "Running", nil default: return false, nil } } +// managedServiceOS reports whether setup installs and restarts a service here. +func managedServiceOS(goos string) bool { + return goos == "linux" || goos == "darwin" || goos == "windows" +} + func installSystemd(ctx context.Context, opts Options, sys System, r *Report, home, exe, configPath, runnerPath string) bool { r.Service = DefaultServiceName + ".service" unitPath := filepath.Join(home, linuxUnitSubdir, r.Service) @@ -412,7 +431,7 @@ func acquireRestartLease(ctx context.Context, sys System, r *Report, socketPath if err == nil { return token, true } - if errors.Is(err, os.ErrNotExist) || errors.Is(err, syscall.ECONNREFUSED) { + if errors.Is(err, os.ErrNotExist) || unixpeer.ConnectionRefused(err) { return "", true } var quiesceErr *QuiesceError @@ -455,6 +474,10 @@ func writeDefinition(sys System, r *Report, name, path, desired string, opts Opt // whose PATH is typically /usr/local/bin:/usr/bin:/bin. Without this, SSH // peers need remote_command set to the binary's absolute path. func ensureOnPath(sys System, r *Report, exe string, force, dryRun bool) { + if sys.GOOS() == "windows" { + r.step("path", "SSH callers are not supported on Windows runners yet; reach this runner over the tailnet", false) + return + } link := filepath.Join(pathSymlinkDir, "errand") if filepath.Dir(exe) == pathSymlinkDir { r.RemoteCommand = "" @@ -509,17 +532,21 @@ func probe(ctx context.Context, sys System, r *Report, previousPID int, expected for time.Now().Before(deadline) { probeCtx, cancel := context.WithTimeout(ctx, 2*time.Second) info, err := sys.Probe(probeCtx, r.SocketPath) - if err == nil && (sys.GOOS() == "linux" || sys.GOOS() == "darwin") { - var pid int + var pid int + if err == nil && managedServiceOS(sys.GOOS()) { pid, err = sys.SocketPID(probeCtx, r.SocketPath) if err == nil && pid == previousPID { err = fmt.Errorf("old runner PID %d is still answering after service restart", pid) } - if err == nil { - err = verifyServiceOwner(probeCtx, sys, r.SocketPath, pid) - } } cancel() + if err == nil && managedServiceOS(sys.GOOS()) { + // Verification asks the service manager, which can be slow to + // start (PowerShell takes seconds), so it gets its own budget. + verifyCtx, cancelVerify := context.WithTimeout(ctx, serviceQueryTimeout) + err = verifyServiceOwner(verifyCtx, sys, r.SocketPath, pid) + cancelVerify() + } if err == nil { r.Info = &info if expectedVersion != "" && info.Version != expectedVersion { diff --git a/internal/setup/setup_test.go b/internal/setup/setup_test.go index 7e585433..171a0fcb 100644 --- a/internal/setup/setup_test.go +++ b/internal/setup/setup_test.go @@ -1,3 +1,6 @@ +// The fake system models Unix paths; Windows setup decisions run here too. +//go:build unix + package setup import ( @@ -43,6 +46,10 @@ type fakeSystem struct { discoverErr error discoverSocket string discoverCLI string + taskState string + taskCommand string + taskDefinition string + processImage string } func newFake(t *testing.T, goos string) *fakeSystem { @@ -117,6 +124,39 @@ func (f *fakeSystem) Run(_ context.Context, name string, args ...string) (string if strings.HasPrefix(line, "launchctl bootstrap ") || strings.HasPrefix(line, "systemctl --user restart ") { f.currentPID++ } + if name == "powershell.exe" { + if f.taskState == "" { + return "Missing", nil + } + out := f.taskState + "\r\n" + f.taskCommand + if args[len(args)-1] == scheduledTaskDefinitionQuery { + definition := f.taskDefinition + if definition == "" { + definition, _ = decodeUTF16(renderScheduledTask("S-1-5-21-1-2-3-1001", f.taskCommand, + f.home+"/.config/errand/errandd.toml", f.home+"/AppData/Local/errand/errand.log")) + } + out += "\r\n" + definition + } + return out, nil + } + switch { + case strings.HasPrefix(line, "schtasks /Create "): + command, err := taskCommand(f.files[args[4]]) + if err != nil { + return "", err + } + f.taskCommand = command + f.taskDefinition, _ = decodeUTF16(f.files[args[4]]) + if f.taskState == "" { + f.taskState = "Ready" + } + case strings.HasPrefix(line, "schtasks /End "): + f.taskState = "Ready" + case strings.HasPrefix(line, "schtasks /Run "): + f.taskState = "Running" + f.currentPID++ + f.processImage = f.taskCommand + } if out, ok := f.cmdOutput[line]; ok { return out, nil } @@ -145,7 +185,7 @@ func (f *fakeSystem) SocketPID(_ context.Context, _ string) (int, error) { if f.socketPID != 0 { return f.socketPID, nil } - if ran(f, "launchctl bootstrap") || ran(f, "systemctl --user restart") { + if ran(f, "launchctl bootstrap") || ran(f, "systemctl --user restart") || ran(f, "schtasks /Run") { return f.currentPID, nil } if f.quiesceErr != nil { @@ -184,6 +224,15 @@ func testActiveJobSummary(info proto.Info) string { } return strings.Join(active, ", ") } +func (f *fakeSystem) RuntimePath(exe, stateDir string, create bool) (string, error) { + path := filepath.Join(stateDir, "runtime", "0123abcd", "errand.exe") + if create { + f.files[path] = "runtime copy of " + exe + } + return path, nil +} +func (f *fakeSystem) UserSID() (string, error) { return "S-1-5-21-1-2-3-1001", nil } +func (f *fakeSystem) ProcessImage(int) (string, error) { return f.processImage, nil } func (f *fakeSystem) ReleaseQuiesce(_ context.Context, socket, token string) error { f.releasedLeases = append(f.releasedLeases, socket+"\x00"+token) return nil diff --git a/internal/setup/ssh_test.go b/internal/setup/ssh_test.go index f450dfbb..5dd5d1a8 100644 --- a/internal/setup/ssh_test.go +++ b/internal/setup/ssh_test.go @@ -1,3 +1,6 @@ +// The fake system models Unix paths; Windows setup decisions run here too. +//go:build unix + package setup import ( diff --git a/internal/setup/system.go b/internal/setup/system.go index 71779156..3d7552aa 100644 --- a/internal/setup/system.go +++ b/internal/setup/system.go @@ -24,6 +24,7 @@ import ( "time" "github.com/lydakis/errand/internal/proto" + "github.com/lydakis/errand/internal/serviceruntime" "github.com/lydakis/errand/internal/tailnet" "github.com/lydakis/errand/internal/unixpeer" ) @@ -51,6 +52,11 @@ type System interface { SocketPID(ctx context.Context, socket string) (int, error) Quiesce(ctx context.Context, socket string) (string, error) ReleaseQuiesce(ctx context.Context, socket, token string) error + // RuntimePath returns the daemon's retained copy of exe, publishing it + // when create is set. + RuntimePath(exe, stateDir string, create bool) (string, error) + UserSID() (string, error) + ProcessImage(pid int) (string, error) } type QuiesceError struct { @@ -74,6 +80,16 @@ func (RealSystem) SocketPID(ctx context.Context, socket string) (int, error) { func (RealSystem) GOOS() string { return runtime.GOOS } +func (RealSystem) RuntimePath(exe, stateDir string, create bool) (string, error) { + if create { + return serviceruntime.Prepare(exe, stateDir) + } + return serviceruntime.Path(exe, stateDir) +} + +func (RealSystem) UserSID() (string, error) { return currentUserSID() } +func (RealSystem) ProcessImage(pid int) (string, error) { return processImage(pid) } + func (RealSystem) Home() (string, error) { home, err := os.UserHomeDir() if err != nil { diff --git a/internal/setup/transport_test.go b/internal/setup/transport_test.go index 2024cb63..3413134c 100644 --- a/internal/setup/transport_test.go +++ b/internal/setup/transport_test.go @@ -1,3 +1,6 @@ +// The fake system models Unix paths; Windows setup decisions run here too. +//go:build unix + package setup import ( diff --git a/internal/setup/upgrade_test.go b/internal/setup/upgrade_test.go index 8658658b..67fe3fb3 100644 --- a/internal/setup/upgrade_test.go +++ b/internal/setup/upgrade_test.go @@ -1,3 +1,6 @@ +// The fake system models Unix paths; Windows setup decisions run here too. +//go:build unix + package setup import ( diff --git a/internal/snapshot/fifo_unix_test.go b/internal/snapshot/fifo_unix_test.go new file mode 100644 index 00000000..3ddbdf74 --- /dev/null +++ b/internal/snapshot/fifo_unix_test.go @@ -0,0 +1,15 @@ +//go:build unix + +package snapshot + +import ( + "syscall" + "testing" +) + +const fifosSupported = true + +// requireFIFOs skips tests that need named pipes in the file system. +func requireFIFOs(*testing.T) {} + +func mkfifo(path string) error { return syscall.Mkfifo(path, 0o600) } diff --git a/internal/snapshot/fifo_windows_test.go b/internal/snapshot/fifo_windows_test.go new file mode 100644 index 00000000..d421dcb4 --- /dev/null +++ b/internal/snapshot/fifo_windows_test.go @@ -0,0 +1,15 @@ +//go:build windows + +package snapshot + +import ( + "errors" + "testing" +) + +const fifosSupported = false + +// requireFIFOs skips tests that need named pipes in the file system. +func requireFIFOs(t *testing.T) { t.Skip("Windows has no FIFOs in the file system") } + +func mkfifo(string) error { return errors.New("Windows has no FIFOs in the file system") } diff --git a/internal/snapshot/snapshot.go b/internal/snapshot/snapshot.go index ed04f9cb..e203c544 100644 --- a/internal/snapshot/snapshot.go +++ b/internal/snapshot/snapshot.go @@ -22,6 +22,7 @@ import ( "strings" "sync" + "github.com/lydakis/errand/internal/fsmode" "github.com/lydakis/errand/internal/pathpolicy" "github.com/lydakis/errand/internal/proto" ) @@ -750,7 +751,7 @@ func buildSelectedContext(ctx context.Context, root string, paths []string, maxB if err != nil { return m, err } - e := proto.ManifestEntry{Path: rel, Mode: uint32(fi.Mode().Perm())} + e := proto.ManifestEntry{Path: rel, Mode: fsmode.Perm(fi)} switch { case fi.Mode().IsDir(): e.Type = proto.EntryDir @@ -792,7 +793,10 @@ func readSymlinkTarget(abs string, prior *Observation) (string, error) { if prior != nil { return prior.Entry.Target, nil } - return os.Readlink(abs) + target, err := os.Readlink(abs) + // Windows stores backslashes in links; manifests use slash-separated + // targets. ToSlash preserves literal backslashes on Unix. + return filepath.ToSlash(target), err } // Pack writes the manifest's entries as a tar stream, verifying each file @@ -851,7 +855,7 @@ func packPartialContext(ctx context.Context, w io.Writer, root string, m proto.M if err != nil { return sourceReadError(err) } - if !fi.IsDir() || uint32(fi.Mode().Perm()) != expectedMode { + if !fi.IsDir() || !fsmode.Matches(fi, expectedMode) { return sourceChangedf("snapshot: %s changed during pack; retry", e.Path) } hdr.Typeflag = tar.TypeDir @@ -864,14 +868,14 @@ func packPartialContext(ctx context.Context, w io.Writer, root string, m proto.M if err != nil { return sourceReadError(err) } - if fi.Mode()&fs.ModeSymlink == 0 || uint32(fi.Mode().Perm()) != expectedMode { + if fi.Mode()&fs.ModeSymlink == 0 || !fsmode.Matches(fi, expectedMode) { return sourceChangedf("snapshot: %s changed during pack; retry", e.Path) } target, err := rootFS.Readlink(e.Path) if err != nil { return sourceReadError(err) } - if target != e.Target { + if filepath.ToSlash(target) != e.Target { return sourceChangedf("snapshot: %s changed during pack; retry", e.Path) } hdr.Typeflag = tar.TypeSymlink @@ -884,7 +888,7 @@ func packPartialContext(ctx context.Context, w io.Writer, root string, m proto.M if err != nil { return fmt.Errorf("snapshot: %s vanished during pack: %w", e.Path, sourceReadError(err)) } - if !fi.Mode().IsRegular() || fi.Size() != e.Size || uint32(fi.Mode().Perm()) != expectedMode { + if !fi.Mode().IsRegular() || fi.Size() != e.Size || !fsmode.Matches(fi, expectedMode) { return sourceChangedf("snapshot: %s changed during pack; retry", e.Path) } f, err := rootFS.Open(e.Path) @@ -895,7 +899,7 @@ func packPartialContext(ctx context.Context, w io.Writer, root string, m proto.M if err != nil { return errors.Join(err, f.Close()) } - if !opened.Mode().IsRegular() || opened.Size() != e.Size || uint32(opened.Mode().Perm()) != expectedMode { + if !opened.Mode().IsRegular() || opened.Size() != e.Size || !fsmode.Matches(opened, expectedMode) { f.Close() return sourceChangedf("snapshot: %s changed during pack; retry", e.Path) } @@ -930,7 +934,7 @@ func packPartialContext(ctx context.Context, w io.Writer, root string, m proto.M } if n != e.Size || hex.EncodeToString(h.Sum(nil)) != e.SHA256 || extraN != 0 || extraErr != io.EOF || !closed.Mode().IsRegular() || - closed.Size() != e.Size || uint32(closed.Mode().Perm()) != expectedMode { + closed.Size() != e.Size || !fsmode.Matches(closed, expectedMode) { return sourceChangedf("snapshot: %s changed during pack; retry", e.Path) } } diff --git a/internal/snapshot/symlink_targets_test.go b/internal/snapshot/symlink_targets_test.go new file mode 100644 index 00000000..be402f7f --- /dev/null +++ b/internal/snapshot/symlink_targets_test.go @@ -0,0 +1,61 @@ +package snapshot + +import ( + "archive/tar" + "bytes" + "io" + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestSymlinkTargetsPackOnWindows(t *testing.T) { + root := t.TempDir() + writeFile(t, root, "src/file", "content") + targets := map[string]string{"link": "src/file", "src/link": "../src/file", "dangling": "missing/file"} + if runtime.GOOS != "windows" { + // Backslashes are literal filename characters on Unix. + targets["literal"] = `src\file` + } + paths := []string{"src", "src/file"} + for name, target := range targets { + if err := os.Symlink(target, filepath.Join(root, filepath.FromSlash(name))); err != nil { + t.Fatal(err) + } + paths = append(paths, name) + } + m, err := Build(root, paths) + if err != nil { + t.Fatal(err) + } + for _, e := range m.Entries { + if target, ok := targets[e.Path]; ok && e.Target != target { + t.Fatalf("manifest target for %s = %q, want %q", e.Path, e.Target, target) + } + } + var packed bytes.Buffer + if err := Pack(&packed, root, m); err != nil { + t.Fatal(err) + } + reader := tar.NewReader(&packed) + links := 0 + for { + hdr, err := reader.Next() + if err == io.EOF { + break + } + if err != nil { + t.Fatal(err) + } + if hdr.Typeflag == tar.TypeSymlink { + if target, ok := targets[hdr.Name]; !ok || hdr.Linkname != target { + t.Fatalf("archive target for %s = %q, want %q", hdr.Name, hdr.Linkname, target) + } + links++ + } + } + if links != len(targets) { + t.Fatalf("packed %d symlinks, want %d", links, len(targets)) + } +} diff --git a/internal/snapshot/watch_test.go b/internal/snapshot/watch_test.go index 71436bc0..f775fdc9 100644 --- a/internal/snapshot/watch_test.go +++ b/internal/snapshot/watch_test.go @@ -7,7 +7,6 @@ import ( "path/filepath" "slices" "strings" - "syscall" "testing" "time" @@ -224,7 +223,7 @@ func TestWatchGitSelectionNeverWatchesGlobalControlDirectories(t *testing.T) { home, xdg := isolateGlobalGit(t) // Entries a kqueue watch on $HOME would open: a FIFO blocks readers // and macOS guards folders like Desktop behind a privacy prompt. - if err := syscall.Mkfifo(filepath.Join(home, "fifo"), 0600); err != nil { + if err := mkfifo(filepath.Join(home, "fifo")); err != nil && fifosSupported { t.Fatal(err) } writeFile(t, home, "Desktop/private", "unrelated") diff --git a/internal/tailnet/tailnet.go b/internal/tailnet/tailnet.go index 83fb6bfb..069b8d87 100644 --- a/internal/tailnet/tailnet.go +++ b/internal/tailnet/tailnet.go @@ -12,11 +12,14 @@ import ( "net/url" "os" "os/exec" + "path/filepath" "runtime" "sort" "strconv" "strings" "time" + + "github.com/lydakis/errand/internal/nowindow" ) // WhoIs is the subset of a Tailscale WhoIs answer errand relies on. @@ -152,6 +155,19 @@ func defaultSocketCandidates() []string { } } +// A service started at logon can miss the installer's PATH change, so look +// where the Windows installer puts the CLI too. +func defaultCLICandidates() []string { + if runtime.GOOS != "windows" { + return nil + } + programFiles := os.Getenv("ProgramFiles") + if programFiles == "" { + programFiles = `C:\Program Files` + } + return []string{filepath.Join(programFiles, "Tailscale", "tailscale.exe")} +} + func Discover(socket, cli string) (Provider, error) { if socket != "" { if err := socketUsable(socket); err != nil { @@ -181,6 +197,12 @@ func discoverDefault(candidates []string) (Provider, error) { return NewCLI(path), nil } tried = append(tried, "tailscale CLI on PATH") + for _, candidate := range defaultCLICandidates() { + if info, err := os.Stat(candidate); err == nil && info.Mode().IsRegular() { + return NewCLI(candidate), nil + } + tried = append(tried, candidate) + } return nil, fmt.Errorf("no way to reach tailscaled (tried: %s); set tailscaled_socket or tailscale_cli in errandd.toml", strings.Join(tried, ", ")) } @@ -338,6 +360,7 @@ func (p *cli) run(ctx context.Context, args ...string) ([]byte, error) { ctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() cmd := exec.CommandContext(ctx, p.path, args...) + nowindow.Hide(cmd) var stderr strings.Builder cmd.Stderr = &stderr out, err := cmd.Output() diff --git a/internal/unixpeer/credentials_other.go b/internal/unixpeer/credentials_other.go index dfbeefad..53b474ec 100644 --- a/internal/unixpeer/credentials_other.go +++ b/internal/unixpeer/credentials_other.go @@ -1,4 +1,4 @@ -//go:build !linux && !darwin +//go:build !linux && !darwin && !windows package unixpeer diff --git a/internal/unixpeer/credentials_windows.go b/internal/unixpeer/credentials_windows.go new file mode 100644 index 00000000..1a9a6c77 --- /dev/null +++ b/internal/unixpeer/credentials_windows.go @@ -0,0 +1,97 @@ +//go:build windows + +package unixpeer + +import ( + "fmt" + "net" + "unsafe" + + "golang.org/x/sys/windows" +) + +// Windows has no numeric uids. Peers are compared by their token's user SID, +// and UID only records the result: SameUser when the peer runs as the same +// account as this process, OtherUser otherwise. +const ( + SameUser uint32 = 0 + OtherUser uint32 = 1 +) + +// sioAFUnixGetPeerPID is SIO_AF_UNIX_GETPEERPID from afunix.h. +const sioAFUnixGetPeerPID = 0x58000100 + +func CurrentUID() uint32 { return SameUser } + +func Credentials(conn *net.UnixConn) (Peer, error) { + pid, err := ProcessID(conn) + if err != nil { + return Peer{}, err + } + sid, account, err := processUser(uint32(pid)) + if err != nil { + return Peer{}, fmt.Errorf("reading peer process %d user: %w", pid, err) + } + self, err := windows.GetCurrentProcessToken().GetTokenUser() + if err != nil { + return Peer{}, err + } + peer := Peer{UID: OtherUser, User: account} + if sid.Equals(self.User.Sid) { + peer.UID = SameUser + } + return peer, nil +} + +// ProcessID returns the PID Windows reports for the connected peer. +func ProcessID(conn *net.UnixConn) (int, error) { + raw, err := conn.SyscallConn() + if err != nil { + return 0, err + } + var pid uint32 + var pidErr error + if err := raw.Control(func(fd uintptr) { + var returned uint32 + pidErr = windows.WSAIoctl(windows.Handle(fd), sioAFUnixGetPeerPID, nil, 0, + (*byte)(unsafe.Pointer(&pid)), uint32(unsafe.Sizeof(pid)), &returned, nil, 0) + }); err != nil { + return 0, err + } + if pidErr != nil { + return 0, pidErr + } + if pid == 0 { + return 0, fmt.Errorf("peer process ID is unavailable") + } + return int(pid), nil +} + +func processUser(pid uint32) (*windows.SID, string, error) { + process, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, pid) + if err != nil { + return nil, "", err + } + defer windows.CloseHandle(process) + var token windows.Token + if err := windows.OpenProcessToken(process, windows.TOKEN_QUERY, &token); err != nil { + return nil, "", err + } + defer token.Close() + user, err := token.GetTokenUser() + if err != nil { + return nil, "", err + } + sid, err := user.User.Sid.Copy() + if err != nil { + return nil, "", err + } + account, domain, _, err := sid.LookupAccount("") + if err != nil { + return sid, sid.String(), nil + } + if domain != "" { + account = domain + `\` + account + } + return sid, account, nil +} diff --git a/internal/unixpeer/dial.go b/internal/unixpeer/dial.go index 7e9eb32f..66f33812 100644 --- a/internal/unixpeer/dial.go +++ b/internal/unixpeer/dial.go @@ -8,7 +8,11 @@ import ( ) // Peer is the effective identity of the process at the other end of a socket. -type Peer struct{ UID, GID uint32 } +// User, when set, names the peer's account. +type Peer struct { + UID, GID uint32 + User string +} // Dial connects only to a server running as the expected effective UID. // Credentials are checked on the connected socket before any request is sent, diff --git a/internal/unixpeer/dial_windows_test.go b/internal/unixpeer/dial_windows_test.go new file mode 100644 index 00000000..18f0a16a --- /dev/null +++ b/internal/unixpeer/dial_windows_test.go @@ -0,0 +1,95 @@ +//go:build windows + +package unixpeer + +import ( + "net" + "os" + "path/filepath" + "testing" +) + +func TestCredentialsIdentifySameUserOnWindows(t *testing.T) { + socket := filepath.Join(t.TempDir(), "s") + listener, err := net.Listen("unix", socket) + if err != nil { + t.Fatal(err) + } + defer listener.Close() + accepted := make(chan net.Conn, 1) + go func() { + conn, err := listener.Accept() + if err != nil { + accepted <- nil + return + } + accepted <- conn + }() + client, err := net.Dial("unix", socket) + if err != nil { + t.Fatal(err) + } + defer client.Close() + server := <-accepted + if server == nil { + t.Fatal("accept failed") + } + defer server.Close() + pid, err := ProcessID(server.(*net.UnixConn)) + if err != nil { + t.Fatal(err) + } + if pid != os.Getpid() { + t.Fatalf("peer pid %d, want %d", pid, os.Getpid()) + } + peer, err := Credentials(server.(*net.UnixConn)) + if err != nil { + t.Fatal(err) + } + if peer.UID != CurrentUID() || peer.User == "" { + t.Fatalf("peer %+v, want same user with a name", peer) + } + conn, err := Dial(t.Context(), socket, CurrentUID()) + if err != nil { + t.Fatalf("Dial: %v", err) + } + conn.Close() + if _, err := Dial(t.Context(), socket, OtherUser); err == nil { + t.Fatal("Dial accepted a server for the wrong user") + } +} + +func TestStaleSocketIsConnectionRefusedOnWindows(t *testing.T) { + socket := filepath.Join(t.TempDir(), "s") + listener, err := net.Listen("unix", socket) + if err != nil { + t.Fatal(err) + } + // Leave the socket file behind, as a crashed daemon would. + listener.(*net.UnixListener).SetUnlinkOnClose(false) + listener.Close() + info, err := os.Lstat(socket) + if err != nil { + t.Fatal(err) + } + if info.Mode()&os.ModeSocket == 0 { + t.Fatalf("stale socket mode %v is not a socket", info.Mode()) + } + _, err = net.Dial("unix", socket) + if !ConnectionRefused(err) { + t.Fatalf("dial stale socket: %v, want connection refused", err) + } +} + +func TestMissingSocketsReportNoListenerOnWindows(t *testing.T) { + dir := t.TempDir() + for _, socket := range []string{ + filepath.Join(dir, "s"), + filepath.Join(dir, "absent", "s"), + } { + _, err := net.Dial("unix", socket) + if err == nil || !ConnectionRefused(err) { + t.Fatalf("dial %s: %v, want no listener", socket, err) + } + } +} diff --git a/internal/unixpeer/pid_other.go b/internal/unixpeer/pid_other.go index 177a34a3..d9d04347 100644 --- a/internal/unixpeer/pid_other.go +++ b/internal/unixpeer/pid_other.go @@ -1,4 +1,4 @@ -//go:build !linux && !darwin +//go:build !linux && !darwin && !windows package unixpeer diff --git a/internal/unixpeer/refused.go b/internal/unixpeer/refused.go new file mode 100644 index 00000000..9bb35ad3 --- /dev/null +++ b/internal/unixpeer/refused.go @@ -0,0 +1,5 @@ +package unixpeer + +// ConnectionRefused reports whether a dial failed because nothing listens on +// the socket, as with a stale socket file left by an exited daemon. +func ConnectionRefused(err error) bool { return connectionRefused(err) } diff --git a/internal/unixpeer/refused_unix.go b/internal/unixpeer/refused_unix.go new file mode 100644 index 00000000..ece659b1 --- /dev/null +++ b/internal/unixpeer/refused_unix.go @@ -0,0 +1,10 @@ +//go:build unix + +package unixpeer + +import ( + "errors" + "syscall" +) + +func connectionRefused(err error) bool { return errors.Is(err, syscall.ECONNREFUSED) } diff --git a/internal/unixpeer/refused_windows.go b/internal/unixpeer/refused_windows.go new file mode 100644 index 00000000..52dc3e03 --- /dev/null +++ b/internal/unixpeer/refused_windows.go @@ -0,0 +1,18 @@ +//go:build windows + +package unixpeer + +import ( + "errors" + "syscall" + + "golang.org/x/sys/windows" +) + +// Winsock reports WSAECONNREFUSED, not the syscall package's ECONNREFUSED. +// Dialing a socket whose directory does not exist yet reports WSAENETDOWN +// instead of a missing file; nothing listens there either. +func connectionRefused(err error) bool { + return errors.Is(err, windows.WSAECONNREFUSED) || errors.Is(err, syscall.ECONNREFUSED) || + errors.Is(err, windows.WSAENETDOWN) +}