carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// The on-demand READ door (spec §5.2): what is known about one object right
2// now, optionally waiting a little for it.
3//
4// Its own file rather than more of apiscan.go, because the scan family there
5// is about POLICY — who may scan, whether enforcement is on — and this one
6// answers a question about a single object for the app that owns it. They
7// share the consent chain and nothing else.
8package console
9
10import (
11 "context"
12 "errors"
13 "log"
14 "net/http"
15 "strings"
16 "time"
17
18 "github.com/carlosframework/platform/internal/objstore"
19)
20
21// scanWaitMax is the long-poll ceiling. Beyond this a client should be
22// polling: a request parked longer than half a minute is holding a console
23// goroutine against an answer that may never come in this process at all.
24const scanWaitMax = 30 * time.Second
25
26// scanWaitersPerApp and scanWaitersGlobal cap parked requests.
27//
28// BOTH, and the per-app one is not implied by the global one: without it a
29// single tenant fills the global budget and every other tenant's wait
30// degrades to polling. Without the GLOBAL one, enough tenants at their
31// per-app limit still add up to more parked goroutines than a console should
32// hold. Neither is a refusal — over the cap the door answers `pending`
33// immediately, which is TRUE, and the client polls.
34const (
35 scanWaitersPerApp = 32
36 scanWaitersGlobal = 256
37)
38
39// scanStatusOf reads what is known about one object, in the order §5.2 sets:
40// the object's own tag first, the fallback result second.
41//
42// THE TAG WINS, and the order is not a preference. A result object exists only
43// because a verdict could not be written onto the object; if the object now
44// carries one, the object is the more recent and more authoritative answer,
45// and the drain deletes the result the moment that happens.
46//
47// The version comparison happens inside the scanner and inside
48// ReadScanResult. It never reaches this door and never reaches an app: the
49// token is an internal format, and an app comparing it wrongly gets the
50// laundering bug as a correctness bug — upload v2 over a key whose v1 was
51// tagged clean, read the stale tag, serve the payload.
52func (s *Server) scanStatusOf(ctx context.Context, acct, app, bucket, key string) (map[string]any, error) {
53 verdict, obj, err := s.cfg.StoreScanner.Status(ctx, bucket, key)
54 if errors.Is(err, objstore.ErrObjectGone) {
55 return map[string]any{"state": "gone", "recorded": false}, nil
56 }
57 if err != nil {
58 return nil, err
59 }
60 if verdict != objstore.VerdictUnknown {
61 // REPORTED AS THEMSELVES. `failed` and `denied` are verdicts, not
62 // absences: the landed tag reader maps them back to unknown so the
63 // SWEEP retries them, which is right as retry policy and wrong as an
64 // answer to a question about an object. Left applied here, a failed
65 // scan would read as `pending` forever — the drain deletes the item
66 // as handled, and nothing changes the answer until a sweep re-fails
67 // it.
68 //
69 // `denied` is not claimed as recorded: Status also answers it for a
70 // refused HeadObject or tag read, where no tag was seen at all.
71 return map[string]any{"state": string(verdict), "recorded": verdict != objstore.VerdictDenied}, nil
72 }
73 // No usable tag. The object may still have an answer that could not be
74 // written onto it.
75 if obj == (objstore.ScanObject{}) {
76 // A denied HeadObject: no version, so no result object can be matched
77 // against it either. Status already said what this is.
78 return map[string]any{"state": "pending", "recorded": false}, nil
79 }
80 res, err := objstore.ReadScanResult(ctx, s.cfg.Releases, acct, app, key, obj.Version())
81 if err != nil {
82 return nil, err
83 }
84 if res == nil {
85 // Absent, or recorded against different bytes — ReadScanResult makes
86 // no distinction and neither should this door. Nothing is known about
87 // the object that is there NOW.
88 return map[string]any{"state": "pending", "recorded": false}, nil
89 }
90 // The verdict is on a result object rather than the object itself, which
91 // the app has to know: an infected object answered this way is NOT covered
92 // by the enforcement Deny, because that matches on a tag that is not
93 // there.
94 out := map[string]any{"state": res.Verdict, "recorded": false}
95 // Provenance, but only where it is actually known. A result object records
96 // the engine and signature that judged it; a TAG, through the Status seam
97 // as it stands, yields the verdict alone. Reporting this deployment's
98 // current engine for a tag would be a guess dressed as a record — the
99 // engine may have been replaced since — so the tag branch above says
100 // nothing rather than something plausible.
101 if res.Engine != "" {
102 out["engine"] = res.Engine
103 }
104 if res.Signature != "" {
105 out["signature"] = res.Signature
106 }
107 if !res.At.IsZero() {
108 out["at"] = res.At.UTC().Format(time.RFC3339)
109 }
110 return out, nil
111}
112
113// scanStatusPermitted runs the consent chain and answers any refusal itself.
114// The door calls it before EVERY read, so a revoke or disable that lands while
115// a request is parked stops the next read rather than being answered past.
116func (s *Server) scanStatusPermitted(ctx context.Context, w http.ResponseWriter, r *http.Request, acct, app string) (bucket string, ok bool) {
117 rec, refusal, err := objstore.QueuePermitted(ctx, s.cfg.Releases, acct, app)
118 if err != nil {
119 log.Printf("console: api scan object %s/%s: consent: %v", acct, app, err)
120 jsonError(w, http.StatusBadGateway, "the store record could not be read — try again")
121 return "", false
122 }
123 // The SAME mapping the enqueue door uses, which is the point of it being a
124 // table: two doors that answered the same refusal differently would let an
125 // app conclude something about its store from the disagreement.
126 if ans := scanRefusalAnswer(refusal); ans.refused() {
127 if ans.unknown {
128 log.Printf("console: api scan object %s/%s: unhandled refusal %q", acct, app, refusal)
129 }
130 if ans.notFound {
131 http.NotFound(w, r)
132 return "", false
133 }
134 scanUnavailable(w, ans.status, string(refusal), ans.msg)
135 return "", false
136 }
137 return rec.Provisioned.Bucket, true
138}
139
140// handleAPIScanObject answers GET .../store/scan/object?key=&wait=.
141//
142// `skipped`, `gone` and `unavailable` are NOT `clean`, and they are distinct
143// states rather than an error field, because an app treating any of them as
144// success reproduces exactly the dishonesty the whole feature exists to
145// prevent: reporting coverage that does not exist.
146func (s *Server) handleAPIScanObject(w http.ResponseWriter, r *http.Request) {
147 _, acct, app, ok := s.apiAccountApp(w, r)
148 if !ok {
149 return
150 }
151 // RATE LIMITED, and on the same budget as the enqueue door on purpose.
152 //
153 // §5.2 caps PARKED waiters, which bounds nothing for a client that polls
154 // with no `wait` at all: each of those costs a HeadObject and a
155 // GetObjectTagging on the console's own credentials, plus a release-store
156 // read on the pending path. Sharing one per-principal budget with enqueue
157 // bounds an app's total cost to this console rather than letting it spend
158 // twice; an app that long-polls properly uses two requests a minute and
159 // never meets it.
160 p, _ := principalFrom(r.Context())
161 if !s.allowWrite(p, time.Now()) {
162 jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again")
163 return
164 }
165 // Queried byte for byte: a trimmed key names a different object.
166 key := r.URL.Query().Get("key")
167 if strings.TrimSpace(key) == "" {
168 jsonError(w, http.StatusBadRequest, `name the object, e.g. ?key=uploads/x.pdf`)
169 return
170 }
171 wait, err := scanWaitFor(r.URL.Query().Get("wait"))
172 if err != nil {
173 jsonError(w, http.StatusBadRequest, err.Error())
174 return
175 }
176 if s.cfg.Releases == nil {
177 scanNoEngineState(w)
178 return
179 }
180 ctx, cancel := context.WithTimeout(r.Context(), wait+storeTimeout)
181 defer cancel()
182
183 bucket, ok := s.scanStatusPermitted(ctx, w, r, acct.Sqid, app)
184 if !ok {
185 return
186 }
187 if s.cfg.StoreScanner == nil {
188 scanNoEngineState(w)
189 return
190 }
191
192 // REGISTERED BEFORE THE FIRST READ. A drain that finishes between the read
193 // and the registration would wake nobody, and this request would then park
194 // for its whole window on an answer that had already landed.
195 //
196 // NOT ASSERTED BY ANY TEST, and said so rather than left looking covered.
197 //
198 // A review suggested this was testable with a hook that runs a drain
199 // inside the door's first read. It was tried, and it does not
200 // discriminate: the hook fires at the top of Status, so the read returns
201 // the state the drain just produced and the door answers from the read in
202 // EITHER order — the mutation that moves this block below the read
203 // survives the test. Catching the wrong order needs the drain to land
204 // after the read has taken its value and before the select, and there is
205 // no seam that holds it there.
206 //
207 // The window is real but small, and the cost of losing the race is one
208 // wait that expires rather than a wrong answer — the door re-reads the
209 // object when its timer fires and never answers from the notification
210 // itself. TestLongPollSurvivesADrainDuringItsFirstRead covers what can be
211 // covered.
212 var woken <-chan struct{}
213 if wait > 0 {
214 ch, release, ok := s.scanWait(objstore.AppRef{Account: acct.Sqid, App: app}, key)
215 if ok {
216 defer release()
217 woken = ch
218 }
219 // Not ok means the caps are full. Nothing is refused: the read below
220 // still happens and still answers truthfully; only the WAITING is
221 // skipped.
222 }
223
224 deadline := time.NewTimer(wait)
225 defer deadline.Stop()
226 for {
227 out, err := s.scanStatusOf(ctx, acct.Sqid, app, bucket, key)
228 if err != nil {
229 log.Printf("console: api scan object %s/%s %q: %v", acct.Sqid, app, key, err)
230 jsonError(w, http.StatusBadGateway, "the verdict could not be read — try again")
231 return
232 }
233 if out["state"] != "pending" || woken == nil {
234 writeJSON(w, http.StatusOK, out)
235 return
236 }
237 select {
238 case <-woken:
239 // Round again and re-read. The wake says "something happened",
240 // never what — the answer comes from the object, not from the
241 // notification, so a spurious wake costs one read and nothing
242 // else.
243 case <-deadline.C:
244 // RE-READ once more, rather than answering with the value captured
245 // before parking: the drain notifies only after a whole app page
246 // returns, so an object scanned early in a busy page would
247 // otherwise be answered `pending` by a waiter that could have seen
248 // its answer. The loop answers whatever that read says.
249 woken = nil
250 case <-ctx.Done():
251 // Nothing was read now, and the earlier `pending` is not an answer.
252 jsonError(w, http.StatusBadGateway, "the verdict could not be read — try again")
253 return
254 }
255 // Consent again before the next read: a revoke, trash or disable that
256 // landed while parked must stop it.
257 if bucket, ok = s.scanStatusPermitted(ctx, w, r, acct.Sqid, app); !ok {
258 return
259 }
260 }
261}
262
263// scanWaitFor parses the wait parameter and bounds it.
264//
265// An over-long wait is CLAMPED rather than refused: a client asking for five
266// minutes wants the longest wait it can have, and answering 400 turns a
267// reasonable request into an error the app author has to special-case. A
268// malformed one is a 400, because that is a bug in the caller rather than an
269// ambitious number.
270func scanWaitFor(raw string) (time.Duration, error) {
271 raw = strings.TrimSpace(raw)
272 if raw == "" {
273 return 0, nil
274 }
275 d, err := time.ParseDuration(raw)
276 if err != nil {
277 return 0, errors.New(`wait must be a duration such as "15s"`)
278 }
279 if d < 0 {
280 return 0, errors.New("wait cannot be negative")
281 }
282 if d > scanWaitMax {
283 return scanWaitMax, nil
284 }
285 return d, nil
286}
287