package dockerclient_test import ( "context" "encoding/json" "errors" "os" "os/exec" "path/filepath" "strings" "sync" "syscall" "testing" "time" "proxyctl/internal/app" "proxyctl/internal/dockerclient" "proxyctl/internal/profile" "proxyctl/internal/target" "proxyctl/internal/target/targettest" ) func TestDetectClassifiesMalformedClientConfigurationAsFailure(t *testing.T) { a := newAdapter(t, nil) writeConfig(t, a.ConfigPath(), `{"proxies":[]}`) detection := a.Detect(context.Background(), activeProfile()) if detection.Err == nil || detection.Inspection.State != target.Disabled { t.Fatalf("detection = %#v, want failed Disabled inspection", detection) } if got := a.Inspect(context.Background(), activeProfile()); got.State != target.Disabled { t.Fatalf("maintenance inspection = %#v, want established Disabled semantics", got) } } func TestAvailabilityAndDescriptionDescribeOnlyDockerClientDefaults(t *testing.T) { a := newAdapter(t, nil) availability := a.Availability(context.Background()) if !availability.Available || !strings.Contains(availability.Evidence, "Docker client configuration") { t.Fatalf("availability = %#v", availability) } description := a.Description() for _, phrase := range []string{"new containers and builds", "not Docker daemon registry access", "existing containers"} { if !strings.Contains(description.Details, phrase) { t.Errorf("details %q do not contain %q", description.Details, phrase) } } if a.ID() != "docker-containers" { t.Fatalf("ID = %q", a.ID()) } root := t.TempDir() outside := dockerclient.New(dockerclient.Configuration{ ConfigFile: filepath.Join(t.TempDir(), "config.json"), RecoveryFile: filepath.Join(root, "state.json"), TrustedRoots: []string{root}, }, nil) if got := outside.Availability(context.Background()); got.Available { t.Fatalf("path outside owning user's trusted configuration roots is available: %#v", got) } } 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 TestDiagnosticsAndEnableRejectMalformedManagedProxyTypesWithoutMutation(t *testing.T) { for _, document := range []string{ `{"proxies":[]}`, `{"proxies":{"default":[]}}`, `{"proxies":{"default":{"httpProxy":42}}}`, `{"proxies":{"default":{"httpsProxy":false}}}`, `{"proxies":{"default":{"allProxy":{}}}}`, `{"proxies":{"default":{"noProxy":[]}}}`, } { t.Run(document, func(t *testing.T) { files := &countingFS{FileSystem: dockerclient.OSFileSystem{}} a := newAdapterWithFS(t, files) writeConfig(t, a.ConfigPath(), document) before, err := os.ReadFile(a.ConfigPath()) if err != nil { t.Fatal(err) } checks := a.DiagnosticChecks(context.Background(), activeProfile()) if checks[1].OK { t.Fatalf("malformed schema passed diagnostics: %#v", checks) } writesBefore := files.writes result := a.SetDesired(context.Background(), true, activeProfile()) if result.Err == nil { t.Fatalf("enable accepted malformed schema: %#v", result) } after, err := os.ReadFile(a.ConfigPath()) if err != nil { t.Fatal(err) } if string(after) != string(before) || files.writes != writesBefore { t.Fatalf("malformed config was modified: writes %d -> %d", writesBefore, files.writes) } }) } } func TestDockerClientAdapterRunsReusableTargetContract(t *testing.T) { targettest.Run(t, func(t *testing.T) targettest.Fixture { a := newAdapter(t, nil) return targettest.Fixture{ Target: a, Active: activeProfile(), Secret: "secret", MakeExternal: func(t *testing.T) { writeConfig(t, a.ConfigPath(), `{"auths":{"registry.example":{}},"proxies":{"default":{"httpProxy":"http://prior.example:3128"}}}`) }, MakeDrifted: func(t *testing.T) { updateDefaultKey(t, a.ConfigPath(), "httpProxy", json.RawMessage(`"http://changed.example:8080"`)) }, PermissionFailure: func(t *testing.T) target.OperationResult { failed := newAdapterWithFS(t, &failingFS{FileSystem: dockerclient.OSFileSystem{}, permission: true}) return failed.SetDesired(context.Background(), true, activeProfile()) }, PartialFailure: func(t *testing.T) target.OperationResult { failed := newAdapterWithFS(t, &failingFS{FileSystem: dockerclient.OSFileSystem{}, failConfig: true}) return failed.SetDesired(context.Background(), true, activeProfile()) }, RedactionFailure: func(t *testing.T) target.OperationResult { failed := newAdapterWithFS(t, &failingFS{FileSystem: dockerclient.OSFileSystem{}, leak: true}) return failed.SetDesired(context.Background(), true, activeProfile()) }, RecoveryRetry: func(t *testing.T) (target.OperationResult, target.OperationResult, target.OperationResult) { files := &failingFS{FileSystem: dockerclient.OSFileSystem{}, failRecoveryOnce: true} retrying := newAdapterWithFS(t, files) writeConfig(t, retrying.ConfigPath(), `{"proxies":{"default":{"httpProxy":"http://prior.example:3128"}}}`) first := retrying.SetDesired(context.Background(), true, activeProfile()) retry := retrying.SetDesired(context.Background(), true, activeProfile()) disabled := retrying.SetDesired(context.Background(), false, nil) return first, retry, disabled }, } }) } func TestEnableSafelyMergesAndDisableRestoresPriorDefaults(t *testing.T) { a := newAdapter(t, nil) original := `{ "auths": {"registry.example": {"auth": "opaque"}}, "credsStore": "pass", "proxies": { "tcp://remote.example:2376": {"httpProxy": "http://remote.example:3128"}, "default": { "httpProxy": "http://prior.example:3128", "ftpProxy": "http://ftp.example:2121", "customOption": {"keep": true} } }, "pluginSetting": [1, 2, 3] }` writeConfig(t, a.ConfigPath(), original) if got := a.SetDesired(context.Background(), true, activeProfile()); got.Err != nil || got.Inspection.State != target.Enabled { t.Fatalf("enable = %#v", got) } config := readObject(t, a.ConfigPath()) if _, ok := config["auths"]; !ok { t.Fatal("auths removed") } if _, ok := config["pluginSetting"]; !ok { t.Fatal("unknown top-level setting removed") } proxies := nestedObject(t, config, "proxies") if _, ok := proxies["tcp://remote.example:2376"]; !ok { t.Fatal("per-daemon proxy setting removed") } defaults := nestedObject(t, proxies, "default") assertJSONString(t, defaults, "httpProxy", "http://alice:secret@proxy.example:8080") assertJSONString(t, defaults, "httpsProxy", "https://proxy.example:8443") assertJSONString(t, defaults, "allProxy", "socks5h://proxy.example:1080") assertJSONString(t, defaults, "noProxy", "localhost,.example.com,10.0.0.0/8") if _, ok := defaults["ftpProxy"]; !ok { t.Fatal("unmanaged default option removed") } // An unrelated edit made while enabled must survive restoration. config["detachKeys"] = json.RawMessage(`"ctrl-x,x"`) writeObject(t, a.ConfigPath(), config) if got := a.SetDesired(context.Background(), false, nil); got.Err != nil || got.Inspection.State != target.External { t.Fatalf("disable = %#v", got) } restored := readObject(t, a.ConfigPath()) if _, ok := restored["detachKeys"]; !ok { t.Fatal("external unrelated edit was removed") } defaults = nestedObject(t, nestedObject(t, restored, "proxies"), "default") assertJSONString(t, defaults, "httpProxy", "http://prior.example:3128") for _, key := range []string{"httpsProxy", "allProxy", "noProxy"} { if _, ok := defaults[key]; ok { t.Errorf("managed key %s was not restored to absence", key) } } if _, ok := defaults["customOption"]; !ok { t.Fatal("custom default option removed") } } func TestResetDriftedDockerClientRejectsAdministratorManagedKeyEditsWithoutWritesOrCleanup(t *testing.T) { files := &countingFS{FileSystem: dockerclient.OSFileSystem{}} a := newAdapterWithFS(t, files) writeConfig(t, a.ConfigPath(), `{"auths":{"keep":{}},"proxies":{"default":{"httpProxy":"http://prior.example:3128"}}}`) if result := a.SetDesired(context.Background(), true, activeProfile()); result.Err != nil { t.Fatal(result.Err) } updateDefaultKey(t, a.ConfigPath(), "httpProxy", json.RawMessage(`"http://administrator.example:3128"`)) configBefore := readFile(t, a.ConfigPath()) metadataBefore := readFile(t, a.RecoveryPath()) writesBefore := files.writes reset := app.NewMaintenance(profile.State{Profiles: []profile.Profile{*activeProfile()}, ActiveProfile: activeProfile().Name}, []target.Target{a}, immediateLock{}, nil).Reset(context.Background(), true) if reset.Err == nil || len(reset.Targets) != 1 || reset.Targets[0].Inspection.State != target.Drifted || reset.Targets[0].Err == nil { t.Fatalf("conflicting reset = %#v", reset) } if !strings.Contains(reset.Targets[0].Err.Error(), "changed after proxyctl applied") || files.writes != writesBefore { t.Fatalf("conflict outcome=%#v writes=%d->%d", reset.Targets[0], writesBefore, files.writes) } if got := readFile(t, a.ConfigPath()); got != configBefore { t.Fatalf("conflict rewrote config:\n%s", got) } if got := readFile(t, a.RecoveryPath()); got != metadataBefore { t.Fatalf("conflict changed metadata:\n%s", got) } } func TestResetDriftedDockerClientAlreadyAtOriginalOnlyCleansMetadata(t *testing.T) { files := &countingFS{FileSystem: dockerclient.OSFileSystem{}} a := newAdapterWithFS(t, files) original := `{"auths":{"keep":{}},"proxies":{"default":{"httpProxy":"http://prior.example:3128","custom":{"keep":true}}}}` writeConfig(t, a.ConfigPath(), original) if result := a.SetDesired(context.Background(), true, activeProfile()); result.Err != nil { t.Fatal(result.Err) } writeConfig(t, a.ConfigPath(), original) writesBefore := files.writes reset := app.NewMaintenance(profile.State{Profiles: []profile.Profile{*activeProfile()}, ActiveProfile: activeProfile().Name}, []target.Target{a}, immediateLock{}, nil).Reset(context.Background(), true) if reset.Err != nil || len(reset.Targets) != 1 || reset.Targets[0].Inspection.State != target.External { t.Fatalf("already-original reset = %#v", reset) } if files.writes != writesBefore || readFile(t, a.ConfigPath()) != original { t.Fatalf("already-original reset wrote config: writes=%d->%d", writesBefore, files.writes) } if _, err := os.Stat(a.RecoveryPath()); !errors.Is(err, os.ErrNotExist) { t.Fatalf("recovery metadata remains: %v", err) } } func TestInspectionDistinguishesAllStatesAndManagedValueEditsDrift(t *testing.T) { a := newAdapter(t, nil) assertState(t, a, target.Disabled) writeConfig(t, a.ConfigPath(), `{"proxies":{"default":{"ftpProxy":"http://ftp.example:2121"}}}`) assertState(t, a, target.External) writeConfig(t, a.ConfigPath(), `{"proxies":{"default":{"noProxy":"localhost"}}}`) assertState(t, a, target.External) if got := a.SetDesired(context.Background(), true, activeProfile()); got.Err != nil { t.Fatal(got.Err) } assertState(t, a, target.Enabled) updateDefaultKey(t, a.ConfigPath(), "noProxy", json.RawMessage(`"externally-edited.example"`)) assertState(t, a, target.Drifted) } func TestAddingEvenAnEmptyOrInvalidManagedValueIsDrift(t *testing.T) { for _, value := range []json.RawMessage{json.RawMessage(`""`), json.RawMessage(`[]`)} { a := newAdapter(t, nil) p := *activeProfile() p.AllURL = "" if got := a.SetDesired(context.Background(), true, &p); got.Err != nil { t.Fatal(got.Err) } updateDefaultKey(t, a.ConfigPath(), "allProxy", value) if got := a.Inspect(context.Background(), &p); got.State != target.Drifted { t.Fatalf("state = %s (%s), want Drifted", got.State, got.Evidence) } } } func TestDocumentedBypassFormsAreRepresentedWithoutLoss(t *testing.T) { a := newAdapter(t, nil) p := *activeProfile() p.Bypass = []string{"host.example", ".example.com", "192.0.2.1", "2001:db8::1", "10.0.0.0/8", "2001:db8::/32"} if got := a.SetDesired(context.Background(), true, &p); got.Err != nil { t.Fatal(got.Err) } defaults := nestedObject(t, nestedObject(t, readObject(t, a.ConfigPath()), "proxies"), "default") assertJSONString(t, defaults, "noProxy", "host.example,.example.com,192.0.2.1,2001:db8::1,10.0.0.0/8,2001:db8::/32") } func TestUnsupportedProfileDataPreventsAnyMutation(t *testing.T) { for _, tc := range []struct { name string edit func(*profile.Profile) want string }{ {"SOCKS HTTP proxy", func(p *profile.Profile) { p.HTTPURL = "socks5://proxy.example:1080" }, "HTTP proxy"}, {"SOCKS HTTPS proxy", func(p *profile.Profile) { p.HTTPSURL = "socks5h://proxy.example:1080" }, "HTTPS proxy"}, {"invalid bypass", func(p *profile.Profile) { p.Bypass = []string{"host:8080"} }, "Bypass"}, } { t.Run(tc.name, func(t *testing.T) { a := newAdapter(t, nil) writeConfig(t, a.ConfigPath(), `{"auths":{"keep":{}}}`) before := readFile(t, a.ConfigPath()) p := *activeProfile() tc.edit(&p) got := a.SetDesired(context.Background(), true, &p) if got.Err == nil || !strings.Contains(got.Err.Error(), tc.want) || got.Inspection.State != target.Disabled { t.Fatalf("result = %#v", got) } if after := readFile(t, a.ConfigPath()); after != before { t.Fatalf("configuration mutated:\nbefore %s\nafter %s", before, after) } if _, err := os.Stat(a.RecoveryPath()); !errors.Is(err, os.ErrNotExist) { t.Fatalf("recovery metadata created: %v", err) } }) } } func TestIncompleteRecoveryMetadataPreventsUnsafeRestoration(t *testing.T) { a := newAdapter(t, nil) writeConfig(t, a.ConfigPath(), `{"proxies":{"default":{"httpProxy":"http://must-not-be-deleted.example:3128"}}}`) writeConfig(t, a.RecoveryPath(), `{"configPresent":true,"proxiesPresent":true,"defaultPresent":true,"original":{},"fingerprint":"invalid"}`) before := readFile(t, a.ConfigPath()) got := a.SetDesired(context.Background(), false, nil) if got.Err == nil || got.Inspection.State != target.Drifted { t.Fatalf("disable = %#v", got) } if after := readFile(t, a.ConfigPath()); after != before { t.Fatalf("incomplete recovery metadata changed config: %s", after) } } func TestUnmergeableJSONPreventsMutation(t *testing.T) { for _, contents := range []string{`not json`, `[]`, `{"proxies":"wrong"}`, `{"proxies":{"default":[]}}`} { t.Run(contents, func(t *testing.T) { a := newAdapter(t, nil) writeConfig(t, a.ConfigPath(), contents) before := readFile(t, a.ConfigPath()) if got := a.SetDesired(context.Background(), true, activeProfile()); got.Err == nil { t.Fatalf("enable accepted %q", contents) } if after := readFile(t, a.ConfigPath()); after != before { t.Fatalf("unsafe JSON merge changed configuration to %q", after) } }) } } func TestConcurrentMutationsSerializeRecoveryAndConfiguration(t *testing.T) { files := &interleavingFS{FileSystem: dockerclient.OSFileSystem{}, firstRecovery: make(chan struct{}), secondRecovery: make(chan struct{}), secondConfig: make(chan struct{})} a := newAdapterWithFS(t, files) first := *activeProfile() first.Name = "first" first.HTTPURL = "http://first.example:8080" second := *activeProfile() second.Name = "second" second.HTTPURL = "http://second.example:8080" firstDone := make(chan target.OperationResult, 1) go func() { firstDone <- a.SetDesired(context.Background(), true, &first) }() select { case <-files.firstRecovery: case <-time.After(5 * time.Second): t.Fatal("first mutation did not reach recovery write") } secondDone := make(chan target.OperationResult, 1) go func() { secondDone <- a.SetDesired(context.Background(), true, &second) }() for name, done := range map[string]<-chan target.OperationResult{"first": firstDone, "second": secondDone} { select { case result := <-done: if result.Err != nil { t.Fatalf("%s mutation: %v", name, result.Err) } case <-time.After(5 * time.Second): t.Fatalf("%s mutation did not complete", name) } } if got := a.Inspect(context.Background(), &second); got.State != target.Enabled { t.Fatalf("final state = %s (%s), want second mutation consistently enabled", got.State, got.Evidence) } } func TestDockerClientLockSerializesAcrossProcesses(t *testing.T) { if root := os.Getenv("PROXYCTL_DOCKER_LOCK_HELPER_ROOT"); root != "" { a := dockerclient.New(dockerclient.Configuration{ ConfigFile: filepath.Join(root, ".docker", "config.json"), RecoveryFile: filepath.Join(root, ".state", "proxyctl", "docker-containers.json"), }, dockerclient.OSFileSystem{}) if result := a.SetDesired(context.Background(), true, activeProfile()); result.Err != nil { t.Fatal(result.Err) } return } a := newAdapter(t, nil) if err := os.MkdirAll(filepath.Dir(a.LockPath()), 0o700); err != nil { t.Fatal(err) } lock, err := os.OpenFile(a.LockPath(), os.O_CREATE|os.O_RDWR, 0o600) if err != nil { t.Fatal(err) } defer lock.Close() if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX); err != nil { t.Fatal(err) } root := filepath.Dir(filepath.Dir(filepath.Dir(a.RecoveryPath()))) command := exec.Command(os.Args[0], "-test.run=^TestDockerClientLockSerializesAcrossProcesses$") command.Env = append(os.Environ(), "PROXYCTL_DOCKER_LOCK_HELPER_ROOT="+root) done := make(chan error, 1) go func() { done <- command.Run() }() select { case err := <-done: t.Fatalf("child mutation did not wait for Docker client lock: %v", err) case <-time.After(100 * time.Millisecond): } if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_UN); err != nil { t.Fatal(err) } select { case err := <-done: if err != nil { t.Fatalf("child mutation after unlock: %v", err) } case <-time.After(5 * time.Second): _ = command.Process.Kill() t.Fatal("child mutation remained blocked after unlock") } if got := a.Inspect(context.Background(), activeProfile()); got.State != target.Enabled { t.Fatalf("state after serialized child mutation = %s (%s)", got.State, got.Evidence) } } func TestWritesAreOwnerOnlyAtomicAndIdempotent(t *testing.T) { files := &countingFS{FileSystem: dockerclient.OSFileSystem{}} a := newAdapterWithFS(t, files) if got := a.SetDesired(context.Background(), true, activeProfile()); got.Err != nil { t.Fatal(got.Err) } writes := files.writes if got := a.SetDesired(context.Background(), true, activeProfile()); got.Err != nil { t.Fatal(got.Err) } if files.writes != writes { t.Fatalf("idempotent enable performed %d additional writes", files.writes-writes) } for _, path := range []string{a.ConfigPath(), a.RecoveryPath(), a.LockPath()} { info, err := os.Stat(path) if err != nil { t.Fatal(err) } if info.Mode().Perm() != 0o600 { t.Errorf("%s mode = %o", path, info.Mode().Perm()) } matches, _ := filepath.Glob(filepath.Join(filepath.Dir(path), ".proxyctl-*")) if len(matches) != 0 { t.Errorf("atomic temporary files remain beside %s: %v", path, matches) } } } func TestOwnedWritesUseConfigurationOwner(t *testing.T) { root := t.TempDir() files := &ownershipFS{FileSystem: dockerclient.OSFileSystem{}} a := dockerclient.New(dockerclient.Configuration{ConfigFile: filepath.Join(root, ".docker", "config.json"), RecoveryFile: filepath.Join(root, ".state", "proxyctl", "docker-containers.json"), OwnFiles: true, UID: 1234, GID: 1235}, files) if got := a.SetDesired(context.Background(), true, activeProfile()); got.Err != nil { t.Fatal(got.Err) } if len(files.owners) != 3 { t.Fatalf("owners = %#v", files.owners) } for _, owner := range files.owners { if owner == nil || owner.UID != 1234 || owner.GID != 1235 { t.Fatalf("owner = %#v", owner) } } } func TestDescriptorAnchoredWritesDoNotFollowDockerConfigSymlinks(t *testing.T) { root, victim := t.TempDir(), t.TempDir() if err := os.Symlink(victim, filepath.Join(root, ".docker")); err != nil { t.Fatal(err) } a := dockerclient.New(dockerclient.Configuration{ConfigFile: filepath.Join(root, ".docker", "config.json"), RecoveryFile: filepath.Join(root, "state", "docker-containers.json"), TrustedRoots: []string{root}}, nil) if got := a.SetDesired(context.Background(), true, activeProfile()); got.Err == nil { t.Fatal("enable followed symlinked Docker configuration ancestor") } entries, err := os.ReadDir(victim) if err != nil || len(entries) != 0 { t.Fatalf("victim changed: %v, %v", entries, err) } } func TestErrorsRedactCredentialsIncludingRestoreFailures(t *testing.T) { files := &failingFS{FileSystem: dockerclient.OSFileSystem{}} a := newAdapterWithFS(t, files) writeConfig(t, a.ConfigPath(), `{"proxies":{"default":{"httpProxy":"http://bob:prior-secret@prior.example:3128"}}}`) if got := a.SetDesired(context.Background(), true, activeProfile()); got.Err != nil { t.Fatal(got.Err) } files.failConfig = true files.restoreLeak = true got := a.SetDesired(context.Background(), false, nil) if got.Err == nil || strings.Contains(got.Err.Error(), "prior-secret") || strings.Contains(got.Err.Error(), "secret") { t.Fatalf("unredacted restore result = %#v", got) } } func activeProfile() *profile.Profile { return &profile.Profile{Name: "office", Mode: profile.AdvancedMode, HTTPURL: "http://alice:secret@proxy.example:8080", HTTPSURL: "https://proxy.example:8443", AllURL: "socks5h://proxy.example:1080", Bypass: []string{"localhost", ".example.com", "10.0.0.0/8"}} } func newAdapter(t *testing.T, files dockerclient.FileSystem) *dockerclient.Adapter { t.Helper() if files == nil { files = dockerclient.OSFileSystem{} } return newAdapterWithFS(t, files) } func newAdapterWithFS(t *testing.T, files dockerclient.FileSystem) *dockerclient.Adapter { t.Helper() root := t.TempDir() return dockerclient.New(dockerclient.Configuration{ConfigFile: filepath.Join(root, ".docker", "config.json"), RecoveryFile: filepath.Join(root, ".state", "proxyctl", "docker-containers.json")}, files) } func writeConfig(t *testing.T, path, contents string) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { t.Fatal(err) } if err := os.WriteFile(path, []byte(contents), 0o600); err != nil { t.Fatal(err) } } func readFile(t *testing.T, path string) string { t.Helper() data, err := os.ReadFile(path) if err != nil { t.Fatal(err) } return string(data) } func readObject(t *testing.T, path string) map[string]json.RawMessage { t.Helper() var object map[string]json.RawMessage if err := json.Unmarshal([]byte(readFile(t, path)), &object); err != nil { t.Fatal(err) } return object } func writeObject(t *testing.T, path string, object map[string]json.RawMessage) { t.Helper() data, err := json.Marshal(object) if err != nil { t.Fatal(err) } writeConfig(t, path, string(data)) } func nestedObject(t *testing.T, parent map[string]json.RawMessage, key string) map[string]json.RawMessage { t.Helper() var object map[string]json.RawMessage if err := json.Unmarshal(parent[key], &object); err != nil { t.Fatalf("%s is not an object: %v", key, err) } return object } func updateDefaultKey(t *testing.T, path, key string, value json.RawMessage) { t.Helper() config := readObject(t, path) proxies := nestedObject(t, config, "proxies") defaults := nestedObject(t, proxies, "default") defaults[key] = value defaultData, err := json.Marshal(defaults) if err != nil { t.Fatal(err) } proxies["default"] = defaultData proxyData, err := json.Marshal(proxies) if err != nil { t.Fatal(err) } config["proxies"] = proxyData writeObject(t, path, config) } func assertJSONString(t *testing.T, object map[string]json.RawMessage, key, want string) { t.Helper() var got string if err := json.Unmarshal(object[key], &got); err != nil || got != want { t.Fatalf("%s = %q (%v), want %q", key, got, err, want) } } func assertState(t *testing.T, a *dockerclient.Adapter, want target.State) { t.Helper() got := a.Inspect(context.Background(), activeProfile()) if got.State != want { t.Fatalf("state = %s (%s), want %s", got.State, got.Evidence, want) } } type immediateLock struct{} func (immediateLock) Lock(context.Context) (func(), error) { return func() {}, nil } type failingFS struct { dockerclient.FileSystem permission, failConfig, leak, restoreLeak, failRecoveryOnce bool } func (f *failingFS) AtomicWrite(path string, data []byte, mode os.FileMode, owner *dockerclient.Ownership) error { if f.permission { return os.ErrPermission } if f.failRecoveryOnce && strings.HasSuffix(path, "docker-containers.json") { f.failRecoveryOnce = false return os.ErrPermission } if f.leak { return errors.New("write http://alice:secret@proxy.example:8080 failed") } if f.failConfig && strings.HasSuffix(path, "config.json") { if f.restoreLeak { return errors.New("restore http://bob:prior-secret@prior.example:3128 and http://alice:secret@proxy.example:8080 failed") } return os.ErrPermission } return f.FileSystem.AtomicWrite(path, data, mode, owner) } type concurrentChangeFS struct { dockerclient.FileSystem configPath string reads int } func (f *concurrentChangeFS) ReadFile(path string) ([]byte, error) { if path == f.configPath { f.reads++ if f.reads == 3 { if err := os.WriteFile(path, []byte(`{"auths":{"registry.example":{"auth":"external-update"}}}`), 0o600); err != nil { return nil, err } } } return f.FileSystem.ReadFile(path) } func TestConcurrentDockerConfigChangeIsNotOverwritten(t *testing.T) { files := &concurrentChangeFS{FileSystem: dockerclient.OSFileSystem{}} a := newAdapter(t, files) files.configPath = a.ConfigPath() writeConfig(t, a.ConfigPath(), `{"auths":{"registry.example":{"auth":"original"}}}`) result := a.SetDesired(context.Background(), true, activeProfile()) if result.Err == nil || !strings.Contains(result.Err.Error(), "changed concurrently") { t.Fatalf("concurrent update result = %#v", result) } if got := readFile(t, a.ConfigPath()); !strings.Contains(got, "external-update") || strings.Contains(got, "httpProxy") { t.Fatalf("external Docker config update was overwritten: %s", got) } } type countingFS struct { dockerclient.FileSystem writes int } func (f *countingFS) AtomicWrite(path string, data []byte, mode os.FileMode, owner *dockerclient.Ownership) error { f.writes++ return f.FileSystem.AtomicWrite(path, data, mode, owner) } type interleavingFS struct { dockerclient.FileSystem mu sync.Mutex recoveryWrites, configWrites int firstRecovery, secondRecovery, secondConfig chan struct{} } func (f *interleavingFS) AtomicWrite(path string, data []byte, mode os.FileMode, owner *dockerclient.Ownership) error { f.mu.Lock() isRecovery := strings.HasSuffix(path, "docker-containers.json") if isRecovery { f.recoveryWrites++ call := f.recoveryWrites if call == 1 { close(f.firstRecovery) } } else { f.configWrites++ } recoveryCall, configCall := f.recoveryWrites, f.configWrites f.mu.Unlock() if isRecovery && recoveryCall == 1 { select { case <-f.secondRecovery: case <-time.After(100 * time.Millisecond): } err := f.FileSystem.AtomicWrite(path, data, mode, owner) return err } if isRecovery && recoveryCall == 2 { err := f.FileSystem.AtomicWrite(path, data, mode, owner) close(f.secondRecovery) return err } if !isRecovery && configCall == 1 { select { case <-f.secondConfig: case <-time.After(100 * time.Millisecond): } return f.FileSystem.AtomicWrite(path, data, mode, owner) } if !isRecovery && configCall == 2 { err := f.FileSystem.AtomicWrite(path, data, mode, owner) close(f.secondConfig) return err } return f.FileSystem.AtomicWrite(path, data, mode, owner) } func (f *interleavingFS) WithLock(path string, owner *dockerclient.Ownership, action func() error) error { return f.FileSystem.WithLock(path, owner, action) } type ownershipFS struct { dockerclient.FileSystem owners []*dockerclient.Ownership } func (f *ownershipFS) AtomicWrite(path string, data []byte, mode os.FileMode, owner *dockerclient.Ownership) error { f.owners = append(f.owners, owner) return f.FileSystem.AtomicWrite(path, data, mode, nil) } func (f *ownershipFS) WithLock(path string, owner *dockerclient.Ownership, action func() error) error { f.owners = append(f.owners, owner) return f.FileSystem.WithLock(path, nil, action) }