| new file mode 100644 |
| index 000000000..74d2d4d1a |
| --- /dev/null |
| +++ b/internal/release/ratchet.go |
| @@ -0,0 +1,138 @@ |
| +package release |
| + |
| +import ( |
| + "errors" |
| + "fmt" |
| + "log" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "sync" |
| + "sync/atomic" |
| + "time" |
| +) |
| + |
| +// 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") |
| + |
| +// 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. |
| +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. The ".digests" suffix lets "canary" and |
| +// "canary/x" both be marked: the second needs a directory named canary. |
| +func markPath(dir, acct, app, channel string) string { |
| + return filepath.Join(dir, acct, app, filepath.FromSlash(channel)+".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. |
| +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) |
| + } |
| + if r.marked(acct, p.App, p.Channel) { |
| + 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 |
| +} |
| + |
| +func (r *DigestRatchet) marked(acct, app, channel string) bool { |
| + k := acct + "|" + app + "|" + channel |
| + r.mu.Lock() |
| + defer r.mu.Unlock() |
| + if r.seen[k] { |
| + return true |
| + } |
| + for _, dir := range append([]string{r.own}, r.also...) { |
| + if dir == "" { |
| + continue |
| + } |
| + if _, err := os.Stat(markPath(dir, acct, app, channel)); err == nil { |
| + r.seen[k] = true |
| + return true |
| + } |
| + } |
| + return false |
| +} |
| + |
| +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 _, err := os.Stat(path); err == nil { |
| + return nil |
| + } |
| + if err := os.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 |
| +} |