From 3f0a9ce49062be96da2ad25be41a52f5aa4b09d1 Mon Sep 17 00:00:00 2001 From: James Hoffman Date: Fri, 6 Dec 2024 12:41:02 -0700 Subject: [PATCH] Initial public commit --- Cargo.lock | 1268 +++++++++++++++++++++++++++++++++++ Cargo.toml | 45 ++ README.md | 151 +++++ build/file-time-machine.wxs | 36 + demo.bak/config.json | 10 + gui/button_1.png | Bin 0 -> 4501 bytes gui/button_2.png | Bin 0 -> 4394 bytes gui/button_3.png | Bin 0 -> 3558 bytes gui/button_4.png | Bin 0 -> 3735 bytes gui/gui.py | 324 +++++++++ gui/image_1.png | Bin 0 -> 7436 bytes gui/logo.ico | Bin 0 -> 23942 bytes logo.png | Bin 0 -> 53278 bytes src/compression.rs | 26 + src/diffs.rs | 869 ++++++++++++++++++++++++ src/main.rs | 755 +++++++++++++++++++++ src/metadata_manager.rs | 32 + src/restore.rs | 737 ++++++++++++++++++++ test.sh | 48 ++ 19 files changed, 4301 insertions(+) create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 README.md create mode 100755 build/file-time-machine.wxs create mode 100644 demo.bak/config.json create mode 100644 gui/button_1.png create mode 100644 gui/button_2.png create mode 100644 gui/button_3.png create mode 100644 gui/button_4.png create mode 100644 gui/gui.py create mode 100644 gui/image_1.png create mode 100644 gui/logo.ico create mode 100644 logo.png create mode 100644 src/compression.rs create mode 100644 src/diffs.rs create mode 100644 src/main.rs create mode 100644 src/metadata_manager.rs create mode 100644 src/restore.rs create mode 100755 test.sh diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..d41b2d5 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1268 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "0.6.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23a1e53f0f5d86382dafe1cf314783b2044280f406e7e1506368220ad11b1338" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8365de52b16c035ff4fcafe0092ba9390540e3e352870ac09933bebcaa2c8c56" + +[[package]] +name = "anstyle-parse" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2109dbce0e72be3ec00bed26e6a7479ca384ad226efdd66db8fa2e3a38c83125" +dependencies = [ + "anstyle", + "windows-sys 0.59.0", +] + +[[package]] +name = "async-trait" +version = "0.1.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "721cae7de5c34fbb2acd27e21e6d2cf7b886dce0c27388d46c4e6c47ea4318dd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.85", +] + +[[package]] +name = "autocfg" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" + +[[package]] +name = "backtrace" +version = "0.3.74" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-targets 0.52.6", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "brotli" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74f7971dbd9326d58187408ab83117d8ac1bb9c17b085fdacd1cf2f598719b6b" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "4.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a45bd2e4095a8b518033b128020dd4a55aab1c0a381ba4404a472630f4bc362" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bsdiff" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7f2e6c4f2a017f63b5a1fd7cc437f061b53a3e890bcca840ef756d72f6b72f2" + +[[package]] +name = "bumpalo" +version = "3.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ac0150caa2ae65ca5bd83f25c7de183dea78d4d366469f148435e2acfbad0da" + +[[package]] +name = "cc" +version = "1.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2e7962b54006dcfcc61cb72735f4d89bb97061dd6a7ed882ec6b8ee53714c6f" +dependencies = [ + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "chrono" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-targets 0.52.6", +] + +[[package]] +name = "colorchoice" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" + +[[package]] +name = "console" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e1f83fc076bd6dd27517eacdf25fef6c4dfe5f1d7448bafaaf3a26f13b5e4eb" +dependencies = [ + "encode_unicode", + "lazy_static", + "libc", + "unicode-width", + "windows-sys 0.52.0", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "608697df725056feaccfa42cffdaeeec3fccc4ffc38358ecd19b243e716a78e0" +dependencies = [ + "libc", +] + +[[package]] +name = "crossterm" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e64e6c0fbe2c17357405f7c758c1ef960fce08bdfb2c03d88d2a18d7e09c4b67" +dependencies = [ + "bitflags 1.3.2", + "crossterm_winapi", + "libc", + "mio", + "parking_lot", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "directories" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "dyn-clone" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d6ef0072f8a535281e4876be788938b528e9a1d43900b82c2569af7da799125" + +[[package]] +name = "encode_unicode" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f" + +[[package]] +name = "env_filter" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f2c92ceda6ceec50f43169f9ee8424fe2db276791afde7b2cd8bc084cb376ab" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13fa619b91fb2381732789fc5de83b45675e882f66623b7d8cb4f643017018d" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "humantime", + "log", +] + +[[package]] +name = "file-time-machine" +version = "0.1.0" +dependencies = [ + "brotli", + "bsdiff", + "chrono", + "directories", + "env_logger", + "gumdrop", + "hex", + "indicatif", + "inquire", + "log", + "num_cpus", + "serde", + "serde_json", + "sha2", + "sha256", + "walkdir", + "xxhash-rust", +] + +[[package]] +name = "fuzzy-matcher" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" +dependencies = [ + "thread_local", +] + +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "gimli" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" + +[[package]] +name = "gumdrop" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bc700f989d2f6f0248546222d9b4258f5b02a171a431f8285a81c08142629e3" +dependencies = [ + "gumdrop_derive", +] + +[[package]] +name = "gumdrop_derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "729f9bd3449d77e7831a18abfb7ba2f99ee813dfd15b8c2167c9a54ba20aa99d" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "hermit-abi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "humantime" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" + +[[package]] +name = "iana-time-zone" +version = "0.1.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "indicatif" +version = "0.17.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "763a5a8f45087d6bcea4222e7b72c291a054edf80e4ef6efd2a4979878c7bea3" +dependencies = [ + "console", + "instant", + "number_prefix", + "portable-atomic", + "unicode-width", +] + +[[package]] +name = "inquire" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fddf93031af70e75410a2511ec04d49e758ed2f26dad3404a934e0fb45cc12a" +dependencies = [ + "bitflags 2.6.0", + "crossterm", + "dyn-clone", + "fuzzy-matcher", + "fxhash", + "newline-converter", + "once_cell", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + +[[package]] +name = "itoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" + +[[package]] +name = "js-sys" +version = "0.3.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a88f1bda2bd75b0452a14784937d796722fdebfe50df998aeb3f0b7603019a9" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.161" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9489c2807c139ffd9c1794f4af0ebe86a828db53ecdc7fea2111d0fed085d1" + +[[package]] +name = "libredox" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +dependencies = [ + "bitflags 2.6.0", + "libc", +] + +[[package]] +name = "lock_api" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "miniz_oxide" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2d80299ef12ff69b16a84bb182e3b9df68b5a91574d3d4fa6e41b65deec4df1" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.48.0", +] + +[[package]] +name = "newline-converter" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b6b097ecb1cbfed438542d16e84fd7ad9b0c76c8a65b7f9039212a3d14dc7f" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "number_prefix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" + +[[package]] +name = "object" +version = "0.36.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedf0a2d09c573ed1d8d85b30c119153926a2b36dce0ab28322c09a117a4683e" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "parking_lot" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets 0.52.6", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915a1e146535de9163f3987b8944ed8cf49a18bb0056bcebcdcece385cece4ff" + +[[package]] +name = "portable-atomic" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc9c68a3f6da06753e9335d63e27f6b9754dd1920d941135b7ea8224f141adb2" + +[[package]] +name = "proc-macro2" +version = "1.0.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f139b0662de085916d1fb67d2b4169d1addddda1919e696f3252b740b629986e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b6dfecf2c74bce2466cabf93f6664d6998a69eb21e39f4207930065b27b771f" +dependencies = [ + "bitflags 2.6.0", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom", + "libredox", + "thiserror", +] + +[[package]] +name = "regex" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368758f23274712b504848e9d5a6f010445cc8b87a7cdb4d7cbee666c1288da3" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + +[[package]] +name = "rustc-demangle" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" + +[[package]] +name = "ryu" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.213" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ea7893ff5e2466df8d720bb615088341b295f849602c6956047f8f80f0e9bc1" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.213" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e85ad2009c50b58e87caa8cd6dac16bdf511bbfb7af6c33df902396aa480fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.85", +] + +[[package]] +name = "serde_json" +version = "1.0.132" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d726bfaff4b320266d395898905d0eba0345aae23b54aee3a737e260fd46db03" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha256" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18278f6a914fa3070aa316493f7d2ddfb9ac86ebc06fa3b83bffda487e9065b0" +dependencies = [ + "async-trait", + "bytes", + "hex", + "sha2", + "tokio", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8621587d4798caf8eb44879d42e56b9a93ea5dcd315a6487c357130095b62801" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" +dependencies = [ + "libc", +] + +[[package]] +name = "smallvec" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5023162dfcd14ef8f32034d8bcd4cc5ddc61ef7a247c024a33e24e1f24d21b56" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d11abd9594d9b38965ef50805c5e469ca9cc6f197f883f717e0269a3057b3d5" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae71770322cbd277e69d762a16c444af02aa0575ac0d174f0b9562d3b37f8602" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.85", +] + +[[package]] +name = "thread_local" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" +dependencies = [ + "cfg-if", + "once_cell", +] + +[[package]] +name = "tokio" +version = "1.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "145f3413504347a2be84393cc8a7d2fb4d863b375909ea59f2158261aa258bbb" +dependencies = [ + "backtrace", + "bytes", + "pin-project-lite", +] + +[[package]] +name = "typenum" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" + +[[package]] +name = "unicode-ident" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasm-bindgen" +version = "0.2.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "128d1e363af62632b8eb57219c8fd7877144af57558fb2ef0368d0087bddeb2e" +dependencies = [ + "cfg-if", + "once_cell", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb6dd4d3ca0ddffd1dd1c9c04f94b868c37ff5fac97c30b97cff2d74fce3a358" +dependencies = [ + "bumpalo", + "log", + "once_cell", + "proc-macro2", + "quote", + "syn 2.0.85", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e79384be7f8f5a9dd5d7167216f022090cf1f9ec128e6e6a482a2cb5c5422c56" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26c6ab57572f7a24a4985830b120de1594465e5d500f24afe89e16b4e833ef68" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.85", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65fc09f10666a9f147042251e0dda9c18f166ff7de300607007e96bdebc1068d" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "xxhash-rust" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a5cbf750400958819fb6178eaa83bee5cd9c29a26a40cc241df8c70fdd46984" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..39b9bc9 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,45 @@ +[package] +name = "file-time-machine" +version = "0.1.0" +edition = "2021" +authors = ["Mizuki Zou + +

File Time Machine

+

+ A snapshotting program as a standalone application +
+

+ + [![Build Linux](https://github.com/timothyhay256/ftm/actions/workflows/build-linux.yml/badge.svg)](https://github.com/timothyhay256/ftm/actions/workflows/build-linux.yml) + [![Build Windows](https://github.com/timothyhay256/ftm/actions/workflows/build-windows.yml/badge.svg)](https://github.com/timothyhay256/ftm/actions/workflows/build-windows.yml) + [![.github/workflows/build-release.yml](https://github.com/timothyhay256/ftm/actions/workflows/build-release.yml/badge.svg)](https://github.com/timothyhay256/ftm/actions/workflows/build-release.yml) + [![Codacy Badge](https://app.codacy.com/project/badge/Grade/afcd3d438c764d18b85299e4c3691262)](https://app.codacy.com?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade) + ![No AI](https://img.shields.io/badge/free_of-AI_code-blue) + + +> [!CAUTION] +> This program is NOT safe for regular usage, and will most likely result in data loss if used in such a way! This is my first Rust project, so it will be unstable! +> This program has been tested fairly well on Linux, but catastrophic bugs still may be present. +### What is this? +In order to start learning Rust, I decided to make a incremental snapshotting program, like Apples Time Machine, but in userspace and cross-platform. And so this is what this is. It allows you to take snapshots of folders, and restore these snapshots, allowing you to go backwards and forwards in time. So like Git, but easier to use, and less powerful. And with a messy codebase. And dangerous and data-loss prone. And slower. +### Installation +#### Linux +Arch: Install `todo` from the AUR, or use Cargo. Optionally, install `todo-gui` as well. +Others: Use cargo to install `file-time-machine` or download the binary from releases. To get the GUI, download it from the releases page and run it with Python. +#### Windows +Download the .msi file from the releases page, and run it. The program and gui will both be installed, and the gui can be launched from the start menu. +#### MacOS (UNTESTED!) +First of all, you already have time machine. +But if you want it anyway, use cargo to install `file-time-machine`. +#### Making +Clone/download the source code, and run the following commands: + - `cargo run --release` # if you just want to run the program/test it without installing it + - `cargo install --path .` # if you want to install the program to ~/.cargo/bin +### Configuration +Create a configuration file inside `~/.file-time-machine/config.json` to automatically reference it when running `ftm` without any arguments, or create one at any path you want and pass it with `-c`, and add the following content: +``` +[ + { + "folder_path": "/folder/path/you/want/to/snapshot", + "get_hashes": false, + "thread_count": 8, + "brotli_compression_level": 5, + "snapshot_mode": "fastest", + "its_my_fault_if_i_lose_data": false + } +] +``` +`folder_path` is the folder path that you want to take and restore snapshots inside of. +`get_hashes` is if you want to find modified files using hashes instead of a faster method such as change date/size. This is much slower. +`thread_count` is how many threads you want to use. Set this to 0 to automatically select a thread count based on your CPU core count. +`brotli_compression_level` is the compression level for snapshot files. As you go higher you will get better compression ratios, but much worse speeds. 5 seems to be a good level. Ranges from 1-11. +`its_my_fault_if_i_lose_data` is you agreeing that it is YOUR fault if you lose data by using this software, and not mine. Set it to true to skip the 5 second warning on each run. +`snapshot_mode` is the way to take snapshots. There are three modes, which are described in more detail below. *Currently ONLY fastest is supported! I might or might not add other modes later.* +`standard` is the normal method. It takes as little disk space as possible, but takes much longer to take snapshots or move backwards in time. If your files are small, this time difference won't be noticable. +`faster` is a mode that makes taking snapshots much faster, but results in increased disk space usage. This doesn't increase the speed of restoring backwards though. If you have the disk space and want the speed, this is a good option. +`fastest` is a mode that makes both taking snapshots much faster, and makes restoring backwards much faster. It does however use nearly twice the disk space as previous modes. +> [!WARNING] +> Once you select a snapshot mode, there is currently no way to switch to another one! + +If you want to pass a specific config file (to snapshot a different path for example), simply use the `-c` flag. + +### Usage +##### Note that .time (used for storing snapshots) and .git are ignored. The ability to specify directories to ignore will be added in the future. +#### GUI +If you are on Windows, launch File Time Machine. On Linux/MacOS, run the gui/gui.py script. +Once it has started, ensure the square in the top right is green and says "Found FTM binary!". Operation of the GUI is fairly self explanatory, but here are some details about it's operation. +**Select Folder**: Select the folder you want to create snapshots for. If the folder has been tracked, `folder_path/.time/gui-config.conf` will be checked for an config. If one is present, the program is ready for usage. If there is not one present, you will be prompted to select the config file location. If the folder has not been tracked, you will be prompted to start doing so. If you say yes, a simple config will be placed in `folder_path/.time/gui-config.conf`, and the program is ready for usage. +**Select Config**: If a config could not be autodetected, then you will need to specify the location of one manually. On Unix systems, the default one (the one used when no options are passed) should be at `~/.file-time-machine/config.json` +**Create Snapshot**: Pretty self explanatory. Creates a snapshot. A valid folder and config file must be selected however. +**Restore Snapshot**: Restores a snapshot. One must be selected in the main box. +##### Issues +If you have any issues, you can check the console for further output. Additionally, the console will show the progress of creating a snapshot, while the GUI does not provide it. The console should open automatically on Windows. +#### CLI +Once you have finished configuration, run `ftm` to collect the initial run of metadata. (Or if specifying a config file `ftm -c /path/to/config`, it will be the same) +On this run, a compressed copy of each file will be created, along with any other metafiles needed. These will be stored in `.time`. +After this initial run, make some changes! You can create new files, delete old ones, and modify existing ones. Now run `ftm` again to create a snapshot. On this run, every file that has been changed will get a diff created between it, and the original file. This can be used to restore yourself to this state in time. +Every time that you run `ftm` and changes have been detected, a new snapshot will be created. +In order to restore a snapshot, first create one with `ftm` so you don't lose any working changes, then run `ftm restore`, and select the snapshot you wish to restore. Optionally, you can also use `ftm restore --restore-index n` to restore the nth snapshot. (Starting at 1 being oldest) +You can safely make changes while a snapshot is restored, but they will be overwritten when a snapshot is restored. You can also safely create additional snapshots while one is restored. + +In order to return to the present, run `ftm restore` and select the most recent snapshot. + +### Notes +In the future, I want to make a daemon that tracks various folders and creates snapshots in defined increments of time. +Until then, you can pass a config file to the binary in order to use those specific paths and settings. This means you can track multiple directories, you just have to have multiple config files. + +Since all snapshots and associated data is stored within the `.time` directory in the target directory, if you want to reset the timeline of snapshots, simply remove the folder. Just know that if you do so, ALL past snapshots and changes will be lost, and if you are currently in the "past" you will NOT be able to go back to the future! + +### How does it work +Please see (unfinished) for more details on how it actually works. Below is only for the unimplemented regular mode. +#### Regular mode +Let our demo folder contain two files. `demo/test` and `demo/other`. +We modify `demo/test`, and take a new snapshot, and we have two patch files: +`.time/000` and `.time/000-reverse` (note that the ID is actually a hash from the date and path). +`.time/000` is created from a empty file, and the new file. It is thus our compressed copy of the current version of the file. Using this on a empty file will yield the file in the state it was in when the snapshot was taken. +`.time/000-reverse` is a placebo, there is nothing inside it. This is because we would never want to go from our first version of the file, to nothing. When read by `restore.rs`, it will be ignored. + +Now we will modify `demo/test`, and then take another snapshot. This is where things get interesting. What we will now do, is load `.time/000-reverse` and `demo/test` to memory, and then attempt to apply `000-reverse` to `demo/test` and keep it in a new variable, lets say `ref`. But, remember that `000-reverse` is not a valid patch file (since we never want to go from a real file to a empty file), so as a reference we will need to use `000` and apply it to a empty "file", yielding the original file. So now `ref` is our original file. Now we take our `demo/test` we loaded to memory, and create two new patches; `001` which is made from `ref` as old and `demo/test` as new (allowing us to recover `demo/test` given `ref`), and `001-reverse` which is created in reverse, alllowing us to recover `ref` given `demo/test`. + +Now we will make one more modification to `demo/test`, and take just one more snapshot. This let's us explain what happens when our `-reverse` IS valid, which was not the case last time. All further snapshots will follow the formula of this specific snapshot. + +We want to make two patches once again, so we will load `.time/001-reverse` and `demo/test` to memory, and apply `001-reverse` to `test`. Since `001-reverse` IS valid this time, we will yield the version of the file right before the last snapshot, AKA the original file. So now `ref` is our original file. And again we take `demo/test` in memory and create two more patches, `002` from `ref` as old and `demo/test` as new (which again allows us to recover `demo/test` given `ref`) and `002-reverse` which recovers `ref` given `demo/test`. + +#### Restoring backwards +Ok, finally we can get to restoring a snapshot. At this point we have 3 snapshots, so let's try to restore our very first one. + +Once it is selected, we see that there is no `activeSnapshot` so we can assume we are in the past. We check the snapshots, and see that there are two snapshots to restore in order to reach our target snapshot, so we restore the second one we took. + +For our first snapshot to restore, the only changed file is `demo/test`, and it is associated with snapshot `002`. Since we are moving into the past, we want to recover `demo/test` at the time of the snapshot given `ref`, so we are going to use `002`. Now we take the patch entry and check the reference patch. It is `001-reverse`. So now we take `demo/test` and load it to memory, and apply `001-reverse`, giving us `ref`, which is identical to the `ref` we got while making that snapshot. Now we can apply `001` to `ref`, giving us our target state. We are now half way to our target snapshot state. + +For our second snapshot, once again the only changed file is `demo/test`, which is this time associated with snapshot `001`. We are again moving into the past, so we will want to recover `ref` from our first snapshot, and so we look at what our reference patch is. We see that it is `000-reverse`, which when read, is not a valid patch file. Since it is not, we will load `000` to memory, and apply it against a empty "file", yielding the target file. But wait- why did we even do that last thing if we could just have just done this, yielding the target file instantly? Because this is a special case where `000-reverse` was not valid. So that last step was not needed. But in a case where the initial state was not the target, we would still have needed that step, since all the patches at that point were created with that reference in mind. + +#### Restoring forwards + +Now lets restore our third snapshot, so we can return to our normal state. +We check the `activeSnapshot` and see that the target is in the future, and we will need to restore two snapshots to get there. Since we are restoring into the future, no references will be necessary, since the patch right in front of the current snapshot used our state as a reference. This means only one patch per patch, instead of two like when restoring backwards! But right before doing any restoring, we will need to check `000-reverse` to make sure it isn't a invalid patch. And what would you know, it is! What this means is that the final target snapshot actually does use our current file state as a ref, since it couldn't do it with the `-reverse` file. This saves us a step, and means we can go directly to the target! + +Great, now lets go to the final, and target snapshot. We load `demo/test` to memory, check if `001-reverse` is valid, see that it is, and determine that we can safely directly apply the patch to the file, so we loa dup `002` to it, yielding our target file. + +Ok, but let's just go over a case where we do have another snapshot ahead, just for examples sake. Ok, so we have a snapshot `003` that has a reference of two snapshots ago, since we restored + + +### .time structure +The .time folder contains all the information related to snapshots of the directory. Inside are 3 `json` files: + - `metadata.json` - This contains stored metadata for every file (date changed, file size, and optionally hash), and is used to detect changed files. + - `patches.json` - Every time a patch is created, the ID (more on that below) and reference patch that was used will be stored here. And of course the target path. There is a layer of abstraction in `diffs.rs` that will handle this file. + - `snapshots.json` - Every time a snapshot is created, every patch that was created and its target path is stored in here. + + Whenever a patch of a file is created, two files will be created. They will be named `ID` and `ID-reverse`. The way the `ID` is generated is by taking the current date and target path, and creating a SHA256 hash from them. This way every patch will have a unique path within `.time` and the path can be easily generated from the `patches.json` file. The way the actual patch is generated is by creating a "patch" from the old (usually a reference in memory) and new (current file), and compressing it with brotli. The `reverse` patch is created in the opposite direction. + + `ID` is just a diff between the old file (which can either be a empty file on the first snapshot or a reference patched file), and `ID-reverse` is just a diff between the new file and old file, allowing us to travel in reverse (since patches are not reversible with `bsdiff`.) + + When we restore a snapshot, we want to check if the snapshot is in the past (relative to the current "state/date"), so we store this in `.time/activeSnapshot`. And if none exists, we can safely assume the most recent snapshot is the current state. Otherwise, everytime a snapshot is restored, we write the snapshot date to this file. + +### Modes explanation + + #### Standard + When a snapshot is created, we will restore upwards from the initial patch, and then create only a forward snapshot. This means only one patch is needed per patch. This however also means we can't truly move backwards into the past, we have to restore upwards from the initial snapshot until we reach our target. + #### Faster and bigger + This is the same as the fastest and biggest approach (see below), except for one thing: The reference is always just the initial stored copy of the file. This means creating snapshots is much much faster, but it also means we don't get any potential reduced disk usage due to deduplication. + #### Fastest and biggest + This is the same as the broken approach, except that to generate a reference, we will need to restore up to the most recent version, and use that. Then, we create two patches like before. This means that going forward is faster, but much more storage is required. + +### Notes +You can place any files you want to inside `demo.bak`, and then run `test.sh`. Just don't remove `config.json` or the test script will break. +Multiple snapshots will be made with various folders that already exist within the repository, and then restoring each of those snapshots will be tested for accuracy. All files will be checksummed as a way to ensure the program is working properly. + +### TODO +Hashing: Use xxhash for file hashing since it is so bloody fast. Currently used to verify existing files. +Optionally change .time location. +Be able to ignore directories, like a .gitignore diff --git a/build/file-time-machine.wxs b/build/file-time-machine.wxs new file mode 100755 index 0000000..3bbf50e --- /dev/null +++ b/build/file-time-machine.wxs @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/demo.bak/config.json b/demo.bak/config.json new file mode 100644 index 0000000..ea546eb --- /dev/null +++ b/demo.bak/config.json @@ -0,0 +1,10 @@ +[ + { + "folder_path": "./demo", + "get_hashes": false, + "thread_count": 0, + "brotli_compression_level": 5, + "snapshot_mode": "fastest", + "its_my_fault_if_i_lose_data": true + } +] diff --git a/gui/button_1.png b/gui/button_1.png new file mode 100644 index 0000000000000000000000000000000000000000..70e471a094c8816698d404f7f3583d4c293509d3 GIT binary patch literal 4501 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 literal 0 HcmV?d00001 diff --git a/gui/button_3.png b/gui/button_3.png new file mode 100644 index 0000000000000000000000000000000000000000..92300c9f4e4ffd813e3bfecd14ea854f7e6bc183 GIT binary patch literal 3558 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 g_Xl6S2?44) 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 BOM3tS literal 0 HcmV?d00001 diff --git a/gui/gui.py b/gui/gui.py new file mode 100644 index 0000000..2ef1f13 --- /dev/null +++ b/gui/gui.py @@ -0,0 +1,324 @@ + +# This file was generated by the Tkinter Designer by Parth Jadhav +# https://github.com/ParthJadhav/Tkinter-Designer +# And modified by Mizuki Zou +# This was not made with quality in mind, but rather as a quick and dirty GUI. Keep this in mind while using it. + + +from pathlib import Path +from shutil import which +import os +import json +import time +import subprocess +import threading + +# from tkinter import * +# Explicit imports to satisfy Flake8 +from tkinter import Tk, Canvas, Entry, Text, Button, PhotoImage, filedialog, messagebox, Listbox, LEFT, BOTH, Scrollbar, RIGHT, END, HORIZONTAL, Toplevel +from tkinter.ttk import Progressbar +from tkinter.filedialog import askopenfilename + + +OUTPUT_PATH = Path(__file__).parent +ASSETS_PATH = OUTPUT_PATH / Path(r".") + +config_path = "" +target_path = "" + +windows_paths = ["C:\\Progam Files\\File Time Machine\\ftm.exe", "C:\\Program Files (x86)\\File Time Machine\\ftm.exe"] +path_windows = "" + +if os.path.exists(windows_paths[0]): + path_windows = windows_paths[0] +elif os.path.exists(windows_paths[1]): + path_windows = windows_paths[1] + +platform = os.name # nt for Windows, posix for Linux. + +def relative_to_assets(path: str) -> Path: + return ASSETS_PATH / Path(path) + +def select_dir_or_file(dir: bool): # If dir, we know it is the target path. Otherwise, it is the config file + global config_path, target_path, exists + if not exists: + messagebox.showerror("No binary", "FTM binary not found! You need to install it before using the gui.") + return + + if dir: + messagebox.showwarning("Warning!", "This software is NOT stable, and will probably result in data loss if you use it!") + folder_selected = filedialog.askdirectory() + target_path = folder_selected + if folder_selected != "": + print(folder_selected) + window.title(folder_selected) + + if not os.path.isdir(folder_selected+"/.time"): + if messagebox.askquestion('Config file','This folder is not currently being tracked, do you want to begin tracking it? A config folder will be created for you, and default settings will be applied. (No hashing, compression level 5, multithreading enabled)'): + os.mkdir(folder_selected+"/.time") + print("Starting to track "+folder_selected) + if platform == "posix": + config_path = folder_selected+"/.time/gui-config.json" + else: + config_path = folder_selected+"\\.time\\gui-config.json" + config_file = open(config_path, 'w') + print("Writing config to "+str(config_file)) + config_file.write('''[ +{ + "folder_path": "'''+folder_selected+'''", + "get_hashes": false, + "thread_count": 0, + "brotli_compression_level": 5, + "snapshot_mode": "fastest", + "its_my_fault_if_i_lose_data": true +} +]''') + config_file.close() + else: + if not os.path.exists(folder_selected+"/.time/gui-config.json"): + print(folder_selected+"/.time/gui-config.json") + messagebox.showinfo("No config", "Could not find a config file, please specify one") + else: + config_path = folder_selected+"/.time/gui-config.json" + get_snap_list() + else: + config_path = askopenfilename() + print(config_path) + +def get_snap_list(): + global listbox, target_path + listbox.delete(0, END) + print(target_path+'/.time/snapshots.json') + if os.path.exists(target_path+'/.time/snapshots.json'): + with open(target_path+'/.time/snapshots.json') as f: + d = json.load(f) + for i in range(len(d)): + print(d[i]["date_created"]) + listbox.insert(END, d[i]["date_created"]) + else: + messagebox.showinfo("No Snapshots", "Did not find any snapshots to list.") + +def create_snapshot(): + global config_path + if config_path == "": + messagebox.showerror("Select folder", "You need to select a folder before you can create a snapshot!") + return + output = "" + progress_window = Toplevel() + progress_window.resizable(width=False, height=False) + progress_window.title("Creating snapshot...") + progress_window.geometry("300x100") + + # Create a progress bar in the new window + progress = Progressbar(progress_window, orient=HORIZONTAL, length=280, mode='indeterminate') + progress.pack(pady=20) + if platform == "posix": + print("Running command 'ftm -c "+config_path+"'") + progress.start() + p1 = subprocess.Popen(['ftm', '-c', config_path], stdout=subprocess.PIPE) + else: + print("Running command '"+path_windows+" -c "+config_path+"'") + progress.start() + p1 = subprocess.Popen([path_windows, '-c', config_path], stdout=subprocess.PIPE) + # p1 = subprocess.Popen(['sleep', '3'], stdout=subprocess.PIPE) + output = p1.communicate()[0] + print(output) + progress.stop() + progress_window.destroy() + if "No files changed" in str(output): + messagebox.showwarning("No changed files", "There were no changed files, so I cannot take a snapshot!") + get_snap_list() + +def restore_snapshot(): + global listbox + # print(listbox.curselection()[0]) + selection = listbox.curselection()[0]+1 + if listbox.curselection() == (): + messagebox.showerror("No snapshot", "No snapshot is selected!") + return + progress_window = Toplevel() + progress_window.resizable(width=False, height=False) + progress_window.title("Restoring snapshot") + progress_window.geometry("300x100") + + # Create a progress bar in the new window + progress = Progressbar(progress_window, orient=HORIZONTAL, length=280, mode='indeterminate') + progress.pack(pady=20) + progress.start() + print("Running 'ftm -c "+config_path+" restore --restore-index "+str(selection)+"'") + p1 = subprocess.Popen( + ['ftm', '-c', config_path, 'restore', '--restore-index', str(selection)], stdout=subprocess.PIPE) + output = p1.communicate()[0] + print(output) + progress.stop() + progress_window.destroy() + if "Finished restoring" not in str(output): + messagebox.showerror("Error", "There was an issue restoring a snapshot! Error: "+str(output)) + +window = Tk() + +window.geometry("443x428") +window.configure(bg = "#313244") + +if platform == "posix": + exists = which("ftm") +else: + exists = os.path.exists(path_windows) + +canvas = Canvas( + window, + bg = "#313244", + height = 428, + width = 443, + bd = 0, + highlightthickness = 0, + relief = "ridge" +) + +canvas.place(x = 0, y = 0) +canvas.create_text( + 90.0, + 19.0, + anchor="nw", + text="FTM-GUI", + fill="#CDD6F4", + font=("Jost Regular", 32 * -1) +) + +if exists: + canvas.create_rectangle( + 228.0, + 20.0, + 416.0, + 70.0, + fill="#A6E3A1", + outline="") + + canvas.create_text( + 260.0, + 32.0, + anchor="nw", + text="Found ftm binary!", + fill="#000000", + font=("Jost Regular", 15 * -1) + ) +else: + canvas.create_rectangle( + 228.0, + 20.0, + 416.0, + 70.0, + fill="#e78284", + outline="") + + canvas.create_text( + 250.0, + 32.0, + anchor="nw", + text="No ftm binary found!", + fill="#000000", + font=("Jost Regular", 15 * -1) + ) + +listbox = Listbox(window, bg="#9399B2", selectmode='single') +scrollbar = Scrollbar(window, bg="#9399B2") +# Create scrollbox for list of snapshots +listbox.place(x=29, y=154, width=387, height=209) # Listbox within the rectangle +scrollbar.place(x=416, y=154, height=209) # Scrollbar on the right side of the Listbox + +# Attach the Listbox to the Scrollbar +# for values in range(100): +# listbox.insert(END, values) + +listbox.config(yscrollcommand=scrollbar.set) +scrollbar.config(command=listbox.yview) +canvas.create_rectangle( # Scrollbox rectangle + 29.0, + 154.0, + 416.0, + 363.0, + fill="#9399B2", + outline="") + +canvas.create_text( + 29.0, + 128.0, + anchor="nw", + text="Available snapshots", + fill="#CDD6F4", + font=("Jost Regular", 15 * -1) +) + +button_image_1 = PhotoImage( + file=relative_to_assets("button_1.png")) +button_1 = Button( + image=button_image_1, + borderwidth=0, + highlightthickness=0, + command=lambda: threading.Thread(target=restore_snapshot, daemon=True).start(), + relief="flat" +) +button_1.place( + x=26.0, + y=376.0, + width=196.0, + height=27.0 +) + +button_image_2 = PhotoImage( + file=relative_to_assets("button_2.png")) +button_2 = Button( + image=button_image_2, + borderwidth=0, + highlightthickness=0, + command=lambda: threading.Thread(target=create_snapshot, daemon=True).start(), + relief="flat" +) +button_2.place( + x=225.0, + y=376.0, + width=194.0, + height=26.0 +) + +button_image_3 = PhotoImage( + file=relative_to_assets("button_3.png")) +button_3 = Button( + image=button_image_3, + borderwidth=0, + highlightthickness=0, + command=lambda: select_dir_or_file(True), + relief="flat" +) +button_3.place( + x=17.0, + y=86.0, + width=194.0, + height=26.0 +) + +button_image_4 = PhotoImage( + file=relative_to_assets("button_4.png")) +button_4 = Button( + image=button_image_4, + borderwidth=0, + highlightthickness=0, + command=lambda: select_dir_or_file(False), + relief="flat" +) +button_4.place( + x=225.0, + y=86.0, + width=194.0, + height=26.0 +) + +image_image_1 = PhotoImage( + file=relative_to_assets("image_1.png")) +image_1 = canvas.create_image( + 42.0, + 42.0, + image=image_image_1 +) +window.resizable(False, False) +window.mainloop() diff --git a/gui/image_1.png b/gui/image_1.png new file mode 100644 index 0000000000000000000000000000000000000000..426be47045efee72025ab54525e1a6f35657821f GIT binary patch literal 7436 zcmV+n9rNOeP)sBm5P_fqMFGP@gjU64dwf(78OCd^dOWR0YXxiXIQ5)=lH74` zPaWr0MMR9x)~dCk1%+z0J&LtOK@`MFm4XBU3FHY$zQ=y8HSZt$`#uuHf{)udMh0YO z?X~Az^Zi)BmjsQCEH-Z3r~szn&mU-6Kjof#o-YJ|*49=D0IMEdJN1#D{bAjT$DjYo z@aI2iX)V5Q)!L~ecV^nSaieN%WbtL;N+)0P*`cve8;Rj+d)S{BQ2uc{NX%@VcG!hcPeg04pDRarUYwez&}_u~9WQFASI&MTKLQ#tRAyqtWO;MG9h1 zH#LFh^;rLsh!)C5j?$zMhz@r&c}f}&_NeibvLxp`sG($Zo* z^!Q76u6+3U*~7p04=0+LiJ7UjwN*}=)*vVMyt#7{iWWj1WNp34B;v$AFV7TdP3%w$qQ5cn*mT1rIxK+F0m9*=!} z%$V}~wBb7dVA`~4)Y{rAb2xk+{J#|OzWVA`IsXFnhj=3KH6l{3<8(3MO+i?~oL^>a zJ0gO>hcO10Wx@A-2qB=91pBJi2fmL3?fVe; zJ{aKe`3Qm$jmEDMN>(^dCeTJJ0-cc~_`ydvT=v*gul;7l!!O)29IsOabt;Q;V${^s z6uv~XI2Mgx8hGB)l@Gu0M`qqfQdH|egNT$40?q{55J3W=I57Yrgz!8Me|Tj*+V{2L zwDGn0`lXA=vMl1`PAHQ}Yk)<#pN5dK`hnHY|BOgZ5JFuLjYW}355)jHlEbm7sfn7K zo6VO(G!ZcXw6?a2jT<+r9e+L2pGl{*A9yAbja?vxxFC~BXGsHyh(dx1%nWT9%#3I> zg091d(7vw?N?B;%--iCaUQ~~(f#bL!A_zesA`r1Wrn2F_@kHT`+Gw~=Cdj1IWDNH; zZrrG*_4F_inf$^pgXmRHyj-JvFH>K?s22e4UAbmJN-2rRc#fkPK%T1vFatqgpbf)P z0)+)O0AMKTp{%SNwWm+U-aUWAq|?ttSy?$8*F#ZZ1Pnqd<-+%MsOyq|?0x`{$s_|X z5eZ@`rP97X*t{@wX)9K|RL0RDc4xz)_LDO4)BeEBL`2;9la|xQjH=uwLU#WNz-9v9 zXj%3Y-}eo&b|Fu!HXs0FM#oWJn&9%%0vc5u12bduyY1-d%|Hl7UvCdeO3Ppjp}aJX zb7$2+2*Q!08Fcri(A}Fv|B*D90ZWMy!~-yvWeMN+K4j2S0L=sNRc4L;qg{2)cQ>@> z@IMi4pUUZ`rX~Wwc5HM804&QMBZOQmm0aw)4m{5T5oHM`1VKn_WqAQ=$B#i}c_A!I zk}(Xe4TKPwH~Tbfey1H>JtUBc#BJ?I zsH-Ol2B4%sR`-SHd9W;Ns%=OAQ)>-o1J}(QfkJvX{-@08oHd*_Z5myD_0?u&%M0tG zvBVEpMQCt+|uS~9>>aj%-B0Pi;K>`tAm?4FL?+1A2y-pk%a51{r z#;lpuuq_2`3-aS<|^3~bAS=Xnr9ZpJuFtQiesn0#M@h>%Fc;W!SIrNC?ui^b5<(FrLfwrzV4g@pz9@Pi%Lzkfec z=`>EOse#rSH8s_6T^9gBYmG!a+{gC=SW2R9dKE^GiedZvo%!!G14JZTCk-P0)∋ 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ÁG^XvHaF0H!izv z(FF*CE1gVwErZtQ))Nt*%78oYCEnp!uu%PXESC5x5h*9U!?8&KR}EX1y~7be>tpk0 zQp)Tdj*;C`9Dj$yNx!k_HUArH*8GF-aO7b4ZJpNER=H+Py(U5J#iL4;>o{+MaN+WX zMN2iCw{1JZ%p6#@EnL^xYE00hlwxK!N-5Sx_j<0g)v{~>=D@ZiY>eJ=N5i6}LI(?7 z&v~(*A5U-Y)l=OUVjn25N(zFy~o00Dbte}DH6Y&vw|vW7(;F*8B) zZLIn7>9VHlZ_xBqXo$Gn-x@at+lw}jZFEBIRe*0A) z_MTCAL4SYu4-AQwK$suY*VpSD4xbDE&-2pBAa?`r$N%@|XA~8V-U`qbcXVd);LfXE zV2vuNv|lJIuezkGyYu%)4i{ezTxhDQHbi~U1`uYa(u1>qbmw(@{(s%T`_kak?pofr zYVFjYw6qoj$L>_FSpCvh9)0rn&#zkb+o{8!Kla%3h5v}Vmj4f&O@21|T~k*80000< KMNUMnLSTZg7A5)s literal 0 HcmV?d00001 diff --git a/gui/logo.ico b/gui/logo.ico new file mode 100644 index 0000000000000000000000000000000000000000..29303741a4009ea5dea334ffa7903a221cf68d7a GIT binary patch literal 23942 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�kCfVtqJ$@{k$?Jg%z?|Mt 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%0Av34EtltOP8gkN75>X) zAJ4>WGLvS5zD8pOcbVbA=r$_O3SB2Q%RwF_`c|YU)npT*?1!0LD4mecKzu)dPzPX3 zH6iJKb>tt7HDik@N=pu)-W1*~+0avrBv0yd-ckelh zT9ej1@En~2@T>EBp302Uv?;oNS*4yu%jS5Q&a+jacV|pVX5VTzk0!1h_Eql@f5jqf zb}wgUS|)T2FdDsNQma7`*X|Tv?a;4V=W65>_y=eHvI$T24!o2=sY&Y|TfZqzO3;zl zF6Qi1xCT96t5|u5JFRW&k|OMZpPF3G=|_93^5XWfj-57^&u5nh(aE4Jv|l&$d(ou8v{rx5@zlJg9-dD9 z--1s{yVhR>?ZHuNpICBBV(4$Yq<(W)L0?Coj=qlMLmCh{)fh=cqx)J-j+IowU~b;pH#I?=yC)B~VuhX+*jH;~ao zFpAOb5AKigrVtSm!I;g6%hMeEQ4ND4ywhsKxGehqowmC&7$2vFYS$So6cQkc8b_sM zF{CyFv_YgTd>Hj?B)V!4#0rcQ=?_^X-_AaX8=@l7)+5?jsCR^sTSavc`n$R;;(grh zKfiD1f|4va_-!-*j2m9`k, compression_level: u32) -> io::Result> { + // Create a Vec to hold the compressed data + let mut compressed_data = Vec::new(); + { + let mut compressor = + CompressorWriter::new(&mut compressed_data, 4096, compression_level, 22); + compressor.write_all(&input)?; + compressor.flush()?; + } // The compressor goes out of scope here, and its resources are released. + + Ok(compressed_data) +} + +pub fn decompress_data(compressed: Vec) -> io::Result> { + let mut decompressed_data = Vec::new(); + { + let mut decompressor = Decompressor::new(&compressed[..], 4096); + decompressor.read_to_end(&mut decompressed_data)?; + } + Ok(decompressed_data) +} diff --git a/src/diffs.rs b/src/diffs.rs new file mode 100644 index 0000000..72deb09 --- /dev/null +++ b/src/diffs.rs @@ -0,0 +1,869 @@ +use std::error::Error; +use std::collections::HashSet; +use std::sync::{Arc, Mutex}; +use sha2::{Digest, Sha256}; +use std::path::Path; +use std::fs::File; +use bsdiff::diff; +use walkdir::WalkDir; +use std::fs::metadata; +use std::{io, time::UNIX_EPOCH}; +use std::io::ErrorKind; +use indicatif::{MultiProgress, ProgressBar, ProgressStyle, ProgressState}; +use std::thread; +use chrono::DateTime; +use std::io::Write; +use std::io::Read; +use std::process; +use log::debug; +use xxhash_rust::xxh3::xxh3_64; + +use crate::compression; +use crate::restore; +use crate::DiffEntry; +use crate::MetaFile; +use crate::ModifiedList; + +pub fn create_diff( // Never call this on a directory. Do checks outside of the function + mut old_file: String, + new_file: String, + target_path: String, + time_dir: String, + ref_patch: String, + old_raw: Vec, + compression_level: u32, + patch_store: &Arc>>, + create_reverse: bool, +) -> Result> { + /* This handles everything related to creating a diff, including storing its metadata/location. + If old_raw is set, then we will use it as the target file. Will create a forward diff and backward diff. + Backward diff will be {diff_id}-reverse. Every diff is compressed with brotli before being written. + */ + // println!("create_diff called"); + // println!("New: {new_file}"); + // println!("Old: {old_file}"); + let mut sha256 = Sha256::new(); + let old: Vec; + let current_time: String = chrono::offset::Local::now().to_string(); + + if !Path::new(&old_file).exists() || !Path::new(&old_file).is_file() { + // In this case, we assume there is a new file, so old_file is directed to an empty file + old_file = time_dir.clone() + "/tmp_empty"; + } + + if !old_raw.is_empty() { + // Handle case where old is stored in memory + debug!("create_diff: Old stored in memory!"); + old = old_raw; + } else { + old = std::fs::read(old_file.clone()).unwrap_or_else(|_| panic!("Could not open {old_file}!")); + } + // println!("Old file is {}", old_file); + let new = std::fs::read(new_file.clone()).unwrap_or_else(|_| panic!("Could not open {new_file}!")); + + sha256.update(current_time.clone() + &target_path); // Generate an ID to identify the patch. This can be derived from the data stored in DiffEntry, which can then be used to identify where the patch file is. + let patch_id: String = format!("{:X}", sha256.finalize()); + + let mut patch_target = File::create(Path::new(&(time_dir.clone() + "/" + &patch_id))).unwrap_or_else(|_| panic!("Could not create patch_target at {}", + time_dir.clone() + "/" + &patch_id)); + if create_reverse { + debug!("Creating reverse!"); + let mut patch_target_reverse = + File::create(Path::new(&(time_dir.clone() + "/" + &patch_id + "-reverse"))).unwrap_or_else(|_| panic!("Could not create patch_target at {}", + time_dir.clone() + "/" + &patch_id)); + + let mut patch_reverse = Vec::new(); + // println!("{:?}", new); + // println!("{:?}", old); + diff(&new, &old, &mut patch_reverse)?; + // println!("Compressing reverse..."); + + let temp_compressed = compression::compress_data(patch_reverse, compression_level)?; + // let elapsed = now.elapsed(); + // println!("Compressing reverse: {:.2?}", elapsed); + + patch_target_reverse + .write_all(&temp_compressed) + .expect("Unable to write to patch file!"); + } else { + debug!("Creating false reverse!"); + let mut patch_target_reverse = + File::create(Path::new(&(time_dir.clone() + "/" + &patch_id + "-reverse"))).unwrap_or_else(|_| panic!("Could not create patch_target at {}", + time_dir.clone() + "/" + &patch_id)); + write!(patch_target_reverse, ":3").unwrap_or_else(|_| panic!("There was an issue writing to {}!", time_dir.clone() + "/" + &patch_id + "-reverse")); + } + + let mut patch = Vec::new(); + + + + // let now = Instant::now(); + diff(&old, &new, &mut patch)?; + // let elapsed = now.elapsed(); + // println!("Diff calc: {:.2?}", elapsed); + + // let now = Instant::now(); + // println!("Compressing patch..."); + let temp_compressed = compression::compress_data(patch, compression_level)?; + // let elapsed = now.elapsed(); + // println!("Compressing orig: {:.2?}", elapsed); + + patch_target + .write_all(&temp_compressed) + .expect("Unable to write to patch file!"); + + // let now = Instant::now(); + + // let mut writer = brotli::Compressor::new(&mut io::stdout(), 4096, 4, 20); + let patch_store_file = time_dir.clone() + "/patches.json"; + + let patch_entry = DiffEntry { + date_created: current_time, + target_path, + ref_patch, + }; + + { + let mut patch_store = patch_store.lock().unwrap(); + patch_store.push(patch_entry); + + let json = + serde_json::to_string_pretty(&*patch_store).expect("Unable to serialize metadata!"); + let mut file = File::create(Path::new(&patch_store_file)).unwrap_or_else(|_| panic!("Unable to create metadata file at {patch_store_file}")); + file.write_all(json.as_bytes()).unwrap_or_else(|_| panic!("Unable to write to metadata file at {patch_store_file}")); + } + Ok(patch_id) +} + +pub fn get_diffs( + check_hash: bool, + metadata_holder: &HashSet, + folder_path: &str, +) -> Result, Box> { + + let mut different_files: HashSet = HashSet::new(); + let mut temp_hold: HashSet = HashSet::new(); + let mut current_files: HashSet = HashSet::new(); + debug!("folder_path is {folder_path}"); + for entry in WalkDir::new(folder_path) { + let entry = entry?; + let path = entry.path(); + // debug!("{:?}", path); + if let Some(path_str) = path.to_str() { + if !path_str.contains(".time") && !path_str.contains(".git") && path_str != folder_path { + current_files.insert(ModifiedList { + path: path_str.to_string(), + exists: true, + modified: false, // We don't know yet, but we will change this if needed. false will be the default. + }); + } + } else { + // Handle the case where the path is not valid UTF-8 + eprintln!("Error: Path is not valid UTF-8: {}", path.display()); + } + } + for path in metadata_holder.iter() { + temp_hold.insert(ModifiedList { + path: path.path.to_string(), + exists: true, + modified: false, + }); + } + + for path in current_files.iter() { + if !temp_hold.contains(&ModifiedList { + path: path.path.clone(), + exists: true, + modified: false, + }) { + debug!("Found new file:{}", path.path.clone()); + different_files.insert(ModifiedList { + path: path.path.clone(), + exists: true, + modified: true, + }); + } + } + for meta in metadata_holder.iter() { + // println!("Got: {}", meta.path); + match metadata(&meta.path) { + Ok(metadata) => { + // File exists, continue + // let metadata = metadata(&meta.path)?; + // Get the modification time from the metadata + let modified_time = metadata.modified()?; // Replace ? with proper error handling if we want to do it here. Otherwise, we handle it outside the function. + + // Convert SystemTime to UNIX epoch + let duration_since_epoch = modified_time.duration_since(UNIX_EPOCH)?; + let epoch_seconds = duration_since_epoch.as_secs(); + // Checking date modified and size is prioritized over hash since it is much faster. + // if Path::new(&meta.path.clone()).is_file() { + // Ensure the parent directory is not counted as updated file + if epoch_seconds != meta.date_modified { + // Check if file is modified using date modified + debug!( + "File is different: {} (discovered using modify date)", + meta.path + ); + different_files.insert(ModifiedList { + path: meta.path.clone(), + exists: true, + modified: true, + }); + } else if metadata.len() != meta.size { + // If date modified is the same, check if file size has changed + debug!("File is different: {} (discovered using size)", meta.path); + different_files.insert(ModifiedList { + path: meta.path.clone(), + exists: true, + modified: true, + }); + } else if check_hash { + // check_hash enabled, check hash as last resort + if hash(&meta.path)? != meta.hash { + debug!("File is different: {} (discovered using hash)", meta.path); + different_files.insert(ModifiedList { + path: meta.path.clone(), + exists: true, + modified: true + }); + } else { + // println!("Confirmed file is not modified. (Used hash)"); + different_files.insert(ModifiedList { + path: meta.path.clone(), + exists: true, + modified: false, + }); + } + } else { + // println!("Confirmed file is not modified. (Used modify date and size)"); + different_files.insert(ModifiedList { + path: meta.path.clone(), + exists: true, + modified: false, + }); + } + // } else if meta.path != folder_path { + // // println!("insert {}", meta.path); + // different_files.insert(ModifiedList { + // path: meta.path.clone(), + // exists: true, + // modified: true, + // }); + // } + } + Err(error) => match error.kind() { + ErrorKind::NotFound => { + debug!("File no longer exists: {}", meta.path); + different_files.insert(ModifiedList { + path: meta.path.clone(), + exists: false, + modified: true, + }); + } + other_error => { + panic!( + "Problem reading file: {} with error: {}", + meta.path, other_error + ); + } + }, + } + } + // println!("{:?}", different_files); + Ok(different_files) +} + +pub fn update_metadata( + metadata_holder: &mut HashSet, + modified_list: &HashSet, + hash_enabled: bool, +) -> Result<(), Box> { + // Update metadata with modified_list to update data. + let mut paths_to_update = Vec::new(); // Paths that need updating + let mut temp_hold: HashSet = HashSet::new(); + let mut updated_files = HashSet::new(); // Temp set to hold elements that we will add at the end + + // for meta in metadata_holder.iter() { + // let item_to_check = ModifiedList { path: meta.path.clone(), exists: true }; + + // if modified_list.contains(&item_to_check) { + // paths_to_update.push(meta.path.clone()); // Collect paths that need updates + // } + // } + for path in metadata_holder.iter() { + temp_hold.insert(ModifiedList { + path: path.path.to_string(), + exists: true, + modified: false, + }); + } + + for path in modified_list.iter() { + if temp_hold.contains(&ModifiedList { + path: path.path.clone(), + exists: true, + modified: false, + }) { + if path.exists { + paths_to_update.push(path.path.clone()); + } + } else if !temp_hold.contains(&ModifiedList { + path: path.path.clone(), + exists: false, + modified: false, + }) { + paths_to_update.push(path.path.clone()); + } + } + + // for path in modified_list.iter() { + // paths_to_update.push(path.path.clone()); + // } + + println!("Finished generating list. Recalculating metadata..."); + // debug!("{:?}", modified_list); + // println!("{:?}", modified_list); + { + let mut modified_files = false; + for modified in modified_list { + if modified.modified { + modified_files = true; + break + } + } + if !modified_files { + println!("No files changed, nothing to do!"); + process::exit(1); + } + } + + for path in paths_to_update { + let _hash_str: String = Default::default(); + if hash_enabled { + let _hash_str: String = hash(&path).unwrap_or_else(|_| panic!("There was a unhandled issue getting the hash of {path}")); + } else { + let _hash_str: String = "".to_string(); + } + let file_metadata = metadata(&path)?; + let size = file_metadata.len(); // Get file size + + // Get the modification time from the metadata + let modified_time = file_metadata.modified()?; + + // Convert SystemTime to UNIX epoch + let duration_since_epoch = modified_time.duration_since(UNIX_EPOCH)?; + let epoch_seconds = duration_since_epoch.as_secs(); + + let updated_meta_file = MetaFile { + date_modified: epoch_seconds, + hash: _hash_str, + size, + path: path.clone(), + }; + + // Remove the old element + metadata_holder.retain(|meta| meta.path != path); + + // Insert the updated element + updated_files.insert(updated_meta_file); // updated_files gets extended at the end + } + + metadata_holder.extend(updated_files); + + let paths_to_remove: HashSet<_> = metadata_holder + .iter() + .filter_map(|meta| { + let item_to_check = ModifiedList { + path: meta.path.clone(), + exists: false, + modified: true, + }; + if modified_list.contains(&item_to_check) { + Some(meta.path.clone()) + } else { + None + } + }) + .collect(); + + metadata_holder.retain(|meta| !paths_to_remove.contains(&meta.path)); + + Ok(()) +} + +pub fn get_properties( + folder_path: &str, + mut metadata_holder: HashSet, + hash_enabled: bool, +) -> Result, Box> { + let mut file_count = 0; + let mut file_index = 0; + + for _entry in WalkDir::new(folder_path) { + file_count += 1; + } + + let pb = ProgressBar::new(file_count); + pb.set_style(ProgressStyle::with_template("{spinner:.green} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {pos:>3}/{len:3} ({eta})") + .unwrap() + .with_key("eta", |state: &ProgressState, w: &mut dyn std::fmt::Write| write!(w, "{:.1}s", state.eta().as_secs_f64()).unwrap()) + .progress_chars("#>-")); + + for entry in WalkDir::new(folder_path) { + file_index += 1; + pb.set_position(file_index); // Update progress bar. + + let entry = entry?; + let path = entry.path(); + + // Convert Path to &str + if let Some(path_str) = path.to_str() { + if !path_str.contains(".time") && !path_str.contains(".git") && path_str != folder_path { + // Use the path as a &str + let _hash_str: String = Default::default(); + if hash_enabled { + let _hash_str: String = hash(path_str).unwrap_or_else(|_| panic!("There was a unhandled issue getting the hash of {path_str}")); + } else { + let _hash_str: String = "".to_string(); + } + let metadata = metadata(path)?; + let size = metadata.len(); // Get file size + + // Get the modification time from the metadata + let modified_time = metadata.modified()?; + + // Convert SystemTime to UNIX epoch + let duration_since_epoch = modified_time.duration_since(UNIX_EPOCH)?; + let epoch_seconds = duration_since_epoch.as_secs(); + // println!("{}", size); + // println!("{}", epoch_seconds); + // println!("{}", path_str); + + let meta_file = MetaFile { + date_modified: epoch_seconds, + hash: _hash_str, + size, + path: path_str.to_string(), + }; + metadata_holder.insert(meta_file); + } + // metadata_holder.push(MetaFile {hash: hash}); + } else { + // Handle the case where the path is not valid UTF-8 + eprintln!("Error: Path is not valid UTF-8: {}", path.display()); + } + } + pb.finish(); + Ok(metadata_holder) +} + +pub fn hash(path: &str) -> Result> { + // println!("hash called"); + let mut file = match File::open(Path::new(path)) { + Ok(file) => file, + Err(e) => { + if e.kind() == io::ErrorKind::NotFound { + eprintln!("Error: The file '{}' was not found.", path); + panic!("quit"); + } else { + // Handle other kinds of I/O errors + eprintln!("Error: Unable to open file '{}': {}", path, e); + } + return Err(Box::new(e)); + } + }; + + let mut hasher = Sha256::new(); + + let mut buffer = [0u8; 1024]; + while let Ok(bytes_read) = file.read(&mut buffer) { + // Run the loop as long as file.read returns Ok(bytes_read) + if bytes_read == 0 { + break; + } + hasher.update(&buffer[..bytes_read]); // Slice of buffer that starts at 0 and ends at bytes_read + } + + let result = hasher.finalize(); + let hash_string = hex::encode(result); + + // println!("Hash is {:x}", result); + + Ok(hash_string) +} + +pub fn create_diffs_multithread( + patch_ids: &Arc>>, + ref_patch_ids: &Arc>>, + target_paths: &Arc>>, + modified: &Arc>>, + folder_path: &String, + changed_files_vec: Vec, // We need it to be a vec since hashset doesn't support slices + changed_count: u32, + thread_count: u32, + compression_level: u32, + patch_store: &Arc>>, // This will be populated if first run, otherwise it must be pre populated + mut create_reverse: bool, + inital_run: bool, + snapshot_mode: &String, +) { + /* + Get the amount that we should give to each thread via split_into. Then calculate slice begin and end + and pass a cloned slice, the thread can own this. The thread will need to lock and unlock patch_ids and target_paths + however. + */ + debug!("create_diffs_multithread called"); + let mut children = Vec::new(); + let split_into = changed_count / thread_count; + let split_into_rem = changed_count % thread_count; + + let mut path_temp_hold_ref = HashSet::new(); + { + let patch_store = patch_store.lock().unwrap(); + for path in patch_store.iter() { + path_temp_hold_ref.insert(ModifiedList { + path: path.target_path.clone().to_string(), + exists: true, + modified: true, // Not needed. This is not really proper usage of ModifiedList. + }); + } + } + + let m = MultiProgress::new(); + + for i in 0..thread_count { + // Spawn our childrenfolder_path + let folder_path_new = folder_path.clone(); // To prevent moving ownership, we need to clone this value. + let slice_begin: usize = (i * split_into).try_into().unwrap(); + let mut slice_end: usize = ((i * split_into) + split_into).try_into().unwrap(); + // println!("slice_begin: {}", slice_begin); + // println!("slice_end: {}", slice_end); + if i == thread_count-1 { + slice_end += split_into_rem as usize; + } + let patch_ids = Arc::clone(patch_ids); + let target_paths = Arc::clone(target_paths); + let ref_patch_ids = Arc::clone(ref_patch_ids); + let patch_store = Arc::clone(patch_store); + let modified = Arc::clone(modified); + + + + let slice = changed_files_vec[slice_begin..slice_end].to_vec(); // Create new vector since our reference will die + // println!("{:?}", slice); + if inital_run { + children.push(thread::spawn(move || { + for path in slice.iter() { + if path.modified { + if Path::new(&path.path.clone()).is_file() { + let patch_id = create_diff( + "".to_string(), // This will never exist, so we can always create a temp file instead. + path.path.clone(), + path.path.clone(), + folder_path_new.clone() + "/.time", + "First patch".to_string(), + Vec::new(), + compression_level, + &patch_store, + create_reverse, + ) + .unwrap_or_else(|_| panic!("Was unable to create a diff between a new empty file and {}", + path.path)); + { + let mut patch_ids = patch_ids.lock().unwrap(); + let mut target_paths = target_paths.lock().unwrap(); + let mut ref_patch_ids = ref_patch_ids.lock().unwrap(); + let mut modified = modified.lock().unwrap(); + + patch_ids.push(patch_id); // Deref is automatic when using a `.` + target_paths.push(path.path.clone()); + ref_patch_ids.push("First patch".to_string()); + modified.push(true); // We want to push true since technically going from no file to a file is "modified". + } // Go out of scope to release our lock + } else { + { + let mut patch_ids = patch_ids.lock().unwrap(); + let mut target_paths = target_paths.lock().unwrap(); + let mut ref_patch_ids = ref_patch_ids.lock().unwrap(); + let mut modified = modified.lock().unwrap(); + + patch_ids.push("DIR".to_string()); + target_paths.push(path.path.clone()); + ref_patch_ids.push("DIR".to_string()); + modified.push(true); // We want to push true since technically going from no file to a file is "modified". + } + } + } else { + let mut patch_ids = patch_ids.lock().unwrap(); + let mut target_paths = target_paths.lock().unwrap(); + let mut ref_patch_ids = ref_patch_ids.lock().unwrap(); + let mut modified = modified.lock().unwrap(); + + if Path::new(&path.path).is_file() { + let file_contents = std::fs::read(&path.path).unwrap_or_else(|_| panic!("Could not open {} to check if it has been modified! Do I have read permission?", + path.path)); + let hash = xxh3_64(&file_contents); + patch_ids.push(hash.to_string()); + } else { + patch_ids.push("UNMODIFIED_DIRECTORY".to_string()); + } + target_paths.push(path.path.clone()); + ref_patch_ids.push("UNMODIFIED".to_string()); + modified.push(false); + } + } + })); + } else { + create_reverse = true; + debug!("create_reverse is true"); + let path_temp_hold = path_temp_hold_ref.clone(); + let folder_path_clone = folder_path.clone(); + let m = m.clone(); + let snapshot_mode = snapshot_mode.clone(); // Is this creating correct snapshots? + children.push(thread::spawn(move || { + let total: u64 = slice.len() as u64; + let pb = m.add(ProgressBar::new(total)); + pb.set_style(ProgressStyle::with_template("{spinner:.green} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {pos:>3}/{len:3} ({eta})") + .unwrap() + .with_key("eta", |state: &ProgressState, w: &mut dyn std::fmt::Write| write!(w, "{:.1}s", state.eta().as_secs_f64()).unwrap()) + .progress_chars("#>-")); + for path in slice.iter() { + if path.modified { + pb.inc(1); + // println!("{}", path.path.clone()); + + if path_temp_hold.contains(&ModifiedList { + path: path.path.clone().to_string(), + exists: path.exists, + modified: true, + }) { + debug!("Snapshot that can be used for reference exists!"); + // Snapshot exists that we can restore for reference + let search_path = path.path.clone().to_string(); // File that we want to snapshot + // let mut matching_items: Vec<&DiffEntry>; + let patch_unguard; + let patch_store = Arc::clone(&patch_store); + { + let patch_store = Arc::clone(&patch_store); + patch_unguard = patch_store.lock().unwrap().clone(); + + } + let matching_items: Vec<&DiffEntry> = patch_unguard + .iter() + .filter(|item| item.target_path == search_path) + .collect(); // Collect all items inside patch_store that have target_path equal to search_path + // Print all matching items + if !matching_items.is_empty() { + if matching_items.len() > 1 { + // println!("Found matching items:"); + // println!("{:?}", matching_items); + let mut date_check; + let mut target_path: String; + if let Some(first_item) = matching_items.first() { + let first_date_string = first_item.date_created.clone(); + // println!("{first_date_string}"); + date_check = DateTime::parse_from_str( + &first_date_string, + "%Y-%m-%d %H:%M:%S%.9f %z", + ) + .unwrap(); + target_path = first_item.target_path.clone(); + } else { + panic!("There was an issue parsing the patch store! Is this a valid date: {:?}", matching_items); + } + // Find correct patch to restore + debug!("{:?}", matching_items); + for item in matching_items { + // Files with snapshots + let date_check_string = item.date_created.clone(); + let new_date_check = DateTime::parse_from_str( + &date_check_string, + "%Y-%m-%d %H:%M:%S%.9f %z", + ) + .unwrap(); + // println!("{}", new_date_check); + // println!("{}", date_check); + if new_date_check > date_check { + // println!("Setting!"); + date_check = new_date_check; + target_path = item.target_path.clone(); + } + } + if Path::new(&target_path).is_file() { + let patch_id = restore::restore_and_diff( + &date_check.to_string(), + &target_path, + &folder_path_clone.clone(), + compression_level, + &patch_store, + create_reverse, + &snapshot_mode + ).expect("There was an issue restoring a reference patch and creating a new patch, did the .time folder go corrupt?"); + + { + let mut patch_ids = patch_ids.lock().unwrap(); + let mut target_paths = target_paths.lock().unwrap(); + let mut ref_patch_ids = ref_patch_ids.lock().unwrap(); + let mut modified = modified.lock().unwrap(); + + patch_ids.push(patch_id); + target_paths.push(target_path.clone()); + let mut sha256 = Sha256::new(); + sha256.update(date_check.to_string() + &target_path); + ref_patch_ids.push(format!("{:X}", sha256.finalize())); + modified.push(path.modified); + } + } else { + { + let mut patch_ids = patch_ids.lock().unwrap(); + let mut target_paths = target_paths.lock().unwrap(); + let mut ref_patch_ids = ref_patch_ids.lock().unwrap(); + let mut modified = modified.lock().unwrap(); + + patch_ids.push("DIR".to_string()); + target_paths.push(target_path.clone()); + ref_patch_ids.push("DIR".to_string()); + modified.push(path.modified); + } + } + } else { + // Restore only existing patch + { + // let mut patch_store = patch_store.lock().unwrap(); + if let Some(first_item) = matching_items.first() { + if Path::new(&first_item.target_path).is_file() { + let patch_id = restore::restore_and_diff( + &first_item.date_created, + &first_item.target_path, + &folder_path_clone.clone(), + compression_level, + &patch_store, + create_reverse, + &snapshot_mode + + ).expect("There was an issue restoring a reference patch and creating a new patch, did the .time folder go corrupt?"); + + { + let mut patch_ids = patch_ids.lock().unwrap(); + let mut target_paths = target_paths.lock().unwrap(); + let mut ref_patch_ids = ref_patch_ids.lock().unwrap(); + let mut modified = modified.lock().unwrap(); + + patch_ids.push(patch_id); + target_paths.push(first_item.target_path.clone()); + let mut sha256 = Sha256::new(); + sha256.update(first_item.date_created.clone() + &first_item.target_path); + ref_patch_ids.push(format!("{:X}", sha256.finalize())); + modified.push(path.modified); + } + } else { + let mut patch_ids = patch_ids.lock().unwrap(); + let mut target_paths = target_paths.lock().unwrap(); + let mut ref_patch_ids = ref_patch_ids.lock().unwrap(); + let mut modified = modified.lock().unwrap(); + + patch_ids.push("DIR".to_string()); + target_paths.push(first_item.target_path.clone()); + ref_patch_ids.push("DIR".to_string()); + modified.push(path.modified); + } + } + } + } + } else { + panic!("Did not find a valid patch in the patch store, even though there should be one!"); + } + + } else if path.exists { + debug!("No existing patch! I will create a compressed copy of the original file. "); + if Path::new(&path.path).is_file() { + let patch_id = create_diff( + "".to_string(), + path.path.clone(), + path.path.clone(), + folder_path_clone.clone() + "/.time", + "First patch".to_string(), + Vec::new(), + compression_level, + &patch_store, + create_reverse, + ) + .unwrap_or_else(|_| panic!("Was unable to create a diff from a new empty file and {}", + path.path)); + { + let mut patch_ids = patch_ids.lock().unwrap(); + let mut target_paths = target_paths.lock().unwrap(); + let mut ref_patch_ids = ref_patch_ids.lock().unwrap(); + let mut modified = modified.lock().unwrap(); + + patch_ids.push(patch_id); + target_paths.push(path.path.clone()); + ref_patch_ids.push("First patch".to_string()); + modified.push(true); + } + } else { + + let mut patch_ids = patch_ids.lock().unwrap(); + let mut target_paths = target_paths.lock().unwrap(); + let mut ref_patch_ids = ref_patch_ids.lock().unwrap(); + let mut modified = modified.lock().unwrap(); + + patch_ids.push("DIR".to_string()); + target_paths.push(path.path.clone()); + ref_patch_ids.push("DIR".to_string()); + modified.push(true); + + } + } else { + /* + When we detect a removed file, mark it as such without creating a patch. We will create a special case to + detect the removed file and thus remove it when restoring and moving forward/create it when restoring and + moving backwards. + */ + debug!("Detected removed file!"); + + { + let mut patch_ids = patch_ids.lock().unwrap(); + let mut target_paths = target_paths.lock().unwrap(); + let mut ref_patch_ids = ref_patch_ids.lock().unwrap(); + let mut modified = modified.lock().unwrap(); + + patch_ids.push("REMOVED".to_string()); + target_paths.push(path.path.clone()); + ref_patch_ids.push("NONE".to_string()); + modified.push(true); + } + } + } else { + let mut patch_ids = patch_ids.lock().unwrap(); + let mut target_paths = target_paths.lock().unwrap(); + let mut ref_patch_ids = ref_patch_ids.lock().unwrap(); + let mut modified = modified.lock().unwrap(); + + // We will take a hash of the date modified and size of the file to use as an way to identify when the file has been changed. + + if Path::new(&path.path).is_file() { + let file_contents = std::fs::read(&path.path).unwrap_or_else(|_| panic!("Could not open {} to check if it has been modified! Do I have read permission?", + path.path)); + let hash = xxh3_64(&file_contents); + patch_ids.push(hash.to_string()); + } else { + patch_ids.push("UNMODIFIED_DIRECTORY".to_string()); + } + target_paths.push(path.path.clone()); + ref_patch_ids.push("UNMODIFIED".to_string()); + modified.push(false); + // debug!("Skipping {} because it is not modified!", path.path); + } + } // Code for checking existing snapshot goes here + pb.finish(); + })) + } + } + for handle in children { + // Wait for our children to die + handle.join().expect("There was an issue joining all the threads, did a child die?"); + } +} + diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..a5cd890 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,755 @@ +// Made with pain by someone who desperately needed a distraction from the 2024 election. +// Trans rights are human rights. +// TODO: Optional exclude directories +// TODO: Restore directly, don't restore intermediates. +#![windows_subsystem = "windows"] // Prevents console from opening when on Windows. +use chrono::DateTime; +use directories::BaseDirs; +use gumdrop::Options; +use inquire::Select; +use log::{debug, warn}; +use serde::{Deserialize, Serialize}; +use std::{ + collections::HashSet, + env, + fs::{self, File}, + hash::Hash, + io::{Read, Write}, + path::Path, + process, + sync::{Arc, Mutex}, + thread, + time::Duration, +}; +use xxhash_rust::xxh3::xxh3_64; +// use std::time::Instant; // For debugging + +pub mod compression; +pub mod diffs; +pub mod metadata_manager; +pub mod restore; + +#[derive(Deserialize, Serialize, Hash, PartialEq, Eq, Debug, Clone)] + +pub struct DiffEntry { + // TODO: Depreceate in favor of SnapshotEntries? + date_created: String, + target_path: String, + ref_patch: String, +} + +#[derive(PartialEq, Hash, Eq, Debug, Clone)] +pub struct ModifiedList { + path: String, + exists: bool, + modified: bool, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Hash, Eq)] // Derive Serialize for JSON serialization +pub struct MetaFile { + date_modified: u64, + hash: String, + size: u64, + path: String, +} +#[derive(Debug, Deserialize, Serialize)] +pub struct SnapshotEntries { + date_created: String, + patch_ids: Vec, + target_path: Vec, + ref_patch_ids: Vec, + modified: Vec, +} +#[derive(Deserialize, Serialize, Debug, Clone)] +struct Config { + // This will grow with time + folder_path: String, + get_hashes: bool, + thread_count: u32, + brotli_compression_level: u32, + snapshot_mode: String, + its_my_fault_if_i_lose_data: bool, +} + +#[derive(Debug, Options)] +struct MyOptions { + #[options(help = "print help message")] + help: bool, + #[options(help = "be verbose")] + verbose: bool, + #[options(help = "specify a specific config file")] + config: String, + + // The `command` option will delegate option parsing to the command type, + // starting at the first free argument. + #[options(command)] + command: Option, +} + +#[derive(Debug, Options)] +enum Command { + // Command names are generated from variant names. + // By default, a CamelCase name will be converted into a lowercase, + // hyphen-separated name; e.g. `FooBar` becomes `foo-bar`. + // + // Names can be explicitly specified using `#[options(name = "...")]` + #[options(help = "take a snapshot")] + Snapshot(SnapshotOptions), + #[options(help = "restore a snapshot")] + Restore(RestoreOptions), +} + +// Options accepted for the `snapshot` command +#[derive(Debug, Options)] // TODO: Add options +struct SnapshotOptions {} + +// Options accepted for the `restore` command +#[derive(Debug, Options)] // TODO: Add options (list snapshots, restore specific one) +struct RestoreOptions { + #[options(help = "restore nth snapshot starting from most recent")] + restore_index: u32, +} +fn main() { + let mut want_restore = false; + let mut skip_snap = false; + let mut man_conf = false; + let opts = MyOptions::parse_args_default_or_exit(); + + let conf_dir; + + if opts.verbose { + println!("Enabling verbosity by setting env var RUST_LOG to debug"); + + env::set_var("RUST_LOG", "debug"); + } + + env_logger::init(); + + if !opts.config.is_empty() { + println!("Using specific config file {}!", opts.config); + conf_dir = opts.config; + man_conf = true; + } else { + let home_dir = if let Some(user_dirs) = BaseDirs::new() { + if let Some(path_str) = user_dirs.home_dir().to_str() { + path_str.to_string() + } else { + panic!("Home directory is not valid UTF-8! What is wrong with your system??"); + } + } else { + panic!("Unable to retrieve user directories."); + }; + // println!("{home_dir}"); + conf_dir = home_dir + "/.file-time-machine"; + } + + if let Some(Command::Snapshot(ref _snapshot_options)) = opts.command { + println!("Taking snapshot!"); + } else if let Some(Command::Restore(ref _restore_options)) = opts.command { + println!("Restoring!"); + want_restore = true; + } else { + println!("No valid option was provided, taking a snapshot!"); + } + // if args.len() < 2 { + // println!("No arguments provided, attempting to snapshot if config is valid."); + // } else if args[1] == "snapshot" { + // println!("Attempting to snapshot if config is valid."); + // } else if args[1] == "restore" { + // println!("Attempting to restore a snapshot. Fixme!"); + // want_restore = true; + // } else { + // panic!( + // "Invalid command {}\nValid commands are: snapshot, restore.", + // args[1] + // ); + // } + // println!("{conf_dir}"); + if !Path::new(&conf_dir).exists() { + if man_conf { + panic!("Could not locate config file {}!", conf_dir); + } + fs::create_dir(Path::new(&conf_dir.clone())).expect( + "Could not create .file-time-machine in home directory! I should not be run as root.", + ); + println!("Creating .file-time-machine"); + } + let conf_path; + if man_conf { + conf_path = conf_dir; + } else { + conf_path = conf_dir + "/config.json"; + } + let mut config_file = File::open(Path::new(&conf_path)).expect("Could not open config file! Create one at $HOME/.file-time-machine/config.json as specified in documentation."); + + let mut config_file_contents = String::new(); + config_file + .read_to_string(&mut config_file_contents) + .expect( + "The config file contains non UTF-8 characters, what in the world did you put in it??", + ); + let config_holder: Vec = serde_json::from_str(&config_file_contents) + .expect("The config file was not formatted properly and could not be read."); + + let mut folder_path = config_holder[0].folder_path.clone(); // Shut up, I am tired + let hash_enabled = config_holder[0].get_hashes; + let mut thread_count = config_holder[0].thread_count; + let compression_level = config_holder[0].brotli_compression_level; + let snapshot_mode = config_holder[0].snapshot_mode.clone(); + let supress_warn = config_holder[0].its_my_fault_if_i_lose_data; + + if snapshot_mode != "fastest" { + println!("Only fastest snapshot mode is currently implemented!"); + process::exit(1); + } + debug!("Snapshot mode is {}", snapshot_mode); + + if !supress_warn { + warn!("\nWARNING WARNING WARNING\nThis program is NOT production ready! You probably WILL lose data using it!\nSet its_my_fault_if_i_lose_data to true to suppress this warning.\n"); + thread::sleep(Duration::from_secs(3)); + } + + folder_path = folder_path.trim_end_matches('/').to_string(); + let create_reverse; // Disabled only on first run to reduce disk usage + + if thread_count == 0 { + thread_count = num_cpus::get() as u32; + debug!("thread_count automatically set to {}", thread_count); + } + if want_restore { + skip_snap = true; + let snapshot_store_file = folder_path.clone() + "/.time/snapshots.json"; + let snapshot_store: Vec; + let mut change_count = 0; + let mut options = Vec::new(); + + // println!("{}", snapshot_store_file); + if !Path::new(&snapshot_store_file).exists() { + panic!("Did not find a valid snapshot store, have you created any snapshots yet?"); + } + + let mut file = File::open(Path::new(&snapshot_store_file)) + .unwrap_or_else(|_| panic!("Could not open {}!", snapshot_store_file)); + + let mut file_contents = String::new(); + file.read_to_string(&mut file_contents) + .unwrap_or_else(|_| panic!("Unable to read file {}!", snapshot_store_file)); + if !file_contents.is_empty() { + snapshot_store = + serde_json::from_str(&file_contents).expect("Snapshot store is corrupt!"); + } else { + panic!("Snapshot store exists, but is empty! No snapshots available."); + } + /*struct Point { + x: f64, + y: f64, + } + + enum Shape { + Circle(Point, f64), + Rectangle(Point, Point), + } + + fn main() { + let my_shape = Shape::Circle(Point { x: 0.0, y: 0.0 }, 10.0); + + match my_shape { + Shape::Circle(_, value) => println!("value: {}", value), + _ => println!("Something else"), + } + } */ + let selected_item; + if let Some(Command::Restore(ref restore_options)) = opts.command { + // println!("{}", snapshot_store.len()); + if snapshot_store.len() >= restore_options.restore_index.try_into().unwrap() + && 0 < restore_options.restore_index.try_into().unwrap() + { + selected_item = DateTime::parse_from_str( + &snapshot_store[restore_options.restore_index as usize - 1].date_created, + "%Y-%m-%d %H:%M:%S%.9f %z", + ) + .unwrap(); + } else { + if restore_options.restore_index != 0 { + // Needed because afaik Gumdrop sets it to 0 if it wasn't passed. This is not desired behaviour. + println!( + "{} is an invalid snapshot. Entering interactive.", + restore_options.restore_index + ); + } + for snapshot in &snapshot_store { + for _change in snapshot.patch_ids.clone() { + change_count += 1; + } + let date_entry = DateTime::parse_from_str( + &snapshot.date_created, + "%Y-%m-%d %H:%M:%S%.9f %z", + ) + .unwrap(); + let formatted_date = date_entry.format("%Y-%m-%d %H:%M:%S %z").to_string(); + // debug!("formatted_date is {}", formatted_date); + options.push(formatted_date + " files changed: " + &change_count.to_string()); + change_count = 0; + } + let selection = Select::new("Select a snapshot to restore:", options).prompt(); + + let selected_item_pretty: String = match selection { + Ok(choice) => choice, + Err(_) => panic!("There was an issue, please try again."), + }; + // Extract true option from human readable format + let selected_item_str = selected_item_pretty[0..25].to_string(); + debug!("{selected_item_str}"); + selected_item = + DateTime::parse_from_str(&selected_item_str, "%Y-%m-%d %H:%M:%S %z") + .expect("Could not correctly parse date in activeSnapshot, is it corrupt?"); + } + } else { + panic!("Could not parse a valid command."); + } + + /* + We have a entry that we want to restore, if it is in the past: + In fastest mode, restore directly, don't restore intermediates + + if it is in the future: + Restore up until we restore the proper patch. + */ + + let active_snapshot_path = folder_path.clone() + "/.time/activeSnapshot"; + + if !Path::new(&active_snapshot_path).exists() { + debug!("No activeSnapshot found, assuming target has to be in past."); + + // In fastest, restore_snapshot_until will NOT iterate. In this case, the name is misleading. + + restore::restore_snapshot_until( + snapshot_store, + &folder_path, + &selected_item, + true, + &snapshot_mode, + ); + + let mut active_snapshot = File::create(Path::new(&active_snapshot_path)) + .unwrap_or_else(|_| { + panic!("Could not create {active_snapshot_path}, do I have write permission?") + }); + active_snapshot + .write_all(selected_item.to_string().as_bytes()) + .unwrap_or_else(|_| { + panic!("Unable to write to active_snapshot file at {active_snapshot_path}") + }); + } else { + let mut file = File::open(Path::new(&(folder_path.clone() + "/.time/activeSnapshot"))) + .unwrap_or_else(|_| { + panic!( + "Could not read {}!", + folder_path.clone() + "/.time/activeSnapshot" + ) + }); + + let mut file_contents = String::new(); + file.read_to_string(&mut file_contents).unwrap_or_else(|_| { + panic!( + "Could not read from {}! Do I have correct permissions?", + folder_path.clone() + "/.time/activeSnapshot" + ) + }); + + let active_snapshot_date_stupid = // Please fix me this is stupid + DateTime::parse_from_str(&file_contents, "%Y-%m-%d %H:%M:%S%.9f %z") + .unwrap() + .format("%Y-%m-%d %H:%M:%S %z") + .to_string(); + let active_snapshot_date = + DateTime::parse_from_str(&active_snapshot_date_stupid, "%Y-%m-%d %H:%M:%S %z") + .unwrap(); + + if selected_item > active_snapshot_date { + debug!("Snapshot is in future!"); + restore::restore_snapshot_until( + snapshot_store, + &folder_path, + &selected_item, + false, + &snapshot_mode, + ); + fs::remove_file(&active_snapshot_path).unwrap_or_else(|_| { + panic!( + "Could not remove {}, it needs to be writeable!", + active_snapshot_path + ) + }); + let mut active_snapshot = File::create(Path::new(&active_snapshot_path)) + .unwrap_or_else(|_| { + panic!( + "Could not create {active_snapshot_path}, do I have write permission?" + ) + }); + active_snapshot + .write_all(selected_item.to_string().as_bytes()) + .unwrap_or_else(|_| { + panic!("Unable to write to activeSnapshot file at {active_snapshot_path}") + }); + } else if selected_item < active_snapshot_date { + debug!("Snapshot is in past!"); + restore::restore_snapshot_until( + snapshot_store, + &folder_path, + &selected_item, + true, + &snapshot_mode, + ); + fs::remove_file(&active_snapshot_path).unwrap_or_else(|_| { + panic!( + "Could not remove {}, it needs to be writeable!", + active_snapshot_path + ) + }); + let mut active_snapshot = File::create(Path::new(&active_snapshot_path)) + .unwrap_or_else(|_| { + panic!( + "Could not create {active_snapshot_path}, do I have write permission?" + ) + }); + active_snapshot + .write_all(selected_item.to_string().as_bytes()) + .unwrap_or_else(|_| { + panic!("Unable to write to activeSnapshot file at {active_snapshot_path}") + }); + } else { + println!( + "The snapshot you selected is already the active snapshot! Nothing to do." + ); + process::exit(1); + } + } + println!("Finished restoring. You can safely make changes, but they will not be saved unless a new snapshot is created."); + } + + if !skip_snap { + let mut initial_run = false; + debug!("take snapshot"); + if !Path::new(&(folder_path.clone() + "/.time/metadata.json")).exists() { + debug!("{folder_path}/.time/metadata.json"); + if !Path::new(&(folder_path.clone() + "/.time")).exists() { + fs::create_dir(folder_path.clone() + "/.time").unwrap_or_else(|_| { + panic!( + "Unable to create a .time folder at {}!", + folder_path.clone() + "/.time" + ) + }); + } + File::create(Path::new(&(folder_path.clone() + "/.time/tmp_empty"))).unwrap_or_else( + |_| { + panic!( + "Unable to create a temporary empty file at {}!", + folder_path.clone() + "/.time/tmp_empty" + ) + }, + ); + println!("No .time or metadata found, creating."); + + println!("Collecting metadata of: {folder_path}"); + if hash_enabled { + warn!("Hashes are enabled. Collecting metadata may take a while."); + } + + // hash(folder_path).expect("msg"); + let metadata_holder: HashSet = HashSet::new(); + let metadata_holder = + diffs::get_properties(&folder_path, metadata_holder, hash_enabled) + .expect("Issue getting hashes of files in folder {folder_path}"); + metadata_manager::write_metadata_to_file( + &metadata_holder, + &(folder_path.clone() + "/.time/metadata.json"), + ); + + debug!("Running a initial snapshot..."); + initial_run = true; // Use to indicate that despite there being zero changes, we still want to run on all the files + } + println!("Existing .time folder found, looking for changes..."); + debug!("Looking for changes in directory {}", folder_path); + let metafile = folder_path.clone() + "/.time/metadata.json"; + let mut metadata_holder: HashSet = HashSet::new(); + + if !initial_run { + debug!("initial_run is false, reading metadata!"); + metadata_holder = metadata_manager::read_metadata_from_file(&metafile) + .unwrap_or_else(|_| panic!("Couldn't read the metadata file at {metafile}")); + } + let changed_files = diffs::get_diffs(false, &metadata_holder, &folder_path) + .expect("Couldn't check for diffs! No files have been written."); + // for meta in changed_files { + // println!("File Path: {}", meta.path); + // } + File::create(Path::new(&(folder_path.clone() + "/.time/tmp_empty"))).unwrap_or_else(|_| { + panic!( + "Unable to create a temporary empty file at {}!", + folder_path.clone() + "/.time/tmp_empty" + ) + }); + diffs::update_metadata(&mut metadata_holder, &changed_files, hash_enabled) + .expect("Something went wrong when collecting metadata. Do you have read permission?"); + if !initial_run { + debug!("initial_run is false, writing metadata!"); + metadata_manager::write_metadata_to_file(&metadata_holder, &metafile); + } + println!("Finished updating metadata."); + + println!("Creating snapshot with {} threads...", thread_count); + let mut patch_store: Arc>> = Arc::new(Mutex::new(Vec::new())); + let patch_store_file = folder_path.clone() + "/.time/patches.json"; + let snapshot_store_file = folder_path.clone() + "/.time/snapshots.json"; + let patch_ids = Arc::new(Mutex::new(Vec::new())); // These need to be communicated through threads, thus Arc and Mutex. + let target_paths = Arc::new(Mutex::new(Vec::new())); + let ref_patch_ids = Arc::new(Mutex::new(Vec::new())); + let modified = Arc::new(Mutex::new(Vec::new())); + + let mut snapshot_store: Vec = Vec::new(); + + if !Path::new(&snapshot_store_file).exists() { + File::create(Path::new(&snapshot_store_file)).unwrap_or_else(|_| { + panic!( + "Could not create snapshot store at {}!", + snapshot_store_file + ) + }); + } else { + let mut file = File::open(Path::new(&snapshot_store_file)) + .unwrap_or_else(|_| panic!("Could not open {}!", snapshot_store_file)); + + let mut file_contents = String::new(); + file.read_to_string(&mut file_contents) + .unwrap_or_else(|_| panic!("Unable to read file {}!", snapshot_store_file)); + if !file_contents.is_empty() { + snapshot_store = + serde_json::from_str(&file_contents).expect("Snapshot store is corrupt!"); + } + } + + if !Path::new(&patch_store_file).exists() { + println!("Did not find patch store! An original compressed copy of every file will be made to use as reference."); + create_reverse = false; // Since this is the first snapshot, there is no need to create a reverse snapshot and use 2*n storage + // Split here if changed_files is greater than thread count! + let mut changed_files_vec: Vec = Vec::new(); + let mut changed_count: u32 = 0; + + File::create(Path::new(&patch_store_file)).unwrap_or_else(|_| { + panic!( + "Unable to create patch store at {}", + patch_store_file.clone() + "/patches.json" + ) + }); + let mut patch_store_path = + File::open(Path::new(&patch_store_file)).expect("Unable to open patch store file!"); + + let mut patch_store_contents = String::new(); + patch_store_path + .read_to_string(&mut patch_store_contents) + .expect("Unable to open patch store file!"); + patch_store = Arc::new(Mutex::new(Vec::new())); + + for item in &changed_files { + // Allows us to split the Vec to give to threads + changed_count += 1; + changed_files_vec.push(ModifiedList { + path: item.path.clone(), + exists: item.exists, + modified: item.modified, + }); + } + if changed_files_vec.len() > thread_count.try_into().unwrap() { + debug!("Running as initial run!"); + diffs::create_diffs_multithread( + &patch_ids, + &ref_patch_ids, + &target_paths, + &modified, + &folder_path, + changed_files_vec, + changed_count, + thread_count, + compression_level, + &patch_store, + create_reverse, + true, // Inital run + &snapshot_mode, + ); + } else { + // Run regularily here! + debug!("Run regularily"); + for path in changed_files.iter() { + /* + Get relative path of backup directory, go through changed_files, and reference relative path of backup directory. ModifiedList will handle removed files. + A non-existing file can be passed, and it will be handled within get_diffs. + */ + if path.modified { + if Path::new(&path.path).is_file() { + let patch_id = diffs::create_diff( + "".to_string(), // This will never exist, so we can always create a temp file instead. + path.path.clone(), + path.path.clone(), + folder_path.clone() + "/.time", + "First patch".to_string(), + Vec::new(), + compression_level, + &patch_store, + create_reverse, + ) + .unwrap_or_else(|_| { + panic!( + "Was unable to create a diff between a new empty file and {}", + path.path + ) + }); + { + let mut patch_ids = patch_ids.lock().unwrap(); + let mut target_paths = target_paths.lock().unwrap(); + let mut ref_patch_ids = ref_patch_ids.lock().unwrap(); + let mut modified = modified.lock().unwrap(); + + patch_ids.push(patch_id); + target_paths.push(path.path.clone()); + ref_patch_ids.push("First patch".to_string()); + modified.push(path.modified); + } + } else { + let mut patch_ids = patch_ids.lock().unwrap(); + let mut target_paths = target_paths.lock().unwrap(); + let mut ref_patch_ids = ref_patch_ids.lock().unwrap(); + let mut modified = modified.lock().unwrap(); + + patch_ids.push("DIR".to_string()); + target_paths.push(path.path.clone()); + ref_patch_ids.push("DIR".to_string()); + modified.push(true); + } + } else { + let mut patch_ids = patch_ids.lock().unwrap(); + let mut target_paths = target_paths.lock().unwrap(); + let mut ref_patch_ids = ref_patch_ids.lock().unwrap(); + let mut modified = modified.lock().unwrap(); + + if Path::new(&path.path).is_file() { + let file_contents = std::fs::read(&path.path).unwrap_or_else(|_| panic!("Could not open {} to check if it has been modified! Do I have read permission?", + path.path)); + let hash = xxh3_64(&file_contents); + patch_ids.push(hash.to_string()); + } else { + patch_ids.push("UNMODIFIED_DIRECTORY".to_string()); + } + target_paths.push(path.path.clone()); + ref_patch_ids.push("UNMODIFIED".to_string()); + modified.push(false); + // debug!("Skipping {} because it is not modified!", path.path); + } + } + } + } else { + debug!("Found patch store!"); + // let path_temp_hold: HashSet = HashSet::new(); + let mut patch_store_path = File::open(Path::new(&patch_store_file)) + .unwrap_or_else(|_| panic!("Could not open {patch_store_file}!")); + + let mut patch_store_contents = String::new(); + patch_store_path + .read_to_string(&mut patch_store_contents) + .expect("Patch store contains non UTF-8 characters which are unsupported!"); + { + let mut patch_store = patch_store.lock().unwrap(); + + *patch_store = serde_json::from_str(&patch_store_contents) + .expect("Patch store is corrupt. Sorgy :("); + } + /* + Cycle through changed files, and check if a snapshot exists. If it does, restore snapshot to memory, to use as reference file. + Then we create a new patch from the two. + + If no snapshot exists yet, use backup directory as reference file to create snapshot. + */ + // REMEMBER TO PASS patch_store + // println!("{:?}", path_temp_hold); + // println!("fdsfsd"); + // println!("{:?}", changed_files); // populate patch_store and pass it + let mut changed_files_vec: Vec = Vec::new(); + let mut changed_count: u32 = 0; + for item in &changed_files { + // Allows us to split the Vec to give to threads + changed_files_vec.push(ModifiedList { + path: item.path.clone(), + exists: item.exists, + modified: item.modified, + }); + changed_count += 1; + } + debug!("Inital run is false!"); + let real_thread_count = if changed_count >= thread_count { + thread_count + } else { + 1 + }; // Only do true multithreading if necessary + debug!("real_thread_count is {real_thread_count}"); + diffs::create_diffs_multithread( + &patch_ids, + &ref_patch_ids, + &target_paths, + &modified, + &folder_path, + changed_files_vec, + changed_count, + real_thread_count, + compression_level, + &patch_store, + false, + false, + &snapshot_mode, + ); + } + + { + // Create a new scope to unlock mutex + debug!("Writing snapshot to store!"); + let patch_ids = patch_ids.lock().unwrap(); + let target_paths = target_paths.lock().unwrap(); + let ref_patch_ids = ref_patch_ids.lock().unwrap(); + let modified = modified.lock().unwrap(); + if patch_ids.len() > 0 { + // println!("Writing snapshot to store!"); + let current_time: String = chrono::offset::Local::now().to_string(); + snapshot_store.push(SnapshotEntries { + date_created: current_time, + patch_ids: patch_ids.to_vec(), + target_path: target_paths.to_vec(), + ref_patch_ids: ref_patch_ids.to_vec(), + modified: modified.to_vec(), + }); + + let json = serde_json::to_string_pretty(&snapshot_store) + .expect("Unable to serialize metadata!"); + + // Write the JSON string to a file + let mut file = File::create(Path::new(&snapshot_store_file)).unwrap_or_else(|_| { + panic!("Unable to open snapshot file at {}", snapshot_store_file) + }); + file.write_all(json.as_bytes()).unwrap_or_else(|_| { + panic!( + "Unable to write to metadata file at {}", + snapshot_store_file + ) + }); + } + } + + // for meta in metadata_holder { + // println!("File Path: {}", meta.path); + // println!("File Hash: {}", meta.hash); + // println!("File Size: {} bytes", meta.size); + // println!("Last Modified Time: {} seconds since UNIX epoch", meta.date_modified); + // } + // Remove our tmp file we used + fs::remove_file(folder_path.clone() + "/.time/tmp_empty") + .expect("Unable to remove old tmp file"); + } +} diff --git a/src/metadata_manager.rs b/src/metadata_manager.rs new file mode 100644 index 0000000..a844dfa --- /dev/null +++ b/src/metadata_manager.rs @@ -0,0 +1,32 @@ +use std::collections::HashSet; +use std::error::Error; +use std::fs::File; +use std::io::Read; +use std::io::Write; +use std::path::Path; + +use crate::MetaFile; + +pub fn write_metadata_to_file(metadata_holder: &HashSet, filename: &str) { + // Serialize the vector to a JSON string + let json = + serde_json::to_string_pretty(metadata_holder).expect("Unable to serialize metadata!"); + + // Write the JSON string to a file + let mut file = File::create(Path::new(filename)) + .unwrap_or_else(|_| panic!("Unable to create metadata file at {filename}")); + file.write_all(json.as_bytes()) + .unwrap_or_else(|_| panic!("Unable to write to metadata file at {filename}")); +} + +pub fn read_metadata_from_file(filename: &str) -> Result, Box> { + // Load file to string, and use serde to turn it into Vec + let mut file = File::open(Path::new(filename))?; + + let mut file_contents = String::new(); + file.read_to_string(&mut file_contents)?; + + let metadata_holder: HashSet = serde_json::from_str(&file_contents)?; + + Ok(metadata_holder) +} diff --git a/src/restore.rs b/src/restore.rs new file mode 100644 index 0000000..7493eae --- /dev/null +++ b/src/restore.rs @@ -0,0 +1,737 @@ +use bsdiff::patch; // TODO: In fastest mode, we can restore directly the target since the reference is always just the original file. So restore_until needs to implement this. +use chrono::DateTime; // TODO: Snapshots should include a list of every single file at it's current state. This way we can actually ensure we get to the correct state. +use chrono::FixedOffset; +use log::debug; +use sha2::{Digest, Sha256}; +use std::error::Error; +use std::fs::{create_dir_all, exists, remove_dir_all, remove_file, File}; +use std::io::Read; +use std::path::Path; +use std::sync::{Arc, Mutex}; +use walkdir::WalkDir; +use xxhash_rust::xxh3::xxh3_64; + +use crate::compression; +use crate::diffs; +use crate::DiffEntry; +use crate::SnapshotEntries; + +pub fn restore_and_diff( + _date_created: &String, + target_path: &String, + folder_path: &String, + compression_level: u32, + patch_store: &Arc>>, + create_reverse: bool, + snapshot_mode: &String, +) -> Result> { + debug!("Creating a patch using reference patch!"); + let mut target_date = "".to_string(); + let mut valid_target_path = "".to_string(); + if snapshot_mode == "fastest" { + debug!("Trying to find initial patch to use as for fastest mode"); + { + let patch_store = patch_store.lock().unwrap(); + + for patch in patch_store.iter() { + if patch.ref_patch == "First patch" && patch.target_path == *target_path { + debug!("Found good patch"); + target_date = patch.date_created.clone(); + valid_target_path = patch.target_path.clone(); + } + } + if target_date.is_empty() || valid_target_path.is_empty() { + panic!("Could not find a valid initial patch for {}!", target_path); + } + } + } else { + panic!("Invalid snapshot mode {}!", snapshot_mode); + } + let mut sha256 = Sha256::new(); + sha256.update(target_date + &valid_target_path); // Generate an ID to identify the patch. This can be derived from the data stored in DiffEntry, which can then be used to identify where the patch file is. + let patch_id: String = format!("{:X}", sha256.finalize()); // We now have the ID of the patch, so we can restore it. + let patch_file; + let target_file; + + let mut patch_file_compressed = std::fs::read( + folder_path.clone() + "/.time/" + &patch_id + "-reverse", + ) + .unwrap_or_else(|_| { + panic!( + "Could not open patch file! Try removing {} from the patch store.", + &target_path + ) + }); + if patch_file_compressed == [58, 51] { + debug!("Detected fake patch!"); + // Not a valid patch, so we need to recover original file to use as reference. + patch_file_compressed = std::fs::read(folder_path.clone() + "/.time/" + &patch_id) + .unwrap_or_else(|_| { + panic!( + "Could not open patch file! Try removing {} from the patch store.", + &target_path + ) + }); + patch_file = compression::decompress_data(patch_file_compressed).unwrap_or_else(|_| { + panic!( + "Could not decompress patch file {}! Is it corrupt?", + target_path + ) + }); + target_file = Vec::new(); + } else { + patch_file = compression::decompress_data(patch_file_compressed).unwrap_or_else(|_| { + panic!( + "Could not decompress patch file {}! Is it corrupt?", + target_path + ) + }); + target_file = std::fs::read(target_path).unwrap_or_else(|_| { + panic!( + "Could not open {} to restore reference patch! Metadata needs updating!", + &target_path + ) + }); + } + let mut ref_file = Vec::new(); + + patch(&target_file, &mut patch_file.as_slice(), &mut ref_file).unwrap_or_else(|_| { + panic!( + "There was an error restoring a reference patch to memory! Target file was {}", + &target_path + ) + }); + + let patch_id = diffs::create_diff( + "".to_string(), + target_path.clone(), + target_path.clone(), + folder_path.clone() + "/.time", + patch_id, + ref_file, + compression_level, + patch_store, + create_reverse, + ) + .expect("There was an issue while creating a diff!"); + Ok(patch_id) +} + +pub fn restore_snapshot( + entry: &SnapshotEntries, + time_dir: String, + past: bool, + snapshot_mode: &String, +) { + let mut patch_path = "".to_string(); + let mut first_cycle = true; + println!("Restoring snapshot {}!", entry.date_created); + let mut dirs_to_remove = Vec::new(); // Remove dirs at the end since we need to cleanup the insides first + // println!("{}", entry.patch_ids.len()); + // println!("{}", entry.ref_patch_ids.len()); + for (index_counter, id) in entry.patch_ids.clone().iter().enumerate() { + // println!("{:?}", &entry.target_path); + // TODO: Remove file if it is supposed to be removed + // TODO: Check if is first patch, if so, don't attempt to restore + debug!("Restoring patch {}", id); + debug!("Restoring past version: {}", past); + let mut skip_file = false; + if id == "REMOVED" { + skip_file = true; + debug!("Detected removed file!"); + if past { + // Going to past where file used to exist, so we need to restore upwards to recreate it. + // Open patch store so we can restore + + let patch_store_file = time_dir.clone() + "/patches.json"; + + // let path_temp_hold: HashSet = HashSet::new(); + let mut patch_store_path = File::open(Path::new(&patch_store_file)) + .unwrap_or_else(|_| panic!("Could not open {patch_store_file}!")); + + let mut patch_store_contents = String::new(); + + patch_store_path + .read_to_string(&mut patch_store_contents) + .expect("Patch store contains non UTF-8 characters which are unsupported!"); + let patch_store: Vec = serde_json::from_str(&patch_store_contents) + .expect("Patch store is corrupt. Sorgy :("); + + for patch_entry in patch_store.iter() { + let mut sha256 = Sha256::new(); + // As long as patch store is properly ordered, we can go through and restore all matching paths. + if patch_entry.target_path == entry.target_path[index_counter] { + if &patch_entry.ref_patch == "First patch" { + let mut new_file: Vec = Vec::new(); + if first_cycle { + check_and_create(&patch_entry.target_path); + first_cycle = false; + } //else { + // panic!("Detected patches.json is out of order! Cannot safely continue."); + // } + check_and_create(&patch_entry.target_path); + let target_file = std::fs::read(&patch_entry.target_path).unwrap(); + sha256.update( + patch_entry.date_created.clone() + &patch_entry.target_path, + ); + let patch_id = format!("{:X}", sha256.finalize()); + let patch_path = time_dir.clone() + "/" + &patch_id; + let patch_file_compressed = + std::fs::read(&patch_path).unwrap_or_else(|_| panic!("Could not open {} to restore snapshot! Do I have read permission?", + patch_path)); + let patch_file = compression::decompress_data(patch_file_compressed) + .unwrap_or_else(|_| { + panic!( + "Could not decompress data in file {}! Is it corrupt?", + patch_path + ) + }); + patch(&target_file, &mut patch_file.as_slice(), &mut new_file) + .unwrap_or_else(|_| { + panic!("Unable to restore patch {}! Is it corrupt?", patch_id) + }); + std::fs::write(&patch_entry.target_path, &new_file).unwrap_or_else( + |_| { + panic!( + "Unable to open file for writing: {}", + &patch_entry.target_path + ) + }, + ); + } else if &patch_entry.ref_patch != "NONE" { + let mut new_file: Vec = Vec::new(); + let target_file = + std::fs::read(&patch_entry.target_path).unwrap_or_else(|_| panic!("Could not open {} to restore snapshot. Metadata needs updating!", + &patch_entry.target_path)); + sha256.update( + patch_entry.date_created.clone() + &patch_entry.target_path, + ); + let patch_id = format!("{:X}", sha256.finalize()); + let patch_path = time_dir.clone() + "/" + &patch_id; + let patch_file_compressed = + std::fs::read(&patch_path).unwrap_or_else(|_| panic!("Could not open {} to restore snapshot! Do I have read permission?", + patch_path)); + let patch_file = compression::decompress_data(patch_file_compressed) + .unwrap_or_else(|_| { + panic!( + "Could not decompress data in file {}! Is it corrupt?", + patch_path + ) + }); + patch(&target_file, &mut patch_file.as_slice(), &mut new_file) + .unwrap_or_else(|_| { + panic!("Unable to restore patch {}! Is it corrupt?", patch_id) + }); + std::fs::write(&patch_entry.target_path, &new_file).unwrap_or_else( + |_| { + panic!( + "Unable to open file for writing: {}", + &patch_entry.target_path + ) + }, + ); + } else { + debug!("Skipping file since ref_id is NONE"); + } + } + } + } else { + // In future, so we simply remove the file. + let target_file = &entry.target_path[index_counter]; + let path = Path::new(target_file); + if path.is_dir() { + debug!("Adding directory to queue to be removed: {}", target_file); + dirs_to_remove.push(target_file); + } else { + let true_path = Path::new(target_file); + if true_path.exists() { + debug!("Removing file {}", target_file); + remove_file(Path::new(&target_file)) + .unwrap_or_else(|_| panic!("Could not remove file {}!", &target_file)); + } + } + } + } else if id == "DIR" || id == "UNMODIFIED_DIRECTORY" { + skip_file = true; + debug!( + "Creating dir if not exists: {}", + &entry.target_path[index_counter] + ); + + let dir = Path::new(&entry.target_path[index_counter]); + + if !dir.exists() { + create_dir_all(dir) + .unwrap_or_else(|_| panic!("Could not create directory {:?}!", dir)); + } + } else if id.len() < 64 { + // Assume this is a unmodified file hash. As such, check if the file is modified, and if it is, restore the original file. + let file_contents = std::fs::read(&entry.target_path[index_counter]).unwrap_or_else(|_| panic!("Could not open {} to check if it has been modified! Do I have read permission?", + entry.target_path[index_counter])); + let hash = xxh3_64(&file_contents); + + if &hash.to_string() == id { + debug!( + "{} is unmodified, leaving it alone!", + entry.target_path[index_counter] + ); + skip_file = true; + } else { + debug!( + "{} is modified, restoring original", + entry.target_path[index_counter] + ); + } + } + if !skip_file { + debug!("No special conditions met, restoring file."); + // Not a removed file + if !past && entry.modified[index_counter] { + // Target is in future. + // In fastest mode, the reference is ALWAYS the first patch (which is just a compressed copy of the file.) + // So we load this and then apply our patch to it. Thus we are fast, but also hog disk usage. + if snapshot_mode == "fastest" { + debug!("Going towards future in fastest mode"); + let mut patch_store_file = + File::open(Path::new(&(time_dir.clone() + "/patches.json"))) + .unwrap_or_else(|_| { + panic!( + "Unable to open patch store at {}!", + time_dir.clone() + "/patches.json" + ) + }); + + let mut patch_store_contents = String::new(); + patch_store_file + .read_to_string(&mut patch_store_contents) + .expect("Unable to open patch store file!"); + // patch_path = time_dir.clone() + "/" + &id + "-reverse"; + let patch_store: Vec = serde_json::from_str(&patch_store_contents) + .expect("Patch store is corrupt. Sorgy :("); + // let mut iter = patch_store.iter().peekable(); // Wrong mode dipshit, you can use this in the future for other modes. + // let mut target_id: String = "".to_string(); + // while let Some(patch) = iter.next() { + // let mut sha256 = Sha256::new(); + // sha256.update(patch.date_created.clone() + &patch.target_path); + // let check_id = format!("{:X}", sha256.finalize()); // We now have the correct target id + + // if check_id == id { + // debug!( + // "Found current patch inside store, getting patch directly ahead..." + // ); + // let mut sha256 = Sha256::new(); + // sha256.reset(); + + // if let Some(next_patch) = iter.peek() { + // sha256.update( + // next_patch.date_created.clone() + &next_patch.target_path, + // ); + // target_id = format!("{:X}", sha256.finalize()); + // debug!("Actually applying patch {}", target_id); + // } else { + // debug!("UNFINISHED UNFINISHED UNFINISHED: Need to handle case where there is no next patch!"); + // } + // } + // } + let mut target_date = "".to_string(); + let mut valid_target_path = "".to_string(); + + for patch in patch_store.iter() { + if patch.ref_patch == "First patch" + && patch.target_path == entry.target_path[index_counter] + { + debug!("Found correct initial patch"); + target_date = patch.date_created.clone(); + valid_target_path = patch.target_path.clone(); + } + } + + let true_path = Path::new(&entry.target_path[index_counter]); + if true_path.is_dir() { + debug!("Got First patch on a directory, creating {:?}", true_path); + create_dir_all(true_path).unwrap_or_else(|_| { + panic!("Unable to create directory {:?}!", true_path) + }); + } else { + if target_date.is_empty() || valid_target_path.is_empty() { + panic!( + "Could not find a valid initial patch in the patch store for {}", + entry.target_path[index_counter] + ) + } + + let mut sha256 = Sha256::new(); + + sha256.update(target_date + &valid_target_path); + let patch_id = format!("{:X}", sha256.finalize()); + + debug!("Applying patch found from patch store"); + + debug!("Checking if file exists"); + if !exists(&entry.target_path[index_counter]).unwrap_or_else(|_| { + panic!( + "Could not check if file exists at {}", + entry.target_path[index_counter] + ) + }) { + debug!( + "File doesn't exist yet, creating {}", + entry.target_path[index_counter] + ); + check_and_create(&entry.target_path[index_counter]); + } + + let mut final_file = Vec::new(); + let mut ref_file: Vec = Vec::new(); + let patch_path = time_dir.clone() + "/" + &patch_id; // Note that this will never be the first patch, so we don't need to handle that case. + let patch_final = time_dir.clone() + "/" + &id; + let target_path = &entry.target_path[index_counter]; + // let target_file = std::fs::read(&target_path).expect(&format!( + // "Could not open {} to restore snapshot. Metadata needs updating!", + // &target_path + // )); + let patch_file_compressed = std::fs::read(&patch_path).unwrap_or_else(|_| panic!("Could not open {} to restore snapshot! Do I have read permission?", + patch_path)); + let patch_file = compression::decompress_data(patch_file_compressed) + .unwrap_or_else(|_| { + panic!( + "Could not decompress data in file {}! Is it corrupt?", + patch_path + ) + }); + let patch_final_file_compressed = + std::fs::read(&patch_final).unwrap_or_else(|_| panic!("Could not open {} to restore snapshot! Do I have read permission?", + patch_path)); + let patch_file_final = + compression::decompress_data(patch_final_file_compressed) + .unwrap_or_else(|_| { + panic!( + "Could not decompress data in file {}! Is it corrupt?", + patch_path + ) + }); + // Generate initial version of file to be used as the reference + patch(&final_file, &mut patch_file.as_slice(), &mut ref_file) + .unwrap_or_else(|_| { + panic!("There was an issue applying patch {}!", patch_path) + }); + + patch(&ref_file, &mut patch_file_final.as_slice(), &mut final_file) + .unwrap_or_else(|_| { + panic!("There was an issue applying patch {}!", patch_path) + }); + debug!("Writing final target file"); + std::fs::write(target_path, &final_file) + .unwrap_or_else(|_| panic!("Unable to write to {}!", target_path)); + // index_counter += 1; + } + } else { + panic!( + "Only fastest snapshot mode supported, not {}!", + snapshot_mode + ); + } + } else if entry.modified[index_counter] { + // Target is in past. Currently works for "fastest" mode. Others untested + let mut ref_patch_compressed: Vec = [58, 51].to_vec(); // The default state will fail the validity check, so we don't need a brand new variable to track if this is "First patch" or not. + let mut ref_path = "".to_string(); + if &entry.ref_patch_ids[index_counter] != "First patch" { + debug!("Restoring into the past!"); + patch_path = time_dir.clone() + "/" + &id; + + ref_path = + time_dir.clone() + "/" + &entry.ref_patch_ids[index_counter] + "-reverse"; + debug!("Found reference patch {}", ref_path); + ref_patch_compressed = std::fs::read(&ref_path).unwrap_or_else(|_| { + panic!("Could not read reference patch at {}!", ref_path) + }); + } + + if ref_patch_compressed == [58, 51] { + // Either this is first patch, or we tried to read a false patch. Either way, we will just restore the initial compressed patch. + + if &entry.ref_patch_ids[index_counter] == "First patch" { + // First patch, we need to get the proper id to restore. Unfortunately, this means we need to load and process patches.json. + debug!("Got a first patch, loading patches.json..."); + + let patch_store_file = time_dir.clone() + "/patches.json"; + + // let path_temp_hold: HashSet = HashSet::new(); + let mut patch_store_path = File::open(Path::new(&patch_store_file)) + .unwrap_or_else(|_| panic!("Could not open {patch_store_file}!")); + + let mut patch_store_contents = String::new(); + + patch_store_path + .read_to_string(&mut patch_store_contents) + .expect( + "Patch store contains non UTF-8 characters which are unsupported!", + ); + let patch_store: Vec = + serde_json::from_str(&patch_store_contents) + .expect("Patch store is corrupt. Sorgy :("); + let mut target_id = "".to_string(); + for item in patch_store.iter() { + if item.target_path == entry.target_path[index_counter] { + let mut sha256 = Sha256::new(); + sha256.update(item.date_created.clone() + &item.target_path); + target_id = format!("{:X}", sha256.finalize()); // We now have the correct target id + break; + } + } + if target_id.is_empty() { + panic!( + "Could not find a target_id that should exist for file {:?}", + &entry.target_path + ); + } + ref_path = time_dir.clone() + "/" + &target_id; + debug!("Got ref_path as {}", ref_path); + } else { + // Read a false patch, so remove the reverse and restore it + ref_path = time_dir.clone() + "/" + &entry.ref_patch_ids[index_counter]; + } + ref_patch_compressed = std::fs::read(&ref_path).unwrap_or_else(|_| { + panic!("Could not read reference patch at {}!", ref_path) + }); + let mut final_target: Vec = Vec::new(); + let empty: Vec = Vec::new(); + let ref_patch_full_file = compression::decompress_data(ref_patch_compressed) + .unwrap_or_else(|_| { + panic!("There was an error decompressing {}!", ref_path) + }); + patch( + &empty, + &mut ref_patch_full_file.as_slice(), + &mut final_target, + ) + .unwrap_or_else(|_| { + panic!( + "There was an error applying patch {} to an empty vec!", + ref_path + ) + }); + let target_path = &entry.target_path[index_counter]; + check_and_create(target_path); + debug!("Restoring original file {}", target_path); + std::fs::write(target_path, &final_target) + .unwrap_or_else(|_| panic!("Unable to write to {}!", target_path)); + } else { + // This is a valid patch/regular case + // TODO: Detect if we are going to the original version and skip the middle steps. + let mut ref_file: Vec = Vec::new(); + let mut final_target: Vec = Vec::new(); + let target_file; + { + let ref_patch = compression::decompress_data(ref_patch_compressed) + .unwrap_or_else(|_| { + panic!("There was an issue decompressing {}!", ref_path) + }); + + let target_path = &entry.target_path[index_counter]; + + target_file = std::fs::read(target_path).unwrap_or_else(|_| { + panic!( + "Could not open {} to restore snapshot. Metadata needs updating!", + &target_path + ) + }); + + patch(&target_file, &mut ref_patch.as_slice(), &mut ref_file) + .unwrap_or_else(|_| { + panic!("There was an issue applying reference patch {}!", ref_path) + }); // TODO: This is impossible, right? We cannot apply this patch against a new unkown file. We need to build upwards. + } + let patch_file_compressed = std::fs::read(&patch_path).unwrap_or_else(|_| { + panic!( + "Could not open {} to restore snapshot! Do I have read permission?", + patch_path + ) + }); + let patch_file = compression::decompress_data(patch_file_compressed) + .unwrap_or_else(|_| { + panic!( + "Could not decompress data in file {}! Is it corrupt?", + patch_path + ) + }); + patch(&ref_file, &mut patch_file.as_slice(), &mut final_target).unwrap_or_else( + |_| panic!("There was an issue applying patch {}!", patch_path), + ); + let target_path = &entry.target_path[index_counter]; + + debug!("Restoring file {}", target_path); + std::fs::write(target_path, &final_target) + .unwrap_or_else(|_| panic!("Unable to write to {}!", target_path)); + } + } else { + debug!("{:?} is not modified, leaving it alone!", entry.target_path); + } + } + } + + // We need to do a walkthrough of the directory and remove any files that are not part of the snapshot. This way files added in the future won't be there when we restore a past snapshot. + let folder_path = Path::new(&time_dir).parent(); + match folder_path { + // Ok what the fuck is even going on :< clearly I need to read the rust book better + Some(x) => { + for path in WalkDir::new(x) { + match path { + Ok(v) => { + let v_parent = v.path().parent(); + match v_parent { + Some(vp) => { + if !entry.target_path.contains(&v.path().display().to_string()) + && v.path() != x + && v.path() != Path::new(&time_dir) + && vp != Path::new(&time_dir) + { + // println!("{:?}", v.path()); + if v.path().is_file() { + debug!("Removing {}", v.path().display()); + remove_file(v.path()).unwrap_or_else(|_| { + panic!("Unable to remove {}!", v.path().display()) + }) + // } else if v + // .path() + // .read_dir() + // .unwrap_or_else(|_| { + // panic!("Could not peek into directory {:?}", v.path()) + // }) + // .next() + // .is_none() + } else { + // println!("{}", v.path().display()); + // Check if directory to be removed is referenced in list at all, and if the reference is NOT to remove it, and if so, don't remove it. + let mut id_count = 0; + for path in entry.target_path.iter() { + // This ensures we don't accidentally remove some empty directory that we want to keep. + if !path.contains(&v.path().display().to_string()) + && v.path().exists() + && entry.patch_ids[id_count] != "REMOVED" + { + debug!("Removing {}", v.path().display()); + remove_dir_all(v.path()).unwrap_or_else(|_| { + panic!( + "Unable to remove {}!", + v.path().display() + ) + }); + } + id_count += 1; + } + } + } + } + None => panic!("Error parsing {:?}", v_parent), + } + } + Err(e) => println!("Error parsing {}", e), + } + } + } + None => panic!( + "There was an issue trying to get the parent directory of {:?}!", + folder_path + ), + } + + for path in dirs_to_remove.iter() { + let true_path = Path::new(path); + if true_path.exists() { + remove_dir_all(path).unwrap_or_else(|_| panic!("Could not remove dir {}!", path)); + } + // We can do all, since we know at this point the only remaining directories will just have other empty directories in it (assuming nothing went wrong when collecting metadata.) + } +} + +pub fn restore_snapshot_until( + // In fastest mode, reference always being the initial file means we can restore directly when going forward or backward, making restoring much much faster. + snapshot_store: Vec, + folder_path: &String, + selected_item: &DateTime, + in_past: bool, + snapshot_mode: &String, +) { + if snapshot_mode == "fastest" { + // If we are in fastest mode, we don't care about restoring anything in between since the reference is alwyas the initial version of the file. + debug!("restoring_until in fastest mode. Skipping intermediates."); + debug!("Target date is {}", selected_item); + for snapshot in snapshot_store.iter() { + let date_entry = + DateTime::parse_from_str(&snapshot.date_created, "%Y-%m-%d %H:%M:%S%.9f %z") + .unwrap(); + let formatted_date = date_entry.format("%Y-%m-%d %H:%M:%S%.9f %z").to_string(); + debug!("formatted_date is {}", formatted_date); + if formatted_date == *selected_item.format("%Y-%m-%d %H:%M:%S%.9f %z").to_string() { + debug!("Found correct snapshot to restore in fastest mode."); + restore_snapshot( + snapshot, + folder_path.clone() + "/.time", + in_past, + snapshot_mode, + ); + } + } + } else if in_past { + for snapshot in snapshot_store.iter().rev() { + let date_entry = + DateTime::parse_from_str(&snapshot.date_created, "%Y-%m-%d %H:%M:%S%.9f %z") + .unwrap(); + + if date_entry == *selected_item { + break; + } + restore_snapshot( + snapshot, + folder_path.clone() + "/.time", + in_past, + snapshot_mode, + ); + // Past is true since we want to restore the reverse patch + } + } else { + debug!("Not reversing!"); + // println!("{:?}", snapshot_store); + for snapshot in snapshot_store.iter() { + let date_entry = + DateTime::parse_from_str(&snapshot.date_created, "%Y-%m-%d %H:%M:%S%.9f %z") + .unwrap(); + + if date_entry == *selected_item { + break; + } + restore_snapshot( + snapshot, + folder_path.clone() + "/.time", + in_past, + snapshot_mode, + ); + // Past is true since we want to restore the reverse patch + } + } +} + +fn check_and_create(target_path: &String) { + if !exists(target_path) + .unwrap_or_else(|_| panic!("Could not check if file exists at {}", target_path)) + { + let true_path = Path::new(target_path).parent(); // Turns target_path into a Path. I know I should do this everywhere. + match true_path { + Some(x) => { + if !exists(x).unwrap() { + debug!("Parent directory doesn't exist, creating {:?}", true_path); + create_dir_all(x) + .unwrap_or_else(|_| panic!("Could not create parent directory at {:?}", x)); + } + } + None => panic!( + "There was an issue trying to get the parent directory of {}!", + target_path + ), + } + debug!("File doesn't exist yet, creating {}", target_path); + File::create(Path::new(&target_path)) + .unwrap_or_else(|_| panic!("Could not create file at {}!", target_path)); + } +} diff --git a/test.sh b/test.sh new file mode 100755 index 0000000..779b62b --- /dev/null +++ b/test.sh @@ -0,0 +1,48 @@ +#!/bin/bash +#set -e + +rm -r demo +cp -r demo.bak demo +find demo -not -path "./demo/.time/*" -type f -exec md5sum {} \; > checklist.chk +cargo run --release -- -c demo.bak/config.json + +rm -r demo/* +cp -r src/* demo/ +find demo -not -path "demo/.time/*" -type f -exec md5sum {} \; > checklist-two.chk +cargo run --release -- -c demo.bak/config.json + +cp -r gui/* demo/ # Do a test that includes pre-existing files. +find demo -not -path "demo/.time/*" -type f -exec md5sum {} \; > checklist-three.chk +cargo run --release -- -c demo.bak/config.json + +echo "Checking demo..." +cargo run --release -- -c demo.bak/config.json restore --restore-index 1 +if ! md5sum -c --quiet checklist.chk +then + echo "demo failed check!" + exit 0 +fi + +echo "Checking src..." +cargo run --release -- -c demo.bak/config.json restore --restore-index 2 +if ! md5sum -c --quiet checklist-two.chk +then + echo "src failed check!" + exit 0 +fi + +echo "Checking src+gui..." +cargo run --release -- -c demo.bak/config.json restore --restore-index 3 +if ! md5sum -c --quiet checklist-three.chk +then + echo "src+gui failed check!" + exit 0 +fi + + +printf "\nAll tests passed!" + +rm checklist.chk +rm checklist-two.chk +rm checklist-three.chk +cp -r demo.bak demo