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)
+}