Files
mesh-host/internal/apply/archive_removal_test.go
T
jschoubben 5dc3a8e17f
mesh/delivery merged: source.main moved to novox/mesh-host@998d8f737008
mesh/check being carried to 5dc3a8e17f97 on 998d8f737008 | from fabfdf04f78a
Keep what an archive unpacked in the engine's own state directory, never beside the archive, and stop no slower than the host waits (issue 1585, review round 1)
The journal of an unpack sat at <target>.unpacked.json, in a directory
the module's account may write: a forged one would make the root engine
remove or adopt any path it named. It now lives under
<statedir>/unpacked/<sha256 of the target>.json, mode 0600, names its
target, and is refused whole when an entry is not filepath.IsLocal or a
parent is not above the target. It goes when the archive is taken off,
whether its state record still said what it unpacked or not.

The launcher's stop grace for a host on trial rises from 20 s to 75 s and
the unit's TimeoutStopSec is 120 s (openrc the same), past the host's own
60 s wait for the apply in flight, so neither cuts a save off again.

Tests folded as rows into existing ones (the repository is over its unit
budget): the journal's rows, a forged entry and parent refused, the
journal gone with a record that lost its Unpacked; the test that passed
on main dropped; the stop test waits on its own line, not a sleep, and no
longer consumes the save it waits for.

Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn
2026-11-04 10:52:42 +01:00

355 lines
15 KiB
Go

