From 1cf5014c8fabe81c1e1cedf9fcb4d704d0962198 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 21 Oct 2026 20:39:12 +1100 Subject: [PATCH 01/58] server: resolve runtime paths from an injected path model - Add internal/runtimepaths with the startup-resolved path model: the config file, the state root and the log directory, plus the derived jobs state, download history and mirror target files. - Resolve the model once and install it process-wide, so a supervised instance never depends on the working directory, HOME or the user profile of the account the supervisor happens to run it as. - Keep the interactive resolution per-call and byte-identical to the previous behaviour: an existing config next to the launch/executable directory still wins over the per-user config directory, and the per-user directory is still where new saves go. - Route the config file, the jobs state, the download history and the mirror targets of internal/server through the model, so one instance keeps a single coherent state identity. - Add internal/logsink, a size-bounded rotating log file writer, so a supervised process can keep its own diagnostics without unbounded growth. Co-Authored-By: opencode --- internal/logsink/logsink.go | 171 ++++++++++++ internal/logsink/logsink_test.go | 289 +++++++++++++++++++++ internal/runtimepaths/runtimepaths.go | 168 ++++++++++++ internal/runtimepaths/runtimepaths_test.go | 270 +++++++++++++++++++ internal/server/api.go | 3 +- internal/server/config.go | 59 +---- internal/server/mirror.go | 17 +- internal/server/runtimepaths_test.go | 173 ++++++++++++ internal/server/state.go | 6 +- 9 files changed, 1094 insertions(+), 62 deletions(-) create mode 100644 internal/logsink/logsink.go create mode 100644 internal/logsink/logsink_test.go create mode 100644 internal/runtimepaths/runtimepaths.go create mode 100644 internal/runtimepaths/runtimepaths_test.go create mode 100644 internal/server/runtimepaths_test.go diff --git a/internal/logsink/logsink.go b/internal/logsink/logsink.go new file mode 100644 index 00000000..1b05bdf9 --- /dev/null +++ b/internal/logsink/logsink.go @@ -0,0 +1,171 @@ +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 + +// Package logsink provides a small size-bounded rotating log file writer. +// +// A supervised HFDesk process writes its own log file. The sink keeps the +// on-disk size bounded by rotating the active file into a fixed number of +// generations and discarding the oldest one. +package logsink + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "sync" +) + +const ( + // MaxFileSize is the maximum size of a single log file. A write that + // would exceed it rotates the file first. + MaxFileSize int64 = 2 << 20 // 2 MiB + // MaxRotatedFiles is the number of rotated generations kept besides the + // active file: hfdesk.log, hfdesk.log.1, hfdesk.log.2, hfdesk.log.3. + MaxRotatedFiles = 3 + // FileName is the name of the active log file inside the log directory. + FileName = "hfdesk.log" + // DirMode is the permission mode of the log directory. + DirMode os.FileMode = 0o700 + // FileMode is the permission mode of a log file. + FileMode os.FileMode = 0o600 +) + +// Sink is a size-bounded rotating file writer. Safe for concurrent use. +type Sink struct { + mu sync.Mutex + path string + file *os.File + size int64 +} + +// Open creates dir (0700) if needed and opens /hfdesk.log (0600). +// An existing directory or file is tightened to the same restrictive modes. +func Open(dir string) (*Sink, error) { + if dir == "" { + return nil, errors.New("logsink: log directory is required") + } + if err := os.MkdirAll(dir, DirMode); err != nil { + return nil, fmt.Errorf("logsink: create log dir: %w", err) + } + if err := os.Chmod(dir, DirMode); err != nil { + return nil, fmt.Errorf("logsink: restrict log dir: %w", err) + } + + path := filepath.Join(dir, FileName) + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_APPEND, FileMode) + if err != nil { + return nil, fmt.Errorf("logsink: open log file: %w", err) + } + if err := file.Chmod(FileMode); err != nil { + _ = file.Close() + return nil, fmt.Errorf("logsink: restrict log file: %w", err) + } + + s := &Sink{path: path, file: file} + if info, err := file.Stat(); err == nil { + s.size = info.Size() + } + return s, nil +} + +// Write implements io.Writer under a mutex. The active file is rotated +// before a write that would exceed MaxFileSize. A single write larger than +// the limit is written whole instead of being split. +func (s *Sink) Write(p []byte) (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + + if s.file == nil { + return 0, os.ErrClosed + } + if len(p) == 0 { + return 0, nil + } + // An empty active file has nothing to rotate: an oversized single write + // is written whole rather than looping forever. + if s.size > 0 && s.size+int64(len(p)) > MaxFileSize { + if err := s.rotate(); err != nil { + return 0, err + } + } + + n, err := s.file.Write(p) + s.size += int64(n) + return n, err +} + +// Path returns the active log file path. +func (s *Sink) Path() string { + s.mu.Lock() + defer s.mu.Unlock() + return s.path +} + +// Close flushes and closes the file. +func (s *Sink) Close() error { + s.mu.Lock() + defer s.mu.Unlock() + + if s.file == nil { + return nil + } + err := s.file.Close() + s.file = nil + return err +} + +// rotate shifts the generations: the active file becomes .1, .1 becomes .2 +// and .2 becomes .3. The oldest generation is discarded, so the on-disk size +// stays bounded. The caller must hold the mutex. +func (s *Sink) rotate() error { + if err := os.Remove(s.generation(MaxRotatedFiles)); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("logsink: remove oldest log: %w", err) + } + for i := MaxRotatedFiles - 1; i >= 1; i-- { + if err := os.Rename(s.generation(i), s.generation(i+1)); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("logsink: rotate log generation %d: %w", i, err) + } + } + + if err := s.file.Close(); err != nil { + return fmt.Errorf("logsink: close log before rotation: %w", err) + } + s.file = nil + + if err := os.Rename(s.path, s.generation(1)); err != nil && !errors.Is(err, os.ErrNotExist) { + // The active file was not replaced: keep the sink usable. + s.reopenActive() + return fmt.Errorf("logsink: rotate log: %w", err) + } + + file, err := os.OpenFile(s.path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC|os.O_APPEND, FileMode) + if err != nil { + return fmt.Errorf("logsink: reopen log after rotation: %w", err) + } + if err := file.Chmod(FileMode); err != nil { + _ = file.Close() + return fmt.Errorf("logsink: restrict log file: %w", err) + } + s.file = file + s.size = 0 + return nil +} + +// reopenActive reopens the active file for appending after a failed +// rotation. The caller must hold the mutex. +func (s *Sink) reopenActive() { + file, err := os.OpenFile(s.path, os.O_WRONLY|os.O_CREATE|os.O_APPEND, FileMode) + if err != nil { + return + } + s.file = file + if info, err := file.Stat(); err == nil { + s.size = info.Size() + } +} + +// generation returns the path of a rotated generation of the active file. +func (s *Sink) generation(n int) string { + return fmt.Sprintf("%s.%d", s.path, n) +} diff --git a/internal/logsink/logsink_test.go b/internal/logsink/logsink_test.go new file mode 100644 index 00000000..2d681c77 --- /dev/null +++ b/internal/logsink/logsink_test.go @@ -0,0 +1,289 @@ +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 + +package logsink + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "testing" +) + +func TestOpenCreatesRestrictiveDirAndFile(t *testing.T) { + dir := filepath.Join(t.TempDir(), "logs") + + sink, err := Open(dir) + if err != nil { + t.Fatal(err) + } + defer sink.Close() + + if got, want := sink.Path(), filepath.Join(dir, "hfdesk.log"); got != want { + t.Errorf("Path() = %q, want %q", got, want) + } + + dirInfo, err := os.Stat(dir) + if err != nil { + t.Fatal(err) + } + if got := dirInfo.Mode().Perm(); got != 0o700 { + t.Errorf("log dir mode = %04o, want 0700", got) + } + + fileInfo, err := os.Stat(sink.Path()) + if err != nil { + t.Fatal(err) + } + if got := fileInfo.Mode().Perm(); got != 0o600 { + t.Errorf("log file mode = %04o, want 0600", got) + } +} + +func TestOpenTightensExistingPermissions(t *testing.T) { + dir := filepath.Join(t.TempDir(), "logs") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.Chmod(dir, 0o755); err != nil { + t.Fatal(err) + } + logFile := filepath.Join(dir, FileName) + if err := os.WriteFile(logFile, []byte("old\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Chmod(logFile, 0o644); err != nil { + t.Fatal(err) + } + + sink, err := Open(dir) + if err != nil { + t.Fatal(err) + } + defer sink.Close() + + dirInfo, err := os.Stat(dir) + if err != nil { + t.Fatal(err) + } + if got := dirInfo.Mode().Perm(); got != 0o700 { + t.Errorf("log dir mode = %04o, want 0700", got) + } + fileInfo, err := os.Stat(logFile) + if err != nil { + t.Fatal(err) + } + if got := fileInfo.Mode().Perm(); got != 0o600 { + t.Errorf("log file mode = %04o, want 0600", got) + } + + // Existing content is kept and new writes are appended. + if _, err := sink.Write([]byte("new\n")); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(logFile) + if err != nil { + t.Fatal(err) + } + if got, want := string(data), "old\nnew\n"; got != want { + t.Errorf("log content = %q, want %q", got, want) + } +} + +func TestWriteAppendsToActiveFile(t *testing.T) { + dir := t.TempDir() + sink, err := Open(dir) + if err != nil { + t.Fatal(err) + } + defer sink.Close() + + for i := range 3 { + if _, err := sink.Write([]byte(fmt.Sprintf("line %d\n", i))); err != nil { + t.Fatal(err) + } + } + + data, err := os.ReadFile(sink.Path()) + if err != nil { + t.Fatal(err) + } + want := "line 0\nline 1\nline 2\n" + if string(data) != want { + t.Errorf("log content = %q, want %q", data, want) + } +} + +func TestRotationKeepsBoundedFileSet(t *testing.T) { + dir := t.TempDir() + sink, err := Open(dir) + if err != nil { + t.Fatal(err) + } + defer sink.Close() + + const chunkSize = 512 * 1024 + const chunks = 40 + var payloads [][]byte + for i := range chunks { + marker := fmt.Sprintf("[%04d]", i) + payloads = append(payloads, []byte(marker+strings.Repeat("x", chunkSize-len(marker)))) + if _, err := sink.Write(payloads[i]); err != nil { + t.Fatal(err) + } + } + + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + var total int64 + seen := map[string]bool{} + for _, entry := range entries { + info, err := entry.Info() + if err != nil { + t.Fatal(err) + } + seen[entry.Name()] = true + if info.Size() > MaxFileSize { + t.Errorf("%s is %d bytes, above the %d byte limit", entry.Name(), info.Size(), MaxFileSize) + } + total += info.Size() + } + + for _, name := range []string{FileName, FileName + ".1", FileName + ".2", FileName + ".3"} { + if !seen[name] { + t.Errorf("missing rotated log %s", name) + } + } + if seen[FileName+".4"] { + t.Errorf("unexpected log generation %s", FileName+".4") + } + if total > 4*MaxFileSize { + t.Errorf("total log size is %d bytes, above the %d byte bound", total, 4*MaxFileSize) + } + + // The oldest generation is discarded, the newest content is kept. + oldest, err := os.ReadFile(filepath.Join(dir, FileName+".3")) + if err != nil { + t.Fatal(err) + } + if bytes.Contains(oldest, []byte("[0000]")) { + t.Error("the oldest generation was not discarded") + } + newest, err := os.ReadFile(filepath.Join(dir, FileName)) + if err != nil { + t.Fatal(err) + } + if !bytes.Contains(newest, payloads[chunks-1]) { + t.Error("the newest write is missing from the active log") + } +} + +func TestOversizedWriteIsWrittenWhole(t *testing.T) { + dir := t.TempDir() + sink, err := Open(dir) + if err != nil { + t.Fatal(err) + } + defer sink.Close() + + big := bytes.Repeat([]byte("z"), int(MaxFileSize)+1024) + n, err := sink.Write(big) + if err != nil { + t.Fatal(err) + } + if n != len(big) { + t.Fatalf("wrote %d bytes, want %d", n, len(big)) + } + + info, err := os.Stat(sink.Path()) + if err != nil { + t.Fatal(err) + } + if info.Size() != int64(len(big)) { + t.Fatalf("log size = %d, want %d", info.Size(), len(big)) + } + + // The sink stays usable afterwards. + if _, err := sink.Write([]byte("tail\n")); err != nil { + t.Fatalf("write after an oversized write: %v", err) + } +} + +func TestConcurrentWritesAreRaceFree(t *testing.T) { + dir := t.TempDir() + sink, err := Open(dir) + if err != nil { + t.Fatal(err) + } + defer sink.Close() + + const writers = 8 + const writes = 50 + const payloadSize = 4096 + payload := bytes.Repeat([]byte("c"), payloadSize) + + var wg sync.WaitGroup + for range writers { + wg.Add(1) + go func() { + defer wg.Done() + for range writes { + if _, err := sink.Write(payload); err != nil { + t.Errorf("write: %v", err) + return + } + } + }() + } + wg.Wait() + + // The total stays below the rotation limit, so nothing is discarded. + info, err := os.Stat(sink.Path()) + if err != nil { + t.Fatal(err) + } + want := int64(writers * writes * payloadSize) + if info.Size() != want { + t.Fatalf("log size = %d, want %d", info.Size(), want) + } +} + +func TestCloseStopsWrites(t *testing.T) { + dir := t.TempDir() + sink, err := Open(dir) + if err != nil { + t.Fatal(err) + } + + if _, err := sink.Write([]byte("line\n")); err != nil { + t.Fatal(err) + } + if err := sink.Close(); err != nil { + t.Fatal(err) + } + if _, err := sink.Write([]byte("after close\n")); err == nil { + t.Error("write after close must fail") + } + if err := sink.Close(); err != nil { + t.Errorf("second close: %v", err) + } + + data, err := os.ReadFile(sink.Path()) + if err != nil { + t.Fatal(err) + } + if got, want := string(data), "line\n"; got != want { + t.Errorf("log content = %q, want %q", got, want) + } +} + +func TestOpenRejectsEmptyDir(t *testing.T) { + if _, err := Open(""); err == nil { + t.Error("Open(\"\") must fail") + } +} diff --git a/internal/runtimepaths/runtimepaths.go b/internal/runtimepaths/runtimepaths.go new file mode 100644 index 00000000..7f3b7c31 --- /dev/null +++ b/internal/runtimepaths/runtimepaths.go @@ -0,0 +1,168 @@ +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 + +// Package runtimepaths resolves the runtime file locations used by HFDesk. +// +// Interactive runs keep their historical behaviour: the config file, the jobs +// state, the download history and the mirror targets are derived from the +// current working directory and the per-user config directory, and are +// resolved again on every call so a changed environment is picked up. +// +// A supervised run (systemd, launchd, an init script, ...) can install one +// explicit model so every path comes from a deterministic root instead of +// from the working directory or the user profile of the service account. +package runtimepaths + +import ( + "os" + "path/filepath" + "strings" + "sync" +) + +// Paths is the startup-resolved runtime path model. +type Paths struct { + // ConfigFile is the config file read at startup and written by settings. + ConfigFile string + // StateDir holds the mutable runtime state: jobs, history and mirror + // targets. + StateDir string + // LogDir holds the process log files. + LogDir string +} + +// configFileNames are the accepted config file names, in search order. +var configFileNames = []string{"hfdesk.json", "hfdesk.yaml", "hfdesk.yml"} + +// JobsStateFile returns the path of the persisted job list. +func (p Paths) JobsStateFile() string { + return filepath.Join(p.StateDir, "jobs_state.json") +} + +// HistoryFile returns the path of the download history. +func (p Paths) HistoryFile() string { + return filepath.Join(p.StateDir, "download_history.json") +} + +// TargetsFile returns the path of the mirror targets config. +func (p Paths) TargetsFile() string { + return filepath.Join(p.StateDir, "targets.yaml") +} + +var ( + installedMu sync.RWMutex + installed *Paths +) + +// Install installs an explicit path model process-wide. Afterwards Current +// returns exactly p and ignores the working directory, HOME and the user +// profile. +func Install(p Paths) { + installedMu.Lock() + defer installedMu.Unlock() + installed = &p +} + +// Reset restores the interactive default resolution. +func Reset() { + installedMu.Lock() + defer installedMu.Unlock() + installed = nil +} + +// Current returns the effective path model. Without an installed model the +// interactive defaults are resolved again on every call, so a changed HOME, +// XDG_CONFIG_HOME, APPDATA or working directory takes effect immediately. +func Current() Paths { + installedMu.RLock() + p := installed + installedMu.RUnlock() + if p != nil { + return *p + } + return interactivePaths() +} + +// interactivePaths resolves the interactive path model from the current +// process environment. +func interactivePaths() Paths { + stateDir := AppConfigDir() + return Paths{ + ConfigFile: interactiveConfigFile(stateDir), + StateDir: stateDir, + LogDir: filepath.Join(stateDir, "logs"), + } +} + +// interactiveConfigFile resolves the config file the way an interactive run +// always has: an existing file next to the current or the executable +// directory first, then the per-user config directory, defaulting to a JSON +// file in the per-user config directory. +func interactiveConfigFile(stateDir string) string { + dirs := []string{RunDir()} + if exe, err := os.Executable(); err == nil { + dirs = append(dirs, filepath.Dir(exe)) + } + + for _, dir := range dirs { + if isTempDir(dir) { + continue + } + if p, ok := firstExistingConfig(dir); ok { + return p + } + } + + if p, ok := firstExistingConfig(stateDir); ok { + return p + } + + return filepath.Join(stateDir, "hfdesk.json") +} + +// firstExistingConfig returns the first existing config file in dir. +func firstExistingConfig(dir string) (string, bool) { + for _, name := range configFileNames { + p := filepath.Join(dir, name) + if _, err := os.Stat(p); err == nil { + return p, true + } + } + return "", false +} + +// RunDir returns the directory where state files (config, jobs, history) +// should be stored. Priority: current working directory → executable dir → ".". +func RunDir() string { + if cwd, err := os.Getwd(); err == nil { + return cwd + } + if exe, err := os.Executable(); err == nil { + return filepath.Dir(exe) + } + return "." +} + +// AppConfigDir returns the normal per-user HFDesk configuration directory. +func AppConfigDir() string { + if dir, err := os.UserConfigDir(); err == nil && dir != "" { + return filepath.Join(dir, "HFDesk") + } + if home, err := os.UserHomeDir(); err == nil && home != "" { + return filepath.Join(home, ".config", "hfdesk") + } + return RunDir() +} + +// isTempDir reports whether path looks like a temporary extraction directory. +// Such directories are skipped when searching for a config file so an +// unpacked archive never becomes the source of truth. +func isTempDir(path string) bool { + path = filepath.Clean(path) + temp := filepath.Clean(os.TempDir()) + lower := strings.ToLower(path) + if strings.HasPrefix(strings.ToLower(path), strings.ToLower(temp)) { + return true + } + return strings.Contains(lower, "rar$") || strings.Contains(lower, ".rartemp") +} diff --git a/internal/runtimepaths/runtimepaths_test.go b/internal/runtimepaths/runtimepaths_test.go new file mode 100644 index 00000000..fec1ba89 --- /dev/null +++ b/internal/runtimepaths/runtimepaths_test.go @@ -0,0 +1,270 @@ +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 + +package runtimepaths + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestCurrentReturnsInstalledModelVerbatim(t *testing.T) { + model := Paths{ + ConfigFile: filepath.Join("/etc", "hfdesk", "hfdesk.yaml"), + StateDir: filepath.Join("/var", "lib", "hfdesk"), + LogDir: filepath.Join("/var", "log", "hfdesk"), + } + Install(model) + t.Cleanup(Reset) + + if got := Current(); got != model { + t.Fatalf("Current() = %#v, want the installed model %#v", got, model) + } +} + +func TestInstalledModelIgnoresEnvironmentAndWorkingDirectory(t *testing.T) { + model := Paths{ + ConfigFile: filepath.Join("/srv", "hfdesk", "hfdesk.json"), + StateDir: filepath.Join("/srv", "hfdesk", "state"), + LogDir: filepath.Join("/srv", "hfdesk", "logs"), + } + Install(model) + t.Cleanup(Reset) + + setSandboxEnv(t, t.TempDir()) + t.Chdir(t.TempDir()) + + if got := Current(); got != model { + t.Fatalf("Current() = %#v, want the installed model %#v", got, model) + } +} + +func TestInteractiveCurrentFollowsEnvironment(t *testing.T) { + Reset() + t.Cleanup(Reset) + + setSandboxEnv(t, t.TempDir()) + + got := Current() + if want := referencePaths(t); got != want { + t.Fatalf("interactive resolution mismatch:\n got: %#v\nwant: %#v", got, want) + } + + // The interactive model is resolved on every call, so a later environment + // change must be picked up. + setSandboxEnv(t, t.TempDir()) + + got = Current() + if got2 := referencePaths(t); got != got2 { + t.Fatalf("interactive resolution did not follow the environment change:\n got: %#v\nwant: %#v", got, got2) + } +} + +func TestDerivedFileNamesLiveInStateDir(t *testing.T) { + stateDir := filepath.Join("var", "lib", "hfdesk") + p := Paths{StateDir: stateDir} + + if got, want := p.JobsStateFile(), filepath.Join(stateDir, "jobs_state.json"); got != want { + t.Errorf("JobsStateFile() = %q, want %q", got, want) + } + if got, want := p.HistoryFile(), filepath.Join(stateDir, "download_history.json"); got != want { + t.Errorf("HistoryFile() = %q, want %q", got, want) + } + if got, want := p.TargetsFile(), filepath.Join(stateDir, "targets.yaml"); got != want { + t.Errorf("TargetsFile() = %q, want %q", got, want) + } +} + +func TestResetRestoresInteractiveResolution(t *testing.T) { + model := Paths{ + ConfigFile: filepath.Join("/etc", "hfdesk", "hfdesk.json"), + StateDir: filepath.Join("/var", "lib", "hfdesk"), + LogDir: filepath.Join("/var", "log", "hfdesk"), + } + Install(model) + if got := Current(); got != model { + t.Fatalf("Current() = %#v, want the installed model %#v", got, model) + } + + Reset() + + got := Current() + if got == model { + t.Fatalf("Reset did not restore the interactive resolution: %#v", got) + } + if want := referencePaths(t); got != want { + t.Fatalf("interactive resolution mismatch after Reset:\n got: %#v\nwant: %#v", got, want) + } +} + +func TestInteractiveConfigFileSearchOrder(t *testing.T) { + Reset() + t.Cleanup(Reset) + + // The home directory has to be captured before the sandbox environment + // replaces it: the run directory must not look like a temporary one. + home := currentHomeDir(t) + + sandbox := t.TempDir() + setSandboxEnv(t, sandbox) + + // The per-user config directory holds a YAML config. + cfgDir := AppConfigDir() + if err := os.MkdirAll(cfgDir, 0o755); err != nil { + t.Fatal(err) + } + userConfig := filepath.Join(cfgDir, "hfdesk.yaml") + if err := os.WriteFile(userConfig, []byte("cache-dir: /tmp\n"), 0o644); err != nil { + t.Fatal(err) + } + + runDir := nonTempRunDir(t, home) + runConfig := filepath.Join(runDir, "hfdesk.json") + if err := os.WriteFile(runConfig, []byte("{}\n"), 0o644); err != nil { + t.Fatal(err) + } + t.Chdir(runDir) + + // A config next to the run directory wins over the per-user config. + if got, want := Current().ConfigFile, runConfig; got != want { + t.Fatalf("config file = %q, want the run directory config %q", got, want) + } + + // Without it the per-user config is used. + if err := os.Remove(runConfig); err != nil { + t.Fatal(err) + } + if got, want := Current().ConfigFile, userConfig; got != want { + t.Fatalf("config file = %q, want the per-user config %q", got, want) + } + + // Without any config file the default JSON file in the per-user config + // directory is used. + if err := os.Remove(userConfig); err != nil { + t.Fatal(err) + } + if got, want := Current().ConfigFile, filepath.Join(cfgDir, "hfdesk.json"); got != want { + t.Fatalf("config file = %q, want the default %q", got, want) + } +} + +// --- helpers --- + +// setSandboxEnv points every platform specific config location at root. +func setSandboxEnv(t *testing.T, root string) { + t.Helper() + t.Setenv("HOME", filepath.Join(root, "home")) + t.Setenv("USERPROFILE", filepath.Join(root, "home")) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(root, "xdg")) + t.Setenv("APPDATA", filepath.Join(root, "appdata")) + t.Setenv("LOCALAPPDATA", filepath.Join(root, "localappdata")) +} + +// currentHomeDir returns the home directory of the current environment. +func currentHomeDir(t *testing.T) string { + t.Helper() + for _, key := range []string{"HOME", "USERPROFILE"} { + if v := os.Getenv(key); v != "" { + return v + } + } + home, err := os.UserHomeDir() + if err != nil { + t.Skipf("no home directory available: %v", err) + } + return home +} + +// nonTempRunDir creates a run directory that the interactive search does not +// skip as a temporary extraction directory. +func nonTempRunDir(t *testing.T, home string) string { + t.Helper() + dir, err := os.MkdirTemp(home, "hfdesk-runtimepaths-run-") + if err != nil { + t.Skipf("cannot create a run directory under %s: %v", home, err) + } + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + if isTempDir(dir) { + t.Skipf("home directory %s is inside the temporary directory", home) + } + return dir +} + +// --- reference implementation --- +// +// The helpers below re-implement the documented interactive resolution without +// using the code under test, so the tests compare behaviour instead of +// comparing a function with itself. + +// referencePaths resolves the documented interactive path model. +func referencePaths(t *testing.T) Paths { + t.Helper() + stateDir := referenceAppConfigDir() + return Paths{ + ConfigFile: referenceConfigFile(t, stateDir), + StateDir: stateDir, + LogDir: filepath.Join(stateDir, "logs"), + } +} + +func referenceAppConfigDir() string { + if dir, err := os.UserConfigDir(); err == nil && dir != "" { + return filepath.Join(dir, "HFDesk") + } + if home, err := os.UserHomeDir(); err == nil && home != "" { + return filepath.Join(home, ".config", "hfdesk") + } + return referenceRunDir() +} + +func referenceRunDir() string { + if cwd, err := os.Getwd(); err == nil { + return cwd + } + if exe, err := os.Executable(); err == nil { + return filepath.Dir(exe) + } + return "." +} + +func referenceConfigFile(t *testing.T, stateDir string) string { + t.Helper() + dirs := []string{referenceRunDir()} + if exe, err := os.Executable(); err == nil { + dirs = append(dirs, filepath.Dir(exe)) + } + for _, dir := range dirs { + if referenceIsTempDir(dir) { + continue + } + if p, ok := referenceFirstExistingConfig(dir); ok { + return p + } + } + if p, ok := referenceFirstExistingConfig(stateDir); ok { + return p + } + return filepath.Join(stateDir, "hfdesk.json") +} + +func referenceFirstExistingConfig(dir string) (string, bool) { + for _, name := range []string{"hfdesk.json", "hfdesk.yaml", "hfdesk.yml"} { + p := filepath.Join(dir, name) + if _, err := os.Stat(p); err == nil { + return p, true + } + } + return "", false +} + +func referenceIsTempDir(path string) bool { + path = filepath.Clean(path) + temp := filepath.Clean(os.TempDir()) + lower := strings.ToLower(path) + if strings.HasPrefix(strings.ToLower(path), strings.ToLower(temp)) { + return true + } + return strings.Contains(lower, "rar$") || strings.Contains(lower, ".rartemp") +} diff --git a/internal/server/api.go b/internal/server/api.go index c0a0a0ca..f6928c64 100644 --- a/internal/server/api.go +++ b/internal/server/api.go @@ -17,6 +17,7 @@ import ( "strings" "time" + "github.com/bashrusakh/hfdesk/internal/runtimepaths" "github.com/bashrusakh/hfdesk/pkg/hfdownloader" "github.com/bashrusakh/hfdesk/pkg/smartdl" ) @@ -412,7 +413,7 @@ func (s *Server) handleGetSettings(w http.ResponseWriter, r *http.Request) { LocalScanDirs: cfg.LocalScanDirs, DownloadRoutes: cfg.DownloadRoutes, ConfigFile: ConfigPath(), - TargetsFile: hfdownloader.DefaultTargetsPath(), + TargetsFile: runtimepaths.Current().TargetsFile(), } // Add proxy settings (without password for security) diff --git a/internal/server/config.go b/internal/server/config.go index 60e7ddf9..f7b0ce05 100644 --- a/internal/server/config.go +++ b/internal/server/config.go @@ -12,6 +12,7 @@ import ( "sync" "time" + "github.com/bashrusakh/hfdesk/internal/runtimepaths" "github.com/bashrusakh/hfdesk/pkg/hfdownloader" "gopkg.in/yaml.v3" ) @@ -19,23 +20,17 @@ import ( // RunDir returns the directory where state files (config, jobs, history) // should be stored. Priority: current working directory → executable dir → ".". func RunDir() string { - if cwd, err := os.Getwd(); err == nil { - return cwd - } - if exe, err := os.Executable(); err == nil { - return filepath.Dir(exe) - } - return "." + return runtimepaths.RunDir() } // JobsStatePath returns the path to jobs_state.json in the app config directory. func JobsStatePath() string { - return filepath.Join(AppConfigDir(), "jobs_state.json") + return runtimepaths.Current().JobsStateFile() } // HistoryPath returns the path to download_history.json in the app config directory. func HistoryPath() string { - return filepath.Join(AppConfigDir(), "download_history.json") + return runtimepaths.Current().HistoryFile() } // ConfigFile represents the persistent configuration file format. @@ -83,56 +78,16 @@ func isRedactedToken(token string) bool { // AppConfigDir returns the normal per-user HFDesk configuration directory. func AppConfigDir() string { - if dir, err := os.UserConfigDir(); err == nil && dir != "" { - return filepath.Join(dir, "HFDesk") - } - if home, err := os.UserHomeDir(); err == nil && home != "" { - return filepath.Join(home, ".config", "hfdesk") - } - return RunDir() -} - -func isTempDir(path string) bool { - path = filepath.Clean(path) - temp := filepath.Clean(os.TempDir()) - lower := strings.ToLower(path) - if strings.HasPrefix(strings.ToLower(path), strings.ToLower(temp)) { - return true - } - return strings.Contains(lower, "rar$") || strings.Contains(lower, ".rartemp") + return runtimepaths.AppConfigDir() } // ConfigPath returns the path to the config file. // Search order: existing config next to the current/executable folder, then the // normal per-user config directory. New saves go to the per-user directory so // running an unpacked archive does not create config in a temp extraction path. +// A supervised instance resolves the path from its installed runtime model. func ConfigPath() string { - dirs := []string{RunDir()} - if exe, err := os.Executable(); err == nil { - dirs = append(dirs, filepath.Dir(exe)) - } - - for _, dir := range dirs { - if isTempDir(dir) { - continue - } - for _, name := range []string{"hfdesk.json", "hfdesk.yaml", "hfdesk.yml"} { - p := filepath.Join(dir, name) - if _, err := os.Stat(p); err == nil { - return p - } - } - } - - configDir := AppConfigDir() - for _, name := range []string{"hfdesk.json", "hfdesk.yaml", "hfdesk.yml"} { - p := filepath.Join(configDir, name) - if _, err := os.Stat(p); err == nil { - return p - } - } - - return filepath.Join(configDir, "hfdesk.json") + return runtimepaths.Current().ConfigFile } // LoadConfigFile loads configuration from the config file. diff --git a/internal/server/mirror.go b/internal/server/mirror.go index e650edf8..8199d379 100644 --- a/internal/server/mirror.go +++ b/internal/server/mirror.go @@ -12,6 +12,7 @@ import ( "sort" "strings" + "github.com/bashrusakh/hfdesk/internal/runtimepaths" "github.com/bashrusakh/hfdesk/pkg/hfdownloader" ) @@ -26,7 +27,7 @@ type MirrorTargetRequest struct { // handleMirrorTargetsList returns all configured mirror targets. func (s *Server) handleMirrorTargetsList(w http.ResponseWriter, r *http.Request) { - cfg, err := hfdownloader.LoadTargets("") + cfg, err := hfdownloader.LoadTargets(runtimepaths.Current().TargetsFile()) if err != nil { writeError(w, http.StatusInternalServerError, "Failed to load targets", err.Error()) return @@ -93,7 +94,7 @@ func (s *Server) handleMirrorTargetAdd(w http.ResponseWriter, r *http.Request) { return } - cfg, err := hfdownloader.LoadTargets("") + cfg, err := hfdownloader.LoadTargets(runtimepaths.Current().TargetsFile()) if err != nil { writeError(w, http.StatusInternalServerError, "Failed to load targets", err.Error()) return @@ -101,7 +102,7 @@ func (s *Server) handleMirrorTargetAdd(w http.ResponseWriter, r *http.Request) { cfg.Add(req.Name, absPath, req.Description) - if err := cfg.Save(""); err != nil { + if err := cfg.Save(runtimepaths.Current().TargetsFile()); err != nil { writeError(w, http.StatusInternalServerError, "Failed to save targets", err.Error()) return } @@ -125,7 +126,7 @@ func (s *Server) handleMirrorTargetRemove(w http.ResponseWriter, r *http.Request return } - cfg, err := hfdownloader.LoadTargets("") + cfg, err := hfdownloader.LoadTargets(runtimepaths.Current().TargetsFile()) if err != nil { writeError(w, http.StatusInternalServerError, "Failed to load targets", err.Error()) return @@ -136,7 +137,7 @@ func (s *Server) handleMirrorTargetRemove(w http.ResponseWriter, r *http.Request return } - if err := cfg.Save(""); err != nil { + if err := cfg.Save(runtimepaths.Current().TargetsFile()); err != nil { writeError(w, http.StatusInternalServerError, "Failed to save targets", err.Error()) return } @@ -179,7 +180,7 @@ func (s *Server) handleMirrorDiff(w http.ResponseWriter, r *http.Request) { } // Resolve target path - targetsCfg, err := hfdownloader.LoadTargets("") + targetsCfg, err := hfdownloader.LoadTargets(runtimepaths.Current().TargetsFile()) if err != nil { writeError(w, http.StatusInternalServerError, "Failed to load targets", err.Error()) return @@ -328,7 +329,7 @@ func (s *Server) handleMirrorPush(w http.ResponseWriter, r *http.Request) { } // Resolve target path - targetsCfg, err := hfdownloader.LoadTargets("") + targetsCfg, err := hfdownloader.LoadTargets(runtimepaths.Current().TargetsFile()) if err != nil { writeError(w, http.StatusInternalServerError, "Failed to load targets", err.Error()) return @@ -371,7 +372,7 @@ func (s *Server) handleMirrorPull(w http.ResponseWriter, r *http.Request) { } // Resolve target path - targetsCfg, err := hfdownloader.LoadTargets("") + targetsCfg, err := hfdownloader.LoadTargets(runtimepaths.Current().TargetsFile()) if err != nil { writeError(w, http.StatusInternalServerError, "Failed to load targets", err.Error()) return diff --git a/internal/server/runtimepaths_test.go b/internal/server/runtimepaths_test.go new file mode 100644 index 00000000..32fe0c01 --- /dev/null +++ b/internal/server/runtimepaths_test.go @@ -0,0 +1,173 @@ +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 + +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/bashrusakh/hfdesk/internal/runtimepaths" +) + +func TestServerPathsFollowInstalledRuntimeModel(t *testing.T) { + state := t.TempDir() + model := runtimepaths.Paths{ + ConfigFile: filepath.Join(state, "hfdesk.json"), + StateDir: state, + LogDir: filepath.Join(state, "logs"), + } + runtimepaths.Install(model) + t.Cleanup(runtimepaths.Reset) + + if got, want := ConfigPath(), model.ConfigFile; got != want { + t.Errorf("ConfigPath() = %q, want %q", got, want) + } + if got, want := JobsStatePath(), filepath.Join(state, "jobs_state.json"); got != want { + t.Errorf("JobsStatePath() = %q, want %q", got, want) + } + if got, want := HistoryPath(), filepath.Join(state, "download_history.json"); got != want { + t.Errorf("HistoryPath() = %q, want %q", got, want) + } +} + +func TestServerPathsInteractiveWithoutInstalledModel(t *testing.T) { + runtimepaths.Reset() + t.Cleanup(runtimepaths.Reset) + + if got, want := ConfigPath(), referenceConfigPath(t); got != want { + t.Errorf("ConfigPath() = %q, want %q", got, want) + } + if got, want := JobsStatePath(), filepath.Join(AppConfigDir(), "jobs_state.json"); got != want { + t.Errorf("JobsStatePath() = %q, want %q", got, want) + } + if got, want := HistoryPath(), filepath.Join(AppConfigDir(), "download_history.json"); got != want { + t.Errorf("HistoryPath() = %q, want %q", got, want) + } +} + +func TestMirrorTargetsUseInstalledRuntimeModel(t *testing.T) { + state := t.TempDir() + runtimepaths.Install(runtimepaths.Paths{ + ConfigFile: filepath.Join(state, "hfdesk.json"), + StateDir: state, + LogDir: filepath.Join(state, "logs"), + }) + t.Cleanup(runtimepaths.Reset) + + s := newTestServer(t) + req := httptest.NewRequest("POST", "/api/mirror/targets", strings.NewReader(`{"name":"backup","path":"/tmp/hfdesk-backup"}`)) + w := httptest.NewRecorder() + s.handleMirrorTargetAdd(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", w.Code, w.Body.String()) + } + + targetsFile := filepath.Join(state, "targets.yaml") + data, err := os.ReadFile(targetsFile) + if err != nil { + t.Fatalf("read targets file %s: %v", targetsFile, err) + } + if !strings.Contains(string(data), "backup") { + t.Errorf("targets file %s does not contain the new target:\n%s", targetsFile, data) + } +} + +func TestSettingsReportInstalledRuntimeModel(t *testing.T) { + state := t.TempDir() + model := runtimepaths.Paths{ + ConfigFile: filepath.Join(state, "hfdesk.json"), + StateDir: state, + LogDir: filepath.Join(state, "logs"), + } + runtimepaths.Install(model) + t.Cleanup(runtimepaths.Reset) + + s := newTestServer(t) + req := httptest.NewRequest("GET", "/api/settings", nil) + w := httptest.NewRecorder() + s.handleGetSettings(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", w.Code, w.Body.String()) + } + + var resp SettingsResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + if want := model.ConfigFile; resp.ConfigFile != want { + t.Errorf("configFile = %q, want %q", resp.ConfigFile, want) + } + if want := filepath.Join(state, "targets.yaml"); resp.TargetsFile != want { + t.Errorf("targetsFile = %q, want %q", resp.TargetsFile, want) + } +} + +// --- reference implementation --- +// +// The helpers below re-implement the documented interactive resolution without +// using the runtime path model, so the tests compare behaviour instead of +// comparing a function with itself. + +func referenceConfigPath(t *testing.T) string { + t.Helper() + dirs := []string{referenceRunDir()} + if exe, err := os.Executable(); err == nil { + dirs = append(dirs, filepath.Dir(exe)) + } + for _, dir := range dirs { + if referenceIsTempDir(dir) { + continue + } + for _, name := range []string{"hfdesk.json", "hfdesk.yaml", "hfdesk.yml"} { + p := filepath.Join(dir, name) + if _, err := os.Stat(p); err == nil { + return p + } + } + } + + configDir := referenceAppConfigDir() + for _, name := range []string{"hfdesk.json", "hfdesk.yaml", "hfdesk.yml"} { + p := filepath.Join(configDir, name) + if _, err := os.Stat(p); err == nil { + return p + } + } + return filepath.Join(configDir, "hfdesk.json") +} + +func referenceAppConfigDir() string { + if dir, err := os.UserConfigDir(); err == nil && dir != "" { + return filepath.Join(dir, "HFDesk") + } + if home, err := os.UserHomeDir(); err == nil && home != "" { + return filepath.Join(home, ".config", "hfdesk") + } + return referenceRunDir() +} + +func referenceRunDir() string { + if cwd, err := os.Getwd(); err == nil { + return cwd + } + if exe, err := os.Executable(); err == nil { + return filepath.Dir(exe) + } + return "." +} + +func referenceIsTempDir(path string) bool { + path = filepath.Clean(path) + temp := filepath.Clean(os.TempDir()) + lower := strings.ToLower(path) + if strings.HasPrefix(strings.ToLower(path), strings.ToLower(temp)) { + return true + } + return strings.Contains(lower, "rar$") || strings.Contains(lower, ".rartemp") +} diff --git a/internal/server/state.go b/internal/server/state.go index aa75f07d..56c266c8 100644 --- a/internal/server/state.go +++ b/internal/server/state.go @@ -13,6 +13,8 @@ import ( "path/filepath" "sync" "time" + + "github.com/bashrusakh/hfdesk/internal/runtimepaths" ) // jobsStateFile is the on-disk format for jobs_state.json. @@ -242,7 +244,9 @@ func AppendHistory(job *Job) error { if err != nil { return err } - if err := os.MkdirAll(AppConfigDir(), 0o755); err != nil { + // The state directory comes from the effective runtime path model, so a + // supervised instance writes its history into its own state root. + if err := os.MkdirAll(runtimepaths.Current().StateDir, 0o755); err != nil { return err } return os.WriteFile(HistoryPath(), data, 0o644) From f97ec297a4a8158ef845f19cca8ac771c85ded7b Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 21 Oct 2026 20:39:12 +1100 Subject: [PATCH 02/58] service: add the platform-neutral service layer - Define the service contract every platform manager implements: the scope, the lifecycle vocabulary, the status report, the installation options and the install/uninstall/start/stop/restart/status operations. - Own the command semantics in one place: the scope parsing, the listen address parsing and validation, the loopback policy, the installation validation and the canonical, non-secret argument vector of the registered process. - Define where an instance keeps its state per scope, so a user scope instance and a system scope instance can never read or write each other's state root. - Add the headless host: it installs the runtime path model, pins the cache environment, redirects the log into the bounded log file of the instance and runs the existing server with its graceful shutdown path. It never opens a browser and never shows a tray icon. - Parse the argument vector of the registered process back into the run options, so the command line and the supervisor registration cannot drift. Co-Authored-By: opencode --- internal/service/host.go | 140 ++++++++ internal/service/host_test.go | 361 +++++++++++++++++++++ internal/service/paths_darwin.go | 43 +++ internal/service/paths_linux.go | 43 +++ internal/service/paths_other.go | 19 ++ internal/service/paths_test.go | 309 ++++++++++++++++++ internal/service/paths_windows.go | 49 +++ internal/service/process.go | 19 ++ internal/service/process_darwin.go | 14 + internal/service/process_linux.go | 14 + internal/service/process_other.go | 14 + internal/service/process_test.go | 65 ++++ internal/service/process_windows.go | 25 ++ internal/service/runargs.go | 154 +++++++++ internal/service/runargs_test.go | 395 +++++++++++++++++++++++ internal/service/service.go | 432 +++++++++++++++++++++++++ internal/service/service_test.go | 479 ++++++++++++++++++++++++++++ 17 files changed, 2575 insertions(+) create mode 100644 internal/service/host.go create mode 100644 internal/service/host_test.go create mode 100644 internal/service/paths_darwin.go create mode 100644 internal/service/paths_linux.go create mode 100644 internal/service/paths_other.go create mode 100644 internal/service/paths_test.go create mode 100644 internal/service/paths_windows.go create mode 100644 internal/service/process.go create mode 100644 internal/service/process_darwin.go create mode 100644 internal/service/process_linux.go create mode 100644 internal/service/process_other.go create mode 100644 internal/service/process_test.go create mode 100644 internal/service/process_windows.go create mode 100644 internal/service/runargs.go create mode 100644 internal/service/runargs_test.go create mode 100644 internal/service/service.go create mode 100644 internal/service/service_test.go diff --git a/internal/service/host.go b/internal/service/host.go new file mode 100644 index 00000000..cbf591f9 --- /dev/null +++ b/internal/service/host.go @@ -0,0 +1,140 @@ +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 + +package service + +import ( + "context" + "fmt" + "io" + "log" + "os" + "path/filepath" + "strings" + + "github.com/bashrusakh/hfdesk/internal/logsink" + "github.com/bashrusakh/hfdesk/internal/runtimepaths" + "github.com/bashrusakh/hfdesk/internal/server" +) + +// RunOptions configures the headless service host. +type RunOptions struct { + // Paths is the runtime path model of the instance. It is installed + // process-wide before any server state is read or written. + Paths runtimepaths.Paths + // Listen is the host:port the instance binds. Empty means DefaultListen. + Listen string + // CacheDir is the absolute Hugging Face cache root. Empty keeps the + // default resolution. + CacheDir string + // HubDir is the absolute Hugging Face hub cache directory. Empty keeps + // the default resolution. + HubDir string + // LocalDir is the absolute flat output directory. Empty means the + // Hugging Face cache layout is used. + LocalDir string +} + +// Run starts HFDesk in service mode and blocks until ctx is cancelled or the +// server fails. It never opens a browser and never creates a tray icon: the +// instance is supervised, there is no desktop session to interact with. +func Run(ctx context.Context, opts RunOptions) error { + // The runtime path model is installed first: the config file, the job + // state, the download history and the mirror targets are all resolved + // from it, so no server state may be read or written before it is in + // place. + runtimepaths.Install(opts.Paths) + + // Pin the cache environment. A supervisor hands over whatever + // environment it happens to have (a login shell, a system-wide + // environment file, a stale export), so the effective cache identity + // would otherwise depend on the machine instead of on the installation. + // The values of the installation win; an option that is not set keeps the + // environment/default resolution. + if opts.CacheDir != "" { + if err := os.Setenv("HF_HOME", opts.CacheDir); err != nil { + return fmt.Errorf("service: pin cache root: %w", err) + } + } + if opts.HubDir != "" { + if err := os.Setenv("HF_HUB_CACHE", opts.HubDir); err != nil { + return fmt.Errorf("service: pin hub cache: %w", err) + } + } + + // The process log goes to the supervisor (systemd journal, launchd log + // files) and into the bounded rotating log file of the instance. + sink, err := logsink.Open(opts.Paths.LogDir) + if err != nil { + return fmt.Errorf("service: open log sink: %w", err) + } + defer sink.Close() + previousLogOutput := log.Writer() + log.SetOutput(io.MultiWriter(os.Stdout, sink)) + defer log.SetOutput(previousLogOutput) + + // Resolve the bind address of the instance. + listen := opts.Listen + if listen == "" { + listen = DefaultListen + } + host, port, err := ParseListen(listen) + if err != nil { + return err + } + + // The credentials of an instance come from its own config file, never + // from the environment the supervisor handed over. + cfg, err := buildConfig(opts, host, port) + if err != nil { + return err + } + + // The server owns the graceful shutdown path (quiesce, HTTP drain, job + // manager close); the host only waits for it. + return server.New(cfg).ListenAndServe(ctx) +} + +// buildConfig assembles the server configuration of a service instance. +func buildConfig(opts RunOptions, host string, port int) (server.Config, error) { + if opts.Paths.StateDir == "" || !filepath.IsAbs(opts.Paths.StateDir) { + return server.Config{}, fmt.Errorf("service: state dir %q must be an absolute path", opts.Paths.StateDir) + } + + // server.ApplyConfigToServer prefers HF_TOKEN over the config file, so + // the environment must not keep a credential: it is removed before the + // configuration is loaded and the service-owned config file is the only + // source of the token. + if err := os.Unsetenv("HF_TOKEN"); err != nil { + return server.Config{}, fmt.Errorf("service: drop environment token: %w", err) + } + + cfg := server.DefaultConfig() + // The server composes the bind address as ":", so an IPv6 + // host keeps its brackets. + cfg.Addr = listenHost(host) + cfg.Port = port + cfg.CacheDir = opts.CacheDir + cfg.LocalDir = opts.LocalDir + // The interactive defaults are relative ("./Models"), which would resolve + // against the working directory the supervisor happened to choose. A + // service instance keeps its output inside its own state root. + cfg.ModelsDir = filepath.Join(opts.Paths.StateDir, "models") + cfg.DatasetsDir = filepath.Join(opts.Paths.StateDir, "datasets") + + // The service-owned config file supplies the token, the proxy and the + // remaining settings of the instance. + if err := server.ApplyConfigToServer(&cfg); err != nil { + return server.Config{}, fmt.Errorf("service: load config %q: %w", opts.Paths.ConfigFile, err) + } + return cfg, nil +} + +// listenHost returns the host in the form the server composes with the port +// into a socket address. +func listenHost(host string) string { + if strings.Contains(host, ":") { + return "[" + host + "]" + } + return host +} diff --git a/internal/service/host_test.go b/internal/service/host_test.go new file mode 100644 index 00000000..e9ff62db --- /dev/null +++ b/internal/service/host_test.go @@ -0,0 +1,361 @@ +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 + +package service + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/bashrusakh/hfdesk/internal/runtimepaths" +) + +func TestRunInstallsRuntimeModelAndStopsOnCancel(t *testing.T) { + root := t.TempDir() + model := runtimepaths.Paths{ + ConfigFile: filepath.Join(root, "etc", "hfdesk.json"), + StateDir: filepath.Join(root, "var"), + LogDir: filepath.Join(root, "var", "logs"), + } + t.Cleanup(runtimepaths.Reset) + + opts := RunOptions{Paths: model} + start := time.Now() + if err := runService(t, opts); err != nil { + t.Fatalf("Run() = %v, want nil", err) + } + if elapsed := time.Since(start); elapsed > 30*time.Second { + t.Errorf("Run() returned after %s, want a prompt shutdown", elapsed) + } + + if got := runtimepaths.Current(); got != model { + t.Errorf("runtimepaths.Current() = %+v, want %+v", got, model) + } + + // The process log reached the log file of the instance. + logFile := filepath.Join(model.LogDir, "hfdesk.log") + data, err := os.ReadFile(logFile) + if err != nil { + t.Fatalf("read %s: %v", logFile, err) + } + if len(data) == 0 { + t.Errorf("%s is empty, want the process log", logFile) + } +} + +func TestRunServesUntilTheContextIsCancelled(t *testing.T) { + root := t.TempDir() + model := runtimepaths.Paths{ + ConfigFile: filepath.Join(root, "etc", "hfdesk.json"), + StateDir: filepath.Join(root, "var"), + LogDir: filepath.Join(root, "var", "logs"), + } + t.Cleanup(runtimepaths.Reset) + + port := freePort(t) + addr := "127.0.0.1:" + strconv.Itoa(port) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + done := make(chan error, 1) + go func() { + done <- Run(ctx, RunOptions{Paths: model, Listen: addr}) + }() + + client := &http.Client{Timeout: 2 * time.Second} + url := "http://" + addr + "/api/health" + var lastErr error + for deadline := time.Now().Add(10 * time.Second); time.Now().Before(deadline); { + body, status, err := get(client, url) + switch { + case err != nil: + lastErr = err + case status != http.StatusOK: + lastErr = fmt.Errorf("status %d: %s", status, body) + case !strings.Contains(body, `"status":"ok"`): + lastErr = fmt.Errorf("unexpected health payload: %s", body) + default: + cancel() + if err := <-done; err != nil { + t.Fatalf("Run() = %v, want nil", err) + } + return + } + time.Sleep(20 * time.Millisecond) + } + + cancel() + <-done + t.Fatalf("the health endpoint never answered: %v", lastErr) +} + +// get performs a GET request and returns the body and the status code. +func get(client *http.Client, url string) (string, int, error) { + resp, err := client.Get(url) + if err != nil { + return "", 0, err + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", resp.StatusCode, err + } + return string(body), resp.StatusCode, nil +} + +func TestRunPinsTheCacheEnvironment(t *testing.T) { + root := t.TempDir() + model := runtimepaths.Paths{ + ConfigFile: filepath.Join(root, "etc", "hfdesk.json"), + StateDir: filepath.Join(root, "var"), + LogDir: filepath.Join(root, "var", "logs"), + } + t.Cleanup(runtimepaths.Reset) + + cacheDir := filepath.Join(root, "cache") + hubDir := filepath.Join(root, "cache", "hub") + t.Setenv("HF_HOME", filepath.Join(root, "stale-home")) + t.Setenv("HF_HUB_CACHE", filepath.Join(root, "stale-hub")) + + opts := RunOptions{Paths: model, CacheDir: cacheDir, HubDir: hubDir} + if err := runService(t, opts); err != nil { + t.Fatalf("Run() = %v, want nil", err) + } + + if got := os.Getenv("HF_HOME"); got != cacheDir { + t.Errorf("HF_HOME = %q, want the cache dir of the installation %q", got, cacheDir) + } + if got := os.Getenv("HF_HUB_CACHE"); got != hubDir { + t.Errorf("HF_HUB_CACHE = %q, want the hub dir of the installation %q", got, hubDir) + } +} + +func TestBuildConfigUsesTheServiceOwnedPaths(t *testing.T) { + root := t.TempDir() + model := runtimepaths.Paths{ + ConfigFile: filepath.Join(root, "etc", "hfdesk.json"), + StateDir: filepath.Join(root, "var"), + LogDir: filepath.Join(root, "var", "logs"), + } + runtimepaths.Install(model) + t.Cleanup(runtimepaths.Reset) + + cacheDir := filepath.Join(root, "cache") + localDir := filepath.Join(root, "models") + cfg, err := buildConfig(RunOptions{ + Paths: model, + CacheDir: cacheDir, + LocalDir: localDir, + }, "127.0.0.1", 8080) + if err != nil { + t.Fatalf("buildConfig() = %v, want nil", err) + } + + if cfg.Addr != "127.0.0.1" || cfg.Port != 8080 { + t.Errorf("listen = %q:%d, want 127.0.0.1:8080", cfg.Addr, cfg.Port) + } + if want := filepath.Join(model.StateDir, "models"); cfg.ModelsDir != want { + t.Errorf("ModelsDir = %q, want %q", cfg.ModelsDir, want) + } + if want := filepath.Join(model.StateDir, "datasets"); cfg.DatasetsDir != want { + t.Errorf("DatasetsDir = %q, want %q", cfg.DatasetsDir, want) + } + if cfg.CacheDir != cacheDir { + t.Errorf("CacheDir = %q, want %q", cfg.CacheDir, cacheDir) + } + if cfg.LocalDir != localDir { + t.Errorf("LocalDir = %q, want %q", cfg.LocalDir, localDir) + } +} + +func TestBuildConfigKeepsIPv6HostsBracketed(t *testing.T) { + root := t.TempDir() + model := runtimepaths.Paths{ + ConfigFile: filepath.Join(root, "etc", "hfdesk.json"), + StateDir: filepath.Join(root, "var"), + LogDir: filepath.Join(root, "var", "logs"), + } + runtimepaths.Install(model) + t.Cleanup(runtimepaths.Reset) + + // The server composes the bind address as ":": an IPv6 host + // must keep its brackets to stay a valid socket address. + cfg, err := buildConfig(RunOptions{Paths: model}, "::1", 8080) + if err != nil { + t.Fatalf("buildConfig() = %v, want nil", err) + } + if cfg.Addr != "[::1]" { + t.Errorf("Addr = %q, want %q", cfg.Addr, "[::1]") + } +} + +func TestBuildConfigRejectsARelativeStateDir(t *testing.T) { + model := runtimepaths.Paths{ + ConfigFile: filepath.Join(t.TempDir(), "etc", "hfdesk.json"), + StateDir: "var", + LogDir: filepath.Join(t.TempDir(), "logs"), + } + if _, err := buildConfig(RunOptions{Paths: model}, "127.0.0.1", 8080); err == nil { + t.Error("buildConfig() = nil, want an error for a relative state dir") + } +} + +func TestBuildConfigTakesTheTokenFromTheConfigFile(t *testing.T) { + root := t.TempDir() + configFile := filepath.Join(root, "etc", "hfdesk.json") + if err := os.MkdirAll(filepath.Dir(configFile), 0o700); err != nil { + t.Fatal(err) + } + data, err := json.Marshal(map[string]string{"token": "hf_file_token"}) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(configFile, data, 0o600); err != nil { + t.Fatal(err) + } + + model := runtimepaths.Paths{ + ConfigFile: configFile, + StateDir: filepath.Join(root, "var"), + LogDir: filepath.Join(root, "var", "logs"), + } + runtimepaths.Install(model) + t.Cleanup(runtimepaths.Reset) + + // The environment must not contribute a credential: the token of the + // service-owned config file is the only source. + t.Setenv("HF_TOKEN", "hf_environment_token") + cfg, err := buildConfig(RunOptions{Paths: model}, "127.0.0.1", 8080) + if err != nil { + t.Fatalf("buildConfig() = %v, want nil", err) + } + if cfg.Token != "hf_file_token" { + t.Errorf("Token = %q, want the token of the config file", cfg.Token) + } + if got := os.Getenv("HF_TOKEN"); got != "" { + t.Errorf("HF_TOKEN = %q, want it dropped from the service environment", got) + } +} + +// guiLaunchMarkers are the markers of a browser or a tray launcher. No file +// of the service package may contain one of them: a supervised instance has +// no desktop session to interact with. +var guiLaunchMarkers = []string{"xdg-open", "rundll32", "systray", "url.dll,FileProtocolHandler"} + +// processLaunchMarkers are the markers of a process launch. +var processLaunchMarkers = []string{`"os/exec"`, "exec.Command"} + +// The package has two file classes and they are treated differently. +// +// The shared, platform-neutral files (host.go, service.go, runargs.go, +// process.go, paths_*.go) run on every operating system. They own the +// command semantics of the service and not the machine, so they must not +// launch a process at all. +// +// The platform files (manager_*.go, process_*.go) own the supervisor of the +// platform and have to run its command line tool ("systemctl", "launchctl"), +// so a process launch is legitimate there. It is still not a browser and not +// a tray icon. +// +// Neither class may launch a browser or a tray icon. +// +// The class comes from the file name, so a new platform file is covered +// without changing this test. +func TestServiceLayerNeverLaunchesAGUI(t *testing.T) { + entries, err := os.ReadDir(".") + if err != nil { + t.Fatal(err) + } + for _, entry := range entries { + name := entry.Name() + if !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { + continue + } + data, err := os.ReadFile(name) + if err != nil { + t.Fatal(err) + } + src := stripComments(string(data)) + if !isPlatformFile(name) { + for _, marker := range processLaunchMarkers { + if strings.Contains(src, marker) { + t.Errorf("%s contains %q: the shared service layer must not launch a process", name, marker) + } + } + } + for _, marker := range guiLaunchMarkers { + if strings.Contains(src, marker) { + t.Errorf("%s contains %q: the service layer must not launch a browser or a tray icon", name, marker) + } + } + } +} + +// isPlatformFile reports whether the file is a platform-specific file of the +// package. A platform file owns the supervisor of the platform and may run +// its command line tool. +func isPlatformFile(name string) bool { + return strings.HasPrefix(name, "manager_") || strings.HasPrefix(name, "process_") +} + +// runService starts the headless host with an already cancelled context and +// returns its error. A cancelled context is the normal shutdown path of a +// supervised instance, so the host must return without an error. +func runService(t *testing.T, opts RunOptions) error { + t.Helper() + return runServiceWith(t, opts, Run) +} + +// runServiceWith starts the given entry point with an already cancelled +// context and returns its error. The entry point is the function under test, +// so the same shutdown path is exercised for every entry point of the +// package. +func runServiceWith(t *testing.T, opts RunOptions, run func(context.Context, RunOptions) error) error { + t.Helper() + var err error + for attempt := 0; attempt < 3; attempt++ { + opts.Listen = "127.0.0.1:" + strconv.Itoa(freePort(t)) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + err = run(ctx, opts) + if err == nil || !strings.Contains(err.Error(), "address already in use") { + return err + } + } + return err +} + +// freePort returns a port that was free a moment ago. +func freePort(t *testing.T) int { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("reserve a port: %v", err) + } + defer listener.Close() + return listener.Addr().(*net.TCPAddr).Port +} + +// stripComments removes the line comments of a Go source so a doc comment +// does not trip a source marker check. +func stripComments(src string) string { + var out strings.Builder + for _, line := range strings.Split(src, "\n") { + if i := strings.Index(line, "//"); i >= 0 { + line = line[:i] + } + out.WriteString(line) + out.WriteString("\n") + } + return out.String() +} diff --git a/internal/service/paths_darwin.go b/internal/service/paths_darwin.go new file mode 100644 index 00000000..aa5e1f03 --- /dev/null +++ b/internal/service/paths_darwin.go @@ -0,0 +1,43 @@ +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 + +//go:build darwin + +package service + +import ( + "fmt" + "path/filepath" + + "github.com/bashrusakh/hfdesk/internal/runtimepaths" +) + +// defaultPathsForScope returns the runtime paths of a service instance in the +// given scope. +// +// A user scope instance keeps its state under the per-user configuration +// directory of the invoking user, a system scope instance under the +// machine-wide directories of the machine. The two roots never overlap. +func defaultPathsForScope(scope Scope) (runtimepaths.Paths, error) { + switch scope { + case ScopeUser: + // The service state of the user lives next to the settings of the + // interactive run, inside the per-user HFDesk configuration root. + root := filepath.Join(runtimepaths.AppConfigDir(), "service") + return runtimepaths.Paths{ + ConfigFile: filepath.Join(root, "hfdesk.json"), + StateDir: root, + LogDir: filepath.Join(root, "logs"), + }, nil + case ScopeSystem: + // The machine-wide instance never uses the home directory of the + // user who happens to run the install command. + return runtimepaths.Paths{ + ConfigFile: "/etc/hfdesk/hfdesk.json", + StateDir: "/var/lib/hfdesk", + LogDir: "/var/log/hfdesk", + }, nil + default: + return runtimepaths.Paths{}, fmt.Errorf("service: unknown scope %q (want %q or %q)", scope, ScopeUser, ScopeSystem) + } +} diff --git a/internal/service/paths_linux.go b/internal/service/paths_linux.go new file mode 100644 index 00000000..e0834ccd --- /dev/null +++ b/internal/service/paths_linux.go @@ -0,0 +1,43 @@ +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 + +//go:build linux + +package service + +import ( + "fmt" + "path/filepath" + + "github.com/bashrusakh/hfdesk/internal/runtimepaths" +) + +// defaultPathsForScope returns the runtime paths of a service instance in the +// given scope. +// +// A user scope instance keeps its state under the per-user configuration +// directory of the invoking user, a system scope instance under the +// machine-wide directories of the machine. The two roots never overlap. +func defaultPathsForScope(scope Scope) (runtimepaths.Paths, error) { + switch scope { + case ScopeUser: + // The service state of the user lives next to the settings of the + // interactive run, inside the per-user HFDesk configuration root. + root := filepath.Join(runtimepaths.AppConfigDir(), "service") + return runtimepaths.Paths{ + ConfigFile: filepath.Join(root, "hfdesk.json"), + StateDir: root, + LogDir: filepath.Join(root, "logs"), + }, nil + case ScopeSystem: + // The machine-wide instance never uses the home directory of the + // user who happens to run the install command. + return runtimepaths.Paths{ + ConfigFile: "/etc/hfdesk/hfdesk.json", + StateDir: "/var/lib/hfdesk", + LogDir: "/var/log/hfdesk", + }, nil + default: + return runtimepaths.Paths{}, fmt.Errorf("service: unknown scope %q (want %q or %q)", scope, ScopeUser, ScopeSystem) + } +} diff --git a/internal/service/paths_other.go b/internal/service/paths_other.go new file mode 100644 index 00000000..b485a50e --- /dev/null +++ b/internal/service/paths_other.go @@ -0,0 +1,19 @@ +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 + +//go:build !linux && !darwin && !windows + +package service + +import ( + "fmt" + "runtime" + + "github.com/bashrusakh/hfdesk/internal/runtimepaths" +) + +// defaultPathsForScope reports that no service scope is supported on this +// platform, so an instance never picks a state root by accident. +func defaultPathsForScope(scope Scope) (runtimepaths.Paths, error) { + return runtimepaths.Paths{}, fmt.Errorf("service: scope %q is not supported on %s", scope, runtime.GOOS) +} diff --git a/internal/service/paths_test.go b/internal/service/paths_test.go new file mode 100644 index 00000000..894ccb44 --- /dev/null +++ b/internal/service/paths_test.go @@ -0,0 +1,309 @@ +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 + +package service + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/bashrusakh/hfdesk/internal/runtimepaths" +) + +// linuxSystemPaths are the machine-wide paths of a system scope instance on +// Linux and macOS. +var linuxSystemPaths = runtimepaths.Paths{ + ConfigFile: "/etc/hfdesk/hfdesk.json", + StateDir: "/var/lib/hfdesk", + LogDir: "/var/log/hfdesk", +} + +func TestDefaultPathsUserScope(t *testing.T) { + paths, err := DefaultPaths(ScopeUser) + switch runtime.GOOS { + case "linux", "darwin": + if err != nil { + t.Fatalf("DefaultPaths(ScopeUser): %v", err) + } + case "windows": + // The Windows service control manager registers machine-wide + // services only. + if err == nil { + t.Fatalf("DefaultPaths(ScopeUser) on windows = %+v, want an error", paths) + } + return + default: + if err == nil { + t.Fatalf("DefaultPaths(ScopeUser) on %s = %+v, want an error", runtime.GOOS, paths) + } + return + } + + // The user scope instance keeps its state under the per-user + // configuration root, next to the settings of the interactive run. + root := filepath.Join(runtimepaths.AppConfigDir(), "service") + want := runtimepaths.Paths{ + ConfigFile: filepath.Join(root, "hfdesk.json"), + StateDir: root, + LogDir: filepath.Join(root, "logs"), + } + if paths != want { + t.Errorf("DefaultPaths(ScopeUser) =\n%+v\nwant\n%+v", paths, want) + } + + assertAbsolutePaths(t, "user scope", paths) + + // The log dir of the instance lives inside its state dir. + if !isInsidePath(paths.LogDir, paths.StateDir) { + t.Errorf("user scope log dir %q is not inside the state dir %q", paths.LogDir, paths.StateDir) + } + // The config file of the instance lives inside the parent of its state + // root. + if !isInsidePath(paths.ConfigFile, filepath.Dir(paths.StateDir)) { + t.Errorf("user scope config file %q is not inside the state root parent %q", paths.ConfigFile, filepath.Dir(paths.StateDir)) + } +} + +func TestDefaultPathsSystemScope(t *testing.T) { + switch runtime.GOOS { + case "linux", "darwin": + paths, err := DefaultPaths(ScopeSystem) + if err != nil { + t.Fatalf("DefaultPaths(ScopeSystem): %v", err) + } + if paths != linuxSystemPaths { + t.Errorf("DefaultPaths(ScopeSystem) =\n%+v\nwant\n%+v", paths, linuxSystemPaths) + } + assertAbsolutePaths(t, "system scope", paths) + case "windows": + programData := t.TempDir() + t.Setenv("ProgramData", programData) + + paths, err := DefaultPaths(ScopeSystem) + if err != nil { + t.Fatalf("DefaultPaths(ScopeSystem): %v", err) + } + root := filepath.Join(programData, "HFDesk") + want := runtimepaths.Paths{ + ConfigFile: filepath.Join(root, "hfdesk.json"), + StateDir: root, + LogDir: filepath.Join(root, "logs"), + } + if paths != want { + t.Errorf("DefaultPaths(ScopeSystem) =\n%+v\nwant\n%+v", paths, want) + } + assertAbsolutePaths(t, "system scope", paths) + if !isInsidePath(paths.LogDir, paths.StateDir) { + t.Errorf("system scope log dir %q is not inside the state dir %q", paths.LogDir, paths.StateDir) + } + + // Without the environment variable the documented default is used. + t.Setenv("ProgramData", "") + paths, err = DefaultPaths(ScopeSystem) + if err != nil { + t.Fatalf("DefaultPaths(ScopeSystem): %v", err) + } + wantRoot := filepath.Join(`C:\ProgramData`, "HFDesk") + if paths.StateDir != wantRoot { + t.Errorf("DefaultPaths(ScopeSystem).StateDir = %q, want %q", paths.StateDir, wantRoot) + } + default: + paths, err := DefaultPaths(ScopeSystem) + if err == nil { + t.Fatalf("DefaultPaths(ScopeSystem) on %s = %+v, want an error", runtime.GOOS, paths) + } + } +} + +func TestDefaultPathsScopesDoNotOverlap(t *testing.T) { + if runtime.GOOS != "linux" && runtime.GOOS != "darwin" { + t.Skipf("the scope roots are platform specific, running on %s", runtime.GOOS) + } + + user, err := DefaultPaths(ScopeUser) + if err != nil { + t.Fatalf("DefaultPaths(ScopeUser): %v", err) + } + system, err := DefaultPaths(ScopeSystem) + if err != nil { + t.Fatalf("DefaultPaths(ScopeSystem): %v", err) + } + + if user.StateDir == system.StateDir { + t.Errorf("both scopes use the state dir %q", user.StateDir) + } + + userPaths := []string{user.ConfigFile, user.StateDir, user.LogDir} + systemPaths := []string{system.ConfigFile, system.StateDir, system.LogDir} + + for _, p := range userPaths { + for _, root := range systemPaths { + if isInsidePath(p, root) { + t.Errorf("user scope path %q is inside the system scope path %q", p, root) + } + } + } + for _, p := range systemPaths { + for _, root := range userPaths { + if isInsidePath(p, root) { + t.Errorf("system scope path %q is inside the user scope path %q", p, root) + } + } + } + + // The machine-wide roots are the documented ones and never appear in a + // user scope path. + for _, p := range userPaths { + for _, root := range []string{"/etc/hfdesk", "/var/lib/hfdesk", "/var/log/hfdesk"} { + if isInsidePath(p, root) { + t.Errorf("user scope path %q is inside the system scope root %q", p, root) + } + } + } +} + +func TestDefaultCacheDirsSystemScope(t *testing.T) { + paths, err := DefaultPaths(ScopeSystem) + if err != nil { + t.Fatalf("DefaultPaths(ScopeSystem): %v", err) + } + + root, hub := DefaultCacheDirs(ScopeSystem, paths) + wantRoot := filepath.Join(paths.StateDir, "cache") + if root != wantRoot { + t.Errorf("cache root = %q, want %q", root, wantRoot) + } + if hub != filepath.Join(wantRoot, "hub") { + t.Errorf("hub dir = %q, want %q", hub, filepath.Join(wantRoot, "hub")) + } + if !filepath.IsAbs(root) || !filepath.IsAbs(hub) { + t.Errorf("cache paths are not absolute: root %q, hub %q", root, hub) + } + + // The machine-wide instance never uses the cache of the invoking user. + home, err := os.UserHomeDir() + if err != nil || home == "" || isRootDir(home) { + return + } + for _, p := range []string{root, hub} { + if isInsidePath(p, home) { + t.Errorf("system scope cache path %q is inside the home directory %q", p, home) + } + } +} + +func TestDefaultCacheDirsUserScope(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("windows has no user scope service") + } + paths, err := DefaultPaths(ScopeUser) + if err != nil { + t.Fatalf("DefaultPaths(ScopeUser): %v", err) + } + + tmp := t.TempDir() + cacheRoot := filepath.Join(tmp, "hf") + t.Setenv("HF_HOME", cacheRoot) + t.Setenv("HF_HUB_CACHE", "") + + root, hub := DefaultCacheDirs(ScopeUser, paths) + if root != cacheRoot { + t.Errorf("cache root = %q, want %q", root, cacheRoot) + } + if hub != filepath.Join(root, "hub") { + t.Errorf("hub dir = %q, want %q", hub, filepath.Join(root, "hub")) + } + if !filepath.IsAbs(root) || !filepath.IsAbs(hub) { + t.Errorf("cache paths are not absolute: root %q, hub %q", root, hub) + } + + // An explicit hub cache wins over the default hub of the root. + hubDir := filepath.Join(tmp, "hub") + t.Setenv("HF_HUB_CACHE", hubDir) + root, hub = DefaultCacheDirs(ScopeUser, paths) + if hub != hubDir { + t.Errorf("hub dir = %q, want %q", hub, hubDir) + } + if root != cacheRoot { + t.Errorf("cache root = %q, want %q", root, cacheRoot) + } +} + +func TestDefaultCacheDirsUserScopeIgnoresTheSystemStateRoot(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("windows has no user scope service") + } + system, err := DefaultPaths(ScopeSystem) + if err != nil { + t.Fatalf("DefaultPaths(ScopeSystem): %v", err) + } + + // A user scope instance keeps its own cache, even when the paths of the + // machine-wide instance are handed to it. + t.Setenv("HOME", t.TempDir()) + t.Setenv("HF_HOME", "") + t.Setenv("HF_HUB_CACHE", "") + + root, hub := DefaultCacheDirs(ScopeUser, system) + if isInsidePath(root, system.StateDir) { + t.Errorf("user scope cache root %q is inside the system state dir %q", root, system.StateDir) + } + if isInsidePath(hub, system.StateDir) { + t.Errorf("user scope hub dir %q is inside the system state dir %q", hub, system.StateDir) + } +} + +func TestDefaultPathsRejectsUnknownScope(t *testing.T) { + paths, err := DefaultPaths(Scope("bogus")) + if err == nil { + t.Fatalf("DefaultPaths(bogus) = %+v, want an error", paths) + } +} + +func TestDefaultCacheDirsUserScopeRootIsAbsolute(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("the windows home directory comes from USERPROFILE") + } + // Without HF_HOME and without a resolvable home directory the default + // cache dir is relative: the reported root must still be absolute. + t.Setenv("HF_HOME", "") + t.Setenv("HF_HUB_CACHE", "") + t.Setenv("HOME", "") + + root, hub := DefaultCacheDirs(ScopeUser, runtimepaths.Paths{}) + if !filepath.IsAbs(root) { + t.Errorf("cache root %q is not absolute", root) + } + if !filepath.IsAbs(hub) { + t.Errorf("hub dir %q is not absolute", hub) + } +} + +// assertAbsolutePaths checks that every path of a path model is absolute. +func assertAbsolutePaths(t *testing.T, scope string, paths runtimepaths.Paths) { + t.Helper() + for name, p := range map[string]string{ + "config file": paths.ConfigFile, + "state dir": paths.StateDir, + "log dir": paths.LogDir, + } { + if !filepath.IsAbs(p) { + t.Errorf("%s %s %q is not an absolute path", scope, name, p) + } + } +} + +// isInsidePath reports whether path is inside dir. +func isInsidePath(path, dir string) bool { + rel, err := filepath.Rel(dir, path) + if err != nil { + return false + } + if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return false + } + return true +} diff --git a/internal/service/paths_windows.go b/internal/service/paths_windows.go new file mode 100644 index 00000000..41955dd4 --- /dev/null +++ b/internal/service/paths_windows.go @@ -0,0 +1,49 @@ +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 + +//go:build windows + +package service + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/bashrusakh/hfdesk/internal/runtimepaths" +) + +// defaultPathsForScope returns the runtime paths of a service instance in the +// given scope. +// +// The Windows service control manager registers machine-wide services only, +// so a user scope installation does not exist on this platform and is +// rejected explicitly instead of silently falling back to a machine-wide +// root. +func defaultPathsForScope(scope Scope) (runtimepaths.Paths, error) { + switch scope { + case ScopeUser: + return runtimepaths.Paths{}, fmt.Errorf("service: scope %q is not supported on windows: the service control manager registers machine-wide services only", scope) + case ScopeSystem: + // The machine-wide instance never uses the profile of the user who + // happens to run the install command. + root := filepath.Join(programDataDir(), "HFDesk") + return runtimepaths.Paths{ + ConfigFile: filepath.Join(root, "hfdesk.json"), + StateDir: root, + LogDir: filepath.Join(root, "logs"), + }, nil + default: + return runtimepaths.Paths{}, fmt.Errorf("service: unknown scope %q (want %q)", scope, ScopeSystem) + } +} + +// programDataDir returns the machine-wide program data directory. The +// environment wins, so an installation follows a relocated ProgramData; the +// documented default is the fallback. +func programDataDir() string { + if dir := os.Getenv("ProgramData"); dir != "" { + return dir + } + return `C:\ProgramData` +} diff --git a/internal/service/process.go b/internal/service/process.go new file mode 100644 index 00000000..b5ae06fb --- /dev/null +++ b/internal/service/process.go @@ -0,0 +1,19 @@ +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 + +package service + +import "context" + +// RunProcess runs HFDesk as the process of a registered service. +// +// On most platforms the registered process is an ordinary foreground process +// and this is the headless host. On Windows a process that the service +// control manager started must first connect to the service dispatcher, so +// that the manager can request a stop and receive the lifecycle states. +// A process started by hand (a console run) runs the plain host instead. +// +// RunProcess never opens a browser and never creates a tray icon. +func RunProcess(ctx context.Context, opts RunOptions) error { + return runProcess(ctx, opts) +} diff --git a/internal/service/process_darwin.go b/internal/service/process_darwin.go new file mode 100644 index 00000000..2ffdb295 --- /dev/null +++ b/internal/service/process_darwin.go @@ -0,0 +1,14 @@ +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 + +//go:build darwin + +package service + +import "context" + +// runProcess runs the headless host: on this platform the supervisor starts +// an ordinary foreground process. +func runProcess(ctx context.Context, opts RunOptions) error { + return Run(ctx, opts) +} diff --git a/internal/service/process_linux.go b/internal/service/process_linux.go new file mode 100644 index 00000000..4bea2e51 --- /dev/null +++ b/internal/service/process_linux.go @@ -0,0 +1,14 @@ +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 + +//go:build linux + +package service + +import "context" + +// runProcess runs the headless host: on this platform the supervisor starts +// an ordinary foreground process. +func runProcess(ctx context.Context, opts RunOptions) error { + return Run(ctx, opts) +} diff --git a/internal/service/process_other.go b/internal/service/process_other.go new file mode 100644 index 00000000..a6a6c341 --- /dev/null +++ b/internal/service/process_other.go @@ -0,0 +1,14 @@ +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 + +//go:build !linux && !darwin && !windows + +package service + +import "context" + +// runProcess runs the headless host: on this platform the supervisor starts +// an ordinary foreground process. +func runProcess(ctx context.Context, opts RunOptions) error { + return Run(ctx, opts) +} diff --git a/internal/service/process_test.go b/internal/service/process_test.go new file mode 100644 index 00000000..e9202caa --- /dev/null +++ b/internal/service/process_test.go @@ -0,0 +1,65 @@ +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 + +package service + +import ( + "context" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/bashrusakh/hfdesk/internal/runtimepaths" +) + +func TestRunProcessInstallsTheRuntimeModelAndStopsOnCancel(t *testing.T) { + root := t.TempDir() + model := runtimepaths.Paths{ + ConfigFile: filepath.Join(root, "etc", "hfdesk.json"), + StateDir: filepath.Join(root, "var"), + LogDir: filepath.Join(root, "var", "logs"), + } + t.Cleanup(runtimepaths.Reset) + + start := time.Now() + if err := runServiceWith(t, RunOptions{Paths: model}, RunProcess); err != nil { + t.Fatalf("RunProcess() = %v, want nil", err) + } + if elapsed := time.Since(start); elapsed > 30*time.Second { + t.Errorf("RunProcess() returned after %s, want a prompt shutdown", elapsed) + } + + // The entry point of the registered process installs the path model of + // the instance, exactly like the headless host does. + if got := runtimepaths.Current(); got != model { + t.Errorf("runtimepaths.Current() = %+v, want %+v", got, model) + } +} + +func TestRunProcessRejectsAnInvalidListenAddress(t *testing.T) { + root := t.TempDir() + model := runtimepaths.Paths{ + ConfigFile: filepath.Join(root, "etc", "hfdesk.json"), + StateDir: filepath.Join(root, "var"), + LogDir: filepath.Join(root, "var", "logs"), + } + t.Cleanup(runtimepaths.Reset) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // The entry point of the registered process reports the error of the + // host instead of starting a half-configured instance. + err := RunProcess(ctx, RunOptions{Paths: model, Listen: "not-an-address"}) + if err == nil { + t.Fatal("RunProcess() = nil, want the error of the host for an invalid listen address") + } + if !strings.Contains(err.Error(), "invalid listen address") { + t.Errorf("RunProcess() = %v, want an invalid listen address error", err) + } +} + +// The registered process never opens a browser and never creates a tray +// icon. The marker guard of TestServiceLayerNeverLaunchesAGUI covers every +// file of the package, so it is not repeated here. diff --git a/internal/service/process_windows.go b/internal/service/process_windows.go new file mode 100644 index 00000000..cee453ee --- /dev/null +++ b/internal/service/process_windows.go @@ -0,0 +1,25 @@ +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 + +//go:build windows + +package service + +import ( + "context" + + "golang.org/x/sys/windows/svc" +) + +// runProcess runs HFDesk as the process of a registered service. +// +// A process started by the service control manager must connect to the +// service dispatcher first: that is how the manager learns the lifecycle +// state and how it requests a stop. A process started by hand (a console +// run) has no dispatcher and runs the plain headless host instead. +func runProcess(ctx context.Context, opts RunOptions) error { + if isService, err := svc.IsWindowsService(); err == nil && isService { + return runAsWindowsService(opts) + } + return Run(ctx, opts) +} diff --git a/internal/service/runargs.go b/internal/service/runargs.go new file mode 100644 index 00000000..87542e55 --- /dev/null +++ b/internal/service/runargs.go @@ -0,0 +1,154 @@ +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 + +package service + +import ( + "flag" + "fmt" + "io" + "path/filepath" + + "github.com/bashrusakh/hfdesk/internal/runtimepaths" +) + +// RunArgsHelp returns the flags of the "service run" argument vector with a +// one-line description each, in the order the usage text of the command +// lists them. +// +// Name is the flag name without the leading dashes ("config" for +// "--config"), so the caller can render it the way it likes. Usage is a +// single line that never ends with a period, so it can be printed as one +// help entry. +func RunArgsHelp() []struct{ Name, Usage string } { + return []struct{ Name, Usage string }{ + {"config", "absolute path of the config file of the instance"}, + {"state-dir", "absolute path of the state directory of the instance"}, + {"log-dir", "absolute path of the log directory of the instance"}, + {"listen", "host:port the instance binds (default " + DefaultListen + ")"}, + {"cache-dir", "absolute path of the Hugging Face cache root"}, + {"hub-dir", "absolute path of the Hugging Face hub cache directory"}, + {"local-dir", "absolute path of the flat output directory"}, + } +} + +// ParseRunArgs parses the argument vector of the service host process into +// the options of the headless host. +// +// The vector is the one RunArgs builds. The leading command tokens are +// optional, so the canonical vector ("service run --config ...") and the +// flags of the subcommand alone are both understood: the CLI hands over the +// flags of "service run", a platform service handler hands over the +// registered argument string. +// +// The parser never reads a credential. The flags it knows carry paths and a +// bind address only, so an argument that carries a token is not a flag of +// this parser: it is rejected as an unknown flag and its value never +// reaches an error message. +func ParseRunArgs(args []string) (RunOptions, error) { + fs := flag.NewFlagSet("service run", flag.ContinueOnError) + // The parser reports its own errors. A usage dump of the flag set is + // noise for a CLI and useless for a service handler that has no + // terminal at all. + fs.SetOutput(io.Discard) + + var ( + configFile string + stateDir string + logDir string + listen string + cacheDir string + hubDir string + localDir string + ) + + fs.StringVar(&configFile, "config", "", "absolute path of the config file of the instance") + fs.StringVar(&stateDir, "state-dir", "", "absolute path of the state directory of the instance") + fs.StringVar(&logDir, "log-dir", "", "absolute path of the log directory of the instance") + fs.StringVar(&listen, "listen", DefaultListen, "host:port the instance binds") + fs.StringVar(&cacheDir, "cache-dir", "", "absolute path of the Hugging Face cache root") + fs.StringVar(&hubDir, "hub-dir", "", "absolute path of the Hugging Face hub cache directory") + fs.StringVar(&localDir, "local-dir", "", "absolute path of the flat output directory") + + if err := fs.Parse(stripRunCommand(args)); err != nil { + return RunOptions{}, fmt.Errorf("service: parse run arguments: %w", err) + } + // Everything the parser does not know is a mistake: a positional + // argument would otherwise be dropped silently. Its value is not + // repeated in the error: a bare word is the shape a credential takes + // when it is handed over without a flag. + if rest := fs.Args(); len(rest) > 0 { + return RunOptions{}, fmt.Errorf("service: parse run arguments: unexpected argument, the vector carries flags only") + } + + // An instance is only started with a complete path model. A missing + // flag means a broken registration, not a default. + for _, required := range []struct { + flag string + value string + }{ + {"--config", configFile}, + {"--state-dir", stateDir}, + {"--log-dir", logDir}, + } { + if required.value == "" { + return RunOptions{}, fmt.Errorf("service: missing required flag %s", required.flag) + } + } + + // A supervised process must not resolve a path against the working + // directory of the supervisor, so every path of the vector is + // absolute. The optional directories may stay unset, but a set one is + // absolute as well. + for _, p := range []struct { + flag string + value string + }{ + {"--config", configFile}, + {"--state-dir", stateDir}, + {"--log-dir", logDir}, + {"--cache-dir", cacheDir}, + {"--hub-dir", hubDir}, + {"--local-dir", localDir}, + } { + if p.value == "" { + continue + } + if !filepath.IsAbs(p.value) { + return RunOptions{}, fmt.Errorf("service: %s %q must be an absolute path", p.flag, p.value) + } + } + + // The bind address is validated exactly as at install time: an empty + // host and an out-of-range port are rejected the same way. + if _, _, err := ParseListen(listen); err != nil { + return RunOptions{}, err + } + + return RunOptions{ + Paths: runtimepaths.Paths{ + ConfigFile: configFile, + StateDir: stateDir, + LogDir: logDir, + }, + // The address keeps the canonical shape the argument vector + // carries, so a parsed instance and a registered one agree. + Listen: listenArg(listen), + CacheDir: cacheDir, + HubDir: hubDir, + LocalDir: localDir, + }, nil +} + +// stripRunCommand removes the leading command tokens of the invocation, so +// the canonical vector ("service run --config ...") and the flags of the +// subcommand alone are both accepted. +func stripRunCommand(args []string) []string { + if len(args) >= 2 && args[0] == "service" && args[1] == "run" { + return args[2:] + } + if len(args) >= 1 && args[0] == "run" { + return args[1:] + } + return args +} diff --git a/internal/service/runargs_test.go b/internal/service/runargs_test.go new file mode 100644 index 00000000..1f77b7a9 --- /dev/null +++ b/internal/service/runargs_test.go @@ -0,0 +1,395 @@ +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 + +package service + +import ( + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/bashrusakh/hfdesk/internal/runtimepaths" +) + +// runArgsPaths returns an absolute path set for the parser tests. The paths +// are built from the temporary directory of the test, so they stay absolute +// on every platform. +func runArgsPaths(t *testing.T) (configFile, stateDir, logDir, cacheDir, hubDir, localDir string) { + t.Helper() + root := t.TempDir() + return filepath.Join(root, "etc", "hfdesk.json"), + filepath.Join(root, "var"), + filepath.Join(root, "var", "logs"), + filepath.Join(root, "cache"), + filepath.Join(root, "cache", "hub"), + filepath.Join(root, "models") +} + +// runArgsVector returns the canonical argument vector of an instance with +// every optional directory set. +func runArgsVector(t *testing.T) []string { + t.Helper() + configFile, stateDir, logDir, cacheDir, hubDir, localDir := runArgsPaths(t) + return []string{ + "service", "run", + "--config", configFile, + "--state-dir", stateDir, + "--log-dir", logDir, + "--listen", "127.0.0.1:9090", + "--cache-dir", cacheDir, + "--hub-dir", hubDir, + "--local-dir", localDir, + } +} + +func TestParseRunArgsParsesTheFullVector(t *testing.T) { + configFile, stateDir, logDir, cacheDir, hubDir, localDir := runArgsPaths(t) + + got, err := ParseRunArgs([]string{ + "service", "run", + "--config", configFile, + "--state-dir", stateDir, + "--log-dir", logDir, + "--listen", "127.0.0.1:9090", + "--cache-dir", cacheDir, + "--hub-dir", hubDir, + "--local-dir", localDir, + }) + if err != nil { + t.Fatalf("ParseRunArgs() = %v, want nil", err) + } + + want := RunOptions{ + Paths: runtimepaths.Paths{ + ConfigFile: configFile, + StateDir: stateDir, + LogDir: logDir, + }, + Listen: "127.0.0.1:9090", + CacheDir: cacheDir, + HubDir: hubDir, + LocalDir: localDir, + } + if got != want { + t.Errorf("ParseRunArgs() = %+v, want %+v", got, want) + } +} + +func TestParseRunArgsAcceptsTheEqualsForm(t *testing.T) { + configFile, stateDir, logDir, cacheDir, hubDir, localDir := runArgsPaths(t) + + spaceForm := []string{ + "service", "run", + "--config", configFile, + "--state-dir", stateDir, + "--log-dir", logDir, + "--listen", "127.0.0.1:9090", + "--cache-dir", cacheDir, + "--hub-dir", hubDir, + "--local-dir", localDir, + } + equalsForm := []string{ + "service", "run", + "--config=" + configFile, + "--state-dir=" + stateDir, + "--log-dir=" + logDir, + "--listen=127.0.0.1:9090", + "--cache-dir=" + cacheDir, + "--hub-dir=" + hubDir, + "--local-dir=" + localDir, + } + + fromSpace, err := ParseRunArgs(spaceForm) + if err != nil { + t.Fatalf("ParseRunArgs(%v) = %v, want nil", spaceForm, err) + } + fromEquals, err := ParseRunArgs(equalsForm) + if err != nil { + t.Fatalf("ParseRunArgs(%v) = %v, want nil", equalsForm, err) + } + if fromSpace != fromEquals { + t.Errorf("ParseRunArgs() = %+v for the equals form, want %+v", fromEquals, fromSpace) + } +} + +func TestParseRunArgsAcceptsTheFlagsWithoutTheCommandTokens(t *testing.T) { + configFile, stateDir, logDir, cacheDir, hubDir, localDir := runArgsPaths(t) + + flags := []string{ + "--config", configFile, + "--state-dir", stateDir, + "--log-dir", logDir, + "--cache-dir", cacheDir, + "--hub-dir", hubDir, + "--local-dir", localDir, + } + + got, err := ParseRunArgs(flags) + if err != nil { + t.Fatalf("ParseRunArgs(%v) = %v, want nil", flags, err) + } + withCommand, err := ParseRunArgs(append([]string{"service", "run"}, flags...)) + if err != nil { + t.Fatalf("ParseRunArgs() = %v, want nil", err) + } + if got != withCommand { + t.Errorf("ParseRunArgs() = %+v, want %+v for the vector with the command tokens", got, withCommand) + } +} + +func TestParseRunArgsRequiresTheMandatoryFlags(t *testing.T) { + base := runArgsVector(t) + + cases := []struct { + name string + flag string + }{ + {"missing config", "--config"}, + {"missing state dir", "--state-dir"}, + {"missing log dir", "--log-dir"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + args := dropFlag(base, tc.flag) + if _, err := ParseRunArgs(args); err == nil { + t.Fatalf("ParseRunArgs(%v) = nil, want an error", args) + } else if !strings.Contains(err.Error(), tc.flag) { + t.Errorf("ParseRunArgs() = %v, want the error to name %s", err, tc.flag) + } + }) + } +} + +func TestParseRunArgsRejectsRelativePaths(t *testing.T) { + base := runArgsVector(t) + + cases := []struct { + name string + flag string + value string + }{ + {"relative config", "--config", "etc/hfdesk.json"}, + {"relative state dir", "--state-dir", "var"}, + {"relative log dir", "--log-dir", "var/logs"}, + {"relative cache dir", "--cache-dir", "cache"}, + {"relative hub dir", "--hub-dir", "cache/hub"}, + {"relative local dir", "--local-dir", "models"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + args := replaceFlag(base, tc.flag, tc.value) + if _, err := ParseRunArgs(args); err == nil { + t.Fatalf("ParseRunArgs(%v) = nil, want an error", args) + } else if !strings.Contains(err.Error(), tc.flag) { + t.Errorf("ParseRunArgs() = %v, want the error to name %s", err, tc.flag) + } + }) + } +} + +func TestParseRunArgsValidatesTheListenAddress(t *testing.T) { + base := runArgsVector(t) + + for _, addr := range []string{":8080", "127.0.0.1:0", "127.0.0.1:70000", ""} { + args := replaceFlag(base, "--listen", addr) + if _, err := ParseRunArgs(args); err == nil { + t.Errorf("ParseRunArgs(%v) = nil, want an error for the listen address %q", args, addr) + } + } + + accepted := []struct { + addr string + want string + }{ + {"127.0.0.1:9090", "127.0.0.1:9090"}, + {"[::1]:8080", "[::1]:8080"}, + {"localhost:8080", "localhost:8080"}, + } + for _, tc := range accepted { + args := replaceFlag(base, "--listen", tc.addr) + got, err := ParseRunArgs(args) + if err != nil { + t.Errorf("ParseRunArgs(%v) = %v, want nil", args, err) + continue + } + if got.Listen != tc.want { + t.Errorf("Listen = %q, want %q", got.Listen, tc.want) + } + } +} + +func TestParseRunArgsIgnoresTheOrderOfTheFlags(t *testing.T) { + configFile, stateDir, logDir, _, _, _ := runArgsPaths(t) + + got, err := ParseRunArgs([]string{ + "--listen", "127.0.0.1:9090", + "--log-dir", logDir, + "--state-dir", stateDir, + "--config", configFile, + }) + if err != nil { + t.Fatalf("ParseRunArgs() = %v, want nil", err) + } + want := RunOptions{ + Paths: runtimepaths.Paths{ + ConfigFile: configFile, + StateDir: stateDir, + LogDir: logDir, + }, + Listen: "127.0.0.1:9090", + } + if got != want { + t.Errorf("ParseRunArgs() = %+v, want %+v", got, want) + } +} + +func TestParseRunArgsUsesTheDefaultListen(t *testing.T) { + args := dropFlag(runArgsVector(t), "--listen") + + got, err := ParseRunArgs(args) + if err != nil { + t.Fatalf("ParseRunArgs(%v) = %v, want nil", args, err) + } + if got.Listen != DefaultListen { + t.Errorf("Listen = %q, want the default %q", got.Listen, DefaultListen) + } +} + +func TestParseRunArgsRejectsAnUnknownFlagWithoutEchoingItsValue(t *testing.T) { + const secret = "hf_secret" + + cases := [][]string{ + append(runArgsVector(t), "--token", secret), + append(runArgsVector(t), "--token="+secret), + } + for _, args := range cases { + _, err := ParseRunArgs(args) + if err == nil { + t.Fatalf("ParseRunArgs(%v) = nil, want an error", args) + } + if strings.Contains(err.Error(), secret) { + t.Errorf("ParseRunArgs() error leaks the value of the unknown flag: %v", err) + } + } +} + +func TestParseRunArgsRejectsAPositionalArgument(t *testing.T) { + args := append(runArgsVector(t), "leftover") + if _, err := ParseRunArgs(args); err == nil { + t.Fatalf("ParseRunArgs(%v) = nil, want an error", args) + } +} + +func TestParseRunArgsRoundTripsTheRunArgsVector(t *testing.T) { + configFile, stateDir, logDir, cacheDir, hubDir, localDir := runArgsPaths(t) + + opts := InstallOptions{ + Scope: ScopeUser, + Listen: "127.0.0.1:8080", + Paths: runtimepaths.Paths{ + ConfigFile: configFile, + StateDir: stateDir, + LogDir: logDir, + }, + CacheDir: cacheDir, + HubDir: hubDir, + LocalDir: localDir, + } + + got, err := ParseRunArgs(RunArgs(opts)) + if err != nil { + t.Fatalf("ParseRunArgs(RunArgs(opts)) = %v, want nil", err) + } + want := RunOptions{ + Paths: opts.Paths, + Listen: opts.Listen, + CacheDir: opts.CacheDir, + HubDir: opts.HubDir, + LocalDir: opts.LocalDir, + } + if got != want { + t.Errorf("ParseRunArgs(RunArgs(opts)) = %+v, want %+v", got, want) + } +} + +func TestRunArgsHelpListsEveryFlag(t *testing.T) { + help := RunArgsHelp() + want := []string{"config", "state-dir", "log-dir", "listen", "cache-dir", "hub-dir", "local-dir"} + + if len(help) != len(want) { + t.Fatalf("RunArgsHelp() = %+v, want %d flags", help, len(want)) + } + for i, entry := range help { + if entry.Name != want[i] { + t.Errorf("RunArgsHelp()[%d].Name = %q, want %q", i, entry.Name, want[i]) + } + if strings.TrimSpace(entry.Usage) == "" { + t.Errorf("RunArgsHelp()[%d] (%s) has no usage text", i, entry.Name) + } + if strings.Contains(entry.Usage, "\n") { + t.Errorf("RunArgsHelp()[%d] (%s) usage is not a single line", i, entry.Name) + } + } +} + +// dropFlag removes the flag and its value from the argument vector. +func dropFlag(args []string, flag string) []string { + for i := 0; i < len(args); i++ { + if args[i] != flag { + continue + } + out := make([]string, 0, len(args)-2) + out = append(out, args[:i]...) + if i+1 < len(args) { + out = append(out, args[i+2:]...) + } + return out + } + return args +} + +// replaceFlag replaces the value of the flag in the argument vector. +func replaceFlag(args []string, flag string, value string) []string { + out := make([]string, len(args)) + copy(out, args) + for i := 0; i < len(out); i++ { + if out[i] == flag && i+1 < len(out) { + out[i+1] = value + return out + } + } + return out +} + +// TestRunArgsHelpMatchesTheParsedFlags keeps the help text and the parser in +// step: every flag the parser knows is listed with a usage. +func TestRunArgsHelpMatchesTheParsedFlags(t *testing.T) { + help := RunArgsHelp() + names := make([]string, 0, len(help)) + for _, entry := range help { + names = append(names, entry.Name) + } + + parsed := runArgsVector(t) + for i := 0; i < len(parsed); i++ { + arg := parsed[i] + if !strings.HasPrefix(arg, "--") { + continue + } + name := strings.TrimPrefix(arg, "--") + found := false + for _, candidate := range names { + if candidate == name { + found = true + break + } + } + if !found { + t.Errorf("the flag %s is parsed but missing from RunArgsHelp() = %v", arg, names) + } + } + if !reflect.DeepEqual(names, []string{"config", "state-dir", "log-dir", "listen", "cache-dir", "hub-dir", "local-dir"}) { + t.Errorf("RunArgsHelp() = %v, want the flags in the documented order", names) + } +} diff --git a/internal/service/service.go b/internal/service/service.go new file mode 100644 index 00000000..a5a4e775 --- /dev/null +++ b/internal/service/service.go @@ -0,0 +1,432 @@ +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 + +// Package service provides the platform-neutral service layer of HFDesk. +// +// The package owns everything about "run HFDesk as a background service" that +// is identical on every operating system: the installation scope and the +// lifecycle vocabulary, the validation rules of an installation, the canonical +// argument vector of the registered process and the headless host that runs +// the server. +// +// The supervisor specific parts (a systemd unit, a launchd property list, a +// Windows SCM registration) live in separate files with a build tag and +// implement the Manager interface. The shared layer never branches on the +// operating system: it owns the command semantics and the validation, the +// platform files own the supervisor details. +package service + +import ( + "context" + "errors" + "fmt" + "net" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/bashrusakh/hfdesk/internal/runtimepaths" + "github.com/bashrusakh/hfdesk/pkg/hfdownloader" +) + +const ( + // ServiceName is the supervisor-visible service name/label of a HFDesk + // instance. + ServiceName = "hfdesk" + + // DefaultListen is the bind address of a service instance. Service mode + // binds loopback by default, even though the interactive server defaults + // to 0.0.0.0: a supervised instance has no user sitting in front of it + // and must not expose the dashboard to the network by accident. + DefaultListen = "127.0.0.1:8080" + + // dirMode is the permission mode of a directory owned by a service + // installation. + dirMode os.FileMode = 0o700 +) + +// ErrNotImplemented reports that service mode is not available on the +// current platform yet. +var ErrNotImplemented = errors.New("service mode is not implemented for this platform") + +// Scope selects the installation scope of a service instance. +type Scope string + +const ( + // ScopeUser is a per-user installation. + ScopeUser Scope = "user" + // ScopeSystem is a machine-wide installation. + ScopeSystem Scope = "system" +) + +// State is the lifecycle state of a registered service. +type State string + +const ( + // StateNotInstalled means the service is not registered. + StateNotInstalled State = "not-installed" + // StateStopped means the service is registered but not running. + StateStopped State = "stopped" + // StateStarting means the service is starting. + StateStarting State = "starting" + // StateRunning means the service is running. + StateRunning State = "running" + // StateStopping means the service is shutting down. + StateStopping State = "stopping" + // StateFailed means the service failed. + StateFailed State = "failed" + // StateBroken means the service is registered, but its executable is + // gone. + StateBroken State = "broken" + // StateUnknown means the supervisor did not report a usable state. + StateUnknown State = "unknown" +) + +// Status describes the current state of a registered service. +type Status struct { + // State is the lifecycle state of the instance. + State State + // Scope is the installation scope of the instance. + Scope Scope + // Name is the supervisor-visible service name/label. + Name string + // UnitPath is the path of the supervisor registration file. + UnitPath string + // Executable is the absolute path the registration points at. + Executable string + // Listen is the host:port the service binds. + Listen string + // Paths are the absolute runtime paths of the instance. + Paths runtimepaths.Paths + // Detail is a human-readable detail, e.g. the last error. + Detail string +} + +// InstallOptions describes one service installation. +type InstallOptions struct { + // Scope is the installation scope. + Scope Scope + // Executable is the absolute path of the hfdesk binary. + Executable string + // Listen is the host:port to bind. + Listen string + // Paths are the absolute config/state/log paths of the instance. + Paths runtimepaths.Paths + // CacheDir is the absolute HF cache root. It may be empty. + CacheDir string + // HubDir is the absolute HF hub dir. It may be empty. + HubDir string + // LocalDir is the absolute flat output dir. It may be empty. + LocalDir string + // User is the target user name of a user scope installation. + User string + // UID is the launchd gui/ target of a user scope installation. + UID string +} + +// Manager drives the platform supervisor. The common layer owns the command +// semantics and the validation; the platform implementations own the +// supervisor details. +type Manager interface { + // Install registers the service with the platform supervisor. + Install(ctx context.Context, opts InstallOptions) error + // Uninstall removes the registration of the service. + Uninstall(ctx context.Context) error + // Start starts the registered service. + Start(ctx context.Context) error + // Stop stops the registered service. + Stop(ctx context.Context) error + // Restart stops and starts the registered service. + Restart(ctx context.Context) error + // Status reports the state of the registered service. + Status(ctx context.Context) (Status, error) +} + +// New returns the manager of the platform supervisor for the given scope. +// The scope is validated here so an unknown scope never reaches a platform +// implementation. +func New(scope Scope) (Manager, error) { + switch scope { + case ScopeUser, ScopeSystem: + default: + return nil, fmt.Errorf("service: unknown scope %q (want %q or %q)", scope, ScopeUser, ScopeSystem) + } + return newPlatformManager(scope) +} + +// ParseScope converts a user supplied scope name into a Scope. The comparison +// is case-insensitive; every other value is rejected. +func ParseScope(s string) (Scope, error) { + switch strings.ToLower(strings.TrimSpace(s)) { + case string(ScopeUser): + return ScopeUser, nil + case string(ScopeSystem): + return ScopeSystem, nil + default: + return "", fmt.Errorf("service: unknown scope %q (want %q or %q)", s, ScopeUser, ScopeSystem) + } +} + +// ParseListen splits a listen address into its host and port. A bracketed +// IPv6 literal ("[::1]:8080") is accepted. The host must be non-empty: an +// empty host ("":8080") binds every interface and is rejected here, so a +// service instance never listens on the whole network by accident. The port +// must be in the range 1-65535. +func ParseListen(addr string) (string, int, error) { + addr = strings.TrimSpace(addr) + if addr == "" { + return "", 0, errors.New("service: listen address is required") + } + + host, portText, err := net.SplitHostPort(addr) + if err != nil { + return "", 0, fmt.Errorf("service: invalid listen address %q: %w", addr, err) + } + if host == "" { + return "", 0, fmt.Errorf("service: invalid listen address %q: the host is empty, an empty host binds every interface", addr) + } + + port, err := strconv.Atoi(portText) + if err != nil { + return "", 0, fmt.Errorf("service: invalid listen address %q: %q is not a port number", addr, portText) + } + if port < 1 || port > 65535 { + return "", 0, fmt.Errorf("service: invalid listen address %q: port %d is out of range (1-65535)", addr, port) + } + return host, port, nil +} + +// IsLoopbackListen reports whether the address binds a loopback interface. +// It is true for a loopback IP literal (127.0.0.0/8 or ::1) and for the +// literal host name "localhost". Every other address, including the wildcard +// addresses 0.0.0.0 and ::, is not loopback. +func IsLoopbackListen(addr string) bool { + host, _, err := ParseListen(addr) + if err != nil { + return false + } + return isLoopbackHost(host) +} + +// isLoopbackHost reports whether the host part of a listen address is +// loopback. +func isLoopbackHost(host string) bool { + if strings.EqualFold(host, "localhost") { + return true + } + ip := net.ParseIP(host) + if ip == nil { + return false + } + return ip.IsLoopback() +} + +// RunArgs returns the canonical argument vector of the registered service +// process. The vector carries paths and the bind address only: a token, a +// proxy password or a basic auth password is never part of it, because a +// supervisor registration is readable by every user of the machine. The +// credentials of an instance come from its own config file. +// +// An empty Listen yields DefaultListen so the vector is always complete. +func RunArgs(opts InstallOptions) []string { + args := []string{ + "service", + "run", + "--config", opts.Paths.ConfigFile, + "--state-dir", opts.Paths.StateDir, + "--log-dir", opts.Paths.LogDir, + "--listen", listenArg(opts.Listen), + } + if opts.CacheDir != "" { + args = append(args, "--cache-dir", opts.CacheDir) + } + if opts.HubDir != "" { + args = append(args, "--hub-dir", opts.HubDir) + } + if opts.LocalDir != "" { + args = append(args, "--local-dir", opts.LocalDir) + } + return args +} + +// listenArg normalizes a listen address for the argument vector. An invalid +// address is passed through unchanged: the caller validates it before the +// registration is written. +func listenArg(listen string) string { + listen = strings.TrimSpace(listen) + if listen == "" { + return DefaultListen + } + if host, port, err := ParseListen(listen); err == nil { + return net.JoinHostPort(host, strconv.Itoa(port)) + } + return listen +} + +// ValidateInstallOptions rejects an installation that cannot be registered or +// started safely: an unusable executable, a relative runtime path, a relative +// output directory, an invalid listen address or an unknown scope. +func ValidateInstallOptions(opts InstallOptions) error { + switch opts.Scope { + case ScopeUser, ScopeSystem: + default: + return fmt.Errorf("service: invalid scope %q (want %q or %q)", opts.Scope, ScopeUser, ScopeSystem) + } + + if opts.Executable == "" { + return errors.New("service: executable is required") + } + if !filepath.IsAbs(opts.Executable) { + return fmt.Errorf("service: executable %q must be an absolute path", opts.Executable) + } + info, err := os.Stat(opts.Executable) + if err != nil { + return fmt.Errorf("service: executable %q is not usable: %w", opts.Executable, err) + } + if !info.Mode().IsRegular() { + return fmt.Errorf("service: executable %q is not a regular file", opts.Executable) + } + + for _, p := range []struct { + name string + path string + }{ + {"config file", opts.Paths.ConfigFile}, + {"state dir", opts.Paths.StateDir}, + {"log dir", opts.Paths.LogDir}, + } { + if p.path == "" { + return fmt.Errorf("service: %s path is required", p.name) + } + if !filepath.IsAbs(p.path) { + return fmt.Errorf("service: %s path %q must be an absolute path", p.name, p.path) + } + } + + for _, p := range []struct { + name string + path string + }{ + {"cache dir", opts.CacheDir}, + {"hub dir", opts.HubDir}, + {"local dir", opts.LocalDir}, + } { + if p.path == "" { + continue + } + if !filepath.IsAbs(p.path) { + return fmt.Errorf("service: %s %q must be an absolute path", p.name, p.path) + } + } + + if _, _, err := ParseListen(opts.Listen); err != nil { + return err + } + return nil +} + +// PrepareDirs creates the directories an installation owns: the parent +// directory of the config file, the state directory and the log directory. +// They are created with mode 0700 and an existing directory is tightened to +// the same mode. Nothing outside those three directories is touched. +func PrepareDirs(opts InstallOptions) error { + dirs := []struct { + name string + path string + }{ + {"config dir", filepath.Dir(opts.Paths.ConfigFile)}, + {"state dir", opts.Paths.StateDir}, + {"log dir", opts.Paths.LogDir}, + } + + seen := make(map[string]bool, len(dirs)) + for _, dir := range dirs { + if dir.path == "" || dir.path == "." { + return fmt.Errorf("service: %s path is required", dir.name) + } + if !filepath.IsAbs(dir.path) { + return fmt.Errorf("service: %s path %q must be an absolute path", dir.name, dir.path) + } + if isRootDir(dir.path) { + return fmt.Errorf("service: %s path %q is the filesystem root and must not be tightened", dir.name, dir.path) + } + if seen[dir.path] { + continue + } + seen[dir.path] = true + + if err := os.MkdirAll(dir.path, dirMode); err != nil { + return fmt.Errorf("service: create %s %q: %w", dir.name, dir.path, err) + } + if err := os.Chmod(dir.path, dirMode); err != nil { + return fmt.Errorf("service: restrict %s %q: %w", dir.name, dir.path, err) + } + } + return nil +} + +// isRootDir reports whether the path is the root of its filesystem. Such a +// directory is never owned by an installation: tightening it to 0700 would +// take the whole filesystem away from its users. +func isRootDir(path string) bool { + clean := filepath.Clean(path) + return clean == filepath.VolumeName(clean)+string(filepath.Separator) +} + +// DefaultPaths returns the default runtime paths of a service instance in the +// given scope: the config file the instance owns, the state directory it +// writes and the directory its log files live in. +// +// The paths are the single source of truth of an installation. The CLI, the +// platform managers and the documentation all derive their paths from them, +// so a user scope instance and a system scope instance can never end up on +// the same state root: a user scope instance keeps its state under the +// per-user configuration directory, a system scope instance under the +// machine-wide directories of the platform. +// +// The scope is rejected with an error when the platform does not support it. +func DefaultPaths(scope Scope) (runtimepaths.Paths, error) { + return defaultPathsForScope(scope) +} + +// DefaultCacheDirs returns the absolute Hugging Face cache root and the +// absolute hub directory of a service instance in the given scope. +// +// A system scope instance uses the cache of its own state root +// (/cache): a machine-wide instance must not read or write the +// cache of the user who happens to run the install command. A user scope +// instance uses the Hugging Face cache of the user itself (HF_HOME or +// ~/.cache/huggingface), so it keeps access to the models the user already +// downloaded. +// +// The hub directory is always reported, even when HF_HUB_CACHE is unset: the +// registered process needs an explicit hub path. An unknown scope is treated +// like ScopeSystem, so an instance that is not known to be a user instance +// never reaches the personal cache of the invoking user. +func DefaultCacheDirs(scope Scope, paths runtimepaths.Paths) (string, string) { + if scope == ScopeUser { + root := absolutePath(hfdownloader.DefaultCacheDir()) + hub := filepath.Join(root, "hub") + if env := os.Getenv("HF_HUB_CACHE"); env != "" { + hub = absolutePath(env) + } + return root, hub + } + + root := absolutePath(filepath.Join(paths.StateDir, "cache")) + return root, filepath.Join(root, "hub") +} + +// absolutePath returns the absolute form of path. A path that cannot be +// resolved (the working directory is unavailable) is returned unchanged, so +// the caller never receives an empty path. +func absolutePath(path string) string { + if path == "" { + return "" + } + if abs, err := filepath.Abs(path); err == nil { + return abs + } + return path +} diff --git a/internal/service/service_test.go b/internal/service/service_test.go new file mode 100644 index 00000000..ce877dad --- /dev/null +++ b/internal/service/service_test.go @@ -0,0 +1,479 @@ +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 + +package service + +import ( + "errors" + "os" + "path/filepath" + "reflect" + "runtime" + "sort" + "strings" + "testing" + + "github.com/bashrusakh/hfdesk/internal/runtimepaths" +) + +// fullInstallOptions returns a fully populated installation. The paths carry +// no credential-looking fragment so the argument vector checks stay exact. +func fullInstallOptions(t *testing.T) InstallOptions { + t.Helper() + const root = "/opt/hfdesk" + return InstallOptions{ + Scope: ScopeUser, + Executable: root + "/bin/hfdesk", + Listen: "127.0.0.1:8080", + Paths: runtimepaths.Paths{ + ConfigFile: root + "/etc/hfdesk.json", + StateDir: root + "/var", + LogDir: root + "/var/logs", + }, + CacheDir: root + "/cache", + HubDir: root + "/cache/hub", + LocalDir: root + "/models", + } +} + +func TestRunArgsMatchesCanonicalArgv(t *testing.T) { + got := RunArgs(fullInstallOptions(t)) + want := []string{ + "service", + "run", + "--config", "/opt/hfdesk/etc/hfdesk.json", + "--state-dir", "/opt/hfdesk/var", + "--log-dir", "/opt/hfdesk/var/logs", + "--listen", "127.0.0.1:8080", + "--cache-dir", "/opt/hfdesk/cache", + "--hub-dir", "/opt/hfdesk/cache/hub", + "--local-dir", "/opt/hfdesk/models", + } + if !reflect.DeepEqual(got, want) { + t.Errorf("RunArgs() =\n%v\nwant\n%v", got, want) + } +} + +func TestRunArgsOmitsUnsetDirectories(t *testing.T) { + opts := InstallOptions{ + Scope: ScopeSystem, + Listen: "127.0.0.1:9090", + Paths: runtimepaths.Paths{ + ConfigFile: "/opt/hfdesk/etc/hfdesk.json", + StateDir: "/opt/hfdesk/var", + LogDir: "/opt/hfdesk/var/logs", + }, + } + got := RunArgs(opts) + want := []string{ + "service", + "run", + "--config", "/opt/hfdesk/etc/hfdesk.json", + "--state-dir", "/opt/hfdesk/var", + "--log-dir", "/opt/hfdesk/var/logs", + "--listen", "127.0.0.1:9090", + } + if !reflect.DeepEqual(got, want) { + t.Errorf("RunArgs() =\n%v\nwant\n%v", got, want) + } + for _, arg := range got { + if strings.HasPrefix(arg, "--cache-dir") || strings.HasPrefix(arg, "--hub-dir") || strings.HasPrefix(arg, "--local-dir") { + t.Errorf("RunArgs() = %v, want no directory flag for an unset directory", got) + } + } +} + +func TestRunArgsUsesDefaultListenWhenUnset(t *testing.T) { + opts := fullInstallOptions(t) + opts.Listen = "" + got := RunArgs(opts) + if want := DefaultListen; !containsValue(got, want) { + t.Errorf("RunArgs() = %v, want the default listen %q", got, want) + } +} + +func TestRunArgsCarriesNoCredential(t *testing.T) { + const secret = "hf_SECRETTOKEN_zzz" + + opts := fullInstallOptions(t) + // InstallOptions has no credential field today. Every field that looks + // like one is filled with the sentinel, so the check keeps its meaning + // when such a field is added. + setCredentialFields(&opts, secret) + + args := RunArgs(opts) + for _, arg := range args { + if strings.Contains(arg, secret) { + t.Errorf("RunArgs() leaks a credential: %v", args) + } + } + for _, arg := range args { + if looksLikeCredential(arg) { + t.Errorf("RunArgs() contains the credential-looking argument %q: %v", arg, args) + } + } +} + +func TestParseListenAcceptsValidAddresses(t *testing.T) { + cases := []struct { + addr string + host string + port int + }{ + {"127.0.0.1:8080", "127.0.0.1", 8080}, + {"[::1]:8080", "::1", 8080}, + {"localhost:8080", "localhost", 8080}, + {"127.0.0.1:1", "127.0.0.1", 1}, + {"127.0.0.1:65535", "127.0.0.1", 65535}, + } + for _, tc := range cases { + host, port, err := ParseListen(tc.addr) + if err != nil { + t.Errorf("ParseListen(%q) = error %v, want (%q, %d)", tc.addr, err, tc.host, tc.port) + continue + } + if host != tc.host || port != tc.port { + t.Errorf("ParseListen(%q) = (%q, %d), want (%q, %d)", tc.addr, host, port, tc.host, tc.port) + } + } +} + +func TestParseListenRejectsInvalidAddresses(t *testing.T) { + invalid := []string{ + "", + " ", + ":8080", + "127.0.0.1", + "127.0.0.1:0", + "127.0.0.1:70000", + "127.0.0.1:http", + "[::1]", + "::1:8080", + } + for _, addr := range invalid { + host, port, err := ParseListen(addr) + if err == nil { + t.Errorf("ParseListen(%q) = (%q, %d), want an error", addr, host, port) + } + } +} + +func TestIsLoopbackListen(t *testing.T) { + loopback := []string{ + "127.0.0.1:8080", + "127.5.5.5:1", + "[::1]:8080", + "localhost:8080", + } + for _, addr := range loopback { + if !IsLoopbackListen(addr) { + t.Errorf("IsLoopbackListen(%q) = false, want true", addr) + } + } + + notLoopback := []string{ + "0.0.0.0:8080", + "192.168.1.10:8080", + "[::]:8080", + ":8080", + "", + "example.com:8080", + } + for _, addr := range notLoopback { + if IsLoopbackListen(addr) { + t.Errorf("IsLoopbackListen(%q) = true, want false", addr) + } + } +} + +func TestParseScope(t *testing.T) { + accepted := map[string]Scope{ + "user": ScopeUser, + "system": ScopeSystem, + "USER": ScopeUser, + "System": ScopeSystem, + } + for input, want := range accepted { + got, err := ParseScope(input) + if err != nil { + t.Errorf("ParseScope(%q) = error %v, want %q", input, err, want) + continue + } + if got != want { + t.Errorf("ParseScope(%q) = %q, want %q", input, got, want) + } + } + + for _, input := range []string{"", "root", "machine"} { + if got, err := ParseScope(input); err == nil { + t.Errorf("ParseScope(%q) = %q, want an error", input, got) + } + } +} + +func TestValidateInstallOptionsAcceptsValidInstallation(t *testing.T) { + root := t.TempDir() + executable := filepath.Join(root, "bin", "hfdesk") + if err := os.MkdirAll(filepath.Dir(executable), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(executable, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + + opts := InstallOptions{ + Scope: ScopeSystem, + Executable: executable, + Listen: "127.0.0.1:8080", + Paths: runtimepaths.Paths{ + ConfigFile: filepath.Join(root, "etc", "hfdesk.json"), + StateDir: filepath.Join(root, "var"), + LogDir: filepath.Join(root, "var", "logs"), + }, + CacheDir: filepath.Join(root, "cache"), + HubDir: filepath.Join(root, "cache", "hub"), + LocalDir: filepath.Join(root, "models"), + } + if err := ValidateInstallOptions(opts); err != nil { + t.Fatalf("ValidateInstallOptions() = %v, want nil", err) + } +} + +func TestValidateInstallOptionsRejectsInvalidInstallation(t *testing.T) { + root := t.TempDir() + executable := filepath.Join(root, "bin", "hfdesk") + if err := os.MkdirAll(filepath.Dir(executable), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(executable, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + + valid := func() InstallOptions { + return InstallOptions{ + Scope: ScopeUser, + Executable: executable, + Listen: "127.0.0.1:8080", + Paths: runtimepaths.Paths{ + ConfigFile: filepath.Join(root, "etc", "hfdesk.json"), + StateDir: filepath.Join(root, "var"), + LogDir: filepath.Join(root, "var", "logs"), + }, + CacheDir: filepath.Join(root, "cache"), + HubDir: filepath.Join(root, "cache", "hub"), + LocalDir: filepath.Join(root, "models"), + } + } + + cases := []struct { + name string + mutate func(*InstallOptions) + }{ + {"empty executable", func(o *InstallOptions) { o.Executable = "" }}, + {"relative executable", func(o *InstallOptions) { o.Executable = "bin/hfdesk" }}, + {"missing executable", func(o *InstallOptions) { o.Executable = filepath.Join(root, "bin", "missing") }}, + {"executable is a directory", func(o *InstallOptions) { o.Executable = filepath.Dir(executable) }}, + {"empty config file", func(o *InstallOptions) { o.Paths.ConfigFile = "" }}, + {"relative config file", func(o *InstallOptions) { o.Paths.ConfigFile = "etc/hfdesk.json" }}, + {"empty state dir", func(o *InstallOptions) { o.Paths.StateDir = "" }}, + {"relative state dir", func(o *InstallOptions) { o.Paths.StateDir = "var" }}, + {"empty log dir", func(o *InstallOptions) { o.Paths.LogDir = "" }}, + {"relative log dir", func(o *InstallOptions) { o.Paths.LogDir = "var/logs" }}, + {"relative cache dir", func(o *InstallOptions) { o.CacheDir = "cache" }}, + {"relative hub dir", func(o *InstallOptions) { o.HubDir = "cache/hub" }}, + {"relative local dir", func(o *InstallOptions) { o.LocalDir = "models" }}, + {"empty listen", func(o *InstallOptions) { o.Listen = "" }}, + {"invalid listen", func(o *InstallOptions) { o.Listen = "127.0.0.1:0" }}, + {"wildcard listen", func(o *InstallOptions) { o.Listen = ":8080" }}, + {"unknown scope", func(o *InstallOptions) { o.Scope = Scope("root") }}, + {"empty scope", func(o *InstallOptions) { o.Scope = "" }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + opts := valid() + tc.mutate(&opts) + if err := ValidateInstallOptions(opts); err == nil { + t.Errorf("ValidateInstallOptions() = nil, want an error") + } + }) + } +} + +func TestPrepareDirsCreatesOwnedDirectories(t *testing.T) { + root := t.TempDir() + configDir := filepath.Join(root, "etc") + stateDir := filepath.Join(root, "var") + logDir := filepath.Join(root, "var", "logs") + + // An existing state directory with loose permissions must be tightened. + if err := os.MkdirAll(stateDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.Chmod(stateDir, 0o755); err != nil { + t.Fatal(err) + } + + opts := InstallOptions{ + Paths: runtimepaths.Paths{ + ConfigFile: filepath.Join(configDir, "hfdesk.json"), + StateDir: stateDir, + LogDir: logDir, + }, + } + if err := PrepareDirs(opts); err != nil { + t.Fatalf("PrepareDirs() = %v, want nil", err) + } + + for _, dir := range []string{configDir, stateDir, logDir} { + info, err := os.Stat(dir) + if err != nil { + t.Errorf("stat %s: %v", dir, err) + continue + } + if !info.IsDir() { + t.Errorf("%s is not a directory", dir) + continue + } + if runtime.GOOS != "windows" && info.Mode().Perm() != 0o700 { + t.Errorf("%s mode = %v, want 0700", dir, info.Mode().Perm()) + } + } +} + +func TestPrepareDirsLeavesUnrelatedPathsAlone(t *testing.T) { + root := t.TempDir() + unrelatedDir := filepath.Join(root, "other") + if err := os.MkdirAll(unrelatedDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.Chmod(unrelatedDir, 0o755); err != nil { + t.Fatal(err) + } + unrelatedFile := filepath.Join(root, "keep.txt") + if err := os.WriteFile(unrelatedFile, []byte("keep"), 0o644); err != nil { + t.Fatal(err) + } + + opts := InstallOptions{ + Paths: runtimepaths.Paths{ + ConfigFile: filepath.Join(root, "etc", "hfdesk.json"), + StateDir: filepath.Join(root, "var"), + LogDir: filepath.Join(root, "var", "logs"), + }, + } + if err := PrepareDirs(opts); err != nil { + t.Fatalf("PrepareDirs() = %v, want nil", err) + } + + if runtime.GOOS != "windows" { + info, err := os.Stat(unrelatedDir) + if err != nil { + t.Fatalf("stat %s: %v", unrelatedDir, err) + } + if info.Mode().Perm() != 0o755 { + t.Errorf("%s mode = %v, want the untouched 0755", unrelatedDir, info.Mode().Perm()) + } + } + data, err := os.ReadFile(unrelatedFile) + if err != nil { + t.Fatalf("read %s: %v", unrelatedFile, err) + } + if string(data) != "keep" { + t.Errorf("%s = %q, want the untouched content", unrelatedFile, data) + } + + entries, err := os.ReadDir(root) + if err != nil { + t.Fatal(err) + } + var names []string + for _, entry := range entries { + names = append(names, entry.Name()) + } + sort.Strings(names) + want := []string{"etc", "keep.txt", "other", "var"} + if !reflect.DeepEqual(names, want) { + t.Errorf("entries of %s = %v, want %v", root, names, want) + } +} + +func TestPrepareDirsRejectsTheFilesystemRoot(t *testing.T) { + root := t.TempDir() + opts := InstallOptions{ + Paths: runtimepaths.Paths{ + // A config file directly in the root would tighten the root + // itself to 0700. + ConfigFile: string(filepath.Separator) + "hfdesk.json", + StateDir: filepath.Join(root, "var"), + LogDir: filepath.Join(root, "var", "logs"), + }, + } + if err := PrepareDirs(opts); err == nil { + t.Error("PrepareDirs() = nil, want an error for the filesystem root") + } +} + +func TestNewRejectsUnknownScope(t *testing.T) { + if m, err := New(Scope("root")); err == nil { + t.Errorf("New(root) = %v, want an error", m) + } +} + +func TestNewReturnsThePlatformManager(t *testing.T) { + m, err := New(ScopeUser) + if err == nil { + if m == nil { + t.Error("New() = nil, nil; want a manager") + } + return + } + // The platform managers are stubs until the platform work lands: the + // error must say so instead of returning a broken manager. + if !errors.Is(err, ErrNotImplemented) && !strings.Contains(err.Error(), "not supported on") { + t.Errorf("New() = %v, want ErrNotImplemented or an unsupported-platform error", err) + } + if m != nil { + t.Errorf("New() = %v, want a nil manager next to the error", m) + } +} + +// containsValue reports whether the argument vector holds the value. +func containsValue(args []string, value string) bool { + for _, arg := range args { + if arg == value { + return true + } + } + return false +} + +// credentialMarkers are the fragments a credential carries. +var credentialMarkers = []string{"hf_", "token", "password", "passwd", "secret", "credential", "bearer"} + +// looksLikeCredential reports whether a value carries a credential marker. +func looksLikeCredential(value string) bool { + lower := strings.ToLower(value) + for _, marker := range credentialMarkers { + if strings.Contains(lower, marker) { + return true + } + } + return false +} + +// setCredentialFields fills every credential-looking string field of opts +// with the sentinel. +func setCredentialFields(opts *InstallOptions, sentinel string) { + value := reflect.ValueOf(opts).Elem() + typ := value.Type() + for i := 0; i < typ.NumField(); i++ { + field := typ.Field(i) + if field.Type.Kind() != reflect.String { + continue + } + if !looksLikeCredential(strings.ToLower(field.Name)) { + continue + } + value.Field(i).SetString(sentinel) + } +} From 1898b46c3d65dd1d7dc6b3c5e22c9fa47dd36923 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 21 Oct 2026 20:39:12 +1100 Subject: [PATCH 03/58] service: add the systemd, launchd and SCM managers - Linux: register a systemd user unit by default and a machine-wide unit for the explicit system scope, managed through systemctl --user and systemctl. The unit stays a foreground process (Type=simple) and never forks, writes a PID file or daemonizes itself. Linger is never enabled silently: booting without a login stays an explicit user or admin decision. - macOS: register a per-user LaunchAgent by default and a LaunchDaemon for the explicit system scope, managed with launchctl bootstrap, bootout and kickstart. The plist keeps the program arguments absolute and free of credentials, and HFDesk writes its own bounded log file because launchd does not rotate the ones it owns. - Windows: register the service with the service control manager through the Go API, never through sc.exe or PowerShell. The service runs as the least-privilege LocalService account, its state under ProgramData carries restrictive ACLs, and the handler reports StartPending, Running only once the HTTP server is usable, StopPending and Stopped around the graceful shutdown, with lifecycle and fatal messages in the Windows Event Log. - Make install, uninstall, start, stop and restart idempotent with deterministic outcomes, roll a failed installation back instead of leaving a half-configured registration, and report a registration whose executable was moved or deleted as broken. - Keep uninstall free of data deletion: it removes the registration only and never touches models, cache, config, jobs/history or logs. Co-Authored-By: opencode --- internal/service/manager_darwin.go | 1158 ++++++++++++++++++++++ internal/service/manager_darwin_test.go | 728 ++++++++++++++ internal/service/manager_linux.go | 888 +++++++++++++++++ internal/service/manager_linux_test.go | 588 +++++++++++ internal/service/manager_other.go | 17 + internal/service/manager_windows.go | 693 +++++++++++++ internal/service/manager_windows_test.go | 331 +++++++ internal/service/scm_windows.go | 442 +++++++++ internal/service/scm_windows_test.go | 203 ++++ 9 files changed, 5048 insertions(+) create mode 100644 internal/service/manager_darwin.go create mode 100644 internal/service/manager_darwin_test.go create mode 100644 internal/service/manager_linux.go create mode 100644 internal/service/manager_linux_test.go create mode 100644 internal/service/manager_other.go create mode 100644 internal/service/manager_windows.go create mode 100644 internal/service/manager_windows_test.go create mode 100644 internal/service/scm_windows.go create mode 100644 internal/service/scm_windows_test.go diff --git a/internal/service/manager_darwin.go b/internal/service/manager_darwin.go new file mode 100644 index 00000000..660c6e2c --- /dev/null +++ b/internal/service/manager_darwin.go @@ -0,0 +1,1158 @@ +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 + +//go:build darwin + +package service + +import ( + "bytes" + "context" + "encoding/xml" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "text/template" + "time" + + "github.com/bashrusakh/hfdesk/internal/runtimepaths" +) + +// The macOS service mode of HFDesk is a launchd registration. +// +// launchd owns the lifecycle of the instance: the registered program stays +// in the foreground, launchd starts it at load time and restarts it after a +// failure. HFDesk never forks, never daemonizes itself, never writes a PID +// file and never runs the program through a shell: the property list holds +// the absolute path of the executable and the canonical argument vector of +// the service host, nothing else. +// +// A user scope instance is a LaunchAgent: it belongs to the user who +// installed it and it lives in the gui domain of that user. A system scope +// instance is a LaunchDaemon: it belongs to the machine, it runs as root and +// it lives in the system domain. The machine-wide instance never reuses the +// state of a user: its home is the machine-level state root of the platform +// and its paths are the machine-level paths. +// +// The label of the registration is com.hfdesk.hfdesk. It is derived from the +// service name ("hfdesk") and the reverse DNS prefix of the project, and it +// is also the name of the property list file: launchd refuses to load a +// property list whose file name does not match its label. +const ( + // launchdLabelPrefix is the reverse DNS prefix of the launchd label. + launchdLabelPrefix = "com.hfdesk." + + // launchAgentsDir is the directory of the per-user LaunchAgents, + // relative to the home directory of the user. + launchAgentsDir = "Library/LaunchAgents" + + // launchDaemonsDir is the directory of the machine-wide LaunchDaemons. + launchDaemonsDir = "/Library/LaunchDaemons" + + // systemHome is the home directory of a machine-wide instance. It is + // the machine-level state root of the platform: it is never the home + // directory of the user who runs the install command and never the + // personal home of root (/var/root). + systemHome = "/var/lib/hfdesk" + + // plistMode is the permission mode of the property list. launchd + // reads the file as the user of the domain, so it has to be readable + // by that user. + plistMode os.FileMode = 0o644 + + // plistDirMode is the permission mode of the directory that holds the + // property list. + plistDirMode os.FileMode = 0o755 +) + +// stopWaitTimeout is the time the manager waits for the graceful shutdown of +// the instance before it unloads the job. +const stopWaitTimeout = 10 * time.Second + +// stopPollInterval is the interval between two checks of the state of the +// job. +const stopPollInterval = 100 * time.Millisecond + +// launchdManager manages a HFDesk instance with launchd. +type launchdManager struct { + // scope is the installation scope of the instance. + scope Scope + // uid is the numeric user id of the invoking user. It is the target + // of the gui domain of a user scope instance. + uid string + // home is the home directory of the invoking user. It holds the + // property list of a user scope instance. + home string +} + +// newPlatformManager returns the launchd manager of the given scope. +func newPlatformManager(scope Scope) (Manager, error) { + switch scope { + case ScopeUser, ScopeSystem: + default: + return nil, fmt.Errorf("service: unknown scope %q (want %q or %q)", scope, ScopeUser, ScopeSystem) + } + + manager := &launchdManager{ + scope: scope, + uid: strconv.Itoa(os.Getuid()), + } + // The home directory of the invoking user is the base of the property + // list of a user scope instance and the reference that keeps a + // machine-wide instance away from the state of a user. A machine-wide + // installation does not need it, so a missing home directory is not an + // error there. + home, err := os.UserHomeDir() + if err == nil { + manager.home = home + } else if scope == ScopeUser { + return nil, fmt.Errorf("service: resolve the home directory of the user scope instance: %w", err) + } + return manager, nil +} + +// launchdLabel returns the launchd label of the instance. +func launchdLabel() string { + return launchdLabelPrefix + ServiceName +} + +// plistPath returns the path of the property list of the instance. +func (m *launchdManager) plistPath() string { + if m.scope == ScopeSystem { + return filepath.Join(launchDaemonsDir, launchdLabel()+".plist") + } + return filepath.Join(m.home, launchAgentsDir, launchdLabel()+".plist") +} + +// domain returns the launchd domain the instance is registered in. +// +// A LaunchAgent belongs to the gui domain of its user, a LaunchDaemon to the +// system domain. The gui domain of the invoking user is used even when the +// installation options carry another user id: registering a job into the +// domain of another user needs root and is not a per-user installation. +func (m *launchdManager) domain() string { + if m.scope == ScopeSystem { + return "system" + } + return "gui/" + m.uid +} + +// target returns the launchd target of the instance. +func (m *launchdManager) target() string { + return m.domain() + "/" + launchdLabel() +} + +// Install registers the instance with launchd. +// +// The property list is rendered, written atomically and loaded into the +// domain of the instance. An installation that fails after the property list +// was written restores the previous registration, so a failed installation +// never leaves a half configured registration behind. +func (m *launchdManager) Install(ctx context.Context, opts InstallOptions) error { + if err := ValidateInstallOptions(opts); err != nil { + return err + } + if err := PrepareDirs(opts); err != nil { + return err + } + if err := m.preflight(opts); err != nil { + return err + } + + content, err := renderPlist(opts) + if err != nil { + return err + } + + path := m.plistPath() + backup, err := backupPlist(path) + if err != nil { + return err + } + changed := !backup.existed || !bytes.Equal(backup.content, []byte(content)) + if err := writePlist(path, []byte(content), plistMode); err != nil { + return err + } + + bootstrapped, err := m.load(ctx, path, changed) + if err != nil { + return m.rollback(ctx, backup, bootstrapped, err) + } + return nil +} + +// Uninstall removes the registration of the instance. +// +// Only the registration is removed. The config file, the jobs, the history, +// the logs, the models and the cache of the instance are left alone: an +// uninstall removes the registration of the service, not its data. +func (m *launchdManager) Uninstall(ctx context.Context) error { + path := m.plistPath() + if _, err := os.Stat(path); err != nil { + if os.IsNotExist(err) { + // There is no registration: the uninstall is already done. + return nil + } + return fmt.Errorf("service: read the registration %q: %w", path, err) + } + + // The job is unloaded before the property list is removed, so launchd + // never keeps a registration whose file is gone. A job that is not + // loaded any more is not an error: the registration is removed either + // way. + if _, err := runLaunchctl(ctx, "bootout", m.target()); err != nil && !isServiceMissing(err) { + return fmt.Errorf("service: bootout %s: %w", m.target(), err) + } + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("service: remove the registration %q: %w", path, err) + } + return nil +} + +// Start starts the registered instance. +// +// The job is started with "launchctl kickstart", which starts a job that is +// not running and leaves a running job alone: starting a running instance +// is a stable no-op. A job that was unloaded by a stop is registered again +// from the property list that is on disk, so a start after a stop works +// without a reinstall. +func (m *launchdManager) Start(ctx context.Context) error { + path := m.plistPath() + if _, err := os.Stat(path); err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("service: %s is not installed", m.target()) + } + return fmt.Errorf("service: read the registration %q: %w", path, err) + } + if err := m.ensureLoaded(ctx, path); err != nil { + return err + } + if _, err := runLaunchctl(ctx, "kickstart", m.target()); err != nil { + return fmt.Errorf("service: kickstart %s: %w", m.target(), err) + } + return nil +} + +// Stop stops the registered instance. +// +// The graceful shutdown is driven by HFDesk itself: SIGTERM lets the server +// quiesce, drain its connections and close the job manager before the +// process goes away. The job is unloaded afterwards, because a process that +// was killed by a signal exits with a non-zero status and launchd would +// restart it under the KeepAlive policy of the registration. The unload +// makes the stop stable and leaves the property list in place, so a later +// start brings the instance back. +// +// Stopping an instance that does not run is a stable no-op: the state the +// caller asked for already holds. +func (m *launchdManager) Stop(ctx context.Context) error { + path := m.plistPath() + if _, err := os.Stat(path); err != nil { + if os.IsNotExist(err) { + // The instance is not registered, so it does not run either. + return nil + } + return fmt.Errorf("service: read the registration %q: %w", path, err) + } + + _, output, err := m.printState(ctx) + if err != nil { + return err + } + _, _, pid := launchdPrintFields(output) + + if _, err := runLaunchctl(ctx, "kill", "SIGTERM", m.target()); err != nil && !isServiceMissing(err) { + return fmt.Errorf("service: kill SIGTERM %s: %w", m.target(), err) + } + if err := m.waitForExit(ctx, pid); err != nil { + return err + } + if _, err := runLaunchctl(ctx, "bootout", m.target()); err != nil && !isServiceMissing(err) { + return fmt.Errorf("service: bootout %s: %w", m.target(), err) + } + return nil +} + +// Restart restarts the registered instance. +// +// The job is restarted with "launchctl kickstart -k": the running process +// is replaced by a new one instead of waiting for it to end. +func (m *launchdManager) Restart(ctx context.Context) error { + path := m.plistPath() + if _, err := os.Stat(path); err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("service: %s is not installed", m.target()) + } + return fmt.Errorf("service: read the registration %q: %w", path, err) + } + if err := m.ensureLoaded(ctx, path); err != nil { + return err + } + if _, err := runLaunchctl(ctx, "kickstart", "-k", m.target()); err != nil { + return fmt.Errorf("service: kickstart -k %s: %w", m.target(), err) + } + return nil +} + +// Status reports the state of the registered instance. +// +// The description of the instance comes from the property list that is on +// disk, so the status describes the registration and not the intent of the +// caller. The state comes from launchd itself. +func (m *launchdManager) Status(ctx context.Context) (Status, error) { + path := m.plistPath() + status := Status{ + State: StateNotInstalled, + Scope: m.scope, + Name: launchdLabel(), + UnitPath: path, + } + + content, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + // There is no registration at all. + status.Detail = fmt.Sprintf("no launchd registration at %s", path) + return status, nil + } + return status, fmt.Errorf("service: read the registration %q: %w", path, err) + } + registration, err := parsePlist(content) + if err != nil { + return status, fmt.Errorf("service: read the registration %q: %w", path, err) + } + status.Executable = registration.executable() + status.Listen = registration.listen() + status.Paths = registration.paths() + + loaded, output, err := m.printState(ctx) + if err != nil { + return status, err + } + if !loaded { + // The registration is on disk but the job is not loaded into its + // domain: the instance is registered and does not run. + status.State = StateStopped + status.Detail = fmt.Sprintf("%s is not loaded into %s", launchdLabel(), m.domain()) + } else { + status.State = parseLaunchdPrint(output) + if status.State == StateFailed { + _, exitCode, _ := launchdPrintFields(output) + status.Detail = fmt.Sprintf("the instance exited with the status %d", exitCode) + } + } + + // A registration that points at a program that is gone is broken: the + // instance can never run again until it is installed again. + if status.Executable != "" { + if _, err := os.Stat(status.Executable); err != nil { + status.State = StateBroken + status.Detail = fmt.Sprintf("the registered executable %s does not exist", status.Executable) + } + } + return status, nil +} + +// preflight checks the conditions an installation needs before it touches +// the system. +func (m *launchdManager) preflight(opts InstallOptions) error { + if _, err := exec.LookPath("launchctl"); err != nil { + return fmt.Errorf("service: launchctl is not available: %w", err) + } + if _, _, err := ParseListen(opts.Listen); err != nil { + return err + } + info, err := os.Stat(opts.Executable) + if err != nil { + return fmt.Errorf("service: executable %q is not usable: %w", opts.Executable, err) + } + if info.Mode().Perm()&0o111 == 0 { + return fmt.Errorf("service: executable %q is not executable", opts.Executable) + } + if m.scope == ScopeSystem { + return m.checkSystemPaths(opts) + } + return nil +} + +// checkSystemPaths rejects a machine-wide installation that would reuse the +// state of a user. +// +// A LaunchDaemon runs as root. A state directory inside the home directory +// of the invoking user would either expose the state of the user to the +// machine or take the state of the user away from it, so the installation is +// refused instead of silently taking the state over. The model and the cache +// trees are never touched by an installation: only the three directories of +// the instance (the config directory, the state directory and the log +// directory) are created, and nothing is ever chowned. +func (m *launchdManager) checkSystemPaths(opts InstallOptions) error { + home := m.home + if home == "" { + return nil + } + for _, p := range []struct { + name string + path string + }{ + {"config file", opts.Paths.ConfigFile}, + {"state dir", opts.Paths.StateDir}, + {"log dir", opts.Paths.LogDir}, + {"cache dir", opts.CacheDir}, + {"hub dir", opts.HubDir}, + {"local dir", opts.LocalDir}, + } { + if p.path == "" { + continue + } + if pathWithin(p.path, home) { + return fmt.Errorf("service: the %s %q of a machine-wide instance must not live inside the home directory %q of the invoking user", p.name, p.path, home) + } + } + return nil +} + +// load registers the property list with launchd and reports whether the job +// was loaded by this call. +// +// A job that is not loaded yet is bootstrapped. A job that is already loaded +// keeps its registration: an installation that does not change the property +// list must not restart the running instance. An installation that does +// change the property list loads the job again, so the new configuration +// takes effect instead of waiting for the next boot of the machine. +func (m *launchdManager) load(ctx context.Context, path string, changed bool) (bool, error) { + loaded, _, err := m.printState(ctx) + if err != nil { + return false, err + } + + bootstrapped := false + switch { + case !loaded: + if _, err := runLaunchctl(ctx, "bootstrap", m.domain(), path); err != nil { + return false, fmt.Errorf("service: bootstrap %s: %w", m.target(), err) + } + bootstrapped = true + case changed: + if _, err := runLaunchctl(ctx, "bootout", m.target()); err != nil && !isServiceMissing(err) { + return false, fmt.Errorf("service: bootout %s: %w", m.target(), err) + } + if _, err := runLaunchctl(ctx, "bootstrap", m.domain(), path); err != nil { + return false, fmt.Errorf("service: bootstrap %s: %w", m.target(), err) + } + bootstrapped = true + } + + // A job that was disabled in the override database of launchd would + // never start, so the disabled flag is cleared explicitly. The job is + // loaded at this point, so the command can address it. + if _, err := runLaunchctl(ctx, "enable", m.target()); err != nil { + return bootstrapped, fmt.Errorf("service: enable %s: %w", m.target(), err) + } + return bootstrapped, nil +} + +// ensureLoaded registers the job with launchd when it is not loaded into its +// domain any more. +func (m *launchdManager) ensureLoaded(ctx context.Context, path string) error { + loaded, _, err := m.printState(ctx) + if err != nil { + return err + } + if loaded { + return nil + } + if _, err := runLaunchctl(ctx, "bootstrap", m.domain(), path); err != nil { + return fmt.Errorf("service: bootstrap %s: %w", m.target(), err) + } + return nil +} + +// printState reports whether the job is loaded into its domain and returns +// the output of "launchctl print". +func (m *launchdManager) printState(ctx context.Context) (bool, string, error) { + output, err := runLaunchctl(ctx, "print", m.target()) + if err != nil { + if isServiceMissing(err) { + return false, "", nil + } + return false, "", fmt.Errorf("service: print %s: %w", m.target(), err) + } + return true, output, nil +} + +// waitForExit waits until the registered process is gone. +// +// The wait is bounded: a process that ignores the signal must not block the +// caller forever. The unload that follows ends it in that case. +func (m *launchdManager) waitForExit(ctx context.Context, pid int) error { + deadline := time.Now().Add(stopWaitTimeout) + for { + loaded, output, err := m.printState(ctx) + if err != nil { + return err + } + if !loaded { + // The job is not loaded any more. + return nil + } + state, _, current := launchdPrintFields(output) + if state != "running" { + return nil + } + if pid > 0 && current != pid { + // The process was replaced: the old one is gone. + return nil + } + if time.Now().After(deadline) { + return nil + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(stopPollInterval): + } + } +} + +// rollback undoes a failed installation: the job is unloaded again and the +// property list is restored to the state it had before the installation. +func (m *launchdManager) rollback(ctx context.Context, backup *plistBackup, bootstrapped bool, cause error) error { + if bootstrapped { + if _, err := runLaunchctl(ctx, "bootout", m.target()); err != nil && !isServiceMissing(err) { + return fmt.Errorf("%w (the registration could not be unloaded: %v)", cause, err) + } + } + if err := backup.restore(); err != nil { + return fmt.Errorf("%w (the property list could not be restored: %v)", cause, err) + } + return cause +} + +// plistBackup is the state of a property list before it was overwritten. +type plistBackup struct { + // path is the path of the property list. + path string + // content is the content of the property list before the change. + content []byte + // mode is the permission mode of the property list before the change. + mode os.FileMode + // existed reports whether there was a property list at all. + existed bool +} + +// backupPlist reads the property list at path, if it exists. +func backupPlist(path string) (*plistBackup, error) { + backup := &plistBackup{path: path} + content, err := os.ReadFile(path) + switch { + case err == nil: + backup.existed = true + backup.content = content + if info, err := os.Stat(path); err == nil { + backup.mode = info.Mode().Perm() + } + case os.IsNotExist(err): + // There was no registration before. + default: + return nil, fmt.Errorf("service: read the registration %q: %w", path, err) + } + return backup, nil +} + +// restore puts the property list back into the state it had before the +// installation. +func (b *plistBackup) restore() error { + if !b.existed { + if err := os.Remove(b.path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("service: remove the registration %q: %w", b.path, err) + } + return nil + } + return writePlist(b.path, b.content, b.mode) +} + +// writePlist writes a property list to path. +// +// The file is written to a temporary file in the same directory and renamed +// afterwards, so launchd never reads a half written property list. +func writePlist(path string, content []byte, mode os.FileMode) error { + if mode == 0 { + mode = plistMode + } + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, plistDirMode); err != nil { + return fmt.Errorf("service: create the registration directory %q: %w", dir, err) + } + tmp, err := os.CreateTemp(dir, ".hfdesk-plist-*") + if err != nil { + return fmt.Errorf("service: create a temporary property list in %q: %w", dir, err) + } + name := tmp.Name() + // The temporary file is removed when it was not renamed onto the + // registration. + defer os.Remove(name) + + if _, err := tmp.Write(content); err != nil { + tmp.Close() + return fmt.Errorf("service: write the temporary property list %q: %w", name, err) + } + if err := tmp.Chmod(mode); err != nil { + tmp.Close() + return fmt.Errorf("service: set the mode of %q: %w", name, err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("service: close the temporary property list %q: %w", name, err) + } + if err := os.Rename(name, path); err != nil { + return fmt.Errorf("service: install the property list %q: %w", path, err) + } + return nil +} + +// runLaunchctl runs launchctl with the given arguments and returns its +// standard output. +// +// The command is never run through a shell: the arguments are passed to +// launchctl as they are, so a path with a space or a quote in it cannot +// change the command. +func runLaunchctl(ctx context.Context, args ...string) (string, error) { + path, err := exec.LookPath("launchctl") + if err != nil { + return "", fmt.Errorf("service: launchctl is not available: %w", err) + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd := exec.CommandContext(ctx, path, args...) + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + detail := strings.TrimSpace(stderr.String()) + if detail == "" { + detail = strings.TrimSpace(stdout.String()) + } + if detail != "" { + return stdout.String(), fmt.Errorf("service: launchctl %s: %w: %s", strings.Join(args, " "), err, detail) + } + return stdout.String(), fmt.Errorf("service: launchctl %s: %w", strings.Join(args, " "), err) + } + return stdout.String(), nil +} + +// serviceMissingMarkers are the messages launchctl prints when a job is not +// loaded into its domain, or when it is not running. +var serviceMissingMarkers = []string{ + "could not find service", + "service not found", + "no such service", + "not found", + "no such process", + "not running", +} + +// isServiceMissing reports whether the error of a launchctl call says that +// the job is not loaded into its domain, or that it is not running. +func isServiceMissing(err error) bool { + if err == nil { + return false + } + text := strings.ToLower(err.Error()) + for _, marker := range serviceMissingMarkers { + if strings.Contains(text, marker) { + return true + } + } + return false +} + +// pathWithin reports whether path is inside dir. +func pathWithin(path, dir string) bool { + if dir == "" || path == "" { + return false + } + rel, err := filepath.Rel(dir, path) + if err != nil { + return false + } + if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return false + } + return true +} + +// plistTemplate is the launchd property list of a HFDesk instance. +// +// The property list is rendered from a template, so the same options always +// produce the same bytes and the bytes can be inspected without touching +// the system. +// +// The property list carries no StandardOutPath and no StandardErrorPath: +// launchd does not rotate those files, so they would grow without bound. +// HFDesk writes its own bounded rotating log file into the log directory of +// the instance instead (see the service host), and the standard output and +// the standard error of the process stay connected to the supervisor. +// +// The environment of the process carries the home directory and nothing +// else: a token, a proxy password or a basic auth password is never part of +// a registration, because a property list is readable by every user of the +// machine. The credentials of an instance come from its own config file. +const plistTemplate = ` + + + + Label + {{.Label | xml}} + ProgramArguments + +{{- range .ProgramArguments}} + {{. | xml}} +{{- end}} + + WorkingDirectory + {{.WorkingDirectory | xml}} + RunAtLoad + + KeepAlive + + SuccessfulExit + + + ProcessType + Background + EnvironmentVariables + + HOME + {{.Home | xml}} + +{{- if .System}} + UserName + root + GroupName + wheel +{{- end}} + + +` + +// plistFuncs are the template functions of the property list. Every value +// is escaped, so a path with a markup character in it cannot break the +// property list. +var plistFuncs = template.FuncMap{ + "xml": func(value string) (string, error) { + var buf bytes.Buffer + if err := xml.EscapeText(&buf, []byte(value)); err != nil { + return "", err + } + return buf.String(), nil + }, +} + +// plistData is the data of the property list template. +type plistData struct { + // Label is the launchd label of the instance. + Label string + // ProgramArguments is the argument vector of the registered process. + ProgramArguments []string + // WorkingDirectory is the state directory of the instance. + WorkingDirectory string + // Home is the home directory of the registered process. + Home string + // System reports whether the instance is a machine-wide one. + System bool +} + +// renderPlist renders the launchd property list of an installation. +// +// The function is pure: it reads the environment of the caller and it +// writes nothing, so the property list of an installation can be inspected +// and tested without touching the machine. +func renderPlist(opts InstallOptions) (string, error) { + if err := validatePlistOptions(opts); err != nil { + return "", err + } + home, err := plistHome(opts) + if err != nil { + return "", err + } + + data := plistData{ + Label: launchdLabel(), + ProgramArguments: append([]string{opts.Executable}, RunArgs(opts)...), + WorkingDirectory: opts.Paths.StateDir, + Home: home, + System: opts.Scope == ScopeSystem, + } + tmpl, err := template.New("plist").Funcs(plistFuncs).Parse(plistTemplate) + if err != nil { + return "", fmt.Errorf("service: parse the property list template: %w", err) + } + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return "", fmt.Errorf("service: render the property list: %w", err) + } + return buf.String(), nil +} + +// validatePlistOptions rejects the options a property list cannot be +// rendered from. The check does not touch the file system: the existence of +// the executable is verified by the installation itself. +func validatePlistOptions(opts InstallOptions) error { + switch opts.Scope { + case ScopeUser, ScopeSystem: + default: + return fmt.Errorf("service: invalid scope %q (want %q or %q)", opts.Scope, ScopeUser, ScopeSystem) + } + if opts.Executable == "" { + return fmt.Errorf("service: executable is required") + } + if !filepath.IsAbs(opts.Executable) { + return fmt.Errorf("service: executable %q must be an absolute path", opts.Executable) + } + for _, p := range []struct { + name string + path string + }{ + {"config file", opts.Paths.ConfigFile}, + {"state dir", opts.Paths.StateDir}, + {"log dir", opts.Paths.LogDir}, + } { + if p.path == "" { + return fmt.Errorf("service: %s path is required", p.name) + } + if !filepath.IsAbs(p.path) { + return fmt.Errorf("service: %s path %q must be an absolute path", p.name, p.path) + } + } + return nil +} + +// plistHome returns the home directory the registered process sees. +// +// A user scope instance runs as the user of its gui domain, so it keeps the +// home directory of that user. A machine-wide instance never inherits the +// home directory of the user who happens to run the install command and +// never inherits the personal home of root: it uses the machine-level +// state root of the platform. +func plistHome(opts InstallOptions) (string, error) { + if opts.Scope == ScopeSystem { + return systemHome, nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("service: resolve the home directory of the user scope instance: %w", err) + } + if home == "" || !filepath.IsAbs(home) { + return "", fmt.Errorf("service: the home directory of the user scope instance is not an absolute path") + } + return home, nil +} + +// plistFile is the document element of a property list. +type plistFile struct { + XMLName xml.Name `xml:"plist"` + Dict plistDict `xml:"dict"` +} + +// plistDict is a dictionary of a property list: an ordered list of keys with +// their values. +type plistDict struct { + keys []string + values map[string]plistValue +} + +// UnmarshalXML reads the key/value pairs of a dictionary element. +func (d *plistDict) UnmarshalXML(dec *xml.Decoder, start xml.StartElement) error { + d.keys = nil + d.values = map[string]plistValue{} + for { + token, err := dec.Token() + if err != nil { + return err + } + switch element := token.(type) { + case xml.StartElement: + if element.Name.Local != "key" { + // A dictionary holds keys and values only: anything else + // is skipped instead of shifting the pairs. + if err := dec.Skip(); err != nil { + return err + } + continue + } + var key string + if err := dec.DecodeElement(&key, &element); err != nil { + return err + } + value, err := decodePlistValue(dec) + if err != nil { + return err + } + if _, seen := d.values[key]; !seen { + d.keys = append(d.keys, key) + } + d.values[key] = value + case xml.EndElement: + return nil + } + } +} + +// get returns the value of a key. +func (d *plistDict) get(key string) (plistValue, bool) { + value, ok := d.values[key] + return value, ok +} + +// keyList returns the keys of the dictionary in the order they appear. +func (d *plistDict) keyList() []string { + return append([]string(nil), d.keys...) +} + +// plistValue is one value of a property list. +type plistValue struct { + // Kind is the name of the element the value was written as. + Kind string + // Text is the content of a scalar value. + Text string + // Dict is the content of a dictionary value. + Dict *plistDict + // Array is the content of an array value. + Array []plistValue +} + +// UnmarshalXML reads one value element of a property list. +func (v *plistValue) UnmarshalXML(dec *xml.Decoder, start xml.StartElement) error { + v.Kind = start.Name.Local + switch v.Kind { + case "dict": + dict := &plistDict{} + if err := dec.DecodeElement(dict, &start); err != nil { + return err + } + v.Dict = dict + case "array": + items, err := decodePlistChildren(dec) + if err != nil { + return err + } + v.Array = items + default: + var text string + if err := dec.DecodeElement(&text, &start); err != nil { + return err + } + v.Text = text + } + return nil +} + +// stringValue returns the value as a string. +func (v plistValue) stringValue() (string, bool) { + if v.Kind != "string" { + return "", false + } + return v.Text, true +} + +// boolValue returns the value as a boolean. +func (v plistValue) boolValue() (bool, bool) { + switch v.Kind { + case "true": + return true, true + case "false": + return false, true + } + return false, false +} + +// decodePlistChildren reads the child elements of the current element. +func decodePlistChildren(dec *xml.Decoder) ([]plistValue, error) { + var items []plistValue + for { + token, err := dec.Token() + if err != nil { + return nil, err + } + switch element := token.(type) { + case xml.StartElement: + var item plistValue + if err := dec.DecodeElement(&item, &element); err != nil { + return nil, err + } + items = append(items, item) + case xml.EndElement: + return items, nil + } + } +} + +// decodePlistValue reads the value element that follows a key. +func decodePlistValue(dec *xml.Decoder) (plistValue, error) { + for { + token, err := dec.Token() + if err != nil { + return plistValue{}, err + } + switch element := token.(type) { + case xml.StartElement: + var value plistValue + if err := dec.DecodeElement(&value, &element); err != nil { + return plistValue{}, err + } + return value, nil + case xml.EndElement: + return plistValue{}, fmt.Errorf("the property list holds a key without a value") + } + } +} + +// plistRegistration is the registration a launchd property list describes. +type plistRegistration struct { + // Label is the launchd label of the registration. + Label string + // ProgramArguments is the argument vector of the registered process. + ProgramArguments []string + // WorkingDirectory is the working directory of the process. + WorkingDirectory string + // Environment is the environment of the process. + Environment map[string]string + // UserName is the user the process runs as. + UserName string + // GroupName is the group the process runs as. + GroupName string + // RunAtLoad reports whether launchd starts the job when it is loaded. + RunAtLoad bool + // KeepAliveSuccessfulExit is the "SuccessfulExit" key of the KeepAlive + // dictionary. It is false in a HFDesk registration: the job is + // restarted after a failure, not after a clean exit. + KeepAliveSuccessfulExit bool + // Keys are the keys of the property list, in the order they appear. + Keys []string +} + +// parsePlist reads a launchd property list. +func parsePlist(content []byte) (plistRegistration, error) { + registration := plistRegistration{Environment: map[string]string{}} + var file plistFile + if err := xml.Unmarshal(content, &file); err != nil { + return registration, fmt.Errorf("parse the property list: %w", err) + } + + registration.Keys = file.Dict.keyList() + for _, key := range registration.Keys { + value, _ := file.Dict.get(key) + switch key { + case "Label": + registration.Label, _ = value.stringValue() + case "ProgramArguments": + for _, item := range value.Array { + text, _ := item.stringValue() + registration.ProgramArguments = append(registration.ProgramArguments, text) + } + case "WorkingDirectory": + registration.WorkingDirectory, _ = value.stringValue() + case "EnvironmentVariables": + if value.Dict == nil { + continue + } + for _, name := range value.Dict.keyList() { + envValue, _ := value.Dict.get(name) + text, _ := envValue.stringValue() + registration.Environment[name] = text + } + case "UserName": + registration.UserName, _ = value.stringValue() + case "GroupName": + registration.GroupName, _ = value.stringValue() + case "RunAtLoad": + registration.RunAtLoad, _ = value.boolValue() + case "KeepAlive": + if value.Dict == nil { + continue + } + keepAlive, _ := value.Dict.get("SuccessfulExit") + registration.KeepAliveSuccessfulExit, _ = keepAlive.boolValue() + } + } + return registration, nil +} + +// executable returns the program the registration points at. +func (r plistRegistration) executable() string { + if len(r.ProgramArguments) == 0 { + return "" + } + return r.ProgramArguments[0] +} + +// argumentValue returns the value of a flag of the argument vector. +func (r plistRegistration) argumentValue(flag string) string { + for i, arg := range r.ProgramArguments { + if arg == flag && i+1 < len(r.ProgramArguments) { + return r.ProgramArguments[i+1] + } + } + return "" +} + +// listen returns the bind address of the registration. +func (r plistRegistration) listen() string { + return r.argumentValue("--listen") +} + +// paths returns the runtime path model of the registration. +func (r plistRegistration) paths() runtimepaths.Paths { + return runtimepaths.Paths{ + ConfigFile: r.argumentValue("--config"), + StateDir: r.argumentValue("--state-dir"), + LogDir: r.argumentValue("--log-dir"), + } +} + +// parseLaunchdPrint maps the output of "launchctl print" to the state of the +// instance. +// +// A job that is not running is stopped, unless it exited with a non-zero +// status: such a job failed. +func parseLaunchdPrint(output string) State { + state, exitCode, _ := launchdPrintFields(output) + switch state { + case "running": + return StateRunning + case "exited", "not running": + if exitCode != 0 { + return StateFailed + } + return StateStopped + case "waiting": + return StateStarting + default: + return StateUnknown + } +} + +// launchdPrintFields extracts the state, the last exit code and the process +// id from the output of "launchctl print". +func launchdPrintFields(output string) (state string, exitCode int, pid int) { + for _, line := range strings.Split(output, "\n") { + line = strings.TrimSpace(line) + switch { + case strings.HasPrefix(line, "state = "): + state = strings.TrimSpace(strings.TrimPrefix(line, "state = ")) + case strings.HasPrefix(line, "last exit code = "): + code, err := strconv.Atoi(strings.TrimSpace(strings.TrimPrefix(line, "last exit code = "))) + if err == nil { + exitCode = code + } + case strings.HasPrefix(line, "pid = "): + value, err := strconv.Atoi(strings.TrimSpace(strings.TrimPrefix(line, "pid = "))) + if err == nil { + pid = value + } + } + } + return state, exitCode, pid +} diff --git a/internal/service/manager_darwin_test.go b/internal/service/manager_darwin_test.go new file mode 100644 index 00000000..0aefca89 --- /dev/null +++ b/internal/service/manager_darwin_test.go @@ -0,0 +1,728 @@ +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 + +//go:build darwin + +package service + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "reflect" + "strconv" + "strings" + "testing" + "time" + + "github.com/bashrusakh/hfdesk/internal/runtimepaths" +) + +// darwinCredentialSentinel is the value the credential looking fields of an +// installation carry in the tests. It must never reach a registration. +const darwinCredentialSentinel = "hf_sentinel_TOKEN_9f2b7c" + +// darwinUserOptions returns the options of a user scope installation. The +// paths are the real ones of the platform, so the test checks the paths the +// installation actually uses. +func darwinUserOptions(t *testing.T) InstallOptions { + t.Helper() + paths, err := DefaultPaths(ScopeUser) + if err != nil { + t.Fatalf("DefaultPaths(ScopeUser): %v", err) + } + cache, hub := DefaultCacheDirs(ScopeUser, paths) + return InstallOptions{ + Scope: ScopeUser, + Executable: "/usr/local/bin/hfdesk", + Listen: DefaultListen, + Paths: paths, + CacheDir: cache, + HubDir: hub, + LocalDir: filepath.Join(paths.StateDir, "models"), + User: darwinCredentialSentinel, + UID: darwinCredentialSentinel, + } +} + +// darwinSystemOptions returns the options of a machine-wide installation. +func darwinSystemOptions(t *testing.T) InstallOptions { + t.Helper() + paths, err := DefaultPaths(ScopeSystem) + if err != nil { + t.Fatalf("DefaultPaths(ScopeSystem): %v", err) + } + cache, hub := DefaultCacheDirs(ScopeSystem, paths) + return InstallOptions{ + Scope: ScopeSystem, + Executable: "/usr/local/bin/hfdesk", + Listen: DefaultListen, + Paths: paths, + CacheDir: cache, + HubDir: hub, + LocalDir: filepath.Join(paths.StateDir, "models"), + User: darwinCredentialSentinel, + UID: darwinCredentialSentinel, + } +} + +// TestRenderPlistForTheUserScope checks the property list of a LaunchAgent. +func TestRenderPlistForTheUserScope(t *testing.T) { + opts := darwinUserOptions(t) + rendered, err := renderPlist(opts) + if err != nil { + t.Fatalf("renderPlist(): %v", err) + } + + registration, err := parsePlist([]byte(rendered)) + if err != nil { + t.Fatalf("parsePlist(): %v", err) + } + + // The label is derived from the service name, and it is also the name + // of the property list file: launchd refuses to load a property list + // whose file name does not match its label. + if registration.Label != "com.hfdesk.hfdesk" { + t.Errorf("Label = %q, want %q", registration.Label, "com.hfdesk.hfdesk") + } + if registration.Label != launchdLabel() { + t.Errorf("Label = %q, want the label of the service name %q", registration.Label, launchdLabel()) + } + + // The argument vector is the executable and the canonical arguments of + // the service host, nothing else. + want := append([]string{opts.Executable}, RunArgs(opts)...) + if !reflect.DeepEqual(registration.ProgramArguments, want) { + t.Errorf("ProgramArguments =\n%v\nwant\n%v", registration.ProgramArguments, want) + } + if len(registration.ProgramArguments) == 0 || registration.ProgramArguments[0] != opts.Executable { + t.Errorf("ProgramArguments[0] = %q, want the absolute executable %q", registration.ProgramArguments[0], opts.Executable) + } + + if registration.WorkingDirectory != opts.Paths.StateDir { + t.Errorf("WorkingDirectory = %q, want the state dir %q", registration.WorkingDirectory, opts.Paths.StateDir) + } + if !registration.RunAtLoad { + t.Error("RunAtLoad = false, want true") + } + // The job is restarted after a failure only, like the systemd unit of + // the Linux installation. + if registration.KeepAliveSuccessfulExit { + t.Error("KeepAlive.SuccessfulExit = true, want false") + } + if registration.UserName != "" || registration.GroupName != "" { + t.Errorf("UserName = %q, GroupName = %q; want none for a user scope instance", registration.UserName, registration.GroupName) + } + + // The environment of the process carries the home directory of the user + // and nothing else. + if len(registration.Environment) != 1 { + t.Errorf("EnvironmentVariables = %v, want the home directory only", registration.Environment) + } + home, err := os.UserHomeDir() + if err != nil { + t.Fatalf("UserHomeDir(): %v", err) + } + if registration.Environment["HOME"] != home { + t.Errorf("EnvironmentVariables.HOME = %q, want %q", registration.Environment["HOME"], home) + } +} + +// TestRenderPlistForTheSystemScope checks the property list of a +// LaunchDaemon. +func TestRenderPlistForTheSystemScope(t *testing.T) { + opts := darwinSystemOptions(t) + rendered, err := renderPlist(opts) + if err != nil { + t.Fatalf("renderPlist(): %v", err) + } + + registration, err := parsePlist([]byte(rendered)) + if err != nil { + t.Fatalf("parsePlist(): %v", err) + } + + // The daemon runs as root of the machine. + if registration.UserName != "root" { + t.Errorf("UserName = %q, want %q", registration.UserName, "root") + } + if registration.GroupName != "wheel" { + t.Errorf("GroupName = %q, want %q", registration.GroupName, "wheel") + } + + // The machine-wide instance never reuses the home directory of the user + // who runs the install command. + if registration.Environment["HOME"] != "/var/lib/hfdesk" { + t.Errorf("EnvironmentVariables.HOME = %q, want %q", registration.Environment["HOME"], "/var/lib/hfdesk") + } + home, err := os.UserHomeDir() + if err == nil && home != "" { + if registration.Environment["HOME"] == home { + t.Errorf("EnvironmentVariables.HOME = %q, want the machine-level home and not the home of the user", home) + } + if strings.Contains(rendered, home) { + t.Errorf("the property list of the machine-wide instance mentions the home directory %q of the user", home) + } + } + + want := append([]string{opts.Executable}, RunArgs(opts)...) + if !reflect.DeepEqual(registration.ProgramArguments, want) { + t.Errorf("ProgramArguments =\n%v\nwant\n%v", registration.ProgramArguments, want) + } +} + +// TestRenderPlistCarriesNoCredential checks that no credential reaches a +// registration. +// +// A property list is readable by every user of the machine, so the +// registration carries the paths and the bind address of the instance and +// nothing else. The credentials of an instance come from its own config +// file. +func TestRenderPlistCarriesNoCredential(t *testing.T) { + for _, scope := range []Scope{ScopeUser, ScopeSystem} { + t.Run(string(scope), func(t *testing.T) { + var opts InstallOptions + if scope == ScopeUser { + opts = darwinUserOptions(t) + } else { + opts = darwinSystemOptions(t) + } + // Every string field of the options that could carry a + // credential carries the sentinel. + setCredentialFields(&opts, darwinCredentialSentinel) + + rendered, err := renderPlist(opts) + if err != nil { + t.Fatalf("renderPlist(): %v", err) + } + if strings.Contains(rendered, darwinCredentialSentinel) { + t.Errorf("the property list carries the credential sentinel:\n%s", rendered) + } + + // The registration holds the known keys only: there is no + // place a credential could hide in. + want := []string{"Label", "ProgramArguments", "WorkingDirectory", "RunAtLoad", "KeepAlive", "ProcessType", "EnvironmentVariables"} + if scope == ScopeSystem { + want = append(want, "UserName", "GroupName") + } + registration, err := parsePlist([]byte(rendered)) + if err != nil { + t.Fatalf("parsePlist(): %v", err) + } + if !reflect.DeepEqual(registration.Keys, want) { + t.Errorf("the keys of the property list =\n%v\nwant\n%v", registration.Keys, want) + } + + // The environment of the process holds the home directory and + // nothing else, so a credential has no place in it either. + if len(registration.Environment) != 1 { + t.Errorf("the environment of the registration = %v, want the home directory only", registration.Environment) + } + if _, ok := registration.Environment["HOME"]; !ok { + t.Errorf("the environment of the registration = %v, want the home directory", registration.Environment) + } + + // Every string of the property list is one of the values the + // installation is made of. + allowed := map[string]bool{ + launchdLabel(): true, + opts.Executable: true, + opts.Paths.StateDir: true, + "Background": true, + "root": true, + "wheel": true, + } + for _, arg := range RunArgs(opts) { + allowed[arg] = true + } + for _, value := range registration.Environment { + allowed[value] = true + } + for _, value := range darwinPlistStrings(registration) { + if !allowed[value] { + t.Errorf("the property list holds the unexpected value %q", value) + } + } + }) + } +} + +// TestRenderPlistKeepsTheLogFilesOfTheSupervisorOut checks that the +// property list does not point launchd at a log file. +// +// launchd does not rotate the files of StandardOutPath and +// StandardErrorPath, so they would grow without bound. HFDesk writes its +// own bounded rotating log file into the log directory of the instance. +func TestRenderPlistKeepsTheLogFilesOfTheSupervisorOut(t *testing.T) { + opts := darwinUserOptions(t) + rendered, err := renderPlist(opts) + if err != nil { + t.Fatalf("renderPlist(): %v", err) + } + + for _, forbidden := range []string{"StandardOutPath", "StandardErrorPath", "nohup"} { + if strings.Contains(rendered, forbidden) { + t.Errorf("the property list mentions %q:\n%s", forbidden, rendered) + } + } + // The program is never run through a shell, so the property list must + // not carry a shell operator. The XML entities are removed first: an + // escaped ampersand of a path is not an operator. + if strings.Contains(darwinStripXMLEntities(rendered), "&") { + t.Errorf("the property list carries an ampersand:\n%s", rendered) + } +} + +// TestParsePlistRecoversTheRegistration checks that the registered program +// and the bind address are recovered from a property list. +func TestParsePlistRecoversTheRegistration(t *testing.T) { + opts := darwinUserOptions(t) + rendered, err := renderPlist(opts) + if err != nil { + t.Fatalf("renderPlist(): %v", err) + } + registration, err := parsePlist([]byte(rendered)) + if err != nil { + t.Fatalf("parsePlist(): %v", err) + } + + if registration.executable() != opts.Executable { + t.Errorf("the executable = %q, want %q", registration.executable(), opts.Executable) + } + if registration.listen() != opts.Listen { + t.Errorf("the listen address = %q, want %q", registration.listen(), opts.Listen) + } + if registration.listen() != DefaultListen { + t.Errorf("the listen address = %q, want the default %q", registration.listen(), DefaultListen) + } + + paths := registration.paths() + want := runtimepaths.Paths{ + ConfigFile: opts.Paths.ConfigFile, + StateDir: opts.Paths.StateDir, + LogDir: opts.Paths.LogDir, + } + if paths != want { + t.Errorf("the paths of the registration =\n%+v\nwant\n%+v", paths, want) + } +} + +// TestParsePlistRejectsBrokenContent checks that a broken property list is +// reported instead of read as an empty registration. +func TestParsePlistRejectsBrokenContent(t *testing.T) { + if _, err := parsePlist([]byte("Label")); err == nil { + t.Error("parsePlist() = nil, want an error for a broken property list") + } +} + +// TestParseLaunchdPrint checks the mapping of the output of "launchctl +// print" to the state of the instance. +func TestParseLaunchdPrint(t *testing.T) { + tests := []struct { + name string + output string + want State + }{ + { + name: "a running job", + output: "com.hfdesk.hfdesk = {\n\tstate = running\n\tpid = 4711\n}", + want: StateRunning, + }, + { + name: "a job that is not running", + output: "com.hfdesk.hfdesk = {\n\tstate = not running\n\tlast exit code = 0\n}", + want: StateStopped, + }, + { + name: "a job that exited", + output: "com.hfdesk.hfdesk = {\n\tstate = exited\n\tlast exit code = 0\n}", + want: StateStopped, + }, + { + name: "a job that failed", + output: "com.hfdesk.hfdesk = {\n\tstate = exited\n\tlast exit code = 1\n}", + want: StateFailed, + }, + { + name: "a job that failed without a running state", + output: "com.hfdesk.hfdesk = {\n\tstate = not running\n\tlast exit code = 78\n}", + want: StateFailed, + }, + { + name: "a job that waits", + output: "com.hfdesk.hfdesk = {\n\tstate = waiting\n}", + want: StateStarting, + }, + { + name: "an unknown state", + output: "com.hfdesk.hfdesk = {\n\tstate = something else\n}", + want: StateUnknown, + }, + { + name: "no state at all", + output: "com.hfdesk.hfdesk = {\n\tpid = 4711\n}", + want: StateUnknown, + }, + { + name: "empty output", + output: "", + want: StateUnknown, + }, + { + name: "the default state is not the state", + output: "com.hfdesk.hfdesk = {\n\tstate = running\n\tdefault state = stopped\n" + + "\tlast exit code = 1\n}", + want: StateRunning, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := parseLaunchdPrint(test.output); got != test.want { + t.Errorf("parseLaunchdPrint() = %q, want %q", got, test.want) + } + }) + } +} + +// TestPlutilAcceptsTheRenderedPlist checks the property list with the +// property list tool of macOS. +func TestPlutilAcceptsTheRenderedPlist(t *testing.T) { + plutil, err := exec.LookPath("plutil") + if err != nil { + t.Skipf("plutil is not available: %v", err) + } + + for _, scope := range []Scope{ScopeUser, ScopeSystem} { + t.Run(string(scope), func(t *testing.T) { + var opts InstallOptions + if scope == ScopeUser { + opts = darwinUserOptions(t) + } else { + opts = darwinSystemOptions(t) + } + rendered, err := renderPlist(opts) + if err != nil { + t.Fatalf("renderPlist(): %v", err) + } + path := filepath.Join(t.TempDir(), launchdLabel()+".plist") + if err := os.WriteFile(path, []byte(rendered), 0o644); err != nil { + t.Fatalf("write the property list: %v", err) + } + output, err := exec.Command(plutil, "-lint", path).CombinedOutput() + if err != nil { + t.Fatalf("plutil -lint: %v\n%s", err, output) + } + }) + } +} + +// TestWritePlistIsAtomic checks that the property list is written with the +// expected mode and that a failed installation can restore it. +func TestWritePlistIsAtomic(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, launchdLabel()+".plist") + + if err := writePlist(path, []byte(""), plistMode); err != nil { + t.Fatalf("writePlist(): %v", err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat the property list: %v", err) + } + if info.Mode().Perm() != plistMode { + t.Errorf("the mode of the property list = %v, want %v", info.Mode().Perm(), plistMode) + } + // The temporary file of the atomic write is gone. + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("read the directory: %v", err) + } + if len(entries) != 1 { + t.Errorf("the directory holds %d entries, want the property list only", len(entries)) + } + + // A failed installation restores the previous property list. + backup, err := backupPlist(path) + if err != nil { + t.Fatalf("backupPlist(): %v", err) + } + if err := writePlist(path, []byte("other"), plistMode); err != nil { + t.Fatalf("writePlist(): %v", err) + } + if err := backup.restore(); err != nil { + t.Fatalf("restore(): %v", err) + } + content, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read the property list: %v", err) + } + if string(content) != "" { + t.Errorf("the restored property list = %q, want %q", content, "") + } + + // A property list that did not exist is removed again. + missing := &plistBackup{path: filepath.Join(dir, "missing.plist")} + if err := missing.restore(); err != nil { + t.Fatalf("restore(): %v", err) + } +} + +// TestStatusReportsAMissingExecutable checks that a registration that points +// at a program that is gone is reported as broken. +func TestStatusReportsAMissingExecutable(t *testing.T) { + if _, err := exec.LookPath("launchctl"); err != nil { + t.Skipf("launchctl is not available: %v", err) + } + + // The manager reads the registration of the instance, so the property + // list of the instance is written into a temporary home directory. + home := t.TempDir() + launchd := &launchdManager{scope: ScopeUser, uid: strconv.Itoa(os.Getuid()), home: home} + + stateDir := t.TempDir() + opts := InstallOptions{ + Scope: ScopeUser, + Executable: filepath.Join(stateDir, "hfdesk"), + Listen: DefaultListen, + Paths: runtimepaths.Paths{ + ConfigFile: filepath.Join(stateDir, "hfdesk.json"), + StateDir: stateDir, + LogDir: filepath.Join(stateDir, "logs"), + }, + } + rendered, err := renderPlist(opts) + if err != nil { + t.Fatalf("renderPlist(): %v", err) + } + path := launchd.plistPath() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("create the registration directory: %v", err) + } + if err := os.WriteFile(path, []byte(rendered), 0o644); err != nil { + t.Fatalf("write the property list: %v", err) + } + if _, err := os.Stat(opts.Executable); err == nil { + t.Fatalf("the executable %q exists, want a program that is gone", opts.Executable) + } + + status, err := launchd.Status(context.Background()) + if err != nil { + t.Fatalf("Status(): %v", err) + } + if status.State != StateBroken { + t.Errorf("the state = %q, want %q", status.State, StateBroken) + } + if !strings.Contains(status.Detail, opts.Executable) { + t.Errorf("the detail = %q, want the path of the missing program", status.Detail) + } + if status.Executable != opts.Executable { + t.Errorf("the executable = %q, want %q", status.Executable, opts.Executable) + } +} + +// TestLaunchdLifecycle runs the whole lifecycle of an instance against +// launchd. +// +// The test uses the property list of the user of the test: a registration +// that was there before the test is restored at the end of it. +func TestLaunchdLifecycle(t *testing.T) { + launchctl, err := exec.LookPath("launchctl") + if err != nil { + t.Skipf("launchctl is not available: %v", err) + } + + manager, err := newPlatformManager(ScopeUser) + if err != nil { + t.Fatalf("newPlatformManager(): %v", err) + } + launchd, ok := manager.(*launchdManager) + if !ok { + t.Fatalf("newPlatformManager() = %T, want a launchd manager", manager) + } + + // A LaunchAgent can only be loaded into the gui domain of a user with + // a session. + if output, err := exec.Command(launchctl, "print", launchd.domain()).CombinedOutput(); err != nil { + t.Skipf("the domain %s is not available: %v\n%s", launchd.domain(), err, output) + } + + // The state of the instance lives in a temporary directory, the + // registration of the instance in the LaunchAgents directory of the + // user. + stateDir := t.TempDir() + backup, err := backupPlist(launchd.plistPath()) + if err != nil { + t.Fatalf("backupPlist(): %v", err) + } + t.Cleanup(func() { + _ = launchd.Uninstall(context.Background()) + _ = backup.restore() + }) + + opts := InstallOptions{ + Scope: ScopeUser, + Executable: darwinWriteFakeExecutable(t), + Listen: DefaultListen, + Paths: runtimepaths.Paths{ + ConfigFile: filepath.Join(stateDir, "hfdesk.json"), + StateDir: stateDir, + LogDir: filepath.Join(stateDir, "logs"), + }, + } + ctx := context.Background() + + if err := launchd.Install(ctx, opts); err != nil { + t.Fatalf("Install(): %v", err) + } + if _, err := os.Stat(launchd.plistPath()); err != nil { + t.Fatalf("the property list is missing: %v", err) + } + + status := darwinWaitForState(t, launchd, StateRunning) + if status.Executable != opts.Executable { + t.Errorf("the executable = %q, want %q", status.Executable, opts.Executable) + } + if status.Listen != opts.Listen { + t.Errorf("the listen address = %q, want %q", status.Listen, opts.Listen) + } + if status.UnitPath != launchd.plistPath() { + t.Errorf("the unit path = %q, want %q", status.UnitPath, launchd.plistPath()) + } + if status.Name != launchdLabel() { + t.Errorf("the name = %q, want %q", status.Name, launchdLabel()) + } + if status.Paths.ConfigFile != opts.Paths.ConfigFile { + t.Errorf("the config file = %q, want %q", status.Paths.ConfigFile, opts.Paths.ConfigFile) + } + + // Starting a running instance is stable. + if err := launchd.Start(ctx); err != nil { + t.Fatalf("Start(): %v", err) + } + status, err = launchd.Status(ctx) + if err != nil { + t.Fatalf("Status(): %v", err) + } + if status.State != StateRunning { + t.Errorf("the state after the start = %q, want %q", status.State, StateRunning) + } + + // The installation of a second time does not fail and does not create a + // second registration. + if err := launchd.Install(ctx, opts); err != nil { + t.Fatalf("Install() a second time: %v", err) + } + status, err = launchd.Status(ctx) + if err != nil { + t.Fatalf("Status(): %v", err) + } + if status.State != StateRunning { + t.Errorf("the state after the second installation = %q, want %q", status.State, StateRunning) + } + + if err := launchd.Stop(ctx); err != nil { + t.Fatalf("Stop(): %v", err) + } + status, err = launchd.Status(ctx) + if err != nil { + t.Fatalf("Status(): %v", err) + } + if status.State != StateStopped { + t.Errorf("the state after the stop = %q, want %q (%s)", status.State, StateStopped, status.Detail) + } + + // Stopping a stopped instance is stable. + if err := launchd.Stop(ctx); err != nil { + t.Fatalf("Stop() a second time: %v", err) + } + + if err := launchd.Uninstall(ctx); err != nil { + t.Fatalf("Uninstall(): %v", err) + } + status, err = launchd.Status(ctx) + if err != nil { + t.Fatalf("Status(): %v", err) + } + if status.State != StateNotInstalled { + t.Errorf("the state after the uninstall = %q, want %q", status.State, StateNotInstalled) + } + // The uninstall removes the registration only: the state of the + // instance stays. + if _, err := os.Stat(stateDir); err != nil { + t.Errorf("the state directory %q of the instance is gone: %v", stateDir, err) + } + + // Uninstalling an instance that is not installed is a clean success. + if err := launchd.Uninstall(ctx); err != nil { + t.Fatalf("Uninstall() a second time: %v", err) + } +} + +// darwinWaitForState waits until the instance reaches a state. +func darwinWaitForState(t *testing.T, manager *launchdManager, want State) Status { + t.Helper() + deadline := time.Now().Add(15 * time.Second) + var status Status + for { + var err error + status, err = manager.Status(context.Background()) + if err != nil { + t.Fatalf("Status(): %v", err) + } + if status.State == want { + return status + } + if time.Now().After(deadline) { + t.Fatalf("the state = %q, want %q (%s)", status.State, want, status.Detail) + } + time.Sleep(100 * time.Millisecond) + } +} + +// darwinWriteFakeExecutable writes a program that stays alive until it is +// signalled and returns its path. +func darwinWriteFakeExecutable(t *testing.T) string { + t.Helper() + path := filepath.Join(t.TempDir(), "hfdesk") + script := "#!/bin/sh\n# The registered program of the test: it waits for a signal.\nexec sleep 300\n" + if err := os.WriteFile(path, []byte(script), 0o755); err != nil { + t.Fatalf("write the program: %v", err) + } + return path +} + +// darwinPlistStrings returns every string of a registration, including the +// strings of the nested dictionaries and arrays. +func darwinPlistStrings(registration plistRegistration) []string { + var values []string + for _, key := range registration.Keys { + switch key { + case "ProgramArguments": + values = append(values, registration.ProgramArguments...) + case "EnvironmentVariables": + for _, value := range registration.Environment { + values = append(values, value) + } + case "Label": + values = append(values, registration.Label) + case "WorkingDirectory": + values = append(values, registration.WorkingDirectory) + case "UserName": + values = append(values, registration.UserName) + case "GroupName": + values = append(values, registration.GroupName) + } + } + return values +} + +// darwinStripXMLEntities removes the XML entities of a value, so an escaped +// ampersand of a path is not mistaken for a shell operator. +func darwinStripXMLEntities(value string) string { + for _, entity := range []string{"&", "<", ">", """, "'", """, "'", " ", " ", " "} { + value = strings.ReplaceAll(value, entity, "") + } + return value +} diff --git a/internal/service/manager_linux.go b/internal/service/manager_linux.go new file mode 100644 index 00000000..81b05f5e --- /dev/null +++ b/internal/service/manager_linux.go @@ -0,0 +1,888 @@ +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 + +//go:build linux + +package service + +import ( + "bytes" + "context" + "errors" + "fmt" + "io/fs" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/bashrusakh/hfdesk/internal/runtimepaths" +) + +const ( + // unitName is the supervisor visible name of the HFDesk unit. + unitName = ServiceName + ".service" + + // unitFileMode is the permission mode of a written unit file. The + // supervisor reads the unit as an unprivileged user, so the file must + // be world readable. + unitFileMode os.FileMode = 0o644 + + // unitDirMode is the permission mode of the directory that holds the + // unit file. + unitDirMode os.FileMode = 0o755 + + // systemUnitPath is the machine wide location of the unit file. + systemUnitPath = "/etc/systemd/system/" + unitName + + // systemServiceHome is the HOME of a machine wide instance. It is a + // machine level directory: a system scope instance never inherits the + // home of the user who happens to run the install command. + systemServiceHome = "/var/lib/hfdesk" +) + +// systemdManager drives the systemd supervisor of HFDesk. +// +// A user scope installation is a systemd user unit: it belongs to the +// session of the user and it is the default, because a desktop or a +// background installation belongs to the user who uses it. +// +// A system scope installation is a machine wide system unit. It is an +// explicit choice and it needs root: the manager never escalates +// privileges, it fails with a clear error when the process is not root. +// +// The supervisor owns the lifecycle of the instance. HFDesk stays a normal +// foreground process: the unit is a simple service, the process is never +// forked, detached or put into the background by HFDesk itself. +// +// Booting without a login session is an explicit choice of the user or of +// the administrator. The manager therefore never enables linger on its own: +// `loginctl enable-linger` (or anything like it) is never called, so a +// user scope instance starts with the session of the user and stops with it. +// +// The manager drives the supervisor with systemctl. The supervisor is never +// called through a shell, and the manager never launches an interactive +// process: no browser and no tray icon are ever started from the service +// layer. +type systemdManager struct { + scope Scope +} + +// newPlatformManager returns the systemd manager of the given scope. +func newPlatformManager(scope Scope) (Manager, error) { + switch scope { + case ScopeUser, ScopeSystem: + return &systemdManager{scope: scope}, nil + default: + return nil, fmt.Errorf("service: unknown scope %q (want %q or %q)", scope, ScopeUser, ScopeSystem) + } +} + +// unitPath returns the path of the unit file of the manager scope. +// +// A user scope unit lives in the "systemd/user" directory of the per user +// configuration directory: $XDG_CONFIG_HOME/systemd/user/hfdesk.service, or +// ~/.config/systemd/user/hfdesk.service when XDG_CONFIG_HOME is not set. A +// system scope unit lives in /etc/systemd/system. +// +// The path is resolved on every call, so the manager always describes the +// registration of the current environment. +func (m *systemdManager) unitPath() (string, error) { + if m.scope == ScopeSystem { + return systemUnitPath, nil + } + dir, err := os.UserConfigDir() + if err != nil || dir == "" { + home, homeErr := os.UserHomeDir() + if homeErr != nil || home == "" { + return "", errors.New("service: the configuration directory of the user is unknown, the unit file path cannot be resolved") + } + dir = filepath.Join(home, ".config") + } + return filepath.Join(dir, "systemd", "user", unitName), nil +} + +// systemctlArgs prefixes the supervisor arguments with the scope of the +// manager: a user scope installation is driven by "systemctl --user", a +// machine wide one by plain "systemctl". +func (m *systemdManager) systemctlArgs(args ...string) []string { + if m.scope == ScopeUser { + return append([]string{"--user"}, args...) + } + return args +} + +// supervisorHint returns the command a user can run to inspect the unit of +// the manager scope. +func (m *systemdManager) supervisorHint() string { + if m.scope == ScopeUser { + return "systemctl --user" + } + return "systemctl" +} + +// systemctlError describes a failed supervisor call. The message of the +// supervisor is kept, so the reason of a failure is never lost. +type systemctlError struct { + args []string + err error + stderr string +} + +func (e *systemctlError) Error() string { + msg := fmt.Sprintf("service: systemctl %s failed: %v", strings.Join(e.args, " "), e.err) + if e.stderr != "" { + msg += ": " + e.stderr + } + return msg +} + +func (e *systemctlError) Unwrap() error { return e.err } + +// runSystemctl runs the supervisor with the given arguments and returns its +// standard output. The supervisor is never called through a shell. +func (m *systemdManager) runSystemctl(ctx context.Context, args ...string) (string, error) { + full := m.systemctlArgs(args...) + cmd := exec.CommandContext(ctx, "systemctl", full...) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return stdout.String(), &systemctlError{ + args: full, + err: err, + stderr: strings.TrimSpace(stderr.String()), + } + } + return stdout.String(), nil +} + +// requireRoot rejects a machine wide operation that needs root when the +// process does not have it. The manager never escalates privileges itself: +// it never calls sudo, su or pkexec. +func (m *systemdManager) requireRoot(action string) error { + if m.scope != ScopeSystem { + return nil + } + if os.Geteuid() == 0 { + return nil + } + return fmt.Errorf("service: %s in the system scope requires root: run the command as root, HFDesk never escalates privileges itself", action) +} + +// Install registers the service with the supervisor. +// +// The installation validates first, writes the unit file atomically, +// reloads the supervisor and enables the unit for the boot of the scope. It +// never starts the instance: starting is an explicit, separate command. +// +// A failure after the unit file was written rolls the unit file back, so an +// installation never leaves a half configured unit behind. +func (m *systemdManager) Install(ctx context.Context, opts InstallOptions) error { + if err := ValidateInstallOptions(opts); err != nil { + return err + } + // The root requirement is checked before any directory is created: a + // permission error of the machine wide directories is a much less + // readable answer than the real reason. + if err := m.requireRoot("an installation"); err != nil { + return err + } + if err := PrepareDirs(opts); err != nil { + return err + } + if err := m.preflight(opts); err != nil { + return err + } + + unit, err := renderUnit(opts) + if err != nil { + return err + } + path, err := m.unitPath() + if err != nil { + return err + } + + previous, existed, err := readUnitFile(path) + if err != nil { + return err + } + if err := writeUnitFile(path, unit); err != nil { + return err + } + if err := m.reloadAndEnable(ctx); err != nil { + m.rollbackUnit(ctx, path, previous, existed) + return err + } + return nil +} + +// preflight rejects an installation the supervisor could not run: a missing +// supervisor, an unusable bind address or an executable that cannot be run. +func (m *systemdManager) preflight(opts InstallOptions) error { + if _, err := exec.LookPath("systemctl"); err != nil { + return fmt.Errorf("service: systemctl is not available: %w", err) + } + if _, _, err := ParseListen(opts.Listen); err != nil { + return err + } + info, err := os.Stat(opts.Executable) + if err != nil { + return fmt.Errorf("service: executable %q is not usable: %w", opts.Executable, err) + } + if !info.Mode().IsRegular() { + return fmt.Errorf("service: executable %q is not a regular file", opts.Executable) + } + if info.Mode().Perm()&0o111 == 0 { + return fmt.Errorf("service: executable %q is not executable", opts.Executable) + } + return nil +} + +// reloadAndEnable makes the supervisor aware of the unit file and enables +// the unit for the boot of the scope. +func (m *systemdManager) reloadAndEnable(ctx context.Context) error { + if _, err := m.runSystemctl(ctx, "daemon-reload"); err != nil { + return err + } + if _, err := m.runSystemctl(ctx, "enable", unitName); err != nil { + return err + } + return nil +} + +// rollbackUnit restores the registration of a failed installation: the +// previous unit file is written back, or the unit file of the failed +// installation is removed when there was none. The supervisor is reloaded +// afterwards, so it never keeps a stale unit. +// +// The rollback is best effort: the original failure of the installation is +// the error the caller gets. +func (m *systemdManager) rollbackUnit(ctx context.Context, path string, previous []byte, existed bool) { + // The caller may have cancelled the context, the rollback still has to + // clean the registration up. + cleanup := context.WithoutCancel(ctx) + if existed { + _ = writeUnitFile(path, string(previous)) + } else { + _ = os.Remove(path) + } + _, _ = m.runSystemctl(cleanup, "daemon-reload") +} + +// Uninstall removes the registration of the service. +// +// It removes the registration only: the config file, the job history, the +// logs, the models and the cache of the instance are never touched. An +// absent registration is a clean success and the supervisor is left alone. +func (m *systemdManager) Uninstall(ctx context.Context) error { + path, err := m.unitPath() + if err != nil { + return err + } + if _, err := os.Stat(path); err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil + } + return err + } + if err := m.requireRoot("an uninstallation"); err != nil { + return err + } + + // Stop the unit and drop it from the boot of the scope. A unit the + // supervisor does not know is not an error: the registration is + // already gone. + if _, err := m.runSystemctl(ctx, "disable", "--now", unitName); err != nil { + if !isUnitAbsent(err) { + return err + } + } + if err := os.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("service: remove the unit file %q: %w", path, err) + } + if _, err := m.runSystemctl(ctx, "daemon-reload"); err != nil { + return err + } + return nil +} + +// Start starts the registered unit. Starting a unit that already runs is a +// stable success, not an error. +func (m *systemdManager) Start(ctx context.Context) error { + if err := m.requireInstalled(); err != nil { + return err + } + if _, err := m.runSystemctl(ctx, "start", unitName); err != nil { + // The supervisor may refuse a start of a unit that is already + // active. That is the state the caller asked for. + if state, ok := m.currentState(ctx); ok && (state == StateRunning || state == StateStarting) { + return nil + } + return err + } + return nil +} + +// Stop stops the registered unit. Stopping a unit that is already stopped is +// a stable success, not an error. +func (m *systemdManager) Stop(ctx context.Context) error { + if err := m.requireInstalled(); err != nil { + return err + } + if _, err := m.runSystemctl(ctx, "stop", unitName); err != nil { + // A unit the supervisor does not know is not running, so the + // wanted state is already reached. + if isUnitAbsent(err) { + return nil + } + if state, ok := m.currentState(ctx); ok && state == StateStopped { + return nil + } + return err + } + return nil +} + +// Restart stops and starts the registered unit. +func (m *systemdManager) Restart(ctx context.Context) error { + if err := m.requireInstalled(); err != nil { + return err + } + if _, err := m.runSystemctl(ctx, "restart", unitName); err != nil { + return err + } + return nil +} + +// requireInstalled rejects a lifecycle command for a service that is not +// registered, so the caller gets a readable reason instead of a cryptic +// supervisor error. +func (m *systemdManager) requireInstalled() error { + path, err := m.unitPath() + if err != nil { + return err + } + if _, err := os.Stat(path); err != nil { + if errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("service: %s is not installed", unitName) + } + return err + } + return nil +} + +// currentState queries the supervisor for the state of the unit. The second +// result is false when the supervisor could not be reached. +func (m *systemdManager) currentState(ctx context.Context) (State, bool) { + out, err := m.runSystemctl(ctx, "show", unitName, + "--property=LoadState", "--property=ActiveState", "--property=SubState") + if err != nil { + return StateUnknown, false + } + return parseSystemdShow(out).State, true +} + +// Status reports the state of the registered service. +// +// The registration is described by the unit file: the executable, the bind +// address and the runtime paths come from the registered unit, not from the +// current intent of the caller. A registered executable that is gone is +// reported as broken, so a moved or deleted binary is never reported as a +// running service. +// +// The name of the status is the label of the service ("hfdesk"), not the +// name of the unit file ("hfdesk.service"): the label is the identity the +// user knows the service by. +func (m *systemdManager) Status(ctx context.Context) (Status, error) { + path, err := m.unitPath() + if err != nil { + return Status{}, err + } + status := Status{ + Scope: m.scope, + Name: ServiceName, + UnitPath: path, + } + + content, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + status.State = StateNotInstalled + status.Detail = fmt.Sprintf("the unit file does not exist: %s", path) + return status, nil + } + return Status{}, err + } + + unit := parseUnit(string(content)) + status.Executable = unit.ExecStart + status.Listen = unit.listen() + status.Paths = unit.paths() + + if status.Executable != "" { + if _, err := os.Stat(status.Executable); err != nil { + status.State = StateBroken + status.Detail = fmt.Sprintf("registered executable not found: %s", status.Executable) + return status, nil + } + } + + out, err := m.runSystemctl(ctx, "show", unitName, + "--property=LoadState", "--property=ActiveState", "--property=SubState") + if err != nil { + status.State = StateUnknown + status.Detail = err.Error() + return status, err + } + show := parseSystemdShow(out) + status.State = show.State + switch status.State { + case StateNotInstalled: + status.Detail = "the supervisor does not know the unit, reload the supervisor to pick the unit file up" + case StateFailed: + status.Detail = fmt.Sprintf("the unit failed, see `%s status %s`", m.supervisorHint(), unitName) + } + return status, nil +} + +// isUnitAbsent reports whether a supervisor error means that the unit is +// not known to the supervisor. Such an error is not a failure of the +// operation: the wanted state (no unit) is already reached. +func isUnitAbsent(err error) bool { + var svcErr *systemctlError + if !errors.As(err, &svcErr) { + return false + } + message := strings.ToLower(svcErr.stderr) + for _, marker := range []string{ + "not loaded", + "does not exist", + "no such file", + "not found", + "unknown unit", + } { + if strings.Contains(message, marker) { + return true + } + } + return false +} + +// readUnitFile reads the current content of a unit file. The second result +// reports whether the file existed at all. +func readUnitFile(path string) ([]byte, bool, error) { + content, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil, false, nil + } + return nil, false, err + } + return content, true, nil +} + +// writeUnitFile writes a unit file atomically: the content goes into a +// temporary file of the same directory and is renamed over the target. A +// reader of the unit file therefore never sees a half written unit. +func writeUnitFile(path, content string) error { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, unitDirMode); err != nil { + return fmt.Errorf("service: create the unit directory %q: %w", dir, err) + } + tmp, err := os.CreateTemp(dir, ".hfdesk-unit-*") + if err != nil { + return fmt.Errorf("service: create a temporary unit file in %q: %w", dir, err) + } + tmpName := tmp.Name() + defer func() { + // The temporary file only survives when it was not renamed. + _ = os.Remove(tmpName) + }() + if _, err := tmp.WriteString(content); err != nil { + _ = tmp.Close() + return fmt.Errorf("service: write the unit file %q: %w", tmpName, err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("service: close the unit file %q: %w", tmpName, err) + } + if err := os.Chmod(tmpName, unitFileMode); err != nil { + return fmt.Errorf("service: set the mode of the unit file %q: %w", tmpName, err) + } + if err := os.Rename(tmpName, path); err != nil { + return fmt.Errorf("service: install the unit file %q: %w", path, err) + } + return nil +} + +// unitTemplate is the shape of a HFDesk unit. +// +// The unit keeps the supervisor in charge: HFDesk stays a normal foreground +// process, the unit is a simple service and the process is never forked, +// detached or put into the background by HFDesk itself. +// +// The hardening set is deliberately small. HFDesk reads and writes the +// models, the cache and the state of the instance in directories that depend +// on the installation, so a sandbox that hides the file system or the home +// directory of the user would break the instance instead of protecting it. +// Every further restriction would need the explicit list of the paths the +// instance may write, and that list depends on the installation. +// +// The unit does not redirect the output: HFDesk writes its own bounded +// rotating log file into the log directory of the instance and the supervisor +// keeps the standard output and the standard error of the process in its +// journal. +const unitTemplate = `# HFDesk service unit. +# +# Generated by the hfdesk service install command; a later installation +# overwrites this file. +# +# The unit holds no access data: an instance reads its own settings from its +# config file, which only the owner of the instance can read. The unit +# therefore carries the executable and the canonical arguments of the +# process only. + +[Unit] +Description=HFDesk +Documentation=https://github.com/bashrusakh/hfdesk +After=network-online.target +Wants=network-online.target + +[Service] +# The supervisor owns the lifecycle of the instance: HFDesk stays a normal +# foreground process and the supervisor restarts it when it fails. +Type=simple +ExecStart=%s +WorkingDirectory=%s +Restart=on-failure +RestartSec=5s +NoNewPrivileges=true +UMask=0077 +Environment=%s + +[Install] +WantedBy=%s +` + +// renderUnit renders the systemd unit of an installation. +// +// The function is pure: it renders the text of the unit and never touches +// the system. The command of the unit is the canonical argument vector of +// the shared contract, so every platform registers the same command. +func renderUnit(opts InstallOptions) (string, error) { + home, err := unitHomeDir(opts) + if err != nil { + return "", err + } + + words := make([]string, 0, len(RunArgs(opts))+1) + words = append(words, systemdQuoteAlways(opts.Executable)) + for _, arg := range RunArgs(opts) { + words = append(words, systemdQuote(arg)) + } + + target := "default.target" + if opts.Scope == ScopeSystem { + target = "multi-user.target" + } + + return fmt.Sprintf(unitTemplate, + strings.Join(words, " "), + systemdQuote(opts.Paths.StateDir), + systemdQuote("HOME="+home), + target, + ), nil +} + +// unitHomeDir returns the HOME of the registered process. +// +// A user scope instance runs as the user of the session, so it keeps the +// home of that user. A machine wide instance never inherits the home of the +// user who runs the install command: it uses the machine level state +// directory as its home. +func unitHomeDir(opts InstallOptions) (string, error) { + if opts.Scope == ScopeSystem { + return systemServiceHome, nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("service: the home directory of the user is unknown: %w", err) + } + if home == "" { + return "", errors.New("service: the home directory of the user is unknown") + } + return home, nil +} + +// systemdQuote renders one value of a unit file. The value is quoted only +// when it needs it, so a plain path stays readable. +func systemdQuote(value string) string { + if value == "" { + return `""` + } + if !strings.ContainsAny(value, " \t\"'\\%") { + return value + } + return systemdQuoteAlways(value) +} + +// systemdQuoteAlways renders one value of a unit file in the C style +// quoting of systemd, no matter what the value contains. +func systemdQuoteAlways(value string) string { + var b strings.Builder + b.WriteByte('"') + for _, r := range value { + switch r { + case '"': + b.WriteString(`\"`) + case '\\': + b.WriteString(`\\`) + case '%': + // A percent sign starts a specifier of the supervisor, a + // doubled one is a literal percent sign. + b.WriteString("%%") + default: + b.WriteRune(r) + } + } + b.WriteByte('"') + return b.String() +} + +// parsedUnit holds the registration facts of a unit file. +type parsedUnit struct { + // ExecStart is the executable of the unit. + ExecStart string + // ExecArgs are the arguments of the executable. + ExecArgs []string + // WorkingDirectory is the working directory of the unit. + WorkingDirectory string + // Environment holds the environment variables of the unit. + Environment map[string]string +} + +// listen returns the bind address of the registered instance. +func (u parsedUnit) listen() string { + return u.flagValue("--listen") +} + +// paths returns the runtime paths of the registered instance. +// +// The values come from the registered unit file, so the status describes +// the registration and not the current intent of the caller. +func (u parsedUnit) paths() runtimepaths.Paths { + paths := runtimepaths.Paths{ + ConfigFile: u.flagValue("--config"), + StateDir: u.flagValue("--state-dir"), + LogDir: u.flagValue("--log-dir"), + } + if paths.StateDir == "" { + paths.StateDir = u.WorkingDirectory + } + return paths +} + +// flagValue returns the value of a flag of the argument vector of the unit. +func (u parsedUnit) flagValue(flag string) string { + for i := 0; i+1 < len(u.ExecArgs); i++ { + if u.ExecArgs[i] == flag { + return u.ExecArgs[i+1] + } + } + return "" +} + +// parseUnit reads the registration facts of a unit file. +// +// The parser is tolerant: a unit file that was edited by hand is described +// as far as it can be read, so the status of the registration stays +// meaningful. +func parseUnit(content string) parsedUnit { + unit := parsedUnit{Environment: map[string]string{}} + section := "" + for _, line := range strings.Split(content, "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") || strings.HasPrefix(trimmed, ";") { + continue + } + if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") { + section = strings.ToLower(strings.TrimSpace(trimmed[1 : len(trimmed)-1])) + continue + } + key, value, ok := strings.Cut(trimmed, "=") + if !ok { + continue + } + if section != "service" { + continue + } + switch strings.TrimSpace(key) { + case "ExecStart": + words := splitSystemdWords(value) + if len(words) == 0 { + continue + } + unit.ExecStart = words[0] + unit.ExecArgs = words[1:] + case "WorkingDirectory": + if words := splitSystemdWords(value); len(words) > 0 { + unit.WorkingDirectory = words[0] + } + case "Environment": + for _, word := range splitSystemdWords(value) { + name, envValue, ok := strings.Cut(word, "=") + if !ok { + continue + } + unit.Environment[name] = envValue + } + } + } + return unit +} + +// splitSystemdWords splits a command line of a unit file into its words. +// It understands the quoting of the supervisor: single quotes, double quotes +// and backslash escapes. +func splitSystemdWords(line string) []string { + var ( + words []string + word strings.Builder + has bool + ) + flush := func() { + if !has { + return + } + words = append(words, word.String()) + word.Reset() + has = false + } + for i := 0; i < len(line); i++ { + switch c := line[i]; { + case c == ' ' || c == '\t' || c == '\n' || c == '\r': + flush() + case c == '\'': + has = true + end := strings.IndexByte(line[i+1:], '\'') + if end < 0 { + word.WriteString(line[i+1:]) + i = len(line) + continue + } + word.WriteString(line[i+1 : i+1+end]) + i = i + 1 + end + case c == '"': + has = true + for i++; i < len(line); i++ { + if line[i] == '\\' && i+1 < len(line) { + word.WriteString(unescapeSystemdChar(line[i+1])) + i++ + continue + } + if line[i] == '%' && i+1 < len(line) && line[i+1] == '%' { + word.WriteByte('%') + i++ + continue + } + if line[i] == '"' { + break + } + word.WriteByte(line[i]) + } + case c == '\\': + has = true + if i+1 < len(line) { + word.WriteString(unescapeSystemdChar(line[i+1])) + i++ + } + case c == '%': + has = true + if i+1 < len(line) && line[i+1] == '%' { + word.WriteByte('%') + i++ + continue + } + word.WriteByte('%') + default: + has = true + word.WriteByte(c) + } + } + flush() + return words +} + +// unescapeSystemdChar returns the character an escape sequence of a unit +// file stands for. +func unescapeSystemdChar(c byte) string { + switch c { + case 'n': + return "\n" + case 't': + return "\t" + case 'r': + return "\r" + case 's': + return " " + default: + return string(c) + } +} + +// systemdShow holds the properties of the unit the supervisor reported. +type systemdShow struct { + // LoadState tells whether the supervisor knows the unit. + LoadState string + // ActiveState is the high level state of the unit. + ActiveState string + // SubState is the low level state of the unit. + SubState string + // State is the lifecycle state of the unit in the vocabulary of the + // shared contract. + State State +} + +// parseSystemdShow parses the output of the supervisor and maps the +// reported properties onto the lifecycle states of the shared contract. +func parseSystemdShow(output string) systemdShow { + show := systemdShow{} + for _, line := range strings.Split(output, "\n") { + key, value, ok := strings.Cut(strings.TrimSpace(line), "=") + if !ok { + continue + } + switch strings.TrimSpace(key) { + case "LoadState": + show.LoadState = strings.TrimSpace(value) + case "ActiveState": + show.ActiveState = strings.TrimSpace(value) + case "SubState": + show.SubState = strings.TrimSpace(value) + } + } + show.State = show.state() + return show +} + +// state maps the reported properties of the unit onto the lifecycle states +// of the shared contract. +func (s systemdShow) state() State { + if s.LoadState == "not-found" { + return StateNotInstalled + } + switch s.ActiveState { + case "active": + return StateRunning + case "inactive": + return StateStopped + case "activating": + return StateStarting + case "deactivating": + return StateStopping + case "failed": + return StateFailed + } + return StateUnknown +} diff --git a/internal/service/manager_linux_test.go b/internal/service/manager_linux_test.go new file mode 100644 index 00000000..633a4c7e --- /dev/null +++ b/internal/service/manager_linux_test.go @@ -0,0 +1,588 @@ +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 + +//go:build linux + +package service + +import ( + "bytes" + "context" + "errors" + "io/fs" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/bashrusakh/hfdesk/internal/runtimepaths" +) + +// systemdTestOptions returns an installation for the systemd tests. The +// paths are absolute and carry no access data, so the checks of the +// rendered unit stay exact. +func systemdTestOptions(t *testing.T, scope Scope) InstallOptions { + t.Helper() + root := "/opt/hfdesk" + if scope == ScopeSystem { + root = "/srv/hfdesk" + } + return InstallOptions{ + Scope: scope, + Executable: root + "/bin/hfdesk", + Listen: "127.0.0.1:8080", + Paths: runtimepaths.Paths{ + ConfigFile: root + "/etc/hfdesk/hfdesk.json", + StateDir: root + "/var/lib/hfdesk", + LogDir: root + "/var/log/hfdesk", + }, + CacheDir: root + "/cache", + HubDir: root + "/cache/hub", + LocalDir: root + "/models", + } +} + +// unitDirectives returns the effective lines of a unit file: the lines the +// supervisor reads, without the comments and the empty lines. +func unitDirectives(unit string) []string { + var directives []string + for _, line := range strings.Split(unit, "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") || strings.HasPrefix(trimmed, ";") { + continue + } + directives = append(directives, trimmed) + } + return directives +} + +func TestSystemdRenderUnitUserScope(t *testing.T) { + opts := systemdTestOptions(t, ScopeUser) + + unit, err := renderUnit(opts) + if err != nil { + t.Fatalf("renderUnit() = %v, want nil", err) + } + + for _, want := range []string{ + "Description=HFDesk", + "Documentation=https://github.com/bashrusakh/hfdesk", + "After=network-online.target", + "Wants=network-online.target", + "Type=simple", + "Restart=on-failure", + "RestartSec=5s", + "NoNewPrivileges=true", + "UMask=0077", + "WantedBy=default.target", + } { + if !strings.Contains(unit, want) { + t.Errorf("the unit does not contain %q:\n%s", want, unit) + } + } + if strings.Contains(unit, "WantedBy=multi-user.target") { + t.Errorf("a user scope unit must not be enabled for the machine:\n%s", unit) + } + + parsed := parseUnit(unit) + if !filepath.IsAbs(parsed.ExecStart) { + t.Errorf("ExecStart = %q, want an absolute path", parsed.ExecStart) + } + if parsed.ExecStart != opts.Executable { + t.Errorf("ExecStart = %q, want %q", parsed.ExecStart, opts.Executable) + } + if parsed.WorkingDirectory != opts.Paths.StateDir { + t.Errorf("WorkingDirectory = %q, want the state dir %q", parsed.WorkingDirectory, opts.Paths.StateDir) + } + + home, err := os.UserHomeDir() + if err != nil { + t.Fatalf("UserHomeDir() = %v, want nil", err) + } + if got := parsed.Environment["HOME"]; got != home { + t.Errorf("Environment HOME = %q, want the home of the user %q", got, home) + } + + // The registered command is the canonical argument vector of the + // shared contract. + wantArgs := RunArgs(opts) + if len(parsed.ExecArgs) != len(wantArgs) { + t.Fatalf("the arguments of the unit = %v, want %v", parsed.ExecArgs, wantArgs) + } + for i := range wantArgs { + if parsed.ExecArgs[i] != wantArgs[i] { + t.Fatalf("the arguments of the unit = %v, want %v", parsed.ExecArgs, wantArgs) + } + } +} + +func TestSystemdRenderUnitSystemScope(t *testing.T) { + opts := systemdTestOptions(t, ScopeSystem) + + unit, err := renderUnit(opts) + if err != nil { + t.Fatalf("renderUnit() = %v, want nil", err) + } + + if !strings.Contains(unit, "WantedBy=multi-user.target") { + t.Errorf("the unit does not contain %q:\n%s", "WantedBy=multi-user.target", unit) + } + if strings.Contains(unit, "WantedBy=default.target") { + t.Errorf("a system scope unit must not be enabled for a user session:\n%s", unit) + } + + parsed := parseUnit(unit) + if got := parsed.Environment["HOME"]; got != "/var/lib/hfdesk" { + t.Errorf("Environment HOME = %q, want %q", got, "/var/lib/hfdesk") + } + home, err := os.UserHomeDir() + if err == nil && home != "" { + if got := parsed.Environment["HOME"]; got == home { + t.Errorf("Environment HOME = %q, want the machine level home of a system scope instance", got) + } + } + // A machine wide instance must not inherit the identity of the user + // who runs the install command. + for _, directive := range unitDirectives(unit) { + if strings.HasPrefix(directive, "User=") { + t.Errorf("the unit names a user: %q", directive) + } + } +} + +func TestSystemdRenderUnitCarriesNoCredential(t *testing.T) { + const sentinel = "hf_zzSENTINELzz_9f3a" + + opts := systemdTestOptions(t, ScopeUser) + // Every field that could plausibly carry access data is filled with + // the sentinel, so the check keeps its meaning when such a field is + // added. + opts.User = sentinel + "_user" + opts.UID = sentinel + "_uid" + setCredentialFields(&opts, sentinel) + + unit, err := renderUnit(opts) + if err != nil { + t.Fatalf("renderUnit() = %v, want nil", err) + } + if strings.Contains(unit, sentinel) { + t.Errorf("the unit leaks the sentinel %q:\n%s", sentinel, unit) + } + for _, directive := range unitDirectives(unit) { + if looksLikeCredential(directive) { + t.Errorf("the unit carries a credential looking directive %q", directive) + } + } + + // The supervisor owns the lifecycle: no fork, no detached process, no + // pid file, no background shell. + for _, forbidden := range []string{"nohup", "Type=forking", "PIDFile=", "StandardOutput=file:", "StandardError=file:"} { + if strings.Contains(unit, forbidden) { + t.Errorf("the unit contains %q:\n%s", forbidden, unit) + } + } + for _, line := range strings.Split(unit, "\n") { + if strings.HasSuffix(strings.TrimSpace(line), "&") { + t.Errorf("the unit puts a command into the background: %q", line) + } + } +} + +func TestSystemdRenderUnitQuotesPathsWithSpaces(t *testing.T) { + opts := systemdTestOptions(t, ScopeUser) + opts.Executable = "/opt/HF Desk/bin/hfdesk" + opts.Paths.StateDir = "/var/lib/HF Desk" + + unit, err := renderUnit(opts) + if err != nil { + t.Fatalf("renderUnit() = %v, want nil", err) + } + if !strings.Contains(unit, `ExecStart="/opt/HF Desk/bin/hfdesk"`) { + t.Errorf("the executable is not quoted:\n%s", unit) + } + if !strings.Contains(unit, `"/var/lib/HF Desk"`) { + t.Errorf("an argument with a space is not quoted:\n%s", unit) + } + + parsed := parseUnit(unit) + if parsed.ExecStart != opts.Executable { + t.Errorf("ExecStart = %q, want %q", parsed.ExecStart, opts.Executable) + } + if got := parsed.flagValue("--state-dir"); got != opts.Paths.StateDir { + t.Errorf("--state-dir = %q, want %q", got, opts.Paths.StateDir) + } + if parsed.WorkingDirectory != opts.Paths.StateDir { + t.Errorf("WorkingDirectory = %q, want %q", parsed.WorkingDirectory, opts.Paths.StateDir) + } +} + +func TestSystemdUnitPaths(t *testing.T) { + t.Run("user scope with XDG_CONFIG_HOME", func(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", "/tmp/hfdesk-xdg") + m := &systemdManager{scope: ScopeUser} + got, err := m.unitPath() + if err != nil { + t.Fatalf("unitPath() = %v, want nil", err) + } + want := "/tmp/hfdesk-xdg/systemd/user/hfdesk.service" + if got != want { + t.Errorf("unitPath() = %q, want %q", got, want) + } + }) + + t.Run("user scope without XDG_CONFIG_HOME", func(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", "") + t.Setenv("HOME", "/home/hfdesk-user") + m := &systemdManager{scope: ScopeUser} + got, err := m.unitPath() + if err != nil { + t.Fatalf("unitPath() = %v, want nil", err) + } + want := "/home/hfdesk-user/.config/systemd/user/hfdesk.service" + if got != want { + t.Errorf("unitPath() = %q, want %q", got, want) + } + }) + + t.Run("system scope", func(t *testing.T) { + m := &systemdManager{scope: ScopeSystem} + got, err := m.unitPath() + if err != nil { + t.Fatalf("unitPath() = %v, want nil", err) + } + want := "/etc/systemd/system/hfdesk.service" + if got != want { + t.Errorf("unitPath() = %q, want %q", got, want) + } + }) +} + +func TestSystemdParseShow(t *testing.T) { + cases := []struct { + name string + output string + want State + }{ + { + name: "not found", + output: "LoadState=not-found\nActiveState=inactive\nSubState=dead", + want: StateNotInstalled, + }, + { + name: "active", + output: "LoadState=loaded\nActiveState=active\nSubState=running", + want: StateRunning, + }, + { + name: "inactive", + output: "LoadState=loaded\nActiveState=inactive\nSubState=dead", + want: StateStopped, + }, + { + name: "activating", + output: "LoadState=loaded\nActiveState=activating\nSubState=start", + want: StateStarting, + }, + { + name: "deactivating", + output: "LoadState=loaded\nActiveState=deactivating\nSubState=stop", + want: StateStopping, + }, + { + name: "failed", + output: "LoadState=loaded\nActiveState=failed\nSubState=failed", + want: StateFailed, + }, + { + name: "unknown active state", + output: "LoadState=loaded\nActiveState=reloading\nSubState=reload", + want: StateUnknown, + }, + { + name: "empty output", + output: "", + want: StateUnknown, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := parseSystemdShow(tc.output) + if got.State != tc.want { + t.Errorf("parseSystemdShow(%q).State = %q, want %q", tc.output, got.State, tc.want) + } + }) + } +} + +func TestSystemdParseShowReadsTheProperties(t *testing.T) { + output := "LoadState=loaded\nActiveState=active\nSubState=running\n" + show := parseSystemdShow(output) + if show.LoadState != "loaded" { + t.Errorf("LoadState = %q, want %q", show.LoadState, "loaded") + } + if show.ActiveState != "active" { + t.Errorf("ActiveState = %q, want %q", show.ActiveState, "active") + } + if show.SubState != "running" { + t.Errorf("SubState = %q, want %q", show.SubState, "running") + } +} + +func TestSystemdParseUnitRecoversTheRegistration(t *testing.T) { + opts := systemdTestOptions(t, ScopeUser) + + unit, err := renderUnit(opts) + if err != nil { + t.Fatalf("renderUnit() = %v, want nil", err) + } + parsed := parseUnit(unit) + + if parsed.ExecStart != opts.Executable { + t.Errorf("the registered executable = %q, want %q", parsed.ExecStart, opts.Executable) + } + if parsed.WorkingDirectory != opts.Paths.StateDir { + t.Errorf("the registered working directory = %q, want %q", parsed.WorkingDirectory, opts.Paths.StateDir) + } + if got := parsed.listen(); got != opts.Listen { + t.Errorf("the registered listen address = %q, want %q", got, opts.Listen) + } + paths := parsed.paths() + if paths.ConfigFile != opts.Paths.ConfigFile { + t.Errorf("the registered config file = %q, want %q", paths.ConfigFile, opts.Paths.ConfigFile) + } + if paths.StateDir != opts.Paths.StateDir { + t.Errorf("the registered state dir = %q, want %q", paths.StateDir, opts.Paths.StateDir) + } + if paths.LogDir != opts.Paths.LogDir { + t.Errorf("the registered log dir = %q, want %q", paths.LogDir, opts.Paths.LogDir) + } +} + +// TestSystemdAnalyzeVerify checks the rendered unit with the verifier of the +// supervisor itself: a unit that the supervisor cannot parse is not a unit. +// +// The unit of the test points at the test binary, so the verifier finds an +// executable that really exists. +func TestSystemdAnalyzeVerify(t *testing.T) { + analyze, err := exec.LookPath("systemd-analyze") + if err != nil { + t.Skipf("skipping: systemd-analyze is not available in this environment: %v", err) + } + executable, err := os.Executable() + if err != nil { + t.Fatalf("Executable() = %v, want nil", err) + } + + root := t.TempDir() + opts := systemdTestOptions(t, ScopeUser) + opts.Executable = executable + opts.Paths = runtimepaths.Paths{ + ConfigFile: filepath.Join(root, "hfdesk.json"), + StateDir: root, + LogDir: filepath.Join(root, "logs"), + } + if err := os.MkdirAll(opts.Paths.LogDir, 0o700); err != nil { + t.Fatal(err) + } + + unit, err := renderUnit(opts) + if err != nil { + t.Fatalf("renderUnit() = %v, want nil", err) + } + path := filepath.Join(root, unitName) + if err := os.WriteFile(path, []byte(unit), 0o644); err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, analyze, "verify", path) + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + t.Fatalf("systemd-analyze verify %s = %v\n%s\nthe unit:\n%s", path, err, stderr.String(), unit) + } +} + +// systemdUserSessionUsable reports whether the supervisor of the session of +// the user can be reached. +func systemdUserSessionUsable() bool { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "systemctl", "--user", "show", "--property=Version") + out, err := cmd.CombinedOutput() + return err == nil && strings.Contains(string(out), "Version=") +} + +// waitForState waits until the supervisor reports the wanted state. +func waitForState(t *testing.T, ctx context.Context, m *systemdManager, want State) { + t.Helper() + deadline := time.Now().Add(20 * time.Second) + var last State + for time.Now().Before(deadline) { + status, err := m.Status(ctx) + if err == nil { + last = status.State + if last == want { + return + } + } + time.Sleep(200 * time.Millisecond) + } + t.Fatalf("the state of the unit = %q, want %q", last, want) +} + +// TestSystemdUserIntegrationCycle runs a full lifecycle of a user scope +// installation against the systemd of the machine. +// +// The test skips itself when the environment has no usable user systemd +// session. It never runs as root: a root process would install a unit of +// the machine instead of a unit of the session of a user. +func TestSystemdUserIntegrationCycle(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("skipping: the integration cycle must not run as root against a real unit of the machine") + } + if !systemdUserSessionUsable() { + t.Skip("skipping: this environment has no usable user systemd session") + } + + home, err := os.UserHomeDir() + if err != nil { + t.Fatalf("UserHomeDir() = %v, want nil", err) + } + + m := &systemdManager{scope: ScopeUser} + unitPath, err := m.unitPath() + if err != nil { + t.Fatalf("unitPath() = %v, want nil", err) + } + // The user manager of the machine loads the units of the + // configuration directory of the user. When the two do not agree, the + // supervisor would never see the unit of the test. + managerPath := filepath.Join(home, ".config", "systemd", "user", unitName) + if unitPath != managerPath { + t.Skipf("skipping: the user manager of this environment loads the units of %s, the manager resolves %s", managerPath, unitPath) + } + if _, err := os.Stat(unitPath); err == nil { + t.Skipf("skipping: %s already exists, the test refuses to replace an existing registration", unitPath) + } + + root := t.TempDir() + executable := filepath.Join(root, "bin", "hfdesk") + if err := os.MkdirAll(filepath.Dir(executable), 0o755); err != nil { + t.Fatal(err) + } + // The probe stays in the foreground and ignores the arguments of the + // canonical vector. + if err := os.WriteFile(executable, []byte("#!/bin/sh\nexec sleep 120\n"), 0o755); err != nil { + t.Fatal(err) + } + + opts := InstallOptions{ + Scope: ScopeUser, + Executable: executable, + Listen: "127.0.0.1:18099", + Paths: runtimepaths.Paths{ + ConfigFile: filepath.Join(root, "etc", "hfdesk.json"), + StateDir: filepath.Join(root, "var"), + LogDir: filepath.Join(root, "var", "logs"), + }, + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + t.Cleanup(func() { + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cleanupCancel() + _ = m.Uninstall(cleanupCtx) + _ = os.Remove(unitPath) + _, _ = m.runSystemctl(cleanupCtx, "daemon-reload") + }) + + if err := m.Install(ctx, opts); err != nil { + t.Fatalf("Install() = %v, want nil", err) + } + + info, err := os.Stat(unitPath) + if err != nil { + t.Fatalf("the unit file was not written: %v", err) + } + if info.Mode().Perm() != 0o644 { + t.Errorf("the mode of the unit file = %v, want 0644", info.Mode().Perm()) + } + content, err := os.ReadFile(unitPath) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(content), "Type=simple") { + t.Errorf("the unit is not a simple service:\n%s", content) + } + + status, err := m.Status(ctx) + if err != nil { + t.Fatalf("Status() = %v, want nil", err) + } + if status.State != StateStopped { + t.Errorf("the state after the installation = %q, want %q", status.State, StateStopped) + } + if status.Executable != opts.Executable { + t.Errorf("the registered executable = %q, want %q", status.Executable, opts.Executable) + } + if status.Listen != opts.Listen { + t.Errorf("the registered listen address = %q, want %q", status.Listen, opts.Listen) + } + if status.UnitPath != unitPath { + t.Errorf("the unit path = %q, want %q", status.UnitPath, unitPath) + } + if status.Paths.ConfigFile != opts.Paths.ConfigFile { + t.Errorf("the registered config file = %q, want %q", status.Paths.ConfigFile, opts.Paths.ConfigFile) + } + + if err := m.Start(ctx); err != nil { + t.Fatalf("Start() = %v, want nil", err) + } + waitForState(t, ctx, m, StateRunning) + // Starting a running service is a stable success. + if err := m.Start(ctx); err != nil { + t.Errorf("the second Start() = %v, want nil", err) + } + + if err := m.Stop(ctx); err != nil { + t.Fatalf("Stop() = %v, want nil", err) + } + waitForState(t, ctx, m, StateStopped) + // Stopping a stopped service is a stable success. + if err := m.Stop(ctx); err != nil { + t.Errorf("the second Stop() = %v, want nil", err) + } + + if err := m.Uninstall(ctx); err != nil { + t.Fatalf("Uninstall() = %v, want nil", err) + } + if _, err := os.Stat(unitPath); !errors.Is(err, fs.ErrNotExist) { + t.Errorf("the unit file still exists after the uninstallation: %v", err) + } + // The uninstallation removes the registration only. + if _, err := os.Stat(opts.Paths.StateDir); err != nil { + t.Errorf("the state directory was removed: %v", err) + } + if _, err := os.Stat(opts.Paths.LogDir); err != nil { + t.Errorf("the log directory was removed: %v", err) + } + + status, err = m.Status(ctx) + if err != nil { + t.Fatalf("Status() = %v, want nil", err) + } + if status.State != StateNotInstalled { + t.Errorf("the state after the uninstallation = %q, want %q", status.State, StateNotInstalled) + } + + // Uninstalling an absent service is a clean success. + if err := m.Uninstall(ctx); err != nil { + t.Errorf("the second Uninstall() = %v, want nil", err) + } +} diff --git a/internal/service/manager_other.go b/internal/service/manager_other.go new file mode 100644 index 00000000..b11e58df --- /dev/null +++ b/internal/service/manager_other.go @@ -0,0 +1,17 @@ +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 + +//go:build !linux && !darwin && !windows + +package service + +import ( + "fmt" + "runtime" +) + +// newPlatformManager reports that service mode is not supported on this +// platform. +func newPlatformManager(scope Scope) (Manager, error) { + return nil, fmt.Errorf("service mode is not supported on %s", runtime.GOOS) +} diff --git a/internal/service/manager_windows.go b/internal/service/manager_windows.go new file mode 100644 index 00000000..6c2e4394 --- /dev/null +++ b/internal/service/manager_windows.go @@ -0,0 +1,693 @@ +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 + +//go:build windows + +package service + +import ( + "context" + "errors" + "fmt" + "log" + "os" + "path/filepath" + "strings" + "syscall" + "time" + "unsafe" + + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/svc" + "golang.org/x/sys/windows/svc/eventlog" + "golang.org/x/sys/windows/svc/mgr" + + "github.com/bashrusakh/hfdesk/internal/runtimepaths" +) + +// The Windows service mode of HFDesk is a registration in the service +// control manager. +// +// The service control manager registers machine-wide services only, so a +// user scope installation does not exist on this platform. The +// registration is driven through the API of the supervisor: sc.exe, +// PowerShell and the shell are never called, so a path with a space or a +// quote in it cannot change the command that runs. +// +// The manager never escalates privileges itself: an installation and an +// uninstallation need an elevated shell, and the manager fails with a +// clear message when the process does not have one. +const ( + // scmDisplayName is the name the service manager shows. + scmDisplayName = "HFDesk" + + // scmDescription is the description of the registration. + scmDescription = "HFDesk - local dashboard for searching, analyzing and managing Hugging Face models and datasets." + + // scmLocalServiceAccount is the account the instance runs as. + // + // LocalService is the least privileged of the built-in service + // accounts of the operating system: it has no password, it is not a + // member of the administrators and it holds only the rights a + // service needs. The instance therefore never runs with the rights + // of the machine and never with the rights of the user who + // installed it. + // + // The account has a profile of its own, so the instance sees + // neither the desktop nor the files of the user who installed it. + // The paths of the instance are the machine-wide paths of the + // platform (%ProgramData%\HFDesk), never the profile of a user. + scmLocalServiceAccount = `NT AUTHORITY\LocalService` + + // scmRegistrationPath is the place the registration lives in. The + // service control manager keeps its database in the registry, so the + // registration has no file of its own. + scmRegistrationPath = `HKLM\SYSTEM\CurrentControlSet\Services\` + ServiceName +) + +const ( + // scmStopTimeout bounds the wait for the graceful stop of the + // instance. + scmStopTimeout = 30 * time.Second + + // scmStopPollInterval is the pause between two checks of the state of + // the instance. + scmStopPollInterval = 200 * time.Millisecond +) + +// scmManager drives the service control manager of HFDesk. +// +// The manager registers the instance as a machine-wide service, it starts +// and stops it and it reports its state. The registration carries the +// canonical argument vector of the shared contract: paths and a bind +// address only. A token, a proxy password or a basic auth password is +// never part of a registration, because every user of the machine can read +// the registration. +type scmManager struct { + // scope is the installation scope of the instance. + scope Scope +} + +// newPlatformManager returns the service control manager of the given +// scope. +// +// The service control manager registers machine-wide services only, so a +// user scope installation is rejected explicitly instead of silently +// falling back to a machine-wide registration. +func newPlatformManager(scope Scope) (Manager, error) { + switch scope { + case ScopeSystem: + return &scmManager{scope: scope}, nil + case ScopeUser: + return nil, fmt.Errorf("service: scope %q is not supported on windows: the service control manager registers machine-wide services only", scope) + default: + return nil, fmt.Errorf("service: unknown scope %q (want %q)", scope, ScopeSystem) + } +} + +// Install registers the instance with the service control manager. +// +// The installation validates the options, prepares the directories of the +// instance, registers the service, registers the event log source of the +// instance and restricts the access to the state of the instance. +// +// A service that is registered already is not registered a second time: +// its registration is updated instead, so the instance keeps its identity. +// +// An installation never starts the instance: starting is an explicit, +// separate command. +// +// A failure after the registration was created removes the registration +// again, so a failed installation never leaves a half-configured service +// behind. +func (m *scmManager) Install(ctx context.Context, opts InstallOptions) error { + if err := ValidateInstallOptions(opts); err != nil { + return err + } + // The elevation requirement is checked before a directory is created: + // a permission error of the machine-wide directories is a much less + // readable answer than the real reason. + if err := requireElevated("an installation"); err != nil { + return err + } + if err := PrepareDirs(opts); err != nil { + return err + } + if err := m.preflight(opts); err != nil { + return err + } + + manager, err := mgr.Connect() + if err != nil { + return fmt.Errorf("service: connect to the service control manager: %w", err) + } + defer manager.Disconnect() + + config := buildSCMConfig(opts) + handle, err := manager.OpenService(ServiceName) + created := false + switch { + case err == nil: + // The service is registered already. The registration is + // updated instead of a second one being created, so the + // instance keeps its identity and the status keeps describing + // one service. + defer handle.Close() + if err := handle.UpdateConfig(config); err != nil { + return fmt.Errorf("service: update the registration of %s: %w", ServiceName, err) + } + log.Printf("service: %s is installed already, the registration was updated", ServiceName) + logEvent(eventLogInfo, fmt.Sprintf("hfdesk: the registration of %s was updated", ServiceName)) + case errors.Is(err, windows.ERROR_SERVICE_DOES_NOT_EXIST): + handle, err = manager.CreateService(ServiceName, opts.Executable, config, RunArgs(opts)...) + if err != nil { + return fmt.Errorf("service: register %s: %w", ServiceName, err) + } + defer handle.Close() + created = true + default: + return fmt.Errorf("service: open the registration of %s: %w", ServiceName, err) + } + + // The event log source carries the diagnostics of the instance. A + // source that cannot be registered is not a failed installation: the + // administrator of the machine can register it separately, and the + // instance falls back to the Application log. + if err := registerEventSource(); err != nil { + log.Printf("service: the event log source of %s was not registered: %v", ServiceName, err) + logEvent(eventLogWarning, fmt.Sprintf("hfdesk: the event log source of %s was not registered: %v", ServiceName, err)) + } + + // The state of the instance belongs to the machine. + if err := restrictInstanceState(opts); err != nil { + // A registration that this call created is removed again: a + // half-configured service must not survive a failed + // installation. A registration that was already there stays: + // it belongs to the machine and not to this call. + if created { + rollbackRegistration(handle) + } + return err + } + return nil +} + +// Uninstall removes the registration of the instance. +// +// It removes the registration only: the config file, the jobs, the history, +// the logs, the models and the cache of the instance are never touched. An +// absent registration is a clean success. +func (m *scmManager) Uninstall(ctx context.Context) error { + if err := requireElevated("an uninstallation"); err != nil { + return err + } + manager, handle, ok, err := m.openRegistration() + if err != nil { + return err + } + if !ok { + // There is no registration: the uninstallation is already done. + return nil + } + defer manager.Disconnect() + defer handle.Close() + + // A running instance is stopped before the registration is removed: + // the service control manager refuses to delete a service that is + // still running. + if err := m.stopService(ctx, handle); err != nil { + return err + } + if err := handle.Delete(); err != nil { + if errors.Is(err, windows.ERROR_SERVICE_MARKED_FOR_DELETE) { + // The registration is marked for the deletion already. + return nil + } + return fmt.Errorf("service: remove the registration of %s: %w", ServiceName, err) + } + return nil +} + +// Start starts the registered instance. +// +// Starting an instance that runs already is a stable success: the state +// the caller asked for already holds. +func (m *scmManager) Start(ctx context.Context) error { + manager, handle, ok, err := m.openRegistration() + if err != nil { + return err + } + if !ok { + return fmt.Errorf("service: %s is not installed", ServiceName) + } + defer manager.Disconnect() + defer handle.Close() + + if err := handle.Start(); err != nil { + if errors.Is(err, windows.ERROR_SERVICE_ALREADY_RUNNING) { + return nil + } + return fmt.Errorf("service: start %s: %w", ServiceName, err) + } + return nil +} + +// Stop stops the registered instance. +// +// Stopping an instance that is stopped already is a stable success, and so +// is stopping an instance that is not registered at all: it does not run +// either. +func (m *scmManager) Stop(ctx context.Context) error { + manager, handle, ok, err := m.openRegistration() + if err != nil { + return err + } + if !ok { + return nil + } + defer manager.Disconnect() + defer handle.Close() + + return m.stopService(ctx, handle) +} + +// Restart stops and starts the registered instance. +// +// The instance is stopped first: the graceful shutdown of the server +// completes before a new process is started, so the two instances never +// compete for the bind address. +func (m *scmManager) Restart(ctx context.Context) error { + manager, handle, ok, err := m.openRegistration() + if err != nil { + return err + } + if !ok { + return fmt.Errorf("service: %s is not installed", ServiceName) + } + defer manager.Disconnect() + defer handle.Close() + + if err := m.stopService(ctx, handle); err != nil { + return err + } + if err := handle.Start(); err != nil { + if errors.Is(err, windows.ERROR_SERVICE_ALREADY_RUNNING) { + return nil + } + return fmt.Errorf("service: start %s: %w", ServiceName, err) + } + return nil +} + +// Status reports the state of the registered instance. +// +// The description of the instance comes from the registration, so the status +// describes the registration and not the intent of the caller. A registered +// executable that is gone is reported as broken, so a moved or deleted +// binary is never reported as a running service. +// +// The registration has no file of its own: the service control manager +// keeps its database in the registry, so the registration path is the +// registry key of the service. +func (m *scmManager) Status(ctx context.Context) (Status, error) { + status := Status{ + State: StateNotInstalled, + Scope: m.scope, + Name: ServiceName, + UnitPath: scmRegistrationPath, + } + + manager, handle, ok, err := m.openRegistration() + if err != nil { + return status, err + } + if !ok { + status.Detail = fmt.Sprintf("%s is not registered", ServiceName) + return status, nil + } + defer manager.Disconnect() + defer handle.Close() + + commandLine, err := queryServiceCommandLine(handle.Handle) + if err != nil { + return status, fmt.Errorf("service: read the registration of %s: %w", ServiceName, err) + } + registration, err := parseSCMCommandLine(commandLine) + if err != nil { + return status, err + } + status.Executable = registration.executable + status.Listen = registration.listen() + status.Paths = registration.paths() + + if status.Executable != "" { + if _, err := os.Stat(status.Executable); err != nil { + status.State = StateBroken + status.Detail = fmt.Sprintf("the registered executable %s does not exist", status.Executable) + return status, nil + } + } + + query, err := handle.Query() + if err != nil { + return status, fmt.Errorf("service: query the state of %s: %w", ServiceName, err) + } + status.State = scmState(query.State) + if status.State == StateStopped && query.Win32ExitCode != 0 { + status.Detail = fmt.Sprintf("the instance exited with the code %d", query.Win32ExitCode) + } + return status, nil +} + +// openRegistration opens the registration of the instance. +// +// The second result reports whether the service is registered at all: an +// absent registration is not an error, it is the state the caller asked +// about. +func (m *scmManager) openRegistration() (*mgr.Mgr, *mgr.Service, bool, error) { + manager, err := mgr.Connect() + if err != nil { + return nil, nil, false, fmt.Errorf("service: connect to the service control manager: %w", err) + } + handle, err := manager.OpenService(ServiceName) + if err != nil { + manager.Disconnect() + if errors.Is(err, windows.ERROR_SERVICE_DOES_NOT_EXIST) { + return nil, nil, false, nil + } + return nil, nil, false, fmt.Errorf("service: open the registration of %s: %w", ServiceName, err) + } + return manager, handle, true, nil +} + +// stopService stops the instance and waits for the graceful shutdown to +// finish. +// +// Stopping an instance that does not run is a stable success: the state the +// caller asked for already holds. +func (m *scmManager) stopService(ctx context.Context, handle *mgr.Service) error { + status, err := handle.Query() + if err != nil { + return fmt.Errorf("service: query the state of %s: %w", ServiceName, err) + } + if status.State == svc.Stopped { + return nil + } + if _, err := handle.Control(svc.Stop); err != nil { + if errors.Is(err, windows.ERROR_SERVICE_NOT_ACTIVE) { + // The instance is not running: the wanted state already + // holds. + return nil + } + if !errors.Is(err, windows.ERROR_SERVICE_CANNOT_ACCEPT_CTRL) { + return fmt.Errorf("service: stop %s: %w", ServiceName, err) + } + } + return m.waitForStopped(ctx, handle) +} + +// waitForStopped waits until the service control manager reports the +// instance as stopped. +// +// The wait is bounded: an instance that ignores the stop request must not +// block the caller forever. +func (m *scmManager) waitForStopped(ctx context.Context, handle *mgr.Service) error { + deadline := time.Now().Add(scmStopTimeout) + for { + status, err := handle.Query() + if err != nil { + return fmt.Errorf("service: query the state of %s: %w", ServiceName, err) + } + if status.State == svc.Stopped { + return nil + } + if time.Now().After(deadline) { + return fmt.Errorf("service: %s did not stop within %s", ServiceName, scmStopTimeout) + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(scmStopPollInterval): + } + } +} + +// preflight rejects an installation the service control manager could not +// run: an unusable executable or an invalid bind address. +func (m *scmManager) preflight(opts InstallOptions) error { + if !filepath.IsAbs(opts.Executable) { + return fmt.Errorf("service: executable %q must be an absolute path", opts.Executable) + } + info, err := os.Stat(opts.Executable) + if err != nil { + return fmt.Errorf("service: executable %q is not usable: %w", opts.Executable, err) + } + if !info.Mode().IsRegular() { + return fmt.Errorf("service: executable %q is not a regular file", opts.Executable) + } + if _, _, err := ParseListen(opts.Listen); err != nil { + return err + } + return nil +} + +// requireElevated rejects a machine-wide operation that needs an elevated +// shell when the process does not have one. +// +// The manager never escalates privileges itself: it never starts a runas +// call, never starts PowerShell and never starts a shell. +func requireElevated(action string) error { + if isElevatedProcess() { + return nil + } + return fmt.Errorf("service: %s needs an elevated shell: run the command as an administrator, HFDesk never escalates privileges itself", action) +} + +// isElevatedProcess reports whether the process holds an elevated token. +func isElevatedProcess() bool { + return windows.GetCurrentProcessToken().IsElevated() +} + +// buildSCMConfig builds the configuration of the registration of the +// instance. +// +// The configuration carries no credential at all: the service runs as +// LocalService, an account without a password, and the instance reads its +// token from its own config file. +func buildSCMConfig(opts InstallOptions) mgr.Config { + return mgr.Config{ + ServiceType: windows.SERVICE_WIN32_OWN_PROCESS, + StartType: mgr.StartAutomatic, + ErrorControl: mgr.ErrorNormal, + DisplayName: scmDisplayName, + Description: scmDescription, + ServiceStartName: scmLocalServiceAccount, + BinaryPathName: scmCommandLine(opts), + } +} + +// scmCommandLine renders the command line the registration carries: the +// escaped executable followed by the escaped canonical arguments of the +// instance. +// +// The vector holds paths and a bind address only. +func scmCommandLine(opts InstallOptions) string { + parts := make([]string, 0, len(RunArgs(opts))+1) + parts = append(parts, syscall.EscapeArg(opts.Executable)) + for _, arg := range RunArgs(opts) { + parts = append(parts, syscall.EscapeArg(arg)) + } + return strings.Join(parts, " ") +} + +// scmRegistration describes the registration of the instance. +type scmRegistration struct { + // executable is the program the registration points at. + executable string + // args are the arguments of the program. + args []string +} + +// listen returns the bind address of the registration. +func (r scmRegistration) listen() string { + return r.flagValue("--listen") +} + +// paths returns the runtime path model of the registration. +func (r scmRegistration) paths() runtimepaths.Paths { + return runtimepaths.Paths{ + ConfigFile: r.flagValue("--config"), + StateDir: r.flagValue("--state-dir"), + LogDir: r.flagValue("--log-dir"), + } +} + +// flagValue returns the value of a flag of the argument vector. +func (r scmRegistration) flagValue(flag string) string { + for i, arg := range r.args { + if arg == flag && i+1 < len(r.args) { + return r.args[i+1] + } + } + return "" +} + +// parseSCMCommandLine reads the executable and the arguments out of the +// command line of a registration. +// +// The command line is decomposed with the rules of the operating system, so +// a path with a space in it is read back as one argument. +func parseSCMCommandLine(commandLine string) (scmRegistration, error) { + registration := scmRegistration{} + parts, err := windows.DecomposeCommandLine(commandLine) + if err != nil { + return registration, fmt.Errorf("service: read the command line of the registration: %w", err) + } + if len(parts) == 0 { + return registration, errors.New("service: the registration carries no command line") + } + registration.executable = parts[0] + registration.args = parts[1:] + return registration, nil +} + +// scmState maps the state of the service control manager onto the +// lifecycle states of the shared contract. +func scmState(state svc.State) State { + switch state { + case svc.Running: + return StateRunning + case svc.Stopped: + return StateStopped + case svc.StartPending: + return StateStarting + case svc.StopPending: + return StateStopping + default: + // The remaining states of the supervisor (paused, pause + // pending and continue pending) have no counterpart in the + // shared contract: the instance does not support them. + return StateUnknown + } +} + +// queryServiceCommandLine reads the command line of a registration. +// +// The service control manager is asked directly, so the status describes +// the registration and not the intent of the caller. +func queryServiceCommandLine(handle windows.Handle) (string, error) { + size := uint32(1024) + for { + buffer := make([]byte, size) + config := (*windows.QUERY_SERVICE_CONFIG)(unsafe.Pointer(&buffer[0])) + var needed uint32 + err := windows.QueryServiceConfig(handle, config, uint32(len(buffer)), &needed) + if err == nil { + return windows.UTF16PtrToString(config.BinaryPathName), nil + } + if !errors.Is(err, windows.ERROR_INSUFFICIENT_BUFFER) { + return "", err + } + if needed <= uint32(len(buffer)) { + return "", err + } + size = needed + } +} + +// registerEventSource registers the event log source of the instance. +// +// A source that is registered already is not an error. A source that cannot +// be registered is not a failed installation either: the administrator of +// the machine can register it separately, and the instance falls back to +// the Application log. +func registerEventSource() error { + err := eventlog.InstallAsEventCreate(scmEventSource, eventlog.Info|eventlog.Warning|eventlog.Error) + if err == nil { + return nil + } + if strings.Contains(err.Error(), "already exists") { + return nil + } + return err +} + +// restrictInstanceState restricts the access to the state of the instance. +// +// The state of a machine-wide instance belongs to the machine: SYSTEM, the +// administrators of the machine and the account the instance runs as may +// work with it, nobody else. +// +// Only the state directory and the config file are touched. The model and +// the cache trees of an installation are never re-owned and never re-ACLed: +// they can hold gigabytes of data that the user may reuse from an +// interactive run, and a recursive change of the access rights would take +// the data away from the user who owns it. +func restrictInstanceState(opts InstallOptions) error { + if err := applyRestrictiveACL(opts.Paths.StateDir); err != nil { + return err + } + // The config file only exists when it was written already. A file + // that is created later inherits the descriptor of the directory. + if info, err := os.Stat(opts.Paths.ConfigFile); err == nil && info.Mode().IsRegular() { + if err := applyRestrictiveACL(opts.Paths.ConfigFile); err != nil { + return err + } + } + return nil +} + +// applyRestrictiveACL applies the security descriptor of the instance to a +// path. +// +// The descriptor blocks the inheritance of the parent directory: the access +// of the directory of ProgramData must not leak into the state of the +// instance. +func applyRestrictiveACL(path string) error { + sd, err := windows.SecurityDescriptorFromString(scmStateSDDL()) + if err != nil { + return fmt.Errorf("service: build the security descriptor of %q: %w", path, err) + } + dacl, _, err := sd.DACL() + if err != nil { + return fmt.Errorf("service: read the access control list of the security descriptor: %w", err) + } + if err := windows.SetNamedSecurityInfo( + path, + windows.SE_FILE_OBJECT, + windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION, + nil, + nil, + dacl, + nil, + ); err != nil { + return fmt.Errorf("service: restrict the access to %q: %w", path, err) + } + return nil +} + +// scmStateSDDL returns the security descriptor of the state of a +// machine-wide instance. +// +// The descriptor grants the full control to SYSTEM (SY), to the +// administrators of the machine (BA) and to the account the instance runs +// as, LocalService (LS, the SID S-1-5-19). Nobody else is granted +// anything: the "Authenticated Users" group (AU) would hand the state of +// the machine to every logged-in user of the machine. +// +// The descriptor is protected ("D:P"), so the access of the parent +// directory is not inherited. +func scmStateSDDL() string { + return "D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;FA;;;LS)" +} + +// rollbackRegistration removes a registration that a failed installation +// left behind. +// +// The rollback is best effort: the original failure of the installation is +// the error the caller gets. +func rollbackRegistration(handle *mgr.Service) { + if err := handle.Delete(); err != nil && !errors.Is(err, windows.ERROR_SERVICE_MARKED_FOR_DELETE) { + log.Printf("service: the registration of %s could not be removed: %v", ServiceName, err) + } +} diff --git a/internal/service/manager_windows_test.go b/internal/service/manager_windows_test.go new file mode 100644 index 00000000..a28a1f19 --- /dev/null +++ b/internal/service/manager_windows_test.go @@ -0,0 +1,331 @@ +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 + +//go:build windows + +package service + +import ( + "context" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/svc" + "golang.org/x/sys/windows/svc/mgr" + + "github.com/bashrusakh/hfdesk/internal/runtimepaths" +) + +// secretSentinel is an obviously secret value. It is put into every field +// of the installation options that could carry a credential, so the tests +// can prove that no credential reaches the registration of the service. +const secretSentinel = "hfdesk-secret-sentinel" + +// scmTestOptions returns the options of a registration whose executable +// does not exist. The pure helpers of the manager never touch the file +// system, so the options do not need one. +func scmTestOptions() InstallOptions { + root := filepath.Join("C:\\ProgramData", "HFDesk") + return InstallOptions{ + Scope: ScopeSystem, + Executable: filepath.Join(root, "hfdesk.exe"), + Listen: "127.0.0.1:8080", + Paths: runtimepaths.Paths{ + ConfigFile: filepath.Join(root, "hfdesk.json"), + StateDir: root, + LogDir: filepath.Join(root, "logs"), + }, + CacheDir: filepath.Join(root, "cache"), + HubDir: filepath.Join(root, "cache", "hub"), + LocalDir: filepath.Join(root, "models"), + } +} + +func TestBuildSCMConfigUsesTheLeastPrivilegedAccount(t *testing.T) { + opts := scmTestOptions() + config := buildSCMConfig(opts) + + if config.ServiceStartName != scmLocalServiceAccount { + t.Errorf("ServiceStartName = %q, want %q", config.ServiceStartName, scmLocalServiceAccount) + } + if config.ServiceStartName != `NT AUTHORITY\LocalService` { + t.Errorf("ServiceStartName = %q, want the LocalService account", config.ServiceStartName) + } + if strings.Contains(strings.ToLower(config.ServiceStartName), "localsystem") { + t.Errorf("ServiceStartName = %q, want the LocalService account and not LocalSystem", config.ServiceStartName) + } + if config.Password != "" { + t.Errorf("Password = %q, want no password at all", config.Password) + } + if config.StartType != mgr.StartAutomatic { + t.Errorf("StartType = %d, want %d", config.StartType, mgr.StartAutomatic) + } + if config.ErrorControl != mgr.ErrorNormal { + t.Errorf("ErrorControl = %d, want %d", config.ErrorControl, mgr.ErrorNormal) + } + if config.ServiceType != windows.SERVICE_WIN32_OWN_PROCESS { + t.Errorf("ServiceType = %d, want %d", config.ServiceType, windows.SERVICE_WIN32_OWN_PROCESS) + } + if config.DisplayName == "" || config.Description == "" { + t.Errorf("DisplayName = %q and Description = %q, want both of them to be set", config.DisplayName, config.Description) + } +} + +func TestSCMCommandLineCarriesNoCredential(t *testing.T) { + opts := scmTestOptions() + // Every field of the options that could carry a credential carries + // the sentinel. + opts.User = secretSentinel + opts.UID = secretSentinel + + commandLine := scmCommandLine(opts) + if strings.Contains(commandLine, secretSentinel) { + t.Errorf("the command line %q carries a credential", commandLine) + } + + // The command line holds the executable and the canonical argument + // vector of the shared contract, nothing else. + parts, err := windows.DecomposeCommandLine(commandLine) + if err != nil { + t.Fatalf("DecomposeCommandLine(%q) = %v, want nil", commandLine, err) + } + want := append([]string{opts.Executable}, RunArgs(opts)...) + if !reflect.DeepEqual(parts, want) { + t.Errorf("the command line = %q, want %q", parts, want) + } + if !filepath.IsAbs(parts[0]) { + t.Errorf("the registered executable %q is not an absolute path", parts[0]) + } + if buildSCMConfig(opts).BinaryPathName != commandLine { + t.Errorf("BinaryPathName = %q, want the command line %q", buildSCMConfig(opts).BinaryPathName, commandLine) + } +} + +func TestParseSCMCommandLineReadsTheRegisteredExecutableAndAddress(t *testing.T) { + opts := scmTestOptions() + // A path with a space in it exercises the escaping of the command + // line. + opts.Executable = filepath.Join("C:\\Program Files", "HFDesk", "hfdesk.exe") + + registration, err := parseSCMCommandLine(scmCommandLine(opts)) + if err != nil { + t.Fatalf("parseSCMCommandLine() = %v, want nil", err) + } + if registration.executable != opts.Executable { + t.Errorf("the registered executable = %q, want %q", registration.executable, opts.Executable) + } + if got, want := registration.listen(), "127.0.0.1:8080"; got != want { + t.Errorf("the registered listen address = %q, want %q", got, want) + } + if got := registration.paths(); got != opts.Paths { + t.Errorf("the registered paths = %+v, want %+v", got, opts.Paths) + } +} + +func TestSCMStateMapsEverySupervisorState(t *testing.T) { + cases := []struct { + name string + state svc.State + want State + }{ + {"running", svc.Running, StateRunning}, + {"stopped", svc.Stopped, StateStopped}, + {"start pending", svc.StartPending, StateStarting}, + {"stop pending", svc.StopPending, StateStopping}, + {"paused", svc.Paused, StateUnknown}, + {"pause pending", svc.PausePending, StateUnknown}, + {"continue pending", svc.ContinuePending, StateUnknown}, + {"unknown", svc.State(4711), StateUnknown}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := scmState(tc.state); got != tc.want { + t.Errorf("scmState(%v) = %q, want %q", tc.state, got, tc.want) + } + }) + } +} + +func TestSCMStateSDDLGrantsOnlyTheMachineAccounts(t *testing.T) { + sddl := scmStateSDDL() + + // SYSTEM, the administrators of the machine and the account the + // instance runs as. + for _, want := range []string{";;;SY)", ";;;BA)", ";;;LS)"} { + if !strings.Contains(sddl, want) { + t.Errorf("the descriptor %q does not grant %q", sddl, want) + } + } + // Nobody else. + for _, forbidden := range []string{"WD", "Everyone", "Users", "AU;", ";;;AU)", "IU)", "BU)", "Authenticated"} { + if strings.Contains(sddl, forbidden) { + t.Errorf("the descriptor %q grants access to %q", sddl, forbidden) + } + } + // The inheritance of the parent directory is blocked. + if !strings.HasPrefix(sddl, "D:P") { + t.Errorf("the descriptor %q does not block the inheritance", sddl) + } +} + +func TestNewPlatformManagerRejectsTheUserScope(t *testing.T) { + manager, err := newPlatformManager(ScopeUser) + if err == nil { + t.Fatalf("newPlatformManager(ScopeUser) = %v, want an error", manager) + } + if !strings.Contains(err.Error(), "machine-wide") { + t.Errorf("newPlatformManager(ScopeUser) = %v, want an error that names the reason", err) + } + if manager != nil { + t.Errorf("newPlatformManager(ScopeUser) = %v, want no manager", manager) + } + + manager, err = newPlatformManager(ScopeSystem) + if err != nil { + t.Fatalf("newPlatformManager(ScopeSystem) = %v, want nil", err) + } + if _, ok := manager.(*scmManager); !ok { + t.Errorf("newPlatformManager(ScopeSystem) = %T, want a *scmManager", manager) + } +} + +func TestRequireElevatedNamesTheMissingPrivilege(t *testing.T) { + err := requireElevated("an installation") + if isElevatedProcess() { + if err != nil { + t.Errorf("requireElevated() = %v, want nil for an elevated process", err) + } + return + } + if err == nil { + t.Fatal("requireElevated() = nil, want an error for a process that is not elevated") + } + if !strings.Contains(err.Error(), "elevated") { + t.Errorf("requireElevated() = %v, want an error that names the elevated shell", err) + } +} + +// TestSCMLifecycle drives the registration of a service against the real +// service control manager. +// +// The test needs an elevated shell: the service control manager only accepts +// a machine-wide registration from an administrator. It is skipped without +// one. +func TestSCMLifecycle(t *testing.T) { + if !isElevatedProcess() { + t.Skip("the service control manager needs an elevated shell, run the test as an administrator") + } + + // A registration that exists already belongs to the machine and not + // to this test: it is never removed here. + manager, err := mgr.Connect() + if err != nil { + t.Skipf("the service control manager is not available: %v", err) + } + existing, err := manager.OpenService(ServiceName) + if err == nil { + existing.Close() + manager.Disconnect() + t.Skipf("%s is registered already, the test would remove a real installation", ServiceName) + } + manager.Disconnect() + + root := t.TempDir() + executable := filepath.Join("C:\\Windows", "System32", "cmd.exe") + if _, err := os.Stat(executable); err != nil { + t.Skipf("the test program %s is not available: %v", executable, err) + } + opts := InstallOptions{ + Scope: ScopeSystem, + Executable: executable, + Listen: "127.0.0.1:8080", + Paths: runtimepaths.Paths{ + ConfigFile: filepath.Join(root, "etc", "hfdesk.json"), + StateDir: filepath.Join(root, "var"), + LogDir: filepath.Join(root, "var", "logs"), + }, + } + service := &scmManager{scope: ScopeSystem} + + // The registration never survives the test. + t.Cleanup(func() { + cleanupManager, err := mgr.Connect() + if err != nil { + return + } + defer cleanupManager.Disconnect() + handle, err := cleanupManager.OpenService(ServiceName) + if err != nil { + return + } + defer handle.Close() + _ = handle.Delete() + }) + + ctx := context.Background() + if err := service.Install(ctx, opts); err != nil { + t.Fatalf("Install() = %v, want nil", err) + } + + status, err := service.Status(ctx) + if err != nil { + t.Fatalf("Status() = %v, want nil", err) + } + if status.State != StateStopped { + t.Errorf("the state = %q, want %q", status.State, StateStopped) + } + if status.Name != ServiceName { + t.Errorf("the name = %q, want %q", status.Name, ServiceName) + } + if status.Executable != opts.Executable { + t.Errorf("the registered executable = %q, want %q", status.Executable, opts.Executable) + } + if status.Listen != opts.Listen { + t.Errorf("the registered listen address = %q, want %q", status.Listen, opts.Listen) + } + if status.Paths != opts.Paths { + t.Errorf("the registered paths = %+v, want %+v", status.Paths, opts.Paths) + } + + // A second installation updates the registration instead of creating + // a second one. + if err := service.Install(ctx, opts); err != nil { + t.Fatalf("the second Install() = %v, want nil", err) + } + status, err = service.Status(ctx) + if err != nil { + t.Fatalf("Status() = %v, want nil", err) + } + if status.Executable != opts.Executable { + t.Errorf("the registered executable = %q, want %q", status.Executable, opts.Executable) + } + + // Stopping an instance that does not run is a stable success. + if err := service.Stop(ctx); err != nil { + t.Errorf("Stop() = %v, want nil for an instance that does not run", err) + } + + // The removal leaves the state of the instance alone. + if err := service.Uninstall(ctx); err != nil { + t.Fatalf("Uninstall() = %v, want nil", err) + } + status, err = service.Status(ctx) + if err != nil { + t.Fatalf("Status() = %v, want nil", err) + } + if status.State != StateNotInstalled { + t.Errorf("the state = %q, want %q", status.State, StateNotInstalled) + } + if _, err := os.Stat(opts.Paths.StateDir); err != nil { + t.Errorf("the state directory of the instance is gone: %v", err) + } + + // The removal of an absent registration is a clean success. + if err := service.Uninstall(ctx); err != nil { + t.Errorf("the second Uninstall() = %v, want nil", err) + } +} diff --git a/internal/service/scm_windows.go b/internal/service/scm_windows.go new file mode 100644 index 00000000..d194b2cf --- /dev/null +++ b/internal/service/scm_windows.go @@ -0,0 +1,442 @@ +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 + +//go:build windows + +package service + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "net/http" + "os" + "time" + + "golang.org/x/sys/windows/svc" + "golang.org/x/sys/windows/svc/eventlog" +) + +// The Windows service mode of HFDesk is a registration in the service +// control manager. +// +// The service control manager owns the lifecycle of the instance: it starts +// the registered program, it asks it for the states and it requests the +// stop. HFDesk stays a normal foreground process: it is never forked, never +// daemonized itself, never writes a PID file and never runs through a +// shell. +// +// The instance runs as the built-in account LocalService, the least +// privileged of the service accounts of the operating system. It has no +// password, it is not a member of the administrators and it holds only the +// rights a service needs. The account has a profile of its own, so the +// instance never sees the desktop, the profile or the files of the user +// who installed it. +// +// The diagnostics of the instance live in the Windows Event Log: the +// lifecycle messages and a fatal startup error are written to the own +// source of the service, so a machine without a terminal can be inspected +// with the usual tools of the platform. +const ( + // scmEventSource is the event log source the lifecycle messages of a + // HFDesk service are written to. + scmEventSource = "HFDesk" + + // scmFallbackEventSource is the source that is used when the own + // source of the service is not registered. The Application log + // accepts the message of every source, so a diagnostic is never + // lost completely. + scmFallbackEventSource = "Application" + + // scmEventIDInfo, scmEventIDWarning and scmEventIDError are the event + // ids of the three kinds of messages. The ids stay inside the range + // of EventCreate.exe (1 to 1000), which is the message file the + // event source is registered with. + scmEventIDInfo = 1 + scmEventIDWarning = 2 + scmEventIDError = 3 +) + +const ( + // scmReadinessTimeout bounds the wait for the HTTP server of the + // instance to become usable. + scmReadinessTimeout = 30 * time.Second + + // scmReadinessInterval is the pause between two readiness probes. + scmReadinessInterval = 100 * time.Millisecond + + // scmShutdownTimeout bounds the wait for the graceful shutdown of the + // instance. + scmShutdownTimeout = 30 * time.Second + + // scmProbeTimeout bounds a single readiness probe. + scmProbeTimeout = 2 * time.Second +) + +// runAsWindowsService runs HFDesk as the process of a registered service. +// +// The function connects the process to the service dispatcher of the +// service control manager and blocks until the instance stops. The +// dispatcher owns the lifecycle: it receives the states of the instance +// and it requests the stop. +func runAsWindowsService(opts RunOptions) error { + handler := newSCMHandler(opts) + if err := svc.Run(ServiceName, handler); err != nil { + logEvent(eventLogError, fmt.Sprintf("hfdesk: the service dispatcher failed: %v", err)) + return fmt.Errorf("service: run as a windows service: %w", err) + } + return nil +} + +// scmHandler is the service handler of a HFDesk instance. +// +// The handler owns the lifecycle of the process the service control manager +// started: it reports the states of the instance to the manager, it starts +// the headless host, it waits until the HTTP server really answers and it +// drives the graceful shutdown of the server. +// +// The handler never opens a browser and never creates a tray icon: the +// instance is supervised and there is no desktop session to interact with. +type scmHandler struct { + // opts is the run options of the instance. They are the fallback of + // the argument resolution: a process that carries no parsable + // argument vector of its own still runs with them. + opts RunOptions + + // readinessTimeout bounds the wait for the HTTP server. + readinessTimeout time.Duration + + // readinessInterval is the pause between two readiness probes. + readinessInterval time.Duration + + // shutdownTimeout bounds the wait for the graceful shutdown. + shutdownTimeout time.Duration +} + +// newSCMHandler returns the handler of an instance with the default +// timeouts. +func newSCMHandler(opts RunOptions) *scmHandler { + return &scmHandler{ + opts: opts, + readinessTimeout: scmReadinessTimeout, + readinessInterval: scmReadinessInterval, + shutdownTimeout: scmShutdownTimeout, + } +} + +// Execute runs the lifecycle of the service. +// +// The method is called by the dispatcher of the service control manager. It +// reports the states of the instance on the changes channel, it reacts to +// the control requests of the manager on the request channel and it returns +// the exit code of the instance. +func (h *scmHandler) Execute(args []string, r <-chan svc.ChangeRequest, changes chan<- svc.Status) (bool, uint32) { + // The instance does not run yet: the manager waits for the report of + // the running state before it hands the instance over to the machine. + changes <- svc.Status{State: svc.StartPending} + + opts, err := h.resolveOptions(args) + if err != nil { + logEvent(eventLogError, fmt.Sprintf("hfdesk: the service could not be started: %v", err)) + changes <- svc.Status{State: svc.Stopped} + return false, 1 + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + hostErr := make(chan error, 1) + go func() { + hostErr <- Run(ctx, opts) + }() + + // The instance is only reported as running when the HTTP server + // really answers: a process that is alive but does not serve is not a + // running service. + stopRequested, err := h.waitForReady(ctx, opts.Listen, hostErr, r, changes) + if err != nil { + logEvent(eventLogError, fmt.Sprintf("hfdesk: the service could not be started: %v", err)) + cancel() + changes <- svc.Status{State: svc.Stopped} + return false, 1 + } + if stopRequested { + // The manager asked the instance to stop while it was starting. + if err := h.stopInstance(cancel, hostErr, changes); err != nil { + return false, 1 + } + return false, 0 + } + + logEvent(eventLogInfo, fmt.Sprintf("hfdesk: the service is running on %s", opts.Listen)) + changes <- svc.Status{State: svc.Running, Accepts: svc.AcceptStop | svc.AcceptShutdown} + + for { + select { + case request, ok := <-r: + if !ok { + // The dispatcher closed the channel: the instance is + // asked to stop. + if err := h.stopInstance(cancel, hostErr, changes); err != nil { + return false, 1 + } + return false, 0 + } + switch request.Cmd { + case svc.Interrogate: + // The manager asks for the current status. The state + // is reported again and the instance keeps running. + changes <- svc.Status{State: svc.Running, Accepts: svc.AcceptStop | svc.AcceptShutdown} + case svc.Stop, svc.Shutdown: + if err := h.stopInstance(cancel, hostErr, changes); err != nil { + return false, 1 + } + return false, 0 + } + case err := <-hostErr: + // The host ended on its own: the instance does not run any + // more. + if err != nil { + logEvent(eventLogError, fmt.Sprintf("hfdesk: the service failed: %v", err)) + changes <- svc.Status{State: svc.Stopped} + return false, 1 + } + logEvent(eventLogInfo, "hfdesk: the service stopped on its own") + changes <- svc.Status{State: svc.Stopped} + return false, 0 + } + } +} + +// stopInstance drives the graceful shutdown of the instance. +// +// The application context is cancelled first: the server quiesces, drains +// its connections and closes the job manager. Only when that is done the +// instance is reported as stopped, so the service control manager never +// kills a process that is still busy. +func (h *scmHandler) stopInstance(cancel context.CancelFunc, hostErr <-chan error, changes chan<- svc.Status) error { + changes <- svc.Status{State: svc.StopPending} + logEvent(eventLogInfo, "hfdesk: the service is stopping") + err := h.waitForShutdown(cancel, hostErr) + if err != nil { + logEvent(eventLogError, fmt.Sprintf("hfdesk: the service did not stop cleanly: %v", err)) + } + logEvent(eventLogInfo, "hfdesk: the service is stopped") + changes <- svc.Status{State: svc.Stopped} + return err +} + +// waitForShutdown cancels the application context and waits for the +// graceful shutdown of the instance. +// +// The wait is bounded: a server that does not finish its drain must not +// keep the instance in the stopping state forever. +func (h *scmHandler) waitForShutdown(cancel context.CancelFunc, hostErr <-chan error) error { + timeout := h.shutdownTimeout + if timeout <= 0 { + timeout = scmShutdownTimeout + } + cancel() + select { + case err := <-hostErr: + return err + case <-time.After(timeout): + return fmt.Errorf("the instance did not stop within %s", timeout) + } +} + +// resolveOptions determines the run options of the instance. +// +// The service control manager hands the handler the name of the service and +// the start parameters of the start request, not the argument vector of the +// registration: the registered command line is the command line of the +// process itself. Every vector that can carry the options is accepted, so +// the instance runs with the options of its registration no matter how the +// process was started. +func (h *scmHandler) resolveOptions(args []string) (RunOptions, error) { + var firstErr error + for _, candidate := range scmArgumentCandidates(args, os.Args) { + opts, err := ParseRunArgs(candidate) + if err == nil { + return opts, nil + } + if firstErr == nil { + firstErr = err + } + } + // The caller handed the options over already: they are the fallback + // for a process that carries no parsable vector of its own. + if h.opts.Paths.ConfigFile != "" { + return h.opts, nil + } + if firstErr != nil { + return RunOptions{}, firstErr + } + return RunOptions{}, errors.New("the service carries no usable argument vector") +} + +// scmArgumentCandidates returns the argument vectors the options of the +// instance can be parsed from, in the order they are tried. +// +// The first candidate holds the start parameters of the start request: the +// service control manager hands them over next to the name of the service. +// The second candidate holds the whole vector of the handler. The third one +// holds the command line of the process, which is the registered command +// line of the instance. +// +// The candidates are tried in that order, so an explicit instruction of the +// caller always wins over the registration. +func scmArgumentCandidates(args []string, processArgs []string) [][]string { + var candidates [][]string + if len(args) > 1 { + candidates = append(candidates, args[1:]) + } + if len(args) > 0 { + candidates = append(candidates, args) + } + if len(processArgs) > 1 { + candidates = append(candidates, processArgs[1:]) + } + return candidates +} + +// waitForReady waits until the HTTP server of the instance answers. +// +// The wait is bounded and it watches three things: +// +// - the host: an instance that fails while it starts is a failed start +// and not a start that timed out; +// - the control requests: a stop request that arrives while the instance +// starts must not wait for the startup to finish; +// - the context of the instance. +// +// The first result reports whether the manager asked the instance to stop +// while it was starting. +func (h *scmHandler) waitForReady(ctx context.Context, listen string, hostErr <-chan error, r <-chan svc.ChangeRequest, changes chan<- svc.Status) (bool, error) { + timeout := h.readinessTimeout + if timeout <= 0 { + timeout = scmReadinessTimeout + } + interval := h.readinessInterval + if interval <= 0 { + interval = scmReadinessInterval + } + deadline := time.Now().Add(timeout) + for { + if err := probeServiceReady(listen); err == nil { + return false, nil + } + select { + case err := <-hostErr: + if err == nil { + return false, errors.New("the instance ended before the HTTP server answered") + } + return false, err + case request, ok := <-r: + if !ok { + // The dispatcher closed the channel: the instance is + // asked to stop. + return true, nil + } + switch request.Cmd { + case svc.Stop, svc.Shutdown: + return true, nil + case svc.Interrogate: + // The manager asks for the current status. The + // instance is still starting, so the state is + // reported again. + changes <- svc.Status{State: svc.StartPending} + } + case <-ctx.Done(): + return false, ctx.Err() + case <-time.After(interval): + } + if time.Now().After(deadline) { + return false, fmt.Errorf("the HTTP server did not answer on %s within %s", listen, timeout) + } + } +} + +// probeServiceReady reports whether the HTTP server of the instance +// answers. +// +// Any HTTP status is accepted: the point of the probe is that an HTTP +// server listens on the address and answers, not that it answers with a +// particular code. +func probeServiceReady(listen string) error { + addr := listen + if addr == "" { + addr = DefaultListen + } + conn, err := net.DialTimeout("tcp", addr, scmProbeTimeout) + if err != nil { + return err + } + _ = conn.Close() + + client := &http.Client{Timeout: scmProbeTimeout} + resp, err := client.Get("http://" + addr + "/api/health") + if err != nil { + return err + } + defer resp.Body.Close() + _, _ = io.Copy(io.Discard, resp.Body) + return nil +} + +// eventLogLevel is the kind of a message of the Windows Event Log. +type eventLogLevel int + +const ( + // eventLogInfo is an informational message. + eventLogInfo eventLogLevel = iota + // eventLogWarning is a warning. + eventLogWarning + // eventLogError is an error. + eventLogError +) + +// logEvent writes one message to the Windows Event Log. +// +// The log is opened, written and closed again for every message: a service +// process lives for hours and an open handle would keep the source +// registered for the whole lifetime of the process. +// +// A log that cannot be opened is not a lost message: the own source of the +// service is tried first, the Application log is the second choice and the +// standard error of the process is the last one. +// +// The message never carries a credential: the arguments of the instance +// carry paths and a bind address only. +func logEvent(level eventLogLevel, message string) { + if err := writeEventLog(scmEventSource, level, message); err == nil { + return + } + if err := writeEventLog(scmFallbackEventSource, level, message); err == nil { + return + } + fmt.Fprintln(os.Stderr, message) +} + +// writeEventLog writes one message to the event log of a source. +// +// The log is opened and closed again for the message. An error is returned +// when the source cannot be opened or when the message is refused. +func writeEventLog(source string, level eventLogLevel, message string) error { + log, err := eventlog.Open(source) + if err != nil { + return err + } + defer log.Close() + + switch level { + case eventLogWarning: + return log.Warning(scmEventIDWarning, message) + case eventLogError: + return log.Error(scmEventIDError, message) + default: + return log.Info(scmEventIDInfo, message) + } +} diff --git a/internal/service/scm_windows_test.go b/internal/service/scm_windows_test.go new file mode 100644 index 00000000..3f0f94f0 --- /dev/null +++ b/internal/service/scm_windows_test.go @@ -0,0 +1,203 @@ +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 + +//go:build windows + +package service + +import ( + "path/filepath" + "reflect" + "strconv" + "testing" + "time" + + "golang.org/x/sys/windows/svc" + + "github.com/bashrusakh/hfdesk/internal/runtimepaths" +) + +// TestSCMHandlerReportsTheLifecycle drives the handler of the service +// without the service control manager. +// +// The test binds a free loopback port, so the instance never touches a +// real registration and never touches the machine it runs on. +func TestSCMHandlerReportsTheLifecycle(t *testing.T) { + root := t.TempDir() + model := runtimepaths.Paths{ + ConfigFile: filepath.Join(root, "etc", "hfdesk.json"), + StateDir: filepath.Join(root, "var"), + LogDir: filepath.Join(root, "var", "logs"), + } + t.Cleanup(runtimepaths.Reset) + + addr := "127.0.0.1:" + strconv.Itoa(freePort(t)) + args := []string{ + ServiceName, + "service", "run", + "--config", model.ConfigFile, + "--state-dir", model.StateDir, + "--log-dir", model.LogDir, + "--listen", addr, + } + + handler := newSCMHandler(RunOptions{}) + handler.readinessTimeout = 30 * time.Second + + changes := make(chan svc.Status, 16) + requests := make(chan svc.ChangeRequest) + + type result struct { + specific bool + code uint32 + } + done := make(chan result, 1) + go func() { + specific, code := handler.Execute(args, requests, changes) + done <- result{specific: specific, code: code} + close(changes) + }() + + var states []svc.State + for { + status, ok := <-changes + if !ok { + t.Fatalf("the handler ended before it reported the running state: %v", states) + } + states = append(states, status.State) + if status.State != svc.Running { + continue + } + if status.Accepts&svc.AcceptStop == 0 { + t.Errorf("the running status accepts %v, want the stop control", status.Accepts) + } + if status.Accepts&svc.AcceptShutdown == 0 { + t.Errorf("the running status accepts %v, want the shutdown control", status.Accepts) + } + break + } + + // The manager asks the instance to stop. + requests <- svc.ChangeRequest{Cmd: svc.Stop} + + for status := range changes { + states = append(states, status.State) + } + + res := <-done + if res.code != 0 { + t.Errorf("the exit code = %d, want 0 for a clean stop", res.code) + } + + want := []svc.State{svc.StartPending, svc.Running, svc.StopPending, svc.Stopped} + if !reflect.DeepEqual(states, want) { + t.Errorf("the reported states = %v, want %v", states, want) + } +} + +// TestSCMHandlerFailsWithoutAUsableArgumentVector drives the handler with a +// process that carries no argument vector at all. +// +// The instance must report the failure and end with a non-zero exit code, +// so the service control manager marks the service as failed instead of +// leaving a service that does nothing. +func TestSCMHandlerFailsWithoutAUsableArgumentVector(t *testing.T) { + handler := newSCMHandler(RunOptions{}) + + changes := make(chan svc.Status, 8) + requests := make(chan svc.ChangeRequest) + + type result struct { + specific bool + code uint32 + } + done := make(chan result, 1) + go func() { + specific, code := handler.Execute([]string{ServiceName}, requests, changes) + done <- result{specific: specific, code: code} + close(changes) + }() + + var states []svc.State + for status := range changes { + states = append(states, status.State) + } + res := <-done + + if res.code == 0 { + t.Errorf("the exit code = %d, want a non-zero code for a failed start", res.code) + } + want := []svc.State{svc.StartPending, svc.Stopped} + if !reflect.DeepEqual(states, want) { + t.Errorf("the reported states = %v, want %v", states, want) + } +} + +func TestSCMArgumentCandidates(t *testing.T) { + args := []string{ServiceName, "service", "run", "--config", `C:\hfdesk\hfdesk.json`} + process := []string{`C:\hfdesk\hfdesk.exe`, "service", "run", "--config", `C:\hfdesk\hfdesk.json`} + + candidates := scmArgumentCandidates(args, process) + want := [][]string{ + {"service", "run", "--config", `C:\hfdesk\hfdesk.json`}, + {"service", "run", "--config", `C:\hfdesk\hfdesk.json`}, + {"service", "run", "--config", `C:\hfdesk\hfdesk.json`}, + } + if !reflect.DeepEqual(candidates, want) { + t.Errorf("the candidates = %q, want %q", candidates, want) + } + + // A process without an argument vector of its own has no candidate + // besides the ones of the handler. + if got := scmArgumentCandidates([]string{ServiceName}, nil); len(got) != 2 { + t.Errorf("the candidates = %q, want the two vectors of the handler", got) + } +} + +func TestResolveOptionsFallsBackToTheHandedOverOptions(t *testing.T) { + model := runtimepaths.Paths{ + ConfigFile: filepath.Join("C:\\ProgramData", "HFDesk", "hfdesk.json"), + StateDir: filepath.Join("C:\\ProgramData", "HFDesk"), + LogDir: filepath.Join("C:\\ProgramData", "HFDesk", "logs"), + } + handler := newSCMHandler(RunOptions{Paths: model, Listen: "127.0.0.1:8080"}) + + opts, err := handler.resolveOptions([]string{ServiceName}) + if err != nil { + t.Fatalf("resolveOptions() = %v, want nil", err) + } + if opts.Paths != model { + t.Errorf("the paths = %+v, want %+v", opts.Paths, model) + } + if opts.Listen != "127.0.0.1:8080" { + t.Errorf("the listen address = %q, want %q", opts.Listen, "127.0.0.1:8080") + } +} + +func TestResolveOptionsReadsTheRegisteredArguments(t *testing.T) { + root := filepath.Join("C:\\ProgramData", "HFDesk") + model := runtimepaths.Paths{ + ConfigFile: filepath.Join(root, "hfdesk.json"), + StateDir: root, + LogDir: filepath.Join(root, "logs"), + } + handler := newSCMHandler(RunOptions{}) + + opts, err := handler.resolveOptions([]string{ + ServiceName, + "service", "run", + "--config", model.ConfigFile, + "--state-dir", model.StateDir, + "--log-dir", model.LogDir, + "--listen", "127.0.0.1:9090", + }) + if err != nil { + t.Fatalf("resolveOptions() = %v, want nil", err) + } + if opts.Paths != model { + t.Errorf("the paths = %+v, want %+v", opts.Paths, model) + } + if opts.Listen != "127.0.0.1:9090" { + t.Errorf("the listen address = %q, want %q", opts.Listen, "127.0.0.1:9090") + } +} From 3d5f6995b687cc3cabe973c8ebe8bcd0a79fba00 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 21 Oct 2026 20:39:12 +1100 Subject: [PATCH 04/58] cmd: add the hfdesk service command family - Add the install, uninstall, start, stop, restart, status and run subcommands with the standard library flag package, one flag set per subcommand and no new dependency. - Resolve the runtime paths, the cache directories and the listen address of the selected scope, record them in the registration and report them back, so the instance keeps one coherent state identity. - Bind loopback by default and make a non-loopback address visible in the install and status output. - Warn when the registered executable lives in a temporary or version-stamped path, because the next release would break the registration. - Keep the interactive command line untouched: only the bare "service" token selects the new family, and hfdesk [flags] behaves exactly as before. Co-Authored-By: opencode --- cmd/hfdesk/main.go | 8 + cmd/hfdesk/service.go | 843 +++++++++++++++++++++++++++++++++++++ cmd/hfdesk/service_test.go | 421 ++++++++++++++++++ 3 files changed, 1272 insertions(+) create mode 100644 cmd/hfdesk/service.go create mode 100644 cmd/hfdesk/service_test.go diff --git a/cmd/hfdesk/main.go b/cmd/hfdesk/main.go index c01338a0..cc17230a 100644 --- a/cmd/hfdesk/main.go +++ b/cmd/hfdesk/main.go @@ -20,6 +20,14 @@ import ( var Version = "dev" func main() { + // The service command family is a command of its own. It is dispatched + // before the interactive startup, so "hfdesk" without a "service" token + // keeps its exact behaviour: same flags, same defaults, same browser + // launch. + if isServiceCommand(os.Args[1:]) { + os.Exit(runServiceCommand(os.Args[2:], os.Stdout, os.Stderr)) + } + if err := run(); err != nil { os.Exit(1) } diff --git a/cmd/hfdesk/service.go b/cmd/hfdesk/service.go new file mode 100644 index 00000000..5c572654 --- /dev/null +++ b/cmd/hfdesk/service.go @@ -0,0 +1,843 @@ +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "io" + "net" + "os" + "os/signal" + "path/filepath" + "regexp" + "runtime" + "strconv" + "strings" + "syscall" + + "github.com/bashrusakh/hfdesk/internal/runtimepaths" + "github.com/bashrusakh/hfdesk/internal/service" +) + +// The service command family is a command of its own: "hfdesk" without a +// "service" token keeps the interactive behaviour of the CLI untouched. +// +// The family is built with the standard library flag package. Every +// subcommand owns its own flag set, so a flag of one subcommand is never +// accepted by another one, and no CLI framework is introduced. +// +// Everything platform specific stays in internal/service: the command +// family only describes what the user asked for and reports what the +// supervisor answered. The only platform check of the command line is the +// default scope, because the Windows service control manager registers +// machine-wide services only. + +const ( + // serviceUsageExitCode is the exit code of a command line that could + // not be understood: a missing subcommand, an unknown subcommand or an + // invalid flag. + serviceUsageExitCode = 2 + + // serviceFailureExitCode is the exit code of an operation that failed. + serviceFailureExitCode = 1 +) + +// serviceSubcommands lists the subcommands of the family in the order the +// usage block prints them. +var serviceSubcommands = []string{ + "install", + "uninstall", + "start", + "stop", + "restart", + "status", + "run", +} + +// errHelpRequested reports that the user asked for the help of a command. It +// is not a failure: the command exits with 0. +var errHelpRequested = errors.New("help requested") + +// versionLikeSegment matches a "digit.digit" segment of a file name. +// +// The heuristic is deliberately simple: a release artifact carries its +// version in its name ("hfdesk-1.2.3-linux-amd64", "hfdesk_0.9.1") and the +// next release replaces the file. A plain "hfdesk" does not match. +var versionLikeSegment = regexp.MustCompile(`\d+\.\d+`) + +// isServiceCommand reports whether the arguments select the service command +// family. +// +// The check is exact: only the bare token "service" selects the family, so +// every other invocation keeps the interactive behaviour of the CLI. +func isServiceCommand(args []string) bool { + return len(args) > 0 && args[0] == "service" +} + +// runServiceCommand runs the "hfdesk service ..." command family and +// returns the exit code of the process. +// +// The arguments are the ones after the "service" token. The output streams +// are arguments as well, so the whole family can be exercised without +// starting a process. +func runServiceCommand(args []string, stdout, stderr io.Writer) int { + if len(args) == 0 { + fmt.Fprintln(stderr, "hfdesk service: a subcommand is required") + writeServiceUsage(stderr) + return serviceUsageExitCode + } + + sub, rest := args[0], args[1:] + + switch sub { + case "install": + return serviceCommandResult(stderr, serviceInstall(rest, stdout)) + case "uninstall": + return serviceCommandResult(stderr, serviceUninstall(rest, stdout)) + case "start": + return serviceCommandResult(stderr, serviceStart(rest, stdout)) + case "stop": + return serviceCommandResult(stderr, serviceStop(rest, stdout)) + case "restart": + return serviceCommandResult(stderr, serviceRestart(rest, stdout)) + case "status": + return serviceStatus(rest, stdout, stderr) + case "run": + return serviceCommandResult(stderr, serviceRun(rest, stdout)) + case "help", "-h", "--help": + writeServiceUsage(stdout) + return 0 + default: + fmt.Fprintf(stderr, "hfdesk service: unknown subcommand %q\n", sub) + fmt.Fprintf(stderr, "Valid subcommands: %s\n", strings.Join(serviceSubcommands, ", ")) + writeServiceUsage(stderr) + return serviceUsageExitCode + } +} + +// serviceCommandResult prints the error of a failed command and returns the +// exit code of the process. +func serviceCommandResult(stderr io.Writer, err error) int { + switch { + case err == nil: + return 0 + case errors.Is(err, errHelpRequested): + return 0 + default: + fmt.Fprintf(stderr, "hfdesk service: %s\n", serviceErrorText(err)) + return serviceFailureExitCode + } +} + +// serviceErrorText renders an error of the service layer for the command +// line. The package prefixes its errors with "service: ", which the command +// line already carries in its own prefix, so it is removed once. +func serviceErrorText(err error) string { + return strings.TrimPrefix(err.Error(), "service: ") +} + +// writeServiceUsage prints the usage block of the service command family. +func writeServiceUsage(w io.Writer) { + fmt.Fprint(w, "Usage: hfdesk service [options]\n\n") + fmt.Fprint(w, "Subcommands:\n") + for _, line := range []struct{ name, summary string }{ + {"install", "register the service with the platform supervisor"}, + {"uninstall", "remove the registration (the user data is kept)"}, + {"start", "start the registered service"}, + {"stop", "stop the registered service"}, + {"restart", "stop and start the registered service"}, + {"status", "report the scope, the state and the paths of the instance"}, + {"run", "run the headless host (internal: the supervisor invokes it)"}, + } { + fmt.Fprintf(w, " %-10s %s\n", line.name, line.summary) + } + fmt.Fprintf(w, ` +Scope: + --user per-user installation (the default on Linux and macOS) + --system machine-wide installation (the default on Windows) + +Options: + --listen HOST:PORT + bind address of the instance (default %s) + +Exit codes: + 0 the operation succeeded + 1 the operation failed + 2 the command line could not be understood + +"hfdesk service status" exits with 0 when the service is running, stopped, +starting or stopping, and with 1 when it is not installed, broken, failed or +in an unknown state, so a script can branch on it. + +Run "hfdesk service --help" for the flags of a subcommand. +`, service.DefaultListen) +} + +// writeSubcommandUsage prints the usage of a subcommand: its summary and +// the flags it registers. +func writeSubcommandUsage(w io.Writer, fs *flag.FlagSet, summary string) { + fmt.Fprintf(w, "Usage: %s [options]\n\n%s\n\nOptions:\n", fs.Name(), summary) + fs.SetOutput(w) + fs.PrintDefaults() + fmt.Fprint(w, "\nExit codes: 0 success, 1 failed operation, 2 invalid command line.\n") +} + +// writeField writes one "Key: value" line of a summary block. The keys are +// padded to a common width, so the values of a block line up. +func writeField(w io.Writer, key, value string) { + if value == "" { + value = "unknown" + } + fmt.Fprintf(w, "%-14s %s\n", key+":", value) +} + +// isWindows reports whether the process runs on Windows. +func isWindows() bool { + switch runtime.GOOS { + case "windows": + return true + default: + return false + } +} + +// defaultScope returns the scope of an installation that does not ask for +// one. +// +// The user scope is the default on Linux and macOS. The Windows service +// control manager registers machine-wide services only, so the system scope +// is the default there. +func defaultScope() service.Scope { + if isWindows() { + return service.ScopeSystem + } + return service.ScopeUser +} + +// resolveScope turns the scope selector of the command line into the scope +// of the installation. +// +// The selectors are mutually exclusive: two scopes are two installations, +// so an ambiguous command line is rejected instead of picking one of them. +func resolveScope(user, system bool) (service.Scope, error) { + switch { + case user && system: + return "", errors.New("--user and --system are mutually exclusive, choose one scope") + case user: + if isWindows() { + return "", errors.New("--user is not available on windows: the service control manager registers machine-wide services only, use --system") + } + return service.ScopeUser, nil + case system: + return service.ScopeSystem, nil + default: + return defaultScope(), nil + } +} + +// scopeFlags holds the scope selector of a command. +type scopeFlags struct { + user bool + system bool +} + +// registerScopeFlags registers the scope selector on a flag set. +func registerScopeFlags(fs *flag.FlagSet, flags *scopeFlags) { + fs.BoolVar(&flags.user, "user", false, "per-user installation (the default on Linux and macOS)") + fs.BoolVar(&flags.system, "system", false, "machine-wide installation (the default on Windows)") +} + +// pathFlag is one path flag of the command line. +type pathFlag struct { + name string + value string +} + +// validateAbsolutePaths rejects a relative path. +// +// A supervised process must not resolve a path against the working +// directory of the supervisor, so a relative path is a mistake and not a +// default. +func validateAbsolutePaths(paths []pathFlag) error { + for _, p := range paths { + if p.value == "" { + continue + } + if !filepath.IsAbs(p.value) { + return fmt.Errorf("%s %q must be an absolute path", p.name, p.value) + } + } + return nil +} + +// validateListen validates a bind address of the command line and returns +// its canonical form. +// +// The rules are the rules of the service layer: the address must name a +// host and a port, and an empty host is rejected because it binds every +// interface. +func validateListen(listen string) (string, error) { + host, port, err := service.ParseListen(listen) + if err != nil { + return "", err + } + return net.JoinHostPort(host, strconv.Itoa(port)), nil +} + +// resolveExecutable returns the absolute path of the running binary. +// +// The registration points at that exact path, so it must be absolute: a +// relative path would be resolved against the working directory of the +// supervisor. +func resolveExecutable() (string, error) { + exe, err := os.Executable() + if err != nil { + return "", fmt.Errorf("the path of the running binary is unknown: %w", err) + } + abs, err := filepath.Abs(exe) + if err != nil { + return "", fmt.Errorf("the path of the running binary %q is not absolute: %w", exe, err) + } + return abs, nil +} + +// installFlags holds the flags of the "service install" command. +type installFlags struct { + scopeFlags + listen string + config string + stateDir string + logDir string + cacheDir string + hubDir string + localDir string +} + +// buildInstallOptions assembles the options of an installation from the +// scope, the flags of the command line and the path of the binary. +// +// The default runtime paths of the scope are the base and an explicit path +// flag overrides the matching default. The config file keeps its own +// directory: the parent of the config file is the directory the +// installation prepares. +// +// The cache directories are derived from the runtime paths, so they are +// resolved after the overrides: a machine-wide instance keeps its cache +// inside its own state root even when the state root was overridden. +func buildInstallOptions(scope service.Scope, flags installFlags, executable string) (service.InstallOptions, error) { + paths, err := service.DefaultPaths(scope) + if err != nil { + return service.InstallOptions{}, err + } + if flags.config != "" { + paths.ConfigFile = flags.config + } + if flags.stateDir != "" { + paths.StateDir = flags.stateDir + } + if flags.logDir != "" { + paths.LogDir = flags.logDir + } + + cacheRoot, hubDir := service.DefaultCacheDirs(scope, paths) + if flags.cacheDir != "" { + cacheRoot = flags.cacheDir + } + if flags.hubDir != "" { + hubDir = flags.hubDir + } + + listen := flags.listen + if listen == "" { + listen = service.DefaultListen + } + + return service.InstallOptions{ + Scope: scope, + Executable: executable, + Listen: listen, + Paths: paths, + CacheDir: cacheRoot, + HubDir: hubDir, + LocalDir: flags.localDir, + }, nil +} + +// serviceInstall registers the service with the platform supervisor of the +// scope. +// +// The command validates its own arguments before it touches the system. It +// never starts the instance: starting is a separate, explicit command. +func serviceInstall(args []string, stdout io.Writer) error { + flags := installFlags{} + fs := flag.NewFlagSet("hfdesk service install", flag.ContinueOnError) + fs.SetOutput(io.Discard) + registerScopeFlags(fs, &flags.scopeFlags) + fs.StringVar(&flags.listen, "listen", service.DefaultListen, "host:port the instance binds") + fs.StringVar(&flags.config, "config", "", "absolute path of the config file the instance owns") + fs.StringVar(&flags.stateDir, "state-dir", "", "absolute path of the state directory of the instance") + fs.StringVar(&flags.logDir, "log-dir", "", "absolute path of the log directory of the instance") + fs.StringVar(&flags.cacheDir, "cache-dir", "", "absolute path of the Hugging Face cache root") + fs.StringVar(&flags.hubDir, "hub-dir", "", "absolute path of the Hugging Face hub cache directory") + fs.StringVar(&flags.localDir, "local-dir", "", "absolute path of the flat output directory") + + if err := fs.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + writeSubcommandUsage(stdout, fs, "Register HFDesk with the platform supervisor of the scope.\n\nThe registration is written, the instance is not started.") + return errHelpRequested + } + return fmt.Errorf("invalid command line: %w", err) + } + if rest := fs.Args(); len(rest) > 0 { + return fmt.Errorf("unexpected argument %q, the command takes flags only", rest[0]) + } + + scope, err := resolveScope(flags.user, flags.system) + if err != nil { + return err + } + listen, err := validateListen(flags.listen) + if err != nil { + return err + } + if err := validateAbsolutePaths([]pathFlag{ + {"--config", flags.config}, + {"--state-dir", flags.stateDir}, + {"--log-dir", flags.logDir}, + {"--cache-dir", flags.cacheDir}, + {"--hub-dir", flags.hubDir}, + {"--local-dir", flags.localDir}, + }); err != nil { + return err + } + flags.listen = listen + + executable, err := resolveExecutable() + if err != nil { + return err + } + + opts, err := buildInstallOptions(scope, flags, executable) + if err != nil { + return err + } + + mgr, err := service.New(scope) + if err != nil { + return err + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + if err := mgr.Install(ctx, opts); err != nil { + return err + } + + writeInstallSummary(stdout, ctx, mgr, scope, opts) + return nil +} + +// writeInstallSummary prints what was registered. +// +// The path of the registration is known only after the installation, so it +// is read back from the supervisor. +func writeInstallSummary(w io.Writer, ctx context.Context, mgr service.Manager, scope service.Scope, opts service.InstallOptions) { + writeField(w, "Service", service.ServiceName) + writeField(w, "Scope", string(scope)) + writeField(w, "Registration", registrationPath(ctx, mgr)) + writeField(w, "Executable", opts.Executable) + writeField(w, "Listen", opts.Listen) + writeField(w, "Config file", opts.Paths.ConfigFile) + writeField(w, "State dir", opts.Paths.StateDir) + writeField(w, "Log dir", opts.Paths.LogDir) + writeField(w, "Cache root", opts.CacheDir) + + if warning := nonLoopbackWarning(opts.Listen); warning != "" { + fmt.Fprintln(w) + fmt.Fprint(w, warning) + } + if warning := upgradeWarning(opts.Executable); warning != "" { + fmt.Fprintln(w) + fmt.Fprint(w, warning) + } + + fmt.Fprintln(w) + fmt.Fprintln(w, "The service was registered but not started.") + fmt.Fprintln(w, "Start it with: hfdesk service start") +} + +// registrationPath returns the path of the supervisor registration. +func registrationPath(ctx context.Context, mgr service.Manager) string { + status, _ := mgr.Status(ctx) + if status.UnitPath == "" { + return "unknown" + } + return status.UnitPath +} + +// serviceUninstall removes the registration of the service. +// +// The registration is removed and nothing else: the models, the cache, the +// config file, the job history and the logs of the instance stay where +// they are. +func serviceUninstall(args []string, stdout io.Writer) error { + mgr, scope, err := newLifecycleManager("hfdesk service uninstall", args, stdout, + "Remove the registration of the service.\n\nThe user data is kept: models, cache, config, jobs/history and logs are not touched.") + if err != nil { + return err + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + // The paths of the registration are read before it is removed, so the + // report can name the directories the data stays in. + paths, cacheRoot := instanceDataPaths(ctx, mgr, scope) + + if err := mgr.Uninstall(ctx); err != nil { + return err + } + + fmt.Fprintf(stdout, "The registration of %s (%s scope) was removed.\n", service.ServiceName, scope) + fmt.Fprintln(stdout, "The user data was kept: models, cache, config, jobs/history and logs are still there.") + writeField(stdout, "Config file", paths.ConfigFile) + writeField(stdout, "State dir", paths.StateDir) + writeField(stdout, "Log dir", paths.LogDir) + writeField(stdout, "Cache root", cacheRoot) + return nil +} + +// instanceDataPaths returns the runtime paths and the cache root of the +// instance of the scope. +// +// The registration is read first, so the report names the directories the +// data of the registration stays in. A registration that cannot be read +// falls back to the default paths of the scope. +func instanceDataPaths(ctx context.Context, mgr service.Manager, scope service.Scope) (runtimepaths.Paths, string) { + paths, err := service.DefaultPaths(scope) + if err != nil { + paths = runtimepaths.Paths{} + } + if status, statusErr := mgr.Status(ctx); statusErr == nil { + if status.Paths.ConfigFile != "" { + paths.ConfigFile = status.Paths.ConfigFile + } + if status.Paths.StateDir != "" { + paths.StateDir = status.Paths.StateDir + } + if status.Paths.LogDir != "" { + paths.LogDir = status.Paths.LogDir + } + } + cacheRoot, _ := service.DefaultCacheDirs(scope, paths) + return paths, cacheRoot +} + +// newLifecycleManager prepares a lifecycle command: it parses the scope +// selector and opens the manager of the scope. +func newLifecycleManager(name string, args []string, stdout io.Writer, summary string) (service.Manager, service.Scope, error) { + flags := scopeFlags{} + fs := flag.NewFlagSet(name, flag.ContinueOnError) + fs.SetOutput(io.Discard) + registerScopeFlags(fs, &flags) + + if err := fs.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + writeSubcommandUsage(stdout, fs, summary) + return nil, "", errHelpRequested + } + return nil, "", fmt.Errorf("invalid command line: %w", err) + } + if rest := fs.Args(); len(rest) > 0 { + return nil, "", fmt.Errorf("unexpected argument %q, the command takes flags only", rest[0]) + } + + scope, err := resolveScope(flags.user, flags.system) + if err != nil { + return nil, "", err + } + mgr, err := service.New(scope) + if err != nil { + return nil, "", err + } + return mgr, scope, nil +} + +// serviceStart starts the registered service. +func serviceStart(args []string, stdout io.Writer) error { + mgr, _, err := newLifecycleManager("hfdesk service start", args, stdout, + "Start the registered service.") + if err != nil { + return err + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + if err := mgr.Start(ctx); err != nil { + return err + } + fmt.Fprintf(stdout, "The %s service was started.\n", service.ServiceName) + writeLifecycleState(ctx, mgr, stdout) + return nil +} + +// serviceStop stops the registered service. +func serviceStop(args []string, stdout io.Writer) error { + mgr, _, err := newLifecycleManager("hfdesk service stop", args, stdout, + "Stop the registered service.") + if err != nil { + return err + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + if err := mgr.Stop(ctx); err != nil { + return err + } + fmt.Fprintf(stdout, "The %s service was stopped.\n", service.ServiceName) + writeLifecycleState(ctx, mgr, stdout) + return nil +} + +// serviceRestart stops and starts the registered service. +func serviceRestart(args []string, stdout io.Writer) error { + mgr, _, err := newLifecycleManager("hfdesk service restart", args, stdout, + "Stop and start the registered service.") + if err != nil { + return err + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + if err := mgr.Restart(ctx); err != nil { + return err + } + fmt.Fprintf(stdout, "The %s service was restarted.\n", service.ServiceName) + writeLifecycleState(ctx, mgr, stdout) + return nil +} + +// writeLifecycleState prints the state of the instance after a lifecycle +// command, when the supervisor can report it. +func writeLifecycleState(ctx context.Context, mgr service.Manager, w io.Writer) { + status, err := mgr.Status(ctx) + if err != nil || status.State == "" { + return + } + writeField(w, "State", string(status.State)) +} + +// serviceStatus prints the state of the registered service. +// +// The exit code tells a script whether the instance is in a usable state: +// 0 for a running, stopped, starting or stopping instance, 1 for an +// instance that is not installed, broken, failed or unknown. +func serviceStatus(args []string, stdout, stderr io.Writer) int { + mgr, scope, err := newLifecycleManager("hfdesk service status", args, stdout, + "Report the scope, the state and the paths of the instance.") + if err != nil { + return serviceCommandResult(stderr, err) + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + status, statusErr := mgr.Status(ctx) + renderStatus(stdout, scope, status) + if statusErr != nil { + fmt.Fprintf(stderr, "hfdesk service: %s\n", serviceErrorText(statusErr)) + return serviceFailureExitCode + } + return statusExitCode(status.State) +} + +// statusExitCode maps the state of an instance onto the exit code of the +// status command. +func statusExitCode(state service.State) int { + switch state { + case service.StateRunning, service.StateStopped, service.StateStarting, service.StateStopping: + return 0 + default: + return serviceFailureExitCode + } +} + +// renderStatus prints the state of an instance as a "Key: value" block. +func renderStatus(w io.Writer, scope service.Scope, status service.Status) { + name := status.Name + if name == "" { + name = service.ServiceName + } + reportedScope := status.Scope + if reportedScope == "" { + reportedScope = scope + } + + // A registration that does not exist carries no paths; the defaults of + // the scope are the paths such an instance would use. + paths := status.Paths + if paths.ConfigFile == "" && paths.StateDir == "" && paths.LogDir == "" { + if defaults, err := service.DefaultPaths(scope); err == nil { + paths = defaults + } + } + cacheRoot, _ := service.DefaultCacheDirs(scope, paths) + + writeField(w, "Service", name) + writeField(w, "Scope", string(reportedScope)) + writeField(w, "State", string(status.State)) + writeField(w, "Registration", status.UnitPath) + writeField(w, "Executable", status.Executable) + writeField(w, "Listen", status.Listen) + writeField(w, "Config file", paths.ConfigFile) + writeField(w, "State dir", paths.StateDir) + writeField(w, "Log dir", paths.LogDir) + writeField(w, "Cache root", cacheRoot) + if status.Detail != "" { + writeField(w, "Detail", status.Detail) + } + + if warning := nonLoopbackWarning(status.Listen); warning != "" { + fmt.Fprintln(w) + fmt.Fprint(w, warning) + } +} + +// nonLoopbackWarning returns the warning for a bind address that is not a +// loopback address, or an empty string for a loopback address. +// +// A supervised instance binds loopback by default. An instance that binds +// another address is reachable from the network, so the exposure must be +// visible instead of silent. +func nonLoopbackWarning(listen string) string { + listen = strings.TrimSpace(listen) + if listen == "" { + return "" + } + if _, _, err := service.ParseListen(listen); err != nil { + return "" + } + if service.IsLoopbackListen(listen) { + return "" + } + return fmt.Sprintf("WARNING: %s is not a loopback address.\n"+ + " The HFDesk dashboard is reachable from the network: everyone\n"+ + " who can reach this machine can reach the dashboard.\n", listen) +} + +// upgradeWarning returns the warning for an executable whose path does not +// survive an upgrade, or an empty string for a stable path. +// +// The registration points at one exact path. Two shapes of path break that +// promise: +// +// - a temporary directory: an unpacked archive or a downloaded artifact +// is removed by the operating system or by the next download. +// - a version-stamped name: a release artifact carries its version in its +// name ("hfdesk-1.2.3-linux-amd64") and the next release replaces the +// file. +// +// The heuristic is deliberately simple: a name with a "digit.digit" segment +// counts as version-stamped. It only produces a warning, so a false +// positive costs a hint and never a broken installation. +func upgradeWarning(executable string) string { + if executable == "" { + return "" + } + reason := "" + switch { + case isTemporaryPath(executable): + reason = "it lives in a temporary directory" + case isVersionStampedName(filepath.Base(executable)), + isVersionStampedName(filepath.Base(filepath.Dir(executable))): + reason = "its name carries a version" + default: + return "" + } + return fmt.Sprintf("WARNING: the registration points at %s, and %s.\n"+ + " The registration points at that exact path: a version-stamped\n"+ + " release binary is replaced by the next upgrade and a temporary\n"+ + " path is removed by the operating system, so the service breaks.\n"+ + " Install the binary at a stable path (for example\n"+ + " /usr/local/bin/hfdesk) and run \"hfdesk service install\" again.\n", + executable, reason) +} + +// isTemporaryPath reports whether the path lives in a temporary directory. +// +// The markers are the ones the runtime path model of the repository already +// uses to skip an unpacked archive: the temporary directory of the operating +// system and the markers of a WinRAR extraction. +func isTemporaryPath(path string) bool { + clean := filepath.Clean(path) + temp := filepath.Clean(os.TempDir()) + if strings.HasPrefix(strings.ToLower(clean), strings.ToLower(temp)) { + return true + } + lower := strings.ToLower(clean) + return strings.Contains(lower, "rar$") || strings.Contains(lower, ".rartemp") +} + +// isVersionStampedName reports whether the name of a file carries a +// version-like segment. +func isVersionStampedName(name string) bool { + return versionLikeSegment.MatchString(name) +} + +// serviceRun runs the headless host of an instance. +// +// The mode is internal: the supervisor invokes it with the canonical +// argument vector of the registration. It never opens a browser and never +// creates a tray icon. +func serviceRun(args []string, stdout io.Writer) error { + fs := flag.NewFlagSet("hfdesk service run", flag.ContinueOnError) + fs.SetOutput(io.Discard) + // The flags of the subcommand are registered so its help lists them. + // The parsing itself is done by the service layer, which owns the + // canonical argument vector of a registered instance. + registerRunFlags(fs) + + if err := fs.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + writeSubcommandUsage(stdout, fs, "Run the headless host of an instance.\n\nThis mode is internal: the supervisor invokes it. It never opens a browser\nand never creates a tray icon.") + return errHelpRequested + } + return fmt.Errorf("invalid command line: %w", err) + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + return runServiceHost(ctx, args) +} + +// registerRunFlags registers the flags of the "service run" subcommand. +// +// The list comes from the service layer, so the help of the command and the +// parser of the layer never drift apart. +func registerRunFlags(fs *flag.FlagSet) { + for _, f := range service.RunArgsHelp() { + fs.String(f.Name, "", f.Usage) + } +} + +// runServiceHost parses the arguments of the host mode and runs the +// instance. +// +// The host never opens a browser and never creates a tray icon: the +// instance is supervised and there is no desktop session to interact with. +func runServiceHost(ctx context.Context, args []string) error { + opts, err := service.ParseRunArgs(args) + if err != nil { + return err + } + return service.RunProcess(ctx, opts) +} diff --git a/cmd/hfdesk/service_test.go b/cmd/hfdesk/service_test.go new file mode 100644 index 00000000..a62fbea8 --- /dev/null +++ b/cmd/hfdesk/service_test.go @@ -0,0 +1,421 @@ +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "bytes" + "context" + "net" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "testing" + "time" + + "github.com/bashrusakh/hfdesk/internal/runtimepaths" + "github.com/bashrusakh/hfdesk/internal/service" +) + +// interactiveFlagPattern matches the registration of a flag of the +// interactive command line, e.g. `flag.IntVar(&port, "port", 8080, ...)`. +var interactiveFlagPattern = regexp.MustCompile(`flag\.\w+Var\(&\w+, "([^"]+)"`) + +func TestServiceUsageListsEverySubcommand(t *testing.T) { + var buf bytes.Buffer + writeServiceUsage(&buf) + + out := buf.String() + for _, name := range serviceSubcommands { + if !strings.Contains(out, name) { + t.Errorf("the usage block does not mention the %q subcommand:\n%s", name, out) + } + } +} + +func TestServiceHelpSucceeds(t *testing.T) { + var stdout, stderr bytes.Buffer + code := runServiceCommand([]string{"--help"}, &stdout, &stderr) + if code != 0 { + t.Errorf("the help of the service command must succeed, got exit code %d", code) + } + if !strings.Contains(stdout.String(), "install") { + t.Errorf("the help does not list the subcommands:\n%s", stdout.String()) + } + if stderr.Len() != 0 { + t.Errorf("the help must not write to stderr:\n%s", stderr.String()) + } +} + +func TestServiceUnknownSubcommand(t *testing.T) { + var stdout, stderr bytes.Buffer + code := runServiceCommand([]string{"frobnicate"}, &stdout, &stderr) + if code == 0 { + t.Errorf("an unknown subcommand must not succeed, got exit code %d", code) + } + + errText := stderr.String() + if !strings.Contains(errText, "frobnicate") { + t.Errorf("the error does not name the unknown subcommand:\n%s", errText) + } + for _, name := range serviceSubcommands { + if !strings.Contains(errText, name) { + t.Errorf("the error does not name the valid subcommand %q:\n%s", name, errText) + } + } +} + +func TestServiceWithoutSubcommand(t *testing.T) { + var stdout, stderr bytes.Buffer + code := runServiceCommand(nil, &stdout, &stderr) + if code == 0 { + t.Errorf("a missing subcommand must not succeed, got exit code %d", code) + } + for _, name := range serviceSubcommands { + if !strings.Contains(stderr.String(), name) { + t.Errorf("the usage block does not name the valid subcommand %q:\n%s", name, stderr.String()) + } + } +} + +func TestServiceScopeSelectorsAreExclusive(t *testing.T) { + if _, err := resolveScope(true, true); err == nil { + t.Error("--user together with --system must be rejected") + } + + var stdout, stderr bytes.Buffer + code := runServiceCommand([]string{"install", "--user", "--system"}, &stdout, &stderr) + if code == 0 { + t.Errorf("an ambiguous scope must not succeed, got exit code %d", code) + } + if !strings.Contains(stderr.String(), "--user") || !strings.Contains(stderr.String(), "--system") { + t.Errorf("the error does not name both selectors:\n%s", stderr.String()) + } +} + +func TestServiceScopeDefaults(t *testing.T) { + scope, err := resolveScope(false, false) + if err != nil { + t.Fatalf("the default scope must resolve: %v", err) + } + want := service.ScopeUser + if isWindows() { + want = service.ScopeSystem + } + if scope != want { + t.Errorf("the default scope is %q, want %q", scope, want) + } + + user, err := resolveScope(true, false) + if err != nil { + t.Fatalf("the user scope must resolve: %v", err) + } + if user != service.ScopeUser { + t.Errorf("the user scope is %q, want %q", user, service.ScopeUser) + } + + system, err := resolveScope(false, true) + if err != nil { + t.Fatalf("the system scope must resolve: %v", err) + } + if system != service.ScopeSystem { + t.Errorf("the system scope is %q, want %q", system, service.ScopeSystem) + } +} + +func TestServiceListenValidation(t *testing.T) { + if _, err := validateListen(":8080"); err == nil { + t.Error("an empty host must be rejected, it binds every interface") + } + if _, err := validateListen("127.0.0.1:0"); err == nil { + t.Error("port 0 must be rejected") + } + if _, err := validateListen("127.0.0.1:70000"); err == nil { + t.Error("a port out of range must be rejected") + } + + listen, err := validateListen("127.0.0.1:9090") + if err != nil { + t.Fatalf("a loopback address must be accepted: %v", err) + } + if listen != "127.0.0.1:9090" { + t.Errorf("the listen address is %q, want %q", listen, "127.0.0.1:9090") + } +} + +func TestServiceRelativePathsAreRejected(t *testing.T) { + for _, args := range [][]string{ + {"install", "--state-dir", "relative/state"}, + {"install", "--config", "relative/hfdesk.json"}, + {"install", "--log-dir", "relative/logs"}, + {"install", "--cache-dir", "relative/cache"}, + {"install", "--hub-dir", "relative/hub"}, + {"install", "--local-dir", "relative/models"}, + } { + var stdout, stderr bytes.Buffer + code := runServiceCommand(args, &stdout, &stderr) + if code == 0 { + t.Errorf("%v must be rejected, got exit code %d", args, code) + } + if !strings.Contains(stderr.String(), "absolute") { + t.Errorf("%v is not reported as a relative path:\n%s", args, stderr.String()) + } + } +} + +func TestBuildInstallOptionsUsesTheDefaultsOfTheScope(t *testing.T) { + const executable = "/usr/local/bin/hfdesk" + + defaults, err := service.DefaultPaths(service.ScopeUser) + if err != nil { + t.Fatalf("the default paths of the scope: %v", err) + } + cacheRoot, hubDir := service.DefaultCacheDirs(service.ScopeUser, defaults) + + opts, err := buildInstallOptions(service.ScopeUser, installFlags{}, executable) + if err != nil { + t.Fatalf("build the install options: %v", err) + } + + if opts.Scope != service.ScopeUser { + t.Errorf("the scope is %q, want %q", opts.Scope, service.ScopeUser) + } + if opts.Executable != executable { + t.Errorf("the executable is %q, want %q", opts.Executable, executable) + } + if opts.Listen != service.DefaultListen { + t.Errorf("the listen address is %q, want %q", opts.Listen, service.DefaultListen) + } + if opts.Paths != defaults { + t.Errorf("the paths are %+v, want %+v", opts.Paths, defaults) + } + if opts.CacheDir != cacheRoot { + t.Errorf("the cache root is %q, want %q", opts.CacheDir, cacheRoot) + } + if opts.HubDir != hubDir { + t.Errorf("the hub dir is %q, want %q", opts.HubDir, hubDir) + } + if opts.LocalDir != "" { + t.Errorf("the local dir is %q, want it to be unset", opts.LocalDir) + } +} + +func TestBuildInstallOptionsAppliesTheOverrides(t *testing.T) { + const executable = "/usr/local/bin/hfdesk" + + flags := installFlags{ + listen: "0.0.0.0:9090", + config: "/etc/hfdesk/custom.json", + stateDir: "/var/lib/hfdesk-custom", + logDir: "/var/log/hfdesk-custom", + cacheDir: "/srv/cache", + hubDir: "/srv/cache/hub", + localDir: "/srv/models", + } + + opts, err := buildInstallOptions(service.ScopeSystem, flags, executable) + if err != nil { + t.Fatalf("build the install options: %v", err) + } + + if opts.Scope != service.ScopeSystem { + t.Errorf("the scope is %q, want %q", opts.Scope, service.ScopeSystem) + } + if opts.Listen != "0.0.0.0:9090" { + t.Errorf("the listen address is %q, want %q", opts.Listen, "0.0.0.0:9090") + } + if opts.Paths.ConfigFile != "/etc/hfdesk/custom.json" { + t.Errorf("the config file is %q, want %q", opts.Paths.ConfigFile, "/etc/hfdesk/custom.json") + } + if opts.Paths.StateDir != "/var/lib/hfdesk-custom" { + t.Errorf("the state dir is %q, want %q", opts.Paths.StateDir, "/var/lib/hfdesk-custom") + } + if opts.Paths.LogDir != "/var/log/hfdesk-custom" { + t.Errorf("the log dir is %q, want %q", opts.Paths.LogDir, "/var/log/hfdesk-custom") + } + if opts.CacheDir != "/srv/cache" { + t.Errorf("the cache root is %q, want %q", opts.CacheDir, "/srv/cache") + } + if opts.HubDir != "/srv/cache/hub" { + t.Errorf("the hub dir is %q, want %q", opts.HubDir, "/srv/cache/hub") + } + if opts.LocalDir != "/srv/models" { + t.Errorf("the local dir is %q, want %q", opts.LocalDir, "/srv/models") + } +} + +func TestRenderStatusReportsTheInstance(t *testing.T) { + status := service.Status{ + State: service.StateRunning, + Scope: service.ScopeSystem, + Name: service.ServiceName, + UnitPath: "/etc/systemd/system/hfdesk.service", + Executable: "/usr/local/bin/hfdesk", + Listen: "127.0.0.1:8080", + Paths: runtimepaths.Paths{ + ConfigFile: "/etc/hfdesk/hfdesk.json", + StateDir: "/var/lib/hfdesk", + LogDir: "/var/log/hfdesk", + }, + } + + var buf bytes.Buffer + renderStatus(&buf, service.ScopeSystem, status) + + out := buf.String() + for _, want := range []string{ + service.ServiceName, + "running", + "127.0.0.1:8080", + "/etc/systemd/system/hfdesk.service", + "/usr/local/bin/hfdesk", + "/etc/hfdesk/hfdesk.json", + "/var/lib/hfdesk", + "/var/log/hfdesk", + } { + if !strings.Contains(out, want) { + t.Errorf("the status does not report %q:\n%s", want, out) + } + } +} + +func TestNonLoopbackWarning(t *testing.T) { + warning := nonLoopbackWarning("0.0.0.0:8080") + if warning == "" { + t.Error("a non-loopback address must produce a warning") + } + if !strings.Contains(warning, "0.0.0.0:8080") { + t.Errorf("the warning does not name the address:\n%s", warning) + } + + if got := nonLoopbackWarning("127.0.0.1:8080"); got != "" { + t.Errorf("a loopback address must not produce a warning, got:\n%s", got) + } + if got := nonLoopbackWarning(""); got != "" { + t.Errorf("an unknown address must not produce a warning, got:\n%s", got) + } +} + +func TestUpgradeWarning(t *testing.T) { + if got := upgradeWarning("/usr/local/bin/hfdesk"); got != "" { + t.Errorf("a stable path must not produce a warning, got:\n%s", got) + } + if got := upgradeWarning("/tmp/hfdesk-1.2.3/hfdesk"); got == "" { + t.Error("a version-stamped path must produce a warning") + } + if got := upgradeWarning(filepath.Join(os.TempDir(), "hfdesk", "hfdesk")); got == "" { + t.Error("a temporary path must produce a warning") + } +} + +func TestRunPathReturnsAfterTheContextIsCancelled(t *testing.T) { + dir := t.TempDir() + stateDir := filepath.Join(dir, "state") + + // The host installs the path model process wide; the interactive + // resolution is restored afterwards. + t.Cleanup(runtimepaths.Reset) + + // The host drops the token of the environment; it is restored so the + // rest of the test run keeps its environment. + token, hadToken := os.LookupEnv("HF_TOKEN") + t.Cleanup(func() { + if hadToken { + _ = os.Setenv("HF_TOKEN", token) + return + } + _ = os.Unsetenv("HF_TOKEN") + }) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + args := []string{ + "--config", filepath.Join(stateDir, "hfdesk.json"), + "--state-dir", stateDir, + "--log-dir", filepath.Join(stateDir, "logs"), + "--listen", net.JoinHostPort("127.0.0.1", strconv.Itoa(freeLoopbackPort(t))), + } + + done := make(chan error, 1) + go func() { done <- runServiceHost(ctx, args) }() + + select { + case err := <-done: + if err != nil { + t.Fatalf("the host returned an error: %v", err) + } + case <-time.After(30 * time.Second): + t.Fatal("the host did not return after the context was cancelled") + } +} + +// freeLoopbackPort returns a port of the loopback interface that is free at +// the moment. +// +// The service layer rejects port 0, so the host needs a real port. The port +// is reserved and released again, so the instance binds a port of its own +// instead of a well known one. +func freeLoopbackPort(t *testing.T) int { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("reserve a loopback port: %v", err) + } + defer func() { _ = listener.Close() }() + + addr, ok := listener.Addr().(*net.TCPAddr) + if !ok { + t.Fatalf("the reserved address is %T, want a TCP address", listener.Addr()) + } + return addr.Port +} + +func TestRunPathRejectsAnUnknownFlag(t *testing.T) { + if err := runServiceHost(context.Background(), []string{"--unknown", "value"}); err == nil { + t.Error("an unknown flag must be rejected") + } +} + +func TestInteractiveFlagsAreUnchanged(t *testing.T) { + source, err := os.ReadFile("main.go") + if err != nil { + t.Fatalf("read main.go: %v", err) + } + + registered := map[string]bool{} + for _, match := range interactiveFlagPattern.FindAllStringSubmatch(string(source), -1) { + registered[match[1]] = true + } + + for _, name := range []string{"port", "cache-dir", "token", "local-dir", "stall-timeout", "open", "no-open"} { + if !registered[name] { + t.Errorf("the interactive command line does not register the %q flag any more", name) + } + } +} + +func TestServiceDispatchIsExact(t *testing.T) { + if !isServiceCommand([]string{"service", "status"}) { + t.Error("the token \"service\" must select the service command family") + } + // The token alone selects the family as well: the family itself + // reports the missing subcommand. + if !isServiceCommand([]string{"service"}) { + t.Error("the token \"service\" alone must select the service command family") + } + for _, args := range [][]string{ + nil, + {}, + {"--port", "9090"}, + {"--help"}, + {"services"}, + {"Service"}, + } { + if isServiceCommand(args) { + t.Errorf("%v must not select the service command family", args) + } + } +} From 97484673a68d473ab4827b4e7fcf1dd281130dd3 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 21 Oct 2026 20:39:12 +1100 Subject: [PATCH 05/58] docs: document the service mode - Add docs/SERVICE.md as the reference for the command surface, the scopes, the runtime paths, the credential handling, the logs, the lifecycle semantics and the per-platform notes. - Describe the service mode in the README with the quick start, the loopback default and a link to the reference. - Record the feature in the changelog, the repository layout, the runtime entry points and the agent rules, and add the service commands to the contribution guide. Co-Authored-By: opencode --- AGENTS.md | 22 ++++ CHANGELOG.md | 4 + CONTRIBUTING.md | 13 +++ README.md | 18 +++ docs/SERVICE.md | 289 ++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 346 insertions(+) create mode 100644 docs/SERVICE.md diff --git a/AGENTS.md b/AGENTS.md index c9713e82..476cab97 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,6 +41,8 @@ browser-only presentation belongs in `internal/assets/static`. | `internal/server/` | HTTP API, jobs, settings, cache browser, websocket state | | `internal/assets/` | embedded frontend assets | | `internal/assets/static/` | browser UI files | +| `internal/service/` | service mode: scopes, lifecycle, and the systemd/launchd/SCM managers | +| `internal/runtimepaths/` | runtime path model for the config file, state root and log dir | | `pkg/hfdownloader/` | Hugging Face scan/download/cache/mirror logic | | `pkg/smartdl/` | model analysis and selectable download helpers | | `docs/` | API docs and screenshots | @@ -52,6 +54,7 @@ Read relevant Markdown before changing behavior: - General project behavior: `README.md` - API contracts: `docs/API.md` +- Service mode reference: `docs/SERVICE.md` - Contribution and commit conventions: `CONTRIBUTING.md` - Release history and user-visible changes: `CHANGELOG.md` - Current project notes: `TODO.md` @@ -83,10 +86,13 @@ change is documentation-only. ## Runtime entry points - Main process: `cmd/hfdesk/main.go` +- Service command family: `cmd/hfdesk/service.go` - Server routes: `internal/server/server.go`, `internal/server/api.go` - Job manager: `internal/server/jobs.go` - WebSocket hub: `internal/server/websocket.go` - Config persistence: `internal/server/config.go` +- Service layer: `internal/service/service.go`, `internal/service/manager_linux.go`, `internal/service/manager_darwin.go` +- Runtime paths: `internal/runtimepaths/runtimepaths.go` - Frontend app: `internal/assets/static/js/app.js` - Frontend styles: `internal/assets/static/css/style.css` - Downloader: `pkg/hfdownloader/downloader.go` @@ -187,6 +193,22 @@ Downloader bugs often produce silent corruption, so safety beats clever resume. - Exact matching and filter behavior must remain predictable for GGUF quants, sharded files, mmproj companions, safetensors, and datasets. +## Service mode rules + +- The supervisor owns the lifecycle: HFDesk stays a foreground process, with + no self-daemonization, no PID file, no shell wrapper, and no startup + shortcut. +- The service is headless: it never opens a browser and never shows a tray + icon. +- Service mode binds loopback by default; a non-loopback address must be + requested explicitly, and `install` and `status` report it. +- Credentials never enter a supervisor registration, a command line, or a unit + environment; they live in the service-owned config file. +- `uninstall` removes the registration only and never deletes models, cache, + config, jobs/history, or logs. +- Platform-specific supervisor code stays in `internal/service`; the shared + layer must not branch on the operating system. + ## Testing expectations - Downloader changes: run `go test ./pkg/hfdownloader` at minimum. diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d92ff4f..39e7dd9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,14 @@ ### Added + +- Add a service mode: `hfdesk service install|uninstall|start|stop|restart|status` registers HFDesk with the native supervisor of the platform (systemd on Linux, launchd on macOS, the Windows Service Control Manager on Windows). The supervisor owns the lifecycle and HFDesk stays a headless foreground process, the instance resolves deterministic runtime paths of its scope (its own config file, state root and log directory), and it binds loopback by default; exposing it to the network needs an explicit `--listen`. Credentials stay in the service-owned config file and are never written into a supervisor registration (#142). + - A second Windows artifact, `hfdesk-desktop_windows_amd64_v1.3.0.exe`, is now built and published next to the console binary: it is a GUI program without a console window that runs the same HFDesk server in-process behind a notification-area icon and a small Runtime / Logs window showing the server status, the active job count and the live log records. A left click on the icon opens that window, the tray menu offers Open HFDesk, Runtime / Logs and Quit HFDesk (a graceful shutdown), and the host is single-instance per user and session: a second launch detects the running instance and opens the web UI of it. The Runtime / Logs window needs the Microsoft Edge WebView2 Evergreen runtime, and a missing runtime produces an actionable message while the server and the tray keep working. The desktop binary is a separate download and the console binary is unchanged (#141). - A structured logging pipeline with the new `--log-file` and `--log-level` options: every record carries a level, a timestamp, the component that produced it and structured attributes, the console sink writes to standard error so redirecting standard output keeps working, and the file sink rotates. The desktop host always writes a log file, because a GUI program has no console, and it lands in `/HFDesk/logs/hfdesk.log` unless `--log-file` names another path (#141). - Local-only runtime and log endpoints: `GET /api/runtime` reports the version, the status, the URL, the active job counts, the run id of the server and the log cursor, `GET /api/runtime/logs` returns a bounded page of the retained records, `GET /api/runtime/logs/stream` streams them as Server-Sent Events, and `POST /api/runtime/open-logs` and `POST /api/runtime/open-ui` open the log folder and the web UI of the running instance. They take no path and no URL from the caller, and three rules keep them local: the peer address has to be a loopback address, an `Origin` header of another site is rejected, and the `Host` header has to name the local machine, so a name of another site that is pointed at `127.0.0.1` cannot read the runtime data of the local machine. A request that breaks a rule is rejected with `403` and never reaches the handler (#141). - The requests of the runtime endpoints are written to the log at `debug` level and not at `info` level, so the polling of the Runtime / Logs window (every four seconds) does not fill the bounded log history that the window exists to show. Every other request keeps the `info` level, including a request that answers with an error (#141). + - Add a read-only Community feature for repository Discussions and Pull Requests: `GET /api/community/{owner}/{repo}` lists both types with paging and type/status filters, while `?num=N` returns a thread with sanitized comments and human-readable event summaries. The Model Details view now has Overview and Community tabs; the read-only Community tab is lazy-loaded, lists Discussions and Pull Requests, opens a thread with comments and events, and supports model and dataset repositories. The proxy uses the configured endpoint, proxy, and HF token without exposing the token to the browser (#136). - Report gated Hugging Face repositories as a dedicated access state instead of a partial analysis: the API returns HTTP 403 with error code `gated_repo`, the repository type, an accept link for the configured endpoint, and boolean-only token state, and the Model Details view shows configured-token and repository-agreement guidance without exposing the token. - Model Details now opens README, lineage, and related-download repository links in-place, with Back/Forward history across models, datasets, and revisions (#135). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 44f892d8..020d8788 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -70,6 +70,17 @@ hfdesk --port 9090 hfdesk --token hf_xxx ``` +**install the service** — working directory `.`; evidence: `README.md` + +```bash +hfdesk service install +hfdesk service status +``` + +The `hfdesk service` command family (`install`, `uninstall`, `start`, `stop`, +`restart`, `status`) registers HFDesk with the native supervisor of the +platform. See `docs/SERVICE.md` for the full reference. + ## Before Submitting **run all tests** — working directory `.`; evidence: `README.md` @@ -167,6 +178,8 @@ Components and important paths observed in the repository: - **hfdesk binary** — Compiled Go binary built in Dockerfile (paths: `Dockerfile`; evidence: `Dockerfile`) - **cmd/hfdesk/main.go** — Application entrypoint CLI/server (paths: `cmd/hfdesk/main.go`; evidence: `README.md`) - **internal/server package** — HTTP API, job scheduling, settings handling (paths: `internal/server`; evidence: `AGENTS.md`) +- **internal/service package** — Service mode: installation scopes, lifecycle, and the systemd/launchd/Windows SCM managers (paths: `internal/service`; evidence: `AGENTS.md`) +- **internal/runtimepaths package** — Runtime path model for the config file, state root, and log directory (paths: `internal/runtimepaths`; evidence: `AGENTS.md`) - **pkg/hfdownloader package** — Hugging Face cache scan and resumable download logic (paths: `pkg/hfdownloader`; evidence: `AGENTS.md`) - **pkg/smartdl package** — Model analysis and download recommendation utilities (paths: `pkg/smartdl`; evidence: `AGENTS.md`) diff --git a/README.md b/README.md index 920de81f..ae33347c 100644 --- a/README.md +++ b/README.md @@ -225,6 +225,24 @@ Proxy example: } ``` +## Service mode + +HFDesk can also run as a supervised background service instead of an +interactive process: systemd on Linux, launchd on macOS, the Windows Service +Control Manager on Windows. The supervisor owns the lifecycle, and HFDesk stays +a headless foreground process inside it. + +```bash +hfdesk service install +hfdesk service status +``` + +Service mode binds loopback by default (`127.0.0.1:8080`). Exposing the +dashboard to the network needs an explicit `--listen 0.0.0.0:8080`. + +See [docs/SERVICE.md](docs/SERVICE.md) for the full reference: scopes, runtime +paths, credentials, logs, and per-platform notes. + ## API HFDesk's web UI is backed by a local JSON API. See [docs/API.md](docs/API.md) for endpoints, request shapes, and response formats. diff --git a/docs/SERVICE.md b/docs/SERVICE.md new file mode 100644 index 00000000..2a770882 --- /dev/null +++ b/docs/SERVICE.md @@ -0,0 +1,289 @@ +# HFDesk Service Mode + +HFDesk can run as a supervised background service instead of an interactive +process. The platform supervisor owns the lifecycle — systemd on Linux, launchd +on macOS, the Windows Service Control Manager on Windows — and HFDesk stays a +plain foreground process inside it. + +The supervisor owns the lifecycle completely: + +- HFDesk stays a foreground process: no self-daemonization, no PID file, no + `nohup`, no shell wrapper, no Task Scheduler, and no startup-folder + shortcut. +- The service is headless: it never opens a browser and never shows a tray + icon. +- The supervisor starts the instance at boot of its scope and restarts it when + it fails. + +Service mode is a **separate command family**. `hfdesk` without a `service` +subcommand behaves exactly as before: same flags, same config precedence, same +browser launch. Nothing about an interactive install is relocated by a service +installation. + +## Quick start + +```bash +hfdesk service install # per-user service on Linux/macOS +hfdesk service status # where it is registered and what it binds +``` + +The dashboard is then served on . + +## Prerequisites + +- A HFDesk binary at a **stable, absolute path** (see + [Upgrades](#upgrades)). +- The platform supervisor: `systemd` on Linux, `launchd` on macOS, the Service + Control Manager on Windows. +- Administrator rights for a machine-wide (`--system`) installation. HFDesk + never elevates itself. +- A writable state root for the chosen scope (see + [Where the state lives](#where-the-state-lives)). + +The interactive CLI keeps its existing config precedence: a config file next to +the launch/executable directory first, then the per-user config directory. +Service mode does not relocate existing interactive installs and does not +change where an interactive run reads its config from. + +## Command surface + +``` +hfdesk service install [--user|--system] [--listen HOST:PORT] [--config PATH] [--state-dir DIR] [--log-dir DIR] [--cache-dir DIR] [--hub-dir DIR] [--local-dir DIR] +hfdesk service uninstall [--user|--system] +hfdesk service start [--user|--system] +hfdesk service stop [--user|--system] +hfdesk service restart [--user|--system] +hfdesk service status [--user|--system] +hfdesk service run --config PATH --state-dir DIR --log-dir DIR [--listen HOST:PORT] [--cache-dir DIR] [--hub-dir DIR] [--local-dir DIR] +``` + +`service run` is the **internal host mode** the supervisor invokes. It is not +meant for interactive use: it requires a complete, absolute path model and it +never opens a browser. + +### Install flags + +| Flag | Meaning | +|---|---| +| `--user` | Per-user installation (the default on Linux and macOS). | +| `--system` | Machine-wide installation. Needs administrator rights. | +| `--listen HOST:PORT` | Bind address of the instance. Default `127.0.0.1:8080`. | +| `--config PATH` | Absolute path of the config file the instance owns. | +| `--state-dir DIR` | Absolute path of the state directory (jobs, history, mirror targets). | +| `--log-dir DIR` | Absolute path of the log directory. | +| `--cache-dir DIR` | Absolute path of the Hugging Face cache root. | +| `--hub-dir DIR` | Absolute path of the Hugging Face hub cache directory. | +| `--local-dir DIR` | Absolute path of the flat output directory. | + +The path flags override the default locations of the scope. Every path of an +installation must be absolute; a relative path is rejected instead of being +resolved against the working directory of the supervisor. + +`install` validates the installation before it writes anything: the +executable must exist, be a regular file and be executable, the bind address +must be usable, and the runtime paths must be absolute. A failed installation +restores the previous registration instead of leaving a half-written one +behind. + +### Lifecycle flags + +`uninstall`, `start`, `stop`, `restart` and `status` take the scope selector +only (`--user` or `--system`). + +## Scopes + +| | `--user` | `--system` | +|---|---|---| +| Linux | systemd **user** unit (`systemctl --user`) | machine-wide systemd unit | +| macOS | per-user **LaunchAgent** | machine-wide **LaunchDaemon** | +| Windows | not available | machine-wide: the Windows Service Control Manager | + +`--user` is the default on Linux and macOS. Windows always uses the Windows +Service Control Manager, which registers machine-wide services only: there is +no `--user` mode there, and asking for one is rejected instead of silently +falling back to a machine-wide root. + +A user-scope instance and a system-scope instance never share a state root. + +## Where the state lives + +| Scope | Config file | State root (jobs, history, mirror targets) | Log dir | +|---|---|---|---| +| user (Linux/macOS) | `/HFDesk/service/hfdesk.json` | `/HFDesk/service` | `/HFDesk/service/logs` | +| system (Linux/macOS) | `/etc/hfdesk/hfdesk.json` | `/var/lib/hfdesk` | `/var/log/hfdesk` | +| Windows (machine) | `%ProgramData%\HFDesk\hfdesk.json` | `%ProgramData%\HFDesk` | `%ProgramData%\HFDesk\logs` | + +`` is the per-user HFDesk configuration directory of the +platform, for example `~/.config/HFDesk` on Linux and +`~/Library/Application Support/HFDesk` on macOS. + +The state root holds the mutable runtime state of the instance: the job list, +the download history and the mirror targets. The config file is the settings +file of the instance and holds its credentials. + +## Network exposure + +**Service mode binds loopback by default** (`127.0.0.1:8080`), even though the +interactive server defaults to a wildcard address. A supervised instance has +nobody sitting in front of it, so it must not expose the dashboard to the +network by accident. + +To reach the service from another machine, request the address explicitly: + +```bash +hfdesk service install --listen 0.0.0.0:8080 # every interface +hfdesk service install --listen 192.0.2.10:8080 # one interface +``` + +An address with an empty host is rejected, because an empty host binds every +interface. `install` and `status` make a non-loopback bind visible, so an +exposed instance is never silent about it. + +## Credentials + +The Hugging Face token, the proxy password and the basic-auth password are +**never** written into the supervisor registration, the process command line, +or the environment of the unit/plist. A registration is readable by every user +of the machine, so it carries paths and the bind address only. + +The credentials live in the config file the service owns, which HFDesk writes +with owner-only permissions (`0600` on Unix). In service mode the instance +reads the token from that config file; the `HF_TOKEN` environment variable is +deliberately ignored, so a supervised instance cannot silently pick up a +credential from an inherited environment. + +**Practical consequence:** after `hfdesk service install`, set the token for +the service through the service's own web UI (`POST /api/settings`), which +persists it into the service-owned config file. + +## Logs + +Service mode writes its own log file into the log directory of the instance +with bounded rotation: + +- `hfdesk.log` — the active log file +- `hfdesk.log.1`, `hfdesk.log.2`, `hfdesk.log.3` — the rotated generations + +The active file is rotated before a write would exceed 2 MiB, and three +rotated generations are kept, so the on-disk size of the log stays bounded. +The log directory and the log files are owner-only (`0700` / `0600` on Unix). + +The standard output and the standard error of the process also reach the +supervisor: the systemd journal on Linux, the launchd log files on macOS. + +## Lifecycle commands + +| Command | Effect | +|---|---| +| `install` | Registers the service with the supervisor and enables it for the boot of the scope. An existing registration is overwritten. | +| `uninstall` | Removes the **registration only**. | +| `start` | Starts the registered service. | +| `stop` | Stops the registered service. | +| `restart` | Stops and starts the registered service. | +| `status` | Reports scope, state, registration path, executable, bind address and runtime paths. | + +**Uninstall never deletes user data.** It removes the registration and nothing +else: not the models, not the cache, not the config file, not the jobs or the +history, not the logs. Removing the state of an instance is a separate, +deliberate action. + +The commands are idempotent where the wanted state is already reached: + +- `uninstall` on a service that is not installed succeeds; the registration is + already gone. +- `start` on a service that already runs succeeds. +- `stop` on a service that is already stopped succeeds. +- `install` over an existing registration replaces it. +- `start`, `stop` and `restart` on a service that is not installed fail with a + readable "not installed" error. + +## Diagnosing a broken registration + +`status` reports the state of the registration: + +| State | Meaning | +|---|---| +| `not-installed` | No registration exists for the scope. | +| `stopped` | Registered, but not running. | +| `starting` / `stopping` | The supervisor is transitioning. | +| `running` | The service is running. | +| `failed` | The service failed. | +| `broken` | Registered, but its executable is gone. | +| `unknown` | The supervisor did not report a usable state. | + +A registration points at an **absolute executable path**. If the binary is +moved or deleted, the registration stays in place but cannot run: `status` +reports the registration as `broken` and names the missing executable, rather +than reporting a running service. The fix is to restore the binary at the +registered path, or to reinstall the service from the new path: + +```bash +hfdesk service status # shows the broken registration +hfdesk service install # re-registers the current binary +``` + +## Per-platform notes + +### Linux (systemd) + +- The user scope is a **systemd user unit** (`~/.config/systemd/user/hfdesk.service`, + or `$XDG_CONFIG_HOME/systemd/user/hfdesk.service`) and is driven with + `systemctl --user`. The system scope is a machine-wide unit in + `/etc/systemd/system`. +- **HFDesk never enables linger.** A user unit normally stops when the last + session of the user ends. Booting without a login is an explicit decision of + the user or the administrator (`loginctl enable-linger `), and HFDesk + does not make it for you. +- The unit keeps the process in the foreground (`Type=simple`) and restarts it + on failure. It holds no access data. + +### macOS (launchd) + +- The user scope is a per-user **LaunchAgent** at + `~/Library/LaunchAgents/com.hfdesk.hfdesk.plist`; the system scope is a + **LaunchDaemon** at `/Library/LaunchDaemons/com.hfdesk.hfdesk.plist`. +- The registration is managed with `launchctl bootstrap`, `bootout` and + `kickstart`. A job that is already loaded is re-bootstrapped so a changed + property list takes effect. +- The property list holds the label, the program arguments and the environment + of the instance — no credential. + +### Windows (Service Control Manager) + +- The Service Control Manager is used **directly through the Go API**, never + `sc.exe` and never PowerShell. +- Installing or removing the service needs an **elevated shell**. HFDesk does + not self-elevate and never invokes UAC. +- The service identity is `LocalService`, a least-privilege Windows service + account, not `LocalSystem`. +- The service-owned state under `%ProgramData%\HFDesk` is ACL-restricted. The + service does **not** recursively re-ACL or re-own an existing model or cache + tree: changing the ownership of a large, possibly shared tree is an explicit + decision of an administrator, not a side effect of an install. + +### Privileged operations + +Scope changes are operating-system operations performed by an administrator. +HFDesk never invokes `sudo`, `su`, `pkexec` or a UAC prompt. A `--system` +installation that lacks the rights fails with a permission error instead of +escalating on its own. + +## Upgrades + +The registration points at an absolute executable path, so an upgrade is a +**binary replacement in place**: + +1. Stop the service. +2. Replace the binary at the registered path. +3. Start the service again. + +The registration does not need to be rewritten, because the path did not +change. If the binary *moves*, `status` reports the registration as `broken` +(see [Diagnosing a broken registration](#diagnosing-a-broken-registration)) +and you reinstall the service from the new path. + +## See also + +- [README](../README.md) — general project behavior +- [API](API.md) — HTTP endpoints, including the settings API used to set the + token of a service instance From f496947f4a1564609e2ee8f28d5073921cb65204 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 21 Oct 2026 20:39:12 +1100 Subject: [PATCH 06/58] workflow: validate the service mode on macOS and Windows in CI - Add a macOS job that builds, vets and tests the repository, so the launchd manager, the plutil validation of the generated property list and the launchctl lifecycle run on a real macOS runner. - Run the service package tests on the Windows job, so the service control manager code is compiled and its tests can execute there. Co-Authored-By: opencode --- .github/workflows/ci.yml | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c1839cf1..0f53f51e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,6 +85,7 @@ jobs: - name: Test selected-cache path locality run: go test ./internal/server -run '^TestCacheSelectionRejectsNonLocalRepoBeforeJoining$' -count=1 -v + # The Windows desktop host is a second binary of HFDesk. Its defining # property is the PE subsystem of the produced file: the console host # stays a console subsystem executable and the desktop host is a GUI @@ -135,3 +136,36 @@ jobs: # skip themselves on a machine that cannot host them. - name: Test the desktop host lifecycle run: go test ./internal/desktop -count=1 -v + + # The service tests skip cleanly when the runner is not elevated, so the + # SCM registration itself is only exercised where the runner allows it. + - name: Test service package + run: go test ./internal/service -v + + # The launchd manager and the property list rendering are darwin-only code, + # so they need a macOS runner. The run executes the plist validation + # (plutil -lint) and the launchctl integration cycle where the runner + # allows it. + go-macos: + name: Go checks (macOS) + runs-on: macos-latest + + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: "1.25" + cache: true + + - name: Build + run: go build ./... + + - name: Vet + run: go vet ./... + + - name: Test + run: go test ./... + From 6c5c8498d59511f5fcd245ae1a0f0d7b79328db0 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 21 Oct 2026 21:49:25 +1100 Subject: [PATCH 07/58] fix(service): let the config file of an instance own its destinations - The cache root and the flat output directory of a service instance now resolve from the config file the instance owns, with the values recorded in the registration as the fallback. The settings API of the instance writes that file, so a change of the cache directory is honoured by the next start instead of being saved and then ignored. - The hub directory follows the cache root when the config file defines it, and stays the registered one otherwise. - Pin the cache environment from the effective values, after the configuration was loaded and before the server captures the cache identity of the process. - Create the state and the config directory owner-only: they hold the job list, the download history, the mirror targets and the credentials of the instance. - Drop the unused InstallOptions fields and export the loader of a config file at an explicit path. Co-Authored-By: opencode --- internal/server/config.go | 27 +- internal/server/state.go | 11 +- internal/service/host.go | 146 +++++++++-- internal/service/host_test.go | 436 +++++++++++++++++++++++++++++++ internal/service/service.go | 25 +- internal/service/service_test.go | 141 +++++++++- pkg/hfdownloader/targets.go | 4 + 7 files changed, 737 insertions(+), 53 deletions(-) diff --git a/internal/server/config.go b/internal/server/config.go index f7b0ce05..c0a92bb8 100644 --- a/internal/server/config.go +++ b/internal/server/config.go @@ -93,12 +93,26 @@ func ConfigPath() string { // LoadConfigFile loads configuration from the config file. // Returns empty config if file doesn't exist (not an error). func LoadConfigFile() (*ConfigFile, error) { - configMu.Lock() - defer configMu.Unlock() return loadConfigFile(ConfigPath()) } +// loadConfigFile loads the configuration from the given path under the +// config lock. The callers that already hold the lock read the file with +// LoadConfigFileAt directly. func loadConfigFile(path string) (*ConfigFile, error) { + configMu.Lock() + defer configMu.Unlock() + return LoadConfigFileAt(path) +} + +// LoadConfigFileAt loads the configuration from the given path. It returns +// an empty configuration when the file does not exist. +// +// The load does not take the config lock: the file is written atomically (a +// temporary file and a rename), so a reader sees either the previous or the +// new file. A caller outside the package therefore reads the config file of +// an instance without taking the lock of the writer. +func LoadConfigFileAt(path string) (*ConfigFile, error) { if path == "" { return &ConfigFile{}, nil } @@ -148,7 +162,7 @@ func saveSettingsConfig(cfg *ConfigFile, tokenWrite *string) error { configMu.Lock() defer configMu.Unlock() path := ConfigPath() - stored, err := loadConfigFile(path) + stored, err := LoadConfigFileAt(path) if err != nil { return err } @@ -168,9 +182,12 @@ func saveConfigFile(path string, cfg *ConfigFile) error { return errors.New("cannot save a redacted token as a credential") } - // Ensure config directory exists + // Ensure the config directory exists. It is owner-only: the config + // directory of an instance holds the job list, the download history, + // the mirror targets and the credentials of the instance, so nobody + // else has to be able to read it. configDir := filepath.Dir(path) - if err := os.MkdirAll(configDir, 0o755); err != nil { + if err := os.MkdirAll(configDir, 0o700); err != nil { return err } diff --git a/internal/server/state.go b/internal/server/state.go index 56c266c8..470def48 100644 --- a/internal/server/state.go +++ b/internal/server/state.go @@ -49,7 +49,10 @@ func saveJobsStateLocked(path string, jobs []*Job) error { if err != nil { return err } - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + // The state directory is owner-only: it holds the job list, the download + // history and the mirror targets of the instance, so nobody else has to + // be able to read it. + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { return err } return replaceJobsStateFile(path, data, defaultStateFileOps{}) @@ -245,8 +248,10 @@ func AppendHistory(job *Job) error { return err } // The state directory comes from the effective runtime path model, so a - // supervised instance writes its history into its own state root. - if err := os.MkdirAll(runtimepaths.Current().StateDir, 0o755); err != nil { + // supervised instance writes its history into its own state root. The + // directory is owner-only: it holds the job list, the download history + // and the mirror targets of the instance. + if err := os.MkdirAll(runtimepaths.Current().StateDir, 0o700); err != nil { return err } return os.WriteFile(HistoryPath(), data, 0o644) diff --git a/internal/service/host.go b/internal/service/host.go index cbf591f9..7aeeb660 100644 --- a/internal/service/host.go +++ b/internal/service/host.go @@ -24,14 +24,18 @@ type RunOptions struct { Paths runtimepaths.Paths // Listen is the host:port the instance binds. Empty means DefaultListen. Listen string - // CacheDir is the absolute Hugging Face cache root. Empty keeps the - // default resolution. + // CacheDir is the absolute Hugging Face cache root of the registration. + // It is the fallback: the cache-dir of the config file of the instance + // wins over it. Empty keeps the default resolution. CacheDir string - // HubDir is the absolute Hugging Face hub cache directory. Empty keeps - // the default resolution. + // HubDir is the absolute Hugging Face hub cache directory of the + // registration. It is the fallback: a config file of the instance that + // defines a cache root owns the hub directory as well. Empty keeps the + // default resolution. HubDir string - // LocalDir is the absolute flat output directory. Empty means the - // Hugging Face cache layout is used. + // LocalDir is the absolute flat output directory of the registration. + // It is the fallback: the local-dir of the config file of the instance + // wins over it. Empty means the Hugging Face cache layout is used. LocalDir string } @@ -45,23 +49,6 @@ func Run(ctx context.Context, opts RunOptions) error { // place. runtimepaths.Install(opts.Paths) - // Pin the cache environment. A supervisor hands over whatever - // environment it happens to have (a login shell, a system-wide - // environment file, a stale export), so the effective cache identity - // would otherwise depend on the machine instead of on the installation. - // The values of the installation win; an option that is not set keeps the - // environment/default resolution. - if opts.CacheDir != "" { - if err := os.Setenv("HF_HOME", opts.CacheDir); err != nil { - return fmt.Errorf("service: pin cache root: %w", err) - } - } - if opts.HubDir != "" { - if err := os.Setenv("HF_HUB_CACHE", opts.HubDir); err != nil { - return fmt.Errorf("service: pin hub cache: %w", err) - } - } - // The process log goes to the supervisor (systemd journal, launchd log // files) and into the bounded rotating log file of the instance. sink, err := logsink.Open(opts.Paths.LogDir) @@ -83,8 +70,10 @@ func Run(ctx context.Context, opts RunOptions) error { return err } - // The credentials of an instance come from its own config file, never - // from the environment the supervisor handed over. + // The configuration of the instance resolves its destinations and pins + // the cache environment, so it has to run before the server is created: + // the server captures the cache identity of the process when it is + // built. cfg, err := buildConfig(opts, host, port) if err != nil { return err @@ -95,7 +84,29 @@ func Run(ctx context.Context, opts RunOptions) error { return server.New(cfg).ListenAndServe(ctx) } +// destinations are the storage locations of a service instance. +type destinations struct { + // CacheDir is the Hugging Face cache root of the instance. + CacheDir string + // HubDir is the Hugging Face hub cache directory of the instance. + HubDir string + // LocalDir is the flat output directory of the instance. + LocalDir string +} + // buildConfig assembles the server configuration of a service instance. +// +// The destinations of an instance follow the precedence of a service +// instance: the config file of the instance wins over the registration. +// The user changes the cache directory and the output directory in the +// settings of the instance, and the next start of the instance has to use +// the changed value. The registration is the fallback for a destination the +// config file does not define. +// +// The cache environment is pinned from the effective values after the +// configuration was loaded: the server reads HF_HOME and HF_HUB_CACHE when +// it captures the cache identity of the process, and neither variable is +// read by the config file. func buildConfig(opts RunOptions, host string, port int) (server.Config, error) { if opts.Paths.StateDir == "" || !filepath.IsAbs(opts.Paths.StateDir) { return server.Config{}, fmt.Errorf("service: state dir %q must be an absolute path", opts.Paths.StateDir) @@ -109,13 +120,18 @@ func buildConfig(opts RunOptions, host string, port int) (server.Config, error) return server.Config{}, fmt.Errorf("service: drop environment token: %w", err) } + dest, err := effectiveDestinations(opts) + if err != nil { + return server.Config{}, err + } + cfg := server.DefaultConfig() // The server composes the bind address as ":", so an IPv6 // host keeps its brackets. cfg.Addr = listenHost(host) cfg.Port = port - cfg.CacheDir = opts.CacheDir - cfg.LocalDir = opts.LocalDir + cfg.CacheDir = dest.CacheDir + cfg.LocalDir = dest.LocalDir // The interactive defaults are relative ("./Models"), which would resolve // against the working directory the supervisor happened to choose. A // service instance keeps its output inside its own state root. @@ -127,9 +143,85 @@ func buildConfig(opts RunOptions, host string, port int) (server.Config, error) if err := server.ApplyConfigToServer(&cfg); err != nil { return server.Config{}, fmt.Errorf("service: load config %q: %w", opts.Paths.ConfigFile, err) } + + // The cache environment is pinned from the effective values: the + // configuration of the instance is loaded, so these are the values the + // instance really uses. + if err := pinCacheEnvironment(dest); err != nil { + return server.Config{}, err + } return cfg, nil } +// effectiveDestinations resolves the storage locations of a service instance. +// +// The config file of the instance wins over the registration: the user +// changes the cache directory and the output directory in the settings of +// the instance, and the instance has to use the changed value after the +// next start. The registration is the fallback for a destination the config +// file does not define. +// +// The hub directory follows the cache root. A config file that defines the +// cache root owns the whole cache layout of the instance, so the hub +// directory is the "hub" child of that root. Without a cache root from the +// config file the registration keeps its hub directory, and a registration +// without one falls back to the "hub" child of the effective cache root. +func effectiveDestinations(opts RunOptions) (destinations, error) { + fileCfg, err := server.LoadConfigFileAt(opts.Paths.ConfigFile) + if err != nil { + return destinations{}, fmt.Errorf("service: load config %q: %w", opts.Paths.ConfigFile, err) + } + + dest := destinations{ + CacheDir: opts.CacheDir, + LocalDir: opts.LocalDir, + } + if fileCfg.CacheDir != "" { + dest.CacheDir = fileCfg.CacheDir + } + if fileCfg.LocalDir != "" { + dest.LocalDir = fileCfg.LocalDir + } + + switch { + case fileCfg.CacheDir != "": + dest.HubDir = filepath.Join(dest.CacheDir, "hub") + case opts.HubDir != "": + dest.HubDir = opts.HubDir + case dest.CacheDir != "": + dest.HubDir = filepath.Join(dest.CacheDir, "hub") + } + // An instance without a cache root and without a registered hub + // directory keeps the default resolution: there is no root to derive a + // hub directory from. + return dest, nil +} + +// pinCacheEnvironment pins the cache identity of the process. +// +// A supervisor hands over whatever environment it happens to have (a login +// shell, a system-wide environment file, a stale export), so the effective +// cache identity would otherwise depend on the machine instead of on the +// installation. The values of the installation win; a destination that is +// not set keeps the environment/default resolution. +// +// Only the two cache variables are pinned. The token of the environment is +// dropped instead: the config file of the instance is the only source of +// its credential. +func pinCacheEnvironment(dest destinations) error { + if dest.CacheDir != "" { + if err := os.Setenv("HF_HOME", dest.CacheDir); err != nil { + return fmt.Errorf("service: pin cache root: %w", err) + } + } + if dest.HubDir != "" { + if err := os.Setenv("HF_HUB_CACHE", dest.HubDir); err != nil { + return fmt.Errorf("service: pin hub cache: %w", err) + } + } + return nil +} + // listenHost returns the host in the form the server composes with the port // into a socket address. func listenHost(host string) string { diff --git a/internal/service/host_test.go b/internal/service/host_test.go index e9ff62db..a01395ef 100644 --- a/internal/service/host_test.go +++ b/internal/service/host_test.go @@ -12,12 +12,14 @@ import ( "net/http" "os" "path/filepath" + "runtime" "strconv" "strings" "testing" "time" "github.com/bashrusakh/hfdesk/internal/runtimepaths" + "github.com/bashrusakh/hfdesk/internal/server" ) func TestRunInstallsRuntimeModelAndStopsOnCancel(t *testing.T) { @@ -178,6 +180,371 @@ func TestBuildConfigUsesTheServiceOwnedPaths(t *testing.T) { } } +// serviceInstance writes the config file of a service instance with the given +// fields and installs the runtime path model that owns it. The model is +// restored when the test ends. +func serviceInstance(t *testing.T, fields map[string]string) runtimepaths.Paths { + t.Helper() + root := t.TempDir() + model := runtimepaths.Paths{ + ConfigFile: filepath.Join(root, "etc", "hfdesk.json"), + StateDir: filepath.Join(root, "var"), + LogDir: filepath.Join(root, "var", "logs"), + } + if err := os.MkdirAll(filepath.Dir(model.ConfigFile), 0o700); err != nil { + t.Fatal(err) + } + data, err := json.Marshal(fields) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(model.ConfigFile, data, 0o600); err != nil { + t.Fatal(err) + } + runtimepaths.Install(model) + t.Cleanup(runtimepaths.Reset) + return model +} + +// TestBuildConfigPrefersTheConfigFileOfTheInstance checks the coherence +// rule of a service instance: the config file of the instance wins over the +// registration. +// +// The user changes the cache directory and the output directory in the +// settings of the instance, so the next start of the instance has to use the +// changed value. The registration is only the fallback. +func TestBuildConfigPrefersTheConfigFileOfTheInstance(t *testing.T) { + model := serviceInstance(t, map[string]string{ + "cache-dir": "/from/config/file", + "local-dir": "/from/config/file/models", + }) + t.Setenv("HF_HOME", "/stale/home") + t.Setenv("HF_HUB_CACHE", "/stale/hub") + + cfg, err := buildConfig(RunOptions{ + Paths: model, + CacheDir: "/registered/cache", + HubDir: "/registered/cache/hub", + LocalDir: "/registered/models", + }, "127.0.0.1", 8080) + if err != nil { + t.Fatalf("buildConfig() = %v, want nil", err) + } + + if cfg.CacheDir != "/from/config/file" { + t.Errorf("CacheDir = %q, want the cache dir of the config file %q", cfg.CacheDir, "/from/config/file") + } + if cfg.LocalDir != "/from/config/file/models" { + t.Errorf("LocalDir = %q, want the local dir of the config file %q", cfg.LocalDir, "/from/config/file/models") + } + if got := os.Getenv("HF_HOME"); got != "/from/config/file" { + t.Errorf("HF_HOME = %q, want the cache dir of the config file %q", got, "/from/config/file") + } + if got, want := os.Getenv("HF_HUB_CACHE"), filepath.Join("/from/config/file", "hub"); got != want { + t.Errorf("HF_HUB_CACHE = %q, want %q", got, want) + } +} + +// TestBuildConfigKeepsTheRegistrationWithoutAConfigFileValue checks the +// fallback of the coherence rule: an instance whose config file does not +// define a directory uses the directory of its registration. +func TestBuildConfigKeepsTheRegistrationWithoutAConfigFileValue(t *testing.T) { + model := serviceInstance(t, map[string]string{}) + t.Setenv("HF_HOME", "/stale/home") + t.Setenv("HF_HUB_CACHE", "/stale/hub") + + cfg, err := buildConfig(RunOptions{ + Paths: model, + CacheDir: "/registered/cache", + HubDir: "/registered/cache/hub", + LocalDir: "/registered/models", + }, "127.0.0.1", 8080) + if err != nil { + t.Fatalf("buildConfig() = %v, want nil", err) + } + + if cfg.CacheDir != "/registered/cache" { + t.Errorf("CacheDir = %q, want the registered cache dir %q", cfg.CacheDir, "/registered/cache") + } + if cfg.LocalDir != "/registered/models" { + t.Errorf("LocalDir = %q, want the registered local dir %q", cfg.LocalDir, "/registered/models") + } + if got := os.Getenv("HF_HOME"); got != "/registered/cache" { + t.Errorf("HF_HOME = %q, want the registered cache dir %q", got, "/registered/cache") + } + if got := os.Getenv("HF_HUB_CACHE"); got != "/registered/cache/hub" { + t.Errorf("HF_HUB_CACHE = %q, want the registered hub dir %q", got, "/registered/cache/hub") + } +} + +// TestBuildConfigDerivesTheHubDir checks the hub rule of an instance. +// +// A config file that defines the cache root owns the whole cache layout, so +// the hub directory is the "hub" child of that root. Without a cache root +// from the config file the registration keeps its hub directory, and a +// registration without one falls back to the "hub" child of the effective +// cache root. +func TestBuildConfigDerivesTheHubDir(t *testing.T) { + t.Run("the config file owns the hub directory", func(t *testing.T) { + model := serviceInstance(t, map[string]string{"cache-dir": "/from/config/file"}) + t.Setenv("HF_HOME", "/stale/home") + t.Setenv("HF_HUB_CACHE", "/stale/hub") + + if _, err := buildConfig(RunOptions{ + Paths: model, + CacheDir: "/registered/cache", + HubDir: "/registered/cache/hub", + }, "127.0.0.1", 8080); err != nil { + t.Fatalf("buildConfig() = %v, want nil", err) + } + if got, want := os.Getenv("HF_HUB_CACHE"), filepath.Join("/from/config/file", "hub"); got != want { + t.Errorf("HF_HUB_CACHE = %q, want %q", got, want) + } + }) + + t.Run("the registration keeps its hub directory", func(t *testing.T) { + model := serviceInstance(t, map[string]string{}) + t.Setenv("HF_HOME", "/stale/home") + t.Setenv("HF_HUB_CACHE", "/stale/hub") + + if _, err := buildConfig(RunOptions{ + Paths: model, + CacheDir: "/registered/cache", + HubDir: "/registered/hub", + }, "127.0.0.1", 8080); err != nil { + t.Fatalf("buildConfig() = %v, want nil", err) + } + if got := os.Getenv("HF_HUB_CACHE"); got != "/registered/hub" { + t.Errorf("HF_HUB_CACHE = %q, want the registered hub dir %q", got, "/registered/hub") + } + }) + + t.Run("the cache root owns the hub directory", func(t *testing.T) { + model := serviceInstance(t, map[string]string{}) + t.Setenv("HF_HOME", "/stale/home") + t.Setenv("HF_HUB_CACHE", "/stale/hub") + + if _, err := buildConfig(RunOptions{ + Paths: model, + CacheDir: "/registered/cache", + }, "127.0.0.1", 8080); err != nil { + t.Fatalf("buildConfig() = %v, want nil", err) + } + if got, want := os.Getenv("HF_HUB_CACHE"), filepath.Join("/registered/cache", "hub"); got != want { + t.Errorf("HF_HUB_CACHE = %q, want %q", got, want) + } + }) +} + +// TestBuildConfigAppliesTheLocalDirRule checks the output directory of an +// instance: the local-dir of the config file of the instance wins over the +// registered output directory. +func TestBuildConfigAppliesTheLocalDirRule(t *testing.T) { + t.Run("the config file wins", func(t *testing.T) { + model := serviceInstance(t, map[string]string{"local-dir": "/from/config/file/models"}) + t.Setenv("HF_HOME", "/stale/home") + t.Setenv("HF_HUB_CACHE", "/stale/hub") + + cfg, err := buildConfig(RunOptions{ + Paths: model, + CacheDir: "/registered/cache", + LocalDir: "/registered/models", + }, "127.0.0.1", 8080) + if err != nil { + t.Fatalf("buildConfig() = %v, want nil", err) + } + if cfg.LocalDir != "/from/config/file/models" { + t.Errorf("LocalDir = %q, want the local dir of the config file %q", cfg.LocalDir, "/from/config/file/models") + } + }) + + t.Run("the registration is the fallback", func(t *testing.T) { + model := serviceInstance(t, map[string]string{}) + t.Setenv("HF_HOME", "/stale/home") + t.Setenv("HF_HUB_CACHE", "/stale/hub") + + cfg, err := buildConfig(RunOptions{ + Paths: model, + CacheDir: "/registered/cache", + LocalDir: "/registered/models", + }, "127.0.0.1", 8080) + if err != nil { + t.Fatalf("buildConfig() = %v, want nil", err) + } + if cfg.LocalDir != "/registered/models" { + t.Errorf("LocalDir = %q, want the registered local dir %q", cfg.LocalDir, "/registered/models") + } + }) +} + +// TestBuildConfigPinsTheCacheEnvironment checks that the cache environment +// of the process carries the effective values of the instance. +// +// A supervisor hands over whatever environment it happens to have, so the +// pinning has to replace it with the values of the instance. The pinning +// happens after the configuration was loaded, so the values are the ones +// the instance really uses. +func TestBuildConfigPinsTheCacheEnvironment(t *testing.T) { + model := serviceInstance(t, map[string]string{"cache-dir": "/from/config/file"}) + t.Setenv("HF_HOME", "/stale/home") + t.Setenv("HF_HUB_CACHE", "/stale/hub") + + if _, err := buildConfig(RunOptions{ + Paths: model, + CacheDir: "/registered/cache", + HubDir: "/registered/cache/hub", + }, "127.0.0.1", 8080); err != nil { + t.Fatalf("buildConfig() = %v, want nil", err) + } + + if got := os.Getenv("HF_HOME"); got != "/from/config/file" { + t.Errorf("HF_HOME = %q, want the effective cache root %q", got, "/from/config/file") + } + if got, want := os.Getenv("HF_HUB_CACHE"), filepath.Join("/from/config/file", "hub"); got != want { + t.Errorf("HF_HUB_CACHE = %q, want the effective hub dir %q", got, want) + } +} + +// TestRunUsesTheConfigFileOfTheInstance checks the coherence rule of a +// running instance. +// +// The config file of the instance wins over the registration, so the cache +// root, the hub directory and the output directory the instance reports are +// the ones the user changed in the settings of the instance. +func TestRunUsesTheConfigFileOfTheInstance(t *testing.T) { + root := t.TempDir() + model := runtimepaths.Paths{ + ConfigFile: filepath.Join(root, "etc", "hfdesk.json"), + StateDir: filepath.Join(root, "var"), + LogDir: filepath.Join(root, "var", "logs"), + } + if err := os.MkdirAll(filepath.Dir(model.ConfigFile), 0o700); err != nil { + t.Fatal(err) + } + cacheRoot := filepath.Join(root, "from-config-file") + localDir := filepath.Join(root, "from-config-file", "models") + data, err := json.Marshal(map[string]string{ + "cache-dir": cacheRoot, + "local-dir": localDir, + }) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(model.ConfigFile, data, 0o600); err != nil { + t.Fatal(err) + } + + // The supervisor handed over a stale environment and a registration + // with other directories: neither of them may win over the config file. + t.Setenv("HF_HOME", filepath.Join(root, "stale-home")) + t.Setenv("HF_HUB_CACHE", filepath.Join(root, "stale-hub")) + t.Cleanup(runtimepaths.Reset) + + port := freePort(t) + addr := "127.0.0.1:" + strconv.Itoa(port) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + done := make(chan error, 1) + go func() { + done <- Run(ctx, RunOptions{ + Paths: model, + Listen: addr, + CacheDir: filepath.Join(root, "registered", "cache"), + HubDir: filepath.Join(root, "registered", "cache", "hub"), + LocalDir: filepath.Join(root, "registered", "models"), + }) + }() + + settings, settingsErr := waitForSettings(t, addr) + cancel() + if err := <-done; err != nil { + t.Fatalf("Run() = %v, want nil", err) + } + if settingsErr != nil { + t.Fatalf("the settings of the instance: %v", settingsErr) + } + + if settings.CacheDir != cacheRoot { + t.Errorf("the cache dir of the instance = %q, want the cache dir of the config file %q", settings.CacheDir, cacheRoot) + } + if settings.ConfiguredCacheDir != cacheRoot { + t.Errorf("the configured cache dir of the instance = %q, want the cache dir of the config file %q", settings.ConfiguredCacheDir, cacheRoot) + } + if want := filepath.Join(cacheRoot, "hub"); settings.EffectiveHubDir != want { + t.Errorf("the effective hub dir of the instance = %q, want %q", settings.EffectiveHubDir, want) + } + if settings.LocalDir != localDir { + t.Errorf("the local dir of the instance = %q, want the local dir of the config file %q", settings.LocalDir, localDir) + } +} + +// waitForSettings reads the settings a running instance reports. +func waitForSettings(t *testing.T, addr string) (server.SettingsResponse, error) { + t.Helper() + client := &http.Client{Timeout: 2 * time.Second} + url := "http://" + addr + "/api/settings" + var lastErr error + for deadline := time.Now().Add(10 * time.Second); time.Now().Before(deadline); { + body, status, err := get(client, url) + switch { + case err != nil: + lastErr = err + case status != http.StatusOK: + lastErr = fmt.Errorf("status %d: %s", status, body) + default: + var settings server.SettingsResponse + if err := json.Unmarshal([]byte(body), &settings); err != nil { + return settings, fmt.Errorf("decode the settings: %w", err) + } + return settings, nil + } + time.Sleep(20 * time.Millisecond) + } + return server.SettingsResponse{}, fmt.Errorf("the settings endpoint never answered: %v", lastErr) +} + +// TestServiceDirectoriesAreOwnerOnly checks that the state directory and the +// config directory of an instance are created owner-only. +// +// They hold the job list, the download history, the mirror targets and the +// credentials of the instance, so a directory that is missing at the start +// of an instance must not be recreated world-readable. +func TestServiceDirectoriesAreOwnerOnly(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows mode bits are emulated and do not represent DACL permissions") + } + + root := t.TempDir() + model := runtimepaths.Paths{ + ConfigFile: filepath.Join(root, "etc", "hfdesk.json"), + StateDir: filepath.Join(root, "var"), + LogDir: filepath.Join(root, "var", "logs"), + } + runtimepaths.Install(model) + t.Cleanup(runtimepaths.Reset) + + if err := server.SaveConfigFile(&server.ConfigFile{CacheDir: "/from/config/file"}); err != nil { + t.Fatalf("SaveConfigFile() = %v, want nil", err) + } + if err := server.SaveJobsState(nil); err != nil { + t.Fatalf("SaveJobsState() = %v, want nil", err) + } + if err := server.AppendHistory(&server.Job{ID: "job", Status: server.JobStatusCompleted}); err != nil { + t.Fatalf("AppendHistory() = %v, want nil", err) + } + + for _, dir := range []string{filepath.Dir(model.ConfigFile), model.StateDir} { + info, err := os.Stat(dir) + if err != nil { + t.Fatalf("stat %s: %v", dir, err) + } + if got := info.Mode().Perm(); got != 0o700 { + t.Errorf("the mode of %s = %04o, want 0700", dir, got) + } + } +} + func TestBuildConfigKeepsIPv6HostsBracketed(t *testing.T) { root := t.TempDir() model := runtimepaths.Paths{ @@ -247,6 +614,75 @@ func TestBuildConfigTakesTheTokenFromTheConfigFile(t *testing.T) { } } +// TestServiceBindsLoopbackWhileTheInteractiveServerKeepsItsDefault checks +// the contrast the service mode is built on. +// +// The interactive server keeps its own default: it binds every interface, so +// the dashboard of an interactive run is reachable. A service instance binds +// loopback instead: a supervised instance has no user sitting in front of it +// and must not expose the dashboard to the network by accident. +func TestServiceBindsLoopbackWhileTheInteractiveServerKeepsItsDefault(t *testing.T) { + // The interactive default is intentionally unchanged by the service + // mode: an interactive run of the server still binds 0.0.0.0. + if got := server.DefaultConfig().Addr; got != "0.0.0.0" { + t.Errorf("server.DefaultConfig().Addr = %q, want %q", got, "0.0.0.0") + } + + // The default listen address of a service instance is loopback. + host, port, err := ParseListen(DefaultListen) + if err != nil { + t.Fatalf("ParseListen(%q) = %v, want nil", DefaultListen, err) + } + if host != "127.0.0.1" { + t.Errorf("the host of the default listen address %q = %q, want %q", DefaultListen, host, "127.0.0.1") + } + if !IsLoopbackListen(DefaultListen) { + t.Errorf("the default listen address %q is not a loopback address", DefaultListen) + } + + root := t.TempDir() + model := runtimepaths.Paths{ + ConfigFile: filepath.Join(root, "etc", "hfdesk.json"), + StateDir: filepath.Join(root, "var"), + LogDir: filepath.Join(root, "var", "logs"), + } + runtimepaths.Install(model) + t.Cleanup(runtimepaths.Reset) + + // The configuration a service instance builds binds loopback, even + // though the interactive default of the server binds every interface. + cfg, err := buildConfig(RunOptions{Paths: model}, host, port) + if err != nil { + t.Fatalf("buildConfig() = %v, want nil", err) + } + if cfg.Addr != "127.0.0.1" { + t.Errorf("the address of the service configuration = %q, want %q", cfg.Addr, "127.0.0.1") + } + if cfg.Port != port { + t.Errorf("the port of the service configuration = %d, want %d", cfg.Port, port) + } + + // The registered argument vector carries the loopback address, so the + // instance the supervisor starts binds loopback as well. + if got := listenArg(""); got != DefaultListen { + t.Errorf("listenArg(\"\") = %q, want the default listen address %q", got, DefaultListen) + } + args := RunArgs(InstallOptions{Paths: model}) + if got := argumentValue(args, "--listen"); got != DefaultListen { + t.Errorf("the listen address of the argument vector = %q, want %q", got, DefaultListen) + } +} + +// argumentValue returns the value of a flag of an argument vector. +func argumentValue(args []string, flag string) string { + for i := 0; i+1 < len(args); i++ { + if args[i] == flag { + return args[i+1] + } + } + return "" +} + // guiLaunchMarkers are the markers of a browser or a tray launcher. No file // of the service package may contain one of them: a supervised instance has // no desktop session to interact with. diff --git a/internal/service/service.go b/internal/service/service.go index a5a4e775..42e13a98 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -27,6 +27,7 @@ import ( "strings" "github.com/bashrusakh/hfdesk/internal/runtimepaths" + "github.com/bashrusakh/hfdesk/internal/server" "github.com/bashrusakh/hfdesk/pkg/hfdownloader" ) @@ -99,6 +100,10 @@ type Status struct { Listen string // Paths are the absolute runtime paths of the instance. Paths runtimepaths.Paths + // CacheDir is the Hugging Face cache root the instance uses: the cache + // directory of the instance's own config file when it defines one, + // otherwise the cache directory recorded in the registration. + CacheDir string // Detail is a human-readable detail, e.g. the last error. Detail string } @@ -119,10 +124,6 @@ type InstallOptions struct { HubDir string // LocalDir is the absolute flat output dir. It may be empty. LocalDir string - // User is the target user name of a user scope installation. - User string - // UID is the launchd gui/ target of a user scope installation. - UID string } // Manager drives the platform supervisor. The common layer owns the command @@ -418,6 +419,22 @@ func DefaultCacheDirs(scope Scope, paths runtimepaths.Paths) (string, string) { return root, filepath.Join(root, "hub") } +// EffectiveCacheDir returns the Hugging Face cache root of an instance: the +// cache-dir of the instance's own config file when it defines one, +// otherwise the registered cache directory. A config file that cannot be +// read falls back to the registered value. +// +// The config file of an instance wins over its registration: the user +// changes the cache directory in the settings of the instance, and the +// instance has to use the changed value after the next start. +func EffectiveCacheDir(configFile, registered string) string { + fileCfg, err := server.LoadConfigFileAt(configFile) + if err != nil || fileCfg == nil || fileCfg.CacheDir == "" { + return registered + } + return fileCfg.CacheDir +} + // absolutePath returns the absolute form of path. A path that cannot be // resolved (the working directory is unavailable) is returned unchanged, so // the caller never receives an empty path. diff --git a/internal/service/service_test.go b/internal/service/service_test.go index ce877dad..12fabe7c 100644 --- a/internal/service/service_test.go +++ b/internal/service/service_test.go @@ -38,7 +38,17 @@ func fullInstallOptions(t *testing.T) InstallOptions { func TestRunArgsMatchesCanonicalArgv(t *testing.T) { got := RunArgs(fullInstallOptions(t)) - want := []string{ + want := canonicalRunArgs() + if !reflect.DeepEqual(got, want) { + t.Errorf("RunArgs() =\n%v\nwant\n%v", got, want) + } +} + +// canonicalRunArgs is the exact argument vector of the installation of +// fullInstallOptions: the paths of the instance and its bind address, and +// nothing else. +func canonicalRunArgs() []string { + return []string{ "service", "run", "--config", "/opt/hfdesk/etc/hfdesk.json", @@ -49,9 +59,6 @@ func TestRunArgsMatchesCanonicalArgv(t *testing.T) { "--hub-dir", "/opt/hfdesk/cache/hub", "--local-dir", "/opt/hfdesk/models", } - if !reflect.DeepEqual(got, want) { - t.Errorf("RunArgs() =\n%v\nwant\n%v", got, want) - } } func TestRunArgsOmitsUnsetDirectories(t *testing.T) { @@ -92,21 +99,55 @@ func TestRunArgsUsesDefaultListenWhenUnset(t *testing.T) { } } -func TestRunArgsCarriesNoCredential(t *testing.T) { - const secret = "hf_SECRETTOKEN_zzz" +// installOptionStringFields are the string fields of InstallOptions. +// +// Every one of them carries the scope, a path or the bind address of an +// instance. The type carries no credential field at all, so a registration +// can never carry one. +var installOptionStringFields = map[string]bool{ + "Scope": true, + "Executable": true, + "Listen": true, + "CacheDir": true, + "HubDir": true, + "LocalDir": true, +} + +// TestInstallOptionsCarryNoCredentialField checks that the options of an +// installation carry no credential. +// +// The check reads the actual fields of the type instead of filling the +// fields that look like a credential: a type without a credential field has +// nothing to fill, so a fill would prove nothing. A credential field that +// is added later fails this test. +func TestInstallOptionsCarryNoCredentialField(t *testing.T) { + typ := reflect.TypeOf(InstallOptions{}) + for i := 0; i < typ.NumField(); i++ { + field := typ.Field(i) + if field.Type.Kind() != reflect.String { + continue + } + if !installOptionStringFields[field.Name] { + t.Errorf("InstallOptions.%s is a string field that is neither the scope, a path nor the bind address: the type must carry no credential field", field.Name) + } + } +} +func TestRunArgsCarriesNoCredential(t *testing.T) { opts := fullInstallOptions(t) - // InstallOptions has no credential field today. Every field that looks - // like one is filled with the sentinel, so the check keeps its meaning - // when such a field is added. - setCredentialFields(&opts, secret) + // The argument vector holds the paths of the instance and its bind + // address and nothing else. The exact vector is asserted, so a value + // that is not part of it cannot reach a registration. args := RunArgs(opts) - for _, arg := range args { - if strings.Contains(arg, secret) { - t.Errorf("RunArgs() leaks a credential: %v", args) - } + want := canonicalRunArgs() + if !reflect.DeepEqual(args, want) { + t.Errorf("RunArgs() =\n%v\nwant\n%v", args, want) } + + // The options carry no credential field (see + // TestInstallOptionsCarryNoCredentialField), so no value of the vector + // can be a credential in the first place. for _, arg := range args { if looksLikeCredential(arg) { t.Errorf("RunArgs() contains the credential-looking argument %q: %v", arg, args) @@ -447,6 +488,73 @@ func containsValue(args []string, value string) bool { return false } +// TestEffectiveCacheDir checks the cache root of an instance: the cache-dir +// of the config file of the instance wins over the registered cache +// directory, and the registration is the fallback. +func TestEffectiveCacheDir(t *testing.T) { + root := t.TempDir() + etc := filepath.Join(root, "etc") + if err := os.MkdirAll(etc, 0o700); err != nil { + t.Fatal(err) + } + + withCacheDir := filepath.Join(etc, "with-cache.json") + if err := os.WriteFile(withCacheDir, []byte(`{"cache-dir":"/from/config/file"}`), 0o600); err != nil { + t.Fatal(err) + } + + withoutCacheDir := filepath.Join(etc, "without-cache.json") + if err := os.WriteFile(withoutCacheDir, []byte(`{"local-dir":"/from/config/file/models"}`), 0o600); err != nil { + t.Fatal(err) + } + + // A path that cannot be read: a directory is not a config file. + unreadable := filepath.Join(etc, "unreadable.json") + if err := os.Mkdir(unreadable, 0o700); err != nil { + t.Fatal(err) + } + + cases := []struct { + name string + configFile string + registered string + want string + }{ + { + name: "the config file of the instance wins", + configFile: withCacheDir, + registered: "/registered/cache", + want: "/from/config/file", + }, + { + name: "the registration is the fallback", + configFile: withoutCacheDir, + registered: "/registered/cache", + want: "/registered/cache", + }, + { + name: "a missing config file falls back", + configFile: filepath.Join(etc, "missing.json"), + registered: "/registered/cache", + want: "/registered/cache", + }, + { + name: "an unreadable config file falls back", + configFile: unreadable, + registered: "/registered/cache", + want: "/registered/cache", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := EffectiveCacheDir(tc.configFile, tc.registered) + if got != tc.want { + t.Errorf("EffectiveCacheDir(%q, %q) = %q, want %q", tc.configFile, tc.registered, got, tc.want) + } + }) + } +} + // credentialMarkers are the fragments a credential carries. var credentialMarkers = []string{"hf_", "token", "password", "passwd", "secret", "credential", "bearer"} @@ -463,6 +571,11 @@ func looksLikeCredential(value string) bool { // setCredentialFields fills every credential-looking string field of opts // with the sentinel. +// +// The options of an installation carry no credential field today, so the +// fill has nothing to fill. The registrations of the platform managers use +// the helper so their check keeps its meaning when such a field is added. +// The service level test asserts that the type carries none. func setCredentialFields(opts *InstallOptions, sentinel string) { value := reflect.ValueOf(opts).Elem() typ := value.Type() diff --git a/pkg/hfdownloader/targets.go b/pkg/hfdownloader/targets.go index 1002678a..43996e91 100644 --- a/pkg/hfdownloader/targets.go +++ b/pkg/hfdownloader/targets.go @@ -23,6 +23,10 @@ type TargetsConfig struct { } // DefaultTargetsPath returns the default path for targets config. +// +// The server resolves the targets file through its runtime path model and +// passes the path explicitly, so this function is the fallback for the +// callers that do not resolve a path themselves. func DefaultTargetsPath() string { if dir, err := os.UserConfigDir(); err == nil && dir != "" { return filepath.Join(dir, "HFDesk", "targets.yaml") From 7c5d725a41dd2ce81a3b39d6c677698a7a7cd01e Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 21 Oct 2026 21:49:25 +1100 Subject: [PATCH 08/58] fix(service): align the supervisor managers with the instance contract - Refuse a machine-wide installation that would adopt the state of the user who runs the install command, on Linux as well as on macOS, and run that check before any directory is created so a refused installation leaves nothing behind. - Require an elevated shell for a machine-wide installation and for a machine-wide removal on macOS, like the other platforms, and report that requirement clearly instead of a raw permission error. - Preflight an explicitly reused data, cache or output path on Windows: the path must be a directory the service identity can reach, and the installation fails with a clear permission error before the registration is written. The preflight only reads the descriptor and never changes the ACL of a reused tree. - Restrict the config directory, the state directory, the log directory and the config file of a Windows instance, so every directory the instance owns carries the descriptor of the service identity. - Report the effective cache root of the instance in the status of every platform, so the status describes the instance that runs. Co-Authored-By: opencode --- internal/service/manager_darwin.go | 99 ++++- internal/service/manager_darwin_test.go | 511 ++++++++++++++++++++++- internal/service/manager_linux.go | 72 ++++ internal/service/manager_linux_test.go | 266 +++++++++++- internal/service/manager_windows.go | 480 +++++++++++++++++++-- internal/service/manager_windows_test.go | 253 ++++++++++- 6 files changed, 1624 insertions(+), 57 deletions(-) diff --git a/internal/service/manager_darwin.go b/internal/service/manager_darwin.go index 660c6e2c..5d5f9d3d 100644 --- a/internal/service/manager_darwin.go +++ b/internal/service/manager_darwin.go @@ -37,6 +37,13 @@ import ( // state of a user: its home is the machine-level state root of the platform // and its paths are the machine-level paths. // +// The machine-wide scope needs an elevated shell: the property list of a +// LaunchDaemon is written into /Library/LaunchDaemons and the job is loaded +// into the system domain, and both belong to the machine. The manager never +// escalates privileges itself: it fails with a clear error when the process +// is not root. The user scope needs no elevation: a LaunchAgent belongs to +// the user who installs it. +// // The label of the registration is com.hfdesk.hfdesk. It is derived from the // service name ("hfdesk") and the reverse DNS prefix of the project, and it // is also the name of the property list file: launchd refuses to load a @@ -84,7 +91,8 @@ type launchdManager struct { // of the gui domain of a user scope instance. uid string // home is the home directory of the invoking user. It holds the - // property list of a user scope instance. + // property list of a user scope instance and it is the reference that + // keeps a machine-wide instance away from the state of a user. home string } @@ -155,6 +163,20 @@ func (m *launchdManager) Install(ctx context.Context, opts InstallOptions) error if err := ValidateInstallOptions(opts); err != nil { return err } + // A machine-wide instance must not adopt the state of the user who + // runs the install command. The check runs before the elevation + // requirement, before any directory is created and before the property + // list is written: a refused installation leaves nothing behind, and + // every platform reports the same reason for the same command line. + if err := m.checkSystemPaths(opts); err != nil { + return err + } + // The elevation requirement is checked before any directory is created: + // a permission error of the machine-wide directories is a much less + // readable answer than the real reason. + if err := m.requireElevated("an installation"); err != nil { + return err + } if err := PrepareDirs(opts); err != nil { return err } @@ -189,6 +211,13 @@ func (m *launchdManager) Install(ctx context.Context, opts InstallOptions) error // Only the registration is removed. The config file, the jobs, the history, // the logs, the models and the cache of the instance are left alone: an // uninstall removes the registration of the service, not its data. +// +// A machine-wide uninstallation needs an elevated shell: the LaunchDaemon is +// registered in /Library/LaunchDaemons and it is unloaded from the system +// domain, and both belong to the machine. The check runs after the existence +// of the registration, so a registration that is not there stays a clean +// success in every shell. A user scope instance belongs to the user who +// installed it and needs no elevation. func (m *launchdManager) Uninstall(ctx context.Context) error { path := m.plistPath() if _, err := os.Stat(path); err != nil { @@ -199,6 +228,13 @@ func (m *launchdManager) Uninstall(ctx context.Context) error { return fmt.Errorf("service: read the registration %q: %w", path, err) } + // The elevation requirement is checked before the job is unloaded and + // before the property list is removed: a permission error of the + // machine is a much less readable answer than the real reason. + if err := m.requireElevated("an uninstallation"); err != nil { + return err + } + // The job is unloaded before the property list is removed, so launchd // never keeps a registration whose file is gone. A job that is not // loaded any more is not an error: the registration is removed either @@ -327,6 +363,12 @@ func (m *launchdManager) Status(ctx context.Context) (Status, error) { status.Executable = registration.executable() status.Listen = registration.listen() status.Paths = registration.paths() + // The cache root of the instance is the cache directory of the + // instance's own config file when it defines one, and the cache + // directory of the registration otherwise. Both come from the + // registration on disk, so the status describes the instance and not + // the intent of the caller. + status.CacheDir = EffectiveCacheDir(status.Paths.ConfigFile, registration.cacheDir()) loaded, output, err := m.printState(ctx) if err != nil { @@ -372,12 +414,42 @@ func (m *launchdManager) preflight(opts InstallOptions) error { if info.Mode().Perm()&0o111 == 0 { return fmt.Errorf("service: executable %q is not executable", opts.Executable) } - if m.scope == ScopeSystem { - return m.checkSystemPaths(opts) - } return nil } +// requireElevated rejects a machine-wide operation that needs an elevated +// shell when the process does not have one. +// +// A LaunchDaemon is registered in /Library/LaunchDaemons and started by the +// system domain: both belong to the machine and need root. The manager never +// escalates privileges itself: it never calls sudo, su or osascript and it +// never asks the user for a password, so it refuses the operation with a +// readable reason instead of letting it fail with a permission error of a +// directory of the machine. +func (m *launchdManager) requireElevated(action string) error { + return m.requireElevatedFor(action, os.Geteuid()) +} + +// requireElevatedFor rejects a machine-wide operation when the given user id +// is not the one of an elevated shell. +// +// The user id is a parameter, so the decision can be driven from both sides +// without an elevated shell: the real check passes the effective user id of +// the process, and the test passes the ids of the two cases. +// +// A user scope instance belongs to the user who installs it: it is written +// into the LaunchAgents directory of that user and it is loaded into the gui +// domain of that user, so it never needs an elevated shell. +func (m *launchdManager) requireElevatedFor(action string, uid int) error { + if m.scope != ScopeSystem { + return nil + } + if uid == 0 { + return nil + } + return fmt.Errorf("service: %s in the system scope requires an elevated shell: the LaunchDaemon is registered in %s and started by the system domain, HFDesk never escalates privileges itself", action, launchDaemonsDir) +} + // checkSystemPaths rejects a machine-wide installation that would reuse the // state of a user. // @@ -388,9 +460,18 @@ func (m *launchdManager) preflight(opts InstallOptions) error { // trees are never touched by an installation: only the three directories of // the instance (the config directory, the state directory and the log // directory) are created, and nothing is ever chowned. +// +// A user scope instance is supposed to live inside the directories of its own +// user, so the guard does not touch it. func (m *launchdManager) checkSystemPaths(opts InstallOptions) error { + if m.scope != ScopeSystem { + return nil + } home := m.home if home == "" { + // The home directory of the invoking user is unknown: there is + // nothing to compare the paths of the installation with, and an + // unknown home is not a failed installation. return nil } for _, p := range []struct { @@ -1103,6 +1184,16 @@ func (r plistRegistration) listen() string { return r.argumentValue("--listen") } +// cacheDir returns the Hugging Face cache root recorded in the +// registration. +// +// The value is empty when the registration carries no cache directory: the +// instance then uses the cache root of its own config file, or the default +// cache root of the platform. +func (r plistRegistration) cacheDir() string { + return r.argumentValue("--cache-dir") +} + // paths returns the runtime path model of the registration. func (r plistRegistration) paths() runtimepaths.Paths { return runtimepaths.Paths{ diff --git a/internal/service/manager_darwin_test.go b/internal/service/manager_darwin_test.go index 0aefca89..6a97e439 100644 --- a/internal/service/manager_darwin_test.go +++ b/internal/service/manager_darwin_test.go @@ -7,6 +7,9 @@ package service import ( "context" + "errors" + "fmt" + "io/fs" "os" "os/exec" "path/filepath" @@ -41,8 +44,6 @@ func darwinUserOptions(t *testing.T) InstallOptions { CacheDir: cache, HubDir: hub, LocalDir: filepath.Join(paths.StateDir, "models"), - User: darwinCredentialSentinel, - UID: darwinCredentialSentinel, } } @@ -62,8 +63,6 @@ func darwinSystemOptions(t *testing.T) InstallOptions { CacheDir: cache, HubDir: hub, LocalDir: filepath.Join(paths.StateDir, "models"), - User: darwinCredentialSentinel, - UID: darwinCredentialSentinel, } } @@ -308,6 +307,55 @@ func TestParsePlistRecoversTheRegistration(t *testing.T) { } } +// TestParsePlistRecoversTheConfigFileAndTheCacheDir checks that the config +// file and the cache directory of the registration are recovered from a +// property list. +// +// Both values come from the argument vector of the registered process: the +// status describes the registration, so it needs the config file of the +// instance and the cache directory the registration recorded. +func TestParsePlistRecoversTheConfigFileAndTheCacheDir(t *testing.T) { + cases := []struct { + name string + cacheDir string + wantCache string + }{ + { + name: "a registration with a cache directory", + cacheDir: "/registered/cache", + wantCache: "/registered/cache", + }, + { + name: "a registration without a cache directory", + cacheDir: "", + wantCache: "", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + opts := darwinUserOptions(t) + opts.CacheDir = tc.cacheDir + + rendered, err := renderPlist(opts) + if err != nil { + t.Fatalf("renderPlist(): %v", err) + } + registration, err := parsePlist([]byte(rendered)) + if err != nil { + t.Fatalf("parsePlist(): %v", err) + } + + if got := registration.paths().ConfigFile; got != opts.Paths.ConfigFile { + t.Errorf("the config file = %q, want %q", got, opts.Paths.ConfigFile) + } + if got := registration.cacheDir(); got != tc.wantCache { + t.Errorf("the cache dir = %q, want %q", got, tc.wantCache) + } + }) + } +} + // TestParsePlistRejectsBrokenContent checks that a broken property list is // reported instead of read as an empty registration. func TestParsePlistRejectsBrokenContent(t *testing.T) { @@ -522,6 +570,98 @@ func TestStatusReportsAMissingExecutable(t *testing.T) { } } +// TestStatusReportsTheCacheDirOfTheInstance checks the cache root the status +// reports: the cache directory of the instance's own config file when it +// defines one, the cache directory of the registration otherwise. +// +// The values come from the property list on disk, so the status describes the +// registration and not the intent of the caller. +func TestStatusReportsTheCacheDirOfTheInstance(t *testing.T) { + launchctl, err := exec.LookPath("launchctl") + if err != nil { + t.Skipf("launchctl is not available: %v", err) + } + + root := t.TempDir() + withCacheDir := filepath.Join(root, "with-cache.json") + if err := os.WriteFile(withCacheDir, []byte(`{"cache-dir":"/from/config/file"}`), 0o600); err != nil { + t.Fatalf("write the config file: %v", err) + } + withoutCacheDir := filepath.Join(root, "without-cache.json") + if err := os.WriteFile(withoutCacheDir, []byte(`{"local-dir":"/from/config/file/models"}`), 0o600); err != nil { + t.Fatalf("write the config file: %v", err) + } + + cases := []struct { + name string + // configFile is the config file the registration points at. + configFile string + // registered is the cache directory the registration records. + registered string + // want is the cache root the status has to report. + want string + }{ + { + name: "the config file of the instance wins", + configFile: withCacheDir, + registered: "/registered/cache", + want: "/from/config/file", + }, + { + name: "the registration is the fallback", + configFile: withoutCacheDir, + registered: "/registered/cache", + want: "/registered/cache", + }, + { + name: "a missing config file falls back to the registration", + configFile: filepath.Join(root, "missing.json"), + registered: "/registered/cache", + want: "/registered/cache", + }, + { + name: "no cache dir at all", + configFile: withoutCacheDir, + registered: "", + want: "", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + launchd := darwinStatusManager(t) + if output, err := exec.Command(launchctl, "print", launchd.domain()).CombinedOutput(); err != nil { + t.Skipf("the domain %s is not available: %v\n%s", launchd.domain(), err, output) + } + + stateDir := t.TempDir() + opts := InstallOptions{ + Scope: ScopeUser, + Executable: darwinWriteFakeExecutable(t), + Listen: DefaultListen, + Paths: runtimepaths.Paths{ + ConfigFile: tc.configFile, + StateDir: stateDir, + LogDir: filepath.Join(stateDir, "logs"), + }, + CacheDir: tc.registered, + } + darwinWriteRegistration(t, launchd, opts) + + status, err := launchd.Status(context.Background()) + if err != nil { + t.Fatalf("Status(): %v", err) + } + if status.State == StateNotInstalled { + t.Fatalf("the state = %q, want the state of a registered instance", status.State) + } + if status.CacheDir != tc.want { + t.Errorf("the cache dir = %q, want %q", status.CacheDir, tc.want) + } + }) + } +} + // TestLaunchdLifecycle runs the whole lifecycle of an instance against // launchd. // @@ -660,6 +800,340 @@ func TestLaunchdLifecycle(t *testing.T) { } } +// TestLaunchdMachineWideInstallNeedsAnElevatedShell checks the elevation +// requirement of the machine-wide scope. +// +// A LaunchDaemon is registered in /Library/LaunchDaemons and started by the +// system domain: both belong to the machine and need root. The user scope +// needs no elevation: a LaunchAgent belongs to the user who installs it. +// +// The decision is a separate function that takes the user id of the process, +// so the test drives both branches without an elevated shell. +func TestLaunchdMachineWideInstallNeedsAnElevatedShell(t *testing.T) { + const action = "an installation" + + t.Run("the machine-wide scope refuses a shell that is not elevated", func(t *testing.T) { + launchd := &launchdManager{scope: ScopeSystem, uid: "501", home: "/Users/somebody"} + + err := launchd.requireElevatedFor(action, 501) + if err == nil { + t.Fatal("requireElevatedFor() = nil, want the refusal of a shell that is not elevated") + } + want := fmt.Sprintf("service: %s in the system scope requires an elevated shell: the LaunchDaemon is registered in %s and started by the system domain, HFDesk never escalates privileges itself", action, launchDaemonsDir) + if err.Error() != want { + t.Errorf("requireElevatedFor() = %q, want %q", err, want) + } + if !strings.Contains(err.Error(), "elevated shell") { + t.Errorf("requireElevatedFor() = %v, want an error that names the elevated shell", err) + } + }) + + t.Run("the machine-wide scope accepts an elevated shell", func(t *testing.T) { + launchd := &launchdManager{scope: ScopeSystem} + + if err := launchd.requireElevatedFor(action, 0); err != nil { + t.Errorf("requireElevatedFor() = %v, want nil for an elevated shell", err) + } + }) + + t.Run("the user scope is not refused", func(t *testing.T) { + launchd := &launchdManager{scope: ScopeUser, uid: "501", home: "/Users/somebody"} + + if err := launchd.requireElevatedFor(action, 501); err != nil { + t.Errorf("requireElevatedFor() = %v, want nil for a user scope instance", err) + } + }) + + t.Run("the check of the process uses the effective user id of the process", func(t *testing.T) { + launchd := &launchdManager{scope: ScopeSystem} + + err := launchd.requireElevated(action) + forced := launchd.requireElevatedFor(action, os.Geteuid()) + switch { + case err == nil && forced == nil: + case err != nil && forced != nil && err.Error() == forced.Error(): + default: + t.Errorf("requireElevated() = %v, want the decision of requireElevatedFor(%d) = %v", err, os.Geteuid(), forced) + } + }) +} + +// TestLaunchdMachineWideUninstallNeedsAnElevatedShell checks the elevation +// requirement of the machine-wide scope for an uninstallation. +// +// A LaunchDaemon is registered in /Library/LaunchDaemons and it is unloaded +// from the system domain: both belong to the machine and need root, so the +// removal of the registration needs an elevated shell as well. The user +// scope needs no elevation: a LaunchAgent belongs to the user who installed +// it. +// +// The decision is the same function that the installation uses, with the user +// id of the process as a parameter, so the test drives both branches without +// an elevated shell. +func TestLaunchdMachineWideUninstallNeedsAnElevatedShell(t *testing.T) { + const action = "an uninstallation" + + t.Run("the machine-wide scope refuses a shell that is not elevated", func(t *testing.T) { + launchd := &launchdManager{scope: ScopeSystem, uid: "501", home: "/Users/somebody"} + + err := launchd.requireElevatedFor(action, 501) + if err == nil { + t.Fatal("requireElevatedFor() = nil, want the refusal of a shell that is not elevated") + } + want := fmt.Sprintf("service: %s in the system scope requires an elevated shell: the LaunchDaemon is registered in %s and started by the system domain, HFDesk never escalates privileges itself", action, launchDaemonsDir) + if err.Error() != want { + t.Errorf("requireElevatedFor() = %q, want %q", err, want) + } + if !strings.Contains(err.Error(), "elevated shell") { + t.Errorf("requireElevatedFor() = %v, want an error that names the elevated shell", err) + } + }) + + t.Run("the machine-wide scope accepts an elevated shell", func(t *testing.T) { + launchd := &launchdManager{scope: ScopeSystem} + + if err := launchd.requireElevatedFor(action, 0); err != nil { + t.Errorf("requireElevatedFor() = %v, want nil for an elevated shell", err) + } + }) + + t.Run("the user scope is not refused", func(t *testing.T) { + launchd := &launchdManager{scope: ScopeUser, uid: "501", home: "/Users/somebody"} + + if err := launchd.requireElevatedFor(action, 501); err != nil { + t.Errorf("requireElevatedFor() = %v, want nil for a user scope instance", err) + } + }) + + t.Run("the check of the process uses the effective user id of the process", func(t *testing.T) { + launchd := &launchdManager{scope: ScopeSystem} + + err := launchd.requireElevated(action) + forced := launchd.requireElevatedFor(action, os.Geteuid()) + switch { + case err == nil && forced == nil: + case err != nil && forced != nil && err.Error() == forced.Error(): + default: + t.Errorf("requireElevated() = %v, want the decision of requireElevatedFor(%d) = %v", err, os.Geteuid(), forced) + } + }) +} + +// TestLaunchdUninstallOfAnAbsentRegistrationIsACleanSuccess checks that the +// existence of the registration is checked before the elevation +// requirement. +// +// A registration that is not there is an uninstallation that is already +// done: it is a clean success, and it stays one when the shell is not +// elevated. The existence of the registration comes first, so the +// machine-wide scope reports the same clean success for an absent +// registration instead of the elevation requirement. +func TestLaunchdUninstallOfAnAbsentRegistrationIsACleanSuccess(t *testing.T) { + t.Run("the machine-wide scope of an absent registration", func(t *testing.T) { + launchd := &launchdManager{scope: ScopeSystem, uid: "501", home: "/Users/somebody"} + path := launchd.plistPath() + if _, err := os.Stat(path); err == nil { + // The machine has a machine-wide registration: the test must + // never remove it. + t.Skipf("the machine has a registration at %q: the test does not touch it", path) + } + + // The elevation requirement refuses a shell that is not elevated. + if err := launchd.requireElevatedFor("an uninstallation", 501); err == nil { + t.Fatal("requireElevatedFor() = nil, want the refusal of a shell that is not elevated") + } + + // The uninstallation of an absent registration is a clean success. + if err := launchd.Uninstall(context.Background()); err != nil { + t.Errorf("Uninstall() = %v, want nil for an absent registration", err) + } + + if os.Geteuid() != 0 { + // The shell of the test is not elevated, so the elevation + // requirement refuses it: the clean success above shows that + // the existence of the registration is checked first. + if err := launchd.requireElevated("an uninstallation"); err == nil { + t.Errorf("requireElevated() = nil, want the refusal of the shell %d", os.Geteuid()) + } + } + }) + + t.Run("the user scope of an absent registration", func(t *testing.T) { + launchd := &launchdManager{scope: ScopeUser, uid: "501", home: t.TempDir()} + + if err := launchd.Uninstall(context.Background()); err != nil { + t.Errorf("Uninstall() = %v, want nil for an absent registration", err) + } + }) +} + +// TestLaunchdCheckSystemPaths checks the guard of the machine-wide scope: a +// path inside the home directory of the user who runs the install command is +// refused, the machine-level paths are accepted, and a user scope instance is +// not touched at all. +func TestLaunchdCheckSystemPaths(t *testing.T) { + home := t.TempDir() + inside := filepath.Join(home, ".config", "HFDesk", "service") + // A sibling of the home that shares its prefix is not inside the home. + sibling := home + "2" + + refused := []struct { + name string + field string + apply func(*InstallOptions) + }{ + {name: "config file", field: "config file", apply: func(o *InstallOptions) { o.Paths.ConfigFile = inside }}, + {name: "state dir", field: "state dir", apply: func(o *InstallOptions) { o.Paths.StateDir = inside }}, + {name: "log dir", field: "log dir", apply: func(o *InstallOptions) { o.Paths.LogDir = inside }}, + {name: "cache dir", field: "cache dir", apply: func(o *InstallOptions) { o.CacheDir = inside }}, + {name: "hub dir", field: "hub dir", apply: func(o *InstallOptions) { o.HubDir = inside }}, + {name: "local dir", field: "local dir", apply: func(o *InstallOptions) { o.LocalDir = inside }}, + } + for _, tc := range refused { + t.Run("the "+tc.name+" inside the home of the user is refused", func(t *testing.T) { + opts := darwinSystemOptions(t) + tc.apply(&opts) + + launchd := &launchdManager{scope: ScopeSystem, home: home} + err := launchd.checkSystemPaths(opts) + if err == nil { + t.Fatalf("checkSystemPaths() = nil, want the refusal of the %s", tc.name) + } + want := fmt.Sprintf("service: the %s %q of a machine-wide instance must not live inside the home directory %q of the invoking user", tc.field, inside, home) + if err.Error() != want { + t.Errorf("checkSystemPaths() = %q, want %q", err, want) + } + }) + } + + t.Run("the home itself is refused", func(t *testing.T) { + opts := darwinSystemOptions(t) + opts.Paths.StateDir = home + + launchd := &launchdManager{scope: ScopeSystem, home: home} + err := launchd.checkSystemPaths(opts) + if err == nil { + t.Fatal("checkSystemPaths() = nil, want the refusal of the home directory of the user") + } + want := fmt.Sprintf("service: the state dir %q of a machine-wide instance must not live inside the home directory %q of the invoking user", home, home) + if err.Error() != want { + t.Errorf("checkSystemPaths() = %q, want %q", err, want) + } + }) + + t.Run("a sibling with a common prefix is accepted", func(t *testing.T) { + opts := darwinSystemOptions(t) + opts.Paths.StateDir = sibling + + launchd := &launchdManager{scope: ScopeSystem, home: home} + if err := launchd.checkSystemPaths(opts); err != nil { + t.Errorf("checkSystemPaths() = %v, want nil for the sibling %q of the home %q", err, sibling, home) + } + }) + + t.Run("a path that leaves the home again is accepted", func(t *testing.T) { + opts := darwinSystemOptions(t) + opts.Paths.StateDir = filepath.Join(home, "..", "user2") + + launchd := &launchdManager{scope: ScopeSystem, home: home} + if err := launchd.checkSystemPaths(opts); err != nil { + t.Errorf("checkSystemPaths() = %v, want nil for the path %q that leaves the home %q", err, opts.Paths.StateDir, home) + } + }) + + t.Run("the machine-level paths are accepted", func(t *testing.T) { + launchd := &launchdManager{scope: ScopeSystem, home: home} + if err := launchd.checkSystemPaths(darwinSystemOptions(t)); err != nil { + t.Errorf("checkSystemPaths() = %v, want nil for the machine-level paths", err) + } + }) + + t.Run("the user scope is not affected", func(t *testing.T) { + opts := darwinUserOptions(t) + opts.Paths.StateDir = inside + opts.CacheDir = inside + + launchd := &launchdManager{scope: ScopeUser, home: home} + if err := launchd.checkSystemPaths(opts); err != nil { + t.Errorf("checkSystemPaths() = %v, want nil for a user scope instance", err) + } + }) +} + +// TestLaunchdSystemInstallRefusesTheHomeOfTheUser checks that the guard of +// the machine-wide scope runs before the property list is written and before +// any directory is created: a refused installation leaves nothing behind. +// +// The guard runs before the elevation requirement, so the refusal is the same +// answer whether the command runs from an elevated shell or not, and it is +// the same answer the Linux manager gives for the same command line. +func TestLaunchdSystemInstallRefusesTheHomeOfTheUser(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + manager, err := newPlatformManager(ScopeSystem) + if err != nil { + t.Fatalf("newPlatformManager(ScopeSystem): %v", err) + } + launchd, ok := manager.(*launchdManager) + if !ok { + t.Fatalf("newPlatformManager(ScopeSystem) = %T, want a launchd manager", manager) + } + + // The property list of the machine-wide scope lives in a fixed place of + // the machine. The test never writes it: the refusal happens before + // the property list is written. The state of the file is compared + // before and after the installation, so a registration that exists + // already is never touched by the test. + path := launchd.plistPath() + before, err := backupPlist(path) + if err != nil { + t.Logf("the registration %q cannot be read: %v", path, err) + before = &plistBackup{path: path} + } + + root := filepath.Join(home, ".config", "HFDesk", "service") + opts := InstallOptions{ + Scope: ScopeSystem, + Executable: darwinWriteFakeExecutable(t), + Listen: DefaultListen, + Paths: runtimepaths.Paths{ + // The config file of the instance stays on the machine, the + // state root of the instance is the one of the user: that is + // the case the guard refuses. + ConfigFile: "/etc/hfdesk/hfdesk.json", + StateDir: root, + LogDir: filepath.Join(root, "logs"), + }, + CacheDir: "/var/lib/hfdesk/cache", + HubDir: "/var/lib/hfdesk/cache/hub", + LocalDir: "/var/lib/hfdesk/models", + } + + err = launchd.Install(context.Background(), opts) + if err == nil { + t.Fatal("Install() = nil, want the refusal of the home directory of the user") + } + want := fmt.Sprintf("service: the state dir %q of a machine-wide instance must not live inside the home directory %q of the invoking user", root, home) + if err.Error() != want { + t.Errorf("Install() = %q, want %q", err, want) + } + + // The refused installation creates no directory of the instance. + if _, err := os.Stat(root); !errors.Is(err, fs.ErrNotExist) { + t.Errorf("the refused installation created the state directory %q: %v", root, err) + } + // The refused installation writes no property list. + after, err := backupPlist(path) + if err != nil { + t.Logf("the registration %q cannot be read: %v", path, err) + after = &plistBackup{path: path} + } + if before.existed != after.existed || string(before.content) != string(after.content) { + t.Errorf("the refused installation changed the registration %q", path) + } +} + // darwinWaitForState waits until the instance reaches a state. func darwinWaitForState(t *testing.T, manager *launchdManager, want State) Status { t.Helper() @@ -693,6 +1167,35 @@ func darwinWriteFakeExecutable(t *testing.T) string { return path } +// darwinStatusManager returns a manager that reads its registration from a +// temporary home directory, so a test never touches the registration of the +// user of the test. +func darwinStatusManager(t *testing.T) *launchdManager { + t.Helper() + return &launchdManager{ + scope: ScopeUser, + uid: strconv.Itoa(os.Getuid()), + home: t.TempDir(), + } +} + +// darwinWriteRegistration renders the property list of an installation and +// writes it to the registration of the manager. +func darwinWriteRegistration(t *testing.T, launchd *launchdManager, opts InstallOptions) { + t.Helper() + rendered, err := renderPlist(opts) + if err != nil { + t.Fatalf("renderPlist(): %v", err) + } + path := launchd.plistPath() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("create the registration directory: %v", err) + } + if err := os.WriteFile(path, []byte(rendered), plistMode); err != nil { + t.Fatalf("write the property list: %v", err) + } +} + // darwinPlistStrings returns every string of a registration, including the // strings of the nested dictionaries and arrays. func darwinPlistStrings(registration plistRegistration) []string { diff --git a/internal/service/manager_linux.go b/internal/service/manager_linux.go index 81b05f5e..97f70623 100644 --- a/internal/service/manager_linux.go +++ b/internal/service/manager_linux.go @@ -17,6 +17,7 @@ import ( "strings" "github.com/bashrusakh/hfdesk/internal/runtimepaths" + "github.com/bashrusakh/hfdesk/pkg/hfdownloader" ) const ( @@ -182,6 +183,14 @@ func (m *systemdManager) Install(ctx context.Context, opts InstallOptions) error if err := ValidateInstallOptions(opts); err != nil { return err } + // A machine-wide instance must not adopt the state of the user who + // runs the install command. The check runs before the root + // requirement, before any directory is created and before the unit + // file is written: a refused installation leaves nothing behind, and + // every platform reports the same reason for the same command line. + if err := m.checkSystemPaths(opts); err != nil { + return err + } // The root requirement is checked before any directory is created: a // permission error of the machine wide directories is a much less // readable answer than the real reason. @@ -240,6 +249,63 @@ func (m *systemdManager) preflight(opts InstallOptions) error { return nil } +// checkSystemPaths rejects a machine-wide installation that would adopt the +// state of the user who runs the install command. +// +// A system scope unit runs as root. A path inside the home directory of the +// invoking user would either expose the state of the user to the machine or +// take the state of the user away from it, so the installation is refused +// instead of silently taking the state over. The model and the cache trees +// are never touched by an installation: only the three directories of the +// instance (the config directory, the state directory and the log directory) +// are created, and nothing is ever chowned. +// +// A user scope instance is supposed to live inside the directories of its own +// user, so the guard does not touch it. +// +// The comparison is lexical: the paths are compared as they are written and +// the symbolic links of the file system are not resolved. A path that only +// reaches the home directory of the user through a symbolic link is +// therefore compared by its own path and not caught. The lexical test is the +// conservative choice: it never resolves a link of an unprivileged user into +// a machine-wide instance, and it reports the paths of the command line as +// they were typed. +func (m *systemdManager) checkSystemPaths(opts InstallOptions) error { + if m.scope != ScopeSystem { + return nil + } + home, err := os.UserHomeDir() + if err != nil || home == "" { + // The home directory of the invoking user is unknown: there is + // nothing to compare the paths of the installation with, and an + // unknown home is not a failed installation. + return nil + } + for _, p := range []struct { + name string + path string + }{ + {"config file", opts.Paths.ConfigFile}, + {"state dir", opts.Paths.StateDir}, + {"log dir", opts.Paths.LogDir}, + {"cache dir", opts.CacheDir}, + {"hub dir", opts.HubDir}, + {"local dir", opts.LocalDir}, + } { + if p.path == "" { + continue + } + // The containment helper of the downloader package is a lexical + // test: it counts the home directory itself as inside it and it + // keeps a sibling that shares the prefix of the home ("/home/user2" + // against "/home/user") outside it. + if hfdownloader.PathInside(home, p.path) { + return fmt.Errorf("service: the %s %q of a machine-wide instance must not live inside the home directory %q of the invoking user", p.name, p.path, home) + } + } + return nil +} + // reloadAndEnable makes the supervisor aware of the unit file and enables // the unit for the boot of the scope. func (m *systemdManager) reloadAndEnable(ctx context.Context) error { @@ -420,6 +486,12 @@ func (m *systemdManager) Status(ctx context.Context) (Status, error) { status.Executable = unit.ExecStart status.Listen = unit.listen() status.Paths = unit.paths() + // The cache root of the instance is the cache dir of the config file + // of the instance when that file defines one, and the registered cache + // dir otherwise: the status describes the instance that runs, not the + // registration alone. An instance that defines no cache root at all + // reports none. + status.CacheDir = EffectiveCacheDir(unit.flagValue("--config"), unit.flagValue("--cache-dir")) if status.Executable != "" { if _, err := os.Stat(status.Executable); err != nil { diff --git a/internal/service/manager_linux_test.go b/internal/service/manager_linux_test.go index 633a4c7e..e3bbd896 100644 --- a/internal/service/manager_linux_test.go +++ b/internal/service/manager_linux_test.go @@ -9,6 +9,7 @@ import ( "bytes" "context" "errors" + "fmt" "io/fs" "os" "os/exec" @@ -18,6 +19,7 @@ import ( "time" "github.com/bashrusakh/hfdesk/internal/runtimepaths" + "github.com/bashrusakh/hfdesk/pkg/hfdownloader" ) // systemdTestOptions returns an installation for the systemd tests. The @@ -58,6 +60,18 @@ func unitDirectives(unit string) []string { return directives } +// systemdTestExecutable writes a program that can be registered and returns +// its path. An installation refuses a program that does not exist, so every +// test of an installation needs one. +func systemdTestExecutable(t *testing.T) string { + t.Helper() + path := filepath.Join(t.TempDir(), "hfdesk") + if err := os.WriteFile(path, []byte("#!/bin/sh\nexec sleep 30\n"), 0o755); err != nil { + t.Fatalf("write the program: %v", err) + } + return path +} + func TestSystemdRenderUnitUserScope(t *testing.T) { opts := systemdTestOptions(t, ScopeUser) @@ -159,8 +173,6 @@ func TestSystemdRenderUnitCarriesNoCredential(t *testing.T) { // Every field that could plausibly carry access data is filled with // the sentinel, so the check keeps its meaning when such a field is // added. - opts.User = sentinel + "_user" - opts.UID = sentinel + "_uid" setCredentialFields(&opts, sentinel) unit, err := renderUnit(opts) @@ -586,3 +598,253 @@ func TestSystemdUserIntegrationCycle(t *testing.T) { t.Errorf("the second Uninstall() = %v, want nil", err) } } + +// TestSystemdPathInsideHome pins the semantics of the containment helper the +// guard of the machine-wide scope relies on. +// +// The test is lexical, the home directory itself counts as inside it, and a +// sibling that shares the prefix of the home does not. +func TestSystemdPathInsideHome(t *testing.T) { + const home = "/home/user" + cases := []struct { + name string + path string + want bool + }{ + {name: "a directory inside the home", path: "/home/user/.config/HFDesk/service", want: true}, + {name: "the home itself", path: "/home/user", want: true}, + {name: "a sibling with a common prefix", path: "/home/user2", want: false}, + {name: "a directory outside the home", path: "/srv/hfdesk", want: false}, + {name: "a path that leaves the home again", path: "/home/user/../user2", want: false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := hfdownloader.PathInside(home, tc.path); got != tc.want { + t.Errorf("PathInside(%q, %q) = %v, want %v", home, tc.path, got, tc.want) + } + }) + } +} + +// TestSystemdCheckSystemPaths checks the guard of the machine-wide scope: a +// path inside the home directory of the user who runs the install command is +// refused, the machine-level paths are accepted, and a user scope instance is +// not touched at all. +func TestSystemdCheckSystemPaths(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + inside := filepath.Join(home, ".config", "HFDesk", "service") + // A sibling of the home that shares its prefix is not inside the home. + sibling := home + "2" + + refused := []struct { + name string + field string + apply func(*InstallOptions) + }{ + {name: "config file", field: "config file", apply: func(o *InstallOptions) { o.Paths.ConfigFile = inside }}, + {name: "state dir", field: "state dir", apply: func(o *InstallOptions) { o.Paths.StateDir = inside }}, + {name: "log dir", field: "log dir", apply: func(o *InstallOptions) { o.Paths.LogDir = inside }}, + {name: "cache dir", field: "cache dir", apply: func(o *InstallOptions) { o.CacheDir = inside }}, + {name: "hub dir", field: "hub dir", apply: func(o *InstallOptions) { o.HubDir = inside }}, + {name: "local dir", field: "local dir", apply: func(o *InstallOptions) { o.LocalDir = inside }}, + } + for _, tc := range refused { + t.Run("the "+tc.name+" inside the home of the user is refused", func(t *testing.T) { + opts := systemdTestOptions(t, ScopeSystem) + tc.apply(&opts) + + m := &systemdManager{scope: ScopeSystem} + err := m.checkSystemPaths(opts) + if err == nil { + t.Fatalf("checkSystemPaths() = nil, want the refusal of the %s", tc.name) + } + want := fmt.Sprintf("service: the %s %q of a machine-wide instance must not live inside the home directory %q of the invoking user", tc.field, inside, home) + if err.Error() != want { + t.Errorf("checkSystemPaths() = %q, want %q", err, want) + } + }) + } + + t.Run("the home itself is refused", func(t *testing.T) { + opts := systemdTestOptions(t, ScopeSystem) + opts.Paths.StateDir = home + + m := &systemdManager{scope: ScopeSystem} + err := m.checkSystemPaths(opts) + if err == nil { + t.Fatal("checkSystemPaths() = nil, want the refusal of the home directory of the user") + } + want := fmt.Sprintf("service: the state dir %q of a machine-wide instance must not live inside the home directory %q of the invoking user", home, home) + if err.Error() != want { + t.Errorf("checkSystemPaths() = %q, want %q", err, want) + } + }) + + t.Run("a sibling with a common prefix is accepted", func(t *testing.T) { + opts := systemdTestOptions(t, ScopeSystem) + opts.Paths.StateDir = sibling + + m := &systemdManager{scope: ScopeSystem} + if err := m.checkSystemPaths(opts); err != nil { + t.Errorf("checkSystemPaths() = %v, want nil for the sibling %q of the home %q", err, sibling, home) + } + }) + + t.Run("the machine-level paths are accepted", func(t *testing.T) { + m := &systemdManager{scope: ScopeSystem} + if err := m.checkSystemPaths(systemdTestOptions(t, ScopeSystem)); err != nil { + t.Errorf("checkSystemPaths() = %v, want nil for the machine-level paths", err) + } + }) + + t.Run("the user scope is not affected", func(t *testing.T) { + opts := systemdTestOptions(t, ScopeUser) + opts.Paths.StateDir = inside + opts.CacheDir = inside + + m := &systemdManager{scope: ScopeUser} + if err := m.checkSystemPaths(opts); err != nil { + t.Errorf("checkSystemPaths() = %v, want nil for a user scope instance", err) + } + }) +} + +// TestSystemdSystemInstallRefusesTheHomeOfTheUser checks that the guard of +// the machine-wide scope runs before the unit file is written: a refused +// installation leaves no unit file and no directory of the instance behind. +func TestSystemdSystemInstallRefusesTheHomeOfTheUser(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + m := &systemdManager{scope: ScopeSystem} + path, err := m.unitPath() + if err != nil { + t.Fatalf("unitPath() = %v, want nil", err) + } + // The unit file of the machine scope lives in a fixed place of the + // machine. The test never writes it: the refusal happens before the + // unit file is written. The state of the file is compared before and + // after the installation, so a unit that is installed already is never + // touched by the test. + before, existedBefore, err := readUnitFile(path) + if err != nil { + t.Logf("the unit file %q cannot be read: %v", path, err) + } + + root := filepath.Join(home, ".config", "HFDesk", "service") + opts := InstallOptions{ + Scope: ScopeSystem, + Executable: systemdTestExecutable(t), + Listen: DefaultListen, + Paths: runtimepaths.Paths{ + // The config file of the instance stays on the machine, the + // state root of the instance is the one of the user: that is + // the case the guard refuses. + ConfigFile: "/etc/hfdesk/hfdesk.json", + StateDir: root, + LogDir: filepath.Join(root, "logs"), + }, + CacheDir: "/var/lib/hfdesk/cache", + HubDir: "/var/lib/hfdesk/cache/hub", + LocalDir: "/var/lib/hfdesk/models", + } + + err = m.Install(context.Background(), opts) + if err == nil { + t.Fatal("Install() = nil, want the refusal of the home directory of the user") + } + want := fmt.Sprintf("service: the state dir %q of a machine-wide instance must not live inside the home directory %q of the invoking user", root, home) + if err.Error() != want { + t.Errorf("Install() = %q, want %q", err, want) + } + + // The refused installation writes no unit file. + after, existedAfter, err := readUnitFile(path) + if err != nil { + t.Logf("the unit file %q cannot be read: %v", path, err) + } + if existedBefore != existedAfter || string(before) != string(after) { + t.Errorf("the refused installation changed the unit file %q", path) + } + // The refused installation creates no directory of the instance + // either. + if _, err := os.Stat(root); !errors.Is(err, fs.ErrNotExist) { + t.Errorf("the refused installation created the state directory %q: %v", root, err) + } +} + +// TestSystemdStatusReportsTheCacheRoot checks that the status of an instance +// reports the cache root the instance uses: the cache dir of the config file +// of the instance when that file defines one, and the registered cache dir +// otherwise. +func TestSystemdStatusReportsTheCacheRoot(t *testing.T) { + root := t.TempDir() + configFile := filepath.Join(root, "hfdesk.json") + registered := filepath.Join(root, "cache") + + t.Run("the registered cache dir", func(t *testing.T) { + if err := os.WriteFile(configFile, []byte(`{"local-dir":"/srv/hfdesk/models"}`), 0o600); err != nil { + t.Fatal(err) + } + status := systemdStatus(t, "--config", configFile, "--cache-dir", registered) + if status.CacheDir != registered { + t.Errorf("the cache root = %q, want the registered %q", status.CacheDir, registered) + } + }) + + t.Run("the cache dir of the config file of the instance", func(t *testing.T) { + configured := filepath.Join(root, "configured") + if err := os.WriteFile(configFile, []byte(`{"cache-dir":"`+configured+`"}`), 0o600); err != nil { + t.Fatal(err) + } + status := systemdStatus(t, "--config", configFile, "--cache-dir", registered) + if status.CacheDir != configured { + t.Errorf("the cache root = %q, want the cache dir %q of the config file of the instance", status.CacheDir, configured) + } + }) + + t.Run("no cache dir at all", func(t *testing.T) { + if err := os.WriteFile(configFile, []byte(`{}`), 0o600); err != nil { + t.Fatal(err) + } + status := systemdStatus(t, "--config", configFile) + if status.CacheDir != "" { + t.Errorf("the cache root = %q, want none", status.CacheDir) + } + }) +} + +// systemdStatus registers a unit with the given arguments in a temporary +// configuration directory and returns the status of the instance. +// +// The unit points at a program that exists, so the status describes a +// registration and not a broken one. +func systemdStatus(t *testing.T, args ...string) Status { + t.Helper() + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + m := &systemdManager{scope: ScopeUser} + path, err := m.unitPath() + if err != nil { + t.Fatalf("unitPath() = %v, want nil", err) + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("create the unit directory: %v", err) + } + words := append([]string{systemdTestExecutable(t)}, args...) + unit := "[Service]\nExecStart=" + strings.Join(words, " ") + "\n" + if err := os.WriteFile(path, []byte(unit), 0o644); err != nil { + t.Fatalf("write the unit file: %v", err) + } + + status, err := m.Status(context.Background()) + if err != nil { + // The supervisor of the test environment may not know the unit: + // the registration facts are filled before the supervisor is + // asked, and they are the subject of the test. + t.Logf("the supervisor did not answer: %v", err) + } + return status +} diff --git a/internal/service/manager_windows.go b/internal/service/manager_windows.go index 6c2e4394..b7e42142 100644 --- a/internal/service/manager_windows.go +++ b/internal/service/manager_windows.go @@ -9,9 +9,11 @@ import ( "context" "errors" "fmt" + "io/fs" "log" "os" "path/filepath" + "strconv" "strings" "syscall" "time" @@ -108,8 +110,19 @@ func newPlatformManager(scope Scope) (Manager, error) { // Install registers the instance with the service control manager. // // The installation validates the options, prepares the directories of the -// instance, registers the service, registers the event log source of the -// instance and restricts the access to the state of the instance. +// instance, restricts the access to them, checks the directories the +// installation reuses, registers the service and registers the event log +// source of the instance. +// +// The state of the instance is restricted before the registration is +// written: every directory the instance owns carries the restrictive +// descriptor, so the directories the instance creates inside them inherit +// it and the account of the instance can work with them. +// +// The reused directories are checked before the registration is written as +// well: a directory the account of the instance cannot reach is a failed +// installation and not a service that fails later with an opaque access +// error. // // A service that is registered already is not registered a second time: // its registration is updated instead, so the instance keeps its identity. @@ -117,8 +130,8 @@ func newPlatformManager(scope Scope) (Manager, error) { // An installation never starts the instance: starting is an explicit, // separate command. // -// A failure after the registration was created removes the registration -// again, so a failed installation never leaves a half-configured service +// Every failure of the installation happens before the registration is +// written, so a failed installation never leaves a half-configured service // behind. func (m *scmManager) Install(ctx context.Context, opts InstallOptions) error { if err := ValidateInstallOptions(opts); err != nil { @@ -133,6 +146,13 @@ func (m *scmManager) Install(ctx context.Context, opts InstallOptions) error { if err := PrepareDirs(opts); err != nil { return err } + // The state of the instance is restricted before the reused + // directories are checked: the directories the instance creates + // inside its own directories inherit the restrictive descriptor, so + // the account of the instance can work with them. + if err := restrictInstanceState(opts); err != nil { + return err + } if err := m.preflight(opts); err != nil { return err } @@ -145,7 +165,6 @@ func (m *scmManager) Install(ctx context.Context, opts InstallOptions) error { config := buildSCMConfig(opts) handle, err := manager.OpenService(ServiceName) - created := false switch { case err == nil: // The service is registered already. The registration is @@ -164,7 +183,6 @@ func (m *scmManager) Install(ctx context.Context, opts InstallOptions) error { return fmt.Errorf("service: register %s: %w", ServiceName, err) } defer handle.Close() - created = true default: return fmt.Errorf("service: open the registration of %s: %w", ServiceName, err) } @@ -177,18 +195,6 @@ func (m *scmManager) Install(ctx context.Context, opts InstallOptions) error { log.Printf("service: the event log source of %s was not registered: %v", ServiceName, err) logEvent(eventLogWarning, fmt.Sprintf("hfdesk: the event log source of %s was not registered: %v", ServiceName, err)) } - - // The state of the instance belongs to the machine. - if err := restrictInstanceState(opts); err != nil { - // A registration that this call created is removed again: a - // half-configured service must not survive a failed - // installation. A registration that was already there stays: - // it belongs to the machine and not to this call. - if created { - rollbackRegistration(handle) - } - return err - } return nil } @@ -336,9 +342,7 @@ func (m *scmManager) Status(ctx context.Context) (Status, error) { if err != nil { return status, err } - status.Executable = registration.executable - status.Listen = registration.listen() - status.Paths = registration.paths() + applyRegistration(&status, registration) if status.Executable != "" { if _, err := os.Stat(status.Executable); err != nil { @@ -359,6 +363,19 @@ func (m *scmManager) Status(ctx context.Context) (Status, error) { return status, nil } +// applyRegistration fills the status with the facts of the registration. +// +// The cache root of the status is the effective one: the cache directory of +// the config file of the instance wins over the registered one, because the +// user changes it in the settings of the instance. A registration that +// defines no cache root at all reports none. +func applyRegistration(status *Status, registration scmRegistration) { + status.Executable = registration.executable + status.Listen = registration.listen() + status.Paths = registration.paths() + status.CacheDir = EffectiveCacheDir(status.Paths.ConfigFile, registration.cacheDir()) +} + // openRegistration opens the registration of the instance. // // The second result reports whether the service is registered at all: an @@ -433,7 +450,12 @@ func (m *scmManager) waitForStopped(ctx context.Context, handle *mgr.Service) er } // preflight rejects an installation the service control manager could not -// run: an unusable executable or an invalid bind address. +// run: an unusable executable, an invalid bind address or a directory the +// account of the instance cannot reach. +// +// The preflight runs before the registration is written, so a rejected +// installation never leaves a service behind that fails later with an +// opaque access error. func (m *scmManager) preflight(opts InstallOptions) error { if !filepath.IsAbs(opts.Executable) { return fmt.Errorf("service: executable %q must be an absolute path", opts.Executable) @@ -448,9 +470,350 @@ func (m *scmManager) preflight(opts InstallOptions) error { if _, _, err := ParseListen(opts.Listen); err != nil { return err } + return preflightDataPaths(opts) +} + +// The service identity of the instance is the account the registration runs +// the program as: LocalService (S-1-5-19). The access control list of a +// directory the installation reuses has to grant that account the access +// the instance needs, otherwise the instance fails later with an opaque +// access error. +const ( + // scmServiceIdentitySID is the SID of the account the instance runs + // as: LocalService (S-1-5-19). + scmServiceIdentitySID = "S-1-5-19" + + // scmServiceIdentityName is the name of the account the instance runs + // as. The name is part of the message of a failed installation, so + // the user knows which account needs the access. + scmServiceIdentityName = `NT AUTHORITY\LocalService` +) + +// scmIdentityGroups are the groups the account of the instance belongs to. +// +// An access control list that grants access to one of them grants the +// access to the account of the instance as well. +var scmIdentityGroups = []string{ + "S-1-1-0", // Everyone + "S-1-5-32-545", // Users + "S-1-5-11", // Authenticated Users + "S-1-5-4", // Interactive Users +} + +// preflightDataPaths checks the directories an installation reuses: the +// cache root, the hub directory and the local output directory. +// +// The directories do not belong to the instance: they can hold the models +// and the cache of an interactive run of the user. Their access control +// list is only read, never changed, and never changed recursively. +func preflightDataPaths(opts InstallOptions) error { + for _, dir := range []struct { + name string + path string + }{ + {"cache dir", opts.CacheDir}, + {"hub dir", opts.HubDir}, + {"local dir", opts.LocalDir}, + } { + if dir.path == "" { + continue + } + if err := preflightDataPath(dir.name, dir.path); err != nil { + return err + } + } + return nil +} + +// preflightDataPath checks that the account of the instance can work with +// the directory the installation reuses. +// +// A directory that does not exist yet is created by the installation, which +// runs elevated: the nearest existing ancestor has to grant the account the +// access to create it, and the created directory is checked again. +func preflightDataPath(name, path string) error { + if !filepath.IsAbs(path) { + return fmt.Errorf("service: %s %q must be an absolute path", name, path) + } + + info, err := os.Stat(path) + switch { + case err == nil: + if !info.IsDir() { + return fmt.Errorf("service: %s %q is not a directory: the instance needs a directory there", name, path) + } + return checkServiceAccess(name, path) + case !errors.Is(err, fs.ErrNotExist): + return fmt.Errorf("service: %s %q is not usable: %w", name, path, err) + } + + // The directory does not exist yet. The account of the instance has to + // be able to create it in the nearest existing ancestor. + ancestor, err := nearestExistingDir(path) + if err != nil { + return fmt.Errorf("service: %s %q cannot be created: %w", name, path, err) + } + if err := checkServiceAccess("parent directory", ancestor); err != nil { + return fmt.Errorf("service: %s %q cannot be created: %w", name, path, err) + } + if err := os.MkdirAll(path, dirMode); err != nil { + return fmt.Errorf("service: create %s %q: %w", name, path, err) + } + return checkServiceAccess(name, path) +} + +// nearestExistingDir returns the closest ancestor of a path that exists. +// +// The ancestor is the directory the installation has to create the missing +// path in. +func nearestExistingDir(path string) (string, error) { + current := filepath.Clean(path) + for { + parent := filepath.Dir(current) + if parent == current { + return "", fmt.Errorf("no existing directory was found above %q", path) + } + info, err := os.Stat(parent) + if err == nil { + if !info.IsDir() { + return "", fmt.Errorf("%q is not a directory", parent) + } + return parent, nil + } + if !errors.Is(err, fs.ErrNotExist) { + return "", fmt.Errorf("read %q: %w", parent, err) + } + current = parent + } +} + +// checkServiceAccess reads the access control list of a path and checks +// that the account of the instance can work with it. +// +// The access control list is only read: the descriptor of the instance is +// never applied to a path the installation reuses. +func checkServiceAccess(label, path string) error { + // The owner and the group are read next to the access control list: + // the conversion of a descriptor into its SDDL form needs the whole + // descriptor, and reading them needs no right beyond the one the + // access control list needs. + sd, err := windows.GetNamedSecurityInfo( + path, + windows.SE_FILE_OBJECT, + windows.DACL_SECURITY_INFORMATION|windows.OWNER_SECURITY_INFORMATION|windows.GROUP_SECURITY_INFORMATION, + ) + if err != nil { + return fmt.Errorf("service: the access rights of the %s %q cannot be read: %w: the installation cannot tell whether the account %s can use the path", label, path, err, scmServiceIdentityName) + } + sddl := sd.String() + if sddl == "" { + return fmt.Errorf("service: the access rights of the %s %q cannot be read: the installation cannot tell whether the account %s can use the path", label, path, scmServiceIdentityName) + } + if err := sddlGrantsIdentity(sddl); err != nil { + return fmt.Errorf("service: the %s %q is not usable by the account the instance runs as (%s): %w: grant the account read and write access to the path, or install the instance without this path", label, path, scmServiceIdentityName, err) + } + return nil +} + +// sddlGrantsIdentity reports whether the access control list of an SDDL +// string grants the account of the instance the access it needs. +// +// The check is a permission preflight, not a simulation of the access check +// of the operating system: it reads the access control list of the path +// itself, it does not resolve the inheritance of a parent directory, the +// privileges of a token or the membership of a group that is not listed +// here. +// +// A denied entry wins over an allowed one, so a directory that grants the +// account the access and denies it as well is refused. +func sddlGrantsIdentity(sddl string) error { + var ( + allowed bool + read bool + write bool + ) + for _, ace := range parseSDDLAces(sddl) { + if !ace.coversIdentity() { + continue + } + switch ace.kind { + case "D", "OD": + return fmt.Errorf("the access control list denies the account %s", scmServiceIdentityName) + case "A", "OA": + allowed = true + read = read || maskGrantsRead(ace.rights) + write = write || maskGrantsWrite(ace.rights) + } + } + switch { + case !allowed: + return fmt.Errorf("the access control list grants the account %s no access", scmServiceIdentityName) + case !read: + return fmt.Errorf("the access control list grants the account %s no read access", scmServiceIdentityName) + case !write: + return fmt.Errorf("the access control list grants the account %s no write access", scmServiceIdentityName) + } return nil } +// sddlACE is one access control entry of an SDDL string. +type sddlACE struct { + // kind is the type of the entry: "A" for an allowed entry and "D" + // for a denied one. + kind string + // rights is the access mask the entry grants or denies. + rights uint32 + // trustee is the SID the entry applies to. + trustee string +} + +// coversIdentity reports whether the entry applies to the account of the +// instance or to one of the groups it belongs to. +// +// The trustees of an SDDL string are raw SIDs. The aliases of the platform +// are accepted as well, so a descriptor that was written by hand is read +// the same way. +func (ace sddlACE) coversIdentity() bool { + trustee := strings.ToUpper(strings.TrimSpace(ace.trustee)) + if sid, ok := sddlTrusteeAliases[trustee]; ok { + trustee = sid + } + if trustee == scmServiceIdentitySID { + return true + } + for _, group := range scmIdentityGroups { + if trustee == group { + return true + } + } + return false +} + +// sddlTrusteeAliases maps the aliases of a trustee of an SDDL access +// control entry onto the SID they stand for. +var sddlTrusteeAliases = map[string]string{ + "WD": "S-1-1-0", // Everyone + "BU": "S-1-5-32-545", // Users + "AU": "S-1-5-11", // Authenticated Users + "IU": "S-1-5-4", // Interactive Users + "LS": "S-1-5-19", // LocalService +} + +// maskGrantsRead reports whether an access mask grants the read access the +// instance needs. +// +// The generic rights and the file specific rights are two different bit +// sets: an entry that grants one of them grants the read access. +func maskGrantsRead(mask uint32) bool { + if mask&windows.GENERIC_ALL != 0 || mask&windows.GENERIC_READ != 0 { + return true + } + return mask&windows.FILE_GENERIC_READ == windows.FILE_GENERIC_READ +} + +// maskGrantsWrite reports whether an access mask grants the write access +// the instance needs. +// +// The generic rights and the file specific rights are two different bit +// sets: an entry that grants one of them grants the write access. +func maskGrantsWrite(mask uint32) bool { + if mask&windows.GENERIC_ALL != 0 || mask&windows.GENERIC_WRITE != 0 { + return true + } + return mask&windows.FILE_GENERIC_WRITE == windows.FILE_GENERIC_WRITE +} + +// parseSDDLAces returns the access control entries of an SDDL string. +// +// The entries are the parenthesized groups of the string. The parts around +// them (the header of the descriptor, the owner and the group) carry no +// entry. +func parseSDDLAces(sddl string) []sddlACE { + var aces []sddlACE + for i := 0; i < len(sddl); { + open := strings.IndexByte(sddl[i:], '(') + if open < 0 { + break + } + i += open + 1 + end := strings.IndexByte(sddl[i:], ')') + if end < 0 { + break + } + body := sddl[i : i+end] + i += end + 1 + if ace, ok := parseSDDLACE(body); ok { + aces = append(aces, ace) + } + } + return aces +} + +// parseSDDLACE reads one access control entry of an SDDL string. +// +// The entry has the shape (type;flags;rights;object-type;inheritance;trustee). +// The trustee is the last field of the entry and the rights the third one. +func parseSDDLACE(body string) (sddlACE, bool) { + fields := strings.Split(body, ";") + if len(fields) < 2 { + return sddlACE{}, false + } + ace := sddlACE{ + kind: strings.ToUpper(strings.TrimSpace(fields[0])), + trustee: strings.TrimSpace(fields[len(fields)-1]), + } + if len(fields) >= 3 { + ace.rights = parseSDDLRights(fields[2]) + } + return ace, true +} + +// parseSDDLRights reads the rights field of an access control entry. +// +// The field holds an access mask as a hexadecimal number or as one of the +// aliases of the platform. A field that cannot be read grants nothing. +func parseSDDLRights(field string) uint32 { + field = strings.TrimSpace(field) + if field == "" { + return 0 + } + if mask, ok := sddlRights[strings.ToUpper(field)]; ok { + return mask + } + text := strings.TrimPrefix(strings.TrimPrefix(field, "0x"), "0X") + mask, err := strconv.ParseUint(text, 16, 32) + if err != nil { + return 0 + } + return uint32(mask) +} + +// scmFileAllAccess is the access mask of the alias "FA" of the rights field +// of an SDDL entry: the standard rights of a file plus every right on the +// data of the file. The platform does not export the constant. +const scmFileAllAccess uint32 = 0x001F01FF + +// sddlRights maps the aliases of the rights field of an SDDL access control +// entry onto the access mask they stand for. +// +// The aliases are the ones the platform reads and writes: the generic +// rights (GA, GR, GW, GX), the standard rights (RC, SD, WD, WO) and the +// file rights (FA, FR, FW, FX). +var sddlRights = map[string]uint32{ + "GA": windows.GENERIC_ALL, + "GR": windows.GENERIC_READ, + "GW": windows.GENERIC_WRITE, + "GX": windows.GENERIC_EXECUTE, + "RC": windows.READ_CONTROL, + "SD": windows.DELETE, + "WD": windows.WRITE_DAC, + "WO": windows.WRITE_OWNER, + "FA": scmFileAllAccess, + "FR": windows.FILE_GENERIC_READ, + "FW": windows.FILE_GENERIC_WRITE, + "FX": windows.FILE_GENERIC_EXECUTE, +} + // requireElevated rejects a machine-wide operation that needs an elevated // shell when the process does not have one. // @@ -522,6 +885,15 @@ func (r scmRegistration) paths() runtimepaths.Paths { } } +// cacheDir returns the cache root the registration carries. +// +// The registration holds the cache root the installation was made with. The +// effective cache root of the instance is the one of its own config file +// when that file defines one. +func (r scmRegistration) cacheDir() string { + return r.flagValue("--cache-dir") +} + // flagValue returns the value of a flag of the argument vector. func (r scmRegistration) flagValue(flag string) string { for i, arg := range r.args { @@ -618,25 +990,54 @@ func registerEventSource() error { // administrators of the machine and the account the instance runs as may // work with it, nobody else. // -// Only the state directory and the config file are touched. The model and -// the cache trees of an installation are never re-owned and never re-ACLed: -// they can hold gigabytes of data that the user may reuse from an -// interactive run, and a recursive change of the access rights would take -// the data away from the user who owns it. +// Every directory the instance owns carries the descriptor: the directory +// of the config file, the state directory and the log directory, and so +// does the config file itself. The descriptor is applied to the path +// itself and never recursively. +// +// The model and the cache trees of an installation are never re-owned and +// never re-ACLed: they can hold gigabytes of data that the user may reuse +// from an interactive run, and a recursive change of the access rights +// would take the data away from the user who owns it. func restrictInstanceState(opts InstallOptions) error { - if err := applyRestrictiveACL(opts.Paths.StateDir); err != nil { - return err - } - // The config file only exists when it was written already. A file - // that is created later inherits the descriptor of the directory. - if info, err := os.Stat(opts.Paths.ConfigFile); err == nil && info.Mode().IsRegular() { - if err := applyRestrictiveACL(opts.Paths.ConfigFile); err != nil { + for _, path := range instanceStatePaths(opts) { + if err := applyRestrictiveACL(path); err != nil { return err } } return nil } +// instanceStatePaths returns the paths of the instance that carry the +// restrictive descriptor: the directory of the config file, the state +// directory, the log directory and the config file itself. +// +// The config file is the only path that may not exist yet: a config file +// that is written later inherits the descriptor of its directory. A path +// that is named twice is returned once: the config file can live in the +// state directory of the instance. +func instanceStatePaths(opts InstallOptions) []string { + paths := []string{ + filepath.Dir(opts.Paths.ConfigFile), + opts.Paths.StateDir, + opts.Paths.LogDir, + } + if info, err := os.Stat(opts.Paths.ConfigFile); err == nil && info.Mode().IsRegular() { + paths = append(paths, opts.Paths.ConfigFile) + } + + unique := make([]string, 0, len(paths)) + seen := make(map[string]bool, len(paths)) + for _, path := range paths { + if path == "" || seen[path] { + continue + } + seen[path] = true + unique = append(unique, path) + } + return unique +} + // applyRestrictiveACL applies the security descriptor of the instance to a // path. // @@ -680,14 +1081,3 @@ func applyRestrictiveACL(path string) error { func scmStateSDDL() string { return "D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;FA;;;LS)" } - -// rollbackRegistration removes a registration that a failed installation -// left behind. -// -// The rollback is best effort: the original failure of the installation is -// the error the caller gets. -func rollbackRegistration(handle *mgr.Service) { - if err := handle.Delete(); err != nil && !errors.Is(err, windows.ERROR_SERVICE_MARKED_FOR_DELETE) { - log.Printf("service: the registration of %s could not be removed: %v", ServiceName, err) - } -} diff --git a/internal/service/manager_windows_test.go b/internal/service/manager_windows_test.go index a28a1f19..cb88fe9a 100644 --- a/internal/service/manager_windows_test.go +++ b/internal/service/manager_windows_test.go @@ -7,6 +7,9 @@ package service import ( "context" + "errors" + "fmt" + "io/fs" "os" "path/filepath" "reflect" @@ -79,8 +82,7 @@ func TestSCMCommandLineCarriesNoCredential(t *testing.T) { opts := scmTestOptions() // Every field of the options that could carry a credential carries // the sentinel. - opts.User = secretSentinel - opts.UID = secretSentinel + setCredentialFields(&opts, secretSentinel) commandLine := scmCommandLine(opts) if strings.Contains(commandLine, secretSentinel) { @@ -329,3 +331,250 @@ func TestSCMLifecycle(t *testing.T) { t.Errorf("the second Uninstall() = %v, want nil", err) } } + +// TestSDDLGrantsIdentity checks the access control list of a path against +// the account the instance runs as. +// +// The access control lists are the SDDL strings the platform writes: the +// trustees appear as raw SIDs and not as aliases. +func TestSDDLGrantsIdentity(t *testing.T) { + cases := []struct { + name string + sddl string + wantErr bool + }{ + {"the account itself", "D:P(A;;FA;;;S-1-5-19)", false}, + {"everyone", "D:P(A;;FA;;;S-1-1-0)", false}, + {"the users group", "D:P(A;;FA;;;S-1-5-32-545)", false}, + {"the authenticated users group", "D:P(A;;FA;;;S-1-5-11)", false}, + {"the interactive users group", "D:P(A;;FA;;;S-1-5-4)", false}, + {"an unrelated account", "D:P(A;;FA;;;S-1-5-18)", true}, + {"an unrelated group", "D:P(A;;FA;;;S-1-5-32-544)", true}, + {"the alias of the account", "D:P(A;;FA;;;LS)", false}, + {"the alias of everyone", "D:P(A;;FA;;;WD)", false}, + {"the alias of the users group", "D:P(A;;FA;;;BU)", false}, + {"a denied entry next to an allowed one", "D:P(D;;GA;;;S-1-5-19)(A;;FA;;;S-1-1-0)", true}, + {"a denied entry for a group of the account", "D:P(D;;GA;;;S-1-1-0)(A;;FA;;;S-1-5-19)", true}, + {"read only rights", "D:P(A;;FR;;;S-1-5-19)", true}, + {"write only rights", "D:P(A;;FW;;;S-1-5-19)", true}, + {"the generic rights", "D:P(A;;0xC0000000;;;S-1-5-19)", false}, + {"the file rights", "D:P(A;;0x0013019F;;;S-1-5-19)", false}, + {"an empty list", "D:P", true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := sddlGrantsIdentity(tc.sddl) + if tc.wantErr && err == nil { + t.Errorf("sddlGrantsIdentity(%q) = nil, want an error", tc.sddl) + } + if !tc.wantErr && err != nil { + t.Errorf("sddlGrantsIdentity(%q) = %v, want nil", tc.sddl, err) + } + }) + } +} + +// TestPreflightDataPathRefusesAFile checks that a file where the instance +// needs a directory is refused. +func TestPreflightDataPathRefusesAFile(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "cache") + if err := os.WriteFile(path, []byte("not a directory"), 0o600); err != nil { + t.Fatalf("WriteFile() = %v, want nil", err) + } + + err := preflightDataPath("cache dir", path) + if err == nil { + t.Fatalf("preflightDataPath(%q) = nil, want an error", path) + } + if !strings.Contains(err.Error(), "not a directory") { + t.Errorf("preflightDataPath(%q) = %v, want an error that names the reason", path, err) + } + if !strings.Contains(err.Error(), path) { + t.Errorf("preflightDataPath(%q) = %v, want an error that names the path", path, err) + } +} + +// TestPreflightDataPathRefusesAPathTheIdentityCannotReach checks that a +// directory the account of the instance cannot reach is refused before the +// registration is written. +// +// The directory does not exist yet: the nearest existing ancestor has to +// grant the account the access, otherwise the installation would leave a +// directory behind that the instance cannot use. +func TestPreflightDataPathRefusesAPathTheIdentityCannotReach(t *testing.T) { + root := t.TempDir() + parent := filepath.Join(root, "parent") + if err := os.Mkdir(parent, 0o700); err != nil { + t.Fatalf("Mkdir() = %v, want nil", err) + } + // The directory grants the machine and its administrators the access, + // not the account the instance runs as. + scmTestSetDACL(t, parent, fmt.Sprintf("D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;FA;;;%s)", scmTestCurrentUserSID(t))) + + path := filepath.Join(parent, "cache") + err := preflightDataPath("cache dir", path) + if err == nil { + t.Fatalf("preflightDataPath(%q) = nil, want an error", path) + } + if !strings.Contains(err.Error(), path) { + t.Errorf("preflightDataPath(%q) = %v, want an error that names the path", path, err) + } + if !strings.Contains(err.Error(), scmServiceIdentityName) { + t.Errorf("preflightDataPath(%q) = %v, want an error that names the account of the instance", path, err) + } + // The refused directory is not created behind the back of the user. + if _, statErr := os.Stat(path); !errors.Is(statErr, fs.ErrNotExist) { + t.Errorf("the refused directory %q exists: %v", path, statErr) + } +} + +// TestApplyRegistrationReportsTheEffectiveCacheDir checks that the status +// reports the cache root the instance uses: the cache directory of the +// config file of the instance wins over the registered one. +func TestApplyRegistrationReportsTheEffectiveCacheDir(t *testing.T) { + root := t.TempDir() + configFile := filepath.Join(root, "hfdesk.json") + registered := filepath.ToSlash(filepath.Join(root, "registered-cache")) + fromConfig := filepath.ToSlash(filepath.Join(root, "cache-of-the-config-file")) + + opts := scmTestOptions() + opts.Paths.ConfigFile = configFile + opts.CacheDir = registered + + registration, err := parseSCMCommandLine(scmCommandLine(opts)) + if err != nil { + t.Fatalf("parseSCMCommandLine() = %v, want nil", err) + } + + status := Status{} + applyRegistration(&status, registration) + if status.CacheDir != registered { + t.Errorf("the cache dir = %q, want the registered %q", status.CacheDir, registered) + } + if status.Paths.ConfigFile != configFile { + t.Errorf("the config file = %q, want %q", status.Paths.ConfigFile, configFile) + } + + // The config file of the instance wins over the registration. + if err := os.WriteFile(configFile, []byte(`{"cache-dir":"`+fromConfig+`"}`), 0o600); err != nil { + t.Fatalf("WriteFile() = %v, want nil", err) + } + status = Status{} + applyRegistration(&status, registration) + if status.CacheDir != fromConfig { + t.Errorf("the cache dir = %q, want the cache dir of the config file %q", status.CacheDir, fromConfig) + } + + // A registration without a cache root and a config file without one + // report no cache root at all. + opts.CacheDir = "" + opts.Paths.ConfigFile = filepath.Join(root, "without-cache.json") + registration, err = parseSCMCommandLine(scmCommandLine(opts)) + if err != nil { + t.Fatalf("parseSCMCommandLine() = %v, want nil", err) + } + status = Status{} + applyRegistration(&status, registration) + if status.CacheDir != "" { + t.Errorf("the cache dir = %q, want none", status.CacheDir) + } +} + +// TestInstanceStatePathsProtectEveryOwnedPath checks that every path the +// instance owns carries the restrictive descriptor: the directory of the +// config file, the state directory, the log directory and the config file +// itself. +func TestInstanceStatePathsProtectEveryOwnedPath(t *testing.T) { + root := t.TempDir() + opts := scmTestOptions() + opts.Paths = runtimepaths.Paths{ + ConfigFile: filepath.Join(root, "etc", "hfdesk.json"), + StateDir: filepath.Join(root, "var"), + LogDir: filepath.Join(root, "var", "logs"), + } + if err := os.MkdirAll(filepath.Dir(opts.Paths.ConfigFile), 0o700); err != nil { + t.Fatalf("MkdirAll() = %v, want nil", err) + } + if err := os.WriteFile(opts.Paths.ConfigFile, []byte("{}"), 0o600); err != nil { + t.Fatalf("WriteFile() = %v, want nil", err) + } + + want := []string{ + filepath.Dir(opts.Paths.ConfigFile), + opts.Paths.StateDir, + opts.Paths.LogDir, + opts.Paths.ConfigFile, + } + got := instanceStatePaths(opts) + if len(got) != len(want) { + t.Fatalf("instanceStatePaths() = %v, want %v", got, want) + } + protected := make(map[string]bool, len(got)) + for _, path := range got { + protected[path] = true + } + for _, path := range want { + if !protected[path] { + t.Errorf("instanceStatePaths() = %v, want %v", got, want) + } + } + + // A config file that does not exist yet inherits the descriptor of its + // directory, so it is not part of the paths. + if err := os.Remove(opts.Paths.ConfigFile); err != nil { + t.Fatalf("Remove() = %v, want nil", err) + } + got = instanceStatePaths(opts) + if len(got) != len(want)-1 { + t.Fatalf("instanceStatePaths() = %v, want the three directories of the instance", got) + } + for _, path := range got { + if path == opts.Paths.ConfigFile { + t.Errorf("instanceStatePaths() = %v, want no config file", got) + } + } +} + +// scmTestCurrentUserSID returns the SID of the account the test runs as. +// +// The account is needed by the tests that build an access control list: the +// list has to keep the access of the test itself, so the test can still +// read the directory it restricted. +func scmTestCurrentUserSID(t *testing.T) string { + t.Helper() + user, err := windows.GetCurrentProcessToken().GetTokenUser() + if err != nil { + t.Skipf("the account of the process is unknown: %v", err) + } + if user.User.Sid == nil { + t.Skip("the account of the process carries no SID") + } + return user.User.Sid.String() +} + +// scmTestSetDACL applies an access control list to a path. +// +// The descriptor is applied to the path itself and never recursively. +func scmTestSetDACL(t *testing.T, path, sddl string) { + t.Helper() + sd, err := windows.SecurityDescriptorFromString(sddl) + if err != nil { + t.Fatalf("SecurityDescriptorFromString(%q) = %v, want nil", sddl, err) + } + dacl, _, err := sd.DACL() + if err != nil { + t.Fatalf("the access control list of %q cannot be read: %v", sddl, err) + } + if err := windows.SetNamedSecurityInfo( + path, + windows.SE_FILE_OBJECT, + windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION, + nil, + nil, + dacl, + nil, + ); err != nil { + t.Fatalf("the access control list %q cannot be applied to %q: %v", sddl, path, err) + } +} From a093ce4eeb2c9e39fa280ed18bfc2cd210a40f53 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 21 Oct 2026 21:49:25 +1100 Subject: [PATCH 09/58] fix(cmd): report the effective cache root and the state of the instance - Report the cache root the instance actually uses in the status and in the uninstall summary. The scope default was reported before, which named the personal cache of the invoking user for a user scope instance and disagreed with the instance that runs. - Report the state the supervisor reports after an installation instead of claiming that the instance was not started: a macOS LaunchAgent starts with its registration, a systemd unit does not. - Document the effective cache root of an instance, the identity of a machine-wide instance per platform and the migration path of a future macOS app bundle to SMAppService. Co-Authored-By: opencode --- cmd/hfdesk/service.go | 117 +++++++++++--- cmd/hfdesk/service_test.go | 308 +++++++++++++++++++++++++++++++++++++ docs/API.md | 5 + docs/SERVICE.md | 93 ++++++++++- 4 files changed, 503 insertions(+), 20 deletions(-) diff --git a/cmd/hfdesk/service.go b/cmd/hfdesk/service.go index 5c572654..1a9e91fa 100644 --- a/cmd/hfdesk/service.go +++ b/cmd/hfdesk/service.go @@ -44,6 +44,15 @@ const ( // serviceFailureExitCode is the exit code of an operation that failed. serviceFailureExitCode = 1 + + // cacheRootNotRecorded is the wording of a cache root that no source + // names. + // + // The default of the scope and the personal cache of the invoking user + // are never reported instead: an instance that does not name a cache + // root does not use them, so naming them would send the reader to the + // wrong directory. + cacheRootNotRecorded = "not recorded (unknown)" ) // serviceSubcommands lists the subcommands of the family in the order the @@ -371,7 +380,9 @@ func buildInstallOptions(scope service.Scope, flags installFlags, executable str // scope. // // The command validates its own arguments before it touches the system. It -// never starts the instance: starting is a separate, explicit command. +// never starts the instance itself: the supervisor may start a registration +// that asks for it (a LaunchAgent with RunAtLoad starts it), so the summary +// reports the state that was reached instead of a fixed claim. func serviceInstall(args []string, stdout io.Writer) error { flags := installFlags{} fs := flag.NewFlagSet("hfdesk service install", flag.ContinueOnError) @@ -387,7 +398,7 @@ func serviceInstall(args []string, stdout io.Writer) error { if err := fs.Parse(args); err != nil { if errors.Is(err, flag.ErrHelp) { - writeSubcommandUsage(stdout, fs, "Register HFDesk with the platform supervisor of the scope.\n\nThe registration is written, the instance is not started.") + writeSubcommandUsage(stdout, fs, "Register HFDesk with the platform supervisor of the scope.\n\nThe registration is written. The summary reports the state the supervisor\nreached: a registration that starts the instance starts it.") return errHelpRequested } return fmt.Errorf("invalid command line: %w", err) @@ -444,18 +455,24 @@ func serviceInstall(args []string, stdout io.Writer) error { // writeInstallSummary prints what was registered. // -// The path of the registration is known only after the installation, so it -// is read back from the supervisor. +// The path of the registration and the state of the instance are known only +// after the installation, so both are read back from the supervisor. +// +// The cache root is the effective one: the config file of the instance wins +// over the registered value, because the cache directory is changed in the +// settings of the instance and the change has to survive a restart. func writeInstallSummary(w io.Writer, ctx context.Context, mgr service.Manager, scope service.Scope, opts service.InstallOptions) { + status, statusErr := mgr.Status(ctx) + writeField(w, "Service", service.ServiceName) writeField(w, "Scope", string(scope)) - writeField(w, "Registration", registrationPath(ctx, mgr)) + writeField(w, "Registration", registrationPath(status)) writeField(w, "Executable", opts.Executable) writeField(w, "Listen", opts.Listen) writeField(w, "Config file", opts.Paths.ConfigFile) writeField(w, "State dir", opts.Paths.StateDir) writeField(w, "Log dir", opts.Paths.LogDir) - writeField(w, "Cache root", opts.CacheDir) + writeField(w, "Cache root", service.EffectiveCacheDir(opts.Paths.ConfigFile, opts.CacheDir)) if warning := nonLoopbackWarning(opts.Listen); warning != "" { fmt.Fprintln(w) @@ -467,13 +484,39 @@ func writeInstallSummary(w io.Writer, ctx context.Context, mgr service.Manager, } fmt.Fprintln(w) - fmt.Fprintln(w, "The service was registered but not started.") - fmt.Fprintln(w, "Start it with: hfdesk service start") + fmt.Fprintln(w, "The service was registered.") + writeInstallState(w, status, statusErr) } -// registrationPath returns the path of the supervisor registration. -func registrationPath(ctx context.Context, mgr service.Manager) string { - status, _ := mgr.Status(ctx) +// writeInstallState prints the state the instance reached after the +// registration and the hint that starts it. +// +// The state is read back from the supervisor: a registration of some +// platforms starts the instance, so the summary cannot claim that the +// instance was not started. A state the supervisor did not report is +// reported as unknown, and the successful registration is reported +// nevertheless. +func writeInstallState(w io.Writer, status service.Status, statusErr error) { + switch { + case statusErr != nil: + writeField(w, "State", fmt.Sprintf("unknown (the state could not be determined: %s)", serviceErrorText(statusErr))) + case status.State == "": + writeField(w, "State", "unknown (the supervisor did not report a state)") + default: + writeField(w, "State", string(status.State)) + } + + // The hint is printed for every state that is not an active one. The + // instance then has to be started by the user, and a state that could + // not be read back at all does not say that the instance runs. + if status.State != service.StateRunning && status.State != service.StateStarting { + fmt.Fprintln(w, "Start it with: hfdesk service start") + } +} + +// registrationPath returns the path of the supervisor registration, or +// "unknown" when the supervisor did not report one. +func registrationPath(status service.Status) string { if status.UnitPath == "" { return "unknown" } @@ -503,26 +546,40 @@ func serviceUninstall(args []string, stdout io.Writer) error { return err } - fmt.Fprintf(stdout, "The registration of %s (%s scope) was removed.\n", service.ServiceName, scope) - fmt.Fprintln(stdout, "The user data was kept: models, cache, config, jobs/history and logs are still there.") - writeField(stdout, "Config file", paths.ConfigFile) - writeField(stdout, "State dir", paths.StateDir) - writeField(stdout, "Log dir", paths.LogDir) - writeField(stdout, "Cache root", cacheRoot) + writeUninstallSummary(stdout, scope, paths, cacheRoot) return nil } +// writeUninstallSummary prints what was removed and what was kept. +// +// The directories of the kept data are named, so the reader knows where the +// models, the cache, the config file, the job history and the logs of the +// instance stay. +func writeUninstallSummary(w io.Writer, scope service.Scope, paths runtimepaths.Paths, cacheRoot string) { + fmt.Fprintf(w, "The registration of %s (%s scope) was removed.\n", service.ServiceName, scope) + fmt.Fprintln(w, "The user data was kept: models, cache, config, jobs/history and logs are still there.") + writeField(w, "Config file", paths.ConfigFile) + writeField(w, "State dir", paths.StateDir) + writeField(w, "Log dir", paths.LogDir) + writeField(w, "Cache root", cacheRoot) +} + // instanceDataPaths returns the runtime paths and the cache root of the // instance of the scope. // // The registration is read first, so the report names the directories the // data of the registration stays in. A registration that cannot be read // falls back to the default paths of the scope. +// +// The cache root is the effective one of the instance: the default of the +// scope and the personal cache of the invoking user are never reported +// instead, because the instance does not use them. func instanceDataPaths(ctx context.Context, mgr service.Manager, scope service.Scope) (runtimepaths.Paths, string) { paths, err := service.DefaultPaths(scope) if err != nil { paths = runtimepaths.Paths{} } + cacheRoot := cacheRootNotRecorded if status, statusErr := mgr.Status(ctx); statusErr == nil { if status.Paths.ConfigFile != "" { paths.ConfigFile = status.Paths.ConfigFile @@ -533,11 +590,29 @@ func instanceDataPaths(ctx context.Context, mgr service.Manager, scope service.S if status.Paths.LogDir != "" { paths.LogDir = status.Paths.LogDir } + cacheRoot = effectiveCacheRoot(status) } - cacheRoot, _ := service.DefaultCacheDirs(scope, paths) return paths, cacheRoot } +// effectiveCacheRoot returns the Hugging Face cache root the instance uses. +// +// The supervisor reports the cache root of the registration, and the config +// file of the instance wins over it: the cache directory is changed in the +// settings of the instance, and the changed value has to survive a restart +// of the instance. +// +// A cache root that neither source names is not recorded. The default of +// the scope and the personal cache of the invoking user are never reported +// instead: the instance does not use them, so naming them would send the +// reader to the wrong directory. +func effectiveCacheRoot(status service.Status) string { + if dir := service.EffectiveCacheDir(status.Paths.ConfigFile, status.CacheDir); dir != "" { + return dir + } + return cacheRootNotRecorded +} + // newLifecycleManager prepares a lifecycle command: it parses the scope // selector and opens the manager of the scope. func newLifecycleManager(name string, args []string, stdout io.Writer, summary string) (service.Manager, service.Scope, error) { @@ -689,7 +764,11 @@ func renderStatus(w io.Writer, scope service.Scope, status service.Status) { paths = defaults } } - cacheRoot, _ := service.DefaultCacheDirs(scope, paths) + // The cache root is not derived from the paths above: the defaults of + // the scope describe the paths such an instance would use, not the + // cache root it uses. The effective cache root of the instance is + // reported instead. + cacheRoot := effectiveCacheRoot(status) writeField(w, "Service", name) writeField(w, "Scope", string(reportedScope)) diff --git a/cmd/hfdesk/service_test.go b/cmd/hfdesk/service_test.go index a62fbea8..d634ff7e 100644 --- a/cmd/hfdesk/service_test.go +++ b/cmd/hfdesk/service_test.go @@ -6,6 +6,8 @@ package main import ( "bytes" "context" + "encoding/json" + "errors" "net" "os" "path/filepath" @@ -23,6 +25,54 @@ import ( // interactive command line, e.g. `flag.IntVar(&port, "port", 8080, ...)`. var interactiveFlagPattern = regexp.MustCompile(`flag\.\w+Var\(&\w+, "([^"]+)"`) +// fakeManager is a service.Manager that answers with a fixed status. +// +// The command family is exercised without touching the system of the test +// run: the manager of the platform supervisor is replaced by this one. +type fakeManager struct { + status service.Status + err error +} + +func (m fakeManager) Install(context.Context, service.InstallOptions) error { return nil } + +func (m fakeManager) Uninstall(context.Context) error { return nil } + +func (m fakeManager) Start(context.Context) error { return nil } + +func (m fakeManager) Stop(context.Context) error { return nil } + +func (m fakeManager) Restart(context.Context) error { return nil } + +func (m fakeManager) Status(context.Context) (service.Status, error) { + return m.status, m.err +} + +// personalCacheRoot returns the Hugging Face cache of the user that runs +// the test. +// +// It is the cache a user scope instance used to be reported with, even when +// the instance used another one, so it is the value the reports must never +// name. +func personalCacheRoot(t *testing.T) string { + t.Helper() + root, _ := service.DefaultCacheDirs(service.ScopeUser, runtimepaths.Paths{}) + return root +} + +// writeConfigFile writes the config file of an instance with the given +// cache root. +func writeConfigFile(t *testing.T, path, cacheRoot string) { + t.Helper() + data, err := json.Marshal(map[string]string{"cache-dir": cacheRoot}) + if err != nil { + t.Fatalf("encode the config file: %v", err) + } + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatalf("write the config file: %v", err) + } +} + func TestServiceUsageListsEverySubcommand(t *testing.T) { var buf bytes.Buffer writeServiceUsage(&buf) @@ -281,6 +331,264 @@ func TestRenderStatusReportsTheInstance(t *testing.T) { } } +// TestRenderStatusReportsTheCacheRootOfTheInstance checks that the status +// reports the cache root the instance uses, and not the cache of the user +// that runs the command. +func TestRenderStatusReportsTheCacheRootOfTheInstance(t *testing.T) { + status := service.Status{ + State: service.StateRunning, + Scope: service.ScopeUser, + Name: service.ServiceName, + UnitPath: "/etc/systemd/user/hfdesk.service", + Executable: "/usr/local/bin/hfdesk", + Listen: "127.0.0.1:8080", + Paths: runtimepaths.Paths{ + ConfigFile: "/etc/hfdesk/hfdesk.json", + StateDir: "/var/lib/hfdesk", + LogDir: "/var/log/hfdesk", + }, + CacheDir: "/srv/instance-cache", + } + + var buf bytes.Buffer + renderStatus(&buf, service.ScopeUser, status) + + out := buf.String() + if !strings.Contains(out, "/srv/instance-cache") { + t.Errorf("the status does not report the cache root of the instance:\n%s", out) + } + if personal := personalCacheRoot(t); personal != "" && strings.Contains(out, personal) { + t.Errorf("the status reports the personal cache of the invoking user %q:\n%s", personal, out) + } +} + +// TestRenderStatusFallsBackToTheConfigFileOfTheInstance checks that the +// status reports the cache root of the config file of the instance when the +// supervisor does not report one. +func TestRenderStatusFallsBackToTheConfigFileOfTheInstance(t *testing.T) { + dir := t.TempDir() + configFile := filepath.Join(dir, "hfdesk.json") + cacheRoot := filepath.Join(dir, "instance-cache") + writeConfigFile(t, configFile, cacheRoot) + + status := service.Status{ + State: service.StateRunning, + Scope: service.ScopeUser, + Paths: runtimepaths.Paths{ + ConfigFile: configFile, + StateDir: filepath.Join(dir, "state"), + LogDir: filepath.Join(dir, "logs"), + }, + } + + var buf bytes.Buffer + renderStatus(&buf, service.ScopeUser, status) + + out := buf.String() + if !strings.Contains(out, cacheRoot) { + t.Errorf("the status does not report the cache root %q of the config file:\n%s", cacheRoot, out) + } + if personal := personalCacheRoot(t); personal != "" && strings.Contains(out, personal) { + t.Errorf("the status reports the personal cache of the invoking user %q:\n%s", personal, out) + } +} + +// TestRenderStatusReportsAnUnrecordedCacheRoot checks that a cache root +// that no source names is reported as unknown instead of the default of the +// scope or the personal cache of the invoking user. +func TestRenderStatusReportsAnUnrecordedCacheRoot(t *testing.T) { + dir := t.TempDir() + status := service.Status{ + State: service.StateNotInstalled, + Scope: service.ScopeUser, + Paths: runtimepaths.Paths{ + ConfigFile: filepath.Join(dir, "missing.json"), + StateDir: filepath.Join(dir, "state"), + LogDir: filepath.Join(dir, "logs"), + }, + } + + var buf bytes.Buffer + renderStatus(&buf, service.ScopeUser, status) + + out := buf.String() + if !strings.Contains(out, "not recorded") { + t.Errorf("the status does not report that the cache root is not recorded:\n%s", out) + } + if personal := personalCacheRoot(t); personal != "" && strings.Contains(out, personal) { + t.Errorf("the status reports the personal cache of the invoking user %q:\n%s", personal, out) + } +} + +// TestInstallSummaryReportsTheStateOfTheInstance checks that the summary of +// the installation reports the state the instance reached and that the hint +// that starts the instance is printed for every state that is not an active +// one. +func TestInstallSummaryReportsTheStateOfTheInstance(t *testing.T) { + opts := service.InstallOptions{ + Scope: service.ScopeUser, + Executable: "/usr/local/bin/hfdesk", + Listen: "127.0.0.1:8080", + Paths: runtimepaths.Paths{ + ConfigFile: "/etc/hfdesk/hfdesk.json", + StateDir: "/var/lib/hfdesk", + LogDir: "/var/log/hfdesk", + }, + CacheDir: "/srv/cache", + } + + for _, tc := range []struct { + state service.State + wantHint bool + }{ + {service.StateRunning, false}, + {service.StateStarting, false}, + {service.StateStopped, true}, + {service.StateFailed, true}, + } { + var buf bytes.Buffer + mgr := fakeManager{status: service.Status{ + State: tc.state, + Scope: service.ScopeUser, + UnitPath: "/etc/systemd/user/hfdesk.service", + }} + writeInstallSummary(&buf, context.Background(), mgr, service.ScopeUser, opts) + + out := buf.String() + if !strings.Contains(out, "State:") || !strings.Contains(out, string(tc.state)) { + t.Errorf("the install summary does not report the state %q:\n%s", tc.state, out) + } + hint := strings.Contains(out, "Start it with: hfdesk service start") + if hint != tc.wantHint { + t.Errorf("the install summary prints the start hint for the state %q: %v, want %v:\n%s", + tc.state, hint, tc.wantHint, out) + } + if strings.Contains(out, "not started") { + t.Errorf("the install summary claims that the instance was not started:\n%s", out) + } + } +} + +// TestInstallSummaryReportsAFailedStatusQuery checks that a state that could +// not be determined is reported as such, and that the successful +// registration is reported nevertheless. +func TestInstallSummaryReportsAFailedStatusQuery(t *testing.T) { + opts := service.InstallOptions{ + Scope: service.ScopeUser, + Executable: "/usr/local/bin/hfdesk", + Listen: "127.0.0.1:8080", + Paths: runtimepaths.Paths{ + ConfigFile: "/etc/hfdesk/hfdesk.json", + StateDir: "/var/lib/hfdesk", + LogDir: "/var/log/hfdesk", + }, + CacheDir: "/srv/cache", + } + + var buf bytes.Buffer + mgr := fakeManager{err: errors.New("the supervisor is not reachable")} + writeInstallSummary(&buf, context.Background(), mgr, service.ScopeUser, opts) + + out := buf.String() + if !strings.Contains(out, "The service was registered.") { + t.Errorf("the install summary does not report the successful registration:\n%s", out) + } + if !strings.Contains(out, "could not be determined") { + t.Errorf("the install summary does not report the failed status query:\n%s", out) + } + for _, state := range []string{"running", "stopped", "starting"} { + if strings.Contains(out, state) { + t.Errorf("the install summary claims the state %q it could not determine:\n%s", state, out) + } + } +} + +// TestInstallSummaryReportsTheEffectiveCacheRoot checks that the summary of +// the installation names the cache root of the config file of the instance +// when it defines one. +func TestInstallSummaryReportsTheEffectiveCacheRoot(t *testing.T) { + dir := t.TempDir() + configFile := filepath.Join(dir, "hfdesk.json") + cacheRoot := filepath.Join(dir, "instance-cache") + writeConfigFile(t, configFile, cacheRoot) + + opts := service.InstallOptions{ + Scope: service.ScopeUser, + Executable: "/usr/local/bin/hfdesk", + Listen: "127.0.0.1:8080", + Paths: runtimepaths.Paths{ + ConfigFile: configFile, + StateDir: filepath.Join(dir, "state"), + LogDir: filepath.Join(dir, "logs"), + }, + CacheDir: "/srv/cache", + } + + var buf bytes.Buffer + mgr := fakeManager{status: service.Status{State: service.StateRunning}} + writeInstallSummary(&buf, context.Background(), mgr, service.ScopeUser, opts) + + out := buf.String() + if !strings.Contains(out, cacheRoot) { + t.Errorf("the install summary does not report the cache root %q of the config file:\n%s", cacheRoot, out) + } +} + +// TestUninstallSummaryNamesTheEffectiveCacheRoot checks that the summary of +// the uninstall names the cache root the instance uses. +func TestUninstallSummaryNamesTheEffectiveCacheRoot(t *testing.T) { + dir := t.TempDir() + configFile := filepath.Join(dir, "hfdesk.json") + cacheRoot := filepath.Join(dir, "instance-cache") + writeConfigFile(t, configFile, cacheRoot) + + for _, tc := range []struct { + name string + status service.Status + want string + }{ + { + name: "the cache root of the config file of the instance", + status: service.Status{ + Paths: runtimepaths.Paths{ + ConfigFile: configFile, + StateDir: filepath.Join(dir, "state"), + LogDir: filepath.Join(dir, "logs"), + }, + }, + want: cacheRoot, + }, + { + name: "the cache root of the registration", + status: service.Status{ + Paths: runtimepaths.Paths{ + ConfigFile: filepath.Join(dir, "missing.json"), + StateDir: filepath.Join(dir, "state"), + LogDir: filepath.Join(dir, "logs"), + }, + CacheDir: "/srv/registered-cache", + }, + want: "/srv/registered-cache", + }, + } { + t.Run(tc.name, func(t *testing.T) { + mgr := fakeManager{status: tc.status} + paths, reported := instanceDataPaths(context.Background(), mgr, service.ScopeUser) + + var buf bytes.Buffer + writeUninstallSummary(&buf, service.ScopeUser, paths, reported) + + out := buf.String() + if !strings.Contains(out, tc.want) { + t.Errorf("the uninstall summary does not name the cache root %q:\n%s", tc.want, out) + } + if personal := personalCacheRoot(t); personal != "" && strings.Contains(out, personal) { + t.Errorf("the uninstall summary reports the personal cache of the invoking user %q:\n%s", personal, out) + } + }) + } +} + func TestNonLoopbackWarning(t *testing.T) { warning := nonLoopbackWarning("0.0.0.0:8080") if warning == "" { diff --git a/docs/API.md b/docs/API.md index a93b3bf8..2ae050a6 100644 --- a/docs/API.md +++ b/docs/API.md @@ -819,6 +819,11 @@ describing H's derivation. Environment paths/defaults are captured at server startup, not re-read by getters or runners. Editing R with ENV H set changes friendly views, not Hub storage, and does not move existing files. +Service mode: the order above does not apply. The config file the instance owns +wins over the cache root recorded in its registration, because the settings an +administrator changes through the settings API of a supervised instance have to +survive a restart of the instance. + When `localDir` is set, downloads use real files under `//`, which matches LM Studio-style model roots. `localScanDirs` are independent model roots scanned as `/` folders diff --git a/docs/SERVICE.md b/docs/SERVICE.md index 2a770882..08073c12 100644 --- a/docs/SERVICE.md +++ b/docs/SERVICE.md @@ -79,6 +79,10 @@ The path flags override the default locations of the scope. Every path of an installation must be absolute; a relative path is rejected instead of being resolved against the working directory of the supervisor. +A cache or output directory recorded in the registration is the **fallback** +of the instance: the config file the instance owns wins over it (see +[The effective cache root of an instance](#the-effective-cache-root-of-an-instance)). + `install` validates the installation before it writes anything: the executable must exist, be a regular file and be executable, the bind address must be usable, and the runtime paths must be absolute. A failed installation @@ -121,6 +125,36 @@ The state root holds the mutable runtime state of the instance: the job list, the download history and the mirror targets. The config file is the settings file of the instance and holds its credentials. +## The effective cache root of an instance + +An instance has two sources for its storage locations: the registration it +was installed with and the config file it owns. The **config file of the +instance wins**: + +- When the config file defines a `cache-dir`, that is the cache root the + instance uses. The cache root recorded in the registration is the fallback + for an instance whose config file does not define one. +- The same holds for the flat output directory (`local-dir`). +- The hub directory follows the cache root: it is the `hub` child + (`/hub`) of the cache root the config file of the instance + defines. Without a cache root from the config file, the hub directory + recorded in the registration is used, and a registration without one falls + back to the `hub` child of the effective cache root. + +By default the registration records the cache root of the scope: the +Hugging Face cache of the user (`HF_HOME`, otherwise `~/.cache/huggingface`) +for a user-scope instance, and `/cache` for a machine-wide one +(`/var/lib/hfdesk/cache` on Linux and macOS, `%ProgramData%\HFDesk\cache` on +Windows). + +**Consequence for the user:** changing the cache directory in the settings of +the service (`POST /api/settings`) is honoured by the next start of the +service, because those settings are written into the config file of the +instance and that file wins over the registration. + +The interactive CLI is unchanged: there a `--cache-dir` flag still wins over +the config file, exactly like a `--local-dir` flag. + ## Network exposure **Service mode binds loopback by default** (`127.0.0.1:8080`), even though the @@ -180,13 +214,19 @@ supervisor: the systemd journal on Linux, the launchd log files on macOS. | `start` | Starts the registered service. | | `stop` | Stops the registered service. | | `restart` | Stops and starts the registered service. | -| `status` | Reports scope, state, registration path, executable, bind address and runtime paths. | +| `status` | Reports scope, state, registration path, executable, bind address, runtime paths and the effective cache root. | **Uninstall never deletes user data.** It removes the registration and nothing else: not the models, not the cache, not the config file, not the jobs or the history, not the logs. Removing the state of an instance is a separate, deliberate action. +The `Cache root` line of `status` and of `uninstall` is the **effective** +cache root of the instance — the one the instance actually uses (see +[The effective cache root of an instance](#the-effective-cache-root-of-an-instance)). +It is not necessarily the cache root that was recorded in the registration: +an instance whose own config file defines a `cache-dir` uses that one. + The commands are idempotent where the wanted state is already reached: - `uninstall` on a service that is not installed succeeds; the registration is @@ -248,6 +288,33 @@ hfdesk service install # re-registers the current binary - The property list holds the label, the program arguments and the environment of the instance — no credential. +#### Future app-bundle packaging + +HFDesk ships a standalone CLI binary today. The LaunchAgent and the +LaunchDaemon are therefore registered by writing a property list into +`~/Library/LaunchAgents` or `/Library/LaunchDaemons`. That is the +**transitional path**. + +On macOS 13 and later, Apple's +[`SMAppService`](https://developer.apple.com/documentation/servicemanagement/smappservice) +is the preferred API for registering a LaunchAgent or a LaunchDaemon that +**lives inside an application bundle**: the registration becomes part of the +bundle instead of a loose property list in a Library directory. + +If HFDesk later gains a proper `.app`, the registration path must **migrate** +to `SMAppService`. The legacy property-list installation must not stay alive +inside the app-bundle flow, so the two mechanisms never register the same +service at the same time. + +The migration is deliberately **not** implemented now: it would require a +Swift helper and an app bundle, and this work explicitly does not force +either. + +**Consequence for the user:** a registration made by the current CLI keeps +working. A future app bundle takes the registration over through +`SMAppService`, and the app bundle and the CLI must not both register the +same service at the same time. + ### Windows (Service Control Manager) - The Service Control Manager is used **directly through the Go API**, never @@ -261,6 +328,30 @@ hfdesk service install # re-registers the current binary tree: changing the ownership of a large, possibly shared tree is an explicit decision of an administrator, not a side effect of an install. +### The identity of a machine-wide instance + +A machine-wide (`--system`) instance runs as a deliberate, documented identity +of its platform. It never inherits the home directory of the user who runs the +install command, and it never inherits the personal profile of root. + +| Platform | The instance runs as | Why | +|---|---|---| +| Linux (systemd, `--system`) | **root** — the unit sets no `User=`, so the instance runs with the identity of the system manager itself | The installer creates no dedicated account. The unit owns the machine-level paths (`/etc/hfdesk/hfdesk.json`, `/var/lib/hfdesk`, `/var/log/hfdesk`) and sets `HOME=/var/lib/hfdesk`, so the instance inherits neither the home of root nor the cache of the invoking user. | +| macOS (launchd, `--system`) | **root:wheel** (`UserName`/`GroupName` of the property list) | The same machine-level paths (`/etc/hfdesk/hfdesk.json`, `/var/lib/hfdesk`, `/var/log/hfdesk`) and the same `HOME` rule: the daemon uses `/var/lib/hfdesk` as its home, not the personal home of root. | +| Windows (Service Control Manager) | **LocalService** (`NT AUTHORITY\LocalService`) | A documented minimum-privilege Windows service account. It is deliberately **not** `LocalSystem`: the instance needs no machine-wide privilege, and `LocalSystem` would give it more access than it uses. | + +**Consequences:** + +- A machine-wide instance on Linux and macOS can read and write everything + the root account can. Use one only on a machine that is dedicated to + serving models, and place the cache on a volume whose ownership fits the + identity of the instance. +- The Windows instance has a profile and a filesystem access of its own: it + does not reuse `%APPDATA%` and it does not point at the model or cache + directories of the interactive user. +- The **user-scope** instance is the one that keeps the identity of the user + and the access to their own Hugging Face cache. + ### Privileged operations Scope changes are operating-system operations performed by an administrator. From 4d8b80a63e74e0198341bde48f6ae7ba5063b82b Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 21 Oct 2026 22:26:55 +1100 Subject: [PATCH 10/58] fix(cmd): keep the registered cache layout together - Move the hub directory with the cache root when only --cache-dir is given, so a registration never names two different trees. An explicit --hub-dir is still honoured as given: it is the deliberate separation of the two locations that HF_HOME and HF_HUB_CACHE have. - Report the hub directory of the instance in the install summary, next to the cache root, so both describe the layout the instance will use. - Name the subcommand that owns --listen in the usage block instead of advertising it for the whole family, and correct the description of the install command: a macOS LaunchAgent starts the instance when it is registered, a systemd unit is only registered and enabled. Co-Authored-By: opencode --- cmd/hfdesk/service.go | 58 ++++++++++- cmd/hfdesk/service_test.go | 201 +++++++++++++++++++++++++++++++++++++ docs/SERVICE.md | 23 ++++- 3 files changed, 273 insertions(+), 9 deletions(-) diff --git a/cmd/hfdesk/service.go b/cmd/hfdesk/service.go index 1a9e91fa..f117be78 100644 --- a/cmd/hfdesk/service.go +++ b/cmd/hfdesk/service.go @@ -150,6 +150,10 @@ func serviceErrorText(err error) string { } // writeServiceUsage prints the usage block of the service command family. +// +// An option of a single subcommand is listed with the subcommand that owns +// it: a family level option list would offer a flag that the other +// subcommands reject. func writeServiceUsage(w io.Writer) { fmt.Fprint(w, "Usage: hfdesk service [options]\n\n") fmt.Fprint(w, "Subcommands:\n") @@ -169,7 +173,7 @@ Scope: --user per-user installation (the default on Linux and macOS) --system machine-wide installation (the default on Windows) -Options: +Options of the "install" subcommand: --listen HOST:PORT bind address of the instance (default %s) @@ -337,6 +341,13 @@ type installFlags struct { // The cache directories are derived from the runtime paths, so they are // resolved after the overrides: a machine-wide instance keeps its cache // inside its own state root even when the state root was overridden. +// +// The cache root and the hub directory are one layout, so an explicit cache +// root moves the hub directory with it: a registration that names a cache +// root and the hub of another tree would write the blobs of the instance +// into a directory its own cache root does not contain. A hub directory that +// was named explicitly is honoured as given: it separates the two locations +// on purpose, the same independence HF_HOME and HF_HUB_CACHE have. func buildInstallOptions(scope service.Scope, flags installFlags, executable string) (service.InstallOptions, error) { paths, err := service.DefaultPaths(scope) if err != nil { @@ -355,6 +366,9 @@ func buildInstallOptions(scope service.Scope, flags installFlags, executable str cacheRoot, hubDir := service.DefaultCacheDirs(scope, paths) if flags.cacheDir != "" { cacheRoot = flags.cacheDir + if flags.hubDir == "" { + hubDir = filepath.Join(cacheRoot, "hub") + } } if flags.hubDir != "" { hubDir = flags.hubDir @@ -458,12 +472,15 @@ func serviceInstall(args []string, stdout io.Writer) error { // The path of the registration and the state of the instance are known only // after the installation, so both are read back from the supervisor. // -// The cache root is the effective one: the config file of the instance wins -// over the registered value, because the cache directory is changed in the -// settings of the instance and the change has to survive a restart. +// The cache root and the hub directory are the effective ones: the config +// file of the instance wins over the registered values, because the cache +// directory is changed in the settings of the instance and the change has to +// survive a restart. func writeInstallSummary(w io.Writer, ctx context.Context, mgr service.Manager, scope service.Scope, opts service.InstallOptions) { status, statusErr := mgr.Status(ctx) + cacheRoot, hubDir := effectiveCacheDirs(opts) + writeField(w, "Service", service.ServiceName) writeField(w, "Scope", string(scope)) writeField(w, "Registration", registrationPath(status)) @@ -472,7 +489,8 @@ func writeInstallSummary(w io.Writer, ctx context.Context, mgr service.Manager, writeField(w, "Config file", opts.Paths.ConfigFile) writeField(w, "State dir", opts.Paths.StateDir) writeField(w, "Log dir", opts.Paths.LogDir) - writeField(w, "Cache root", service.EffectiveCacheDir(opts.Paths.ConfigFile, opts.CacheDir)) + writeField(w, "Cache root", cacheRoot) + writeField(w, "Hub dir", hubDir) if warning := nonLoopbackWarning(opts.Listen); warning != "" { fmt.Fprintln(w) @@ -488,6 +506,36 @@ func writeInstallSummary(w io.Writer, ctx context.Context, mgr service.Manager, writeInstallState(w, status, statusErr) } +// effectiveCacheDirs returns the cache root and the hub directory the +// instance uses. +// +// The rule is the one the headless host applies to the running instance: a +// config file that defines a cache root owns the whole cache layout of the +// instance, so the hub directory is the "hub" child of that root. Without a +// cache root from the config file the registration keeps its hub directory, +// and a registration without one falls back to the "hub" child of the +// effective cache root. +// +// The two directories are reported as one pair: a summary that named a cache +// root without its hub directory would hide a layout that sends the blobs of +// the instance somewhere else. +func effectiveCacheDirs(opts service.InstallOptions) (string, string) { + cacheRoot := service.EffectiveCacheDir(opts.Paths.ConfigFile, opts.CacheDir) + + // The registered value is empty when the config file of the instance + // defines a cache root of its own: the config file then owns the whole + // layout of the instance. + if configured := service.EffectiveCacheDir(opts.Paths.ConfigFile, ""); configured != "" { + return configured, filepath.Join(configured, "hub") + } + + hubDir := opts.HubDir + if hubDir == "" && cacheRoot != "" { + hubDir = filepath.Join(cacheRoot, "hub") + } + return cacheRoot, hubDir +} + // writeInstallState prints the state the instance reached after the // registration and the hint that starts it. // diff --git a/cmd/hfdesk/service_test.go b/cmd/hfdesk/service_test.go index d634ff7e..4e61ca13 100644 --- a/cmd/hfdesk/service_test.go +++ b/cmd/hfdesk/service_test.go @@ -73,6 +73,40 @@ func writeConfigFile(t *testing.T, path, cacheRoot string) { } } +// summaryField returns the value of a "Key: value" line of a summary block. +// +// The value is read from the line of the key itself, so a value that is the +// prefix of another value of the same block cannot satisfy the check. +func summaryField(out, key string) string { + for _, line := range strings.Split(out, "\n") { + trimmed := strings.TrimSpace(line) + if !strings.HasPrefix(trimmed, key+":") { + continue + } + return strings.TrimSpace(strings.TrimPrefix(trimmed, key+":")) + } + return "" +} + +// usageOptionOwner returns the heading that introduces the option block the +// given option belongs to. +func usageOptionOwner(out, option string) string { + lines := strings.Split(out, "\n") + for i, line := range lines { + if !strings.HasPrefix(strings.TrimSpace(line), option) { + continue + } + for j := i - 1; j >= 0; j-- { + heading := strings.TrimSpace(lines[j]) + if heading == "" { + continue + } + return heading + } + } + return "" +} + func TestServiceUsageListsEverySubcommand(t *testing.T) { var buf bytes.Buffer writeServiceUsage(&buf) @@ -85,6 +119,41 @@ func TestServiceUsageListsEverySubcommand(t *testing.T) { } } +// TestServiceUsageNamesTheOwnerOfTheListenOption checks that the usage +// block of the family does not offer a bind address as an option of the +// family: the option is listed with the subcommand that accepts it, because +// the other subcommands reject it. +func TestServiceUsageNamesTheOwnerOfTheListenOption(t *testing.T) { + var buf bytes.Buffer + writeServiceUsage(&buf) + out := buf.String() + + owner := usageOptionOwner(out, "--listen") + if owner == "" { + // The family block does not offer the option at all: nothing it + // advertises is rejected by a subcommand. + return + } + if !strings.Contains(owner, "install") { + t.Errorf("the usage block offers --listen without naming the subcommand that accepts it (%q):\n%s", owner, out) + } +} + +// TestServiceLifecycleSubcommandsRejectTheListenOption checks that the bind +// address is an option of the install subcommand only. +func TestServiceLifecycleSubcommandsRejectTheListenOption(t *testing.T) { + for _, sub := range []string{"uninstall", "start", "stop", "restart", "status"} { + var stdout, stderr bytes.Buffer + code := runServiceCommand([]string{sub, "--listen", "127.0.0.1:9090"}, &stdout, &stderr) + if code == 0 { + t.Errorf("the %q subcommand must reject --listen, got exit code %d", sub, code) + } + if !strings.Contains(stderr.String(), "listen") { + t.Errorf("the error of the %q subcommand does not name the rejected option:\n%s", sub, stderr.String()) + } + } +} + func TestServiceHelpSucceeds(t *testing.T) { var stdout, stderr bytes.Buffer code := runServiceCommand([]string{"--help"}, &stdout, &stderr) @@ -296,6 +365,69 @@ func TestBuildInstallOptionsAppliesTheOverrides(t *testing.T) { } } +// TestBuildInstallOptionsKeepsTheCacheLayoutTogether checks that the +// registered cache root and the registered hub directory are one layout: an +// explicit cache root moves the hub directory with it, and an explicit hub +// directory is honoured as given. +// +// A registration that names a cache root and the hub of another tree would +// write the blobs of the instance into a directory its own cache root does +// not contain. +func TestBuildInstallOptionsKeepsTheCacheLayoutTogether(t *testing.T) { + const executable = "/usr/local/bin/hfdesk" + + defaults, err := service.DefaultPaths(service.ScopeUser) + if err != nil { + t.Fatalf("the default paths of the scope: %v", err) + } + defaultRoot, defaultHub := service.DefaultCacheDirs(service.ScopeUser, defaults) + + for _, tc := range []struct { + name string + flags installFlags + wantRoot string + wantHub string + }{ + { + name: "a cache root alone moves the hub directory with it", + flags: installFlags{cacheDir: "/srv/cache"}, + wantRoot: "/srv/cache", + wantHub: "/srv/cache/hub", + }, + { + name: "a hub directory alone is honoured as given", + flags: installFlags{hubDir: "/mnt/hub"}, + wantRoot: defaultRoot, + wantHub: "/mnt/hub", + }, + { + name: "both directories are honoured as given", + flags: installFlags{cacheDir: "/srv/cache", hubDir: "/mnt/hub"}, + wantRoot: "/srv/cache", + wantHub: "/mnt/hub", + }, + { + name: "without an override the pair of the scope is kept", + flags: installFlags{}, + wantRoot: defaultRoot, + wantHub: defaultHub, + }, + } { + t.Run(tc.name, func(t *testing.T) { + opts, err := buildInstallOptions(service.ScopeUser, tc.flags, executable) + if err != nil { + t.Fatalf("build the install options: %v", err) + } + if opts.CacheDir != tc.wantRoot { + t.Errorf("the cache root is %q, want %q", opts.CacheDir, tc.wantRoot) + } + if opts.HubDir != tc.wantHub { + t.Errorf("the hub dir is %q, want %q", opts.HubDir, tc.wantHub) + } + }) + } +} + func TestRenderStatusReportsTheInstance(t *testing.T) { status := service.Status{ State: service.StateRunning, @@ -534,6 +666,75 @@ func TestInstallSummaryReportsTheEffectiveCacheRoot(t *testing.T) { } } +// TestInstallSummaryReportsTheCachePairOfTheInstance checks that the +// summary of the installation names the cache root and the hub directory of +// the pair the instance uses. +// +// A summary that names a cache root without its hub directory hides a layout +// that sends the blobs of the instance somewhere else. +func TestInstallSummaryReportsTheCachePairOfTheInstance(t *testing.T) { + opts := service.InstallOptions{ + Scope: service.ScopeUser, + Executable: "/usr/local/bin/hfdesk", + Listen: "127.0.0.1:8080", + Paths: runtimepaths.Paths{ + ConfigFile: filepath.Join(t.TempDir(), "hfdesk.json"), + StateDir: "/var/lib/hfdesk", + LogDir: "/var/log/hfdesk", + }, + CacheDir: "/srv/cache", + HubDir: "/srv/cache/hub", + } + + var buf bytes.Buffer + mgr := fakeManager{status: service.Status{State: service.StateRunning}} + writeInstallSummary(&buf, context.Background(), mgr, service.ScopeUser, opts) + + out := buf.String() + if got := summaryField(out, "Cache root"); got != "/srv/cache" { + t.Errorf("the cache root of the summary is %q, want %q:\n%s", got, "/srv/cache", out) + } + if got := summaryField(out, "Hub dir"); got != "/srv/cache/hub" { + t.Errorf("the hub dir of the summary is %q, want %q:\n%s", got, "/srv/cache/hub", out) + } +} + +// TestInstallSummaryReportsTheHubDirOfTheConfigFile checks that the summary +// names the hub directory of the cache root the config file of the instance +// defines: the config file owns the whole cache layout of the instance. +func TestInstallSummaryReportsTheHubDirOfTheConfigFile(t *testing.T) { + dir := t.TempDir() + configFile := filepath.Join(dir, "hfdesk.json") + cacheRoot := filepath.Join(dir, "instance-cache") + writeConfigFile(t, configFile, cacheRoot) + + opts := service.InstallOptions{ + Scope: service.ScopeUser, + Executable: "/usr/local/bin/hfdesk", + Listen: "127.0.0.1:8080", + Paths: runtimepaths.Paths{ + ConfigFile: configFile, + StateDir: filepath.Join(dir, "state"), + LogDir: filepath.Join(dir, "logs"), + }, + CacheDir: "/srv/cache", + HubDir: "/srv/cache/hub", + } + + var buf bytes.Buffer + mgr := fakeManager{status: service.Status{State: service.StateRunning}} + writeInstallSummary(&buf, context.Background(), mgr, service.ScopeUser, opts) + + out := buf.String() + if got := summaryField(out, "Cache root"); got != cacheRoot { + t.Errorf("the cache root of the summary is %q, want %q:\n%s", got, cacheRoot, out) + } + wantHub := filepath.Join(cacheRoot, "hub") + if got := summaryField(out, "Hub dir"); got != wantHub { + t.Errorf("the hub dir of the summary is %q, want %q:\n%s", got, wantHub, out) + } +} + // TestUninstallSummaryNamesTheEffectiveCacheRoot checks that the summary of // the uninstall names the cache root the instance uses. func TestUninstallSummaryNamesTheEffectiveCacheRoot(t *testing.T) { diff --git a/docs/SERVICE.md b/docs/SERVICE.md index 08073c12..f363227e 100644 --- a/docs/SERVICE.md +++ b/docs/SERVICE.md @@ -71,14 +71,23 @@ never opens a browser. | `--config PATH` | Absolute path of the config file the instance owns. | | `--state-dir DIR` | Absolute path of the state directory (jobs, history, mirror targets). | | `--log-dir DIR` | Absolute path of the log directory. | -| `--cache-dir DIR` | Absolute path of the Hugging Face cache root. | -| `--hub-dir DIR` | Absolute path of the Hugging Face hub cache directory. | +| `--cache-dir DIR` | Absolute path of the Hugging Face cache root. Without `--hub-dir`, the hub directory follows it: `/hub`. | +| `--hub-dir DIR` | Absolute path of the Hugging Face hub cache directory. Given explicitly, it separates the hub from the cache root on purpose. | | `--local-dir DIR` | Absolute path of the flat output directory. | The path flags override the default locations of the scope. Every path of an installation must be absolute; a relative path is rejected instead of being resolved against the working directory of the supervisor. +The cache root and the hub directory are **one layout**: the cache root holds +the friendly views (`models/`, `datasets/`), the hub directory holds the +blobs. `--cache-dir` alone therefore moves the whole pair — the registered +hub directory becomes the `hub` child (`/hub`) of the registered +cache root, so a registration never names two different trees. `--hub-dir` +names the hub directory explicitly and separates the two locations on +purpose, the same independence `HF_HOME` and `HF_HUB_CACHE` have. The +summary of the installation reports both halves of the pair it registered. + A cache or output directory recorded in the registration is the **fallback** of the instance: the config file the instance owns wins over it (see [The effective cache root of an instance](#the-effective-cache-root-of-an-instance)). @@ -145,7 +154,9 @@ By default the registration records the cache root of the scope: the Hugging Face cache of the user (`HF_HOME`, otherwise `~/.cache/huggingface`) for a user-scope instance, and `/cache` for a machine-wide one (`/var/lib/hfdesk/cache` on Linux and macOS, `%ProgramData%\HFDesk\cache` on -Windows). +Windows). The registered hub directory is the `hub` child of the registered +cache root, unless a hub directory was given explicitly (see +[Install flags](#install-flags)). **Consequence for the user:** changing the cache directory in the settings of the service (`POST /api/settings`) is honoured by the next start of the @@ -209,7 +220,7 @@ supervisor: the systemd journal on Linux, the launchd log files on macOS. | Command | Effect | |---|---| -| `install` | Registers the service with the supervisor and enables it for the boot of the scope. An existing registration is overwritten. | +| `install` | Registers the service with the supervisor and enables it for the boot of the scope. A macOS LaunchAgent starts the instance as soon as it is registered (`RunAtLoad`); a systemd unit is only registered and enabled. The `State` line of the summary reports the state the supervisor reached. An existing registration is overwritten. | | `uninstall` | Removes the **registration only**. | | `start` | Starts the registered service. | | `stop` | Stops the registered service. | @@ -227,6 +238,10 @@ cache root of the instance — the one the instance actually uses (see It is not necessarily the cache root that was recorded in the registration: an instance whose own config file defines a `cache-dir` uses that one. +The summary of `install` reports the same pair: its `Cache root` and `Hub +dir` lines name the two directories the registered instance uses, so a reader +sees at once which tree the models of the instance go into. + The commands are idempotent where the wanted state is already reached: - `uninstall` on a service that is not installed succeeds; the registration is From d01038b1eeefc902c48ba5c0a76c8940bfe98b45 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 21 Oct 2026 22:26:55 +1100 Subject: [PATCH 11/58] fix(service): probe a wildcard bind over the loopback address - A wildcard bind has no address a client can connect to, so the readiness probe of the Windows service now dials the loopback address of the same family. Without it a service that listens on every interface could fail its own startup and be reported as stopped while it is running. - Extract the decision of a broken registration into a testable helper and cover it, so the criterion that a moved or deleted executable is reported clearly is proven on Windows as well. Co-Authored-By: opencode --- internal/service/manager_windows.go | 32 ++++++++-- internal/service/manager_windows_test.go | 44 ++++++++++++++ internal/service/scm_windows.go | 45 ++++++++++++-- internal/service/scm_windows_test.go | 74 ++++++++++++++++++++++++ 4 files changed, 184 insertions(+), 11 deletions(-) diff --git a/internal/service/manager_windows.go b/internal/service/manager_windows.go index b7e42142..3f4a0d71 100644 --- a/internal/service/manager_windows.go +++ b/internal/service/manager_windows.go @@ -344,12 +344,10 @@ func (m *scmManager) Status(ctx context.Context) (Status, error) { } applyRegistration(&status, registration) - if status.Executable != "" { - if _, err := os.Stat(status.Executable); err != nil { - status.State = StateBroken - status.Detail = fmt.Sprintf("the registered executable %s does not exist", status.Executable) - return status, nil - } + if broken, detail := scmBrokenRegistration(status.Executable); broken { + status.State = StateBroken + status.Detail = detail + return status, nil } query, err := handle.Query() @@ -363,6 +361,28 @@ func (m *scmManager) Status(ctx context.Context) (Status, error) { return status, nil } +// scmBrokenRegistration reports whether the registration points at an +// executable that is gone and describes the damage. +// +// A registration that points at a program that does not exist anymore is +// broken: the instance can never run again until it is installed again. A +// registration that carries no executable at all is not broken: there is no +// program that could be missing. +// +// The decision is a pure function of the registered path, so it can be +// checked without a registration of its own: reading the command line of a +// registration needs a real service in the service control manager, which a +// test cannot create without an elevated shell. +func scmBrokenRegistration(executable string) (bool, string) { + if executable == "" { + return false, "" + } + if _, err := os.Stat(executable); err != nil { + return true, fmt.Sprintf("the registered executable %s does not exist", executable) + } + return false, "" +} + // applyRegistration fills the status with the facts of the registration. // // The cache root of the status is the effective one: the cache directory of diff --git a/internal/service/manager_windows_test.go b/internal/service/manager_windows_test.go index cb88fe9a..b8afc98a 100644 --- a/internal/service/manager_windows_test.go +++ b/internal/service/manager_windows_test.go @@ -332,6 +332,50 @@ func TestSCMLifecycle(t *testing.T) { } } +// TestSCMBrokenRegistration checks that a registration that points at a +// program that is gone is reported as broken. +// +// The decision is checked on the registered path alone: reading the command +// line of a registration needs a real service in the service control +// manager, which a test cannot create without an elevated shell. +func TestSCMBrokenRegistration(t *testing.T) { + root := t.TempDir() + existing := filepath.Join(root, "hfdesk.exe") + if err := os.WriteFile(existing, []byte("not a program"), 0o600); err != nil { + t.Fatalf("WriteFile() = %v, want nil", err) + } + missing := filepath.Join(root, "gone", "hfdesk.exe") + + // A registered program that is gone is a broken registration, and the + // detail names the missing path. + broken, detail := scmBrokenRegistration(missing) + if !broken { + t.Errorf("scmBrokenRegistration(%q) = %v, want a broken registration", missing, broken) + } + if !strings.Contains(detail, missing) { + t.Errorf("the detail = %q, want the path of the missing program %q", detail, missing) + } + + // A registered program that exists is not a broken registration. + broken, detail = scmBrokenRegistration(existing) + if broken { + t.Errorf("scmBrokenRegistration(%q) = %v, want no broken registration", existing, broken) + } + if detail != "" { + t.Errorf("the detail = %q, want none", detail) + } + + // A registration that carries no program has nothing that could be + // missing. + broken, detail = scmBrokenRegistration("") + if broken { + t.Errorf("scmBrokenRegistration(\"\") = %v, want no broken registration", broken) + } + if detail != "" { + t.Errorf("the detail = %q, want none", detail) + } +} + // TestSDDLGrantsIdentity checks the access control list of a path against // the account the instance runs as. // diff --git a/internal/service/scm_windows.go b/internal/service/scm_windows.go index d194b2cf..08f4ca4b 100644 --- a/internal/service/scm_windows.go +++ b/internal/service/scm_windows.go @@ -13,6 +13,7 @@ import ( "net" "net/http" "os" + "strings" "time" "golang.org/x/sys/windows/svc" @@ -364,12 +365,10 @@ func (h *scmHandler) waitForReady(ctx context.Context, listen string, hostErr <- // // Any HTTP status is accepted: the point of the probe is that an HTTP // server listens on the address and answers, not that it answers with a -// particular code. +// particular code. A server that answers with the status of the basic +// authentication of the instance is a server that answers. func probeServiceReady(listen string) error { - addr := listen - if addr == "" { - addr = DefaultListen - } + addr := scmProbeAddress(listen) conn, err := net.DialTimeout("tcp", addr, scmProbeTimeout) if err != nil { return err @@ -386,6 +385,42 @@ func probeServiceReady(listen string) error { return nil } +// scmProbeAddress maps the bind address of the instance onto the address +// the readiness probe dials. +// +// A wildcard bind has no address a client can connect to: 0.0.0.0 and :: are +// the addresses every interface of the machine answers on, and a connect() +// to them fails with WSAEADDRNOTAVAIL. The probe therefore dials the +// loopback address of the same family, which a wildcard listener accepts +// like any other client. Every other host is used unchanged. +// +// The port of the bind address is always kept: the probe has to reach the +// server of the instance and not another one. +func scmProbeAddress(listen string) string { + addr := strings.TrimSpace(listen) + if addr == "" { + addr = DefaultListen + } + host, port, err := net.SplitHostPort(addr) + if err != nil { + // An address that carries no port cannot be mapped: it is used + // unchanged, so the probe reports the error of the dial itself. + return addr + } + switch host { + case "0.0.0.0": + host = "127.0.0.1" + case "::": + host = "::1" + case "": + // An empty host binds every interface of every family. The + // probe uses the loopback address of the IPv4 family, which a + // dual-stack listener accepts. + host = "127.0.0.1" + } + return net.JoinHostPort(host, port) +} + // eventLogLevel is the kind of a message of the Windows Event Log. type eventLogLevel int diff --git a/internal/service/scm_windows_test.go b/internal/service/scm_windows_test.go index 3f0f94f0..d9645626 100644 --- a/internal/service/scm_windows_test.go +++ b/internal/service/scm_windows_test.go @@ -6,6 +6,8 @@ package service import ( + "net" + "net/http" "path/filepath" "reflect" "strconv" @@ -201,3 +203,75 @@ func TestResolveOptionsReadsTheRegisteredArguments(t *testing.T) { t.Errorf("the listen address = %q, want %q", opts.Listen, "127.0.0.1:9090") } } + +// TestSCMProbeAddressMapsTheWildcardBindOntoTheLoopback checks the address +// the readiness probe dials. +// +// A wildcard bind has no address a client can connect to: 0.0.0.0 and :: +// are the addresses every interface answers on, and a connect() to them +// fails with WSAEADDRNOTAVAIL. The probe therefore dials the loopback +// address of the same family, and every other host is used unchanged. +func TestSCMProbeAddressMapsTheWildcardBindOntoTheLoopback(t *testing.T) { + cases := []struct { + name string + listen string + want string + }{ + {"the IPv4 wildcard", "0.0.0.0:8080", "127.0.0.1:8080"}, + {"the IPv6 wildcard", "[::]:8080", "[::1]:8080"}, + {"an empty host", ":8080", "127.0.0.1:8080"}, + {"an IPv4 loopback", "127.0.0.1:8080", "127.0.0.1:8080"}, + {"an IPv6 loopback", "[::1]:8080", "[::1]:8080"}, + {"an address of the network", "192.168.1.5:8080", "192.168.1.5:8080"}, + {"a host name", "localhost:8080", "localhost:8080"}, + {"no address at all", "", DefaultListen}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := scmProbeAddress(tc.listen); got != tc.want { + t.Errorf("scmProbeAddress(%q) = %q, want %q", tc.listen, got, tc.want) + } + }) + } +} + +// TestProbeServiceReadyReachesAWildcardListener checks that the readiness +// probe reaches a server that binds every interface of the machine. +// +// The server answers with the status of the basic authentication of the +// instance: the probe accepts every answer, because the point of the probe +// is that an HTTP server listens on the address and not that it answers +// with a particular code. +func TestProbeServiceReadyReachesAWildcardListener(t *testing.T) { + cases := []struct { + name string + bind string + host string + }{ + {"the IPv4 wildcard", "0.0.0.0:0", "0.0.0.0"}, + {"the IPv6 wildcard", "[::]:0", "::"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + listener, err := net.Listen("tcp", tc.bind) + if err != nil { + t.Skipf("the address %q cannot be bound: %v", tc.bind, err) + } + server := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })} + go func() { + _ = server.Serve(listener) + }() + t.Cleanup(func() { + _ = server.Close() + }) + + port := listener.Addr().(*net.TCPAddr).Port + listen := net.JoinHostPort(tc.host, strconv.Itoa(port)) + if err := probeServiceReady(listen); err != nil { + t.Errorf("probeServiceReady(%q) = %v, want nil for a server that binds %q", listen, err, tc.bind) + } + }) + } +} From ea3d5c54e5a36d5d89e3d5d2976cfbd7f25e5e0c Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 21 Oct 2026 22:26:55 +1100 Subject: [PATCH 12/58] tests: cover the broken registration of the systemd manager - Extract the decision of a broken registration into a helper and drive the real status path with a unit file in a temporary unit directory, so the criterion that a moved or deleted executable is reported clearly is proven on Linux as well. Co-Authored-By: opencode --- internal/service/manager_linux.go | 31 ++++++++-- internal/service/manager_linux_test.go | 82 +++++++++++++++++++++++++- 2 files changed, 105 insertions(+), 8 deletions(-) diff --git a/internal/service/manager_linux.go b/internal/service/manager_linux.go index 97f70623..eaaae853 100644 --- a/internal/service/manager_linux.go +++ b/internal/service/manager_linux.go @@ -493,12 +493,10 @@ func (m *systemdManager) Status(ctx context.Context) (Status, error) { // reports none. status.CacheDir = EffectiveCacheDir(unit.flagValue("--config"), unit.flagValue("--cache-dir")) - if status.Executable != "" { - if _, err := os.Stat(status.Executable); err != nil { - status.State = StateBroken - status.Detail = fmt.Sprintf("registered executable not found: %s", status.Executable) - return status, nil - } + if broken, detail := brokenExecutable(status.Executable); broken { + status.State = StateBroken + status.Detail = detail + return status, nil } out, err := m.runSystemctl(ctx, "show", unitName, @@ -519,6 +517,27 @@ func (m *systemdManager) Status(ctx context.Context) (Status, error) { return status, nil } +// brokenExecutable reports whether a registration that points at the given +// executable is broken, and the detail that names the missing program. +// +// A registration without an executable is not broken: there is no program +// that could be missing. A registered program that cannot be reached is +// broken: the instance can never run again until the program is installed +// again. +// +// The decision is pure and separate from the supervisor: a broken +// registration is described by the unit file alone, so the status of a +// broken registration never depends on a running supervisor. +func brokenExecutable(executable string) (bool, string) { + if executable == "" { + return false, "" + } + if _, err := os.Stat(executable); err != nil { + return true, fmt.Sprintf("registered executable not found: %s", executable) + } + return false, "" +} + // isUnitAbsent reports whether a supervisor error means that the unit is // not known to the supervisor. Such an error is not a failure of the // operation: the wanted state (no unit) is already reached. diff --git a/internal/service/manager_linux_test.go b/internal/service/manager_linux_test.go index e3bbd896..71f99a7f 100644 --- a/internal/service/manager_linux_test.go +++ b/internal/service/manager_linux_test.go @@ -816,12 +816,87 @@ func TestSystemdStatusReportsTheCacheRoot(t *testing.T) { }) } +// TestSystemdStatusReportsAMissingExecutable checks that a registration that +// points at a program that is gone is reported as broken: the state of the +// instance is broken and the detail names the path of the missing program. +// +// The unit file is written into the temporary unit directory of the test, so +// the registration of the user of the machine is never touched, and the +// registration facts of the unit are parsed as they are in a real +// installation. +// +// The test does not need a running supervisor: a broken registration is +// decided from the unit file alone, and the other two cases only assert that +// the registration is not broken, whatever the supervisor answers. +func TestSystemdStatusReportsAMissingExecutable(t *testing.T) { + t.Run("the registered executable is gone", func(t *testing.T) { + missing := filepath.Join(t.TempDir(), "bin", "hfdesk") + status := systemdStatusOf(t, missing) + if status.State != StateBroken { + t.Errorf("the state = %q, want %q", status.State, StateBroken) + } + if !strings.Contains(status.Detail, missing) { + t.Errorf("the detail = %q, want the path of the missing program %q", status.Detail, missing) + } + if status.Executable != missing { + t.Errorf("the executable = %q, want %q", status.Executable, missing) + } + }) + + t.Run("the registered executable exists", func(t *testing.T) { + executable := systemdTestExecutable(t) + status := systemdStatusOf(t, executable) + if status.State == StateBroken { + t.Errorf("the state = %q, want a state that is not broken", status.State) + } + if status.Executable != executable { + t.Errorf("the executable = %q, want %q", status.Executable, executable) + } + }) + + t.Run("the registration holds no executable", func(t *testing.T) { + status := systemdStatusOf(t) + if status.State == StateBroken { + t.Errorf("the state = %q, want a state that is not broken", status.State) + } + if status.Executable != "" { + t.Errorf("the executable = %q, want none", status.Executable) + } + // The unit file was really read: the registration that the status + // describes is the unit of the temporary unit directory, and it + // holds no program at all. + content, err := os.ReadFile(status.UnitPath) + if err != nil { + t.Fatalf("read the unit file %q: %v", status.UnitPath, err) + } + if !strings.Contains(string(content), "ExecStart=") { + t.Errorf("the unit file holds no ExecStart directive:\n%s", content) + } + }) +} + // systemdStatus registers a unit with the given arguments in a temporary // configuration directory and returns the status of the instance. // // The unit points at a program that exists, so the status describes a // registration and not a broken one. func systemdStatus(t *testing.T, args ...string) Status { + t.Helper() + return systemdStatusOf(t, append([]string{systemdTestExecutable(t)}, args...)...) +} + +// systemdStatusOf registers a unit that starts the given words in a +// temporary configuration directory and returns the status of the instance. +// +// The words are the argument vector of the registered program, so an empty +// vector registers a unit without a program. +// +// The unit file is written into the temporary unit directory of the test, so +// the registration of the user of the machine is never touched. The +// supervisor of the environment may not know the unit: the registration +// facts are filled before the supervisor is asked, and they are the subject +// of the tests. +func systemdStatusOf(t *testing.T, words ...string) Status { t.Helper() t.Setenv("XDG_CONFIG_HOME", t.TempDir()) @@ -833,8 +908,11 @@ func systemdStatus(t *testing.T, args ...string) Status { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatalf("create the unit directory: %v", err) } - words := append([]string{systemdTestExecutable(t)}, args...) - unit := "[Service]\nExecStart=" + strings.Join(words, " ") + "\n" + quoted := make([]string, 0, len(words)) + for _, word := range words { + quoted = append(quoted, systemdQuoteAlways(word)) + } + unit := "[Service]\nExecStart=" + strings.Join(quoted, " ") + "\n" if err := os.WriteFile(path, []byte(unit), 0o644); err != nil { t.Fatalf("write the unit file: %v", err) } From dfc442e7e68c7d52d8423237851a41683c6b3065 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 21 Oct 2026 22:26:55 +1100 Subject: [PATCH 13/58] fix(server): keep the interactive directory mode unchanged - The directories a server owns are owner-only when a supervised instance owns them and keep their historical mode for an interactive run, so the per-user layout of an existing installation does not change. - Let the server create the state directory before the mirror targets are written, so the mode of the directory does not depend on which code path created it. Co-Authored-By: opencode --- internal/runtimepaths/runtimepaths.go | 9 + internal/runtimepaths/runtimepaths_test.go | 23 +++ internal/server/config.go | 10 +- internal/server/dirs.go | 40 +++++ internal/server/mirror.go | 18 +- internal/server/runtimepaths_test.go | 199 ++++++++++++++++++++- internal/server/state.go | 13 +- 7 files changed, 297 insertions(+), 15 deletions(-) create mode 100644 internal/server/dirs.go diff --git a/internal/runtimepaths/runtimepaths.go b/internal/runtimepaths/runtimepaths.go index 7f3b7c31..0c0ccb58 100644 --- a/internal/runtimepaths/runtimepaths.go +++ b/internal/runtimepaths/runtimepaths.go @@ -70,6 +70,15 @@ func Reset() { installed = nil } +// IsInstalled reports whether an explicit runtime path model is installed +// for this process. A supervised instance installs one; an interactive run +// resolves its paths per call and installs nothing. +func IsInstalled() bool { + installedMu.RLock() + defer installedMu.RUnlock() + return installed != nil +} + // Current returns the effective path model. Without an installed model the // interactive defaults are resolved again on every call, so a changed HOME, // XDG_CONFIG_HOME, APPDATA or working directory takes effect immediately. diff --git a/internal/runtimepaths/runtimepaths_test.go b/internal/runtimepaths/runtimepaths_test.go index fec1ba89..fdfb115f 100644 --- a/internal/runtimepaths/runtimepaths_test.go +++ b/internal/runtimepaths/runtimepaths_test.go @@ -77,6 +77,29 @@ func TestDerivedFileNamesLiveInStateDir(t *testing.T) { } } +func TestIsInstalledTracksTheInstalledModel(t *testing.T) { + Reset() + t.Cleanup(Reset) + + if IsInstalled() { + t.Fatalf("IsInstalled() = true before a model was installed") + } + + Install(Paths{ + ConfigFile: filepath.Join("/etc", "hfdesk", "hfdesk.json"), + StateDir: filepath.Join("/var", "lib", "hfdesk"), + LogDir: filepath.Join("/var", "log", "hfdesk"), + }) + if !IsInstalled() { + t.Fatalf("IsInstalled() = false after Install") + } + + Reset() + if IsInstalled() { + t.Fatalf("IsInstalled() = true after Reset") + } +} + func TestResetRestoresInteractiveResolution(t *testing.T) { model := Paths{ ConfigFile: filepath.Join("/etc", "hfdesk", "hfdesk.json"), diff --git a/internal/server/config.go b/internal/server/config.go index c0a92bb8..181b741d 100644 --- a/internal/server/config.go +++ b/internal/server/config.go @@ -182,12 +182,12 @@ func saveConfigFile(path string, cfg *ConfigFile) error { return errors.New("cannot save a redacted token as a credential") } - // Ensure the config directory exists. It is owner-only: the config - // directory of an instance holds the job list, the download history, - // the mirror targets and the credentials of the instance, so nobody - // else has to be able to read it. + // Ensure the config directory exists. It is created with the mode the + // server owns: an owner-only directory for a supervised instance, which + // holds the credentials of the instance, and the historical mode for an + // interactive run. configDir := filepath.Dir(path) - if err := os.MkdirAll(configDir, 0o700); err != nil { + if err := os.MkdirAll(configDir, serverDirMode()); err != nil { return err } diff --git a/internal/server/dirs.go b/internal/server/dirs.go new file mode 100644 index 00000000..fbf96619 --- /dev/null +++ b/internal/server/dirs.go @@ -0,0 +1,40 @@ +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 + +package server + +import ( + "os" + + "github.com/bashrusakh/hfdesk/internal/runtimepaths" +) + +// The permission modes of the directories the server owns. +const ( + // interactiveDirMode is the historical mode of the per-user directories + // of an interactive run. + interactiveDirMode os.FileMode = 0o755 + // ownedDirMode is the owner-only mode of the directories a supervised + // instance owns. + ownedDirMode os.FileMode = 0o700 +) + +// serverDirMode returns the permission mode for a directory the server owns. +// +// A supervised instance owns its directories: they hold the job list, the +// download history, the mirror targets and the credentials of the instance, +// so nobody else has to be able to read them. +// +// An interactive run keeps the historical mode of its per-user directories. +// The container image deliberately keeps its data root world-writable so an +// arbitrary UID can create its own state there, and an owner-only directory +// created by one UID would lock a second UID out of the shared tree. +// +// The mode must not depend on which code path created the directory, so +// every directory the server creates uses this helper. +func serverDirMode() os.FileMode { + if runtimepaths.IsInstalled() { + return ownedDirMode + } + return interactiveDirMode +} diff --git a/internal/server/mirror.go b/internal/server/mirror.go index 8199d379..72c97525 100644 --- a/internal/server/mirror.go +++ b/internal/server/mirror.go @@ -102,7 +102,7 @@ func (s *Server) handleMirrorTargetAdd(w http.ResponseWriter, r *http.Request) { cfg.Add(req.Name, absPath, req.Description) - if err := cfg.Save(runtimepaths.Current().TargetsFile()); err != nil { + if err := saveMirrorTargets(cfg); err != nil { writeError(w, http.StatusInternalServerError, "Failed to save targets", err.Error()) return } @@ -137,7 +137,7 @@ func (s *Server) handleMirrorTargetRemove(w http.ResponseWriter, r *http.Request return } - if err := cfg.Save(runtimepaths.Current().TargetsFile()); err != nil { + if err := saveMirrorTargets(cfg); err != nil { writeError(w, http.StatusInternalServerError, "Failed to save targets", err.Error()) return } @@ -404,6 +404,20 @@ func (s *Server) handleMirrorPull(w http.ResponseWriter, r *http.Request) { // --- Helper types and functions --- +// saveMirrorTargets writes the mirror targets into the state directory. +// +// The downloader package creates the parent directory of the targets file +// with its own mode, so the server creates the state directory with the mode +// it owns before the file is written: the mode of the directory must not +// depend on which code path created it. +func saveMirrorTargets(cfg *hfdownloader.TargetsConfig) error { + targetsFile := runtimepaths.Current().TargetsFile() + if err := os.MkdirAll(filepath.Dir(targetsFile), serverDirMode()); err != nil { + return err + } + return cfg.Save(targetsFile) +} + type mirrorEntry struct { Type string Repo string diff --git a/internal/server/runtimepaths_test.go b/internal/server/runtimepaths_test.go index 32fe0c01..5c7f73cd 100644 --- a/internal/server/runtimepaths_test.go +++ b/internal/server/runtimepaths_test.go @@ -9,6 +9,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "runtime" "strings" "testing" @@ -108,8 +109,204 @@ func TestSettingsReportInstalledRuntimeModel(t *testing.T) { } } +func TestServerDirectoryModeFollowsTheInstalledRuntimeModel(t *testing.T) { + runtimepaths.Reset() + t.Cleanup(runtimepaths.Reset) + + // Without an installed model the interactive run keeps the historical + // mode of its per-user directories. + if got, want := serverDirMode(), os.FileMode(0o755); got != want { + t.Errorf("serverDirMode() without an installed model = %o, want %o", got, want) + } + + runtimepaths.Install(runtimepaths.Paths{ + ConfigFile: filepath.Join(t.TempDir(), "hfdesk.json"), + StateDir: filepath.Join(t.TempDir(), "state"), + LogDir: filepath.Join(t.TempDir(), "logs"), + }) + + // A supervised instance owns its directories. + if got, want := serverDirMode(), os.FileMode(0o700); got != want { + t.Errorf("serverDirMode() with an installed model = %o, want %o", got, want) + } +} + +func TestServerDirectoriesKeepTheHistoricalModeWithoutAnInstalledModel(t *testing.T) { + runtimepaths.Reset() + t.Cleanup(runtimepaths.Reset) + setServerSandboxEnv(t, t.TempDir()) + + configDir := filepath.Join(t.TempDir(), "config") + if err := saveConfigFile(filepath.Join(configDir, "hfdesk.json"), &ConfigFile{}); err != nil { + t.Fatalf("save config file: %v", err) + } + assertDirMode(t, configDir, 0o755) + + stateDir := filepath.Join(t.TempDir(), "state") + if err := saveJobsState(filepath.Join(stateDir, "jobs_state.json"), nil); err != nil { + t.Fatalf("save jobs state: %v", err) + } + assertDirMode(t, stateDir, 0o755) + + // The history is written into the state directory of the effective + // runtime path model, which is the per-user directory of an interactive + // run. + if err := AppendHistory(&Job{ID: "1", Repo: "owner/model"}); err != nil { + t.Fatalf("append history: %v", err) + } + assertDirMode(t, AppConfigDir(), 0o755) +} + +func TestServerDirectoriesAreOwnerOnlyWithAnInstalledModel(t *testing.T) { + root := t.TempDir() + model := runtimepaths.Paths{ + ConfigFile: filepath.Join(root, "hfdesk.json"), + StateDir: filepath.Join(root, "state"), + LogDir: filepath.Join(root, "logs"), + } + runtimepaths.Install(model) + t.Cleanup(runtimepaths.Reset) + + configDir := filepath.Join(t.TempDir(), "config") + if err := saveConfigFile(filepath.Join(configDir, "hfdesk.json"), &ConfigFile{}); err != nil { + t.Fatalf("save config file: %v", err) + } + assertDirMode(t, configDir, 0o700) + + stateDir := filepath.Join(t.TempDir(), "state") + if err := saveJobsState(filepath.Join(stateDir, "jobs_state.json"), nil); err != nil { + t.Fatalf("save jobs state: %v", err) + } + assertDirMode(t, stateDir, 0o700) + + if err := AppendHistory(&Job{ID: "1", Repo: "owner/model"}); err != nil { + t.Fatalf("append history: %v", err) + } + assertDirMode(t, model.StateDir, 0o700) +} + +func TestServerDirectoriesKeepTheModeOfAnExistingDirectory(t *testing.T) { + runtimepaths.Reset() + t.Cleanup(runtimepaths.Reset) + + // MkdirAll never changes the mode of a directory that already exists, + // so an existing directory keeps its mode. + configDir := filepath.Join(t.TempDir(), "config") + if err := os.MkdirAll(configDir, 0o755); err != nil { + t.Fatal(err) + } + if err := saveConfigFile(filepath.Join(configDir, "hfdesk.json"), &ConfigFile{}); err != nil { + t.Fatalf("save config file: %v", err) + } + assertDirMode(t, configDir, 0o755) + + stateDir := filepath.Join(t.TempDir(), "state") + if err := os.MkdirAll(stateDir, 0o755); err != nil { + t.Fatal(err) + } + if err := saveJobsState(filepath.Join(stateDir, "jobs_state.json"), nil); err != nil { + t.Fatalf("save jobs state: %v", err) + } + assertDirMode(t, stateDir, 0o755) +} + +func TestMirrorTargetsSaveCreatesTheStateDirectoryWithTheServerMode(t *testing.T) { + t.Run("interactive", func(t *testing.T) { + runtimepaths.Reset() + t.Cleanup(runtimepaths.Reset) + setServerSandboxEnv(t, t.TempDir()) + + addMirrorTarget(t) + + // The state directory of an interactive run keeps the historical + // mode, so the targets save cannot introduce a different one. + assertDirMode(t, AppConfigDir(), 0o755) + if _, err := os.Stat(filepath.Join(AppConfigDir(), "targets.yaml")); err != nil { + t.Fatalf("stat targets file: %v", err) + } + }) + + t.Run("installed", func(t *testing.T) { + root := t.TempDir() + stateDir := filepath.Join(root, "state") + runtimepaths.Install(runtimepaths.Paths{ + ConfigFile: filepath.Join(root, "hfdesk.json"), + StateDir: stateDir, + LogDir: filepath.Join(root, "logs"), + }) + t.Cleanup(runtimepaths.Reset) + + addMirrorTarget(t) + + assertDirMode(t, stateDir, 0o700) + if _, err := os.Stat(filepath.Join(stateDir, "targets.yaml")); err != nil { + t.Fatalf("stat targets file: %v", err) + } + }) +} + +// addMirrorTarget adds a mirror target through the API. +func addMirrorTarget(t *testing.T) { + t.Helper() + + s := newTestServer(t) + req := httptest.NewRequest("POST", "/api/mirror/targets", strings.NewReader(`{"name":"backup","path":"/tmp/hfdesk-backup"}`)) + w := httptest.NewRecorder() + s.handleMirrorTargetAdd(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", w.Code, w.Body.String()) + } +} + +// --- helpers --- + +// setServerSandboxEnv points every platform specific config location at a +// temporary root, so an interactive run resolves its per-user directories +// inside the sandbox of the test. +func setServerSandboxEnv(t *testing.T, root string) { + t.Helper() + t.Setenv("HOME", filepath.Join(root, "home")) + t.Setenv("USERPROFILE", filepath.Join(root, "home")) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(root, "xdg")) + t.Setenv("APPDATA", filepath.Join(root, "appdata")) + t.Setenv("LOCALAPPDATA", filepath.Join(root, "localappdata")) +} + +// assertDirMode checks the permission bits of a directory. The umask of the +// process masks the mode bits of a created directory, so the expected mode is +// masked the same way. +func assertDirMode(t *testing.T, dir string, want os.FileMode) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("Windows does not support POSIX permission bits") + } + + info, err := os.Stat(dir) + if err != nil { + t.Fatalf("stat %s: %v", dir, err) + } + if got, expected := info.Mode().Perm(), want&^processUmask(t); got != expected { + t.Errorf("%s mode = %o, want %o", dir, got, expected) + } +} + +// processUmask returns the permission bits the umask of the process removes +// from a created file or directory. +func processUmask(t *testing.T) os.FileMode { + t.Helper() + + probe := filepath.Join(t.TempDir(), "umask-probe") + if err := os.Mkdir(probe, 0o777); err != nil { + t.Fatalf("create umask probe: %v", err) + } + info, err := os.Stat(probe) + if err != nil { + t.Fatalf("stat umask probe: %v", err) + } + return os.FileMode(0o777) &^ info.Mode().Perm() +} + // --- reference implementation --- -// // The helpers below re-implement the documented interactive resolution without // using the runtime path model, so the tests compare behaviour instead of // comparing a function with itself. diff --git a/internal/server/state.go b/internal/server/state.go index 470def48..5ba343ce 100644 --- a/internal/server/state.go +++ b/internal/server/state.go @@ -49,10 +49,10 @@ func saveJobsStateLocked(path string, jobs []*Job) error { if err != nil { return err } - // The state directory is owner-only: it holds the job list, the download - // history and the mirror targets of the instance, so nobody else has to - // be able to read it. - if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + // The state directory is created with the mode the server owns: an + // owner-only directory for a supervised instance, the historical mode + // for an interactive run. + if err := os.MkdirAll(filepath.Dir(path), serverDirMode()); err != nil { return err } return replaceJobsStateFile(path, data, defaultStateFileOps{}) @@ -249,9 +249,8 @@ func AppendHistory(job *Job) error { } // The state directory comes from the effective runtime path model, so a // supervised instance writes its history into its own state root. The - // directory is owner-only: it holds the job list, the download history - // and the mirror targets of the instance. - if err := os.MkdirAll(runtimepaths.Current().StateDir, 0o700); err != nil { + // directory is created with the mode the server owns. + if err := os.MkdirAll(runtimepaths.Current().StateDir, serverDirMode()); err != nil { return err } return os.WriteFile(HistoryPath(), data, 0o644) From 99836f2d0d6c310acaf0766a0c66df0048fa1f4d Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 22 Oct 2026 00:18:43 +1100 Subject: [PATCH 14/58] service: run a machine-wide instance as a dedicated account - A machine-wide instance runs as a dedicated, stable, unprivileged account instead of root: hfdesk on Linux, _hfdesk on macOS, the built-in LocalService on Windows. Root is the platform's LocalSystem analogue, and the issue asks for explicit privilege boundaries and a least-privilege identity. - HFDesk never creates an account and never shells out: it resolves the account, refuses the installation clearly when it does not exist and names the command that creates it. - The account is granted only the directories the installation created. A directory that existed before keeps its owner and its permissions, and the account has to be able to write into it already. Every directory is checked before the first one is granted, so a refused installation leaves every ownership as it was. - A named cache, hub or output directory is validated for accessibility and never rewritten: the installation gives ownership guidance instead. - Refuse a top-level directory of the machine as an instance directory on both platforms, so the same command line is answered the same way. - Let the instance configuration come from one source: it installs the runtime path model itself instead of relying on an ambient global. - Create the directories of an instance owner-only, and leave the permissions of a directory that already exists alone. Co-Authored-By: opencode --- internal/service/host.go | 11 + internal/service/host_test.go | 55 ++ internal/service/manager_darwin.go | 485 ++++++++++++++- internal/service/manager_darwin_test.go | 777 +++++++++++++++++++++++- internal/service/manager_linux.go | 405 +++++++++++- internal/service/manager_linux_test.go | 736 +++++++++++++++++++++- internal/service/service.go | 117 +++- internal/service/service_test.go | 111 +++- internal/service/serviceuser_darwin.go | 22 + internal/service/serviceuser_linux.go | 18 + internal/service/serviceuser_other.go | 15 + internal/service/serviceuser_windows.go | 17 + 12 files changed, 2705 insertions(+), 64 deletions(-) create mode 100644 internal/service/serviceuser_darwin.go create mode 100644 internal/service/serviceuser_linux.go create mode 100644 internal/service/serviceuser_other.go create mode 100644 internal/service/serviceuser_windows.go diff --git a/internal/service/host.go b/internal/service/host.go index 7aeeb660..6a19aa71 100644 --- a/internal/service/host.go +++ b/internal/service/host.go @@ -103,6 +103,12 @@ type destinations struct { // the changed value. The registration is the fallback for a destination the // config file does not define. // +// The runtime path model of the instance is installed here, so the settings +// of the instance come from its own config file and never from an ambient +// global the caller did not set. The server applies the settings of the +// instance through the process wide path model, and the model of the +// instance is the only one that may be in place. +// // The cache environment is pinned from the effective values after the // configuration was loaded: the server reads HF_HOME and HF_HUB_CACHE when // it captures the cache identity of the process, and neither variable is @@ -112,6 +118,11 @@ func buildConfig(opts RunOptions, host string, port int) (server.Config, error) return server.Config{}, fmt.Errorf("service: state dir %q must be an absolute path", opts.Paths.StateDir) } + // The path model of the instance is installed before any server state + // is read: the config file, the job state, the download history and + // the mirror targets all resolve from it. + runtimepaths.Install(opts.Paths) + // server.ApplyConfigToServer prefers HF_TOKEN over the config file, so // the environment must not keep a credential: it is removed before the // configuration is loaded and the service-owned config file is the only diff --git a/internal/service/host_test.go b/internal/service/host_test.go index a01395ef..3b342200 100644 --- a/internal/service/host_test.go +++ b/internal/service/host_test.go @@ -180,6 +180,61 @@ func TestBuildConfigUsesTheServiceOwnedPaths(t *testing.T) { } } +// TestBuildConfigInstallsTheRuntimeModel checks that the settings of an +// instance come from its own config file, even when no runtime path model +// was installed before. +// +// The server applies the settings of the instance through the process wide +// path model. Without a model of its own, the instance would read the +// settings of an ambient model instead of its own config file. +func TestBuildConfigInstallsTheRuntimeModel(t *testing.T) { + // No model is installed: the interactive resolution is in place. + runtimepaths.Reset() + t.Cleanup(runtimepaths.Reset) + + root := t.TempDir() + model := runtimepaths.Paths{ + ConfigFile: filepath.Join(root, "etc", "hfdesk.json"), + StateDir: filepath.Join(root, "var"), + LogDir: filepath.Join(root, "var", "logs"), + } + if err := os.MkdirAll(filepath.Dir(model.ConfigFile), 0o700); err != nil { + t.Fatal(err) + } + cacheRoot := filepath.Join(root, "from-config-file") + data, err := json.Marshal(map[string]string{ + "cache-dir": cacheRoot, + "token": "hf_token_of_the_instance", + }) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(model.ConfigFile, data, 0o600); err != nil { + t.Fatal(err) + } + + cfg, err := buildConfig(RunOptions{ + Paths: model, + CacheDir: "/registered/cache", + }, "127.0.0.1", 8080) + if err != nil { + t.Fatalf("buildConfig() = %v, want nil", err) + } + + if cfg.CacheDir != cacheRoot { + t.Errorf("CacheDir = %q, want the cache dir of the config file of the instance %q", cfg.CacheDir, cacheRoot) + } + // The token is applied through the path model of the instance, so it + // is the token of the config file of the instance and never the one + // of an ambient config file. + if cfg.Token != "hf_token_of_the_instance" { + t.Errorf("Token = %q, want the token of the config file of the instance", cfg.Token) + } + if got := runtimepaths.Current(); got != model { + t.Errorf("runtimepaths.Current() = %+v, want the model of the instance %+v", got, model) + } +} + // serviceInstance writes the config file of a service instance with the given // fields and installs the runtime path model that owns it. The model is // restored when the test ends. diff --git a/internal/service/manager_darwin.go b/internal/service/manager_darwin.go index 5d5f9d3d..3f4c2ca5 100644 --- a/internal/service/manager_darwin.go +++ b/internal/service/manager_darwin.go @@ -12,9 +12,11 @@ import ( "fmt" "os" "os/exec" + "os/user" "path/filepath" "strconv" "strings" + "syscall" "text/template" "time" @@ -32,10 +34,17 @@ import ( // // A user scope instance is a LaunchAgent: it belongs to the user who // installed it and it lives in the gui domain of that user. A system scope -// instance is a LaunchDaemon: it belongs to the machine, it runs as root and -// it lives in the system domain. The machine-wide instance never reuses the -// state of a user: its home is the machine-level state root of the platform -// and its paths are the machine-level paths. +// instance is a LaunchDaemon: it belongs to the machine and it lives in the +// system domain. The machine-wide instance never reuses the state of a user: +// its home is the machine-level state root of the platform and its paths are +// the machine-level paths. +// +// A machine-wide instance runs as a dedicated service account, not as root: +// the account is a stable, unprivileged identity that owns the three +// directories of the instance (the config directory, the state directory and +// the log directory) and nothing else. The installation never creates the +// account: it validates the account and refuses the installation with the +// commands that create it when the account does not exist. // // The machine-wide scope needs an elevated shell: the property list of a // LaunchDaemon is written into /Library/LaunchDaemons and the job is loaded @@ -159,6 +168,11 @@ func (m *launchdManager) target() string { // domain of the instance. An installation that fails after the property list // was written restores the previous registration, so a failed installation // never leaves a half configured registration behind. +// +// A machine-wide instance runs as a service account. The account is +// resolved and validated before any directory is created and before the +// property list is written, so a refused installation leaves nothing behind +// and every shell reports the same reason. func (m *launchdManager) Install(ctx context.Context, opts InstallOptions) error { if err := ValidateInstallOptions(opts); err != nil { return err @@ -171,20 +185,50 @@ func (m *launchdManager) Install(ctx context.Context, opts InstallOptions) error if err := m.checkSystemPaths(opts); err != nil { return err } + // The service account of a machine-wide instance is resolved before + // anything is created: an account that does not exist is a refusal with + // the commands that create it, not a property list that points at an + // identity the machine does not know. + account, err := m.resolveAccount(opts) + if err != nil { + return err + } + // A directory the user named outside of the state directory of the + // instance has to be writable by the account. The check runs before the + // directories of the instance are created, so a refused installation + // leaves nothing behind. + if err := m.checkNamedDirs(opts, account); err != nil { + return err + } // The elevation requirement is checked before any directory is created: // a permission error of the machine-wide directories is a much less // readable answer than the real reason. if err := m.requireElevated("an installation"); err != nil { return err } + // The directories the installation is about to create are remembered + // before they are created, so the account is given the ownership of + // exactly those and of nothing else. + var created map[string][]string + if account != nil { + created = make(map[string][]string, 3) + for _, dir := range instanceDirs(opts) { + created[dir.path] = createdDirs(dir.path) + } + } if err := PrepareDirs(opts); err != nil { return err } + // The instance runs as the account, so the account owns the directories + // the instance writes. + if err := m.grantInstanceDirs(opts, account, created); err != nil { + return err + } if err := m.preflight(opts); err != nil { return err } - content, err := renderPlist(opts) + content, err := renderPlist(opts, account) if err != nil { return err } @@ -453,13 +497,14 @@ func (m *launchdManager) requireElevatedFor(action string, uid int) error { // checkSystemPaths rejects a machine-wide installation that would reuse the // state of a user. // -// A LaunchDaemon runs as root. A state directory inside the home directory -// of the invoking user would either expose the state of the user to the -// machine or take the state of the user away from it, so the installation is -// refused instead of silently taking the state over. The model and the cache -// trees are never touched by an installation: only the three directories of -// the instance (the config directory, the state directory and the log -// directory) are created, and nothing is ever chowned. +// A LaunchDaemon runs as the service account of the machine. A state +// directory inside the home directory of the invoking user would either +// expose the state of the user to the machine or take the state of the user +// away from it, so the installation is refused instead of silently taking +// the state over. The model and the cache trees are never touched by an +// installation: only the three directories of the instance (the config +// directory, the state directory and the log directory) are created, and +// only those are granted to the service account. // // A user scope instance is supposed to live inside the directories of its own // user, so the guard does not touch it. @@ -495,6 +540,388 @@ func (m *launchdManager) checkSystemPaths(opts InstallOptions) error { return nil } +// serviceAccount is the identity a machine-wide instance runs as. +// +// The account is a dedicated, stable and unprivileged identity: it owns the +// three directories of the instance and nothing else, so the files it writes +// stay writable across reboots and the instance needs no privilege of root. +type serviceAccount struct { + // Name is the name of the account. + Name string + // UID is the numeric id of the account. + UID int + // GID is the numeric id of the primary group of the account. + GID int + // Group is the name of the primary group of the account. + Group string + // Groups holds the numeric ids of the groups the account belongs to. + Groups []int +} + +// serviceAccountName returns the name of the account a machine-wide instance +// runs as. +// +// The name of the options wins: an installation may name another account. An +// empty name means the default account of the platform. +func serviceAccountName(opts InstallOptions) string { + if name := strings.TrimSpace(opts.ServiceUser); name != "" { + return name + } + return DefaultServiceUser() +} + +// resolveAccount returns the account a machine-wide instance runs as. +// +// A user scope instance runs as the user who installs it: it needs no +// service account, and the returned account is nil. +func (m *launchdManager) resolveAccount(opts InstallOptions) (*serviceAccount, error) { + if m.scope != ScopeSystem { + return nil, nil + } + return resolveServiceAccount(opts) +} + +// resolveServiceAccount resolves the account of a machine-wide instance. +// +// The account is looked up in the user database of the machine: on macOS the +// lookup queries the OpenDirectory, so an account that was created with +// "dscl" is found. A build without cgo only knows the accounts of +// /etc/passwd, so it does not find a local account of the OpenDirectory. +// +// The installation never creates an account: an account that does not exist +// is refused with the commands that create it. +func resolveServiceAccount(opts InstallOptions) (*serviceAccount, error) { + name := serviceAccountName(opts) + entry, err := user.Lookup(name) + if err != nil { + return nil, missingServiceAccountError(name) + } + uid, err := unixID(entry.Uid) + if err != nil { + return nil, fmt.Errorf("service: the service account %q has the unreadable user id %q", name, entry.Uid) + } + gid, err := unixID(entry.Gid) + if err != nil { + return nil, fmt.Errorf("service: the service account %q has the unreadable group id %q", name, entry.Gid) + } + group, err := user.LookupGroupId(entry.Gid) + if err != nil { + return nil, fmt.Errorf("service: the primary group %q of the service account %q cannot be resolved: %w (give the account a primary group and install again)", entry.Gid, name, err) + } + + account := &serviceAccount{ + Name: entry.Username, + UID: uid, + GID: gid, + Group: group.Name, + // The primary group belongs to the account as well, so it is + // part of the groups the account may own a directory through. + Groups: []int{gid}, + } + if ids, err := entry.GroupIds(); err == nil { + for _, id := range ids { + if gid, err := unixID(id); err == nil { + account.Groups = append(account.Groups, gid) + } + } + } + return account, nil +} + +// missingServiceAccountError reports the refusal of an installation whose +// service account does not exist. +// +// The installation never creates an account: it names the account and the +// commands that create it, so the administrator can run them and install +// again. +func missingServiceAccountError(name string) error { + return fmt.Errorf("service: the machine-wide instance needs the service account %q: create it with %q, %q and %q, then install again (HFDesk never creates accounts itself)", + name, + fmt.Sprintf("dscl . -create /Users/%s UserShell /usr/bin/false", name), + fmt.Sprintf("dscl . -create /Users/%s UserHome %s", name, systemHome), + fmt.Sprintf("dscl . -create /Users/%s UniqueID ", name)) +} + +// unixID converts the numeric id of the user database into the unsigned form +// the file system uses. +// +// The user database of macOS knows the account "nobody" with the id -2, and +// the file system holds the very same id as the unsigned 4294967294. +func unixID(value string) (int, error) { + id, err := strconv.Atoi(value) + if err != nil { + return 0, err + } + if id < 0 { + id += 1 << 32 + } + return id, nil +} + +// instanceDirs returns the directories an instance owns: the directory of +// the config file, the state directory and the log directory. +// +// They are the only directories the installation creates, so they are the +// only directories the service account is given the ownership of. The model +// and the cache trees are never part of them. +func instanceDirs(opts InstallOptions) []instanceDir { + return []instanceDir{ + {name: "config directory", path: filepath.Dir(opts.Paths.ConfigFile)}, + {name: "state directory", path: opts.Paths.StateDir}, + {name: "log directory", path: opts.Paths.LogDir}, + } +} + +// instanceDir is one directory an instance owns. +type instanceDir struct { + // name is the name of the directory in an error message. + name string + // path is the absolute path of the directory. + path string +} + +// createdDirs returns the directories that do not exist yet on the way to +// the path, from the top down. +// +// The list holds the path itself when it is missing: it is the set of +// directories an installation creates for the path. The list is read before +// the directories are created, so the installation knows which of them it +// owns. +func createdDirs(path string) []string { + var missing []string + current := filepath.Clean(path) + for { + if _, err := os.Stat(current); err == nil { + break + } else if !os.IsNotExist(err) { + break + } + missing = append(missing, current) + parent := filepath.Dir(current) + if parent == current { + break + } + current = parent + } + // The list was collected from the bottom up, the directories are + // created from the top down. + for i, j := 0, len(missing)-1; i < j; i, j = i+1, j-1 { + missing[i], missing[j] = missing[j], missing[i] + } + return missing +} + +// machineDir reports whether the directory is a directory of the machine +// itself: a top level directory of the file system, like /etc or /usr. +// +// Such a directory belongs to the machine and to the software it holds, so +// it is never handed to the account of an instance. The instance needs a +// directory of its own below it. +// +// The rule is the rule of the Linux installation, so the same command line +// is answered the same way on both platforms. +func machineDir(path string) bool { + clean := filepath.Clean(path) + return filepath.Dir(clean) == string(filepath.Separator) +} + +// grantInstanceDirs grants the directories of the instance to the account. +// +// The directories the installation creates belong to the account: the +// instance writes its config file, its state and its logs there, and it runs +// as the account. The ownership is granted with a single chown per +// directory: the tree of a directory is never walked, and the model and the +// cache trees are never touched. +// +// A directory that existed before the installation belongs to whoever owns +// it, so it is not chowned. The account still has to be able to write there, +// and the installation refuses to go on when it cannot. +// +// A directory of the machine is refused: the directory of the config file is +// derived from the path of a file, so a config file in /etc would hand /etc +// itself to the account of the instance. +// +// Every directory is checked before the first of them is granted, so a +// refused installation leaves every ownership exactly as it was. +// +// The created map holds the directories the installation creates for every +// directory of the instance. It is read before the directories are created, +// so the installation knows which of them it owns. +func (m *launchdManager) grantInstanceDirs(opts InstallOptions, account *serviceAccount, created map[string][]string) error { + if account == nil { + // A user scope instance runs as the user who installs it: it owns + // its directories already. + return nil + } + dirs := instanceDirs(opts) + // A directory of the machine is refused before the first ownership is + // changed, so a refused installation leaves every ownership exactly as + // it was. + for _, dir := range dirs { + if machineDir(dir.path) { + return fmt.Errorf("service: the %s %q is a directory of the machine: the instance needs a directory of its own, name one below a directory of the machine", dir.name, dir.path) + } + } + // The directories are checked before the first of them is granted, so a + // refused installation leaves every ownership as it was. + for _, dir := range dirs { + if len(created[dir.path]) > 0 { + // The installation creates the directory: it owns it and + // grants it to the account below. + continue + } + // The directory existed before the installation: it belongs to its + // owner, so it is left alone. The account still has to be able to + // write there. + ok, err := accountCanWrite(dir.path, account) + if err != nil { + return fmt.Errorf("service: the %s %q of the machine-wide instance cannot be used: %w", dir.name, dir.path, err) + } + if !ok { + return fmt.Errorf("service: the %s %q of the machine-wide instance is not writable by the service account %q: grant it to the account (for example %q) and install again", + dir.name, dir.path, account.Name, "chown "+account.Name+" "+dir.path) + } + } + // Every directory passed the check: the directories the installation + // created belong to the account now. + for _, dir := range dirs { + for _, path := range created[dir.path] { + if err := os.Chown(path, account.UID, account.GID); err != nil { + return fmt.Errorf("service: grant the %s %q to the service account %q: %w", dir.name, path, account.Name, err) + } + } + } + return nil +} + +// checkNamedDirs verifies that the service account can write to the +// directories the user named explicitly. +// +// The defaults of the scope live inside the state directory of the instance, +// and the state directory belongs to the account, so they need no check. A +// directory the user named is outside of the instance: the installation +// verifies that the account can write there and refuses the installation +// with the commands that grant the access when it cannot. +// +// The installation never changes the ownership or the permissions of such a +// tree: a model or a cache tree belongs to whoever put it there. +func (m *launchdManager) checkNamedDirs(opts InstallOptions, account *serviceAccount) error { + if account == nil { + // A user scope instance runs as the user who installs it: it can + // write into its own directories. + return nil + } + for _, dir := range []struct { + name string + path string + }{ + {"cache directory", opts.CacheDir}, + {"hub directory", opts.HubDir}, + {"local directory", opts.LocalDir}, + } { + if dir.path == "" { + continue + } + if pathWithin(dir.path, opts.Paths.StateDir) { + // The default of the scope lives inside the state directory + // of the instance, and the state directory belongs to the + // account. + continue + } + ok, err := accountCanWrite(dir.path, account) + if err != nil { + return fmt.Errorf("service: the %s %q of the machine-wide instance cannot be used: %w", dir.name, dir.path, err) + } + if !ok { + return fmt.Errorf("service: the service account %q cannot write to the %s %q: grant it access (for example %q or by adding it to the group that owns the directory) and install again", + account.Name, dir.name, dir.path, "chown -R "+account.Name+" "+dir.path) + } + } + return nil +} + +// accountCanWrite reports whether the account can write to the directory. +// +// The directory itself is checked when it exists: it has to be a directory, +// and the account has to own it, to belong to the group that owns it and +// that group has to be allowed to write, or the directory has to be +// writable by everybody. +// +// A directory that does not exist yet is checked through its nearest +// existing ancestor: the account has to be able to create it there. +func accountCanWrite(path string, account *serviceAccount) (bool, error) { + target := path + info, err := os.Stat(path) + switch { + case err == nil: + if !info.IsDir() { + return false, fmt.Errorf("it exists and is not a directory") + } + case os.IsNotExist(err): + ancestor, err := nearestExistingDir(path) + if err != nil { + return false, err + } + target = ancestor + info, err = os.Stat(target) + if err != nil { + return false, err + } + default: + return false, err + } + + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return false, fmt.Errorf("the ownership of %q cannot be read", target) + } + if int(stat.Uid) == account.UID { + return true, nil + } + if info.Mode().Perm()&0o002 != 0 { + return true, nil + } + if info.Mode().Perm()&0o020 != 0 && accountInGroup(int(stat.Gid), account) { + return true, nil + } + return false, nil +} + +// accountInGroup reports whether the account belongs to the group. +func accountInGroup(gid int, account *serviceAccount) bool { + for _, group := range account.Groups { + if group == gid { + return true + } + } + return false +} + +// nearestExistingDir returns the nearest existing ancestor of the path. +// +// The ancestor has to be a directory: a path below a file can never be a +// directory. +func nearestExistingDir(path string) (string, error) { + current := filepath.Dir(filepath.Clean(path)) + for { + info, err := os.Stat(current) + if err == nil { + if !info.IsDir() { + return "", fmt.Errorf("%q is not a directory", current) + } + return current, nil + } + if !os.IsNotExist(err) { + return "", err + } + parent := filepath.Dir(current) + if parent == current { + return "", fmt.Errorf("no existing parent directory of %q was found", path) + } + current = parent + } +} + // load registers the property list with launchd and reports whether the job // was loaded by this call. // @@ -780,6 +1207,10 @@ func pathWithin(path, dir string) bool { // else: a token, a proxy password or a basic auth password is never part of // a registration, because a property list is readable by every user of the // machine. The credentials of an instance come from its own config file. +// +// A machine-wide instance names the account it runs as and the primary group +// of that account. A user scope instance names neither: a LaunchAgent runs +// as the user of its gui domain. const plistTemplate = ` @@ -808,11 +1239,11 @@ const plistTemplate = ` HOME {{.Home | xml}} -{{- if .System}} +{{- if .UserName}} UserName - root + {{.UserName | xml}} GroupName - wheel + {{.GroupName | xml}} {{- end}} @@ -841,8 +1272,12 @@ type plistData struct { WorkingDirectory string // Home is the home directory of the registered process. Home string - // System reports whether the instance is a machine-wide one. - System bool + // UserName is the account the registered process runs as. It is empty + // for a user scope instance: a LaunchAgent runs as the user of its + // gui domain. + UserName string + // GroupName is the primary group of the account. + GroupName string } // renderPlist renders the launchd property list of an installation. @@ -850,7 +1285,13 @@ type plistData struct { // The function is pure: it reads the environment of the caller and it // writes nothing, so the property list of an installation can be inspected // and tested without touching the machine. -func renderPlist(opts InstallOptions) (string, error) { +// +// A machine-wide instance runs as the given service account: the property +// list names the account and its primary group, so launchd starts the +// instance with the identity the installation granted its directories to. +// A user scope instance names neither: it runs as the user of its gui +// domain. +func renderPlist(opts InstallOptions, account *serviceAccount) (string, error) { if err := validatePlistOptions(opts); err != nil { return "", err } @@ -864,7 +1305,13 @@ func renderPlist(opts InstallOptions) (string, error) { ProgramArguments: append([]string{opts.Executable}, RunArgs(opts)...), WorkingDirectory: opts.Paths.StateDir, Home: home, - System: opts.Scope == ScopeSystem, + } + if opts.Scope == ScopeSystem { + if account == nil { + return "", fmt.Errorf("service: the machine-wide instance needs a service account") + } + data.UserName = account.Name + data.GroupName = account.Group } tmpl, err := template.New("plist").Funcs(plistFuncs).Parse(plistTemplate) if err != nil { diff --git a/internal/service/manager_darwin_test.go b/internal/service/manager_darwin_test.go index 6a97e439..3fb3bd92 100644 --- a/internal/service/manager_darwin_test.go +++ b/internal/service/manager_darwin_test.go @@ -12,10 +12,12 @@ import ( "io/fs" "os" "os/exec" + "os/user" "path/filepath" "reflect" "strconv" "strings" + "syscall" "testing" "time" @@ -69,7 +71,7 @@ func darwinSystemOptions(t *testing.T) InstallOptions { // TestRenderPlistForTheUserScope checks the property list of a LaunchAgent. func TestRenderPlistForTheUserScope(t *testing.T) { opts := darwinUserOptions(t) - rendered, err := renderPlist(opts) + rendered, err := renderPlist(opts, nil) if err != nil { t.Fatalf("renderPlist(): %v", err) } @@ -132,7 +134,10 @@ func TestRenderPlistForTheUserScope(t *testing.T) { // LaunchDaemon. func TestRenderPlistForTheSystemScope(t *testing.T) { opts := darwinSystemOptions(t) - rendered, err := renderPlist(opts) + account := darwinCurrentAccount(t) + opts.ServiceUser = account.Name + + rendered, err := renderPlist(opts, account) if err != nil { t.Fatalf("renderPlist(): %v", err) } @@ -142,12 +147,16 @@ func TestRenderPlistForTheSystemScope(t *testing.T) { t.Fatalf("parsePlist(): %v", err) } - // The daemon runs as root of the machine. - if registration.UserName != "root" { - t.Errorf("UserName = %q, want %q", registration.UserName, "root") + // The daemon runs as the service account of the machine, not as root: + // the instance owns its directories and needs no privilege of root. + if registration.UserName != account.Name { + t.Errorf("UserName = %q, want the service account %q", registration.UserName, account.Name) + } + if registration.UserName == "root" { + t.Error("the machine-wide instance runs as root, want the service account") } - if registration.GroupName != "wheel" { - t.Errorf("GroupName = %q, want %q", registration.GroupName, "wheel") + if registration.GroupName != account.Group { + t.Errorf("GroupName = %q, want the primary group %q of the service account", registration.GroupName, account.Group) } // The machine-wide instance never reuses the home directory of the user @@ -179,19 +188,24 @@ func TestRenderPlistForTheSystemScope(t *testing.T) { // nothing else. The credentials of an instance come from its own config // file. func TestRenderPlistCarriesNoCredential(t *testing.T) { + current := darwinCurrentAccount(t) + for _, scope := range []Scope{ScopeUser, ScopeSystem} { t.Run(string(scope), func(t *testing.T) { var opts InstallOptions + var account *serviceAccount if scope == ScopeUser { opts = darwinUserOptions(t) } else { opts = darwinSystemOptions(t) + opts.ServiceUser = current.Name + account = current } // Every string field of the options that could carry a // credential carries the sentinel. setCredentialFields(&opts, darwinCredentialSentinel) - rendered, err := renderPlist(opts) + rendered, err := renderPlist(opts, account) if err != nil { t.Fatalf("renderPlist(): %v", err) } @@ -229,8 +243,10 @@ func TestRenderPlistCarriesNoCredential(t *testing.T) { opts.Executable: true, opts.Paths.StateDir: true, "Background": true, - "root": true, - "wheel": true, + } + if scope == ScopeSystem { + allowed[current.Name] = true + allowed[current.Group] = true } for _, arg := range RunArgs(opts) { allowed[arg] = true @@ -255,7 +271,7 @@ func TestRenderPlistCarriesNoCredential(t *testing.T) { // own bounded rotating log file into the log directory of the instance. func TestRenderPlistKeepsTheLogFilesOfTheSupervisorOut(t *testing.T) { opts := darwinUserOptions(t) - rendered, err := renderPlist(opts) + rendered, err := renderPlist(opts, nil) if err != nil { t.Fatalf("renderPlist(): %v", err) } @@ -277,7 +293,7 @@ func TestRenderPlistKeepsTheLogFilesOfTheSupervisorOut(t *testing.T) { // and the bind address are recovered from a property list. func TestParsePlistRecoversTheRegistration(t *testing.T) { opts := darwinUserOptions(t) - rendered, err := renderPlist(opts) + rendered, err := renderPlist(opts, nil) if err != nil { t.Fatalf("renderPlist(): %v", err) } @@ -337,7 +353,7 @@ func TestParsePlistRecoversTheConfigFileAndTheCacheDir(t *testing.T) { opts := darwinUserOptions(t) opts.CacheDir = tc.cacheDir - rendered, err := renderPlist(opts) + rendered, err := renderPlist(opts, nil) if err != nil { t.Fatalf("renderPlist(): %v", err) } @@ -441,16 +457,20 @@ func TestPlutilAcceptsTheRenderedPlist(t *testing.T) { if err != nil { t.Skipf("plutil is not available: %v", err) } + current := darwinCurrentAccount(t) for _, scope := range []Scope{ScopeUser, ScopeSystem} { t.Run(string(scope), func(t *testing.T) { var opts InstallOptions + var account *serviceAccount if scope == ScopeUser { opts = darwinUserOptions(t) } else { opts = darwinSystemOptions(t) + opts.ServiceUser = current.Name + account = current } - rendered, err := renderPlist(opts) + rendered, err := renderPlist(opts, account) if err != nil { t.Fatalf("renderPlist(): %v", err) } @@ -540,7 +560,7 @@ func TestStatusReportsAMissingExecutable(t *testing.T) { LogDir: filepath.Join(stateDir, "logs"), }, } - rendered, err := renderPlist(opts) + rendered, err := renderPlist(opts, nil) if err != nil { t.Fatalf("renderPlist(): %v", err) } @@ -1134,6 +1154,679 @@ func TestLaunchdSystemInstallRefusesTheHomeOfTheUser(t *testing.T) { } } +// TestLaunchdServiceAccountName checks the name of the account a +// machine-wide instance runs as. +// +// The name of the options wins: an installation may name another account. +// An empty name means the default account of the platform. +func TestLaunchdServiceAccountName(t *testing.T) { + if got := DefaultServiceUser(); got != "_hfdesk" { + t.Errorf("DefaultServiceUser() = %q, want %q", got, "_hfdesk") + } + + opts := InstallOptions{Scope: ScopeSystem} + if got := serviceAccountName(opts); got != DefaultServiceUser() { + t.Errorf("serviceAccountName() = %q, want the default account %q", got, DefaultServiceUser()) + } + + opts.ServiceUser = "hfdesk_other_account" + if got := serviceAccountName(opts); got != "hfdesk_other_account" { + t.Errorf("serviceAccountName() = %q, want %q", got, "hfdesk_other_account") + } +} + +// TestLaunchdSystemInstallRefusesAMissingServiceAccount checks that a +// machine-wide installation whose service account does not exist is refused +// with an actionable message, and that the refusal leaves nothing behind. +// +// The account is never created by the installation: the message names the +// account and the commands that create it. The check runs before any +// directory is created and before the property list is written, so the +// refusal is the same in every shell. +func TestLaunchdSystemInstallRefusesAMissingServiceAccount(t *testing.T) { + const missing = "hfdesk_missing_service_account_for_the_test" + + root := t.TempDir() + stateDir := filepath.Join(root, "state") + + manager, err := newPlatformManager(ScopeSystem) + if err != nil { + t.Fatalf("newPlatformManager(ScopeSystem): %v", err) + } + launchd, ok := manager.(*launchdManager) + if !ok { + t.Fatalf("newPlatformManager(ScopeSystem) = %T, want a launchd manager", manager) + } + + // The property list of the machine-wide scope lives in a fixed place of + // the machine. The test never writes it: the refusal happens before + // the property list is written. The state of the file is compared + // before and after the installation, so a registration that exists + // already is never touched by the test. + path := launchd.plistPath() + before, err := backupPlist(path) + if err != nil { + t.Logf("the registration %q cannot be read: %v", path, err) + before = &plistBackup{path: path} + } + + opts := InstallOptions{ + Scope: ScopeSystem, + Executable: darwinWriteFakeExecutable(t), + Listen: DefaultListen, + ServiceUser: missing, + Paths: runtimepaths.Paths{ + ConfigFile: filepath.Join(root, "etc", "hfdesk.json"), + StateDir: stateDir, + LogDir: filepath.Join(root, "logs"), + }, + } + + err = launchd.Install(context.Background(), opts) + if err == nil { + t.Fatal("Install() = nil, want the refusal of a missing service account") + } + message := err.Error() + for _, want := range []string{ + missing, + "dscl", + "UserShell", + "UserHome", + "UniqueID", + "a free numeric id", + "HFDesk never creates accounts itself", + } { + if !strings.Contains(message, want) { + t.Errorf("Install() = %q, want a message that mentions %q", message, want) + } + } + + // The refused installation creates no directory of the instance. + if _, err := os.Stat(stateDir); !errors.Is(err, fs.ErrNotExist) { + t.Errorf("the refused installation created the state directory %q: %v", stateDir, err) + } + // The refused installation writes no property list. + after, err := backupPlist(path) + if err != nil { + t.Logf("the registration %q cannot be read: %v", path, err) + after = &plistBackup{path: path} + } + if before.existed != after.existed || string(before.content) != string(after.content) { + t.Errorf("the refused installation changed the registration %q", path) + } +} + +// TestLaunchdRenderPlistCarriesTheServiceAccount checks that the property +// list of a machine-wide instance names the account it runs as and the +// primary group of that account, and that the property list of a user scope +// instance names neither. +func TestLaunchdRenderPlistCarriesTheServiceAccount(t *testing.T) { + account := darwinCurrentAccount(t) + + t.Run("the machine-wide instance runs as the service account", func(t *testing.T) { + opts := darwinSystemOptions(t) + opts.ServiceUser = account.Name + + rendered, err := renderPlist(opts, account) + if err != nil { + t.Fatalf("renderPlist(): %v", err) + } + registration, err := parsePlist([]byte(rendered)) + if err != nil { + t.Fatalf("parsePlist(): %v", err) + } + if registration.UserName != account.Name { + t.Errorf("UserName = %q, want the service account %q", registration.UserName, account.Name) + } + if registration.GroupName != account.Group { + t.Errorf("GroupName = %q, want the primary group %q of the service account", registration.GroupName, account.Group) + } + }) + + t.Run("the machine-wide instance needs an account", func(t *testing.T) { + opts := darwinSystemOptions(t) + + if _, err := renderPlist(opts, nil); err == nil { + t.Error("renderPlist() = nil, want the refusal of a machine-wide instance without an account") + } + }) + + t.Run("the user scope instance runs as the user itself", func(t *testing.T) { + opts := darwinUserOptions(t) + + rendered, err := renderPlist(opts, nil) + if err != nil { + t.Fatalf("renderPlist(): %v", err) + } + registration, err := parsePlist([]byte(rendered)) + if err != nil { + t.Fatalf("parsePlist(): %v", err) + } + if registration.UserName != "" || registration.GroupName != "" { + t.Errorf("UserName = %q, GroupName = %q; want none for a user scope instance", registration.UserName, registration.GroupName) + } + }) +} + +// TestLaunchdGrantInstanceDirs checks that the directories of the instance +// are granted to the service account, and that the model and the cache trees +// are never touched. +// +// The test needs an elevated shell: only root may change the ownership of a +// directory. The test skips itself when the shell of the test is not +// elevated. +func TestLaunchdGrantInstanceDirs(t *testing.T) { + if os.Geteuid() != 0 { + t.Skipf("only root may change the ownership of a directory, the shell of the test is %d", os.Geteuid()) + } + account := darwinOtherAccount(t) + + root := t.TempDir() + cache := filepath.Join(root, "cache") + if err := os.Mkdir(cache, 0o755); err != nil { + t.Fatalf("create the cache directory: %v", err) + } + model := filepath.Join(cache, "model.bin") + if err := os.WriteFile(model, []byte("model"), 0o644); err != nil { + t.Fatalf("write the model: %v", err) + } + cacheOwnerUID, cacheOwnerGID := darwinOwner(t, cache) + modelOwnerUID, modelOwnerGID := darwinOwner(t, model) + + opts := InstallOptions{ + Scope: ScopeSystem, + CacheDir: cache, + Paths: runtimepaths.Paths{ + ConfigFile: filepath.Join(root, "etc", "hfdesk.json"), + StateDir: filepath.Join(root, "state"), + LogDir: filepath.Join(root, "logs"), + }, + } + + // The directories of the instance do not exist yet: the installation + // creates them and grants them to the account. + created := make(map[string][]string, 3) + for _, dir := range instanceDirs(opts) { + created[dir.path] = createdDirs(dir.path) + } + if err := PrepareDirs(opts); err != nil { + t.Fatalf("PrepareDirs(): %v", err) + } + + launchd := &launchdManager{scope: ScopeSystem} + if err := launchd.grantInstanceDirs(opts, account, created); err != nil { + t.Fatalf("grantInstanceDirs(): %v", err) + } + + for _, dir := range instanceDirs(opts) { + owner, group := darwinOwner(t, dir.path) + if owner != account.UID { + t.Errorf("the owner of the %s %q = %d, want the service account %d", dir.name, dir.path, owner, account.UID) + } + if group != account.GID { + t.Errorf("the group of the %s %q = %d, want the primary group of the service account %d", dir.name, dir.path, group, account.GID) + } + } + + // The cache tree belongs to whoever put it there: the installation + // never chowns it and never walks into it. + if owner, group := darwinOwner(t, cache); owner != cacheOwnerUID || group != cacheOwnerGID { + t.Errorf("the owner of the cache directory %q = %d:%d, want %d:%d", cache, owner, group, cacheOwnerUID, cacheOwnerGID) + } + if owner, group := darwinOwner(t, model); owner != modelOwnerUID || group != modelOwnerGID { + t.Errorf("the owner of the model %q = %d:%d, want %d:%d", model, owner, group, modelOwnerUID, modelOwnerGID) + } +} + +// TestLaunchdGrantInstanceDirsRefusesBeforeItGrants checks that a refused +// installation leaves every ownership exactly as it was. +// +// The first directory of the instance can be granted to the service +// account, the second one cannot be used: the installation has to refuse it +// before it grants the first one, so a refused installation never leaves a +// partially granted instance behind. +// +// Only root can change the ownership of a directory, so the test skips +// itself when the shell of the test is not elevated. Every path of the test +// lives in a temporary directory: the test never touches a directory of the +// machine. +func TestLaunchdGrantInstanceDirsRefusesBeforeItGrants(t *testing.T) { + if os.Geteuid() != 0 { + t.Skipf("only root may change the ownership of a directory, the shell of the test is %d", os.Geteuid()) + } + account := darwinOtherAccount(t) + + // The state directory is the second directory of the instance: it is + // the one that cannot be used. It either belongs to somebody else or + // it is not a directory at all. + cases := []struct { + name string + // prepare makes the state directory of the instance unusable. + prepare func(t *testing.T, path string) + // want is the refusal the installation has to report. + want func(state string) string + }{ + { + name: "the account cannot write into the directory", + prepare: func(t *testing.T, path string) { + t.Helper() + // The directory belongs to the user of the test, so the + // service account cannot write into it. + if err := os.Mkdir(path, 0o700); err != nil { + t.Fatalf("create the state directory: %v", err) + } + }, + want: func(state string) string { + return fmt.Sprintf("service: the state directory %q of the machine-wide instance is not writable by the service account %q: grant it to the account (for example %q) and install again", + state, account.Name, "chown "+account.Name+" "+state) + }, + }, + { + name: "the path is not a directory", + prepare: func(t *testing.T, path string) { + t.Helper() + if err := os.WriteFile(path, []byte("not a directory"), 0o600); err != nil { + t.Fatalf("write the state directory: %v", err) + } + }, + want: func(state string) string { + return fmt.Sprintf("service: the state directory %q of the machine-wide instance cannot be used: it exists and is not a directory", state) + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + root := t.TempDir() + configDir := filepath.Join(root, "etc") + state := filepath.Join(root, "state") + logDir := filepath.Join(root, "logs") + + opts := InstallOptions{ + Scope: ScopeSystem, + Listen: DefaultListen, + Paths: runtimepaths.Paths{ + ConfigFile: filepath.Join(configDir, "hfdesk.json"), + StateDir: state, + LogDir: logDir, + }, + } + + // The state directory existed before the installation. + tc.prepare(t, state) + + // The directories the installation is about to create are + // remembered before they are created, exactly like the + // installation does it. + created := make(map[string][]string, 3) + for _, dir := range instanceDirs(opts) { + created[dir.path] = createdDirs(dir.path) + } + // The installation creates the directories it owns itself. + for _, path := range created[configDir] { + if err := os.MkdirAll(path, 0o700); err != nil { + t.Fatalf("create the config directory: %v", err) + } + } + for _, path := range created[logDir] { + if err := os.MkdirAll(path, 0o700); err != nil { + t.Fatalf("create the log directory: %v", err) + } + } + + configOwner, configGroup := darwinOwner(t, configDir) + logOwner, logGroup := darwinOwner(t, logDir) + stateOwner, stateGroup := darwinOwner(t, state) + + launchd := &launchdManager{scope: ScopeSystem} + err := launchd.grantInstanceDirs(opts, account, created) + if err == nil { + t.Fatal("grantInstanceDirs() = nil, want the refusal of the state directory") + } + want := tc.want(state) + if err.Error() != want { + t.Errorf("grantInstanceDirs() = %q, want %q", err, want) + } + + // The refusal happened before the first ownership was changed, + // so the directory the installation would have granted still + // belongs to its owner. + if owner, group := darwinOwner(t, configDir); owner != configOwner || group != configGroup { + t.Errorf("the ownership of the config directory %q = %d:%d, want %d:%d", configDir, owner, group, configOwner, configGroup) + } + if owner, group := darwinOwner(t, logDir); owner != logOwner || group != logGroup { + t.Errorf("the ownership of the log directory %q = %d:%d, want %d:%d", logDir, owner, group, logOwner, logGroup) + } + // The directory that existed before the installation is left + // alone as well. + if owner, group := darwinOwner(t, state); owner != stateOwner || group != stateGroup { + t.Errorf("the ownership of the state directory %q = %d:%d, want %d:%d", state, owner, group, stateOwner, stateGroup) + } + }) + } +} + +// TestLaunchdGrantInstanceDirsKeepsAWritablePreExistingDirectory checks +// that a directory that existed before the installation and that the service +// account can write into is accepted and left alone. +// +// The directory belongs to whoever owns it, so the installation grants it to +// nobody: it only makes sure that the account can work there. +func TestLaunchdGrantInstanceDirsKeepsAWritablePreExistingDirectory(t *testing.T) { + account := darwinOtherAccount(t) + + root := t.TempDir() + opts := InstallOptions{ + Scope: ScopeSystem, + Listen: DefaultListen, + Paths: runtimepaths.Paths{ + ConfigFile: filepath.Join(root, "etc", "hfdesk.json"), + StateDir: filepath.Join(root, "state"), + LogDir: filepath.Join(root, "logs"), + }, + } + // Every directory of the instance exists before the installation and + // is writable by the account. + owners := map[string][2]int{} + for _, dir := range instanceDirs(opts) { + if err := os.MkdirAll(dir.path, 0o700); err != nil { + t.Fatalf("create the %s %q: %v", dir.name, dir.path, err) + } + // The mode of a create call is filtered by the umask of the + // process, so the mode is set explicitly. + if err := os.Chmod(dir.path, 0o777); err != nil { + t.Fatalf("make the %s %q writable: %v", dir.name, dir.path, err) + } + owner, group := darwinOwner(t, dir.path) + owners[dir.path] = [2]int{owner, group} + } + + created := make(map[string][]string, 3) + for _, dir := range instanceDirs(opts) { + created[dir.path] = createdDirs(dir.path) + } + + launchd := &launchdManager{scope: ScopeSystem} + if err := launchd.grantInstanceDirs(opts, account, created); err != nil { + t.Fatalf("grantInstanceDirs() = %v, want nil for a pre-existing directory the account can write into", err) + } + + // The directories still belong to their owner: the installation grants + // only the directories it created itself. + for _, dir := range instanceDirs(opts) { + owner, group := darwinOwner(t, dir.path) + want := owners[dir.path] + if owner != want[0] || group != want[1] { + t.Errorf("the ownership of the %s %q = %d:%d, want %d:%d", dir.name, dir.path, owner, group, want[0], want[1]) + } + } +} + +// TestLaunchdMachineDir checks the pure test that tells a directory of the +// machine from a directory of the instance. +// +// A directory of the machine is a top level directory of the file system, +// like /etc or /var. The account of a machine-wide instance may never own +// it: the account would own everything below it. +// +// The test is lexical: it never touches the file system, so it can name a +// directory of the machine without creating it. +func TestLaunchdMachineDir(t *testing.T) { + cases := []struct { + name string + path string + want bool + }{ + {name: "the root of the file system", path: "/", want: true}, + {name: "a top level directory of the machine", path: "/etc", want: true}, + {name: "another top level directory of the machine", path: "/var", want: true}, + {name: "a world writable directory of the machine", path: "/tmp", want: true}, + {name: "a directory below a directory of the machine", path: "/etc/hfdesk", want: false}, + {name: "a directory of the instance", path: "/var/lib/hfdesk", want: false}, + {name: "a relative path", path: "etc/hfdesk", want: false}, + {name: "an empty path", path: "", want: false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := machineDir(tc.path); got != tc.want { + t.Errorf("machineDir(%q) = %v, want %v", tc.path, got, tc.want) + } + }) + } +} + +// TestLaunchdGrantInstanceDirsRefusesADirectoryOfTheMachine checks that a +// machine-wide installation never hands a directory of the machine to the +// account of the instance. +// +// The rule is the rule of the Linux installation: a top level directory of +// the file system belongs to the machine, so the instance needs a directory +// of its own below it. The same command line is answered the same way on +// both platforms. +// +// The refusal happens in the check phase of the installation, before the +// first ownership is changed, so a refused installation leaves every +// ownership exactly as it was. +// +// The directory of the machine is only named by the test: every path the +// test writes to lives in a temporary directory. +func TestLaunchdGrantInstanceDirsRefusesADirectoryOfTheMachine(t *testing.T) { + account := darwinOtherAccount(t) + + root := t.TempDir() + configDir := filepath.Join(root, "etc") + logDir := filepath.Join(root, "logs") + for _, path := range []string{configDir, logDir} { + if err := os.MkdirAll(path, 0o700); err != nil { + t.Fatalf("create the directory %q: %v", path, err) + } + } + + opts := InstallOptions{ + Scope: ScopeSystem, + Listen: DefaultListen, + Paths: runtimepaths.Paths{ + ConfigFile: filepath.Join(configDir, "hfdesk.json"), + // The state directory is a top level directory of the + // machine: the account would own everything below it. + StateDir: "/etc", + LogDir: logDir, + }, + } + + // The directories the installation would grant to the account are + // remembered before they are created, exactly like the installation + // does it. The directory of the machine is never created: it is only + // named by the command line. + created := map[string][]string{ + configDir: {configDir}, + "/etc": nil, + logDir: {logDir}, + } + + owners := map[string][2]int{} + for _, path := range []string{configDir, logDir} { + owner, group := darwinOwner(t, path) + owners[path] = [2]int{owner, group} + } + + launchd := &launchdManager{scope: ScopeSystem} + err := launchd.grantInstanceDirs(opts, account, created) + if err == nil { + t.Fatal("grantInstanceDirs() = nil, want the refusal of a directory of the machine") + } + want := `service: the state directory "/etc" is a directory of the machine: the instance needs a directory of its own, name one below a directory of the machine` + if err.Error() != want { + t.Errorf("grantInstanceDirs() = %q, want %q", err, want) + } + + // The refusal happened before the first ownership was changed, so the + // directories the installation would have granted still belong to + // their owner. + for _, path := range []string{configDir, logDir} { + owner, group := darwinOwner(t, path) + want := owners[path] + if owner != want[0] || group != want[1] { + t.Errorf("the ownership of the directory %q = %d:%d, want %d:%d", path, owner, group, want[0], want[1]) + } + } +} + +// TestLaunchdGrantInstanceDirsKeepsADirectoryBelowADirectoryOfTheMachine +// checks that the rule of the directories of the machine does not reach a +// directory below a directory of the machine. +// +// Only a top level directory of the file system belongs to the machine. A +// directory below it, like /etc/hfdesk, is a directory of the instance, so +// the rule of the machine directories does not refuse it. +// +// The path is only named by the test: the test never writes to it, it only +// reads the answer of the installation. +func TestLaunchdGrantInstanceDirsKeepsADirectoryBelowADirectoryOfTheMachine(t *testing.T) { + account := darwinOtherAccount(t) + + root := t.TempDir() + configDir := filepath.Join(root, "etc") + logDir := filepath.Join(root, "logs") + for _, path := range []string{configDir, logDir} { + if err := os.MkdirAll(path, 0o700); err != nil { + t.Fatalf("create the directory %q: %v", path, err) + } + } + + opts := InstallOptions{ + Scope: ScopeSystem, + Listen: DefaultListen, + Paths: runtimepaths.Paths{ + ConfigFile: filepath.Join(configDir, "hfdesk.json"), + // The state directory lies below a directory of the + // machine: it is not a directory of the machine itself. + StateDir: "/etc/hfdesk", + LogDir: logDir, + }, + } + created := map[string][]string{ + configDir: {configDir}, + "/etc/hfdesk": nil, + logDir: {logDir}, + } + + owners := map[string][2]int{} + for _, path := range []string{configDir, logDir} { + owner, group := darwinOwner(t, path) + owners[path] = [2]int{owner, group} + } + + launchd := &launchdManager{scope: ScopeSystem} + err := launchd.grantInstanceDirs(opts, account, created) + // The installation answers the question of the writability of the + // directory, but it never refuses the directory as a directory of the + // machine. + if err != nil && strings.Contains(err.Error(), "is a directory of the machine") { + t.Fatalf("grantInstanceDirs() = %q, want no refusal of a directory below a directory of the machine", err) + } + + // Nothing was granted either way. + for _, path := range []string{configDir, logDir} { + owner, group := darwinOwner(t, path) + want := owners[path] + if owner != want[0] || group != want[1] { + t.Errorf("the ownership of the directory %q = %d:%d, want %d:%d", path, owner, group, want[0], want[1]) + } + } +} + +// TestLaunchdSystemInstallRefusesACacheDirTheAccountCannotWrite checks that +// a cache directory the service account cannot write to is refused with an +// actionable message, and that the installation leaves the tree alone. +// +// The installation never chowns a cache tree: the guidance names the +// commands that grant the access, the administrator runs them. +func TestLaunchdSystemInstallRefusesACacheDirTheAccountCannotWrite(t *testing.T) { + account := darwinOtherAccount(t) + + root := t.TempDir() + cache := filepath.Join(root, "cache") + if err := os.Mkdir(cache, 0o755); err != nil { + t.Fatalf("create the cache directory: %v", err) + } + beforeUID, beforeGID := darwinOwner(t, cache) + + manager, err := newPlatformManager(ScopeSystem) + if err != nil { + t.Fatalf("newPlatformManager(ScopeSystem): %v", err) + } + launchd, ok := manager.(*launchdManager) + if !ok { + t.Fatalf("newPlatformManager(ScopeSystem) = %T, want a launchd manager", manager) + } + + stateDir := filepath.Join(root, "state") + opts := InstallOptions{ + Scope: ScopeSystem, + Executable: darwinWriteFakeExecutable(t), + Listen: DefaultListen, + ServiceUser: account.Name, + Paths: runtimepaths.Paths{ + ConfigFile: filepath.Join(root, "etc", "hfdesk.json"), + StateDir: stateDir, + LogDir: filepath.Join(root, "logs"), + }, + CacheDir: cache, + HubDir: filepath.Join(cache, "hub"), + LocalDir: filepath.Join(root, "models"), + } + + err = launchd.Install(context.Background(), opts) + if err == nil { + t.Fatal("Install() = nil, want the refusal of a cache directory the account cannot write to") + } + message := err.Error() + for _, want := range []string{account.Name, cache, "chown"} { + if !strings.Contains(message, want) { + t.Errorf("Install() = %q, want a message that mentions %q", message, want) + } + } + + // The ownership of the cache tree is unchanged. + if owner, group := darwinOwner(t, cache); owner != beforeUID || group != beforeGID { + t.Errorf("the owner of the cache directory %q = %d:%d, want %d:%d", cache, owner, group, beforeUID, beforeGID) + } + // The refused installation creates no directory of the instance. + if _, err := os.Stat(stateDir); !errors.Is(err, fs.ErrNotExist) { + t.Errorf("the refused installation created the state directory %q: %v", stateDir, err) + } +} + +// TestLaunchdUserScopeNeedsNoServiceAccount checks that the user scope is +// unaffected by the service account of the machine-wide scope. +// +// A user scope instance runs as the user who installs it: it needs no +// service account, it is granted no directory and it names no account in +// its registration. +func TestLaunchdUserScopeNeedsNoServiceAccount(t *testing.T) { + launchd := &launchdManager{scope: ScopeUser, home: t.TempDir()} + + opts := darwinUserOptions(t) + // The account of the options is ignored in the user scope: the + // instance runs as the user itself. + opts.ServiceUser = "hfdesk_missing_service_account_for_the_test" + + account, err := launchd.resolveAccount(opts) + if err != nil { + t.Fatalf("resolveAccount() = %v, want nil for a user scope instance", err) + } + if account != nil { + t.Errorf("resolveAccount() = %+v, want nil for a user scope instance", account) + } + if err := launchd.checkNamedDirs(opts, account); err != nil { + t.Errorf("checkNamedDirs() = %v, want nil for a user scope instance", err) + } + if err := launchd.grantInstanceDirs(opts, account, nil); err != nil { + t.Errorf("grantInstanceDirs() = %v, want nil for a user scope instance", err) + } +} + // darwinWaitForState waits until the instance reaches a state. func darwinWaitForState(t *testing.T, manager *launchdManager, want State) Status { t.Helper() @@ -1179,11 +1872,63 @@ func darwinStatusManager(t *testing.T) *launchdManager { } } +// darwinCurrentAccount returns the service account of the user of the test. +// +// The account of the user of the test exists on every machine, so the tests +// use it as the identity of a machine-wide instance. +func darwinCurrentAccount(t *testing.T) *serviceAccount { + t.Helper() + current, err := user.Current() + if err != nil { + t.Skipf("the user of the test cannot be resolved: %v", err) + } + account, err := resolveServiceAccount(InstallOptions{Scope: ScopeSystem, ServiceUser: current.Username}) + if err != nil { + t.Skipf("the account %q of the user of the test cannot be resolved: %v", current.Username, err) + } + return account +} + +// darwinOtherAccount returns an account of the machine that is not the user +// of the test. +// +// The tests need an account that exists and that owns nothing of the test, +// so the ownership of the directories of the test is unchanged. +func darwinOtherAccount(t *testing.T) *serviceAccount { + t.Helper() + for _, name := range []string{"nobody", "daemon"} { + account, err := resolveServiceAccount(InstallOptions{Scope: ScopeSystem, ServiceUser: name}) + if err != nil { + continue + } + if account.UID == os.Getuid() { + continue + } + return account + } + t.Skip("the machine has no service account besides the user of the test") + return nil +} + +// darwinOwner returns the owner and the group of a path. +func darwinOwner(t *testing.T, path string) (int, int) { + t.Helper() + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat %q: %v", path, err) + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + t.Fatalf("the ownership of %q cannot be read", path) + } + return int(stat.Uid), int(stat.Gid) +} + // darwinWriteRegistration renders the property list of an installation and // writes it to the registration of the manager. func darwinWriteRegistration(t *testing.T, launchd *launchdManager, opts InstallOptions) { t.Helper() - rendered, err := renderPlist(opts) + rendered, err := renderPlist(opts, nil) if err != nil { t.Fatalf("renderPlist(): %v", err) } diff --git a/internal/service/manager_linux.go b/internal/service/manager_linux.go index eaaae853..04b47a89 100644 --- a/internal/service/manager_linux.go +++ b/internal/service/manager_linux.go @@ -13,8 +13,11 @@ import ( "io/fs" "os" "os/exec" + "os/user" "path/filepath" + "strconv" "strings" + "syscall" "github.com/bashrusakh/hfdesk/internal/runtimepaths" "github.com/bashrusakh/hfdesk/pkg/hfdownloader" @@ -171,12 +174,355 @@ func (m *systemdManager) requireRoot(action string) error { return fmt.Errorf("service: %s in the system scope requires root: run the command as root, HFDesk never escalates privileges itself", action) } +// serviceAccountName returns the name of the account a machine-wide +// instance runs as: the account the user named, or the default of the +// platform. +// +// The name is resolved without touching the machine: the existence of the +// account is a separate question, so a unit can be rendered for an account +// that is not on the machine yet. +func serviceAccountName(opts InstallOptions) string { + if opts.ServiceUser != "" { + return opts.ServiceUser + } + return DefaultServiceUser() +} + +// serviceAccountCreateCommand returns the command that creates the service +// account of a machine-wide instance. +// +// The command is a hint for the administrator: the installation never runs +// it, it never calls useradd and it never escalates privileges. +func serviceAccountCreateCommand(name string) string { + return fmt.Sprintf("useradd --system --no-create-home --home-dir %s --shell /usr/sbin/nologin %s", systemServiceHome, name) +} + +// resolveServiceAccount resolves the account a machine-wide instance runs +// as. +// +// The account is never created by an installation: the installation refuses +// an instance whose account does not exist and names the command that +// creates it. The account has to be a stable one, so the files it owns stay +// writable across reboots. +func resolveServiceAccount(opts InstallOptions) (*user.User, error) { + name := serviceAccountName(opts) + if name == "" { + return nil, errors.New("service: the machine-wide instance needs a service account") + } + account, err := user.Lookup(name) + if err != nil { + return nil, fmt.Errorf("service: the machine-wide instance needs the service account %q: create it with %q and install again (HFDesk never creates accounts itself)", name, serviceAccountCreateCommand(name)) + } + return account, nil +} + +// serviceAccount resolves the account of the manager scope. +// +// A user scope instance runs as the user of the session: it needs no +// service account, so no account is resolved for it. +func (m *systemdManager) serviceAccount(opts InstallOptions) (*user.User, error) { + if m.scope != ScopeSystem { + return nil, nil + } + return resolveServiceAccount(opts) +} + +// errNotWritable reports that the service account cannot write into a +// directory. The caller turns it into the guidance of the installation. +var errNotWritable = errors.New("the service account cannot write into the directory") + +// checkServiceAccountAccess verifies that the account of a machine-wide +// instance can write into the directories the user named explicitly. +// +// The scope defaults are not checked: the cache of a machine-wide instance +// lives inside its own state directory, which the installation owns. +// +// The ownership and the permissions of a named tree are never changed: the +// installation refuses the installation and names the way to grant the +// access. +func (m *systemdManager) checkServiceAccountAccess(opts InstallOptions, account *user.User) error { + if m.scope != ScopeSystem || account == nil { + return nil + } + defaultCache, defaultHub := DefaultCacheDirs(m.scope, opts.Paths) + for _, dir := range []struct { + kind string + path string + def string + }{ + {"cache", opts.CacheDir, defaultCache}, + {"hub", opts.HubDir, defaultHub}, + {"local", opts.LocalDir, ""}, + } { + if dir.path == "" || dir.path == dir.def { + continue + } + if err := checkDirWritableByAccount(dir.path, account); err != nil { + if !errors.Is(err, errNotWritable) { + return fmt.Errorf("service: the %s directory %q of the machine-wide instance is not usable: %w", dir.kind, dir.path, err) + } + return fmt.Errorf("service: the service account %q cannot write to the %s directory %q: grant it access (for example %q or by adding it to the group that owns the directory) and install again", account.Username, dir.kind, dir.path, "chown -R "+account.Username+" "+dir.path) + } + } + return nil +} + +// checkDirWritableByAccount reports whether the account can write into the +// given directory. +// +// The directory itself is checked when it exists. A directory that is +// missing is checked through its nearest existing ancestor: the instance +// creates the directory it needs inside a directory that has to be writable +// for it. +func checkDirWritableByAccount(path string, account *user.User) error { + dir := path + for { + info, err := os.Stat(dir) + if err == nil { + if !info.IsDir() { + return fmt.Errorf("%q is not a directory", path) + } + if writableByAccount(info, account) { + return nil + } + return errNotWritable + } + if !errors.Is(err, fs.ErrNotExist) { + return err + } + parent := filepath.Dir(dir) + if parent == dir { + // The root of the file system is never missing, so there + // is nothing left to check. + return errNotWritable + } + dir = parent + } +} + +// writableByAccount reports whether the account can write into the +// directory the file information describes. +// +// The owner of a directory can always widen the permissions of it, so a +// directory the account owns counts as writable. A group the account belongs +// to needs the group write permission, and a directory of the machine needs +// the world write permission. +func writableByAccount(info fs.FileInfo, account *user.User) bool { + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return false + } + if strconv.Itoa(int(stat.Uid)) == account.Uid { + return true + } + mode := info.Mode().Perm() + if mode&0o002 != 0 { + return true + } + if mode&0o020 == 0 { + return false + } + gid := strconv.Itoa(int(stat.Gid)) + if gid == account.Gid { + return true + } + for _, group := range accountGroupIds(account) { + if group == gid { + return true + } + } + return false +} + +// accountGroupIds returns the ids of the groups the account belongs to. +// +// The primary group of the account is always one of them. The other groups +// are read from the group database of the machine: the standard library +// knows no membership of an account. +func accountGroupIds(account *user.User) []string { + ids := []string{account.Gid} + content, err := os.ReadFile("/etc/group") + if err != nil { + return ids + } + for _, line := range strings.Split(string(content), "\n") { + fields := strings.Split(strings.TrimSpace(line), ":") + if len(fields) < 4 { + continue + } + for _, member := range strings.Split(fields[3], ",") { + if member == account.Username { + ids = append(ids, fields[2]) + break + } + } + } + return ids +} + +// instanceDir is one directory an installation owns. +type instanceDir struct { + // name names the directory in an error message. + name string + // path is the absolute path of the directory. + path string +} + +// instanceDirs returns the directories an installation owns: the directory +// of the config file, the state directory and the log directory. +// +// These are the directories the installation creates anyway. A model, a +// cache or a hub tree is never one of them: such a tree belongs to the user +// who named it. +func instanceDirs(opts InstallOptions) []instanceDir { + // The directory of the config file is derived from the path of a + // file. An empty path has no directory: the parent of an empty path + // would be the working directory of the process, which belongs to + // nobody. + configDir := "" + if opts.Paths.ConfigFile != "" { + configDir = filepath.Dir(opts.Paths.ConfigFile) + } + dirs := []instanceDir{ + {"config dir", configDir}, + {"state dir", opts.Paths.StateDir}, + {"log dir", opts.Paths.LogDir}, + } + seen := make(map[string]bool, len(dirs)) + unique := make([]instanceDir, 0, len(dirs)) + for _, dir := range dirs { + if dir.path == "" || dir.path == "." || seen[dir.path] { + continue + } + seen[dir.path] = true + unique = append(unique, dir) + } + return unique +} + +// createdDirs returns the directories that do not exist yet on the way to +// the path, from the top down. +// +// The list holds the path itself when it is missing: it is the set of +// directories an installation creates for the path. The list is read before +// the directories are created, so the installation knows which of them it +// owns. +func createdDirs(path string) []string { + var missing []string + current := filepath.Clean(path) + for { + if _, err := os.Stat(current); err == nil { + break + } else if !errors.Is(err, fs.ErrNotExist) { + break + } + missing = append(missing, current) + parent := filepath.Dir(current) + if parent == current { + break + } + current = parent + } + // The list was collected from the bottom up, the directories are + // created from the top down. + for i, j := 0, len(missing)-1; i < j; i, j = i+1, j-1 { + missing[i], missing[j] = missing[j], missing[i] + } + return missing +} + +// machineDir reports whether the directory is a directory of the machine +// itself: a top level directory of the file system, like /etc or /usr. +// +// Such a directory belongs to the machine and to the software it holds, so +// it is never handed to the account of an instance. The instance needs a +// directory of its own below it. +func machineDir(path string) bool { + clean := filepath.Clean(path) + return filepath.Dir(clean) == string(filepath.Separator) +} + +// grantInstanceDirs grants the three directories the installation owns to +// the account of the instance. +// +// A directory the installation created belongs to the account: the instance +// writes its config file, its state and its logs there, and it runs as the +// account. The ownership is granted with a single chown per directory: the +// tree of a directory is never walked, and the model and the cache trees are +// never touched. +// +// A directory that existed before the installation belongs to whoever owns +// it, so it is not chowned. The account still has to be able to write there, +// and the installation refuses to go on when it cannot. +// +// A directory of the machine is refused: the directory of the config file is +// derived from the path of a file, so a config file in /etc would hand /etc +// itself to the account of the instance. +// +// Every refusal happens before the first ownership is changed, so a refused +// installation leaves every ownership as it was. +// +// The created map holds the directories the installation creates for every +// directory of the instance. It is read before the directories are created, +// so the installation knows which of them it owns. +func (m *systemdManager) grantInstanceDirs(opts InstallOptions, account *user.User, created map[string][]string) error { + if m.scope != ScopeSystem || account == nil { + return nil + } + uid, err := strconv.Atoi(account.Uid) + if err != nil { + return fmt.Errorf("service: the id of the service account %q is not a number: %w", account.Username, err) + } + gid, err := strconv.Atoi(account.Gid) + if err != nil { + return fmt.Errorf("service: the group id of the service account %q is not a number: %w", account.Username, err) + } + dirs := instanceDirs(opts) + // The directories are checked before the first of them is granted, so + // a refused installation leaves every ownership as it was. + for _, dir := range dirs { + if machineDir(dir.path) { + return fmt.Errorf("service: the %s %q is a directory of the machine: the instance needs a directory of its own, name one below a directory of the machine", dir.name, dir.path) + } + } + // A directory that existed before the installation belongs to its + // owner: it is left alone, so the account has to be able to write + // there already. + for _, dir := range dirs { + if len(created[dir.path]) > 0 { + // The installation creates the directory: it owns it and + // grants it to the account below. + continue + } + if err := checkDirWritableByAccount(dir.path, account); err != nil { + if !errors.Is(err, errNotWritable) { + return fmt.Errorf("service: the %s %q of the machine-wide instance is not usable: %w", dir.name, dir.path, err) + } + return fmt.Errorf("service: the %s %q of the machine-wide instance is not writable by the service account %q: grant it to the account (for example %q) and install again", + dir.name, dir.path, account.Username, "chown "+account.Username+" "+dir.path) + } + } + for _, dir := range dirs { + for _, path := range created[dir.path] { + if err := os.Chown(path, uid, gid); err != nil { + return fmt.Errorf("service: grant the %s %q to the service account %q: %w", dir.name, path, account.Username, err) + } + } + } + return nil +} + // Install registers the service with the supervisor. // // The installation validates first, writes the unit file atomically, // reloads the supervisor and enables the unit for the boot of the scope. It // never starts the instance: starting is an explicit, separate command. // +// A machine-wide instance runs as the dedicated service account of the +// platform. The account is validated before anything is created: the +// installation never creates an account and never changes the ownership of +// a model or a cache tree. +// // A failure after the unit file was written rolls the unit file back, so an // installation never leaves a half configured unit behind. func (m *systemdManager) Install(ctx context.Context, opts InstallOptions) error { @@ -191,15 +537,43 @@ func (m *systemdManager) Install(ctx context.Context, opts InstallOptions) error if err := m.checkSystemPaths(opts); err != nil { return err } + // The account of a machine-wide instance is a rule of the scope and + // not a privilege of the process, so it is validated the same way: + // before the root requirement, before any directory is created and + // before the unit file is written. A refused installation leaves + // nothing behind. + account, err := m.serviceAccount(opts) + if err != nil { + return err + } + if err := m.checkServiceAccountAccess(opts, account); err != nil { + return err + } // The root requirement is checked before any directory is created: a // permission error of the machine wide directories is a much less // readable answer than the real reason. if err := m.requireRoot("an installation"); err != nil { return err } + // The directories the installation is about to create are remembered + // before they are created, so the account is given the ownership of + // exactly those and of nothing else. A directory that exists already + // belongs to its owner and is left alone. + var created map[string][]string + if account != nil { + created = make(map[string][]string, 3) + for _, dir := range instanceDirs(opts) { + created[dir.path] = createdDirs(dir.path) + } + } if err := PrepareDirs(opts); err != nil { return err } + // The instance runs as the service account, so the directories it owns + // belong to it. + if err := m.grantInstanceDirs(opts, account, created); err != nil { + return err + } if err := m.preflight(opts); err != nil { return err } @@ -624,6 +998,10 @@ func writeUnitFile(path, content string) error { // rotating log file into the log directory of the instance and the supervisor // keeps the standard output and the standard error of the process in its // journal. +// +// The identity of a machine-wide unit is the dedicated service account of +// the platform. A user scope unit names no account: it runs as the user of +// the session. const unitTemplate = `# HFDesk service unit. # # Generated by the hfdesk service install command; a later installation @@ -644,7 +1022,7 @@ Wants=network-online.target # The supervisor owns the lifecycle of the instance: HFDesk stays a normal # foreground process and the supervisor restarts it when it fails. Type=simple -ExecStart=%s +%sExecStart=%s WorkingDirectory=%s Restart=on-failure RestartSec=5s @@ -666,6 +1044,10 @@ func renderUnit(opts InstallOptions) (string, error) { if err != nil { return "", err } + identity, err := unitIdentity(opts) + if err != nil { + return "", err + } words := make([]string, 0, len(RunArgs(opts))+1) words = append(words, systemdQuoteAlways(opts.Executable)) @@ -679,6 +1061,7 @@ func renderUnit(opts InstallOptions) (string, error) { } return fmt.Sprintf(unitTemplate, + identity, strings.Join(words, " "), systemdQuote(opts.Paths.StateDir), systemdQuote("HOME="+home), @@ -686,6 +1069,26 @@ func renderUnit(opts InstallOptions) (string, error) { ), nil } +// unitIdentity renders the identity directives of a unit. +// +// A machine-wide instance runs as the dedicated service account of the +// platform: the unit names the account and its group, so the supervisor +// starts the instance with the least privilege it needs and not with the +// identity of the administrator who installed it. +// +// A user scope instance runs as the user of the session, so it names no +// account at all. +func unitIdentity(opts InstallOptions) (string, error) { + if opts.Scope != ScopeSystem { + return "", nil + } + account := serviceAccountName(opts) + if account == "" { + return "", errors.New("service: the machine-wide instance needs a service account") + } + return fmt.Sprintf("User=%s\nGroup=%s\n", systemdQuote(account), systemdQuote(account)), nil +} + // unitHomeDir returns the HOME of the registered process. // // A user scope instance runs as the user of the session, so it keeps the diff --git a/internal/service/manager_linux_test.go b/internal/service/manager_linux_test.go index 71f99a7f..b63bd7a6 100644 --- a/internal/service/manager_linux_test.go +++ b/internal/service/manager_linux_test.go @@ -13,8 +13,11 @@ import ( "io/fs" "os" "os/exec" + "os/user" "path/filepath" + "strconv" "strings" + "syscall" "testing" "time" @@ -99,6 +102,13 @@ func TestSystemdRenderUnitUserScope(t *testing.T) { if strings.Contains(unit, "WantedBy=multi-user.target") { t.Errorf("a user scope unit must not be enabled for the machine:\n%s", unit) } + // A user scope instance runs as the user of the session: the unit + // names no account at all. + for _, directive := range unitDirectives(unit) { + if strings.HasPrefix(directive, "User=") || strings.HasPrefix(directive, "Group=") { + t.Errorf("a user scope unit names an account: %q", directive) + } + } parsed := parseUnit(unit) if !filepath.IsAbs(parsed.ExecStart) { @@ -157,11 +167,20 @@ func TestSystemdRenderUnitSystemScope(t *testing.T) { t.Errorf("Environment HOME = %q, want the machine level home of a system scope instance", got) } } - // A machine wide instance must not inherit the identity of the user - // who runs the install command. - for _, directive := range unitDirectives(unit) { - if strings.HasPrefix(directive, "User=") { - t.Errorf("the unit names a user: %q", directive) + // A machine wide instance runs as the dedicated service account of + // the platform, never as the user who runs the install command. + account := DefaultServiceUser() + if !strings.Contains(unit, "User="+account+"\n") { + t.Errorf("the unit does not run as the service account %q:\n%s", account, unit) + } + if !strings.Contains(unit, "Group="+account+"\n") { + t.Errorf("the unit does not run in the group of the service account %q:\n%s", account, unit) + } + if current, err := user.Current(); err == nil && current.Username != "" { + for _, directive := range unitDirectives(unit) { + if directive == "User="+current.Username { + t.Errorf("the unit runs as the user who installed it: %q", directive) + } } } } @@ -926,3 +945,710 @@ func systemdStatusOf(t *testing.T, words ...string) Status { } return status } + +// systemdSecondAccount returns an account of the machine that is not the +// account of the test process. +// +// The tests of the service account need an account that is not the owner of +// the directories of the test: only then a directory the account cannot +// write into is a directory it really cannot write into. +func systemdSecondAccount(t *testing.T) *user.User { + t.Helper() + current, err := user.Current() + if err != nil { + t.Skipf("skipping: the account of the test process is unknown: %v", err) + } + for _, name := range []string{"nobody", "daemon", "bin", "sys", "games"} { + account, err := user.Lookup(name) + if err != nil { + continue + } + if account.Uid == current.Uid { + continue + } + return account + } + t.Skip("skipping: the machine has no second account the test could use") + return nil +} + +// systemdOwner returns the owner and the group of a path. +func systemdOwner(t *testing.T, path string) (string, string) { + t.Helper() + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat %q: %v", path, err) + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + t.Skipf("skipping: the file system of the test does not report an owner: %v", info.Sys()) + } + return strconv.Itoa(int(stat.Uid)), strconv.Itoa(int(stat.Gid)) +} + +// TestSystemdServiceAccountName checks how the account of a machine-wide +// instance is resolved: the account the user named wins, and an empty value +// falls back to the default of the platform. +func TestSystemdServiceAccountName(t *testing.T) { + t.Run("the account of the user wins", func(t *testing.T) { + opts := systemdTestOptions(t, ScopeSystem) + opts.ServiceUser = "hfdesk-svc" + + if got := serviceAccountName(opts); got != "hfdesk-svc" { + t.Errorf("serviceAccountName() = %q, want %q", got, "hfdesk-svc") + } + }) + + t.Run("an empty account falls back to the default of the platform", func(t *testing.T) { + opts := systemdTestOptions(t, ScopeSystem) + opts.ServiceUser = "" + + got := serviceAccountName(opts) + if want := DefaultServiceUser(); got != want { + t.Errorf("serviceAccountName() = %q, want %q", got, want) + } + }) +} + +// TestSystemdInstallRefusesAMissingServiceAccount checks that a +// machine-wide installation whose account does not exist is refused with the +// guidance that names the account and the command that creates it, and that +// the refused installation leaves nothing behind. +func TestSystemdInstallRefusesAMissingServiceAccount(t *testing.T) { + const missing = "hfdesk-missing-service-account" + if _, err := user.Lookup(missing); err == nil { + t.Skipf("skipping: the account %q exists on this machine", missing) + } + + // The home of the user is a temporary directory, so the guard of the + // machine-wide scope does not refuse the paths of the test. + home := t.TempDir() + t.Setenv("HOME", home) + + m := &systemdManager{scope: ScopeSystem} + path, err := m.unitPath() + if err != nil { + t.Fatalf("unitPath() = %v, want nil", err) + } + // The unit file of the machine scope lives in a fixed place of the + // machine. The test never writes it: the refusal happens before the + // unit file is written. The state of the file is compared before and + // after the installation, so a unit that is installed already is never + // touched by the test. + before, existedBefore, err := readUnitFile(path) + if err != nil { + t.Logf("the unit file %q cannot be read: %v", path, err) + } + + root := t.TempDir() + opts := InstallOptions{ + Scope: ScopeSystem, + Executable: systemdTestExecutable(t), + Listen: DefaultListen, + Paths: runtimepaths.Paths{ + ConfigFile: filepath.Join(root, "etc", "hfdesk.json"), + StateDir: filepath.Join(root, "var", "lib", "hfdesk"), + LogDir: filepath.Join(root, "var", "log", "hfdesk"), + }, + ServiceUser: missing, + } + + err = m.Install(context.Background(), opts) + if err == nil { + t.Fatal("Install() = nil, want the refusal of the missing service account") + } + want := fmt.Sprintf("service: the machine-wide instance needs the service account %q: create it with %q and install again (HFDesk never creates accounts itself)", + missing, serviceAccountCreateCommand(missing)) + if err.Error() != want { + t.Errorf("Install() = %q, want %q", err, want) + } + if !strings.Contains(err.Error(), "useradd --system --no-create-home") { + t.Errorf("Install() = %q, want the command that creates the account", err) + } + + // The refused installation writes no unit file. + after, existedAfter, err := readUnitFile(path) + if err != nil { + t.Logf("the unit file %q cannot be read: %v", path, err) + } + if existedBefore != existedAfter || string(before) != string(after) { + t.Errorf("the refused installation changed the unit file %q", path) + } + // The refused installation creates no directory of the instance + // either. + for _, dir := range instanceDirs(opts) { + if _, err := os.Stat(dir.path); !errors.Is(err, fs.ErrNotExist) { + t.Errorf("the refused installation created the %s %q: %v", dir.name, dir.path, err) + } + } +} + +// TestSystemdRenderUnitNamesTheServiceAccount checks that the unit of a +// machine-wide instance names the service account and its group, and that +// the unit of a user scope instance names no account at all. +func TestSystemdRenderUnitNamesTheServiceAccount(t *testing.T) { + t.Run("the machine-wide scope names the account of the user", func(t *testing.T) { + opts := systemdTestOptions(t, ScopeSystem) + opts.ServiceUser = "hfdesk-svc" + + unit, err := renderUnit(opts) + if err != nil { + t.Fatalf("renderUnit() = %v, want nil", err) + } + if !strings.Contains(unit, "User=hfdesk-svc\n") { + t.Errorf("the unit does not run as the service account:\n%s", unit) + } + if !strings.Contains(unit, "Group=hfdesk-svc\n") { + t.Errorf("the unit does not run in the group of the service account:\n%s", unit) + } + }) + + t.Run("the machine-wide scope falls back to the default of the platform", func(t *testing.T) { + opts := systemdTestOptions(t, ScopeSystem) + + unit, err := renderUnit(opts) + if err != nil { + t.Fatalf("renderUnit() = %v, want nil", err) + } + account := DefaultServiceUser() + if !strings.Contains(unit, "User="+account+"\n") { + t.Errorf("the unit does not run as the default service account %q:\n%s", account, unit) + } + if !strings.Contains(unit, "Group="+account+"\n") { + t.Errorf("the unit does not run in the group of the default service account %q:\n%s", account, unit) + } + }) + + t.Run("the user scope names no account", func(t *testing.T) { + opts := systemdTestOptions(t, ScopeUser) + opts.ServiceUser = "hfdesk-svc" + + unit, err := renderUnit(opts) + if err != nil { + t.Fatalf("renderUnit() = %v, want nil", err) + } + for _, directive := range unitDirectives(unit) { + if strings.HasPrefix(directive, "User=") || strings.HasPrefix(directive, "Group=") { + t.Errorf("a user scope unit names an account: %q", directive) + } + } + }) +} + +// TestSystemdGrantInstanceDirs checks that the directories an installation +// creates belong to the service account of the instance, and that the +// installation never changes the ownership of a tree it does not own. +// +// Only root can change the ownership of a directory, so the test skips +// itself when it does not run as root. +func TestSystemdGrantInstanceDirs(t *testing.T) { + if os.Geteuid() != 0 { + t.Skip("skipping: only root can change the ownership of a directory") + } + account := systemdSecondAccount(t) + + root := t.TempDir() + opts := InstallOptions{ + Scope: ScopeSystem, + Executable: systemdTestExecutable(t), + Listen: DefaultListen, + Paths: runtimepaths.Paths{ + ConfigFile: filepath.Join(root, "etc", "hfdesk.json"), + StateDir: filepath.Join(root, "var", "lib", "hfdesk"), + LogDir: filepath.Join(root, "var", "log", "hfdesk"), + }, + } + + // The directories of the instance do not exist yet: the installation + // creates them and grants them to the account. + created := make(map[string][]string, 3) + for _, dir := range instanceDirs(opts) { + created[dir.path] = createdDirs(dir.path) + } + if err := PrepareDirs(opts); err != nil { + t.Fatalf("PrepareDirs() = %v, want nil", err) + } + + // A cache tree of the instance is not owned by the installation: its + // ownership has to stay the one it had. + cache := filepath.Join(root, "cache") + if err := os.Mkdir(cache, 0o700); err != nil { + t.Fatalf("create the cache directory: %v", err) + } + cacheOwner, cacheGroup := systemdOwner(t, cache) + + m := &systemdManager{scope: ScopeSystem} + if err := m.grantInstanceDirs(opts, account, created); err != nil { + t.Fatalf("grantInstanceDirs() = %v, want nil", err) + } + + // Every directory the installation created belongs to the account: the + // parents it created for the directories of the instance belong to it + // as well, because the instance has to reach its own directories + // through them. + for _, dir := range instanceDirs(opts) { + for _, path := range created[dir.path] { + uid, gid := systemdOwner(t, path) + if uid != account.Uid { + t.Errorf("the owner of the %s %q = %q, want the service account %q", dir.name, path, uid, account.Uid) + } + if gid != account.Gid { + t.Errorf("the group of the %s %q = %q, want the group %q of the service account", dir.name, path, gid, account.Gid) + } + } + } + if uid, gid := systemdOwner(t, cache); uid != cacheOwner || gid != cacheGroup { + t.Errorf("the ownership of the cache directory %q = %q:%q, want %q:%q", cache, uid, gid, cacheOwner, cacheGroup) + } +} + +// TestSystemdGrantInstanceDirsRefusesAPreExistingDirectory checks that a +// directory that existed before the installation is not chowned. +// +// The directory belongs to whoever owns it, so the installation leaves it +// alone. The service account still has to be able to write there, so an +// installation that cannot do that is refused with the guidance that names +// the directory and the account. +func TestSystemdGrantInstanceDirsRefusesAPreExistingDirectory(t *testing.T) { + account := systemdSecondAccount(t) + + root := t.TempDir() + // The state directory exists before the installation and belongs to + // the user of the test: the service account cannot write into it. + state := filepath.Join(root, "state") + if err := os.Mkdir(state, 0o700); err != nil { + t.Fatalf("create the state directory: %v", err) + } + stateOwner, stateGroup := systemdOwner(t, state) + + opts := InstallOptions{ + Scope: ScopeSystem, + Executable: systemdTestExecutable(t), + Listen: DefaultListen, + Paths: runtimepaths.Paths{ + ConfigFile: filepath.Join(root, "etc", "hfdesk.json"), + StateDir: state, + LogDir: filepath.Join(root, "var", "log", "hfdesk"), + }, + } + + // The state directory existed before the installation, the other two + // directories are created by it. + created := make(map[string][]string, 3) + for _, dir := range instanceDirs(opts) { + created[dir.path] = createdDirs(dir.path) + } + if err := PrepareDirs(opts); err != nil { + t.Fatalf("PrepareDirs() = %v, want nil", err) + } + config := filepath.Dir(opts.Paths.ConfigFile) + configOwner, configGroup := systemdOwner(t, config) + + m := &systemdManager{scope: ScopeSystem} + err := m.grantInstanceDirs(opts, account, created) + if err == nil { + t.Fatal("grantInstanceDirs() = nil, want the refusal of a pre-existing directory") + } + want := fmt.Sprintf("service: the state dir %q of the machine-wide instance is not writable by the service account %q: grant it to the account (for example %q) and install again", + state, account.Username, "chown "+account.Username+" "+state) + if err.Error() != want { + t.Errorf("grantInstanceDirs() = %q, want %q", err, want) + } + + // The directory that existed before the installation still belongs to + // its owner: it was not chowned. + if uid, gid := systemdOwner(t, state); uid != stateOwner || gid != stateGroup { + t.Errorf("the ownership of the state directory %q = %q:%q, want %q:%q", state, uid, gid, stateOwner, stateGroup) + } + // The refusal happened before the first ownership was changed, so the + // directory the installation would have granted is untouched as well. + if uid, gid := systemdOwner(t, config); uid != configOwner || gid != configGroup { + t.Errorf("the ownership of the config directory %q = %q:%q, want %q:%q", config, uid, gid, configOwner, configGroup) + } +} + +// TestSystemdGrantInstanceDirsKeepsAWritablePreExistingDirectory checks that +// a directory that existed before the installation and that the service +// account can write into is accepted and left alone. +// +// The directory belongs to whoever owns it, so the installation grants it to +// nobody: it only makes sure that the account can work there. +func TestSystemdGrantInstanceDirsKeepsAWritablePreExistingDirectory(t *testing.T) { + account := systemdSecondAccount(t) + + root := t.TempDir() + opts := InstallOptions{ + Scope: ScopeSystem, + Executable: systemdTestExecutable(t), + Listen: DefaultListen, + Paths: runtimepaths.Paths{ + ConfigFile: filepath.Join(root, "etc", "hfdesk.json"), + StateDir: filepath.Join(root, "state"), + LogDir: filepath.Join(root, "logs"), + }, + } + // Every directory of the instance exists before the installation and + // is writable by the account. + owners := map[string][2]string{} + for _, dir := range instanceDirs(opts) { + if err := os.MkdirAll(dir.path, 0o700); err != nil { + t.Fatalf("create the %s %q: %v", dir.name, dir.path, err) + } + // The mode of a create call is filtered by the umask of the + // process, so the mode is set explicitly. + if err := os.Chmod(dir.path, 0o777); err != nil { + t.Fatalf("make the %s %q writable: %v", dir.name, dir.path, err) + } + uid, gid := systemdOwner(t, dir.path) + owners[dir.path] = [2]string{uid, gid} + } + + created := make(map[string][]string, 3) + for _, dir := range instanceDirs(opts) { + created[dir.path] = createdDirs(dir.path) + } + + m := &systemdManager{scope: ScopeSystem} + if err := m.grantInstanceDirs(opts, account, created); err != nil { + t.Fatalf("grantInstanceDirs() = %v, want nil for a pre-existing directory the account can write into", err) + } + + // The directories still belong to their owner: the installation grants + // only the directories it created itself. + for _, dir := range instanceDirs(opts) { + uid, gid := systemdOwner(t, dir.path) + want := owners[dir.path] + if uid != want[0] || gid != want[1] { + t.Errorf("the ownership of the %s %q = %q:%q, want %q:%q", dir.name, dir.path, uid, gid, want[0], want[1]) + } + } +} + +// TestSystemdInstallRefusesAPreExistingDirectory checks that a machine-wide +// installation whose state directory existed before the installation is +// refused with the guidance that names the directory and the account, and +// that the refused installation never changes the ownership of that +// directory. +// +// The installation needs an elevated shell, so the test skips itself when it +// does not run as root. +func TestSystemdInstallRefusesAPreExistingDirectory(t *testing.T) { + if os.Geteuid() != 0 { + t.Skip("skipping: a machine-wide installation needs root") + } + account := systemdSecondAccount(t) + + // The home of the user is a temporary directory, so the guard of the + // machine-wide scope does not refuse the paths of the test. + home := t.TempDir() + t.Setenv("HOME", home) + + root := t.TempDir() + // The state directory exists before the installation and belongs to + // the user of the test: the service account cannot write into it. + state := filepath.Join(root, "state") + if err := os.Mkdir(state, 0o700); err != nil { + t.Fatalf("create the state directory: %v", err) + } + stateOwner, stateGroup := systemdOwner(t, state) + + m := &systemdManager{scope: ScopeSystem} + path, err := m.unitPath() + if err != nil { + t.Fatalf("unitPath() = %v, want nil", err) + } + // The unit file of the machine scope lives in a fixed place of the + // machine. The test never writes it: the refusal happens before the + // unit file is written. The state of the file is compared before and + // after the installation, so a unit that is installed already is never + // touched by the test. + before, existedBefore, err := readUnitFile(path) + if err != nil { + t.Logf("the unit file %q cannot be read: %v", path, err) + } + + opts := InstallOptions{ + Scope: ScopeSystem, + Executable: systemdTestExecutable(t), + Listen: DefaultListen, + ServiceUser: account.Username, + Paths: runtimepaths.Paths{ + ConfigFile: filepath.Join(root, "etc", "hfdesk.json"), + StateDir: state, + LogDir: filepath.Join(root, "var", "log", "hfdesk"), + }, + } + + err = m.Install(context.Background(), opts) + if err == nil { + t.Fatal("Install() = nil, want the refusal of the pre-existing state directory") + } + want := fmt.Sprintf("service: the state dir %q of the machine-wide instance is not writable by the service account %q: grant it to the account (for example %q) and install again", + state, account.Username, "chown "+account.Username+" "+state) + if err.Error() != want { + t.Errorf("Install() = %q, want %q", err, want) + } + + // The refused installation never changes the ownership of the + // directory that existed before it. + if uid, gid := systemdOwner(t, state); uid != stateOwner || gid != stateGroup { + t.Errorf("the ownership of the state directory %q = %q:%q, want %q:%q", state, uid, gid, stateOwner, stateGroup) + } + // The refused installation writes no unit file. + after, existedAfter, err := readUnitFile(path) + if err != nil { + t.Logf("the unit file %q cannot be read: %v", path, err) + } + if existedBefore != existedAfter || string(before) != string(after) { + t.Errorf("the refused installation changed the unit file %q", path) + } +} + +// TestSystemdGrantInstanceDirsRefusesADirectoryOfTheMachine checks that the +// installation never hands a directory of the machine to the account of the +// instance. +// +// The directory of the config file is derived from the path of a file, so a +// config file in /etc would hand /etc itself to the account. The refusal +// happens before the first directory is granted, so no ownership changes at +// all. +func TestSystemdGrantInstanceDirsRefusesADirectoryOfTheMachine(t *testing.T) { + account := systemdSecondAccount(t) + + root := t.TempDir() + opts := InstallOptions{ + Scope: ScopeSystem, + Executable: systemdTestExecutable(t), + Listen: DefaultListen, + Paths: runtimepaths.Paths{ + // The config file lies directly in a top level directory + // of the machine: its directory is /etc. + ConfigFile: "/etc/hfdesk.json", + StateDir: filepath.Join(root, "var", "lib", "hfdesk"), + LogDir: filepath.Join(root, "var", "log", "hfdesk"), + }, + } + if err := PrepareDirs(opts); err != nil { + t.Fatalf("PrepareDirs() = %v, want nil", err) + } + stateOwner, stateGroup := systemdOwner(t, opts.Paths.StateDir) + + m := &systemdManager{scope: ScopeSystem} + err := m.grantInstanceDirs(opts, account, nil) + if err == nil { + t.Fatal("grantInstanceDirs() = nil, want the refusal of a directory of the machine") + } + want := `service: the config dir "/etc" is a directory of the machine: the instance needs a directory of its own, name one below a directory of the machine` + if err.Error() != want { + t.Errorf("grantInstanceDirs() = %q, want %q", err, want) + } + + // The refusal granted nothing: the state directory still belongs to + // the account it belonged to. + if uid, gid := systemdOwner(t, opts.Paths.StateDir); uid != stateOwner || gid != stateGroup { + t.Errorf("the ownership of the state directory %q = %q:%q, want %q:%q", opts.Paths.StateDir, uid, gid, stateOwner, stateGroup) + } +} + +// TestSystemdCheckServiceAccountAccess checks the rules that decide whether +// the service account can write into a directory the user named: the +// ownership of the account, the group of the account, the world write +// permission and the nearest existing ancestor of a directory that is not +// there yet. +// +// The account of the test is the account of the test process, so the +// directories of the test belong to it. +func TestSystemdCheckServiceAccountAccess(t *testing.T) { + account, err := user.Current() + if err != nil { + t.Fatalf("Current() = %v, want nil", err) + } + other := systemdSecondAccount(t) + + root := t.TempDir() + owned := filepath.Join(root, "owned") + if err := os.Mkdir(owned, 0o700); err != nil { + t.Fatal(err) + } + group := filepath.Join(root, "group") + if err := os.Mkdir(group, 0o770); err != nil { + t.Fatal(err) + } + world := filepath.Join(root, "world") + if err := os.Mkdir(world, 0o777); err != nil { + t.Fatal(err) + } + closed := filepath.Join(root, "closed") + if err := os.Mkdir(closed, 0o500); err != nil { + t.Fatal(err) + } + + // A directory the account cannot write into: it belongs to the + // account of the test, and the account of the check is another one. + foreign := filepath.Join(root, "foreign") + if err := os.Mkdir(foreign, 0o700); err != nil { + t.Fatal(err) + } + + m := &systemdManager{scope: ScopeSystem} + cases := []struct { + name string + account *user.User + path string + wantErr bool + }{ + {name: "a directory the account owns", account: account, path: owned}, + {name: "a directory of the group of the account", account: account, path: group}, + {name: "a world writable directory", account: account, path: world}, + {name: "a directory the account cannot write into", account: other, path: foreign, wantErr: true}, + {name: "a directory without a write permission", account: other, path: closed, wantErr: true}, + {name: "a missing directory below a writable ancestor", account: account, path: filepath.Join(owned, "cache", "hub")}, + {name: "a missing directory below a closed ancestor", account: other, path: filepath.Join(closed, "cache"), wantErr: true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := checkDirWritableByAccount(tc.path, tc.account) + if tc.wantErr && err == nil { + t.Errorf("checkDirWritableByAccount(%q) = nil, want a refusal", tc.path) + } + if !tc.wantErr && err != nil { + t.Errorf("checkDirWritableByAccount(%q) = %v, want nil", tc.path, err) + } + }) + } + + t.Run("the scope default of the cache is not checked", func(t *testing.T) { + opts := systemdTestOptions(t, ScopeSystem) + opts.Paths.StateDir = filepath.Join(root, "state") + opts.CacheDir = filepath.Join(opts.Paths.StateDir, "cache") + opts.HubDir = filepath.Join(opts.CacheDir, "hub") + // The machine-wide scope has no local dir of its own, so no + // directory is named and nothing is checked. + opts.LocalDir = "" + // The account cannot write into the cache of the scope, and the + // installation does not check it: the cache lives inside the + // state directory of the instance. + if err := m.checkServiceAccountAccess(opts, other); err != nil { + t.Errorf("checkServiceAccountAccess() = %v, want nil for the cache of the scope", err) + } + }) + + t.Run("a named cache of the scope is checked", func(t *testing.T) { + opts := systemdTestOptions(t, ScopeSystem) + opts.Paths.StateDir = filepath.Join(root, "state") + opts.CacheDir = foreign + opts.HubDir = filepath.Join(foreign, "hub") + if err := m.checkServiceAccountAccess(opts, other); err == nil { + t.Error("checkServiceAccountAccess() = nil, want the refusal of the named cache") + } + }) + + t.Run("the user scope is not checked", func(t *testing.T) { + opts := systemdTestOptions(t, ScopeUser) + opts.CacheDir = foreign + + user := &systemdManager{scope: ScopeUser} + if err := user.checkServiceAccountAccess(opts, other); err != nil { + t.Errorf("checkServiceAccountAccess() = %v, want nil for a user scope instance", err) + } + }) +} + +// TestSystemdInstallRefusesACacheDirTheAccountCannotWrite checks that a +// machine-wide installation whose cache directory the service account cannot +// write into is refused with the guidance that names the account and the +// directory, and that the refused installation never changes the ownership +// of that directory. +func TestSystemdInstallRefusesACacheDirTheAccountCannotWrite(t *testing.T) { + account := systemdSecondAccount(t) + + // The home of the user is a temporary directory, so the guard of the + // machine-wide scope does not refuse the paths of the test. + home := t.TempDir() + t.Setenv("HOME", home) + + root := t.TempDir() + cache := filepath.Join(root, "cache") + if err := os.Mkdir(cache, 0o700); err != nil { + t.Fatalf("create the cache directory: %v", err) + } + owner, group := systemdOwner(t, cache) + + m := &systemdManager{scope: ScopeSystem} + opts := InstallOptions{ + Scope: ScopeSystem, + Executable: systemdTestExecutable(t), + Listen: DefaultListen, + Paths: runtimepaths.Paths{ + ConfigFile: filepath.Join(root, "etc", "hfdesk.json"), + StateDir: filepath.Join(root, "var", "lib", "hfdesk"), + LogDir: filepath.Join(root, "var", "log", "hfdesk"), + }, + CacheDir: cache, + ServiceUser: account.Username, + } + + err := m.Install(context.Background(), opts) + if err == nil { + t.Fatal("Install() = nil, want the refusal of the cache directory") + } + want := fmt.Sprintf("service: the service account %q cannot write to the cache directory %q: grant it access (for example %q or by adding it to the group that owns the directory) and install again", + account.Username, cache, "chown -R "+account.Username+" "+cache) + if err.Error() != want { + t.Errorf("Install() = %q, want %q", err, want) + } + + // The refused installation never changes the ownership of the cache + // tree. + if uid, gid := systemdOwner(t, cache); uid != owner || gid != group { + t.Errorf("the ownership of the cache directory %q = %q:%q, want %q:%q", cache, uid, gid, owner, group) + } + // The refused installation creates no directory of the instance. + for _, dir := range instanceDirs(opts) { + if _, err := os.Stat(dir.path); !errors.Is(err, fs.ErrNotExist) { + t.Errorf("the refused installation created the %s %q: %v", dir.name, dir.path, err) + } + } +} + +// TestSystemdUserScopeNeedsNoServiceAccount checks that a user scope +// instance is not affected by the service account of the machine-wide scope: +// it needs no account, no account is written into its unit and the access of +// a directory it does not own is not checked. +func TestSystemdUserScopeNeedsNoServiceAccount(t *testing.T) { + const missing = "hfdesk-missing-service-account" + + opts := systemdTestOptions(t, ScopeUser) + opts.ServiceUser = missing + // A cache directory the account could not write into: the user scope + // instance writes it as the user of the session. + opts.CacheDir = filepath.Join(t.TempDir(), "cache") + + m := &systemdManager{scope: ScopeUser} + + account, err := m.serviceAccount(opts) + if err != nil { + t.Fatalf("serviceAccount() = %v, want nil for a user scope instance", err) + } + if account != nil { + t.Errorf("a user scope instance resolved the account %q, want none", account.Username) + } + if err := m.checkServiceAccountAccess(opts, account); err != nil { + t.Errorf("checkServiceAccountAccess() = %v, want nil for a user scope instance", err) + } + if err := m.grantInstanceDirs(opts, account, nil); err != nil { + t.Errorf("grantInstanceDirs() = %v, want nil for a user scope instance", err) + } + + // The unit of a user scope instance names no account, even when the + // command line named one. + unit, err := renderUnit(opts) + if err != nil { + t.Fatalf("renderUnit() = %v, want nil", err) + } + for _, directive := range unitDirectives(unit) { + if strings.HasPrefix(directive, "User=") || strings.HasPrefix(directive, "Group=") { + t.Errorf("a user scope unit names an account: %q", directive) + } + } +} diff --git a/internal/service/service.go b/internal/service/service.go index 42e13a98..4ae3574d 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -20,6 +20,7 @@ import ( "context" "errors" "fmt" + "io/fs" "net" "os" "path/filepath" @@ -124,6 +125,33 @@ type InstallOptions struct { HubDir string // LocalDir is the absolute flat output dir. It may be empty. LocalDir string + // ServiceUser is the account a machine-wide instance runs as. + // + // It applies to the machine-wide scope only: a user scope instance + // runs as the user itself and never names a service account. An + // empty value means the default account of the platform + // (DefaultServiceUser), and a platform without a named account + // (Windows uses the built-in LocalService) keeps its own identity. + // + // The account is never created by an installation: it is the identity + // the supervisor starts the instance with, and the installation + // refuses to register an instance whose account does not exist. + ServiceUser string +} + +// DefaultServiceUser returns the default account a machine-wide instance +// runs as on this platform. +// +// The account is a dedicated, stable and unprivileged service account: the +// files it owns have to stay writable across reboots, and a machine-wide +// instance needs no privilege of root. The name is only a default, the +// caller may name another account. +// +// A platform without a named service account returns an empty string: +// Windows runs the instance as the built-in LocalService account, which has +// no name of its own. +func DefaultServiceUser() string { + return defaultServiceUser() } // Manager drives the platform supervisor. The common layer owns the command @@ -329,8 +357,14 @@ func ValidateInstallOptions(opts InstallOptions) error { // PrepareDirs creates the directories an installation owns: the parent // directory of the config file, the state directory and the log directory. -// They are created with mode 0700 and an existing directory is tightened to -// the same mode. Nothing outside those three directories is touched. +// +// A directory that is missing is created with the mode the instance owns +// (0700). The mode of a directory that already exists is never changed: a +// user who names an existing directory with --state-dir or --config keeps +// the permissions of that directory, and the installation only makes sure +// that it exists. +// +// Nothing outside those three directories is touched. func PrepareDirs(opts InstallOptions) error { dirs := []struct { name string @@ -357,16 +391,87 @@ func PrepareDirs(opts InstallOptions) error { } seen[dir.path] = true - if err := os.MkdirAll(dir.path, dirMode); err != nil { - return fmt.Errorf("service: create %s %q: %w", dir.name, dir.path, err) + if err := ensureDir(dir.path, dirMode); err != nil { + return fmt.Errorf("service: %s %q: %w", dir.name, dir.path, err) } - if err := os.Chmod(dir.path, dirMode); err != nil { - return fmt.Errorf("service: restrict %s %q: %w", dir.name, dir.path, err) + } + return nil +} + +// ensureDir creates a directory that is missing and leaves the mode of a +// directory that already exists alone. +// +// The mode of a directory the installation creates itself is the owner-only +// mode of the instance: the state directory holds the job list, the history +// and the mirror targets, and the config directory holds the credentials. +// The mode of a directory that already exists belongs to whoever named it, +// so the installation never changes it. +func ensureDir(path string, mode os.FileMode) error { + info, err := os.Stat(path) + switch { + case err == nil: + if !info.IsDir() { + return fmt.Errorf("it exists and is not a directory") + } + return nil + case !errors.Is(err, fs.ErrNotExist): + return err + } + + created, err := createMissingDirs(path, mode) + if err != nil { + return err + } + // The mode of a create call is filtered by the umask of the process, + // so the mode of every directory the installation created is set + // explicitly. + for _, dir := range created { + if err := os.Chmod(dir, mode); err != nil { + return err } } return nil } +// createMissingDirs creates the directory and the missing parents of it and +// returns the directories it created. +// +// The parents are collected first, so the directories are created from the +// top down and every missing one is known afterwards. A directory that +// appears in the meantime is not created and not touched. +func createMissingDirs(path string, mode os.FileMode) ([]string, error) { + var missing []string + current := filepath.Clean(path) + for { + if _, err := os.Stat(current); err == nil { + break + } else if !errors.Is(err, fs.ErrNotExist) { + return nil, err + } + missing = append(missing, current) + parent := filepath.Dir(current) + if parent == current { + break + } + current = parent + } + + created := make([]string, 0, len(missing)) + for i := len(missing) - 1; i >= 0; i-- { + err := os.Mkdir(missing[i], mode) + if errors.Is(err, fs.ErrExist) { + // The directory appeared in the meantime: its permissions + // belong to whoever created it. + continue + } + if err != nil { + return nil, err + } + created = append(created, missing[i]) + } + return created, nil +} + // isRootDir reports whether the path is the root of its filesystem. Such a // directory is never owned by an installation: tightening it to 0700 would // take the whole filesystem away from its users. diff --git a/internal/service/service_test.go b/internal/service/service_test.go index 12fabe7c..ac7bb992 100644 --- a/internal/service/service_test.go +++ b/internal/service/service_test.go @@ -101,16 +101,17 @@ func TestRunArgsUsesDefaultListenWhenUnset(t *testing.T) { // installOptionStringFields are the string fields of InstallOptions. // -// Every one of them carries the scope, a path or the bind address of an -// instance. The type carries no credential field at all, so a registration -// can never carry one. +// Every one of them carries the scope, a path, the bind address or the +// account of an instance. The type carries no credential field at all, so a +// registration can never carry one. var installOptionStringFields = map[string]bool{ - "Scope": true, - "Executable": true, - "Listen": true, - "CacheDir": true, - "HubDir": true, - "LocalDir": true, + "Scope": true, + "Executable": true, + "Listen": true, + "CacheDir": true, + "HubDir": true, + "LocalDir": true, + "ServiceUser": true, } // TestInstallOptionsCarryNoCredentialField checks that the options of an @@ -340,20 +341,18 @@ func TestValidateInstallOptionsRejectsInvalidInstallation(t *testing.T) { } } +// TestPrepareDirsCreatesOwnedDirectories checks that the directories an +// installation owns are created owner-only. +// +// They hold the job list, the download history, the mirror targets and the +// credentials of the instance, so a directory that is missing at the start +// of an instance must not be created world-readable. func TestPrepareDirsCreatesOwnedDirectories(t *testing.T) { root := t.TempDir() configDir := filepath.Join(root, "etc") stateDir := filepath.Join(root, "var") logDir := filepath.Join(root, "var", "logs") - // An existing state directory with loose permissions must be tightened. - if err := os.MkdirAll(stateDir, 0o755); err != nil { - t.Fatal(err) - } - if err := os.Chmod(stateDir, 0o755); err != nil { - t.Fatal(err) - } - opts := InstallOptions{ Paths: runtimepaths.Paths{ ConfigFile: filepath.Join(configDir, "hfdesk.json"), @@ -381,6 +380,47 @@ func TestPrepareDirsCreatesOwnedDirectories(t *testing.T) { } } +// TestPrepareDirsLeavesTheModeOfAnExistingDirectoryAlone checks that the +// installation never changes the permissions of a directory the user named. +// +// An explicit --state-dir or --config can point at a directory that is +// shared with something else. Tightening it to 0700 would silently take it +// away from its other users, so the installation only makes sure that the +// directory exists. +func TestPrepareDirsLeavesTheModeOfAnExistingDirectoryAlone(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows mode bits are emulated and do not represent DACL permissions") + } + + root := t.TempDir() + stateDir := filepath.Join(root, "var") + if err := os.MkdirAll(stateDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.Chmod(stateDir, 0o755); err != nil { + t.Fatal(err) + } + + opts := InstallOptions{ + Paths: runtimepaths.Paths{ + ConfigFile: filepath.Join(root, "etc", "hfdesk.json"), + StateDir: stateDir, + LogDir: filepath.Join(root, "logs"), + }, + } + if err := PrepareDirs(opts); err != nil { + t.Fatalf("PrepareDirs() = %v, want nil", err) + } + + info, err := os.Stat(stateDir) + if err != nil { + t.Fatalf("stat %s: %v", stateDir, err) + } + if info.Mode().Perm() != 0o755 { + t.Errorf("the mode of %s is %v, want the untouched 0755", stateDir, info.Mode().Perm()) + } +} + func TestPrepareDirsLeavesUnrelatedPathsAlone(t *testing.T) { root := t.TempDir() unrelatedDir := filepath.Join(root, "other") @@ -460,6 +500,43 @@ func TestNewRejectsUnknownScope(t *testing.T) { } } +// TestDefaultServiceUserIsTheAccountOfThePlatform checks the default +// account of a machine-wide instance. +// +// The account is a dedicated, stable and unprivileged service account: the +// files it owns have to stay writable across reboots, and the instance +// needs no privilege of root. Windows has no account to name: the instance +// runs as the built-in LocalService account. +func TestDefaultServiceUserIsTheAccountOfThePlatform(t *testing.T) { + want := map[string]string{ + "linux": "hfdesk", + "darwin": "_hfdesk", + "windows": "", + } + expected, ok := want[runtime.GOOS] + if !ok { + expected = "" + } + if got := DefaultServiceUser(); got != expected { + t.Errorf("DefaultServiceUser() = %q, want %q", got, expected) + } +} + +// TestDefaultServiceUserIsStable checks that the default account does not +// depend on the machine or on the user that runs the command. +// +// The account owns the files of the instance, and those files have to stay +// writable across reboots: an account that changed from run to run would +// break the instance at the next restart. +func TestDefaultServiceUserIsStable(t *testing.T) { + first := DefaultServiceUser() + for i := 0; i < 3; i++ { + if got := DefaultServiceUser(); got != first { + t.Fatalf("DefaultServiceUser() = %q, want the stable value %q", got, first) + } + } +} + func TestNewReturnsThePlatformManager(t *testing.T) { m, err := New(ScopeUser) if err == nil { diff --git a/internal/service/serviceuser_darwin.go b/internal/service/serviceuser_darwin.go new file mode 100644 index 00000000..bbff9d79 --- /dev/null +++ b/internal/service/serviceuser_darwin.go @@ -0,0 +1,22 @@ +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 + +//go:build darwin + +package service + +// defaultServiceUser returns the default account a machine-wide instance +// runs as on macOS. +// +// The account follows the convention of the platform for a service account: +// a name that starts with an underscore is reserved for a machine level +// identity and is not a login account. It is dedicated, stable and +// unprivileged: the files it owns have to stay writable across reboots, and +// the instance needs no privilege of root. +// +// The installation never creates it: an administrator does that once, and +// the installation refuses to register an instance whose account does not +// exist. +func defaultServiceUser() string { + return "_hfdesk" +} diff --git a/internal/service/serviceuser_linux.go b/internal/service/serviceuser_linux.go new file mode 100644 index 00000000..a000f568 --- /dev/null +++ b/internal/service/serviceuser_linux.go @@ -0,0 +1,18 @@ +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 + +//go:build linux + +package service + +// defaultServiceUser returns the default account a machine-wide instance +// runs as on Linux. +// +// The account is a dedicated, stable, unprivileged service account: the +// files it owns have to stay writable across reboots, and the instance +// needs no privilege of root. The installation never creates it: an +// administrator does that once, and the installation refuses to register an +// instance whose account does not exist. +func defaultServiceUser() string { + return "hfdesk" +} diff --git a/internal/service/serviceuser_other.go b/internal/service/serviceuser_other.go new file mode 100644 index 00000000..e021a6df --- /dev/null +++ b/internal/service/serviceuser_other.go @@ -0,0 +1,15 @@ +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 + +//go:build !linux && !darwin && !windows + +package service + +// defaultServiceUser returns the default account a machine-wide instance +// runs as on a platform without a service mode. +// +// The shared layer never branches on the operating system, and a platform +// without a supervisor has no account to name either. +func defaultServiceUser() string { + return "" +} diff --git a/internal/service/serviceuser_windows.go b/internal/service/serviceuser_windows.go new file mode 100644 index 00000000..31d4a12b --- /dev/null +++ b/internal/service/serviceuser_windows.go @@ -0,0 +1,17 @@ +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 + +//go:build windows + +package service + +// defaultServiceUser returns the default account a machine-wide instance +// runs as on Windows. +// +// Windows has no account to name: the instance runs as LocalService, a +// built-in least-privilege service account of the service control manager. +// The value is therefore empty, and the platform manager uses the built-in +// account instead of a name of its own. +func defaultServiceUser() string { + return "" +} From fbf43c4722051e783ba5fbb98f0568b3cb68b542 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 22 Oct 2026 00:18:43 +1100 Subject: [PATCH 15/58] fix(service): make the Windows uninstall idempotent without elevation - Look the registration up with a read-only connection to the service control manager before the elevation requirement applies, so uninstalling a service that was never installed is a clean success in every shell. - Report the elevation requirement when a registration exists or when the caller may not look, instead of a raw access-denied error, and make the start, stop and status answers independent of the rights of the shell. Co-Authored-By: opencode --- internal/service/manager_windows.go | 283 +++++++++++++++++++++-- internal/service/manager_windows_test.go | 251 ++++++++++++++++++++ 2 files changed, 517 insertions(+), 17 deletions(-) diff --git a/internal/service/manager_windows.go b/internal/service/manager_windows.go index 3f4a0d71..94d61978 100644 --- a/internal/service/manager_windows.go +++ b/internal/service/manager_windows.go @@ -36,9 +36,16 @@ import ( // PowerShell and the shell are never called, so a path with a space or a // quote in it cannot change the command that runs. // -// The manager never escalates privileges itself: an installation and an -// uninstallation need an elevated shell, and the manager fails with a -// clear message when the process does not have one. +// The manager never escalates privileges itself: an installation needs an +// elevated shell, and so does an uninstallation that has a registration to +// remove. The manager fails with a clear message when the process does not +// have one. +// +// The existence of the registration is looked up before the elevation +// requirement is applied: an ordinary shell can read the registration of +// the instance, so it can tell an absent registration from a registration +// it may not look at. A registration that is not there is a clean success +// in every shell. const ( // scmDisplayName is the name the service manager shows. scmDisplayName = "HFDesk" @@ -203,16 +210,61 @@ func (m *scmManager) Install(ctx context.Context, opts InstallOptions) error { // It removes the registration only: the config file, the jobs, the history, // the logs, the models and the cache of the instance are never touched. An // absent registration is a clean success. +// +// The existence of the registration is checked before the elevation +// requirement is applied. An ordinary shell can read the registration of +// the instance, so it tells an absent registration from a registration it +// may not look at: an absent registration is a clean success in every +// shell, and the elevation requirement applies only when there is +// something to remove. func (m *scmManager) Uninstall(ctx context.Context) error { - if err := requireElevated("an uninstallation"); err != nil { + state, err := scmRegistrationStateOf() + if err != nil { + return err + } + return m.uninstall(ctx, state, isElevatedProcess()) +} + +// uninstall removes the registration of the instance after the existence +// of the registration was established. +// +// The state of the registration and the state of the token of the process +// are parameters, so the decision of the uninstallation can be driven from +// both sides without an elevated shell: the service control manager +// accepts a machine-wide registration only from an administrator, so a +// test cannot create one without an elevated shell. +func (m *scmManager) uninstall(ctx context.Context, state scmRegistrationState, elevated bool) error { + switch scmUninstallActionFor(state) { + case scmUninstallNothing: + // There is no registration: the uninstallation is already done. + return nil + case scmUninstallElevate: + // The registration could not be read: the shell of the caller + // has no rights on it. The elevation requirement is the + // truthful reason, so it is reported instead of the access + // denied error of the platform. + if err := requireElevatedFor("an uninstallation", elevated); err != nil { + return err + } + return scmElevationRequired("an uninstallation") + case scmUninstallRemove: + // The registration has to be removed: the elevation requirement + // is checked before the removal. + } + + // The elevation requirement is checked before the registration is + // removed: a permission error of the machine is a much less readable + // answer than the real reason. + if err := requireElevatedFor("an uninstallation", elevated); err != nil { return err } - manager, handle, ok, err := m.openRegistration() + manager, handle, ok, err := m.openRegistrationPrivileged("an uninstallation") if err != nil { return err } if !ok { - // There is no registration: the uninstallation is already done. + // The registration is gone between the check and the removal: + // the uninstallation is already done. return nil } defer manager.Disconnect() @@ -238,8 +290,12 @@ func (m *scmManager) Uninstall(ctx context.Context) error { // // Starting an instance that runs already is a stable success: the state // the caller asked for already holds. +// +// The existence of the registration is looked up with a read-only +// connection, so the answer does not depend on the rights of the shell: an +// instance that is not registered is reported as such in every shell. func (m *scmManager) Start(ctx context.Context) error { - manager, handle, ok, err := m.openRegistration() + manager, handle, ok, err := m.openRegistration("a start") if err != nil { return err } @@ -253,7 +309,7 @@ func (m *scmManager) Start(ctx context.Context) error { if errors.Is(err, windows.ERROR_SERVICE_ALREADY_RUNNING) { return nil } - return fmt.Errorf("service: start %s: %w", ServiceName, err) + return scmRefused("a start", fmt.Errorf("service: start %s: %w", ServiceName, err)) } return nil } @@ -264,7 +320,7 @@ func (m *scmManager) Start(ctx context.Context) error { // is stopping an instance that is not registered at all: it does not run // either. func (m *scmManager) Stop(ctx context.Context) error { - manager, handle, ok, err := m.openRegistration() + manager, handle, ok, err := m.openRegistration("a stop") if err != nil { return err } @@ -283,7 +339,7 @@ func (m *scmManager) Stop(ctx context.Context) error { // completes before a new process is started, so the two instances never // compete for the bind address. func (m *scmManager) Restart(ctx context.Context) error { - manager, handle, ok, err := m.openRegistration() + manager, handle, ok, err := m.openRegistration("a restart") if err != nil { return err } @@ -300,7 +356,7 @@ func (m *scmManager) Restart(ctx context.Context) error { if errors.Is(err, windows.ERROR_SERVICE_ALREADY_RUNNING) { return nil } - return fmt.Errorf("service: start %s: %w", ServiceName, err) + return scmRefused("a restart", fmt.Errorf("service: start %s: %w", ServiceName, err)) } return nil } @@ -323,7 +379,7 @@ func (m *scmManager) Status(ctx context.Context) (Status, error) { UnitPath: scmRegistrationPath, } - manager, handle, ok, err := m.openRegistration() + manager, handle, ok, err := m.openRegistration("a status") if err != nil { return status, err } @@ -396,14 +452,166 @@ func applyRegistration(status *Status, registration scmRegistration) { status.CacheDir = EffectiveCacheDir(status.Paths.ConfigFile, registration.cacheDir()) } +// scmRegistrationState describes what the service control manager knows +// about the registration of the instance. +type scmRegistrationState int + +const ( + // scmRegistrationAbsent means the instance is not registered. + scmRegistrationAbsent scmRegistrationState = iota + // scmRegistrationPresent means the instance is registered. + scmRegistrationPresent + // scmRegistrationUnreadable means the caller may not look at the + // registration, so it cannot tell whether the instance is + // registered. + scmRegistrationUnreadable +) + +// scmRegistrationStateOf reports what the service control manager knows +// about the registration of the instance. +func scmRegistrationStateOf() (scmRegistrationState, error) { + return scmRegistrationStateOfName(ServiceName) +} + +// scmRegistrationStateOfName reports what the service control manager +// knows about the registration of the given name. +// +// The check opens the service control manager with the right to connect +// only and the registration with the right to query its state only: an +// ordinary shell can read the registration of the instance, so it can tell +// whether the instance is installed at all. The manager of the platform +// opens the service control manager with every right instead, which an +// ordinary shell does not have. +// +// A registration the caller may not look at is not an absent registration: +// the caller cannot tell, and the truth is that the operation needs the +// rights of the machine either way. +func scmRegistrationStateOfName(name string) (scmRegistrationState, error) { + manager, err := windows.OpenSCManager(nil, nil, windows.SC_MANAGER_CONNECT) + if err != nil { + state, reason := scmClassifyRegistrationError(err) + if reason != nil { + return state, fmt.Errorf("service: connect to the service control manager: %w", reason) + } + return state, nil + } + defer windows.CloseServiceHandle(manager) + + pointer, err := windows.UTF16PtrFromString(name) + if err != nil { + return scmRegistrationUnreadable, fmt.Errorf("service: the name of the service cannot be read: %w", err) + } + handle, err := windows.OpenService(manager, pointer, windows.SERVICE_QUERY_STATUS) + if err != nil { + state, reason := scmClassifyRegistrationError(err) + if reason != nil { + return state, fmt.Errorf("service: open the registration of %s: %w", name, reason) + } + return state, nil + } + windows.CloseServiceHandle(handle) + return scmRegistrationPresent, nil +} + +// scmClassifyRegistrationError classifies the error of an existence check. +// +// An absent registration is not an error: it is the state the caller asked +// about. A registration the caller may not look at is not an absent +// registration either: the caller cannot tell, and the truth is that the +// operation needs the rights of the machine either way. +// +// The second result is the error the caller reports. An access denied +// error is not reported as it is: the elevation requirement is the +// truthful reason, so the raw error of the platform is dropped. +func scmClassifyRegistrationError(err error) (scmRegistrationState, error) { + switch { + case err == nil: + return scmRegistrationPresent, nil + case errors.Is(err, windows.ERROR_SERVICE_DOES_NOT_EXIST): + return scmRegistrationAbsent, nil + case errors.Is(err, windows.ERROR_ACCESS_DENIED): + return scmRegistrationUnreadable, nil + default: + return scmRegistrationUnreadable, err + } +} + +// scmUninstallAction is what an uninstallation does with the registration +// of the instance. +type scmUninstallAction int + +const ( + // scmUninstallNothing means there is no registration to remove. + scmUninstallNothing scmUninstallAction = iota + // scmUninstallRemove means the registration has to be removed. + scmUninstallRemove + // scmUninstallElevate means the caller has to run the command as an + // administrator before the registration can be removed. + scmUninstallElevate +) + +// scmUninstallActionFor decides what an uninstallation does after the +// existence of the registration was established. +// +// The decision is a pure function of the outcome of the existence check, +// so it can be checked without a registration of its own: the service +// control manager accepts a machine-wide registration only from an +// administrator, so a test cannot create one without an elevated shell. +// +// An absent registration is a clean success: the uninstallation is already +// done and it needs no elevation at all. A registration the caller may not +// look at needs an elevated shell: the caller cannot tell whether the +// instance is installed, and the removal needs the rights of the machine +// either way. +func scmUninstallActionFor(state scmRegistrationState) scmUninstallAction { + switch state { + case scmRegistrationAbsent: + return scmUninstallNothing + case scmRegistrationPresent: + return scmUninstallRemove + default: + return scmUninstallElevate + } +} + // openRegistration opens the registration of the instance. // // The second result reports whether the service is registered at all: an // absent registration is not an error, it is the state the caller asked // about. -func (m *scmManager) openRegistration() (*mgr.Mgr, *mgr.Service, bool, error) { +// +// The existence of the registration is looked up with a read-only +// connection first: an ordinary shell can read the registration of the +// instance, so it tells an absent registration from a registration it may +// not look at. The privileged connection is opened only when there is +// something to change. +func (m *scmManager) openRegistration(action string) (*mgr.Mgr, *mgr.Service, bool, error) { + state, err := scmRegistrationStateOf() + if err != nil { + return nil, nil, false, err + } + switch state { + case scmRegistrationAbsent: + return nil, nil, false, nil + case scmRegistrationUnreadable: + return nil, nil, false, scmElevationRequired(action) + } + return m.openRegistrationPrivileged(action) +} + +// openRegistrationPrivileged opens the registration of the instance with +// the rights the supervisor needs to change it. +// +// The connection of the platform asks for every right of the service +// control manager, so an ordinary shell cannot open it. A refusal of the +// platform because the shell has no rights is reported as the elevation +// requirement it is. +func (m *scmManager) openRegistrationPrivileged(action string) (*mgr.Mgr, *mgr.Service, bool, error) { manager, err := mgr.Connect() if err != nil { + if errors.Is(err, windows.ERROR_ACCESS_DENIED) { + return nil, nil, false, scmElevationRequired(action) + } return nil, nil, false, fmt.Errorf("service: connect to the service control manager: %w", err) } handle, err := manager.OpenService(ServiceName) @@ -412,6 +620,9 @@ func (m *scmManager) openRegistration() (*mgr.Mgr, *mgr.Service, bool, error) { if errors.Is(err, windows.ERROR_SERVICE_DOES_NOT_EXIST) { return nil, nil, false, nil } + if errors.Is(err, windows.ERROR_ACCESS_DENIED) { + return nil, nil, false, scmElevationRequired(action) + } return nil, nil, false, fmt.Errorf("service: open the registration of %s: %w", ServiceName, err) } return manager, handle, true, nil @@ -425,7 +636,7 @@ func (m *scmManager) openRegistration() (*mgr.Mgr, *mgr.Service, bool, error) { func (m *scmManager) stopService(ctx context.Context, handle *mgr.Service) error { status, err := handle.Query() if err != nil { - return fmt.Errorf("service: query the state of %s: %w", ServiceName, err) + return scmRefused("a stop", fmt.Errorf("service: query the state of %s: %w", ServiceName, err)) } if status.State == svc.Stopped { return nil @@ -437,7 +648,7 @@ func (m *scmManager) stopService(ctx context.Context, handle *mgr.Service) error return nil } if !errors.Is(err, windows.ERROR_SERVICE_CANNOT_ACCEPT_CTRL) { - return fmt.Errorf("service: stop %s: %w", ServiceName, err) + return scmRefused("a stop", fmt.Errorf("service: stop %s: %w", ServiceName, err)) } } return m.waitForStopped(ctx, handle) @@ -453,7 +664,7 @@ func (m *scmManager) waitForStopped(ctx context.Context, handle *mgr.Service) er for { status, err := handle.Query() if err != nil { - return fmt.Errorf("service: query the state of %s: %w", ServiceName, err) + return scmRefused("a stop", fmt.Errorf("service: query the state of %s: %w", ServiceName, err)) } if status.State == svc.Stopped { return nil @@ -840,12 +1051,50 @@ var sddlRights = map[string]uint32{ // The manager never escalates privileges itself: it never starts a runas // call, never starts PowerShell and never starts a shell. func requireElevated(action string) error { - if isElevatedProcess() { + return requireElevatedFor(action, isElevatedProcess()) +} + +// requireElevatedFor rejects a machine-wide operation when the process does +// not hold an elevated token. +// +// The state of the token is a parameter, so the decision can be driven from +// both sides without an elevated shell: the real check passes the state of +// the token of the process, and the test passes the state of the two cases. +func requireElevatedFor(action string, elevated bool) error { + if elevated { return nil } return fmt.Errorf("service: %s needs an elevated shell: run the command as an administrator, HFDesk never escalates privileges itself", action) } +// scmElevationRequired reports that the service control manager refused an +// operation because the shell of the caller has no rights on the +// registration. +// +// The elevation requirement is the truthful reason: the registration of a +// machine-wide instance belongs to the machine, so a shell that is not an +// administrator shell can neither read it nor change it. The manager never +// escalates privileges itself. +func scmElevationRequired(action string) error { + return fmt.Errorf("service: %s needs an elevated shell: the service control manager refused the operation because the shell has no rights on the registration of %s, run the command as an administrator, HFDesk never escalates privileges itself", action, ServiceName) +} + +// scmRefused reports the reason of a refused operation of the service +// control manager. +// +// A refusal because the shell of the caller has no rights on the +// registration is reported as the elevation requirement it is: the raw +// access denied error of the platform does not tell the user what to do. +func scmRefused(action string, err error) error { + if err == nil { + return nil + } + if errors.Is(err, windows.ERROR_ACCESS_DENIED) { + return scmElevationRequired(action) + } + return err +} + // isElevatedProcess reports whether the process holds an elevated token. func isElevatedProcess() bool { return windows.GetCurrentProcessToken().IsElevated() diff --git a/internal/service/manager_windows_test.go b/internal/service/manager_windows_test.go index b8afc98a..225f9dcf 100644 --- a/internal/service/manager_windows_test.go +++ b/internal/service/manager_windows_test.go @@ -15,6 +15,7 @@ import ( "reflect" "strings" "testing" + "time" "golang.org/x/sys/windows" "golang.org/x/sys/windows/svc" @@ -211,6 +212,256 @@ func TestRequireElevatedNamesTheMissingPrivilege(t *testing.T) { } } +// TestSCMClassifyRegistrationError checks that the outcome of the +// existence check tells the three cases apart: the registration is not +// there, the registration is there, and the caller may not look at it. +func TestSCMClassifyRegistrationError(t *testing.T) { + // A registration that is there. + state, err := scmClassifyRegistrationError(nil) + if state != scmRegistrationPresent { + t.Errorf("scmClassifyRegistrationError(nil) = %v, want %v", state, scmRegistrationPresent) + } + if err != nil { + t.Errorf("scmClassifyRegistrationError(nil) = %v, want no error", err) + } + + // A registration that is not there is not an error: it is the state + // the caller asked about. + state, err = scmClassifyRegistrationError(windows.ERROR_SERVICE_DOES_NOT_EXIST) + if state != scmRegistrationAbsent { + t.Errorf("scmClassifyRegistrationError(ERROR_SERVICE_DOES_NOT_EXIST) = %v, want %v", state, scmRegistrationAbsent) + } + if err != nil { + t.Errorf("scmClassifyRegistrationError(ERROR_SERVICE_DOES_NOT_EXIST) = %v, want no error", err) + } + + // A registration the caller may not look at is not an absent + // registration, and the raw access denied error is not reported: the + // elevation requirement is the truthful reason. + state, err = scmClassifyRegistrationError(windows.ERROR_ACCESS_DENIED) + if state != scmRegistrationUnreadable { + t.Errorf("scmClassifyRegistrationError(ERROR_ACCESS_DENIED) = %v, want %v", state, scmRegistrationUnreadable) + } + if err != nil { + t.Errorf("scmClassifyRegistrationError(ERROR_ACCESS_DENIED) = %v, want no error", err) + } + + // A failure that is not about the registration is reported as it is. + other := errors.New("the service control manager is gone") + state, err = scmClassifyRegistrationError(other) + if state != scmRegistrationUnreadable { + t.Errorf("scmClassifyRegistrationError(%v) = %v, want %v", other, state, scmRegistrationUnreadable) + } + if !errors.Is(err, other) { + t.Errorf("scmClassifyRegistrationError(%v) = %v, want the error itself", other, err) + } +} + +// TestSCMUninstallActionFor checks the decision of the uninstallation: an +// absent registration needs nothing, a registered one has to be removed, +// and a registration the caller may not look at needs an elevated shell. +func TestSCMUninstallActionFor(t *testing.T) { + cases := []struct { + name string + state scmRegistrationState + want scmUninstallAction + }{ + {"absent", scmRegistrationAbsent, scmUninstallNothing}, + {"present", scmRegistrationPresent, scmUninstallRemove}, + {"unreadable", scmRegistrationUnreadable, scmUninstallElevate}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := scmUninstallActionFor(tc.state); got != tc.want { + t.Errorf("scmUninstallActionFor(%v) = %v, want %v", tc.state, got, tc.want) + } + }) + } +} + +// TestSCMUninstallOfAnAbsentRegistrationIsACleanSuccess checks that the +// uninstallation of an instance that is not registered is a clean success +// in every shell. +// +// The decision is driven from both sides: the state of the registration +// and the state of the token of the process are parameters, so the test +// needs neither a registration nor an elevated shell of its own. The real +// command is checked as well, against the service control manager of the +// machine. +func TestSCMUninstallOfAnAbsentRegistrationIsACleanSuccess(t *testing.T) { + manager := &scmManager{scope: ScopeSystem} + ctx := context.Background() + + // The decision does not ask for an elevated shell: a registration + // that is not there is uninstalled already. + for _, elevated := range []bool{false, true} { + if err := manager.uninstall(ctx, scmRegistrationAbsent, elevated); err != nil { + t.Errorf("uninstall(absent, elevated=%v) = %v, want nil", elevated, err) + } + } + + // The real command against the service control manager of the + // machine. + state, err := scmRegistrationStateOf() + if err != nil { + t.Skipf("the service control manager is not available: %v", err) + } + if state != scmRegistrationAbsent { + t.Skipf("%s is registered, the test would remove a real installation", ServiceName) + } + if err := manager.Uninstall(ctx); err != nil { + t.Errorf("Uninstall() = %v, want nil for an absent registration", err) + } +} + +// TestSCMUninstallOfARegisteredInstanceNeedsElevation checks that the +// removal of a registration asks for an elevated shell. +// +// The state of the registration is a parameter of the decision, so the +// test never opens the registration of the instance: the elevation +// requirement is answered before the service control manager is asked for +// anything. +func TestSCMUninstallOfARegisteredInstanceNeedsElevation(t *testing.T) { + manager := &scmManager{scope: ScopeSystem} + err := manager.uninstall(context.Background(), scmRegistrationPresent, false) + if err == nil { + t.Fatal("uninstall(present) = nil, want an error that names the elevated shell") + } + if !strings.Contains(err.Error(), "elevated") { + t.Errorf("uninstall(present) = %v, want an error that names the elevated shell", err) + } + if !strings.Contains(err.Error(), "administrator") { + t.Errorf("uninstall(present) = %v, want an error that tells the user what to do", err) + } +} + +// TestSCMUninstallOfAnUnreadableRegistrationNeedsElevation checks that a +// registration the caller may not look at is reported as the elevation +// requirement it is, and not as the raw access denied error of the +// platform. +func TestSCMUninstallOfAnUnreadableRegistrationNeedsElevation(t *testing.T) { + manager := &scmManager{scope: ScopeSystem} + for _, elevated := range []bool{false, true} { + err := manager.uninstall(context.Background(), scmRegistrationUnreadable, elevated) + if err == nil { + t.Fatalf("uninstall(unreadable, elevated=%v) = nil, want an error", elevated) + } + if !strings.Contains(err.Error(), "elevated") { + t.Errorf("uninstall(unreadable, elevated=%v) = %v, want an error that names the elevated shell", elevated, err) + } + if errors.Is(err, windows.ERROR_ACCESS_DENIED) { + t.Errorf("uninstall(unreadable, elevated=%v) = %v, want the elevation requirement and not the raw access denied error", elevated, err) + } + } +} + +// TestSCMRefusedReportsTheElevationRequirement checks that a refusal of the +// service control manager because the shell has no rights is reported as +// the elevation requirement it is, and not as the raw access denied error +// of the platform. +func TestSCMRefusedReportsTheElevationRequirement(t *testing.T) { + for _, action := range []string{"a start", "a stop"} { + err := scmRefused(action, fmt.Errorf("service: start %s: %w", ServiceName, windows.ERROR_ACCESS_DENIED)) + if err == nil { + t.Fatalf("scmRefused(%q, access denied) = nil, want an error", action) + } + if !strings.Contains(err.Error(), "elevated") { + t.Errorf("scmRefused(%q, access denied) = %v, want an error that names the elevated shell", action, err) + } + if errors.Is(err, windows.ERROR_ACCESS_DENIED) { + t.Errorf("scmRefused(%q, access denied) = %v, want the elevation requirement and not the raw access denied error", action, err) + } + } + + // A failure that is not about the rights of the shell is reported as + // it is. + other := errors.New("the service control manager is gone") + if got := scmRefused("a start", other); !errors.Is(got, other) { + t.Errorf("scmRefused(%q, %v) = %v, want the error itself", "a start", other, got) + } + if got := scmRefused("a start", nil); got != nil { + t.Errorf("scmRefused(%q, nil) = %v, want nil", "a start", got) + } +} + +// TestSCMRegistrationStateOfFindsAnAbsentRegistration checks the existence +// check against the service control manager of the machine: a name that +// cannot be registered is reported as absent. +// +// The check reads the registration with the right to query its state only, +// so it works in a normal shell as well. +func TestSCMRegistrationStateOfFindsAnAbsentRegistration(t *testing.T) { + name := fmt.Sprintf("HfdeskTestAbsentRegistration%d", time.Now().UnixNano()) + state, err := scmRegistrationStateOfName(name) + if err != nil { + t.Skipf("the service control manager is not available: %v", err) + } + if state == scmRegistrationUnreadable { + t.Skip("the service control manager cannot be read from this shell") + } + if state != scmRegistrationAbsent { + t.Errorf("scmRegistrationStateOfName(%q) = %v, want %v", name, state, scmRegistrationAbsent) + } +} + +// TestSCMRegistrationStateOfFindsARegisteredService checks the existence +// check against the service control manager of the machine: a service of +// the operating system is reported as registered. +// +// The test needs a service that is registered on the machine. The +// candidates are the services every installation of the operating system +// carries, so the test is skipped when the machine carries none of them. +func TestSCMRegistrationStateOfFindsARegisteredService(t *testing.T) { + for _, candidate := range []string{"EventLog", "RpcSs", "Dhcp", "W32Time", "NlaSvc", "Schedule", "Power"} { + state, err := scmRegistrationStateOfName(candidate) + if err != nil { + t.Skipf("the service control manager is not available: %v", err) + } + switch state { + case scmRegistrationPresent: + return + case scmRegistrationAbsent: + continue + default: + t.Skipf("the registration of %s cannot be read from this shell", candidate) + } + } + t.Skip("the machine carries none of the services of the operating system") +} + +// TestSCMStartAndStopOfAnAbsentRegistration checks the lifecycle commands +// against an instance that is not registered: the stop is a clean success +// and the start reports that the instance is not installed. +// +// The test is skipped when the instance is registered, so it never touches +// a real installation. +func TestSCMStartAndStopOfAnAbsentRegistration(t *testing.T) { + state, err := scmRegistrationStateOf() + if err != nil { + t.Skipf("the service control manager is not available: %v", err) + } + if state != scmRegistrationAbsent { + t.Skipf("%s is registered, the test would touch a real installation", ServiceName) + } + + manager := &scmManager{scope: ScopeSystem} + ctx := context.Background() + + // An instance that does not run is stopped already. + if err := manager.Stop(ctx); err != nil { + t.Errorf("Stop() = %v, want nil for an instance that is not registered", err) + } + // An instance that is not registered cannot be started, and the + // reason is reported in every shell. + err = manager.Start(ctx) + if err == nil { + t.Fatal("Start() = nil, want an error for an instance that is not registered") + } + if !strings.Contains(err.Error(), "not installed") { + t.Errorf("Start() = %v, want an error that names the missing registration", err) + } +} + // TestSCMLifecycle drives the registration of a service against the real // service control manager. // From 253b0302ccade713b912c6d0e2c0dfef9330daed Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 22 Oct 2026 00:18:43 +1100 Subject: [PATCH 16/58] fix(cmd): register a stable executable path and the service account - Register the path the binary was invoked with when it is a usable path, so a stable name is registered instead of the version-stamped file it points at, and add an --executable option to name the path explicitly. - Follow the upgrade warning from the path that is registered instead of from the resolved target. - Add the --service-user option of a machine-wide installation, and report the account of the instance in the install summary. Co-Authored-By: opencode --- cmd/hfdesk/service.go | 177 ++++++++++++++-- cmd/hfdesk/service_test.go | 402 +++++++++++++++++++++++++++++++++++++ 2 files changed, 559 insertions(+), 20 deletions(-) diff --git a/cmd/hfdesk/service.go b/cmd/hfdesk/service.go index f117be78..f998cf3a 100644 --- a/cmd/hfdesk/service.go +++ b/cmd/hfdesk/service.go @@ -12,6 +12,7 @@ import ( "net" "os" "os/signal" + "os/user" "path/filepath" "regexp" "runtime" @@ -53,6 +54,11 @@ const ( // root does not use them, so naming them would send the reader to the // wrong directory. cacheRootNotRecorded = "not recorded (unknown)" + + // windowsServiceAccount is the identity of a machine-wide instance on + // Windows. The service control manager registers the built-in + // LocalService account, which has no name of its own. + windowsServiceAccount = "LocalService" ) // serviceSubcommands lists the subcommands of the family in the order the @@ -176,6 +182,13 @@ Scope: Options of the "install" subcommand: --listen HOST:PORT bind address of the instance (default %s) + --service-user ACCOUNT + account a machine-wide instance runs as + (the machine-wide scope only; default: the service + account of the platform) + --executable PATH + absolute path of the binary to register + (default: the invoked binary) Exit codes: 0 the operation succeeded @@ -301,12 +314,30 @@ func validateListen(listen string) (string, error) { return net.JoinHostPort(host, strconv.Itoa(port)), nil } -// resolveExecutable returns the absolute path of the running binary. +// resolveExecutable returns the absolute path of the binary the +// registration points at. +// +// An explicit path wins: the user names the stable path of the +// installation, which may differ from the binary they happen to run. +// +// Without an explicit path the invocation path is preferred: the path the +// user typed is the name the installation is known by, and a symbolic link +// on that path keeps its name in the registration. The invocation path is +// only usable when it names a file: a bare name ("hfdesk") comes from the +// search path of the shell and must not be resolved against the working +// directory of the install command. // -// The registration points at that exact path, so it must be absolute: a -// relative path would be resolved against the working directory of the -// supervisor. -func resolveExecutable() (string, error) { +// The resolved path of the running binary (os.Executable) is the fallback. +// It is always a real file, but it is the target of a symbolic link, so a +// registration that uses it points at a version-stamped release artifact +// instead of at the stable name of the installation. +func resolveExecutable(explicit, invocation string) (string, error) { + if explicit != "" { + return validateExecutablePath(explicit) + } + if path, ok := invocationExecutable(invocation); ok { + return path, nil + } exe, err := os.Executable() if err != nil { return "", fmt.Errorf("the path of the running binary is unknown: %w", err) @@ -318,16 +349,72 @@ func resolveExecutable() (string, error) { return abs, nil } +// invocationExecutable returns the absolute path of the invoked binary when +// the invocation path is usable. +// +// The invocation path is usable when it names a path (it contains a +// separator), so a bare name from the search path of the shell is never +// resolved against the working directory, and when it is an existing +// regular file. +func invocationExecutable(invocation string) (string, bool) { + if invocation == "" || !hasPathSeparator(invocation) { + return "", false + } + abs, err := filepath.Abs(invocation) + if err != nil { + return "", false + } + info, err := os.Stat(abs) + if err != nil || !info.Mode().IsRegular() { + return "", false + } + return abs, true +} + +// hasPathSeparator reports whether the path names a file instead of a +// command of the search path. +// +// Both separators are accepted, so a path written with the separator of the +// other platform is not mistaken for a bare name. +func hasPathSeparator(path string) bool { + return strings.ContainsRune(path, '/') || strings.ContainsRune(path, filepath.Separator) +} + +// validateExecutablePath checks that the path can be registered as the +// program of the instance and returns it. +// +// The path must be absolute (a supervised process must not resolve a +// relative path against the working directory of the supervisor), it must +// exist, it must be a regular file and it must be executable. +func validateExecutablePath(path string) (string, error) { + if !filepath.IsAbs(path) { + return "", fmt.Errorf("the executable %q must be an absolute path", path) + } + info, err := os.Stat(path) + if err != nil { + return "", fmt.Errorf("the executable %q is not usable: %w", path, err) + } + if !info.Mode().IsRegular() { + return "", fmt.Errorf("the executable %q is not a regular file", path) + } + if info.Mode().Perm()&0o111 == 0 { + return "", fmt.Errorf("the executable %q is not executable", path) + } + return path, nil +} + // installFlags holds the flags of the "service install" command. type installFlags struct { scopeFlags - listen string - config string - stateDir string - logDir string - cacheDir string - hubDir string - localDir string + listen string + config string + stateDir string + logDir string + cacheDir string + hubDir string + localDir string + serviceUser string + executable string } // buildInstallOptions assembles the options of an installation from the @@ -348,7 +435,15 @@ type installFlags struct { // into a directory its own cache root does not contain. A hub directory that // was named explicitly is honoured as given: it separates the two locations // on purpose, the same independence HF_HOME and HF_HUB_CACHE have. +// +// The service account belongs to the machine-wide scope only: a user scope +// instance runs as the user itself, so naming an account there is a +// mistake of the command line and not a default. func buildInstallOptions(scope service.Scope, flags installFlags, executable string) (service.InstallOptions, error) { + if flags.serviceUser != "" && scope == service.ScopeUser { + return service.InstallOptions{}, errors.New("--service-user applies to a machine-wide installation only: a user scope instance runs as the user itself, drop the option or use --system") + } + paths, err := service.DefaultPaths(scope) if err != nil { return service.InstallOptions{}, err @@ -380,13 +475,14 @@ func buildInstallOptions(scope service.Scope, flags installFlags, executable str } return service.InstallOptions{ - Scope: scope, - Executable: executable, - Listen: listen, - Paths: paths, - CacheDir: cacheRoot, - HubDir: hubDir, - LocalDir: flags.localDir, + Scope: scope, + Executable: executable, + Listen: listen, + Paths: paths, + CacheDir: cacheRoot, + HubDir: hubDir, + LocalDir: flags.localDir, + ServiceUser: flags.serviceUser, }, nil } @@ -409,6 +505,8 @@ func serviceInstall(args []string, stdout io.Writer) error { fs.StringVar(&flags.cacheDir, "cache-dir", "", "absolute path of the Hugging Face cache root") fs.StringVar(&flags.hubDir, "hub-dir", "", "absolute path of the Hugging Face hub cache directory") fs.StringVar(&flags.localDir, "local-dir", "", "absolute path of the flat output directory") + fs.StringVar(&flags.serviceUser, "service-user", "", "account the machine-wide instance runs as (the machine-wide scope only)") + fs.StringVar(&flags.executable, "executable", "", "absolute path of the binary to register (default: the invoked binary)") if err := fs.Parse(args); err != nil { if errors.Is(err, flag.ErrHelp) { @@ -441,7 +539,9 @@ func serviceInstall(args []string, stdout io.Writer) error { } flags.listen = listen - executable, err := resolveExecutable() + // The registration points at the path the user knows the installation + // by: the invoked binary, or the path the user named explicitly. + executable, err := resolveExecutable(flags.executable, os.Args[0]) if err != nil { return err } @@ -483,6 +583,7 @@ func writeInstallSummary(w io.Writer, ctx context.Context, mgr service.Manager, writeField(w, "Service", service.ServiceName) writeField(w, "Scope", string(scope)) + writeField(w, "Account", installAccount(scope, opts.ServiceUser)) writeField(w, "Registration", registrationPath(status)) writeField(w, "Executable", opts.Executable) writeField(w, "Listen", opts.Listen) @@ -506,6 +607,42 @@ func writeInstallSummary(w io.Writer, ctx context.Context, mgr service.Manager, writeInstallState(w, status, statusErr) } +// installAccount returns the account the registered instance runs as. +// +// A user scope instance runs as the user itself: it keeps the identity of +// the session and the access to the own cache of the user. +// +// A machine-wide instance runs as the dedicated service account of the +// platform: the account the user named, or the default of the platform. +// Windows registers the built-in LocalService account, which has no name +// of its own. +func installAccount(scope service.Scope, serviceUser string) string { + switch { + case scope == service.ScopeUser: + return invokingUser() + case serviceUser != "": + return serviceUser + case isWindows(): + return windowsServiceAccount + default: + return service.DefaultServiceUser() + } +} + +// invokingUser returns the name of the user that runs the command. +// +// The name is empty when it cannot be determined: a summary that names no +// account says "unknown" instead of guessing one. +func invokingUser() string { + if u, err := user.Current(); err == nil && u.Username != "" { + return u.Username + } + if name := os.Getenv("USER"); name != "" { + return name + } + return "" +} + // effectiveCacheDirs returns the cache root and the hub directory the // instance uses. // diff --git a/cmd/hfdesk/service_test.go b/cmd/hfdesk/service_test.go index 4e61ca13..6f961ffe 100644 --- a/cmd/hfdesk/service_test.go +++ b/cmd/hfdesk/service_test.go @@ -154,6 +154,148 @@ func TestServiceLifecycleSubcommandsRejectTheListenOption(t *testing.T) { } } +// TestServiceLifecycleSubcommandsRejectTheInstallOnlyOptions checks that +// the options of the install subcommand are options of that subcommand +// only. +// +// The account of a machine-wide instance and the binary the registration +// points at belong to the installation: a lifecycle command has nothing to +// do with them and must not accept them. +func TestServiceLifecycleSubcommandsRejectTheInstallOnlyOptions(t *testing.T) { + for _, sub := range []string{"uninstall", "start", "stop", "restart", "status", "run"} { + for _, option := range [][]string{ + {"--service-user", "hfdesk"}, + {"--executable", "/usr/local/bin/hfdesk"}, + } { + var stdout, stderr bytes.Buffer + code := runServiceCommand(append([]string{sub}, option...), &stdout, &stderr) + if code == 0 { + t.Errorf("the %q subcommand must reject %s, got exit code %d", sub, option[0], code) + } + // The parser names the option without its leading dashes. + name := strings.TrimPrefix(option[0], "--") + if !strings.Contains(stderr.String(), name) { + t.Errorf("the error of the %q subcommand does not name the rejected option %s:\n%s", sub, option[0], stderr.String()) + } + } + } +} + +// TestServiceUserIsRejectedForTheUserScope checks that the account of a +// machine-wide instance cannot be named for a user scope instance. +// +// A user scope instance runs as the user itself, so an account name would +// be a promise the installation cannot keep. +func TestServiceUserIsRejectedForTheUserScope(t *testing.T) { + if isWindows() { + t.Skip("the user scope does not exist on windows") + } + + var stdout, stderr bytes.Buffer + code := runServiceCommand([]string{"install", "--user", "--service-user", "hfdesk"}, &stdout, &stderr) + if code == 0 { + t.Fatalf("the user scope must reject --service-user, got exit code %d", code) + } + errText := stderr.String() + if !strings.Contains(errText, "--service-user") { + t.Errorf("the error does not name the rejected option:\n%s", errText) + } + if !strings.Contains(errText, "user") { + t.Errorf("the error does not explain the scope of the option:\n%s", errText) + } +} + +// TestBuildInstallOptionsCarriesTheServiceUser checks that the account +// reaches the options of the installation. +// +// The account is empty when the command line does not name one: the default +// of the platform is resolved by the platform manager, which knows the +// accounts of the machine. +func TestBuildInstallOptionsCarriesTheServiceUser(t *testing.T) { + const executable = "/usr/local/bin/hfdesk" + + opts, err := buildInstallOptions(service.ScopeSystem, installFlags{serviceUser: "hfdesk-svc"}, executable) + if err != nil { + t.Fatalf("build the install options: %v", err) + } + if opts.ServiceUser != "hfdesk-svc" { + t.Errorf("the service account is %q, want %q", opts.ServiceUser, "hfdesk-svc") + } + + opts, err = buildInstallOptions(service.ScopeSystem, installFlags{}, executable) + if err != nil { + t.Fatalf("build the install options: %v", err) + } + if opts.ServiceUser != "" { + t.Errorf("the service account is %q, want it to be unset", opts.ServiceUser) + } +} + +// TestBuildInstallOptionsRejectsTheServiceUserForTheUserScope checks that +// a user scope instance never carries a service account. +func TestBuildInstallOptionsRejectsTheServiceUserForTheUserScope(t *testing.T) { + if isWindows() { + t.Skip("the user scope does not exist on windows") + } + if _, err := buildInstallOptions(service.ScopeUser, installFlags{serviceUser: "hfdesk"}, "/usr/local/bin/hfdesk"); err == nil { + t.Error("the user scope must reject a service account") + } +} + +// TestInstallSummaryReportsTheAccountOfTheInstance checks that the summary +// names the account the instance runs as. +func TestInstallSummaryReportsTheAccountOfTheInstance(t *testing.T) { + opts := service.InstallOptions{ + Scope: service.ScopeSystem, + Executable: "/usr/local/bin/hfdesk", + Listen: "127.0.0.1:8080", + Paths: runtimepaths.Paths{ + ConfigFile: "/etc/hfdesk/hfdesk.json", + StateDir: "/var/lib/hfdesk", + LogDir: "/var/log/hfdesk", + }, + ServiceUser: "hfdesk-svc", + } + + var buf bytes.Buffer + mgr := fakeManager{status: service.Status{State: service.StateRunning}} + writeInstallSummary(&buf, context.Background(), mgr, service.ScopeSystem, opts) + + out := buf.String() + if got := summaryField(out, "Account"); got != "hfdesk-svc" { + t.Errorf("the account of the summary is %q, want %q:\n%s", got, "hfdesk-svc", out) + } +} + +// TestInstallSummaryReportsTheDefaultAccountOfThePlatform checks that the +// summary names the account of the platform when the command line does not +// name one. +func TestInstallSummaryReportsTheDefaultAccountOfThePlatform(t *testing.T) { + opts := service.InstallOptions{ + Scope: service.ScopeSystem, + Executable: "/usr/local/bin/hfdesk", + Listen: "127.0.0.1:8080", + Paths: runtimepaths.Paths{ + ConfigFile: "/etc/hfdesk/hfdesk.json", + StateDir: "/var/lib/hfdesk", + LogDir: "/var/log/hfdesk", + }, + } + + var buf bytes.Buffer + mgr := fakeManager{status: service.Status{State: service.StateRunning}} + writeInstallSummary(&buf, context.Background(), mgr, service.ScopeSystem, opts) + + out := buf.String() + got := summaryField(out, "Account") + if got == "" { + t.Fatalf("the summary does not name an account:\n%s", out) + } + if want := service.DefaultServiceUser(); want != "" && got != want { + t.Errorf("the account of the summary is %q, want the default account of the platform %q:\n%s", got, want, out) + } +} + func TestServiceHelpSucceeds(t *testing.T) { var stdout, stderr bytes.Buffer code := runServiceCommand([]string{"--help"}, &stdout, &stderr) @@ -819,6 +961,266 @@ func TestUpgradeWarning(t *testing.T) { } } +// TestUpgradeWarningDescribesTheRegisteredPath checks that the warning is +// about the path the registration points at. +// +// The registration keeps the name the installation is known by, so a stable +// name stays quiet even when the file behind it is a version-stamped +// release artifact: the next release replaces the file, the registration +// does not change. +func TestUpgradeWarningDescribesTheRegisteredPath(t *testing.T) { + if got := upgradeWarning("/usr/local/bin/hfdesk"); got != "" { + t.Errorf("a stable registered path must not produce a warning, got:\n%s", got) + } + if got := upgradeWarning("/opt/hfdesk-1.2.3/hfdesk"); got == "" { + t.Error("a version-stamped registered path must produce a warning") + } +} + +// TestServiceInstallRejectsAnUnusableExecutable checks that the +// installation is refused when the named binary cannot be registered. +// +// The registration points at that exact path, so a path that is not a +// program would break the instance at the first start. +func TestServiceInstallRejectsAnUnusableExecutable(t *testing.T) { + dir := t.TempDir() + plain := filepath.Join(dir, "notes.txt") + if err := os.WriteFile(plain, []byte("notes\n"), 0o644); err != nil { + t.Fatal(err) + } + + for _, tc := range []struct { + name string + path string + want string + }{ + {"a relative path", "bin/hfdesk", "absolute"}, + {"a missing file", filepath.Join(dir, "missing"), "not usable"}, + {"a directory", dir, "regular file"}, + {"a file that is not executable", plain, "not executable"}, + } { + t.Run(tc.name, func(t *testing.T) { + var stdout, stderr bytes.Buffer + code := runServiceCommand([]string{"install", "--system", "--executable", tc.path}, &stdout, &stderr) + if code == 0 { + t.Fatalf("the installation must be refused, got exit code %d", code) + } + if !strings.Contains(stderr.String(), tc.want) { + t.Errorf("the error does not report %q:\n%s", tc.want, stderr.String()) + } + if stdout.Len() != 0 { + t.Errorf("a refused installation must not report a registration:\n%s", stdout.String()) + } + }) + } +} + +// TestResolveExecutableKeepsTheStableNameOfTheInvocation checks that the +// registration points at the name the user invoked. +// +// os.Executable() resolves a symbolic link and returns its target, so a +// registration built from it points at the version-stamped release artifact +// instead of at the stable name of the installation. The invocation path +// keeps the name. +func TestResolveExecutableKeepsTheStableNameOfTheInvocation(t *testing.T) { + root := t.TempDir() + release := filepath.Join(root, "hfdesk-1.2.3", "hfdesk") + if err := os.MkdirAll(filepath.Dir(release), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(release, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + stable := filepath.Join(root, "bin", "hfdesk") + if err := os.MkdirAll(filepath.Dir(stable), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(release, stable); err != nil { + t.Fatal(err) + } + + got, err := resolveExecutable("", stable) + if err != nil { + t.Fatalf("resolveExecutable() = %v, want nil", err) + } + if got != stable { + t.Errorf("the registered executable is %q, want the invoked path %q", got, stable) + } +} + +// TestUpgradeWarningStaysQuietForAStableName checks the whole chain: a +// stable name that points at a version-stamped file does not produce a +// warning. +// +// The registration keeps the name the installation is known by, and the next +// release replaces the file behind that name. +func TestUpgradeWarningStaysQuietForAStableName(t *testing.T) { + root := treeOutsideTempDir(t) + release := filepath.Join(root, "hfdesk-1.2.3", "hfdesk") + if err := os.MkdirAll(filepath.Dir(release), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(release, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + stable := filepath.Join(root, "bin", "hfdesk") + if err := os.MkdirAll(filepath.Dir(stable), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(release, stable); err != nil { + t.Fatal(err) + } + + registered, err := resolveExecutable("", stable) + if err != nil { + t.Fatalf("resolveExecutable() = %v, want nil", err) + } + if registered != stable { + t.Fatalf("the registered executable is %q, want the invoked path %q", registered, stable) + } + if got := upgradeWarning(registered); got != "" { + t.Errorf("a stable registered path must not produce a warning, got:\n%s", got) + } + // The version-stamped path itself is a warning: it does not survive + // the next upgrade. + if got := upgradeWarning(release); got == "" { + t.Error("a version-stamped registered path must produce a warning") + } +} + +// treeOutsideTempDir returns a directory that is not inside the temporary +// directory of the process. +// +// The upgrade warning treats a path inside the temporary directory as a path +// that does not survive an upgrade, so a test of the warning needs a tree +// outside of it. +func treeOutsideTempDir(t *testing.T) string { + t.Helper() + base := t.TempDir() + // The temporary directory of the process moves next to the tree, so + // the tree is not inside it. + moved := filepath.Join(base, "tmp") + t.Setenv("TMPDIR", moved) + t.Setenv("TMP", moved) + t.Setenv("TEMP", moved) + return filepath.Join(base, "tree") +} + +// TestResolveExecutablePrefersTheInvocationPath checks that the path the +// user typed is the one the registration points at. +func TestResolveExecutablePrefersTheInvocationPath(t *testing.T) { + root := t.TempDir() + bin := filepath.Join(root, "hfdesk") + if err := os.WriteFile(bin, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + + got, err := resolveExecutable("", bin) + if err != nil { + t.Fatalf("resolveExecutable() = %v, want nil", err) + } + if got != bin { + t.Errorf("the registered executable is %q, want the invoked path %q", got, bin) + } +} + +// TestResolveExecutableFallsBackToTheRunningBinary checks that a bare name +// from the search path of the shell is never resolved against the working +// directory. +// +// The name of a command of the search path says nothing about the file the +// user wants to register: it is resolved against the working directory of +// the install command, which is not where the binary lives. +func TestResolveExecutableFallsBackToTheRunningBinary(t *testing.T) { + root := t.TempDir() + // A file with the name of the command in the working directory. + if err := os.WriteFile(filepath.Join(root, "hfdesk"), []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + previous, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(root); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(previous) }) + + got, err := resolveExecutable("", "hfdesk") + if err != nil { + t.Fatalf("resolveExecutable() = %v, want nil", err) + } + if got == filepath.Join(root, "hfdesk") { + t.Errorf("the registered executable is %q, want the path of the running binary", got) + } + + running, err := os.Executable() + if err != nil { + t.Fatalf("the path of the running binary: %v", err) + } + want, err := filepath.Abs(running) + if err != nil { + t.Fatalf("the absolute path of the running binary: %v", err) + } + if got != want { + t.Errorf("the registered executable is %q, want the path of the running binary %q", got, want) + } +} + +// TestResolveExecutableRejectsAnUnusableExplicitPath checks that an +// explicit path has to be a program that can be started. +func TestResolveExecutableRejectsAnUnusableExplicitPath(t *testing.T) { + root := t.TempDir() + program := filepath.Join(root, "hfdesk") + if err := os.WriteFile(program, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + plain := filepath.Join(root, "notes.txt") + if err := os.WriteFile(plain, []byte("notes\n"), 0o644); err != nil { + t.Fatal(err) + } + + cases := []struct { + name string + path string + want string + }{ + {"a relative path", "bin/hfdesk", "absolute"}, + {"a missing file", filepath.Join(root, "missing"), "not usable"}, + {"a directory", root, "regular file"}, + {"a file that is not executable", plain, "not executable"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := resolveExecutable(tc.path, "") + if err == nil { + t.Fatalf("resolveExecutable(%q) = %q, want an error", tc.path, got) + } + if !strings.Contains(err.Error(), tc.want) { + t.Errorf("the error of resolveExecutable(%q) = %v, want it to mention %q", tc.path, err, tc.want) + } + }) + } +} + +// TestResolveExecutableUsesTheExplicitPath checks that an explicit path +// wins over the invoked binary. +func TestResolveExecutableUsesTheExplicitPath(t *testing.T) { + root := t.TempDir() + program := filepath.Join(root, "hfdesk") + if err := os.WriteFile(program, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + + got, err := resolveExecutable(program, filepath.Join(root, "other")) + if err != nil { + t.Fatalf("resolveExecutable() = %v, want nil", err) + } + if got != program { + t.Errorf("the registered executable is %q, want the explicit path %q", got, program) + } +} + func TestRunPathReturnsAfterTheContextIsCancelled(t *testing.T) { dir := t.TempDir() stateDir := filepath.Join(dir, "state") From 756250d3e3f78ec28c2927bf44ed92175f5a497d Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 22 Oct 2026 00:18:43 +1100 Subject: [PATCH 17/58] tests: cover the graceful shutdown of a service instance - Drive the service host through a complete lifecycle: it serves, it shuts down on the cancellation of its context without an error, and it persists the job list into the state directory of the instance. Co-Authored-By: opencode --- internal/service/process_test.go | 160 +++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) diff --git a/internal/service/process_test.go b/internal/service/process_test.go index e9202caa..abf9b6bb 100644 --- a/internal/service/process_test.go +++ b/internal/service/process_test.go @@ -5,7 +5,14 @@ package service import ( "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "os" "path/filepath" + "strconv" "strings" "testing" "time" @@ -63,3 +70,156 @@ func TestRunProcessRejectsAnInvalidListenAddress(t *testing.T) { // The registered process never opens a browser and never creates a tray // icon. The marker guard of TestServiceLayerNeverLaunchesAGUI covers every // file of the package, so it is not repeated here. + +// TestServiceHostShutdownPersistsTheJobListInTheInstanceStateDir drives the +// headless service host through a complete lifecycle in a service context: +// the instance serves on loopback, the context is cancelled and the host +// returns. +// +// A cancelled context is the normal shutdown path of a supervised instance, +// so the host has to return without an error. The persistence path of the +// instance is exercised as well: the job list is committed to the state +// directory of the instance itself and not to a directory of the ambient +// environment. +// +// The helpers of the test are local on purpose: the file of the host tests +// belongs to another package, so this test does not depend on it. +func TestServiceHostShutdownPersistsTheJobListInTheInstanceStateDir(t *testing.T) { + root := t.TempDir() + model := runtimepaths.Paths{ + ConfigFile: filepath.Join(root, "etc", "hfdesk.json"), + StateDir: filepath.Join(root, "var"), + LogDir: filepath.Join(root, "var", "logs"), + } + t.Cleanup(runtimepaths.Reset) + + // The cache identity and the credential of the instance are pinned + // inside the temporary directory of the test, and the environment of + // the test is restored when it ends. + t.Setenv("HF_HOME", "") + t.Setenv("HF_HUB_CACHE", "") + t.Setenv("HF_TOKEN", "") + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + client := &http.Client{Timeout: 2 * time.Second} + + // healthOK reports whether the instance answers the health endpoint. + healthOK := func(url string) error { + resp, err := client.Get(url) + if err != nil { + return err + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("status %d: %s", resp.StatusCode, body) + } + if !strings.Contains(string(body), `"status":"ok"`) { + return fmt.Errorf("unexpected health payload: %s", body) + } + return nil + } + + // freeLoopbackPort returns a loopback port that was free a moment ago. + freeLoopbackPort := func() int { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("reserve a port: %v", err) + } + defer listener.Close() + return listener.Addr().(*net.TCPAddr).Port + } + + var ( + runErr error + served bool + shutdown time.Duration + lastHealthErr error + ) + +attempt: + for attempt := 0; attempt < 3; attempt++ { + addr := "127.0.0.1:" + strconv.Itoa(freeLoopbackPort()) + url := "http://" + addr + "/api/health" + + attemptCtx, attemptCancel := context.WithCancel(ctx) + done := make(chan error, 1) + go func() { + done <- Run(attemptCtx, RunOptions{ + Paths: model, + Listen: addr, + CacheDir: filepath.Join(root, "cache"), + HubDir: filepath.Join(root, "cache", "hub"), + }) + }() + + // The instance has to serve before the shutdown is requested: a + // host that never came up must not report a clean shutdown. + for deadline := time.Now().Add(10 * time.Second); time.Now().Before(deadline); { + err := healthOK(url) + if err == nil { + served = true + break + } + lastHealthErr = err + select { + case hostErr := <-done: + // The host returned before it served. + attemptCancel() + runErr = hostErr + if hostErr != nil && strings.Contains(hostErr.Error(), "address already in use") { + // The port was taken between the reservation and + // the bind: retry with a new one. + continue attempt + } + t.Fatalf("the host returned before it served: %v (last health error: %v)", hostErr, lastHealthErr) + case <-time.After(20 * time.Millisecond): + } + } + + if !served { + attemptCancel() + runErr = <-done + break + } + + // The shutdown of a supervised instance is a cancelled context. + start := time.Now() + attemptCancel() + runErr = <-done + shutdown = time.Since(start) + break + } + + if !served { + t.Fatalf("the instance never served: %v (last health error: %v)", runErr, lastHealthErr) + } + if runErr != nil { + t.Fatalf("Run() = %v, want nil", runErr) + } + if shutdown > 30*time.Second { + t.Errorf("the host returned after %s, want a prompt shutdown", shutdown) + } + + // The persistence path of the instance ran: the job list is committed + // to the state directory of the instance itself. + stateFile := model.JobsStateFile() + data, err := os.ReadFile(stateFile) + if err != nil { + t.Fatalf("read %s: %v", stateFile, err) + } + var persisted struct { + Jobs []json.RawMessage `json:"jobs"` + } + if err := json.Unmarshal(data, &persisted); err != nil { + t.Fatalf("parse %s: %v", stateFile, err) + } + if persisted.Jobs == nil { + t.Errorf("%s = %s, want a job list document", stateFile, data) + } +} From 54c7588b7f52428943b5c2aba60b88a7e4487220 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 22 Oct 2026 00:18:43 +1100 Subject: [PATCH 18/58] docs: document the dedicated service account - Describe the identity of a machine-wide instance per platform, the account that has to exist, the directories that are granted to it and the guidance for a named cache directory. - Correct the account creation commands, the command surface, the registered executable path, the cache layout and the directory permissions so the document matches the implementation. Co-Authored-By: opencode --- docs/SERVICE.md | 140 ++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 125 insertions(+), 15 deletions(-) diff --git a/docs/SERVICE.md b/docs/SERVICE.md index f363227e..493291e9 100644 --- a/docs/SERVICE.md +++ b/docs/SERVICE.md @@ -37,6 +37,9 @@ The dashboard is then served on . Control Manager on Windows. - Administrator rights for a machine-wide (`--system`) installation. HFDesk never elevates itself. +- The **service account** of a machine-wide installation, already created (see + [The identity of a machine-wide instance](#the-identity-of-a-machine-wide-instance)). + A user-scope installation needs no account: it runs as the user itself. - A writable state root for the chosen scope (see [Where the state lives](#where-the-state-lives)). @@ -48,7 +51,7 @@ change where an interactive run reads its config from. ## Command surface ``` -hfdesk service install [--user|--system] [--listen HOST:PORT] [--config PATH] [--state-dir DIR] [--log-dir DIR] [--cache-dir DIR] [--hub-dir DIR] [--local-dir DIR] +hfdesk service install [--user|--system] [--listen HOST:PORT] [--config PATH] [--state-dir DIR] [--log-dir DIR] [--cache-dir DIR] [--hub-dir DIR] [--local-dir DIR] [--service-user ACCOUNT] [--executable PATH] hfdesk service uninstall [--user|--system] hfdesk service start [--user|--system] hfdesk service stop [--user|--system] @@ -74,11 +77,31 @@ never opens a browser. | `--cache-dir DIR` | Absolute path of the Hugging Face cache root. Without `--hub-dir`, the hub directory follows it: `/hub`. | | `--hub-dir DIR` | Absolute path of the Hugging Face hub cache directory. Given explicitly, it separates the hub from the cache root on purpose. | | `--local-dir DIR` | Absolute path of the flat output directory. | +| `--service-user ACCOUNT` | Account a **machine-wide** instance runs as. Default: the service account of the platform. Rejected for the user scope. | +| `--executable PATH` | Absolute path of the binary the registration points at. Default: the binary that is invoked. | The path flags override the default locations of the scope. Every path of an installation must be absolute; a relative path is rejected instead of being resolved against the working directory of the supervisor. +`--service-user` and `--executable` belong to `install` alone: they describe +the registration that is written, and a lifecycle command has no registration +to write them into. `--listen` is a flag of `install` and of the internal +`run` mode, because both of them bind the address of the instance. + +`--executable` names the program of the registration when it differs from the +binary that is run. The path must be absolute, must exist, must be a regular +file and must be executable. Without it the **invoked** path is used, so a +symbolic link keeps its name in the registration (see +[Upgrades](#upgrades)); a bare name that comes from the search path of the +shell names no file, so the resolved path of the running binary is registered +instead. + +`--service-user` names the account of a machine-wide instance (see +[The identity of a machine-wide instance](#the-identity-of-a-machine-wide-instance)). +It applies to the machine-wide scope only: a user-scope instance runs as the +user itself, so the option is rejected there instead of being ignored. + The cache root and the hub directory are **one layout**: the cache root holds the friendly views (`models/`, `datasets/`), the hub directory holds the blobs. `--cache-dir` alone therefore moves the whole pair — the registered @@ -98,6 +121,17 @@ must be usable, and the runtime paths must be absolute. A failed installation restores the previous registration instead of leaving a half-written one behind. +The installation creates the directories of the instance — the config +directory, the state directory and the log directory — with owner-only +permissions (`0700`). A directory that **already exists** keeps its +permissions **and its ownership**: the installation never tightens a +directory the user named (for example with `--state-dir`), because that +directory may be shared with something else, and it never takes a directory +over from the owner it already has. A machine-wide instance grants the +service account only the directories it created itself; a directory that +already existed has to be writable by that account already (see +[The identity of a machine-wide instance](#the-identity-of-a-machine-wide-instance)). + ### Lifecycle flags `uninstall`, `start`, `stop`, `restart` and `status` take the scope selector @@ -211,10 +245,16 @@ with bounded rotation: The active file is rotated before a write would exceed 2 MiB, and three rotated generations are kept, so the on-disk size of the log stays bounded. -The log directory and the log files are owner-only (`0700` / `0600` on Unix). +The log directory and the log files are owner-only (`0700` / `0600` on Unix): +the running instance tightens its own log directory and its own log files to +those modes. The standard output and the standard error of the process also reach the -supervisor: the systemd journal on Linux, the launchd log files on macOS. +supervisor: the systemd journal on Linux. On macOS the property list sets +**no** `StandardOutPath` and **no** `StandardErrorPath`: launchd does not +rotate the files it owns, so they would grow without bound. launchd +therefore discards them, and the bounded `hfdesk.log` inside the log +directory of the instance is the real log sink on macOS. ## Lifecycle commands @@ -302,6 +342,11 @@ hfdesk service install # re-registers the current binary property list takes effect. - The property list holds the label, the program arguments and the environment of the instance — no credential. +- The **macOS CI job** is narrowed to the service package: the launchd + manager and the property list rendering are darwin-only code, so the job + runs `go test ./internal/service` (plus the build and the vet of the whole + module) on a macOS runner. The full macOS suite has separate pre-existing + macOS-compatibility gaps; the job stays narrowed until they are resolved. #### Future app-bundle packaging @@ -345,27 +390,76 @@ same service at the same time. ### The identity of a machine-wide instance -A machine-wide (`--system`) instance runs as a deliberate, documented identity -of its platform. It never inherits the home directory of the user who runs the -install command, and it never inherits the personal profile of root. +A machine-wide (`--system`) instance runs as a **dedicated, stable and +unprivileged service account** of its platform. It never runs as root, it +never inherits the home directory of the user who runs the install command, +and it never inherits the personal profile of root. -| Platform | The instance runs as | Why | +| Platform | The instance runs as | The account | |---|---|---| -| Linux (systemd, `--system`) | **root** — the unit sets no `User=`, so the instance runs with the identity of the system manager itself | The installer creates no dedicated account. The unit owns the machine-level paths (`/etc/hfdesk/hfdesk.json`, `/var/lib/hfdesk`, `/var/log/hfdesk`) and sets `HOME=/var/lib/hfdesk`, so the instance inherits neither the home of root nor the cache of the invoking user. | -| macOS (launchd, `--system`) | **root:wheel** (`UserName`/`GroupName` of the property list) | The same machine-level paths (`/etc/hfdesk/hfdesk.json`, `/var/lib/hfdesk`, `/var/log/hfdesk`) and the same `HOME` rule: the daemon uses `/var/lib/hfdesk` as its home, not the personal home of root. | -| Windows (Service Control Manager) | **LocalService** (`NT AUTHORITY\LocalService`) | A documented minimum-privilege Windows service account. It is deliberately **not** `LocalSystem`: the instance needs no machine-wide privilege, and `LocalSystem` would give it more access than it uses. | +| Linux (systemd, `--system`) | the account **`hfdesk`** | a dedicated system account; it must exist before the installation | +| macOS (launchd, `--system`) | the account **`_hfdesk`** | a dedicated system account (the underscore prefix is the macOS convention for a service account); it must exist before the installation | +| Windows (Service Control Manager) | **LocalService** (`NT AUTHORITY\LocalService`) | a documented minimum-privilege Windows service account. It is deliberately **not** `LocalSystem`: the instance needs no machine-wide privilege, and `LocalSystem` would give it more access than it uses. | + +The account is **stable** on purpose: the files it owns have to stay writable +across reboots, so an ephemeral identity would break the instance at the next +restart. It is also **unprivileged**: the instance serves a dashboard and +downloads models, it does not administer the machine. + +The name is the default of the platform and can be changed with +`--service-user ACCOUNT`. The option applies to the machine-wide scope only: +a user-scope instance runs as the user itself and never names an account. + +**The account must already exist.** HFDesk is a service manager, not a system +provisioner: it never creates an account, never calls `useradd` or `dscl`, +never runs `sudo` and never elevates itself. An installation whose account +does not exist is **refused**, and the error names the account and the exact +commands that create it: + +```bash +# Linux +sudo useradd --system --no-create-home --home-dir /var/lib/hfdesk --shell /usr/sbin/nologin hfdesk + +# macOS +sudo dscl . -create /Users/_hfdesk UserShell /usr/bin/false +sudo dscl . -create /Users/_hfdesk UserHome /var/lib/hfdesk +sudo dscl . -create /Users/_hfdesk UniqueID +``` + +Creating the account needs **administrative rights**: the commands above are +run by an administrator, before the installation. HFDesk never creates an +account itself and never asks for a password. + +**Only the three directories of the instance are granted to the account:** the +config directory, the state directory and the log directory — the directories +the installation creates anyway, together with the parent directories it +creates for them. A directory the installation **created** is granted to the +account; a directory that **existed before** the installation belongs to its +owner and is left alone, so the installation never takes a directory over from +the user who put it there. The account still has to be able to write into such +a directory: when it cannot, the installation is **refused** with the guidance +that names the directory, the account and the command that grants the access, +and the ownership of every directory stays as it was. + +A model or cache tree is **never** chowned, never chmodded and never walked +recursively. A cache directory the user names explicitly is **validated** for +accessibility by the service account instead: when the account cannot reach +it, the installation fails with ownership/permission guidance, and the tree +stays exactly as it is. **Consequences:** -- A machine-wide instance on Linux and macOS can read and write everything - the root account can. Use one only on a machine that is dedicated to - serving models, and place the cache on a volume whose ownership fits the - identity of the instance. +- The cache of a machine-wide instance has to be **readable and writable by + the service account**. Point the instance at a cache directory that the + account can reach, or grant it access to that directory explicitly, or run + a user-scope instance, which keeps the identity of the user and the access + to their own Hugging Face cache. - The Windows instance has a profile and a filesystem access of its own: it does not reuse `%APPDATA%` and it does not point at the model or cache directories of the interactive user. - The **user-scope** instance is the one that keeps the identity of the user - and the access to their own Hugging Face cache. + and the access to their own Hugging Face cache. It needs no service + account. ### Privileged operations @@ -388,6 +482,22 @@ change. If the binary *moves*, `status` reports the registration as `broken` (see [Diagnosing a broken registration](#diagnosing-a-broken-registration)) and you reinstall the service from the new path. +The registered path is the **invoked** path, so a stable name keeps its name +in the registration: a symbolic link `/usr/local/bin/hfdesk` that points at +`/opt/hfdesk-1.2.3/hfdesk` is registered as `/usr/local/bin/hfdesk`, and the +next release replaces the file behind it. `install` warns when the registered +path is version-stamped (`/opt/hfdesk-1.2.3/hfdesk`), when the name of its +directory is version-stamped (`/opt/hfdesk-1.2.3/bin/hfdesk`) or when it +lives in a temporary directory, because such a path does not survive the next +upgrade. + +When the binary that is run is not the one that should be registered, name it +explicitly: + +```bash +hfdesk service install --executable /usr/local/bin/hfdesk +``` + ## See also - [README](../README.md) — general project behavior From 1145d66be605b4d12c3c8d7af6c169cc1fd5defc Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 22 Oct 2026 00:18:43 +1100 Subject: [PATCH 19/58] workflow: narrow the macOS CI job to the service package - The launchd manager, the plutil validation of the generated property list and the launchctl lifecycle are darwin-only code, so they need a macOS runner. The full suite still has pre-existing macOS-compatibility gaps, so the job stays narrowed to the service package. Co-Authored-By: opencode --- .github/workflows/ci.yml | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0f53f51e..f5310572 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -146,6 +146,10 @@ jobs: # so they need a macOS runner. The run executes the plist validation # (plutil -lint) and the launchctl integration cycle where the runner # allows it. + # + # The full suite has separate pre-existing macOS-compatibility gaps, so the + # job stays narrowed to the service package. The full macOS suite can be + # enabled once the pre-existing platform gaps are resolved. go-macos: name: Go checks (macOS) runs-on: macos-latest @@ -166,6 +170,10 @@ jobs: - name: Vet run: go vet ./... - - name: Test - run: go test ./... + + # The service package covers the launchd manager, the plutil -lint + # validation of the generated property list and the launchctl + # lifecycle where the runner allows it. + - name: Test service package + run: go test ./internal/service -v From 2a9c5ef5e21124d86414e49f0b32f197cd5ccc83 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 22 Oct 2026 00:55:59 +1100 Subject: [PATCH 20/58] fix(logsink): never change the mode of an existing path - The log directory and the log file are created owner-only, and a directory or a file that already exists keeps its mode. The instance no longer requires an ownership the installation never checked, so an instance whose log directory belongs to another owner can start instead of failing with a permission error before it serves. Co-Authored-By: opencode --- docs/SERVICE.md | 13 +++- internal/logsink/logsink.go | 112 ++++++++++++++++++++++++++----- internal/logsink/logsink_test.go | 73 ++++++++++++++++++-- 3 files changed, 172 insertions(+), 26 deletions(-) diff --git a/docs/SERVICE.md b/docs/SERVICE.md index 493291e9..874ae1a7 100644 --- a/docs/SERVICE.md +++ b/docs/SERVICE.md @@ -131,6 +131,9 @@ over from the owner it already has. A machine-wide instance grants the service account only the directories it created itself; a directory that already existed has to be writable by that account already (see [The identity of a machine-wide instance](#the-identity-of-a-machine-wide-instance)). +The running instance follows the same rule for its log directory and its log +file: it creates them owner-only when they are missing and never changes the +mode of a directory or of a file that already exists. ### Lifecycle flags @@ -245,9 +248,13 @@ with bounded rotation: The active file is rotated before a write would exceed 2 MiB, and three rotated generations are kept, so the on-disk size of the log stays bounded. -The log directory and the log files are owner-only (`0700` / `0600` on Unix): -the running instance tightens its own log directory and its own log files to -those modes. +The instance creates its log directory and its log files owner-only (`0700` / +`0600` on Unix) when they are missing. It never changes the mode of a +directory or of a file that already exists: the log directory may be a +directory that existed before the installation and that belongs to someone +else, and the instance may only be able to write there. Tightening it needs +an ownership the installation never checked, and it would keep the instance +from starting at all. The standard output and the standard error of the process also reach the supervisor: the systemd journal on Linux. On macOS the property list sets diff --git a/internal/logsink/logsink.go b/internal/logsink/logsink.go index 1b05bdf9..2a29da5b 100644 --- a/internal/logsink/logsink.go +++ b/internal/logsink/logsink.go @@ -11,6 +11,7 @@ package logsink import ( "errors" "fmt" + "io/fs" "os" "path/filepath" "sync" @@ -40,27 +41,25 @@ type Sink struct { } // Open creates dir (0700) if needed and opens /hfdesk.log (0600). -// An existing directory or file is tightened to the same restrictive modes. +// +// The mode of a directory or of a file that already exists is left alone. +// The log directory of an instance may be a directory that existed before +// the installation and that belongs to someone else: the instance may only +// be able to write there. Tightening it needs an ownership the installation +// never checked, and it would keep the instance from starting at all. func Open(dir string) (*Sink, error) { if dir == "" { return nil, errors.New("logsink: log directory is required") } - if err := os.MkdirAll(dir, DirMode); err != nil { + if err := createDir(dir, DirMode); err != nil { return nil, fmt.Errorf("logsink: create log dir: %w", err) } - if err := os.Chmod(dir, DirMode); err != nil { - return nil, fmt.Errorf("logsink: restrict log dir: %w", err) - } path := filepath.Join(dir, FileName) - file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_APPEND, FileMode) + file, err := createFile(path, os.O_WRONLY|os.O_APPEND) if err != nil { return nil, fmt.Errorf("logsink: open log file: %w", err) } - if err := file.Chmod(FileMode); err != nil { - _ = file.Close() - return nil, fmt.Errorf("logsink: restrict log file: %w", err) - } s := &Sink{path: path, file: file} if info, err := file.Stat(); err == nil { @@ -69,6 +68,91 @@ func Open(dir string) (*Sink, error) { return s, nil } +// createDir creates the directory and the missing parents of it. +// +// A directory that already exists keeps its mode: it belongs to whoever +// named it, and the instance may not own it. The directories created here +// belong to this process, so their mode is set explicitly: the mode of a +// create call is filtered by the umask of the process. +func createDir(path string, mode os.FileMode) error { + missing, err := missingDirs(path) + if err != nil { + return err + } + // The directories are created from the top down, so every parent + // exists before its child. + for i := len(missing) - 1; i >= 0; i-- { + if err := os.Mkdir(missing[i], mode); err != nil { + if errors.Is(err, fs.ErrExist) { + // Another process created the directory in the + // meantime: its mode belongs to whoever created it. + continue + } + return err + } + if err := os.Chmod(missing[i], mode); err != nil { + return err + } + } + return nil +} + +// missingDirs returns the directories of path that do not exist yet, the +// closest one first. +// +// The path itself has to be a directory: a file at that path is reported +// here instead of surfacing later as an unusable log file. +func missingDirs(path string) ([]string, error) { + var missing []string + for p := path; ; { + info, err := os.Stat(p) + if err == nil { + if !info.IsDir() { + return nil, fmt.Errorf("%q exists and is not a directory", p) + } + break + } + if !errors.Is(err, fs.ErrNotExist) { + return nil, err + } + missing = append(missing, p) + parent := filepath.Dir(p) + if parent == p { + break + } + p = parent + } + return missing, nil +} + +// createFile opens the file at path for writing and creates it with the +// owner-only mode when it is missing. +// +// The mode of a file that already exists is left alone: the log file may +// belong to another account that only granted the instance write access, +// and a chmod on such a file fails with "operation not permitted". +func createFile(path string, flag int) (*os.File, error) { + file, err := os.OpenFile(path, flag|os.O_CREATE|os.O_EXCL, FileMode) + if err == nil { + // The file was created here, so it belongs to this process: the + // umask of the process may have filtered the mode, so it is set + // explicitly. + if err := file.Chmod(FileMode); err != nil { + _ = file.Close() + return nil, err + } + return file, nil + } + if !errors.Is(err, fs.ErrExist) { + return nil, err + } + // The file already exists: it is opened and never tightened. A file + // that vanished between the two calls is created here instead, with + // the mode of the create call, which the umask of the process may + // have narrowed. + return os.OpenFile(path, flag|os.O_CREATE, FileMode) +} + // Write implements io.Writer under a mutex. The active file is rotated // before a write that would exceed MaxFileSize. A single write larger than // the limit is written whole instead of being split. @@ -139,14 +223,10 @@ func (s *Sink) rotate() error { return fmt.Errorf("logsink: rotate log: %w", err) } - file, err := os.OpenFile(s.path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC|os.O_APPEND, FileMode) + file, err := createFile(s.path, os.O_WRONLY|os.O_TRUNC|os.O_APPEND) if err != nil { return fmt.Errorf("logsink: reopen log after rotation: %w", err) } - if err := file.Chmod(FileMode); err != nil { - _ = file.Close() - return fmt.Errorf("logsink: restrict log file: %w", err) - } s.file = file s.size = 0 return nil @@ -155,7 +235,7 @@ func (s *Sink) rotate() error { // reopenActive reopens the active file for appending after a failed // rotation. The caller must hold the mutex. func (s *Sink) reopenActive() { - file, err := os.OpenFile(s.path, os.O_WRONLY|os.O_CREATE|os.O_APPEND, FileMode) + file, err := createFile(s.path, os.O_WRONLY|os.O_APPEND) if err != nil { return } diff --git a/internal/logsink/logsink_test.go b/internal/logsink/logsink_test.go index 2d681c77..5f5ebcf1 100644 --- a/internal/logsink/logsink_test.go +++ b/internal/logsink/logsink_test.go @@ -43,7 +43,64 @@ func TestOpenCreatesRestrictiveDirAndFile(t *testing.T) { } } -func TestOpenTightensExistingPermissions(t *testing.T) { +func TestOpenCreatesMissingParentsOwnerOnly(t *testing.T) { + dir := filepath.Join(t.TempDir(), "a", "b", "logs") + + sink, err := Open(dir) + if err != nil { + t.Fatal(err) + } + defer sink.Close() + + for _, path := range []string{dir, filepath.Dir(dir), filepath.Dir(filepath.Dir(dir))} { + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if got := info.Mode().Perm(); got != 0o700 { + t.Errorf("dir %s mode = %04o, want 0700", path, got) + } + } +} + +func TestOpenKeepsExistingDirMode(t *testing.T) { + dir := filepath.Join(t.TempDir(), "logs") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.Chmod(dir, 0o755); err != nil { + t.Fatal(err) + } + + sink, err := Open(dir) + if err != nil { + t.Fatal(err) + } + defer sink.Close() + + // The mode of a directory that already exists belongs to whoever + // named it: the instance writes there and leaves the mode alone. + info, err := os.Stat(dir) + if err != nil { + t.Fatal(err) + } + if got := info.Mode().Perm(); got != 0o755 { + t.Errorf("log dir mode = %04o, want 0755", got) + } + + if _, err := sink.Write([]byte("line\n")); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(sink.Path()) + if err != nil { + t.Fatal(err) + } + if got, want := string(data), "line\n"; got != want { + t.Errorf("log content = %q, want %q", got, want) + } +} + +func TestOpenKeepsExistingFileMode(t *testing.T) { dir := filepath.Join(t.TempDir(), "logs") if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatal(err) @@ -65,19 +122,21 @@ func TestOpenTightensExistingPermissions(t *testing.T) { } defer sink.Close() - dirInfo, err := os.Stat(dir) + // The mode of a file that already exists belongs to whoever owns it: + // the instance appends to it and leaves the mode alone. + fileInfo, err := os.Stat(logFile) if err != nil { t.Fatal(err) } - if got := dirInfo.Mode().Perm(); got != 0o700 { - t.Errorf("log dir mode = %04o, want 0700", got) + if got := fileInfo.Mode().Perm(); got != 0o644 { + t.Errorf("log file mode = %04o, want 0644", got) } - fileInfo, err := os.Stat(logFile) + dirInfo, err := os.Stat(dir) if err != nil { t.Fatal(err) } - if got := fileInfo.Mode().Perm(); got != 0o600 { - t.Errorf("log file mode = %04o, want 0600", got) + if got := dirInfo.Mode().Perm(); got != 0o755 { + t.Errorf("log dir mode = %04o, want 0755", got) } // Existing content is kept and new writes are appended. From eadfe6acb6c80ec63f7123c426e15d898d33a8c6 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 22 Oct 2026 00:55:59 +1100 Subject: [PATCH 21/58] perf(server): resolve the state directory without the config search - The jobs state, the download history and the mirror targets resolve their state directory directly, so a save no longer re-scans the working directory and the executable directory for a config file it does not need. Co-Authored-By: opencode --- internal/runtimepaths/runtimepaths.go | 33 ++++- internal/runtimepaths/runtimepaths_test.go | 136 +++++++++++++++++++++ internal/server/config.go | 16 ++- internal/server/mirror.go | 15 ++- internal/server/runtimepaths_test.go | 44 +++++++ internal/server/state.go | 2 +- 6 files changed, 230 insertions(+), 16 deletions(-) diff --git a/internal/runtimepaths/runtimepaths.go b/internal/runtimepaths/runtimepaths.go index 0c0ccb58..e9bda263 100644 --- a/internal/runtimepaths/runtimepaths.go +++ b/internal/runtimepaths/runtimepaths.go @@ -34,19 +34,29 @@ type Paths struct { // configFileNames are the accepted config file names, in search order. var configFileNames = []string{"hfdesk.json", "hfdesk.yaml", "hfdesk.yml"} +// The names of the state files inside the state directory. +const ( + // JobsStateFileName is the name of the persisted job list. + JobsStateFileName = "jobs_state.json" + // HistoryFileName is the name of the download history. + HistoryFileName = "download_history.json" + // TargetsFileName is the name of the mirror targets config. + TargetsFileName = "targets.yaml" +) + // JobsStateFile returns the path of the persisted job list. func (p Paths) JobsStateFile() string { - return filepath.Join(p.StateDir, "jobs_state.json") + return filepath.Join(p.StateDir, JobsStateFileName) } // HistoryFile returns the path of the download history. func (p Paths) HistoryFile() string { - return filepath.Join(p.StateDir, "download_history.json") + return filepath.Join(p.StateDir, HistoryFileName) } // TargetsFile returns the path of the mirror targets config. func (p Paths) TargetsFile() string { - return filepath.Join(p.StateDir, "targets.yaml") + return filepath.Join(p.StateDir, TargetsFileName) } var ( @@ -92,6 +102,23 @@ func Current() Paths { return interactivePaths() } +// StateDir returns the state directory of the effective path model. +// +// Unlike Current it does not resolve the config file, so it stays cheap +// enough for the hot paths that write the job list, the download history and +// the mirror targets on every save. The result is always the StateDir of +// Current: the installed state directory of a supervised instance and the +// per-user config directory of an interactive run. +func StateDir() string { + installedMu.RLock() + p := installed + installedMu.RUnlock() + if p != nil { + return p.StateDir + } + return AppConfigDir() +} + // interactivePaths resolves the interactive path model from the current // process environment. func interactivePaths() Paths { diff --git a/internal/runtimepaths/runtimepaths_test.go b/internal/runtimepaths/runtimepaths_test.go index fdfb115f..d093f83e 100644 --- a/internal/runtimepaths/runtimepaths_test.go +++ b/internal/runtimepaths/runtimepaths_test.go @@ -62,6 +62,142 @@ func TestInteractiveCurrentFollowsEnvironment(t *testing.T) { } } +func TestStateDirReturnsTheInstalledStateDirectory(t *testing.T) { + model := Paths{ + ConfigFile: filepath.Join("/etc", "hfdesk", "hfdesk.json"), + StateDir: filepath.Join("/var", "lib", "hfdesk"), + LogDir: filepath.Join("/var", "log", "hfdesk"), + } + Install(model) + t.Cleanup(Reset) + + if got, want := StateDir(), model.StateDir; got != want { + t.Fatalf("StateDir() = %q, want the installed state directory %q", got, want) + } + + // The installed state directory does not depend on the environment. + setSandboxEnv(t, t.TempDir()) + t.Chdir(t.TempDir()) + + if got, want := StateDir(), model.StateDir; got != want { + t.Fatalf("StateDir() = %q, want the installed state directory %q", got, want) + } +} + +func TestStateDirFollowsTheInteractiveEnvironment(t *testing.T) { + Reset() + t.Cleanup(Reset) + + root := t.TempDir() + setSandboxEnv(t, root) + + got := StateDir() + if want := referenceAppConfigDir(); got != want { + t.Fatalf("StateDir() = %q, want %q", got, want) + } + if !strings.Contains(got, root) { + t.Fatalf("StateDir() = %q, want a directory inside the sandbox %q", got, root) + } + + // The state directory is resolved on every call, so a later environment + // change must be picked up. + next := t.TempDir() + setSandboxEnv(t, next) + + got = StateDir() + if want := referenceAppConfigDir(); got != want { + t.Fatalf("StateDir() after the environment change = %q, want %q", got, want) + } + if !strings.Contains(got, next) { + t.Fatalf("StateDir() = %q, want a directory inside the sandbox %q", got, next) + } +} + +func TestStateDirDoesNotResolveTheConfigFile(t *testing.T) { + Reset() + t.Cleanup(Reset) + + // The home directory has to be captured before the sandbox environment + // replaces it: the run directory must not look like a temporary one. + home := currentHomeDir(t) + + setSandboxEnv(t, t.TempDir()) + + stateDir := StateDir() + + // A config file next to the run directory is the config file of the + // model, but it does not change the state directory. + runDir := nonTempRunDir(t, home) + if err := os.WriteFile(filepath.Join(runDir, "hfdesk.json"), []byte("{}\n"), 0o644); err != nil { + t.Fatal(err) + } + t.Chdir(runDir) + + if got, want := Current().ConfigFile, filepath.Join(runDir, "hfdesk.json"); got != want { + t.Fatalf("config file = %q, want %q", got, want) + } + if got := StateDir(); got != stateDir { + t.Fatalf("StateDir() = %q, want the unchanged state directory %q", got, stateDir) + } + if got, want := StateDir(), referenceAppConfigDir(); got != want { + t.Fatalf("StateDir() = %q, want %q", got, want) + } +} + +func TestStateDirAgreesWithCurrent(t *testing.T) { + Reset() + t.Cleanup(Reset) + + model := Paths{ + ConfigFile: filepath.Join("/etc", "hfdesk", "hfdesk.json"), + StateDir: filepath.Join("/var", "lib", "hfdesk"), + LogDir: filepath.Join("/var", "log", "hfdesk"), + } + + check := func(stage string) { + t.Helper() + if got, want := StateDir(), Current().StateDir; got != want { + t.Fatalf("%s: StateDir() = %q, want Current().StateDir = %q", stage, got, want) + } + } + + setSandboxEnv(t, t.TempDir()) + check("interactive") + + Install(model) + check("installed") + + // An installed model without a state directory keeps its empty state + // directory: the accessor does not invent one. + Install(Paths{}) + check("installed without a state directory") + + Reset() + check("after reset") + + if got, want := StateDir(), referenceAppConfigDir(); got != want { + t.Fatalf("StateDir() after Reset = %q, want %q", got, want) + } +} + +func TestStateDirAgreesWithCurrentWithoutAnEnvironment(t *testing.T) { + Reset() + t.Cleanup(Reset) + + // Without a home and a config directory the interactive model falls + // back to the run directory. The accessor has to agree there too. + for _, key := range []string{"HOME", "USERPROFILE", "XDG_CONFIG_HOME", "APPDATA", "LOCALAPPDATA"} { + t.Setenv(key, "") + } + + if got, want := StateDir(), Current().StateDir; got != want { + t.Fatalf("StateDir() = %q, want Current().StateDir = %q", got, want) + } + if got := StateDir(); got == "" { + t.Fatalf("StateDir() = %q, want a usable path", got) + } +} + func TestDerivedFileNamesLiveInStateDir(t *testing.T) { stateDir := filepath.Join("var", "lib", "hfdesk") p := Paths{StateDir: stateDir} diff --git a/internal/server/config.go b/internal/server/config.go index 181b741d..2666eaec 100644 --- a/internal/server/config.go +++ b/internal/server/config.go @@ -23,14 +23,22 @@ func RunDir() string { return runtimepaths.RunDir() } -// JobsStatePath returns the path to jobs_state.json in the app config directory. +// JobsStatePath returns the path to jobs_state.json in the state directory +// of the effective runtime path model. func JobsStatePath() string { - return runtimepaths.Current().JobsStateFile() + return filepath.Join(runtimepaths.StateDir(), runtimepaths.JobsStateFileName) } -// HistoryPath returns the path to download_history.json in the app config directory. +// HistoryPath returns the path to download_history.json in the state +// directory of the effective runtime path model. func HistoryPath() string { - return runtimepaths.Current().HistoryFile() + return filepath.Join(runtimepaths.StateDir(), runtimepaths.HistoryFileName) +} + +// TargetsFilePath returns the path to targets.yaml in the state directory of +// the effective runtime path model. +func TargetsFilePath() string { + return filepath.Join(runtimepaths.StateDir(), runtimepaths.TargetsFileName) } // ConfigFile represents the persistent configuration file format. diff --git a/internal/server/mirror.go b/internal/server/mirror.go index 72c97525..2a0fa0bf 100644 --- a/internal/server/mirror.go +++ b/internal/server/mirror.go @@ -12,7 +12,6 @@ import ( "sort" "strings" - "github.com/bashrusakh/hfdesk/internal/runtimepaths" "github.com/bashrusakh/hfdesk/pkg/hfdownloader" ) @@ -27,7 +26,7 @@ type MirrorTargetRequest struct { // handleMirrorTargetsList returns all configured mirror targets. func (s *Server) handleMirrorTargetsList(w http.ResponseWriter, r *http.Request) { - cfg, err := hfdownloader.LoadTargets(runtimepaths.Current().TargetsFile()) + cfg, err := hfdownloader.LoadTargets(TargetsFilePath()) if err != nil { writeError(w, http.StatusInternalServerError, "Failed to load targets", err.Error()) return @@ -94,7 +93,7 @@ func (s *Server) handleMirrorTargetAdd(w http.ResponseWriter, r *http.Request) { return } - cfg, err := hfdownloader.LoadTargets(runtimepaths.Current().TargetsFile()) + cfg, err := hfdownloader.LoadTargets(TargetsFilePath()) if err != nil { writeError(w, http.StatusInternalServerError, "Failed to load targets", err.Error()) return @@ -126,7 +125,7 @@ func (s *Server) handleMirrorTargetRemove(w http.ResponseWriter, r *http.Request return } - cfg, err := hfdownloader.LoadTargets(runtimepaths.Current().TargetsFile()) + cfg, err := hfdownloader.LoadTargets(TargetsFilePath()) if err != nil { writeError(w, http.StatusInternalServerError, "Failed to load targets", err.Error()) return @@ -180,7 +179,7 @@ func (s *Server) handleMirrorDiff(w http.ResponseWriter, r *http.Request) { } // Resolve target path - targetsCfg, err := hfdownloader.LoadTargets(runtimepaths.Current().TargetsFile()) + targetsCfg, err := hfdownloader.LoadTargets(TargetsFilePath()) if err != nil { writeError(w, http.StatusInternalServerError, "Failed to load targets", err.Error()) return @@ -329,7 +328,7 @@ func (s *Server) handleMirrorPush(w http.ResponseWriter, r *http.Request) { } // Resolve target path - targetsCfg, err := hfdownloader.LoadTargets(runtimepaths.Current().TargetsFile()) + targetsCfg, err := hfdownloader.LoadTargets(TargetsFilePath()) if err != nil { writeError(w, http.StatusInternalServerError, "Failed to load targets", err.Error()) return @@ -372,7 +371,7 @@ func (s *Server) handleMirrorPull(w http.ResponseWriter, r *http.Request) { } // Resolve target path - targetsCfg, err := hfdownloader.LoadTargets(runtimepaths.Current().TargetsFile()) + targetsCfg, err := hfdownloader.LoadTargets(TargetsFilePath()) if err != nil { writeError(w, http.StatusInternalServerError, "Failed to load targets", err.Error()) return @@ -411,7 +410,7 @@ func (s *Server) handleMirrorPull(w http.ResponseWriter, r *http.Request) { // it owns before the file is written: the mode of the directory must not // depend on which code path created it. func saveMirrorTargets(cfg *hfdownloader.TargetsConfig) error { - targetsFile := runtimepaths.Current().TargetsFile() + targetsFile := TargetsFilePath() if err := os.MkdirAll(filepath.Dir(targetsFile), serverDirMode()); err != nil { return err } diff --git a/internal/server/runtimepaths_test.go b/internal/server/runtimepaths_test.go index 5c7f73cd..b2f9a697 100644 --- a/internal/server/runtimepaths_test.go +++ b/internal/server/runtimepaths_test.go @@ -35,6 +35,33 @@ func TestServerPathsFollowInstalledRuntimeModel(t *testing.T) { if got, want := HistoryPath(), filepath.Join(state, "download_history.json"); got != want { t.Errorf("HistoryPath() = %q, want %q", got, want) } + if got, want := TargetsFilePath(), filepath.Join(state, "targets.yaml"); got != want { + t.Errorf("TargetsFilePath() = %q, want %q", got, want) + } +} + +func TestServerPathsFollowTheEnvironmentWithoutAnInstalledModel(t *testing.T) { + runtimepaths.Reset() + t.Cleanup(runtimepaths.Reset) + + root := t.TempDir() + setServerSandboxEnv(t, root) + + // The per-user config directory of the sandboxed environment. + stateDir := referenceAppConfigDir() + if !strings.HasPrefix(stateDir, root) { + t.Fatalf("config directory %q is not inside the sandbox %q", stateDir, root) + } + + if got, want := JobsStatePath(), filepath.Join(stateDir, "jobs_state.json"); got != want { + t.Errorf("JobsStatePath() = %q, want %q", got, want) + } + if got, want := HistoryPath(), filepath.Join(stateDir, "download_history.json"); got != want { + t.Errorf("HistoryPath() = %q, want %q", got, want) + } + if got, want := TargetsFilePath(), filepath.Join(stateDir, "targets.yaml"); got != want { + t.Errorf("TargetsFilePath() = %q, want %q", got, want) + } } func TestServerPathsInteractiveWithoutInstalledModel(t *testing.T) { @@ -79,6 +106,23 @@ func TestMirrorTargetsUseInstalledRuntimeModel(t *testing.T) { } } +func TestMirrorTargetsFollowTheEnvironmentWithoutAnInstalledModel(t *testing.T) { + runtimepaths.Reset() + t.Cleanup(runtimepaths.Reset) + setServerSandboxEnv(t, t.TempDir()) + + addMirrorTarget(t) + + targetsFile := filepath.Join(referenceAppConfigDir(), "targets.yaml") + data, err := os.ReadFile(targetsFile) + if err != nil { + t.Fatalf("read targets file %s: %v", targetsFile, err) + } + if !strings.Contains(string(data), "backup") { + t.Errorf("targets file %s does not contain the new target:\n%s", targetsFile, data) + } +} + func TestSettingsReportInstalledRuntimeModel(t *testing.T) { state := t.TempDir() model := runtimepaths.Paths{ diff --git a/internal/server/state.go b/internal/server/state.go index 5ba343ce..c2432f72 100644 --- a/internal/server/state.go +++ b/internal/server/state.go @@ -250,7 +250,7 @@ func AppendHistory(job *Job) error { // The state directory comes from the effective runtime path model, so a // supervised instance writes its history into its own state root. The // directory is created with the mode the server owns. - if err := os.MkdirAll(runtimepaths.Current().StateDir, serverDirMode()); err != nil { + if err := os.MkdirAll(runtimepaths.StateDir(), serverDirMode()); err != nil { return err } return os.WriteFile(HistoryPath(), data, 0o644) From df74ef1b291a4fe9749718d67f97023e3fe737d3 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 22 Oct 2026 00:55:59 +1100 Subject: [PATCH 22/58] fix(service): remove the directories a failed installation created - A failed installation leaves the machine as it was: the directories it created are removed when they are still empty, and a directory that held something is kept. A directory that existed before is never touched. Co-Authored-By: opencode --- internal/service/manager_darwin.go | 88 ++++++- internal/service/manager_darwin_test.go | 289 +++++++++++++++++++++++ internal/service/manager_linux.go | 88 ++++++- internal/service/manager_linux_test.go | 299 ++++++++++++++++++++++++ 4 files changed, 750 insertions(+), 14 deletions(-) diff --git a/internal/service/manager_darwin.go b/internal/service/manager_darwin.go index 3f4c2ca5..4cefcdc4 100644 --- a/internal/service/manager_darwin.go +++ b/internal/service/manager_darwin.go @@ -167,7 +167,9 @@ func (m *launchdManager) target() string { // The property list is rendered, written atomically and loaded into the // domain of the instance. An installation that fails after the property list // was written restores the previous registration, so a failed installation -// never leaves a half configured registration behind. +// never leaves a half configured registration behind. A failure removes the +// directories the installation created as well, so a failed attempt leaves +// the machine as it was. // // A machine-wide instance runs as a service account. The account is // resolved and validated before any directory is created and before the @@ -209,16 +211,39 @@ func (m *launchdManager) Install(ctx context.Context, opts InstallOptions) error // The directories the installation is about to create are remembered // before they are created, so the account is given the ownership of // exactly those and of nothing else. - var created map[string][]string - if account != nil { - created = make(map[string][]string, 3) - for _, dir := range instanceDirs(opts) { - created[dir.path] = createdDirs(dir.path) - } + // + // The list is also what a failed installation removes again, so a + // failed installation leaves the machine as it was. + created := make(map[string][]string, 3) + for _, dir := range instanceDirs(opts) { + created[dir.path] = createdDirs(dir.path) } if err := PrepareDirs(opts); err != nil { + // The preparation may have created some of the directories + // before it failed: they are removed again, so a failed attempt + // leaves the machine as it was. + removeCreatedDirs(createdDirOrder(opts, created)) + return err + } + // The installation owns the directories it created from here on: a + // failure removes them again, so a failed installation never leaves + // the directories of the instance behind. + if err := m.register(ctx, opts, account, created); err != nil { + removeCreatedDirs(createdDirOrder(opts, created)) return err } + return nil +} + +// register writes the registration of the instance and hands it to the +// supervisor. +// +// The directories of the instance are already created when the function is +// called. A failure of the registration is rolled back: the property list is +// restored and the directories the installation created are removed by the +// caller, so a failed installation leaves no half configured registration and +// no empty directories behind. +func (m *launchdManager) register(ctx context.Context, opts InstallOptions, account *serviceAccount, created map[string][]string) error { // The instance runs as the account, so the account owns the directories // the instance writes. if err := m.grantInstanceDirs(opts, account, created); err != nil { @@ -711,6 +736,55 @@ func createdDirs(path string) []string { return missing } +// createdDirOrder returns the directories an installation creates, in the +// order it creates them. +// +// The map holds the directories of every directory of the instance. The +// directories of the instance are created in the order of instanceDirs (the +// config directory, the state directory and the log directory) and from the +// top down inside each of them, so the list is the order of the creation. +// +// A directory that several directories of the instance need is listed once: +// the first directory of the instance that needed it is the one that created +// it. +func createdDirOrder(opts InstallOptions, created map[string][]string) []string { + var order []string + seen := make(map[string]bool, len(created)) + for _, dir := range instanceDirs(opts) { + for _, path := range created[dir.path] { + if seen[path] { + continue + } + seen[path] = true + order = append(order, path) + } + } + return order +} + +// removeCreatedDirs removes the directories an installation created. +// +// The directories are removed in the reverse order of their creation, so a +// parent is only removed after its children. A directory that is not empty is +// kept: the removal never deletes data and it never walks into a directory. +// +// The removal is best effort: a failure of the removal is ignored, so the +// original failure of the installation stays the error the caller gets. +func removeCreatedDirs(dirs []string) { + for i := len(dirs) - 1; i >= 0; i-- { + path := dirs[i] + info, err := os.Lstat(path) + if err != nil || !info.IsDir() { + // The directory is gone already, or it is not a directory + // any more: nothing is removed, so no file is ever deleted. + continue + } + // A directory that holds something is kept: the removal of such + // a directory would delete what it holds. + _ = os.Remove(path) + } +} + // machineDir reports whether the directory is a directory of the machine // itself: a top level directory of the file system, like /etc or /usr. // diff --git a/internal/service/manager_darwin_test.go b/internal/service/manager_darwin_test.go index 3fb3bd92..ce038511 100644 --- a/internal/service/manager_darwin_test.go +++ b/internal/service/manager_darwin_test.go @@ -1974,3 +1974,292 @@ func darwinStripXMLEntities(value string) string { } return value } + +// darwinFakeSupervisor replaces the supervisor of the machine with a fake +// one, so an installation can be driven through a known point. +// +// The fake fails every call, unless the environment variable +// HFDESK_TEST_SUPERVISOR is "ok". A failing installation therefore stops +// after the directories of the instance were created, which is the point the +// cleanup of a failed installation has to cover. +// +// The fake writes a marker file into the directory of the environment +// variable HFDESK_TEST_MARKER_DIR when that variable names a directory, so +// a test can make a directory of the instance hold data before the +// installation fails. +func darwinFakeSupervisor(t *testing.T) { + t.Helper() + dir := t.TempDir() + script := `#!/bin/sh +if [ -n "$HFDESK_TEST_MARKER_DIR" ]; then + mkdir -p "$HFDESK_TEST_MARKER_DIR" || exit 1 + : > "$HFDESK_TEST_MARKER_DIR/marker" || exit 1 +fi +if [ "$HFDESK_TEST_SUPERVISOR" = "ok" ]; then + exit 0 +fi +echo "the fake supervisor refuses the command" >&2 +exit 1 +` + if err := os.WriteFile(filepath.Join(dir, "launchctl"), []byte(script), 0o755); err != nil { + t.Fatalf("write the fake supervisor: %v", err) + } + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) +} + +// darwinTempInstallOptions returns the options of a user scope installation +// whose directories live below the given root. +// +// The scope is the user scope: it needs neither an elevated shell nor a +// service account, so the test runs everywhere. +func darwinTempInstallOptions(t *testing.T, root string) InstallOptions { + t.Helper() + executable := filepath.Join(t.TempDir(), "hfdesk") + if err := os.WriteFile(executable, []byte("#!/bin/sh\nexec sleep 30\n"), 0o755); err != nil { + t.Fatalf("write the program: %v", err) + } + return InstallOptions{ + Scope: ScopeUser, + Executable: executable, + Listen: DefaultListen, + Paths: runtimepaths.Paths{ + ConfigFile: filepath.Join(root, "etc", "hfdesk", "hfdesk.json"), + StateDir: filepath.Join(root, "var", "lib", "hfdesk"), + LogDir: filepath.Join(root, "var", "log", "hfdesk"), + }, + } +} + +// TestLaunchdCreatedDirOrder checks the order of the directories a failed +// installation removes: the directories are listed in the order they are +// created, so a parent is always removed after its children. +func TestLaunchdCreatedDirOrder(t *testing.T) { + root := t.TempDir() + opts := InstallOptions{ + Scope: ScopeUser, + Executable: filepath.Join(root, "hfdesk"), + Listen: DefaultListen, + Paths: runtimepaths.Paths{ + ConfigFile: filepath.Join(root, "etc", "hfdesk", "hfdesk.json"), + StateDir: filepath.Join(root, "var", "lib", "hfdesk"), + LogDir: filepath.Join(root, "var", "log", "hfdesk"), + }, + } + + created := make(map[string][]string, 3) + for _, dir := range instanceDirs(opts) { + created[dir.path] = createdDirs(dir.path) + } + order := createdDirOrder(opts, created) + + want := []string{ + filepath.Join(root, "etc"), + filepath.Join(root, "etc", "hfdesk"), + filepath.Join(root, "var"), + filepath.Join(root, "var", "lib"), + filepath.Join(root, "var", "lib", "hfdesk"), + filepath.Join(root, "var", "log"), + filepath.Join(root, "var", "log", "hfdesk"), + } + if !reflect.DeepEqual(order, want) { + t.Errorf("createdDirOrder() = %v, want %v", order, want) + } +} + +// TestLaunchdFailedInstallRemovesTheDirectoriesItCreated checks that a +// failed installation removes the directories it created, so a failed attempt +// leaves the machine as it was. +func TestLaunchdFailedInstallRemovesTheDirectoriesItCreated(t *testing.T) { + darwinFakeSupervisor(t) + root := t.TempDir() + + opts := darwinTempInstallOptions(t, root) + m := darwinStatusManager(t) + if err := m.Install(context.Background(), opts); err == nil { + t.Fatal("Install() = nil, want the failure of the supervisor") + } + + for _, dir := range []string{ + filepath.Join(root, "etc"), + filepath.Join(root, "etc", "hfdesk"), + filepath.Join(root, "var"), + filepath.Join(root, "var", "lib"), + filepath.Join(root, "var", "lib", "hfdesk"), + filepath.Join(root, "var", "log"), + filepath.Join(root, "var", "log", "hfdesk"), + } { + if _, err := os.Stat(dir); !errors.Is(err, fs.ErrNotExist) { + t.Errorf("the directory %q is still there after the failed installation (stat error: %v)", dir, err) + } + } +} + +// TestLaunchdFailedInstallKeepsADirectoryThatExistedBefore checks that a +// failed installation never removes a directory that existed before it: such +// a directory belongs to whoever owns it, with the ownership and the +// permissions it had. +func TestLaunchdFailedInstallKeepsADirectoryThatExistedBefore(t *testing.T) { + darwinFakeSupervisor(t) + root := t.TempDir() + + // The config directory existed before the installation. + config := filepath.Join(root, "etc") + if err := os.Mkdir(config, 0o750); err != nil { + t.Fatalf("create the config directory: %v", err) + } + owner, group := darwinOwner(t, config) + + opts := darwinTempInstallOptions(t, root) + m := darwinStatusManager(t) + if err := m.Install(context.Background(), opts); err == nil { + t.Fatal("Install() = nil, want the failure of the supervisor") + } + + // The directory that existed before the installation is still there, + // with the owner and the permissions it had. + info, err := os.Stat(config) + if err != nil { + t.Fatalf("the config directory %q is gone after the failed installation: %v", config, err) + } + if info.Mode().Perm() != 0o750 { + t.Errorf("the mode of the config directory %q = %v, want %v", config, info.Mode().Perm(), os.FileMode(0o750)) + } + if uid, gid := darwinOwner(t, config); uid != owner || gid != group { + t.Errorf("the ownership of the config directory %q = %d:%d, want %d:%d", config, uid, gid, owner, group) + } + // The directory the installation created inside it is gone. + created := filepath.Join(config, "hfdesk") + if _, err := os.Stat(created); !errors.Is(err, fs.ErrNotExist) { + t.Errorf("the directory %q is still there after the failed installation (stat error: %v)", created, err) + } +} + +// TestLaunchdFailedInstallKeepsACreatedDirectoryThatHoldsData checks that +// the cleanup of a failed installation never deletes data: a directory the +// installation created but that holds something is kept, and the caller +// still gets the original error of the installation. +func TestLaunchdFailedInstallKeepsACreatedDirectoryThatHoldsData(t *testing.T) { + darwinFakeSupervisor(t) + root := t.TempDir() + + opts := darwinTempInstallOptions(t, root) + state := opts.Paths.StateDir + m := darwinStatusManager(t) + + // The error of the failed supervisor call is the error the caller + // gets: the cleanup of the directories never replaces it. + t.Setenv("HFDESK_TEST_MARKER_DIR", t.TempDir()) + _, _, wantErr := m.printState(context.Background()) + if wantErr == nil { + t.Fatal("the fake supervisor did not fail") + } + + // The fake supervisor writes a marker into the state directory, so the + // directory holds data when the installation fails. + t.Setenv("HFDESK_TEST_MARKER_DIR", state) + err := m.Install(context.Background(), opts) + if err == nil { + t.Fatal("Install() = nil, want the failure of the supervisor") + } + if err.Error() != wantErr.Error() { + t.Errorf("Install() = %q, want %q", err, wantErr) + } + + // The state directory holds the marker, so it is kept, and so are the + // directories that hold it. + for _, dir := range []string{ + filepath.Join(root, "var"), + filepath.Join(root, "var", "lib"), + state, + } { + if _, err := os.Stat(dir); err != nil { + t.Errorf("the directory %q is gone after the failed installation: %v", dir, err) + } + } + if _, err := os.Stat(filepath.Join(state, "marker")); err != nil { + t.Errorf("the marker of the state directory is gone: %v", err) + } + // The other directories of the instance are empty, so they are gone. + for _, dir := range []string{ + filepath.Join(root, "etc"), + filepath.Join(root, "etc", "hfdesk"), + filepath.Join(root, "var", "log"), + filepath.Join(root, "var", "log", "hfdesk"), + } { + if _, err := os.Stat(dir); !errors.Is(err, fs.ErrNotExist) { + t.Errorf("the directory %q is still there after the failed installation (stat error: %v)", dir, err) + } + } +} + +// TestLaunchdFailedInstallRemovesTheDirectoriesOfAFailedPreparation checks +// that an installation that fails while it prepares the directories of the +// instance leaves the machine as it was as well: the directories it created +// before the failure are removed too. +func TestLaunchdFailedInstallRemovesTheDirectoriesOfAFailedPreparation(t *testing.T) { + darwinFakeSupervisor(t) + root := t.TempDir() + + opts := darwinTempInstallOptions(t, root) + // The log directory is the root of the file system: the installation + // refuses to tighten it, and it fails after the other two + // directories were created. + opts.Paths.LogDir = string(filepath.Separator) + + m := darwinStatusManager(t) + err := m.Install(context.Background(), opts) + if err == nil { + t.Fatal("Install() = nil, want the refusal of the log directory") + } + want := fmt.Sprintf("service: log dir path %q is the filesystem root and must not be tightened", string(filepath.Separator)) + if err.Error() != want { + t.Errorf("Install() = %q, want %q", err, want) + } + + // The directories the installation created before it failed are gone. + for _, dir := range []string{ + filepath.Join(root, "etc"), + filepath.Join(root, "etc", "hfdesk"), + filepath.Join(root, "var"), + filepath.Join(root, "var", "lib"), + filepath.Join(root, "var", "lib", "hfdesk"), + } { + if _, err := os.Stat(dir); !errors.Is(err, fs.ErrNotExist) { + t.Errorf("the directory %q is still there after the failed installation (stat error: %v)", dir, err) + } + } +} + +// TestLaunchdSuccessfulInstallKeepsItsDirectories checks that a successful +// installation keeps the directories it created: the cleanup of a failed +// installation never touches the directories of an instance that runs. +func TestLaunchdSuccessfulInstallKeepsItsDirectories(t *testing.T) { + darwinFakeSupervisor(t) + t.Setenv("HFDESK_TEST_SUPERVISOR", "ok") + root := t.TempDir() + + opts := darwinTempInstallOptions(t, root) + m := darwinStatusManager(t) + if err := m.Install(context.Background(), opts); err != nil { + t.Fatalf("Install() = %v, want nil", err) + } + + for _, dir := range []string{ + filepath.Join(root, "etc"), + filepath.Join(root, "etc", "hfdesk"), + filepath.Join(root, "var"), + filepath.Join(root, "var", "lib"), + filepath.Join(root, "var", "lib", "hfdesk"), + filepath.Join(root, "var", "log"), + filepath.Join(root, "var", "log", "hfdesk"), + } { + info, err := os.Stat(dir) + if err != nil { + t.Errorf("the directory %q is gone after the successful installation: %v", dir, err) + continue + } + if !info.IsDir() { + t.Errorf("the path %q is not a directory after the successful installation", dir) + } + } +} diff --git a/internal/service/manager_linux.go b/internal/service/manager_linux.go index 04b47a89..9386125e 100644 --- a/internal/service/manager_linux.go +++ b/internal/service/manager_linux.go @@ -431,6 +431,55 @@ func createdDirs(path string) []string { return missing } +// createdDirOrder returns the directories an installation creates, in the +// order it creates them. +// +// The map holds the directories of every directory of the instance. The +// directories of the instance are created in the order of instanceDirs (the +// config directory, the state directory and the log directory) and from the +// top down inside each of them, so the list is the order of the creation. +// +// A directory that several directories of the instance need is listed once: +// the first directory of the instance that needed it is the one that created +// it. +func createdDirOrder(opts InstallOptions, created map[string][]string) []string { + var order []string + seen := make(map[string]bool, len(created)) + for _, dir := range instanceDirs(opts) { + for _, path := range created[dir.path] { + if seen[path] { + continue + } + seen[path] = true + order = append(order, path) + } + } + return order +} + +// removeCreatedDirs removes the directories an installation created. +// +// The directories are removed in the reverse order of their creation, so a +// parent is only removed after its children. A directory that is not empty is +// kept: the removal never deletes data and it never walks into a directory. +// +// The removal is best effort: a failure of the removal is ignored, so the +// original failure of the installation stays the error the caller gets. +func removeCreatedDirs(dirs []string) { + for i := len(dirs) - 1; i >= 0; i-- { + path := dirs[i] + info, err := os.Lstat(path) + if err != nil || !info.IsDir() { + // The directory is gone already, or it is not a directory + // any more: nothing is removed, so no file is ever deleted. + continue + } + // A directory that holds something is kept: the removal of such + // a directory would delete what it holds. + _ = os.Remove(path) + } +} + // machineDir reports whether the directory is a directory of the machine // itself: a top level directory of the file system, like /etc or /usr. // @@ -524,7 +573,9 @@ func (m *systemdManager) grantInstanceDirs(opts InstallOptions, account *user.Us // a model or a cache tree. // // A failure after the unit file was written rolls the unit file back, so an -// installation never leaves a half configured unit behind. +// installation never leaves a half configured unit behind. A failure removes +// the directories the installation created as well, so a failed attempt +// leaves the machine as it was. func (m *systemdManager) Install(ctx context.Context, opts InstallOptions) error { if err := ValidateInstallOptions(opts); err != nil { return err @@ -559,16 +610,39 @@ func (m *systemdManager) Install(ctx context.Context, opts InstallOptions) error // before they are created, so the account is given the ownership of // exactly those and of nothing else. A directory that exists already // belongs to its owner and is left alone. - var created map[string][]string - if account != nil { - created = make(map[string][]string, 3) - for _, dir := range instanceDirs(opts) { - created[dir.path] = createdDirs(dir.path) - } + // + // The list is also what a failed installation removes again, so a + // failed installation leaves the machine as it was. + created := make(map[string][]string, 3) + for _, dir := range instanceDirs(opts) { + created[dir.path] = createdDirs(dir.path) } if err := PrepareDirs(opts); err != nil { + // The preparation may have created some of the directories + // before it failed: they are removed again, so a failed attempt + // leaves the machine as it was. + removeCreatedDirs(createdDirOrder(opts, created)) return err } + // The installation owns the directories it created from here on: a + // failure removes them again, so a failed installation never leaves + // the directories of the instance behind. + if err := m.register(ctx, opts, account, created); err != nil { + removeCreatedDirs(createdDirOrder(opts, created)) + return err + } + return nil +} + +// register writes the registration of the instance and hands it to the +// supervisor. +// +// The directories of the instance are already created when the function is +// called. A failure of the registration is rolled back: the unit file is +// restored and the directories the installation created are removed by the +// caller, so a failed installation leaves no half configured unit and no +// empty directories behind. +func (m *systemdManager) register(ctx context.Context, opts InstallOptions, account *user.User, created map[string][]string) error { // The instance runs as the service account, so the directories it owns // belong to it. if err := m.grantInstanceDirs(opts, account, created); err != nil { diff --git a/internal/service/manager_linux_test.go b/internal/service/manager_linux_test.go index b63bd7a6..aeadfcf9 100644 --- a/internal/service/manager_linux_test.go +++ b/internal/service/manager_linux_test.go @@ -1652,3 +1652,302 @@ func TestSystemdUserScopeNeedsNoServiceAccount(t *testing.T) { } } } + +// systemdFakeSupervisor replaces the supervisor of the machine with a fake +// one, so an installation can be driven through a known point. +// +// The fake fails every call, unless the environment variable +// HFDESK_TEST_SUPERVISOR is "ok". A failing installation therefore stops +// after the directories of the instance were created, which is the point the +// cleanup of a failed installation has to cover. +// +// The fake writes a marker file into the directory of the environment +// variable HFDESK_TEST_MARKER_DIR when that variable names a directory, so +// a test can make a directory of the instance hold data before the +// installation fails. +func systemdFakeSupervisor(t *testing.T) { + t.Helper() + dir := t.TempDir() + script := `#!/bin/sh +if [ -n "$HFDESK_TEST_MARKER_DIR" ]; then + mkdir -p "$HFDESK_TEST_MARKER_DIR" || exit 1 + : > "$HFDESK_TEST_MARKER_DIR/marker" || exit 1 +fi +if [ "$HFDESK_TEST_SUPERVISOR" = "ok" ]; then + exit 0 +fi +echo "the fake supervisor refuses the command" >&2 +exit 1 +` + if err := os.WriteFile(filepath.Join(dir, "systemctl"), []byte(script), 0o755); err != nil { + t.Fatalf("write the fake supervisor: %v", err) + } + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) +} + +// systemdTempInstallOptions returns the options of a user scope installation +// whose directories live below the given root. +// +// The scope is the user scope: it needs neither an elevated shell nor a +// service account, so the test runs everywhere. +func systemdTempInstallOptions(t *testing.T, root string) InstallOptions { + t.Helper() + return InstallOptions{ + Scope: ScopeUser, + Executable: systemdTestExecutable(t), + Listen: DefaultListen, + Paths: runtimepaths.Paths{ + ConfigFile: filepath.Join(root, "etc", "hfdesk", "hfdesk.json"), + StateDir: filepath.Join(root, "var", "lib", "hfdesk"), + LogDir: filepath.Join(root, "var", "log", "hfdesk"), + }, + } +} + +// systemdTempInstallEnvironment points the registration and the home +// directory of the instance at temporary directories, so a test never touches +// the registration of the user of the test. +func systemdTempInstallEnvironment(t *testing.T) { + t.Helper() + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("HOME", t.TempDir()) +} + +// TestSystemdCreatedDirOrder checks the order of the directories a failed +// installation removes: the directories are listed in the order they are +// created, so a parent is always removed after its children. +func TestSystemdCreatedDirOrder(t *testing.T) { + root := t.TempDir() + opts := InstallOptions{ + Scope: ScopeUser, + Executable: systemdTestExecutable(t), + Listen: DefaultListen, + Paths: runtimepaths.Paths{ + ConfigFile: filepath.Join(root, "etc", "hfdesk", "hfdesk.json"), + StateDir: filepath.Join(root, "var", "lib", "hfdesk"), + LogDir: filepath.Join(root, "var", "log", "hfdesk"), + }, + } + + created := make(map[string][]string, 3) + for _, dir := range instanceDirs(opts) { + created[dir.path] = createdDirs(dir.path) + } + order := createdDirOrder(opts, created) + + want := []string{ + filepath.Join(root, "etc"), + filepath.Join(root, "etc", "hfdesk"), + filepath.Join(root, "var"), + filepath.Join(root, "var", "lib"), + filepath.Join(root, "var", "lib", "hfdesk"), + filepath.Join(root, "var", "log"), + filepath.Join(root, "var", "log", "hfdesk"), + } + if strings.Join(order, "\n") != strings.Join(want, "\n") { + t.Errorf("createdDirOrder() =\n%s\nwant\n%s", strings.Join(order, "\n"), strings.Join(want, "\n")) + } +} + +// TestSystemdFailedInstallRemovesTheDirectoriesItCreated checks that a +// failed installation removes the directories it created, so a failed attempt +// leaves the machine as it was. +func TestSystemdFailedInstallRemovesTheDirectoriesItCreated(t *testing.T) { + systemdFakeSupervisor(t) + systemdTempInstallEnvironment(t) + root := t.TempDir() + + opts := systemdTempInstallOptions(t, root) + m := &systemdManager{scope: ScopeUser} + if err := m.Install(context.Background(), opts); err == nil { + t.Fatal("Install() = nil, want the failure of the supervisor") + } + + for _, dir := range []string{ + filepath.Join(root, "etc"), + filepath.Join(root, "etc", "hfdesk"), + filepath.Join(root, "var"), + filepath.Join(root, "var", "lib"), + filepath.Join(root, "var", "lib", "hfdesk"), + filepath.Join(root, "var", "log"), + filepath.Join(root, "var", "log", "hfdesk"), + } { + if _, err := os.Stat(dir); !errors.Is(err, fs.ErrNotExist) { + t.Errorf("the directory %q is still there after the failed installation (stat error: %v)", dir, err) + } + } +} + +// TestSystemdFailedInstallKeepsADirectoryThatExistedBefore checks that a +// failed installation never removes a directory that existed before it: such +// a directory belongs to whoever owns it, with the ownership and the +// permissions it had. +func TestSystemdFailedInstallKeepsADirectoryThatExistedBefore(t *testing.T) { + systemdFakeSupervisor(t) + systemdTempInstallEnvironment(t) + root := t.TempDir() + + // The config directory existed before the installation. + config := filepath.Join(root, "etc") + if err := os.Mkdir(config, 0o750); err != nil { + t.Fatalf("create the config directory: %v", err) + } + owner, group := systemdOwner(t, config) + + opts := systemdTempInstallOptions(t, root) + m := &systemdManager{scope: ScopeUser} + if err := m.Install(context.Background(), opts); err == nil { + t.Fatal("Install() = nil, want the failure of the supervisor") + } + + // The directory that existed before the installation is still there, + // with the owner and the permissions it had. + info, err := os.Stat(config) + if err != nil { + t.Fatalf("the config directory %q is gone after the failed installation: %v", config, err) + } + if info.Mode().Perm() != 0o750 { + t.Errorf("the mode of the config directory %q = %v, want %v", config, info.Mode().Perm(), os.FileMode(0o750)) + } + if uid, gid := systemdOwner(t, config); uid != owner || gid != group { + t.Errorf("the ownership of the config directory %q = %q:%q, want %q:%q", config, uid, gid, owner, group) + } + // The directory the installation created inside it is gone. + created := filepath.Join(config, "hfdesk") + if _, err := os.Stat(created); !errors.Is(err, fs.ErrNotExist) { + t.Errorf("the directory %q is still there after the failed installation (stat error: %v)", created, err) + } +} + +// TestSystemdFailedInstallKeepsACreatedDirectoryThatHoldsData checks that +// the cleanup of a failed installation never deletes data: a directory the +// installation created but that holds something is kept, and the caller +// still gets the original error of the installation. +func TestSystemdFailedInstallKeepsACreatedDirectoryThatHoldsData(t *testing.T) { + systemdFakeSupervisor(t) + systemdTempInstallEnvironment(t) + root := t.TempDir() + + opts := systemdTempInstallOptions(t, root) + state := opts.Paths.StateDir + m := &systemdManager{scope: ScopeUser} + + // The error of the failed supervisor call is the error the caller + // gets: the cleanup of the directories never replaces it. + t.Setenv("HFDESK_TEST_MARKER_DIR", t.TempDir()) + wantErr := m.reloadAndEnable(context.Background()) + if wantErr == nil { + t.Fatal("the fake supervisor did not fail") + } + + // The fake supervisor writes a marker into the state directory, so the + // directory holds data when the installation fails. + t.Setenv("HFDESK_TEST_MARKER_DIR", state) + err := m.Install(context.Background(), opts) + if err == nil { + t.Fatal("Install() = nil, want the failure of the supervisor") + } + if err.Error() != wantErr.Error() { + t.Errorf("Install() = %q, want %q", err, wantErr) + } + + // The state directory holds the marker, so it is kept, and so are the + // directories that hold it. + for _, dir := range []string{ + filepath.Join(root, "var"), + filepath.Join(root, "var", "lib"), + state, + } { + if _, err := os.Stat(dir); err != nil { + t.Errorf("the directory %q is gone after the failed installation: %v", dir, err) + } + } + if _, err := os.Stat(filepath.Join(state, "marker")); err != nil { + t.Errorf("the marker of the state directory is gone: %v", err) + } + // The other directories of the instance are empty, so they are gone. + for _, dir := range []string{ + filepath.Join(root, "etc"), + filepath.Join(root, "etc", "hfdesk"), + filepath.Join(root, "var", "log"), + filepath.Join(root, "var", "log", "hfdesk"), + } { + if _, err := os.Stat(dir); !errors.Is(err, fs.ErrNotExist) { + t.Errorf("the directory %q is still there after the failed installation (stat error: %v)", dir, err) + } + } +} + +// TestSystemdFailedInstallRemovesTheDirectoriesOfAFailedPreparation checks +// that an installation that fails while it prepares the directories of the +// instance leaves the machine as it was as well: the directories it created +// before the failure are removed too. +func TestSystemdFailedInstallRemovesTheDirectoriesOfAFailedPreparation(t *testing.T) { + systemdFakeSupervisor(t) + systemdTempInstallEnvironment(t) + root := t.TempDir() + + opts := systemdTempInstallOptions(t, root) + // The log directory is the root of the file system: the installation + // refuses to tighten it, and it fails after the other two + // directories were created. + opts.Paths.LogDir = string(filepath.Separator) + + m := &systemdManager{scope: ScopeUser} + err := m.Install(context.Background(), opts) + if err == nil { + t.Fatal("Install() = nil, want the refusal of the log directory") + } + want := fmt.Sprintf("service: log dir path %q is the filesystem root and must not be tightened", string(filepath.Separator)) + if err.Error() != want { + t.Errorf("Install() = %q, want %q", err, want) + } + + // The directories the installation created before it failed are gone. + for _, dir := range []string{ + filepath.Join(root, "etc"), + filepath.Join(root, "etc", "hfdesk"), + filepath.Join(root, "var"), + filepath.Join(root, "var", "lib"), + filepath.Join(root, "var", "lib", "hfdesk"), + } { + if _, err := os.Stat(dir); !errors.Is(err, fs.ErrNotExist) { + t.Errorf("the directory %q is still there after the failed installation (stat error: %v)", dir, err) + } + } +} + +// TestSystemdSuccessfulInstallKeepsItsDirectories checks that a successful +// installation keeps the directories it created: the cleanup of a failed +// installation never touches the directories of an instance that runs. +func TestSystemdSuccessfulInstallKeepsItsDirectories(t *testing.T) { + systemdFakeSupervisor(t) + t.Setenv("HFDESK_TEST_SUPERVISOR", "ok") + systemdTempInstallEnvironment(t) + root := t.TempDir() + + opts := systemdTempInstallOptions(t, root) + m := &systemdManager{scope: ScopeUser} + if err := m.Install(context.Background(), opts); err != nil { + t.Fatalf("Install() = %v, want nil", err) + } + + for _, dir := range []string{ + filepath.Join(root, "etc"), + filepath.Join(root, "etc", "hfdesk"), + filepath.Join(root, "var"), + filepath.Join(root, "var", "lib"), + filepath.Join(root, "var", "lib", "hfdesk"), + filepath.Join(root, "var", "log"), + filepath.Join(root, "var", "log", "hfdesk"), + } { + info, err := os.Stat(dir) + if err != nil { + t.Errorf("the directory %q is gone after the successful installation: %v", dir, err) + continue + } + if !info.IsDir() { + t.Errorf("the path %q is not a directory after the successful installation", dir) + } + } +} From 9ead8a4294046487598bf404905c90b5d7bfe389 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 22 Oct 2026 00:55:59 +1100 Subject: [PATCH 23/58] fix(service): read the state of a Windows instance without elevation - The status of a registered instance is read through a query-only connection to the service control manager, so a normal shell can see the state of the instance, including a registration whose executable is gone. Only the operations that change the registration keep the privileged path. Co-Authored-By: opencode --- internal/service/manager_windows.go | 145 ++++++++++-- internal/service/manager_windows_test.go | 288 +++++++++++++++++++++++ 2 files changed, 414 insertions(+), 19 deletions(-) diff --git a/internal/service/manager_windows.go b/internal/service/manager_windows.go index 94d61978..c2350b89 100644 --- a/internal/service/manager_windows.go +++ b/internal/service/manager_windows.go @@ -46,6 +46,12 @@ import ( // the instance, so it can tell an absent registration from a registration // it may not look at. A registration that is not there is a clean success // in every shell. +// +// The status of the instance is read with the rights a status needs only: +// the right to query the state of the instance and the right to query its +// configuration. The default descriptor of a service grants both of them +// to the interactive users of the machine, so a normal shell reads the +// state of a registration without an elevated shell. const ( // scmDisplayName is the name the service manager shows. scmDisplayName = "HFDesk" @@ -363,6 +369,12 @@ func (m *scmManager) Restart(ctx context.Context) error { // Status reports the state of the registered instance. // +// The registration is read with the rights a status needs only: the right +// to query the state of the instance and the right to query its +// configuration. The default descriptor of a service grants both of them +// to the interactive users of the machine, so a normal shell reads the +// state of a registration without an elevated shell. +// // The description of the instance comes from the registration, so the status // describes the registration and not the intent of the caller. A registered // executable that is gone is reported as broken, so a moved or deleted @@ -372,29 +384,128 @@ func (m *scmManager) Restart(ctx context.Context) error { // keeps its database in the registry, so the registration path is the // registry key of the service. func (m *scmManager) Status(ctx context.Context) (Status, error) { - status := Status{ + facts, err := scmLookupRegistration(ServiceName) + if err != nil { + return scmBaseStatus(m.scope), err + } + return scmStatusFrom(m.scope, facts) +} + +// scmBaseStatus returns the status of the instance without the facts of a +// registration. +// +// The status names the instance and its registration even when the +// registration could not be read: the reader sees which instance the +// answer is about. +func scmBaseStatus(scope Scope) Status { + return Status{ State: StateNotInstalled, - Scope: m.scope, + Scope: scope, Name: ServiceName, UnitPath: scmRegistrationPath, } +} + +// scmRegistrationFacts are the facts of a registration the status needs. +// +// The facts are read with the rights a status needs only, so a normal +// shell can read them. +type scmRegistrationFacts struct { + // present reports whether the instance is registered at all. + present bool + // state is the state the service control manager reports. + state svc.State + // exitCode is the exit code the instance ended with. + exitCode uint32 + // commandLine is the command line the registration carries. + commandLine string +} - manager, handle, ok, err := m.openRegistration("a status") +// scmLookupRegistration reads the facts of the registration of the given +// name with the rights a status needs. +// +// The service control manager is opened with the right to connect only and +// the registration with the right to query its state and the right to +// query its configuration. The privileged connection of the platform asks +// for every right instead, which a normal shell does not have. +// +// An absent registration is not an error: it is the state the caller asked +// about, and it is reported as such. A registration the caller may not +// look at is reported as the elevation requirement it is: the raw access +// denied error of the platform does not tell the user what to do. +func scmLookupRegistration(name string) (scmRegistrationFacts, error) { + facts := scmRegistrationFacts{} + + manager, err := windows.OpenSCManager(nil, nil, windows.SC_MANAGER_CONNECT) if err != nil { - return status, err + return facts, scmLookupError("connect to the service control manager", err) } - if !ok { - status.Detail = fmt.Sprintf("%s is not registered", ServiceName) - return status, nil + defer windows.CloseServiceHandle(manager) + + pointer, err := windows.UTF16PtrFromString(name) + if err != nil { + return facts, fmt.Errorf("service: the name of the service cannot be read: %w", err) } - defer manager.Disconnect() - defer handle.Close() + handle, err := windows.OpenService(manager, pointer, windows.SERVICE_QUERY_STATUS|windows.SERVICE_QUERY_CONFIG) + if err != nil { + if errors.Is(err, windows.ERROR_SERVICE_DOES_NOT_EXIST) { + // The instance is not registered: that is the state the + // caller asked about, not a failure. + return facts, nil + } + return facts, scmLookupError(fmt.Sprintf("open the registration of %s", name), err) + } + defer windows.CloseServiceHandle(handle) - commandLine, err := queryServiceCommandLine(handle.Handle) + var query windows.SERVICE_STATUS + if err := windows.QueryServiceStatus(handle, &query); err != nil { + return facts, scmLookupError(fmt.Sprintf("query the state of %s", name), err) + } + commandLine, err := queryServiceCommandLine(handle) if err != nil { - return status, fmt.Errorf("service: read the registration of %s: %w", ServiceName, err) + return facts, scmLookupError(fmt.Sprintf("read the registration of %s", name), err) + } + + facts.present = true + facts.state = svc.State(query.CurrentState) + facts.exitCode = query.Win32ExitCode + facts.commandLine = commandLine + return facts, nil +} + +// scmLookupError reports the reason of a failed read-only lookup. +// +// A refusal of the platform because the shell has no rights on the +// registration is reported as the elevation requirement it is: the raw +// access denied error of the platform does not tell the user what to do. +func scmLookupError(what string, err error) error { + if err == nil { + return nil } - registration, err := parseSCMCommandLine(commandLine) + if errors.Is(err, windows.ERROR_ACCESS_DENIED) { + return scmElevationRequired("a status") + } + return fmt.Errorf("service: %s: %w", what, err) +} + +// scmStatusFrom builds the status of the instance out of the facts of a +// registration. +// +// The mapping is a pure function of the facts, so it can be checked +// without a registration of its own: reading the command line of a +// registration needs a real service in the service control manager, which +// a test cannot create without an elevated shell. +// +// An absent registration is reported as not installed: the registration is +// not there, so there is nothing to describe. +func scmStatusFrom(scope Scope, facts scmRegistrationFacts) (Status, error) { + status := scmBaseStatus(scope) + if !facts.present { + status.Detail = fmt.Sprintf("%s is not registered", ServiceName) + return status, nil + } + + registration, err := parseSCMCommandLine(facts.commandLine) if err != nil { return status, err } @@ -406,13 +517,9 @@ func (m *scmManager) Status(ctx context.Context) (Status, error) { return status, nil } - query, err := handle.Query() - if err != nil { - return status, fmt.Errorf("service: query the state of %s: %w", ServiceName, err) - } - status.State = scmState(query.State) - if status.State == StateStopped && query.Win32ExitCode != 0 { - status.Detail = fmt.Sprintf("the instance exited with the code %d", query.Win32ExitCode) + status.State = scmState(facts.state) + if status.State == StateStopped && facts.exitCode != 0 { + status.Detail = fmt.Sprintf("the instance exited with the code %d", facts.exitCode) } return status, nil } diff --git a/internal/service/manager_windows_test.go b/internal/service/manager_windows_test.go index 225f9dcf..2500ff66 100644 --- a/internal/service/manager_windows_test.go +++ b/internal/service/manager_windows_test.go @@ -583,6 +583,294 @@ func TestSCMLifecycle(t *testing.T) { } } +// TestSCMStatusFromRegistrationReportsTheStateAndTheCommandLine checks +// that the status of a registered instance is built out of the facts of a +// read-only lookup. +// +// The mapping is checked without a registration of its own: reading the +// command line of a registration needs a real service in the service +// control manager, which a test cannot create without an elevated shell. +func TestSCMStatusFromRegistrationReportsTheStateAndTheCommandLine(t *testing.T) { + root := t.TempDir() + opts := scmTestOptions() + opts.Executable = filepath.Join(root, "hfdesk.exe") + if err := os.WriteFile(opts.Executable, []byte("not a program"), 0o600); err != nil { + t.Fatalf("WriteFile() = %v, want nil", err) + } + + facts := scmRegistrationFacts{ + present: true, + state: svc.Running, + commandLine: scmCommandLine(opts), + } + + status, err := scmStatusFrom(ScopeSystem, facts) + if err != nil { + t.Fatalf("scmStatusFrom() = %v, want nil", err) + } + if status.State != StateRunning { + t.Errorf("the state = %q, want %q", status.State, StateRunning) + } + if status.Scope != ScopeSystem { + t.Errorf("the scope = %q, want %q", status.Scope, ScopeSystem) + } + if status.Name != ServiceName { + t.Errorf("the name = %q, want %q", status.Name, ServiceName) + } + if status.UnitPath != scmRegistrationPath { + t.Errorf("the registration path = %q, want %q", status.UnitPath, scmRegistrationPath) + } + if status.Executable != opts.Executable { + t.Errorf("the registered executable = %q, want %q", status.Executable, opts.Executable) + } + if status.Listen != opts.Listen { + t.Errorf("the registered listen address = %q, want %q", status.Listen, opts.Listen) + } + if status.Paths != opts.Paths { + t.Errorf("the registered paths = %+v, want %+v", status.Paths, opts.Paths) + } + if status.CacheDir != opts.CacheDir { + t.Errorf("the cache dir = %q, want %q", status.CacheDir, opts.CacheDir) + } +} + +// TestSCMStatusFromAnAbsentRegistrationIsNotInstalled checks that an +// instance that is not registered is reported as not installed, without an +// error. +func TestSCMStatusFromAnAbsentRegistrationIsNotInstalled(t *testing.T) { + status, err := scmStatusFrom(ScopeSystem, scmRegistrationFacts{}) + if err != nil { + t.Fatalf("scmStatusFrom() = %v, want nil for an absent registration", err) + } + if status.State != StateNotInstalled { + t.Errorf("the state = %q, want %q", status.State, StateNotInstalled) + } + if status.Scope != ScopeSystem { + t.Errorf("the scope = %q, want %q", status.Scope, ScopeSystem) + } + if status.Name != ServiceName { + t.Errorf("the name = %q, want %q", status.Name, ServiceName) + } + if !strings.Contains(status.Detail, ServiceName) { + t.Errorf("the detail = %q, want the name of the missing registration %q", status.Detail, ServiceName) + } +} + +// TestSCMStatusFromABrokenRegistrationNamesTheMissingExecutable checks +// that a registration that points at a program that is gone is reported as +// broken, and that the detail names the missing program. +// +// The broken registration is the case a normal shell has to be able to +// see: the registration is readable, the program behind it is not there +// anymore. +func TestSCMStatusFromABrokenRegistrationNamesTheMissingExecutable(t *testing.T) { + root := t.TempDir() + missing := filepath.Join(root, "gone", "hfdesk.exe") + opts := scmTestOptions() + opts.Executable = missing + + facts := scmRegistrationFacts{ + present: true, + state: svc.Stopped, + commandLine: scmCommandLine(opts), + } + + status, err := scmStatusFrom(ScopeSystem, facts) + if err != nil { + t.Fatalf("scmStatusFrom() = %v, want nil", err) + } + if status.State != StateBroken { + t.Errorf("the state = %q, want %q", status.State, StateBroken) + } + if !strings.Contains(status.Detail, missing) { + t.Errorf("the detail = %q, want the path of the missing executable %q", status.Detail, missing) + } +} + +// TestSCMStatusFromAStoppedInstanceNamesTheExitCode checks that the exit +// code of a stopped instance is reported. +func TestSCMStatusFromAStoppedInstanceNamesTheExitCode(t *testing.T) { + root := t.TempDir() + opts := scmTestOptions() + opts.Executable = filepath.Join(root, "hfdesk.exe") + if err := os.WriteFile(opts.Executable, []byte("not a program"), 0o600); err != nil { + t.Fatalf("WriteFile() = %v, want nil", err) + } + + facts := scmRegistrationFacts{ + present: true, + state: svc.Stopped, + exitCode: 3, + commandLine: scmCommandLine(opts), + } + + status, err := scmStatusFrom(ScopeSystem, facts) + if err != nil { + t.Fatalf("scmStatusFrom() = %v, want nil", err) + } + if status.State != StateStopped { + t.Errorf("the state = %q, want %q", status.State, StateStopped) + } + if !strings.Contains(status.Detail, "3") { + t.Errorf("the detail = %q, want the exit code of the instance", status.Detail) + } +} + +// TestSCMLookupErrorReportsTheElevationRequirement checks that a caller +// that may not read the registration is told about the elevation +// requirement, and not about the raw access denied error of the platform. +func TestSCMLookupErrorReportsTheElevationRequirement(t *testing.T) { + err := scmLookupError("open the registration of "+ServiceName, windows.ERROR_ACCESS_DENIED) + if err == nil { + t.Fatal("scmLookupError(access denied) = nil, want an error") + } + if !strings.Contains(err.Error(), "elevated") { + t.Errorf("scmLookupError(access denied) = %v, want an error that names the elevated shell", err) + } + if !strings.Contains(err.Error(), "administrator") { + t.Errorf("scmLookupError(access denied) = %v, want an error that tells the user what to do", err) + } + if errors.Is(err, windows.ERROR_ACCESS_DENIED) { + t.Errorf("scmLookupError(access denied) = %v, want the elevation requirement and not the raw access denied error", err) + } + + // A failure that is not about the rights of the shell is reported as + // it is. + other := errors.New("the service control manager is gone") + if got := scmLookupError("connect to the service control manager", other); !errors.Is(got, other) { + t.Errorf("scmLookupError(%v) = %v, want the error itself", other, got) + } + if got := scmLookupError("connect to the service control manager", nil); got != nil { + t.Errorf("scmLookupError(nil) = %v, want nil", got) + } +} + +// TestSCMLookupRegistrationReportsAnAbsentRegistration checks the +// read-only lookup against the service control manager of the machine: a +// name that cannot be registered is reported as absent, without an error. +func TestSCMLookupRegistrationReportsAnAbsentRegistration(t *testing.T) { + name := fmt.Sprintf("HfdeskTestAbsentRegistration%d", time.Now().UnixNano()) + facts, err := scmLookupRegistration(name) + if err != nil { + t.Skipf("the service control manager is not available: %v", err) + } + if facts.present { + t.Errorf("scmLookupRegistration(%q) = a registered instance, want an absent registration", name) + } + + status, err := scmStatusFrom(ScopeSystem, facts) + if err != nil { + t.Fatalf("scmStatusFrom() = %v, want nil for an absent registration", err) + } + if status.State != StateNotInstalled { + t.Errorf("the state = %q, want %q", status.State, StateNotInstalled) + } +} + +// TestSCMLookupRegistrationReadsARegisteredService checks the read-only +// lookup against the service control manager of the machine: a service of +// the operating system is reported with its state and its registered +// command line. +// +// The lookup needs no elevated shell: it opens the service control manager +// with the right to connect and the registration with the right to query +// its state and its configuration. +func TestSCMLookupRegistrationReadsARegisteredService(t *testing.T) { + name := scmTestFindReadableService(t) + facts, err := scmLookupRegistration(name) + if err != nil { + t.Fatalf("scmLookupRegistration(%q) = %v, want nil", name, err) + } + if !facts.present { + t.Errorf("scmLookupRegistration(%q) = an absent registration, want a registered one", name) + } + if facts.commandLine == "" { + t.Errorf("the command line of %q is empty, want the registered command line", name) + } + if facts.state == 0 { + t.Errorf("the state of %q = %d, want a state of the supervisor", name, facts.state) + } +} + +// TestSCMLookupRegistrationNeedsNoElevatedShell checks that the read-only +// lookup reads a registration where the privileged connection of the +// platform is refused. +// +// The privileged connection asks for every right of the service control +// manager, so a normal shell does not have it. The read-only lookup opens +// the manager with the right to connect and the registration with the +// rights a status needs, so it reads the state of a registration in a +// normal shell. +func TestSCMLookupRegistrationNeedsNoElevatedShell(t *testing.T) { + manager, err := mgr.Connect() + if err == nil { + manager.Disconnect() + t.Skip("the shell of the test is elevated, the read-only path cannot be told apart from the privileged one") + } + if !errors.Is(err, windows.ERROR_ACCESS_DENIED) { + t.Skipf("the service control manager is not available: %v", err) + } + + name := scmTestFindReadableService(t) + facts, err := scmLookupRegistration(name) + if err != nil { + t.Fatalf("scmLookupRegistration(%q) = %v, want nil", name, err) + } + if !facts.present { + t.Errorf("scmLookupRegistration(%q) = an absent registration, want a registered one", name) + } +} + +// scmTestFindReadableService returns the name of a service of the +// operating system that this shell may read. +// +// The candidates are the services every installation of the operating +// system carries. The test is skipped when the machine carries none of +// them that this shell may read. +func scmTestFindReadableService(t *testing.T) string { + t.Helper() + for _, candidate := range []string{"EventLog", "RpcSs", "Dhcp", "W32Time", "NlaSvc", "Schedule", "Power"} { + facts, err := scmLookupRegistration(candidate) + if err != nil || !facts.present || facts.commandLine == "" { + // The service is not there or not readable from this + // shell: the next candidate is tried. + continue + } + return candidate + } + t.Skip("the machine carries no service that this shell may read") + return "" +} + +// TestSCMStatusOfAnAbsentRegistrationIsNotInstalled checks the status of +// an instance that is not registered: it reports that the instance is not +// installed, and it does so without an error and without an elevated +// shell. +// +// The test is skipped when the instance is registered, so it never reads a +// real installation. +func TestSCMStatusOfAnAbsentRegistrationIsNotInstalled(t *testing.T) { + state, err := scmRegistrationStateOf() + if err != nil { + t.Skipf("the service control manager is not available: %v", err) + } + if state != scmRegistrationAbsent { + t.Skipf("%s is registered, the test would read a real installation", ServiceName) + } + + manager := &scmManager{scope: ScopeSystem} + status, err := manager.Status(context.Background()) + if err != nil { + t.Fatalf("Status() = %v, want nil for an absent registration", err) + } + if status.State != StateNotInstalled { + t.Errorf("the state = %q, want %q", status.State, StateNotInstalled) + } + if status.Name != ServiceName { + t.Errorf("the name = %q, want %q", status.Name, ServiceName) + } +} + // TestSCMBrokenRegistration checks that a registration that points at a // program that is gone is reported as broken. // From e7a58f69bc5a95c7992bf0a4e19b792db84e3c1c Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 22 Oct 2026 00:55:59 +1100 Subject: [PATCH 24/58] workflow: run the race detector over the suite - The service mode adds process-global mutable state, so the Linux job runs the race detector over the whole suite. Co-Authored-By: opencode --- .github/workflows/ci.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f5310572..ea27c608 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,13 @@ jobs: - name: Test run: go test ./... + # The service mode adds process-global mutable state (the installed + # runtime path model behind a lock, and the standard logger's output + # being redirected by the service host), so the race detector must run + # over the whole suite on the Linux runner. + - name: Test (race detector) + run: go test ./... -race + # The Go jobs never touch the Docker surface. Exercise the real entrypoint # image so PUID/PGID, the non-root drop, the --user path, symlink refusal, and # argument validation cannot regress silently. From 4c75b00ce600b4311b9ca715827dae264a2e78f0 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 22 Oct 2026 01:36:58 +1100 Subject: [PATCH 25/58] fix(cmd): honour the documented exit codes of the service family - A command line that cannot be understood exits with 2 on every subcommand: an unknown flag, mutually exclusive scope selectors, a relative path, an unusable executable, an unparsable listen address. The failure of an operation keeps exit code 1. - Report honestly that there was no registration to remove when the uninstall found nothing. Co-Authored-By: opencode --- cmd/hfdesk/service.go | 173 +++++++++++++++++----- cmd/hfdesk/service_test.go | 294 ++++++++++++++++++++++++++++++++++--- 2 files changed, 406 insertions(+), 61 deletions(-) diff --git a/cmd/hfdesk/service.go b/cmd/hfdesk/service.go index f998cf3a..71c4c479 100644 --- a/cmd/hfdesk/service.go +++ b/cmd/hfdesk/service.go @@ -77,6 +77,45 @@ var serviceSubcommands = []string{ // is not a failure: the command exits with 0. var errHelpRequested = errors.New("help requested") +// serviceUsageError reports a command line that could not be understood. +// +// The type is the marker of the exit code 2: the family answers a command +// line it cannot understand with the exit code 2 and an operation that was +// understood but failed with the exit code 1. The kind of the failure +// travels with the error, so the classification is the same for every +// subcommand and never depends on the call site that reports the error. +type serviceUsageError struct { + err error +} + +// newServiceUsageError marks an error as a command line that could not be +// understood. +func newServiceUsageError(err error) error { + if err == nil { + return nil + } + return &serviceUsageError{err: err} +} + +// Error renders the message of the wrapped error. +func (e *serviceUsageError) Error() string { return e.err.Error() } + +// Unwrap gives access to the wrapped error. +func (e *serviceUsageError) Unwrap() error { return e.err } + +// isServiceUsageError reports whether the error reports a command line that +// could not be understood. +func isServiceUsageError(err error) bool { + var usage *serviceUsageError + return errors.As(err, &usage) +} + +// newServiceManager opens the manager of the platform supervisor of a scope. +// +// The variable is the seam of the tests: the command family is exercised +// without touching the system of the test run. +var newServiceManager = service.New + // versionLikeSegment matches a "digit.digit" segment of a file name. // // The heuristic is deliberately simple: a release artifact carries its @@ -101,9 +140,9 @@ func isServiceCommand(args []string) bool { // starting a process. func runServiceCommand(args []string, stdout, stderr io.Writer) int { if len(args) == 0 { - fmt.Fprintln(stderr, "hfdesk service: a subcommand is required") + code := serviceCommandResult(stderr, newServiceUsageError(errors.New("a subcommand is required"))) writeServiceUsage(stderr) - return serviceUsageExitCode + return code } sub, rest := args[0], args[1:] @@ -127,25 +166,33 @@ func runServiceCommand(args []string, stdout, stderr io.Writer) int { writeServiceUsage(stdout) return 0 default: - fmt.Fprintf(stderr, "hfdesk service: unknown subcommand %q\n", sub) + code := serviceCommandResult(stderr, newServiceUsageError(fmt.Errorf("unknown subcommand %q", sub))) fmt.Fprintf(stderr, "Valid subcommands: %s\n", strings.Join(serviceSubcommands, ", ")) writeServiceUsage(stderr) - return serviceUsageExitCode + return code } } // serviceCommandResult prints the error of a failed command and returns the // exit code of the process. +// +// The exit code is the kind of the failure and nothing else: a command line +// that could not be understood exits with 2, an operation that was +// understood but failed exits with 1. The kind travels with the error, so +// every subcommand classifies a failure the same way. func serviceCommandResult(stderr io.Writer, err error) int { switch { case err == nil: return 0 case errors.Is(err, errHelpRequested): return 0 - default: - fmt.Fprintf(stderr, "hfdesk service: %s\n", serviceErrorText(err)) - return serviceFailureExitCode } + + fmt.Fprintf(stderr, "hfdesk service: %s\n", serviceErrorText(err)) + if isServiceUsageError(err) { + return serviceUsageExitCode + } + return serviceFailureExitCode } // serviceErrorText renders an error of the service layer for the command @@ -192,8 +239,10 @@ Options of the "install" subcommand: Exit codes: 0 the operation succeeded - 1 the operation failed - 2 the command line could not be understood + 1 the operation failed: the service is not installed, the elevation is + missing, the supervisor refused the operation + 2 the command line could not be understood: an unknown subcommand or + flag, a relative path, an unusable --executable, an unparsable --listen "hfdesk service status" exits with 0 when the service is running, stopped, starting or stopping, and with 1 when it is not installed, broken, failed or @@ -209,7 +258,7 @@ func writeSubcommandUsage(w io.Writer, fs *flag.FlagSet, summary string) { fmt.Fprintf(w, "Usage: %s [options]\n\n%s\n\nOptions:\n", fs.Name(), summary) fs.SetOutput(w) fs.PrintDefaults() - fmt.Fprint(w, "\nExit codes: 0 success, 1 failed operation, 2 invalid command line.\n") + fmt.Fprint(w, "\nExit codes: 0 the operation succeeded, 1 the operation failed, 2 the command line could not be understood.\n") } // writeField writes one "Key: value" line of a summary block. The keys are @@ -249,13 +298,16 @@ func defaultScope() service.Scope { // // The selectors are mutually exclusive: two scopes are two installations, // so an ambiguous command line is rejected instead of picking one of them. +// +// A rejected selector is a command line that could not be understood, so the +// error carries that kind. func resolveScope(user, system bool) (service.Scope, error) { switch { case user && system: - return "", errors.New("--user and --system are mutually exclusive, choose one scope") + return "", newServiceUsageError(errors.New("--user and --system are mutually exclusive, choose one scope")) case user: if isWindows() { - return "", errors.New("--user is not available on windows: the service control manager registers machine-wide services only, use --system") + return "", newServiceUsageError(errors.New("--user is not available on windows: the service control manager registers machine-wide services only, use --system")) } return service.ScopeUser, nil case system: @@ -288,13 +340,16 @@ type pathFlag struct { // A supervised process must not resolve a path against the working // directory of the supervisor, so a relative path is a mistake and not a // default. +// +// A rejected path is a command line that could not be understood, so the +// error carries that kind. func validateAbsolutePaths(paths []pathFlag) error { for _, p := range paths { if p.value == "" { continue } if !filepath.IsAbs(p.value) { - return fmt.Errorf("%s %q must be an absolute path", p.name, p.value) + return newServiceUsageError(fmt.Errorf("%s %q must be an absolute path", p.name, p.value)) } } return nil @@ -306,10 +361,13 @@ func validateAbsolutePaths(paths []pathFlag) error { // The rules are the rules of the service layer: the address must name a // host and a port, and an empty host is rejected because it binds every // interface. +// +// A rejected address is a command line that could not be understood, so the +// error carries that kind. func validateListen(listen string) (string, error) { host, port, err := service.ParseListen(listen) if err != nil { - return "", err + return "", newServiceUsageError(err) } return net.JoinHostPort(host, strconv.Itoa(port)), nil } @@ -331,6 +389,11 @@ func validateListen(listen string) (string, error) { // It is always a real file, but it is the target of a symbolic link, so a // registration that uses it points at a version-stamped release artifact // instead of at the stable name of the installation. +// +// A named path that cannot be registered is a command line that could not be +// understood, so its error carries that kind. A binary of the process that +// cannot be located is not a mistake of the command line: the operation +// failed, so its error keeps the kind of a failed operation. func resolveExecutable(explicit, invocation string) (string, error) { if explicit != "" { return validateExecutablePath(explicit) @@ -386,19 +449,22 @@ func hasPathSeparator(path string) bool { // The path must be absolute (a supervised process must not resolve a // relative path against the working directory of the supervisor), it must // exist, it must be a regular file and it must be executable. +// +// A rejected path is a command line that could not be understood, so the +// error carries that kind. func validateExecutablePath(path string) (string, error) { if !filepath.IsAbs(path) { - return "", fmt.Errorf("the executable %q must be an absolute path", path) + return "", newServiceUsageError(fmt.Errorf("the executable %q must be an absolute path", path)) } info, err := os.Stat(path) if err != nil { - return "", fmt.Errorf("the executable %q is not usable: %w", path, err) + return "", newServiceUsageError(fmt.Errorf("the executable %q is not usable: %w", path, err)) } if !info.Mode().IsRegular() { - return "", fmt.Errorf("the executable %q is not a regular file", path) + return "", newServiceUsageError(fmt.Errorf("the executable %q is not a regular file", path)) } if info.Mode().Perm()&0o111 == 0 { - return "", fmt.Errorf("the executable %q is not executable", path) + return "", newServiceUsageError(fmt.Errorf("the executable %q is not executable", path)) } return path, nil } @@ -439,9 +505,12 @@ type installFlags struct { // The service account belongs to the machine-wide scope only: a user scope // instance runs as the user itself, so naming an account there is a // mistake of the command line and not a default. +// +// A named account of a user scope instance is a command line that could not +// be understood, so the error carries that kind. func buildInstallOptions(scope service.Scope, flags installFlags, executable string) (service.InstallOptions, error) { if flags.serviceUser != "" && scope == service.ScopeUser { - return service.InstallOptions{}, errors.New("--service-user applies to a machine-wide installation only: a user scope instance runs as the user itself, drop the option or use --system") + return service.InstallOptions{}, newServiceUsageError(errors.New("--service-user applies to a machine-wide installation only: a user scope instance runs as the user itself, drop the option or use --system")) } paths, err := service.DefaultPaths(scope) @@ -513,10 +582,10 @@ func serviceInstall(args []string, stdout io.Writer) error { writeSubcommandUsage(stdout, fs, "Register HFDesk with the platform supervisor of the scope.\n\nThe registration is written. The summary reports the state the supervisor\nreached: a registration that starts the instance starts it.") return errHelpRequested } - return fmt.Errorf("invalid command line: %w", err) + return newServiceUsageError(fmt.Errorf("invalid command line: %w", err)) } if rest := fs.Args(); len(rest) > 0 { - return fmt.Errorf("unexpected argument %q, the command takes flags only", rest[0]) + return newServiceUsageError(fmt.Errorf("unexpected argument %q, the command takes flags only", rest[0])) } scope, err := resolveScope(flags.user, flags.system) @@ -551,7 +620,7 @@ func serviceInstall(args []string, stdout io.Writer) error { return err } - mgr, err := service.New(scope) + mgr, err := newServiceManager(scope) if err != nil { return err } @@ -724,24 +793,34 @@ func serviceUninstall(args []string, stdout io.Writer) error { defer stop() // The paths of the registration are read before it is removed, so the - // report can name the directories the data stays in. - paths, cacheRoot := instanceDataPaths(ctx, mgr, scope) + // report can name the directories the data stays in. The state of the + // scope is read as well, so the report can state what the removal did. + paths, cacheRoot, registered := instanceDataPaths(ctx, mgr, scope) if err := mgr.Uninstall(ctx); err != nil { return err } - writeUninstallSummary(stdout, scope, paths, cacheRoot) + writeUninstallSummary(stdout, scope, paths, cacheRoot, registered) return nil } // writeUninstallSummary prints what was removed and what was kept. // +// The summary states what the uninstallation did: a registration that was +// there is removed, and a scope without a registration has nothing to +// remove. A summary that always claims a removal hides the difference +// between the two. +// // The directories of the kept data are named, so the reader knows where the // models, the cache, the config file, the job history and the logs of the // instance stay. -func writeUninstallSummary(w io.Writer, scope service.Scope, paths runtimepaths.Paths, cacheRoot string) { - fmt.Fprintf(w, "The registration of %s (%s scope) was removed.\n", service.ServiceName, scope) +func writeUninstallSummary(w io.Writer, scope service.Scope, paths runtimepaths.Paths, cacheRoot string, registered bool) { + if registered { + fmt.Fprintf(w, "The registration of %s (%s scope) was removed.\n", service.ServiceName, scope) + } else { + fmt.Fprintf(w, "There was no registration of %s (%s scope) to remove.\n", service.ServiceName, scope) + } fmt.Fprintln(w, "The user data was kept: models, cache, config, jobs/history and logs are still there.") writeField(w, "Config file", paths.ConfigFile) writeField(w, "State dir", paths.StateDir) @@ -750,7 +829,7 @@ func writeUninstallSummary(w io.Writer, scope service.Scope, paths runtimepaths. } // instanceDataPaths returns the runtime paths and the cache root of the -// instance of the scope. +// instance of the scope, and whether the scope had a registration. // // The registration is read first, so the report names the directories the // data of the registration stays in. A registration that cannot be read @@ -759,13 +838,21 @@ func writeUninstallSummary(w io.Writer, scope service.Scope, paths runtimepaths. // The cache root is the effective one of the instance: the default of the // scope and the personal cache of the invoking user are never reported // instead, because the instance does not use them. -func instanceDataPaths(ctx context.Context, mgr service.Manager, scope service.Scope) (runtimepaths.Paths, string) { +// +// A scope without a registration is reported as such: the supervisor answers +// it with the state "not-installed", and the removal of such a scope has +// nothing to remove. A scope whose state the supervisor did not report +// counts as a registration: the removal succeeded, so whatever was +// registered is gone. +func instanceDataPaths(ctx context.Context, mgr service.Manager, scope service.Scope) (runtimepaths.Paths, string, bool) { paths, err := service.DefaultPaths(scope) if err != nil { paths = runtimepaths.Paths{} } cacheRoot := cacheRootNotRecorded + registered := true if status, statusErr := mgr.Status(ctx); statusErr == nil { + registered = status.State != service.StateNotInstalled if status.Paths.ConfigFile != "" { paths.ConfigFile = status.Paths.ConfigFile } @@ -777,7 +864,7 @@ func instanceDataPaths(ctx context.Context, mgr service.Manager, scope service.S } cacheRoot = effectiveCacheRoot(status) } - return paths, cacheRoot + return paths, cacheRoot, registered } // effectiveCacheRoot returns the Hugging Face cache root the instance uses. @@ -800,6 +887,10 @@ func effectiveCacheRoot(status service.Status) string { // newLifecycleManager prepares a lifecycle command: it parses the scope // selector and opens the manager of the scope. +// +// A command line the parser rejects is a command line that could not be +// understood, so its error carries that kind. An error of the platform +// manager is a failed operation and keeps the kind of one. func newLifecycleManager(name string, args []string, stdout io.Writer, summary string) (service.Manager, service.Scope, error) { flags := scopeFlags{} fs := flag.NewFlagSet(name, flag.ContinueOnError) @@ -811,17 +902,17 @@ func newLifecycleManager(name string, args []string, stdout io.Writer, summary s writeSubcommandUsage(stdout, fs, summary) return nil, "", errHelpRequested } - return nil, "", fmt.Errorf("invalid command line: %w", err) + return nil, "", newServiceUsageError(fmt.Errorf("invalid command line: %w", err)) } if rest := fs.Args(); len(rest) > 0 { - return nil, "", fmt.Errorf("unexpected argument %q, the command takes flags only", rest[0]) + return nil, "", newServiceUsageError(fmt.Errorf("unexpected argument %q, the command takes flags only", rest[0])) } scope, err := resolveScope(flags.user, flags.system) if err != nil { return nil, "", err } - mgr, err := service.New(scope) + mgr, err := newServiceManager(scope) if err != nil { return nil, "", err } @@ -902,7 +993,10 @@ func writeLifecycleState(ctx context.Context, mgr service.Manager, w io.Writer) // instance that is not installed, broken, failed or unknown. func serviceStatus(args []string, stdout, stderr io.Writer) int { mgr, scope, err := newLifecycleManager("hfdesk service status", args, stdout, - "Report the scope, the state and the paths of the instance.") + "Report the scope, the state and the paths of the instance.\n\n"+ + "The exit code tells a script whether the instance is in a usable state:\n"+ + "0 for a running, stopped, starting or stopping instance, 1 for an\n"+ + "instance that is not installed, broken, failed or in an unknown state.") if err != nil { return serviceCommandResult(stderr, err) } @@ -913,8 +1007,7 @@ func serviceStatus(args []string, stdout, stderr io.Writer) int { status, statusErr := mgr.Status(ctx) renderStatus(stdout, scope, status) if statusErr != nil { - fmt.Fprintf(stderr, "hfdesk service: %s\n", serviceErrorText(statusErr)) - return serviceFailureExitCode + return serviceCommandResult(stderr, statusErr) } return statusExitCode(status.State) } @@ -1074,7 +1167,7 @@ func serviceRun(args []string, stdout io.Writer) error { writeSubcommandUsage(stdout, fs, "Run the headless host of an instance.\n\nThis mode is internal: the supervisor invokes it. It never opens a browser\nand never creates a tray icon.") return errHelpRequested } - return fmt.Errorf("invalid command line: %w", err) + return newServiceUsageError(fmt.Errorf("invalid command line: %w", err)) } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) @@ -1098,10 +1191,14 @@ func registerRunFlags(fs *flag.FlagSet) { // // The host never opens a browser and never creates a tray icon: the // instance is supervised and there is no desktop session to interact with. +// +// An argument vector the parser rejects is a command line that could not be +// understood, so its error carries that kind. A host that cannot run is a +// failed operation and keeps the kind of one. func runServiceHost(ctx context.Context, args []string) error { opts, err := service.ParseRunArgs(args) if err != nil { - return err + return newServiceUsageError(err) } return service.RunProcess(ctx, opts) } diff --git a/cmd/hfdesk/service_test.go b/cmd/hfdesk/service_test.go index 6f961ffe..095e57b6 100644 --- a/cmd/hfdesk/service_test.go +++ b/cmd/hfdesk/service_test.go @@ -8,6 +8,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "net" "os" "path/filepath" @@ -32,22 +33,39 @@ var interactiveFlagPattern = regexp.MustCompile(`flag\.\w+Var\(&\w+, "([^"]+)"`) type fakeManager struct { status service.Status err error + // opErr is the failure of every lifecycle operation. It is the + // answer of a supervisor that refuses the operation, e.g. a service + // that is not installed. + opErr error } -func (m fakeManager) Install(context.Context, service.InstallOptions) error { return nil } +func (m fakeManager) Install(context.Context, service.InstallOptions) error { return m.opErr } -func (m fakeManager) Uninstall(context.Context) error { return nil } +func (m fakeManager) Uninstall(context.Context) error { return m.opErr } -func (m fakeManager) Start(context.Context) error { return nil } +func (m fakeManager) Start(context.Context) error { return m.opErr } -func (m fakeManager) Stop(context.Context) error { return nil } +func (m fakeManager) Stop(context.Context) error { return m.opErr } -func (m fakeManager) Restart(context.Context) error { return nil } +func (m fakeManager) Restart(context.Context) error { return m.opErr } func (m fakeManager) Status(context.Context) (service.Status, error) { return m.status, m.err } +// useFakeManager replaces the manager of the platform supervisor for the +// duration of the test. +// +// The command family never touches the system of the test run: the manager +// of the platform is the fake one, so a lifecycle command can be driven +// through every answer of a supervisor. +func useFakeManager(t *testing.T, mgr service.Manager) { + t.Helper() + previous := newServiceManager + newServiceManager = func(service.Scope) (service.Manager, error) { return mgr, nil } + t.Cleanup(func() { newServiceManager = previous }) +} + // personalCacheRoot returns the Hugging Face cache of the user that runs // the test. // @@ -145,8 +163,8 @@ func TestServiceLifecycleSubcommandsRejectTheListenOption(t *testing.T) { for _, sub := range []string{"uninstall", "start", "stop", "restart", "status"} { var stdout, stderr bytes.Buffer code := runServiceCommand([]string{sub, "--listen", "127.0.0.1:9090"}, &stdout, &stderr) - if code == 0 { - t.Errorf("the %q subcommand must reject --listen, got exit code %d", sub, code) + if code != serviceUsageExitCode { + t.Errorf("the %q subcommand must reject --listen with the exit code %d, got %d", sub, serviceUsageExitCode, code) } if !strings.Contains(stderr.String(), "listen") { t.Errorf("the error of the %q subcommand does not name the rejected option:\n%s", sub, stderr.String()) @@ -169,8 +187,8 @@ func TestServiceLifecycleSubcommandsRejectTheInstallOnlyOptions(t *testing.T) { } { var stdout, stderr bytes.Buffer code := runServiceCommand(append([]string{sub}, option...), &stdout, &stderr) - if code == 0 { - t.Errorf("the %q subcommand must reject %s, got exit code %d", sub, option[0], code) + if code != serviceUsageExitCode { + t.Errorf("the %q subcommand must reject %s with the exit code %d, got %d", sub, option[0], serviceUsageExitCode, code) } // The parser names the option without its leading dashes. name := strings.TrimPrefix(option[0], "--") @@ -193,8 +211,8 @@ func TestServiceUserIsRejectedForTheUserScope(t *testing.T) { var stdout, stderr bytes.Buffer code := runServiceCommand([]string{"install", "--user", "--service-user", "hfdesk"}, &stdout, &stderr) - if code == 0 { - t.Fatalf("the user scope must reject --service-user, got exit code %d", code) + if code != serviceUsageExitCode { + t.Fatalf("the user scope must reject --service-user with the exit code %d, got %d", serviceUsageExitCode, code) } errText := stderr.String() if !strings.Contains(errText, "--service-user") { @@ -313,8 +331,8 @@ func TestServiceHelpSucceeds(t *testing.T) { func TestServiceUnknownSubcommand(t *testing.T) { var stdout, stderr bytes.Buffer code := runServiceCommand([]string{"frobnicate"}, &stdout, &stderr) - if code == 0 { - t.Errorf("an unknown subcommand must not succeed, got exit code %d", code) + if code != serviceUsageExitCode { + t.Errorf("an unknown subcommand must exit with %d, got %d", serviceUsageExitCode, code) } errText := stderr.String() @@ -331,8 +349,8 @@ func TestServiceUnknownSubcommand(t *testing.T) { func TestServiceWithoutSubcommand(t *testing.T) { var stdout, stderr bytes.Buffer code := runServiceCommand(nil, &stdout, &stderr) - if code == 0 { - t.Errorf("a missing subcommand must not succeed, got exit code %d", code) + if code != serviceUsageExitCode { + t.Errorf("a missing subcommand must exit with %d, got %d", serviceUsageExitCode, code) } for _, name := range serviceSubcommands { if !strings.Contains(stderr.String(), name) { @@ -348,14 +366,55 @@ func TestServiceScopeSelectorsAreExclusive(t *testing.T) { var stdout, stderr bytes.Buffer code := runServiceCommand([]string{"install", "--user", "--system"}, &stdout, &stderr) - if code == 0 { - t.Errorf("an ambiguous scope must not succeed, got exit code %d", code) + if code != serviceUsageExitCode { + t.Errorf("an ambiguous scope must exit with %d, got %d", serviceUsageExitCode, code) } if !strings.Contains(stderr.String(), "--user") || !strings.Contains(stderr.String(), "--system") { t.Errorf("the error does not name both selectors:\n%s", stderr.String()) } } +// TestServiceScopeSelectorsAreExclusiveEverywhere checks that every +// subcommand that carries the scope selector rejects an ambiguous one. +// +// Two scopes are two installations, so an ambiguous command line is a +// command line that could not be understood, on every subcommand that +// carries the selector. +func TestServiceScopeSelectorsAreExclusiveEverywhere(t *testing.T) { + for _, sub := range []string{"install", "uninstall", "start", "stop", "restart", "status"} { + var stdout, stderr bytes.Buffer + code := runServiceCommand([]string{sub, "--user", "--system"}, &stdout, &stderr) + if code != serviceUsageExitCode { + t.Errorf("the %q subcommand must reject two scopes with the exit code %d, got %d", + sub, serviceUsageExitCode, code) + } + if !strings.Contains(stderr.String(), "--user") || !strings.Contains(stderr.String(), "--system") { + t.Errorf("the error of the %q subcommand does not name both selectors:\n%s", sub, stderr.String()) + } + } +} + +// TestServiceRunRejectsAnInvalidVector checks that an argument vector the +// host mode cannot parse is a command line that could not be understood. +// +// The vector of the host mode is the one the supervisor invokes, so a +// broken vector is a mistake of the invocation and not a failed operation. +func TestServiceRunRejectsAnInvalidVector(t *testing.T) { + for _, args := range [][]string{ + {"run", "--config", "relative/hfdesk.json"}, + {"run", "--config", "/tmp/hfdesk.json"}, + } { + var stdout, stderr bytes.Buffer + code := runServiceCommand(args, &stdout, &stderr) + if code != serviceUsageExitCode { + t.Errorf("%v must exit with %d, got %d", args, serviceUsageExitCode, code) + } + if stderr.Len() == 0 { + t.Errorf("%v is rejected without a reason", args) + } + } +} + func TestServiceScopeDefaults(t *testing.T) { scope, err := resolveScope(false, false) if err != nil { @@ -417,8 +476,8 @@ func TestServiceRelativePathsAreRejected(t *testing.T) { } { var stdout, stderr bytes.Buffer code := runServiceCommand(args, &stdout, &stderr) - if code == 0 { - t.Errorf("%v must be rejected, got exit code %d", args, code) + if code != serviceUsageExitCode { + t.Errorf("%v must exit with %d, got %d", args, serviceUsageExitCode, code) } if !strings.Contains(stderr.String(), "absolute") { t.Errorf("%v is not reported as a relative path:\n%s", args, stderr.String()) @@ -916,10 +975,10 @@ func TestUninstallSummaryNamesTheEffectiveCacheRoot(t *testing.T) { } { t.Run(tc.name, func(t *testing.T) { mgr := fakeManager{status: tc.status} - paths, reported := instanceDataPaths(context.Background(), mgr, service.ScopeUser) + paths, reported, registered := instanceDataPaths(context.Background(), mgr, service.ScopeUser) var buf bytes.Buffer - writeUninstallSummary(&buf, service.ScopeUser, paths, reported) + writeUninstallSummary(&buf, service.ScopeUser, paths, reported, registered) out := buf.String() if !strings.Contains(out, tc.want) { @@ -1002,8 +1061,8 @@ func TestServiceInstallRejectsAnUnusableExecutable(t *testing.T) { t.Run(tc.name, func(t *testing.T) { var stdout, stderr bytes.Buffer code := runServiceCommand([]string{"install", "--system", "--executable", tc.path}, &stdout, &stderr) - if code == 0 { - t.Fatalf("the installation must be refused, got exit code %d", code) + if code != serviceUsageExitCode { + t.Fatalf("the installation must be refused with the exit code %d, got %d", serviceUsageExitCode, code) } if !strings.Contains(stderr.String(), tc.want) { t.Errorf("the error does not report %q:\n%s", tc.want, stderr.String()) @@ -1330,3 +1389,192 @@ func TestServiceDispatchIsExact(t *testing.T) { } } } + +// TestServiceUnknownFlagIsAUsageError checks that an unknown flag of every +// subcommand is a command line that could not be understood. +// +// The exit code 2 tells a script that the invocation was wrong. The exit +// code 1 would tell it that the operation failed, and the script would +// report a broken installation for a typo. +func TestServiceUnknownFlagIsAUsageError(t *testing.T) { + for _, sub := range serviceSubcommands { + t.Run(sub, func(t *testing.T) { + var stdout, stderr bytes.Buffer + code := runServiceCommand([]string{sub, "--bogus"}, &stdout, &stderr) + if code != serviceUsageExitCode { + t.Errorf("the %q subcommand must reject an unknown flag with the exit code %d, got %d", + sub, serviceUsageExitCode, code) + } + if !strings.Contains(stderr.String(), "bogus") { + t.Errorf("the error of the %q subcommand does not name the rejected flag:\n%s", sub, stderr.String()) + } + }) + } +} + +// TestServiceInvalidListenIsAUsageError checks that a bind address the +// command line cannot be parsed with is a command line that could not be +// understood. +// +// An empty host binds every interface, a port out of range names no port +// and a missing port is no address at all. +func TestServiceInvalidListenIsAUsageError(t *testing.T) { + for _, listen := range []string{ + ":8080", + "127.0.0.1:0", + "127.0.0.1:70000", + "127.0.0.1", + "localhost", + "", + } { + var stdout, stderr bytes.Buffer + code := runServiceCommand([]string{"install", "--listen", listen}, &stdout, &stderr) + if code != serviceUsageExitCode { + t.Errorf("the bind address %q must exit with %d, got %d", listen, serviceUsageExitCode, code) + } + if stderr.Len() == 0 { + t.Errorf("the bind address %q is rejected without a reason", listen) + } + } +} + +// TestServiceOperationalFailureExitsWithOne checks that an operation that +// was understood but failed exits with 1. +// +// The failure of the supervisor is the case of the family: the service is +// not installed, the elevation is missing or the supervisor refuses. The +// exit code 1 keeps it apart from a command line that could not be +// understood. +func TestServiceOperationalFailureExitsWithOne(t *testing.T) { + useFakeManager(t, fakeManager{opErr: errors.New("service: hfdesk.service is not installed")}) + + var stdout, stderr bytes.Buffer + code := runServiceCommand([]string{"start"}, &stdout, &stderr) + if code != serviceFailureExitCode { + t.Errorf("a failed operation must exit with %d, got %d", serviceFailureExitCode, code) + } + if !strings.Contains(stderr.String(), "not installed") { + t.Errorf("the error does not name the reason of the failure:\n%s", stderr.String()) + } +} + +// TestServiceStatusOfAnAbsentRegistrationExitsWithOne checks that the +// status of a service that is not installed is a failed operation. +// +// The state is not one a script can use, so the command reports it with the +// exit code 1 and not with the exit code 0 of a usable instance. +func TestServiceStatusOfAnAbsentRegistrationExitsWithOne(t *testing.T) { + useFakeManager(t, fakeManager{status: service.Status{State: service.StateNotInstalled}}) + + var stdout, stderr bytes.Buffer + code := runServiceCommand([]string{"status"}, &stdout, &stderr) + if code != serviceFailureExitCode { + t.Errorf("the status of an absent registration must exit with %d, got %d", serviceFailureExitCode, code) + } +} + +// TestServiceSuccessfulCommandExitsWithZero checks that a command that did +// what it was asked to do exits with 0. +func TestServiceSuccessfulCommandExitsWithZero(t *testing.T) { + useFakeManager(t, fakeManager{status: service.Status{State: service.StateRunning}}) + + for _, sub := range []string{"start", "status"} { + var stdout, stderr bytes.Buffer + code := runServiceCommand([]string{sub}, &stdout, &stderr) + if code != 0 { + t.Errorf("the %q subcommand must exit with 0, got %d:\n%s", sub, code, stderr.String()) + } + if stderr.Len() != 0 { + t.Errorf("the %q subcommand must not write to stderr:\n%s", sub, stderr.String()) + } + } +} + +// TestUninstallSummaryStatesWhatTheRemovalDid checks that the summary of +// the uninstall states what actually happened. +// +// A summary that always claims a removal hides the difference between a +// scope that had a registration and a scope that had none. +func TestUninstallSummaryStatesWhatTheRemovalDid(t *testing.T) { + for _, tc := range []struct { + name string + state service.State + want string + notWant string + }{ + { + name: "a scope with a registration", + state: service.StateStopped, + want: "The registration of hfdesk (user scope) was removed.", + notWant: "There was no registration", + }, + { + name: "a scope without a registration", + state: service.StateNotInstalled, + want: "There was no registration of hfdesk (user scope) to remove.", + notWant: "was removed", + }, + } { + t.Run(tc.name, func(t *testing.T) { + var buf bytes.Buffer + writeUninstallSummary(&buf, service.ScopeUser, runtimepaths.Paths{}, cacheRootNotRecorded, + tc.state != service.StateNotInstalled) + + out := buf.String() + if !strings.Contains(out, tc.want) { + t.Errorf("the uninstall summary does not state %q:\n%s", tc.want, out) + } + if strings.Contains(out, tc.notWant) { + t.Errorf("the uninstall summary states %q as well:\n%s", tc.notWant, out) + } + if !strings.Contains(out, "The user data was kept") { + t.Errorf("the uninstall summary does not report the kept user data:\n%s", out) + } + }) + } +} + +// TestServiceUninstallReportsWhatTheRemovalDid drives the uninstall +// command through the manager of the test and checks that the summary +// states what the removal did. +func TestServiceUninstallReportsWhatTheRemovalDid(t *testing.T) { + for _, tc := range []struct { + name string + state service.State + want string + notWant string + }{ + { + name: "a registered service", + state: service.StateStopped, + want: fmt.Sprintf("The registration of %s (%s scope) was removed.", service.ServiceName, defaultScope()), + notWant: "There was no registration", + }, + { + name: "an unregistered service", + state: service.StateNotInstalled, + want: fmt.Sprintf("There was no registration of %s (%s scope) to remove.", service.ServiceName, defaultScope()), + notWant: "was removed", + }, + } { + t.Run(tc.name, func(t *testing.T) { + useFakeManager(t, fakeManager{status: service.Status{State: tc.state}}) + + var stdout, stderr bytes.Buffer + code := runServiceCommand([]string{"uninstall"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("the uninstall must exit with 0, got %d:\n%s", code, stderr.String()) + } + out := stdout.String() + if !strings.Contains(out, tc.want) { + t.Errorf("the uninstall summary does not state %q:\n%s", tc.want, out) + } + if strings.Contains(out, tc.notWant) { + t.Errorf("the uninstall summary states %q as well:\n%s", tc.notWant, out) + } + if !strings.Contains(out, "The user data was kept") { + t.Errorf("the uninstall summary does not report the kept user data:\n%s", out) + } + }) + } +} From a6b717749d1171b3cee1ca0f1b661da014782706 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 22 Oct 2026 01:36:58 +1100 Subject: [PATCH 26/58] fix(service): one contract for the lifecycle of an absent service - Stopping a service that is not installed is a clean success on every platform, because there is nothing running to stop. Starting or restarting one still fails with a readable not-installed error. - The machine-wide start, stop and restart report the elevation requirement the same way the installation and the removal do. The not-installed check comes first, so it needs no privilege and gives the more useful answer. Co-Authored-By: opencode --- internal/service/manager_darwin.go | 40 ++++ internal/service/manager_darwin_test.go | 137 +++++++++++++ internal/service/manager_linux.go | 124 ++++++++++-- internal/service/manager_linux_test.go | 249 ++++++++++++++++++++++++ 4 files changed, 537 insertions(+), 13 deletions(-) diff --git a/internal/service/manager_darwin.go b/internal/service/manager_darwin.go index 4cefcdc4..c944dd2f 100644 --- a/internal/service/manager_darwin.go +++ b/internal/service/manager_darwin.go @@ -324,6 +324,13 @@ func (m *launchdManager) Uninstall(ctx context.Context) error { // is a stable no-op. A job that was unloaded by a stop is registered again // from the property list that is on disk, so a start after a stop works // without a reinstall. +// +// A machine-wide instance needs an elevated shell: the LaunchDaemon is +// registered in /Library/LaunchDaemons and it is started by the system +// domain, and both belong to the machine. The check runs after the +// existence of the registration, so a registration that is not there is +// reported as such in every shell. A user scope instance belongs to the +// user who installs it and needs no elevation. func (m *launchdManager) Start(ctx context.Context) error { path := m.plistPath() if _, err := os.Stat(path); err != nil { @@ -332,6 +339,12 @@ func (m *launchdManager) Start(ctx context.Context) error { } return fmt.Errorf("service: read the registration %q: %w", path, err) } + // The elevation requirement is checked before the job is started: a + // permission error of the machine is a much less readable answer than + // the real reason. + if err := m.requireElevated("a start"); err != nil { + return err + } if err := m.ensureLoaded(ctx, path); err != nil { return err } @@ -353,6 +366,13 @@ func (m *launchdManager) Start(ctx context.Context) error { // // Stopping an instance that does not run is a stable no-op: the state the // caller asked for already holds. +// +// A machine-wide instance needs an elevated shell: the LaunchDaemon is +// unloaded from the system domain, and the system domain belongs to the +// machine. The check runs after the existence of the registration, so a +// registration that is not there stays a clean success in every shell. A +// user scope instance belongs to the user who installs it and needs no +// elevation. func (m *launchdManager) Stop(ctx context.Context) error { path := m.plistPath() if _, err := os.Stat(path); err != nil { @@ -363,6 +383,13 @@ func (m *launchdManager) Stop(ctx context.Context) error { return fmt.Errorf("service: read the registration %q: %w", path, err) } + // The elevation requirement is checked before the job is unloaded: a + // permission error of the machine is a much less readable answer than + // the real reason. + if err := m.requireElevated("a stop"); err != nil { + return err + } + _, output, err := m.printState(ctx) if err != nil { return err @@ -385,6 +412,13 @@ func (m *launchdManager) Stop(ctx context.Context) error { // // The job is restarted with "launchctl kickstart -k": the running process // is replaced by a new one instead of waiting for it to end. +// +// A machine-wide instance needs an elevated shell: the LaunchDaemon is +// registered in /Library/LaunchDaemons and it is started by the system +// domain, and both belong to the machine. The check runs after the +// existence of the registration, so a registration that is not there is +// reported as such in every shell. A user scope instance belongs to the +// user who installs it and needs no elevation. func (m *launchdManager) Restart(ctx context.Context) error { path := m.plistPath() if _, err := os.Stat(path); err != nil { @@ -393,6 +427,12 @@ func (m *launchdManager) Restart(ctx context.Context) error { } return fmt.Errorf("service: read the registration %q: %w", path, err) } + // The elevation requirement is checked before the job is started: a + // permission error of the machine is a much less readable answer than + // the real reason. + if err := m.requireElevated("a restart"); err != nil { + return err + } if err := m.ensureLoaded(ctx, path); err != nil { return err } diff --git a/internal/service/manager_darwin_test.go b/internal/service/manager_darwin_test.go index ce038511..00db8ea2 100644 --- a/internal/service/manager_darwin_test.go +++ b/internal/service/manager_darwin_test.go @@ -2263,3 +2263,140 @@ func TestLaunchdSuccessfulInstallKeepsItsDirectories(t *testing.T) { } } } + +// TestLaunchdLifecycleOfAnAbsentRegistration checks the lifecycle commands +// against an instance that is not registered: the stop is a stable success, +// and the start and the restart report that the instance is not installed. +// +// The test never touches a real installation: the registration of the user +// scope lives in a temporary home directory, and the test skips itself when +// the machine holds a LaunchDaemon. +func TestLaunchdLifecycleOfAnAbsentRegistration(t *testing.T) { + t.Run("the user scope", func(t *testing.T) { + launchd := &launchdManager{scope: ScopeUser, uid: strconv.Itoa(os.Getuid()), home: t.TempDir()} + path := launchd.plistPath() + if _, err := os.Stat(path); err == nil { + t.Fatalf("the registration %q exists, the test needs an instance that is not registered", path) + } + + ctx := context.Background() + // An instance that is not registered does not run either: the + // state the caller asked for already holds. + if err := launchd.Stop(ctx); err != nil { + t.Errorf("Stop() = %v, want nil for an instance that is not registered", err) + } + // An instance that is not registered cannot be started, and the + // reason is reported in every shell. + err := launchd.Start(ctx) + if err == nil { + t.Fatal("Start() = nil, want an error for an instance that is not registered") + } + if !strings.Contains(err.Error(), "not installed") { + t.Errorf("Start() = %v, want an error that names the missing registration", err) + } + // A restart cannot start the instance either. + err = launchd.Restart(ctx) + if err == nil { + t.Fatal("Restart() = nil, want an error for an instance that is not registered") + } + if !strings.Contains(err.Error(), "not installed") { + t.Errorf("Restart() = %v, want an error that names the missing registration", err) + } + }) + + t.Run("the machine-wide scope", func(t *testing.T) { + launchd := &launchdManager{scope: ScopeSystem, uid: "0", home: t.TempDir()} + path := launchd.plistPath() + if _, err := os.Stat(path); err == nil { + t.Skipf("the machine has a registration at %q: the test does not touch it", path) + } + + // The elevation requirement refuses a shell that is not + // elevated. + if err := launchd.requireElevatedFor("a stop", 501); err == nil { + t.Fatal("requireElevatedFor() = nil, want the refusal of a shell that is not elevated") + } + + ctx := context.Background() + // The existence of the registration comes first, so the stop of + // an instance that is not registered stays a clean success even + // when the shell is not elevated. + if err := launchd.Stop(ctx); err != nil { + t.Errorf("Stop() = %v, want nil for an instance that is not registered", err) + } + err := launchd.Start(ctx) + if err == nil { + t.Fatal("Start() = nil, want an error for an instance that is not registered") + } + if !strings.Contains(err.Error(), "not installed") { + t.Errorf("Start() = %v, want an error that names the missing registration", err) + } + if strings.Contains(err.Error(), "elevated shell") { + t.Errorf("Start() = %v, want the missing registration and not the elevation requirement", err) + } + err = launchd.Restart(ctx) + if err == nil { + t.Fatal("Restart() = nil, want an error for an instance that is not registered") + } + if !strings.Contains(err.Error(), "not installed") { + t.Errorf("Restart() = %v, want an error that names the missing registration", err) + } + }) +} + +// TestLaunchdMachineWideLifecycleNeedsAnElevatedShell checks the elevation +// requirement of the machine-wide scope for the lifecycle commands. +// +// A LaunchDaemon is registered in /Library/LaunchDaemons and it is started +// and stopped by the system domain: both belong to the machine and need +// root. The user scope needs no elevation: a LaunchAgent belongs to the +// user who installs it. +// +// The decision is the same function that the installation uses, with the +// user id of the process as a parameter, so the test drives both branches +// without an elevated shell. +func TestLaunchdMachineWideLifecycleNeedsAnElevatedShell(t *testing.T) { + for _, action := range []string{"a start", "a stop", "a restart"} { + t.Run("the machine-wide scope refuses "+action+" without an elevated shell", func(t *testing.T) { + launchd := &launchdManager{scope: ScopeSystem, uid: "501", home: "/Users/somebody"} + + err := launchd.requireElevatedFor(action, 501) + if err == nil { + t.Fatal("requireElevatedFor() = nil, want the refusal of a shell that is not elevated") + } + want := fmt.Sprintf("service: %s in the system scope requires an elevated shell: the LaunchDaemon is registered in %s and started by the system domain, HFDesk never escalates privileges itself", action, launchDaemonsDir) + if err.Error() != want { + t.Errorf("requireElevatedFor() = %q, want %q", err, want) + } + }) + + t.Run("the machine-wide scope accepts "+action+" with an elevated shell", func(t *testing.T) { + launchd := &launchdManager{scope: ScopeSystem} + + if err := launchd.requireElevatedFor(action, 0); err != nil { + t.Errorf("requireElevatedFor() = %v, want nil for an elevated shell", err) + } + }) + + t.Run("the user scope is not refused for "+action, func(t *testing.T) { + launchd := &launchdManager{scope: ScopeUser, uid: "501", home: "/Users/somebody"} + + if err := launchd.requireElevatedFor(action, 501); err != nil { + t.Errorf("requireElevatedFor() = %v, want nil for a user scope instance", err) + } + }) + } + + t.Run("the check of the process uses the effective user id of the process", func(t *testing.T) { + launchd := &launchdManager{scope: ScopeSystem} + + err := launchd.requireElevated("a start") + forced := launchd.requireElevatedFor("a start", os.Geteuid()) + switch { + case err == nil && forced == nil: + case err != nil && forced != nil && err.Error() == forced.Error(): + default: + t.Errorf("requireElevated() = %v, want the decision of requireElevatedFor(%d) = %v", err, os.Geteuid(), forced) + } + }) +} diff --git a/internal/service/manager_linux.go b/internal/service/manager_linux.go index 9386125e..de29c2dd 100644 --- a/internal/service/manager_linux.go +++ b/internal/service/manager_linux.go @@ -54,6 +54,8 @@ const ( // A system scope installation is a machine wide system unit. It is an // explicit choice and it needs root: the manager never escalates // privileges, it fails with a clear error when the process is not root. +// The lifecycle commands of the machine wide scope need root as well: the +// unit of the machine belongs to the machine. // // The supervisor owns the lifecycle of the instance. HFDesk stays a normal // foreground process: the unit is a simple service, the process is never @@ -165,10 +167,24 @@ func (m *systemdManager) runSystemctl(ctx context.Context, args ...string) (stri // process does not have it. The manager never escalates privileges itself: // it never calls sudo, su or pkexec. func (m *systemdManager) requireRoot(action string) error { + return m.requireRootFor(action, os.Geteuid()) +} + +// requireRootFor rejects a machine wide operation when the given user id is +// not the one of root. +// +// The user id is a parameter, so the decision can be driven from both sides +// without an elevated shell: the real check passes the effective user id of +// the process, and the test passes the ids of the two cases. +// +// A user scope instance belongs to the user of the session: it is written +// into the configuration directory of the user and it is driven by the +// supervisor of that session, so it never needs root. +func (m *systemdManager) requireRootFor(action string, uid int) error { if m.scope != ScopeSystem { return nil } - if os.Geteuid() == 0 { + if uid == 0 { return nil } return fmt.Errorf("service: %s in the system scope requires root: run the command as root, HFDesk never escalates privileges itself", action) @@ -822,10 +838,44 @@ func (m *systemdManager) Uninstall(ctx context.Context) error { return nil } +// lifecycleKind tells the guard of a lifecycle command what the command +// needs from the registration of the service. +type lifecycleKind int + +const ( + // lifecycleStart is a command that brings the service up, a start or a + // restart: it needs a registration, so an absent one is a refusal. + lifecycleStart lifecycleKind = iota + // lifecycleStop is a command that stops the service: a service that is + // not registered does not run either, so an absent registration is a + // stable success. + lifecycleStop +) + +// lifecycleOutcome is the answer of the guard of a lifecycle command. +type lifecycleOutcome int + +const ( + // lifecycleRun means the command may touch the supervisor. + lifecycleRun lifecycleOutcome = iota + // lifecycleAlreadyReached means the wanted state of the command holds + // already: the service is not registered, so it does not run either, + // and the command is a stable success that must not touch the + // supervisor. + lifecycleAlreadyReached +) + // Start starts the registered unit. Starting a unit that already runs is a // stable success, not an error. +// +// A unit that is not registered cannot be started: the command reports that +// the service is not installed. func (m *systemdManager) Start(ctx context.Context) error { - if err := m.requireInstalled(); err != nil { + installed, err := m.installed() + if err != nil { + return err + } + if _, err := m.requireLifecycle(lifecycleStart, installed, os.Geteuid(), "a start"); err != nil { return err } if _, err := m.runSystemctl(ctx, "start", unitName); err != nil { @@ -840,11 +890,22 @@ func (m *systemdManager) Start(ctx context.Context) error { } // Stop stops the registered unit. Stopping a unit that is already stopped is -// a stable success, not an error. +// a stable success, not an error, and so is stopping a unit that is not +// registered at all: it does not run either. func (m *systemdManager) Stop(ctx context.Context) error { - if err := m.requireInstalled(); err != nil { + installed, err := m.installed() + if err != nil { + return err + } + outcome, err := m.requireLifecycle(lifecycleStop, installed, os.Geteuid(), "a stop") + if err != nil { return err } + if outcome == lifecycleAlreadyReached { + // The unit is not registered, so it does not run either: the + // state the caller asked for already holds. + return nil + } if _, err := m.runSystemctl(ctx, "stop", unitName); err != nil { // A unit the supervisor does not know is not running, so the // wanted state is already reached. @@ -860,8 +921,15 @@ func (m *systemdManager) Stop(ctx context.Context) error { } // Restart stops and starts the registered unit. +// +// A unit that is not registered cannot be restarted: the command reports +// that the service is not installed. func (m *systemdManager) Restart(ctx context.Context) error { - if err := m.requireInstalled(); err != nil { + installed, err := m.installed() + if err != nil { + return err + } + if _, err := m.requireLifecycle(lifecycleStart, installed, os.Geteuid(), "a restart"); err != nil { return err } if _, err := m.runSystemctl(ctx, "restart", unitName); err != nil { @@ -870,21 +938,51 @@ func (m *systemdManager) Restart(ctx context.Context) error { return nil } -// requireInstalled rejects a lifecycle command for a service that is not -// registered, so the caller gets a readable reason instead of a cryptic -// supervisor error. -func (m *systemdManager) requireInstalled() error { +// requireLifecycle reports what a lifecycle command of the manager may do. +// +// The registration of the service comes first: it needs no privilege and it +// gives the more useful answer. A stop of a service that is not registered +// reaches its wanted state already, because such a service does not run +// either. +// +// The elevation requirement comes second: a machine wide unit belongs to +// the machine, so a lifecycle command of that scope needs root. A user scope +// instance belongs to the user of the session and needs no elevation. +// +// The state of the registration and the effective user id are parameters, +// so the decision can be driven from both sides without an elevated shell: +// the command passes the state of the file system and the id of the process, +// and the test passes the states of the cases. +func (m *systemdManager) requireLifecycle(kind lifecycleKind, installed bool, uid int, action string) (lifecycleOutcome, error) { + if !installed { + if kind == lifecycleStop { + return lifecycleAlreadyReached, nil + } + return lifecycleRun, fmt.Errorf("service: %s is not installed", unitName) + } + if err := m.requireRootFor(action, uid); err != nil { + return lifecycleRun, err + } + return lifecycleRun, nil +} + +// installed reports whether the unit file of the manager scope is there. +// +// The registration is read from the file system, so the answer needs no +// privilege: a caller that is not elevated still learns whether the +// service is registered. +func (m *systemdManager) installed() (bool, error) { path, err := m.unitPath() if err != nil { - return err + return false, err } if _, err := os.Stat(path); err != nil { if errors.Is(err, fs.ErrNotExist) { - return fmt.Errorf("service: %s is not installed", unitName) + return false, nil } - return err + return false, err } - return nil + return true, nil } // currentState queries the supervisor for the state of the unit. The second diff --git a/internal/service/manager_linux_test.go b/internal/service/manager_linux_test.go index aeadfcf9..a191422f 100644 --- a/internal/service/manager_linux_test.go +++ b/internal/service/manager_linux_test.go @@ -1951,3 +1951,252 @@ func TestSystemdSuccessfulInstallKeepsItsDirectories(t *testing.T) { } } } + +// TestSystemdLifecycleOfAnAbsentUnit checks the lifecycle commands against a +// service that is not registered: the stop is a stable success, and the +// start and the restart report that the service is not installed. +// +// The test never touches a real installation: the unit file of the user +// scope lives in a temporary configuration directory, and the test skips +// itself when the machine holds a unit of the machine scope. +func TestSystemdLifecycleOfAnAbsentUnit(t *testing.T) { + t.Run("the user scope", func(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + m := &systemdManager{scope: ScopeUser} + path, err := m.unitPath() + if err != nil { + t.Fatalf("unitPath() = %v, want nil", err) + } + if _, err := os.Stat(path); err == nil { + t.Fatalf("the unit file %q exists, the test needs a service that is not registered", path) + } + + ctx := context.Background() + // A service that is not registered does not run either: the + // state the caller asked for already holds. + if err := m.Stop(ctx); err != nil { + t.Errorf("Stop() = %v, want nil for a service that is not registered", err) + } + // A service that is not registered cannot be started, and the + // reason is reported in every shell. + err = m.Start(ctx) + if err == nil { + t.Fatal("Start() = nil, want an error for a service that is not registered") + } + if !strings.Contains(err.Error(), "not installed") { + t.Errorf("Start() = %v, want an error that names the missing registration", err) + } + // A restart cannot start the service either. + err = m.Restart(ctx) + if err == nil { + t.Fatal("Restart() = nil, want an error for a service that is not registered") + } + if !strings.Contains(err.Error(), "not installed") { + t.Errorf("Restart() = %v, want an error that names the missing registration", err) + } + }) + + t.Run("the machine-wide scope", func(t *testing.T) { + m := &systemdManager{scope: ScopeSystem} + path, err := m.unitPath() + if err != nil { + t.Fatalf("unitPath() = %v, want nil", err) + } + if _, err := os.Stat(path); err == nil { + t.Skipf("the machine has a unit at %q: the test does not touch it", path) + } + + ctx := context.Background() + if err := m.Stop(ctx); err != nil { + t.Errorf("Stop() = %v, want nil for a service that is not registered", err) + } + err = m.Start(ctx) + if err == nil { + t.Fatal("Start() = nil, want an error for a service that is not registered") + } + if !strings.Contains(err.Error(), "not installed") { + t.Errorf("Start() = %v, want an error that names the missing registration", err) + } + err = m.Restart(ctx) + if err == nil { + t.Fatal("Restart() = nil, want an error for a service that is not registered") + } + if !strings.Contains(err.Error(), "not installed") { + t.Errorf("Restart() = %v, want an error that names the missing registration", err) + } + }) +} + +// TestSystemdMachineWideLifecycleNeedsRoot checks the elevation requirement +// of the machine-wide scope for the lifecycle commands. +// +// A unit of the machine belongs to the machine, so starting, stopping and +// restarting it needs root. The user scope belongs to the user of the +// session and never needs an elevation. +// +// The state of the registration and the user id are parameters of the +// guard, so the test drives every case without an elevated shell. +func TestSystemdMachineWideLifecycleNeedsRoot(t *testing.T) { + // A user id that is not the one of root. + const unprivileged = 501 + + cases := []struct { + name string + kind lifecycleKind + action string + }{ + {name: "a start", kind: lifecycleStart, action: "a start"}, + {name: "a stop", kind: lifecycleStop, action: "a stop"}, + {name: "a restart", kind: lifecycleStart, action: "a restart"}, + } + for _, tc := range cases { + t.Run("the machine-wide scope refuses "+tc.name+" without root", func(t *testing.T) { + m := &systemdManager{scope: ScopeSystem} + + _, err := m.requireLifecycle(tc.kind, true, unprivileged, tc.action) + if err == nil { + t.Fatal("requireLifecycle() = nil, want the refusal of a shell that is not elevated") + } + want := fmt.Sprintf("service: %s in the system scope requires root: run the command as root, HFDesk never escalates privileges itself", tc.action) + if err.Error() != want { + t.Errorf("requireLifecycle() = %q, want %q", err, want) + } + }) + + t.Run("the machine-wide scope accepts "+tc.name+" with root", func(t *testing.T) { + m := &systemdManager{scope: ScopeSystem} + + if _, err := m.requireLifecycle(tc.kind, true, 0, tc.action); err != nil { + t.Errorf("requireLifecycle() = %v, want nil for an elevated shell", err) + } + }) + + t.Run("the user scope is not refused for "+tc.name, func(t *testing.T) { + m := &systemdManager{scope: ScopeUser} + + if _, err := m.requireLifecycle(tc.kind, true, unprivileged, tc.action); err != nil { + t.Errorf("requireLifecycle() = %v, want nil for a user scope instance", err) + } + }) + } + + t.Run("the check of the process uses the effective user id of the process", func(t *testing.T) { + m := &systemdManager{scope: ScopeSystem} + + err := m.requireRoot("a start") + forced := m.requireRootFor("a start", os.Geteuid()) + switch { + case err == nil && forced == nil: + case err != nil && forced != nil && err.Error() == forced.Error(): + default: + t.Errorf("requireRoot() = %v, want the decision of requireRootFor(%d) = %v", err, os.Geteuid(), forced) + } + }) +} + +// TestSystemdStopOfAnAbsentUnitReachesItsState checks the answer of the +// guard for a service that is not registered: a stop reaches its wanted +// state already, so it is a stable success that must not touch the +// supervisor, and a start and a restart are refused. +func TestSystemdStopOfAnAbsentUnitReachesItsState(t *testing.T) { + m := &systemdManager{scope: ScopeSystem} + + outcome, err := m.requireLifecycle(lifecycleStop, false, 501, "a stop") + if err != nil { + t.Fatalf("requireLifecycle() = %v, want nil for a stop of a service that is not registered", err) + } + if outcome != lifecycleAlreadyReached { + t.Errorf("requireLifecycle() = %d, want %d", outcome, lifecycleAlreadyReached) + } + + for _, action := range []string{"a start", "a restart"} { + _, err := m.requireLifecycle(lifecycleStart, false, 501, action) + if err == nil { + t.Fatalf("requireLifecycle() = nil, want the refusal of %s for a service that is not registered", action) + } + want := fmt.Sprintf("service: %s is not installed", unitName) + if err.Error() != want { + t.Errorf("requireLifecycle() = %q, want %q", err, want) + } + } +} + +// TestSystemdAbsentUnitComesBeforeTheElevationRequirement checks the order +// of the two checks of a lifecycle command. +// +// The registration of the service comes first: it needs no privilege and it +// gives the more useful answer. A stop of a service that is not registered +// therefore stays a stable success even when the shell is not elevated, and +// a start of it still names the missing registration instead of the +// elevation requirement. +func TestSystemdAbsentUnitComesBeforeTheElevationRequirement(t *testing.T) { + m := &systemdManager{scope: ScopeSystem} + path, err := m.unitPath() + if err != nil { + t.Fatalf("unitPath() = %v, want nil", err) + } + if _, err := os.Stat(path); err == nil { + t.Skipf("the machine has a unit at %q: the test does not touch it", path) + } + + // The elevation requirement refuses a shell that is not elevated. + if err := m.requireRootFor("a stop", 501); err == nil { + t.Fatal("requireRootFor() = nil, want the refusal of a shell that is not elevated") + } + + // The stop of a service that is not registered is a stable success: + // the registration is checked first, so the elevation requirement is + // not even looked at. + if err := m.Stop(context.Background()); err != nil { + t.Errorf("Stop() = %v, want nil for a service that is not registered", err) + } + + // The start of a service that is not registered names the missing + // registration, not the elevation requirement. + err = m.Start(context.Background()) + if err == nil { + t.Fatal("Start() = nil, want an error for a service that is not registered") + } + if !strings.Contains(err.Error(), "not installed") { + t.Errorf("Start() = %v, want an error that names the missing registration", err) + } + if strings.Contains(err.Error(), "requires root") { + t.Errorf("Start() = %v, want the missing registration and not the elevation requirement", err) + } +} + +// TestSystemdUserScopeLifecycleNeedsNoRoot checks that the lifecycle +// commands of a user scope instance are never refused for the elevation +// requirement: a user unit belongs to the user of the session. +// +// The supervisor of the test is a fake one, so the test never touches the +// supervisor of the machine, and the registration of the test lives in a +// temporary configuration directory. +func TestSystemdUserScopeLifecycleNeedsNoRoot(t *testing.T) { + systemdFakeSupervisor(t) + t.Setenv("HFDESK_TEST_SUPERVISOR", "ok") + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + m := &systemdManager{scope: ScopeUser} + path, err := m.unitPath() + if err != nil { + t.Fatalf("unitPath() = %v, want nil", err) + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("create the unit directory: %v", err) + } + if err := os.WriteFile(path, []byte("[Service]\nExecStart=/bin/true\n"), 0o644); err != nil { + t.Fatalf("write the unit file: %v", err) + } + + ctx := context.Background() + if err := m.Start(ctx); err != nil { + t.Errorf("Start() = %v, want nil for a user scope instance", err) + } + if err := m.Stop(ctx); err != nil { + t.Errorf("Stop() = %v, want nil for a user scope instance", err) + } + if err := m.Restart(ctx); err != nil { + t.Errorf("Restart() = %v, want nil for a user scope instance", err) + } +} From 1e006cc750248e8ac9d0d693b1ff4aa8d0b03bb4 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 22 Oct 2026 01:36:58 +1100 Subject: [PATCH 27/58] fix(service): remove the directories a failed Windows installation created - A failed installation leaves the machine as it was: the directories it created are removed when they are still empty, and a directory that held something is kept. The installation order is unchanged, so the restrictive descriptor still precedes the preflight of the reused data paths. - Document that the binary path of the registration is what the update path sends, while the create path rebuilds the command line itself. Co-Authored-By: opencode --- internal/service/manager_windows.go | 161 ++++++++- internal/service/manager_windows_test.go | 396 +++++++++++++++++++++++ 2 files changed, 548 insertions(+), 9 deletions(-) diff --git a/internal/service/manager_windows.go b/internal/service/manager_windows.go index c2350b89..7615d8f0 100644 --- a/internal/service/manager_windows.go +++ b/internal/service/manager_windows.go @@ -145,7 +145,10 @@ func newPlatformManager(scope Scope) (Manager, error) { // // Every failure of the installation happens before the registration is // written, so a failed installation never leaves a half-configured service -// behind. +// behind. A failure removes the directories the installation created as +// well, so a refused attempt leaves the machine as it was: the directories +// it created are removed while they are still empty, and a directory that +// existed before is never touched. func (m *scmManager) Install(ctx context.Context, opts InstallOptions) error { if err := ValidateInstallOptions(opts); err != nil { return err @@ -156,23 +159,44 @@ func (m *scmManager) Install(ctx context.Context, opts InstallOptions) error { if err := requireElevated("an installation"); err != nil { return err } - if err := PrepareDirs(opts); err != nil { + // The directories the installation is about to create are remembered + // before they are created, so the installation knows which of them it + // owns. The list holds the directories of the instance and the + // directories the installation reuses: the preflight creates the + // missing ones as well, so they belong to the installation too. + dirs := scmInstallDirs(opts) + created := make(map[string][]string, len(dirs)) + for _, dir := range dirs { + created[dir] = createdDirs(dir) + } + order := createdDirOrder(opts, created) + // A failed installation removes the directories it created. The + // cleanup is best effort: it never replaces the error of the + // installation, which is what the caller gets. + fail := func(err error) error { + removeCreatedDirs(order) return err } + if err := PrepareDirs(opts); err != nil { + // The preparation may have created some of the directories + // before it failed: they are removed again, so a failed attempt + // leaves the machine as it was. + return fail(err) + } // The state of the instance is restricted before the reused // directories are checked: the directories the instance creates // inside its own directories inherit the restrictive descriptor, so // the account of the instance can work with them. if err := restrictInstanceState(opts); err != nil { - return err + return fail(err) } if err := m.preflight(opts); err != nil { - return err + return fail(err) } manager, err := mgr.Connect() if err != nil { - return fmt.Errorf("service: connect to the service control manager: %w", err) + return fail(fmt.Errorf("service: connect to the service control manager: %w", err)) } defer manager.Disconnect() @@ -186,18 +210,18 @@ func (m *scmManager) Install(ctx context.Context, opts InstallOptions) error { // one service. defer handle.Close() if err := handle.UpdateConfig(config); err != nil { - return fmt.Errorf("service: update the registration of %s: %w", ServiceName, err) + return fail(fmt.Errorf("service: update the registration of %s: %w", ServiceName, err)) } log.Printf("service: %s is installed already, the registration was updated", ServiceName) logEvent(eventLogInfo, fmt.Sprintf("hfdesk: the registration of %s was updated", ServiceName)) case errors.Is(err, windows.ERROR_SERVICE_DOES_NOT_EXIST): handle, err = manager.CreateService(ServiceName, opts.Executable, config, RunArgs(opts)...) if err != nil { - return fmt.Errorf("service: register %s: %w", ServiceName, err) + return fail(fmt.Errorf("service: register %s: %w", ServiceName, err)) } defer handle.Close() default: - return fmt.Errorf("service: open the registration of %s: %w", ServiceName, err) + return fail(fmt.Errorf("service: open the registration of %s: %w", ServiceName, err)) } // The event log source carries the diagnostics of the instance. A @@ -211,6 +235,119 @@ func (m *scmManager) Install(ctx context.Context, opts InstallOptions) error { return nil } +// scmInstallDirs returns the directories an installation creates, in the +// order it creates them. +// +// The instance owns three directories: the directory of the config file, the +// state directory and the log directory. The preflight creates the +// directories the installation reuses afterwards: the cache root, the hub +// directory and the local output directory. +// +// The list holds every path once: the config file can live in the state +// directory of the instance, and the hub directory can live in the cache +// root. +func scmInstallDirs(opts InstallOptions) []string { + dirs := []string{ + filepath.Dir(opts.Paths.ConfigFile), + opts.Paths.StateDir, + opts.Paths.LogDir, + opts.CacheDir, + opts.HubDir, + opts.LocalDir, + } + + unique := make([]string, 0, len(dirs)) + seen := make(map[string]bool, len(dirs)) + for _, dir := range dirs { + if dir == "" || dir == "." || seen[dir] { + continue + } + seen[dir] = true + unique = append(unique, dir) + } + return unique +} + +// createdDirs returns the directories that do not exist yet on the way to +// the path, from the top down. +// +// The list holds the path itself when it is missing: it is the set of +// directories an installation creates for the path. The list is read before +// the directories are created, so the installation knows which of them it +// owns. +func createdDirs(path string) []string { + var missing []string + current := filepath.Clean(path) + for { + if _, err := os.Stat(current); err == nil { + break + } else if !errors.Is(err, fs.ErrNotExist) { + break + } + missing = append(missing, current) + parent := filepath.Dir(current) + if parent == current { + break + } + current = parent + } + // The list was collected from the bottom up, the directories are + // created from the top down. + for i, j := 0, len(missing)-1; i < j; i, j = i+1, j-1 { + missing[i], missing[j] = missing[j], missing[i] + } + return missing +} + +// createdDirOrder returns the directories an installation creates, in the +// order it creates them. +// +// The map holds the directories of every directory of the installation. The +// directories are created in the order of scmInstallDirs (the directories of +// the instance first, the directories the installation reuses afterwards) +// and from the top down inside each of them, so the list is the order of the +// creation. +// +// A directory that several directories need is listed once: the first +// directory that needed it is the one that created it. +func createdDirOrder(opts InstallOptions, created map[string][]string) []string { + var order []string + seen := make(map[string]bool, len(created)) + for _, dir := range scmInstallDirs(opts) { + for _, path := range created[dir] { + if seen[path] { + continue + } + seen[path] = true + order = append(order, path) + } + } + return order +} + +// removeCreatedDirs removes the directories an installation created. +// +// The directories are removed in the reverse order of their creation, so a +// parent is only removed after its children. A directory that is not empty is +// kept: the removal never deletes data and it never walks into a directory. +// +// The removal is best effort: a failure of the removal is ignored, so the +// original failure of the installation stays the error the caller gets. +func removeCreatedDirs(dirs []string) { + for i := len(dirs) - 1; i >= 0; i-- { + path := dirs[i] + info, err := os.Lstat(path) + if err != nil || !info.IsDir() { + // The directory is gone already, or it is not a directory + // any more: nothing is removed, so no file is ever deleted. + continue + } + // A directory that holds something is kept: the removal of such + // a directory would delete what it holds. + _ = os.Remove(path) + } +} + // Uninstall removes the registration of the instance. // // It removes the registration only: the config file, the jobs, the history, @@ -1221,7 +1358,13 @@ func buildSCMConfig(opts InstallOptions) mgr.Config { DisplayName: scmDisplayName, Description: scmDescription, ServiceStartName: scmLocalServiceAccount, - BinaryPathName: scmCommandLine(opts), + // BinaryPathName is the command line of the update path: the + // update of a registration sends the field to the service + // control manager. The create path ignores the field: the + // service control manager rebuilds the command line of a new + // registration from the executable and the arguments, so the + // field does not control the create path. + BinaryPathName: scmCommandLine(opts), } } diff --git a/internal/service/manager_windows_test.go b/internal/service/manager_windows_test.go index 2500ff66..05fe8e28 100644 --- a/internal/service/manager_windows_test.go +++ b/internal/service/manager_windows_test.go @@ -1161,3 +1161,399 @@ func scmTestSetDACL(t *testing.T, path, sddl string) { t.Fatalf("the access control list %q cannot be applied to %q: %v", sddl, path, err) } } + +// scmTestInstallOptions returns the options of an installation whose +// directories live below the given root. +// +// The executable is a program of the operating system: the preflight needs a +// regular file it can stat. The cache root lives in the state directory of +// the instance, the way the default paths of a machine-wide instance build +// it. +func scmTestInstallOptions(t *testing.T, root string) InstallOptions { + t.Helper() + executable := filepath.Join("C:\\Windows", "System32", "cmd.exe") + if _, err := os.Stat(executable); err != nil { + t.Skipf("the test program %s is not available: %v", executable, err) + } + return InstallOptions{ + Scope: ScopeSystem, + Executable: executable, + Listen: DefaultListen, + Paths: runtimepaths.Paths{ + ConfigFile: filepath.Join(root, "etc", "hfdesk", "hfdesk.json"), + StateDir: filepath.Join(root, "var", "lib", "hfdesk"), + LogDir: filepath.Join(root, "var", "log", "hfdesk"), + }, + CacheDir: filepath.Join(root, "var", "lib", "hfdesk", "cache"), + HubDir: filepath.Join(root, "var", "lib", "hfdesk", "cache", "hub"), + LocalDir: filepath.Join(root, "models"), + } +} + +// scmTestDenyInstanceIdentityDACL returns a descriptor that grants the +// machine, its administrators and the account of the test the access, and +// the account of the instance none. +// +// A directory with this descriptor is refused by the preflight: the account +// the instance runs as cannot work with it. +func scmTestDenyInstanceIdentityDACL(t *testing.T) string { + t.Helper() + return fmt.Sprintf("D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;FA;;;%s)", scmTestCurrentUserSID(t)) +} + +// scmTestAllowInstanceIdentityDACL returns a descriptor that grants the +// machine, its administrators, the account of the instance and the account +// of the test the access. +// +// A directory with this descriptor passes the preflight: the account the +// instance runs as can work with it. +func scmTestAllowInstanceIdentityDACL(t *testing.T) string { + t.Helper() + return fmt.Sprintf("%s(A;OICI;FA;;;%s)", scmStateSDDL(), scmTestCurrentUserSID(t)) +} + +// scmTestSecurity returns the owner and the permissions of a path. +// +// The owner is the account that owns the path, the permissions are the +// security descriptor of the path in its SDDL form. +func scmTestSecurity(t *testing.T, path string) (string, string) { + t.Helper() + sd, err := windows.GetNamedSecurityInfo( + path, + windows.SE_FILE_OBJECT, + windows.DACL_SECURITY_INFORMATION|windows.OWNER_SECURITY_INFORMATION|windows.GROUP_SECURITY_INFORMATION, + ) + if err != nil { + t.Fatalf("the security descriptor of %q cannot be read: %v", path, err) + } + owner, _, err := sd.Owner() + if err != nil { + t.Fatalf("the owner of %q cannot be read: %v", path, err) + } + if owner == nil { + t.Fatalf("the owner of %q is unknown", path) + } + return owner.String(), sd.String() +} + +// scmTestOwnerAndMode returns the owner and the mode of a path. +// +// The owner is the account that owns the path, the mode are the permission +// bits of the path. +func scmTestOwnerAndMode(t *testing.T, path string) (string, os.FileMode) { + t.Helper() + sd, err := windows.GetNamedSecurityInfo( + path, + windows.SE_FILE_OBJECT, + windows.OWNER_SECURITY_INFORMATION, + ) + if err != nil { + t.Fatalf("the owner of %q cannot be read: %v", path, err) + } + owner, _, err := sd.Owner() + if err != nil { + t.Fatalf("the owner of %q cannot be read: %v", path, err) + } + if owner == nil { + t.Fatalf("the owner of %q is unknown", path) + } + info, err := os.Stat(path) + if err != nil { + t.Fatalf("the mode of %q cannot be read: %v", path, err) + } + return owner.String(), info.Mode().Perm() +} + +// scmTestRefuseLocalDir turns the local directory of an installation into a +// directory the account of the instance cannot reach. +// +// The preflight refuses the installation when it reaches the local +// directory, so the installation fails after the directories of the instance +// were created: the point a failed installation has to clean up. +func scmTestRefuseLocalDir(t *testing.T, opts InstallOptions) { + t.Helper() + if err := os.MkdirAll(opts.LocalDir, 0o700); err != nil { + t.Fatalf("MkdirAll(%q) = %v, want nil", opts.LocalDir, err) + } + scmTestSetDACL(t, opts.LocalDir, scmTestDenyInstanceIdentityDACL(t)) +} + +// TestSCMCreatedDirOrder checks the order of the directories a failed +// installation removes: the directories are listed in the order they are +// created, so a parent is always removed after its children. +// +// The directories of the instance come first, the directories the +// installation reuses come afterwards: the preflight creates them once the +// directories of the instance exist. +func TestSCMCreatedDirOrder(t *testing.T) { + root := t.TempDir() + opts := scmTestInstallOptions(t, root) + + dirs := scmInstallDirs(opts) + created := make(map[string][]string, len(dirs)) + for _, dir := range dirs { + created[dir] = createdDirs(dir) + } + order := createdDirOrder(opts, created) + + want := []string{ + filepath.Join(root, "etc"), + filepath.Join(root, "etc", "hfdesk"), + filepath.Join(root, "var"), + filepath.Join(root, "var", "lib"), + filepath.Join(root, "var", "lib", "hfdesk"), + filepath.Join(root, "var", "log"), + filepath.Join(root, "var", "log", "hfdesk"), + filepath.Join(root, "var", "lib", "hfdesk", "cache"), + filepath.Join(root, "var", "lib", "hfdesk", "cache", "hub"), + filepath.Join(root, "models"), + } + if strings.Join(order, "\n") != strings.Join(want, "\n") { + t.Errorf("createdDirOrder() =\n%s\nwant\n%s", strings.Join(order, "\n"), strings.Join(want, "\n")) + } +} + +// TestSCMFailedInstallRemovesTheDirectoriesItCreated checks that a failed +// installation removes the directories it created, so a failed attempt +// leaves the machine as it was. +// +// The installation is refused by the preflight: the local directory cannot +// be reached by the account of the instance. The refusal happens after the +// directories of the instance were created, so the cleanup has to remove +// them again. +func TestSCMFailedInstallRemovesTheDirectoriesItCreated(t *testing.T) { + if !isElevatedProcess() { + t.Skip("an installation needs an elevated shell, run the test as an administrator") + } + root := t.TempDir() + opts := scmTestInstallOptions(t, root) + scmTestRefuseLocalDir(t, opts) + + service := &scmManager{scope: ScopeSystem} + if err := service.Install(context.Background(), opts); err == nil { + t.Fatal("Install() = nil, want the refusal of the local directory") + } + + // Every directory the installation created is gone again: the cache + // root of the instance is removed before the state directory that + // holds it. + for _, dir := range []string{ + filepath.Join(root, "etc"), + filepath.Join(root, "etc", "hfdesk"), + filepath.Join(root, "var"), + filepath.Join(root, "var", "lib"), + filepath.Join(root, "var", "lib", "hfdesk"), + filepath.Join(root, "var", "lib", "hfdesk", "cache"), + filepath.Join(root, "var", "lib", "hfdesk", "cache", "hub"), + filepath.Join(root, "var", "log"), + filepath.Join(root, "var", "log", "hfdesk"), + } { + if _, err := os.Stat(dir); !errors.Is(err, fs.ErrNotExist) { + t.Errorf("the directory %q is still there after the failed installation (stat error: %v)", dir, err) + } + } +} + +// TestSCMFailedInstallKeepsADirectoryThatExistedBefore checks that a failed +// installation never removes a directory that existed before it: such a +// directory belongs to whoever owns it. +// +// The local directory is a directory the installation reuses: the +// installation never describes it, so its owner and its permissions are the +// ones it had. The config directory of the instance is a directory the +// installation owns: it is still there as well, with the owner it had. Its +// descriptor is the restrictive one of the instance, because the +// installation restricts the directories of the instance before it checks the +// directories it reuses. +func TestSCMFailedInstallKeepsADirectoryThatExistedBefore(t *testing.T) { + if !isElevatedProcess() { + t.Skip("an installation needs an elevated shell, run the test as an administrator") + } + root := t.TempDir() + opts := scmTestInstallOptions(t, root) + scmTestRefuseLocalDir(t, opts) + + // The config directory of the instance existed before the + // installation as well. + configDir := filepath.Dir(opts.Paths.ConfigFile) + if err := os.MkdirAll(configDir, 0o750); err != nil { + t.Fatalf("MkdirAll(%q) = %v, want nil", configDir, err) + } + configOwner, configMode := scmTestOwnerAndMode(t, configDir) + + owner, permissions := scmTestSecurity(t, opts.LocalDir) + + service := &scmManager{scope: ScopeSystem} + if err := service.Install(context.Background(), opts); err == nil { + t.Fatal("Install() = nil, want the refusal of the local directory") + } + + // The directory the installation reuses is still there, with the + // owner and the permissions it had. + info, err := os.Stat(opts.LocalDir) + if err != nil { + t.Fatalf("the local directory %q is gone after the failed installation: %v", opts.LocalDir, err) + } + if !info.IsDir() { + t.Errorf("the path %q is not a directory after the failed installation", opts.LocalDir) + } + gotOwner, gotPermissions := scmTestSecurity(t, opts.LocalDir) + if gotOwner != owner { + t.Errorf("the owner of the local directory %q = %q, want %q", opts.LocalDir, gotOwner, owner) + } + if gotPermissions != permissions { + t.Errorf("the permissions of the local directory %q = %q, want %q", opts.LocalDir, gotPermissions, permissions) + } + + // The directory of the instance that existed before the installation + // is still there as well, with the owner and the mode it had. + if _, err := os.Stat(configDir); err != nil { + t.Errorf("the config directory %q is gone after the failed installation: %v", configDir, err) + } + gotConfigOwner, gotConfigMode := scmTestOwnerAndMode(t, configDir) + if gotConfigOwner != configOwner { + t.Errorf("the owner of the config directory %q = %q, want %q", configDir, gotConfigOwner, configOwner) + } + if gotConfigMode != configMode { + t.Errorf("the mode of the config directory %q = %v, want %v", configDir, gotConfigMode, configMode) + } +} + +// TestSCMRemoveCreatedDirsKeepsADirectoryThatHoldsData checks that the +// cleanup of a failed installation never deletes data: a directory that +// holds something is kept, and the empty directories around it are removed. +// +// The directory that holds data is checked on the cleanup itself: an +// installation writes no file into the directories it creates, so a +// directory that holds something is a directory that holds the data of +// somebody else. +func TestSCMRemoveCreatedDirsKeepsADirectoryThatHoldsData(t *testing.T) { + root := t.TempDir() + holding := filepath.Join(root, "holding") + if err := os.Mkdir(holding, 0o700); err != nil { + t.Fatalf("Mkdir() = %v, want nil", err) + } + marker := filepath.Join(holding, "marker") + if err := os.WriteFile(marker, []byte("data"), 0o600); err != nil { + t.Fatalf("WriteFile() = %v, want nil", err) + } + empty := filepath.Join(root, "empty") + if err := os.Mkdir(empty, 0o700); err != nil { + t.Fatalf("Mkdir() = %v, want nil", err) + } + + removeCreatedDirs([]string{holding, empty}) + + // The directory that holds data and the file in it are still there. + if _, err := os.Stat(marker); err != nil { + t.Errorf("the file of the directory that holds data is gone: %v", err) + } + if _, err := os.Stat(holding); err != nil { + t.Errorf("the directory %q that holds data is gone: %v", holding, err) + } + // The empty directory is gone. + if _, err := os.Stat(empty); !errors.Is(err, fs.ErrNotExist) { + t.Errorf("the empty directory %q is still there (stat error: %v)", empty, err) + } +} + +// TestSCMFailedInstallReportsTheOriginalError checks that the cleanup of a +// failed installation never replaces the error of the installation: the +// caller gets the refusal of the preflight itself. +func TestSCMFailedInstallReportsTheOriginalError(t *testing.T) { + if !isElevatedProcess() { + t.Skip("an installation needs an elevated shell, run the test as an administrator") + } + root := t.TempDir() + opts := scmTestInstallOptions(t, root) + scmTestRefuseLocalDir(t, opts) + + // The refusal of the preflight is the error the caller gets. + want := preflightDataPath("local dir", opts.LocalDir) + if want == nil { + t.Fatal("the local directory is usable by the account of the instance, the test cannot fail the installation") + } + + service := &scmManager{scope: ScopeSystem} + err := service.Install(context.Background(), opts) + if err == nil { + t.Fatal("Install() = nil, want the refusal of the local directory") + } + if err.Error() != want.Error() { + t.Errorf("Install() = %q, want %q", err, want) + } +} + +// TestSCMSuccessfulInstallKeepsItsDirectories checks that a successful +// installation keeps the directories it created: the cleanup of a failed +// installation never touches the directories of an instance that is +// installed. +// +// The test registers the instance in the service control manager of the +// machine, so it needs an elevated shell. The registration never survives +// the test. +func TestSCMSuccessfulInstallKeepsItsDirectories(t *testing.T) { + if !isElevatedProcess() { + t.Skip("an installation needs an elevated shell, run the test as an administrator") + } + // A registration that exists already belongs to the machine and not + // to this test: it is never touched here. + state, err := scmRegistrationStateOf() + if err != nil { + t.Skipf("the service control manager is not available: %v", err) + } + if state != scmRegistrationAbsent { + t.Skipf("%s is registered, the test would touch a real installation", ServiceName) + } + + root := t.TempDir() + opts := scmTestInstallOptions(t, root) + // The local directory has to be usable by the account of the + // instance: the preflight refuses the installation otherwise. + if err := os.MkdirAll(opts.LocalDir, 0o700); err != nil { + t.Fatalf("MkdirAll(%q) = %v, want nil", opts.LocalDir, err) + } + scmTestSetDACL(t, opts.LocalDir, scmTestAllowInstanceIdentityDACL(t)) + + // The registration never survives the test. + t.Cleanup(func() { + cleanupManager, err := mgr.Connect() + if err != nil { + return + } + defer cleanupManager.Disconnect() + handle, err := cleanupManager.OpenService(ServiceName) + if err != nil { + return + } + defer handle.Close() + _ = handle.Delete() + }) + + service := &scmManager{scope: ScopeSystem} + if err := service.Install(context.Background(), opts); err != nil { + t.Fatalf("Install() = %v, want nil", err) + } + + for _, dir := range []string{ + filepath.Join(root, "etc"), + filepath.Join(root, "etc", "hfdesk"), + filepath.Join(root, "var"), + filepath.Join(root, "var", "lib"), + filepath.Join(root, "var", "lib", "hfdesk"), + filepath.Join(root, "var", "lib", "hfdesk", "cache"), + filepath.Join(root, "var", "lib", "hfdesk", "cache", "hub"), + filepath.Join(root, "var", "log"), + filepath.Join(root, "var", "log", "hfdesk"), + filepath.Join(root, "models"), + } { + info, err := os.Stat(dir) + if err != nil { + t.Errorf("the directory %q is gone after the successful installation: %v", dir, err) + continue + } + if !info.IsDir() { + t.Errorf("the path %q is not a directory after the successful installation", dir) + } + } +} From c0e1b39386f62bbdc6d80bae26d65c771bb6313a Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 22 Oct 2026 01:36:58 +1100 Subject: [PATCH 28/58] docs: state the lifecycle and the exit codes of the service family - Document what every lifecycle command does with a service that is not installed, and add the exit codes of the family with the distinction between a command line that could not be understood and an operation that failed. - State that the machine-wide start, stop and restart need an elevated shell. Co-Authored-By: opencode --- docs/SERVICE.md | 66 ++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 51 insertions(+), 15 deletions(-) diff --git a/docs/SERVICE.md b/docs/SERVICE.md index 874ae1a7..dc15e779 100644 --- a/docs/SERVICE.md +++ b/docs/SERVICE.md @@ -35,8 +35,9 @@ The dashboard is then served on . [Upgrades](#upgrades)). - The platform supervisor: `systemd` on Linux, `launchd` on macOS, the Service Control Manager on Windows. -- Administrator rights for a machine-wide (`--system`) installation. HFDesk - never elevates itself. +- Administrator rights for a machine-wide (`--system`) installation, and for + the machine-wide `start`, `stop` and `restart`. HFDesk never elevates + itself. - The **service account** of a machine-wide installation, already created (see [The identity of a machine-wide instance](#the-identity-of-a-machine-wide-instance)). A user-scope installation needs no account: it runs as the user itself. @@ -265,14 +266,19 @@ directory of the instance is the real log sink on macOS. ## Lifecycle commands -| Command | Effect | -|---|---| -| `install` | Registers the service with the supervisor and enables it for the boot of the scope. A macOS LaunchAgent starts the instance as soon as it is registered (`RunAtLoad`); a systemd unit is only registered and enabled. The `State` line of the summary reports the state the supervisor reached. An existing registration is overwritten. | -| `uninstall` | Removes the **registration only**. | -| `start` | Starts the registered service. | -| `stop` | Stops the registered service. | -| `restart` | Stops and starts the registered service. | -| `status` | Reports scope, state, registration path, executable, bind address, runtime paths and the effective cache root. | +| Command | Effect | A service that is not installed | +|---|---|---| +| `install` | Registers the service with the supervisor and enables it for the boot of the scope. A macOS LaunchAgent starts the instance as soon as it is registered (`RunAtLoad`); a systemd unit is only registered and enabled. The `State` line of the summary reports the state the supervisor reached. An existing registration is overwritten. | It creates the registration. | +| `uninstall` | Removes the **registration only**. | Clean success: the registration is already gone. | +| `start` | Starts the registered service. | **Error** that says the service is not installed. | +| `stop` | Stops the registered service. | Clean success: there is nothing running to stop. | +| `restart` | Stops and starts the registered service. | **Error** that says the service is not installed. | +| `status` | Reports scope, state, registration path, executable, bind address, runtime paths and the effective cache root. | Reports the state `not-installed` and exits with `1`. | + +The **machine-wide scope** needs an **elevated shell** for `start`, `stop` and +`restart`, exactly as it needs one for `install` and `uninstall` (see +[Privileged operations](#privileged-operations)). The user scope needs no +elevation: the instance belongs to the user who runs the command. **Uninstall never deletes user data.** It removes the registration and nothing else: not the models, not the cache, not the config file, not the jobs or the @@ -294,10 +300,32 @@ The commands are idempotent where the wanted state is already reached: - `uninstall` on a service that is not installed succeeds; the registration is already gone. - `start` on a service that already runs succeeds. -- `stop` on a service that is already stopped succeeds. +- `stop` on a service that is already stopped succeeds, and so does a `stop` on + a service that is not installed at all: there is nothing running to stop. - `install` over an existing registration replaces it. -- `start`, `stop` and `restart` on a service that is not installed fail with a - readable "not installed" error. + +`start` and `restart` are the two commands that need a registration to work on: +on a service that is not installed they fail with a readable "not installed" +error. + +## Exit codes + +Every command of the service family ends with one of three exit codes: + +| Code | Meaning | +|---|---| +| `0` | The operation succeeded. | +| `1` | The operation failed: the service is not installed, the elevation is missing, the supervisor refused the operation, or the installation or the removal failed. | +| `2` | The command line could not be understood: an unknown or a missing subcommand, an unknown flag, mutually exclusive scope selectors, a relative path, an unusable `--executable` or an unparsable `--listen`. | + +`status` uses the same codes with a narrower meaning: it exits with `0` when the +state of the service is `running`, `stopped`, `starting` or `stopping`, and with +`1` when the state is `not-installed`, `broken`, `failed` or `unknown`, so a +script can branch on it. + +The internal `run` mode is the exception in practice: it is the foreground host +of the instance, so it only ends when the supervisor stops the instance. It ends +with the same three codes. ## Diagnosing a broken registration @@ -386,8 +414,8 @@ same service at the same time. - The Service Control Manager is used **directly through the Go API**, never `sc.exe` and never PowerShell. -- Installing or removing the service needs an **elevated shell**. HFDesk does - not self-elevate and never invokes UAC. +- Installing, removing, starting, stopping and restarting the service needs an + **elevated shell**. HFDesk does not self-elevate and never invokes UAC. - The service identity is `LocalService`, a least-privilege Windows service account, not `LocalSystem`. - The service-owned state under `%ProgramData%\HFDesk` is ACL-restricted. The @@ -475,6 +503,14 @@ HFDesk never invokes `sudo`, `su`, `pkexec` or a UAC prompt. A `--system` installation that lacks the rights fails with a permission error instead of escalating on its own. +The **machine-wide scope** needs an **elevated shell** (root on Linux) for +`install`, `uninstall`, `start`, `stop` and `restart`: the registration lives +in a machine-wide location and the supervisor starts and stops the instance as +the system, not as the user who runs the command. A shell that is not elevated +fails with a permission error that names the requirement instead of escalating +on its own. The user scope needs no elevation: the instance belongs to the user +who runs the command. + ## Upgrades The registration points at an absolute executable path, so an upgrade is a From 4303798d3cffca9346851d5600fe97afc93e4688 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 22 Oct 2026 02:18:35 +1100 Subject: [PATCH 29/58] fix(service): restrict only the paths a Windows installation created - The restrictive descriptor of the service identity is applied only to the directories and the config file the installation created. A path that existed before keeps its descriptor: it is only read, to verify that the service identity can read and write it, and the installation is refused with actionable guidance when it cannot. Co-Authored-By: opencode --- internal/service/manager_windows.go | 199 +++++++++--- internal/service/manager_windows_test.go | 386 ++++++++++++++++++++--- 2 files changed, 507 insertions(+), 78 deletions(-) diff --git a/internal/service/manager_windows.go b/internal/service/manager_windows.go index 7615d8f0..0c17d305 100644 --- a/internal/service/manager_windows.go +++ b/internal/service/manager_windows.go @@ -128,10 +128,17 @@ func newPlatformManager(scope Scope) (Manager, error) { // source of the instance. // // The state of the instance is restricted before the registration is -// written: every directory the instance owns carries the restrictive +// written: every directory the installation created carries the restrictive // descriptor, so the directories the instance creates inside them inherit // it and the account of the instance can work with them. // +// A path of the instance that existed before the installation keeps its +// descriptor: it belongs to whoever named it, and the installation never +// takes a directory over from the owner it already has. The installation +// only reads the descriptor of such a path, to check that the account of +// the instance can work with it, and it refuses the installation before it +// changes a single descriptor when it cannot. +// // The reused directories are checked before the registration is written as // well: a directory the account of the instance cannot reach is a failed // installation and not a service that fails later with an opaque access @@ -159,17 +166,13 @@ func (m *scmManager) Install(ctx context.Context, opts InstallOptions) error { if err := requireElevated("an installation"); err != nil { return err } - // The directories the installation is about to create are remembered - // before they are created, so the installation knows which of them it - // owns. The list holds the directories of the instance and the - // directories the installation reuses: the preflight creates the - // missing ones as well, so they belong to the installation too. - dirs := scmInstallDirs(opts) - created := make(map[string][]string, len(dirs)) - for _, dir := range dirs { - created[dir] = createdDirs(dir) - } - order := createdDirOrder(opts, created) + // The paths the installation is about to create are remembered before + // they are created, so the installation knows which of them it owns. + // The list holds the directories of the instance and the directories + // the installation reuses: the preflight creates the missing ones as + // well, so they belong to the installation too. + snapshot := newSCMInstallSnapshot(opts) + order := createdDirOrder(opts, snapshot.created) // A failed installation removes the directories it created. The // cleanup is best effort: it never replaces the error of the // installation, which is what the caller gets. @@ -187,7 +190,13 @@ func (m *scmManager) Install(ctx context.Context, opts InstallOptions) error { // directories are checked: the directories the instance creates // inside its own directories inherit the restrictive descriptor, so // the account of the instance can work with them. - if err := restrictInstanceState(opts); err != nil { + // + // A path of the instance that existed before the installation is + // never rewritten: its descriptor is only read, to check that the + // account of the instance can work with it. The check of every such + // path happens before the first descriptor is changed, so a refused + // installation leaves every descriptor of the machine as it was. + if err := restrictInstanceState(opts, snapshot); err != nil { return fail(err) } if err := m.preflight(opts); err != nil { @@ -325,6 +334,48 @@ func createdDirOrder(opts InstallOptions, created map[string][]string) []string return order } +// scmInstallSnapshot is the state of the paths of an installation before it +// creates anything. +// +// The snapshot is read before the first directory is created, so the +// installation knows which of the paths of the instance it owns and which +// ones belong to whoever named them. +type scmInstallSnapshot struct { + // created holds the directories the installation creates for every + // directory of the installation, the missing parents included. + created map[string][]string + // configFileMissing reports whether the config file of the instance + // did not exist before the installation. + configFileMissing bool +} + +// newSCMInstallSnapshot reads the state of the paths of an installation +// before it creates anything. +// +// The directories of the installation are read one by one: a directory that +// does not exist yet is a directory the installation creates, and so are the +// missing parents of it. +func newSCMInstallSnapshot(opts InstallOptions) scmInstallSnapshot { + dirs := scmInstallDirs(opts) + created := make(map[string][]string, len(dirs)) + for _, dir := range dirs { + created[dir] = createdDirs(dir) + } + return scmInstallSnapshot{ + created: created, + configFileMissing: pathMissing(opts.Paths.ConfigFile), + } +} + +// pathMissing reports whether a path does not exist. +// +// A path that cannot be read is not missing: the installation leaves such a +// path alone instead of taking it over. +func pathMissing(path string) bool { + _, err := os.Stat(path) + return errors.Is(err, fs.ErrNotExist) +} + // removeCreatedDirs removes the directories an installation created. // // The directories are removed in the reverse order of their creation, so a @@ -1509,52 +1560,118 @@ func registerEventSource() error { // administrators of the machine and the account the instance runs as may // work with it, nobody else. // -// Every directory the instance owns carries the descriptor: the directory -// of the config file, the state directory and the log directory, and so -// does the config file itself. The descriptor is applied to the path -// itself and never recursively. +// The restrictive descriptor is applied to the paths the installation +// created: the directory of the config file, the state directory, the log +// directory and the config file itself. The descriptor is applied to the +// path itself and never recursively. +// +// A path that existed before the installation belongs to whoever owns it: +// its descriptor is only read, to check that the account of the instance can +// work with it. The check of every such path happens before the first +// descriptor is changed, so a refused installation leaves every descriptor +// of the machine as it was. // // The model and the cache trees of an installation are never re-owned and // never re-ACLed: they can hold gigabytes of data that the user may reuse // from an interactive run, and a recursive change of the access rights // would take the data away from the user who owns it. -func restrictInstanceState(opts InstallOptions) error { - for _, path := range instanceStatePaths(opts) { - if err := applyRestrictiveACL(path); err != nil { +func restrictInstanceState(opts InstallOptions, snapshot scmInstallSnapshot) error { + paths := instanceStatePaths(opts, snapshot) + // The paths that existed before the installation are checked first: + // the account of the instance has to be able to work with them + // before a single descriptor is changed. + for _, path := range paths { + if path.created { + continue + } + if err := checkServiceAccess(path.name, path.path); err != nil { + return err + } + } + for _, path := range paths { + if !path.created { + continue + } + if err := applyRestrictiveACL(path.path); err != nil { return err } } return nil } -// instanceStatePaths returns the paths of the instance that carry the -// restrictive descriptor: the directory of the config file, the state +// scmInstancePath is one path of the instance whose descriptor the +// installation manages. +type scmInstancePath struct { + // name names the path in an error message. + name string + // path is the absolute path of the path. + path string + // created reports whether the installation creates the path itself. + created bool +} + +// instanceStatePaths returns the paths of the instance whose descriptor the +// installation manages: the directory of the config file, the state // directory, the log directory and the config file itself. // -// The config file is the only path that may not exist yet: a config file -// that is written later inherits the descriptor of its directory. A path -// that is named twice is returned once: the config file can live in the -// state directory of the instance. -func instanceStatePaths(opts InstallOptions) []string { - paths := []string{ - filepath.Dir(opts.Paths.ConfigFile), - opts.Paths.StateDir, - opts.Paths.LogDir, - } - if info, err := os.Stat(opts.Paths.ConfigFile); err == nil && info.Mode().IsRegular() { - paths = append(paths, opts.Paths.ConfigFile) +// A path the installation creates is marked as created: the snapshot holds +// the directories an installation creates for every directory of the +// installation, and it is read before the directories are created, so the +// installation knows which of them it owns. The missing parents of such a +// directory belong to the installation as well. +// +// A path that existed before the installation is not marked: it belongs to +// whoever owns it. +// +// The config file is a file the instance writes itself: a file that existed +// before the installation is not marked, a file the installation created is. +// +// A path that is named twice is returned once: the config file can live in +// the state directory of the instance. +func instanceStatePaths(opts InstallOptions, snapshot scmInstallSnapshot) []scmInstancePath { + var paths []scmInstancePath + seen := make(map[string]bool, 4) + add := func(name, path string, created bool) { + if path == "" || path == "." || seen[path] { + return + } + seen[path] = true + paths = append(paths, scmInstancePath{name: name, path: path, created: created}) } - unique := make([]string, 0, len(paths)) - seen := make(map[string]bool, len(paths)) - for _, path := range paths { - if path == "" || seen[path] { + for _, dir := range []struct { + name string + path string + }{ + {"config dir", filepath.Dir(opts.Paths.ConfigFile)}, + {"state dir", opts.Paths.StateDir}, + {"log dir", opts.Paths.LogDir}, + } { + if dir.path == "" || dir.path == "." { continue } - seen[path] = true - unique = append(unique, path) + own := snapshot.created[dir.path] + if len(own) == 0 { + // The directory existed before the installation: it + // belongs to whoever owns it. + add(dir.name, dir.path, false) + continue + } + // The installation creates the directory and the missing + // parents of it: they belong to the installation. + for _, path := range own { + add(dir.name, path, true) + } } - return unique + + // The config file holds the credentials of the instance. It is a + // file the instance writes itself, so it is only a path of the + // installation when it exists: a file that is written later inherits + // the descriptor of its directory. + if info, err := os.Stat(opts.Paths.ConfigFile); err == nil && info.Mode().IsRegular() { + add("config file", opts.Paths.ConfigFile, snapshot.configFileMissing) + } + return paths } // applyRestrictiveACL applies the security descriptor of the instance to a diff --git a/internal/service/manager_windows_test.go b/internal/service/manager_windows_test.go index 05fe8e28..8849fdc2 100644 --- a/internal/service/manager_windows_test.go +++ b/internal/service/manager_windows_test.go @@ -13,6 +13,7 @@ import ( "os" "path/filepath" "reflect" + "slices" "strings" "testing" "time" @@ -1064,11 +1065,14 @@ func TestApplyRegistrationReportsTheEffectiveCacheDir(t *testing.T) { } } -// TestInstanceStatePathsProtectEveryOwnedPath checks that every path the -// instance owns carries the restrictive descriptor: the directory of the -// config file, the state directory, the log directory and the config file -// itself. -func TestInstanceStatePathsProtectEveryOwnedPath(t *testing.T) { +// TestInstanceStatePathsTellTheCreatedPathsFromTheExistingOnes checks that +// the installation knows which paths of the instance it owns: the +// directories it creates carry the restrictive descriptor, the paths that +// existed before the installation do not. +// +// A directory that existed before the installation belongs to whoever owns +// it, so the installation reads its descriptor and changes nothing. +func TestInstanceStatePathsTellTheCreatedPathsFromTheExistingOnes(t *testing.T) { root := t.TempDir() opts := scmTestOptions() opts.Paths = runtimepaths.Paths{ @@ -1076,6 +1080,12 @@ func TestInstanceStatePathsProtectEveryOwnedPath(t *testing.T) { StateDir: filepath.Join(root, "var"), LogDir: filepath.Join(root, "var", "logs"), } + opts.CacheDir = filepath.Join(root, "cache") + opts.HubDir = filepath.Join(root, "cache", "hub") + opts.LocalDir = filepath.Join(root, "models") + + // The config directory and the config file existed before the + // installation. if err := os.MkdirAll(filepath.Dir(opts.Paths.ConfigFile), 0o700); err != nil { t.Fatalf("MkdirAll() = %v, want nil", err) } @@ -1083,38 +1093,34 @@ func TestInstanceStatePathsProtectEveryOwnedPath(t *testing.T) { t.Fatalf("WriteFile() = %v, want nil", err) } - want := []string{ - filepath.Dir(opts.Paths.ConfigFile), - opts.Paths.StateDir, - opts.Paths.LogDir, - opts.Paths.ConfigFile, - } - got := instanceStatePaths(opts) - if len(got) != len(want) { - t.Fatalf("instanceStatePaths() = %v, want %v", got, want) - } - protected := make(map[string]bool, len(got)) - for _, path := range got { - protected[path] = true - } - for _, path := range want { - if !protected[path] { - t.Errorf("instanceStatePaths() = %v, want %v", got, want) + snapshot := newSCMInstallSnapshot(opts) + paths := instanceStatePaths(opts, snapshot) + + created := make(map[string]bool, len(paths)) + existing := make(map[string]bool, len(paths)) + for _, path := range paths { + if path.created { + created[path.path] = true + continue } + existing[path.path] = true } - // A config file that does not exist yet inherits the descriptor of its - // directory, so it is not part of the paths. - if err := os.Remove(opts.Paths.ConfigFile); err != nil { - t.Fatalf("Remove() = %v, want nil", err) + // The directories the installation creates are the state directory + // and the log directory: the config directory existed already. + for _, want := range []string{opts.Paths.StateDir, opts.Paths.LogDir} { + if !created[want] { + t.Errorf("the state directory %q is not marked as created: %v", want, paths) + } } - got = instanceStatePaths(opts) - if len(got) != len(want)-1 { - t.Fatalf("instanceStatePaths() = %v, want the three directories of the instance", got) + for _, want := range []string{filepath.Dir(opts.Paths.ConfigFile), opts.Paths.ConfigFile} { + if !existing[want] { + t.Errorf("the path %q is not marked as existing: %v", want, paths) + } } - for _, path := range got { - if path == opts.Paths.ConfigFile { - t.Errorf("instanceStatePaths() = %v, want no config file", got) + for path := range created { + if existing[path] { + t.Errorf("the path %q is marked as created and as existing: %v", path, paths) } } } @@ -1136,6 +1142,304 @@ func scmTestCurrentUserSID(t *testing.T) string { return user.User.Sid.String() } +// scmTestAliases maps the aliases of the trustees of an access control +// entry onto the SID they stand for. +// +// The platform writes a trustee as an alias or as a raw SID, depending on +// the entry. The tests compare the trustees as SIDs, so the form the +// platform uses does not matter. +var scmTestAliases = map[string]string{ + "SY": "S-1-5-18", // LocalSystem + "BA": "S-1-5-32-544", // Administrators + "LS": "S-1-5-19", // LocalService + "WD": "S-1-1-0", // Everyone +} + +// scmTestDACL returns the access control list of a path in its SDDL form. +// +// The descriptor of a path carries the owner and the group next to the +// access control list: only the list is compared, so a change of the access +// rights is told apart from a change of the owner. +func scmTestDACL(t *testing.T, path string) string { + t.Helper() + _, permissions := scmTestSecurity(t, path) + index := strings.Index(permissions, "D:") + if index < 0 { + t.Fatalf("the security descriptor of %q = %q, want an access control list", path, permissions) + } + return permissions[index:] +} + +// scmTestTrustees returns the trustees of the access control list of a path. +// +// The trustees are returned as SIDs: the aliases of the platform are +// resolved, so the assertions do not depend on the form the platform writes +// them in. +func scmTestTrustees(t *testing.T, path string) []string { + t.Helper() + var trustees []string + for _, ace := range parseSDDLAces(scmTestDACL(t, path)) { + trustee := strings.ToUpper(strings.TrimSpace(ace.trustee)) + if sid, ok := scmTestAliases[trustee]; ok { + trustee = sid + } + trustees = append(trustees, trustee) + } + return trustees +} + +// scmTestDACLProtected reports whether the access control list of a path +// blocks the inheritance of its parent directory. +// +// The flag is read from the descriptor of the path itself and not from its +// SDDL form, so the assertion does not depend on the way the platform writes +// the flag. +func scmTestDACLProtected(t *testing.T, path string) bool { + t.Helper() + sd, err := windows.GetNamedSecurityInfo( + path, + windows.SE_FILE_OBJECT, + windows.DACL_SECURITY_INFORMATION, + ) + if err != nil { + t.Fatalf("the security descriptor of %q cannot be read: %v", path, err) + } + control, _, err := sd.Control() + if err != nil { + t.Fatalf("the control bits of %q cannot be read: %v", path, err) + } + return control&windows.SE_DACL_PROTECTED != 0 +} + +// TestSCMInstanceStateRestrictsTheDirectoriesItCreated checks that the +// directories the installation creates carry the restrictive descriptor of +// the instance. +// +// The descriptor is the one of the instance: SYSTEM, the administrators of +// the machine and the account the instance runs as, nobody else, and the +// inheritance of the parent directory is cut off. +func TestSCMInstanceStateRestrictsTheDirectoriesItCreated(t *testing.T) { + if !isElevatedProcess() { + t.Skip("an installation needs an elevated shell, run the test as an administrator") + } + root := t.TempDir() + opts := scmTestInstallOptions(t, root) + + snapshot := newSCMInstallSnapshot(opts) + if err := PrepareDirs(opts); err != nil { + t.Fatalf("PrepareDirs() = %v, want nil", err) + } + if err := restrictInstanceState(opts, snapshot); err != nil { + t.Fatalf("restrictInstanceState() = %v, want nil", err) + } + + want := []string{"S-1-5-18", "S-1-5-19", "S-1-5-32-544"} + for _, dir := range []string{ + filepath.Dir(opts.Paths.ConfigFile), + opts.Paths.StateDir, + opts.Paths.LogDir, + } { + if !scmTestDACLProtected(t, dir) { + t.Errorf("the access control list of %q is not protected, want a protected one", dir) + } + got := scmTestTrustees(t, dir) + slices.Sort(got) + if !slices.Equal(got, want) { + t.Errorf("the trustees of %q = %v, want %v", dir, got, want) + } + } +} + +// TestSCMInstanceStateKeepsTheDescriptorOfADirectoryThatExistedBefore +// checks that a directory the user named and that existed before the +// installation keeps its descriptor. +// +// The descriptor of the directory grants the access to everybody, so a +// rewrite of it by the restrictive descriptor of the instance would be +// visible in the comparison. +func TestSCMInstanceStateKeepsTheDescriptorOfADirectoryThatExistedBefore(t *testing.T) { + if !isElevatedProcess() { + t.Skip("an installation needs an elevated shell, run the test as an administrator") + } + root := t.TempDir() + opts := scmTestInstallOptions(t, root) + + // The state directory existed before the installation. + stateDir := opts.Paths.StateDir + if err := os.MkdirAll(stateDir, 0o750); err != nil { + t.Fatalf("MkdirAll(%q) = %v, want nil", stateDir, err) + } + scmTestSetDACL(t, stateDir, fmt.Sprintf("D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;FA;;;WD)")) + before := scmTestDACL(t, stateDir) + + snapshot := newSCMInstallSnapshot(opts) + if err := PrepareDirs(opts); err != nil { + t.Fatalf("PrepareDirs() = %v, want nil", err) + } + if err := restrictInstanceState(opts, snapshot); err != nil { + t.Fatalf("restrictInstanceState() = %v, want nil", err) + } + + if after := scmTestDACL(t, stateDir); after != before { + t.Errorf("the access control list of the state directory %q = %q, want %q", stateDir, after, before) + } + // The restrictive descriptor of the instance was not applied to it: + // everybody still has the access the directory had. + trustees := scmTestTrustees(t, stateDir) + if !slices.Contains(trustees, "S-1-1-0") { + t.Errorf("the trustees of the state directory %q = %v, want everybody", stateDir, trustees) + } +} + +// TestSCMInstanceStateRefusesADirectoryTheIdentityCannotUse checks that a +// directory that existed before the installation and that the account of the +// instance cannot use is refused, and that no descriptor was changed. +// +// The refusal happens before the first descriptor is changed, so the +// directories the installation created keep the descriptor they inherited. +func TestSCMInstanceStateRefusesADirectoryTheIdentityCannotUse(t *testing.T) { + if !isElevatedProcess() { + t.Skip("an installation needs an elevated shell, run the test as an administrator") + } + root := t.TempDir() + opts := scmTestInstallOptions(t, root) + + // The state directory existed before the installation and grants the + // access to the machine and to its administrators only. + stateDir := opts.Paths.StateDir + if err := os.MkdirAll(stateDir, 0o750); err != nil { + t.Fatalf("MkdirAll(%q) = %v, want nil", stateDir, err) + } + scmTestSetDACL(t, stateDir, scmTestDenyInstanceIdentityDACL(t)) + + snapshot := newSCMInstallSnapshot(opts) + if err := PrepareDirs(opts); err != nil { + t.Fatalf("PrepareDirs() = %v, want nil", err) + } + before := make(map[string]string, 3) + for _, path := range []string{ + stateDir, + filepath.Dir(opts.Paths.ConfigFile), + opts.Paths.LogDir, + } { + before[path] = scmTestDACL(t, path) + } + + err := restrictInstanceState(opts, snapshot) + if err == nil { + t.Fatal("restrictInstanceState() = nil, want the refusal of the state directory") + } + if !strings.Contains(err.Error(), stateDir) { + t.Errorf("restrictInstanceState() = %v, want an error that names the path", err) + } + if !strings.Contains(err.Error(), scmServiceIdentityName) { + t.Errorf("restrictInstanceState() = %v, want an error that names the account of the instance", err) + } + if !strings.Contains(err.Error(), "grant the account") { + t.Errorf("restrictInstanceState() = %v, want an error that tells the user what to do", err) + } + + // No descriptor was changed anywhere: not on the directory that + // existed before and not on the directories the installation + // created. + for path, want := range before { + if got := scmTestDACL(t, path); got != want { + t.Errorf("the access control list of %q = %q, want %q", path, got, want) + } + } +} + +// TestSCMInstanceStateKeepsTheDescriptorOfAConfigFileThatExistedBefore +// checks that the config file of the instance keeps its descriptor when it +// existed before the installation. +// +// The config file holds the credentials of the instance, but it belongs to +// whoever wrote it: the installation reads its descriptor and changes +// nothing. +func TestSCMInstanceStateKeepsTheDescriptorOfAConfigFileThatExistedBefore(t *testing.T) { + if !isElevatedProcess() { + t.Skip("an installation needs an elevated shell, run the test as an administrator") + } + root := t.TempDir() + opts := scmTestInstallOptions(t, root) + + // The config directory and the config file existed before the + // installation. + configDir := filepath.Dir(opts.Paths.ConfigFile) + if err := os.MkdirAll(configDir, 0o750); err != nil { + t.Fatalf("MkdirAll(%q) = %v, want nil", configDir, err) + } + // The directory has to be usable by the account of the instance, + // otherwise the installation refuses it. + scmTestSetDACL(t, configDir, scmTestAllowInstanceIdentityDACL(t)) + if err := os.WriteFile(opts.Paths.ConfigFile, []byte("{}"), 0o600); err != nil { + t.Fatalf("WriteFile() = %v, want nil", err) + } + scmTestSetDACL(t, opts.Paths.ConfigFile, fmt.Sprintf("D:P(A;;FA;;;SY)(A;;FA;;;BA)(A;;FA;;;WD)")) + before := scmTestDACL(t, opts.Paths.ConfigFile) + + snapshot := newSCMInstallSnapshot(opts) + if err := PrepareDirs(opts); err != nil { + t.Fatalf("PrepareDirs() = %v, want nil", err) + } + if err := restrictInstanceState(opts, snapshot); err != nil { + t.Fatalf("restrictInstanceState() = %v, want nil", err) + } + + if after := scmTestDACL(t, opts.Paths.ConfigFile); after != before { + t.Errorf("the access control list of the config file %q = %q, want %q", opts.Paths.ConfigFile, after, before) + } +} + +// TestSCMInstanceStateKeepsTheDescriptorOfTheReusedDirectories checks that +// the directories the installation reuses keep their descriptor. +// +// The cache root, the hub directory and the local output directory can hold +// the models of the user: the installation reads their descriptor and never +// changes it. +func TestSCMInstanceStateKeepsTheDescriptorOfTheReusedDirectories(t *testing.T) { + if !isElevatedProcess() { + t.Skip("an installation needs an elevated shell, run the test as an administrator") + } + root := t.TempDir() + opts := scmTestInstallOptions(t, root) + + // The reused directories existed before the installation. The + // descriptor of every one of them is protected, so the inheritance + // of the platform cannot change it behind the back of the test. + reused := []string{opts.CacheDir, opts.HubDir, opts.LocalDir} + before := make(map[string]string, len(reused)) + for _, dir := range reused { + if err := os.MkdirAll(dir, 0o750); err != nil { + t.Fatalf("MkdirAll(%q) = %v, want nil", dir, err) + } + scmTestSetDACL(t, dir, scmTestAllowInstanceIdentityDACL(t)) + before[dir] = scmTestDACL(t, dir) + } + // The cache root lives in the state directory of the instance, so the + // state directory existed before the installation as well: it has to + // be usable by the account of the instance. + scmTestSetDACL(t, opts.Paths.StateDir, scmTestAllowInstanceIdentityDACL(t)) + + snapshot := newSCMInstallSnapshot(opts) + if err := PrepareDirs(opts); err != nil { + t.Fatalf("PrepareDirs() = %v, want nil", err) + } + if err := restrictInstanceState(opts, snapshot); err != nil { + t.Fatalf("restrictInstanceState() = %v, want nil", err) + } + // The preflight reads the reused directories as well. + if err := preflightDataPaths(opts); err != nil { + t.Fatalf("preflightDataPaths() = %v, want nil", err) + } + + for _, dir := range reused { + if after := scmTestDACL(t, dir); after != before[dir] { + t.Errorf("the access control list of the reused directory %q = %q, want %q", dir, after, before[dir]) + } + } +} + // scmTestSetDACL applies an access control list to a path. // // The descriptor is applied to the path itself and never recursively. @@ -1361,10 +1665,9 @@ func TestSCMFailedInstallRemovesTheDirectoriesItCreated(t *testing.T) { // The local directory is a directory the installation reuses: the // installation never describes it, so its owner and its permissions are the // ones it had. The config directory of the instance is a directory the -// installation owns: it is still there as well, with the owner it had. Its -// descriptor is the restrictive one of the instance, because the -// installation restricts the directories of the instance before it checks the -// directories it reuses. +// installation owns as well: it is still there as well, with the owner it +// had and with the descriptor it had, because the installation never +// rewrites the descriptor of a path that existed before it. func TestSCMFailedInstallKeepsADirectoryThatExistedBefore(t *testing.T) { if !isElevatedProcess() { t.Skip("an installation needs an elevated shell, run the test as an administrator") @@ -1379,7 +1682,12 @@ func TestSCMFailedInstallKeepsADirectoryThatExistedBefore(t *testing.T) { if err := os.MkdirAll(configDir, 0o750); err != nil { t.Fatalf("MkdirAll(%q) = %v, want nil", configDir, err) } + // The directory has to be usable by the account of the instance: + // the installation refuses it otherwise, before it reaches the local + // directory. + scmTestSetDACL(t, configDir, scmTestAllowInstanceIdentityDACL(t)) configOwner, configMode := scmTestOwnerAndMode(t, configDir) + configPermissions := scmTestDACL(t, configDir) owner, permissions := scmTestSecurity(t, opts.LocalDir) @@ -1406,7 +1714,8 @@ func TestSCMFailedInstallKeepsADirectoryThatExistedBefore(t *testing.T) { } // The directory of the instance that existed before the installation - // is still there as well, with the owner and the mode it had. + // is still there as well, with the owner, the mode and the + // descriptor it had. if _, err := os.Stat(configDir); err != nil { t.Errorf("the config directory %q is gone after the failed installation: %v", configDir, err) } @@ -1417,6 +1726,9 @@ func TestSCMFailedInstallKeepsADirectoryThatExistedBefore(t *testing.T) { if gotConfigMode != configMode { t.Errorf("the mode of the config directory %q = %v, want %v", configDir, gotConfigMode, configMode) } + if gotConfigDACL := scmTestDACL(t, configDir); gotConfigDACL != configPermissions { + t.Errorf("the access control list of the config directory %q = %q, want %q", configDir, gotConfigDACL, configPermissions) + } } // TestSCMRemoveCreatedDirsKeepsADirectoryThatHoldsData checks that the From add174e1256d2cabb01dcfb7859db435f15ec36f Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 22 Oct 2026 02:18:35 +1100 Subject: [PATCH 30/58] fix(cmd): report the account the instance actually runs as - Windows refuses the --service-user option: the machine-wide instance runs as the built-in, passwordless LocalService account, and a service under a user account would need a credential that must never reach the command line. - The install summary reports the identity the registration uses, never the account the command line named. Co-Authored-By: opencode --- cmd/hfdesk/service.go | 58 +++++-- cmd/hfdesk/service_test.go | 328 +++++++++++++++++++++++++++++++++++++ 2 files changed, 368 insertions(+), 18 deletions(-) diff --git a/cmd/hfdesk/service.go b/cmd/hfdesk/service.go index 71c4c479..e8aa2a08 100644 --- a/cmd/hfdesk/service.go +++ b/cmd/hfdesk/service.go @@ -56,9 +56,13 @@ const ( cacheRootNotRecorded = "not recorded (unknown)" // windowsServiceAccount is the identity of a machine-wide instance on - // Windows. The service control manager registers the built-in - // LocalService account, which has no name of its own. - windowsServiceAccount = "LocalService" + // Windows. + // + // The service control manager registers the built-in LocalService + // account, which has no name of its own: the value is the name the + // registration carries, so the summary names the account the instance + // really runs as and not the one a command line asked for. + windowsServiceAccount = `NT AUTHORITY\LocalService` ) // serviceSubcommands lists the subcommands of the family in the order the @@ -231,8 +235,9 @@ Options of the "install" subcommand: bind address of the instance (default %s) --service-user ACCOUNT account a machine-wide instance runs as - (the machine-wide scope only; default: the service - account of the platform) + (the machine-wide scope of Linux and macOS; the + account cannot be chosen on Windows; default: the + service account of the platform) --executable PATH absolute path of the binary to register (default: the invoked binary) @@ -270,14 +275,17 @@ func writeField(w io.Writer, key, value string) { fmt.Fprintf(w, "%-14s %s\n", key+":", value) } -// isWindows reports whether the process runs on Windows. +// platformGOOS is the operating system the command line decides for. +// +// The variable is the seam of the tests: the decisions of the command line +// that depend on the platform (the default scope, the account of an +// instance) are checked on every platform, so the behaviour of a Windows +// instance is covered by a test that runs on Linux as well. +var platformGOOS = runtime.GOOS + +// isWindows reports whether the command line behaves as on Windows. func isWindows() bool { - switch runtime.GOOS { - case "windows": - return true - default: - return false - } + return platformGOOS == "windows" } // defaultScope returns the scope of an installation that does not ask for @@ -508,9 +516,21 @@ type installFlags struct { // // A named account of a user scope instance is a command line that could not // be understood, so the error carries that kind. +// +// The account of a machine-wide instance cannot be chosen on Windows: the +// service control manager registers the instance as the built-in +// LocalService account, and a service under a user account would need a +// credential, which must never reach the command line of an installation. +// A named account there is a command line that could not be understood as +// well, so its error carries that kind too. func buildInstallOptions(scope service.Scope, flags installFlags, executable string) (service.InstallOptions, error) { - if flags.serviceUser != "" && scope == service.ScopeUser { - return service.InstallOptions{}, newServiceUsageError(errors.New("--service-user applies to a machine-wide installation only: a user scope instance runs as the user itself, drop the option or use --system")) + if flags.serviceUser != "" { + if scope == service.ScopeUser { + return service.InstallOptions{}, newServiceUsageError(errors.New("--service-user applies to a machine-wide installation only: a user scope instance runs as the user itself, drop the option or use --system")) + } + if isWindows() { + return service.InstallOptions{}, newServiceUsageError(fmt.Errorf("--service-user is not available on windows: the machine-wide instance runs as the built-in %s account and the account cannot be chosen on this platform (a service under a user account needs a credential, and a credential must never reach a command line), drop the option", windowsServiceAccount)) + } } paths, err := service.DefaultPaths(scope) @@ -574,7 +594,7 @@ func serviceInstall(args []string, stdout io.Writer) error { fs.StringVar(&flags.cacheDir, "cache-dir", "", "absolute path of the Hugging Face cache root") fs.StringVar(&flags.hubDir, "hub-dir", "", "absolute path of the Hugging Face hub cache directory") fs.StringVar(&flags.localDir, "local-dir", "", "absolute path of the flat output directory") - fs.StringVar(&flags.serviceUser, "service-user", "", "account the machine-wide instance runs as (the machine-wide scope only)") + fs.StringVar(&flags.serviceUser, "service-user", "", "account the machine-wide instance runs as (the machine-wide scope of Linux and macOS; the account cannot be chosen on Windows)") fs.StringVar(&flags.executable, "executable", "", "absolute path of the binary to register (default: the invoked binary)") if err := fs.Parse(args); err != nil { @@ -684,15 +704,17 @@ func writeInstallSummary(w io.Writer, ctx context.Context, mgr service.Manager, // A machine-wide instance runs as the dedicated service account of the // platform: the account the user named, or the default of the platform. // Windows registers the built-in LocalService account, which has no name -// of its own. +// of its own: there the account of the registration is the one that is +// reported, and an account of the command line is never reported, because +// the registration does not use it. func installAccount(scope service.Scope, serviceUser string) string { switch { case scope == service.ScopeUser: return invokingUser() - case serviceUser != "": - return serviceUser case isWindows(): return windowsServiceAccount + case serviceUser != "": + return serviceUser default: return service.DefaultServiceUser() } diff --git a/cmd/hfdesk/service_test.go b/cmd/hfdesk/service_test.go index 095e57b6..4a6bd2a2 100644 --- a/cmd/hfdesk/service_test.go +++ b/cmd/hfdesk/service_test.go @@ -66,6 +66,19 @@ func useFakeManager(t *testing.T, mgr service.Manager) { t.Cleanup(func() { newServiceManager = previous }) } +// usePlatform makes the command line behave as on the given platform for +// the duration of the test. +// +// The platform is the seam of the tests: a decision of the command line +// about Windows is checked on every platform, so the behaviour of a +// Windows instance is covered by a test that runs on Linux as well. +func usePlatform(t *testing.T, goos string) { + t.Helper() + previous := platformGOOS + platformGOOS = goos + t.Cleanup(func() { platformGOOS = previous }) +} + // personalCacheRoot returns the Hugging Face cache of the user that runs // the test. // @@ -125,6 +138,35 @@ func usageOptionOwner(out, option string) string { return "" } +// usageOptionText returns the description of an option in a usage block: +// the line that names the option and the lines below it that belong to it. +// +// The option is named with the dashes it is written with, so the option of +// the family ("--service-user") and the option of the flag set of a +// subcommand ("-service-user") are two different names. +func usageOptionText(out, option string) string { + lines := strings.Split(out, "\n") + for i, line := range lines { + if !strings.HasPrefix(strings.TrimSpace(line), option) { + continue + } + indent := len(line) - len(strings.TrimLeft(line, " ")) + text := strings.TrimSpace(line) + for j := i + 1; j < len(lines); j++ { + next := lines[j] + if strings.TrimSpace(next) == "" { + break + } + if len(next)-len(strings.TrimLeft(next, " ")) <= indent { + break + } + text += " " + strings.TrimSpace(next) + } + return text + } + return "" +} + func TestServiceUsageListsEverySubcommand(t *testing.T) { var buf bytes.Buffer writeServiceUsage(&buf) @@ -223,6 +265,58 @@ func TestServiceUserIsRejectedForTheUserScope(t *testing.T) { } } +// TestServiceUserIsRejectedOnWindows checks that the account of a +// machine-wide instance cannot be chosen on Windows. +// +// The service control manager registers the instance as the built-in +// LocalService account, and a service under a user account would need a +// credential, which must never reach the command line of an installation: +// the account is not a choice of the command line on this platform, so a +// command line that names one is refused. +func TestServiceUserIsRejectedOnWindows(t *testing.T) { + usePlatform(t, "windows") + + var stdout, stderr bytes.Buffer + code := runServiceCommand([]string{"install", "--system", "--service-user", "hfdesk-svc"}, &stdout, &stderr) + if code != serviceUsageExitCode { + t.Fatalf("the machine-wide installation must refuse --service-user with the exit code %d, got %d:\n%s", + serviceUsageExitCode, code, stderr.String()) + } + + errText := stderr.String() + if !strings.Contains(errText, "--service-user") { + t.Errorf("the error does not name the rejected option:\n%s", errText) + } + if !strings.Contains(errText, windowsServiceAccount) { + t.Errorf("the error does not name the account the instance really runs as (%q):\n%s", windowsServiceAccount, errText) + } + if !strings.Contains(errText, "cannot be chosen") { + t.Errorf("the error does not say that the account cannot be chosen:\n%s", errText) + } + if stdout.Len() != 0 { + t.Errorf("a refused installation must not report a registration:\n%s", stdout.String()) + } +} + +// TestServiceUserIsRejectedForTheUserScopeOnWindows checks that a user scope +// installation with a named account is refused on Windows. +// +// The user scope does not exist on Windows, so the scope itself is refused +// before the account is looked at: the command line is refused either way. +func TestServiceUserIsRejectedForTheUserScopeOnWindows(t *testing.T) { + usePlatform(t, "windows") + + var stdout, stderr bytes.Buffer + code := runServiceCommand([]string{"install", "--user", "--service-user", "hfdesk-svc"}, &stdout, &stderr) + if code != serviceUsageExitCode { + t.Fatalf("the user scope must be refused with the exit code %d, got %d:\n%s", + serviceUsageExitCode, code, stderr.String()) + } + if stdout.Len() != 0 { + t.Errorf("a refused installation must not report a registration:\n%s", stdout.String()) + } +} + // TestBuildInstallOptionsCarriesTheServiceUser checks that the account // reaches the options of the installation. // @@ -260,6 +354,64 @@ func TestBuildInstallOptionsRejectsTheServiceUserForTheUserScope(t *testing.T) { } } +// TestBuildInstallOptionsRejectsTheServiceUserOnWindows checks that a +// machine-wide installation never carries a named account on Windows. +// +// The registration of a Windows instance runs as the built-in LocalService +// account, so an account in the options would be a promise the +// registration cannot keep. +func TestBuildInstallOptionsRejectsTheServiceUserOnWindows(t *testing.T) { + usePlatform(t, "windows") + + _, err := buildInstallOptions(service.ScopeSystem, installFlags{serviceUser: "hfdesk-svc"}, "/usr/local/bin/hfdesk") + if err == nil { + t.Fatal("the machine-wide scope must reject a service account on windows") + } + if !isServiceUsageError(err) { + t.Errorf("the refusal must be a command line that could not be understood: %v", err) + } + if !strings.Contains(err.Error(), windowsServiceAccount) { + t.Errorf("the refusal does not name the account the instance really runs as (%q): %v", windowsServiceAccount, err) + } +} + +// TestBuildInstallOptionsKeepsTheServiceUserOnLinuxAndMacOS checks that the +// account of a machine-wide instance is honoured on the platforms that have +// a named service account. +func TestBuildInstallOptionsKeepsTheServiceUserOnLinuxAndMacOS(t *testing.T) { + for _, goos := range []string{"linux", "darwin"} { + t.Run(goos, func(t *testing.T) { + usePlatform(t, goos) + + opts, err := buildInstallOptions(service.ScopeSystem, installFlags{serviceUser: "hfdesk-svc"}, "/usr/local/bin/hfdesk") + if err != nil { + t.Fatalf("build the install options: %v", err) + } + if opts.ServiceUser != "hfdesk-svc" { + t.Errorf("the service account is %q, want %q", opts.ServiceUser, "hfdesk-svc") + } + }) + } +} + +// TestBuildInstallOptionsRejectsTheServiceUserForTheUserScopeEverywhere +// checks that a user scope instance never carries a service account, on +// every platform. +// +// A user scope instance runs as the user itself, so an account name would +// be a promise the installation cannot keep. +func TestBuildInstallOptionsRejectsTheServiceUserForTheUserScopeEverywhere(t *testing.T) { + for _, goos := range []string{"linux", "darwin", "windows"} { + t.Run(goos, func(t *testing.T) { + usePlatform(t, goos) + + if _, err := buildInstallOptions(service.ScopeUser, installFlags{serviceUser: "hfdesk"}, "/usr/local/bin/hfdesk"); err == nil { + t.Error("the user scope must reject a service account") + } + }) + } +} + // TestInstallSummaryReportsTheAccountOfTheInstance checks that the summary // names the account the instance runs as. func TestInstallSummaryReportsTheAccountOfTheInstance(t *testing.T) { @@ -314,6 +466,182 @@ func TestInstallSummaryReportsTheDefaultAccountOfThePlatform(t *testing.T) { } } +// TestServiceUsageDescribesTheServiceUserOption checks that the usage +// block of the family says where the account of a machine-wide instance +// can be named. +// +// The option is not available on every platform: the account of a Windows +// instance cannot be chosen, so a usage block that offers it everywhere +// promises more than the command line can keep. +func TestServiceUsageDescribesTheServiceUserOption(t *testing.T) { + var buf bytes.Buffer + writeServiceUsage(&buf) + + out := buf.String() + text := usageOptionText(out, "--service-user") + if text == "" { + t.Fatalf("the usage block does not describe --service-user:\n%s", out) + } + for _, want := range []string{"Linux", "macOS", "Windows"} { + if !strings.Contains(text, want) { + t.Errorf("the description of --service-user does not mention %q:\n%s", want, text) + } + } +} + +// TestServiceInstallHelpDescribesTheServiceUserOption checks that the help +// of the install subcommand says where the account of a machine-wide +// instance can be named. +func TestServiceInstallHelpDescribesTheServiceUserOption(t *testing.T) { + var stdout, stderr bytes.Buffer + code := runServiceCommand([]string{"install", "--help"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("the help of the install subcommand must succeed, got exit code %d:\n%s", code, stderr.String()) + } + + out := stdout.String() + text := usageOptionText(out, "-service-user") + if text == "" { + t.Fatalf("the help of the install subcommand does not describe --service-user:\n%s", out) + } + for _, want := range []string{"Linux", "macOS", "Windows"} { + if !strings.Contains(text, want) { + t.Errorf("the description of --service-user does not mention %q:\n%s", want, text) + } + } +} + +// TestWindowsServiceAccountIsTheNameOfTheRegistration checks that the +// account the command line names for a Windows instance is the account the +// registration uses. +// +// The service control manager registers the instance with the built-in +// LocalService account under its full name (the platform manager of the +// service layer carries the same name). A summary that names another +// account describes an identity the registration does not have. +func TestWindowsServiceAccountIsTheNameOfTheRegistration(t *testing.T) { + const want = `NT AUTHORITY\LocalService` + if windowsServiceAccount != want { + t.Errorf("the account of a Windows instance is %q, want %q", windowsServiceAccount, want) + } +} + +// TestInstallSummaryReportsTheWindowsAccount checks that the summary of a +// machine-wide installation names the account the registration uses on +// Windows. +// +// The registration runs as the built-in LocalService account, so the +// summary names that account and never the one a command line named: a +// summary that reports an account the instance does not run as sends the +// reader to the wrong identity. +func TestInstallSummaryReportsTheWindowsAccount(t *testing.T) { + usePlatform(t, "windows") + + for _, tc := range []struct { + name string + serviceUser string + }{ + {name: "without a named account"}, + {name: "with a named account", serviceUser: "hfdesk-svc"}, + } { + t.Run(tc.name, func(t *testing.T) { + opts := service.InstallOptions{ + Scope: service.ScopeSystem, + Executable: "/usr/local/bin/hfdesk", + Listen: "127.0.0.1:8080", + Paths: runtimepaths.Paths{ + ConfigFile: "/etc/hfdesk/hfdesk.json", + StateDir: "/var/lib/hfdesk", + LogDir: "/var/log/hfdesk", + }, + ServiceUser: tc.serviceUser, + } + + var buf bytes.Buffer + mgr := fakeManager{status: service.Status{State: service.StateRunning}} + writeInstallSummary(&buf, context.Background(), mgr, service.ScopeSystem, opts) + + out := buf.String() + if got := summaryField(out, "Account"); got != windowsServiceAccount { + t.Errorf("the account of the summary is %q, want %q:\n%s", got, windowsServiceAccount, out) + } + if tc.serviceUser != "" && strings.Contains(out, tc.serviceUser) { + t.Errorf("the summary reports the account %q the registration does not use:\n%s", tc.serviceUser, out) + } + }) + } +} + +// TestInstallSummaryReportsTheNamedAccountOnLinuxAndMacOS checks that the +// summary names the account of the command line on the platforms that +// honour it. +func TestInstallSummaryReportsTheNamedAccountOnLinuxAndMacOS(t *testing.T) { + for _, goos := range []string{"linux", "darwin"} { + t.Run(goos, func(t *testing.T) { + usePlatform(t, goos) + + opts := service.InstallOptions{ + Scope: service.ScopeSystem, + Executable: "/usr/local/bin/hfdesk", + Listen: "127.0.0.1:8080", + Paths: runtimepaths.Paths{ + ConfigFile: "/etc/hfdesk/hfdesk.json", + StateDir: "/var/lib/hfdesk", + LogDir: "/var/log/hfdesk", + }, + ServiceUser: "hfdesk-svc", + } + + var buf bytes.Buffer + mgr := fakeManager{status: service.Status{State: service.StateRunning}} + writeInstallSummary(&buf, context.Background(), mgr, service.ScopeSystem, opts) + + out := buf.String() + if got := summaryField(out, "Account"); got != "hfdesk-svc" { + t.Errorf("the account of the summary is %q, want %q:\n%s", got, "hfdesk-svc", out) + } + }) + } +} + +// TestInstallSummaryReportsTheInvokingUserForTheUserScope checks that the +// summary of a user scope installation names the user the instance runs as. +// +// A user scope instance runs as the user itself, so the summary names that +// user and never an account of a command line. +func TestInstallSummaryReportsTheInvokingUserForTheUserScope(t *testing.T) { + usePlatform(t, "linux") + + opts := service.InstallOptions{ + Scope: service.ScopeUser, + Executable: "/usr/local/bin/hfdesk", + Listen: "127.0.0.1:8080", + Paths: runtimepaths.Paths{ + ConfigFile: "/etc/hfdesk/hfdesk.json", + StateDir: "/var/lib/hfdesk", + LogDir: "/var/log/hfdesk", + }, + ServiceUser: "hfdesk-svc", + } + + var buf bytes.Buffer + mgr := fakeManager{status: service.Status{State: service.StateRunning}} + writeInstallSummary(&buf, context.Background(), mgr, service.ScopeUser, opts) + + out := buf.String() + got := summaryField(out, "Account") + want := invokingUser() + if want == "" { + want = "unknown" + } + if got != want { + t.Errorf("the account of the summary is %q, want the invoking user %q:\n%s", got, want, out) + } + if strings.Contains(out, "hfdesk-svc") { + t.Errorf("the summary reports an account the instance does not run as:\n%s", out) + } +} + func TestServiceHelpSucceeds(t *testing.T) { var stdout, stderr bytes.Buffer code := runServiceCommand([]string{"--help"}, &stdout, &stderr) From 3291f15fbd0427bbe34c81e0ed75883d116a4115 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 22 Oct 2026 02:18:35 +1100 Subject: [PATCH 31/58] tests: verify the machine-wide systemd unit as well - Run systemd-analyze verify over the generated machine-wide unit too, so both units the installer can write are validated. Co-Authored-By: opencode --- internal/service/manager_linux_test.go | 85 ++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/internal/service/manager_linux_test.go b/internal/service/manager_linux_test.go index a191422f..f62fa634 100644 --- a/internal/service/manager_linux_test.go +++ b/internal/service/manager_linux_test.go @@ -437,6 +437,91 @@ func TestSystemdAnalyzeVerify(t *testing.T) { } } +// TestSystemdAnalyzeVerifySystemScope checks the machine-wide unit with the +// verifier of the supervisor itself: the machine-wide unit names an account, +// and the verifier resolves that name against the accounts of the machine. +// +// The unit of the test names the account of the test process, which is what +// a machine-wide installation names when the operator names their own +// account. The template names the account and its group with the same name, +// so the machine has to know a group of that name as well. +// +// The executable of the unit is the test binary, so the verifier finds an +// executable that really exists, and the working directory of the unit is the +// temporary directory of the test, so the verifier finds a directory that +// really exists. Every other path of the unit stays the machine level path +// of the template. +// +// The test needs no privilege and installs nothing: it writes its unit into +// a temporary directory and hands it to the verifier. +func TestSystemdAnalyzeVerifySystemScope(t *testing.T) { + analyze, err := exec.LookPath("systemd-analyze") + if err != nil { + t.Skipf("skipping: systemd-analyze is not available in this environment: %v", err) + } + executable, err := os.Executable() + if err != nil { + t.Fatalf("Executable() = %v, want nil", err) + } + account, err := user.Current() + if err != nil { + t.Skipf("skipping: the account of the test process is unknown: %v", err) + } + // The unit names the account by its name, so the machine has to + // resolve that name the way the supervisor resolves it. + if _, err := user.Lookup(account.Username); err != nil { + t.Skipf("skipping: the machine knows no account %q: %v", account.Username, err) + } + // The template names the group of the unit with the name of the + // account as well. + if _, err := user.LookupGroup(account.Username); err != nil { + t.Skipf("skipping: the machine knows no group %q: %v", account.Username, err) + } + + root := t.TempDir() + opts := systemdTestOptions(t, ScopeSystem) + opts.Executable = executable + opts.ServiceUser = account.Username + // The working directory of the unit has to exist, so the state + // directory of the test is the temporary directory. Every other path + // stays the machine level path of the template. + opts.Paths.StateDir = root + + unit, err := renderUnit(opts) + if err != nil { + t.Fatalf("renderUnit() = %v, want nil", err) + } + // The unit that is verified is the machine-wide one: it names the + // account and it is enabled for the boot of the machine. + if want := "User=" + systemdQuote(account.Username) + "\n"; !strings.Contains(unit, want) { + t.Fatalf("the unit does not run as the account %q:\n%s", account.Username, unit) + } + if !strings.Contains(unit, "WantedBy=multi-user.target") { + t.Fatalf("the unit is not enabled for the boot of the machine:\n%s", unit) + } + // The machine-wide unit keeps the machine level home of the instance: + // it never adopts the home of the user who runs the test. + if want := "Environment=HOME=" + systemServiceHome; !strings.Contains(unit, want) { + t.Fatalf("the unit does not carry the machine level home %q:\n%s", systemServiceHome, unit) + } + + path := filepath.Join(root, unitName) + if err := os.WriteFile(path, []byte(unit), 0o644); err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, analyze, "verify", path) + var stderr bytes.Buffer + cmd.Stderr = &stderr + t.Logf("running %s verify %s", analyze, path) + if err := cmd.Run(); err != nil { + t.Fatalf("systemd-analyze verify %s = %v\n%s\nthe unit:\n%s", path, err, stderr.String(), unit) + } + t.Logf("%s verify %s accepted the machine-wide unit", analyze, path) +} + // systemdUserSessionUsable reports whether the supervisor of the session of // the user can be reached. func systemdUserSessionUsable() bool { From a95be9689f7701603bec8cc5874689effa5a0618 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 22 Oct 2026 02:18:35 +1100 Subject: [PATCH 32/58] docs: state the Windows identity and the macOS CI scope - The identity of a machine-wide instance cannot be chosen on Windows, where the built-in LocalService account is used. - The macOS CI job covers the darwin-only code because the repository has never run its full suite on macOS; widening it is a separate step. Co-Authored-By: opencode --- .github/workflows/ci.yml | 7 +++--- docs/SERVICE.md | 49 +++++++++++++++++++++++++++------------- 2 files changed, 37 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ea27c608..5aa62ce3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -154,9 +154,10 @@ jobs: # (plutil -lint) and the launchctl integration cycle where the runner # allows it. # - # The full suite has separate pre-existing macOS-compatibility gaps, so the - # job stays narrowed to the service package. The full macOS suite can be - # enabled once the pre-existing platform gaps are resolved. + # The repository has never run its full suite on macOS, so the job covers + # the darwin-only code only: the launchd manager, the property list + # rendering, the plutil validation and the launchctl lifecycle. Widening + # it to the full suite is a separate, deliberate step. go-macos: name: Go checks (macOS) runs-on: macos-latest diff --git a/docs/SERVICE.md b/docs/SERVICE.md index dc15e779..0b605deb 100644 --- a/docs/SERVICE.md +++ b/docs/SERVICE.md @@ -38,9 +38,12 @@ The dashboard is then served on . - Administrator rights for a machine-wide (`--system`) installation, and for the machine-wide `start`, `stop` and `restart`. HFDesk never elevates itself. -- The **service account** of a machine-wide installation, already created (see +- The **service account** of a machine-wide installation on Linux and macOS, + already created (see [The identity of a machine-wide instance](#the-identity-of-a-machine-wide-instance)). A user-scope installation needs no account: it runs as the user itself. + Windows needs none either: the instance runs as the built-in `LocalService` + account. - A writable state root for the chosen scope (see [Where the state lives](#where-the-state-lives)). @@ -78,7 +81,7 @@ never opens a browser. | `--cache-dir DIR` | Absolute path of the Hugging Face cache root. Without `--hub-dir`, the hub directory follows it: `/hub`. | | `--hub-dir DIR` | Absolute path of the Hugging Face hub cache directory. Given explicitly, it separates the hub from the cache root on purpose. | | `--local-dir DIR` | Absolute path of the flat output directory. | -| `--service-user ACCOUNT` | Account a **machine-wide** instance runs as. Default: the service account of the platform. Rejected for the user scope. | +| `--service-user ACCOUNT` | Account a **machine-wide** instance runs as on Linux and macOS. Default: the service account of the platform (`hfdesk` on Linux, `_hfdesk` on macOS). Rejected for the user scope, and rejected on Windows, where the instance always runs as the built-in `LocalService` account and the identity cannot be chosen. | | `--executable PATH` | Absolute path of the binary the registration points at. Default: the binary that is invoked. | The path flags override the default locations of the scope. Every path of an @@ -100,8 +103,10 @@ instead. `--service-user` names the account of a machine-wide instance (see [The identity of a machine-wide instance](#the-identity-of-a-machine-wide-instance)). -It applies to the machine-wide scope only: a user-scope instance runs as the -user itself, so the option is rejected there instead of being ignored. +It applies to the machine-wide scope on Linux and macOS: a user-scope instance +runs as the user itself, so the option is rejected there instead of being +ignored. Windows has no account to name: the instance always runs as the +built-in `LocalService` account, so the option is rejected there as well. The cache root and the hub directory are **one layout**: the cache root holds the friendly views (`models/`, `datasets/`), the hub directory holds the @@ -380,8 +385,9 @@ hfdesk service install # re-registers the current binary - The **macOS CI job** is narrowed to the service package: the launchd manager and the property list rendering are darwin-only code, so the job runs `go test ./internal/service` (plus the build and the vet of the whole - module) on a macOS runner. The full macOS suite has separate pre-existing - macOS-compatibility gaps; the job stays narrowed until they are resolved. + module) on a macOS runner. The repository has never run its full suite on + macOS, so the job covers the darwin-only code only; widening it to the full + suite is a separate, deliberate step. #### Future app-bundle packaging @@ -417,7 +423,9 @@ same service at the same time. - Installing, removing, starting, stopping and restarting the service needs an **elevated shell**. HFDesk does not self-elevate and never invokes UAC. - The service identity is `LocalService`, a least-privilege Windows service - account, not `LocalSystem`. + account, not `LocalSystem`. It is **not configurable**: the built-in account + has no name of its own and needs no credential, so a Windows instance + cannot run as another account. - The service-owned state under `%ProgramData%\HFDesk` is ACL-restricted. The service does **not** recursively re-ACL or re-own an existing model or cache tree: changing the ownership of a large, possibly shared tree is an explicit @@ -425,27 +433,36 @@ same service at the same time. ### The identity of a machine-wide instance -A machine-wide (`--system`) instance runs as a **dedicated, stable and -unprivileged service account** of its platform. It never runs as root, it -never inherits the home directory of the user who runs the install command, -and it never inherits the personal profile of root. +A machine-wide (`--system`) instance runs as a **stable and unprivileged +service account** of its platform: a dedicated one on Linux and macOS, the +built-in `LocalService` account on Windows. It never runs as root, it never +inherits the home directory of the user who runs the install command, and it +never inherits the personal profile of root. | Platform | The instance runs as | The account | |---|---|---| | Linux (systemd, `--system`) | the account **`hfdesk`** | a dedicated system account; it must exist before the installation | | macOS (launchd, `--system`) | the account **`_hfdesk`** | a dedicated system account (the underscore prefix is the macOS convention for a service account); it must exist before the installation | -| Windows (Service Control Manager) | **LocalService** (`NT AUTHORITY\LocalService`) | a documented minimum-privilege Windows service account. It is deliberately **not** `LocalSystem`: the instance needs no machine-wide privilege, and `LocalSystem` would give it more access than it uses. | +| Windows (Service Control Manager) | **LocalService** (`NT AUTHORITY\LocalService`) | a documented minimum-privilege Windows service account. It is deliberately **not** `LocalSystem`: the instance needs no machine-wide privilege, and `LocalSystem` would give it more access than it uses. The identity **cannot be chosen**: the built-in account has no name of its own and needs no credential, so it is the identity every Windows instance runs as. | The account is **stable** on purpose: the files it owns have to stay writable across reboots, so an ephemeral identity would break the instance at the next restart. It is also **unprivileged**: the instance serves a dashboard and downloads models, it does not administer the machine. -The name is the default of the platform and can be changed with -`--service-user ACCOUNT`. The option applies to the machine-wide scope only: -a user-scope instance runs as the user itself and never names an account. +On Linux and macOS the name is the default of the platform and can be changed +with `--service-user ACCOUNT`. The option applies to the machine-wide scope +only: a user-scope instance runs as the user itself and never names an +account. + +**Windows has no account to choose.** The instance runs as the built-in, +passwordless `LocalService` account, and `--service-user` is rejected there: a +service under a user account would need a credential, and a credential must +never reach the command line of an installation. -**The account must already exist.** HFDesk is a service manager, not a system +**The account must already exist.** That is a statement about Linux and +macOS: the Windows identity is an account of the operating system and is +therefore always there. HFDesk is a service manager, not a system provisioner: it never creates an account, never calls `useradd` or `dscl`, never runs `sudo` and never elevates itself. An installation whose account does not exist is **refused**, and the error names the account and the exact From 50fe3e62d93789f0ed27fbfd4e0e2f4808861f43 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 22 Oct 2026 03:20:07 +1100 Subject: [PATCH 33/58] fix(service): name the primary group of the service account - The machine-wide unit named the account as its group, so an account whose primary group carries another name produced a unit that systemd refuses to start. The unit now names the real primary group of the account, and names no group at all when it cannot be resolved, so the supervisor uses the default group of the account. - The refusal for a cache directory the account cannot write names the directory, the account and the missing access, and recommends only non-recursive alternatives: a recursive ownership change of a model or cache tree is out of scope. Co-Authored-By: opencode --- internal/service/manager_linux.go | 54 ++++- internal/service/manager_linux_test.go | 292 +++++++++++++++++++++---- 2 files changed, 297 insertions(+), 49 deletions(-) diff --git a/internal/service/manager_linux.go b/internal/service/manager_linux.go index de29c2dd..22dd4f06 100644 --- a/internal/service/manager_linux.go +++ b/internal/service/manager_linux.go @@ -277,7 +277,14 @@ func (m *systemdManager) checkServiceAccountAccess(opts InstallOptions, account if !errors.Is(err, errNotWritable) { return fmt.Errorf("service: the %s directory %q of the machine-wide instance is not usable: %w", dir.kind, dir.path, err) } - return fmt.Errorf("service: the service account %q cannot write to the %s directory %q: grant it access (for example %q or by adding it to the group that owns the directory) and install again", account.Username, dir.kind, dir.path, "chown -R "+account.Username+" "+dir.path) + // The guidance never names a recursive ownership change: + // the tree of a model or a cache directory belongs to the + // user who named it, and the installation never walks it. + // The account is either made a member of the group that + // owns the directory, or the directory itself is given to + // the account. + return fmt.Errorf("service: the service account %q cannot write to the %s directory %q: grant it access and install again (for example add the account to the group that owns the directory, or give the directory itself to the account with %q; for a directory that holds a lot of data, granting group access is preferable to changing the owner)", + account.Username, dir.kind, dir.path, "chown "+account.Username+" "+dir.path) } } return nil @@ -668,7 +675,7 @@ func (m *systemdManager) register(ctx context.Context, opts InstallOptions, acco return err } - unit, err := renderUnit(opts) + unit, err := renderUnit(opts, account) if err != nil { return err } @@ -1208,15 +1215,19 @@ WantedBy=%s // renderUnit renders the systemd unit of an installation. // -// The function is pure: it renders the text of the unit and never touches -// the system. The command of the unit is the canonical argument vector of +// The function renders the text of the unit and never changes anything on +// the machine. The command of the unit is the canonical argument vector of // the shared contract, so every platform registers the same command. -func renderUnit(opts InstallOptions) (string, error) { +// +// The account is the service account the installation resolved for a +// machine-wide instance. It is nil for a user scope instance, which runs as +// the user of the session. +func renderUnit(opts InstallOptions, account *user.User) (string, error) { home, err := unitHomeDir(opts) if err != nil { return "", err } - identity, err := unitIdentity(opts) + identity, err := unitIdentity(opts, account) if err != nil { return "", err } @@ -1248,17 +1259,40 @@ func renderUnit(opts InstallOptions) (string, error) { // starts the instance with the least privilege it needs and not with the // identity of the administrator who installed it. // +// The group of the unit is the primary group of the account, never the name +// of the account: an account whose primary group is named after it only by +// accident (a wheel group, a shared group, a group of a directory server) +// would otherwise name a group that does not exist, and the unit would fail +// to start with a group error the installation never sees. +// +// A group that cannot be resolved is left out of the unit: the supervisor +// then uses the default group of the account, which is the primary group of +// the account itself. A name is never guessed. +// // A user scope instance runs as the user of the session, so it names no // account at all. -func unitIdentity(opts InstallOptions) (string, error) { +func unitIdentity(opts InstallOptions, account *user.User) (string, error) { if opts.Scope != ScopeSystem { return "", nil } - account := serviceAccountName(opts) - if account == "" { + name := serviceAccountName(opts) + if name == "" { return "", errors.New("service: the machine-wide instance needs a service account") } - return fmt.Sprintf("User=%s\nGroup=%s\n", systemdQuote(account), systemdQuote(account)), nil + identity := "User=" + systemdQuote(name) + "\n" + if account == nil { + // The account of the instance is unknown: the unit names the + // account of the command line and no group at all. + return identity, nil + } + group, err := user.LookupGroupId(account.Gid) + if err != nil || group.Name == "" { + // The primary group of the account cannot be resolved: the + // unit names no group, and the supervisor uses the default + // group of the account. + return identity, nil + } + return identity + "Group=" + systemdQuote(group.Name) + "\n", nil } // unitHomeDir returns the HOME of the registered process. diff --git a/internal/service/manager_linux_test.go b/internal/service/manager_linux_test.go index f62fa634..c9dd378d 100644 --- a/internal/service/manager_linux_test.go +++ b/internal/service/manager_linux_test.go @@ -63,6 +63,25 @@ func unitDirectives(unit string) []string { return directives } +// unitDirective returns the value of one directive of a unit file, e.g. the +// account of a "User=" directive. +// +// An empty string is returned when the unit holds no such directive, so a +// caller can tell a directive that is absent from one that carries an empty +// value. +func unitDirective(unit, key string) string { + for _, directive := range unitDirectives(unit) { + name, value, ok := strings.Cut(directive, "=") + if !ok { + continue + } + if strings.TrimSpace(name) == key { + return strings.TrimSpace(value) + } + } + return "" +} + // systemdTestExecutable writes a program that can be registered and returns // its path. An installation refuses a program that does not exist, so every // test of an installation needs one. @@ -78,7 +97,7 @@ func systemdTestExecutable(t *testing.T) string { func TestSystemdRenderUnitUserScope(t *testing.T) { opts := systemdTestOptions(t, ScopeUser) - unit, err := renderUnit(opts) + unit, err := renderUnit(opts, nil) if err != nil { t.Fatalf("renderUnit() = %v, want nil", err) } @@ -143,9 +162,14 @@ func TestSystemdRenderUnitUserScope(t *testing.T) { } func TestSystemdRenderUnitSystemScope(t *testing.T) { + // The account of a machine-wide instance is an account of the machine: + // the unit names it and the primary group of it. + account := systemdSecondAccount(t) + opts := systemdTestOptions(t, ScopeSystem) + opts.ServiceUser = account.Username - unit, err := renderUnit(opts) + unit, err := renderUnit(opts, account) if err != nil { t.Fatalf("renderUnit() = %v, want nil", err) } @@ -169,12 +193,17 @@ func TestSystemdRenderUnitSystemScope(t *testing.T) { } // A machine wide instance runs as the dedicated service account of // the platform, never as the user who runs the install command. - account := DefaultServiceUser() - if !strings.Contains(unit, "User="+account+"\n") { - t.Errorf("the unit does not run as the service account %q:\n%s", account, unit) + if want := "User=" + systemdQuote(account.Username) + "\n"; !strings.Contains(unit, want) { + t.Errorf("the unit does not run as the service account %q:\n%s", account.Username, unit) } - if !strings.Contains(unit, "Group="+account+"\n") { - t.Errorf("the unit does not run in the group of the service account %q:\n%s", account, unit) + // The group of the unit is the primary group of the account, never the + // name of the account. + group, err := user.LookupGroupId(account.Gid) + if err != nil { + t.Fatalf("LookupGroupId(%q) = %v, want nil", account.Gid, err) + } + if want := "Group=" + systemdQuote(group.Name) + "\n"; !strings.Contains(unit, want) { + t.Errorf("the unit does not run in the primary group %q of the service account %q:\n%s", group.Name, account.Username, unit) } if current, err := user.Current(); err == nil && current.Username != "" { for _, directive := range unitDirectives(unit) { @@ -194,7 +223,7 @@ func TestSystemdRenderUnitCarriesNoCredential(t *testing.T) { // added. setCredentialFields(&opts, sentinel) - unit, err := renderUnit(opts) + unit, err := renderUnit(opts, nil) if err != nil { t.Fatalf("renderUnit() = %v, want nil", err) } @@ -226,7 +255,7 @@ func TestSystemdRenderUnitQuotesPathsWithSpaces(t *testing.T) { opts.Executable = "/opt/HF Desk/bin/hfdesk" opts.Paths.StateDir = "/var/lib/HF Desk" - unit, err := renderUnit(opts) + unit, err := renderUnit(opts, nil) if err != nil { t.Fatalf("renderUnit() = %v, want nil", err) } @@ -364,7 +393,7 @@ func TestSystemdParseShowReadsTheProperties(t *testing.T) { func TestSystemdParseUnitRecoversTheRegistration(t *testing.T) { opts := systemdTestOptions(t, ScopeUser) - unit, err := renderUnit(opts) + unit, err := renderUnit(opts, nil) if err != nil { t.Fatalf("renderUnit() = %v, want nil", err) } @@ -418,7 +447,7 @@ func TestSystemdAnalyzeVerify(t *testing.T) { t.Fatal(err) } - unit, err := renderUnit(opts) + unit, err := renderUnit(opts, nil) if err != nil { t.Fatalf("renderUnit() = %v, want nil", err) } @@ -443,8 +472,8 @@ func TestSystemdAnalyzeVerify(t *testing.T) { // // The unit of the test names the account of the test process, which is what // a machine-wide installation names when the operator names their own -// account. The template names the account and its group with the same name, -// so the machine has to know a group of that name as well. +// account. The template names the primary group of that account, so the +// machine has to know that group as well. // // The executable of the unit is the test binary, so the verifier finds an // executable that really exists, and the working directory of the unit is the @@ -472,10 +501,10 @@ func TestSystemdAnalyzeVerifySystemScope(t *testing.T) { if _, err := user.Lookup(account.Username); err != nil { t.Skipf("skipping: the machine knows no account %q: %v", account.Username, err) } - // The template names the group of the unit with the name of the - // account as well. - if _, err := user.LookupGroup(account.Username); err != nil { - t.Skipf("skipping: the machine knows no group %q: %v", account.Username, err) + // The template names the primary group of the account, so the machine + // has to resolve that group the way the supervisor resolves it. + if _, err := user.LookupGroupId(account.Gid); err != nil { + t.Skipf("skipping: the machine knows no primary group for the account %q: %v", account.Username, err) } root := t.TempDir() @@ -487,7 +516,7 @@ func TestSystemdAnalyzeVerifySystemScope(t *testing.T) { // stays the machine level path of the template. opts.Paths.StateDir = root - unit, err := renderUnit(opts) + unit, err := renderUnit(opts, account) if err != nil { t.Fatalf("renderUnit() = %v, want nil", err) } @@ -1039,10 +1068,7 @@ func systemdStatusOf(t *testing.T, words ...string) Status { // write into is a directory it really cannot write into. func systemdSecondAccount(t *testing.T) *user.User { t.Helper() - current, err := user.Current() - if err != nil { - t.Skipf("skipping: the account of the test process is unknown: %v", err) - } + current := systemdCurrentAccount(t) for _, name := range []string{"nobody", "daemon", "bin", "sys", "games"} { account, err := user.Lookup(name) if err != nil { @@ -1057,6 +1083,88 @@ func systemdSecondAccount(t *testing.T) *user.User { return nil } +// systemdCurrentAccount returns the account of the test process. +func systemdCurrentAccount(t *testing.T) *user.User { + t.Helper() + account, err := user.Current() + if err != nil { + t.Skipf("skipping: the account of the test process is unknown: %v", err) + } + return account +} + +// systemdAccountsOfTheMachine returns the accounts of the machine. +// +// The accounts are read from the password database of the machine, so the +// test works on every machine and needs no privilege. +func systemdAccountsOfTheMachine(t *testing.T) []*user.User { + t.Helper() + content, err := os.ReadFile("/etc/passwd") + if err != nil { + t.Skipf("skipping: the password database of the machine cannot be read: %v", err) + } + var accounts []*user.User + for _, line := range strings.Split(string(content), "\n") { + name, _, ok := strings.Cut(strings.TrimSpace(line), ":") + if !ok || name == "" { + continue + } + account, err := user.Lookup(name) + if err != nil { + continue + } + accounts = append(accounts, account) + } + if len(accounts) == 0 { + t.Skip("skipping: the machine knows no account the test could use") + } + return accounts +} + +// systemdAccountWithAnotherPrimaryGroup returns an account of the machine +// whose primary group carries another name than the account itself. +// +// Such an account is the case that shows the difference between the primary +// group of an account and the name of the account. The second result is +// false when the machine knows no such account. +func systemdAccountWithAnotherPrimaryGroup(t *testing.T) (*user.User, bool) { + t.Helper() + for _, account := range systemdAccountsOfTheMachine(t) { + group, err := user.LookupGroupId(account.Gid) + if err != nil || group.Name == "" { + continue + } + if group.Name != account.Username { + return account, true + } + } + return nil, false +} + +// systemdUnresolvableAccount returns an account whose primary group the +// machine does not know. +// +// The group of a unit is never guessed: an account whose group cannot be +// resolved leaves the group out of the unit, so the supervisor uses the +// default group of the account. +func systemdUnresolvableAccount(t *testing.T) *user.User { + t.Helper() + for _, gid := range []string{"4294967294", "999999999", "999999998"} { + if _, err := user.LookupGroupId(gid); err == nil { + continue + } + return &user.User{ + Username: "hfdesk-svc", + Uid: "999999999", + Gid: gid, + Name: "HFDesk service account", + HomeDir: systemServiceHome, + } + } + t.Skip("skipping: the machine knows a group for every id the test could use") + return nil +} + // systemdOwner returns the owner and the group of a path. func systemdOwner(t *testing.T, path string) (string, string) { t.Helper() @@ -1169,38 +1277,58 @@ func TestSystemdInstallRefusesAMissingServiceAccount(t *testing.T) { } // TestSystemdRenderUnitNamesTheServiceAccount checks that the unit of a -// machine-wide instance names the service account and its group, and that -// the unit of a user scope instance names no account at all. +// machine-wide instance names the service account, and that the group of the +// unit is the primary group of that account and never the name of the +// account. The unit of a user scope instance names no account at all. func TestSystemdRenderUnitNamesTheServiceAccount(t *testing.T) { t.Run("the machine-wide scope names the account of the user", func(t *testing.T) { opts := systemdTestOptions(t, ScopeSystem) opts.ServiceUser = "hfdesk-svc" - unit, err := renderUnit(opts) + // The account is not on the machine, so no group of it can be + // resolved: the unit names the account and no group at all. + unit, err := renderUnit(opts, nil) if err != nil { t.Fatalf("renderUnit() = %v, want nil", err) } - if !strings.Contains(unit, "User=hfdesk-svc\n") { - t.Errorf("the unit does not run as the service account:\n%s", unit) + if got := unitDirective(unit, "User"); got != "hfdesk-svc" { + t.Errorf("the account of the unit = %q, want %q", got, "hfdesk-svc") } - if !strings.Contains(unit, "Group=hfdesk-svc\n") { - t.Errorf("the unit does not run in the group of the service account:\n%s", unit) + if got := unitDirective(unit, "Group"); got != "" { + t.Errorf("the unit names the group %q of an account the machine does not know", got) } }) t.Run("the machine-wide scope falls back to the default of the platform", func(t *testing.T) { opts := systemdTestOptions(t, ScopeSystem) - unit, err := renderUnit(opts) + // The default account of the platform may or may not be on this + // machine: the unit names it either way, and the group of the + // unit is the primary group of the account when the machine + // knows the account. + account, err := user.Lookup(DefaultServiceUser()) + if err != nil { + account = nil + } + + unit, err := renderUnit(opts, account) if err != nil { t.Fatalf("renderUnit() = %v, want nil", err) } - account := DefaultServiceUser() - if !strings.Contains(unit, "User="+account+"\n") { - t.Errorf("the unit does not run as the default service account %q:\n%s", account, unit) + name := DefaultServiceUser() + if got := unitDirective(unit, "User"); got != name { + t.Errorf("the account of the unit = %q, want the default service account %q", got, name) + } + wantGroup := "" + if account != nil { + group, err := user.LookupGroupId(account.Gid) + if err != nil { + t.Fatalf("LookupGroupId(%q) = %v, want nil", account.Gid, err) + } + wantGroup = group.Name } - if !strings.Contains(unit, "Group="+account+"\n") { - t.Errorf("the unit does not run in the group of the default service account %q:\n%s", account, unit) + if got := unitDirective(unit, "Group"); got != wantGroup { + t.Errorf("the group of the unit = %q, want %q", got, wantGroup) } }) @@ -1208,7 +1336,7 @@ func TestSystemdRenderUnitNamesTheServiceAccount(t *testing.T) { opts := systemdTestOptions(t, ScopeUser) opts.ServiceUser = "hfdesk-svc" - unit, err := renderUnit(opts) + unit, err := renderUnit(opts, nil) if err != nil { t.Fatalf("renderUnit() = %v, want nil", err) } @@ -1217,9 +1345,82 @@ func TestSystemdRenderUnitNamesTheServiceAccount(t *testing.T) { t.Errorf("a user scope unit names an account: %q", directive) } } + if got := unitDirective(unit, "User"); got != "" { + t.Errorf("the account of a user scope unit = %q, want none", got) + } + if got := unitDirective(unit, "Group"); got != "" { + t.Errorf("the group of a user scope unit = %q, want none", got) + } }) } +// TestSystemdRenderUnitNamesThePrimaryGroupOfTheAccount checks that the +// group of a machine-wide unit is the primary group of its service account +// and never the name of the account. +// +// The account of the test is an account whose primary group carries another +// name than the account, whenever the machine knows one: that is the case +// that shows the difference. A machine whose accounts all carry the name of +// their primary group is checked against the resolved primary group instead. +func TestSystemdRenderUnitNamesThePrimaryGroupOfTheAccount(t *testing.T) { + account, other := systemdAccountWithAnotherPrimaryGroup(t) + if !other { + account = systemdCurrentAccount(t) + } + group, err := user.LookupGroupId(account.Gid) + if err != nil { + t.Fatalf("LookupGroupId(%q) = %v, want nil", account.Gid, err) + } + + opts := systemdTestOptions(t, ScopeSystem) + opts.ServiceUser = account.Username + + unit, err := renderUnit(opts, account) + if err != nil { + t.Fatalf("renderUnit() = %v, want nil", err) + } + t.Logf("the machine-wide unit of the account %q:\n%s", account.Username, unit) + + if got := unitDirective(unit, "User"); got != account.Username { + t.Errorf("the account of the unit = %q, want %q", got, account.Username) + } + if got := unitDirective(unit, "Group"); got != group.Name { + t.Errorf("the group of the unit = %q, want the primary group %q of the account %q", got, group.Name, account.Username) + } + // The name of the account is never the group of the unit: an account + // whose primary group carries another name would name a group that + // does not exist. + if group.Name != account.Username { + if got := unitDirective(unit, "Group"); got == account.Username { + t.Errorf("the group of the unit = %q, want the primary group %q of the account %q", got, group.Name, account.Username) + } + } +} + +// TestSystemdRenderUnitOmitsAnUnresolvableGroup checks that a machine-wide +// unit names no group at all when the primary group of its service account +// cannot be resolved: the supervisor then uses the default group of the +// account, and no group name is ever guessed. +func TestSystemdRenderUnitOmitsAnUnresolvableGroup(t *testing.T) { + account := systemdUnresolvableAccount(t) + + opts := systemdTestOptions(t, ScopeSystem) + opts.ServiceUser = account.Username + + unit, err := renderUnit(opts, account) + if err != nil { + t.Fatalf("renderUnit() = %v, want nil", err) + } + t.Logf("the machine-wide unit of the account %q:\n%s", account.Username, unit) + + if got := unitDirective(unit, "User"); got != account.Username { + t.Errorf("the account of the unit = %q, want %q", got, account.Username) + } + if got := unitDirective(unit, "Group"); got != "" { + t.Errorf("the unit names the group %q of an account whose group cannot be resolved", got) + } +} + // TestSystemdGrantInstanceDirs checks that the directories an installation // creates belong to the service account of the instance, and that the // installation never changes the ownership of a tree it does not own. @@ -1677,11 +1878,24 @@ func TestSystemdInstallRefusesACacheDirTheAccountCannotWrite(t *testing.T) { if err == nil { t.Fatal("Install() = nil, want the refusal of the cache directory") } - want := fmt.Sprintf("service: the service account %q cannot write to the cache directory %q: grant it access (for example %q or by adding it to the group that owns the directory) and install again", - account.Username, cache, "chown -R "+account.Username+" "+cache) + want := fmt.Sprintf("service: the service account %q cannot write to the cache directory %q: grant it access and install again (for example add the account to the group that owns the directory, or give the directory itself to the account with %q; for a directory that holds a lot of data, granting group access is preferable to changing the owner)", + account.Username, cache, "chown "+account.Username+" "+cache) if err.Error() != want { t.Errorf("Install() = %q, want %q", err, want) } + // The refusal names the directory, the account and the missing write + // access. + for _, part := range []string{account.Username, cache, "cannot write to"} { + if !strings.Contains(err.Error(), part) { + t.Errorf("Install() = %q, want the %q in the refusal", err, part) + } + } + // The refusal never names a recursive ownership change: the tree of a + // model or a cache directory belongs to the user who named it, and the + // installation never walks it. + if strings.Contains(err.Error(), "-R") { + t.Errorf("Install() = %q, want no recursive ownership change in the refusal", err) + } // The refused installation never changes the ownership of the cache // tree. @@ -1727,7 +1941,7 @@ func TestSystemdUserScopeNeedsNoServiceAccount(t *testing.T) { // The unit of a user scope instance names no account, even when the // command line named one. - unit, err := renderUnit(opts) + unit, err := renderUnit(opts, nil) if err != nil { t.Fatalf("renderUnit() = %v, want nil", err) } From 743f3cb62b21be6467a72ce33c2dc8798d3d038f Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 22 Oct 2026 03:20:07 +1100 Subject: [PATCH 34/58] fix(service): report a failed Windows instance as failed - The service control manager has no state of its own for a failure, so it reports a crashed instance as stopped; the exit code is the only fact that distinguishes it from a clean stop. A non-zero exit code is now reported as failed, like the Linux and macOS managers do, so the status of a crashed instance is the same on every platform. Co-Authored-By: opencode --- internal/service/manager_windows.go | 12 +++ internal/service/manager_windows_test.go | 95 ++++++++++++++++++++++-- 2 files changed, 102 insertions(+), 5 deletions(-) diff --git a/internal/service/manager_windows.go b/internal/service/manager_windows.go index 0c17d305..347adbaa 100644 --- a/internal/service/manager_windows.go +++ b/internal/service/manager_windows.go @@ -686,6 +686,11 @@ func scmLookupError(what string, err error) error { // // An absent registration is reported as not installed: the registration is // not there, so there is nothing to describe. +// +// An instance that crashed is reported as failed: the service control +// manager has no state of its own for a failure and reports a crashed +// instance as stopped, so the exit code is what tells a failed instance +// from a cleanly stopped one. func scmStatusFrom(scope Scope, facts scmRegistrationFacts) (Status, error) { status := scmBaseStatus(scope) if !facts.present { @@ -707,6 +712,13 @@ func scmStatusFrom(scope Scope, facts scmRegistrationFacts) (Status, error) { status.State = scmState(facts.state) if status.State == StateStopped && facts.exitCode != 0 { + // The service control manager has no state of its own for a + // failure: an instance that crashed is reported as stopped + // there, like an instance that ended cleanly. The exit code is + // what tells the two apart, so a stopped instance that ended + // with a non-zero code is reported as failed, the way the + // managers of the other platforms report the same crash. + status.State = StateFailed status.Detail = fmt.Sprintf("the instance exited with the code %d", facts.exitCode) } return status, nil diff --git a/internal/service/manager_windows_test.go b/internal/service/manager_windows_test.go index 8849fdc2..09792919 100644 --- a/internal/service/manager_windows_test.go +++ b/internal/service/manager_windows_test.go @@ -688,9 +688,15 @@ func TestSCMStatusFromABrokenRegistrationNamesTheMissingExecutable(t *testing.T) } } -// TestSCMStatusFromAStoppedInstanceNamesTheExitCode checks that the exit -// code of a stopped instance is reported. -func TestSCMStatusFromAStoppedInstanceNamesTheExitCode(t *testing.T) { +// TestSCMStatusFromAFailedInstanceIsFailedAndNamesTheExitCode checks that +// an instance that crashed is reported as failed, and that the detail names +// the exit code it ended with. +// +// The service control manager has no state of its own for a failure: it +// reports a crashed instance as stopped, like a cleanly stopped one. The +// exit code is what tells the two apart, and the managers of the other +// platforms report the same crash as failed. +func TestSCMStatusFromAFailedInstanceIsFailedAndNamesTheExitCode(t *testing.T) { root := t.TempDir() opts := scmTestOptions() opts.Executable = filepath.Join(root, "hfdesk.exe") @@ -709,14 +715,93 @@ func TestSCMStatusFromAStoppedInstanceNamesTheExitCode(t *testing.T) { if err != nil { t.Fatalf("scmStatusFrom() = %v, want nil", err) } - if status.State != StateStopped { - t.Errorf("the state = %q, want %q", status.State, StateStopped) + if status.State != StateFailed { + t.Errorf("the state = %q, want %q", status.State, StateFailed) } if !strings.Contains(status.Detail, "3") { t.Errorf("the detail = %q, want the exit code of the instance", status.Detail) } } +// TestSCMStatusFromACleanlyStoppedInstanceIsStopped checks that an +// instance that ended cleanly is reported as stopped, and not as failed. +// +// The exit code of a clean run is zero, so the instance is not a failed one: +// the two cases are told apart by the exit code alone. +func TestSCMStatusFromACleanlyStoppedInstanceIsStopped(t *testing.T) { + root := t.TempDir() + opts := scmTestOptions() + opts.Executable = filepath.Join(root, "hfdesk.exe") + if err := os.WriteFile(opts.Executable, []byte("not a program"), 0o600); err != nil { + t.Fatalf("WriteFile() = %v, want nil", err) + } + + facts := scmRegistrationFacts{ + present: true, + state: svc.Stopped, + commandLine: scmCommandLine(opts), + } + + status, err := scmStatusFrom(ScopeSystem, facts) + if err != nil { + t.Fatalf("scmStatusFrom() = %v, want nil", err) + } + if status.State != StateStopped { + t.Errorf("the state = %q, want %q", status.State, StateStopped) + } + if status.Detail != "" { + t.Errorf("the detail = %q, want none", status.Detail) + } +} + +// TestSCMStatusFromKeepsTheOtherSupervisorStates checks that the exit code +// of an earlier run does not turn an instance that runs, that starts or that +// stops into a failed one. +// +// The exit code is only read for an instance that is stopped: every other +// state of the supervisor is reported as it is, the way the managers of the +// other platforms report it. +func TestSCMStatusFromKeepsTheOtherSupervisorStates(t *testing.T) { + cases := []struct { + name string + state svc.State + want State + }{ + {"running", svc.Running, StateRunning}, + {"start pending", svc.StartPending, StateStarting}, + {"stop pending", svc.StopPending, StateStopping}, + {"paused", svc.Paused, StateUnknown}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + root := t.TempDir() + opts := scmTestOptions() + opts.Executable = filepath.Join(root, "hfdesk.exe") + if err := os.WriteFile(opts.Executable, []byte("not a program"), 0o600); err != nil { + t.Fatalf("WriteFile() = %v, want nil", err) + } + + facts := scmRegistrationFacts{ + present: true, + state: tc.state, + exitCode: 3, + commandLine: scmCommandLine(opts), + } + + status, err := scmStatusFrom(ScopeSystem, facts) + if err != nil { + t.Fatalf("scmStatusFrom() = %v, want nil", err) + } + if status.State != tc.want { + t.Errorf("the state = %q, want %q", status.State, tc.want) + } + if status.Detail != "" { + t.Errorf("the detail = %q, want none", status.Detail) + } + }) + } +} + // TestSCMLookupErrorReportsTheElevationRequirement checks that a caller // that may not read the registration is told about the elevation // requirement, and not about the raw access denied error of the platform. From 59dfe8be2289235d3ea67abccaad2177898afaca Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 22 Oct 2026 03:20:07 +1100 Subject: [PATCH 35/58] fix(service): recommend only non-recursive access to a cache tree - The refusal for a cache directory the account cannot write names the directory, the account and the missing access, and recommends only non-recursive alternatives: a recursive ownership change of a model or cache tree is out of scope. - Correct the note about the account lookup: on macOS it goes through the directory service, and only the explicit osusergo build tag selects the reader of /etc/passwd. Co-Authored-By: opencode --- internal/service/manager_darwin.go | 20 +++++-- internal/service/manager_darwin_test.go | 76 ++++++++++++++++++++++++- 2 files changed, 90 insertions(+), 6 deletions(-) diff --git a/internal/service/manager_darwin.go b/internal/service/manager_darwin.go index c944dd2f..d9f8c13f 100644 --- a/internal/service/manager_darwin.go +++ b/internal/service/manager_darwin.go @@ -649,9 +649,13 @@ func (m *launchdManager) resolveAccount(opts InstallOptions) (*serviceAccount, e // resolveServiceAccount resolves the account of a machine-wide instance. // // The account is looked up in the user database of the machine: on macOS the -// lookup queries the OpenDirectory, so an account that was created with -// "dscl" is found. A build without cgo only knows the accounts of -// /etc/passwd, so it does not find a local account of the OpenDirectory. +// lookup goes through the directory service, so an account that was created +// with "dscl" is found. +// +// The lookup does not depend on cgo: the darwin build of os/user calls the +// getpwnam_r of libSystem, which asks the directory service, and the reader +// of /etc/passwd is only compiled when the osusergo build tag is set +// explicitly. // // The installation never creates an account: an account that does not exist // is refused with the commands that create it. @@ -919,6 +923,12 @@ func (m *launchdManager) grantInstanceDirs(opts InstallOptions, account *service // // The installation never changes the ownership or the permissions of such a // tree: a model or a cache tree belongs to whoever put it there. +// +// The refusal names the directory, the account and the access that is +// missing. It never recommends a recursive ownership change of such a tree: +// the access is granted to the directory itself or through the group that +// owns it, and for a tree that holds a lot of data the group is preferable +// to a new owner. func (m *launchdManager) checkNamedDirs(opts InstallOptions, account *serviceAccount) error { if account == nil { // A user scope instance runs as the user who installs it: it can @@ -947,8 +957,8 @@ func (m *launchdManager) checkNamedDirs(opts InstallOptions, account *serviceAcc return fmt.Errorf("service: the %s %q of the machine-wide instance cannot be used: %w", dir.name, dir.path, err) } if !ok { - return fmt.Errorf("service: the service account %q cannot write to the %s %q: grant it access (for example %q or by adding it to the group that owns the directory) and install again", - account.Name, dir.name, dir.path, "chown -R "+account.Name+" "+dir.path) + return fmt.Errorf("service: the service account %q has no write access to the %s %q: grant it access to that directory (for example %q) or make it a member of the group that owns the directory; for a tree that holds a lot of data, granting group access is preferable to changing the owner, and install again", + account.Name, dir.name, dir.path, "chown "+account.Name+" "+dir.path) } } return nil diff --git a/internal/service/manager_darwin_test.go b/internal/service/manager_darwin_test.go index 00db8ea2..ec95b635 100644 --- a/internal/service/manager_darwin_test.go +++ b/internal/service/manager_darwin_test.go @@ -1741,7 +1741,8 @@ func TestLaunchdGrantInstanceDirsKeepsADirectoryBelowADirectoryOfTheMachine(t *t // actionable message, and that the installation leaves the tree alone. // // The installation never chowns a cache tree: the guidance names the -// commands that grant the access, the administrator runs them. +// commands that grant the access, the administrator runs them. The guidance +// never asks for a recursive ownership change of the tree. func TestLaunchdSystemInstallRefusesACacheDirTheAccountCannotWrite(t *testing.T) { account := darwinOtherAccount(t) @@ -1787,6 +1788,11 @@ func TestLaunchdSystemInstallRefusesACacheDirTheAccountCannotWrite(t *testing.T) t.Errorf("Install() = %q, want a message that mentions %q", message, want) } } + // The refusal never recommends a recursive ownership change of the + // cache tree. + if strings.Contains(message, "-R") { + t.Errorf("Install() = %q, want a message without a recursive ownership command", message) + } // The ownership of the cache tree is unchanged. if owner, group := darwinOwner(t, cache); owner != beforeUID || group != beforeGID { @@ -1798,6 +1804,74 @@ func TestLaunchdSystemInstallRefusesACacheDirTheAccountCannotWrite(t *testing.T) } } +// TestLaunchdRefusesANamedDirTheAccountCannotWrite checks the refusal of a +// named directory the service account cannot write to. +// +// The refusal names the directory, the account and the access that is +// missing. It never recommends a recursive ownership change of a model or a +// cache tree: the access is granted to the directory itself or through the +// group that owns it, and for a tree that holds a lot of data the group is +// preferable to a new owner. +// +// The test needs no elevated shell: it only reads the ownership of a +// directory of a temporary directory. +func TestLaunchdRefusesANamedDirTheAccountCannotWrite(t *testing.T) { + account := darwinOtherAccount(t) + + root := t.TempDir() + // The directory belongs to the user of the test and to nobody else, so + // the service account has no write access to it. + cache := filepath.Join(root, "cache") + if err := os.Mkdir(cache, 0o700); err != nil { + t.Fatalf("create the cache directory: %v", err) + } + + opts := InstallOptions{ + Scope: ScopeSystem, + CacheDir: cache, + Paths: runtimepaths.Paths{ + ConfigFile: filepath.Join(root, "etc", "hfdesk.json"), + StateDir: filepath.Join(root, "state"), + LogDir: filepath.Join(root, "logs"), + }, + } + + launchd := &launchdManager{scope: ScopeSystem} + err := launchd.checkNamedDirs(opts, account) + if err == nil { + t.Fatal("checkNamedDirs() = nil, want the refusal of a cache directory the account cannot write to") + } + message := err.Error() + + // The refusal names the directory, the account and the access that is + // missing. + for _, want := range []string{ + "the service account " + strconv.Quote(account.Name), + strconv.Quote(cache), + "write access", + } { + if !strings.Contains(message, want) { + t.Errorf("checkNamedDirs() = %q, want a message that mentions %q", message, want) + } + } + // The refusal never recommends a recursive ownership change of the + // tree. + if strings.Contains(message, "-R") { + t.Errorf("checkNamedDirs() = %q, want a message without a recursive ownership command", message) + } + // The guidance is the ownership of the directory itself and the group + // that owns it. + for _, want := range []string{ + "chown " + account.Name + " " + cache, + "member of the group that owns the directory", + "group access is preferable to changing the owner", + } { + if !strings.Contains(message, want) { + t.Errorf("checkNamedDirs() = %q, want a message that mentions %q", message, want) + } + } +} + // TestLaunchdUserScopeNeedsNoServiceAccount checks that the user scope is // unaffected by the service account of the machine-wide scope. // From 509b325a1ef5cc0ce04f724acc1e9d46eee5fe28 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 22 Oct 2026 03:20:07 +1100 Subject: [PATCH 36/58] refactor(service): keep the platform facts in the service layer - The default scope of an installation, the existence of the user scope and the possibility to name the account of a machine-wide instance are facts of the platform. They now live in the service layer behind its build tags, so the command family does not branch on the operating system itself. Co-Authored-By: opencode --- cmd/hfdesk/service.go | 61 ++++-- cmd/hfdesk/service_test.go | 279 +++++++++++++++++++++++- internal/service/service.go | 41 +++- internal/service/serviceuser_darwin.go | 26 +++ internal/service/serviceuser_linux.go | 26 +++ internal/service/serviceuser_other.go | 27 +++ internal/service/serviceuser_windows.go | 26 +++ 7 files changed, 454 insertions(+), 32 deletions(-) diff --git a/cmd/hfdesk/service.go b/cmd/hfdesk/service.go index e8aa2a08..bbdb114e 100644 --- a/cmd/hfdesk/service.go +++ b/cmd/hfdesk/service.go @@ -15,7 +15,6 @@ import ( "os/user" "path/filepath" "regexp" - "runtime" "strconv" "strings" "syscall" @@ -33,9 +32,9 @@ import ( // // Everything platform specific stays in internal/service: the command // family only describes what the user asked for and reports what the -// supervisor answered. The only platform check of the command line is the -// default scope, because the Windows service control manager registers -// machine-wide services only. +// supervisor answered. The command line never compares the operating +// system itself: the scope and the account of an instance are facts of the +// platform, and the service layer owns them. const ( // serviceUsageExitCode is the exit code of a command line that could @@ -275,30 +274,46 @@ func writeField(w io.Writer, key, value string) { fmt.Fprintf(w, "%-14s %s\n", key+":", value) } -// platformGOOS is the operating system the command line decides for. +// platformFacts are the facts of the platform that the command line decides +// with. +// +// The facts come from the service layer: it owns everything that depends on +// the operating system, so the command line never compares one itself. +type platformFacts struct { + // defaultScope is the scope of an installation that does not ask for + // one. + defaultScope service.Scope + // supportsUserScope reports whether a per-user installation exists. + supportsUserScope bool + // supportsNamedServiceAccount reports whether the account of a + // machine-wide instance can be named. + supportsNamedServiceAccount bool +} + +// hostPlatform holds the facts of the platform the command line runs on. // // The variable is the seam of the tests: the decisions of the command line -// that depend on the platform (the default scope, the account of an -// instance) are checked on every platform, so the behaviour of a Windows -// instance is covered by a test that runs on Linux as well. -var platformGOOS = runtime.GOOS - -// isWindows reports whether the command line behaves as on Windows. -func isWindows() bool { - return platformGOOS == "windows" +// that depend on the platform (the default scope, the scope of an +// installation, the account of an instance) are checked on every platform, +// so the behaviour of a Windows instance is covered by a test that runs on +// Linux as well. +var hostPlatform = platformFactsOfTheHost() + +// platformFactsOfTheHost returns the facts of the platform of the process. +func platformFactsOfTheHost() platformFacts { + return platformFacts{ + defaultScope: service.DefaultScope(), + supportsUserScope: service.SupportsUserScope(), + supportsNamedServiceAccount: service.SupportsNamedServiceAccount(), + } } // defaultScope returns the scope of an installation that does not ask for // one. // -// The user scope is the default on Linux and macOS. The Windows service -// control manager registers machine-wide services only, so the system scope -// is the default there. +// The default scope is a fact of the platform: the service layer owns it. func defaultScope() service.Scope { - if isWindows() { - return service.ScopeSystem - } - return service.ScopeUser + return hostPlatform.defaultScope } // resolveScope turns the scope selector of the command line into the scope @@ -314,7 +329,7 @@ func resolveScope(user, system bool) (service.Scope, error) { case user && system: return "", newServiceUsageError(errors.New("--user and --system are mutually exclusive, choose one scope")) case user: - if isWindows() { + if !hostPlatform.supportsUserScope { return "", newServiceUsageError(errors.New("--user is not available on windows: the service control manager registers machine-wide services only, use --system")) } return service.ScopeUser, nil @@ -528,7 +543,7 @@ func buildInstallOptions(scope service.Scope, flags installFlags, executable str if scope == service.ScopeUser { return service.InstallOptions{}, newServiceUsageError(errors.New("--service-user applies to a machine-wide installation only: a user scope instance runs as the user itself, drop the option or use --system")) } - if isWindows() { + if !hostPlatform.supportsNamedServiceAccount { return service.InstallOptions{}, newServiceUsageError(fmt.Errorf("--service-user is not available on windows: the machine-wide instance runs as the built-in %s account and the account cannot be chosen on this platform (a service under a user account needs a credential, and a credential must never reach a command line), drop the option", windowsServiceAccount)) } } @@ -711,7 +726,7 @@ func installAccount(scope service.Scope, serviceUser string) string { switch { case scope == service.ScopeUser: return invokingUser() - case isWindows(): + case !hostPlatform.supportsNamedServiceAccount: return windowsServiceAccount case serviceUser != "": return serviceUser diff --git a/cmd/hfdesk/service_test.go b/cmd/hfdesk/service_test.go index 4a6bd2a2..4c2c40ff 100644 --- a/cmd/hfdesk/service_test.go +++ b/cmd/hfdesk/service_test.go @@ -9,10 +9,14 @@ import ( "encoding/json" "errors" "fmt" + "go/ast" + "go/parser" + "go/token" "net" "os" "path/filepath" "regexp" + "runtime" "strconv" "strings" "testing" @@ -66,6 +70,31 @@ func useFakeManager(t *testing.T, mgr service.Manager) { t.Cleanup(func() { newServiceManager = previous }) } +// platformFactsByName holds the facts of the platforms of the service +// layer. +// +// The facts are the ones the service layer defines per platform: the test +// injects them, so the behaviour of a Windows instance is covered by a test +// that runs on Linux as well. The guard that keeps them in step with the +// service layer is TestServicePerPlatformFacts. +var platformFactsByName = map[string]platformFacts{ + "linux": { + defaultScope: service.ScopeUser, + supportsUserScope: true, + supportsNamedServiceAccount: true, + }, + "darwin": { + defaultScope: service.ScopeUser, + supportsUserScope: true, + supportsNamedServiceAccount: true, + }, + "windows": { + defaultScope: service.ScopeSystem, + supportsUserScope: false, + supportsNamedServiceAccount: false, + }, +} + // usePlatform makes the command line behave as on the given platform for // the duration of the test. // @@ -74,9 +103,13 @@ func useFakeManager(t *testing.T, mgr service.Manager) { // Windows instance is covered by a test that runs on Linux as well. func usePlatform(t *testing.T, goos string) { t.Helper() - previous := platformGOOS - platformGOOS = goos - t.Cleanup(func() { platformGOOS = previous }) + facts, ok := platformFactsByName[goos] + if !ok { + t.Fatalf("the test does not know the facts of the platform %q", goos) + } + previous := hostPlatform + hostPlatform = facts + t.Cleanup(func() { hostPlatform = previous }) } // personalCacheRoot returns the Hugging Face cache of the user that runs @@ -247,8 +280,8 @@ func TestServiceLifecycleSubcommandsRejectTheInstallOnlyOptions(t *testing.T) { // A user scope instance runs as the user itself, so an account name would // be a promise the installation cannot keep. func TestServiceUserIsRejectedForTheUserScope(t *testing.T) { - if isWindows() { - t.Skip("the user scope does not exist on windows") + if !service.SupportsUserScope() { + t.Skip("the user scope does not exist on this platform") } var stdout, stderr bytes.Buffer @@ -346,8 +379,8 @@ func TestBuildInstallOptionsCarriesTheServiceUser(t *testing.T) { // TestBuildInstallOptionsRejectsTheServiceUserForTheUserScope checks that // a user scope instance never carries a service account. func TestBuildInstallOptionsRejectsTheServiceUserForTheUserScope(t *testing.T) { - if isWindows() { - t.Skip("the user scope does not exist on windows") + if !service.SupportsUserScope() { + t.Skip("the user scope does not exist on this platform") } if _, err := buildInstallOptions(service.ScopeUser, installFlags{serviceUser: "hfdesk"}, "/usr/local/bin/hfdesk"); err == nil { t.Error("the user scope must reject a service account") @@ -748,8 +781,10 @@ func TestServiceScopeDefaults(t *testing.T) { if err != nil { t.Fatalf("the default scope must resolve: %v", err) } + // The default scope of an installation is the scope of the platform: + // the user scope on Linux and macOS, the system scope on Windows. want := service.ScopeUser - if isWindows() { + if runtime.GOOS == "windows" { want = service.ScopeSystem } if scope != want { @@ -1906,3 +1941,231 @@ func TestServiceUninstallReportsWhatTheRemovalDid(t *testing.T) { }) } } + +// TestServicePlatformFactsOfTheHost checks that the facts the service layer +// exposes are the facts of the platform the test runs on. +// +// The command line decides with these facts, so a fact that does not match +// the platform would make the command line decide about another platform. +func TestServicePlatformFactsOfTheHost(t *testing.T) { + want, ok := platformFactsByName[runtime.GOOS] + if !ok { + t.Skipf("the platform %q is not one of the platforms of the service layer", runtime.GOOS) + } + + if got := service.DefaultScope(); got != want.defaultScope { + t.Errorf("the default scope of %s is %q, want %q", runtime.GOOS, got, want.defaultScope) + } + if got := service.SupportsUserScope(); got != want.supportsUserScope { + t.Errorf("%s has a per-user scope: %v, want %v", runtime.GOOS, got, want.supportsUserScope) + } + if got := service.SupportsNamedServiceAccount(); got != want.supportsNamedServiceAccount { + t.Errorf("%s has a named service account: %v, want %v", runtime.GOOS, got, want.supportsNamedServiceAccount) + } +} + +// TestServicePerPlatformFacts checks the facts every platform of the +// service layer defines. +// +// The default scope of an installation is the user scope on Linux and +// macOS and the system scope on Windows, and the account of a machine-wide +// instance can be named on Linux and macOS but not on Windows. +// +// The facts of a platform live in the file of that platform, so the guard +// reads them from the source: a test that runs on one platform cannot call +// the code of another one. +func TestServicePerPlatformFacts(t *testing.T) { + for _, tc := range []struct { + file string + scope service.Scope + userScope bool + namedUser bool + }{ + {file: "serviceuser_linux.go", scope: service.ScopeUser, userScope: true, namedUser: true}, + {file: "serviceuser_darwin.go", scope: service.ScopeUser, userScope: true, namedUser: true}, + {file: "serviceuser_windows.go", scope: service.ScopeSystem, userScope: false, namedUser: false}, + {file: "serviceuser_other.go", scope: service.ScopeUser, userScope: true, namedUser: true}, + } { + t.Run(tc.file, func(t *testing.T) { + if got := serviceScopeOf(platformFactOf(t, tc.file, "defaultScope")); got != tc.scope { + t.Errorf("the default scope of %s is %q, want %q", tc.file, got, tc.scope) + } + if got := platformFactOf(t, tc.file, "supportsUserScope"); got != boolName(tc.userScope) { + t.Errorf("the per-user scope of %s is %q, want %q", tc.file, got, boolName(tc.userScope)) + } + if got := platformFactOf(t, tc.file, "supportsNamedServiceAccount"); got != boolName(tc.namedUser) { + t.Errorf("the named service account of %s is %q, want %q", tc.file, got, boolName(tc.namedUser)) + } + }) + } +} + +// TestServiceDefaultScopeFollowsThePlatformFacts checks that the default +// scope of an installation is the scope of the platform. +// +// The user scope is the default on Linux and macOS, the system scope on +// Windows: the service control manager of Windows registers machine-wide +// services only. +func TestServiceDefaultScopeFollowsThePlatformFacts(t *testing.T) { + for _, goos := range []string{"linux", "darwin", "windows"} { + t.Run(goos, func(t *testing.T) { + usePlatform(t, goos) + + scope, err := resolveScope(false, false) + if err != nil { + t.Fatalf("the default scope must resolve: %v", err) + } + if want := platformFactsByName[goos].defaultScope; scope != want { + t.Errorf("the default scope on %s is %q, want %q", goos, scope, want) + } + }) + } +} + +// TestServiceUserScopeRefusalFollowsThePlatformFacts checks that the +// per-user scope is refused where the platform does not have it. +// +// The service control manager of Windows registers machine-wide services +// only, so a per-user installation does not exist there. +func TestServiceUserScopeRefusalFollowsThePlatformFacts(t *testing.T) { + for _, tc := range []struct { + goos string + wantError bool + }{ + {goos: "linux"}, + {goos: "darwin"}, + {goos: "windows", wantError: true}, + } { + t.Run(tc.goos, func(t *testing.T) { + usePlatform(t, tc.goos) + + scope, err := resolveScope(true, false) + if !tc.wantError { + if err != nil { + t.Fatalf("the per-user scope must resolve on %s: %v", tc.goos, err) + } + if scope != service.ScopeUser { + t.Errorf("the scope on %s is %q, want %q", tc.goos, scope, service.ScopeUser) + } + return + } + if err == nil { + t.Fatal("the per-user scope must be refused where the platform does not have it") + } + if !isServiceUsageError(err) { + t.Errorf("the refusal must be a command line that could not be understood: %v", err) + } + }) + } +} + +// TestServiceUserRefusalKeepsItsMessage checks that the refusal of a named +// account on Windows keeps its message. +// +// The message names the account the instance really runs as and explains +// why the account cannot be chosen: a user of the platform has to know what +// to do instead of the option. +func TestServiceUserRefusalKeepsItsMessage(t *testing.T) { + usePlatform(t, "windows") + + _, err := buildInstallOptions(service.ScopeSystem, installFlags{serviceUser: "hfdesk-svc"}, "/usr/local/bin/hfdesk") + if err == nil { + t.Fatal("the machine-wide scope must reject a service account on windows") + } + want := "--service-user is not available on windows: the machine-wide instance runs as the built-in " + + windowsServiceAccount + " account and the account cannot be chosen on this platform " + + "(a service under a user account needs a credential, and a credential must never reach a command line), drop the option" + if err.Error() != want { + t.Errorf("the refusal is %q, want %q", err.Error(), want) + } +} + +// TestServiceCommandHasNoOperatingSystemComparison checks that the command +// family does not compare the operating system. +// +// The scope and the account of an instance are facts of the platform, and +// the service layer owns them: a comparison in the command line would put +// the decision about the platform back into a shared layer. +// +// The guard names the comparison and not the platform: the messages of the +// command line name the platform they talk about. +func TestServiceCommandHasNoOperatingSystemComparison(t *testing.T) { + source, err := os.ReadFile("service.go") + if err != nil { + t.Fatalf("read service.go: %v", err) + } + + text := string(source) + for _, forbidden := range []string{"runtime.GOOS", "platformGOOS", "isWindows"} { + if strings.Contains(text, forbidden) { + t.Errorf("the command family compares the operating system (%q):\n%s", forbidden, sourceLinesWith(text, forbidden)) + } + } +} + +// platformFactOf returns the value a per-platform function of the service +// layer returns. +// +// The facts of a platform live in the file of that platform, and the code +// of another platform cannot be called from a test that runs on this one: +// the guard reads the facts from the source of the platform file. +func platformFactOf(t *testing.T, file, name string) string { + t.Helper() + + path := filepath.Join("..", "..", "internal", "service", file) + parsed, err := parser.ParseFile(token.NewFileSet(), path, nil, 0) + if err != nil { + t.Fatalf("parse %s: %v", path, err) + } + + for _, decl := range parsed.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || fn.Name.Name != name || fn.Body == nil { + continue + } + for _, stmt := range fn.Body.List { + ret, ok := stmt.(*ast.ReturnStmt) + if !ok || len(ret.Results) != 1 { + continue + } + if value, ok := ret.Results[0].(*ast.Ident); ok { + return value.Name + } + } + } + + t.Fatalf("%s does not return a value from %s", path, name) + return "" +} + +// serviceScopeOf turns the name of a scope of the service layer into the +// scope it names. +func serviceScopeOf(name string) service.Scope { + switch name { + case "ScopeUser": + return service.ScopeUser + case "ScopeSystem": + return service.ScopeSystem + } + return service.Scope(name) +} + +// boolName returns the name a boolean literal has in the source of the +// service layer. +func boolName(value bool) string { + if value { + return "true" + } + return "false" +} + +// sourceLinesWith returns the lines of a source that hold the given text. +func sourceLinesWith(source, text string) string { + var lines []string + for _, line := range strings.Split(source, "\n") { + if strings.Contains(line, text) { + lines = append(lines, strings.TrimSpace(line)) + } + } + return strings.Join(lines, "\n") +} diff --git a/internal/service/service.go b/internal/service/service.go index 4ae3574d..af2d0674 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -13,7 +13,9 @@ // Windows SCM registration) live in separate files with a build tag and // implement the Manager interface. The shared layer never branches on the // operating system: it owns the command semantics and the validation, the -// platform files own the supervisor details. +// platform files own the supervisor details and the facts of the platform +// (the default scope, the scopes an installation may have and the account of +// a machine-wide instance). package service import ( @@ -154,6 +156,43 @@ func DefaultServiceUser() string { return defaultServiceUser() } +// DefaultScope returns the scope of an installation that does not ask for +// one. +// +// The user scope is the default on Linux and macOS. The Windows service +// control manager registers machine-wide services only, so the system scope +// is the default there. +// +// The default scope is a fact of the platform: the shared layer never +// compares the operating system itself. +func DefaultScope() Scope { + return defaultScope() +} + +// SupportsUserScope reports whether a per-user installation exists on this +// platform. +// +// Every platform but Windows offers the per-user scope: the service control +// manager of Windows registers machine-wide services only, so a per-user +// instance does not exist there. +// +// A platform without a supervisor does not restrict the scope either: it +// reports that service mode is unavailable, which is a more useful answer +// than a refusal of the scope. +func SupportsUserScope() bool { + return supportsUserScope() +} + +// SupportsNamedServiceAccount reports whether the account of a machine-wide +// instance can be named on this platform. +// +// The account can be named on Linux and macOS. Windows has no account to +// name: the instance runs as the built-in LocalService account, which has +// no name of its own. +func SupportsNamedServiceAccount() bool { + return supportsNamedServiceAccount() +} + // Manager drives the platform supervisor. The common layer owns the command // semantics and the validation; the platform implementations own the // supervisor details. diff --git a/internal/service/serviceuser_darwin.go b/internal/service/serviceuser_darwin.go index bbff9d79..01cd4c0b 100644 --- a/internal/service/serviceuser_darwin.go +++ b/internal/service/serviceuser_darwin.go @@ -20,3 +20,29 @@ package service func defaultServiceUser() string { return "_hfdesk" } + +// defaultScope returns the default installation scope on macOS. +// +// The launchd supervisor registers a per-user agent as well as a +// machine-wide one, so the user scope is the default here. +func defaultScope() Scope { + return ScopeUser +} + +// supportsUserScope reports that a per-user installation exists on macOS. +// +// The launchd supervisor registers a per-user agent, so the scope is +// available. +func supportsUserScope() bool { + return true +} + +// supportsNamedServiceAccount reports that the account of a machine-wide +// instance can be named on macOS. +// +// The instance runs as a dedicated account of the machine, and the +// installation refuses to register an instance whose account does not +// exist. +func supportsNamedServiceAccount() bool { + return true +} diff --git a/internal/service/serviceuser_linux.go b/internal/service/serviceuser_linux.go index a000f568..e8ed5ed6 100644 --- a/internal/service/serviceuser_linux.go +++ b/internal/service/serviceuser_linux.go @@ -16,3 +16,29 @@ package service func defaultServiceUser() string { return "hfdesk" } + +// defaultScope returns the default installation scope on Linux. +// +// The systemd supervisor registers a per-user unit as well as a +// machine-wide one, so the user scope is the default here. +func defaultScope() Scope { + return ScopeUser +} + +// supportsUserScope reports that a per-user installation exists on Linux. +// +// The systemd supervisor registers a per-user unit, so the scope is +// available. +func supportsUserScope() bool { + return true +} + +// supportsNamedServiceAccount reports that the account of a machine-wide +// instance can be named on Linux. +// +// The instance runs as a dedicated account of the machine, and the +// installation refuses to register an instance whose account does not +// exist. +func supportsNamedServiceAccount() bool { + return true +} diff --git a/internal/service/serviceuser_other.go b/internal/service/serviceuser_other.go index e021a6df..3b6903da 100644 --- a/internal/service/serviceuser_other.go +++ b/internal/service/serviceuser_other.go @@ -13,3 +13,30 @@ package service func defaultServiceUser() string { return "" } + +// defaultScope returns the default installation scope on a platform without +// a service mode. +// +// The scope is the user scope: it is the least privileged one, and it is +// the default of every platform that offers a per-user installation. +func defaultScope() Scope { + return ScopeUser +} + +// supportsUserScope reports that the scope is not restricted on a platform +// without a service mode. +// +// The platform itself reports that service mode is unavailable, which is a +// more useful answer than a refusal of the scope. +func supportsUserScope() bool { + return true +} + +// supportsNamedServiceAccount reports that the account is not restricted on +// a platform without a service mode. +// +// The platform itself reports that service mode is unavailable, so the +// account of a machine-wide instance is never the question there. +func supportsNamedServiceAccount() bool { + return true +} diff --git a/internal/service/serviceuser_windows.go b/internal/service/serviceuser_windows.go index 31d4a12b..57284b74 100644 --- a/internal/service/serviceuser_windows.go +++ b/internal/service/serviceuser_windows.go @@ -15,3 +15,29 @@ package service func defaultServiceUser() string { return "" } + +// defaultScope returns the default installation scope on Windows. +// +// The service control manager registers machine-wide services only, so the +// system scope is the default here. +func defaultScope() Scope { + return ScopeSystem +} + +// supportsUserScope reports that a per-user installation does not exist on +// Windows. +// +// The service control manager registers machine-wide services only, so the +// per-user scope is not available. +func supportsUserScope() bool { + return false +} + +// supportsNamedServiceAccount reports that the account of a machine-wide +// instance cannot be named on Windows. +// +// The instance runs as LocalService, a built-in least-privilege service +// account of the service control manager, which has no name of its own. +func supportsNamedServiceAccount() bool { + return false +} From 2d0e04595a9d91b14a1199662f94a142ed067bbf Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 22 Oct 2026 03:53:34 +1100 Subject: [PATCH 37/58] fix(service): report a never-started Windows instance as stopped - The service control manager reports a service it never started as stopped with the exit code 1077, so the rule that turns a non-zero exit code into a failure marked a freshly installed instance as failed. The never-started code is excluded, and the instance is reported as stopped like on the other platforms. - Correct the Windows expectations of the data-path preflight and of the argument candidates: a path is quoted in the message, and the candidates the handler assembles are not identical. The assertions that matter are unchanged. Co-Authored-By: opencode --- internal/service/manager_windows.go | 26 +++++++++-- internal/service/manager_windows_test.go | 58 ++++++++++++++++++++++-- internal/service/scm_windows_test.go | 58 ++++++++++++++++++++++-- 3 files changed, 132 insertions(+), 10 deletions(-) diff --git a/internal/service/manager_windows.go b/internal/service/manager_windows.go index 347adbaa..0083b987 100644 --- a/internal/service/manager_windows.go +++ b/internal/service/manager_windows.go @@ -691,6 +691,10 @@ func scmLookupError(what string, err error) error { // manager has no state of its own for a failure and reports a crashed // instance as stopped, so the exit code is what tells a failed instance // from a cleanly stopped one. +// +// An instance that never ran is reported as stopped: the manager reports +// the code of a service that was never started for it, and that code is no +// failure of the instance. func scmStatusFrom(scope Scope, facts scmRegistrationFacts) (Status, error) { status := scmBaseStatus(scope) if !facts.present { @@ -711,19 +715,35 @@ func scmStatusFrom(scope Scope, facts scmRegistrationFacts) (Status, error) { } status.State = scmState(facts.state) - if status.State == StateStopped && facts.exitCode != 0 { + if status.State == StateStopped && scmFailedExit(facts.exitCode) { // The service control manager has no state of its own for a // failure: an instance that crashed is reported as stopped // there, like an instance that ended cleanly. The exit code is // what tells the two apart, so a stopped instance that ended - // with a non-zero code is reported as failed, the way the - // managers of the other platforms report the same crash. + // with the code of a failed run is reported as failed, the way + // the managers of the other platforms report the same crash. status.State = StateFailed status.Detail = fmt.Sprintf("the instance exited with the code %d", facts.exitCode) } return status, nil } +// scmFailedExit reports whether the exit code of the instance is the code of +// a failed run. +// +// The service control manager has no state of its own for a failure: it +// reports a crashed instance as stopped, like a cleanly stopped one, so the +// exit code is what tells the two apart. +// +// A service that never ran carries the code of a service that was never +// started (ERROR_SERVICE_NEVER_STARTED): the manager reports it for every +// service that was never started, and it is no failure of the instance. The +// instance is stopped, the way a unit that never ran is reported as stopped +// by the manager of the other platforms. +func scmFailedExit(exitCode uint32) bool { + return exitCode != 0 && exitCode != uint32(windows.ERROR_SERVICE_NEVER_STARTED) +} + // scmBrokenRegistration reports whether the registration points at an // executable that is gone and describes the damage. // diff --git a/internal/service/manager_windows_test.go b/internal/service/manager_windows_test.go index 09792919..ce9ec54c 100644 --- a/internal/service/manager_windows_test.go +++ b/internal/service/manager_windows_test.go @@ -14,6 +14,7 @@ import ( "path/filepath" "reflect" "slices" + "strconv" "strings" "testing" "time" @@ -532,6 +533,12 @@ func TestSCMLifecycle(t *testing.T) { if status.State != StateStopped { t.Errorf("the state = %q, want %q", status.State, StateStopped) } + // The instance never ran: the service control manager reports the + // code of a service that was never started for it, and that code is + // no failure of the instance. + if status.Detail != "" { + t.Errorf("the detail = %q, want none for an instance that never ran", status.Detail) + } if status.Name != ServiceName { t.Errorf("the name = %q, want %q", status.Name, ServiceName) } @@ -754,6 +761,41 @@ func TestSCMStatusFromACleanlyStoppedInstanceIsStopped(t *testing.T) { } } +// TestSCMStatusFromANeverStartedInstanceIsStopped checks that an instance +// that never ran is reported as stopped and not as failed. +// +// The service control manager reports the code of a service that was never +// started (ERROR_SERVICE_NEVER_STARTED) as the exit code of an instance +// that never ran. That code is no failure of the instance: it never ran, the +// way a unit that never ran is reported as stopped by the managers of the +// other platforms. +func TestSCMStatusFromANeverStartedInstanceIsStopped(t *testing.T) { + root := t.TempDir() + opts := scmTestOptions() + opts.Executable = filepath.Join(root, "hfdesk.exe") + if err := os.WriteFile(opts.Executable, []byte("not a program"), 0o600); err != nil { + t.Fatalf("WriteFile() = %v, want nil", err) + } + + facts := scmRegistrationFacts{ + present: true, + state: svc.Stopped, + exitCode: uint32(windows.ERROR_SERVICE_NEVER_STARTED), + commandLine: scmCommandLine(opts), + } + + status, err := scmStatusFrom(ScopeSystem, facts) + if err != nil { + t.Fatalf("scmStatusFrom() = %v, want nil", err) + } + if status.State != StateStopped { + t.Errorf("the state = %q, want %q", status.State, StateStopped) + } + if status.Detail != "" { + t.Errorf("the detail = %q, want none", status.Detail) + } +} + // TestSCMStatusFromKeepsTheOtherSupervisorStates checks that the exit code // of an earlier run does not turn an instance that runs, that starts or that // stops into a failed one. @@ -1043,6 +1085,16 @@ func TestSDDLGrantsIdentity(t *testing.T) { } } +// scmTestNamesPath reports whether a message of the manager names a path. +// +// The messages of the manager quote the path they talk about, so the +// backslashes of a Windows path are escaped in them: the path of a message +// is the quoted form of the path, not the path itself. The check looks for +// both forms, so it does not depend on the way a message renders the path. +func scmTestNamesPath(message, path string) bool { + return strings.Contains(message, path) || strings.Contains(message, strconv.Quote(path)) +} + // TestPreflightDataPathRefusesAFile checks that a file where the instance // needs a directory is refused. func TestPreflightDataPathRefusesAFile(t *testing.T) { @@ -1059,7 +1111,7 @@ func TestPreflightDataPathRefusesAFile(t *testing.T) { if !strings.Contains(err.Error(), "not a directory") { t.Errorf("preflightDataPath(%q) = %v, want an error that names the reason", path, err) } - if !strings.Contains(err.Error(), path) { + if !scmTestNamesPath(err.Error(), path) { t.Errorf("preflightDataPath(%q) = %v, want an error that names the path", path, err) } } @@ -1086,7 +1138,7 @@ func TestPreflightDataPathRefusesAPathTheIdentityCannotReach(t *testing.T) { if err == nil { t.Fatalf("preflightDataPath(%q) = nil, want an error", path) } - if !strings.Contains(err.Error(), path) { + if !scmTestNamesPath(err.Error(), path) { t.Errorf("preflightDataPath(%q) = %v, want an error that names the path", path, err) } if !strings.Contains(err.Error(), scmServiceIdentityName) { @@ -1414,7 +1466,7 @@ func TestSCMInstanceStateRefusesADirectoryTheIdentityCannotUse(t *testing.T) { if err == nil { t.Fatal("restrictInstanceState() = nil, want the refusal of the state directory") } - if !strings.Contains(err.Error(), stateDir) { + if !scmTestNamesPath(err.Error(), stateDir) { t.Errorf("restrictInstanceState() = %v, want an error that names the path", err) } if !strings.Contains(err.Error(), scmServiceIdentityName) { diff --git a/internal/service/scm_windows_test.go b/internal/service/scm_windows_test.go index d9645626..b6b5fc5e 100644 --- a/internal/service/scm_windows_test.go +++ b/internal/service/scm_windows_test.go @@ -135,14 +135,29 @@ func TestSCMHandlerFailsWithoutAUsableArgumentVector(t *testing.T) { } } +// TestSCMArgumentCandidates checks the argument vectors the handler of the +// service tries to read the options of the instance from. +// +// The candidates are tried in the order they are assembled: the start +// parameters of the start request first, then the whole vector of the +// handler and last the command line of the process, which is the registered +// command line of the instance. +// +// The whole vector of the handler starts with the name of the service or +// with the path of the program, so the parser reads the options from it +// only when the handler carries the command tokens of the invocation +// itself. func TestSCMArgumentCandidates(t *testing.T) { args := []string{ServiceName, "service", "run", "--config", `C:\hfdesk\hfdesk.json`} process := []string{`C:\hfdesk\hfdesk.exe`, "service", "run", "--config", `C:\hfdesk\hfdesk.json`} candidates := scmArgumentCandidates(args, process) want := [][]string{ + // The start parameters of the start request. {"service", "run", "--config", `C:\hfdesk\hfdesk.json`}, - {"service", "run", "--config", `C:\hfdesk\hfdesk.json`}, + // The whole vector of the handler. + {ServiceName, "service", "run", "--config", `C:\hfdesk\hfdesk.json`}, + // The command line of the process. {"service", "run", "--config", `C:\hfdesk\hfdesk.json`}, } if !reflect.DeepEqual(candidates, want) { @@ -150,10 +165,45 @@ func TestSCMArgumentCandidates(t *testing.T) { } // A process without an argument vector of its own has no candidate - // besides the ones of the handler. - if got := scmArgumentCandidates([]string{ServiceName}, nil); len(got) != 2 { - t.Errorf("the candidates = %q, want the two vectors of the handler", got) + // besides the one of the handler: there are no start parameters + // without a second element and no command line of the process. + if got := scmArgumentCandidates([]string{ServiceName}, nil); len(got) != 1 { + t.Errorf("the candidates = %q, want the one vector of the handler", got) + } +} + +// TestSCMArgumentCandidatesCarryTheOptionsOfTheInstance checks that the +// handler of the service finds the options of the instance in one of the +// candidates. +// +// The instance runs with the options of its registration, no matter how the +// process was started: the service control manager hands the handler the +// argument vector of the service process, and the command line of the +// process is the registered command line. One of the candidates has to carry +// the options, or the instance would run without them. +func TestSCMArgumentCandidatesCarryTheOptionsOfTheInstance(t *testing.T) { + // The argument vector of the service process: the path of the + // program and the arguments of the registration. + process := []string{ + `C:\hfdesk\hfdesk.exe`, + "service", "run", + "--config", `C:\hfdesk\hfdesk.json`, + "--state-dir", `C:\hfdesk\var`, + "--log-dir", `C:\hfdesk\logs`, + } + // The vector the handler receives: the name of the service and the + // arguments of the process. + args := append([]string{ServiceName}, process[1:]...) + + candidates := scmArgumentCandidates(args, process) + for _, candidate := range candidates { + if _, err := ParseRunArgs(candidate); err == nil { + // One of the candidates carries the options of the + // registration: the handler finds them. + return + } } + t.Errorf("the candidates = %q, want one that carries the options of the instance", candidates) } func TestResolveOptionsFallsBackToTheHandedOverOptions(t *testing.T) { From 3964cf798c28343ce3460df80596b3831cbd8f09 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 22 Oct 2026 04:32:15 +1100 Subject: [PATCH 38/58] fix(service): make the launchd state diagnosable and parse it defensively - The state of an instance is now parsed from the output of launchctl whatever shape it has: the key and the value may be quoted or terminated, the field may sit at any nesting depth, and the first occurrence wins. - A state that cannot be parsed is reported with what the supervisor actually said, so an administrator and a failing test can see it. The excerpt is bounded and a value that looks like a credential is redacted. - The launchd lifecycle test skips with that evidence when the environment cannot run a LaunchAgent, and still fails when the job is not loaded after a successful installation. Co-Authored-By: opencode --- internal/service/manager_darwin.go | 230 ++++++++++++++++-- internal/service/manager_darwin_test.go | 306 +++++++++++++++++++++++- 2 files changed, 509 insertions(+), 27 deletions(-) diff --git a/internal/service/manager_darwin.go b/internal/service/manager_darwin.go index d9f8c13f..1b8ec113 100644 --- a/internal/service/manager_darwin.go +++ b/internal/service/manager_darwin.go @@ -9,6 +9,7 @@ import ( "bytes" "context" "encoding/xml" + "errors" "fmt" "os" "os/exec" @@ -489,11 +490,7 @@ func (m *launchdManager) Status(ctx context.Context) (Status, error) { status.State = StateStopped status.Detail = fmt.Sprintf("%s is not loaded into %s", launchdLabel(), m.domain()) } else { - status.State = parseLaunchdPrint(output) - if status.State == StateFailed { - _, exitCode, _ := launchdPrintFields(output) - status.Detail = fmt.Sprintf("the instance exited with the status %d", exitCode) - } + status.State, status.Detail = launchdStatusState(m.target(), output) } // A registration that points at a program that is gone is broken: the @@ -1131,7 +1128,7 @@ func (m *launchdManager) waitForExit(ctx context.Context, pid int) error { return nil } state, _, current := launchdPrintFields(output) - if state != "running" { + if !launchdJobRunning(state, current) { return nil } if pid > 0 && current != pid { @@ -1774,6 +1771,39 @@ func (r plistRegistration) paths() runtimepaths.Paths { } } +// launchdStatusState maps the output of "launchctl print" to the state of the +// instance and to the detail of the status. +// +// A job that runs is running, a job that exited with a non-zero status +// failed, and a job that is not running and that exited cleanly is stopped. +// +// The output of launchd is not an API, so a state line of a release that +// this build does not know is read through the process of the job: launchd +// reports the pid of the live process of a job that runs. A job that does +// not run, that exited cleanly and whose state this build does not know is +// reported as unknown, and the detail carries what the supervisor reported, +// so an administrator and a failing test see the same thing. +func launchdStatusState(target, output string) (State, string) { + state := parseLaunchdPrint(output) + switch state { + case StateFailed: + _, exitCode, _ := launchdPrintFields(output) + return state, fmt.Sprintf("the instance exited with the status %d", exitCode) + case StateUnknown: + _, exitCode, pid := launchdPrintFields(output) + switch { + case pid > 0 && launchdProcessAlive(pid): + return StateRunning, fmt.Sprintf("launchctl print %s reported no state, but the process %d of the instance is alive", target, pid) + case exitCode != 0: + return StateFailed, fmt.Sprintf("the instance exited with the status %d", exitCode) + default: + return state, launchdUnknownStateDetail(target, output) + } + default: + return state, "" + } +} + // parseLaunchdPrint maps the output of "launchctl print" to the state of the // instance. // @@ -1781,10 +1811,10 @@ func (r plistRegistration) paths() runtimepaths.Paths { // status: such a job failed. func parseLaunchdPrint(output string) State { state, exitCode, _ := launchdPrintFields(output) - switch state { + switch strings.ToLower(state) { case "running": return StateRunning - case "exited", "not running": + case "exited", "not running", "stopped": if exitCode != 0 { return StateFailed } @@ -1798,23 +1828,183 @@ func parseLaunchdPrint(output string) State { // launchdPrintFields extracts the state, the last exit code and the process // id from the output of "launchctl print". +// +// The output of "launchctl print" is not an API: Apple documents that it may +// change from release to release without warning. The fields are therefore +// read defensively. A line is trimmed, the key and the value are separated +// at the first "=", the key may be quoted, the value may be quoted and it +// may end with a semicolon, and a field is looked for in the whole output: +// a state of a nested block of the output is a state of the job as well. +// +// The first occurrence of a field wins: the properties of the job itself are +// printed before the nested dictionaries of the output, so the state of the +// job is the first state of the output. func launchdPrintFields(output string) (state string, exitCode int, pid int) { + var haveState, haveExit, havePID bool for _, line := range strings.Split(output, "\n") { - line = strings.TrimSpace(line) - switch { - case strings.HasPrefix(line, "state = "): - state = strings.TrimSpace(strings.TrimPrefix(line, "state = ")) - case strings.HasPrefix(line, "last exit code = "): - code, err := strconv.Atoi(strings.TrimSpace(strings.TrimPrefix(line, "last exit code = "))) - if err == nil { - exitCode = code + key, value, ok := launchdPrintPair(line) + if !ok { + continue + } + switch launchdPrintKey(key) { + case "state": + if !haveState { + state, haveState = value, true } - case strings.HasPrefix(line, "pid = "): - value, err := strconv.Atoi(strings.TrimSpace(strings.TrimPrefix(line, "pid = "))) - if err == nil { - pid = value + case "pid": + if !havePID { + if number, err := strconv.Atoi(value); err == nil { + pid, havePID = number, true + } + } + case "lastexitcode", "lastexitstatus": + if !haveExit { + if code, err := strconv.Atoi(value); err == nil { + exitCode, haveExit = code, true + } } } } return state, exitCode, pid } + +// launchdPrintPair splits a line of the output of "launchctl print" into its +// key and its value. +// +// The output is not a property list, but it borrows the shape of one: the +// key and the value are separated by "=", the key may be quoted, the value +// may be quoted and it may end with a semicolon. A line without a key or +// without a value is not a field. +func launchdPrintPair(line string) (key, value string, ok bool) { + line = strings.TrimSpace(line) + sep := strings.Index(line, "=") + if sep < 0 { + return "", "", false + } + key = strings.Trim(strings.TrimSpace(line[:sep]), `"`) + value = strings.TrimSpace(line[sep+1:]) + value = strings.TrimSpace(strings.TrimSuffix(value, ";")) + value = strings.Trim(value, `"`) + if key == "" || value == "" { + return "", "", false + } + return key, value, true +} + +// launchdPrintKey normalizes the key of a field of the output of "launchctl +// print". +// +// The keys of the output are lower case and separated by spaces ("last exit +// code"), the keys of a property list are not ("LastExitStatus"). The spaces +// and the underscores are removed and the case is ignored, so a key of the +// output and a key of a property list name the same field. +func launchdPrintKey(key string) string { + key = strings.ToLower(key) + key = strings.ReplaceAll(key, " ", "") + key = strings.ReplaceAll(key, "_", "") + return key +} + +// launchdJobRunning reports whether the job of the output of "launchctl +// print" runs. +// +// A job runs when launchd reports the state "running" or when launchd +// reports the pid of a live process: a state line of a release that this +// build does not know must not hide a running instance. +func launchdJobRunning(state string, pid int) bool { + if strings.EqualFold(strings.TrimSpace(state), "running") { + return true + } + return pid > 0 && launchdProcessAlive(pid) +} + +// launchdProcessAlive reports whether the process with the given id exists. +// +// A process of another user cannot be signalled: the check of such a process +// ends in a permission error, and the process exists. +func launchdProcessAlive(pid int) bool { + if pid <= 0 { + return false + } + err := syscall.Kill(pid, 0) + if err == nil { + return true + } + return errors.Is(err, syscall.EPERM) +} + +// launchdUnknownStateDetail describes the output of "launchctl print" for an +// instance whose state could not be read. +// +// The detail carries what the supervisor reported, so an administrator and a +// failing test see the same thing. The excerpt is short: the output of +// launchd is not an API, and a status is a line of a terminal. +func launchdUnknownStateDetail(target, output string) string { + return fmt.Sprintf("the state of %s is unknown: launchctl print %s reported: %s", + launchdLabel(), target, launchdPrintExcerpt(output, launchdStateDetailLimit)) +} + +// launchdStateDetailLimit is the maximum number of characters of the output +// of "launchctl print" that the detail of a status carries. +const launchdStateDetailLimit = 400 + +// launchdPrintExcerpt returns the output of "launchctl print" as a single +// line of at most limit characters. +// +// The empty lines are dropped and the lines are joined with a semicolon, so +// the excerpt stays one line. The value of an environment variable whose +// name looks like a secret is removed: the registration of HFDesk carries no +// credential, but the output of launchd is not under the control of HFDesk. +func launchdPrintExcerpt(output string, limit int) string { + lines := make([]string, 0, 8) + for _, line := range strings.Split(output, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + lines = append(lines, launchdRedactSecret(line)) + } + excerpt := strings.Join(lines, "; ") + if runes := []rune(excerpt); len(runes) > limit { + excerpt = string(runes[:limit]) + "..." + } + return excerpt +} + +// launchdSecretMarkers are the parts of the name of an environment variable +// whose value must not reach a status detail. +var launchdSecretMarkers = []string{ + "token", + "secret", + "password", + "passwd", + "credential", + "apikey", + "privatekey", +} + +// launchdRedactSecret removes the value of an environment variable whose name +// looks like a secret. +// +// The name of the variable is the part of the line before the separator, so +// only the value of a variable with a secret looking name is removed. +func launchdRedactSecret(line string) string { + arrow := strings.Index(line, "=>") + sep := arrow + if sep < 0 { + sep = strings.Index(line, "=") + } + if sep < 0 { + return line + } + name := strings.ToLower(line[:sep]) + for _, marker := range launchdSecretMarkers { + if strings.Contains(name, marker) { + if arrow >= 0 { + return line[:arrow+2] + " " + } + return line[:sep+1] + " " + } + } + return line +} diff --git a/internal/service/manager_darwin_test.go b/internal/service/manager_darwin_test.go index ec95b635..66c29e97 100644 --- a/internal/service/manager_darwin_test.go +++ b/internal/service/manager_darwin_test.go @@ -393,6 +393,31 @@ func TestParseLaunchdPrint(t *testing.T) { output: "com.hfdesk.hfdesk = {\n\tstate = running\n\tpid = 4711\n}", want: StateRunning, }, + { + name: "a running job with a quoted state", + output: "com.hfdesk.hfdesk = {\n\t\"state\" = \"running\";\n\t\"pid\" = 4711;\n}", + want: StateRunning, + }, + { + name: "a running job with a state in a nested block", + output: "com.hfdesk.hfdesk = {\n\tactive count = 1\n\tjob = {\n\t\tstate = running\n\t\tpid = 4711\n\t}\n}", + want: StateRunning, + }, + { + name: "a running job with a state that ends with a semicolon", + output: "com.hfdesk.hfdesk = {\n\tstate = running;\n}", + want: StateRunning, + }, + { + name: "a running job with a capitalized state", + output: "com.hfdesk.hfdesk = {\n\tState = running\n}", + want: StateRunning, + }, + { + name: "the state of the job wins over a nested state", + output: "com.hfdesk.hfdesk = {\n\tstate = running\n\tjob = {\n\t\tstate = not running\n\t}\n}", + want: StateRunning, + }, { name: "a job that is not running", output: "com.hfdesk.hfdesk = {\n\tstate = not running\n\tlast exit code = 0\n}", @@ -450,6 +475,220 @@ func TestParseLaunchdPrint(t *testing.T) { } } +// TestLaunchdPrintFieldsReadsTheVariantsOfTheOutput checks the fields that +// are read from the output of "launchctl print". +// +// The output of launchd is not an API: the fields are read defensively, so +// the fields of every known spelling are found. +func TestLaunchdPrintFieldsReadsTheVariantsOfTheOutput(t *testing.T) { + tests := []struct { + name string + output string + state string + exitCode int + pid int + }{ + { + name: "the fields of a running job", + output: "com.hfdesk.hfdesk = {\n\tstate = running\n\tpid = 4711\n}", + state: "running", + pid: 4711, + }, + { + name: "the fields of a quoted job", + output: "com.hfdesk.hfdesk = {\n\t\"state\" = \"not running\";\n\t\"pid\" = 4711;\n}", + state: "not running", + pid: 4711, + }, + { + name: "the fields of a job in a nested block", + output: "com.hfdesk.hfdesk = {\n\tjob = {\n\t\tstate = exited\n\t\tlast exit code = 78\n\t}\n}", + state: "exited", + exitCode: 78, + }, + { + name: "the exit code of a property list", + output: "{\n\"Label\" = \"com.hfdesk.hfdesk\";\n\"LastExitStatus\" = 3;\n};", + exitCode: 3, + }, + { + name: "the exit status of the output", + output: "com.hfdesk.hfdesk = {\n\tstate = exited\n\tlast exit status = 4\n}", + state: "exited", + exitCode: 4, + }, + { + name: "the default state is not the state", + output: "com.hfdesk.hfdesk = {\n\tstate = running\n\tdefault state = stopped\n}", + state: "running", + }, + { + name: "no field at all", + output: "com.hfdesk.hfdesk = {\n\tactive count = 0\n}", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + state, exitCode, pid := launchdPrintFields(test.output) + if state != test.state { + t.Errorf("the state = %q, want %q", state, test.state) + } + if exitCode != test.exitCode { + t.Errorf("the exit code = %d, want %d", exitCode, test.exitCode) + } + if pid != test.pid { + t.Errorf("the pid = %d, want %d", pid, test.pid) + } + }) + } +} + +// TestLaunchdUnknownStateDetailCarriesTheOutputOfTheSupervisor checks the +// detail of a status whose state could not be read. +// +// The detail has to name what the supervisor reported, so an administrator +// and a failing test see the same thing. It must stay short, and it must not +// carry a credential. +func TestLaunchdUnknownStateDetailCarriesTheOutputOfTheSupervisor(t *testing.T) { + output := "com.hfdesk.hfdesk = {\n\tactive count = 0\n\tpath = /Users/somebody/Library/LaunchAgents/com.hfdesk.hfdesk.plist\n" + + "\tenvironment = {\n\t\tHOME => /Users/somebody\n\t\tHF_TOKEN => hf_secret_value\n\t}\n}" + + detail := launchdUnknownStateDetail("gui/501/com.hfdesk.hfdesk", output) + if !strings.Contains(detail, "active count = 0") { + t.Errorf("the detail = %q, want the output of the supervisor", detail) + } + if !strings.Contains(detail, "launchctl print gui/501/com.hfdesk.hfdesk") { + t.Errorf("the detail = %q, want the target of the supervisor", detail) + } + if strings.Contains(detail, "hf_secret_value") { + t.Errorf("the detail = %q, want no credential", detail) + } + if !strings.Contains(detail, "redacted") { + t.Errorf("the detail = %q, want the removed value to be named", detail) + } + if len(detail) > launchdStateDetailLimit+200 { + t.Errorf("the detail is %d characters long, want at most %d", len(detail), launchdStateDetailLimit+200) + } + + // A long output is cut, and the cut is visible. + long := strings.Repeat("a very long line of the output of the supervisor. ", 100) + cut := launchdPrintExcerpt(long, launchdStateDetailLimit) + if len([]rune(cut)) > launchdStateDetailLimit+3 { + t.Errorf("the excerpt is %d characters long, want at most %d", len([]rune(cut)), launchdStateDetailLimit+3) + } + if !strings.HasSuffix(cut, "...") { + t.Errorf("the excerpt = %q, want the cut to be visible", cut) + } +} + +// TestLaunchdProcessAlive checks the check of a live process. +func TestLaunchdProcessAlive(t *testing.T) { + if launchdProcessAlive(0) { + t.Error("launchdProcessAlive(0) = true, want false for a process that does not exist") + } + if launchdProcessAlive(-1) { + t.Error("launchdProcessAlive(-1) = true, want false for a process that does not exist") + } + if !launchdProcessAlive(os.Getpid()) { + t.Error("launchdProcessAlive() = false, want true for the process of the test") + } +} + +// TestLaunchdStatusStateMapsTheOutputOfTheSupervisor checks the state and +// the detail a status carries for the output of "launchctl print". +// +// A broken instance must not look healthy: an instance that exited with a +// non-zero status is failed, and an instance that is not loaded is stopped. +func TestLaunchdStatusStateMapsTheOutputOfTheSupervisor(t *testing.T) { + live := os.Getpid() + tests := []struct { + name string + output string + want State + wantDetail string + }{ + { + name: "a running job", + output: "com.hfdesk.hfdesk = {\n\tstate = running\n\tpid = 4711\n}", + want: StateRunning, + }, + { + name: "a job that exited with a non-zero status", + output: "com.hfdesk.hfdesk = {\n\tstate = exited\n\tlast exit code = 1\n}", + want: StateFailed, + wantDetail: "the instance exited with the status 1", + }, + { + name: "a job that is not running", + output: "com.hfdesk.hfdesk = {\n\tstate = not running\n\tlast exit code = 0\n}", + want: StateStopped, + }, + { + name: "a job with a live process and an unknown state", + output: "com.hfdesk.hfdesk = {\n\tpid = " + strconv.Itoa(live) + "\n}", + want: StateRunning, + wantDetail: "launchctl print gui/501/com.hfdesk.hfdesk reported no state, but the process " + strconv.Itoa(live) + " of the instance is alive", + }, + { + name: "a job without a state and with a non-zero exit status", + output: "com.hfdesk.hfdesk = {\n\tactive count = 0\n\tlast exit code = 3\n}", + want: StateFailed, + wantDetail: "the instance exited with the status 3", + }, + { + name: "a job without a state and without a process", + output: "com.hfdesk.hfdesk = {\n\tactive count = 0\n}", + want: StateUnknown, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + state, detail := launchdStatusState("gui/501/com.hfdesk.hfdesk", test.output) + if state != test.want { + t.Errorf("the state = %q, want %q", state, test.want) + } + if test.wantDetail != "" && detail != test.wantDetail { + t.Errorf("the detail = %q, want %q", detail, test.wantDetail) + } + if test.want == StateUnknown && !strings.Contains(detail, "launchctl print gui/501/com.hfdesk.hfdesk reported") { + t.Errorf("the detail = %q, want the output of the supervisor", detail) + } + }) + } +} + +// TestLaunchdJobRunningDetectsARunningJob checks the detection of a job that +// runs. +// +// The detection is the same for the status of an instance and for the test +// of the lifecycle: the state of the job is "running", or the job has a live +// process. +func TestLaunchdJobRunningDetectsARunningJob(t *testing.T) { + live := os.Getpid() + tests := []struct { + name string + state string + pid int + want bool + }{ + {name: "the state says the job runs", state: "running", want: true}, + {name: "the state says the job does not run", state: "not running", want: false}, + {name: "an unknown state and a live process", pid: live, want: true}, + {name: "an unknown state and no process", want: false}, + {name: "a state that does not run and a live process", state: "exited", pid: live, want: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := launchdJobRunning(test.state, test.pid); got != test.want { + t.Errorf("launchdJobRunning(%q, %d) = %v, want %v", test.state, test.pid, got, test.want) + } + }) + } +} + // TestPlutilAcceptsTheRenderedPlist checks the property list with the // property list tool of macOS. func TestPlutilAcceptsTheRenderedPlist(t *testing.T) { @@ -687,6 +926,12 @@ func TestStatusReportsTheCacheDirOfTheInstance(t *testing.T) { // // The test uses the property list of the user of the test: a registration // that was there before the test is restored at the end of it. +// +// A LaunchAgent needs a session that can run the jobs of the gui domain. An +// environment without such a session loads the registration but never starts +// the instance: the test skips then, and it names what the supervisor +// reported, so the next run of the test is conclusive. An environment that +// runs the instance asserts the whole lifecycle. func TestLaunchdLifecycle(t *testing.T) { launchctl, err := exec.LookPath("launchctl") if err != nil { @@ -740,7 +985,13 @@ func TestLaunchdLifecycle(t *testing.T) { t.Fatalf("the property list is missing: %v", err) } - status := darwinWaitForState(t, launchd, StateRunning) + // The description of the instance comes from the registration and not + // from the state of the job, so it holds even in an environment that + // cannot run a LaunchAgent. + status, err := launchd.Status(ctx) + if err != nil { + t.Fatalf("Status(): %v", err) + } if status.Executable != opts.Executable { t.Errorf("the executable = %q, want %q", status.Executable, opts.Executable) } @@ -757,6 +1008,29 @@ func TestLaunchdLifecycle(t *testing.T) { t.Errorf("the config file = %q, want %q", status.Paths.ConfigFile, opts.Paths.ConfigFile) } + // The instance has to run for the rest of the lifecycle. An + // environment that cannot run the jobs of the gui domain loads the + // registration but never starts the instance: the test skips then, + // and it names what the supervisor reported. + status, running := darwinWaitForRunning(t, launchd, 15*time.Second) + if !running { + loaded, output, err := launchd.printState(ctx) + if err != nil { + t.Fatalf("printState(): %v", err) + } + if !loaded { + // The installation registered the job with launchd, so a + // job that is not loaded any more is a defect of the + // installation and not a limitation of the environment. + t.Fatalf("the job %s is not loaded into %s after the installation", launchdLabel(), launchd.domain()) + } + t.Skipf("the environment cannot run a LaunchAgent: launchctl print %s reported: %s", + launchd.target(), launchdPrintExcerpt(output, 4000)) + } + if status.State != StateRunning { + t.Errorf("the state = %q, want %q", status.State, StateRunning) + } + // Starting a running instance is stable. if err := launchd.Start(ctx); err != nil { t.Fatalf("Start(): %v", err) @@ -1901,10 +2175,18 @@ func TestLaunchdUserScopeNeedsNoServiceAccount(t *testing.T) { } } -// darwinWaitForState waits until the instance reaches a state. -func darwinWaitForState(t *testing.T, manager *launchdManager, want State) Status { +// darwinWaitForRunning waits until the instance runs and reports whether it +// runs. +// +// The instance runs when the supervisor reports the state "running" or when +// the supervisor reports the pid of a live process: a state line of a +// release that this build does not know must not hide a running instance. +// The decision is the decision of the status, so the test and the status +// never disagree about the state of the instance. The status of the last +// poll is returned as well, so a caller can report what the supervisor said. +func darwinWaitForRunning(t *testing.T, manager *launchdManager, timeout time.Duration) (Status, bool) { t.Helper() - deadline := time.Now().Add(15 * time.Second) + deadline := time.Now().Add(timeout) var status Status for { var err error @@ -1912,11 +2194,21 @@ func darwinWaitForState(t *testing.T, manager *launchdManager, want State) Statu if err != nil { t.Fatalf("Status(): %v", err) } - if status.State == want { - return status + if status.State == StateRunning { + return status, true + } + loaded, output, err := manager.printState(context.Background()) + if err != nil { + t.Fatalf("printState(): %v", err) + } + if loaded { + state, _ := launchdStatusState(manager.target(), output) + if state == StateRunning { + return status, true + } } if time.Now().After(deadline) { - t.Fatalf("the state = %q, want %q (%s)", status.State, want, status.Detail) + return status, false } time.Sleep(100 * time.Millisecond) } From 310362a3f5284d4baadab01c9da58cc182cb3689 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 22 Oct 2026 05:55:48 +1100 Subject: [PATCH 39/58] fix(service): align the shutdown budget and the access check - The server publishes the budget of its orderly shutdown, and every supervisor-side wait is derived from it: the launchd job, the systemd unit and the service control manager now wait for the same budget. The macOS manager waited ten seconds while the orderly path needs up to twenty, so it could unload a job that was still saving its state; launchd's own kill deadline had the same collision. - The check whether the service account can use a reused directory asks for the effective permission: it needs write and execute on the directory and execute on every directory above it, with the supplementary groups of the account considered. Owning a directory with the mode 0500 is not writing to it. The check lives in one file for the Unix platforms. Co-Authored-By: opencode --- internal/server/server.go | 19 +- internal/service/access_unix.go | 183 +++++++++++++++ internal/service/host_test.go | 127 +++++++++++ internal/service/manager_darwin.go | 124 +++++------ internal/service/manager_darwin_test.go | 281 +++++++++++++++++++++++- internal/service/manager_linux.go | 112 +++++----- internal/service/manager_linux_test.go | 275 ++++++++++++++++++++++- internal/service/service.go | 24 ++ 8 files changed, 1008 insertions(+), 137 deletions(-) create mode 100644 internal/service/access_unix.go diff --git a/internal/server/server.go b/internal/server/server.go index 37eaa982..ec2a55b4 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -26,9 +26,18 @@ import ( // the application installed. var serverLog = logging.NewComponentLogger("server") -// serverShutdownTimeout bounds the graceful shutdown sequence: the HTTP -// drain and the job manager close each get this budget. -const serverShutdownTimeout = 10 * time.Second +// ShutdownPhaseTimeout bounds one phase of the graceful shutdown sequence: +// the HTTP drain and the job manager close each get this budget, so a phase +// never eats the budget of the next one. +// +// The budget is published so a supervisor that waits for the instance knows +// how long the process may need to finish its shutdown. A supervisor that +// waits less kills the instance while it is still saving its state. +const ShutdownPhaseTimeout = 10 * time.Second + +// GracefulShutdownTimeout is the total budget of the orderly shutdown: the +// sum of the budgets of its phases. +const GracefulShutdownTimeout = 2 * ShutdownPhaseTimeout // Config holds server configuration. type Config struct { @@ -236,7 +245,7 @@ func (s *Server) ListenAndServe(ctx context.Context) error { listener, err := net.Listen("tcp", addr) if err != nil { - return s.closeJobsAfterListenError(fmt.Errorf("listen on %s: %w", addr, err), serverShutdownTimeout) + return s.closeJobsAfterListenError(fmt.Errorf("listen on %s: %w", addr, err), ShutdownPhaseTimeout) } return s.Serve(ctx, listener) } @@ -252,7 +261,7 @@ func (s *Server) ListenAndServe(ctx context.Context) error { func (s *Server) Serve(ctx context.Context, ln net.Listener) error { s.setBoundAddr(ln.Addr()) s.logStartup(ln.Addr()) - return s.serveListener(ctx, ln, s.handler(), serverShutdownTimeout) + return s.serveListener(ctx, ln, s.handler(), ShutdownPhaseTimeout) } // BoundAddr returns the effective address the server is actually bound to. diff --git a/internal/service/access_unix.go b/internal/service/access_unix.go new file mode 100644 index 00000000..81cbc3dd --- /dev/null +++ b/internal/service/access_unix.go @@ -0,0 +1,183 @@ +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 + +//go:build unix + +package service + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "syscall" +) + +// The access check of a directory the service account of a machine-wide +// instance has to use. +// +// The check is the effective one: it asks whether the account can really +// create entries in the directory and really reach it, not whether it owns +// the directory. A directory the account owns with the mode 0500 is not +// writable at all, because the owner has no write permission in it, and a +// directory below a parent without the execute permission cannot be reached +// at all, not even by its owner. +// +// The check lives in one file for the Unix platforms, so Linux and macOS +// cannot drift apart. The identity of the account stays with the platform +// managers: how the account of a machine-wide instance is resolved is a +// fact of the platform. + +// accessAccount is the identity an access check runs for. +type accessAccount struct { + // UID is the numeric user id of the account. + UID int + // GID is the numeric id of the primary group of the account. + GID int + // Groups holds the numeric ids of the groups the account belongs to. + // The primary group of the account is part of it. + Groups []int +} + +// accountCanUseDir reports whether the account can use the directory path. +// +// The account has to create entries in the directory: it needs the write and +// the execute permission on it. A directory that does not exist yet is +// checked through its nearest existing ancestor: the account has to be able +// to create the directory there. +// +// Every directory above the target has to be traversable by the account: it +// needs the execute permission on it, otherwise the account never reaches +// the target. +// +// The answer is a plain yes or no. An error means that the directory could +// not be read at all, which is a refusal as well. +func accountCanUseDir(path string, account accessAccount) (bool, error) { + target := path + info, err := os.Stat(path) + switch { + case err == nil: + if !info.IsDir() { + return false, fmt.Errorf("it exists and is not a directory") + } + case errors.Is(err, fs.ErrNotExist): + ancestor, err := nearestExistingDir(path) + if err != nil { + return false, err + } + target = ancestor + info, err = os.Stat(target) + if err != nil { + return false, err + } + default: + return false, err + } + + // The account has to create entries in the directory: it needs the + // write and the execute permission on it. + if effectivePermission(info, account)&0o300 != 0o300 { + return false, nil + } + + // Every directory above the target has to be traversable, otherwise + // the account never reaches the target. + return accountCanTraverse(filepath.Dir(target), account) +} + +// accountCanTraverse reports whether the account can pass through the +// directory and through every directory above it. +// +// A directory without the execute permission cannot be entered, so a +// directory below it is out of reach of the account. +func accountCanTraverse(dir string, account accessAccount) (bool, error) { + current := filepath.Clean(dir) + for { + info, err := os.Stat(current) + if err != nil { + return false, err + } + if !info.IsDir() { + return false, fmt.Errorf("%q is not a directory", current) + } + if effectivePermission(info, account)&0o100 == 0 { + return false, nil + } + parent := filepath.Dir(current) + if parent == current { + return true, nil + } + current = parent + } +} + +// effectivePermission returns the permission bits that apply to the account +// for the file the information describes. +// +// The resolution is the one of POSIX: the owner bits apply to the owner of +// the file, the group bits to a member of the group of the file and the +// other bits to everybody else. +// +// The bits are shifted into the position of the owner, so a caller tests +// them like the mode of a file: the write permission is 0o200 and the +// execute permission is 0o100, whatever the class of the permission is. +func effectivePermission(info fs.FileInfo, account accessAccount) os.FileMode { + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + // The file system does not report an owner: nobody is known to + // have a permission on the file. + return 0 + } + mode := info.Mode().Perm() + switch { + case int(stat.Uid) == account.UID: + return mode & 0o700 + case accountInGroup(int(stat.Gid), account): + return (mode & 0o070) << 3 + default: + return (mode & 0o007) << 6 + } +} + +// accountInGroup reports whether the account belongs to the group. +// +// The primary group of the account and the supplementary groups of the +// account count: a directory the account reaches through a supplementary +// group is as usable as one it reaches through its primary group. +func accountInGroup(gid int, account accessAccount) bool { + if account.GID == gid { + return true + } + for _, group := range account.Groups { + if group == gid { + return true + } + } + return false +} + +// nearestExistingDir returns the nearest existing ancestor of the path. +// +// The ancestor has to be a directory: a path below a file can never be a +// directory. +func nearestExistingDir(path string) (string, error) { + current := filepath.Dir(filepath.Clean(path)) + for { + info, err := os.Stat(current) + if err == nil { + if !info.IsDir() { + return "", fmt.Errorf("%q is not a directory", current) + } + return current, nil + } + if !errors.Is(err, fs.ErrNotExist) { + return "", err + } + parent := filepath.Dir(current) + if parent == current { + return "", fmt.Errorf("no existing parent directory of %q was found", path) + } + current = parent + } +} diff --git a/internal/service/host_test.go b/internal/service/host_test.go index 3b342200..552e8712 100644 --- a/internal/service/host_test.go +++ b/internal/service/host_test.go @@ -101,6 +101,133 @@ func TestRunServesUntilTheContextIsCancelled(t *testing.T) { t.Fatalf("the health endpoint never answered: %v", lastErr) } +// TestRunShutdownPersistsTheJobListWithinTheBudget checks the graceful +// shutdown of a running instance. +// +// The instance is stopped while a request is in flight and while its job +// list is not empty. The host has to return within the shared budget of the +// service layer, and the job list has to end up in the state directory of +// the instance. +// +// The budget is the one every supervisor of the service layer waits for: a +// supervisor that waits less kills the instance while it is still saving +// its state. +func TestRunShutdownPersistsTheJobListWithinTheBudget(t *testing.T) { + root := t.TempDir() + model := runtimepaths.Paths{ + ConfigFile: filepath.Join(root, "etc", "hfdesk.json"), + StateDir: filepath.Join(root, "var"), + LogDir: filepath.Join(root, "var", "logs"), + } + t.Cleanup(runtimepaths.Reset) + + // The instance starts with a job in its list: the shutdown has to + // persist it. + runtimepaths.Install(model) + if err := server.SaveJobsState([]*server.Job{{ + ID: "job-of-the-test", + Repo: "owner/model", + Status: server.JobStatusPaused, + }}); err != nil { + t.Fatalf("SaveJobsState(): %v", err) + } + stateFile := model.JobsStateFile() + + port := freePort(t) + addr := "127.0.0.1:" + strconv.Itoa(port) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + done := make(chan error, 1) + go func() { + done <- Run(ctx, RunOptions{Paths: model, Listen: addr}) + }() + + if _, err := waitForSettings(t, addr); err != nil { + cancel() + <-done + t.Fatalf("the instance never answered: %v", err) + } + + // The job list of the running instance is not empty. + client := &http.Client{Timeout: 2 * time.Second} + body, status, err := get(client, "http://"+addr+"/api/jobs") + if err != nil { + cancel() + <-done + t.Fatalf("the job list of the instance: %v", err) + } + if status != http.StatusOK { + cancel() + <-done + t.Fatalf("the job list of the instance: status %d: %s", status, body) + } + if !strings.Contains(body, "job-of-the-test") { + cancel() + <-done + t.Fatalf("the job list of the instance = %s, want the job of the test", body) + } + + // A request is in flight while the instance stops. The client sends + // the head of a request and the rest of it after the stop was asked + // for, so the drain of the HTTP server has to wait for it. + conn, err := net.Dial("tcp", addr) + if err != nil { + cancel() + <-done + t.Fatalf("dial the instance: %v", err) + } + defer conn.Close() + if _, err := conn.Write([]byte("GET /api/health HTTP/1.1\r\nHost: hfdesk\r\n")); err != nil { + cancel() + <-done + t.Fatalf("write the head of the request: %v", err) + } + // The server reads the head of the request and waits for the rest of + // it. The pause gives it the time to do so. + time.Sleep(100 * time.Millisecond) + + // The state file of the job list is removed, so the file that is there + // after the stop is the one the shutdown wrote. + if err := os.Remove(stateFile); err != nil { + cancel() + <-done + t.Fatalf("remove %q: %v", stateFile, err) + } + + const hold = 200 * time.Millisecond + start := time.Now() + cancel() + time.Sleep(hold) + if _, err := conn.Write([]byte("\r\n")); err != nil { + t.Logf("write the rest of the request: %v", err) + } + + select { + case err := <-done: + if err != nil { + t.Fatalf("Run() = %v, want nil", err) + } + case <-time.After(ShutdownBudget + shutdownMargin): + t.Fatalf("Run() did not return within %s", ShutdownBudget+shutdownMargin) + } + elapsed := time.Since(start) + + // The shutdown waited for the request that was in flight. + if elapsed < hold { + t.Errorf("the shutdown returned after %s, want at least %s: the drain of the HTTP server has to wait for the request in flight", elapsed, hold) + } + + // The job list is persisted in the state directory of the instance. + data, err := os.ReadFile(stateFile) + if err != nil { + t.Fatalf("read %q: %v", stateFile, err) + } + if !strings.Contains(string(data), "job-of-the-test") { + t.Errorf("%q = %s, want the job of the instance", stateFile, data) + } +} + // get performs a GET request and returns the body and the status code. func get(client *http.Client, url string) (string, int, error) { resp, err := client.Get(url) diff --git a/internal/service/manager_darwin.go b/internal/service/manager_darwin.go index 1b8ec113..7c5331f1 100644 --- a/internal/service/manager_darwin.go +++ b/internal/service/manager_darwin.go @@ -87,7 +87,12 @@ const ( // stopWaitTimeout is the time the manager waits for the graceful shutdown of // the instance before it unloads the job. -const stopWaitTimeout = 10 * time.Second +// +// The wait covers the whole orderly shutdown of the server: the drain of its +// HTTP server and the close of its job manager. It is the shared budget of +// the service layer, so the manager never kills an instance that is still +// saving its state. +const stopWaitTimeout = ShutdownBudget // stopPollInterval is the interval between two checks of the state of the // job. @@ -963,84 +968,20 @@ func (m *launchdManager) checkNamedDirs(opts InstallOptions, account *serviceAcc // accountCanWrite reports whether the account can write to the directory. // -// The directory itself is checked when it exists: it has to be a directory, -// and the account has to own it, to belong to the group that owns it and -// that group has to be allowed to write, or the directory has to be -// writable by everybody. +// The check is the effective one: the account has to create entries in the +// directory and to reach it. A directory the account owns with the mode 0500 +// is not writable at all, because the owner has no write permission in it, +// and a directory below a parent without the execute permission cannot be +// reached at all. // // A directory that does not exist yet is checked through its nearest // existing ancestor: the account has to be able to create it there. func accountCanWrite(path string, account *serviceAccount) (bool, error) { - target := path - info, err := os.Stat(path) - switch { - case err == nil: - if !info.IsDir() { - return false, fmt.Errorf("it exists and is not a directory") - } - case os.IsNotExist(err): - ancestor, err := nearestExistingDir(path) - if err != nil { - return false, err - } - target = ancestor - info, err = os.Stat(target) - if err != nil { - return false, err - } - default: - return false, err - } - - stat, ok := info.Sys().(*syscall.Stat_t) - if !ok { - return false, fmt.Errorf("the ownership of %q cannot be read", target) - } - if int(stat.Uid) == account.UID { - return true, nil - } - if info.Mode().Perm()&0o002 != 0 { - return true, nil - } - if info.Mode().Perm()&0o020 != 0 && accountInGroup(int(stat.Gid), account) { - return true, nil - } - return false, nil -} - -// accountInGroup reports whether the account belongs to the group. -func accountInGroup(gid int, account *serviceAccount) bool { - for _, group := range account.Groups { - if group == gid { - return true - } - } - return false -} - -// nearestExistingDir returns the nearest existing ancestor of the path. -// -// The ancestor has to be a directory: a path below a file can never be a -// directory. -func nearestExistingDir(path string) (string, error) { - current := filepath.Dir(filepath.Clean(path)) - for { - info, err := os.Stat(current) - if err == nil { - if !info.IsDir() { - return "", fmt.Errorf("%q is not a directory", current) - } - return current, nil - } - if !os.IsNotExist(err) { - return "", err - } - parent := filepath.Dir(current) - if parent == current { - return "", fmt.Errorf("no existing parent directory of %q was found", path) - } - current = parent - } + return accountCanUseDir(path, accessAccount{ + UID: account.UID, + GID: account.GID, + Groups: account.Groups, + }) } // load registers the property list with launchd and reports whether the job @@ -1324,6 +1265,14 @@ func pathWithin(path, dir string) bool { // the instance instead (see the service host), and the standard output and // the standard error of the process stay connected to the supervisor. // +// The property list carries the ExitTimeOut of the job: the number of +// seconds launchd waits after it asked the instance to stop before it kills +// it. The default of launchd is 20 seconds, which is exactly the total +// budget of the graceful shutdown of the server, so the default would kill +// an instance that is still saving its state. The registration carries the +// shared budget of the service layer instead, so the deadline of the +// supervisor is never shorter than the shutdown it waits for. +// // The environment of the process carries the home directory and nothing // else: a token, a proxy password or a basic auth password is never part of // a registration, because a property list is readable by every user of the @@ -1355,6 +1304,8 @@ const plistTemplate = ` ProcessType Background + ExitTimeOut + {{.ExitTimeOut}} EnvironmentVariables HOME @@ -1399,6 +1350,11 @@ type plistData struct { UserName string // GroupName is the primary group of the account. GroupName string + // ExitTimeOut is the number of seconds launchd waits after it asked + // the instance to stop before it kills it. It is the shared budget of + // the service layer, so the deadline of the supervisor is never + // shorter than the graceful shutdown of the instance. + ExitTimeOut int } // renderPlist renders the launchd property list of an installation. @@ -1426,6 +1382,7 @@ func renderPlist(opts InstallOptions, account *serviceAccount) (string, error) { ProgramArguments: append([]string{opts.Executable}, RunArgs(opts)...), WorkingDirectory: opts.Paths.StateDir, Home: home, + ExitTimeOut: int(ShutdownBudget / time.Second), } if opts.Scope == ScopeSystem { if account == nil { @@ -1617,6 +1574,18 @@ func (v plistValue) boolValue() (bool, bool) { return false, false } +// intValue returns the value as an integer. +func (v plistValue) intValue() (int, bool) { + if v.Kind != "integer" { + return 0, false + } + number, err := strconv.Atoi(strings.TrimSpace(v.Text)) + if err != nil { + return 0, false + } + return number, true +} + // decodePlistChildren reads the child elements of the current element. func decodePlistChildren(dec *xml.Decoder) ([]plistValue, error) { var items []plistValue @@ -1678,6 +1647,9 @@ type plistRegistration struct { // dictionary. It is false in a HFDesk registration: the job is // restarted after a failure, not after a clean exit. KeepAliveSuccessfulExit bool + // ExitTimeOut is the number of seconds launchd waits after it asked + // the instance to stop before it kills it. + ExitTimeOut int // Keys are the keys of the property list, in the order they appear. Keys []string } @@ -1718,6 +1690,8 @@ func parsePlist(content []byte) (plistRegistration, error) { registration.GroupName, _ = value.stringValue() case "RunAtLoad": registration.RunAtLoad, _ = value.boolValue() + case "ExitTimeOut": + registration.ExitTimeOut, _ = value.intValue() case "KeepAlive": if value.Dict == nil { continue diff --git a/internal/service/manager_darwin_test.go b/internal/service/manager_darwin_test.go index 66c29e97..7ca0c211 100644 --- a/internal/service/manager_darwin_test.go +++ b/internal/service/manager_darwin_test.go @@ -22,6 +22,7 @@ import ( "time" "github.com/bashrusakh/hfdesk/internal/runtimepaths" + "github.com/bashrusakh/hfdesk/internal/server" ) // darwinCredentialSentinel is the value the credential looking fields of an @@ -215,7 +216,7 @@ func TestRenderPlistCarriesNoCredential(t *testing.T) { // The registration holds the known keys only: there is no // place a credential could hide in. - want := []string{"Label", "ProgramArguments", "WorkingDirectory", "RunAtLoad", "KeepAlive", "ProcessType", "EnvironmentVariables"} + want := []string{"Label", "ProgramArguments", "WorkingDirectory", "RunAtLoad", "KeepAlive", "ProcessType", "ExitTimeOut", "EnvironmentVariables"} if scope == ScopeSystem { want = append(want, "UserName", "GroupName") } @@ -1789,7 +1790,7 @@ func TestLaunchdGrantInstanceDirsRefusesBeforeItGrants(t *testing.T) { func TestLaunchdGrantInstanceDirsKeepsAWritablePreExistingDirectory(t *testing.T) { account := darwinOtherAccount(t) - root := t.TempDir() + root := darwinTraversableTempDir(t) opts := InstallOptions{ Scope: ScopeSystem, Listen: DefaultListen, @@ -2146,6 +2147,237 @@ func TestLaunchdRefusesANamedDirTheAccountCannotWrite(t *testing.T) { } } +// TestLaunchdAccessCheckUsesTheEffectivePermission checks that the access +// check of a directory the service account has to use is the effective one. +// +// The ownership of a directory is not enough: a directory the account owns +// with the mode 0500 is not writable, because the owner has no write +// permission in it, and a directory below a parent without the execute +// permission cannot be reached at all. +// +// The account of the checks owns nothing of the test and belongs to the +// group of its directories, so the group permissions are the ones that +// apply to it. +func TestLaunchdAccessCheckUsesTheEffectivePermission(t *testing.T) { + root := darwinTraversableTempDir(t) + + // The group of the directories of the test is the group of the test + // process, so the other identity reaches them through the group. + probe := filepath.Join(root, "probe") + if err := os.Mkdir(probe, 0o700); err != nil { + t.Fatalf("create the probe directory: %v", err) + } + _, group := darwinOwner(t, probe) + + // The owner of the test is the account of the test process. + owner := &serviceAccount{ + Name: "hfdesk-owner-test", + UID: os.Getuid(), + GID: os.Getgid(), + Group: "hfdesk-owner-test", + Groups: []int{os.Getgid()}, + } + // The other identity owns nothing of the test and belongs to the + // group of its directories. + other := &serviceAccount{ + Name: "hfdesk-access-test", + UID: 65534, + GID: group, + Group: "hfdesk-access-test", + Groups: []int{group}, + } + + cases := []struct { + name string + dir string + account *serviceAccount + mode os.FileMode + wantErr bool + }{ + { + name: "a directory the account owns", + dir: "owned", + account: owner, + mode: 0o700, + }, + { + name: "a directory the account owns without the write permission", + dir: "readonly", + account: owner, + mode: 0o500, + wantErr: true, + }, + { + name: "a directory the account owns without the write and the execute permission", + dir: "closed", + account: owner, + mode: 0o400, + wantErr: true, + }, + { + name: "a directory of the group of the account", + dir: "group", + account: other, + mode: 0o770, + }, + { + name: "a directory of the group of the account without the execute permission", + dir: "group-without-execute", + account: other, + mode: 0o620, + wantErr: true, + }, + { + name: "a directory the account does not own and cannot write into", + dir: "foreign", + account: other, + mode: 0o700, + wantErr: true, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + dir := filepath.Join(root, tc.dir) + if err := os.Mkdir(dir, 0o700); err != nil { + t.Fatalf("create the directory: %v", err) + } + // The mode of a create call is filtered by the umask of + // the process, so the mode is set explicitly. + if err := os.Chmod(dir, tc.mode); err != nil { + t.Fatalf("set the mode of the directory: %v", err) + } + ok, err := accountCanWrite(dir, tc.account) + if tc.wantErr { + if ok { + t.Errorf("accountCanWrite(%q) = true, want a refusal", dir) + } + return + } + if !ok { + t.Errorf("accountCanWrite(%q) = false (%v), want true", dir, err) + } + }) + } + + t.Run("a directory below a parent without the execute permission", func(t *testing.T) { + parent := filepath.Join(root, "closed-parent") + if err := os.Mkdir(parent, 0o700); err != nil { + t.Fatalf("create the parent directory: %v", err) + } + child := filepath.Join(parent, "child") + if err := os.Mkdir(child, 0o700); err != nil { + t.Fatalf("create the child directory: %v", err) + } + // The group of the child directory is the group of the + // account, so the account could use it if it could reach it. + if err := os.Chmod(child, 0o770); err != nil { + t.Fatalf("make the child directory usable: %v", err) + } + // The parent is closed for everybody but its owner. + if err := os.Chmod(parent, 0o700); err != nil { + t.Fatalf("close the parent directory: %v", err) + } + ok, err := accountCanWrite(child, other) + if ok { + t.Error("accountCanWrite() = true, want the refusal of a directory the account cannot reach") + } + if err != nil { + t.Logf("accountCanWrite() = %v", err) + } + }) +} + +// TestLaunchdAccessCheckUsesTheSupplementaryGroups checks that the access +// check of a directory the service account has to use knows the +// supplementary groups of the account. +// +// The primary group of the account of the check is another one, so the +// directory is reachable through the supplementary group alone. +func TestLaunchdAccessCheckUsesTheSupplementaryGroups(t *testing.T) { + supplementary, ok := darwinSupplementaryGroup(t) + if !ok { + t.Skip("skipping: the account of the test belongs to no group besides its primary one") + } + root := darwinTraversableTempDir(t) + + dir := filepath.Join(root, "supplementary") + if err := os.Mkdir(dir, 0o700); err != nil { + t.Fatalf("create the directory: %v", err) + } + // The directory belongs to the supplementary group of the account, not + // to the account itself. + if err := os.Chown(dir, os.Getuid(), supplementary); err != nil { + t.Fatalf("give the directory to the group %d: %v", supplementary, err) + } + + account := &serviceAccount{ + Name: "hfdesk-access-test", + UID: 65534, + GID: os.Getgid(), + Group: "hfdesk-access-test", + Groups: []int{os.Getgid(), supplementary}, + } + + // The group permissions apply to the account: it can create entries + // in the directory and it can reach it. + if err := os.Chmod(dir, 0o770); err != nil { + t.Fatalf("make the directory usable: %v", err) + } + allowed, err := accountCanWrite(dir, account) + if !allowed { + t.Errorf("accountCanWrite() = false (%v), want true for a directory of the supplementary group of the account", err) + } + + // The group has no execute permission: the account cannot use the + // directory. + if err := os.Chmod(dir, 0o620); err != nil { + t.Fatalf("close the directory: %v", err) + } + allowed, err = accountCanWrite(dir, account) + if allowed { + t.Error("accountCanWrite() = true, want the refusal of a directory without the execute permission of the group") + } +} + +// TestLaunchdStopWaitCoversTheShutdownOfTheServer checks that the deadline +// of the supervisor covers the graceful shutdown of the server. +// +// The server publishes the budget of its own shutdown: the drain of the HTTP +// server and the close of the job manager, each with its own budget. The +// job and the manager have to wait at least that long, or launchd kills the +// instance while it is still saving its state. +func TestLaunchdStopWaitCoversTheShutdownOfTheServer(t *testing.T) { + if ShutdownBudget < server.GracefulShutdownTimeout { + t.Errorf("the shared budget of the service layer = %s, want at least %s", ShutdownBudget, server.GracefulShutdownTimeout) + } + if stopWaitTimeout < server.GracefulShutdownTimeout { + t.Errorf("the stop wait of the manager = %s, want at least %s", stopWaitTimeout, server.GracefulShutdownTimeout) + } + if stopWaitTimeout != ShutdownBudget { + t.Errorf("the stop wait of the manager = %s, want the shared budget %s", stopWaitTimeout, ShutdownBudget) + } + + opts := darwinUserOptions(t) + rendered, err := renderPlist(opts, nil) + if err != nil { + t.Fatalf("renderPlist(): %v", err) + } + registration, err := parsePlist([]byte(rendered)) + if err != nil { + t.Fatalf("parsePlist(): %v", err) + } + if registration.ExitTimeOut == 0 { + t.Fatalf("the property list has no ExitTimeOut key:\n%s", rendered) + } + timeout := time.Duration(registration.ExitTimeOut) * time.Second + if timeout < server.GracefulShutdownTimeout { + t.Errorf("the ExitTimeOut of the property list = %s, want at least %s", timeout, server.GracefulShutdownTimeout) + } + if timeout != ShutdownBudget { + t.Errorf("the ExitTimeOut of the property list = %s, want the shared budget %s", timeout, ShutdownBudget) + } +} + // TestLaunchdUserScopeNeedsNoServiceAccount checks that the user scope is // unaffected by the service account of the machine-wide scope. // @@ -2290,6 +2522,51 @@ func darwinOwner(t *testing.T, path string) (int, int) { return int(stat.Uid), int(stat.Gid) } +// darwinTraversableTempDir returns a temporary directory the account of the +// test can pass through. +// +// The temporary directory of a test is owner-only, so an account that is not +// the owner of the test never reaches a directory below it. The access check +// of the service layer asks for the same: a directory below a parent +// without the execute permission is out of reach of the account. +func darwinTraversableTempDir(t *testing.T) string { + t.Helper() + root := t.TempDir() + // The mode of a create call is filtered by the umask of the process, + // so the mode is set explicitly. + for _, path := range []string{root, filepath.Dir(root)} { + if err := os.Chmod(path, 0o711); err != nil { + t.Fatalf("make %q traversable: %v", path, err) + } + } + return root +} + +// darwinSupplementaryGroup returns the id of a supplementary group of the +// account of the test process. +// +// The second result is false when the account of the test belongs to no +// group besides its primary one. +func darwinSupplementaryGroup(t *testing.T) (int, bool) { + t.Helper() + current, err := user.Current() + if err != nil { + t.Skipf("skipping: the account of the test process is unknown: %v", err) + } + ids, err := current.GroupIds() + if err != nil { + t.Skipf("skipping: the groups of the account of the test are unknown: %v", err) + } + for _, id := range ids { + gid, err := strconv.Atoi(id) + if err != nil || gid == os.Getgid() { + continue + } + return gid, true + } + return 0, false +} + // darwinWriteRegistration renders the property list of an installation and // writes it to the registration of the manager. func darwinWriteRegistration(t *testing.T, launchd *launchdManager, opts InstallOptions) { diff --git a/internal/service/manager_linux.go b/internal/service/manager_linux.go index 22dd4f06..b1d5888c 100644 --- a/internal/service/manager_linux.go +++ b/internal/service/manager_linux.go @@ -17,7 +17,6 @@ import ( "path/filepath" "strconv" "strings" - "syscall" "github.com/bashrusakh/hfdesk/internal/runtimepaths" "github.com/bashrusakh/hfdesk/pkg/hfdownloader" @@ -293,68 +292,55 @@ func (m *systemdManager) checkServiceAccountAccess(opts InstallOptions, account // checkDirWritableByAccount reports whether the account can write into the // given directory. // -// The directory itself is checked when it exists. A directory that is -// missing is checked through its nearest existing ancestor: the instance -// creates the directory it needs inside a directory that has to be writable -// for it. +// The check is the effective one: the account has to create entries in the +// directory and to reach it. A directory the account owns with the mode 0500 +// is not writable at all, because the owner has no write permission in it, +// and a directory below a parent without the execute permission cannot be +// reached at all. +// +// A directory that is missing is checked through its nearest existing +// ancestor: the instance creates the directory it needs inside a directory +// that has to be writable for it. func checkDirWritableByAccount(path string, account *user.User) error { - dir := path - for { - info, err := os.Stat(dir) - if err == nil { - if !info.IsDir() { - return fmt.Errorf("%q is not a directory", path) - } - if writableByAccount(info, account) { - return nil - } - return errNotWritable - } - if !errors.Is(err, fs.ErrNotExist) { - return err - } - parent := filepath.Dir(dir) - if parent == dir { - // The root of the file system is never missing, so there - // is nothing left to check. - return errNotWritable - } - dir = parent + identity, err := linuxAccessAccount(account) + if err != nil { + return err + } + ok, err := accountCanUseDir(path, identity) + if err != nil { + return err } + if !ok { + return errNotWritable + } + return nil } -// writableByAccount reports whether the account can write into the -// directory the file information describes. +// linuxAccessAccount returns the identity of the account an access check +// runs for. // -// The owner of a directory can always widen the permissions of it, so a -// directory the account owns counts as writable. A group the account belongs -// to needs the group write permission, and a directory of the machine needs -// the world write permission. -func writableByAccount(info fs.FileInfo, account *user.User) bool { - stat, ok := info.Sys().(*syscall.Stat_t) - if !ok { - return false - } - if strconv.Itoa(int(stat.Uid)) == account.Uid { - return true - } - mode := info.Mode().Perm() - if mode&0o002 != 0 { - return true - } - if mode&0o020 == 0 { - return false +// The groups of the account are its primary group and the supplementary +// groups the group database of the machine knows: a directory the account +// reaches through a supplementary group is as usable as one it reaches +// through its primary group. +func linuxAccessAccount(account *user.User) (accessAccount, error) { + uid, err := strconv.Atoi(account.Uid) + if err != nil { + return accessAccount{}, fmt.Errorf("the user id %q of the account %q cannot be read: %w", account.Uid, account.Username, err) } - gid := strconv.Itoa(int(stat.Gid)) - if gid == account.Gid { - return true + gid, err := strconv.Atoi(account.Gid) + if err != nil { + return accessAccount{}, fmt.Errorf("the group id %q of the account %q cannot be read: %w", account.Gid, account.Username, err) } - for _, group := range accountGroupIds(account) { - if group == gid { - return true + identity := accessAccount{UID: uid, GID: gid} + for _, id := range accountGroupIds(account) { + group, err := strconv.Atoi(id) + if err != nil { + continue } + identity.Groups = append(identity.Groups, group) } - return false + return identity, nil } // accountGroupIds returns the ids of the groups the account belongs to. @@ -1205,6 +1191,10 @@ Type=simple WorkingDirectory=%s Restart=on-failure RestartSec=5s +# The supervisor waits for the graceful shutdown of the instance before it +# kills it. The wait covers the whole orderly shutdown of the server, so a +# stop never interrupts an instance that is still saving its state. +TimeoutStopSec=%s NoNewPrivileges=true UMask=0077 Environment=%s @@ -1247,11 +1237,25 @@ func renderUnit(opts InstallOptions, account *user.User) (string, error) { identity, strings.Join(words, " "), systemdQuote(opts.Paths.StateDir), + systemdStopTimeout(), systemdQuote("HOME="+home), target, ), nil } +// systemdStopTimeout returns the value of the TimeoutStopSec directive of a +// unit. +// +// The directive is the deadline of the supervisor: systemd asks the instance +// to stop and kills it when the deadline is over. The deadline is the shared +// budget of the service layer, so it covers the whole orderly shutdown of +// the server and can never be shorter than it. +// +// The value is written as a duration, the form systemd reads in every unit. +func systemdStopTimeout() string { + return ShutdownBudget.String() +} + // unitIdentity renders the identity directives of a unit. // // A machine-wide instance runs as the dedicated service account of the diff --git a/internal/service/manager_linux_test.go b/internal/service/manager_linux_test.go index c9dd378d..f3f06aca 100644 --- a/internal/service/manager_linux_test.go +++ b/internal/service/manager_linux_test.go @@ -22,6 +22,7 @@ import ( "time" "github.com/bashrusakh/hfdesk/internal/runtimepaths" + "github.com/bashrusakh/hfdesk/internal/server" "github.com/bashrusakh/hfdesk/pkg/hfdownloader" ) @@ -1179,6 +1180,58 @@ func systemdOwner(t *testing.T, path string) (string, string) { return strconv.Itoa(int(stat.Uid)), strconv.Itoa(int(stat.Gid)) } +// systemdTraversableTempDir returns a temporary directory the account of the +// test can pass through. +// +// The temporary directory of a test is owner-only, so an account that is not +// the owner of the test never reaches a directory below it. The access check +// of the service layer asks for the same: a directory below a parent +// without the execute permission is out of reach of the account. +func systemdTraversableTempDir(t *testing.T) string { + t.Helper() + root := t.TempDir() + // The mode of a create call is filtered by the umask of the process, + // so the mode is set explicitly. + for _, path := range []string{root, filepath.Dir(root)} { + if err := os.Chmod(path, 0o711); err != nil { + t.Fatalf("make %q traversable: %v", path, err) + } + } + return root +} + +// systemdSupplementaryGroup returns the id of a supplementary group of the +// account of the test process. +// +// The groups of an account are read from the group database of the machine, +// so the test needs a group whose member list holds the account of the +// test. The second result is false when the machine knows no such group. +func systemdSupplementaryGroup(t *testing.T) (string, bool) { + t.Helper() + current := systemdCurrentAccount(t) + content, err := os.ReadFile("/etc/group") + if err != nil { + t.Skipf("skipping: the group database of the machine cannot be read: %v", err) + } + for _, line := range strings.Split(string(content), "\n") { + fields := strings.Split(strings.TrimSpace(line), ":") + if len(fields) < 4 { + continue + } + if fields[2] == current.Gid { + // The primary group of the account is no supplementary + // group of it. + continue + } + for _, member := range strings.Split(fields[3], ",") { + if member == current.Username { + return fields[2], true + } + } + } + return "", false +} + // TestSystemdServiceAccountName checks how the account of a machine-wide // instance is resolved: the account the user named wins, and an empty value // falls back to the default of the platform. @@ -1562,7 +1615,7 @@ func TestSystemdGrantInstanceDirsRefusesAPreExistingDirectory(t *testing.T) { func TestSystemdGrantInstanceDirsKeepsAWritablePreExistingDirectory(t *testing.T) { account := systemdSecondAccount(t) - root := t.TempDir() + root := systemdTraversableTempDir(t) opts := InstallOptions{ Scope: ScopeSystem, Executable: systemdTestExecutable(t), @@ -1840,6 +1893,226 @@ func TestSystemdCheckServiceAccountAccess(t *testing.T) { }) } +// TestSystemdAccessCheckUsesTheEffectivePermission checks that the access +// check of a directory the service account has to use is the effective one. +// +// The ownership of a directory is not enough: a directory the account owns +// with the mode 0500 is not writable, because the owner has no write +// permission in it, and a directory below a parent without the execute +// permission cannot be reached at all. +// +// The account of the checks owns nothing of the test and belongs to the +// group of its directories, so the group permissions are the ones that +// apply to it. +func TestSystemdAccessCheckUsesTheEffectivePermission(t *testing.T) { + current := systemdCurrentAccount(t) + root := systemdTraversableTempDir(t) + + // The group of the directories of the test is the group of the test + // process, so the other identity reaches them through the group. + probe := filepath.Join(root, "probe") + if err := os.Mkdir(probe, 0o700); err != nil { + t.Fatalf("create the probe directory: %v", err) + } + _, group := systemdOwner(t, probe) + + // The other identity owns nothing of the test and belongs to the + // group of its directories. + other := &user.User{ + Username: "hfdesk-access-test", + Uid: "65534", + Gid: group, + Name: "HFDesk access test account", + HomeDir: "/nonexistent", + } + + cases := []struct { + name string + dir string + account *user.User + mode os.FileMode + wantErr bool + }{ + { + name: "a directory the account owns", + dir: "owned", + account: current, + mode: 0o700, + }, + { + name: "a directory the account owns without the write permission", + dir: "readonly", + account: current, + mode: 0o500, + wantErr: true, + }, + { + name: "a directory the account owns without the write and the execute permission", + dir: "closed", + account: current, + mode: 0o400, + wantErr: true, + }, + { + name: "a directory of the group of the account", + dir: "group", + account: other, + mode: 0o770, + }, + { + name: "a directory of the group of the account without the execute permission", + dir: "group-without-execute", + account: other, + mode: 0o620, + wantErr: true, + }, + { + name: "a directory the account does not own and cannot write into", + dir: "foreign", + account: other, + mode: 0o700, + wantErr: true, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + dir := filepath.Join(root, tc.dir) + if err := os.Mkdir(dir, 0o700); err != nil { + t.Fatalf("create the directory: %v", err) + } + // The mode of a create call is filtered by the umask of + // the process, so the mode is set explicitly. + if err := os.Chmod(dir, tc.mode); err != nil { + t.Fatalf("set the mode of the directory: %v", err) + } + err := checkDirWritableByAccount(dir, tc.account) + if tc.wantErr && err == nil { + t.Errorf("checkDirWritableByAccount(%q) = nil, want a refusal", dir) + } + if !tc.wantErr && err != nil { + t.Errorf("checkDirWritableByAccount(%q) = %v, want nil", dir, err) + } + }) + } + + t.Run("a directory below a parent without the execute permission", func(t *testing.T) { + parent := filepath.Join(root, "closed-parent") + if err := os.Mkdir(parent, 0o700); err != nil { + t.Fatalf("create the parent directory: %v", err) + } + child := filepath.Join(parent, "child") + if err := os.Mkdir(child, 0o700); err != nil { + t.Fatalf("create the child directory: %v", err) + } + // The group of the child directory is the group of the + // account, so the account could use it if it could reach it. + if err := os.Chmod(child, 0o770); err != nil { + t.Fatalf("make the child directory usable: %v", err) + } + // The parent is closed for everybody but its owner. + if err := os.Chmod(parent, 0o700); err != nil { + t.Fatalf("close the parent directory: %v", err) + } + if err := checkDirWritableByAccount(child, other); err == nil { + t.Error("checkDirWritableByAccount() = nil, want the refusal of a directory the account cannot reach") + } + }) +} + +// TestSystemdAccessCheckUsesTheSupplementaryGroups checks that the access +// check of a directory the service account has to use knows the +// supplementary groups of the account. +// +// The groups of an account are read from the group database of the machine, +// so the test needs a group the account of the test belongs to. The test +// skips itself when the machine knows no such group. +func TestSystemdAccessCheckUsesTheSupplementaryGroups(t *testing.T) { + current := systemdCurrentAccount(t) + group, ok := systemdSupplementaryGroup(t) + if !ok { + t.Skip("skipping: the machine knows no supplementary group of the account of the test") + } + root := systemdTraversableTempDir(t) + + dir := filepath.Join(root, "supplementary") + if err := os.Mkdir(dir, 0o700); err != nil { + t.Fatalf("create the directory: %v", err) + } + gid, err := strconv.Atoi(group) + if err != nil { + t.Fatalf("the group id %q is not a number: %v", group, err) + } + // The directory belongs to the group of the account, not to the + // account itself. + if err := os.Chown(dir, os.Getuid(), gid); err != nil { + t.Fatalf("give the directory to the group %q: %v", group, err) + } + + // The account of the check carries the name of the account of the + // test, so the group database of the machine gives it the + // supplementary group of the account of the test. It owns nothing of + // the test. + account := &user.User{ + Username: current.Username, + Uid: "65534", + Gid: current.Gid, + Name: current.Name, + HomeDir: current.HomeDir, + } + + // The group permissions apply to the account: it can create entries + // in the directory and it can reach it. + if err := os.Chmod(dir, 0o770); err != nil { + t.Fatalf("make the directory usable: %v", err) + } + if err := checkDirWritableByAccount(dir, account); err != nil { + t.Errorf("checkDirWritableByAccount() = %v, want nil for a directory of the supplementary group of the account", err) + } + + // The group has no execute permission: the account cannot use the + // directory. + if err := os.Chmod(dir, 0o620); err != nil { + t.Fatalf("close the directory: %v", err) + } + if err := checkDirWritableByAccount(dir, account); err == nil { + t.Error("checkDirWritableByAccount() = nil, want the refusal of a directory without the execute permission of the group") + } +} + +// TestSystemdStopTimeoutCoversTheShutdownOfTheServer checks that the +// deadline of the supervisor covers the graceful shutdown of the server. +// +// The server publishes the budget of its own shutdown: the drain of the HTTP +// server and the close of the job manager, each with its own budget. The +// unit has to wait at least that long, or systemd kills the instance while +// it is still saving its state. +func TestSystemdStopTimeoutCoversTheShutdownOfTheServer(t *testing.T) { + if ShutdownBudget < server.GracefulShutdownTimeout { + t.Errorf("the shared budget of the service layer = %s, want at least %s", ShutdownBudget, server.GracefulShutdownTimeout) + } + + opts := systemdTestOptions(t, ScopeUser) + unit, err := renderUnit(opts, nil) + if err != nil { + t.Fatalf("renderUnit() = %v, want nil", err) + } + + value := unitDirective(unit, "TimeoutStopSec") + if value == "" { + t.Fatalf("the unit has no TimeoutStopSec directive:\n%s", unit) + } + timeout, err := time.ParseDuration(value) + if err != nil { + t.Fatalf("the TimeoutStopSec of the unit = %q, want a duration: %v", value, err) + } + if timeout < server.GracefulShutdownTimeout { + t.Errorf("the TimeoutStopSec of the unit = %s, want at least %s: the deadline of the supervisor has to cover the graceful shutdown of the server", timeout, server.GracefulShutdownTimeout) + } + if timeout != ShutdownBudget { + t.Errorf("the TimeoutStopSec of the unit = %s, want the shared budget %s", timeout, ShutdownBudget) + } +} + // TestSystemdInstallRefusesACacheDirTheAccountCannotWrite checks that a // machine-wide installation whose cache directory the service account cannot // write into is refused with the guidance that names the account and the diff --git a/internal/service/service.go b/internal/service/service.go index af2d0674..305b59a9 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -28,6 +28,7 @@ import ( "path/filepath" "strconv" "strings" + "time" "github.com/bashrusakh/hfdesk/internal/runtimepaths" "github.com/bashrusakh/hfdesk/internal/server" @@ -48,6 +49,29 @@ const ( // dirMode is the permission mode of a directory owned by a service // installation. dirMode os.FileMode = 0o700 + + // shutdownMargin is the room a supervisor keeps on top of the budget + // the server publishes for its own shutdown. + // + // The margin covers the work that happens around the shutdown of the + // server: the process still has to leave its event loop, close its log + // sink and return from the supervisor call. + shutdownMargin = 5 * time.Second + + // ShutdownBudget is the time a supervisor waits for the graceful + // shutdown of an instance. + // + // The server publishes the budget of its own orderly shutdown + // (server.GracefulShutdownTimeout): it drains its HTTP server and then + // closes its job manager, and every phase of it has its own budget. A + // supervisor waits for that total plus a margin, so it never kills an + // instance that is still saving its state. + // + // Every supervisor side wait and timeout of the service layer is sized + // after this budget: the wait of the launchd manager, the kill deadline + // of the launchd job, the stop timeout of the systemd unit and the wait + // of the Windows service handler. + ShutdownBudget = server.GracefulShutdownTimeout + shutdownMargin ) // ErrNotImplemented reports that service mode is not available on the From 458d5ec04122717b8a1c99dc821bc502c419383c Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 22 Oct 2026 05:55:48 +1100 Subject: [PATCH 40/58] fix(service): check the access of the Windows identity with its own token - The membership of the service identity is read from a token of the identity itself instead of a static list of presumed groups, so a descriptor that grants access only to Interactive Users is refused: the instance runs a service logon, not an interactive one. - When the platform refuses the logon, the check falls back to the subset that is certain and says so in the refusal. - The waits of the service control manager and of its handler use the shared shutdown budget. Co-Authored-By: opencode --- internal/service/manager_windows.go | 319 ++++++++++++++++++++--- internal/service/manager_windows_test.go | 201 +++++++++++++- internal/service/scm_windows.go | 18 +- internal/service/scm_windows_test.go | 28 ++ 4 files changed, 523 insertions(+), 43 deletions(-) diff --git a/internal/service/manager_windows.go b/internal/service/manager_windows.go index 0083b987..4deba849 100644 --- a/internal/service/manager_windows.go +++ b/internal/service/manager_windows.go @@ -15,6 +15,7 @@ import ( "path/filepath" "strconv" "strings" + "sync" "syscall" "time" "unsafe" @@ -83,7 +84,14 @@ const ( const ( // scmStopTimeout bounds the wait for the graceful stop of the // instance. - scmStopTimeout = 30 * time.Second + // + // The wait is the shared budget of the service layer: the server + // publishes the budget of its own orderly shutdown and the manager + // waits for that total plus a margin, so the manager never reports a + // failed stop while the instance is still saving its state. A number + // of its own here would be a second copy of the same decision, and + // the manager could drift below the budget of the server. + scmStopTimeout = ShutdownBudget // scmStopPollInterval is the pause between two checks of the state of // the instance. @@ -1036,6 +1044,15 @@ func (m *scmManager) preflight(opts InstallOptions) error { // directory the installation reuses has to grant that account the access // the instance needs, otherwise the instance fails later with an opaque // access error. +// +// The membership of the account is the effective one: it is read from a +// token of the account itself. A static list of presumed groups cannot +// answer the question, because the groups of an account are a property of +// the machine and not of the code: the Interactive Users group (S-1-5-4) is +// the group of an interactive logon, and the instance never runs an +// interactive logon. A descriptor that grants the access to the interactive +// users only is a descriptor the instance can never use, and the +// installation has to refuse it. const ( // scmServiceIdentitySID is the SID of the account the instance runs // as: LocalService (S-1-5-19). @@ -1045,19 +1062,256 @@ const ( // as. The name is part of the message of a failed installation, so // the user knows which account needs the access. scmServiceIdentityName = `NT AUTHORITY\LocalService` + + // scmEveryoneSID is the SID of the Everyone group. Every token of + // the platform carries it, so an access control list that grants the + // access to everybody grants it to the account of the instance as + // well. + scmEveryoneSID = "S-1-1-0" + + // scmInteractiveUsersSID is the SID of the Interactive Users group. + // It is the group of an interactive logon: the instance runs a + // service logon and is never an interactive user, so the group never + // covers the account of the instance. + scmInteractiveUsersSID = "S-1-5-4" +) + +// scmIdentitySIDs is the set of security identifiers that cover the account +// the instance runs as. +// +// The set is the effective membership of the account: the account itself and +// the groups of a token of the account. An access control list that grants +// the access to one of them grants it to the account of the instance as +// well. +type scmIdentitySIDs struct { + // sids holds the SIDs of the set in their canonical form. + sids map[string]bool + // exact reports whether the set is the effective membership of the + // account, read from a token of the account itself. + exact bool + // reason names why the set is not the effective membership of the + // account. It is empty when the set is exact. + reason string +} + +// covers reports whether a SID covers the account of the instance. +func (i scmIdentitySIDs) covers(sid string) bool { + return i.sids[scmNormalizeSID(sid)] +} + +// note returns the note that tells the caller that the check was made +// against a subset of the groups of the account. +// +// The note is empty for an exact set: the caller has nothing to say about a +// check that was made against the whole membership of the account. +func (i scmIdentitySIDs) note() string { + if i.exact { + return "" + } + return fmt.Sprintf(": the effective groups of the account %s are unknown (%s), so the check was made against the account itself and everybody only", scmServiceIdentityName, i.reason) +} + +// scmNormalizeSID returns the canonical form of a SID. +// +// The trustees of an access control entry and the SIDs of a token are both +// written in the canonical form, but a descriptor that was written by hand +// can carry another case and spaces around the SID. +func scmNormalizeSID(sid string) string { + return strings.ToUpper(strings.TrimSpace(sid)) +} + +// scmLogonUserW logs an account on and returns the token of the logon. +// +// The procedure is not exported by the platform library of Go: it is called +// through the dynamic library of the platform, the way the platform itself +// calls it. No tool of the platform is called, and no shell is started. +var scmLogonUserW = windows.NewLazySystemDLL("advapi32.dll").NewProc("LogonUserW") + +const ( + // scmLogon32LogonService is the logon type of a service: the token + // of the logon carries the groups the account has when it runs as a + // service. + scmLogon32LogonService uintptr = 5 + + // scmLogon32ProviderDefault is the logon provider of the platform. + scmLogon32ProviderDefault uintptr = 0 ) -// scmIdentityGroups are the groups the account of the instance belongs to. +// scmLogonServiceToken logs the account of the instance on as a service and +// returns the token of the logon. +// +// The built-in service accounts have no password, so the logon needs no +// credential of the user. The caller needs the right to log on as a service, +// which the administrators of the machine hold. +// +// The token is a real token and has to be closed. +func scmLogonServiceToken(account string) (windows.Token, error) { + username, err := windows.UTF16PtrFromString(account) + if err != nil { + return 0, fmt.Errorf("the name of the account cannot be read: %w", err) + } + password, err := windows.UTF16PtrFromString("") + if err != nil { + return 0, fmt.Errorf("the empty password of the account cannot be read: %w", err) + } + var handle windows.Handle + result, _, callErr := scmLogonUserW.Call( + uintptr(unsafe.Pointer(username)), + // The domain of a service logon is empty. + 0, + uintptr(unsafe.Pointer(password)), + scmLogon32LogonService, + scmLogon32ProviderDefault, + uintptr(unsafe.Pointer(&handle)), + ) + if result == 0 { + if callErr == nil { + callErr = windows.ERROR_INVALID_FUNCTION + } + return 0, fmt.Errorf("the account %s cannot be logged on as a service: %w", account, callErr) + } + return windows.Token(handle), nil +} + +// scmIdentitySIDsFromToken reads the effective membership of an account out +// of the access token of the account. +// +// The set is the account of the token and the groups of the token: the +// groups of a token carry the membership of the account, the nested members +// included. +// +// The Interactive Users group is never part of the set: it is the group of +// an interactive logon, and the account of the instance runs a service +// logon. +func scmIdentitySIDsFromToken(token windows.Token) (scmIdentitySIDs, error) { + user, err := token.GetTokenUser() + if err != nil { + return scmIdentitySIDs{}, fmt.Errorf("the account of the token cannot be read: %w", err) + } + if user.User.Sid == nil { + return scmIdentitySIDs{}, errors.New("the account of the token carries no SID") + } + groups, err := token.GetTokenGroups() + if err != nil { + return scmIdentitySIDs{}, fmt.Errorf("the groups of the token cannot be read: %w", err) + } + sids := make(map[string]bool, groups.GroupCount+1) + if sid := scmNormalizeSID(user.User.Sid.String()); sid != "" { + sids[sid] = true + } + for _, group := range groups.AllGroups() { + if group.Sid == nil { + continue + } + sid := scmNormalizeSID(group.Sid.String()) + if sid == "" { + continue + } + sids[sid] = true + } + delete(sids, scmNormalizeSID(scmInteractiveUsersSID)) + return scmIdentitySIDs{sids: sids, exact: true}, nil +} + +// scmServiceIdentityAccounts are the names the account of the instance can be +// logged on with. +// +// The account is a well-known account of the machine: the platform knows it +// under its full name and under its short name, and the logon of a service +// accepts both of them. +var scmServiceIdentityAccounts = []string{ + `NT AUTHORITY\LocalService`, + `LocalService`, +} + +// scmReadServiceIdentitySIDs reads the effective membership of the account +// the instance runs as out of a token of the account. +// +// The account is logged on the way the service control manager logs it on, +// as a service, so the token carries the groups the account really has +// while it runs as the instance. +// +// A platform that refuses the logon of the account leaves the membership +// unknown, and the caller falls back to the certain subset. +func scmReadServiceIdentitySIDs() (scmIdentitySIDs, error) { + var reasons []string + for _, account := range scmServiceIdentityAccounts { + identity, err := scmServiceIdentityOfAccount(account) + if err == nil { + return identity, nil + } + reasons = append(reasons, err.Error()) + } + return scmIdentitySIDs{}, fmt.Errorf("the account %s cannot be logged on as a service: %s", scmServiceIdentityName, strings.Join(reasons, "; ")) +} + +// scmServiceIdentityOfAccount logs an account on as a service and reads its +// effective membership out of the token of the logon. +// +// A token that belongs to another account is not the token of the identity: +// the membership is unknown then. +func scmServiceIdentityOfAccount(account string) (scmIdentitySIDs, error) { + token, err := scmLogonServiceToken(account) + if err != nil { + return scmIdentitySIDs{}, err + } + defer token.Close() + identity, err := scmIdentitySIDsFromToken(token) + if err != nil { + return scmIdentitySIDs{}, err + } + if !identity.covers(scmServiceIdentitySID) { + return scmIdentitySIDs{}, fmt.Errorf("the token of the logon of %s belongs to another account than %s", account, scmServiceIdentityName) + } + return identity, nil +} + +// scmCertainIdentitySIDs returns the subset of the membership of the account +// that is certain. +// +// The instance runs as the account itself, so the account covers it, and +// everybody covers every account. The rest of the membership is unknown when +// the platform refuses the logon, so it is not used: a check against the +// certain subset can refuse a path the account could use, and it never +// grants a path the account cannot use. +// +// The subset is used only when the token of the account is not available, +// and the fact is reported: the message of a refusal names it, and the +// installation logs it. +func scmCertainIdentitySIDs(reason error) scmIdentitySIDs { + return scmIdentitySIDs{ + sids: map[string]bool{ + scmNormalizeSID(scmServiceIdentitySID): true, + scmNormalizeSID(scmEveryoneSID): true, + }, + exact: false, + reason: reason.Error(), + } +} + +// scmServiceIdentitySIDs returns the effective membership of the account the +// instance runs as. // -// An access control list that grants access to one of them grants the -// access to the account of the instance as well. -var scmIdentityGroups = []string{ - "S-1-1-0", // Everyone - "S-1-5-32-545", // Users - "S-1-5-11", // Authenticated Users - "S-1-5-4", // Interactive Users +// A platform that refuses the logon of the account leaves the certain +// subset: the account itself and everybody. The refusal is reported, so the +// user knows that the check of a path was made against a subset of the +// groups of the account. +func scmServiceIdentitySIDs() scmIdentitySIDs { + identity, err := scmReadServiceIdentitySIDs() + if err != nil { + log.Printf("service: the effective groups of the account %s are unknown: %v: the access of a path is checked against the account itself and everybody only", scmServiceIdentityName, err) + return scmCertainIdentitySIDs(err) + } + return identity } +// scmServiceIdentity is the effective membership of the account the instance +// runs as. +// +// The membership is read once per process: it is a property of the account +// and not of the path that is checked. +var scmServiceIdentity = sync.OnceValue(scmServiceIdentitySIDs) + // preflightDataPaths checks the directories an installation reuses: the // cache root, the hub directory and the local output directory. // @@ -1167,7 +1421,7 @@ func checkServiceAccess(label, path string) error { if sddl == "" { return fmt.Errorf("service: the access rights of the %s %q cannot be read: the installation cannot tell whether the account %s can use the path", label, path, scmServiceIdentityName) } - if err := sddlGrantsIdentity(sddl); err != nil { + if err := sddlGrantsIdentity(sddl, scmServiceIdentity()); err != nil { return fmt.Errorf("service: the %s %q is not usable by the account the instance runs as (%s): %w: grant the account read and write access to the path, or install the instance without this path", label, path, scmServiceIdentityName, err) } return nil @@ -1178,25 +1432,30 @@ func checkServiceAccess(label, path string) error { // // The check is a permission preflight, not a simulation of the access check // of the operating system: it reads the access control list of the path -// itself, it does not resolve the inheritance of a parent directory, the -// privileges of a token or the membership of a group that is not listed -// here. +// itself, it does not resolve the inheritance of a parent directory or the +// privileges of a token. +// +// The trustees that cover the account are the ones of the effective +// membership of the account, the one a token of the account carries. The +// Interactive Users group is never one of them: the instance runs a service +// logon and is never an interactive user, so a descriptor that grants the +// access to the interactive users only is refused. // // A denied entry wins over an allowed one, so a directory that grants the // account the access and denies it as well is refused. -func sddlGrantsIdentity(sddl string) error { +func sddlGrantsIdentity(sddl string, identity scmIdentitySIDs) error { var ( allowed bool read bool write bool ) for _, ace := range parseSDDLAces(sddl) { - if !ace.coversIdentity() { + if !ace.coversIdentity(identity) { continue } switch ace.kind { case "D", "OD": - return fmt.Errorf("the access control list denies the account %s", scmServiceIdentityName) + return fmt.Errorf("the access control list denies the account %s%s", scmServiceIdentityName, identity.note()) case "A", "OA": allowed = true read = read || maskGrantsRead(ace.rights) @@ -1205,11 +1464,11 @@ func sddlGrantsIdentity(sddl string) error { } switch { case !allowed: - return fmt.Errorf("the access control list grants the account %s no access", scmServiceIdentityName) + return fmt.Errorf("the access control list grants the account %s no access%s", scmServiceIdentityName, identity.note()) case !read: - return fmt.Errorf("the access control list grants the account %s no read access", scmServiceIdentityName) + return fmt.Errorf("the access control list grants the account %s no read access%s", scmServiceIdentityName, identity.note()) case !write: - return fmt.Errorf("the access control list grants the account %s no write access", scmServiceIdentityName) + return fmt.Errorf("the access control list grants the account %s no write access%s", scmServiceIdentityName, identity.note()) } return nil } @@ -1231,24 +1490,26 @@ type sddlACE struct { // The trustees of an SDDL string are raw SIDs. The aliases of the platform // are accepted as well, so a descriptor that was written by hand is read // the same way. -func (ace sddlACE) coversIdentity() bool { +// +// The membership of the account decides: an alias is only another way to +// write a SID, and the SID is one of the ones of the effective membership of +// the account or it is not one of them. +func (ace sddlACE) coversIdentity(identity scmIdentitySIDs) bool { trustee := strings.ToUpper(strings.TrimSpace(ace.trustee)) if sid, ok := sddlTrusteeAliases[trustee]; ok { trustee = sid } - if trustee == scmServiceIdentitySID { - return true - } - for _, group := range scmIdentityGroups { - if trustee == group { - return true - } - } - return false + return identity.covers(trustee) } // sddlTrusteeAliases maps the aliases of a trustee of an SDDL access // control entry onto the SID they stand for. +// +// The map resolves the aliases of the platform, it does not grant any +// access: a resolved trustee covers the account of the instance only when +// the effective membership of the account carries it. The alias of the +// Interactive Users (IU) therefore never covers the account of the +// instance, which runs a service logon. var sddlTrusteeAliases = map[string]string{ "WD": "S-1-1-0", // Everyone "BU": "S-1-5-32-545", // Users diff --git a/internal/service/manager_windows_test.go b/internal/service/manager_windows_test.go index ce9ec54c..2e081c22 100644 --- a/internal/service/manager_windows_test.go +++ b/internal/service/manager_windows_test.go @@ -24,6 +24,7 @@ import ( "golang.org/x/sys/windows/svc/mgr" "github.com/bashrusakh/hfdesk/internal/runtimepaths" + "github.com/bashrusakh/hfdesk/internal/server" ) // secretSentinel is an obviously secret value. It is put into every field @@ -464,6 +465,30 @@ func TestSCMStartAndStopOfAnAbsentRegistration(t *testing.T) { } } +// TestSCMStopWaitCoversTheShutdownOfTheServer checks that the wait of the +// manager for the stopped instance covers the graceful shutdown of the +// server. +// +// The server publishes the budget of its own orderly shutdown: the drain of +// the HTTP server and the close of the job manager, each with its own budget. +// The manager has to wait at least the total of those budgets, or it reports +// a failed stop while the instance is still saving its state. +// +// The wait is the shared budget of the service layer and not a number of its +// own: a second copy of the same decision would drift below the budget of +// the server. +func TestSCMStopWaitCoversTheShutdownOfTheServer(t *testing.T) { + if ShutdownBudget < server.GracefulShutdownTimeout { + t.Errorf("the shared budget of the service layer = %s, want at least %s", ShutdownBudget, server.GracefulShutdownTimeout) + } + if scmStopTimeout < server.GracefulShutdownTimeout { + t.Errorf("the stop wait of the manager = %s, want at least %s: the wait of the manager has to cover the graceful shutdown of the server", scmStopTimeout, server.GracefulShutdownTimeout) + } + if scmStopTimeout != ShutdownBudget { + t.Errorf("the stop wait of the manager = %s, want the shared budget %s", scmStopTimeout, ShutdownBudget) + } +} + // TestSCMLifecycle drives the registration of a service against the real // service control manager. // @@ -1048,24 +1073,30 @@ func TestSCMBrokenRegistration(t *testing.T) { // // The access control lists are the SDDL strings the platform writes: the // trustees appear as raw SIDs and not as aliases. +// +// The set of the trustees that cover the account is the certain subset: the +// account itself and everybody. It is the set the check falls back to when +// the platform refuses the logon of the account, and the effective +// membership of the account is checked by TestSDDLGrantsIdentityWithTheEffectiveGroups. func TestSDDLGrantsIdentity(t *testing.T) { + identity := scmCertainIdentitySIDs(errors.New("the test checks the certain subset")) cases := []struct { name string sddl string wantErr bool }{ {"the account itself", "D:P(A;;FA;;;S-1-5-19)", false}, + {"the alias of the account", "D:P(A;;FA;;;LS)", false}, {"everyone", "D:P(A;;FA;;;S-1-1-0)", false}, - {"the users group", "D:P(A;;FA;;;S-1-5-32-545)", false}, - {"the authenticated users group", "D:P(A;;FA;;;S-1-5-11)", false}, - {"the interactive users group", "D:P(A;;FA;;;S-1-5-4)", false}, + {"the alias of everyone", "D:P(A;;FA;;;WD)", false}, + {"the interactive users group", "D:P(A;;FA;;;S-1-5-4)", true}, + {"the alias of the interactive users group", "D:P(A;;FA;;;IU)", true}, + {"the users group", "D:P(A;;FA;;;S-1-5-32-545)", true}, + {"the authenticated users group", "D:P(A;;FA;;;S-1-5-11)", true}, {"an unrelated account", "D:P(A;;FA;;;S-1-5-18)", true}, {"an unrelated group", "D:P(A;;FA;;;S-1-5-32-544)", true}, - {"the alias of the account", "D:P(A;;FA;;;LS)", false}, - {"the alias of everyone", "D:P(A;;FA;;;WD)", false}, - {"the alias of the users group", "D:P(A;;FA;;;BU)", false}, {"a denied entry next to an allowed one", "D:P(D;;GA;;;S-1-5-19)(A;;FA;;;S-1-1-0)", true}, - {"a denied entry for a group of the account", "D:P(D;;GA;;;S-1-1-0)(A;;FA;;;S-1-5-19)", true}, + {"a denied entry for everybody", "D:P(D;;GA;;;S-1-1-0)(A;;FA;;;S-1-5-19)", true}, {"read only rights", "D:P(A;;FR;;;S-1-5-19)", true}, {"write only rights", "D:P(A;;FW;;;S-1-5-19)", true}, {"the generic rights", "D:P(A;;0xC0000000;;;S-1-5-19)", false}, @@ -1074,7 +1105,7 @@ func TestSDDLGrantsIdentity(t *testing.T) { } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - err := sddlGrantsIdentity(tc.sddl) + err := sddlGrantsIdentity(tc.sddl, identity) if tc.wantErr && err == nil { t.Errorf("sddlGrantsIdentity(%q) = nil, want an error", tc.sddl) } @@ -1085,6 +1116,125 @@ func TestSDDLGrantsIdentity(t *testing.T) { } } +// TestSDDLGrantsIdentityWithTheEffectiveGroups checks the access control +// list of a path against the effective membership of the account the +// instance runs as. +// +// The membership comes from a token of the account, so a group the account +// really carries covers it and a group it does not carry does not. +func TestSDDLGrantsIdentityWithTheEffectiveGroups(t *testing.T) { + identity := scmTestEffectiveIdentitySIDs(t) + + // The account itself. + if err := sddlGrantsIdentity("D:P(A;;FA;;;S-1-5-19)", identity); err != nil { + t.Errorf("sddlGrantsIdentity() = %v, want nil for the account itself", err) + } + // Everybody. + if err := sddlGrantsIdentity("D:P(A;;FA;;;S-1-1-0)", identity); err != nil { + t.Errorf("sddlGrantsIdentity() = %v, want nil for everybody", err) + } + // The interactive users: the instance runs a service logon and is + // never an interactive user. + if err := sddlGrantsIdentity("D:P(A;;FA;;;S-1-5-4)", identity); err == nil { + t.Error("sddlGrantsIdentity() = nil, want the refusal of the interactive users") + } + // A group the account does not carry. + uncarried := scmTestUncarriedSID(t, identity) + if err := sddlGrantsIdentity("D:P(A;;FA;;;"+uncarried+")", identity); err == nil { + t.Errorf("sddlGrantsIdentity() = nil, want the refusal of the group %s", uncarried) + } + // Every group of the membership covers the account. + for group := range identity.sids { + if err := sddlGrantsIdentity("D:P(A;;FA;;;"+group+")", identity); err != nil { + t.Errorf("sddlGrantsIdentity() = %v, want nil for the group %s the account carries", err, group) + } + } +} + +// TestSCMEffectiveIdentitySIDs checks the membership the implementation +// derives out of a token of the account the instance runs as. +// +// The membership is the effective one: the account itself is in it, everybody +// is in it, and the interactive users are never in it, because the instance +// runs a service logon. +func TestSCMEffectiveIdentitySIDs(t *testing.T) { + identity := scmTestEffectiveIdentitySIDs(t) + + if !identity.exact { + t.Error("the membership of the account is not the effective one, want the membership of a token") + } + if !identity.covers(scmServiceIdentitySID) { + t.Errorf("the membership %v does not carry the account %s", identity.sids, scmServiceIdentitySID) + } + if !identity.covers(scmEveryoneSID) { + t.Errorf("the membership %v does not carry everybody", identity.sids) + } + if identity.covers(scmInteractiveUsersSID) { + t.Errorf("the membership %v carries the interactive users, want a membership without them", identity.sids) + } +} + +// TestSddlGrantsIdentityNamesTheCertainSubset checks that a check that was +// made against the certain subset says so. +// +// The platform can refuse the logon of the account. The check then falls +// back to the subset that is certain, and the user has to know that the +// groups of the account were not known. +func TestSddlGrantsIdentityNamesTheCertainSubset(t *testing.T) { + identity := scmCertainIdentitySIDs(errors.New("the logon of the account was refused")) + + err := sddlGrantsIdentity("D:P(A;;FA;;;S-1-5-32-544)", identity) + if err == nil { + t.Fatal("sddlGrantsIdentity() = nil, want the refusal of a group outside the certain subset") + } + if !strings.Contains(err.Error(), "everybody only") { + t.Errorf("sddlGrantsIdentity() = %v, want an error that names the certain subset", err) + } + if !strings.Contains(err.Error(), "the logon of the account was refused") { + t.Errorf("sddlGrantsIdentity() = %v, want an error that names the reason", err) + } +} + +// scmTestEffectiveIdentitySIDs returns the effective membership of the +// account the instance runs as. +// +// The membership is a property of the machine: the test skips when the +// platform refuses the logon of the account, because the machine cannot +// answer the question then. +func scmTestEffectiveIdentitySIDs(t *testing.T) scmIdentitySIDs { + t.Helper() + if !isElevatedProcess() { + t.Skip("the logon of a service account needs an elevated shell, run the test as an administrator") + } + identity, err := scmReadServiceIdentitySIDs() + if err != nil { + t.Skipf("the account %s cannot be logged on as a service: %v", scmServiceIdentityName, err) + } + return identity +} + +// scmTestUncarriedSID returns a SID the account of the instance does not +// carry. +// +// The candidates are the groups of the machine that the account of the +// instance is not a member of. +func scmTestUncarriedSID(t *testing.T, identity scmIdentitySIDs) string { + t.Helper() + for _, candidate := range []string{ + "S-1-5-32-544", // Administrators + "S-1-5-18", // LocalSystem + "S-1-5-20", // NetworkService + "S-1-5-32-546", // Guests + "S-1-5-4", // Interactive Users + } { + if !identity.covers(candidate) { + return candidate + } + } + t.Skip("the account of the instance carries every candidate group") + return "" +} + // scmTestNamesPath reports whether a message of the manager names a path. // // The messages of the manager quote the path they talk about, so the @@ -1150,6 +1300,41 @@ func TestPreflightDataPathRefusesAPathTheIdentityCannotReach(t *testing.T) { } } +// TestPreflightDataPathRefusesADirectoryThatGrantsOnlyTheInteractiveUsers +// checks that a directory the instance can never use is refused. +// +// The instance runs a service logon and is never an interactive user, so a +// descriptor that grants the access to the interactive users of the machine +// only grants the access to the users of an interactive session. The +// preflight has to refuse the directory instead of accepting it. +func TestPreflightDataPathRefusesADirectoryThatGrantsOnlyTheInteractiveUsers(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "cache") + if err := os.Mkdir(path, 0o700); err != nil { + t.Fatalf("Mkdir() = %v, want nil", err) + } + // The directory grants the access to the interactive users of the + // machine, to the machine itself and to the account of the test: the + // test has to keep the access to the directory it restricted. + scmTestSetDACL(t, path, fmt.Sprintf( + "D:P(A;OICI;FA;;;%s)(A;OICI;FA;;;SY)(A;OICI;FA;;;%s)", + scmInteractiveUsersSID, scmTestCurrentUserSID(t))) + + err := preflightDataPath("cache dir", path) + if err == nil { + t.Fatalf("preflightDataPath(%q) = nil, want the refusal of a directory that grants the access to the interactive users only", path) + } + if !scmTestNamesPath(err.Error(), path) { + t.Errorf("preflightDataPath(%q) = %v, want an error that names the path", path, err) + } + if !strings.Contains(err.Error(), scmServiceIdentityName) { + t.Errorf("preflightDataPath(%q) = %v, want an error that names the account of the instance", path, err) + } + if !strings.Contains(err.Error(), "grant the account") { + t.Errorf("preflightDataPath(%q) = %v, want an error that tells the user what to do", path, err) + } +} + // TestApplyRegistrationReportsTheEffectiveCacheDir checks that the status // reports the cache root the instance uses: the cache directory of the // config file of the instance wins over the registered one. diff --git a/internal/service/scm_windows.go b/internal/service/scm_windows.go index 08f4ca4b..c03ecf18 100644 --- a/internal/service/scm_windows.go +++ b/internal/service/scm_windows.go @@ -68,10 +68,6 @@ const ( // scmReadinessInterval is the pause between two readiness probes. scmReadinessInterval = 100 * time.Millisecond - // scmShutdownTimeout bounds the wait for the graceful shutdown of the - // instance. - scmShutdownTimeout = 30 * time.Second - // scmProbeTimeout bounds a single readiness probe. scmProbeTimeout = 2 * time.Second ) @@ -113,6 +109,12 @@ type scmHandler struct { readinessInterval time.Duration // shutdownTimeout bounds the wait for the graceful shutdown. + // + // The wait is the shared budget of the service layer: the server + // publishes the budget of its own orderly shutdown and the supervisor + // waits for that total plus a margin. A number of its own here would + // be a second copy of the same decision, and the platforms would drift + // apart. shutdownTimeout time.Duration } @@ -123,7 +125,7 @@ func newSCMHandler(opts RunOptions) *scmHandler { opts: opts, readinessTimeout: scmReadinessTimeout, readinessInterval: scmReadinessInterval, - shutdownTimeout: scmShutdownTimeout, + shutdownTimeout: ShutdownBudget, } } @@ -234,10 +236,14 @@ func (h *scmHandler) stopInstance(cancel context.CancelFunc, hostErr <-chan erro // // The wait is bounded: a server that does not finish its drain must not // keep the instance in the stopping state forever. +// +// The bound is the shared budget of the service layer, so the handler +// never gives up before the server had the time to finish its own +// shutdown. func (h *scmHandler) waitForShutdown(cancel context.CancelFunc, hostErr <-chan error) error { timeout := h.shutdownTimeout if timeout <= 0 { - timeout = scmShutdownTimeout + timeout = ShutdownBudget } cancel() select { diff --git a/internal/service/scm_windows_test.go b/internal/service/scm_windows_test.go index b6b5fc5e..2bc1a6ef 100644 --- a/internal/service/scm_windows_test.go +++ b/internal/service/scm_windows_test.go @@ -17,6 +17,7 @@ import ( "golang.org/x/sys/windows/svc" "github.com/bashrusakh/hfdesk/internal/runtimepaths" + "github.com/bashrusakh/hfdesk/internal/server" ) // TestSCMHandlerReportsTheLifecycle drives the handler of the service @@ -135,6 +136,33 @@ func TestSCMHandlerFailsWithoutAUsableArgumentVector(t *testing.T) { } } +// TestSCMShutdownWaitCoversTheShutdownOfTheServer checks that the bounded +// wait of the service handler covers the graceful shutdown of the server. +// +// The server publishes the budget of its own orderly shutdown: the drain of +// the HTTP server and the close of the job manager, each with its own +// budget. The handler has to wait at least the total of those budgets, or +// the service control manager gives up while the instance is still saving +// its state. +// +// The wait is the shared budget of the service layer, so the platforms +// cannot drift apart: a number of its own in the handler would be a second +// copy of the same decision. +func TestSCMShutdownWaitCoversTheShutdownOfTheServer(t *testing.T) { + if ShutdownBudget < server.GracefulShutdownTimeout { + t.Errorf("the shared budget of the service layer = %s, want at least %s", ShutdownBudget, server.GracefulShutdownTimeout) + } + + handler := newSCMHandler(RunOptions{}) + wait := handler.shutdownTimeout + if wait < server.GracefulShutdownTimeout { + t.Errorf("the bounded wait of the handler = %s, want at least %s: the wait of the supervisor has to cover the graceful shutdown of the server", wait, server.GracefulShutdownTimeout) + } + if wait != ShutdownBudget { + t.Errorf("the bounded wait of the handler = %s, want the shared budget %s", wait, ShutdownBudget) + } +} + // TestSCMArgumentCandidates checks the argument vectors the handler of the // service tries to read the options of the instance from. // From 51eaf8bb2c957e23e834ff7b26b1e271a51a0883 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 22 Oct 2026 06:11:29 +1100 Subject: [PATCH 41/58] tests: build the launchd access fixtures where the account can reach them - The fixtures of the access check lived in the per-user temporary directory of macOS, which the account under test cannot traverse, so the check correctly refused them and the tests measured the temporary directory instead of the fixture. They now live in the world-traversable scratch directory, and the tests assert that precondition explicitly. Co-Authored-By: opencode --- internal/service/manager_darwin_test.go | 78 +++++++++++++++++++++---- 1 file changed, 67 insertions(+), 11 deletions(-) diff --git a/internal/service/manager_darwin_test.go b/internal/service/manager_darwin_test.go index 7ca0c211..8b87298c 100644 --- a/internal/service/manager_darwin_test.go +++ b/internal/service/manager_darwin_test.go @@ -1790,7 +1790,11 @@ func TestLaunchdGrantInstanceDirsRefusesBeforeItGrants(t *testing.T) { func TestLaunchdGrantInstanceDirsKeepsAWritablePreExistingDirectory(t *testing.T) { account := darwinOtherAccount(t) - root := darwinTraversableTempDir(t) + root := darwinScratchDir(t) + // The precondition of the fixtures below: the account of the instance + // reaches the scratch directory, so a refusal of the installation is + // about the directories themselves. + darwinAssertReachable(t, account, root) opts := InstallOptions{ Scope: ScopeSystem, Listen: DefaultListen, @@ -2159,7 +2163,7 @@ func TestLaunchdRefusesANamedDirTheAccountCannotWrite(t *testing.T) { // group of its directories, so the group permissions are the ones that // apply to it. func TestLaunchdAccessCheckUsesTheEffectivePermission(t *testing.T) { - root := darwinTraversableTempDir(t) + root := darwinScratchDir(t) // The group of the directories of the test is the group of the test // process, so the other identity reaches them through the group. @@ -2187,6 +2191,13 @@ func TestLaunchdAccessCheckUsesTheEffectivePermission(t *testing.T) { Groups: []int{group}, } + // The precondition of the cases below: every account reaches the + // scratch directory, so a refusal of the check is about the directory + // of the case and not about the scratch directory. + for _, account := range []*serviceAccount{owner, other} { + darwinAssertReachable(t, account, root) + } + cases := []struct { name string dir string @@ -2298,7 +2309,7 @@ func TestLaunchdAccessCheckUsesTheSupplementaryGroups(t *testing.T) { if !ok { t.Skip("skipping: the account of the test belongs to no group besides its primary one") } - root := darwinTraversableTempDir(t) + root := darwinScratchDir(t) dir := filepath.Join(root, "supplementary") if err := os.Mkdir(dir, 0o700); err != nil { @@ -2318,6 +2329,10 @@ func TestLaunchdAccessCheckUsesTheSupplementaryGroups(t *testing.T) { Groups: []int{os.Getgid(), supplementary}, } + // The precondition of the checks below: the account reaches the scratch + // directory, so a refusal of the check is about the directory itself. + darwinAssertReachable(t, account, root) + // The group permissions apply to the account: it can create entries // in the directory and it can reach it. if err := os.Chmod(dir, 0o770); err != nil { @@ -2522,26 +2537,67 @@ func darwinOwner(t *testing.T, path string) (int, int) { return int(stat.Uid), int(stat.Gid) } -// darwinTraversableTempDir returns a temporary directory the account of the -// test can pass through. +// darwinMachineTempRoot is the temporary directory of the machine that every +// account can pass through. // // The temporary directory of a test is owner-only, so an account that is not // the owner of the test never reaches a directory below it. The access check // of the service layer asks for the same: a directory below a parent // without the execute permission is out of reach of the account. -func darwinTraversableTempDir(t *testing.T) string { +// +// The fixtures of the access checks therefore live in the temporary directory +// of the machine: /tmp is of the mode 1777 on Linux and on macOS, where it +// resolves to /private/tmp. A fixture is then out of the reach of the account +// only because of its own permissions, which is what the checks are about. +const darwinMachineTempRoot = "/tmp" + +// darwinScratchDir returns a scratch directory in the temporary directory of +// the machine. +// +// The directory is traversable by every account, and it is removed when the +// test ends. +func darwinScratchDir(t *testing.T) string { t.Helper() - root := t.TempDir() + root, err := os.MkdirTemp(darwinMachineTempRoot, "hfdesk-darwin-test-") + if err != nil { + t.Fatalf("create the scratch directory in %q: %v", darwinMachineTempRoot, err) + } // The mode of a create call is filtered by the umask of the process, // so the mode is set explicitly. - for _, path := range []string{root, filepath.Dir(root)} { - if err := os.Chmod(path, 0o711); err != nil { - t.Fatalf("make %q traversable: %v", path, err) - } + if err := os.Chmod(root, 0o755); err != nil { + t.Fatalf("make the scratch directory %q traversable: %v", root, err) } + t.Cleanup(func() { + if err := os.RemoveAll(root); err != nil { + t.Errorf("remove the scratch directory %q: %v", root, err) + } + }) return root } +// darwinAssertReachable asserts that the account reaches the scratch +// directory. +// +// The precondition of every fixture of an access check: the account has to +// pass through the scratch directory, otherwise a refusal of the check tells +// more about the scratch directory than about the fixture itself. The +// assertion names the account and the directory, so a broken precondition is +// diagnosable instead of looking like a wrong verdict of the check. +func darwinAssertReachable(t *testing.T, account *serviceAccount, root string) { + t.Helper() + ok, err := accountCanTraverse(root, accessAccount{ + UID: account.UID, + GID: account.GID, + Groups: account.Groups, + }) + if err != nil { + t.Fatalf("the account %q cannot pass through the scratch directory %q: %v", account.Name, root, err) + } + if !ok { + t.Fatalf("the account %q cannot pass through the scratch directory %q: the fixture below it would be out of the reach of the account for a reason that has nothing to do with the fixture itself", account.Name, root) + } +} + // darwinSupplementaryGroup returns the id of a supplementary group of the // account of the test process. // From c490b02f4588f5f84e9894d9b81960f6d01319ae Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 22 Oct 2026 06:18:15 +1100 Subject: [PATCH 42/58] docs: describe the graceful stop of an instance - State the two phases of an orderly shutdown, the time they may take and the budget every supervisor waits for, so an operator can tell what a stop does and why it takes as long as it does. Co-Authored-By: opencode --- docs/SERVICE.md | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/docs/SERVICE.md b/docs/SERVICE.md index 0b605deb..acce9269 100644 --- a/docs/SERVICE.md +++ b/docs/SERVICE.md @@ -313,6 +313,42 @@ The commands are idempotent where the wanted state is already reached: on a service that is not installed they fail with a readable "not installed" error. +### Stopping an instance + +A stop is a **graceful** shutdown, in two phases: + +- The HTTP server is drained: it accepts no new request any more, and the + requests that are already running finish — up to **10 s**. +- The job manager is closed: the running jobs are cancelled and drained, and + the job list is written back to the state file — up to **10 s** again. + +The orderly path therefore needs up to **20 s**. + +The service layer publishes that budget and sizes every supervisor-side wait +after it, with a margin for the work the process still has to do around the +shutdown. The shared budget is **25 s**. + +| Platform | Where the budget is published | +|---|---| +| Linux (systemd) | `TimeoutStopSec=25s` in the generated unit. | +| macOS (launchd) | `ExitTimeOut` 25 in the generated property list. | +| Windows (Service Control Manager) | the bounded wait of the service handler. | + +launchd's own default of 20 s was exactly the total of the orderly path, so +the property list carries the budget of the service layer instead: the +deadline of a supervisor must never be shorter than the shutdown it waits +for. + +The instance is **never killed to make a stop succeed**. The deadline of the +supervisor is the only hard stop, and it is larger than the orderly path +needs, so an instance that shuts down in time is never interrupted while it +saves its state. + +A stop of an instance that is not running is a **clean success**: there is +nothing running to stop. A stop deletes nothing either: the stopped instance +keeps its job list and its download history, and the next start finds both +again. + ## Exit codes Every command of the service family ends with one of three exit codes: From 6f597c69623c01d3667f1dff283be762627f285b Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 22 Oct 2026 07:52:23 +1100 Subject: [PATCH 43/58] fix(service): restart the launchd instance through the graceful stop - A restart replaced the running process with kickstart -k, which does not deliver the signal the service host turns into the cancellation of the application context, so the server never drained its connections and never wrote its job list back. A restart is now the graceful stop followed by the start, and the replacement starts only when the stop succeeded. - A stop that exceeded its budget is reported instead of looking like a success. - A failed reinstall restores the supervisor state and not only the property list, so an instance that was running before the attempt runs again. Co-Authored-By: opencode --- internal/service/manager_darwin.go | 312 ++++++++++++++--- internal/service/manager_darwin_test.go | 428 ++++++++++++++++++++++++ 2 files changed, 694 insertions(+), 46 deletions(-) diff --git a/internal/service/manager_darwin.go b/internal/service/manager_darwin.go index 7c5331f1..7c1498db 100644 --- a/internal/service/manager_darwin.go +++ b/internal/service/manager_darwin.go @@ -109,6 +109,16 @@ type launchdManager struct { // property list of a user scope instance and it is the reference that // keeps a machine-wide instance away from the state of a user. home string + // run runs the commands of the supervisor. It is nil in production, + // where launchctl is called directly, and a test sets it to a + // recorder: a lifecycle can then be driven without a supervisor and + // the commands the manager issues can be asserted. + run func(ctx context.Context, args ...string) (string, error) + // stopBudget is the time the manager waits for the graceful shutdown + // of the instance. It is zero in production, where the wait uses the + // shared budget of the service layer, and a test shortens it to drive + // the timeout of the wait. + stopBudget time.Duration } // newPlatformManager returns the launchd manager of the given scope. @@ -274,9 +284,9 @@ func (m *launchdManager) register(ctx context.Context, opts InstallOptions, acco return err } - bootstrapped, err := m.load(ctx, path, changed) + state, err := m.load(ctx, path, changed) if err != nil { - return m.rollback(ctx, backup, bootstrapped, err) + return m.rollback(ctx, backup, state, err) } return nil } @@ -314,7 +324,7 @@ func (m *launchdManager) Uninstall(ctx context.Context) error { // never keeps a registration whose file is gone. A job that is not // loaded any more is not an error: the registration is removed either // way. - if _, err := runLaunchctl(ctx, "bootout", m.target()); err != nil && !isServiceMissing(err) { + if _, err := m.launchctl(ctx, "bootout", m.target()); err != nil && !isServiceMissing(err) { return fmt.Errorf("service: bootout %s: %w", m.target(), err) } if err := os.Remove(path); err != nil && !os.IsNotExist(err) { @@ -354,7 +364,7 @@ func (m *launchdManager) Start(ctx context.Context) error { if err := m.ensureLoaded(ctx, path); err != nil { return err } - if _, err := runLaunchctl(ctx, "kickstart", m.target()); err != nil { + if _, err := m.launchctl(ctx, "kickstart", m.target()); err != nil { return fmt.Errorf("service: kickstart %s: %w", m.target(), err) } return nil @@ -373,6 +383,10 @@ func (m *launchdManager) Start(ctx context.Context) error { // Stopping an instance that does not run is a stable no-op: the state the // caller asked for already holds. // +// An instance that did not finish its shutdown within the budget of the +// service layer is reported: the stop unloads the job anyway, but a caller +// never sees a plain success for a shutdown that did not complete. +// // A machine-wide instance needs an elevated shell: the LaunchDaemon is // unloaded from the system domain, and the system domain belongs to the // machine. The check runs after the existence of the registration, so a @@ -402,22 +416,39 @@ func (m *launchdManager) Stop(ctx context.Context) error { } _, _, pid := launchdPrintFields(output) - if _, err := runLaunchctl(ctx, "kill", "SIGTERM", m.target()); err != nil && !isServiceMissing(err) { + if _, err := m.launchctl(ctx, "kill", "SIGTERM", m.target()); err != nil && !isServiceMissing(err) { return fmt.Errorf("service: kill SIGTERM %s: %w", m.target(), err) } - if err := m.waitForExit(ctx, pid); err != nil { - return err - } - if _, err := runLaunchctl(ctx, "bootout", m.target()); err != nil && !isServiceMissing(err) { + // The wait reports the overrun of the budget: an instance that is + // still there when the budget is over did not shut down in an orderly + // way. + waitErr := m.waitForExit(ctx, pid) + // The job is unloaded even when the instance did not finish its + // shutdown: a process that is still there would be restarted by + // launchd under the KeepAlive policy of the registration. + if _, err := m.launchctl(ctx, "bootout", m.target()); err != nil && !isServiceMissing(err) { + if waitErr != nil { + return fmt.Errorf("%w (the registration could not be unloaded: %v)", waitErr, err) + } return fmt.Errorf("service: bootout %s: %w", m.target(), err) } + if waitErr != nil { + return fmt.Errorf("%w (the instance was unloaded anyway)", waitErr) + } return nil } // Restart restarts the registered instance. // -// The job is restarted with "launchctl kickstart -k": the running process -// is replaced by a new one instead of waiting for it to end. +// The restart is the graceful stop followed by the start: SIGTERM lets the +// server quiesce, drain its connections and close the job manager, and the +// job is unloaded before the replacement is registered and started. The +// replacement is never started while the old instance is still draining, so +// the two never compete for the bind address. +// +// The replacement is started only when the stop succeeded: an instance that +// did not shut down in an orderly way is not covered by a new one, and the +// failure is reported instead. // // A machine-wide instance needs an elevated shell: the LaunchDaemon is // registered in /Library/LaunchDaemons and it is started by the system @@ -433,17 +464,20 @@ func (m *launchdManager) Restart(ctx context.Context) error { } return fmt.Errorf("service: read the registration %q: %w", path, err) } - // The elevation requirement is checked before the job is started: a - // permission error of the machine is a much less readable answer than - // the real reason. + // The elevation requirement is checked before the job is stopped and + // before the job is started: a permission error of the machine is a + // much less readable answer than the real reason. if err := m.requireElevated("a restart"); err != nil { return err } - if err := m.ensureLoaded(ctx, path); err != nil { - return err + // The instance is stopped the way a stop stops it: the graceful + // shutdown of the server, the bounded wait for it and the unload of + // the job. The replacement is started only when that succeeded. + if err := m.Stop(ctx); err != nil { + return fmt.Errorf("service: restart %s: %w", m.target(), err) } - if _, err := runLaunchctl(ctx, "kickstart", "-k", m.target()); err != nil { - return fmt.Errorf("service: kickstart -k %s: %w", m.target(), err) + if err := m.Start(ctx); err != nil { + return fmt.Errorf("service: restart %s: %w", m.target(), err) } return nil } @@ -984,44 +1018,156 @@ func accountCanWrite(path string, account *serviceAccount) (bool, error) { }) } -// load registers the property list with launchd and reports whether the job -// was loaded by this call. +// load registers the property list with launchd and reports what it did to +// the registration of the instance. // // A job that is not loaded yet is bootstrapped. A job that is already loaded // keeps its registration: an installation that does not change the property // list must not restart the running instance. An installation that does // change the property list loads the job again, so the new configuration // takes effect instead of waiting for the next boot of the machine. -func (m *launchdManager) load(ctx context.Context, path string, changed bool) (bool, error) { - loaded, _, err := m.printState(ctx) +// +// The report tells a rollback what the attempt did to the supervisor: the +// state the registration had before it, whether the attempt loaded the job +// and whether it took over the registration of a job that was loaded +// already. +func (m *launchdManager) load(ctx context.Context, path string, changed bool) (launchdLoadState, error) { + previous, err := m.supervisorState(ctx) if err != nil { - return false, err + return launchdLoadState{}, err } + state := launchdLoadState{previous: previous} - bootstrapped := false switch { - case !loaded: - if _, err := runLaunchctl(ctx, "bootstrap", m.domain(), path); err != nil { - return false, fmt.Errorf("service: bootstrap %s: %w", m.target(), err) + case !previous.loaded: + if _, err := m.launchctl(ctx, "bootstrap", m.domain(), path); err != nil { + return state, fmt.Errorf("service: bootstrap %s: %w", m.target(), err) } - bootstrapped = true + state.bootstrapped = true case changed: - if _, err := runLaunchctl(ctx, "bootout", m.target()); err != nil && !isServiceMissing(err) { - return false, fmt.Errorf("service: bootout %s: %w", m.target(), err) + if _, err := m.launchctl(ctx, "bootout", m.target()); err != nil && !isServiceMissing(err) { + return state, fmt.Errorf("service: bootout %s: %w", m.target(), err) } - if _, err := runLaunchctl(ctx, "bootstrap", m.domain(), path); err != nil { - return false, fmt.Errorf("service: bootstrap %s: %w", m.target(), err) + // The job is unloaded now: it holds no registration of its + // own, so the restored file has to be loaded again by a + // rollback. + state.replaced = true + if _, err := m.launchctl(ctx, "bootstrap", m.domain(), path); err != nil { + return state, fmt.Errorf("service: bootstrap %s: %w", m.target(), err) } - bootstrapped = true + state.bootstrapped = true } // A job that was disabled in the override database of launchd would // never start, so the disabled flag is cleared explicitly. The job is // loaded at this point, so the command can address it. - if _, err := runLaunchctl(ctx, "enable", m.target()); err != nil { - return bootstrapped, fmt.Errorf("service: enable %s: %w", m.target(), err) + if _, err := m.launchctl(ctx, "enable", m.target()); err != nil { + return state, fmt.Errorf("service: enable %s: %w", m.target(), err) + } + return state, nil +} + +// launchdLoadState is what an installation did to the registration of the +// instance in the supervisor. +type launchdLoadState struct { + // previous is the state the registration had before the attempt. + previous launchdSupervisorState + // bootstrapped reports whether the attempt loaded the job into its + // domain. + bootstrapped bool + // replaced reports whether the attempt took over the registration of + // a job that was loaded already: the job holds the registration of + // the attempt now, or it holds none at all. + replaced bool +} + +// launchdSupervisorState is the state of the registration of the instance in +// the supervisor. +type launchdSupervisorState struct { + // loaded reports whether the job is loaded into its domain. + loaded bool + // disabled reports whether the job is disabled in the override + // database of launchd. + disabled bool +} + +// supervisorState reports the state of the registration of the instance in +// the supervisor: whether the job is loaded into its domain and whether it +// is disabled in the override database of launchd. +func (m *launchdManager) supervisorState(ctx context.Context) (launchdSupervisorState, error) { + loaded, _, err := m.printState(ctx) + if err != nil { + return launchdSupervisorState{}, err } - return bootstrapped, nil + return launchdSupervisorState{ + loaded: loaded, + disabled: m.jobDisabled(ctx), + }, nil +} + +// jobDisabled reports whether the job of the instance is disabled in the +// override database of launchd. +// +// The database of the domain is asked, because that is where the disabled +// flag of a job lives. The question is best effort: a supervisor that cannot +// answer it is treated as one that knows no disabled job, so an installation +// never fails because of it. +func (m *launchdManager) jobDisabled(ctx context.Context) bool { + output, err := m.launchctl(ctx, "print-disabled", m.domain()) + if err != nil { + return false + } + return launchdDisabledLabel(output, launchdLabel()) +} + +// launchdDisabledLabel reports whether the output of "launchctl +// print-disabled" says that the job of the given label is disabled. +// +// The output of launchd is not an API: a disabled job is listed as +// "