@nitpicker/crawler 0.9.0 → 0.12.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/lib/archive/archive-accessor.d.ts +87 -14
- package/lib/archive/archive-accessor.js +162 -36
- package/lib/archive/archive.d.ts +262 -24
- package/lib/archive/archive.js +397 -86
- package/lib/archive/cache/compute-archive-cache-key.d.ts +39 -0
- package/lib/archive/cache/compute-archive-cache-key.js +95 -0
- package/lib/archive/cache/extract-archive-to-cache.d.ts +43 -0
- package/lib/archive/cache/extract-archive-to-cache.js +309 -0
- package/lib/archive/cache/get-archive-cache-root.d.ts +20 -0
- package/lib/archive/cache/get-archive-cache-root.js +53 -0
- package/lib/archive/cache/is-archive-cache-disabled.d.ts +24 -0
- package/lib/archive/cache/is-archive-cache-disabled.js +34 -0
- package/lib/archive/cache/resolve-archive-cache-dir.d.ts +26 -0
- package/lib/archive/cache/resolve-archive-cache-dir.js +32 -0
- package/lib/archive/database.d.ts +411 -26
- package/lib/archive/database.js +1881 -540
- package/lib/archive/derive-lineage-from-parent.d.ts +37 -0
- package/lib/archive/derive-lineage-from-parent.js +42 -0
- package/lib/archive/filesystem/peek-tar-top-dir.d.ts +28 -0
- package/lib/archive/filesystem/peek-tar-top-dir.js +65 -0
- package/lib/archive/get-failed-page-messages.d.ts +43 -0
- package/lib/archive/get-failed-page-messages.js +131 -0
- package/lib/archive/init-schema.d.ts +53 -2
- package/lib/archive/init-schema.js +399 -15
- package/lib/archive/is-inventory-source.d.ts +21 -0
- package/lib/archive/is-inventory-source.js +22 -0
- package/lib/archive/meta/assert-compatible-version.d.ts +39 -0
- package/lib/archive/meta/assert-compatible-version.js +72 -0
- package/lib/archive/meta/classify-jsonld-type.d.ts +23 -0
- package/lib/archive/meta/classify-jsonld-type.js +43 -0
- package/lib/archive/meta/compare-semver.d.ts +23 -0
- package/lib/archive/meta/compare-semver.js +51 -0
- package/lib/archive/meta/compute-page-denormalized.d.ts +21 -0
- package/lib/archive/meta/compute-page-denormalized.js +35 -0
- package/lib/archive/meta/derive-flat-from-meta.d.ts +35 -0
- package/lib/archive/meta/derive-flat-from-meta.js +158 -0
- package/lib/archive/meta/derive-meta-extras.d.ts +20 -0
- package/lib/archive/meta/derive-meta-extras.js +23 -0
- package/lib/archive/meta/extract-tags-for-archive.d.ts +18 -0
- package/lib/archive/meta/extract-tags-for-archive.js +36 -0
- package/lib/archive/meta/summarize-jsonld.d.ts +17 -0
- package/lib/archive/meta/summarize-jsonld.js +29 -0
- package/lib/archive/meta/summarize-tags.d.ts +16 -0
- package/lib/archive/meta/summarize-tags.js +33 -0
- package/lib/archive/meta/types.d.ts +207 -0
- package/lib/archive/meta/types.js +33 -0
- package/lib/archive/migrate-crawl-errors.d.ts +20 -0
- package/lib/archive/migrate-crawl-errors.js +38 -0
- package/lib/archive/migrate-html-blob-tables.d.ts +24 -0
- package/lib/archive/migrate-html-blob-tables.js +53 -0
- package/lib/archive/migrate-inventory-runs.d.ts +29 -0
- package/lib/archive/migrate-inventory-runs.js +52 -0
- package/lib/archive/migrate-page-errors.d.ts +16 -0
- package/lib/archive/migrate-page-errors.js +35 -0
- package/lib/archive/migrate-pages-resources-source.d.ts +16 -0
- package/lib/archive/migrate-pages-resources-source.js +46 -0
- package/lib/archive/page.d.ts +187 -49
- package/lib/archive/page.js +258 -63
- package/lib/archive/peek-archive-lock.d.ts +40 -0
- package/lib/archive/peek-archive-lock.js +62 -0
- package/lib/archive/resolve-redirect-chain.d.ts +33 -0
- package/lib/archive/resolve-redirect-chain.js +27 -0
- package/lib/archive/types.d.ts +168 -26
- package/lib/classify-error-kind.d.ts +19 -0
- package/lib/classify-error-kind.js +122 -0
- package/lib/crawler/build-js-redirect-edge.d.ts +68 -0
- package/lib/crawler/build-js-redirect-edge.js +57 -0
- package/lib/crawler/build-redirect-event.d.ts +24 -0
- package/lib/crawler/build-redirect-event.js +28 -0
- package/lib/crawler/clear-dns-burned-host-cache.d.ts +6 -0
- package/lib/crawler/clear-dns-burned-host-cache.js +11 -0
- package/lib/crawler/close-browser-safely.d.ts +64 -0
- package/lib/crawler/close-browser-safely.js +73 -0
- package/lib/crawler/crawler.d.ts +7 -2
- package/lib/crawler/crawler.js +919 -113
- package/lib/crawler/create-change-phase-handler.d.ts +54 -0
- package/lib/crawler/create-change-phase-handler.js +44 -0
- package/lib/crawler/derive-js-redirect-target.d.ts +68 -0
- package/lib/crawler/derive-js-redirect-target.js +129 -0
- package/lib/crawler/derive-page-source.d.ts +23 -0
- package/lib/crawler/derive-page-source.js +28 -0
- package/lib/crawler/derive-resource-source.d.ts +33 -0
- package/lib/crawler/derive-resource-source.js +37 -0
- package/lib/crawler/dns-burned-host-cache.d.ts +26 -0
- package/lib/crawler/dns-burned-host-cache.js +25 -0
- package/lib/crawler/dns-burned-host-short-circuit-counter.d.ts +13 -0
- package/lib/crawler/dns-burned-host-short-circuit-counter.js +11 -0
- package/lib/crawler/drain-phase-errors.d.ts +48 -0
- package/lib/crawler/drain-phase-errors.js +35 -0
- package/lib/crawler/fetch-destination.d.ts +12 -4
- package/lib/crawler/fetch-destination.js +132 -18
- package/lib/crawler/format-crawl-progress.d.ts +12 -3
- package/lib/crawler/format-crawl-progress.js +14 -6
- package/lib/crawler/handle-browser-close.d.ts +29 -0
- package/lib/crawler/handle-browser-close.js +28 -0
- package/lib/crawler/is-html-content-type.d.ts +17 -0
- package/lib/crawler/is-html-content-type.js +19 -0
- package/lib/crawler/is-js-redirect-error-shape.d.ts +40 -0
- package/lib/crawler/is-js-redirect-error-shape.js +53 -0
- package/lib/crawler/is-likely-html-url.d.ts +22 -0
- package/lib/crawler/is-likely-html-url.js +65 -0
- package/lib/crawler/is-puppeteer-fallback-candidate.d.ts +16 -0
- package/lib/crawler/is-puppeteer-fallback-candidate.js +63 -0
- package/lib/crawler/kill-process-tree.d.ts +94 -0
- package/lib/crawler/kill-process-tree.js +178 -0
- package/lib/crawler/link-list.d.ts +21 -1
- package/lib/crawler/link-list.js +25 -4
- package/lib/crawler/link-to-page-data.d.ts +13 -5
- package/lib/crawler/link-to-page-data.js +26 -5
- package/lib/crawler/log-undrained-phase-errors.d.ts +37 -0
- package/lib/crawler/log-undrained-phase-errors.js +34 -0
- package/lib/crawler/normalize-content-type.d.ts +14 -0
- package/lib/crawler/normalize-content-type.js +20 -0
- package/lib/crawler/partition-urls-by-html.d.ts +16 -0
- package/lib/crawler/partition-urls-by-html.js +23 -0
- package/lib/crawler/plan-sub-resource-emits.d.ts +63 -0
- package/lib/crawler/plan-sub-resource-emits.js +44 -0
- package/lib/crawler/preload-short-circuit-error.d.ts +22 -0
- package/lib/crawler/preload-short-circuit-error.js +25 -0
- package/lib/crawler/redirect-dest-key.d.ts +19 -0
- package/lib/crawler/redirect-dest-key.js +27 -0
- package/lib/crawler/resource-to-page-data.d.ts +28 -0
- package/lib/crawler/resource-to-page-data.js +59 -0
- package/lib/crawler/should-burn-host.d.ts +78 -0
- package/lib/crawler/should-burn-host.js +61 -0
- package/lib/crawler/should-get-fallback-on-head-failure.d.ts +38 -0
- package/lib/crawler/should-get-fallback-on-head-failure.js +46 -0
- package/lib/crawler/types.d.ts +229 -1
- package/lib/crawler-orchestrator.d.ts +105 -3
- package/lib/crawler-orchestrator.js +620 -20
- package/lib/crawler.d.ts +8 -2
- package/lib/crawler.js +6 -1
- package/lib/permanent-error-kinds.d.ts +43 -0
- package/lib/permanent-error-kinds.js +48 -0
- package/lib/resource-row-to-lookup-result.d.ts +13 -0
- package/lib/resource-row-to-lookup-result.js +20 -0
- package/lib/types.d.ts +95 -1
- package/lib/utils/compute-file-sha256.d.ts +23 -0
- package/lib/utils/compute-file-sha256.js +55 -0
- package/lib/utils/error/emit-error-with-retry.d.ts +40 -0
- package/lib/utils/error/emit-error-with-retry.js +44 -0
- package/lib/utils/error/emit-error.d.ts +39 -0
- package/lib/utils/error/emit-error.js +41 -0
- package/lib/utils/object/parse-response-headers.d.ts +12 -0
- package/lib/utils/object/parse-response-headers.js +26 -0
- package/package.json +11 -11
- package/lib/utils/error/error-emitter.d.ts +0 -18
- package/lib/utils/error/error-emitter.js +0 -29
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Derive a stable cache key from a `.nitpicker` file's inode metadata
|
|
3
|
+
* plus a head+tail content sample.
|
|
4
|
+
*
|
|
5
|
+
* Metadata fields:
|
|
6
|
+
*
|
|
7
|
+
* - `size` covers the bulk of accidental cache collision risk in O(1).
|
|
8
|
+
* - `mtime` (mod time) changes whenever the file's content is rewritten,
|
|
9
|
+
* which is the common case (`crawl --append`, `crawl --retry-failed`,
|
|
10
|
+
* `cp -f`, rsync).
|
|
11
|
+
* - `ctime` (inode change time) closes the `touch -m -t <past>` loophole:
|
|
12
|
+
* even if a user resets `mtime` to fake "unchanged", the act of touching
|
|
13
|
+
* bumps `ctime` on POSIX so the key still rolls.
|
|
14
|
+
*
|
|
15
|
+
* Head + tail digest:
|
|
16
|
+
*
|
|
17
|
+
* - On filesystems with low-resolution timestamps (FAT / exFAT / NFSv3 /
|
|
18
|
+
* some Docker volume mounts where mtime/ctime are second-granular and
|
|
19
|
+
* sometimes don't move on small appends), `size + mtime + ctime`
|
|
20
|
+
* alone can stay identical across an in-place rewrite.
|
|
21
|
+
* - We hash the first {@link HEAD_TAIL_HASH_BYTES} bytes and the last
|
|
22
|
+
* {@link HEAD_TAIL_HASH_BYTES} bytes to detect this. For tar archives
|
|
23
|
+
* the head holds the first entry's header (which moves when the inner
|
|
24
|
+
* directory's name changes) and the tail holds the last entry's data
|
|
25
|
+
* (which always moves on `crawl --append` because the appended pages
|
|
26
|
+
* land near the end of the tar stream).
|
|
27
|
+
* - Full-content sha256 was rejected: ~20-30 s on a 10 GB archive,
|
|
28
|
+
* which is slower than the untar this cache is meant to avoid.
|
|
29
|
+
*
|
|
30
|
+
* The key is stable across symlinks (the caller resolves via
|
|
31
|
+
* `fs.realpath` upstream) but **not** across hardlinks pointing at a
|
|
32
|
+
* mutated inode — that is intentional, hardlinking is a power-user
|
|
33
|
+
* move and the user is expected to know the cache will share an entry.
|
|
34
|
+
* @param archivePath - Absolute path to the `.nitpicker` file.
|
|
35
|
+
* @returns A string of the form
|
|
36
|
+
* `<size>-<mtime_ns>-<ctime_ns>-<headHex>-<tailHex>` suitable for use
|
|
37
|
+
* as a directory-name component.
|
|
38
|
+
*/
|
|
39
|
+
export declare function computeArchiveCacheKey(archivePath: string): Promise<string>;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { createReadStream } from 'node:fs';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
/**
|
|
5
|
+
* Number of bytes hashed from each end of the file to corner the case
|
|
6
|
+
* where two different archives share `size + mtime + ctime` by accident.
|
|
7
|
+
*
|
|
8
|
+
* 64 KiB is a sweet spot: on a 10 GB archive it reads 0.0006 % of the
|
|
9
|
+
* file (~1 ms on an SSD, ~5 ms on a spinning disk) while reliably
|
|
10
|
+
* sampling enough of both the tar leading header AND the tail (which
|
|
11
|
+
* for tar archives contains the last-written entry's data, so any real
|
|
12
|
+
* mutation moves at least one of the two windows).
|
|
13
|
+
*/
|
|
14
|
+
const HEAD_TAIL_HASH_BYTES = 64 * 1024;
|
|
15
|
+
/**
|
|
16
|
+
* Cache-key segment substituted when a file is smaller than the
|
|
17
|
+
* head+tail sample window. In that case the head segment alone already
|
|
18
|
+
* spans the whole file (size column also rolls), so a separate tail
|
|
19
|
+
* digest is meaningless and we encode that explicitly rather than
|
|
20
|
+
* silently overlapping reads.
|
|
21
|
+
*/
|
|
22
|
+
const TAIL_NOT_NEEDED = 'short';
|
|
23
|
+
/**
|
|
24
|
+
* Derive a stable cache key from a `.nitpicker` file's inode metadata
|
|
25
|
+
* plus a head+tail content sample.
|
|
26
|
+
*
|
|
27
|
+
* Metadata fields:
|
|
28
|
+
*
|
|
29
|
+
* - `size` covers the bulk of accidental cache collision risk in O(1).
|
|
30
|
+
* - `mtime` (mod time) changes whenever the file's content is rewritten,
|
|
31
|
+
* which is the common case (`crawl --append`, `crawl --retry-failed`,
|
|
32
|
+
* `cp -f`, rsync).
|
|
33
|
+
* - `ctime` (inode change time) closes the `touch -m -t <past>` loophole:
|
|
34
|
+
* even if a user resets `mtime` to fake "unchanged", the act of touching
|
|
35
|
+
* bumps `ctime` on POSIX so the key still rolls.
|
|
36
|
+
*
|
|
37
|
+
* Head + tail digest:
|
|
38
|
+
*
|
|
39
|
+
* - On filesystems with low-resolution timestamps (FAT / exFAT / NFSv3 /
|
|
40
|
+
* some Docker volume mounts where mtime/ctime are second-granular and
|
|
41
|
+
* sometimes don't move on small appends), `size + mtime + ctime`
|
|
42
|
+
* alone can stay identical across an in-place rewrite.
|
|
43
|
+
* - We hash the first {@link HEAD_TAIL_HASH_BYTES} bytes and the last
|
|
44
|
+
* {@link HEAD_TAIL_HASH_BYTES} bytes to detect this. For tar archives
|
|
45
|
+
* the head holds the first entry's header (which moves when the inner
|
|
46
|
+
* directory's name changes) and the tail holds the last entry's data
|
|
47
|
+
* (which always moves on `crawl --append` because the appended pages
|
|
48
|
+
* land near the end of the tar stream).
|
|
49
|
+
* - Full-content sha256 was rejected: ~20-30 s on a 10 GB archive,
|
|
50
|
+
* which is slower than the untar this cache is meant to avoid.
|
|
51
|
+
*
|
|
52
|
+
* The key is stable across symlinks (the caller resolves via
|
|
53
|
+
* `fs.realpath` upstream) but **not** across hardlinks pointing at a
|
|
54
|
+
* mutated inode — that is intentional, hardlinking is a power-user
|
|
55
|
+
* move and the user is expected to know the cache will share an entry.
|
|
56
|
+
* @param archivePath - Absolute path to the `.nitpicker` file.
|
|
57
|
+
* @returns A string of the form
|
|
58
|
+
* `<size>-<mtime_ns>-<ctime_ns>-<headHex>-<tailHex>` suitable for use
|
|
59
|
+
* as a directory-name component.
|
|
60
|
+
*/
|
|
61
|
+
export async function computeArchiveCacheKey(archivePath) {
|
|
62
|
+
const stats = await fs.stat(archivePath, { bigint: true });
|
|
63
|
+
const fileSize = Number(stats.size);
|
|
64
|
+
const headHex = await sha256OfRange(archivePath, 0, Math.min(HEAD_TAIL_HASH_BYTES, fileSize));
|
|
65
|
+
const tailHex = fileSize > HEAD_TAIL_HASH_BYTES
|
|
66
|
+
? await sha256OfRange(archivePath, fileSize - HEAD_TAIL_HASH_BYTES, HEAD_TAIL_HASH_BYTES)
|
|
67
|
+
: TAIL_NOT_NEEDED;
|
|
68
|
+
return `${stats.size}-${stats.mtimeNs}-${stats.ctimeNs}-${headHex}-${tailHex}`;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* SHA-256 the requested byte range of a file via a single read stream.
|
|
72
|
+
* Truncated to 16 hex chars (64 bits) because the digest only needs to
|
|
73
|
+
* disambiguate within a `(size, mtime, ctime)` bucket — full 256-bit
|
|
74
|
+
* fingerprints would only waste filesystem path budget.
|
|
75
|
+
* @param filePath - Absolute path to the file.
|
|
76
|
+
* @param start - Inclusive byte offset to begin reading at.
|
|
77
|
+
* @param length - Number of bytes to read; 0 returns the empty-input digest.
|
|
78
|
+
* @returns A 16-character hex string.
|
|
79
|
+
*/
|
|
80
|
+
async function sha256OfRange(filePath, start, length) {
|
|
81
|
+
const hash = createHash('sha256');
|
|
82
|
+
if (length === 0) {
|
|
83
|
+
return hash.digest('hex').slice(0, 16);
|
|
84
|
+
}
|
|
85
|
+
await new Promise((resolve, reject) => {
|
|
86
|
+
const stream = createReadStream(filePath, {
|
|
87
|
+
start,
|
|
88
|
+
end: start + length - 1,
|
|
89
|
+
});
|
|
90
|
+
stream.on('data', (chunk) => hash.update(chunk));
|
|
91
|
+
stream.on('end', () => resolve());
|
|
92
|
+
stream.on('error', (error) => reject(error));
|
|
93
|
+
});
|
|
94
|
+
return hash.digest('hex').slice(0, 16);
|
|
95
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Untar a `.nitpicker` archive into the given cache directory.
|
|
3
|
+
*
|
|
4
|
+
* Concurrency / re-entry contract:
|
|
5
|
+
*
|
|
6
|
+
* - Two viewers opening the same archive race for the cache lock; the
|
|
7
|
+
* loser waits and recheck-loops until the winner writes the ready
|
|
8
|
+
* marker, then short-circuits.
|
|
9
|
+
* - Same-process concurrent callers dedupe through
|
|
10
|
+
* `inFlightByCacheDir` so they share one extraction promise.
|
|
11
|
+
*
|
|
12
|
+
* Per-archive staging:
|
|
13
|
+
*
|
|
14
|
+
* - The tar's inner directory is extracted into `${cacheDir}.staging/`
|
|
15
|
+
* first, then atomically renamed into `cacheDir`. Crucially, the
|
|
16
|
+
* staging path is keyed off `cacheDir` (not the tar's inner directory
|
|
17
|
+
* name) so two archives that happen to share an inner-dir name never
|
|
18
|
+
* collide on the same staging slot.
|
|
19
|
+
*
|
|
20
|
+
* Integrity guard:
|
|
21
|
+
*
|
|
22
|
+
* - After rename, we run the writer-side `Database.connect` so all
|
|
23
|
+
* migrations apply (column adds, new tables) and the version check
|
|
24
|
+
* fires before the ready marker is written. A corrupt or incompatible
|
|
25
|
+
* archive therefore poisons no cache entry — the cacheDir is removed
|
|
26
|
+
* and the next caller retries from scratch.
|
|
27
|
+
*
|
|
28
|
+
* TOCTOU guard:
|
|
29
|
+
*
|
|
30
|
+
* - The cache key is recomputed from the file's stat AFTER the untar
|
|
31
|
+
* completes. If the file changed mid-flight (a concurrent
|
|
32
|
+
* `crawl --append` rewrote it), the freshly-landed cache contents do
|
|
33
|
+
* not correspond to the original key, so we abort and remove the
|
|
34
|
+
* cacheDir so the caller upstream sees the inconsistency rather than
|
|
35
|
+
* serving misattributed data.
|
|
36
|
+
* @param archivePath - Absolute path to the source `.nitpicker` file.
|
|
37
|
+
* @param cacheRoot - Absolute path to the cache root directory.
|
|
38
|
+
* @param cacheDir - Absolute path the extracted contents should end up at.
|
|
39
|
+
* @param cacheKey - The cache key used to derive `cacheDir`. Recomputed
|
|
40
|
+
* after extraction to detect concurrent writers; must match.
|
|
41
|
+
* @returns Resolves once `cacheDir` is ready to be opened read-only.
|
|
42
|
+
*/
|
|
43
|
+
export declare function extractArchiveToCache(archivePath: string, cacheRoot: string, cacheDir: string, cacheKey: string): Promise<void>;
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { acquireArchiveLock, ArchiveLockError } from '../archive-lock.js';
|
|
4
|
+
import { Database } from '../database.js';
|
|
5
|
+
import { peekTarTopDir } from '../filesystem/peek-tar-top-dir.js';
|
|
6
|
+
import { rename } from '../filesystem/rename.js';
|
|
7
|
+
import { untar } from '../filesystem/untar.js';
|
|
8
|
+
import { IncompatibleArchiveError } from '../meta/types.js';
|
|
9
|
+
import { computeArchiveCacheKey } from './compute-archive-cache-key.js';
|
|
10
|
+
/**
|
|
11
|
+
* How long {@link extractArchiveToCache} waits in total for a peer
|
|
12
|
+
* extractor to finish (ms). 5 minutes is enough for the largest archives
|
|
13
|
+
* the crawler currently produces (~10 GB untars in ~10 s; 5 min gives
|
|
14
|
+
* 30× headroom) while still bounded so a deadlocked process eventually
|
|
15
|
+
* surfaces as a real error instead of hanging the viewer indefinitely.
|
|
16
|
+
*/
|
|
17
|
+
const PEER_WAIT_TIMEOUT_MS = 5 * 60 * 1000;
|
|
18
|
+
/**
|
|
19
|
+
* Polling interval while waiting on a peer extractor (ms). Short enough
|
|
20
|
+
* to keep latency low when the peer is fast, long enough to avoid
|
|
21
|
+
* burning CPU on a busy `fs.stat` loop.
|
|
22
|
+
*/
|
|
23
|
+
const PEER_WAIT_POLL_MS = 50;
|
|
24
|
+
/**
|
|
25
|
+
* Marker file dropped into a cache directory only after the extraction
|
|
26
|
+
* has fully completed AND the contents have passed a structural sanity
|
|
27
|
+
* check (db.sqlite present, schema compatible, migrations applied).
|
|
28
|
+
*
|
|
29
|
+
* Internal — the file name is an implementation detail. Tests should
|
|
30
|
+
* assert through externally observable behaviour (cache hit on second
|
|
31
|
+
* open) rather than importing this constant.
|
|
32
|
+
*/
|
|
33
|
+
const READY_MARKER = '.nitpicker-cache-ready';
|
|
34
|
+
/**
|
|
35
|
+
* In-process deduplication for concurrent {@link extractArchiveToCache}
|
|
36
|
+
* calls targeting the same `cacheDir`.
|
|
37
|
+
*
|
|
38
|
+
* The file-based {@link acquireArchiveLock} (mkdir-based) cannot defend
|
|
39
|
+
* against same-process race windows because the lock holder writes its
|
|
40
|
+
* `pid.txt` in a separate `await` from the directory creation: a sibling
|
|
41
|
+
* promise that observes the `EEXIST` between those two steps sees an
|
|
42
|
+
* empty `pid.txt`, mistakes the lock for stale, and clobbers it.
|
|
43
|
+
*
|
|
44
|
+
* Single-process dedup eliminates that race entirely for the common
|
|
45
|
+
* "viewer + MCP in the same process" topology while still letting the
|
|
46
|
+
* inner file lock guard cross-process collisions.
|
|
47
|
+
*/
|
|
48
|
+
const inFlightByCacheDir = new Map();
|
|
49
|
+
/**
|
|
50
|
+
* Untar a `.nitpicker` archive into the given cache directory.
|
|
51
|
+
*
|
|
52
|
+
* Concurrency / re-entry contract:
|
|
53
|
+
*
|
|
54
|
+
* - Two viewers opening the same archive race for the cache lock; the
|
|
55
|
+
* loser waits and recheck-loops until the winner writes the ready
|
|
56
|
+
* marker, then short-circuits.
|
|
57
|
+
* - Same-process concurrent callers dedupe through
|
|
58
|
+
* `inFlightByCacheDir` so they share one extraction promise.
|
|
59
|
+
*
|
|
60
|
+
* Per-archive staging:
|
|
61
|
+
*
|
|
62
|
+
* - The tar's inner directory is extracted into `${cacheDir}.staging/`
|
|
63
|
+
* first, then atomically renamed into `cacheDir`. Crucially, the
|
|
64
|
+
* staging path is keyed off `cacheDir` (not the tar's inner directory
|
|
65
|
+
* name) so two archives that happen to share an inner-dir name never
|
|
66
|
+
* collide on the same staging slot.
|
|
67
|
+
*
|
|
68
|
+
* Integrity guard:
|
|
69
|
+
*
|
|
70
|
+
* - After rename, we run the writer-side `Database.connect` so all
|
|
71
|
+
* migrations apply (column adds, new tables) and the version check
|
|
72
|
+
* fires before the ready marker is written. A corrupt or incompatible
|
|
73
|
+
* archive therefore poisons no cache entry — the cacheDir is removed
|
|
74
|
+
* and the next caller retries from scratch.
|
|
75
|
+
*
|
|
76
|
+
* TOCTOU guard:
|
|
77
|
+
*
|
|
78
|
+
* - The cache key is recomputed from the file's stat AFTER the untar
|
|
79
|
+
* completes. If the file changed mid-flight (a concurrent
|
|
80
|
+
* `crawl --append` rewrote it), the freshly-landed cache contents do
|
|
81
|
+
* not correspond to the original key, so we abort and remove the
|
|
82
|
+
* cacheDir so the caller upstream sees the inconsistency rather than
|
|
83
|
+
* serving misattributed data.
|
|
84
|
+
* @param archivePath - Absolute path to the source `.nitpicker` file.
|
|
85
|
+
* @param cacheRoot - Absolute path to the cache root directory.
|
|
86
|
+
* @param cacheDir - Absolute path the extracted contents should end up at.
|
|
87
|
+
* @param cacheKey - The cache key used to derive `cacheDir`. Recomputed
|
|
88
|
+
* after extraction to detect concurrent writers; must match.
|
|
89
|
+
* @returns Resolves once `cacheDir` is ready to be opened read-only.
|
|
90
|
+
*/
|
|
91
|
+
export async function extractArchiveToCache(archivePath, cacheRoot, cacheDir, cacheKey) {
|
|
92
|
+
if (await isCacheDirReady(cacheDir)) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const existing = inFlightByCacheDir.get(cacheDir);
|
|
96
|
+
if (existing) {
|
|
97
|
+
return existing;
|
|
98
|
+
}
|
|
99
|
+
const promise = runExtraction(archivePath, cacheRoot, cacheDir, cacheKey).finally(() => {
|
|
100
|
+
inFlightByCacheDir.delete(cacheDir);
|
|
101
|
+
});
|
|
102
|
+
inFlightByCacheDir.set(cacheDir, promise);
|
|
103
|
+
return promise;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* The actual extraction work. Split out from {@link extractArchiveToCache}
|
|
107
|
+
* so the in-process dedup map can wrap it without disturbing the
|
|
108
|
+
* extraction flow itself.
|
|
109
|
+
* @param archivePath - Absolute path to the source `.nitpicker` file.
|
|
110
|
+
* @param cacheRoot - Absolute path to the cache root directory.
|
|
111
|
+
* @param cacheDir - Absolute path the extracted contents should end up at.
|
|
112
|
+
* @param cacheKey - Pre-extraction cache key, re-verified post-extraction.
|
|
113
|
+
*/
|
|
114
|
+
async function runExtraction(archivePath, cacheRoot, cacheDir, cacheKey) {
|
|
115
|
+
await fs.mkdir(cacheRoot, { recursive: true });
|
|
116
|
+
const releaseLock = await acquireLockWithPeerWait(cacheDir);
|
|
117
|
+
try {
|
|
118
|
+
if (await isCacheDirReady(cacheDir)) {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
// Half-populated cacheDir recovery: another extractor crashed
|
|
122
|
+
// before writing the marker. Rename it aside instead of `rm`-ing
|
|
123
|
+
// in place so any reader that somehow still holds an fd on the
|
|
124
|
+
// old contents (the cache layer doesn't refcount readers) is not
|
|
125
|
+
// pulled out from under. The renamed quarantine dir is left for
|
|
126
|
+
// OS-level temp cleanup.
|
|
127
|
+
await quarantineHalfPopulatedCacheDir(cacheDir);
|
|
128
|
+
const stagingDir = `${cacheDir}.staging`;
|
|
129
|
+
// Stage clean: a previous crashed run may have left
|
|
130
|
+
// `<cacheDir>.staging/` around. `untar` would happily merge into
|
|
131
|
+
// it (with `newer:true` cherrypicking entries) so wipe first.
|
|
132
|
+
await fs.rm(stagingDir, { recursive: true, force: true });
|
|
133
|
+
await fs.mkdir(stagingDir, { recursive: true });
|
|
134
|
+
await untar(archivePath, { cwd: stagingDir });
|
|
135
|
+
// Concurrent-writer detection: if the source archive changed
|
|
136
|
+
// during our untar, the contents we just landed do NOT match the
|
|
137
|
+
// `cacheKey` that named the directory. Drop the staging dir and
|
|
138
|
+
// surface as an error so the caller (or a retry) can pick up the
|
|
139
|
+
// new key.
|
|
140
|
+
const postKey = await computeArchiveCacheKey(archivePath);
|
|
141
|
+
if (postKey !== cacheKey) {
|
|
142
|
+
await fs.rm(stagingDir, { recursive: true, force: true });
|
|
143
|
+
throw new Error(`Archive changed during cache extraction (key ${cacheKey} -> ${postKey}); ` +
|
|
144
|
+
`refusing to land mismatched contents at ${cacheDir}.`);
|
|
145
|
+
}
|
|
146
|
+
const innerDirName = await peekTarTopDir(archivePath);
|
|
147
|
+
const extractedInner = path.resolve(stagingDir, innerDirName);
|
|
148
|
+
// Move the inner directory into the cache slot. Any old quarantine
|
|
149
|
+
// is already aside, so the target is guaranteed empty.
|
|
150
|
+
await rename(extractedInner, cacheDir, true);
|
|
151
|
+
// The empty `stagingDir` wrapper is no longer useful — best effort
|
|
152
|
+
// remove (ignore errors, OS cleanup catches stragglers).
|
|
153
|
+
await fs.rm(stagingDir, { recursive: true, force: true }).catch(() => { });
|
|
154
|
+
// Validate + migrate: open in WRITER mode so the full migration
|
|
155
|
+
// stack runs (initSchema / migrate*). On failure (db.sqlite
|
|
156
|
+
// missing, incompatible archive, broken schema), tear down the
|
|
157
|
+
// cache entry so the next caller does not get stuck on a
|
|
158
|
+
// "ready"-marked but broken cache.
|
|
159
|
+
try {
|
|
160
|
+
await runMigrationsOnCacheDir(cacheDir);
|
|
161
|
+
}
|
|
162
|
+
catch (error) {
|
|
163
|
+
await fs.rm(cacheDir, { recursive: true, force: true });
|
|
164
|
+
throw error;
|
|
165
|
+
}
|
|
166
|
+
await fs.writeFile(path.join(cacheDir, READY_MARKER), '', 'utf8');
|
|
167
|
+
}
|
|
168
|
+
finally {
|
|
169
|
+
await releaseLock();
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Acquire the cache lock, blocking until any peer extractor finishes
|
|
174
|
+
* (rather than failing fast on `EEXIST` the way the writer-oriented
|
|
175
|
+
* {@link acquireArchiveLock} does). Returns the release function once
|
|
176
|
+
* the lock is held.
|
|
177
|
+
* @param cacheDir - Absolute path the lock guards.
|
|
178
|
+
*/
|
|
179
|
+
async function acquireLockWithPeerWait(cacheDir) {
|
|
180
|
+
const startedAt = Date.now();
|
|
181
|
+
while (true) {
|
|
182
|
+
if (await isCacheDirReady(cacheDir)) {
|
|
183
|
+
// Peer finished and the marker is up. Return a no-op release
|
|
184
|
+
// so the caller can `finally`-await it without branching.
|
|
185
|
+
return async () => { };
|
|
186
|
+
}
|
|
187
|
+
try {
|
|
188
|
+
return await acquireArchiveLock(cacheDir);
|
|
189
|
+
}
|
|
190
|
+
catch (error) {
|
|
191
|
+
if (!(error instanceof ArchiveLockError)) {
|
|
192
|
+
throw error;
|
|
193
|
+
}
|
|
194
|
+
if (Date.now() - startedAt > PEER_WAIT_TIMEOUT_MS) {
|
|
195
|
+
throw error;
|
|
196
|
+
}
|
|
197
|
+
await sleep(PEER_WAIT_POLL_MS);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Open the migrated DB in writer mode so all migrations apply, then
|
|
203
|
+
* release the handle. Cache entries land migrated, so the subsequent
|
|
204
|
+
* `Archive.connect` read-only open never needs to mutate the cache dir
|
|
205
|
+
* (and never silently misses a newly-added column).
|
|
206
|
+
* @param cacheDir - Absolute path to the freshly-extracted cache dir.
|
|
207
|
+
*/
|
|
208
|
+
async function runMigrationsOnCacheDir(cacheDir) {
|
|
209
|
+
const dbPath = path.join(cacheDir, 'db.sqlite');
|
|
210
|
+
if (!(await fileExists(dbPath))) {
|
|
211
|
+
throw new Error(`Cache directory does not contain db.sqlite: ${cacheDir}`);
|
|
212
|
+
}
|
|
213
|
+
const db = await Database.connect({ filename: dbPath, readOnly: false });
|
|
214
|
+
try {
|
|
215
|
+
// `Database.connect` ran migrations during init. Closing here
|
|
216
|
+
// flushes WAL + drops the handle so the read-only re-open in
|
|
217
|
+
// `Archive.connect` does not race the writer connection.
|
|
218
|
+
await db.destroy();
|
|
219
|
+
}
|
|
220
|
+
catch (error) {
|
|
221
|
+
// Propagate after attempting cleanup of the connection (best
|
|
222
|
+
// effort — the cache dir tear-down happens in the caller).
|
|
223
|
+
if (error instanceof IncompatibleArchiveError) {
|
|
224
|
+
throw error;
|
|
225
|
+
}
|
|
226
|
+
throw error;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Move a half-populated cacheDir aside so the next extraction can land
|
|
231
|
+
* cleanly without yanking live readers' files out from under them. The
|
|
232
|
+
* quarantine path gets a unique suffix so two crashes in succession
|
|
233
|
+
* don't collide.
|
|
234
|
+
* @param cacheDir - Absolute path of the cache slot to free.
|
|
235
|
+
*/
|
|
236
|
+
async function quarantineHalfPopulatedCacheDir(cacheDir) {
|
|
237
|
+
try {
|
|
238
|
+
await fs.access(cacheDir);
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
// Nothing to quarantine.
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
if (await isCacheDirReady(cacheDir)) {
|
|
245
|
+
// Already settled — caller will short-circuit, no quarantine needed.
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
const quarantinePath = `${cacheDir}.corrupt.${process.pid}.${nextQuarantineCounter()}`;
|
|
249
|
+
try {
|
|
250
|
+
await fs.rename(cacheDir, quarantinePath);
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
// If rename fails (e.g. cross-volume), fall back to direct
|
|
254
|
+
// removal — accepting the rm-while-reader risk this single time
|
|
255
|
+
// since we have no other option.
|
|
256
|
+
await fs.rm(cacheDir, { recursive: true, force: true });
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Monotonic counter appended to quarantine directory names so multiple
|
|
261
|
+
* recoveries in a single process never collide on the same path. Resets
|
|
262
|
+
* to zero after `2 ** 32` increments; that bound is far larger than any
|
|
263
|
+
* realistic per-process quarantine count.
|
|
264
|
+
*/
|
|
265
|
+
let quarantineCounter = 0;
|
|
266
|
+
/**
|
|
267
|
+
* Issue the next quarantine sequence number.
|
|
268
|
+
* @returns A non-negative integer, monotonically increasing per process.
|
|
269
|
+
*/
|
|
270
|
+
function nextQuarantineCounter() {
|
|
271
|
+
quarantineCounter = (quarantineCounter + 1) % 0x1_00_00_00_00;
|
|
272
|
+
return quarantineCounter;
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Async existence probe — avoids blocking the event loop on the warm
|
|
276
|
+
* cache-hit fast path.
|
|
277
|
+
* @param targetPath - Absolute path to probe.
|
|
278
|
+
* @returns `true` if the path is reachable via `fs.access`.
|
|
279
|
+
*/
|
|
280
|
+
async function fileExists(targetPath) {
|
|
281
|
+
try {
|
|
282
|
+
await fs.access(targetPath);
|
|
283
|
+
return true;
|
|
284
|
+
}
|
|
285
|
+
catch {
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Whether a cache directory contains a fully-validated extraction. The
|
|
291
|
+
* READY marker is the only trustworthy signal — sibling files alone
|
|
292
|
+
* cannot be trusted because a half-extracted dir can also contain a
|
|
293
|
+
* `db.sqlite`.
|
|
294
|
+
* @param cacheDir - Absolute path to a candidate cache directory.
|
|
295
|
+
* @returns `true` if the marker exists.
|
|
296
|
+
*/
|
|
297
|
+
async function isCacheDirReady(cacheDir) {
|
|
298
|
+
return await fileExists(path.join(cacheDir, READY_MARKER));
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Promise-based sleep used by the peer-wait poll. Kept inline so the
|
|
302
|
+
* file has no external sleep dependency and so the implementation can
|
|
303
|
+
* later swap for an AbortSignal-aware version without touching the
|
|
304
|
+
* call site.
|
|
305
|
+
* @param ms - Milliseconds to wait.
|
|
306
|
+
*/
|
|
307
|
+
function sleep(ms) {
|
|
308
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
309
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve the directory where extracted `.nitpicker` archives are cached.
|
|
3
|
+
*
|
|
4
|
+
* Resolution order:
|
|
5
|
+
*
|
|
6
|
+
* 1. `NITPICKER_TAR_CACHE_DIR` env — explicit override (CI, testing, or
|
|
7
|
+
* operators who want the cache on a specific volume). Must be a
|
|
8
|
+
* path; values that look like boolean / sentinel words (e.g. `0`,
|
|
9
|
+
* `false`) are ignored to keep the cache from landing somewhere
|
|
10
|
+
* surprising when the user mistakes the env contract.
|
|
11
|
+
* 2. `<os.tmpdir()>/nitpicker/cache/` — default. Lives under the OS
|
|
12
|
+
* temp directory so the platform's own cleanup (macOS reboot, Linux
|
|
13
|
+
* `systemd-tmpfiles`, Windows Disk Cleanup) reclaims stale entries
|
|
14
|
+
* without bespoke logic on our side.
|
|
15
|
+
*
|
|
16
|
+
* The returned path is absolute. The caller is responsible for creating
|
|
17
|
+
* it on demand (this function is pure).
|
|
18
|
+
* @returns Absolute path to the cache root directory.
|
|
19
|
+
*/
|
|
20
|
+
export declare function getArchiveCacheRoot(): string;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
/**
|
|
4
|
+
* Env values that look like a "disable" sentinel rather than a path.
|
|
5
|
+
*
|
|
6
|
+
* A user who copy-pastes the `NITPICKER_DISABLE_TAR_CACHE` convention
|
|
7
|
+
* onto `NITPICKER_TAR_CACHE_DIR` (e.g. `NITPICKER_TAR_CACHE_DIR=0`
|
|
8
|
+
* thinking it means "use default") would otherwise silently land the
|
|
9
|
+
* cache at `$PWD/0/` — multi-gigabyte extracts polluting the project
|
|
10
|
+
* tree, never reclaimed by OS temp cleanup. Reject these explicitly
|
|
11
|
+
* and fall back to the default location instead.
|
|
12
|
+
*/
|
|
13
|
+
const SENTINEL_LIKE_OVERRIDES = new Set([
|
|
14
|
+
'0',
|
|
15
|
+
'1',
|
|
16
|
+
'false',
|
|
17
|
+
'true',
|
|
18
|
+
'no',
|
|
19
|
+
'yes',
|
|
20
|
+
'off',
|
|
21
|
+
'on',
|
|
22
|
+
'null',
|
|
23
|
+
'undefined',
|
|
24
|
+
]);
|
|
25
|
+
/**
|
|
26
|
+
* Resolve the directory where extracted `.nitpicker` archives are cached.
|
|
27
|
+
*
|
|
28
|
+
* Resolution order:
|
|
29
|
+
*
|
|
30
|
+
* 1. `NITPICKER_TAR_CACHE_DIR` env — explicit override (CI, testing, or
|
|
31
|
+
* operators who want the cache on a specific volume). Must be a
|
|
32
|
+
* path; values that look like boolean / sentinel words (e.g. `0`,
|
|
33
|
+
* `false`) are ignored to keep the cache from landing somewhere
|
|
34
|
+
* surprising when the user mistakes the env contract.
|
|
35
|
+
* 2. `<os.tmpdir()>/nitpicker/cache/` — default. Lives under the OS
|
|
36
|
+
* temp directory so the platform's own cleanup (macOS reboot, Linux
|
|
37
|
+
* `systemd-tmpfiles`, Windows Disk Cleanup) reclaims stale entries
|
|
38
|
+
* without bespoke logic on our side.
|
|
39
|
+
*
|
|
40
|
+
* The returned path is absolute. The caller is responsible for creating
|
|
41
|
+
* it on demand (this function is pure).
|
|
42
|
+
* @returns Absolute path to the cache root directory.
|
|
43
|
+
*/
|
|
44
|
+
export function getArchiveCacheRoot() {
|
|
45
|
+
const envOverride = process.env.NITPICKER_TAR_CACHE_DIR;
|
|
46
|
+
if (envOverride && envOverride.trim().length > 0) {
|
|
47
|
+
const trimmed = envOverride.trim();
|
|
48
|
+
if (!SENTINEL_LIKE_OVERRIDES.has(trimmed.toLowerCase())) {
|
|
49
|
+
return path.resolve(trimmed);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return path.resolve(os.tmpdir(), 'nitpicker', 'cache');
|
|
53
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether the tar cache is disabled by the `NITPICKER_DISABLE_TAR_CACHE`
|
|
3
|
+
* env var.
|
|
4
|
+
*
|
|
5
|
+
* Useful for two scenarios:
|
|
6
|
+
*
|
|
7
|
+
* 1. Debugging — bypass the cache to reproduce the cold-start behaviour
|
|
8
|
+
* against a fresh tmpDir.
|
|
9
|
+
* 2. Sandboxed CI — when the runner's tmpfs would not survive between
|
|
10
|
+
* steps anyway, the cache only adds first-step overhead.
|
|
11
|
+
*
|
|
12
|
+
* Accepted truthy values: `1`, `true`, `yes`, `on` (case-insensitive).
|
|
13
|
+
* Anything else (including unset) keeps the cache enabled.
|
|
14
|
+
* @returns `true` when the cache should be bypassed.
|
|
15
|
+
* @example
|
|
16
|
+
* ```ts
|
|
17
|
+
* if (isArchiveCacheDisabled()) {
|
|
18
|
+
* // Fall back to the writer path; cwd tmpDir + close-time cleanup.
|
|
19
|
+
* const archive = await Archive.open({ filePath });
|
|
20
|
+
* ...
|
|
21
|
+
* }
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
export declare function isArchiveCacheDisabled(): boolean;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether the tar cache is disabled by the `NITPICKER_DISABLE_TAR_CACHE`
|
|
3
|
+
* env var.
|
|
4
|
+
*
|
|
5
|
+
* Useful for two scenarios:
|
|
6
|
+
*
|
|
7
|
+
* 1. Debugging — bypass the cache to reproduce the cold-start behaviour
|
|
8
|
+
* against a fresh tmpDir.
|
|
9
|
+
* 2. Sandboxed CI — when the runner's tmpfs would not survive between
|
|
10
|
+
* steps anyway, the cache only adds first-step overhead.
|
|
11
|
+
*
|
|
12
|
+
* Accepted truthy values: `1`, `true`, `yes`, `on` (case-insensitive).
|
|
13
|
+
* Anything else (including unset) keeps the cache enabled.
|
|
14
|
+
* @returns `true` when the cache should be bypassed.
|
|
15
|
+
* @example
|
|
16
|
+
* ```ts
|
|
17
|
+
* if (isArchiveCacheDisabled()) {
|
|
18
|
+
* // Fall back to the writer path; cwd tmpDir + close-time cleanup.
|
|
19
|
+
* const archive = await Archive.open({ filePath });
|
|
20
|
+
* ...
|
|
21
|
+
* }
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
export function isArchiveCacheDisabled() {
|
|
25
|
+
const raw = process.env.NITPICKER_DISABLE_TAR_CACHE;
|
|
26
|
+
if (!raw) {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
const normalized = raw.trim().toLowerCase();
|
|
30
|
+
return (normalized === '1' ||
|
|
31
|
+
normalized === 'true' ||
|
|
32
|
+
normalized === 'yes' ||
|
|
33
|
+
normalized === 'on');
|
|
34
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build the absolute path to a single archive's cache directory.
|
|
3
|
+
*
|
|
4
|
+
* The directory name is `<cacheKey>-<safeBasename>`:
|
|
5
|
+
*
|
|
6
|
+
* - `cacheKey` carries the freshness signal (size/mtime/ctime) and is
|
|
7
|
+
* what actually drives cache hit vs miss.
|
|
8
|
+
* - `safeBasename` is appended purely so a human running `ls` on the
|
|
9
|
+
* cache root can recognise which archive an entry belongs to. It is
|
|
10
|
+
* NOT used to disambiguate keys — two archives with identical inode
|
|
11
|
+
* metadata (e.g. an identical copy under a different name) intentionally
|
|
12
|
+
* share an entry under the first basename that landed there.
|
|
13
|
+
*
|
|
14
|
+
* The basename is sanitised: anything outside `[A-Za-z0-9._-]` becomes
|
|
15
|
+
* `_`. This keeps the path portable across filesystems (no spaces,
|
|
16
|
+
* unicode normalisation surprises, Windows-reserved chars) and removes
|
|
17
|
+
* any chance that a crafted archive name could escape the cache root
|
|
18
|
+
* (e.g. via `..` or path separators), independent of the upstream
|
|
19
|
+
* `path.basename` call that already drops directory components.
|
|
20
|
+
* @param cacheRoot - Absolute path returned by `getArchiveCacheRoot()`.
|
|
21
|
+
* @param cacheKey - The freshness key from `computeArchiveCacheKey()`.
|
|
22
|
+
* @param archivePath - Absolute path to the source `.nitpicker` file;
|
|
23
|
+
* only its basename contributes to the cache entry name.
|
|
24
|
+
* @returns Absolute path to the per-archive cache directory.
|
|
25
|
+
*/
|
|
26
|
+
export declare function resolveArchiveCacheDir(cacheRoot: string, cacheKey: string, archivePath: string): string;
|