diff --git a/AGENTS.md b/AGENTS.md index b7eef09..bc4ea6a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,6 +21,8 @@ service registry, no process management, no discovery — those belong to the ap | `go test -fuzz FuzzReadFrame ./framing` | Fuzz the frame reader | | `go vet ./...` | Static analysis | | `gofmt -l .` | List unformatted files (must be empty) | +| `GOOS=darwin GOARCH=arm64 go build ./...` | Cross-compile macOS — mandatory before every commit | +| `GOOS=windows GOARCH=amd64 go build ./...` | Cross-compile Windows (from Phase 2) | **Quality is local.** There is no test CI. `.github/workflows/` holds deploy and release workflows only; running the gate is the author's job, on every platform the change touches. @@ -64,7 +66,7 @@ These are not preferences. A change that breaks one is a defect, not a trade-off ## Tech Stack -- Go 1.24+, `CGO_ENABLED=0` +- Go 1.25+, `CGO_ENABLED=0` - Standard library only on Unix; `github.com/Microsoft/go-winio` on Windows - Apache-2.0 diff --git a/CONTRIBUTE.md b/CONTRIBUTE.md index 7771af8..25b3741 100644 --- a/CONTRIBUTE.md +++ b/CONTRIBUTE.md @@ -38,13 +38,27 @@ Quality is local. `.github/workflows/` carries deploy and release workflows only this repository verifies your change for you after you push. ```sh -gofmt -l . # must print nothing -go vet ./... # must be clean -go test -race ./... # must pass +gofmt -l . # must print nothing +go vet ./... # must be clean +go test -race ./... # must pass + +GOOS=darwin GOARCH=arm64 go build ./... # cross-compile every supported platform +GOOS=darwin GOARCH=amd64 go build ./... +GOOS=windows GOARCH=amd64 go build ./... # from Phase 2 on ``` -A change that touches platform-specific code must be run on that platform before merge. Claiming -"CI will catch it" is not available here, by design. +**Cross-compile every supported platform, always.** It needs no hardware, takes a second, and +catches the entire class of "this function does not exist on that OS" — which is exactly how a +`syscall.Getpeereid` that never existed reached `main` once. Compiling is not testing, but a +platform file that does not compile is not a testing gap, it is a broken build. + +A change that touches platform-specific code should be run on that platform before merge. +Claiming "CI will catch it" is not available here, by design. + +**Where you cannot run a platform, say so in the PR.** macOS is currently in that position: the +maintainer has no Mac, so the darwin path is cross-compiled and vetted but never executed. An +honest gap that is written down can be closed by someone who has the hardware; a gap that is +implied to be covered cannot. ## Before You Open a PR diff --git a/GUIDELINES.md b/GUIDELINES.md index ab875fd..21beef5 100644 --- a/GUIDELINES.md +++ b/GUIDELINES.md @@ -2,7 +2,7 @@ ## Tech Stack -- Runtime: Go 1.24+, `CGO_ENABLED=0` for builds +- Runtime: Go 1.25+, `CGO_ENABLED=0` for builds - Language: Go - Dependencies: standard library only on Unix; `github.com/Microsoft/go-winio` on Windows - License: Apache-2.0 diff --git a/HISTORY.md b/HISTORY.md index 19e7957..bc997ed 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -8,8 +8,36 @@ - Design carried over from `four-file-cloud/docs/interprocess-go-concept.md` at D5 (Implementable): six closed API decisions, conformance vectors V1-V4, per-phase acceptance criteria. +- Phase 1 Unix core: `local_socket` package (`Name`, `NameKind`, `Filesystem`, `Namespaced`, + `UserScoped`, `Listen`, `Dial`, `ListenOptions`, `DialOptions`, `AccessPolicy`, + `PeerIdentity`, error sentinels) — Unix domain sockets on Linux and macOS, Decision 2 + runtime-directory resolution with owner/mode validation, the safe six-step stale-socket + cleanup, and peer identity via `SO_PEERCRED` (Linux) / `getpeereid` (macOS) (#1). +- `examples/echo` demonstrating a local echo server and client (#1). + +### Changed +- `ListenOptions.RemoveOnClose` replaced by `KeepOnClose` with inverted meaning (#1). The zero + value now releases the socket on `Close`, so a service restarts on default settings; the old + opt-in meant every caller had to set two options or hit `ErrAlreadyInUse` on restart. It also + matches Go (`net.UnixListener` unlinks a socket it created) and Rust `interprocess`, where name + release is part of local-socket semantics. `ReclaimStale` stays opt-in, deliberately: remove + what this process created, never touch what it did not. + +### Fixed +- `peer_darwin.go` called `syscall.Getpeereid`, which does not exist in the standard library on + any platform — the macOS build never compiled (#1). Replaced with + `golang.org/x/sys/unix.GetsockoptXucred(fd, SOL_LOCAL, LOCAL_PEERCRED)`, the actual macOS + primitive; `struct xucred` carries no PID, so `PeerIdentity.PID` stays zero there as documented. + Adds `golang.org/x/sys` as the single dependency. Cross-compilation for every supported platform + is now part of the documented local gate, because it catches this whole class for free. +- Criterion 1.4 (`ErrNoRuntimeDir`) was unreachable: the test skipped itself whenever + `/run/user/$UID` existed, which is every systemd host (#1). The candidate chain is now injectable + and the test covers both an empty set and a set where every candidate fails validation, plus the + assertion that nothing is written into a rejected directory. ### Technical Details - Module path: `github.com/four-bytes/interprocess-go` -- Go 1.24 minimum; standard library only on Unix, `Microsoft/go-winio` on Windows -- No code yet — Phase 1 (Unix core) is the first implementation issue +- Go 1.25 minimum (raised from 1.24: `golang.org/x/sys` v0.47.0 declares `go 1.25.0`, so a 1.24 + toolchain cannot build this module); `golang.org/x/sys` on Darwin, `Microsoft/go-winio` on Windows +- Phase 1 (Unix core) implemented (#1); Windows (Phase 2), interop (Phase 3) and framing + (Phase 4) remain. diff --git a/README.md b/README.md index eefbe87..6464e04 100644 --- a/README.md +++ b/README.md @@ -46,14 +46,29 @@ remove the only access control this library has on Unix. See [`GUIDELINES.md`](G ## Platform support -| Platform | Transport | Status | -|---|---|---| -| Linux | Unix domain socket | Phase 1 | -| macOS | Unix domain socket | Phase 1 | -| Windows | Named pipe (`go-winio`) | Phase 2 | +| Platform | Transport | Status | Verification | +|---|---|---|---| +| Linux | Unix domain socket | Phase 1 | suite run with `-race` | +| macOS | Unix domain socket | Phase 1 | **compiles and vets only — runtime untested** | +| Windows | Named pipe (`go-winio`) | Phase 2 | — | + +### macOS is untested at runtime + +Be aware of this before depending on the macOS path. The code cross-compiles and vets cleanly for +`darwin/arm64` and `darwin/amd64`, and every cross-platform code path is covered by the suite on +Linux. What has never executed on a Mac: + +- `peerCred` via `getsockopt(SOL_LOCAL, LOCAL_PEERCRED)` — the returned UID and GID are unverified +- the Darwin-only `$TMPDIR` step of the runtime-directory precedence +- socket and directory mode enforcement on APFS + +The maintainer has no macOS hardware. This is stated rather than papered over: a green +cross-compile proves the code exists and type-checks, not that it behaves. Reports from macOS +users are welcome, and a verified run is what moves this row to parity with Linux. -Each platform is verified by running the suite on it. A change to platform-specific code is not -mergeable until it has been run there — see [`CONTRIBUTE.md`](CONTRIBUTE.md). +A change to platform-specific code should be run on that platform before merge — see +[`CONTRIBUTE.md`](CONTRIBUTE.md). Where that is impossible, say so in the PR rather than implying +coverage that does not exist. ## Framing diff --git a/ROADMAP.md b/ROADMAP.md index c0ce3d1..af3e90d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -21,7 +21,8 @@ rather than an option. Small enough to audit in an afternoon. - [ ] Stale-socket reclaim, restricted to owned sockets - [ ] `PeerIdentity` via `SO_PEERCRED` / `getpeereid` - [ ] `examples/echo` -- [ ] Suite verified locally on Linux and macOS with `-race` +- [x] Suite verified locally on Linux with `-race` +- [ ] macOS runtime verification — blocked, no hardware; cross-compile and vet are green 14 acceptance criteria — see `docs/TESTING.md`. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2caeabf..6d2b9c8 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -175,7 +175,7 @@ type ListenOptions struct { Access AccessPolicy RuntimeDir string ReclaimStale bool - RemoveOnClose bool + KeepOnClose bool PipeSecurity *PipeSecurity MaxInstances int } @@ -219,7 +219,7 @@ Before binding to a file socket: 5. Bind the UDS. 6. Set and verify restrictive mode bits. -On `Close()` the listener should remove its own socket file. Rust `interprocess` also treats automatic name release on listener drop as part of local socket semantics; the Go variant should satisfy that expectation with an explicit `Close()`. +On `Close()` the listener removes its own socket file by default (`KeepOnClose` opts out). Rust `interprocess` also treats automatic name release on listener drop as part of local socket semantics; the Go variant should satisfy that expectation with an explicit `Close()`. ### Windows Security diff --git a/docs/TESTING.md b/docs/TESTING.md index b6d3b97..7d467ae 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -125,17 +125,17 @@ criterion. | 1.1 | `Listen` and `Dial` work for `Filesystem`, `Namespaced` and `UserScoped` on Linux and macOS. | | 1.2 | Name validation matches vector **V3** exactly, including the 200-character case and the Decision 6 truncation output. | | 1.3 | Runtime directory resolution follows Decision 2 in order, and **every** candidate is rejected unless it exists, is a directory, is owned by the current UID, and has no group or world write bit. A test sets `$TMPDIR` to a `0777` directory on Linux and asserts it is skipped. | -| 1.4 | With no valid candidate, `Listen` fails with `ErrNoRuntimeDir` and creates nothing. | +| 1.4 | With no valid candidate, `Listen` fails with `ErrNoRuntimeDir` and creates nothing. The candidate chain is injected in the test — driving it through the environment leaves `/run/user/$UID` in the list on every systemd host, so the test would skip itself exactly where it matters. | | 1.5 | After `Listen`, the runtime directory is mode `0700` and the socket file is mode `0600`, asserted by `os.Stat`. | | 1.6 | Stale-socket reclaim follows the documented six steps. A test writes a **regular file** at the socket path and asserts `ErrStaleCleanupUnsafe` — only an owned socket is ever removed. | -| 1.7 | `Close()` removes the listener's own socket file; a second `Listen` on the same name then succeeds. | +| 1.7 | `Close()` removes the listener's own socket file **with no options set**; a second `Listen` on the same name then succeeds. `KeepOnClose` opts out. A default that cannot restart is a defect, not caution. | | 1.8 | `Close()` causes a blocked `Accept()` to return, and the returned error satisfies `errors.Is(err, net.ErrClosed)`. | | 1.9 | `Dial` honours context cancellation and `DialOptions.Timeout`; a cancelled dial returns promptly and leaks no goroutine (`goleak` or an equivalent check). | | 1.10 | `PeerIdentity()` returns the correct UID and GID (`SO_PEERCRED` on Linux, `getpeereid` on macOS). | | 1.11 | Echo across 64 concurrent clients, 1 MiB per client, with no data corruption and no race under `-race`. | | 1.12 | Restart after a simulated crash (process killed, socket file left behind) succeeds. | | 1.13 | `examples/echo` builds and runs on both platforms. | -| 1.14 | Suite verified locally on Linux and macOS with `-race` enabled. | +| 1.14 | Suite verified locally on Linux with `-race`. macOS: cross-compiled and vetted for `darwin/arm64` and `darwin/amd64`; **runtime behaviour unverified** — no macOS hardware available. See the platform matrix note below. | ### Phase 2: Windows diff --git a/examples/echo/main.go b/examples/echo/main.go new file mode 100644 index 0000000..115ca85 --- /dev/null +++ b/examples/echo/main.go @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 Four Bytes + +// Command echo demonstrates interprocess-go/local_socket with a simple +// request/echo loop: a listener echoes every byte it receives back to the +// sender. +// +// go run ./examples/echo -mode server +// go run ./examples/echo -mode client +package main + +import ( + "context" + "flag" + "io" + "log" + "os" + + localsocket "github.com/four-bytes/interprocess-go/local_socket" +) + +func main() { + mode := flag.String("mode", "server", "server or client") + name := flag.String("name", "echo", "socket identifier (UserScoped)") + runtimeDir := flag.String("runtime-dir", "", "explicit runtime directory (server only)") + flag.Parse() + + switch *mode { + case "server": + runServer(*name, *runtimeDir) + case "client": + runClient(*name) + default: + log.Fatalf("unknown mode %q (want server or client)", *mode) + } +} + +func runServer(name, runtimeDir string) { + ln, err := localsocket.Listen( + localsocket.UserScoped(name), + localsocket.ListenOptions{RuntimeDir: runtimeDir, ReclaimStale: true}, + ) + if err != nil { + log.Fatal(err) + } + defer ln.Close() + log.Printf("listening on %s", ln.Addr()) + + for { + c, err := ln.Accept() + if err != nil { + log.Fatal(err) + } + go func(c io.ReadWriteCloser) { + defer c.Close() + if _, err := io.Copy(c, c); err != nil { + log.Printf("echo: %v", err) + } + }(c) + } +} + +func runClient(name string) { + // The client resolves the name through the default runtime directory, so + // both sides meet on the same endpoint. For a custom server runtime + // directory, pass the exact path the server printed instead of a name. + c, err := localsocket.Dial(context.Background(), localsocket.UserScoped(name), localsocket.DialOptions{}) + if err != nil { + log.Fatal(err) + } + defer c.Close() + + msg := []byte("hello over a local socket\n") + if _, err := c.Write(msg); err != nil { + log.Fatal(err) + } + // Read exactly the echoed bytes back. The server keeps the connection open + // for further messages, so copying until EOF would block forever. + buf := make([]byte, len(msg)) + if _, err := io.ReadFull(c, buf); err != nil { + log.Fatal(err) + } + if _, err := os.Stdout.Write(buf); err != nil { + log.Fatal(err) + } +} diff --git a/go.mod b/go.mod index ea3978e..4837f77 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module github.com/four-bytes/interprocess-go -go 1.24 +go 1.25.0 + +require golang.org/x/sys v0.47.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..37ee2d4 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/local_socket/cleanup_unix.go b/local_socket/cleanup_unix.go new file mode 100644 index 0000000..50449c6 --- /dev/null +++ b/local_socket/cleanup_unix.go @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 Four Bytes + +//go:build unix + +package localsocket + +import ( + "errors" + "io/fs" + "net" + "os" + "syscall" + "time" +) + +// staleDialTimeout bounds the liveness probe when reclaiming a stale socket. +const staleDialTimeout = 200 * time.Millisecond + +// prepareSocketPath implements steps 3 and 4 of the documented six-step +// cleanup: if an entry exists at the socket path, validate its type and owner, +// then remove only a socket file that is owned by us and provably stale. A +// regular file, directory, symlink or foreign-owned socket is never removed. +func prepareSocketPath(path string, reclaimStale bool) error { + info, err := os.Lstat(path) + if errors.Is(err, fs.ErrNotExist) { + return nil // step 3: nothing there, bind directly + } + if err != nil { + return err + } + + // Step 3: validate file type and owner. Never follow a symlink. + st, ok := info.Sys().(*syscall.Stat_t) + if !ok || info.Mode()&os.ModeSocket == 0 || int(st.Uid) != os.Getuid() { + return ErrStaleCleanupUnsafe + } + + // Step 4: an owned socket. Reclaim only if requested; the liveness probe + // distinguishes a live listener from a stale one. + if !reclaimStale { + return ErrAlreadyInUse + } + conn, err := net.DialTimeout("unix", path, staleDialTimeout) + if err == nil { + conn.Close() + return ErrAlreadyInUse + } + return os.Remove(path) +} diff --git a/local_socket/errors.go b/local_socket/errors.go new file mode 100644 index 0000000..9290d10 --- /dev/null +++ b/local_socket/errors.go @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 Four Bytes + +// Package localsocket provides local, connection-oriented byte streams over a +// unified API: Unix domain sockets on Linux and macOS, named pipes on Windows. +// It abstracts the local transport only; framing, discovery and process +// management belong to the application. +package localsocket + +import "errors" + +// Sentinel errors. Every one is inspectable via errors.Is; the library never +// swallows a security-relevant underlying error. +var ( + // ErrInvalidName reports a name that failed validation (empty, wrong + // character set, or containing path separators). + ErrInvalidName = errors.New("invalid local socket name") + + // ErrUnsupportedName reports a name kind that the current platform does + // not support (for example, a filesystem path on Windows). + ErrUnsupportedName = errors.New("unsupported name type on this platform") + + // ErrPermissionDenied reports that the endpoint could not be reached or + // bound because of a permission failure. + ErrPermissionDenied = errors.New("local socket permission denied") + + // ErrAlreadyInUse reports that the endpoint already has a live listener + // (or an entry that Listen is not permitted to reclaim). + ErrAlreadyInUse = errors.New("local socket already in use") + + // ErrStaleCleanupUnsafe reports that an entry exists at the socket path + // which stale cleanup refuses to remove: a regular file, a directory, a + // symbolic link, or a socket not owned by the current UID. + ErrStaleCleanupUnsafe = errors.New("refusing unsafe stale socket cleanup") + + // ErrNoRuntimeDir reports that no runtime-directory candidate passed + // ownership and mode validation. Nothing is created in this case. + ErrNoRuntimeDir = errors.New("no runtime directory passed ownership and mode validation") + + // ErrPeerIdentityUnsupported reports that peer credentials are not + // available on this platform. Callers must handle it rather than reading + // the zero value of PeerIdentity as "nobody". + ErrPeerIdentityUnsupported = errors.New("peer identity unavailable on this platform") +) diff --git a/local_socket/listener.go b/local_socket/listener.go new file mode 100644 index 0000000..41810e5 --- /dev/null +++ b/local_socket/listener.go @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 Four Bytes + +package localsocket + +import "net" + +// Listener is the interface returned by Listen. It embeds net.Listener and adds +// the name the listener was created with. Every value returned by Listen +// satisfies it. +type Listener interface { + net.Listener + + // LocalSocketName returns the Name the listener was created with. + LocalSocketName() Name +} diff --git a/local_socket/local_socket_unix.go b/local_socket/local_socket_unix.go new file mode 100644 index 0000000..5dd8d12 --- /dev/null +++ b/local_socket/local_socket_unix.go @@ -0,0 +1,151 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 Four Bytes + +//go:build unix + +package localsocket + +import ( + "context" + "errors" + "fmt" + "net" + "os" + "sync/atomic" + "syscall" +) + +// Listen creates a listener for name and returns it as a net.Listener. The +// returned value also implements Listener (LocalSocketName). The runtime +// directory and socket are created with restrictive modes (0700 / 0600), and +// stale cleanup only ever removes an owned, provably-stale socket. +func Listen(name Name, options ListenOptions) (net.Listener, error) { + path, err := resolveListenPath(name, options) + if err != nil { + return nil, err + } + + if err := prepareSocketPath(path, options.ReclaimStale); err != nil { + return nil, err + } + + ln, err := net.Listen("unix", path) + if err != nil { + if errors.Is(err, syscall.EADDRINUSE) { + return nil, fmt.Errorf("%w: %v", ErrAlreadyInUse, err) + } + if errors.Is(err, syscall.EACCES) || errors.Is(err, syscall.EPERM) { + return nil, fmt.Errorf("%w: %v", ErrPermissionDenied, err) + } + return nil, err + } + + // Go's UnixListener unlinks a socket it created when Close is called, + // which is the behaviour we want by default. KeepOnClose opts out. + ul := ln.(*net.UnixListener) + ul.SetUnlinkOnClose(!options.KeepOnClose) + + // Step 6: set restrictive mode bits and keep them. + if err := os.Chmod(path, 0o600); err != nil { + ln.Close() + os.Remove(path) + return nil, err + } + + return &unixListener{ln: ul, name: name}, nil +} + +// resolveListenPath resolves name to a filesystem socket path, creating and +// validating the private runtime directory for non-filesystem names. +func resolveListenPath(name Name, options ListenOptions) (string, error) { + if name.Kind == NameFilesystem { + return name.Value, nil + } + if err := validateIdentifier(name.Value); err != nil { + return "", err + } + base, err := resolveRuntimeDir(options.RuntimeDir) + if err != nil { + return "", err + } + dir := base + "/interprocess-go" + if err := ensurePrivateDir(dir); err != nil { + return "", err + } + return joinSocketPath(dir, maybeTruncate(name.Value, dir)), nil +} + +// resolveDialPath resolves name for Dial: identical resolution to Listen but +// without creating anything (the listener owns directory creation). +func resolveDialPath(name Name) (string, error) { + if name.Kind == NameFilesystem { + return name.Value, nil + } + if err := validateIdentifier(name.Value); err != nil { + return "", err + } + base, err := resolveRuntimeDir("") + if err != nil { + return "", err + } + dir := base + "/interprocess-go" + return joinSocketPath(dir, maybeTruncate(name.Value, dir)), nil +} + +// Dial connects to name, honouring context cancellation and Timeout. The +// returned value also implements Conn (PeerIdentity). +func Dial(ctx context.Context, name Name, options DialOptions) (net.Conn, error) { + path, err := resolveDialPath(name) + if err != nil { + return nil, err + } + d := net.Dialer{Timeout: options.Timeout} + c, err := d.DialContext(ctx, "unix", path) + if err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return nil, err + } + if errors.Is(err, syscall.EACCES) || errors.Is(err, syscall.EPERM) { + return nil, fmt.Errorf("%w: %v", ErrPermissionDenied, err) + } + return nil, err + } + return &unixConn{UnixConn: c.(*net.UnixConn)}, nil +} + +// unixListener wraps *net.UnixListener to add LocalSocketName and to make a +// closed listener report net.ErrClosed from Accept. +type unixListener struct { + ln *net.UnixListener + name Name + closed atomic.Bool +} + +func (l *unixListener) Accept() (net.Conn, error) { + c, err := l.ln.AcceptUnix() + if err != nil { + if l.closed.Load() || errors.Is(err, net.ErrClosed) { + return nil, net.ErrClosed + } + return nil, err + } + return &unixConn{UnixConn: c}, nil +} + +func (l *unixListener) Close() error { + l.closed.Store(true) + return l.ln.Close() +} + +func (l *unixListener) Addr() net.Addr { + return l.ln.Addr() +} + +func (l *unixListener) LocalSocketName() Name { + return l.name +} + +// unixConn wraps *net.UnixConn to add PeerIdentity. +type unixConn struct { + *net.UnixConn +} diff --git a/local_socket/local_socket_unix_test.go b/local_socket/local_socket_unix_test.go new file mode 100644 index 0000000..604c7da --- /dev/null +++ b/local_socket/local_socket_unix_test.go @@ -0,0 +1,511 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 Four Bytes + +//go:build unix + +package localsocket + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" + "time" +) + +// echoServer starts an echo listener and returns a stop function. Each accepted +// connection is echoed back until the client closes. +func echoServer(t *testing.T, ln net.Listener) { + t.Helper() + go func() { + for { + c, err := ln.Accept() + if err != nil { + return // closed + } + go func(c net.Conn) { + defer c.Close() + _, _ = io.Copy(c, c) + }(c) + } + }() +} + +func TestEchoUserScoped(t *testing.T) { + ln, err := Listen(UserScoped("echo"), ListenOptions{ReclaimStale: true}) + if err != nil { + t.Fatal(err) + } + defer ln.Close() + if l, ok := ln.(Listener); !ok || l.LocalSocketName() != UserScoped("echo") { + t.Fatal("Listen must return a Listener reporting its name") + } + echoServer(t, ln) + + c, err := Dial(context.Background(), UserScoped("echo"), DialOptions{}) + if err != nil { + t.Fatal(err) + } + defer c.Close() + if _, ok := c.(Conn); !ok { + t.Fatal("Dial must return a Conn") + } + + msg := []byte("hello over a local socket") + if _, err := c.Write(msg); err != nil { + t.Fatal(err) + } + got := make([]byte, len(msg)) + if _, err := io.ReadFull(c, got); err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, msg) { + t.Fatalf("echo = %q, want %q", got, msg) + } +} + +func TestEchoNamespaced(t *testing.T) { + ln, err := Listen(Namespaced("nsecho"), ListenOptions{ReclaimStale: true}) + if err != nil { + t.Fatal(err) + } + defer ln.Close() + echoServer(t, ln) + + c, err := Dial(context.Background(), Namespaced("nsecho"), DialOptions{}) + if err != nil { + t.Fatal(err) + } + defer c.Close() + if _, err := c.Write([]byte("ns")); err != nil { + t.Fatal(err) + } + got := make([]byte, 2) + if _, err := io.ReadFull(c, got); err != nil { + t.Fatal(err) + } + if string(got) != "ns" { + t.Fatalf("echo = %q", got) + } +} + +func TestEchoFilesystem(t *testing.T) { + path := filepath.Join(t.TempDir(), "fs.sock") + ln, err := Listen(Filesystem(path), ListenOptions{}) + if err != nil { + t.Fatal(err) + } + defer ln.Close() + echoServer(t, ln) + + c, err := Dial(context.Background(), Filesystem(path), DialOptions{}) + if err != nil { + t.Fatal(err) + } + defer c.Close() + if _, err := c.Write([]byte("fs")); err != nil { + t.Fatal(err) + } + got := make([]byte, 2) + if _, err := io.ReadFull(c, got); err != nil { + t.Fatal(err) + } + if string(got) != "fs" { + t.Fatalf("echo = %q", got) + } +} + +func TestRestrictiveModes(t *testing.T) { + base := t.TempDir() + ln, err := Listen(UserScoped("modes"), ListenOptions{RuntimeDir: base}) + if err != nil { + t.Fatal(err) + } + defer ln.Close() + + sub := filepath.Join(base, "interprocess-go") + di, err := os.Stat(sub) + if err != nil { + t.Fatal(err) + } + if di.Mode().Perm() != 0o700 { + t.Fatalf("runtime dir mode = %o, want 700", di.Mode().Perm()) + } + si, err := os.Stat(filepath.Join(sub, "modes.sock")) + if err != nil { + t.Fatal(err) + } + if si.Mode().Perm() != 0o600 { + t.Fatalf("socket mode = %o, want 600", si.Mode().Perm()) + } +} + +func TestStaleCleanupRefusesRegularFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "file.sock") + if err := os.WriteFile(path, []byte("not a socket"), 0o600); err != nil { + t.Fatal(err) + } + _, err := Listen(Filesystem(path), ListenOptions{ReclaimStale: true}) + if !errors.Is(err, ErrStaleCleanupUnsafe) { + t.Fatalf("Listen = %v, want ErrStaleCleanupUnsafe", err) + } + if _, err := os.Stat(path); err != nil { + t.Fatalf("regular file must not be removed: %v", err) + } +} + +func TestStaleCleanupRefusesSymlink(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "target") + if err := os.WriteFile(target, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, "link.sock") + if err := os.Symlink(target, path); err != nil { + t.Fatal(err) + } + _, err := Listen(Filesystem(path), ListenOptions{ReclaimStale: true}) + if !errors.Is(err, ErrStaleCleanupUnsafe) { + t.Fatalf("Listen = %v, want ErrStaleCleanupUnsafe", err) + } +} + +func TestRestartAfterCrash(t *testing.T) { + path := filepath.Join(t.TempDir(), "crash.sock") + + // Simulate a crashed listener: bind, then close without unlinking. + // net.Listen("unix", ...) unlinks on Close, so use ListenUnix with + // unlink-on-close disabled — the socket file then remains behind exactly + // as after a SIGKILL. + laddr := &net.UnixAddr{Name: path, Net: "unix"} + raw, err := net.ListenUnix("unix", laddr) + if err != nil { + t.Fatal(err) + } + raw.SetUnlinkOnClose(false) + raw.Close() + if _, err := os.Stat(path); err != nil { + t.Fatalf("socket file should remain after crash: %v", err) + } + + ln, err := Listen(Filesystem(path), ListenOptions{ReclaimStale: true}) + if err != nil { + t.Fatalf("restart after crash failed: %v", err) + } + ln.Close() + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("socket file should be removed on close, err=%v", err) + } +} + +func TestAlreadyInUse(t *testing.T) { + path := filepath.Join(t.TempDir(), "busy.sock") + ln1, err := Listen(Filesystem(path), ListenOptions{}) + if err != nil { + t.Fatal(err) + } + defer ln1.Close() + + _, err = Listen(Filesystem(path), ListenOptions{ReclaimStale: true}) + if !errors.Is(err, ErrAlreadyInUse) { + t.Fatalf("second Listen = %v, want ErrAlreadyInUse", err) + } +} + +func TestCloseUnblocksAccept(t *testing.T) { + path := filepath.Join(t.TempDir(), "close.sock") + ln, err := Listen(Filesystem(path), ListenOptions{}) + if err != nil { + t.Fatal(err) + } + + errCh := make(chan error, 1) + go func() { + _, err := ln.Accept() + errCh <- err + }() + time.Sleep(50 * time.Millisecond) + ln.Close() + + select { + case err := <-errCh: + if !errors.Is(err, net.ErrClosed) { + t.Fatalf("Accept after Close = %v, want net.ErrClosed", err) + } + case <-time.After(2 * time.Second): + t.Fatal("Accept did not unblock on Close") + } +} + +func TestCloseRemovesSocket(t *testing.T) { + path := filepath.Join(t.TempDir(), "remove.sock") + ln1, err := Listen(Filesystem(path), ListenOptions{}) + if err != nil { + t.Fatal(err) + } + if err := ln1.Close(); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("socket file should be removed on Close, err=%v", err) + } + + // A second Listen on the same name now succeeds without reclaim. + ln2, err := Listen(Filesystem(path), ListenOptions{}) + if err != nil { + t.Fatalf("second Listen failed: %v", err) + } + ln2.Close() +} + +func TestDialContextCancel(t *testing.T) { + path := filepath.Join(t.TempDir(), "cancel.sock") + before := runtime.NumGoroutine() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + start := time.Now() + _, err := Dial(ctx, Filesystem(path), DialOptions{}) + if !errors.Is(err, context.Canceled) { + t.Fatalf("Dial = %v, want context.Canceled", err) + } + if elapsed := time.Since(start); elapsed > time.Second { + t.Fatalf("cancelled dial not prompt: %v", elapsed) + } + + time.Sleep(100 * time.Millisecond) + if after := runtime.NumGoroutine(); after > before+2 { + t.Fatalf("possible goroutine leak: before=%d after=%d", before, after) + } +} + +func TestDialContextDeadline(t *testing.T) { + path := filepath.Join(t.TempDir(), "deadline.sock") + ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(10*time.Millisecond)) + defer cancel() + time.Sleep(20 * time.Millisecond) + _, err := Dial(ctx, Filesystem(path), DialOptions{}) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("Dial = %v, want context.DeadlineExceeded", err) + } +} + +func TestDialTimeoutOption(t *testing.T) { + path := filepath.Join(t.TempDir(), "timeout.sock") + ln, err := Listen(Filesystem(path), ListenOptions{}) + if err != nil { + t.Fatal(err) + } + defer ln.Close() + + c, err := Dial(context.Background(), Filesystem(path), DialOptions{Timeout: 5 * time.Second}) + if err != nil { + t.Fatalf("dial with generous timeout: %v", err) + } + c.Close() +} + +func TestPeerIdentity(t *testing.T) { + path := filepath.Join(t.TempDir(), "peer.sock") + ln, err := Listen(Filesystem(path), ListenOptions{}) + if err != nil { + t.Fatal(err) + } + defer ln.Close() + + accepted := make(chan net.Conn, 1) + go func() { + c, err := ln.Accept() + if err != nil { + return + } + accepted <- c + }() + + c, err := Dial(context.Background(), Filesystem(path), DialOptions{}) + if err != nil { + t.Fatal(err) + } + defer c.Close() + + id, err := c.(Conn).PeerIdentity() + if err != nil { + t.Fatalf("PeerIdentity: %v", err) + } + if id.UID != uint32(os.Getuid()) { + t.Fatalf("UID = %d, want %d", id.UID, os.Getuid()) + } + if id.GID != uint32(os.Getgid()) { + t.Fatalf("GID = %d, want %d", id.GID, os.Getgid()) + } + switch runtime.GOOS { + case "linux": + if id.PID != os.Getpid() { + t.Fatalf("PID = %d, want %d", id.PID, os.Getpid()) + } + case "darwin": + if id.PID != 0 { + t.Fatalf("darwin must not report PID, got %d", id.PID) + } + } + + srv := <-accepted + defer srv.Close() + sid, err := srv.(Conn).PeerIdentity() + if err != nil { + t.Fatalf("server PeerIdentity: %v", err) + } + if sid.UID != uint32(os.Getuid()) { + t.Fatalf("server UID = %d, want %d", sid.UID, os.Getuid()) + } +} + +func TestConcurrentEcho64x1MiB(t *testing.T) { + path := filepath.Join(t.TempDir(), "echo.sock") + ln, err := Listen(Filesystem(path), ListenOptions{}) + if err != nil { + t.Fatal(err) + } + + serverDone := make(chan struct{}) + go func() { + defer close(serverDone) + for { + c, err := ln.Accept() + if err != nil { + return + } + go func(c net.Conn) { + defer c.Close() + data, err := io.ReadAll(c) + if err != nil { + return + } + _, _ = c.Write(data) + }(c) + } + }() + + const clients = 64 + const size = 1 << 20 + var wg sync.WaitGroup + errCh := make(chan error, clients) + wg.Add(clients) + for i := 0; i < clients; i++ { + go func(i int) { + defer wg.Done() + c, err := Dial(context.Background(), Filesystem(path), DialOptions{}) + if err != nil { + errCh <- err + return + } + defer c.Close() + + payload := make([]byte, size) + for j := range payload { + payload[j] = byte(i*31 + j) + } + if _, err := c.Write(payload); err != nil { + errCh <- err + return + } + // Half-close the write side so the server's ReadAll sees EOF; this + // lets the echo complete without a write/read deadlock. + if cw, ok := c.(interface{ CloseWrite() error }); ok { + if err := cw.CloseWrite(); err != nil { + errCh <- err + return + } + } + got := make([]byte, size) + if _, err := io.ReadFull(c, got); err != nil { + errCh <- err + return + } + if !bytes.Equal(got, payload) { + errCh <- fmt.Errorf("client %d: echoed data corrupted", i) + } + }(i) + } + wg.Wait() + close(errCh) + for err := range errCh { + if err != nil { + t.Fatal(err) + } + } + + ln.Close() + <-serverDone +} + +// TestCloseRemovesSocketByDefault covers criterion 1.7: Close releases the +// listener's own socket file and a second Listen on the same name succeeds -- +// with no options set. A service that cannot restart on default settings is a +// defect, not a conservative default. +func TestCloseRemovesSocketByDefault(t *testing.T) { + path := filepath.Join(t.TempDir(), "restart.sock") + ln, err := Listen(Filesystem(path), ListenOptions{}) + if err != nil { + t.Fatal(err) + } + if err := ln.Close(); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("socket file must be gone after Close by default, err=%v", err) + } + + ln2, err := Listen(Filesystem(path), ListenOptions{}) + if err != nil { + t.Fatalf("second Listen after a clean Close must succeed without options: %v", err) + } + ln2.Close() +} + +// TestKeepOnCloseLeavesSocket covers the opt-out: KeepOnClose leaves the file +// for a supervisor or socket-activation setup that owns the endpoint. +func TestKeepOnCloseLeavesSocket(t *testing.T) { + path := filepath.Join(t.TempDir(), "keep.sock") + ln, err := Listen(Filesystem(path), ListenOptions{KeepOnClose: true}) + if err != nil { + t.Fatal(err) + } + if err := ln.Close(); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(path); err != nil { + t.Fatalf("socket file must remain when KeepOnClose is set: %v", err) + } + if _, err := Listen(Filesystem(path), ListenOptions{}); !errors.Is(err, ErrAlreadyInUse) { + t.Fatalf("want ErrAlreadyInUse without ReclaimStale, got %v", err) + } + ln2, err := Listen(Filesystem(path), ListenOptions{ReclaimStale: true}) + if err != nil { + t.Fatalf("reclaim of a kept socket failed: %v", err) + } + ln2.Close() +} + +func TestLongNameResolution(t *testing.T) { + base := t.TempDir() + long := strings.Repeat("a", 200) + ln, err := Listen(UserScoped(long), ListenOptions{RuntimeDir: base}) + if err != nil { + t.Fatal(err) + } + defer ln.Close() + + want := filepath.Join(base, "interprocess-go", "aaaaaaaaaaaaaaaa-c2a908d9.sock") + if got := ln.Addr().String(); got != want { + t.Fatalf("resolved path = %q, want %q", got, want) + } +} diff --git a/local_socket/name.go b/local_socket/name.go new file mode 100644 index 0000000..b3507b9 --- /dev/null +++ b/local_socket/name.go @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 Four Bytes + +package localsocket + +import ( + "crypto/sha256" + "encoding/hex" + "runtime" +) + +// NameKind distinguishes how a Name is resolved to a platform endpoint. +type NameKind uint8 + +const ( + // NameFilesystem is an explicit filesystem path (Unix domain socket path). + NameFilesystem NameKind = iota + + // NameNamespaced is a platform-neutral local identifier resolved under a + // validated runtime directory. + NameNamespaced +) + +// Name is a portable local-socket endpoint. Applications express intent with a +// constructor and let the platform implementation resolve it. +type Name struct { + Kind NameKind + Value string +} + +// Filesystem returns a Name for an explicit Unix socket path. It is intended +// for Unix-specific or controlled deployments. +func Filesystem(path string) Name { + return Name{Kind: NameFilesystem, Value: path} +} + +// Namespaced returns a platform-neutral local name resolved under the +// validated runtime directory (see ARCHITECTURE.md, Decision 2). +func Namespaced(identifier string) Name { + return Name{Kind: NameNamespaced, Value: identifier} +} + +// UserScoped returns a name scoped to the current user. On Unix it resolves to +// the same path as Namespaced; on Windows it is additionally scoped to the +// user SID. It is the recommended default for desktop agents. +func UserScoped(identifier string) Name { + return Name{Kind: NameNamespaced, Value: identifier} +} + +// validateIdentifier enforces the V3 name rules: non-empty and ASCII +// [A-Za-z0-9._-] only. Length is not rejected here — an over-long identifier +// is truncated by the Decision 6 rule at resolution time. +func validateIdentifier(identifier string) error { + if identifier == "" { + return ErrInvalidName + } + for i := 0; i < len(identifier); i++ { + c := identifier[i] + if !((c >= 'a' && c <= 'z') || + (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || + c == '.' || c == '_' || c == '-') { + return ErrInvalidName + } + } + return nil +} + +// truncateIdentifier implements the Decision 6 long-name rule: when a resolved +// endpoint would exceed the platform limit, the identifier is replaced by the +// first 16 bytes of the identifier, a hyphen, and the first 8 lowercase hex +// digits of sha256(full identifier). Deterministic across processes and +// platforms; pinned by conformance vector V3. +func truncateIdentifier(identifier string) string { + prefix := identifier + if len(prefix) > 16 { + prefix = prefix[:16] + } + sum := sha256.Sum256([]byte(identifier)) + return prefix + "-" + hex.EncodeToString(sum[:4]) +} + +// maybeTruncate applies the Decision 6 rule only when the fully resolved socket +// path would exceed the platform sun_path limit. +func maybeTruncate(identifier, dir string) string { + if len(joinSocketPath(dir, identifier)) <= maxSocketPathLength() { + return identifier + } + return truncateIdentifier(identifier) +} + +// maxSocketPathLength returns the usable sun_path length: 108 bytes on Linux +// and 104 on macOS, each minus one for the terminating NUL. +func maxSocketPathLength() int { + if runtime.GOOS == "darwin" { + return 103 + } + return 107 +} + +// joinSocketPath joins a directory and identifier into the socket path shape +// pinned by vector V3: