@hraness/slopcamera 3.2.4
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/LICENSE +21 -0
- package/NOTICE.md +60 -0
- package/PRIVACY.md +46 -0
- package/README.md +311 -0
- package/SECURITY.md +21 -0
- package/apps/desktop/README.md +966 -0
- package/apps/desktop/analysis/protocol.ts +223 -0
- package/apps/desktop/application/capability-binding.ts +594 -0
- package/apps/desktop/application/context.ts +97 -0
- package/apps/desktop/application/creative-iteration.ts +1385 -0
- package/apps/desktop/application/default-registry.ts +146 -0
- package/apps/desktop/application/directing-contract.ts +218 -0
- package/apps/desktop/application/directing-plan.ts +178 -0
- package/apps/desktop/application/errors.ts +18 -0
- package/apps/desktop/application/gateway-port.ts +867 -0
- package/apps/desktop/application/html-overlay-browser-runtime.ts +687 -0
- package/apps/desktop/application/html-overlay-integrity.ts +337 -0
- package/apps/desktop/application/html-overlay-renderer.ts +45 -0
- package/apps/desktop/application/index.ts +13 -0
- package/apps/desktop/application/operation-completion-checkpoint.ts +333 -0
- package/apps/desktop/application/operation-effects.ts +94 -0
- package/apps/desktop/application/operation.ts +224 -0
- package/apps/desktop/application/operations/analysis/faces.ts +244 -0
- package/apps/desktop/application/operations/analysis/music.ts +248 -0
- package/apps/desktop/application/operations/analysis/project-auto-zooms.ts +302 -0
- package/apps/desktop/application/operations/analysis/project-inactivity.ts +381 -0
- package/apps/desktop/application/operations/analysis/scenes.ts +272 -0
- package/apps/desktop/application/operations/analysis/shared.ts +202 -0
- package/apps/desktop/application/operations/derive/edit-batch.ts +967 -0
- package/apps/desktop/application/operations/derive/follow-faces.ts +454 -0
- package/apps/desktop/application/operations/edit/bind-candidate-revision.ts +91 -0
- package/apps/desktop/application/operations/edit/create-candidate-revision.ts +377 -0
- package/apps/desktop/application/operations/edit/create-revision.ts +255 -0
- package/apps/desktop/application/operations/edit/freeze-revision.ts +182 -0
- package/apps/desktop/application/operations/gateway/image.ts +41 -0
- package/apps/desktop/application/operations/gateway/index.ts +17 -0
- package/apps/desktop/application/operations/gateway/shared.ts +122 -0
- package/apps/desktop/application/operations/gateway/speech.ts +41 -0
- package/apps/desktop/application/operations/gateway/transcription.ts +42 -0
- package/apps/desktop/application/operations/gateway/video.ts +41 -0
- package/apps/desktop/application/operations/index.ts +31 -0
- package/apps/desktop/application/operations/iteration/create-candidate.ts +240 -0
- package/apps/desktop/application/operations/iteration/create-matrix.ts +117 -0
- package/apps/desktop/application/operations/iteration/select.ts +155 -0
- package/apps/desktop/application/operations/iteration/shared.ts +250 -0
- package/apps/desktop/application/operations/media/audio-effects.ts +376 -0
- package/apps/desktop/application/operations/media/binding.ts +79 -0
- package/apps/desktop/application/operations/media/capabilities.ts +112 -0
- package/apps/desktop/application/operations/media/color-grade.ts +343 -0
- package/apps/desktop/application/operations/media/html-overlay.ts +802 -0
- package/apps/desktop/application/operations/media/index.ts +8 -0
- package/apps/desktop/application/operations/media/ingest.ts +303 -0
- package/apps/desktop/application/operations/media/overlay.ts +729 -0
- package/apps/desktop/application/operations/media/shared.ts +845 -0
- package/apps/desktop/application/operations/media/transform-platform.ts +120 -0
- package/apps/desktop/application/operations/project/commit-edits.ts +2054 -0
- package/apps/desktop/application/operations/project/promote-selection.ts +495 -0
- package/apps/desktop/application/operations/project/snapshot.ts +95 -0
- package/apps/desktop/application/operations/recording/index.ts +5 -0
- package/apps/desktop/application/operations/recording/pause.ts +34 -0
- package/apps/desktop/application/operations/recording/resume.ts +34 -0
- package/apps/desktop/application/operations/recording/shared.ts +199 -0
- package/apps/desktop/application/operations/recording/start.ts +169 -0
- package/apps/desktop/application/operations/recording/stop.ts +34 -0
- package/apps/desktop/application/operations/render/bind-candidate-output.ts +324 -0
- package/apps/desktop/application/operations/render/materialize-selection.ts +322 -0
- package/apps/desktop/application/operations/render/project-caption-plan.ts +287 -0
- package/apps/desktop/application/operations/render/project-plan.ts +939 -0
- package/apps/desktop/application/operations/render/project-spatial-receipt.ts +70 -0
- package/apps/desktop/application/operations/render/project.ts +1922 -0
- package/apps/desktop/application/operations/shared.ts +67 -0
- package/apps/desktop/application/operations/slopcamera-portable.ts +1188 -0
- package/apps/desktop/application/operations/slopcamera-visuals.ts +784 -0
- package/apps/desktop/application/operations/spatial-project.ts +209 -0
- package/apps/desktop/application/operations/spatial-render.ts +415 -0
- package/apps/desktop/application/operations/spatial-scene.ts +97 -0
- package/apps/desktop/application/operations/studio.ts +143 -0
- package/apps/desktop/application/output-publication-lease.ts +163 -0
- package/apps/desktop/application/project-publication-lease.ts +233 -0
- package/apps/desktop/application/project-store.ts +196 -0
- package/apps/desktop/application/receipts.ts +533 -0
- package/apps/desktop/application/registry.ts +182 -0
- package/apps/desktop/application/spatial-assets.ts +675 -0
- package/apps/desktop/application/spatial-compositor-cadence.ts +23 -0
- package/apps/desktop/application/spatial-project-authority.ts +35 -0
- package/apps/desktop/application/spatial-project-lease.ts +126 -0
- package/apps/desktop/application/spatial-project-render-preparation.ts +225 -0
- package/apps/desktop/application/spatial-project-store.ts +286 -0
- package/apps/desktop/application/spatial-render-projection.ts +201 -0
- package/apps/desktop/application/spatial-render.ts +677 -0
- package/apps/desktop/application/spatial-spz.ts +44 -0
- package/apps/desktop/application/spatial-world-import.ts +135 -0
- package/apps/desktop/application/spatial-world-provenance.ts +46 -0
- package/apps/desktop/application/studio-port.ts +58 -0
- package/apps/desktop/application/verified-receipt-reconciliation.ts +1574 -0
- package/apps/desktop/capture/protocol.ts +689 -0
- package/apps/desktop/cli/analyzer.ts +406 -0
- package/apps/desktop/cli/args.ts +3181 -0
- package/apps/desktop/cli/asset-ingest.ts +747 -0
- package/apps/desktop/cli/atomic-render-effects.ts +210 -0
- package/apps/desktop/cli/atomic-render-platform.ts +205 -0
- package/apps/desktop/cli/atomic-render.ts +50 -0
- package/apps/desktop/cli/audio-analysis.ts +252 -0
- package/apps/desktop/cli/bounded-map.ts +39 -0
- package/apps/desktop/cli/bundle-service.ts +256 -0
- package/apps/desktop/cli/capabilities.ts +212 -0
- package/apps/desktop/cli/capture-bundle.ts +1117 -0
- package/apps/desktop/cli/command-host-resources.ts +251 -0
- package/apps/desktop/cli/commands.ts +6836 -0
- package/apps/desktop/cli/directing-blob.ts +291 -0
- package/apps/desktop/cli/directing-media.ts +517 -0
- package/apps/desktop/cli/directing-quote.ts +89 -0
- package/apps/desktop/cli/directing-service.ts +288 -0
- package/apps/desktop/cli/emoji-assets.ts +538 -0
- package/apps/desktop/cli/errors.ts +29 -0
- package/apps/desktop/cli/face-analysis-service.ts +940 -0
- package/apps/desktop/cli/filter-script.ts +76 -0
- package/apps/desktop/cli/gateway-application-port.ts +1142 -0
- package/apps/desktop/cli/gateway-credential.ts +81 -0
- package/apps/desktop/cli/gateway-media-artifacts.ts +723 -0
- package/apps/desktop/cli/gateway-media-catalog.ts +922 -0
- package/apps/desktop/cli/gateway-media-service.ts +3106 -0
- package/apps/desktop/cli/gateway-media-signature.ts +122 -0
- package/apps/desktop/cli/gateway-provider-options.ts +562 -0
- package/apps/desktop/cli/gateway-scene-provider.ts +391 -0
- package/apps/desktop/cli/help.ts +501 -0
- package/apps/desktop/cli/html-overlay-gpu.ts +97 -0
- package/apps/desktop/cli/html-overlay-renderer.ts +2478 -0
- package/apps/desktop/cli/io.ts +290 -0
- package/apps/desktop/cli/main.ts +123 -0
- package/apps/desktop/cli/media-effects-service.ts +885 -0
- package/apps/desktop/cli/media-ingest-model.ts +364 -0
- package/apps/desktop/cli/media-ingest-platform.ts +227 -0
- package/apps/desktop/cli/media-ingest-program.ts +200 -0
- package/apps/desktop/cli/media-ingest.ts +30 -0
- package/apps/desktop/cli/music-analysis-service.ts +434 -0
- package/apps/desktop/cli/mutation-lock.ts +530 -0
- package/apps/desktop/cli/native-assets.d.ts +12 -0
- package/apps/desktop/cli/native-media-runtime.macos.ts +7 -0
- package/apps/desktop/cli/overlay-playback.ts +177 -0
- package/apps/desktop/cli/paths.ts +265 -0
- package/apps/desktop/cli/portable-surface.ts +242 -0
- package/apps/desktop/cli/project-camera-receipt.ts +112 -0
- package/apps/desktop/cli/project-face-camera.ts +394 -0
- package/apps/desktop/cli/project-inactivity-service.ts +552 -0
- package/apps/desktop/cli/project-media-integrity.ts +121 -0
- package/apps/desktop/cli/project-renderer.ts +1526 -0
- package/apps/desktop/cli/project-service.ts +519 -0
- package/apps/desktop/cli/project-state-transaction.ts +520 -0
- package/apps/desktop/cli/query-limits.ts +1 -0
- package/apps/desktop/cli/recording-controller.ts +1567 -0
- package/apps/desktop/cli/recording-daemon.ts +931 -0
- package/apps/desktop/cli/recording-ref.ts +56 -0
- package/apps/desktop/cli/renamed-environment.ts +6 -0
- package/apps/desktop/cli/renderer.ts +1439 -0
- package/apps/desktop/cli/run-cli-test-helper.ts +60 -0
- package/apps/desktop/cli/scene-analysis-service.ts +879 -0
- package/apps/desktop/cli/spatial-compositor-verifier.ts +213 -0
- package/apps/desktop/cli/spatial-project-service.ts +90 -0
- package/apps/desktop/cli/spatial-scene-service.ts +105 -0
- package/apps/desktop/cli/spatial-world-service.ts +39 -0
- package/apps/desktop/cli/speech-analysis-service.ts +682 -0
- package/apps/desktop/cli/studio-assemble.ts +58 -0
- package/apps/desktop/cli/studio-command.ts +60 -0
- package/apps/desktop/cli/studio-custody.ts +60 -0
- package/apps/desktop/cli/studio-encode.ts +216 -0
- package/apps/desktop/cli/studio-exr.ts +52 -0
- package/apps/desktop/cli/studio-files.ts +179 -0
- package/apps/desktop/cli/studio-output-validation.ts +162 -0
- package/apps/desktop/cli/studio-process.ts +145 -0
- package/apps/desktop/cli/studio-runtime.ts +77 -0
- package/apps/desktop/cli/studio-scaffold.ts +70 -0
- package/apps/desktop/cli/studio-service.ts +301 -0
- package/apps/desktop/cli/studio-spatial-asset.ts +248 -0
- package/apps/desktop/cli/studio-template-names.ts +3 -0
- package/apps/desktop/cli/studio-workflow.ts +19 -0
- package/apps/desktop/cli/time.ts +25 -0
- package/apps/desktop/cli/workflow-code.ts +584 -0
- package/apps/desktop/cli/workflow-runs.ts +576 -0
- package/apps/desktop/code/advanced.ts +139 -0
- package/apps/desktop/code/application-node-planner.ts +1288 -0
- package/apps/desktop/code/compiler.ts +329 -0
- package/apps/desktop/code/contracts.ts +13 -0
- package/apps/desktop/code/creative-recipes.ts +94 -0
- package/apps/desktop/code/define-workflow.ts +115 -0
- package/apps/desktop/code/editing.ts +284 -0
- package/apps/desktop/code/file-candidate-provenance.ts +420 -0
- package/apps/desktop/code/file-candidate.ts +69 -0
- package/apps/desktop/code/graph-builder.ts +10 -0
- package/apps/desktop/code/host-resource-policy.ts +55 -0
- package/apps/desktop/code/plan-contracts.ts +159 -0
- package/apps/desktop/code/planning.ts +361 -0
- package/apps/desktop/code/public.ts +134 -0
- package/apps/desktop/code/reference-projection.ts +62 -0
- package/apps/desktop/code/run-contracts.ts +466 -0
- package/apps/desktop/code/run-store.ts +2478 -0
- package/apps/desktop/code/runtime-identity.ts +328 -0
- package/apps/desktop/code/scheduler.ts +3067 -0
- package/apps/desktop/code/semantic-builder.ts +1436 -0
- package/apps/desktop/code/source-bundle.ts +1641 -0
- package/apps/desktop/code/source-typecheck.ts +211 -0
- package/apps/desktop/code/testing.ts +79 -0
- package/apps/desktop/code/worker-client.ts +1675 -0
- package/apps/desktop/code/worker-effects.ts +141 -0
- package/apps/desktop/code/worker-entry.ts +435 -0
- package/apps/desktop/code/worker-lease-guardian.ts +179 -0
- package/apps/desktop/code/worker-process-identity.ts +484 -0
- package/apps/desktop/code/worker-protocol.ts +211 -0
- package/apps/desktop/code/workflow-effects.ts +132 -0
- package/apps/desktop/contracts/analysis.ts +949 -0
- package/apps/desktop/contracts/edit.ts +576 -0
- package/apps/desktop/contracts/index.ts +9 -0
- package/apps/desktop/contracts/media-effects.ts +139 -0
- package/apps/desktop/contracts/project-render.ts +644 -0
- package/apps/desktop/contracts/project.ts +740 -0
- package/apps/desktop/contracts/recording.ts +1434 -0
- package/apps/desktop/contracts/render.ts +334 -0
- package/apps/desktop/contracts/runtime.ts +155 -0
- package/apps/desktop/contracts/spatial-compositor.ts +58 -0
- package/apps/desktop/contracts/spatial-project.ts +159 -0
- package/apps/desktop/contracts/spatial-world.ts +48 -0
- package/apps/desktop/contracts/video-effects.ts +120 -0
- package/apps/desktop/core/alignment-apply.ts +400 -0
- package/apps/desktop/core/audio-alignment.ts +466 -0
- package/apps/desktop/core/auto-zoom.ts +209 -0
- package/apps/desktop/core/canonical-json.ts +14 -0
- package/apps/desktop/core/emoji.ts +210 -0
- package/apps/desktop/core/events.ts +409 -0
- package/apps/desktop/core/face-framing.ts +533 -0
- package/apps/desktop/core/face-tracks.ts +326 -0
- package/apps/desktop/core/inactivity.ts +137 -0
- package/apps/desktop/core/index.ts +30 -0
- package/apps/desktop/core/intervals.ts +123 -0
- package/apps/desktop/core/montage.ts +236 -0
- package/apps/desktop/core/music-analysis.ts +518 -0
- package/apps/desktop/core/plan.ts +244 -0
- package/apps/desktop/core/project-auto-zoom.ts +75 -0
- package/apps/desktop/core/project-camera-spatial.ts +676 -0
- package/apps/desktop/core/project-camera.ts +403 -0
- package/apps/desktop/core/project-captions.ts +888 -0
- package/apps/desktop/core/project-inactivity.ts +125 -0
- package/apps/desktop/core/project-metadata.ts +927 -0
- package/apps/desktop/core/project-plan.ts +375 -0
- package/apps/desktop/core/project-render-plan.ts +303 -0
- package/apps/desktop/core/project-time.ts +469 -0
- package/apps/desktop/core/render-plan.ts +442 -0
- package/apps/desktop/core/scene-cache.ts +174 -0
- package/apps/desktop/core/scene-sampling.ts +448 -0
- package/apps/desktop/core/spatial-compositor.ts +22 -0
- package/apps/desktop/core/spatial-durability.ts +99 -0
- package/apps/desktop/core/spatial-project.ts +169 -0
- package/apps/desktop/core/spatial-shot-clock.ts +32 -0
- package/apps/desktop/core/speech-analysis.ts +333 -0
- package/apps/desktop/core/storage.ts +970 -0
- package/apps/desktop/core/time-map.ts +91 -0
- package/apps/desktop/core/time.ts +61 -0
- package/apps/desktop/core/video-effects.ts +660 -0
- package/apps/desktop/dist/cli/NebulaSans-Bold-26se8aek.otf +0 -0
- package/apps/desktop/dist/cli/NebulaSans-Bold-bcz7y08t.woff2 +0 -0
- package/apps/desktop/dist/cli/NebulaSans-Book-5ax05zvn.woff2 +0 -0
- package/apps/desktop/dist/cli/NebulaSans-Book-8cenzchw.otf +0 -0
- package/apps/desktop/dist/cli/main.js +5086 -0
- package/apps/desktop/html-overlay/catalog.ts +171 -0
- package/apps/desktop/html-overlay/contracts.ts +372 -0
- package/apps/desktop/html-overlay/execution-profile.ts +77 -0
- package/apps/desktop/html-overlay/index.ts +98 -0
- package/apps/desktop/html-overlay/libraries.ts +263 -0
- package/apps/desktop/html-overlay/random.ts +49 -0
- package/apps/desktop/html-overlay/runtime.ts +813 -0
- package/apps/desktop/html-overlay/scaffolds.ts +792 -0
- package/apps/desktop/html-overlay/spark-runtime.ts +88 -0
- package/apps/desktop/html-overlay/spatial-splat-runtime.ts +60 -0
- package/apps/desktop/html-overlay/spatial.ts +720 -0
- package/apps/desktop/studio/assets/README.md +39 -0
- package/apps/desktop/studio/assets/contracts.ts +129 -0
- package/apps/desktop/studio/assets/network.ts +64 -0
- package/apps/desktop/studio/assets/poly-haven.ts +148 -0
- package/apps/desktop/studio/drivers/blender_driver.py +466 -0
- package/apps/desktop/studio/drivers/cadquery_driver.py +176 -0
- package/apps/desktop/studio/education/driver.py +308 -0
- package/apps/desktop/studio/education/lesson.py +180 -0
- package/apps/desktop/studio/education/toolkit.py +249 -0
- package/apps/desktop/workflows/chaptered-demo.ts +48 -0
- package/apps/desktop/workflows/creative-iteration.ts +107 -0
- package/apps/desktop/workflows/creative-selection.ts +92 -0
- package/apps/desktop/workflows/fragments.ts +486 -0
- package/apps/desktop/workflows/index.ts +93 -0
- package/apps/desktop/workflows/polished-screen-demo.ts +128 -0
- package/apps/desktop/workflows/scene-direction.ts +12 -0
- package/apps/desktop/workflows/social-variants.ts +75 -0
- package/apps/desktop/workflows/talking-head-cleanup.ts +53 -0
- package/dist/NebulaSans-Bold-26se8aek.otf +0 -0
- package/dist/NebulaSans-Bold-bcz7y08t.woff2 +0 -0
- package/dist/NebulaSans-Book-5ax05zvn.woff2 +0 -0
- package/dist/NebulaSans-Book-8cenzchw.otf +0 -0
- package/dist/cli.js +479 -0
- package/dist/code/advanced.js +270 -0
- package/dist/code/index.js +2240 -0
- package/dist/generate.js +24 -0
- package/dist/host-resources.js +26 -0
- package/dist/index-42zsesc1.js +2257 -0
- package/dist/index-7308egqr.js +48 -0
- package/dist/index-8txs6fkn.js +712 -0
- package/dist/index-mcy8z0br.js +192 -0
- package/dist/index-p63wavx0.js +2029 -0
- package/dist/index-r7gdhmsp.js +424 -0
- package/dist/index-sh6xbav6.js +1219 -0
- package/dist/index-z1w83f81.js +4 -0
- package/dist/index-z7239b4h.js +2363 -0
- package/dist/index-zfeg9hmw.js +2013 -0
- package/dist/index.js +241 -0
- package/dist/operations.js +29 -0
- package/dist/skill-install-dh0ntqba.js +10 -0
- package/dist/vectorize/worker.js +142 -0
- package/dist/workflow.js +16 -0
- package/docs/README.md +38 -0
- package/docs/studio.md +215 -0
- package/examples/studio/blender/README.md +26 -0
- package/examples/studio/blender/character.py +152 -0
- package/examples/studio/blender/cloth.py +101 -0
- package/examples/studio/blender/color_chart.py +29 -0
- package/examples/studio/blender/fluid.py +78 -0
- package/examples/studio/blender/import_model.py +74 -0
- package/examples/studio/blender/product.py +53 -0
- package/examples/studio/blender/shaded_street.py +481 -0
- package/examples/studio/blender/studio_scene.py +147 -0
- package/examples/studio/cadquery/README.md +13 -0
- package/examples/studio/cadquery/bracket.py +36 -0
- package/examples/studio/cadquery/import_step.py +12 -0
- package/examples/studio/education/README.md +46 -0
- package/examples/studio/education/lesson.json +99 -0
- package/examples/studio/education/scene.py +7 -0
- package/examples/studio/hybrid-scene.ts +96 -0
- package/examples/studio/native-workflow.ts +14 -0
- package/examples/studio/vgpu/README.md +64 -0
- package/examples/studio/vgpu/heat-field.mjs +290 -0
- package/package.json +304 -0
- package/packages/scene/src/index.ts +1 -0
- package/packages/scene/src/scene.ts +215 -0
- package/schema/diagram.schema.json +334 -0
- package/skills/slopcamera/SKILL.md +45 -0
- package/skills/slopcamera/agents/openai.yaml +4 -0
- package/skills/slopcamera/references/customization.md +77 -0
- package/skills/slopcamera/references/diagrams.md +153 -0
- package/skills/slopcamera/references/directed-scenes.md +42 -0
- package/skills/slopcamera/references/directing-video.md +18 -0
- package/skills/slopcamera/references/educational-video.md +26 -0
- package/skills/slopcamera/references/gateway-media.md +134 -0
- package/skills/slopcamera/references/install.md +62 -0
- package/skills/slopcamera/references/native-studio.md +31 -0
- package/skills/slopcamera/references/reference-led-3d.md +140 -0
- package/skills/slopcamera/references/rubber-stamp-examples/poster-example-1.jpg +0 -0
- package/skills/slopcamera/references/rubber-stamp-examples/poster-example-2.jpg +0 -0
- package/skills/slopcamera/references/rubber-stamp-examples/stamp-style-1.png +0 -0
- package/skills/slopcamera/references/rubber-stamp-examples/stamp-style-2.png +0 -0
- package/skills/slopcamera/references/rubber-stamp-field-notes.md +144 -0
- package/skills/slopcamera/references/vectorization.md +29 -0
- package/skills/slopcamera/references/video-projects.md +187 -0
- package/skills/slopcamera/references/visual-communication.md +120 -0
- package/skills/slopcamera/references/workflows-sdk.md +45 -0
- package/skills/slopcamera/scripts/compose-rubber-stamp-field-note.ts +229 -0
- package/src/artifacts.ts +106 -0
- package/src/assets/fonts/nebula-sans/LICENSE.txt +96 -0
- package/src/assets/fonts/nebula-sans/NebulaSans-Bold.otf +0 -0
- package/src/assets/fonts/nebula-sans/NebulaSans-Bold.woff2 +0 -0
- package/src/assets/fonts/nebula-sans/NebulaSans-Book.otf +0 -0
- package/src/assets/fonts/nebula-sans/NebulaSans-Book.woff2 +0 -0
- package/src/assets/fonts/nebula-sans/PROVENANCE.md +21 -0
- package/src/assets.d.ts +9 -0
- package/src/cli.ts +601 -0
- package/src/cloud-errors.ts +26 -0
- package/src/code/advanced.ts +10 -0
- package/src/code/boundary.ts +21 -0
- package/src/code/canonical-json.ts +129 -0
- package/src/code/compiler.ts +953 -0
- package/src/code/contracts.ts +576 -0
- package/src/code/define-workflow.ts +334 -0
- package/src/code/errors.ts +41 -0
- package/src/code/graph-builder.ts +945 -0
- package/src/code/index.ts +121 -0
- package/src/code/json-snapshot.ts +899 -0
- package/src/code/json-utf8.ts +88 -0
- package/src/code/portable-builder.ts +136 -0
- package/src/code/projection.ts +378 -0
- package/src/code/public-operations.ts +450 -0
- package/src/code/runtime.ts +580 -0
- package/src/code/sha256.ts +36 -0
- package/src/config.ts +180 -0
- package/src/desktop.ts +253 -0
- package/src/fs.ts +10 -0
- package/src/generate.ts +704 -0
- package/src/host-resource-posix.ts +161 -0
- package/src/host-resources.ts +1749 -0
- package/src/icons.ts +58 -0
- package/src/index.ts +200 -0
- package/src/label-layout.ts +130 -0
- package/src/layout.ts +413 -0
- package/src/lint.ts +127 -0
- package/src/mcp/boundary.ts +354 -0
- package/src/mcp/index.ts +33 -0
- package/src/mcp/server.ts +272 -0
- package/src/mcp/tools.ts +963 -0
- package/src/mcp/types.ts +71 -0
- package/src/operations.ts +775 -0
- package/src/parse.ts +861 -0
- package/src/process-environment.ts +17 -0
- package/src/render.ts +437 -0
- package/src/skill-install.ts +49 -0
- package/src/spatial-scene/authoring.ts +27 -0
- package/src/spatial-scene/camera-track.ts +83 -0
- package/src/spatial-scene/contracts.ts +277 -0
- package/src/spatial-scene/evaluate.ts +145 -0
- package/src/spatial-scene/gltf.ts +558 -0
- package/src/spatial-scene/identity.ts +260 -0
- package/src/spatial-scene/index.ts +10 -0
- package/src/spatial-scene/inspect.ts +68 -0
- package/src/spatial-scene/math.ts +280 -0
- package/src/spatial-scene/patch.ts +131 -0
- package/src/spatial-scene/time.ts +53 -0
- package/src/studio/contracts.ts +129 -0
- package/src/studio/index.ts +2 -0
- package/src/studio/plan.ts +156 -0
- package/src/studio/shared.ts +41 -0
- package/src/theme.ts +68 -0
- package/src/tldr.ts +474 -0
- package/src/types.ts +212 -0
- package/src/vectorize/archive.ts +101 -0
- package/src/vectorize/command.ts +797 -0
- package/src/vectorize/index.ts +27 -0
- package/src/vectorize/limits.ts +61 -0
- package/src/vectorize/metrics.ts +351 -0
- package/src/vectorize/pixels.ts +323 -0
- package/src/vectorize/supervisor.ts +339 -0
- package/src/vectorize/svg.ts +188 -0
- package/src/vectorize/tool.ts +542 -0
- package/src/vectorize/types.ts +113 -0
- package/src/vectorize/vectorize.ts +634 -0
- package/src/vectorize/worker-protocol.ts +40 -0
- package/src/vectorize/worker.ts +209 -0
- package/src/version.ts +1 -0
- package/src/workflow.ts +409 -0
|
@@ -0,0 +1,970 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { constants, fstatSync, linkSync, lstatSync, readSync, renameSync, type BigIntStats, type Stats } from "node:fs";
|
|
3
|
+
import {
|
|
4
|
+
link,
|
|
5
|
+
lstat,
|
|
6
|
+
mkdir,
|
|
7
|
+
open,
|
|
8
|
+
realpath,
|
|
9
|
+
rename,
|
|
10
|
+
unlink,
|
|
11
|
+
type FileHandle,
|
|
12
|
+
} from "node:fs/promises";
|
|
13
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
AudioAlignmentAnalysisV1Schema,
|
|
17
|
+
EditPlanV1Schema,
|
|
18
|
+
FaceAnalysisV1Schema,
|
|
19
|
+
MusicAnalysisV1Schema,
|
|
20
|
+
ProjectInactivityAnalysisV1Schema,
|
|
21
|
+
ProjectEditPlanV1Schema,
|
|
22
|
+
RecordingManifestV1Schema,
|
|
23
|
+
RepositoryRelativePathSchema,
|
|
24
|
+
SceneAnalysisV1Schema,
|
|
25
|
+
Sha256Schema,
|
|
26
|
+
SpeechAnalysisV1Schema,
|
|
27
|
+
VideoProjectV1Schema,
|
|
28
|
+
type AudioAlignmentAnalysisV1,
|
|
29
|
+
type EditPlanV1,
|
|
30
|
+
type FaceAnalysisV1,
|
|
31
|
+
type MusicAnalysisV1,
|
|
32
|
+
type ProjectInactivityAnalysisV1,
|
|
33
|
+
type ProjectEditPlanV1,
|
|
34
|
+
type RecordingManifestV1,
|
|
35
|
+
type SceneAnalysisV1,
|
|
36
|
+
type SpeechAnalysisV1,
|
|
37
|
+
type VideoProjectV1,
|
|
38
|
+
} from "../contracts";
|
|
39
|
+
import { canonicalJson, sha256Hex } from "./canonical-json";
|
|
40
|
+
|
|
41
|
+
export const RECORDING_MANIFEST_PATH = "manifest.json";
|
|
42
|
+
export const VIDEO_PROJECT_PATH = "project.json";
|
|
43
|
+
export const CURRENT_PROJECT_EDIT_PLAN_PATH = "edits/current.json";
|
|
44
|
+
const MAXIMUM_STRUCTURED_FILE_BYTES = 256 * 1024 * 1024;
|
|
45
|
+
const IMMUTABLE_COPY_TEMP_PREFIX = ".slopcamera-copy-";
|
|
46
|
+
|
|
47
|
+
export interface BundleFileSystem {
|
|
48
|
+
/**
|
|
49
|
+
* Inspect a physical regular file without following a leaf symlink.
|
|
50
|
+
* Implementations may omit binary support when they are used only for
|
|
51
|
+
* structured in-memory tests.
|
|
52
|
+
*/
|
|
53
|
+
inspectFile?(path: string, maximumBytes?: number): Promise<BundleFileIntegrity>;
|
|
54
|
+
readText(path: string, maximumBytes?: number): Promise<string>;
|
|
55
|
+
writeTextAtomic(path: string, contents: string): Promise<void>;
|
|
56
|
+
/** Revalidate caller custody after staging and immediately before rename dispatch. */
|
|
57
|
+
writeTextAtomicGuarded?(path: string, contents: string, beforeReplace: () => Promise<void>): Promise<void>;
|
|
58
|
+
/**
|
|
59
|
+
* Install a new physical file without replacing an existing path.
|
|
60
|
+
*
|
|
61
|
+
* This is optional because lightweight read/write adapters do not need to
|
|
62
|
+
* support immutable publication. Callers that publish content-addressed
|
|
63
|
+
* artifacts must require it explicitly.
|
|
64
|
+
*/
|
|
65
|
+
writeTextNoReplace?(
|
|
66
|
+
path: string,
|
|
67
|
+
contents: string,
|
|
68
|
+
beforePublication?: () => Promise<void>,
|
|
69
|
+
): Promise<"created" | "exists">;
|
|
70
|
+
/**
|
|
71
|
+
* Copy one exact bundle file using atomic no-replace publication. The final
|
|
72
|
+
* path remains absent until the complete bytes are durable. Existing
|
|
73
|
+
* identical bytes are accepted for deterministic recovery; different bytes
|
|
74
|
+
* always fail.
|
|
75
|
+
*/
|
|
76
|
+
copyFileNoReplace?(
|
|
77
|
+
sourcePath: string,
|
|
78
|
+
destinationPath: string,
|
|
79
|
+
expected: BundleFileIntegrity,
|
|
80
|
+
beforePublication?: () => Promise<void>,
|
|
81
|
+
): Promise<"created" | "exists">;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface BundleFileIntegrity {
|
|
85
|
+
readonly bytes: number;
|
|
86
|
+
readonly sha256: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export type SlopcameraPersistenceDocument =
|
|
90
|
+
| AnalysisArtifact
|
|
91
|
+
| EditPlanV1
|
|
92
|
+
| ProjectEditPlanV1
|
|
93
|
+
| RecordingManifestV1
|
|
94
|
+
| VideoProjectV1;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Project a parsed mutable persistence document to the exact identity that a
|
|
98
|
+
* current Slopcamera writer will put on disk. Callers that authenticate immutable
|
|
99
|
+
* predecessor bytes must do that before invoking this projection.
|
|
100
|
+
*/
|
|
101
|
+
export function canonicalSlopcameraPersistenceDocument<
|
|
102
|
+
Value extends SlopcameraPersistenceDocument,
|
|
103
|
+
>(value: Value): Value {
|
|
104
|
+
const kind = value.kind.replace(/^studio\./u, "slopcamera.");
|
|
105
|
+
if ("tool" in value && value.kind.endsWith(".recording-bundle")) {
|
|
106
|
+
if (kind === value.kind && value.tool.name === "slopcamera") return value;
|
|
107
|
+
return {
|
|
108
|
+
...value,
|
|
109
|
+
kind,
|
|
110
|
+
tool: { ...value.tool, name: "slopcamera" },
|
|
111
|
+
} as Value;
|
|
112
|
+
}
|
|
113
|
+
return kind === value.kind ? value : { ...value, kind } as Value;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Publish canonical immutable text and verify the physical winner. */
|
|
117
|
+
export async function saveImmutableText(
|
|
118
|
+
fileSystem: BundleFileSystem,
|
|
119
|
+
path: string,
|
|
120
|
+
contents: string,
|
|
121
|
+
expectedSha256: string,
|
|
122
|
+
): Promise<"created" | "exists"> {
|
|
123
|
+
RepositoryRelativePathSchema.parse(path);
|
|
124
|
+
const sha256 = Sha256Schema.parse(expectedSha256);
|
|
125
|
+
if (sha256Hex(contents) !== sha256) {
|
|
126
|
+
throw new Error(`Immutable text hash mismatch for ${path}.`);
|
|
127
|
+
}
|
|
128
|
+
if (fileSystem.writeTextNoReplace === undefined) {
|
|
129
|
+
throw new Error("Bundle file system does not support immutable no-replace publication.");
|
|
130
|
+
}
|
|
131
|
+
const disposition = await fileSystem.writeTextNoReplace(path, contents);
|
|
132
|
+
const published = await fileSystem.readText(path);
|
|
133
|
+
if (published !== contents || sha256Hex(published) !== sha256) {
|
|
134
|
+
throw new Error(
|
|
135
|
+
disposition === "exists"
|
|
136
|
+
? `Immutable path already contains different bytes: ${path}`
|
|
137
|
+
: `Immutable publication failed read-back verification: ${path}`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
return disposition;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function editPlanPath(planId: string): string {
|
|
144
|
+
return RepositoryRelativePathSchema.parse(`edits/${planId}.json`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function projectEditRevisionPath(artifactSha256: string): string {
|
|
148
|
+
const sha256 = Sha256Schema.parse(artifactSha256);
|
|
149
|
+
return RepositoryRelativePathSchema.parse(`edits/revisions/${sha256}.json`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export async function loadRecordingManifest(
|
|
153
|
+
fileSystem: BundleFileSystem,
|
|
154
|
+
path = RECORDING_MANIFEST_PATH,
|
|
155
|
+
): Promise<RecordingManifestV1> {
|
|
156
|
+
RepositoryRelativePathSchema.parse(path);
|
|
157
|
+
const input: unknown = JSON.parse(await fileSystem.readText(path));
|
|
158
|
+
return RecordingManifestV1Schema.parse(input);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export async function saveRecordingManifest(
|
|
162
|
+
fileSystem: BundleFileSystem,
|
|
163
|
+
manifest: RecordingManifestV1,
|
|
164
|
+
path = RECORDING_MANIFEST_PATH,
|
|
165
|
+
): Promise<void> {
|
|
166
|
+
RepositoryRelativePathSchema.parse(path);
|
|
167
|
+
const parsed = RecordingManifestV1Schema.parse(canonicalSlopcameraPersistenceDocument(
|
|
168
|
+
RecordingManifestV1Schema.parse(manifest),
|
|
169
|
+
));
|
|
170
|
+
await fileSystem.writeTextAtomic(path, `${canonicalJson(parsed)}\n`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export async function loadEditPlan(fileSystem: BundleFileSystem, path: string): Promise<EditPlanV1> {
|
|
174
|
+
RepositoryRelativePathSchema.parse(path);
|
|
175
|
+
const input: unknown = JSON.parse(await fileSystem.readText(path));
|
|
176
|
+
return EditPlanV1Schema.parse(input);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export async function saveEditPlan(
|
|
180
|
+
fileSystem: BundleFileSystem,
|
|
181
|
+
plan: EditPlanV1,
|
|
182
|
+
path = editPlanPath(plan.planId),
|
|
183
|
+
): Promise<void> {
|
|
184
|
+
RepositoryRelativePathSchema.parse(path);
|
|
185
|
+
const parsed = EditPlanV1Schema.parse(canonicalSlopcameraPersistenceDocument(
|
|
186
|
+
EditPlanV1Schema.parse(plan),
|
|
187
|
+
));
|
|
188
|
+
await fileSystem.writeTextAtomic(path, `${canonicalJson(parsed)}\n`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export async function loadVideoProject(
|
|
192
|
+
fileSystem: BundleFileSystem,
|
|
193
|
+
path = VIDEO_PROJECT_PATH,
|
|
194
|
+
): Promise<VideoProjectV1> {
|
|
195
|
+
RepositoryRelativePathSchema.parse(path);
|
|
196
|
+
const input: unknown = JSON.parse(await fileSystem.readText(path));
|
|
197
|
+
return VideoProjectV1Schema.parse(input);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export async function saveVideoProject(
|
|
201
|
+
fileSystem: BundleFileSystem,
|
|
202
|
+
project: VideoProjectV1,
|
|
203
|
+
path = VIDEO_PROJECT_PATH,
|
|
204
|
+
): Promise<void> {
|
|
205
|
+
RepositoryRelativePathSchema.parse(path);
|
|
206
|
+
const parsed = VideoProjectV1Schema.parse(canonicalSlopcameraPersistenceDocument(
|
|
207
|
+
VideoProjectV1Schema.parse(project),
|
|
208
|
+
));
|
|
209
|
+
await fileSystem.writeTextAtomic(path, `${canonicalJson(parsed)}\n`);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export async function loadProjectEditPlan(
|
|
213
|
+
fileSystem: BundleFileSystem,
|
|
214
|
+
path = CURRENT_PROJECT_EDIT_PLAN_PATH,
|
|
215
|
+
): Promise<ProjectEditPlanV1> {
|
|
216
|
+
RepositoryRelativePathSchema.parse(path);
|
|
217
|
+
const input: unknown = JSON.parse(await fileSystem.readText(path));
|
|
218
|
+
return ProjectEditPlanV1Schema.parse(input);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export async function saveProjectEditPlan(
|
|
222
|
+
fileSystem: BundleFileSystem,
|
|
223
|
+
plan: ProjectEditPlanV1,
|
|
224
|
+
path = CURRENT_PROJECT_EDIT_PLAN_PATH,
|
|
225
|
+
): Promise<void> {
|
|
226
|
+
RepositoryRelativePathSchema.parse(path);
|
|
227
|
+
const parsed = ProjectEditPlanV1Schema.parse(canonicalSlopcameraPersistenceDocument(
|
|
228
|
+
ProjectEditPlanV1Schema.parse(plan),
|
|
229
|
+
));
|
|
230
|
+
await fileSystem.writeTextAtomic(path, `${canonicalJson(parsed)}\n`);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Publish canonical revision bytes exactly once under their physical hash.
|
|
235
|
+
*
|
|
236
|
+
* A retry is accepted only when the already-published bytes are identical.
|
|
237
|
+
* The mutable current-plan pointer is intentionally outside this operation.
|
|
238
|
+
*/
|
|
239
|
+
export async function saveProjectEditRevision(
|
|
240
|
+
fileSystem: BundleFileSystem,
|
|
241
|
+
contents: string,
|
|
242
|
+
expectedArtifactSha256: string,
|
|
243
|
+
): Promise<string> {
|
|
244
|
+
const sha256 = Sha256Schema.parse(expectedArtifactSha256);
|
|
245
|
+
const actualSha256 = sha256Hex(contents);
|
|
246
|
+
if (actualSha256 !== sha256) {
|
|
247
|
+
throw new Error(
|
|
248
|
+
`Project edit revision artifact hash mismatch: expected ${sha256}, received ${actualSha256}.`,
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
if (fileSystem.writeTextNoReplace === undefined) {
|
|
252
|
+
throw new Error("Bundle file system does not support immutable no-replace publication.");
|
|
253
|
+
}
|
|
254
|
+
const path = projectEditRevisionPath(sha256);
|
|
255
|
+
const disposition = await fileSystem.writeTextNoReplace(path, contents);
|
|
256
|
+
const installed = await fileSystem.readText(path);
|
|
257
|
+
if (installed !== contents) {
|
|
258
|
+
throw new Error(
|
|
259
|
+
disposition === "exists"
|
|
260
|
+
? `Project edit revision path already contains different bytes: ${path}`
|
|
261
|
+
: `Published project edit revision failed read-back verification: ${path}`,
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
return path;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export type AnalysisArtifact =
|
|
268
|
+
| AudioAlignmentAnalysisV1
|
|
269
|
+
| FaceAnalysisV1
|
|
270
|
+
| ProjectInactivityAnalysisV1
|
|
271
|
+
| MusicAnalysisV1
|
|
272
|
+
| SceneAnalysisV1
|
|
273
|
+
| SpeechAnalysisV1;
|
|
274
|
+
|
|
275
|
+
export async function loadAnalysisArtifact(
|
|
276
|
+
fileSystem: BundleFileSystem,
|
|
277
|
+
path: string,
|
|
278
|
+
): Promise<AnalysisArtifact> {
|
|
279
|
+
RepositoryRelativePathSchema.parse(path);
|
|
280
|
+
const input: unknown = JSON.parse(await fileSystem.readText(path));
|
|
281
|
+
for (const schema of [
|
|
282
|
+
AudioAlignmentAnalysisV1Schema,
|
|
283
|
+
FaceAnalysisV1Schema,
|
|
284
|
+
ProjectInactivityAnalysisV1Schema,
|
|
285
|
+
MusicAnalysisV1Schema,
|
|
286
|
+
SceneAnalysisV1Schema,
|
|
287
|
+
SpeechAnalysisV1Schema,
|
|
288
|
+
] as const) {
|
|
289
|
+
const parsed = schema.safeParse(input);
|
|
290
|
+
if (parsed.success) return parsed.data as AnalysisArtifact;
|
|
291
|
+
}
|
|
292
|
+
throw new Error(`Analysis artifact does not match a supported schema: ${path}`);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export async function saveAnalysisArtifact(
|
|
296
|
+
fileSystem: BundleFileSystem,
|
|
297
|
+
artifact: AnalysisArtifact,
|
|
298
|
+
path: string,
|
|
299
|
+
): Promise<void> {
|
|
300
|
+
RepositoryRelativePathSchema.parse(path);
|
|
301
|
+
const schemas = [
|
|
302
|
+
AudioAlignmentAnalysisV1Schema,
|
|
303
|
+
FaceAnalysisV1Schema,
|
|
304
|
+
ProjectInactivityAnalysisV1Schema,
|
|
305
|
+
MusicAnalysisV1Schema,
|
|
306
|
+
SceneAnalysisV1Schema,
|
|
307
|
+
SpeechAnalysisV1Schema,
|
|
308
|
+
] as const;
|
|
309
|
+
const parsed = schemas.map(schema => schema.safeParse(artifact)).find(result => result.success);
|
|
310
|
+
if (parsed === undefined || !parsed.success) {
|
|
311
|
+
throw new Error("Analysis artifact does not match a supported schema.");
|
|
312
|
+
}
|
|
313
|
+
await fileSystem.writeTextAtomic(
|
|
314
|
+
path,
|
|
315
|
+
`${canonicalJson(canonicalSlopcameraPersistenceDocument(parsed.data))}\n`,
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function isWithin(root: string, candidate: string): boolean {
|
|
320
|
+
const pathFromRoot = relative(root, candidate);
|
|
321
|
+
return pathFromRoot === "" || (!pathFromRoot.startsWith(`..${sep}`) && pathFromRoot !== ".." && !isAbsolute(pathFromRoot));
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function sameNonLinkSnapshot(before: Stats, after: Stats): boolean {
|
|
325
|
+
return after.isFile()
|
|
326
|
+
&& after.dev === before.dev && after.ino === before.ino
|
|
327
|
+
&& after.mode === before.mode && after.uid === before.uid && after.gid === before.gid
|
|
328
|
+
&& after.rdev === before.rdev && after.size === before.size
|
|
329
|
+
&& after.blksize === before.blksize && after.blocks === before.blocks
|
|
330
|
+
&& after.mtimeMs === before.mtimeMs && after.birthtimeMs === before.birthtimeMs;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export function createNodeBundleFileSystem(
|
|
334
|
+
bundleRoot: string,
|
|
335
|
+
options: Readonly<{
|
|
336
|
+
readonly duringFileInspectionForTesting?: (input: Readonly<{
|
|
337
|
+
readonly attempt: 1 | 2;
|
|
338
|
+
readonly path: string;
|
|
339
|
+
}>) => Promise<void>;
|
|
340
|
+
readonly duringFileCopyForTesting?: (input: Readonly<{
|
|
341
|
+
readonly attempt: 1 | 2;
|
|
342
|
+
readonly path: string;
|
|
343
|
+
}>) => Promise<void>;
|
|
344
|
+
}> = {},
|
|
345
|
+
): BundleFileSystem {
|
|
346
|
+
const lexicalRoot = resolve(bundleRoot);
|
|
347
|
+
|
|
348
|
+
async function physicalRoot(): Promise<string> {
|
|
349
|
+
await mkdir(lexicalRoot, { mode: 0o700, recursive: true });
|
|
350
|
+
const details = await lstat(lexicalRoot);
|
|
351
|
+
if (details.isSymbolicLink() || !details.isDirectory()) {
|
|
352
|
+
throw new Error(`Bundle root must be a physical directory: ${lexicalRoot}`);
|
|
353
|
+
}
|
|
354
|
+
return await realpath(lexicalRoot);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async function safePath(path: string, createParent: boolean): Promise<string> {
|
|
358
|
+
const relativePath = RepositoryRelativePathSchema.parse(path);
|
|
359
|
+
const root = await physicalRoot();
|
|
360
|
+
const candidate = resolve(root, relativePath);
|
|
361
|
+
if (!isWithin(root, candidate)) throw new Error(`Bundle path escapes its root: ${path}`);
|
|
362
|
+
const parentRelative = relative(root, dirname(candidate));
|
|
363
|
+
const parentParts = parentRelative === "" ? [] : parentRelative.split(sep);
|
|
364
|
+
let physicalParent = root;
|
|
365
|
+
for (const part of parentParts) {
|
|
366
|
+
const next = join(physicalParent, part);
|
|
367
|
+
try {
|
|
368
|
+
const details = await lstat(next);
|
|
369
|
+
if (details.isSymbolicLink() || !details.isDirectory()) {
|
|
370
|
+
throw new Error(`Bundle path requires physical directories: ${path}`);
|
|
371
|
+
}
|
|
372
|
+
} catch (error) {
|
|
373
|
+
if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error;
|
|
374
|
+
if (!createParent) throw error;
|
|
375
|
+
try {
|
|
376
|
+
await mkdir(next, { mode: 0o700 });
|
|
377
|
+
} catch (mkdirError) {
|
|
378
|
+
// Concurrent immutable publishers may create the same private
|
|
379
|
+
// parent. Verify the winner below instead of treating that race as
|
|
380
|
+
// a publication failure.
|
|
381
|
+
if (!(
|
|
382
|
+
mkdirError instanceof Error
|
|
383
|
+
&& "code" in mkdirError
|
|
384
|
+
&& mkdirError.code === "EEXIST"
|
|
385
|
+
)) {
|
|
386
|
+
throw mkdirError;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
const created = await lstat(next);
|
|
390
|
+
if (created.isSymbolicLink() || !created.isDirectory()) {
|
|
391
|
+
throw new Error(`Bundle directory creation was redirected: ${path}`);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
physicalParent = next;
|
|
395
|
+
}
|
|
396
|
+
return join(physicalParent, basename(candidate));
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
async function readPhysicalText(path: string, maximumBytes = MAXIMUM_STRUCTURED_FILE_BYTES): Promise<string> {
|
|
400
|
+
if (!Number.isSafeInteger(maximumBytes) || maximumBytes < 0 || maximumBytes > MAXIMUM_STRUCTURED_FILE_BYTES) throw new RangeError("Invalid structured file byte limit.");
|
|
401
|
+
const target = await safePath(path, false);
|
|
402
|
+
const lexical = await lstat(target);
|
|
403
|
+
if (
|
|
404
|
+
lexical.isSymbolicLink()
|
|
405
|
+
|| !lexical.isFile()
|
|
406
|
+
|| lexical.size > maximumBytes
|
|
407
|
+
) {
|
|
408
|
+
throw new Error(`Bundle file must be a bounded physical regular file: ${path}`);
|
|
409
|
+
}
|
|
410
|
+
const handle = await open(target, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
411
|
+
try {
|
|
412
|
+
const before = await handle.stat();
|
|
413
|
+
if (
|
|
414
|
+
!before.isFile()
|
|
415
|
+
|| before.dev !== lexical.dev
|
|
416
|
+
|| before.ino !== lexical.ino
|
|
417
|
+
|| before.size !== lexical.size
|
|
418
|
+
) {
|
|
419
|
+
throw new Error(`Bundle file changed before it was read: ${path}`);
|
|
420
|
+
}
|
|
421
|
+
// Read at most the admitted size plus one byte. A concurrent append must
|
|
422
|
+
// not turn readFile into an unbounded allocation before the final stat.
|
|
423
|
+
const buffer = Buffer.alloc(before.size + 1);
|
|
424
|
+
let count = 0;
|
|
425
|
+
while (count < buffer.byteLength) {
|
|
426
|
+
const result = await handle.read(buffer, count, Math.min(256 * 1024, buffer.byteLength - count), count);
|
|
427
|
+
if (result.bytesRead === 0) break;
|
|
428
|
+
count += result.bytesRead;
|
|
429
|
+
}
|
|
430
|
+
const bytes = buffer.subarray(0, count);
|
|
431
|
+
const after = await handle.stat();
|
|
432
|
+
if (
|
|
433
|
+
bytes.byteLength !== before.size
|
|
434
|
+
|| after.dev !== before.dev
|
|
435
|
+
|| after.ino !== before.ino
|
|
436
|
+
|| after.size !== before.size
|
|
437
|
+
|| after.mtimeMs !== before.mtimeMs
|
|
438
|
+
|| after.ctimeMs !== before.ctimeMs
|
|
439
|
+
) {
|
|
440
|
+
throw new Error(`Bundle file changed while it was read: ${path}`);
|
|
441
|
+
}
|
|
442
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
443
|
+
} finally {
|
|
444
|
+
await handle.close();
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
async function inspectPhysicalFile(path: string, maximumBytes = Number.MAX_SAFE_INTEGER): Promise<BundleFileIntegrity> {
|
|
449
|
+
if (!Number.isSafeInteger(maximumBytes) || maximumBytes < 0) throw new RangeError("Invalid physical file byte limit.");
|
|
450
|
+
const target = await safePath(path, false);
|
|
451
|
+
const lexical = await lstat(target);
|
|
452
|
+
if (lexical.isSymbolicLink() || !lexical.isFile() || lexical.size > maximumBytes) {
|
|
453
|
+
throw new Error(`Bundle file must be a physical regular file: ${path}`);
|
|
454
|
+
}
|
|
455
|
+
async function assertTargetStillNamesOpenedFile(
|
|
456
|
+
expected: Stats,
|
|
457
|
+
): Promise<void> {
|
|
458
|
+
const current = await lstat(target).catch((error: unknown) => {
|
|
459
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
|
460
|
+
return null;
|
|
461
|
+
}
|
|
462
|
+
throw error;
|
|
463
|
+
});
|
|
464
|
+
if (
|
|
465
|
+
current === null
|
|
466
|
+
|| current.isSymbolicLink()
|
|
467
|
+
|| !current.isFile()
|
|
468
|
+
|| !sameNonLinkSnapshot(expected, current)
|
|
469
|
+
|| current.nlink !== expected.nlink
|
|
470
|
+
|| current.ctimeMs !== expected.ctimeMs
|
|
471
|
+
) {
|
|
472
|
+
throw new Error(`Bundle file changed while it was inspected: ${path}`);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
const handle = await open(target, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
476
|
+
try {
|
|
477
|
+
const buffer = Buffer.allocUnsafe(256 * 1024);
|
|
478
|
+
let retrySnapshot: Stats | undefined;
|
|
479
|
+
let retryDigest: string | undefined;
|
|
480
|
+
for (const attempt of [1, 2] as const) {
|
|
481
|
+
const before = await handle.stat();
|
|
482
|
+
if (
|
|
483
|
+
!before.isFile()
|
|
484
|
+
|| before.dev !== lexical.dev
|
|
485
|
+
|| before.ino !== lexical.ino
|
|
486
|
+
|| before.size !== lexical.size
|
|
487
|
+
|| (retrySnapshot !== undefined && (
|
|
488
|
+
!sameNonLinkSnapshot(retrySnapshot, before)
|
|
489
|
+
|| before.nlink !== retrySnapshot.nlink
|
|
490
|
+
|| before.ctimeMs !== retrySnapshot.ctimeMs
|
|
491
|
+
))
|
|
492
|
+
) {
|
|
493
|
+
throw new Error(`Bundle file changed before it was inspected: ${path}`);
|
|
494
|
+
}
|
|
495
|
+
const digest = createHash("sha256");
|
|
496
|
+
let bytes = 0;
|
|
497
|
+
while (true) {
|
|
498
|
+
// Positional reads keep both bounded attempts pinned to byte zero on
|
|
499
|
+
// the same already-verified inode.
|
|
500
|
+
const result = await handle.read(
|
|
501
|
+
buffer,
|
|
502
|
+
0,
|
|
503
|
+
Math.min(buffer.byteLength, maximumBytes - bytes + 1),
|
|
504
|
+
bytes,
|
|
505
|
+
);
|
|
506
|
+
if (result.bytesRead === 0) break;
|
|
507
|
+
digest.update(buffer.subarray(0, result.bytesRead));
|
|
508
|
+
bytes += result.bytesRead;
|
|
509
|
+
if (!Number.isSafeInteger(bytes) || bytes > maximumBytes) {
|
|
510
|
+
throw new Error(`Bundle file exceeds safe byte accounting: ${path}`);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
await options.duringFileInspectionForTesting?.({ attempt, path });
|
|
514
|
+
const after = await handle.stat();
|
|
515
|
+
// Reading may update atime. A ctime transition invalidates this read;
|
|
516
|
+
// one fresh, stable pass must independently reproduce its exact bytes.
|
|
517
|
+
const nonLinkSnapshotStayedStable = bytes === before.size && sameNonLinkSnapshot(before, after);
|
|
518
|
+
const sha256 = digest.digest("hex");
|
|
519
|
+
if (retryDigest !== undefined && retryDigest !== sha256) {
|
|
520
|
+
throw new Error(`Bundle file changed while it was inspected: ${path}`);
|
|
521
|
+
}
|
|
522
|
+
if (
|
|
523
|
+
nonLinkSnapshotStayedStable
|
|
524
|
+
&& after.nlink === before.nlink
|
|
525
|
+
&& after.ctimeMs === before.ctimeMs
|
|
526
|
+
) {
|
|
527
|
+
await assertTargetStillNamesOpenedFile(after);
|
|
528
|
+
return {
|
|
529
|
+
bytes,
|
|
530
|
+
sha256: Sha256Schema.parse(sha256),
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
const metadataRequiresFreshRead = (
|
|
534
|
+
attempt === 1
|
|
535
|
+
&& nonLinkSnapshotStayedStable
|
|
536
|
+
&& after.ctimeMs !== before.ctimeMs
|
|
537
|
+
&& (after.nlink === before.nlink || (before.nlink >= 2 && after.nlink === before.nlink - 1))
|
|
538
|
+
);
|
|
539
|
+
if (!metadataRequiresFreshRead) {
|
|
540
|
+
throw new Error(`Bundle file changed while it was inspected: ${path}`);
|
|
541
|
+
}
|
|
542
|
+
await assertTargetStillNamesOpenedFile(after);
|
|
543
|
+
retrySnapshot = after;
|
|
544
|
+
retryDigest = sha256;
|
|
545
|
+
}
|
|
546
|
+
throw new Error(`Bundle file changed while it was inspected: ${path}`);
|
|
547
|
+
} finally {
|
|
548
|
+
await handle.close();
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
async function immutableLink(temporary: string, target: string): Promise<"created" | "exists"> {
|
|
553
|
+
try { await link(temporary, target); return "created"; }
|
|
554
|
+
catch (error) {
|
|
555
|
+
if (error instanceof Error && "code" in error && error.code === "EEXIST") return "exists";
|
|
556
|
+
throw error;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
async function guardedImmutableLink(temporary: string, target: string, expected: BundleFileIntegrity, beforePublication: () => Promise<void>): Promise<"created" | "exists"> {
|
|
561
|
+
const handle = await open(temporary, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
562
|
+
let staged: BigIntStats;
|
|
563
|
+
try {
|
|
564
|
+
staged = fstatSync(handle.fd, { bigint: true });
|
|
565
|
+
if (!staged.isFile() || staged.size !== BigInt(expected.bytes) || staged.nlink !== 1n || (staged.mode & 0o777n) !== 0o600n) throw new Error("Immutable stage is not a private exact-sized regular file.");
|
|
566
|
+
const digest = createHash("sha256"), buffer = Buffer.allocUnsafe(256 * 1024);
|
|
567
|
+
let bytes = 0;
|
|
568
|
+
while (bytes < expected.bytes) {
|
|
569
|
+
const count = readSync(handle.fd, buffer, 0, Math.min(buffer.length, expected.bytes - bytes), bytes);
|
|
570
|
+
if (count === 0) throw new Error("Immutable stage ended during verification.");
|
|
571
|
+
digest.update(buffer.subarray(0, count)); bytes += count;
|
|
572
|
+
}
|
|
573
|
+
const after = fstatSync(handle.fd, { bigint: true });
|
|
574
|
+
if (digest.digest("hex") !== expected.sha256 || after.size !== staged.size || after.mtimeNs !== staged.mtimeNs || after.ctimeNs !== staged.ctimeNs) throw new Error("Immutable stage changed during verification.");
|
|
575
|
+
} finally { await handle.close(); }
|
|
576
|
+
const parents = [];
|
|
577
|
+
for (let directory = dirname(target);; directory = dirname(directory)) {
|
|
578
|
+
const snapshot = lstatSync(directory, { bigint: true });
|
|
579
|
+
if (!snapshot.isDirectory() || snapshot.isSymbolicLink()) throw new Error("Immutable publication parent is unsafe.");
|
|
580
|
+
parents.push({ directory, snapshot });
|
|
581
|
+
if (dirname(directory) === directory) break;
|
|
582
|
+
}
|
|
583
|
+
await beforePublication();
|
|
584
|
+
for (const item of parents) {
|
|
585
|
+
const current = lstatSync(item.directory, { bigint: true });
|
|
586
|
+
if (!current.isDirectory() || current.isSymbolicLink() || current.dev !== item.snapshot.dev || current.ino !== item.snapshot.ino) throw new Error("Immutable publication parent changed during custody check.");
|
|
587
|
+
}
|
|
588
|
+
const current = lstatSync(temporary, { bigint: true });
|
|
589
|
+
if (!current.isFile() || current.isSymbolicLink() || current.dev !== staged.dev || current.ino !== staged.ino || current.size !== staged.size
|
|
590
|
+
|| current.mtimeNs !== staged.mtimeNs || current.ctimeNs !== staged.ctimeNs || current.nlink !== 1n || current.mode !== staged.mode) throw new Error("Immutable stage changed during custody check.");
|
|
591
|
+
// Complete physical revalidation and no-replace dispatch share one synchronous turn.
|
|
592
|
+
try { linkSync(temporary, target); return "created"; }
|
|
593
|
+
catch (error) {
|
|
594
|
+
if (error instanceof Error && "code" in error && error.code === "EEXIST") return "exists";
|
|
595
|
+
throw error;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
return {
|
|
600
|
+
inspectFile: inspectPhysicalFile,
|
|
601
|
+
readText: readPhysicalText,
|
|
602
|
+
async writeTextAtomicGuarded(path, contents, beforeReplace) {
|
|
603
|
+
const target = await safePath(path, true);
|
|
604
|
+
const parent = await lstat(dirname(target));
|
|
605
|
+
const temporary = `${target}.tmp-${randomUUID()}`;
|
|
606
|
+
const handle = await open(temporary, constants.O_CREAT | constants.O_EXCL | constants.O_RDWR | constants.O_NOFOLLOW, 0o600);
|
|
607
|
+
let identity: Stats | undefined;
|
|
608
|
+
let stagedSnapshot: BigIntStats;
|
|
609
|
+
try {
|
|
610
|
+
try {
|
|
611
|
+
identity = await handle.stat();
|
|
612
|
+
await handle.writeFile(contents, "utf8"); await handle.sync();
|
|
613
|
+
const expectedBytes = Buffer.from(contents, "utf8");
|
|
614
|
+
const actualBytes = Buffer.allocUnsafe(Math.min(expectedBytes.length, 256 * 1024));
|
|
615
|
+
let offset = 0;
|
|
616
|
+
while (offset < expectedBytes.length) {
|
|
617
|
+
const count = readSync(handle.fd, actualBytes, 0, Math.min(actualBytes.length, expectedBytes.length - offset), offset);
|
|
618
|
+
if (count === 0 || !actualBytes.subarray(0, count).equals(expectedBytes.subarray(offset, offset + count))) throw new Error("Guarded atomic publication stage differs from the requested bytes.");
|
|
619
|
+
offset += count;
|
|
620
|
+
}
|
|
621
|
+
stagedSnapshot = fstatSync(handle.fd, { bigint: true });
|
|
622
|
+
if (!stagedSnapshot.isFile() || stagedSnapshot.size !== BigInt(expectedBytes.length) || stagedSnapshot.nlink !== 1n) throw new Error("Guarded atomic publication stage metadata is invalid.");
|
|
623
|
+
}
|
|
624
|
+
finally { await handle.close(); }
|
|
625
|
+
const checked = await safePath(path, false);
|
|
626
|
+
const currentParent = await lstat(dirname(checked));
|
|
627
|
+
const staged = await lstat(temporary);
|
|
628
|
+
if (checked !== target || parent.dev !== currentParent.dev || parent.ino !== currentParent.ino
|
|
629
|
+
|| !staged.isFile() || staged.isSymbolicLink() || staged.dev !== identity.dev || staged.ino !== identity.ino) {
|
|
630
|
+
throw new Error("Guarded atomic publication path changed during staging.");
|
|
631
|
+
}
|
|
632
|
+
const parents = [];
|
|
633
|
+
for (let directory = dirname(target);; directory = dirname(directory)) {
|
|
634
|
+
const snapshot = lstatSync(directory, { bigint: true });
|
|
635
|
+
if (!snapshot.isDirectory() || snapshot.isSymbolicLink()) throw new Error("Guarded atomic publication parent is unsafe.");
|
|
636
|
+
parents.push({ directory, snapshot });
|
|
637
|
+
if (dirname(directory) === directory) break;
|
|
638
|
+
}
|
|
639
|
+
await beforeReplace();
|
|
640
|
+
// The asynchronous custody check may yield to other filesystem users.
|
|
641
|
+
// Revalidate the complete physical path and staged content metadata,
|
|
642
|
+
// then dispatch synchronously without another JavaScript await gap.
|
|
643
|
+
for (const item of parents) {
|
|
644
|
+
const current = lstatSync(item.directory, { bigint: true });
|
|
645
|
+
if (!current.isDirectory() || current.isSymbolicLink() || current.dev !== item.snapshot.dev || current.ino !== item.snapshot.ino) throw new Error("Guarded atomic publication parent changed during custody check.");
|
|
646
|
+
}
|
|
647
|
+
const finalStage = lstatSync(temporary, { bigint: true });
|
|
648
|
+
if (!finalStage.isFile() || finalStage.isSymbolicLink() || finalStage.dev !== stagedSnapshot.dev || finalStage.ino !== stagedSnapshot.ino
|
|
649
|
+
|| finalStage.size !== stagedSnapshot.size || finalStage.mtimeNs !== stagedSnapshot.mtimeNs || finalStage.ctimeNs !== stagedSnapshot.ctimeNs
|
|
650
|
+
|| finalStage.nlink !== 1n || finalStage.mode !== stagedSnapshot.mode) throw new Error("Guarded atomic publication stage changed during custody check.");
|
|
651
|
+
renameSync(temporary, target);
|
|
652
|
+
const directoryHandle = await open(dirname(target), constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
653
|
+
try { await directoryHandle.sync(); } finally { await directoryHandle.close(); }
|
|
654
|
+
} finally {
|
|
655
|
+
const staged = await lstat(temporary).catch((error: unknown) => {
|
|
656
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") return undefined;
|
|
657
|
+
throw error;
|
|
658
|
+
});
|
|
659
|
+
if (staged !== undefined && identity !== undefined && staged.dev === identity.dev && staged.ino === identity.ino && !staged.isSymbolicLink()) await unlink(temporary);
|
|
660
|
+
}
|
|
661
|
+
},
|
|
662
|
+
async writeTextAtomic(path, contents) {
|
|
663
|
+
const target = await safePath(path, true);
|
|
664
|
+
const temporary = `${target}.tmp-${randomUUID()}`;
|
|
665
|
+
const handle = await open(temporary, "wx", 0o600);
|
|
666
|
+
try {
|
|
667
|
+
await handle.writeFile(contents, "utf8");
|
|
668
|
+
await handle.sync();
|
|
669
|
+
} finally {
|
|
670
|
+
await handle.close();
|
|
671
|
+
}
|
|
672
|
+
await rename(temporary, target);
|
|
673
|
+
const directoryHandle = await open(dirname(target), "r");
|
|
674
|
+
try {
|
|
675
|
+
await directoryHandle.sync();
|
|
676
|
+
} finally {
|
|
677
|
+
await directoryHandle.close();
|
|
678
|
+
}
|
|
679
|
+
},
|
|
680
|
+
async writeTextNoReplace(path, contents, beforePublication) {
|
|
681
|
+
const target = await safePath(path, true);
|
|
682
|
+
const temporary = `${target}.tmp-${randomUUID()}`;
|
|
683
|
+
const handle = await open(temporary, "wx", 0o600);
|
|
684
|
+
let disposition: "created" | "exists" | undefined;
|
|
685
|
+
let cleanupError: Error | undefined;
|
|
686
|
+
try {
|
|
687
|
+
try {
|
|
688
|
+
await handle.writeFile(contents, "utf8");
|
|
689
|
+
await handle.sync();
|
|
690
|
+
} finally {
|
|
691
|
+
await handle.close();
|
|
692
|
+
}
|
|
693
|
+
if (beforePublication === undefined) disposition = await immutableLink(temporary, target);
|
|
694
|
+
else {
|
|
695
|
+
if (await safePath(path, false) !== target) throw new Error("Immutable publication path changed during staging.");
|
|
696
|
+
disposition = await guardedImmutableLink(temporary, target, { bytes: Buffer.byteLength(contents), sha256: sha256Hex(contents) }, beforePublication);
|
|
697
|
+
}
|
|
698
|
+
} finally {
|
|
699
|
+
try {
|
|
700
|
+
await unlink(temporary);
|
|
701
|
+
} catch (error) {
|
|
702
|
+
if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) {
|
|
703
|
+
cleanupError = error instanceof Error
|
|
704
|
+
? error
|
|
705
|
+
: new Error(String(error));
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
if (cleanupError !== undefined) throw cleanupError;
|
|
710
|
+
if (disposition === undefined) {
|
|
711
|
+
throw new Error(`Immutable publication did not resolve a disposition: ${path}`);
|
|
712
|
+
}
|
|
713
|
+
if (disposition === "created") {
|
|
714
|
+
const directoryHandle = await open(dirname(target), "r");
|
|
715
|
+
try {
|
|
716
|
+
await directoryHandle.sync();
|
|
717
|
+
} finally {
|
|
718
|
+
await directoryHandle.close();
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
return disposition;
|
|
722
|
+
},
|
|
723
|
+
async copyFileNoReplace(sourcePath, destinationPath, expectedInput, beforePublication) {
|
|
724
|
+
const expected = {
|
|
725
|
+
bytes: zSafeBytes(expectedInput.bytes),
|
|
726
|
+
sha256: Sha256Schema.parse(expectedInput.sha256),
|
|
727
|
+
};
|
|
728
|
+
if (sourcePath === destinationPath) {
|
|
729
|
+
throw new Error("Immutable bundle copy requires a distinct destination path.");
|
|
730
|
+
}
|
|
731
|
+
const source = await safePath(sourcePath, false);
|
|
732
|
+
const destination = await safePath(destinationPath, true);
|
|
733
|
+
const lexicalSource = await lstat(source);
|
|
734
|
+
if (lexicalSource.isSymbolicLink() || !lexicalSource.isFile()) {
|
|
735
|
+
throw new Error(`Immutable bundle copy source is not a physical file: ${sourcePath}`);
|
|
736
|
+
}
|
|
737
|
+
const sourceHandle = await open(
|
|
738
|
+
source,
|
|
739
|
+
constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK,
|
|
740
|
+
);
|
|
741
|
+
const temporary = join(
|
|
742
|
+
dirname(destination),
|
|
743
|
+
`${IMMUTABLE_COPY_TEMP_PREFIX}${randomUUID()}.tmp`,
|
|
744
|
+
);
|
|
745
|
+
let temporaryHandle: FileHandle | undefined;
|
|
746
|
+
let ownsTemporary = false;
|
|
747
|
+
let createdIdentity:
|
|
748
|
+
| { readonly dev: number; readonly ino: number }
|
|
749
|
+
| undefined;
|
|
750
|
+
try {
|
|
751
|
+
const sourceBefore = await sourceHandle.stat();
|
|
752
|
+
if (
|
|
753
|
+
!sourceBefore.isFile()
|
|
754
|
+
|| !sameNonLinkSnapshot(lexicalSource, sourceBefore)
|
|
755
|
+
|| sourceBefore.nlink !== lexicalSource.nlink
|
|
756
|
+
|| sourceBefore.size !== expected.bytes
|
|
757
|
+
) {
|
|
758
|
+
throw new Error(`Immutable bundle copy source changed before opening: ${sourcePath}`);
|
|
759
|
+
}
|
|
760
|
+
let existingIntegrity: BundleFileIntegrity | undefined;
|
|
761
|
+
try { existingIntegrity = await inspectPhysicalFile(destinationPath, expected.bytes); }
|
|
762
|
+
catch (error) {
|
|
763
|
+
if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error;
|
|
764
|
+
}
|
|
765
|
+
if (existingIntegrity !== undefined) {
|
|
766
|
+
if (
|
|
767
|
+
existingIntegrity.bytes !== expected.bytes
|
|
768
|
+
|| existingIntegrity.sha256 !== expected.sha256
|
|
769
|
+
) {
|
|
770
|
+
throw new Error(
|
|
771
|
+
`Immutable bundle copy destination contains different bytes: ${destinationPath}`,
|
|
772
|
+
);
|
|
773
|
+
}
|
|
774
|
+
await beforePublication?.();
|
|
775
|
+
return "exists";
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// A killed copier may leave this private stage behind, but can never
|
|
779
|
+
// expose partial bytes under the immutable destination name.
|
|
780
|
+
temporaryHandle = await open(
|
|
781
|
+
temporary,
|
|
782
|
+
constants.O_CREAT
|
|
783
|
+
| constants.O_EXCL
|
|
784
|
+
| constants.O_WRONLY
|
|
785
|
+
| constants.O_NOFOLLOW,
|
|
786
|
+
0o600,
|
|
787
|
+
);
|
|
788
|
+
ownsTemporary = true;
|
|
789
|
+
const temporaryBefore = await temporaryHandle.stat();
|
|
790
|
+
const digest = createHash("sha256");
|
|
791
|
+
const buffer = Buffer.allocUnsafe(256 * 1024);
|
|
792
|
+
let bytes = 0;
|
|
793
|
+
while (true) {
|
|
794
|
+
const result = await sourceHandle.read(
|
|
795
|
+
buffer,
|
|
796
|
+
0,
|
|
797
|
+
Math.min(buffer.byteLength, expected.bytes - bytes + 1),
|
|
798
|
+
null,
|
|
799
|
+
);
|
|
800
|
+
if (result.bytesRead === 0) break;
|
|
801
|
+
if (bytes + result.bytesRead > expected.bytes) throw new Error(`Immutable bundle copy source grew beyond its exact byte limit: ${sourcePath}`);
|
|
802
|
+
digest.update(buffer.subarray(0, result.bytesRead));
|
|
803
|
+
let written = 0;
|
|
804
|
+
while (written < result.bytesRead) {
|
|
805
|
+
const write = await temporaryHandle.write(
|
|
806
|
+
buffer,
|
|
807
|
+
written,
|
|
808
|
+
result.bytesRead - written,
|
|
809
|
+
null,
|
|
810
|
+
);
|
|
811
|
+
if (write.bytesWritten < 1) {
|
|
812
|
+
throw new Error("Immutable bundle copy made no write progress.");
|
|
813
|
+
}
|
|
814
|
+
written += write.bytesWritten;
|
|
815
|
+
}
|
|
816
|
+
bytes += result.bytesRead;
|
|
817
|
+
if (!Number.isSafeInteger(bytes)) {
|
|
818
|
+
throw new Error(`Bundle file exceeds safe byte accounting: ${sourcePath}`);
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
await options.duringFileCopyForTesting?.({ attempt: 1, path: sourcePath });
|
|
822
|
+
const [sourceAfter, temporaryAfter] = await Promise.all([
|
|
823
|
+
sourceHandle.stat(),
|
|
824
|
+
temporaryHandle.stat(),
|
|
825
|
+
]);
|
|
826
|
+
const copiedSha256 = Sha256Schema.parse(digest.digest("hex"));
|
|
827
|
+
if (
|
|
828
|
+
bytes !== expected.bytes
|
|
829
|
+
|| copiedSha256 !== expected.sha256
|
|
830
|
+
|| !sameNonLinkSnapshot(sourceBefore, sourceAfter)
|
|
831
|
+
|| sourceAfter.nlink !== sourceBefore.nlink
|
|
832
|
+
|| temporaryAfter.dev !== temporaryBefore.dev
|
|
833
|
+
|| temporaryAfter.ino !== temporaryBefore.ino
|
|
834
|
+
|| temporaryAfter.size !== bytes
|
|
835
|
+
) {
|
|
836
|
+
throw new Error(`Immutable bundle copy source changed or failed verification: ${sourcePath}`);
|
|
837
|
+
}
|
|
838
|
+
let verifiedSource = sourceAfter;
|
|
839
|
+
if (sourceAfter.ctimeMs !== sourceBefore.ctimeMs) {
|
|
840
|
+
// A metadata-only transition invalidates the first source read. Do
|
|
841
|
+
// not recopy or ignore it: one stable complete pass on the same open
|
|
842
|
+
// inode must independently reproduce the exact expected bytes.
|
|
843
|
+
const retryBefore = await sourceHandle.stat();
|
|
844
|
+
if (!sameNonLinkSnapshot(sourceAfter, retryBefore)
|
|
845
|
+
|| retryBefore.nlink !== sourceAfter.nlink
|
|
846
|
+
|| retryBefore.ctimeMs !== sourceAfter.ctimeMs) {
|
|
847
|
+
throw new Error(`Immutable bundle copy source changed before verification reread: ${sourcePath}`);
|
|
848
|
+
}
|
|
849
|
+
const retryDigest = createHash("sha256");
|
|
850
|
+
let retryBytes = 0;
|
|
851
|
+
while (true) {
|
|
852
|
+
const result = await sourceHandle.read(buffer, 0, Math.min(buffer.byteLength, expected.bytes - retryBytes + 1), retryBytes);
|
|
853
|
+
if (result.bytesRead === 0) break;
|
|
854
|
+
if (retryBytes + result.bytesRead > expected.bytes) throw new Error(`Immutable bundle copy source grew during verification reread: ${sourcePath}`);
|
|
855
|
+
retryDigest.update(buffer.subarray(0, result.bytesRead));
|
|
856
|
+
retryBytes += result.bytesRead;
|
|
857
|
+
}
|
|
858
|
+
await options.duringFileCopyForTesting?.({ attempt: 2, path: sourcePath });
|
|
859
|
+
const retryAfter = await sourceHandle.stat();
|
|
860
|
+
if (retryBytes !== expected.bytes || retryDigest.digest("hex") !== expected.sha256
|
|
861
|
+
|| !sameNonLinkSnapshot(retryBefore, retryAfter)
|
|
862
|
+
|| retryAfter.nlink !== retryBefore.nlink
|
|
863
|
+
|| retryAfter.ctimeMs !== retryBefore.ctimeMs) {
|
|
864
|
+
throw new Error(`Immutable bundle copy source changed or failed verification reread: ${sourcePath}`);
|
|
865
|
+
}
|
|
866
|
+
verifiedSource = retryAfter;
|
|
867
|
+
}
|
|
868
|
+
const currentSource = await lstat(source);
|
|
869
|
+
if (currentSource.isSymbolicLink() || !sameNonLinkSnapshot(verifiedSource, currentSource)
|
|
870
|
+
|| currentSource.nlink !== verifiedSource.nlink || currentSource.ctimeMs !== verifiedSource.ctimeMs) {
|
|
871
|
+
throw new Error(`Immutable bundle copy source path changed after verification: ${sourcePath}`);
|
|
872
|
+
}
|
|
873
|
+
await temporaryHandle.sync();
|
|
874
|
+
await temporaryHandle.close();
|
|
875
|
+
temporaryHandle = undefined;
|
|
876
|
+
const staged = await lstat(temporary);
|
|
877
|
+
if (
|
|
878
|
+
staged.isSymbolicLink()
|
|
879
|
+
|| !staged.isFile()
|
|
880
|
+
|| staged.dev !== temporaryAfter.dev
|
|
881
|
+
|| staged.ino !== temporaryAfter.ino
|
|
882
|
+
|| staged.size !== temporaryAfter.size
|
|
883
|
+
) {
|
|
884
|
+
throw new Error(`Immutable bundle copy staging path changed: ${destinationPath}`);
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
let disposition: "created" | "exists";
|
|
888
|
+
if (beforePublication === undefined) disposition = await immutableLink(temporary, destination);
|
|
889
|
+
else {
|
|
890
|
+
if (await safePath(destinationPath, false) !== destination) throw new Error("Immutable copy destination changed during staging.");
|
|
891
|
+
disposition = await guardedImmutableLink(temporary, destination, expected, beforePublication);
|
|
892
|
+
}
|
|
893
|
+
if (disposition === "created") {
|
|
894
|
+
createdIdentity = {
|
|
895
|
+
dev: temporaryAfter.dev,
|
|
896
|
+
ino: temporaryAfter.ino,
|
|
897
|
+
};
|
|
898
|
+
}
|
|
899
|
+
const destinationIntegrity = await inspectPhysicalFile(destinationPath, expected.bytes);
|
|
900
|
+
if (
|
|
901
|
+
destinationIntegrity.bytes !== expected.bytes
|
|
902
|
+
|| destinationIntegrity.sha256 !== expected.sha256
|
|
903
|
+
) {
|
|
904
|
+
throw new Error(
|
|
905
|
+
disposition === "exists"
|
|
906
|
+
? `Immutable bundle copy destination contains different bytes: ${destinationPath}`
|
|
907
|
+
: `Immutable bundle copy failed read-back verification: ${destinationPath}`,
|
|
908
|
+
);
|
|
909
|
+
}
|
|
910
|
+
if (disposition === "created") {
|
|
911
|
+
await syncParentDirectory(destination);
|
|
912
|
+
}
|
|
913
|
+
return disposition;
|
|
914
|
+
} catch (error) {
|
|
915
|
+
if (createdIdentity !== undefined) {
|
|
916
|
+
const current = await lstat(destination).catch((lstatError: unknown) => {
|
|
917
|
+
if (
|
|
918
|
+
lstatError instanceof Error
|
|
919
|
+
&& "code" in lstatError
|
|
920
|
+
&& lstatError.code === "ENOENT"
|
|
921
|
+
) return null;
|
|
922
|
+
throw lstatError;
|
|
923
|
+
});
|
|
924
|
+
if (
|
|
925
|
+
current !== null
|
|
926
|
+
&& !current.isSymbolicLink()
|
|
927
|
+
&& current.isFile()
|
|
928
|
+
&& current.dev === createdIdentity.dev
|
|
929
|
+
&& current.ino === createdIdentity.ino
|
|
930
|
+
) {
|
|
931
|
+
await unlink(destination);
|
|
932
|
+
await syncParentDirectory(destination);
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
throw error;
|
|
936
|
+
} finally {
|
|
937
|
+
if (temporaryHandle !== undefined) {
|
|
938
|
+
await temporaryHandle.close().catch(() => undefined);
|
|
939
|
+
}
|
|
940
|
+
if (ownsTemporary) {
|
|
941
|
+
await unlink(temporary).catch((error: unknown) => {
|
|
942
|
+
if (
|
|
943
|
+
error instanceof Error
|
|
944
|
+
&& "code" in error
|
|
945
|
+
&& error.code === "ENOENT"
|
|
946
|
+
) return;
|
|
947
|
+
throw error;
|
|
948
|
+
});
|
|
949
|
+
}
|
|
950
|
+
await sourceHandle.close();
|
|
951
|
+
}
|
|
952
|
+
},
|
|
953
|
+
};
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
function zSafeBytes(value: number): number {
|
|
957
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
958
|
+
throw new TypeError("File byte count must be a nonnegative safe integer.");
|
|
959
|
+
}
|
|
960
|
+
return value;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
async function syncParentDirectory(path: string): Promise<void> {
|
|
964
|
+
const handle = await open(dirname(path), "r");
|
|
965
|
+
try {
|
|
966
|
+
await handle.sync();
|
|
967
|
+
} finally {
|
|
968
|
+
await handle.close();
|
|
969
|
+
}
|
|
970
|
+
}
|