// The on-demand READ door (spec §5.2): what is known about one object right // now, optionally waiting a little for it. // // Its own file rather than more of apiscan.go, because the scan family there // is about POLICY — who may scan, whether enforcement is on — and this one // answers a question about a single object for the app that owns it. They // share the consent chain and nothing else. package console import ( "context" "errors" "log" "net/http" "strings" "time" "github.com/carlosframework/platform/internal/objstore" ) // scanWaitMax is the long-poll ceiling. Beyond this a client should be // polling: a request parked longer than half a minute is holding a console // goroutine against an answer that may never come in this process at all. const scanWaitMax = 30 * time.Second // scanWaitersPerApp and scanWaitersGlobal cap parked requests. // // BOTH, and the per-app one is not implied by the global one: without it a // single tenant fills the global budget and every other tenant's wait // degrades to polling. Without the GLOBAL one, enough tenants at their // per-app limit still add up to more parked goroutines than a console should // hold. Neither is a refusal — over the cap the door answers `pending` // immediately, which is TRUE, and the client polls. const ( scanWaitersPerApp = 32 scanWaitersGlobal = 256 ) // scanStatusOf reads what is known about one object, in the order §5.2 sets: // the object's own tag first, the fallback result second. // // THE TAG WINS, and the order is not a preference. A result object exists only // because a verdict could not be written onto the object; if the object now // carries one, the object is the more recent and more authoritative answer, // and the drain deletes the result the moment that happens. // // The version comparison happens inside the scanner and inside // ReadScanResult. It never reaches this door and never reaches an app: the // token is an internal format, and an app comparing it wrongly gets the // laundering bug as a correctness bug — upload v2 over a key whose v1 was // tagged clean, read the stale tag, serve the payload. func (s *Server) scanStatusOf(ctx context.Context, acct, app, bucket, key string) (map[string]any, error) { verdict, obj, err := s.cfg.StoreScanner.Status(ctx, bucket, key) if errors.Is(err, objstore.ErrObjectGone) { return map[string]any{"state": "gone", "recorded": false}, nil } if err != nil { return nil, err } if verdict != objstore.VerdictUnknown { // REPORTED AS THEMSELVES. `failed` and `denied` are verdicts, not // absences: the landed tag reader maps them back to unknown so the // SWEEP retries them, which is right as retry policy and wrong as an // answer to a question about an object. Left applied here, a failed // scan would read as `pending` forever — the drain deletes the item // as handled, and nothing changes the answer until a sweep re-fails // it. // // `denied` is not claimed as recorded: Status also answers it for a // refused HeadObject or tag read, where no tag was seen at all. return map[string]any{"state": string(verdict), "recorded": verdict != objstore.VerdictDenied}, nil } // No usable tag. The object may still have an answer that could not be // written onto it. if obj == (objstore.ScanObject{}) { // A denied HeadObject: no version, so no result object can be matched // against it either. Status already said what this is. return map[string]any{"state": "pending", "recorded": false}, nil } res, err := objstore.ReadScanResult(ctx, s.cfg.Releases, acct, app, key, obj.Version()) if err != nil { return nil, err } if res == nil { // Absent, or recorded against different bytes — ReadScanResult makes // no distinction and neither should this door. Nothing is known about // the object that is there NOW. return map[string]any{"state": "pending", "recorded": false}, nil } // The verdict is on a result object rather than the object itself, which // the app has to know: an infected object answered this way is NOT covered // by the enforcement Deny, because that matches on a tag that is not // there. out := map[string]any{"state": res.Verdict, "recorded": false} // Provenance, but only where it is actually known. A result object records // the engine and signature that judged it; a TAG, through the Status seam // as it stands, yields the verdict alone. Reporting this deployment's // current engine for a tag would be a guess dressed as a record — the // engine may have been replaced since — so the tag branch above says // nothing rather than something plausible. if res.Engine != "" { out["engine"] = res.Engine } if res.Signature != "" { out["signature"] = res.Signature } if !res.At.IsZero() { out["at"] = res.At.UTC().Format(time.RFC3339) } return out, nil } // scanStatusPermitted runs the consent chain and answers any refusal itself. // The door calls it before EVERY read, so a revoke or disable that lands while // a request is parked stops the next read rather than being answered past. func (s *Server) scanStatusPermitted(ctx context.Context, w http.ResponseWriter, r *http.Request, acct, app string) (bucket string, ok bool) { rec, refusal, err := objstore.QueuePermitted(ctx, s.cfg.Releases, acct, app) if err != nil { log.Printf("console: api scan object %s/%s: consent: %v", acct, app, err) jsonError(w, http.StatusBadGateway, "the store record could not be read — try again") return "", false } // The SAME mapping the enqueue door uses, which is the point of it being a // table: two doors that answered the same refusal differently would let an // app conclude something about its store from the disagreement. if ans := scanRefusalAnswer(refusal); ans.refused() { if ans.unknown { log.Printf("console: api scan object %s/%s: unhandled refusal %q", acct, app, refusal) } if ans.notFound { http.NotFound(w, r) return "", false } scanUnavailable(w, ans.status, string(refusal), ans.msg) return "", false } return rec.Provisioned.Bucket, true } // handleAPIScanObject answers GET .../store/scan/object?key=&wait=. // // `skipped`, `gone` and `unavailable` are NOT `clean`, and they are distinct // states rather than an error field, because an app treating any of them as // success reproduces exactly the dishonesty the whole feature exists to // prevent: reporting coverage that does not exist. func (s *Server) handleAPIScanObject(w http.ResponseWriter, r *http.Request) { _, acct, app, ok := s.apiAccountApp(w, r) if !ok { return } // RATE LIMITED, and on the same budget as the enqueue door on purpose. // // §5.2 caps PARKED waiters, which bounds nothing for a client that polls // with no `wait` at all: each of those costs a HeadObject and a // GetObjectTagging on the console's own credentials, plus a release-store // read on the pending path. Sharing one per-principal budget with enqueue // bounds an app's total cost to this console rather than letting it spend // twice; an app that long-polls properly uses two requests a minute and // never meets it. p, _ := principalFrom(r.Context()) if !s.allowWrite(p, time.Now()) { jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") return } // Queried byte for byte: a trimmed key names a different object. key := r.URL.Query().Get("key") if strings.TrimSpace(key) == "" { jsonError(w, http.StatusBadRequest, `name the object, e.g. ?key=uploads/x.pdf`) return } wait, err := scanWaitFor(r.URL.Query().Get("wait")) if err != nil { jsonError(w, http.StatusBadRequest, err.Error()) return } if s.cfg.Releases == nil { scanNoEngineState(w) return } ctx, cancel := context.WithTimeout(r.Context(), wait+storeTimeout) defer cancel() bucket, ok := s.scanStatusPermitted(ctx, w, r, acct.Sqid, app) if !ok { return } if s.cfg.StoreScanner == nil { scanNoEngineState(w) return } // REGISTERED BEFORE THE FIRST READ. A drain that finishes between the read // and the registration would wake nobody, and this request would then park // for its whole window on an answer that had already landed. // // NOT ASSERTED BY ANY TEST, and said so rather than left looking covered. // // A review suggested this was testable with a hook that runs a drain // inside the door's first read. It was tried, and it does not // discriminate: the hook fires at the top of Status, so the read returns // the state the drain just produced and the door answers from the read in // EITHER order — the mutation that moves this block below the read // survives the test. Catching the wrong order needs the drain to land // after the read has taken its value and before the select, and there is // no seam that holds it there. // // The window is real but small, and the cost of losing the race is one // wait that expires rather than a wrong answer — the door re-reads the // object when its timer fires and never answers from the notification // itself. TestLongPollSurvivesADrainDuringItsFirstRead covers what can be // covered. var woken <-chan struct{} if wait > 0 { ch, release, ok := s.scanWait(objstore.AppRef{Account: acct.Sqid, App: app}, key) if ok { defer release() woken = ch } // Not ok means the caps are full. Nothing is refused: the read below // still happens and still answers truthfully; only the WAITING is // skipped. } deadline := time.NewTimer(wait) defer deadline.Stop() for { out, err := s.scanStatusOf(ctx, acct.Sqid, app, bucket, key) if err != nil { log.Printf("console: api scan object %s/%s %q: %v", acct.Sqid, app, key, err) jsonError(w, http.StatusBadGateway, "the verdict could not be read — try again") return } if out["state"] != "pending" || woken == nil { writeJSON(w, http.StatusOK, out) return } select { case <-woken: // Round again and re-read. The wake says "something happened", // never what — the answer comes from the object, not from the // notification, so a spurious wake costs one read and nothing // else. case <-deadline.C: // RE-READ once more, rather than answering with the value captured // before parking: the drain notifies only after a whole app page // returns, so an object scanned early in a busy page would // otherwise be answered `pending` by a waiter that could have seen // its answer. The loop answers whatever that read says. woken = nil case <-ctx.Done(): // Nothing was read now, and the earlier `pending` is not an answer. jsonError(w, http.StatusBadGateway, "the verdict could not be read — try again") return } // Consent again before the next read: a revoke, trash or disable that // landed while parked must stop it. if bucket, ok = s.scanStatusPermitted(ctx, w, r, acct.Sqid, app); !ok { return } } } // scanWaitFor parses the wait parameter and bounds it. // // An over-long wait is CLAMPED rather than refused: a client asking for five // minutes wants the longest wait it can have, and answering 400 turns a // reasonable request into an error the app author has to special-case. A // malformed one is a 400, because that is a bug in the caller rather than an // ambitious number. func scanWaitFor(raw string) (time.Duration, error) { raw = strings.TrimSpace(raw) if raw == "" { return 0, nil } d, err := time.ParseDuration(raw) if err != nil { return 0, errors.New(`wait must be a duration such as "15s"`) } if d < 0 { return 0, errors.New("wait cannot be negative") } if d > scanWaitMax { return scanWaitMax, nil } return d, nil }