diff --git a/cmd/proxyctl/main.go b/cmd/proxyctl/main.go index d22ee25..159635b 100644 --- a/cmd/proxyctl/main.go +++ b/cmd/proxyctl/main.go @@ -224,7 +224,7 @@ func runMaintenanceCommand(args []string, input io.Reader, output io.Writer, sto continue } if item.Err != nil { - fmt.Fprintf(output, "%s: failed (%s) — %s\n", item.TargetID, item.Inspection.State, profile.RedactText(item.Err.Error())) + fmt.Fprintf(output, "%s: failed (%s) — %s; fresh inspection: %s\n", item.TargetID, item.Inspection.State, profile.RedactText(item.Err.Error()), profile.RedactText(item.Inspection.Evidence)) continue } fmt.Fprintf(output, "%s: reset (%s) — %s\n", item.TargetID, item.Inspection.State, profile.RedactText(item.Inspection.Evidence)) diff --git a/cmd/proxyctl/main_test.go b/cmd/proxyctl/main_test.go index cdef224..7009b45 100644 --- a/cmd/proxyctl/main_test.go +++ b/cmd/proxyctl/main_test.go @@ -50,6 +50,9 @@ func TestProductionCompositionRegistersAPTContainerGNOMEAndDaemonTargets(t *test var ids []string for _, adapter := range targets { ids = append(ids, adapter.ID()) + if _, ok := adapter.(target.Diagnosable); !ok { + t.Errorf("production Target %s does not contribute doctor checks", adapter.ID()) + } } want := []string{"apt", "docker-containers", "gnome", "docker-daemon"} if strings.Join(ids, ",") != strings.Join(want, ",") { diff --git a/cmd/proxyctl/maintenance_test.go b/cmd/proxyctl/maintenance_test.go index 3b9393f..a10bf71 100644 --- a/cmd/proxyctl/maintenance_test.go +++ b/cmd/proxyctl/maintenance_test.go @@ -232,10 +232,10 @@ func TestResetCommandCancellationAndTruthfulPartialFailureWithFakeTargets(t *tes t.Fatal("partial reset returned success") } text := output.String() - if good.calls != 1 || bad.calls != 1 || external.calls != 0 || !strings.Contains(text, "good: reset (Disabled)") || !strings.Contains(text, "bad: failed (Drifted)") || !strings.Contains(text, "external: skipped (External)") { + if good.calls != 1 || bad.calls != 1 || external.calls != 0 || !strings.Contains(text, "good: reset (Disabled)") || !strings.Contains(text, "bad: failed (Drifted)") || !strings.Contains(text, "fresh inspection: fake evidence for http://alice:REDACTED@proxy.test") || !strings.Contains(text, "external: skipped (External)") { t.Fatalf("partial reset output=%q calls=%d/%d/%d", text, good.calls, bad.calls, external.calls) } - if strings.Contains(text, "hunter2") || strings.Contains(err.Error(), "hunter2") { + if strings.Contains(text, "hunter2") || strings.Contains(text, "secret") || strings.Contains(err.Error(), "hunter2") { t.Fatalf("reset leaked credentials: output=%q err=%v", text, err) } logData, readErr := os.ReadFile(filepath.Join(stateDir, "operations.log")) diff --git a/internal/app/maintenance.go b/internal/app/maintenance.go index 9b5fd4b..133da03 100644 --- a/internal/app/maintenance.go +++ b/internal/app/maintenance.go @@ -80,9 +80,9 @@ func (m *Maintenance) Doctor(ctx context.Context, applicationChecks []target.Che active := activeFromState(m.state) for _, adapter := range m.targets { availability := adapter.Availability(ctx) - report.Diagnostics = append(report.Diagnostics, Diagnostic{TargetID: adapter.ID(), Check: target.Check{Name: "Target availability", OK: availability.Available, Evidence: availability.Evidence}}) + report.Diagnostics = append(report.Diagnostics, Diagnostic{TargetID: adapter.ID(), Check: target.Check{Name: "Target availability", OK: availability.Available, Evidence: profile.RedactText(availability.Evidence)}}) inspection := adapter.Inspect(ctx, active) - report.Diagnostics = append(report.Diagnostics, Diagnostic{TargetID: adapter.ID(), Check: target.Check{Name: "detected state", OK: inspection.State != target.Drifted, Evidence: string(inspection.State) + ": " + inspection.Evidence}}) + report.Diagnostics = append(report.Diagnostics, Diagnostic{TargetID: adapter.ID(), Check: target.Check{Name: "detected state", OK: inspection.State != target.Drifted, Evidence: profile.RedactText(string(inspection.State) + ": " + inspection.Evidence)}}) if diagnosable, ok := adapter.(target.Diagnosable); ok { for _, check := range diagnosable.DiagnosticChecks(ctx, active) { check.Evidence = profile.RedactText(check.Evidence) @@ -126,17 +126,22 @@ func (m *Maintenance) Reset(ctx context.Context, confirmed bool) ResetResult { continue } operation := adapter.SetDesired(ctx, false, active) - detail, outcome := operation.Inspection.Evidence, "success" + finalInspection := operation.Inspection + detail, outcome := finalInspection.Evidence, "success" if operation.Err != nil { + // OperationResult inspection is explicitly advisory on failure. Reset + // must publish and log a fresh observation just like interactive + // Target operations do. + finalInspection = adapter.Inspect(ctx, active) outcome = "failure" - detail = operation.Err.Error() + detail = fmt.Sprintf("%v; fresh inspection %s: %s", operation.Err, finalInspection.State, finalInspection.Evidence) result.Err = errors.Join(result.Err, fmt.Errorf("reset %s Target: %w", adapter.ID(), redactError(operation.Err))) } detail = RedactLogDetail(detail) if m.logger != nil { _ = m.logger.Write(LogEntry{Time: time.Now(), Operation: "reset", Target: adapter.ID(), Result: outcome, Detail: detail}) } - result.Targets = append(result.Targets, ResetTargetResult{TargetID: adapter.ID(), Inspection: operation.Inspection, Err: redactError(operation.Err)}) + result.Targets = append(result.Targets, ResetTargetResult{TargetID: adapter.ID(), Inspection: finalInspection, Err: redactError(operation.Err)}) } return result } diff --git a/internal/app/maintenance_test.go b/internal/app/maintenance_test.go index 4ebc173..d4022a6 100644 --- a/internal/app/maintenance_test.go +++ b/internal/app/maintenance_test.go @@ -43,6 +43,28 @@ func (f *maintenanceTarget) DiagnosticChecks(context.Context, *profile.Profile) return append([]target.Check(nil), f.checks...) } +type staleFailureTarget struct{ inspectCalls int } + +func (*staleFailureTarget) ID() string { return "stale" } +func (*staleFailureTarget) Description() target.Description { return target.Description{Name: "stale"} } +func (*staleFailureTarget) Availability(context.Context) target.Availability { + return target.Availability{Available: true} +} +func (*staleFailureTarget) Confirmation(bool) string { return "" } +func (f *staleFailureTarget) Inspect(context.Context, *profile.Profile) target.Inspection { + f.inspectCalls++ + if f.inspectCalls == 1 { + return target.Inspection{State: target.Enabled, Evidence: "initial managed state"} + } + return target.Inspection{State: target.External, Evidence: "fresh evidence http://alice:secret@proxy.example:8080"} +} +func (*staleFailureTarget) SetDesired(context.Context, bool, *profile.Profile) target.OperationResult { + return target.OperationResult{ + Inspection: target.Inspection{State: target.Drifted, Evidence: "stale returned evidence"}, + Err: errors.New("failed https://bob:hunter2@proxy.example/path"), + } +} + type recordingLock struct{ held, acquired int } func (l *recordingLock) Lock(context.Context) (func(), error) { @@ -111,6 +133,22 @@ func TestApplicationSerializesAndLogsTargetMutations(t *testing.T) { } } +func TestResetFailureUsesFreshInspectionForResultAndRedactedLog(t *testing.T) { + adapter := &staleFailureTarget{} + log := &recordingLog{} + result := app.NewMaintenance(profile.State{}, []target.Target{adapter}, &recordingLock{}, log).Reset(context.Background(), true) + if result.Err == nil || adapter.inspectCalls != 2 || len(result.Targets) != 1 { + t.Fatalf("reset result=%#v inspect calls=%d", result, adapter.inspectCalls) + } + got := result.Targets[0].Inspection + if got.State != target.External || !strings.Contains(got.Evidence, "fresh evidence") || strings.Contains(got.Evidence, "stale") { + t.Fatalf("failure inspection = %#v", got) + } + if len(log.entries) != 1 || !strings.Contains(log.entries[0].Detail, "fresh inspection External") || strings.Contains(log.entries[0].Detail, "secret") || strings.Contains(log.entries[0].Detail, "hunter2") { + t.Fatalf("failure log = %#v", log.entries) + } +} + func TestResetRequiresConfirmationAndReportsTruthfulPartialResults(t *testing.T) { good := &maintenanceTarget{id: "good", available: target.Availability{Available: true}, inspection: target.Inspection{State: target.Enabled, Evidence: "managed"}} failure := errors.New("remove https://alice:secret@proxy.example failed: permission denied") diff --git a/internal/apt/apt.go b/internal/apt/apt.go index faa1038..c9bf917 100644 --- a/internal/apt/apt.go +++ b/internal/apt/apt.go @@ -82,6 +82,8 @@ type Adapter struct { runner Runner } +var _ target.Diagnosable = (*Adapter)(nil) + func New(configuration Configuration, files FileSystem, runner Runner) *Adapter { defaults := DefaultConfiguration() if configuration.DebianVersionFile == "" { @@ -116,6 +118,32 @@ func (a *Adapter) Description() target.Description { func (a *Adapter) Confirmation(bool) string { return "" } +// DiagnosticChecks observes the APT command, configuration schema, and current +// filesystem authority without changing the overlay or effective settings. +func (a *Adapter) DiagnosticChecks(ctx context.Context, _ *profile.Profile) []target.Check { + commandCheck := target.Check{Name: "required command", OK: true, Evidence: "apt-config is available"} + command, err := a.runner.LookPath("apt-config") + if err != nil { + commandCheck.OK = false + commandCheck.Evidence = "apt-config is unavailable: " + profile.RedactText(err.Error()) + } else if _, err := a.runner.Output(ctx, command, "dump"); err != nil { + commandCheck.OK = false + commandCheck.Evidence = "apt-config dump failed: " + profile.RedactText(err.Error()) + } + marker, markerErr := os.Stat(a.configuration.DebianVersionFile) + directory, directoryErr := os.Stat(a.configuration.ConfigDirectory) + schemaOK := markerErr == nil && marker.Mode().IsRegular() && directoryErr == nil && directory.IsDir() + schemaEvidence := "Debian marker and apt.conf.d configuration directory are present" + if !schemaOK { + schemaEvidence = profile.RedactText(fmt.Sprintf("Debian marker or apt.conf.d configuration directory is unusable: marker=%v directory=%v", markerErr, directoryErr)) + } + return []target.Check{ + commandCheck, + {Name: "configuration schema", OK: schemaOK, Evidence: schemaEvidence}, + target.ReadOnlyWriteAuthority(a.configuration.OverlayFile), + } +} + func (a *Adapter) Availability(ctx context.Context) target.Availability { marker, err := os.Stat(a.configuration.DebianVersionFile) if err != nil || !marker.Mode().IsRegular() { diff --git a/internal/apt/apt_test.go b/internal/apt/apt_test.go index b6975ad..c50e9d1 100644 --- a/internal/apt/apt_test.go +++ b/internal/apt/apt_test.go @@ -46,6 +46,30 @@ func TestAvailabilityRequiresDebianMarkerAPTToolAndConfigurationDirectory(t *tes } } +func TestDiagnosticChecksCoverCommandSchemaAndAuthorityWithoutMutation(t *testing.T) { + a, _ := newAdapter(t, nil) + checks := a.DiagnosticChecks(context.Background(), compatibleProfile()) + assertDiagnosticNames(t, checks, "required command", "configuration schema", "write authority") + if _, err := os.Stat(a.OverlayPath()); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("diagnostics created APT overlay: %v", err) + } +} + +func assertDiagnosticNames(t *testing.T, checks []target.Check, names ...string) { + t.Helper() + for _, name := range names { + found := false + for _, check := range checks { + if check.Name == name { + found = true + } + } + if !found { + t.Errorf("diagnostics missing %q: %#v", name, checks) + } + } +} + func TestDescriptionExplainsEnvironmentPersistenceAndPrivilegeBoundary(t *testing.T) { a, _ := newAdapter(t, nil) got := a.Description() diff --git a/internal/dockerclient/dockerclient.go b/internal/dockerclient/dockerclient.go index 9e65681..43409bc 100644 --- a/internal/dockerclient/dockerclient.go +++ b/internal/dockerclient/dockerclient.go @@ -154,7 +154,10 @@ type Adapter struct { files FileSystem } -var _ target.Target = (*Adapter)(nil) +var ( + _ target.Target = (*Adapter)(nil) + _ target.Diagnosable = (*Adapter)(nil) +) func New(configuration Configuration, files FileSystem) *Adapter { if files == nil { @@ -174,6 +177,31 @@ func (a *Adapter) Description() target.Description { } func (a *Adapter) Confirmation(bool) string { return "" } +// DiagnosticChecks validates the readable JSON surfaces and observes write +// authority without taking the adapter lock or creating any files. +func (a *Adapter) DiagnosticChecks(_ context.Context, _ *profile.Profile) []target.Check { + commandCheck := target.Check{Name: "required commands", OK: true, Evidence: "Docker client defaults require no external commands"} + schemaCheck := target.Check{Name: "configuration schema", OK: true, Evidence: "Docker client configuration and recovery metadata are absent or valid JSON objects"} + if config, _, err := a.readConfig(); err != nil { + schemaCheck.OK = false + schemaCheck.Evidence = profile.RedactText("Docker client config.json is invalid or unreadable: " + err.Error()) + } else if proxies, _, err := childObject(config, "proxies"); err != nil { + schemaCheck.OK = false + schemaCheck.Evidence = profile.RedactText(err.Error()) + } else if _, _, err := childObject(proxies, "default"); err != nil { + schemaCheck.OK = false + schemaCheck.Evidence = profile.RedactText(err.Error()) + } else if _, _, err := a.readRecovery(); err != nil && !errors.Is(err, fs.ErrNotExist) { + schemaCheck.OK = false + schemaCheck.Evidence = profile.RedactText("Docker client recovery metadata is invalid or unreadable: " + err.Error()) + } + return []target.Check{ + commandCheck, + schemaCheck, + target.ReadOnlyWriteAuthority(a.configuration.ConfigFile, a.configuration.RecoveryFile), + } +} + func (a *Adapter) Availability(context.Context) target.Availability { if !a.usablePath(a.configuration.ConfigFile) || !a.usablePath(a.configuration.RecoveryFile) { return target.Availability{Evidence: "standard Docker client configuration paths are not usable for the owning user"} diff --git a/internal/dockerclient/dockerclient_test.go b/internal/dockerclient/dockerclient_test.go index 3236b02..b9f1f06 100644 --- a/internal/dockerclient/dockerclient_test.go +++ b/internal/dockerclient/dockerclient_test.go @@ -44,6 +44,29 @@ func TestAvailabilityAndDescriptionDescribeOnlyDockerClientDefaults(t *testing.T } } +func TestDiagnosticChecksCoverSchemaAndAuthorityWithoutMutation(t *testing.T) { + files := &countingFS{FileSystem: dockerclient.OSFileSystem{}} + a := newAdapterWithFS(t, files) + checks := a.DiagnosticChecks(context.Background(), activeProfile()) + for _, name := range []string{"required commands", "configuration schema", "write authority"} { + found := false + for _, check := range checks { + found = found || check.Name == name + } + if !found { + t.Errorf("diagnostics missing %q: %#v", name, checks) + } + } + if files.writes != 0 { + t.Fatalf("diagnostics wrote Docker client state %d times", files.writes) + } + for _, path := range []string{a.ConfigPath(), a.RecoveryPath(), a.LockPath()} { + if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("diagnostics created %s: %v", path, err) + } + } +} + func TestDockerClientAdapterRunsReusableTargetContract(t *testing.T) { targettest.Run(t, func(t *testing.T) targettest.Fixture { a := newAdapter(t, nil) diff --git a/internal/dockerdaemon/dockerdaemon.go b/internal/dockerdaemon/dockerdaemon.go index 3a888d5..ddd9496 100644 --- a/internal/dockerdaemon/dockerdaemon.go +++ b/internal/dockerdaemon/dockerdaemon.go @@ -74,6 +74,8 @@ type Adapter struct { runner CommandRunner } +var _ target.Diagnosable = (*Adapter)(nil) + func New(configuration Configuration, files FileSystem, runner CommandRunner) *Adapter { if configuration.DaemonConfigPath == "" { configuration.DaemonConfigPath = "/etc/docker/daemon.json" @@ -102,6 +104,42 @@ func (a *Adapter) Description() target.Description { func (a *Adapter) Confirmation(bool) string { return "Docker will restart immediately. This may disrupt running containers and other work. Confirm before proxyctl changes daemon configuration." } + +// DiagnosticChecks performs only systemctl show and filesystem reads/metadata +// probes; service reload/restart and all configuration mutations are excluded. +func (a *Adapter) DiagnosticChecks(ctx context.Context, _ *profile.Profile) []target.Check { + commandCheck := target.Check{Name: "required command and service", OK: true, Evidence: "systemctl can inspect the loaded docker.service"} + if availability, err := a.detect(ctx); err != nil || !availability.Available { + commandCheck.OK = false + if err != nil { + commandCheck.Evidence = profile.RedactText("systemctl show failed: " + err.Error()) + } else { + commandCheck.Evidence = availability.Evidence + } + } + schemaCheck := target.Check{Name: "configuration schema", OK: true, Evidence: "Docker daemon configuration and recovery metadata are absent or valid JSON"} + if data, err := a.files.ReadFile(a.configuration.DaemonConfigPath); err == nil { + if _, schemaErr := mergeConfig(data, profile.Profile{}); schemaErr != nil { + schemaCheck.OK = false + schemaCheck.Evidence = profile.RedactText("Docker daemon configuration cannot be safely merged: " + schemaErr.Error()) + } + } else if !errors.Is(err, fs.ErrNotExist) { + schemaCheck.OK = false + schemaCheck.Evidence = profile.RedactText("Docker daemon configuration cannot be read: " + err.Error()) + } + if schemaCheck.OK { + if _, _, err := a.readRecovery(); err != nil && !errors.Is(err, fs.ErrNotExist) { + schemaCheck.OK = false + schemaCheck.Evidence = profile.RedactText("Docker daemon recovery metadata is invalid or unreadable: " + err.Error()) + } + } + return []target.Check{ + commandCheck, + schemaCheck, + target.ReadOnlyWriteAuthority(a.configuration.DaemonConfigPath, a.configuration.RecoveryPath), + } +} + func (a *Adapter) Availability(ctx context.Context) target.Availability { availability, _ := a.detect(ctx) return availability diff --git a/internal/dockerdaemon/dockerdaemon_test.go b/internal/dockerdaemon/dockerdaemon_test.go index bd8b592..4f5d631 100644 --- a/internal/dockerdaemon/dockerdaemon_test.go +++ b/internal/dockerdaemon/dockerdaemon_test.go @@ -102,6 +102,30 @@ func (f *faultFS) AtomicWrite(path string, data []byte, mode os.FileMode) error return f.recordingFS.AtomicWrite(path, data, mode) } +func TestDiagnosticChecksCoverSystemdSchemaAndAuthorityWithoutMutation(t *testing.T) { + a, record, daemonPath, recoveryPath, _ := fixture(t) + checks := a.DiagnosticChecks(context.Background(), activeProfile()) + for _, name := range []string{"required command and service", "configuration schema", "write authority"} { + found := false + for _, check := range checks { + found = found || check.Name == name + } + if !found { + t.Errorf("diagnostics missing %q: %#v", name, checks) + } + } + for _, event := range record.events { + if strings.HasPrefix(event, "write ") || strings.HasPrefix(event, "remove ") || strings.Contains(event, " restart ") || strings.Contains(event, "daemon-reload") { + t.Fatalf("diagnostics mutated daemon state: %#v", record.events) + } + } + for _, path := range []string{daemonPath, recoveryPath} { + if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("diagnostics created %s: %v", path, err) + } + } +} + func TestDockerDaemonRunsReusableTargetContract(t *testing.T) { targettest.Run(t, func(t *testing.T) targettest.Fixture { a, _, daemonPath, _, _ := fixture(t) diff --git a/internal/gnome/gnome.go b/internal/gnome/gnome.go index a242739..085c424 100644 --- a/internal/gnome/gnome.go +++ b/internal/gnome/gnome.go @@ -34,6 +34,12 @@ const ( var requiredSchemas = []string{proxySchema, httpSchema, httpsSchema, socksSchema} +var managedSettings = []struct{ schema, key string }{ + {proxySchema, "mode"}, {proxySchema, "autoconfig-url"}, {proxySchema, "ignore-hosts"}, + {httpSchema, "host"}, {httpSchema, "port"}, {httpSchema, "use-authentication"}, {httpSchema, "authentication-user"}, {httpSchema, "authentication-password"}, + {httpsSchema, "host"}, {httpsSchema, "port"}, {socksSchema, "host"}, {socksSchema, "port"}, +} + // Command is the complete command contract used by the adapter. UID, GID, and // Env make user-session identity explicit when proxyctl itself runs under sudo. type Command struct { @@ -230,7 +236,10 @@ type Adapter struct { state StateStore } -var _ target.Target = (*Adapter)(nil) +var ( + _ target.Target = (*Adapter)(nil) + _ target.Diagnosable = (*Adapter)(nil) +) func New(configuration Configuration, runner CommandRunner, state StateStore) *Adapter { if configuration.CommandName == "" { @@ -271,6 +280,65 @@ func (a *Adapter) Description() target.Description { func (a *Adapter) Confirmation(bool) string { return "" } +// DiagnosticChecks uses only gsettings list/get operations and filesystem +// metadata probes. In particular, it never invokes gsettings set or locks or +// writes the recovery StateStore. +func (a *Adapter) DiagnosticChecks(ctx context.Context, _ *profile.Profile) []target.Check { + commandCheck := target.Check{Name: "required command", OK: true, Evidence: a.configuration.CommandName + " is usable for the owning user"} + schemaCheck := target.Check{Name: "required schemas", OK: true, Evidence: "all GNOME proxy schemas are present"} + sessionCheck := target.Check{Name: "desktop session", OK: true, Evidence: "GNOME proxy settings are readable through the configured session bus"} + output, err := a.run(ctx, "list-schemas") + if err != nil { + commandCheck.OK = false + commandCheck.Evidence = profile.RedactText(a.configuration.CommandName + " failed: " + err.Error()) + schemaCheck.OK = false + schemaCheck.Evidence = "GNOME schemas could not be listed" + sessionCheck.OK = false + sessionCheck.Evidence = "desktop session could not be inspected" + } else { + present := make(map[string]bool) + for _, schema := range strings.Fields(output) { + present[schema] = true + } + var missing []string + for _, schema := range requiredSchemas { + if !present[schema] { + missing = append(missing, schema) + } + } + if len(missing) != 0 { + schemaCheck.OK = false + schemaCheck.Evidence = "missing GNOME proxy schemas: " + strings.Join(missing, ", ") + sessionCheck.OK = false + sessionCheck.Evidence = "desktop session settings cannot be checked without required schemas" + } else if _, err := a.readSettings(ctx); err != nil { + sessionCheck.OK = false + sessionCheck.Evidence = profile.RedactText("GNOME proxy settings are unreadable: " + err.Error()) + } + } + authorityCheck := target.ReadOnlyWriteAuthority(a.configuration.StateFile) + if !commandCheck.OK || !schemaCheck.OK || !sessionCheck.OK { + authorityCheck.OK = false + authorityCheck.Evidence += "; GNOME setting authority could not be inspected" + } else { + for _, setting := range managedSettings { + output, err := a.run(ctx, "writable", setting.schema, setting.key) + if err != nil || strings.TrimSpace(output) != "true" { + authorityCheck.OK = false + detail := strings.TrimSpace(output) + if err != nil { + detail = err.Error() + } + authorityCheck.Evidence += profile.RedactText(fmt.Sprintf("; %s %s is not writable: %s", setting.schema, setting.key, detail)) + } + } + if authorityCheck.OK { + authorityCheck.Evidence += "; all managed GNOME settings report writable" + } + } + return []target.Check{commandCheck, schemaCheck, sessionCheck, authorityCheck} +} + func (a *Adapter) Availability(ctx context.Context) target.Availability { output, err := a.run(ctx, "list-schemas") if err != nil { diff --git a/internal/gnome/gnome_test.go b/internal/gnome/gnome_test.go index 4c0f306..526b2e7 100644 --- a/internal/gnome/gnome_test.go +++ b/internal/gnome/gnome_test.go @@ -55,6 +55,9 @@ func (r *fakeRunner) Run(_ context.Context, c gnome.Command) (string, error) { } return value + "\n", nil } + if len(c.Args) == 3 && c.Args[0] == "writable" { + return "true\n", nil + } if len(c.Args) == 4 && c.Args[0] == "set" { r.values[c.Args[1]+" "+c.Args[2]] = c.Args[3] return "", nil @@ -105,6 +108,29 @@ func active() *profile.Profile { return &profile.Profile{Name: "office", Mode: profile.AdvancedMode, HTTPURL: "http://proxy.example:8080", HTTPSURL: "http://secure.example:8443", AllURL: "socks5://socks.example:1080", Bypass: []string{"localhost", ".example.com", "10.0.0.0/8", "2001:db8::1"}} } +func TestDiagnosticChecksCoverCommandSchemasSessionAndAuthorityWithoutMutation(t *testing.T) { + r, state := newRunner(), &memoryState{} + a := adapter(r, state) + checks := a.DiagnosticChecks(context.Background(), active()) + for _, name := range []string{"required command", "required schemas", "desktop session", "write authority"} { + found := false + for _, check := range checks { + found = found || check.Name == name + } + if !found { + t.Errorf("diagnostics missing %q: %#v", name, checks) + } + } + for _, call := range r.calls { + if len(call.Args) > 0 && call.Args[0] == "set" { + t.Fatalf("diagnostics mutated GNOME settings: %#v", call) + } + } + if state.data != nil { + t.Fatalf("diagnostics wrote GNOME recovery state: %q", state.data) + } +} + func TestGNOMEAdapterRunsReusableTargetContract(t *testing.T) { targettest.Run(t, func(t *testing.T) targettest.Fixture { r, state := newRunner(), &memoryState{} diff --git a/internal/target/diagnostic.go b/internal/target/diagnostic.go new file mode 100644 index 0000000..9c39b06 --- /dev/null +++ b/internal/target/diagnostic.go @@ -0,0 +1,51 @@ +package target + +import ( + "errors" + "io/fs" + "os" + "path/filepath" + "strings" + + "golang.org/x/sys/unix" + + "proxyctl/internal/profile" +) + +// ReadOnlyWriteAuthority observes whether the current process can write the +// configured locations. It never creates a probe file or changes permissions. +func ReadOnlyWriteAuthority(paths ...string) Check { + check := Check{Name: "write authority", OK: true} + var evidence []string + for _, path := range paths { + ok, detail := readOnlyWritablePath(path) + check.OK = check.OK && ok + evidence = append(evidence, detail) + } + check.Evidence = profile.RedactText(strings.Join(evidence, "; ")) + return check +} + +func readOnlyWritablePath(path string) (bool, string) { + candidate := filepath.Clean(path) + for { + info, err := os.Stat(candidate) + if err == nil { + if !info.IsDir() { + candidate = filepath.Dir(candidate) + } + if err := unix.Access(candidate, unix.W_OK); err != nil { + return false, candidate + " is not writable: " + err.Error() + } + return true, candidate + " is writable" + } + if !errors.Is(err, fs.ErrNotExist) { + return false, candidate + " cannot be inspected: " + err.Error() + } + parent := filepath.Dir(candidate) + if parent == candidate { + return false, path + " has no writable existing ancestor" + } + candidate = parent + } +}