| 1 | package release |
| 2 | |
| 3 | import ( |
| 4 | "errors" |
| 5 | "fmt" |
| 6 | "io/fs" |
| 7 | "log" |
| 8 | "os" |
| 9 | "path/filepath" |
| 10 | "strings" |
| 11 | "sync" |
| 12 | "sync/atomic" |
| 13 | "syscall" |
| 14 | "time" |
| 15 | |
| 16 | "github.com/carlosframework/platform/internal/safefile" |
| 17 | ) |
| 18 | |
| 19 | // RequirePointerDigestEnv is the box flag that refuses every channel pointer |
| 20 | // without digests, even for a channel this box has never seen (spec §4.6, the |
| 21 | // fleet-wide switch). Off by default; it goes on once `carlos channels |
| 22 | // digests` lists nothing. |
| 23 | const RequirePointerDigestEnv = "CARLOS_REQUIRE_POINTER_DIGEST" |
| 24 | |
| 25 | // ErrPointerWithoutDigests is a pointer the ratchet or the flag refuses. |
| 26 | var ErrPointerWithoutDigests = errors.New("release: pointer carries no digests") |
| 27 | |
| 28 | // ErrRatchetUnpersisted is a pointer with digests whose channel mark could |
| 29 | // not be written: nothing installs or extracts on it until the mark is |
| 30 | // durable, because a restart would otherwise forget the channel (Astra I1 #4). |
| 31 | var ErrRatchetUnpersisted = errors.New("release: the pointer digest mark could not be persisted") |
| 32 | |
| 33 | // errRatchetMarkUnreadable marks Admit's refusal of a pointer without |
| 34 | // digests because the channel's mark could not be read: unlike the flag or |
| 35 | // a mark that is there, it is not a verdict, so a caller that can retry |
| 36 | // (the held stage) does. markUnreadable carries it without changing the |
| 37 | // error's text. |
| 38 | var errRatchetMarkUnreadable = errors.New("release: the pointer digest mark could not be read") |
| 39 | |
| 40 | type markUnreadable struct{ err error } |
| 41 | |
| 42 | func (e markUnreadable) Error() string { return e.err.Error() } |
| 43 | func (e markUnreadable) Unwrap() error { return e.err } |
| 44 | func (markUnreadable) Is(target error) bool { return target == errRatchetMarkUnreadable } |
| 45 | |
| 46 | // RequirePointerDigest reads the flag: 1, true, on or yes in any case. |
| 47 | func RequirePointerDigest(getenv func(string) string) bool { |
| 48 | switch strings.ToLower(strings.TrimSpace(getenv(RequirePointerDigestEnv))) { |
| 49 | case "1", "true", "on", "yes": |
| 50 | return true |
| 51 | } |
| 52 | return false |
| 53 | } |
| 54 | |
| 55 | // RootRatchetDir is root's marks (/opt/carlos/pointer-digests), and |
| 56 | // EdgeRatchetDir the sandboxed agent's and edge's, which cannot write /opt |
| 57 | // (/data/pointer-digests). Each process writes its own and reads both. A mark |
| 58 | // is one flat file per channel, <dir>/<acct>/<app>/<channel>.digests, with |
| 59 | // every "/" in the channel written as "%2F" (see markPath). |
| 60 | func RootRatchetDir(optDir string) string { return filepath.Join(optDir, "pointer-digests") } |
| 61 | func EdgeRatchetDir(dataDir string) string { return filepath.Join(dataDir, "pointer-digests") } |
| 62 | |
| 63 | // DigestRatchet is the per-box read-side protection of #253 (spec §4.6): |
| 64 | // once this box has verified a pointer with digests for a channel, it |
| 65 | // refuses pointers without them for that channel, so a saved old pointer |
| 66 | // cannot be replayed to it. Marks are files, so they outlive the process; |
| 67 | // they are never cleared, and removing the emission marker does not clear |
| 68 | // them. |
| 69 | type DigestRatchet struct { |
| 70 | own string |
| 71 | also []string |
| 72 | // require is atomic: SharedDigestRatchet (Task 8) may widen it while |
| 73 | // another goroutine's Admit reads it (Astra I3 #7). |
| 74 | require atomic.Bool |
| 75 | |
| 76 | mu sync.Mutex |
| 77 | seen map[string]bool // channels found marked, in any dir, by this process |
| 78 | } |
| 79 | |
| 80 | // NewDigestRatchet reads marks from own and also, and writes them to own. |
| 81 | func NewDigestRatchet(own string, also []string, require bool) *DigestRatchet { |
| 82 | r := &DigestRatchet{own: own, also: also, seen: map[string]bool{}} |
| 83 | r.require.Store(require) |
| 84 | return r |
| 85 | } |
| 86 | |
| 87 | // markPath is a channel's mark: <dir>/<acct>/<app>/<channel>.digests, one |
| 88 | // flat file, with every "/" in the channel escaped as "%2F". A channel name |
| 89 | // never contains "%", so the encoding is collision-free: "canary" and |
| 90 | // "canary/x", or "foo" and "foo.digests/bar", each get a file of their own |
| 91 | // and never need the other's path to be a directory (Astra r1 #5). |
| 92 | func markPath(dir, acct, app, channel string) string { |
| 93 | return filepath.Join(dir, acct, app, strings.ReplaceAll(channel, "/", "%2F")+".digests") |
| 94 | } |
| 95 | |
| 96 | // Admit is the ratchet's verdict on a verified pointer. A nil ratchet admits |
| 97 | // everything, which is today's behaviour for any caller that does not arm |
| 98 | // one. A pointer with digests marks its channel: in memory at once, so this |
| 99 | // process refuses a digestless replay from that moment, and on disk before |
| 100 | // the pointer is admitted. A mark that cannot be persisted refuses the |
| 101 | // pointer (ErrRatchetUnpersisted): installing or extracting on it would leave |
| 102 | // the box unprotected after a restart. A mark that cannot be read (anything |
| 103 | // but a confirmed absence) refuses a pointer without digests: only a box that |
| 104 | // knows it has never seen digests for the channel admits one (Astra r1 #1). |
| 105 | func (r *DigestRatchet) Admit(acct string, p *ChannelPointer) error { |
| 106 | if r == nil || p == nil { |
| 107 | return nil |
| 108 | } |
| 109 | if p.CarriesDigests() { |
| 110 | return r.note(acct, p.App, p.Channel) |
| 111 | } |
| 112 | if r.require.Load() { |
| 113 | return fmt.Errorf("%w: %s/%s/%s, and %s is on", ErrPointerWithoutDigests, acct, p.App, p.Channel, RequirePointerDigestEnv) |
| 114 | } |
| 115 | marked, err := r.marked(acct, p.App, p.Channel) |
| 116 | if err != nil { |
| 117 | return fmt.Errorf("%w: %s/%s/%s, and this box cannot tell whether it has already verified one with digests for that channel: %w", |
| 118 | ErrPointerWithoutDigests, acct, p.App, p.Channel, markUnreadable{err}) |
| 119 | } |
| 120 | if marked { |
| 121 | return fmt.Errorf("%w: %s/%s/%s, and this box has already verified one with digests for that channel", |
| 122 | ErrPointerWithoutDigests, acct, p.App, p.Channel) |
| 123 | } |
| 124 | return nil |
| 125 | } |
| 126 | |
| 127 | // marked reports whether any of the ratchet's directories holds the channel's |
| 128 | // mark. Only a confirmed absence (ENOENT, or ENOTDIR on a path component) |
| 129 | // counts as "not marked"; a mark must be a regular file, and any other answer |
| 130 | // (EACCES, EIO, a directory where the mark belongs) is an error, which the |
| 131 | // caller treats as fail-closed. A mark found in any directory wins over an |
| 132 | // error in another, since both refuse. |
| 133 | func (r *DigestRatchet) marked(acct, app, channel string) (bool, error) { |
| 134 | k := acct + "|" + app + "|" + channel |
| 135 | r.mu.Lock() |
| 136 | defer r.mu.Unlock() |
| 137 | if r.seen[k] { |
| 138 | return true, nil |
| 139 | } |
| 140 | var firstErr error |
| 141 | for _, dir := range append([]string{r.own}, r.also...) { |
| 142 | if dir == "" { |
| 143 | continue |
| 144 | } |
| 145 | path := markPath(dir, acct, app, channel) |
| 146 | fi, err := os.Stat(path) |
| 147 | switch { |
| 148 | case err == nil && fi.Mode().IsRegular(): |
| 149 | r.seen[k] = true |
| 150 | return true, nil |
| 151 | case err == nil: |
| 152 | if firstErr == nil { |
| 153 | firstErr = fmt.Errorf("pointer digest mark %s is not a regular file", path) |
| 154 | } |
| 155 | case errors.Is(err, fs.ErrNotExist) || errors.Is(err, syscall.ENOTDIR): |
| 156 | // confirmed absent |
| 157 | default: |
| 158 | if firstErr == nil { |
| 159 | firstErr = err |
| 160 | } |
| 161 | } |
| 162 | } |
| 163 | return false, firstErr |
| 164 | } |
| 165 | |
| 166 | func (r *DigestRatchet) note(acct, app, channel string) error { |
| 167 | k := acct + "|" + app + "|" + channel |
| 168 | r.mu.Lock() |
| 169 | defer r.mu.Unlock() |
| 170 | r.seen[k] = true // remembered at once, whatever the disk says |
| 171 | if r.own == "" { |
| 172 | return nil |
| 173 | } |
| 174 | path := markPath(r.own, acct, app, channel) |
| 175 | if fi, err := os.Stat(path); err == nil && fi.Mode().IsRegular() { |
| 176 | return nil |
| 177 | } |
| 178 | if err := safefile.MkdirAll(filepath.Dir(path), 0o755); err != nil { |
| 179 | log.Printf("release: pointer digest ratchet: %v", err) |
| 180 | return fmt.Errorf("%w: %v", ErrRatchetUnpersisted, err) |
| 181 | } |
| 182 | if err := WriteFileAtomic(path, []byte(time.Now().UTC().Format(time.RFC3339)+"\n"), 0o644); err != nil { |
| 183 | log.Printf("release: pointer digest ratchet: %v", err) |
| 184 | return fmt.Errorf("%w: %v", ErrRatchetUnpersisted, err) |
| 185 | } |
| 186 | return nil |
| 187 | } |
| 188 | |