feat(backup): shared backup archive format v3 (writer/reader/crypto) for cms + platform backups
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
5aa52c00ff
commit
62b245d24f
224
backup/archive.go
Normal file
224
backup/archive.go
Normal file
@ -0,0 +1,224 @@
|
|||||||
|
package backup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Section maps a zip path prefix to an on-disk directory to be archived.
|
||||||
|
type Section struct {
|
||||||
|
Prefix string
|
||||||
|
Dir string
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildInput is the full set of inputs for BuildArchive.
|
||||||
|
type BuildInput struct {
|
||||||
|
Source Source
|
||||||
|
Dump []byte
|
||||||
|
Password string
|
||||||
|
Sections []Section
|
||||||
|
// Extra holds additional fixed zip entries (zip path -> contents), written
|
||||||
|
// after database.dump and before section files.
|
||||||
|
Extra map[string][]byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// backupFile pairs a zip path with its source path on disk.
|
||||||
|
type backupFile struct {
|
||||||
|
zipPath string
|
||||||
|
diskPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
// collectSectionFiles walks a section directory and returns zipPath→diskPath
|
||||||
|
// pairs. A missing directory is skipped silently (count 0, no warning), matching
|
||||||
|
// the cms collectSectionFiles semantics. Files that cannot be accessed inside an
|
||||||
|
// existing directory append warnings and are skipped.
|
||||||
|
func collectSectionFiles(ctx context.Context, section Section, warnings *[]string) ([]backupFile, error) {
|
||||||
|
if section.Dir == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(section.Dir); os.IsNotExist(err) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var files []backupFile
|
||||||
|
err := filepath.Walk(section.Dir, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
*warnings = append(*warnings, fmt.Sprintf("Failed to access %s: %v", path, err))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if info.IsDir() || !info.Mode().IsRegular() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
relPath, err := filepath.Rel(section.Dir, path)
|
||||||
|
if err != nil {
|
||||||
|
*warnings = append(*warnings, fmt.Sprintf("Failed to get relative path for %s: %v", path, err))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
files = append(files, backupFile{
|
||||||
|
zipPath: section.Prefix + filepath.ToSlash(relPath),
|
||||||
|
diskPath: path,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
*warnings = append(*warnings, fmt.Sprintf("Failed to walk %s: %v", section.Dir, err))
|
||||||
|
}
|
||||||
|
return files, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildArchive streams a backup zip archive to w: a manifest.json, the
|
||||||
|
// password-encrypted database dump, any Extra entries, then the files of each
|
||||||
|
// configured section in order. It returns the manifest and any non-fatal
|
||||||
|
// warnings encountered while collecting/writing section files.
|
||||||
|
//
|
||||||
|
// Zip entry ordering is: manifest.json, database.dump, Extra (sorted by path),
|
||||||
|
// then section files in section order. Counts for the well-known section
|
||||||
|
// prefixes (media/, brand/, icons/, styles/, plugins/) are recorded in the
|
||||||
|
// manifest.
|
||||||
|
func BuildArchive(ctx context.Context, w io.Writer, in BuildInput) (*Manifest, []string, error) {
|
||||||
|
var warnings []string
|
||||||
|
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, warnings, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Checksum of the unencrypted dump.
|
||||||
|
sum := sha256.Sum256(in.Dump)
|
||||||
|
checksum := hex.EncodeToString(sum[:])
|
||||||
|
|
||||||
|
// Encrypt the dump.
|
||||||
|
encryptedDump, encParams, err := EncryptWithPassword(in.Dump, in.Password)
|
||||||
|
if err != nil {
|
||||||
|
return nil, warnings, fmt.Errorf("encryption failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect files for every configured section, preserving section order.
|
||||||
|
sectionFiles := make([][]backupFile, len(in.Sections))
|
||||||
|
countsByPrefix := make(map[string]int)
|
||||||
|
for i, section := range in.Sections {
|
||||||
|
files, err := collectSectionFiles(ctx, section, &warnings)
|
||||||
|
if err != nil {
|
||||||
|
return nil, warnings, err
|
||||||
|
}
|
||||||
|
sectionFiles[i] = files
|
||||||
|
countsByPrefix[section.Prefix] += len(files)
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest := &Manifest{
|
||||||
|
Version: FormatVersion,
|
||||||
|
Format: FormatName,
|
||||||
|
ExportedAt: time.Now().UTC(),
|
||||||
|
Source: in.Source,
|
||||||
|
DatabaseSize: int64(len(in.Dump)),
|
||||||
|
MediaCount: countsByPrefix["media/"],
|
||||||
|
BrandCount: countsByPrefix["brand/"],
|
||||||
|
IconsCount: countsByPrefix["icons/"],
|
||||||
|
StylesCount: countsByPrefix["styles/"],
|
||||||
|
PluginsCount: countsByPrefix["plugins/"],
|
||||||
|
Encryption: encParams,
|
||||||
|
Checksum: checksum,
|
||||||
|
}
|
||||||
|
|
||||||
|
manifestJSON, err := json.MarshalIndent(manifest, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return nil, warnings, fmt.Errorf("manifest marshal failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, warnings, err
|
||||||
|
}
|
||||||
|
|
||||||
|
zipWriter := zip.NewWriter(w)
|
||||||
|
|
||||||
|
manifestEntry, err := zipWriter.Create("manifest.json")
|
||||||
|
if err != nil {
|
||||||
|
return nil, warnings, fmt.Errorf("failed to create manifest.json in zip: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := manifestEntry.Write(manifestJSON); err != nil {
|
||||||
|
return nil, warnings, fmt.Errorf("failed to write manifest.json: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dumpEntry, err := zipWriter.Create("database.dump")
|
||||||
|
if err != nil {
|
||||||
|
return nil, warnings, fmt.Errorf("failed to create database.dump in zip: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := dumpEntry.Write(encryptedDump); err != nil {
|
||||||
|
return nil, warnings, fmt.Errorf("failed to write database.dump: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extra fixed entries, in a stable (sorted) order for reproducibility.
|
||||||
|
if len(in.Extra) > 0 {
|
||||||
|
names := make([]string, 0, len(in.Extra))
|
||||||
|
for name := range in.Extra {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
for _, name := range names {
|
||||||
|
entry, err := zipWriter.Create(name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, warnings, fmt.Errorf("failed to create %s in zip: %w", name, err)
|
||||||
|
}
|
||||||
|
if _, err := entry.Write(in.Extra[name]); err != nil {
|
||||||
|
return nil, warnings, fmt.Errorf("failed to write %s: %w", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Section files, with periodic context checks (every 10 files).
|
||||||
|
written := 0
|
||||||
|
for _, files := range sectionFiles {
|
||||||
|
for _, bf := range files {
|
||||||
|
if written%10 == 0 {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, warnings, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
written++
|
||||||
|
|
||||||
|
src, err := os.Open(bf.diskPath)
|
||||||
|
if err != nil {
|
||||||
|
warnings = append(warnings, fmt.Sprintf("Failed to read %s for zip: %v", bf.diskPath, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
entry, err := zipWriter.Create(bf.zipPath)
|
||||||
|
if err != nil {
|
||||||
|
closeBestEffort(src)
|
||||||
|
warnings = append(warnings, fmt.Sprintf("Failed to create %s in zip: %v", bf.zipPath, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := io.Copy(entry, src); err != nil {
|
||||||
|
warnings = append(warnings, fmt.Sprintf("Failed to write %s to zip: %v", bf.zipPath, err))
|
||||||
|
}
|
||||||
|
closeBestEffort(src)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := zipWriter.Close(); err != nil {
|
||||||
|
return nil, warnings, fmt.Errorf("failed to close zip: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return manifest, warnings, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func closeBestEffort(c io.Closer) {
|
||||||
|
_ = c.Close()
|
||||||
|
}
|
||||||
97
backup/archive_test.go
Normal file
97
backup/archive_test.go
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
package backup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func readZipEntry(t *testing.T, data []byte, name string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("zip.NewReader: %v", err)
|
||||||
|
}
|
||||||
|
for _, f := range zr.File {
|
||||||
|
if f.Name == name {
|
||||||
|
rc, err := f.Open()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open %s: %v", name, err)
|
||||||
|
}
|
||||||
|
defer rc.Close()
|
||||||
|
b, err := io.ReadAll(rc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read %s: %v", name, err)
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("zip entry %q not found", name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildArchiveRoundTrip(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
mediaDir := filepath.Join(dir, "media")
|
||||||
|
if err := os.MkdirAll(filepath.Join(mediaDir, "sub"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(mediaDir, "sub", "a.txt"), []byte("hello"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dump := []byte("fake pg_dump custom-format payload")
|
||||||
|
var buf bytes.Buffer
|
||||||
|
manifest, warnings, err := BuildArchive(context.Background(), &buf, BuildInput{
|
||||||
|
Source: Source{SiteTitle: "Test Site", Domain: "test.example.com"},
|
||||||
|
Dump: dump,
|
||||||
|
Password: "correct horse",
|
||||||
|
Sections: []Section{
|
||||||
|
{Prefix: "media/", Dir: mediaDir},
|
||||||
|
{Prefix: "brand/", Dir: filepath.Join(dir, "does-not-exist")}, // missing dir → warning, not error
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildArchive: %v", err)
|
||||||
|
}
|
||||||
|
if len(warnings) != 0 {
|
||||||
|
t.Fatalf("missing section dir must be silently skipped (cms parity), got warnings: %v", warnings)
|
||||||
|
}
|
||||||
|
if manifest.Version != FormatVersion || manifest.Format != FormatName {
|
||||||
|
t.Fatalf("manifest version/format = %d/%q", manifest.Version, manifest.Format)
|
||||||
|
}
|
||||||
|
if manifest.MediaCount != 1 || manifest.BrandCount != 0 {
|
||||||
|
t.Fatalf("counts: media=%d brand=%d", manifest.MediaCount, manifest.BrandCount)
|
||||||
|
}
|
||||||
|
wantSum := sha256.Sum256(dump)
|
||||||
|
if manifest.Checksum != hex.EncodeToString(wantSum[:]) {
|
||||||
|
t.Fatalf("checksum mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read back: manifest parses, dump decrypts to the original bytes.
|
||||||
|
ra := bytes.NewReader(buf.Bytes())
|
||||||
|
got, err := ReadManifest(ra, int64(buf.Len()))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadManifest: %v", err)
|
||||||
|
}
|
||||||
|
encDump := readZipEntry(t, buf.Bytes(), "database.dump")
|
||||||
|
plain, err := DecryptWithPassword(encDump, "correct horse", got.Encryption)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DecryptWithPassword: %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(plain, dump) {
|
||||||
|
t.Fatal("decrypted dump != original")
|
||||||
|
}
|
||||||
|
if _, err := DecryptWithPassword(encDump, "wrong password", got.Encryption); err == nil {
|
||||||
|
t.Fatal("wrong password must fail")
|
||||||
|
}
|
||||||
|
if string(readZipEntry(t, buf.Bytes(), "media/sub/a.txt")) != "hello" {
|
||||||
|
t.Fatal("media file missing or corrupted in archive")
|
||||||
|
}
|
||||||
|
}
|
||||||
120
backup/crypto.go
Normal file
120
backup/crypto.go
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
package backup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/argon2"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Argon2id parameters — byte-identical to the cms settings_crypto so that
|
||||||
|
// archives produced here decrypt through the existing cms restore path.
|
||||||
|
argon2Time = 3
|
||||||
|
argon2Memory = 64 * 1024 // 64 MB
|
||||||
|
argon2Threads = 4
|
||||||
|
argon2KeyLen = 32 // AES-256
|
||||||
|
saltLength = 16
|
||||||
|
)
|
||||||
|
|
||||||
|
// EncryptionParams stores the parameters used for key derivation. Its JSON shape
|
||||||
|
// is wire-identical to the cms EncryptionParams so manifests interoperate.
|
||||||
|
type EncryptionParams struct {
|
||||||
|
Algorithm string `json:"algorithm"`
|
||||||
|
KDF string `json:"kdf"`
|
||||||
|
Salt string `json:"salt"`
|
||||||
|
Iterations int `json:"iterations"`
|
||||||
|
Memory int `json:"memory"`
|
||||||
|
Parallelism int `json:"parallelism"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// deriveKey derives a 32-byte key from password using Argon2id.
|
||||||
|
func deriveKey(password string, salt []byte) []byte {
|
||||||
|
return argon2.IDKey([]byte(password), salt, argon2Time, argon2Memory, argon2Threads, argon2KeyLen)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EncryptWithPassword encrypts data with AES-256-GCM using a password-derived key.
|
||||||
|
// The nonce is prepended to the returned ciphertext.
|
||||||
|
func EncryptWithPassword(plaintext []byte, password string) ([]byte, *EncryptionParams, error) {
|
||||||
|
// Generate random salt
|
||||||
|
salt := make([]byte, saltLength)
|
||||||
|
if _, err := io.ReadFull(rand.Reader, salt); err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("failed to generate salt: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive key from password
|
||||||
|
key := deriveKey(password, salt)
|
||||||
|
|
||||||
|
// Create AES cipher
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("failed to create cipher: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create GCM mode
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("failed to create GCM: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate nonce
|
||||||
|
nonce := make([]byte, gcm.NonceSize())
|
||||||
|
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("failed to generate nonce: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt (prepend nonce to ciphertext)
|
||||||
|
ciphertext := gcm.Seal(nonce, nonce, plaintext, nil)
|
||||||
|
|
||||||
|
return ciphertext, &EncryptionParams{
|
||||||
|
Algorithm: "aes-256-gcm",
|
||||||
|
KDF: "argon2id",
|
||||||
|
Salt: base64.StdEncoding.EncodeToString(salt),
|
||||||
|
Iterations: argon2Time,
|
||||||
|
Memory: argon2Memory,
|
||||||
|
Parallelism: argon2Threads,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecryptWithPassword decrypts data encrypted with EncryptWithPassword.
|
||||||
|
func DecryptWithPassword(ciphertext []byte, password string, params *EncryptionParams) ([]byte, error) {
|
||||||
|
// Decode salt
|
||||||
|
salt, err := base64.StdEncoding.DecodeString(params.Salt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode salt: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive key from password
|
||||||
|
key := deriveKey(password, salt)
|
||||||
|
|
||||||
|
// Create AES cipher
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create cipher: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create GCM mode
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create GCM: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract nonce
|
||||||
|
nonceSize := gcm.NonceSize()
|
||||||
|
if len(ciphertext) < nonceSize {
|
||||||
|
return nil, fmt.Errorf("ciphertext too short")
|
||||||
|
}
|
||||||
|
nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]
|
||||||
|
|
||||||
|
// Decrypt
|
||||||
|
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("decryption failed - invalid password or corrupted data")
|
||||||
|
}
|
||||||
|
|
||||||
|
return plaintext, nil
|
||||||
|
}
|
||||||
74
backup/manifest.go
Normal file
74
backup/manifest.go
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
package backup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// FormatVersion / FormatName identify the backup archive format. They are
|
||||||
|
// byte-identical to the cms constants (backupFormatVersion / backupFormatName)
|
||||||
|
// so archives interoperate with the existing cms restore path.
|
||||||
|
FormatVersion = 3
|
||||||
|
FormatName = "blockninja-pgdump-backup"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Source identifies where a backup came from. Field-for-field identical to the
|
||||||
|
// cms BackupSource JSON shape.
|
||||||
|
type Source struct {
|
||||||
|
SiteTitle string `json:"site_title"`
|
||||||
|
Domain string `json:"domain,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manifest is the metadata stored in manifest.json. It is a field-for-field copy
|
||||||
|
// of the cms BackupManifest so archives built here restore through the existing
|
||||||
|
// cms path unchanged.
|
||||||
|
type Manifest struct {
|
||||||
|
Version int `json:"version"`
|
||||||
|
Format string `json:"format"`
|
||||||
|
ExportedAt time.Time `json:"exported_at"`
|
||||||
|
Source Source `json:"source"`
|
||||||
|
DatabaseSize int64 `json:"database_size"`
|
||||||
|
MediaCount int `json:"media_count"`
|
||||||
|
BrandCount int `json:"brand_count,omitempty"`
|
||||||
|
IconsCount int `json:"icons_count,omitempty"`
|
||||||
|
StylesCount int `json:"styles_count,omitempty"`
|
||||||
|
PluginsCount int `json:"plugins_count,omitempty"`
|
||||||
|
Encryption *EncryptionParams `json:"encryption,omitempty"`
|
||||||
|
Checksum string `json:"checksum"` // SHA-256 of unencrypted database dump
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadManifest opens a backup zip archive and parses its manifest.json entry.
|
||||||
|
func ReadManifest(r io.ReaderAt, size int64) (*Manifest, error) {
|
||||||
|
zr, err := zip.NewReader(r, size)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to open backup archive: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, f := range zr.File {
|
||||||
|
if f.Name != "manifest.json" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rc, err := f.Open()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to open manifest.json: %w", err)
|
||||||
|
}
|
||||||
|
defer rc.Close()
|
||||||
|
|
||||||
|
data, err := io.ReadAll(rc)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read manifest.json: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var m Manifest
|
||||||
|
if err := json.Unmarshal(data, &m); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse manifest.json: %w", err)
|
||||||
|
}
|
||||||
|
return &m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("manifest.json not found in backup archive")
|
||||||
|
}
|
||||||
1
go.mod
1
go.mod
@ -10,6 +10,7 @@ require (
|
|||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/jackc/pgx/v5 v5.9.2
|
github.com/jackc/pgx/v5 v5.9.2
|
||||||
github.com/tetratelabs/wazero v1.12.0
|
github.com/tetratelabs/wazero v1.12.0
|
||||||
|
golang.org/x/crypto v0.48.0
|
||||||
golang.org/x/mod v0.34.0
|
golang.org/x/mod v0.34.0
|
||||||
google.golang.org/protobuf v1.36.11
|
google.golang.org/protobuf v1.36.11
|
||||||
)
|
)
|
||||||
|
|||||||
2
go.sum
2
go.sum
@ -36,6 +36,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
|||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU=
|
github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU=
|
||||||
github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0=
|
github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0=
|
||||||
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
|
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||||
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||||
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
||||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user