184 lines
4.8 KiB
Go
184 lines
4.8 KiB
Go
package app
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bufio"
|
|
"errors"
|
|
"io"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
)
|
|
|
|
// blackboxArchiveCompareChunk is the buffer size used when diffing the newly
|
|
// built local zip against the last one successfully written to removable
|
|
// media, to find how many leading bytes are unchanged.
|
|
const blackboxArchiveCompareChunk = 256 * 1024
|
|
|
|
// buildZipArchive walks root (a locally-staged, fast-storage copy of the
|
|
// blackbox tree — never the slow removable-media mountpoint) and writes a
|
|
// single deterministic zip to destPath: same input tree -> byte-identical
|
|
// output, so two cycles that changed nothing produce identical archives and
|
|
// patchArchiveOnTarget (below) can skip re-writing the unchanged prefix to
|
|
// the slow target. Determinism relies on fs.WalkDir's guaranteed lexical
|
|
// order and each entry's Modified time coming from the source file's mtime
|
|
// (stable across cycles for files nothing touched).
|
|
func buildZipArchive(root, destPath string) error {
|
|
if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
|
|
return err
|
|
}
|
|
f, err := os.OpenFile(destPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
zw := zip.NewWriter(f)
|
|
|
|
walkErr := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if d.IsDir() {
|
|
return nil
|
|
}
|
|
rel, err := filepath.Rel(root, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
info, err := d.Info()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
header, err := zip.FileInfoHeader(info)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
header.Name = filepath.ToSlash(rel)
|
|
header.Method = zip.Deflate
|
|
w, err := zw.CreateHeader(header)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
src, err := os.Open(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, copyErr := io.Copy(w, src)
|
|
return errors.Join(copyErr, src.Close())
|
|
})
|
|
if walkErr != nil {
|
|
_ = zw.Close()
|
|
_ = f.Close()
|
|
return walkErr
|
|
}
|
|
if err := zw.Close(); err != nil {
|
|
_ = f.Close()
|
|
return err
|
|
}
|
|
return f.Close()
|
|
}
|
|
|
|
// commonPrefixLen returns how many leading bytes two files share. Used to
|
|
// find how much of a freshly-rebuilt local zip is identical to the previous
|
|
// cycle's, so patchArchiveOnTarget only has to write the changed suffix to
|
|
// removable media.
|
|
func commonPrefixLen(pathA, pathB string) (int64, error) {
|
|
fa, err := os.Open(pathA)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer fa.Close()
|
|
fb, err := os.Open(pathB)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer fb.Close()
|
|
|
|
ra := bufio.NewReaderSize(fa, blackboxArchiveCompareChunk)
|
|
rb := bufio.NewReaderSize(fb, blackboxArchiveCompareChunk)
|
|
bufA := make([]byte, blackboxArchiveCompareChunk)
|
|
bufB := make([]byte, blackboxArchiveCompareChunk)
|
|
|
|
var total int64
|
|
for {
|
|
na, errA := io.ReadFull(ra, bufA)
|
|
nb, errB := io.ReadFull(rb, bufB)
|
|
n := na
|
|
if nb < n {
|
|
n = nb
|
|
}
|
|
for i := 0; i < n; i++ {
|
|
if bufA[i] != bufB[i] {
|
|
return total + int64(i), nil
|
|
}
|
|
}
|
|
total += int64(n)
|
|
if na != nb || isEOFLike(errA) || isEOFLike(errB) {
|
|
return total, nil
|
|
}
|
|
if errA != nil {
|
|
return total, errA
|
|
}
|
|
if errB != nil {
|
|
return total, errB
|
|
}
|
|
}
|
|
}
|
|
|
|
func isEOFLike(err error) bool {
|
|
return err == io.EOF || err == io.ErrUnexpectedEOF
|
|
}
|
|
|
|
// patchArchiveOnTarget makes targetPath (on removable media, possibly
|
|
// FUSE-mounted with synchronous writes) byte-identical to newLocalZipPath (on
|
|
// fast local storage), writing only the changed suffix instead of the whole
|
|
// file. cachedPath is our own local record of what we last wrote to
|
|
// targetPath; if targetPath's size doesn't match what cachedPath implies
|
|
// (first run, external tampering, a previous crash mid-write), it falls back
|
|
// to writing the whole archive rather than risk corrupting it with a wrong
|
|
// truncate point.
|
|
func patchArchiveOnTarget(targetPath, newLocalZipPath, cachedPath string) (retErr error) {
|
|
newInfo, err := os.Stat(newLocalZipPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var prefixLen int64
|
|
if cachedInfo, err := os.Stat(cachedPath); err == nil {
|
|
if targetInfo, err := os.Stat(targetPath); err == nil && targetInfo.Size() == cachedInfo.Size() {
|
|
prefixLen, err = commonPrefixLen(cachedPath, newLocalZipPath)
|
|
if err != nil {
|
|
prefixLen = 0
|
|
}
|
|
}
|
|
}
|
|
if prefixLen > newInfo.Size() {
|
|
prefixLen = 0
|
|
}
|
|
|
|
target, err := os.OpenFile(targetPath, os.O_CREATE|os.O_RDWR, 0644)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { retErr = errors.Join(retErr, target.Close()) }()
|
|
|
|
if err := target.Truncate(prefixLen); err != nil {
|
|
return err
|
|
}
|
|
if _, err := target.Seek(prefixLen, io.SeekStart); err != nil {
|
|
return err
|
|
}
|
|
|
|
src, err := os.Open(newLocalZipPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { retErr = errors.Join(retErr, src.Close()) }()
|
|
if _, err := src.Seek(prefixLen, io.SeekStart); err != nil {
|
|
return err
|
|
}
|
|
if _, err := io.Copy(target, src); err != nil {
|
|
return err
|
|
}
|
|
return target.Sync()
|
|
}
|