carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package release
2
3import (
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.
23const RequirePointerDigestEnv = "CARLOS_REQUIRE_POINTER_DIGEST"
24
25// ErrPointerWithoutDigests is a pointer the ratchet or the flag refuses.
26var 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).
31var 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.
38var errRatchetMarkUnreadable = errors.New("release: the pointer digest mark could not be read")
39
40type markUnreadable struct{ err error }
41
42func (e markUnreadable) Error() string { return e.err.Error() }
43func (e markUnreadable) Unwrap() error { return e.err }
44func (markUnreadable) Is(target error) bool { return target == errRatchetMarkUnreadable }
45
46// RequirePointerDigest reads the flag: 1, true, on or yes in any case.
47func 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).
60func RootRatchetDir(optDir string) string { return filepath.Join(optDir, "pointer-digests") }
61func 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.
69type 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.
81func 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).
92func 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).
105func (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.
133func (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
166func (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