|
| 1 | +import { createHash } from "node:crypto"; |
| 2 | + |
1 | 3 | import { describe, expect, it, vi } from "vitest"; |
2 | 4 |
|
3 | 5 | import { link } from "../fs/link.js"; |
@@ -142,6 +144,103 @@ describe("applyChanges", () => { |
142 | 144 | ); |
143 | 145 | }); |
144 | 146 |
|
| 147 | + it("rejects a file entry whose interior chunks are not chunk-aligned", async () => { |
| 148 | + // Positional reads locate a chunk by dividing the offset by |
| 149 | + // CHUNK_SIZE, so only the final chunk may be short. Linking a |
| 150 | + // sender's chunk list verbatim has to enforce that. |
| 151 | + await withDB(async (db) => { |
| 152 | + const first = new TextEncoder().encode("first"); |
| 153 | + const second = new TextEncoder().encode("second"); |
| 154 | + const chunks = [first, second].map((bytes) => { |
| 155 | + const hash = new Uint8Array(createHash("sha256").update(bytes).digest()); |
| 156 | + stageBlob(db, hash, bytes, 1000); |
| 157 | + return { hash, size: bytes.byteLength }; |
| 158 | + }); |
| 159 | + |
| 160 | + await expect( |
| 161 | + applyChanges( |
| 162 | + db, |
| 163 | + [ |
| 164 | + { |
| 165 | + kind: "file", |
| 166 | + rev: 1, |
| 167 | + path: "/ragged.txt", |
| 168 | + mode: 0o644, |
| 169 | + mtime: 1000, |
| 170 | + size: first.byteLength + second.byteLength, |
| 171 | + chunks, |
| 172 | + }, |
| 173 | + ], |
| 174 | + new Map(), |
| 175 | + { source: "upstream" }, |
| 176 | + ), |
| 177 | + ).rejects.toThrow(/chunk/); |
| 178 | + expect(resolveInode(db, "/ragged.txt")).toBeNull(); |
| 179 | + }); |
| 180 | + }); |
| 181 | + |
| 182 | + it("leaves the existing file in place when it rejects a ragged entry", async () => { |
| 183 | + await withDB(async (db) => { |
| 184 | + await writeFile(db, "/keep.txt", "original", {}, () => 1000); |
| 185 | + |
| 186 | + const first = new TextEncoder().encode("first"); |
| 187 | + const second = new TextEncoder().encode("second"); |
| 188 | + const chunks = [first, second].map((bytes) => { |
| 189 | + const hash = new Uint8Array(createHash("sha256").update(bytes).digest()); |
| 190 | + stageBlob(db, hash, bytes, 1000); |
| 191 | + return { hash, size: bytes.byteLength }; |
| 192 | + }); |
| 193 | + |
| 194 | + await expect( |
| 195 | + applyChanges( |
| 196 | + db, |
| 197 | + [ |
| 198 | + { |
| 199 | + kind: "file", |
| 200 | + rev: 2, |
| 201 | + path: "/keep.txt", |
| 202 | + mode: 0o644, |
| 203 | + mtime: 2000, |
| 204 | + size: first.byteLength + second.byteLength, |
| 205 | + chunks, |
| 206 | + }, |
| 207 | + ], |
| 208 | + new Map(), |
| 209 | + { source: "upstream" }, |
| 210 | + ), |
| 211 | + ).rejects.toThrow(/chunk/); |
| 212 | + expect(await readFile(db, "/keep.txt", "utf8")).toBe("original"); |
| 213 | + }); |
| 214 | + }); |
| 215 | + |
| 216 | + it("rejects a file entry with a chunk larger than the chunk size", async () => { |
| 217 | + await withDB(async (db) => { |
| 218 | + const bytes = new Uint8Array(CHUNK_SIZE + 1); |
| 219 | + const hash = new Uint8Array(createHash("sha256").update(bytes).digest()); |
| 220 | + stageBlob(db, hash, bytes, 1000); |
| 221 | + |
| 222 | + await expect( |
| 223 | + applyChanges( |
| 224 | + db, |
| 225 | + [ |
| 226 | + { |
| 227 | + kind: "file", |
| 228 | + rev: 1, |
| 229 | + path: "/oversized.bin", |
| 230 | + mode: 0o644, |
| 231 | + mtime: 1000, |
| 232 | + size: bytes.byteLength, |
| 233 | + chunks: [{ hash, size: bytes.byteLength }], |
| 234 | + }, |
| 235 | + ], |
| 236 | + new Map(), |
| 237 | + { source: "upstream" }, |
| 238 | + ), |
| 239 | + ).rejects.toThrow(/chunk/); |
| 240 | + expect(resolveInode(db, "/oversized.bin")).toBeNull(); |
| 241 | + }); |
| 242 | + }); |
| 243 | + |
145 | 244 | it("commits in batches capped by byte budget", async () => { |
146 | 245 | // Force many small files; with a tiny byte budget the apply |
147 | 246 | // path should still converge, just across more batches. We |
|
0 commit comments