Skip to content

Commit 7a82c00

Browse files
committed
Fix tinytex install silently ignoring extraction failures
Check the return value of unzip() during TinyTeX installation. When extraction fails (e.g., tar xf on a .tar.xz without xz-utils), the installer now throws a clear error instead of proceeding to move non-existent files and producing a confusing lstat/NotFound error. For .tar.xz archives, the error message includes a hint to install xz-utils. Also add xz-utils to .deb Recommends and xz to .rpm Recommends since TinyTeX now ships .tar.xz as the preferred format. Fixes #14304
1 parent 23bb687 commit 7a82c00

4 files changed

Lines changed: 107 additions & 10 deletions

File tree

.claude/rules/testing/typescript-tests.md

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -104,8 +104,11 @@ const markdown = asMappedString("");
104104
const markdownWithContent = asMappedString("# Title\nSome content");
105105
```
106106

107-
**Mock ProjectContext:**
108-
```typescript
109-
import { createMockProjectContext } from "./utils.ts"; // tests/unit/project/utils.ts
110-
const project = createMockProjectContext(); // Creates temp dir + FileInformationCacheMap
111-
```
107+
**Mock Contexts:**
108+
109+
Several subsystems use context interfaces passed to functions. For unit tests, create `createMock*()` helpers with no-op stubs. Key pattern: async callbacks (like `withSpinner`) should just `await op()` so errors propagate normally. Check existing test files for helpers before writing new ones.
110+
111+
| Context | Interface | Existing helpers |
112+
|---------|-----------|-----------------|
113+
| `ProjectContext` | `src/project/types.ts` | `tests/unit/project/utils.ts``createMockProjectContext()` |
114+
| `InstallContext` | `src/tools/types.ts` | `tests/unit/tools/chrome-headless-shell.test.ts``createMockContext()` |

package/src/linux/installer.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,11 +92,15 @@ async function createNfpmConfig(
9292
// Format-specific configuration
9393
if (format === 'deb') {
9494
config.overrides.deb = {
95-
recommends: ["unzip"],
95+
recommends: ["unzip", "xz-utils"],
9696
};
9797
// Add Debian-specific metadata
9898
config.section = "user/text";
9999
config.priority = "optional";
100+
} else if (format === 'rpm') {
101+
config.overrides.rpm = {
102+
recommends: ["xz"],
103+
};
100104
}
101105
return config;
102106
}

src/tools/impl/tinytex.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,15 @@ async function install(
190190
await context.withSpinner(
191191
{ message: `Unzipping ${basename(pkgInfo.filePath)}` },
192192
async () => {
193-
await unzip(pkgInfo.filePath);
193+
const result = await unzip(pkgInfo.filePath);
194+
if (!result.success) {
195+
const hint = pkgInfo.filePath.endsWith(".tar.xz")
196+
? "\nOn Linux, you may need to install xz-utils (e.g., apt install xz-utils)."
197+
: "";
198+
throw new Error(
199+
`Failed to extract ${basename(pkgInfo.filePath)}.${hint}\n${result.stderr}`,
200+
);
201+
}
194202
},
195203
);
196204

tests/unit/tools/tinytex.test.ts

Lines changed: 85 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,14 @@
55
*/
66

77
import { unitTest } from "../../test.ts";
8-
import { assert, assertEquals } from "testing/asserts";
9-
import { tinyTexPkgName } from "../../../src/tools/impl/tinytex.ts";
8+
import { assert, assertEquals, assertRejects } from "testing/asserts";
9+
import {
10+
tinyTexInstallable,
11+
tinyTexPkgName,
12+
} from "../../../src/tools/impl/tinytex.ts";
1013
import { getLatestRelease } from "../../../src/tools/github.ts";
11-
import { GitHubRelease } from "../../../src/tools/types.ts";
14+
import { GitHubRelease, InstallContext, PackageInfo } from "../../../src/tools/types.ts";
15+
import { join } from "../../../src/deno_ral/path.ts";
1216

1317
// ---- Pure logic tests for tinyTexPkgName ----
1418

@@ -158,3 +162,81 @@ unitTest(
158162
assertAssetExists(candidates, assetNames, "Windows");
159163
},
160164
);
165+
166+
// ---- Extraction failure tests ----
167+
168+
function createMockContext(workingDir: string): InstallContext {
169+
return {
170+
workingDir,
171+
info: (_msg: string) => {},
172+
withSpinner: async (_options, op) => {
173+
await op();
174+
},
175+
error: (_msg: string) => {},
176+
confirm: async (_msg: string) => true,
177+
download: async (_name: string, _url: string, _target: string) => {},
178+
props: {},
179+
flags: {},
180+
};
181+
}
182+
183+
unitTest(
184+
"install - throws on extraction failure for corrupt archive",
185+
async () => {
186+
const workingDir = Deno.makeTempDirSync({ prefix: "quarto-tinytex-test" });
187+
try {
188+
// Create a corrupt .tar.xz file that tar cannot extract
189+
const badArchive = join(
190+
workingDir,
191+
"TinyTeX-linux-x86_64-v2026.04.tar.xz",
192+
);
193+
Deno.writeTextFileSync(badArchive, "this is not a valid archive");
194+
195+
const pkgInfo: PackageInfo = {
196+
filePath: badArchive,
197+
version: "v2026.04",
198+
};
199+
const context = createMockContext(workingDir);
200+
201+
await assertRejects(
202+
() => tinyTexInstallable.install(pkgInfo, context),
203+
Error,
204+
"Failed to extract",
205+
);
206+
} finally {
207+
Deno.removeSync(workingDir, { recursive: true });
208+
}
209+
},
210+
);
211+
212+
unitTest(
213+
"install - extraction failure for .tar.xz includes xz-utils hint",
214+
async () => {
215+
const workingDir = Deno.makeTempDirSync({ prefix: "quarto-tinytex-test" });
216+
try {
217+
const badArchive = join(
218+
workingDir,
219+
"TinyTeX-linux-x86_64-v2026.04.tar.xz",
220+
);
221+
Deno.writeTextFileSync(badArchive, "this is not a valid archive");
222+
223+
const pkgInfo: PackageInfo = {
224+
filePath: badArchive,
225+
version: "v2026.04",
226+
};
227+
const context = createMockContext(workingDir);
228+
229+
try {
230+
await tinyTexInstallable.install(pkgInfo, context);
231+
throw new Error("Expected install to throw");
232+
} catch (e) {
233+
assert(
234+
e instanceof Error && e.message.includes("xz-utils"),
235+
`Error message should mention xz-utils, got: ${e}`,
236+
);
237+
}
238+
} finally {
239+
Deno.removeSync(workingDir, { recursive: true });
240+
}
241+
},
242+
);

0 commit comments

Comments
 (0)