no file based cloning any more

This commit is contained in:
Alexander Gabriel 2026-09-07 07:35:35 +02:00
parent 21c21e17dd
commit 2c3e341343
25 changed files with 54 additions and 3009 deletions

206
README.md
View File

@ -3,18 +3,7 @@
Block-level sync for a large file or block device between two machines (or
two paths on the same machine), driven from a third, passive "manager"
machine. Single static Go binary, no runtime dependencies beyond the
system `ssh` client for remote endpoints.
Two subcommands:
- **`sync`** — mirror one file/device onto another (the original tool; see
most of this README).
- **`clone-disk`** — clone a whole system disk: boot record + partition
table + per-filesystem data, rebuilt on the target then filled from the
source. Same manager/agent/SSH/self-deploy model as `sync`; leans on the
standard disk tools already present on a rescue system (`sfdisk`,
`ntfsclone`, `partclone.*`, `e2image`, `rsync`, …). See
[clone-disk](#clone-disk) below.
system `ssh` client for remote endpoints. Linux only.
## How it works
@ -72,8 +61,8 @@ Two subcommands:
self-deployed from an older build of this tool) is treated the same as
"not runnable" and triggers the self-deploy above, so the remote binary
is kept in sync with whatever you're running locally. Only the build
timestamp is compared, not `GOOS`/`GOARCH` — those are expected to
differ across a cross-compiled deploy.
timestamp is compared, not `GOARCH` — that is expected to differ across
a cross-compiled deploy.
- **sudo:** if reading or writing an endpoint that is a **block device**
fails with a permission error, `--sudo=auto` (the default) transparently
restarts that side's agent — and the helper it spawns on the peer —
@ -84,8 +73,8 @@ Two subcommands:
start; `--sudo=never` never does.
- Sizing rules:
- Destination is a **block device**: it can't be resized, so if the
source is larger the job fails; otherwise exactly `min(source, dest)`
bytes are synced and the remainder of the device is left untouched.
source is larger the job fails; otherwise exactly the source's size is
synced and the remainder of the device is left untouched.
- Destination is a **regular file**: it's truncated (created if
missing) to exactly the source's size, growing or shrinking it.
Shrinking an existing non-empty file prompts for confirmation unless
@ -97,17 +86,15 @@ Two subcommands:
CGO_ENABLED=0 go build -o clonetool .
```
Cross-compile for another OS/arch by setting `GOOS`/`GOARCH` (no cgo, so
these all work from any host):
Cross-compile for another architecture by setting `GOARCH` (no cgo, so
this works from any host):
```
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o clonetool .
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -o clonetool.exe .
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -o clonetool-darwin .
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o clonetool .
```
`./build.sh` writes all three of the above (plus linux/amd64) into `dist/`.
It also stamps every binary it builds with the same build timestamp (via
`./build.sh` writes linux/amd64 and linux/arm64 binaries into `dist/`. It
also stamps every binary it builds with the same build timestamp (via
`-ldflags -X main.buildTime=...`), which is how the manager tells a stale
self-deployed remote binary apart from a current one (see "Version/staleness
check" above) — build all your binaries for a given release with one
@ -119,171 +106,7 @@ this check (it only ever compares equal to another literal copy of the same
Copy the resulting binary to the manager host. Source and destination
hosts get it automatically (see self-deploy above), or place it yourself
and point `--remote-bin` at it. The binary is architecture-specific —
cross-compile if your hosts differ. Self-deploy streams a POSIX shell
script over SSH, so a **Windows** host can be the manager or a local
endpoint but cannot be an automatic deploy target — put `clonetool.exe`
on it yourself and point `--remote-bin` at it.
## Windows
clonetool runs on Windows and can clone **physical drives and volumes**,
not just files:
```
# Whole disk to an image file
clonetool sync --source \\.\PhysicalDrive2 --dest D:\backup\disk2.img
# Image file back onto a disk (must not be larger than the disk)
clonetool sync --source D:\backup\disk2.img --dest \\.\PhysicalDrive2
# A single volume
clonetool sync --source \\.\E: --dest \\.\F:
```
- Raw-disk paths are `\\.\PhysicalDrive<n>` (whole disk) or `\\.\<X>:` (a
volume). `\\?\` also works. Forward slashes are accepted.
- **Run from an elevated (Administrator) console** to open a raw disk.
There is no `sudo` fallback on Windows; `--sudo` is ignored. A
permission error tells you to elevate.
- A raw-disk **destination** should have no mounted filesystem in use
(take the disk offline in Disk Management, or target a volume that
nothing else has open) — Windows blocks writes to a disk region owned by
a mounted volume. Reading a live disk as the **source** is fine.
- Raw-disk I/O must be sector-aligned. `--block-size` must be a multiple
of the drive's sector size (512 or 4096); the default 4M is. clonetool
handles the final partial block itself.
- Device size is read with `IOCTL_DISK_GET_LENGTH_INFO`.
## clone-disk
`clonetool clone-disk --source LOC --dest LOC [options]` clones a whole
disk — the boot record, the partition table, and the contents of every
partition — by **rebuilding** the layout on the target and then filling it
from the source. Orchestrated from the manager exactly like `sync`: one
control agent per side, data straight between source and target (push, or
pull fallback), self-deploy, `--sudo` escalation.
It does not reimplement any filesystem knowledge — it calls the standard
tools a rescue system already has and just moves their bytes between the two
machines.
### What it copies
1. **Partition table.** The source agent runs `sfdisk -d` (the canonical
restorable dump for both GPT and MBR). The manager turns it into a
device-independent restore script — dropping the `device:` line and
`last-lba` so it re-sizes for the target, keeping the GPT disk GUID and
every partition GUID/PARTUUID (so existing `fstab` / BCD / GRUB
references still resolve) unless `--new-ids` is given. The target agent
feeds that to `sfdisk` and re-reads the table.
2. **Boot record / boot code.** The 440-byte MBR bootstrap on every disk,
plus — on MBR disks — the whole gap before the first partition (where
GRUB's `core.img` lives), are raw-copied through the same block-diff
engine `sync` uses, addressed as byte windows. On GPT disks the
GPT structures come from `sfdisk`/`sgdisk`; a BIOS-boot partition
(`EF02`) is always cloned raw.
3. **Partition data**, per partition, by the best available method:
- **fs-image** (default when the tool exists): stream a
filesystem-aware image — `ntfsclone` for NTFS, `partclone.<fs>` for
ext*/xfs/btrfs/f2fs/fat/exfat, `e2image` as an ext fallback. Only
used blocks move.
- **raw**: the `sync` block-diff engine over the partition's byte
window. Used for swap, unrecognised filesystems, `--raw N,…`, and
whenever no image tool is installed. Re-runs move only changed blocks.
- **file-level** (`--file-level N,…`): `mkfs` on the target + `rsync`
*(planned; not yet implemented — use `--raw` or an fs-image type)*.
### Target sizing (same rules as `sync`)
- **Target is a device:** never grown. If the source layout fits, it is
reproduced as-is and any trailing space on the target is left untouched.
If the last partition(s) overflow, `--allow-shrink` will shrink them
(filesystem then partition, from the last inward) with `ntfsresize` /
`resize2fs` — this **resizes the source filesystem in place** before
imaging, so it also needs `--yes`. Without `--allow-shrink` an
over-large source is a hard error naming the partition.
- **Target is a file:** created and truncated to just what the layout
needs (or `--image-size SIZE`). Shrinking an existing image prompts
unless `--yes`.
### NTFS / Windows partitions
- **From a Linux rescue system: fully supported.** `ntfsclone` clones
Windows NTFS partitions (incl. Win10/11) at cluster level, copying only
used clusters and preserving every NTFS feature. The volume must be
*clean* — Windows Fast Startup and hibernation leave NTFS dirty; boot
Windows once and shut down fully, or run `ntfsfix -d` first. `ntfsclone`
does not fix booting: keep the partition GUIDs (default) so the existing
BCD resolves, or run `bcdboot` from Windows recovery afterwards. This is
the same approach Clonezilla uses.
- **On Windows itself: raw / VSS only.** There is no `ntfsclone` on
Windows. `clone-disk` reads the partition table via
`IOCTL_DISK_GET_DRIVE_LAYOUT_EX`, reproduces it by raw-copying the
leading sectors (and the backup GPT), and block-clones each partition
as a byte window of `\\.\PhysicalDriveN`. With `--vss` (default) it
takes a Volume Shadow Copy of each NTFS volume first for a
crash-consistent point-in-time source. This is **not** free-space-aware
(a `$Bitmap`-driven skip may come later), and shrinking to a smaller
target is not supported when the source is native Windows — use a
target at least as large, or run the clone from a Linux rescue system
to get `ntfsclone`.
### Bootloader
By default `clone-disk` only *reproduces* boot structures and preserves
disk/partition IDs — enough for a like-for-like replacement disk to boot.
`--reinstall-bootloader` additionally mounts the cloned root (+ ESP),
bind-mounts `/dev /proc /sys`, and runs `grub-install` + `update-grub` /
`grub-mkconfig` in a chroot (Linux targets). It is best-effort and never
fails the clone. For Windows, run `bcdboot C:\Windows /s S: /f ALL` from a
recovery environment.
### Examples
```
# Whole disk to an image file (image is sized to the layout)
clonetool clone-disk --source /dev/sda --dest /srv/sda.img
# Disk to disk on another host, orchestrated from a third machine
clonetool clone-disk --source box1:/dev/nvme0n1 --dest box2:/dev/nvme0n1
# Onto a smaller SSD, shrinking the last (data) partition to fit
clonetool clone-disk --source /dev/sda --dest /dev/sdb --allow-shrink --yes
# Fresh IDs so the clone can sit next to the original
clonetool clone-disk --source /dev/sda --dest /dev/sdb --new-ids
# Force a raw block clone of a partition an image tool would otherwise handle
clonetool clone-disk --source /dev/sda --dest /dev/sdb --raw 2
```
### Tools the agents call (checked at probe time, reported if missing)
`sfdisk`, `sgdisk`, `partprobe`/`blockdev`, `blkid`, `lsblk`, `losetup`,
`ntfsclone`, `ntfsresize`, `ntfsfix`, `partclone.*`, `e2image`,
`resize2fs`, `dumpe2fs`, `e2fsck`, `mkfs.*`, `mount`/`umount`, `rsync`,
`grub-install`, `update-grub`/`grub-mkconfig`. Windows: `vssadmin` /
`wmic`, `bcdboot`, `diskpart`. A missing tool just means the affected
partitions fall back to a raw block copy (or the run stops with a clear
message if that isn't safe, e.g. a partition that must shrink).
### clone-disk options
| Flag | Default | Meaning |
|---|---|---|
| `--parts LIST` | all | Only clone these partition numbers (data only; the table still lists them all). |
| `--raw LIST` | — | Force a raw block clone for these partitions. |
| `--file-level LIST` | — | `mkfs` + `rsync` these partitions (not yet implemented). |
| `--file-level-auto` | off | Use file-level for fs types with no image cloner. |
| `--allow-shrink` | off | Permit shrinking trailing partitions to fit a smaller target (resizes the **source** fs in place; needs `--yes`). |
| `--no-shrink` | off | Never shrink; fail if the target is too small. |
| `--new-ids` | off | Randomize the GPT disk GUID / MBR signature on the target. |
| `--reinstall-bootloader` | off | Run `grub-install` / `grub-mkconfig` on the target after copy (Linux). |
| `--vss` | on | Windows source: take a Volume Shadow Copy per NTFS volume. |
| `--image-size SIZE` | auto | File target: image size (default = enough for the layout). |
`--block-size --job --yes --sudo --deploy --ssh --ssh-opt --remote-bin
--connect-timeout --manager-host` mean the same as for `sync`.
cross-compile if your hosts differ.
## Usage
@ -298,6 +121,9 @@ clonetool sync --source LOC --dest LOC [options]
# Same machine
clonetool sync --source /dev/sda --dest /dev/sdb
# Whole disk to an image file
clonetool sync --source /dev/sda --dest /srv/sda.img
# Two remote machines, orchestrated from a third
clonetool sync --source db1:/dev/vdb --dest backup-host:/srv/db1.img
@ -332,9 +158,7 @@ and `wr(dst)` is the destination actually writing changed blocks.
## Caveats
- Block-device size detection is implemented on Linux (`BLKGETSIZE64`) and
Windows (`IOCTL_DISK_GET_LENGTH_INFO`). On other systems only regular
files can be synced.
- Block-device size detection uses `BLKGETSIZE64`; the tool is Linux only.
- If a destination path doesn't exist yet, it's created as a regular
file — clonetool won't create device nodes, so double-check device
paths for typos before running.

View File

@ -16,15 +16,11 @@ import (
func cmdAgent(args []string) error {
fs := flag.NewFlagSet("agent", flag.ContinueOnError)
role := fs.String("role", "", "control|sink|source-stream|fs-send|fs-recv (internal)")
role := fs.String("role", "", "control|sink|source-stream (internal)")
path := fs.String("path", "", "path to read/write")
size := fs.Int64("size", 0, "total sync size in bytes")
base := fs.Int64("base", 0, "byte offset the window starts at (clone-disk boot region / offset partition)")
base := fs.Int64("base", 0, "byte offset the window starts at")
blockSize := fs.Int64("block-size", defaultBlockSize, "block size in bytes")
fsType := fs.String("fs", "", "filesystem type (fs-send/fs-recv)")
fsTool := fs.String("fstool", "", "fs-image tool family (fs-send/fs-recv)")
peerDisk := fs.String("peerdisk", "", "this helper's whole-disk/image path (fs-send/fs-recv)")
shrinkTo := fs.Int64("shrink", 0, "shrink this fs to N bytes before sending (fs-send)")
if err := fs.Parse(args); err != nil {
return err
}
@ -36,12 +32,6 @@ func cmdAgent(args []string) error {
return runSinkRole(*path, *base, *size, *blockSize)
case "source-stream":
return runSourceStreamRole(*path, *base, *size, *blockSize)
case "fs-send":
n, _ := strconv.Atoi(*path)
return runFSSendRole(n, *fsType, *fsTool, *peerDisk, *shrinkTo)
case "fs-recv":
n, _ := strconv.Atoi(*path)
return runFSRecvRole(n, *fsType, *fsTool, *peerDisk)
default:
return fmt.Errorf("agent: unknown or missing --role %q", *role)
}
@ -77,16 +67,7 @@ func runControlAgent() error {
runPushDriver(m, out)
case msgConnectPull:
runPullDriver(m, out)
case msgProbeDisk:
handleProbeDisk(out, m)
case msgBuildLayout:
handleBuildLayout(out, m)
case msgClonePartition:
handleClonePartition(out, m)
case msgReinstallBoot:
handleReinstallBoot(out, m)
case msgClose:
detachAllDisks()
_ = out.WriteJSON(CtrlMsg{Type: msgBye})
return nil
default:
@ -579,11 +560,33 @@ func runSinkRole(path string, base, size, blockSize int64) error {
// block is always hashed before a write for it can arrive (the source
// only sends after seeing that block's hash), so the two accesses to f
// never race on the same region.
// While the destination scan runs, emit a heartbeat on a fixed cadence
// even if streamHashBlocks is parked inside one slow ReadAt (a big /
// non-sparse / remote target). The push driver's idle watchdog counts any
// frame from the sink as activity, so this stops a legitimately slow scan
// from being declared a stalled sink during a check-only run, where there
// is no DATA/ACK traffic to feed the watchdog. A sink that has actually
// died stops heartbeating and its pipe closes, so the watchdog still fires.
scanDone := make(chan struct{})
go func() {
t := time.NewTicker(10 * time.Second)
defer t.Stop()
for {
select {
case <-scanDone:
return
case <-t.C:
_ = out.WriteJSON(CtrlMsg{Type: msgProgress, Phase: "scan"})
}
}
}()
hashErrCh := make(chan error, 1)
go func() {
err := streamHashBlocks(f, base, size, blockSize, align, func(bh blockHash) error {
return out.WriteFrame(frameBlockHash, encodeBlockHashFrame(bh.index, bh.hash))
}, scanProgressEmitter(out))
close(scanDone)
if err == nil {
err = out.WriteFrame(frameHashDone, nil)
} else {

View File

@ -1,375 +0,0 @@
package main
import (
"bufio"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"strconv"
"strings"
)
// ----------------------------------------------------------------------------
// clone-disk agent state: loop-device / mount attachments held for the life
// of the control agent so several partition clones can reuse them.
// ----------------------------------------------------------------------------
var diskAttach = map[string]*diskAttachment{}
func attachedNode(diskPath string, num int, writable bool) (string, error) {
a := diskAttach[diskPath]
if a == nil {
var err error
a, err = attachDisk(diskPath, writable)
if err != nil {
return "", err
}
diskAttach[diskPath] = a
}
return partNode(a.blockPath, num), nil
}
func detachAllDisks() {
for k, a := range diskAttach {
if a.cleanup != nil {
a.cleanup()
}
delete(diskAttach, k)
}
}
// ----------------------------------------------------------------------------
// probe_disk
// ----------------------------------------------------------------------------
func handleProbeDisk(out *FrameWriter, m CtrlMsg) {
d, err := probeDiskLayout(m.Path)
if err != nil {
_ = out.WriteJSON(CtrlMsg{Type: msgError, Message: err.Error(), NeedPriv: isPermErr(err)})
return
}
_ = out.WriteJSON(CtrlMsg{Type: msgProbeDiskOK, LayoutJSON: d.JSON()})
}
// ----------------------------------------------------------------------------
// build_layout: create/size an image, write the partition table, re-read it.
// The boot-region bytes and the partition data are moved afterwards by the
// manager through ordinary push/pull transfers.
// ----------------------------------------------------------------------------
type buildOpts struct {
Script string `json:"script"` // sfdisk restore script (Linux)
IsFile bool `json:"isFile"` // target is a regular file, not a device
ImageSize int64 `json:"imageSize"` // truncate the file to this many bytes first
}
func handleBuildLayout(out *FrameWriter, m CtrlMsg) {
var o buildOpts
if err := json.Unmarshal([]byte(m.Options), &o); err != nil {
_ = out.WriteJSON(CtrlMsg{Type: msgError, Message: "decode build opts: " + err.Error()})
return
}
if o.IsFile {
f, err := os.OpenFile(m.Path, os.O_CREATE|os.O_RDWR, 0644)
if err != nil {
_ = out.WriteJSON(CtrlMsg{Type: msgError, Message: err.Error(), NeedPriv: isPermErr(err)})
return
}
if err := f.Truncate(o.ImageSize); err != nil {
f.Close()
_ = out.WriteJSON(CtrlMsg{Type: msgError, Message: fmt.Sprintf("truncate %s: %v", m.Path, err)})
return
}
f.Close()
}
if strings.TrimSpace(o.Script) != "" {
if err := applyPartitionTable(m.Path, o.Script); err != nil {
_ = out.WriteJSON(CtrlMsg{Type: msgError, Message: err.Error(), NeedPriv: isPermErr(err)})
return
}
} else {
rereadPartTable(m.Path)
}
_ = out.WriteJSON(CtrlMsg{Type: msgBuildLayoutOK})
}
// ----------------------------------------------------------------------------
// clone_partition: fs-image and file-level methods. (Raw partitions and boot
// regions go through connect_push / connect_pull instead.)
//
// The message is sent to the SOURCE control agent first (Method set, peer
// fields as for connect_push). On a push failure the manager re-sends it to
// the DEST control agent with m.Reason == "pull".
// ----------------------------------------------------------------------------
func handleClonePartition(out *FrameWriter, m CtrlMsg) {
o, err := decodeFSCloneOpts(m.Options)
if err != nil {
_ = out.WriteJSON(CtrlMsg{Type: msgError, Message: "decode clone opts: " + err.Error()})
return
}
switch m.Method {
case "fs-image":
if m.Reason == "pull" {
runFSPullDriver(m, o, out)
} else {
runFSPushDriver(m, o, out)
}
case "file-level":
runFileLevelDriver(m, o, out)
default:
_ = out.WriteJSON(CtrlMsg{Type: msgError, Message: "unknown clone method " + m.Method})
}
}
// localDevice resolves this side's partition node. m.Path is the whole disk
// (or image); m.PartIndex is the 1-based partition number.
func localDevice(diskPath string, num int, writable bool) (string, error) {
if isDevicePath(diskPath) || looksLikeBlockDevice(diskPath) {
// real disk: node is derived directly, no attach needed
return partNode(diskPath, num), nil
}
return attachedNode(diskPath, num, writable)
}
func looksLikeBlockDevice(p string) bool {
fi, err := os.Stat(p)
return err == nil && fi.Mode()&os.ModeDevice != 0
}
// runFSPushDriver runs in the SOURCE agent: spawn the peer fs-recv (ssh or
// local), run the save tool locally, pipe save-stdout -> peer-stdin.
func runFSPushDriver(m CtrlMsg, o fsCloneOpts, out *FrameWriter) {
dev, err := localDevice(m.Path, m.PartIndex, false)
if err != nil {
_ = out.WriteJSON(CtrlMsg{Type: msgPushFailed, Reason: "source device: " + err.Error()})
return
}
cl := pickCloner(dev, o.FSType, o.Tool)
if cl == nil {
_ = out.WriteJSON(CtrlMsg{Type: msgError, Message: "no fs-image cloner for " + o.FSType})
return
}
if o.ShrinkToBytes > 0 {
_ = out.WriteJSON(CtrlMsg{Type: msgProgress, Stage: "shrink source " + o.FSType + " p" + strconv.Itoa(m.PartIndex)})
if err := preShrinkFS(dev, o.FSType, o.ShrinkToBytes); err != nil {
_ = out.WriteJSON(CtrlMsg{Type: msgError, Message: "pre-shrink: " + err.Error()})
return
}
}
peerTail := []string{"agent", "--role", "fs-recv",
"--path", strconv.Itoa(m.PartIndex), // partition number; peer resolves its own node
"--fs", o.FSType, "--fstool", o.Tool,
"--size", strconv.FormatInt(o.SizeBytes, 10),
"--peerdisk", m.PeerPath,
}
peer := peerAgentCommand(m, peerTail)
peerIn, err := peer.StdinPipe()
if err != nil {
_ = out.WriteJSON(CtrlMsg{Type: msgPushFailed, Reason: err.Error()})
return
}
peerOut, _ := peer.StdoutPipe()
peerErr := newLimitedBuffer(8192)
peer.Stderr = peerErr
if err := peer.Start(); err != nil {
_ = out.WriteJSON(CtrlMsg{Type: msgPushFailed, Reason: "start peer: " + err.Error()})
return
}
save := exec.Command(cl.save[0], cl.save[1:]...)
save.Stdout = peerIn
saveErr, _ := save.StderrPipe()
if err := save.Start(); err != nil {
_ = peer.Process.Kill()
_ = out.WriteJSON(CtrlMsg{Type: msgError, Message: fmt.Sprintf("start %s: %v", cl.save[0], err)})
return
}
var moved int64
go scanCloneProgress(saveErr, o.SizeBytes, func(b int64) {
moved = b
_ = out.WriteJSON(CtrlMsg{Type: msgProgress, Stage: o.FSType + " p" + strconv.Itoa(m.PartIndex),
BytesCopied: b, BytesTotal: o.SizeBytes, PartIndex: m.PartIndex})
})
saveWait := make(chan error, 1)
go func() { saveWait <- save.Wait() }()
sErr := <-saveWait
_ = peerIn.Close()
peerLine := readLine(peerOut)
pErr := peer.Wait()
if sErr != nil {
_ = out.WriteJSON(CtrlMsg{Type: msgError, Message: fmt.Sprintf("%s: %v", cl.save[0], sErr)})
return
}
if pErr != nil || !strings.HasPrefix(peerLine, "OK") {
reason := strings.TrimSpace(peerLine + " " + peerErr.String())
if moved == 0 {
_ = out.WriteJSON(CtrlMsg{Type: msgPushFailed, Reason: reason})
return
}
_ = out.WriteJSON(CtrlMsg{Type: msgError, Message: "peer restore failed: " + reason})
return
}
_ = out.WriteJSON(CtrlMsg{Type: msgProgress, BytesCopied: o.SizeBytes, BytesTotal: o.SizeBytes, PartIndex: m.PartIndex})
_ = out.WriteJSON(CtrlMsg{Type: msgPushOK})
}
// runFSPullDriver runs in the DEST agent: spawn the peer fs-send, run the
// restore tool locally, pipe peer-stdout -> restore-stdin.
func runFSPullDriver(m CtrlMsg, o fsCloneOpts, out *FrameWriter) {
dev, err := localDevice(m.Path, m.PartIndex, true)
if err != nil {
_ = out.WriteJSON(CtrlMsg{Type: msgPullFailed, Reason: "dest device: " + err.Error()})
return
}
cl := pickCloner(dev, o.FSType, o.Tool)
if cl == nil {
_ = out.WriteJSON(CtrlMsg{Type: msgError, Message: "no fs-image cloner for " + o.FSType})
return
}
peerTail := []string{"agent", "--role", "fs-send",
"--path", strconv.Itoa(m.PartIndex),
"--fs", o.FSType, "--fstool", o.Tool,
"--peerdisk", m.PeerPath,
"--shrink", strconv.FormatInt(o.ShrinkToBytes, 10),
}
peer := peerAgentCommand(m, peerTail)
peerOut, err := peer.StdoutPipe()
if err != nil {
_ = out.WriteJSON(CtrlMsg{Type: msgPullFailed, Reason: err.Error()})
return
}
peerErr := newLimitedBuffer(8192)
peer.Stderr = peerErr
if err := peer.Start(); err != nil {
_ = out.WriteJSON(CtrlMsg{Type: msgPullFailed, Reason: "start peer: " + err.Error()})
return
}
restore := exec.Command(cl.restore[0], cl.restore[1:]...)
restore.Stdin = peerOut
restoreErr, _ := restore.StderrPipe()
if err := restore.Start(); err != nil {
_ = peer.Process.Kill()
_ = out.WriteJSON(CtrlMsg{Type: msgError, Message: fmt.Sprintf("start %s: %v", cl.restore[0], err)})
return
}
go scanCloneProgress(restoreErr, o.SizeBytes, func(b int64) {
_ = out.WriteJSON(CtrlMsg{Type: msgProgress, Stage: o.FSType + " p" + strconv.Itoa(m.PartIndex),
BytesCopied: b, BytesTotal: o.SizeBytes, PartIndex: m.PartIndex})
})
rErr := restore.Wait()
pErr := peer.Wait()
if pErr != nil {
if rErr != nil {
_ = out.WriteJSON(CtrlMsg{Type: msgPullFailed, Reason: strings.TrimSpace(peerErr.String())})
return
}
}
if rErr != nil {
_ = out.WriteJSON(CtrlMsg{Type: msgError, Message: fmt.Sprintf("%s: %v", cl.restore[0], rErr)})
return
}
_ = out.WriteJSON(CtrlMsg{Type: msgProgress, BytesCopied: o.SizeBytes, BytesTotal: o.SizeBytes, PartIndex: m.PartIndex})
_ = out.WriteJSON(CtrlMsg{Type: msgPullOK})
}
// ----------------------------------------------------------------------------
// fs-send / fs-recv: one-shot stdio helpers spawned on the peer.
// ----------------------------------------------------------------------------
func runFSSendRole(partNum int, fsType, tool, peerDisk string, shrinkTo int64) error {
dev, err := resolveHelperDevice(peerDisk, partNum, shrinkTo > 0)
if err != nil {
return err
}
cl := pickCloner(dev, fsType, tool)
if cl == nil {
return fmt.Errorf("no fs-image cloner for %s", fsType)
}
if shrinkTo > 0 {
if err := preShrinkFS(dev, fsType, shrinkTo); err != nil {
return fmt.Errorf("pre-shrink: %w", err)
}
}
cmd := exec.Command(cl.save[0], cl.save[1:]...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
func runFSRecvRole(partNum int, fsType, tool, peerDisk string) error {
dev, err := resolveHelperDevice(peerDisk, partNum, true)
if err != nil {
fmt.Fprintf(os.Stderr, "ERR: %v\n", err)
return err
}
cl := pickCloner(dev, fsType, tool)
if cl == nil {
err := fmt.Errorf("no fs-image cloner for %s", fsType)
fmt.Fprintf(os.Stdout, "ERR: %v\n", err)
return err
}
cmd := exec.Command(cl.restore[0], cl.restore[1:]...)
cmd.Stdin = os.Stdin
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
fmt.Fprintf(os.Stdout, "ERR: %v\n", err)
return err
}
fmt.Fprintln(os.Stdout, "OK")
return nil
}
// resolveHelperDevice is used by the one-shot fs-send/fs-recv helpers, which
// have no persistent attachment map of their own.
func resolveHelperDevice(disk string, num int, writable bool) (string, error) {
if isDevicePath(disk) || looksLikeBlockDevice(disk) {
return partNode(disk, num), nil
}
a, err := attachDisk(disk, writable)
if err != nil {
return "", err
}
// deliberately leak the loop device until process exit; a one-shot
// helper is short-lived and the parent detaches on the control side.
_ = a
return partNode(a.blockPath, num), nil
}
// ----------------------------------------------------------------------------
// file-level: mkfs on the target partition + rsync the tree across.
// ----------------------------------------------------------------------------
func runFileLevelDriver(m CtrlMsg, o fsCloneOpts, out *FrameWriter) {
_ = out.WriteJSON(CtrlMsg{Type: msgError,
Message: "file-level clone (--file-level) is not implemented yet; use --raw or an fs-image type"})
}
// ----------------------------------------------------------------------------
// reinstall_boot: best-effort, never fatal to the clone.
// ----------------------------------------------------------------------------
func handleReinstallBoot(out *FrameWriter, m CtrlMsg) {
msg := reinstallBootloader(m.Path, m.Options)
_ = out.WriteJSON(CtrlMsg{Type: msgReinstallBootOK, Message: msg})
}
// readLine reads one \n-terminated line (used for the fs-recv status line).
func readLine(r io.Reader) string {
if r == nil {
return ""
}
br := bufio.NewReader(r)
s, _ := br.ReadString('\n')
return strings.TrimSpace(s)
}

View File

@ -1,84 +0,0 @@
//go:build linux
package main
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"strings"
)
// reinstallBootloader mounts the freshly cloned root (and ESP), bind-mounts
// the API filesystems, and runs grub-install + grub-mkconfig inside a chroot.
// It is best-effort: it returns a human summary and never a hard error, so a
// clone still completes even when boot repair cannot.
func reinstallBootloader(diskPath, optsJSON string) string {
var o bootOpts
if err := json.Unmarshal([]byte(optsJSON), &o); err != nil {
return "skipped: bad boot opts: " + err.Error()
}
if o.RootPart == 0 {
return "skipped: no root partition identified (pass --reinstall-bootloader only for a Linux system disk)"
}
att, err := attachDisk(diskPath, true)
if err != nil {
return "skipped: attach: " + err.Error()
}
defer att.cleanup()
rootNode := partNode(att.blockPath, o.RootPart)
root, umount, err := mountAt(rootNode, false)
if err != nil {
return "skipped: mount root: " + err.Error()
}
defer umount()
if o.ESPPart != 0 {
espNode := partNode(att.blockPath, o.ESPPart)
espDir := root + "/boot/efi"
_ = os.MkdirAll(espDir, 0755)
if out, err := exec.Command("mount", espNode, espDir).CombinedOutput(); err != nil {
return "skipped: mount ESP: " + strings.TrimSpace(string(out))
}
defer exec.Command("umount", espDir).Run()
}
var log strings.Builder
for _, d := range []string{"dev", "proc", "sys", "run"} {
_ = exec.Command("mount", "--bind", "/"+d, root+"/"+d).Run()
defer exec.Command("umount", "-l", root+"/"+d).Run()
}
grubTarget := att.blockPath
if isDevicePath(diskPath) || looksLikeBlockDevice(diskPath) {
grubTarget = diskPath
}
giArgs := []string{root, "grub-install", "--recheck"}
if o.UEFI {
giArgs = append(giArgs, "--target=x86_64-efi", "--efi-directory=/boot/efi", "--removable")
} else {
giArgs = append(giArgs, grubTarget)
}
if out, err := exec.Command("chroot", giArgs...).CombinedOutput(); err != nil {
fmt.Fprintf(&log, "grub-install failed: %s; ", strings.TrimSpace(string(out)))
} else {
log.WriteString("grub-install ok; ")
}
mkcfg := "grub-mkconfig"
if _, err := exec.LookPath("update-grub"); err == nil {
mkcfg = "update-grub"
}
args := []string{root, mkcfg}
if mkcfg == "grub-mkconfig" {
args = append(args, "-o", "/boot/grub/grub.cfg")
}
if out, err := exec.Command("chroot", args...).CombinedOutput(); err != nil {
fmt.Fprintf(&log, "%s failed: %s", mkcfg, strings.TrimSpace(string(out)))
} else {
log.WriteString(mkcfg + " ok")
}
return log.String()
}

View File

@ -1,13 +0,0 @@
//go:build !linux
package main
// reinstallBootloader is Linux-only for now. On Windows the recommended path
// is to preserve the disk signature / partition GUIDs (clone-disk does this by
// raw-copying the leading sectors) so the existing BCD store still resolves,
// and otherwise run `bcdboot W:\Windows /s S: /f ALL` from a WinPE/recovery
// environment by hand.
func reinstallBootloader(diskPath, optsJSON string) string {
return "skipped: --reinstall-bootloader is only implemented on Linux; " +
"run bcdboot from Windows recovery, or grub-install from a Linux rescue system"
}

View File

@ -15,10 +15,6 @@ build() {
build linux amd64 clonetool-linux-amd64
build linux arm64 clonetool-linux-arm64
build windows amd64 clonetool-windows-amd64.exe
build windows arm64 clonetool-windows-arm64.exe
build darwin amd64 clonetool-darwin-amd64
build darwin arm64 clonetool-darwin-arm64
echo "done:"
ls -la dist/

View File

@ -1,261 +0,0 @@
package main
import (
"encoding/json"
"errors"
"fmt"
"os"
"sort"
"strings"
)
// CloneDiskConfig is everything `clonetool clone-disk` needs. It embeds
// SyncConfig for the transport/deploy/ssh knobs shared with `sync`.
type CloneDiskConfig struct {
SyncConfig
Parts []int
Raw []int
FileLevel []int
FileAuto bool
AllowShrink bool
NoShrink bool
NewIDs bool
ReinstallBoot bool
VSS bool
ImageSize int64
}
func runCloneDisk(cfg CloneDiskConfig) error {
srcSpec, err := parseSpec(cfg.Source)
if err != nil {
return fmt.Errorf("--source: %w", err)
}
dstSpec, err := parseSpec(cfg.Dest)
if err != nil {
return fmt.Errorf("--dest: %w", err)
}
if err := checkNotSame(srcSpec, dstSpec); err != nil {
return err
}
srcRemoteBin, dstRemoteBin := cfg.RemoteBin, cfg.RemoteBin
if !srcSpec.IsLocal() {
if srcRemoteBin, err = resolveRemoteBin(&cfg.SyncConfig, srcSpec, "source"); err != nil {
return err
}
}
if !dstSpec.IsLocal() {
if !srcSpec.IsLocal() && sameHost(srcSpec, dstSpec) {
dstRemoteBin = srcRemoteBin
} else if dstRemoteBin, err = resolveRemoteBin(&cfg.SyncConfig, dstSpec, "dest"); err != nil {
return err
}
}
// --- bring up both control agents -------------------------------------
srcCtrl, srcLayout, srcSudo, err := bringUpProbe(srcSpec, "source", &cfg.SyncConfig, srcRemoteBin)
if err != nil {
return fmt.Errorf("source: %w", err)
}
defer srcCtrl.Close()
dstCtrl, dstInfo, dstSudo, err := bringUpController(dstSpec, "dest", &cfg.SyncConfig, dstRemoteBin, dstSpec.Path)
if err != nil {
return fmt.Errorf("dest: %w", err)
}
defer dstCtrl.Close()
isFile := !(dstInfo.Exists && dstInfo.IsDevice)
destSize := dstInfo.Size
fmt.Fprintf(os.Stderr, "source %s: %s, %s, %d partitions\n",
srcSpec, srcLayout.Scheme, humanBytes(srcLayout.DiskSize), len(srcLayout.Partitions))
reportMissingTools(srcLayout)
// --- plan the target -------------------------------------------------
plan, err := planTargetLayout(srcLayout, isFile, destSize, planOpts{
Parts: intSet(cfg.Parts),
Raw: intSet(cfg.Raw),
FileLevel: intSet(cfg.FileLevel),
FileAuto: cfg.FileAuto,
AllowShrink: cfg.AllowShrink && !cfg.NoShrink,
NewIDs: cfg.NewIDs,
ImageSize: cfg.ImageSize,
})
if err != nil {
return err
}
for _, n := range plan.Notes {
fmt.Fprintf(os.Stderr, " %s\n", n)
}
shrinks := false
for _, pp := range plan.Parts {
if pp.ShrinkToB > 0 {
shrinks = true
}
}
if shrinks && !cfg.Yes {
return fmt.Errorf("shrinking resizes the SOURCE filesystem(s) in place before imaging; " +
"re-run with --yes to confirm, or use a larger target")
}
if isFile && !cfg.Yes && dstInfo.Exists && dstInfo.Size > plan.ImageSize {
if !confirmShrink(dstSpec, dstInfo.Size, plan.ImageSize) {
return fmt.Errorf("aborted")
}
}
// --- rebuild the partition table on the target ---------------------
bo := buildOpts{Script: plan.Script, IsFile: isFile, ImageSize: plan.ImageSize}
boJSON, _ := json.Marshal(bo)
fmt.Fprintf(os.Stderr, "rebuilding partition table on %s ...\n", dstSpec)
if err := dstCtrl.BuildLayout(dstSpec.Path, string(boJSON)); err != nil {
return fmt.Errorf("build layout: %w", err)
}
bothLocal := srcSpec.IsLocal() && dstSpec.IsLocal()
srcHost, srcUser := resolveConnectHost(srcSpec, &cfg.SyncConfig)
dstHost, dstUser := resolveConnectHost(dstSpec, &cfg.SyncConfig)
base := func(s Spec, remoteBin string) CtrlMsg {
return CtrlMsg{
BlockSize: cfg.BlockSize, SSHBin: cfg.SSHBin, SSHOpts: cfg.SSHOpts,
ConnectTimeoutSec: cfg.ConnectTimeoutSec, PeerLocal: bothLocal,
}
}
_ = base
// --- copy the boot regions verbatim (offset windows) ---------------
for _, r := range plan.BootRegions {
if r.Length <= 0 {
continue
}
fmt.Fprintf(os.Stderr, "boot region @%d (%s) %s ...\n", r.Offset, humanBytes(r.Length), r.Note)
if err := runWindowTransfer(srcCtrl, dstCtrl, windowJob{
srcPath: srcSpec.Path, dstPath: dstSpec.Path,
srcBase: r.Offset, dstBase: r.Offset, size: r.Length,
bothLocal: bothLocal, blockSize: cfg.BlockSize, cfg: &cfg.SyncConfig,
srcHost: srcHost, srcUser: srcUser, dstHost: dstHost, dstUser: dstUser,
srcRemoteBin: srcRemoteBin, dstRemoteBin: dstRemoteBin,
srcSudo: srcSudo, dstSudo: dstSudo,
}); err != nil {
return fmt.Errorf("boot region @%d: %w", r.Offset, err)
}
}
// --- per-partition data ------------------------------------------------
for i, pp := range plan.Parts {
tag := fmt.Sprintf("partition %d/%d (p%d %s, %s)", i+1, len(plan.Parts), pp.Num, orDash(pp.FSType), pp.Method)
fmt.Fprintf(os.Stderr, "%s ...\n", tag)
switch pp.Method {
case "raw":
sz := pp.SrcSizeB
if pp.DstSizeB < sz {
sz = pp.DstSizeB
}
if err := runWindowTransfer(srcCtrl, dstCtrl, windowJob{
srcPath: srcSpec.Path, dstPath: dstSpec.Path,
srcBase: pp.SrcStartB, dstBase: pp.DstStartB, size: sz,
bothLocal: bothLocal, blockSize: cfg.BlockSize, cfg: &cfg.SyncConfig,
srcHost: srcHost, srcUser: srcUser, dstHost: dstHost, dstUser: dstUser,
srcRemoteBin: srcRemoteBin, dstRemoteBin: dstRemoteBin,
srcSudo: srcSudo, dstSudo: dstSudo,
}); err != nil {
return fmt.Errorf("%s: %w", tag, err)
}
case "fs-image":
if err := runFSImagePartition(srcCtrl, dstCtrl, pp, cloneCtx{
srcPath: srcSpec.Path, dstPath: dstSpec.Path, bothLocal: bothLocal,
cfg: &cfg.SyncConfig, srcHost: srcHost, srcUser: srcUser,
dstHost: dstHost, dstUser: dstUser,
srcRemoteBin: srcRemoteBin, dstRemoteBin: dstRemoteBin,
srcSudo: srcSudo, dstSudo: dstSudo,
}); err != nil {
return fmt.Errorf("%s: %w", tag, err)
}
case "file-level":
return fmt.Errorf("%s: file-level clone is not implemented yet", tag)
}
}
// --- bootloader (opt-in) --------------------------------------------
if cfg.ReinstallBoot {
bootJSON, _ := json.Marshal(bootOpts{
RootPart: plan.RootPart, ESPPart: plan.ESPPart, UEFI: plan.UEFI, DiskPath: dstSpec.Path,
})
fmt.Fprintf(os.Stderr, "reinstalling bootloader on %s ...\n", dstSpec)
summary, err := dstCtrl.ReinstallBoot(dstSpec.Path, string(bootJSON))
if err != nil {
fmt.Fprintf(os.Stderr, " bootloader: %v\n", err)
} else {
fmt.Fprintf(os.Stderr, " bootloader: %s\n", summary)
}
}
label := cfg.Job
if label == "" {
label = fmt.Sprintf("%s -> %s", srcSpec, dstSpec)
}
fmt.Fprintf(os.Stderr, "done: %s\n", label)
return nil
}
// bringUpProbe starts a source control agent and runs probe_disk, retrying
// once under sudo on a permission error (mirrors bringUpController).
func bringUpProbe(spec Spec, tag string, cfg *SyncConfig, remoteBin string) (*Controller, *DiskLayout, bool, error) {
sudo := cfg.Sudo == "always" && canElevate()
c, err := startController(spec, tag, cfg, remoteBin, sudo)
if err != nil {
return nil, nil, sudo, err
}
lay, err := c.ProbeDisk(spec.Path)
if err == nil {
return c, lay, sudo, nil
}
if errors.Is(err, errNeedPriv) && cfg.Sudo == "auto" && !sudo && canElevate() {
fmt.Fprintf(os.Stderr, "%s: permission denied on %s; retrying via sudo ...\n", tag, spec.Path)
c.Close()
sudo = true
if c, err = startController(spec, tag, cfg, remoteBin, true); err != nil {
return nil, nil, sudo, err
}
if lay, err = c.ProbeDisk(spec.Path); err == nil {
return c, lay, sudo, nil
}
}
c.Close()
return nil, nil, sudo, err
}
func reportMissingTools(d *DiskLayout) {
want := []string{"sfdisk", "ntfsclone", "partclone.extfs", "partclone.restore", "e2image"}
var miss []string
for _, t := range want {
if !d.Tools[t] {
miss = append(miss, t)
}
}
if len(miss) > 0 {
fmt.Fprintf(os.Stderr, " note: not present on source: %s (affected partitions fall back to raw block copy)\n",
strings.Join(miss, ", "))
}
}
func orDash(s string) string {
if s == "" {
return "-"
}
return s
}
func intSet(xs []int) map[int]bool {
m := make(map[int]bool, len(xs))
for _, x := range xs {
m[x] = true
}
return m
}
func sortedParts(ps []Partition) []Partition {
out := append([]Partition(nil), ps...)
sort.Slice(out, func(i, j int) bool { return out[i].Start < out[j].Start })
return out
}

View File

@ -1,117 +0,0 @@
package main
import (
"fmt"
"os"
)
// windowJob raw-copies a byte window [srcBase,srcBase+size) of the source
// disk onto [dstBase,dstBase+size) of the target, through the same
// push/pull block-diff engine `sync` uses. Used for boot regions and for
// partitions cloned with method "raw".
type windowJob struct {
srcPath, dstPath string
srcBase, dstBase int64
size int64
bothLocal bool
blockSize int64
cfg *SyncConfig
srcHost, srcUser string
dstHost, dstUser string
srcRemoteBin string
dstRemoteBin string
srcSudo, dstSudo bool
}
func runWindowTransfer(srcCtrl, dstCtrl *Controller, j windowJob) error {
pp := newProgressPrinter(j.blockSize)
cb := transferCallbacks{onProgress: pp.print}
pushReq := CtrlMsg{
Path: j.srcPath, Base: j.srcBase, PeerBase: j.dstBase, Size: j.size, BlockSize: j.blockSize,
PeerHost: j.dstHost, PeerUser: j.dstUser, PeerPath: j.dstPath, PeerLocal: j.bothLocal,
RemoteBin: j.dstRemoteBin, SSHBin: j.cfg.SSHBin, SSHOpts: j.cfg.SSHOpts,
ConnectTimeoutSec: j.cfg.ConnectTimeoutSec, Sudo: j.dstSudo,
}
ok, reason, err := srcCtrl.ConnectPush(pushReq, cb)
if err != nil {
pp.finish()
return err
}
if !ok {
pp.finish()
fmt.Fprintf(os.Stderr, " push not possible (%s); pulling instead ...\n", reason)
pullReq := CtrlMsg{
Path: j.dstPath, Base: j.dstBase, PeerBase: j.srcBase, Size: j.size, BlockSize: j.blockSize,
PeerHost: j.srcHost, PeerUser: j.srcUser, PeerPath: j.srcPath, PeerLocal: j.bothLocal,
RemoteBin: j.srcRemoteBin, SSHBin: j.cfg.SSHBin, SSHOpts: j.cfg.SSHOpts,
ConnectTimeoutSec: j.cfg.ConnectTimeoutSec, Sudo: j.srcSudo,
}
ok2, reason2, err2 := dstCtrl.ConnectPull(pullReq, cb)
if err2 != nil {
pp.finish()
return err2
}
if !ok2 {
pp.finish()
return fmt.Errorf("no direct connection either way (push: %s; pull: %s)", reason, reason2)
}
}
pp.finish()
return nil
}
// cloneCtx carries the transport context for an fs-image partition clone.
type cloneCtx struct {
srcPath, dstPath string
bothLocal bool
cfg *SyncConfig
srcHost, srcUser string
dstHost, dstUser string
srcRemoteBin string
dstRemoteBin string
srcSudo, dstSudo bool
}
// runFSImagePartition streams a filesystem image for one partition, trying
// push (source drives) then pull (dest drives).
func runFSImagePartition(srcCtrl, dstCtrl *Controller, pp plannedPart, c cloneCtx) error {
opts := fsCloneOpts{FSType: pp.FSType, Tool: pp.Tool, SizeBytes: pp.SrcSizeB, ShrinkToBytes: pp.ShrinkToB}
prog := newProgressPrinter(1 << 20)
cb := transferCallbacks{onProgress: prog.printBytes}
pushReq := CtrlMsg{
Method: "fs-image", PartIndex: pp.Num, Options: opts.encode(),
Path: c.srcPath, PeerPath: c.dstPath, PeerLocal: c.bothLocal,
PeerHost: c.dstHost, PeerUser: c.dstUser,
RemoteBin: c.dstRemoteBin, SSHBin: c.cfg.SSHBin, SSHOpts: c.cfg.SSHOpts,
ConnectTimeoutSec: c.cfg.ConnectTimeoutSec, Sudo: c.dstSudo,
}
ok, reason, err := srcCtrl.ClonePartition(pushReq, cb)
if err != nil {
prog.finish()
return err
}
if !ok {
prog.finish()
fmt.Fprintf(os.Stderr, " fs-image push not possible (%s); pulling instead ...\n", reason)
pullReq := CtrlMsg{
Method: "fs-image", Reason: "pull", PartIndex: pp.Num, Options: opts.encode(),
Path: c.dstPath, PeerPath: c.srcPath, PeerLocal: c.bothLocal,
PeerHost: c.srcHost, PeerUser: c.srcUser,
RemoteBin: c.srcRemoteBin, SSHBin: c.cfg.SSHBin, SSHOpts: c.cfg.SSHOpts,
ConnectTimeoutSec: c.cfg.ConnectTimeoutSec, Sudo: c.srcSudo,
}
ok2, reason2, err2 := dstCtrl.ClonePartition(pullReq, cb)
if err2 != nil {
prog.finish()
return err2
}
if !ok2 {
prog.finish()
return fmt.Errorf("fs-image failed both ways (push: %s; pull: %s)", reason, reason2)
}
}
prog.finish()
return nil
}

View File

@ -135,69 +135,6 @@ func (c *Controller) agentErr(op, path string, resp CtrlMsg) error {
return fmt.Errorf("%s: %s %s: %s", c.tag, op, path, resp.Message)
}
// ProbeDisk asks the agent to enumerate a whole disk (partition table, boot
// regions, per-partition filesystem facts, tool availability).
func (c *Controller) ProbeDisk(path string) (*DiskLayout, error) {
resp, err := c.call(CtrlMsg{Type: msgProbeDisk, Path: path})
if err != nil {
return nil, err
}
switch resp.Type {
case msgProbeDiskOK:
return parseDiskLayoutJSON(resp.LayoutJSON)
case msgError:
return nil, c.agentErr("probe", path, resp)
default:
return nil, fmt.Errorf("%s: unexpected response %q to probe_disk", c.tag, resp.Type)
}
}
// BuildLayout asks the agent to create/size an image and write the partition
// table (optsJSON is a buildOpts blob).
func (c *Controller) BuildLayout(path, optsJSON string) error {
resp, err := c.call(CtrlMsg{Type: msgBuildLayout, Path: path, Options: optsJSON})
if err != nil {
return err
}
switch resp.Type {
case msgBuildLayoutOK:
return nil
case msgError:
return c.agentErr("build_layout", path, resp)
default:
return fmt.Errorf("%s: unexpected response %q to build_layout", c.tag, resp.Type)
}
}
// ClonePartition drives one fs-image or file-level partition clone. dir is
// "push" (send to source agent) or "pull" (send to dest agent).
func (c *Controller) ClonePartition(req CtrlMsg, cb transferCallbacks) (ok bool, reason string, err error) {
req.Type = msgClonePartition
okType, failType := msgPushOK, msgPushFailed
if req.Reason == "pull" {
okType, failType = msgPullOK, msgPullFailed
}
return c.connectAndPump(req, cb, okType, failType)
}
// ReinstallBoot asks the (dest) agent to run grub-install/bcdboot; the
// returned string is a human summary. Best-effort — never returns an error
// for a boot-repair failure, only for a transport failure.
func (c *Controller) ReinstallBoot(path, optsJSON string) (string, error) {
resp, err := c.call(CtrlMsg{Type: msgReinstallBoot, Path: path, Options: optsJSON})
if err != nil {
return "", err
}
switch resp.Type {
case msgReinstallBootOK:
return resp.Message, nil
case msgError:
return "", c.agentErr("reinstall_boot", path, resp)
default:
return "", fmt.Errorf("%s: unexpected response %q to reinstall_boot", c.tag, resp.Type)
}
}
// transferCallbacks receives streaming updates while a connect_push or
// connect_pull is in flight.
type transferCallbacks struct {

View File

@ -19,17 +19,12 @@ type CtrlMsg struct {
// connect_push (-> source agent) / connect_pull (-> dest agent)
PeerHost string `json:"peerHost,omitempty"`
PeerUser string `json:"peerUser,omitempty"`
PeerPort int `json:"peerPort,omitempty"`
PeerPath string `json:"peerPath,omitempty"`
// Base/Length restrict a push/pull transfer to a byte window of the
// path (offset Base, Length bytes). Used by clone-disk to raw-copy a
// boot region or an offset-addressed partition through the same engine.
// Size stays the *window* length for the loop math; Base shifts every
// block offset. Zero Base + Length==Size is the whole-file case that
// `sync` uses unchanged.
// Base/PeerBase shift every block offset on the local/peer side. Zero on
// both sides (the whole-file case) is what `sync` uses; the sink and
// source-stream roles still accept a non-zero base for a windowed copy.
Base int64 `json:"base,omitempty"`
PeerBase int64 `json:"peerBase,omitempty"`
Length int64 `json:"length,omitempty"`
// PeerLocal is set when both source and dest are local to the manager,
// so the driver (itself already a local child of the manager) can spawn
// the sink/source-stream helper as a plain local subprocess instead of
@ -66,24 +61,6 @@ type CtrlMsg struct {
SrcRead int64 `json:"srcRead,omitempty"`
Written int64 `json:"written,omitempty"`
TotalBlocks int64 `json:"totalBlocks,omitempty"`
BytesCopied int64 `json:"bytesCopied,omitempty"`
BytesTotal int64 `json:"bytesTotal,omitempty"`
// clone-disk staging: which partition/step of the whole-disk job the
// progress or status refers to. Stage is a short human label
// ("partition table", "boot region", "ntfs p2", "bootloader").
Stage string `json:"stage,omitempty"`
PartIndex int `json:"partIndex,omitempty"`
PartTotal int `json:"partTotal,omitempty"`
// clone-disk payloads: probe result / requested target layout, both
// carried as embedded JSON so CtrlMsg stays a flat envelope.
LayoutJSON string `json:"layoutJSON,omitempty"`
Method string `json:"method,omitempty"` // partition clone method: "fs-image" | "raw" | "file-level"
Options string `json:"options,omitempty"` // JSON of the build/clone options block
// log
Level string `json:"level,omitempty"`
}
const (
@ -98,17 +75,7 @@ const (
msgPullOK = "pull_ok"
msgPullFailed = "pull_failed"
msgProgress = "progress"
msgLog = "log"
msgError = "error"
msgClose = "close"
msgBye = "bye"
// clone-disk control verbs
msgProbeDisk = "probe_disk"
msgProbeDiskOK = "probe_disk_ok"
msgBuildLayout = "build_layout"
msgBuildLayoutOK = "build_layout_ok"
msgClonePartition = "clone_partition"
msgReinstallBoot = "reinstall_boot"
msgReinstallBootOK = "reinstall_boot_ok"
)

View File

@ -13,20 +13,16 @@ type PathInfo struct {
Size int64
}
func statPath(path string) (PathInfo, error) {
// A raw disk handle (Windows \\.\PhysicalDrive0, \\.\C:) is not something
// os.Stat can describe, so ask the platform for its size directly.
if isDevicePath(path) {
size, err := blockDeviceSize(path)
if err != nil {
if os.IsNotExist(err) {
return PathInfo{Exists: false}, nil
}
return PathInfo{}, fmt.Errorf("stat device %s: %w", path, err)
}
return PathInfo{Exists: true, IsDevice: true, Size: size}, nil
}
// alignmentFor returns the offset/length alignment a path's handle requires
// for positioned reads and writes. Linux block devices accept ordinary
// buffered pread/pwrite at any alignment, so there is nothing to round to.
func alignmentFor(string) int64 { return 1 }
// canElevate reports whether a permission failure opening a device is worth
// retrying under `sudo` (see --sudo).
func canElevate() bool { return true }
func statPath(path string) (PathInfo, error) {
fi, err := os.Stat(path)
if err != nil {
if os.IsNotExist(err) {
@ -63,9 +59,6 @@ func prepareDest(path string, targetSize int64) error {
}
return nil
}
if isDevicePath(path) && !info.Exists {
return fmt.Errorf("destination device %s not found", path)
}
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return fmt.Errorf("open/create %s: %w", path, err)

View File

@ -1,9 +0,0 @@
//go:build !linux && !windows
package main
import "fmt"
func blockDeviceSize(path string) (int64, error) {
return 0, fmt.Errorf("block device size detection is only implemented on linux and windows (got path %s)", path)
}

View File

@ -1,18 +0,0 @@
//go:build !windows
package main
// isDevicePath reports whether a path must be treated as a raw device from
// its string form alone. On unix, os.Stat's mode bits already identify
// device nodes accurately (symlinks, non-standard locations and all), so
// this is always false and statPath relies on those instead.
func isDevicePath(string) bool { return false }
// alignmentFor returns the offset/length alignment a path's handle requires
// for positioned reads and writes. Unix block devices accept ordinary
// buffered pread/pwrite at any alignment, so there is nothing to round to.
func alignmentFor(string) int64 { return 1 }
// canElevate reports whether a permission failure opening a device is worth
// retrying under `sudo` (see --sudo). Always true on unix.
func canElevate() bool { return true }

View File

@ -1,99 +0,0 @@
//go:build windows
package main
import (
"fmt"
"os"
"strings"
"syscall"
"unsafe"
)
// isDevicePath recognises the Win32 device namespaces clonetool can sync
// against by string form: \\.\PhysicalDrive0 (a whole disk) and \\.\C: (a
// volume). Both the \\.\ and \\?\ prefixes, and their forward-slash
// spellings, are accepted. os.Stat can't describe these paths, so statPath
// keys off this instead of mode bits on Windows.
func isDevicePath(path string) bool {
p := strings.ReplaceAll(path, "/", `\`)
return strings.HasPrefix(p, `\\.\`) || strings.HasPrefix(p, `\\?\`)
}
// canElevate is false on Windows: there is no `sudo` equivalent to re-exec
// under, so a permission failure opening a raw disk is reported with a hint
// to run from an elevated console instead of being retried.
func canElevate() bool { return false }
const (
ioctlDiskGetLengthInfo = 0x0007405C // IOCTL_DISK_GET_LENGTH_INFO
ioctlDiskGetDriveGeometry = 0x00070000 // IOCTL_DISK_GET_DRIVE_GEOMETRY
)
// diskGeometry mirrors DISK_GEOMETRY (24 bytes; Cylinders is a LARGE_INTEGER).
type diskGeometry struct {
Cylinders int64
MediaType uint32
TracksPerCylinder uint32
SectorsPerTrack uint32
BytesPerSector uint32
}
// openDeviceHandle opens path for a metadata ioctl only: zero access rights
// (which need no privilege and don't require the volume to be unlocked) and
// shared read/write so it doesn't disturb a mounted filesystem.
func openDeviceHandle(path string) (syscall.Handle, error) {
p, err := syscall.UTF16PtrFromString(path)
if err != nil {
return syscall.InvalidHandle, err
}
return syscall.CreateFile(p, 0,
syscall.FILE_SHARE_READ|syscall.FILE_SHARE_WRITE, nil,
syscall.OPEN_EXISTING, 0, 0)
}
// blockDeviceSize returns the byte length of a physical drive or volume via
// DeviceIoControl(IOCTL_DISK_GET_LENGTH_INFO).
func blockDeviceSize(path string) (int64, error) {
h, err := openDeviceHandle(path)
if err != nil {
return 0, &os.PathError{Op: "open", Path: path, Err: err}
}
defer syscall.CloseHandle(h)
var length int64 // GET_LENGTH_INFORMATION is a single LARGE_INTEGER
var ret uint32
err = syscall.DeviceIoControl(h, ioctlDiskGetLengthInfo,
nil, 0,
(*byte)(unsafe.Pointer(&length)), uint32(unsafe.Sizeof(length)),
&ret, nil)
if err != nil {
return 0, fmt.Errorf("IOCTL_DISK_GET_LENGTH_INFO %s: %w", path, err)
}
return length, nil
}
// alignmentFor returns the sector size a raw disk handle's positioned reads
// and writes must be aligned to; 1 for an ordinary file path. It falls back
// to 512 if the geometry query fails.
func alignmentFor(path string) int64 {
if !isDevicePath(path) {
return 1
}
h, err := openDeviceHandle(path)
if err != nil {
return 512
}
defer syscall.CloseHandle(h)
var g diskGeometry
var ret uint32
err = syscall.DeviceIoControl(h, ioctlDiskGetDriveGeometry,
nil, 0,
(*byte)(unsafe.Pointer(&g)), uint32(unsafe.Sizeof(g)),
&ret, nil)
if err != nil || g.BytesPerSector == 0 {
return 512
}
return int64(g.BytesPerSector)
}

View File

@ -1,322 +0,0 @@
//go:build linux
package main
import (
"encoding/base64"
"fmt"
"os"
"os/exec"
"strconv"
"strings"
"unsafe"
"syscall"
)
// BLKSSZGET returns the logical (soft) sector size of a block device.
const blkSSZGet = 0x1268
func logicalSectorSize(path string) (int64, error) {
fi, err := os.Stat(path)
if err == nil && fi.Mode().IsRegular() {
return 512, nil
}
f, err := os.Open(path)
if err != nil {
return 512, err
}
defer f.Close()
var ssz int32
_, _, errno := syscall.Syscall(syscall.SYS_IOCTL, f.Fd(), blkSSZGet, uintptr(unsafe.Pointer(&ssz)))
if errno != 0 || ssz <= 0 {
return 512, nil
}
return int64(ssz), nil
}
// diskAttachment is a probe-time view of a whole disk: the block path whose
// partitions can be opened (the disk itself for a real device, a loop device
// for an image file) plus a cleanup to release a loop device.
type diskAttachment struct {
blockPath string
cleanup func()
}
// attachDisk returns a block path whose partition nodes exist. For a regular
// file it sets up a partition-scanning loop device; for a real block device
// it is a no-op.
func attachDisk(path string, writable bool) (*diskAttachment, error) {
fi, err := os.Stat(path)
if err != nil {
return nil, err
}
if fi.Mode()&os.ModeDevice != 0 {
return &diskAttachment{blockPath: path, cleanup: func() {}}, nil
}
args := []string{"--find", "--show", "--partscan"}
if !writable {
args = append(args, "--read-only")
}
args = append(args, path)
out, err := exec.Command("losetup", args...).Output()
if err != nil {
return nil, fmt.Errorf("losetup %s: %w", path, cmdErr(err))
}
lo := strings.TrimSpace(string(out))
return &diskAttachment{
blockPath: lo,
cleanup: func() { _ = exec.Command("losetup", "--detach", lo).Run() },
}, nil
}
// partNode maps a whole-disk block path + 1-based partition number to its
// partition device node (sda -> sda1, nvme0n1 -> nvme0n1p1, loop0 -> loop0p1).
func partNode(disk string, num int) string {
if l := len(disk); l > 0 && disk[l-1] >= '0' && disk[l-1] <= '9' {
return fmt.Sprintf("%sp%d", disk, num)
}
return fmt.Sprintf("%s%d", disk, num)
}
var diskTools = []string{
"sfdisk", "sgdisk", "partprobe", "blockdev", "blkid", "lsblk", "losetup",
"ntfsclone", "ntfsresize", "ntfsfix", "mkntfs",
"e2image", "resize2fs", "dumpe2fs", "mkfs.ext4", "e2fsck",
"partclone.dd", "partclone.extfs", "partclone.ntfs", "partclone.fat",
"partclone.exfat", "partclone.xfs", "partclone.btrfs", "partclone.restore",
"xfs_copy", "mkfs.vfat", "mkfs.xfs", "mkswap", "rsync",
"mount", "umount", "grub-install", "update-grub", "grub-mkconfig",
}
func probeToolset() map[string]bool {
m := make(map[string]bool, len(diskTools))
for _, t := range diskTools {
if _, err := exec.LookPath(t); err == nil {
m[t] = true
}
}
return m
}
// probeDiskLayout gathers everything clone-disk needs from a source disk.
func probeDiskLayout(path string) (*DiskLayout, error) {
d := &DiskLayout{DiskPath: path, Tools: probeToolset()}
info, err := statPath(path)
if err != nil {
return nil, err
}
if !info.Exists {
return nil, fmt.Errorf("%s does not exist", path)
}
d.DiskSize = info.Size
if d.LogicalSector, err = logicalSectorSize(path); err != nil {
d.LogicalSector = 512
}
d.PhysicalSector = d.LogicalSector
// Partition table via sfdisk -d (works on a file directly).
dump, derr := exec.Command("sfdisk", "-d", path).Output()
if derr != nil {
d.Scheme = "raw"
d.Warnings = append(d.Warnings, fmt.Sprintf("no partition table (%v)", cmdErr(derr)))
return d, nil
}
if err := d.parseSfdiskDump(string(dump)); err != nil {
return nil, err
}
if d.LogicalSector == 0 {
d.LogicalSector = 512
}
if d.Scheme == "gpt" {
if b, err := exec.Command("sgdisk", "--backup=/dev/stdout", path).Output(); err == nil {
d.SgdiskB64 = base64.StdEncoding.EncodeToString(b)
}
}
// Attach so per-partition nodes exist, then enrich each row.
att, err := attachDisk(path, false)
if err != nil {
d.Warnings = append(d.Warnings, fmt.Sprintf("cannot attach for fs probe: %v", err))
} else {
defer att.cleanup()
for i := range d.Partitions {
enrichPartition(&d.Partitions[i], att.blockPath, d.LogicalSector)
}
}
d.BootRegions = computeBootRegions(d)
return d, nil
}
// enrichPartition fills the FS* fields of p using blkid + fs-specific probes.
func enrichPartition(p *Partition, disk string, sector int64) {
node := partNode(disk, p.Num)
p.DevPath = node
if _, err := os.Stat(node); err != nil {
return
}
if out, err := exec.Command("blkid", "-o", "export", node).Output(); err == nil {
for _, line := range strings.Split(string(out), "\n") {
k, v, ok := splitEq(strings.TrimSpace(line))
if !ok {
continue
}
switch k {
case "TYPE":
p.FSType = v
case "LABEL":
p.FSLabel = v
case "UUID":
p.FSUUID = v
}
}
}
switch p.FSType {
case "ext2", "ext3", "ext4":
p.FSUsedBytes, p.FSMinBytes = probeExtSizes(node)
case "ntfs":
p.FSUsedBytes, p.FSMinBytes = probeNTFSSizes(node)
}
}
func probeExtSizes(node string) (used, min int64) {
if out, err := exec.Command("dumpe2fs", "-h", node).Output(); err == nil {
var bs, count, free int64
for _, line := range strings.Split(string(out), "\n") {
f := strings.SplitN(line, ":", 2)
if len(f) != 2 {
continue
}
v := strings.TrimSpace(f[1])
switch strings.TrimSpace(f[0]) {
case "Block size":
bs, _ = strconv.ParseInt(v, 10, 64)
case "Block count":
count, _ = strconv.ParseInt(v, 10, 64)
case "Free blocks":
free, _ = strconv.ParseInt(v, 10, 64)
}
}
if bs > 0 && count > 0 {
used = (count - free) * bs
}
}
// resize2fs -P needs a clean fs; ignore failures.
if out, err := exec.Command("resize2fs", "-P", node).Output(); err == nil {
// "Estimated minimum size of the filesystem: 123456" (in 4k blocks)
s := string(out)
if i := strings.LastIndex(s, ":"); i >= 0 {
if n, err := strconv.ParseInt(strings.TrimSpace(s[i+1:]), 10, 64); err == nil {
min = n * 4096
}
}
}
return used, min
}
func probeNTFSSizes(node string) (used, min int64) {
out, err := exec.Command("ntfsresize", "--info", "--force", node).CombinedOutput()
if err != nil {
return 0, 0
}
for _, line := range strings.Split(string(out), "\n") {
line = strings.TrimSpace(line)
switch {
case strings.HasPrefix(line, "You might resize at "):
// "You might resize at 1234567890 bytes ..."
fields := strings.Fields(line)
if len(fields) >= 5 {
if n, err := strconv.ParseInt(fields[4], 10, 64); err == nil {
min = n
}
}
case strings.Contains(line, "Space in use") && strings.Contains(line, "bytes"):
// "Space in use : 1234 MB (1.2%)" - not bytes; skip
}
}
return min, min
}
// computeBootRegions returns the byte ranges outside any cloned partition
// that must still be copied verbatim.
func computeBootRegions(d *DiskLayout) []Region {
var regs []Region
// bootstrap code before the MBR partition table on both MBR and (BIOS-)GPT disks
regs = append(regs, Region{Offset: 0, Length: 446, Note: "MBR bootstrap"})
if d.Scheme == "mbr" && len(d.Partitions) > 0 {
firstStart := d.Partitions[0].Start * d.LogicalSector
for _, p := range d.Partitions {
s := p.Start * d.LogicalSector
if s > 0 && s < firstStart {
firstStart = s
}
}
if firstStart > 512 {
regs = append(regs, Region{Offset: 512, Length: firstStart - 512, Note: "MBR gap (GRUB core.img etc.)"})
}
}
return regs
}
// applyPartitionTable writes script (an sfdisk restore script) to target,
// which may be a block device or an image file, and re-reads the table.
func applyPartitionTable(target, script string) error {
cmd := exec.Command("sfdisk", "--no-reread", "--no-tell-kernel", "--force", "--wipe=always", target)
cmd.Stdin = strings.NewReader(script)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("sfdisk %s: %v: %s", target, err, strings.TrimSpace(string(out)))
}
rereadPartTable(target)
return nil
}
func rereadPartTable(target string) {
fi, err := os.Stat(target)
if err != nil || fi.Mode()&os.ModeDevice == 0 {
return
}
if err := exec.Command("partprobe", target).Run(); err != nil {
_ = exec.Command("blockdev", "--rereadpt", target).Run()
}
}
// lsblkFSType reports the filesystem type on a node ("" if none).
func lsblkFSType(node string) string {
out, err := exec.Command("lsblk", "-ndo", "FSTYPE", node).Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}
// mountAt mounts node at a fresh temp dir and returns the dir + an unmount fn.
func mountAt(node string, readonly bool) (string, func(), error) {
dir, err := os.MkdirTemp("", "clonetool-mnt-")
if err != nil {
return "", nil, err
}
args := []string{}
if readonly {
args = append(args, "-o", "ro")
}
args = append(args, node, dir)
if out, err := exec.Command("mount", args...).CombinedOutput(); err != nil {
os.Remove(dir)
return "", nil, fmt.Errorf("mount %s: %v: %s", node, err, strings.TrimSpace(string(out)))
}
return dir, func() {
_ = exec.Command("umount", dir).Run()
_ = os.Remove(dir)
}, nil
}
func cmdErr(err error) error {
if ee, ok := err.(*exec.ExitError); ok {
if s := strings.TrimSpace(string(ee.Stderr)); s != "" {
return fmt.Errorf("%v: %s", err, s)
}
}
return err
}

View File

@ -1,37 +0,0 @@
//go:build !linux && !windows
package main
import (
"fmt"
"runtime"
)
func logicalSectorSize(string) (int64, error) { return 512, nil }
type diskAttachment struct {
blockPath string
cleanup func()
}
func attachDisk(string, bool) (*diskAttachment, error) {
return nil, fmt.Errorf("clone-disk is only implemented on linux and windows (this is %s)", runtime.GOOS)
}
func partNode(disk string, num int) string { return fmt.Sprintf("%s%d", disk, num) }
func probeDiskLayout(string) (*DiskLayout, error) {
return nil, fmt.Errorf("clone-disk is only implemented on linux and windows (this is %s)", runtime.GOOS)
}
func applyPartitionTable(string, string) error {
return fmt.Errorf("clone-disk partition rebuild is only implemented on linux and windows")
}
func rereadPartTable(string) {}
func lsblkFSType(string) string { return "" }
func mountAt(string, bool) (string, func(), error) {
return "", nil, fmt.Errorf("mount is only implemented on linux")
}

View File

@ -1,232 +0,0 @@
//go:build windows
package main
import (
"encoding/binary"
"fmt"
"os"
"os/exec"
"strings"
"syscall"
)
const (
ioctlDiskGetDriveLayoutEx = 0x00070050 // IOCTL_DISK_GET_DRIVE_LAYOUT_EX
ioctlDiskUpdateProperties = 0x00070140 // IOCTL_DISK_UPDATE_PROPERTIES
)
func logicalSectorSize(path string) (int64, error) {
if !isDevicePath(path) {
return 512, nil
}
return alignmentFor(path), nil
}
// guidString renders a little-endian Win32 GUID (4-2-2 + 8 bytes) as the
// canonical upper-case string.
func guidString(b []byte) string {
if len(b) < 16 {
return ""
}
d1 := binary.LittleEndian.Uint32(b[0:4])
d2 := binary.LittleEndian.Uint16(b[4:6])
d3 := binary.LittleEndian.Uint16(b[6:8])
return strings.ToUpper(fmt.Sprintf("%08X-%04X-%04X-%02X%02X-%02X%02X%02X%02X%02X%02X",
d1, d2, d3, b[8], b[9], b[10], b[11], b[12], b[13], b[14], b[15]))
}
// probeDiskLayout reads the partition table of a \\.\PhysicalDriveN via
// IOCTL_DISK_GET_DRIVE_LAYOUT_EX. Windows-native clone-disk is raw/VSS only:
// every partition is cloned block-wise and the table itself is reproduced by
// raw-copying the leading sectors (see computeBootRegionsWindows), so no
// sfdisk-style rebuild is needed.
func probeDiskLayout(path string) (*DiskLayout, error) {
d := &DiskLayout{DiskPath: path, Tools: probeToolsetWindows()}
info, err := statPath(path)
if err != nil {
return nil, err
}
if !info.Exists {
return nil, fmt.Errorf("%s does not exist", path)
}
d.DiskSize = info.Size
d.LogicalSector, _ = logicalSectorSize(path)
if d.LogicalSector == 0 {
d.LogicalSector = 512
}
d.PhysicalSector = d.LogicalSector
if !isDevicePath(path) {
// An image file: we cannot enumerate its table without extra tooling.
d.Scheme = "raw"
d.Warnings = append(d.Warnings, "image-file source on Windows: whole-image raw clone only")
return d, nil
}
p16, err := syscall.UTF16PtrFromString(path)
if err != nil {
return nil, err
}
h, err := syscall.CreateFile(p16, syscall.GENERIC_READ,
syscall.FILE_SHARE_READ|syscall.FILE_SHARE_WRITE, nil, syscall.OPEN_EXISTING, 0, 0)
if err != nil {
return nil, &os.PathError{Op: "open", Path: path, Err: err}
}
defer syscall.CloseHandle(h)
buf := make([]byte, 64*1024)
var ret uint32
if err := syscall.DeviceIoControl(h, ioctlDiskGetDriveLayoutEx, nil, 0,
&buf[0], uint32(len(buf)), &ret, nil); err != nil {
return nil, fmt.Errorf("IOCTL_DISK_GET_DRIVE_LAYOUT_EX %s: %w", path, err)
}
style := binary.LittleEndian.Uint32(buf[0:4])
count := binary.LittleEndian.Uint32(buf[4:8])
switch style {
case 0:
d.Scheme, d.Label = "mbr", "dos"
sig := binary.LittleEndian.Uint32(buf[8:12])
d.LabelID = fmt.Sprintf("0x%08x", sig)
case 1:
d.Scheme, d.Label = "gpt", "gpt"
d.LabelID = guidString(buf[8:24])
default:
d.Scheme = "raw"
}
const hdr = 48
const peSize = 144
sector := d.LogicalSector
for i := uint32(0); i < count; i++ {
off := hdr + int(i)*peSize
if off+peSize > len(buf) {
break
}
e := buf[off : off+peSize]
start := int64(binary.LittleEndian.Uint64(e[8:16]))
length := int64(binary.LittleEndian.Uint64(e[16:24]))
num := int(binary.LittleEndian.Uint32(e[24:28]))
if length == 0 || num == 0 {
continue
}
p := Partition{
Num: num,
Start: start / sector,
Size: length / sector,
}
if style == 1 { // GPT union at offset 32
p.Type = guidString(e[32:48])
p.UUID = guidString(e[48:64])
p.Name = decodeUTF16(e[72:144])
} else if style == 0 { // MBR union at offset 32
p.Type = fmt.Sprintf("%02x", e[32])
if e[33] != 0 {
p.Attrs = "bootable"
}
}
d.Partitions = append(d.Partitions, p)
}
d.BootRegions = computeBootRegionsWindows(d)
return d, nil
}
func decodeUTF16(b []byte) string {
u := make([]uint16, 0, len(b)/2)
for i := 0; i+1 < len(b); i += 2 {
c := binary.LittleEndian.Uint16(b[i : i+2])
if c == 0 {
break
}
u = append(u, c)
}
return strings.TrimRight(syscall.UTF16ToString(u), "\x00")
}
// computeBootRegionsWindows: reproduce the partition table by raw-copying the
// sectors before the first partition, plus the trailing 33 sectors that hold
// the backup GPT.
func computeBootRegionsWindows(d *DiskLayout) []Region {
var regs []Region
if len(d.Partitions) == 0 {
return regs
}
first := d.Partitions[0].Start * d.LogicalSector
for _, p := range d.Partitions {
if s := p.Start * d.LogicalSector; s > 0 && s < first {
first = s
}
}
if first > 0 {
regs = append(regs, Region{Offset: 0, Length: first, Note: "protective MBR + primary GPT / MBR table + gap"})
}
if d.Scheme == "gpt" && d.DiskSize > 33*d.LogicalSector {
regs = append(regs, Region{
Offset: d.DiskSize - 33*d.LogicalSector,
Length: 33 * d.LogicalSector,
Note: "backup GPT",
})
}
return regs
}
func rereadPartTable(target string) {
if !isDevicePath(target) {
return
}
p16, err := syscall.UTF16PtrFromString(target)
if err != nil {
return
}
h, err := syscall.CreateFile(p16, syscall.GENERIC_READ|syscall.GENERIC_WRITE,
syscall.FILE_SHARE_READ|syscall.FILE_SHARE_WRITE, nil, syscall.OPEN_EXISTING, 0, 0)
if err != nil {
return
}
defer syscall.CloseHandle(h)
var ret uint32
_ = syscall.DeviceIoControl(h, ioctlDiskUpdateProperties, nil, 0, nil, 0, &ret, nil)
}
// applyPartitionTable is a no-op on Windows: the table is reproduced via the
// raw boot regions and rereadPartTable() refreshes the kernel's view.
func applyPartitionTable(target, script string) error {
rereadPartTable(target)
return nil
}
func probeToolsetWindows() map[string]bool {
m := map[string]bool{}
for _, t := range []string{"vssadmin", "wmic", "bcdboot", "diskpart", "robocopy", "powershell"} {
if _, err := exec.LookPath(t); err == nil {
m[t] = true
}
}
return m
}
type diskAttachment struct {
blockPath string
cleanup func()
}
// attachDisk is a no-op for a raw \\.\PhysicalDriveN (partitions are addressed
// by byte offset within the whole-disk handle on Windows) and unsupported for
// an image file.
func attachDisk(path string, writable bool) (*diskAttachment, error) {
if isDevicePath(path) {
return &diskAttachment{blockPath: path, cleanup: func() {}}, nil
}
return nil, fmt.Errorf("filesystem-aware / file-level clone of an image file is not supported on Windows; use --raw")
}
func partNode(disk string, num int) string { return fmt.Sprintf("%s (partition %d)", disk, num) }
func lsblkFSType(string) string { return "" }
func mountAt(string, bool) (string, func(), error) {
return "", nil, fmt.Errorf("mount-based file-level clone is not supported on Windows; use --raw")
}

View File

@ -1,298 +0,0 @@
package main
import (
"bufio"
"encoding/json"
"fmt"
"strconv"
"strings"
)
// DiskLayout is the full picture of a source disk that clone-disk needs to
// reproduce a target: the partition table (as parsed from `sfdisk -d`, the
// canonical restorable form for both GPT and MBR), the byte ranges outside
// any partition that still have to be copied verbatim (MBR bootstrap + gap,
// BIOS-boot partition), the per-partition filesystem facts, and which of the
// external tools we shell out to are actually present.
type DiskLayout struct {
// Whole-disk geometry.
DiskPath string `json:"diskPath"`
DiskSize int64 `json:"diskSize"` // bytes
LogicalSector int64 `json:"logicalSector"` // bytes, from the kernel/ioctl
PhysicalSector int64 `json:"physicalSector"` // bytes
Scheme string `json:"scheme"` // "gpt" | "mbr" | "raw" (no recognisable table)
// Partition table, parsed from `sfdisk -d`.
Label string `json:"label"` // "gpt" | "dos"
LabelID string `json:"labelID"` // GPT disk GUID / MBR 4-byte signature (0x...)
FirstLBA int64 `json:"firstLBA"` // sectors; 0 if the dump omitted it
LastLBA int64 `json:"lastLBA"` // sectors; 0 if omitted
Partitions []Partition `json:"partitions"`
SfdiskDump string `json:"sfdiskDump"` // the verbatim `sfdisk -d` output
SgdiskB64 string `json:"sgdiskB64"` // base64 `sgdisk --backup` blob (GPT only), fallback restore path
// Byte ranges that must be raw-copied and are not covered by a cloned
// partition: MBR bootstrap (0..0x1BE) + the gap before the first
// partition, and any BIOS-boot partition.
BootRegions []Region `json:"bootRegions"`
Tools map[string]bool `json:"tools"` // external tool name -> present on PATH
Warnings []string `json:"warnings,omitempty"`
}
// Region is a [Offset, Offset+Length) byte window on the whole disk.
type Region struct {
Offset int64 `json:"offset"`
Length int64 `json:"length"`
Note string `json:"note,omitempty"`
}
// Partition is one row of the partition table plus the filesystem facts the
// source agent gathered for it.
type Partition struct {
Num int `json:"num"`
Start int64 `json:"start"` // sectors
Size int64 `json:"size"` // sectors
Type string `json:"type"` // GPT type GUID or MBR hex code
UUID string `json:"uuid"` // GPT partition GUID (PARTUUID)
Name string `json:"name"` // GPT partition name
Attrs string `json:"attrs"` // trailing flags: "bootable", "attrs=\"...\"", ...
// Filesystem facts (source agent; not from sfdisk).
DevPath string `json:"devPath"` // source node, e.g. /dev/sda2
FSType string `json:"fsType"` // ext4 | ntfs | vfat | xfs | btrfs | swap | "" (none/unknown)
FSLabel string `json:"fsLabel"`
FSUUID string `json:"fsUUID"`
FSUsedBytes int64 `json:"fsUsedBytes"` // 0 if unknown
FSMinBytes int64 `json:"fsMinBytes"` // smallest the fs can be shrunk to; 0 if unknown/unshrinkable
}
// StartBytes / SizeBytes convert the sector-unit table fields using the
// disk's logical sector size.
func (p Partition) StartBytes(sectorSize int64) int64 { return p.Start * sectorSize }
func (p Partition) SizeBytes(sectorSize int64) int64 { return p.Size * sectorSize }
func (d *DiskLayout) JSON() string {
b, _ := json.Marshal(d)
return string(b)
}
func parseDiskLayoutJSON(s string) (*DiskLayout, error) {
var d DiskLayout
if err := json.Unmarshal([]byte(s), &d); err != nil {
return nil, fmt.Errorf("decode disk layout: %w", err)
}
return &d, nil
}
// parseSfdiskDump fills the Label/LabelID/FirstLBA/LastLBA/Partitions fields
// of d from the text of `sfdisk -d`. It leaves the raw text in d.SfdiskDump.
func (d *DiskLayout) parseSfdiskDump(dump string) error {
d.SfdiskDump = dump
sc := bufio.NewScanner(strings.NewReader(dump))
sc.Buffer(make([]byte, 64*1024), 1024*1024)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" {
continue
}
if i := strings.Index(line, " : "); i >= 0 && strings.Contains(line, "start=") {
p, err := parseSfdiskPartLine(line)
if err != nil {
return err
}
d.Partitions = append(d.Partitions, p)
continue
}
key, val, ok := splitKV(line)
if !ok {
continue
}
switch key {
case "label":
d.Label = val
case "label-id":
d.LabelID = val
case "first-lba":
d.FirstLBA, _ = strconv.ParseInt(val, 10, 64)
case "last-lba":
d.LastLBA, _ = strconv.ParseInt(val, 10, 64)
case "sector-size":
if n, err := strconv.ParseInt(val, 10, 64); err == nil && d.LogicalSector == 0 {
d.LogicalSector = n
}
}
}
if err := sc.Err(); err != nil {
return fmt.Errorf("scan sfdisk dump: %w", err)
}
switch d.Label {
case "gpt":
d.Scheme = "gpt"
case "dos":
d.Scheme = "mbr"
}
return nil
}
// splitKV parses a "key: value" header line.
func splitKV(line string) (string, string, bool) {
i := strings.Index(line, ":")
if i < 0 {
return "", "", false
}
return strings.TrimSpace(line[:i]), strings.TrimSpace(line[i+1:]), true
}
// parseSfdiskPartLine parses a body line like
//
// /dev/sda2 : start= 1050624, size= 1951xxxxxx, type=0FC6..., uuid=..., name="root", attrs="..."
// /dev/sda1 : start= 2048, size= 204800, type=83, bootable
func parseSfdiskPartLine(line string) (Partition, error) {
var p Partition
i := strings.Index(line, " : ")
dev := strings.TrimSpace(line[:i])
p.DevPath = dev
p.Num = trailingInt(dev)
rest := line[i+3:]
for _, fld := range splitTopLevel(rest, ',') {
fld = strings.TrimSpace(fld)
if fld == "" {
continue
}
k, v, ok := splitEq(fld)
if !ok {
// bare flag, e.g. "bootable"
if p.Attrs == "" {
p.Attrs = fld
} else {
p.Attrs += ", " + fld
}
continue
}
v = strings.Trim(strings.TrimSpace(v), `"`)
switch k {
case "start":
p.Start, _ = strconv.ParseInt(strings.TrimSpace(v), 10, 64)
case "size":
p.Size, _ = strconv.ParseInt(strings.TrimSpace(v), 10, 64)
case "type":
p.Type = v
case "uuid":
p.UUID = v
case "name":
p.Name = v
case "attrs":
if p.Attrs == "" {
p.Attrs = `attrs="` + v + `"`
} else {
p.Attrs += `, attrs="` + v + `"`
}
}
}
if p.Start == 0 && p.Size == 0 {
return p, fmt.Errorf("sfdisk line without start/size: %q", line)
}
return p, nil
}
// sfdiskRestoreScript renders a device-independent script that recreates this
// table on any target with `sfdisk`. It intentionally drops `device:` and the
// per-line device prefixes (sfdisk numbers the partitions in order) and
// `last-lba` (let sfdisk size the secondary GPT for the target). label-id and
// per-partition uuid are dropped when newIDs is set so a replacement disk can
// coexist with the original; otherwise they are preserved so existing
// BCD/fstab/GRUB references still resolve. sizeOverride maps a 1-based
// partition number to a new size in sectors (used by the shrink path).
func (d *DiskLayout) sfdiskRestoreScript(newIDs bool, sizeOverride map[int]int64) string {
var b strings.Builder
fmt.Fprintf(&b, "label: %s\n", d.Label)
if d.LabelID != "" && !newIDs {
fmt.Fprintf(&b, "label-id: %s\n", d.LabelID)
}
if d.FirstLBA > 0 {
fmt.Fprintf(&b, "first-lba: %d\n", d.FirstLBA)
}
if d.LogicalSector > 0 {
fmt.Fprintf(&b, "sector-size: %d\n", d.LogicalSector)
}
b.WriteString("unit: sectors\n\n")
for _, p := range d.Partitions {
size := p.Size
if ov, ok := sizeOverride[p.Num]; ok && ov > 0 {
size = ov
}
fmt.Fprintf(&b, "start=%d, size=%d", p.Start, size)
if p.Type != "" {
fmt.Fprintf(&b, ", type=%s", p.Type)
}
if p.UUID != "" && !newIDs {
fmt.Fprintf(&b, ", uuid=%s", p.UUID)
}
if p.Name != "" {
fmt.Fprintf(&b, ", name=\"%s\"", p.Name)
}
if p.Attrs != "" {
fmt.Fprintf(&b, ", %s", p.Attrs)
}
b.WriteByte('\n')
}
return b.String()
}
// biosBootType / espType are the GPT type GUIDs clone-disk special-cases.
const (
gptBIOSBoot = "21686148-6449-6E6F-744E-656564454649" // EF02 BIOS boot partition
gptESP = "C12A7328-F81F-11D2-BA4B-00A0C93EC93B" // EF00 EFI system partition
)
func (p Partition) isBIOSBoot() bool {
return strings.EqualFold(p.Type, gptBIOSBoot) || p.Type == "21" /* mbr */
}
func (p Partition) isESP() bool {
return strings.EqualFold(p.Type, gptESP) || p.Type == "ef" || p.Type == "EF"
}
// --- small string helpers -------------------------------------------------
func trailingInt(s string) int {
j := len(s)
for j > 0 && s[j-1] >= '0' && s[j-1] <= '9' {
j--
}
if j == len(s) {
return 0
}
n, _ := strconv.Atoi(s[j:])
return n
}
func splitEq(s string) (string, string, bool) {
i := strings.Index(s, "=")
if i < 0 {
return "", "", false
}
return strings.TrimSpace(s[:i]), s[i+1:], true
}
// splitTopLevel splits on sep but not inside double quotes.
func splitTopLevel(s string, sep byte) []string {
var out []string
var cur strings.Builder
inQ := false
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case c == '"':
inQ = !inQ
cur.WriteByte(c)
case c == sep && !inQ:
out = append(out, cur.String())
cur.Reset()
default:
cur.WriteByte(c)
}
}
out = append(out, cur.String())
return out
}

