@animalabs/agent-framework 0.21.1 → 0.22.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -4
- package/dist/src/agent.d.ts +17 -4
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +105 -43
- package/dist/src/agent.js.map +1 -1
- package/dist/src/api/server.d.ts.map +1 -1
- package/dist/src/api/server.js +41 -4
- package/dist/src/api/server.js.map +1 -1
- package/dist/src/code-execution/py-runner.d.ts +22 -1
- package/dist/src/code-execution/py-runner.d.ts.map +1 -1
- package/dist/src/code-execution/py-runner.js +64 -9
- package/dist/src/code-execution/py-runner.js.map +1 -1
- package/dist/src/code-execution/runtime-py.d.ts +4 -0
- package/dist/src/code-execution/runtime-py.d.ts.map +1 -1
- package/dist/src/code-execution/runtime-py.js +215 -38
- package/dist/src/code-execution/runtime-py.js.map +1 -1
- package/dist/src/framework.d.ts +569 -59
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +3091 -542
- package/dist/src/framework.js.map +1 -1
- package/dist/src/gate/event-gate.d.ts +21 -0
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/event-gate.js +40 -7
- package/dist/src/gate/event-gate.js.map +1 -1
- package/dist/src/index.d.ts +7 -2
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +4 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +26 -3
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/conversation-router.d.ts +28 -1
- package/dist/src/mcpl/conversation-router.d.ts.map +1 -1
- package/dist/src/mcpl/conversation-router.js +17 -0
- package/dist/src/mcpl/conversation-router.js.map +1 -1
- package/dist/src/mcpl/feature-set-manager.d.ts +11 -0
- package/dist/src/mcpl/feature-set-manager.d.ts.map +1 -1
- package/dist/src/mcpl/feature-set-manager.js +31 -7
- package/dist/src/mcpl/feature-set-manager.js.map +1 -1
- package/dist/src/mcpl/index.d.ts +4 -0
- package/dist/src/mcpl/index.d.ts.map +1 -1
- package/dist/src/mcpl/index.js +1 -0
- package/dist/src/mcpl/index.js.map +1 -1
- package/dist/src/mcpl/prose-stream-router.d.ts +27 -6
- package/dist/src/mcpl/prose-stream-router.d.ts.map +1 -1
- package/dist/src/mcpl/prose-stream-router.js +85 -22
- package/dist/src/mcpl/prose-stream-router.js.map +1 -1
- package/dist/src/mcpl/push-coalescer.d.ts +32 -4
- package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
- package/dist/src/mcpl/push-coalescer.js +46 -13
- package/dist/src/mcpl/push-coalescer.js.map +1 -1
- package/dist/src/mcpl/push-handler.d.ts +5 -3
- package/dist/src/mcpl/push-handler.d.ts.map +1 -1
- package/dist/src/mcpl/push-handler.js +41 -11
- package/dist/src/mcpl/push-handler.js.map +1 -1
- package/dist/src/mcpl/reference-fetcher.d.ts +2 -1
- package/dist/src/mcpl/reference-fetcher.d.ts.map +1 -1
- package/dist/src/mcpl/reference-fetcher.js +20 -9
- package/dist/src/mcpl/reference-fetcher.js.map +1 -1
- package/dist/src/mcpl/references.d.ts +29 -6
- package/dist/src/mcpl/references.d.ts.map +1 -1
- package/dist/src/mcpl/references.js +57 -16
- package/dist/src/mcpl/references.js.map +1 -1
- package/dist/src/mcpl/server-connection.d.ts +32 -1
- package/dist/src/mcpl/server-connection.d.ts.map +1 -1
- package/dist/src/mcpl/server-connection.js +76 -11
- package/dist/src/mcpl/server-connection.js.map +1 -1
- package/dist/src/mcpl/server-registry.d.ts +5 -2
- package/dist/src/mcpl/server-registry.d.ts.map +1 -1
- package/dist/src/mcpl/server-registry.js +25 -7
- package/dist/src/mcpl/server-registry.js.map +1 -1
- package/dist/src/mcpl/tool-lifecycle.d.ts +52 -2
- package/dist/src/mcpl/tool-lifecycle.d.ts.map +1 -1
- package/dist/src/mcpl/tool-lifecycle.js +47 -1
- package/dist/src/mcpl/tool-lifecycle.js.map +1 -1
- package/dist/src/mcpl/tool-pattern-check.d.ts +51 -0
- package/dist/src/mcpl/tool-pattern-check.d.ts.map +1 -0
- package/dist/src/mcpl/tool-pattern-check.js +79 -0
- package/dist/src/mcpl/tool-pattern-check.js.map +1 -0
- package/dist/src/mcpl/types.d.ts +15 -5
- package/dist/src/mcpl/types.d.ts.map +1 -1
- package/dist/src/mcpl/types.js.map +1 -1
- package/dist/src/mcpl/visible-content.d.ts +39 -0
- package/dist/src/mcpl/visible-content.d.ts.map +1 -0
- package/dist/src/mcpl/visible-content.js +54 -0
- package/dist/src/mcpl/visible-content.js.map +1 -0
- package/dist/src/module-registry.d.ts +90 -6
- package/dist/src/module-registry.d.ts.map +1 -1
- package/dist/src/module-registry.js +260 -18
- package/dist/src/module-registry.js.map +1 -1
- package/dist/src/modules/workspace/branch-intent.d.ts +72 -0
- package/dist/src/modules/workspace/branch-intent.d.ts.map +1 -0
- package/dist/src/modules/workspace/branch-intent.js +83 -0
- package/dist/src/modules/workspace/branch-intent.js.map +1 -0
- package/dist/src/modules/workspace/disk-agreement.d.ts +167 -0
- package/dist/src/modules/workspace/disk-agreement.d.ts.map +1 -0
- package/dist/src/modules/workspace/disk-agreement.js +259 -0
- package/dist/src/modules/workspace/disk-agreement.js.map +1 -0
- package/dist/src/modules/workspace/effects.d.ts +84 -0
- package/dist/src/modules/workspace/effects.d.ts.map +1 -0
- package/dist/src/modules/workspace/effects.js +368 -0
- package/dist/src/modules/workspace/effects.js.map +1 -0
- package/dist/src/modules/workspace/index.d.ts +155 -17
- package/dist/src/modules/workspace/index.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.js +1101 -424
- package/dist/src/modules/workspace/index.js.map +1 -1
- package/dist/src/modules/workspace/observe.d.ts +288 -0
- package/dist/src/modules/workspace/observe.d.ts.map +1 -0
- package/dist/src/modules/workspace/observe.js +825 -0
- package/dist/src/modules/workspace/observe.js.map +1 -0
- package/dist/src/modules/workspace/reconcile.d.ts +238 -0
- package/dist/src/modules/workspace/reconcile.d.ts.map +1 -0
- package/dist/src/modules/workspace/reconcile.js +909 -0
- package/dist/src/modules/workspace/reconcile.js.map +1 -0
- package/dist/src/modules/workspace/sync.d.ts +6 -59
- package/dist/src/modules/workspace/sync.d.ts.map +1 -1
- package/dist/src/modules/workspace/sync.js +6 -257
- package/dist/src/modules/workspace/sync.js.map +1 -1
- package/dist/src/modules/workspace/types.d.ts +77 -10
- package/dist/src/modules/workspace/types.d.ts.map +1 -1
- package/dist/src/modules/workspace/types.js.map +1 -1
- package/dist/src/operator-log.d.ts +41 -0
- package/dist/src/operator-log.d.ts.map +1 -1
- package/dist/src/operator-log.js.map +1 -1
- package/dist/src/provider-waits.d.ts +216 -0
- package/dist/src/provider-waits.d.ts.map +1 -0
- package/dist/src/provider-waits.js +482 -0
- package/dist/src/provider-waits.js.map +1 -0
- package/dist/src/record-journal.d.ts +127 -0
- package/dist/src/record-journal.d.ts.map +1 -0
- package/dist/src/record-journal.js +202 -0
- package/dist/src/record-journal.js.map +1 -0
- package/dist/src/refusal-reactions.d.ts +14 -12
- package/dist/src/refusal-reactions.d.ts.map +1 -1
- package/dist/src/refusal-reactions.js +14 -12
- package/dist/src/refusal-reactions.js.map +1 -1
- package/dist/src/shutdown.d.ts +13 -0
- package/dist/src/shutdown.d.ts.map +1 -0
- package/dist/src/shutdown.js +46 -0
- package/dist/src/shutdown.js.map +1 -0
- package/dist/src/silent-heartbeat.d.ts +74 -0
- package/dist/src/silent-heartbeat.d.ts.map +1 -0
- package/dist/src/silent-heartbeat.js +77 -0
- package/dist/src/silent-heartbeat.js.map +1 -0
- package/dist/src/tool-presentation.d.ts +50 -0
- package/dist/src/tool-presentation.d.ts.map +1 -0
- package/dist/src/tool-presentation.js +235 -0
- package/dist/src/tool-presentation.js.map +1 -0
- package/dist/src/tool-result-guard.d.ts +142 -6
- package/dist/src/tool-result-guard.d.ts.map +1 -1
- package/dist/src/tool-result-guard.js +357 -36
- package/dist/src/tool-result-guard.js.map +1 -1
- package/dist/src/tool-result-history.d.ts +13 -0
- package/dist/src/tool-result-history.d.ts.map +1 -1
- package/dist/src/tool-result-history.js +21 -1
- package/dist/src/tool-result-history.js.map +1 -1
- package/dist/src/types/agent.d.ts +18 -2
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/events.d.ts +16 -0
- package/dist/src/types/events.d.ts.map +1 -1
- package/dist/src/types/framework.d.ts +12 -0
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/src/types/module.d.ts +26 -4
- package/dist/src/types/module.d.ts.map +1 -1
- package/dist/src/types/trace.d.ts +87 -3
- package/dist/src/types/trace.d.ts.map +1 -1
- package/dist/test/batched-wake-locus.test.d.ts +2 -0
- package/dist/test/batched-wake-locus.test.d.ts.map +1 -0
- package/dist/test/batched-wake-locus.test.js +166 -0
- package/dist/test/batched-wake-locus.test.js.map +1 -0
- package/dist/test/code-execution.test.js +386 -2
- package/dist/test/code-execution.test.js.map +1 -1
- package/dist/test/component-defaults.test.d.ts +2 -0
- package/dist/test/component-defaults.test.d.ts.map +1 -0
- package/dist/test/component-defaults.test.js +54 -0
- package/dist/test/component-defaults.test.js.map +1 -0
- package/dist/test/conversation-routing.test.js +57 -1
- package/dist/test/conversation-routing.test.js.map +1 -1
- package/dist/test/discord-awareness-outbox.test.js +3 -0
- package/dist/test/discord-awareness-outbox.test.js.map +1 -1
- package/dist/test/ephemeral-settle.test.js +3 -0
- package/dist/test/ephemeral-settle.test.js.map +1 -1
- package/dist/test/event-gate.test.js +118 -0
- package/dist/test/event-gate.test.js.map +1 -1
- package/dist/test/explicit-prose-routing.test.js +975 -0
- package/dist/test/explicit-prose-routing.test.js.map +1 -1
- package/dist/test/framework.test.js +160 -1
- package/dist/test/framework.test.js.map +1 -1
- package/dist/test/helpers/coalescing-fixture.d.ts.map +1 -1
- package/dist/test/helpers/coalescing-fixture.js +3 -2
- package/dist/test/helpers/coalescing-fixture.js.map +1 -1
- package/dist/test/helpers/pytc-stop-rows.d.ts +11 -0
- package/dist/test/helpers/pytc-stop-rows.d.ts.map +1 -0
- package/dist/test/helpers/pytc-stop-rows.js +143 -0
- package/dist/test/helpers/pytc-stop-rows.js.map +1 -0
- package/dist/test/inference-exhaustion-rewind.test.js +142 -14
- package/dist/test/inference-exhaustion-rewind.test.js.map +1 -1
- package/dist/test/inference-failure-bounds.test.d.ts +2 -0
- package/dist/test/inference-failure-bounds.test.d.ts.map +1 -0
- package/dist/test/inference-failure-bounds.test.js +1201 -0
- package/dist/test/inference-failure-bounds.test.js.map +1 -0
- package/dist/test/late-events-after-stop.test.d.ts +2 -0
- package/dist/test/late-events-after-stop.test.d.ts.map +1 -0
- package/dist/test/late-events-after-stop.test.js +122 -0
- package/dist/test/late-events-after-stop.test.js.map +1 -0
- package/dist/test/mcpl-awareness-barrier.test.js +141 -0
- package/dist/test/mcpl-awareness-barrier.test.js.map +1 -1
- package/dist/test/mcpl-coalescing.test.js +97 -22
- package/dist/test/mcpl-coalescing.test.js.map +1 -1
- package/dist/test/mcpl-config-diagnostics.test.d.ts +2 -0
- package/dist/test/mcpl-config-diagnostics.test.d.ts.map +1 -0
- package/dist/test/mcpl-config-diagnostics.test.js +452 -0
- package/dist/test/mcpl-config-diagnostics.test.js.map +1 -0
- package/dist/test/mcpl-empty-content.test.d.ts +2 -0
- package/dist/test/mcpl-empty-content.test.d.ts.map +1 -0
- package/dist/test/mcpl-empty-content.test.js +368 -0
- package/dist/test/mcpl-empty-content.test.js.map +1 -0
- package/dist/test/mcpl-manifest-changed.test.js +2 -1
- package/dist/test/mcpl-manifest-changed.test.js.map +1 -1
- package/dist/test/mcpl-reference-fetcher.test.js +232 -4
- package/dist/test/mcpl-reference-fetcher.test.js.map +1 -1
- package/dist/test/mcpl-reference-fields.test.js +69 -2
- package/dist/test/mcpl-reference-fields.test.js.map +1 -1
- package/dist/test/mcpl-tool-lifecycle-emitter.test.js +106 -1
- package/dist/test/mcpl-tool-lifecycle-emitter.test.js.map +1 -1
- package/dist/test/mcpl-tool-lifecycle-script.test.d.ts +2 -0
- package/dist/test/mcpl-tool-lifecycle-script.test.d.ts.map +1 -0
- package/dist/test/mcpl-tool-lifecycle-script.test.js +288 -0
- package/dist/test/mcpl-tool-lifecycle-script.test.js.map +1 -0
- package/dist/test/mcpl-ws-transport.test.js +103 -0
- package/dist/test/mcpl-ws-transport.test.js.map +1 -1
- package/dist/test/ops-alert.test.js +2 -1
- package/dist/test/ops-alert.test.js.map +1 -1
- package/dist/test/placed-reaction-baseline.test.d.ts +2 -0
- package/dist/test/placed-reaction-baseline.test.d.ts.map +1 -0
- package/dist/test/placed-reaction-baseline.test.js +312 -0
- package/dist/test/placed-reaction-baseline.test.js.map +1 -0
- package/dist/test/prose-stream-router.test.js +41 -0
- package/dist/test/prose-stream-router.test.js.map +1 -1
- package/dist/test/provider-acceleration-cooldown.test.js +5 -1
- package/dist/test/provider-acceleration-cooldown.test.js.map +1 -1
- package/dist/test/provider-waits.test.d.ts +2 -0
- package/dist/test/provider-waits.test.d.ts.map +1 -0
- package/dist/test/provider-waits.test.js +2196 -0
- package/dist/test/provider-waits.test.js.map +1 -0
- package/dist/test/reaction-baseline-platform.test.d.ts +2 -0
- package/dist/test/reaction-baseline-platform.test.d.ts.map +1 -0
- package/dist/test/reaction-baseline-platform.test.js +124 -0
- package/dist/test/reaction-baseline-platform.test.js.map +1 -0
- package/dist/test/record-journal.test.d.ts +6 -0
- package/dist/test/record-journal.test.d.ts.map +1 -0
- package/dist/test/record-journal.test.js +269 -0
- package/dist/test/record-journal.test.js.map +1 -0
- package/dist/test/refusal-reaction-baseline.test.js +10 -3
- package/dist/test/refusal-reaction-baseline.test.js.map +1 -1
- package/dist/test/save-recent-image.test.js +240 -2
- package/dist/test/save-recent-image.test.js.map +1 -1
- package/dist/test/shutdown-errors.test.d.ts +2 -0
- package/dist/test/shutdown-errors.test.d.ts.map +1 -0
- package/dist/test/shutdown-errors.test.js +662 -0
- package/dist/test/shutdown-errors.test.js.map +1 -0
- package/dist/test/silent-heartbeat-rows.test.d.ts +2 -0
- package/dist/test/silent-heartbeat-rows.test.d.ts.map +1 -0
- package/dist/test/silent-heartbeat-rows.test.js +451 -0
- package/dist/test/silent-heartbeat-rows.test.js.map +1 -0
- package/dist/test/silent-heartbeat.test.js +49 -38
- package/dist/test/silent-heartbeat.test.js.map +1 -1
- package/dist/test/stop-with-work-in-flight.test.d.ts +23 -0
- package/dist/test/stop-with-work-in-flight.test.d.ts.map +1 -0
- package/dist/test/stop-with-work-in-flight.test.js +672 -0
- package/dist/test/stop-with-work-in-flight.test.js.map +1 -0
- package/dist/test/surgery-marks-receipt.test.d.ts +2 -0
- package/dist/test/surgery-marks-receipt.test.d.ts.map +1 -0
- package/dist/test/surgery-marks-receipt.test.js +540 -0
- package/dist/test/surgery-marks-receipt.test.js.map +1 -0
- package/dist/test/tool-batch-hook.test.d.ts +8 -0
- package/dist/test/tool-batch-hook.test.d.ts.map +1 -0
- package/dist/test/tool-batch-hook.test.js +275 -0
- package/dist/test/tool-batch-hook.test.js.map +1 -0
- package/dist/test/tool-presentation.test.d.ts +2 -0
- package/dist/test/tool-presentation.test.d.ts.map +1 -0
- package/dist/test/tool-presentation.test.js +268 -0
- package/dist/test/tool-presentation.test.js.map +1 -0
- package/dist/test/tool-result-guard.test.js +2545 -19
- package/dist/test/tool-result-guard.test.js.map +1 -1
- package/dist/test/tool-result-history.test.js +2 -1
- package/dist/test/tool-result-history.test.js.map +1 -1
- package/dist/test/turn-trigger-provenance.test.js +35 -0
- package/dist/test/turn-trigger-provenance.test.js.map +1 -1
- package/dist/test/unconvertible-tool-result.test.d.ts +2 -0
- package/dist/test/unconvertible-tool-result.test.d.ts.map +1 -0
- package/dist/test/unconvertible-tool-result.test.js +358 -0
- package/dist/test/unconvertible-tool-result.test.js.map +1 -0
- package/dist/test/workspace-materialize-freshness.test.d.ts +16 -0
- package/dist/test/workspace-materialize-freshness.test.d.ts.map +1 -0
- package/dist/test/workspace-materialize-freshness.test.js +252 -0
- package/dist/test/workspace-materialize-freshness.test.js.map +1 -0
- package/dist/test/workspace-three-way.test.d.ts +12 -0
- package/dist/test/workspace-three-way.test.d.ts.map +1 -0
- package/dist/test/workspace-three-way.test.js +3194 -0
- package/dist/test/workspace-three-way.test.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/agent.ts +124 -45
- package/src/api/server.ts +43 -4
- package/src/code-execution/py-runner.ts +66 -9
- package/src/code-execution/runtime-py.ts +215 -38
- package/src/framework.ts +3191 -544
- package/src/gate/event-gate.ts +59 -7
- package/src/index.ts +18 -2
- package/src/mcpl/channel-registry.ts +24 -3
- package/src/mcpl/conversation-router.ts +34 -1
- package/src/mcpl/feature-set-manager.ts +36 -11
- package/src/mcpl/index.ts +4 -0
- package/src/mcpl/prose-stream-router.ts +79 -23
- package/src/mcpl/push-coalescer.ts +62 -13
- package/src/mcpl/push-handler.ts +41 -12
- package/src/mcpl/reference-fetcher.ts +18 -8
- package/src/mcpl/references.ts +57 -13
- package/src/mcpl/server-connection.ts +95 -13
- package/src/mcpl/server-registry.ts +24 -7
- package/src/mcpl/tool-lifecycle.ts +77 -2
- package/src/mcpl/tool-pattern-check.ts +106 -0
- package/src/mcpl/types.ts +15 -5
- package/src/mcpl/visible-content.ts +57 -0
- package/src/module-registry.ts +269 -25
- package/src/modules/workspace/branch-intent.ts +123 -0
- package/src/modules/workspace/disk-agreement.ts +325 -0
- package/src/modules/workspace/effects.ts +362 -0
- package/src/modules/workspace/index.ts +1098 -446
- package/src/modules/workspace/observe.ts +865 -0
- package/src/modules/workspace/reconcile.ts +1157 -0
- package/src/modules/workspace/sync.ts +6 -336
- package/src/modules/workspace/types.ts +75 -10
- package/src/operator-log.ts +30 -0
- package/src/provider-waits.ts +523 -0
- package/src/record-journal.ts +246 -0
- package/src/refusal-reactions.ts +14 -12
- package/src/shutdown.ts +49 -0
- package/src/silent-heartbeat.ts +141 -0
- package/src/tool-presentation.ts +188 -0
- package/src/tool-result-guard.ts +384 -34
- package/src/tool-result-history.ts +25 -1
- package/src/types/agent.ts +18 -2
- package/src/types/events.ts +16 -0
- package/src/types/framework.ts +12 -0
- package/src/types/module.ts +27 -4
- package/src/types/trace.ts +94 -3
|
@@ -0,0 +1,865 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How the workspace observes disk for reconciliation, under the mount's
|
|
3
|
+
* boundary.
|
|
4
|
+
*
|
|
5
|
+
* Observation honors the boundary the way openContainedFile does: the lexical
|
|
6
|
+
* path is lstat'd first, a symlink is refused unless the mount follows
|
|
7
|
+
* symlinks, and a followed one must resolve inside the canonical mount root.
|
|
8
|
+
* Anything that isn't a regular file inside the mount is `unobserved` — it
|
|
9
|
+
* proves nothing either way, and reconciliation leaves the path alone.
|
|
10
|
+
*
|
|
11
|
+
* Reads go through one descriptor that is fstat'd, located inside the mount
|
|
12
|
+
* and then read, so what is read and the fingerprint recorded with its hash
|
|
13
|
+
* describe the same file, inside the mount. A hard link is a name inside the
|
|
14
|
+
* mount like any other, and reading through it shows what that path holds;
|
|
15
|
+
* only a write in place reaches a file's other names, so effects.ts refuses
|
|
16
|
+
* that instead.
|
|
17
|
+
*
|
|
18
|
+
* Directories are checked (CheckedDir): located inside the mount, with their
|
|
19
|
+
* identity, and what they showed — a listing, an absence — is accepted only
|
|
20
|
+
* if each is still the directory its logical path names afterwards. A walk records which
|
|
21
|
+
* directories it listed completely, and only those prove a file absent: an
|
|
22
|
+
* unreadable directory, an ignored subtree, the file cap, a symlink the walk
|
|
23
|
+
* doesn't descend, or a directory that changed while it was listed leave
|
|
24
|
+
* everything beneath them unproven. Outside a walk, absence is proven the
|
|
25
|
+
* same way: by a directory inside the mount, still at its path, that lacks
|
|
26
|
+
* the entry (confirmAbsent). A missing parent alone proves nothing, since a
|
|
27
|
+
* dangling symlink or a swapped directory looks just like one.
|
|
28
|
+
*
|
|
29
|
+
* Each check is made where the operation happens, against what is there at
|
|
30
|
+
* that moment, so the boundary holds against what is in the mount, including
|
|
31
|
+
* what appears while an operation runs. Node has no openat or fdopendir, so
|
|
32
|
+
* a directory is checked by its canonical path and identity, not held open:
|
|
33
|
+
* a parent replaced concurrently in the window between a check and its use
|
|
34
|
+
* (or replaced and restored around it) can still redirect a listing or
|
|
35
|
+
* lookup there. File contents are bound regardless: a file is read only
|
|
36
|
+
* through a descriptor bound to its path first.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { createHash } from 'node:crypto';
|
|
40
|
+
import { constants as fsConstants } from 'node:fs';
|
|
41
|
+
import type { BigIntStats } from 'node:fs';
|
|
42
|
+
import { lstat, mkdir, open, readdir, realpath, stat, statfs } from 'node:fs/promises';
|
|
43
|
+
import type { FileHandle } from 'node:fs/promises';
|
|
44
|
+
import { dirname, join, sep } from 'node:path';
|
|
45
|
+
import { sameRootIdentity, type Fingerprint, type RootIdentity } from './disk-agreement.js';
|
|
46
|
+
|
|
47
|
+
export interface MountView {
|
|
48
|
+
/** Mount root as configured. */
|
|
49
|
+
root: string;
|
|
50
|
+
/**
|
|
51
|
+
* The root directory disk last agreed under, once the mount has evidence.
|
|
52
|
+
* A root that resolves to any other directory is unavailable.
|
|
53
|
+
*/
|
|
54
|
+
rootIdentity?: RootIdentity;
|
|
55
|
+
followSymlinks: boolean;
|
|
56
|
+
/** Text above this size is not ingested. */
|
|
57
|
+
maxFileSize: number;
|
|
58
|
+
ignore: string[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type StatFingerprint = Omit<Fingerprint, 'hashedAt'>;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Opened with every read or write of a workspace path, where the platform has
|
|
65
|
+
* it: for a regular file it changes nothing, and a FIFO or other special file
|
|
66
|
+
* swapped in at the path neither blocks the open nor passes the regular-file
|
|
67
|
+
* check that follows.
|
|
68
|
+
*/
|
|
69
|
+
export const NONBLOCK = typeof fsConstants.O_NONBLOCK === 'number' ? fsConstants.O_NONBLOCK : 0;
|
|
70
|
+
|
|
71
|
+
export type Observation =
|
|
72
|
+
| { kind: 'absent' }
|
|
73
|
+
| { kind: 'file'; size: number; mtimeMs: number; fp: StatFingerprint }
|
|
74
|
+
| { kind: 'unobserved'; reason: string };
|
|
75
|
+
|
|
76
|
+
export interface ReadResult {
|
|
77
|
+
kind: 'read';
|
|
78
|
+
size: number;
|
|
79
|
+
mtimeMs: number;
|
|
80
|
+
fp: Fingerprint;
|
|
81
|
+
hash: string;
|
|
82
|
+
/** The whole file, when it is at most the requested limit. */
|
|
83
|
+
bytes: Buffer | null;
|
|
84
|
+
/** First bytes, for binary detection and MIME sniffing. */
|
|
85
|
+
head: Buffer;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The first bytes of a file and its size: nothing else was read or hashed. */
|
|
89
|
+
export interface HeadResult {
|
|
90
|
+
kind: 'head';
|
|
91
|
+
size: number;
|
|
92
|
+
mtimeMs: number;
|
|
93
|
+
head: Buffer;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Bytes hashed per read when streaming a file larger than the read limit. */
|
|
97
|
+
const HASH_CHUNK = 1 << 20;
|
|
98
|
+
/** Bytes kept for binary detection and MIME sniffing. */
|
|
99
|
+
export const HEAD_BYTES = 8192;
|
|
100
|
+
/** Timestamps this close to the hashing time can't vouch for the bytes. */
|
|
101
|
+
export const RACY_WINDOW_MS = 2000;
|
|
102
|
+
|
|
103
|
+
export const OUTSIDE_PARENT = 'a parent directory resolves outside the mount';
|
|
104
|
+
export const NOT_FOLLOWED = 'a symlink, which this mount does not follow';
|
|
105
|
+
export const ROOT_UNAVAILABLE = 'the mount root is unavailable';
|
|
106
|
+
export const ROOT_REPLACED =
|
|
107
|
+
'the mount root is not the directory disk last agreed with (an unmounted drive, or a replaced directory): ' +
|
|
108
|
+
'reconnect it; if it was replaced on purpose, sync the mount with acceptRoot to take it as it is now';
|
|
109
|
+
const CHANGED = 'it changed while it was observed';
|
|
110
|
+
const CHANGED_WALK = 'the directory changed while the walk ran';
|
|
111
|
+
const BENEATH_SYMLINK = 'beneath a symlink that does not lead to a directory inside the mount';
|
|
112
|
+
|
|
113
|
+
export function errno(err: unknown): string | undefined {
|
|
114
|
+
return (err as { code?: string } | null)?.code;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function contained(rootReal: string, candidate: string): boolean {
|
|
118
|
+
return candidate === rootReal || candidate.startsWith(rootReal.endsWith(sep) ? rootReal : rootReal + sep);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The mount-relative parent of a path ('' for the root). */
|
|
122
|
+
export function parentOf(relativePath: string): string {
|
|
123
|
+
const slash = relativePath.lastIndexOf('/');
|
|
124
|
+
return slash < 0 ? '' : relativePath.slice(0, slash);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The last component of a mount-relative path. */
|
|
128
|
+
export function nameOf(relativePath: string): string {
|
|
129
|
+
return relativePath.slice(relativePath.lastIndexOf('/') + 1);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function lexicalOf(view: MountView, rel: string): string {
|
|
133
|
+
return rel === '' ? view.root : join(view.root, rel);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ===========================================================================
|
|
137
|
+
// Checked directories
|
|
138
|
+
// ===========================================================================
|
|
139
|
+
|
|
140
|
+
export type DirCheck =
|
|
141
|
+
| { kind: 'checked'; dir: CheckedDir }
|
|
142
|
+
/**
|
|
143
|
+
* No directory there, proven: nothing can exist beneath the path. `by` is
|
|
144
|
+
* the directory whose entries prove it — the one that lacks it, or that
|
|
145
|
+
* holds the non-directory in its place.
|
|
146
|
+
*/
|
|
147
|
+
| { kind: 'absent'; by: string }
|
|
148
|
+
/** Unproven either way; `outside` when it resolves outside the mount. */
|
|
149
|
+
| { kind: 'unobserved'; reason: string; outside?: true };
|
|
150
|
+
|
|
151
|
+
/** Errors meaning a directory can't be synced on this filesystem, not that syncing it failed. */
|
|
152
|
+
const DIRECTORY_SYNC_UNSUPPORTED = new Set(['EINVAL', 'ENOTSUP']);
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* A directory inside the mount, as it was checked: its canonical path and
|
|
156
|
+
* its identity. Its entries are named through that path, and what it showed
|
|
157
|
+
* is accepted only while it is still the directory its logical path names
|
|
158
|
+
* (see the module doc for what that does and doesn't bind).
|
|
159
|
+
*/
|
|
160
|
+
export class CheckedDir {
|
|
161
|
+
constructor(
|
|
162
|
+
/** Mount-relative path ('' for the root). */
|
|
163
|
+
readonly rel: string,
|
|
164
|
+
/** Its canonical location when checked. */
|
|
165
|
+
readonly real: string,
|
|
166
|
+
private readonly lexical: string,
|
|
167
|
+
private readonly dev: bigint,
|
|
168
|
+
private readonly ino: bigint,
|
|
169
|
+
) {}
|
|
170
|
+
|
|
171
|
+
/** Which directory it is. */
|
|
172
|
+
identity(): RootIdentity {
|
|
173
|
+
return { dev: String(this.dev), ino: String(this.ino) };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** A path that reaches `name` in this directory. */
|
|
177
|
+
at(name: string): string {
|
|
178
|
+
return join(this.real, name);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Whether it is still the directory its logical path names: same location, same directory. */
|
|
182
|
+
async stillHere(): Promise<boolean> {
|
|
183
|
+
if ((await realpath(this.lexical).catch(() => null)) !== this.real) return false;
|
|
184
|
+
const info = await lstat(this.real, { bigint: true }).catch(() => null);
|
|
185
|
+
return info !== null && info.isDirectory() && info.dev === this.dev && info.ino === this.ino;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Make its entries durable: this very directory, never one that replaced
|
|
190
|
+
* it at its path. Throws if it can't; a filesystem that can't sync
|
|
191
|
+
* directories (or Windows, where a directory can't be opened for it) is
|
|
192
|
+
* not a failure.
|
|
193
|
+
*/
|
|
194
|
+
async sync(): Promise<void> {
|
|
195
|
+
if (process.platform === 'win32') return;
|
|
196
|
+
const handle = await open(this.real, 'r');
|
|
197
|
+
try {
|
|
198
|
+
const info = await handle.stat({ bigint: true });
|
|
199
|
+
if (!info.isDirectory() || info.dev !== this.dev || info.ino !== this.ino) throw new Error(CHANGED);
|
|
200
|
+
try {
|
|
201
|
+
await handle.sync();
|
|
202
|
+
} catch (err) {
|
|
203
|
+
if (!DIRECTORY_SYNC_UNSUPPORTED.has(errno(err) ?? '')) throw err;
|
|
204
|
+
}
|
|
205
|
+
} finally {
|
|
206
|
+
await handle.close();
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Check the directory at a canonical location, if one is there. */
|
|
212
|
+
async function checkedAt(view: MountView, rel: string, real: string): Promise<CheckedDir | null> {
|
|
213
|
+
const info = await lstat(real, { bigint: true }).catch(() => null);
|
|
214
|
+
if (!info?.isDirectory()) return null;
|
|
215
|
+
return new CheckedDir(rel, real, lexicalOf(view, rel), info.dev, info.ino);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Where a mount directory is now: checked; missing — nothing there, or not a
|
|
220
|
+
* directory, either way for confirmAbsent to prove; or not inside the mount.
|
|
221
|
+
*/
|
|
222
|
+
async function locateDir(view: MountView, rootReal: string, rel: string): Promise<DirCheck | { kind: 'missing' }> {
|
|
223
|
+
const lexical = lexicalOf(view, rel);
|
|
224
|
+
let real: string;
|
|
225
|
+
try {
|
|
226
|
+
real = await realpath(lexical);
|
|
227
|
+
} catch (err) {
|
|
228
|
+
const code = errno(err);
|
|
229
|
+
if (code === 'ENOENT' || code === 'ENOTDIR') return rel === '' ? { kind: 'unobserved', reason: ROOT_UNAVAILABLE } : { kind: 'missing' };
|
|
230
|
+
return { kind: 'unobserved', reason: `cannot resolve the directory (${code ?? String(err)})` };
|
|
231
|
+
}
|
|
232
|
+
if (rel === '') {
|
|
233
|
+
// The root is whatever the configured path names, and it must still be
|
|
234
|
+
// the root the pass was given.
|
|
235
|
+
if (real !== rootReal) return { kind: 'unobserved', reason: ROOT_UNAVAILABLE };
|
|
236
|
+
} else if (!contained(rootReal, real)) {
|
|
237
|
+
return { kind: 'unobserved', reason: OUTSIDE_PARENT, outside: true };
|
|
238
|
+
}
|
|
239
|
+
const checked = await checkedAt(view, rel, real);
|
|
240
|
+
if (checked) {
|
|
241
|
+
// A root that isn't the directory disk last agreed under proves nothing
|
|
242
|
+
// about the files that agreed: an empty mountpoint where a drive was
|
|
243
|
+
// would otherwise read as every file deleted.
|
|
244
|
+
if (rel === '' && view.rootIdentity && !sameRootIdentity(checked.identity(), view.rootIdentity)) {
|
|
245
|
+
return { kind: 'unobserved', reason: ROOT_REPLACED };
|
|
246
|
+
}
|
|
247
|
+
return { kind: 'checked', dir: checked };
|
|
248
|
+
}
|
|
249
|
+
return rel === '' ? { kind: 'unobserved', reason: ROOT_UNAVAILABLE } : { kind: 'missing' };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* The mount root as it is now: its identity if it is available (a directory,
|
|
254
|
+
* the one the pass was given, and the one disk last agreed under), else why not.
|
|
255
|
+
*/
|
|
256
|
+
export async function locateRoot(view: MountView, rootReal: string): Promise<{ identity: RootIdentity } | { reason: string }> {
|
|
257
|
+
const found = await checkRoot(view, rootReal);
|
|
258
|
+
return 'dir' in found ? { identity: found.dir.identity() } : found;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** The mount root as locateRoot finds it, checked, so a caller can ask later whether it's still there. */
|
|
262
|
+
export async function checkRoot(view: MountView, rootReal: string): Promise<{ dir: CheckedDir } | { reason: string }> {
|
|
263
|
+
const found = await locateDir(view, rootReal, '');
|
|
264
|
+
if (found.kind === 'checked') return { dir: found.dir };
|
|
265
|
+
return { reason: found.kind === 'unobserved' ? found.reason : ROOT_UNAVAILABLE };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Check the directory at a mount-relative path. With `create`, missing
|
|
270
|
+
* directories are made one at a time beneath the deepest one that exists;
|
|
271
|
+
* the mount root itself is never created (a missing root is an unavailable
|
|
272
|
+
* mount). Without it, a missing directory is `absent` only where that can be
|
|
273
|
+
* proven (confirmAbsent).
|
|
274
|
+
*/
|
|
275
|
+
export async function checkDir(view: MountView, rootReal: string, rel: string, opts: { create?: boolean } = {}): Promise<DirCheck> {
|
|
276
|
+
const found = await locateDir(view, rootReal, rel);
|
|
277
|
+
if (found.kind !== 'missing') return found;
|
|
278
|
+
if (opts.create) return createDir(view, rootReal, rel);
|
|
279
|
+
const proof = await confirmAbsent(view, rootReal, rel, 'beneath');
|
|
280
|
+
return typeof proof === 'string' ? { kind: 'unobserved', reason: proof } : { kind: 'absent', by: proof.by };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function createDir(view: MountView, rootReal: string, rel: string): Promise<DirCheck> {
|
|
284
|
+
const parts = rel.split('/');
|
|
285
|
+
let base: CheckedDir | null = null;
|
|
286
|
+
let depth = parts.length - 1;
|
|
287
|
+
for (; depth >= 0; depth--) {
|
|
288
|
+
const found = await locateDir(view, rootReal, parts.slice(0, depth).join('/'));
|
|
289
|
+
if (found.kind === 'checked') {
|
|
290
|
+
base = found.dir;
|
|
291
|
+
break;
|
|
292
|
+
}
|
|
293
|
+
if (found.kind !== 'missing') return found;
|
|
294
|
+
}
|
|
295
|
+
if (base === null) return { kind: 'unobserved', reason: ROOT_UNAVAILABLE };
|
|
296
|
+
for (let i = depth; i < parts.length; i++) {
|
|
297
|
+
const childRel = parts.slice(0, i + 1).join('/');
|
|
298
|
+
try {
|
|
299
|
+
await mkdir(base.at(parts[i]!));
|
|
300
|
+
} catch (err) {
|
|
301
|
+
const code = errno(err);
|
|
302
|
+
if (code !== 'EEXIST') return { kind: 'unobserved', reason: `cannot create the directory ${childRel} (${code ?? String(err)})` };
|
|
303
|
+
}
|
|
304
|
+
// Made, or there already (made meanwhile, or not a directory): it must
|
|
305
|
+
// be a real directory now, never a symlink or file in its place.
|
|
306
|
+
const checked = await checkedAt(view, childRel, base.at(parts[i]!));
|
|
307
|
+
if (!checked) return { kind: 'unobserved', reason: 'a parent path is not a directory' };
|
|
308
|
+
base = checked;
|
|
309
|
+
}
|
|
310
|
+
return { kind: 'checked', dir: base };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Prove that nothing exists at a mount-relative path (`entry`), or that no
|
|
315
|
+
* directory does, so nothing can exist beneath it (`beneath`). Walking down
|
|
316
|
+
* from the root through checked directories: some directory lacks the next
|
|
317
|
+
* component; or a component is a non-directory, still the same one, beneath
|
|
318
|
+
* which nothing can exist. Either proves it only while every directory the
|
|
319
|
+
* walk went through, the root included, is still the one its path names
|
|
320
|
+
* afterwards: a root or parent replaced during the walk would otherwise lend
|
|
321
|
+
* its replacement's absence to the original's path. Symlinked directories
|
|
322
|
+
* inside the mount are followed as parents; any other symlink proves
|
|
323
|
+
* nothing. Returns the directory whose entries prove it, or why absence
|
|
324
|
+
* couldn't be shown.
|
|
325
|
+
*/
|
|
326
|
+
export async function confirmAbsent(view: MountView, rootReal: string, rel: string, target: 'entry' | 'beneath' = 'entry'): Promise<{ by: string } | string> {
|
|
327
|
+
const root = await locateDir(view, rootReal, '');
|
|
328
|
+
if (root.kind !== 'checked') return ROOT_UNAVAILABLE;
|
|
329
|
+
let cur: CheckedDir = root.dir;
|
|
330
|
+
/** Every directory the walk went through, the root first. */
|
|
331
|
+
const walked: CheckedDir[] = [cur];
|
|
332
|
+
const proven = async (): Promise<{ by: string } | string> => {
|
|
333
|
+
for (const dir of walked) if (!(await dir.stillHere())) return CHANGED;
|
|
334
|
+
return { by: cur.rel };
|
|
335
|
+
};
|
|
336
|
+
const parts = rel.split('/');
|
|
337
|
+
for (let i = 0; i < parts.length; i++) {
|
|
338
|
+
const name = parts[i]!;
|
|
339
|
+
const last = i === parts.length - 1;
|
|
340
|
+
const childRel = parts.slice(0, i + 1).join('/');
|
|
341
|
+
let info;
|
|
342
|
+
try {
|
|
343
|
+
info = await lstat(cur.at(name), { bigint: true });
|
|
344
|
+
} catch (err) {
|
|
345
|
+
const code = errno(err);
|
|
346
|
+
if (code === 'ENOTDIR') return CHANGED;
|
|
347
|
+
if (code !== 'ENOENT') return `cannot stat (${code ?? String(err)})`;
|
|
348
|
+
// Absent from a directory that, like each above it, is still the one its path names.
|
|
349
|
+
return proven();
|
|
350
|
+
}
|
|
351
|
+
if (info.isDirectory()) {
|
|
352
|
+
if (last) return CHANGED; // present after all
|
|
353
|
+
cur = new CheckedDir(childRel, cur.at(name), lexicalOf(view, childRel), info.dev, info.ino);
|
|
354
|
+
walked.push(cur);
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
if (info.isSymbolicLink()) {
|
|
358
|
+
if (last && target === 'entry') return CHANGED; // present: the link itself
|
|
359
|
+
const located = await locateDir(view, rootReal, childRel);
|
|
360
|
+
if (located.kind !== 'checked') return BENEATH_SYMLINK;
|
|
361
|
+
if (last) return CHANGED; // a directory there after all
|
|
362
|
+
cur = located.dir;
|
|
363
|
+
walked.push(cur);
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
if (last && target === 'entry') return CHANGED; // present after all
|
|
367
|
+
// A non-directory: nothing can exist beneath it, while it is still this
|
|
368
|
+
// same one, in the directory still at its path.
|
|
369
|
+
const again = await lstat(cur.at(name), { bigint: true }).catch(() => null);
|
|
370
|
+
if (again === null || again.isDirectory() || again.isSymbolicLink() || again.dev !== info.dev || again.ino !== info.ino) return CHANGED;
|
|
371
|
+
return proven();
|
|
372
|
+
}
|
|
373
|
+
return CHANGED;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async function absence(view: MountView, rootReal: string, rel: string): Promise<Exclude<Observation, { kind: 'file' }>> {
|
|
377
|
+
const proof = await confirmAbsent(view, rootReal, rel);
|
|
378
|
+
return typeof proof === 'string' ? { kind: 'unobserved', reason: proof } : { kind: 'absent' };
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// ===========================================================================
|
|
382
|
+
// Fingerprints
|
|
383
|
+
// ===========================================================================
|
|
384
|
+
|
|
385
|
+
function statFingerprint(st: { size: bigint; mtimeNs: bigint; ctimeNs: bigint; ino: bigint; dev: bigint }): StatFingerprint {
|
|
386
|
+
return {
|
|
387
|
+
size: Number(st.size),
|
|
388
|
+
mtimeNs: st.mtimeNs.toString(),
|
|
389
|
+
ctimeNs: st.ctimeNs.toString(),
|
|
390
|
+
ino: st.ino.toString(),
|
|
391
|
+
dev: st.dev.toString(),
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** Same file, same bytes, as far as stat can tell. */
|
|
396
|
+
export function sameFingerprint(a: StatFingerprint, b: StatFingerprint): boolean {
|
|
397
|
+
return a.size === b.size && a.mtimeNs === b.mtimeNs && a.ctimeNs === b.ctimeNs && a.ino === b.ino && a.dev === b.dev;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Whether a recorded fingerprint vouches that the current file still holds
|
|
402
|
+
* the bytes hashed with it: the filesystem maintains ctime (so no user-space
|
|
403
|
+
* tool can restore the old stamps after a write), every field matches, and
|
|
404
|
+
* the file's timestamps were already more than the racy window old when it
|
|
405
|
+
* was hashed (a write in the same timestamp tick can't hide behind them).
|
|
406
|
+
* Device and inode are part of it, so a stat that reached another file — a
|
|
407
|
+
* path whose parent was swapped, say — can never vouch for P's bytes.
|
|
408
|
+
*/
|
|
409
|
+
export function fingerprintVouches(recorded: Fingerprint | undefined, current: StatFingerprint, trusted: boolean): boolean {
|
|
410
|
+
if (!trusted || !recorded || !sameFingerprint(recorded, current)) return false;
|
|
411
|
+
const newest = Math.max(Number(BigInt(current.mtimeNs) / 1_000_000n), Number(BigInt(current.ctimeNs) / 1_000_000n));
|
|
412
|
+
return recorded.hashedAt - newest > RACY_WINDOW_MS;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Linux filesystems known to maintain ctime on every content write (statfs
|
|
417
|
+
* f_type). Elsewhere a fingerprint can't vouch, and every observation rehashes.
|
|
418
|
+
*/
|
|
419
|
+
const CTIME_TRUSTED_FS_TYPES = new Set<number>([
|
|
420
|
+
0xef53, // ext2/ext3/ext4
|
|
421
|
+
0x58465342, // xfs
|
|
422
|
+
0x9123683e, // btrfs
|
|
423
|
+
0x01021994, // tmpfs
|
|
424
|
+
0xf2f52010, // f2fs
|
|
425
|
+
0x2fc12fc1, // zfs
|
|
426
|
+
0x794c7630, // overlayfs
|
|
427
|
+
]);
|
|
428
|
+
|
|
429
|
+
/** Whether a statfs f_type is one of those filesystems. */
|
|
430
|
+
export function fsTypeTrustsCtime(type: number | bigint): boolean {
|
|
431
|
+
return CTIME_TRUSTED_FS_TYPES.has(Number(type) >>> 0);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
export async function filesystemTrustsCtime(root: string): Promise<boolean> {
|
|
435
|
+
if (process.platform !== 'linux') return false;
|
|
436
|
+
try {
|
|
437
|
+
return fsTypeTrustsCtime((await statfs(root)).type);
|
|
438
|
+
} catch {
|
|
439
|
+
return false;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// ===========================================================================
|
|
444
|
+
// Paths and files
|
|
445
|
+
// ===========================================================================
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Where a path's parent directory canonically lies: inside the mount, gone
|
|
449
|
+
* (absence is then proven separately), or somewhere a no-follow open would
|
|
450
|
+
* still reach through — O_NOFOLLOW guards only the final component.
|
|
451
|
+
*/
|
|
452
|
+
async function parentPlacement(rootReal: string, lexical: string): Promise<'inside' | 'gone' | { reason: string }> {
|
|
453
|
+
try {
|
|
454
|
+
const parent = await realpath(dirname(lexical));
|
|
455
|
+
return contained(rootReal, parent) ? 'inside' : { reason: OUTSIDE_PARENT };
|
|
456
|
+
} catch (err) {
|
|
457
|
+
const code = errno(err);
|
|
458
|
+
if (code === 'ENOENT' || code === 'ENOTDIR') return 'gone';
|
|
459
|
+
return { reason: `cannot resolve the parent directory (${code ?? String(err)})` };
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Observe a mount-relative path without reading it. A `file` here is only a
|
|
465
|
+
* stat — anything decided on its bytes is read through a located descriptor,
|
|
466
|
+
* or vouched for by a fingerprint of the very inode P was recorded from — so
|
|
467
|
+
* only `absent` needs proof beyond the path lookup, and gets it.
|
|
468
|
+
*/
|
|
469
|
+
export async function observePath(view: MountView, rootReal: string, relativePath: string): Promise<Observation> {
|
|
470
|
+
const lexical = join(view.root, relativePath);
|
|
471
|
+
const placement = await parentPlacement(rootReal, lexical);
|
|
472
|
+
if (placement === 'gone') return absence(view, rootReal, relativePath);
|
|
473
|
+
if (placement !== 'inside') return { kind: 'unobserved', reason: placement.reason };
|
|
474
|
+
let info;
|
|
475
|
+
try {
|
|
476
|
+
info = await lstat(lexical, { bigint: true });
|
|
477
|
+
} catch (err) {
|
|
478
|
+
const code = errno(err);
|
|
479
|
+
if (code === 'ENOENT' || code === 'ENOTDIR') return absence(view, rootReal, relativePath);
|
|
480
|
+
return { kind: 'unobserved', reason: `cannot stat (${code ?? String(err)})` };
|
|
481
|
+
}
|
|
482
|
+
if (info.isSymbolicLink()) {
|
|
483
|
+
if (!view.followSymlinks) return { kind: 'unobserved', reason: NOT_FOLLOWED };
|
|
484
|
+
let target;
|
|
485
|
+
try {
|
|
486
|
+
target = await stat(lexical, { bigint: true });
|
|
487
|
+
} catch (err) {
|
|
488
|
+
return { kind: 'unobserved', reason: `a symlink whose target cannot be read (${errno(err) ?? String(err)})` };
|
|
489
|
+
}
|
|
490
|
+
let real: string;
|
|
491
|
+
try {
|
|
492
|
+
real = await realpath(lexical);
|
|
493
|
+
} catch (err) {
|
|
494
|
+
return { kind: 'unobserved', reason: `a symlink that cannot be resolved (${errno(err) ?? String(err)})` };
|
|
495
|
+
}
|
|
496
|
+
if (!contained(rootReal, real)) return { kind: 'unobserved', reason: 'a symlink that leaves the mount' };
|
|
497
|
+
if (!target.isFile()) {
|
|
498
|
+
return { kind: 'unobserved', reason: target.isDirectory() ? 'a symlink to a directory, not walked' : 'not a regular file' };
|
|
499
|
+
}
|
|
500
|
+
info = target;
|
|
501
|
+
} else if (info.isDirectory()) {
|
|
502
|
+
return { kind: 'unobserved', reason: 'a directory' };
|
|
503
|
+
} else if (!info.isFile()) {
|
|
504
|
+
return { kind: 'unobserved', reason: 'not a regular file' };
|
|
505
|
+
}
|
|
506
|
+
return { kind: 'file', size: Number(info.size), mtimeMs: Number(info.mtimeMs), fp: statFingerprint(info) };
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Open a mount-relative file for reading, located: a regular file that
|
|
511
|
+
* canonically lies inside the mount — whatever the symlink policy, since a
|
|
512
|
+
* symlinked parent escapes O_NOFOLLOW — and is the file the canonical path
|
|
513
|
+
* names now. The handle is the caller's to close.
|
|
514
|
+
*/
|
|
515
|
+
async function openContained(
|
|
516
|
+
view: MountView,
|
|
517
|
+
rootReal: string,
|
|
518
|
+
relativePath: string,
|
|
519
|
+
): Promise<{ handle: FileHandle; info: BigIntStats } | Exclude<Observation, { kind: 'file' }>> {
|
|
520
|
+
const lexical = join(view.root, relativePath);
|
|
521
|
+
const noFollow = !view.followSymlinks && typeof fsConstants.O_NOFOLLOW === 'number';
|
|
522
|
+
if (!view.followSymlinks && !noFollow) {
|
|
523
|
+
// No O_NOFOLLOW (Windows): refuse a symlink by lstat before opening.
|
|
524
|
+
const seen = await observePath(view, rootReal, relativePath);
|
|
525
|
+
if (seen.kind !== 'file') return seen;
|
|
526
|
+
}
|
|
527
|
+
let handle;
|
|
528
|
+
try {
|
|
529
|
+
// O_NONBLOCK: a FIFO swapped in since any lstat opens at once instead of
|
|
530
|
+
// waiting for a writer, and the regular-file check below refuses it.
|
|
531
|
+
handle = await open(lexical, fsConstants.O_RDONLY | NONBLOCK | (noFollow ? fsConstants.O_NOFOLLOW : 0));
|
|
532
|
+
} catch (err) {
|
|
533
|
+
const code = errno(err);
|
|
534
|
+
if (code === 'ENOENT' || code === 'ENOTDIR') return absence(view, rootReal, relativePath);
|
|
535
|
+
if (code === 'ELOOP' && !view.followSymlinks) return { kind: 'unobserved', reason: NOT_FOLLOWED };
|
|
536
|
+
if (code === 'EISDIR') return { kind: 'unobserved', reason: 'a directory' };
|
|
537
|
+
return { kind: 'unobserved', reason: `cannot open (${code ?? String(err)})` };
|
|
538
|
+
}
|
|
539
|
+
let keep = false;
|
|
540
|
+
try {
|
|
541
|
+
const info = await handle.stat({ bigint: true });
|
|
542
|
+
if (!info.isFile()) return { kind: 'unobserved', reason: info.isDirectory() ? 'a directory' : 'not a regular file' };
|
|
543
|
+
const real = await realpath(lexical).catch(() => null);
|
|
544
|
+
if (real === null) return { kind: 'unobserved', reason: 'the file changed while it was read' };
|
|
545
|
+
if (!contained(rootReal, real)) {
|
|
546
|
+
return { kind: 'unobserved', reason: view.followSymlinks ? 'a symlink that leaves the mount' : OUTSIDE_PARENT };
|
|
547
|
+
}
|
|
548
|
+
const named = await stat(real, { bigint: true }).catch(() => null);
|
|
549
|
+
if (named === null || named.dev !== info.dev || named.ino !== info.ino) {
|
|
550
|
+
return { kind: 'unobserved', reason: 'the file changed while it was read' };
|
|
551
|
+
}
|
|
552
|
+
if (!view.followSymlinks) {
|
|
553
|
+
// Where no symlink is followed, the path itself must be the file the
|
|
554
|
+
// descriptor holds: without O_NOFOLLOW, a link swapped in after the
|
|
555
|
+
// lstat above is followed by the open.
|
|
556
|
+
const entry = await lstat(lexical, { bigint: true }).catch(() => null);
|
|
557
|
+
if (entry?.isSymbolicLink()) return { kind: 'unobserved', reason: NOT_FOLLOWED };
|
|
558
|
+
if (entry === null || !entry.isFile() || entry.dev !== info.dev || entry.ino !== info.ino) {
|
|
559
|
+
return { kind: 'unobserved', reason: 'the file changed while it was read' };
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
keep = true;
|
|
563
|
+
return { handle, info };
|
|
564
|
+
} catch (err) {
|
|
565
|
+
const code = errno(err);
|
|
566
|
+
if (code === 'ENOENT') return absence(view, rootReal, relativePath);
|
|
567
|
+
return { kind: 'unobserved', reason: `cannot read (${code ?? String(err)})` };
|
|
568
|
+
} finally {
|
|
569
|
+
if (!keep) await handle.close();
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Read and hash a mount-relative file through one located descriptor. The
|
|
575
|
+
* whole file is returned when it is at most `limit` bytes; larger files are
|
|
576
|
+
* hashed in chunks and only their head is kept.
|
|
577
|
+
*/
|
|
578
|
+
export async function readPath(
|
|
579
|
+
view: MountView,
|
|
580
|
+
rootReal: string,
|
|
581
|
+
relativePath: string,
|
|
582
|
+
limit: number,
|
|
583
|
+
): Promise<ReadResult | Exclude<Observation, { kind: 'file' }>> {
|
|
584
|
+
const opened = await openContained(view, rootReal, relativePath);
|
|
585
|
+
if (!('handle' in opened)) return opened;
|
|
586
|
+
const { handle, info } = opened;
|
|
587
|
+
try {
|
|
588
|
+
const hashedAt = Date.now();
|
|
589
|
+
const size = Number(info.size);
|
|
590
|
+
const fp: Fingerprint = { ...statFingerprint(info), hashedAt };
|
|
591
|
+
if (size <= limit) {
|
|
592
|
+
const bytes = await handle.readFile();
|
|
593
|
+
return {
|
|
594
|
+
kind: 'read',
|
|
595
|
+
size: bytes.byteLength,
|
|
596
|
+
mtimeMs: Number(info.mtimeMs),
|
|
597
|
+
fp: { ...fp, size: bytes.byteLength },
|
|
598
|
+
hash: createHash('sha256').update(bytes).digest('hex'),
|
|
599
|
+
bytes,
|
|
600
|
+
head: bytes.subarray(0, HEAD_BYTES),
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
const hash = createHash('sha256');
|
|
604
|
+
const chunk = Buffer.alloc(HASH_CHUNK);
|
|
605
|
+
let head: Buffer | null = null;
|
|
606
|
+
let offset = 0;
|
|
607
|
+
for (;;) {
|
|
608
|
+
const { bytesRead } = await handle.read(chunk, 0, HASH_CHUNK, offset);
|
|
609
|
+
if (bytesRead === 0) break;
|
|
610
|
+
if (head === null) head = Buffer.from(chunk.subarray(0, Math.min(bytesRead, HEAD_BYTES)));
|
|
611
|
+
hash.update(chunk.subarray(0, bytesRead));
|
|
612
|
+
offset += bytesRead;
|
|
613
|
+
}
|
|
614
|
+
return {
|
|
615
|
+
kind: 'read',
|
|
616
|
+
size: offset,
|
|
617
|
+
mtimeMs: Number(info.mtimeMs),
|
|
618
|
+
fp: { ...fp, size: offset },
|
|
619
|
+
hash: hash.digest('hex'),
|
|
620
|
+
bytes: null,
|
|
621
|
+
head: head ?? Buffer.alloc(0),
|
|
622
|
+
};
|
|
623
|
+
} catch (err) {
|
|
624
|
+
const code = errno(err);
|
|
625
|
+
if (code === 'ENOENT') return absence(view, rootReal, relativePath);
|
|
626
|
+
return { kind: 'unobserved', reason: `cannot read (${code ?? String(err)})` };
|
|
627
|
+
} finally {
|
|
628
|
+
await handle.close();
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* The first bytes of a mount-relative file and its size, through the same
|
|
634
|
+
* located descriptor as readPath — for listing a file nothing tracks, which
|
|
635
|
+
* needs a MIME type, not a hash.
|
|
636
|
+
*/
|
|
637
|
+
export async function readHead(
|
|
638
|
+
view: MountView,
|
|
639
|
+
rootReal: string,
|
|
640
|
+
relativePath: string,
|
|
641
|
+
): Promise<HeadResult | Exclude<Observation, { kind: 'file' }>> {
|
|
642
|
+
const opened = await openContained(view, rootReal, relativePath);
|
|
643
|
+
if (!('handle' in opened)) return opened;
|
|
644
|
+
const { handle, info } = opened;
|
|
645
|
+
try {
|
|
646
|
+
const head = Buffer.alloc(HEAD_BYTES);
|
|
647
|
+
const { bytesRead } = await handle.read(head, 0, HEAD_BYTES, 0);
|
|
648
|
+
return { kind: 'head', size: Number(info.size), mtimeMs: Number(info.mtimeMs), head: head.subarray(0, bytesRead) };
|
|
649
|
+
} catch (err) {
|
|
650
|
+
return { kind: 'unobserved', reason: `cannot read (${errno(err) ?? String(err)})` };
|
|
651
|
+
} finally {
|
|
652
|
+
await handle.close();
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/** Null bytes in the first 8 KB: the store holds text only from disk. */
|
|
657
|
+
export function looksBinary(head: Buffer): boolean {
|
|
658
|
+
return head.subarray(0, HEAD_BYTES).includes(0);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/** Image MIME type from magic bytes, for listing disk-only files. */
|
|
662
|
+
export function sniffImageMime(head: Buffer): string | undefined {
|
|
663
|
+
if (head.length >= 8 && head.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return 'image/png';
|
|
664
|
+
if (head.length >= 3 && head[0] === 0xff && head[1] === 0xd8 && head[2] === 0xff) return 'image/jpeg';
|
|
665
|
+
if (head.length >= 6 && (head.subarray(0, 6).toString('ascii') === 'GIF87a' || head.subarray(0, 6).toString('ascii') === 'GIF89a')) return 'image/gif';
|
|
666
|
+
if (head.length >= 12 && head.subarray(0, 4).toString('ascii') === 'RIFF' && head.subarray(8, 12).toString('ascii') === 'WEBP') return 'image/webp';
|
|
667
|
+
return undefined;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// ===========================================================================
|
|
671
|
+
// Walks
|
|
672
|
+
// ===========================================================================
|
|
673
|
+
|
|
674
|
+
export interface Walk {
|
|
675
|
+
/** Regular files seen. */
|
|
676
|
+
files: Set<string>;
|
|
677
|
+
/** Directories seen. */
|
|
678
|
+
dirs: Set<string>;
|
|
679
|
+
/** Names seen that are neither (symlinks, sockets…), or ignored: present, unproven. */
|
|
680
|
+
others: Map<string, string>;
|
|
681
|
+
/** Directories listed completely. */
|
|
682
|
+
complete: Set<string>;
|
|
683
|
+
/** Directories that don't exist (everything beneath them is absent). */
|
|
684
|
+
missing: Set<string>;
|
|
685
|
+
/**
|
|
686
|
+
* Where the walk didn't reach, and why. `error` and `cap` regions may hide
|
|
687
|
+
* files the listing would show. An `ignored` region is passed over by
|
|
688
|
+
* design: one that hides a tracked entry is reported as incomplete, and
|
|
689
|
+
* one that hides nothing is counted as passed over. A `symlink` region is
|
|
690
|
+
* never walked; the pass reports the link itself by path.
|
|
691
|
+
*/
|
|
692
|
+
incomplete: Array<{ path: string; reason: string; kind: 'error' | 'cap' | 'ignored' | 'symlink' }>;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* Whether a mount's ignore list covers a mount-relative path: the path itself
|
|
697
|
+
* or any directory above it, as the walk's own test reads each entry by its
|
|
698
|
+
* path and name.
|
|
699
|
+
*/
|
|
700
|
+
export function coveredByIgnore(relativePath: string, patterns: string[]): boolean {
|
|
701
|
+
const segments = relativePath.split('/').filter(Boolean);
|
|
702
|
+
for (let i = 0; i < segments.length; i++) {
|
|
703
|
+
if (isIgnored(segments.slice(0, i + 1).join('/'), segments[i]!, patterns)) return true;
|
|
704
|
+
}
|
|
705
|
+
return false;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/** Simple ignore patterns, as the watcher and the old walk read them. */
|
|
709
|
+
export function isIgnored(relativePath: string, name: string, patterns: string[]): boolean {
|
|
710
|
+
for (const pattern of patterns) {
|
|
711
|
+
if (pattern === name) return true;
|
|
712
|
+
if (pattern.endsWith('/**')) {
|
|
713
|
+
const prefix = pattern.slice(0, -3);
|
|
714
|
+
if (relativePath === prefix || relativePath.startsWith(prefix + '/')) return true;
|
|
715
|
+
}
|
|
716
|
+
if (!pattern.includes('/') && relativePath === pattern) return true;
|
|
717
|
+
if (pattern.startsWith('*.') && name.endsWith(pattern.slice(1))) return true;
|
|
718
|
+
}
|
|
719
|
+
return false;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* A subdirectory a checked directory's listing showed, checked in turn: still a
|
|
724
|
+
* real directory beneath that same directory. One that became a symlink or a
|
|
725
|
+
* file since is never followed, and proves nothing.
|
|
726
|
+
*/
|
|
727
|
+
async function checkChild(view: MountView, rootReal: string, parent: CheckedDir, rel: string): Promise<DirCheck> {
|
|
728
|
+
const real = parent.at(nameOf(rel));
|
|
729
|
+
let info;
|
|
730
|
+
try {
|
|
731
|
+
info = await lstat(real, { bigint: true });
|
|
732
|
+
} catch (err) {
|
|
733
|
+
if (errno(err) !== 'ENOENT') return { kind: 'unobserved', reason: `cannot stat (${errno(err) ?? String(err)})` };
|
|
734
|
+
const proof = await confirmAbsent(view, rootReal, rel, 'beneath');
|
|
735
|
+
return typeof proof === 'string' ? { kind: 'unobserved', reason: proof } : { kind: 'absent', by: proof.by };
|
|
736
|
+
}
|
|
737
|
+
if (!info.isDirectory()) return { kind: 'unobserved', reason: CHANGED_WALK };
|
|
738
|
+
return { kind: 'checked', dir: new CheckedDir(rel, real, lexicalOf(view, rel), info.dev, info.ino) };
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* List a directory scope ('' for the mount root), descending when recursive,
|
|
743
|
+
* up to `cap` files in total. A scope that resolves outside the mount is not
|
|
744
|
+
* listed at all; beneath it, the walk only descends real directories, never
|
|
745
|
+
* symlinks. Each directory's listing counts only if, once listed, it is still
|
|
746
|
+
* the directory at its path: a directory moved or replaced meanwhile is an
|
|
747
|
+
* incomplete region, contributing no names and proving nothing absent.
|
|
748
|
+
*/
|
|
749
|
+
export async function walkScope(
|
|
750
|
+
view: MountView, rootReal: string, scope: string, recursive: boolean, cap: number,
|
|
751
|
+
opts: { named?: boolean } = {},
|
|
752
|
+
): Promise<Walk> {
|
|
753
|
+
const walk: Walk = { files: new Set(), dirs: new Set(), others: new Map(), complete: new Set(), missing: new Set(), incomplete: [] };
|
|
754
|
+
const top = await checkDir(view, rootReal, scope);
|
|
755
|
+
if (top.kind === 'absent') {
|
|
756
|
+
walk.missing.add(scope);
|
|
757
|
+
return walk;
|
|
758
|
+
}
|
|
759
|
+
if (top.kind === 'unobserved') {
|
|
760
|
+
walk.incomplete.push({ path: scope, reason: top.outside ? 'the directory resolves outside the mount' : top.reason, kind: 'error' });
|
|
761
|
+
return walk;
|
|
762
|
+
}
|
|
763
|
+
let capped = false;
|
|
764
|
+
// A scope a path sync names is walked even where the ignore list covers
|
|
765
|
+
// it: a pattern covering the scope itself, or a directory above it, doesn't
|
|
766
|
+
// apply beneath it. So a named directory's contents are synced, as a named
|
|
767
|
+
// file is (agent-framework #276). A listing keeps the ignore: its pass
|
|
768
|
+
// would otherwise start tracking what it lists.
|
|
769
|
+
const ignore = opts.named ? view.ignore.filter((pattern) => !coveredByIgnore(scope, [pattern])) : view.ignore;
|
|
770
|
+
|
|
771
|
+
const visit = async (dir: CheckedDir): Promise<void> => {
|
|
772
|
+
const rel = dir.rel;
|
|
773
|
+
if (capped) {
|
|
774
|
+
walk.incomplete.push({ path: rel, reason: `not visited: the file cap (${cap}) was reached`, kind: 'cap' });
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
let entries;
|
|
778
|
+
try {
|
|
779
|
+
entries = await readdir(dir.real, { withFileTypes: true });
|
|
780
|
+
} catch (err) {
|
|
781
|
+
const code = errno(err);
|
|
782
|
+
if (code === 'ENOENT' || code === 'ENOTDIR') {
|
|
783
|
+
// Gone since it was checked: absent beneath only if that can be shown.
|
|
784
|
+
const proof = rel === '' ? ROOT_UNAVAILABLE : await confirmAbsent(view, rootReal, rel, 'beneath');
|
|
785
|
+
if (typeof proof === 'string') walk.incomplete.push({ path: rel, reason: proof, kind: 'error' });
|
|
786
|
+
else walk.missing.add(rel);
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
walk.incomplete.push({ path: rel, reason: `cannot list (${code ?? String(err)})`, kind: 'error' });
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
const files: string[] = [];
|
|
793
|
+
const subdirs: string[] = [];
|
|
794
|
+
const others: Array<[string, string]> = [];
|
|
795
|
+
const regions: Walk['incomplete'] = [];
|
|
796
|
+
let reachedCap = false;
|
|
797
|
+
for (const entry of entries) {
|
|
798
|
+
const childRel = rel ? `${rel}/${entry.name}` : entry.name;
|
|
799
|
+
if (isIgnored(childRel, entry.name, ignore)) {
|
|
800
|
+
others.push([childRel, 'ignored by the mount']);
|
|
801
|
+
regions.push({ path: childRel, reason: 'ignored by the mount', kind: 'ignored' });
|
|
802
|
+
continue;
|
|
803
|
+
}
|
|
804
|
+
if (entry.isDirectory()) {
|
|
805
|
+
subdirs.push(childRel);
|
|
806
|
+
} else if (entry.isFile()) {
|
|
807
|
+
if (walk.files.size + files.length >= cap) {
|
|
808
|
+
capped = true;
|
|
809
|
+
reachedCap = true;
|
|
810
|
+
regions.push({ path: rel, reason: `the file cap (${cap}) was reached`, kind: 'cap' });
|
|
811
|
+
break;
|
|
812
|
+
}
|
|
813
|
+
files.push(childRel);
|
|
814
|
+
} else {
|
|
815
|
+
const symlink = entry.isSymbolicLink();
|
|
816
|
+
others.push([childRel, symlink ? 'a symlink' : 'not a regular file']);
|
|
817
|
+
if (symlink) regions.push({ path: childRel, reason: 'a symlink, not walked', kind: 'symlink' });
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
// A listing of a directory that was moved or replaced meanwhile says
|
|
821
|
+
// nothing about this path: neither its names nor its absences count.
|
|
822
|
+
if (!(await dir.stillHere())) {
|
|
823
|
+
walk.incomplete.push({ path: rel, reason: CHANGED_WALK, kind: 'error' });
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
for (const f of files) walk.files.add(f);
|
|
827
|
+
for (const d of subdirs) walk.dirs.add(d);
|
|
828
|
+
for (const [p, why] of others) walk.others.set(p, why);
|
|
829
|
+
walk.incomplete.push(...regions);
|
|
830
|
+
if (reachedCap) return;
|
|
831
|
+
walk.complete.add(rel);
|
|
832
|
+
if (!recursive) return;
|
|
833
|
+
for (const sub of subdirs) {
|
|
834
|
+
const child = await checkChild(view, rootReal, dir, sub);
|
|
835
|
+
if (child.kind === 'checked') await visit(child.dir);
|
|
836
|
+
else if (child.kind === 'absent') walk.missing.add(sub);
|
|
837
|
+
else walk.incomplete.push({ path: sub, reason: child.reason, kind: 'error' });
|
|
838
|
+
}
|
|
839
|
+
};
|
|
840
|
+
|
|
841
|
+
await visit(top.dir);
|
|
842
|
+
return walk;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/**
|
|
846
|
+
* Whether the walk proves nothing exists at `path`: some ancestor directory
|
|
847
|
+
* is missing, or a completely listed ancestor holds no entry that could lead
|
|
848
|
+
* to it. Ancestors outside the walked scope are passed through, so a walk of
|
|
849
|
+
* `a/b` decides paths beneath `a/b` and nothing else.
|
|
850
|
+
*/
|
|
851
|
+
export function provenAbsent(walk: Walk, path: string): boolean {
|
|
852
|
+
const parts = path.split('/');
|
|
853
|
+
let dir = '';
|
|
854
|
+
for (let i = 0; i < parts.length; i++) {
|
|
855
|
+
if (walk.missing.has(dir)) return true;
|
|
856
|
+
const child = dir ? `${dir}/${parts[i]}` : parts[i]!;
|
|
857
|
+
if (walk.complete.has(dir)) {
|
|
858
|
+
if (walk.others.has(child)) return false; // present, but unproven
|
|
859
|
+
if (i === parts.length - 1) return !walk.files.has(child) && !walk.dirs.has(child);
|
|
860
|
+
if (!walk.dirs.has(child)) return true; // no directory there: nothing beneath can exist
|
|
861
|
+
}
|
|
862
|
+
dir = child;
|
|
863
|
+
}
|
|
864
|
+
return false;
|
|
865
|
+
}
|