| 1 | package hostbox |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "errors" |
| 6 | "os" |
| 7 | "path/filepath" |
| 8 | "strings" |
| 9 | |
| 10 | "github.com/carlosframework/platform/internal/hostguard" |
| 11 | "github.com/carlosframework/platform/internal/provision" |
| 12 | "github.com/carlosframework/platform/internal/registry" |
| 13 | ) |
| 14 | |
| 15 | // ValidateAgentInventory confines agent-authored artifacts to this route's |
| 16 | // state directory and registry rows. Opt and units need root-captured authority; |
| 17 | // an inventory written by the agent never supplies that authority. |
| 18 | func ValidateAgentInventory(in StopInventory, row *registry.Route, dataDir string) error { |
| 19 | if !component(in.Host) { |
| 20 | return ErrTypedInventory |
| 21 | } |
| 22 | if row != nil && (row.Host != in.Host || row.ClaimIncarnation != in.Incarnation || row.ClaimStamp != in.InstallationStamp || !row.ClaimOwnership.Valid()) { |
| 23 | return ErrTypedInventory |
| 24 | } |
| 25 | seen := make(map[string]bool) |
| 26 | for _, e := range in.Entries { |
| 27 | if e.TimerRetired && e.Kind != "timer" { |
| 28 | return ErrTypedInventory |
| 29 | } |
| 30 | key := e.ManagedRoot + "\x00" + e.RelativePath |
| 31 | if seen[key] || !managedName(e.RelativePath) || (row != nil && e.Ownership != row.ClaimOwner) { |
| 32 | return ErrTypedInventory |
| 33 | } |
| 34 | seen[key] = true |
| 35 | switch e.Kind { |
| 36 | case "route": |
| 37 | if e.ManagedRoot != "registry" || e.RelativePath != in.Host || e.RowIdentity == "" || (row != nil && e.RowIdentity != row.ClaimRowID) || (row == nil && e.Materialization != "intended") { |
| 38 | return ErrTypedInventory |
| 39 | } |
| 40 | case "timer": |
| 41 | if row == nil || e.ManagedRoot != "registry" || !strings.HasPrefix(e.RelativePath, in.Host+"/") || !component(strings.TrimPrefix(e.RelativePath, in.Host+"/")) || e.RowIdentity == "" { |
| 42 | return ErrTypedInventory |
| 43 | } |
| 44 | case "path": |
| 45 | if row == nil || e.ManagedRoot != "data" || !filepath.IsAbs(dataDir) || !filepath.IsAbs(row.DBPath) { |
| 46 | return ErrTypedInventory |
| 47 | } |
| 48 | if row.DBPath != provision.NestedDBPath(dataDir, in.Host) && row.DBPath != provision.DefaultDBPath(dataDir, in.Host) { |
| 49 | return ErrTypedInventory |
| 50 | } |
| 51 | if filepath.Clean(e.RelativePath) != e.RelativePath || !filepath.IsLocal(e.RelativePath) { |
| 52 | return ErrTypedInventory |
| 53 | } |
| 54 | parent, err := filepath.Rel(dataDir, filepath.Dir(row.DBPath)) |
| 55 | if err != nil || (parent != "." && !managedName(parent)) { |
| 56 | return ErrTypedInventory |
| 57 | } |
| 58 | if parent == "." { |
| 59 | if row.DBPath == filepath.Join(dataDir, "registry.db") { |
| 60 | return ErrTypedInventory |
| 61 | } |
| 62 | // Flat legacy layouts: the database and its exact SQLite |
| 63 | // companion names. Enabled claim launches require the nested |
| 64 | // layout, so an agent has no flat state-directory authority. |
| 65 | db := filepath.Base(row.DBPath) |
| 66 | isDB := e.RelativePath == db || e.RelativePath == db+"-wal" || e.RelativePath == db+"-shm" || e.RelativePath == db+"-journal" |
| 67 | if !isDB { |
| 68 | return ErrTypedInventory |
| 69 | } |
| 70 | } else if e.RelativePath != parent && !strings.HasPrefix(e.RelativePath, parent+string(filepath.Separator)) { |
| 71 | // The host's own nested state container (root derives it from |
| 72 | // NestedDBPath), or a path inside it. Parking the empty container |
| 73 | // is what lets a cold handover find the namespace empty. |
| 74 | return ErrTypedInventory |
| 75 | } |
| 76 | default: |
| 77 | return ErrTypedInventory |
| 78 | } |
| 79 | } |
| 80 | return nil |
| 81 | } |
| 82 | |
| 83 | // retirementRoute recovers the row root archived before removing it. The |
| 84 | // agent's inventory carries no database path and cannot choose this namespace. |
| 85 | func (a ReconcileAdapter) retirementRoute(row registry.Route, target hostguard.FenceTarget) (*registry.Route, error) { |
| 86 | current, err := a.Registry.Get(row.Host) |
| 87 | if err != nil { |
| 88 | return nil, err |
| 89 | } |
| 90 | if current != nil && current.ClaimOwnership == row.ClaimOwnership { |
| 91 | return current, nil |
| 92 | } |
| 93 | raw, err := readTrustedJournal(filepath.Join(a.Retirement.JournalDir, target.FenceID+".json")) |
| 94 | if errors.Is(err, os.ErrNotExist) { |
| 95 | return nil, ErrTypedInventory |
| 96 | } |
| 97 | if err != nil { |
| 98 | return nil, err |
| 99 | } |
| 100 | var journal RetirementJournal |
| 101 | if decodeStrict(raw, &journal) != nil || journal.Target == nil || *journal.Target != target || journal.ID != target.FenceID { |
| 102 | return nil, ErrTypedJournal |
| 103 | } |
| 104 | var archives []registry.ClaimRowArchive |
| 105 | if json.Unmarshal(journal.Archives, &archives) != nil { |
| 106 | return nil, ErrTypedJournal |
| 107 | } |
| 108 | var found *registry.Route |
| 109 | for _, archive := range archives { |
| 110 | if archive.Row.Kind != "route" { |
| 111 | continue |
| 112 | } |
| 113 | var saved registry.Route |
| 114 | if found != nil || archive.Row.Host != row.Host || archive.Row.Ownership != row.ClaimOwnership || json.Unmarshal(archive.Contents, &saved) != nil || saved.Host != row.Host || saved.ClaimOwnership != row.ClaimOwnership { |
| 115 | return nil, ErrTypedJournal |
| 116 | } |
| 117 | found = &saved |
| 118 | } |
| 119 | if found == nil { |
| 120 | return nil, ErrTypedInventory |
| 121 | } |
| 122 | return found, nil |
| 123 | } |
| 124 | |