diff options
author | Stefan Kreutz <mail@skreutz.com> | 2024-03-24 15:04:09 +0100 |
---|---|---|
committer | Stefan Kreutz <mail@skreutz.com> | 2024-03-24 15:04:09 +0100 |
commit | c1fa48e9bd617d70e823efef5d6dcea41b1d2087 (patch) | |
tree | 421e69c512ac54bf65495ef23fd7d9ec5a5e67d5 | |
download | brck-c1fa48e9bd617d70e823efef5d6dcea41b1d2087.tar |
Add initial implementationbrck-0.1.0
-rw-r--r-- | .cargo/audit.toml | 6 | ||||
-rw-r--r-- | .gitignore | 4 | ||||
-rw-r--r-- | CHANGELOG.md | 17 | ||||
-rw-r--r-- | Cargo.lock | 1101 | ||||
-rw-r--r-- | Cargo.toml | 38 | ||||
-rw-r--r-- | IDEAS | 24 | ||||
-rw-r--r-- | LICENSE-APACHE-2.0 | 12 | ||||
-rw-r--r-- | LICENSE-MIT | 19 | ||||
-rw-r--r-- | README.md | 113 | ||||
-rwxr-xr-x | script/check | 15 | ||||
-rw-r--r-- | src/core.rs | 306 | ||||
-rw-r--r-- | src/fs.rs | 144 | ||||
-rw-r--r-- | src/main.rs | 343 | ||||
-rw-r--r-- | tests/cli.rs | 299 |
14 files changed, 2441 insertions, 0 deletions
diff --git a/.cargo/audit.toml b/.cargo/audit.toml new file mode 100644 index 0000000..d0abe57 --- /dev/null +++ b/.cargo/audit.toml @@ -0,0 +1,6 @@ +# https://github.com/rustsec/rustsec/blob/main/cargo-audit/audit.toml.example + +[advisories] +ignore = [ + # "RUSTSEC-2020-0071", +] diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4b2a36a --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/target +.brck +.brck.tmp +.brck.bak diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..9ef0612 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,17 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep A Changelog][] and this project adheres to +[Semantic Versioning][]. + +[Keep A Changelog]: https://keepachangelog.com/en/1.0.0/ +[Semantic Versioning]: https://semver.org/spec/v2.0.0.html + +## Unreleased + +## [0.1.0] - 2024-03-24 + +### Added + +- Add initial implementation diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..d2ab2c9 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1101 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "adler" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[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.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d96bd03f33fe50a863e394ee9718a706f988b9079b20c3784fb726e7678b62fb" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8901269c6307e8d93993578286ac0edf7f195079ffff5ebdeea6a59ffb7e36bc" + +[[package]] +name = "anstyle-parse" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c75ac65da39e5fe5ab759307499ddad880d724eed2f6ce5b5e8a26f4f387928c" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e28923312444cdd728e4738b3f9c9cac739500909bb3d3c94b43551b16517648" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cd54b81ec8d6180e24654d0b371ad22fc3dd083b6ff8ba325b72e00c87660a7" +dependencies = [ + "anstyle", + "windows-sys 0.52.0", +] + +[[package]] +name = "anyhow" +version = "1.0.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0952808a6c2afd1aa8947271f3a60f1a6763c7b912d210184c5149b5cf147247" + +[[package]] +name = "assert_cmd" +version = "2.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed72493ac66d5804837f480ab3766c72bdfab91a65e565fc54fa9e42db0073a8" +dependencies = [ + "anstyle", + "bstr", + "doc-comment", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + +[[package]] +name = "assert_fs" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd762e110c8ed629b11b6cde59458cc1c71de78ebbcc30099fc8e0403a2a2ec" +dependencies = [ + "anstyle", + "doc-comment", + "globwalk", + "predicates", + "predicates-core", + "predicates-tree", + "tempfile", +] + +[[package]] +name = "autocfg" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf4b9d6a944f767f8e5e0db018570623c85f3d925ac718db4e06d0187adb21c1" + +[[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 = "brck" +version = "0.1.0" +dependencies = [ + "anyhow", + "assert_cmd", + "assert_fs", + "chrono", + "clap", + "ctrlc", + "either", + "filetime", + "flate2", + "hex", + "itertools", + "parseq", + "pledge", + "predicates", + "serde", + "serde_json", + "sha2", + "testresult", + "unveil", + "walkdir", +] + +[[package]] +name = "bstr" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05efc5cfd9110c8416e471df0e96702d58690178e206e61b7173706673c93706" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + +[[package]] +name = "bumpalo" +version = "3.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ff69b9dd49fd426c69a0db9fc04dd934cdb6645ff000864d98f7e2af8830eaa" + +[[package]] +name = "cc" +version = "1.0.90" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cd6604a82acf3039f1144f54b8eb34e91ffba622051189e71b781822d5ee1f5" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + +[[package]] +name = "chrono" +version = "0.4.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf5903dcbc0a39312feb77df2ff4c76387d591b9fc7b04a238dcf8bb62639a" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-targets 0.52.4", +] + +[[package]] +name = "clap" +version = "4.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "949626d00e063efc93b6dca932419ceb5432f99769911c0b995f7e884c778813" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae129e2e766ae0ec03484e609954119f123cc1fe650337e155d03b022f24f7b4" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", + "terminal_size", +] + +[[package]] +name = "clap_derive" +version = "4.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90239a040c80f5e14809ca132ddc4176ab33d5e17e49691793296e3fcb34d72f" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98cc8fbded0c607b7ba9dd60cd98df59af97e84d24e49c8557331cfc26d301ce" + +[[package]] +name = "colorchoice" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" + +[[package]] +name = "core-foundation-sys" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" + +[[package]] +name = "cpufeatures" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53fe5e26ff1b7aef8bca9c6080520cfb8d9333c7568e1829cef191a9723e5504" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3855a8a784b474f333699ef2bbca9db2c4a1f6d9088a90a2d25b1eb53111eaa" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab3db02a9c5b5121e1e42fbdb1aeb65f5e02624cc58c43f2884c6ccac0b82f95" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613f8cc01fe9cf1a3eb3d7f488fd2fa8388403e97039e2f73692932e291a770d" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "248e3bacc7dc6baa3b21e405ee045c3047101a49145e7e9eca583ab4c2ca5345" + +[[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 = "ctrlc" +version = "3.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "672465ae37dc1bc6380a6547a8883d5dd397b0f1faaad4f265726cc7042a5345" +dependencies = [ + "nix", + "windows-sys 0.52.0", +] + +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + +[[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 = "doc-comment" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10" + +[[package]] +name = "either" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11157ac094ffbdde99aa67b23417ebdd801842852b500e395a45a9c0aac03e4a" + +[[package]] +name = "errno" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a258e46cdc063eb8519c00b9fc845fc47bcfca4130e2f08e88665ceda8474245" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "fastrand" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "658bd65b1cf4c852a3cc96f18a8ce7b5640f6b703f905c7d74532294c2a63984" + +[[package]] +name = "filetime" +version = "0.2.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ee447700ac8aa0b2f2bd7bc4462ad686ba06baa6727ac149a2d6277f0d240fd" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "windows-sys 0.52.0", +] + +[[package]] +name = "flate2" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46303f565772937ffe1d394a4fac6f411c6013172fadde9dcdb1e147a086940e" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" +dependencies = [ + "num-traits", +] + +[[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 = "globset" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57da3b9b5b85bd66f31093f8c408b90a74431672542466497dcbdfdc02034be1" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "globwalk" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757" +dependencies = [ + "bitflags 2.5.0", + "ignore", + "walkdir", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +dependencies = [ + "serde", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7ffbb5a1b541ea2561f8c41c087286cc091e21e556a4f09a8f6cbf17b69b141" +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 = "ignore" +version = "0.4.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b46810df39e66e925525d6e38ce1e7f6e1d208f72dc39757880fcb66e2c58af1" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1a46d1a171d865aa5f83f92695765caa047a9b4cbae2cbf37dbd613a793fd4c" + +[[package]] +name = "js-sys" +version = "0.3.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29c15563dc2726973df627357ce0c9ddddbea194836909d655df6a75d2cf296d" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.153" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd" + +[[package]] +name = "linux-raw-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01cda141df6706de531b6c46c3a33ecca755538219bd484262fa09410c13539c" + +[[package]] +name = "log" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c" + +[[package]] +name = "memchr" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "523dc4f511e55ab87b694dc30d0f820d60906ef06413f93d4d7a1385599cc149" + +[[package]] +name = "miniz_oxide" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d811f3e15f28568be3407c8e7fdb6514c1cda3cb30683f15b6a1a1dc4ea14a7" +dependencies = [ + "adler", +] + +[[package]] +name = "nix" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" +dependencies = [ + "bitflags 2.5.0", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "normalize-line-endings" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + +[[package]] +name = "num-traits" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0df0e5185db44f69b44f26786fe401b6c293d1907744beaa7fa62b2e5a517a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" + +[[package]] +name = "parseq" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96e0928f33f10595e789baf7a10c0fdd288694f9f9866748cf1fb9cb9f84b084" +dependencies = [ + "crossbeam-channel", +] + +[[package]] +name = "pledge" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252599417b7d9a43b7fdc63dd790b0848666a8910b2ebe1a25118309c3c981e5" +dependencies = [ + "libc", +] + +[[package]] +name = "predicates" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68b87bfd4605926cdfefc1c3b5f8fe560e3feca9d5552cf68c466d3d8236c7e8" +dependencies = [ + "anstyle", + "difflib", + "float-cmp", + "normalize-line-endings", + "predicates-core", + "regex", +] + +[[package]] +name = "predicates-core" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b794032607612e7abeb4db69adb4e33590fa6cf1149e95fd7cb00e634b92f174" + +[[package]] +name = "predicates-tree" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368ba315fb8c5052ab692e68a0eefec6ec57b23a36959c14496f0b0df2c0cecf" +dependencies = [ + "predicates-core", + "termtree", +] + +[[package]] +name = "proc-macro2" +version = "1.0.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835ff2298f5721608eb1a980ecaee1aef2c132bf95ecc026a11b7bf3c01c02e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "regex" +version = "1.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c117dbdfde9c8308975b6a18d71f3f385c89461f7b3fb054288ecf2a2058ba4c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86b83b8b9847f9bf95ef68afb0b8e6cdb80f498442f5179a29fad448fcc1eaea" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08c74e62047bb2de4ff487b251e4a92e24f48745648451635cec7d591162d9f" + +[[package]] +name = "rustix" +version = "0.38.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65e04861e65f21776e67888bfbea442b3642beaa0138fdb1dd7a84a52dffdb89" +dependencies = [ + "bitflags 2.5.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.52.0", +] + +[[package]] +name = "ryu" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e86697c916019a8588c99b5fac3cead74ec0b4b819707a682fd4d23fa0ce1ba1" + +[[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 = "serde" +version = "1.0.197" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fb1c873e1b9b056a4dc4c0c198b24c3ffa059243875552b2bd0933b1aee4ce2" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.197" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eb0b34b42edc17f6b7cac84a52a1c5f0e1bb2227e997ca9011ea3dd34e8610b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5f09b1bd632ef549eaa9f60a1f8de742bdbc698e6cee2095fc84dde5f549ae0" +dependencies = [ + "itoa", + "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 = "strsim" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee073c9e4cd00e28217186dbe12796d692868f432bf2e97ee73bed0c56dfa01" + +[[package]] +name = "syn" +version = "2.0.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "002a1b3dbf967edfafc32655d0f377ab0bb7b994aa1d32c8cc7e9b8bf3ebb8f0" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85b77fafb263dd9d05cbeac119526425676db3784113aa9295c88498cbf8bff1" +dependencies = [ + "cfg-if", + "fastrand", + "rustix", + "windows-sys 0.52.0", +] + +[[package]] +name = "terminal_size" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21bebf2b7c9e0a515f6e0f8c51dc0f8e4696391e6f1ff30379559f8365fb0df7" +dependencies = [ + "rustix", + "windows-sys 0.48.0", +] + +[[package]] +name = "termtree" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3369f5ac52d5eb6ab48c6b4ffdc8efbcad6b89c765749064ba298f2c68a16a76" + +[[package]] +name = "testresult" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d72e255c0541f86589b0287139b70bd941a197ea4cea8fd8f87afe9c965a99e4" + +[[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.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" + +[[package]] +name = "unveil" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e7fa867d559102001ec694165ed17d5f82e95213060a65f9c8b6280084bbfec" +dependencies = [ + "libc", +] + +[[package]] +name = "utf8parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" + +[[package]] +name = "version_check" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" + +[[package]] +name = "wait-timeout" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f200f5b12eb75f8c1ed65abd4b2db8a6e1b138a20de009dacee265a2498f3f6" +dependencies = [ + "libc", +] + +[[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 = "wasm-bindgen" +version = "0.2.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4be2531df63900aeb2bca0daaaddec08491ee64ceecbee5076636a3b026795a8" +dependencies = [ + "cfg-if", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "614d787b966d3989fa7bb98a654e369c762374fd3213d212cfc0251257e747da" +dependencies = [ + "bumpalo", + "log", + "once_cell", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1f8823de937b71b9460c0c34e25f3da88250760bec0ebac694b49997550d726" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96" + +[[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.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f29e6f9198ba0d26b4c9f07dbe6f9ed633e1f3d5b8b414090084349e46a52596" +dependencies = [ + "winapi", +] + +[[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.4", +] + +[[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.4", +] + +[[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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd37b7e5ab9018759f893a1952c9420d060016fc19a472b4bb20d1bdd694d1b" +dependencies = [ + "windows_aarch64_gnullvm 0.52.4", + "windows_aarch64_msvc 0.52.4", + "windows_i686_gnu 0.52.4", + "windows_i686_msvc 0.52.4", + "windows_x86_64_gnu 0.52.4", + "windows_x86_64_gnullvm 0.52.4", + "windows_x86_64_msvc 0.52.4", +] + +[[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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcf46cf4c365c6f2d1cc93ce535f2c8b244591df96ceee75d8e83deb70a9cac9" + +[[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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da9f259dd3bcf6990b55bffd094c4f7235817ba4ceebde8e6d11cd0c5633b675" + +[[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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b474d8268f99e0995f25b9f095bc7434632601028cf86590aea5c8a5cb7801d3" + +[[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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1515e9a29e5bed743cb4415a9ecf5dfca648ce85ee42e15873c3cd8610ff8e02" + +[[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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eee091590e89cc02ad514ffe3ead9eb6b660aedca2183455434b93546371a03" + +[[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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ca79f2451b49fa9e2af39f0747fe999fcda4f5e241b2898624dca97a1f2177" + +[[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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32b752e52a2da0ddfbdbcc6fceadfeede4c939ed16d13e648833a61dfb611ed8" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..f152902 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "brck" +version = "0.1.0" +authors = ["Stefan Kreutz <mail@skreutz.com>"] +edition = "2021" +description = "A simple bit rot checker" +readme = "README.md" +repository = "https://git.skreutz.com/brck.git" +license = "MIT OR Apache-2.0" +keywords = ["bitrot", "filesystem", "integrity"] +categories = ["command-line-utilities", "filesystem"] +publish = true + +[dependencies] +anyhow = "1.0.81" +chrono = { version = "0.4.35", features = ["serde"] } +clap = { version = "4.5.3", features = ["derive", "wrap_help"] } +ctrlc = "3.4.4" +either = "1.10.0" +flate2 = "1.0.28" +hex = { version = "0.4.3", features = ["serde"] } +itertools = "0.12.1" +parseq = "0.1.3" +serde = { version = "1.0.197", features = ["derive"] } +serde_json = "1.0.114" +sha2 = "0.10.8" +walkdir = "2.5.0" + +[target.'cfg(target_os = "openbsd")'.dependencies] +pledge = "0.4.2" +unveil = "0.3.2" + +[dev-dependencies] +assert_cmd = "2.0.14" +assert_fs = "1.1.1" +filetime = "0.2.23" +predicates = "3.1.0" +testresult = "0.4.0" @@ -0,0 +1,24 @@ +Accept units (k, m, g) for --chunk-size. + +Add option -k/--keep-going to continue in case of IO errors such as permission denied. + +Add option -e/--errexit to immediately exit >0 on the first denied difference. + +Add option to scan ahead and show progress on standard error. + +Hash and check symlinks targets? + https://doc.rust-lang.org/std/fs/fn.read_link.html + https://unix.stackexchange.com/questions/63876/changing-the-timestamp-of-a-symlink + +Estimate default number of worker threads based on disk read speed and CPU speed. + +Test effect of stdout buffering and/or locking on performance with high verbosity. + https://rust-cli.github.io/book/tutorial/output.html#a-note-on-printing-performance + +Generate a man page. + https://rust-cli.github.io/book/in-depth/docs.html + https://lib.rs/crates/clap_mangen + +Use common exit codes. + https://man.openbsd.org/sysexits + https://github.com/dtolnay/anyhow/issues/247 diff --git a/LICENSE-APACHE-2.0 b/LICENSE-APACHE-2.0 new file mode 100644 index 0000000..8df1d9e --- /dev/null +++ b/LICENSE-APACHE-2.0 @@ -0,0 +1,12 @@ +Copyright 2024 Stefan Kreutz <mail@skreutz.com> + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use +this file except in compliance with the License. You may obtain a copy of the +License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed +under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied. See the License for the +specific language governing permissions and limitations under the License. diff --git a/LICENSE-MIT b/LICENSE-MIT new file mode 100644 index 0000000..452e8f1 --- /dev/null +++ b/LICENSE-MIT @@ -0,0 +1,19 @@ +Copyright 2024 Stefan Kreutz <mail@skreutz.com> + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b37513f --- /dev/null +++ b/README.md @@ -0,0 +1,113 @@ +# Brck -- a simple bit rot checker + +Brck is a simple bit rot checker for legacy file systems. It records and +compares the modification times and cryptographic hash sums of regular files. +When a file's hash changed but it's modification time did not, then Brck +reports the file as corrupted so you have a chance to restore the file from +backup. You do have backups, right? 😉 + +Brck's original source code is hosted [here][brck-repo]. + +## Installation + +Brck is available on [crates.io][brck-cratesio]. You can install it using +Rust's package manager [Cargo][cargo]. + + $ cargo install brck + +## Usage + +Without any options, Brck will record all regular files in current working +directory recursively and write those records to a gzipped `.brck` file. + + $ brck + +When you run the same command again, Brck will compare the recorded files +against the current file system and report any corrupted files. + + $ brck + corrupted: ./test_file + Error: Found 1 denied difference + +Brck is quiet by default. Increase the verbosity using `-v` or request a +summary using `-s`. Alternatively, enable the JSON output using `-J` and +perform your own post-processing, e.g., using [jq][]: + + $ brck -vvJ | jq -sr 'group_by(.type)[] | [.[0].type, length] | @tsv' 2>/dev/null + added 4 + corrupted 1 + unchanged 1219 + +See the built-in help for all supported options: + + $ brck --help + +## Features + +- Parallel sequential processing: Brck processes files in parallel, yet outputs + and records files in-order + +- One-pass: Brck reads your files only once + +- Constant memory: Brck's memory footprint is independent of the number of + processed files + +- Relative paths: Brck records relative paths such that you can move the + containing directory around + +- Quiet by default: Without any options, Brck prints denied differences to + standard output, and errors to standard error; nothing else + +- Human readable or machine readable, newline-delimited JSON output, at your + option + +- Graceful shutdown on the first interrupt signal, forceful immediate shutdown + on the second + +- [Pledged][pledge] and [unveiled][unveil] on OpenBSD + +## Limitations + +- Brck doesn't follow symlinks. + +- Brck doesn't track hardlinks. Performance will be suboptimal in the presence + of large hardlinked files because Brck hashes each copy individually. + +- Brck doesn't track reflinks. Modern copy-on-write file systems are out of + scope because they should check file content integrity themselves, like + OpenZFS and Btrfs do. + +- Brck may leak file meta data because its `.brck` file may have different + permissions than the listed files. + +- Brck doesn't respect platform-specific temporary directories such as `TMPDIR` + on UNIX. Instead, it creates its temporary files in the current working + directory, next to the `.brck` file. This way, Brck can update the files + atomically on POSIX-conform systems. + +- Brck may fail to remove its temporary files, e.g., in the event of a + segfault. + +## See also + +- Definition of *bit rot* in the [Jargon File][jargon] + ([archive][jargon-archive]) + +- A similar Python program named [Bitrot][python-bitrot] + +- A similar Rust program named [Legdur][legdur] ([crates.io][legdur-cratesio]) + +- A blog post on [Things UNIX can do atomically][rcrowley] + +[brck-repo]: https://git.skreutz.com/brck.git/ +[brck-cratesio]: https://crates.io/crates/brck +[cargo]: https://doc.rust-lang.org/cargo/ +[jq]: https://jqlang.github.io/jq/ +[pledge]: https://man.openbsd.org/pledge +[unveil]: https://man.openbsd.org/unveil +[jargon]: http://www.catb.org/jargon/html/B/bit-rot.html +[jargon-archive]: https://web.archive.org/web/20240312124910/http://www.catb.org/jargon/html/B/bit-rot.html +[legdur]: https://git.cyplo.dev/cyplo/legdur +[legdur-cratesio]: https://crates.io/crates/legdur +[python-bitrot]: https://github.com/ambv/bitrot/ +[rcrowley]: https://web.archive.org/web/20160304224616/http://rcrowley.org/2010/01/06/things-unix-can-do-atomically diff --git a/script/check b/script/check new file mode 100755 index 0000000..edf251e --- /dev/null +++ b/script/check @@ -0,0 +1,15 @@ +#! /bin/sh + +set -o errexit +set -o nounset +set -o xtrace + +cargo fmt --all --check + +cargo check --workspace --all-targets --all-features +cargo build --workspace --all-targets --all-features +cargo test --workspace --all-targets --all-features +cargo clippy --workspace --all-targets --all-features -- --deny warnings +env RUSTDOCFLAGS="-Dwarnings" \ + cargo doc --all-features --no-deps --document-private-items +cargo audit diff --git a/src/core.rs b/src/core.rs new file mode 100644 index 0000000..6ac3ba5 --- /dev/null +++ b/src/core.rs @@ -0,0 +1,306 @@ +//! Core functionality. +//! +//! This module contains Brck's core types and functions. + +use std::{ + cmp::Ordering, + fs::OpenOptions, + io::{BufReader, Read}, + path::{Path, PathBuf}, + sync::{atomic::AtomicBool, Arc}, +}; + +use chrono::{DateTime, Utc}; +use either::Either; +use flate2::bufread::GzDecoder; +use itertools::{EitherOrBoth, Itertools}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use walkdir::WalkDir; + +/// Produces an iterator over the regular files below the given path. +/// +/// The files are sorted by their full path. +/// Symlinks are not followed, except for the original `path`. +pub fn find_files<P: AsRef<Path>>( + path: P, +) -> impl Iterator<Item = Result<PathBuf, walkdir::Error>> { + WalkDir::new(path.as_ref()) + .sort_by_path() + .into_iter() + .filter_ok(|entry| entry.file_type().is_file()) + .map_ok(|entry| entry.into_path()) +} + +/// Extension for [WalkDir]. +trait WalkDirExt { + /// Sort directory entries by full path. + /// + /// Yiedls "foo bar/buzz" before "foo/buzz". + fn sort_by_path(self) -> WalkDir; +} + +impl WalkDirExt for WalkDir { + fn sort_by_path(self) -> WalkDir { + self.sort_by_key(|a| { + if a.file_type().is_dir() { + let mut name = a.file_name().to_os_string(); + name.push(std::path::MAIN_SEPARATOR.to_string()); + name + } else { + a.file_name().to_os_string() + } + }) + } +} + +/// Returns an iterator over the records of the given database. +pub fn read_db<P: AsRef<Path>>( + path: P, +) -> Result<impl Iterator<Item = Result<Record, serde_json::Error>>, std::io::Error> { + let file = OpenOptions::new().read(true).open(path)?; + let buf = BufReader::new(file); + let dec = GzDecoder::new(buf); + let iter = serde_json::Deserializer::from_reader(dec).into_iter::<Record>(); + Ok(iter) +} + +/// A record of a file's modification time and hash sum. +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] +pub struct Record { + pub path: PathBuf, + pub modified: DateTime<Utc>, + #[serde(with = "hex::serde")] + pub sha256: Vec<u8>, +} + +impl Record { + /// Constructs a `Record` of the given file. + /// + /// This function reads the given file in chunks of at most `chunk_size` bytes. It checks the + /// `terminate` flag before reading each chunk, returning the `RecordError::Interrupt` error + /// when set. + pub fn from_path<P: AsRef<Path>>( + path: P, + chunk_size: u64, + terminate: Arc<AtomicBool>, + ) -> Result<Record, RecordError> { + let modified = std::fs::symlink_metadata(&path)?.modified()?; + let mut file = std::fs::File::open(&path)?; + let mut hasher = Sha256::new(); + loop { + if terminate.load(std::sync::atomic::Ordering::SeqCst) { + return Err(RecordError::Interrupt); + } + let mut reader = std::io::Read::by_ref(&mut file).take(chunk_size); + if std::io::copy(&mut reader, &mut hasher)? == 0 { + break; + } + } + let hash = hasher.finalize(); + Ok(Record { + path: path.as_ref().into(), + modified: modified.into(), + sha256: hash.as_slice().into(), + }) + } +} + +/// The possible errors of [Record::from_path]. +#[derive(Debug)] +pub enum RecordError { + IO(std::io::Error), + Interrupt, +} + +impl std::fmt::Display for RecordError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + RecordError::IO(err) => write!(f, "{}", err), + RecordError::Interrupt => write!(f, "Received interrupt signal"), + } + } +} + +impl std::error::Error for RecordError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + RecordError::IO(err) => Some(err), + RecordError::Interrupt => None, + } + } +} + +impl From<std::io::Error> for RecordError { + fn from(value: std::io::Error) -> Self { + RecordError::IO(value) + } +} + +/// Returns an iterator over the difference of the given files. +pub fn diff<I, J, E, F>(cached: I, found: J) -> impl Iterator<Item = Result<Diff, Either<E, F>>> +where + I: Iterator<Item = Result<Record, E>>, + J: Iterator<Item = Result<Record, F>>, +{ + cached + .merge_join_by(found, |cached, found| match (cached, found) { + (Err(_), _) => Ordering::Less, + (Ok(_), Err(_)) => Ordering::Greater, + (Ok(cached), Ok(found)) => cached.path.cmp(&found.path), + }) + .map(|item| match item { + EitherOrBoth::Left(Err(err)) => Err(Either::Left(err)), + EitherOrBoth::Right(Err(err)) => Err(Either::Right(err)), + EitherOrBoth::Left(Ok(old)) => Ok(Diff::Removed { old }), + EitherOrBoth::Right(Ok(new)) => Ok(Diff::Added { new }), + EitherOrBoth::Both(Ok(old), Ok(new)) => { + if old.modified == new.modified { + if old.sha256 == new.sha256 { + Ok(Diff::Unchanged { old, new }) + } else { + Ok(Diff::Corrupted { old, new }) + } + } else if old.sha256 == new.sha256 { + Ok(Diff::Touched { old, new }) + } else { + Ok(Diff::Changed { old, new }) + } + } + _ => unreachable!(), + }) +} + +/// The list of possible types of differences. +#[derive(Debug, Clone, Hash, Eq, PartialEq, clap::ValueEnum)] +pub enum DiffKind { + Added, + Touched, + Changed, + Unchanged, + Removed, + Corrupted, +} + +/// The difference between two records of a file. +#[derive(Debug, Clone, Eq, PartialEq, Serialize)] +#[serde(tag = "type", rename_all = "lowercase")] +pub enum Diff { + // Modeled variants as structs to improve their JSON representation. + Added { new: Record }, + Touched { old: Record, new: Record }, + Changed { old: Record, new: Record }, + Unchanged { old: Record, new: Record }, + Removed { old: Record }, + Corrupted { old: Record, new: Record }, +} + +impl Diff { + pub fn kind(&self) -> DiffKind { + match self { + Diff::Added { .. } => DiffKind::Added, + Diff::Touched { .. } => DiffKind::Touched, + Diff::Changed { .. } => DiffKind::Changed, + Diff::Unchanged { .. } => DiffKind::Unchanged, + Diff::Removed { .. } => DiffKind::Removed, + Diff::Corrupted { .. } => DiffKind::Corrupted, + } + } +} + +impl std::fmt::Display for Diff { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Diff::Added { new, .. } => write!(f, "added: {}", new.path.display()), + Diff::Touched { old, .. } => write!(f, "touched: {}", old.path.display()), + Diff::Changed { old, .. } => write!(f, "changed: {}", old.path.display()), + Diff::Unchanged { old, .. } => write!(f, "unchanged: {}", old.path.display()), + Diff::Removed { old, .. } => write!(f, "removed: {}", old.path.display()), + Diff::Corrupted { old, .. } => write!(f, "corrupted: {}", old.path.display()), + } + } +} + +#[cfg(test)] +mod tests { + use testresult::TestResult; + + use super::*; + + fn record(path: &str, modified: &str, sha256: &str) -> Record { + Record { + path: path.into(), + modified: DateTime::parse_from_rfc3339(modified).unwrap().into(), + sha256: sha256.into(), + } + } + + #[test] + fn diff_works() -> TestResult { + let a = record("a", "2024-03-22T00:00:00Z", "apple"); + let b = record("b", "2024-03-22T00:00:00Z", "banana"); + let b_touched = record("b", "2024-03-22T01:00:00Z", "banana"); + let c = record("c", "2024-03-22T00:00:00Z", "cherry"); + let c_changed = record("c", "2024-03-22T01:00:00Z", "cashew"); + let d = record("d", "2024-03-22T00:00:00Z", "date"); + let e = record("e", "2024-03-22T00:00:00Z", "elderberry"); + let f = record("f", "2024-03-22T00:00:00Z", "fig"); + let f_corrupted = record("f", "2024-03-22T00:00:00Z", "feijoa"); + + let db = [ + Err("foo"), + Ok(b.clone()), + Ok(c.clone()), + Ok(d.clone()), + Ok(e.clone()), + Ok(f.clone()), + ]; + + let fs = [ + Ok(a.clone()), + Ok(b_touched.clone()), + Err("bar"), + Ok(c_changed.clone()), + Ok(d.clone()), + Ok(f_corrupted.clone()), + ]; + + let mut result = diff(db.into_iter(), fs.into_iter()); + + assert_eq!(result.next(), Some(Err(Either::Left("foo")))); + assert_eq!(result.next(), Some(Ok(Diff::Added { new: a }))); + assert_eq!( + result.next(), + Some(Ok(Diff::Touched { + old: b, + new: b_touched + })) + ); + assert_eq!(result.next(), Some(Err(Either::Right("bar")))); + assert_eq!( + result.next(), + Some(Ok(Diff::Changed { + old: c, + new: c_changed + })) + ); + assert_eq!( + result.next(), + Some(Ok(Diff::Unchanged { + old: d.clone(), + new: d + })) + ); + assert_eq!(result.next(), Some(Ok(Diff::Removed { old: e }))); + assert_eq!( + result.next(), + Some(Ok(Diff::Corrupted { + old: f, + new: f_corrupted + })) + ); + assert_eq!(result.next(), None); + + Ok(()) + } +} diff --git a/src/fs.rs b/src/fs.rs new file mode 100644 index 0000000..f962e53 --- /dev/null +++ b/src/fs.rs @@ -0,0 +1,144 @@ +//! Filesytem functionality. +//! +//! This module contains several extensions to [std::fs]. + +use std::{ + fs::{File, OpenOptions}, + path::{Path, PathBuf}, +}; + +/// A std::fs::File wrapper that removes the file when dropped. +#[derive(Debug)] +pub struct TmpFile { + path: PathBuf, + file: File, +} + +impl TmpFile { + /// Opens a new temporary file at the given path. + pub fn open<P: AsRef<Path>>(path: P) -> Result<TmpFile, std::io::Error> { + let mut options = OpenOptions::new(); + options.create_new(true).write(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let file = options.open(&path)?; + Ok(TmpFile { + path: path.as_ref().into(), + file, + }) + } + + pub fn file(&self) -> &File { + &self.file + } +} + +impl Drop for TmpFile { + fn drop(&mut self) { + if let Err(err) = remove_file_if_exists(&self.path) { + eprintln!( + "Failed to remove temporary file {}: {}", + &self.path.display(), + err + ); + } + } +} + +/// Removes a file from the filesystem, if it exists. +pub fn remove_file_if_exists<P: AsRef<Path>>(path: P) -> Result<(), std::io::Error> { + std::fs::remove_file(path).or_else(|err| { + if err.kind() == std::io::ErrorKind::NotFound { + Ok(()) + } else { + Err(err) + } + }) +} + +/// Returns the path without it's final component, if there is one. +/// +/// In contrast to [Path::parent], this function returns the [CurDir][std::path::Component::CurDir] +/// instead of the empty string for the current directory. +pub fn parent_dir<P: AsRef<Path>>(path: &P) -> Option<&Path> { + path.as_ref().parent().map(|p| { + if p.as_os_str().is_empty() { + Path::new(std::path::Component::CurDir.as_os_str()) + } else { + p + } + }) +} + +/// Rename a file. +/// +/// This function should be atomic on POSIX-conform systems. +/// +/// Both paths must be located on the same filesystem. +pub fn rename_file<P, Q>(from: P, to: Q) -> Result<(), std::io::Error> +where + P: AsRef<Path>, + Q: AsRef<Path>, +{ + File::open(&from)?.sync_all()?; + std::fs::rename(&from, &to)?; + + let dest_dir = parent_dir(&to).ok_or_else(|| std::io::Error::other("is root directory"))?; + File::open(dest_dir)?.sync_all()?; + + let src_dir = parent_dir(&from).ok_or_else(|| std::io::Error::other("is root directory"))?; + if src_dir != dest_dir { + File::open(src_dir)?.sync_all()?; + } + + Ok(()) +} + +/// Copy a file. +/// +/// This functions hard links `from` to `tmp`, and then renames the latter to `to`. The renaming +/// should be atomic on POSIX-conform systems. +/// +/// All three paths must be located on the same filesystem. +// Didn't use https://crates.io/crates/atomic-write-file or https://crates.io/crates/atomicwrites +// because they create randomly named temporary files, which I wanted to unveil(2) on OpenBSD, and +// remove when receiving a termination signal. +pub fn copy_file<P, Q, R>(from: P, tmp: Q, to: R) -> Result<(), std::io::Error> +where + P: AsRef<Path>, + Q: AsRef<Path>, + R: AsRef<Path>, +{ + fn inner<P, Q, R>(from: P, tmp: Q, to: R) -> Result<(), std::io::Error> + where + P: AsRef<Path>, + Q: AsRef<Path>, + R: AsRef<Path>, + { + let tmp_dir = + parent_dir(&from).ok_or_else(|| std::io::Error::other("is root directory"))?; + File::open(tmp_dir)?.sync_all()?; + + std::fs::rename(&tmp, &to)?; + + let to_dir = parent_dir(&from).ok_or_else(|| std::io::Error::other("is root directory"))?; + File::open(to_dir)?.sync_all()?; + + Ok(()) + } + + File::open(&from)?.sync_all()?; + std::fs::hard_link(&from, &tmp)?; + inner(&from, &tmp, &to).inspect_err(|_| { + if let Err(err) = remove_file_if_exists(&tmp) { + eprintln!( + "Failed to remove temporary file {}: {}", + tmp.as_ref().display(), + err + ); + } + }) +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..2d26eed --- /dev/null +++ b/src/main.rs @@ -0,0 +1,343 @@ +#![forbid(unsafe_code)] + +use std::{ + collections::HashMap, + num::{NonZeroU64, NonZeroUsize}, + path::PathBuf, + sync::{atomic::AtomicBool, Arc}, +}; + +use anyhow::{anyhow, Context}; +use clap::Parser; +use either::Either; +use flate2::{write::GzEncoder, Compression}; +use itertools::Itertools; +use parseq::ParallelIterator; + +mod core; +use core::*; + +mod fs; +use fs::*; + +// std::process::ExitCode::exit_process is unstable +// Exit codes should fit u8. +const EXIT_SUCCESS: i32 = 0; +const EXIT_FAILURE: i32 = 1; +const EXIT_INTERRUPT: i32 = 130; + +/// A simple bit rot checker for legacy file systems. +/// +/// On the first invocation, Brck records the modification time and a cryptographic hash sum of each regular file in the current working directory to a .brck file. +/// On subsequent invocations, Brck compares the recorded files against the current file system. +/// +/// A file can be added, touched, changed, unchanged, removed, or corrupted. +/// A file is corrupted if it's content changed but it's modification did not. +/// +/// Without any options, Brck denies corrupted files. +/// If Brck finds one or more denied differences, it prints them to standard output and exits >0. +/// Otherwise, Brck updates the .brck file. +#[derive(Debug, Parser)] +#[command(about, version, author)] +struct Args { + /// Increase verbosity. + /// + /// Specify once to print all changed files. + /// Specify twice to print unchanged files, too. + /// By default, only denied differences are printed. + #[arg(short = 'v', long = "verbose", action = clap::ArgAction::Count)] + verbosity: u8, + + /// Do not actually modify the database. + #[arg(short = 'n', long)] + dry_run: bool, + + /// Specify the number of worker threads. + /// + /// Defaults to the available parallelism, typically the number of CPUs. + #[arg(short, long)] + jobs: Option<NonZeroUsize>, + + /// Deny select differences only. + /// + /// This option accepts a comma-separated list of differences to deny. + /// Without this option, only corrupted files are denied. + /// With this option, only the specified differences are denied. + #[arg(value_enum, short, long, default_values_t = vec![DiffKind::Corrupted], value_delimiter = ',', action = clap::ArgAction::Append)] + deny: Vec<DiffKind>, + + /// Enable structured output. + #[arg(short = 'J', long)] + json: bool, + + /// Print summary to standard error. + #[arg(short, long)] + summary: bool, + + /// Specify the maximum number of bytes a worker thread may read without checking for an interrupt signal. + /// + /// Defaults to 1 GiB. + #[arg(long)] + chunk_size: Option<NonZeroU64>, + + /// Specify the maximum number of files to read ahead. + /// + /// This should be greater than the number of worker threads. + /// + /// Defaults to 1024 files per worker thread. + #[arg(long)] + queue_size: Option<NonZeroUsize>, +} + +fn main() { + std::process::exit(match run() { + Ok(_) => EXIT_SUCCESS, + Err(err) if err.is::<clap::Error>() => { + // Use clap's own error formatter + eprintln!("{err}"); + EXIT_FAILURE + } + Err(err) => { + eprintln!("Error: {:?}", err); + match err.downcast_ref::<RecordError>() { + Some(RecordError::Interrupt) => EXIT_INTERRUPT, + _ => EXIT_FAILURE, + } + } + }) +} + +fn run() -> Result<(), anyhow::Error> { + let terminate = Arc::new(AtomicBool::new(false)); + let args = Args::try_parse()?; + + // Paths must begin with ./ for correct filtering. + let current_dir: PathBuf = std::path::Component::CurDir.as_os_str().into(); + let db_path = current_dir.join(".brck"); + let backup_path = current_dir.join(".brck.bak"); + + // Creating temporary files in the same directory instead of std::env_temp_dir() to enable atomic rename(2) on POSIX-conform systems. + // The drawback is that the operating system won't remove these files automatically. + let tmp_db_path = current_dir.join(".brck.tmp"); + let tmp_backup_path = current_dir.join(".brck.bak.tmp"); + + #[cfg(target_os = "openbsd")] + { + use std::{os::unix::ffi::OsStrExt, path::Path}; + + pledge::pledge![Stdio Rpath Wpath Cpath Unveil, Stdio].context("Failed to pledge(2)")?; + + fn unveil<P: AsRef<Path>>(path: P, permissions: &str) -> Result<(), anyhow::Error> { + unveil::unveil(path.as_ref().as_os_str().as_bytes(), permissions).with_context(|| { + format!( + "Failed to unveil(2) {} with permissions {}", + path.as_ref().display(), + permissions + ) + }) + } + + unveil(".", "r")?; + unveil(&db_path, "rwc")?; + unveil(&tmp_db_path, "rwc")?; + unveil(&backup_path, "rwc")?; + unveil(&tmp_backup_path, "rwc")?; + unveil::unveil("", "").context("Failed to disable unveil(2)")?; + } + + let t = terminate.clone(); + ctrlc::try_set_handler(move || { + if t.load(std::sync::atomic::Ordering::SeqCst) { + eprintln!("Received second interrupt signal"); + std::process::exit(EXIT_INTERRUPT); + } + eprintln!("Received interrupt signal"); + t.store(true, std::sync::atomic::Ordering::SeqCst); + }) + .context("Failed to register interrupt signal handler")?; + + let (db, first_run) = match read_db(&db_path) { + Ok(db) => Ok((Either::Left(db), false)), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + Ok((Either::Right(std::iter::empty()), true)) + } + Err(err) => Err(err), + } + .with_context(|| format!("Failed to read database {}", db_path.display()))?; + let db = db.map(|item| { + item.map_err(|err| { + anyhow::Error::new(err).context(format!( + "Failed to decode record from database {}", + &db_path.display() + )) + }) + }); + + let tmp = TmpFile::open(&tmp_db_path).with_context(|| { + format!( + "Failed to create temporary database {}", + tmp_db_path.display() + ) + })?; + let mut tmp = GzEncoder::new(tmp.file(), Compression::default()); + + let jobs = args + .jobs + .or_else(|| std::thread::available_parallelism().ok()) + .map(NonZeroUsize::get) + .unwrap_or(1); + let queue_size = args + .queue_size + .map(NonZeroUsize::get) + .unwrap_or_else(|| jobs.saturating_mul(1024)); + let chunk_size = args + .chunk_size + .map(NonZeroU64::get) + .unwrap_or(1024 * 1024 * 1024); + + let fs = find_files(¤t_dir) + .filter_ok(|path| path != &db_path && path != &tmp_db_path && path != &backup_path) + .map(|item| { + item.map_err(|err| anyhow::Error::new(err).context("Failed to walk filesystem")) + }) + .map_parallel_limit(jobs, queue_size, move |item| { + item.and_then(|path| { + Record::from_path(&path, chunk_size, terminate.clone()) + .with_context(|| format!("Failed to read file {}", path.display())) + }) + }); + + let mut counter = HashMap::new(); + let mut denied = 0; + + diff(db, fs).try_for_each(|item| match item { + Err(Either::Left(err)) => Err(err), + Err(Either::Right(err)) => Err(err), + Ok(diff) => { + *counter.entry(diff.kind()).or_insert(0) += 1; + record(&diff, &mut tmp).with_context(|| { + format!( + "Failed to write to temporary database {}", + tmp_db_path.display() + ) + })?; + if args.deny.contains(&diff.kind()) { + if args.json { + println!("{}", serde_json::to_string(&diff).unwrap()); + } else { + println!("{diff}"); + } + denied += 1; + } else if args.verbosity > 1 + || (diff.kind() != DiffKind::Unchanged && args.verbosity > 0) + { + if args.json { + println!("{}", serde_json::to_string(&diff).unwrap()); + } else { + println!("{diff}"); + } + } + Ok(()) + } + })?; + + if args.summary { + let total: usize = counter.values().sum(); + let width = usize::try_from(total.checked_ilog10().unwrap_or(0) + 1).unwrap_or(0); + eprintln!( + "Removed: {:>width$}", + counter.get(&DiffKind::Removed).unwrap_or(&0) + ); + eprintln!( + "Added: {:>width$}", + counter.get(&DiffKind::Added).unwrap_or(&0) + ); + eprintln!( + "Unchanged: {:>width$}", + counter.get(&DiffKind::Unchanged).unwrap_or(&0) + ); + eprintln!( + "Corrupted: {:>width$}", + counter.get(&DiffKind::Corrupted).unwrap_or(&0) + ); + eprintln!( + "Touched: {:>width$}", + counter.get(&DiffKind::Touched).unwrap_or(&0) + ); + eprintln!( + "Changed: {:>width$}", + counter.get(&DiffKind::Changed).unwrap_or(&0) + ); + eprintln!("Total: {:>width$}", total); + } + + if denied > 0 { + return Err(anyhow!( + "Found {} denied {}", + denied, + if denied == 1 { + "difference" + } else { + "differences" + } + )); + } + + if args.dry_run { + eprintln!("Exiting due to dry run"); + return Ok(()); + } + + tmp.finish().with_context(|| { + format!( + "Failed to synchronize temporary database {}", + tmp_db_path.display() + ) + })?; + if !first_run { + copy_file(&db_path, tmp_backup_path, &backup_path).with_context(|| { + format!( + "Failed to backup database {} to {}", + db_path.display(), + backup_path.display() + ) + })?; + } + rename_file(&tmp_db_path, &db_path).with_context(|| { + format!( + "Failed to persist temporary database {} to {}", + tmp_db_path.display(), + db_path.display() + ) + })?; + + Ok(()) +} + +fn record<T: std::io::Write>(diff: &Diff, mut writer: T) -> Result<(), std::io::Error> { + let file = match diff { + Diff::Removed { .. } => None, + Diff::Added { new, .. } => Some(new), + Diff::Unchanged { new, .. } => Some(new), + Diff::Corrupted { new, .. } => Some(new), + Diff::Touched { new, .. } => Some(new), + Diff::Changed { new, .. } => Some(new), + }; + if let Some(file) = file { + let s = serde_json::to_string(file).unwrap(); + writeln!(writer, "{}", s) + } else { + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn verify_clap_app() { + use clap::CommandFactory; + Args::command().debug_assert() + } +} diff --git a/tests/cli.rs b/tests/cli.rs new file mode 100644 index 0000000..2846a62 --- /dev/null +++ b/tests/cli.rs @@ -0,0 +1,299 @@ +use std::{fs::File, path::Path, process::Command}; + +use assert_cmd::prelude::*; +use assert_fs::fixture::{FileWriteStr, PathChild}; +use filetime::FileTime; +use predicates::prelude::*; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use testresult::TestResult; + +#[test] +fn corrupted() -> TestResult { + let dir = assert_fs::TempDir::new()?; + dir.child("a").write_str("apple")?; + dir.child("b").write_str("banana")?; + + let mut cmd = Command::cargo_bin("brck")?; + cmd.current_dir(&dir) + .assert() + .success() + .stdout(predicate::str::is_empty()) + .stderr(predicate::str::is_empty()); + + let db = dir.child(".brck"); + assert!(db.exists()); + let db_time = db.metadata()?.modified()?; + let db_hash = sha256(db.path())?; + + let b = dir.child("b"); + let b_time = FileTime::from_last_modification_time(&b.metadata()?); + b.write_str("cheese")?; + filetime::set_file_mtime(b.path(), b_time)?; + + let mut cmd = Command::cargo_bin("brck")?; + cmd.current_dir(&dir) + .assert() + .failure() + .stdout(predicate::str::contains("corrupted")) + .stderr(predicate::str::contains("Error")); + + assert_eq!(db_time, db.metadata()?.modified()?); + assert_eq!(db_hash, sha256(db.path())?); + + Ok(()) +} + +#[test] +fn quiet_by_default() -> TestResult { + let dir = assert_fs::TempDir::new()?; + dir.child("a").write_str("apple")?; + + let mut cmd = Command::cargo_bin("brck")?; + cmd.current_dir(&dir) + .assert() + .success() + .stdout(predicate::str::is_empty()) + .stderr(predicate::str::is_empty()); + + Ok(()) +} + +#[test] +fn verbose() -> TestResult { + let dir = assert_fs::TempDir::new()?; + dir.child("a").write_str("apple")?; + + let mut cmd = Command::cargo_bin("brck")?; + cmd.current_dir(&dir) + .arg("--verbose") + .assert() + .success() + .stdout(predicate::str::contains("added")) + .stderr(predicate::str::is_empty()); + + let mut cmd = Command::cargo_bin("brck")?; + cmd.current_dir(&dir) + .arg("--verbose") + .assert() + .success() + .stdout(predicate::str::is_empty()) + .stderr(predicate::str::is_empty()); + + let mut cmd = Command::cargo_bin("brck")?; + cmd.current_dir(&dir) + .arg("--verbose") + .arg("--verbose") + .assert() + .success() + .stdout(predicate::str::contains("unchanged")) + .stderr(predicate::str::is_empty()); + + Ok(()) +} + +#[test] +fn json() -> TestResult { + let dir = assert_fs::TempDir::new()?; + dir.child("a").write_str("apple")?; + + let mut cmd = Command::cargo_bin("brck")?; + let assert = cmd + .current_dir(&dir) + .arg("--verbose") + .arg("--verbose") + .arg("--json") + .assert() + .success() + .stderr(predicate::str::is_empty()); + + let out = &assert.get_output().stdout; + let mut iter = serde_json::Deserializer::from_slice(out).into_iter::<Value>(); + let added = iter.next().unwrap()?; + assert_eq!( + added.get("type"), + Some(Value::String("added".into())).as_ref() + ); + assert_eq!( + added.get("new").unwrap().get("sha256"), + Some(Value::String( + "3a7bd3e2360a3d29eea436fcfb7e44c735d117c42d1c1835420b6b9942dd4f1b".into() + )) + .as_ref() + ); + assert!(iter.next().is_none()); + + Ok(()) +} + +#[test] +fn summary() -> TestResult { + let dir = assert_fs::TempDir::new()?; + dir.child("a").write_str("apple")?; + dir.child("b").write_str("banana")?; + dir.child("c").write_str("cherry")?; + dir.child("d").write_str("date")?; + + let mut cmd = Command::cargo_bin("brck")?; + cmd.current_dir(&dir) + .assert() + .success() + .stdout(predicate::str::is_empty()) + .stderr(predicate::str::is_empty()); + + std::fs::remove_file(dir.child("b"))?; + dir.child("c").write_str("cashew")?; + dir.child("d").write_str("date")?; + dir.child("e").write_str("elderberry")?; + dir.child("f").write_str("fig")?; + + let mut cmd = Command::cargo_bin("brck")?; + // Matching ^ and $ doesn't really work. + let pred = predicate::str::is_match("Removed: *1")? + .and(predicate::str::is_match("Added: *2")?) // e, f + .and(predicate::str::is_match("Unchanged: *1")?) // a + .and(predicate::str::is_match("Corrupted: *0")?) // - + .and(predicate::str::is_match("Touched: *1")?) // d + .and(predicate::str::is_match("Changed: *1")?) // c + .and(predicate::str::is_match("Total: *6")?); // a-f + cmd.current_dir(&dir) + .arg("--summary") + .assert() + .success() + .stdout(predicate::str::is_empty()) + .stderr(pred); + + Ok(()) +} + +#[test] +fn dry_run_init() -> TestResult { + let dir = assert_fs::TempDir::new()?; + dir.child("a").write_str("apple")?; + + let mut cmd = Command::cargo_bin("brck")?; + cmd.current_dir(&dir) + .arg("--dry-run") + .assert() + .success() + .stdout(predicate::str::is_empty()) + .stderr(predicate::str::contains("dry run")); + assert!(!dir.child(".brck").exists()); + + Ok(()) +} + +#[test] +fn dry_run_update() -> TestResult { + let dir = assert_fs::TempDir::new()?; + dir.child("a").write_str("apple")?; + + let mut cmd = Command::cargo_bin("brck")?; + cmd.current_dir(&dir) + .assert() + .success() + .stdout(predicate::str::is_empty()) + .stderr(predicate::str::is_empty()); + + let db = dir.child(".brck"); + assert!(db.exists()); + let db_time = db.metadata()?.modified()?; + let db_hash = sha256(db.path())?; + + dir.child("b").write_str("banana")?; + + let mut cmd = Command::cargo_bin("brck")?; + cmd.current_dir(&dir) + .arg("--dry-run") + .assert() + .success() + .stdout(predicate::str::is_empty()) + .stderr(predicate::str::contains("dry run")); + + assert_eq!(db_time, db.metadata()?.modified()?); + assert_eq!(db_hash, sha256(db.path())?); + + Ok(()) +} + +#[test] +fn deny() -> TestResult { + let dir = assert_fs::TempDir::new()?; + dir.child("a").write_str("apple")?; + + let mut cmd = Command::cargo_bin("brck")?; + cmd.current_dir(&dir) + .assert() + .success() + .stdout(predicate::str::is_empty()) + .stderr(predicate::str::is_empty()); + + let db = dir.child(".brck"); + assert!(db.exists()); + let db_time = db.metadata()?.modified()?; + let db_hash = sha256(db.path())?; + + dir.child("b").write_str("banana")?; + + let mut cmd = Command::cargo_bin("brck")?; + cmd.current_dir(&dir) + .arg("--deny") + .arg("added") + .assert() + .failure() + .stdout(predicate::str::contains("added")) + .stderr(predicate::str::contains("Error")); + + assert_eq!(db_time, db.metadata()?.modified()?); + assert_eq!(db_hash, sha256(db.path())?); + + Ok(()) +} + +#[test] +fn empty_dir() -> TestResult { + let dir = assert_fs::TempDir::new()?; + + let mut cmd = Command::cargo_bin("brck")?; + cmd.current_dir(&dir) + .assert() + .success() + .stdout(predicate::str::is_empty()) + .stderr(predicate::str::is_empty()); + + assert!(dir.child(".brck").exists()); + + Ok(()) +} + +#[test] +fn short_help() -> TestResult { + let mut cmd = Command::cargo_bin("brck")?; + cmd.arg("-h") + .assert() + .failure() + .stdout(predicate::str::is_empty()) + .stderr(predicate::str::contains("--help")); + + Ok(()) +} + +#[test] +fn long_help() -> TestResult { + let mut cmd = Command::cargo_bin("brck")?; + cmd.arg("--help") + .assert() + .failure() + .stdout(predicate::str::is_empty()) + .stderr(predicate::str::contains("Usage")); + + Ok(()) +} + +fn sha256<P: AsRef<Path>>(path: P) -> Result<Vec<u8>, std::io::Error> { + let mut file = File::open(path)?; + let mut hasher = Sha256::new(); + std::io::copy(&mut file, &mut hasher)?; + let hash = hasher.finalize(); + Ok(hash.as_slice().into()) +} |