package apply import ( "context" "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)) } } 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") } } }