Skip to content

Commit da2edfd

Browse files
jcfischerclaude
andauthored
test: SHA-256 regression tests (#54) (#74)
* test: SHA-256 and R2 install path regression tests (#54) Eight adversarial test cases per DD-79 covering hash mismatch, content swap, truncation, payload append, HTTP errors, bad archive, and missing manifest scenarios. Closes #54 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: strengthen partial-file test with filesystem check Also verify no arc-download-* files leaked to disk on download failure, not just the API return value. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: bump version to 0.19.2 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 9b1f34f commit da2edfd

2 files changed

Lines changed: 150 additions & 1 deletion

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "arc",
3-
"version": "0.19.1",
3+
"version": "0.19.2",
44
"description": "Agentic component package manager — install, manage, and distribute AI agent skills",
55
"type": "module",
66
"bin": {

test/unit/registry-install.test.ts

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,3 +378,152 @@ describe("downloadPackage", () => {
378378
}
379379
});
380380
});
381+
382+
// ---------------------------------------------------------------------------
383+
// Adversarial regression tests (DD-79)
384+
// ---------------------------------------------------------------------------
385+
386+
describe("adversarial: SHA-256 tamper detection", () => {
387+
test("rejects install when registry returns tampered hash", async () => {
388+
// Scenario: registry is compromised and returns a different SHA-256
389+
// than what the artifact actually contains. arc must reject because
390+
// it recomputes the hash from the downloaded bytes independently.
391+
const realContent = "legitimate package content";
392+
const filePath = join(env.paths.reposDir, "tampered-hash.tar.gz");
393+
await writeFile(filePath, realContent);
394+
395+
// Registry claims a hash that doesn't match the actual file
396+
const tamperedHash = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
397+
398+
const result = await verifyChecksum(filePath, tamperedHash);
399+
expect(result.valid).toBe(false);
400+
expect(result.expected).toBe(tamperedHash);
401+
expect(result.actual).not.toBe(tamperedHash);
402+
});
403+
404+
test("rejects when artifact is replaced with different content but same size", async () => {
405+
// Scenario: attacker replaces artifact with malicious payload of the
406+
// same byte length. SHA-256 must catch this.
407+
const originalContent = "AAAA"; // 4 bytes
408+
const maliciousContent = "BBBB"; // 4 bytes — same length
409+
410+
// Compute hash of original
411+
const hasher = new Bun.CryptoHasher("sha256");
412+
hasher.update(originalContent);
413+
const originalHash = hasher.digest("hex");
414+
415+
// Write the malicious content but verify against original hash
416+
const filePath = join(env.paths.reposDir, "swapped-same-size.bin");
417+
await writeFile(filePath, maliciousContent);
418+
419+
const result = await verifyChecksum(filePath, originalHash);
420+
expect(result.valid).toBe(false);
421+
expect(result.actual).not.toBe(originalHash);
422+
});
423+
424+
test("rejects truncated artifact", async () => {
425+
// Scenario: artifact is truncated mid-transfer. The partial file has
426+
// a different SHA-256 than the complete artifact.
427+
const fullContent = "full package content with many bytes of data";
428+
const truncatedContent = "full pack"; // cut mid-stream
429+
430+
// Hash of the full content is what the registry advertises
431+
const hasher = new Bun.CryptoHasher("sha256");
432+
hasher.update(fullContent);
433+
const fullHash = hasher.digest("hex");
434+
435+
// Write truncated version and verify against full hash
436+
const filePath = join(env.paths.reposDir, "truncated.tar.gz");
437+
await writeFile(filePath, truncatedContent);
438+
439+
const result = await verifyChecksum(filePath, fullHash);
440+
expect(result.valid).toBe(false);
441+
expect(result.actual).not.toBe(fullHash);
442+
});
443+
444+
test("rejects artifact with appended bytes", async () => {
445+
// Scenario: attacker appends malicious payload to a valid artifact
446+
const originalContent = "valid package";
447+
const tamperedContent = "valid package\x00MALICIOUS_PAYLOAD";
448+
449+
const hasher = new Bun.CryptoHasher("sha256");
450+
hasher.update(originalContent);
451+
const originalHash = hasher.digest("hex");
452+
453+
const filePath = join(env.paths.reposDir, "appended.tar.gz");
454+
await writeFile(filePath, tamperedContent);
455+
456+
const result = await verifyChecksum(filePath, originalHash);
457+
expect(result.valid).toBe(false);
458+
});
459+
});
460+
461+
describe("adversarial: download path error handling", () => {
462+
test("download returns error on server 500 after retries", async () => {
463+
const originalFetch = globalThis.fetch;
464+
let attempts = 0;
465+
globalThis.fetch = mockFetch(async () => {
466+
attempts++;
467+
return new Response("Internal Server Error", { status: 500 });
468+
});
469+
470+
try {
471+
const result = await downloadPackage("https://example.com/pkg.tar.gz", env.paths.reposDir);
472+
expect(result.success).toBe(false);
473+
expect(result.error).toContain("500");
474+
expect(attempts).toBe(2); // original + 1 retry
475+
} finally {
476+
globalThis.fetch = originalFetch;
477+
}
478+
});
479+
480+
test("download does not write partial file on failure", async () => {
481+
const originalFetch = globalThis.fetch;
482+
globalThis.fetch = mockFetch(async () => new Response("Forbidden", { status: 403 }));
483+
484+
try {
485+
const result = await downloadPackage("https://example.com/pkg.tar.gz", env.paths.reposDir);
486+
expect(result.success).toBe(false);
487+
// No tempPath should be returned on failure
488+
expect(result.tempPath).toBeUndefined();
489+
// Also verify no arc-download-* files leaked to disk
490+
const { readdirSync } = await import("fs");
491+
const leaked = readdirSync(env.paths.reposDir).filter((f: string) => f.startsWith("arc-download-"));
492+
expect(leaked).toHaveLength(0);
493+
} finally {
494+
globalThis.fetch = originalFetch;
495+
}
496+
});
497+
});
498+
499+
describe("adversarial: extract path integrity", () => {
500+
test("extraction cleans up on invalid tarball — no partial files left", async () => {
501+
const badTarball = join(env.paths.reposDir, "adversarial-bad.tar.gz");
502+
await writeFile(badTarball, "this is not a valid tarball at all");
503+
504+
const extractDir = "adversarial-test-pkg";
505+
const result = await extractPackage(badTarball, env.paths.reposDir, extractDir);
506+
507+
expect(result.success).toBe(false);
508+
// The target directory should be cleaned up after failed extraction
509+
const { existsSync } = await import("fs");
510+
expect(existsSync(join(env.paths.reposDir, extractDir))).toBe(false);
511+
});
512+
513+
test("extraction rejects archive without manifest", async () => {
514+
// Create a valid tarball but without arc-manifest.yaml
515+
const tarDir = join(env.paths.reposDir, "no-manifest-src");
516+
const { mkdir: mkdirFs } = await import("fs/promises");
517+
await mkdirFs(tarDir, { recursive: true });
518+
await writeFile(join(tarDir, "README.md"), "# No manifest here");
519+
520+
const tarball = join(env.paths.reposDir, "no-manifest.tar.gz");
521+
Bun.spawnSync(["tar", "czf", tarball, "-C", env.paths.reposDir, "no-manifest-src"], {
522+
stdout: "pipe", stderr: "pipe",
523+
});
524+
525+
const result = await extractPackage(tarball, env.paths.reposDir, "no-manifest-pkg");
526+
expect(result.success).toBe(false);
527+
expect(result.error).toContain("arc-manifest.yaml");
528+
});
529+
});

0 commit comments

Comments
 (0)