coverage-check 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,26 +2,46 @@ import { mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import type { SuiteMeta } from "./types.mts";
4
4
 
5
+ export function assertSafePathComponent(value: string, label: string): void {
6
+ if (
7
+ typeof value !== "string" ||
8
+ value.length === 0 ||
9
+ value === "." ||
10
+ value === ".." ||
11
+ value.includes("/") ||
12
+ value.includes("\\")
13
+ ) {
14
+ throw new Error(`invalid ${label}: ${JSON.stringify(value)}`);
15
+ }
16
+ }
17
+
5
18
  export type { SuiteMeta };
6
19
 
7
20
  export interface SuiteStore {
8
21
  /** Returns all suite names currently in the store. */
9
22
  list(): Promise<string[]>;
10
- /** Returns the merged LCOV bytes for a suite, or null if absent. */
11
- get(suite: string): Promise<Buffer | null>;
12
- /** Stores the merged LCOV bytes for a suite, with optional metadata. */
13
- put(suite: string, lcov: Buffer, meta?: SuiteMeta): Promise<void>;
23
+ /**
24
+ * Returns the merged LCOV bytes for a suite, or null if absent.
25
+ * Resolves by sha if opts.sha is set; otherwise follows the branch pointer
26
+ * (opts.branch, defaulting to "main").
27
+ */
28
+ get(suite: string, opts?: { sha?: string; branch?: string }): Promise<Buffer | null>;
29
+ /** Stores the merged LCOV bytes for a suite. sha and branch are required. */
30
+ put(
31
+ suite: string,
32
+ lcov: Buffer,
33
+ meta: SuiteMeta & { sha: string; branch: string },
34
+ ): Promise<void>;
14
35
  }
15
36
 
16
37
  /**
17
38
  * Filesystem-backed SuiteStore.
18
39
  *
19
40
  * Layout:
20
- * <root>/<suite>/lcov.info merged LCOV text
21
- * <root>/<suite>/meta.json — { sha?, ref?, timestamp }
41
+ * <root>/<suite>/sha/<sha>/lcov.info — LCOV payload
42
+ * <root>/<suite>/branch/<branch>/latest.json — { sha, timestamp }
22
43
  *
23
- * Transport is the caller's responsibility (e.g. git orphan branch, S3 sync,
24
- * GitHub Actions cache). This class only reads/writes local files.
44
+ * Transport is the caller's responsibility (e.g. S3 sync, git orphan branch).
25
45
  */
26
46
  export class FileSystemSuiteStore implements SuiteStore {
27
47
  private readonly root: string;
@@ -40,23 +60,53 @@ export class FileSystemSuiteStore implements SuiteStore {
40
60
  }
41
61
  }
42
62
 
43
- async get(suite: string): Promise<Buffer | null> {
44
- const path = join(this.root, suite, "lcov.info");
63
+ async get(suite: string, opts?: { sha?: string; branch?: string }): Promise<Buffer | null> {
64
+ assertSafePathComponent(suite, "suite");
65
+ if (opts?.sha !== undefined) assertSafePathComponent(opts.sha, "sha");
66
+ let sha = opts?.sha;
67
+ if (!sha) {
68
+ const branch = opts?.branch ?? "main";
69
+ assertSafePathComponent(branch, "branch");
70
+ const pointerPath = join(this.root, suite, "branch", branch, "latest.json");
71
+ try {
72
+ const pointer = JSON.parse(readFileSync(pointerPath, "utf8")) as { sha: string };
73
+ assertSafePathComponent(pointer.sha, "sha");
74
+ sha = pointer.sha;
75
+ } catch (err) {
76
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
77
+ throw err;
78
+ }
79
+ }
80
+ const lcovPath = join(this.root, suite, "sha", sha, "lcov.info");
45
81
  try {
46
- return readFileSync(path);
82
+ return readFileSync(lcovPath);
47
83
  } catch (err) {
48
84
  if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
49
85
  throw err;
50
86
  }
51
87
  }
52
88
 
53
- async put(suite: string, lcov: Buffer, meta: SuiteMeta = {}): Promise<void> {
54
- const dir = join(this.root, suite);
55
- mkdirSync(dir, { recursive: true });
56
- writeFileSync(join(dir, "lcov.info"), lcov);
89
+ async put(
90
+ suite: string,
91
+ lcov: Buffer,
92
+ meta: SuiteMeta & { sha: string; branch: string },
93
+ ): Promise<void> {
94
+ assertSafePathComponent(suite, "suite");
95
+ assertSafePathComponent(meta.sha, "sha");
96
+ assertSafePathComponent(meta.branch, "branch");
97
+ const shaDir = join(this.root, suite, "sha", meta.sha);
98
+ mkdirSync(shaDir, { recursive: true });
99
+ writeFileSync(join(shaDir, "lcov.info"), lcov);
100
+
101
+ const branchDir = join(this.root, suite, "branch", meta.branch);
102
+ mkdirSync(branchDir, { recursive: true });
57
103
  writeFileSync(
58
- join(dir, "meta.json"),
59
- JSON.stringify({ ...meta, timestamp: meta.timestamp ?? new Date().toISOString() }, null, 2),
104
+ join(branchDir, "latest.json"),
105
+ JSON.stringify(
106
+ { sha: meta.sha, timestamp: meta.timestamp ?? new Date().toISOString() },
107
+ null,
108
+ 2,
109
+ ),
60
110
  );
61
111
  }
62
112
  }
@@ -2,7 +2,7 @@ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { tmpdir } from "node:os";
4
4
  import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
- import { FileSystemSuiteStore } from "./suite-store.mts";
5
+ import { assertSafePathComponent, FileSystemSuiteStore } from "./suite-store.mts";
6
6
 
7
7
  describe("FileSystemSuiteStore", () => {
8
8
  let tmpDir: string;
@@ -28,8 +28,14 @@ describe("FileSystemSuiteStore", () => {
28
28
  });
29
29
 
30
30
  it("returns suite names after putting suites", async () => {
31
- await store.put("backend", Buffer.from("SF:foo\nend_of_record\n"));
32
- await store.put("frontend", Buffer.from("SF:bar\nend_of_record\n"));
31
+ await store.put("backend", Buffer.from("SF:foo\nend_of_record\n"), {
32
+ sha: "abc",
33
+ branch: "main",
34
+ });
35
+ await store.put("frontend", Buffer.from("SF:bar\nend_of_record\n"), {
36
+ sha: "def",
37
+ branch: "main",
38
+ });
33
39
  const suites = await store.list();
34
40
  expect(suites).toContain("backend");
35
41
  expect(suites).toContain("frontend");
@@ -37,8 +43,10 @@ describe("FileSystemSuiteStore", () => {
37
43
  });
38
44
 
39
45
  it("ignores non-directory entries in the root", async () => {
40
- await store.put("backend", Buffer.from("SF:foo\nend_of_record\n"));
41
- // The lcov.info inside backend dir should not appear as a suite
46
+ await store.put("backend", Buffer.from("SF:foo\nend_of_record\n"), {
47
+ sha: "abc",
48
+ branch: "main",
49
+ });
42
50
  const suites = await store.list();
43
51
  expect(suites).not.toContain("lcov.info");
44
52
  expect(suites).not.toContain("meta.json");
@@ -51,65 +59,151 @@ describe("FileSystemSuiteStore", () => {
51
59
  });
52
60
 
53
61
  describe("get()", () => {
54
- it("returns null for a missing suite", async () => {
62
+ it("returns null for a missing suite (no pointer file)", async () => {
55
63
  expect(await store.get("nonexistent")).toBeNull();
56
64
  });
57
65
 
58
- it("returns the LCOV buffer after put()", async () => {
66
+ it("returns null when get() is called with explicit sha that has no lcov file", async () => {
67
+ // Pointer exists (from a different sha), but requested sha is absent
68
+ await store.put("backend", Buffer.from("SF:backend/foo.mts\nDA:1,1\nend_of_record\n"), {
69
+ sha: "abc",
70
+ branch: "main",
71
+ });
72
+ expect(await store.get("backend", { sha: "nonexistent-sha" })).toBeNull();
73
+ });
74
+
75
+ it("returns the LCOV buffer after put() via branch pointer", async () => {
59
76
  const lcov = Buffer.from("SF:backend/foo.mts\nDA:1,1\nend_of_record\n");
60
- await store.put("backend", lcov);
77
+ await store.put("backend", lcov, { sha: "abc123", branch: "main" });
61
78
  const result = await store.get("backend");
62
79
  expect(result).not.toBeNull();
63
80
  expect(result!.toString()).toBe(lcov.toString());
64
81
  });
65
82
 
66
- it("rethrows non-ENOENT errors from readFileSync", async () => {
83
+ it("returns the LCOV buffer when get() is called with explicit sha", async () => {
84
+ const lcov = Buffer.from("SF:backend/foo.mts\nDA:1,1\nend_of_record\n");
85
+ await store.put("backend", lcov, { sha: "abc123", branch: "main" });
86
+ const result = await store.get("backend", { sha: "abc123" });
87
+ expect(result).not.toBeNull();
88
+ expect(result!.toString()).toBe(lcov.toString());
89
+ });
90
+
91
+ it("resolves the correct sha via the branch pointer", async () => {
92
+ const lcovV1 = Buffer.from("SF:backend/v1.mts\nDA:1,1\nend_of_record\n");
93
+ const lcovV2 = Buffer.from("SF:backend/v2.mts\nDA:2,1\nend_of_record\n");
94
+ await store.put("backend", lcovV1, { sha: "sha1", branch: "main" });
95
+ await store.put("backend", lcovV2, { sha: "sha2", branch: "main" });
96
+ // Branch pointer now points to sha2; sha1 still exists on disk
97
+ const result = await store.get("backend", { branch: "main" });
98
+ expect(result!.toString()).toBe(lcovV2.toString());
99
+ });
100
+
101
+ it("rethrows non-ENOENT errors from pointer readFileSync", async () => {
67
102
  const badStore = new FileSystemSuiteStore("\0invalid");
68
103
  await expect(badStore.get("suite")).rejects.toThrow();
69
104
  });
105
+
106
+ it("rethrows non-ENOENT errors from lcov readFileSync when sha is explicit", async () => {
107
+ const badStore = new FileSystemSuiteStore("\0invalid");
108
+ await expect(badStore.get("suite", { sha: "abc" })).rejects.toThrow();
109
+ });
70
110
  });
71
111
 
72
112
  describe("put()", () => {
73
- it("writes lcov.info and meta.json", async () => {
113
+ it("writes lcov.info under sha/ and latest.json under branch/", async () => {
74
114
  const lcov = Buffer.from("SF:foo.mts\nDA:1,5\nend_of_record\n");
75
- await store.put("backend", lcov, { sha: "abc123", ref: "refs/heads/main" });
115
+ await store.put("backend", lcov, { sha: "abc123", branch: "main" });
76
116
 
77
- const lcovPath = join(tmpDir, "backend", "lcov.info");
78
- const metaPath = join(tmpDir, "backend", "meta.json");
117
+ const lcovPath = join(tmpDir, "backend", "sha", "abc123", "lcov.info");
118
+ const pointerPath = join(tmpDir, "backend", "branch", "main", "latest.json");
79
119
  expect(readFileSync(lcovPath).toString()).toBe(lcov.toString());
80
120
 
81
- const meta = JSON.parse(readFileSync(metaPath, "utf8"));
82
- expect(meta.sha).toBe("abc123");
83
- expect(meta.ref).toBe("refs/heads/main");
84
- expect(typeof meta.timestamp).toBe("string");
121
+ const pointer = JSON.parse(readFileSync(pointerPath, "utf8"));
122
+ expect(pointer.sha).toBe("abc123");
123
+ expect(typeof pointer.timestamp).toBe("string");
85
124
  });
86
125
 
87
- it("uses the provided timestamp in meta.json", async () => {
88
- await store.put("backend", Buffer.from(""), { timestamp: "2026-01-01T00:00:00.000Z" });
89
- const meta = JSON.parse(readFileSync(join(tmpDir, "backend", "meta.json"), "utf8"));
90
- expect(meta.timestamp).toBe("2026-01-01T00:00:00.000Z");
126
+ it("uses the provided timestamp", async () => {
127
+ await store.put("backend", Buffer.from(""), {
128
+ sha: "abc",
129
+ branch: "main",
130
+ timestamp: "2026-01-01T00:00:00.000Z",
131
+ });
132
+ const pointer = JSON.parse(
133
+ readFileSync(join(tmpDir, "backend", "branch", "main", "latest.json"), "utf8"),
134
+ );
135
+ expect(pointer.timestamp).toBe("2026-01-01T00:00:00.000Z");
91
136
  });
92
137
 
93
138
  it("creates a default timestamp when none is provided", async () => {
94
139
  const before = new Date().toISOString();
95
- await store.put("backend", Buffer.from(""));
96
- const meta = JSON.parse(readFileSync(join(tmpDir, "backend", "meta.json"), "utf8"));
140
+ await store.put("backend", Buffer.from(""), { sha: "abc", branch: "main" });
97
141
  const after = new Date().toISOString();
98
- expect(meta.timestamp >= before).toBe(true);
99
- expect(meta.timestamp <= after).toBe(true);
142
+ const pointer = JSON.parse(
143
+ readFileSync(join(tmpDir, "backend", "branch", "main", "latest.json"), "utf8"),
144
+ );
145
+ expect(pointer.timestamp >= before).toBe(true);
146
+ expect(pointer.timestamp <= after).toBe(true);
100
147
  });
101
148
 
102
149
  it("creates parent directories recursively", async () => {
103
150
  const nested = new FileSystemSuiteStore(join(tmpDir, "deep", "nested", "store"));
104
- await nested.put("backend", Buffer.from("SF:foo\nend_of_record\n"));
151
+ await nested.put("backend", Buffer.from("SF:foo\nend_of_record\n"), {
152
+ sha: "abc",
153
+ branch: "main",
154
+ });
105
155
  expect(await nested.list()).toContain("backend");
106
156
  });
107
157
 
108
- it("overwrites an existing suite", async () => {
109
- await store.put("backend", Buffer.from("v1"));
110
- await store.put("backend", Buffer.from("v2"));
111
- const result = await store.get("backend");
158
+ it("overwrites an existing suite when same sha is used", async () => {
159
+ await store.put("backend", Buffer.from("v1"), { sha: "abc", branch: "main" });
160
+ await store.put("backend", Buffer.from("v2"), { sha: "abc", branch: "main" });
161
+ const result = await store.get("backend", { sha: "abc" });
112
162
  expect(result!.toString()).toBe("v2");
113
163
  });
164
+
165
+ it("keeps multiple sha entries independently", async () => {
166
+ await store.put("backend", Buffer.from("v1"), { sha: "sha1", branch: "main" });
167
+ await store.put("backend", Buffer.from("v2"), { sha: "sha2", branch: "main" });
168
+ expect((await store.get("backend", { sha: "sha1" }))!.toString()).toBe("v1");
169
+ expect((await store.get("backend", { sha: "sha2" }))!.toString()).toBe("v2");
170
+ });
171
+ });
172
+
173
+ describe("path traversal protection", () => {
174
+ const invalid = ["", ".", "..", "a/b", "a\\b"];
175
+ for (const val of invalid) {
176
+ it(`get() rejects suite=${JSON.stringify(val)}`, async () => {
177
+ await expect(store.get(val)).rejects.toThrow("invalid suite");
178
+ });
179
+ it(`get() rejects branch=${JSON.stringify(val)}`, async () => {
180
+ await expect(store.get("backend", { branch: val })).rejects.toThrow("invalid branch");
181
+ });
182
+ it(`get() rejects sha=${JSON.stringify(val)}`, async () => {
183
+ await expect(store.get("backend", { sha: val })).rejects.toThrow("invalid sha");
184
+ });
185
+ it(`put() rejects suite=${JSON.stringify(val)}`, async () => {
186
+ await expect(
187
+ store.put(val, Buffer.from(""), { sha: "abc", branch: "main" }),
188
+ ).rejects.toThrow("invalid suite");
189
+ });
190
+ it(`put() rejects sha=${JSON.stringify(val)}`, async () => {
191
+ await expect(
192
+ store.put("backend", Buffer.from(""), { sha: val, branch: "main" }),
193
+ ).rejects.toThrow("invalid sha");
194
+ });
195
+ it(`put() rejects branch=${JSON.stringify(val)}`, async () => {
196
+ await expect(
197
+ store.put("backend", Buffer.from(""), { sha: "abc", branch: val }),
198
+ ).rejects.toThrow("invalid branch");
199
+ });
200
+ }
201
+ });
202
+ });
203
+
204
+ describe("assertSafePathComponent", () => {
205
+ it("rejects non-string values at runtime (e.g. from JSON.parse)", () => {
206
+ expect(() => assertSafePathComponent(123 as unknown as string, "sha")).toThrow("invalid sha");
207
+ expect(() => assertSafePathComponent(null as unknown as string, "sha")).toThrow("invalid sha");
114
208
  });
115
209
  });
package/src/types.mts CHANGED
@@ -38,6 +38,6 @@ export type CoverageCheckResult = {
38
38
 
39
39
  export type SuiteMeta = {
40
40
  sha?: string;
41
- ref?: string;
41
+ branch?: string;
42
42
  timestamp?: string;
43
43
  };