diff --git a/backend/src/main/java/org/raddatz/familienarchiv/importing/DocumentImporter.java b/backend/src/main/java/org/raddatz/familienarchiv/importing/DocumentImporter.java index 085a4ff4..ab693920 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/importing/DocumentImporter.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/importing/DocumentImporter.java @@ -28,7 +28,6 @@ import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; import java.nio.file.Files; -import java.nio.file.Path; import java.nio.file.Paths; import java.time.LocalDate; import java.time.format.DateTimeParseException; @@ -38,19 +37,23 @@ import java.util.List; import java.util.Optional; import java.util.Set; import java.util.UUID; -import java.util.stream.Stream; +import java.util.regex.Pattern; /** * Loads {@code canonical-documents.xlsx} into the document domain. Java performs no * semantic transformation: the normalizer already resolved people to slugs and dates to * ISO values. This loader maps columns by header name, routes each attribution * register-first (always retaining the raw cell in {@code sender_text}/{@code receiver_text}), - * parses clean dates, and keeps the file/S3/thumbnail plumbing. + * parses clean dates, and keeps the S3/thumbnail plumbing. * - *

The {@code file} value is hostile input regardless of upstream trust (CWE-22 does not - * care that it came from our Python tool): its basename is validated with - * {@link #isValidImportFilename} and then resolved with canonical-path containment in - * {@link #findFileRecursive}. + *

The import corpus is uniform — every PDF is named {@code .pdf} flat in the import + * dir — so a document's PDF is resolved directly by its index: + * {@code importDir.resolve(index + ".pdf")}. The {@code index} is still hostile input + * regardless of upstream trust (CWE-22 does not care it came from our Python tool): it is + * validated against a strict catalog pattern with {@link #isValidImportIndex} (no path + * separators, no {@code .}/{@code ..}, no absolute path, no slash homoglyphs) and the + * resolved path is asserted to stay inside the import dir in {@link #resolvePdfByIndex} as + * defense-in-depth. The {@code %PDF} magic-byte check still gates upload. */ @Component @RequiredArgsConstructor @@ -58,9 +61,19 @@ import java.util.stream.Stream; public class DocumentImporter { static final List REQUIRED_HEADERS = List.of( - "index", "file", "sender_person_id", "sender_name", + "index", "sender_person_id", "sender_name", "receiver_person_ids", "receiver_names", "date_iso", "date_raw", "date_precision"); + // Catalog index shape: 1–4 letters (ASCII + Latin-1 letters, e.g. the German "ü" in + // "Mü-0001"), one or more hyphens (the corpus has a few "C--0029" data-entry artefacts), + // digits, and an optional trailing "x" the normalizer recognises. Anchored, with no + // separator / dot / slash characters in the class, so ".pdf" can never traverse. + // NOTE: `\d` here is intentionally ASCII-only ([0-9]). Java's java.util.regex matches `\d` + // against [0-9] unless Pattern.UNICODE_CHARACTER_CLASS is set — do NOT add that flag, or + // Arabic-Indic / fullwidth digits would silently widen the accepted set. + private static final Pattern INDEX_PATTERN = + Pattern.compile("[A-Za-z\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u00FF]{1,4}-+\\d+x?"); + private final DocumentService documentService; private final PersonService personService; private final TagService tagService; @@ -84,12 +97,16 @@ public class DocumentImporter { List rows = CanonicalSheetReader.readRows(artifact, REQUIRED_HEADERS); int processed = 0; List skipped = new ArrayList<>(); + // 1-based source row number for ops triage breadcrumbs (the spreadsheet header is row 1, + // so the first data row is row 2 — matches what an operator sees in the .xlsx). + int rowNumber = 1; for (CanonicalSheetReader.Row row : rows) { + rowNumber++; String index = row.get("index"); if (index.isBlank()) continue; - Optional skipReason = importRow(row, index, skipped); + Optional skipReason = importRow(row, index, rowNumber); if (skipReason.isPresent()) { - skipped.add(new ImportStatus.SkippedFile(displayName(row, index), skipReason.get())); + skipped.add(new ImportStatus.SkippedFile(index, skipReason.get())); } else { processed++; } @@ -98,16 +115,24 @@ public class DocumentImporter { return new LoadResult(processed, skipped); } - private Optional importRow(CanonicalSheetReader.Row row, String index, - List skipped) { - Optional resolved; - try { - resolved = resolveFile(row.get("file")); - } catch (InvalidImportFilenameException e) { - log.warn("Skipping import row {}: filename rejected", index); + private Optional importRow(CanonicalSheetReader.Row row, String index, int rowNumber) { + if (!isValidImportIndex(index)) { + // Breadcrumb is the source row number, NOT the raw (possibly-hostile) index — an + // operator triaging the import can find the offending row in the .xlsx without us + // echoing attacker-controlled input into the log. + log.warn("Skipping import row {}: index rejected (fails catalog-shape validation)", rowNumber); return Optional.of(ImportStatus.SkipReason.INVALID_FILENAME_PATH_TRAVERSAL); } - if (resolved.isPresent()) { + Optional resolved = resolvePdfByIndex(index, rowNumber); + if (resolved.isEmpty()) { + // Distinct from the "index rejected" skip above: the index is VALID but no + // .pdf is on disk, so the row becomes a normal PLACEHOLDER (not skipped). The + // index is a validated catalog id (no hostile content), so it is safe to log here — + // this surfaces a corpus that drifts from the ".pdf" assumption (e.g. a file + // that arrived under a different name) rather than dropping it silently. + log.info("Import row {}: index {} is valid but {}.pdf is absent — creating PLACEHOLDER", + rowNumber, index, index); + } else { try { if (!isPdfMagicBytes(resolved.get())) { return Optional.of(ImportStatus.SkipReason.INVALID_PDF_SIGNATURE); @@ -257,21 +282,6 @@ public class DocumentImporter { // ─── file handling + S3 (small ≤20-line methods) ───────────────────────────────── - private Optional resolveFile(String fileColumn) { - if (fileColumn == null || fileColumn.isBlank()) return Optional.empty(); - String basename = basenameOf(fileColumn); - if (!isValidImportFilename(basename)) { - throw new InvalidImportFilenameException(); - } - return findFileRecursive(basename); - } - - private static String basenameOf(String fileColumn) { - String normalized = fileColumn.replace('\\', '/'); - int lastSlash = normalized.lastIndexOf('/'); - return lastSlash < 0 ? normalized.trim() : normalized.substring(lastSlash + 1).trim(); - } - private String probeContentType(File file) { try { String probed = Files.probeContentType(file.toPath()); @@ -290,20 +300,23 @@ public class DocumentImporter { RequestBody.fromFile(file)); } - // ─── security guards — ported verbatim from MassImportService — do not weaken ──── + // ─── index validation + containment — defense-in-depth, do not weaken ──────────── - private boolean isValidImportFilename(String filename) { - if (filename == null || filename.isBlank()) return false; - if (filename.contains("/")) return false; - if (filename.contains("\\")) return false; - if (filename.contains("∕")) return false; // U+2215 DIVISION SLASH - if (filename.contains("/")) return false; // U+FF0F FULLWIDTH SOLIDUS - if (filename.contains("⧵")) return false; // U+29F5 REVERSE SOLIDUS OPERATOR - if (filename.contains("..")) return false; - if (filename.equals(".")) return false; - if (filename.contains("\0")) return false; - if (Paths.get(filename).isAbsolute()) return false; - return true; + // The index is the only thing that drives the on-disk lookup, so it must never contain a + // path separator, traversal token, slash homoglyph, null byte, or absolute-path marker — + // each guard mirrors the filename guards ported from MassImportService — and it must match + // the strict catalog shape so anything unexpected is skipped loudly rather than read. + private boolean isValidImportIndex(String index) { + if (index == null || index.isBlank()) return false; + if (index.contains("/")) return false; + if (index.contains("\\")) return false; + if (index.contains("∕")) return false; // U+2215 DIVISION SLASH + if (index.contains("/")) return false; // U+FF0F FULLWIDTH SOLIDUS + if (index.contains("⧵")) return false; // U+29F5 REVERSE SOLIDUS OPERATOR + if (index.contains(".")) return false; // no dots — ".pdf" is the only extension + if (index.contains("\0")) return false; + if (Paths.get(index).isAbsolute()) return false; + return INDEX_PATTERN.matcher(index).matches(); } // package-private: a Mockito spy in tests can override to inject IOException @@ -322,33 +335,30 @@ public class DocumentImporter { } } - private Optional findFileRecursive(String filename) { + // O(1) direct lookup: the PDF is exactly importDir/.pdf. The caller has already + // validated the index shape; the canonical-path containment assertion below is + // defense-in-depth so even a symlinked .pdf cannot read outside importDir. + private Optional resolvePdfByIndex(String index, int rowNumber) { File baseDir = new File(importDir); - try (Stream walk = Files.walk(baseDir.toPath())) { - Optional match = walk.filter(p -> !Files.isDirectory(p)) - .filter(p -> p.getFileName().toString().equals(filename)) - .findFirst(); - if (match.isEmpty()) return Optional.empty(); - File candidate = match.get().toFile(); + File candidate = baseDir.toPath().resolve(index + ".pdf").toFile(); + try { + if (!candidate.isFile()) return Optional.empty(); String baseDirCanonical = baseDir.getCanonicalPath(); if (!candidate.getCanonicalPath().startsWith(baseDirCanonical + File.separator)) { throw DomainException.internal(ErrorCode.INTERNAL_ERROR, "Path escape detected: " + candidate); } return Optional.of(candidate); } catch (IOException e) { + // Distinct from the deliberate symlink-escape abort above (which throws): canonical + // resolution itself failed (e.g. the OS rejected the path mid-resolution). We fail + // safe to a PLACEHOLDER, but never silently — log it so the asymmetry surfaces in ops. + log.warn("Canonical path resolution failed for import row {}: treating {}.pdf as absent", + rowNumber, index, e); return Optional.empty(); } } - private static String displayName(CanonicalSheetReader.Row row, String index) { - String file = row.get("file"); - return file.isBlank() ? index : basenameOf(file); - } - private static String blankToNull(String s) { return (s == null || s.isBlank()) ? null : s; } - - private static final class InvalidImportFilenameException extends RuntimeException { - } } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/importing/CanonicalImportIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/importing/CanonicalImportIntegrationTest.java index bd2b8d8f..090ffe31 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/importing/CanonicalImportIntegrationTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/importing/CanonicalImportIntegrationTest.java @@ -140,12 +140,12 @@ class CanonicalImportIntegrationTest { // Re-stage the document sheet with W-0001's receiver and tag removed. writeSheet(artifactDir.resolve("canonical-documents.xlsx"), - List.of("index", "file", "sender_person_id", "sender_name", "receiver_person_ids", + List.of("index", "sender_person_id", "sender_name", "receiver_person_ids", "receiver_names", "date_iso", "date_raw", "date_precision", "date_end", "location", "tags", "summary"), List.of( - List.of("W-0001", "", "de-gruyter-walter", "Walter de Gruyter", + List.of("W-0001", "de-gruyter-walter", "Walter de Gruyter", "", "", "1888-02-15", "15.2.1888", "DAY", "", "Rotterdam", "", "Geschäftsreise"), - List.of("W-0002", "", "de-gruyter-eugenie", "Eugenie de Gruyter", + List.of("W-0002", "de-gruyter-eugenie", "Eugenie de Gruyter", "de-gruyter-walter", "Walter de Gruyter", "1888-02-16", "16.2.1888", "DAY", "", "Middelburg", "Themen/Brautbriefe", "Reisepläne"))); @@ -196,13 +196,13 @@ class CanonicalImportIntegrationTest { """); writeSheet(dir.resolve("canonical-documents.xlsx"), - List.of("index", "file", "sender_person_id", "sender_name", "receiver_person_ids", + List.of("index", "sender_person_id", "sender_name", "receiver_person_ids", "receiver_names", "date_iso", "date_raw", "date_precision", "date_end", "location", "tags", "summary"), List.of( - List.of("W-0001", "", "de-gruyter-walter", "Walter de Gruyter", + List.of("W-0001", "de-gruyter-walter", "Walter de Gruyter", "de-gruyter-eugenie", "Eugenie de Gruyter", "1888-02-15", "15.2.1888", "DAY", "", "Rotterdam", "Themen/Brautbriefe", "Geschäftsreise"), - List.of("W-0002", "", "de-gruyter-eugenie", "Eugenie de Gruyter", + List.of("W-0002", "de-gruyter-eugenie", "Eugenie de Gruyter", "de-gruyter-walter", "Walter de Gruyter", "1888-02-16", "16.2.1888", "DAY", "", "Middelburg", "Themen/Brautbriefe", "Reisepläne"))); } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/importing/DocumentImporterTest.java b/backend/src/test/java/org/raddatz/familienarchiv/importing/DocumentImporterTest.java index 99d7bd5c..f136b0e0 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/importing/DocumentImporterTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/importing/DocumentImporterTest.java @@ -58,99 +58,179 @@ class DocumentImporterTest { ReflectionTestUtils.setField(importer, "bucketName", "test-bucket"); } - // ─── security regression — ported from MassImportServiceTest — do not remove ───── + // ─── index validation — a malicious/garbage index can never reach disk I/O ───────── @Test - void isValidImportFilename_returnsFalse_whenNull() { - assertThat((Boolean) ReflectionTestUtils.invokeMethod(importer, "isValidImportFilename", (String) null)).isFalse(); + void isValidImportIndex_returnsFalse_whenNull() { + assertThat(validIndex(null)).isFalse(); } @Test - void isValidImportFilename_returnsFalse_whenBlank() { - assertThat((Boolean) ReflectionTestUtils.invokeMethod(importer, "isValidImportFilename", " ")).isFalse(); + void isValidImportIndex_returnsFalse_whenBlank() { + assertThat(validIndex(" ")).isFalse(); } @Test - void isValidImportFilename_returnsFalse_whenForwardSlash() { - assertThat((Boolean) ReflectionTestUtils.invokeMethod(importer, "isValidImportFilename", "etc/passwd")).isFalse(); + void isValidImportIndex_returnsFalse_whenForwardSlash() { + assertThat(validIndex("etc/passwd")).isFalse(); } @Test - void isValidImportFilename_returnsFalse_whenBackslash() { - assertThat((Boolean) ReflectionTestUtils.invokeMethod(importer, "isValidImportFilename", "..\\etc\\passwd")).isFalse(); + void isValidImportIndex_returnsFalse_whenBackslash() { + assertThat(validIndex("..\\etc\\passwd")).isFalse(); } @Test - void isValidImportFilename_returnsFalse_whenDotDot() { - assertThat((Boolean) ReflectionTestUtils.invokeMethod(importer, "isValidImportFilename", "doc..evil.pdf")).isFalse(); + void isValidImportIndex_returnsFalse_whenDotDot() { + assertThat(validIndex("W-..0001")).isFalse(); } @Test - void isValidImportFilename_returnsFalse_whenIsDotDot() { - assertThat((Boolean) ReflectionTestUtils.invokeMethod(importer, "isValidImportFilename", "..")).isFalse(); + void isValidImportIndex_returnsFalse_whenIsDotDot() { + assertThat(validIndex("..")).isFalse(); } @Test - void isValidImportFilename_returnsFalse_whenAbsolutePath() { - assertThat((Boolean) ReflectionTestUtils.invokeMethod(importer, "isValidImportFilename", "/etc/passwd")).isFalse(); + void isValidImportIndex_returnsFalse_whenSingleDot() { + assertThat(validIndex(".")).isFalse(); } @Test - void isValidImportFilename_returnsFalse_whenNullByte() { - assertThat((Boolean) ReflectionTestUtils.invokeMethod(importer, "isValidImportFilename", "file\0.pdf")).isFalse(); + void isValidImportIndex_returnsFalse_whenAbsolutePath() { + assertThat(validIndex("/etc/passwd")).isFalse(); } @Test - void isValidImportFilename_returnsFalse_whenUnicodeDivisionSlash() { - assertThat((Boolean) ReflectionTestUtils.invokeMethod(importer, "isValidImportFilename", "foo∕bar.pdf")).isFalse(); + void isValidImportIndex_returnsFalse_whenNullByte() { + assertThat(validIndex("W-0001\0")).isFalse(); } @Test - void isValidImportFilename_returnsFalse_whenFullwidthSlash() { - assertThat((Boolean) ReflectionTestUtils.invokeMethod(importer, "isValidImportFilename", "foo/bar.pdf")).isFalse(); + void isValidImportIndex_returnsFalse_whenUnicodeDivisionSlash() { + assertThat(validIndex("W∕0001")).isFalse(); } @Test - void isValidImportFilename_returnsFalse_whenReverseSolidusOperator() { - assertThat((Boolean) ReflectionTestUtils.invokeMethod(importer, "isValidImportFilename", "foo⧵bar.pdf")).isFalse(); + void isValidImportIndex_returnsFalse_whenFullwidthSlash() { + assertThat(validIndex("W/0001")).isFalse(); } @Test - void isValidImportFilename_returnsTrue_whenPlainBasename() { - assertThat((Boolean) ReflectionTestUtils.invokeMethod(importer, "isValidImportFilename", "document.pdf")).isTrue(); + void isValidImportIndex_returnsFalse_whenReverseSolidusOperator() { + assertThat(validIndex("W⧵0001")).isFalse(); } @Test - void isValidImportFilename_returnsTrue_whenLeadingDot() { - assertThat((Boolean) ReflectionTestUtils.invokeMethod(importer, "isValidImportFilename", ".hidden.pdf")).isTrue(); + void isValidImportIndex_returnsFalse_whenContainsDotPdfExtension() { + // The index is the bare catalog id; appending ".pdf" is the importer's job. A dot in + // the index would let "W-0001.pdf" become "W-0001.pdf.pdf" or smuggle an extension. + assertThat(validIndex("W-0001.pdf")).isFalse(); + } + + // ─── catalog-shape rejects — pass the char pre-checks but must fail INDEX_PATTERN ──── + // These pin the regex branch itself: each string contains no separator, dot, slash + // homoglyph, null byte, or absolute marker, so it sails past every char guard and is + // rejected *only* because INDEX_PATTERN.matches() returns false. A weaker pattern would + // let them through — these tests would then go red. + + @Test + void isValidImportIndex_returnsFalse_whenSpaceInIndex() { + // The real-world reject: "J 0070" is a space-typo with no PDF on disk. + assertThat(validIndex("J 0070")).isFalse(); } @Test - void isValidImportFilename_returnsTrue_whenHasSpaces() { - assertThat((Boolean) ReflectionTestUtils.invokeMethod(importer, "isValidImportFilename", "Brief an Oma.pdf")).isTrue(); + void isValidImportIndex_returnsFalse_whenFiveLetterPrefix() { + // The catalog prefix is at most 4 letters; 5 must not match. + assertThat(validIndex("WXYZA-0001")).isFalse(); } @Test - void findFileRecursive_throwsDomainException_whenSymlinkEscapesImportDir( - @TempDir Path importDirPath, @TempDir Path outsideDir) throws Exception { - Path outsideFile = outsideDir.resolve("secret.pdf"); - Files.writeString(outsideFile, "sensitive"); - Files.createSymbolicLink(importDirPath.resolve("secret.pdf"), outsideFile); - ReflectionTestUtils.setField(importer, "importDir", importDirPath.toString()); - - org.assertj.core.api.Assertions.assertThatThrownBy( - () -> ReflectionTestUtils.invokeMethod(importer, "findFileRecursive", "secret.pdf")) - .isInstanceOf(org.raddatz.familienarchiv.exception.DomainException.class); + void isValidImportIndex_returnsFalse_whenNoLetterPrefix() { + // A digit-led id (no letter prefix) is not a catalog shape. + assertThat(validIndex("12-0001")).isFalse(); } - // ─── path traversal in the file column cannot escape importDir ─────────────────── + @Test + void isValidImportIndex_returnsFalse_whenUppercaseXSuffix() { + // Only a lowercase trailing "x" is allowed; an uppercase "X" suffix must fail. + assertThat(validIndex("W-0001X")).isFalse(); + } @Test - void load_rejectsFileColumn_whenBasenameIsTraversalToken(@TempDir Path tempDir) throws Exception { - // A file column whose basename is itself a traversal token must be rejected - // outright, never used for disk I/O. + void isValidImportIndex_returnsTrue_whenPlainCatalogIndex() { + assertThat(validIndex("W-0124")).isTrue(); + } + + @Test + void isValidImportIndex_returnsTrue_whenTwoLetterPrefix() { + assertThat(validIndex("Al-0001")).isTrue(); + } + + @Test + void isValidImportIndex_returnsTrue_whenThreeLetterPrefix() { + assertThat(validIndex("CuH-0010")).isTrue(); + } + + @Test + void isValidImportIndex_returnsTrue_whenUmlautPrefix() { + // Real corpus indices carry a German umlaut, e.g. "Mü-0001.pdf" exists on disk. + assertThat(validIndex("Mü-0001")).isTrue(); + } + + @Test + void isValidImportIndex_returnsTrue_whenDoubleHyphen() { + // Real corpus: "C--0029" appears in the spreadsheet (a data-entry artefact, but a + // legitimate catalog shape that must still resolve, not crash). + assertThat(validIndex("C--0029")).isTrue(); + } + + @Test + void isValidImportIndex_returnsTrue_whenXSuffix() { + // The normalizer recognises an x-suffix catalog id; allow it defensively. + assertThat(validIndex("W-0001x")).isTrue(); + } + + // ─── a valid index resolves to exactly importDir/.pdf within containment ───── + + @Test + void load_resolvesPdfByIndex_uploadsToS3_andSetsStatusUploaded(@TempDir Path tempDir) throws Exception { ReflectionTestUtils.setField(importer, "importDir", tempDir.toString()); - Path xlsx = writeDocs(tempDir, docRow("W-0001", "evil/..", "", "", "", "", "", "", "", "")); + byte[] pdf = {0x25, 0x50, 0x44, 0x46, 0x2D}; + Files.write(tempDir.resolve("W-0124.pdf"), pdf); + when(documentService.findByOriginalFilename("W-0124")).thenReturn(Optional.empty()); + when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0)); + Path xlsx = writeDocs(tempDir, docRow("W-0124", "", "", "", "", "", "", "", "")); + + importer.load(xlsx.toFile()); + + // exactly importDir/.pdf was uploaded — the S3 key carries that basename + org.mockito.ArgumentCaptor bodyCaptor = org.mockito.ArgumentCaptor.forClass(RequestBody.class); + verify(s3Client).putObject(any(PutObjectRequest.class), bodyCaptor.capture()); + verify(documentService).save(org.mockito.ArgumentMatchers.argThat(d -> + d.getStatus() == DocumentStatus.UPLOADED + && d.getFilePath() != null + && d.getFilePath().endsWith("_W-0124.pdf"))); + } + + @Test + void load_yieldsPlaceholder_whenIndexedPdfMissing(@TempDir Path tempDir) throws Exception { + ReflectionTestUtils.setField(importer, "importDir", tempDir.toString()); + when(documentService.findByOriginalFilename("X-9999")).thenReturn(Optional.empty()); + when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0)); + Path xlsx = writeDocs(tempDir, docRow("X-9999", "", "", "", "", "", "", "", "")); + + importer.load(xlsx.toFile()); + + verify(documentService).save(org.mockito.ArgumentMatchers.argThat(d -> d.getStatus() == DocumentStatus.PLACEHOLDER)); + verify(s3Client, never()).putObject(any(PutObjectRequest.class), any(RequestBody.class)); + } + + @Test + void load_rejectsMaliciousIndex_neverReadsOutsideImportDir(@TempDir Path tempDir) throws Exception { + // An index with a path separator must be skipped outright, never used for disk I/O. + ReflectionTestUtils.setField(importer, "importDir", tempDir.toString()); + Path xlsx = writeDocs(tempDir, docRow("../../etc/cron.d/x", "", "", "", "", "", "", "", "")); DocumentImporter.LoadResult result = importer.load(xlsx.toFile()); @@ -158,24 +238,49 @@ class DocumentImporterTest { .extracting(ImportStatus.SkippedFile::reason) .containsExactly(ImportStatus.SkipReason.INVALID_FILENAME_PATH_TRAVERSAL); verify(documentService, never()).save(any()); + verify(s3Client, never()).putObject(any(PutObjectRequest.class), any(RequestBody.class)); } @Test - void load_traversalFileColumn_cannotEscapeImportDir_yieldsPlaceholder(@TempDir Path tempDir) throws Exception { - // ../../etc/cron.d/x reduces to basename "x"; the disk lookup is confined to - // importDir, so no file is found, nothing is uploaded, and the row becomes a - // metadata-only PLACEHOLDER — the file outside importDir is never read. - ReflectionTestUtils.setField(importer, "importDir", tempDir.toString()); - when(documentService.findByOriginalFilename("W-0001")).thenReturn(Optional.empty()); - when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0)); - Path xlsx = writeDocs(tempDir, docRow("W-0001", "../../etc/cron.d/x", "", "", "", "", "", "", "", "")); + void resolvePdfByIndex_throwsWhenResolvedPathEscapesImportDir_viaSymlink( + @TempDir Path importDirPath, @TempDir Path outsideDir) throws Exception { + // Containment defense-in-depth: even a syntactically valid index whose .pdf is a + // symlink pointing outside importDir must be refused — the resolved canonical path is + // asserted to stay inside importDir. + Path outsideFile = outsideDir.resolve("secret.pdf"); + Files.writeString(outsideFile, "sensitive"); + Files.createSymbolicLink(importDirPath.resolve("W-0001.pdf"), outsideFile); + ReflectionTestUtils.setField(importer, "importDir", importDirPath.toString()); - importer.load(xlsx.toFile()); - - verify(s3Client, never()).putObject(any(PutObjectRequest.class), any(RequestBody.class)); - verify(documentService).save(org.mockito.ArgumentMatchers.argThat(d -> d.getStatus() == DocumentStatus.PLACEHOLDER)); + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> ReflectionTestUtils.invokeMethod(importer, "resolvePdfByIndex", "W-0001", 2)) + .isInstanceOf(org.raddatz.familienarchiv.exception.DomainException.class); } + @Test + void resolvePdfByIndex_returnsExactlyImportDirIndexPdf_whenPresent(@TempDir Path tempDir) throws Exception { + ReflectionTestUtils.setField(importer, "importDir", tempDir.toString()); + Path expected = tempDir.resolve("Eu-0628.pdf"); + Files.writeString(expected, "%PDF-1.4"); + + Optional resolved = ReflectionTestUtils.invokeMethod(importer, "resolvePdfByIndex", "Eu-0628", 2); + + assertThat(resolved).isPresent(); + assertThat(resolved.get().getCanonicalFile()).isEqualTo(expected.toFile().getCanonicalFile()); + } + + // NOTE (Sara, PR #687): the IOException branch of resolvePdfByIndex — where + // File.getCanonicalPath() itself throws (an OS-level failure mid-resolution, not the + // symlink-escape DomainException) — is intentionally NOT covered by a test. Unlike + // isPdfMagicBytes, which has the package-private openFileStream(File) seam a Mockito spy can + // make throw, getCanonicalPath() is called on a File built internally with no injection seam, + // and there is no portable, deterministic way to make it throw on a temp file (it does not + // throw for missing/symlinked paths — those are handled by isFile()/the containment check). + // Adding a seam purely to test this would be production code in service of a non-defect; the + // substantive fix is the log.warn() now emitted in that branch so the quiet skip surfaces in + // ops. Left uncovered by deliberate decision, documented here so the branch is not assumed + // tested. + // ─── PDF magic-byte guard — ported — do not remove ────────────────────────────── @Test @@ -183,7 +288,7 @@ class DocumentImporterTest { ReflectionTestUtils.setField(importer, "importDir", tempDir.toString()); Files.writeString(tempDir.resolve("W-0001.pdf"), "not a pdf"); lenient().when(documentService.findByOriginalFilename(any())).thenReturn(Optional.empty()); - Path xlsx = writeDocs(tempDir, docRow("W-0001", "..\\__scan\\W-0001.pdf", "", "", "", "", "", "", "", "")); + Path xlsx = writeDocs(tempDir, docRow("W-0001", "", "", "", "", "", "", "", "")); DocumentImporter.LoadResult result = importer.load(xlsx.toFile()); @@ -198,7 +303,7 @@ class DocumentImporterTest { ReflectionTestUtils.setField(importer, "importDir", tempDir.toString()); Files.writeString(tempDir.resolve("W-0001.pdf"), "content"); lenient().when(documentService.findByOriginalFilename(any())).thenReturn(Optional.empty()); - Path xlsx = writeDocs(tempDir, docRow("W-0001", "..\\__scan\\W-0001.pdf", "", "", "", "", "", "", "", "")); + Path xlsx = writeDocs(tempDir, docRow("W-0001", "", "", "", "", "", "", "", "")); DocumentImporter spyImporter = org.mockito.Mockito.spy(importer); org.mockito.Mockito.doThrow(new java.io.IOException("read error")) @@ -217,7 +322,7 @@ class DocumentImporterTest { Document existing = Document.builder().id(UUID.randomUUID()) .originalFilename("W-0001").status(DocumentStatus.UPLOADED).build(); when(documentService.findByOriginalFilename("W-0001")).thenReturn(Optional.of(existing)); - Path xlsx = writeDocs(tempDir, docRow("W-0001", "", "", "", "", "", "", "", "", "")); + Path xlsx = writeDocs(tempDir, docRow("W-0001", "", "", "", "", "", "", "", "")); DocumentImporter.LoadResult result = importer.load(xlsx.toFile()); @@ -227,29 +332,14 @@ class DocumentImporterTest { verify(documentService, never()).save(any()); } - // ─── file column drives status: present → UPLOADED, empty → PLACEHOLDER ─────────── + // ─── presence of importDir/.pdf drives status: present → UPLOADED, absent → PLACEHOLDER ─ @Test - void load_uploadsToS3_andSetsStatusUploaded_whenFilePresent(@TempDir Path tempDir) throws Exception { - ReflectionTestUtils.setField(importer, "importDir", tempDir.toString()); - byte[] pdf = {0x25, 0x50, 0x44, 0x46, 0x2D}; - Files.write(tempDir.resolve("W-0001.pdf"), pdf); - when(documentService.findByOriginalFilename("W-0001")).thenReturn(Optional.empty()); - when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0)); - Path xlsx = writeDocs(tempDir, docRow("W-0001", "..\\__scan\\W-0001.pdf", "", "", "", "", "", "", "", "")); - - importer.load(xlsx.toFile()); - - verify(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class)); - verify(documentService).save(org.mockito.ArgumentMatchers.argThat(d -> d.getStatus() == DocumentStatus.UPLOADED)); - } - - @Test - void load_setsStatusPlaceholder_whenFileColumnEmpty(@TempDir Path tempDir) throws Exception { + void load_setsStatusPlaceholder_whenNoIndexedPdf(@TempDir Path tempDir) throws Exception { ReflectionTestUtils.setField(importer, "importDir", tempDir.toString()); when(documentService.findByOriginalFilename("W-0099")).thenReturn(Optional.empty()); when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0)); - Path xlsx = writeDocs(tempDir, docRow("W-0099", "", "", "", "", "", "", "", "", "")); + Path xlsx = writeDocs(tempDir, docRow("W-0099", "", "", "", "", "", "", "", "")); importer.load(xlsx.toFile()); @@ -267,7 +357,7 @@ class DocumentImporterTest { when(documentService.findByOriginalFilename("W-0001")).thenReturn(Optional.empty()); when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0)); when(personService.findBySourceRef("de-gruyter-walter")).thenReturn(Optional.of(walter)); - Path xlsx = writeDocs(tempDir, docRow("W-0001", "", "de-gruyter-walter", "Walter de Gruyter", + Path xlsx = writeDocs(tempDir, docRow("W-0001", "de-gruyter-walter", "Walter de Gruyter", "", "", "", "", "", "")); importer.load(xlsx.toFile()); @@ -285,7 +375,7 @@ class DocumentImporterTest { when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0)); when(personService.findBySourceRef("schwester-hanni")).thenReturn(Optional.empty()); when(personService.upsertBySourceRef(any())).thenReturn(provisional); - Path xlsx = writeDocs(tempDir, docRow("W-0002", "", "schwester-hanni", "Schwester Hanni", + Path xlsx = writeDocs(tempDir, docRow("W-0002", "schwester-hanni", "Schwester Hanni", "", "", "", "", "", "")); importer.load(xlsx.toFile()); @@ -302,7 +392,7 @@ class DocumentImporterTest { ReflectionTestUtils.setField(importer, "importDir", tempDir.toString()); when(documentService.findByOriginalFilename("W-0003")).thenReturn(Optional.empty()); when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0)); - Path xlsx = writeDocs(tempDir, docRow("W-0003", "", "", "?", + Path xlsx = writeDocs(tempDir, docRow("W-0003", "", "?", "", "", "", "", "", "")); importer.load(xlsx.toFile()); @@ -322,7 +412,7 @@ class DocumentImporterTest { when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0)); when(personService.findBySourceRef("cram-herbert")).thenReturn(Optional.of(herbert)); when(personService.findBySourceRef("clara")).thenReturn(Optional.of(clara)); - Path xlsx = writeDocs(tempDir, docRow("W-0004", "", "", "", + Path xlsx = writeDocs(tempDir, docRow("W-0004", "", "", "cram-herbert|clara", "Herbert Cram|Clara", "", "", "", "")); importer.load(xlsx.toFile()); @@ -341,7 +431,7 @@ class DocumentImporterTest { ReflectionTestUtils.setField(importer, "importDir", tempDir.toString()); when(documentService.findByOriginalFilename("W-0005")).thenReturn(Optional.empty()); when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0)); - Path xlsx = writeDocs(tempDir, docRow("W-0005", "", "", "", + Path xlsx = writeDocs(tempDir, docRow("W-0005", "", "", "", "", "1916-06-01", "1.6.1916", "MONTH", "")); importer.load(xlsx.toFile()); @@ -375,7 +465,7 @@ class DocumentImporterTest { .originalFilename("W-0007").status(DocumentStatus.PLACEHOLDER).build(); when(documentService.findByOriginalFilename("W-0007")).thenReturn(Optional.of(existing)); when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0)); - Path xlsx = writeDocs(tempDir, docRow("W-0007", "", "", "", "", "", "", "", "", "")); + Path xlsx = writeDocs(tempDir, docRow("W-0007", "", "", "", "", "", "", "", "")); importer.load(xlsx.toFile()); @@ -396,7 +486,7 @@ class DocumentImporterTest { when(documentService.findByOriginalFilename("W-0008")).thenReturn(Optional.of(existing)); when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0)); // The canonical row now carries no receiver and no tag: both stale links must go. - Path xlsx = writeDocs(tempDir, docRow("W-0008", "", "", "", "", "", "", "", "", "")); + Path xlsx = writeDocs(tempDir, docRow("W-0008", "", "", "", "", "", "", "", "")); importer.load(xlsx.toFile()); @@ -411,7 +501,7 @@ class DocumentImporterTest { ReflectionTestUtils.setField(importer, "importDir", tempDir.toString()); when(documentService.findByOriginalFilename("W-0100")).thenReturn(Optional.empty()); when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0)); - Path xlsx = writeDocs(tempDir, docRow("W-0100", "", "", "", "", "", + Path xlsx = writeDocs(tempDir, docRow("W-0100", "", "", "", "", "1916-06-01", "Juni 1916", "MONTH", "")); importer.load(xlsx.toFile()); @@ -425,7 +515,7 @@ class DocumentImporterTest { ReflectionTestUtils.setField(importer, "importDir", tempDir.toString()); when(documentService.findByOriginalFilename("W-0101")).thenReturn(Optional.empty()); when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0)); - Path xlsx = writeDocs(tempDir, docRow("W-0101", "", "", "", "", "", + Path xlsx = writeDocs(tempDir, docRow("W-0101", "", "", "", "", "1943-12-24", "24.12.1943", "DAY", "")); importer.load(xlsx.toFile()); @@ -439,7 +529,7 @@ class DocumentImporterTest { ReflectionTestUtils.setField(importer, "importDir", tempDir.toString()); when(documentService.findByOriginalFilename("W-0102")).thenReturn(Optional.empty()); when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0)); - Path xlsx = writeDocs(tempDir, docRow("W-0102", "", "", "", "", "", + Path xlsx = writeDocs(tempDir, docRow("W-0102", "", "", "", "", "", "?", "UNKNOWN", "")); importer.load(xlsx.toFile()); @@ -450,12 +540,15 @@ class DocumentImporterTest { // ─── helpers ───────────────────────────────────────────────────────────────────── - private Map docRow(String index, String file, String senderId, String senderName, + private Boolean validIndex(String index) { + return ReflectionTestUtils.invokeMethod(importer, "isValidImportIndex", index); + } + + private Map docRow(String index, String senderId, String senderName, String receiverIds, String receiverNames, String dateIso, String dateRaw, String datePrecision, String dateEnd) { Map r = new LinkedHashMap<>(); r.put("index", index); - r.put("file", file); r.put("sender_person_id", senderId); r.put("sender_name", senderName); r.put("receiver_person_ids", receiverIds); @@ -471,7 +564,7 @@ class DocumentImporterTest { } private Map docRowWithTag(String index, String tagPath) { - Map r = docRow(index, "", "", "", "", "", "", "", "", ""); + Map r = docRow(index, "", "", "", "", "", "", "", ""); r.put("tags", tagPath); return r; } @@ -479,7 +572,7 @@ class DocumentImporterTest { @SafeVarargs private Path writeDocs(Path dir, Map... rows) throws Exception { Path xlsx = dir.resolve("canonical-documents.xlsx"); - List headers = List.of("index", "file", "sender_person_id", "sender_name", + List headers = List.of("index", "sender_person_id", "sender_name", "receiver_person_ids", "receiver_names", "date_iso", "date_raw", "date_precision", "date_end", "location", "tags", "summary"); try (XSSFWorkbook wb = new XSSFWorkbook()) { diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index cdae6581..70e78f0d 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -26,8 +26,10 @@ # MAIL_HOST, MAIL_PORT, SMTP relay (production only; staging uses mailpit) # MAIL_USERNAME, MAIL_PASSWORD # APP_MAIL_FROM sender address (e.g. noreply@raddatz.cloud) -# IMPORT_HOST_DIR absolute host path holding ONLY the ODS -# spreadsheet and PDFs for /admin/system mass +# IMPORT_HOST_DIR absolute host path holding the canonical +# import artifacts (canonical-*.xlsx + +# canonical-persons-tree.json) and the +# .pdf files for /admin/system # import — mounted read-only at /import inside # the backend. Compose refuses to start when # this var is unset, so staging and prod cannot @@ -217,12 +219,13 @@ services: # Bound to localhost only — Caddy fronts external traffic. ports: - "127.0.0.1:${PORT_BACKEND}:8080" - # Host path holding the ODS spreadsheet + PDFs for the mass-import endpoint. - # Read-only; MassImportService only reads (Files.list / Files.walk on /import). + # Host path holding the canonical import artifacts (canonical-*.xlsx + + # canonical-persons-tree.json) + .pdf files for the import endpoint. + # Read-only; the canonical importer only reads them from /import. # Required — no default — so staging and prod cannot accidentally share an # import source. CI workflows pin this per-env (see .gitea/workflows/). volumes: - - ${IMPORT_HOST_DIR:?Set IMPORT_HOST_DIR to a host path holding the mass-import payload (ODS + PDFs). See docs/DEPLOYMENT.md.}:/import:ro + - ${IMPORT_HOST_DIR:?Set IMPORT_HOST_DIR to a host path holding the import payload (canonical artifacts + .pdf files). See docs/DEPLOYMENT.md.}:/import:ro environment: SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/archiv SPRING_DATASOURCE_USERNAME: archiv diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index de071a43..5bc46261 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -65,7 +65,7 @@ Members of the cross-cutting layer have no entity of their own, no user-facing C | `dashboard` | Stats aggregation for the admin dashboard and Family Pulse widget | Aggregates from 3+ domains; no owned entities | | `exception` | `DomainException`, `ErrorCode` enum, `GlobalExceptionHandler` | Framework infra; consumed by every controller and service. Adding a new `ErrorCode` requires matching updates in `frontend/src/lib/shared/errors.ts` and all three `messages/*.json` locale files. Current security-related codes: `CSRF_TOKEN_MISSING` (403 on mutating request without valid `X-XSRF-TOKEN` header), `TOO_MANY_LOGIN_ATTEMPTS` (429 when login rate limit exceeded). | | `filestorage` | `FileService` — MinIO/S3 upload, download, presigned-URL generation | Generic service; consumed by `document` and `ocr` | -| `importing` | `MassImportService` — async ODS/Excel batch import | Orchestrates across `person`, `tag`, `document` | +| `importing` | `CanonicalImportOrchestrator` — async canonical import running four idempotent loaders (`TagTreeImporter` → `PersonRegisterImporter` → `PersonTreeImporter` → `DocumentImporter`) over the normalizer's committed canonical artifacts (`canonical-*.xlsx` + `canonical-persons-tree.json`) | Orchestrates across `person`, `tag`, `document` | | `security` | `SecurityConfig`, `Permission` enum, `@RequirePermission` annotation, `PermissionAspect` (AOP) | Framework infra; enforced globally across all controllers | **Frontend `shared/`** follows the same admission criteria. Key members: `api.server.ts` (typed openapi-fetch client factory), `errors.ts` (backend `ErrorCode` → i18n mapping), `shared/primitives/` (generic UI components used across ≥2 domains), `shared/discussion/` (comment/mention editor used by `document` and `geschichte`), `shared/utils/` (pure date/sort/debounce utilities). diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 0e1fe07e..2e79481e 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -99,7 +99,7 @@ All vars are set in `.env` at the repo root (copy from `.env.example`). The back | `APP_BASE_URL` | Public-facing URL for email links | `http://localhost:3000` | YES (prod) | — | | `APP_OCR_BASE_URL` | Internal URL of the OCR service | — | YES | — | | `APP_OCR_TRAINING_TOKEN` | Secret token for OCR training endpoints | — | YES (prod) | YES | -| `IMPORT_HOST_DIR` | Absolute host path holding the ODS spreadsheet + PDFs for the `/admin/system` mass-import card. Mounted read-only at `/import` inside the backend (compose-only — backend reads via `app.import.dir`). Compose refuses to start when unset, so staging and prod cannot accidentally share the source. Convention: `/srv/familienarchiv-staging/import` and `/srv/familienarchiv-production/import` | — | YES (prod compose) | — | +| `IMPORT_HOST_DIR` | Absolute host path holding the normalizer's canonical artifacts (`canonical-{documents,persons,tag-tree}.xlsx` + `canonical-persons-tree.json`) **plus the `.pdf` files** for the `/admin/system` import. Mounted read-only at `/import` inside the backend (the canonical importer reads via `app.import.dir`). Compose refuses to start when unset, so staging and prod cannot accidentally share the source. Convention: `/srv/familienarchiv-staging/import` and `/srv/familienarchiv-production/import` | — | YES (prod compose) | — | | `MAIL_HOST` | SMTP host | `mailpit` (dev) | YES (prod) | — | | `MAIL_PORT` | SMTP port | `1025` (dev) | YES (prod) | — | | `MAIL_USERNAME` | SMTP username | — | YES (prod) | YES | @@ -577,10 +577,15 @@ python3 -m venv .venv && .venv/bin/pip install -r requirements.txt # once, on # writes the four canonical artifacts into ./out/ ``` -**Dev:** place all four canonical artifacts **plus** the referenced PDFs into `./import/` +**Dev:** place all four canonical artifacts **plus** the PDFs into `./import/` at the repo root (the dev compose bind-mounts it to `/import`, which is `app.import.dir`). -The orchestrator smoke-checks that all four artifacts are present before starting and fails -closed (`IMPORT_ARTIFACT_INVALID`) if any is missing. +Each PDF must be named `.pdf` (e.g. `W-0124.pdf`, `Mü-0001.pdf`) and live flat in the +import dir: since #686 the importer resolves a document's PDF directly by its index +(`importDir/.pdf`), not via a `datei`/`file` column — the recursive directory walk and +its basename/homoglyph guards are gone, replaced by strict index validation plus a +canonical-path containment assertion (a document whose `.pdf` is absent simply becomes a +`PLACEHOLDER`). The orchestrator smoke-checks that all four artifacts are present before +starting and fails closed (`IMPORT_ARTIFACT_INVALID`) if any is missing. **Staging/production:** diff --git a/docs/TODO-backend.md b/docs/TODO-backend.md index 7b03f802..e09f47e9 100644 --- a/docs/TODO-backend.md +++ b/docs/TODO-backend.md @@ -94,17 +94,6 @@ The schema includes `spring_session` and `spring_session_attributes` tables, but --- -### `MassImportService` provides no status or error feedback -**File:** `service/MassImportService.java`, `controller/AdminController.java` - -`/api/admin/trigger-import` returns immediately (async), but there is no way for the admin to know whether the import succeeded, failed, or is still running. Errors during async execution are silently swallowed. - -**Fix options:** -- Store import job status in a DB table (`import_jobs`) with state (`RUNNING`, `DONE`, `FAILED`) and expose a `GET /api/admin/import-status` endpoint -- Alternatively, make the endpoint synchronous since it already blocks on file I/O — only use async if you need true non-blocking behaviour - ---- - ## Missing Capabilities ### No test coverage @@ -114,7 +103,7 @@ The only test is a Spring context load test. No unit or integration tests exist **Suggested starting points (highest value for effort):** 1. `DocumentSpecifications` — pure logic, easy to unit test with an in-memory H2 or Testcontainers PostgreSQL -2. `ExcelService` — parsing logic, test with fixture `.xlsx` files (one exists in `api_tests/`) +2. Canonical import loaders (`CanonicalSheetReader`, `DocumentImporter`, etc.) — parsing/upsert logic, test with fixture canonical `.xlsx` files 3. `PermissionAspect` — security logic should be tested; use `@WithMockUser` from Spring Security Test --- diff --git a/docs/adr/025-canonical-import-and-single-migration-schema-foundation.md b/docs/adr/025-canonical-import-and-single-migration-schema-foundation.md index 3515413b..27c34092 100644 --- a/docs/adr/025-canonical-import-and-single-migration-schema-foundation.md +++ b/docs/adr/025-canonical-import-and-single-migration-schema-foundation.md @@ -102,12 +102,23 @@ Settled sub-decisions: - **`provisional` is now populated.** Importer-minted persons are `provisional = true`; register and tree persons stay `false`. This is the Phase-3 contract the schema (decision 1) left at default-`false`. -- **Security guards are defense-in-depth, not upstream-trust.** The `file` column is treated as - hostile (CWE-22 does not care it came from our tool): its basename is validated - (`isValidImportFilename` — slash/backslash, three Unicode slash homoglyphs, `..`, null byte, - absolute path) and resolved only inside the import dir with canonical-path containment, so a - traversal value can never escape. The `%PDF` magic-byte check gates upload. These guards and - their tests were ported from `MassImportService` **before** it was deleted. +- **PDFs resolve directly by index (`.pdf`), not by a `file` column.** The corpus is + uniform — all PDFs are named `.pdf` flat in the import dir (e.g. `W-0124.pdf`, + `Mü-0001.pdf`) — so `DocumentImporter` resolves a document's PDF with an O(1) + `importDir.resolve(index + ".pdf")` lookup. The redundant `file` column (carrying the + spreadsheet's messy `datei` value) and the recursive directory walk that resolved it were + removed (#686, which also closed #676 — the O(rows×tree) walk is gone). The normalizer no + longer emits `file` or the `index_file_mismatch` review flag. +- **Security guards are defense-in-depth, not upstream-trust.** The `index` is the only thing + that drives the on-disk lookup, so it is treated as hostile (CWE-22 does not care it came from + our tool): `isValidImportIndex` rejects slash/backslash, three Unicode slash homoglyphs, any + `.` (so `.pdf` is the only extension and `..` can never appear), null byte, and + absolute paths, and requires a strict catalog shape (1–4 Latin letters incl. umlauts, one or + more hyphens, digits, optional trailing `x`). A bad index skips the row with a clear + `SkipReason` (`INVALID_FILENAME_PATH_TRAVERSAL`). The resolved canonical path is still asserted + to stay inside the import dir as a second line of defense (a symlinked `.pdf` cannot + escape), and the `%PDF` magic-byte check still gates upload. These guards and their tests were + ported from the file-column resolution (originally from `MassImportService`). --- @@ -138,11 +149,25 @@ Settled sub-decisions: the same state, so the operational recovery for a partial failure is simply to fix the offending artifact and re-trigger the import — no manual cleanup of half-written data is required. A future maintainer must not assume all-or-nothing semantics. -- **Path-escape aborts the whole import (fail-closed), by design.** A path-traversal or - symlink-escape in a row's file path is treated as an attack signal: the import aborts rather - than recording the row as a `SkippedFile` and continuing. This is a deliberate owner decision - (2026-05-27) over a per-file skip — a malicious path must surface loudly, not be silently - tolerated. +- **The index pattern is corpus-specific and must be revisited if the catalog scheme grows.** + `INDEX_PATTERN` accepts only the *current* corpus shape — at most four Latin-1 letters (incl. + umlauts) followed by one or more hyphens, ASCII digits, and an optional trailing `x`. This is a + conscious constraint, not a general filename validator: a future sub-collection catalogued with + a 5-letter prefix, a digit-led id, or a non-Latin-1 letter (e.g. `Č` or a Cyrillic id) would + fail `isValidImportIndex` and its rows would be **skipped** (`INVALID_FILENAME_PATH_TRAVERSAL`), + not imported. Likewise a real PDF that does not follow `.pdf` produces a `PLACEHOLDER` + (the importer logs both cases distinctly — see #686). If the catalog scheme ever changes, the + pattern and its tests must be widened deliberately; do not loosen it casually, as it is the + allowlist that keeps the on-disk lookup safe. Note `\d` is intentionally ASCII-only — adding + `Pattern.UNICODE_CHARACTER_CLASS` would silently widen the accepted digit set. +- **A malicious/garbage index skips its row with a loud `SkipReason`, by design.** Since #686 + the index is the only on-disk lookup key. An index that fails `isValidImportIndex` + (path separator, traversal token, slash homoglyph, null byte, absolute path, or a non-catalog + shape) is recorded as a `SkippedFile` with reason `INVALID_FILENAME_PATH_TRAVERSAL` and the + import continues with the remaining rows — nothing outside the import dir is ever read. A + symlinked `.pdf` whose canonical path escapes the import dir is the one case that still + aborts the import (a `DomainException` from the containment assertion), because a syntactically + valid index resolving outside the dir is an environment-level attack signal, not a row typo. - **`PersonSummaryDTO` coupling.** `provisional` was added to the `PersonSummaryDTO` native interface projection; because the projection is backed by native SQL, the column had to be added to all three native `SELECT`s (`findAllWithDocumentCount`, `searchWithDocumentCount`, diff --git a/docs/architecture/c4-diagrams.md b/docs/architecture/c4-diagrams.md index 858082aa..d01cdfaa 100644 --- a/docs/architecture/c4-diagrams.md +++ b/docs/architecture/c4-diagrams.md @@ -93,7 +93,7 @@ C4Component ### 3b — Document Management & Import -Document management, file storage, and bulk Excel/ODS import. +Document management, file storage, and the canonical import. ```mermaid C4Component @@ -105,12 +105,11 @@ C4Component System_Boundary(backend, "API Backend (Spring Boot)") { Component(docCtrl, "DocumentController", "Spring MVC — /api/documents", "CRUD for documents: search, get by ID, update metadata, upload/download file, conversation thread, and batch metadata updates.") - Component(adminCtrl, "AdminController", "Spring MVC — /api/admin", "Triggers asynchronous Excel/ODS mass import (requires ADMIN permission). Reports import state (IDLE/RUNNING/DONE/FAILED).") + Component(adminCtrl, "AdminController", "Spring MVC — /api/admin", "Triggers the asynchronous canonical import (requires ADMIN permission). Reports import state via GET /api/admin/import-status (IDLE/RUNNING/DONE/FAILED).") Component(docSvc, "DocumentService", "Spring Service", "Core document business logic: store, update, search. Resolves persons and tags, delegates file I/O to FileService, builds dynamic JPA Specifications, and integrates with audit logging.") Component(fileSvc, "FileService", "Spring Service", "Wraps AWS SDK v2 S3Client. Uploads files with UUID-keyed paths, computes SHA-256 hash, downloads with content-type detection, and generates presigned URLs for OCR access.") - Component(massImport, "MassImportService", "Spring Service — @Async", "Reads Excel/ODS files from /import mount. Tracks import state (IDLE/RUNNING/DONE/FAILED) and delegates to ExcelService. Returns immediately; processing runs asynchronously.") - Component(excelSvc, "ExcelService", "Spring Service", "Parses Excel/ODS workbooks (Apache POI). Column indices configurable via application.properties. Creates/updates document records per row.") + Component(importOrch, "CanonicalImportOrchestrator", "Spring Service — @Async", "Runs four idempotent loaders (TagTree → PersonRegister → PersonTree → Document) in a fixed DAG over the normalizer's committed canonical artifacts (canonical-*.xlsx + canonical-persons-tree.json) from /import — see diagram 3b. Owns the IDLE/RUNNING/DONE/FAILED state machine.") Component(minioConf, "MinioConfig", "Spring @Configuration", "Creates the S3Client and S3Presigner beans with path-style access for MinIO. Validates MinIO connectivity on startup.") Component(docRepo, "DocumentRepository", "Spring Data JPA", "Queries documents with Specification-based dynamic search, bidirectional conversation thread queries, full-text search with ranking and match highlighting, and transcription pipeline queue projections.") @@ -123,14 +122,15 @@ C4Component Rel(frontend, docCtrl, "Document requests", "HTTP / JSON") Rel(frontend, adminCtrl, "Trigger import", "HTTP / JSON") Rel(docCtrl, docSvc, "Delegates to", "") - Rel(adminCtrl, massImport, "Triggers", "") + Rel(adminCtrl, importOrch, "Triggers", "") Rel(docSvc, fileSvc, "Upload / download files", "") Rel(docSvc, docRepo, "Reads / writes documents", "") Rel(docSvc, docSpec, "Builds search predicates", "") Rel(docSvc, personSvc, "Resolves sender / receivers", "") Rel(docSvc, tagSvc, "Finds or creates tags", "") - Rel(massImport, excelSvc, "Parses Excel/ODS file", "") - Rel(excelSvc, docSvc, "Creates / updates documents", "") + Rel(importOrch, docSvc, "Upserts documents (PDF by index) — see 3b", "") + Rel(importOrch, personSvc, "Upserts persons + relationships", "") + Rel(importOrch, tagSvc, "Upserts tag hierarchy", "") Rel(minioConf, fileSvc, "Provides S3Client and S3Presigner beans", "") Rel(fileSvc, minio, "PUT / GET / presigned URL objects", "S3 API / HTTP") Rel(docRepo, db, "SQL queries", "JDBC") @@ -492,7 +492,7 @@ C4Component Component(adminGroups, "/admin/groups, /admin/groups/[id], /admin/groups/new", "SvelteKit Routes", "Permission group management: create/edit groups and their permission sets.") Component(adminTags, "/admin/tags and /admin/tags/[id]", "SvelteKit Routes", "Tag administration: edit tag hierarchy, merge tags, delete subtrees.") Component(adminOcr, "/admin/ocr and /admin/ocr/[personId]", "SvelteKit Routes", "Global and per-person OCR configuration. Manages script types and triggers sender model training.") - Component(adminSystem, "/admin/system", "SvelteKit Route", "System status panel. Triggers Excel/ODS mass import (POST /api/admin/trigger-import). Displays import state.") + Component(adminSystem, "/admin/system", "SvelteKit Route", "System status panel. Triggers the canonical import (POST /api/admin/trigger-import). Displays import state.") Component(hilfe, "/hilfe/transkription", "SvelteKit Route", "Static transcription style guide for Kurrent and Sütterlin character recognition. No backend calls.") } diff --git a/docs/architecture/c4/l3-backend-3b-document-management.puml b/docs/architecture/c4/l3-backend-3b-document-management.puml index cca25d75..ac2f0208 100644 --- a/docs/architecture/c4/l3-backend-3b-document-management.puml +++ b/docs/architecture/c4/l3-backend-3b-document-management.puml @@ -16,7 +16,7 @@ System_Boundary(backend, "API Backend (Spring Boot)") { Component(tagTreeLoader, "TagTreeImporter", "Spring Component", "Upserts the tag hierarchy from canonical-tag-tree.xlsx via TagService (by canonical tag_path).") Component(personRegLoader, "PersonRegisterImporter", "Spring Component", "Upserts register persons from canonical-persons.xlsx via PersonService (by normalizer person_id).") Component(personTreeLoader, "PersonTreeImporter", "Spring Component", "Upserts tree persons + relationships from canonical-persons-tree.json via PersonService and RelationshipService.") - Component(docLoader, "DocumentImporter", "Spring Component", "Loads canonical-documents.xlsx: routes attribution register-first (raw cell always retained in sender_text/receiver_text), parses clean dates, builds an honest precision-aware title via DocumentTitleFormatter, keeps the S3 upload + thumbnail plumbing, and ports the path-traversal / homoglyph / absolute-path / %PDF magic-byte security guards.") + Component(docLoader, "DocumentImporter", "Spring Component", "Loads canonical-documents.xlsx: routes attribution register-first (raw cell always retained in sender_text/receiver_text), parses clean dates, builds an honest precision-aware title via DocumentTitleFormatter, keeps the S3 upload + thumbnail plumbing, and resolves each PDF by index (importDir/.pdf) guarded by strict index validation + canonical-path containment + %PDF magic-byte check (no recursive walk).") Component(titleFmt, "DocumentTitleFormatter", "Pure helper", "Formats the date label baked into an import title at exactly the data's precision (MONTH -> 'Juni 1916', never a fabricated day). Mirrors the frontend formatDocumentDate; both are pinned to docs/date-label-fixtures.json (#666).") Component(sheetReader, "CanonicalSheetReader", "POI helper", "Maps a canonical .xlsx by header name (no positional indices), splits pipe-delimited list columns, fails closed (IMPORT_ARTIFACT_INVALID) on a missing required header.") Component(minioConf, "MinioConfig", "Spring @Configuration", "Creates the S3Client and S3Presigner beans with path-style access for MinIO. Validates MinIO connectivity on startup.") diff --git a/docs/architecture/c4/l3-frontend-3d-administration.puml b/docs/architecture/c4/l3-frontend-3d-administration.puml index 3f7c89ef..5b711b3a 100644 --- a/docs/architecture/c4/l3-frontend-3d-administration.puml +++ b/docs/architecture/c4/l3-frontend-3d-administration.puml @@ -12,7 +12,7 @@ System_Boundary(frontend, "Web Frontend (SvelteKit / SSR)") { Component(adminGroups, "/admin/groups, /admin/groups/[id], /admin/groups/new", "SvelteKit Routes", "Permission group management: create/edit groups and their permission sets.") Component(adminTags, "/admin/tags and /admin/tags/[id]", "SvelteKit Routes", "Tag administration: edit tag hierarchy, merge tags, delete subtrees.") Component(adminOcr, "/admin/ocr and /admin/ocr/[personId]", "SvelteKit Routes", "Global and per-person OCR configuration. Manages script types and triggers sender model training.") - Component(adminSystem, "/admin/system", "SvelteKit Route", "System status panel. Triggers Excel/ODS mass import (POST /api/admin/trigger-import). Displays import state.") + Component(adminSystem, "/admin/system", "SvelteKit Route", "System status panel. Triggers the canonical import (POST /api/admin/trigger-import). Displays import state.") Component(hilfe, "/hilfe/transkription", "SvelteKit Route", "Static transcription style guide for Kurrent and Sütterlin character recognition. No backend calls.") } diff --git a/tools/import-normalizer/README.md b/tools/import-normalizer/README.md index ca820519..4500db5c 100644 --- a/tools/import-normalizer/README.md +++ b/tools/import-normalizer/README.md @@ -26,7 +26,6 @@ Outputs: | --- | --- | | `unparsed-dates.csv` | For each `raw` (sorted by frequency), fill `suggested_iso` + `suggested_precision`, then paste `raw,suggested_iso,suggested_precision` into `overrides/dates.csv` (header `raw,iso,precision`). | | `unresolved-names.csv` | Names whose value is itself problematic, grouped by `category`: `unknown` (`?`/illegible), `single_token` (first OR last name only), `relational` (`Tante …`), `collective` (`Familie …`), `prose` (a description landed in a name column), `ambiguous_pair` (two given names → likely two people, not auto-split). Review highest-impact categories first; add decisions to `overrides/names.csv` (look up valid ids in `out/canonical-persons.xlsx`). | -| `index-file-mismatch.csv` | The `Datei` path disagrees with the index-derived filename — reconcile when the PDFs arrive. | | `duplicate-index.csv`, `blank-index-rows.csv`, `skipped-x-suffix.csv` | Inspect; fix in the source spreadsheet if needed. | > `unresolved-names.csv` is the focused "names that need a human" list. Non-family diff --git a/tools/import-normalizer/config.py b/tools/import-normalizer/config.py index 66261d06..afea47c9 100644 --- a/tools/import-normalizer/config.py +++ b/tools/import-normalizer/config.py @@ -18,7 +18,6 @@ OVERRIDES_DIR = BASE_DIR / "overrides" # --- Header text (lowercased, whitespace-collapsed) -> canonical field --- DOCUMENT_HEADER_MAP = { "index": "index", - "datei": "file", "box": "box", "mappe": "folder", "briefeschreiberin": "sender", diff --git a/tools/import-normalizer/documents.py b/tools/import-normalizer/documents.py index 94381acf..08bb1c31 100644 --- a/tools/import-normalizer/documents.py +++ b/tools/import-normalizer/documents.py @@ -17,7 +17,6 @@ class Triage(Enum): class RawRow: source_row: int index: str = "" - file: str = "" box: str = "" folder: str = "" sender: str = "" @@ -31,7 +30,6 @@ class RawRow: @dataclass class CanonicalDocument: index: str - file: str = "" box: str = "" folder: str = "" sender_person_id: str = "" @@ -49,7 +47,7 @@ class CanonicalDocument: needs_review: list = field(default_factory=list) -_FIELDS = ["index", "file", "box", "folder", "sender", "receivers", "date", "location", "tags", "summary"] +_FIELDS = ["index", "box", "folder", "sender", "receivers", "date", "location", "tags", "summary"] def extract_row(cells: list[str], header: dict[str, int], source_row: int) -> RawRow: @@ -82,15 +80,6 @@ def classify_blank_index(cells: list[str], header: dict[str, int]) -> str: return "data_no_index" -def index_file_mismatch(index: str, file_path: str) -> bool: - # Assumes the Datei value is a filename with an extension (all corpus paths are *.pdf). - if not file_path.strip(): - return False - basename = file_path.replace("\\", "/").rsplit("/", 1)[-1] - stem = basename.rsplit(".", 1)[0] - return stem != index - - def to_canonical(raw, ctx, date_overrides: dict, approved_themes: frozenset = frozenset()) -> CanonicalDocument: pd = _dates.parse_date(raw.date, date_overrides) flags = [] @@ -109,11 +98,9 @@ def to_canonical(raw, ctx, date_overrides: dict, approved_themes: frozenset = fr flags.append("unparsed_date") if pd.needs_review: flags.append("range_end_unparsed") - if index_file_mismatch(raw.index, raw.file): - flags.append("index_file_mismatch") return CanonicalDocument( - index=raw.index, file=raw.file, box=raw.box, folder=raw.folder, + index=raw.index, box=raw.box, folder=raw.folder, sender_person_id=sender_id, sender_name=sender_name, receiver_person_ids=[r[0] for r in receivers], receiver_names=[r[1] for r in receivers], diff --git a/tools/import-normalizer/normalize.py b/tools/import-normalizer/normalize.py index 2e4fd98d..5bde0246 100644 --- a/tools/import-normalizer/normalize.py +++ b/tools/import-normalizer/normalize.py @@ -33,7 +33,7 @@ def run(*, document_workbook, document_sheet, person_workbook, person_sheet, d_fields, unknown_headers = ingest.build_header_map(doc_rows[0], config.DOCUMENT_HEADER_MAP, config.DOCUMENT_REQUIRED_FIELDS) index_col = d_fields["index"] - canon_docs, blank_index, skipped_x, mismatches = [], [], [], [] + canon_docs, blank_index, skipped_x = [], [], [] unparsed_by_raw: dict[str, list] = {} dates_by_override = 0 empty_count = 0 @@ -59,8 +59,6 @@ def run(*, document_workbook, document_sheet, person_workbook, person_sheet, doc = documents.to_canonical(raw, ctx, date_overrides, frozenset(approved_themes)) if "unparsed_date" in doc.needs_review: unparsed_by_raw.setdefault(raw.date, []).append(source_row) - if "index_file_mismatch" in doc.needs_review: - mismatches.append([source_row, raw.index, raw.file]) canon_docs.append(doc) # REQ-TRIAGE-01: flag EVERY occurrence of a duplicated index and report all of them. @@ -102,7 +100,6 @@ def run(*, document_workbook, document_sheet, person_workbook, person_sheet, key=lambda r: (r[0], -r[2], r[1])) writers.write_review_csv(review_dir / "unresolved-names.csv", ["category", "raw", "count", "example_rows"], unresolved_rows) - writers.write_review_csv(review_dir / "index-file-mismatch.csv", ["source_row", "index", "file"], mismatches) all_summaries = [doc.summary for doc in canon_docs if doc.summary] candidates = _tags.mine_summary_candidates(all_summaries) @@ -140,7 +137,6 @@ def run(*, document_workbook, document_sheet, person_workbook, person_sheet, "blank_index_rows": len(blank_index), "skipped_x_suffix": len(skipped_x), "duplicate_index_rows": len(duplicates), - "index_file_mismatches": len(mismatches), "# OVERRIDES": "", "date_overrides_loaded": len(date_overrides), "name_overrides_loaded": len(name_overrides), diff --git a/tools/import-normalizer/out/canonical-documents.xlsx b/tools/import-normalizer/out/canonical-documents.xlsx index e0731299..a0048761 100644 Binary files a/tools/import-normalizer/out/canonical-documents.xlsx and b/tools/import-normalizer/out/canonical-documents.xlsx differ diff --git a/tools/import-normalizer/tests/test_documents.py b/tools/import-normalizer/tests/test_documents.py index fe07f40d..bbf8c4c1 100644 --- a/tools/import-normalizer/tests/test_documents.py +++ b/tools/import-normalizer/tests/test_documents.py @@ -3,9 +3,9 @@ import documents from documents import Triage def test_extract_row(): - header = {"index": 0, "file": 1, "box": 2, "folder": 3, "sender": 4, - "receivers": 5, "date": 6, "location": 7, "tags": 8, "summary": 9} - cells = ["W-0001", r"..\__scan\W-0001.pdf", "V", "1", "Walter de Gruyter", + header = {"index": 0, "box": 1, "folder": 2, "sender": 3, + "receivers": 4, "date": 5, "location": 6, "tags": 7, "summary": 8} + cells = ["W-0001", "V", "1", "Walter de Gruyter", "Eugenie Müller", "15.2.1888", "Rotterdam", "Brautbriefe", "Geschäftsreise"] raw = documents.extract_row(cells, header, source_row=3) assert raw.index == "W-0001" @@ -26,14 +26,6 @@ def test_classify_blank_index(): assert documents.classify_blank_index(banner, header) == "section_banner" assert documents.classify_blank_index(data, header) == "data_no_index" -def test_index_file_mismatch(): - assert documents.index_file_mismatch("W-0010x", r"..\__scan\W-0011x.pdf") is True - assert documents.index_file_mismatch("W-0001", r"..\__scan\W-0001.pdf") is False - assert documents.index_file_mismatch("W-0001", "") is False - assert documents.index_file_mismatch("W-0001", "scans/W-0001.pdf") is False # unix path - assert documents.index_file_mismatch("W-0001", "W-0001.pdf") is False # no dir - - def _ctx(): people = persons.parse_register([ {"last_name": "de Gruyter", "first_name": "Walter"}, @@ -46,22 +38,19 @@ def test_to_canonical_resolves_and_flags(): raw = documents.RawRow(source_row=3, index="W-0001", box="V", folder="1", sender="Walter de Gruyter", receivers="Eugenie Müller", date="15.2.1888", location="Rotterdam", tags="Brautbriefe", - summary="Geschäftsreise", file=r"..\__scan\W-0001.pdf") + summary="Geschäftsreise") doc = documents.to_canonical(raw, ctx, date_overrides={}) assert doc.sender_person_id == "de-gruyter-walter" assert doc.receiver_person_ids == ["de-gruyter-eugenie"] # matched via maiden alias assert doc.date_iso == "1888-02-15" and doc.date_precision == "DAY" assert doc.tags == ["Themen/Brautbriefe"] - assert doc.file == r"..\__scan\W-0001.pdf" # file name carried through for the importer assert doc.needs_review == [] -def test_to_canonical_carries_file_name(): - ctx = _ctx() - raw = documents.RawRow(source_row=4, index="H-0730", sender="", receivers="", - file="H-0730.pdf") - doc = documents.to_canonical(raw, ctx, date_overrides={}) - assert doc.file == "H-0730.pdf" +def test_canonical_document_has_no_file_field(): + # #686: PDFs resolve by index (.pdf) in the importer; the file field is gone. + doc = documents.CanonicalDocument(index="W-0001") + assert not hasattr(doc, "file") def test_to_canonical_range_carries_date_end(): diff --git a/tools/import-normalizer/tests/test_writers.py b/tools/import-normalizer/tests/test_writers.py index 9f20d501..fe6dfbe9 100644 --- a/tools/import-normalizer/tests/test_writers.py +++ b/tools/import-normalizer/tests/test_writers.py @@ -32,18 +32,19 @@ def test_write_documents_xlsx_joins_lists(tmp_path): assert row["needs_review"] == "unparsed_date" -def test_write_documents_xlsx_carries_file_and_date_end(tmp_path): +def test_write_documents_xlsx_carries_date_end_and_has_no_file_column(tmp_path): + # #686: PDFs resolve by index (.pdf), so the redundant "file" column is dropped. doc = documents.CanonicalDocument( - index="H-0730", file="H-0730.pdf", date_iso="1917-01-10", + index="H-0730", date_iso="1917-01-10", date_precision="RANGE", date_end="1917-01-11") out = tmp_path / "docs.xlsx" writers.write_documents_xlsx([doc], out) wb = openpyxl.load_workbook(out) ws = wb.active header = [c.value for c in ws[1]] - assert "file" in header and "date_end" in header + assert "file" not in header + assert "date_end" in header row = {h: c.value for h, c in zip(header, ws[2])} - assert row["file"] == "H-0730.pdf" assert row["date_end"] == "1917-01-11" def test_write_documents_xlsx_pins_timestamp(tmp_path): diff --git a/tools/import-normalizer/writers.py b/tools/import-normalizer/writers.py index 5b9799e1..b7c3e816 100644 --- a/tools/import-normalizer/writers.py +++ b/tools/import-normalizer/writers.py @@ -22,7 +22,7 @@ def _csv_safe(value): return "'" + s if s[:1] in ("=", "+", "-", "@", "\t", "\r", "\n") else s -DOC_COLUMNS = ["index", "file", "box", "folder", "sender_person_id", "sender_name", +DOC_COLUMNS = ["index", "box", "folder", "sender_person_id", "sender_name", "receiver_person_ids", "receiver_names", "date_iso", "date_raw", "date_precision", "date_end", "location", "tags", "summary", "source_row", "needs_review"]