323 lines
9.1 KiB
Go
323 lines
9.1 KiB
Go
//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
|
|
}
|