@hraness/ghostget 0.17.2
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/CHANGELOG.md +458 -0
- package/LICENSE +21 -0
- package/README.md +1359 -0
- package/bunfig.toml +2 -0
- package/dist/apple-photos-client.js +704 -0
- package/dist/beeper-client.js +4602 -0
- package/dist/client.js +1605 -0
- package/dist/index-26yq8q16.js +31 -0
- package/dist/index-9t13b4k7.js +508 -0
- package/dist/index-axwm9b3v.js +5 -0
- package/dist/index-gwk7rbyj.js +30 -0
- package/dist/index.js +701 -0
- package/dist/messaging.js +1130 -0
- package/dist/omni-client.js +1406 -0
- package/dist/whatsapp-client.js +355 -0
- package/docs/imessage-direct-provider.md +106 -0
- package/docs/rental-listings.md +94 -0
- package/package.json +600 -0
- package/skills/ghostget/SKILL.md +251 -0
- package/skills/ghostget/agents/openai.yaml +4 -0
- package/skills/ghostget/references/adapter-contract.md +181 -0
- package/skills/ghostget/references/apple-photos.md +115 -0
- package/skills/ghostget/references/article-drafts.md +235 -0
- package/skills/ghostget/references/code-owned-provider-scaffold.md +134 -0
- package/skills/ghostget/references/cross-posting-video.md +139 -0
- package/skills/ghostget/references/cross-posting.md +82 -0
- package/skills/ghostget/references/derivation.md +390 -0
- package/skills/ghostget/references/hraness-social-profile-stats.json +269 -0
- package/skills/ghostget/references/install.md +66 -0
- package/skills/ghostget/references/linkedin-adapter.md +442 -0
- package/skills/ghostget/references/messaging.md +168 -0
- package/skills/ghostget/references/meta-comet-contract.md +153 -0
- package/skills/ghostget/references/platform-patterns.md +122 -0
- package/skills/ghostget/references/provider-plugins.md +496 -0
- package/skills/ghostget/references/reddit-flair.md +78 -0
- package/skills/ghostget/references/safety-and-state.md +214 -0
- package/skills/ghostget/references/settlement-and-duplicate-cleanup.md +79 -0
- package/skills/ghostget/references/social-platform-routing.md +66 -0
- package/skills/ghostget/references/social-profile-stats.md +195 -0
- package/skills/ghostget/references/social-video-platform-routing.md +89 -0
- package/skills/ghostget/references/whatsapp-adapter.md +101 -0
- package/skills/ghostget/references/x-adapter.md +332 -0
- package/skills/ghostget/references/x-ai-disclosure.md +71 -0
- package/src/apple-photos-cli.ts +639 -0
- package/src/apple-photos-client-types.ts +168 -0
- package/src/apple-photos-client.ts +201 -0
- package/src/apple-photos-contact-evidence.ts +838 -0
- package/src/apple-photos-local-source.ts +1131 -0
- package/src/args.ts +2053 -0
- package/src/article-draft-document.ts +417 -0
- package/src/article-draft-embeds.ts +123 -0
- package/src/article-draft-images.ts +214 -0
- package/src/assets/adapter-template/wrench-adapter.json +40 -0
- package/src/assets/adapters/beeper/wrench-web-adapter.json +1500 -0
- package/src/assets/adapters/beeper/wrench-web-adapter.v1.0.0.json +129 -0
- package/src/assets/adapters/beeper/wrench-web-adapter.v1.1.0.json +207 -0
- package/src/assets/adapters/beeper/wrench-web-adapter.v2.0.0.json +1463 -0
- package/src/assets/adapters/beeper/wrench-web-adapter.v2.1.0.json +1463 -0
- package/src/assets/adapters/beeper/wrench-web-adapter.v2.2.0.json +1475 -0
- package/src/assets/adapters/beeper/wrench-web-adapter.v2.3.0.json +1488 -0
- package/src/assets/adapters/bluesky/wrench-web-adapter.json +684 -0
- package/src/assets/adapters/bluesky/wrench-web-adapter.v1.0.0.json +581 -0
- package/src/assets/adapters/bluesky/wrench-web-adapter.v1.1.0.json +581 -0
- package/src/assets/adapters/bluesky/wrench-web-adapter.v1.2.0.json +581 -0
- package/src/assets/adapters/bluesky/wrench-web-adapter.v1.3.0.json +615 -0
- package/src/assets/adapters/bluesky/wrench-web-adapter.v1.4.0.json +684 -0
- package/src/assets/adapters/bluesky/wrench-web-adapter.v1.5.0.json +684 -0
- package/src/assets/adapters/bluesky/wrench-web-adapter.v1.6.0.json +684 -0
- package/src/assets/adapters/clasificados/wrench-web-adapter.json +54 -0
- package/src/assets/adapters/facebook/wrench-web-adapter.json +750 -0
- package/src/assets/adapters/facebook/wrench-web-adapter.v1.0.0.json +706 -0
- package/src/assets/adapters/facebook/wrench-web-adapter.v1.1.0.json +703 -0
- package/src/assets/adapters/facebook/wrench-web-adapter.v1.2.0.json +750 -0
- package/src/assets/adapters/facebook-group/wrench-web-adapter.json +682 -0
- package/src/assets/adapters/facebook-group/wrench-web-adapter.v1.0.0.json +583 -0
- package/src/assets/adapters/facebook-group/wrench-web-adapter.v1.1.0.json +682 -0
- package/src/assets/adapters/facebook-marketplace/wrench-web-adapter.json +437 -0
- package/src/assets/adapters/facebook-marketplace/wrench-web-adapter.v1.0.0.json +383 -0
- package/src/assets/adapters/facebook-marketplace/wrench-web-adapter.v1.1.0.json +437 -0
- package/src/assets/adapters/facebook-marketplace/wrench-web-adapter.v1.1.1.json +437 -0
- package/src/assets/adapters/facebook-page/wrench-web-adapter.json +880 -0
- package/src/assets/adapters/facebook-page/wrench-web-adapter.v1.0.0.json +740 -0
- package/src/assets/adapters/facebook-page/wrench-web-adapter.v1.1.0.json +880 -0
- package/src/assets/adapters/github/wrench-web-adapter.json +69 -0
- package/src/assets/adapters/github/wrench-web-adapter.v1.0.0.json +42 -0
- package/src/assets/adapters/github/wrench-web-adapter.v1.1.0.json +69 -0
- package/src/assets/adapters/gmail/wrench-adapter.json +161 -0
- package/src/assets/adapters/gmail/wrench-adapter.v1.2.0.json +157 -0
- package/src/assets/adapters/hacker-news/wrench-web-adapter.json +315 -0
- package/src/assets/adapters/hacker-news/wrench-web-adapter.v1.0.0.json +315 -0
- package/src/assets/adapters/imessage/wrench-web-adapter.json +236 -0
- package/src/assets/adapters/instagram/wrench-web-adapter.json +719 -0
- package/src/assets/adapters/instagram/wrench-web-adapter.v1.0.0.json +627 -0
- package/src/assets/adapters/instagram/wrench-web-adapter.v1.1.0.json +609 -0
- package/src/assets/adapters/instagram/wrench-web-adapter.v1.2.0.json +640 -0
- package/src/assets/adapters/instagram/wrench-web-adapter.v1.3.0.json +671 -0
- package/src/assets/adapters/instagram/wrench-web-adapter.v1.4.0.json +710 -0
- package/src/assets/adapters/instagram/wrench-web-adapter.v1.5.0.json +710 -0
- package/src/assets/adapters/instagram/wrench-web-adapter.v1.6.0.json +719 -0
- package/src/assets/adapters/instagram/wrench-web-adapter.v1.7.0.json +719 -0
- package/src/assets/adapters/linkedin/wrench-adapter.json +442 -0
- package/src/assets/adapters/linkedin/wrench-adapter.v0.4.0.json +124 -0
- package/src/assets/adapters/linkedin/wrench-adapter.v1.0.0.json +411 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.json +914 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.0.0.json +604 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.1.0.json +604 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.10.0.json +809 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.11.0.json +809 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.12.0.json +820 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.13.0.json +819 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.14.0.json +819 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.15.0.json +819 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.16.0.json +874 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.17.0.json +874 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.18.0.json +874 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.19.0.json +874 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.2.0.json +731 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.3.0.json +768 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.4.0.json +808 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.5.0.json +808 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.6.0.json +808 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.7.0.json +825 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.8.0.json +792 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.9.0.json +809 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.json +855 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.0.0.json +577 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.1.0.json +604 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.10.0.json +712 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.11.0.json +712 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.12.0.json +712 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.2.0.json +653 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.3.0.json +712 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.4.0.json +713 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.5.0.json +712 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.6.0.json +713 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.7.0.json +713 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.8.0.json +713 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.9.0.json +713 -0
- package/src/assets/adapters/substack/wrench-web-adapter.json +936 -0
- package/src/assets/adapters/substack/wrench-web-adapter.v1.0.0.json +817 -0
- package/src/assets/adapters/substack/wrench-web-adapter.v1.1.0.json +812 -0
- package/src/assets/adapters/substack/wrench-web-adapter.v1.2.0.json +812 -0
- package/src/assets/adapters/substack/wrench-web-adapter.v1.3.0.json +902 -0
- package/src/assets/adapters/substack/wrench-web-adapter.v1.4.0.json +936 -0
- package/src/assets/adapters/substack/wrench-web-adapter.v1.5.0.json +936 -0
- package/src/assets/adapters/substack/wrench-web-adapter.v1.6.0.json +936 -0
- package/src/assets/adapters/threads/wrench-web-adapter.json +692 -0
- package/src/assets/adapters/threads/wrench-web-adapter.v1.0.0.json +629 -0
- package/src/assets/adapters/threads/wrench-web-adapter.v1.1.0.json +623 -0
- package/src/assets/adapters/threads/wrench-web-adapter.v1.2.0.json +622 -0
- package/src/assets/adapters/threads/wrench-web-adapter.v1.3.0.json +622 -0
- package/src/assets/adapters/threads/wrench-web-adapter.v1.4.0.json +622 -0
- package/src/assets/adapters/threads/wrench-web-adapter.v1.5.0.json +699 -0
- package/src/assets/adapters/threads/wrench-web-adapter.v1.6.0.json +693 -0
- package/src/assets/adapters/threads/wrench-web-adapter.v1.7.0.json +692 -0
- package/src/assets/adapters/tiktok/wrench-web-adapter.json +762 -0
- package/src/assets/adapters/tiktok/wrench-web-adapter.v1.0.0.json +681 -0
- package/src/assets/adapters/tiktok/wrench-web-adapter.v1.1.0.json +708 -0
- package/src/assets/adapters/tiktok/wrench-web-adapter.v1.2.0.json +762 -0
- package/src/assets/adapters/tiktok/wrench-web-adapter.v1.3.0.json +762 -0
- package/src/assets/adapters/twitch/wrench-web-adapter.json +42 -0
- package/src/assets/adapters/twitch/wrench-web-adapter.v1.0.0.json +42 -0
- package/src/assets/adapters/whatsapp/wrench-web-adapter.json +400 -0
- package/src/assets/adapters/whatsapp/wrench-web-adapter.v1.0.0.json +355 -0
- package/src/assets/adapters/whatsapp/wrench-web-adapter.v1.1.0.json +386 -0
- package/src/assets/adapters/whatsapp/wrench-web-adapter.v1.2.0.json +386 -0
- package/src/assets/adapters/whatsapp/wrench-web-adapter.v1.3.0.json +400 -0
- package/src/assets/adapters/x/wrench-adapter.json +749 -0
- package/src/assets/adapters/x/wrench-adapter.v1.0.0.json +700 -0
- package/src/assets/adapters/x/wrench-adapter.v1.1.0.json +707 -0
- package/src/assets/adapters/x/wrench-web-adapter.json +712 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.1.0.json +579 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.10.0.json +685 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.11.0.json +712 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.12.0.json +712 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.13.0.json +712 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.2.0.json +578 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.3.0.json +610 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.4.0.json +617 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.5.0.json +634 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.6.0.json +632 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.7.0.json +649 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.8.0.json +649 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.9.0.json +683 -0
- package/src/assets/adapters/youtube/wrench-web-adapter.json +561 -0
- package/src/assets/adapters/youtube/wrench-web-adapter.v1.0.0.json +465 -0
- package/src/assets/adapters/youtube/wrench-web-adapter.v1.1.0.json +519 -0
- package/src/assets/adapters/youtube/wrench-web-adapter.v1.2.0.json +561 -0
- package/src/assets/adapters/youtube/wrench-web-adapter.v1.3.0.json +561 -0
- package/src/assets/code-owned-provider-template/plugin.test.ts.template +54 -0
- package/src/assets/code-owned-provider-template/plugin.ts.template +102 -0
- package/src/assets/code-owned-provider-template/runtime.internal.test.ts.template +44 -0
- package/src/assets/code-owned-provider-template/runtime.ts.template +59 -0
- package/src/assets/code-owned-provider-template/wrench-adapter.json.template +33 -0
- package/src/assets/fonts/nebula-sans/LICENSE.txt +96 -0
- package/src/assets/fonts/nebula-sans/NebulaSans-Book.woff2 +0 -0
- package/src/assets/fonts/nebula-sans/PROVENANCE.md +14 -0
- package/src/assets.d.ts +4 -0
- package/src/auth.ts +1157 -0
- package/src/beeper-client-types.ts +169 -0
- package/src/beeper-client.ts +250 -0
- package/src/beeper-contact-interactions-cli.ts +180 -0
- package/src/beeper-contact-interactions.ts +1612 -0
- package/src/beeper-message-bundle-v1.ts +257 -0
- package/src/beeper-message-like-me-cli.ts +84 -0
- package/src/beeper-message-like-me-export.ts +2274 -0
- package/src/beeper-message-like-me-recovery.ts +931 -0
- package/src/beeper-message-like-me-source.ts +4034 -0
- package/src/browser-admission.ts +928 -0
- package/src/browser-snapshots.ts +260 -0
- package/src/browser.ts +3873 -0
- package/src/canonical-json.ts +60 -0
- package/src/catalog-cli.ts +638 -0
- package/src/cli.ts +208 -0
- package/src/client-types.ts +301 -0
- package/src/client.ts +2726 -0
- package/src/confirmed-write-failure.ts +44 -0
- package/src/confirmed-write-model.ts +72 -0
- package/src/confirmed-write-platform.ts +1055 -0
- package/src/confirmed-write-program.ts +224 -0
- package/src/confirmed-write-runtime.ts +16 -0
- package/src/cursor-token.ts +228 -0
- package/src/derivation-dnr-readiness.ts +367 -0
- package/src/derivation-file-chooser.ts +384 -0
- package/src/derivation-network-boundary.ts +835 -0
- package/src/derivation-network-guard.ts +782 -0
- package/src/derivation-network-proxy-helper.ts +440 -0
- package/src/derivation-network-proxy.ts +385 -0
- package/src/derive-command-helper.ts +693 -0
- package/src/derive-fixtures.ts +343 -0
- package/src/derive-review.ts +980 -0
- package/src/derive.ts +4754 -0
- package/src/fixtures/beeper-message-like-me-v1/accounts.ndjson +2 -0
- package/src/fixtures/beeper-message-like-me-v1/conversations.ndjson +1 -0
- package/src/fixtures/beeper-message-like-me-v1/manifest.json +1 -0
- package/src/fixtures/beeper-message-like-me-v1/messages.ndjson +2 -0
- package/src/fixtures/beeper-message-like-me-v1/participants.ndjson +3 -0
- package/src/fixtures/beeper-message-like-me-v1/reactions.ndjson +1 -0
- package/src/fixtures/beeper-message-like-me-v1/tombstones.ndjson +1 -0
- package/src/ghostget.ts +3672 -0
- package/src/gmail-capture.ts +1244 -0
- package/src/har-internal.ts +1242 -0
- package/src/har.ts +743 -0
- package/src/index.ts +218 -0
- package/src/invocation-read-platform.ts +55 -0
- package/src/invocation-read-program.ts +93 -0
- package/src/invocation-read-runtime.ts +11 -0
- package/src/linked-device-lifecycle-admission.ts +450 -0
- package/src/linked-device-lifecycle-journal.ts +1560 -0
- package/src/linked-device-lifecycle-runtime.ts +1602 -0
- package/src/local-cli-admission.ts +135 -0
- package/src/local-cli-contracts.ts +271 -0
- package/src/local-cli-execution.ts +98 -0
- package/src/local-cli-surface-contract.ts +1510 -0
- package/src/local-cli-tool-identity.ts +336 -0
- package/src/media/archive.ts +2325 -0
- package/src/media/args.ts +303 -0
- package/src/media/cli.ts +287 -0
- package/src/media/doctor.ts +475 -0
- package/src/media/ffmpeg.ts +1330 -0
- package/src/media/http-capture.ts +711 -0
- package/src/media/http-probe.ts +593 -0
- package/src/media/http.ts +387 -0
- package/src/media/index.ts +10 -0
- package/src/media/local-transcription.ts +613 -0
- package/src/media/lock.ts +321 -0
- package/src/media/manifest.ts +1712 -0
- package/src/media/metadata.ts +906 -0
- package/src/media/process.ts +723 -0
- package/src/media/revision.ts +166 -0
- package/src/media/runtime-closure.ts +1063 -0
- package/src/media/source-router.ts +187 -0
- package/src/media/transcriber-config.ts +1122 -0
- package/src/media/transcript-persistence-model.ts +15 -0
- package/src/media/transcript-persistence-platform.ts +89 -0
- package/src/media/transcript-persistence-program.ts +39 -0
- package/src/media/transcript-persistence-runtime.ts +24 -0
- package/src/media/transcript.ts +616 -0
- package/src/media/utf8-order.ts +30 -0
- package/src/media/whisper-cpp.ts +464 -0
- package/src/media/whisper-language.ts +58 -0
- package/src/media/yt-dlp.ts +439 -0
- package/src/message-like-me-agentic-messaging.ts +882 -0
- package/src/messaging-action-store.ts +793 -0
- package/src/messaging-runtime.ts +2136 -0
- package/src/messaging-store.ts +539 -0
- package/src/messaging-types.ts +1663 -0
- package/src/messaging.ts +334 -0
- package/src/model.ts +2671 -0
- package/src/oauth-google.ts +888 -0
- package/src/omni-client-types.ts +306 -0
- package/src/omni-client.ts +2225 -0
- package/src/omni-limits.ts +34 -0
- package/src/omni-model.ts +1614 -0
- package/src/omni-request.ts +379 -0
- package/src/omni-runtime.ts +1632 -0
- package/src/operation-deadline.ts +189 -0
- package/src/path-helper.ts +1656 -0
- package/src/pinned-https.ts +194 -0
- package/src/plan-assets.ts +832 -0
- package/src/platform-catalog.ts +1832 -0
- package/src/plugins/beeper-linked-device/plugin.ts +310 -0
- package/src/plugins/bluesky-web/plugin.ts +178 -0
- package/src/plugins/clasificados-web/plugin.ts +66 -0
- package/src/plugins/github-web/plugin.ts +80 -0
- package/src/plugins/gmail-official/plugin.ts +110 -0
- package/src/plugins/hacker-news-web/plugin.ts +52 -0
- package/src/plugins/imessage-direct/plugin.ts +195 -0
- package/src/plugins/imessage-direct/vendor/0001-fix-keep-AppleScript-send-payloads-out-of-child-argv.patch +1430 -0
- package/src/plugins/imessage-direct/vendor/0002-feat-rpc-add-exact-chat-lookup.patch +225 -0
- package/src/plugins/imessage-direct/vendor/provenance.json +80 -0
- package/src/plugins/linkedin-official/plugin.ts +84 -0
- package/src/plugins/linkedin-web/plugin.ts +412 -0
- package/src/plugins/meta-web/plugin.ts +317 -0
- package/src/plugins/reddit-web/flair.ts +492 -0
- package/src/plugins/reddit-web/plugin.ts +477 -0
- package/src/plugins/substack-web/plugin.ts +124 -0
- package/src/plugins/tiktok-web/plugin.ts +67 -0
- package/src/plugins/twitch-web/plugin.ts +54 -0
- package/src/plugins/whatsapp-linked-device/plugin.ts +105 -0
- package/src/plugins/x-official/plugin.ts +64 -0
- package/src/plugins/x-web/plugin.ts +488 -0
- package/src/plugins/youtube-web/plugin.ts +101 -0
- package/src/portable-run-recovery.ts +466 -0
- package/src/process-identity.ts +501 -0
- package/src/profile-clone-helper.ts +104 -0
- package/src/provider-catalog-views.ts +51 -0
- package/src/provider-context.ts +69 -0
- package/src/provider-contract-definitions-x.ts +45 -0
- package/src/provider-contract-definitions.ts +948 -0
- package/src/provider-contract-input-gmail.ts +176 -0
- package/src/provider-contract-input-linkedin.ts +77 -0
- package/src/provider-contract-input-x.ts +304 -0
- package/src/provider-contract-planning.ts +23 -0
- package/src/provider-contract-semantic-identity.ts +54 -0
- package/src/provider-contracts.ts +251 -0
- package/src/provider-http.ts +723 -0
- package/src/provider-plugin-auth.ts +36 -0
- package/src/provider-plugin-builtins.ts +229 -0
- package/src/provider-plugin-cleanup-barrier.ts +229 -0
- package/src/provider-plugin-cleanup-execution.ts +79 -0
- package/src/provider-plugin-cleanup-resource.ts +470 -0
- package/src/provider-plugin-contract-identity.ts +707 -0
- package/src/provider-plugin-host.ts +1972 -0
- package/src/provider-plugin-identifiers.ts +60 -0
- package/src/provider-plugin-invocation-lease.ts +889 -0
- package/src/provider-plugin-lifecycle-kernel.ts +566 -0
- package/src/provider-plugin-lifecycle.ts +1455 -0
- package/src/provider-plugin-module-analysis.ts +336 -0
- package/src/provider-plugin-package.ts +2530 -0
- package/src/provider-plugin-portable-authority.ts +106 -0
- package/src/provider-plugin-portable-catalog.ts +94 -0
- package/src/provider-plugin-portable-identity.ts +315 -0
- package/src/provider-plugin-portable-runtime.ts +2057 -0
- package/src/provider-plugin-protocol.ts +1625 -0
- package/src/provider-plugin-registry.ts +3342 -0
- package/src/provider-plugin-store.ts +2155 -0
- package/src/provider-plugin-test-fixture.ts +5 -0
- package/src/provider-plugin.ts +4907 -0
- package/src/provider-plugins.generated.ts +43 -0
- package/src/provider-plugins.ts +8 -0
- package/src/provider-subject.ts +41 -0
- package/src/provider.ts +315 -0
- package/src/providers/beeper-local-runtime.ts +5426 -0
- package/src/providers/beeper-local.ts +2739 -0
- package/src/providers/beeper-messaging.ts +462 -0
- package/src/providers/beeper-omni.ts +1159 -0
- package/src/providers/bluesky-web-runtime.ts +2895 -0
- package/src/providers/bluesky-web.ts +1614 -0
- package/src/providers/clasificados-web-runtime.ts +255 -0
- package/src/providers/clasificados-web.ts +503 -0
- package/src/providers/contact-projection.ts +301 -0
- package/src/providers/github-read-model.ts +94 -0
- package/src/providers/github-read-platform.ts +165 -0
- package/src/providers/github-read-program.ts +97 -0
- package/src/providers/github-web-runtime.ts +320 -0
- package/src/providers/github-web.ts +403 -0
- package/src/providers/gmail-api.ts +2834 -0
- package/src/providers/gmail-omni.ts +830 -0
- package/src/providers/gmail.ts +1130 -0
- package/src/providers/hacker-news-web-runtime.ts +220 -0
- package/src/providers/hacker-news-web.ts +809 -0
- package/src/providers/imessage-direct-install.ts +354 -0
- package/src/providers/imessage-direct-messaging.ts +532 -0
- package/src/providers/imessage-direct-omni.ts +400 -0
- package/src/providers/imessage-direct-runtime.ts +1498 -0
- package/src/providers/imessage-direct.ts +351 -0
- package/src/providers/instagram-video-foundations.ts +913 -0
- package/src/providers/instagram-web-profile-browser.ts +600 -0
- package/src/providers/iso-bmff.ts +338 -0
- package/src/providers/linkedin-company-platform.ts +42 -0
- package/src/providers/linkedin-company-program.ts +88 -0
- package/src/providers/linkedin-contact-failure.ts +84 -0
- package/src/providers/linkedin-contact-platform.ts +45 -0
- package/src/providers/linkedin-contact-program.ts +91 -0
- package/src/providers/linkedin-profile-activity-failure.ts +76 -0
- package/src/providers/linkedin-profile-activity-platform.ts +41 -0
- package/src/providers/linkedin-profile-activity-program.ts +67 -0
- package/src/providers/linkedin-read-failure.ts +52 -0
- package/src/providers/linkedin-self-platform.ts +53 -0
- package/src/providers/linkedin-self-program.ts +122 -0
- package/src/providers/linkedin-web-article-browser.ts +794 -0
- package/src/providers/linkedin-web-bootstrap.ts +146 -0
- package/src/providers/linkedin-web-contact.ts +830 -0
- package/src/providers/linkedin-web-feed-browser.ts +832 -0
- package/src/providers/linkedin-web-feed.ts +864 -0
- package/src/providers/linkedin-web-post-browser.ts +653 -0
- package/src/providers/linkedin-web-profile-browser.ts +683 -0
- package/src/providers/linkedin-web-runtime.ts +2388 -0
- package/src/providers/linkedin-web.ts +3305 -0
- package/src/providers/linkedin.ts +1885 -0
- package/src/providers/meta-bootstrap.ts +1043 -0
- package/src/providers/meta-facebook-group.ts +690 -0
- package/src/providers/meta-marketplace-relay.ts +747 -0
- package/src/providers/meta-omni.ts +373 -0
- package/src/providers/meta-relay-bundle.ts +1049 -0
- package/src/providers/meta-web-descriptors.ts +1740 -0
- package/src/providers/meta-web-runtime.ts +3388 -0
- package/src/providers/meta-web.ts +2931 -0
- package/src/providers/read-failure.ts +139 -0
- package/src/providers/reddit-omni.ts +462 -0
- package/src/providers/reddit-web-runtime.ts +1929 -0
- package/src/providers/reddit-web.ts +1611 -0
- package/src/providers/rental-listings.ts +460 -0
- package/src/providers/substack-omni.ts +406 -0
- package/src/providers/substack-video-mp4.ts +33 -0
- package/src/providers/substack-web-runtime.ts +2812 -0
- package/src/providers/substack-web.ts +1502 -0
- package/src/providers/tiktok-video-mp4.ts +33 -0
- package/src/providers/tiktok-web-runtime.ts +770 -0
- package/src/providers/tiktok-web.ts +2786 -0
- package/src/providers/twitch-web-runtime.ts +226 -0
- package/src/providers/twitch-web.ts +274 -0
- package/src/providers/whatsapp-account-identity.ts +55 -0
- package/src/providers/whatsapp-contact-projection-helper.ts +874 -0
- package/src/providers/whatsapp-contact-projection-protocol.ts +403 -0
- package/src/providers/whatsapp-interaction-projection-helper.ts +1452 -0
- package/src/providers/whatsapp-interaction-projection-protocol.ts +462 -0
- package/src/providers/whatsapp-message-export-projection-protocol.ts +532 -0
- package/src/providers/whatsapp-messaging.ts +132 -0
- package/src/providers/whatsapp-omni.ts +586 -0
- package/src/providers/whatsapp-web-runtime.ts +3108 -0
- package/src/providers/whatsapp-web.ts +1037 -0
- package/src/providers/x-image-provenance.ts +523 -0
- package/src/providers/x-made-with-ai.ts +106 -0
- package/src/providers/x-transaction-id.ts +413 -0
- package/src/providers/x-web-runtime.ts +3485 -0
- package/src/providers/x-web.ts +2332 -0
- package/src/providers/x.ts +1889 -0
- package/src/providers/youtube-web-runtime.ts +1400 -0
- package/src/providers/youtube-web.ts +1221 -0
- package/src/read-admission-runtime.ts +91 -0
- package/src/read-client.ts +521 -0
- package/src/read-effect-platform.ts +7 -0
- package/src/read-effect-runtime.ts +14 -0
- package/src/read-effect.ts +52 -0
- package/src/read-projection-admission.ts +866 -0
- package/src/read-projections.ts +3151 -0
- package/src/recovery.ts +1492 -0
- package/src/reviewed-template.ts +146 -0
- package/src/run-journal.ts +1272 -0
- package/src/runtime.ts +5116 -0
- package/src/scripts/generate-provider-plugin-catalog.ts +121 -0
- package/src/scripts/install-whatsapp-protocol.sh +232 -0
- package/src/scripts/recover-install-stages.ts +1491 -0
- package/src/scripts/resolve-state-home.ts +30 -0
- package/src/scripts/scaffold-web-provider.ts +973 -0
- package/src/scripts/sync-bundled-adapters.ts +695 -0
- package/src/session-secrets.ts +1183 -0
- package/src/state-helper.bunfig.toml +2 -0
- package/src/state-helper.ts +2914 -0
- package/src/storage.ts +3842 -0
- package/src/transport-policy.ts +2 -0
- package/src/usage.ts +182 -0
- package/src/version.ts +2 -0
- package/src/web-session-authentication-policy.ts +136 -0
- package/src/web-session-cleanup-admission.ts +2344 -0
- package/src/web-session-cleanup-join.ts +99 -0
- package/src/web-session-client.ts +996 -0
- package/src/web-session-contract-definitions.ts +820 -0
- package/src/web-session-contract-planning.ts +42 -0
- package/src/web-session-contracts.ts +228 -0
- package/src/web-session-cookies.ts +500 -0
- package/src/web-session-execution.ts +385 -0
- package/src/web-session-http.ts +373 -0
- package/src/web-session-read-errors.ts +19 -0
- package/src/web-session-read-runtime.ts +124 -0
- package/src/web-session-recovery.ts +818 -0
- package/src/web-session-sites.ts +58 -0
- package/src/web-session-template.ts +994 -0
- package/src/web-session.ts +93 -0
- package/src/whatsapp-client-binding.ts +21 -0
- package/src/whatsapp-client-types.ts +75 -0
- package/src/whatsapp-client.ts +395 -0
- package/src/whatsapp-export-coordinate.ts +17 -0
- package/src/whatsapp-message-bundle-v2.ts +195 -0
- package/src/whatsapp-message-like-me-cli.ts +195 -0
- package/src/whatsapp-message-like-me-export.ts +1072 -0
- package/src/whatsapp-message-like-me-source.ts +1012 -0
- package/tsconfig.json +67 -0
package/src/storage.ts
ADDED
|
@@ -0,0 +1,3842 @@
|
|
|
1
|
+
import {
|
|
2
|
+
chmodSync,
|
|
3
|
+
closeSync,
|
|
4
|
+
constants,
|
|
5
|
+
existsSync,
|
|
6
|
+
fchmodSync,
|
|
7
|
+
fstatSync,
|
|
8
|
+
fsyncSync,
|
|
9
|
+
lstatSync,
|
|
10
|
+
mkdtempSync,
|
|
11
|
+
openSync,
|
|
12
|
+
opendirSync,
|
|
13
|
+
readSync,
|
|
14
|
+
realpathSync,
|
|
15
|
+
rmSync,
|
|
16
|
+
writeFileSync,
|
|
17
|
+
type BigIntStats,
|
|
18
|
+
type Dirent,
|
|
19
|
+
} from "node:fs";
|
|
20
|
+
import { spawnSync } from "node:child_process";
|
|
21
|
+
import { homedir, tmpdir } from "node:os";
|
|
22
|
+
import { basename, dirname, isAbsolute, join, parse, relative, resolve, sep } from "node:path";
|
|
23
|
+
import { fileURLToPath } from "node:url";
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
canonicalJson,
|
|
27
|
+
manifestHash,
|
|
28
|
+
parseDiagnosticManifest,
|
|
29
|
+
parseRuntimeManifest,
|
|
30
|
+
sha256,
|
|
31
|
+
type ParseResult,
|
|
32
|
+
type GhostgetManifest,
|
|
33
|
+
} from "./model";
|
|
34
|
+
import {
|
|
35
|
+
currentProcessStartIdentity,
|
|
36
|
+
processOwnerStatus,
|
|
37
|
+
type ProcessOwnerIdentity,
|
|
38
|
+
} from "./process-identity";
|
|
39
|
+
import type { ProviderPluginRegistry } from "./provider-plugin-registry";
|
|
40
|
+
|
|
41
|
+
function requireManifestRegistry(
|
|
42
|
+
registry: ProviderPluginRegistry | undefined,
|
|
43
|
+
): ProviderPluginRegistry {
|
|
44
|
+
if (registry === undefined) {
|
|
45
|
+
throw new Error("manifest validation requires an explicit provider plugin registry");
|
|
46
|
+
}
|
|
47
|
+
return registry;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const MAX_WRENCH_JSON_BYTES = 1024 * 1024;
|
|
51
|
+
export const MAX_PRIVATE_STATE_BATCH_FILES = 1_000;
|
|
52
|
+
export const MAX_PRIVATE_STATE_BATCH_FILE_BYTES = 2 * 1024 * 1024;
|
|
53
|
+
export const MAX_PRIVATE_STATE_BATCH_TOTAL_BYTES = 64 * 1024 * 1024;
|
|
54
|
+
const DEFAULT_PRIVATE_STATE_EXPECTED_CONTENT_BYTES = 2 * 1024 * 1024;
|
|
55
|
+
const MAX_PRIVATE_STATE_EXPECTED_CONTENT_BYTES = 4 * 1024 * 1024;
|
|
56
|
+
const MAX_PRIVATE_STATE_BATCH_NAME_BYTES = 256 * 1024;
|
|
57
|
+
const MAX_PRIVATE_STATE_BATCH_STDOUT_BYTES = 96 * 1024 * 1024;
|
|
58
|
+
const TEST_STATE_HELPER_TIMEOUT_MS = 120_000;
|
|
59
|
+
|
|
60
|
+
export interface PrivateDirectoryIdentity {
|
|
61
|
+
readonly device: string;
|
|
62
|
+
readonly inode: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
type StateRootIdentity = PrivateDirectoryIdentity;
|
|
66
|
+
|
|
67
|
+
type StateDirectoryExpectation = StateRootIdentity | null;
|
|
68
|
+
|
|
69
|
+
type EmptyDirectoryRemovalRaceForTest =
|
|
70
|
+
| "insert-after-quarantine"
|
|
71
|
+
| "replace-target-after-validation";
|
|
72
|
+
type PrivateStateBatchReadFaultForTest =
|
|
73
|
+
| "malformed-response"
|
|
74
|
+
| "remove-first-file-after-open"
|
|
75
|
+
| "replace-first-file-after-open"
|
|
76
|
+
| "replace-first-child-after-bind"
|
|
77
|
+
| "replace-directory-after-bind";
|
|
78
|
+
type PrivateStateCasFaultForTest =
|
|
79
|
+
| "fail-after-cas-commit"
|
|
80
|
+
| "pause-after-cas-claim"
|
|
81
|
+
| "pause-after-mutation-claim-read";
|
|
82
|
+
type StateHelperFaultForTest =
|
|
83
|
+
| EmptyDirectoryRemovalRaceForTest
|
|
84
|
+
| PrivateStateBatchReadFaultForTest
|
|
85
|
+
| PrivateStateCasFaultForTest;
|
|
86
|
+
|
|
87
|
+
interface StateRootRecord {
|
|
88
|
+
readonly claimed: boolean;
|
|
89
|
+
readonly creationAnchor: {
|
|
90
|
+
readonly identity: StateRootIdentity;
|
|
91
|
+
readonly path: string;
|
|
92
|
+
readonly segments: readonly string[];
|
|
93
|
+
} | null;
|
|
94
|
+
readonly identity: StateRootIdentity | null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const knownStateRoots = new Map<string, StateRootRecord>();
|
|
98
|
+
const stateDirectoryNames = [
|
|
99
|
+
"adapter-generations",
|
|
100
|
+
"adapters",
|
|
101
|
+
"auth",
|
|
102
|
+
"browser-snapshots",
|
|
103
|
+
"captures",
|
|
104
|
+
"derivations",
|
|
105
|
+
"idempotency",
|
|
106
|
+
"linked-device-stores",
|
|
107
|
+
"messaging",
|
|
108
|
+
"omni-read-projections",
|
|
109
|
+
"plan-assets",
|
|
110
|
+
"plans",
|
|
111
|
+
"provider-plugin-state",
|
|
112
|
+
"provider-plugins",
|
|
113
|
+
"read-projection-control",
|
|
114
|
+
"read-projections",
|
|
115
|
+
"recovery",
|
|
116
|
+
"run-journals",
|
|
117
|
+
"runs",
|
|
118
|
+
"session-secrets",
|
|
119
|
+
"tools",
|
|
120
|
+
] as const;
|
|
121
|
+
const stateMarkerName = ".io-state.json";
|
|
122
|
+
const stateMarkerText = '{"kind":"io-state","schemaVersion":1}\n';
|
|
123
|
+
const stateHelperPath = join(dirname(fileURLToPath(import.meta.url)), "state-helper.ts");
|
|
124
|
+
const stateHelperConfigPath = join(dirname(fileURLToPath(import.meta.url)), "state-helper.bunfig.toml");
|
|
125
|
+
const pathHelperPath = join(dirname(fileURLToPath(import.meta.url)), "path-helper.ts");
|
|
126
|
+
const ghostgetSourcePackageRoot = realpathSync(
|
|
127
|
+
resolve(dirname(fileURLToPath(import.meta.url)), ".."),
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
function isWithinPath(root: string, candidate: string): boolean {
|
|
131
|
+
const pathFromRoot = relative(root, candidate);
|
|
132
|
+
return pathFromRoot === ""
|
|
133
|
+
|| (
|
|
134
|
+
pathFromRoot !== ".."
|
|
135
|
+
&& !pathFromRoot.startsWith(`..${sep}`)
|
|
136
|
+
&& !isAbsolute(pathFromRoot)
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
type StateHelperOperation =
|
|
141
|
+
| { readonly kind: "claim" }
|
|
142
|
+
| { readonly kind: "create-root"; readonly segments: readonly string[] }
|
|
143
|
+
| {
|
|
144
|
+
readonly kind: "ensure-directories";
|
|
145
|
+
readonly segments: readonly string[];
|
|
146
|
+
readonly directoryExpectations: readonly StateDirectoryExpectation[];
|
|
147
|
+
}
|
|
148
|
+
| {
|
|
149
|
+
readonly kind: "read-file";
|
|
150
|
+
readonly segments: readonly string[];
|
|
151
|
+
readonly directoryExpectations: readonly StateDirectoryExpectation[];
|
|
152
|
+
readonly maximumBytes: number;
|
|
153
|
+
}
|
|
154
|
+
| {
|
|
155
|
+
readonly kind: "read-file-if-present";
|
|
156
|
+
readonly segments: readonly string[];
|
|
157
|
+
readonly directoryExpectations: readonly StateDirectoryExpectation[];
|
|
158
|
+
readonly maximumBytes: number;
|
|
159
|
+
}
|
|
160
|
+
| {
|
|
161
|
+
readonly kind: "batch-read-files";
|
|
162
|
+
readonly segments: readonly string[];
|
|
163
|
+
readonly directoryExpectations: readonly StateDirectoryExpectation[];
|
|
164
|
+
readonly names: readonly string[];
|
|
165
|
+
readonly maximumBytesPerFile: number;
|
|
166
|
+
readonly maximumTotalBytes: number;
|
|
167
|
+
}
|
|
168
|
+
| {
|
|
169
|
+
readonly kind: "batch-read-child-files";
|
|
170
|
+
readonly segments: readonly string[];
|
|
171
|
+
readonly directoryExpectations: readonly StateDirectoryExpectation[];
|
|
172
|
+
readonly files: readonly {
|
|
173
|
+
readonly directoryName: string;
|
|
174
|
+
readonly directoryIdentity: PrivateDirectoryIdentity;
|
|
175
|
+
readonly fileName: string;
|
|
176
|
+
}[];
|
|
177
|
+
readonly maximumBytesPerFile: number;
|
|
178
|
+
readonly maximumTotalBytes: number;
|
|
179
|
+
}
|
|
180
|
+
| {
|
|
181
|
+
readonly kind: "write-file";
|
|
182
|
+
readonly segments: readonly string[];
|
|
183
|
+
readonly directoryExpectations: readonly StateDirectoryExpectation[];
|
|
184
|
+
readonly content: string;
|
|
185
|
+
readonly createOnly: boolean;
|
|
186
|
+
readonly expectedContentSha256: string | null;
|
|
187
|
+
readonly maximumExpectedContentBytes: number;
|
|
188
|
+
}
|
|
189
|
+
| {
|
|
190
|
+
readonly kind: "create-directory";
|
|
191
|
+
readonly segments: readonly string[];
|
|
192
|
+
readonly directoryExpectations: readonly StateDirectoryExpectation[];
|
|
193
|
+
}
|
|
194
|
+
| {
|
|
195
|
+
readonly kind: "remove-file";
|
|
196
|
+
readonly segments: readonly string[];
|
|
197
|
+
readonly directoryExpectations: readonly StateDirectoryExpectation[];
|
|
198
|
+
}
|
|
199
|
+
| {
|
|
200
|
+
readonly kind: "remove-file-if-unchanged";
|
|
201
|
+
readonly segments: readonly string[];
|
|
202
|
+
readonly directoryExpectations: readonly StateDirectoryExpectation[];
|
|
203
|
+
readonly expectedContentSha256: string;
|
|
204
|
+
}
|
|
205
|
+
| {
|
|
206
|
+
readonly kind: "remove-empty-directory";
|
|
207
|
+
readonly segments: readonly string[];
|
|
208
|
+
readonly directoryExpectations: readonly StateDirectoryExpectation[];
|
|
209
|
+
}
|
|
210
|
+
| {
|
|
211
|
+
readonly kind: "list-directory";
|
|
212
|
+
readonly segments: readonly string[];
|
|
213
|
+
readonly directoryExpectations: readonly StateDirectoryExpectation[];
|
|
214
|
+
readonly recoverOrphanedMutationClaims: boolean;
|
|
215
|
+
}
|
|
216
|
+
| {
|
|
217
|
+
readonly kind: "remove-directory-tree";
|
|
218
|
+
readonly segments: readonly string[];
|
|
219
|
+
readonly directoryExpectations: readonly StateDirectoryExpectation[];
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
export type PrivateStateDirectoryEntry = {
|
|
223
|
+
readonly name: string;
|
|
224
|
+
readonly kind: "file" | "directory" | "symbolic-link" | "other";
|
|
225
|
+
readonly identity?: PrivateDirectoryIdentity;
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
export type PrivateStateDirectorySnapshot = {
|
|
229
|
+
readonly identity: PrivateDirectoryIdentity | null;
|
|
230
|
+
readonly entries: readonly PrivateStateDirectoryEntry[];
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
export type PrivateStateBatchReadInvalidReason =
|
|
234
|
+
| StateHelperBatchReadInvalidReason
|
|
235
|
+
| "invalid-utf8";
|
|
236
|
+
|
|
237
|
+
export type PrivateStateBatchReadResult =
|
|
238
|
+
| {
|
|
239
|
+
readonly name: string;
|
|
240
|
+
readonly status: "present";
|
|
241
|
+
readonly content: string;
|
|
242
|
+
}
|
|
243
|
+
| {
|
|
244
|
+
readonly name: string;
|
|
245
|
+
readonly status: "absent";
|
|
246
|
+
}
|
|
247
|
+
| {
|
|
248
|
+
readonly name: string;
|
|
249
|
+
readonly status: "invalid";
|
|
250
|
+
readonly reason: PrivateStateBatchReadInvalidReason;
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
export type PrivateStateBatchChildFile = {
|
|
254
|
+
readonly directoryName: string;
|
|
255
|
+
readonly directoryIdentity: PrivateDirectoryIdentity;
|
|
256
|
+
readonly fileName: string;
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
export type PrivateStateBatchChildReadResult =
|
|
260
|
+
| {
|
|
261
|
+
readonly directoryName: string;
|
|
262
|
+
readonly fileName: string;
|
|
263
|
+
readonly status: "present";
|
|
264
|
+
readonly content: string;
|
|
265
|
+
}
|
|
266
|
+
| {
|
|
267
|
+
readonly directoryName: string;
|
|
268
|
+
readonly fileName: string;
|
|
269
|
+
readonly status: "absent";
|
|
270
|
+
}
|
|
271
|
+
| {
|
|
272
|
+
readonly directoryName: string;
|
|
273
|
+
readonly fileName: string;
|
|
274
|
+
readonly status: "invalid";
|
|
275
|
+
readonly reason: PrivateStateBatchReadInvalidReason;
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
type StateHelperBatchReadInvalidReason =
|
|
279
|
+
| "unsafe-file"
|
|
280
|
+
| "unreadable"
|
|
281
|
+
| "file-byte-bound"
|
|
282
|
+
| "aggregate-byte-bound"
|
|
283
|
+
| "changed-during-read";
|
|
284
|
+
type StateHelperBatchReadFile =
|
|
285
|
+
| {
|
|
286
|
+
readonly name: string;
|
|
287
|
+
readonly status: "present";
|
|
288
|
+
readonly contentBase64: string;
|
|
289
|
+
}
|
|
290
|
+
| {
|
|
291
|
+
readonly name: string;
|
|
292
|
+
readonly status: "absent";
|
|
293
|
+
}
|
|
294
|
+
| {
|
|
295
|
+
readonly name: string;
|
|
296
|
+
readonly status: "invalid";
|
|
297
|
+
readonly reason: StateHelperBatchReadInvalidReason;
|
|
298
|
+
};
|
|
299
|
+
type StateHelperBatchChildReadFile =
|
|
300
|
+
| {
|
|
301
|
+
readonly directoryName: string;
|
|
302
|
+
readonly fileName: string;
|
|
303
|
+
readonly status: "present";
|
|
304
|
+
readonly contentBase64: string;
|
|
305
|
+
}
|
|
306
|
+
| {
|
|
307
|
+
readonly directoryName: string;
|
|
308
|
+
readonly fileName: string;
|
|
309
|
+
readonly status: "absent";
|
|
310
|
+
}
|
|
311
|
+
| {
|
|
312
|
+
readonly directoryName: string;
|
|
313
|
+
readonly fileName: string;
|
|
314
|
+
readonly status: "invalid";
|
|
315
|
+
readonly reason: StateHelperBatchReadInvalidReason;
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
interface StateHelperResponse {
|
|
319
|
+
readonly ok: true;
|
|
320
|
+
readonly identity: StateRootIdentity;
|
|
321
|
+
readonly created?: boolean;
|
|
322
|
+
readonly removed?: boolean;
|
|
323
|
+
readonly present?: boolean;
|
|
324
|
+
readonly contentBase64?: string;
|
|
325
|
+
readonly entries?: readonly PrivateStateDirectoryEntry[];
|
|
326
|
+
readonly files?: readonly StateHelperBatchReadFile[];
|
|
327
|
+
readonly childFiles?: readonly StateHelperBatchChildReadFile[];
|
|
328
|
+
readonly targetIdentity?: PrivateDirectoryIdentity;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
type PathHelperOperation =
|
|
332
|
+
| {
|
|
333
|
+
readonly kind: "ensure-directories";
|
|
334
|
+
readonly segments: readonly string[];
|
|
335
|
+
readonly directoryExpectations: readonly StateDirectoryExpectation[];
|
|
336
|
+
readonly requireFinalPrivate: boolean;
|
|
337
|
+
}
|
|
338
|
+
| {
|
|
339
|
+
readonly kind: "read-file";
|
|
340
|
+
readonly segments: readonly string[];
|
|
341
|
+
readonly directoryExpectations: readonly StateDirectoryExpectation[];
|
|
342
|
+
readonly maximumBytes: number;
|
|
343
|
+
}
|
|
344
|
+
| {
|
|
345
|
+
readonly kind: "write-file";
|
|
346
|
+
readonly segments: readonly string[];
|
|
347
|
+
readonly directoryExpectations: readonly StateDirectoryExpectation[];
|
|
348
|
+
readonly content: string;
|
|
349
|
+
readonly createOnly: boolean;
|
|
350
|
+
readonly expectedContentSha256?: string | null;
|
|
351
|
+
readonly maximumExpectedContentBytes?: number;
|
|
352
|
+
}
|
|
353
|
+
| {
|
|
354
|
+
readonly kind: "remove-directory-tree";
|
|
355
|
+
readonly segments: readonly string[];
|
|
356
|
+
readonly directoryExpectations: readonly StateDirectoryExpectation[];
|
|
357
|
+
readonly expectedTargetBirthtimeNs: string | null;
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
function hasCode(error: unknown, code: string): boolean {
|
|
361
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === code;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function readDescriptorBounded(descriptor: number, maximumBytes: number): Buffer {
|
|
365
|
+
const chunks: Buffer[] = [];
|
|
366
|
+
const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, maximumBytes + 1));
|
|
367
|
+
let total = 0;
|
|
368
|
+
for (;;) {
|
|
369
|
+
const remaining = maximumBytes + 1 - total;
|
|
370
|
+
const count = readSync(descriptor, buffer, 0, Math.min(buffer.byteLength, remaining), null);
|
|
371
|
+
if (count === 0) return Buffer.concat(chunks, total);
|
|
372
|
+
total += count;
|
|
373
|
+
if (total > maximumBytes) throw new Error("file grew beyond its byte bound");
|
|
374
|
+
chunks.push(Buffer.from(buffer.subarray(0, count)));
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function pathInside(root: string, target: string): boolean {
|
|
379
|
+
const child = relative(root, target);
|
|
380
|
+
return child === "" || (!isAbsolute(child) && child !== ".." && !child.startsWith(`..${sep}`));
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function stateRootFor(path: string): string | null {
|
|
384
|
+
const lexicalTarget = resolve(path);
|
|
385
|
+
let selected: string | null = null;
|
|
386
|
+
for (const root of knownStateRoots.keys()) {
|
|
387
|
+
if (pathInside(root, lexicalTarget) && (selected === null || root.length > selected.length)) selected = root;
|
|
388
|
+
}
|
|
389
|
+
if (selected !== null) return selected;
|
|
390
|
+
const target = canonicalNonStatePath(path);
|
|
391
|
+
for (const root of knownStateRoots.keys()) {
|
|
392
|
+
if (pathInside(root, target) && (selected === null || root.length > selected.length)) selected = root;
|
|
393
|
+
}
|
|
394
|
+
return selected;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** True when a path resolves at or below the selected, validated Ghostget state root. */
|
|
398
|
+
export function isGhostgetStatePath(
|
|
399
|
+
path: string,
|
|
400
|
+
environment: Readonly<Record<string, string | undefined>> = process.env,
|
|
401
|
+
): boolean {
|
|
402
|
+
const root = ghostgetStateHome(environment);
|
|
403
|
+
return stateRootFor(path) === root;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function sameIdentity(left: StateRootIdentity | null, right: StateRootIdentity | null): boolean {
|
|
407
|
+
return left === null || right === null
|
|
408
|
+
? left === right
|
|
409
|
+
: left.device === right.device && left.inode === right.inode;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function exactObjectKeys(value: Record<string, unknown>, required: readonly string[], optional: readonly string[] = []): boolean {
|
|
413
|
+
const allowed = new Set([...required, ...optional]);
|
|
414
|
+
return required.every((key) => Object.hasOwn(value, key))
|
|
415
|
+
&& Object.keys(value).every((key) => allowed.has(key));
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
419
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function isArrayAtRuntime(value: unknown): boolean {
|
|
423
|
+
return Array.isArray(value);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function isSafeBatchFileName(value: unknown): value is string {
|
|
427
|
+
if (
|
|
428
|
+
typeof value !== "string"
|
|
429
|
+
|| value === ""
|
|
430
|
+
|| value === "."
|
|
431
|
+
|| value === ".."
|
|
432
|
+
|| value.includes("/")
|
|
433
|
+
|| value.includes("\\")
|
|
434
|
+
|| value.includes("\uFFFD")
|
|
435
|
+
|| Buffer.byteLength(value, "utf8") > 255
|
|
436
|
+
) return false;
|
|
437
|
+
for (const character of value) {
|
|
438
|
+
const codePoint = character.codePointAt(0);
|
|
439
|
+
if (codePoint !== undefined && (codePoint <= 0x1f || codePoint === 0x7f)) {
|
|
440
|
+
return false;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
return true;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function decodeCanonicalBase64(
|
|
447
|
+
value: unknown,
|
|
448
|
+
maximumBytes: number,
|
|
449
|
+
): Buffer | null {
|
|
450
|
+
if (
|
|
451
|
+
typeof value !== "string"
|
|
452
|
+
|| value.length > Math.ceil(maximumBytes / 3) * 4 + 4
|
|
453
|
+
|| !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(value)
|
|
454
|
+
) return null;
|
|
455
|
+
const decoded = Buffer.from(value, "base64");
|
|
456
|
+
return decoded.byteLength <= maximumBytes
|
|
457
|
+
&& decoded.toString("base64") === value
|
|
458
|
+
? decoded
|
|
459
|
+
: null;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function parseStateHelperBatchFiles(value: unknown): readonly StateHelperBatchReadFile[] {
|
|
463
|
+
if (!Array.isArray(value) || value.length > MAX_PRIVATE_STATE_BATCH_FILES) {
|
|
464
|
+
throw new Error("state helper returned a malformed response");
|
|
465
|
+
}
|
|
466
|
+
const names = new Set<string>();
|
|
467
|
+
const files: StateHelperBatchReadFile[] = [];
|
|
468
|
+
let contentBytes = 0;
|
|
469
|
+
const invalidReasons = new Set<StateHelperBatchReadInvalidReason>([
|
|
470
|
+
"unsafe-file",
|
|
471
|
+
"unreadable",
|
|
472
|
+
"file-byte-bound",
|
|
473
|
+
"aggregate-byte-bound",
|
|
474
|
+
"changed-during-read",
|
|
475
|
+
]);
|
|
476
|
+
for (const candidate of value) {
|
|
477
|
+
if (!isRecord(candidate) || !isSafeBatchFileName(candidate.name)) {
|
|
478
|
+
throw new Error("state helper returned a malformed response");
|
|
479
|
+
}
|
|
480
|
+
const name = candidate.name;
|
|
481
|
+
if (names.has(name)) {
|
|
482
|
+
throw new Error("state helper returned a malformed response");
|
|
483
|
+
}
|
|
484
|
+
names.add(name);
|
|
485
|
+
if (
|
|
486
|
+
candidate.status === "present"
|
|
487
|
+
&& exactObjectKeys(candidate, ["name", "status", "contentBase64"])
|
|
488
|
+
) {
|
|
489
|
+
const content = decodeCanonicalBase64(
|
|
490
|
+
candidate.contentBase64,
|
|
491
|
+
MAX_PRIVATE_STATE_BATCH_FILE_BYTES,
|
|
492
|
+
);
|
|
493
|
+
if (content === null) {
|
|
494
|
+
throw new Error("state helper returned a malformed response");
|
|
495
|
+
}
|
|
496
|
+
contentBytes += content.byteLength;
|
|
497
|
+
if (contentBytes > MAX_PRIVATE_STATE_BATCH_TOTAL_BYTES) {
|
|
498
|
+
throw new Error("state helper returned a malformed response");
|
|
499
|
+
}
|
|
500
|
+
files.push({
|
|
501
|
+
name,
|
|
502
|
+
status: "present",
|
|
503
|
+
contentBase64: candidate.contentBase64 as string,
|
|
504
|
+
});
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
if (
|
|
508
|
+
candidate.status === "absent"
|
|
509
|
+
&& exactObjectKeys(candidate, ["name", "status"])
|
|
510
|
+
) {
|
|
511
|
+
files.push({ name, status: "absent" });
|
|
512
|
+
continue;
|
|
513
|
+
}
|
|
514
|
+
if (
|
|
515
|
+
candidate.status === "invalid"
|
|
516
|
+
&& exactObjectKeys(candidate, ["name", "status", "reason"])
|
|
517
|
+
&& invalidReasons.has(candidate.reason as StateHelperBatchReadInvalidReason)
|
|
518
|
+
) {
|
|
519
|
+
files.push({
|
|
520
|
+
name,
|
|
521
|
+
status: "invalid",
|
|
522
|
+
reason: candidate.reason as StateHelperBatchReadInvalidReason,
|
|
523
|
+
});
|
|
524
|
+
continue;
|
|
525
|
+
}
|
|
526
|
+
throw new Error("state helper returned a malformed response");
|
|
527
|
+
}
|
|
528
|
+
return files;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function parseStateHelperBatchChildFiles(
|
|
532
|
+
value: unknown,
|
|
533
|
+
): readonly StateHelperBatchChildReadFile[] {
|
|
534
|
+
if (!Array.isArray(value) || value.length > MAX_PRIVATE_STATE_BATCH_FILES) {
|
|
535
|
+
throw new Error("state helper returned a malformed response");
|
|
536
|
+
}
|
|
537
|
+
const keys = new Set<string>();
|
|
538
|
+
const files: StateHelperBatchChildReadFile[] = [];
|
|
539
|
+
let contentBytes = 0;
|
|
540
|
+
const invalidReasons = new Set<StateHelperBatchReadInvalidReason>([
|
|
541
|
+
"unsafe-file",
|
|
542
|
+
"unreadable",
|
|
543
|
+
"file-byte-bound",
|
|
544
|
+
"aggregate-byte-bound",
|
|
545
|
+
"changed-during-read",
|
|
546
|
+
]);
|
|
547
|
+
for (const candidate of value) {
|
|
548
|
+
if (
|
|
549
|
+
!isRecord(candidate)
|
|
550
|
+
|| !isSafeBatchFileName(candidate.directoryName)
|
|
551
|
+
|| !isSafeBatchFileName(candidate.fileName)
|
|
552
|
+
) {
|
|
553
|
+
throw new Error("state helper returned a malformed response");
|
|
554
|
+
}
|
|
555
|
+
const directoryName = candidate.directoryName;
|
|
556
|
+
const fileName = candidate.fileName;
|
|
557
|
+
const key = `${directoryName}\u0000${fileName}`;
|
|
558
|
+
if (keys.has(key)) {
|
|
559
|
+
throw new Error("state helper returned a malformed response");
|
|
560
|
+
}
|
|
561
|
+
keys.add(key);
|
|
562
|
+
if (
|
|
563
|
+
candidate.status === "present"
|
|
564
|
+
&& exactObjectKeys(candidate, [
|
|
565
|
+
"directoryName",
|
|
566
|
+
"fileName",
|
|
567
|
+
"status",
|
|
568
|
+
"contentBase64",
|
|
569
|
+
])
|
|
570
|
+
) {
|
|
571
|
+
const content = decodeCanonicalBase64(
|
|
572
|
+
candidate.contentBase64,
|
|
573
|
+
MAX_PRIVATE_STATE_BATCH_FILE_BYTES,
|
|
574
|
+
);
|
|
575
|
+
if (content === null) {
|
|
576
|
+
throw new Error("state helper returned a malformed response");
|
|
577
|
+
}
|
|
578
|
+
contentBytes += content.byteLength;
|
|
579
|
+
if (contentBytes > MAX_PRIVATE_STATE_BATCH_TOTAL_BYTES) {
|
|
580
|
+
throw new Error("state helper returned a malformed response");
|
|
581
|
+
}
|
|
582
|
+
files.push({
|
|
583
|
+
directoryName,
|
|
584
|
+
fileName,
|
|
585
|
+
status: "present",
|
|
586
|
+
contentBase64: candidate.contentBase64 as string,
|
|
587
|
+
});
|
|
588
|
+
continue;
|
|
589
|
+
}
|
|
590
|
+
if (
|
|
591
|
+
candidate.status === "absent"
|
|
592
|
+
&& exactObjectKeys(candidate, [
|
|
593
|
+
"directoryName",
|
|
594
|
+
"fileName",
|
|
595
|
+
"status",
|
|
596
|
+
])
|
|
597
|
+
) {
|
|
598
|
+
files.push({ directoryName, fileName, status: "absent" });
|
|
599
|
+
continue;
|
|
600
|
+
}
|
|
601
|
+
if (
|
|
602
|
+
candidate.status === "invalid"
|
|
603
|
+
&& exactObjectKeys(candidate, [
|
|
604
|
+
"directoryName",
|
|
605
|
+
"fileName",
|
|
606
|
+
"status",
|
|
607
|
+
"reason",
|
|
608
|
+
])
|
|
609
|
+
&& invalidReasons.has(
|
|
610
|
+
candidate.reason as StateHelperBatchReadInvalidReason,
|
|
611
|
+
)
|
|
612
|
+
) {
|
|
613
|
+
files.push({
|
|
614
|
+
directoryName,
|
|
615
|
+
fileName,
|
|
616
|
+
status: "invalid",
|
|
617
|
+
reason: candidate.reason as StateHelperBatchReadInvalidReason,
|
|
618
|
+
});
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
621
|
+
throw new Error("state helper returned a malformed response");
|
|
622
|
+
}
|
|
623
|
+
return files;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function parseResponseIdentity(value: unknown): PrivateDirectoryIdentity | null {
|
|
627
|
+
if (
|
|
628
|
+
!isRecord(value)
|
|
629
|
+
|| !exactObjectKeys(value, ["device", "inode"])
|
|
630
|
+
|| typeof value.device !== "string"
|
|
631
|
+
|| !/^\d{1,40}$/u.test(value.device)
|
|
632
|
+
|| typeof value.inode !== "string"
|
|
633
|
+
|| !/^\d{1,40}$/u.test(value.inode)
|
|
634
|
+
) return null;
|
|
635
|
+
return { device: value.device, inode: value.inode };
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function parseStateHelperResponse(value: unknown): StateHelperResponse {
|
|
639
|
+
if (!isRecord(value) || !exactObjectKeys(
|
|
640
|
+
value,
|
|
641
|
+
["ok", "identity"],
|
|
642
|
+
["created", "removed", "present", "contentBase64", "entries", "files", "childFiles", "targetIdentity"],
|
|
643
|
+
)) {
|
|
644
|
+
throw new Error("state helper returned a malformed response");
|
|
645
|
+
}
|
|
646
|
+
const identityValue = value.identity;
|
|
647
|
+
const parsedIdentity = parseResponseIdentity(identityValue);
|
|
648
|
+
if (value.ok !== true || parsedIdentity === null) throw new Error("state helper returned a malformed response");
|
|
649
|
+
const created = value.created;
|
|
650
|
+
const removed = value.removed;
|
|
651
|
+
const present = value.present;
|
|
652
|
+
const contentBase64 = value.contentBase64;
|
|
653
|
+
const entries = value.entries;
|
|
654
|
+
const files = value.files;
|
|
655
|
+
const childFiles = value.childFiles;
|
|
656
|
+
const targetIdentityValue = value.targetIdentity;
|
|
657
|
+
const targetIdentity = targetIdentityValue === undefined ? undefined : parseResponseIdentity(targetIdentityValue);
|
|
658
|
+
if (created !== undefined && typeof created !== "boolean") throw new Error("state helper returned a malformed response");
|
|
659
|
+
if (removed !== undefined && typeof removed !== "boolean") throw new Error("state helper returned a malformed response");
|
|
660
|
+
if (present !== undefined && typeof present !== "boolean") throw new Error("state helper returned a malformed response");
|
|
661
|
+
if (contentBase64 !== undefined && typeof contentBase64 !== "string") throw new Error("state helper returned a malformed response");
|
|
662
|
+
if (targetIdentityValue !== undefined && targetIdentity === null) {
|
|
663
|
+
throw new Error("state helper returned a malformed response");
|
|
664
|
+
}
|
|
665
|
+
const responseShape = Object.keys(value)
|
|
666
|
+
.filter((key) => key !== "ok" && key !== "identity")
|
|
667
|
+
.sort()
|
|
668
|
+
.join(",");
|
|
669
|
+
if (!new Set([
|
|
670
|
+
"",
|
|
671
|
+
"contentBase64",
|
|
672
|
+
"contentBase64,present",
|
|
673
|
+
"created",
|
|
674
|
+
"created,targetIdentity",
|
|
675
|
+
"entries",
|
|
676
|
+
"entries,targetIdentity",
|
|
677
|
+
"childFiles,targetIdentity",
|
|
678
|
+
"files,targetIdentity",
|
|
679
|
+
"present",
|
|
680
|
+
"removed",
|
|
681
|
+
"targetIdentity",
|
|
682
|
+
]).has(responseShape)) throw new Error("state helper returned a malformed response");
|
|
683
|
+
if ((present === true) !== (responseShape === "contentBase64,present")) {
|
|
684
|
+
if (present !== undefined) throw new Error("state helper returned a malformed response");
|
|
685
|
+
}
|
|
686
|
+
if (
|
|
687
|
+
entries !== undefined
|
|
688
|
+
&& (
|
|
689
|
+
!Array.isArray(entries)
|
|
690
|
+
|| entries.length > 10_000
|
|
691
|
+
|| entries.some((entry) => (
|
|
692
|
+
!isRecord(entry)
|
|
693
|
+
|| !exactObjectKeys(entry, ["name", "kind"], ["identity"])
|
|
694
|
+
|| typeof entry.name !== "string"
|
|
695
|
+
|| entry.name === ""
|
|
696
|
+
|| entry.name === "."
|
|
697
|
+
|| entry.name === ".."
|
|
698
|
+
|| entry.name.includes("/")
|
|
699
|
+
|| entry.name.includes("\\")
|
|
700
|
+
|| entry.name.includes("\u0000")
|
|
701
|
+
|| Buffer.byteLength(entry.name, "utf8") > 255
|
|
702
|
+
|| (entry.kind !== "file" && entry.kind !== "directory" && entry.kind !== "symbolic-link" && entry.kind !== "other")
|
|
703
|
+
|| (entry.kind === "directory") !== (parseResponseIdentity(entry.identity) !== null)
|
|
704
|
+
))
|
|
705
|
+
)
|
|
706
|
+
) throw new Error("state helper returned a malformed response");
|
|
707
|
+
const parsedFiles = files === undefined
|
|
708
|
+
? undefined
|
|
709
|
+
: parseStateHelperBatchFiles(files);
|
|
710
|
+
const parsedChildFiles = childFiles === undefined
|
|
711
|
+
? undefined
|
|
712
|
+
: parseStateHelperBatchChildFiles(childFiles);
|
|
713
|
+
return {
|
|
714
|
+
ok: true,
|
|
715
|
+
identity: parsedIdentity,
|
|
716
|
+
...(created === undefined ? {} : { created }),
|
|
717
|
+
...(removed === undefined ? {} : { removed }),
|
|
718
|
+
...(present === undefined ? {} : { present }),
|
|
719
|
+
...(contentBase64 === undefined ? {} : { contentBase64 }),
|
|
720
|
+
...(entries === undefined ? {} : { entries: entries as PrivateStateDirectoryEntry[] }),
|
|
721
|
+
...(parsedFiles === undefined ? {} : { files: parsedFiles }),
|
|
722
|
+
...(parsedChildFiles === undefined ? {} : { childFiles: parsedChildFiles }),
|
|
723
|
+
...(targetIdentity === undefined || targetIdentity === null ? {} : { targetIdentity }),
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function runStateHelper(
|
|
728
|
+
directory: string,
|
|
729
|
+
expected: StateRootIdentity,
|
|
730
|
+
operation: StateHelperOperation,
|
|
731
|
+
expectCreatedIdentity = false,
|
|
732
|
+
faultForTest?: StateHelperFaultForTest,
|
|
733
|
+
): StateHelperResponse {
|
|
734
|
+
// Generate before cwd binding so deterministic fault injection can force the
|
|
735
|
+
// exact pre-spawn swap that the helper must reject without touching it.
|
|
736
|
+
const requestId = crypto.randomUUID();
|
|
737
|
+
const child = spawnSync(process.execPath, [
|
|
738
|
+
"--no-env-file",
|
|
739
|
+
"--no-install",
|
|
740
|
+
"--no-macros",
|
|
741
|
+
"--no-addons",
|
|
742
|
+
`--config=${stateHelperConfigPath}`,
|
|
743
|
+
stateHelperPath,
|
|
744
|
+
], {
|
|
745
|
+
cwd: directory,
|
|
746
|
+
encoding: "utf8",
|
|
747
|
+
env: faultForTest === undefined
|
|
748
|
+
? { NODE_ENV: "production" }
|
|
749
|
+
: {
|
|
750
|
+
NODE_ENV: "test",
|
|
751
|
+
...(faultForTest === "insert-after-quarantine"
|
|
752
|
+
|| faultForTest === "replace-target-after-validation"
|
|
753
|
+
? { GHOSTGET_TEST_EMPTY_DIRECTORY_REMOVAL_RACE: faultForTest }
|
|
754
|
+
: faultForTest === "pause-after-cas-claim"
|
|
755
|
+
|| faultForTest === "pause-after-mutation-claim-read"
|
|
756
|
+
|| faultForTest === "fail-after-cas-commit"
|
|
757
|
+
? { GHOSTGET_TEST_CAS_FAULT: faultForTest }
|
|
758
|
+
: { GHOSTGET_TEST_BATCH_READ_FAULT: faultForTest }),
|
|
759
|
+
},
|
|
760
|
+
input: JSON.stringify({ schemaVersion: 1, requestId, expected, operation }),
|
|
761
|
+
maxBuffer: operation.kind === "batch-read-files"
|
|
762
|
+
|| operation.kind === "batch-read-child-files"
|
|
763
|
+
? MAX_PRIVATE_STATE_BATCH_STDOUT_BYTES
|
|
764
|
+
: 180 * 1024 * 1024,
|
|
765
|
+
shell: false,
|
|
766
|
+
timeout: faultForTest === "pause-after-cas-claim"
|
|
767
|
+
|| faultForTest === "pause-after-mutation-claim-read"
|
|
768
|
+
? TEST_STATE_HELPER_TIMEOUT_MS
|
|
769
|
+
: 30_000,
|
|
770
|
+
windowsHide: true,
|
|
771
|
+
});
|
|
772
|
+
const current = inspectRealDirectoryIdentity(directory);
|
|
773
|
+
if (!sameIdentity(current, expected)) throw new Error(`GHOSTGET_STATE_HOME changed identity after validation: ${directory}`);
|
|
774
|
+
if (child.error !== undefined) throw new Error("bound state helper failed to start", { cause: child.error });
|
|
775
|
+
if (child.status !== 0) {
|
|
776
|
+
const detail = child.stderr.trim().slice(0, 512);
|
|
777
|
+
throw new Error(detail === "" ? "bound state helper rejected the operation" : detail);
|
|
778
|
+
}
|
|
779
|
+
let parsed: unknown;
|
|
780
|
+
try {
|
|
781
|
+
parsed = JSON.parse(child.stdout) as unknown;
|
|
782
|
+
} catch (error) {
|
|
783
|
+
throw new Error("state helper returned invalid JSON", { cause: error });
|
|
784
|
+
}
|
|
785
|
+
const response = parseStateHelperResponse(parsed);
|
|
786
|
+
if (!expectCreatedIdentity && !sameIdentity(response.identity, expected)) {
|
|
787
|
+
throw new Error("state helper response came from the wrong directory identity");
|
|
788
|
+
}
|
|
789
|
+
return response;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
function runPathHelper(
|
|
793
|
+
directory: string,
|
|
794
|
+
expected: StateRootIdentity,
|
|
795
|
+
operation: PathHelperOperation,
|
|
796
|
+
): StateHelperResponse {
|
|
797
|
+
const requestId = crypto.randomUUID();
|
|
798
|
+
const child = spawnSync(process.execPath, [
|
|
799
|
+
"--no-env-file",
|
|
800
|
+
"--no-install",
|
|
801
|
+
"--no-macros",
|
|
802
|
+
"--no-addons",
|
|
803
|
+
`--config=${stateHelperConfigPath}`,
|
|
804
|
+
pathHelperPath,
|
|
805
|
+
], {
|
|
806
|
+
cwd: directory,
|
|
807
|
+
encoding: "utf8",
|
|
808
|
+
env: { NODE_ENV: "production" },
|
|
809
|
+
input: JSON.stringify({ schemaVersion: 1, requestId, expected, operation }),
|
|
810
|
+
maxBuffer: 180 * 1024 * 1024,
|
|
811
|
+
shell: false,
|
|
812
|
+
timeout: 30_000,
|
|
813
|
+
windowsHide: true,
|
|
814
|
+
});
|
|
815
|
+
const current = inspectRealDirectoryIdentity(directory);
|
|
816
|
+
if (!sameIdentity(current, expected)) throw new Error(`bound path root changed identity after validation: ${directory}`);
|
|
817
|
+
if (child.error !== undefined) throw new Error("bound path helper failed to start", { cause: child.error });
|
|
818
|
+
if (child.status !== 0) {
|
|
819
|
+
const detail = child.stderr.trim().slice(0, 512);
|
|
820
|
+
throw new Error(detail === "" ? "bound path helper rejected the operation" : detail);
|
|
821
|
+
}
|
|
822
|
+
let parsed: unknown;
|
|
823
|
+
try {
|
|
824
|
+
parsed = JSON.parse(child.stdout) as unknown;
|
|
825
|
+
} catch (error) {
|
|
826
|
+
throw new Error("path helper returned invalid JSON", { cause: error });
|
|
827
|
+
}
|
|
828
|
+
const response = parseStateHelperResponse(parsed);
|
|
829
|
+
if (!sameIdentity(response.identity, expected)) throw new Error("path helper response came from the wrong directory identity");
|
|
830
|
+
return response;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
function stateSegments(root: string, path: string): readonly string[] {
|
|
834
|
+
const child = relative(root, canonicalNonStatePath(path));
|
|
835
|
+
if (child === "") return [];
|
|
836
|
+
if (isAbsolute(child) || child === ".." || child.startsWith(`..${sep}`)) throw new Error(`state path escapes its root: ${path}`);
|
|
837
|
+
return child.split(sep);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/**
|
|
841
|
+
* Snapshot every existing directory identity before the helper process is
|
|
842
|
+
* spawned. The helper replays this chain from its inode-bound cwd, so a rename
|
|
843
|
+
* or replacement at any depth is rejected before the requested mutation.
|
|
844
|
+
*/
|
|
845
|
+
function captureStateDirectoryExpectations(
|
|
846
|
+
root: string,
|
|
847
|
+
segments: readonly string[],
|
|
848
|
+
): readonly StateDirectoryExpectation[] {
|
|
849
|
+
const expectations: StateDirectoryExpectation[] = [];
|
|
850
|
+
let current = root;
|
|
851
|
+
let missing = false;
|
|
852
|
+
for (const segment of segments) {
|
|
853
|
+
current = join(current, segment);
|
|
854
|
+
if (missing) {
|
|
855
|
+
expectations.push(null);
|
|
856
|
+
continue;
|
|
857
|
+
}
|
|
858
|
+
let stats: BigIntStats;
|
|
859
|
+
try {
|
|
860
|
+
stats = lstatSync(current, { bigint: true });
|
|
861
|
+
} catch (error) {
|
|
862
|
+
if (!hasCode(error, "ENOENT")) throw error;
|
|
863
|
+
missing = true;
|
|
864
|
+
expectations.push(null);
|
|
865
|
+
continue;
|
|
866
|
+
}
|
|
867
|
+
if (
|
|
868
|
+
!stats.isDirectory()
|
|
869
|
+
|| stats.isSymbolicLink()
|
|
870
|
+
|| !ownedByCurrentUser(stats)
|
|
871
|
+
|| (stats.mode & 0o777n) !== 0o700n
|
|
872
|
+
) {
|
|
873
|
+
throw new Error(`ghostget state directory must be an owned real directory with mode 0700: ${current}`);
|
|
874
|
+
}
|
|
875
|
+
expectations.push({ device: stats.dev.toString(), inode: stats.ino.toString() });
|
|
876
|
+
}
|
|
877
|
+
return expectations;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
function inspectStateRootIdentity(root: string): StateRootIdentity | null {
|
|
881
|
+
let stats: BigIntStats;
|
|
882
|
+
try {
|
|
883
|
+
stats = lstatSync(root, { bigint: true });
|
|
884
|
+
} catch (error) {
|
|
885
|
+
if (hasCode(error, "ENOENT")) return null;
|
|
886
|
+
throw error;
|
|
887
|
+
}
|
|
888
|
+
if (!stats.isDirectory() || stats.isSymbolicLink() || !ownedByCurrentUser(stats)) {
|
|
889
|
+
throw new Error(`GHOSTGET_STATE_HOME must be an owned real directory: ${root}`);
|
|
890
|
+
}
|
|
891
|
+
return { device: stats.dev.toString(), inode: stats.ino.toString() };
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
function inspectRealDirectoryIdentity(path: string): StateRootIdentity | null {
|
|
895
|
+
let stats: BigIntStats;
|
|
896
|
+
try {
|
|
897
|
+
stats = lstatSync(path, { bigint: true });
|
|
898
|
+
} catch (error) {
|
|
899
|
+
if (hasCode(error, "ENOENT")) return null;
|
|
900
|
+
throw error;
|
|
901
|
+
}
|
|
902
|
+
if (!stats.isDirectory() || stats.isSymbolicLink()) throw new Error(`path must be a real directory: ${path}`);
|
|
903
|
+
return { device: stats.dev.toString(), inode: stats.ino.toString() };
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
function findCreationAnchor(root: string): NonNullable<StateRootRecord["creationAnchor"]> {
|
|
907
|
+
const segments: string[] = [];
|
|
908
|
+
let current = root;
|
|
909
|
+
for (;;) {
|
|
910
|
+
let stats: BigIntStats;
|
|
911
|
+
try {
|
|
912
|
+
stats = lstatSync(current, { bigint: true });
|
|
913
|
+
} catch (error) {
|
|
914
|
+
if (!hasCode(error, "ENOENT")) throw error;
|
|
915
|
+
const parent = dirname(current);
|
|
916
|
+
if (parent === current) throw new Error(`GHOSTGET_STATE_HOME has no real existing creation anchor: ${root}`);
|
|
917
|
+
segments.unshift(basename(current));
|
|
918
|
+
current = parent;
|
|
919
|
+
continue;
|
|
920
|
+
}
|
|
921
|
+
if (
|
|
922
|
+
!stats.isDirectory()
|
|
923
|
+
|| stats.isSymbolicLink()
|
|
924
|
+
|| !ownedByCurrentUser(stats)
|
|
925
|
+
|| (stats.mode & 0o022n) !== 0n
|
|
926
|
+
) {
|
|
927
|
+
throw new Error(`GHOSTGET_STATE_HOME creation path contains a non-owned real directory: ${current}`);
|
|
928
|
+
}
|
|
929
|
+
return {
|
|
930
|
+
identity: { device: stats.dev.toString(), inode: stats.ino.toString() },
|
|
931
|
+
path: current,
|
|
932
|
+
segments,
|
|
933
|
+
};
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
function assertStateRootIdentity(root: string): StateRootRecord {
|
|
938
|
+
const expected = knownStateRoots.get(root);
|
|
939
|
+
if (expected === undefined) throw new Error(`ghostget state root has not been validated: ${root}`);
|
|
940
|
+
const actual = inspectStateRootIdentity(root);
|
|
941
|
+
if (!sameIdentity(actual, expected.identity)) {
|
|
942
|
+
throw new Error(`GHOSTGET_STATE_HOME changed identity after validation: ${root}`);
|
|
943
|
+
}
|
|
944
|
+
if (expected.identity === null) {
|
|
945
|
+
const anchor = findCreationAnchor(root);
|
|
946
|
+
if (
|
|
947
|
+
expected.creationAnchor === null
|
|
948
|
+
|| anchor.path !== expected.creationAnchor.path
|
|
949
|
+
|| !sameIdentity(anchor.identity, expected.creationAnchor.identity)
|
|
950
|
+
|| anchor.segments.join("\u0000") !== expected.creationAnchor.segments.join("\u0000")
|
|
951
|
+
) throw new Error(`GHOSTGET_STATE_HOME creation path changed after validation: ${root}`);
|
|
952
|
+
}
|
|
953
|
+
if (expected.claimed) {
|
|
954
|
+
if (actual === null) throw new Error(`GHOSTGET_STATE_HOME disappeared after validation: ${root}`);
|
|
955
|
+
const stats = lstatSync(root);
|
|
956
|
+
if ((stats.mode & 0o777) !== 0o700) throw new Error(`GHOSTGET_STATE_HOME must remain private (mode 0700): ${root}`);
|
|
957
|
+
readStateMarker(join(root, stateMarkerName));
|
|
958
|
+
}
|
|
959
|
+
return expected;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
function assertNoSymbolicLinks(
|
|
963
|
+
root: string,
|
|
964
|
+
target: string,
|
|
965
|
+
includeTarget: boolean,
|
|
966
|
+
requirePrivateDirectories = false,
|
|
967
|
+
): void {
|
|
968
|
+
const canonicalRoot = resolve(root);
|
|
969
|
+
const absoluteTarget = resolve(target);
|
|
970
|
+
if (!pathInside(canonicalRoot, absoluteTarget)) throw new Error(`state path escapes its root: ${absoluteTarget}`);
|
|
971
|
+
const checkedTarget = includeTarget ? absoluteTarget : dirname(absoluteTarget);
|
|
972
|
+
const child = relative(canonicalRoot, checkedTarget);
|
|
973
|
+
const components = child === "" ? [] : child.split(sep);
|
|
974
|
+
let current = canonicalRoot;
|
|
975
|
+
const paths = [current, ...components.map((component) => {
|
|
976
|
+
current = join(current, component);
|
|
977
|
+
return current;
|
|
978
|
+
})];
|
|
979
|
+
for (const [index, path] of paths.entries()) {
|
|
980
|
+
let stats;
|
|
981
|
+
try {
|
|
982
|
+
stats = lstatSync(path);
|
|
983
|
+
} catch (error) {
|
|
984
|
+
if (hasCode(error, "ENOENT")) return;
|
|
985
|
+
throw error;
|
|
986
|
+
}
|
|
987
|
+
if (stats.isSymbolicLink()) throw new Error(`ghostget state path contains a symbolic link: ${path}`);
|
|
988
|
+
if (stats.isDirectory() && requirePrivateDirectories && (!ownedByCurrentUser(stats) || (stats.mode & 0o777) !== 0o700)) {
|
|
989
|
+
throw new Error(`ghostget state directory must be owned and private (mode 0700): ${path}`);
|
|
990
|
+
}
|
|
991
|
+
if (index < paths.length - 1 && !stats.isDirectory()) {
|
|
992
|
+
throw new Error(`ghostget state ancestor is not a directory: ${path}`);
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
function assertKnownStatePath(path: string, includeTarget: boolean): void {
|
|
998
|
+
const root = stateRootFor(path);
|
|
999
|
+
if (root !== null) {
|
|
1000
|
+
assertStateRootIdentity(root);
|
|
1001
|
+
assertNoSymbolicLinks(root, path, includeTarget, true);
|
|
1002
|
+
assertStateRootIdentity(root);
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
/** Refuse a state path whose existing components below the canonical state root contain a symlink. */
|
|
1007
|
+
export function assertSafeStatePath(
|
|
1008
|
+
path: string,
|
|
1009
|
+
environment: Readonly<Record<string, string | undefined>> = process.env,
|
|
1010
|
+
includeTarget = true,
|
|
1011
|
+
): void {
|
|
1012
|
+
const root = ghostgetStateHome(environment);
|
|
1013
|
+
assertStateRootIdentity(root);
|
|
1014
|
+
assertNoSymbolicLinks(root, path, includeTarget, true);
|
|
1015
|
+
assertStateRootIdentity(root);
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
function canonicalPotentialPath(value: string): string {
|
|
1019
|
+
const suffix: string[] = [];
|
|
1020
|
+
let ancestor = resolve(value);
|
|
1021
|
+
while (!existsSync(ancestor)) {
|
|
1022
|
+
const parent = dirname(ancestor);
|
|
1023
|
+
if (parent === ancestor) throw new Error(`path has no existing ancestor: ${value}`);
|
|
1024
|
+
suffix.unshift(ancestor.slice(parent.length + (parent.endsWith("/") ? 0 : 1)));
|
|
1025
|
+
ancestor = parent;
|
|
1026
|
+
}
|
|
1027
|
+
const stats = lstatSync(ancestor);
|
|
1028
|
+
if (stats.isSymbolicLink()) return resolve(realpathSync(ancestor), ...suffix);
|
|
1029
|
+
return resolve(realpathSync(ancestor), ...suffix);
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
function ownedByCurrentUser(stats: { readonly uid: number | bigint }): boolean {
|
|
1033
|
+
const currentUid = typeof process.getuid === "function" ? process.getuid() : undefined;
|
|
1034
|
+
return currentUid === undefined || stats.uid === (typeof stats.uid === "bigint" ? BigInt(currentUid) : currentUid);
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
function readStateMarker(path: string): void {
|
|
1038
|
+
const noFollow = "O_NOFOLLOW" in constants ? constants.O_NOFOLLOW : 0;
|
|
1039
|
+
const nonBlocking = "O_NONBLOCK" in constants ? constants.O_NONBLOCK : 0;
|
|
1040
|
+
const descriptor = openSync(path, constants.O_RDONLY | noFollow | nonBlocking);
|
|
1041
|
+
try {
|
|
1042
|
+
const stats = fstatSync(descriptor);
|
|
1043
|
+
if (!stats.isFile() || stats.size > 256 || !ownedByCurrentUser(stats) || (stats.mode & 0o077) !== 0) {
|
|
1044
|
+
throw new Error("ghostget state marker must be a private, owned regular file");
|
|
1045
|
+
}
|
|
1046
|
+
const content = new TextDecoder("utf-8", {
|
|
1047
|
+
fatal: true,
|
|
1048
|
+
ignoreBOM: true,
|
|
1049
|
+
}).decode(readDescriptorBounded(descriptor, 256));
|
|
1050
|
+
if (content !== stateMarkerText) throw new Error("ghostget state marker is malformed");
|
|
1051
|
+
const value = JSON.parse(content) as unknown;
|
|
1052
|
+
if (
|
|
1053
|
+
typeof value !== "object"
|
|
1054
|
+
|| value === null
|
|
1055
|
+
|| Array.isArray(value)
|
|
1056
|
+
|| Object.keys(value).sort().join(",") !== "kind,schemaVersion"
|
|
1057
|
+
|| !("schemaVersion" in value)
|
|
1058
|
+
|| value.schemaVersion !== 1
|
|
1059
|
+
|| !("kind" in value)
|
|
1060
|
+
|| value.kind !== "io-state"
|
|
1061
|
+
) throw new Error("ghostget state marker is malformed");
|
|
1062
|
+
} finally {
|
|
1063
|
+
closeSync(descriptor);
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
function hasGhostgetPathIdentity(path: string): boolean {
|
|
1068
|
+
return resolve(path)
|
|
1069
|
+
.split(sep)
|
|
1070
|
+
.filter((segment) => segment !== "")
|
|
1071
|
+
.slice(-3)
|
|
1072
|
+
.some((segment) =>
|
|
1073
|
+
/(?:^|[^a-z0-9])(?:ghostget|wrench|oh|io)(?:[^a-z0-9]|$)/iu.test(segment),
|
|
1074
|
+
);
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
function validateUnmarkedStateRoot(root: string): boolean {
|
|
1078
|
+
if (!hasGhostgetPathIdentity(root)) {
|
|
1079
|
+
throw new Error(`GHOSTGET_STATE_HOME is not marked as wrench-owned and its path does not identify dedicated ghostget state: ${root}`);
|
|
1080
|
+
}
|
|
1081
|
+
const allowed = new Set<string>([
|
|
1082
|
+
...stateDirectoryNames,
|
|
1083
|
+
".cursor-encryption-key",
|
|
1084
|
+
".plan-encryption-key",
|
|
1085
|
+
".projection-encryption-key",
|
|
1086
|
+
".recovery-encryption-key",
|
|
1087
|
+
".session-encryption-key",
|
|
1088
|
+
]);
|
|
1089
|
+
const entries: Dirent<string>[] = [];
|
|
1090
|
+
const directory = opendirSync(root);
|
|
1091
|
+
try {
|
|
1092
|
+
for (;;) {
|
|
1093
|
+
const entry = directory.readSync();
|
|
1094
|
+
if (entry === null) break;
|
|
1095
|
+
if (entries.length >= 10_000) throw new Error("GHOSTGET_STATE_HOME contains more than 10000 entries");
|
|
1096
|
+
if (entry.name.includes("\uFFFD") || Buffer.byteLength(entry.name, "utf8") > 255) {
|
|
1097
|
+
throw new Error("GHOSTGET_STATE_HOME contains an unsafe entry name");
|
|
1098
|
+
}
|
|
1099
|
+
entries.push(entry);
|
|
1100
|
+
}
|
|
1101
|
+
} finally {
|
|
1102
|
+
directory.closeSync();
|
|
1103
|
+
}
|
|
1104
|
+
for (const entry of entries) {
|
|
1105
|
+
if (entry.isSymbolicLink()) throw new Error(`GHOSTGET_STATE_HOME contains a symbolic link: ${join(root, entry.name)}`);
|
|
1106
|
+
if (/^\.io-state\.stage-\d+-[0-9a-f-]{36}\.json$/u.test(entry.name)) {
|
|
1107
|
+
const stats = lstatSync(join(root, entry.name));
|
|
1108
|
+
if (!stats.isFile() || !ownedByCurrentUser(stats) || (stats.mode & 0o077) !== 0 || stats.size > 256) {
|
|
1109
|
+
throw new Error(`GHOSTGET_STATE_HOME contains an invalid interrupted state-marker stage: ${entry.name}`);
|
|
1110
|
+
}
|
|
1111
|
+
continue;
|
|
1112
|
+
}
|
|
1113
|
+
if (!allowed.has(entry.name)) {
|
|
1114
|
+
throw new Error(`GHOSTGET_STATE_HOME is not an empty or recognizable dedicated ghostget state directory: ${root}`);
|
|
1115
|
+
}
|
|
1116
|
+
if (stateDirectoryNames.includes(entry.name as typeof stateDirectoryNames[number]) && !entry.isDirectory()) {
|
|
1117
|
+
throw new Error(`GHOSTGET_STATE_HOME contains an invalid state entry: ${entry.name}`);
|
|
1118
|
+
}
|
|
1119
|
+
if (
|
|
1120
|
+
(
|
|
1121
|
+
entry.name === ".plan-encryption-key"
|
|
1122
|
+
|| entry.name === ".cursor-encryption-key"
|
|
1123
|
+
|| entry.name === ".projection-encryption-key"
|
|
1124
|
+
|| entry.name === ".recovery-encryption-key"
|
|
1125
|
+
|| entry.name === ".session-encryption-key"
|
|
1126
|
+
)
|
|
1127
|
+
&& !entry.isFile()
|
|
1128
|
+
) {
|
|
1129
|
+
throw new Error("GHOSTGET_STATE_HOME contains an invalid encryption key entry");
|
|
1130
|
+
}
|
|
1131
|
+
const stats = lstatSync(join(root, entry.name));
|
|
1132
|
+
const hasPrivateMode = entry.isDirectory()
|
|
1133
|
+
? (stats.mode & 0o777) === 0o700
|
|
1134
|
+
: (stats.mode & 0o077) === 0;
|
|
1135
|
+
if (!ownedByCurrentUser(stats) || !hasPrivateMode) {
|
|
1136
|
+
throw new Error(`GHOSTGET_STATE_HOME contains a state entry that is not owned and private: ${entry.name}`);
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
return entries.length > 0;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
function validateStateRoot(root: string): StateRootRecord {
|
|
1143
|
+
if (!existsSync(root)) return { claimed: false, creationAnchor: findCreationAnchor(root), identity: null };
|
|
1144
|
+
const stats = lstatSync(root, { bigint: true });
|
|
1145
|
+
if (!stats.isDirectory() || stats.isSymbolicLink() || !ownedByCurrentUser(stats)) {
|
|
1146
|
+
throw new Error(`GHOSTGET_STATE_HOME must be an owned real directory: ${root}`);
|
|
1147
|
+
}
|
|
1148
|
+
const marker = join(root, stateMarkerName);
|
|
1149
|
+
const claimed = existsSync(marker);
|
|
1150
|
+
const hasUnmarkedState = claimed ? false : validateUnmarkedStateRoot(root);
|
|
1151
|
+
if (claimed) readStateMarker(marker);
|
|
1152
|
+
if (!claimed && !hasUnmarkedState && (stats.mode & 0o022n) !== 0n) {
|
|
1153
|
+
throw new Error(`an unclaimed GHOSTGET_STATE_HOME must not be group/world-writable: ${root}`);
|
|
1154
|
+
}
|
|
1155
|
+
if ((claimed || hasUnmarkedState) && (stats.mode & 0o777n) !== 0o700n) {
|
|
1156
|
+
throw new Error(`GHOSTGET_STATE_HOME must be private (mode 0700) before trusted state is read: ${root}`);
|
|
1157
|
+
}
|
|
1158
|
+
return { claimed, creationAnchor: null, identity: { device: stats.dev.toString(), inode: stats.ino.toString() } };
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
export function ghostgetStateHome(environment: Readonly<Record<string, string | undefined>> = process.env): string {
|
|
1162
|
+
const configuredRoots = [
|
|
1163
|
+
["GHOSTGET_STATE_HOME", environment.GHOSTGET_STATE_HOME],
|
|
1164
|
+
["WRENCH_STATE_HOME", environment.WRENCH_STATE_HOME],
|
|
1165
|
+
["OH_STATE_HOME", environment.OH_STATE_HOME],
|
|
1166
|
+
["IO_HOME", environment.IO_HOME],
|
|
1167
|
+
] as const satisfies readonly (readonly [string, string | undefined])[];
|
|
1168
|
+
const requestedRoots = configuredRoots.flatMap(([name, value]) => {
|
|
1169
|
+
const trimmed = value?.trim() ?? "";
|
|
1170
|
+
return trimmed === "" ? [] : [{ name, root: canonicalPotentialPath(trimmed) }];
|
|
1171
|
+
});
|
|
1172
|
+
const distinctRequestedRoots = new Set(requestedRoots.map(({ root }) => root));
|
|
1173
|
+
if (distinctRequestedRoots.size > 1) {
|
|
1174
|
+
throw new Error(
|
|
1175
|
+
`${requestedRoots.map(({ name }) => name).join(", ")} select different state roots`,
|
|
1176
|
+
);
|
|
1177
|
+
}
|
|
1178
|
+
const dataRoot = environment.XDG_DATA_HOME !== undefined
|
|
1179
|
+
&& environment.XDG_DATA_HOME.trim() !== ""
|
|
1180
|
+
? resolve(environment.XDG_DATA_HOME)
|
|
1181
|
+
: join(homedir(), ".local", "share");
|
|
1182
|
+
const currentDefault = canonicalPotentialPath(join(dataRoot, "ghostget"));
|
|
1183
|
+
// Reuse existing state in place: ciphertext, auth, and dispatch journals
|
|
1184
|
+
// retain their original directory and wire identities across the rename.
|
|
1185
|
+
const legacyDefaults = [
|
|
1186
|
+
canonicalPotentialPath(join(dataRoot, "wrench")),
|
|
1187
|
+
canonicalPotentialPath(join(dataRoot, "oh")),
|
|
1188
|
+
canonicalPotentialPath(join(dataRoot, "io")),
|
|
1189
|
+
] as const;
|
|
1190
|
+
let root = requestedRoots[0]?.root ?? null;
|
|
1191
|
+
if (root === null) {
|
|
1192
|
+
const existingDefaults = [currentDefault, ...legacyDefaults].filter(
|
|
1193
|
+
(candidate) => existsSync(candidate),
|
|
1194
|
+
);
|
|
1195
|
+
if (existingDefaults.length > 1) {
|
|
1196
|
+
throw new Error(
|
|
1197
|
+
`multiple Ghostget and legacy state roots exist; set GHOSTGET_STATE_HOME explicitly after reconciling ${existingDefaults.join(", ")}`,
|
|
1198
|
+
);
|
|
1199
|
+
}
|
|
1200
|
+
root = existingDefaults[0] ?? currentDefault;
|
|
1201
|
+
}
|
|
1202
|
+
const home = canonicalPotentialPath(homedir());
|
|
1203
|
+
const forbiddenRoots = new Set([
|
|
1204
|
+
parse(root).root,
|
|
1205
|
+
home,
|
|
1206
|
+
dirname(home),
|
|
1207
|
+
canonicalPotentialPath(tmpdir()),
|
|
1208
|
+
canonicalPotentialPath(process.cwd()),
|
|
1209
|
+
...["Desktop", "Documents", "Downloads", "Library", ".cache", ".config", ".local"]
|
|
1210
|
+
.map((name) => canonicalPotentialPath(join(home, name))),
|
|
1211
|
+
...(environment.XDG_DATA_HOME === undefined || environment.XDG_DATA_HOME.trim() === ""
|
|
1212
|
+
? []
|
|
1213
|
+
: [canonicalPotentialPath(environment.XDG_DATA_HOME)]),
|
|
1214
|
+
]);
|
|
1215
|
+
if (forbiddenRoots.has(root) || isWithinPath(ghostgetSourcePackageRoot, root)) {
|
|
1216
|
+
throw new Error(`GHOSTGET_STATE_HOME must be a dedicated child directory, not a filesystem, home, temporary, repository, or shared data root: ${root}`);
|
|
1217
|
+
}
|
|
1218
|
+
const inspected = validateStateRoot(root);
|
|
1219
|
+
const remembered = knownStateRoots.get(root);
|
|
1220
|
+
if (remembered !== undefined) {
|
|
1221
|
+
if (!sameIdentity(remembered.identity, inspected.identity)) {
|
|
1222
|
+
throw new Error(`GHOSTGET_STATE_HOME changed identity after validation: ${root}`);
|
|
1223
|
+
}
|
|
1224
|
+
if (
|
|
1225
|
+
remembered.identity === null
|
|
1226
|
+
&& (
|
|
1227
|
+
remembered.creationAnchor === null
|
|
1228
|
+
|| inspected.creationAnchor === null
|
|
1229
|
+
|| remembered.creationAnchor.path !== inspected.creationAnchor.path
|
|
1230
|
+
|| !sameIdentity(remembered.creationAnchor.identity, inspected.creationAnchor.identity)
|
|
1231
|
+
|| remembered.creationAnchor.segments.join("\u0000") !== inspected.creationAnchor.segments.join("\u0000")
|
|
1232
|
+
)
|
|
1233
|
+
) throw new Error(`GHOSTGET_STATE_HOME creation path changed after validation: ${root}`);
|
|
1234
|
+
if (remembered.claimed && !inspected.claimed) {
|
|
1235
|
+
throw new Error(`GHOSTGET_STATE_HOME lost its ownership marker after validation: ${root}`);
|
|
1236
|
+
}
|
|
1237
|
+
knownStateRoots.set(root, { ...inspected, claimed: remembered.claimed || inspected.claimed });
|
|
1238
|
+
} else {
|
|
1239
|
+
knownStateRoots.set(root, inspected);
|
|
1240
|
+
}
|
|
1241
|
+
for (const name of stateDirectoryNames) assertNoSymbolicLinks(root, join(root, name), true);
|
|
1242
|
+
return root;
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
function ensureClaimedStateRoot(root: string): StateRootIdentity {
|
|
1246
|
+
let remembered = assertStateRootIdentity(root);
|
|
1247
|
+
if (remembered.identity === null) {
|
|
1248
|
+
const anchor = remembered.creationAnchor;
|
|
1249
|
+
if (anchor === null) throw new Error(`GHOSTGET_STATE_HOME has no validated creation anchor: ${root}`);
|
|
1250
|
+
const response = runStateHelper(
|
|
1251
|
+
anchor.path,
|
|
1252
|
+
anchor.identity,
|
|
1253
|
+
{ kind: "create-root", segments: anchor.segments },
|
|
1254
|
+
true,
|
|
1255
|
+
);
|
|
1256
|
+
const current = inspectStateRootIdentity(root);
|
|
1257
|
+
if (!sameIdentity(current, response.identity)) throw new Error(`GHOSTGET_STATE_HOME changed identity while being created: ${root}`);
|
|
1258
|
+
remembered = { claimed: true, creationAnchor: null, identity: response.identity };
|
|
1259
|
+
knownStateRoots.set(root, remembered);
|
|
1260
|
+
} else if (!remembered.claimed) {
|
|
1261
|
+
validateUnmarkedStateRoot(root);
|
|
1262
|
+
const descriptor = openSync(
|
|
1263
|
+
root,
|
|
1264
|
+
constants.O_RDONLY
|
|
1265
|
+
| ("O_NOFOLLOW" in constants ? constants.O_NOFOLLOW : 0)
|
|
1266
|
+
| ("O_DIRECTORY" in constants ? constants.O_DIRECTORY : 0),
|
|
1267
|
+
);
|
|
1268
|
+
try {
|
|
1269
|
+
const stats = fstatSync(descriptor, { bigint: true });
|
|
1270
|
+
const actual = { device: stats.dev.toString(), inode: stats.ino.toString() };
|
|
1271
|
+
if (!sameIdentity(actual, remembered.identity) || !ownedByCurrentUser(stats)) {
|
|
1272
|
+
throw new Error(`GHOSTGET_STATE_HOME changed identity while being claimed: ${root}`);
|
|
1273
|
+
}
|
|
1274
|
+
fchmodSync(descriptor, 0o700);
|
|
1275
|
+
fsyncSync(descriptor);
|
|
1276
|
+
} finally {
|
|
1277
|
+
closeSync(descriptor);
|
|
1278
|
+
}
|
|
1279
|
+
if (!sameIdentity(inspectStateRootIdentity(root), remembered.identity)) {
|
|
1280
|
+
throw new Error(`GHOSTGET_STATE_HOME changed identity while being claimed: ${root}`);
|
|
1281
|
+
}
|
|
1282
|
+
runStateHelper(root, remembered.identity, { kind: "claim" });
|
|
1283
|
+
if (!sameIdentity(inspectStateRootIdentity(root), remembered.identity)) {
|
|
1284
|
+
throw new Error(`GHOSTGET_STATE_HOME changed identity while being claimed: ${root}`);
|
|
1285
|
+
}
|
|
1286
|
+
remembered = { claimed: true, creationAnchor: null, identity: remembered.identity };
|
|
1287
|
+
knownStateRoots.set(root, remembered);
|
|
1288
|
+
}
|
|
1289
|
+
if (remembered.identity === null) throw new Error(`GHOSTGET_STATE_HOME is unavailable after being claimed: ${root}`);
|
|
1290
|
+
assertStateRootIdentity(root);
|
|
1291
|
+
return remembered.identity;
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
/**
|
|
1295
|
+
* Resolve immutable system-owned compatibility links (for example macOS
|
|
1296
|
+
* `/var -> /private/var`) while rejecting links controlled by the invoking
|
|
1297
|
+
* user. All subsequent helper operations use the returned real-prefix path,
|
|
1298
|
+
* never the original alias.
|
|
1299
|
+
*/
|
|
1300
|
+
function canonicalNonStatePath(value: string): string {
|
|
1301
|
+
const absolute = resolve(value);
|
|
1302
|
+
const filesystemRoot = parse(absolute).root;
|
|
1303
|
+
const child = relative(filesystemRoot, absolute);
|
|
1304
|
+
const segments = child === "" ? [] : child.split(sep);
|
|
1305
|
+
let current = filesystemRoot;
|
|
1306
|
+
for (const [index, segment] of segments.entries()) {
|
|
1307
|
+
const candidate = join(current, segment);
|
|
1308
|
+
let stats: ReturnType<typeof lstatSync>;
|
|
1309
|
+
try {
|
|
1310
|
+
stats = lstatSync(candidate);
|
|
1311
|
+
} catch (error) {
|
|
1312
|
+
if (!hasCode(error, "ENOENT")) throw error;
|
|
1313
|
+
return join(current, ...segments.slice(index));
|
|
1314
|
+
}
|
|
1315
|
+
if (stats.isSymbolicLink()) {
|
|
1316
|
+
if (process.platform === "win32" || Number(stats.uid) !== 0) {
|
|
1317
|
+
throw new Error(`private path is not a real directory (symbolic link): ${candidate}`);
|
|
1318
|
+
}
|
|
1319
|
+
current = realpathSync(candidate);
|
|
1320
|
+
continue;
|
|
1321
|
+
}
|
|
1322
|
+
current = candidate;
|
|
1323
|
+
}
|
|
1324
|
+
return current;
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
function genericPathParts(path: string): {
|
|
1328
|
+
readonly canonical: string;
|
|
1329
|
+
readonly root: string;
|
|
1330
|
+
readonly rootIdentity: StateRootIdentity;
|
|
1331
|
+
readonly segments: readonly string[];
|
|
1332
|
+
} {
|
|
1333
|
+
const canonical = canonicalNonStatePath(path);
|
|
1334
|
+
const root = parse(canonical).root;
|
|
1335
|
+
const rootIdentity = inspectRealDirectoryIdentity(root);
|
|
1336
|
+
if (rootIdentity === null) throw new Error(`path filesystem root is unavailable: ${root}`);
|
|
1337
|
+
const child = relative(root, canonical);
|
|
1338
|
+
return {
|
|
1339
|
+
canonical,
|
|
1340
|
+
root,
|
|
1341
|
+
rootIdentity,
|
|
1342
|
+
segments: child === "" ? [] : child.split(sep),
|
|
1343
|
+
};
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
function captureGenericDirectoryExpectations(
|
|
1347
|
+
root: string,
|
|
1348
|
+
segments: readonly string[],
|
|
1349
|
+
): readonly StateDirectoryExpectation[] {
|
|
1350
|
+
const expectations: StateDirectoryExpectation[] = [];
|
|
1351
|
+
let current = root;
|
|
1352
|
+
let missing = false;
|
|
1353
|
+
for (const segment of segments) {
|
|
1354
|
+
current = join(current, segment);
|
|
1355
|
+
if (missing) {
|
|
1356
|
+
expectations.push(null);
|
|
1357
|
+
continue;
|
|
1358
|
+
}
|
|
1359
|
+
let stats: BigIntStats;
|
|
1360
|
+
try {
|
|
1361
|
+
stats = lstatSync(current, { bigint: true });
|
|
1362
|
+
} catch (error) {
|
|
1363
|
+
if (!hasCode(error, "ENOENT")) throw error;
|
|
1364
|
+
missing = true;
|
|
1365
|
+
expectations.push(null);
|
|
1366
|
+
continue;
|
|
1367
|
+
}
|
|
1368
|
+
if (!stats.isDirectory() || stats.isSymbolicLink()) {
|
|
1369
|
+
throw new Error(`private path ancestor is not a real directory: ${current}`);
|
|
1370
|
+
}
|
|
1371
|
+
expectations.push({ device: stats.dev.toString(), inode: stats.ino.toString() });
|
|
1372
|
+
}
|
|
1373
|
+
return expectations;
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
function ensureBoundNonStateDirectory(path: string, requireFinalPrivate: boolean): ReturnType<typeof genericPathParts> {
|
|
1377
|
+
const parts = genericPathParts(path);
|
|
1378
|
+
runPathHelper(parts.root, parts.rootIdentity, {
|
|
1379
|
+
kind: "ensure-directories",
|
|
1380
|
+
segments: parts.segments,
|
|
1381
|
+
directoryExpectations: captureGenericDirectoryExpectations(parts.root, parts.segments),
|
|
1382
|
+
requireFinalPrivate,
|
|
1383
|
+
});
|
|
1384
|
+
return genericPathParts(parts.canonical);
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
export function removePrivateDirectoryTree(
|
|
1388
|
+
path: string,
|
|
1389
|
+
expectedTarget?: Readonly<StateRootIdentity> & Readonly<{ birthtimeNs?: string }>,
|
|
1390
|
+
): boolean {
|
|
1391
|
+
const parts = genericPathParts(path);
|
|
1392
|
+
if (parts.segments.length < 2) throw new Error("private recursive removal target is too broad");
|
|
1393
|
+
const captured = [...captureGenericDirectoryExpectations(parts.root, parts.segments)];
|
|
1394
|
+
if (expectedTarget !== undefined) {
|
|
1395
|
+
captured[captured.length - 1] = {
|
|
1396
|
+
device: expectedTarget.device,
|
|
1397
|
+
inode: expectedTarget.inode,
|
|
1398
|
+
};
|
|
1399
|
+
}
|
|
1400
|
+
const response = runPathHelper(parts.root, parts.rootIdentity, {
|
|
1401
|
+
kind: "remove-directory-tree",
|
|
1402
|
+
segments: parts.segments,
|
|
1403
|
+
directoryExpectations: captured,
|
|
1404
|
+
expectedTargetBirthtimeNs: expectedTarget?.birthtimeNs ?? null,
|
|
1405
|
+
});
|
|
1406
|
+
return response.removed === true;
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
export function ensurePrivateDirectory(path: string): void {
|
|
1410
|
+
const target = resolve(path);
|
|
1411
|
+
const root = stateRootFor(target);
|
|
1412
|
+
if (root !== null) {
|
|
1413
|
+
const identity = ensureClaimedStateRoot(root);
|
|
1414
|
+
const segments = stateSegments(root, target);
|
|
1415
|
+
if (segments.length > 0) {
|
|
1416
|
+
runStateHelper(root, identity, {
|
|
1417
|
+
kind: "ensure-directories",
|
|
1418
|
+
segments,
|
|
1419
|
+
directoryExpectations: captureStateDirectoryExpectations(root, segments),
|
|
1420
|
+
});
|
|
1421
|
+
}
|
|
1422
|
+
assertKnownStatePath(target, true);
|
|
1423
|
+
return;
|
|
1424
|
+
}
|
|
1425
|
+
ensureBoundNonStateDirectory(target, true);
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
export function ensurePrivateStateDirectory(
|
|
1429
|
+
path: string,
|
|
1430
|
+
environment: Readonly<Record<string, string | undefined>> = process.env,
|
|
1431
|
+
): PrivateDirectoryIdentity {
|
|
1432
|
+
assertSafeStatePath(path, environment);
|
|
1433
|
+
const root = ghostgetStateHome(environment);
|
|
1434
|
+
const identity = ensureClaimedStateRoot(root);
|
|
1435
|
+
const segments = stateSegments(root, path);
|
|
1436
|
+
if (segments.length === 0) return identity;
|
|
1437
|
+
const response = runStateHelper(root, identity, {
|
|
1438
|
+
kind: "ensure-directories",
|
|
1439
|
+
segments,
|
|
1440
|
+
directoryExpectations: captureStateDirectoryExpectations(root, segments),
|
|
1441
|
+
});
|
|
1442
|
+
if (response.targetIdentity === undefined) {
|
|
1443
|
+
throw new Error("state helper omitted the ensured directory identity");
|
|
1444
|
+
}
|
|
1445
|
+
return response.targetIdentity;
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
export function createPrivateStateDirectory(
|
|
1449
|
+
path: string,
|
|
1450
|
+
environment: Readonly<Record<string, string | undefined>> = process.env,
|
|
1451
|
+
expectedParent?: Readonly<PrivateDirectoryIdentity>,
|
|
1452
|
+
): PrivateDirectoryIdentity {
|
|
1453
|
+
assertSafeStatePath(path, environment, false);
|
|
1454
|
+
const root = ghostgetStateHome(environment);
|
|
1455
|
+
const identity = ensureClaimedStateRoot(root);
|
|
1456
|
+
const segments = stateSegments(root, path);
|
|
1457
|
+
if (segments.length < 2) throw new Error("only a nested ghostget state directory can be created exclusively");
|
|
1458
|
+
const directoryExpectations = [...captureStateDirectoryExpectations(root, segments)];
|
|
1459
|
+
if (directoryExpectations.at(-1) !== null) throw new Error("private state directory already exists");
|
|
1460
|
+
if (expectedParent !== undefined) {
|
|
1461
|
+
directoryExpectations[directoryExpectations.length - 2] = expectedParent;
|
|
1462
|
+
}
|
|
1463
|
+
const response = runStateHelper(root, identity, {
|
|
1464
|
+
kind: "create-directory",
|
|
1465
|
+
segments,
|
|
1466
|
+
directoryExpectations,
|
|
1467
|
+
});
|
|
1468
|
+
if (response.created !== true || response.targetIdentity === undefined) {
|
|
1469
|
+
throw new Error("state helper omitted the created directory identity");
|
|
1470
|
+
}
|
|
1471
|
+
return response.targetIdentity;
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
export function readPrivateStateFileBytesIfPresent(
|
|
1475
|
+
path: string,
|
|
1476
|
+
maximumBytes: number,
|
|
1477
|
+
label: string,
|
|
1478
|
+
environment: Readonly<Record<string, string | undefined>> = process.env,
|
|
1479
|
+
expectedStateDirectories?: readonly Readonly<PrivateDirectoryIdentity>[],
|
|
1480
|
+
): Buffer | null {
|
|
1481
|
+
assertSafeStatePath(path, environment);
|
|
1482
|
+
const root = ghostgetStateHome(environment);
|
|
1483
|
+
const identity = ensureClaimedStateRoot(root);
|
|
1484
|
+
const segments = stateSegments(root, path);
|
|
1485
|
+
if (segments.length < 2) throw new Error(`${label} must be nested inside an ghostget state directory`);
|
|
1486
|
+
const directoryExpectations = [...captureStateDirectoryExpectations(root, segments.slice(0, -1))];
|
|
1487
|
+
if (expectedStateDirectories !== undefined) {
|
|
1488
|
+
if (expectedStateDirectories.length !== directoryExpectations.length) {
|
|
1489
|
+
throw new Error(`optional ${label} directory identity count does not match its state path`);
|
|
1490
|
+
}
|
|
1491
|
+
directoryExpectations.splice(0, directoryExpectations.length, ...expectedStateDirectories);
|
|
1492
|
+
}
|
|
1493
|
+
let response: StateHelperResponse;
|
|
1494
|
+
try {
|
|
1495
|
+
response = runStateHelper(root, identity, {
|
|
1496
|
+
kind: "read-file-if-present",
|
|
1497
|
+
segments,
|
|
1498
|
+
directoryExpectations,
|
|
1499
|
+
maximumBytes,
|
|
1500
|
+
});
|
|
1501
|
+
} catch (error) {
|
|
1502
|
+
const detail = error instanceof Error ? ` (${error.message})` : "";
|
|
1503
|
+
throw new Error(`could not safely open optional ${label}: ${path}${detail}`, { cause: error });
|
|
1504
|
+
}
|
|
1505
|
+
if (response.present === false) {
|
|
1506
|
+
if (response.contentBase64 !== undefined) throw new Error(`optional ${label} returned content while absent`);
|
|
1507
|
+
return null;
|
|
1508
|
+
}
|
|
1509
|
+
if (response.present !== true) throw new Error(`optional ${label} omitted its presence state`);
|
|
1510
|
+
const encoded = response.contentBase64;
|
|
1511
|
+
if (
|
|
1512
|
+
encoded === undefined
|
|
1513
|
+
|| encoded.length > Math.ceil(maximumBytes / 3) * 4 + 4
|
|
1514
|
+
|| !/^[A-Za-z0-9+/]*={0,2}$/u.test(encoded)
|
|
1515
|
+
) throw new Error(`optional ${label} returned malformed bounded content`);
|
|
1516
|
+
const content = Buffer.from(encoded, "base64");
|
|
1517
|
+
if (content.byteLength > maximumBytes) throw new Error(`optional ${label} grew beyond ${maximumBytes} bytes while being read`);
|
|
1518
|
+
return content;
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
export function readPrivateStateFileIfPresent(
|
|
1522
|
+
path: string,
|
|
1523
|
+
maximumBytes: number,
|
|
1524
|
+
label: string,
|
|
1525
|
+
environment: Readonly<Record<string, string | undefined>> = process.env,
|
|
1526
|
+
expectedStateDirectories?: readonly Readonly<PrivateDirectoryIdentity>[],
|
|
1527
|
+
): string | null {
|
|
1528
|
+
const content = readPrivateStateFileBytesIfPresent(
|
|
1529
|
+
path,
|
|
1530
|
+
maximumBytes,
|
|
1531
|
+
label,
|
|
1532
|
+
environment,
|
|
1533
|
+
expectedStateDirectories,
|
|
1534
|
+
);
|
|
1535
|
+
if (content === null) return null;
|
|
1536
|
+
try {
|
|
1537
|
+
return new TextDecoder("utf-8", {
|
|
1538
|
+
fatal: true,
|
|
1539
|
+
ignoreBOM: true,
|
|
1540
|
+
}).decode(content);
|
|
1541
|
+
} catch (error) {
|
|
1542
|
+
throw new Error(`optional ${label} is not valid UTF-8`, { cause: error });
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
export function readRegularFile(
|
|
1547
|
+
path: string,
|
|
1548
|
+
maximumBytes: number,
|
|
1549
|
+
label = "file",
|
|
1550
|
+
expectedStateParent?: Readonly<PrivateDirectoryIdentity>,
|
|
1551
|
+
): string {
|
|
1552
|
+
let stateRoot: string | null;
|
|
1553
|
+
try {
|
|
1554
|
+
stateRoot = stateRootFor(path);
|
|
1555
|
+
} catch (error) {
|
|
1556
|
+
const reason = error instanceof Error && error.message.includes("symbolic link")
|
|
1557
|
+
? " (state path contains a symbolic link)"
|
|
1558
|
+
: "";
|
|
1559
|
+
throw new Error(`could not safely open ${label}: ${path}${reason}`, { cause: error });
|
|
1560
|
+
}
|
|
1561
|
+
if (stateRoot !== null) {
|
|
1562
|
+
ensurePrivateDirectory(stateRoot);
|
|
1563
|
+
const record = assertStateRootIdentity(stateRoot);
|
|
1564
|
+
if (record.identity === null || !record.claimed) throw new Error("ghostget state root is not claimed");
|
|
1565
|
+
const segments = stateSegments(stateRoot, path);
|
|
1566
|
+
if (segments.length === 0) {
|
|
1567
|
+
throw new Error(`${label} must be a regular file no larger than ${maximumBytes} bytes`);
|
|
1568
|
+
}
|
|
1569
|
+
let response: StateHelperResponse;
|
|
1570
|
+
try {
|
|
1571
|
+
const directoryExpectations = [...captureStateDirectoryExpectations(stateRoot, segments.slice(0, -1))];
|
|
1572
|
+
if (expectedStateParent !== undefined && directoryExpectations.length > 0 && directoryExpectations.at(-1) !== null) {
|
|
1573
|
+
directoryExpectations[directoryExpectations.length - 1] = expectedStateParent;
|
|
1574
|
+
}
|
|
1575
|
+
response = runStateHelper(stateRoot, record.identity, {
|
|
1576
|
+
kind: "read-file",
|
|
1577
|
+
segments,
|
|
1578
|
+
directoryExpectations,
|
|
1579
|
+
maximumBytes,
|
|
1580
|
+
});
|
|
1581
|
+
} catch (error) {
|
|
1582
|
+
const reason = error instanceof Error && error.message.includes("symbolic link")
|
|
1583
|
+
? " (state path contains a symbolic link)"
|
|
1584
|
+
: "";
|
|
1585
|
+
throw new Error(`could not safely open ${label}: ${path}${reason}`, { cause: error });
|
|
1586
|
+
}
|
|
1587
|
+
const encoded = response.contentBase64;
|
|
1588
|
+
if (
|
|
1589
|
+
encoded === undefined
|
|
1590
|
+
|| encoded.length > Math.ceil(maximumBytes / 3) * 4 + 4
|
|
1591
|
+
|| !/^[A-Za-z0-9+/]*={0,2}$/u.test(encoded)
|
|
1592
|
+
) throw new Error(`${label} returned malformed bounded content`);
|
|
1593
|
+
const content = Buffer.from(encoded, "base64");
|
|
1594
|
+
if (content.byteLength > maximumBytes) throw new Error(`${label} grew beyond ${maximumBytes} bytes while being read`);
|
|
1595
|
+
try {
|
|
1596
|
+
return new TextDecoder("utf-8", {
|
|
1597
|
+
fatal: true,
|
|
1598
|
+
ignoreBOM: true,
|
|
1599
|
+
}).decode(content);
|
|
1600
|
+
} catch (error) {
|
|
1601
|
+
throw new Error(`${label} is not valid UTF-8`, { cause: error });
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
const parts = genericPathParts(path);
|
|
1605
|
+
if (parts.segments.length === 0) throw new Error(`${label} must be a regular file`);
|
|
1606
|
+
let response: StateHelperResponse;
|
|
1607
|
+
try {
|
|
1608
|
+
response = runPathHelper(parts.root, parts.rootIdentity, {
|
|
1609
|
+
kind: "read-file",
|
|
1610
|
+
segments: parts.segments,
|
|
1611
|
+
directoryExpectations: captureGenericDirectoryExpectations(parts.root, parts.segments.slice(0, -1)),
|
|
1612
|
+
maximumBytes,
|
|
1613
|
+
});
|
|
1614
|
+
} catch (error) {
|
|
1615
|
+
if (
|
|
1616
|
+
error instanceof Error
|
|
1617
|
+
&& (
|
|
1618
|
+
error.message.includes("bounded regular file")
|
|
1619
|
+
|| error.message.includes("byte bound")
|
|
1620
|
+
|| error.message.includes("no longer matches its validated file identity")
|
|
1621
|
+
)
|
|
1622
|
+
) {
|
|
1623
|
+
throw new Error(`${label} must be a regular file no larger than ${maximumBytes} bytes`, { cause: error });
|
|
1624
|
+
}
|
|
1625
|
+
throw new Error(`could not safely open ${label}: ${path}`, { cause: error });
|
|
1626
|
+
}
|
|
1627
|
+
const encoded = response.contentBase64;
|
|
1628
|
+
if (
|
|
1629
|
+
encoded === undefined
|
|
1630
|
+
|| encoded.length > Math.ceil(maximumBytes / 3) * 4 + 4
|
|
1631
|
+
|| !/^[A-Za-z0-9+/]*={0,2}$/u.test(encoded)
|
|
1632
|
+
) throw new Error(`${label} returned malformed bounded content`);
|
|
1633
|
+
const content = Buffer.from(encoded, "base64");
|
|
1634
|
+
if (content.byteLength > maximumBytes) throw new Error(`${label} grew beyond ${maximumBytes} bytes while being read`);
|
|
1635
|
+
try {
|
|
1636
|
+
return new TextDecoder("utf-8", {
|
|
1637
|
+
fatal: true,
|
|
1638
|
+
ignoreBOM: true,
|
|
1639
|
+
}).decode(content);
|
|
1640
|
+
} catch (error) {
|
|
1641
|
+
throw new Error(`${label} is not valid UTF-8`, { cause: error });
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
export function readJsonFile(path: string): unknown {
|
|
1646
|
+
return JSON.parse(readRegularFile(path, MAX_WRENCH_JSON_BYTES, "JSON file")) as unknown;
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
export function writePrivateJson(path: string, value: unknown, options: { readonly privateParent?: boolean } = {}): void {
|
|
1650
|
+
const parent = dirname(path);
|
|
1651
|
+
const stateRoot = stateRootFor(path);
|
|
1652
|
+
if (stateRoot !== null) {
|
|
1653
|
+
const identity = ensureClaimedStateRoot(stateRoot);
|
|
1654
|
+
const segments = stateSegments(stateRoot, path);
|
|
1655
|
+
runStateHelper(stateRoot, identity, {
|
|
1656
|
+
kind: "write-file",
|
|
1657
|
+
segments,
|
|
1658
|
+
directoryExpectations: captureStateDirectoryExpectations(stateRoot, segments.slice(0, -1)),
|
|
1659
|
+
content: `${canonicalJson(value)}\n`,
|
|
1660
|
+
createOnly: false,
|
|
1661
|
+
expectedContentSha256: null,
|
|
1662
|
+
maximumExpectedContentBytes: DEFAULT_PRIVATE_STATE_EXPECTED_CONTENT_BYTES,
|
|
1663
|
+
});
|
|
1664
|
+
return;
|
|
1665
|
+
}
|
|
1666
|
+
const parentParts = ensureBoundNonStateDirectory(parent, options.privateParent === true);
|
|
1667
|
+
const destination = genericPathParts(join(parentParts.canonical, basename(path)));
|
|
1668
|
+
runPathHelper(destination.root, destination.rootIdentity, {
|
|
1669
|
+
kind: "write-file",
|
|
1670
|
+
segments: destination.segments,
|
|
1671
|
+
directoryExpectations: captureGenericDirectoryExpectations(destination.root, destination.segments.slice(0, -1)),
|
|
1672
|
+
content: `${canonicalJson(value)}\n`,
|
|
1673
|
+
createOnly: false,
|
|
1674
|
+
});
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
/**
|
|
1678
|
+
* Atomically replace one private state JSON file only when its complete
|
|
1679
|
+
* current bytes still match the caller's snapshot.
|
|
1680
|
+
*
|
|
1681
|
+
* The content hash includes the canonical trailing newline written by this
|
|
1682
|
+
* module. A false result is an ordinary compare-and-swap conflict; every
|
|
1683
|
+
* filesystem, ownership, permission, or helper failure still throws. State
|
|
1684
|
+
* and non-state Ghostget writers participate in the helper's per-leaf mutation
|
|
1685
|
+
* claim. As with the state helper, arbitrary same-UID writes made outside the
|
|
1686
|
+
* bounded Ghostget helper are outside that cooperative serialization boundary.
|
|
1687
|
+
*/
|
|
1688
|
+
export function writePrivateJsonIfUnchanged(
|
|
1689
|
+
path: string,
|
|
1690
|
+
value: unknown,
|
|
1691
|
+
options: {
|
|
1692
|
+
readonly expectedCurrentContentSha256: string;
|
|
1693
|
+
/**
|
|
1694
|
+
* Explicitly widen only the current-file read used to authenticate a
|
|
1695
|
+
* bounded recovery snapshot. New JSON remains subject to the ordinary
|
|
1696
|
+
* state-mutation byte cap.
|
|
1697
|
+
*/
|
|
1698
|
+
readonly maximumExpectedCurrentBytes?: number;
|
|
1699
|
+
/** Test-only seam that pauses the winning helper after exclusive admission. */
|
|
1700
|
+
readonly pauseAfterClaimForTest?: boolean;
|
|
1701
|
+
/** Test-only seam that pauses after reading a competing mutation claim. */
|
|
1702
|
+
readonly pauseAfterMutationClaimReadForTest?: boolean;
|
|
1703
|
+
/** Test-only seam that reports failure after the replacement is durable. */
|
|
1704
|
+
readonly failAfterCommitForTest?: boolean;
|
|
1705
|
+
/** Require a current-user-owned mode-0700 parent for a non-state path. */
|
|
1706
|
+
readonly privateParent?: boolean;
|
|
1707
|
+
},
|
|
1708
|
+
): boolean {
|
|
1709
|
+
if (!/^[0-9a-f]{64}$/u.test(options.expectedCurrentContentSha256)) {
|
|
1710
|
+
throw new Error("expected private state content hash is invalid");
|
|
1711
|
+
}
|
|
1712
|
+
const maximumExpectedCurrentBytes =
|
|
1713
|
+
options.maximumExpectedCurrentBytes
|
|
1714
|
+
?? DEFAULT_PRIVATE_STATE_EXPECTED_CONTENT_BYTES;
|
|
1715
|
+
if (
|
|
1716
|
+
!Number.isSafeInteger(maximumExpectedCurrentBytes)
|
|
1717
|
+
|| maximumExpectedCurrentBytes < 0
|
|
1718
|
+
|| maximumExpectedCurrentBytes > MAX_PRIVATE_STATE_EXPECTED_CONTENT_BYTES
|
|
1719
|
+
) {
|
|
1720
|
+
throw new Error("expected private state content byte bound is invalid");
|
|
1721
|
+
}
|
|
1722
|
+
if (
|
|
1723
|
+
(options.pauseAfterClaimForTest === true
|
|
1724
|
+
|| options.pauseAfterMutationClaimReadForTest === true
|
|
1725
|
+
|| options.failAfterCommitForTest === true)
|
|
1726
|
+
&& process.env.NODE_ENV !== "test"
|
|
1727
|
+
) {
|
|
1728
|
+
throw new Error("state CAS fault injection is available only in tests");
|
|
1729
|
+
}
|
|
1730
|
+
const stateRoot = stateRootFor(path);
|
|
1731
|
+
if (stateRoot === null) {
|
|
1732
|
+
if (
|
|
1733
|
+
options.pauseAfterClaimForTest === true
|
|
1734
|
+
|| options.pauseAfterMutationClaimReadForTest === true
|
|
1735
|
+
|| options.failAfterCommitForTest === true
|
|
1736
|
+
) {
|
|
1737
|
+
throw new Error("compare-and-swap fault injection requires a GHOSTGET_STATE_HOME path");
|
|
1738
|
+
}
|
|
1739
|
+
const parentParts = ensureBoundNonStateDirectory(
|
|
1740
|
+
dirname(path),
|
|
1741
|
+
options.privateParent === true,
|
|
1742
|
+
);
|
|
1743
|
+
const destination = genericPathParts(
|
|
1744
|
+
join(parentParts.canonical, basename(path)),
|
|
1745
|
+
);
|
|
1746
|
+
try {
|
|
1747
|
+
runPathHelper(destination.root, destination.rootIdentity, {
|
|
1748
|
+
kind: "write-file",
|
|
1749
|
+
segments: destination.segments,
|
|
1750
|
+
directoryExpectations: captureGenericDirectoryExpectations(
|
|
1751
|
+
destination.root,
|
|
1752
|
+
destination.segments.slice(0, -1),
|
|
1753
|
+
),
|
|
1754
|
+
content: `${canonicalJson(value)}\n`,
|
|
1755
|
+
createOnly: false,
|
|
1756
|
+
expectedContentSha256: options.expectedCurrentContentSha256,
|
|
1757
|
+
maximumExpectedContentBytes: maximumExpectedCurrentBytes,
|
|
1758
|
+
});
|
|
1759
|
+
return true;
|
|
1760
|
+
} catch (error) {
|
|
1761
|
+
if (
|
|
1762
|
+
error instanceof Error
|
|
1763
|
+
&& error.message.includes("file content no longer matches the expected hash")
|
|
1764
|
+
) return false;
|
|
1765
|
+
throw error;
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
const identity = ensureClaimedStateRoot(stateRoot);
|
|
1769
|
+
const segments = stateSegments(stateRoot, path);
|
|
1770
|
+
const replacementContent = `${canonicalJson(value)}\n`;
|
|
1771
|
+
try {
|
|
1772
|
+
runStateHelper(
|
|
1773
|
+
stateRoot,
|
|
1774
|
+
identity,
|
|
1775
|
+
{
|
|
1776
|
+
kind: "write-file",
|
|
1777
|
+
segments,
|
|
1778
|
+
directoryExpectations: captureStateDirectoryExpectations(
|
|
1779
|
+
stateRoot,
|
|
1780
|
+
segments.slice(0, -1),
|
|
1781
|
+
),
|
|
1782
|
+
content: replacementContent,
|
|
1783
|
+
createOnly: false,
|
|
1784
|
+
expectedContentSha256: options.expectedCurrentContentSha256,
|
|
1785
|
+
maximumExpectedContentBytes: maximumExpectedCurrentBytes,
|
|
1786
|
+
},
|
|
1787
|
+
false,
|
|
1788
|
+
options.pauseAfterClaimForTest === true
|
|
1789
|
+
? "pause-after-cas-claim"
|
|
1790
|
+
: options.pauseAfterMutationClaimReadForTest === true
|
|
1791
|
+
? "pause-after-mutation-claim-read"
|
|
1792
|
+
: options.failAfterCommitForTest === true
|
|
1793
|
+
? "fail-after-cas-commit"
|
|
1794
|
+
: undefined,
|
|
1795
|
+
);
|
|
1796
|
+
return true;
|
|
1797
|
+
} catch (error) {
|
|
1798
|
+
if (
|
|
1799
|
+
error instanceof Error
|
|
1800
|
+
&& error.message.includes("state file content no longer matches the expected hash")
|
|
1801
|
+
) {
|
|
1802
|
+
return false;
|
|
1803
|
+
}
|
|
1804
|
+
throw error;
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
export function createPrivateJsonIfAbsent(
|
|
1809
|
+
path: string,
|
|
1810
|
+
value: unknown,
|
|
1811
|
+
options: {
|
|
1812
|
+
/** Test seam for deterministic publication interleavings. Production callers must leave this unset. */
|
|
1813
|
+
readonly beforePublish?: (temporaryPath: string) => void;
|
|
1814
|
+
readonly environment?: Readonly<Record<string, string | undefined>>;
|
|
1815
|
+
/** Bind the immediate legacy-schema state parent to an identity already validated by the caller. */
|
|
1816
|
+
readonly expectedStateParent?: Readonly<PrivateDirectoryIdentity>;
|
|
1817
|
+
readonly expectedStateDirectories?: readonly Readonly<PrivateDirectoryIdentity>[];
|
|
1818
|
+
readonly privateParent?: boolean;
|
|
1819
|
+
} = {},
|
|
1820
|
+
): { readonly created: boolean } {
|
|
1821
|
+
const parent = dirname(path);
|
|
1822
|
+
// An explicit environment means the caller intends a dedicated Ghostget state path.
|
|
1823
|
+
// Register and validate that root before classifying the destination.
|
|
1824
|
+
if (options.environment !== undefined) ghostgetStateHome(options.environment);
|
|
1825
|
+
const stateRoot = stateRootFor(path);
|
|
1826
|
+
if (stateRoot !== null) {
|
|
1827
|
+
const identity = ensureClaimedStateRoot(stateRoot);
|
|
1828
|
+
if (options.beforePublish !== undefined) {
|
|
1829
|
+
const previewDirectory = mkdtempSync(join(tmpdir(), "wrench-create-preview-"));
|
|
1830
|
+
const previewPath = join(previewDirectory, "value.json");
|
|
1831
|
+
try {
|
|
1832
|
+
const directoryDescriptor = openSync(
|
|
1833
|
+
previewDirectory,
|
|
1834
|
+
constants.O_RDONLY | ("O_NOFOLLOW" in constants ? constants.O_NOFOLLOW : 0) | ("O_DIRECTORY" in constants ? constants.O_DIRECTORY : 0),
|
|
1835
|
+
);
|
|
1836
|
+
try {
|
|
1837
|
+
fchmodSync(directoryDescriptor, 0o700);
|
|
1838
|
+
} finally {
|
|
1839
|
+
closeSync(directoryDescriptor);
|
|
1840
|
+
}
|
|
1841
|
+
writeFileSync(previewPath, `${canonicalJson(value)}\n`, { encoding: "utf8", flag: "wx", mode: 0o600 });
|
|
1842
|
+
options.beforePublish(previewPath);
|
|
1843
|
+
} finally {
|
|
1844
|
+
rmSync(previewDirectory, { recursive: true, force: true });
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
const segments = stateSegments(stateRoot, path);
|
|
1848
|
+
const directoryExpectations = [...captureStateDirectoryExpectations(stateRoot, segments.slice(0, -1))];
|
|
1849
|
+
if (options.expectedStateParent !== undefined && options.expectedStateDirectories !== undefined) {
|
|
1850
|
+
throw new Error("expected state parent cannot be combined with expected state directories");
|
|
1851
|
+
}
|
|
1852
|
+
if (options.expectedStateDirectories !== undefined) {
|
|
1853
|
+
if (options.expectedStateDirectories.length !== directoryExpectations.length) {
|
|
1854
|
+
throw new Error("expected state-directory identity count does not match the JSON path");
|
|
1855
|
+
}
|
|
1856
|
+
directoryExpectations.splice(0, directoryExpectations.length, ...options.expectedStateDirectories);
|
|
1857
|
+
}
|
|
1858
|
+
if (options.expectedStateParent !== undefined) {
|
|
1859
|
+
if (directoryExpectations.length === 0) throw new Error("expected state parent requires a nested JSON path");
|
|
1860
|
+
directoryExpectations[directoryExpectations.length - 1] = options.expectedStateParent;
|
|
1861
|
+
}
|
|
1862
|
+
const response = runStateHelper(stateRoot, identity, {
|
|
1863
|
+
kind: "write-file",
|
|
1864
|
+
segments,
|
|
1865
|
+
directoryExpectations,
|
|
1866
|
+
content: `${canonicalJson(value)}\n`,
|
|
1867
|
+
createOnly: true,
|
|
1868
|
+
expectedContentSha256: null,
|
|
1869
|
+
maximumExpectedContentBytes: DEFAULT_PRIVATE_STATE_EXPECTED_CONTENT_BYTES,
|
|
1870
|
+
});
|
|
1871
|
+
return { created: response.created === true };
|
|
1872
|
+
}
|
|
1873
|
+
if (options.expectedStateParent !== undefined || options.expectedStateDirectories !== undefined) {
|
|
1874
|
+
throw new Error("state-directory identity expectations require an ghostget state path");
|
|
1875
|
+
}
|
|
1876
|
+
if (options.environment !== undefined) assertSafeStatePath(path, options.environment);
|
|
1877
|
+
if (options.beforePublish !== undefined) {
|
|
1878
|
+
const previewDirectory = mkdtempSync(join(tmpdir(), "wrench-create-preview-"));
|
|
1879
|
+
const previewPath = join(previewDirectory, "value.json");
|
|
1880
|
+
try {
|
|
1881
|
+
chmodSync(previewDirectory, 0o700);
|
|
1882
|
+
writeFileSync(previewPath, `${canonicalJson(value)}\n`, { encoding: "utf8", flag: "wx", mode: 0o600 });
|
|
1883
|
+
options.beforePublish(previewPath);
|
|
1884
|
+
} finally {
|
|
1885
|
+
rmSync(previewDirectory, { recursive: true, force: true });
|
|
1886
|
+
}
|
|
1887
|
+
}
|
|
1888
|
+
const parentParts = ensureBoundNonStateDirectory(parent, options.privateParent === true);
|
|
1889
|
+
const destination = genericPathParts(join(parentParts.canonical, basename(path)));
|
|
1890
|
+
const response = runPathHelper(destination.root, destination.rootIdentity, {
|
|
1891
|
+
kind: "write-file",
|
|
1892
|
+
segments: destination.segments,
|
|
1893
|
+
directoryExpectations: captureGenericDirectoryExpectations(destination.root, destination.segments.slice(0, -1)),
|
|
1894
|
+
content: `${canonicalJson(value)}\n`,
|
|
1895
|
+
createOnly: true,
|
|
1896
|
+
});
|
|
1897
|
+
return { created: response.created === true };
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
export function removePrivateStateFile(
|
|
1901
|
+
path: string,
|
|
1902
|
+
environment: Readonly<Record<string, string | undefined>> = process.env,
|
|
1903
|
+
expectedStateParent?: Readonly<PrivateDirectoryIdentity>,
|
|
1904
|
+
): boolean {
|
|
1905
|
+
assertSafeStatePath(path, environment);
|
|
1906
|
+
const root = ghostgetStateHome(environment);
|
|
1907
|
+
ensurePrivateDirectory(root);
|
|
1908
|
+
const record = assertStateRootIdentity(root);
|
|
1909
|
+
if (record.identity === null || !record.claimed) throw new Error("ghostget state root is not claimed");
|
|
1910
|
+
const segments = stateSegments(root, path);
|
|
1911
|
+
const directoryExpectations = [...captureStateDirectoryExpectations(root, segments.slice(0, -1))];
|
|
1912
|
+
if (expectedStateParent !== undefined && directoryExpectations.length > 0 && directoryExpectations.at(-1) !== null) {
|
|
1913
|
+
directoryExpectations[directoryExpectations.length - 1] = expectedStateParent;
|
|
1914
|
+
}
|
|
1915
|
+
const response = runStateHelper(root, record.identity, {
|
|
1916
|
+
kind: "remove-file",
|
|
1917
|
+
segments,
|
|
1918
|
+
directoryExpectations,
|
|
1919
|
+
});
|
|
1920
|
+
return response.removed === true;
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
export function removePrivateStateFileIfUnchanged(
|
|
1924
|
+
path: string,
|
|
1925
|
+
options: {
|
|
1926
|
+
readonly expectedCurrentContentSha256: string;
|
|
1927
|
+
},
|
|
1928
|
+
environment: Readonly<Record<string, string | undefined>> = process.env,
|
|
1929
|
+
): boolean {
|
|
1930
|
+
if (!/^[0-9a-f]{64}$/u.test(options.expectedCurrentContentSha256)) {
|
|
1931
|
+
throw new Error("expected private state content hash is invalid");
|
|
1932
|
+
}
|
|
1933
|
+
assertSafeStatePath(path, environment);
|
|
1934
|
+
const root = ghostgetStateHome(environment);
|
|
1935
|
+
const identity = ensureClaimedStateRoot(root);
|
|
1936
|
+
const segments = stateSegments(root, path);
|
|
1937
|
+
if (segments.length < 2) {
|
|
1938
|
+
throw new Error("conditional state-file removal requires a nested state path");
|
|
1939
|
+
}
|
|
1940
|
+
const response = runStateHelper(root, identity, {
|
|
1941
|
+
kind: "remove-file-if-unchanged",
|
|
1942
|
+
segments,
|
|
1943
|
+
directoryExpectations: captureStateDirectoryExpectations(
|
|
1944
|
+
root,
|
|
1945
|
+
segments.slice(0, -1),
|
|
1946
|
+
),
|
|
1947
|
+
expectedContentSha256: options.expectedCurrentContentSha256,
|
|
1948
|
+
});
|
|
1949
|
+
return response.removed === true;
|
|
1950
|
+
}
|
|
1951
|
+
|
|
1952
|
+
export function listPrivateStateDirectory(
|
|
1953
|
+
path: string,
|
|
1954
|
+
environment: Readonly<Record<string, string | undefined>> = process.env,
|
|
1955
|
+
expectedTarget?: Readonly<PrivateDirectoryIdentity>,
|
|
1956
|
+
options: {
|
|
1957
|
+
readonly recoverOrphanedMutationClaims?: boolean;
|
|
1958
|
+
} = {},
|
|
1959
|
+
): readonly PrivateStateDirectoryEntry[] {
|
|
1960
|
+
return snapshotPrivateStateDirectory(
|
|
1961
|
+
path,
|
|
1962
|
+
environment,
|
|
1963
|
+
expectedTarget,
|
|
1964
|
+
options,
|
|
1965
|
+
).entries;
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
export function snapshotPrivateStateDirectory(
|
|
1969
|
+
path: string,
|
|
1970
|
+
environment: Readonly<Record<string, string | undefined>> = process.env,
|
|
1971
|
+
expectedTarget?: Readonly<PrivateDirectoryIdentity>,
|
|
1972
|
+
options: {
|
|
1973
|
+
readonly recoverOrphanedMutationClaims?: boolean;
|
|
1974
|
+
} = {},
|
|
1975
|
+
): PrivateStateDirectorySnapshot {
|
|
1976
|
+
const root = ghostgetStateHome(environment);
|
|
1977
|
+
const identity = ensureClaimedStateRoot(root);
|
|
1978
|
+
const segments = stateSegments(root, path);
|
|
1979
|
+
if (segments.length === 0) throw new Error("the ghostget state root cannot be listed as a state collection");
|
|
1980
|
+
const directoryExpectations = [...captureStateDirectoryExpectations(root, segments)];
|
|
1981
|
+
if (expectedTarget !== undefined && directoryExpectations.at(-1) !== null) {
|
|
1982
|
+
directoryExpectations[directoryExpectations.length - 1] = expectedTarget;
|
|
1983
|
+
}
|
|
1984
|
+
const response = runStateHelper(root, identity, {
|
|
1985
|
+
kind: "list-directory",
|
|
1986
|
+
segments,
|
|
1987
|
+
directoryExpectations,
|
|
1988
|
+
recoverOrphanedMutationClaims:
|
|
1989
|
+
options.recoverOrphanedMutationClaims === true,
|
|
1990
|
+
});
|
|
1991
|
+
if (response.entries === undefined) throw new Error("state helper omitted its directory entries");
|
|
1992
|
+
return Object.freeze({
|
|
1993
|
+
identity: response.targetIdentity ?? null,
|
|
1994
|
+
entries: response.entries,
|
|
1995
|
+
});
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
/**
|
|
1999
|
+
* Read an exact caller-ordered set of files through one inode-bound helper.
|
|
2000
|
+
*
|
|
2001
|
+
* File disappearance and unsafe or unstable file state are inert per-file
|
|
2002
|
+
* results. A changed directory identity rejects the whole snapshot.
|
|
2003
|
+
*/
|
|
2004
|
+
export function readPrivateStateFilesBatch(
|
|
2005
|
+
path: string,
|
|
2006
|
+
names: readonly string[],
|
|
2007
|
+
options: {
|
|
2008
|
+
readonly maximumBytesPerFile: number;
|
|
2009
|
+
readonly maximumTotalBytes: number;
|
|
2010
|
+
readonly environment?: Readonly<Record<string, string | undefined>>;
|
|
2011
|
+
readonly expectedDirectoryIdentity?: Readonly<PrivateDirectoryIdentity>;
|
|
2012
|
+
/** Narrow child-helper interleaving used only by deterministic tests. */
|
|
2013
|
+
readonly faultForTest?: PrivateStateBatchReadFaultForTest;
|
|
2014
|
+
},
|
|
2015
|
+
): readonly PrivateStateBatchReadResult[] {
|
|
2016
|
+
if (
|
|
2017
|
+
!Number.isSafeInteger(options.maximumBytesPerFile)
|
|
2018
|
+
|| options.maximumBytesPerFile < 0
|
|
2019
|
+
|| options.maximumBytesPerFile > MAX_PRIVATE_STATE_BATCH_FILE_BYTES
|
|
2020
|
+
) {
|
|
2021
|
+
throw new Error("private state batch per-file byte bound is invalid");
|
|
2022
|
+
}
|
|
2023
|
+
if (
|
|
2024
|
+
!Number.isSafeInteger(options.maximumTotalBytes)
|
|
2025
|
+
|| options.maximumTotalBytes < 0
|
|
2026
|
+
|| options.maximumTotalBytes > MAX_PRIVATE_STATE_BATCH_TOTAL_BYTES
|
|
2027
|
+
) {
|
|
2028
|
+
throw new Error("private state batch aggregate byte bound is invalid");
|
|
2029
|
+
}
|
|
2030
|
+
if (
|
|
2031
|
+
!Array.isArray(names)
|
|
2032
|
+
|| names.length > MAX_PRIVATE_STATE_BATCH_FILES
|
|
2033
|
+
) {
|
|
2034
|
+
throw new Error("private state batch file count is invalid");
|
|
2035
|
+
}
|
|
2036
|
+
let nameBytes = 0;
|
|
2037
|
+
const validatedNames: string[] = [];
|
|
2038
|
+
for (const name of names as readonly unknown[]) {
|
|
2039
|
+
if (!isSafeBatchFileName(name)) {
|
|
2040
|
+
throw new Error("private state batch contains an invalid file name");
|
|
2041
|
+
}
|
|
2042
|
+
nameBytes += Buffer.byteLength(name, "utf8");
|
|
2043
|
+
if (nameBytes > MAX_PRIVATE_STATE_BATCH_NAME_BYTES) {
|
|
2044
|
+
throw new Error("private state batch file names exceed their byte bound");
|
|
2045
|
+
}
|
|
2046
|
+
validatedNames.push(name);
|
|
2047
|
+
}
|
|
2048
|
+
if (new Set(validatedNames).size !== validatedNames.length) {
|
|
2049
|
+
throw new Error("private state batch file names must be unique");
|
|
2050
|
+
}
|
|
2051
|
+
if (options.faultForTest !== undefined && process.env.NODE_ENV !== "test") {
|
|
2052
|
+
throw new Error("private state batch fault injection is available only under the test runtime");
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
const environment = options.environment ?? process.env;
|
|
2056
|
+
let root: string;
|
|
2057
|
+
let identity: StateRootIdentity;
|
|
2058
|
+
let segments: readonly string[];
|
|
2059
|
+
let directoryExpectations: StateDirectoryExpectation[];
|
|
2060
|
+
try {
|
|
2061
|
+
assertSafeStatePath(path, environment);
|
|
2062
|
+
root = ghostgetStateHome(environment);
|
|
2063
|
+
identity = ensureClaimedStateRoot(root);
|
|
2064
|
+
segments = stateSegments(root, path);
|
|
2065
|
+
if (segments.length === 0) {
|
|
2066
|
+
throw new Error("root is not a state collection");
|
|
2067
|
+
}
|
|
2068
|
+
directoryExpectations = [
|
|
2069
|
+
...captureStateDirectoryExpectations(root, segments),
|
|
2070
|
+
];
|
|
2071
|
+
} catch {
|
|
2072
|
+
throw new Error("private state batch directory is unsafe");
|
|
2073
|
+
}
|
|
2074
|
+
const capturedTarget = directoryExpectations.at(-1);
|
|
2075
|
+
if (capturedTarget === undefined || capturedTarget === null) {
|
|
2076
|
+
throw new Error("private state batch directory is absent");
|
|
2077
|
+
}
|
|
2078
|
+
const expectedTarget = options.expectedDirectoryIdentity ?? capturedTarget;
|
|
2079
|
+
if (!sameIdentity(capturedTarget, expectedTarget)) {
|
|
2080
|
+
throw new Error("private state batch directory changed identity");
|
|
2081
|
+
}
|
|
2082
|
+
directoryExpectations[directoryExpectations.length - 1] = expectedTarget;
|
|
2083
|
+
|
|
2084
|
+
let response: StateHelperResponse;
|
|
2085
|
+
try {
|
|
2086
|
+
response = runStateHelper(root, identity, {
|
|
2087
|
+
kind: "batch-read-files",
|
|
2088
|
+
segments,
|
|
2089
|
+
directoryExpectations,
|
|
2090
|
+
names: validatedNames,
|
|
2091
|
+
maximumBytesPerFile: options.maximumBytesPerFile,
|
|
2092
|
+
maximumTotalBytes: options.maximumTotalBytes,
|
|
2093
|
+
}, false, options.faultForTest);
|
|
2094
|
+
} catch (error) {
|
|
2095
|
+
if (
|
|
2096
|
+
error instanceof Error
|
|
2097
|
+
&& (
|
|
2098
|
+
error.message.includes("malformed response")
|
|
2099
|
+
|| error.message.includes("invalid JSON")
|
|
2100
|
+
)
|
|
2101
|
+
) {
|
|
2102
|
+
throw new Error("state helper returned a malformed batch response");
|
|
2103
|
+
}
|
|
2104
|
+
throw new Error("private state batch helper rejected the snapshot");
|
|
2105
|
+
}
|
|
2106
|
+
if (
|
|
2107
|
+
response.targetIdentity === undefined
|
|
2108
|
+
|| !sameIdentity(response.targetIdentity, expectedTarget)
|
|
2109
|
+
) {
|
|
2110
|
+
throw new Error("private state batch directory changed identity");
|
|
2111
|
+
}
|
|
2112
|
+
let currentTarget: StateRootIdentity | null;
|
|
2113
|
+
try {
|
|
2114
|
+
currentTarget = inspectRealDirectoryIdentity(path);
|
|
2115
|
+
} catch {
|
|
2116
|
+
throw new Error("private state batch directory changed identity");
|
|
2117
|
+
}
|
|
2118
|
+
if (!sameIdentity(currentTarget, expectedTarget)) {
|
|
2119
|
+
throw new Error("private state batch directory changed identity");
|
|
2120
|
+
}
|
|
2121
|
+
const files = response.files;
|
|
2122
|
+
if (
|
|
2123
|
+
files === undefined
|
|
2124
|
+
|| files.length !== validatedNames.length
|
|
2125
|
+
|| files.some((file, index) => file.name !== validatedNames[index])
|
|
2126
|
+
) {
|
|
2127
|
+
throw new Error("state helper returned a malformed batch response");
|
|
2128
|
+
}
|
|
2129
|
+
|
|
2130
|
+
const results: PrivateStateBatchReadResult[] = [];
|
|
2131
|
+
let totalBytes = 0;
|
|
2132
|
+
for (const file of files) {
|
|
2133
|
+
if (file.status === "absent") {
|
|
2134
|
+
results.push(Object.freeze({ name: file.name, status: "absent" }));
|
|
2135
|
+
continue;
|
|
2136
|
+
}
|
|
2137
|
+
if (file.status === "invalid") {
|
|
2138
|
+
results.push(Object.freeze({
|
|
2139
|
+
name: file.name,
|
|
2140
|
+
status: "invalid",
|
|
2141
|
+
reason: file.reason,
|
|
2142
|
+
}));
|
|
2143
|
+
continue;
|
|
2144
|
+
}
|
|
2145
|
+
const content = decodeCanonicalBase64(
|
|
2146
|
+
file.contentBase64,
|
|
2147
|
+
options.maximumBytesPerFile,
|
|
2148
|
+
);
|
|
2149
|
+
if (content === null) {
|
|
2150
|
+
throw new Error("state helper returned a malformed batch response");
|
|
2151
|
+
}
|
|
2152
|
+
totalBytes += content.byteLength;
|
|
2153
|
+
if (totalBytes > options.maximumTotalBytes) {
|
|
2154
|
+
throw new Error("state helper returned a malformed batch response");
|
|
2155
|
+
}
|
|
2156
|
+
try {
|
|
2157
|
+
results.push(Object.freeze({
|
|
2158
|
+
name: file.name,
|
|
2159
|
+
status: "present",
|
|
2160
|
+
content: new TextDecoder("utf-8", {
|
|
2161
|
+
fatal: true,
|
|
2162
|
+
ignoreBOM: true,
|
|
2163
|
+
}).decode(content),
|
|
2164
|
+
}));
|
|
2165
|
+
} catch {
|
|
2166
|
+
results.push(Object.freeze({
|
|
2167
|
+
name: file.name,
|
|
2168
|
+
status: "invalid",
|
|
2169
|
+
reason: "invalid-utf8",
|
|
2170
|
+
}));
|
|
2171
|
+
}
|
|
2172
|
+
}
|
|
2173
|
+
return Object.freeze(results);
|
|
2174
|
+
}
|
|
2175
|
+
|
|
2176
|
+
export function readPrivateStateFilesBatched(
|
|
2177
|
+
path: string,
|
|
2178
|
+
names: readonly string[],
|
|
2179
|
+
options: {
|
|
2180
|
+
readonly maximumBytesPerFile: number;
|
|
2181
|
+
readonly environment?: Readonly<Record<string, string | undefined>>;
|
|
2182
|
+
readonly expectedDirectoryIdentity: Readonly<PrivateDirectoryIdentity>;
|
|
2183
|
+
},
|
|
2184
|
+
): readonly PrivateStateBatchReadResult[] {
|
|
2185
|
+
if (!Array.isArray(names) || names.length > 10_000) {
|
|
2186
|
+
throw new Error("private state collection file count is invalid");
|
|
2187
|
+
}
|
|
2188
|
+
if (
|
|
2189
|
+
(names as readonly unknown[]).some(
|
|
2190
|
+
(name) => !isSafeBatchFileName(name),
|
|
2191
|
+
)
|
|
2192
|
+
) {
|
|
2193
|
+
throw new Error("private state collection contains an invalid file name");
|
|
2194
|
+
}
|
|
2195
|
+
if (new Set(names).size !== names.length) {
|
|
2196
|
+
throw new Error("private state collection file names must be unique");
|
|
2197
|
+
}
|
|
2198
|
+
if (
|
|
2199
|
+
!Number.isSafeInteger(options.maximumBytesPerFile)
|
|
2200
|
+
|| options.maximumBytesPerFile < 0
|
|
2201
|
+
|| options.maximumBytesPerFile > MAX_PRIVATE_STATE_BATCH_FILE_BYTES
|
|
2202
|
+
) {
|
|
2203
|
+
throw new Error("private state collection per-file byte bound is invalid");
|
|
2204
|
+
}
|
|
2205
|
+
const filesPerBatch = options.maximumBytesPerFile === 0
|
|
2206
|
+
? MAX_PRIVATE_STATE_BATCH_FILES
|
|
2207
|
+
: Math.min(
|
|
2208
|
+
MAX_PRIVATE_STATE_BATCH_FILES,
|
|
2209
|
+
Math.floor(
|
|
2210
|
+
MAX_PRIVATE_STATE_BATCH_TOTAL_BYTES / options.maximumBytesPerFile,
|
|
2211
|
+
),
|
|
2212
|
+
);
|
|
2213
|
+
const results: PrivateStateBatchReadResult[] = [];
|
|
2214
|
+
for (let index = 0; index < names.length; index += filesPerBatch) {
|
|
2215
|
+
const batchNames = names.slice(index, index + filesPerBatch);
|
|
2216
|
+
results.push(...readPrivateStateFilesBatch(path, batchNames, {
|
|
2217
|
+
maximumBytesPerFile: options.maximumBytesPerFile,
|
|
2218
|
+
maximumTotalBytes: options.maximumBytesPerFile * batchNames.length,
|
|
2219
|
+
...(options.environment === undefined
|
|
2220
|
+
? {}
|
|
2221
|
+
: { environment: options.environment }),
|
|
2222
|
+
expectedDirectoryIdentity: options.expectedDirectoryIdentity,
|
|
2223
|
+
}));
|
|
2224
|
+
}
|
|
2225
|
+
return Object.freeze(results);
|
|
2226
|
+
}
|
|
2227
|
+
|
|
2228
|
+
export function readPrivateStateChildFilesBatch(
|
|
2229
|
+
path: string,
|
|
2230
|
+
files: readonly PrivateStateBatchChildFile[],
|
|
2231
|
+
options: {
|
|
2232
|
+
readonly maximumBytesPerFile: number;
|
|
2233
|
+
readonly maximumTotalBytes: number;
|
|
2234
|
+
readonly environment?: Readonly<Record<string, string | undefined>>;
|
|
2235
|
+
readonly expectedDirectoryIdentity?: Readonly<PrivateDirectoryIdentity>;
|
|
2236
|
+
/** Narrow child-helper interleaving used only by deterministic tests. */
|
|
2237
|
+
readonly faultForTest?: PrivateStateBatchReadFaultForTest;
|
|
2238
|
+
},
|
|
2239
|
+
): readonly PrivateStateBatchChildReadResult[] {
|
|
2240
|
+
if (
|
|
2241
|
+
!Number.isSafeInteger(options.maximumBytesPerFile)
|
|
2242
|
+
|| options.maximumBytesPerFile < 0
|
|
2243
|
+
|| options.maximumBytesPerFile > MAX_PRIVATE_STATE_BATCH_FILE_BYTES
|
|
2244
|
+
) {
|
|
2245
|
+
throw new Error("private state child batch per-file byte bound is invalid");
|
|
2246
|
+
}
|
|
2247
|
+
if (
|
|
2248
|
+
!Number.isSafeInteger(options.maximumTotalBytes)
|
|
2249
|
+
|| options.maximumTotalBytes < 0
|
|
2250
|
+
|| options.maximumTotalBytes > MAX_PRIVATE_STATE_BATCH_TOTAL_BYTES
|
|
2251
|
+
) {
|
|
2252
|
+
throw new Error("private state child batch aggregate byte bound is invalid");
|
|
2253
|
+
}
|
|
2254
|
+
if (!Array.isArray(files) || files.length > MAX_PRIVATE_STATE_BATCH_FILES) {
|
|
2255
|
+
throw new Error("private state child batch file count is invalid");
|
|
2256
|
+
}
|
|
2257
|
+
let nameBytes = 0;
|
|
2258
|
+
const keys = new Set<string>();
|
|
2259
|
+
const validatedFiles: PrivateStateBatchChildFile[] = [];
|
|
2260
|
+
for (const file of files as readonly unknown[]) {
|
|
2261
|
+
if (
|
|
2262
|
+
!isRecord(file)
|
|
2263
|
+
|| !exactObjectKeys(file, [
|
|
2264
|
+
"directoryName",
|
|
2265
|
+
"directoryIdentity",
|
|
2266
|
+
"fileName",
|
|
2267
|
+
])
|
|
2268
|
+
|| !isSafeBatchFileName(file.directoryName)
|
|
2269
|
+
|| !isSafeBatchFileName(file.fileName)
|
|
2270
|
+
) {
|
|
2271
|
+
throw new Error("private state child batch request is invalid");
|
|
2272
|
+
}
|
|
2273
|
+
const directoryIdentity = parseResponseIdentity(file.directoryIdentity);
|
|
2274
|
+
if (directoryIdentity === null) {
|
|
2275
|
+
throw new Error("private state child batch directory identity is invalid");
|
|
2276
|
+
}
|
|
2277
|
+
nameBytes += Buffer.byteLength(file.directoryName, "utf8")
|
|
2278
|
+
+ Buffer.byteLength(file.fileName, "utf8");
|
|
2279
|
+
if (nameBytes > MAX_PRIVATE_STATE_BATCH_NAME_BYTES) {
|
|
2280
|
+
throw new Error("private state child batch names exceed their byte bound");
|
|
2281
|
+
}
|
|
2282
|
+
const key = `${file.directoryName}\u0000${file.fileName}`;
|
|
2283
|
+
if (keys.has(key)) {
|
|
2284
|
+
throw new Error("private state child batch requests must be unique");
|
|
2285
|
+
}
|
|
2286
|
+
keys.add(key);
|
|
2287
|
+
validatedFiles.push({
|
|
2288
|
+
directoryName: file.directoryName,
|
|
2289
|
+
directoryIdentity,
|
|
2290
|
+
fileName: file.fileName,
|
|
2291
|
+
});
|
|
2292
|
+
}
|
|
2293
|
+
if (options.faultForTest !== undefined && process.env.NODE_ENV !== "test") {
|
|
2294
|
+
throw new Error("private state child batch fault injection is available only under the test runtime");
|
|
2295
|
+
}
|
|
2296
|
+
|
|
2297
|
+
const environment = options.environment ?? process.env;
|
|
2298
|
+
let root: string;
|
|
2299
|
+
let identity: StateRootIdentity;
|
|
2300
|
+
let segments: readonly string[];
|
|
2301
|
+
let directoryExpectations: StateDirectoryExpectation[];
|
|
2302
|
+
try {
|
|
2303
|
+
assertSafeStatePath(path, environment);
|
|
2304
|
+
root = ghostgetStateHome(environment);
|
|
2305
|
+
identity = ensureClaimedStateRoot(root);
|
|
2306
|
+
segments = stateSegments(root, path);
|
|
2307
|
+
if (segments.length === 0) {
|
|
2308
|
+
throw new Error("root is not a state collection");
|
|
2309
|
+
}
|
|
2310
|
+
directoryExpectations = [
|
|
2311
|
+
...captureStateDirectoryExpectations(root, segments),
|
|
2312
|
+
];
|
|
2313
|
+
} catch {
|
|
2314
|
+
throw new Error("private state child batch directory is unsafe");
|
|
2315
|
+
}
|
|
2316
|
+
const capturedTarget = directoryExpectations.at(-1);
|
|
2317
|
+
if (capturedTarget === undefined || capturedTarget === null) {
|
|
2318
|
+
throw new Error("private state child batch directory is absent");
|
|
2319
|
+
}
|
|
2320
|
+
const expectedTarget = options.expectedDirectoryIdentity ?? capturedTarget;
|
|
2321
|
+
if (!sameIdentity(capturedTarget, expectedTarget)) {
|
|
2322
|
+
throw new Error("private state child batch directory changed identity");
|
|
2323
|
+
}
|
|
2324
|
+
directoryExpectations[directoryExpectations.length - 1] = expectedTarget;
|
|
2325
|
+
|
|
2326
|
+
let response: StateHelperResponse;
|
|
2327
|
+
try {
|
|
2328
|
+
response = runStateHelper(root, identity, {
|
|
2329
|
+
kind: "batch-read-child-files",
|
|
2330
|
+
segments,
|
|
2331
|
+
directoryExpectations,
|
|
2332
|
+
files: validatedFiles,
|
|
2333
|
+
maximumBytesPerFile: options.maximumBytesPerFile,
|
|
2334
|
+
maximumTotalBytes: options.maximumTotalBytes,
|
|
2335
|
+
}, false, options.faultForTest);
|
|
2336
|
+
} catch (error) {
|
|
2337
|
+
if (
|
|
2338
|
+
error instanceof Error
|
|
2339
|
+
&& (
|
|
2340
|
+
error.message.includes("malformed response")
|
|
2341
|
+
|| error.message.includes("invalid JSON")
|
|
2342
|
+
)
|
|
2343
|
+
) {
|
|
2344
|
+
throw new Error("state helper returned a malformed child batch response");
|
|
2345
|
+
}
|
|
2346
|
+
throw new Error("private state child batch helper rejected the snapshot");
|
|
2347
|
+
}
|
|
2348
|
+
if (
|
|
2349
|
+
response.targetIdentity === undefined
|
|
2350
|
+
|| !sameIdentity(response.targetIdentity, expectedTarget)
|
|
2351
|
+
) {
|
|
2352
|
+
throw new Error("private state child batch directory changed identity");
|
|
2353
|
+
}
|
|
2354
|
+
let currentTarget: StateRootIdentity | null;
|
|
2355
|
+
try {
|
|
2356
|
+
currentTarget = inspectRealDirectoryIdentity(path);
|
|
2357
|
+
} catch {
|
|
2358
|
+
throw new Error("private state child batch directory changed identity");
|
|
2359
|
+
}
|
|
2360
|
+
if (!sameIdentity(currentTarget, expectedTarget)) {
|
|
2361
|
+
throw new Error("private state child batch directory changed identity");
|
|
2362
|
+
}
|
|
2363
|
+
const childFiles = response.childFiles;
|
|
2364
|
+
if (
|
|
2365
|
+
childFiles === undefined
|
|
2366
|
+
|| childFiles.length !== validatedFiles.length
|
|
2367
|
+
|| childFiles.some((file, index) => (
|
|
2368
|
+
file.directoryName !== validatedFiles[index]?.directoryName
|
|
2369
|
+
|| file.fileName !== validatedFiles[index]?.fileName
|
|
2370
|
+
))
|
|
2371
|
+
) {
|
|
2372
|
+
throw new Error("state helper returned a malformed child batch response");
|
|
2373
|
+
}
|
|
2374
|
+
|
|
2375
|
+
const results: PrivateStateBatchChildReadResult[] = [];
|
|
2376
|
+
let totalBytes = 0;
|
|
2377
|
+
for (const file of childFiles) {
|
|
2378
|
+
if (file.status === "absent") {
|
|
2379
|
+
results.push(Object.freeze({
|
|
2380
|
+
directoryName: file.directoryName,
|
|
2381
|
+
fileName: file.fileName,
|
|
2382
|
+
status: "absent",
|
|
2383
|
+
}));
|
|
2384
|
+
continue;
|
|
2385
|
+
}
|
|
2386
|
+
if (file.status === "invalid") {
|
|
2387
|
+
results.push(Object.freeze({
|
|
2388
|
+
directoryName: file.directoryName,
|
|
2389
|
+
fileName: file.fileName,
|
|
2390
|
+
status: "invalid",
|
|
2391
|
+
reason: file.reason,
|
|
2392
|
+
}));
|
|
2393
|
+
continue;
|
|
2394
|
+
}
|
|
2395
|
+
const content = decodeCanonicalBase64(
|
|
2396
|
+
file.contentBase64,
|
|
2397
|
+
options.maximumBytesPerFile,
|
|
2398
|
+
);
|
|
2399
|
+
if (content === null) {
|
|
2400
|
+
throw new Error("state helper returned a malformed child batch response");
|
|
2401
|
+
}
|
|
2402
|
+
totalBytes += content.byteLength;
|
|
2403
|
+
if (totalBytes > options.maximumTotalBytes) {
|
|
2404
|
+
throw new Error("state helper returned a malformed child batch response");
|
|
2405
|
+
}
|
|
2406
|
+
try {
|
|
2407
|
+
results.push(Object.freeze({
|
|
2408
|
+
directoryName: file.directoryName,
|
|
2409
|
+
fileName: file.fileName,
|
|
2410
|
+
status: "present",
|
|
2411
|
+
content: new TextDecoder("utf-8", {
|
|
2412
|
+
fatal: true,
|
|
2413
|
+
ignoreBOM: true,
|
|
2414
|
+
}).decode(content),
|
|
2415
|
+
}));
|
|
2416
|
+
} catch {
|
|
2417
|
+
results.push(Object.freeze({
|
|
2418
|
+
directoryName: file.directoryName,
|
|
2419
|
+
fileName: file.fileName,
|
|
2420
|
+
status: "invalid",
|
|
2421
|
+
reason: "invalid-utf8",
|
|
2422
|
+
}));
|
|
2423
|
+
}
|
|
2424
|
+
}
|
|
2425
|
+
return Object.freeze(results);
|
|
2426
|
+
}
|
|
2427
|
+
|
|
2428
|
+
export function readPrivateStateChildFilesBatched(
|
|
2429
|
+
path: string,
|
|
2430
|
+
files: readonly PrivateStateBatchChildFile[],
|
|
2431
|
+
options: {
|
|
2432
|
+
readonly maximumBytesPerFile: number;
|
|
2433
|
+
readonly environment?: Readonly<Record<string, string | undefined>>;
|
|
2434
|
+
readonly expectedDirectoryIdentity: Readonly<PrivateDirectoryIdentity>;
|
|
2435
|
+
},
|
|
2436
|
+
): readonly PrivateStateBatchChildReadResult[] {
|
|
2437
|
+
if (!Array.isArray(files) || files.length > 10_000) {
|
|
2438
|
+
throw new Error("private state child collection file count is invalid");
|
|
2439
|
+
}
|
|
2440
|
+
const keys = new Set<string>();
|
|
2441
|
+
for (const file of files as readonly unknown[]) {
|
|
2442
|
+
if (
|
|
2443
|
+
!isRecord(file)
|
|
2444
|
+
|| !exactObjectKeys(file, [
|
|
2445
|
+
"directoryName",
|
|
2446
|
+
"directoryIdentity",
|
|
2447
|
+
"fileName",
|
|
2448
|
+
])
|
|
2449
|
+
|| !isSafeBatchFileName(file.directoryName)
|
|
2450
|
+
|| !isSafeBatchFileName(file.fileName)
|
|
2451
|
+
|| parseResponseIdentity(file.directoryIdentity) === null
|
|
2452
|
+
) {
|
|
2453
|
+
throw new Error("private state child collection request is invalid");
|
|
2454
|
+
}
|
|
2455
|
+
const key = `${file.directoryName}\u0000${file.fileName}`;
|
|
2456
|
+
if (keys.has(key)) {
|
|
2457
|
+
throw new Error("private state child collection requests must be unique");
|
|
2458
|
+
}
|
|
2459
|
+
keys.add(key);
|
|
2460
|
+
}
|
|
2461
|
+
if (
|
|
2462
|
+
!Number.isSafeInteger(options.maximumBytesPerFile)
|
|
2463
|
+
|| options.maximumBytesPerFile < 0
|
|
2464
|
+
|| options.maximumBytesPerFile > MAX_PRIVATE_STATE_BATCH_FILE_BYTES
|
|
2465
|
+
) {
|
|
2466
|
+
throw new Error("private state child collection per-file byte bound is invalid");
|
|
2467
|
+
}
|
|
2468
|
+
const filesPerBatch = options.maximumBytesPerFile === 0
|
|
2469
|
+
? Math.min(
|
|
2470
|
+
MAX_PRIVATE_STATE_BATCH_FILES,
|
|
2471
|
+
Math.floor(MAX_PRIVATE_STATE_BATCH_NAME_BYTES / (2 * 255)),
|
|
2472
|
+
)
|
|
2473
|
+
: Math.min(
|
|
2474
|
+
MAX_PRIVATE_STATE_BATCH_FILES,
|
|
2475
|
+
Math.floor(MAX_PRIVATE_STATE_BATCH_NAME_BYTES / (2 * 255)),
|
|
2476
|
+
Math.floor(
|
|
2477
|
+
MAX_PRIVATE_STATE_BATCH_TOTAL_BYTES / options.maximumBytesPerFile,
|
|
2478
|
+
),
|
|
2479
|
+
);
|
|
2480
|
+
const results: PrivateStateBatchChildReadResult[] = [];
|
|
2481
|
+
for (let index = 0; index < files.length; index += filesPerBatch) {
|
|
2482
|
+
const batchFiles = files.slice(index, index + filesPerBatch);
|
|
2483
|
+
results.push(...readPrivateStateChildFilesBatch(path, batchFiles, {
|
|
2484
|
+
maximumBytesPerFile: options.maximumBytesPerFile,
|
|
2485
|
+
maximumTotalBytes: options.maximumBytesPerFile * batchFiles.length,
|
|
2486
|
+
...(options.environment === undefined
|
|
2487
|
+
? {}
|
|
2488
|
+
: { environment: options.environment }),
|
|
2489
|
+
expectedDirectoryIdentity: options.expectedDirectoryIdentity,
|
|
2490
|
+
}));
|
|
2491
|
+
}
|
|
2492
|
+
return Object.freeze(results);
|
|
2493
|
+
}
|
|
2494
|
+
|
|
2495
|
+
export function removePrivateStateDirectoryTree(
|
|
2496
|
+
path: string,
|
|
2497
|
+
environment: Readonly<Record<string, string | undefined>> = process.env,
|
|
2498
|
+
expectedTarget?: Readonly<StateRootIdentity>,
|
|
2499
|
+
expectedParent?: Readonly<StateRootIdentity>,
|
|
2500
|
+
): boolean {
|
|
2501
|
+
const root = ghostgetStateHome(environment);
|
|
2502
|
+
const identity = ensureClaimedStateRoot(root);
|
|
2503
|
+
const segments = stateSegments(root, path);
|
|
2504
|
+
if (segments.length < 2) throw new Error("only a nested ghostget state directory can be recursively removed");
|
|
2505
|
+
const captured = [...captureStateDirectoryExpectations(root, segments)];
|
|
2506
|
+
if (expectedTarget !== undefined && captured.at(-1) !== null) captured[captured.length - 1] = expectedTarget;
|
|
2507
|
+
if (expectedParent !== undefined && captured.length > 1 && captured.at(-2) !== null) {
|
|
2508
|
+
captured[captured.length - 2] = expectedParent;
|
|
2509
|
+
}
|
|
2510
|
+
const response = runStateHelper(root, identity, {
|
|
2511
|
+
kind: "remove-directory-tree",
|
|
2512
|
+
segments,
|
|
2513
|
+
directoryExpectations: captured,
|
|
2514
|
+
});
|
|
2515
|
+
return response.removed === true;
|
|
2516
|
+
}
|
|
2517
|
+
|
|
2518
|
+
export function removePrivateEmptyStateDirectory(
|
|
2519
|
+
path: string,
|
|
2520
|
+
environment: Readonly<Record<string, string | undefined>> = process.env,
|
|
2521
|
+
expectedTarget?: Readonly<StateRootIdentity>,
|
|
2522
|
+
expectedParent?: Readonly<StateRootIdentity>,
|
|
2523
|
+
options: {
|
|
2524
|
+
/** Narrow child-helper interleave for deterministic inode-race coverage. */
|
|
2525
|
+
readonly raceForTest?: EmptyDirectoryRemovalRaceForTest;
|
|
2526
|
+
} = {},
|
|
2527
|
+
): boolean {
|
|
2528
|
+
if (options.raceForTest !== undefined && process.env.NODE_ENV !== "test") {
|
|
2529
|
+
throw new Error("empty-directory race injection is available only under the test runtime");
|
|
2530
|
+
}
|
|
2531
|
+
const root = ghostgetStateHome(environment);
|
|
2532
|
+
const identity = ensureClaimedStateRoot(root);
|
|
2533
|
+
const segments = stateSegments(root, path);
|
|
2534
|
+
if (segments.length < 2) throw new Error("only a nested ghostget state directory can be removed");
|
|
2535
|
+
const captured = [...captureStateDirectoryExpectations(root, segments)];
|
|
2536
|
+
if (expectedTarget !== undefined && captured.at(-1) !== null) captured[captured.length - 1] = expectedTarget;
|
|
2537
|
+
if (expectedParent !== undefined && captured.length > 1 && captured.at(-2) !== null) {
|
|
2538
|
+
captured[captured.length - 2] = expectedParent;
|
|
2539
|
+
}
|
|
2540
|
+
const response = runStateHelper(root, identity, {
|
|
2541
|
+
kind: "remove-empty-directory",
|
|
2542
|
+
segments,
|
|
2543
|
+
directoryExpectations: captured,
|
|
2544
|
+
}, false, options.raceForTest);
|
|
2545
|
+
return response.removed === true;
|
|
2546
|
+
}
|
|
2547
|
+
|
|
2548
|
+
export function adapterDirectory(environment: Readonly<Record<string, string | undefined>> = process.env): string {
|
|
2549
|
+
return join(ghostgetStateHome(environment), "adapters");
|
|
2550
|
+
}
|
|
2551
|
+
|
|
2552
|
+
export function adapterManifestPath(id: string, environment: Readonly<Record<string, string | undefined>> = process.env): string {
|
|
2553
|
+
if (!/^[a-z][a-z0-9-]{0,47}$/u.test(id)) throw new Error("adapter ID must be lowercase kebab-case");
|
|
2554
|
+
// This filename is persisted state, not product copy. Keep the schema-v1
|
|
2555
|
+
// spelling byte-stable so interrupted upgrades and existing installations
|
|
2556
|
+
// remain readable after the Ghostget rename. Source/scaffold manifests use the new
|
|
2557
|
+
// `wrench-adapter.json` name and are normalized into this compatibility mirror.
|
|
2558
|
+
return join(adapterDirectory(environment), id, "io-adapter.json");
|
|
2559
|
+
}
|
|
2560
|
+
|
|
2561
|
+
// These discriminants are part of the persisted schema-v1 contract. Branding
|
|
2562
|
+
// changes must not create a new meaning under the same schema version.
|
|
2563
|
+
const adapterGenerationKind = "io-adapter-generation";
|
|
2564
|
+
const adapterGenerationTransactionKind = "io-adapter-generation-transaction";
|
|
2565
|
+
const adapterGenerationSchemaVersion = 1;
|
|
2566
|
+
const adapterGenerationMaximumEntries = 1_000;
|
|
2567
|
+
const adapterGenerationMaximumIndexBytes = 1024 * 1024;
|
|
2568
|
+
const adapterGenerationIdPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u;
|
|
2569
|
+
const contentSha256Pattern = /^[0-9a-f]{64}$/u;
|
|
2570
|
+
|
|
2571
|
+
type AdapterGenerationPresentEntry = {
|
|
2572
|
+
readonly id: string;
|
|
2573
|
+
readonly state: "present";
|
|
2574
|
+
readonly objectContentSha256: string;
|
|
2575
|
+
readonly manifestHash: string;
|
|
2576
|
+
readonly sourceContentSha256: string;
|
|
2577
|
+
};
|
|
2578
|
+
|
|
2579
|
+
type AdapterGenerationAbsentEntry = {
|
|
2580
|
+
readonly id: string;
|
|
2581
|
+
readonly state: "absent";
|
|
2582
|
+
};
|
|
2583
|
+
|
|
2584
|
+
type AdapterGenerationEntry =
|
|
2585
|
+
| AdapterGenerationPresentEntry
|
|
2586
|
+
| AdapterGenerationAbsentEntry;
|
|
2587
|
+
|
|
2588
|
+
type AdapterGenerationIndex = {
|
|
2589
|
+
readonly kind: typeof adapterGenerationKind;
|
|
2590
|
+
readonly schemaVersion: typeof adapterGenerationSchemaVersion;
|
|
2591
|
+
readonly commitId: string;
|
|
2592
|
+
readonly entries: readonly AdapterGenerationEntry[];
|
|
2593
|
+
};
|
|
2594
|
+
|
|
2595
|
+
type AdapterGenerationTransaction = {
|
|
2596
|
+
readonly kind: typeof adapterGenerationTransactionKind;
|
|
2597
|
+
readonly schemaVersion: typeof adapterGenerationSchemaVersion;
|
|
2598
|
+
readonly transactionId: string;
|
|
2599
|
+
readonly beforeCommitId: string | null;
|
|
2600
|
+
readonly owner: ProcessOwnerIdentity;
|
|
2601
|
+
};
|
|
2602
|
+
|
|
2603
|
+
type AdapterGenerationTransactionClaim = {
|
|
2604
|
+
readonly record: AdapterGenerationTransaction;
|
|
2605
|
+
readonly contentSha256: string;
|
|
2606
|
+
};
|
|
2607
|
+
|
|
2608
|
+
const activeAdapterGenerationTransactions = new Map<
|
|
2609
|
+
string,
|
|
2610
|
+
AdapterGenerationTransactionClaim
|
|
2611
|
+
>();
|
|
2612
|
+
|
|
2613
|
+
export type BundledAdapterGenerationSelection =
|
|
2614
|
+
| {
|
|
2615
|
+
readonly id: string;
|
|
2616
|
+
readonly state: "present";
|
|
2617
|
+
/** Runtime-valid current or diagnostic-only preserved manifest. */
|
|
2618
|
+
readonly manifest: GhostgetManifest;
|
|
2619
|
+
/** Hash of the exact source bytes parsed to produce `manifest`. */
|
|
2620
|
+
readonly sourceContentSha256: string;
|
|
2621
|
+
/**
|
|
2622
|
+
* Exact active bytes observed while deciding whether to install,
|
|
2623
|
+
* upgrade, or preserve this adapter. `null` means it was absent.
|
|
2624
|
+
*
|
|
2625
|
+
* Callers that classify mutable installed state must provide this so a
|
|
2626
|
+
* concurrent user edit cannot be overwritten by a later generation
|
|
2627
|
+
* commit. Omission is retained for internal unconditional publication.
|
|
2628
|
+
*/
|
|
2629
|
+
readonly expectedCurrentContentSha256?: string | null;
|
|
2630
|
+
}
|
|
2631
|
+
| {
|
|
2632
|
+
readonly id: string;
|
|
2633
|
+
/**
|
|
2634
|
+
* Leave this ID on the legacy flat-file layout. This is reserved for a
|
|
2635
|
+
* malformed user-owned install that cannot be copied into an immutable
|
|
2636
|
+
* canonical object without changing its bytes.
|
|
2637
|
+
*/
|
|
2638
|
+
readonly state: "legacy";
|
|
2639
|
+
/** Exact malformed flat-file bytes that were classified as legacy. */
|
|
2640
|
+
readonly expectedCurrentContentSha256?: string | null;
|
|
2641
|
+
};
|
|
2642
|
+
|
|
2643
|
+
type AdapterGenerationInstallTestOptions = {
|
|
2644
|
+
/** Exact catalog used to validate every current or retired manifest. */
|
|
2645
|
+
readonly registry?: ProviderPluginRegistry;
|
|
2646
|
+
/** Test-only fault seam after each immutable object reaches durable storage. */
|
|
2647
|
+
readonly afterObjectForTest?: (completedObjects: number) => void;
|
|
2648
|
+
/** Test-only fault seam immediately after the generation pointer commits. */
|
|
2649
|
+
readonly afterCommitForTest?: () => void;
|
|
2650
|
+
};
|
|
2651
|
+
|
|
2652
|
+
function isAdapterId(value: unknown): value is string {
|
|
2653
|
+
return typeof value === "string" && /^[a-z][a-z0-9-]{0,47}$/u.test(value);
|
|
2654
|
+
}
|
|
2655
|
+
|
|
2656
|
+
function adapterGenerationDirectory(
|
|
2657
|
+
environment: Readonly<Record<string, string | undefined>>,
|
|
2658
|
+
): string {
|
|
2659
|
+
return join(ghostgetStateHome(environment), "adapter-generations");
|
|
2660
|
+
}
|
|
2661
|
+
|
|
2662
|
+
function adapterGenerationIndexPath(
|
|
2663
|
+
environment: Readonly<Record<string, string | undefined>>,
|
|
2664
|
+
): string {
|
|
2665
|
+
return join(adapterGenerationDirectory(environment), "current.json");
|
|
2666
|
+
}
|
|
2667
|
+
|
|
2668
|
+
function adapterGenerationTransactionPath(
|
|
2669
|
+
environment: Readonly<Record<string, string | undefined>>,
|
|
2670
|
+
): string {
|
|
2671
|
+
return join(adapterGenerationDirectory(environment), "transaction.json");
|
|
2672
|
+
}
|
|
2673
|
+
|
|
2674
|
+
function adapterGenerationObjectsDirectory(
|
|
2675
|
+
environment: Readonly<Record<string, string | undefined>>,
|
|
2676
|
+
): string {
|
|
2677
|
+
return join(adapterGenerationDirectory(environment), "objects");
|
|
2678
|
+
}
|
|
2679
|
+
|
|
2680
|
+
function adapterGenerationObjectPath(
|
|
2681
|
+
contentSha256: string,
|
|
2682
|
+
environment: Readonly<Record<string, string | undefined>>,
|
|
2683
|
+
): string {
|
|
2684
|
+
if (!contentSha256Pattern.test(contentSha256)) {
|
|
2685
|
+
throw new Error("adapter generation object hash is invalid");
|
|
2686
|
+
}
|
|
2687
|
+
return join(
|
|
2688
|
+
adapterGenerationObjectsDirectory(environment),
|
|
2689
|
+
`${contentSha256}.json`,
|
|
2690
|
+
);
|
|
2691
|
+
}
|
|
2692
|
+
|
|
2693
|
+
function parseAdapterGenerationEntry(value: unknown): AdapterGenerationEntry {
|
|
2694
|
+
if (!isRecord(value)) {
|
|
2695
|
+
throw new Error("adapter generation entry must be an object");
|
|
2696
|
+
}
|
|
2697
|
+
if (!("state" in value) || value.state === "absent") {
|
|
2698
|
+
if (
|
|
2699
|
+
!exactObjectKeys(value, ["id", "state"])
|
|
2700
|
+
|| !isAdapterId(value.id)
|
|
2701
|
+
|| value.state !== "absent"
|
|
2702
|
+
) {
|
|
2703
|
+
throw new Error("adapter generation absent entry is malformed");
|
|
2704
|
+
}
|
|
2705
|
+
return Object.freeze({ id: value.id, state: "absent" });
|
|
2706
|
+
}
|
|
2707
|
+
if (
|
|
2708
|
+
!exactObjectKeys(value, [
|
|
2709
|
+
"id",
|
|
2710
|
+
"manifestHash",
|
|
2711
|
+
"objectContentSha256",
|
|
2712
|
+
"sourceContentSha256",
|
|
2713
|
+
"state",
|
|
2714
|
+
])
|
|
2715
|
+
|| !isAdapterId(value.id)
|
|
2716
|
+
|| value.state !== "present"
|
|
2717
|
+
|| typeof value.objectContentSha256 !== "string"
|
|
2718
|
+
|| !contentSha256Pattern.test(value.objectContentSha256)
|
|
2719
|
+
|| typeof value.manifestHash !== "string"
|
|
2720
|
+
|| !contentSha256Pattern.test(value.manifestHash)
|
|
2721
|
+
|| typeof value.sourceContentSha256 !== "string"
|
|
2722
|
+
|| !contentSha256Pattern.test(value.sourceContentSha256)
|
|
2723
|
+
) {
|
|
2724
|
+
throw new Error("adapter generation present entry is malformed");
|
|
2725
|
+
}
|
|
2726
|
+
return Object.freeze({
|
|
2727
|
+
id: value.id,
|
|
2728
|
+
state: "present",
|
|
2729
|
+
objectContentSha256: value.objectContentSha256,
|
|
2730
|
+
manifestHash: value.manifestHash,
|
|
2731
|
+
sourceContentSha256: value.sourceContentSha256,
|
|
2732
|
+
});
|
|
2733
|
+
}
|
|
2734
|
+
|
|
2735
|
+
function parseAdapterGenerationIndex(value: unknown): AdapterGenerationIndex {
|
|
2736
|
+
if (
|
|
2737
|
+
!isRecord(value)
|
|
2738
|
+
|| !exactObjectKeys(value, ["commitId", "entries", "kind", "schemaVersion"])
|
|
2739
|
+
|| value.kind !== adapterGenerationKind
|
|
2740
|
+
|| value.schemaVersion !== adapterGenerationSchemaVersion
|
|
2741
|
+
|| typeof value.commitId !== "string"
|
|
2742
|
+
|| !adapterGenerationIdPattern.test(value.commitId)
|
|
2743
|
+
|| !Array.isArray(value.entries)
|
|
2744
|
+
|| value.entries.length > adapterGenerationMaximumEntries
|
|
2745
|
+
) {
|
|
2746
|
+
throw new Error("adapter generation index is malformed");
|
|
2747
|
+
}
|
|
2748
|
+
const entries = value.entries.map(parseAdapterGenerationEntry);
|
|
2749
|
+
const ids = entries.map((entry) => entry.id);
|
|
2750
|
+
if (
|
|
2751
|
+
new Set(ids).size !== ids.length
|
|
2752
|
+
|| ids.some((id, index) => index > 0 && id <= ids[index - 1]!)
|
|
2753
|
+
) {
|
|
2754
|
+
throw new Error("adapter generation entries must have unique sorted IDs");
|
|
2755
|
+
}
|
|
2756
|
+
return Object.freeze({
|
|
2757
|
+
kind: adapterGenerationKind,
|
|
2758
|
+
schemaVersion: adapterGenerationSchemaVersion,
|
|
2759
|
+
commitId: value.commitId,
|
|
2760
|
+
entries: Object.freeze(entries),
|
|
2761
|
+
});
|
|
2762
|
+
}
|
|
2763
|
+
|
|
2764
|
+
function parseProcessOwnerIdentity(value: unknown): ProcessOwnerIdentity {
|
|
2765
|
+
if (
|
|
2766
|
+
!isRecord(value)
|
|
2767
|
+
|| !exactObjectKeys(value, ["bootId", "pid", "processStartId"])
|
|
2768
|
+
|| typeof value.pid !== "number"
|
|
2769
|
+
|| !Number.isSafeInteger(value.pid)
|
|
2770
|
+
|| value.pid < 1
|
|
2771
|
+
|| typeof value.bootId !== "string"
|
|
2772
|
+
|| !contentSha256Pattern.test(value.bootId)
|
|
2773
|
+
|| typeof value.processStartId !== "string"
|
|
2774
|
+
|| !contentSha256Pattern.test(value.processStartId)
|
|
2775
|
+
) {
|
|
2776
|
+
throw new Error("adapter generation transaction owner is malformed");
|
|
2777
|
+
}
|
|
2778
|
+
return Object.freeze({
|
|
2779
|
+
pid: value.pid,
|
|
2780
|
+
bootId: value.bootId,
|
|
2781
|
+
processStartId: value.processStartId,
|
|
2782
|
+
});
|
|
2783
|
+
}
|
|
2784
|
+
|
|
2785
|
+
function parseAdapterGenerationTransaction(
|
|
2786
|
+
value: unknown,
|
|
2787
|
+
): AdapterGenerationTransaction {
|
|
2788
|
+
if (
|
|
2789
|
+
!isRecord(value)
|
|
2790
|
+
|| !exactObjectKeys(value, [
|
|
2791
|
+
"beforeCommitId",
|
|
2792
|
+
"kind",
|
|
2793
|
+
"owner",
|
|
2794
|
+
"schemaVersion",
|
|
2795
|
+
"transactionId",
|
|
2796
|
+
])
|
|
2797
|
+
|| value.kind !== adapterGenerationTransactionKind
|
|
2798
|
+
|| value.schemaVersion !== adapterGenerationSchemaVersion
|
|
2799
|
+
|| typeof value.transactionId !== "string"
|
|
2800
|
+
|| !adapterGenerationIdPattern.test(value.transactionId)
|
|
2801
|
+
|| (
|
|
2802
|
+
value.beforeCommitId !== null
|
|
2803
|
+
&& (
|
|
2804
|
+
typeof value.beforeCommitId !== "string"
|
|
2805
|
+
|| !adapterGenerationIdPattern.test(value.beforeCommitId)
|
|
2806
|
+
)
|
|
2807
|
+
)
|
|
2808
|
+
) {
|
|
2809
|
+
throw new Error("adapter generation transaction is malformed");
|
|
2810
|
+
}
|
|
2811
|
+
return Object.freeze({
|
|
2812
|
+
kind: adapterGenerationTransactionKind,
|
|
2813
|
+
schemaVersion: adapterGenerationSchemaVersion,
|
|
2814
|
+
transactionId: value.transactionId,
|
|
2815
|
+
beforeCommitId: value.beforeCommitId,
|
|
2816
|
+
owner: parseProcessOwnerIdentity(value.owner),
|
|
2817
|
+
});
|
|
2818
|
+
}
|
|
2819
|
+
|
|
2820
|
+
function readOptionalAdapterGenerationJson(
|
|
2821
|
+
path: string,
|
|
2822
|
+
label: string,
|
|
2823
|
+
environment: Readonly<Record<string, string | undefined>>,
|
|
2824
|
+
): {
|
|
2825
|
+
readonly content: string;
|
|
2826
|
+
readonly contentSha256: string;
|
|
2827
|
+
readonly value: unknown;
|
|
2828
|
+
} | null {
|
|
2829
|
+
const content = readPrivateStateFileIfPresent(
|
|
2830
|
+
path,
|
|
2831
|
+
adapterGenerationMaximumIndexBytes,
|
|
2832
|
+
label,
|
|
2833
|
+
environment,
|
|
2834
|
+
);
|
|
2835
|
+
if (content === null) return null;
|
|
2836
|
+
let value: unknown;
|
|
2837
|
+
try {
|
|
2838
|
+
value = JSON.parse(content) as unknown;
|
|
2839
|
+
} catch (error) {
|
|
2840
|
+
throw new Error(`${label} is not valid JSON`, { cause: error });
|
|
2841
|
+
}
|
|
2842
|
+
return Object.freeze({
|
|
2843
|
+
content,
|
|
2844
|
+
contentSha256: sha256(content),
|
|
2845
|
+
value,
|
|
2846
|
+
});
|
|
2847
|
+
}
|
|
2848
|
+
|
|
2849
|
+
function readAdapterGenerationIndex(
|
|
2850
|
+
environment: Readonly<Record<string, string | undefined>>,
|
|
2851
|
+
): AdapterGenerationIndex | null {
|
|
2852
|
+
const record = readOptionalAdapterGenerationJson(
|
|
2853
|
+
adapterGenerationIndexPath(environment),
|
|
2854
|
+
"adapter generation index",
|
|
2855
|
+
environment,
|
|
2856
|
+
);
|
|
2857
|
+
if (record === null) return null;
|
|
2858
|
+
if (`${canonicalJson(record.value)}\n` !== record.content) {
|
|
2859
|
+
throw new Error("adapter generation index is not canonical JSON");
|
|
2860
|
+
}
|
|
2861
|
+
return parseAdapterGenerationIndex(record.value);
|
|
2862
|
+
}
|
|
2863
|
+
|
|
2864
|
+
function readAdapterGenerationTransaction(
|
|
2865
|
+
environment: Readonly<Record<string, string | undefined>>,
|
|
2866
|
+
): AdapterGenerationTransactionClaim | null {
|
|
2867
|
+
const record = readOptionalAdapterGenerationJson(
|
|
2868
|
+
adapterGenerationTransactionPath(environment),
|
|
2869
|
+
"adapter generation transaction",
|
|
2870
|
+
environment,
|
|
2871
|
+
);
|
|
2872
|
+
if (record === null) return null;
|
|
2873
|
+
if (`${canonicalJson(record.value)}\n` !== record.content) {
|
|
2874
|
+
throw new Error("adapter generation transaction is not canonical JSON");
|
|
2875
|
+
}
|
|
2876
|
+
return Object.freeze({
|
|
2877
|
+
record: parseAdapterGenerationTransaction(record.value),
|
|
2878
|
+
contentSha256: record.contentSha256,
|
|
2879
|
+
});
|
|
2880
|
+
}
|
|
2881
|
+
|
|
2882
|
+
function acquireAdapterGenerationTransaction(
|
|
2883
|
+
environment: Readonly<Record<string, string | undefined>>,
|
|
2884
|
+
): AdapterGenerationTransactionClaim {
|
|
2885
|
+
for (let attempt = 0; attempt < 4; attempt += 1) {
|
|
2886
|
+
const current = readAdapterGenerationIndex(environment);
|
|
2887
|
+
const processIdentity = currentProcessStartIdentity();
|
|
2888
|
+
const record: AdapterGenerationTransaction = Object.freeze({
|
|
2889
|
+
kind: adapterGenerationTransactionKind,
|
|
2890
|
+
schemaVersion: adapterGenerationSchemaVersion,
|
|
2891
|
+
transactionId: crypto.randomUUID(),
|
|
2892
|
+
beforeCommitId: current?.commitId ?? null,
|
|
2893
|
+
owner: Object.freeze({
|
|
2894
|
+
pid: process.pid,
|
|
2895
|
+
...processIdentity,
|
|
2896
|
+
}),
|
|
2897
|
+
});
|
|
2898
|
+
const created = createPrivateJsonIfAbsent(
|
|
2899
|
+
adapterGenerationTransactionPath(environment),
|
|
2900
|
+
record,
|
|
2901
|
+
{ environment, privateParent: true },
|
|
2902
|
+
);
|
|
2903
|
+
if (created.created) {
|
|
2904
|
+
return Object.freeze({
|
|
2905
|
+
record,
|
|
2906
|
+
contentSha256: sha256(`${canonicalJson(record)}\n`),
|
|
2907
|
+
});
|
|
2908
|
+
}
|
|
2909
|
+
const existing = readAdapterGenerationTransaction(environment);
|
|
2910
|
+
if (existing === null) continue;
|
|
2911
|
+
const status = processOwnerStatus(existing.record.owner);
|
|
2912
|
+
if (status === "exact-live-owner") {
|
|
2913
|
+
throw new Error("another adapter generation transaction is active");
|
|
2914
|
+
}
|
|
2915
|
+
if (status === "unknown") {
|
|
2916
|
+
throw new Error("adapter generation transaction owner cannot be inspected safely");
|
|
2917
|
+
}
|
|
2918
|
+
const currentIndex = readAdapterGenerationIndex(environment);
|
|
2919
|
+
if (
|
|
2920
|
+
currentIndex !== null
|
|
2921
|
+
&& existing.record.beforeCommitId !== null
|
|
2922
|
+
&& currentIndex.commitId !== existing.record.beforeCommitId
|
|
2923
|
+
&& currentIndex.commitId !== existing.record.transactionId
|
|
2924
|
+
) {
|
|
2925
|
+
throw new Error("stale adapter generation transaction does not match the active generation");
|
|
2926
|
+
}
|
|
2927
|
+
removePrivateStateFileIfUnchanged(
|
|
2928
|
+
adapterGenerationTransactionPath(environment),
|
|
2929
|
+
{ expectedCurrentContentSha256: existing.contentSha256 },
|
|
2930
|
+
environment,
|
|
2931
|
+
);
|
|
2932
|
+
}
|
|
2933
|
+
throw new Error("adapter generation transaction could not be acquired");
|
|
2934
|
+
}
|
|
2935
|
+
|
|
2936
|
+
function releaseAdapterGenerationTransaction(
|
|
2937
|
+
claim: AdapterGenerationTransactionClaim,
|
|
2938
|
+
environment: Readonly<Record<string, string | undefined>>,
|
|
2939
|
+
): void {
|
|
2940
|
+
const removed = removePrivateStateFileIfUnchanged(
|
|
2941
|
+
adapterGenerationTransactionPath(environment),
|
|
2942
|
+
{ expectedCurrentContentSha256: claim.contentSha256 },
|
|
2943
|
+
environment,
|
|
2944
|
+
);
|
|
2945
|
+
if (!removed) {
|
|
2946
|
+
throw new Error("adapter generation transaction ownership changed before release");
|
|
2947
|
+
}
|
|
2948
|
+
}
|
|
2949
|
+
|
|
2950
|
+
function withAdapterGenerationTransaction<T>(
|
|
2951
|
+
environment: Readonly<Record<string, string | undefined>>,
|
|
2952
|
+
operation: (
|
|
2953
|
+
claim: AdapterGenerationTransactionClaim,
|
|
2954
|
+
current: AdapterGenerationIndex | null,
|
|
2955
|
+
) => T,
|
|
2956
|
+
): T {
|
|
2957
|
+
const generationDirectory = adapterGenerationDirectory(environment);
|
|
2958
|
+
const active = activeAdapterGenerationTransactions.get(generationDirectory);
|
|
2959
|
+
if (active !== undefined) {
|
|
2960
|
+
return operation(active, readAdapterGenerationIndex(environment));
|
|
2961
|
+
}
|
|
2962
|
+
const claim = acquireAdapterGenerationTransaction(environment);
|
|
2963
|
+
activeAdapterGenerationTransactions.set(generationDirectory, claim);
|
|
2964
|
+
try {
|
|
2965
|
+
const current = readAdapterGenerationIndex(environment);
|
|
2966
|
+
if ((current?.commitId ?? null) !== claim.record.beforeCommitId) {
|
|
2967
|
+
throw new Error("adapter generation changed while transaction ownership was acquired");
|
|
2968
|
+
}
|
|
2969
|
+
return operation(claim, current);
|
|
2970
|
+
} finally {
|
|
2971
|
+
activeAdapterGenerationTransactions.delete(generationDirectory);
|
|
2972
|
+
releaseAdapterGenerationTransaction(claim, environment);
|
|
2973
|
+
}
|
|
2974
|
+
}
|
|
2975
|
+
|
|
2976
|
+
function writeAdapterGenerationIndex(
|
|
2977
|
+
entries: readonly AdapterGenerationEntry[],
|
|
2978
|
+
commitId: string,
|
|
2979
|
+
environment: Readonly<Record<string, string | undefined>>,
|
|
2980
|
+
): AdapterGenerationIndex {
|
|
2981
|
+
const index = parseAdapterGenerationIndex({
|
|
2982
|
+
kind: adapterGenerationKind,
|
|
2983
|
+
schemaVersion: adapterGenerationSchemaVersion,
|
|
2984
|
+
commitId,
|
|
2985
|
+
entries,
|
|
2986
|
+
});
|
|
2987
|
+
const content = `${canonicalJson(index)}\n`;
|
|
2988
|
+
if (Buffer.byteLength(content, "utf8") > adapterGenerationMaximumIndexBytes) {
|
|
2989
|
+
throw new Error("adapter generation index exceeds its byte bound");
|
|
2990
|
+
}
|
|
2991
|
+
writePrivateJson(adapterGenerationIndexPath(environment), index);
|
|
2992
|
+
return index;
|
|
2993
|
+
}
|
|
2994
|
+
|
|
2995
|
+
function publishAdapterGenerationObject(
|
|
2996
|
+
manifest: GhostgetManifest,
|
|
2997
|
+
sourceContentSha256: string,
|
|
2998
|
+
environment: Readonly<Record<string, string | undefined>>,
|
|
2999
|
+
registry: ProviderPluginRegistry,
|
|
3000
|
+
): AdapterGenerationPresentEntry {
|
|
3001
|
+
if (!contentSha256Pattern.test(sourceContentSha256)) {
|
|
3002
|
+
throw new Error("adapter generation source hash is invalid");
|
|
3003
|
+
}
|
|
3004
|
+
const diagnostic = parseDiagnosticManifest(manifest, registry);
|
|
3005
|
+
if (!diagnostic.ok) {
|
|
3006
|
+
throw new Error(`adapter generation manifest is invalid: ${diagnostic.issues.join("; ")}`);
|
|
3007
|
+
}
|
|
3008
|
+
if (canonicalJson(diagnostic.value) !== canonicalJson(manifest)) {
|
|
3009
|
+
throw new Error("adapter generation manifest is not canonical");
|
|
3010
|
+
}
|
|
3011
|
+
const content = `${canonicalJson(diagnostic.value)}\n`;
|
|
3012
|
+
const objectContentSha256 = sha256(content);
|
|
3013
|
+
const path = adapterGenerationObjectPath(objectContentSha256, environment);
|
|
3014
|
+
const created = createPrivateJsonIfAbsent(path, diagnostic.value, {
|
|
3015
|
+
environment,
|
|
3016
|
+
privateParent: true,
|
|
3017
|
+
});
|
|
3018
|
+
if (!created.created) {
|
|
3019
|
+
const stored = readPrivateStateFileIfPresent(
|
|
3020
|
+
path,
|
|
3021
|
+
MAX_WRENCH_JSON_BYTES,
|
|
3022
|
+
"adapter generation object",
|
|
3023
|
+
environment,
|
|
3024
|
+
);
|
|
3025
|
+
if (
|
|
3026
|
+
stored === null
|
|
3027
|
+
|| sha256(stored) !== objectContentSha256
|
|
3028
|
+
|| stored !== content
|
|
3029
|
+
) {
|
|
3030
|
+
throw new Error("adapter generation object content does not match its address");
|
|
3031
|
+
}
|
|
3032
|
+
}
|
|
3033
|
+
return Object.freeze({
|
|
3034
|
+
id: diagnostic.value.id,
|
|
3035
|
+
state: "present",
|
|
3036
|
+
objectContentSha256,
|
|
3037
|
+
manifestHash: manifestHash(diagnostic.value),
|
|
3038
|
+
sourceContentSha256,
|
|
3039
|
+
});
|
|
3040
|
+
}
|
|
3041
|
+
|
|
3042
|
+
function readAdapterGenerationObject(
|
|
3043
|
+
entry: AdapterGenerationPresentEntry,
|
|
3044
|
+
environment: Readonly<Record<string, string | undefined>>,
|
|
3045
|
+
parseInstalled: (value: unknown) => ParseResult<GhostgetManifest>,
|
|
3046
|
+
): InstalledManifestSnapshot {
|
|
3047
|
+
let content: string | null;
|
|
3048
|
+
try {
|
|
3049
|
+
content = readPrivateStateFileIfPresent(
|
|
3050
|
+
adapterGenerationObjectPath(entry.objectContentSha256, environment),
|
|
3051
|
+
MAX_WRENCH_JSON_BYTES,
|
|
3052
|
+
"adapter generation object",
|
|
3053
|
+
environment,
|
|
3054
|
+
);
|
|
3055
|
+
} catch (error) {
|
|
3056
|
+
return {
|
|
3057
|
+
result: {
|
|
3058
|
+
ok: false,
|
|
3059
|
+
issues: [error instanceof Error ? error.message : String(error)],
|
|
3060
|
+
},
|
|
3061
|
+
availability: "unsafe",
|
|
3062
|
+
contentSha256: entry.objectContentSha256,
|
|
3063
|
+
};
|
|
3064
|
+
}
|
|
3065
|
+
return parseAdapterGenerationObjectContent(entry, content, parseInstalled);
|
|
3066
|
+
}
|
|
3067
|
+
|
|
3068
|
+
function parseAdapterGenerationObjectContent(
|
|
3069
|
+
entry: AdapterGenerationPresentEntry,
|
|
3070
|
+
content: string | null,
|
|
3071
|
+
parseInstalled: (value: unknown) => ParseResult<GhostgetManifest>,
|
|
3072
|
+
): InstalledManifestSnapshot {
|
|
3073
|
+
if (content === null || sha256(content) !== entry.objectContentSha256) {
|
|
3074
|
+
return {
|
|
3075
|
+
result: {
|
|
3076
|
+
ok: false,
|
|
3077
|
+
issues: ["adapter generation object is missing or hash-mismatched"],
|
|
3078
|
+
},
|
|
3079
|
+
availability: "unsafe",
|
|
3080
|
+
contentSha256: entry.objectContentSha256,
|
|
3081
|
+
};
|
|
3082
|
+
}
|
|
3083
|
+
try {
|
|
3084
|
+
const value = JSON.parse(content) as unknown;
|
|
3085
|
+
if (`${canonicalJson(value)}\n` !== content) {
|
|
3086
|
+
throw new Error("adapter generation object is not canonical JSON");
|
|
3087
|
+
}
|
|
3088
|
+
const result = parseInstalled(value);
|
|
3089
|
+
if (
|
|
3090
|
+
result.ok
|
|
3091
|
+
&& (
|
|
3092
|
+
result.value.id !== entry.id
|
|
3093
|
+
|| manifestHash(result.value) !== entry.manifestHash
|
|
3094
|
+
)
|
|
3095
|
+
) {
|
|
3096
|
+
return {
|
|
3097
|
+
result: {
|
|
3098
|
+
ok: false,
|
|
3099
|
+
issues: ["adapter generation object identity does not match its index"],
|
|
3100
|
+
},
|
|
3101
|
+
availability: "unsafe",
|
|
3102
|
+
contentSha256: entry.objectContentSha256,
|
|
3103
|
+
};
|
|
3104
|
+
}
|
|
3105
|
+
return {
|
|
3106
|
+
result,
|
|
3107
|
+
availability: "present",
|
|
3108
|
+
contentSha256: entry.objectContentSha256,
|
|
3109
|
+
};
|
|
3110
|
+
} catch (error) {
|
|
3111
|
+
return {
|
|
3112
|
+
result: {
|
|
3113
|
+
ok: false,
|
|
3114
|
+
issues: [error instanceof Error ? error.message : String(error)],
|
|
3115
|
+
},
|
|
3116
|
+
availability: "unsafe",
|
|
3117
|
+
contentSha256: entry.objectContentSha256,
|
|
3118
|
+
};
|
|
3119
|
+
}
|
|
3120
|
+
}
|
|
3121
|
+
|
|
3122
|
+
function generationEntryById(
|
|
3123
|
+
index: AdapterGenerationIndex | null,
|
|
3124
|
+
id: string,
|
|
3125
|
+
): AdapterGenerationEntry | undefined {
|
|
3126
|
+
return index?.entries.find((entry) => entry.id === id);
|
|
3127
|
+
}
|
|
3128
|
+
|
|
3129
|
+
function activeAdapterContentSha256(
|
|
3130
|
+
index: AdapterGenerationIndex | null,
|
|
3131
|
+
id: string,
|
|
3132
|
+
environment: Readonly<Record<string, string | undefined>>,
|
|
3133
|
+
): string | null {
|
|
3134
|
+
const entry = generationEntryById(index, id);
|
|
3135
|
+
if (entry?.state === "absent") return null;
|
|
3136
|
+
if (entry?.state === "present") {
|
|
3137
|
+
const content = readPrivateStateFileIfPresent(
|
|
3138
|
+
adapterGenerationObjectPath(entry.objectContentSha256, environment),
|
|
3139
|
+
MAX_WRENCH_JSON_BYTES,
|
|
3140
|
+
"adapter generation object",
|
|
3141
|
+
environment,
|
|
3142
|
+
);
|
|
3143
|
+
if (
|
|
3144
|
+
content === null
|
|
3145
|
+
|| sha256(content) !== entry.objectContentSha256
|
|
3146
|
+
) {
|
|
3147
|
+
throw new Error(
|
|
3148
|
+
`installed adapter ${id} generation object is missing or hash-mismatched`,
|
|
3149
|
+
);
|
|
3150
|
+
}
|
|
3151
|
+
return entry.objectContentSha256;
|
|
3152
|
+
}
|
|
3153
|
+
const content = readPrivateStateFileIfPresent(
|
|
3154
|
+
adapterManifestPath(id, environment),
|
|
3155
|
+
MAX_WRENCH_JSON_BYTES,
|
|
3156
|
+
"installed adapter manifest",
|
|
3157
|
+
environment,
|
|
3158
|
+
);
|
|
3159
|
+
return content === null ? null : sha256(content);
|
|
3160
|
+
}
|
|
3161
|
+
|
|
3162
|
+
function activeAdapterContentSha256Batch(
|
|
3163
|
+
index: AdapterGenerationIndex | null,
|
|
3164
|
+
ids: readonly string[],
|
|
3165
|
+
environment: Readonly<Record<string, string | undefined>>,
|
|
3166
|
+
): ReadonlyMap<string, string | null> {
|
|
3167
|
+
const generationEntries = ids
|
|
3168
|
+
.map((id) => generationEntryById(index, id))
|
|
3169
|
+
.filter(
|
|
3170
|
+
(entry): entry is AdapterGenerationPresentEntry =>
|
|
3171
|
+
entry?.state === "present",
|
|
3172
|
+
);
|
|
3173
|
+
const objectContents = new Map<string, string>();
|
|
3174
|
+
if (generationEntries.length > 0) {
|
|
3175
|
+
const objectsDirectory = adapterGenerationObjectsDirectory(environment);
|
|
3176
|
+
const objectNames = [...new Set(generationEntries.map(
|
|
3177
|
+
(entry) => `${entry.objectContentSha256}.json`,
|
|
3178
|
+
))];
|
|
3179
|
+
const singleBatchMaximum = Math.floor(
|
|
3180
|
+
MAX_PRIVATE_STATE_BATCH_TOTAL_BYTES / MAX_WRENCH_JSON_BYTES,
|
|
3181
|
+
);
|
|
3182
|
+
const files = objectNames.length <= singleBatchMaximum
|
|
3183
|
+
? readPrivateStateFilesBatch(
|
|
3184
|
+
objectsDirectory,
|
|
3185
|
+
objectNames,
|
|
3186
|
+
{
|
|
3187
|
+
maximumBytesPerFile: MAX_WRENCH_JSON_BYTES,
|
|
3188
|
+
maximumTotalBytes: MAX_WRENCH_JSON_BYTES * objectNames.length,
|
|
3189
|
+
environment,
|
|
3190
|
+
},
|
|
3191
|
+
)
|
|
3192
|
+
: (() => {
|
|
3193
|
+
const directory = snapshotPrivateStateDirectory(
|
|
3194
|
+
objectsDirectory,
|
|
3195
|
+
environment,
|
|
3196
|
+
);
|
|
3197
|
+
if (directory.identity === null) {
|
|
3198
|
+
throw new Error(
|
|
3199
|
+
`installed adapter ${generationEntries[0]!.id} generation object is missing or hash-mismatched`,
|
|
3200
|
+
);
|
|
3201
|
+
}
|
|
3202
|
+
return readPrivateStateFilesBatched(
|
|
3203
|
+
objectsDirectory,
|
|
3204
|
+
objectNames,
|
|
3205
|
+
{
|
|
3206
|
+
maximumBytesPerFile: MAX_WRENCH_JSON_BYTES,
|
|
3207
|
+
environment,
|
|
3208
|
+
expectedDirectoryIdentity: directory.identity,
|
|
3209
|
+
},
|
|
3210
|
+
);
|
|
3211
|
+
})();
|
|
3212
|
+
for (const file of files) {
|
|
3213
|
+
if (file.status === "present") {
|
|
3214
|
+
objectContents.set(file.name, file.content);
|
|
3215
|
+
}
|
|
3216
|
+
}
|
|
3217
|
+
}
|
|
3218
|
+
|
|
3219
|
+
const results = new Map<string, string | null>();
|
|
3220
|
+
for (const id of ids) {
|
|
3221
|
+
const entry = generationEntryById(index, id);
|
|
3222
|
+
if (entry?.state === "present") {
|
|
3223
|
+
const content = objectContents.get(
|
|
3224
|
+
`${entry.objectContentSha256}.json`,
|
|
3225
|
+
);
|
|
3226
|
+
if (
|
|
3227
|
+
content === undefined
|
|
3228
|
+
|| sha256(content) !== entry.objectContentSha256
|
|
3229
|
+
) {
|
|
3230
|
+
throw new Error(
|
|
3231
|
+
`installed adapter ${id} generation object is missing or hash-mismatched`,
|
|
3232
|
+
);
|
|
3233
|
+
}
|
|
3234
|
+
results.set(id, entry.objectContentSha256);
|
|
3235
|
+
continue;
|
|
3236
|
+
}
|
|
3237
|
+
results.set(
|
|
3238
|
+
id,
|
|
3239
|
+
activeAdapterContentSha256(index, id, environment),
|
|
3240
|
+
);
|
|
3241
|
+
}
|
|
3242
|
+
return results;
|
|
3243
|
+
}
|
|
3244
|
+
|
|
3245
|
+
export function installBundledAdapterGeneration(
|
|
3246
|
+
selections: readonly BundledAdapterGenerationSelection[],
|
|
3247
|
+
environment: Readonly<Record<string, string | undefined>> = process.env,
|
|
3248
|
+
options: AdapterGenerationInstallTestOptions = {},
|
|
3249
|
+
): {
|
|
3250
|
+
readonly commitId: string;
|
|
3251
|
+
readonly installed: number;
|
|
3252
|
+
readonly preservedLegacy: number;
|
|
3253
|
+
} {
|
|
3254
|
+
if (
|
|
3255
|
+
(options.afterObjectForTest !== undefined || options.afterCommitForTest !== undefined)
|
|
3256
|
+
&& process.env.NODE_ENV !== "test"
|
|
3257
|
+
) {
|
|
3258
|
+
throw new Error("adapter generation fault injection is available only under the test runtime");
|
|
3259
|
+
}
|
|
3260
|
+
if (
|
|
3261
|
+
!isArrayAtRuntime(selections)
|
|
3262
|
+
|| selections.length < 1
|
|
3263
|
+
|| selections.length > adapterGenerationMaximumEntries
|
|
3264
|
+
) {
|
|
3265
|
+
throw new Error("bundled adapter generation selection count is invalid");
|
|
3266
|
+
}
|
|
3267
|
+
const ids = selections.map((selection) => selection.id);
|
|
3268
|
+
if (
|
|
3269
|
+
ids.some((id) => !isAdapterId(id))
|
|
3270
|
+
|| new Set(ids).size !== ids.length
|
|
3271
|
+
|| ids.some((id, index) => index > 0 && id <= ids[index - 1]!)
|
|
3272
|
+
) {
|
|
3273
|
+
throw new Error("bundled adapter generation selections require unique sorted IDs");
|
|
3274
|
+
}
|
|
3275
|
+
for (const selection of selections) {
|
|
3276
|
+
const expected = selection.expectedCurrentContentSha256;
|
|
3277
|
+
if (
|
|
3278
|
+
expected !== undefined
|
|
3279
|
+
&& expected !== null
|
|
3280
|
+
&& !contentSha256Pattern.test(expected)
|
|
3281
|
+
) {
|
|
3282
|
+
throw new Error(
|
|
3283
|
+
`bundled adapter ${selection.id} expected content hash is invalid`,
|
|
3284
|
+
);
|
|
3285
|
+
}
|
|
3286
|
+
}
|
|
3287
|
+
|
|
3288
|
+
return withAdapterGenerationTransaction(environment, (claim, current) => {
|
|
3289
|
+
const guardedIds = selections
|
|
3290
|
+
.filter((selection) =>
|
|
3291
|
+
selection.expectedCurrentContentSha256 !== undefined
|
|
3292
|
+
)
|
|
3293
|
+
.map((selection) => selection.id);
|
|
3294
|
+
const activeContentSha256 = activeAdapterContentSha256Batch(
|
|
3295
|
+
current,
|
|
3296
|
+
guardedIds,
|
|
3297
|
+
environment,
|
|
3298
|
+
);
|
|
3299
|
+
const entries = new Map(
|
|
3300
|
+
(current?.entries ?? []).map((entry) => [entry.id, entry] as const),
|
|
3301
|
+
);
|
|
3302
|
+
const changedIds = new Set<string>();
|
|
3303
|
+
let installed = 0;
|
|
3304
|
+
let preservedLegacy = 0;
|
|
3305
|
+
let completedObjects = 0;
|
|
3306
|
+
for (const selection of selections) {
|
|
3307
|
+
if (selection.expectedCurrentContentSha256 !== undefined) {
|
|
3308
|
+
const actual = activeContentSha256.get(selection.id);
|
|
3309
|
+
if (actual !== selection.expectedCurrentContentSha256) {
|
|
3310
|
+
throw new Error(
|
|
3311
|
+
`installed adapter ${selection.id} changed after it was classified; retry the bundled adapter sync`,
|
|
3312
|
+
);
|
|
3313
|
+
}
|
|
3314
|
+
}
|
|
3315
|
+
if (selection.state === "legacy") {
|
|
3316
|
+
if (entries.delete(selection.id)) changedIds.add(selection.id);
|
|
3317
|
+
preservedLegacy += 1;
|
|
3318
|
+
continue;
|
|
3319
|
+
}
|
|
3320
|
+
if (selection.manifest.id !== selection.id) {
|
|
3321
|
+
throw new Error("bundled adapter generation selection ID does not match its manifest");
|
|
3322
|
+
}
|
|
3323
|
+
const canonicalContent = `${canonicalJson(selection.manifest)}\n`;
|
|
3324
|
+
const candidateObjectHash = sha256(canonicalContent);
|
|
3325
|
+
const candidateManifestHash = manifestHash(selection.manifest);
|
|
3326
|
+
const existing = entries.get(selection.id);
|
|
3327
|
+
const entry = existing?.state === "present"
|
|
3328
|
+
&& existing.objectContentSha256 === candidateObjectHash
|
|
3329
|
+
&& existing.manifestHash === candidateManifestHash
|
|
3330
|
+
&& existing.sourceContentSha256 === selection.sourceContentSha256
|
|
3331
|
+
? existing
|
|
3332
|
+
: publishAdapterGenerationObject(
|
|
3333
|
+
selection.manifest,
|
|
3334
|
+
selection.sourceContentSha256,
|
|
3335
|
+
environment,
|
|
3336
|
+
requireManifestRegistry(options.registry),
|
|
3337
|
+
);
|
|
3338
|
+
if (entry !== existing) changedIds.add(selection.id);
|
|
3339
|
+
entries.set(selection.id, entry);
|
|
3340
|
+
installed += 1;
|
|
3341
|
+
if (entry !== existing) {
|
|
3342
|
+
completedObjects += 1;
|
|
3343
|
+
options.afterObjectForTest?.(completedObjects);
|
|
3344
|
+
}
|
|
3345
|
+
}
|
|
3346
|
+
const nextEntries = [...entries.values()].sort((left, right) =>
|
|
3347
|
+
left.id.localeCompare(right.id)
|
|
3348
|
+
);
|
|
3349
|
+
const pointerChanged = current === null
|
|
3350
|
+
|| canonicalJson(current.entries) !== canonicalJson(nextEntries);
|
|
3351
|
+
const commitId = pointerChanged
|
|
3352
|
+
? claim.record.transactionId
|
|
3353
|
+
: current.commitId;
|
|
3354
|
+
if (pointerChanged) {
|
|
3355
|
+
writeAdapterGenerationIndex(nextEntries, commitId, environment);
|
|
3356
|
+
options.afterCommitForTest?.();
|
|
3357
|
+
}
|
|
3358
|
+
|
|
3359
|
+
// Keep the historical flat layout as a compatibility mirror. The atomic
|
|
3360
|
+
// generation index is already authoritative, so interruption here cannot
|
|
3361
|
+
// expose a mixed generation to Ghostget readers.
|
|
3362
|
+
for (const selection of selections) {
|
|
3363
|
+
if (
|
|
3364
|
+
selection.state !== "present"
|
|
3365
|
+
|| !changedIds.has(selection.id)
|
|
3366
|
+
) {
|
|
3367
|
+
continue;
|
|
3368
|
+
}
|
|
3369
|
+
writePrivateJson(
|
|
3370
|
+
adapterManifestPath(selection.id, environment),
|
|
3371
|
+
selection.manifest,
|
|
3372
|
+
{ privateParent: true },
|
|
3373
|
+
);
|
|
3374
|
+
}
|
|
3375
|
+
return Object.freeze({ commitId, installed, preservedLegacy });
|
|
3376
|
+
});
|
|
3377
|
+
}
|
|
3378
|
+
|
|
3379
|
+
export function readManifestFile(
|
|
3380
|
+
path: string,
|
|
3381
|
+
registry?: ProviderPluginRegistry,
|
|
3382
|
+
): ParseResult<GhostgetManifest> {
|
|
3383
|
+
try {
|
|
3384
|
+
return parseRuntimeManifest(readJsonFile(path), requireManifestRegistry(registry));
|
|
3385
|
+
} catch (error) {
|
|
3386
|
+
return { ok: false, issues: [error instanceof Error ? error.message : String(error)] };
|
|
3387
|
+
}
|
|
3388
|
+
}
|
|
3389
|
+
|
|
3390
|
+
/**
|
|
3391
|
+
* Parse a retired manifest only as inert migration evidence.
|
|
3392
|
+
*
|
|
3393
|
+
* Callers must never return this value from installed-state loading or pass it
|
|
3394
|
+
* to an execution boundary. Runtime validation remains `readManifestFile`.
|
|
3395
|
+
*/
|
|
3396
|
+
export function readDiagnosticManifestFile(
|
|
3397
|
+
path: string,
|
|
3398
|
+
registry?: ProviderPluginRegistry,
|
|
3399
|
+
): ParseResult<GhostgetManifest> {
|
|
3400
|
+
try {
|
|
3401
|
+
return parseDiagnosticManifest(
|
|
3402
|
+
readJsonFile(path),
|
|
3403
|
+
requireManifestRegistry(registry),
|
|
3404
|
+
);
|
|
3405
|
+
} catch (error) {
|
|
3406
|
+
return { ok: false, issues: [error instanceof Error ? error.message : String(error)] };
|
|
3407
|
+
}
|
|
3408
|
+
}
|
|
3409
|
+
|
|
3410
|
+
export type InstalledManifestSnapshot = {
|
|
3411
|
+
readonly result: ParseResult<GhostgetManifest>;
|
|
3412
|
+
readonly availability: "absent" | "present" | "unsafe";
|
|
3413
|
+
/** Hash of the exact private file bytes read for this snapshot. */
|
|
3414
|
+
readonly contentSha256: string | null;
|
|
3415
|
+
};
|
|
3416
|
+
|
|
3417
|
+
function loadInstalledManifestSnapshotWith(
|
|
3418
|
+
id: string,
|
|
3419
|
+
environment: Readonly<Record<string, string | undefined>>,
|
|
3420
|
+
parseInstalled: (value: unknown) => ParseResult<GhostgetManifest>,
|
|
3421
|
+
): InstalledManifestSnapshot {
|
|
3422
|
+
let index: AdapterGenerationIndex | null;
|
|
3423
|
+
try {
|
|
3424
|
+
index = readAdapterGenerationIndex(environment);
|
|
3425
|
+
} catch (error) {
|
|
3426
|
+
return {
|
|
3427
|
+
result: {
|
|
3428
|
+
ok: false,
|
|
3429
|
+
issues: [error instanceof Error ? error.message : String(error)],
|
|
3430
|
+
},
|
|
3431
|
+
availability: "unsafe",
|
|
3432
|
+
contentSha256: null,
|
|
3433
|
+
};
|
|
3434
|
+
}
|
|
3435
|
+
const generationEntry = generationEntryById(index, id);
|
|
3436
|
+
if (generationEntry?.state === "absent") {
|
|
3437
|
+
return {
|
|
3438
|
+
result: {
|
|
3439
|
+
ok: false,
|
|
3440
|
+
issues: [`adapter ${id} is not installed`],
|
|
3441
|
+
},
|
|
3442
|
+
availability: "absent",
|
|
3443
|
+
contentSha256: null,
|
|
3444
|
+
};
|
|
3445
|
+
}
|
|
3446
|
+
if (generationEntry?.state === "present") {
|
|
3447
|
+
return readAdapterGenerationObject(
|
|
3448
|
+
generationEntry,
|
|
3449
|
+
environment,
|
|
3450
|
+
parseInstalled,
|
|
3451
|
+
);
|
|
3452
|
+
}
|
|
3453
|
+
const path = adapterManifestPath(id, environment);
|
|
3454
|
+
let content: string | null;
|
|
3455
|
+
try {
|
|
3456
|
+
content = readPrivateStateFileIfPresent(
|
|
3457
|
+
path,
|
|
3458
|
+
MAX_WRENCH_JSON_BYTES,
|
|
3459
|
+
"installed adapter manifest",
|
|
3460
|
+
environment,
|
|
3461
|
+
);
|
|
3462
|
+
} catch (error) {
|
|
3463
|
+
return {
|
|
3464
|
+
result: { ok: false, issues: [error instanceof Error ? error.message : String(error)] },
|
|
3465
|
+
availability: "unsafe",
|
|
3466
|
+
contentSha256: null,
|
|
3467
|
+
};
|
|
3468
|
+
}
|
|
3469
|
+
if (content === null) {
|
|
3470
|
+
return {
|
|
3471
|
+
result: { ok: false, issues: [`adapter ${id} is not installed`] },
|
|
3472
|
+
availability: "absent",
|
|
3473
|
+
contentSha256: null,
|
|
3474
|
+
};
|
|
3475
|
+
}
|
|
3476
|
+
try {
|
|
3477
|
+
return {
|
|
3478
|
+
result: parseInstalled(JSON.parse(content) as unknown),
|
|
3479
|
+
availability: "present",
|
|
3480
|
+
contentSha256: sha256(content),
|
|
3481
|
+
};
|
|
3482
|
+
} catch (error) {
|
|
3483
|
+
return {
|
|
3484
|
+
result: { ok: false, issues: [error instanceof Error ? error.message : String(error)] },
|
|
3485
|
+
availability: "present",
|
|
3486
|
+
contentSha256: sha256(content),
|
|
3487
|
+
};
|
|
3488
|
+
}
|
|
3489
|
+
}
|
|
3490
|
+
|
|
3491
|
+
export function loadInstalledManifestSnapshot(
|
|
3492
|
+
id: string,
|
|
3493
|
+
environment: Readonly<Record<string, string | undefined>> = process.env,
|
|
3494
|
+
registry?: ProviderPluginRegistry,
|
|
3495
|
+
): InstalledManifestSnapshot {
|
|
3496
|
+
return loadInstalledManifestSnapshotWith(
|
|
3497
|
+
id,
|
|
3498
|
+
environment,
|
|
3499
|
+
(value) => parseRuntimeManifest(value, requireManifestRegistry(registry)),
|
|
3500
|
+
);
|
|
3501
|
+
}
|
|
3502
|
+
|
|
3503
|
+
/**
|
|
3504
|
+
* Read installed bytes as inert migration evidence. The result is unsafe for
|
|
3505
|
+
* capability listing or execution and exists only to compare an archived hash
|
|
3506
|
+
* before replacing it with a runtime-valid manifest.
|
|
3507
|
+
*/
|
|
3508
|
+
export function loadInstalledDiagnosticManifestSnapshot(
|
|
3509
|
+
id: string,
|
|
3510
|
+
environment: Readonly<Record<string, string | undefined>> = process.env,
|
|
3511
|
+
registry?: ProviderPluginRegistry,
|
|
3512
|
+
): InstalledManifestSnapshot {
|
|
3513
|
+
const resolvedRegistry = requireManifestRegistry(registry);
|
|
3514
|
+
return loadInstalledManifestSnapshotWith(
|
|
3515
|
+
id,
|
|
3516
|
+
environment,
|
|
3517
|
+
(value) => parseDiagnosticManifest(value, resolvedRegistry),
|
|
3518
|
+
);
|
|
3519
|
+
}
|
|
3520
|
+
|
|
3521
|
+
export function listInstalledDiagnosticManifestSnapshots(
|
|
3522
|
+
environment: Readonly<Record<string, string | undefined>> = process.env,
|
|
3523
|
+
registry?: ProviderPluginRegistry,
|
|
3524
|
+
): readonly {
|
|
3525
|
+
readonly id: string;
|
|
3526
|
+
readonly snapshot: InstalledManifestSnapshot;
|
|
3527
|
+
}[] {
|
|
3528
|
+
const results = new Map<string, InstalledManifestSnapshot>();
|
|
3529
|
+
const directory = adapterDirectory(environment);
|
|
3530
|
+
const legacyDirectory = snapshotPrivateStateDirectory(directory, environment);
|
|
3531
|
+
if (legacyDirectory.identity !== null) {
|
|
3532
|
+
const entries = legacyDirectory.entries.filter((entry) =>
|
|
3533
|
+
entry.kind === "directory" && isAdapterId(entry.name)
|
|
3534
|
+
);
|
|
3535
|
+
const readable = entries.filter(
|
|
3536
|
+
(entry): entry is PrivateStateDirectoryEntry & {
|
|
3537
|
+
readonly identity: PrivateDirectoryIdentity;
|
|
3538
|
+
} => entry.identity !== undefined,
|
|
3539
|
+
);
|
|
3540
|
+
const files = readPrivateStateChildFilesBatched(
|
|
3541
|
+
directory,
|
|
3542
|
+
readable.map((entry) => ({
|
|
3543
|
+
directoryName: entry.name,
|
|
3544
|
+
directoryIdentity: entry.identity,
|
|
3545
|
+
fileName: "io-adapter.json",
|
|
3546
|
+
})),
|
|
3547
|
+
{
|
|
3548
|
+
maximumBytesPerFile: MAX_WRENCH_JSON_BYTES,
|
|
3549
|
+
environment,
|
|
3550
|
+
expectedDirectoryIdentity: legacyDirectory.identity,
|
|
3551
|
+
},
|
|
3552
|
+
);
|
|
3553
|
+
const byId = new Map(files.map((file) => [file.directoryName, file]));
|
|
3554
|
+
for (const entry of entries) {
|
|
3555
|
+
const file = byId.get(entry.name);
|
|
3556
|
+
if (file?.status !== "present") {
|
|
3557
|
+
results.set(entry.name, {
|
|
3558
|
+
result: {
|
|
3559
|
+
ok: false,
|
|
3560
|
+
issues: ["installed adapter manifest is unavailable"],
|
|
3561
|
+
},
|
|
3562
|
+
availability: "unsafe",
|
|
3563
|
+
contentSha256: null,
|
|
3564
|
+
});
|
|
3565
|
+
continue;
|
|
3566
|
+
}
|
|
3567
|
+
const contentSha256 = sha256(file.content);
|
|
3568
|
+
try {
|
|
3569
|
+
results.set(entry.name, {
|
|
3570
|
+
result: parseDiagnosticManifest(
|
|
3571
|
+
JSON.parse(file.content) as unknown,
|
|
3572
|
+
requireManifestRegistry(registry),
|
|
3573
|
+
),
|
|
3574
|
+
availability: "present",
|
|
3575
|
+
contentSha256,
|
|
3576
|
+
});
|
|
3577
|
+
} catch (error) {
|
|
3578
|
+
results.set(entry.name, {
|
|
3579
|
+
result: {
|
|
3580
|
+
ok: false,
|
|
3581
|
+
issues: [error instanceof Error ? error.message : String(error)],
|
|
3582
|
+
},
|
|
3583
|
+
availability: "present",
|
|
3584
|
+
contentSha256,
|
|
3585
|
+
});
|
|
3586
|
+
}
|
|
3587
|
+
}
|
|
3588
|
+
}
|
|
3589
|
+
|
|
3590
|
+
const generation = readAdapterGenerationIndex(environment);
|
|
3591
|
+
const presentEntries = (generation?.entries ?? []).filter(
|
|
3592
|
+
(entry): entry is AdapterGenerationPresentEntry =>
|
|
3593
|
+
entry.state === "present",
|
|
3594
|
+
);
|
|
3595
|
+
const objectContents = new Map<string, string | null>();
|
|
3596
|
+
if (presentEntries.length > 0) {
|
|
3597
|
+
const objectsDirectory = adapterGenerationObjectsDirectory(environment);
|
|
3598
|
+
const objectsSnapshot = snapshotPrivateStateDirectory(
|
|
3599
|
+
objectsDirectory,
|
|
3600
|
+
environment,
|
|
3601
|
+
);
|
|
3602
|
+
if (objectsSnapshot.identity !== null) {
|
|
3603
|
+
const names = [...new Set(presentEntries.map((entry) =>
|
|
3604
|
+
`${entry.objectContentSha256}.json`
|
|
3605
|
+
))];
|
|
3606
|
+
const files = readPrivateStateFilesBatched(
|
|
3607
|
+
objectsDirectory,
|
|
3608
|
+
names,
|
|
3609
|
+
{
|
|
3610
|
+
maximumBytesPerFile: MAX_WRENCH_JSON_BYTES,
|
|
3611
|
+
environment,
|
|
3612
|
+
expectedDirectoryIdentity: objectsSnapshot.identity,
|
|
3613
|
+
},
|
|
3614
|
+
);
|
|
3615
|
+
for (const file of files) {
|
|
3616
|
+
objectContents.set(
|
|
3617
|
+
file.name,
|
|
3618
|
+
file.status === "present" ? file.content : null,
|
|
3619
|
+
);
|
|
3620
|
+
}
|
|
3621
|
+
}
|
|
3622
|
+
}
|
|
3623
|
+
for (const entry of generation?.entries ?? []) {
|
|
3624
|
+
if (entry.state === "absent") {
|
|
3625
|
+
results.delete(entry.id);
|
|
3626
|
+
continue;
|
|
3627
|
+
}
|
|
3628
|
+
results.set(
|
|
3629
|
+
entry.id,
|
|
3630
|
+
parseAdapterGenerationObjectContent(
|
|
3631
|
+
entry,
|
|
3632
|
+
objectContents.get(`${entry.objectContentSha256}.json`) ?? null,
|
|
3633
|
+
(value) => parseDiagnosticManifest(
|
|
3634
|
+
value,
|
|
3635
|
+
requireManifestRegistry(registry),
|
|
3636
|
+
),
|
|
3637
|
+
),
|
|
3638
|
+
);
|
|
3639
|
+
}
|
|
3640
|
+
return Object.freeze(
|
|
3641
|
+
[...results.entries()]
|
|
3642
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
3643
|
+
.map(([id, snapshot]) => Object.freeze({ id, snapshot })),
|
|
3644
|
+
);
|
|
3645
|
+
}
|
|
3646
|
+
|
|
3647
|
+
export function loadInstalledManifest(
|
|
3648
|
+
id: string,
|
|
3649
|
+
environment: Readonly<Record<string, string | undefined>> = process.env,
|
|
3650
|
+
registry?: ProviderPluginRegistry,
|
|
3651
|
+
): ParseResult<GhostgetManifest> {
|
|
3652
|
+
return loadInstalledManifestSnapshot(id, environment, registry).result;
|
|
3653
|
+
}
|
|
3654
|
+
|
|
3655
|
+
export function installManifest(
|
|
3656
|
+
manifest: GhostgetManifest,
|
|
3657
|
+
options: {
|
|
3658
|
+
readonly force: boolean;
|
|
3659
|
+
readonly environment?: Readonly<Record<string, string | undefined>>;
|
|
3660
|
+
/** Exact private-file hash returned by loadInstalledManifestSnapshot. */
|
|
3661
|
+
readonly expectedCurrentContentSha256?: string;
|
|
3662
|
+
/** Test seam for deterministic same-UID replacement interleavings. */
|
|
3663
|
+
readonly beforeReplace?: () => void;
|
|
3664
|
+
/** Registry that owns any non-built-in provider contracts in this manifest. */
|
|
3665
|
+
readonly registry?: ProviderPluginRegistry;
|
|
3666
|
+
},
|
|
3667
|
+
): string {
|
|
3668
|
+
const registry = requireManifestRegistry(options.registry);
|
|
3669
|
+
const parsed = parseRuntimeManifest(manifest, registry);
|
|
3670
|
+
if (!parsed.ok) throw new Error(`invalid runtime manifest: ${parsed.issues.join("; ")}`);
|
|
3671
|
+
if (canonicalJson(parsed.value) !== canonicalJson(manifest)) {
|
|
3672
|
+
throw new Error("runtime manifest contains unsupported or non-canonical state");
|
|
3673
|
+
}
|
|
3674
|
+
manifest = parsed.value;
|
|
3675
|
+
if (
|
|
3676
|
+
options.expectedCurrentContentSha256 !== undefined
|
|
3677
|
+
&& !contentSha256Pattern.test(options.expectedCurrentContentSha256)
|
|
3678
|
+
) throw new Error("expected installed manifest content hash is invalid");
|
|
3679
|
+
if (
|
|
3680
|
+
!options.force
|
|
3681
|
+
&& (
|
|
3682
|
+
options.expectedCurrentContentSha256 !== undefined
|
|
3683
|
+
|| options.beforeReplace !== undefined
|
|
3684
|
+
)
|
|
3685
|
+
) {
|
|
3686
|
+
throw new Error("conditional manifest replacement requires force");
|
|
3687
|
+
}
|
|
3688
|
+
const environment = options.environment ?? process.env;
|
|
3689
|
+
const path = adapterManifestPath(manifest.id, environment);
|
|
3690
|
+
return withAdapterGenerationTransaction(environment, (claim, index) => {
|
|
3691
|
+
const generationEntry = generationEntryById(index, manifest.id);
|
|
3692
|
+
if (generationEntry !== undefined) {
|
|
3693
|
+
const installed = generationEntry.state === "present"
|
|
3694
|
+
? readAdapterGenerationObject(
|
|
3695
|
+
generationEntry,
|
|
3696
|
+
environment,
|
|
3697
|
+
(value) => parseRuntimeManifest(value, registry),
|
|
3698
|
+
)
|
|
3699
|
+
: {
|
|
3700
|
+
result: {
|
|
3701
|
+
ok: false as const,
|
|
3702
|
+
issues: [`adapter ${manifest.id} is not installed`],
|
|
3703
|
+
},
|
|
3704
|
+
contentSha256: null,
|
|
3705
|
+
};
|
|
3706
|
+
if (!options.force) {
|
|
3707
|
+
if (
|
|
3708
|
+
installed.result.ok
|
|
3709
|
+
&& canonicalJson(installed.result.value) === canonicalJson(manifest)
|
|
3710
|
+
) {
|
|
3711
|
+
writePrivateJson(path, manifest, { privateParent: true });
|
|
3712
|
+
return path;
|
|
3713
|
+
}
|
|
3714
|
+
if (generationEntry.state === "present") {
|
|
3715
|
+
throw new Error(
|
|
3716
|
+
`adapter ${manifest.id} is already installed and differs; pass --force to replace it`,
|
|
3717
|
+
);
|
|
3718
|
+
}
|
|
3719
|
+
}
|
|
3720
|
+
if (
|
|
3721
|
+
options.expectedCurrentContentSha256 !== undefined
|
|
3722
|
+
&& installed.contentSha256 !== options.expectedCurrentContentSha256
|
|
3723
|
+
) {
|
|
3724
|
+
throw new Error("state file content no longer matches the expected hash");
|
|
3725
|
+
}
|
|
3726
|
+
options.beforeReplace?.();
|
|
3727
|
+
if (
|
|
3728
|
+
options.beforeReplace !== undefined
|
|
3729
|
+
&& canonicalJson(readAdapterGenerationIndex(environment))
|
|
3730
|
+
!== canonicalJson(index)
|
|
3731
|
+
) {
|
|
3732
|
+
throw new Error("adapter generation changed before manifest replacement");
|
|
3733
|
+
}
|
|
3734
|
+
const canonicalContentSha256 = sha256(`${canonicalJson(manifest)}\n`);
|
|
3735
|
+
const nextEntry = publishAdapterGenerationObject(
|
|
3736
|
+
manifest,
|
|
3737
|
+
canonicalContentSha256,
|
|
3738
|
+
environment,
|
|
3739
|
+
registry,
|
|
3740
|
+
);
|
|
3741
|
+
const entries = new Map(
|
|
3742
|
+
(index?.entries ?? []).map((entry) => [entry.id, entry] as const),
|
|
3743
|
+
);
|
|
3744
|
+
entries.set(manifest.id, nextEntry);
|
|
3745
|
+
writeAdapterGenerationIndex(
|
|
3746
|
+
[...entries.values()].sort((left, right) =>
|
|
3747
|
+
left.id.localeCompare(right.id)
|
|
3748
|
+
),
|
|
3749
|
+
claim.record.transactionId,
|
|
3750
|
+
environment,
|
|
3751
|
+
);
|
|
3752
|
+
writePrivateJson(path, manifest, { privateParent: true });
|
|
3753
|
+
return path;
|
|
3754
|
+
}
|
|
3755
|
+
|
|
3756
|
+
if (!options.force) {
|
|
3757
|
+
const result = createPrivateJsonIfAbsent(path, manifest, {
|
|
3758
|
+
privateParent: true,
|
|
3759
|
+
environment,
|
|
3760
|
+
});
|
|
3761
|
+
if (result.created) return path;
|
|
3762
|
+
const installed = readManifestFile(path, options.registry);
|
|
3763
|
+
if (
|
|
3764
|
+
installed.ok
|
|
3765
|
+
&& canonicalJson(installed.value) === canonicalJson(manifest)
|
|
3766
|
+
) {
|
|
3767
|
+
return path;
|
|
3768
|
+
}
|
|
3769
|
+
throw new Error(
|
|
3770
|
+
`adapter ${manifest.id} is already installed and differs; pass --force to replace it`,
|
|
3771
|
+
);
|
|
3772
|
+
}
|
|
3773
|
+
const root = ghostgetStateHome(environment);
|
|
3774
|
+
const identity = ensureClaimedStateRoot(root);
|
|
3775
|
+
const segments = stateSegments(root, path);
|
|
3776
|
+
const directoryExpectations = captureStateDirectoryExpectations(
|
|
3777
|
+
root,
|
|
3778
|
+
segments.slice(0, -1),
|
|
3779
|
+
);
|
|
3780
|
+
options.beforeReplace?.();
|
|
3781
|
+
runStateHelper(root, identity, {
|
|
3782
|
+
kind: "write-file",
|
|
3783
|
+
segments,
|
|
3784
|
+
directoryExpectations,
|
|
3785
|
+
content: `${canonicalJson(manifest)}\n`,
|
|
3786
|
+
createOnly: false,
|
|
3787
|
+
expectedContentSha256: options.expectedCurrentContentSha256 ?? null,
|
|
3788
|
+
maximumExpectedContentBytes: DEFAULT_PRIVATE_STATE_EXPECTED_CONTENT_BYTES,
|
|
3789
|
+
});
|
|
3790
|
+
return path;
|
|
3791
|
+
});
|
|
3792
|
+
}
|
|
3793
|
+
|
|
3794
|
+
export function removeInstalledManifest(
|
|
3795
|
+
id: string,
|
|
3796
|
+
environment: Readonly<Record<string, string | undefined>> = process.env,
|
|
3797
|
+
): boolean {
|
|
3798
|
+
return withAdapterGenerationTransaction(environment, (claim, index) => {
|
|
3799
|
+
const path = adapterManifestPath(id, environment);
|
|
3800
|
+
const generationEntry = generationEntryById(index, id);
|
|
3801
|
+
if (generationEntry !== undefined) {
|
|
3802
|
+
if (generationEntry.state === "absent") return false;
|
|
3803
|
+
const entries = new Map(
|
|
3804
|
+
(index?.entries ?? []).map((entry) => [entry.id, entry] as const),
|
|
3805
|
+
);
|
|
3806
|
+
entries.set(id, Object.freeze({ id, state: "absent" }));
|
|
3807
|
+
writeAdapterGenerationIndex(
|
|
3808
|
+
[...entries.values()].sort((left, right) =>
|
|
3809
|
+
left.id.localeCompare(right.id)
|
|
3810
|
+
),
|
|
3811
|
+
claim.record.transactionId,
|
|
3812
|
+
environment,
|
|
3813
|
+
);
|
|
3814
|
+
removePrivateStateFile(path, environment);
|
|
3815
|
+
removePrivateEmptyStateDirectory(dirname(path), environment);
|
|
3816
|
+
return true;
|
|
3817
|
+
}
|
|
3818
|
+
const removed = removePrivateStateFile(path, environment);
|
|
3819
|
+
if (!removed) return false;
|
|
3820
|
+
removePrivateEmptyStateDirectory(dirname(path), environment);
|
|
3821
|
+
return true;
|
|
3822
|
+
});
|
|
3823
|
+
}
|
|
3824
|
+
|
|
3825
|
+
export function listInstalledManifests(
|
|
3826
|
+
environment: Readonly<Record<string, string | undefined>> = process.env,
|
|
3827
|
+
registry?: ProviderPluginRegistry,
|
|
3828
|
+
): readonly { readonly id: string; readonly result: ParseResult<GhostgetManifest> }[] {
|
|
3829
|
+
return Object.freeze(
|
|
3830
|
+
listInstalledDiagnosticManifestSnapshots(environment, registry).map(
|
|
3831
|
+
({ id, snapshot }) => Object.freeze({
|
|
3832
|
+
id,
|
|
3833
|
+
result: snapshot.result.ok
|
|
3834
|
+
? parseRuntimeManifest(
|
|
3835
|
+
snapshot.result.value,
|
|
3836
|
+
requireManifestRegistry(registry),
|
|
3837
|
+
)
|
|
3838
|
+
: snapshot.result,
|
|
3839
|
+
}),
|
|
3840
|
+
),
|
|
3841
|
+
);
|
|
3842
|
+
}
|