View File

@ -1,113 +0,0 @@
package main
import (
"strings"
"testing"
)
const gptDump = `label: gpt
label-id: 1D2E3F4A-1111-2222-3333-444455556666
device: /dev/sda
unit: sectors
first-lba: 2048
last-lba: 8388574
sector-size: 512
/dev/sda1 : start= 2048, size= 204800, type=C12A7328-F81F-11D2-BA4B-00A0C93EC93B, uuid=AAAAAAAA-1111-2222-3333-444455556666, name="EFI System Partition"
/dev/sda2 : start= 206848, size= 8179712, type=0FC63DAF-8483-4772-8E79-3D69D8477DE4, uuid=BBBBBBBB-1111-2222-3333-444455556666
`
const mbrDump = `label: dos
label-id: 0x1a2b3c4d
device: /dev/sdb
unit: sectors
sector-size: 512
/dev/sdb1 : start= 2048, size= 512000, type=83, bootable
/dev/sdb2 : start= 514048, size= 41734144, type=83
`
func TestParseSfdiskDumpGPT(t *testing.T) {
var d DiskLayout
if err := d.parseSfdiskDump(gptDump); err != nil {
t.Fatal(err)
}
if d.Label != "gpt" || d.Scheme != "gpt" {
t.Fatalf("label/scheme = %q/%q", d.Label, d.Scheme)
}
if d.LabelID != "1D2E3F4A-1111-2222-3333-444455556666" {
t.Fatalf("labelID = %q", d.LabelID)
}
if d.FirstLBA != 2048 || d.LastLBA != 8388574 {
t.Fatalf("lba = %d/%d", d.FirstLBA, d.LastLBA)
}
if len(d.Partitions) != 2 {
t.Fatalf("parts = %d", len(d.Partitions))
}
p1 := d.Partitions[0]
if p1.Num != 1 || p1.Start != 2048 || p1.Size != 204800 {
t.Fatalf("p1 = %+v", p1)
}
if p1.Type != "C12A7328-F81F-11D2-BA4B-00A0C93EC93B" || p1.UUID != "AAAAAAAA-1111-2222-3333-444455556666" {
t.Fatalf("p1 type/uuid = %q/%q", p1.Type, p1.UUID)
}
if p1.Name != "EFI System Partition" {
t.Fatalf("p1 name = %q", p1.Name)
}
if !p1.isESP() {
t.Fatalf("p1 should be ESP")
}
if d.Partitions[1].Num != 2 || d.Partitions[1].Size != 8179712 {
t.Fatalf("p2 = %+v", d.Partitions[1])
}
}
func TestParseSfdiskDumpMBR(t *testing.T) {
var d DiskLayout
if err := d.parseSfdiskDump(mbrDump); err != nil {
t.Fatal(err)
}
if d.Scheme != "mbr" || d.LabelID != "0x1a2b3c4d" {
t.Fatalf("scheme/id = %q/%q", d.Scheme, d.LabelID)
}
if len(d.Partitions) != 2 || d.Partitions[0].Attrs != "bootable" {
t.Fatalf("parts = %+v", d.Partitions)
}
}
func TestSfdiskRestoreScript(t *testing.T) {
var d DiskLayout
if err := d.parseSfdiskDump(gptDump); err != nil {
t.Fatal(err)
}
// keep IDs
s := d.sfdiskRestoreScript(false, nil)
if !strings.Contains(s, "label-id: 1D2E3F4A-1111-2222-3333-444455556666") {
t.Fatalf("expected label-id kept:\n%s", s)
}
if !strings.Contains(s, "uuid=AAAAAAAA-1111-2222-3333-444455556666") {
t.Fatalf("expected part uuid kept:\n%s", s)
}
if strings.Contains(s, "device:") || strings.Contains(s, "/dev/sda1") {
t.Fatalf("script must be device-independent:\n%s", s)
}
if strings.Contains(s, "last-lba") {
t.Fatalf("script must drop last-lba:\n%s", s)
}
// new IDs
s2 := d.sfdiskRestoreScript(true, nil)
if strings.Contains(s2, "label-id:") || strings.Contains(s2, "uuid=") {
t.Fatalf("--new-ids must strip ids:\n%s", s2)
}
// size override on p2
s3 := d.sfdiskRestoreScript(false, map[int]int64{2: 4000000})
if !strings.Contains(s3, "size=4000000") {
t.Fatalf("expected overridden size:\n%s", s3)
}
if strings.Contains(s3, "size=8179712") {
t.Fatalf("old size should be gone:\n%s", s3)
}
}

