@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,1131 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
constants as fsConstants,
|
|
4
|
+
createReadStream,
|
|
5
|
+
type BigIntStats,
|
|
6
|
+
lstatSync,
|
|
7
|
+
realpathSync,
|
|
8
|
+
} from "node:fs";
|
|
9
|
+
import {
|
|
10
|
+
lstat,
|
|
11
|
+
mkdtemp,
|
|
12
|
+
open,
|
|
13
|
+
opendir,
|
|
14
|
+
realpath,
|
|
15
|
+
rm,
|
|
16
|
+
} from "node:fs/promises";
|
|
17
|
+
import { tmpdir, userInfo } from "node:os";
|
|
18
|
+
import {
|
|
19
|
+
isAbsolute,
|
|
20
|
+
join,
|
|
21
|
+
resolve,
|
|
22
|
+
sep,
|
|
23
|
+
} from "node:path";
|
|
24
|
+
|
|
25
|
+
import { Database } from "bun:sqlite";
|
|
26
|
+
|
|
27
|
+
import {
|
|
28
|
+
APPLE_PHOTOS_CONTACT_EVIDENCE_LIMITS,
|
|
29
|
+
createApplePhotosContactEvidenceExportResult,
|
|
30
|
+
} from "./apple-photos-contact-evidence";
|
|
31
|
+
import type {
|
|
32
|
+
ApplePhotosContactEvidence,
|
|
33
|
+
ApplePhotosContactEvidenceExportResult,
|
|
34
|
+
} from "./apple-photos-client-types";
|
|
35
|
+
import {
|
|
36
|
+
createBeeperMessageLikeMeDirectoryLease,
|
|
37
|
+
releaseBeeperMessageLikeMeDirectoryLease,
|
|
38
|
+
type BeeperMessageLikeMeDirectoryLease,
|
|
39
|
+
updateBeeperMessageLikeMeDirectoryLease,
|
|
40
|
+
} from "./beeper-message-like-me-recovery";
|
|
41
|
+
import { canonicalJson, sha256 } from "./canonical-json";
|
|
42
|
+
import { removePrivateDirectoryTree } from "./storage";
|
|
43
|
+
|
|
44
|
+
const PHOTOS_DATABASE_RELATIVE_PATH = join("database", "Photos.sqlite");
|
|
45
|
+
const CONTACTS_RELATIVE_ROOT = join(
|
|
46
|
+
"Library",
|
|
47
|
+
"Application Support",
|
|
48
|
+
"AddressBook",
|
|
49
|
+
);
|
|
50
|
+
const SQLITE_HEADER_BYTES = 512;
|
|
51
|
+
const CONTACT_DATABASE_PATTERN = /^AddressBook-v([1-9][0-9]*)\.abcddb$/u;
|
|
52
|
+
const CONTACT_SOURCE_DIRECTORY_PATTERN =
|
|
53
|
+
/^[A-Fa-f0-9]{8}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{12}$/u;
|
|
54
|
+
|
|
55
|
+
const PHOTOS_SCHEMA_CONTRACT = Object.freeze({
|
|
56
|
+
ZPERSON: Object.freeze({
|
|
57
|
+
Z_PK: Object.freeze({ type: "INTEGER", notnull: 0, defaultValue: null, pk: 1, hidden: 0 }),
|
|
58
|
+
ZPERSONUUID: Object.freeze({ type: "VARCHAR", notnull: 0, defaultValue: null, pk: 0, hidden: 0 }),
|
|
59
|
+
ZPERSONURI: Object.freeze({ type: "VARCHAR", notnull: 0, defaultValue: null, pk: 0, hidden: 0 }),
|
|
60
|
+
}),
|
|
61
|
+
ZDETECTEDFACE: Object.freeze({
|
|
62
|
+
Z_PK: Object.freeze({ type: "INTEGER", notnull: 0, defaultValue: null, pk: 1, hidden: 0 }),
|
|
63
|
+
ZPERSONFORFACE: Object.freeze({ type: "INTEGER", notnull: 0, defaultValue: null, pk: 0, hidden: 0 }),
|
|
64
|
+
ZASSETFORFACE: Object.freeze({ type: "INTEGER", notnull: 0, defaultValue: null, pk: 0, hidden: 0 }),
|
|
65
|
+
}),
|
|
66
|
+
ZASSET: Object.freeze({
|
|
67
|
+
Z_PK: Object.freeze({ type: "INTEGER", notnull: 0, defaultValue: null, pk: 1, hidden: 0 }),
|
|
68
|
+
ZDATECREATED: Object.freeze({ type: "TIMESTAMP", notnull: 0, defaultValue: null, pk: 0, hidden: 0 }),
|
|
69
|
+
}),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const CONTACTS_SCHEMA_CONTRACT = Object.freeze({
|
|
73
|
+
ZABCDRECORD: Object.freeze({
|
|
74
|
+
Z_PK: Object.freeze({ type: "INTEGER", notnull: 0, defaultValue: null, pk: 1, hidden: 0 }),
|
|
75
|
+
ZUNIQUEID: Object.freeze({ type: "VARCHAR", notnull: 0, defaultValue: null, pk: 0, hidden: 0 }),
|
|
76
|
+
}),
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
export const APPLE_PHOTOS_SCHEMA_SHA256 = sha256(
|
|
80
|
+
canonicalJson(PHOTOS_SCHEMA_CONTRACT),
|
|
81
|
+
);
|
|
82
|
+
export const APPLE_CONTACTS_SCHEMA_SHA256 = sha256(
|
|
83
|
+
canonicalJson(CONTACTS_SCHEMA_CONTRACT),
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
type CaptureRole = "photos" | "contacts";
|
|
87
|
+
|
|
88
|
+
export type ApplePhotosLocalSourceDependencies = Readonly<{
|
|
89
|
+
platform?: NodeJS.Platform;
|
|
90
|
+
resolveAccountHomeDirectory?: () => string;
|
|
91
|
+
temporaryDirectory?: string;
|
|
92
|
+
now?: () => Date;
|
|
93
|
+
runId?: () => string;
|
|
94
|
+
afterDatabaseCaptured?: (
|
|
95
|
+
role: CaptureRole,
|
|
96
|
+
attempt: number,
|
|
97
|
+
) => void | Promise<void>;
|
|
98
|
+
}>;
|
|
99
|
+
|
|
100
|
+
export type ApplePhotosLocalSourceRequest = Readonly<{
|
|
101
|
+
library?: string;
|
|
102
|
+
stateEnvironment?: Readonly<Record<string, string | undefined>>;
|
|
103
|
+
signal?: AbortSignal;
|
|
104
|
+
dependencies?: ApplePhotosLocalSourceDependencies;
|
|
105
|
+
progress?: (event: ApplePhotosLocalSourceProgressEvent) => void;
|
|
106
|
+
}>;
|
|
107
|
+
|
|
108
|
+
export type ApplePhotosLocalSourceProgressEvent = Readonly<
|
|
109
|
+
| { phase: "source-admission" }
|
|
110
|
+
| { phase: "contacts-discovery" }
|
|
111
|
+
| { phase: "photos-capture" }
|
|
112
|
+
| { phase: "contacts-capture"; current: number; total: number }
|
|
113
|
+
| { phase: "evidence-validation" }
|
|
114
|
+
| { phase: "generation-hashing" }
|
|
115
|
+
| { phase: "cleanup" }
|
|
116
|
+
>;
|
|
117
|
+
|
|
118
|
+
type DirectoryIdentity = Readonly<{
|
|
119
|
+
kind: "directory";
|
|
120
|
+
dev: bigint;
|
|
121
|
+
ino: bigint;
|
|
122
|
+
birthtimeNs: bigint;
|
|
123
|
+
uid: bigint;
|
|
124
|
+
mode: bigint;
|
|
125
|
+
}>;
|
|
126
|
+
|
|
127
|
+
type SnapshotDirectory = Readonly<{
|
|
128
|
+
path: string;
|
|
129
|
+
parentPath: string;
|
|
130
|
+
identity: DirectoryIdentity;
|
|
131
|
+
parentIdentity: DirectoryIdentity;
|
|
132
|
+
}>;
|
|
133
|
+
|
|
134
|
+
type FileIdentity = Readonly<{
|
|
135
|
+
kind: "regular-file";
|
|
136
|
+
dev: bigint;
|
|
137
|
+
ino: bigint;
|
|
138
|
+
birthtimeNs: bigint;
|
|
139
|
+
mode: bigint;
|
|
140
|
+
nlink: bigint;
|
|
141
|
+
uid: bigint;
|
|
142
|
+
size: bigint;
|
|
143
|
+
mtimeNs: bigint;
|
|
144
|
+
ctimeNs: bigint;
|
|
145
|
+
}>;
|
|
146
|
+
|
|
147
|
+
type SourceFile = Readonly<{
|
|
148
|
+
path: string;
|
|
149
|
+
identity: FileIdentity;
|
|
150
|
+
}>;
|
|
151
|
+
|
|
152
|
+
type SnapshotDatabase = Readonly<{
|
|
153
|
+
mainPath: string;
|
|
154
|
+
capture: Readonly<{
|
|
155
|
+
startedAt: string;
|
|
156
|
+
finishedAt: string;
|
|
157
|
+
}>;
|
|
158
|
+
}>;
|
|
159
|
+
|
|
160
|
+
type TableColumnContract = Readonly<Record<string, Readonly<{
|
|
161
|
+
type: string;
|
|
162
|
+
notnull: number;
|
|
163
|
+
defaultValue: null;
|
|
164
|
+
pk: number;
|
|
165
|
+
hidden: number;
|
|
166
|
+
}>>>;
|
|
167
|
+
|
|
168
|
+
type SchemaContract = Readonly<Record<string, TableColumnContract>>;
|
|
169
|
+
|
|
170
|
+
function fail(message: string): never {
|
|
171
|
+
throw new Error(`Apple Photos local source: ${message}`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function abortIfRequested(signal: AbortSignal | undefined): void {
|
|
175
|
+
if (signal?.aborted === true) return fail("operation was cancelled");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function reportProgress(
|
|
179
|
+
request: ApplePhotosLocalSourceRequest,
|
|
180
|
+
event: ApplePhotosLocalSourceProgressEvent,
|
|
181
|
+
): void {
|
|
182
|
+
try {
|
|
183
|
+
request.progress?.(Object.freeze(event));
|
|
184
|
+
} catch {
|
|
185
|
+
// Progress is advisory and cannot weaken capture or cleanup custody.
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function normalizedAbsolutePath(value: string, label: string): string {
|
|
190
|
+
if (
|
|
191
|
+
!isAbsolute(value)
|
|
192
|
+
|| resolve(value) !== value
|
|
193
|
+
|| Buffer.byteLength(value, "utf8") > 4_096
|
|
194
|
+
|| /[\0\r\n]/u.test(value)
|
|
195
|
+
) return fail(`${label} must be one normalized absolute path`);
|
|
196
|
+
return value;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function exactRealDirectory(
|
|
200
|
+
path: string,
|
|
201
|
+
label: string,
|
|
202
|
+
uid: bigint,
|
|
203
|
+
): Promise<DirectoryIdentity> {
|
|
204
|
+
let metadata: BigIntStats;
|
|
205
|
+
let resolved: string;
|
|
206
|
+
try {
|
|
207
|
+
[metadata, resolved] = await Promise.all([
|
|
208
|
+
lstat(path, { bigint: true }),
|
|
209
|
+
realpath(path),
|
|
210
|
+
]);
|
|
211
|
+
} catch {
|
|
212
|
+
return fail(`${label} is unavailable`);
|
|
213
|
+
}
|
|
214
|
+
if (
|
|
215
|
+
!metadata.isDirectory()
|
|
216
|
+
|| metadata.isSymbolicLink()
|
|
217
|
+
|| metadata.uid !== uid
|
|
218
|
+
|| resolved !== path
|
|
219
|
+
) return fail(`${label} must be an owned real directory without symlink components`);
|
|
220
|
+
return Object.freeze({
|
|
221
|
+
kind: "directory" as const,
|
|
222
|
+
dev: metadata.dev,
|
|
223
|
+
ino: metadata.ino,
|
|
224
|
+
birthtimeNs: metadata.birthtimeNs,
|
|
225
|
+
uid: metadata.uid,
|
|
226
|
+
mode: metadata.mode,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function sameDirectoryIdentity(
|
|
231
|
+
left: DirectoryIdentity,
|
|
232
|
+
right: DirectoryIdentity,
|
|
233
|
+
): boolean {
|
|
234
|
+
return left.kind === right.kind
|
|
235
|
+
&& left.dev === right.dev
|
|
236
|
+
&& left.ino === right.ino
|
|
237
|
+
&& left.birthtimeNs === right.birthtimeNs
|
|
238
|
+
&& left.uid === right.uid
|
|
239
|
+
&& left.mode === right.mode;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function exactPrivateDirectory(
|
|
243
|
+
path: string,
|
|
244
|
+
label: string,
|
|
245
|
+
uid: bigint,
|
|
246
|
+
): Promise<DirectoryIdentity> {
|
|
247
|
+
const observed = await exactRealDirectory(path, label, uid);
|
|
248
|
+
if ((observed.mode & 0o777n) !== 0o700n) {
|
|
249
|
+
return fail(`${label} must be private mode 0700`);
|
|
250
|
+
}
|
|
251
|
+
return observed;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function assertBoundDirectory(
|
|
255
|
+
path: string,
|
|
256
|
+
expected: DirectoryIdentity,
|
|
257
|
+
label: string,
|
|
258
|
+
uid: bigint,
|
|
259
|
+
privateMode: boolean,
|
|
260
|
+
): Promise<void> {
|
|
261
|
+
const observed = privateMode
|
|
262
|
+
? await exactPrivateDirectory(path, label, uid)
|
|
263
|
+
: await exactRealDirectory(path, label, uid);
|
|
264
|
+
if (!sameDirectoryIdentity(observed, expected)) {
|
|
265
|
+
return fail(`${label} changed filesystem identity during the export`);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async function assertSnapshotDirectory(
|
|
270
|
+
snapshot: SnapshotDirectory,
|
|
271
|
+
uid: bigint,
|
|
272
|
+
): Promise<void> {
|
|
273
|
+
await assertBoundDirectory(
|
|
274
|
+
snapshot.parentPath,
|
|
275
|
+
snapshot.parentIdentity,
|
|
276
|
+
"the private snapshot parent",
|
|
277
|
+
uid,
|
|
278
|
+
true,
|
|
279
|
+
);
|
|
280
|
+
await assertBoundDirectory(
|
|
281
|
+
snapshot.path,
|
|
282
|
+
snapshot.identity,
|
|
283
|
+
"the private snapshot root",
|
|
284
|
+
uid,
|
|
285
|
+
true,
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function assertSnapshotDirectorySync(
|
|
290
|
+
snapshot: SnapshotDirectory,
|
|
291
|
+
uid: bigint,
|
|
292
|
+
): void {
|
|
293
|
+
for (const [path, expected, label] of [
|
|
294
|
+
[snapshot.parentPath, snapshot.parentIdentity, "the private snapshot parent"],
|
|
295
|
+
[snapshot.path, snapshot.identity, "the private snapshot root"],
|
|
296
|
+
] as const) {
|
|
297
|
+
let metadata: BigIntStats;
|
|
298
|
+
let resolved: string;
|
|
299
|
+
try {
|
|
300
|
+
metadata = lstatSync(path, { bigint: true });
|
|
301
|
+
resolved = realpathSync(path);
|
|
302
|
+
} catch {
|
|
303
|
+
return fail(`${label} is unavailable`);
|
|
304
|
+
}
|
|
305
|
+
const observed = Object.freeze({
|
|
306
|
+
kind: "directory" as const,
|
|
307
|
+
dev: metadata.dev,
|
|
308
|
+
ino: metadata.ino,
|
|
309
|
+
birthtimeNs: metadata.birthtimeNs,
|
|
310
|
+
uid: metadata.uid,
|
|
311
|
+
mode: metadata.mode,
|
|
312
|
+
});
|
|
313
|
+
if (
|
|
314
|
+
!metadata.isDirectory()
|
|
315
|
+
|| metadata.isSymbolicLink()
|
|
316
|
+
|| metadata.uid !== uid
|
|
317
|
+
|| resolved !== path
|
|
318
|
+
|| (metadata.mode & 0o777n) !== 0o700n
|
|
319
|
+
|| !sameDirectoryIdentity(observed, expected)
|
|
320
|
+
) return fail(`${label} changed filesystem identity during the export`);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function identity(metadata: BigIntStats): FileIdentity {
|
|
325
|
+
if (!metadata.isFile() || metadata.isSymbolicLink()) {
|
|
326
|
+
return fail("a database descriptor is not a regular file");
|
|
327
|
+
}
|
|
328
|
+
return Object.freeze({
|
|
329
|
+
kind: "regular-file" as const,
|
|
330
|
+
dev: metadata.dev,
|
|
331
|
+
ino: metadata.ino,
|
|
332
|
+
birthtimeNs: metadata.birthtimeNs,
|
|
333
|
+
mode: metadata.mode,
|
|
334
|
+
nlink: metadata.nlink,
|
|
335
|
+
uid: metadata.uid,
|
|
336
|
+
size: metadata.size,
|
|
337
|
+
mtimeNs: metadata.mtimeNs,
|
|
338
|
+
ctimeNs: metadata.ctimeNs,
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function sameExactIdentity(left: FileIdentity, right: FileIdentity): boolean {
|
|
343
|
+
return sameStableIdentity(left, right)
|
|
344
|
+
&& left.size === right.size
|
|
345
|
+
&& left.mtimeNs === right.mtimeNs
|
|
346
|
+
&& left.ctimeNs === right.ctimeNs;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function libraryRealmSha256(identity: DirectoryIdentity): string {
|
|
350
|
+
return sha256(canonicalJson({
|
|
351
|
+
domain: "wrench.apple-photos.library-realm.v1",
|
|
352
|
+
device: identity.dev.toString(),
|
|
353
|
+
inode: identity.ino.toString(),
|
|
354
|
+
birthtimeNs: identity.birthtimeNs.toString(),
|
|
355
|
+
}));
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function sameStableIdentity(left: FileIdentity, right: FileIdentity): boolean {
|
|
359
|
+
return left.kind === right.kind
|
|
360
|
+
&& left.dev === right.dev
|
|
361
|
+
&& left.ino === right.ino
|
|
362
|
+
&& left.birthtimeNs === right.birthtimeNs
|
|
363
|
+
&& left.mode === right.mode
|
|
364
|
+
&& left.nlink === right.nlink
|
|
365
|
+
&& left.uid === right.uid;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async function ownedSourceFile(
|
|
369
|
+
path: string,
|
|
370
|
+
uid: bigint,
|
|
371
|
+
maximumBytes: number,
|
|
372
|
+
): Promise<SourceFile> {
|
|
373
|
+
let metadata: BigIntStats;
|
|
374
|
+
try {
|
|
375
|
+
metadata = await lstat(path, { bigint: true });
|
|
376
|
+
} catch {
|
|
377
|
+
return fail("a source database file could not be inspected");
|
|
378
|
+
}
|
|
379
|
+
if (
|
|
380
|
+
!metadata.isFile()
|
|
381
|
+
|| metadata.isSymbolicLink()
|
|
382
|
+
|| metadata.uid !== uid
|
|
383
|
+
|| metadata.nlink !== 1n
|
|
384
|
+
|| metadata.size < BigInt(SQLITE_HEADER_BYTES)
|
|
385
|
+
|| metadata.size > BigInt(maximumBytes)
|
|
386
|
+
) return fail("a source database file failed type, owner, hardlink, or size validation");
|
|
387
|
+
return Object.freeze({ path, identity: identity(metadata) });
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async function sourcePathStillMatches(
|
|
391
|
+
file: SourceFile,
|
|
392
|
+
uid: bigint,
|
|
393
|
+
maximumBytes: number,
|
|
394
|
+
): Promise<boolean> {
|
|
395
|
+
try {
|
|
396
|
+
const current = await lstat(file.path, { bigint: true });
|
|
397
|
+
return current.isFile()
|
|
398
|
+
&& !current.isSymbolicLink()
|
|
399
|
+
&& current.uid === uid
|
|
400
|
+
&& current.nlink === 1n
|
|
401
|
+
&& current.size >= BigInt(SQLITE_HEADER_BYTES)
|
|
402
|
+
&& current.size <= BigInt(maximumBytes)
|
|
403
|
+
&& sameStableIdentity(file.identity, identity(current));
|
|
404
|
+
} catch {
|
|
405
|
+
return false;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
async function capturedFileIdentity(
|
|
410
|
+
path: string,
|
|
411
|
+
uid: bigint,
|
|
412
|
+
maximumBytes: number,
|
|
413
|
+
): Promise<FileIdentity> {
|
|
414
|
+
const handle = await open(path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
|
|
415
|
+
try {
|
|
416
|
+
await handle.chmod(0o600);
|
|
417
|
+
await handle.sync();
|
|
418
|
+
const metadata = await handle.stat({ bigint: true });
|
|
419
|
+
if (
|
|
420
|
+
!metadata.isFile()
|
|
421
|
+
|| metadata.isSymbolicLink()
|
|
422
|
+
|| metadata.uid !== uid
|
|
423
|
+
|| metadata.nlink !== 1n
|
|
424
|
+
|| (metadata.mode & 0o777n) !== 0o600n
|
|
425
|
+
|| metadata.size < BigInt(SQLITE_HEADER_BYTES)
|
|
426
|
+
|| metadata.size > BigInt(maximumBytes)
|
|
427
|
+
) return fail("a captured database failed private file validation");
|
|
428
|
+
return identity(metadata);
|
|
429
|
+
} finally {
|
|
430
|
+
await handle.close();
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
async function captureSqliteDatabase(
|
|
435
|
+
sourceMainPath: string,
|
|
436
|
+
snapshotRoot: SnapshotDirectory,
|
|
437
|
+
snapshotStem: string,
|
|
438
|
+
mainMaximum: number,
|
|
439
|
+
uid: bigint,
|
|
440
|
+
role: CaptureRole,
|
|
441
|
+
signal: AbortSignal | undefined,
|
|
442
|
+
afterCaptured: ApplePhotosLocalSourceDependencies["afterDatabaseCaptured"],
|
|
443
|
+
now: () => Date,
|
|
444
|
+
): Promise<SnapshotDatabase> {
|
|
445
|
+
const destination = join(snapshotRoot.path, snapshotStem);
|
|
446
|
+
abortIfRequested(signal);
|
|
447
|
+
await assertSnapshotDirectory(snapshotRoot, uid);
|
|
448
|
+
await rm(destination, { force: true });
|
|
449
|
+
await assertSnapshotDirectory(snapshotRoot, uid);
|
|
450
|
+
const source = await ownedSourceFile(sourceMainPath, uid, mainMaximum);
|
|
451
|
+
const sourceHandle = await open(
|
|
452
|
+
source.path,
|
|
453
|
+
fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW,
|
|
454
|
+
);
|
|
455
|
+
let capturedIdentity: FileIdentity | undefined;
|
|
456
|
+
const startedAt = now().toISOString();
|
|
457
|
+
try {
|
|
458
|
+
const opened = identity(await sourceHandle.stat({ bigint: true }));
|
|
459
|
+
if (!sameStableIdentity(opened, source.identity)) {
|
|
460
|
+
return fail("the source database changed identity before capture");
|
|
461
|
+
}
|
|
462
|
+
const sourceDatabase = new Database(source.path, {
|
|
463
|
+
readonly: true,
|
|
464
|
+
strict: true,
|
|
465
|
+
});
|
|
466
|
+
try {
|
|
467
|
+
sourceDatabase.exec("PRAGMA trusted_schema = OFF");
|
|
468
|
+
sourceDatabase.exec("PRAGMA foreign_keys = OFF");
|
|
469
|
+
sourceDatabase.query("VACUUM INTO ?1").run(destination);
|
|
470
|
+
} finally {
|
|
471
|
+
sourceDatabase.close(false);
|
|
472
|
+
}
|
|
473
|
+
capturedIdentity = await capturedFileIdentity(destination, uid, mainMaximum);
|
|
474
|
+
const captured = new Database(destination, { readonly: true, strict: true });
|
|
475
|
+
try {
|
|
476
|
+
captured.exec("PRAGMA trusted_schema = OFF");
|
|
477
|
+
validateQuickCheck(captured, "captured SQLite database");
|
|
478
|
+
captured.exec("PRAGMA query_only = ON");
|
|
479
|
+
captured.exec("PRAGMA foreign_keys = OFF");
|
|
480
|
+
} finally {
|
|
481
|
+
captured.close(false);
|
|
482
|
+
}
|
|
483
|
+
await afterCaptured?.(
|
|
484
|
+
role,
|
|
485
|
+
APPLE_PHOTOS_CONTACT_EVIDENCE_LIMITS.captureAttemptsPerDatabase,
|
|
486
|
+
);
|
|
487
|
+
abortIfRequested(signal);
|
|
488
|
+
await assertSnapshotDirectory(snapshotRoot, uid);
|
|
489
|
+
const descriptorStable = sameStableIdentity(
|
|
490
|
+
identity(await sourceHandle.stat({ bigint: true })),
|
|
491
|
+
source.identity,
|
|
492
|
+
);
|
|
493
|
+
const pathStable = await sourcePathStillMatches(source, uid, mainMaximum);
|
|
494
|
+
if (!descriptorStable || !pathStable) {
|
|
495
|
+
return fail("the source database changed filesystem identity during capture");
|
|
496
|
+
}
|
|
497
|
+
const afterValidation = identity(await lstat(destination, { bigint: true }));
|
|
498
|
+
if (!sameExactIdentity(capturedIdentity, afterValidation)) {
|
|
499
|
+
return fail("the captured database changed after integrity validation");
|
|
500
|
+
}
|
|
501
|
+
return Object.freeze({
|
|
502
|
+
mainPath: destination,
|
|
503
|
+
capture: Object.freeze({
|
|
504
|
+
startedAt,
|
|
505
|
+
finishedAt: now().toISOString(),
|
|
506
|
+
}),
|
|
507
|
+
});
|
|
508
|
+
} finally {
|
|
509
|
+
await sourceHandle.close().catch(() => undefined);
|
|
510
|
+
if (capturedIdentity === undefined) {
|
|
511
|
+
await assertSnapshotDirectory(snapshotRoot, uid);
|
|
512
|
+
await rm(destination, { force: true });
|
|
513
|
+
await assertSnapshotDirectory(snapshotRoot, uid);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
async function latestContactDatabaseInDirectory(
|
|
519
|
+
directory: string,
|
|
520
|
+
uid: bigint,
|
|
521
|
+
): Promise<string | null> {
|
|
522
|
+
await exactRealDirectory(directory, "an Apple Contacts store directory", uid);
|
|
523
|
+
const entries = await opendir(directory);
|
|
524
|
+
let scanned = 0;
|
|
525
|
+
let latest: Readonly<{ path: string; version: number }> | null = null;
|
|
526
|
+
for await (const entry of entries) {
|
|
527
|
+
scanned += 1;
|
|
528
|
+
if (scanned > APPLE_PHOTOS_CONTACT_EVIDENCE_LIMITS.maximumDirectoryEntries) {
|
|
529
|
+
return fail("an Apple Contacts store directory exceeds the reviewed entry bound");
|
|
530
|
+
}
|
|
531
|
+
const match = CONTACT_DATABASE_PATTERN.exec(entry.name);
|
|
532
|
+
if (match === null) continue;
|
|
533
|
+
if (!entry.isFile()) return fail("an Apple Contacts database candidate is not a regular directory entry");
|
|
534
|
+
const version = Number(match[1]);
|
|
535
|
+
if (!Number.isSafeInteger(version)) return fail("an Apple Contacts database version is malformed");
|
|
536
|
+
if (latest === null || version > latest.version) {
|
|
537
|
+
latest = Object.freeze({ path: join(directory, entry.name), version });
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
return latest?.path ?? null;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
async function discoverContactDatabases(
|
|
544
|
+
home: string,
|
|
545
|
+
uid: bigint,
|
|
546
|
+
): Promise<readonly string[]> {
|
|
547
|
+
const root = join(home, CONTACTS_RELATIVE_ROOT);
|
|
548
|
+
await exactRealDirectory(root, "the Apple Contacts data root", uid);
|
|
549
|
+
const candidates: string[] = [];
|
|
550
|
+
const primary = await latestContactDatabaseInDirectory(root, uid);
|
|
551
|
+
if (primary !== null) candidates.push(primary);
|
|
552
|
+
const sourcesRoot = join(root, "Sources");
|
|
553
|
+
const sourceNames: string[] = [];
|
|
554
|
+
try {
|
|
555
|
+
await exactRealDirectory(sourcesRoot, "the Apple Contacts Sources directory", uid);
|
|
556
|
+
const entries = await opendir(sourcesRoot);
|
|
557
|
+
let scanned = 0;
|
|
558
|
+
for await (const entry of entries) {
|
|
559
|
+
scanned += 1;
|
|
560
|
+
if (scanned > APPLE_PHOTOS_CONTACT_EVIDENCE_LIMITS.maximumDirectoryEntries) {
|
|
561
|
+
return fail("the Apple Contacts Sources directory exceeds the reviewed entry bound");
|
|
562
|
+
}
|
|
563
|
+
if (!CONTACT_SOURCE_DIRECTORY_PATTERN.test(entry.name)) continue;
|
|
564
|
+
if (!entry.isDirectory()) {
|
|
565
|
+
return fail("an Apple Contacts source identifier is not a directory");
|
|
566
|
+
}
|
|
567
|
+
sourceNames.push(entry.name);
|
|
568
|
+
if (
|
|
569
|
+
sourceNames.length
|
|
570
|
+
> APPLE_PHOTOS_CONTACT_EVIDENCE_LIMITS.maximumContactsSourceDirectories
|
|
571
|
+
) return fail("Apple Contacts source directories exceed the reviewed bound");
|
|
572
|
+
}
|
|
573
|
+
} catch (error) {
|
|
574
|
+
if (
|
|
575
|
+
!(error instanceof Error)
|
|
576
|
+
|| error.message !== "Apple Photos local source: the Apple Contacts Sources directory is unavailable"
|
|
577
|
+
) throw error;
|
|
578
|
+
}
|
|
579
|
+
sourceNames.sort((left, right) => Buffer.compare(
|
|
580
|
+
Buffer.from(left, "utf8"),
|
|
581
|
+
Buffer.from(right, "utf8"),
|
|
582
|
+
));
|
|
583
|
+
for (const sourceName of sourceNames) {
|
|
584
|
+
const candidate = await latestContactDatabaseInDirectory(
|
|
585
|
+
join(sourcesRoot, sourceName),
|
|
586
|
+
uid,
|
|
587
|
+
);
|
|
588
|
+
if (candidate !== null) candidates.push(candidate);
|
|
589
|
+
}
|
|
590
|
+
candidates.sort((left, right) => Buffer.compare(
|
|
591
|
+
Buffer.from(left, "utf8"),
|
|
592
|
+
Buffer.from(right, "utf8"),
|
|
593
|
+
));
|
|
594
|
+
if (candidates.length < 1) return fail("no Apple Contacts database is available");
|
|
595
|
+
if (candidates.length > APPLE_PHOTOS_CONTACT_EVIDENCE_LIMITS.maximumContactsDatabases) {
|
|
596
|
+
return fail("the Apple Contacts database count exceeds the reviewed bound");
|
|
597
|
+
}
|
|
598
|
+
const identities = new Set<string>();
|
|
599
|
+
for (const candidate of candidates) {
|
|
600
|
+
const metadata = await lstat(candidate, { bigint: true });
|
|
601
|
+
const key = `${metadata.dev.toString()}:${metadata.ino.toString()}`;
|
|
602
|
+
if (identities.has(key)) return fail("one Apple Contacts database is reachable through multiple hardlinks");
|
|
603
|
+
identities.add(key);
|
|
604
|
+
}
|
|
605
|
+
return Object.freeze(candidates);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function databaseRow(value: unknown, label: string): Readonly<Record<string, unknown>> {
|
|
609
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
610
|
+
return fail(`${label} is not a SQLite row`);
|
|
611
|
+
}
|
|
612
|
+
return value as Readonly<Record<string, unknown>>;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function integerField(value: unknown, label: string, maximum: number): number {
|
|
616
|
+
if (
|
|
617
|
+
typeof value !== "number"
|
|
618
|
+
|| !Number.isSafeInteger(value)
|
|
619
|
+
|| value < 0
|
|
620
|
+
|| value > maximum
|
|
621
|
+
) return fail(`${label} is outside its reviewed integer bound`);
|
|
622
|
+
return value;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function validateQuickCheck(database: Database, label: string): void {
|
|
626
|
+
const quickCheck = database.query("PRAGMA quick_check(1)").get() as unknown;
|
|
627
|
+
const quickCheckRow = databaseRow(quickCheck, `${label} quick check`);
|
|
628
|
+
if (Object.values(quickCheckRow).length !== 1 || Object.values(quickCheckRow)[0] !== "ok") {
|
|
629
|
+
return fail(`${label} failed SQLite quick_check`);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function validateSchema(
|
|
634
|
+
database: Database,
|
|
635
|
+
contract: SchemaContract,
|
|
636
|
+
expectedSha256: string,
|
|
637
|
+
label: string,
|
|
638
|
+
): void {
|
|
639
|
+
const observed: Record<string, Record<string, unknown>> = Object.create(null);
|
|
640
|
+
for (const [table, columns] of Object.entries(contract)) {
|
|
641
|
+
const schemaRows = database.query(
|
|
642
|
+
"SELECT type, name FROM sqlite_schema WHERE name = ?1 ORDER BY type, name",
|
|
643
|
+
).all(table) as unknown[];
|
|
644
|
+
if (
|
|
645
|
+
schemaRows.length !== 1
|
|
646
|
+
|| databaseRow(schemaRows[0], `${label} schema object`).type !== "table"
|
|
647
|
+
|| databaseRow(schemaRows[0], `${label} schema object`).name !== table
|
|
648
|
+
) return fail(`${label} required table ${table} drifted`);
|
|
649
|
+
const tableRows = database.query(`PRAGMA table_xinfo('${table}')`).all() as unknown[];
|
|
650
|
+
const byName = new Map<string, Readonly<Record<string, unknown>>>();
|
|
651
|
+
for (const raw of tableRows) {
|
|
652
|
+
const row = databaseRow(raw, `${label} ${table} column`);
|
|
653
|
+
if (typeof row.name !== "string" || byName.has(row.name)) {
|
|
654
|
+
return fail(`${label} table ${table} has malformed columns`);
|
|
655
|
+
}
|
|
656
|
+
byName.set(row.name, row);
|
|
657
|
+
}
|
|
658
|
+
const selected: Record<string, unknown> = Object.create(null);
|
|
659
|
+
for (const [name, expected] of Object.entries(columns)) {
|
|
660
|
+
const row = byName.get(name);
|
|
661
|
+
if (row === undefined) return fail(`${label} table ${table} lost column ${name}`);
|
|
662
|
+
const actual = Object.freeze({
|
|
663
|
+
type: row.type,
|
|
664
|
+
notnull: row.notnull,
|
|
665
|
+
defaultValue: row.dflt_value,
|
|
666
|
+
pk: row.pk,
|
|
667
|
+
hidden: row.hidden,
|
|
668
|
+
});
|
|
669
|
+
if (canonicalJson(actual) !== canonicalJson(expected)) {
|
|
670
|
+
return fail(`${label} table ${table} column ${name} drifted`);
|
|
671
|
+
}
|
|
672
|
+
selected[name] = actual;
|
|
673
|
+
}
|
|
674
|
+
observed[table] = selected;
|
|
675
|
+
}
|
|
676
|
+
if (sha256(canonicalJson(observed)) !== expectedSha256) {
|
|
677
|
+
return fail(`${label} relevant Core Data schema fingerprint drifted`);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function configureReadOnlyDatabase(database: Database): void {
|
|
682
|
+
database.exec("PRAGMA trusted_schema = OFF");
|
|
683
|
+
database.exec("PRAGMA query_only = ON");
|
|
684
|
+
database.exec("PRAGMA foreign_keys = OFF");
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
function providerIdentifier(value: unknown, label: string, maximum: number): string {
|
|
688
|
+
if (
|
|
689
|
+
typeof value !== "string"
|
|
690
|
+
|| value.length < 1
|
|
691
|
+
|| Buffer.byteLength(value, "utf8") > maximum
|
|
692
|
+
) return fail(`${label} is malformed`);
|
|
693
|
+
return value;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function exportedProviderIdentifier(
|
|
697
|
+
value: unknown,
|
|
698
|
+
label: string,
|
|
699
|
+
maximum: number,
|
|
700
|
+
): string {
|
|
701
|
+
const parsed = providerIdentifier(value, label, maximum);
|
|
702
|
+
if (!/^[A-Za-z0-9._:-]+$/u.test(parsed)) {
|
|
703
|
+
return fail(`${label} contains characters outside the reviewed output alphabet`);
|
|
704
|
+
}
|
|
705
|
+
return parsed;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function coreDataTimestamp(value: unknown, label: string): string | null {
|
|
709
|
+
if (value === null) return null;
|
|
710
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
711
|
+
return fail(`${label} is not a finite Core Data timestamp`);
|
|
712
|
+
}
|
|
713
|
+
const unixMilliseconds = (value + 978_307_200) * 1_000;
|
|
714
|
+
if (!Number.isSafeInteger(Math.trunc(unixMilliseconds))) {
|
|
715
|
+
return fail(`${label} exceeds the supported timestamp range`);
|
|
716
|
+
}
|
|
717
|
+
const date = new Date(unixMilliseconds);
|
|
718
|
+
if (!Number.isFinite(date.getTime())) return fail(`${label} is outside the Date range`);
|
|
719
|
+
const rendered = date.toISOString();
|
|
720
|
+
if (!/^\d{4}-/u.test(rendered)) return fail(`${label} is outside years 0000 through 9999`);
|
|
721
|
+
return rendered;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
function loadContactIds(
|
|
725
|
+
snapshots: readonly SnapshotDatabase[],
|
|
726
|
+
snapshotRoot: SnapshotDirectory,
|
|
727
|
+
uid: bigint,
|
|
728
|
+
): Set<string> {
|
|
729
|
+
const ids = new Set<string>();
|
|
730
|
+
let scanned = 0;
|
|
731
|
+
for (const snapshot of snapshots) {
|
|
732
|
+
assertSnapshotDirectorySync(snapshotRoot, uid);
|
|
733
|
+
const database = new Database(snapshot.mainPath, {
|
|
734
|
+
readonly: true,
|
|
735
|
+
strict: true,
|
|
736
|
+
});
|
|
737
|
+
try {
|
|
738
|
+
assertSnapshotDirectorySync(snapshotRoot, uid);
|
|
739
|
+
configureReadOnlyDatabase(database);
|
|
740
|
+
validateSchema(
|
|
741
|
+
database,
|
|
742
|
+
CONTACTS_SCHEMA_CONTRACT,
|
|
743
|
+
APPLE_CONTACTS_SCHEMA_SHA256,
|
|
744
|
+
"Apple Contacts",
|
|
745
|
+
);
|
|
746
|
+
const remaining = APPLE_PHOTOS_CONTACT_EVIDENCE_LIMITS.maximumContacts
|
|
747
|
+
- scanned;
|
|
748
|
+
const rows = database.query(
|
|
749
|
+
"SELECT ZUNIQUEID AS contactId FROM ZABCDRECORD WHERE ZUNIQUEID IS NOT NULL ORDER BY ZUNIQUEID COLLATE BINARY LIMIT ?1",
|
|
750
|
+
).all(remaining + 1) as unknown[];
|
|
751
|
+
if (rows.length > remaining) return fail("Apple Contacts rows exceed the reviewed bound");
|
|
752
|
+
scanned += rows.length;
|
|
753
|
+
for (const [index, raw] of rows.entries()) {
|
|
754
|
+
const row = databaseRow(raw, `Apple Contacts row ${String(index)}`);
|
|
755
|
+
ids.add(providerIdentifier(row.contactId, "an Apple Contacts identifier", 4_096));
|
|
756
|
+
}
|
|
757
|
+
assertSnapshotDirectorySync(snapshotRoot, uid);
|
|
758
|
+
} finally {
|
|
759
|
+
database.close(false);
|
|
760
|
+
assertSnapshotDirectorySync(snapshotRoot, uid);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
return ids;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
function queryPhotosEvidence(
|
|
767
|
+
snapshot: SnapshotDatabase,
|
|
768
|
+
contactIds: ReadonlySet<string>,
|
|
769
|
+
snapshotRoot: SnapshotDirectory,
|
|
770
|
+
uid: bigint,
|
|
771
|
+
): readonly ApplePhotosContactEvidence[] {
|
|
772
|
+
assertSnapshotDirectorySync(snapshotRoot, uid);
|
|
773
|
+
const database = new Database(snapshot.mainPath, {
|
|
774
|
+
readonly: true,
|
|
775
|
+
strict: true,
|
|
776
|
+
});
|
|
777
|
+
try {
|
|
778
|
+
assertSnapshotDirectorySync(snapshotRoot, uid);
|
|
779
|
+
configureReadOnlyDatabase(database);
|
|
780
|
+
validateSchema(
|
|
781
|
+
database,
|
|
782
|
+
PHOTOS_SCHEMA_CONTRACT,
|
|
783
|
+
APPLE_PHOTOS_SCHEMA_SHA256,
|
|
784
|
+
"Apple Photos",
|
|
785
|
+
);
|
|
786
|
+
const rows = database.query(`
|
|
787
|
+
SELECT
|
|
788
|
+
p.ZPERSONUUID AS photosPersonId,
|
|
789
|
+
p.ZPERSONURI AS appleContactId,
|
|
790
|
+
COUNT(DISTINCT f.Z_PK) AS linkedFaceCount,
|
|
791
|
+
COUNT(DISTINCT a.Z_PK) AS linkedAssetCount,
|
|
792
|
+
MIN(a.ZDATECREATED) AS firstAssetAt,
|
|
793
|
+
MAX(a.ZDATECREATED) AS lastAssetAt
|
|
794
|
+
FROM ZPERSON AS p
|
|
795
|
+
LEFT JOIN ZDETECTEDFACE AS f ON f.ZPERSONFORFACE = p.Z_PK
|
|
796
|
+
LEFT JOIN ZASSET AS a ON a.Z_PK = f.ZASSETFORFACE
|
|
797
|
+
WHERE p.ZPERSONUUID IS NOT NULL AND p.ZPERSONURI IS NOT NULL
|
|
798
|
+
GROUP BY p.Z_PK, p.ZPERSONUUID, p.ZPERSONURI
|
|
799
|
+
ORDER BY p.ZPERSONURI COLLATE BINARY, p.ZPERSONUUID COLLATE BINARY
|
|
800
|
+
LIMIT ?1
|
|
801
|
+
`).all(APPLE_PHOTOS_CONTACT_EVIDENCE_LIMITS.maximumPeople + 1) as unknown[];
|
|
802
|
+
if (rows.length > APPLE_PHOTOS_CONTACT_EVIDENCE_LIMITS.maximumPeople) {
|
|
803
|
+
return fail("Apple Photos matched-person candidates exceed the reviewed bound");
|
|
804
|
+
}
|
|
805
|
+
const evidence: ApplePhotosContactEvidence[] = [];
|
|
806
|
+
for (const [index, raw] of rows.entries()) {
|
|
807
|
+
const row = databaseRow(raw, `Apple Photos person row ${String(index)}`);
|
|
808
|
+
const contactId = providerIdentifier(
|
|
809
|
+
row.appleContactId,
|
|
810
|
+
"a Photos contact identifier",
|
|
811
|
+
4_096,
|
|
812
|
+
);
|
|
813
|
+
if (!contactIds.has(contactId)) continue;
|
|
814
|
+
const firstAssetAt = coreDataTimestamp(
|
|
815
|
+
row.firstAssetAt,
|
|
816
|
+
"the first linked asset date",
|
|
817
|
+
);
|
|
818
|
+
const lastAssetAt = coreDataTimestamp(
|
|
819
|
+
row.lastAssetAt,
|
|
820
|
+
"the last linked asset date",
|
|
821
|
+
);
|
|
822
|
+
if ((firstAssetAt === null) !== (lastAssetAt === null)) {
|
|
823
|
+
return fail("linked asset date bounds are incomplete");
|
|
824
|
+
}
|
|
825
|
+
evidence.push(Object.freeze({
|
|
826
|
+
photosPersonId: exportedProviderIdentifier(
|
|
827
|
+
row.photosPersonId,
|
|
828
|
+
"a Photos person identifier",
|
|
829
|
+
128,
|
|
830
|
+
),
|
|
831
|
+
appleContactId: exportedProviderIdentifier(
|
|
832
|
+
contactId,
|
|
833
|
+
"a matched Apple Contacts identifier",
|
|
834
|
+
512,
|
|
835
|
+
),
|
|
836
|
+
linkedFaceCount: integerField(
|
|
837
|
+
row.linkedFaceCount,
|
|
838
|
+
"a linked face count",
|
|
839
|
+
10_000_000,
|
|
840
|
+
),
|
|
841
|
+
linkedAssetCount: integerField(
|
|
842
|
+
row.linkedAssetCount,
|
|
843
|
+
"a linked asset count",
|
|
844
|
+
10_000_000,
|
|
845
|
+
),
|
|
846
|
+
firstAssetAt,
|
|
847
|
+
lastAssetAt,
|
|
848
|
+
}));
|
|
849
|
+
}
|
|
850
|
+
assertSnapshotDirectorySync(snapshotRoot, uid);
|
|
851
|
+
return Object.freeze(evidence);
|
|
852
|
+
} finally {
|
|
853
|
+
database.close(false);
|
|
854
|
+
assertSnapshotDirectorySync(snapshotRoot, uid);
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
async function hashSnapshotDatabases(
|
|
859
|
+
photos: SnapshotDatabase,
|
|
860
|
+
contacts: readonly SnapshotDatabase[],
|
|
861
|
+
snapshotRoot: SnapshotDirectory,
|
|
862
|
+
uid: bigint,
|
|
863
|
+
signal: AbortSignal | undefined,
|
|
864
|
+
): Promise<string> {
|
|
865
|
+
const hash = createHash("sha256");
|
|
866
|
+
const groups = [photos, ...contacts];
|
|
867
|
+
for (const [databaseIndex, database] of groups.entries()) {
|
|
868
|
+
hash.update(databaseIndex === 0
|
|
869
|
+
? "apple-photos\0"
|
|
870
|
+
: `apple-contacts-${String(databaseIndex - 1).padStart(2, "0")}\0`);
|
|
871
|
+
abortIfRequested(signal);
|
|
872
|
+
await assertSnapshotDirectory(snapshotRoot, uid);
|
|
873
|
+
const before = await capturedFileIdentity(
|
|
874
|
+
database.mainPath,
|
|
875
|
+
uid,
|
|
876
|
+
databaseIndex === 0
|
|
877
|
+
? APPLE_PHOTOS_CONTACT_EVIDENCE_LIMITS.maximumPhotosDatabaseBytes
|
|
878
|
+
: APPLE_PHOTOS_CONTACT_EVIDENCE_LIMITS.maximumContactsDatabaseBytes,
|
|
879
|
+
);
|
|
880
|
+
hash.update("vacuum-capture\0");
|
|
881
|
+
hash.update(`${before.size.toString()}\0`);
|
|
882
|
+
for await (const chunk of createReadStream(database.mainPath)) {
|
|
883
|
+
abortIfRequested(signal);
|
|
884
|
+
hash.update(chunk as Buffer);
|
|
885
|
+
}
|
|
886
|
+
const after = identity(await lstat(database.mainPath, { bigint: true }));
|
|
887
|
+
if (!sameExactIdentity(before, after)) {
|
|
888
|
+
return fail("a captured database changed during generation hashing");
|
|
889
|
+
}
|
|
890
|
+
await assertSnapshotDirectory(snapshotRoot, uid);
|
|
891
|
+
}
|
|
892
|
+
return hash.digest("hex");
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
async function checkedHomeDirectory(
|
|
896
|
+
request: ApplePhotosLocalSourceRequest,
|
|
897
|
+
uid: bigint,
|
|
898
|
+
): Promise<string> {
|
|
899
|
+
const selected = request.dependencies?.resolveAccountHomeDirectory?.()
|
|
900
|
+
?? resolveApplePhotosAccountHomeDirectory();
|
|
901
|
+
const home = normalizedAbsolutePath(selected, "the home directory");
|
|
902
|
+
await exactRealDirectory(home, "the home directory", uid);
|
|
903
|
+
return home;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
/** Resolve the signed-in OS account's home without consulting ambient env. */
|
|
907
|
+
export function resolveApplePhotosAccountHomeDirectory(): string {
|
|
908
|
+
try {
|
|
909
|
+
return userInfo({ encoding: "utf8" }).homedir;
|
|
910
|
+
} catch {
|
|
911
|
+
return fail("the operating system account home directory is unavailable");
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
export async function exportApplePhotosContactEvidence(
|
|
916
|
+
request: ApplePhotosLocalSourceRequest = {},
|
|
917
|
+
): Promise<ApplePhotosContactEvidenceExportResult> {
|
|
918
|
+
const platform = request.dependencies?.platform ?? process.platform;
|
|
919
|
+
if (platform !== "darwin") return fail("this source requires macOS");
|
|
920
|
+
const currentUid = process.getuid?.();
|
|
921
|
+
if (currentUid === undefined) return fail("the current file owner cannot be established");
|
|
922
|
+
const uid = BigInt(currentUid);
|
|
923
|
+
const now = request.dependencies?.now ?? (() => new Date());
|
|
924
|
+
const startedAt = now().toISOString();
|
|
925
|
+
reportProgress(request, { phase: "source-admission" });
|
|
926
|
+
const home = await checkedHomeDirectory(request, uid);
|
|
927
|
+
const library = normalizedAbsolutePath(
|
|
928
|
+
request.library ?? join(home, "Pictures", "Photos Library.photoslibrary"),
|
|
929
|
+
"the Photos library",
|
|
930
|
+
);
|
|
931
|
+
if (!library.endsWith(".photoslibrary")) {
|
|
932
|
+
return fail("the Photos library must have the .photoslibrary suffix");
|
|
933
|
+
}
|
|
934
|
+
const libraryIdentity = await exactRealDirectory(
|
|
935
|
+
library,
|
|
936
|
+
"the Photos library",
|
|
937
|
+
uid,
|
|
938
|
+
);
|
|
939
|
+
const databaseDirectory = join(library, "database");
|
|
940
|
+
const databaseDirectoryIdentity = await exactRealDirectory(
|
|
941
|
+
databaseDirectory,
|
|
942
|
+
"the Photos database directory",
|
|
943
|
+
uid,
|
|
944
|
+
);
|
|
945
|
+
if (!join(databaseDirectory, "Photos.sqlite").startsWith(`${library}${sep}`)) {
|
|
946
|
+
return fail("the Photos database escaped the selected library");
|
|
947
|
+
}
|
|
948
|
+
reportProgress(request, { phase: "contacts-discovery" });
|
|
949
|
+
const contacts = await discoverContactDatabases(home, uid);
|
|
950
|
+
const explicitTemporary = request.dependencies?.temporaryDirectory;
|
|
951
|
+
const selectedTemporary = normalizedAbsolutePath(
|
|
952
|
+
explicitTemporary ?? tmpdir(),
|
|
953
|
+
"the temporary directory",
|
|
954
|
+
);
|
|
955
|
+
const temporaryBase = explicitTemporary === undefined
|
|
956
|
+
? normalizedAbsolutePath(
|
|
957
|
+
await realpath(selectedTemporary).catch(() =>
|
|
958
|
+
fail("the temporary directory is unavailable")),
|
|
959
|
+
"the physical temporary directory",
|
|
960
|
+
)
|
|
961
|
+
: selectedTemporary;
|
|
962
|
+
await exactPrivateDirectory(
|
|
963
|
+
temporaryBase,
|
|
964
|
+
"the temporary directory",
|
|
965
|
+
uid,
|
|
966
|
+
);
|
|
967
|
+
const snapshotRootPath = await mkdtemp(join(temporaryBase, "wrench-apple-photos-"));
|
|
968
|
+
const snapshotParentIdentity = await exactPrivateDirectory(
|
|
969
|
+
temporaryBase,
|
|
970
|
+
"the temporary directory",
|
|
971
|
+
uid,
|
|
972
|
+
);
|
|
973
|
+
const snapshotRoot: SnapshotDirectory = Object.freeze({
|
|
974
|
+
path: snapshotRootPath,
|
|
975
|
+
parentPath: temporaryBase,
|
|
976
|
+
identity: await exactPrivateDirectory(
|
|
977
|
+
snapshotRootPath,
|
|
978
|
+
"the private snapshot root",
|
|
979
|
+
uid,
|
|
980
|
+
),
|
|
981
|
+
parentIdentity: snapshotParentIdentity,
|
|
982
|
+
});
|
|
983
|
+
let snapshotLease: BeeperMessageLikeMeDirectoryLease | undefined;
|
|
984
|
+
try {
|
|
985
|
+
await assertSnapshotDirectory(snapshotRoot, uid);
|
|
986
|
+
if (request.stateEnvironment !== undefined) {
|
|
987
|
+
const createdAtMs = Date.now();
|
|
988
|
+
snapshotLease = await createBeeperMessageLikeMeDirectoryLease({
|
|
989
|
+
role: "raw-working",
|
|
990
|
+
path: snapshotRoot.path,
|
|
991
|
+
recoverAfterMs: createdAtMs,
|
|
992
|
+
nowMs: createdAtMs,
|
|
993
|
+
environment: request.stateEnvironment,
|
|
994
|
+
});
|
|
995
|
+
updateBeeperMessageLikeMeDirectoryLease(snapshotLease, "launching");
|
|
996
|
+
updateBeeperMessageLikeMeDirectoryLease(
|
|
997
|
+
snapshotLease,
|
|
998
|
+
"running",
|
|
999
|
+
process.pid,
|
|
1000
|
+
);
|
|
1001
|
+
}
|
|
1002
|
+
reportProgress(request, { phase: "photos-capture" });
|
|
1003
|
+
const photosSnapshot = await captureSqliteDatabase(
|
|
1004
|
+
join(library, PHOTOS_DATABASE_RELATIVE_PATH),
|
|
1005
|
+
snapshotRoot,
|
|
1006
|
+
"photos.sqlite",
|
|
1007
|
+
APPLE_PHOTOS_CONTACT_EVIDENCE_LIMITS.maximumPhotosDatabaseBytes,
|
|
1008
|
+
uid,
|
|
1009
|
+
"photos",
|
|
1010
|
+
request.signal,
|
|
1011
|
+
request.dependencies?.afterDatabaseCaptured,
|
|
1012
|
+
now,
|
|
1013
|
+
);
|
|
1014
|
+
const contactSnapshots: SnapshotDatabase[] = [];
|
|
1015
|
+
for (const [index, source] of contacts.entries()) {
|
|
1016
|
+
reportProgress(request, {
|
|
1017
|
+
phase: "contacts-capture",
|
|
1018
|
+
current: index + 1,
|
|
1019
|
+
total: contacts.length,
|
|
1020
|
+
});
|
|
1021
|
+
contactSnapshots.push(await captureSqliteDatabase(
|
|
1022
|
+
source,
|
|
1023
|
+
snapshotRoot,
|
|
1024
|
+
`contacts-${String(index).padStart(2, "0")}.sqlite`,
|
|
1025
|
+
APPLE_PHOTOS_CONTACT_EVIDENCE_LIMITS.maximumContactsDatabaseBytes,
|
|
1026
|
+
uid,
|
|
1027
|
+
"contacts",
|
|
1028
|
+
request.signal,
|
|
1029
|
+
request.dependencies?.afterDatabaseCaptured,
|
|
1030
|
+
now,
|
|
1031
|
+
));
|
|
1032
|
+
}
|
|
1033
|
+
abortIfRequested(request.signal);
|
|
1034
|
+
await assertSnapshotDirectory(snapshotRoot, uid);
|
|
1035
|
+
await assertBoundDirectory(
|
|
1036
|
+
library,
|
|
1037
|
+
libraryIdentity,
|
|
1038
|
+
"the Photos library",
|
|
1039
|
+
uid,
|
|
1040
|
+
false,
|
|
1041
|
+
);
|
|
1042
|
+
await assertBoundDirectory(
|
|
1043
|
+
databaseDirectory,
|
|
1044
|
+
databaseDirectoryIdentity,
|
|
1045
|
+
"the Photos database directory",
|
|
1046
|
+
uid,
|
|
1047
|
+
false,
|
|
1048
|
+
);
|
|
1049
|
+
reportProgress(request, { phase: "evidence-validation" });
|
|
1050
|
+
const contactIds = loadContactIds(contactSnapshots, snapshotRoot, uid);
|
|
1051
|
+
const evidence = queryPhotosEvidence(
|
|
1052
|
+
photosSnapshot,
|
|
1053
|
+
contactIds,
|
|
1054
|
+
snapshotRoot,
|
|
1055
|
+
uid,
|
|
1056
|
+
);
|
|
1057
|
+
reportProgress(request, { phase: "generation-hashing" });
|
|
1058
|
+
const generationSha256 = await hashSnapshotDatabases(
|
|
1059
|
+
photosSnapshot,
|
|
1060
|
+
contactSnapshots,
|
|
1061
|
+
snapshotRoot,
|
|
1062
|
+
uid,
|
|
1063
|
+
request.signal,
|
|
1064
|
+
);
|
|
1065
|
+
await assertBoundDirectory(
|
|
1066
|
+
library,
|
|
1067
|
+
libraryIdentity,
|
|
1068
|
+
"the Photos library",
|
|
1069
|
+
uid,
|
|
1070
|
+
false,
|
|
1071
|
+
);
|
|
1072
|
+
await assertBoundDirectory(
|
|
1073
|
+
databaseDirectory,
|
|
1074
|
+
databaseDirectoryIdentity,
|
|
1075
|
+
"the Photos database directory",
|
|
1076
|
+
uid,
|
|
1077
|
+
false,
|
|
1078
|
+
);
|
|
1079
|
+
const observedAt = now().toISOString();
|
|
1080
|
+
const captureFinishedAt = now().toISOString();
|
|
1081
|
+
const finishedAt = now().toISOString();
|
|
1082
|
+
return createApplePhotosContactEvidenceExportResult({
|
|
1083
|
+
...(request.dependencies?.runId === undefined
|
|
1084
|
+
? {}
|
|
1085
|
+
: { runId: request.dependencies.runId() }),
|
|
1086
|
+
startedAt,
|
|
1087
|
+
finishedAt,
|
|
1088
|
+
observedAt,
|
|
1089
|
+
contactsDatabases: contactSnapshots.length,
|
|
1090
|
+
libraryRealmSha256: libraryRealmSha256(libraryIdentity),
|
|
1091
|
+
generationSha256,
|
|
1092
|
+
photosSchemaSha256: APPLE_PHOTOS_SCHEMA_SHA256,
|
|
1093
|
+
contactsSchemaSha256: APPLE_CONTACTS_SCHEMA_SHA256,
|
|
1094
|
+
capture: Object.freeze({
|
|
1095
|
+
startedAt,
|
|
1096
|
+
finishedAt: captureFinishedAt,
|
|
1097
|
+
photos: photosSnapshot.capture,
|
|
1098
|
+
contacts: Object.freeze(contactSnapshots.map((snapshot, ordinal) =>
|
|
1099
|
+
Object.freeze({ ordinal, ...snapshot.capture }))),
|
|
1100
|
+
consistency: "independent-read-transactions" as const,
|
|
1101
|
+
crossDatabaseAtomicity: "not-asserted" as const,
|
|
1102
|
+
}),
|
|
1103
|
+
evidence,
|
|
1104
|
+
});
|
|
1105
|
+
} catch (error) {
|
|
1106
|
+
if (
|
|
1107
|
+
error instanceof Error
|
|
1108
|
+
&& error.message.startsWith("Apple Photos ")
|
|
1109
|
+
) throw error;
|
|
1110
|
+
throw new Error("Apple Photos local source: private local operation failed");
|
|
1111
|
+
} finally {
|
|
1112
|
+
let removed = false;
|
|
1113
|
+
try {
|
|
1114
|
+
reportProgress(request, { phase: "cleanup" });
|
|
1115
|
+
await assertSnapshotDirectory(snapshotRoot, uid);
|
|
1116
|
+
removed = removePrivateDirectoryTree(snapshotRoot.path, {
|
|
1117
|
+
device: snapshotRoot.identity.dev.toString(),
|
|
1118
|
+
inode: snapshotRoot.identity.ino.toString(),
|
|
1119
|
+
birthtimeNs: snapshotRoot.identity.birthtimeNs.toString(),
|
|
1120
|
+
});
|
|
1121
|
+
if (!removed) return fail("the private snapshot root could not be removed exactly");
|
|
1122
|
+
} catch {
|
|
1123
|
+
return fail("the private snapshot root changed before exact cleanup");
|
|
1124
|
+
} finally {
|
|
1125
|
+
if (removed && snapshotLease !== undefined) {
|
|
1126
|
+
updateBeeperMessageLikeMeDirectoryLease(snapshotLease, "settled");
|
|
1127
|
+
releaseBeeperMessageLikeMeDirectoryLease(snapshotLease);
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
}
|