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.
This commit is contained in:
alvarosth authored and GitHub committed 2026-10-05 20:41:29 -04:00
1 parent 9ec48c4f13
commit eeea1a633c
12 files changed
+293 -39

No files matched your search

+1 -1
View File
@@ -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{
+7 -34
View File
@@ -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
+3 -1
View File
@@ -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)
}
+56
View File
@@ -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
}
+47
View File
@@ -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)
})
}
}
+5 -1
View File
@@ -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
}
+67
View File
@@ -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)
+14
View File
@@ -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 {
@@ -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")
}
+4 -1
View File
@@ -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 {
+11 -1
View File
@@ -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)
}
+57
View File
@@ -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)
}
}