205 lines
5.3 KiB
Go
205 lines
5.3 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/binary"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
)
|
|
|
|
const jobStateMagic = "CTJ1"
|
|
|
|
// JobState is the manager-local trust anchor for one named job: the
|
|
// per-block hashes of the destination as of the last confirmed write. It is
|
|
// never derived by reading the destination — only by recording what this
|
|
// tool itself wrote there (or confirmed already matched).
|
|
type JobState struct {
|
|
BlockSize int64
|
|
SrcSpec string
|
|
DstSpec string
|
|
Size int64
|
|
Hashes [][32]byte // zero hash = never synced
|
|
}
|
|
|
|
func statePathFor(stateDir, job string) string {
|
|
return filepath.Join(stateDir, job+".state")
|
|
}
|
|
|
|
func defaultStateDir() string {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return ".clonetool/jobs"
|
|
}
|
|
return filepath.Join(home, ".clonetool", "jobs")
|
|
}
|
|
|
|
// LoadJobState reads the job's state file. If it doesn't exist yet, an
|
|
// empty state is returned (first run). If it exists but was recorded
|
|
// against a different source/dest spec, an error is returned unless force
|
|
// is set, in which case the old hash history is discarded.
|
|
func LoadJobState(path, srcSpec, dstSpec string, blockSize int64, force bool) (*JobState, error) {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return &JobState{BlockSize: blockSize, SrcSpec: srcSpec, DstSpec: dstSpec}, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
defer f.Close()
|
|
|
|
r := bufio.NewReader(f)
|
|
magic := make([]byte, 4)
|
|
if _, err := io.ReadFull(r, magic); err != nil {
|
|
return nil, fmt.Errorf("read job state %s: %w", path, err)
|
|
}
|
|
if string(magic) != jobStateMagic {
|
|
return nil, fmt.Errorf("job state %s: bad magic", path)
|
|
}
|
|
var version uint8
|
|
if err := binary.Read(r, binary.BigEndian, &version); err != nil {
|
|
return nil, err
|
|
}
|
|
if version != 1 {
|
|
return nil, fmt.Errorf("job state %s: unsupported version %d", path, version)
|
|
}
|
|
var storedBlockSize uint32
|
|
if err := binary.Read(r, binary.BigEndian, &storedBlockSize); err != nil {
|
|
return nil, err
|
|
}
|
|
var blockCount uint64
|
|
if err := binary.Read(r, binary.BigEndian, &blockCount); err != nil {
|
|
return nil, err
|
|
}
|
|
storedSrc, err := readLPString(r)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
storedDst, err := readLPString(r)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var size int64
|
|
if err := binary.Read(r, binary.BigEndian, &size); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if !force && (storedSrc != srcSpec || storedDst != dstSpec) {
|
|
return nil, fmt.Errorf(
|
|
"job state %s was recorded for source=%q dest=%q, but this run uses source=%q dest=%q; "+
|
|
"reusing its hash history against different endpoints could skip blocks never written there. "+
|
|
"Pass --force to rebind this job (discards hash history)",
|
|
path, storedSrc, storedDst, srcSpec, dstSpec)
|
|
}
|
|
|
|
hashes := make([][32]byte, blockCount)
|
|
for i := range hashes {
|
|
if _, err := io.ReadFull(r, hashes[i][:]); err != nil {
|
|
return nil, fmt.Errorf("read job state %s: %w", path, err)
|
|
}
|
|
}
|
|
|
|
js := &JobState{BlockSize: int64(storedBlockSize), SrcSpec: srcSpec, DstSpec: dstSpec, Size: size, Hashes: hashes}
|
|
if force {
|
|
js.SrcSpec, js.DstSpec = srcSpec, dstSpec
|
|
}
|
|
if js.BlockSize != blockSize {
|
|
// Block size changed: the stored per-index hashes no longer line up
|
|
// with block boundaries, so start over rather than misinterpret them.
|
|
js.BlockSize = blockSize
|
|
js.Hashes = nil
|
|
}
|
|
return js, nil
|
|
}
|
|
|
|
// Resize grows (with zero/"unknown" hashes) or shrinks the hash table to
|
|
// match a new block count.
|
|
func (js *JobState) Resize(blockCount uint64) {
|
|
if uint64(len(js.Hashes)) == blockCount {
|
|
return
|
|
}
|
|
grown := make([][32]byte, blockCount)
|
|
copy(grown, js.Hashes)
|
|
js.Hashes = grown
|
|
}
|
|
|
|
func (js *JobState) Save(path string) error {
|
|
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
|
return err
|
|
}
|
|
tmp := path + ".tmp"
|
|
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
w := bufio.NewWriter(f)
|
|
writeErr := func() error {
|
|
if _, err := w.WriteString(jobStateMagic); err != nil {
|
|
return err
|
|
}
|
|
if err := binary.Write(w, binary.BigEndian, uint8(1)); err != nil {
|
|
return err
|
|
}
|
|
if err := binary.Write(w, binary.BigEndian, uint32(js.BlockSize)); err != nil {
|
|
return err
|
|
}
|
|
if err := binary.Write(w, binary.BigEndian, uint64(len(js.Hashes))); err != nil {
|
|
return err
|
|
}
|
|
if err := writeLPString(w, js.SrcSpec); err != nil {
|
|
return err
|
|
}
|
|
if err := writeLPString(w, js.DstSpec); err != nil {
|
|
return err
|
|
}
|
|
if err := binary.Write(w, binary.BigEndian, js.Size); err != nil {
|
|
return err
|
|
}
|
|
for _, h := range js.Hashes {
|
|
if _, err := w.Write(h[:]); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return w.Flush()
|
|
}()
|
|
if writeErr != nil {
|
|
f.Close()
|
|
os.Remove(tmp)
|
|
return writeErr
|
|
}
|
|
if err := f.Sync(); err != nil {
|
|
f.Close()
|
|
os.Remove(tmp)
|
|
return err
|
|
}
|
|
if err := f.Close(); err != nil {
|
|
os.Remove(tmp)
|
|
return err
|
|
}
|
|
return os.Rename(tmp, path)
|
|
}
|
|
|
|
func readLPString(r io.Reader) (string, error) {
|
|
var n uint16
|
|
if err := binary.Read(r, binary.BigEndian, &n); err != nil {
|
|
return "", err
|
|
}
|
|
buf := make([]byte, n)
|
|
if _, err := io.ReadFull(r, buf); err != nil {
|
|
return "", err
|
|
}
|
|
return string(buf), nil
|
|
}
|
|
|
|
func writeLPString(w io.Writer, s string) error {
|
|
if len(s) > 65535 {
|
|
return fmt.Errorf("string too long to store (%d bytes)", len(s))
|
|
}
|
|
if err := binary.Write(w, binary.BigEndian, uint16(len(s))); err != nil {
|
|
return err
|
|
}
|
|
_, err := io.WriteString(w, s)
|
|
return err
|
|
}
|