v0.9.0: add manifest checksums
pipeline / build (push) Has been cancelled
pipeline / test (push) Has been cancelled

This commit is contained in:
Ein Anderssono
2026-06-15 02:19:47 +02:00
parent d909d30b87
commit 7555b561bd
11 changed files with 185 additions and 24 deletions
+51 -6
View File
@@ -2,6 +2,8 @@ package main
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"encoding/xml"
"fmt"
@@ -42,6 +44,7 @@ type exportOptions struct {
verify bool
format string
sidecar string
checksum string
xmpPrivacy string
xmpKeywords string
xmpRating string
@@ -228,6 +231,9 @@ COMMON EXPORT FLAGS
--verify
Run manifest/file verification after export or backup-all.
--checksum none|sha256
Store optional file checksum metadata in the manifest. Default: none.
--sidecar none|xmp|json|xmp,json
Write opt-in metadata sidecars next to each exported file. Default: none.
If sidecar writing fails, the asset is counted as failed.
@@ -789,7 +795,7 @@ func logEntry(event, level, assetID, album, filename, cloud string, size int64,
}
}
func addManifestEntry(m manifest.Manifest, pa pendingAsset, result photos.ExportResult) {
func addManifestEntry(m manifest.Manifest, pa pendingAsset, result photos.ExportResult, checksum string) {
if m == nil {
return
}
@@ -802,7 +808,33 @@ func addManifestEntry(m manifest.Manifest, pa pendingAsset, result photos.Export
if err != nil || strings.HasPrefix(relPath, "..") {
relPath = result.Filename
}
m.AddEntry(manifest.NewEntry(pa.asset.ID, result.Filename, relPath, result.Size, result.Cloud))
m.AddEntry(manifest.NewEntryWithChecksum(pa.asset.ID, result.Filename, relPath, result.Size, result.Cloud, checksum))
}
func addManifestEntryForResult(m manifest.Manifest, pa pendingAsset, result photos.ExportResult, opts exportOptions) error {
checksum := ""
if opts.checksum == "sha256" && !result.Skipped {
var err error
checksum, err = fileSHA256(filepath.Join(pa.path, result.Filename))
if err != nil {
return err
}
}
addManifestEntry(m, pa, result, checksum)
return nil
}
func fileSHA256(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
type xmpSidecarData struct {
@@ -1468,16 +1500,20 @@ func exportPendingSerial(pending []pendingAsset, targetSize, quality int, origin
failed++
appendFailure(pa.path, pa, exportErr)
} else if isSkipped {
addManifestEntry(m, pa, result)
_ = addManifestEntryForResult(m, pa, result, opts)
} else {
if sidecarErr := writeSidecarIfNeeded(pa, result, originals, opts, geo, bridge); sidecarErr != nil {
failed++
exportErr = sidecarErr
isErr = true
appendFailure(pa.path, pa, sidecarErr)
} else if checksumErr := addManifestEntryForResult(m, pa, result, opts); checksumErr != nil {
failed++
exportErr = checksumErr
isErr = true
appendFailure(pa.path, pa, checksumErr)
} else {
done++
addManifestEntry(m, pa, result)
}
}
avgSpeed := float64(0)
@@ -1608,16 +1644,20 @@ func exportPendingParallel(pending []pendingAsset, targetSize, quality int, orig
failed++
appendFailure(entry.pa.path, entry.pa, entry.err)
} else if isSkipped {
addManifestEntry(m, entry.pa, entry.result)
_ = addManifestEntryForResult(m, entry.pa, entry.result, opts)
} else {
if sidecarErr := writeSidecarIfNeeded(entry.pa, entry.result, originals, opts, geo, bridge); sidecarErr != nil {
failed++
entry.err = sidecarErr
isErr = true
appendFailure(entry.pa.path, entry.pa, sidecarErr)
} else if checksumErr := addManifestEntryForResult(m, entry.pa, entry.result, opts); checksumErr != nil {
failed++
entry.err = checksumErr
isErr = true
appendFailure(entry.pa.path, entry.pa, checksumErr)
} else {
done++
addManifestEntry(m, entry.pa, entry.result)
}
}
avgSpeed := float64(0)
@@ -1966,6 +2006,7 @@ func parseExportOptions(args []string, stderr io.Writer) (exportOptions, bool) {
verify: hasFlag(args, "--verify"),
format: flagValWithDefault(args, "--format", "jpeg"),
sidecar: flagValWithDefault(args, "--sidecar", "none"),
checksum: flagValWithDefault(args, "--checksum", "none"),
xmpPrivacy: flagValWithDefault(args, "--xmp-privacy", "keep"),
xmpKeywords: flagValWithDefault(args, "--xmp-keywords", "album-path"),
xmpRating: flagValWithDefault(args, "--xmp-rating", "favorite"),
@@ -1994,6 +2035,10 @@ func parseExportOptions(args []string, stderr io.Writer) (exportOptions, bool) {
}
}
}
if opts.checksum != "none" && opts.checksum != "sha256" {
fmt.Fprintf(stderr, "error: --checksum must be none or sha256, got %q\n", opts.checksum)
return opts, false
}
if opts.xmpPrivacy != "keep" && opts.xmpPrivacy != "strip-location" && opts.xmpPrivacy != "strip-address" {
fmt.Fprintf(stderr, "error: --xmp-privacy must be keep, strip-location, or strip-address, got %q\n", opts.xmpPrivacy)
return opts, false
+74 -1
View File
@@ -2662,6 +2662,23 @@ func TestExportPendingParallelManifestAdd(t *testing.T) {
}
}
func TestExportPendingParallelChecksumError(t *testing.T) {
dir := t.TempDir()
mf := manifest.LoadJSONL(dir)
if err := mf.OpenAppend(); err != nil {
t.Fatal(err)
}
b := &mockBridge{assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}}
b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) {
return photos.ExportResult{Filename: "missing.jpg", Size: 3}, nil
}
bar := newProgressBar(io.Discard, 1)
done, failed := exportPendingParallel([]pendingAsset{{asset: b.assets[0], path: dir}}, 1024, 85, false, 1, bar, b, 1, mf, manifest.NoopLogWriter, exportOptions{checksum: "sha256"})
if done != 0 || failed != 1 {
t.Fatalf("done=%d failed=%d", done, failed)
}
}
func TestExportPendingParallelCancel(t *testing.T) {
b := &mockBridge{assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}}
b.Cancel()
@@ -2804,6 +2821,58 @@ func TestExportAssetsManifestWrite(t *testing.T) {
}
}
func TestExportAssetsChecksum(t *testing.T) {
b := &mockBridge{assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}}
dir := t.TempDir()
b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) {
if err := os.WriteFile(filepath.Join(out, "img.jpg"), []byte("abc"), 0644); err != nil {
return photos.ExportResult{}, err
}
return photos.ExportResult{Filename: "img.jpg", Size: 3, Cloud: "local"}, nil
}
done, failed := exportAssets(b.assets, dir, 1024, 85, 1, false, 1, io.Discard, b, "", false, manifest.FormatJSONL, false, exportOptions{checksum: "sha256"})
if done != 1 || failed != 0 {
t.Fatalf("done=%d failed=%d", done, failed)
}
entry := manifest.LoadJSONL(dir).Entries()["x1"]
if entry.Checksum != "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" {
t.Fatalf("checksum=%q", entry.Checksum)
}
got, err := fileSHA256(filepath.Join(dir, "img.jpg"))
if err != nil || got != entry.Checksum {
t.Fatalf("fileSHA256 got=%q err=%v", got, err)
}
if _, err := fileSHA256(filepath.Join(dir, "missing.jpg")); err == nil {
t.Fatal("expected missing checksum error")
}
if _, err := fileSHA256(dir); err == nil {
t.Fatal("expected directory checksum error")
}
if err := addManifestEntryForResult(nil, pendingAsset{}, photos.ExportResult{}, exportOptions{}); err != nil {
t.Fatalf("nil manifest add error: %v", err)
}
if err := addManifestEntryForResult(manifest.LoadJSONL(dir), pendingAsset{asset: photos.Asset{ID: "skip"}, path: dir}, photos.ExportResult{Filename: "missing.jpg", Skipped: true}, exportOptions{checksum: "sha256"}); err != nil {
t.Fatalf("skipped checksum add error: %v", err)
}
}
func TestExportAssetsChecksumErrors(t *testing.T) {
dir := t.TempDir()
b := &mockBridge{assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}}
b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) {
return photos.ExportResult{Filename: "missing.jpg", Size: 3}, nil
}
done, failed := exportAssets(b.assets, dir, 1024, 85, 1, false, 1, io.Discard, b, "", false, manifest.FormatJSONL, false, exportOptions{checksum: "sha256"})
if done != 0 || failed != 1 {
t.Fatalf("serial checksum error done=%d failed=%d", done, failed)
}
dir = t.TempDir()
done, failed = exportAssets(b.assets, dir, 1024, 85, 2, false, 2, io.Discard, b, "", false, manifest.FormatJSONL, false, exportOptions{checksum: "sha256"})
if done != 0 || failed != 1 {
t.Fatalf("parallel checksum error done=%d failed=%d", done, failed)
}
}
func TestBackupTreeManifestOpenErr(t *testing.T) {
b := &mockBridge{
tree: []photos.CollectionNode{
@@ -4657,6 +4726,10 @@ func TestSidecarConfigAndErrors(t *testing.T) {
if _, ok := parseExportOptions([]string{"--xmp-rating", "bad"}, &stderr); ok || !strings.Contains(stderr.String(), "--xmp-rating") {
t.Fatalf("expected xmp rating validation error, stderr=%q", stderr.String())
}
stderr.Reset()
if _, ok := parseExportOptions([]string{"--checksum", "bad"}, &stderr); ok || !strings.Contains(stderr.String(), "--checksum") {
t.Fatalf("expected checksum validation error, stderr=%q", stderr.String())
}
b := &mockBridge{assets: []photos.Asset{{ID: "x1", Filename: "photo.jpg"}}}
b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) {
@@ -5018,7 +5091,7 @@ func TestInjectedErrorBranchesForCoverage(t *testing.T) {
removeFunc = oldRemove
mf := &mockManifest{}
addManifestEntry(mf, pendingAsset{asset: photos.Asset{ID: "x"}, root: filepath.Join(dir, "other"), path: dir}, photos.ExportResult{Filename: "file.jpg", Size: 1})
addManifestEntry(mf, pendingAsset{asset: photos.Asset{ID: "x"}, root: filepath.Join(dir, "other"), path: dir}, photos.ExportResult{Filename: "file.jpg", Size: 1}, "")
if mf.last.Path != "file.jpg" {
t.Fatalf("expected fallback rel path, got %+v", mf.last)
}