Store: crawl generations as the sync cursor. Diffs compare generations
on entity identity plus a content digest, never on upstream timestamps —
GET /course-rooms/{id}/board returns request time as updatedAt for most
elements, so a timestamp cursor would report every board as changed on
every crawl. Identity diffing also yields deletions, which no timestamp
scheme can. A per-course crawl carries the other courses' rows forward
so every completed generation is a complete picture and any two diff
directly; without that a partial crawl reads as a mass deletion.
FTS uses the german dictionary with weighted title/body, plus a pg_trgm
arm because stemming will not match "Datenschutz" inside
"Datenschutzgrundverordnung" and German compounds make that the common
case. file_texts is keyed by file record id and deliberately outlives
generations: records are immutable upstream, so text extracted once is
valid forever and a re-crawl of unchanged content costs nothing.
Store.open returns undefined instead of throwing when Postgres is
unreachable — the index is an accelerator, and a Pi that loses its
database should get slower, not broken.
core/paths.ts is the security boundary for the mirror. Course titles,
card titles and filenames are all user-supplied upstream, so this is
where a hostile name stops being text and becomes a path. Two bugs found
by its own tests: "///" produced "---" instead of falling back, and dot
runs survived mid-component. Now no ".." can survive anywhere, which
makes the invariant checkable rather than a claim about ordering.
Indexer coalesces concurrent refreshes onto one run and enforces a
minimum interval, since a full crawl is ~270 requests from an account
that looks like a student.
9 store tests against a real Postgres (mocks would test nothing here)
and 13 path tests; 47 total.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
83 lines
2.8 KiB
TypeScript
83 lines
2.8 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { describe, it } from 'node:test';
|
|
import { mirrorPath, resolveWithin, safeComponent } from '../src/core/paths.ts';
|
|
|
|
describe('safeComponent', () => {
|
|
it('keeps ordinary German titles intact', () => {
|
|
assert.equal(safeComponent('Verschlüsselung & Sicherheit'), 'Verschlüsselung & Sicherheit');
|
|
});
|
|
|
|
it('neutralises separators rather than escaping them', () => {
|
|
assert.equal(safeComponent('a/b\\c'), 'a-b-c');
|
|
});
|
|
|
|
it('defuses traversal in every shape', () => {
|
|
assert.equal(safeComponent('..'), 'untitled');
|
|
assert.equal(safeComponent('../../etc/passwd'), 'etc-passwd');
|
|
assert.equal(safeComponent('...hidden'), 'hidden');
|
|
});
|
|
|
|
it('never leaves a ".." anywhere in the result', () => {
|
|
for (const evil of ['..', '../..', 'a/../b', '....', '.. .. ..']) {
|
|
assert.ok(!safeComponent(evil).includes('..'), `".." survived in ${evil}`);
|
|
}
|
|
});
|
|
|
|
it('strips control characters and NUL', () => {
|
|
assert.equal(safeComponent('a\u0000b\u001fc'), 'abc');
|
|
});
|
|
|
|
it('avoids Windows reserved names and trailing-dot collisions', () => {
|
|
assert.equal(safeComponent('CON'), '_CON');
|
|
assert.equal(safeComponent('report.'), 'report');
|
|
});
|
|
|
|
it('falls back when nothing survives', () => {
|
|
assert.equal(safeComponent('///', 'fallback'), 'fallback');
|
|
assert.equal(safeComponent(' '), 'untitled');
|
|
});
|
|
|
|
it('truncates long names but keeps the extension', () => {
|
|
const out = safeComponent('x'.repeat(300) + '.pdf');
|
|
assert.ok(out.length <= 100);
|
|
assert.ok(out.endsWith('.pdf'));
|
|
});
|
|
});
|
|
|
|
describe('mirrorPath', () => {
|
|
it('builds a course/container/card/name path', () => {
|
|
const path = mirrorPath(
|
|
{ courseId: 'c', courseTitle: 'Mathe', containerTitle: 'Board 1', cardTitle: 'Karte' },
|
|
'blatt.pdf',
|
|
'f1',
|
|
);
|
|
assert.equal(path, 'Mathe/Board 1/Karte/blatt.pdf');
|
|
});
|
|
|
|
it('omits missing breadcrumb levels', () => {
|
|
assert.equal(mirrorPath({ courseId: 'c', courseTitle: 'Mathe' }, 'a.pdf', 'f1'), 'Mathe/a.pdf');
|
|
});
|
|
|
|
it('cannot be made to escape via any component', () => {
|
|
const path = mirrorPath(
|
|
{ courseId: 'c', courseTitle: '../..', containerTitle: '/etc', cardTitle: '..' },
|
|
'../../.ssh/authorized_keys',
|
|
'f1',
|
|
);
|
|
assert.ok(!path.includes('..'), `escaped: ${path}`);
|
|
assert.ok(!path.startsWith('/'), `absolute: ${path}`);
|
|
});
|
|
});
|
|
|
|
describe('resolveWithin', () => {
|
|
it('resolves a normal relative path under the root', () => {
|
|
assert.equal(resolveWithin('/mirror', 'Mathe/a.pdf'), '/mirror/Mathe/a.pdf');
|
|
});
|
|
|
|
it('refuses absolute paths, traversal and drive letters', () => {
|
|
assert.throws(() => resolveWithin('/mirror', '/etc/passwd'), /absolute/);
|
|
assert.throws(() => resolveWithin('/mirror', 'a/../../etc'), /traversal/);
|
|
assert.throws(() => resolveWithin('/mirror', 'C:/windows'), /absolute/);
|
|
});
|
|
});
|