diff --git a/duplicacy/duplicacy_main.go b/duplicacy/duplicacy_main.go index c0f6897..cc36555 100644 --- a/duplicacy/duplicacy_main.go +++ b/duplicacy/duplicacy_main.go @@ -1351,7 +1351,7 @@ func main() { }, cli.BoolFlag{ Name: "vss", - Usage: "enable the Volume Shadow Copy service (Windows only)", + Usage: "enable the Volume Shadow Copy service (Windows and macOS using APFS only)", }, cli.IntFlag{ Name: "vss-timeout", diff --git a/src/duplicacy_shadowcopy.go b/src/duplicacy_shadowcopy.go old mode 100644 new mode 100755 index 0e75ac2..aeab70e --- a/src/duplicacy_shadowcopy.go +++ b/src/duplicacy_shadowcopy.go @@ -3,6 +3,7 @@ // Commercial use requires per-user licenses available from https://duplicacy.com // +build !windows +// +build !darwin package duplicacy diff --git a/src/duplicacy_shadowcopy_darwin.go b/src/duplicacy_shadowcopy_darwin.go new file mode 100755 index 0000000..b48c294 --- /dev/null +++ b/src/duplicacy_shadowcopy_darwin.go @@ -0,0 +1,170 @@ +// +// Shadow copy module for Mac OSX using APFS snapshot +// +// +// This module copyright 2018 Adam Marcus (https://github.com/amarcu5) +// and may be distributed under the same terms as Duplicacy. + +package duplicacy + +import ( + "context" + "errors" + "io/ioutil" + "os" + "os/exec" + "strings" + "syscall" + "time" +) + +var snapshotPath string +var snapshotDate string + +// Converts char array to string +func CharsToString(ca []int8) string { + + len := len(ca) + ba := make([]byte, len) + + for i, v := range ca { + ba[i] = byte(v) + if ba[i] == 0 { + len = i + break + } + } + + return string(ba[:len]) +} + +// Get ID of device containing path +func GetPathDeviceId(path string) (deviceId int32, err error) { + + stat := syscall.Stat_t{} + + err = syscall.Stat(path, &stat) + if err != nil { + return 0, err + } + + return stat.Dev, nil +} + +// Executes shell command with timeout and returns stdout +func CommandWithTimeout(timeoutInSeconds int, name string, arg ...string) (output string, err error) { + + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeoutInSeconds) * time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, name, arg...) + out, err := cmd.Output() + + if ctx.Err() == context.DeadlineExceeded { + err = errors.New("Command '" + name + "' timed out") + } + + output = string(out) + return output, err +} + +func DeleteShadowCopy() { + + if snapshotPath == "" { + return + } + + err := exec.Command("/sbin/umount", "-f", snapshotPath).Run() + if err != nil { + LOG_ERROR("VSS_DELETE", "Error while unmounting snapshot") + return + } + + err = exec.Command("tmutil", "deletelocalsnapshots", snapshotDate).Run() + if err != nil { + LOG_ERROR("VSS_DELETE", "Error while deleting local snapshot") + return + } + + err = os.RemoveAll(snapshotPath) + if err != nil { + LOG_ERROR("VSS_DELETE", "Error while deleting temporary mount directory") + return + } + + LOG_INFO("VSS_DELETE", "Shadow copy unmounted and deleted at %s", snapshotPath) + + snapshotPath = "" +} + +func CreateShadowCopy(top string, shadowCopy bool, timeoutInSeconds int) (shadowTop string) { + + if !shadowCopy { + return top + } + + // Check repository filesystem is APFS + stat := syscall.Statfs_t{} + err := syscall.Statfs(top, &stat) + if err != nil { + LOG_ERROR("VSS_INIT", "Unable to determine filesystem of repository path") + return top + } + if CharsToString(stat.Fstypename[:]) != "apfs" { + LOG_WARN("VSS_INIT", "VSS requires APFS filesystem") + return top + } + + // Check path is local as tmutil snapshots will not support APFS formatted external drives + deviceIdLocal, err := GetPathDeviceId("/") + if err != nil { + LOG_ERROR("VSS_INIT", "Unable to get device ID of path: /") + return top + } + deviceIdRepository, err := GetPathDeviceId(top) + if err != nil { + LOG_ERROR("VSS_INIT", "Unable to get device ID of path: ", top) + return top + } + if deviceIdLocal != deviceIdRepository { + LOG_WARN("VSS_PATH", "VSS not supported for non-local repository path: ", top) + return top + } + + if timeoutInSeconds <= 60 { + timeoutInSeconds = 60 + } + + // Create mount point + snapshotPath, err = ioutil.TempDir("/tmp/", "snp_") + if err != nil { + LOG_ERROR("VSS_CREATE", "Failed to create temporary mount directory") + return top + } + + // Use tmutil to create snapshot + tmutilOutput, err := CommandWithTimeout(timeoutInSeconds, "tmutil", "snapshot") + if err != nil { + LOG_ERROR("VSS_CREATE", "Error while calling tmutil: ", err) + return top + } + + colonPos := strings.IndexByte(tmutilOutput, ':') + if colonPos < 0 { + LOG_ERROR("VSS_CREATE", "Snapshot creation failed: ", tmutilOutput) + return top + } + snapshotDate = strings.TrimSpace(tmutilOutput[colonPos+1:]) + + // Mount snapshot as readonly and hide from GUI i.e. Finder + _, err = CommandWithTimeout(timeoutInSeconds, + "/sbin/mount", "-t", "apfs", "-o", "nobrowse,-r,-s=com.apple.TimeMachine." + snapshotDate, "/", snapshotPath) + if err != nil { + LOG_ERROR("VSS_CREATE", "Error while mounting snapshot: ", err) + return top + } + + LOG_INFO("VSS_DONE", "Shadow copy created and mounted at %s", snapshotPath) + + return snapshotPath + top +}