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 }