fix(host-kit): keep extracted directories owner-accessible - #3111
Conversation
An archive could declare a directory without owner read access (e.g. 0o300). Recursive mkdir applied that mode to implied parents too, so cleanup's recursive rm failed with EACCES: a failed extraction reported the cleanup error instead of its own and leaked the temp tree, and a successful one leaked it after install. Entry modes now come from one rule shared by zip and tar: declared permission bits only, at least owner rwx for directories and rw for files. Implied parents are created with the default mode. Adds adversarial extraction tests pinning link, special-entry, escaping-path, PAX override, duplicate, case-collision, and set-id handling, none of which were covered.
There was a problem hiding this comment.
2 issues found across 6 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="packages/host-kit/src/internal/archive-extraction.test.ts">
<violation number="1" location="packages/host-kit/src/internal/archive-extraction.test.ts:29">
P3: `extractFixture` creates a temp `root` in os.tmpdir() for every test and never removes it, so each run leaks many temp directories (REFUSED_ENTRIES alone adds 12). Every sibling test file in this directory cleans up its temp root (e.g. archive-extraction-tar.test.ts removes `workspace.root` in a `finally`; durable-file.test.ts uses an `afterEach`). Collect each created root and remove it in an `afterEach` so the suite stops leaking temp trees.</violation>
</file>
<file name="packages/host-kit/src/internal/archive-extraction-zip.ts">
<violation number="1" location="packages/host-kit/src/internal/archive-extraction-zip.ts:162">
P2: `extractedEntryMode` does not guarantee owner access because ZIP creation APIs mask its result with the process umask. With an owner-clearing umask such as `0o777`, entries become mode `000` and nested extraction fails; chmod entries after creation, including files, to enforce the invariant.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| mode: unixMode & 0o777 || fallback, | ||
| kind, | ||
| size: kind === 'directory' ? 0 : entry.uncompressedSize, | ||
| mode: extractedEntryMode(kind, unixMode), |
There was a problem hiding this comment.
P2: extractedEntryMode does not guarantee owner access because ZIP creation APIs mask its result with the process umask. With an owner-clearing umask such as 0o777, entries become mode 000 and nested extraction fails; chmod entries after creation, including files, to enforce the invariant.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/host-kit/src/internal/archive-extraction-zip.ts, line 162:
<comment>`extractedEntryMode` does not guarantee owner access because ZIP creation APIs mask its result with the process umask. With an owner-clearing umask such as `0o777`, entries become mode `000` and nested extraction fails; chmod entries after creation, including files, to enforce the invariant.</comment>
<file context>
@@ -152,13 +154,12 @@ function toManifestEntry(entry: yauzl.Entry): ArchiveManifestEntry {
- mode: unixMode & 0o777 || fallback,
+ kind,
+ size: kind === 'directory' ? 0 : entry.uncompressedSize,
+ mode: extractedEntryMode(kind, unixMode),
};
}
</file context>
| outputRoot: string; | ||
| error: unknown; | ||
| }> { | ||
| const root = await mkdtempForTest('agent-device-archive-extraction-'); |
There was a problem hiding this comment.
P3: extractFixture creates a temp root in os.tmpdir() for every test and never removes it, so each run leaks many temp directories (REFUSED_ENTRIES alone adds 12). Every sibling test file in this directory cleans up its temp root (e.g. archive-extraction-tar.test.ts removes workspace.root in a finally; durable-file.test.ts uses an afterEach). Collect each created root and remove it in an afterEach so the suite stops leaking temp trees.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/host-kit/src/internal/archive-extraction.test.ts, line 29:
<comment>`extractFixture` creates a temp `root` in os.tmpdir() for every test and never removes it, so each run leaks many temp directories (REFUSED_ENTRIES alone adds 12). Every sibling test file in this directory cleans up its temp root (e.g. archive-extraction-tar.test.ts removes `workspace.root` in a `finally`; durable-file.test.ts uses an `afterEach`). Collect each created root and remove it in an `afterEach` so the suite stops leaking temp trees.</comment>
<file context>
@@ -0,0 +1,259 @@
+ outputRoot: string;
+ error: unknown;
+}> {
+ const root = await mkdtempForTest('agent-device-archive-extraction-');
+ const archivePath = path.join(root, `fixture.${fixture.type}`);
+ if (fixture.type === 'zip') await writeZipFixture(archivePath, fixture.entries);
</file context>
|
This PR is ready at 0f45e3b. Extracted directories now stay owner-accessible, and the change is limited to host-kit archive extraction. CI is green, with 14 checks and none failing. I know of no conflicts. Not blocking, and you can take or leave these: (1) a tar header with an explicit mode of 0 now gets the default permissions through the I did not run the new tests on either commit. The count of 4 tests failing before the fix comes from reading the old code. I did not check the umask interaction live, and the case-collision test accepts either outcome, so it does not tell case-sensitive and case-insensitive filesystems apart. No further step is needed before merge. |
A directory entry that follows its children now takes its declared mode instead of keeping the implied default, so entry order no longer changes extracted permissions. An explicit mode of zero is honored as owner access only; only an absent mode falls back to the default, and zip entries without Unix attributes still get it. The tar fixture can now encode a zero mode, which tar-stream otherwise replaces with its default. Tests cover both entry orders, zero modes, attribute-less zip entries, and a directory colliding with an earlier file.
There was a problem hiding this comment.
2 issues found across 5 files (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="packages/host-kit/src/internal/archive-extraction.fixtures.ts">
<violation number="1" location="packages/host-kit/src/internal/archive-extraction.fixtures.ts:128">
P3: This raw 100-byte ASCII name lookup misses valid tar entry names that use the USTAR prefix or contain non-ASCII or significant spaces. The helper then fails to restore an explicit zero mode, so tests for those paths exercise the default mode instead; reconstruct the complete header name with the archive's encoding before matching.</violation>
</file>
<file name="packages/host-kit/src/internal/archive-extraction.test.ts">
<violation number="1" location="packages/host-kit/src/internal/archive-extraction.test.ts:246">
P3: This test asserts exact modes `0o700`/`0o600` for entries created fresh via `fs.mkdir(..., { mode })` and `createWriteStream(..., { mode })`, but Node masks those modes with the process umask, so the assertions hold only under the CI default umask. The sibling "zip entry without Unix attributes" test added in the same delta compensates with `& ~process.umask()`; make this test umask-independent by asserting the guarantee (owner access present) instead of the exact mode.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| if (names.size === 0) return; | ||
| for (let offset = 0; offset + TAR_BLOCK <= archive.length;) { | ||
| const block = archive.subarray(offset, offset + TAR_BLOCK); | ||
| const name = readTarField(block, 0, 100); |
There was a problem hiding this comment.
P3: This raw 100-byte ASCII name lookup misses valid tar entry names that use the USTAR prefix or contain non-ASCII or significant spaces. The helper then fails to restore an explicit zero mode, so tests for those paths exercise the default mode instead; reconstruct the complete header name with the archive's encoding before matching.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/host-kit/src/internal/archive-extraction.fixtures.ts, line 128:
<comment>This raw 100-byte ASCII name lookup misses valid tar entry names that use the USTAR prefix or contain non-ASCII or significant spaces. The helper then fails to restore an explicit zero mode, so tests for those paths exercise the default mode instead; reconstruct the complete header name with the archive's encoding before matching.</comment>
<file context>
@@ -99,14 +99,54 @@ export async function writeTarFixture(
+ if (names.size === 0) return;
+ for (let offset = 0; offset + TAR_BLOCK <= archive.length;) {
+ const block = archive.subarray(offset, offset + TAR_BLOCK);
+ const name = readTarField(block, 0, 100);
+ if (!name) return;
+ if (names.has(name.replace(/\/$/, ''))) {
</file context>
| const { outputRoot, error } = await extractFixture(fixture); | ||
|
|
||
| assert.equal(error, undefined); | ||
| assert.equal((await fs.stat(path.join(outputRoot, 'App.app/private'))).mode & 0o777, 0o700); |
There was a problem hiding this comment.
P3: This test asserts exact modes 0o700/0o600 for entries created fresh via fs.mkdir(..., { mode }) and createWriteStream(..., { mode }), but Node masks those modes with the process umask, so the assertions hold only under the CI default umask. The sibling "zip entry without Unix attributes" test added in the same delta compensates with & ~process.umask(); make this test umask-independent by asserting the guarantee (owner access present) instead of the exact mode.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/host-kit/src/internal/archive-extraction.test.ts, line 246:
<comment>This test asserts exact modes `0o700`/`0o600` for entries created fresh via `fs.mkdir(..., { mode })` and `createWriteStream(..., { mode })`, but Node masks those modes with the process umask, so the assertions hold only under the CI default umask. The sibling "zip entry without Unix attributes" test added in the same delta compensates with `& ~process.umask()`; make this test umask-independent by asserting the guarantee (owner access present) instead of the exact mode.</comment>
<file context>
@@ -199,6 +199,95 @@ test.each<ArchiveFixture>([
+ const { outputRoot, error } = await extractFixture(fixture);
+
+ assert.equal(error, undefined);
+ assert.equal((await fs.stat(path.join(outputRoot, 'App.app/private'))).mode & 0o777, 0o700);
+ assert.equal(
+ (await fs.stat(path.join(outputRoot, 'App.app/private/secret'))).mode & 0o777,
</file context>
Matching headers by name would patch every duplicate when one declares a zero mode and depended on the 100-byte name field. The fixture now patches by emitted entry order, skipping the PAX extended header that precedes an entry, and the zero-mode tar test carries a PAX record to prove the skip.
|
At 4b1b594 the new mode-0 handling looks right, but the new directory rule still depends on entry order. I removed the ready-for-human label until this is fixed. CI is green and there are no conflicts. In https://github.com/callstack/agent-device/blob/4b1b594/packages/host-kit/src/internal/archive-safety.ts#L212, Could both branches use one rule, for example chmod to The newer head 85fec93 changes only the test fixture and tests, so this still applies there. |
mkdir applies the process umask to its mode and chmod does not, so a directory declared after its children extracted with more permissions than the same directory declared before them. Both branches now chmod to the declared mode under the umask, with owner access kept, matching what the kernel does for files. The entry-order test declares 0o770 and expects it under the umask in both orders.
|
Looks good at 5d9f400. The directory mode rule no longer depends on entry order: new and existing directories both get Optional: use |
…lugin * origin/main: (628 commits) fix(ios): pin runner build roots under derived data (callstack#3158) feat: add managed provider plugin infrastructure (callstack#3121) fix(ios): report keyboard focus from the AX bridge's is-editing trait (callstack#3163) feat(devices): report model and osVersion (callstack#3119) fix: guard alert deadline before native tap synthesis (callstack#3113) feat(install-source): accept archive URLs from any public host (callstack#3110) test: keep uptime responsive behind busy runner work (callstack#3114) fix(apple): read the launch confirmation whenever the open cannot see the app (callstack#3115) fix(host-kit): keep extracted directories owner-accessible (callstack#3111) fix(daemon): run Apple tools with the requesting client's DEVELOPER_DIR (callstack#3109) fix(daemon): fence daemon.json removal to its owning process (callstack#3102) fix(snapshot): stop sibling-sized chrome containers from covering their own region (callstack#2996) (callstack#3097) fix(ios): stop reading windows past the one the runner resolved (callstack#3103) refactor(daemon): apply one dispatch-disclosure rule to returned and thrown failures (callstack#3099) chore: drop unused production exports and suppress dynamic consumers (callstack#3100) docs(help): document wait readiness and restart exhaustion (callstack#3098) docs: simplify Host to fresh Simlock devices and lease recovery (callstack#3095) feat(capture): report the display rotation a screenshot was rendered in (callstack#3088) refactor(snapshot): preserve normalized node attributes through presentation (callstack#3092) refactor(help): colocate fold guidance and extract workflows (callstack#3093) ... # Conflicts: # README.md # package.json # packages/kernel/src/snapshot.ts # src/__tests__/eager-closure-budgets.ts # src/cli/commands/connection-presentation.ts # src/commands/schema/cli-help.ts # src/commands/schema/command-overrides.ts
Summary
An archive could declare a directory without owner read access (for example
0o300). The extractor applied that mode with recursivemkdir, so implied parents got it too, and cleanup's recursivermthen failed withEACCES:Any caller who can get an archive extracted (uploads, path sources, allowlisted URLs) could repeat this, up to the 4 GiB expansion budget each time.
Zip and tar now share one entry-mode rule in
archive-safety.ts: declared permission bits only (set-id and sticky stripped, as before), at least ownerrwxfor directories andrwfor files. Implied parents are created with the default mode; directories get their declared mode under the process umask whether declared before or after their children, so entry order does not change permissions. An explicit mode of zero is honored as owner access only; only an absent mode (including zip entries without Unix attributes) falls back to the default.This also adds adversarial extraction tests for guarantees that had no coverage: zip/tar symlinks and hard links, device and FIFO entries,
../absolute paths, PAXpath/linkpathoverrides, duplicate, case-colliding, and file/directory-colliding entries, set-id stripping, and zero modes. It's groundwork for allowing archive URLs from any public host, which follows in #3110.6 files, all in
packages/host-kit/src/internal.Validation
5d9f400aaorigin/main(4 of 20; the unfixed bug also left the run's TMPDIR undeletable) and pass after the fix. The entry-order and zero-mode tests fail against0f45e3band pass after4b1b594.pnpm check:affected --runon5d9f400aa: all runnable checks passed (434 files, 2,917 tests, typecheck, lint, layering, fallow, build). Replay and smoke lanes are GitHub-authoritative.