fix(plugin): writeMod drops allowed_hosts/max_response_mb/required_icon_packs
writeMod hand-reconstructs plugin.mod field by field but never emitted three first-class core.ModFile fields, so ParseModFull → mutate → writeMod (every bump/init/tags edit) silently stripped them. For allowed_hosts this is security-relevant: a `ninja plugin bump` erased a plugin's ADR-0023 egress declaration, the install/update consent gate saw nothing to grant, and the plugin's outbound calls were denied at runtime with no prompt ever shown. Emit all three; add a writeMod ⇄ ParseModFull round-trip regression test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
7cfc371a42
commit
4eb4bcfc50
@ -1107,12 +1107,29 @@ func writeMod(path string, m *core.ModFile) error {
|
|||||||
}
|
}
|
||||||
fmt.Fprintf(&b, "tags = [%s]\n", strings.Join(quoted, ", "))
|
fmt.Fprintf(&b, "tags = [%s]\n", strings.Join(quoted, ", "))
|
||||||
}
|
}
|
||||||
|
if len(m.Plugin.RequiredIconPacks) > 0 {
|
||||||
|
quoted := make([]string, len(m.Plugin.RequiredIconPacks))
|
||||||
|
for i, p := range m.Plugin.RequiredIconPacks {
|
||||||
|
quoted[i] = fmt.Sprintf("%q", p)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, "required_icon_packs = [%s]\n", strings.Join(quoted, ", "))
|
||||||
|
}
|
||||||
if m.Plugin.Private {
|
if m.Plugin.Private {
|
||||||
b.WriteString("private = true\n")
|
b.WriteString("private = true\n")
|
||||||
}
|
}
|
||||||
if m.Plugin.DataDir {
|
if m.Plugin.DataDir {
|
||||||
b.WriteString("data_dir = true\n")
|
b.WriteString("data_dir = true\n")
|
||||||
}
|
}
|
||||||
|
if len(m.Plugin.AllowedHosts) > 0 {
|
||||||
|
quoted := make([]string, len(m.Plugin.AllowedHosts))
|
||||||
|
for i, h := range m.Plugin.AllowedHosts {
|
||||||
|
quoted[i] = fmt.Sprintf("%q", h)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, "allowed_hosts = [%s]\n", strings.Join(quoted, ", "))
|
||||||
|
}
|
||||||
|
if m.Plugin.MaxResponseMB > 0 {
|
||||||
|
fmt.Fprintf(&b, "max_response_mb = %d\n", m.Plugin.MaxResponseMB)
|
||||||
|
}
|
||||||
if m.Compatibility != nil {
|
if m.Compatibility != nil {
|
||||||
b.WriteString("\n[compatibility]\n")
|
b.WriteString("\n[compatibility]\n")
|
||||||
fmt.Fprintf(&b, "block_core = %q\n", m.Compatibility.BlockCore)
|
fmt.Fprintf(&b, "block_core = %q\n", m.Compatibility.BlockCore)
|
||||||
|
|||||||
@ -364,6 +364,45 @@ func TestWriteMod_PrivateFalseOmitted(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestWriteMod_RoundTripsFirstClassFields guards the writeMod ⇄ ParseModFull
|
||||||
|
// round-trip for the first-class fields the hand-rolled writer must emit: a
|
||||||
|
// bump/init/tags edit re-serializes plugin.mod, and any field the writer forgets
|
||||||
|
// is silently dropped. allowed_hosts (ADR 0023) dropping is a security-relevant
|
||||||
|
// regression — a bump strips the egress declaration and the consent gate goes
|
||||||
|
// dark — so this test exists to keep the writer in sync with core.ModFile.
|
||||||
|
func TestWriteMod_RoundTripsFirstClassFields(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "plugin.mod")
|
||||||
|
m := &core.ModFile{Plugin: core.ModPlugin{
|
||||||
|
Name: "myplugin",
|
||||||
|
Scope: "ninja",
|
||||||
|
Version: "1.2.3",
|
||||||
|
AllowedHosts: []string{"api.cal.com", "*.example.com"},
|
||||||
|
MaxResponseMB: 25,
|
||||||
|
RequiredIconPacks: []string{"tabler"},
|
||||||
|
}}
|
||||||
|
if err := writeMod(path, m); err != nil {
|
||||||
|
t.Fatalf("writeMod: %v", err)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read back: %v", err)
|
||||||
|
}
|
||||||
|
back, err := core.ParseModFull(data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseModFull round-trip: %v\n%s", err, data)
|
||||||
|
}
|
||||||
|
if got := back.Plugin.AllowedHosts; len(got) != 2 || got[0] != "api.cal.com" || got[1] != "*.example.com" {
|
||||||
|
t.Errorf("allowed_hosts not preserved: %v\n%s", got, data)
|
||||||
|
}
|
||||||
|
if back.Plugin.MaxResponseMB != 25 {
|
||||||
|
t.Errorf("max_response_mb not preserved: %d\n%s", back.Plugin.MaxResponseMB, data)
|
||||||
|
}
|
||||||
|
if got := back.Plugin.RequiredIconPacks; len(got) != 1 || got[0] != "tabler" {
|
||||||
|
t.Errorf("required_icon_packs not preserved: %v\n%s", got, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestParsePrivateCoord(t *testing.T) {
|
func TestParsePrivateCoord(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
in string
|
in string
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user