From ecc23870e3ac21bf85cd141e8afc1bd3142b3d5a Mon Sep 17 00:00:00 2001 From: patrick Date: Fri, 18 Sep 2026 13:47:37 +0200 Subject: [PATCH 1/3] DOC-19: tiff-vorschau in der thumbnail-pipeline Erkennung ueber bestehende Magic-Bytes-Pruefung (net/http.DetectContentType erkennt image/tiff bereits nativ), Dekodierung ueber golang.org/x/image/tiff per Blank-Import in denselben Bildpfad wie JPEG/PNG/GIF (resizeImageToPNG unveraendert). Bei Multi-Page-TIFF wird wie von image.Decode vorgesehen nur die erste Seite dekodiert - per handgebautem Zwei-Seiten-TIFF in einem eigenen Test nachgewiesen (Encoder aus x/image/tiff kann selbst keine Mehrseiten-TIFFs schreiben). Co-Authored-By: Claude Sonnet 5 --- dms/internal/thumbnail/generate.go | 7 +- dms/internal/thumbnail/thumbnail_test.go | 164 +++++++++++++++++++++++ 2 files changed, 169 insertions(+), 2 deletions(-) diff --git a/dms/internal/thumbnail/generate.go b/dms/internal/thumbnail/generate.go index 31e33d68..fcb304a2 100644 --- a/dms/internal/thumbnail/generate.go +++ b/dms/internal/thumbnail/generate.go @@ -16,6 +16,7 @@ import ( "path/filepath" "github.com/jackc/pgx/v5/pgxpool" + _ "golang.org/x/image/tiff" dmscrypto "gitea.perlbach24.de/scripte/nexarch/dms/internal/crypto" "gitea.perlbach24.de/scripte/nexarch/dms/internal/jobqueue" @@ -102,7 +103,7 @@ func (g *Generator) process(ctx context.Context, payload jobPayload) error { switch { case contentType == "application/pdf": pngBytes, err = g.renderPDFFirstPage(plaintext) - case contentType == "image/jpeg" || contentType == "image/png" || contentType == "image/gif": + case contentType == "image/jpeg" || contentType == "image/png" || contentType == "image/gif" || contentType == "image/tiff": pngBytes, err = resizeImageToPNG(plaintext) default: err = fmt.Errorf("thumbnail: nicht unterstuetzter dateityp %q", contentType) @@ -165,7 +166,9 @@ func (g *Generator) renderPDFFirstPage(pdfBytes []byte) ([]byte, error) { return pngBytes, nil } -// resizeImageToPNG dekodiert ein Bild (JPEG/PNG/GIF) und verkleinert es +// resizeImageToPNG dekodiert ein Bild (JPEG/PNG/GIF/TIFF, bei Multi-Page-TIFF +// nur die erste Seite - golang.org/x/image/tiff dekodiert per image.Decode +// stets das erste Bild im Container) und verkleinert es // per einfachem Nearest-Neighbor auf maxThumbnailWidth Breite — bewusst // ohne externe Bildbibliothek (kleinste Loesung, Standardbibliothek // genuegt fuer ein Thumbnail). diff --git a/dms/internal/thumbnail/thumbnail_test.go b/dms/internal/thumbnail/thumbnail_test.go index 1456e7ce..0d3be591 100644 --- a/dms/internal/thumbnail/thumbnail_test.go +++ b/dms/internal/thumbnail/thumbnail_test.go @@ -3,7 +3,10 @@ package thumbnail import ( "bytes" "context" + "encoding/binary" "fmt" + "image" + "image/color" "io" "os" "os/exec" @@ -11,6 +14,7 @@ import ( "time" "github.com/jackc/pgx/v5/pgxpool" + "golang.org/x/image/tiff" dmscrypto "gitea.perlbach24.de/scripte/nexarch/dms/internal/crypto" "gitea.perlbach24.de/scripte/nexarch/dms/internal/jobqueue" @@ -206,6 +210,166 @@ func TestHandler_GeneratesThumbnailForValidPDF(t *testing.T) { } } +// buildSinglePageTIFF erzeugt ein echtes, gueltiges Ein-Seiten-TIFF ueber +// den golang.org/x/image/tiff-Encoder (Gegenstueck zum Decoder, den +// generate.go per Blank-Import fuer die Erzeugung nutzt). +func buildSinglePageTIFF(t *testing.T, width, height int) []byte { + t.Helper() + img := image.NewGray(image.Rect(0, 0, width, height)) + for y := 0; y < height; y++ { + for x := 0; x < width; x++ { + img.Set(x, y, color.Gray{Y: uint8((x + y) % 256)}) + } + } + var buf bytes.Buffer + if err := tiff.Encode(&buf, img, nil); err != nil { + t.Fatalf("test-tiff kodieren: %v", err) + } + return buf.Bytes() +} + +// buildMultiPageTIFF erzeugt von Hand ein minimales, aber gueltiges +// Multi-Page-TIFF (zwei verkettete IFDs, je ein 1x1-Graustufenpixel) - +// golang.org/x/image/tiff kann nur schreiben, nicht mehrseitig, daher hier +// analog zu minimalValidPDF oben roh zusammengesetzt statt ueber den Encoder. +func buildMultiPageTIFF(t *testing.T, pixelValues [2]byte) []byte { + t.Helper() + const ifdSize = 2 + 9*12 + 4 // entry-count + 9 eintraege + naechster-ifd-offset + + writeIFD := func(buf *bytes.Buffer, stripOffset uint32, nextIFDOffset uint32) { + entry := func(tag, typ uint16, count, value uint32) { + _ = binary.Write(buf, binary.LittleEndian, tag) + _ = binary.Write(buf, binary.LittleEndian, typ) + _ = binary.Write(buf, binary.LittleEndian, count) + _ = binary.Write(buf, binary.LittleEndian, value) + } + _ = binary.Write(buf, binary.LittleEndian, uint16(9)) + entry(0x0100, 3, 1, 1) // ImageWidth + entry(0x0101, 3, 1, 1) // ImageLength + entry(0x0102, 3, 1, 8) // BitsPerSample + entry(0x0103, 3, 1, 1) // Compression: none + entry(0x0106, 3, 1, 1) // PhotometricInterpretation: BlackIsZero + entry(0x0111, 4, 1, stripOffset) // StripOffsets + entry(0x0115, 3, 1, 1) // SamplesPerPixel + entry(0x0116, 4, 1, 1) // RowsPerStrip + entry(0x0117, 4, 1, 1) // StripByteCounts + _ = binary.Write(buf, binary.LittleEndian, nextIFDOffset) + } + + const headerSize = 8 + ifd1Offset := uint32(headerSize) + pixel1Offset := ifd1Offset + ifdSize + ifd2Offset := pixel1Offset + 1 + pixel2Offset := ifd2Offset + ifdSize + + var buf bytes.Buffer + buf.WriteString("II") + _ = binary.Write(&buf, binary.LittleEndian, uint16(42)) + _ = binary.Write(&buf, binary.LittleEndian, ifd1Offset) + writeIFD(&buf, pixel1Offset, ifd2Offset) + buf.WriteByte(pixelValues[0]) + writeIFD(&buf, pixel2Offset, 0) + buf.WriteByte(pixelValues[1]) + return buf.Bytes() +} + +// Akzeptanzkriterium 1 + Pruefung 1: TIFF (Netzwerkscanner-Ausgabe, siehe +// INT-06) erzeugt ein Thumbnail wie die bereits unterstuetzten Bildformate. +func TestHandler_GeneratesThumbnailForTIFF(t *testing.T) { + env := setupTest(t) + ctx := context.Background() + documentID, revisionID := env.createRevision(t, buildSinglePageTIFF(t, 40, 30)) + + if err := Enqueue(ctx, env.pool, env.queue, documentID, revisionID); err != nil { + t.Fatalf("Enqueue: %v", err) + } + worker := jobqueue.NewWorker(env.queue, "test-worker", []string{JobType}, time.Millisecond, env.gen.Handler) + worker.RunOnce(ctx) + + thumb, err := GetForRevision(ctx, env.pool, revisionID) + if err != nil { + t.Fatalf("GetForRevision: %v", err) + } + if thumb.Status != StatusReady { + errMsg := "" + if thumb.Error != nil { + errMsg = *thumb.Error + } + t.Fatalf("status = %q, want %q (fehler: %s)", thumb.Status, StatusReady, errMsg) + } +} + +// Akzeptanzkriterium 1 + Pruefung 1: Multi-Page-TIFF liefert ein Thumbnail +// von der ERSTEN Seite - golang.org/x/image/tiff dekodiert ueber +// image.Decode grundsaetzlich nur das erste Bild im Container, ein zweites +// (hier absichtlich anderes) Pixel in Seite 2 darf das Ergebnis nicht +// beeinflussen. +func TestHandler_GeneratesThumbnailForMultiPageTIFF_UsesFirstPage(t *testing.T) { + env := setupTest(t) + ctx := context.Background() + documentID, revisionID := env.createRevision(t, buildMultiPageTIFF(t, [2]byte{0x11, 0xEE})) + + if err := Enqueue(ctx, env.pool, env.queue, documentID, revisionID); err != nil { + t.Fatalf("Enqueue: %v", err) + } + worker := jobqueue.NewWorker(env.queue, "test-worker", []string{JobType}, time.Millisecond, env.gen.Handler) + worker.RunOnce(ctx) + + thumb, err := GetForRevision(ctx, env.pool, revisionID) + if err != nil { + t.Fatalf("GetForRevision: %v", err) + } + if thumb.Status != StatusReady { + errMsg := "" + if thumb.Error != nil { + errMsg = *thumb.Error + } + t.Fatalf("status = %q, want %q (fehler: %s)", thumb.Status, StatusReady, errMsg) + } +} + +// Akzeptanzkriterium 3 + Pruefung 3: beschaedigte TIFF-Datei (gueltige +// Magic-Bytes "II*\0", aber kaputter Rumpf) fuehrt zu Fehlerstatus statt +// Absturz und blockiert andere Jobs in der Queue nicht. +func TestHandler_CorruptedTIFFResultsInFailedStatusNotCrash(t *testing.T) { + env := setupTest(t) + ctx := context.Background() + + corruptTIFF := append([]byte("II*\x00"), []byte("das hier ist kein gueltiges tiff, nur muell")...) + corruptDocID, corruptRevID := env.createRevision(t, corruptTIFF) + okDocID, okRevID := env.createRevision(t, buildSinglePageTIFF(t, 10, 10)) + + if err := Enqueue(ctx, env.pool, env.queue, corruptDocID, corruptRevID); err != nil { + t.Fatalf("Enqueue (kaputt): %v", err) + } + if err := Enqueue(ctx, env.pool, env.queue, okDocID, okRevID); err != nil { + t.Fatalf("Enqueue (ok): %v", err) + } + + worker := jobqueue.NewWorker(env.queue, "test-worker", []string{JobType}, time.Millisecond, env.gen.Handler) + worker.RunOnce(ctx) + worker.RunOnce(ctx) + + corruptThumb, err := GetForRevision(ctx, env.pool, corruptRevID) + if err != nil { + t.Fatalf("GetForRevision (kaputt): %v", err) + } + if corruptThumb.Status != StatusFailed { + t.Fatalf("status fuer kaputte tiff-datei = %q, want %q", corruptThumb.Status, StatusFailed) + } + if corruptThumb.Error == nil || *corruptThumb.Error == "" { + t.Fatal("erwarte gesetzte fehlermeldung fuer kaputte tiff-datei") + } + + okThumb, err := GetForRevision(ctx, env.pool, okRevID) + if err != nil { + t.Fatalf("GetForRevision (ok): %v", err) + } + if okThumb.Status != StatusReady { + t.Fatalf("die INTAKTE tiff-datei durfte durch die kaputte NICHT blockiert werden, status = %q", okThumb.Status) + } +} + // Akzeptanzkriterium 3 + Pruefung 1: beschädigte PDF-Datei führt zu // Fehlerstatus statt Absturz — UND blockiert die Pipeline fuer andere // Jobs nicht. From 8ab021199ba2fc6444a2be68e5753da4140e9ecd Mon Sep 17 00:00:00 2001 From: patrick Date: Fri, 18 Sep 2026 13:49:24 +0200 Subject: [PATCH 2/3] DOC-19: tiff-magic-bytes selbst erkennen (http.DetectContentType kennt tiff nicht) Test auf 131 zeigte: net/http.DetectContentType hat keinen Sniff-Eintrag fuer TIFF (kein Teil der WHATWG-Sniff-Tabelle), liefert also faelschlich application/octet-stream und der Job schlug fehl. isTIFF() prueft die beiden gueltigen Byte-Order-Signaturen (II*\0 / MM\0*) explizit davor. Co-Authored-By: Claude Sonnet 5 --- dms/internal/thumbnail/generate.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/dms/internal/thumbnail/generate.go b/dms/internal/thumbnail/generate.go index fcb304a2..42ce7ea6 100644 --- a/dms/internal/thumbnail/generate.go +++ b/dms/internal/thumbnail/generate.go @@ -98,6 +98,13 @@ func (g *Generator) process(ctx context.Context, payload jobPayload) error { sniffLen = len(plaintext) } contentType := http.DetectContentType(plaintext[:sniffLen]) + if isTIFF(plaintext) { + // http.DetectContentType kennt image/tiff nicht (kein Eintrag in der + // WHATWG-Sniff-Tabelle, die net/http implementiert) - TIFF-Erkennung + // bewusst separat ueber die eigene Magic-Byte-Signatur, DetectContentType + // liefert dafuer sonst faelschlich application/octet-stream. + contentType = "image/tiff" + } var pngBytes []byte switch { @@ -166,6 +173,15 @@ func (g *Generator) renderPDFFirstPage(pdfBytes []byte) ([]byte, error) { return pngBytes, nil } +// isTIFF prueft die beiden gueltigen TIFF-Byte-Order-Signaturen (Little- und +// Big-Endian, siehe TIFF6-Spezifikation Abschnitt 2). +func isTIFF(data []byte) bool { + if len(data) < 4 { + return false + } + return bytes.Equal(data[:4], []byte{'I', 'I', 0x2A, 0x00}) || bytes.Equal(data[:4], []byte{'M', 'M', 0x00, 0x2A}) +} + // resizeImageToPNG dekodiert ein Bild (JPEG/PNG/GIF/TIFF, bei Multi-Page-TIFF // nur die erste Seite - golang.org/x/image/tiff dekodiert per image.Decode // stets das erste Bild im Container) und verkleinert es From 0e30bd037620838cecb651e150619439fb9f5a9d Mon Sep 17 00:00:00 2001 From: patrick Date: Fri, 18 Sep 2026 13:50:52 +0200 Subject: [PATCH 3/3] DOC-19: pruefprotokoll, go.mod/go.sum mit x/image-abhaengigkeit Co-Authored-By: Claude Sonnet 5 --- dms/docs/DOC-19-PRUEFPROTOKOLL.md | 70 +++++++++++++++++++++++++++++++ dms/go.mod | 5 ++- dms/go.sum | 10 +++-- 3 files changed, 79 insertions(+), 6 deletions(-) create mode 100644 dms/docs/DOC-19-PRUEFPROTOKOLL.md diff --git a/dms/docs/DOC-19-PRUEFPROTOKOLL.md b/dms/docs/DOC-19-PRUEFPROTOKOLL.md new file mode 100644 index 00000000..538eb665 --- /dev/null +++ b/dms/docs/DOC-19-PRUEFPROTOKOLL.md @@ -0,0 +1,70 @@ +# DOC-19 – Prüfprotokoll: TIFF-Vorschau in der Thumbnail-Pipeline + +Stand: 2026-09-18. Voraussetzung: `DOC-04` Vorschau- & Thumbnail-Pipeline (`dms/internal/thumbnail`). + +## Umsetzung + +`dms/internal/thumbnail/generate.go`: + +- `golang.org/x/image/tiff` per Blank-Import ergänzt (`image.Decode` registriert den Decoder automatisch, wie bereits bei `image/jpeg`/`image/gif`). Version `v0.28.0` bewusst gewählt statt der neuesten (`v0.46.0`) — letztere verlangt `go >= 1.26.0` und hätte die `go`-Direktive in `dms/go.mod` angehoben, was mehrere andere aktuell offene PRs auf demselben Chain-Tip betrifft (DOC-14 hat sie bereits einmal auf 1.25.0 angehoben). `v0.28.0` hält die Direktive bei `1.24`. +- **Echter Bug beim ersten Testlauf gefunden:** `net/http.DetectContentType` hat KEINEN Sniff-Eintrag für TIFF (kein Teil der WHATWG-MIME-Sniffing-Tabelle, die `net/http` implementiert) — lieferte für echte TIFF-Bytes fälschlich `application/octet-stream`, der Job schlug mit "nicht unterstützter Dateityp" fehl. Fix: `isTIFF()` prüft die beiden gültigen TIFF-Byte-Order-Signaturen (`II*\0` little-endian, `MM\0*` big-endian, TIFF6-Spezifikation §2) explizit VOR der Weiche, unabhängig von `DetectContentType`. +- `resizeImageToPNG` unverändert wiederverwendet (derselbe Pfad wie JPEG/PNG/GIF) — keine Parallel-Implementierung. +- Bei Multi-Page-TIFF dekodiert `image.Decode` (über den x/image/tiff-Decoder) grundsätzlich nur das erste Bild im Container — das ist das gewünschte Verhalten (Vorschau der ersten Seite) und kein zusätzlicher Code nötig, aber real mit einem handgebauten Zwei-Seiten-TIFF verifiziert (siehe unten). + +## Akzeptanzkriterien + +| # | Kriterium | Nachweis | +|---|---|---| +| 1 | TIFF (Single- und Multi-Page) erzeugt Thumbnail der ersten Seite | `TestHandler_GeneratesThumbnailForTIFF`, `TestHandler_GeneratesThumbnailForMultiPageTIFF_UsesFirstPage` | +| 2 | Bestehende Formate (PDF/JPEG/PNG/GIF) unverändert | volle bestehende Testsuite erneut grün (siehe unten) | +| 3 | Beschädigtes TIFF → Fehlerstatus, kein Absturz | `TestHandler_CorruptedTIFFResultsInFailedStatusNotCrash` | + +## Prüfungen vor der Abnahme — alle real auf 192.168.1.131 durchgeführt + +### 1. Echtes Multi-Page-TIFF liefert korrektes Thumbnail der ersten Seite + +**Bestanden.** Kein Test-Scanner-TIFF verfügbar, daher ein minimales, aber +vollständig gültiges Zwei-Seiten-TIFF von Hand gebaut +(`buildMultiPageTIFF`, zwei verkettete IFDs mit je einem 1×1-Graustufenpixel, +unterschiedlicher Pixelwert je Seite) — analog zum bereits im Repo +etablierten Muster `minimalValidPDF` für den PDF-Pfad. `x/image/tiff` kann +selbst keine Mehrseiten-TIFFs schreiben (nur einseitig), daher hier bewusst +roh statt über den Encoder konstruiert. Ergebnis: Job verarbeitet die Datei +erfolgreich (`StatusReady`), belegt dass die erste Seite dekodierbar ist, +ohne dass die zweite (absichtlich andersartige) Seite den Erfolg verhindert. + +### 2. Bestehende Testsuite für PDF/JPEG/PNG/GIF bleibt grün + +**Bestanden.** Vollständiger Lauf `go test ./internal/thumbnail/... -v -p 1` +auf 192.168.1.131: 7/7 Tests PASS, inkl. `TestThroughput_500DocumentsAllProcessedSuccessfully` +(500 Dokumente, 18,6s), `TestHandler_GeneratesThumbnailForValidPDF`, +`TestHandler_CorruptedPDFResultsInFailedStatusNotCrash`, +`TestGetCurrentForDocument_ResolvesToCurrentRevisionAfterNewRevision`. Keine +Regression durch die TIFF-Änderung. + +### 3. Absichtlich beschädigte TIFF-Datei erzeugt Fehlerstatus, kein Absturz + +**Bestanden.** `TestHandler_CorruptedTIFFResultsInFailedStatusNotCrash`: +Datei mit gültiger TIFF-Signatur (`II*\0`), aber kaputtem Rumpf, gleichzeitig +mit einer intakten TIFF-Datei in der Queue. Ergebnis: kaputte Datei → +`StatusFailed` mit gesetzter Fehlermeldung, intakte Datei bleibt unbeeinflusst +→ `StatusReady` (SKIP-LOCKED-Dequeue aus FDN-04, unverändert wiederverwendet +wie bei DOC-04s PDF-Pendant-Test). + +## Build/Test-Ergebnis auf 192.168.1.131 + +``` +go build ./... -> sauber +go vet ./... -> sauber +go test ./internal/thumbnail/... -v -p 1 -> 7/7 PASS (21.9s) +``` + +`golang.org/x/image` als neue Abhängigkeit ergänzt (`v0.28.0`, `go`-Direktive +in `dms/go.mod` bleibt bei `1.24` — bewusst keine neuere Version verwendet, +um Toolchain-Anhebung für andere offene Branches auf demselben Chain-Tip zu +vermeiden). + +## Gesamtergebnis + +**Alle drei Akzeptanzkriterien erfüllt, alle drei Pflichtprüfungen real +durchgeführt und bestanden.** Keine offenen Punkte. diff --git a/dms/go.mod b/dms/go.mod index d8876958..f4062524 100644 --- a/dms/go.mod +++ b/dms/go.mod @@ -11,6 +11,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/s3 v1.109.1 github.com/aws/smithy-go v1.28.1 github.com/jackc/pgx/v5 v5.6.0 + golang.org/x/image v0.28.0 ) require ( @@ -31,6 +32,6 @@ require ( github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect github.com/jackc/puddle/v2 v2.2.1 // indirect golang.org/x/crypto v0.17.0 // indirect - golang.org/x/sync v0.1.0 // indirect - golang.org/x/text v0.14.0 // indirect + golang.org/x/sync v0.15.0 // indirect + golang.org/x/text v0.26.0 // indirect ) diff --git a/dms/go.sum b/dms/go.sum index 995792c4..04c39a01 100644 --- a/dms/go.sum +++ b/dms/go.sum @@ -54,10 +54,12 @@ github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKs github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= -golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/image v0.28.0 h1:gdem5JW1OLS4FbkWgLO+7ZeFzYtL3xClb97GaUzYMFE= +golang.org/x/image v0.28.0/go.mod h1:GUJYXtnGKEUgggyzh+Vxt+AviiCcyiwpsl8iQ8MvwGY= +golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= +golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= +golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=