package backup import ( "archive/zip" "context" "crypto/sha256" "encoding/base64" "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. checksum := computeChecksum(in.Dump) // 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. // Failure contract (cms parity — the cssManifestsFile block in // buildBackupArchive): a failed Create skips the entry silently, a failed // Write appends a warning. Extra entries are regenerable extras; a problem // with one must never abort the archive. 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 { continue } if _, err := entry.Write(in.Extra[name]); err != nil { warnings = append(warnings, fmt.Sprintf("Failed to write %s: %v", 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() } // computeChecksum calculates the SHA-256 hash of data, base64-encoded — // byte-identical to the cms computeChecksum so the existing cms restore path // (verifyChecksum) accepts archives built by this package. func computeChecksum(data []byte) string { hash := sha256.Sum256(data) return base64.StdEncoding.EncodeToString(hash[:]) }