From eeea1a633c6e2ab8bea398cdbc9f16b437b62ad7 Mon Sep 17 00:00:00 2001 From: alvarosth <65863679+alvarosth@users.noreply.github.com> Date: Tue, 6 Oct 2026 02:41:29 +0200 Subject: [PATCH] registries: let the CLI policy turn the registries off (#3703) * policy: load the CLI policy file from an internal package The plugin and theme registries need to read the policy too, and internal packages cannot import package main. * registries: let the CLI policy turn the registries off With disable_registries set in /usr/share/dms/cli-policy.json or /etc/dms/cli-policy.json (the /etc file wins), the plugin and theme registries start without sources, so nothing is cloned or pulled. Installed plugins and themes keep working. --- core/cmd/dms/commands_registry.go | 2 +- core/cmd/dms/immutable_policy.go | 41 +++---------- core/cmd/dms/immutable_policy_test.go | 4 +- core/internal/clipolicy/clipolicy.go | 56 +++++++++++++++++ core/internal/clipolicy/clipolicy_test.go | 47 +++++++++++++++ core/internal/plugins/registry.go | 6 +- core/internal/plugins/registry_test.go | 67 +++++++++++++++++++++ core/internal/registries/registries.go | 14 +++++ core/internal/registries/registries_test.go | 21 +++++++ core/internal/server/plugins/list.go | 5 +- core/internal/themes/registry.go | 12 +++- core/internal/themes/registry_test.go | 57 ++++++++++++++++++ 12 files changed, 293 insertions(+), 39 deletions(-) create mode 100644 core/internal/clipolicy/clipolicy.go create mode 100644 core/internal/clipolicy/clipolicy_test.go diff --git a/core/cmd/dms/commands_registry.go b/core/cmd/dms/commands_registry.go index 56d193267..4be2796ac 100644 --- a/core/cmd/dms/commands_registry.go +++ b/core/cmd/dms/commands_registry.go @@ -12,7 +12,7 @@ import ( var registryCmd = &cobra.Command{ Use: "registry", Short: "Manage plugin and theme registries", - Long: "Manage the registries DMS fetches plugins and themes from. The official registry is always active; additional registries can be added by name and git URL.", + Long: "Manage the registries DMS fetches plugins and themes from. The official registry is always active and additional registries can be added by name and git URL, unless the CLI policy sets disable_registries.", } var registryListCmd = &cobra.Command{ diff --git a/core/cmd/dms/immutable_policy.go b/core/cmd/dms/immutable_policy.go index 921d797ba..19d841ad7 100644 --- a/core/cmd/dms/immutable_policy.go +++ b/core/cmd/dms/immutable_policy.go @@ -9,15 +9,12 @@ import ( "strings" "sync" + "github.com/AvengeMedia/DankMaterialShell/core/internal/clipolicy" "github.com/AvengeMedia/DankMaterialShell/core/internal/privesc" + "github.com/spf13/afero" "github.com/spf13/cobra" ) -const ( - cliPolicyPackagedPath = "/usr/share/dms/cli-policy.json" - cliPolicyAdminPath = "/etc/dms/cli-policy.json" -) - var ( immutablePolicyOnce sync.Once immutablePolicy immutableCommandPolicy @@ -34,13 +31,6 @@ type immutableCommandPolicy struct { Message string } -type cliPolicyFile struct { - PolicyVersion int `json:"policy_version"` - ImmutableSystem *bool `json:"immutable_system"` - BlockedCommands *[]string `json:"blocked_commands"` - Message *string `json:"message"` -} - func normalizeCommandSpec(raw string) string { normalized := strings.ToLower(strings.TrimSpace(raw)) normalized = strings.TrimPrefix(normalized, "dms ") @@ -82,25 +72,8 @@ func commandBlockedByPolicy(commandPath string, blocked []string) bool { return false } -func loadPolicyFile(path string) (*cliPolicyFile, error) { - data, err := os.ReadFile(path) - if err != nil { - if os.IsNotExist(err) { - return nil, nil - } - return nil, fmt.Errorf("failed to read %s: %w", path, err) - } - - var policy cliPolicyFile - if err := json.Unmarshal(data, &policy); err != nil { - return nil, fmt.Errorf("failed to parse %s: %w", path, err) - } - - return &policy, nil -} - func mergePolicyFile(base *immutableCommandPolicy, path string) error { - policyFile, err := loadPolicyFile(path) + policyFile, err := clipolicy.LoadFile(afero.NewOsFs(), path) if err != nil { return err } @@ -218,7 +191,7 @@ func getImmutablePolicy() (*immutableCommandPolicy, error) { Message: "This command is disabled on immutable/image-based systems. Use your distro-native workflow for system-level changes.", } - var defaultPolicy cliPolicyFile + var defaultPolicy clipolicy.File if err := json.Unmarshal(defaultCLIPolicyJSON, &defaultPolicy); err != nil { immutablePolicyErr = fmt.Errorf("failed to parse embedded default CLI policy: %w", err) return @@ -233,11 +206,11 @@ func getImmutablePolicy() (*immutableCommandPolicy, error) { } } - if err := mergePolicyFile(&immutablePolicy, cliPolicyPackagedPath); err != nil { + if err := mergePolicyFile(&immutablePolicy, clipolicy.PackagedPath); err != nil { immutablePolicyErr = err return } - if err := mergePolicyFile(&immutablePolicy, cliPolicyAdminPath); err != nil { + if err := mergePolicyFile(&immutablePolicy, clipolicy.AdminPath); err != nil { immutablePolicyErr = err return } @@ -268,7 +241,7 @@ func requireMutableSystemCommand(cmd *cobra.Command, _ []string) error { reason = "Detected immutable system: " + policy.ImmutableReason + "\n" } - return fmt.Errorf("%s%s\nCommand: dms %s\nPolicy files:\n %s\n %s", reason, policy.Message, commandPath, cliPolicyPackagedPath, cliPolicyAdminPath) + return fmt.Errorf("%s%s\nCommand: dms %s\nPolicy files:\n %s\n %s", reason, policy.Message, commandPath, clipolicy.PackagedPath, clipolicy.AdminPath) } // preRunPrivileged combines the immutable-system check with a privesc tool diff --git a/core/cmd/dms/immutable_policy_test.go b/core/cmd/dms/immutable_policy_test.go index d95dc18d2..2a5df0504 100644 --- a/core/cmd/dms/immutable_policy_test.go +++ b/core/cmd/dms/immutable_policy_test.go @@ -3,10 +3,12 @@ package main import ( "encoding/json" "testing" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/clipolicy" ) func TestDefaultImmutablePolicyAllowsSyncButBlocksEnable(t *testing.T) { - var policyFile cliPolicyFile + var policyFile clipolicy.File if err := json.Unmarshal(defaultCLIPolicyJSON, &policyFile); err != nil { t.Fatalf("failed to parse embedded CLI policy: %v", err) } diff --git a/core/internal/clipolicy/clipolicy.go b/core/internal/clipolicy/clipolicy.go new file mode 100644 index 000000000..deb454078 --- /dev/null +++ b/core/internal/clipolicy/clipolicy.go @@ -0,0 +1,56 @@ +package clipolicy + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/spf13/afero" +) + +const ( + PackagedPath = "/usr/share/dms/cli-policy.json" + AdminPath = "/etc/dms/cli-policy.json" +) + +type File struct { + PolicyVersion int `json:"policy_version"` + ImmutableSystem *bool `json:"immutable_system"` + BlockedCommands *[]string `json:"blocked_commands"` + Message *string `json:"message"` + DisableRegistries *bool `json:"disable_registries"` +} + +// LoadFile returns nil without an error when the file does not exist. +func LoadFile(fs afero.Fs, path string) (*File, error) { + data, err := afero.ReadFile(fs, path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("failed to read %s: %w", path, err) + } + + var policy File + if err := json.Unmarshal(data, &policy); err != nil { + return nil, fmt.Errorf("failed to parse %s: %w", path, err) + } + + return &policy, nil +} + +// RegistriesDisabled reports whether the policy turns off the plugin and theme +// registries. An explicit value in AdminPath overrides PackagedPath. +func RegistriesDisabled(fs afero.Fs) (bool, error) { + disabled := false + for _, path := range []string{PackagedPath, AdminPath} { + policy, err := LoadFile(fs, path) + if err != nil { + return false, err + } + if policy != nil && policy.DisableRegistries != nil { + disabled = *policy.DisableRegistries + } + } + return disabled, nil +} diff --git a/core/internal/clipolicy/clipolicy_test.go b/core/internal/clipolicy/clipolicy_test.go new file mode 100644 index 000000000..a4c3753da --- /dev/null +++ b/core/internal/clipolicy/clipolicy_test.go @@ -0,0 +1,47 @@ +package clipolicy + +import ( + "testing" + + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRegistriesDisabled(t *testing.T) { + tests := []struct { + name string + packaged string + admin string + want bool + wantErr string + }{ + {name: "no policy files"}, + {name: "packaged policy", packaged: `{"disable_registries": true}`, want: true}, + {name: "admin policy overrides packaged", packaged: `{"disable_registries": true}`, admin: `{"disable_registries": false}`}, + {name: "admin policy", admin: `{"disable_registries": true}`, want: true}, + {name: "admin policy without the key keeps packaged", packaged: `{"disable_registries": true}`, admin: `{"immutable_system": true}`, want: true}, + {name: "malformed packaged policy", packaged: "{not json", admin: `{"disable_registries": false}`, wantErr: "failed to parse " + PackagedPath}, + {name: "malformed admin policy", packaged: `{"disable_registries": false}`, admin: "{not json", wantErr: "failed to parse " + AdminPath}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fs := afero.NewMemMapFs() + if tt.packaged != "" { + require.NoError(t, afero.WriteFile(fs, PackagedPath, []byte(tt.packaged), 0o644)) + } + if tt.admin != "" { + require.NoError(t, afero.WriteFile(fs, AdminPath, []byte(tt.admin), 0o644)) + } + + disabled, err := RegistriesDisabled(fs) + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, disabled) + }) + } +} diff --git a/core/internal/plugins/registry.go b/core/internal/plugins/registry.go index 0a0b4e28f..d8989318d 100644 --- a/core/internal/plugins/registry.go +++ b/core/internal/plugins/registry.go @@ -178,10 +178,14 @@ func NewRegistry() (*Registry, error) { } func NewRegistryWithFs(fs afero.Fs) (*Registry, error) { + sources, err := registries.Active(fs) + if err != nil { + return nil, err + } return &Registry{ fs: fs, cacheDir: getCacheDir(), - registries: registries.Load(fs), + registries: sources, git: &realGitClient{}, }, nil } diff --git a/core/internal/plugins/registry_test.go b/core/internal/plugins/registry_test.go index 1946ccff9..bac113614 100644 --- a/core/internal/plugins/registry_test.go +++ b/core/internal/plugins/registry_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "github.com/AvengeMedia/DankMaterialShell/core/internal/clipolicy" "github.com/AvengeMedia/DankMaterialShell/core/internal/registries" "github.com/go-git/go-git/v6" "github.com/go-git/go-git/v6/plumbing" @@ -313,6 +314,72 @@ func TestList(t *testing.T) { }) } +func setupDisabledRegistry(t *testing.T) (*Registry, afero.Fs) { + t.Helper() + t.Setenv("XDG_CONFIG_HOME", "/xdg") + fs := afero.NewMemMapFs() + require.NoError(t, registries.Add(fs, "extra", "https://example.com/extra.git")) + require.NoError(t, afero.WriteFile(fs, clipolicy.AdminPath, []byte(`{"disable_registries": true}`), 0o644)) + registry, err := NewRegistryWithFs(fs) + require.NoError(t, err) + return registry, fs +} + +func TestRegistriesDisabledByPolicy(t *testing.T) { + t.Run("does not clone", func(t *testing.T) { + registry, _ := setupDisabledRegistry(t) + + var cloned []string + registry.git = &mockGitClient{ + cloneFunc: func(path string, url string) error { + cloned = append(cloned, url) + return nil + }, + } + + plugins, err := registry.List() + assert.NoError(t, err) + assert.Empty(t, plugins) + assert.Empty(t, cloned) + }) + + t.Run("does not pull a cached clone", func(t *testing.T) { + registry, fs := setupDisabledRegistry(t) + + origins := make(map[string]string) + for _, src := range registries.Load(fs) { + dir := filepath.Join(registry.cacheDir, src.Name) + require.NoError(t, fs.MkdirAll(dir, 0o755)) + origins[dir] = src.URL + } + require.Len(t, origins, 2) + + var pulled []string + registry.git = &mockGitClient{ + originFunc: func(path string) (string, error) { + return origins[path], nil + }, + pullFunc: func(path string) error { + pulled = append(pulled, path) + return nil + }, + } + + plugins, err := registry.List() + assert.NoError(t, err) + assert.Empty(t, plugins) + assert.Empty(t, pulled) + }) + + t.Run("malformed policy fails construction", func(t *testing.T) { + fs := afero.NewMemMapFs() + require.NoError(t, afero.WriteFile(fs, clipolicy.AdminPath, []byte("{not json"), 0o644)) + + _, err := NewRegistryWithFs(fs) + assert.Error(t, err) + }) +} + func TestUpdate(t *testing.T) { t.Run("clones repository when cache doesn't exist", func(t *testing.T) { registry, fs, tmpDir := setupTestRegistry(t) diff --git a/core/internal/registries/registries.go b/core/internal/registries/registries.go index ebbb94f8b..a2a36f464 100644 --- a/core/internal/registries/registries.go +++ b/core/internal/registries/registries.go @@ -8,6 +8,7 @@ import ( "regexp" "strings" + "github.com/AvengeMedia/DankMaterialShell/core/internal/clipolicy" "github.com/spf13/afero" ) @@ -63,6 +64,19 @@ func Load(fs afero.Fs) []Source { return sources } +// Active returns the registries to fetch from: none when the CLI policy +// disables them, otherwise Load. +func Active(fs afero.Fs) ([]Source, error) { + disabled, err := clipolicy.RegistriesDisabled(fs) + if err != nil { + return nil, err + } + if disabled { + return nil, nil + } + return Load(fs), nil +} + func saveExtras(fs afero.Fs, extras []Source) error { path, err := configPath() if err != nil { diff --git a/core/internal/registries/registries_test.go b/core/internal/registries/registries_test.go index f4aae075c..114116252 100644 --- a/core/internal/registries/registries_test.go +++ b/core/internal/registries/registries_test.go @@ -3,6 +3,7 @@ package registries import ( "testing" + "github.com/AvengeMedia/DankMaterialShell/core/internal/clipolicy" "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -77,3 +78,23 @@ func TestLoadIgnoresInvalidConfig(t *testing.T) { assert.Equal(t, "ok", sources[1].Name) assert.Equal(t, officialURL, sources[0].URL, "official cannot be spoofed from config") } + +func TestActive(t *testing.T) { + fs := setupFs(t) + require.NoError(t, Add(fs, "extra", "https://example.com/extra.git")) + + sources, err := Active(fs) + require.NoError(t, err) + require.Len(t, sources, 2) + assert.Equal(t, Load(fs), sources, "registries are on without a policy") + + require.NoError(t, afero.WriteFile(fs, clipolicy.AdminPath, []byte(`{"disable_registries": true}`), 0o644)) + sources, err = Active(fs) + require.NoError(t, err) + assert.Empty(t, sources, "the extra registry is off too") + assert.Len(t, Load(fs), 2, "Load still reports the configured registries") + + require.NoError(t, afero.WriteFile(fs, clipolicy.AdminPath, []byte("{not json"), 0o644)) + _, err = Active(fs) + assert.Error(t, err, "a malformed policy is not ignored") +} diff --git a/core/internal/server/plugins/list.go b/core/internal/server/plugins/list.go index b6f92ff92..32c20e808 100644 --- a/core/internal/server/plugins/list.go +++ b/core/internal/server/plugins/list.go @@ -27,7 +27,10 @@ func HandleList(conn *ipc.ConnWriter, req ipc.Request) { return } - feedback := plugins.FetchFeedback() + var feedback map[string]plugins.Feedback + if len(pluginList) > 0 { + feedback = plugins.FetchFeedback() + } result := make([]PluginInfo, len(pluginList)) for i, p := range pluginList { diff --git a/core/internal/themes/registry.go b/core/internal/themes/registry.go index a6f5fe61b..7630c9077 100644 --- a/core/internal/themes/registry.go +++ b/core/internal/themes/registry.go @@ -211,10 +211,14 @@ func NewRegistry() (*Registry, error) { } func NewRegistryWithFs(fs afero.Fs) (*Registry, error) { + sources, err := registries.Active(fs) + if err != nil { + return nil, err + } return &Registry{ fs: fs, cacheDir: getCacheDir(), - registries: registries.Load(fs), + registries: sources, git: &realGitClient{}, }, nil } @@ -405,6 +409,9 @@ func (r *Registry) GetThemeSourcePath(themeID string) string { return candidate } } + if len(r.registries) == 0 { + return "" + } // Fallback to first registry (legacy path semantics). return filepath.Join(r.cacheDirFor(r.registries[0]), "themes", themeID, "theme.json") } @@ -416,6 +423,9 @@ func (r *Registry) GetThemeDir(themeID string) string { return candidate } } + if len(r.registries) == 0 { + return "" + } return filepath.Join(r.cacheDirFor(r.registries[0]), "themes", themeID) } diff --git a/core/internal/themes/registry_test.go b/core/internal/themes/registry_test.go index 858430afd..9f0b98f73 100644 --- a/core/internal/themes/registry_test.go +++ b/core/internal/themes/registry_test.go @@ -4,6 +4,7 @@ import ( "os" "testing" + "github.com/AvengeMedia/DankMaterialShell/core/internal/clipolicy" "github.com/AvengeMedia/DankMaterialShell/core/internal/registries" "github.com/spf13/afero" ) @@ -136,3 +137,59 @@ func TestUpdateMultiRegistry(t *testing.T) { t.Fatalf("expected theme source under official registry, got %q", path) } } + +func TestRegistriesDisabledByPolicy(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", "/xdg") + fs := afero.NewMemMapFs() + if err := registries.Add(fs, "extra", "https://example.com/extra.git"); err != nil { + t.Fatal(err) + } + if err := afero.WriteFile(fs, clipolicy.AdminPath, []byte(`{"disable_registries": true}`), 0o644); err != nil { + t.Fatal(err) + } + + r, err := NewRegistryWithFs(fs) + if err != nil { + t.Fatalf("NewRegistryWithFs: %v", err) + } + var cloned []string + r.git = &stubGitClient{ + cloneFunc: func(path string, url string) error { + cloned = append(cloned, url) + return nil + }, + } + + list, err := r.List() + if err != nil { + t.Fatalf("List: %v", err) + } + if len(list) != 0 { + t.Fatalf("expected no themes, got %d", len(list)) + } + if len(cloned) != 0 { + t.Fatalf("expected no registry clone, got %v", cloned) + } +} + +func TestNewRegistryWithFsMalformedPolicy(t *testing.T) { + fs := afero.NewMemMapFs() + if err := afero.WriteFile(fs, clipolicy.AdminPath, []byte("{not json"), 0o644); err != nil { + t.Fatal(err) + } + + if _, err := NewRegistryWithFs(fs); err == nil { + t.Fatal("expected an error for a malformed policy file") + } +} + +func TestThemePathsWithoutRegistries(t *testing.T) { + r := &Registry{fs: afero.NewMemMapFs(), cacheDir: "/test-cache"} + + if dir := r.GetThemeDir("one"); dir != "" { + t.Fatalf("expected no theme dir, got %q", dir) + } + if path := r.GetThemeSourcePath("one"); path != "" { + t.Fatalf("expected no theme source, got %q", path) + } +}