From 8fe551ede49b70b203781fd185caa03bd62a4da1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 16:46:06 +0000 Subject: [PATCH] feat(core): cache SDK outputs with their .git, long paths and a raised size limit A target that installs a git-checkout SDK such as Flutter's into .zuke/tools could not be cached. The archive dropped the SDK's own .git, the tar writer refused any name or link target past ustar's 100 bytes, and the 2 GiB bound on what a restore inflates to was fixed. A .git inside .zuke/tools now archives and restores, while the workspace's own .git and any nested one elsewhere stay refused, as does one spelled with a trailing dot or space, which Windows would resolve to the real directory. The tar writer emits a pax extended header for a long name or link target, which untar and modern tar already read. Every built-in store takes maxInflatedBytes, an env-selected store reads ZUKE_REMOTE_CACHE_MAX_INFLATED_BYTES once a store is chosen, the executor passes the store's bound to the restore, and a bound that is not a positive whole number is refused rather than disabling the check. Closes #890 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015ni2F1uPKAVqstioh8EpG8 --- docs/caching.md | 22 +++- llms-full.txt | 53 +++++++-- packages/core/README.md | 53 +++++++-- packages/core/mod.ts | 1 + packages/core/src/cache.ts | 7 +- packages/core/src/compression.ts | 129 ++++++++++++++-------- packages/core/src/github_actions_cache.ts | 8 ++ packages/core/src/remote_cache.ts | 103 +++++++++++++++-- packages/core/src/s3_cache.ts | 8 ++ packages/core/tests/cache_test.ts | 27 +++++ packages/core/tests/compression_test.ts | 33 +++--- packages/core/tests/remote_cache_test.ts | 93 ++++++++++++++++ tests/integration/caching_test.ts | 64 +++++++++++ 13 files changed, 505 insertions(+), 96 deletions(-) diff --git a/docs/caching.md b/docs/caching.md index b6d5e61a..8b2c671e 100644 --- a/docs/caching.md +++ b/docs/caching.md @@ -203,6 +203,9 @@ ZUKE_REMOTE_CACHE_URL=https://cache.example.com ZUKE_REMOTE_CACHE_TOKEN=… ./zu ZUKE_REMOTE_CACHE_DIR=/mnt/zuke-cache ./zuke ci ``` +`ZUKE_REMOTE_CACHE_MAX_INFLATED_BYTES` raises the 2 GiB bound on what a fetched +artifact may decompress to, for whichever store the variables select. + That is also the precedence: a plain URL, then an S3 URL, then a directory, then the Actions credentials. **`--no-remote-cache`** uses the local cache only for a run (it skips both restore and upload); **`--no-cache`** still disables _both_ @@ -326,10 +329,13 @@ target: the archive); when it lands under **`.git` or `.zuke`** — a restored git hook runs on the next ordinary git command — with `.zuke/tools` the one exception, since a provisioned toolchain is what a remote cache is for and the toolchain - re-verifies a restored binary against its recorded checksum before reusing it; - and when it falls **outside the paths the target declared with - `.outputs(...)`**. That last one is what stops an innocuous-looking - `deno.json` or lockfile riding along in an otherwise valid artifact. + re-verifies a restored binary against its recorded checksum before reusing it. + A `.git` _inside_ `.zuke/tools` is allowed too, because an SDK such as + Flutter's is a git checkout that runs `git` on itself; a store that could + write there could already replace the SDK's binaries; and when it falls + **outside the paths the target declared with `.outputs(...)`**. That last one + is what stops an innocuous-looking `deno.json` or lockfile riding along in an + otherwise valid artifact. - **A restore never writes through a symbolic link.** The checks above are lexical — they reason about the entry's _name_. An archive cannot carry a link, but a workspace can already hold one at a declared output, and writing a @@ -351,8 +357,12 @@ target: outputs compress, so a legitimate archive inflates several times over and a shared cap would refuse real artifacts. The wire bound defaults to 512 MiB and is `maxArtifactBytes` on `HttpCacheStore`; the inflated bound defaults to 2 - GiB. Because a tar entry costs at least its 512-byte header, bounding bytes - bounds entry count too. + GiB and is `maxInflatedBytes` on every built-in store, or + `ZUKE_REMOTE_CACHE_MAX_INFLATED_BYTES` (a byte count) for an env-selected one. + Raise it to cache a larger output such as a whole SDK; the restore holds the + inflated archive in memory, so it is also the memory ceiling. Because a tar + entry costs at least its 512-byte header, bounding bytes bounds entry count + too. - **A refused archive is a cache miss, not a build failure.** The target rebuilds and the refusal is reported as a warning — so whoever can write the store cannot halt every build that reads it. That covers every refusal above, diff --git a/llms-full.txt b/llms-full.txt index 3f4cd3c4..9aed5e7f 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -90,7 +90,7 @@ async function archiveOutputs(outputs: readonly string[], host: OutputHost): Pro Archive a target's `outputs` into a gzipped tar of their current contents, each file with its permission bits so a binary restores executable. A declared output that does not exist is skipped, as is anything under a - `.git` or `.zuke` directory other than `.zuke/tools`. + `.git` or `.zuke` directory, other than `.zuke/tools` and whatever it holds. function assert(condition: unknown, message: string): asserts condition Assert that `condition` is truthy, narrowing it for the rest of the scope. @@ -331,6 +331,8 @@ function envCacheStore(readEnv: (name: string) => string | undefined): RemoteCac on a GitHub Actions job that exposes its `ACTIONS_RUNTIME_TOKEN` and `ACTIONS_RESULTS_URL` (the `actions-cache` input of the `zuke-build/zuke` action), a {@link "./github_actions_cache.ts".GitHubActionsCacheStore}. + Whichever is chosen takes `ZUKE_REMOTE_CACHE_MAX_INFLATED_BYTES`, when set, + as its {@link RemoteCacheStore.maxInflatedBytes}. function envStateStore(readEnv: (name: string) => string | undefined, host: StateHost): StateStore | undefined Resolve a {@link StateStore} from the environment, or `undefined` when none is @@ -857,7 +859,8 @@ async function restoreOutputs(artifact: Uint8Array, host: OutputHost, outputs?: @param maxBytes The most the artifact may decompress to before it is refused, - defaulting to 2 GiB. The bound is applied while decompressing, so a small + defaulting to 2 GiB; the executor passes the store's + {@link RemoteCacheStore.maxInflatedBytes}. The bound is applied while decompressing, so a small archive that expands without limit is refused rather than buffered first. It is larger than the bound a store puts on the compressed bytes because outputs compress; both exist to stop memory exhaustion. @@ -984,7 +987,10 @@ async function syncCiFiles(files: readonly CiFile[], options: CiSyncOptions): Pr instead (so CI can fail when the committed config has drifted). function tar(entries: TarEntry[]): Uint8Array - Create a `ustar` archive from the given entries (in order). + Create a `ustar` archive from the given entries (in order). A name or + symlink target past ustar's 100 bytes is carried by a pax extended header + (typeflag `'x'`) ahead of its member, which {@link untar} and every modern + `tar` read back. function target(): TargetBuilder Create a new, empty target builder. @@ -1698,12 +1704,17 @@ class FileSystemBuildRegistry implements BuildRegistry class FileSystemCacheStore implements RemoteCacheStore A {@link RemoteCacheStore} backed by a shared or mounted directory. - constructor(dir: string) + constructor(dir: string, options: FileSystemCacheStoreOptions) Build the store over a directory. @param dir The directory archives are read from and written to. + @param options + The restore bound. + + readonly maxInflatedBytes?: number + The most an artifact may decompress to; see {@link RemoteCacheStore.maxInflatedBytes}. get(key: string): Promise Fetch the archived outputs stored under `key`, or `null` if there are none. async put(key: string, artifact: Uint8Array): Promise @@ -1805,6 +1816,8 @@ class GitHubActionsCacheStore implements RemoteCacheStore constructor(options: GitHubActionsCacheStoreOptions) Build the store from the service URL, the runtime token, and the `fetch` seam. + readonly maxInflatedBytes?: number + The most an artifact may decompress to; see {@link RemoteCacheStore.maxInflatedBytes}. async get(key: string): Promise Fetch the archived outputs stored under `key`, or `null` if there are none. async put(key: string, artifact: Uint8Array): Promise @@ -1867,7 +1880,9 @@ class HttpCacheStore implements RemoteCacheStore decompresses to. constructor(options: HttpCacheStoreOptions) - Build the store from its URL, optional token, size cap, and `fetch` seam. + Build the store from its URL, optional token, size caps, and `fetch` seam. + readonly maxInflatedBytes?: number + The most an artifact may decompress to; see {@link RemoteCacheStore.maxInflatedBytes}. async get(key: string): Promise Fetch the archived outputs stored under `key`, or `null` if there are none. async put(key: string, artifact: Uint8Array): Promise @@ -2214,6 +2229,8 @@ class S3CacheStore implements RemoteCacheStore constructor(options: S3CacheStoreOptions) Build the store from the bucket URL, region, credentials and seams. + readonly maxInflatedBytes?: number + The most an artifact may decompress to; see {@link RemoteCacheStore.maxInflatedBytes}. get(key: string): Promise Fetch the archived outputs stored under `key`, or `null` if there are none. put(key: string, artifact: Uint8Array): Promise @@ -3930,6 +3947,13 @@ interface FanOutOptions env?: Record Environment variables set on every job (e.g. the remote-cache config). +interface FileSystemCacheStoreOptions + Configuration for a {@link FileSystemCacheStore}. + + maxInflatedBytes?: number + The most a fetched artifact may decompress to, in bytes — see + {@link RemoteCacheStore.maxInflatedBytes}. Defaults to 2 GiB. + interface FileTasksApi The shape of {@link FileTasks}. @@ -4049,6 +4073,9 @@ interface GitHubActionsCacheStoreOptions this store is written only by the repository's own workflows and the outputs worth caching here are the big ones (a 1.5 GB SDK). What the bytes decompress to is bounded separately, by `restoreOutputs`. + maxInflatedBytes?: number + The most a fetched artifact may decompress to, in bytes — see + {@link RemoteCacheStore.maxInflatedBytes}. Defaults to 2 GiB. interface GlobOptions Options for {@link glob}. @@ -4116,6 +4143,9 @@ interface HttpCacheStoreOptions outputs are genuinely larger; a refusal is a warned rebuild, not a build failure. What the bytes decompress to is bounded separately, by {@link restoreOutputs}. + maxInflatedBytes?: number + The most a fetched artifact may decompress to, in bytes — see + {@link RemoteCacheStore.maxInflatedBytes}. Defaults to 2 GiB. interface HttpOptions Options shared by the HTTP helpers. @@ -4543,6 +4573,11 @@ interface RemoteCacheStore point of view: the executor never fails a build because the store is unreachable — it just rebuilds and, where it can, re-uploads. + readonly maxInflatedBytes?: number + The most an artifact from this store may decompress to, in bytes, before + {@link restoreOutputs} refuses it. Defaults to 2 GiB; raise it for a target + whose outputs are genuinely larger, such as a whole SDK. The restore holds + the inflated archive in memory, so the bound is also its memory ceiling. get(key: string): Promise Fetch the archived outputs stored under `key`, or `null` if there are none. put(key: string, artifact: Uint8Array): Promise @@ -4981,6 +5016,9 @@ interface S3CacheStoreOptions The most a fetched artifact may weigh on the wire, in bytes, before it is refused. Defaults to 512 MiB — see {@link "./remote_cache.ts".HttpCacheStoreOptions.maxArtifactBytes}. + maxInflatedBytes?: number + The most a fetched artifact may decompress to, in bytes — see + {@link RemoteCacheStore.maxInflatedBytes}. Defaults to 2 GiB. interface ScheduleEntry A scheduled trigger: a 5-field cron expression in an optional IANA timezone. @@ -5109,12 +5147,11 @@ interface TarEntry A single entry within a tar archive — a regular file or a symbolic link. name: string - The entry's path inside the archive (≤ 100 bytes). + The entry's path inside the archive. data: Uint8Array The file contents (empty for a symlink entry). linkname?: string - For a symbolic-link entry, its target (≤ 100 bytes); absent for a regular - file. Node's release tarballs, for one, ship `bin/npm`/`bin/npx` as symlinks + For a symbolic-link entry, its target; absent for a regular file. Node's release tarballs, for one, ship `bin/npm`/`bin/npx` as symlinks into `lib/node_modules`, so extracting a runtime tree must preserve them. mode?: number A regular file's permission bits, written into its header: `0o755` for an diff --git a/packages/core/README.md b/packages/core/README.md index 0d0f077f..38d9ab2b 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -144,7 +144,7 @@ async function archiveOutputs(outputs: readonly string[], host: OutputHost): Pro Archive a target's `outputs` into a gzipped tar of their current contents, each file with its permission bits so a binary restores executable. A declared output that does not exist is skipped, as is anything under a - `.git` or `.zuke` directory other than `.zuke/tools`. + `.git` or `.zuke` directory, other than `.zuke/tools` and whatever it holds. function assert(condition: unknown, message: string): asserts condition Assert that `condition` is truthy, narrowing it for the rest of the scope. @@ -385,6 +385,8 @@ function envCacheStore(readEnv: (name: string) => string | undefined): RemoteCac on a GitHub Actions job that exposes its `ACTIONS_RUNTIME_TOKEN` and `ACTIONS_RESULTS_URL` (the `actions-cache` input of the `zuke-build/zuke` action), a {@link "./github_actions_cache.ts".GitHubActionsCacheStore}. + Whichever is chosen takes `ZUKE_REMOTE_CACHE_MAX_INFLATED_BYTES`, when set, + as its {@link RemoteCacheStore.maxInflatedBytes}. function envStateStore(readEnv: (name: string) => string | undefined, host: StateHost): StateStore | undefined Resolve a {@link StateStore} from the environment, or `undefined` when none is @@ -911,7 +913,8 @@ async function restoreOutputs(artifact: Uint8Array, host: OutputHost, outputs?: @param maxBytes The most the artifact may decompress to before it is refused, - defaulting to 2 GiB. The bound is applied while decompressing, so a small + defaulting to 2 GiB; the executor passes the store's + {@link RemoteCacheStore.maxInflatedBytes}. The bound is applied while decompressing, so a small archive that expands without limit is refused rather than buffered first. It is larger than the bound a store puts on the compressed bytes because outputs compress; both exist to stop memory exhaustion. @@ -1038,7 +1041,10 @@ async function syncCiFiles(files: readonly CiFile[], options: CiSyncOptions): Pr instead (so CI can fail when the committed config has drifted). function tar(entries: TarEntry[]): Uint8Array - Create a `ustar` archive from the given entries (in order). + Create a `ustar` archive from the given entries (in order). A name or + symlink target past ustar's 100 bytes is carried by a pax extended header + (typeflag `'x'`) ahead of its member, which {@link untar} and every modern + `tar` read back. function target(): TargetBuilder Create a new, empty target builder. @@ -1752,12 +1758,17 @@ class FileSystemBuildRegistry implements BuildRegistry class FileSystemCacheStore implements RemoteCacheStore A {@link RemoteCacheStore} backed by a shared or mounted directory. - constructor(dir: string) + constructor(dir: string, options: FileSystemCacheStoreOptions) Build the store over a directory. @param dir The directory archives are read from and written to. + @param options + The restore bound. + + readonly maxInflatedBytes?: number + The most an artifact may decompress to; see {@link RemoteCacheStore.maxInflatedBytes}. get(key: string): Promise Fetch the archived outputs stored under `key`, or `null` if there are none. async put(key: string, artifact: Uint8Array): Promise @@ -1859,6 +1870,8 @@ class GitHubActionsCacheStore implements RemoteCacheStore constructor(options: GitHubActionsCacheStoreOptions) Build the store from the service URL, the runtime token, and the `fetch` seam. + readonly maxInflatedBytes?: number + The most an artifact may decompress to; see {@link RemoteCacheStore.maxInflatedBytes}. async get(key: string): Promise Fetch the archived outputs stored under `key`, or `null` if there are none. async put(key: string, artifact: Uint8Array): Promise @@ -1921,7 +1934,9 @@ class HttpCacheStore implements RemoteCacheStore decompresses to. constructor(options: HttpCacheStoreOptions) - Build the store from its URL, optional token, size cap, and `fetch` seam. + Build the store from its URL, optional token, size caps, and `fetch` seam. + readonly maxInflatedBytes?: number + The most an artifact may decompress to; see {@link RemoteCacheStore.maxInflatedBytes}. async get(key: string): Promise Fetch the archived outputs stored under `key`, or `null` if there are none. async put(key: string, artifact: Uint8Array): Promise @@ -2268,6 +2283,8 @@ class S3CacheStore implements RemoteCacheStore constructor(options: S3CacheStoreOptions) Build the store from the bucket URL, region, credentials and seams. + readonly maxInflatedBytes?: number + The most an artifact may decompress to; see {@link RemoteCacheStore.maxInflatedBytes}. get(key: string): Promise Fetch the archived outputs stored under `key`, or `null` if there are none. put(key: string, artifact: Uint8Array): Promise @@ -3984,6 +4001,13 @@ interface FanOutOptions env?: Record Environment variables set on every job (e.g. the remote-cache config). +interface FileSystemCacheStoreOptions + Configuration for a {@link FileSystemCacheStore}. + + maxInflatedBytes?: number + The most a fetched artifact may decompress to, in bytes — see + {@link RemoteCacheStore.maxInflatedBytes}. Defaults to 2 GiB. + interface FileTasksApi The shape of {@link FileTasks}. @@ -4103,6 +4127,9 @@ interface GitHubActionsCacheStoreOptions this store is written only by the repository's own workflows and the outputs worth caching here are the big ones (a 1.5 GB SDK). What the bytes decompress to is bounded separately, by `restoreOutputs`. + maxInflatedBytes?: number + The most a fetched artifact may decompress to, in bytes — see + {@link RemoteCacheStore.maxInflatedBytes}. Defaults to 2 GiB. interface GlobOptions Options for {@link glob}. @@ -4170,6 +4197,9 @@ interface HttpCacheStoreOptions outputs are genuinely larger; a refusal is a warned rebuild, not a build failure. What the bytes decompress to is bounded separately, by {@link restoreOutputs}. + maxInflatedBytes?: number + The most a fetched artifact may decompress to, in bytes — see + {@link RemoteCacheStore.maxInflatedBytes}. Defaults to 2 GiB. interface HttpOptions Options shared by the HTTP helpers. @@ -4597,6 +4627,11 @@ interface RemoteCacheStore point of view: the executor never fails a build because the store is unreachable — it just rebuilds and, where it can, re-uploads. + readonly maxInflatedBytes?: number + The most an artifact from this store may decompress to, in bytes, before + {@link restoreOutputs} refuses it. Defaults to 2 GiB; raise it for a target + whose outputs are genuinely larger, such as a whole SDK. The restore holds + the inflated archive in memory, so the bound is also its memory ceiling. get(key: string): Promise Fetch the archived outputs stored under `key`, or `null` if there are none. put(key: string, artifact: Uint8Array): Promise @@ -5035,6 +5070,9 @@ interface S3CacheStoreOptions The most a fetched artifact may weigh on the wire, in bytes, before it is refused. Defaults to 512 MiB — see {@link "./remote_cache.ts".HttpCacheStoreOptions.maxArtifactBytes}. + maxInflatedBytes?: number + The most a fetched artifact may decompress to, in bytes — see + {@link RemoteCacheStore.maxInflatedBytes}. Defaults to 2 GiB. interface ScheduleEntry A scheduled trigger: a 5-field cron expression in an optional IANA timezone. @@ -5163,12 +5201,11 @@ interface TarEntry A single entry within a tar archive — a regular file or a symbolic link. name: string - The entry's path inside the archive (≤ 100 bytes). + The entry's path inside the archive. data: Uint8Array The file contents (empty for a symlink entry). linkname?: string - For a symbolic-link entry, its target (≤ 100 bytes); absent for a regular - file. Node's release tarballs, for one, ship `bin/npm`/`bin/npx` as symlinks + For a symbolic-link entry, its target; absent for a regular file. Node's release tarballs, for one, ship `bin/npm`/`bin/npx` as symlinks into `lib/node_modules`, so extracting a runtime tree must preserve them. mode?: number A regular file's permission bits, written into its header: `0o755` for an diff --git a/packages/core/mod.ts b/packages/core/mod.ts index 249ebe6d..90d16e21 100644 --- a/packages/core/mod.ts +++ b/packages/core/mod.ts @@ -166,6 +166,7 @@ export { archiveOutputs, envCacheStore, FileSystemCacheStore, + type FileSystemCacheStoreOptions, HttpCacheStore, type HttpCacheStoreOptions, type OutputHost, diff --git a/packages/core/src/cache.ts b/packages/core/src/cache.ts index bd964ff4..16a920ca 100644 --- a/packages/core/src/cache.ts +++ b/packages/core/src/cache.ts @@ -233,7 +233,12 @@ class FsCache implements BuildCache { // Throwing instead would let anyone who can write the store halt every // build that reads it, and the store is documented as best-effort. try { - await restoreOutputs(artifact, this.#host, target.outputs_); + await restoreOutputs( + artifact, + this.#host, + target.outputs_, + this.#remote.maxInflatedBytes, + ); } catch (error) { this.#warn?.( `remote cache restore for "${name}" was refused, rebuilding: ${ diff --git a/packages/core/src/compression.ts b/packages/core/src/compression.ts index 5899ad23..9b16e833 100644 --- a/packages/core/src/compression.ts +++ b/packages/core/src/compression.ts @@ -17,8 +17,8 @@ * await extractZip("tool.zip", "out"); * ``` * - * `tar` entry names are limited to 100 bytes (the `ustar` name field); longer - * names throw. Archives are written with a fixed mtime so output is + * `tar` writes a name or symlink target past the `ustar` field's 100 bytes as + * a pax extended header. Archives are written with a fixed mtime so output is * reproducible, and a zip is written with deflated entries. Zip reading supports the two methods release assets use — * `stored` and `deflate` — and rejects encrypted or zip64 archives with a * friendly error; every extractor rejects an entry name that would escape the @@ -122,13 +122,12 @@ export async function gunzipBounded( /** A single entry within a tar archive — a regular file or a symbolic link. */ export interface TarEntry { - /** The entry's path inside the archive (≤ 100 bytes). */ + /** The entry's path inside the archive. */ name: string; /** The file contents (empty for a symlink entry). */ data: Uint8Array; /** - * For a symbolic-link entry, its target (≤ 100 bytes); absent for a regular - * file. Node's release tarballs, for one, ship `bin/npm`/`bin/npx` as symlinks + * For a symbolic-link entry, its target; absent for a regular file. Node's release tarballs, for one, ship `bin/npm`/`bin/npx` as symlinks * into `lib/node_modules`, so extracting a runtime tree must preserve them. */ linkname?: string; @@ -166,41 +165,27 @@ function writeOctal( writeString(block, offset, text); } -/** Build the 512-byte `ustar` header for one entry (a file, or a symlink when - * `linkname` is given). */ +/** + * Build the 512-byte `ustar` header for one member: `name` and `linkname` are + * already cut to the field's 100 bytes (a longer value rides in a pax record + * before it). + */ function tarHeader( - name: string, + name: Uint8Array, size: number, - linkname: string | undefined, + typeflag: number, mode: number, + linkname: Uint8Array, ): Uint8Array { - const nameBytes = new TextEncoder().encode(name); - if (nameBytes.length > 100) { - throw new Error( - `tar: entry name exceeds 100 bytes (ustar limit): "${name}"`, - ); - } - const isLink = linkname !== undefined; - if (isLink && new TextEncoder().encode(linkname).length > 100) { - throw new Error( - `tar: symlink target exceeds 100 bytes (ustar limit): "${linkname}"`, - ); - } - if (!Number.isInteger(mode) || mode < 0 || mode > 0o7777) { - throw new Error( - `tar: mode for "${name}" must be permission bits from 0 to 0o7777, ` + - `got ${mode}`, - ); - } const header = new Uint8Array(BLOCK); - writeString(header, 0, name); - writeOctal(header, 100, 7, isLink ? 0o777 : mode); // mode + header.set(name, 0); + writeOctal(header, 100, 7, mode); // mode writeOctal(header, 108, 7, 0); // uid writeOctal(header, 116, 7, 0); // gid - writeOctal(header, 124, 11, isLink ? 0 : size); // size (a symlink carries none) + writeOctal(header, 124, 11, size); // size writeOctal(header, 136, 11, 0); // mtime (fixed for reproducibility) - header[156] = isLink ? 0x32 : 0x30; // typeflag '2' (symlink) | '0' (file) - if (isLink) writeString(header, 157, linkname); // linkname field + header[156] = typeflag; + header.set(linkname, 157); // linkname field writeString(header, 257, "ustar\0"); // magic header[263] = 0x30; // version "00" header[264] = 0x30; @@ -214,25 +199,77 @@ function tarHeader( return header; } -/** Create a `ustar` archive from the given entries (in order). */ +/** The ustar name and linkname field width. */ +const USTAR_FIELD = 100; + +/** + * One pax extended-header record, `" =\n"`, where `` + * counts the whole record in bytes, its own digits included. + */ +function paxRecord(key: string, value: string): string { + const rest = new TextEncoder().encode(` ${key}=${value}\n`).length; + let len = rest + 1; + while (len !== rest + String(len).length) len = rest + String(len).length; + return `${len} ${key}=${value}\n`; +} + +/** + * Create a `ustar` archive from the given entries (in order). A name or + * symlink target past ustar's 100 bytes is carried by a pax extended header + * (typeflag `'x'`) ahead of its member, which {@link untar} and every modern + * `tar` read back. + */ export function tar(entries: TarEntry[]): Uint8Array { + const encoder = new TextEncoder(); const blocks: Uint8Array[] = []; let total = 0; + const member = (header: Uint8Array, data: Uint8Array) => { + const body = new Uint8Array(padded(data.length)); + body.set(data); + blocks.push(header, body); + total += BLOCK + body.length; + }; for (const entry of entries) { - const header = tarHeader( - entry.name, - entry.data.length, - entry.linkname, - entry.mode ?? 0o644, + const mode = entry.mode ?? 0o644; + if (!Number.isInteger(mode) || mode < 0 || mode > 0o7777) { + throw new Error( + `tar: mode for "${entry.name}" must be permission bits from 0 to ` + + `0o7777, got ${mode}`, + ); + } + const name = encoder.encode(entry.name); + const link = encoder.encode(entry.linkname ?? ""); + let records = ""; + if (name.length > USTAR_FIELD) records += paxRecord("path", entry.name); + if (link.length > USTAR_FIELD) { + records += paxRecord("linkpath", entry.linkname ?? ""); + } + if (records !== "") { + const data = encoder.encode(records); + member( + tarHeader( + encoder.encode("PaxHeader"), + data.length, + 0x78, // typeflag 'x' (pax extended header) + 0o644, + new Uint8Array(0), + ), + data, + ); + } + const isLink = entry.linkname !== undefined; + // A symlink carries no data and is always 0o777. + const data = isLink ? new Uint8Array(0) : entry.data; + member( + tarHeader( + name.subarray(0, USTAR_FIELD), + data.length, + isLink ? 0x32 : 0x30, // typeflag '2' (symlink) | '0' (file) + isLink ? 0o777 : mode, + link.subarray(0, USTAR_FIELD), + ), + data, ); - blocks.push(header); - total += BLOCK; - // A symlink entry carries no data blocks; a file's data is block-padded. - const size = entry.linkname === undefined ? entry.data.length : 0; - const body = new Uint8Array(padded(size)); - if (entry.linkname === undefined) body.set(entry.data); - blocks.push(body); - total += body.length; } // Two zero blocks terminate the archive. const trailer = new Uint8Array(BLOCK * 2); diff --git a/packages/core/src/github_actions_cache.ts b/packages/core/src/github_actions_cache.ts index e078fb02..d1f77067 100644 --- a/packages/core/src/github_actions_cache.ts +++ b/packages/core/src/github_actions_cache.ts @@ -42,6 +42,11 @@ export interface GitHubActionsCacheStoreOptions { * *decompress* to is bounded separately, by `restoreOutputs`. */ maxArtifactBytes?: number; + /** + * The most a fetched artifact may decompress to, in bytes — see + * {@link RemoteCacheStore.maxInflatedBytes}. Defaults to 2 GiB. + */ + maxInflatedBytes?: number; } /** The Twirp route every `CacheService` call is `POST`ed to, under the base URL. */ @@ -81,9 +86,12 @@ export class GitHubActionsCacheStore implements RemoteCacheStore { readonly #token: string; readonly #fetch: typeof fetch; readonly #maxArtifactBytes: number; + /** The most an artifact may decompress to; see {@link RemoteCacheStore.maxInflatedBytes}. */ + readonly maxInflatedBytes?: number; /** Build the store from the service URL, the runtime token, and the `fetch` seam. */ constructor(options: GitHubActionsCacheStoreOptions) { + this.maxInflatedBytes = options.maxInflatedBytes; this.#base = options.url.replace(/\/+$/, ""); this.#token = options.token; this.#fetch = options.fetch ?? fetch; diff --git a/packages/core/src/remote_cache.ts b/packages/core/src/remote_cache.ts index 374e7e98..134404a4 100644 --- a/packages/core/src/remote_cache.ts +++ b/packages/core/src/remote_cache.ts @@ -52,6 +52,13 @@ export interface RemoteCacheStore { get(key: string): Promise; /** Store `artifact` (a gzipped tar of a target's outputs) under `key`. */ put(key: string, artifact: Uint8Array): Promise; + /** + * The most an artifact from this store may decompress to, in bytes, before + * {@link restoreOutputs} refuses it. Defaults to 2 GiB; raise it for a target + * whose outputs are genuinely larger, such as a whole SDK. The restore holds + * the inflated archive in memory, so the bound is also its memory ceiling. + */ + readonly maxInflatedBytes?: number; } /** @@ -149,7 +156,7 @@ async function collectEntries( * Archive a target's `outputs` into a gzipped tar of their current contents, * each file with its permission bits so a binary restores executable. A * declared output that does not exist is skipped, as is anything under a - * `.git` or `.zuke` directory other than `.zuke/tools`. + * `.git` or `.zuke` directory, other than `.zuke/tools` and whatever it holds. */ export async function archiveOutputs( outputs: readonly string[], @@ -168,6 +175,12 @@ export async function archiveOutputs( * exactly the outputs a remote cache exists to share (a pinned SDK, a * checksum-verified scanner), and the toolchain re-verifies a restored binary * against its recorded checksum before reusing it. + * + * A `.git` *inside* that subtree is allowed too: an SDK such as Flutter's is a + * git checkout that runs `git` on itself, so it is broken without one. That + * grants a store nothing new — it can already replace the SDK's own binaries + * and scripts there — and the workspace's own `.git`, or any outside + * `.zuke/tools`, stays refused. */ const FORBIDDEN_DIRS = [".git", ".zuke"]; @@ -181,10 +194,17 @@ const TOOLS_DIR = "tools"; * runs on the same ordinary git command the top-level one would. */ function isForbiddenPath(name: string): boolean { - const segments = name.toLowerCase().split("/"); + // Windows drops a segment's trailing dots and spaces, so `.git.` is `.git`. + const segments = name.toLowerCase().split("/").map((segment) => + segment.replace(/[. ]+$/, "") + ); + const tools = segments.findIndex((segment, i) => + segment === ".zuke" && segments[i + 1] === TOOLS_DIR + ); return segments.some((segment, i) => FORBIDDEN_DIRS.includes(segment) && - !(segment === ".zuke" && segments[i + 1] === TOOLS_DIR) + !(segment === ".zuke" && segments[i + 1] === TOOLS_DIR) && + !(segment === ".git" && tools !== -1 && tools < i) ); } @@ -243,7 +263,8 @@ function assertPlainFileEntry(entry: TarEntry): void { * surprise. * * @param maxBytes The most the artifact may decompress to before it is refused, - * defaulting to 2 GiB. The bound is applied *while* decompressing, so a small + * defaulting to 2 GiB; the executor passes the store's + * {@link RemoteCacheStore.maxInflatedBytes}. The bound is applied *while* decompressing, so a small * archive that expands without limit is refused rather than buffered first. * It is larger than the bound a store puts on the compressed bytes because * outputs compress; both exist to stop memory exhaustion. @@ -262,6 +283,13 @@ export async function restoreOutputs( outputs?: readonly string[], maxBytes: number = DEFAULT_MAX_INFLATED_BYTES, ): Promise { + // A NaN bound would disable the check (`total > NaN` is never true). + if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) { + throw new Error( + `remote cache: maxInflatedBytes must be a positive whole number of ` + + `bytes, got ${maxBytes}.`, + ); + } const inflated = await gunzipBounded(artifact, maxBytes); if (inflated === null) { throw new Error( @@ -336,17 +364,30 @@ export function remoteCacheKey(name: string, fingerprint: string): string { return `${safe}-${fingerprint}`; } +/** Configuration for a {@link FileSystemCacheStore}. */ +export interface FileSystemCacheStoreOptions { + /** + * The most a fetched artifact may decompress to, in bytes — see + * {@link RemoteCacheStore.maxInflatedBytes}. Defaults to 2 GiB. + */ + maxInflatedBytes?: number; +} + /** A {@link RemoteCacheStore} backed by a shared or mounted directory. */ export class FileSystemCacheStore implements RemoteCacheStore { readonly #dir: string; + /** The most an artifact may decompress to; see {@link RemoteCacheStore.maxInflatedBytes}. */ + readonly maxInflatedBytes?: number; /** * Build the store over a directory. * * @param dir The directory archives are read from and written to. + * @param options The restore bound. */ - constructor(dir: string) { + constructor(dir: string, options: FileSystemCacheStoreOptions = {}) { this.#dir = dir; + this.maxInflatedBytes = options.maxInflatedBytes; } #path(key: string): string { @@ -392,6 +433,11 @@ export interface HttpCacheStoreOptions { * {@link restoreOutputs}. */ maxArtifactBytes?: number; + /** + * The most a fetched artifact may decompress to, in bytes — see + * {@link RemoteCacheStore.maxInflatedBytes}. Defaults to 2 GiB. + */ + maxInflatedBytes?: number; } /** @@ -420,9 +466,12 @@ export class HttpCacheStore implements RemoteCacheStore { readonly #headers: Readonly>; readonly #fetch: typeof fetch; readonly #maxArtifactBytes: number; + /** The most an artifact may decompress to; see {@link RemoteCacheStore.maxInflatedBytes}. */ + readonly maxInflatedBytes?: number; - /** Build the store from its URL, optional token, size cap, and `fetch` seam. */ + /** Build the store from its URL, optional token, size caps, and `fetch` seam. */ constructor(options: HttpCacheStoreOptions) { + this.maxInflatedBytes = options.maxInflatedBytes; const at = options.url.indexOf("?"); const path = at === -1 ? options.url : options.url.slice(0, at); this.#base = path.replace(/\/+$/, ""); @@ -505,25 +554,36 @@ export class HttpCacheStore implements RemoteCacheStore { * on a GitHub Actions job that exposes its `ACTIONS_RUNTIME_TOKEN` and * `ACTIONS_RESULTS_URL` (the `actions-cache` input of the `zuke-build/zuke` * action), a {@link "./github_actions_cache.ts".GitHubActionsCacheStore}. + * Whichever is chosen takes `ZUKE_REMOTE_CACHE_MAX_INFLATED_BYTES`, when set, + * as its {@link RemoteCacheStore.maxInflatedBytes}. */ export function envCacheStore( readEnv: (name: string) => string | undefined, ): RemoteCacheStore | undefined { + // Read only once a store is chosen, so it cannot fail a build that has none. + const limit = () => envInflatedLimit(readEnv); const url = readEnv("ZUKE_REMOTE_CACHE_URL"); if (url !== undefined && url !== "") { assertSecureBackendUrl(url, "ZUKE_REMOTE_CACHE_URL", readEnv); return new HttpCacheStore({ url, token: readEnv("ZUKE_REMOTE_CACHE_TOKEN"), + maxInflatedBytes: limit(), }); } const s3 = readEnv("ZUKE_REMOTE_CACHE_S3_URL"); if (s3 !== undefined && s3 !== "") { assertSecureBackendUrl(s3, "ZUKE_REMOTE_CACHE_S3_URL", readEnv); - return new S3CacheStore({ url: s3, ...s3Signing(readEnv) }); + return new S3CacheStore({ + url: s3, + ...s3Signing(readEnv), + maxInflatedBytes: limit(), + }); } const dir = readEnv("ZUKE_REMOTE_CACHE_DIR"); - if (dir !== undefined && dir !== "") return new FileSystemCacheStore(dir); + if (dir !== undefined && dir !== "") { + return new FileSystemCacheStore(dir, { maxInflatedBytes: limit() }); + } const token = readEnv("ACTIONS_RUNTIME_TOKEN"); const results = readEnv("ACTIONS_RESULTS_URL"); if ( @@ -531,11 +591,36 @@ export function envCacheStore( results !== "" ) { assertSecureBackendUrl(results, "ACTIONS_RESULTS_URL", readEnv); - return new GitHubActionsCacheStore({ url: results, token }); + return new GitHubActionsCacheStore({ + url: results, + token, + maxInflatedBytes: limit(), + }); } return undefined; } +/** + * `ZUKE_REMOTE_CACHE_MAX_INFLATED_BYTES` as a byte count, or `undefined` when + * unset. Anything but a positive whole number is an error naming the variable, + * not a silently ignored limit. + */ +function envInflatedLimit( + readEnv: (name: string) => string | undefined, +): number | undefined { + const name = "ZUKE_REMOTE_CACHE_MAX_INFLATED_BYTES"; + const raw = readEnv(name); + if (raw === undefined || raw === "") return undefined; + const bytes = Number(raw); + if (!/^[1-9][0-9]*$/.test(raw) || !Number.isSafeInteger(bytes)) { + throw new Error( + `remote cache: ${name} must be a positive whole number of bytes, ` + + `got "${raw}".`, + ); + } + return bytes; +} + /** * The signing region and credentials for `ZUKE_REMOTE_CACHE_S3_URL`, from the * variables the AWS SDKs read. Misconfiguration is an error naming the diff --git a/packages/core/src/s3_cache.ts b/packages/core/src/s3_cache.ts index 136062a8..71d4933e 100644 --- a/packages/core/src/s3_cache.ts +++ b/packages/core/src/s3_cache.ts @@ -43,6 +43,11 @@ export interface S3CacheStoreOptions { * {@link "./remote_cache.ts".HttpCacheStoreOptions.maxArtifactBytes}. */ maxArtifactBytes?: number; + /** + * The most a fetched artifact may decompress to, in bytes — see + * {@link RemoteCacheStore.maxInflatedBytes}. Defaults to 2 GiB. + */ + maxInflatedBytes?: number; } /** The request body as bytes: the HTTP store sends a `Blob` or nothing. */ @@ -67,6 +72,8 @@ async function bodyBytes( */ export class S3CacheStore implements RemoteCacheStore { readonly #http: HttpCacheStore; + /** The most an artifact may decompress to; see {@link RemoteCacheStore.maxInflatedBytes}. */ + readonly maxInflatedBytes?: number; /** Build the store from the bucket URL, region, credentials and seams. */ constructor(options: S3CacheStoreOptions) { @@ -97,6 +104,7 @@ export class S3CacheStore implements RemoteCacheStore { fetch: signing, maxArtifactBytes: options.maxArtifactBytes, }); + this.maxInflatedBytes = options.maxInflatedBytes; } /** Fetch the archived outputs stored under `key`, or `null` if there are none. */ diff --git a/packages/core/tests/cache_test.ts b/packages/core/tests/cache_test.ts index 3b3fbd26..fa8e7d95 100644 --- a/packages/core/tests/cache_test.ts +++ b/packages/core/tests/cache_test.ts @@ -219,6 +219,33 @@ Deno.test("the remote store restores outputs on a local miss", async () => { assertEquals(dec(host.files.get("dist/app.js") ?? enc("")), "built"); // written back }); +Deno.test("a restore honours the store's maxInflatedBytes", async () => { + const host = new MemHost(); + host.files.set("in.txt", enc("v1")); + host.dirs.set("dist", ["app.js"]); + host.files.set("dist/app.js", enc("built")); + const t = buildTarget(); + const fp = await fingerprint(t, host); + // The archive inflates past 64 bytes (a tar header alone is 512). + class TinyStore extends MemCacheStore { + readonly maxInflatedBytes = 64; + } + const store = new TinyStore(); + store.map.set( + remoteCacheKey("build", fp), + await archiveOutputs(["dist"], host), + ); + host.dirs.delete("dist"); + host.files.delete("dist/app.js"); + const warnings: string[] = []; + const cache = await openCache(STORE, host, { + remote: store, + warn: (message) => warnings.push(message), + }); + assertEquals(await cache.upToDate(t), false); // refused: a rebuild + assertEquals(warnings.some((w) => w.includes("more than 64 bytes")), true); +}); + Deno.test("a successful run uploads outputs to the remote store", async () => { const host = new MemHost(); host.files.set("in.txt", enc("v1")); diff --git a/packages/core/tests/compression_test.ts b/packages/core/tests/compression_test.ts index c615d30d..75bb6cbe 100644 --- a/packages/core/tests/compression_test.ts +++ b/packages/core/tests/compression_test.ts @@ -86,16 +86,16 @@ Deno.test("tar output is reproducible (fixed mtime)", () => { assertEquals(tar(e), tar(e)); }); -Deno.test("tar rejects names longer than the ustar limit", () => { - const longName = "a/".repeat(60); // > 100 bytes - // assertThrows would need sync; use a try to assert the message. - let message = ""; - try { - tar([{ name: longName, data: enc("x") }]); - } catch (error) { - message = error instanceof Error ? error.message : String(error); - } - assertEquals(message.includes("exceeds 100 bytes"), true); +Deno.test("tar carries a name past the ustar limit in a pax header", () => { + // Multi-byte, so the record length counts bytes, not characters. + const longName = "é/".repeat(60) + "file"; + const out = untar(tar([ + { name: longName, data: enc("x"), mode: 0o755 }, + { name: "short", data: enc("y") }, + ])); + assertEquals(out.map((e) => e.name), [longName, "short"]); + assertEquals(dec(out[0].data), "x"); + assertEquals(out[0].mode, 0o755); }); Deno.test("untar stops at the zero-block trailer and ignores non-files", () => { @@ -780,14 +780,11 @@ Deno.test("tar/untar round-trips a symlink entry (linkname, no data)", () => { assertEquals(out[1].data.length, 0); // a symlink carries no data }); -Deno.test("tar rejects a symlink target longer than the ustar limit", () => { - let message = ""; - try { - tar([{ name: "l", data: new Uint8Array(0), linkname: "x/".repeat(60) }]); - } catch (error) { - message = error instanceof Error ? error.message : String(error); - } - assertEquals(message.includes("symlink target exceeds 100 bytes"), true); +Deno.test("tar carries a long name and symlink target in one pax header", () => { + const name = "n/".repeat(55) + "link"; + const target = "x/".repeat(60) + "t"; + const out = untar(tar([{ name, data: new Uint8Array(0), linkname: target }])); + assertEquals(out, [{ name, data: new Uint8Array(0), linkname: target }]); }); Deno.test("extractTarGzip strips leading path components", async () => { diff --git a/packages/core/tests/remote_cache_test.ts b/packages/core/tests/remote_cache_test.ts index 3728b418..6ec820cc 100644 --- a/packages/core/tests/remote_cache_test.ts +++ b/packages/core/tests/remote_cache_test.ts @@ -507,6 +507,99 @@ Deno.test("restoreOutputs refuses protected paths whatever the outputs declare", } }); +Deno.test("a .git inside .zuke/tools round-trips, and only there", async () => { + // An SDK such as Flutter's is a git checkout that runs git on itself. + const src = new MemFs(); + src.dirs.set(".zuke/tools", ["flutter"]); + src.dirs.set(".zuke/tools/flutter", [".git", "bin"]); + src.dirs.set(".zuke/tools/flutter/.git", ["HEAD"]); + src.files.set(".zuke/tools/flutter/.git/HEAD", enc("ref: refs/heads/stable")); + src.files.set(".zuke/tools/flutter/bin", enc("flutter")); + const out = new MemFs(); + assertEquals( + await restoreOutputs( + await archiveOutputs([".zuke/tools"], src), + out, + [".zuke/tools"], + ), + [".zuke/tools/flutter/.git/HEAD", ".zuke/tools/flutter/bin"], + ); + // A .git before the tools subtree, or beside it, is still refused. + for ( + const name of [ + ".git/.zuke/tools/x", + "sub/.git/hooks/pre-commit", + ".zuke/.git/config", + ".zuke/runs/.git/config", + ] + ) { + const artifact = await gzip(tar([{ name, data: enc("x") }])); + const err = await assertRejects(() => restoreOutputs(artifact, out, ["."])); + assertStringIncludes(err.message, "protected path"); + } +}); + +Deno.test("envCacheStore passes ZUKE_REMOTE_CACHE_MAX_INFLATED_BYTES to the store", () => { + const env = + (vars: Record) => (name: string): string | undefined => + Object.prototype.hasOwnProperty.call(vars, name) ? vars[name] : undefined; + const limit = { ZUKE_REMOTE_CACHE_MAX_INFLATED_BYTES: "4294967296" }; + const stores: Record[] = [ + { ZUKE_REMOTE_CACHE_URL: "https://c.test" }, + { + ZUKE_REMOTE_CACHE_S3_URL: "https://bucket.s3.amazonaws.com/cache", + AWS_ACCESS_KEY_ID: "AKIDEXAMPLE", + AWS_SECRET_ACCESS_KEY: "secret", + AWS_REGION: "auto", + }, + { ZUKE_REMOTE_CACHE_DIR: "/tmp/x" }, + { ACTIONS_RUNTIME_TOKEN: "t", ACTIONS_RESULTS_URL: "https://r.test" }, + ]; + for (const vars of stores) { + assertEquals( + envCacheStore(env({ ...vars, ...limit }))?.maxInflatedBytes, + 4294967296, + ); + assertEquals(envCacheStore(env(vars))?.maxInflatedBytes, undefined); + } + // Read only once a store is chosen: a bad value cannot fail a build with none. + assertEquals( + envCacheStore(env({ ZUKE_REMOTE_CACHE_MAX_INFLATED_BYTES: "abc" })), + undefined, + ); + for (const bad of ["0", "-1", "4GiB", "1e10", "9007199254740993"]) { + assertThrows( + () => + envCacheStore( + env({ ...stores[2], ZUKE_REMOTE_CACHE_MAX_INFLATED_BYTES: bad }), + ), + Error, + "ZUKE_REMOTE_CACHE_MAX_INFLATED_BYTES", + ); + } +}); + +Deno.test("restoreOutputs refuses a bound that is not a positive whole number", async () => { + const artifact = await gzip(tar([{ name: "dist/a", data: enc("x") }])); + for (const bound of [NaN, 0, -1, 1.5]) { + const err = await assertRejects(() => + restoreOutputs(artifact, new MemFs(), ["dist"], bound) + ); + assertStringIncludes(err.message, "maxInflatedBytes"); + } +}); + +Deno.test("restoreOutputs refuses a .git spelled with a trailing dot or space", async () => { + // Windows drops both, so either would land in the workspace's real `.git`. + for (const name of [".git./hooks/pre-commit", ".git /config", ".zuke. /x"]) { + const artifact = await gzip(tar([{ name, data: enc("x") }])); + const err = await assertRejects(() => + restoreOutputs(artifact, new MemFs(), ["."]) + ); + assertStringIncludes(err.message, "protected path"); + } +}); + Deno.test("restoreOutputs refuses entry kinds archiveOutputs never produces", async () => { const out = new MemFs(); // A symlink is how a *later* entry writes through a name the checks approved. diff --git a/tests/integration/caching_test.ts b/tests/integration/caching_test.ts index 09d9b6ac..ccc02e48 100644 --- a/tests/integration/caching_test.ts +++ b/tests/integration/caching_test.ts @@ -189,6 +189,70 @@ Deno.test("a FileSystemCacheStore (env-configured) restores outputs on a fresh c }); }); +Deno.test("an SDK under .zuke/tools caches with its .git and long paths, within the configured limit", async () => { + await withStateDir(async () => { + await withTempCwd(async (dir) => { + const remoteDir = await Deno.makeTempDir({ prefix: "zuke-it-remote-" }); + const vars = [ + "ZUKE_REMOTE_CACHE_DIR", + "ZUKE_REMOTE_CACHE_MAX_INFLATED_BYTES", + ]; + const prev = vars.map((name) => Deno.env.get(name)); + Deno.env.set("ZUKE_REMOTE_CACHE_DIR", remoteDir); + Deno.env.set("ZUKE_REMOTE_CACHE_MAX_INFLATED_BYTES", "1048576"); + const sdk = ".zuke/tools/sdk"; + // Past ustar's 100-byte name field, so it rides in a pax header. + const deep = `${sdk}/${"packages/".repeat(12)}lib.dart`; + try { + await Deno.writeTextFile(`${dir}/input.txt`, "v1"); + const log: string[] = []; + class B extends Build { + sdk = target().inputs("input.txt").outputs(sdk).executes( + async () => { + log.push("install"); + await Deno.mkdir(`${sdk}/.git`, { recursive: true }); + await Deno.writeTextFile(`${sdk}/.git/HEAD`, "ref: stable\n"); + await Deno.mkdir(deep.slice(0, deep.lastIndexOf("/")), { + recursive: true, + }); + await Deno.writeTextFile(deep, "library sdk;\n"); + }, + ); + } + assertEquals((await runCli(B, ["sdk"])).code, 0); + // A fresh checkout: the local cache and the SDK both gone. + await Deno.remove(`${dir}/.zuke`, { recursive: true }); + assertEquals((await runCli(B, ["sdk"])).code, 0); + assertEquals(log, ["install"]); // restored, not reinstalled + assertEquals( + await Deno.readTextFile(`${dir}/${sdk}/.git/HEAD`), + "ref: stable\n", + ); + assertEquals( + await Deno.readTextFile(`${dir}/${deep}`), + "library sdk;\n", + ); + + // Below what the artifact inflates to, the restore is refused and + // the target rebuilds rather than failing. + Deno.env.set("ZUKE_REMOTE_CACHE_MAX_INFLATED_BYTES", "1024"); + await Deno.remove(`${dir}/.zuke`, { recursive: true }); + const refused = await runCli(B, ["sdk"]); + assertEquals(refused.code, 0); + assertEquals(log, ["install", "install"]); + assertStringIncludes(refused.err + refused.out, "more than 1024 bytes"); + } finally { + vars.forEach((name, i) => { + const value = prev[i]; + if (value === undefined) Deno.env.delete(name); + else Deno.env.set(name, value); + }); + await Deno.remove(remoteDir, { recursive: true }); + } + }); + }); +}); + Deno.test("a remote restore onto a symlinked output is refused, and rebuilds", async () => { if (Deno.build.os === "windows") return; // symlink creation is privileged there await withStateDir(async () => {