package apply
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/novox/mesh-host/internal/store"
)
// Defends novox/hq issue 162: an archive can be undeclared. What it unpacked is gone, anything that
// was in its directory beforehand is still there, and the apply that removed it applied everything
// else in the same declaration.
func exists(path string) bool {
_, err := os.Lstat(path)
return err == nil
}
func archiveDecl(t *testing.T, id, path string, files map[string]string, more string) (string, string) {
t.Helper()
body, digest := anArchive(t, files)
return `{"id":"` + id + `","type":"archive","source":"` + serving(t, body) + `","digest":"` + digest +
`","path":"` + path + `"}` + more, digest
}
func TestUnassigningAnArchiveRemovesExactlyWhatItUnpacked(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "bundles", "notes", "tools")
archive, _ := archiveDecl(t, "notes.tools", target,
map[string]string{"index.js": "x", "lib/one.js": "1", "lib/two.js": "2"},
`,{"id":"notes.conf","type":"file","path":"`+dir+`/notes.conf","content":"a"}`)
_, state, err := Apply(context.Background(), archHost(t), declare(t, archive), store.State{},
store.OriginDeclared, noServices, nil, nil)
if err != nil {
t.Fatal(err)
}
if rec, _ := state.Find("notes.tools"); rec.Unpacked == nil || !rec.Unpacked.Made ||
len(rec.Unpacked.Files) != 3 || len(rec.Unpacked.Parents) != 2 {
t.Fatalf("what the archive unpacked was not recorded: %+v", rec.Unpacked)
}
// Unassigned, and the same push declares something new: both happen.
d := declare(t, `{"id":"notes.conf","type":"file","path":"`+dir+`/notes.conf","content":"a"},
{"id":"other.conf","type":"file","path":"`+dir+`/other.conf","content":"b"}`)
report, state, err := Apply(context.Background(), archHost(t), d, state, store.OriginDeclared, noServices, nil, nil)
if err != nil {
t.Fatalf("undeclaring an archive stopped the apply: %v", err)
}
if o := outcomeOf(report, "notes.tools"); o.Action != "removed" {
t.Fatalf("the archive: %+v", o)
}
if o := outcomeOf(report, "other.conf"); o.Action != "created" {
t.Fatalf("the rest of the declaration: %+v", o)
}
if _, still := state.Find("notes.tools"); still {
t.Fatal("the archive is still on record")
}
// The directory it was unpacked into and the parents the host made to reach it, all gone.
if exists(filepath.Join(dir, "bundles")) {
t.Fatal("what the host made for the archive is still there")
}
if !exists(filepath.Join(dir, "notes.conf")) {
t.Fatal("a file the archive did not place was removed")
}
if entries, _ := os.ReadDir(dir); len(entries) != 2 {
t.Fatalf("%d entries left in the directory", len(entries))
}
}
func TestADirectoryFoundBeforeTheArchiveKeepsWhatWasInIt(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "powerlevel10k")
if err := os.MkdirAll(filepath.Join(target, "lib"), 0o755); err != nil {
t.Fatal(err)
}
os.WriteFile(filepath.Join(target, "mine.zsh"), []byte("somebody's"), 0o644)
os.WriteFile(filepath.Join(target, "lib", "mine.zsh"), []byte("somebody's"), 0o644)
archive, _ := archiveDecl(t, "shell.theme", target,
map[string]string{"p10k.zsh": "theme", "lib/theme.zsh": "lib", "gitstatus/gs": "gs"}, "")
_, state, err := Apply(context.Background(), archHost(t), declare(t, archive), store.State{},
store.OriginDeclared, noServices, nil, nil)
if err != nil {
t.Fatal(err)
}
// Applying over a directory that was there keeps what was in it.
for _, kept := range []string{"mine.zsh", "lib/mine.zsh"} {
if got, _ := os.ReadFile(filepath.Join(target, kept)); string(got) != "somebody's" {
t.Fatalf("%s after the apply: %q", kept, got)
}
}
if got, _ := os.ReadFile(filepath.Join(target, "lib", "theme.zsh")); string(got) != "lib" {
t.Fatalf("lib/theme.zsh is %q", got)
}
rec, _ := state.Find("shell.theme")
if rec.Unpacked == nil || rec.Unpacked.Made || strings.Join(rec.Unpacked.Dirs, ",") != "gitstatus" {
t.Fatalf("recorded as %+v", rec.Unpacked)
}
// A new version without one of its files: that file goes, nothing else does.
archive, _ = archiveDecl(t, "shell.theme", target, map[string]string{"p10k.zsh": "theme 2"}, "")
if _, state, err = Apply(context.Background(), archHost(t), declare(t, archive), state,
store.OriginDeclared, noServices, nil, nil); err != nil {
t.Fatal(err)
}
if exists(filepath.Join(target, "lib", "theme.zsh")) || exists(filepath.Join(target, "gitstatus")) {
t.Fatal("the previous version's files are still there")
}
if !exists(filepath.Join(target, "lib", "mine.zsh")) {
t.Fatal("a file the mesh did not put there went with the previous version")
}
report, _, err := Apply(context.Background(), archHost(t), declare(t, `{"id":"other","type":"file","path":"`+dir+`/other","content":"o"}`), state,
store.OriginDeclared, noServices, nil, nil)
if err != nil {
t.Fatal(err)
}
if o := outcomeOf(report, "shell.theme"); o.Action != "removed" || !strings.Contains(o.Detail, "there before") {
t.Fatalf("the archive: %+v", o)
}
if exists(filepath.Join(target, "p10k.zsh")) {
t.Fatal("the archive's file is still there")
}
for _, kept := range []string{"mine.zsh", "lib/mine.zsh"} {
if got, _ := os.ReadFile(filepath.Join(target, kept)); string(got) != "somebody's" {
t.Fatalf("%s after the removal: %q", kept, got)
}
}
}
// A file the archive would write over that the mesh did not put there refuses the archive, and the
// directory is left exactly as it was.
func TestAnArchiveDoesNotWriteOverAFileItFound(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "theme")
os.MkdirAll(target, 0o755)
os.WriteFile(filepath.Join(target, "p10k.zsh"), []byte("somebody's"), 0o644)
archive, _ := archiveDecl(t, "shell.theme", target, map[string]string{"p10k.zsh": "theme", "x": "x"}, "")
if _, _, err := Apply(context.Background(), archHost(t), declare(t, archive), store.State{},
store.OriginDeclared, noServices, nil, nil); err == nil || !strings.Contains(err.Error(), "did not put there") {
t.Fatalf("written over: %v", err)
}
if got, _ := os.ReadFile(filepath.Join(target, "p10k.zsh")); string(got) != "somebody's" {
t.Fatalf("p10k.zsh is %q", got)
}
if exists(filepath.Join(target, "x")) {
t.Fatal("part of a refused archive was moved in")
}
if entries, _ := os.ReadDir(dir); len(entries) != 1 {
t.Fatalf("%d entries beside the directory", len(entries))
}
// Issue 1585: what the host unpacked is written down at once, in its own state directory, so an archive whose
// record in the state was lost is still the host's: the next version replaces its files, drops the ones it no
// longer has and leaves a file somebody added. A file the host never unpacked still refuses it, and so does a
// journal naming a path outside the archive's directory. The journal goes with the archive, record lost or not.
for _, row := range []struct {
name string
journaled bool // the first version was unpacked by the host
foreign string
forged func(j *unpackJournal)
refused string
}{
{"unpacked by the host, record lost", true, "", nil, ""},
{"unpacked by the host, somebody's file beside", true, "mine.txt", nil, ""},
{"never unpacked by the host", false, "", nil, "did not put there"},
{"a journal naming a path outside", true, "", func(j *unpackJournal) {
j.Unpacked.Files = append(j.Unpacked.Files, "../../etc/passwd")
}, "not inside it"},
{"a journal naming a parent not above", true, "", func(j *unpackJournal) {
j.Unpacked.Parents = []string{"/etc"}
}, "not above it"},
} {
t.Run(row.name, func(t *testing.T) {
dir, state := t.TempDir(), t.TempDir()
windows, journals := WindowsIn(state), filepath.Join(state, JournalDirName)
applyIt := func(decl string, known store.State) (store.State, error) {
_, after, err := ApplyMindingWindows(context.Background(), archHost(t), declare(t, decl), known,
store.OriginDeclared, noServices, nil, nil, nil, windows)
return after, err
}
target := filepath.Join(dir, "tools")
first, _ := archiveDecl(t, "m.tools", target, map[string]string{"bin": "one", "old": "o"}, "")
if row.journaled {
if _, err := applyIt(first, store.State{}); err != nil {
t.Fatal(err)
}
if entries, _ := os.ReadDir(dir); len(entries) != 1 {
t.Fatalf("%d entries beside the archive: the journal is the state directory's", len(entries))
}
info, err := os.Stat(journalPath(journals, target))
if err != nil || info.Mode().Perm() != 0o600 {
t.Fatalf("no journal of root's alone after the unpack: %v %v", info, err)
}
} else {
os.MkdirAll(target, 0o755)
os.WriteFile(filepath.Join(target, "bin"), []byte("one"), 0o644)
}
if row.forged != nil {
j, _, err := journalOf(journals, target)
if err != nil {
t.Fatal(err)
}
row.forged(&j)
raw, _ := json.Marshal(j)
os.WriteFile(journalPath(journals, target), raw, 0o600)
}
if row.foreign != "" {
os.WriteFile(filepath.Join(target, row.foreign), []byte("x"), 0o644)
}
second, _ := archiveDecl(t, "m.tools", target, map[string]string{"bin": "two"}, "")
// The state is empty: the record of the first unpack was lost.
after, err := applyIt(second, store.State{})
if row.refused != "" {
if err == nil || !strings.Contains(err.Error(), row.refused) {
t.Fatalf("refused? %v", err)
}
return
}
if err != nil {
t.Fatal(err)
}
if got, _ := os.ReadFile(filepath.Join(target, "bin")); string(got) != "two" {
t.Fatalf("bin is %q", got)
}
if exists(filepath.Join(target, "old")) {
t.Fatal("a file the new version no longer has is still there")
}
if row.foreign != "" && !exists(filepath.Join(target, row.foreign)) {
t.Fatal("somebody's file went")
}
// Taken off, with a record that no longer says what it unpacked: the journal goes too.
for i := range after.Resources {
after.Resources[i].Unpacked = nil
}
if _, err := applyIt(`{"id":"other","type":"file","path":"`+dir+`/other","content":"o"}`, after); err != nil {
t.Fatal(err)
}
if exists(journalPath(journals, target)) {
t.Fatal("the journal outlived the archive")
}
})
}
}
func TestADirectoryTheHostMadeGoesOnlyWhenEmpty(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "theme")
archive, _ := archiveDecl(t, "shell.theme", target, map[string]string{"p10k.zsh": "t", "lib/a.zsh": "a"}, "")
_, state, err := Apply(context.Background(), archHost(t), declare(t, archive), store.State{},
store.OriginDeclared, noServices, nil, nil)
if err != nil {
t.Fatal(err)
}
// Somebody writes into the directory the host made.
os.WriteFile(filepath.Join(target, "lib", "local.zsh"), []byte("mine"), 0o644)
report, _, err := Apply(context.Background(), archHost(t), declare(t, `{"id":"other","type":"file","path":"`+dir+`/other","content":"o"}`), state,
store.OriginDeclared, noServices, nil, nil)
if err != nil {
t.Fatal(err)
}
o := outcomeOf(report, "shell.theme")
if o.Action != "removed" || !strings.Contains(o.Detail, "did not put there") {
t.Fatalf("the archive: %+v", o)
}
if exists(filepath.Join(target, "p10k.zsh")) || exists(filepath.Join(target, "lib", "a.zsh")) {
t.Fatal("the archive's files are still there")
}
if got, _ := os.ReadFile(filepath.Join(target, "lib", "local.zsh")); string(got) != "mine" {
t.Fatalf("a file the archive did not place: %q", got)
}
}
// An archive recorded before the host kept what it unpacked cannot be told from anything else in
// its directory: it is left in place, said and forgotten, and the apply goes on.
func TestAnArchiveRecordedBeforeItsFilesWereKeptIsLeftAndForgotten(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "tools")
os.MkdirAll(target, 0o755)
os.WriteFile(filepath.Join(target, "index.js"), []byte("x"), 0o644)
known := store.State{Resources: []store.Applied{
{ID: "notes.tools", Type: "archive", Target: target, Wrote: "sha256:old", Origin: store.OriginDeclared},
}}
d := declare(t, `{"id":"notes.conf","type":"file","path":"`+dir+`/notes.conf","content":"a"}`)
report, state, err := Apply(context.Background(), archHost(t), d, known, store.OriginDeclared, noServices, nil, nil)
if err != nil {
t.Fatalf("an archive from before stopped the apply: %v", err)
}
if o := outcomeOf(report, "notes.tools"); o.Action != "forgotten" || !strings.Contains(o.Detail, "left in place") {
t.Fatalf("the archive: %+v", o)
}
if o := outcomeOf(report, "notes.conf"); o.Action != "created" {
t.Fatalf("the rest of the declaration: %+v", o)
}
if _, still := state.Find("notes.tools"); still {
t.Fatal("still on record")
}
if !exists(filepath.Join(target, "index.js")) {
t.Fatal("removed without a record of what it unpacked")
}
}
// One recorded before, still declared and unchanged, is read from its own bytes on the next apply,
// so it can be undeclared from then on: the whole directory when it holds exactly the archive, only
// the archive's files when it holds anything else.
func TestAnArchiveRecordedBeforeLearnsWhatItUnpacked(t *testing.T) {
for _, extra := range []bool{false, true} {
dir := t.TempDir()
target := filepath.Join(dir, "tools")
files := map[string]string{"index.js": "x", "lib/a.js": "a"}
archive, digest := archiveDecl(t, "notes.tools", target, files, "")
for name, body := range files {
os.MkdirAll(filepath.Dir(filepath.Join(target, name)), 0o755)
os.WriteFile(filepath.Join(target, name), []byte(body), 0o644)
}
if extra {
os.WriteFile(filepath.Join(target, "lib", "local.js"), []byte("mine"), 0o644)
}
known := store.State{Resources: []store.Applied{
{ID: "notes.tools", Type: "archive", Target: target, Wrote: digest, Origin: store.OriginDeclared},
}}
report, state, err := Apply(context.Background(), archHost(t), declare(t, archive), known,
store.OriginDeclared, noServices, nil, nil)
if err != nil {
t.Fatal(err)
}
if o := outcomeOf(report, "notes.tools"); o.Action != "unchanged" {
t.Fatalf("an unchanged archive was %s", o.Action)
}
rec, _ := state.Find("notes.tools")
if rec.Unpacked == nil || len(rec.Unpacked.Files) != 2 || rec.Unpacked.Made == extra {
t.Fatalf("extra=%v: learned %+v", extra, rec.Unpacked)
}
if _, _, err := Apply(context.Background(), archHost(t), declare(t, `{"id":"other","type":"file","path":"`+dir+`/other","content":"o"}`), state,
store.OriginDeclared, noServices, nil, nil); err != nil {
t.Fatal(err)
}
if exists(filepath.Join(target, "index.js")) || exists(filepath.Join(target, "lib", "a.js")) {
t.Fatalf("extra=%v: the archive's files are still there", extra)
}
if exists(target) != extra {
t.Fatalf("extra=%v: the directory is there: %v", extra, exists(target))
}
if extra && !exists(filepath.Join(target, "lib", "local.js")) {
t.Fatal("a file the archive did not place was removed")
}
}
}