package release import ( "errors" "fmt" "io/fs" "log" "os" "path/filepath" "strings" "sync" "sync/atomic" "syscall" "time" "github.com/carlosframework/platform/internal/safefile" ) // RequirePointerDigestEnv is the box flag that refuses every channel pointer // without digests, even for a channel this box has never seen (spec §4.6, the // fleet-wide switch). Off by default; it goes on once `carlos channels // digests` lists nothing. const RequirePointerDigestEnv = "CARLOS_REQUIRE_POINTER_DIGEST" // ErrPointerWithoutDigests is a pointer the ratchet or the flag refuses. var ErrPointerWithoutDigests = errors.New("release: pointer carries no digests") // ErrRatchetUnpersisted is a pointer with digests whose channel mark could // not be written: nothing installs or extracts on it until the mark is // durable, because a restart would otherwise forget the channel (Astra I1 #4). var ErrRatchetUnpersisted = errors.New("release: the pointer digest mark could not be persisted") // errRatchetMarkUnreadable marks Admit's refusal of a pointer without // digests because the channel's mark could not be read: unlike the flag or // a mark that is there, it is not a verdict, so a caller that can retry // (the held stage) does. markUnreadable carries it without changing the // error's text. var errRatchetMarkUnreadable = errors.New("release: the pointer digest mark could not be read") type markUnreadable struct{ err error } func (e markUnreadable) Error() string { return e.err.Error() } func (e markUnreadable) Unwrap() error { return e.err } func (markUnreadable) Is(target error) bool { return target == errRatchetMarkUnreadable } // RequirePointerDigest reads the flag: 1, true, on or yes in any case. func RequirePointerDigest(getenv func(string) string) bool { switch strings.ToLower(strings.TrimSpace(getenv(RequirePointerDigestEnv))) { case "1", "true", "on", "yes": return true } return false } // RootRatchetDir is root's marks (/opt/carlos/pointer-digests), and // EdgeRatchetDir the sandboxed agent's and edge's, which cannot write /opt // (/data/pointer-digests). Each process writes its own and reads both. A mark // is one flat file per channel, ///.digests, with // every "/" in the channel written as "%2F" (see markPath). func RootRatchetDir(optDir string) string { return filepath.Join(optDir, "pointer-digests") } func EdgeRatchetDir(dataDir string) string { return filepath.Join(dataDir, "pointer-digests") } // DigestRatchet is the per-box read-side protection of #253 (spec §4.6): // once this box has verified a pointer with digests for a channel, it // refuses pointers without them for that channel, so a saved old pointer // cannot be replayed to it. Marks are files, so they outlive the process; // they are never cleared, and removing the emission marker does not clear // them. type DigestRatchet struct { own string also []string // require is atomic: SharedDigestRatchet (Task 8) may widen it while // another goroutine's Admit reads it (Astra I3 #7). require atomic.Bool mu sync.Mutex seen map[string]bool // channels found marked, in any dir, by this process } // NewDigestRatchet reads marks from own and also, and writes them to own. func NewDigestRatchet(own string, also []string, require bool) *DigestRatchet { r := &DigestRatchet{own: own, also: also, seen: map[string]bool{}} r.require.Store(require) return r } // markPath is a channel's mark: ///.digests, one // flat file, with every "/" in the channel escaped as "%2F". A channel name // never contains "%", so the encoding is collision-free: "canary" and // "canary/x", or "foo" and "foo.digests/bar", each get a file of their own // and never need the other's path to be a directory (Astra r1 #5). func markPath(dir, acct, app, channel string) string { return filepath.Join(dir, acct, app, strings.ReplaceAll(channel, "/", "%2F")+".digests") } // Admit is the ratchet's verdict on a verified pointer. A nil ratchet admits // everything, which is today's behaviour for any caller that does not arm // one. A pointer with digests marks its channel: in memory at once, so this // process refuses a digestless replay from that moment, and on disk before // the pointer is admitted. A mark that cannot be persisted refuses the // pointer (ErrRatchetUnpersisted): installing or extracting on it would leave // the box unprotected after a restart. A mark that cannot be read (anything // but a confirmed absence) refuses a pointer without digests: only a box that // knows it has never seen digests for the channel admits one (Astra r1 #1). func (r *DigestRatchet) Admit(acct string, p *ChannelPointer) error { if r == nil || p == nil { return nil } if p.CarriesDigests() { return r.note(acct, p.App, p.Channel) } if r.require.Load() { return fmt.Errorf("%w: %s/%s/%s, and %s is on", ErrPointerWithoutDigests, acct, p.App, p.Channel, RequirePointerDigestEnv) } marked, err := r.marked(acct, p.App, p.Channel) if err != nil { return fmt.Errorf("%w: %s/%s/%s, and this box cannot tell whether it has already verified one with digests for that channel: %w", ErrPointerWithoutDigests, acct, p.App, p.Channel, markUnreadable{err}) } if marked { return fmt.Errorf("%w: %s/%s/%s, and this box has already verified one with digests for that channel", ErrPointerWithoutDigests, acct, p.App, p.Channel) } return nil } // marked reports whether any of the ratchet's directories holds the channel's // mark. Only a confirmed absence (ENOENT, or ENOTDIR on a path component) // counts as "not marked"; a mark must be a regular file, and any other answer // (EACCES, EIO, a directory where the mark belongs) is an error, which the // caller treats as fail-closed. A mark found in any directory wins over an // error in another, since both refuse. func (r *DigestRatchet) marked(acct, app, channel string) (bool, error) { k := acct + "|" + app + "|" + channel r.mu.Lock() defer r.mu.Unlock() if r.seen[k] { return true, nil } var firstErr error for _, dir := range append([]string{r.own}, r.also...) { if dir == "" { continue } path := markPath(dir, acct, app, channel) fi, err := os.Stat(path) switch { case err == nil && fi.Mode().IsRegular(): r.seen[k] = true return true, nil case err == nil: if firstErr == nil { firstErr = fmt.Errorf("pointer digest mark %s is not a regular file", path) } case errors.Is(err, fs.ErrNotExist) || errors.Is(err, syscall.ENOTDIR): // confirmed absent default: if firstErr == nil { firstErr = err } } } return false, firstErr } func (r *DigestRatchet) note(acct, app, channel string) error { k := acct + "|" + app + "|" + channel r.mu.Lock() defer r.mu.Unlock() r.seen[k] = true // remembered at once, whatever the disk says if r.own == "" { return nil } path := markPath(r.own, acct, app, channel) if fi, err := os.Stat(path); err == nil && fi.Mode().IsRegular() { return nil } if err := safefile.MkdirAll(filepath.Dir(path), 0o755); err != nil { log.Printf("release: pointer digest ratchet: %v", err) return fmt.Errorf("%w: %v", ErrRatchetUnpersisted, err) } if err := WriteFileAtomic(path, []byte(time.Now().UTC().Format(time.RFC3339)+"\n"), 0o644); err != nil { log.Printf("release: pointer digest ratchet: %v", err) return fmt.Errorf("%w: %v", ErrRatchetUnpersisted, err) } return nil }