Merge remote-tracking branch 'origin/feature/dms2-01-01-standardisiertes-export-import-format-offen-dokumentiert'

This commit is contained in:
patrick committed 2026-09-26 00:11:02 +02:00
commit 09261fcc7d
4 files changed
+508

No files matched your search

+73
View File
@@ -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.
+43
View File
@@ -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.
+185
View File
@@ -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)
}
@@ -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)
}