mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-09 13:31:26 -10:00
* feat(plugins): add CLI enable/disable for plugin.json
Add zero plugins enable|disable so installed plugins can be toggled
without removing them, matching hooks/MCP parity and EXTENDING docs.
* fix(plugins): serialize toggles with installs and preserve manifest bytes
Address review findings on the enable/disable CLI:
- Take the same cross-process installtxn lock plugins add/remove take on
the plugin's install root before the final lookup and read, and publish
plugin.json with installtxn.WriteFileAtomically while holding it.
- Re-resolve the plugin under the lock so a concurrent install can never
be overwritten with manifest bytes read before it committed, retrying
when precedence moves to the other root; a plugin removed under the
lock reports ErrNotInstalled instead of being recreated.
- Edit the enabled value in place (a targeted token walk) so field
order, formatting, and numeric literals survive; the old
decode/re-encode dropped precision (9007199254740993 became
9007199254740992) and sorted the keys.
- Fail closed on manifest stat errors, refresh a clean plugins.lock hash
after a toggle, and leave pre-existing drift in place instead of
absorbing it into the lockfile.
- Redact the text-mode output, reject an empty plugin id as a usage
error, and document project-over-user precedence in the help text.
* fix(plugins): recover interrupted install transactions before toggling
SetEnabledByID takes the plugin root's installtxn lock but went straight
to reading and rewriting the tree. installtxn.Recover requires every
holder of that lock to resolve interrupted transactions first and to
abort on its error, as Install and Remove already do.
Without it, a toggle after an install killed between its tree swap and
its lockfile publish edited the unpublished tree, and the next install
or removal restored the retained backup over it, silently undoing the
enable/disable. A transaction recovery could not resolve also failed to
stop the toggle.
Call installtxn.Recover with the plugins reconciler right after taking
the lock, and add regression tests for every interrupted state that
leaves the plugin discoverable, the unresolved-transaction abort, and a
plugin removed while the toggle waits on the lock.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* docs(plugins): say when a plugin toggle takes effect
Zero reads plugin.json "enabled" only when it activates plugins at
startup (runExec and the interactive TUI bootstrap), and nothing reloads
them afterwards. A running interactive session therefore keeps the
tools, hooks, and skills of a plugin disabled from another terminal.
State that in the plugins docs and in the enable/disable help so the
documented activation rule matches what the command does.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(plugins): pin toggled manifests, redact toggle errors, honor last enabled key
Address the review findings on the plugin enable/disable toggle.
Pin the plugin directory before editing its manifest. The install lock
only serializes cooperating installers, so swapping the plugin directory
for a link after discovery redirected the read and the atomic publish
through it, overwriting another manifest. The toggle now opens the
directory under its install root with pathjail, refuses a link or
reparse point, checks that the handle is the directory entry itself,
and reads, stats, creates the temporary file, and renames through that
handle.
Redact every stderr message the toggle writes. Parser and not-installed
usage errors echoed the raw argument, so a mistyped credential reached
stderr unredacted while the toggle's other output was redacted.
Validate only the last "enabled" value, as the loader does. The loader
keeps the last duplicate key, but the toggle rejected an earlier,
overwritten non-boolean value, so a plugin discovered as enabled could
not be toggled.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(plugins): keep the toggle on the pinned plugin directory throughout
The previous fix pinned the manifest read and publish to a handle on the
plugin directory but still hashed the tree for plugins.lock by path, and
reported success without checking that the path still named the pinned
directory. If the directory was replaced after it was opened, the edit
landed in the moved-away original while the lockfile check and refresh
read the replacement, and the command reported a state the installed
plugin did not have.
Hash the tree before and after the edit through the pinned handle, and
confirm that the plugin's directory entry is still the pinned directory
before refreshing the lockfile or reporting any result, including a
no-op. hashTree now delegates to the same rooted walker, so path and
handle callers hash identical bytes.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(plugins): hash plugin trees through native os.Root calls
20b484f moved hashTree onto an os.Root handle but walked it through
handle.FS(). That adapter accepts only fs.ValidPath names, which must be
UTF-8, so a plugin holding a Linux-valid name such as byte 0xff failed
with "lstat ...: invalid argument". copyTree and the previous walker
accept those names, so Install, the toggle's lock refresh, and Info's
drift check all regressed for such plugins.
Walk the handle with its own Open/Lstat methods instead. The walk stays
pinned to the directory, and the hash bytes match the merge-base
implementation (checked on a fixture with nested and non-UTF-8 names,
an exec bit, a symlink, .git and a FIFO).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Ayush7614 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
1000 lines
32 KiB
Go
1000 lines
32 KiB
Go
package plugins
|
|
|
|
import (
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/Twigpine/zero/internal/installtxn"
|
|
"github.com/Twigpine/zero/internal/pathjail"
|
|
)
|
|
|
|
func enableTestOptions(root string) LoadOptions {
|
|
return LoadOptions{Roots: []Root{{Source: SourceUser, Path: root}}}
|
|
}
|
|
|
|
func writeRawManifest(t *testing.T, root, dir, raw string) string {
|
|
t.Helper()
|
|
pluginDir := filepath.Join(root, dir)
|
|
if err := os.MkdirAll(pluginDir, 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
manifestPath := filepath.Join(pluginDir, "plugin.json")
|
|
if err := os.WriteFile(manifestPath, []byte(raw), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return manifestPath
|
|
}
|
|
|
|
func demoManifest(enabled bool) string {
|
|
state := "false"
|
|
if enabled {
|
|
state = "true"
|
|
}
|
|
return `{
|
|
"schemaVersion": 1,
|
|
"id": "zero.demo",
|
|
"name": "Demo",
|
|
"version": "1.0.0",
|
|
"enabled": ` + state + `
|
|
}
|
|
`
|
|
}
|
|
|
|
func TestSetEnabledByIDPreservesManifestBytes(t *testing.T) {
|
|
root := t.TempDir()
|
|
raw := `{
|
|
"schemaVersion": 1,
|
|
"id": "zero.demo",
|
|
"name": "Demo",
|
|
"version": "1.0.0",
|
|
"limits": {
|
|
"maxBytes": 9007199254740993
|
|
},
|
|
"description": "keep me",
|
|
"enabled": true,
|
|
"tools": []
|
|
}
|
|
`
|
|
manifestPath := writeRawManifest(t, root, "demo", raw)
|
|
|
|
result, err := SetEnabledByID(enableTestOptions(root), "zero.demo", false)
|
|
if err != nil {
|
|
t.Fatalf("SetEnabledByID(false): %v", err)
|
|
}
|
|
if !result.Changed || result.Enabled {
|
|
t.Fatalf("unexpected result: %#v", result)
|
|
}
|
|
|
|
disabled, err := os.ReadFile(manifestPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := strings.Replace(raw, `"enabled": true`, `"enabled": false`, 1)
|
|
if string(disabled) != want {
|
|
t.Fatalf("toggle changed more than the enabled value:\n got %q\nwant %q", disabled, want)
|
|
}
|
|
if !strings.Contains(string(disabled), "9007199254740993") {
|
|
t.Fatal("large integer literal was rounded by the toggle")
|
|
}
|
|
|
|
plugin, ok, err := FindByID(enableTestOptions(root), "zero.demo")
|
|
if err != nil || !ok {
|
|
t.Fatalf("FindByID after disable: ok=%v err=%v", ok, err)
|
|
}
|
|
if plugin.Enabled {
|
|
t.Fatal("loader still reports the plugin enabled after disable")
|
|
}
|
|
|
|
result, err = SetEnabledByID(enableTestOptions(root), "zero.demo", true)
|
|
if err != nil {
|
|
t.Fatalf("SetEnabledByID(true): %v", err)
|
|
}
|
|
if !result.Changed || !result.Enabled {
|
|
t.Fatalf("unexpected enable result: %#v", result)
|
|
}
|
|
enabled, err := os.ReadFile(manifestPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(enabled) != raw {
|
|
t.Fatalf("round trip did not restore the original bytes:\n got %q\nwant %q", enabled, raw)
|
|
}
|
|
}
|
|
|
|
func TestSetEnabledByIDTogglesManifestAndIsIdempotent(t *testing.T) {
|
|
root := t.TempDir()
|
|
writePluginManifest(t, filepath.Join(root, "demo"), map[string]any{
|
|
"schemaVersion": 1,
|
|
"id": "zero.demo",
|
|
"name": "Demo",
|
|
"version": "1.0.0",
|
|
"description": "keep me",
|
|
"tools": []any{},
|
|
})
|
|
manifestPath := filepath.Join(root, "demo", "plugin.json")
|
|
|
|
result, err := SetEnabledByID(enableTestOptions(root), "zero.demo", false)
|
|
if err != nil {
|
|
t.Fatalf("SetEnabledByID(false): %v", err)
|
|
}
|
|
if !result.Changed {
|
|
t.Fatal("expected first disable to change the manifest")
|
|
}
|
|
raw, err := os.ReadFile(manifestPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(string(raw), `"enabled": false`) {
|
|
t.Fatalf("manifest not disabled: %s", raw)
|
|
}
|
|
if !strings.Contains(string(raw), `"keep me"`) {
|
|
t.Fatalf("description was not preserved: %s", raw)
|
|
}
|
|
|
|
result, err = SetEnabledByID(enableTestOptions(root), "zero.demo", false)
|
|
if err != nil {
|
|
t.Fatalf("SetEnabledByID(false) again: %v", err)
|
|
}
|
|
if result.Changed {
|
|
t.Fatal("expected second disable to be a no-op")
|
|
}
|
|
|
|
result, err = SetEnabledByID(enableTestOptions(root), "zero.demo", true)
|
|
if err != nil {
|
|
t.Fatalf("SetEnabledByID(true): %v", err)
|
|
}
|
|
if !result.Changed {
|
|
t.Fatal("expected enable to change the manifest")
|
|
}
|
|
}
|
|
|
|
func TestSetEnabledByIDInsertsMissingField(t *testing.T) {
|
|
t.Run("pretty manifest", func(t *testing.T) {
|
|
root := t.TempDir()
|
|
raw := `{
|
|
"schemaVersion": 1,
|
|
"id": "zero.demo",
|
|
"name": "Demo",
|
|
"version": "1.0.0"
|
|
}
|
|
`
|
|
manifestPath := writeRawManifest(t, root, "demo", raw)
|
|
|
|
if _, err := SetEnabledByID(enableTestOptions(root), "zero.demo", false); err != nil {
|
|
t.Fatalf("SetEnabledByID(false): %v", err)
|
|
}
|
|
disabled, err := os.ReadFile(manifestPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
wantDisabled := `{
|
|
"schemaVersion": 1,
|
|
"id": "zero.demo",
|
|
"name": "Demo",
|
|
"version": "1.0.0",
|
|
"enabled": false
|
|
}
|
|
`
|
|
if string(disabled) != wantDisabled {
|
|
t.Fatalf("after insert:\n got %q\nwant %q", disabled, wantDisabled)
|
|
}
|
|
|
|
if _, err := SetEnabledByID(enableTestOptions(root), "zero.demo", true); err != nil {
|
|
t.Fatalf("SetEnabledByID(true): %v", err)
|
|
}
|
|
enabled, err := os.ReadFile(manifestPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
wantEnabled := strings.Replace(wantDisabled, `"enabled": false`, `"enabled": true`, 1)
|
|
if string(enabled) != wantEnabled {
|
|
t.Fatalf("after replace:\n got %q\nwant %q", enabled, wantEnabled)
|
|
}
|
|
})
|
|
|
|
t.Run("compact manifest", func(t *testing.T) {
|
|
root := t.TempDir()
|
|
raw := `{"schemaVersion":1,"id":"zero.demo","name":"Demo","version":"1.0.0"}`
|
|
manifestPath := writeRawManifest(t, root, "demo", raw)
|
|
|
|
if _, err := SetEnabledByID(enableTestOptions(root), "zero.demo", false); err != nil {
|
|
t.Fatalf("SetEnabledByID(false): %v", err)
|
|
}
|
|
disabled, err := os.ReadFile(manifestPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := `{"schemaVersion":1,"id":"zero.demo","name":"Demo","version":"1.0.0","enabled": false}`
|
|
if string(disabled) != want {
|
|
t.Fatalf("compact insert:\n got %q\nwant %q", disabled, want)
|
|
}
|
|
})
|
|
|
|
t.Run("null value", func(t *testing.T) {
|
|
root := t.TempDir()
|
|
raw := `{
|
|
"schemaVersion": 1,
|
|
"id": "zero.demo",
|
|
"name": "Demo",
|
|
"version": "1.0.0",
|
|
"enabled": null
|
|
}
|
|
`
|
|
manifestPath := writeRawManifest(t, root, "demo", raw)
|
|
|
|
result, err := SetEnabledByID(enableTestOptions(root), "zero.demo", true)
|
|
if err != nil {
|
|
t.Fatalf("SetEnabledByID(true): %v", err)
|
|
}
|
|
if result.Changed {
|
|
t.Fatal("null counts as enabled, so enabling must be a no-op")
|
|
}
|
|
unchanged, err := os.ReadFile(manifestPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(unchanged) != raw {
|
|
t.Fatalf("no-op toggle rewrote the manifest: %q", unchanged)
|
|
}
|
|
|
|
if _, err := SetEnabledByID(enableTestOptions(root), "zero.demo", false); err != nil {
|
|
t.Fatalf("SetEnabledByID(false): %v", err)
|
|
}
|
|
disabled, err := os.ReadFile(manifestPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := strings.Replace(raw, `"enabled": null`, `"enabled": false`, 1)
|
|
if string(disabled) != want {
|
|
t.Fatalf("null replacement:\n got %q\nwant %q", disabled, want)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestToggleEnabledManifestFormattingEdges(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
in string
|
|
enabled bool
|
|
want string
|
|
changed bool
|
|
}{
|
|
{"empty object", `{}`, false, `{"enabled": false}`, true},
|
|
{"empty object with space", `{ }`, false, `{"enabled": false }`, true},
|
|
{"empty multiline", "{\n}", false, "{\n \"enabled\": false\n}", true},
|
|
{"compact members", `{"a":1}`, false, `{"a":1,"enabled": false}`, true},
|
|
{"crlf members", "{\r\n \"a\": 1\r\n}", false, "{\r\n \"a\": 1,\r\n \"enabled\": false\r\n}", true},
|
|
{"nested enabled untouched", `{"nested":{"enabled":true},"a":2}`, false, `{"nested":{"enabled":true},"a":2,"enabled": false}`, true},
|
|
{"replace with true", `{"enabled": false}`, true, `{"enabled": true}`, true},
|
|
{"replace with false", `{"enabled":true}`, false, `{"enabled":false}`, true},
|
|
{"null to false", `{"enabled": null}`, false, `{"enabled": false}`, true},
|
|
{"null to true is a no-op", `{"enabled": null}`, true, `{"enabled": null}`, false},
|
|
{"already enabled is a no-op", `{"enabled": true}`, true, `{"enabled": true}`, false},
|
|
// The loader keeps the last duplicate, so an overwritten non-boolean
|
|
// value is not an error and only the last value is rewritten.
|
|
{"overwritten string", `{"enabled":"obsolete","enabled":true}`, false, `{"enabled":"obsolete","enabled":false}`, true},
|
|
{"overwritten composite", `{"enabled":{"a":[1,{"enabled":2}]},"enabled":null}`, false, `{"enabled":{"a":[1,{"enabled":2}]},"enabled":false}`, true},
|
|
{"overwritten number is a no-op", `{"enabled":1,"enabled":false}`, false, `{"enabled":1,"enabled":false}`, false},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
out, changed, err := toggleEnabledManifest([]byte(tc.in), tc.enabled)
|
|
if err != nil {
|
|
t.Fatalf("toggleEnabledManifest: %v", err)
|
|
}
|
|
if changed != tc.changed {
|
|
t.Fatalf("changed = %v, want %v", changed, tc.changed)
|
|
}
|
|
if !tc.changed {
|
|
if out != nil {
|
|
t.Fatalf("no-op returned bytes: %q", out)
|
|
}
|
|
return
|
|
}
|
|
if string(out) != tc.want {
|
|
t.Fatalf("got %q, want %q", out, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestToggleEnabledManifestRejectsMalformedInput(t *testing.T) {
|
|
for _, raw := range []string{`{"enabled": "yes"}`, `{"enabled": 1}`, `{"enabled": []}`, `{"enabled": {}}`, `{"enabled": true, "enabled": "yes"}`} {
|
|
t.Run("value "+raw, func(t *testing.T) {
|
|
_, _, err := toggleEnabledManifest([]byte(raw), false)
|
|
if err == nil {
|
|
t.Fatal("expected error")
|
|
}
|
|
var manifestErr ManifestError
|
|
if !errors.As(err, &manifestErr) {
|
|
t.Fatalf("error = %v, want ManifestError", err)
|
|
}
|
|
if !strings.Contains(err.Error(), "enabled") {
|
|
t.Fatalf("error = %v, want it to name the enabled field", err)
|
|
}
|
|
})
|
|
}
|
|
for _, raw := range []string{``, `{`, `[]`, `null`, `"str"`, `{} {}`, `{}{}`} {
|
|
t.Run("document "+raw, func(t *testing.T) {
|
|
if _, _, err := toggleEnabledManifest([]byte(raw), false); err == nil {
|
|
t.Fatalf("toggleEnabledManifest(%q) succeeded, want error", raw)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestSetEnabledByIDHonorsTheLastDuplicateEnabledValue keeps the toggle in step
|
|
// with the loader on a manifest whose earlier "enabled" value is overwritten:
|
|
// the loader discovers the plugin from the last value, so the toggle must be
|
|
// able to change it too.
|
|
func TestSetEnabledByIDHonorsTheLastDuplicateEnabledValue(t *testing.T) {
|
|
root := t.TempDir()
|
|
writeRawManifest(t, root, "demo", `{
|
|
"schemaVersion": 1,
|
|
"id": "zero.demo",
|
|
"name": "Demo",
|
|
"version": "1.0.0",
|
|
"enabled": "obsolete",
|
|
"enabled": true
|
|
}
|
|
`)
|
|
loadedEnabled := func() bool {
|
|
t.Helper()
|
|
plugin, ok, err := FindByID(enableTestOptions(root), "zero.demo")
|
|
if err != nil || !ok {
|
|
t.Fatalf("FindByID: ok=%v err=%v", ok, err)
|
|
}
|
|
return plugin.Enabled
|
|
}
|
|
if !loadedEnabled() {
|
|
t.Fatal("the loader should discover the plugin as enabled from the last value")
|
|
}
|
|
for _, enabled := range []bool{false, true} {
|
|
result, err := SetEnabledByID(enableTestOptions(root), "zero.demo", enabled)
|
|
if err != nil {
|
|
t.Fatalf("SetEnabledByID(%v): %v", enabled, err)
|
|
}
|
|
if !result.Changed {
|
|
t.Fatalf("SetEnabledByID(%v) reported no change", enabled)
|
|
}
|
|
if got := loadedEnabled(); got != enabled {
|
|
t.Fatalf("after SetEnabledByID(%v) the loader sees enabled=%v", enabled, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestSetEnabledRefusesASwappedPluginDirectory swaps the plugin directory that
|
|
// discovery found for a link between the lookup and the edit. The install lock
|
|
// does not cover that window, because it only serializes cooperating
|
|
// installers. The edit must fail without following the link: neither the
|
|
// link's target nor the moved-away original may change.
|
|
func TestSetEnabledRefusesASwappedPluginDirectory(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
// link returns the symlink target to plant and the manifest it leads to.
|
|
link func(t *testing.T, root string) (target string, manifest string)
|
|
}{
|
|
{"to a directory outside the plugins root", func(t *testing.T, root string) (string, string) {
|
|
manifest := writeRawManifest(t, t.TempDir(), "elsewhere", demoManifest(true))
|
|
return filepath.Dir(manifest), manifest
|
|
}},
|
|
{"to another plugin in the same root", func(t *testing.T, root string) (string, string) {
|
|
// A relative link stays inside the root, so confinement to the
|
|
// root alone would follow it.
|
|
manifest := writeRawManifest(t, root, "other", strings.Replace(demoManifest(true), "zero.demo", "zero.other", 1))
|
|
return "other", manifest
|
|
}},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
root := t.TempDir()
|
|
raw := demoManifest(true)
|
|
writeRawManifest(t, root, "demo", raw)
|
|
plugin, ok, err := FindByID(enableTestOptions(root), "zero.demo")
|
|
if err != nil || !ok {
|
|
t.Fatalf("FindByID: ok=%v err=%v", ok, err)
|
|
}
|
|
target, targetManifest := tc.link(t, root)
|
|
targetBefore, err := os.ReadFile(targetManifest)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
moved := filepath.Join(t.TempDir(), "moved-away")
|
|
if err := os.Rename(plugin.PluginDir, moved); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Symlink(target, plugin.PluginDir); err != nil {
|
|
t.Skipf("cannot create a directory symlink here: %v", err)
|
|
}
|
|
|
|
if _, err := setEnabledLocked(plugin, false); !errors.Is(err, pathjail.ErrReparse) {
|
|
t.Fatalf("setEnabledLocked through a swapped directory: err = %v, want %v", err, pathjail.ErrReparse)
|
|
}
|
|
if after, err := os.ReadFile(targetManifest); err != nil || string(after) != string(targetBefore) {
|
|
t.Fatalf("the edit followed the link and changed %s (err %v):\n got %q\nwant %q", targetManifest, err, after, targetBefore)
|
|
}
|
|
if after, err := os.ReadFile(filepath.Join(moved, "plugin.json")); err != nil || string(after) != raw {
|
|
t.Fatalf("the moved-away manifest changed (err %v): %q", err, after)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestSetEnabledDetectsAPluginDirectoryReplacedAfterOpening replaces the live
|
|
// plugin directory after the toggle pinned it. The edit then lands in the
|
|
// moved-away directory, so the toggle must not refresh the lockfile from either
|
|
// tree and must not report success: the plugin installed at the path now is
|
|
// unchanged.
|
|
func TestSetEnabledDetectsAPluginDirectoryReplacedAfterOpening(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
pinned bool // the pinned manifest's enabled value
|
|
changes bool // whether disabling it is a real edit
|
|
}{
|
|
{"edit", true, true},
|
|
{"no-op", false, false},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
root := t.TempDir()
|
|
manifestPath := writeRawManifest(t, root, "demo", demoManifest(tc.pinned))
|
|
pluginDir := filepath.Dir(manifestPath)
|
|
hash, err := hashTree(pluginDir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := writeLock(root, map[string]LockEntry{"zero.demo": {Source: "./demo", Hash: hash}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
lockPath := filepath.Join(root, LockFileName)
|
|
lockBefore, err := os.ReadFile(lockPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
plugin, ok, err := FindByID(enableTestOptions(root), "zero.demo")
|
|
if err != nil || !ok {
|
|
t.Fatalf("FindByID: ok=%v err=%v", ok, err)
|
|
}
|
|
pinned, err := openPluginDir(plugin)
|
|
if err != nil {
|
|
t.Fatalf("openPluginDir: %v", err)
|
|
}
|
|
defer pinned.Close()
|
|
|
|
moved := filepath.Join(t.TempDir(), "moved-away")
|
|
if err := os.Rename(pluginDir, moved); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
replacement := strings.Replace(demoManifest(true), `"version": "1.0.0"`, `"version": "2.0.0"`, 1)
|
|
writeRawManifest(t, root, "demo", replacement)
|
|
|
|
// The lockfile check hashes the pinned tree, which still matches the
|
|
// recorded hash, not the replacement now at the path.
|
|
if _, _, clean := cleanLockEntry(plugin, pinned.dir); !clean {
|
|
t.Error("cleanLockEntry hashed the replacement at the path instead of the pinned directory")
|
|
}
|
|
|
|
if _, err := setEnabledPinned(plugin, pinned, false); !errors.Is(err, errPluginDirReplaced) {
|
|
t.Fatalf("setEnabledPinned after the directory was replaced: err = %v, want %v", err, errPluginDirReplaced)
|
|
}
|
|
if after, err := os.ReadFile(manifestPath); err != nil || string(after) != replacement {
|
|
t.Fatalf("the replacement manifest changed (err %v): %q", err, after)
|
|
}
|
|
if after, err := os.ReadFile(lockPath); err != nil || string(after) != string(lockBefore) {
|
|
t.Fatalf("the lockfile changed (err %v):\n got %s\nwant %s", err, after, lockBefore)
|
|
}
|
|
movedRaw, err := os.ReadFile(filepath.Join(moved, "plugin.json"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if tc.changes != (string(movedRaw) != demoManifest(tc.pinned)) {
|
|
t.Fatalf("pinned manifest edit = %v, want %v: %q", !tc.changes, tc.changes, movedRaw)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSetEnabledByIDRespectsUserOnlyFilter(t *testing.T) {
|
|
dir := t.TempDir()
|
|
userRoot := filepath.Join(dir, "user")
|
|
projectRoot := filepath.Join(dir, "project")
|
|
writePluginManifest(t, filepath.Join(userRoot, "demo"), map[string]any{
|
|
"schemaVersion": 1,
|
|
"id": "zero.demo",
|
|
"name": "User Demo",
|
|
"version": "0.1.0",
|
|
"enabled": true,
|
|
})
|
|
writePluginManifest(t, filepath.Join(projectRoot, "demo"), map[string]any{
|
|
"schemaVersion": 1,
|
|
"id": "zero.demo",
|
|
"name": "Project Demo",
|
|
"version": "0.2.0",
|
|
"enabled": true,
|
|
})
|
|
|
|
result, err := SetEnabledByID(LoadOptions{
|
|
Roots: []Root{
|
|
{Source: SourceUser, Path: userRoot},
|
|
{Source: SourceProject, Path: projectRoot},
|
|
},
|
|
}, "zero.demo", false)
|
|
if err != nil {
|
|
t.Fatalf("SetEnabledByID: %v", err)
|
|
}
|
|
if result.Source != SourceProject {
|
|
t.Fatalf("source = %s, want project (precedence)", result.Source)
|
|
}
|
|
if !result.Changed || result.Enabled {
|
|
t.Fatalf("unexpected result: %#v", result)
|
|
}
|
|
|
|
userOnly, err := SetEnabledByID(LoadOptions{
|
|
Roots: []Root{
|
|
{Source: SourceUser, Path: userRoot},
|
|
{Source: SourceProject, Path: projectRoot},
|
|
},
|
|
ExcludeProject: true,
|
|
}, "zero.demo", false)
|
|
if err != nil {
|
|
t.Fatalf("SetEnabledByID user-only: %v", err)
|
|
}
|
|
if userOnly.Source != SourceUser {
|
|
t.Fatalf("user-only source = %s, want user", userOnly.Source)
|
|
}
|
|
}
|
|
|
|
func TestSetEnabledPreservesManifestMode(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("Windows does not preserve chmod bits in os.Stat mode")
|
|
}
|
|
root := t.TempDir()
|
|
writePluginManifest(t, filepath.Join(root, "demo"), map[string]any{
|
|
"schemaVersion": 1,
|
|
"id": "zero.demo",
|
|
"name": "Demo",
|
|
"version": "1.0.0",
|
|
})
|
|
manifestPath := filepath.Join(root, "demo", "plugin.json")
|
|
if err := os.Chmod(manifestPath, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if _, err := SetEnabledByID(enableTestOptions(root), "zero.demo", false); err != nil {
|
|
t.Fatalf("SetEnabledByID: %v", err)
|
|
}
|
|
info, err := os.Stat(manifestPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := info.Mode().Perm(); got != 0o644 {
|
|
t.Fatalf("mode = %04o, want 0644", got)
|
|
}
|
|
}
|
|
|
|
func TestSetEnabledByIDMissingPlugin(t *testing.T) {
|
|
_, err := SetEnabledByID(LoadOptions{Roots: []Root{{Source: SourceUser, Path: t.TempDir()}}}, "missing", false)
|
|
if err == nil {
|
|
t.Fatal("expected missing plugin error")
|
|
}
|
|
if !errors.Is(err, ErrNotInstalled) {
|
|
t.Fatalf("error = %v, want ErrNotInstalled", err)
|
|
}
|
|
}
|
|
|
|
func TestSetEnabledPublishFailureLeavesManifestIntact(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("Windows directory permissions do not block writes the way this test needs")
|
|
}
|
|
if os.Geteuid() == 0 {
|
|
t.Skip("root bypasses directory permission checks")
|
|
}
|
|
root := t.TempDir()
|
|
raw := demoManifest(true)
|
|
manifestPath := writeRawManifest(t, root, "demo", raw)
|
|
pluginDir := filepath.Dir(manifestPath)
|
|
if err := os.Chmod(pluginDir, 0o555); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() { _ = os.Chmod(pluginDir, 0o755) }()
|
|
|
|
if _, err := SetEnabledByID(enableTestOptions(root), "zero.demo", false); err == nil {
|
|
t.Fatal("expected the publish to fail in a read-only plugin directory")
|
|
}
|
|
after, err := os.ReadFile(manifestPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(after) != raw {
|
|
t.Fatalf("failed publish changed the manifest:\n got %q\nwant %q", after, raw)
|
|
}
|
|
}
|
|
|
|
// TestSetEnabledByIDWaitsForInstallRootLock proves the toggle takes the same
|
|
// cross-process lock Install and Remove take on the plugin's install root: it
|
|
// must not complete while an install holds that lock.
|
|
func TestSetEnabledByIDWaitsForInstallRootLock(t *testing.T) {
|
|
root := t.TempDir()
|
|
manifestPath := writeRawManifest(t, root, "demo", demoManifest(true))
|
|
|
|
unlock, err := installtxn.Lock(root)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
results := make(chan SetEnabledResult, 1)
|
|
failures := make(chan error, 1)
|
|
go func() {
|
|
result, err := SetEnabledByID(enableTestOptions(root), "zero.demo", false)
|
|
if err != nil {
|
|
failures <- err
|
|
return
|
|
}
|
|
results <- result
|
|
}()
|
|
|
|
select {
|
|
case result := <-results:
|
|
unlock()
|
|
t.Fatalf("toggle completed while the install lock was held: %#v", result)
|
|
case err := <-failures:
|
|
unlock()
|
|
t.Fatalf("toggle failed while the install lock was held: %v", err)
|
|
case <-time.After(200 * time.Millisecond):
|
|
}
|
|
|
|
unlock()
|
|
|
|
select {
|
|
case result := <-results:
|
|
if !result.Changed {
|
|
t.Fatalf("toggle did not report a change: %#v", result)
|
|
}
|
|
case err := <-failures:
|
|
t.Fatalf("toggle failed after the lock was released: %v", err)
|
|
case <-time.After(10 * time.Second):
|
|
t.Fatal("toggle did not finish after the install lock was released")
|
|
}
|
|
|
|
raw, err := os.ReadFile(manifestPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(string(raw), `"enabled": false`) {
|
|
t.Fatalf("manifest not disabled: %s", raw)
|
|
}
|
|
}
|
|
|
|
// TestSetEnabledByIDSynchronizesWithStagedInstallCommit pauses an install
|
|
// between staging and commit, lets the toggle try to run, then commits the
|
|
// install. The toggle must apply to the newly installed manifest instead of
|
|
// overwriting it with bytes read before the install, and the lockfile entry
|
|
// must describe the tree actually on disk.
|
|
func TestSetEnabledByIDSynchronizesWithStagedInstallCommit(t *testing.T) {
|
|
root := t.TempDir()
|
|
oldRaw := demoManifest(true)
|
|
pluginDir := filepath.Dir(writeRawManifest(t, root, "zero.demo", oldRaw))
|
|
|
|
// Build the replacement tree with the same staging primitives Install
|
|
// uses and leave it staged but uncommitted.
|
|
src := t.TempDir()
|
|
newRaw := `{
|
|
"schemaVersion": 1,
|
|
"id": "zero.demo",
|
|
"name": "Demo",
|
|
"version": "2.0.0",
|
|
"description": "reinstalled",
|
|
"enabled": true
|
|
}
|
|
`
|
|
srcPlugin := filepath.Join(src, "zero.demo")
|
|
if err := os.MkdirAll(srcPlugin, 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(srcPlugin, "plugin.json"), []byte(newRaw), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
staged, cleanup, err := installtxn.StageDir(root)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer cleanup()
|
|
if err := copyTree(srcPlugin, staged); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
stagedHash, err := hashTree(staged)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// An install holds the root lock from before its commit until after it.
|
|
unlock, err := installtxn.Lock(root)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
results := make(chan SetEnabledResult, 1)
|
|
failures := make(chan error, 1)
|
|
go func() {
|
|
result, err := SetEnabledByID(enableTestOptions(root), "zero.demo", false)
|
|
if err != nil {
|
|
failures <- err
|
|
return
|
|
}
|
|
results <- result
|
|
}()
|
|
|
|
select {
|
|
case result := <-results:
|
|
unlock()
|
|
t.Fatalf("toggle completed while the install lock was held: %#v", result)
|
|
case err := <-failures:
|
|
unlock()
|
|
t.Fatalf("toggle failed while the install lock was held: %v", err)
|
|
case <-time.After(200 * time.Millisecond):
|
|
}
|
|
|
|
// Commit the staged install exactly like Install does: swap the tree and
|
|
// publish the lockfile entry while still holding the lock.
|
|
lock, err := ReadLock(root)
|
|
if err != nil {
|
|
unlock()
|
|
t.Fatal(err)
|
|
}
|
|
lock["zero.demo"] = LockEntry{Source: string(SourceUser), Hash: stagedHash}
|
|
if err := installtxn.CommitDir(pluginDir, staged, func() error { return writeLock(root, lock) }); err != nil {
|
|
unlock()
|
|
t.Fatal(err)
|
|
}
|
|
unlock()
|
|
|
|
var result SetEnabledResult
|
|
select {
|
|
case result = <-results:
|
|
case err := <-failures:
|
|
t.Fatalf("toggle failed after the install committed: %v", err)
|
|
case <-time.After(10 * time.Second):
|
|
t.Fatal("toggle did not finish after the install committed")
|
|
}
|
|
if !result.Changed {
|
|
t.Fatalf("toggle result = %#v, want changed", result)
|
|
}
|
|
|
|
// The toggle must have applied to the newly installed manifest, not to the
|
|
// bytes it first read.
|
|
final, err := os.ReadFile(filepath.Join(pluginDir, "plugin.json"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := strings.Replace(newRaw, `"enabled": true`, `"enabled": false`, 1)
|
|
if string(final) != want {
|
|
t.Fatalf("manifest after concurrent install + disable:\n got %q\nwant %q", final, want)
|
|
}
|
|
|
|
// The lockfile entry must describe the tree that is actually on disk.
|
|
entries, err := ReadLock(root)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
current, err := hashTree(pluginDir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if entries["zero.demo"].Hash != current {
|
|
t.Fatalf("plugins.lock hash = %q, want current tree hash %q", entries["zero.demo"].Hash, current)
|
|
}
|
|
}
|
|
|
|
func TestSetEnabledByIDRefreshesCleanLockEntry(t *testing.T) {
|
|
root := t.TempDir()
|
|
manifestPath := writeRawManifest(t, root, "zero.demo", demoManifest(true))
|
|
pluginDir := filepath.Dir(manifestPath)
|
|
|
|
current, err := hashTree(pluginDir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := writeLock(root, map[string]LockEntry{"zero.demo": {Source: string(SourceUser), Hash: current}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if _, err := SetEnabledByID(enableTestOptions(root), "zero.demo", false); err != nil {
|
|
t.Fatalf("SetEnabledByID: %v", err)
|
|
}
|
|
entries, err := ReadLock(root)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
afterToggle, err := hashTree(pluginDir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if entries["zero.demo"].Hash == current {
|
|
t.Fatal("lock hash was not refreshed for a clean install")
|
|
}
|
|
if entries["zero.demo"].Hash != afterToggle {
|
|
t.Fatalf("lock hash = %q, want refreshed tree hash %q", entries["zero.demo"].Hash, afterToggle)
|
|
}
|
|
|
|
info, err := Info(InfoOptions{LoadOptions: enableTestOptions(root)}, "zero.demo")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if info.HashDrift {
|
|
t.Fatal("toggle left hash drift behind for a clean install")
|
|
}
|
|
|
|
// Pre-existing drift must survive the next toggle instead of being
|
|
// absorbed into the lockfile.
|
|
if err := os.WriteFile(filepath.Join(pluginDir, "extra.txt"), []byte("edited\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := SetEnabledByID(enableTestOptions(root), "zero.demo", true); err != nil {
|
|
t.Fatalf("SetEnabledByID(true): %v", err)
|
|
}
|
|
entries, err = ReadLock(root)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if entries["zero.demo"].Hash != afterToggle {
|
|
t.Fatalf("toggle absorbed pre-existing drift: hash = %q, want %q", entries["zero.demo"].Hash, afterToggle)
|
|
}
|
|
}
|
|
|
|
// TestSetEnabledByIDReportsAPluginRemovedWhileWaiting removes the plugin while
|
|
// the toggle is blocked on the install-root lock, exactly as Remove does while
|
|
// it holds that lock. The toggle must report the plugin as not installed
|
|
// instead of editing, or recreating, a tree that no longer exists.
|
|
func TestSetEnabledByIDReportsAPluginRemovedWhileWaiting(t *testing.T) {
|
|
root := t.TempDir()
|
|
pluginDir := filepath.Dir(writeRawManifest(t, root, "zero.demo", demoManifest(true)))
|
|
|
|
unlock, err := installtxn.Lock(root)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
failures := make(chan error, 1)
|
|
go func() {
|
|
_, err := SetEnabledByID(enableTestOptions(root), "zero.demo", false)
|
|
failures <- err
|
|
}()
|
|
|
|
select {
|
|
case err := <-failures:
|
|
unlock()
|
|
t.Fatalf("toggle returned while the install lock was held: %v", err)
|
|
case <-time.After(200 * time.Millisecond):
|
|
}
|
|
|
|
if err := installtxn.RemoveDir(pluginDir, func() error { return nil }); err != nil {
|
|
unlock()
|
|
t.Fatal(err)
|
|
}
|
|
unlock()
|
|
|
|
select {
|
|
case err := <-failures:
|
|
if !errors.Is(err, ErrNotInstalled) {
|
|
t.Fatalf("toggle after a concurrent removal: error = %v, want ErrNotInstalled", err)
|
|
}
|
|
case <-time.After(10 * time.Second):
|
|
t.Fatal("toggle did not finish after the removal released the lock")
|
|
}
|
|
if _, err := os.Stat(pluginDir); !os.IsNotExist(err) {
|
|
t.Fatalf("toggle recreated the removed plugin: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestSetEnabledByIDResolvesInterruptedUpdatesFirst covers every point an
|
|
// update can be killed at that leaves the plugin discoverable. The toggle
|
|
// holds the install-root lock, so it has to
|
|
// run the same recovery pass Install and Remove run before it reads the tree:
|
|
// otherwise it edits, or fails to find, a tree that is still owed a restore,
|
|
// and the next install or removal puts the backup back over the user's edit.
|
|
func TestSetEnabledByIDResolvesInterruptedUpdatesFirst(t *testing.T) {
|
|
for _, state := range interruptedStates() {
|
|
t.Run(state.name, func(t *testing.T) {
|
|
u := seedInterruptedUpdate(t)
|
|
workspace := state.plant(t, u)
|
|
want := state.want(u)
|
|
if _, err := os.Lstat(u.target()); os.IsNotExist(err) {
|
|
// No root lists a plugin whose only copy is in a workspace, so the
|
|
// toggle reports it not installed without taking any lock, just as
|
|
// list and info do; the next install or removal restores it.
|
|
t.Skip("nothing is at the target, so the toggle never takes the lock")
|
|
}
|
|
|
|
result, err := SetEnabledByID(enableTestOptions(u.dir), "zero.demo", false)
|
|
if err != nil {
|
|
t.Fatalf("toggle after the interrupted update: %v", err)
|
|
}
|
|
if !result.Changed || result.Enabled {
|
|
t.Fatalf("toggle result = %#v, want changed and disabled", result)
|
|
}
|
|
assertDisabledAfterRecovery(t, u, workspace, want)
|
|
|
|
// The next lock holder runs recovery again. The disable has to survive
|
|
// it rather than be replaced by a backup the toggle never saw.
|
|
if err := installOther(t, u.dir, "zero.other"); err != nil {
|
|
t.Fatalf("install after the toggle: %v", err)
|
|
}
|
|
assertDisabledAfterRecovery(t, u, workspace, want)
|
|
})
|
|
}
|
|
}
|
|
|
|
func assertDisabledAfterRecovery(t *testing.T, u interruptedUpdate, workspace string, want recoveryWant) {
|
|
t.Helper()
|
|
data, err := os.ReadFile(filepath.Join(u.target(), "generation.txt"))
|
|
if err != nil {
|
|
t.Fatalf("reading the live tree: %v", err)
|
|
}
|
|
if string(data) != want.generation {
|
|
t.Errorf("the live tree is the %q generation, want %q", data, want.generation)
|
|
}
|
|
plugin, ok, err := FindByID(enableTestOptions(u.dir), "zero.demo")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !ok {
|
|
t.Fatal("Load does not see the toggled plugin")
|
|
}
|
|
if plugin.Version != want.version {
|
|
t.Errorf("Load sees version %q, want %q", plugin.Version, want.version)
|
|
}
|
|
if plugin.Enabled {
|
|
t.Errorf("the disable did not stick: Load sees the plugin enabled")
|
|
}
|
|
_, err = os.Stat(workspace)
|
|
if want.workspace && err != nil {
|
|
t.Errorf("the workspace should be left alone, got %v", err)
|
|
}
|
|
if !want.workspace && !os.IsNotExist(err) {
|
|
t.Errorf("the resolved workspace should be cleared, got %v", err)
|
|
}
|
|
}
|
|
|
|
// TestSetEnabledByIDAbortsOnAnUnresolvedInterruptedUpdate leaves a workspace
|
|
// whose trees match neither lockfile hash. Recovery cannot pick one, so the
|
|
// toggle must fail without editing the live manifest or the lockfile.
|
|
func TestSetEnabledByIDAbortsOnAnUnresolvedInterruptedUpdate(t *testing.T) {
|
|
u := seedInterruptedUpdate(t)
|
|
workspace := u.plantWorkspace(t)
|
|
u.mustRename(t, u.target(), filepath.Join(workspace, "previous"))
|
|
u.mustRename(t, filepath.Join(workspace, "staged"), u.target())
|
|
unmatched := LockEntry{Source: u.oldSource, Hash: "sha256:0000000000000000000000000000000000000000000000000000000000000000"}
|
|
lock, err := ReadLock(u.dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
lock["zero.demo"] = unmatched
|
|
if err := writeLock(u.dir, lock); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
manifestPath := filepath.Join(u.target(), "plugin.json")
|
|
before, err := os.ReadFile(manifestPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if _, err := SetEnabledByID(enableTestOptions(u.dir), "zero.demo", false); err == nil {
|
|
t.Fatal("toggle did not report the unresolved transaction")
|
|
}
|
|
after, err := os.ReadFile(manifestPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(after) != string(before) {
|
|
t.Errorf("the aborted toggle edited the manifest:\n got %q\nwant %q", after, before)
|
|
}
|
|
assertUntouched(t, u, "new", unmatched)
|
|
if _, err := os.Stat(filepath.Join(workspace, "previous")); err != nil {
|
|
t.Errorf("the backup should still be there for a hand rescue, got %v", err)
|
|
}
|
|
}
|