View File

@ -1,200 +0,0 @@
package main
import (
"bufio"
"encoding/json"
"fmt"
"io"
"regexp"
"strconv"
"strings"
)
// fsCloner is the pair of argv templates that stream one filesystem as an
// image: save reads the fs on dev and writes an image to stdout; restore
// reads that image from stdin and writes it onto dev. The image format is
// whatever the chosen standard tool uses (ntfsclone, partclone, e2image) —
// clonetool only moves the bytes between the two ends.
type fsCloner struct {
name string // tool family, for logs
save []string
restore []string
}
// fsCloneOpts is the JSON blob carried in CtrlMsg.Options for a fs-image
// partition clone so both ends build the identical argv.
type fsCloneOpts struct {
FSType string `json:"fsType"`
Tool string `json:"tool"`
SizeBytes int64 `json:"sizeBytes"`
// ShrinkToBytes > 0: shrink the SOURCE filesystem to this size in place
// before imaging it (so it fits a smaller target partition). This
// mutates the source and only runs when the operator passed
// --allow-shrink --yes.
ShrinkToBytes int64 `json:"shrinkToBytes,omitempty"`
}
func (o fsCloneOpts) encode() string { b, _ := json.Marshal(o); return string(b) }
func decodeFSCloneOpts(s string) (fsCloneOpts, error) {
var o fsCloneOpts
err := json.Unmarshal([]byte(s), &o)
return o, err
}
// pickCloner returns the fs-image cloner for (fsType, tool) or nil if the
// partition should be handled some other way. tool is the family name chosen
// by chooseCloneMethod ("ntfsclone", "partclone", "e2image").
func pickCloner(dev, fsType, tool string) *fsCloner {
switch tool {
case "ntfsclone":
return &fsCloner{
name: "ntfsclone",
save: []string{"ntfsclone", "--save-image", "--force", "--output", "-", dev},
restore: []string{"ntfsclone", "--restore-image", "--overwrite", dev, "-"},
}
case "e2image":
return &fsCloner{
name: "e2image",
// -ra: raw + skip unallocated; stream to/from stdio.
save: []string{"e2image", "-ra", "-p", dev, "-"},
restore: []string{"e2image", "-ra", "-p", "-", dev},
}
case "partclone":
pc := "partclone." + partcloneSuffix(fsType)
return &fsCloner{
name: pc,
save: []string{pc, "-c", "-s", dev, "-O", "-", "-L", "/dev/null"},
restore: []string{"partclone.restore", "-s", "-", "-O", dev, "-L", "/dev/null"},
}
}
return nil
}
func partcloneSuffix(fsType string) string {
switch fsType {
case "ext2", "ext3", "ext4":
return "extfs"
case "vfat", "fat", "fat12", "fat16", "fat32", "msdos":
return "fat"
case "ntfs":
return "ntfs"
default:
return fsType // exfat, xfs, btrfs, f2fs, hfsplus, ...
}
}
// chooseCloneMethod decides how a partition's data is copied.
//
// "raw" -> the block-diff engine over an offset window (also the
// re-run-friendly path; used for swap, unknown fs, --raw)
// "fs-image" -> stream a filesystem image with a standard tool
// "file-level" -> mkfs on the target + rsync
func chooseCloneMethod(p Partition, tools map[string]bool, forceRaw, forceFile map[int]bool, fileAuto bool) (method, tool string) {
if forceRaw[p.Num] {
return "raw", ""
}
if forceFile[p.Num] {
return "file-level", ""
}
if p.isBIOSBoot() {
return "raw", "" // tiny, and holds GRUB core - always verbatim
}
switch p.FSType {
case "", "swap", "crypto_LUKS", "LVM2_member":
return "raw", ""
case "ntfs":
if tools["ntfsclone"] {
return "fs-image", "ntfsclone"
}
case "ext2", "ext3", "ext4":
if tools["partclone.extfs"] && tools["partclone.restore"] {
return "fs-image", "partclone"
}
if tools["e2image"] {
return "fs-image", "e2image"
}
case "vfat", "fat", "fat12", "fat16", "fat32", "msdos":
if tools["partclone.fat"] && tools["partclone.restore"] {
return "fs-image", "partclone"
}
case "exfat", "xfs", "btrfs", "f2fs", "hfsplus":
if tools["partclone."+partcloneSuffix(p.FSType)] && tools["partclone.restore"] {
return "fs-image", "partclone"
}
}
if fileAuto && p.FSType != "" && tools["rsync"] {
return "file-level", ""
}
return "raw", ""
}
// progressScanner reads a cloner's stderr line by line, pulls a percentage
// out of the tool's own progress chatter, and calls emit with an estimated
// byte count (fraction * sizeBytes). It swallows everything else.
var pctRe = regexp.MustCompile(`([0-9]{1,3}(?:\.[0-9]+)?)\s*%|([0-9]{1,3}(?:\.[0-9]+)?)\s*percent`)
func scanCloneProgress(r io.Reader, sizeBytes int64, emit func(bytesDone int64)) {
br := bufio.NewReader(r)
var buf []byte
flush := func() {
line := strings.TrimSpace(string(buf))
buf = buf[:0]
if line == "" {
return
}
m := pctRe.FindStringSubmatch(line)
if m == nil {
return
}
s := m[1]
if s == "" {
s = m[2]
}
if f, err := strconv.ParseFloat(s, 64); err == nil && f >= 0 && f <= 100 {
emit(int64(f / 100 * float64(sizeBytes)))
}
}
for {
c, err := br.ReadByte()
if err != nil {
flush()
return
}
if c == '\n' || c == '\r' {
flush()
continue
}
buf = append(buf, c)
if len(buf) > 4096 {
buf = buf[:0]
}
}
}
// preShrinkFS shrinks the filesystem on dev to (at least) toBytes in place.
// Used only on the source, only with --allow-shrink --yes. Best effort with a
// hard error if the resize tool fails.
func preShrinkFS(dev, fsType string, toBytes int64) error {
if toBytes <= 0 {
return nil
}
switch {
case isExtFS(fsType):
if out, err := runCmd("e2fsck", "-f", "-y", dev); err != nil {
return fmt.Errorf("e2fsck %s: %v: %s", dev, err, out)
}
kib := toBytes / 1024
if out, err := runCmd("resize2fs", dev, fmt.Sprintf("%dK", kib)); err != nil {
return fmt.Errorf("resize2fs %s %dK: %v: %s", dev, kib, err, out)
}
return nil
case fsType == "ntfs":
// ntfsresize prompts for confirmation on a real resize; feed it "y".
if out, err := runCmdStdin("y\n", "ntfsresize", "--force", "--size", strconv.FormatInt(toBytes, 10), dev); err != nil {
return fmt.Errorf("ntfsresize %s --size %d: %v: %s", dev, toBytes, err, out)
}
return nil
default:
return fmt.Errorf("don't know how to shrink a %s filesystem", fsType)
}
}

