chainlesschain 0.166.64 → 0.166.68
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -12
- package/package.json +4 -4
- package/src/assets/web-panel/assets/{AIOps-BFbLDWTF.js → AIOps-CXkSNOGY.js} +1 -1
- package/src/assets/web-panel/assets/{ActionButton-CAHMtgNB.js → ActionButton-ysMR6gTs.js} +1 -1
- package/src/assets/web-panel/assets/{Analytics-CkrSj0oR.js → Analytics-WGZ0sWuw.js} +3 -3
- package/src/assets/web-panel/assets/{AppLayout-BTb0Ws1l.js → AppLayout-Cs2iRlQG.js} +4 -4
- package/src/assets/web-panel/assets/{Artifacts-C2Pgepj-.js → Artifacts-5IdfxWz0.js} +1 -1
- package/src/assets/web-panel/assets/{Audit-ZweyxXW8.js → Audit-U9V434QX.js} +1 -1
- package/src/assets/web-panel/assets/{BackgroundAgents-BS8tAnzP.js → BackgroundAgents-BvFwxPlC.js} +1 -1
- package/src/assets/web-panel/assets/{Backup-CLkG_74M.js → Backup-C9yTr9hO.js} +1 -1
- package/src/assets/web-panel/assets/{BaseInput-MIfNcdUZ.js → BaseInput-BdsSwUgE.js} +1 -1
- package/src/assets/web-panel/assets/{Chat-DBwqlWWr.js → Chat-BB_2oBgH.js} +5 -5
- package/src/assets/web-panel/assets/ChatBubbleRenderer-Bpf1hDUl.js +1 -0
- package/src/assets/web-panel/assets/{Checkbox-DdBYhlVX.js → Checkbox-v6M5m7dK.js} +1 -1
- package/src/assets/web-panel/assets/{Codegen-BR6MhiDo.js → Codegen-CtouWSIz.js} +1 -1
- package/src/assets/web-panel/assets/{Col-yO-5EbbM.js → Col-CqQJ4ORW.js} +1 -1
- package/src/assets/web-panel/assets/{Community-B608q_m7.js → Community-BO__QCOy.js} +1 -1
- package/src/assets/web-panel/assets/{Compact-BmVHi7Z7.js → Compact-D0Kq4xlH.js} +1 -1
- package/src/assets/web-panel/assets/{Compliance-BQ-DzutA.js → Compliance-BSLKKBjA.js} +1 -1
- package/src/assets/web-panel/assets/{Cowork-BEfLyXvi.js → Cowork-VsujXW5o.js} +3 -3
- package/src/assets/web-panel/assets/{Cron-Cld6NX-_.js → Cron-j8DbLGi5.js} +2 -2
- package/src/assets/web-panel/assets/{Crosschain-CUA3nDId.js → Crosschain-DWpYH1yN.js} +1 -1
- package/src/assets/web-panel/assets/{DID-DSI3E9zv.js → DID-mLaRxLQh.js} +2 -2
- package/src/assets/web-panel/assets/{Dashboard-CoWtEdhO.js → Dashboard-Cff033Jr.js} +2 -2
- package/src/assets/web-panel/assets/{Dropdown-CGhZ1Dyy.js → Dropdown-B1Upt6fP.js} +1 -1
- package/src/assets/web-panel/assets/{EmailListRenderer-BHZe2K5q.js → EmailListRenderer-CPtb8joV.js} +1 -1
- package/src/assets/web-panel/assets/{EvolutionSettings-Corjo98k.js → EvolutionSettings-CVh4Uv4g.js} +1 -1
- package/src/assets/web-panel/assets/{FamilyGuardDashboard-dPSWF3Pt.js → FamilyGuardDashboard-CrD1PhbF.js} +1 -1
- package/src/assets/web-panel/assets/{Federation-DT-Zr78c.js → Federation-ftxrvqW5.js} +1 -1
- package/src/assets/web-panel/assets/{FormItemContext-CzKq63B0.js → FormItemContext-9O80slC1.js} +1 -1
- package/src/assets/web-panel/assets/{GenericCardRenderer-CDkKrmu0.js → GenericCardRenderer-DaYvYIj9.js} +1 -1
- package/src/assets/web-panel/assets/{Git-BrwRnLOv.js → Git-BKZh0YE5.js} +2 -2
- package/src/assets/web-panel/assets/{Governance-C4PtCzod.js → Governance-vWbN873j.js} +1 -1
- package/src/assets/web-panel/assets/{Inference-CVPv5nwa.js → Inference-BN6RR-l8.js} +1 -1
- package/src/assets/web-panel/assets/{KnowledgeGraph-CmT9lvED.js → KnowledgeGraph-x49x9m7Q.js} +1 -1
- package/src/assets/web-panel/assets/{Logs-DaRgv7pL.js → Logs-Co2EPLsJ.js} +2 -2
- package/src/assets/web-panel/assets/{MarkdownRenderer-B3zZwlSz.js → MarkdownRenderer-D7wIB-c7.js} +1 -1
- package/src/assets/web-panel/assets/{Marketplace-Bm0GGEaL.js → Marketplace-DpPu6XQp.js} +1 -1
- package/src/assets/web-panel/assets/{McpTools-71L5h0en.js → McpTools-BR0ksiax.js} +3 -3
- package/src/assets/web-panel/assets/{Memory-BoWrfASi.js → Memory-BXznJ5-C.js} +2 -2
- package/src/assets/web-panel/assets/{MobileBridge-Dp4FcX_-.js → MobileBridge-BHSOpfv7.js} +1 -1
- package/src/assets/web-panel/assets/{MobileProjects-00ja0Z7O.js → MobileProjects-DdWmgIup.js} +1 -1
- package/src/assets/web-panel/assets/{Mtc-BZF6YNYp.js → Mtc-D9Rk_IGT.js} +4 -4
- package/src/assets/web-panel/assets/{MtcAudit-Bp_vcI6H.js → MtcAudit--i8q1lKc.js} +2 -2
- package/src/assets/web-panel/assets/{Multisig-CnCo56CS.js → Multisig-BHnNG7R9.js} +3 -3
- package/src/assets/web-panel/assets/{NLProgramming-BCSzNHpv.js → NLProgramming-Duo1ij2_.js} +1 -1
- package/src/assets/web-panel/assets/{Notes-kXoQk8B7.js → Notes-B44Le73p.js} +3 -3
- package/src/assets/web-panel/assets/{NotificationSettings-oN-1fkcy.js → NotificationSettings-C3oy_7Gf.js} +1 -1
- package/src/assets/web-panel/assets/{OrderTableRenderer-5ZVHrVZ9.js → OrderTableRenderer-Da_73SI5.js} +1 -1
- package/src/assets/web-panel/assets/{Organization-D4_T9Tdc.js → Organization-ChsSwKxT.js} +4 -4
- package/src/assets/web-panel/assets/{Overflow-d8vZwo9w.js → Overflow-DR5G_cCn.js} +1 -1
- package/src/assets/web-panel/assets/{P2P-0HGUdmTv.js → P2P-yfaobqow.js} +2 -2
- package/src/assets/web-panel/assets/PdhVaultBrowser-DgimH6Jp.js +7 -0
- package/src/assets/web-panel/assets/{Permissions-XHd6pVQB.js → Permissions-DhinFvtE.js} +4 -4
- package/src/assets/web-panel/assets/{PersonalDataHub-CB5bZU9T.js → PersonalDataHub-CfeNVic1.js} +3 -3
- package/src/assets/web-panel/assets/{Pipeline-Dy_qx4-0.js → Pipeline-CGKO26Nk.js} +1 -1
- package/src/assets/web-panel/assets/{Privacy-BgR0-uEu.js → Privacy-CxxfYEJh.js} +1 -1
- package/src/assets/web-panel/assets/{ProjectInit-DAcRi1Nx.js → ProjectInit-CZsaAab9.js} +2 -2
- package/src/assets/web-panel/assets/{ProjectSettings-Bd1Q0rK_.js → ProjectSettings-BUm_6clQ.js} +2 -2
- package/src/assets/web-panel/assets/{Projects-DVc9DcbM.js → Projects-Capr8bVq.js} +1 -1
- package/src/assets/web-panel/assets/{Providers-C3c4QfUT.js → Providers-D28VqPja.js} +1 -1
- package/src/assets/web-panel/assets/{QrScannerModal-Ba_GbitW.js → QrScannerModal-Cj200vUb.js} +1 -1
- package/src/assets/web-panel/assets/{QuickAsk-CSn5tIkO.js → QuickAsk-BGpnEiam.js} +1 -1
- package/src/assets/web-panel/assets/{Recommend-B3-8PLX0.js → Recommend-Ix79Tpni.js} +1 -1
- package/src/assets/web-panel/assets/{RemoteSession-Bd_7Zmi-.js → RemoteSession-BdGAFbIk.js} +2 -2
- package/src/assets/web-panel/assets/{Reputation-BU2A_wy5.js → Reputation-B1Lis0Wk.js} +1 -1
- package/src/assets/web-panel/assets/{Row-I0JFMjCz.js → Row-Ba_okwOW.js} +1 -1
- package/src/assets/web-panel/assets/{RssFeed-CNc4Dgfz.js → RssFeed-DDbEuI-m.js} +3 -3
- package/src/assets/web-panel/assets/{Search-uAA6ceLl.js → Search-D89wAkMy.js} +1 -1
- package/src/assets/web-panel/assets/{Security-U1SWj5nP.js → Security-CJKCKAU-.js} +3 -3
- package/src/assets/web-panel/assets/{Services-BFjZ5ASY.js → Services-mitEc1XV.js} +2 -2
- package/src/assets/web-panel/assets/{Skeleton-B8InBvQf.js → Skeleton-CnBjLDfX.js} +1 -1
- package/src/assets/web-panel/assets/{Skills-BDYwf3QV.js → Skills-DSzDYvH3.js} +1 -1
- package/src/assets/web-panel/assets/{Sla-Dcc6iq3q.js → Sla-CglJ_4p3.js} +1 -1
- package/src/assets/web-panel/assets/SpeechSettings-BicWJS7f.js +1 -0
- package/src/assets/web-panel/assets/{SyncSettings-BNHEYO95.js → SyncSettings-Cq7o6fmr.js} +2 -2
- package/src/assets/web-panel/assets/{Tasks-JbV6L1FX.js → Tasks-DOxFOBpU.js} +1 -1
- package/src/assets/web-panel/assets/{Templates-Yp0CUTBz.js → Templates-bY0wSbQM.js} +1 -1
- package/src/assets/web-panel/assets/{Tenant-CToNYxCo.js → Tenant-BAyyl0qX.js} +1 -1
- package/src/assets/web-panel/assets/{Terminal-D7W9UqSD.js → Terminal-BGBhSpqc.js} +2 -2
- package/src/assets/web-panel/assets/{TimelineRenderer-B7rqc1kE.js → TimelineRenderer-BvW5lXYz.js} +1 -1
- package/src/assets/web-panel/assets/{Tokens-QP7K4ppA.js → Tokens-Ua_LIx8-.js} +1 -1
- package/src/assets/web-panel/assets/{Trigger-CAWowQCR.js → Trigger-BGwb2KL1.js} +1 -1
- package/src/assets/web-panel/assets/{Trust-B-0XOP97.js → Trust-CD2IEHvj.js} +1 -1
- package/src/assets/web-panel/assets/{UkeySign-BtiZbiuj.js → UkeySign-D5LetK6P.js} +1 -1
- package/src/assets/web-panel/assets/{VideoEditing-Dee11JI0.js → VideoEditing-oP7ehFgR.js} +1 -1
- package/src/assets/web-panel/assets/{Wallet-y7qOmaWY.js → Wallet-BBtu4-xa.js} +4 -4
- package/src/assets/web-panel/assets/{WebAuthn-CjyRRWGK.js → WebAuthn-iSPRU4Ht.js} +3 -3
- package/src/assets/web-panel/assets/{WorkflowEditor-D7Kiz25Q.js → WorkflowEditor-DuEDhQmB.js} +1 -1
- package/src/assets/web-panel/assets/{chat-4F_yB-Gw.js → chat-DGDgqvvm.js} +1 -1
- package/src/assets/web-panel/assets/{colors-CrczPd-t.js → colors-DGQMWa1-.js} +1 -1
- package/src/assets/web-panel/assets/{compact-item-AenPL6fz.js → compact-item-COVznBL4.js} +1 -1
- package/src/assets/web-panel/assets/{createContext-BcwoqPgi.js → createContext-DIxVeHXI.js} +1 -1
- package/src/assets/web-panel/assets/devWarning-CjR9h-Z_.js +1 -0
- package/src/assets/web-panel/assets/{hasIn-YouHmap8.js → hasIn-DkxZO3E9.js} +1 -1
- package/src/assets/web-panel/assets/{index-fCuM1NPH.js → index-0ToBFsvW.js} +1 -1
- package/src/assets/web-panel/assets/{index-D0dXNgvi.js → index-2JZizEPc.js} +1 -1
- package/src/assets/web-panel/assets/{index-BHA5Fyjc.js → index-BAL2p7YC.js} +1 -1
- package/src/assets/web-panel/assets/{index-CedvtJA_.js → index-BAlrovUm.js} +1 -1
- package/src/assets/web-panel/assets/{index-5GHNjmnt.js → index-BBw2MLIe.js} +1 -1
- package/src/assets/web-panel/assets/{index-C5hpEYbx.js → index-BHH95jcm.js} +1 -1
- package/src/assets/web-panel/assets/{index-D8a1AEVK.js → index-BOTshZNx.js} +1 -1
- package/src/assets/web-panel/assets/{index-D7m5Z4We.js → index-BQm7ntcn.js} +1 -1
- package/src/assets/web-panel/assets/{index-D_4VsL-L.js → index-BUqwlzc8.js} +1 -1
- package/src/assets/web-panel/assets/{index-GP5u18DV.js → index-BY6MSbWT.js} +1 -1
- package/src/assets/web-panel/assets/{index-CHNKoexH.js → index-BY9Ossof.js} +1 -1
- package/src/assets/web-panel/assets/{index-DNHmbdGm.js → index-BgM-3a1w.js} +1 -1
- package/src/assets/web-panel/assets/{index-YX4dcbgh.js → index-Bv0_u0ze.js} +1 -1
- package/src/assets/web-panel/assets/{index-D5Rvq67L.js → index-C3XeNS82.js} +3 -3
- package/src/assets/web-panel/assets/{index-DAGfuEUu.js → index-C3bN6751.js} +1 -1
- package/src/assets/web-panel/assets/{index-CZT-LLGS.js → index-CNRRrP3-.js} +1 -1
- package/src/assets/web-panel/assets/{index-4r7B8sTx.js → index-CV0KnNqR.js} +1 -1
- package/src/assets/web-panel/assets/{index-B5Vmr0CM.js → index-CcNGQWum.js} +1 -1
- package/src/assets/web-panel/assets/{index-DE3qPp9l.js → index-Ci34vbh0.js} +1 -1
- package/src/assets/web-panel/assets/{index-CWgIKCVS.js → index-ClQVcrFt.js} +1 -1
- package/src/assets/web-panel/assets/{index-31dhJpt3.js → index-Cligb-Dl.js} +1 -1
- package/src/assets/web-panel/assets/{index-BsGhFk79.js → index-CokrhbY2.js} +1 -1
- package/src/assets/web-panel/assets/{index-B_I1INkw.js → index-CpZxDiVK.js} +1 -1
- package/src/assets/web-panel/assets/{index-BaguGDJP.js → index-CyMHF2Bi.js} +1 -1
- package/src/assets/web-panel/assets/{index-DPrzFdCg.js → index-DDwPBGdA.js} +1 -1
- package/src/assets/web-panel/assets/{index-gy1H_PJk.js → index-DU1ArKl5.js} +1 -1
- package/src/assets/web-panel/assets/{index-DAh5KwVL.js → index-DV0H83eS.js} +1 -1
- package/src/assets/web-panel/assets/index-D_nxBMX5.js +1 -0
- package/src/assets/web-panel/assets/{index-q_9aqSgF.js → index-DaWQSp5a.js} +1 -1
- package/src/assets/web-panel/assets/{index-B8EXHVmM.js → index-DmduvOYT.js} +1 -1
- package/src/assets/web-panel/assets/{index-DVrgnhfu.js → index-ECoDhyH3.js} +1 -1
- package/src/assets/web-panel/assets/{index-BwUqDg9_.js → index-ZcqTvPnt.js} +1 -1
- package/src/assets/web-panel/assets/{index--xwN0ZvG.js → index-b2_M6o20.js} +1 -1
- package/src/assets/web-panel/assets/{index-CZ9Ikuo6.js → index-b_0qTo8O.js} +1 -1
- package/src/assets/web-panel/assets/{index-BoVeUGhC.js → index-dfYkGovV.js} +1 -1
- package/src/assets/web-panel/assets/{index-COgeZETv.js → index-jo7DbU0w.js} +1 -1
- package/src/assets/web-panel/assets/{index-Dfw3WyZU.js → index-usmG13jq.js} +1 -1
- package/src/assets/web-panel/assets/{index-BzyDSW_e.js → index-vXrktF3e.js} +1 -1
- package/src/assets/web-panel/assets/index-z_cTUMX-.js +1 -0
- package/src/assets/web-panel/assets/{initDefaultProps-Ci6zc6ZA.js → initDefaultProps-CkSeiBwL.js} +1 -1
- package/src/assets/web-panel/assets/{motion-DS3kjIxw.js → motion-CSzhDP0s.js} +1 -1
- package/src/assets/web-panel/assets/{move-DCdWAKk4.js → move-mgCt4iC6.js} +1 -1
- package/src/assets/web-panel/assets/{mtc-parser-B0Wfce9U.js → mtc-parser-BEZBLcKt.js} +1 -1
- package/src/assets/web-panel/assets/{omit-Clx9TR6z.js → omit-BsHctMEr.js} +1 -1
- package/src/assets/web-panel/assets/{pickAttrs-CqSiqUvN.js → pickAttrs-1BHamnGW.js} +1 -1
- package/src/assets/web-panel/assets/{placementArrow-BWe_v-40.js → placementArrow--Vb4Bd7l.js} +1 -1
- package/src/assets/web-panel/assets/{responsiveObserve-BSJ68bw6.js → responsiveObserve-DD33SqCm.js} +1 -1
- package/src/assets/web-panel/assets/{slide-VRPlLxZB.js → slide-DfrLtxgK.js} +1 -1
- package/src/assets/web-panel/assets/{statusUtils-BFBVDPdB.js → statusUtils-D9Pe_gXW.js} +1 -1
- package/src/assets/web-panel/assets/{styleChecker-CehOXy1f.js → styleChecker-0NY5iMun.js} +1 -1
- package/src/assets/web-panel/assets/{useFlexGapSupport-BCOdfK18.js → useFlexGapSupport-DSsZstwg.js} +1 -1
- package/src/assets/web-panel/assets/{useFs-BMKrjfJQ.js → useFs-BU8HyH59.js} +1 -1
- package/src/assets/web-panel/assets/{usePersonalDataHub-BIjZ7IgI.js → usePersonalDataHub-D_xttink.js} +1 -1
- package/src/assets/web-panel/assets/{vnode-oD0MXddQ.js → vnode-39VhX2lM.js} +1 -1
- package/src/assets/web-panel/assets/{zoom-D7WqH2Ai.js → zoom-D5KQ8Pks.js} +1 -1
- package/src/assets/web-panel/index.html +1 -1
- package/src/data/changelog.json +33 -7
- package/src/lib/dependency-install-policy.js +1 -1
- package/src/lib/evolution/browser-download-action-authority.js +747 -0
- package/src/lib/evolution/browser-download-artifact-disposal-authority.js +436 -0
- package/src/lib/evolution/browser-filesystem-quarantine-custody.js +2453 -0
- package/src/lib/evolution/browser-filesystem-quarantine-disposal.js +36 -0
- package/src/lib/evolution/browser-keyboard-action-authority.js +560 -0
- package/src/lib/evolution/browser-navigation-action-authority.js +546 -0
- package/src/lib/evolution/browser-quarantine-lock-maintenance-authority.js +573 -0
- package/src/lib/evolution/browser-quarantine-operator-revocation-authority.js +442 -0
- package/src/lib/evolution/browser-quarantine-retention-authority.js +503 -0
- package/src/lib/evolution/browser-quarantine-retention-scheduler.js +207 -0
- package/src/lib/evolution/browser-quarantined-download-executor.js +525 -0
- package/src/lib/evolution/browser-tab-open-action-authority.js +491 -0
- package/src/lib/evolution/browser-vision-action-authority.js +444 -0
- package/src/lib/evolution/browser-vision-observation-authority.js +304 -0
- package/src/lib/evolution/evolution-deployment-loader.js +202 -1
- package/src/lib/evolution/evolution-eval-child-evidence-ledger-adapter.js +5 -0
- package/src/lib/evolution/evolution-eval-gate.js +2 -1
- package/src/lib/evolution/evolution-eval-process-supervisor-worker.mjs +71 -9
- package/src/lib/evolution/evolution-eval-process-supervisor.js +276 -9
- package/src/lib/evolution/evolution-workbench-metrics.js +81 -21
- package/src/lib/evolution/pm-exploration-benchmark.js +747 -0
- package/src/lib/evolution/pm-exploration-budget-executor.js +106 -9
- package/src/lib/evolution/pm-exploration-egress-authority.js +382 -0
- package/src/lib/evolution/pm-exploration-evidence-bundle.js +149 -44
- package/src/lib/evolution/pm-exploration-execution-host.js +1254 -8
- package/src/lib/evolution/pm-exploration-process-grader.js +458 -0
- package/src/lib/evolution/pm-exploration-process-reviewer.js +456 -0
- package/src/lib/evolution/pm-exploration-process-runner.js +500 -0
- package/src/lib/evolution/pm-exploration-receipts.js +211 -11
- package/src/lib/evolution/pm-exploration-recovery-snapshot-store.js +465 -46
- package/src/lib/evolution/pm-exploration-transition-committer.js +185 -34
- package/src/lib/evolution/skill-outcome-index-authority.js +7 -3
- package/src/lib/governance-coverage.js +1 -1
- package/src/lib/headless-manifest.js +1 -1
- package/src/lib/protocol-replay.js +1 -1
- package/src/lib/read-file-loop-guard.js +5 -8
- package/src/lib/remote-read-loop-guard.js +25 -18
- package/src/lib/skill-outcome-transcript-authority.js +45 -5
- package/src/lib/tool-recovery-result.js +11 -0
- package/src/runtime/agent-core.js +26 -3
- package/src/assets/web-panel/assets/ChatBubbleRenderer-6T0uoJuS.js +0 -1
- package/src/assets/web-panel/assets/PdhVaultBrowser-Cx8vp92z.js +0 -7
- package/src/assets/web-panel/assets/SpeechSettings-C3Ta-mC9.js +0 -1
- package/src/assets/web-panel/assets/devWarning-C4qWsAvh.js +0 -1
- package/src/assets/web-panel/assets/index-C-ltOW-O.js +0 -1
- package/src/assets/web-panel/assets/index-D7Ir0TiS.js +0 -1
|
@@ -0,0 +1,2453 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { createReadStream } from "node:fs";
|
|
3
|
+
import {
|
|
4
|
+
lstat,
|
|
5
|
+
link,
|
|
6
|
+
mkdir,
|
|
7
|
+
open,
|
|
8
|
+
readFile,
|
|
9
|
+
readdir,
|
|
10
|
+
realpath,
|
|
11
|
+
rename,
|
|
12
|
+
rmdir,
|
|
13
|
+
stat,
|
|
14
|
+
unlink,
|
|
15
|
+
} from "node:fs/promises";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import { types } from "node:util";
|
|
19
|
+
|
|
20
|
+
export const BROWSER_FILESYSTEM_QUARANTINE_CUSTODY_DESCRIPTOR_SCHEMA =
|
|
21
|
+
"chainlesschain.browser-filesystem-quarantine-custody-descriptor/v1";
|
|
22
|
+
export const BROWSER_FILESYSTEM_QUARANTINE_METADATA_SCHEMA =
|
|
23
|
+
"chainlesschain.browser-filesystem-quarantine-metadata/v1";
|
|
24
|
+
export const BROWSER_FILESYSTEM_QUARANTINE_DELETION_INTENT_SCHEMA =
|
|
25
|
+
"chainlesschain.browser-filesystem-quarantine-deletion-intent/v1";
|
|
26
|
+
export const BROWSER_FILESYSTEM_QUARANTINE_TOMBSTONE_SCHEMA =
|
|
27
|
+
"chainlesschain.browser-filesystem-quarantine-tombstone/v1";
|
|
28
|
+
export const BROWSER_FILESYSTEM_QUARANTINE_RETENTION_DESCRIPTOR_SCHEMA =
|
|
29
|
+
"chainlesschain.browser-filesystem-quarantine-retention-descriptor/v1";
|
|
30
|
+
export const BROWSER_FILESYSTEM_QUARANTINE_EXPIRY_PLAN_SCHEMA =
|
|
31
|
+
"chainlesschain.browser-filesystem-quarantine-expiry-plan/v1";
|
|
32
|
+
export const BROWSER_FILESYSTEM_QUARANTINE_OPERATOR_REVOCATION_DESCRIPTOR_SCHEMA =
|
|
33
|
+
"chainlesschain.browser-filesystem-quarantine-operator-revocation-descriptor/v1";
|
|
34
|
+
export const BROWSER_FILESYSTEM_QUARANTINE_LOCK_MAINTENANCE_DESCRIPTOR_SCHEMA =
|
|
35
|
+
"chainlesschain.browser-filesystem-quarantine-lock-maintenance-descriptor/v1";
|
|
36
|
+
export const BROWSER_FILESYSTEM_QUARANTINE_LOCK_OWNER_SCHEMA =
|
|
37
|
+
"chainlesschain.browser-filesystem-quarantine-lock-owner/v1";
|
|
38
|
+
export const BROWSER_FILESYSTEM_QUARANTINE_LOCK_DIAGNOSTIC_SCHEMA =
|
|
39
|
+
"chainlesschain.browser-filesystem-quarantine-lock-diagnostic/v1";
|
|
40
|
+
export const BROWSER_FILESYSTEM_QUARANTINE_LOCK_RELEASE_ACK_SCHEMA =
|
|
41
|
+
"chainlesschain.browser-filesystem-quarantine-lock-release-ack/v1";
|
|
42
|
+
|
|
43
|
+
const QUARANTINE_COMMIT_ACK_SCHEMA =
|
|
44
|
+
"chainlesschain.browser-download-quarantine-commit-ack/v1";
|
|
45
|
+
const COMPLETION_ACK_SCHEMA =
|
|
46
|
+
"chainlesschain.browser-download-completion-ack/v1";
|
|
47
|
+
const DISPOSAL_DESCRIPTOR_SCHEMA =
|
|
48
|
+
"chainlesschain.browser-download-artifact-disposal-descriptor/v1";
|
|
49
|
+
const DELETION_ACK_SCHEMA =
|
|
50
|
+
"chainlesschain.browser-download-artifact-deletion-ack/v1";
|
|
51
|
+
const DIGEST = /^sha256:[a-f0-9]{64}$/u;
|
|
52
|
+
const ID = /^[A-Za-z0-9._:-]{1,128}$/u;
|
|
53
|
+
const ARTIFACT_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,95}$/u;
|
|
54
|
+
const ARTIFACT_REF = /^quarantine:[A-Za-z0-9][A-Za-z0-9._-]{0,95}$/u;
|
|
55
|
+
const METADATA_FILE = /^([A-Za-z0-9][A-Za-z0-9._-]{0,95})\.json$/u;
|
|
56
|
+
const CONTENT_TYPE =
|
|
57
|
+
/^[a-z0-9][a-z0-9!#$&^_.+-]{0,126}\/[a-z0-9][a-z0-9!#$&^_.+-]{0,126}$/u;
|
|
58
|
+
const MAX_BYTES = 100 * 1024 * 1024;
|
|
59
|
+
const LOCK_OWNER_INIT_TIMEOUT_MS = 30_000;
|
|
60
|
+
const LOCK_OPERATIONS = new Set([
|
|
61
|
+
"write",
|
|
62
|
+
"scan",
|
|
63
|
+
"complete",
|
|
64
|
+
"dispose",
|
|
65
|
+
"recover",
|
|
66
|
+
]);
|
|
67
|
+
const LOCK_TEMP_FILE =
|
|
68
|
+
/^owner\.json\.[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.tmp$/iu;
|
|
69
|
+
const LOCK_MAINTENANCE_CLAIM_SCHEMA =
|
|
70
|
+
"chainlesschain.browser-filesystem-quarantine-lock-maintenance-claim/v1";
|
|
71
|
+
const LOCK_MAINTENANCE_TEMP_FILE =
|
|
72
|
+
/^maintenance\.json\.[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.tmp$/iu;
|
|
73
|
+
const OBJECT_FILE = /^([A-Za-z0-9][A-Za-z0-9._-]{0,95})\.(part|blob)$/u;
|
|
74
|
+
const METADATA_TEMP_FILE =
|
|
75
|
+
/^([A-Za-z0-9][A-Za-z0-9._-]{0,95})\.json\.[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.tmp$/iu;
|
|
76
|
+
const DELETION_FILE =
|
|
77
|
+
/^([A-Za-z0-9][A-Za-z0-9._-]{0,95})\.(intent|deleted)\.json$/u;
|
|
78
|
+
const DELETION_TEMP_FILE =
|
|
79
|
+
/^([A-Za-z0-9][A-Za-z0-9._-]{0,95})\.(?:intent|deleted)\.json\.[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.tmp$/iu;
|
|
80
|
+
const LOCK_DIRECTORY = /^([A-Za-z0-9][A-Za-z0-9._-]{0,95})\.lock$/u;
|
|
81
|
+
const DISPOSAL_REASONS = new Set([
|
|
82
|
+
"user-discard",
|
|
83
|
+
"expired",
|
|
84
|
+
"revoked",
|
|
85
|
+
"delivery-failed",
|
|
86
|
+
]);
|
|
87
|
+
const METADATA_KEYS = [
|
|
88
|
+
"schema",
|
|
89
|
+
"custodyId",
|
|
90
|
+
"tenantId",
|
|
91
|
+
"artifactRef",
|
|
92
|
+
"artifactDigest",
|
|
93
|
+
"sizeBytes",
|
|
94
|
+
"contentType",
|
|
95
|
+
"networkReceiptDigest",
|
|
96
|
+
"sourceActionReceiptDigest",
|
|
97
|
+
"status",
|
|
98
|
+
"committedAt",
|
|
99
|
+
"expiresAt",
|
|
100
|
+
"quarantineReceiptDigest",
|
|
101
|
+
"scanEvidenceDigest",
|
|
102
|
+
"completedAt",
|
|
103
|
+
"completionReceiptDigest",
|
|
104
|
+
];
|
|
105
|
+
const custodies = new WeakMap();
|
|
106
|
+
|
|
107
|
+
function canonical(value) {
|
|
108
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
109
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
|
|
110
|
+
return `{${Object.keys(value)
|
|
111
|
+
.sort()
|
|
112
|
+
.map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`)
|
|
113
|
+
.join(",")}}`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function digest(domain, value) {
|
|
117
|
+
return `sha256:${createHash("sha256")
|
|
118
|
+
.update(`${domain}\0`)
|
|
119
|
+
.update(canonical(value))
|
|
120
|
+
.digest("hex")}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function exact(value, keys, label) {
|
|
124
|
+
if (
|
|
125
|
+
!value ||
|
|
126
|
+
typeof value !== "object" ||
|
|
127
|
+
Array.isArray(value) ||
|
|
128
|
+
types.isProxy(value) ||
|
|
129
|
+
![Object.prototype, null].includes(Object.getPrototypeOf(value)) ||
|
|
130
|
+
Reflect.ownKeys(value).length !== keys.length ||
|
|
131
|
+
keys.some((key) => {
|
|
132
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
133
|
+
return !descriptor?.enumerable || !("value" in descriptor);
|
|
134
|
+
})
|
|
135
|
+
)
|
|
136
|
+
throw new TypeError(`${label} has an invalid shape`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function normalizeDescriptor(value) {
|
|
140
|
+
exact(
|
|
141
|
+
value,
|
|
142
|
+
[
|
|
143
|
+
"schema",
|
|
144
|
+
"custodyId",
|
|
145
|
+
"tenantId",
|
|
146
|
+
"handlerArtifactDigest",
|
|
147
|
+
"retentionMs",
|
|
148
|
+
"custodyMode",
|
|
149
|
+
],
|
|
150
|
+
"filesystem quarantine custody descriptor",
|
|
151
|
+
);
|
|
152
|
+
if (
|
|
153
|
+
value.schema !== BROWSER_FILESYSTEM_QUARANTINE_CUSTODY_DESCRIPTOR_SCHEMA ||
|
|
154
|
+
!ID.test(value.custodyId) ||
|
|
155
|
+
!ID.test(value.tenantId) ||
|
|
156
|
+
!DIGEST.test(value.handlerArtifactDigest) ||
|
|
157
|
+
!Number.isSafeInteger(value.retentionMs) ||
|
|
158
|
+
value.retentionMs < 1000 ||
|
|
159
|
+
value.retentionMs > 30 * 24 * 60 * 60 * 1000 ||
|
|
160
|
+
value.custodyMode !== "exclusive-stream-fsync"
|
|
161
|
+
)
|
|
162
|
+
throw new TypeError("filesystem quarantine custody descriptor is invalid");
|
|
163
|
+
return Object.freeze({ ...value });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function normalizeStateRoot(value) {
|
|
167
|
+
if (
|
|
168
|
+
typeof value !== "string" ||
|
|
169
|
+
value.length < 1 ||
|
|
170
|
+
value.length > 4096 ||
|
|
171
|
+
value.includes("\0") ||
|
|
172
|
+
!path.isAbsolute(value)
|
|
173
|
+
)
|
|
174
|
+
throw new TypeError("filesystem quarantine state root is invalid");
|
|
175
|
+
return path.resolve(value);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function pathsFor(root, artifactId) {
|
|
179
|
+
if (!ARTIFACT_ID.test(artifactId))
|
|
180
|
+
throw new TypeError("filesystem quarantine artifact id is invalid");
|
|
181
|
+
const objects = path.join(root, "objects");
|
|
182
|
+
const metadata = path.join(root, "metadata");
|
|
183
|
+
return Object.freeze({
|
|
184
|
+
part: path.join(objects, `${artifactId}.part`),
|
|
185
|
+
blob: path.join(objects, `${artifactId}.blob`),
|
|
186
|
+
metadata: path.join(metadata, `${artifactId}.json`),
|
|
187
|
+
deletionIntent: path.join(root, "deletions", `${artifactId}.intent.json`),
|
|
188
|
+
tombstone: path.join(root, "deletions", `${artifactId}.deleted.json`),
|
|
189
|
+
lockDirectory: path.join(root, "locks", `${artifactId}.lock`),
|
|
190
|
+
lockOwner: path.join(root, "locks", `${artifactId}.lock`, "owner.json"),
|
|
191
|
+
lockMaintenance: path.join(
|
|
192
|
+
root,
|
|
193
|
+
"locks",
|
|
194
|
+
`${artifactId}.lock`,
|
|
195
|
+
"maintenance.json",
|
|
196
|
+
),
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function artifactIdFromRef(value) {
|
|
201
|
+
if (typeof value !== "string" || !ARTIFACT_REF.test(value))
|
|
202
|
+
throw new TypeError("filesystem quarantine artifact reference is invalid");
|
|
203
|
+
return value.slice("quarantine:".length);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function exists(file) {
|
|
207
|
+
try {
|
|
208
|
+
await lstat(file);
|
|
209
|
+
return true;
|
|
210
|
+
} catch (error) {
|
|
211
|
+
if (error?.code === "ENOENT") return false;
|
|
212
|
+
throw error;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function syncDirectory(directory) {
|
|
217
|
+
const handle = await open(
|
|
218
|
+
directory,
|
|
219
|
+
process.platform === "win32" ? "r+" : "r",
|
|
220
|
+
);
|
|
221
|
+
try {
|
|
222
|
+
await handle.sync();
|
|
223
|
+
} finally {
|
|
224
|
+
await handle.close();
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function syncCreatedDirectoryChain(firstCreated, target) {
|
|
229
|
+
let current = path.toNamespacedPath(path.resolve(firstCreated));
|
|
230
|
+
const resolvedTarget = path.toNamespacedPath(path.resolve(target));
|
|
231
|
+
const relativeTarget = path.relative(current, resolvedTarget);
|
|
232
|
+
if (
|
|
233
|
+
relativeTarget === ".." ||
|
|
234
|
+
relativeTarget.startsWith(`..${path.sep}`) ||
|
|
235
|
+
path.isAbsolute(relativeTarget)
|
|
236
|
+
)
|
|
237
|
+
throw new Error("filesystem quarantine directory creation escaped target");
|
|
238
|
+
for (;;) {
|
|
239
|
+
await syncDirectory(path.dirname(current));
|
|
240
|
+
const remaining = path.relative(current, resolvedTarget);
|
|
241
|
+
if (remaining === "") return;
|
|
242
|
+
const [next] = remaining.split(path.sep);
|
|
243
|
+
current = path.join(current, next);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function mkdirDurably(directory, options) {
|
|
248
|
+
const firstCreated = await mkdir(directory, options);
|
|
249
|
+
if (options?.recursive) {
|
|
250
|
+
if (typeof firstCreated === "string")
|
|
251
|
+
await syncCreatedDirectoryChain(firstCreated, directory);
|
|
252
|
+
} else {
|
|
253
|
+
await syncDirectory(path.dirname(directory));
|
|
254
|
+
}
|
|
255
|
+
return firstCreated;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function unlinkIfPresent(file) {
|
|
259
|
+
try {
|
|
260
|
+
await unlink(file);
|
|
261
|
+
} catch (error) {
|
|
262
|
+
if (error?.code === "ENOENT") return false;
|
|
263
|
+
throw error;
|
|
264
|
+
}
|
|
265
|
+
await syncDirectory(path.dirname(file));
|
|
266
|
+
return true;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async function unlinkDurably(file) {
|
|
270
|
+
await unlink(file);
|
|
271
|
+
await syncDirectory(path.dirname(file));
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function renameDurably(source, target) {
|
|
275
|
+
await rename(source, target);
|
|
276
|
+
const sourceDirectory = path.dirname(source);
|
|
277
|
+
const targetDirectory = path.dirname(target);
|
|
278
|
+
await syncDirectory(targetDirectory);
|
|
279
|
+
if (sourceDirectory !== targetDirectory) await syncDirectory(sourceDirectory);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async function rmdirDurably(directory) {
|
|
283
|
+
await rmdir(directory);
|
|
284
|
+
await syncDirectory(path.dirname(directory));
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function assertSafeDirectory(root, directory) {
|
|
288
|
+
await mkdirDurably(directory, { recursive: true, mode: 0o700 });
|
|
289
|
+
const info = await lstat(directory);
|
|
290
|
+
if (!info.isDirectory() || info.isSymbolicLink())
|
|
291
|
+
throw new Error("filesystem quarantine directory is unsafe");
|
|
292
|
+
const resolved = await realpath(directory);
|
|
293
|
+
if (!isContainedPath(root, resolved))
|
|
294
|
+
throw new Error("filesystem quarantine directory escapes its state root");
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function isContainedPath(root, candidate) {
|
|
298
|
+
const relative = path.relative(root, candidate);
|
|
299
|
+
return (
|
|
300
|
+
relative === "" ||
|
|
301
|
+
(relative !== ".." &&
|
|
302
|
+
!relative.startsWith(`..${path.sep}`) &&
|
|
303
|
+
!path.isAbsolute(relative))
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function resolveSafeStateRoot(stateRoot) {
|
|
308
|
+
const resolvedRoot = await realpath(stateRoot);
|
|
309
|
+
if (path.relative(stateRoot, resolvedRoot) === "") return resolvedRoot;
|
|
310
|
+
|
|
311
|
+
// macOS exposes its temporary directory through /var even though the
|
|
312
|
+
// canonical path starts with /private/var. Accept only that operating-system
|
|
313
|
+
// alias; a link introduced anywhere below the temp root must still fail.
|
|
314
|
+
const temporaryRoot = path.resolve(tmpdir());
|
|
315
|
+
if (!isContainedPath(temporaryRoot, stateRoot))
|
|
316
|
+
throw new Error("filesystem quarantine state root must not be a link");
|
|
317
|
+
const relativeRoot = path.relative(temporaryRoot, stateRoot);
|
|
318
|
+
const resolvedTemporaryRoot = await realpath(temporaryRoot);
|
|
319
|
+
const expectedRoot = path.resolve(resolvedTemporaryRoot, relativeRoot);
|
|
320
|
+
if (path.relative(expectedRoot, resolvedRoot) !== "")
|
|
321
|
+
throw new Error("filesystem quarantine state root must not be a link");
|
|
322
|
+
return resolvedRoot;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async function prepare(state) {
|
|
326
|
+
if (!state.preparePromise) {
|
|
327
|
+
state.preparePromise = (async () => {
|
|
328
|
+
await mkdirDurably(state.stateRoot, { recursive: true, mode: 0o700 });
|
|
329
|
+
const rootInfo = await lstat(state.stateRoot);
|
|
330
|
+
if (!rootInfo.isDirectory() || rootInfo.isSymbolicLink())
|
|
331
|
+
throw new Error("filesystem quarantine state root is unsafe");
|
|
332
|
+
const resolvedRoot = await resolveSafeStateRoot(state.stateRoot);
|
|
333
|
+
await assertSafeDirectory(resolvedRoot, state.objectsRoot);
|
|
334
|
+
await assertSafeDirectory(resolvedRoot, state.metadataRoot);
|
|
335
|
+
await assertSafeDirectory(resolvedRoot, state.deletionsRoot);
|
|
336
|
+
await assertSafeDirectory(resolvedRoot, state.locksRoot);
|
|
337
|
+
})();
|
|
338
|
+
}
|
|
339
|
+
await state.preparePromise;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function hashFile(file) {
|
|
343
|
+
const hash = createHash("sha256");
|
|
344
|
+
let sizeBytes = 0;
|
|
345
|
+
for await (const chunk of createReadStream(file)) {
|
|
346
|
+
sizeBytes += chunk.byteLength;
|
|
347
|
+
hash.update(chunk);
|
|
348
|
+
}
|
|
349
|
+
return Object.freeze({
|
|
350
|
+
artifactDigest: `sha256:${hash.digest("hex")}`,
|
|
351
|
+
sizeBytes,
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function pathlessBody(state, artifactId, file, expectedMetadata) {
|
|
356
|
+
return Object.freeze({
|
|
357
|
+
async *[Symbol.asyncIterator]() {
|
|
358
|
+
const releaseLock = await acquireArtifactLock(state, artifactId, "scan");
|
|
359
|
+
let readerRegistered = false;
|
|
360
|
+
let stream = null;
|
|
361
|
+
try {
|
|
362
|
+
if (state.disposals.has(artifactId))
|
|
363
|
+
throw new Error("filesystem quarantine artifact disposal is active");
|
|
364
|
+
state.readers.set(artifactId, (state.readers.get(artifactId) ?? 0) + 1);
|
|
365
|
+
readerRegistered = true;
|
|
366
|
+
const persisted = await readMetadata(
|
|
367
|
+
state,
|
|
368
|
+
expectedMetadata.artifactRef,
|
|
369
|
+
);
|
|
370
|
+
if (
|
|
371
|
+
persisted.metadata.status !== "quarantined" ||
|
|
372
|
+
persisted.metadata.artifactDigest !==
|
|
373
|
+
expectedMetadata.artifactDigest ||
|
|
374
|
+
persisted.metadata.quarantineReceiptDigest !==
|
|
375
|
+
expectedMetadata.quarantineReceiptDigest
|
|
376
|
+
)
|
|
377
|
+
throw new Error(
|
|
378
|
+
"filesystem quarantine scan changed before streaming",
|
|
379
|
+
);
|
|
380
|
+
await verifyBlob(persisted.files, persisted.metadata);
|
|
381
|
+
stream = createReadStream(file);
|
|
382
|
+
for await (const chunk of stream) yield Buffer.from(chunk);
|
|
383
|
+
} finally {
|
|
384
|
+
stream?.destroy();
|
|
385
|
+
if (readerRegistered) {
|
|
386
|
+
const remaining = (state.readers.get(artifactId) ?? 1) - 1;
|
|
387
|
+
if (remaining === 0) state.readers.delete(artifactId);
|
|
388
|
+
else state.readers.set(artifactId, remaining);
|
|
389
|
+
}
|
|
390
|
+
await releaseLock();
|
|
391
|
+
}
|
|
392
|
+
},
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function normalizeMetadata(value, descriptor) {
|
|
397
|
+
exact(value, METADATA_KEYS, "filesystem quarantine metadata");
|
|
398
|
+
if (
|
|
399
|
+
value.schema !== BROWSER_FILESYSTEM_QUARANTINE_METADATA_SCHEMA ||
|
|
400
|
+
value.custodyId !== descriptor.custodyId ||
|
|
401
|
+
value.tenantId !== descriptor.tenantId ||
|
|
402
|
+
!ARTIFACT_REF.test(value.artifactRef) ||
|
|
403
|
+
!DIGEST.test(value.artifactDigest) ||
|
|
404
|
+
!Number.isSafeInteger(value.sizeBytes) ||
|
|
405
|
+
value.sizeBytes < 1 ||
|
|
406
|
+
value.sizeBytes > MAX_BYTES ||
|
|
407
|
+
!CONTENT_TYPE.test(value.contentType) ||
|
|
408
|
+
!DIGEST.test(value.networkReceiptDigest) ||
|
|
409
|
+
!DIGEST.test(value.sourceActionReceiptDigest) ||
|
|
410
|
+
!["quarantined", "ready"].includes(value.status) ||
|
|
411
|
+
!Number.isFinite(Date.parse(value.committedAt)) ||
|
|
412
|
+
!Number.isFinite(Date.parse(value.expiresAt)) ||
|
|
413
|
+
Date.parse(value.expiresAt) <= Date.parse(value.committedAt) ||
|
|
414
|
+
!DIGEST.test(value.quarantineReceiptDigest) ||
|
|
415
|
+
(value.status === "quarantined" &&
|
|
416
|
+
(value.scanEvidenceDigest !== null ||
|
|
417
|
+
value.completedAt !== null ||
|
|
418
|
+
value.completionReceiptDigest !== null)) ||
|
|
419
|
+
(value.status === "ready" &&
|
|
420
|
+
(!DIGEST.test(value.scanEvidenceDigest) ||
|
|
421
|
+
!Number.isFinite(Date.parse(value.completedAt)) ||
|
|
422
|
+
!DIGEST.test(value.completionReceiptDigest)))
|
|
423
|
+
)
|
|
424
|
+
throw new Error("filesystem quarantine metadata is invalid");
|
|
425
|
+
return Object.freeze({ ...value });
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
async function writeMetadata(file, value) {
|
|
429
|
+
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
430
|
+
let handle = null;
|
|
431
|
+
try {
|
|
432
|
+
handle = await open(temporary, "wx", 0o600);
|
|
433
|
+
await handle.writeFile(`${canonical(value)}\n`, "utf8");
|
|
434
|
+
await handle.sync();
|
|
435
|
+
await handle.close();
|
|
436
|
+
handle = null;
|
|
437
|
+
await rename(temporary, file);
|
|
438
|
+
// Windows requires a write-capable handle for FlushFileBuffers/fsync.
|
|
439
|
+
const committed = await open(file, "r+");
|
|
440
|
+
try {
|
|
441
|
+
await committed.sync();
|
|
442
|
+
} finally {
|
|
443
|
+
await committed.close();
|
|
444
|
+
}
|
|
445
|
+
await syncDirectory(path.dirname(file));
|
|
446
|
+
} catch (error) {
|
|
447
|
+
if (handle) await handle.close().catch(() => {});
|
|
448
|
+
await unlinkIfPresent(temporary).catch(() => {});
|
|
449
|
+
throw error;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
async function writeNewMetadata(file, value) {
|
|
454
|
+
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
455
|
+
let handle = null;
|
|
456
|
+
try {
|
|
457
|
+
handle = await open(temporary, "wx", 0o600);
|
|
458
|
+
await handle.writeFile(`${canonical(value)}\n`, "utf8");
|
|
459
|
+
await handle.sync();
|
|
460
|
+
await handle.close();
|
|
461
|
+
handle = null;
|
|
462
|
+
await link(temporary, file);
|
|
463
|
+
await unlink(temporary);
|
|
464
|
+
const committed = await open(file, "r+");
|
|
465
|
+
try {
|
|
466
|
+
await committed.sync();
|
|
467
|
+
} finally {
|
|
468
|
+
await committed.close();
|
|
469
|
+
}
|
|
470
|
+
await syncDirectory(path.dirname(file));
|
|
471
|
+
} catch (error) {
|
|
472
|
+
if (handle) await handle.close().catch(() => {});
|
|
473
|
+
await unlinkIfPresent(temporary).catch(() => {});
|
|
474
|
+
throw error;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function lockError(message) {
|
|
479
|
+
const error = new Error(message);
|
|
480
|
+
error.code = "BROWSER_FILESYSTEM_QUARANTINE_ARTIFACT_LOCKED";
|
|
481
|
+
return error;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function normalizeLockOwner(value, state, artifactId) {
|
|
485
|
+
exact(
|
|
486
|
+
value,
|
|
487
|
+
[
|
|
488
|
+
"schema",
|
|
489
|
+
"custodyId",
|
|
490
|
+
"tenantId",
|
|
491
|
+
"artifactId",
|
|
492
|
+
"operation",
|
|
493
|
+
"lockId",
|
|
494
|
+
"pid",
|
|
495
|
+
"acquiredAt",
|
|
496
|
+
],
|
|
497
|
+
"filesystem quarantine lock owner",
|
|
498
|
+
);
|
|
499
|
+
if (
|
|
500
|
+
value.schema !== BROWSER_FILESYSTEM_QUARANTINE_LOCK_OWNER_SCHEMA ||
|
|
501
|
+
value.custodyId !== state.descriptor.custodyId ||
|
|
502
|
+
value.tenantId !== state.descriptor.tenantId ||
|
|
503
|
+
value.artifactId !== artifactId ||
|
|
504
|
+
!LOCK_OPERATIONS.has(value.operation) ||
|
|
505
|
+
typeof value.lockId !== "string" ||
|
|
506
|
+
!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu.test(
|
|
507
|
+
value.lockId,
|
|
508
|
+
) ||
|
|
509
|
+
!Number.isSafeInteger(value.pid) ||
|
|
510
|
+
value.pid < 1 ||
|
|
511
|
+
!Number.isFinite(Date.parse(value.acquiredAt))
|
|
512
|
+
)
|
|
513
|
+
throw new Error("filesystem quarantine lock owner is invalid");
|
|
514
|
+
return Object.freeze({ ...value });
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function normalizeLockMaintenanceClaim(value, state, artifactId) {
|
|
518
|
+
exact(
|
|
519
|
+
value,
|
|
520
|
+
[
|
|
521
|
+
"schema",
|
|
522
|
+
"custodyId",
|
|
523
|
+
"tenantId",
|
|
524
|
+
"authorityId",
|
|
525
|
+
"handlerArtifactDigest",
|
|
526
|
+
"artifactId",
|
|
527
|
+
"maintenanceId",
|
|
528
|
+
"pid",
|
|
529
|
+
"acquiredAt",
|
|
530
|
+
"expectedLockStateDigest",
|
|
531
|
+
"actionReceiptDigest",
|
|
532
|
+
"requestDigest",
|
|
533
|
+
],
|
|
534
|
+
"filesystem quarantine lock maintenance claim",
|
|
535
|
+
);
|
|
536
|
+
if (
|
|
537
|
+
value.schema !== LOCK_MAINTENANCE_CLAIM_SCHEMA ||
|
|
538
|
+
value.custodyId !== state.descriptor.custodyId ||
|
|
539
|
+
value.tenantId !== state.descriptor.tenantId ||
|
|
540
|
+
!ID.test(value.authorityId) ||
|
|
541
|
+
value.handlerArtifactDigest !== state.descriptor.handlerArtifactDigest ||
|
|
542
|
+
value.artifactId !== artifactId ||
|
|
543
|
+
typeof value.maintenanceId !== "string" ||
|
|
544
|
+
!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu.test(
|
|
545
|
+
value.maintenanceId,
|
|
546
|
+
) ||
|
|
547
|
+
!Number.isSafeInteger(value.pid) ||
|
|
548
|
+
value.pid < 1 ||
|
|
549
|
+
!Number.isFinite(Date.parse(value.acquiredAt)) ||
|
|
550
|
+
!DIGEST.test(value.expectedLockStateDigest) ||
|
|
551
|
+
!DIGEST.test(value.actionReceiptDigest) ||
|
|
552
|
+
!DIGEST.test(value.requestDigest)
|
|
553
|
+
)
|
|
554
|
+
throw new Error("filesystem quarantine lock maintenance claim is invalid");
|
|
555
|
+
return Object.freeze({ ...value });
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function isProcessAlive(pid) {
|
|
559
|
+
if (pid === process.pid) return true;
|
|
560
|
+
try {
|
|
561
|
+
process.kill(pid, 0);
|
|
562
|
+
return true;
|
|
563
|
+
} catch (error) {
|
|
564
|
+
if (error?.code === "ESRCH") return false;
|
|
565
|
+
if (error?.code === "EPERM") return true;
|
|
566
|
+
throw error;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
async function inspectLockDirectory(files) {
|
|
571
|
+
let info;
|
|
572
|
+
try {
|
|
573
|
+
info = await lstat(files.lockDirectory);
|
|
574
|
+
} catch (error) {
|
|
575
|
+
if (error?.code === "ENOENT") return null;
|
|
576
|
+
throw error;
|
|
577
|
+
}
|
|
578
|
+
if (!info.isDirectory() || info.isSymbolicLink())
|
|
579
|
+
throw new Error("filesystem quarantine artifact lock is unsafe");
|
|
580
|
+
const entries = await readdir(files.lockDirectory, { withFileTypes: true });
|
|
581
|
+
for (const entry of entries) {
|
|
582
|
+
if (
|
|
583
|
+
(entry.name !== "owner.json" &&
|
|
584
|
+
entry.name !== "maintenance.json" &&
|
|
585
|
+
!LOCK_TEMP_FILE.test(entry.name) &&
|
|
586
|
+
!LOCK_MAINTENANCE_TEMP_FILE.test(entry.name)) ||
|
|
587
|
+
!entry.isFile() ||
|
|
588
|
+
entry.isSymbolicLink()
|
|
589
|
+
)
|
|
590
|
+
throw new Error("filesystem quarantine artifact lock is unsafe");
|
|
591
|
+
}
|
|
592
|
+
return Object.freeze({
|
|
593
|
+
info,
|
|
594
|
+
entries,
|
|
595
|
+
maintenanceEntries: Object.freeze(
|
|
596
|
+
entries.filter(
|
|
597
|
+
(entry) =>
|
|
598
|
+
entry.name === "maintenance.json" ||
|
|
599
|
+
LOCK_MAINTENANCE_TEMP_FILE.test(entry.name),
|
|
600
|
+
),
|
|
601
|
+
),
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
async function removeLockDirectory(files) {
|
|
606
|
+
const inspected = await inspectLockDirectory(files);
|
|
607
|
+
if (inspected === null) return;
|
|
608
|
+
if (inspected.maintenanceEntries.length > 0)
|
|
609
|
+
throw lockError(
|
|
610
|
+
"filesystem quarantine artifact lock maintenance is active",
|
|
611
|
+
);
|
|
612
|
+
for (const entry of inspected.entries)
|
|
613
|
+
await unlinkIfPresent(path.join(files.lockDirectory, entry.name));
|
|
614
|
+
try {
|
|
615
|
+
await rmdirDurably(files.lockDirectory);
|
|
616
|
+
} catch (error) {
|
|
617
|
+
if (error?.code !== "ENOENT") throw error;
|
|
618
|
+
}
|
|
619
|
+
if (await exists(files.lockDirectory))
|
|
620
|
+
throw new Error("filesystem quarantine artifact lock cleanup failed");
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
async function recoverAbandonedMaintenanceClaim(
|
|
624
|
+
state,
|
|
625
|
+
files,
|
|
626
|
+
artifactId,
|
|
627
|
+
inspected,
|
|
628
|
+
) {
|
|
629
|
+
if (inspected.maintenanceEntries.length === 0) return inspected;
|
|
630
|
+
let claim = null;
|
|
631
|
+
try {
|
|
632
|
+
claim = normalizeLockMaintenanceClaim(
|
|
633
|
+
JSON.parse(await readFile(files.lockMaintenance, "utf8")),
|
|
634
|
+
state,
|
|
635
|
+
artifactId,
|
|
636
|
+
);
|
|
637
|
+
} catch (error) {
|
|
638
|
+
if (error?.code !== "ENOENT") throw error;
|
|
639
|
+
}
|
|
640
|
+
if (claim) {
|
|
641
|
+
const active =
|
|
642
|
+
claim.pid === process.pid
|
|
643
|
+
? state.maintenanceClaims.has(claim.maintenanceId)
|
|
644
|
+
: isProcessAlive(claim.pid);
|
|
645
|
+
if (active)
|
|
646
|
+
throw lockError(
|
|
647
|
+
"filesystem quarantine artifact lock maintenance is active",
|
|
648
|
+
);
|
|
649
|
+
} else {
|
|
650
|
+
const ages = await Promise.all(
|
|
651
|
+
inspected.maintenanceEntries.map(
|
|
652
|
+
async (entry) =>
|
|
653
|
+
(await lstat(path.join(files.lockDirectory, entry.name))).mtimeMs,
|
|
654
|
+
),
|
|
655
|
+
);
|
|
656
|
+
if (Date.now() - Math.max(...ages) < LOCK_OWNER_INIT_TIMEOUT_MS)
|
|
657
|
+
throw lockError(
|
|
658
|
+
"filesystem quarantine artifact lock maintenance is initializing",
|
|
659
|
+
);
|
|
660
|
+
}
|
|
661
|
+
for (const entry of inspected.maintenanceEntries)
|
|
662
|
+
await unlinkIfPresent(path.join(files.lockDirectory, entry.name));
|
|
663
|
+
const recovered = await inspectLockDirectory(files);
|
|
664
|
+
if (recovered === null)
|
|
665
|
+
throw new Error(
|
|
666
|
+
"filesystem quarantine artifact lock disappeared during maintenance recovery",
|
|
667
|
+
);
|
|
668
|
+
return recovered;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
async function recoverAbandonedLock(state, files, artifactId) {
|
|
672
|
+
let inspected = await inspectLockDirectory(files);
|
|
673
|
+
if (inspected === null) return;
|
|
674
|
+
inspected = await recoverAbandonedMaintenanceClaim(
|
|
675
|
+
state,
|
|
676
|
+
files,
|
|
677
|
+
artifactId,
|
|
678
|
+
inspected,
|
|
679
|
+
);
|
|
680
|
+
let parsed;
|
|
681
|
+
try {
|
|
682
|
+
parsed = JSON.parse(await readFile(files.lockOwner, "utf8"));
|
|
683
|
+
} catch (error) {
|
|
684
|
+
if (error?.code !== "ENOENT") throw error;
|
|
685
|
+
if (Date.now() - inspected.info.mtimeMs < LOCK_OWNER_INIT_TIMEOUT_MS)
|
|
686
|
+
throw lockError("filesystem quarantine artifact lock is initializing");
|
|
687
|
+
await removeLockDirectory(files);
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
const owner = normalizeLockOwner(parsed, state, artifactId);
|
|
691
|
+
if (isProcessAlive(owner.pid))
|
|
692
|
+
throw lockError(
|
|
693
|
+
`filesystem quarantine artifact is locked for ${owner.operation}`,
|
|
694
|
+
);
|
|
695
|
+
await removeLockDirectory(files);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
async function acquireArtifactLock(state, artifactId, operation) {
|
|
699
|
+
if (!ARTIFACT_ID.test(artifactId) || !LOCK_OPERATIONS.has(operation))
|
|
700
|
+
throw new TypeError("filesystem quarantine artifact lock input is invalid");
|
|
701
|
+
await prepare(state);
|
|
702
|
+
const files = pathsFor(state.stateRoot, artifactId);
|
|
703
|
+
const ownerValue = Object.freeze({
|
|
704
|
+
schema: BROWSER_FILESYSTEM_QUARANTINE_LOCK_OWNER_SCHEMA,
|
|
705
|
+
custodyId: state.descriptor.custodyId,
|
|
706
|
+
tenantId: state.descriptor.tenantId,
|
|
707
|
+
artifactId,
|
|
708
|
+
operation,
|
|
709
|
+
lockId: randomUUID(),
|
|
710
|
+
pid: process.pid,
|
|
711
|
+
acquiredAt: new Date().toISOString(),
|
|
712
|
+
});
|
|
713
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
714
|
+
try {
|
|
715
|
+
await mkdirDurably(files.lockDirectory, { mode: 0o700 });
|
|
716
|
+
} catch (error) {
|
|
717
|
+
if (error?.code !== "EEXIST") throw error;
|
|
718
|
+
await recoverAbandonedLock(state, files, artifactId);
|
|
719
|
+
continue;
|
|
720
|
+
}
|
|
721
|
+
try {
|
|
722
|
+
await writeNewMetadata(files.lockOwner, ownerValue);
|
|
723
|
+
const readback = normalizeLockOwner(
|
|
724
|
+
JSON.parse(await readFile(files.lockOwner, "utf8")),
|
|
725
|
+
state,
|
|
726
|
+
artifactId,
|
|
727
|
+
);
|
|
728
|
+
if (readback.lockId !== ownerValue.lockId)
|
|
729
|
+
throw new Error("filesystem quarantine artifact lock differs");
|
|
730
|
+
if (state.heldLocks.has(artifactId))
|
|
731
|
+
throw new Error("filesystem quarantine artifact lock state conflicts");
|
|
732
|
+
state.heldLocks.set(artifactId, ownerValue.lockId);
|
|
733
|
+
} catch (error) {
|
|
734
|
+
await removeLockDirectory(files).catch(() => {});
|
|
735
|
+
throw error;
|
|
736
|
+
}
|
|
737
|
+
let released = false;
|
|
738
|
+
return async () => {
|
|
739
|
+
if (released) return;
|
|
740
|
+
try {
|
|
741
|
+
const current = normalizeLockOwner(
|
|
742
|
+
JSON.parse(await readFile(files.lockOwner, "utf8")),
|
|
743
|
+
state,
|
|
744
|
+
artifactId,
|
|
745
|
+
);
|
|
746
|
+
if (current.lockId !== ownerValue.lockId)
|
|
747
|
+
throw new Error(
|
|
748
|
+
"filesystem quarantine artifact lock ownership changed",
|
|
749
|
+
);
|
|
750
|
+
await removeLockDirectory(files);
|
|
751
|
+
released = true;
|
|
752
|
+
} finally {
|
|
753
|
+
if (state.heldLocks.get(artifactId) === ownerValue.lockId)
|
|
754
|
+
state.heldLocks.delete(artifactId);
|
|
755
|
+
}
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
throw lockError("filesystem quarantine artifact lock acquisition raced");
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function lockDirectoryIdentityDigest(info) {
|
|
762
|
+
return digest(
|
|
763
|
+
"chainlesschain.browser-filesystem-quarantine-lock-directory-identity/v1",
|
|
764
|
+
{
|
|
765
|
+
device: info.dev,
|
|
766
|
+
inode: info.ino,
|
|
767
|
+
birthtimeMs: info.birthtimeMs,
|
|
768
|
+
},
|
|
769
|
+
);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
async function ownerEvidence(inspected, files, state, artifactId) {
|
|
773
|
+
let rawOwner = null;
|
|
774
|
+
try {
|
|
775
|
+
rawOwner = await readFile(files.lockOwner);
|
|
776
|
+
} catch (error) {
|
|
777
|
+
if (error?.code !== "ENOENT") throw error;
|
|
778
|
+
}
|
|
779
|
+
if (rawOwner) {
|
|
780
|
+
const ownerEvidenceDigest = `sha256:${createHash("sha256")
|
|
781
|
+
.update(rawOwner)
|
|
782
|
+
.digest("hex")}`;
|
|
783
|
+
try {
|
|
784
|
+
const owner = normalizeLockOwner(
|
|
785
|
+
JSON.parse(rawOwner.toString("utf8")),
|
|
786
|
+
state,
|
|
787
|
+
artifactId,
|
|
788
|
+
);
|
|
789
|
+
return Object.freeze({
|
|
790
|
+
status: isProcessAlive(owner.pid) ? "owned-live" : "owned-dead",
|
|
791
|
+
owner,
|
|
792
|
+
ownerEvidenceDigest,
|
|
793
|
+
});
|
|
794
|
+
} catch {
|
|
795
|
+
return Object.freeze({
|
|
796
|
+
status: "owned-invalid",
|
|
797
|
+
owner: null,
|
|
798
|
+
ownerEvidenceDigest,
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
const temporaryEvidence = [];
|
|
803
|
+
for (const entry of inspected.entries
|
|
804
|
+
.filter((candidate) => LOCK_TEMP_FILE.test(candidate.name))
|
|
805
|
+
.sort((left, right) => left.name.localeCompare(right.name))) {
|
|
806
|
+
const file = path.join(files.lockDirectory, entry.name);
|
|
807
|
+
const [contents, info] = await Promise.all([readFile(file), lstat(file)]);
|
|
808
|
+
temporaryEvidence.push(
|
|
809
|
+
Object.freeze({
|
|
810
|
+
name: entry.name,
|
|
811
|
+
size: info.size,
|
|
812
|
+
contentsDigest: `sha256:${createHash("sha256")
|
|
813
|
+
.update(contents)
|
|
814
|
+
.digest("hex")}`,
|
|
815
|
+
}),
|
|
816
|
+
);
|
|
817
|
+
}
|
|
818
|
+
return Object.freeze({
|
|
819
|
+
status:
|
|
820
|
+
Date.now() - inspected.info.mtimeMs < LOCK_OWNER_INIT_TIMEOUT_MS
|
|
821
|
+
? "owner-initializing"
|
|
822
|
+
: "owner-orphaned",
|
|
823
|
+
owner: null,
|
|
824
|
+
ownerEvidenceDigest: digest(
|
|
825
|
+
"chainlesschain.browser-filesystem-quarantine-lock-owner-evidence/v1",
|
|
826
|
+
temporaryEvidence,
|
|
827
|
+
),
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
async function inspectArtifactLockState(
|
|
832
|
+
state,
|
|
833
|
+
artifactRef,
|
|
834
|
+
allowedMaintenanceId = null,
|
|
835
|
+
) {
|
|
836
|
+
await prepare(state);
|
|
837
|
+
const artifactId = artifactIdFromRef(artifactRef);
|
|
838
|
+
const files = pathsFor(state.stateRoot, artifactId);
|
|
839
|
+
const inspected = await inspectLockDirectory(files);
|
|
840
|
+
const observedAtMs = state.now();
|
|
841
|
+
if (!Number.isFinite(observedAtMs))
|
|
842
|
+
throw new Error("filesystem quarantine lock diagnostic clock is invalid");
|
|
843
|
+
const artifactRefDigest = digest(
|
|
844
|
+
"chainlesschain.browser-download-artifact-ref/v1",
|
|
845
|
+
artifactRef,
|
|
846
|
+
);
|
|
847
|
+
if (inspected === null) {
|
|
848
|
+
const stateCore = Object.freeze({
|
|
849
|
+
custodyId: state.descriptor.custodyId,
|
|
850
|
+
tenantId: state.descriptor.tenantId,
|
|
851
|
+
handlerArtifactDigest: state.descriptor.handlerArtifactDigest,
|
|
852
|
+
artifactRefDigest,
|
|
853
|
+
lockDirectoryIdentityDigest: null,
|
|
854
|
+
ownerEvidenceDigest: null,
|
|
855
|
+
});
|
|
856
|
+
return Object.freeze({
|
|
857
|
+
schema: BROWSER_FILESYSTEM_QUARANTINE_LOCK_DIAGNOSTIC_SCHEMA,
|
|
858
|
+
custodyId: state.descriptor.custodyId,
|
|
859
|
+
tenantId: state.descriptor.tenantId,
|
|
860
|
+
handlerArtifactDigest: state.descriptor.handlerArtifactDigest,
|
|
861
|
+
artifactRefDigest,
|
|
862
|
+
status: "absent",
|
|
863
|
+
ownerOperation: null,
|
|
864
|
+
ownerPid: null,
|
|
865
|
+
ownerAcquiredAt: null,
|
|
866
|
+
ownerEvidenceDigest: null,
|
|
867
|
+
lockDirectoryIdentityDigest: null,
|
|
868
|
+
observedAt: new Date(observedAtMs).toISOString(),
|
|
869
|
+
lockStateDigest: digest(
|
|
870
|
+
BROWSER_FILESYSTEM_QUARANTINE_LOCK_DIAGNOSTIC_SCHEMA,
|
|
871
|
+
stateCore,
|
|
872
|
+
),
|
|
873
|
+
});
|
|
874
|
+
}
|
|
875
|
+
if (inspected.maintenanceEntries.length > 0) {
|
|
876
|
+
let claim;
|
|
877
|
+
try {
|
|
878
|
+
claim = normalizeLockMaintenanceClaim(
|
|
879
|
+
JSON.parse(await readFile(files.lockMaintenance, "utf8")),
|
|
880
|
+
state,
|
|
881
|
+
artifactId,
|
|
882
|
+
);
|
|
883
|
+
} catch (error) {
|
|
884
|
+
if (error?.code === "ENOENT")
|
|
885
|
+
throw lockError(
|
|
886
|
+
"filesystem quarantine artifact lock maintenance is initializing",
|
|
887
|
+
);
|
|
888
|
+
throw error;
|
|
889
|
+
}
|
|
890
|
+
if (
|
|
891
|
+
allowedMaintenanceId === null ||
|
|
892
|
+
claim.maintenanceId !== allowedMaintenanceId
|
|
893
|
+
)
|
|
894
|
+
throw lockError(
|
|
895
|
+
"filesystem quarantine artifact lock maintenance is active",
|
|
896
|
+
);
|
|
897
|
+
}
|
|
898
|
+
const evidence = await ownerEvidence(inspected, files, state, artifactId);
|
|
899
|
+
const lockIdentity = lockDirectoryIdentityDigest(inspected.info);
|
|
900
|
+
const stateCore = Object.freeze({
|
|
901
|
+
custodyId: state.descriptor.custodyId,
|
|
902
|
+
tenantId: state.descriptor.tenantId,
|
|
903
|
+
handlerArtifactDigest: state.descriptor.handlerArtifactDigest,
|
|
904
|
+
artifactRefDigest,
|
|
905
|
+
lockDirectoryIdentityDigest: lockIdentity,
|
|
906
|
+
ownerEvidenceDigest: evidence.ownerEvidenceDigest,
|
|
907
|
+
});
|
|
908
|
+
return Object.freeze({
|
|
909
|
+
schema: BROWSER_FILESYSTEM_QUARANTINE_LOCK_DIAGNOSTIC_SCHEMA,
|
|
910
|
+
custodyId: state.descriptor.custodyId,
|
|
911
|
+
tenantId: state.descriptor.tenantId,
|
|
912
|
+
handlerArtifactDigest: state.descriptor.handlerArtifactDigest,
|
|
913
|
+
artifactRefDigest,
|
|
914
|
+
status: evidence.status,
|
|
915
|
+
ownerOperation: evidence.owner?.operation ?? null,
|
|
916
|
+
ownerPid: evidence.owner?.pid ?? null,
|
|
917
|
+
ownerAcquiredAt: evidence.owner?.acquiredAt ?? null,
|
|
918
|
+
ownerEvidenceDigest: evidence.ownerEvidenceDigest,
|
|
919
|
+
lockDirectoryIdentityDigest: lockIdentity,
|
|
920
|
+
observedAt: new Date(observedAtMs).toISOString(),
|
|
921
|
+
lockStateDigest: digest(
|
|
922
|
+
BROWSER_FILESYSTEM_QUARANTINE_LOCK_DIAGNOSTIC_SCHEMA,
|
|
923
|
+
stateCore,
|
|
924
|
+
),
|
|
925
|
+
});
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
function normalizeLockReleaseInput(value) {
|
|
929
|
+
exact(
|
|
930
|
+
value,
|
|
931
|
+
[
|
|
932
|
+
"actionReceiptDigest",
|
|
933
|
+
"requestDigest",
|
|
934
|
+
"artifactRef",
|
|
935
|
+
"expectedLockStateDigest",
|
|
936
|
+
],
|
|
937
|
+
"filesystem quarantine lock release request",
|
|
938
|
+
);
|
|
939
|
+
if (
|
|
940
|
+
!DIGEST.test(value.actionReceiptDigest) ||
|
|
941
|
+
!DIGEST.test(value.requestDigest) ||
|
|
942
|
+
!ARTIFACT_REF.test(value.artifactRef) ||
|
|
943
|
+
!DIGEST.test(value.expectedLockStateDigest)
|
|
944
|
+
)
|
|
945
|
+
throw new TypeError(
|
|
946
|
+
"filesystem quarantine lock release request is invalid",
|
|
947
|
+
);
|
|
948
|
+
return Object.freeze({ ...value });
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
async function cleanupMaintenanceClaim(
|
|
952
|
+
state,
|
|
953
|
+
files,
|
|
954
|
+
artifactId,
|
|
955
|
+
maintenanceId,
|
|
956
|
+
) {
|
|
957
|
+
let claim;
|
|
958
|
+
try {
|
|
959
|
+
claim = normalizeLockMaintenanceClaim(
|
|
960
|
+
JSON.parse(await readFile(files.lockMaintenance, "utf8")),
|
|
961
|
+
state,
|
|
962
|
+
artifactId,
|
|
963
|
+
);
|
|
964
|
+
} catch (error) {
|
|
965
|
+
if (error?.code === "ENOENT") return;
|
|
966
|
+
throw error;
|
|
967
|
+
}
|
|
968
|
+
if (claim.maintenanceId === maintenanceId)
|
|
969
|
+
await unlinkIfPresent(files.lockMaintenance);
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
async function releaseArtifactLock(state, maintenanceDescriptor, requestValue) {
|
|
973
|
+
const request = normalizeLockReleaseInput(requestValue);
|
|
974
|
+
const before = await inspectArtifactLockState(state, request.artifactRef);
|
|
975
|
+
if (before.status === "absent")
|
|
976
|
+
throw new Error("filesystem quarantine artifact lock is absent");
|
|
977
|
+
if (before.lockStateDigest !== request.expectedLockStateDigest)
|
|
978
|
+
throw new Error("filesystem quarantine artifact lock state changed");
|
|
979
|
+
const artifactId = artifactIdFromRef(request.artifactRef);
|
|
980
|
+
if (state.heldLocks.has(artifactId))
|
|
981
|
+
throw lockError(
|
|
982
|
+
"filesystem quarantine artifact lock is held by this custody instance",
|
|
983
|
+
);
|
|
984
|
+
const files = pathsFor(state.stateRoot, artifactId);
|
|
985
|
+
const maintenanceId = randomUUID();
|
|
986
|
+
const acquiredAtMs = state.now();
|
|
987
|
+
if (!Number.isFinite(acquiredAtMs))
|
|
988
|
+
throw new Error("filesystem quarantine lock maintenance clock is invalid");
|
|
989
|
+
const claim = Object.freeze({
|
|
990
|
+
schema: LOCK_MAINTENANCE_CLAIM_SCHEMA,
|
|
991
|
+
custodyId: state.descriptor.custodyId,
|
|
992
|
+
tenantId: state.descriptor.tenantId,
|
|
993
|
+
authorityId: maintenanceDescriptor.authorityId,
|
|
994
|
+
handlerArtifactDigest: state.descriptor.handlerArtifactDigest,
|
|
995
|
+
artifactId,
|
|
996
|
+
maintenanceId,
|
|
997
|
+
pid: process.pid,
|
|
998
|
+
acquiredAt: new Date(acquiredAtMs).toISOString(),
|
|
999
|
+
expectedLockStateDigest: request.expectedLockStateDigest,
|
|
1000
|
+
actionReceiptDigest: request.actionReceiptDigest,
|
|
1001
|
+
requestDigest: request.requestDigest,
|
|
1002
|
+
});
|
|
1003
|
+
state.maintenanceClaims.add(maintenanceId);
|
|
1004
|
+
try {
|
|
1005
|
+
await writeNewMetadata(files.lockMaintenance, claim);
|
|
1006
|
+
const claimed = await inspectArtifactLockState(
|
|
1007
|
+
state,
|
|
1008
|
+
request.artifactRef,
|
|
1009
|
+
maintenanceId,
|
|
1010
|
+
);
|
|
1011
|
+
if (claimed.lockStateDigest !== request.expectedLockStateDigest)
|
|
1012
|
+
throw new Error(
|
|
1013
|
+
"filesystem quarantine artifact lock changed before maintenance claim",
|
|
1014
|
+
);
|
|
1015
|
+
const inspected = await inspectLockDirectory(files);
|
|
1016
|
+
if (inspected === null)
|
|
1017
|
+
throw new Error(
|
|
1018
|
+
"filesystem quarantine artifact lock disappeared during maintenance",
|
|
1019
|
+
);
|
|
1020
|
+
const persistedClaim = normalizeLockMaintenanceClaim(
|
|
1021
|
+
JSON.parse(await readFile(files.lockMaintenance, "utf8")),
|
|
1022
|
+
state,
|
|
1023
|
+
artifactId,
|
|
1024
|
+
);
|
|
1025
|
+
if (persistedClaim.maintenanceId !== maintenanceId)
|
|
1026
|
+
throw new Error(
|
|
1027
|
+
"filesystem quarantine lock maintenance ownership changed",
|
|
1028
|
+
);
|
|
1029
|
+
for (const entry of inspected.entries) {
|
|
1030
|
+
if (
|
|
1031
|
+
entry.name === "maintenance.json" ||
|
|
1032
|
+
LOCK_MAINTENANCE_TEMP_FILE.test(entry.name)
|
|
1033
|
+
)
|
|
1034
|
+
continue;
|
|
1035
|
+
await unlinkIfPresent(path.join(files.lockDirectory, entry.name));
|
|
1036
|
+
}
|
|
1037
|
+
await unlinkDurably(files.lockMaintenance);
|
|
1038
|
+
await rmdirDurably(files.lockDirectory);
|
|
1039
|
+
if (await exists(files.lockDirectory))
|
|
1040
|
+
throw new Error(
|
|
1041
|
+
"filesystem quarantine artifact lock release readback failed",
|
|
1042
|
+
);
|
|
1043
|
+
const releasedAtMs = state.now();
|
|
1044
|
+
if (!Number.isFinite(releasedAtMs))
|
|
1045
|
+
throw new Error(
|
|
1046
|
+
"filesystem quarantine lock maintenance clock is invalid",
|
|
1047
|
+
);
|
|
1048
|
+
const core = Object.freeze({
|
|
1049
|
+
schema: BROWSER_FILESYSTEM_QUARANTINE_LOCK_RELEASE_ACK_SCHEMA,
|
|
1050
|
+
custodyId: state.descriptor.custodyId,
|
|
1051
|
+
authorityId: maintenanceDescriptor.authorityId,
|
|
1052
|
+
tenantId: maintenanceDescriptor.tenantId,
|
|
1053
|
+
handlerArtifactDigest: maintenanceDescriptor.handlerArtifactDigest,
|
|
1054
|
+
policyRevision: maintenanceDescriptor.policyRevision,
|
|
1055
|
+
actionReceiptDigest: request.actionReceiptDigest,
|
|
1056
|
+
requestDigest: request.requestDigest,
|
|
1057
|
+
artifactRefDigest: before.artifactRefDigest,
|
|
1058
|
+
releasedLockStateDigest: before.lockStateDigest,
|
|
1059
|
+
releasedAt: new Date(releasedAtMs).toISOString(),
|
|
1060
|
+
});
|
|
1061
|
+
return Object.freeze({
|
|
1062
|
+
...core,
|
|
1063
|
+
releaseReceiptDigest: digest(
|
|
1064
|
+
BROWSER_FILESYSTEM_QUARANTINE_LOCK_RELEASE_ACK_SCHEMA,
|
|
1065
|
+
core,
|
|
1066
|
+
),
|
|
1067
|
+
authenticated: true,
|
|
1068
|
+
durable: true,
|
|
1069
|
+
readbackVerified: true,
|
|
1070
|
+
artifactBytesChanged: false,
|
|
1071
|
+
qualifiesForPromotion: false,
|
|
1072
|
+
});
|
|
1073
|
+
} catch (error) {
|
|
1074
|
+
await cleanupMaintenanceClaim(
|
|
1075
|
+
state,
|
|
1076
|
+
files,
|
|
1077
|
+
artifactId,
|
|
1078
|
+
maintenanceId,
|
|
1079
|
+
).catch(() => {});
|
|
1080
|
+
throw error;
|
|
1081
|
+
} finally {
|
|
1082
|
+
state.maintenanceClaims.delete(maintenanceId);
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
function assertRegularRecoveryEntry(entry, label) {
|
|
1087
|
+
if (!entry.isFile() || entry.isSymbolicLink())
|
|
1088
|
+
throw new Error(`filesystem quarantine ${label} entry is unsafe`);
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
async function discoverRecoveryState(state) {
|
|
1092
|
+
const candidates = new Set();
|
|
1093
|
+
const temporaryFiles = new Map();
|
|
1094
|
+
const addTemporary = (artifactId, file) => {
|
|
1095
|
+
candidates.add(artifactId);
|
|
1096
|
+
let files = temporaryFiles.get(artifactId);
|
|
1097
|
+
if (!files) {
|
|
1098
|
+
files = new Set();
|
|
1099
|
+
temporaryFiles.set(artifactId, files);
|
|
1100
|
+
}
|
|
1101
|
+
files.add(file);
|
|
1102
|
+
};
|
|
1103
|
+
|
|
1104
|
+
const metadataIds = new Set();
|
|
1105
|
+
for (const entry of await readdir(state.metadataRoot, {
|
|
1106
|
+
withFileTypes: true,
|
|
1107
|
+
})) {
|
|
1108
|
+
assertRegularRecoveryEntry(entry, "metadata");
|
|
1109
|
+
const metadataMatch = METADATA_FILE.exec(entry.name);
|
|
1110
|
+
if (metadataMatch) {
|
|
1111
|
+
metadataIds.add(metadataMatch[1]);
|
|
1112
|
+
continue;
|
|
1113
|
+
}
|
|
1114
|
+
const temporaryMatch = METADATA_TEMP_FILE.exec(entry.name);
|
|
1115
|
+
if (!temporaryMatch)
|
|
1116
|
+
throw new Error("filesystem quarantine metadata entry is unknown");
|
|
1117
|
+
addTemporary(temporaryMatch[1], path.join(state.metadataRoot, entry.name));
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
for (const entry of await readdir(state.objectsRoot, {
|
|
1121
|
+
withFileTypes: true,
|
|
1122
|
+
})) {
|
|
1123
|
+
assertRegularRecoveryEntry(entry, "object");
|
|
1124
|
+
const match = OBJECT_FILE.exec(entry.name);
|
|
1125
|
+
if (!match)
|
|
1126
|
+
throw new Error("filesystem quarantine object entry is unknown");
|
|
1127
|
+
if (match[2] === "part" || !metadataIds.has(match[1]))
|
|
1128
|
+
candidates.add(match[1]);
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
for (const entry of await readdir(state.deletionsRoot, {
|
|
1132
|
+
withFileTypes: true,
|
|
1133
|
+
})) {
|
|
1134
|
+
assertRegularRecoveryEntry(entry, "deletion");
|
|
1135
|
+
if (DELETION_FILE.test(entry.name)) continue;
|
|
1136
|
+
const temporaryMatch = DELETION_TEMP_FILE.exec(entry.name);
|
|
1137
|
+
if (!temporaryMatch)
|
|
1138
|
+
throw new Error("filesystem quarantine deletion entry is unknown");
|
|
1139
|
+
addTemporary(temporaryMatch[1], path.join(state.deletionsRoot, entry.name));
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
for (const entry of await readdir(state.locksRoot, {
|
|
1143
|
+
withFileTypes: true,
|
|
1144
|
+
})) {
|
|
1145
|
+
if (!entry.isDirectory() || entry.isSymbolicLink())
|
|
1146
|
+
throw new Error("filesystem quarantine lock entry is unsafe");
|
|
1147
|
+
const match = LOCK_DIRECTORY.exec(entry.name);
|
|
1148
|
+
if (!match) throw new Error("filesystem quarantine lock entry is unknown");
|
|
1149
|
+
candidates.add(match[1]);
|
|
1150
|
+
}
|
|
1151
|
+
return Object.freeze({ candidates, temporaryFiles });
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
async function findArtifactTemporaryFiles(state, artifactId) {
|
|
1155
|
+
const files = [];
|
|
1156
|
+
for (const [directory, pattern] of [
|
|
1157
|
+
[state.metadataRoot, METADATA_TEMP_FILE],
|
|
1158
|
+
[state.deletionsRoot, DELETION_TEMP_FILE],
|
|
1159
|
+
]) {
|
|
1160
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
1161
|
+
const match = pattern.exec(entry.name);
|
|
1162
|
+
if (!match || match[1] !== artifactId) continue;
|
|
1163
|
+
assertRegularRecoveryEntry(entry, "temporary");
|
|
1164
|
+
files.push(path.join(directory, entry.name));
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
return files;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
async function removeRecoveryTemporaryFiles(files) {
|
|
1171
|
+
for (const file of files) {
|
|
1172
|
+
let info;
|
|
1173
|
+
try {
|
|
1174
|
+
info = await lstat(file);
|
|
1175
|
+
} catch (error) {
|
|
1176
|
+
if (error?.code === "ENOENT") continue;
|
|
1177
|
+
throw error;
|
|
1178
|
+
}
|
|
1179
|
+
if (!info.isFile() || info.isSymbolicLink())
|
|
1180
|
+
throw new Error("filesystem quarantine temporary file is unsafe");
|
|
1181
|
+
await unlinkDurably(file);
|
|
1182
|
+
if (await exists(file))
|
|
1183
|
+
throw new Error("filesystem quarantine temporary cleanup failed");
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
function normalizeRecoveryDeletionEvidence(value, state, kind) {
|
|
1188
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
1189
|
+
throw new Error(
|
|
1190
|
+
"filesystem quarantine recovery deletion evidence is invalid",
|
|
1191
|
+
);
|
|
1192
|
+
const descriptor = {
|
|
1193
|
+
authorityId: value.authorityId,
|
|
1194
|
+
tenantId: state.descriptor.tenantId,
|
|
1195
|
+
handlerArtifactDigest: state.descriptor.handlerArtifactDigest,
|
|
1196
|
+
};
|
|
1197
|
+
const input = {
|
|
1198
|
+
actionReceiptDigest: value.actionReceiptDigest,
|
|
1199
|
+
requestDigest: value.requestDigest,
|
|
1200
|
+
artifactRef: value.artifactRef,
|
|
1201
|
+
artifactDigest: value.artifactDigest,
|
|
1202
|
+
sourceActionReceiptDigest: value.sourceActionReceiptDigest,
|
|
1203
|
+
reason: value.reason,
|
|
1204
|
+
};
|
|
1205
|
+
return kind === "intent"
|
|
1206
|
+
? normalizeDeletionIntent(value, state, descriptor, input)
|
|
1207
|
+
: normalizeTombstone(value, state, descriptor, input);
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
function sameDeletionBinding(left, right) {
|
|
1211
|
+
return [
|
|
1212
|
+
"custodyId",
|
|
1213
|
+
"tenantId",
|
|
1214
|
+
"authorityId",
|
|
1215
|
+
"handlerArtifactDigest",
|
|
1216
|
+
"actionReceiptDigest",
|
|
1217
|
+
"requestDigest",
|
|
1218
|
+
"artifactRef",
|
|
1219
|
+
"artifactDigest",
|
|
1220
|
+
"sourceActionReceiptDigest",
|
|
1221
|
+
"reason",
|
|
1222
|
+
].every((key) => left[key] === right[key]);
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
async function reconcileArtifactDebris(state, artifactId, temporaryFiles = []) {
|
|
1226
|
+
const files = pathsFor(state.stateRoot, artifactId);
|
|
1227
|
+
const [intentValue, tombstoneValue] = await Promise.all([
|
|
1228
|
+
readJsonIfPresent(files.deletionIntent),
|
|
1229
|
+
readJsonIfPresent(files.tombstone),
|
|
1230
|
+
]);
|
|
1231
|
+
const intent =
|
|
1232
|
+
intentValue === null
|
|
1233
|
+
? null
|
|
1234
|
+
: normalizeRecoveryDeletionEvidence(intentValue, state, "intent");
|
|
1235
|
+
const tombstone =
|
|
1236
|
+
tombstoneValue === null
|
|
1237
|
+
? null
|
|
1238
|
+
: normalizeRecoveryDeletionEvidence(tombstoneValue, state, "tombstone");
|
|
1239
|
+
if (intent && tombstone && !sameDeletionBinding(intent, tombstone))
|
|
1240
|
+
throw new Error(
|
|
1241
|
+
"filesystem quarantine recovery deletion evidence conflicts",
|
|
1242
|
+
);
|
|
1243
|
+
|
|
1244
|
+
if (tombstone) {
|
|
1245
|
+
if (
|
|
1246
|
+
(await exists(files.part)) ||
|
|
1247
|
+
(await exists(files.blob)) ||
|
|
1248
|
+
(await exists(files.metadata))
|
|
1249
|
+
)
|
|
1250
|
+
throw new Error("filesystem quarantine tombstone conflicts with debris");
|
|
1251
|
+
await removeRecoveryTemporaryFiles(temporaryFiles);
|
|
1252
|
+
return "tombstoned";
|
|
1253
|
+
}
|
|
1254
|
+
if (intent) {
|
|
1255
|
+
await removeRecoveryTemporaryFiles(temporaryFiles);
|
|
1256
|
+
return "pending-deletion";
|
|
1257
|
+
}
|
|
1258
|
+
if (await exists(files.metadata)) {
|
|
1259
|
+
const persisted = await readMetadata(state, `quarantine:${artifactId}`);
|
|
1260
|
+
await verifyBlob(persisted.files, persisted.metadata);
|
|
1261
|
+
await unlinkIfPresent(files.part);
|
|
1262
|
+
if (await exists(files.part))
|
|
1263
|
+
throw new Error("filesystem quarantine partial cleanup failed");
|
|
1264
|
+
await removeRecoveryTemporaryFiles(temporaryFiles);
|
|
1265
|
+
return "verified-artifact";
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
await unlinkIfPresent(files.part);
|
|
1269
|
+
await unlinkIfPresent(files.blob);
|
|
1270
|
+
await removeRecoveryTemporaryFiles(temporaryFiles);
|
|
1271
|
+
if ((await exists(files.part)) || (await exists(files.blob)))
|
|
1272
|
+
throw new Error("filesystem quarantine orphan cleanup failed");
|
|
1273
|
+
return "removed-orphan";
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
async function recoverInterruptedArtifacts(state) {
|
|
1277
|
+
await prepare(state);
|
|
1278
|
+
const discovered = await discoverRecoveryState(state);
|
|
1279
|
+
let recoveredArtifacts = 0;
|
|
1280
|
+
let activeArtifacts = 0;
|
|
1281
|
+
for (const artifactId of [...discovered.candidates].sort()) {
|
|
1282
|
+
let releaseLock;
|
|
1283
|
+
try {
|
|
1284
|
+
releaseLock = await acquireArtifactLock(state, artifactId, "recover");
|
|
1285
|
+
} catch (error) {
|
|
1286
|
+
if (error?.code === "BROWSER_FILESYSTEM_QUARANTINE_ARTIFACT_LOCKED") {
|
|
1287
|
+
activeArtifacts += 1;
|
|
1288
|
+
continue;
|
|
1289
|
+
}
|
|
1290
|
+
throw error;
|
|
1291
|
+
}
|
|
1292
|
+
try {
|
|
1293
|
+
await reconcileArtifactDebris(state, artifactId, [
|
|
1294
|
+
...(discovered.temporaryFiles.get(artifactId) ?? []),
|
|
1295
|
+
]);
|
|
1296
|
+
recoveredArtifacts += 1;
|
|
1297
|
+
} finally {
|
|
1298
|
+
await releaseLock();
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
return Object.freeze({ recoveredArtifacts, activeArtifacts });
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
async function ensureRecovered(state) {
|
|
1305
|
+
await prepare(state);
|
|
1306
|
+
if (!state.recoveryPromise) {
|
|
1307
|
+
state.recoveryPromise = recoverInterruptedArtifacts(state).then(
|
|
1308
|
+
(report) => {
|
|
1309
|
+
if (report.activeArtifacts > 0) state.recoveryPromise = null;
|
|
1310
|
+
return report;
|
|
1311
|
+
},
|
|
1312
|
+
(error) => {
|
|
1313
|
+
state.recoveryPromise = null;
|
|
1314
|
+
throw error;
|
|
1315
|
+
},
|
|
1316
|
+
);
|
|
1317
|
+
}
|
|
1318
|
+
return state.recoveryPromise;
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
async function readMetadata(state, artifactRef) {
|
|
1322
|
+
const artifactId = artifactIdFromRef(artifactRef);
|
|
1323
|
+
const files = pathsFor(state.stateRoot, artifactId);
|
|
1324
|
+
const parsed = JSON.parse(await readFile(files.metadata, "utf8"));
|
|
1325
|
+
const metadata = normalizeMetadata(parsed, state.descriptor);
|
|
1326
|
+
if (metadata.artifactRef !== artifactRef)
|
|
1327
|
+
throw new Error("filesystem quarantine metadata reference mismatch");
|
|
1328
|
+
return Object.freeze({ files, metadata });
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
async function verifyBlob(files, metadata) {
|
|
1332
|
+
const info = await stat(files.blob);
|
|
1333
|
+
if (!info.isFile() || info.size !== metadata.sizeBytes)
|
|
1334
|
+
throw new Error("filesystem quarantine blob size differs from metadata");
|
|
1335
|
+
const observed = await hashFile(files.blob);
|
|
1336
|
+
if (
|
|
1337
|
+
observed.sizeBytes !== metadata.sizeBytes ||
|
|
1338
|
+
observed.artifactDigest !== metadata.artifactDigest
|
|
1339
|
+
)
|
|
1340
|
+
throw new Error("filesystem quarantine blob digest differs from metadata");
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
function normalizeOpenInput(value, descriptor) {
|
|
1344
|
+
exact(
|
|
1345
|
+
value,
|
|
1346
|
+
[
|
|
1347
|
+
"artifactId",
|
|
1348
|
+
"tenantId",
|
|
1349
|
+
"maxBytes",
|
|
1350
|
+
"contentType",
|
|
1351
|
+
"networkReceiptDigest",
|
|
1352
|
+
"actionReceiptDigest",
|
|
1353
|
+
],
|
|
1354
|
+
"filesystem quarantine open request",
|
|
1355
|
+
);
|
|
1356
|
+
if (
|
|
1357
|
+
!ARTIFACT_ID.test(value.artifactId) ||
|
|
1358
|
+
value.tenantId !== descriptor.tenantId ||
|
|
1359
|
+
!Number.isSafeInteger(value.maxBytes) ||
|
|
1360
|
+
value.maxBytes < 1 ||
|
|
1361
|
+
value.maxBytes > MAX_BYTES ||
|
|
1362
|
+
!CONTENT_TYPE.test(value.contentType) ||
|
|
1363
|
+
!DIGEST.test(value.networkReceiptDigest) ||
|
|
1364
|
+
!DIGEST.test(value.actionReceiptDigest)
|
|
1365
|
+
)
|
|
1366
|
+
throw new TypeError("filesystem quarantine open request is invalid");
|
|
1367
|
+
return Object.freeze({ ...value });
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
async function openQuarantine(state, value) {
|
|
1371
|
+
const input = normalizeOpenInput(value, state.descriptor);
|
|
1372
|
+
await ensureRecovered(state);
|
|
1373
|
+
const releaseLock = await acquireArtifactLock(
|
|
1374
|
+
state,
|
|
1375
|
+
input.artifactId,
|
|
1376
|
+
"write",
|
|
1377
|
+
);
|
|
1378
|
+
const files = pathsFor(state.stateRoot, input.artifactId);
|
|
1379
|
+
let handle;
|
|
1380
|
+
try {
|
|
1381
|
+
await reconcileArtifactDebris(
|
|
1382
|
+
state,
|
|
1383
|
+
input.artifactId,
|
|
1384
|
+
await findArtifactTemporaryFiles(state, input.artifactId),
|
|
1385
|
+
);
|
|
1386
|
+
if (state.active.has(input.artifactId))
|
|
1387
|
+
throw new Error("filesystem quarantine artifact is already active");
|
|
1388
|
+
if (
|
|
1389
|
+
(await exists(files.blob)) ||
|
|
1390
|
+
(await exists(files.metadata)) ||
|
|
1391
|
+
(await exists(files.deletionIntent)) ||
|
|
1392
|
+
(await exists(files.tombstone))
|
|
1393
|
+
)
|
|
1394
|
+
throw new Error("filesystem quarantine artifact already exists");
|
|
1395
|
+
handle = await open(files.part, "wx", 0o600);
|
|
1396
|
+
await syncDirectory(state.objectsRoot);
|
|
1397
|
+
} catch (error) {
|
|
1398
|
+
if (handle) {
|
|
1399
|
+
await handle.close().catch(() => {});
|
|
1400
|
+
handle = null;
|
|
1401
|
+
await unlinkIfPresent(files.part).catch(() => {});
|
|
1402
|
+
}
|
|
1403
|
+
await releaseLock().catch(() => {});
|
|
1404
|
+
throw error;
|
|
1405
|
+
}
|
|
1406
|
+
const session = {
|
|
1407
|
+
handle,
|
|
1408
|
+
state: "writing",
|
|
1409
|
+
sizeBytes: 0,
|
|
1410
|
+
};
|
|
1411
|
+
state.active.add(input.artifactId);
|
|
1412
|
+
const cleanup = async () => {
|
|
1413
|
+
if (session.handle) {
|
|
1414
|
+
await session.handle.close().catch(() => {});
|
|
1415
|
+
session.handle = null;
|
|
1416
|
+
}
|
|
1417
|
+
const failures = [];
|
|
1418
|
+
for (const file of [files.part, files.blob, files.metadata]) {
|
|
1419
|
+
try {
|
|
1420
|
+
await unlinkIfPresent(file);
|
|
1421
|
+
if (await exists(file))
|
|
1422
|
+
throw new Error("filesystem quarantine cleanup readback failed");
|
|
1423
|
+
} catch (error) {
|
|
1424
|
+
failures.push(error);
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
state.active.delete(input.artifactId);
|
|
1428
|
+
try {
|
|
1429
|
+
await releaseLock();
|
|
1430
|
+
} catch (error) {
|
|
1431
|
+
failures.push(error);
|
|
1432
|
+
}
|
|
1433
|
+
session.state = failures.length === 0 ? "discarded" : "uncertain";
|
|
1434
|
+
if (failures.length > 0)
|
|
1435
|
+
throw new Error("filesystem quarantine cleanup failed", {
|
|
1436
|
+
cause: failures[0],
|
|
1437
|
+
});
|
|
1438
|
+
};
|
|
1439
|
+
return Object.freeze({
|
|
1440
|
+
writeChunk: async (chunk) => {
|
|
1441
|
+
if (
|
|
1442
|
+
session.state !== "writing" ||
|
|
1443
|
+
!types.isUint8Array(chunk) ||
|
|
1444
|
+
types.isProxy(chunk) ||
|
|
1445
|
+
chunk.byteLength < 1 ||
|
|
1446
|
+
chunk.byteLength > input.maxBytes - session.sizeBytes
|
|
1447
|
+
)
|
|
1448
|
+
throw new Error("filesystem quarantine chunk is invalid or oversized");
|
|
1449
|
+
const buffer = Buffer.from(chunk);
|
|
1450
|
+
let offset = 0;
|
|
1451
|
+
while (offset < buffer.byteLength) {
|
|
1452
|
+
const { bytesWritten } = await session.handle.write(
|
|
1453
|
+
buffer,
|
|
1454
|
+
offset,
|
|
1455
|
+
buffer.byteLength - offset,
|
|
1456
|
+
null,
|
|
1457
|
+
);
|
|
1458
|
+
if (!Number.isSafeInteger(bytesWritten) || bytesWritten < 1)
|
|
1459
|
+
throw new Error("filesystem quarantine write made no progress");
|
|
1460
|
+
offset += bytesWritten;
|
|
1461
|
+
}
|
|
1462
|
+
session.sizeBytes += buffer.byteLength;
|
|
1463
|
+
},
|
|
1464
|
+
commitArtifact: async (streamed) => {
|
|
1465
|
+
exact(
|
|
1466
|
+
streamed,
|
|
1467
|
+
["artifactDigest", "sizeBytes", "contentType"],
|
|
1468
|
+
"filesystem quarantine streamed artifact",
|
|
1469
|
+
);
|
|
1470
|
+
if (
|
|
1471
|
+
session.state !== "writing" ||
|
|
1472
|
+
!DIGEST.test(streamed.artifactDigest) ||
|
|
1473
|
+
streamed.sizeBytes !== session.sizeBytes ||
|
|
1474
|
+
streamed.sizeBytes < 1 ||
|
|
1475
|
+
streamed.sizeBytes > input.maxBytes ||
|
|
1476
|
+
streamed.contentType !== input.contentType
|
|
1477
|
+
)
|
|
1478
|
+
throw new Error("filesystem quarantine streamed artifact is invalid");
|
|
1479
|
+
session.state = "committing";
|
|
1480
|
+
try {
|
|
1481
|
+
await session.handle.sync();
|
|
1482
|
+
await session.handle.close();
|
|
1483
|
+
session.handle = null;
|
|
1484
|
+
await renameDurably(files.part, files.blob);
|
|
1485
|
+
const observed = await hashFile(files.blob);
|
|
1486
|
+
if (
|
|
1487
|
+
observed.sizeBytes !== streamed.sizeBytes ||
|
|
1488
|
+
observed.artifactDigest !== streamed.artifactDigest
|
|
1489
|
+
)
|
|
1490
|
+
throw new Error("filesystem quarantine commit readback failed");
|
|
1491
|
+
const committedAtMs = state.now();
|
|
1492
|
+
const expiresAtMs = committedAtMs + state.descriptor.retentionMs;
|
|
1493
|
+
if (
|
|
1494
|
+
!Number.isFinite(committedAtMs) ||
|
|
1495
|
+
!Number.isSafeInteger(expiresAtMs)
|
|
1496
|
+
)
|
|
1497
|
+
throw new Error("filesystem quarantine clock is invalid");
|
|
1498
|
+
const committedAt = new Date(committedAtMs).toISOString();
|
|
1499
|
+
const expiresAt = new Date(expiresAtMs).toISOString();
|
|
1500
|
+
const artifactRef = `quarantine:${input.artifactId}`;
|
|
1501
|
+
const receiptCore = Object.freeze({
|
|
1502
|
+
custodyId: state.descriptor.custodyId,
|
|
1503
|
+
tenantId: state.descriptor.tenantId,
|
|
1504
|
+
handlerArtifactDigest: state.descriptor.handlerArtifactDigest,
|
|
1505
|
+
artifactRef,
|
|
1506
|
+
artifactDigest: streamed.artifactDigest,
|
|
1507
|
+
sizeBytes: streamed.sizeBytes,
|
|
1508
|
+
contentType: streamed.contentType,
|
|
1509
|
+
networkReceiptDigest: input.networkReceiptDigest,
|
|
1510
|
+
sourceActionReceiptDigest: input.actionReceiptDigest,
|
|
1511
|
+
committedAt,
|
|
1512
|
+
expiresAt,
|
|
1513
|
+
});
|
|
1514
|
+
const quarantineReceiptDigest = digest(
|
|
1515
|
+
"chainlesschain.browser-filesystem-quarantine-commit/v1",
|
|
1516
|
+
receiptCore,
|
|
1517
|
+
);
|
|
1518
|
+
const metadata = Object.freeze({
|
|
1519
|
+
schema: BROWSER_FILESYSTEM_QUARANTINE_METADATA_SCHEMA,
|
|
1520
|
+
custodyId: state.descriptor.custodyId,
|
|
1521
|
+
tenantId: state.descriptor.tenantId,
|
|
1522
|
+
artifactRef,
|
|
1523
|
+
artifactDigest: streamed.artifactDigest,
|
|
1524
|
+
sizeBytes: streamed.sizeBytes,
|
|
1525
|
+
contentType: streamed.contentType,
|
|
1526
|
+
networkReceiptDigest: input.networkReceiptDigest,
|
|
1527
|
+
sourceActionReceiptDigest: input.actionReceiptDigest,
|
|
1528
|
+
status: "quarantined",
|
|
1529
|
+
committedAt,
|
|
1530
|
+
expiresAt,
|
|
1531
|
+
quarantineReceiptDigest,
|
|
1532
|
+
scanEvidenceDigest: null,
|
|
1533
|
+
completedAt: null,
|
|
1534
|
+
completionReceiptDigest: null,
|
|
1535
|
+
});
|
|
1536
|
+
await writeMetadata(files.metadata, metadata);
|
|
1537
|
+
const persisted = await readMetadata(state, artifactRef);
|
|
1538
|
+
await verifyBlob(files, persisted.metadata);
|
|
1539
|
+
session.state = "committed";
|
|
1540
|
+
state.active.delete(input.artifactId);
|
|
1541
|
+
await releaseLock();
|
|
1542
|
+
return Object.freeze({
|
|
1543
|
+
schema: QUARANTINE_COMMIT_ACK_SCHEMA,
|
|
1544
|
+
artifactRef,
|
|
1545
|
+
artifactDigest: streamed.artifactDigest,
|
|
1546
|
+
sizeBytes: streamed.sizeBytes,
|
|
1547
|
+
contentType: streamed.contentType,
|
|
1548
|
+
quarantineReceiptDigest,
|
|
1549
|
+
authenticated: true,
|
|
1550
|
+
durable: true,
|
|
1551
|
+
readbackVerified: true,
|
|
1552
|
+
});
|
|
1553
|
+
} catch (error) {
|
|
1554
|
+
await cleanup().catch((cleanupError) => {
|
|
1555
|
+
throw new Error("filesystem quarantine commit cleanup failed", {
|
|
1556
|
+
cause: cleanupError,
|
|
1557
|
+
});
|
|
1558
|
+
});
|
|
1559
|
+
throw error;
|
|
1560
|
+
}
|
|
1561
|
+
},
|
|
1562
|
+
discardArtifact: async () => {
|
|
1563
|
+
await cleanup();
|
|
1564
|
+
},
|
|
1565
|
+
});
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
async function openArtifactForScan(state, value) {
|
|
1569
|
+
exact(
|
|
1570
|
+
value,
|
|
1571
|
+
[
|
|
1572
|
+
"artifactRef",
|
|
1573
|
+
"artifactDigest",
|
|
1574
|
+
"sizeBytes",
|
|
1575
|
+
"contentType",
|
|
1576
|
+
"quarantineReceiptDigest",
|
|
1577
|
+
],
|
|
1578
|
+
"filesystem quarantine scan request",
|
|
1579
|
+
);
|
|
1580
|
+
await ensureRecovered(state);
|
|
1581
|
+
const persisted = await readMetadata(state, value.artifactRef);
|
|
1582
|
+
const metadata = persisted.metadata;
|
|
1583
|
+
if (
|
|
1584
|
+
metadata.status !== "quarantined" ||
|
|
1585
|
+
metadata.artifactDigest !== value.artifactDigest ||
|
|
1586
|
+
metadata.sizeBytes !== value.sizeBytes ||
|
|
1587
|
+
metadata.contentType !== value.contentType ||
|
|
1588
|
+
metadata.quarantineReceiptDigest !== value.quarantineReceiptDigest
|
|
1589
|
+
)
|
|
1590
|
+
throw new Error("filesystem quarantine scan request differs from custody");
|
|
1591
|
+
await verifyBlob(persisted.files, metadata);
|
|
1592
|
+
const artifactId = artifactIdFromRef(value.artifactRef);
|
|
1593
|
+
return Object.freeze({
|
|
1594
|
+
artifactRef: metadata.artifactRef,
|
|
1595
|
+
artifactDigest: metadata.artifactDigest,
|
|
1596
|
+
sizeBytes: metadata.sizeBytes,
|
|
1597
|
+
contentType: metadata.contentType,
|
|
1598
|
+
body: pathlessBody(state, artifactId, persisted.files.blob, metadata),
|
|
1599
|
+
});
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
async function completeArtifact(state, value) {
|
|
1603
|
+
exact(
|
|
1604
|
+
value,
|
|
1605
|
+
[
|
|
1606
|
+
"artifactRef",
|
|
1607
|
+
"artifactDigest",
|
|
1608
|
+
"sizeBytes",
|
|
1609
|
+
"contentType",
|
|
1610
|
+
"networkReceiptDigest",
|
|
1611
|
+
"quarantineReceiptDigest",
|
|
1612
|
+
"scanEvidenceDigest",
|
|
1613
|
+
"completedAt",
|
|
1614
|
+
],
|
|
1615
|
+
"filesystem quarantine completion request",
|
|
1616
|
+
);
|
|
1617
|
+
if (
|
|
1618
|
+
!ARTIFACT_REF.test(value.artifactRef) ||
|
|
1619
|
+
!DIGEST.test(value.artifactDigest) ||
|
|
1620
|
+
!Number.isSafeInteger(value.sizeBytes) ||
|
|
1621
|
+
value.sizeBytes < 1 ||
|
|
1622
|
+
!CONTENT_TYPE.test(value.contentType) ||
|
|
1623
|
+
!DIGEST.test(value.networkReceiptDigest) ||
|
|
1624
|
+
!DIGEST.test(value.quarantineReceiptDigest) ||
|
|
1625
|
+
!DIGEST.test(value.scanEvidenceDigest) ||
|
|
1626
|
+
!Number.isFinite(Date.parse(value.completedAt))
|
|
1627
|
+
)
|
|
1628
|
+
throw new TypeError("filesystem quarantine completion request is invalid");
|
|
1629
|
+
await ensureRecovered(state);
|
|
1630
|
+
const artifactId = artifactIdFromRef(value.artifactRef);
|
|
1631
|
+
if (state.disposals.has(artifactId))
|
|
1632
|
+
throw new Error("filesystem quarantine artifact disposal is active");
|
|
1633
|
+
if (state.completions.has(artifactId))
|
|
1634
|
+
throw new Error("filesystem quarantine artifact completion is active");
|
|
1635
|
+
state.completions.add(artifactId);
|
|
1636
|
+
let releaseLock = null;
|
|
1637
|
+
try {
|
|
1638
|
+
releaseLock = await acquireArtifactLock(state, artifactId, "complete");
|
|
1639
|
+
const persisted = await readMetadata(state, value.artifactRef);
|
|
1640
|
+
const metadata = persisted.metadata;
|
|
1641
|
+
if (
|
|
1642
|
+
metadata.status !== "quarantined" ||
|
|
1643
|
+
metadata.artifactDigest !== value.artifactDigest ||
|
|
1644
|
+
metadata.sizeBytes !== value.sizeBytes ||
|
|
1645
|
+
metadata.contentType !== value.contentType ||
|
|
1646
|
+
metadata.networkReceiptDigest !== value.networkReceiptDigest ||
|
|
1647
|
+
metadata.quarantineReceiptDigest !== value.quarantineReceiptDigest ||
|
|
1648
|
+
Date.parse(value.completedAt) < Date.parse(metadata.committedAt) ||
|
|
1649
|
+
Date.parse(value.completedAt) >= Date.parse(metadata.expiresAt)
|
|
1650
|
+
)
|
|
1651
|
+
throw new Error("filesystem quarantine completion differs from custody");
|
|
1652
|
+
await verifyBlob(persisted.files, metadata);
|
|
1653
|
+
const completionCore = Object.freeze({
|
|
1654
|
+
custodyId: state.descriptor.custodyId,
|
|
1655
|
+
tenantId: state.descriptor.tenantId,
|
|
1656
|
+
handlerArtifactDigest: state.descriptor.handlerArtifactDigest,
|
|
1657
|
+
artifactRef: value.artifactRef,
|
|
1658
|
+
artifactDigest: value.artifactDigest,
|
|
1659
|
+
scanEvidenceDigest: value.scanEvidenceDigest,
|
|
1660
|
+
completedAt: value.completedAt,
|
|
1661
|
+
});
|
|
1662
|
+
const completionReceiptDigest = digest(
|
|
1663
|
+
"chainlesschain.browser-filesystem-quarantine-completion/v1",
|
|
1664
|
+
completionCore,
|
|
1665
|
+
);
|
|
1666
|
+
const completed = Object.freeze({
|
|
1667
|
+
...metadata,
|
|
1668
|
+
status: "ready",
|
|
1669
|
+
scanEvidenceDigest: value.scanEvidenceDigest,
|
|
1670
|
+
completedAt: value.completedAt,
|
|
1671
|
+
completionReceiptDigest,
|
|
1672
|
+
});
|
|
1673
|
+
await writeMetadata(persisted.files.metadata, completed);
|
|
1674
|
+
const readback = await readMetadata(state, value.artifactRef);
|
|
1675
|
+
if (
|
|
1676
|
+
readback.metadata.status !== "ready" ||
|
|
1677
|
+
readback.metadata.completionReceiptDigest !== completionReceiptDigest
|
|
1678
|
+
)
|
|
1679
|
+
throw new Error("filesystem quarantine completion readback failed");
|
|
1680
|
+
await verifyBlob(readback.files, readback.metadata);
|
|
1681
|
+
return Object.freeze({
|
|
1682
|
+
schema: COMPLETION_ACK_SCHEMA,
|
|
1683
|
+
artifactRef: value.artifactRef,
|
|
1684
|
+
artifactDigest: value.artifactDigest,
|
|
1685
|
+
scanEvidenceDigest: value.scanEvidenceDigest,
|
|
1686
|
+
completionReceiptDigest,
|
|
1687
|
+
authenticated: true,
|
|
1688
|
+
durable: true,
|
|
1689
|
+
readbackVerified: true,
|
|
1690
|
+
});
|
|
1691
|
+
} finally {
|
|
1692
|
+
state.completions.delete(artifactId);
|
|
1693
|
+
if (releaseLock) await releaseLock();
|
|
1694
|
+
}
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
async function inspectArtifact(state, artifactRef) {
|
|
1698
|
+
await ensureRecovered(state);
|
|
1699
|
+
const persisted = await readMetadata(state, artifactRef);
|
|
1700
|
+
await verifyBlob(persisted.files, persisted.metadata);
|
|
1701
|
+
const { metadata } = persisted;
|
|
1702
|
+
return Object.freeze({
|
|
1703
|
+
artifactRefDigest: digest(
|
|
1704
|
+
"chainlesschain.browser-download-artifact-ref/v1",
|
|
1705
|
+
metadata.artifactRef,
|
|
1706
|
+
),
|
|
1707
|
+
artifactDigest: metadata.artifactDigest,
|
|
1708
|
+
sizeBytes: metadata.sizeBytes,
|
|
1709
|
+
contentType: metadata.contentType,
|
|
1710
|
+
sourceActionReceiptDigest: metadata.sourceActionReceiptDigest,
|
|
1711
|
+
status: metadata.status,
|
|
1712
|
+
committedAt: metadata.committedAt,
|
|
1713
|
+
expiresAt: metadata.expiresAt,
|
|
1714
|
+
quarantineReceiptDigest: metadata.quarantineReceiptDigest,
|
|
1715
|
+
scanEvidenceDigest: metadata.scanEvidenceDigest,
|
|
1716
|
+
completedAt: metadata.completedAt,
|
|
1717
|
+
completionReceiptDigest: metadata.completionReceiptDigest,
|
|
1718
|
+
});
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
function normalizeDisposalDescriptor(value, custodyDescriptor) {
|
|
1722
|
+
exact(
|
|
1723
|
+
value,
|
|
1724
|
+
[
|
|
1725
|
+
"schema",
|
|
1726
|
+
"authorityId",
|
|
1727
|
+
"tenantId",
|
|
1728
|
+
"handlerArtifactDigest",
|
|
1729
|
+
"policyRevision",
|
|
1730
|
+
"maxGrantTtlMs",
|
|
1731
|
+
"approvalMode",
|
|
1732
|
+
"auditMode",
|
|
1733
|
+
"effectMode",
|
|
1734
|
+
],
|
|
1735
|
+
"filesystem quarantine disposal descriptor",
|
|
1736
|
+
);
|
|
1737
|
+
if (
|
|
1738
|
+
value.schema !== DISPOSAL_DESCRIPTOR_SCHEMA ||
|
|
1739
|
+
!ID.test(value.authorityId) ||
|
|
1740
|
+
value.tenantId !== custodyDescriptor.tenantId ||
|
|
1741
|
+
value.handlerArtifactDigest !== custodyDescriptor.handlerArtifactDigest ||
|
|
1742
|
+
!ID.test(value.policyRevision) ||
|
|
1743
|
+
!Number.isSafeInteger(value.maxGrantTtlMs) ||
|
|
1744
|
+
value.maxGrantTtlMs < 1 ||
|
|
1745
|
+
value.maxGrantTtlMs > 30_000 ||
|
|
1746
|
+
value.approvalMode !== "interactive" ||
|
|
1747
|
+
value.auditMode !== "authenticated-durable-readback" ||
|
|
1748
|
+
value.effectMode !== "irreversible-byte-disposal"
|
|
1749
|
+
)
|
|
1750
|
+
throw new TypeError("filesystem quarantine disposal descriptor is invalid");
|
|
1751
|
+
return Object.freeze({ ...value });
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
function normalizeRetentionDescriptor(value, custodyDescriptor) {
|
|
1755
|
+
exact(
|
|
1756
|
+
value,
|
|
1757
|
+
[
|
|
1758
|
+
"schema",
|
|
1759
|
+
"authorityId",
|
|
1760
|
+
"tenantId",
|
|
1761
|
+
"handlerArtifactDigest",
|
|
1762
|
+
"policyRevision",
|
|
1763
|
+
"maxBatchSize",
|
|
1764
|
+
"maxGrantTtlMs",
|
|
1765
|
+
"auditMode",
|
|
1766
|
+
"effectMode",
|
|
1767
|
+
],
|
|
1768
|
+
"filesystem quarantine retention descriptor",
|
|
1769
|
+
);
|
|
1770
|
+
if (
|
|
1771
|
+
value.schema !==
|
|
1772
|
+
BROWSER_FILESYSTEM_QUARANTINE_RETENTION_DESCRIPTOR_SCHEMA ||
|
|
1773
|
+
!ID.test(value.authorityId) ||
|
|
1774
|
+
value.tenantId !== custodyDescriptor.tenantId ||
|
|
1775
|
+
value.handlerArtifactDigest !== custodyDescriptor.handlerArtifactDigest ||
|
|
1776
|
+
!ID.test(value.policyRevision) ||
|
|
1777
|
+
!Number.isSafeInteger(value.maxBatchSize) ||
|
|
1778
|
+
value.maxBatchSize < 1 ||
|
|
1779
|
+
value.maxBatchSize > 256 ||
|
|
1780
|
+
!Number.isSafeInteger(value.maxGrantTtlMs) ||
|
|
1781
|
+
value.maxGrantTtlMs < 1 ||
|
|
1782
|
+
value.maxGrantTtlMs > 30_000 ||
|
|
1783
|
+
value.auditMode !== "authenticated-durable-readback" ||
|
|
1784
|
+
value.effectMode !== "irreversible-expiry-disposal"
|
|
1785
|
+
)
|
|
1786
|
+
throw new TypeError(
|
|
1787
|
+
"filesystem quarantine retention descriptor is invalid",
|
|
1788
|
+
);
|
|
1789
|
+
return Object.freeze({ ...value });
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
function normalizeOperatorRevocationDescriptor(value, custodyDescriptor) {
|
|
1793
|
+
exact(
|
|
1794
|
+
value,
|
|
1795
|
+
[
|
|
1796
|
+
"schema",
|
|
1797
|
+
"authorityId",
|
|
1798
|
+
"tenantId",
|
|
1799
|
+
"handlerArtifactDigest",
|
|
1800
|
+
"policyRevision",
|
|
1801
|
+
"maxGrantTtlMs",
|
|
1802
|
+
"approvalMode",
|
|
1803
|
+
"auditMode",
|
|
1804
|
+
"effectMode",
|
|
1805
|
+
],
|
|
1806
|
+
"filesystem quarantine operator revocation descriptor",
|
|
1807
|
+
);
|
|
1808
|
+
if (
|
|
1809
|
+
value.schema !==
|
|
1810
|
+
BROWSER_FILESYSTEM_QUARANTINE_OPERATOR_REVOCATION_DESCRIPTOR_SCHEMA ||
|
|
1811
|
+
!ID.test(value.authorityId) ||
|
|
1812
|
+
value.tenantId !== custodyDescriptor.tenantId ||
|
|
1813
|
+
value.handlerArtifactDigest !== custodyDescriptor.handlerArtifactDigest ||
|
|
1814
|
+
!ID.test(value.policyRevision) ||
|
|
1815
|
+
!Number.isSafeInteger(value.maxGrantTtlMs) ||
|
|
1816
|
+
value.maxGrantTtlMs < 1 ||
|
|
1817
|
+
value.maxGrantTtlMs > 30_000 ||
|
|
1818
|
+
value.approvalMode !== "operator-signed" ||
|
|
1819
|
+
value.auditMode !== "authenticated-durable-readback" ||
|
|
1820
|
+
value.effectMode !== "irreversible-byte-revocation"
|
|
1821
|
+
)
|
|
1822
|
+
throw new TypeError(
|
|
1823
|
+
"filesystem quarantine operator revocation descriptor is invalid",
|
|
1824
|
+
);
|
|
1825
|
+
return Object.freeze({ ...value });
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
function normalizeLockMaintenanceDescriptor(value, custodyDescriptor) {
|
|
1829
|
+
exact(
|
|
1830
|
+
value,
|
|
1831
|
+
[
|
|
1832
|
+
"schema",
|
|
1833
|
+
"authorityId",
|
|
1834
|
+
"tenantId",
|
|
1835
|
+
"handlerArtifactDigest",
|
|
1836
|
+
"policyRevision",
|
|
1837
|
+
"maxGrantTtlMs",
|
|
1838
|
+
"approvalMode",
|
|
1839
|
+
"auditMode",
|
|
1840
|
+
"effectMode",
|
|
1841
|
+
],
|
|
1842
|
+
"filesystem quarantine lock maintenance descriptor",
|
|
1843
|
+
);
|
|
1844
|
+
if (
|
|
1845
|
+
value.schema !==
|
|
1846
|
+
BROWSER_FILESYSTEM_QUARANTINE_LOCK_MAINTENANCE_DESCRIPTOR_SCHEMA ||
|
|
1847
|
+
!ID.test(value.authorityId) ||
|
|
1848
|
+
value.tenantId !== custodyDescriptor.tenantId ||
|
|
1849
|
+
value.handlerArtifactDigest !== custodyDescriptor.handlerArtifactDigest ||
|
|
1850
|
+
!ID.test(value.policyRevision) ||
|
|
1851
|
+
!Number.isSafeInteger(value.maxGrantTtlMs) ||
|
|
1852
|
+
value.maxGrantTtlMs < 1 ||
|
|
1853
|
+
value.maxGrantTtlMs > 30_000 ||
|
|
1854
|
+
value.approvalMode !== "operator-signed" ||
|
|
1855
|
+
value.auditMode !== "authenticated-durable-readback" ||
|
|
1856
|
+
value.effectMode !== "orphan-lock-release"
|
|
1857
|
+
)
|
|
1858
|
+
throw new TypeError(
|
|
1859
|
+
"filesystem quarantine lock maintenance descriptor is invalid",
|
|
1860
|
+
);
|
|
1861
|
+
return Object.freeze({ ...value });
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
function normalizeExpiryPlanInput(value, retentionDescriptor, nowMs) {
|
|
1865
|
+
exact(
|
|
1866
|
+
value,
|
|
1867
|
+
["sweepId", "cutoffAt", "maxArtifacts"],
|
|
1868
|
+
"filesystem quarantine expiry plan request",
|
|
1869
|
+
);
|
|
1870
|
+
const cutoffMs = Date.parse(value.cutoffAt);
|
|
1871
|
+
if (
|
|
1872
|
+
!ID.test(value.sweepId) ||
|
|
1873
|
+
!Number.isFinite(cutoffMs) ||
|
|
1874
|
+
new Date(cutoffMs).toISOString() !== value.cutoffAt ||
|
|
1875
|
+
cutoffMs > nowMs ||
|
|
1876
|
+
!Number.isSafeInteger(value.maxArtifacts) ||
|
|
1877
|
+
value.maxArtifacts < 1 ||
|
|
1878
|
+
value.maxArtifacts > retentionDescriptor.maxBatchSize
|
|
1879
|
+
)
|
|
1880
|
+
throw new TypeError("filesystem quarantine expiry plan request is invalid");
|
|
1881
|
+
return Object.freeze({ ...value, cutoffMs });
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
async function planExpiredArtifacts(state, retentionDescriptor, value) {
|
|
1885
|
+
await ensureRecovered(state);
|
|
1886
|
+
const nowMs = state.now();
|
|
1887
|
+
if (!Number.isFinite(nowMs))
|
|
1888
|
+
throw new Error("filesystem quarantine clock is invalid");
|
|
1889
|
+
const input = normalizeExpiryPlanInput(value, retentionDescriptor, nowMs);
|
|
1890
|
+
const artifacts = [];
|
|
1891
|
+
const entries = (await readdir(state.metadataRoot, { withFileTypes: true }))
|
|
1892
|
+
.filter((entry) => METADATA_FILE.test(entry.name))
|
|
1893
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
1894
|
+
for (const entry of entries) {
|
|
1895
|
+
if (!entry.isFile())
|
|
1896
|
+
throw new Error("filesystem quarantine metadata entry is unsafe");
|
|
1897
|
+
const artifactId = METADATA_FILE.exec(entry.name)?.[1];
|
|
1898
|
+
if (!artifactId)
|
|
1899
|
+
throw new Error("filesystem quarantine metadata entry is invalid");
|
|
1900
|
+
const artifactRef = `quarantine:${artifactId}`;
|
|
1901
|
+
const persisted = await readMetadata(state, artifactRef);
|
|
1902
|
+
const expiresAtMs = Date.parse(persisted.metadata.expiresAt);
|
|
1903
|
+
if (expiresAtMs > input.cutoffMs || expiresAtMs > nowMs) continue;
|
|
1904
|
+
await verifyBlob(persisted.files, persisted.metadata);
|
|
1905
|
+
artifacts.push(
|
|
1906
|
+
Object.freeze({
|
|
1907
|
+
artifactRef,
|
|
1908
|
+
artifactRefDigest: digest(
|
|
1909
|
+
"chainlesschain.browser-download-artifact-ref/v1",
|
|
1910
|
+
artifactRef,
|
|
1911
|
+
),
|
|
1912
|
+
artifactDigest: persisted.metadata.artifactDigest,
|
|
1913
|
+
sourceActionReceiptDigest: persisted.metadata.sourceActionReceiptDigest,
|
|
1914
|
+
expiresAt: persisted.metadata.expiresAt,
|
|
1915
|
+
}),
|
|
1916
|
+
);
|
|
1917
|
+
if (artifacts.length === input.maxArtifacts) break;
|
|
1918
|
+
}
|
|
1919
|
+
const core = Object.freeze({
|
|
1920
|
+
schema: BROWSER_FILESYSTEM_QUARANTINE_EXPIRY_PLAN_SCHEMA,
|
|
1921
|
+
custodyId: state.descriptor.custodyId,
|
|
1922
|
+
authorityId: retentionDescriptor.authorityId,
|
|
1923
|
+
tenantId: retentionDescriptor.tenantId,
|
|
1924
|
+
handlerArtifactDigest: retentionDescriptor.handlerArtifactDigest,
|
|
1925
|
+
policyRevision: retentionDescriptor.policyRevision,
|
|
1926
|
+
sweepId: input.sweepId,
|
|
1927
|
+
cutoffAt: value.cutoffAt,
|
|
1928
|
+
plannedAt: new Date(nowMs).toISOString(),
|
|
1929
|
+
artifacts: Object.freeze(artifacts),
|
|
1930
|
+
});
|
|
1931
|
+
return Object.freeze({
|
|
1932
|
+
...core,
|
|
1933
|
+
planDigest: digest(BROWSER_FILESYSTEM_QUARANTINE_EXPIRY_PLAN_SCHEMA, core),
|
|
1934
|
+
});
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
function normalizeExpiredDisposalInput(value) {
|
|
1938
|
+
exact(
|
|
1939
|
+
value,
|
|
1940
|
+
[
|
|
1941
|
+
"sweepReceiptDigest",
|
|
1942
|
+
"planDigest",
|
|
1943
|
+
"sweepId",
|
|
1944
|
+
"cutoffAt",
|
|
1945
|
+
"artifactRef",
|
|
1946
|
+
"artifactRefDigest",
|
|
1947
|
+
"artifactDigest",
|
|
1948
|
+
"sourceActionReceiptDigest",
|
|
1949
|
+
"expiresAt",
|
|
1950
|
+
],
|
|
1951
|
+
"filesystem quarantine expired artifact disposal request",
|
|
1952
|
+
);
|
|
1953
|
+
const cutoffMs = Date.parse(value.cutoffAt);
|
|
1954
|
+
const expiresAtMs = Date.parse(value.expiresAt);
|
|
1955
|
+
if (
|
|
1956
|
+
!DIGEST.test(value.sweepReceiptDigest) ||
|
|
1957
|
+
!DIGEST.test(value.planDigest) ||
|
|
1958
|
+
!ID.test(value.sweepId) ||
|
|
1959
|
+
!Number.isFinite(cutoffMs) ||
|
|
1960
|
+
new Date(cutoffMs).toISOString() !== value.cutoffAt ||
|
|
1961
|
+
!ARTIFACT_REF.test(value.artifactRef) ||
|
|
1962
|
+
value.artifactRefDigest !==
|
|
1963
|
+
digest(
|
|
1964
|
+
"chainlesschain.browser-download-artifact-ref/v1",
|
|
1965
|
+
value.artifactRef,
|
|
1966
|
+
) ||
|
|
1967
|
+
!DIGEST.test(value.artifactDigest) ||
|
|
1968
|
+
!DIGEST.test(value.sourceActionReceiptDigest) ||
|
|
1969
|
+
!Number.isFinite(expiresAtMs) ||
|
|
1970
|
+
new Date(expiresAtMs).toISOString() !== value.expiresAt ||
|
|
1971
|
+
expiresAtMs > cutoffMs
|
|
1972
|
+
)
|
|
1973
|
+
throw new TypeError(
|
|
1974
|
+
"filesystem quarantine expired artifact disposal request is invalid",
|
|
1975
|
+
);
|
|
1976
|
+
return Object.freeze({ ...value, cutoffMs, expiresAtMs });
|
|
1977
|
+
}
|
|
1978
|
+
|
|
1979
|
+
async function disposeExpiredArtifact(state, retentionDescriptor, input) {
|
|
1980
|
+
await ensureRecovered(state);
|
|
1981
|
+
const nowMs = state.now();
|
|
1982
|
+
if (!Number.isFinite(nowMs) || input.cutoffMs > nowMs)
|
|
1983
|
+
throw new Error("filesystem quarantine expiry cutoff is not current");
|
|
1984
|
+
try {
|
|
1985
|
+
const persisted = await readMetadata(state, input.artifactRef);
|
|
1986
|
+
if (
|
|
1987
|
+
persisted.metadata.artifactDigest !== input.artifactDigest ||
|
|
1988
|
+
persisted.metadata.sourceActionReceiptDigest !==
|
|
1989
|
+
input.sourceActionReceiptDigest ||
|
|
1990
|
+
persisted.metadata.expiresAt !== input.expiresAt ||
|
|
1991
|
+
input.expiresAtMs > nowMs
|
|
1992
|
+
)
|
|
1993
|
+
throw new Error("filesystem quarantine expiry differs from custody");
|
|
1994
|
+
} catch (error) {
|
|
1995
|
+
if (error?.code !== "ENOENT") throw error;
|
|
1996
|
+
}
|
|
1997
|
+
const requestCore = Object.freeze({
|
|
1998
|
+
authorityId: retentionDescriptor.authorityId,
|
|
1999
|
+
tenantId: retentionDescriptor.tenantId,
|
|
2000
|
+
handlerArtifactDigest: retentionDescriptor.handlerArtifactDigest,
|
|
2001
|
+
policyRevision: retentionDescriptor.policyRevision,
|
|
2002
|
+
sweepReceiptDigest: input.sweepReceiptDigest,
|
|
2003
|
+
planDigest: input.planDigest,
|
|
2004
|
+
sweepId: input.sweepId,
|
|
2005
|
+
cutoffAt: input.cutoffAt,
|
|
2006
|
+
artifactRef: input.artifactRef,
|
|
2007
|
+
artifactDigest: input.artifactDigest,
|
|
2008
|
+
sourceActionReceiptDigest: input.sourceActionReceiptDigest,
|
|
2009
|
+
expiresAt: input.expiresAt,
|
|
2010
|
+
});
|
|
2011
|
+
return disposeArtifact(
|
|
2012
|
+
state,
|
|
2013
|
+
retentionDescriptor,
|
|
2014
|
+
Object.freeze({
|
|
2015
|
+
actionReceiptDigest: input.sweepReceiptDigest,
|
|
2016
|
+
requestDigest: digest(
|
|
2017
|
+
"chainlesschain.browser-filesystem-quarantine-expired-disposal/v1",
|
|
2018
|
+
requestCore,
|
|
2019
|
+
),
|
|
2020
|
+
artifactRef: input.artifactRef,
|
|
2021
|
+
artifactDigest: input.artifactDigest,
|
|
2022
|
+
sourceActionReceiptDigest: input.sourceActionReceiptDigest,
|
|
2023
|
+
reason: "expired",
|
|
2024
|
+
}),
|
|
2025
|
+
);
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
function normalizeDisposalInput(value) {
|
|
2029
|
+
exact(
|
|
2030
|
+
value,
|
|
2031
|
+
[
|
|
2032
|
+
"actionReceiptDigest",
|
|
2033
|
+
"requestDigest",
|
|
2034
|
+
"artifactRef",
|
|
2035
|
+
"artifactDigest",
|
|
2036
|
+
"sourceActionReceiptDigest",
|
|
2037
|
+
"reason",
|
|
2038
|
+
],
|
|
2039
|
+
"filesystem quarantine disposal request",
|
|
2040
|
+
);
|
|
2041
|
+
if (
|
|
2042
|
+
!DIGEST.test(value.actionReceiptDigest) ||
|
|
2043
|
+
!DIGEST.test(value.requestDigest) ||
|
|
2044
|
+
!ARTIFACT_REF.test(value.artifactRef) ||
|
|
2045
|
+
!DIGEST.test(value.artifactDigest) ||
|
|
2046
|
+
!DIGEST.test(value.sourceActionReceiptDigest) ||
|
|
2047
|
+
!DISPOSAL_REASONS.has(value.reason)
|
|
2048
|
+
)
|
|
2049
|
+
throw new TypeError("filesystem quarantine disposal request is invalid");
|
|
2050
|
+
return Object.freeze({ ...value });
|
|
2051
|
+
}
|
|
2052
|
+
|
|
2053
|
+
function deletionBinding(descriptor, input) {
|
|
2054
|
+
return Object.freeze({
|
|
2055
|
+
custodyId: descriptor.custodyId,
|
|
2056
|
+
tenantId: descriptor.tenantId,
|
|
2057
|
+
authorityId: input.authorityId,
|
|
2058
|
+
handlerArtifactDigest: descriptor.handlerArtifactDigest,
|
|
2059
|
+
actionReceiptDigest: input.actionReceiptDigest,
|
|
2060
|
+
requestDigest: input.requestDigest,
|
|
2061
|
+
artifactRef: input.artifactRef,
|
|
2062
|
+
artifactDigest: input.artifactDigest,
|
|
2063
|
+
sourceActionReceiptDigest: input.sourceActionReceiptDigest,
|
|
2064
|
+
reason: input.reason,
|
|
2065
|
+
});
|
|
2066
|
+
}
|
|
2067
|
+
|
|
2068
|
+
function normalizeDeletionIntent(value, state, disposalDescriptor, input) {
|
|
2069
|
+
const keys = [
|
|
2070
|
+
"schema",
|
|
2071
|
+
"custodyId",
|
|
2072
|
+
"tenantId",
|
|
2073
|
+
"authorityId",
|
|
2074
|
+
"handlerArtifactDigest",
|
|
2075
|
+
"actionReceiptDigest",
|
|
2076
|
+
"requestDigest",
|
|
2077
|
+
"artifactRef",
|
|
2078
|
+
"artifactDigest",
|
|
2079
|
+
"sourceActionReceiptDigest",
|
|
2080
|
+
"reason",
|
|
2081
|
+
"startedAt",
|
|
2082
|
+
"intentDigest",
|
|
2083
|
+
];
|
|
2084
|
+
exact(value, keys, "filesystem quarantine deletion intent");
|
|
2085
|
+
const binding = deletionBinding(state.descriptor, {
|
|
2086
|
+
...input,
|
|
2087
|
+
authorityId: disposalDescriptor.authorityId,
|
|
2088
|
+
});
|
|
2089
|
+
const core = Object.freeze({
|
|
2090
|
+
schema: BROWSER_FILESYSTEM_QUARANTINE_DELETION_INTENT_SCHEMA,
|
|
2091
|
+
...binding,
|
|
2092
|
+
startedAt: value.startedAt,
|
|
2093
|
+
});
|
|
2094
|
+
if (
|
|
2095
|
+
value.schema !== core.schema ||
|
|
2096
|
+
keys.slice(1, -2).some((key) => value[key] !== core[key]) ||
|
|
2097
|
+
!Number.isFinite(Date.parse(value.startedAt)) ||
|
|
2098
|
+
value.intentDigest !==
|
|
2099
|
+
digest(BROWSER_FILESYSTEM_QUARANTINE_DELETION_INTENT_SCHEMA, core)
|
|
2100
|
+
)
|
|
2101
|
+
throw new Error("filesystem quarantine deletion intent is invalid");
|
|
2102
|
+
return Object.freeze({ ...value });
|
|
2103
|
+
}
|
|
2104
|
+
|
|
2105
|
+
function normalizeTombstone(value, state, disposalDescriptor, input) {
|
|
2106
|
+
const keys = [
|
|
2107
|
+
"schema",
|
|
2108
|
+
"custodyId",
|
|
2109
|
+
"tenantId",
|
|
2110
|
+
"authorityId",
|
|
2111
|
+
"handlerArtifactDigest",
|
|
2112
|
+
"actionReceiptDigest",
|
|
2113
|
+
"requestDigest",
|
|
2114
|
+
"artifactRef",
|
|
2115
|
+
"artifactDigest",
|
|
2116
|
+
"sourceActionReceiptDigest",
|
|
2117
|
+
"reason",
|
|
2118
|
+
"discardedAt",
|
|
2119
|
+
"deletionReceiptDigest",
|
|
2120
|
+
];
|
|
2121
|
+
exact(value, keys, "filesystem quarantine tombstone");
|
|
2122
|
+
const binding = deletionBinding(state.descriptor, {
|
|
2123
|
+
...input,
|
|
2124
|
+
authorityId: disposalDescriptor.authorityId,
|
|
2125
|
+
});
|
|
2126
|
+
const core = Object.freeze({
|
|
2127
|
+
schema: BROWSER_FILESYSTEM_QUARANTINE_TOMBSTONE_SCHEMA,
|
|
2128
|
+
...binding,
|
|
2129
|
+
discardedAt: value.discardedAt,
|
|
2130
|
+
});
|
|
2131
|
+
if (
|
|
2132
|
+
value.schema !== core.schema ||
|
|
2133
|
+
keys.slice(1, -2).some((key) => value[key] !== core[key]) ||
|
|
2134
|
+
!Number.isFinite(Date.parse(value.discardedAt)) ||
|
|
2135
|
+
value.deletionReceiptDigest !==
|
|
2136
|
+
digest(BROWSER_FILESYSTEM_QUARANTINE_TOMBSTONE_SCHEMA, core)
|
|
2137
|
+
)
|
|
2138
|
+
throw new Error("filesystem quarantine tombstone is invalid");
|
|
2139
|
+
return Object.freeze({ ...value });
|
|
2140
|
+
}
|
|
2141
|
+
|
|
2142
|
+
async function readJsonIfPresent(file) {
|
|
2143
|
+
try {
|
|
2144
|
+
return JSON.parse(await readFile(file, "utf8"));
|
|
2145
|
+
} catch (error) {
|
|
2146
|
+
if (error?.code === "ENOENT") return null;
|
|
2147
|
+
throw error;
|
|
2148
|
+
}
|
|
2149
|
+
}
|
|
2150
|
+
|
|
2151
|
+
function deletionAck(disposalDescriptor, tombstone) {
|
|
2152
|
+
return Object.freeze({
|
|
2153
|
+
schema: DELETION_ACK_SCHEMA,
|
|
2154
|
+
authorityId: disposalDescriptor.authorityId,
|
|
2155
|
+
tenantId: disposalDescriptor.tenantId,
|
|
2156
|
+
handlerArtifactDigest: disposalDescriptor.handlerArtifactDigest,
|
|
2157
|
+
actionReceiptDigest: tombstone.actionReceiptDigest,
|
|
2158
|
+
requestDigest: tombstone.requestDigest,
|
|
2159
|
+
artifactRef: tombstone.artifactRef,
|
|
2160
|
+
artifactDigest: tombstone.artifactDigest,
|
|
2161
|
+
sourceActionReceiptDigest: tombstone.sourceActionReceiptDigest,
|
|
2162
|
+
discardedAt: tombstone.discardedAt,
|
|
2163
|
+
deletionReceiptDigest: tombstone.deletionReceiptDigest,
|
|
2164
|
+
authenticated: true,
|
|
2165
|
+
durable: true,
|
|
2166
|
+
readbackVerified: true,
|
|
2167
|
+
bytesUnavailable: true,
|
|
2168
|
+
qualifiesForPromotion: false,
|
|
2169
|
+
});
|
|
2170
|
+
}
|
|
2171
|
+
|
|
2172
|
+
async function disposeArtifact(state, disposalDescriptor, value) {
|
|
2173
|
+
const input = normalizeDisposalInput(value);
|
|
2174
|
+
await ensureRecovered(state);
|
|
2175
|
+
const artifactId = artifactIdFromRef(input.artifactRef);
|
|
2176
|
+
if (state.disposals.has(artifactId))
|
|
2177
|
+
throw new Error(
|
|
2178
|
+
"filesystem quarantine artifact disposal is already active",
|
|
2179
|
+
);
|
|
2180
|
+
if (state.completions.has(artifactId))
|
|
2181
|
+
throw new Error("filesystem quarantine artifact completion is active");
|
|
2182
|
+
if ((state.readers.get(artifactId) ?? 0) > 0)
|
|
2183
|
+
throw new Error("filesystem quarantine artifact has active scan readers");
|
|
2184
|
+
state.disposals.add(artifactId);
|
|
2185
|
+
let releaseLock = null;
|
|
2186
|
+
try {
|
|
2187
|
+
releaseLock = await acquireArtifactLock(state, artifactId, "dispose");
|
|
2188
|
+
return await disposeArtifactExclusive(
|
|
2189
|
+
state,
|
|
2190
|
+
disposalDescriptor,
|
|
2191
|
+
input,
|
|
2192
|
+
artifactId,
|
|
2193
|
+
);
|
|
2194
|
+
} finally {
|
|
2195
|
+
state.disposals.delete(artifactId);
|
|
2196
|
+
if (releaseLock) await releaseLock();
|
|
2197
|
+
}
|
|
2198
|
+
}
|
|
2199
|
+
|
|
2200
|
+
async function disposeArtifactExclusive(
|
|
2201
|
+
state,
|
|
2202
|
+
disposalDescriptor,
|
|
2203
|
+
input,
|
|
2204
|
+
artifactId,
|
|
2205
|
+
) {
|
|
2206
|
+
const files = pathsFor(state.stateRoot, artifactId);
|
|
2207
|
+
const existingTombstone = await readJsonIfPresent(files.tombstone);
|
|
2208
|
+
if (existingTombstone !== null) {
|
|
2209
|
+
const tombstone = normalizeTombstone(
|
|
2210
|
+
existingTombstone,
|
|
2211
|
+
state,
|
|
2212
|
+
disposalDescriptor,
|
|
2213
|
+
input,
|
|
2214
|
+
);
|
|
2215
|
+
const existingIntent = await readJsonIfPresent(files.deletionIntent);
|
|
2216
|
+
if (existingIntent !== null)
|
|
2217
|
+
normalizeDeletionIntent(existingIntent, state, disposalDescriptor, input);
|
|
2218
|
+
if (
|
|
2219
|
+
(await exists(files.part)) ||
|
|
2220
|
+
(await exists(files.blob)) ||
|
|
2221
|
+
(await exists(files.metadata))
|
|
2222
|
+
)
|
|
2223
|
+
throw new Error("filesystem quarantine tombstone conflicts with bytes");
|
|
2224
|
+
await unlinkIfPresent(files.deletionIntent);
|
|
2225
|
+
if (await exists(files.deletionIntent))
|
|
2226
|
+
throw new Error("filesystem quarantine deletion intent cleanup failed");
|
|
2227
|
+
return deletionAck(disposalDescriptor, tombstone);
|
|
2228
|
+
}
|
|
2229
|
+
|
|
2230
|
+
let intentValue = await readJsonIfPresent(files.deletionIntent);
|
|
2231
|
+
let intent;
|
|
2232
|
+
if (intentValue === null) {
|
|
2233
|
+
const persisted = await readMetadata(state, input.artifactRef);
|
|
2234
|
+
if (
|
|
2235
|
+
persisted.metadata.artifactDigest !== input.artifactDigest ||
|
|
2236
|
+
persisted.metadata.sourceActionReceiptDigest !==
|
|
2237
|
+
input.sourceActionReceiptDigest
|
|
2238
|
+
)
|
|
2239
|
+
throw new Error("filesystem quarantine disposal differs from custody");
|
|
2240
|
+
const startedAtMs = state.now();
|
|
2241
|
+
if (!Number.isFinite(startedAtMs))
|
|
2242
|
+
throw new Error("filesystem quarantine clock is invalid");
|
|
2243
|
+
const binding = deletionBinding(state.descriptor, {
|
|
2244
|
+
...input,
|
|
2245
|
+
authorityId: disposalDescriptor.authorityId,
|
|
2246
|
+
});
|
|
2247
|
+
const core = Object.freeze({
|
|
2248
|
+
schema: BROWSER_FILESYSTEM_QUARANTINE_DELETION_INTENT_SCHEMA,
|
|
2249
|
+
...binding,
|
|
2250
|
+
startedAt: new Date(startedAtMs).toISOString(),
|
|
2251
|
+
});
|
|
2252
|
+
intentValue = Object.freeze({
|
|
2253
|
+
...core,
|
|
2254
|
+
intentDigest: digest(
|
|
2255
|
+
BROWSER_FILESYSTEM_QUARANTINE_DELETION_INTENT_SCHEMA,
|
|
2256
|
+
core,
|
|
2257
|
+
),
|
|
2258
|
+
});
|
|
2259
|
+
try {
|
|
2260
|
+
await writeNewMetadata(files.deletionIntent, intentValue);
|
|
2261
|
+
} catch (error) {
|
|
2262
|
+
if (error?.code !== "EEXIST") throw error;
|
|
2263
|
+
intentValue = await readJsonIfPresent(files.deletionIntent);
|
|
2264
|
+
if (intentValue === null)
|
|
2265
|
+
throw new Error("filesystem quarantine deletion intent is uncertain");
|
|
2266
|
+
}
|
|
2267
|
+
}
|
|
2268
|
+
intent = normalizeDeletionIntent(
|
|
2269
|
+
intentValue,
|
|
2270
|
+
state,
|
|
2271
|
+
disposalDescriptor,
|
|
2272
|
+
input,
|
|
2273
|
+
);
|
|
2274
|
+
|
|
2275
|
+
for (const file of [files.part, files.blob, files.metadata])
|
|
2276
|
+
await unlinkIfPresent(file);
|
|
2277
|
+
if (
|
|
2278
|
+
(await exists(files.part)) ||
|
|
2279
|
+
(await exists(files.blob)) ||
|
|
2280
|
+
(await exists(files.metadata))
|
|
2281
|
+
)
|
|
2282
|
+
throw new Error("filesystem quarantine deletion readback failed");
|
|
2283
|
+
|
|
2284
|
+
const binding = deletionBinding(state.descriptor, {
|
|
2285
|
+
...input,
|
|
2286
|
+
authorityId: disposalDescriptor.authorityId,
|
|
2287
|
+
});
|
|
2288
|
+
const tombstoneCore = Object.freeze({
|
|
2289
|
+
schema: BROWSER_FILESYSTEM_QUARANTINE_TOMBSTONE_SCHEMA,
|
|
2290
|
+
...binding,
|
|
2291
|
+
discardedAt: intent.startedAt,
|
|
2292
|
+
});
|
|
2293
|
+
const tombstoneValue = Object.freeze({
|
|
2294
|
+
...tombstoneCore,
|
|
2295
|
+
deletionReceiptDigest: digest(
|
|
2296
|
+
BROWSER_FILESYSTEM_QUARANTINE_TOMBSTONE_SCHEMA,
|
|
2297
|
+
tombstoneCore,
|
|
2298
|
+
),
|
|
2299
|
+
});
|
|
2300
|
+
try {
|
|
2301
|
+
await writeNewMetadata(files.tombstone, tombstoneValue);
|
|
2302
|
+
} catch (error) {
|
|
2303
|
+
if (error?.code !== "EEXIST") throw error;
|
|
2304
|
+
}
|
|
2305
|
+
const tombstone = normalizeTombstone(
|
|
2306
|
+
JSON.parse(await readFile(files.tombstone, "utf8")),
|
|
2307
|
+
state,
|
|
2308
|
+
disposalDescriptor,
|
|
2309
|
+
input,
|
|
2310
|
+
);
|
|
2311
|
+
await unlinkIfPresent(files.deletionIntent);
|
|
2312
|
+
if (
|
|
2313
|
+
(await exists(files.part)) ||
|
|
2314
|
+
(await exists(files.blob)) ||
|
|
2315
|
+
(await exists(files.metadata))
|
|
2316
|
+
)
|
|
2317
|
+
throw new Error("filesystem quarantine deletion durability is uncertain");
|
|
2318
|
+
return deletionAck(disposalDescriptor, tombstone);
|
|
2319
|
+
}
|
|
2320
|
+
|
|
2321
|
+
export function createBrowserFilesystemQuarantineCustody({
|
|
2322
|
+
descriptor,
|
|
2323
|
+
stateRoot,
|
|
2324
|
+
now = Date.now,
|
|
2325
|
+
} = {}) {
|
|
2326
|
+
const normalizedDescriptor = normalizeDescriptor(descriptor);
|
|
2327
|
+
if (typeof now !== "function" || types.isProxy(now))
|
|
2328
|
+
throw new TypeError("filesystem quarantine clock is invalid");
|
|
2329
|
+
const normalizedRoot = normalizeStateRoot(stateRoot);
|
|
2330
|
+
const custody = Object.freeze({});
|
|
2331
|
+
custodies.set(custody, {
|
|
2332
|
+
descriptor: normalizedDescriptor,
|
|
2333
|
+
stateRoot: normalizedRoot,
|
|
2334
|
+
objectsRoot: path.join(normalizedRoot, "objects"),
|
|
2335
|
+
metadataRoot: path.join(normalizedRoot, "metadata"),
|
|
2336
|
+
deletionsRoot: path.join(normalizedRoot, "deletions"),
|
|
2337
|
+
locksRoot: path.join(normalizedRoot, "locks"),
|
|
2338
|
+
now,
|
|
2339
|
+
preparePromise: null,
|
|
2340
|
+
recoveryPromise: null,
|
|
2341
|
+
active: new Set(),
|
|
2342
|
+
readers: new Map(),
|
|
2343
|
+
disposals: new Set(),
|
|
2344
|
+
completions: new Set(),
|
|
2345
|
+
heldLocks: new Map(),
|
|
2346
|
+
maintenanceClaims: new Set(),
|
|
2347
|
+
});
|
|
2348
|
+
return custody;
|
|
2349
|
+
}
|
|
2350
|
+
|
|
2351
|
+
export function captureBrowserFilesystemQuarantineCustody(value) {
|
|
2352
|
+
const state = custodies.get(value);
|
|
2353
|
+
if (!state)
|
|
2354
|
+
throw new TypeError("A branded filesystem quarantine custody is required");
|
|
2355
|
+
return Object.freeze({
|
|
2356
|
+
descriptor: state.descriptor,
|
|
2357
|
+
openQuarantine: async (input) => openQuarantine(state, input),
|
|
2358
|
+
openArtifactForScan: async (input) => openArtifactForScan(state, input),
|
|
2359
|
+
completeArtifact: async (input) => completeArtifact(state, input),
|
|
2360
|
+
inspectArtifact: async (artifactRef) => inspectArtifact(state, artifactRef),
|
|
2361
|
+
bindDisposalAuthority: (descriptor) => {
|
|
2362
|
+
const disposalDescriptor = normalizeDisposalDescriptor(
|
|
2363
|
+
descriptor,
|
|
2364
|
+
state.descriptor,
|
|
2365
|
+
);
|
|
2366
|
+
return async (input) => disposeArtifact(state, disposalDescriptor, input);
|
|
2367
|
+
},
|
|
2368
|
+
bindRetentionAuthority: (descriptor) => {
|
|
2369
|
+
const retentionDescriptor = normalizeRetentionDescriptor(
|
|
2370
|
+
descriptor,
|
|
2371
|
+
state.descriptor,
|
|
2372
|
+
);
|
|
2373
|
+
const issuedPlans = new Map();
|
|
2374
|
+
return Object.freeze({
|
|
2375
|
+
planExpiredArtifacts: async (input) => {
|
|
2376
|
+
const plan = await planExpiredArtifacts(
|
|
2377
|
+
state,
|
|
2378
|
+
retentionDescriptor,
|
|
2379
|
+
input,
|
|
2380
|
+
);
|
|
2381
|
+
issuedPlans.clear();
|
|
2382
|
+
if (plan.artifacts.length > 0)
|
|
2383
|
+
issuedPlans.set(plan.planDigest, {
|
|
2384
|
+
sweepId: plan.sweepId,
|
|
2385
|
+
cutoffAt: plan.cutoffAt,
|
|
2386
|
+
artifacts: new Map(
|
|
2387
|
+
plan.artifacts.map((artifact) => [
|
|
2388
|
+
artifact.artifactRef,
|
|
2389
|
+
artifact,
|
|
2390
|
+
]),
|
|
2391
|
+
),
|
|
2392
|
+
});
|
|
2393
|
+
return plan;
|
|
2394
|
+
},
|
|
2395
|
+
disposeExpiredArtifact: async (input) => {
|
|
2396
|
+
const normalizedInput = normalizeExpiredDisposalInput(input);
|
|
2397
|
+
const plan = issuedPlans.get(normalizedInput.planDigest);
|
|
2398
|
+
const artifact = plan?.artifacts.get(normalizedInput.artifactRef);
|
|
2399
|
+
if (
|
|
2400
|
+
!plan ||
|
|
2401
|
+
!artifact ||
|
|
2402
|
+
plan.sweepId !== normalizedInput.sweepId ||
|
|
2403
|
+
plan.cutoffAt !== normalizedInput.cutoffAt ||
|
|
2404
|
+
[
|
|
2405
|
+
"artifactRefDigest",
|
|
2406
|
+
"artifactDigest",
|
|
2407
|
+
"sourceActionReceiptDigest",
|
|
2408
|
+
"expiresAt",
|
|
2409
|
+
].some((key) => artifact[key] !== normalizedInput[key])
|
|
2410
|
+
)
|
|
2411
|
+
throw new Error(
|
|
2412
|
+
"filesystem quarantine expiry disposal is not in the active plan",
|
|
2413
|
+
);
|
|
2414
|
+
const acknowledgement = await disposeExpiredArtifact(
|
|
2415
|
+
state,
|
|
2416
|
+
retentionDescriptor,
|
|
2417
|
+
normalizedInput,
|
|
2418
|
+
);
|
|
2419
|
+
plan.artifacts.delete(normalizedInput.artifactRef);
|
|
2420
|
+
if (plan.artifacts.size === 0)
|
|
2421
|
+
issuedPlans.delete(normalizedInput.planDigest);
|
|
2422
|
+
return acknowledgement;
|
|
2423
|
+
},
|
|
2424
|
+
});
|
|
2425
|
+
},
|
|
2426
|
+
bindOperatorRevocationAuthority: (descriptor) => {
|
|
2427
|
+
const revocationDescriptor = normalizeOperatorRevocationDescriptor(
|
|
2428
|
+
descriptor,
|
|
2429
|
+
state.descriptor,
|
|
2430
|
+
);
|
|
2431
|
+
return async (input) => {
|
|
2432
|
+
const normalized = normalizeDisposalInput(input);
|
|
2433
|
+
if (normalized.reason !== "revoked")
|
|
2434
|
+
throw new Error(
|
|
2435
|
+
"filesystem quarantine operator revocation reason is invalid",
|
|
2436
|
+
);
|
|
2437
|
+
return disposeArtifact(state, revocationDescriptor, normalized);
|
|
2438
|
+
};
|
|
2439
|
+
},
|
|
2440
|
+
bindLockMaintenanceAuthority: (descriptor) => {
|
|
2441
|
+
const maintenanceDescriptor = normalizeLockMaintenanceDescriptor(
|
|
2442
|
+
descriptor,
|
|
2443
|
+
state.descriptor,
|
|
2444
|
+
);
|
|
2445
|
+
return Object.freeze({
|
|
2446
|
+
inspectLock: async (artifactRef) =>
|
|
2447
|
+
inspectArtifactLockState(state, artifactRef),
|
|
2448
|
+
releaseLock: async (input) =>
|
|
2449
|
+
releaseArtifactLock(state, maintenanceDescriptor, input),
|
|
2450
|
+
});
|
|
2451
|
+
},
|
|
2452
|
+
});
|
|
2453
|
+
}
|