//go:build linux && rootrunner package host import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "os" "path/filepath" "testing" "github.com/carlosframework/platform/internal/release" "github.com/carlosframework/platform/internal/rootstate" "golang.org/x/sys/unix" ) const generationPinRebootRoot = "/var/lib/carlos-e53-pins" func TestRootRebootGenerationPinIdentityPhase1(t *testing.T) { if os.Geteuid() != 0 { t.Fatal("requires root") } if err := os.Mkdir(generationPinRebootRoot, 0700); err != nil { t.Fatal(err) } path := release.VersionPath(filepath.Join(generationPinRebootRoot, "opt"), "bab", "v1", "app") tree := filepath.Dir(path) if err := os.MkdirAll(tree, 0755); err != nil { t.Fatal(err) } if err := os.WriteFile(path, []byte("app"), 0755); err != nil { t.Fatal(err) } capture := func(path string, dir bool) rootstate.FileIdentity { var f *os.File var err error if dir { f, err = rootstate.OpenAuthorityDirectory(path) } else { f, err = rootstate.OpenAuthorityFile(path) } if err != nil { t.Fatal(err) } defer f.Close() id, err := rootstate.PersistentIdentity(f) if err != nil { t.Fatal(err) } return id } exe, parent := capture(path, false), capture(tree, true) // Force boot-local renumbering even when this runner reuses device numbers. exe.Device, parent.Device = exe.Device^0x100000, parent.Device^0x100000 exe.Mount, parent.Mount = exe.Mount+1000, parent.Mount+1000 digest := sha256.Sum256([]byte("app")) b := release.GenerationBinding{Binding: release.HistoryBinding{Host: "inventory.example", Account: "bab", App: "app"}, Installation: "installation", Record: "record", RecordETag: "etag", Attempt: "main", Inputs: "inputs", Invocation: "invocation", ControllerPID: os.Getpid(), ControllerStart: "fixture", Version: "v1", Digest: hex.EncodeToString(digest[:]), Path: path, Tree: tree, Executable: rootstate.FileIdentity{Device: exe.Device, Inode: exe.Inode}, TreeIdentity: rootstate.FileIdentity{Device: parent.Device, Inode: parent.Inode}, ExecutableProof: exe, TreeProof: parent, Target: release.GenerationOwner{Host: "inventory.example", Incarnation: "inc", Stamp: 1, Session: 1, Box: "box", Enrollment: "enrollment", BootID: exe.Boot, ControllerID: "fixture"}} state, err := rootstate.Open(filepath.Join(generationPinRebootRoot, "generations"), true, false) if err != nil { t.Fatal(err) } defer state.Close() if _, err := (&release.RootGenerations{Store: state}).Reserve(b); err != nil { t.Fatal(err) } raw, err := json.Marshal(b) if err != nil { t.Fatal(err) } if err := writeFileDurable(filepath.Join(generationPinRebootRoot, "binding.json"), raw, 0600); err != nil { t.Fatal(err) } t.Logf("generation saved boot=%s device=%d mount=%d", exe.Boot, exe.Device, exe.Mount) } func TestRootRebootGenerationPinIdentityPhase2(t *testing.T) { if os.Geteuid() != 0 { t.Fatal("requires root") } defer os.RemoveAll(generationPinRebootRoot) raw, err := os.ReadFile(filepath.Join(generationPinRebootRoot, "binding.json")) var b release.GenerationBinding if err != nil || json.Unmarshal(raw, &b) != nil { t.Fatal("binding", err) } current, err := claimUnitBoot() if err != nil || current == b.Target.BootID { t.Fatal("requires actual reboot", err) } state, err := rootstate.Open(filepath.Join(generationPinRebootRoot, "generations"), false, false) if err != nil { t.Fatal(err) } defer state.Close() ledger := &release.RootGenerations{Store: state} // Completeness is supplied; persisted file and directory pin checks are real. source := release.RootGenerationPins{Ledger: ledger, Verify: func(context.Context, []release.GenerationReservation) error { return nil }} check := func() error { pins, err := source.StagedPins(t.Context(), filepath.Join(generationPinRebootRoot, "opt")) if err != nil { return err } held, err := pins.Refers(b.Path) if err != nil { return err } if !held { return os.ErrPermission } return nil } if err := check(); err != nil { t.Fatal("cross-boot staged pin", err) } if err := unix.Mount(b.Tree, b.Tree, "", unix.MS_BIND, ""); err != nil { t.Fatal(err) } if err := check(); err == nil { t.Error("staged pin accepted substituted mount") } if err := unix.Unmount(b.Tree, 0); err != nil { t.Fatal(err) } if err := os.Rename(b.Path, b.Path+".saved"); err != nil { t.Fatal(err) } if err := os.WriteFile(b.Path, []byte("replacement"), 0755); err != nil { t.Fatal(err) } if err := check(); err == nil { t.Error("staged pin accepted replacement") } if err := os.Remove(b.Path); err != nil { t.Fatal(err) } if err := os.Rename(b.Path+".saved", b.Path); err != nil { t.Fatal(err) } if err := os.Chown(filepath.Join(generationPinRebootRoot, "opt"), 65534, 65534); err != nil { t.Fatal(err) } if err := check(); err == nil { t.Error("staged pin accepted untrusted ancestry") } if err := os.Chown(filepath.Join(generationPinRebootRoot, "opt"), 0, 0); err != nil { t.Fatal(err) } if err := check(); err != nil { t.Fatal("restored staged pin", err) } saved, err := ledger.Read(b) if err != nil || saved.Binding != b { t.Fatal("rewrote old generation identity", err) } t.Logf("staged pin retained across boot %s -> %s", b.Target.BootID, current) }