package host import ( "bufio" "bytes" "os" "path/filepath" "slices" "strings" "github.com/carlosframework/platform/internal/hostbox" "github.com/carlosframework/platform/internal/hostwrite" "github.com/carlosframework/platform/internal/release" "github.com/carlosframework/platform/internal/rollout" ) // activationLockName is the lock install-control and its siblings hold in // the directory they install into (activationLock). const activationLockName = ".claims-activation.lock" // claimGlobalSharedPaths is the empty-installation census's classification // of what the box's own installers put under the managed roots (host // lifecycle claim P6-D2: shared release directories, shared unit templates // and the registry database are not host-owned). Each entry is derived from // the installer that writes it: bootstrap's embedded unit set and arming // plan, the self-update applier's opt layout and the release store's // version layout. Nothing here is a wildcard. Per-host live links, unit // instances, drop-ins and env files stay unclassified, so the census // refuses them. func claimGlobalSharedPaths(r ClaimRuntimeResources) map[string][]string { return map[string][]string{ "data": claimDataCensus(r.DataDir), "opt": claimOptCensus(r.OptDir), "units": claimUnitCensus(r.UnitsDir), } } // claimCensusRel names path below root, or "" (which the census refuses as // a malformed classification) when it is not strictly inside it. func claimCensusRel(root, path string) string { rel, err := filepath.Rel(root, path) if err != nil || !filepath.IsLocal(rel) { return "" } return filepath.ToSlash(rel) } func claimDataCensus(dataDir string) []string { // The claim journals and guard records verify their own contents; the // census reads neither. The registry is bootstrap's file, shared by // every host, plus SQLite's sidecars once something has opened it. reg := claimCensusRel(dataDir, filepath.Join(dataDir, "registry.db")) // Region HA's relocation journals (N9) verify their own contents, like // the claim's: install and attempt journals, release positions and the // build's scratch, every one keyed by op id under relocate/ and swept // once its operation is over. No installation artifact lives there. // Moves' held-stage relay (N11, until moves ask M-K25 classifies it): // requests and acks are keyed by host, validated field by field by // root against the claim before any effect, and never an installation // artifact. A relocation target whose stage is blocked leaves its // request there (M3). stage := claimCensusRel(dataDir, release.HeldStageDir(dataDir)) staged := claimCensusRel(dataDir, release.HeldStagedDir(dataDir)) // And its held-stage lock (Task 8c fix round 1, I1): every held // publication, root's relay and the relocation install's own, holds // .moves/held-.lock (withHeldStageLock). Moves removes it when the // stage finishes (M-K25 on #236), but a holder killed mid-stage leaves // it, so the fallback stays, as moves asked: .moves is a shared tree, // not opaque: regular files and directories only, and anything naming // the host being censused still refuses. entries := []string{".hostclaims", ".hostexec", "hostguard", "relocate", stage, staged, hostbox.CensusTree(movesDirName), hostbox.CensusFile(reg), hostbox.CensusFile(reg + "-wal"), hostbox.CensusFile(reg + "-shm")} return append(entries, claimBoxDataCensus(dataDir)...) } // claimBoxDataCensus is the box's own state under the data root, found on a // serving box (leo, 2026-10-19) and owned by no installation (step 8 // readiness, R2 (b) and (c)). Each entry is the exact path its writer // uses, a regular file only, except the edge's certificate cache, which is a // shared tree of directories and regular files. Nothing here names a host, // and the per-host census still refuses any path that names the host it // censuses, so a certificate for that host still refuses its install. func claimBoxDataCensus(dataDir string) []string { rel := func(path string) string { return claimCensusRel(dataDir, path) } files := []string{ // The edge's autocert cache: certificates and keys, the ACME account // key (edge.go's -acme-cache default; bootstrap makes the directory). hostbox.CensusTree(rel(filepath.Join(dataDir, "acme"))), // The agent's and edge's pointer-digest ratchet (#253): one mark per // channel, //.digests. Box anti-rollback state, // never cleared and never an installation artifact (#324). hostbox.CensusTree(rel(release.EdgeRatchetDir(dataDir))), // The agent's system update state: its request (requested.json, also // a file below) and its cache of the router build it fetched, // /carlos (systemCachePath). Root walks the cache beneath // the data dir and checks it against the release's signed sums before // it uses it; a normal update leaves it until the agent's next hourly // observe forgets it, so a migration window can meet it (harness, // 2026-10-20). Box state, never an installation artifact. hostbox.CensusTree(rel(systemDataDir(dataDir))), } for _, path := range []string{ filepath.Join(dataDir, stageRequestMarker), // the agent's root stage request filepath.Join(dataDir, systemRequestMarker), // the agent's self-update request systemRequestPath(dataDir), // the update the agent asked for restartMarksPath(dataDir), // root's restart memory restartMarksPath(dataDir) + ".lock", // and its lock (lockRestartMarks) adoptPassLockPath(dataDir), // every root adoption pass's lock filepath.Join(dataDir, rollout.FailureFile), // the swap failure record release.FirstSeenPath(dataDir), // channel first-seen observations release.GenerationsPath(dataDir), // zero-downtime generations release.GenerationsSeqPath(release.GenerationsPath(dataDir)), // The edge's GeoIP mirror pointer (edge.go newGeoMirror's directory, // geoipmirror.go's "current") and the metrics loop's alert state. filepath.Join(dataDir, "carlos", "geoip", "current"), filepath.Join(dataDir, "monitor", "alerts.json"), } { files = append(files, hostbox.CensusFile(rel(path))) } return files } func claimOptCensus(optDir string) []string { entries := []string{ // The staged platform binary and the self-update applier's fallback // with its sidecars. hostbox.CensusFile(claimCensusRel(optDir, systemBinaryPath(optDir))), hostbox.CensusFile(claimCensusRel(optDir, systemPrevPath(optDir))), hostbox.CensusFile(claimCensusRel(optDir, prevVersionPath(optDir))), hostbox.CensusFile(claimCensusRel(optDir, prevAllowedPath(optDir))), // The seeds floor's verdict on carlos.prev (seedsfloor.go), beside // the moves verdict and written by the same root passes. Its floor // files live in .moves, the shared tree above. hostbox.CensusFile(claimCensusRel(optDir, seedsPrevAllowedPath(optDir))), // The shared release store: /versions//[/]. // Directories and regular files only; a symlink in it is refused. hostbox.CensusTree(claimCensusRel(optDir, filepath.Dir(filepath.Dir(release.VersionPath(optDir, "", "v", "a"))))), // Root's pointer-digest ratchet (#253), the same marks as the edge's // under the data root; root reads both, so neither is moved aside. hostbox.CensusTree(claimCensusRel(optDir, release.RootRatchetDir(optDir))), } // Root's self-update state and the host-floor guard copy. Both are box // state below one parent; it is opaque to the census, as before. for _, dir := range []string{systemRootDir(optDir), systemGuardDir(optDir)} { if rel := claimCensusRel(optDir, filepath.Dir(dir)); !slices.Contains(entries, rel) { entries = append(entries, rel) } } // /live itself is only a directory. Every link in it belongs to a // host and is unclassified. return entries } // claimCensusEnabledUnits is every unit an installer enables: bootstrap's // arming plan (which a self-update apply also arms, from the target build's // own plan) and ensureSystemUnits' second belt. func claimCensusEnabledUnits() []string { units := currentBootstrapPlan().Enabled for _, u := range ensureSystemArmed { if !slices.Contains(units, u) { units = append(units, u) } } return units } func claimUnitCensus(unitsDir string) []string { names, err := unitNames() if err != nil { // Nothing of the platform's is classified, so the census refuses. return nil } var entries []string for _, name := range names { // Every embedded unit, templates, slices and mounts included, is a // file bootstrap and the applier install for the whole box. entries = append(entries, hostbox.CensusFile(name)) } for _, unit := range []string{"carlos-edge.service", "carlos-system-update.service", "carlos-system-recover.service"} { entries = append(entries, hostbox.CensusFile(unit+".d/90-host-floor.conf")) } // install-control's lock (activationLock), left in the unit directory // after it runs. It stays: unlinking a flocked file lets a second // installer lock a fresh inode beside the first. entries = append(entries, hostbox.CensusFile(activationLockName)) for _, unit := range claimCensusEnabledUnits() { if !slices.Contains(names, unit) || strings.Contains(unit, "@") { continue } body, err := unitsFS.ReadFile("units/" + unit) if err != nil { continue } target := filepath.Join(unitsDir, unit) for _, link := range claimUnitInstallLinks(unit, body) { entries = append(entries, hostbox.CensusLink(link, target)) } } if hostwrite.Enabled(os.Getenv) && slices.Contains(names, claimControlUnit) { // Claim rollout uses add-wants to start the static root service at // boot. Its unit has no [Install] metadata to derive this link from. entries = append(entries, hostbox.CensusLink("multi-user.target.wants/"+claimControlUnit, filepath.Join(unitsDir, claimControlUnit))) } return entries } // claimUnitInstallLinks lists the symlinks `systemctl enable` creates for a // non-template unit from its [Install] section, relative to the unit // directory. A value it cannot read exactly (a specifier, an Also= list) is // left out, so the link it would have made stays unclassified. func claimUnitInstallLinks(unit string, body []byte) []string { var links []string section := "" scan := bufio.NewScanner(bytes.NewReader(body)) for scan.Scan() { line := strings.TrimSpace(scan.Text()) if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") { continue } if strings.HasPrefix(line, "[") { section = line continue } key, value, ok := strings.Cut(line, "=") if section != "[Install]" || !ok { continue } suffix := map[string]string{"WantedBy": ".wants/", "RequiredBy": ".requires/", "UpheldBy": ".upholds/", "Alias": ""} dir, known := suffix[strings.TrimSpace(key)] if !known { continue } for _, name := range strings.Fields(value) { if strings.ContainsAny(name, "%/\\") { continue } if dir == "" { links = append(links, name) continue } links = append(links, name+dir+unit) } } return links }