@crvouga/atlas 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -1
- package/README.md +178 -617
- package/dist/bun/src/driver.d.ts +41 -7
- package/dist/bun/src/driver.js +47 -6
- package/dist/bun/src/index.d.ts +8 -0
- package/dist/bun/src/index.js +8 -0
- package/dist/core/src/cli.d.ts +14 -1
- package/dist/core/src/cli.js +30 -1
- package/dist/core/src/config.d.ts +70 -4
- package/dist/core/src/config.js +62 -3
- package/dist/core/src/events/cloudevents.d.ts +54 -9
- package/dist/core/src/events/cloudevents.js +60 -9
- package/dist/core/src/graph.d.ts +21 -0
- package/dist/core/src/graph.js +26 -0
- package/dist/core/src/index.d.ts +11 -0
- package/dist/core/src/index.js +11 -0
- package/dist/core/src/lint.d.ts +30 -0
- package/dist/core/src/lint.js +13 -0
- package/dist/core/src/plan.d.ts +39 -1
- package/dist/core/src/plan.js +28 -1
- package/dist/core/src/privacy.d.ts +29 -2
- package/dist/core/src/privacy.js +19 -2
- package/dist/core/src/report/formats.d.ts +34 -4
- package/dist/core/src/report/formats.js +24 -4
- package/dist/core/src/report/markdown.d.ts +15 -0
- package/dist/core/src/report/markdown.js +8 -0
- package/dist/core/src/run/clients.d.ts +44 -5
- package/dist/core/src/run/clients.js +46 -2
- package/dist/core/src/run/execute.d.ts +69 -16
- package/dist/core/src/run/execute.js +67 -16
- package/dist/core/src/run/frames.d.ts +13 -4
- package/dist/core/src/run/frames.js +19 -4
- package/dist/core/src/run/http.d.ts +34 -1
- package/dist/core/src/run/http.js +14 -1
- package/dist/core/src/run/types.d.ts +76 -3
- package/dist/core/src/run/types.js +19 -3
- package/dist/core/src/signals.d.ts +49 -5
- package/dist/core/src/signals.js +22 -5
- package/dist/core/src/spec/compose.d.ts +23 -1
- package/dist/core/src/spec/compose.js +10 -1
- package/dist/core/src/spec/index.d.ts +9 -1
- package/dist/core/src/spec/index.js +9 -1
- package/dist/core/src/spec/load.d.ts +22 -1
- package/dist/core/src/spec/load.js +31 -1
- package/dist/core/src/spec/parse.d.ts +16 -2
- package/dist/core/src/spec/parse.js +25 -2
- package/dist/core/src/spec/scxml.d.ts +25 -4
- package/dist/core/src/spec/scxml.js +36 -4
- package/dist/core/src/spec/types.d.ts +52 -1
- package/dist/core/src/spec/types.js +24 -0
- package/dist/detox/src/index.d.ts +54 -11
- package/dist/detox/src/index.js +45 -7
- package/dist/playwright/src/driver.d.ts +39 -3
- package/dist/playwright/src/driver.js +27 -3
- package/dist/playwright/src/index.d.ts +10 -0
- package/dist/playwright/src/index.js +10 -0
- package/dist/playwright/src/media.d.ts +50 -4
- package/dist/playwright/src/media.js +55 -4
- package/dist/playwright/src/signals.d.ts +44 -5
- package/dist/playwright/src/signals.js +29 -5
- package/dist/reports/src/attachment-files.d.ts +44 -0
- package/dist/reports/src/attachment-files.js +121 -0
- package/dist/reports/src/attachment-response.d.ts +31 -0
- package/dist/reports/src/attachment-response.js +55 -0
- package/dist/reports/src/aws-credentials.d.ts +48 -0
- package/dist/reports/src/aws-credentials.js +221 -0
- package/dist/reports/src/browser-navigation.d.ts +14 -0
- package/dist/reports/src/browser-navigation.js +7 -0
- package/dist/reports/src/bundle.d.ts +34 -2
- package/dist/reports/src/bundle.js +38 -2
- package/dist/reports/src/catalog.d.ts +34 -3
- package/dist/reports/src/catalog.js +17 -3
- package/dist/reports/src/cli-stores.d.ts +36 -0
- package/dist/reports/src/cli-stores.js +73 -0
- package/dist/reports/src/clock.d.ts +7 -2
- package/dist/reports/src/clock.js +5 -2
- package/dist/reports/src/completion-order.d.ts +18 -5
- package/dist/reports/src/completion-order.js +18 -5
- package/dist/reports/src/compose.d.ts +80 -0
- package/dist/reports/src/compose.js +117 -0
- package/dist/reports/src/copy-store.d.ts +29 -0
- package/dist/reports/src/copy-store.js +52 -0
- package/dist/reports/src/directory-outline.d.ts +38 -0
- package/dist/reports/src/directory-outline.js +103 -0
- package/dist/reports/src/directory-store.d.ts +20 -0
- package/dist/reports/src/directory-store.js +166 -0
- package/dist/reports/src/doctor.d.ts +29 -0
- package/dist/reports/src/doctor.js +83 -0
- package/dist/reports/src/effective-delta.d.ts +34 -0
- package/dist/reports/src/effective-delta.js +25 -0
- package/dist/reports/src/evidence-detail.d.ts +64 -0
- package/dist/reports/src/evidence-detail.js +92 -0
- package/dist/reports/src/evidence-live.d.ts +35 -5
- package/dist/reports/src/evidence-live.js +18 -2
- package/dist/reports/src/evidence-types.d.ts +68 -1
- package/dist/reports/src/evidence-types.js +5 -1
- package/dist/reports/src/evidence-view.d.ts +43 -4
- package/dist/reports/src/evidence-view.js +13 -2
- package/dist/reports/src/explorer-types.d.ts +40 -3
- package/dist/reports/src/explorer-types.js +8 -0
- package/dist/reports/src/explorer-versions.d.ts +9 -1
- package/dist/reports/src/explorer-versions.js +18 -1
- package/dist/reports/src/export-evidence.d.ts +16 -1
- package/dist/reports/src/export-evidence.js +52 -1
- package/dist/reports/src/fetcher.d.ts +27 -3
- package/dist/reports/src/fetcher.js +11 -3
- package/dist/reports/src/format-date.d.ts +6 -0
- package/dist/reports/src/format-date.js +6 -0
- package/dist/reports/src/format.d.ts +16 -0
- package/dist/reports/src/format.js +10 -0
- package/dist/reports/src/frames.d.ts +36 -3
- package/dist/reports/src/frames.js +22 -3
- package/dist/reports/src/hash.d.ts +18 -0
- package/dist/reports/src/hash.js +13 -0
- package/dist/reports/src/host.d.ts +29 -0
- package/dist/reports/src/host.js +64 -0
- package/dist/reports/src/html-frame.d.ts +5 -6
- package/dist/reports/src/html-frame.js +12 -1
- package/dist/reports/src/http-store.d.ts +52 -0
- package/dist/reports/src/http-store.js +145 -0
- package/dist/reports/src/http.d.ts +7 -0
- package/dist/reports/src/http.js +6 -0
- package/dist/reports/src/ingest.d.ts +54 -0
- package/dist/reports/src/ingest.js +210 -0
- package/dist/reports/src/init.d.ts +40 -0
- package/dist/reports/src/init.js +364 -0
- package/dist/reports/src/junit.d.ts +33 -6
- package/dist/reports/src/junit.js +87 -21
- package/dist/reports/src/keyboard.d.ts +20 -0
- package/dist/reports/src/keyboard.js +11 -0
- package/dist/reports/src/keys.d.ts +21 -3
- package/dist/reports/src/keys.js +21 -3
- package/dist/reports/src/layer-axes.d.ts +35 -5
- package/dist/reports/src/layer-axes.js +25 -5
- package/dist/reports/src/layer-catalog.d.ts +28 -1
- package/dist/reports/src/layer-catalog.js +18 -1
- package/dist/reports/src/layer-merge.d.ts +98 -9
- package/dist/reports/src/layer-merge.js +147 -31
- package/dist/reports/src/layer-source.d.ts +18 -0
- package/dist/reports/src/layer-source.js +11 -0
- package/dist/reports/src/layer-store.d.ts +42 -0
- package/dist/reports/src/layer-store.js +63 -5
- package/dist/reports/src/layer-types.d.ts +32 -1
- package/dist/reports/src/layers-route.d.ts +47 -4
- package/dist/reports/src/layers-route.js +86 -7
- package/dist/reports/src/media-types.d.ts +11 -0
- package/dist/reports/src/media-types.js +22 -0
- package/dist/reports/src/namespace-guard.d.ts +10 -1
- package/dist/reports/src/namespace-guard.js +10 -1
- package/dist/reports/src/navigation.d.ts +12 -0
- package/dist/reports/src/navigation.js +7 -0
- package/dist/reports/src/object-io.d.ts +11 -4
- package/dist/reports/src/object-io.js +4 -4
- package/dist/reports/src/object-store-conformance.d.ts +27 -0
- package/dist/reports/src/object-store-conformance.js +87 -0
- package/dist/reports/src/object-store.d.ts +33 -24
- package/dist/reports/src/object-store.js +17 -24
- package/dist/reports/src/parse-with.d.ts +16 -0
- package/dist/reports/src/parse-with.js +14 -0
- package/dist/reports/src/performance-part.d.ts +25 -1
- package/dist/reports/src/performance-part.js +15 -1
- package/dist/reports/src/performance.d.ts +22 -2
- package/dist/reports/src/performance.js +12 -2
- package/dist/reports/src/persist.d.ts +12 -3
- package/dist/reports/src/persist.js +12 -3
- package/dist/reports/src/phone.d.ts +18 -1
- package/dist/reports/src/phone.js +18 -1
- package/dist/reports/src/protocol.d.ts +26 -4
- package/dist/reports/src/protocol.js +15 -4
- package/dist/reports/src/publication.d.ts +21 -1
- package/dist/reports/src/publication.js +21 -1
- package/dist/reports/src/publish-checks.d.ts +35 -0
- package/dist/reports/src/publish-checks.js +122 -0
- package/dist/reports/src/publish.d.ts +42 -9
- package/dist/reports/src/publish.js +36 -7
- package/dist/reports/src/read.d.ts +46 -16
- package/dist/reports/src/read.js +38 -14
- package/dist/reports/src/recording-bundle.d.ts +13 -2
- package/dist/reports/src/recording-bundle.js +4 -2
- package/dist/reports/src/recording-keys.d.ts +15 -1
- package/dist/reports/src/recording-keys.js +15 -1
- package/dist/reports/src/remote-report.d.ts +28 -7
- package/dist/reports/src/remote-report.js +15 -7
- package/dist/reports/src/report-cli.d.ts +51 -0
- package/dist/reports/src/report-cli.js +485 -0
- package/dist/reports/src/report-index.d.ts +99 -0
- package/dist/reports/src/report-index.js +337 -0
- package/dist/reports/src/report-loader.d.ts +10 -0
- package/dist/reports/src/report-loader.js +6 -0
- package/dist/reports/src/report-parts.d.ts +29 -3
- package/dist/reports/src/report-parts.js +37 -3
- package/dist/reports/src/report-status.d.ts +48 -0
- package/dist/reports/src/report-status.js +156 -0
- package/dist/reports/src/report-types.d.ts +73 -0
- package/dist/reports/src/report-types.js +3 -0
- package/dist/reports/src/reporter.d.ts +67 -0
- package/dist/reports/src/reporter.js +128 -0
- package/dist/reports/src/reports-explorer-route.d.ts +541 -0
- package/dist/reports/src/reports-explorer-route.js +11 -0
- package/dist/reports/src/reports-list-route.d.ts +9 -0
- package/dist/reports/src/reports-list-route.js +9 -0
- package/dist/reports/src/reports-route.d.ts +561 -22
- package/dist/reports/src/reports-route.js +11 -3
- package/dist/reports/src/reports-store.d.ts +19 -2
- package/dist/reports/src/reports-store.js +10 -2
- package/dist/reports/src/resilient-cache.d.ts +14 -1
- package/dist/reports/src/resilient-cache.js +8 -1
- package/dist/reports/src/retention.d.ts +17 -1
- package/dist/reports/src/retention.js +5 -1
- package/dist/reports/src/run-bundle.d.ts +63 -0
- package/dist/reports/src/run-bundle.js +159 -0
- package/dist/reports/src/s3-store.d.ts +79 -0
- package/dist/reports/src/s3-store.js +295 -0
- package/dist/reports/src/scenario.d.ts +14 -4
- package/dist/reports/src/scenario.js +5 -4
- package/dist/reports/src/sigv4.d.ts +72 -0
- package/dist/reports/src/sigv4.js +96 -0
- package/dist/reports/src/snapshot-bundle.d.ts +24 -1
- package/dist/reports/src/snapshot-bundle.js +27 -2
- package/dist/reports/src/snapshot-surface.d.ts +20 -9
- package/dist/reports/src/snapshot-surface.js +32 -19
- package/dist/reports/src/source-content.d.ts +23 -0
- package/dist/reports/src/source-content.js +30 -1
- package/dist/reports/src/source-entries.d.ts +13 -0
- package/dist/reports/src/source-entries.js +6 -0
- package/dist/reports/src/source-part.d.ts +15 -0
- package/dist/reports/src/source-part.js +8 -0
- package/dist/reports/src/source-reader.d.ts +11 -0
- package/dist/reports/src/source-reader.js +5 -0
- package/dist/reports/src/spec-evidence.d.ts +12 -2
- package/dist/reports/src/spec-evidence.js +6 -2
- package/dist/reports/src/spec-inventory.d.ts +15 -1
- package/dist/reports/src/spec-inventory.js +22 -1
- package/dist/reports/src/spec-link.d.ts +28 -0
- package/dist/reports/src/spec-link.js +70 -0
- package/dist/reports/src/spec-set.d.ts +63 -0
- package/dist/reports/src/spec-set.js +177 -0
- package/dist/reports/src/status.d.ts +10 -0
- package/dist/reports/src/status.js +5 -0
- package/dist/reports/src/store-prefix.d.ts +28 -0
- package/dist/reports/src/store-prefix.js +22 -0
- package/dist/reports/src/store-url.d.ts +89 -0
- package/dist/reports/src/store-url.js +160 -0
- package/dist/reports/src/test-catalog-entity.d.ts +11 -0
- package/dist/reports/src/test-catalog-entity.js +6 -0
- package/dist/reports/src/types.d.ts +6 -1
- package/dist/reports/src/use-report-nav.d.ts +35 -0
- package/dist/reports/src/use-report-nav.js +12 -0
- package/dist/schema/src/api.d.ts +799 -0
- package/dist/schema/src/api.js +201 -0
- package/dist/schema/src/cli-results.d.ts +210 -0
- package/dist/schema/src/cli-results.js +124 -0
- package/dist/schema/src/common.d.ts +53 -0
- package/dist/schema/src/common.js +42 -0
- package/dist/schema/src/index.d.ts +14 -0
- package/dist/schema/src/index.js +14 -0
- package/dist/schema/src/layer.d.ts +2273 -0
- package/dist/schema/src/layer.js +462 -0
- package/dist/schema/src/manifest.d.ts +95 -0
- package/dist/schema/src/manifest.js +84 -0
- package/dist/schema/src/report-sources.d.ts +28 -0
- package/dist/schema/src/report-sources.js +25 -0
- package/dist/schema/src/runs-index.d.ts +16 -0
- package/dist/schema/src/runs-index.js +15 -0
- package/dist/schema/src/server-config.d.ts +93 -0
- package/dist/schema/src/server-config.js +73 -0
- package/dist/schema/src/spec.d.ts +69 -0
- package/dist/schema/src/spec.js +58 -0
- package/dist/server/src/app.d.ts +62 -0
- package/dist/server/src/app.js +419 -0
- package/dist/server/src/auth.d.ts +41 -0
- package/dist/server/src/auth.js +59 -0
- package/dist/server/src/config.d.ts +83 -0
- package/dist/server/src/config.js +187 -0
- package/dist/server/src/dev.d.ts +12 -0
- package/dist/server/src/dev.js +80 -0
- package/dist/server/src/events.d.ts +29 -0
- package/dist/server/src/events.js +101 -0
- package/dist/server/src/host.d.ts +30 -0
- package/dist/server/src/host.js +115 -0
- package/dist/server/src/index.d.ts +16 -0
- package/dist/server/src/index.js +16 -0
- package/dist/server/src/llms.d.ts +19 -0
- package/dist/server/src/llms.js +63 -0
- package/dist/server/src/main.d.ts +30 -0
- package/dist/server/src/main.js +112 -0
- package/dist/server/src/openapi.d.ts +59 -0
- package/dist/server/src/openapi.js +374 -0
- package/dist/server/src/problem.d.ts +8 -0
- package/dist/server/src/problem.js +39 -0
- package/dist/server/src/site.d.ts +13 -0
- package/dist/server/src/site.js +43 -0
- package/dist/server/src/store-api.d.ts +28 -0
- package/dist/server/src/store-api.js +159 -0
- package/dist/server/src/validate.d.ts +71 -0
- package/dist/server/src/validate.js +197 -0
- package/dist/vitest/src/index.d.ts +27 -5
- package/dist/vitest/src/index.js +18 -5
- package/dist/webdriver/src/driver.d.ts +63 -11
- package/dist/webdriver/src/driver.js +57 -6
- package/dist/webdriver/src/index.d.ts +8 -0
- package/dist/webdriver/src/index.js +1 -0
- package/dist/webdriver/src/protocol.d.ts +75 -6
- package/dist/webdriver/src/protocol.js +79 -6
- package/docs/agents.md +137 -0
- package/docs/deploying.md +345 -0
- package/docs/dogfooding.md +175 -0
- package/docs/extending.md +242 -0
- package/docs/integrating.md +485 -0
- package/docs/reference/bun.md +141 -0
- package/docs/reference/cli.md +146 -0
- package/docs/reference/core.md +2426 -0
- package/docs/reference/detox.md +150 -0
- package/docs/reference/http-api.md +303 -0
- package/docs/reference/playwright.md +303 -0
- package/docs/reference/reports.md +7098 -0
- package/docs/reference/schema.md +1651 -0
- package/docs/reference/server.md +685 -0
- package/docs/reference/vitest.md +45 -0
- package/docs/reference/webdriver.md +330 -0
- package/docs/report-backends.md +144 -0
- package/docs/report-contract.md +400 -0
- package/docs/reporting-issues.md +160 -0
- package/docs/server.md +285 -0
- package/docs/statecharts.md +641 -0
- package/llms-full.txt +3506 -0
- package/llms.txt +47 -0
- package/package.json +32 -4
- package/packages/core/README.md +2 -1
- package/packages/reports/README.md +65 -0
- package/packages/reports/STORAGE.md +4 -0
- package/packages/schema/README.md +14 -0
- package/packages/schema/schemas/check-detail.schema.json +1032 -0
- package/packages/schema/schemas/checks-result.schema.json +26 -0
- package/packages/schema/schemas/command-error.schema.json +35 -0
- package/packages/schema/schemas/doctor-result.schema.json +60 -0
- package/packages/schema/schemas/effective-delta.schema.json +490 -0
- package/packages/schema/schemas/effective-evidence.schema.json +471 -0
- package/packages/schema/schemas/evidence-page.schema.json +271 -0
- package/packages/schema/schemas/hello-event.schema.json +22 -0
- package/packages/schema/schemas/init-plan.schema.json +347 -0
- package/packages/schema/schemas/layer-rows.schema.json +199 -0
- package/packages/schema/schemas/layer.schema.json +2888 -0
- package/packages/schema/schemas/merge-result.schema.json +40 -0
- package/packages/schema/schemas/outline.schema.json +62 -0
- package/packages/schema/schemas/problem.schema.json +37 -0
- package/packages/schema/schemas/publish-checks.schema.json +1413 -0
- package/packages/schema/schemas/publish-result.schema.json +168 -0
- package/packages/schema/schemas/ready.schema.json +68 -0
- package/packages/schema/schemas/report-event.schema.json +100 -0
- package/packages/schema/schemas/report-status.schema.json +370 -0
- package/packages/schema/schemas/server-config.schema.json +194 -0
- package/packages/schema/schemas/server-info.schema.json +114 -0
- package/packages/schema/schemas/spec-set.schema.json +103 -0
- package/packages/schema/schemas/store-listing.schema.json +29 -0
- package/packages/schema/schemas/store-write-result.schema.json +28 -0
- package/packages/schema/schemas/stream-result.schema.json +59 -0
- package/packages/server/README.md +43 -0
- package/packages/server/openapi.json +6699 -0
- package/packages/visualizer/README.md +21 -0
- package/packages/visualizer/src/app/AppShell.tsx +9 -2
- package/packages/visualizer/src/app/report-sources.tsx +6 -4
- package/packages/visualizer/src/app/router.tsx +3 -3
- package/packages/visualizer/src/components/Header.module.css +35 -0
- package/packages/visualizer/src/components/Header.tsx +40 -3
- package/packages/visualizer/src/data/adapters/adapter.ts +5 -17
- package/packages/visualizer/src/data/adapters/index.ts +10 -1
- package/packages/visualizer/src/evidence/api.ts +14 -2
- package/packages/visualizer/src/evidence/artifacts.module.css +213 -0
- package/packages/visualizer/src/evidence/artifacts.tsx +272 -0
- package/packages/visualizer/src/evidence/check-detail.tsx +3 -1
- package/packages/visualizer/src/evidence/evidence-context.tsx +74 -12
- package/packages/visualizer/src/evidence/spec-page.tsx +23 -16
- package/packages/visualizer/src/find/Finder.tsx +1 -7
- package/packages/visualizer/src/find/open.ts +4 -0
- package/packages/visualizer/src/live/live-stream.ts +204 -0
- package/packages/visualizer/src/runtime.ts +19 -0
- package/packages/visualizer/vite/atlas-server-proxy.ts +36 -0
- package/packages/visualizer/vite.config.ts +11 -3
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module Reading the files attachments name
|
|
3
|
+
*
|
|
4
|
+
* Turns an attachment that names a file on disk (`path`) into one that carries its bytes (`data`), just before a
|
|
5
|
+
* layer is published. It owns the safety rules for doing so: a path that came from a test report may only name
|
|
6
|
+
* files inside the allowed roots, never a hidden file, and never a link that leads out; a path the person running
|
|
7
|
+
* the command typed is trusted. It does not upload anything. Node-only (reads the file system).
|
|
8
|
+
*
|
|
9
|
+
* @see docs/report-contract.md (evidence rule 7: paths inside a report are data from a test run)
|
|
10
|
+
*/
|
|
11
|
+
import { readFile, realpath, stat } from 'node:fs/promises';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { contentTypeOf } from './media-types.js';
|
|
14
|
+
/**
|
|
15
|
+
* Reads the files that attachments name (`path`), at every level (the holder, its `steps` and its `attempts`,
|
|
16
|
+
* recursively), and hands their bytes over as `data` with a `contentType` guessed from the file name when the
|
|
17
|
+
* attachment gave none.
|
|
18
|
+
*
|
|
19
|
+
* A file that is missing, not allowed or too large is left out with a warning: evidence that could not be
|
|
20
|
+
* found never stops the results from being published. Attachments without a `path` are kept untouched.
|
|
21
|
+
*
|
|
22
|
+
* @param holder A check, step, attempt or any object that may have `attachments`, `steps` and `attempts`.
|
|
23
|
+
* @param roots Folders a relative path is resolved against (in order), and the only folders an untrusted path may name.
|
|
24
|
+
* @returns The rewritten copy, counts and warnings; never throws for a bad file.
|
|
25
|
+
*/
|
|
26
|
+
export async function readAttachmentFiles(holder, roots, options = {}) {
|
|
27
|
+
const warnings = [];
|
|
28
|
+
const maxBytes = options.maxBytes ?? 512 * 1024 * 1024;
|
|
29
|
+
let attached = 0, bytes = 0;
|
|
30
|
+
async function walk(value) {
|
|
31
|
+
const out = { ...value };
|
|
32
|
+
if (Array.isArray(out.attachments)) {
|
|
33
|
+
const kept = [];
|
|
34
|
+
for (const given of out.attachments) {
|
|
35
|
+
const file = typeof given.path === 'string' ? given.path : undefined;
|
|
36
|
+
if (file === undefined) {
|
|
37
|
+
kept.push(given);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
const name = String(given.name ?? file);
|
|
41
|
+
const { path: _path, ...rest } = given;
|
|
42
|
+
const found = await locate(file, roots, options.trusted === true);
|
|
43
|
+
if (found.problem) {
|
|
44
|
+
warnings.push(`${name}: ${found.problem}`);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
const size = (await stat(found.file)).size;
|
|
48
|
+
if (size > maxBytes) {
|
|
49
|
+
warnings.push(`${name}: ${(size / 1024 / 1024).toFixed(0)} MiB is over the limit of ${Math.round(maxBytes / 1024 / 1024)} MiB`);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (options.budget && size > options.budget.remaining) {
|
|
53
|
+
warnings.push(`${name}: the total size of attachments would pass the budget (raise --max-total-attachment-mb, or attach a uri)`);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (options.budget)
|
|
57
|
+
options.budget.remaining -= size;
|
|
58
|
+
attached += 1;
|
|
59
|
+
bytes += size;
|
|
60
|
+
kept.push({ ...rest, data: new Uint8Array(await readFile(found.file)), ...(rest.contentType ? {} : { contentType: contentTypeOf(found.file) }) });
|
|
61
|
+
}
|
|
62
|
+
out.attachments = kept;
|
|
63
|
+
}
|
|
64
|
+
if (Array.isArray(out.steps)) {
|
|
65
|
+
const steps = [];
|
|
66
|
+
for (const step of out.steps)
|
|
67
|
+
steps.push(await walk(step));
|
|
68
|
+
out.steps = steps;
|
|
69
|
+
}
|
|
70
|
+
if (Array.isArray(out.attempts)) {
|
|
71
|
+
const attempts = [];
|
|
72
|
+
for (const attempt of out.attempts)
|
|
73
|
+
attempts.push(await walk(attempt));
|
|
74
|
+
out.attempts = attempts;
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
return { value: await walk(holder), attached, bytes, warnings };
|
|
79
|
+
}
|
|
80
|
+
/** Is `file` inside `root`, once both are real paths (so a link cannot lead out)? */
|
|
81
|
+
function inside(file, root) {
|
|
82
|
+
const relative = path.relative(root, file);
|
|
83
|
+
return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative);
|
|
84
|
+
}
|
|
85
|
+
// Resolves a named file to a real path, or says why not. A trusted path may also be relative to the working
|
|
86
|
+
// directory; an untrusted one must land, after resolving links, inside a root and under no dot-named part.
|
|
87
|
+
async function locate(file, roots, trusted) {
|
|
88
|
+
const candidates = path.isAbsolute(file) ? [file] : [...roots.map(root => path.resolve(root, file)), ...(trusted ? [path.resolve(file)] : [])];
|
|
89
|
+
let outside = false;
|
|
90
|
+
for (const candidate of candidates) {
|
|
91
|
+
let real;
|
|
92
|
+
try {
|
|
93
|
+
real = await realpath(candidate);
|
|
94
|
+
if (!(await stat(real)).isFile())
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (trusted)
|
|
101
|
+
return { file: real };
|
|
102
|
+
const root = await firstRootHolding(real, roots);
|
|
103
|
+
// A hidden file or folder (.env, .aws, .git, .ssh) is never attached on a report's say-so.
|
|
104
|
+
if (!root || path.relative(root, real).split(path.sep).some(part => part.startsWith('.'))) {
|
|
105
|
+
outside = true;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
return { file: real };
|
|
109
|
+
}
|
|
110
|
+
return { problem: outside ? `${file} is outside the folders reports may attach from (the report's own folder and each --attach-root), or is a hidden file: pass --attach-root <dir> to allow a folder` : `file not found (${file})` };
|
|
111
|
+
}
|
|
112
|
+
async function firstRootHolding(real, roots) {
|
|
113
|
+
for (const root of roots) {
|
|
114
|
+
try {
|
|
115
|
+
if (inside(real, await realpath(root)))
|
|
116
|
+
return await realpath(root);
|
|
117
|
+
}
|
|
118
|
+
catch { /* a root that is not there holds nothing */ }
|
|
119
|
+
}
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module Serving attachment bytes safely
|
|
3
|
+
*
|
|
4
|
+
* Builds the HTTP response for an attachment's bytes. A producer chose the media type, and the bytes come from a
|
|
5
|
+
* test run, so they are treated as untrusted: never sniffed, never allowed to run scripts, never embedded by
|
|
6
|
+
* another site. Videos can be sought (HTTP range requests). Every route that serves attachment bytes must go
|
|
7
|
+
* through here (or add {@link UNTRUSTED_HEADERS}); it never reads from a store itself.
|
|
8
|
+
*
|
|
9
|
+
* @see docs/report-contract.md (evidence rule 8)
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Headers for bytes that came from a test run: never sniffed (`nosniff`), a sandboxing content-security policy so a
|
|
13
|
+
* page navigated to at this address runs locked down, and `cross-origin-resource-policy: same-origin` so
|
|
14
|
+
* another site cannot embed it. Spread these into every response that serves attachment or recorded bytes.
|
|
15
|
+
*/
|
|
16
|
+
export declare const UNTRUSTED_HEADERS: Readonly<Record<string, string>>;
|
|
17
|
+
/**
|
|
18
|
+
* Answers a request for one attachment's bytes with the untrusted-bytes headers, long-lived immutable caching
|
|
19
|
+
* and range support.
|
|
20
|
+
*
|
|
21
|
+
* The producer's `contentType` is used only when it is a well-formed media type (`type/subtype` with simple
|
|
22
|
+
* parameters); anything else is served as `application/octet-stream`. A matching `if-none-match` answers 304.
|
|
23
|
+
* A single `bytes=a-b`, `bytes=a-` or `bytes=-n` range answers 206, an unsatisfiable one 416; multiple ranges are
|
|
24
|
+
* not understood and get the whole body (200).
|
|
25
|
+
*
|
|
26
|
+
* @param etag A strong validator the caller derives from content identity (it is sent as given, so include the quotes).
|
|
27
|
+
*/
|
|
28
|
+
export declare function attachmentResponse(request: Request, entry: {
|
|
29
|
+
readonly bytes: Uint8Array;
|
|
30
|
+
readonly contentType: string;
|
|
31
|
+
}, etag: string): Response;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module Serving attachment bytes safely
|
|
3
|
+
*
|
|
4
|
+
* Builds the HTTP response for an attachment's bytes. A producer chose the media type, and the bytes come from a
|
|
5
|
+
* test run, so they are treated as untrusted: never sniffed, never allowed to run scripts, never embedded by
|
|
6
|
+
* another site. Videos can be sought (HTTP range requests). Every route that serves attachment bytes must go
|
|
7
|
+
* through here (or add {@link UNTRUSTED_HEADERS}); it never reads from a store itself.
|
|
8
|
+
*
|
|
9
|
+
* @see docs/report-contract.md (evidence rule 8)
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Headers for bytes that came from a test run: never sniffed (`nosniff`), a sandboxing content-security policy so a
|
|
13
|
+
* page navigated to at this address runs locked down, and `cross-origin-resource-policy: same-origin` so
|
|
14
|
+
* another site cannot embed it. Spread these into every response that serves attachment or recorded bytes.
|
|
15
|
+
*/
|
|
16
|
+
export const UNTRUSTED_HEADERS = {
|
|
17
|
+
'x-content-type-options': 'nosniff',
|
|
18
|
+
// If a page is ever navigated to or framed from this address, it runs as a locked-down document.
|
|
19
|
+
'content-security-policy': "sandbox; default-src 'none'; img-src data:; media-src 'self'; style-src 'unsafe-inline'",
|
|
20
|
+
'cross-origin-resource-policy': 'same-origin'
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Answers a request for one attachment's bytes with the untrusted-bytes headers, long-lived immutable caching
|
|
24
|
+
* and range support.
|
|
25
|
+
*
|
|
26
|
+
* The producer's `contentType` is used only when it is a well-formed media type (`type/subtype` with simple
|
|
27
|
+
* parameters); anything else is served as `application/octet-stream`. A matching `if-none-match` answers 304.
|
|
28
|
+
* A single `bytes=a-b`, `bytes=a-` or `bytes=-n` range answers 206, an unsatisfiable one 416; multiple ranges are
|
|
29
|
+
* not understood and get the whole body (200).
|
|
30
|
+
*
|
|
31
|
+
* @param etag A strong validator the caller derives from content identity (it is sent as given, so include the quotes).
|
|
32
|
+
*/
|
|
33
|
+
export function attachmentResponse(request, entry, etag) {
|
|
34
|
+
// A media type is a short token list; anything with line breaks or odd characters is not one.
|
|
35
|
+
const type = /^[\w.+-]+\/[\w.+-]+(?:\s*;\s*[\w.-]+=[\w.\-"]+)*$/.test(entry.contentType) ? entry.contentType : 'application/octet-stream';
|
|
36
|
+
const headers = {
|
|
37
|
+
'content-type': type,
|
|
38
|
+
...UNTRUSTED_HEADERS,
|
|
39
|
+
'cache-control': 'public, max-age=31536000, immutable',
|
|
40
|
+
'accept-ranges': 'bytes',
|
|
41
|
+
etag
|
|
42
|
+
};
|
|
43
|
+
if (request.headers.get('if-none-match') === etag)
|
|
44
|
+
return new Response(null, { status: 304, headers });
|
|
45
|
+
const size = entry.bytes.length;
|
|
46
|
+
const range = /^bytes=(\d*)-(\d*)$/.exec(request.headers.get('range') ?? '');
|
|
47
|
+
if (!range || (range[1] === '' && range[2] === ''))
|
|
48
|
+
return new Response(entry.bytes, { headers: { ...headers, 'content-length': String(size) } });
|
|
49
|
+
// `bytes=-500` is the last 500 bytes; `bytes=500-` is from 500 on.
|
|
50
|
+
const start = range[1] === '' ? Math.max(0, size - Number(range[2])) : Number(range[1]);
|
|
51
|
+
const end = range[1] === '' || range[2] === '' ? size - 1 : Math.min(Number(range[2]), size - 1);
|
|
52
|
+
if (start >= size || start > end)
|
|
53
|
+
return new Response(null, { status: 416, headers: { ...headers, 'content-range': `bytes */${size}` } });
|
|
54
|
+
return new Response(entry.bytes.slice(start, end + 1), { status: 206, headers: { ...headers, 'content-range': `bytes ${start}-${end}/${size}`, 'content-length': String(end - start + 1) } });
|
|
55
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { Credentials } from './sigv4.js';
|
|
2
|
+
type Env = Readonly<Record<string, string | undefined>>;
|
|
3
|
+
/**
|
|
4
|
+
* An async function returning currently valid credentials, with cache control. Concurrent calls share one lookup;
|
|
5
|
+
* cached credentials are reused until shortly before they expire (never, when they carry no expiry).
|
|
6
|
+
*/
|
|
7
|
+
export type CredentialProvider = (() => Promise<Credentials>) & {
|
|
8
|
+
/** Drop what is cached, so the next call asks again. Used when a store says the credentials expired. */
|
|
9
|
+
invalidate(): void;
|
|
10
|
+
/** Where the credentials came from, once known: "environment", "web identity (IRSA)", "profile prod", … */
|
|
11
|
+
source(): string | null;
|
|
12
|
+
};
|
|
13
|
+
/** Options of {@link credentialChain}; every one exists so a test can replace a piece of the machine. */
|
|
14
|
+
export type ChainOptions = {
|
|
15
|
+
/** The environment variables to read. Default `process.env`. */
|
|
16
|
+
readonly env?: Env;
|
|
17
|
+
/** The HTTP client for STS, container and instance-metadata calls. Default global `fetch`. */
|
|
18
|
+
readonly fetch?: typeof fetch;
|
|
19
|
+
/** Reads a UTF-8 file. Default `fs.readFile`. */
|
|
20
|
+
readonly readFile?: (file: string) => Promise<string>;
|
|
21
|
+
/** Run a command and return its stdout, or null when it is not installed or fails. Default spawns it with a 20 s timeout. */
|
|
22
|
+
readonly run?: (command: string, args: readonly string[]) => Promise<string | null>;
|
|
23
|
+
/** The clock, epoch milliseconds. Default `Date.now`. */
|
|
24
|
+
readonly now?: () => number;
|
|
25
|
+
/** The home directory that holds `.aws/credentials`. Default `$HOME`, then the OS home. */
|
|
26
|
+
readonly home?: string;
|
|
27
|
+
/** Refresh this long before credentials expire, in milliseconds. Default 5 minutes. */
|
|
28
|
+
readonly skewMs?: number;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Creates a provider for the credentials an AWS SDK would find, in the same order, with no SDK: environment, web
|
|
32
|
+
* identity (EKS), container (ECS), shared credentials file, the AWS CLI (which covers SSO and assumed roles),
|
|
33
|
+
* and EC2 instance metadata. `ATLAS_S3_ACCESS_KEY_ID` / `ATLAS_S3_SECRET_ACCESS_KEY` (and `ATLAS_S3_SESSION_TOKEN`)
|
|
34
|
+
* come first so one store can use keys that are not the machine's AWS keys, such as Cloudflare R2 tokens.
|
|
35
|
+
*
|
|
36
|
+
* Nothing is looked up until the provider is first called. The first source that yields credentials wins and
|
|
37
|
+
* is cached; a source that throws (for example an STS error) is skipped and reported if no later source works.
|
|
38
|
+
* When none works the provider rejects with an error that lists every source tried and how to fix it. Set
|
|
39
|
+
* `ATLAS_NO_AWS_CLI` to skip the `aws` CLI step and `AWS_EC2_METADATA_DISABLED=true` to skip instance metadata.
|
|
40
|
+
*
|
|
41
|
+
* @example
|
|
42
|
+
* const credentials = await credentialChain()();
|
|
43
|
+
* credentials.accessKeyId;
|
|
44
|
+
*/
|
|
45
|
+
export declare function credentialChain(options?: ChainOptions): CredentialProvider;
|
|
46
|
+
/** Wraps keys that are already known in a {@link CredentialProvider} that never refreshes (its `invalidate` does nothing). */
|
|
47
|
+
export declare function staticCredentials(credentials: Credentials): CredentialProvider;
|
|
48
|
+
export {};
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module AWS credential lookup
|
|
3
|
+
*
|
|
4
|
+
* Finds S3 credentials the way an AWS SDK does, without depending on one: environment variables, web identity
|
|
5
|
+
* (EKS), ECS container credentials, the shared credentials file, the AWS CLI (SSO, assumed roles) and EC2 instance
|
|
6
|
+
* metadata. It owns the lookup order, the caching and the early refresh before expiry; it does not sign requests
|
|
7
|
+
* (that is `sigv4.ts`) or know about buckets (`s3-store.ts`). Credentials are never put in a store URL: they
|
|
8
|
+
* come from here. Node-only (reads files, spawns `aws`). Nothing runs at import time.
|
|
9
|
+
*
|
|
10
|
+
* @see docs/report-contract.md (backends)
|
|
11
|
+
*/
|
|
12
|
+
import { spawn } from 'node:child_process';
|
|
13
|
+
import { readFile } from 'node:fs/promises';
|
|
14
|
+
import os from 'node:os';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { XMLParser } from 'fast-xml-parser';
|
|
17
|
+
let parser;
|
|
18
|
+
/** Built on first use, so importing this module does no work. */
|
|
19
|
+
const xml = { parse: (text) => (parser ??= new XMLParser({ ignoreAttributes: true, parseTagValue: false })).parse(text) };
|
|
20
|
+
async function runCommand(command, args) {
|
|
21
|
+
return new Promise(resolve => {
|
|
22
|
+
try {
|
|
23
|
+
const child = spawn(command, [...args], { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
24
|
+
let out = '';
|
|
25
|
+
const timer = setTimeout(() => { child.kill(); resolve(null); }, 20_000);
|
|
26
|
+
child.stdout.on('data', chunk => { out += String(chunk); });
|
|
27
|
+
child.on('error', () => { clearTimeout(timer); resolve(null); });
|
|
28
|
+
child.on('close', code => { clearTimeout(timer); resolve(code === 0 ? out : null); });
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
resolve(null);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
function parseIni(text) {
|
|
36
|
+
const sections = {};
|
|
37
|
+
let current = null;
|
|
38
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
39
|
+
const line = raw.trim();
|
|
40
|
+
if (!line || line.startsWith('#') || line.startsWith(';'))
|
|
41
|
+
continue;
|
|
42
|
+
const header = /^\[(?:profile\s+)?([^\]]+)\]$/.exec(line);
|
|
43
|
+
if (header) {
|
|
44
|
+
current = sections[header[1].trim()] ??= {};
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
const at = line.indexOf('=');
|
|
48
|
+
if (current && at > 0)
|
|
49
|
+
current[line.slice(0, at).trim().toLowerCase()] = line.slice(at + 1).trim();
|
|
50
|
+
}
|
|
51
|
+
return sections;
|
|
52
|
+
}
|
|
53
|
+
function expiry(value) {
|
|
54
|
+
const time = typeof value === 'string' ? Date.parse(value) : NaN;
|
|
55
|
+
return Number.isFinite(time) ? time : undefined;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Creates a provider for the credentials an AWS SDK would find, in the same order, with no SDK: environment, web
|
|
59
|
+
* identity (EKS), container (ECS), shared credentials file, the AWS CLI (which covers SSO and assumed roles),
|
|
60
|
+
* and EC2 instance metadata. `ATLAS_S3_ACCESS_KEY_ID` / `ATLAS_S3_SECRET_ACCESS_KEY` (and `ATLAS_S3_SESSION_TOKEN`)
|
|
61
|
+
* come first so one store can use keys that are not the machine's AWS keys, such as Cloudflare R2 tokens.
|
|
62
|
+
*
|
|
63
|
+
* Nothing is looked up until the provider is first called. The first source that yields credentials wins and
|
|
64
|
+
* is cached; a source that throws (for example an STS error) is skipped and reported if no later source works.
|
|
65
|
+
* When none works the provider rejects with an error that lists every source tried and how to fix it. Set
|
|
66
|
+
* `ATLAS_NO_AWS_CLI` to skip the `aws` CLI step and `AWS_EC2_METADATA_DISABLED=true` to skip instance metadata.
|
|
67
|
+
*
|
|
68
|
+
* @example
|
|
69
|
+
* const credentials = await credentialChain()();
|
|
70
|
+
* credentials.accessKeyId;
|
|
71
|
+
*/
|
|
72
|
+
export function credentialChain(options = {}) {
|
|
73
|
+
const env = options.env ?? process.env;
|
|
74
|
+
const doFetch = options.fetch ?? fetch;
|
|
75
|
+
const read = options.readFile ?? ((file) => readFile(file, 'utf8'));
|
|
76
|
+
const run = options.run ?? runCommand;
|
|
77
|
+
const now = options.now ?? Date.now;
|
|
78
|
+
const skew = options.skewMs ?? 5 * 60_000;
|
|
79
|
+
const home = options.home ?? env.HOME ?? os.homedir();
|
|
80
|
+
const profile = env.AWS_PROFILE || env.AWS_DEFAULT_PROFILE || 'default';
|
|
81
|
+
let cached = null;
|
|
82
|
+
let pending = null;
|
|
83
|
+
const sources = [
|
|
84
|
+
{
|
|
85
|
+
name: 'environment (ATLAS_S3_*)',
|
|
86
|
+
load: async () => env.ATLAS_S3_ACCESS_KEY_ID && env.ATLAS_S3_SECRET_ACCESS_KEY
|
|
87
|
+
? { accessKeyId: env.ATLAS_S3_ACCESS_KEY_ID, secretAccessKey: env.ATLAS_S3_SECRET_ACCESS_KEY, ...(env.ATLAS_S3_SESSION_TOKEN ? { sessionToken: env.ATLAS_S3_SESSION_TOKEN } : {}) }
|
|
88
|
+
: null
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
name: 'environment (AWS_ACCESS_KEY_ID)',
|
|
92
|
+
load: async () => env.AWS_ACCESS_KEY_ID && env.AWS_SECRET_ACCESS_KEY
|
|
93
|
+
? { accessKeyId: env.AWS_ACCESS_KEY_ID, secretAccessKey: env.AWS_SECRET_ACCESS_KEY, ...(env.AWS_SESSION_TOKEN ? { sessionToken: env.AWS_SESSION_TOKEN } : {}) }
|
|
94
|
+
: null
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
name: 'web identity (IRSA)',
|
|
98
|
+
load: async () => {
|
|
99
|
+
if (!env.AWS_WEB_IDENTITY_TOKEN_FILE || !env.AWS_ROLE_ARN)
|
|
100
|
+
return null;
|
|
101
|
+
const region = env.AWS_REGION || env.AWS_DEFAULT_REGION || 'us-east-1';
|
|
102
|
+
const token = (await read(env.AWS_WEB_IDENTITY_TOKEN_FILE)).trim();
|
|
103
|
+
const url = new URL(env.AWS_STS_ENDPOINT || `https://sts.${region}.amazonaws.com/`);
|
|
104
|
+
url.search = new URLSearchParams({ Action: 'AssumeRoleWithWebIdentity', Version: '2011-06-15', RoleArn: env.AWS_ROLE_ARN, RoleSessionName: env.AWS_ROLE_SESSION_NAME || 'atlas', WebIdentityToken: token }).toString();
|
|
105
|
+
const response = await doFetch(url, { method: 'GET', headers: { accept: 'application/xml' } });
|
|
106
|
+
const body = await response.text();
|
|
107
|
+
if (!response.ok)
|
|
108
|
+
throw new Error(`STS AssumeRoleWithWebIdentity failed (${response.status}): ${body.slice(0, 300)}`);
|
|
109
|
+
const found = xml.parse(body)?.AssumeRoleWithWebIdentityResponse?.AssumeRoleWithWebIdentityResult?.Credentials;
|
|
110
|
+
if (!found?.AccessKeyId)
|
|
111
|
+
throw new Error('STS returned no credentials');
|
|
112
|
+
return { accessKeyId: found.AccessKeyId, secretAccessKey: found.SecretAccessKey, sessionToken: found.SessionToken, ...(expiry(found.Expiration) ? { expiresAtMs: expiry(found.Expiration) } : {}) };
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
name: 'container credentials (ECS)',
|
|
117
|
+
load: async () => {
|
|
118
|
+
const url = env.AWS_CONTAINER_CREDENTIALS_FULL_URI || (env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI ? `http://169.254.170.2${env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI}` : null);
|
|
119
|
+
if (!url)
|
|
120
|
+
return null;
|
|
121
|
+
const token = env.AWS_CONTAINER_AUTHORIZATION_TOKEN || (env.AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE ? (await read(env.AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE)).trim() : null);
|
|
122
|
+
const response = await doFetch(url, { headers: token ? { authorization: token } : {} });
|
|
123
|
+
if (!response.ok)
|
|
124
|
+
throw new Error(`The container credentials endpoint answered ${response.status}`);
|
|
125
|
+
const found = await response.json();
|
|
126
|
+
return { accessKeyId: found.AccessKeyId, secretAccessKey: found.SecretAccessKey, sessionToken: found.Token, ...(expiry(found.Expiration) ? { expiresAtMs: expiry(found.Expiration) } : {}) };
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
name: `shared credentials file (profile ${profile})`,
|
|
131
|
+
load: async () => {
|
|
132
|
+
const file = env.AWS_SHARED_CREDENTIALS_FILE || path.join(home, '.aws', 'credentials');
|
|
133
|
+
const text = await read(file).catch(() => null);
|
|
134
|
+
const section = text ? parseIni(text)[profile] : undefined;
|
|
135
|
+
return section?.aws_access_key_id && section.aws_secret_access_key
|
|
136
|
+
? { accessKeyId: section.aws_access_key_id, secretAccessKey: section.aws_secret_access_key, ...(section.aws_session_token ? { sessionToken: section.aws_session_token } : {}) }
|
|
137
|
+
: null;
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
name: `AWS CLI (profile ${profile})`,
|
|
142
|
+
load: async () => {
|
|
143
|
+
// Covers SSO, assumed roles and credential_process: whatever `aws` itself can sign in with.
|
|
144
|
+
if (env.ATLAS_NO_AWS_CLI)
|
|
145
|
+
return null;
|
|
146
|
+
const out = await run('aws', ['configure', 'export-credentials', '--format', 'process', ...(env.AWS_PROFILE ? ['--profile', env.AWS_PROFILE] : [])]);
|
|
147
|
+
if (!out)
|
|
148
|
+
return null;
|
|
149
|
+
try {
|
|
150
|
+
const found = JSON.parse(out);
|
|
151
|
+
return found.AccessKeyId && found.SecretAccessKey ? { accessKeyId: found.AccessKeyId, secretAccessKey: found.SecretAccessKey, ...(found.SessionToken ? { sessionToken: found.SessionToken } : {}), ...(expiry(found.Expiration) ? { expiresAtMs: expiry(found.Expiration) } : {}) } : null;
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
name: 'EC2 instance metadata',
|
|
160
|
+
load: async () => {
|
|
161
|
+
if (env.AWS_EC2_METADATA_DISABLED === 'true')
|
|
162
|
+
return null;
|
|
163
|
+
const base = env.AWS_EC2_METADATA_SERVICE_ENDPOINT || 'http://169.254.169.254';
|
|
164
|
+
const signal = () => AbortSignal.timeout(1_000);
|
|
165
|
+
try {
|
|
166
|
+
const token = await doFetch(`${base}/latest/api/token`, { method: 'PUT', headers: { 'x-aws-ec2-metadata-token-ttl-seconds': '21600' }, signal: signal() });
|
|
167
|
+
if (!token.ok)
|
|
168
|
+
return null;
|
|
169
|
+
const headers = { 'x-aws-ec2-metadata-token': await token.text() };
|
|
170
|
+
const roles = await doFetch(`${base}/latest/meta-data/iam/security-credentials/`, { headers, signal: signal() });
|
|
171
|
+
const role = (await roles.text()).split('\n')[0]?.trim();
|
|
172
|
+
if (!roles.ok || !role)
|
|
173
|
+
return null;
|
|
174
|
+
const found = await (await doFetch(`${base}/latest/meta-data/iam/security-credentials/${role}`, { headers, signal: signal() })).json();
|
|
175
|
+
return { accessKeyId: found.AccessKeyId, secretAccessKey: found.SecretAccessKey, sessionToken: found.Token, ...(expiry(found.Expiration) ? { expiresAtMs: expiry(found.Expiration) } : {}) };
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
];
|
|
183
|
+
async function resolve() {
|
|
184
|
+
const failures = [];
|
|
185
|
+
for (const source of sources) {
|
|
186
|
+
try {
|
|
187
|
+
const credentials = await source.load();
|
|
188
|
+
if (credentials) {
|
|
189
|
+
cached = { credentials, source: source.name };
|
|
190
|
+
return credentials;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
catch (error) {
|
|
194
|
+
failures.push(`${source.name}: ${error instanceof Error ? error.message : String(error)}`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
throw new Error([
|
|
198
|
+
'No AWS credentials found for the object store.',
|
|
199
|
+
'Atlas looked, in order, at: ATLAS_S3_ACCESS_KEY_ID / ATLAS_S3_SECRET_ACCESS_KEY, AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY, EKS web identity, ECS container credentials, ~/.aws/credentials, the AWS CLI (aws sso login), and EC2 instance metadata.',
|
|
200
|
+
...failures.map(failure => ` - ${failure}`),
|
|
201
|
+
'Fix: export AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY, or run `aws sso login` / `aws configure`, or attach a role to the machine.'
|
|
202
|
+
].join('\n'));
|
|
203
|
+
}
|
|
204
|
+
const provider = (async () => {
|
|
205
|
+
if (cached && (cached.credentials.expiresAtMs === undefined || cached.credentials.expiresAtMs - skew > now()))
|
|
206
|
+
return cached.credentials;
|
|
207
|
+
// One lookup at a time, however many requests are waiting for credentials.
|
|
208
|
+
pending ??= resolve().finally(() => { pending = null; });
|
|
209
|
+
return pending;
|
|
210
|
+
});
|
|
211
|
+
provider.invalidate = () => { cached = null; };
|
|
212
|
+
provider.source = () => cached?.source ?? null;
|
|
213
|
+
return provider;
|
|
214
|
+
}
|
|
215
|
+
/** Wraps keys that are already known in a {@link CredentialProvider} that never refreshes (its `invalidate` does nothing). */
|
|
216
|
+
export function staticCredentials(credentials) {
|
|
217
|
+
const provider = (async () => credentials);
|
|
218
|
+
provider.invalidate = () => { };
|
|
219
|
+
provider.source = () => 'given to the store';
|
|
220
|
+
return provider;
|
|
221
|
+
}
|
|
@@ -1,2 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module Browser navigation for the reports viewer
|
|
3
|
+
*
|
|
4
|
+
* The one implementation of {@link ReportNavigation} that touches `window` and `document`: the explorer's selection
|
|
5
|
+
* lives in the URL hash, keys come from `keydown`, and the tab title is `document.title`. Browser-only; nothing
|
|
6
|
+
* here runs until {@link browserReportNavigation} is called, so importing it is safe in Node.
|
|
7
|
+
*/
|
|
1
8
|
import type { ReportNavigation } from './navigation.js';
|
|
9
|
+
/**
|
|
10
|
+
* Creates a {@link ReportNavigation} backed by `window.location.hash`, `history`, `keydown` and `document.title`.
|
|
11
|
+
*
|
|
12
|
+
* `writeHash` does nothing when the hash is already the given one; otherwise it replaces or pushes a history entry
|
|
13
|
+
* (no `hashchange` is fired by `replaceState`/`pushState`, so the caller owns re-reading). `subscribeHash` listens to both
|
|
14
|
+
* `hashchange` and `popstate`. Every `subscribe*` returns its unsubscribe function. Must be called in a browser.
|
|
15
|
+
*/
|
|
2
16
|
export declare function browserReportNavigation(): ReportNavigation;
|
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Creates a {@link ReportNavigation} backed by `window.location.hash`, `history`, `keydown` and `document.title`.
|
|
3
|
+
*
|
|
4
|
+
* `writeHash` does nothing when the hash is already the given one; otherwise it replaces or pushes a history entry
|
|
5
|
+
* (no `hashchange` is fired by `replaceState`/`pushState`, so the caller owns re-reading). `subscribeHash` listens to both
|
|
6
|
+
* `hashchange` and `popstate`. Every `subscribe*` returns its unsubscribe function. Must be called in a browser.
|
|
7
|
+
*/
|
|
1
8
|
export function browserReportNavigation() {
|
|
2
9
|
return {
|
|
3
10
|
readHash: () => window.location.hash,
|
|
@@ -1,24 +1,56 @@
|
|
|
1
1
|
import type { ObjectStore } from './object-store.js';
|
|
2
|
+
/** Uncompressed size of one chunk, in bytes (4 MiB). A file larger than this is split across several chunks. */
|
|
2
3
|
export declare const BUNDLE_BYTES: number;
|
|
3
4
|
type Location = {
|
|
4
5
|
blob: string;
|
|
5
6
|
offset: number;
|
|
6
7
|
size: number;
|
|
7
8
|
};
|
|
9
|
+
/**
|
|
10
|
+
* Where each file of a bundle is, keyed by the file's key (for a layer, `attachments/<sha256>` and the like).
|
|
11
|
+
* `parts` lists, in order, the byte ranges (`offset`, `size` within the decompressed blob `blob`, a store key) whose
|
|
12
|
+
* concatenation is the file.
|
|
13
|
+
*/
|
|
8
14
|
export type BundleIndex = Record<string, {
|
|
9
15
|
contentType: string;
|
|
10
16
|
parts: Location[];
|
|
11
17
|
}>;
|
|
18
|
+
/** One file to bundle, or one file read back. */
|
|
12
19
|
export type BundleEntry = {
|
|
20
|
+
/** The file's name inside the bundle; the key in the {@link BundleIndex}. A repeated key appends more parts to the same file. */
|
|
13
21
|
key: string;
|
|
14
22
|
bytes: Uint8Array;
|
|
23
|
+
/** The media type to serve the bytes with. The first entry with a key decides it. */
|
|
15
24
|
contentType: string;
|
|
16
25
|
};
|
|
26
|
+
/** The store key of a commit's bundle index for one source: `commits/<sha>/bundles/<source>.json`. This is the older, per-commit layout; layers hold their index in the layer document instead. */
|
|
17
27
|
export declare function bundleIndexKey(sha: string, source: string): string;
|
|
18
|
-
/**
|
|
28
|
+
/**
|
|
29
|
+
* Writes `entries` as blobs next to `key` and then the index at `key`: up to four compressed chunks in flight,
|
|
30
|
+
* with the one small index published last, so a reader never finds an index whose blobs have not arrived.
|
|
31
|
+
*
|
|
32
|
+
* Trap: blobs go to `<key up to "/bundles/">/blobs/`, so `key` must contain `/bundles/` (as {@link bundleIndexKey}
|
|
33
|
+
* gives); otherwise the prefix is computed from a wrong slice.
|
|
34
|
+
*/
|
|
19
35
|
export declare function publishBundle(store: ObjectStore, key: string, entries: AsyncIterable<BundleEntry>): Promise<void>;
|
|
36
|
+
/**
|
|
37
|
+
* Packs `entries` into gzip blobs under `prefix` (for layers, `layers/blobs/`) and returns the index that locates
|
|
38
|
+
* every file; the caller stores the index (a layer keeps it as its `bundle`).
|
|
39
|
+
*
|
|
40
|
+
* The entries are consumed lazily with up to four chunks in flight. A blob that already exists in the store is not
|
|
41
|
+
* written again (one `head` per distinct blob). The gzip header's timestamp is zeroed, and the blob name is the
|
|
42
|
+
* SHA-256 of the uncompressed chunk, so the same files in the same order always produce the same blobs. Order of
|
|
43
|
+
* entries matters for that: chunking follows input order. A store error rejects after started writes settle.
|
|
44
|
+
*/
|
|
20
45
|
export declare function writeBundle(store: ObjectStore, entries: AsyncIterable<BundleEntry>, prefix: string): Promise<BundleIndex>;
|
|
21
|
-
/**
|
|
46
|
+
/**
|
|
47
|
+
* Creates a reader that returns one file of a bundle by key, or `null` when the key is not in `index` or one of
|
|
48
|
+
* its blobs is missing from the store.
|
|
49
|
+
*
|
|
50
|
+
* Only immutable blobs are cached (the last four decompressed blobs, oldest evicted first, including a cached
|
|
51
|
+
* "missing" answer); mutable indexes are read for each request. Transport errors from the store reject.
|
|
52
|
+
*/
|
|
22
53
|
export declare function createBundleReader(store: ObjectStore, index: BundleIndex): (key: string) => Promise<BundleEntry | null>;
|
|
54
|
+
/** Reads and parses the bundle index at `key`; an index that is not there is an empty bundle (`{}`). Invalid JSON throws. */
|
|
23
55
|
export declare function readBundleIndex(store: ObjectStore, key: string): Promise<BundleIndex>;
|
|
24
56
|
export {};
|
|
@@ -1,6 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module Bundles of captured files
|
|
3
|
+
*
|
|
4
|
+
* Packs many small captured files (screenshots, frames, attachments) into a few gzip-compressed, content-addressed
|
|
5
|
+
* blobs in an {@link ObjectStore}, and reads single files back out. The bytes of every entry are concatenated into
|
|
6
|
+
* chunks of at most {@link BUNDLE_BYTES} uncompressed bytes; each chunk is gzipped and stored once at
|
|
7
|
+
* `<prefix><sha256 of the uncompressed chunk>.gz`, so identical chunks and re-publishes write nothing new. A
|
|
8
|
+
* {@link BundleIndex} says, per file key, which byte ranges of which blobs hold it. A layer document keeps that
|
|
9
|
+
* index in its `bundle` field. Blobs are immutable; the index is the only mutable-looking part and is written last.
|
|
10
|
+
*
|
|
11
|
+
* @see docs/report-contract.md (where bytes go)
|
|
12
|
+
*/
|
|
1
13
|
import { readObjectBytes, readUtf8, writeUtf8, } from './object-io.js';
|
|
2
14
|
import { eachLimitedStream } from './completion-order.js';
|
|
15
|
+
/** Uncompressed size of one chunk, in bytes (4 MiB). A file larger than this is split across several chunks. */
|
|
3
16
|
export const BUNDLE_BYTES = 4 * 1024 * 1024;
|
|
17
|
+
/** The store key of a commit's bundle index for one source: `commits/<sha>/bundles/<source>.json`. This is the older, per-commit layout; layers hold their index in the layer document instead. */
|
|
4
18
|
export function bundleIndexKey(sha, source) {
|
|
5
19
|
return `commits/${sha}/bundles/${source}.json`;
|
|
6
20
|
}
|
|
@@ -67,18 +81,39 @@ async function* chunks(entries) {
|
|
|
67
81
|
if (pending.length > 0)
|
|
68
82
|
yield pending;
|
|
69
83
|
}
|
|
70
|
-
/**
|
|
84
|
+
/**
|
|
85
|
+
* Writes `entries` as blobs next to `key` and then the index at `key`: up to four compressed chunks in flight,
|
|
86
|
+
* with the one small index published last, so a reader never finds an index whose blobs have not arrived.
|
|
87
|
+
*
|
|
88
|
+
* Trap: blobs go to `<key up to "/bundles/">/blobs/`, so `key` must contain `/bundles/` (as {@link bundleIndexKey}
|
|
89
|
+
* gives); otherwise the prefix is computed from a wrong slice.
|
|
90
|
+
*/
|
|
71
91
|
export async function publishBundle(store, key, entries) {
|
|
72
92
|
const index = await writeBundle(store, entries, `${key.slice(0, key.indexOf('/bundles/'))}/blobs/`);
|
|
73
93
|
await writeUtf8(store, key, JSON.stringify(index), 'application/json');
|
|
74
94
|
}
|
|
95
|
+
/**
|
|
96
|
+
* Packs `entries` into gzip blobs under `prefix` (for layers, `layers/blobs/`) and returns the index that locates
|
|
97
|
+
* every file; the caller stores the index (a layer keeps it as its `bundle`).
|
|
98
|
+
*
|
|
99
|
+
* The entries are consumed lazily with up to four chunks in flight. A blob that already exists in the store is not
|
|
100
|
+
* written again (one `head` per distinct blob). The gzip header's timestamp is zeroed, and the blob name is the
|
|
101
|
+
* SHA-256 of the uncompressed chunk, so the same files in the same order always produce the same blobs. Order of
|
|
102
|
+
* entries matters for that: chunking follows input order. A store error rejects after started writes settle.
|
|
103
|
+
*/
|
|
75
104
|
export async function writeBundle(store, entries, prefix) {
|
|
76
105
|
const index = {};
|
|
77
106
|
const writes = { prefix, pending: new Map() };
|
|
78
107
|
await eachLimitedStream(chunks(entries), 4, chunk => flush(store, chunk, index, writes));
|
|
79
108
|
return index;
|
|
80
109
|
}
|
|
81
|
-
/**
|
|
110
|
+
/**
|
|
111
|
+
* Creates a reader that returns one file of a bundle by key, or `null` when the key is not in `index` or one of
|
|
112
|
+
* its blobs is missing from the store.
|
|
113
|
+
*
|
|
114
|
+
* Only immutable blobs are cached (the last four decompressed blobs, oldest evicted first, including a cached
|
|
115
|
+
* "missing" answer); mutable indexes are read for each request. Transport errors from the store reject.
|
|
116
|
+
*/
|
|
82
117
|
export function createBundleReader(store, index) {
|
|
83
118
|
const cache = new Map();
|
|
84
119
|
return async (key) => {
|
|
@@ -108,6 +143,7 @@ export function createBundleReader(store, index) {
|
|
|
108
143
|
return { key, bytes, contentType: location.contentType };
|
|
109
144
|
};
|
|
110
145
|
}
|
|
146
|
+
/** Reads and parses the bundle index at `key`; an index that is not there is an empty bundle (`{}`). Invalid JSON throws. */
|
|
111
147
|
export async function readBundleIndex(store, key) {
|
|
112
148
|
const text = await readUtf8(store, key);
|
|
113
149
|
return text === null ? {} : JSON.parse(text);
|