@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
|
@@ -0,0 +1,2914 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import {
|
|
4
|
+
closeSync,
|
|
5
|
+
constants,
|
|
6
|
+
fchmodSync,
|
|
7
|
+
fstatSync,
|
|
8
|
+
fsyncSync,
|
|
9
|
+
linkSync,
|
|
10
|
+
lstatSync,
|
|
11
|
+
mkdirSync,
|
|
12
|
+
openSync,
|
|
13
|
+
opendirSync,
|
|
14
|
+
readdirSync,
|
|
15
|
+
readSync,
|
|
16
|
+
renameSync,
|
|
17
|
+
rmSync,
|
|
18
|
+
rmdirSync,
|
|
19
|
+
unlinkSync,
|
|
20
|
+
writeFileSync,
|
|
21
|
+
type BigIntStats,
|
|
22
|
+
} from "node:fs";
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
currentProcessStartIdentity,
|
|
26
|
+
processOwnerStatus,
|
|
27
|
+
type ProcessOwnerIdentity,
|
|
28
|
+
} from "./process-identity";
|
|
29
|
+
|
|
30
|
+
const MAX_REQUEST_BYTES = 4 * 1024 * 1024;
|
|
31
|
+
const MAX_BATCH_FILES = 1_000;
|
|
32
|
+
const MAX_BATCH_FILE_BYTES = 2 * 1024 * 1024;
|
|
33
|
+
const MAX_BATCH_TOTAL_BYTES = 64 * 1024 * 1024;
|
|
34
|
+
const MAX_BATCH_NAME_BYTES = 256 * 1024;
|
|
35
|
+
const MAX_BATCH_STDOUT_BYTES = 96 * 1024 * 1024;
|
|
36
|
+
const MAX_STATE_MUTATION_CONTENT_BYTES = 2 * 1024 * 1024;
|
|
37
|
+
const MAX_STATE_MUTATION_EXPECTED_CONTENT_BYTES = 4 * 1024 * 1024;
|
|
38
|
+
const stateDirectories = new Set([
|
|
39
|
+
"adapter-generations",
|
|
40
|
+
"adapters",
|
|
41
|
+
"auth",
|
|
42
|
+
"browser-snapshots",
|
|
43
|
+
"captures",
|
|
44
|
+
"derivations",
|
|
45
|
+
"idempotency",
|
|
46
|
+
"linked-device-stores",
|
|
47
|
+
"messaging",
|
|
48
|
+
"omni-read-projections",
|
|
49
|
+
"plan-assets",
|
|
50
|
+
"plans",
|
|
51
|
+
"provider-plugin-state",
|
|
52
|
+
"provider-plugins",
|
|
53
|
+
"read-projection-control",
|
|
54
|
+
"read-projections",
|
|
55
|
+
"recovery",
|
|
56
|
+
"run-journals",
|
|
57
|
+
"runs",
|
|
58
|
+
"session-secrets",
|
|
59
|
+
"tools",
|
|
60
|
+
]);
|
|
61
|
+
const markerName = ".io-state.json";
|
|
62
|
+
const markerText = '{"kind":"io-state","schemaVersion":1}\n';
|
|
63
|
+
const emptyDirectoryRemovalRaceForTest = process.env.NODE_ENV === "test"
|
|
64
|
+
? (process.env.GHOSTGET_TEST_EMPTY_DIRECTORY_REMOVAL_RACE ?? process.env.WRENCH_TEST_EMPTY_DIRECTORY_REMOVAL_RACE)
|
|
65
|
+
: undefined;
|
|
66
|
+
const batchReadFaultForTest = process.env.NODE_ENV === "test"
|
|
67
|
+
? (process.env.GHOSTGET_TEST_BATCH_READ_FAULT ?? process.env.WRENCH_TEST_BATCH_READ_FAULT)
|
|
68
|
+
: undefined;
|
|
69
|
+
const casOverlapFaultForTest = process.env.NODE_ENV === "test"
|
|
70
|
+
? (process.env.GHOSTGET_TEST_CAS_FAULT ?? process.env.WRENCH_TEST_CAS_FAULT)
|
|
71
|
+
: undefined;
|
|
72
|
+
const TEST_BARRIER_TIMEOUT_MS = 90_000;
|
|
73
|
+
let pausedAfterStateMutationClaimReadForTest = false;
|
|
74
|
+
const writeTemporaryFaultForTest = process.env.NODE_ENV === "test"
|
|
75
|
+
? (process.env.GHOSTGET_TEST_WRITE_TEMP_FAULT ?? process.env.WRENCH_TEST_WRITE_TEMP_FAULT)
|
|
76
|
+
: undefined;
|
|
77
|
+
const writeTemporaryNamePattern =
|
|
78
|
+
/^\.io-write-([1-9][0-9]{0,9})-([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})\.tmp$/u;
|
|
79
|
+
const stateMutationStageNamePattern =
|
|
80
|
+
/^\.io-mutation-stage-([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})-([1-9][0-9]{0,9})\.tmp$/u;
|
|
81
|
+
const removeQuarantineNamePattern =
|
|
82
|
+
/^\.io-remove-tree-([1-9][0-9]{0,9})-([1-9][0-9]{0,15})-([0-9a-f]{12}4[0-9a-f]{3}[89ab][0-9a-f]{15})\.quarantine$/u;
|
|
83
|
+
const REMOVE_QUARANTINE_SCAN_MAXIMUM = 10_000;
|
|
84
|
+
|
|
85
|
+
type Identity = { readonly device: string; readonly inode: string };
|
|
86
|
+
type DirectoryExpectation = Identity | null;
|
|
87
|
+
type StateMutationClaimPhase = "waiting" | "candidate" | "held";
|
|
88
|
+
type StateMutationClaim = ProcessOwnerIdentity & {
|
|
89
|
+
readonly kind: "io-state-mutation-claim";
|
|
90
|
+
readonly schemaVersion: 1;
|
|
91
|
+
readonly targetSha256: string;
|
|
92
|
+
readonly claimId: string;
|
|
93
|
+
};
|
|
94
|
+
type StateMutationStageSnapshot = {
|
|
95
|
+
readonly claim: StateMutationClaim | null;
|
|
96
|
+
readonly content: Buffer;
|
|
97
|
+
readonly identity: Identity;
|
|
98
|
+
readonly size: bigint;
|
|
99
|
+
readonly mtimeNs: bigint;
|
|
100
|
+
readonly ctimeNs: bigint;
|
|
101
|
+
readonly mode: bigint;
|
|
102
|
+
};
|
|
103
|
+
const stateMutationClaimSnapshotMoved = Symbol(
|
|
104
|
+
"state-mutation-claim-snapshot-moved",
|
|
105
|
+
);
|
|
106
|
+
type StateMutationClaimSnapshotMoved = typeof stateMutationClaimSnapshotMoved;
|
|
107
|
+
type DirectoryEntry = {
|
|
108
|
+
readonly name: string;
|
|
109
|
+
readonly kind: "file" | "directory" | "symbolic-link" | "other";
|
|
110
|
+
readonly identity?: Identity;
|
|
111
|
+
};
|
|
112
|
+
type BatchReadInvalidReason =
|
|
113
|
+
| "unsafe-file"
|
|
114
|
+
| "unreadable"
|
|
115
|
+
| "file-byte-bound"
|
|
116
|
+
| "aggregate-byte-bound"
|
|
117
|
+
| "changed-during-read";
|
|
118
|
+
type BatchReadFileResult =
|
|
119
|
+
| {
|
|
120
|
+
readonly name: string;
|
|
121
|
+
readonly status: "present";
|
|
122
|
+
readonly contentBase64: string;
|
|
123
|
+
}
|
|
124
|
+
| {
|
|
125
|
+
readonly name: string;
|
|
126
|
+
readonly status: "absent";
|
|
127
|
+
}
|
|
128
|
+
| {
|
|
129
|
+
readonly name: string;
|
|
130
|
+
readonly status: "invalid";
|
|
131
|
+
readonly reason: BatchReadInvalidReason;
|
|
132
|
+
};
|
|
133
|
+
type BatchReadChildFile = {
|
|
134
|
+
readonly directoryName: string;
|
|
135
|
+
readonly directoryIdentity: Identity;
|
|
136
|
+
readonly fileName: string;
|
|
137
|
+
};
|
|
138
|
+
type BatchReadChildFileResult =
|
|
139
|
+
| {
|
|
140
|
+
readonly directoryName: string;
|
|
141
|
+
readonly fileName: string;
|
|
142
|
+
readonly status: "present";
|
|
143
|
+
readonly contentBase64: string;
|
|
144
|
+
}
|
|
145
|
+
| {
|
|
146
|
+
readonly directoryName: string;
|
|
147
|
+
readonly fileName: string;
|
|
148
|
+
readonly status: "absent";
|
|
149
|
+
}
|
|
150
|
+
| {
|
|
151
|
+
readonly directoryName: string;
|
|
152
|
+
readonly fileName: string;
|
|
153
|
+
readonly status: "invalid";
|
|
154
|
+
readonly reason: BatchReadInvalidReason;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
type Request = {
|
|
158
|
+
readonly schemaVersion: 1;
|
|
159
|
+
readonly requestId: string;
|
|
160
|
+
readonly expected: Identity;
|
|
161
|
+
readonly operation:
|
|
162
|
+
| { readonly kind: "claim" }
|
|
163
|
+
| { readonly kind: "create-root"; readonly segments: readonly string[] }
|
|
164
|
+
| {
|
|
165
|
+
readonly kind: "ensure-directories";
|
|
166
|
+
readonly segments: readonly string[];
|
|
167
|
+
readonly directoryExpectations: readonly DirectoryExpectation[];
|
|
168
|
+
}
|
|
169
|
+
| {
|
|
170
|
+
readonly kind: "read-file";
|
|
171
|
+
readonly segments: readonly string[];
|
|
172
|
+
readonly directoryExpectations: readonly DirectoryExpectation[];
|
|
173
|
+
readonly maximumBytes: number;
|
|
174
|
+
}
|
|
175
|
+
| {
|
|
176
|
+
readonly kind: "read-file-if-present";
|
|
177
|
+
readonly segments: readonly string[];
|
|
178
|
+
readonly directoryExpectations: readonly DirectoryExpectation[];
|
|
179
|
+
readonly maximumBytes: number;
|
|
180
|
+
}
|
|
181
|
+
| {
|
|
182
|
+
readonly kind: "batch-read-files";
|
|
183
|
+
readonly segments: readonly string[];
|
|
184
|
+
readonly directoryExpectations: readonly DirectoryExpectation[];
|
|
185
|
+
readonly names: readonly string[];
|
|
186
|
+
readonly maximumBytesPerFile: number;
|
|
187
|
+
readonly maximumTotalBytes: number;
|
|
188
|
+
}
|
|
189
|
+
| {
|
|
190
|
+
readonly kind: "batch-read-child-files";
|
|
191
|
+
readonly segments: readonly string[];
|
|
192
|
+
readonly directoryExpectations: readonly DirectoryExpectation[];
|
|
193
|
+
readonly files: readonly BatchReadChildFile[];
|
|
194
|
+
readonly maximumBytesPerFile: number;
|
|
195
|
+
readonly maximumTotalBytes: number;
|
|
196
|
+
}
|
|
197
|
+
| {
|
|
198
|
+
readonly kind: "write-file";
|
|
199
|
+
readonly segments: readonly string[];
|
|
200
|
+
readonly directoryExpectations: readonly DirectoryExpectation[];
|
|
201
|
+
readonly content: string;
|
|
202
|
+
readonly createOnly: boolean;
|
|
203
|
+
readonly expectedContentSha256: string | null;
|
|
204
|
+
readonly maximumExpectedContentBytes: number;
|
|
205
|
+
}
|
|
206
|
+
| {
|
|
207
|
+
readonly kind: "create-directory";
|
|
208
|
+
readonly segments: readonly string[];
|
|
209
|
+
readonly directoryExpectations: readonly DirectoryExpectation[];
|
|
210
|
+
}
|
|
211
|
+
| {
|
|
212
|
+
readonly kind: "remove-file";
|
|
213
|
+
readonly segments: readonly string[];
|
|
214
|
+
readonly directoryExpectations: readonly DirectoryExpectation[];
|
|
215
|
+
}
|
|
216
|
+
| {
|
|
217
|
+
readonly kind: "remove-file-if-unchanged";
|
|
218
|
+
readonly segments: readonly string[];
|
|
219
|
+
readonly directoryExpectations: readonly DirectoryExpectation[];
|
|
220
|
+
readonly expectedContentSha256: string;
|
|
221
|
+
}
|
|
222
|
+
| {
|
|
223
|
+
readonly kind: "remove-empty-directory";
|
|
224
|
+
readonly segments: readonly string[];
|
|
225
|
+
readonly directoryExpectations: readonly DirectoryExpectation[];
|
|
226
|
+
}
|
|
227
|
+
| {
|
|
228
|
+
readonly kind: "list-directory";
|
|
229
|
+
readonly segments: readonly string[];
|
|
230
|
+
readonly directoryExpectations: readonly DirectoryExpectation[];
|
|
231
|
+
readonly recoverOrphanedMutationClaims: boolean;
|
|
232
|
+
}
|
|
233
|
+
| {
|
|
234
|
+
readonly kind: "remove-directory-tree";
|
|
235
|
+
readonly segments: readonly string[];
|
|
236
|
+
readonly directoryExpectations: readonly DirectoryExpectation[];
|
|
237
|
+
};
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
type Response = {
|
|
241
|
+
readonly ok: true;
|
|
242
|
+
readonly identity: Identity;
|
|
243
|
+
readonly created?: boolean;
|
|
244
|
+
readonly removed?: boolean;
|
|
245
|
+
readonly present?: boolean;
|
|
246
|
+
readonly contentBase64?: string;
|
|
247
|
+
readonly entries?: readonly DirectoryEntry[];
|
|
248
|
+
readonly files?: readonly BatchReadFileResult[];
|
|
249
|
+
readonly childFiles?: readonly BatchReadChildFileResult[];
|
|
250
|
+
readonly targetIdentity?: Identity;
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
function hasCode(error: unknown, code: string): boolean {
|
|
254
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === code;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function ownedByCurrentUser(stats: { readonly uid: number | bigint }): boolean {
|
|
258
|
+
const currentUid = typeof process.getuid === "function" ? process.getuid() : undefined;
|
|
259
|
+
return currentUid === undefined || stats.uid === (typeof stats.uid === "bigint" ? BigInt(currentUid) : currentUid);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function identity(stats: { readonly dev: number | bigint; readonly ino: number | bigint }): Identity {
|
|
263
|
+
return { device: stats.dev.toString(), inode: stats.ino.toString() };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function sameIdentity(left: Identity, right: Identity): boolean {
|
|
267
|
+
return left.device === right.device && left.inode === right.inode;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function hasPrivateDirectoryMode(stats: { readonly mode: number | bigint }): boolean {
|
|
271
|
+
return typeof stats.mode === "bigint"
|
|
272
|
+
? (stats.mode & 0o777n) === 0o700n
|
|
273
|
+
: (stats.mode & 0o777) === 0o700;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function readBoundedStdin(): string {
|
|
277
|
+
const chunks: Buffer[] = [];
|
|
278
|
+
const buffer = Buffer.allocUnsafe(64 * 1024);
|
|
279
|
+
let total = 0;
|
|
280
|
+
for (;;) {
|
|
281
|
+
const count = readSync(0, buffer, 0, buffer.byteLength, null);
|
|
282
|
+
if (count === 0) break;
|
|
283
|
+
total += count;
|
|
284
|
+
if (total > MAX_REQUEST_BYTES) throw new Error("request exceeds its byte bound");
|
|
285
|
+
chunks.push(Buffer.from(buffer.subarray(0, count)));
|
|
286
|
+
}
|
|
287
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks, total));
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function readDescriptorBounded(descriptor: number, maximumBytes: number): Buffer {
|
|
291
|
+
const chunks: Buffer[] = [];
|
|
292
|
+
const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, maximumBytes + 1));
|
|
293
|
+
let total = 0;
|
|
294
|
+
for (;;) {
|
|
295
|
+
const remaining = maximumBytes + 1 - total;
|
|
296
|
+
const count = readSync(descriptor, buffer, 0, Math.min(buffer.byteLength, remaining), null);
|
|
297
|
+
if (count === 0) return Buffer.concat(chunks, total);
|
|
298
|
+
total += count;
|
|
299
|
+
if (total > maximumBytes) throw new Error("file grew beyond its byte bound");
|
|
300
|
+
chunks.push(Buffer.from(buffer.subarray(0, count)));
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
305
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function exactKeys(value: Record<string, unknown>, expected: readonly string[]): boolean {
|
|
309
|
+
const actual = Object.keys(value).sort();
|
|
310
|
+
const sorted = [...expected].sort();
|
|
311
|
+
return actual.length === sorted.length && actual.every((key, index) => key === sorted[index]);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function parseIdentity(value: unknown): Identity {
|
|
315
|
+
if (
|
|
316
|
+
!isRecord(value)
|
|
317
|
+
|| !exactKeys(value, ["device", "inode"])
|
|
318
|
+
|| typeof value.device !== "string"
|
|
319
|
+
|| !/^\d{1,40}$/u.test(value.device)
|
|
320
|
+
|| typeof value.inode !== "string"
|
|
321
|
+
|| !/^\d{1,40}$/u.test(value.inode)
|
|
322
|
+
) throw new Error("expected directory identity is invalid");
|
|
323
|
+
return { device: value.device, inode: value.inode };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function parseSegments(value: unknown, allowEmpty = false): readonly string[] {
|
|
327
|
+
if (!Array.isArray(value) || (!allowEmpty && value.length === 0) || value.length > 32) {
|
|
328
|
+
throw new Error("state path segment count is invalid");
|
|
329
|
+
}
|
|
330
|
+
const segments = value.map((candidate) => {
|
|
331
|
+
if (
|
|
332
|
+
typeof candidate !== "string"
|
|
333
|
+
|| candidate === ""
|
|
334
|
+
|| candidate === "."
|
|
335
|
+
|| candidate === ".."
|
|
336
|
+
|| candidate.includes("/")
|
|
337
|
+
|| candidate.includes("\\")
|
|
338
|
+
|| candidate.includes("\u0000")
|
|
339
|
+
|| Buffer.byteLength(candidate, "utf8") > 255
|
|
340
|
+
) throw new Error("state path segment is invalid");
|
|
341
|
+
return candidate;
|
|
342
|
+
});
|
|
343
|
+
return segments;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function parseDirectoryExpectations(
|
|
347
|
+
value: unknown,
|
|
348
|
+
expectedLength: number,
|
|
349
|
+
): readonly DirectoryExpectation[] {
|
|
350
|
+
if (!Array.isArray(value) || value.length !== expectedLength) {
|
|
351
|
+
throw new Error("directory expectation count does not match the state path");
|
|
352
|
+
}
|
|
353
|
+
let sawAbsent = false;
|
|
354
|
+
return value.map((candidate) => {
|
|
355
|
+
if (candidate === null) {
|
|
356
|
+
sawAbsent = true;
|
|
357
|
+
return null;
|
|
358
|
+
}
|
|
359
|
+
if (sawAbsent) throw new Error("an existing directory cannot follow an absent directory expectation");
|
|
360
|
+
return parseIdentity(candidate);
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function parseBatchFileNames(value: unknown): readonly string[] {
|
|
365
|
+
if (!Array.isArray(value) || value.length > MAX_BATCH_FILES) {
|
|
366
|
+
throw new Error("batch file count is invalid");
|
|
367
|
+
}
|
|
368
|
+
let totalBytes = 0;
|
|
369
|
+
const names = value.map((candidate) => {
|
|
370
|
+
if (typeof candidate !== "string") {
|
|
371
|
+
throw new Error("batch file name is invalid");
|
|
372
|
+
}
|
|
373
|
+
assertSafeDirectoryEntryName(candidate);
|
|
374
|
+
totalBytes += Buffer.byteLength(candidate, "utf8");
|
|
375
|
+
if (totalBytes > MAX_BATCH_NAME_BYTES) {
|
|
376
|
+
throw new Error("batch file names exceed their byte bound");
|
|
377
|
+
}
|
|
378
|
+
return candidate;
|
|
379
|
+
});
|
|
380
|
+
if (new Set(names).size !== names.length) {
|
|
381
|
+
throw new Error("batch file names must be unique");
|
|
382
|
+
}
|
|
383
|
+
return names;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function parseBatchChildFiles(value: unknown): readonly BatchReadChildFile[] {
|
|
387
|
+
if (!Array.isArray(value) || value.length > MAX_BATCH_FILES) {
|
|
388
|
+
throw new Error("batch child-file count is invalid");
|
|
389
|
+
}
|
|
390
|
+
let totalBytes = 0;
|
|
391
|
+
const keys = new Set<string>();
|
|
392
|
+
return value.map((candidate) => {
|
|
393
|
+
if (
|
|
394
|
+
!isRecord(candidate)
|
|
395
|
+
|| !exactKeys(candidate, [
|
|
396
|
+
"directoryName",
|
|
397
|
+
"directoryIdentity",
|
|
398
|
+
"fileName",
|
|
399
|
+
])
|
|
400
|
+
|| typeof candidate.directoryName !== "string"
|
|
401
|
+
|| typeof candidate.fileName !== "string"
|
|
402
|
+
) {
|
|
403
|
+
throw new Error("batch child-file request is invalid");
|
|
404
|
+
}
|
|
405
|
+
assertSafeDirectoryEntryName(candidate.directoryName);
|
|
406
|
+
assertSafeDirectoryEntryName(candidate.fileName);
|
|
407
|
+
totalBytes += Buffer.byteLength(candidate.directoryName, "utf8")
|
|
408
|
+
+ Buffer.byteLength(candidate.fileName, "utf8");
|
|
409
|
+
if (totalBytes > MAX_BATCH_NAME_BYTES) {
|
|
410
|
+
throw new Error("batch child-file names exceed their byte bound");
|
|
411
|
+
}
|
|
412
|
+
const key = `${candidate.directoryName}\u0000${candidate.fileName}`;
|
|
413
|
+
if (keys.has(key)) {
|
|
414
|
+
throw new Error("batch child-file requests must be unique");
|
|
415
|
+
}
|
|
416
|
+
keys.add(key);
|
|
417
|
+
return {
|
|
418
|
+
directoryName: candidate.directoryName,
|
|
419
|
+
directoryIdentity: parseIdentity(candidate.directoryIdentity),
|
|
420
|
+
fileName: candidate.fileName,
|
|
421
|
+
};
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function parseRequest(value: unknown): Request {
|
|
426
|
+
if (
|
|
427
|
+
!isRecord(value)
|
|
428
|
+
|| !exactKeys(value, ["schemaVersion", "requestId", "expected", "operation"])
|
|
429
|
+
|| value.schemaVersion !== 1
|
|
430
|
+
|| typeof value.requestId !== "string"
|
|
431
|
+
|| !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u.test(value.requestId)
|
|
432
|
+
|| !isRecord(value.operation)
|
|
433
|
+
|| typeof value.operation.kind !== "string"
|
|
434
|
+
) throw new Error("request envelope is invalid");
|
|
435
|
+
const expected = parseIdentity(value.expected);
|
|
436
|
+
const operation = value.operation;
|
|
437
|
+
if (operation.kind === "claim" && exactKeys(operation, ["kind"])) {
|
|
438
|
+
return { schemaVersion: 1, requestId: value.requestId, expected, operation: { kind: "claim" } };
|
|
439
|
+
}
|
|
440
|
+
if (operation.kind === "create-root" && exactKeys(operation, ["kind", "segments"])) {
|
|
441
|
+
return { schemaVersion: 1, requestId: value.requestId, expected, operation: { kind: "create-root", segments: parseSegments(operation.segments) } };
|
|
442
|
+
}
|
|
443
|
+
if (operation.kind === "ensure-directories" && exactKeys(operation, ["kind", "segments", "directoryExpectations"])) {
|
|
444
|
+
const segments = parseSegments(operation.segments, true);
|
|
445
|
+
return {
|
|
446
|
+
schemaVersion: 1,
|
|
447
|
+
requestId: value.requestId,
|
|
448
|
+
expected,
|
|
449
|
+
operation: {
|
|
450
|
+
kind: "ensure-directories",
|
|
451
|
+
segments,
|
|
452
|
+
directoryExpectations: parseDirectoryExpectations(operation.directoryExpectations, segments.length),
|
|
453
|
+
},
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
if (
|
|
457
|
+
(operation.kind === "read-file" || operation.kind === "read-file-if-present")
|
|
458
|
+
&& exactKeys(operation, ["kind", "segments", "directoryExpectations", "maximumBytes"])
|
|
459
|
+
&& typeof operation.maximumBytes === "number"
|
|
460
|
+
&& Number.isSafeInteger(operation.maximumBytes)
|
|
461
|
+
&& operation.maximumBytes >= 0
|
|
462
|
+
&& operation.maximumBytes <= 128 * 1024 * 1024
|
|
463
|
+
) {
|
|
464
|
+
const segments = parseSegments(operation.segments);
|
|
465
|
+
return {
|
|
466
|
+
schemaVersion: 1,
|
|
467
|
+
requestId: value.requestId,
|
|
468
|
+
expected,
|
|
469
|
+
operation: {
|
|
470
|
+
kind: operation.kind,
|
|
471
|
+
segments,
|
|
472
|
+
directoryExpectations: parseDirectoryExpectations(operation.directoryExpectations, segments.length - 1),
|
|
473
|
+
maximumBytes: operation.maximumBytes,
|
|
474
|
+
},
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
if (
|
|
478
|
+
operation.kind === "batch-read-files"
|
|
479
|
+
&& exactKeys(operation, [
|
|
480
|
+
"kind",
|
|
481
|
+
"segments",
|
|
482
|
+
"directoryExpectations",
|
|
483
|
+
"names",
|
|
484
|
+
"maximumBytesPerFile",
|
|
485
|
+
"maximumTotalBytes",
|
|
486
|
+
])
|
|
487
|
+
&& typeof operation.maximumBytesPerFile === "number"
|
|
488
|
+
&& Number.isSafeInteger(operation.maximumBytesPerFile)
|
|
489
|
+
&& operation.maximumBytesPerFile >= 0
|
|
490
|
+
&& operation.maximumBytesPerFile <= MAX_BATCH_FILE_BYTES
|
|
491
|
+
&& typeof operation.maximumTotalBytes === "number"
|
|
492
|
+
&& Number.isSafeInteger(operation.maximumTotalBytes)
|
|
493
|
+
&& operation.maximumTotalBytes >= 0
|
|
494
|
+
&& operation.maximumTotalBytes <= MAX_BATCH_TOTAL_BYTES
|
|
495
|
+
) {
|
|
496
|
+
const segments = parseSegments(operation.segments);
|
|
497
|
+
return {
|
|
498
|
+
schemaVersion: 1,
|
|
499
|
+
requestId: value.requestId,
|
|
500
|
+
expected,
|
|
501
|
+
operation: {
|
|
502
|
+
kind: "batch-read-files",
|
|
503
|
+
segments,
|
|
504
|
+
directoryExpectations: parseDirectoryExpectations(
|
|
505
|
+
operation.directoryExpectations,
|
|
506
|
+
segments.length,
|
|
507
|
+
),
|
|
508
|
+
names: parseBatchFileNames(operation.names),
|
|
509
|
+
maximumBytesPerFile: operation.maximumBytesPerFile,
|
|
510
|
+
maximumTotalBytes: operation.maximumTotalBytes,
|
|
511
|
+
},
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
if (
|
|
515
|
+
operation.kind === "batch-read-child-files"
|
|
516
|
+
&& exactKeys(operation, [
|
|
517
|
+
"kind",
|
|
518
|
+
"segments",
|
|
519
|
+
"directoryExpectations",
|
|
520
|
+
"files",
|
|
521
|
+
"maximumBytesPerFile",
|
|
522
|
+
"maximumTotalBytes",
|
|
523
|
+
])
|
|
524
|
+
&& typeof operation.maximumBytesPerFile === "number"
|
|
525
|
+
&& Number.isSafeInteger(operation.maximumBytesPerFile)
|
|
526
|
+
&& operation.maximumBytesPerFile >= 0
|
|
527
|
+
&& operation.maximumBytesPerFile <= MAX_BATCH_FILE_BYTES
|
|
528
|
+
&& typeof operation.maximumTotalBytes === "number"
|
|
529
|
+
&& Number.isSafeInteger(operation.maximumTotalBytes)
|
|
530
|
+
&& operation.maximumTotalBytes >= 0
|
|
531
|
+
&& operation.maximumTotalBytes <= MAX_BATCH_TOTAL_BYTES
|
|
532
|
+
) {
|
|
533
|
+
const segments = parseSegments(operation.segments);
|
|
534
|
+
return {
|
|
535
|
+
schemaVersion: 1,
|
|
536
|
+
requestId: value.requestId,
|
|
537
|
+
expected,
|
|
538
|
+
operation: {
|
|
539
|
+
kind: "batch-read-child-files",
|
|
540
|
+
segments,
|
|
541
|
+
directoryExpectations: parseDirectoryExpectations(
|
|
542
|
+
operation.directoryExpectations,
|
|
543
|
+
segments.length,
|
|
544
|
+
),
|
|
545
|
+
files: parseBatchChildFiles(operation.files),
|
|
546
|
+
maximumBytesPerFile: operation.maximumBytesPerFile,
|
|
547
|
+
maximumTotalBytes: operation.maximumTotalBytes,
|
|
548
|
+
},
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
if (
|
|
552
|
+
operation.kind === "write-file"
|
|
553
|
+
&& exactKeys(operation, [
|
|
554
|
+
"kind",
|
|
555
|
+
"segments",
|
|
556
|
+
"directoryExpectations",
|
|
557
|
+
"content",
|
|
558
|
+
"createOnly",
|
|
559
|
+
"expectedContentSha256",
|
|
560
|
+
"maximumExpectedContentBytes",
|
|
561
|
+
])
|
|
562
|
+
&& typeof operation.content === "string"
|
|
563
|
+
&& Buffer.byteLength(operation.content, "utf8") <= MAX_STATE_MUTATION_CONTENT_BYTES
|
|
564
|
+
&& typeof operation.createOnly === "boolean"
|
|
565
|
+
&& (operation.expectedContentSha256 === null
|
|
566
|
+
|| (typeof operation.expectedContentSha256 === "string" && /^[0-9a-f]{64}$/u.test(operation.expectedContentSha256)))
|
|
567
|
+
&& typeof operation.maximumExpectedContentBytes === "number"
|
|
568
|
+
&& Number.isSafeInteger(operation.maximumExpectedContentBytes)
|
|
569
|
+
&& operation.maximumExpectedContentBytes >= 0
|
|
570
|
+
&& operation.maximumExpectedContentBytes <= MAX_STATE_MUTATION_EXPECTED_CONTENT_BYTES
|
|
571
|
+
&& (!operation.createOnly || operation.expectedContentSha256 === null)
|
|
572
|
+
) {
|
|
573
|
+
const segments = parseSegments(operation.segments);
|
|
574
|
+
return {
|
|
575
|
+
schemaVersion: 1,
|
|
576
|
+
requestId: value.requestId,
|
|
577
|
+
expected,
|
|
578
|
+
operation: {
|
|
579
|
+
kind: "write-file",
|
|
580
|
+
segments,
|
|
581
|
+
directoryExpectations: parseDirectoryExpectations(operation.directoryExpectations, segments.length - 1),
|
|
582
|
+
content: operation.content,
|
|
583
|
+
createOnly: operation.createOnly,
|
|
584
|
+
expectedContentSha256: operation.expectedContentSha256,
|
|
585
|
+
maximumExpectedContentBytes: operation.maximumExpectedContentBytes,
|
|
586
|
+
},
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
if (operation.kind === "remove-file" && exactKeys(operation, ["kind", "segments", "directoryExpectations"])) {
|
|
590
|
+
const segments = parseSegments(operation.segments);
|
|
591
|
+
return {
|
|
592
|
+
schemaVersion: 1,
|
|
593
|
+
requestId: value.requestId,
|
|
594
|
+
expected,
|
|
595
|
+
operation: {
|
|
596
|
+
kind: "remove-file",
|
|
597
|
+
segments,
|
|
598
|
+
directoryExpectations: parseDirectoryExpectations(operation.directoryExpectations, segments.length - 1),
|
|
599
|
+
},
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
if (
|
|
603
|
+
operation.kind === "remove-file-if-unchanged"
|
|
604
|
+
&& exactKeys(operation, [
|
|
605
|
+
"kind",
|
|
606
|
+
"segments",
|
|
607
|
+
"directoryExpectations",
|
|
608
|
+
"expectedContentSha256",
|
|
609
|
+
])
|
|
610
|
+
&& typeof operation.expectedContentSha256 === "string"
|
|
611
|
+
&& /^[0-9a-f]{64}$/u.test(operation.expectedContentSha256)
|
|
612
|
+
) {
|
|
613
|
+
const segments = parseSegments(operation.segments);
|
|
614
|
+
return {
|
|
615
|
+
schemaVersion: 1,
|
|
616
|
+
requestId: value.requestId,
|
|
617
|
+
expected,
|
|
618
|
+
operation: {
|
|
619
|
+
kind: "remove-file-if-unchanged",
|
|
620
|
+
segments,
|
|
621
|
+
directoryExpectations: parseDirectoryExpectations(
|
|
622
|
+
operation.directoryExpectations,
|
|
623
|
+
segments.length - 1,
|
|
624
|
+
),
|
|
625
|
+
expectedContentSha256: operation.expectedContentSha256,
|
|
626
|
+
},
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
if (operation.kind === "create-directory" && exactKeys(operation, ["kind", "segments", "directoryExpectations"])) {
|
|
630
|
+
const segments = parseSegments(operation.segments);
|
|
631
|
+
return {
|
|
632
|
+
schemaVersion: 1,
|
|
633
|
+
requestId: value.requestId,
|
|
634
|
+
expected,
|
|
635
|
+
operation: {
|
|
636
|
+
kind: "create-directory",
|
|
637
|
+
segments,
|
|
638
|
+
directoryExpectations: parseDirectoryExpectations(operation.directoryExpectations, segments.length),
|
|
639
|
+
},
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
if (
|
|
643
|
+
operation.kind === "remove-empty-directory"
|
|
644
|
+
&& exactKeys(operation, ["kind", "segments", "directoryExpectations"])
|
|
645
|
+
) {
|
|
646
|
+
const segments = parseSegments(operation.segments);
|
|
647
|
+
return {
|
|
648
|
+
schemaVersion: 1,
|
|
649
|
+
requestId: value.requestId,
|
|
650
|
+
expected,
|
|
651
|
+
operation: {
|
|
652
|
+
kind: "remove-empty-directory",
|
|
653
|
+
segments,
|
|
654
|
+
directoryExpectations: parseDirectoryExpectations(operation.directoryExpectations, segments.length),
|
|
655
|
+
},
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
if (
|
|
659
|
+
operation.kind === "list-directory"
|
|
660
|
+
&& exactKeys(operation, [
|
|
661
|
+
"kind",
|
|
662
|
+
"segments",
|
|
663
|
+
"directoryExpectations",
|
|
664
|
+
"recoverOrphanedMutationClaims",
|
|
665
|
+
])
|
|
666
|
+
&& typeof operation.recoverOrphanedMutationClaims === "boolean"
|
|
667
|
+
) {
|
|
668
|
+
const segments = parseSegments(operation.segments);
|
|
669
|
+
return {
|
|
670
|
+
schemaVersion: 1,
|
|
671
|
+
requestId: value.requestId,
|
|
672
|
+
expected,
|
|
673
|
+
operation: {
|
|
674
|
+
kind: "list-directory",
|
|
675
|
+
segments,
|
|
676
|
+
directoryExpectations: parseDirectoryExpectations(operation.directoryExpectations, segments.length),
|
|
677
|
+
recoverOrphanedMutationClaims:
|
|
678
|
+
operation.recoverOrphanedMutationClaims,
|
|
679
|
+
},
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
if (
|
|
683
|
+
operation.kind === "remove-directory-tree"
|
|
684
|
+
&& exactKeys(operation, ["kind", "segments", "directoryExpectations"])
|
|
685
|
+
) {
|
|
686
|
+
const segments = parseSegments(operation.segments);
|
|
687
|
+
return {
|
|
688
|
+
schemaVersion: 1,
|
|
689
|
+
requestId: value.requestId,
|
|
690
|
+
expected,
|
|
691
|
+
operation: {
|
|
692
|
+
kind: "remove-directory-tree",
|
|
693
|
+
segments,
|
|
694
|
+
directoryExpectations: parseDirectoryExpectations(operation.directoryExpectations, segments.length),
|
|
695
|
+
},
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
throw new Error("request operation is invalid");
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function openBoundDirectory(path: string): number {
|
|
702
|
+
const descriptor = openSync(
|
|
703
|
+
path,
|
|
704
|
+
constants.O_RDONLY
|
|
705
|
+
| ("O_NOFOLLOW" in constants ? constants.O_NOFOLLOW : 0)
|
|
706
|
+
| ("O_DIRECTORY" in constants ? constants.O_DIRECTORY : 0)
|
|
707
|
+
| ("O_NONBLOCK" in constants ? constants.O_NONBLOCK : 0),
|
|
708
|
+
);
|
|
709
|
+
const stats = fstatSync(descriptor, { bigint: true });
|
|
710
|
+
if (!stats.isDirectory() || !ownedByCurrentUser(stats)) {
|
|
711
|
+
closeSync(descriptor);
|
|
712
|
+
throw new Error("state directory is not an owned real directory");
|
|
713
|
+
}
|
|
714
|
+
return descriptor;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function syncDirectory(path: string): void {
|
|
718
|
+
if (process.platform === "win32") return;
|
|
719
|
+
const descriptor = openBoundDirectory(path);
|
|
720
|
+
try {
|
|
721
|
+
fsyncSync(descriptor);
|
|
722
|
+
} finally {
|
|
723
|
+
closeSync(descriptor);
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function processIsDefinitelyMissing(pid: number): boolean {
|
|
728
|
+
try {
|
|
729
|
+
process.kill(pid, 0);
|
|
730
|
+
return false;
|
|
731
|
+
} catch (error) {
|
|
732
|
+
return (
|
|
733
|
+
typeof error === "object"
|
|
734
|
+
&& error !== null
|
|
735
|
+
&& "code" in error
|
|
736
|
+
&& error.code === "ESRCH"
|
|
737
|
+
);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* A write temporary is recoverable only when its filename is exact, its owner
|
|
743
|
+
* PID is definitely absent, and the leaf is still a private owned regular
|
|
744
|
+
* file. Live, reused, or uninspectable PIDs are retained.
|
|
745
|
+
*/
|
|
746
|
+
function recoverDefinitelyOrphanedWriteTemporaries(): void {
|
|
747
|
+
const names = readdirSync(".");
|
|
748
|
+
let removed = false;
|
|
749
|
+
for (const name of names) {
|
|
750
|
+
const match = writeTemporaryNamePattern.exec(name);
|
|
751
|
+
const pidText = match?.[1];
|
|
752
|
+
if (pidText === undefined) continue;
|
|
753
|
+
const pid = Number(pidText);
|
|
754
|
+
if (
|
|
755
|
+
!Number.isSafeInteger(pid)
|
|
756
|
+
|| pid < 1
|
|
757
|
+
|| pid > 2_147_483_647
|
|
758
|
+
|| !processIsDefinitelyMissing(pid)
|
|
759
|
+
) continue;
|
|
760
|
+
let stats: ReturnType<typeof lstatSync>;
|
|
761
|
+
try {
|
|
762
|
+
stats = lstatSync(name, { bigint: true });
|
|
763
|
+
} catch (error) {
|
|
764
|
+
if (hasCode(error, "ENOENT")) continue;
|
|
765
|
+
throw error;
|
|
766
|
+
}
|
|
767
|
+
if (
|
|
768
|
+
stats.isSymbolicLink()
|
|
769
|
+
|| !stats.isFile()
|
|
770
|
+
|| !ownedByCurrentUser(stats)
|
|
771
|
+
|| (stats.mode & 0o077n) !== 0n
|
|
772
|
+
) continue;
|
|
773
|
+
try {
|
|
774
|
+
unlinkSync(name);
|
|
775
|
+
removed = true;
|
|
776
|
+
} catch (error) {
|
|
777
|
+
if (!hasCode(error, "ENOENT")) throw error;
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
if (removed) syncDirectory(".");
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* A recursive-removal quarantine is helper-owned only when its complete name
|
|
785
|
+
* is exact. Reclaim it only after proving its PID absent and revalidating that
|
|
786
|
+
* the leaf is still one private, current-user directory. Live, reused, and
|
|
787
|
+
* uninspectable PIDs remain untouched.
|
|
788
|
+
*/
|
|
789
|
+
function recoverDefinitelyOrphanedDirectoryQuarantines(): void {
|
|
790
|
+
let entriesRead = 0;
|
|
791
|
+
let removed = false;
|
|
792
|
+
const directory = opendirSync(".");
|
|
793
|
+
try {
|
|
794
|
+
for (;;) {
|
|
795
|
+
const entry = directory.readSync();
|
|
796
|
+
if (entry === null) break;
|
|
797
|
+
entriesRead += 1;
|
|
798
|
+
if (entriesRead > REMOVE_QUARANTINE_SCAN_MAXIMUM) {
|
|
799
|
+
throw new Error(
|
|
800
|
+
`directory quarantine recovery exceeds its ${REMOVE_QUARANTINE_SCAN_MAXIMUM} entry bound`,
|
|
801
|
+
);
|
|
802
|
+
}
|
|
803
|
+
const pidText = removeQuarantineNamePattern.exec(entry.name)?.[1];
|
|
804
|
+
if (pidText === undefined) continue;
|
|
805
|
+
const pid = Number(pidText);
|
|
806
|
+
if (
|
|
807
|
+
!Number.isSafeInteger(pid)
|
|
808
|
+
|| pid < 1
|
|
809
|
+
|| pid > 2_147_483_647
|
|
810
|
+
|| !processIsDefinitelyMissing(pid)
|
|
811
|
+
) continue;
|
|
812
|
+
let stats: BigIntStats;
|
|
813
|
+
try {
|
|
814
|
+
stats = lstatSync(entry.name, { bigint: true });
|
|
815
|
+
} catch (error) {
|
|
816
|
+
if (hasCode(error, "ENOENT")) continue;
|
|
817
|
+
throw error;
|
|
818
|
+
}
|
|
819
|
+
if (
|
|
820
|
+
stats.isSymbolicLink()
|
|
821
|
+
|| !stats.isDirectory()
|
|
822
|
+
|| !ownedByCurrentUser(stats)
|
|
823
|
+
|| !hasPrivateDirectoryMode(stats)
|
|
824
|
+
) continue;
|
|
825
|
+
try {
|
|
826
|
+
rmSync(entry.name, { recursive: true, force: false, maxRetries: 0 });
|
|
827
|
+
removed = true;
|
|
828
|
+
} catch (error) {
|
|
829
|
+
if (!hasCode(error, "ENOENT")) throw error;
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
} finally {
|
|
833
|
+
directory.closeSync();
|
|
834
|
+
}
|
|
835
|
+
if (removed) syncDirectory(".");
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
function pauseAfterWriteTemporaryForTest(): void {
|
|
839
|
+
if (writeTemporaryFaultForTest !== "pause-after-temp-fsync") return;
|
|
840
|
+
const waitState = new Int32Array(new SharedArrayBuffer(4));
|
|
841
|
+
Atomics.wait(waitState, 0, 0, 60_000);
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
function assertExpectedCwd(expected: Identity): Identity {
|
|
845
|
+
const descriptor = openBoundDirectory(".");
|
|
846
|
+
try {
|
|
847
|
+
const stats = fstatSync(descriptor, { bigint: true });
|
|
848
|
+
const actual = identity(stats);
|
|
849
|
+
if (!sameIdentity(actual, expected)) throw new Error("bound state directory identity does not match");
|
|
850
|
+
return actual;
|
|
851
|
+
} finally {
|
|
852
|
+
closeSync(descriptor);
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
function readMarker(): void {
|
|
857
|
+
const descriptor = openSync(
|
|
858
|
+
markerName,
|
|
859
|
+
constants.O_RDONLY
|
|
860
|
+
| ("O_NOFOLLOW" in constants ? constants.O_NOFOLLOW : 0)
|
|
861
|
+
| ("O_NONBLOCK" in constants ? constants.O_NONBLOCK : 0),
|
|
862
|
+
);
|
|
863
|
+
try {
|
|
864
|
+
const stats = fstatSync(descriptor, { bigint: true });
|
|
865
|
+
if (!stats.isFile() || !ownedByCurrentUser(stats) || (stats.mode & 0o077n) !== 0n || stats.size > 256n) {
|
|
866
|
+
throw new Error("state marker is not a private owned file");
|
|
867
|
+
}
|
|
868
|
+
const content = new TextDecoder("utf-8", { fatal: true }).decode(readDescriptorBounded(descriptor, 256));
|
|
869
|
+
if (content !== markerText) throw new Error("state marker is malformed");
|
|
870
|
+
} finally {
|
|
871
|
+
closeSync(descriptor);
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
function assertClaimedRoot(expected: Identity): Identity {
|
|
876
|
+
const actual = assertExpectedCwd(expected);
|
|
877
|
+
const stats = lstatSync(".", { bigint: true });
|
|
878
|
+
if (!hasPrivateDirectoryMode(stats)) throw new Error("state root mode is not 0700");
|
|
879
|
+
readMarker();
|
|
880
|
+
return actual;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
function validateUnclaimedRoot(): void {
|
|
884
|
+
const allowed = new Set([
|
|
885
|
+
...stateDirectories,
|
|
886
|
+
".cursor-encryption-key",
|
|
887
|
+
".plan-encryption-key",
|
|
888
|
+
".projection-encryption-key",
|
|
889
|
+
".recovery-encryption-key",
|
|
890
|
+
".session-encryption-key",
|
|
891
|
+
]);
|
|
892
|
+
const directory = opendirSync(".");
|
|
893
|
+
let count = 0;
|
|
894
|
+
try {
|
|
895
|
+
for (;;) {
|
|
896
|
+
const entry = directory.readSync();
|
|
897
|
+
if (entry === null) break;
|
|
898
|
+
count += 1;
|
|
899
|
+
if (count > 10_000) throw new Error("unclaimed state root contains too many entries");
|
|
900
|
+
assertSafeDirectoryEntryName(entry.name);
|
|
901
|
+
if (entry.isSymbolicLink()) throw new Error("unclaimed state root contains a symbolic link");
|
|
902
|
+
if (/^\.io-state\.stage-\d+-[0-9a-f-]{36}\.json$/u.test(entry.name)) {
|
|
903
|
+
const stats = lstatSync(entry.name);
|
|
904
|
+
if (!stats.isFile() || !ownedByCurrentUser(stats) || (stats.mode & 0o077) !== 0 || stats.size > 256) {
|
|
905
|
+
throw new Error("unclaimed state root contains an invalid marker stage");
|
|
906
|
+
}
|
|
907
|
+
continue;
|
|
908
|
+
}
|
|
909
|
+
if (!allowed.has(entry.name)) throw new Error("unclaimed state root contains unrelated data");
|
|
910
|
+
const stats = lstatSync(entry.name);
|
|
911
|
+
if (!ownedByCurrentUser(stats)) throw new Error("unclaimed state root contains non-owned data");
|
|
912
|
+
if (
|
|
913
|
+
stateDirectories.has(entry.name)
|
|
914
|
+
? !stats.isDirectory() || stats.isSymbolicLink() || !hasPrivateDirectoryMode(stats)
|
|
915
|
+
: !stats.isFile() || (stats.mode & 0o077) !== 0
|
|
916
|
+
) {
|
|
917
|
+
throw new Error("unclaimed state root contains an invalid state entry");
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
} finally {
|
|
921
|
+
directory.closeSync();
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
function publishMarker(): void {
|
|
926
|
+
try {
|
|
927
|
+
readMarker();
|
|
928
|
+
return;
|
|
929
|
+
} catch (error) {
|
|
930
|
+
if (!hasCode(error, "ENOENT")) throw error;
|
|
931
|
+
}
|
|
932
|
+
validateUnclaimedRoot();
|
|
933
|
+
const temporary = `.io-state.stage-${process.pid}-${crypto.randomUUID()}.json`;
|
|
934
|
+
let descriptor: number | null = null;
|
|
935
|
+
try {
|
|
936
|
+
descriptor = openSync(
|
|
937
|
+
temporary,
|
|
938
|
+
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | ("O_NOFOLLOW" in constants ? constants.O_NOFOLLOW : 0),
|
|
939
|
+
0o600,
|
|
940
|
+
);
|
|
941
|
+
fchmodSync(descriptor, 0o600);
|
|
942
|
+
writeFileSync(descriptor, markerText, "utf8");
|
|
943
|
+
fsyncSync(descriptor);
|
|
944
|
+
closeSync(descriptor);
|
|
945
|
+
descriptor = null;
|
|
946
|
+
try {
|
|
947
|
+
linkSync(temporary, markerName);
|
|
948
|
+
} catch (error) {
|
|
949
|
+
if (!hasCode(error, "EEXIST")) throw error;
|
|
950
|
+
readMarker();
|
|
951
|
+
}
|
|
952
|
+
syncDirectory(".");
|
|
953
|
+
} finally {
|
|
954
|
+
if (descriptor !== null) closeSync(descriptor);
|
|
955
|
+
try {
|
|
956
|
+
unlinkSync(temporary);
|
|
957
|
+
syncDirectory(".");
|
|
958
|
+
} catch {
|
|
959
|
+
// A private, strictly named interrupted stage is accepted by the next claim.
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
function claim(expected: Identity): Response {
|
|
965
|
+
const actual = assertExpectedCwd(expected);
|
|
966
|
+
try {
|
|
967
|
+
readMarker();
|
|
968
|
+
if (!hasPrivateDirectoryMode(lstatSync(".", { bigint: true }))) {
|
|
969
|
+
throw new Error("claimed state root mode is not 0700");
|
|
970
|
+
}
|
|
971
|
+
return { ok: true, identity: actual };
|
|
972
|
+
} catch (error) {
|
|
973
|
+
if (!hasCode(error, "ENOENT")) throw error;
|
|
974
|
+
}
|
|
975
|
+
validateUnclaimedRoot();
|
|
976
|
+
const descriptor = openBoundDirectory(".");
|
|
977
|
+
try {
|
|
978
|
+
fchmodSync(descriptor, 0o700);
|
|
979
|
+
fsyncSync(descriptor);
|
|
980
|
+
} finally {
|
|
981
|
+
closeSync(descriptor);
|
|
982
|
+
}
|
|
983
|
+
publishMarker();
|
|
984
|
+
return { ok: true, identity: actual };
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
function assertPrivateDirectory(
|
|
988
|
+
stats: {
|
|
989
|
+
readonly mode: number | bigint;
|
|
990
|
+
readonly uid: number | bigint;
|
|
991
|
+
isDirectory(): boolean;
|
|
992
|
+
isSymbolicLink(): boolean;
|
|
993
|
+
},
|
|
994
|
+
): void {
|
|
995
|
+
if (stats.isSymbolicLink()) throw new Error("state path contains a symbolic link");
|
|
996
|
+
if (!stats.isDirectory() || !ownedByCurrentUser(stats)) {
|
|
997
|
+
throw new Error("state path contains a non-owned real directory");
|
|
998
|
+
}
|
|
999
|
+
if (!hasPrivateDirectoryMode(stats)) throw new Error("existing state directory mode is not 0700");
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
function assertCurrentDirectory(expected: Identity): void {
|
|
1003
|
+
assertExpectedCwd(expected);
|
|
1004
|
+
assertPrivateDirectory(lstatSync(".", { bigint: true }));
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
function currentPrivateDirectoryIdentity(): Identity {
|
|
1008
|
+
const descriptor = openBoundDirectory(".");
|
|
1009
|
+
try {
|
|
1010
|
+
const stats = fstatSync(descriptor, { bigint: true });
|
|
1011
|
+
assertPrivateDirectory(stats);
|
|
1012
|
+
return identity(stats);
|
|
1013
|
+
} finally {
|
|
1014
|
+
closeSync(descriptor);
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
function bindExistingDirectory(segment: string, expected: Identity): void {
|
|
1019
|
+
const stats = lstatSync(segment, { bigint: true });
|
|
1020
|
+
assertPrivateDirectory(stats);
|
|
1021
|
+
if (!sameIdentity(identity(stats), expected)) throw new Error("state directory identity does not match its expectation");
|
|
1022
|
+
process.chdir(segment);
|
|
1023
|
+
assertCurrentDirectory(expected);
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
function createAndBindDirectory(segment: string): Identity {
|
|
1027
|
+
try {
|
|
1028
|
+
lstatSync(segment);
|
|
1029
|
+
throw new Error("state directory appeared where absence was required");
|
|
1030
|
+
} catch (error) {
|
|
1031
|
+
if (!hasCode(error, "ENOENT")) throw error;
|
|
1032
|
+
}
|
|
1033
|
+
try {
|
|
1034
|
+
mkdirSync(segment, { mode: 0o700 });
|
|
1035
|
+
} catch (error) {
|
|
1036
|
+
if (hasCode(error, "EEXIST")) throw new Error("state directory appeared while being created");
|
|
1037
|
+
throw error;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
const createdStats = lstatSync(segment, { bigint: true });
|
|
1041
|
+
if (createdStats.isSymbolicLink() || !createdStats.isDirectory() || !ownedByCurrentUser(createdStats)) {
|
|
1042
|
+
throw new Error("new state path is not an owned real directory");
|
|
1043
|
+
}
|
|
1044
|
+
const createdIdentity = identity(createdStats);
|
|
1045
|
+
const descriptor = openBoundDirectory(segment);
|
|
1046
|
+
try {
|
|
1047
|
+
if (!sameIdentity(identity(fstatSync(descriptor, { bigint: true })), createdIdentity)) {
|
|
1048
|
+
throw new Error("new state directory changed identity before it could be bound");
|
|
1049
|
+
}
|
|
1050
|
+
fchmodSync(descriptor, 0o700);
|
|
1051
|
+
fsyncSync(descriptor);
|
|
1052
|
+
} finally {
|
|
1053
|
+
closeSync(descriptor);
|
|
1054
|
+
}
|
|
1055
|
+
syncDirectory(".");
|
|
1056
|
+
process.chdir(segment);
|
|
1057
|
+
assertCurrentDirectory(createdIdentity);
|
|
1058
|
+
return createdIdentity;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
function assertDirectoryAbsent(segment: string): void {
|
|
1062
|
+
try {
|
|
1063
|
+
lstatSync(segment);
|
|
1064
|
+
} catch (error) {
|
|
1065
|
+
if (hasCode(error, "ENOENT")) return;
|
|
1066
|
+
throw error;
|
|
1067
|
+
}
|
|
1068
|
+
throw new Error("state directory appeared where absence was expected");
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
function createRoot(expected: Identity, segments: readonly string[]): Response {
|
|
1072
|
+
assertExpectedCwd(expected);
|
|
1073
|
+
const anchorStats = lstatSync(".", { bigint: true });
|
|
1074
|
+
if ((anchorStats.mode & 0o022n) !== 0n) {
|
|
1075
|
+
throw new Error("state creation anchor must not be group- or world-writable");
|
|
1076
|
+
}
|
|
1077
|
+
let actual = expected;
|
|
1078
|
+
for (const segment of segments) actual = createAndBindDirectory(segment);
|
|
1079
|
+
assertCurrentDirectory(actual);
|
|
1080
|
+
validateUnclaimedRoot();
|
|
1081
|
+
publishMarker();
|
|
1082
|
+
return { ok: true, identity: actual };
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
function assertStatePath(segments: readonly string[]): void {
|
|
1086
|
+
const first = segments[0];
|
|
1087
|
+
if (
|
|
1088
|
+
first === undefined
|
|
1089
|
+
|| (
|
|
1090
|
+
!stateDirectories.has(first)
|
|
1091
|
+
&& !(
|
|
1092
|
+
segments.length === 1
|
|
1093
|
+
&& (
|
|
1094
|
+
first === ".plan-encryption-key"
|
|
1095
|
+
|| first === ".cursor-encryption-key"
|
|
1096
|
+
|| first === ".projection-encryption-key"
|
|
1097
|
+
|| first === ".recovery-encryption-key"
|
|
1098
|
+
|| first === ".session-encryption-key"
|
|
1099
|
+
)
|
|
1100
|
+
)
|
|
1101
|
+
)
|
|
1102
|
+
) {
|
|
1103
|
+
throw new Error("state path is outside the owned state layout");
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
function assertStateDirectoryPath(segments: readonly string[]): void {
|
|
1108
|
+
const first = segments[0];
|
|
1109
|
+
if (first === undefined || !stateDirectories.has(first)) {
|
|
1110
|
+
throw new Error("state directory is outside the owned state layout");
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
function traverseDirectories(
|
|
1115
|
+
segments: readonly string[],
|
|
1116
|
+
expectations: readonly DirectoryExpectation[],
|
|
1117
|
+
createAbsent: boolean,
|
|
1118
|
+
): boolean {
|
|
1119
|
+
if (segments.length !== expectations.length) throw new Error("directory expectation count does not match the state path");
|
|
1120
|
+
if (segments.length > 0) assertStateDirectoryPath(segments);
|
|
1121
|
+
recoverDefinitelyOrphanedDirectoryQuarantines();
|
|
1122
|
+
for (const [index, segment] of segments.entries()) {
|
|
1123
|
+
const expected = expectations[index];
|
|
1124
|
+
if (expected === undefined) throw new Error("directory expectation is missing");
|
|
1125
|
+
if (expected === null) {
|
|
1126
|
+
if (createAbsent) {
|
|
1127
|
+
createAndBindDirectory(segment);
|
|
1128
|
+
} else {
|
|
1129
|
+
assertDirectoryAbsent(segment);
|
|
1130
|
+
return false;
|
|
1131
|
+
}
|
|
1132
|
+
} else {
|
|
1133
|
+
bindExistingDirectory(segment, expected);
|
|
1134
|
+
}
|
|
1135
|
+
recoverDefinitelyOrphanedDirectoryQuarantines();
|
|
1136
|
+
}
|
|
1137
|
+
return true;
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
function fileName(segments: readonly string[]): string {
|
|
1141
|
+
assertStatePath(segments);
|
|
1142
|
+
const name = segments.at(-1);
|
|
1143
|
+
if (name === undefined) throw new Error("state file path is empty");
|
|
1144
|
+
return name;
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
class StateFileChangedWhileReadError extends Error {
|
|
1148
|
+
readonly before: BigIntStats;
|
|
1149
|
+
readonly after: BigIntStats;
|
|
1150
|
+
|
|
1151
|
+
constructor(
|
|
1152
|
+
before: BigIntStats,
|
|
1153
|
+
after: BigIntStats,
|
|
1154
|
+
) {
|
|
1155
|
+
super("state file changed while it was read");
|
|
1156
|
+
this.before = before;
|
|
1157
|
+
this.after = after;
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
function readStablePrivateFile(
|
|
1162
|
+
descriptor: number,
|
|
1163
|
+
maximumBytes: number,
|
|
1164
|
+
afterReadForTest?: () => void,
|
|
1165
|
+
): Buffer {
|
|
1166
|
+
const before = fstatSync(descriptor, { bigint: true });
|
|
1167
|
+
if (
|
|
1168
|
+
!before.isFile()
|
|
1169
|
+
|| !ownedByCurrentUser(before)
|
|
1170
|
+
|| (before.mode & 0o077n) !== 0n
|
|
1171
|
+
|| before.size > BigInt(maximumBytes)
|
|
1172
|
+
) {
|
|
1173
|
+
throw new Error("state file is not a bounded private owned file");
|
|
1174
|
+
}
|
|
1175
|
+
const content = readDescriptorBounded(descriptor, maximumBytes);
|
|
1176
|
+
afterReadForTest?.();
|
|
1177
|
+
const after: BigIntStats = fstatSync(descriptor, { bigint: true });
|
|
1178
|
+
if (
|
|
1179
|
+
!sameIdentity(identity(before), identity(after))
|
|
1180
|
+
|| before.size !== after.size
|
|
1181
|
+
|| before.mtimeNs !== after.mtimeNs
|
|
1182
|
+
|| before.ctimeNs !== after.ctimeNs
|
|
1183
|
+
|| before.mode !== after.mode
|
|
1184
|
+
) {
|
|
1185
|
+
throw new StateFileChangedWhileReadError(before, after);
|
|
1186
|
+
}
|
|
1187
|
+
return content;
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
function stateMutationTargetSha256(name: string): string {
|
|
1191
|
+
return createHash("sha256")
|
|
1192
|
+
.update("io-state-mutation", "utf8")
|
|
1193
|
+
.update("\0", "utf8")
|
|
1194
|
+
.update(name, "utf8")
|
|
1195
|
+
.digest("hex");
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
function stateMutationClaimPrefix(targetSha256: string): string {
|
|
1199
|
+
return `.io-mutation-${targetSha256}-`;
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
function stateMutationClaimName(
|
|
1203
|
+
targetSha256: string,
|
|
1204
|
+
phase: StateMutationClaimPhase,
|
|
1205
|
+
claimId: string,
|
|
1206
|
+
): string {
|
|
1207
|
+
return `${stateMutationClaimPrefix(targetSha256)}${phase}-${claimId}.lock`;
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
function parseStateMutationClaimName(
|
|
1211
|
+
name: string,
|
|
1212
|
+
targetSha256: string,
|
|
1213
|
+
): {
|
|
1214
|
+
readonly phase: StateMutationClaimPhase;
|
|
1215
|
+
readonly claimId: string;
|
|
1216
|
+
} {
|
|
1217
|
+
const prefix = stateMutationClaimPrefix(targetSha256);
|
|
1218
|
+
if (!name.startsWith(prefix)) {
|
|
1219
|
+
throw new Error("state mutation claim does not match its target");
|
|
1220
|
+
}
|
|
1221
|
+
const match =
|
|
1222
|
+
/^(waiting|candidate|held)-([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})\.lock$/u
|
|
1223
|
+
.exec(name.slice(prefix.length));
|
|
1224
|
+
if (match === null) throw new Error("state mutation claim filename is invalid");
|
|
1225
|
+
return {
|
|
1226
|
+
phase: match[1] as StateMutationClaimPhase,
|
|
1227
|
+
claimId: match[2]!,
|
|
1228
|
+
};
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
function renderStateMutationClaim(claim: StateMutationClaim): string {
|
|
1232
|
+
return `${JSON.stringify(claim)}\n`;
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
function parseStateMutationClaim(
|
|
1236
|
+
content: Buffer,
|
|
1237
|
+
targetSha256: string,
|
|
1238
|
+
claimId: string,
|
|
1239
|
+
): StateMutationClaim {
|
|
1240
|
+
let text: string;
|
|
1241
|
+
try {
|
|
1242
|
+
text = new TextDecoder("utf-8", {
|
|
1243
|
+
fatal: true,
|
|
1244
|
+
ignoreBOM: true,
|
|
1245
|
+
}).decode(content);
|
|
1246
|
+
} catch (error) {
|
|
1247
|
+
throw new Error("state mutation claim is not valid UTF-8", {
|
|
1248
|
+
cause: error,
|
|
1249
|
+
});
|
|
1250
|
+
}
|
|
1251
|
+
let value: unknown;
|
|
1252
|
+
try {
|
|
1253
|
+
value = JSON.parse(text) as unknown;
|
|
1254
|
+
} catch (error) {
|
|
1255
|
+
throw new Error("state mutation claim is not valid JSON", {
|
|
1256
|
+
cause: error,
|
|
1257
|
+
});
|
|
1258
|
+
}
|
|
1259
|
+
if (
|
|
1260
|
+
!isRecord(value)
|
|
1261
|
+
|| !exactKeys(value, [
|
|
1262
|
+
"bootId",
|
|
1263
|
+
"claimId",
|
|
1264
|
+
"kind",
|
|
1265
|
+
"pid",
|
|
1266
|
+
"processStartId",
|
|
1267
|
+
"schemaVersion",
|
|
1268
|
+
"targetSha256",
|
|
1269
|
+
])
|
|
1270
|
+
|| value.kind !== "io-state-mutation-claim"
|
|
1271
|
+
|| value.schemaVersion !== 1
|
|
1272
|
+
|| value.targetSha256 !== targetSha256
|
|
1273
|
+
|| value.claimId !== claimId
|
|
1274
|
+
|| !Number.isSafeInteger(value.pid)
|
|
1275
|
+
|| typeof value.pid !== "number"
|
|
1276
|
+
|| value.pid < 1
|
|
1277
|
+
|| typeof value.bootId !== "string"
|
|
1278
|
+
|| !/^[a-f0-9]{64}$/u.test(value.bootId)
|
|
1279
|
+
|| typeof value.processStartId !== "string"
|
|
1280
|
+
|| !/^[a-f0-9]{64}$/u.test(value.processStartId)
|
|
1281
|
+
) {
|
|
1282
|
+
throw new Error("state mutation claim is invalid");
|
|
1283
|
+
}
|
|
1284
|
+
const claim: StateMutationClaim = {
|
|
1285
|
+
kind: value.kind,
|
|
1286
|
+
schemaVersion: value.schemaVersion,
|
|
1287
|
+
targetSha256: value.targetSha256,
|
|
1288
|
+
claimId: value.claimId,
|
|
1289
|
+
pid: value.pid,
|
|
1290
|
+
bootId: value.bootId,
|
|
1291
|
+
processStartId: value.processStartId,
|
|
1292
|
+
};
|
|
1293
|
+
if (text !== renderStateMutationClaim(claim)) {
|
|
1294
|
+
throw new Error("state mutation claim is not canonical JSON");
|
|
1295
|
+
}
|
|
1296
|
+
return claim;
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
function stateMutationClaimLifecycleMovedDuringRead(
|
|
1300
|
+
name: string,
|
|
1301
|
+
before: BigIntStats,
|
|
1302
|
+
after: BigIntStats,
|
|
1303
|
+
): boolean {
|
|
1304
|
+
if (
|
|
1305
|
+
!before.isFile()
|
|
1306
|
+
|| !after.isFile()
|
|
1307
|
+
|| !ownedByCurrentUser(before)
|
|
1308
|
+
|| !ownedByCurrentUser(after)
|
|
1309
|
+
|| (before.mode & 0o077n) !== 0n
|
|
1310
|
+
|| (after.mode & 0o077n) !== 0n
|
|
1311
|
+
|| !sameIdentity(identity(before), identity(after))
|
|
1312
|
+
|| before.size !== after.size
|
|
1313
|
+
|| before.mtimeNs !== after.mtimeNs
|
|
1314
|
+
|| before.mode !== after.mode
|
|
1315
|
+
|| before.uid !== after.uid
|
|
1316
|
+
|| before.gid !== after.gid
|
|
1317
|
+
|| before.nlink !== 1n
|
|
1318
|
+
|| (after.nlink !== 1n && after.nlink !== 0n)
|
|
1319
|
+
|| before.ctimeNs === after.ctimeNs
|
|
1320
|
+
) return false;
|
|
1321
|
+
try {
|
|
1322
|
+
lstatSync(name);
|
|
1323
|
+
return false;
|
|
1324
|
+
} catch (error) {
|
|
1325
|
+
if (hasCode(error, "ENOENT")) return true;
|
|
1326
|
+
throw error;
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
function pauseAfterStateMutationClaimReadForTest(): void {
|
|
1331
|
+
if (
|
|
1332
|
+
casOverlapFaultForTest !== "pause-after-mutation-claim-read"
|
|
1333
|
+
|| pausedAfterStateMutationClaimReadForTest
|
|
1334
|
+
) return;
|
|
1335
|
+
pausedAfterStateMutationClaimReadForTest = true;
|
|
1336
|
+
const readyName = ".wrench-test-claim-read-ready";
|
|
1337
|
+
const releaseName = ".wrench-test-claim-read-release";
|
|
1338
|
+
const descriptor = openSync(
|
|
1339
|
+
readyName,
|
|
1340
|
+
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL,
|
|
1341
|
+
0o600,
|
|
1342
|
+
);
|
|
1343
|
+
try {
|
|
1344
|
+
fchmodSync(descriptor, 0o600);
|
|
1345
|
+
fsyncSync(descriptor);
|
|
1346
|
+
} finally {
|
|
1347
|
+
closeSync(descriptor);
|
|
1348
|
+
}
|
|
1349
|
+
syncDirectory(".");
|
|
1350
|
+
const deadline = Date.now() + TEST_BARRIER_TIMEOUT_MS;
|
|
1351
|
+
for (;;) {
|
|
1352
|
+
try {
|
|
1353
|
+
const release = lstatSync(releaseName);
|
|
1354
|
+
if (
|
|
1355
|
+
release.isSymbolicLink()
|
|
1356
|
+
|| !release.isFile()
|
|
1357
|
+
|| !ownedByCurrentUser(release)
|
|
1358
|
+
|| (release.mode & 0o077) !== 0
|
|
1359
|
+
) {
|
|
1360
|
+
throw new Error("state claim-read test release is not one private file");
|
|
1361
|
+
}
|
|
1362
|
+
break;
|
|
1363
|
+
} catch (error) {
|
|
1364
|
+
if (!hasCode(error, "ENOENT")) throw error;
|
|
1365
|
+
}
|
|
1366
|
+
if (Date.now() >= deadline) {
|
|
1367
|
+
throw new Error("state claim-read overlap test timed out");
|
|
1368
|
+
}
|
|
1369
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
|
|
1370
|
+
}
|
|
1371
|
+
unlinkSync(readyName);
|
|
1372
|
+
unlinkSync(releaseName);
|
|
1373
|
+
syncDirectory(".");
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
function readStateMutationClaim(
|
|
1377
|
+
name: string,
|
|
1378
|
+
targetSha256: string,
|
|
1379
|
+
claimId: string,
|
|
1380
|
+
): StateMutationClaim | StateMutationClaimSnapshotMoved | null {
|
|
1381
|
+
let descriptor: number;
|
|
1382
|
+
try {
|
|
1383
|
+
descriptor = openPrivateFileForRead(name);
|
|
1384
|
+
} catch (error) {
|
|
1385
|
+
if (hasCode(error, "ENOENT")) return null;
|
|
1386
|
+
throw error;
|
|
1387
|
+
}
|
|
1388
|
+
try {
|
|
1389
|
+
try {
|
|
1390
|
+
return parseStateMutationClaim(
|
|
1391
|
+
readStablePrivateFile(
|
|
1392
|
+
descriptor,
|
|
1393
|
+
4 * 1024,
|
|
1394
|
+
pauseAfterStateMutationClaimReadForTest,
|
|
1395
|
+
),
|
|
1396
|
+
targetSha256,
|
|
1397
|
+
claimId,
|
|
1398
|
+
);
|
|
1399
|
+
} catch (error) {
|
|
1400
|
+
if (
|
|
1401
|
+
error instanceof StateFileChangedWhileReadError
|
|
1402
|
+
&& stateMutationClaimLifecycleMovedDuringRead(
|
|
1403
|
+
name,
|
|
1404
|
+
error.before,
|
|
1405
|
+
error.after,
|
|
1406
|
+
)
|
|
1407
|
+
) return stateMutationClaimSnapshotMoved;
|
|
1408
|
+
throw error;
|
|
1409
|
+
}
|
|
1410
|
+
} finally {
|
|
1411
|
+
closeSync(descriptor);
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
function stateMutationOwnerStatus(
|
|
1416
|
+
claim: StateMutationClaim,
|
|
1417
|
+
): ReturnType<typeof processOwnerStatus> {
|
|
1418
|
+
let status = processOwnerStatus(claim);
|
|
1419
|
+
for (let attempt = 0; status === "unknown" && attempt < 3; attempt += 1) {
|
|
1420
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
|
|
1421
|
+
status = processOwnerStatus(claim);
|
|
1422
|
+
}
|
|
1423
|
+
return status;
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
function readStateMutationStageSnapshot(
|
|
1427
|
+
name: string,
|
|
1428
|
+
claimId: string,
|
|
1429
|
+
pid: number,
|
|
1430
|
+
): StateMutationStageSnapshot | null {
|
|
1431
|
+
let descriptor: number;
|
|
1432
|
+
try {
|
|
1433
|
+
descriptor = openPrivateFileForRead(name);
|
|
1434
|
+
} catch (error) {
|
|
1435
|
+
if (hasCode(error, "ENOENT")) return null;
|
|
1436
|
+
throw error;
|
|
1437
|
+
}
|
|
1438
|
+
try {
|
|
1439
|
+
const content = readStablePrivateFile(descriptor, 4 * 1024);
|
|
1440
|
+
const stats = fstatSync(descriptor, { bigint: true });
|
|
1441
|
+
let value: unknown;
|
|
1442
|
+
try {
|
|
1443
|
+
value = JSON.parse(new TextDecoder("utf-8", {
|
|
1444
|
+
fatal: true,
|
|
1445
|
+
ignoreBOM: true,
|
|
1446
|
+
}).decode(content)) as unknown;
|
|
1447
|
+
} catch {
|
|
1448
|
+
value = null;
|
|
1449
|
+
}
|
|
1450
|
+
let claim: StateMutationClaim | null = null;
|
|
1451
|
+
if (
|
|
1452
|
+
isRecord(value)
|
|
1453
|
+
&& typeof value.targetSha256 === "string"
|
|
1454
|
+
&& /^[a-f0-9]{64}$/u.test(value.targetSha256)
|
|
1455
|
+
) {
|
|
1456
|
+
try {
|
|
1457
|
+
const parsed = parseStateMutationClaim(
|
|
1458
|
+
content,
|
|
1459
|
+
value.targetSha256,
|
|
1460
|
+
claimId,
|
|
1461
|
+
);
|
|
1462
|
+
if (parsed.pid === pid) claim = parsed;
|
|
1463
|
+
} catch {
|
|
1464
|
+
// A stable malformed stage is recoverable only from a definitely
|
|
1465
|
+
// absent filename PID because it has not published ownership.
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
return {
|
|
1469
|
+
claim,
|
|
1470
|
+
content,
|
|
1471
|
+
identity: identity(stats),
|
|
1472
|
+
size: stats.size,
|
|
1473
|
+
mtimeNs: stats.mtimeNs,
|
|
1474
|
+
ctimeNs: stats.ctimeNs,
|
|
1475
|
+
mode: stats.mode,
|
|
1476
|
+
};
|
|
1477
|
+
} finally {
|
|
1478
|
+
closeSync(descriptor);
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
function sameStateMutationStageSnapshot(
|
|
1483
|
+
left: StateMutationStageSnapshot,
|
|
1484
|
+
right: StateMutationStageSnapshot,
|
|
1485
|
+
): boolean {
|
|
1486
|
+
return left.content.equals(right.content)
|
|
1487
|
+
&& sameIdentity(left.identity, right.identity)
|
|
1488
|
+
&& left.size === right.size
|
|
1489
|
+
&& left.mtimeNs === right.mtimeNs
|
|
1490
|
+
&& left.ctimeNs === right.ctimeNs
|
|
1491
|
+
&& left.mode === right.mode;
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
function stateMutationStageOwnerIsDefinitelyGone(
|
|
1495
|
+
snapshot: StateMutationStageSnapshot,
|
|
1496
|
+
pid: number,
|
|
1497
|
+
): boolean {
|
|
1498
|
+
return snapshot.claim === null
|
|
1499
|
+
? processIsDefinitelyMissing(pid)
|
|
1500
|
+
: stateMutationOwnerStatus(snapshot.claim) === "different-or-dead";
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
/**
|
|
1504
|
+
* A mutation-claim stage has not published arbitration ownership, but an
|
|
1505
|
+
* active helper still needs it for the atomic rename that publishes its
|
|
1506
|
+
* claim. A canonical stage uses its complete process identity. An empty,
|
|
1507
|
+
* partial, or malformed stage can use only its exact filename PID, so reclaim
|
|
1508
|
+
* it after two stable snapshots only when that PID is definitely absent.
|
|
1509
|
+
* Live, reused, changing, and uninspectable stages remain visible.
|
|
1510
|
+
*/
|
|
1511
|
+
function recoverDefinitelyOrphanedStateMutationStages(): void {
|
|
1512
|
+
let entriesRead = 0;
|
|
1513
|
+
let removed = false;
|
|
1514
|
+
for (const name of readdirSync(".")) {
|
|
1515
|
+
entriesRead += 1;
|
|
1516
|
+
if (entriesRead > 10_000) {
|
|
1517
|
+
throw new Error("state mutation stage recovery exceeds its entry bound");
|
|
1518
|
+
}
|
|
1519
|
+
const match = stateMutationStageNamePattern.exec(name);
|
|
1520
|
+
const claimId = match?.[1];
|
|
1521
|
+
const pidText = match?.[2];
|
|
1522
|
+
if (claimId === undefined || pidText === undefined) continue;
|
|
1523
|
+
const pid = Number(pidText);
|
|
1524
|
+
if (!Number.isSafeInteger(pid) || pid < 1 || pid > 2_147_483_647) {
|
|
1525
|
+
continue;
|
|
1526
|
+
}
|
|
1527
|
+
let snapshot: StateMutationStageSnapshot | null;
|
|
1528
|
+
try {
|
|
1529
|
+
snapshot = readStateMutationStageSnapshot(name, claimId, pid);
|
|
1530
|
+
} catch {
|
|
1531
|
+
continue;
|
|
1532
|
+
}
|
|
1533
|
+
if (
|
|
1534
|
+
snapshot === null
|
|
1535
|
+
|| !stateMutationStageOwnerIsDefinitelyGone(snapshot, pid)
|
|
1536
|
+
) continue;
|
|
1537
|
+
let revalidated: StateMutationStageSnapshot | null;
|
|
1538
|
+
try {
|
|
1539
|
+
revalidated = readStateMutationStageSnapshot(name, claimId, pid);
|
|
1540
|
+
} catch {
|
|
1541
|
+
continue;
|
|
1542
|
+
}
|
|
1543
|
+
if (
|
|
1544
|
+
revalidated === null
|
|
1545
|
+
|| !sameStateMutationStageSnapshot(snapshot, revalidated)
|
|
1546
|
+
|| !stateMutationStageOwnerIsDefinitelyGone(revalidated, pid)
|
|
1547
|
+
) continue;
|
|
1548
|
+
let current: BigIntStats;
|
|
1549
|
+
try {
|
|
1550
|
+
current = lstatSync(name, { bigint: true });
|
|
1551
|
+
} catch (error) {
|
|
1552
|
+
if (hasCode(error, "ENOENT")) continue;
|
|
1553
|
+
throw error;
|
|
1554
|
+
}
|
|
1555
|
+
if (
|
|
1556
|
+
current.isSymbolicLink()
|
|
1557
|
+
|| !current.isFile()
|
|
1558
|
+
|| !sameIdentity(revalidated.identity, identity(current))
|
|
1559
|
+
|| revalidated.size !== current.size
|
|
1560
|
+
|| revalidated.mtimeNs !== current.mtimeNs
|
|
1561
|
+
|| revalidated.ctimeNs !== current.ctimeNs
|
|
1562
|
+
|| revalidated.mode !== current.mode
|
|
1563
|
+
) continue;
|
|
1564
|
+
try {
|
|
1565
|
+
unlinkSync(name);
|
|
1566
|
+
removed = true;
|
|
1567
|
+
} catch (error) {
|
|
1568
|
+
if (!hasCode(error, "ENOENT")) throw error;
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
if (removed) syncDirectory(".");
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
function listLiveStateMutationClaims(
|
|
1575
|
+
targetSha256: string,
|
|
1576
|
+
): readonly {
|
|
1577
|
+
readonly name: string;
|
|
1578
|
+
readonly phase: StateMutationClaimPhase;
|
|
1579
|
+
readonly claim: StateMutationClaim;
|
|
1580
|
+
}[] {
|
|
1581
|
+
const prefix = stateMutationClaimPrefix(targetSha256);
|
|
1582
|
+
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
1583
|
+
const names = readdirSync(".")
|
|
1584
|
+
.filter((name) => name.startsWith(prefix))
|
|
1585
|
+
.sort();
|
|
1586
|
+
if (names.length > 1_024) {
|
|
1587
|
+
throw new Error("state mutation claim count exceeds its bound");
|
|
1588
|
+
}
|
|
1589
|
+
let retry = false;
|
|
1590
|
+
let removedStaleClaim = false;
|
|
1591
|
+
const live: {
|
|
1592
|
+
readonly name: string;
|
|
1593
|
+
readonly phase: StateMutationClaimPhase;
|
|
1594
|
+
readonly claim: StateMutationClaim;
|
|
1595
|
+
}[] = [];
|
|
1596
|
+
for (const name of names) {
|
|
1597
|
+
const parsedName = parseStateMutationClaimName(name, targetSha256);
|
|
1598
|
+
const claim = readStateMutationClaim(
|
|
1599
|
+
name,
|
|
1600
|
+
targetSha256,
|
|
1601
|
+
parsedName.claimId,
|
|
1602
|
+
);
|
|
1603
|
+
if (claim === null) {
|
|
1604
|
+
retry = true;
|
|
1605
|
+
break;
|
|
1606
|
+
}
|
|
1607
|
+
if (claim === stateMutationClaimSnapshotMoved) {
|
|
1608
|
+
retry = true;
|
|
1609
|
+
break;
|
|
1610
|
+
}
|
|
1611
|
+
const status = stateMutationOwnerStatus(claim);
|
|
1612
|
+
if (status === "unknown") {
|
|
1613
|
+
throw new Error(
|
|
1614
|
+
"state mutation claim owner cannot be inspected safely",
|
|
1615
|
+
);
|
|
1616
|
+
}
|
|
1617
|
+
if (status === "different-or-dead") {
|
|
1618
|
+
try {
|
|
1619
|
+
unlinkSync(name);
|
|
1620
|
+
removedStaleClaim = true;
|
|
1621
|
+
} catch (error) {
|
|
1622
|
+
if (!hasCode(error, "ENOENT")) throw error;
|
|
1623
|
+
retry = true;
|
|
1624
|
+
break;
|
|
1625
|
+
}
|
|
1626
|
+
continue;
|
|
1627
|
+
}
|
|
1628
|
+
live.push({ name, phase: parsedName.phase, claim });
|
|
1629
|
+
}
|
|
1630
|
+
if (removedStaleClaim) syncDirectory(".");
|
|
1631
|
+
if (retry || removedStaleClaim) continue;
|
|
1632
|
+
return live;
|
|
1633
|
+
}
|
|
1634
|
+
throw new Error("state mutation claims did not reach a stable snapshot");
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
/**
|
|
1638
|
+
* Directory readers can otherwise strand behind a mutation claim left by a
|
|
1639
|
+
* helper that died after publishing its claim. Inspect only exact claim
|
|
1640
|
+
* filenames, then reuse the target-scoped arbitration logic so a claim is
|
|
1641
|
+
* removed only when its complete process identity is definitely dead. Live,
|
|
1642
|
+
* malformed, and uninspectable state remains visible or fails closed.
|
|
1643
|
+
*/
|
|
1644
|
+
function recoverDefinitelyOrphanedStateMutationClaims(): void {
|
|
1645
|
+
const targets = new Set<string>();
|
|
1646
|
+
for (const name of readdirSync(".")) {
|
|
1647
|
+
const match =
|
|
1648
|
+
/^\.io-mutation-([a-f0-9]{64})-(?:waiting|candidate|held)-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.lock$/u
|
|
1649
|
+
.exec(name);
|
|
1650
|
+
const targetSha256 = match?.[1];
|
|
1651
|
+
if (targetSha256 === undefined) continue;
|
|
1652
|
+
targets.add(targetSha256);
|
|
1653
|
+
if (targets.size > 10_000) {
|
|
1654
|
+
throw new Error("state mutation recovery exceeds its target bound");
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
for (const targetSha256 of [...targets].sort()) {
|
|
1658
|
+
listLiveStateMutationClaims(targetSha256);
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
function writeStateMutationClaim(
|
|
1663
|
+
name: string,
|
|
1664
|
+
claim: StateMutationClaim,
|
|
1665
|
+
): void {
|
|
1666
|
+
const temporary =
|
|
1667
|
+
`.io-mutation-stage-${claim.claimId}-${process.pid}.tmp`;
|
|
1668
|
+
let descriptor: number | null = null;
|
|
1669
|
+
let temporaryExists = false;
|
|
1670
|
+
try {
|
|
1671
|
+
descriptor = openSync(
|
|
1672
|
+
temporary,
|
|
1673
|
+
constants.O_WRONLY
|
|
1674
|
+
| constants.O_CREAT
|
|
1675
|
+
| constants.O_EXCL
|
|
1676
|
+
| ("O_NOFOLLOW" in constants ? constants.O_NOFOLLOW : 0),
|
|
1677
|
+
0o600,
|
|
1678
|
+
);
|
|
1679
|
+
temporaryExists = true;
|
|
1680
|
+
writeFileSync(descriptor, renderStateMutationClaim(claim), "utf8");
|
|
1681
|
+
fchmodSync(descriptor, 0o600);
|
|
1682
|
+
fsyncSync(descriptor);
|
|
1683
|
+
closeSync(descriptor);
|
|
1684
|
+
descriptor = null;
|
|
1685
|
+
assertDirectoryAbsent(name);
|
|
1686
|
+
renameSync(temporary, name);
|
|
1687
|
+
temporaryExists = false;
|
|
1688
|
+
syncDirectory(".");
|
|
1689
|
+
} finally {
|
|
1690
|
+
if (descriptor !== null) closeSync(descriptor);
|
|
1691
|
+
if (temporaryExists) {
|
|
1692
|
+
try {
|
|
1693
|
+
unlinkSync(temporary);
|
|
1694
|
+
syncDirectory(".");
|
|
1695
|
+
} catch {
|
|
1696
|
+
// The unique unpublished claim stage conveys no mutation ownership.
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
function pauseAfterStateMutationClaimForTest(): void {
|
|
1703
|
+
if (casOverlapFaultForTest !== "pause-after-cas-claim") return;
|
|
1704
|
+
const readyName = ".wrench-test-cas-ready";
|
|
1705
|
+
const releaseName = ".wrench-test-cas-release";
|
|
1706
|
+
const descriptor = openSync(
|
|
1707
|
+
readyName,
|
|
1708
|
+
constants.O_WRONLY
|
|
1709
|
+
| constants.O_CREAT
|
|
1710
|
+
| constants.O_EXCL
|
|
1711
|
+
| ("O_NOFOLLOW" in constants ? constants.O_NOFOLLOW : 0),
|
|
1712
|
+
0o600,
|
|
1713
|
+
);
|
|
1714
|
+
try {
|
|
1715
|
+
writeFileSync(descriptor, "ready\n", "utf8");
|
|
1716
|
+
fchmodSync(descriptor, 0o600);
|
|
1717
|
+
fsyncSync(descriptor);
|
|
1718
|
+
} finally {
|
|
1719
|
+
closeSync(descriptor);
|
|
1720
|
+
}
|
|
1721
|
+
syncDirectory(".");
|
|
1722
|
+
const deadline = Date.now() + TEST_BARRIER_TIMEOUT_MS;
|
|
1723
|
+
for (;;) {
|
|
1724
|
+
try {
|
|
1725
|
+
const release = lstatSync(releaseName);
|
|
1726
|
+
if (
|
|
1727
|
+
release.isSymbolicLink()
|
|
1728
|
+
|| !release.isFile()
|
|
1729
|
+
|| !ownedByCurrentUser(release)
|
|
1730
|
+
|| (release.mode & 0o077) !== 0
|
|
1731
|
+
) {
|
|
1732
|
+
throw new Error("state CAS test release is not one private file");
|
|
1733
|
+
}
|
|
1734
|
+
break;
|
|
1735
|
+
} catch (error) {
|
|
1736
|
+
if (!hasCode(error, "ENOENT")) throw error;
|
|
1737
|
+
}
|
|
1738
|
+
if (Date.now() >= deadline) {
|
|
1739
|
+
throw new Error("state CAS overlap test timed out");
|
|
1740
|
+
}
|
|
1741
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
|
|
1742
|
+
}
|
|
1743
|
+
unlinkSync(readyName);
|
|
1744
|
+
unlinkSync(releaseName);
|
|
1745
|
+
syncDirectory(".");
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
function currentStateMutationProcessIdentity(): ReturnType<
|
|
1749
|
+
typeof currentProcessStartIdentity
|
|
1750
|
+
> {
|
|
1751
|
+
let failure: unknown;
|
|
1752
|
+
for (let attempt = 0; attempt < 4; attempt += 1) {
|
|
1753
|
+
try {
|
|
1754
|
+
return currentProcessStartIdentity();
|
|
1755
|
+
} catch (error) {
|
|
1756
|
+
failure = error;
|
|
1757
|
+
if (attempt < 3) {
|
|
1758
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
throw new Error("state mutation process identity is unavailable", {
|
|
1763
|
+
cause: failure,
|
|
1764
|
+
});
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
function acquireStateMutationClaim(
|
|
1768
|
+
targetName: string,
|
|
1769
|
+
requestId: string,
|
|
1770
|
+
): (() => void) | null {
|
|
1771
|
+
recoverDefinitelyOrphanedStateMutationStages();
|
|
1772
|
+
const targetSha256 = stateMutationTargetSha256(targetName);
|
|
1773
|
+
const processIdentity = currentStateMutationProcessIdentity();
|
|
1774
|
+
const claim: StateMutationClaim = {
|
|
1775
|
+
kind: "io-state-mutation-claim",
|
|
1776
|
+
schemaVersion: 1,
|
|
1777
|
+
targetSha256,
|
|
1778
|
+
claimId: requestId,
|
|
1779
|
+
pid: process.pid,
|
|
1780
|
+
...processIdentity,
|
|
1781
|
+
};
|
|
1782
|
+
let claimName = stateMutationClaimName(
|
|
1783
|
+
targetSha256,
|
|
1784
|
+
"waiting",
|
|
1785
|
+
requestId,
|
|
1786
|
+
);
|
|
1787
|
+
writeStateMutationClaim(claimName, claim);
|
|
1788
|
+
const release = (): void => {
|
|
1789
|
+
try {
|
|
1790
|
+
unlinkSync(claimName);
|
|
1791
|
+
syncDirectory(".");
|
|
1792
|
+
} catch (error) {
|
|
1793
|
+
if (!hasCode(error, "ENOENT")) throw error;
|
|
1794
|
+
}
|
|
1795
|
+
};
|
|
1796
|
+
try {
|
|
1797
|
+
const waitingClaims = listLiveStateMutationClaims(targetSha256);
|
|
1798
|
+
if (
|
|
1799
|
+
waitingClaims.some((candidate) =>
|
|
1800
|
+
candidate.claim.claimId !== requestId
|
|
1801
|
+
&& (
|
|
1802
|
+
candidate.phase !== "waiting"
|
|
1803
|
+
|| candidate.claim.claimId < requestId
|
|
1804
|
+
)
|
|
1805
|
+
)
|
|
1806
|
+
) {
|
|
1807
|
+
release();
|
|
1808
|
+
return null;
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
const candidateName = stateMutationClaimName(
|
|
1812
|
+
targetSha256,
|
|
1813
|
+
"candidate",
|
|
1814
|
+
requestId,
|
|
1815
|
+
);
|
|
1816
|
+
renameSync(claimName, candidateName);
|
|
1817
|
+
claimName = candidateName;
|
|
1818
|
+
syncDirectory(".");
|
|
1819
|
+
const candidateClaims = listLiveStateMutationClaims(targetSha256);
|
|
1820
|
+
if (
|
|
1821
|
+
candidateClaims.some((candidate) =>
|
|
1822
|
+
candidate.claim.claimId !== requestId
|
|
1823
|
+
&& (
|
|
1824
|
+
candidate.phase === "held"
|
|
1825
|
+
|| candidate.claim.claimId < requestId
|
|
1826
|
+
)
|
|
1827
|
+
)
|
|
1828
|
+
) {
|
|
1829
|
+
release();
|
|
1830
|
+
return null;
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
const heldName = stateMutationClaimName(
|
|
1834
|
+
targetSha256,
|
|
1835
|
+
"held",
|
|
1836
|
+
requestId,
|
|
1837
|
+
);
|
|
1838
|
+
renameSync(claimName, heldName);
|
|
1839
|
+
claimName = heldName;
|
|
1840
|
+
syncDirectory(".");
|
|
1841
|
+
const heldClaims = listLiveStateMutationClaims(targetSha256);
|
|
1842
|
+
if (
|
|
1843
|
+
heldClaims.some((candidate) =>
|
|
1844
|
+
candidate.claim.claimId !== requestId
|
|
1845
|
+
&& candidate.phase === "held"
|
|
1846
|
+
)
|
|
1847
|
+
) {
|
|
1848
|
+
throw new Error("state mutation arbitration admitted two owners");
|
|
1849
|
+
}
|
|
1850
|
+
pauseAfterStateMutationClaimForTest();
|
|
1851
|
+
return release;
|
|
1852
|
+
} catch (error) {
|
|
1853
|
+
release();
|
|
1854
|
+
throw error;
|
|
1855
|
+
}
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
function openPrivateFileForRead(name: string): number {
|
|
1859
|
+
return openSync(
|
|
1860
|
+
name,
|
|
1861
|
+
constants.O_RDONLY
|
|
1862
|
+
| ("O_NOFOLLOW" in constants ? constants.O_NOFOLLOW : 0)
|
|
1863
|
+
| ("O_NONBLOCK" in constants ? constants.O_NONBLOCK : 0),
|
|
1864
|
+
);
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
function readFile(
|
|
1868
|
+
expected: Identity,
|
|
1869
|
+
segments: readonly string[],
|
|
1870
|
+
directoryExpectations: readonly DirectoryExpectation[],
|
|
1871
|
+
maximumBytes: number,
|
|
1872
|
+
): Response {
|
|
1873
|
+
const actual = assertClaimedRoot(expected);
|
|
1874
|
+
const name = fileName(segments);
|
|
1875
|
+
if (!traverseDirectories(segments.slice(0, -1), directoryExpectations, false)) {
|
|
1876
|
+
throw new Error("state file parent directory is absent");
|
|
1877
|
+
}
|
|
1878
|
+
const descriptor = openPrivateFileForRead(name);
|
|
1879
|
+
try {
|
|
1880
|
+
const content = readStablePrivateFile(descriptor, maximumBytes);
|
|
1881
|
+
return { ok: true, identity: actual, contentBase64: content.toString("base64") };
|
|
1882
|
+
} finally {
|
|
1883
|
+
closeSync(descriptor);
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
function readFileIfPresent(
|
|
1888
|
+
expected: Identity,
|
|
1889
|
+
segments: readonly string[],
|
|
1890
|
+
directoryExpectations: readonly DirectoryExpectation[],
|
|
1891
|
+
maximumBytes: number,
|
|
1892
|
+
): Response {
|
|
1893
|
+
const actual = assertClaimedRoot(expected);
|
|
1894
|
+
const name = fileName(segments);
|
|
1895
|
+
if (!traverseDirectories(segments.slice(0, -1), directoryExpectations, false)) {
|
|
1896
|
+
return { ok: true, identity: actual, present: false };
|
|
1897
|
+
}
|
|
1898
|
+
let descriptor: number;
|
|
1899
|
+
try {
|
|
1900
|
+
descriptor = openPrivateFileForRead(name);
|
|
1901
|
+
} catch (error) {
|
|
1902
|
+
if (hasCode(error, "ENOENT")) return { ok: true, identity: actual, present: false };
|
|
1903
|
+
throw error;
|
|
1904
|
+
}
|
|
1905
|
+
try {
|
|
1906
|
+
const content = readStablePrivateFile(descriptor, maximumBytes);
|
|
1907
|
+
return {
|
|
1908
|
+
ok: true,
|
|
1909
|
+
identity: actual,
|
|
1910
|
+
present: true,
|
|
1911
|
+
contentBase64: content.toString("base64"),
|
|
1912
|
+
};
|
|
1913
|
+
} finally {
|
|
1914
|
+
closeSync(descriptor);
|
|
1915
|
+
}
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
function releaseStateMutationAfterWrite(
|
|
1919
|
+
release: () => void,
|
|
1920
|
+
mutationDurable: boolean,
|
|
1921
|
+
): void {
|
|
1922
|
+
try {
|
|
1923
|
+
if (
|
|
1924
|
+
mutationDurable
|
|
1925
|
+
&& casOverlapFaultForTest === "fail-after-cas-commit"
|
|
1926
|
+
) {
|
|
1927
|
+
throw new Error("state CAS test fault while releasing a durable claim");
|
|
1928
|
+
}
|
|
1929
|
+
release();
|
|
1930
|
+
} catch (error) {
|
|
1931
|
+
if (!mutationDurable) throw error;
|
|
1932
|
+
// The target rename and parent-directory sync are authoritative. A dead
|
|
1933
|
+
// helper's exact claim is recoverable by the next arbitration operation.
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
function writeFile(
|
|
1938
|
+
expected: Identity,
|
|
1939
|
+
segments: readonly string[],
|
|
1940
|
+
directoryExpectations: readonly DirectoryExpectation[],
|
|
1941
|
+
content: string,
|
|
1942
|
+
createOnly: boolean,
|
|
1943
|
+
expectedContentSha256: string | null,
|
|
1944
|
+
maximumExpectedContentBytes: number,
|
|
1945
|
+
requestId: string,
|
|
1946
|
+
): Response {
|
|
1947
|
+
const actual = assertClaimedRoot(expected);
|
|
1948
|
+
const name = fileName(segments);
|
|
1949
|
+
traverseDirectories(segments.slice(0, -1), directoryExpectations, true);
|
|
1950
|
+
recoverDefinitelyOrphanedWriteTemporaries();
|
|
1951
|
+
const temporary = `.io-write-${process.pid}-${crypto.randomUUID()}.tmp`;
|
|
1952
|
+
let descriptor: number | null = null;
|
|
1953
|
+
let temporaryExists = false;
|
|
1954
|
+
let releaseMutation: (() => void) | null = null;
|
|
1955
|
+
let mutationDurable = false;
|
|
1956
|
+
try {
|
|
1957
|
+
descriptor = openSync(
|
|
1958
|
+
temporary,
|
|
1959
|
+
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | ("O_NOFOLLOW" in constants ? constants.O_NOFOLLOW : 0),
|
|
1960
|
+
0o600,
|
|
1961
|
+
);
|
|
1962
|
+
temporaryExists = true;
|
|
1963
|
+
writeFileSync(descriptor, content, "utf8");
|
|
1964
|
+
fchmodSync(descriptor, 0o600);
|
|
1965
|
+
fsyncSync(descriptor);
|
|
1966
|
+
closeSync(descriptor);
|
|
1967
|
+
descriptor = null;
|
|
1968
|
+
pauseAfterWriteTemporaryForTest();
|
|
1969
|
+
releaseMutation = acquireStateMutationClaim(name, requestId);
|
|
1970
|
+
if (releaseMutation === null) {
|
|
1971
|
+
if (expectedContentSha256 !== null) {
|
|
1972
|
+
throw new Error(
|
|
1973
|
+
"state file content no longer matches the expected hash",
|
|
1974
|
+
);
|
|
1975
|
+
}
|
|
1976
|
+
throw new Error("state file mutation is already active");
|
|
1977
|
+
}
|
|
1978
|
+
if (createOnly) {
|
|
1979
|
+
try {
|
|
1980
|
+
linkSync(temporary, name);
|
|
1981
|
+
} catch (error) {
|
|
1982
|
+
if (!hasCode(error, "EEXIST")) throw error;
|
|
1983
|
+
unlinkSync(temporary);
|
|
1984
|
+
temporaryExists = false;
|
|
1985
|
+
syncDirectory(".");
|
|
1986
|
+
return { ok: true, identity: actual, created: false };
|
|
1987
|
+
}
|
|
1988
|
+
unlinkSync(temporary);
|
|
1989
|
+
temporaryExists = false;
|
|
1990
|
+
} else {
|
|
1991
|
+
if (expectedContentSha256 !== null) {
|
|
1992
|
+
let current: number;
|
|
1993
|
+
try {
|
|
1994
|
+
current = openPrivateFileForRead(name);
|
|
1995
|
+
} catch (error) {
|
|
1996
|
+
if (hasCode(error, "ENOENT")) {
|
|
1997
|
+
throw new Error("state file content no longer matches the expected hash");
|
|
1998
|
+
}
|
|
1999
|
+
throw error;
|
|
2000
|
+
}
|
|
2001
|
+
try {
|
|
2002
|
+
const currentContent = readStablePrivateFile(
|
|
2003
|
+
current,
|
|
2004
|
+
maximumExpectedContentBytes,
|
|
2005
|
+
);
|
|
2006
|
+
const currentSha256 = createHash("sha256").update(currentContent).digest("hex");
|
|
2007
|
+
if (currentSha256 !== expectedContentSha256) {
|
|
2008
|
+
throw new Error("state file content no longer matches the expected hash");
|
|
2009
|
+
}
|
|
2010
|
+
} finally {
|
|
2011
|
+
closeSync(current);
|
|
2012
|
+
}
|
|
2013
|
+
}
|
|
2014
|
+
renameSync(temporary, name);
|
|
2015
|
+
temporaryExists = false;
|
|
2016
|
+
}
|
|
2017
|
+
syncDirectory(".");
|
|
2018
|
+
mutationDurable = true;
|
|
2019
|
+
return { ok: true, identity: actual, created: true };
|
|
2020
|
+
} finally {
|
|
2021
|
+
if (releaseMutation !== null) {
|
|
2022
|
+
releaseStateMutationAfterWrite(releaseMutation, mutationDurable);
|
|
2023
|
+
}
|
|
2024
|
+
if (descriptor !== null) closeSync(descriptor);
|
|
2025
|
+
if (temporaryExists) {
|
|
2026
|
+
try {
|
|
2027
|
+
unlinkSync(temporary);
|
|
2028
|
+
syncDirectory(".");
|
|
2029
|
+
} catch {
|
|
2030
|
+
// The private state remains bound to this helper's current directory.
|
|
2031
|
+
}
|
|
2032
|
+
}
|
|
2033
|
+
}
|
|
2034
|
+
}
|
|
2035
|
+
|
|
2036
|
+
function removeFile(
|
|
2037
|
+
expected: Identity,
|
|
2038
|
+
segments: readonly string[],
|
|
2039
|
+
directoryExpectations: readonly DirectoryExpectation[],
|
|
2040
|
+
requestId: string,
|
|
2041
|
+
): Response {
|
|
2042
|
+
const actual = assertClaimedRoot(expected);
|
|
2043
|
+
const name = fileName(segments);
|
|
2044
|
+
if (!traverseDirectories(segments.slice(0, -1), directoryExpectations, false)) {
|
|
2045
|
+
return { ok: true, identity: actual, removed: false };
|
|
2046
|
+
}
|
|
2047
|
+
const releaseMutation = acquireStateMutationClaim(name, requestId);
|
|
2048
|
+
if (releaseMutation === null) {
|
|
2049
|
+
throw new Error("state file mutation is already active");
|
|
2050
|
+
}
|
|
2051
|
+
try {
|
|
2052
|
+
let stats;
|
|
2053
|
+
try {
|
|
2054
|
+
stats = lstatSync(name);
|
|
2055
|
+
} catch (error) {
|
|
2056
|
+
if (hasCode(error, "ENOENT")) return { ok: true, identity: actual, removed: false };
|
|
2057
|
+
throw error;
|
|
2058
|
+
}
|
|
2059
|
+
if (!stats.isFile() || stats.isSymbolicLink() || !ownedByCurrentUser(stats) || (stats.mode & 0o077) !== 0) {
|
|
2060
|
+
throw new Error("state removal target is not a private owned regular file");
|
|
2061
|
+
}
|
|
2062
|
+
unlinkSync(name);
|
|
2063
|
+
syncDirectory(".");
|
|
2064
|
+
return { ok: true, identity: actual, removed: true };
|
|
2065
|
+
} finally {
|
|
2066
|
+
releaseMutation();
|
|
2067
|
+
}
|
|
2068
|
+
}
|
|
2069
|
+
|
|
2070
|
+
function removeFileIfUnchanged(
|
|
2071
|
+
expected: Identity,
|
|
2072
|
+
segments: readonly string[],
|
|
2073
|
+
directoryExpectations: readonly DirectoryExpectation[],
|
|
2074
|
+
expectedContentSha256: string,
|
|
2075
|
+
requestId: string,
|
|
2076
|
+
): Response {
|
|
2077
|
+
const actual = assertClaimedRoot(expected);
|
|
2078
|
+
const name = fileName(segments);
|
|
2079
|
+
if (!traverseDirectories(segments.slice(0, -1), directoryExpectations, false)) {
|
|
2080
|
+
return { ok: true, identity: actual, removed: false };
|
|
2081
|
+
}
|
|
2082
|
+
const releaseMutation = acquireStateMutationClaim(name, requestId);
|
|
2083
|
+
if (releaseMutation === null) {
|
|
2084
|
+
return { ok: true, identity: actual, removed: false };
|
|
2085
|
+
}
|
|
2086
|
+
try {
|
|
2087
|
+
let descriptor: number;
|
|
2088
|
+
try {
|
|
2089
|
+
descriptor = openPrivateFileForRead(name);
|
|
2090
|
+
} catch (error) {
|
|
2091
|
+
if (hasCode(error, "ENOENT")) {
|
|
2092
|
+
return { ok: true, identity: actual, removed: false };
|
|
2093
|
+
}
|
|
2094
|
+
throw error;
|
|
2095
|
+
}
|
|
2096
|
+
try {
|
|
2097
|
+
const content = readStablePrivateFile(descriptor, 2 * 1024 * 1024);
|
|
2098
|
+
if (
|
|
2099
|
+
createHash("sha256").update(content).digest("hex")
|
|
2100
|
+
!== expectedContentSha256
|
|
2101
|
+
) {
|
|
2102
|
+
return { ok: true, identity: actual, removed: false };
|
|
2103
|
+
}
|
|
2104
|
+
const descriptorStats = fstatSync(descriptor, { bigint: true });
|
|
2105
|
+
let current: BigIntStats;
|
|
2106
|
+
try {
|
|
2107
|
+
current = lstatSync(name, { bigint: true });
|
|
2108
|
+
} catch (error) {
|
|
2109
|
+
if (hasCode(error, "ENOENT")) {
|
|
2110
|
+
return { ok: true, identity: actual, removed: false };
|
|
2111
|
+
}
|
|
2112
|
+
throw error;
|
|
2113
|
+
}
|
|
2114
|
+
if (
|
|
2115
|
+
current.isSymbolicLink()
|
|
2116
|
+
|| !current.isFile()
|
|
2117
|
+
|| !sameIdentity(identity(descriptorStats), identity(current))
|
|
2118
|
+
|| descriptorStats.size !== current.size
|
|
2119
|
+
|| descriptorStats.mtimeNs !== current.mtimeNs
|
|
2120
|
+
|| descriptorStats.ctimeNs !== current.ctimeNs
|
|
2121
|
+
|| descriptorStats.mode !== current.mode
|
|
2122
|
+
) {
|
|
2123
|
+
return { ok: true, identity: actual, removed: false };
|
|
2124
|
+
}
|
|
2125
|
+
|
|
2126
|
+
const quarantine = `.io-remove-file-${requestId}.quarantine`;
|
|
2127
|
+
assertDirectoryAbsent(quarantine);
|
|
2128
|
+
try {
|
|
2129
|
+
renameSync(name, quarantine);
|
|
2130
|
+
} catch (error) {
|
|
2131
|
+
if (hasCode(error, "ENOENT")) {
|
|
2132
|
+
return { ok: true, identity: actual, removed: false };
|
|
2133
|
+
}
|
|
2134
|
+
throw error;
|
|
2135
|
+
}
|
|
2136
|
+
const quarantined = lstatSync(quarantine, { bigint: true });
|
|
2137
|
+
if (
|
|
2138
|
+
quarantined.isSymbolicLink()
|
|
2139
|
+
|| !quarantined.isFile()
|
|
2140
|
+
|| !sameIdentity(identity(descriptorStats), identity(quarantined))
|
|
2141
|
+
|| descriptorStats.size !== quarantined.size
|
|
2142
|
+
|| descriptorStats.mtimeNs !== quarantined.mtimeNs
|
|
2143
|
+
|| descriptorStats.mode !== quarantined.mode
|
|
2144
|
+
|| !ownedByCurrentUser(quarantined)
|
|
2145
|
+
|| (quarantined.mode & 0o077n) !== 0n
|
|
2146
|
+
) {
|
|
2147
|
+
throw new Error("conditional state-file removal lost its inode claim");
|
|
2148
|
+
}
|
|
2149
|
+
syncDirectory(".");
|
|
2150
|
+
unlinkSync(quarantine);
|
|
2151
|
+
syncDirectory(".");
|
|
2152
|
+
return { ok: true, identity: actual, removed: true };
|
|
2153
|
+
} finally {
|
|
2154
|
+
closeSync(descriptor);
|
|
2155
|
+
}
|
|
2156
|
+
} finally {
|
|
2157
|
+
releaseMutation();
|
|
2158
|
+
}
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
function createDirectory(
|
|
2162
|
+
expected: Identity,
|
|
2163
|
+
segments: readonly string[],
|
|
2164
|
+
directoryExpectations: readonly DirectoryExpectation[],
|
|
2165
|
+
): Response {
|
|
2166
|
+
const actual = assertClaimedRoot(expected);
|
|
2167
|
+
assertStateDirectoryPath(segments);
|
|
2168
|
+
if (segments.length < 2) throw new Error("refusing to create a top-level state directory exclusively");
|
|
2169
|
+
const targetExpectation = directoryExpectations.at(-1);
|
|
2170
|
+
if (targetExpectation !== null) throw new Error("exclusive state directory creation requires an absent target");
|
|
2171
|
+
if (!traverseDirectories(segments.slice(0, -1), directoryExpectations.slice(0, -1), false)) {
|
|
2172
|
+
throw new Error("state directory parent is absent");
|
|
2173
|
+
}
|
|
2174
|
+
const name = segments.at(-1);
|
|
2175
|
+
if (name === undefined) throw new Error("state directory creation target is missing");
|
|
2176
|
+
const targetIdentity = createAndBindDirectory(name);
|
|
2177
|
+
return { ok: true, identity: actual, created: true, targetIdentity };
|
|
2178
|
+
}
|
|
2179
|
+
|
|
2180
|
+
function removeEmptyDirectory(
|
|
2181
|
+
expected: Identity,
|
|
2182
|
+
segments: readonly string[],
|
|
2183
|
+
directoryExpectations: readonly DirectoryExpectation[],
|
|
2184
|
+
requestId: string,
|
|
2185
|
+
): Response {
|
|
2186
|
+
const actual = assertClaimedRoot(expected);
|
|
2187
|
+
assertStateDirectoryPath(segments);
|
|
2188
|
+
if (!traverseDirectories(segments, directoryExpectations, false)) {
|
|
2189
|
+
return { ok: true, identity: actual, removed: false };
|
|
2190
|
+
}
|
|
2191
|
+
const targetExpected = directoryExpectations.at(-1);
|
|
2192
|
+
if (targetExpected === undefined || targetExpected === null) {
|
|
2193
|
+
throw new Error("directory removal target expectation is missing");
|
|
2194
|
+
}
|
|
2195
|
+
const parentExpected = directoryExpectations.length === 1
|
|
2196
|
+
? expected
|
|
2197
|
+
: directoryExpectations.at(-2);
|
|
2198
|
+
if (parentExpected === undefined || parentExpected === null) {
|
|
2199
|
+
throw new Error("directory removal parent expectation is missing");
|
|
2200
|
+
}
|
|
2201
|
+
const name = segments.at(-1);
|
|
2202
|
+
if (name === undefined) throw new Error("directory removal target is missing");
|
|
2203
|
+
|
|
2204
|
+
process.chdir("..");
|
|
2205
|
+
assertCurrentDirectory(parentExpected);
|
|
2206
|
+
const stats = lstatSync(name, { bigint: true });
|
|
2207
|
+
assertPrivateDirectory(stats);
|
|
2208
|
+
if (!sameIdentity(identity(stats), targetExpected)) {
|
|
2209
|
+
throw new Error("directory removal target changed identity after it was bound");
|
|
2210
|
+
}
|
|
2211
|
+
|
|
2212
|
+
if (emptyDirectoryRemovalRaceForTest === "replace-target-after-validation") {
|
|
2213
|
+
const preserved = `.wrench-test-preserved-${requestId}`;
|
|
2214
|
+
assertDirectoryAbsent(preserved);
|
|
2215
|
+
renameSync(name, preserved);
|
|
2216
|
+
mkdirSync(name, { mode: 0o700 });
|
|
2217
|
+
}
|
|
2218
|
+
|
|
2219
|
+
const quarantine = `.io-remove-${process.pid}-${Date.now()}-${crypto.randomUUID().replaceAll("-", "")}.quarantine`;
|
|
2220
|
+
assertDirectoryAbsent(quarantine);
|
|
2221
|
+
renameSync(name, quarantine);
|
|
2222
|
+
syncDirectory(".");
|
|
2223
|
+
|
|
2224
|
+
if (emptyDirectoryRemovalRaceForTest === "insert-after-quarantine") {
|
|
2225
|
+
writeFileSync(`${quarantine}/arrived-late`, "keep", { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
2226
|
+
}
|
|
2227
|
+
|
|
2228
|
+
const quarantinedStats = lstatSync(quarantine, { bigint: true });
|
|
2229
|
+
assertPrivateDirectory(quarantinedStats);
|
|
2230
|
+
if (!sameIdentity(identity(quarantinedStats), targetExpected)) {
|
|
2231
|
+
throw new Error("directory removal quarantine has the wrong identity");
|
|
2232
|
+
}
|
|
2233
|
+
|
|
2234
|
+
let removalError: Error | null = null;
|
|
2235
|
+
let remainedNonempty = false;
|
|
2236
|
+
try {
|
|
2237
|
+
rmdirSync(quarantine);
|
|
2238
|
+
} catch (error) {
|
|
2239
|
+
remainedNonempty = hasCode(error, "ENOTEMPTY") || hasCode(error, "EEXIST");
|
|
2240
|
+
if (!remainedNonempty) {
|
|
2241
|
+
removalError = error instanceof Error
|
|
2242
|
+
? error
|
|
2243
|
+
: new Error("empty-directory removal failed with a non-error value", { cause: error });
|
|
2244
|
+
}
|
|
2245
|
+
}
|
|
2246
|
+
let syncError: Error | null = null;
|
|
2247
|
+
try {
|
|
2248
|
+
syncDirectory(".");
|
|
2249
|
+
} catch (error) {
|
|
2250
|
+
syncError = error instanceof Error
|
|
2251
|
+
? error
|
|
2252
|
+
: new Error("empty-directory parent sync failed with a non-error value", { cause: error });
|
|
2253
|
+
}
|
|
2254
|
+
if (removalError !== null && syncError !== null) {
|
|
2255
|
+
throw new AggregateError([removalError, syncError], "empty-directory removal and parent sync both failed");
|
|
2256
|
+
}
|
|
2257
|
+
if (removalError !== null) throw removalError;
|
|
2258
|
+
if (syncError !== null) throw syncError;
|
|
2259
|
+
return { ok: true, identity: actual, removed: !remainedNonempty };
|
|
2260
|
+
}
|
|
2261
|
+
|
|
2262
|
+
function removeDirectoryTree(
|
|
2263
|
+
expected: Identity,
|
|
2264
|
+
segments: readonly string[],
|
|
2265
|
+
directoryExpectations: readonly DirectoryExpectation[],
|
|
2266
|
+
): Response {
|
|
2267
|
+
const actual = assertClaimedRoot(expected);
|
|
2268
|
+
assertStateDirectoryPath(segments);
|
|
2269
|
+
if (segments.length < 2) throw new Error("refusing to recursively remove a top-level state directory");
|
|
2270
|
+
if (!traverseDirectories(segments, directoryExpectations, false)) {
|
|
2271
|
+
return { ok: true, identity: actual, removed: false };
|
|
2272
|
+
}
|
|
2273
|
+
const targetExpected = directoryExpectations.at(-1);
|
|
2274
|
+
if (targetExpected === undefined || targetExpected === null) {
|
|
2275
|
+
throw new Error("directory-tree removal target expectation is missing");
|
|
2276
|
+
}
|
|
2277
|
+
const parentExpected = directoryExpectations.length === 1
|
|
2278
|
+
? expected
|
|
2279
|
+
: directoryExpectations.at(-2);
|
|
2280
|
+
if (parentExpected === undefined || parentExpected === null) {
|
|
2281
|
+
throw new Error("directory-tree removal parent expectation is missing");
|
|
2282
|
+
}
|
|
2283
|
+
const name = segments.at(-1);
|
|
2284
|
+
if (name === undefined) throw new Error("directory-tree removal target is missing");
|
|
2285
|
+
|
|
2286
|
+
process.chdir("..");
|
|
2287
|
+
assertCurrentDirectory(parentExpected);
|
|
2288
|
+
const stats = lstatSync(name, { bigint: true });
|
|
2289
|
+
assertPrivateDirectory(stats);
|
|
2290
|
+
if (!sameIdentity(identity(stats), targetExpected)) {
|
|
2291
|
+
throw new Error("directory-tree removal target changed identity after it was bound");
|
|
2292
|
+
}
|
|
2293
|
+
|
|
2294
|
+
const quarantine = `.io-remove-tree-${process.pid}-${Date.now()}-${crypto.randomUUID().replaceAll("-", "")}.quarantine`;
|
|
2295
|
+
assertDirectoryAbsent(quarantine);
|
|
2296
|
+
renameSync(name, quarantine);
|
|
2297
|
+
const quarantinedStats = lstatSync(quarantine, { bigint: true });
|
|
2298
|
+
assertPrivateDirectory(quarantinedStats);
|
|
2299
|
+
if (!sameIdentity(identity(quarantinedStats), targetExpected)) {
|
|
2300
|
+
throw new Error("directory-tree removal quarantine has the wrong identity");
|
|
2301
|
+
}
|
|
2302
|
+
syncDirectory(".");
|
|
2303
|
+
|
|
2304
|
+
let removalError: Error | null = null;
|
|
2305
|
+
try {
|
|
2306
|
+
rmSync(quarantine, { recursive: true, force: false, maxRetries: 0 });
|
|
2307
|
+
} catch (error) {
|
|
2308
|
+
removalError = error instanceof Error
|
|
2309
|
+
? error
|
|
2310
|
+
: new Error("directory-tree removal failed with a non-error value", { cause: error });
|
|
2311
|
+
}
|
|
2312
|
+
let syncError: Error | null = null;
|
|
2313
|
+
try {
|
|
2314
|
+
syncDirectory(".");
|
|
2315
|
+
} catch (error) {
|
|
2316
|
+
syncError = error instanceof Error
|
|
2317
|
+
? error
|
|
2318
|
+
: new Error("directory-tree parent sync failed with a non-error value", { cause: error });
|
|
2319
|
+
}
|
|
2320
|
+
if (removalError !== null && syncError !== null) {
|
|
2321
|
+
throw new AggregateError([removalError, syncError], "directory-tree removal and parent sync both failed");
|
|
2322
|
+
}
|
|
2323
|
+
if (removalError !== null) throw removalError;
|
|
2324
|
+
if (syncError !== null) throw syncError;
|
|
2325
|
+
return { ok: true, identity: actual, removed: true };
|
|
2326
|
+
}
|
|
2327
|
+
|
|
2328
|
+
function directoryEntryKind(entry: {
|
|
2329
|
+
isDirectory(): boolean;
|
|
2330
|
+
isFile(): boolean;
|
|
2331
|
+
isSymbolicLink(): boolean;
|
|
2332
|
+
}): DirectoryEntry["kind"] {
|
|
2333
|
+
if (entry.isSymbolicLink()) return "symbolic-link";
|
|
2334
|
+
if (entry.isFile()) return "file";
|
|
2335
|
+
if (entry.isDirectory()) return "directory";
|
|
2336
|
+
return "other";
|
|
2337
|
+
}
|
|
2338
|
+
|
|
2339
|
+
function containsControlCharacter(value: string): boolean {
|
|
2340
|
+
for (const character of value) {
|
|
2341
|
+
const codePoint = character.codePointAt(0);
|
|
2342
|
+
if (codePoint !== undefined && (codePoint <= 0x1f || codePoint === 0x7f)) return true;
|
|
2343
|
+
}
|
|
2344
|
+
return false;
|
|
2345
|
+
}
|
|
2346
|
+
|
|
2347
|
+
function assertSafeDirectoryEntryName(name: string): void {
|
|
2348
|
+
if (
|
|
2349
|
+
name === ""
|
|
2350
|
+
|| name === "."
|
|
2351
|
+
|| name === ".."
|
|
2352
|
+
|| name.includes("/")
|
|
2353
|
+
|| name.includes("\\")
|
|
2354
|
+
|| name.includes("\uFFFD")
|
|
2355
|
+
|| containsControlCharacter(name)
|
|
2356
|
+
|| Buffer.byteLength(name, "utf8") > 255
|
|
2357
|
+
) throw new Error("state directory contains an unsafe entry name");
|
|
2358
|
+
}
|
|
2359
|
+
|
|
2360
|
+
function listDirectory(
|
|
2361
|
+
expected: Identity,
|
|
2362
|
+
segments: readonly string[],
|
|
2363
|
+
directoryExpectations: readonly DirectoryExpectation[],
|
|
2364
|
+
recoverOrphanedMutationClaims: boolean,
|
|
2365
|
+
): Response {
|
|
2366
|
+
const actual = assertClaimedRoot(expected);
|
|
2367
|
+
assertStateDirectoryPath(segments);
|
|
2368
|
+
if (!traverseDirectories(segments, directoryExpectations, false)) {
|
|
2369
|
+
return { ok: true, identity: actual, entries: [] };
|
|
2370
|
+
}
|
|
2371
|
+
recoverDefinitelyOrphanedWriteTemporaries();
|
|
2372
|
+
recoverDefinitelyOrphanedStateMutationStages();
|
|
2373
|
+
if (recoverOrphanedMutationClaims) {
|
|
2374
|
+
recoverDefinitelyOrphanedStateMutationClaims();
|
|
2375
|
+
}
|
|
2376
|
+
const targetIdentity = currentPrivateDirectoryIdentity();
|
|
2377
|
+
const entries: DirectoryEntry[] = [];
|
|
2378
|
+
const directory = opendirSync(".");
|
|
2379
|
+
try {
|
|
2380
|
+
for (;;) {
|
|
2381
|
+
const entry = directory.readSync();
|
|
2382
|
+
if (entry === null) break;
|
|
2383
|
+
if (entries.length >= 10_000) throw new Error("state directory contains more than 10000 entries");
|
|
2384
|
+
assertSafeDirectoryEntryName(entry.name);
|
|
2385
|
+
const kind = directoryEntryKind(entry);
|
|
2386
|
+
if (kind !== "directory") {
|
|
2387
|
+
entries.push({ name: entry.name, kind });
|
|
2388
|
+
continue;
|
|
2389
|
+
}
|
|
2390
|
+
const descriptor = openBoundDirectory(entry.name);
|
|
2391
|
+
try {
|
|
2392
|
+
const stats = fstatSync(descriptor, { bigint: true });
|
|
2393
|
+
assertPrivateDirectory(stats);
|
|
2394
|
+
entries.push({ name: entry.name, kind, identity: identity(stats) });
|
|
2395
|
+
} finally {
|
|
2396
|
+
closeSync(descriptor);
|
|
2397
|
+
}
|
|
2398
|
+
}
|
|
2399
|
+
} finally {
|
|
2400
|
+
directory.closeSync();
|
|
2401
|
+
}
|
|
2402
|
+
entries.sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0);
|
|
2403
|
+
return { ok: true, identity: actual, entries, targetIdentity };
|
|
2404
|
+
}
|
|
2405
|
+
|
|
2406
|
+
function invalidBatchFile(
|
|
2407
|
+
name: string,
|
|
2408
|
+
reason: BatchReadInvalidReason,
|
|
2409
|
+
): BatchReadFileResult {
|
|
2410
|
+
return { name, status: "invalid", reason };
|
|
2411
|
+
}
|
|
2412
|
+
|
|
2413
|
+
function applyBatchFileFaultForTest(
|
|
2414
|
+
name: string,
|
|
2415
|
+
index: number,
|
|
2416
|
+
requestId: string,
|
|
2417
|
+
): void {
|
|
2418
|
+
if (index !== 0) return;
|
|
2419
|
+
if (batchReadFaultForTest === "remove-first-file-after-open") {
|
|
2420
|
+
unlinkSync(name);
|
|
2421
|
+
return;
|
|
2422
|
+
}
|
|
2423
|
+
if (batchReadFaultForTest === "replace-first-file-after-open") {
|
|
2424
|
+
const preserved = `.wrench-test-batch-file-${requestId}`;
|
|
2425
|
+
assertDirectoryAbsent(preserved);
|
|
2426
|
+
renameSync(name, preserved);
|
|
2427
|
+
writeFileSync(name, "replacement must not be returned\n", {
|
|
2428
|
+
encoding: "utf8",
|
|
2429
|
+
flag: "wx",
|
|
2430
|
+
mode: 0o600,
|
|
2431
|
+
});
|
|
2432
|
+
}
|
|
2433
|
+
}
|
|
2434
|
+
|
|
2435
|
+
function readOneBatchFile(
|
|
2436
|
+
name: string,
|
|
2437
|
+
index: number,
|
|
2438
|
+
requestId: string,
|
|
2439
|
+
maximumBytes: number,
|
|
2440
|
+
remainingAggregateBytes: number,
|
|
2441
|
+
): {
|
|
2442
|
+
readonly result: BatchReadFileResult;
|
|
2443
|
+
readonly contentBytes: number;
|
|
2444
|
+
} {
|
|
2445
|
+
let descriptor: number;
|
|
2446
|
+
try {
|
|
2447
|
+
descriptor = openPrivateFileForRead(name);
|
|
2448
|
+
} catch (error) {
|
|
2449
|
+
if (hasCode(error, "ENOENT")) {
|
|
2450
|
+
return { result: { name, status: "absent" }, contentBytes: 0 };
|
|
2451
|
+
}
|
|
2452
|
+
return {
|
|
2453
|
+
result: invalidBatchFile(
|
|
2454
|
+
name,
|
|
2455
|
+
hasCode(error, "ELOOP") ? "unsafe-file" : "unreadable",
|
|
2456
|
+
),
|
|
2457
|
+
contentBytes: 0,
|
|
2458
|
+
};
|
|
2459
|
+
}
|
|
2460
|
+
try {
|
|
2461
|
+
const before = fstatSync(descriptor, { bigint: true });
|
|
2462
|
+
if (
|
|
2463
|
+
!before.isFile()
|
|
2464
|
+
|| !ownedByCurrentUser(before)
|
|
2465
|
+
|| (before.mode & 0o077n) !== 0n
|
|
2466
|
+
) {
|
|
2467
|
+
return {
|
|
2468
|
+
result: invalidBatchFile(name, "unsafe-file"),
|
|
2469
|
+
contentBytes: 0,
|
|
2470
|
+
};
|
|
2471
|
+
}
|
|
2472
|
+
if (before.size > BigInt(maximumBytes)) {
|
|
2473
|
+
return {
|
|
2474
|
+
result: invalidBatchFile(name, "file-byte-bound"),
|
|
2475
|
+
contentBytes: 0,
|
|
2476
|
+
};
|
|
2477
|
+
}
|
|
2478
|
+
if (before.size > BigInt(remainingAggregateBytes)) {
|
|
2479
|
+
return {
|
|
2480
|
+
result: invalidBatchFile(name, "aggregate-byte-bound"),
|
|
2481
|
+
contentBytes: 0,
|
|
2482
|
+
};
|
|
2483
|
+
}
|
|
2484
|
+
applyBatchFileFaultForTest(name, index, requestId);
|
|
2485
|
+
const effectiveMaximum = Math.min(
|
|
2486
|
+
maximumBytes,
|
|
2487
|
+
remainingAggregateBytes,
|
|
2488
|
+
);
|
|
2489
|
+
let content: Buffer;
|
|
2490
|
+
try {
|
|
2491
|
+
content = readDescriptorBounded(descriptor, effectiveMaximum);
|
|
2492
|
+
} catch {
|
|
2493
|
+
return {
|
|
2494
|
+
result: invalidBatchFile(
|
|
2495
|
+
name,
|
|
2496
|
+
effectiveMaximum < maximumBytes
|
|
2497
|
+
? "aggregate-byte-bound"
|
|
2498
|
+
: "file-byte-bound",
|
|
2499
|
+
),
|
|
2500
|
+
contentBytes: 0,
|
|
2501
|
+
};
|
|
2502
|
+
}
|
|
2503
|
+
const after = fstatSync(descriptor, { bigint: true });
|
|
2504
|
+
let current: BigIntStats;
|
|
2505
|
+
try {
|
|
2506
|
+
current = lstatSync(name, { bigint: true });
|
|
2507
|
+
} catch (error) {
|
|
2508
|
+
if (hasCode(error, "ENOENT")) {
|
|
2509
|
+
return { result: { name, status: "absent" }, contentBytes: 0 };
|
|
2510
|
+
}
|
|
2511
|
+
return {
|
|
2512
|
+
result: invalidBatchFile(name, "unreadable"),
|
|
2513
|
+
contentBytes: 0,
|
|
2514
|
+
};
|
|
2515
|
+
}
|
|
2516
|
+
if (
|
|
2517
|
+
!sameIdentity(identity(before), identity(after))
|
|
2518
|
+
|| before.size !== after.size
|
|
2519
|
+
|| before.mtimeNs !== after.mtimeNs
|
|
2520
|
+
|| before.ctimeNs !== after.ctimeNs
|
|
2521
|
+
|| before.mode !== after.mode
|
|
2522
|
+
|| content.byteLength !== Number(after.size)
|
|
2523
|
+
|| current.isSymbolicLink()
|
|
2524
|
+
|| !current.isFile()
|
|
2525
|
+
|| !sameIdentity(identity(after), identity(current))
|
|
2526
|
+
|| current.size !== after.size
|
|
2527
|
+
|| current.mtimeNs !== after.mtimeNs
|
|
2528
|
+
|| current.ctimeNs !== after.ctimeNs
|
|
2529
|
+
|| current.mode !== after.mode
|
|
2530
|
+
) {
|
|
2531
|
+
return {
|
|
2532
|
+
result: invalidBatchFile(name, "changed-during-read"),
|
|
2533
|
+
contentBytes: 0,
|
|
2534
|
+
};
|
|
2535
|
+
}
|
|
2536
|
+
return {
|
|
2537
|
+
result: {
|
|
2538
|
+
name,
|
|
2539
|
+
status: "present",
|
|
2540
|
+
contentBase64: content.toString("base64"),
|
|
2541
|
+
},
|
|
2542
|
+
contentBytes: content.byteLength,
|
|
2543
|
+
};
|
|
2544
|
+
} catch {
|
|
2545
|
+
return {
|
|
2546
|
+
result: invalidBatchFile(name, "unreadable"),
|
|
2547
|
+
contentBytes: 0,
|
|
2548
|
+
};
|
|
2549
|
+
} finally {
|
|
2550
|
+
closeSync(descriptor);
|
|
2551
|
+
}
|
|
2552
|
+
}
|
|
2553
|
+
|
|
2554
|
+
function replaceBoundBatchDirectoryForTest(
|
|
2555
|
+
expected: Identity,
|
|
2556
|
+
segments: readonly string[],
|
|
2557
|
+
directoryExpectations: readonly DirectoryExpectation[],
|
|
2558
|
+
requestId: string,
|
|
2559
|
+
targetIdentity: Identity,
|
|
2560
|
+
): void {
|
|
2561
|
+
if (batchReadFaultForTest !== "replace-directory-after-bind") return;
|
|
2562
|
+
const targetName = segments.at(-1);
|
|
2563
|
+
const parentExpected = segments.length === 1
|
|
2564
|
+
? expected
|
|
2565
|
+
: directoryExpectations.at(-2);
|
|
2566
|
+
if (
|
|
2567
|
+
targetName === undefined
|
|
2568
|
+
|| parentExpected === undefined
|
|
2569
|
+
|| parentExpected === null
|
|
2570
|
+
) {
|
|
2571
|
+
throw new Error("batch directory fault injection is missing an identity");
|
|
2572
|
+
}
|
|
2573
|
+
process.chdir("..");
|
|
2574
|
+
assertCurrentDirectory(parentExpected);
|
|
2575
|
+
const preserved = `.wrench-test-batch-directory-${requestId}`;
|
|
2576
|
+
assertDirectoryAbsent(preserved);
|
|
2577
|
+
renameSync(targetName, preserved);
|
|
2578
|
+
mkdirSync(targetName, { mode: 0o700 });
|
|
2579
|
+
syncDirectory(".");
|
|
2580
|
+
process.chdir(preserved);
|
|
2581
|
+
assertCurrentDirectory(targetIdentity);
|
|
2582
|
+
}
|
|
2583
|
+
|
|
2584
|
+
function batchReadFiles(
|
|
2585
|
+
expected: Identity,
|
|
2586
|
+
segments: readonly string[],
|
|
2587
|
+
directoryExpectations: readonly DirectoryExpectation[],
|
|
2588
|
+
names: readonly string[],
|
|
2589
|
+
maximumBytesPerFile: number,
|
|
2590
|
+
maximumTotalBytes: number,
|
|
2591
|
+
requestId: string,
|
|
2592
|
+
): Response {
|
|
2593
|
+
const actual = assertClaimedRoot(expected);
|
|
2594
|
+
assertStateDirectoryPath(segments);
|
|
2595
|
+
if (!traverseDirectories(segments, directoryExpectations, false)) {
|
|
2596
|
+
throw new Error("batch state directory is absent");
|
|
2597
|
+
}
|
|
2598
|
+
const targetIdentity = currentPrivateDirectoryIdentity();
|
|
2599
|
+
replaceBoundBatchDirectoryForTest(
|
|
2600
|
+
expected,
|
|
2601
|
+
segments,
|
|
2602
|
+
directoryExpectations,
|
|
2603
|
+
requestId,
|
|
2604
|
+
targetIdentity,
|
|
2605
|
+
);
|
|
2606
|
+
const files: BatchReadFileResult[] = [];
|
|
2607
|
+
let contentBytes = 0;
|
|
2608
|
+
for (const [index, name] of names.entries()) {
|
|
2609
|
+
const read = readOneBatchFile(
|
|
2610
|
+
name,
|
|
2611
|
+
index,
|
|
2612
|
+
requestId,
|
|
2613
|
+
maximumBytesPerFile,
|
|
2614
|
+
maximumTotalBytes - contentBytes,
|
|
2615
|
+
);
|
|
2616
|
+
files.push(read.result);
|
|
2617
|
+
contentBytes += read.contentBytes;
|
|
2618
|
+
}
|
|
2619
|
+
return {
|
|
2620
|
+
ok: true,
|
|
2621
|
+
identity: actual,
|
|
2622
|
+
files,
|
|
2623
|
+
targetIdentity,
|
|
2624
|
+
};
|
|
2625
|
+
}
|
|
2626
|
+
|
|
2627
|
+
function invalidBatchChildFile(
|
|
2628
|
+
file: BatchReadChildFile,
|
|
2629
|
+
reason: BatchReadInvalidReason,
|
|
2630
|
+
): BatchReadChildFileResult {
|
|
2631
|
+
return {
|
|
2632
|
+
directoryName: file.directoryName,
|
|
2633
|
+
fileName: file.fileName,
|
|
2634
|
+
status: "invalid",
|
|
2635
|
+
reason,
|
|
2636
|
+
};
|
|
2637
|
+
}
|
|
2638
|
+
|
|
2639
|
+
function readOneBatchChildFile(
|
|
2640
|
+
file: BatchReadChildFile,
|
|
2641
|
+
index: number,
|
|
2642
|
+
requestId: string,
|
|
2643
|
+
parentIdentity: Identity,
|
|
2644
|
+
maximumBytes: number,
|
|
2645
|
+
remainingAggregateBytes: number,
|
|
2646
|
+
): {
|
|
2647
|
+
readonly result: BatchReadChildFileResult;
|
|
2648
|
+
readonly contentBytes: number;
|
|
2649
|
+
} {
|
|
2650
|
+
let entered = false;
|
|
2651
|
+
let read: ReturnType<typeof readOneBatchFile> | null = null;
|
|
2652
|
+
try {
|
|
2653
|
+
const before = lstatSync(file.directoryName, { bigint: true });
|
|
2654
|
+
assertPrivateDirectory(before);
|
|
2655
|
+
if (!sameIdentity(identity(before), file.directoryIdentity)) {
|
|
2656
|
+
return {
|
|
2657
|
+
result: invalidBatchChildFile(file, "changed-during-read"),
|
|
2658
|
+
contentBytes: 0,
|
|
2659
|
+
};
|
|
2660
|
+
}
|
|
2661
|
+
process.chdir(file.directoryName);
|
|
2662
|
+
entered = true;
|
|
2663
|
+
assertCurrentDirectory(file.directoryIdentity);
|
|
2664
|
+
if (
|
|
2665
|
+
index === 0
|
|
2666
|
+
&& batchReadFaultForTest === "replace-first-child-after-bind"
|
|
2667
|
+
) {
|
|
2668
|
+
process.chdir("..");
|
|
2669
|
+
assertCurrentDirectory(parentIdentity);
|
|
2670
|
+
const preserved = `.wrench-test-batch-child-${requestId}`;
|
|
2671
|
+
assertDirectoryAbsent(preserved);
|
|
2672
|
+
renameSync(file.directoryName, preserved);
|
|
2673
|
+
mkdirSync(file.directoryName, { mode: 0o700 });
|
|
2674
|
+
process.chdir(preserved);
|
|
2675
|
+
assertCurrentDirectory(file.directoryIdentity);
|
|
2676
|
+
}
|
|
2677
|
+
read = readOneBatchFile(
|
|
2678
|
+
file.fileName,
|
|
2679
|
+
index,
|
|
2680
|
+
requestId,
|
|
2681
|
+
maximumBytes,
|
|
2682
|
+
remainingAggregateBytes,
|
|
2683
|
+
);
|
|
2684
|
+
} catch {
|
|
2685
|
+
return {
|
|
2686
|
+
result: invalidBatchChildFile(file, "unreadable"),
|
|
2687
|
+
contentBytes: 0,
|
|
2688
|
+
};
|
|
2689
|
+
} finally {
|
|
2690
|
+
if (entered) {
|
|
2691
|
+
process.chdir("..");
|
|
2692
|
+
assertCurrentDirectory(parentIdentity);
|
|
2693
|
+
}
|
|
2694
|
+
}
|
|
2695
|
+
|
|
2696
|
+
let current: BigIntStats;
|
|
2697
|
+
try {
|
|
2698
|
+
current = lstatSync(file.directoryName, { bigint: true });
|
|
2699
|
+
assertPrivateDirectory(current);
|
|
2700
|
+
} catch {
|
|
2701
|
+
return {
|
|
2702
|
+
result: invalidBatchChildFile(file, "changed-during-read"),
|
|
2703
|
+
contentBytes: 0,
|
|
2704
|
+
};
|
|
2705
|
+
}
|
|
2706
|
+
if (!sameIdentity(identity(current), file.directoryIdentity)) {
|
|
2707
|
+
return {
|
|
2708
|
+
result: invalidBatchChildFile(file, "changed-during-read"),
|
|
2709
|
+
contentBytes: 0,
|
|
2710
|
+
};
|
|
2711
|
+
}
|
|
2712
|
+
if (read === null) {
|
|
2713
|
+
return {
|
|
2714
|
+
result: invalidBatchChildFile(file, "unreadable"),
|
|
2715
|
+
contentBytes: 0,
|
|
2716
|
+
};
|
|
2717
|
+
}
|
|
2718
|
+
if (read.result.status === "present") {
|
|
2719
|
+
return {
|
|
2720
|
+
result: {
|
|
2721
|
+
directoryName: file.directoryName,
|
|
2722
|
+
fileName: file.fileName,
|
|
2723
|
+
status: "present",
|
|
2724
|
+
contentBase64: read.result.contentBase64,
|
|
2725
|
+
},
|
|
2726
|
+
contentBytes: read.contentBytes,
|
|
2727
|
+
};
|
|
2728
|
+
}
|
|
2729
|
+
if (read.result.status === "absent") {
|
|
2730
|
+
return {
|
|
2731
|
+
result: {
|
|
2732
|
+
directoryName: file.directoryName,
|
|
2733
|
+
fileName: file.fileName,
|
|
2734
|
+
status: "absent",
|
|
2735
|
+
},
|
|
2736
|
+
contentBytes: 0,
|
|
2737
|
+
};
|
|
2738
|
+
}
|
|
2739
|
+
return {
|
|
2740
|
+
result: invalidBatchChildFile(file, read.result.reason),
|
|
2741
|
+
contentBytes: 0,
|
|
2742
|
+
};
|
|
2743
|
+
}
|
|
2744
|
+
|
|
2745
|
+
function batchReadChildFiles(
|
|
2746
|
+
expected: Identity,
|
|
2747
|
+
segments: readonly string[],
|
|
2748
|
+
directoryExpectations: readonly DirectoryExpectation[],
|
|
2749
|
+
requestedFiles: readonly BatchReadChildFile[],
|
|
2750
|
+
maximumBytesPerFile: number,
|
|
2751
|
+
maximumTotalBytes: number,
|
|
2752
|
+
requestId: string,
|
|
2753
|
+
): Response {
|
|
2754
|
+
const actual = assertClaimedRoot(expected);
|
|
2755
|
+
assertStateDirectoryPath(segments);
|
|
2756
|
+
if (!traverseDirectories(segments, directoryExpectations, false)) {
|
|
2757
|
+
throw new Error("batch child-file parent directory is absent");
|
|
2758
|
+
}
|
|
2759
|
+
const targetIdentity = currentPrivateDirectoryIdentity();
|
|
2760
|
+
replaceBoundBatchDirectoryForTest(
|
|
2761
|
+
expected,
|
|
2762
|
+
segments,
|
|
2763
|
+
directoryExpectations,
|
|
2764
|
+
requestId,
|
|
2765
|
+
targetIdentity,
|
|
2766
|
+
);
|
|
2767
|
+
const childFiles: BatchReadChildFileResult[] = [];
|
|
2768
|
+
let contentBytes = 0;
|
|
2769
|
+
for (const [index, file] of requestedFiles.entries()) {
|
|
2770
|
+
const read = readOneBatchChildFile(
|
|
2771
|
+
file,
|
|
2772
|
+
index,
|
|
2773
|
+
requestId,
|
|
2774
|
+
targetIdentity,
|
|
2775
|
+
maximumBytesPerFile,
|
|
2776
|
+
maximumTotalBytes - contentBytes,
|
|
2777
|
+
);
|
|
2778
|
+
childFiles.push(read.result);
|
|
2779
|
+
contentBytes += read.contentBytes;
|
|
2780
|
+
}
|
|
2781
|
+
return {
|
|
2782
|
+
ok: true,
|
|
2783
|
+
identity: actual,
|
|
2784
|
+
childFiles,
|
|
2785
|
+
targetIdentity,
|
|
2786
|
+
};
|
|
2787
|
+
}
|
|
2788
|
+
|
|
2789
|
+
function execute(request: Request): Response {
|
|
2790
|
+
const operation = request.operation;
|
|
2791
|
+
if (operation.kind === "claim") return claim(request.expected);
|
|
2792
|
+
if (operation.kind === "create-root") return createRoot(request.expected, operation.segments);
|
|
2793
|
+
if (operation.kind === "ensure-directories") {
|
|
2794
|
+
const actual = assertClaimedRoot(request.expected);
|
|
2795
|
+
traverseDirectories(operation.segments, operation.directoryExpectations, true);
|
|
2796
|
+
return { ok: true, identity: actual, targetIdentity: currentPrivateDirectoryIdentity() };
|
|
2797
|
+
}
|
|
2798
|
+
if (operation.kind === "read-file") {
|
|
2799
|
+
return readFile(
|
|
2800
|
+
request.expected,
|
|
2801
|
+
operation.segments,
|
|
2802
|
+
operation.directoryExpectations,
|
|
2803
|
+
operation.maximumBytes,
|
|
2804
|
+
);
|
|
2805
|
+
}
|
|
2806
|
+
if (operation.kind === "read-file-if-present") {
|
|
2807
|
+
return readFileIfPresent(
|
|
2808
|
+
request.expected,
|
|
2809
|
+
operation.segments,
|
|
2810
|
+
operation.directoryExpectations,
|
|
2811
|
+
operation.maximumBytes,
|
|
2812
|
+
);
|
|
2813
|
+
}
|
|
2814
|
+
if (operation.kind === "batch-read-files") {
|
|
2815
|
+
return batchReadFiles(
|
|
2816
|
+
request.expected,
|
|
2817
|
+
operation.segments,
|
|
2818
|
+
operation.directoryExpectations,
|
|
2819
|
+
operation.names,
|
|
2820
|
+
operation.maximumBytesPerFile,
|
|
2821
|
+
operation.maximumTotalBytes,
|
|
2822
|
+
request.requestId,
|
|
2823
|
+
);
|
|
2824
|
+
}
|
|
2825
|
+
if (operation.kind === "batch-read-child-files") {
|
|
2826
|
+
return batchReadChildFiles(
|
|
2827
|
+
request.expected,
|
|
2828
|
+
operation.segments,
|
|
2829
|
+
operation.directoryExpectations,
|
|
2830
|
+
operation.files,
|
|
2831
|
+
operation.maximumBytesPerFile,
|
|
2832
|
+
operation.maximumTotalBytes,
|
|
2833
|
+
request.requestId,
|
|
2834
|
+
);
|
|
2835
|
+
}
|
|
2836
|
+
if (operation.kind === "write-file") {
|
|
2837
|
+
return writeFile(
|
|
2838
|
+
request.expected,
|
|
2839
|
+
operation.segments,
|
|
2840
|
+
operation.directoryExpectations,
|
|
2841
|
+
operation.content,
|
|
2842
|
+
operation.createOnly,
|
|
2843
|
+
operation.expectedContentSha256,
|
|
2844
|
+
operation.maximumExpectedContentBytes,
|
|
2845
|
+
request.requestId,
|
|
2846
|
+
);
|
|
2847
|
+
}
|
|
2848
|
+
if (operation.kind === "remove-file") {
|
|
2849
|
+
return removeFile(
|
|
2850
|
+
request.expected,
|
|
2851
|
+
operation.segments,
|
|
2852
|
+
operation.directoryExpectations,
|
|
2853
|
+
request.requestId,
|
|
2854
|
+
);
|
|
2855
|
+
}
|
|
2856
|
+
if (operation.kind === "remove-file-if-unchanged") {
|
|
2857
|
+
return removeFileIfUnchanged(
|
|
2858
|
+
request.expected,
|
|
2859
|
+
operation.segments,
|
|
2860
|
+
operation.directoryExpectations,
|
|
2861
|
+
operation.expectedContentSha256,
|
|
2862
|
+
request.requestId,
|
|
2863
|
+
);
|
|
2864
|
+
}
|
|
2865
|
+
if (operation.kind === "create-directory") {
|
|
2866
|
+
return createDirectory(request.expected, operation.segments, operation.directoryExpectations);
|
|
2867
|
+
}
|
|
2868
|
+
if (operation.kind === "remove-empty-directory") {
|
|
2869
|
+
return removeEmptyDirectory(request.expected, operation.segments, operation.directoryExpectations, request.requestId);
|
|
2870
|
+
}
|
|
2871
|
+
if (operation.kind === "list-directory") {
|
|
2872
|
+
return listDirectory(
|
|
2873
|
+
request.expected,
|
|
2874
|
+
operation.segments,
|
|
2875
|
+
operation.directoryExpectations,
|
|
2876
|
+
operation.recoverOrphanedMutationClaims,
|
|
2877
|
+
);
|
|
2878
|
+
}
|
|
2879
|
+
return removeDirectoryTree(request.expected, operation.segments, operation.directoryExpectations);
|
|
2880
|
+
}
|
|
2881
|
+
|
|
2882
|
+
function main(): void {
|
|
2883
|
+
const request = parseRequest(JSON.parse(readBoundedStdin()) as unknown);
|
|
2884
|
+
if (
|
|
2885
|
+
(
|
|
2886
|
+
request.operation.kind === "batch-read-files"
|
|
2887
|
+
|| request.operation.kind === "batch-read-child-files"
|
|
2888
|
+
)
|
|
2889
|
+
&& batchReadFaultForTest === "malformed-response"
|
|
2890
|
+
) {
|
|
2891
|
+
process.stdout.write('{"files":"malformed","identity":null,"ok":true}\n');
|
|
2892
|
+
return;
|
|
2893
|
+
}
|
|
2894
|
+
const encoded = `${JSON.stringify(execute(request))}\n`;
|
|
2895
|
+
if (
|
|
2896
|
+
(
|
|
2897
|
+
request.operation.kind === "batch-read-files"
|
|
2898
|
+
|| request.operation.kind === "batch-read-child-files"
|
|
2899
|
+
)
|
|
2900
|
+
&& Buffer.byteLength(encoded, "utf8") > MAX_BATCH_STDOUT_BYTES
|
|
2901
|
+
) {
|
|
2902
|
+
throw new Error("batch response exceeds its stdout byte bound");
|
|
2903
|
+
}
|
|
2904
|
+
process.stdout.write(encoded);
|
|
2905
|
+
}
|
|
2906
|
+
|
|
2907
|
+
if (import.meta.main) {
|
|
2908
|
+
try {
|
|
2909
|
+
main();
|
|
2910
|
+
} catch (error) {
|
|
2911
|
+
process.stderr.write(`state helper: ${error instanceof Error ? error.message : "unknown failure"}\n`);
|
|
2912
|
+
process.exitCode = 1;
|
|
2913
|
+
}
|
|
2914
|
+
}
|