//go:build linux && rootrunner package host import ( "context" "encoding/json" "os" "path/filepath" "strings" "testing" "github.com/carlosframework/platform/internal/release" "github.com/carlosframework/platform/internal/rootstate" "golang.org/x/sys/unix" ) const claimUnitRebootDir = "/var/lib/carlos-e51-unit-reboot" func claimUnitRebootFixture(t *testing.T, create bool) (claimUnitMaterializer, claimUnitBinding, claimUnitFiles) { t.Helper() dir := claimUnitRebootDir if create { if err := os.Mkdir(dir, 0755); err != nil { t.Fatal(err) } } state, err := rootstate.Open(filepath.Join(dir, "records"), create, false) if err != nil { t.Fatal(err) } t.Cleanup(func() { state.Close() }) if create { if err := os.Mkdir(filepath.Join(dir, "units"), 0755); err != nil { t.Fatal(err) } } files := claimUnitFiles{dir: filepath.Join(dir, "units")} m := claimUnitMaterializer{state: state, read: files.read, create: files.create, replace: files.replace, idle: func(string) error { return nil }} _, in, s := plan14UnitBindingFixture(t, "plan14-reboot.service", "", "") s.Program = strings.Repeat("a", 64) b := claimUnitBinding{Schema: 1, Owner: unitBindingOwner(in), Shape: s} return m, b, files } func TestRootRebootClaimUnitDevicePhase1(t *testing.T) { m, b, _ := claimUnitRebootFixture(t, true) if err := m.install(t.Context(), b); err != nil { t.Fatal(err) } r, err := m.load(b.Owner.Host) if err != nil || r.Identity.Handle == "" { t.Fatal("persistent unit identity", r, err) } raw, _ := json.Marshal(r.Identity) if err := os.WriteFile(filepath.Join(claimUnitRebootDir, "before.json"), raw, 0600); err != nil { t.Fatal(err) } dir, err := os.Open(filepath.Join(claimUnitRebootDir, "units")) if err != nil { t.Fatal(err) } treeProof, err := rootstate.PersistentIdentity(dir) dir.Close() if err != nil { t.Fatal(err) } binding := release.GenerationBinding{Path: filepath.Join(claimUnitRebootDir, "units", b.Shape.Unit), Tree: filepath.Join(claimUnitRebootDir, "units"), Executable: rootstate.FileIdentity{Device: r.Identity.Device, Inode: r.Identity.Inode}, TreeIdentity: rootstate.FileIdentity{Device: treeProof.Device, Inode: treeProof.Inode}, ExecutableProof: r.Identity, TreeProof: treeProof, Target: release.GenerationOwner{BootID: r.Identity.Boot}} raw, _ = json.Marshal(binding) if err := os.WriteFile(filepath.Join(claimUnitRebootDir, "generation.json"), raw, 0600); err != nil { t.Fatal(err) } t.Logf("before boot=%s device=%d mount=%d", r.Identity.Boot, r.Identity.Device, r.Identity.Mount) claimRebootPredecessorPhase1(t) } func TestRootRebootClaimUnitDevicePhase2(t *testing.T) { m, b, files := claimUnitRebootFixture(t, false) t.Cleanup(func() { os.RemoveAll(claimUnitRebootDir) }) var before rootstate.FileIdentity raw, err := os.ReadFile(filepath.Join(claimUnitRebootDir, "before.json")) if err != nil || json.Unmarshal(raw, &before) != nil { t.Fatal(err) } _, current, err := files.read(b.Shape.Unit) if err != nil || before.Boot == current.Boot { t.Fatal("actual reboot required", err) } t.Logf("after boot=%s device=%d mount=%d", current.Boot, current.Device, current.Mount) claimRebootPredecessorPhase2(t) if err = m.verify(b); err != nil { t.Fatal("persistent unit rejected after reboot", err) } r, err := m.load(b.Owner.Host) if err != nil { t.Fatal(err) } // Force numerical drift too, even if this reboot reused a number. r.Identity.Device = current.Device + 1 r.Identity.Mount = current.Mount + 1 if err = m.save(r); err != nil { t.Fatal(err) } if err = m.verify(b); err != nil { t.Fatal("boot-local numbers treated as persistent", err) } var binding release.GenerationBinding raw, err = os.ReadFile(filepath.Join(claimUnitRebootDir, "generation.json")) if err != nil || json.Unmarshal(raw, &binding) != nil { t.Fatal(err) } binding.Executable.Device = current.Device + 1 binding.TreeIdentity.Device = current.Device + 1 binding.ExecutableProof.Device = current.Device + 1 binding.TreeProof.Device = current.Device + 1 local, err := generationCurrentFiles(binding, rootstate.OpenTrustedFile) if err != nil || local.Executable.Device != current.Device || local.TreeIdentity.Device != current.Device { t.Fatal("generation remapping", local, err) } if _, err = generationTree(t.Context(), local); err != nil { t.Fatal("generation tree census", err) } mounts, err := os.ReadFile("/proc/self/mountinfo") if err != nil { t.Fatal(err) } if err = generationMounts(mounts, local); err != nil { t.Fatal("generation mount census", err) } // Same filesystem, same inode through a substituted bind mount still refuses. unitPath := filepath.Join(files.dir, b.Shape.Unit) if err = unix.Mount(unitPath, unitPath, "", unix.MS_BIND, ""); err != nil { t.Fatal(err) } rejected := m.verify(b) _, generationRejected := generationCurrentFiles(binding, rootstate.OpenTrustedFile) if generationRejected == nil { t.Error("generation accepted substituted mount") } unmount := unix.Unmount(unitPath, 0) if rejected == nil || unmount != nil { t.Fatal("substituted mount", rejected, unmount) } // A different filesystem cannot use identical file bytes as identity. original, _, err := files.read(b.Shape.Unit) if err != nil { t.Fatal(err) } if err = unix.Mount("tmpfs", files.dir, "tmpfs", 0, "mode=0755"); err != nil { t.Fatal(err) } writeErr := os.WriteFile(unitPath, original, 0644) rejected = m.verify(b) unmount = unix.Unmount(files.dir, 0) if writeErr != nil || rejected == nil || unmount != nil { t.Fatal("substituted filesystem", writeErr, rejected, unmount) } if err = m.verify(b); err != nil { t.Fatal("restored unit", err) } if err = m.retire(t.Context(), b, func() error { return nil }); err != nil { t.Fatal("cross-boot retirement", err) } old := b old.Retiring = true old.Retired = true next := b next.Owner.Stamp++ if err = m.handover(t.Context(), old, next); err != nil { t.Fatal("successor handover", err) } if err = m.verify(next); err != nil { t.Fatal(err) } if err = m.retire(context.Background(), b, func() error { return nil }); err == nil { t.Fatal("old owner retired successor") } }