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

136 lines
4.0 KiB
Go

package main
import (
"crypto/sha256"
"encoding/json"
"fmt"
"io"
"os"
)
// readBlockAt reads exactly min(len(buf), totalSize-offset) bytes at offset,
// treating a fully-satisfied read as success even if the underlying
// implementation also reports io.EOF for it.
func readBlockAt(f *os.File, buf []byte, offset, totalSize int64) (int, error) {
remaining := totalSize - offset
if remaining <= 0 {
return 0, io.EOF
}
want := int64(len(buf))
if remaining < want {
want = remaining
}
n, err := f.ReadAt(buf[:want], offset)
if err != nil && !(err == io.EOF && int64(n) == want) {
return n, err
}
return n, nil
}
// sourceLoopParams drives the single read -> hash -> compare -> maybe-send
// pass over a source path. It is used both by the standalone
// "source-stream" role (writing to its own stdout) and by a source control
// agent's push driver (writing into a spawned ssh subprocess's stdin).
type sourceLoopParams struct {
File *os.File // already opened for reading by the caller
Size int64
BlockSize int64
Hashes [][32]byte // previous known hashes, len == block count
Out *FrameWriter
OnSkip func(index uint64)
OnSend func(index uint64, hash [32]byte) // called after the DATA frame is written
}
func runSourceLoop(p sourceLoopParams) error {
f := p.File
buf := make([]byte, p.BlockSize)
blockCount := uint64(len(p.Hashes))
for index := uint64(0); index < blockCount; index++ {
offset := int64(index) * p.BlockSize
n, err := readBlockAt(f, buf, offset, p.Size)
if err != nil {
return fmt.Errorf("read %s at block %d: %w", f.Name(), index, err)
}
hash := sha256.Sum256(buf[:n])
if hash == p.Hashes[index] {
if p.OnSkip != nil {
p.OnSkip(index)
}
continue
}
if err := p.Out.WriteFrame(frameData, encodeDataFrame(index, hash, buf[:n])); err != nil {
return fmt.Errorf("send block %d: %w", index, err)
}
if p.OnSend != nil {
p.OnSend(index, hash)
}
}
return p.Out.WriteFrame(frameDone, nil)
}
// destLoopParams drives the receive -> verify -> pwrite pass on the
// destination path. It is used both by the standalone "sink" role (reading
// from its own stdin, and required to ack/err back over AckOut) and by a
// dest control agent's pull driver (reading from a spawned ssh subprocess's
// stdout; no AckOut needed since the write is confirmed locally, in the
// same process, before OnWritten is called).
type destLoopParams struct {
File *os.File // already opened for read/write by the caller
BlockSize int64
In *FrameReader
AckOut *FrameWriter // optional
OnWritten func(index uint64, hash [32]byte)
// OnCtrlMsg handles an interleaved frameCtrlJSON frame (used only in
// pull mode, where the remote source-stream has no other channel back
// to the manager for progress updates). Sink-role callers leave it nil.
OnCtrlMsg func(CtrlMsg)
}
func runDestLoop(p destLoopParams) error {
f := p.File
for {
typ, payload, err := p.In.ReadFrame()
if err != nil {
return fmt.Errorf("read frame: %w", err)
}
switch typ {
case frameDone:
return f.Sync()
case frameData:
index, hash, block, err := decodeDataFrame(payload)
if err != nil {
return err
}
if sha256.Sum256(block) != hash {
if p.AckOut != nil {
_ = p.AckOut.WriteFrame(frameErr, encodeErrFrame(index, "hash mismatch after transfer"))
}
return fmt.Errorf("block %d: hash mismatch after transfer", index)
}
if _, err := f.WriteAt(block, int64(index)*p.BlockSize); err != nil {
if p.AckOut != nil {
_ = p.AckOut.WriteFrame(frameErr, encodeErrFrame(index, err.Error()))
}
return fmt.Errorf("write block %d: %w", index, err)
}
if p.AckOut != nil {
if err := p.AckOut.WriteFrame(frameAck, encodeIndexFrame(index)); err != nil {
return err
}
}
if p.OnWritten != nil {
p.OnWritten(index, hash)
}
case frameCtrlJSON:
if p.OnCtrlMsg != nil {
var m CtrlMsg
if err := json.Unmarshal(payload, &m); err == nil {
p.OnCtrlMsg(m)
}
}
default:
return fmt.Errorf("unexpected frame type %d on data channel", typ)
}
}
}