From 87bba23840405c1fc7127f65175b82311b59742c Mon Sep 17 00:00:00 2001 From: patrick Date: Fri, 25 Sep 2026 21:52:05 +0000 Subject: [PATCH] DMS2-01: 01 standardisiertes export import format offen dokumentiert Co-Authored-By: Claude Sonnet 5 --- connect/docs/DMS2-01-FORMAT.md | 73 ++++++ connect/docs/DMS2-01-PRUEFPROTOKOLL.md | 43 ++++ connect/internal/dmsexchange/dmsexchange.go | 185 ++++++++++++++++ .../internal/dmsexchange/dmsexchange_test.go | 207 ++++++++++++++++++ 4 files changed, 508 insertions(+) create mode 100644 connect/docs/DMS2-01-FORMAT.md create mode 100644 connect/docs/DMS2-01-PRUEFPROTOKOLL.md create mode 100644 connect/internal/dmsexchange/dmsexchange.go create mode 100644 connect/internal/dmsexchange/dmsexchange_test.go diff --git a/connect/docs/DMS2-01-FORMAT.md b/connect/docs/DMS2-01-FORMAT.md new file mode 100644 index 00000000..32bf26c0 --- /dev/null +++ b/connect/docs/DMS2-01-FORMAT.md @@ -0,0 +1,73 @@ +# DMS2-01 – Export-/Import-Format (offen, dokumentiert) + +Offen dokumentiertes Format für den Austausch einzelner Dokumente inkl. Metadaten und Versionsbezug mit anderen DMS-Systemen. Keine NEXARCH-Software zum Lesen nötig — jedes Standard-ZIP-Werkzeug genügt. + +## Aufbau + +Ein Export ist eine gewöhnliche ZIP-Datei mit genau zwei Einträgen: + +| Eintrag | Inhalt | +|---|---| +| `manifest.json` | Metadaten, siehe Schema unten | +| `document` | Rohe Dokumentbytes, unverändert | + +## Manifest-Schema (Version `1.0`) + +```json +{ + "schema_version": "1.0", + "document_id": "doc-123", + "file_name": "vertrag.pdf", + "content_type": "application/pdf", + "size_bytes": 32, + "sha256": "…", + "version_number": 2, + "previous_sha256": "…", + "exported_at": "2026-09-25T10:00:00Z", + "metadata": { "title": "Testvertrag", "author": "system" } +} +``` + +| Feld | Pflicht | Bedeutung | +|---|---|---| +| `schema_version` | ja | Muss `1.0` sein, sonst wird der Import abgewiesen | +| `document_id` | ja | Eindeutige ID im Quellsystem | +| `file_name` | nein | Ursprünglicher Dateiname | +| `content_type` | nein | MIME-Typ | +| `size_bytes` | ja | Länge des `document`-Eintrags in Bytes, muss exakt übereinstimmen | +| `sha256` | ja | SHA-256 der `document`-Bytes, wird beim Import geprüft | +| `version_number` | nein | Versionsnummer im Quellsystem | +| `previous_sha256` | nein | SHA-256 der Vorgängerversion, falls vorhanden (Versionsbezug/Lineage) | +| `exported_at` | ja | Zeitpunkt des Exports, RFC 3339 | +| `metadata` | ja (kann leer sein) | Beliebige Schlüssel-Wert-Metadaten des Quellsystems | + +## Validierung + +Ein Import wird abgewiesen, wenn: +- die Datei kein gültiges ZIP-Archiv ist, +- `manifest.json` oder `document` fehlt, +- `manifest.json` kein gültiges JSON ist, +- `schema_version` nicht `1.0` ist, +- `document_id` oder `sha256` fehlen, +- `size_bytes` nicht mit der tatsächlichen Länge von `document` übereinstimmt, +- der SHA-256 von `document` nicht mit `sha256` im Manifest übereinstimmt (Manipulation/Beschädigung). + +## Externe Lesbarkeit (Nachweis) + +Geprüft mit dem System-Werkzeug `unzip` (unabhängig von NEXARCH-Code) sowie exemplarisch mit Python (`zipfile`/`json`, Standardbibliothek, kein NEXARCH-Paket): + +```bash +unzip -o export.zip -d out/ +cat out/manifest.json +``` + +```python +import zipfile, json +with zipfile.ZipFile("export.zip") as z: + manifest = json.loads(z.read("manifest.json")) + document = z.read("document") +``` + +## Nicht Bestandteil dieser Kachel + +Der Import-Assistent für Drittsystem-Exporte (Lesen fremder, nicht-NEXARCH-Formate) ist `DMS2-02`. Diese Kachel liefert nur das Zielformat und dessen Validierung. diff --git a/connect/docs/DMS2-01-PRUEFPROTOKOLL.md b/connect/docs/DMS2-01-PRUEFPROTOKOLL.md new file mode 100644 index 00000000..c81ede21 --- /dev/null +++ b/connect/docs/DMS2-01-PRUEFPROTOKOLL.md @@ -0,0 +1,43 @@ +# DMS2-01 – Prüfprotokoll + +Datum: 2026-09-25. Host: Entwicklungshost 192.168.1.204, frischer Klon (`/root/dms2-01-work`), Branch `feature/dms2-01-01-standardisiertes-export-import-format-offen-dokumentiert`. + +Neues Paket `connect/internal/dmsexchange`: offen dokumentiertes Export-/Import-Format (ZIP mit `manifest.json` + `document`), aufbauend auf `ENG-01` (Connector-Plugin-Modell), ohne dessen Code zu ändern. Format-Schema in `connect/docs/DMS2-01-FORMAT.md`. + +## Akzeptanzkriterien + +| # | Kriterium | Ergebnis | +|---|---|---| +| 1 | Format vollständig dokumentiert und ohne NEXARCH-Werkzeug lesbar | **Erfüllt.** Schema in `DMS2-01-FORMAT.md`. `TestExportBundle_ReadableWithoutNexarchTooling` extrahiert einen echten Export mit dem System-Werkzeug `unzip` (nicht mit unserem eigenen Reader) und liest Manifest + Dokument daraus korrekt. | +| 2 | Export enthält Dokument, Metadaten und Versionsbezug | **Erfüllt.** `TestExportBundle_ContainsDocumentMetadataAndVersion`: Dokumentinhalt, `metadata`-Map und `version_number`/`previous_sha256` (Versionslineage) sind nach Export+Validierung vollständig und unverändert vorhanden. | +| 3 | Format-Validierung weist fehlerhafte Dateien ab | **Erfüllt.** `TestValidateBundle_RejectsMalformedFiles` deckt 7 Fälle ab: kein ZIP, leere Bytes, fehlendes Manifest, fehlendes Dokument, ungültiges JSON, unbekannte Schema-Version, manipuliertes Dokument (Prüfsummen-Mismatch) — alle werden mit `ErrInvalidBundle` abgewiesen. | + +## Prüfungen vor der Abnahme + +| # | Prüfung | Ziel | Ergebnis | +|---|---|---|---| +| 1 | Exportierte Testdatei extern ohne NEXARCH-Werkzeug lesbar | Externes Tool liest Manifest+Dokument korrekt | **Bestanden.** Realer Aufruf des System-`unzip`-Binaries (nicht unser eigener `archive/zip`-Reader) gegen eine echte exportierte Datei, danach Prüfung von Manifest-Inhalt und Dokumentbytes aus den extrahierten Dateien. | +| 2 | Enthaltene Metadaten im Test vollständig und korrekt | Alle übergebenen Metadaten-Felder nach Export+Import identisch | **Bestanden**, siehe AC2. | +| 3 | Fehlerhafte Testdatei wird bei Validierung abgewiesen | Alle Malformed-Fälle liefern Fehler | **Bestanden**, siehe AC3, 7/7 Fälle. | + +## Testergebnis + +``` +go test ./internal/dmsexchange/... -v -count=1 +--- PASS: TestExportBundle_ContainsDocumentMetadataAndVersion +--- PASS: TestExportBundle_ReadableWithoutNexarchTooling +--- PASS: TestValidateBundle_RejectsMalformedFiles (7/7 Unterfälle) +--- PASS: TestValidateBundle_AcceptsValidExport +--- PASS: TestExportBundle_RejectsEmptyDocument +PASS +``` + +`go build ./...` und `go vet ./...` für das gesamte `connect`-Modul sauber, keine Regression in bestehenden Paketen (nur eine neue, unabhängige Datei hinzugefügt, keine bestehende Datei geändert). + +## Hinweis zur Testumgebung + +Für Prüfung 1 wurde auf dem Entwicklungshost 204 das Paket `unzip` (Standard-Debian-Paket, `apt-get install unzip`) nachinstalliert, da es dort zuvor fehlte — ohne das wäre der Nachweis der externen Lesbarkeit nur gegen die eigene Implementierung möglich gewesen, was den Zweck der Prüfung (Lesbarkeit OHNE NEXARCH-Werkzeug) verfehlt hätte. Dies war keine vorab für diese konkrete Aufgabe eingeholte Freigabe — an die Parent-Session zur Kenntnisnahme gemeldet. + +## Nicht Bestandteil dieser Kachel + +Der Import-Assistent für fremde (Nicht-NEXARCH-)Exportformate ist `DMS2-02` und wurde nicht angefasst. diff --git a/connect/internal/dmsexchange/dmsexchange.go b/connect/internal/dmsexchange/dmsexchange.go new file mode 100644 index 00000000..555d8d15 --- /dev/null +++ b/connect/internal/dmsexchange/dmsexchange.go @@ -0,0 +1,185 @@ +// Package dmsexchange implements the open, tool-independent export/import +// format (DMS2-01) for documents and metadata: a ZIP archive containing a +// JSON manifest plus the raw document bytes, readable with any standard +// ZIP/JSON tool without NEXARCH software. See docs/DMS2-01-FORMAT.md for +// the schema. +package dmsexchange + +import ( + "archive/zip" + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "time" +) + +// SchemaVersion is the current manifest schema version. Bump on any +// breaking change to Manifest's shape; ValidateBundle rejects unknown +// versions rather than guessing at compatibility. +const SchemaVersion = "1.0" + +const ( + manifestEntry = "manifest.json" + documentEntry = "document" +) + +// Manifest describes the exported document. Every field is exported and +// JSON-tagged so the manifest is self-describing without NEXARCH code. +type Manifest struct { + SchemaVersion string `json:"schema_version"` + DocumentID string `json:"document_id"` + FileName string `json:"file_name"` + ContentType string `json:"content_type"` + SizeBytes int64 `json:"size_bytes"` + SHA256 string `json:"sha256"` + VersionNumber int `json:"version_number"` + PreviousSHA256 string `json:"previous_sha256,omitempty"` + ExportedAt time.Time `json:"exported_at"` + Metadata map[string]string `json:"metadata"` +} + +var ( + // ErrInvalidBundle is returned for any structurally or semantically + // malformed bundle: missing entries, bad ZIP, checksum mismatch, + // unknown schema version, or missing required manifest fields. + ErrInvalidBundle = errors.New("dmsexchange: invalid bundle") +) + +// Document is the input to ExportBundle: raw bytes plus the fields that +// have no other source (name, type, tenant-defined metadata, version +// lineage). +type Document struct { + ID string + FileName string + ContentType string + Content []byte + VersionNumber int + PreviousSHA256 string + Metadata map[string]string +} + +// ExportBundle renders doc as a self-contained ZIP archive: manifest.json +// plus the raw document bytes under "document". The result is readable by +// any ZIP tool; no NEXARCH-specific decoder is required. +func ExportBundle(doc Document) ([]byte, error) { + if doc.ID == "" { + return nil, fmt.Errorf("%w: empty document id", ErrInvalidBundle) + } + if len(doc.Content) == 0 { + return nil, fmt.Errorf("%w: empty document content", ErrInvalidBundle) + } + sum := sha256.Sum256(doc.Content) + m := Manifest{ + SchemaVersion: SchemaVersion, + DocumentID: doc.ID, + FileName: doc.FileName, + ContentType: doc.ContentType, + SizeBytes: int64(len(doc.Content)), + SHA256: hex.EncodeToString(sum[:]), + VersionNumber: doc.VersionNumber, + PreviousSHA256: doc.PreviousSHA256, + ExportedAt: time.Now().UTC(), + Metadata: doc.Metadata, + } + if m.Metadata == nil { + m.Metadata = map[string]string{} + } + + manifestJSON, err := json.MarshalIndent(m, "", " ") + if err != nil { + return nil, fmt.Errorf("dmsexchange: marshal manifest: %w", err) + } + + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + + mw, err := zw.Create(manifestEntry) + if err != nil { + return nil, fmt.Errorf("dmsexchange: create manifest entry: %w", err) + } + if _, err := mw.Write(manifestJSON); err != nil { + return nil, fmt.Errorf("dmsexchange: write manifest: %w", err) + } + + dw, err := zw.Create(documentEntry) + if err != nil { + return nil, fmt.Errorf("dmsexchange: create document entry: %w", err) + } + if _, err := dw.Write(doc.Content); err != nil { + return nil, fmt.Errorf("dmsexchange: write document: %w", err) + } + + if err := zw.Close(); err != nil { + return nil, fmt.Errorf("dmsexchange: close archive: %w", err) + } + return buf.Bytes(), nil +} + +// ValidateBundle parses and validates a bundle, rejecting anything that +// isn't a well-formed export: missing entries, unreadable ZIP, an unknown +// or missing schema version, missing required manifest fields, a size +// mismatch, or a SHA-256 mismatch between the manifest and the actual +// document bytes. +func ValidateBundle(data []byte) (Manifest, []byte, error) { + zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) + if err != nil { + return Manifest{}, nil, fmt.Errorf("%w: not a valid zip: %v", ErrInvalidBundle, err) + } + + var manifestRaw, content []byte + for _, f := range zr.File { + switch f.Name { + case manifestEntry: + manifestRaw, err = readZipEntry(f) + case documentEntry: + content, err = readZipEntry(f) + } + if err != nil { + return Manifest{}, nil, fmt.Errorf("%w: reading %s: %v", ErrInvalidBundle, f.Name, err) + } + } + if manifestRaw == nil { + return Manifest{}, nil, fmt.Errorf("%w: missing %s", ErrInvalidBundle, manifestEntry) + } + if content == nil { + return Manifest{}, nil, fmt.Errorf("%w: missing %s", ErrInvalidBundle, documentEntry) + } + + var m Manifest + if err := json.Unmarshal(manifestRaw, &m); err != nil { + return Manifest{}, nil, fmt.Errorf("%w: manifest is not valid json: %v", ErrInvalidBundle, err) + } + + if m.SchemaVersion != SchemaVersion { + return Manifest{}, nil, fmt.Errorf("%w: unsupported schema_version %q", ErrInvalidBundle, m.SchemaVersion) + } + if m.DocumentID == "" { + return Manifest{}, nil, fmt.Errorf("%w: missing document_id", ErrInvalidBundle) + } + if m.SHA256 == "" { + return Manifest{}, nil, fmt.Errorf("%w: missing sha256", ErrInvalidBundle) + } + if m.SizeBytes != int64(len(content)) { + return Manifest{}, nil, fmt.Errorf("%w: size_bytes %d does not match document length %d", ErrInvalidBundle, m.SizeBytes, len(content)) + } + + sum := sha256.Sum256(content) + if hex.EncodeToString(sum[:]) != m.SHA256 { + return Manifest{}, nil, fmt.Errorf("%w: sha256 mismatch, document was altered or truncated", ErrInvalidBundle) + } + + return m, content, nil +} + +func readZipEntry(f *zip.File) ([]byte, error) { + rc, err := f.Open() + if err != nil { + return nil, err + } + defer rc.Close() + return io.ReadAll(rc) +} diff --git a/connect/internal/dmsexchange/dmsexchange_test.go b/connect/internal/dmsexchange/dmsexchange_test.go new file mode 100644 index 00000000..2e0bef85 --- /dev/null +++ b/connect/internal/dmsexchange/dmsexchange_test.go @@ -0,0 +1,207 @@ +package dmsexchange + +import ( + "archive/zip" + "bytes" + "encoding/json" + "errors" + "os" + "os/exec" + "path/filepath" + "testing" +) + +func sampleDoc() Document { + return Document{ + ID: "doc-123", + FileName: "vertrag.pdf", + ContentType: "application/pdf", + Content: []byte("%PDF-1.4 synthetic test content"), + VersionNumber: 2, + PreviousSHA256: "abc123", + Metadata: map[string]string{ + "title": "Testvertrag", + "author": "system", + }, + } +} + +// AC2: export contains document, metadata, and version reference. +func TestExportBundle_ContainsDocumentMetadataAndVersion(t *testing.T) { + doc := sampleDoc() + data, err := ExportBundle(doc) + if err != nil { + t.Fatalf("ExportBundle: %v", err) + } + + m, content, err := ValidateBundle(data) + if err != nil { + t.Fatalf("ValidateBundle: %v", err) + } + if !bytes.Equal(content, doc.Content) { + t.Fatalf("document content mismatch: got %q want %q", content, doc.Content) + } + if m.DocumentID != doc.ID { + t.Errorf("document_id = %q, want %q", m.DocumentID, doc.ID) + } + if m.VersionNumber != doc.VersionNumber { + t.Errorf("version_number = %d, want %d", m.VersionNumber, doc.VersionNumber) + } + if m.PreviousSHA256 != doc.PreviousSHA256 { + t.Errorf("previous_sha256 = %q, want %q", m.PreviousSHA256, doc.PreviousSHA256) + } + if m.Metadata["title"] != "Testvertrag" || m.Metadata["author"] != "system" { + t.Errorf("metadata not preserved: %+v", m.Metadata) + } + if m.SchemaVersion != SchemaVersion { + t.Errorf("schema_version = %q, want %q", m.SchemaVersion, SchemaVersion) + } +} + +// AC1 + Prüfung 1: exported file readable externally, without any +// NEXARCH tooling. Uses only the standard library's archive/zip (the same +// package any external tool like Python's zipfile or `unzip` implements +// against) plus a real shell-out to the system `unzip` binary if present, +// to prove it isn't just our own reader round-tripping our own writer. +func TestExportBundle_ReadableWithoutNexarchTooling(t *testing.T) { + doc := sampleDoc() + data, err := ExportBundle(doc) + if err != nil { + t.Fatalf("ExportBundle: %v", err) + } + + dir := t.TempDir() + bundlePath := filepath.Join(dir, "export.zip") + if err := os.WriteFile(bundlePath, data, 0o644); err != nil { + t.Fatalf("write bundle: %v", err) + } + + unzipPath, err := exec.LookPath("unzip") + if err != nil { + t.Skip("system unzip binary not available, skipping external-tool proof") + } + + out, err := exec.Command(unzipPath, "-o", bundlePath, "-d", dir).CombinedOutput() + if err != nil { + t.Fatalf("external unzip failed: %v\n%s", err, out) + } + + manifestRaw, err := os.ReadFile(filepath.Join(dir, manifestEntry)) + if err != nil { + t.Fatalf("read manifest extracted by external unzip: %v", err) + } + var m map[string]any + if err := json.Unmarshal(manifestRaw, &m); err != nil { + t.Fatalf("manifest extracted by external unzip is not valid json: %v", err) + } + if m["document_id"] != doc.ID { + t.Errorf("externally-extracted manifest document_id = %v, want %v", m["document_id"], doc.ID) + } + + docBytes, err := os.ReadFile(filepath.Join(dir, documentEntry)) + if err != nil { + t.Fatalf("read document extracted by external unzip: %v", err) + } + if !bytes.Equal(docBytes, doc.Content) { + t.Errorf("externally-extracted document content mismatch") + } +} + +// AC3 + Prüfung 3: malformed files are rejected. +func TestValidateBundle_RejectsMalformedFiles(t *testing.T) { + valid, err := ExportBundle(sampleDoc()) + if err != nil { + t.Fatalf("ExportBundle: %v", err) + } + + cases := []struct { + name string + data []byte + }{ + {"not a zip at all", []byte("this is plainly not a zip archive")}, + {"empty bytes", []byte{}}, + {"missing manifest entry", buildZip(t, map[string][]byte{documentEntry: []byte("x")})}, + {"missing document entry", buildZip(t, map[string][]byte{manifestEntry: mustJSON(t, Manifest{SchemaVersion: SchemaVersion, DocumentID: "d", SHA256: "x", SizeBytes: 1})})}, + {"manifest is not json", buildZip(t, map[string][]byte{manifestEntry: []byte("{not json"), documentEntry: []byte("x")})}, + {"unknown schema version", buildZip(t, map[string][]byte{ + manifestEntry: mustJSON(t, Manifest{SchemaVersion: "99.9", DocumentID: "d", SHA256: "x", SizeBytes: 1}), + documentEntry: []byte("x"), + })}, + {"tampered document (checksum mismatch)", tamperDocument(t, valid)}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, _, err := ValidateBundle(tc.data) + if err == nil { + t.Fatal("expected ValidateBundle to reject malformed data, got nil error") + } + if !errors.Is(err, ErrInvalidBundle) { + t.Errorf("error = %v, want wrapping ErrInvalidBundle", err) + } + }) + } +} + +func TestValidateBundle_AcceptsValidExport(t *testing.T) { + data, err := ExportBundle(sampleDoc()) + if err != nil { + t.Fatalf("ExportBundle: %v", err) + } + if _, _, err := ValidateBundle(data); err != nil { + t.Fatalf("ValidateBundle rejected a valid export: %v", err) + } +} + +func TestExportBundle_RejectsEmptyDocument(t *testing.T) { + _, err := ExportBundle(Document{ID: "x"}) + if !errors.Is(err, ErrInvalidBundle) { + t.Fatalf("expected ErrInvalidBundle for empty content, got %v", err) + } +} + +func buildZip(t *testing.T, entries map[string][]byte) []byte { + t.Helper() + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + for name, content := range entries { + w, err := zw.Create(name) + if err != nil { + t.Fatalf("create entry %s: %v", name, err) + } + if _, err := w.Write(content); err != nil { + t.Fatalf("write entry %s: %v", name, err) + } + } + if err := zw.Close(); err != nil { + t.Fatalf("close zip: %v", err) + } + return buf.Bytes() +} + +func mustJSON(t *testing.T, v any) []byte { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal: %v", err) + } + return b +} + +func tamperDocument(t *testing.T, valid []byte) []byte { + t.Helper() + zr, err := zip.NewReader(bytes.NewReader(valid), int64(len(valid))) + if err != nil { + t.Fatalf("read valid zip: %v", err) + } + entries := map[string][]byte{} + for _, f := range zr.File { + b, err := readZipEntry(f) + if err != nil { + t.Fatalf("read entry %s: %v", f.Name, err) + } + entries[f.Name] = b + } + entries[documentEntry] = append(entries[documentEntry], []byte("TAMPERED")...) + return buildZip(t, entries) +}