Skip to content

Commit e582882

Browse files
committed
Bound preview dimensions and reject dangling SQLite sidecars
1 parent 58cad78 commit e582882

7 files changed

Lines changed: 41 additions & 3 deletions

File tree

‎docs/implementation/read-only-review.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,5 @@ Review round 4 had no inline findings but raised two summary concerns. Both were
4848
Review round 5 found a symlinked-ancestor escape and raw-spelling collisions in planting guards. Both regression cases failed before the fix. Demo creation/reuse now checks every ancestor (canonicalizing only the OS temporary-directory prefix); planting compares canonical identities for declared files and every path in the base/commit trees before cloning. The summary's status-styling concern had no additional concrete example; accepted cards already use the explicit label added in round 4.
4949

5050
Review round 6 reproduced destination-inside-source mutation; planting now resolves the existing destination ancestor and rejects canonical source descendants before creating files, including `.git` and symlink aliases. The Git-environment finding was declined: `gitRaw` already passes `{cwd, env, ...}` to `execFileSync`. A new end-to-end test with inherited `GIT_DIR`/`GIT_WORK_TREE` passed before any production change. The summary's snapshot-race claim supplied no concrete interleaving; the service already checks revision/snapshot/review version before publishing a view and writes use atomic store CAS, covered by concurrent-write tests.
51+
52+
Review round 7 reproduced oversized-dimension raster previews and dangling SQLite sidecar symlinks. Preview metadata is now parsed from the bounded buffer with image-size: at most 8,192 pixels per side, 4 million pixels per image and 16 million pixels across unique preview blobs. Unknown dimensions omit the preview; existing compressed/response byte limits remain. Sidecars use lstat so dangling links are rejected. Both new regressions failed before the fixes. The summary mentioned rename-scope/literal-path concerns without specific findings; no additional behavior was inferred from that shorthand.

‎git/history.ts‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { imageSize } from 'image-size';
12
import { execFileSync } from 'node:child_process';
23
import { lstatSync, opendirSync } from 'node:fs';
34
import { resolve as resolvePath, join } from 'node:path';
@@ -90,7 +91,7 @@ export function readHistory(repo: string, baseRef: string, headRef = 'HEAD', lim
9091
});
9192
if (expectedParent !== head) throw new Error('The base must be an ancestor of the head.');
9293
const blobs = new Map<string, { text: string | null; byteSize: number; preview?: string }>();
93-
let previewBytes = 0;
94+
let previewBytes = 0, previewPixels = 0;
9495
const version = (oid: string, mode: string): FileVersion | null => {
9596
if (/^0+$/.test(oid)) return null;
9697
if (mode === '160000') return { oid, mode, text: null }; // gitlink is not a local blob
@@ -108,7 +109,14 @@ export function readHistory(repo: string, baseRef: string, headRef = 'HEAD', lim
108109
/^GIF8[79]a$/.test(data.subarray(0,6).toString('ascii')) ? 'image/gif' :
109110
data.subarray(0,4).toString('ascii') === 'RIFF' && data.subarray(8,12).toString('ascii') === 'WEBP' ? 'image/webp' : null;
110111
if (mime && data.length <= 1024 * 1024 && previewBytes + data.length <= 4 * 1024 * 1024) {
111-
preview = `data:${mime};base64,${data.toString('base64')}`; previewBytes += data.length;
112+
try {
113+
// Parse metadata from the already bounded buffer; never allocate decoded pixels.
114+
const { width, height } = imageSize(data), pixels = width * height;
115+
if (Number.isSafeInteger(pixels) && width > 0 && height > 0 && width <= 8192 && height <= 8192 && pixels <= 4_000_000 && previewPixels + pixels <= 16_000_000) {
116+
preview = `data:${mime};base64,${data.toString('base64')}`;
117+
previewBytes += data.length; previewPixels += pixels;
118+
}
119+
} catch { /* Unknown or malformed dimensions: metadata card only. */ }
112120
}
113121
blobs.set(oid, { text, byteSize: data.length, ...(preview ? { preview } : {}) });
114122
}

‎package-lock.json‎

Lines changed: 13 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"@fontsource/ibm-plex-sans": "5.3.0",
1919
"ajv": "8.20.0",
2020
"diff": "9.0.0",
21+
"image-size": "2.0.4",
2122
"yaml": "2.9.1"
2223
},
2324
"devDependencies": {

‎scripts/demo.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ export function createDemo(directory: string): ReviewConfig {
3030
const config = JSON.parse(readFileSync(configPath, 'utf8')) as ReviewConfig;
3131
if (config.demo !== true || config.repository !== join(root, 'retry-service') || config.database !== join(root, 'review.sqlite')) throw new Error('Demo configuration must refer to its own fixture.');
3232
check(config.repository, true); check(join(config.repository, '.git'), true); check(config.database, false);
33-
for (const suffix of ['-wal', '-shm']) if (existsSync(config.database + suffix)) check(config.database + suffix, false);
33+
for (const suffix of ['-wal', '-shm']) if (lstatSync(config.database + suffix, { throwIfNoEntry: false })) check(config.database + suffix, false);
3434
return config;
3535
}
3636
if (existsSync(root)) throw new Error('Demo directory exists without a configuration. Choose a new empty path.');

‎test/demo.test.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,10 @@ it('rejects symlinked ancestors before creating a demo', () => {
4848
const base = root(); symlinkSync(base, join(base, 'alias'));
4949
expect(() => createDemo(join(base, 'alias', 'nested', 'demo'))).toThrow(/fixture/i);
5050
});
51+
it('rejects dangling SQLite sidecar symlinks', () => {
52+
const base=root(),directory=join(base,'demo');const config=createDemo(directory);
53+
for(const suffix of ['-wal','-shm']) {
54+
const path=config.database+suffix;rmSync(path,{force:true});symlinkSync(join(base,'missing'+suffix),path);
55+
expect(()=>createDemo(directory)).toThrow(/fixture/i);rmSync(path);
56+
}
57+
});

‎test/history.test.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,3 +328,10 @@ it('rejects shallow parent rewriting before loading history', () => {
328328
f.write('.git/shallow', `${head}\n`);
329329
expect(() => readHistory(f.dir, f.base, head)).toThrow(/shallow/i);
330330
});
331+
it('omits raster previews with oversized declared dimensions', () => {
332+
const f=fixture(); const base=f.git('rev-parse','HEAD');
333+
const image=Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/lXsAAAAASUVORK5CYII=','base64');
334+
image.writeUInt32BE(100000,16);image.writeUInt32BE(100000,20);
335+
f.write('oversized.png',image);const head=f.commit();
336+
expect(readHistory(f.dir,base,head).final.find(file=>file.newPath==='oversized.png')?.after?.preview).toBeUndefined();
337+
});

0 commit comments

Comments
 (0)