112
main.go
View File

@ -4,7 +4,6 @@ import (
"flag"
"fmt"
"os"
"strings"
)
func main() {
@ -17,8 +16,6 @@ func main() {
switch os.Args[1] {
case "sync":
err = cmdSync(os.Args[2:])
case "clone-disk":
err = cmdCloneDisk(os.Args[2:])
case "agent":
err = cmdAgent(os.Args[2:])
case "version", "--version":
@ -42,9 +39,8 @@ func usage() {
Usage:
clonetool sync --source LOC --dest LOC [options]
clonetool clone-disk --source LOC --dest LOC [options]
clonetool version
clonetool agent --role {control|sink|source-stream|fs-send|fs-recv} ... (internal, spawned automatically)
clonetool agent --role {control|sink|source-stream} ... (internal, spawned automatically)
LOC is either a local path, or [user@]host:path for a path reached over SSH.
Source, destination, and the machine running "sync" (the manager) may all be
@ -54,21 +50,6 @@ a single block itself.
clonetool keeps no state between runs: every sync re-reads and re-hashes both
the source and the destination and transfers only the blocks that differ.
Options for clone-disk (whole-disk: boot record + partition table + per-filesystem data):
--parts LIST only clone these partition numbers (comma-separated; default all)
--raw LIST force a raw block clone for these partitions
--file-level LIST mkfs + rsync these partitions instead of a filesystem image
--file-level-auto use file-level for filesystem types with no image cloner
--allow-shrink permit shrinking trailing partitions to fit a smaller target
(resizes the SOURCE filesystem in place before imaging)
--no-shrink never shrink; fail instead if the target is too small
--new-ids randomize the GPT disk GUID / MBR signature on the target
--reinstall-bootloader after copy, run grub-install / grub-mkconfig on the target (Linux)
--vss Windows source: take a Volume Shadow Copy per NTFS volume (default on)
--image-size SIZE file target: image size (default = enough for the layout)
(also accepts --block-size --job --yes --sudo --deploy --ssh --ssh-opt --remote-bin
--connect-timeout --manager-host, same meaning as sync)
Options for sync:
--block-size SIZE block size, e.g. 4M (default 4M)
--job NAME optional label shown in progress/log output
@ -134,97 +115,6 @@ func cmdSync(args []string) error {
})
}
func parseIntList(s string) ([]int, error) {
if strings.TrimSpace(s) == "" {
return nil, nil
}
var out []int
for _, f := range strings.Split(s, ",") {
f = strings.TrimSpace(f)
if f == "" {
continue
}
var n int
if _, err := fmt.Sscanf(f, "%d", &n); err != nil || n <= 0 {
return nil, fmt.Errorf("invalid partition number %q", f)
}
out = append(out, n)
}
return out, nil
}
func cmdCloneDisk(args []string) error {
fs := flag.NewFlagSet("clone-disk", flag.ContinueOnError)
job := fs.String("job", "", "optional label for progress/log output")
source := fs.String("source", "", "source whole-disk location (required)")
dest := fs.String("dest", "", "destination whole-disk or image location (required)")
blockSizeStr := fs.String("block-size", "4M", "block size for raw/boot-region transfers, e.g. 4M")
yes := fs.Bool("yes", false, "don't prompt before shrinking an existing destination image")
sudoMode := fs.String("sudo", "auto", "device-access privilege escalation: auto|always|never")
deploy := fs.Bool("deploy", true, "copy this binary to remote hosts that lack it")
connectTimeout := fs.Int("connect-timeout", defaultConnectTimeoutSec, "ssh connect timeout (seconds)")
sshBin := fs.String("ssh", "ssh", "ssh binary")
remoteBin := fs.String("remote-bin", "clonetool", "clonetool path on remote hosts")
managerHost := fs.String("manager-host", "", "address peers use to reach this machine")
partsStr := fs.String("parts", "", "only clone these partition numbers (comma-separated)")
rawStr := fs.String("raw", "", "force raw block clone for these partition numbers")
fileLevelStr := fs.String("file-level", "", "mkfs + rsync these partition numbers")
fileAuto := fs.Bool("file-level-auto", false, "file-level for fs types with no image cloner")
allowShrink := fs.Bool("allow-shrink", false, "permit shrinking trailing partitions to fit a smaller target")
noShrink := fs.Bool("no-shrink", false, "never shrink; fail if the target is too small")
newIDs := fs.Bool("new-ids", false, "randomize the GPT disk GUID / MBR signature on the target")
reinstallBoot := fs.Bool("reinstall-bootloader", false, "run grub-install / grub-mkconfig on the target after copy")
vss := fs.Bool("vss", true, "Windows source: take a Volume Shadow Copy per NTFS volume")
imageSizeStr := fs.String("image-size", "", "file target: image size (default: enough for the layout)")
var sshOpts stringSlice
fs.Var(&sshOpts, "ssh-opt", `extra "-o OPT" passed to ssh (repeatable)`)
if err := fs.Parse(args); err != nil {
return err
}
if *source == "" || *dest == "" {
fs.Usage()
return fmt.Errorf("--source and --dest are required")
}
blockSize, err := parseSize(*blockSizeStr)
if err != nil {
return fmt.Errorf("--block-size: %w", err)
}
switch *sudoMode {
case "auto", "always", "never":
default:
return fmt.Errorf("--sudo: want auto|always|never, got %q", *sudoMode)
}
parts, err := parseIntList(*partsStr)
if err != nil {
return fmt.Errorf("--parts: %w", err)
}
raw, err := parseIntList(*rawStr)
if err != nil {
return fmt.Errorf("--raw: %w", err)
}
fileLevel, err := parseIntList(*fileLevelStr)
if err != nil {
return fmt.Errorf("--file-level: %w", err)
}
var imageSize int64
if *imageSizeStr != "" {
if imageSize, err = parseSize(*imageSizeStr); err != nil {
return fmt.Errorf("--image-size: %w", err)
}
}
return runCloneDisk(CloneDiskConfig{
SyncConfig: SyncConfig{
Job: *job, Source: *source, Dest: *dest, BlockSize: blockSize,
Yes: *yes, Sudo: *sudoMode, Deploy: *deploy, ConnectTimeoutSec: *connectTimeout,
SSHBin: *sshBin, SSHOpts: sshOpts, RemoteBin: *remoteBin, ManagerHost: *managerHost,
},
Parts: parts, Raw: raw, FileLevel: fileLevel, FileAuto: *fileAuto,
AllowShrink: *allowShrink, NoShrink: *noShrink, NewIDs: *newIDs,
ReinstallBoot: *reinstallBoot, VSS: *vss, ImageSize: imageSize,
})
}
func parseSize(s string) (int64, error) {
if s == "" {
return 0, fmt.Errorf("empty size")

View File

@ -153,11 +153,6 @@ func bringUpController(spec Spec, tag string, cfg *SyncConfig, remoteBin, probeP
if err == nil {
return c, info, sudo, nil
}
if errors.Is(err, errNeedPriv) && !canElevate() {
c.Close()
return nil, PathInfo{}, sudo, fmt.Errorf(
"%w; on Windows, run clonetool from an elevated (Administrator) console to open a raw disk", err)
}
if errors.Is(err, errNeedPriv) && cfg.Sudo == "auto" && !sudo {
fmt.Fprintf(os.Stderr, "%s: permission denied on %s; retrying via sudo ...\n", tag, probePath)
c.Close()
@ -331,26 +326,6 @@ func (p *progressPrinter) print(m CtrlMsg) {
p.srcReadRate.mib(p.blockSize), p.dstReadRate.mib(p.blockSize), p.dstWriteRate.mib(p.blockSize))
}
// printBytes renders a byte-oriented status line for clone-disk's fs-image
// partition clones, where progress arrives as BytesCopied/BytesTotal rather
// than block counts.
func (p *progressPrinter) printBytes(m CtrlMsg) {
if m.Type != msgProgress {
return
}
p.active = true
done, total := m.BytesCopied, m.BytesTotal
if total <= 0 {
total = done
}
stage := m.Stage
if stage == "" {
stage = "fs-image"
}
fmt.Fprintf(os.Stderr, "\r %s %s %s / %s ",
stage, progressBar(done, total), humanBytes(done), humanBytes(total))
}
// finish ends the current status line with a newline so following output
// (and the shell prompt) starts clean.
func (p *progressPrinter) finish() {

215
plan.go
View File

@ -1,215 +0,0 @@
package main
import (
"fmt"
"strings"
)
// plannedPart is one partition's copy plan: where it lives on each side (in
// bytes) and how its data moves.
type plannedPart struct {
Num int
SrcStartB, SrcSizeB int64
DstStartB, DstSizeB int64
Method string // "raw" | "fs-image" | "file-level"
Tool string // fs-image tool family
FSType string
ShrinkToB int64 // >0: shrink the fs to this many bytes before imaging
}
type targetPlan struct {
Script string // sfdisk restore script ("" when the source has no table / Windows-native)
IsFile bool
ImageSize int64 // file target: truncate to this
Parts []plannedPart
BootRegions []Region
RootPart int
ESPPart int
UEFI bool
Notes []string
}
// bootOpts is the JSON blob passed to reinstall_boot.
type bootOpts struct {
RootPart int `json:"rootPart"` // 1-based partition number holding "/"
ESPPart int `json:"espPart"` // 1-based ESP partition number, 0 if BIOS-only
UEFI bool `json:"uefi"`
DiskPath string `json:"diskPath"` // whole-disk path grub-install targets
}
type planOpts struct {
Parts map[int]bool // empty = all
Raw map[int]bool
FileLevel map[int]bool
FileAuto bool
AllowShrink bool
NewIDs bool
ImageSize int64 // explicit file-target size, 0 = auto
}
const gptTailSectors = 33 // secondary GPT header + entries
// planTargetLayout turns a probed source layout + the target's nature/size
// into a concrete copy plan, applying the sizing rules:
//
// - device target: never grow; if everything fits, keep the layout and
// leave trailing space untouched; if the last partition(s) overflow,
// shrink them (filesystem + partition) from the last inward, or fail.
// - file target: size the image to hold the layout (or ImageSize).
func planTargetLayout(src *DiskLayout, isFile bool, destSize int64, o planOpts) (*targetPlan, error) {
sector := src.LogicalSector
if sector <= 0 {
sector = 512
}
parts := sortedParts(src.Partitions)
var sel []Partition
for _, p := range parts {
if len(o.Parts) == 0 || o.Parts[p.Num] {
sel = append(sel, p)
}
}
if len(sel) == 0 && src.Scheme != "raw" {
return nil, fmt.Errorf("no partitions selected")
}
plan := &targetPlan{IsFile: isFile, BootRegions: append([]Region(nil), src.BootRegions...)}
// With --new-ids on an MBR disk, keep the raw bootstrap copy clear of the
// 4-byte disk signature at offset 0x1B8 so sfdisk's freshly generated one
// survives (GPT regenerates its GUID in the header, which we never
// raw-copy, so no clamp is needed there).
if o.NewIDs && src.Scheme == "mbr" {
for i := range plan.BootRegions {
r := &plan.BootRegions[i]
if r.Offset == 0 && r.Length > 0x1B8 {
r.Length = 0x1B8
}
}
}
// Bytes the layout needs on disk = end of the last selected partition
// (+ secondary GPT for GPT disks).
lastEndB := int64(0)
for _, p := range sel {
if e := (p.Start + p.Size) * sector; e > lastEndB {
lastEndB = e
}
}
neededB := lastEndB
if src.Scheme == "gpt" {
neededB += gptTailSectors * sector
}
if src.Scheme == "raw" {
neededB = src.DiskSize
}
if isFile {
plan.ImageSize = o.ImageSize
if plan.ImageSize == 0 {
plan.ImageSize = roundUp(neededB+(1<<20), 1<<20)
}
destSize = plan.ImageSize
}
if destSize <= 0 {
return nil, fmt.Errorf("could not determine target size")
}
// --- shrink pass ----------------------------------------------------
sizeOverride := map[int]int64{}
if destSize < neededB {
short := neededB - destSize
for i := len(sel) - 1; i >= 0 && short > 0; i-- {
p := &sel[i]
curB := p.Size * sector
minB := p.FSMinBytes
shrinkable := minB > 0 && (isExtFS(p.FSType) || p.FSType == "ntfs")
if !shrinkable {
continue
}
minB = roundUp(minB+(16<<20), sector) // 16 MiB slack
if minB >= curB {
continue
}
reduce := curB - minB
if reduce > short {
reduce = roundUp(short, sector)
}
newB := curB - reduce
sizeOverride[p.Num] = newB / sector
plan.Notes = append(plan.Notes,
fmt.Sprintf("partition p%d (%s) shrinks %s -> %s to fit the target",
p.Num, p.FSType, humanBytes(curB), humanBytes(newB)))
short -= reduce
}
if short > 0 {
return nil, fmt.Errorf(
"target (%s) is smaller than the source layout needs (%s); the trailing partition(s) "+
"cannot shrink to make up the missing %s; use a larger target",
humanBytes(destSize), humanBytes(neededB), humanBytes(short))
}
if !o.AllowShrink {
var names []string
for n := range sizeOverride {
names = append(names, fmt.Sprintf("p%d", n))
}
return nil, fmt.Errorf(
"target is smaller than the source; partition(s) %s would have to be shrunk. "+
"Re-run with --allow-shrink (this resizes the SOURCE filesystem in place before imaging) "+
"or use --no-shrink off / a larger target",
strings.Join(names, ", "))
}
}
// --- partition-table script --------------------------------------
if src.Scheme != "raw" && strings.TrimSpace(src.SfdiskDump) != "" {
plan.Script = src.sfdiskRestoreScript(o.NewIDs, sizeOverride)
}
// --- per-partition method --------------------------------------
for _, p := range sel {
pp := plannedPart{
Num: p.Num,
SrcStartB: p.Start * sector,
SrcSizeB: p.Size * sector,
DstStartB: p.Start * sector,
DstSizeB: p.Size * sector,
FSType: p.FSType,
}
if ov, ok := sizeOverride[p.Num]; ok {
pp.DstSizeB = ov * sector
pp.ShrinkToB = ov * sector
}
method, tool := chooseCloneMethod(p, src.Tools, o.Raw, o.FileLevel, o.FileAuto)
pp.Method, pp.Tool = method, tool
if pp.ShrinkToB > 0 && method != "fs-image" {
return nil, fmt.Errorf(
"partition p%d (%s) must shrink to fit but no image cloner is available for it; "+
"install ntfsclone / partclone or use a larger target", p.Num, orDash(p.FSType))
}
plan.Parts = append(plan.Parts, pp)
}
// --- boot roles ------------------------------------------------
plan.RootPart, plan.ESPPart = pickBootParts(sel)
plan.UEFI = plan.ESPPart != 0
return plan, nil
}
func isExtFS(t string) bool { return t == "ext2" || t == "ext3" || t == "ext4" }
// pickBootParts guesses the "/" partition (largest Linux fs) and the ESP.
func pickBootParts(sel []Partition) (root, esp int) {
var bestSize int64
for _, p := range sel {
if p.isESP() && esp == 0 {
esp = p.Num
}
switch p.FSType {
case "ext2", "ext3", "ext4", "btrfs", "xfs":
if sz := p.Size; sz > bestSize {
bestSize, root = sz, p.Num
}
}
}
return root, esp
}

View File

@ -1,146 +0,0 @@
package main
import "testing"
// mkLayout builds a GPT layout: ESP (vfat) + root (ext4) + data (ntfs).
func mkLayout(diskBytes int64) *DiskLayout {
d := &DiskLayout{
DiskPath: "/dev/src", DiskSize: diskBytes, LogicalSector: 512,
Scheme: "gpt", Label: "gpt", LabelID: "GUID",
SfdiskDump: gptDump, // any non-empty dump so a script is produced
Tools: map[string]bool{
"sfdisk": true, "ntfsclone": true,
"partclone.extfs": true, "partclone.restore": true, "e2image": true,
},
Partitions: []Partition{
{Num: 1, Start: 2048, Size: 204800, Type: gptESP, FSType: "vfat"}, // 100 MiB
{Num: 2, Start: 206848, Size: 10 * 2048 * 1024, FSType: "ext4", FSMinBytes: 2 << 30}, // 10 GiB, min 2 GiB
{Num: 3, Start: 206848 + 10*2048*1024, Size: 10 * 2048 * 1024, FSType: "ntfs", FSMinBytes: 3 << 30}, // 10 GiB, min 3 GiB
},
BootRegions: []Region{{Offset: 0, Length: 446, Note: "MBR bootstrap"}},
}
return d
}
func lastEndBytes(d *DiskLayout) int64 {
var e int64
for _, p := range d.Partitions {
if x := (p.Start + p.Size) * d.LogicalSector; x > e {
e = x
}
}
return e + gptTailSectors*d.LogicalSector
}
func TestPlanFitsDeviceKeepsLayout(t *testing.T) {
d := mkLayout(40 << 30)
plan, err := planTargetLayout(d, false, 40<<30, planOpts{})
if err != nil {
t.Fatal(err)
}
if len(plan.Parts) != 3 {
t.Fatalf("parts = %d", len(plan.Parts))
}
for _, p := range plan.Parts {
if p.SrcSizeB != p.DstSizeB || p.ShrinkToB != 0 {
t.Fatalf("p%d resized unexpectedly: %+v", p.Num, p)
}
if p.SrcStartB != p.DstStartB {
t.Fatalf("p%d start moved", p.Num)
}
}
if plan.Parts[0].Method != "raw" { // vfat, no partclone.fat in tool set
t.Fatalf("p1 method = %s", plan.Parts[0].Method)
}
if plan.Parts[1].Method != "fs-image" || plan.Parts[1].Tool != "partclone" {
t.Fatalf("p2 method/tool = %s/%s", plan.Parts[1].Method, plan.Parts[1].Tool)
}
if plan.Parts[2].Method != "fs-image" || plan.Parts[2].Tool != "ntfsclone" {
t.Fatalf("p3 method/tool = %s/%s", plan.Parts[2].Method, plan.Parts[2].Tool)
}
if plan.ESPPart != 1 || plan.RootPart != 2 || !plan.UEFI {
t.Fatalf("boot roles: esp=%d root=%d uefi=%v", plan.ESPPart, plan.RootPart, plan.UEFI)
}
}
func TestPlanTrailingFreeSpaceOK(t *testing.T) {
d := mkLayout(60 << 30)
// target smaller than the *disk* but larger than what the layout needs
need := lastEndBytes(d)
if _, err := planTargetLayout(d, false, need+1<<20, planOpts{}); err != nil {
t.Fatalf("should fit into %d (need %d): %v", need+1<<20, need, err)
}
}
func TestPlanNeedsShrinkRequiresFlag(t *testing.T) {
d := mkLayout(40 << 30)
need := lastEndBytes(d)
small := need - (4 << 30) // 4 GiB short; ntfs (min 3 GiB) can give it up
if _, err := planTargetLayout(d, false, small, planOpts{}); err == nil {
t.Fatalf("expected error without --allow-shrink")
}
plan, err := planTargetLayout(d, false, small, planOpts{AllowShrink: true})
if err != nil {
t.Fatalf("with --allow-shrink: %v", err)
}
p3 := plan.Parts[2]
if p3.ShrinkToB == 0 || p3.ShrinkToB >= p3.SrcSizeB {
t.Fatalf("p3 should shrink: %+v", p3)
}
if p3.Method != "fs-image" {
t.Fatalf("shrunk partition must be fs-image, got %s", p3.Method)
}
}
func TestPlanCannotFit(t *testing.T) {
d := mkLayout(40 << 30)
// absurdly small: even shrinking ntfs+ext to their minimums can't help
if _, err := planTargetLayout(d, false, 1<<30, planOpts{AllowShrink: true}); err == nil {
t.Fatalf("expected cannot-fit error")
}
}
func TestPlanFileTargetSizing(t *testing.T) {
d := mkLayout(40 << 30)
plan, err := planTargetLayout(d, true, 0, planOpts{})
if err != nil {
t.Fatal(err)
}
need := lastEndBytes(d)
if plan.ImageSize < need || plan.ImageSize > need+(2<<20) {
t.Fatalf("image size %d not ~%d", plan.ImageSize, need)
}
if plan.Script == "" {
t.Fatalf("expected a partition-table script for a file target")
}
}
func TestPlanNewIDsScript(t *testing.T) {
d := mkLayout(40 << 30)
plan, err := planTargetLayout(d, true, 0, planOpts{NewIDs: true})
if err != nil {
t.Fatal(err)
}
if plan.Script == "" || containsAny(plan.Script, "label-id:", "uuid=") {
t.Fatalf("--new-ids script still has ids:\n%s", plan.Script)
}
}
func containsAny(s string, subs ...string) bool {
for _, x := range subs {
if len(x) > 0 && indexOf(s, x) >= 0 {
return true
}
}
return false
}
func indexOf(s, sub string) int {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return i
}
}
return -1
}

View File

@ -86,9 +86,8 @@ type blockHash struct {
// is computed (this is what lets the peer start comparing before the whole
// side has been scanned). It keeps no hash state of its own. onProgress, if
// non-nil, is called with (blocksHashed, totalBlocks) before the first block
// and after each one. base is 0 for a whole-file sync; clone-disk passes a
// non-zero base to fingerprint just a boot region / offset-addressed
// partition within a larger handle.
// and after each one. base is 0 for a whole-file sync; a non-zero base
// fingerprints just a byte window within a larger handle.
func streamHashBlocks(f *os.File, base, size, blockSize, align int64, onHash func(bh blockHash) error, onProgress func(done, total int64)) error {
if err := checkBlockAlign(blockSize, align); err != nil {
return err