|
| 1 | +/** |
| 2 | + * Implementation tests for the disk asset reader: byte identity, the |
| 3 | + * stat-before-read size cap, and lexical + canonical (symlink-aware) |
| 4 | + * project-root containment — all against the real filesystem in a temp |
| 5 | + * tree. |
| 6 | + */ |
| 7 | + |
| 8 | +import { afterAll, beforeAll, describe, expect, it } from "vitest"; |
| 9 | +import { |
| 10 | + chmodSync, |
| 11 | + mkdirSync, |
| 12 | + mkdtempSync, |
| 13 | + rmSync, |
| 14 | + symlinkSync, |
| 15 | + writeFileSync, |
| 16 | +} from "node:fs"; |
| 17 | +import { tmpdir } from "node:os"; |
| 18 | +import { join } from "node:path"; |
| 19 | +import { realpathSync } from "node:fs"; |
| 20 | +import { createDiskAssetReader } from "./asset-reader.js"; |
| 21 | +import type { AssetReader } from "@blend65/core"; |
| 22 | + |
| 23 | +let base: string; |
| 24 | +let projectRoot: string; |
| 25 | +let sourcePath: string; |
| 26 | +let reader: AssetReader; |
| 27 | + |
| 28 | +const SRC_ID = 1; |
| 29 | + |
| 30 | +beforeAll(() => { |
| 31 | + base = mkdtempSync(join(tmpdir(), "blend65-asset-")); |
| 32 | + projectRoot = join(base, "proj"); |
| 33 | + mkdirSync(join(projectRoot, "src"), { recursive: true }); |
| 34 | + sourcePath = join(projectRoot, "src", "main.blend"); |
| 35 | + writeFileSync(sourcePath, "module Main;\n"); |
| 36 | + reader = createDiskAssetReader({ |
| 37 | + sources: new Map([[SRC_ID, sourcePath]]), |
| 38 | + projectRoot, |
| 39 | + }); |
| 40 | +}); |
| 41 | + |
| 42 | +afterAll(() => { |
| 43 | + rmSync(base, { recursive: true, force: true }); |
| 44 | +}); |
| 45 | + |
| 46 | +describe("disk asset reader — bytes", () => { |
| 47 | + it("returns high-bit and NUL bytes identically (no text decoding)", () => { |
| 48 | + const bytes = Uint8Array.from([0x00, 0x41, 0x80, 0xff, 0x0d, 0x0a]); |
| 49 | + writeFileSync(join(projectRoot, "src", "raw.bin"), bytes); |
| 50 | + |
| 51 | + const result = reader.readAsset(SRC_ID, "raw.bin"); |
| 52 | + expect(result.kind).toBe("ok"); |
| 53 | + if (result.kind === "ok") { |
| 54 | + expect(Array.from(result.bytes)).toEqual(Array.from(bytes)); |
| 55 | + expect(result.resolvedPath).toBe(realpathSync(join(projectRoot, "src", "raw.bin"))); |
| 56 | + } |
| 57 | + }); |
| 58 | + |
| 59 | + it("resolves relative to the CALLING source file's directory", () => { |
| 60 | + writeFileSync(join(projectRoot, "top.bin"), Uint8Array.from([1])); |
| 61 | + // The source lives in src/, so the asset one level up needs `..` — |
| 62 | + // legal as long as it stays inside the project root. |
| 63 | + const result = reader.readAsset(SRC_ID, "../top.bin"); |
| 64 | + expect(result.kind).toBe("ok"); |
| 65 | + }); |
| 66 | +}); |
| 67 | + |
| 68 | +describe("disk asset reader — size cap", () => { |
| 69 | + it("rejects an oversized file by stat, before any read", () => { |
| 70 | + const big = join(projectRoot, "src", "big.bin"); |
| 71 | + writeFileSync(big, new Uint8Array(65537)); |
| 72 | + // Remove read permission: a read attempt would fail as not-found, so |
| 73 | + // getting too-large proves the cap fired on stat alone. |
| 74 | + chmodSync(big, 0o000); |
| 75 | + |
| 76 | + const result = reader.readAsset(SRC_ID, "big.bin"); |
| 77 | + expect(result).toEqual({ kind: "too-large", size: 65537 }); |
| 78 | + chmodSync(big, 0o644); |
| 79 | + }); |
| 80 | + |
| 81 | + it("accepts a file of exactly the cap size", () => { |
| 82 | + writeFileSync(join(projectRoot, "src", "max.bin"), new Uint8Array(65536)); |
| 83 | + expect(reader.readAsset(SRC_ID, "max.bin").kind).toBe("ok"); |
| 84 | + }); |
| 85 | +}); |
| 86 | + |
| 87 | +describe("disk asset reader — containment", () => { |
| 88 | + it("rejects a traversal escape whether or not the target exists", () => { |
| 89 | + // No ../../outside.bin exists anywhere — the lexical check needs no |
| 90 | + // filesystem access, so the rejection must still be outside-root. |
| 91 | + expect(reader.readAsset(SRC_ID, "../../outside.bin")).toEqual({ |
| 92 | + kind: "outside-root", |
| 93 | + }); |
| 94 | + }); |
| 95 | + |
| 96 | + it("rejects an inside-the-project symlink that points outside it", () => { |
| 97 | + writeFileSync(join(base, "secret.bin"), Uint8Array.from([7])); |
| 98 | + symlinkSync(join(base, "secret.bin"), join(projectRoot, "src", "link.bin")); |
| 99 | + |
| 100 | + expect(reader.readAsset(SRC_ID, "link.bin")).toEqual({ kind: "outside-root" }); |
| 101 | + }); |
| 102 | + |
| 103 | + it("rejects absolute and escape-bearing path literals as invalid", () => { |
| 104 | + expect(reader.readAsset(SRC_ID, "/etc/hostname").kind).toBe("not-found"); |
| 105 | + expect(reader.readAsset(SRC_ID, "a\\x2Eb.bin").kind).toBe("not-found"); |
| 106 | + expect(reader.readAsset(SRC_ID, "").kind).toBe("not-found"); |
| 107 | + }); |
| 108 | + |
| 109 | + it("reports an unknown source id as not-found", () => { |
| 110 | + expect(reader.readAsset(999, "raw.bin").kind).toBe("not-found"); |
| 111 | + }); |
| 112 | + |
| 113 | + it("reports a genuinely missing in-root file as not-found", () => { |
| 114 | + expect(reader.readAsset(SRC_ID, "nope.bin").kind).toBe("not-found"); |
| 115 | + }); |
| 116 | +}); |
0 commit comments