From 80ad9df09bb112079f8786d83028ebf5e396bc39 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 03:38:52 +0000 Subject: [PATCH 01/27] Build errand for Windows and run jobs in Job Objects First step toward a Windows runner. GOOS=windows now builds and vets, with real Windows implementations for the primitives the runner needs: - fsidentity: volume serial and file index, the identity os.SameFile uses - filelock: LockFileEx behind one API, replacing direct flock calls - durable: directory syncs skip Windows directories, which can't be flushed - renameNoReplace: handle-relative NtSetInformationFile, like renameat2 - fsowner: owner SID check in place of the uid check - proctree: Job Objects; jobs and runtime probes start suspended, join a job, then resume, so every descendant is contained and killed together Unix behavior is unchanged: the process-group code moves behind small processScope methods. Unix-only tests are tagged or skip on Windows, and CI gains a windows-2025 job that vets, builds and tests the packages with Windows support so far. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Qzjh5YFvH2uew2AQsqp3No --- .github/workflows/ci.yml | 35 ++ cmd/errand/fifo_unix_test.go | 13 + cmd/errand/fifo_windows_test.go | 13 + cmd/errand/push_benchmark_test.go | 2 + cmd/errand/push_watch_burst_test.go | 4 +- internal/archive/archive_test.go | 5 +- internal/archive/umask_unix_test.go | 13 + internal/archive/umask_windows_test.go | 10 + internal/changes/apply.go | 3 +- internal/changes/changes_test.go | 7 +- internal/changes/export.go | 3 +- internal/changes/fifo_unix_test.go | 13 + internal/changes/fifo_windows_test.go | 13 + internal/changes/journal.go | 9 +- internal/changes/parent_directory_unix.go | 17 + internal/changes/parent_directory_windows.go | 35 ++ internal/changes/rename_noreplace_other.go | 2 +- internal/changes/rename_noreplace_windows.go | 91 ++++++ internal/changes/staging_sync.go | 8 +- internal/changes/transfer.go | 4 +- internal/client/automatic_apply.go | 3 +- internal/client/automatic_apply_inspect.go | 9 +- .../client/automatic_apply_inspect_test.go | 8 +- internal/client/automatic_apply_unix.go | 14 + internal/client/automatic_apply_windows.go | 16 + internal/client/change_state.go | 44 +-- internal/daemon/cache_test.go | 3 +- internal/daemon/job.go | 49 ++- internal/daemon/milestone2_test.go | 2 + internal/daemon/placement.go | 16 +- internal/daemon/placement_unix.go | 32 ++ internal/daemon/placement_windows.go | 62 ++++ internal/daemon/process_group_test.go | 2 + ...process_group.go => process_group_unix.go} | 12 +- internal/daemon/process_group_windows.go | 15 + internal/daemon/process_scope.go | 274 +--------------- internal/daemon/process_scope_test.go | 2 + internal/daemon/process_scope_unix.go | 304 ++++++++++++++++++ internal/daemon/process_scope_windows.go | 147 +++++++++ internal/daemon/server.go | 8 +- internal/daemon/umask_unix_test.go | 13 + internal/daemon/umask_windows_test.go | 10 + .../workspace_concurrent_recovery_test.go | 2 + internal/daemon/workspace_leases.go | 3 +- internal/daemon/workspace_upload_test.go | 125 ------- internal/daemon/workspace_upload_unix_test.go | 145 +++++++++ internal/durable/durable.go | 7 + internal/durable/durable_test.go | 30 ++ internal/durable/durable_unix.go | 7 + internal/durable/durable_windows.go | 18 ++ internal/filelock/filelock.go | 23 ++ internal/filelock/filelock_test.go | 59 ++++ internal/filelock/filelock_unix.go | 26 ++ internal/filelock/filelock_windows.go | 30 ++ internal/fsidentity/identity.go | 16 +- internal/fsidentity/identity_test.go | 88 +++++ internal/fsidentity/identity_unix.go | 17 + internal/fsidentity/identity_windows.go | 49 +++ internal/fsowner/fsowner.go | 8 + internal/fsowner/fsowner_test.go | 21 ++ internal/fsowner/fsowner_unix.go | 21 ++ internal/fsowner/fsowner_windows.go | 25 ++ internal/namedcache/gc_pressure_test.go | 2 + internal/namedcache/holders.go | 3 +- internal/namedcache/open_flags_unix.go | 11 + internal/namedcache/open_flags_windows.go | 11 + internal/namedcache/store.go | 11 +- internal/namedcache/tree_files.go | 12 +- internal/namedcache/trees.go | 3 +- internal/proctree/doc.go | 4 + internal/proctree/job_windows.go | 152 +++++++++ internal/proctree/job_windows_test.go | 107 ++++++ internal/snapshot/fifo_unix_test.go | 15 + internal/snapshot/fifo_windows_test.go | 15 + internal/snapshot/watch_test.go | 3 +- 75 files changed, 1891 insertions(+), 523 deletions(-) create mode 100644 cmd/errand/fifo_unix_test.go create mode 100644 cmd/errand/fifo_windows_test.go create mode 100644 internal/archive/umask_unix_test.go create mode 100644 internal/archive/umask_windows_test.go create mode 100644 internal/changes/fifo_unix_test.go create mode 100644 internal/changes/fifo_windows_test.go create mode 100644 internal/changes/parent_directory_unix.go create mode 100644 internal/changes/parent_directory_windows.go create mode 100644 internal/changes/rename_noreplace_windows.go create mode 100644 internal/client/automatic_apply_unix.go create mode 100644 internal/client/automatic_apply_windows.go create mode 100644 internal/daemon/placement_unix.go create mode 100644 internal/daemon/placement_windows.go rename internal/daemon/{process_group.go => process_group_unix.go} (75%) create mode 100644 internal/daemon/process_group_windows.go create mode 100644 internal/daemon/process_scope_unix.go create mode 100644 internal/daemon/process_scope_windows.go create mode 100644 internal/daemon/umask_unix_test.go create mode 100644 internal/daemon/umask_windows_test.go create mode 100644 internal/daemon/workspace_upload_unix_test.go create mode 100644 internal/durable/durable.go create mode 100644 internal/durable/durable_test.go create mode 100644 internal/durable/durable_unix.go create mode 100644 internal/durable/durable_windows.go create mode 100644 internal/filelock/filelock.go create mode 100644 internal/filelock/filelock_test.go create mode 100644 internal/filelock/filelock_unix.go create mode 100644 internal/filelock/filelock_windows.go create mode 100644 internal/fsidentity/identity_test.go create mode 100644 internal/fsidentity/identity_unix.go create mode 100644 internal/fsidentity/identity_windows.go create mode 100644 internal/fsowner/fsowner.go create mode 100644 internal/fsowner/fsowner_test.go create mode 100644 internal/fsowner/fsowner_unix.go create mode 100644 internal/fsowner/fsowner_windows.go create mode 100644 internal/namedcache/open_flags_unix.go create mode 100644 internal/namedcache/open_flags_windows.go create mode 100644 internal/proctree/doc.go create mode 100644 internal/proctree/job_windows.go create mode 100644 internal/proctree/job_windows_test.go create mode 100644 internal/snapshot/fifo_unix_test.go create mode 100644 internal/snapshot/fifo_windows_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 15e2deed..d0435863 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/durable ./internal/filelock ./internal/fsidentity ./internal/fsowner + ./internal/logio ./internal/manifest ./internal/pathpolicy ./internal/placement + ./internal/proctree ./internal/proto + # Temporary while the Windows runner is built: shows which packages still + # fail on Windows without failing the check. + - name: Survey packages without Windows support yet + run: go test -count=1 -timeout=10m ./... 2>&1 | grep -E '^(ok|FAIL|---|panic)' || true 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/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/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/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/changes/apply.go b/internal/changes/apply.go index 3a95c61b..96460e4b 100644 --- a/internal/changes/apply.go +++ b/internal/changes/apply.go @@ -14,6 +14,7 @@ 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/proto" "github.com/lydakis/errand/internal/snapshot" @@ -666,7 +667,7 @@ 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) { 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/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/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/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/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/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/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/cache_test.go b/internal/daemon/cache_test.go index da7c4ccc..3b82b9bb 100644 --- a/internal/daemon/cache_test.go +++ b/internal/daemon/cache_test.go @@ -9,7 +9,6 @@ import ( "os/exec" "path/filepath" "strings" - "syscall" "testing" "time" @@ -49,7 +48,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") diff --git a/internal/daemon/job.go b/internal/daemon/job.go index 838746b4..8ddca586 100644 --- a/internal/daemon/job.go +++ b/internal/daemon/job.go @@ -486,7 +486,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 +528,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 +551,7 @@ func (j *Job) launch(d *Daemon) error { } j.mu.Unlock() if err != nil { + scope.close() closePipes() logw.Close() return sanitizeProcessStartError(err) @@ -553,7 +560,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 +625,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 @@ -875,6 +882,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 +919,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 +949,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/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/placement.go b/internal/daemon/placement.go index 55489a49..3fcf110f 100644 --- a/internal/daemon/placement.go +++ b/internal/daemon/placement.go @@ -7,19 +7,12 @@ 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 -} - // Relative entries depend on the future workspace, so cannot be attested. func placementTool(tool string, env []string) string { for _, dir := range filepath.SplitList(envValue(env, "PATH")) { @@ -91,15 +84,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_unix.go b/internal/daemon/placement_unix.go new file mode 100644 index 00000000..9d48f288 --- /dev/null +++ b/internal/daemon/placement_unix.go @@ -0,0 +1,32 @@ +//go:build unix + +package daemon + +import ( + "os" + "os/exec" + "syscall" + + "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 +} + +// 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..df3baab2 --- /dev/null +++ b/internal/daemon/placement_windows.go @@ -0,0 +1,62 @@ +//go:build windows + +package daemon + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/lydakis/errand/internal/proctree" +) + +// executableFile reports whether path names a regular file Windows would run: +// one whose extension is listed in PATHEXT. +func executableFile(path string) bool { + info, err := os.Stat(path) + if err != nil || !info.Mode().IsRegular() { + return false + } + ext := strings.ToLower(filepath.Ext(path)) + if ext == "" { + return false + } + pathext := os.Getenv("PATHEXT") + if pathext == "" { + pathext = ".COM;.EXE;.BAT;.CMD" + } + for _, candidate := range filepath.SplitList(pathext) { + if strings.ToLower(candidate) == ext { + return true + } + } + return false +} + +// 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..f9912e9d --- /dev/null +++ b/internal/daemon/process_scope_windows.go @@ -0,0 +1,147 @@ +//go:build windows + +package daemon + +import ( + "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 := s.job.PIDs() + if err != nil { + return nil, err + } + if len(pids) == 0 { + return nil, s.closeLocked() + } + if err := s.job.Terminate(terminatedExitCode); err != nil { + return pids, err + } + deadline := time.Now().Add(timeout) + for { + remaining, err := s.job.PIDs() + if err != nil { + return pids, err + } + if len(remaining) == 0 { + return pids, s.closeLocked() + } + 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/server.go b/internal/daemon/server.go index 2732a519..80688098 100644 --- a/internal/daemon/server.go +++ b/internal/daemon/server.go @@ -27,6 +27,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" @@ -225,7 +227,7 @@ 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 { + if err := filelock.TryLock(f); err != nil { _ = f.Close() return fmt.Errorf("state directory %q is already in use: %w", d.cfg.StateDir, err) } @@ -245,7 +247,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 +286,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/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_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/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..772f7746 --- /dev/null +++ b/internal/fsowner/fsowner_windows.go @@ -0,0 +1,25 @@ +//go:build windows + +package fsowner + +import ( + "os" + + "golang.org/x/sys/windows" +) + +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 + } + user, err := windows.GetCurrentProcessToken().GetTokenUser() + if err != nil { + return false, err + } + return owner.Equals(user.User.Sid), nil +} 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..646eded4 --- /dev/null +++ b/internal/namedcache/open_flags_unix.go @@ -0,0 +1,11 @@ +//go:build unix + +package namedcache + +import "syscall" + +const ( + openDirectory = syscall.O_DIRECTORY + openNoFollow = syscall.O_NOFOLLOW + openNonblock = syscall.O_NONBLOCK +) diff --git a/internal/namedcache/open_flags_windows.go b/internal/namedcache/open_flags_windows.go new file mode 100644 index 00000000..1cc3b572 --- /dev/null +++ b/internal/namedcache/open_flags_windows.go @@ -0,0 +1,11 @@ +//go:build windows + +package namedcache + +// Windows has no equivalent open flags. os.Root still refuses links that +// leave the store. +const ( + openDirectory = 0 + openNoFollow = 0 + openNonblock = 0 +) diff --git a/internal/namedcache/store.go b/internal/namedcache/store.go index b030d527..15ab9652 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" ) @@ -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/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..9dfa52ea --- /dev/null +++ b/internal/proctree/job_windows.go @@ -0,0 +1,152 @@ +//go:build windows + +package proctree + +import ( + "errors" + "fmt" + "os" + "os/exec" + "syscall" + "unsafe" + + "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) { + if cmd.SysProcAttr == nil { + cmd.SysProcAttr = &syscall.SysProcAttr{} + } + cmd.SysProcAttr.CreationFlags |= windows.CREATE_SUSPENDED | windows.CREATE_NO_WINDOW +} + +// 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..8690b285 --- /dev/null +++ b/internal/proctree/job_windows_test.go @@ -0,0 +1,107 @@ +//go:build windows + +package proctree + +import ( + "os" + "os/exec" + "slices" + "testing" + "time" +) + +// 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) + } + pids := waitForPIDs(t, job, 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, 0) +} + +func waitForPIDs(t *testing.T, job *Job, want int) []int { + t.Helper() + deadline := time.Now().Add(10 * time.Second) + for { + pids, err := job.PIDs() + if err != nil { + t.Fatal(err) + } + if len(pids) == want { + return pids + } + if time.Now().After(deadline) { + t.Fatalf("job has pids %v, want %d", pids, want) + } + time.Sleep(20 * time.Millisecond) + } +} 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/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") From b647eb2d1b08c24f2edd04fa245b67122748f289 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 03:42:28 +0000 Subject: [PATCH 02/27] Run Windows commands the way Windows finds them - Executable lookup applies PATHEXT, so "cargo" runs cargo.exe and "npm" runs npm.cmd, and accepts either slash in relative paths. - Jobs get the Windows environment programs rely on (profile, temp, system and program folders, PATHEXT, ComSpec), and environment names match case-insensitively. - Arguments cmd.exe would reinterpret when running a .bat or .cmd file are refused with a clear error instead of running something else. - Job cleanup gives the console host a moment to exit on its own. Tests: Windows-only tests end in OnWindows and run in CI; the gitignore oracle skips names Windows can't create; the Job Object test allows a console host in the job. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Qzjh5YFvH2uew2AQsqp3No --- .github/workflows/ci.yml | 3 + internal/daemon/job.go | 17 +++-- internal/daemon/job_exec_unix.go | 30 ++++++++ internal/daemon/job_exec_windows.go | 86 ++++++++++++++++++++++ internal/daemon/job_exec_windows_test.go | 81 ++++++++++++++++++++ internal/daemon/placement.go | 3 +- internal/daemon/placement_unix.go | 8 -- internal/daemon/placement_windows.go | 26 ------- internal/daemon/process_scope_windows.go | 24 ++++-- internal/daemon/workspace_transfer_test.go | 3 +- internal/pathpolicy/policy_test.go | 15 ++++ internal/proctree/job_windows_test.go | 11 +-- 12 files changed, 251 insertions(+), 56 deletions(-) create mode 100644 internal/daemon/job_exec_unix.go create mode 100644 internal/daemon/job_exec_windows.go create mode 100644 internal/daemon/job_exec_windows_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d0435863..53e1ff12 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -120,6 +120,9 @@ jobs: ./internal/durable ./internal/filelock ./internal/fsidentity ./internal/fsowner ./internal/logio ./internal/manifest ./internal/pathpolicy ./internal/placement ./internal/proctree ./internal/proto + # 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$' ./... # Temporary while the Windows runner is built: shows which packages still # fail on Windows without failing the check. - name: Survey packages without Windows support yet diff --git a/internal/daemon/job.go b/internal/daemon/job.go index 8ddca586..a6155836 100644 --- a/internal/daemon/job.go +++ b/internal/daemon/job.go @@ -478,6 +478,9 @@ func (j *Job) launch(d *Daemon) error { } jobEnv = append(jobEnv, scope.env()) executable, err := resolveExecutable(j.Spec.Argv[0], envValue(jobEnv, "PATH"), workdir) + if err == nil { + err = checkCommandLine(executable, j.Spec.Argv[1:]) + } if err != nil { logw.Close() return err @@ -813,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) { + check := findExecutable + if hasPathSeparator(name) { candidate := name if !filepath.IsAbs(candidate) { candidate = filepath.Join(workdir, candidate) @@ -866,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) } diff --git a/internal/daemon/job_exec_unix.go b/internal/daemon/job_exec_unix.go new file mode 100644 index 00000000..6b8e27c8 --- /dev/null +++ b/internal/daemon/job_exec_unix.go @@ -0,0 +1,30 @@ +//go:build unix + +package daemon + +import ( + "os" + "strings" + + "golang.org/x/sys/unix" +) + +// 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 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 +} + +// findExecutable reports whether path can be executed as named. +func findExecutable(path string) (string, bool) { + return path, executableFile(path) +} + +// checkCommandLine accepts every argument: exec passes argv unchanged. +func checkCommandLine(string, []string) error { return nil } diff --git a/internal/daemon/job_exec_windows.go b/internal/daemon/job_exec_windows.go new file mode 100644 index 00000000..21e96ad5 --- /dev/null +++ b/internal/daemon/job_exec_windows.go @@ -0,0 +1,86 @@ +//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 executableExtensions() []string { + pathext := os.Getenv("PATHEXT") + 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: path itself when its +// extension is in PATHEXT, otherwise the first path+extension that exists. +// So "cargo" finds cargo.exe and "npm" finds npm.cmd. +func findExecutable(path string) (string, bool) { + exts := executableExtensions() + ext := strings.ToLower(filepath.Ext(path)) + for _, candidate := range exts { + if ext == candidate { + return path, regularFile(path) + } + } + 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..bbf6c1ce --- /dev/null +++ b/internal/daemon/job_exec_windows_test.go @@ -0,0 +1,81 @@ +//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": "", + "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 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()) + 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); 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 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/placement.go b/internal/daemon/placement.go index 3fcf110f..53272bec 100644 --- a/internal/daemon/placement.go +++ b/internal/daemon/placement.go @@ -19,8 +19,7 @@ func placementTool(tool string, env []string) string { if !filepath.IsAbs(dir) { continue } - path := filepath.Join(dir, tool) - if executableFile(path) { + if path, ok := findExecutable(filepath.Join(dir, tool)); ok { return path } } diff --git a/internal/daemon/placement_unix.go b/internal/daemon/placement_unix.go index 9d48f288..18036a0c 100644 --- a/internal/daemon/placement_unix.go +++ b/internal/daemon/placement_unix.go @@ -3,18 +3,10 @@ package daemon import ( - "os" "os/exec" "syscall" - - "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 -} - // probeProcess is a runtime probe running in its own process group. type probeProcess struct{ pid int } diff --git a/internal/daemon/placement_windows.go b/internal/daemon/placement_windows.go index df3baab2..d7171138 100644 --- a/internal/daemon/placement_windows.go +++ b/internal/daemon/placement_windows.go @@ -3,37 +3,11 @@ package daemon import ( - "os" "os/exec" - "path/filepath" - "strings" "github.com/lydakis/errand/internal/proctree" ) -// executableFile reports whether path names a regular file Windows would run: -// one whose extension is listed in PATHEXT. -func executableFile(path string) bool { - info, err := os.Stat(path) - if err != nil || !info.Mode().IsRegular() { - return false - } - ext := strings.ToLower(filepath.Ext(path)) - if ext == "" { - return false - } - pathext := os.Getenv("PATHEXT") - if pathext == "" { - pathext = ".COM;.EXE;.BAT;.CMD" - } - for _, candidate := range filepath.SplitList(pathext) { - if strings.ToLower(candidate) == ext { - return true - } - } - return false -} - // probeProcess is a runtime probe running in its own Job Object. type probeProcess struct{ job *proctree.Job } diff --git a/internal/daemon/process_scope_windows.go b/internal/daemon/process_scope_windows.go index f9912e9d..b4f52b9f 100644 --- a/internal/daemon/process_scope_windows.go +++ b/internal/daemon/process_scope_windows.go @@ -105,12 +105,24 @@ func (s *processScope) cleanup(timeout time.Duration) ([]int, error) { if s.job == nil { return nil, nil } - pids, err := s.job.PIDs() - if err != nil { - return nil, err - } - if len(pids) == 0 { - return nil, s.closeLocked() + // 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. + var pids []int + settle := time.Now().Add(250 * time.Millisecond) + for { + var err error + pids, err = s.job.PIDs() + if err != nil { + return nil, err + } + if len(pids) == 0 { + return nil, s.closeLocked() + } + if time.Now().After(settle) { + break + } + time.Sleep(20 * time.Millisecond) } if err := s.job.Terminate(terminatedExitCode); err != nil { return pids, err 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/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/job_windows_test.go b/internal/proctree/job_windows_test.go index 8690b285..a0d069f7 100644 --- a/internal/proctree/job_windows_test.go +++ b/internal/proctree/job_windows_test.go @@ -74,7 +74,8 @@ func TestTerminateKillsDescendants(t *testing.T) { if _, err := stdout.Read(line); err != nil { t.Fatal(err) } - pids := waitForPIDs(t, job, 2) + // 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) } @@ -85,10 +86,10 @@ func TestTerminateKillsDescendants(t *testing.T) { if code := cmd.ProcessState.ExitCode(); code != 9 { t.Fatalf("exit code %d, want 9", code) } - waitForPIDs(t, job, 0) + waitForPIDs(t, job, func(n int) bool { return n == 0 }) } -func waitForPIDs(t *testing.T, job *Job, want int) []int { +func waitForPIDs(t *testing.T, job *Job, done func(int) bool) []int { t.Helper() deadline := time.Now().Add(10 * time.Second) for { @@ -96,11 +97,11 @@ func waitForPIDs(t *testing.T, job *Job, want int) []int { if err != nil { t.Fatal(err) } - if len(pids) == want { + if done(len(pids)) { return pids } if time.Now().After(deadline) { - t.Fatalf("job has pids %v, want %d", pids, want) + t.Fatalf("job still has pids %v", pids) } time.Sleep(20 * time.Millisecond) } From 3d9a55e3982f95bc7c5264e7a9223a44ee1b827d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 03:45:59 +0000 Subject: [PATCH 03/27] Authenticate local socket peers on Windows by user SID Windows AF_UNIX sockets report the peer's PID (SIO_AF_UNIX_GETPEERPID). The peer's token user SID is compared with this process's, so the daemon and its client still only trust the same account. Windows has no numeric uids, so the UID field only records whether the SIDs matched, and the account name comes from the SID. Also keeps executableFile where it was in placement.go, now Unix-only, with the shared placement code in placement_facts.go. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Qzjh5YFvH2uew2AQsqp3No --- .github/workflows/ci.yml | 2 +- internal/daemon/job_exec_unix.go | 13 --- internal/daemon/peercred.go | 8 +- internal/daemon/placement.go | 110 +++++------------------ internal/daemon/placement_facts.go | 101 +++++++++++++++++++++ internal/daemon/placement_unix.go | 24 ----- internal/unixpeer/credentials_other.go | 2 +- internal/unixpeer/credentials_windows.go | 97 ++++++++++++++++++++ internal/unixpeer/dial.go | 6 +- internal/unixpeer/dial_windows_test.go | 60 +++++++++++++ internal/unixpeer/pid_other.go | 2 +- 11 files changed, 294 insertions(+), 131 deletions(-) create mode 100644 internal/daemon/placement_facts.go delete mode 100644 internal/daemon/placement_unix.go create mode 100644 internal/unixpeer/credentials_windows.go create mode 100644 internal/unixpeer/dial_windows_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 53e1ff12..7bc530b0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -119,7 +119,7 @@ jobs: go test -count=1 -timeout=5m ./internal/durable ./internal/filelock ./internal/fsidentity ./internal/fsowner ./internal/logio ./internal/manifest ./internal/pathpolicy ./internal/placement - ./internal/proctree ./internal/proto + ./internal/proctree ./internal/proto ./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/internal/daemon/job_exec_unix.go b/internal/daemon/job_exec_unix.go index 6b8e27c8..0026744d 100644 --- a/internal/daemon/job_exec_unix.go +++ b/internal/daemon/job_exec_unix.go @@ -3,10 +3,7 @@ package daemon import ( - "os" "strings" - - "golang.org/x/sys/unix" ) // baseEnvNames are taken from the daemon's environment into every job. @@ -16,15 +13,5 @@ func envNameEqual(a, b string) bool { return a == b } func hasPathSeparator(name string) bool { return strings.ContainsRune(name, '/') } -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 -} - -// findExecutable reports whether path can be executed as named. -func findExecutable(path string) (string, bool) { - return path, executableFile(path) -} - // checkCommandLine accepts every argument: exec passes argv unchanged. func checkCommandLine(string, []string) error { return nil } 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 53272bec..2ee75f52 100644 --- a/internal/daemon/placement.go +++ b/internal/daemon/placement.go @@ -1,101 +1,37 @@ +//go:build unix + package daemon import ( - "context" - "fmt" "os" "os/exec" - "path/filepath" - "sync" - "time" + "syscall" - "github.com/lydakis/errand/internal/placement" - "github.com/lydakis/errand/internal/proto" + "golang.org/x/sys/unix" ) -// Relative entries depend on the future workspace, so cannot be attested. -func placementTool(tool string, env []string) string { - for _, dir := range filepath.SplitList(envValue(env, "PATH")) { - if !filepath.IsAbs(dir) { - continue - } - if path, ok := findExecutable(filepath.Join(dir, tool)); ok { - return path - } - } - return "" +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 } -// Tool lookup observes the job PATH without executing it. Runtime liveness uses -// only the daemon's base environment and executable. Submitted environment must -// never control a pre-admission subprocess, even when its executable is trusted. -func (d *Daemon) measurePlacementFacts(ctx context.Context, q placement.Requirements, env []string) proto.Facts { - f := measureFacts() - f.ToolErrors = make(map[string]string) - baseEnv := (&Job{}).buildEnv() - ctx, cancel := context.WithTimeout(ctx, time.Second) - defer cancel() - var wg sync.WaitGroup - var mu sync.Mutex - for _, tool := range q.Tools { - delete(f.Tools, tool) - } - for _, tool := range q.Tools { - wg.Add(1) - go func() { - defer wg.Done() - path := placementTool(tool, env) - var reason string - if path == "" { - reason = "not executable in absolute PATH entries" - } else if tool == "docker" || tool == "podman" { - trusted := placementTool(tool, baseEnv) - jobFile, jobErr := os.Stat(path) - baseFile, baseErr := os.Stat(trusted) - if jobErr != nil || baseErr != nil || !os.SameFile(jobFile, baseFile) { - reason = "job PATH does not resolve to the daemon's runtime" - } else if err := d.probeRuntime(ctx, trusted, baseEnv); err != nil { - reason = err.Error() - } - } - mu.Lock() - defer mu.Unlock() - if reason != "" { - f.ToolErrors[tool] = reason - } else { - f.Tools[tool] = path - } - }() - } - wg.Wait() - return f +// findExecutable reports whether path can be executed as named. +func findExecutable(path string) (string, bool) { + return path, executableFile(path) } -// Bound subprocesses across info, submission and workspace-creation requests. -// Probes run concurrently so a slow Docker does not starve Podman. -func (d *Daemon) probeRuntime(ctx context.Context, path string, env []string) error { - select { - case d.placementSlots <- struct{}{}: - defer func() { <-d.placementSlots }() - case <-ctx.Done(): - return fmt.Errorf("runtime probe deadline exceeded waiting for capacity") - } - cmd := exec.CommandContext(ctx, path, "info") - cmd.Env = env - cmd.Dir = "/" - cmd.WaitDelay = 100 * time.Millisecond - probe, err := startProbe(cmd) - if err != nil { - return fmt.Errorf("runtime probe could not start") - } - err = cmd.Wait() - // Remove any children retained by a runtime connection helper. - probe.kill() - if ctx.Err() != nil { - return fmt.Errorf("runtime probe deadline exceeded") - } - if err != nil { - return fmt.Errorf("runtime info failed") +// 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 nil + return &probeProcess{pid: cmd.Process.Pid}, nil +} + +func (p *probeProcess) kill() { + _ = syscall.Kill(-p.pid, syscall.SIGKILL) } diff --git a/internal/daemon/placement_facts.go b/internal/daemon/placement_facts.go new file mode 100644 index 00000000..53272bec --- /dev/null +++ b/internal/daemon/placement_facts.go @@ -0,0 +1,101 @@ +package daemon + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "sync" + "time" + + "github.com/lydakis/errand/internal/placement" + "github.com/lydakis/errand/internal/proto" +) + +// Relative entries depend on the future workspace, so cannot be attested. +func placementTool(tool string, env []string) string { + for _, dir := range filepath.SplitList(envValue(env, "PATH")) { + if !filepath.IsAbs(dir) { + continue + } + if path, ok := findExecutable(filepath.Join(dir, tool)); ok { + return path + } + } + return "" +} + +// Tool lookup observes the job PATH without executing it. Runtime liveness uses +// only the daemon's base environment and executable. Submitted environment must +// never control a pre-admission subprocess, even when its executable is trusted. +func (d *Daemon) measurePlacementFacts(ctx context.Context, q placement.Requirements, env []string) proto.Facts { + f := measureFacts() + f.ToolErrors = make(map[string]string) + baseEnv := (&Job{}).buildEnv() + ctx, cancel := context.WithTimeout(ctx, time.Second) + defer cancel() + var wg sync.WaitGroup + var mu sync.Mutex + for _, tool := range q.Tools { + delete(f.Tools, tool) + } + for _, tool := range q.Tools { + wg.Add(1) + go func() { + defer wg.Done() + path := placementTool(tool, env) + var reason string + if path == "" { + reason = "not executable in absolute PATH entries" + } else if tool == "docker" || tool == "podman" { + trusted := placementTool(tool, baseEnv) + jobFile, jobErr := os.Stat(path) + baseFile, baseErr := os.Stat(trusted) + if jobErr != nil || baseErr != nil || !os.SameFile(jobFile, baseFile) { + reason = "job PATH does not resolve to the daemon's runtime" + } else if err := d.probeRuntime(ctx, trusted, baseEnv); err != nil { + reason = err.Error() + } + } + mu.Lock() + defer mu.Unlock() + if reason != "" { + f.ToolErrors[tool] = reason + } else { + f.Tools[tool] = path + } + }() + } + wg.Wait() + return f +} + +// Bound subprocesses across info, submission and workspace-creation requests. +// Probes run concurrently so a slow Docker does not starve Podman. +func (d *Daemon) probeRuntime(ctx context.Context, path string, env []string) error { + select { + case d.placementSlots <- struct{}{}: + defer func() { <-d.placementSlots }() + case <-ctx.Done(): + return fmt.Errorf("runtime probe deadline exceeded waiting for capacity") + } + cmd := exec.CommandContext(ctx, path, "info") + cmd.Env = env + cmd.Dir = "/" + cmd.WaitDelay = 100 * time.Millisecond + probe, err := startProbe(cmd) + if err != nil { + return fmt.Errorf("runtime probe could not start") + } + err = cmd.Wait() + // Remove any children retained by a runtime connection helper. + probe.kill() + if ctx.Err() != nil { + return fmt.Errorf("runtime probe deadline exceeded") + } + if err != nil { + return fmt.Errorf("runtime info failed") + } + return nil +} diff --git a/internal/daemon/placement_unix.go b/internal/daemon/placement_unix.go deleted file mode 100644 index 18036a0c..00000000 --- a/internal/daemon/placement_unix.go +++ /dev/null @@ -1,24 +0,0 @@ -//go:build unix - -package daemon - -import ( - "os/exec" - "syscall" -) - -// 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/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..f88bd6a2 --- /dev/null +++ b/internal/unixpeer/dial_windows_test.go @@ -0,0 +1,60 @@ +//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") + } +} 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 From 435951b0d50c32aacaf4fed87554f7523ff7f8aa Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 03:50:52 +0000 Subject: [PATCH 04/27] Prepare the Windows service runtime and detect stale sockets portably serviceruntime keeps a retained errand.exe copy on Windows and checks ownership instead of POSIX modes. unixpeer.ConnectionRefused recognizes a stale AF_UNIX socket on Windows (WSAECONNREFUSED) as well as Unix. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Qzjh5YFvH2uew2AQsqp3No --- cmd/errand/main.go | 4 +- internal/serviceruntime/runtime.go | 36 +--------------- internal/serviceruntime/runtime_unix.go | 44 +++++++++++++++++++ internal/serviceruntime/runtime_windows.go | 50 ++++++++++++++++++++++ internal/setup/setup.go | 6 +-- internal/unixpeer/dial_windows_test.go | 22 ++++++++++ internal/unixpeer/refused.go | 5 +++ internal/unixpeer/refused_unix.go | 10 +++++ internal/unixpeer/refused_windows.go | 15 +++++++ 9 files changed, 153 insertions(+), 39 deletions(-) create mode 100644 internal/serviceruntime/runtime_unix.go create mode 100644 internal/serviceruntime/runtime_windows.go create mode 100644 internal/unixpeer/refused.go create mode 100644 internal/unixpeer/refused_unix.go create mode 100644 internal/unixpeer/refused_windows.go diff --git a/cmd/errand/main.go b/cmd/errand/main.go index e321de6f..da2c02f2 100644 --- a/cmd/errand/main.go +++ b/cmd/errand/main.go @@ -19,7 +19,6 @@ import ( "strconv" "strings" "sync" - "syscall" "time" "github.com/lydakis/errand/internal/client" @@ -29,6 +28,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" ) @@ -791,7 +791,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/internal/serviceruntime/runtime.go b/internal/serviceruntime/runtime.go index f934a824..836d8f84 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. @@ -74,7 +56,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 +103,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_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/setup.go b/internal/setup/setup.go index 4eadc50e..43003f26 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 ( @@ -188,7 +188,7 @@ func Run(ctx context.Context, opts Options, sys System) (*Report, error) { 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 } @@ -412,7 +412,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 diff --git a/internal/unixpeer/dial_windows_test.go b/internal/unixpeer/dial_windows_test.go index f88bd6a2..47d76802 100644 --- a/internal/unixpeer/dial_windows_test.go +++ b/internal/unixpeer/dial_windows_test.go @@ -58,3 +58,25 @@ func TestCredentialsIdentifySameUserOnWindows(t *testing.T) { 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) + } +} 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..f02b0d84 --- /dev/null +++ b/internal/unixpeer/refused_windows.go @@ -0,0 +1,15 @@ +//go:build windows + +package unixpeer + +import ( + "errors" + "syscall" + + "golang.org/x/sys/windows" +) + +// Winsock reports WSAECONNREFUSED, not the syscall package's ECONNREFUSED. +func connectionRefused(err error) bool { + return errors.Is(err, windows.WSAECONNREFUSED) || errors.Is(err, syscall.ECONNREFUSED) +} From 368c935c2cc48be70412c7e635bc02e7b9880306 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 03:57:34 +0000 Subject: [PATCH 05/27] Install the Windows runner as a Task Scheduler logon task errand setup on Windows registers a task that starts at sign-in and runs the retained runtime copy with serve --log-file, so replacing errand.exe never fights a running daemon. Setup stops a running task before re-registering it, verifies an answering runner is the task's process, and refuses to replace a task definition it did not write without --force. New Windows runners default to the tailnet transport because SSH callers are not supported there yet. Tailscale discovery also looks in Program Files, and doctor knows the task definition's path. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Qzjh5YFvH2uew2AQsqp3No --- cmd/errand/console_other.go | 5 + cmd/errand/console_windows.go | 26 +++ cmd/errand/main.go | 10 + internal/serviceruntime/runtime.go | 25 ++- internal/setup/diagnose.go | 7 +- internal/setup/process_unix.go | 16 ++ internal/setup/process_windows.go | 29 +++ internal/setup/process_windows_test.go | 26 +++ internal/setup/scheduled_task.go | 283 +++++++++++++++++++++++++ internal/setup/scheduled_task_test.go | 174 +++++++++++++++ internal/setup/service_identity.go | 23 ++ internal/setup/setup.go | 26 ++- internal/setup/setup_test.go | 37 +++- internal/setup/system.go | 16 ++ internal/tailnet/tailnet.go | 20 ++ 15 files changed, 717 insertions(+), 6 deletions(-) create mode 100644 cmd/errand/console_other.go create mode 100644 cmd/errand/console_windows.go create mode 100644 internal/setup/process_unix.go create mode 100644 internal/setup/process_windows.go create mode 100644 internal/setup/process_windows_test.go create mode 100644 internal/setup/scheduled_task.go create mode 100644 internal/setup/scheduled_task_test.go diff --git a/cmd/errand/console_other.go b/cmd/errand/console_other.go new file mode 100644 index 00000000..e10881bb --- /dev/null +++ b/cmd/errand/console_other.go @@ -0,0 +1,5 @@ +//go:build !windows + +package main + +func detachServiceConsole() {} diff --git a/cmd/errand/console_windows.go b/cmd/errand/console_windows.go new file mode 100644 index 00000000..8c248594 --- /dev/null +++ b/cmd/errand/console_windows.go @@ -0,0 +1,26 @@ +//go:build windows + +package main + +import ( + "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. +func detachServiceConsole() { + var pids [2]uint32 + n, _, _ := procGetConsoleProcessList.Call(uintptr(unsafe.Pointer(&pids[0])), uintptr(len(pids))) + if n == 1 { + procFreeConsole.Call() + } +} diff --git a/cmd/errand/main.go b/cmd/errand/main.go index da2c02f2..920cc21a 100644 --- a/cmd/errand/main.go +++ b/cmd/errand/main.go @@ -632,10 +632,20 @@ 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) + } + os.Stdout, os.Stderr = f, f + log.SetOutput(f) + detachServiceConsole() + } fileCfg, err := config.LoadDaemon(*cfgPath) if err != nil { diff --git a/internal/serviceruntime/runtime.go b/internal/serviceruntime/runtime.go index 836d8f84..dc99d7a8 100644 --- a/internal/serviceruntime/runtime.go +++ b/internal/serviceruntime/runtime.go @@ -43,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 } diff --git a/internal/setup/diagnose.go b/internal/setup/diagnose.go index 8e0aa051..b1b92198 100644 --- a/internal/setup/diagnose.go +++ b/internal/setup/diagnose.go @@ -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/process_unix.go b/internal/setup/process_unix.go new file mode 100644 index 00000000..fb4a537d --- /dev/null +++ b/internal/setup/process_unix.go @@ -0,0 +1,16 @@ +//go:build !windows + +package setup + +import ( + "errors" + "runtime" +) + +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..b644a399 --- /dev/null +++ b/internal/setup/process_windows.go @@ -0,0 +1,29 @@ +//go:build windows + +package setup + +import ( + "golang.org/x/sys/windows" +) + +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..8dcb95ed --- /dev/null +++ b/internal/setup/scheduled_task.go @@ -0,0 +1,283 @@ +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 = `$t = Get-ScheduledTask -TaskPath '\' -TaskName '` + scheduledTaskName + `' -ErrorAction SilentlyContinue; if ($t) { "$($t.State)"; $t.Actions[0].Execute } else { 'Missing' }` + +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 +} + +func queryScheduledTask(ctx context.Context, sys serviceSystem) (scheduledTaskState, error) { + out, err := sys.Run(ctx, "powershell.exe", "-NoProfile", "-NonInteractive", "-Command", scheduledTaskQuery) + if err != nil { + return scheduledTaskState{}, err + } + lines := strings.Split(strings.ReplaceAll(strings.TrimSpace(out), "\r\n", "\n"), "\n") + state := scheduledTaskState{State: strings.TrimSpace(lines[0])} + if len(lines) > 1 { + state.Command = strings.TrimSpace(lines[1]) + } + 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) + 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 + } + current, err := queryScheduledTask(ctx, sys) + if err != nil { + r.fail("service", fmt.Errorf("cannot query Task Scheduler: %w", err)) + 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 + } + 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 existing == renderScheduledTask(user, command, configPath, logPath) +} + +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 { + 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 encodeUTF16(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_test.go b/internal/setup/scheduled_task_test.go new file mode 100644 index 00000000..9bed832b --- /dev/null +++ b/internal/setup/scheduled_task_test.go @@ -0,0 +1,174 @@ +package setup + +import ( + "context" + "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 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/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/setup.go b/internal/setup/setup.go index 43003f26..13896ee4 100644 --- a/internal/setup/setup.go +++ b/internal/setup/setup.go @@ -160,6 +160,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,7 +189,7 @@ 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() @@ -259,6 +264,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 +319,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) @@ -455,6 +473,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,7 +531,7 @@ 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") { + if err == nil && managedServiceOS(sys.GOOS()) { var pid int pid, err = sys.SocketPID(probeCtx, r.SocketPath) if err == nil && pid == previousPID { diff --git a/internal/setup/setup_test.go b/internal/setup/setup_test.go index 7e585433..798ece54 100644 --- a/internal/setup/setup_test.go +++ b/internal/setup/setup_test.go @@ -43,6 +43,9 @@ type fakeSystem struct { discoverErr error discoverSocket string discoverCLI string + taskState string + taskCommand string + processImage string } func newFake(t *testing.T, goos string) *fakeSystem { @@ -117,6 +120,29 @@ 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 + } + return f.taskState + "\r\n" + f.taskCommand, nil + } + switch { + case strings.HasPrefix(line, "schtasks /Create "): + command, err := taskCommand(f.files[args[4]]) + if err != nil { + return "", err + } + f.taskCommand = command + 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 +171,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 +210,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/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/tailnet/tailnet.go b/internal/tailnet/tailnet.go index 83fb6bfb..069cde54 100644 --- a/internal/tailnet/tailnet.go +++ b/internal/tailnet/tailnet.go @@ -12,6 +12,7 @@ import ( "net/url" "os" "os/exec" + "path/filepath" "runtime" "sort" "strconv" @@ -152,6 +153,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 +195,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, ", ")) } From 377093c4209eb6153d5f4587012ab7f20c748bd0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 04:06:36 +0000 Subject: [PATCH 06/27] Keep submitted file modes on Windows runners and ship Windows zips - fsmode: NTFS has no POSIX modes, so Windows reads files as 0644 (0444 when read-only) and directories as 0755, and mode checks compare only the write bit. Collected results inherit each path's submitted mode, so exec bits from a Mac survive a job and only real edits come back. The errand_logicalmodes build tag exercises this path on Unix. - archive: on Windows, reject paths and symlink targets Windows would read differently (backslashes, colons, device names, trailing dots or spaces, GIT~N). - Named caches are off on Windows runners for now. - Release: GoReleaser builds windows amd64/arm64 zips, the release workflow uploads and checks them, RELEASING.md describes them. - docs/WINDOWS.md covers install, setup and how Windows jobs differ. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Qzjh5YFvH2uew2AQsqp3No --- .github/workflows/release.yml | 5 +- .goreleaser.yaml | 5 +- README.md | 3 +- docs/OPERATIONS.md | 4 +- docs/RELEASING.md | 10 +-- docs/WINDOWS.md | 88 ++++++++++++++++++++++++++ internal/archive/archive.go | 4 +- internal/archive/windows_names.go | 59 +++++++++++++++++ internal/archive/windows_names_test.go | 23 +++++++ internal/changes/access.go | 19 +++--- internal/changes/apply.go | 3 +- internal/changes/apply_group.go | 3 +- internal/changes/changes.go | 4 +- internal/changes/logical_modes.go | 30 +++++++++ internal/changes/logical_modes_test.go | 72 +++++++++++++++++++++ internal/changes/transfer_stage.go | 7 +- internal/daemon/server.go | 6 ++ internal/fsmode/fsmode.go | 38 +++++++++++ internal/fsmode/fsmode_logical.go | 23 +++++++ internal/fsmode/fsmode_unix.go | 11 ++++ internal/snapshot/snapshot.go | 13 ++-- 21 files changed, 399 insertions(+), 31 deletions(-) create mode 100644 docs/WINDOWS.md create mode 100644 internal/archive/windows_names.go create mode 100644 internal/archive/windows_names_test.go create mode 100644 internal/changes/logical_modes.go create mode 100644 internal/changes/logical_modes_test.go create mode 100644 internal/fsmode/fsmode.go create mode 100644 internal/fsmode/fsmode_logical.go create mode 100644 internal/fsmode/fsmode_unix.go 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/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..a6412e89 100644 --- a/internal/archive/archive.go +++ b/internal/archive/archive.go @@ -165,11 +165,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)) diff --git a/internal/archive/windows_names.go b/internal/archive/windows_names.go new file mode 100644 index 00000000..8a0ee14b --- /dev/null +++ b/internal/archive/windows_names.go @@ -0,0 +1,59 @@ +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.Split(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" + } + base, _, _ := strings.Cut(name, ".") + base = strings.ToUpper(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")) && base[3] >= '0' && base[3] <= '9' { + return base + " is a device name" + } + if upper := strings.ToUpper(name); 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..833d165d --- /dev/null +++ b/internal/archive/windows_names_test.go @@ -0,0 +1,23 @@ +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>", + } { + 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", + } { + if err := checkWindowsPath(good); err != nil { + t.Errorf("checkWindowsPath(%q) = %v", good, err) + } + } +} diff --git a/internal/changes/access.go b/internal/changes/access.go index 84d5994a..79765c0d 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,7 +429,7 @@ 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 @@ -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()) diff --git a/internal/changes/apply.go b/internal/changes/apply.go index 96460e4b..726bfedf 100644 --- a/internal/changes/apply.go +++ b/internal/changes/apply.go @@ -16,6 +16,7 @@ import ( "github.com/lydakis/errand/internal/archive" "github.com/lydakis/errand/internal/durable" "github.com/lydakis/errand/internal/fsidentity" + "github.com/lydakis/errand/internal/fsmode" "github.com/lydakis/errand/internal/proto" "github.com/lydakis/errand/internal/snapshot" ) @@ -627,7 +628,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 } 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/changes.go b/internal/changes/changes.go index 3e0b2ffe..1053e298 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 @@ -397,6 +398,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/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..eccf7522 --- /dev/null +++ b/internal/changes/logical_modes_test.go @@ -0,0 +1,72 @@ +package changes + +import ( + "context" + "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) + } +} diff --git a/internal/changes/transfer_stage.go b/internal/changes/transfer_stage.go index cb0568a5..f351b02b 100644 --- a/internal/changes/transfer_stage.go +++ b/internal/changes/transfer_stage.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" + "github.com/lydakis/errand/internal/fsmode" "github.com/lydakis/errand/internal/proto" ) @@ -117,7 +118,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 +133,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()) @@ -155,7 +156,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 { diff --git a/internal/daemon/server.go b/internal/daemon/server.go index 80688098..9632464f 100644 --- a/internal/daemon/server.go +++ b/internal/daemon/server.go @@ -18,6 +18,7 @@ import ( "os" "path" "path/filepath" + "runtime" "sort" "strconv" "strings" @@ -136,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 } 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/snapshot/snapshot.go b/internal/snapshot/snapshot.go index ed04f9cb..bac208f8 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 @@ -851,7 +852,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,7 +865,7 @@ 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) @@ -884,7 +885,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 +896,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 +931,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) } } From 618b73adc549ccfd327a7d34fe4e5e49820bd6d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 04:13:34 +0000 Subject: [PATCH 07/27] Let the Windows runner open its cache store; test a job end to end The named cache store refused its root because Windows directories report mode 0777, so the daemon could not start on Windows. Private now means owned by the current user there, as it does for the runtime directory. A Windows-only daemon test runs a cmd.exe job from a client snapshot and checks its exit code and retained changes. The temporary CI survey now prints failure messages. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Qzjh5YFvH2uew2AQsqp3No --- .github/workflows/ci.yml | 5 ++++- internal/daemon/job_windows_test.go | 24 +++++++++++++++++++++ internal/namedcache/open_flags_unix.go | 13 +++++++++++- internal/namedcache/open_flags_windows.go | 26 +++++++++++++++++++++++ internal/namedcache/store.go | 4 ++-- 5 files changed, 68 insertions(+), 4 deletions(-) create mode 100644 internal/daemon/job_windows_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7bc530b0..81738384 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -126,4 +126,7 @@ jobs: # Temporary while the Windows runner is built: shows which packages still # fail on Windows without failing the check. - name: Survey packages without Windows support yet - run: go test -count=1 -timeout=10m ./... 2>&1 | grep -E '^(ok|FAIL|---|panic)' || true + run: >- + go test -count=1 -timeout=4m ./... 2>&1 + | awk '/^panic/ { stack = 30 } stack > 0 { stack--; print; next } + /^(ok|FAIL|--- FAIL)|^ +[A-Za-z0-9_]+\.go:[0-9]+: / { print substr($0, 1, 300) }' || true diff --git a/internal/daemon/job_windows_test.go b/internal/daemon/job_windows_test.go new file mode 100644 index 00000000..97ca0086 --- /dev/null +++ b/internal/daemon/job_windows_test.go @@ -0,0 +1,24 @@ +//go:build windows + +package daemon + +import ( + "fmt" + "testing" +) + +// 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"}) + _, status := submitChangeJob(t, d, ts.URL, root, + []string{"cmd", "/d", "/c", "type input.txt && 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) + } +} diff --git a/internal/namedcache/open_flags_unix.go b/internal/namedcache/open_flags_unix.go index 646eded4..d49c5daa 100644 --- a/internal/namedcache/open_flags_unix.go +++ b/internal/namedcache/open_flags_unix.go @@ -2,10 +2,21 @@ package namedcache -import "syscall" +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 index 1cc3b572..57b4c672 100644 --- a/internal/namedcache/open_flags_windows.go +++ b/internal/namedcache/open_flags_windows.go @@ -2,6 +2,13 @@ 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 ( @@ -9,3 +16,22 @@ const ( 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 15ab9652..c1d542e3 100644 --- a/internal/namedcache/store.go +++ b/internal/namedcache/store.go @@ -118,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 { From 8910d14968712273af46db2f912e4f90431ad6ac Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 04:14:31 +0000 Subject: [PATCH 08/27] Keep Unix-only setup and runtime tests off Windows The setup tests drive a fake system with Unix paths, including the Windows task decisions, so they run on Unix. Two runtime tests rely on POSIX modes or exec and skip on Windows. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Qzjh5YFvH2uew2AQsqp3No --- internal/serviceruntime/runtime_test.go | 7 +++++++ internal/setup/config_path_test.go | 3 +++ internal/setup/config_plan_test.go | 3 +++ internal/setup/diagnose_test.go | 3 +++ internal/setup/executable_test.go | 3 +++ internal/setup/local_test.go | 3 +++ internal/setup/scheduled_task_test.go | 3 +++ internal/setup/service_identity_test.go | 3 +++ internal/setup/setup_test.go | 3 +++ internal/setup/ssh_test.go | 3 +++ internal/setup/transport_test.go | 3 +++ internal/setup/upgrade_test.go | 3 +++ 12 files changed, 40 insertions(+) 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/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_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/scheduled_task_test.go b/internal/setup/scheduled_task_test.go index 9bed832b..d8eee0bd 100644 --- a/internal/setup/scheduled_task_test.go +++ b/internal/setup/scheduled_task_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/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_test.go b/internal/setup/setup_test.go index 798ece54..301e16f4 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 ( 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/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 ( From 37cadcbc11c81a7249ec6ebdd2e1097e9c3095f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 04:17:16 +0000 Subject: [PATCH 09/27] Fix Windows job admission and keep placement code where it was - Change-base capture closes its handle on the staging tree before renaming it; Windows refuses to rename a directory with an open handle. - Staged directories are flushed through durable.Sync, which skips the directory flush Windows can't do. - placement.go is shared again and matches main except for the per-OS calls, so code scanning doesn't report its existing lines as new. - serviceruntime and setup tests run in the Windows job. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Qzjh5YFvH2uew2AQsqp3No --- .github/workflows/ci.yml | 3 +- internal/changes/base.go | 4 + internal/changes/staging_sync_other.go | 9 ++- internal/daemon/placement.go | 108 ++++++++++++++++++++----- internal/daemon/placement_facts.go | 101 ----------------------- internal/daemon/placement_unix.go | 35 ++++++++ internal/daemon/placement_windows.go | 4 + 7 files changed, 141 insertions(+), 123 deletions(-) delete mode 100644 internal/daemon/placement_facts.go create mode 100644 internal/daemon/placement_unix.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 81738384..510f8ea6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -119,7 +119,8 @@ jobs: go test -count=1 -timeout=5m ./internal/durable ./internal/filelock ./internal/fsidentity ./internal/fsowner ./internal/logio ./internal/manifest ./internal/pathpolicy ./internal/placement - ./internal/proctree ./internal/proto ./internal/unixpeer + ./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/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/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/daemon/placement.go b/internal/daemon/placement.go index 2ee75f52..034457d8 100644 --- a/internal/daemon/placement.go +++ b/internal/daemon/placement.go @@ -1,37 +1,107 @@ -//go:build unix - package daemon import ( + "context" + "fmt" "os" "os/exec" - "syscall" + "path/filepath" + "sync" + "time" - "golang.org/x/sys/unix" + "github.com/lydakis/errand/internal/placement" + "github.com/lydakis/errand/internal/proto" ) 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) } -// findExecutable reports whether path can be executed as named. -func findExecutable(path string) (string, bool) { - return path, executableFile(path) +// Relative entries depend on the future workspace, so cannot be attested. +func placementTool(tool string, env []string) string { + for _, dir := range filepath.SplitList(envValue(env, "PATH")) { + if !filepath.IsAbs(dir) { + continue + } + path := filepath.Join(dir, tool) + if path, ok := findExecutable(path); ok { + return path + } + } + return "" } -// 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 +// Tool lookup observes the job PATH without executing it. Runtime liveness uses +// only the daemon's base environment and executable. Submitted environment must +// never control a pre-admission subprocess, even when its executable is trusted. +func (d *Daemon) measurePlacementFacts(ctx context.Context, q placement.Requirements, env []string) proto.Facts { + f := measureFacts() + f.ToolErrors = make(map[string]string) + baseEnv := (&Job{}).buildEnv() + ctx, cancel := context.WithTimeout(ctx, time.Second) + defer cancel() + var wg sync.WaitGroup + var mu sync.Mutex + for _, tool := range q.Tools { + delete(f.Tools, tool) + } + for _, tool := range q.Tools { + wg.Add(1) + go func() { + defer wg.Done() + path := placementTool(tool, env) + var reason string + if path == "" { + reason = "not executable in absolute PATH entries" + } else if tool == "docker" || tool == "podman" { + trusted := placementTool(tool, baseEnv) + jobFile, jobErr := os.Stat(path) + baseFile, baseErr := os.Stat(trusted) + if jobErr != nil || baseErr != nil || !os.SameFile(jobFile, baseFile) { + reason = "job PATH does not resolve to the daemon's runtime" + } else if err := d.probeRuntime(ctx, trusted, baseEnv); err != nil { + reason = err.Error() + } + } + mu.Lock() + defer mu.Unlock() + if reason != "" { + f.ToolErrors[tool] = reason + } else { + f.Tools[tool] = path + } + }() } - return &probeProcess{pid: cmd.Process.Pid}, nil + wg.Wait() + return f } -func (p *probeProcess) kill() { - _ = syscall.Kill(-p.pid, syscall.SIGKILL) +// Bound subprocesses across info, submission and workspace-creation requests. +// Probes run concurrently so a slow Docker does not starve Podman. +func (d *Daemon) probeRuntime(ctx context.Context, path string, env []string) error { + select { + case d.placementSlots <- struct{}{}: + defer func() { <-d.placementSlots }() + case <-ctx.Done(): + return fmt.Errorf("runtime probe deadline exceeded waiting for capacity") + } + cmd := exec.CommandContext(ctx, path, "info") + cmd.Env = env + cmd.Dir = "/" + cmd.WaitDelay = 100 * time.Millisecond + probe, err := startProbe(cmd) + if err != nil { + return fmt.Errorf("runtime probe could not start") + } + err = cmd.Wait() + // Remove any children retained by a runtime connection helper. + probe.kill() + if ctx.Err() != nil { + return fmt.Errorf("runtime probe deadline exceeded") + } + if err != nil { + return fmt.Errorf("runtime info failed") + } + return nil } diff --git a/internal/daemon/placement_facts.go b/internal/daemon/placement_facts.go deleted file mode 100644 index 53272bec..00000000 --- a/internal/daemon/placement_facts.go +++ /dev/null @@ -1,101 +0,0 @@ -package daemon - -import ( - "context" - "fmt" - "os" - "os/exec" - "path/filepath" - "sync" - "time" - - "github.com/lydakis/errand/internal/placement" - "github.com/lydakis/errand/internal/proto" -) - -// Relative entries depend on the future workspace, so cannot be attested. -func placementTool(tool string, env []string) string { - for _, dir := range filepath.SplitList(envValue(env, "PATH")) { - if !filepath.IsAbs(dir) { - continue - } - if path, ok := findExecutable(filepath.Join(dir, tool)); ok { - return path - } - } - return "" -} - -// Tool lookup observes the job PATH without executing it. Runtime liveness uses -// only the daemon's base environment and executable. Submitted environment must -// never control a pre-admission subprocess, even when its executable is trusted. -func (d *Daemon) measurePlacementFacts(ctx context.Context, q placement.Requirements, env []string) proto.Facts { - f := measureFacts() - f.ToolErrors = make(map[string]string) - baseEnv := (&Job{}).buildEnv() - ctx, cancel := context.WithTimeout(ctx, time.Second) - defer cancel() - var wg sync.WaitGroup - var mu sync.Mutex - for _, tool := range q.Tools { - delete(f.Tools, tool) - } - for _, tool := range q.Tools { - wg.Add(1) - go func() { - defer wg.Done() - path := placementTool(tool, env) - var reason string - if path == "" { - reason = "not executable in absolute PATH entries" - } else if tool == "docker" || tool == "podman" { - trusted := placementTool(tool, baseEnv) - jobFile, jobErr := os.Stat(path) - baseFile, baseErr := os.Stat(trusted) - if jobErr != nil || baseErr != nil || !os.SameFile(jobFile, baseFile) { - reason = "job PATH does not resolve to the daemon's runtime" - } else if err := d.probeRuntime(ctx, trusted, baseEnv); err != nil { - reason = err.Error() - } - } - mu.Lock() - defer mu.Unlock() - if reason != "" { - f.ToolErrors[tool] = reason - } else { - f.Tools[tool] = path - } - }() - } - wg.Wait() - return f -} - -// Bound subprocesses across info, submission and workspace-creation requests. -// Probes run concurrently so a slow Docker does not starve Podman. -func (d *Daemon) probeRuntime(ctx context.Context, path string, env []string) error { - select { - case d.placementSlots <- struct{}{}: - defer func() { <-d.placementSlots }() - case <-ctx.Done(): - return fmt.Errorf("runtime probe deadline exceeded waiting for capacity") - } - cmd := exec.CommandContext(ctx, path, "info") - cmd.Env = env - cmd.Dir = "/" - cmd.WaitDelay = 100 * time.Millisecond - probe, err := startProbe(cmd) - if err != nil { - return fmt.Errorf("runtime probe could not start") - } - err = cmd.Wait() - // Remove any children retained by a runtime connection helper. - probe.kill() - if ctx.Err() != nil { - return fmt.Errorf("runtime probe deadline exceeded") - } - if err != nil { - return fmt.Errorf("runtime info failed") - } - return nil -} 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 index d7171138..07f6b967 100644 --- a/internal/daemon/placement_windows.go +++ b/internal/daemon/placement_windows.go @@ -8,6 +8,10 @@ import ( "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 } From db1afe25121af6620fc834d7b04e5b56c8a71b65 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 04:27:03 +0000 Subject: [PATCH 10/27] Release open handles before deleting on Windows Windows refuses to delete a file or directory while a handle to it is open. Two runner paths deleted through a handle they still held: - changes.RemoveTree removed the tree's root while its os.Root was open. That broke startup recovery of interrupted change collection and cleanup of failed workspace uploads. It now empties the tree through the root, closes it, re-checks the root's identity and removes it. - The blob cache deleted a corrupt blob while its read handle was open, so the blob stayed and every later job re-read it. It now closes the handle first and compares identities captured from the handle, not a path-based FileInfo that Windows resolves lazily. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Qzjh5YFvH2uew2AQsqp3No --- internal/changes/access.go | 38 +++++++++++++++++++++++-- internal/changes/remove_windows_test.go | 27 ++++++++++++++++++ internal/daemon/cache.go | 25 ++++++++++++---- internal/daemon/cache_test.go | 5 ++-- internal/daemon/cache_windows_test.go | 32 +++++++++++++++++++++ 5 files changed, 117 insertions(+), 10 deletions(-) create mode 100644 internal/changes/remove_windows_test.go create mode 100644 internal/daemon/cache_windows_test.go diff --git a/internal/changes/access.go b/internal/changes/access.go index 79765c0d..a8aeae46 100644 --- a/internal/changes/access.go +++ b/internal/changes/access.go @@ -560,11 +560,43 @@ func RemoveTree(rootPath string) error { identityErr, access.restore(), ) } - removeErr := os.RemoveAll(rootPath) - if removeErr != nil { + // Empty the tree through the verified root, then release it before + // removing the root itself: Windows cannot delete a directory while a + // handle to it is open. + if removeErr := removeRootChildren(access.root); removeErr != nil { return errors.Join(removeErr, access.restore()) } - return access.closeWithoutRestore() + if err := access.closeWithoutRestore(); err != nil { + return err + } + identity, info, identityErr = fsidentity.Lstat(rootPath) + if os.IsNotExist(identityErr) { + return nil + } + if identityErr != nil || !info.IsDir() || identity != access.rootIdentity { + return errors.Join(fmt.Errorf("retained tree root changed during removal"), identityErr) + } + return os.Remove(rootPath) +} + +func removeRootChildren(root *os.Root) error { + dir, err := 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 := root.RemoveAll(name); err != nil { + return err + } + } + return nil } func removeTreeAtRoot(root *os.Root, rel string) error { 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/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 3b82b9bb..a70ca7f7 100644 --- a/internal/daemon/cache_test.go +++ b/internal/daemon/cache_test.go @@ -12,6 +12,7 @@ import ( "testing" "time" + "github.com/lydakis/errand/internal/fsidentity" "github.com/lydakis/errand/internal/proto" ) @@ -372,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 { @@ -384,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) + } +} From c59250da5d171b94c5b259ff0f3bad61e1177219 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 04:29:33 +0000 Subject: [PATCH 11/27] Keep tree removal where CodeQL already tracks it The Windows release step (empty through the root, close it, re-check the root's identity) moves to remove_windows.go. RemoveTree keeps main's removal by path, which on Windows now finds an empty, released directory. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Qzjh5YFvH2uew2AQsqp3No --- internal/changes/access.go | 41 ++++--------------------- internal/changes/remove_other.go | 7 +++++ internal/changes/remove_windows.go | 48 ++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 35 deletions(-) create mode 100644 internal/changes/remove_other.go create mode 100644 internal/changes/remove_windows.go diff --git a/internal/changes/access.go b/internal/changes/access.go index a8aeae46..a2de61c9 100644 --- a/internal/changes/access.go +++ b/internal/changes/access.go @@ -560,43 +560,14 @@ func RemoveTree(rootPath string) error { identityErr, access.restore(), ) } - // Empty the tree through the verified root, then release it before - // removing the root itself: Windows cannot delete a directory while a - // handle to it is open. - if removeErr := removeRootChildren(access.root); removeErr != nil { - return errors.Join(removeErr, access.restore()) - } - if err := access.closeWithoutRestore(); err != nil { - return err - } - identity, info, identityErr = fsidentity.Lstat(rootPath) - if os.IsNotExist(identityErr) { - return nil - } - if identityErr != nil || !info.IsDir() || identity != access.rootIdentity { - return errors.Join(fmt.Errorf("retained tree root changed during removal"), identityErr) - } - return os.Remove(rootPath) -} - -func removeRootChildren(root *os.Root) error { - dir, err := root.Open(".") - if err != nil { - return err + if err := releaseTreeForRemoval(access, rootPath); err != nil { + return errors.Join(err, access.restore()) } - names, err := dir.Readdirnames(-1) - if closeErr := dir.Close(); err == nil { - err = closeErr - } - if err != nil { - return err - } - for _, name := range names { - if err := root.RemoveAll(name); err != nil { - return err - } + removeErr := os.RemoveAll(rootPath) + if removeErr != nil { + return errors.Join(removeErr, access.restore()) } - return nil + return access.closeWithoutRestore() } func removeTreeAtRoot(root *os.Root, rel string) error { 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 +} From ba5b0c09688d4975f627ff65c3104598e8ac2d05 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 00:03:59 +0000 Subject: [PATCH 12/27] Fix clean Windows setup and upgrades found on a real VM Testing setup on a Windows 11 ARM VM found four failures: - A fresh install stopped before creating any state: dialing the runner's socket in a directory that does not exist yet reports WSAENETDOWN, not a missing file. It now counts as no listener. - An administrator's token without UAC filtering makes BUILTIN\Administrators the owner of everything it creates, so errand's own runtime and cache directories failed the owner check. Files owned by the token's default owner now count as the user's. - Verifying the scheduled task runs PowerShell, which takes 2-3 seconds to start, inside the probe's 2 second deadline. It now has its own 30 second budget, and doctor's service check gets 10 seconds. - On upgrade, the new runner started before the stopped one released its state lock. A Windows runner now waits up to 10 seconds for it. The runner also once exited with status 2 and nothing in its log. With --log-file, fatal runtime errors now go to the log, and a Windows runner logs the console event that stops it instead of exiting silently. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Qzjh5YFvH2uew2AQsqp3No --- cmd/errand/console_other.go | 3 ++ cmd/errand/console_windows.go | 16 ++++++++++ cmd/errand/main.go | 7 +++++ internal/daemon/server.go | 17 ++++++++++- internal/daemon/state_lock_windows_test.go | 35 ++++++++++++++++++++++ internal/fsowner/fsowner_windows.go | 35 ++++++++++++++++++++-- internal/setup/diagnose.go | 2 +- internal/setup/setup.go | 13 +++++--- internal/unixpeer/dial_windows_test.go | 13 ++++++++ internal/unixpeer/refused_windows.go | 5 +++- 10 files changed, 137 insertions(+), 9 deletions(-) create mode 100644 internal/daemon/state_lock_windows_test.go diff --git a/cmd/errand/console_other.go b/cmd/errand/console_other.go index e10881bb..a58087f2 100644 --- a/cmd/errand/console_other.go +++ b/cmd/errand/console_other.go @@ -3,3 +3,6 @@ package main func detachServiceConsole() {} + +// 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 index 8c248594..c5e4ded1 100644 --- a/cmd/errand/console_windows.go +++ b/cmd/errand/console_windows.go @@ -3,6 +3,10 @@ package main import ( + "log" + "os" + "os/signal" + "syscall" "unsafe" "golang.org/x/sys/windows" @@ -24,3 +28,15 @@ func detachServiceConsole() { procFreeConsole.Call() } } + +// 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/main.go b/cmd/errand/main.go index 920cc21a..1a5bc23b 100644 --- a/cmd/errand/main.go +++ b/cmd/errand/main.go @@ -15,6 +15,7 @@ import ( "net/http" "os" "path/filepath" + "runtime/debug" "sort" "strconv" "strings" @@ -644,6 +645,12 @@ func cmdServe(args []string) int { } 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() } diff --git a/internal/daemon/server.go b/internal/daemon/server.go index 9632464f..ea216ce1 100644 --- a/internal/daemon/server.go +++ b/internal/daemon/server.go @@ -233,7 +233,15 @@ func (d *Daemon) lockStateDir() error { if err != nil { return err } - if err := filelock.TryLock(f); 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) } @@ -241,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() { 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/fsowner/fsowner_windows.go b/internal/fsowner/fsowner_windows.go index 772f7746..f3eba82a 100644 --- a/internal/fsowner/fsowner_windows.go +++ b/internal/fsowner/fsowner_windows.go @@ -4,10 +4,17 @@ 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 { @@ -17,9 +24,33 @@ func ownedByCurrentUser(f *os.File) (bool, error) { if err != nil { return false, err } - user, err := windows.GetCurrentProcessToken().GetTokenUser() + token := windows.GetCurrentProcessToken() + user, err := token.GetTokenUser() if err != nil { return false, err } - return owner.Equals(user.User.Sid), nil + 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/setup/diagnose.go b/internal/setup/diagnose.go index b1b92198..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) diff --git a/internal/setup/setup.go b/internal/setup/setup.go index 13896ee4..5e786dff 100644 --- a/internal/setup/setup.go +++ b/internal/setup/setup.go @@ -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" @@ -531,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) + var pid int if err == nil && managedServiceOS(sys.GOOS()) { - var pid int 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/unixpeer/dial_windows_test.go b/internal/unixpeer/dial_windows_test.go index 47d76802..18f0a16a 100644 --- a/internal/unixpeer/dial_windows_test.go +++ b/internal/unixpeer/dial_windows_test.go @@ -80,3 +80,16 @@ func TestStaleSocketIsConnectionRefusedOnWindows(t *testing.T) { 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/refused_windows.go b/internal/unixpeer/refused_windows.go index f02b0d84..52dc3e03 100644 --- a/internal/unixpeer/refused_windows.go +++ b/internal/unixpeer/refused_windows.go @@ -10,6 +10,9 @@ import ( ) // 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) + return errors.Is(err, windows.WSAECONNREFUSED) || errors.Is(err, syscall.ECONNREFUSED) || + errors.Is(err, windows.WSAENETDOWN) } From a29416ff4b37fa06da1f067a59cc258b19fe1338 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 00:19:12 +0000 Subject: [PATCH 13/27] Keep a detached Windows runner's standard handles valid A runner started by Task Scheduler detaches from its console, which closes the console's handles. Two things still named them: the standard handles, and the *os.File values serve replaced with the log file. When the garbage collector finalized those files, it closed whatever objects had reused the handle values since. On a Windows 11 VM that crashed the runner with "netpoll failed" during its second job after every start, which was also the unexplained exit status 2. The replaced files now stay reachable, and after detaching, the standard handles point at the log and NUL. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Qzjh5YFvH2uew2AQsqp3No --- cmd/errand/console_other.go | 4 +- cmd/errand/console_windows.go | 20 +++++- cmd/errand/console_windows_test.go | 106 +++++++++++++++++++++++++++++ cmd/errand/main.go | 30 +++++--- 4 files changed, 147 insertions(+), 13 deletions(-) create mode 100644 cmd/errand/console_windows_test.go diff --git a/cmd/errand/console_other.go b/cmd/errand/console_other.go index a58087f2..65ab4b05 100644 --- a/cmd/errand/console_other.go +++ b/cmd/errand/console_other.go @@ -2,7 +2,9 @@ package main -func detachServiceConsole() {} +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 index c5e4ded1..8d45512b 100644 --- a/cmd/errand/console_windows.go +++ b/cmd/errand/console_windows.go @@ -21,12 +21,26 @@ var ( // 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. -func detachServiceConsole() { +// +// 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 { - procFreeConsole.Call() + 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 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/main.go b/cmd/errand/main.go index 1a5bc23b..c23125d3 100644 --- a/cmd/errand/main.go +++ b/cmd/errand/main.go @@ -643,15 +643,7 @@ func cmdServe(args []string) int { if err != nil { log.Fatalf("errand serve: %v", err) } - 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() + useServiceLog(f) } fileCfg, err := config.LoadDaemon(*cfgPath) @@ -761,6 +753,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( From 5c63d9d764cafd8528809b08b317eeb1682a9f95 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 00:28:01 +0000 Subject: [PATCH 14/27] Drop the temporary Windows CI survey The survey listed which packages failed on Windows while the runner was built. The archive package now passes, so it joins the supported list; the rest still assume Unix fixtures and are covered by their OnWindows tests. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Qzjh5YFvH2uew2AQsqp3No --- .github/workflows/ci.yml | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 510f8ea6..b1478625 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -117,17 +117,10 @@ jobs: - name: Test Windows-supported packages run: >- go test -count=1 -timeout=5m - ./internal/durable ./internal/filelock ./internal/fsidentity ./internal/fsowner + ./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$' ./... - # Temporary while the Windows runner is built: shows which packages still - # fail on Windows without failing the check. - - name: Survey packages without Windows support yet - run: >- - go test -count=1 -timeout=4m ./... 2>&1 - | awk '/^panic/ { stack = 30 } stack > 0 { stack--; print; next } - /^(ok|FAIL|--- FAIL)|^ +[A-Za-z0-9_]+\.go:[0-9]+: / { print substr($0, 1, 300) }' || true From 4e2bfe4f4bfb4656a936dc154128179625d1e7d8 Mon Sep 17 00:00:00 2001 From: George Lydakis Date: Thu, 1 Oct 2026 01:02:25 +0000 Subject: [PATCH 15/27] Fix Windows symlink round trips and reserved device names --- internal/archive/windows_names.go | 18 +++-- internal/archive/windows_names_test.go | 15 ++++ internal/changes/changes.go | 1 + internal/changes/symlink_targets_test.go | 87 +++++++++++++++++++++++ internal/changes/transfer_stage.go | 2 +- internal/daemon/job_windows_test.go | 10 ++- internal/snapshot/snapshot.go | 7 +- internal/snapshot/symlink_targets_test.go | 61 ++++++++++++++++ 8 files changed, 191 insertions(+), 10 deletions(-) create mode 100644 internal/changes/symlink_targets_test.go create mode 100644 internal/snapshot/symlink_targets_test.go diff --git a/internal/archive/windows_names.go b/internal/archive/windows_names.go index 8a0ee14b..70540fef 100644 --- a/internal/archive/windows_names.go +++ b/internal/archive/windows_names.go @@ -26,7 +26,7 @@ func platformSymlinkTarget(target string) bool { // 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.Split(p, "/") { + 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) } @@ -43,16 +43,22 @@ func windowsNameProblem(name string) string { if strings.HasSuffix(name, ".") || strings.HasSuffix(name, " ") { return "names can't end with a dot or space" } - base, _, _ := strings.Cut(name, ".") - base = strings.ToUpper(strings.TrimRight(base, " ")) + 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")) && base[3] >= '0' && base[3] <= '9' { - 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 upper := strings.ToUpper(name); strings.HasPrefix(upper, "GIT~") && strings.Trim(upper[4:], "0123456789") == "" && len(upper) > 4 { + 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 index 833d165d..3eb6c04f 100644 --- a/internal/archive/windows_names_test.go +++ b/internal/archive/windows_names_test.go @@ -7,6 +7,7 @@ func TestWindowsPathRejectsNamesWindowsWouldReadDifferently(t *testing.T) { `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) @@ -15,9 +16,23 @@ func TestWindowsPathRejectsNamesWindowsWouldReadDifferently(t *testing.T) { 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/changes.go b/internal/changes/changes.go index 1053e298..1ad43698 100644 --- a/internal/changes/changes.go +++ b/internal/changes/changes.go @@ -261,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) } 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/transfer_stage.go b/internal/changes/transfer_stage.go index f351b02b..4c9a2d40 100644 --- a/internal/changes/transfer_stage.go +++ b/internal/changes/transfer_stage.go @@ -172,7 +172,7 @@ 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) } } diff --git a/internal/daemon/job_windows_test.go b/internal/daemon/job_windows_test.go index 97ca0086..dbcd765f 100644 --- a/internal/daemon/job_windows_test.go +++ b/internal/daemon/job_windows_test.go @@ -4,6 +4,8 @@ package daemon import ( "fmt" + "os" + "path/filepath" "testing" ) @@ -12,8 +14,14 @@ import ( 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 input.txt && echo remote> report.txt && exit /b 7"}) + []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) diff --git a/internal/snapshot/snapshot.go b/internal/snapshot/snapshot.go index bac208f8..e203c544 100644 --- a/internal/snapshot/snapshot.go +++ b/internal/snapshot/snapshot.go @@ -793,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 @@ -872,7 +875,7 @@ func packPartialContext(ctx context.Context, w io.Writer, root string, m proto.M 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 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)) + } +} From a807aedf55a6e50cb928ff63297ba0aed6022618 Mon Sep 17 00:00:00 2001 From: George Lydakis Date: Thu, 1 Oct 2026 01:24:15 +0000 Subject: [PATCH 16/27] Fix Windows workspace merges and protect registered task edits --- internal/changes/apply.go | 4 + internal/changes/logical_modes_test.go | 79 +++++++++++++++ internal/setup/scheduled_task.go | 48 ++++++--- internal/setup/scheduled_task_definition.go | 98 +++++++++++++++++++ .../setup/scheduled_task_definition_test.go | 40 ++++++++ internal/setup/scheduled_task_test.go | 46 +++++++++ internal/setup/scheduled_task_windows_test.go | 66 +++++++++++++ internal/setup/setup_test.go | 13 ++- 8 files changed, 381 insertions(+), 13 deletions(-) create mode 100644 internal/setup/scheduled_task_definition.go create mode 100644 internal/setup/scheduled_task_definition_test.go create mode 100644 internal/setup/scheduled_task_windows_test.go diff --git a/internal/changes/apply.go b/internal/changes/apply.go index 726bfedf..f9716c80 100644 --- a/internal/changes/apply.go +++ b/internal/changes/apply.go @@ -252,6 +252,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"), diff --git a/internal/changes/logical_modes_test.go b/internal/changes/logical_modes_test.go index eccf7522..6784a016 100644 --- a/internal/changes/logical_modes_test.go +++ b/internal/changes/logical_modes_test.go @@ -2,6 +2,7 @@ package changes import ( "context" + "errors" "fmt" "os" "path/filepath" @@ -70,3 +71,81 @@ func TestCollectionKeepsSubmittedModesOnWindows(t *testing.T) { 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 + 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}, + } { + 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 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) + 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/setup/scheduled_task.go b/internal/setup/scheduled_task.go index 8dcb95ed..1d58065d 100644 --- a/internal/setup/scheduled_task.go +++ b/internal/setup/scheduled_task.go @@ -21,7 +21,9 @@ const scheduledTaskName = DefaultServiceName // The task queries go through PowerShell because its state names are not // localized, unlike schtasks output. -const scheduledTaskQuery = `$t = Get-ScheduledTask -TaskPath '\' -TaskName '` + scheduledTaskName + `' -ErrorAction SilentlyContinue; if ($t) { "$($t.State)"; $t.Actions[0].Execute } else { 'Missing' }` +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") @@ -32,20 +34,28 @@ func scheduledTaskPath(home string) string { } type scheduledTaskState struct { - State string - Command string + State string + Command string + Definition string } func queryScheduledTask(ctx context.Context, sys serviceSystem) (scheduledTaskState, error) { - out, err := sys.Run(ctx, "powershell.exe", "-NoProfile", "-NonInteractive", "-Command", scheduledTaskQuery) + 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.Split(strings.ReplaceAll(strings.TrimSpace(out), "\r\n", "\n"), "\n") + 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) } @@ -71,6 +81,17 @@ func installScheduledTask(ctx context.Context, opts Options, sys System, r *Repo 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 @@ -79,11 +100,6 @@ func installScheduledTask(ctx context.Context, opts Options, sys System, r *Repo r.step("service", "would run: schtasks /Create /TN "+scheduledTaskName+" /XML "+taskPath+" /F && schtasks /Run /TN "+scheduledTaskName, true) return false } - current, err := queryScheduledTask(ctx, sys) - if err != nil { - r.fail("service", fmt.Errorf("cannot query Task Scheduler: %w", err)) - 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)) @@ -145,6 +161,10 @@ func setupRenderedTask(existing, user, configPath, logPath, stateDir string) boo 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 @@ -153,7 +173,7 @@ func setupRenderedTask(existing, user, configPath, logPath, stateDir string) boo if err != nil || rel == "." || strings.HasPrefix(rel, "..") || filepath.IsAbs(rel) { return false } - return existing == renderScheduledTask(user, command, configPath, logPath) + return true } func taskCommand(definition string) (string, error) { @@ -178,6 +198,10 @@ func taskCommand(definition string) (string, error) { // 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. @@ -230,7 +254,7 @@ func renderScheduledTask(user, executable, configPath, logPath string) string { `, xmlText(scheduledTaskName), xmlText(user), xmlText(user), xmlText(executable), xmlText(arguments)) - return encodeUTF16(strings.ReplaceAll(text, "\n", "\r\n")) + return strings.ReplaceAll(text, "\n", "\r\n") } func encodeUTF16(text string) string { diff --git a/internal/setup/scheduled_task_definition.go b/internal/setup/scheduled_task_definition.go new file mode 100644 index 00000000..62973757 --- /dev/null +++ b/internal/setup/scheduled_task_definition.go @@ -0,0 +1,98 @@ +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": "", +} + +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" { + fields = append(fields, path+"/@"+attr.Name.Local+"="+attr.Value) + } + } + if len(node.Children) == 0 { + if value, ok := taskSchemaDefaults[path]; !ok || node.Text != value { + fields = append(fields, path+"="+node.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..bf67117d --- /dev/null +++ b/internal/setup/scheduled_task_definition_test.go @@ -0,0 +1,40 @@ +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-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: "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 index d8eee0bd..463a98a5 100644 --- a/internal/setup/scheduled_task_test.go +++ b/internal/setup/scheduled_task_test.go @@ -5,6 +5,7 @@ package setup import ( "context" + "fmt" "os" "strings" "testing" @@ -113,6 +114,51 @@ func TestWindowsSetupLeavesAnOperatorTaskAlone(t *testing.T) { } } +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" 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/setup_test.go b/internal/setup/setup_test.go index 301e16f4..171a0fcb 100644 --- a/internal/setup/setup_test.go +++ b/internal/setup/setup_test.go @@ -48,6 +48,7 @@ type fakeSystem struct { discoverCLI string taskState string taskCommand string + taskDefinition string processImage string } @@ -127,7 +128,16 @@ func (f *fakeSystem) Run(_ context.Context, name string, args ...string) (string if f.taskState == "" { return "Missing", nil } - return f.taskState + "\r\n" + f.taskCommand, 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 "): @@ -136,6 +146,7 @@ func (f *fakeSystem) Run(_ context.Context, name string, args ...string) (string return "", err } f.taskCommand = command + f.taskDefinition, _ = decodeUTF16(f.files[args[4]]) if f.taskState == "" { f.taskState = "Ready" } From fb4de8d8f265a98206c6ca8dee834b6f1bed6f94 Mon Sep 17 00:00:00 2001 From: George Lydakis Date: Thu, 1 Oct 2026 01:28:19 +0000 Subject: [PATCH 17/27] Normalize Task Scheduler export metadata and account identities --- internal/setup/process_unix.go | 2 ++ internal/setup/process_windows.go | 14 ++++++++++++++ internal/setup/scheduled_task_definition.go | 16 +++++++++++++--- internal/setup/scheduled_task_definition_test.go | 6 ++++++ 4 files changed, 35 insertions(+), 3 deletions(-) diff --git a/internal/setup/process_unix.go b/internal/setup/process_unix.go index fb4a537d..4527937c 100644 --- a/internal/setup/process_unix.go +++ b/internal/setup/process_unix.go @@ -7,6 +7,8 @@ import ( "runtime" ) +func taskUserID(name string) string { return name } + func currentUserSID() (string, error) { return "", errors.New("user SIDs exist only on Windows, not " + runtime.GOOS) } diff --git a/internal/setup/process_windows.go b/internal/setup/process_windows.go index b644a399..fa8ca84b 100644 --- a/internal/setup/process_windows.go +++ b/internal/setup/process_windows.go @@ -4,8 +4,22 @@ 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 { diff --git a/internal/setup/scheduled_task_definition.go b/internal/setup/scheduled_task_definition.go index 62973757..18e7d768 100644 --- a/internal/setup/scheduled_task_definition.go +++ b/internal/setup/scheduled_task_definition.go @@ -56,6 +56,8 @@ var taskSchemaDefaults = map[string]string{ "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) { @@ -78,13 +80,21 @@ func taskDefinitionFields(text string) ([]string, error) { return } for _, attr := range node.Attributes { - if attr.Name.Local != "xmlns" && attr.Name.Space != "xmlns" { - fields = append(fields, path+"/@"+attr.Name.Local+"="+attr.Value) + 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 { - fields = append(fields, path+"="+node.Text) + text := node.Text + if path == "Task/Principals/Principal/UserId" || path == "Task/Triggers/LogonTrigger/UserId" { + text = taskUserID(text) + } + fields = append(fields, path+"="+text) } return } diff --git a/internal/setup/scheduled_task_definition_test.go b/internal/setup/scheduled_task_definition_test.go index bf67117d..a03dacb2 100644 --- a/internal/setup/scheduled_task_definition_test.go +++ b/internal/setup/scheduled_task_definition_test.go @@ -15,6 +15,9 @@ func TestTaskDefinitionPreservesOperatorSettings(t *testing.T) { 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}, @@ -29,6 +32,9 @@ func TestTaskDefinitionPreservesOperatorSettings(t *testing.T) { {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) { From b6e4670c397c929552783098db385b19c0b1488b Mon Sep 17 00:00:00 2001 From: George Lydakis Date: Thu, 1 Oct 2026 01:42:02 +0000 Subject: [PATCH 18/27] Open Windows apply backups and staging files with flush access --- internal/changes/access.go | 2 +- internal/changes/apply_sync.go | 2 +- internal/changes/logical_modes_test.go | 15 ++++++ internal/changes/sync_open_other.go | 13 +++++ internal/changes/sync_open_windows.go | 69 ++++++++++++++++++++++++++ 5 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 internal/changes/sync_open_other.go create mode 100644 internal/changes/sync_open_windows.go diff --git a/internal/changes/access.go b/internal/changes/access.go index a2de61c9..2e42b5bc 100644 --- a/internal/changes/access.go +++ b/internal/changes/access.go @@ -434,7 +434,7 @@ func (a *treeAccess) restoreWithSync(syncData func(*os.File) error) error { } return nil } - file, err := a.root.Open(rel) + file, err := openRetainedSyncFile(a.root, rel, info) if err != nil { return err } 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/logical_modes_test.go b/internal/changes/logical_modes_test.go index 6784a016..1bda496a 100644 --- a/internal/changes/logical_modes_test.go +++ b/internal/changes/logical_modes_test.go @@ -81,12 +81,14 @@ func TestApplyKeepsSubmittedModesOnWindows(t *testing.T) { 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() @@ -99,6 +101,12 @@ func TestApplyKeepsSubmittedModesOnWindows(t *testing.T) { 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) } @@ -115,6 +123,13 @@ func TestApplyKeepsSubmittedModesOnWindows(t *testing.T) { 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) 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 +} From fc01dd3267ef2fff4a1561bed1e3c5bd47f27bdf Mon Sep 17 00:00:00 2001 From: George Lydakis Date: Wed, 30 Sep 2026 21:47:20 -0400 Subject: [PATCH 19/27] Keep runner helper programs from flashing console windows A Windows runner has no console of its own, so every console program it starts gets a new visible window. The tailscale CLI runs for each request to identify the caller, so the desktop showed a Windows Terminal window flashing for every peers call, job and file transfer. nowindow.Hide starts a program with CREATE_NO_WINDOW. The tailscale CLI calls and git merge-file use it, and proctree.Prepare now shares it. --- internal/changes/merge.go | 2 + internal/nowindow/nowindow.go | 7 ++++ internal/nowindow/nowindow_other.go | 7 ++++ internal/nowindow/nowindow_windows.go | 18 +++++++++ internal/nowindow/nowindow_windows_test.go | 47 ++++++++++++++++++++++ internal/proctree/job_windows.go | 8 ++-- internal/tailnet/tailnet.go | 3 ++ 7 files changed, 87 insertions(+), 5 deletions(-) create mode 100644 internal/nowindow/nowindow.go create mode 100644 internal/nowindow/nowindow_other.go create mode 100644 internal/nowindow/nowindow_windows.go create mode 100644 internal/nowindow/nowindow_windows_test.go diff --git a/internal/changes/merge.go b/internal/changes/merge.go index 2ba8d53a..a8a10390 100644 --- a/internal/changes/merge.go +++ b/internal/changes/merge.go @@ -13,6 +13,7 @@ import ( "sort" "strings" + "github.com/lydakis/errand/internal/nowindow" "github.com/lydakis/errand/internal/proto" ) @@ -404,6 +405,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/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/proctree/job_windows.go b/internal/proctree/job_windows.go index 9dfa52ea..13ff1c19 100644 --- a/internal/proctree/job_windows.go +++ b/internal/proctree/job_windows.go @@ -7,9 +7,9 @@ import ( "fmt" "os" "os/exec" - "syscall" "unsafe" + "github.com/lydakis/errand/internal/nowindow" "golang.org/x/sys/windows" ) @@ -43,10 +43,8 @@ func New() (*Job, error) { // 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) { - if cmd.SysProcAttr == nil { - cmd.SysProcAttr = &syscall.SysProcAttr{} - } - cmd.SysProcAttr.CreationFlags |= windows.CREATE_SUSPENDED | windows.CREATE_NO_WINDOW + nowindow.Hide(cmd) + cmd.SysProcAttr.CreationFlags |= windows.CREATE_SUSPENDED } // Adopt assigns a process started after Prepare to the job, then resumes it. diff --git a/internal/tailnet/tailnet.go b/internal/tailnet/tailnet.go index 069cde54..069b8d87 100644 --- a/internal/tailnet/tailnet.go +++ b/internal/tailnet/tailnet.go @@ -18,6 +18,8 @@ import ( "strconv" "strings" "time" + + "github.com/lydakis/errand/internal/nowindow" ) // WhoIs is the subset of a Tailscale WhoIs answer errand relies on. @@ -358,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() From f3aa2d0f76cf6afd8edfcaa060fb925b822c8628 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 02:10:34 +0000 Subject: [PATCH 20/27] Say plainly that Windows can't send jobs yet Client commands on Windows got as far as submitting, then failed on a Unix-only state permission check. They now stop at once and point to docs/WINDOWS.md; runner commands are unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Qzjh5YFvH2uew2AQsqp3No --- cmd/errand/main.go | 4 ++++ cmd/errand/platform.go | 19 +++++++++++++++++++ cmd/errand/platform_windows_test.go | 18 ++++++++++++++++++ 3 files changed, 41 insertions(+) create mode 100644 cmd/errand/platform.go create mode 100644 cmd/errand/platform_windows_test.go diff --git a/cmd/errand/main.go b/cmd/errand/main.go index c23125d3..e0fbdc0e 100644 --- a/cmd/errand/main.go +++ b/cmd/errand/main.go @@ -97,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:]) 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) + } + } +} From 09d60acea425798204465cafa6d65a4b96f20197 Mon Sep 17 00:00:00 2001 From: George Lydakis Date: Thu, 1 Oct 2026 02:19:14 +0000 Subject: [PATCH 21/27] Preserve Windows directory links and resolve effective PATHEXT --- internal/archive/archive.go | 30 +++- internal/changes/apply.go | 11 +- internal/changes/apply_snapshot.go | 36 +++++ internal/changes/directory_links_test.go | 77 ++++++++++ internal/changes/merge.go | 17 ++- internal/changes/source_prepared.go | 15 +- internal/changes/transfer_blob_base.go | 11 +- internal/changes/transfer_blob_tree.go | 5 +- internal/changes/transfer_materialize.go | 20 ++- internal/changes/transfer_session.go | 12 +- internal/changes/transfer_stage.go | 23 ++- internal/daemon/admission_regression_test.go | 2 +- internal/daemon/job.go | 6 +- internal/daemon/job_exec_unix.go | 3 + internal/daemon/job_exec_windows.go | 30 ++-- internal/daemon/job_exec_windows_test.go | 33 ++++- internal/daemon/placement.go | 3 +- internal/daemon/placement_test.go | 2 +- .../daemon/workspace_links_windows_test.go | 42 ++++++ internal/daemon/workspace_push.go | 1 + internal/fslink/fslink.go | 76 ++++++++++ internal/fslink/fslink_test.go | 43 ++++++ internal/fslink/link_other.go | 15 ++ internal/fslink/link_windows.go | 133 ++++++++++++++++++ internal/fslink/link_windows_test.go | 78 ++++++++++ 25 files changed, 683 insertions(+), 41 deletions(-) create mode 100644 internal/changes/apply_snapshot.go create mode 100644 internal/changes/directory_links_test.go create mode 100644 internal/daemon/workspace_links_windows_test.go create mode 100644 internal/fslink/fslink.go create mode 100644 internal/fslink/fslink_test.go create mode 100644 internal/fslink/link_other.go create mode 100644 internal/fslink/link_windows.go create mode 100644 internal/fslink/link_windows_test.go diff --git a/internal/archive/archive.go b/internal/archive/archive.go index a6412e89..af0d5302 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" ) @@ -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,7 +313,28 @@ 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 err := os.Symlink(e.Target, abs); err != nil { + directory := false + 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 fslink.NativeTypes { + if err := fslink.Create(linkRoot, e.Target, filepath.FromSlash(e.Path), directory); err != nil { + return err + } + } else if err := os.Symlink(e.Target, abs); err != nil { return err } } diff --git a/internal/changes/apply.go b/internal/changes/apply.go index f9716c80..d9fc1c0b 100644 --- a/internal/changes/apply.go +++ b/internal/changes/apply.go @@ -16,6 +16,7 @@ import ( "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" @@ -676,6 +677,9 @@ func installMetadataChange(root *os.Root, item applyJournalItem) error { } 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 @@ -711,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 @@ -1268,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) } @@ -1330,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_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/directory_links_test.go b/internal/changes/directory_links_test.go new file mode 100644 index 00000000..8cb60975 --- /dev/null +++ b/internal/changes/directory_links_test.go @@ -0,0 +1,77 @@ +package changes + +import ( + "os" + "path/filepath" + "testing" + + "github.com/lydakis/errand/internal/proto" + "github.com/lydakis/errand/internal/snapshot" +) + +func TestTransferDirectorySymlinksOnWindows(t *testing.T) { + for _, link := range []string{"alias", "nested/alias"} { + t.Run(link, 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 _, _, 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) + } + 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/merge.go b/internal/changes/merge.go index a8a10390..2c664d38 100644 --- a/internal/changes/merge.go +++ b/internal/changes/merge.go @@ -13,6 +13,7 @@ import ( "sort" "strings" + "github.com/lydakis/errand/internal/fslink" "github.com/lydakis/errand/internal/nowindow" "github.com/lydakis/errand/internal/proto" ) @@ -272,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 { 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/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 4c9a2d40..902c902c 100644 --- a/internal/changes/transfer_stage.go +++ b/internal/changes/transfer_stage.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" + "github.com/lydakis/errand/internal/fslink" "github.com/lydakis/errand/internal/fsmode" "github.com/lydakis/errand/internal/proto" ) @@ -22,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 @@ -36,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 { @@ -83,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 @@ -92,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) @@ -143,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 @@ -175,6 +183,9 @@ func checkMaterializationSource(paths *materializationPaths, e proto.ManifestEnt 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/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/job.go b/internal/daemon/job.go index a6155836..9355e503 100644 --- a/internal/daemon/job.go +++ b/internal/daemon/job.go @@ -477,7 +477,7 @@ 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, envValue(jobEnv, "PATHEXT")) if err == nil { err = checkCommandLine(executable, j.Spec.Argv[1:]) } @@ -825,8 +825,8 @@ func envValue(env []string, key string) string { return "" } -func resolveExecutable(name, pathEnv, workdir string) (string, error) { - check := findExecutable +func resolveExecutable(name, pathEnv, workdir, pathext string) (string, error) { + check := executableFinder(pathext) if hasPathSeparator(name) { candidate := name if !filepath.IsAbs(candidate) { diff --git a/internal/daemon/job_exec_unix.go b/internal/daemon/job_exec_unix.go index 0026744d..727f9c31 100644 --- a/internal/daemon/job_exec_unix.go +++ b/internal/daemon/job_exec_unix.go @@ -15,3 +15,6 @@ func hasPathSeparator(name string) bool { return strings.ContainsRune(name, '/') // 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 index 21e96ad5..552a4cfe 100644 --- a/internal/daemon/job_exec_windows.go +++ b/internal/daemon/job_exec_windows.go @@ -27,8 +27,7 @@ func envNameEqual(a, b string) bool { return strings.EqualFold(a, b) } func hasPathSeparator(name string) bool { return strings.ContainsAny(name, `/\`) } -func executableExtensions() []string { - pathext := os.Getenv("PATHEXT") +func executableExtensions(pathext string) []string { if pathext == "" { pathext = ".COM;.EXE;.BAT;.CMD" } @@ -50,19 +49,26 @@ func regularFile(path string) bool { // extension is in PATHEXT, otherwise the first path+extension that exists. // So "cargo" finds cargo.exe and "npm" finds npm.cmd. func findExecutable(path string) (string, bool) { - exts := executableExtensions() - ext := strings.ToLower(filepath.Ext(path)) - for _, candidate := range exts { - if ext == candidate { - return path, regularFile(path) + 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) { + ext := strings.ToLower(filepath.Ext(path)) + for _, candidate := range exts { + if ext == candidate { + return path, regularFile(path) + } } - } - for _, candidate := range exts { - if regularFile(path + candidate) { - return path + candidate, true + for _, candidate := range exts { + if regularFile(path + candidate) { + return path + candidate, true + } } + return "", false } - return "", false } // cmd.exe runs .bat and .cmd files and reparses their command line with its diff --git a/internal/daemon/job_exec_windows_test.go b/internal/daemon/job_exec_windows_test.go index bbf6c1ce..a973a016 100644 --- a/internal/daemon/job_exec_windows_test.go +++ b/internal/daemon/job_exec_windows_test.go @@ -44,14 +44,14 @@ func TestResolveExecutableSearchesPATHWithExtensionsOnWindows(t *testing.T) { if err := os.WriteFile(filepath.Join(dir, "npm.cmd"), nil, 0o644); err != nil { t.Fatal(err) } - got, err := resolveExecutable("npm", dir, t.TempDir()) + 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); err != nil { + if _, err := resolveExecutable("./npm", dir, dir, os.Getenv("PATHEXT")); err != nil { t.Fatalf("relative path with forward slash: %v", err) } } @@ -66,6 +66,35 @@ func TestEnvValueIgnoresNameCaseOnWindows(t *testing.T) { } } +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) diff --git a/internal/daemon/placement.go b/internal/daemon/placement.go index 034457d8..648d6d20 100644 --- a/internal/daemon/placement.go +++ b/internal/daemon/placement.go @@ -20,12 +20,13 @@ func executableFile(path string) bool { // Relative entries depend on the future workspace, so cannot be attested. func placementTool(tool string, env []string) string { + check := executableFinder(envValue(env, "PATHEXT")) for _, dir := range filepath.SplitList(envValue(env, "PATH")) { if !filepath.IsAbs(dir) { continue } path := filepath.Join(dir, tool) - if path, ok := findExecutable(path); ok { + if path, ok := check(path); ok { return path } } 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/workspace_links_windows_test.go b/internal/daemon/workspace_links_windows_test.go new file mode 100644 index 00000000..7c9d2f1a --- /dev/null +++ b/internal/daemon/workspace_links_windows_test.go @@ -0,0 +1,42 @@ +//go:build windows + +package daemon + +import ( + "os" + "path/filepath" + "testing" + + "github.com/lydakis/errand/internal/client" +) + +func TestWorkspaceDirectorySymlinkPushOnWindows(t *testing.T) { + d, ts := 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) + } + } + ws, err := client.CreateWorkspace(client.RunOptions{PeerURL: ts.URL, Root: root}, "links") + if err != nil { + t.Fatal(err) + } + remote := filepath.Join(d.workspaces.dir, ws.ID, "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) + } + if _, err := client.PushChanges(client.PushOptions{PeerURL: ts.URL, Workspace: ws.Name, Root: root, Apply: true}); err != nil { + t.Fatal(err) + } + 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) + } +} 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/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) + } +} From 73d6622bfee82c579aba681c1e9101c1f5022ae4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 02:21:37 +0000 Subject: [PATCH 22/27] Keep the Unix symlink call where CodeQL already tracks it The directory-link change turned the existing os.Symlink line into an else branch, which CodeQL reported as two new path alerts. Windows links now return early, so that line and its context are unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Qzjh5YFvH2uew2AQsqp3No --- internal/archive/archive.go | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/internal/archive/archive.go b/internal/archive/archive.go index af0d5302..d5508bbc 100644 --- a/internal/archive/archive.go +++ b/internal/archive/archive.go @@ -313,7 +313,6 @@ 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 } - directory := false if fslink.NativeTypes { if linkLookup == nil { metadata := m @@ -328,13 +327,13 @@ func ExtractWith(r io.Reader, dest string, m proto.Manifest, maxBytes int64, opt } defer linkRoot.Close() } - directory = fslink.IsDirectory(e, linkLookup) - } - if fslink.NativeTypes { + directory := fslink.IsDirectory(e, linkLookup) if err := fslink.Create(linkRoot, e.Target, filepath.FromSlash(e.Path), directory); err != nil { return err } - } else if err := os.Symlink(e.Target, abs); err != nil { + continue + } + if err := os.Symlink(e.Target, abs); err != nil { return err } } From f04676f2db0447c0bd6bc142a1b823b59e2c20df Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 02:24:12 +0000 Subject: [PATCH 23/27] Test Windows directory links without the Windows client The workspace version of this test failed on Windows CI: the client's local state check reads POSIX modes, which Windows reports as writable by everyone. The Windows client isn't supported yet, so the end-to-end check now runs as a job, and an archive test covers a pushed link whose target is known only from the complete manifest. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Qzjh5YFvH2uew2AQsqp3No --- internal/archive/directory_links_test.go | 43 +++++++++++++++++++ .../daemon/workspace_links_windows_test.go | 38 ++++++---------- 2 files changed, 57 insertions(+), 24 deletions(-) create mode 100644 internal/archive/directory_links_test.go 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/daemon/workspace_links_windows_test.go b/internal/daemon/workspace_links_windows_test.go index 7c9d2f1a..ff082661 100644 --- a/internal/daemon/workspace_links_windows_test.go +++ b/internal/daemon/workspace_links_windows_test.go @@ -6,37 +6,27 @@ import ( "os" "path/filepath" "testing" - - "github.com/lydakis/errand/internal/client" ) -func TestWorkspaceDirectorySymlinkPushOnWindows(t *testing.T) { +// 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"}) - // 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) - } - } - ws, err := client.CreateWorkspace(client.RunOptions{PeerURL: ts.URL, Root: root}, "links") - if err != nil { - t.Fatal(err) - } - remote := filepath.Join(d.workspaces.dir, ws.ID, "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) - } - if _, err := client.PushChanges(client.PushOptions{PeerURL: ts.URL, Workspace: ws.Name, Root: root, Apply: true}); 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) + } } - 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) + _, 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) } } From 497453f5a4d9df6802ef6d9f576264869e770daf Mon Sep 17 00:00:00 2001 From: George Lydakis Date: Thu, 1 Oct 2026 02:24:43 +0000 Subject: [PATCH 24/27] Exercise runner link pushes without Windows client state --- internal/changes/directory_links_test.go | 23 +++- internal/daemon/job.go | 2 +- internal/daemon/job_exec_unix.go | 2 + internal/daemon/job_exec_windows.go | 2 + internal/daemon/placement.go | 2 +- .../daemon/workspace_links_windows_test.go | 105 ++++++++++++++++++ 6 files changed, 131 insertions(+), 5 deletions(-) diff --git a/internal/changes/directory_links_test.go b/internal/changes/directory_links_test.go index 8cb60975..1b0c5c48 100644 --- a/internal/changes/directory_links_test.go +++ b/internal/changes/directory_links_test.go @@ -1,6 +1,7 @@ package changes import ( + "fmt" "os" "path/filepath" "testing" @@ -10,8 +11,12 @@ import ( ) func TestTransferDirectorySymlinksOnWindows(t *testing.T) { - for _, link := range []string{"alias", "nested/alias"} { - t.Run(link, func(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 { @@ -57,12 +62,24 @@ func TestTransferDirectorySymlinksOnWindows(t *testing.T) { t.Fatal(err) } id := proto.NewULID() - if _, _, err := session.Stage(t.Context(), id, source, current); err != nil { + 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" diff --git a/internal/daemon/job.go b/internal/daemon/job.go index 9355e503..77f8da9e 100644 --- a/internal/daemon/job.go +++ b/internal/daemon/job.go @@ -477,7 +477,7 @@ 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, envValue(jobEnv, "PATHEXT")) + executable, err := resolveExecutable(j.Spec.Argv[0], envValue(jobEnv, "PATH"), workdir, jobPATHEXT(jobEnv)) if err == nil { err = checkCommandLine(executable, j.Spec.Argv[1:]) } diff --git a/internal/daemon/job_exec_unix.go b/internal/daemon/job_exec_unix.go index 727f9c31..01d8528f 100644 --- a/internal/daemon/job_exec_unix.go +++ b/internal/daemon/job_exec_unix.go @@ -13,6 +13,8 @@ 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 } diff --git a/internal/daemon/job_exec_windows.go b/internal/daemon/job_exec_windows.go index 552a4cfe..d36d70cc 100644 --- a/internal/daemon/job_exec_windows.go +++ b/internal/daemon/job_exec_windows.go @@ -27,6 +27,8 @@ 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" diff --git a/internal/daemon/placement.go b/internal/daemon/placement.go index 648d6d20..afa635ad 100644 --- a/internal/daemon/placement.go +++ b/internal/daemon/placement.go @@ -20,7 +20,7 @@ func executableFile(path string) bool { // Relative entries depend on the future workspace, so cannot be attested. func placementTool(tool string, env []string) string { - check := executableFinder(envValue(env, "PATHEXT")) + check := executableFinder(jobPATHEXT(env)) for _, dir := range filepath.SplitList(envValue(env, "PATH")) { if !filepath.IsAbs(dir) { continue diff --git a/internal/daemon/workspace_links_windows_test.go b/internal/daemon/workspace_links_windows_test.go index ff082661..8836df57 100644 --- a/internal/daemon/workspace_links_windows_test.go +++ b/internal/daemon/workspace_links_windows_test.go @@ -3,11 +3,116 @@ 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 From b0fa7f9e19dd840bf520ab16ac0ec24d57bf07cb Mon Sep 17 00:00:00 2001 From: George Lydakis Date: Thu, 1 Oct 2026 02:48:44 +0000 Subject: [PATCH 25/27] Honor explicit Windows executable names before PATHEXT suffixes --- internal/daemon/job_exec_windows.go | 11 +++--- internal/daemon/job_exec_windows_test.go | 46 +++++++++++++++++++++++- internal/daemon/job_windows_test.go | 32 +++++++++++++++++ 3 files changed, 81 insertions(+), 8 deletions(-) diff --git a/internal/daemon/job_exec_windows.go b/internal/daemon/job_exec_windows.go index d36d70cc..d909722e 100644 --- a/internal/daemon/job_exec_windows.go +++ b/internal/daemon/job_exec_windows.go @@ -47,8 +47,8 @@ func regularFile(path string) bool { return err == nil && info.Mode().IsRegular() } -// findExecutable finds what Windows would run for path: path itself when its -// extension is in PATHEXT, otherwise the first path+extension that exists. +// 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) @@ -58,11 +58,8 @@ func findExecutable(path string) (string, bool) { func executableFinder(pathext string) func(string) (string, bool) { exts := executableExtensions(pathext) return func(path string) (string, bool) { - ext := strings.ToLower(filepath.Ext(path)) - for _, candidate := range exts { - if ext == candidate { - return path, regularFile(path) - } + if filepath.Ext(path) != "" && regularFile(path) { + return path, true } for _, candidate := range exts { if regularFile(path + candidate) { diff --git a/internal/daemon/job_exec_windows_test.go b/internal/daemon/job_exec_windows_test.go index a973a016..5f02b80d 100644 --- a/internal/daemon/job_exec_windows_test.go +++ b/internal/daemon/job_exec_windows_test.go @@ -21,7 +21,7 @@ func TestFindExecutableAppliesPATHEXTOnWindows(t *testing.T) { "tool": "tool.exe", "tool.exe": "tool.exe", "shim": "shim.cmd", - "notes.txt": "", + "notes.txt": "notes.txt", "notes": "", "missing": "", } { @@ -38,6 +38,50 @@ func TestFindExecutableAppliesPATHEXTOnWindows(t *testing.T) { } } +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() diff --git a/internal/daemon/job_windows_test.go b/internal/daemon/job_windows_test.go index dbcd765f..551a73fc 100644 --- a/internal/daemon/job_windows_test.go +++ b/internal/daemon/job_windows_test.go @@ -4,9 +4,14 @@ 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 @@ -30,3 +35,30 @@ func TestJobRoundTripOnWindows(t *testing.T) { 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"}, + 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) + } +} From c410aeb2ff842719a2e0b83b323d8cd0769a062b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 02:51:13 +0000 Subject: [PATCH 26/27] Declare PATHEXT's source in the explicit-executable Windows test The runner rejects job environment variables without provenance, so the submission failed with 400 before the PATHEXT case ran. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Qzjh5YFvH2uew2AQsqp3No --- internal/daemon/job_windows_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/daemon/job_windows_test.go b/internal/daemon/job_windows_test.go index 551a73fc..8cba5228 100644 --- a/internal/daemon/job_windows_test.go +++ b/internal/daemon/job_windows_test.go @@ -49,6 +49,7 @@ func TestJobExplicitExecutableWithRestrictedPATHEXTOnWindows(t *testing.T) { 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 { From 594c0e30669f79364302dfec496c344bb7caddda Mon Sep 17 00:00:00 2001 From: George Lydakis Date: Thu, 1 Oct 2026 03:24:50 +0000 Subject: [PATCH 27/27] Release Windows Job Objects on every cleanup outcome --- internal/daemon/process_scope_windows.go | 37 ++++- internal/daemon/process_scope_windows_test.go | 126 ++++++++++++++++++ internal/proctree/job_windows_test.go | 49 +++++++ 3 files changed, 206 insertions(+), 6 deletions(-) create mode 100644 internal/daemon/process_scope_windows_test.go diff --git a/internal/daemon/process_scope_windows.go b/internal/daemon/process_scope_windows.go index b4f52b9f..6ecdf949 100644 --- a/internal/daemon/process_scope_windows.go +++ b/internal/daemon/process_scope_windows.go @@ -3,6 +3,7 @@ package daemon import ( + "errors" "fmt" "os/exec" "sync" @@ -105,36 +106,60 @@ func (s *processScope) cleanup(timeout time.Duration) ([]int, error) { 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. - var pids []int settle := time.Now().Add(250 * time.Millisecond) for { var err error - pids, err = s.job.PIDs() + pids, err = job.PIDs() if err != nil { return nil, err } if len(pids) == 0 { - return nil, s.closeLocked() + return nil, nil } if time.Now().After(settle) { break } time.Sleep(20 * time.Millisecond) } - if err := s.job.Terminate(terminatedExitCode); err != nil { + if err := job.Terminate(terminatedExitCode); err != nil { return pids, err } deadline := time.Now().Add(timeout) for { - remaining, err := s.job.PIDs() + remaining, err := job.PIDs() if err != nil { return pids, err } if len(remaining) == 0 { - return pids, s.closeLocked() + return pids, nil } if time.Now().After(deadline) { return pids, fmt.Errorf("process scope still contains pids %v", remaining) 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/proctree/job_windows_test.go b/internal/proctree/job_windows_test.go index a0d069f7..c0adee92 100644 --- a/internal/proctree/job_windows_test.go +++ b/internal/proctree/job_windows_test.go @@ -8,6 +8,8 @@ import ( "slices" "testing" "time" + + "golang.org/x/sys/windows" ) // TestMain lets the test binary act as the contained program. @@ -89,6 +91,53 @@ func TestTerminateKillsDescendants(t *testing.T) { 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)