clonetool/jobstate.go
2026-09-05 23:02:29 +02:00

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
}