diff --git a/.gitignore b/.gitignore index 8f6caf4..0cd02b7 100644 --- a/.gitignore +++ b/.gitignore @@ -5,10 +5,12 @@ # These are backup files generated by rustfmt **/*.rs.bk +# IDE files +.idea +.vscode + # Test data created may accidentally be included -/tests/test_data/test_repos/repo_remove_me -/tests/test_data/test_repos/repo_remove_me_2 -tests/fixtures/test_data/rustic_server.toml #often updated with content that should not be archived -/tmp_test_data -tests/fixtures/test_data/test_repos/test_repo/keys/__add_file_test_adds_this_one__ -tests/fixtures/test_data/test_repos/test_repo/keys/__get_file_test_adds_this_two__ +tests/fixtures/rest_server +repo_remove_me* +__* +ci_repo \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml deleted file mode 100644 index 7e25165..0000000 --- a/.pre-commit-config.yaml +++ /dev/null @@ -1,26 +0,0 @@ -repos: - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.4.0 - hooks: - - id: check-yaml - stages: [commit] - - id: check-json - stages: [commit] - - id: check-toml - stages: [commit] - - id: check-merge-conflict - stages: [commit] - - id: check-case-conflict - stages: [commit] - - id: detect-private-key - stages: [commit] - - repo: https://github.com/crate-ci/typos - rev: v1.16.3 - hooks: - - id: typos - stages: [commit] - - repo: https://github.com/crate-ci/committed - rev: v1.0.20 - hooks: - - id: committed - stages: [commit-msg] diff --git a/Cargo.lock b/Cargo.lock index 433ac95..97675fa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,20 +2,69 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "abscissa_core" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de5df09bc18cb069dec8524aff811cbe9d7bf5f4b78ef739ef125a37b9d3f044" +dependencies = [ + "abscissa_derive", + "arc-swap", + "backtrace", + "canonical-path", + "clap", + "color-eyre", + "fs-err", + "once_cell", + "regex", + "secrecy", + "semver", + "serde", + "termcolor", + "toml", + "tracing", + "tracing-log", + "tracing-subscriber", + "wait-timeout", +] + +[[package]] +name = "abscissa_derive" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e04c7df69b2c6b9b6dba8422d1295e58ac4bcfc7c9e7e7d4c55a38aaff2ad92a" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "syn 1.0.109", + "synstructure", +] + +[[package]] +name = "abscissa_tokio" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbfe75534601ac38dd9119c552c97b91814b079c065efdbfcabd7a1eb998c23e" +dependencies = [ + "abscissa_core", + "tokio", +] + [[package]] name = "addr2line" -version = "0.24.2" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb" dependencies = [ "gimli", ] [[package]] -name = "adler2" -version = "2.0.0" +name = "adler" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" +checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" [[package]] name = "aho-corasick" @@ -87,6 +136,22 @@ version = "1.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" +[[package]] +name = "assert_cmd" +version = "2.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1835b7f27878de8525dc71410b5a31cdcc5f230aed5ba5df968e09c201b23d" +dependencies = [ + "anstyle", + "bstr", + "doc-comment", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + [[package]] name = "async-trait" version = "0.1.83" @@ -95,7 +160,7 @@ checksum = "721cae7de5c34fbb2acd27e21e6d2cf7b886dce0c27388d46c4e6c47ea4318dd" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.87", ] [[package]] @@ -242,7 +307,7 @@ checksum = "57d123550fa8d071b7255cb0cc04dc302baa6c8c4a79f55701552684d8399bce" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.87", ] [[package]] @@ -286,17 +351,17 @@ dependencies = [ [[package]] name = "backtrace" -version = "0.3.74" +version = "0.3.71" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" +checksum = "26b05800d2e817c8b3b4b54abd461726265fa9789ae34330622f2db9ee696f9d" dependencies = [ "addr2line", + "cc", "cfg-if", "libc", "miniz_oxide", "object", "rustc-demangle", - "windows-targets 0.52.6", ] [[package]] @@ -348,7 +413,7 @@ dependencies = [ "regex", "rustc-hash", "shlex", - "syn", + "syn 2.0.87", "which", ] @@ -403,6 +468,17 @@ dependencies = [ "cipher 0.4.4", ] +[[package]] +name = "bstr" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40723b8fb387abc38f4f4a37c09073622e41dd12327033091ef8950659e6dc0c" +dependencies = [ + "memchr", + "regex-automata 0.4.9", + "serde", +] + [[package]] name = "byteorder" version = "1.5.0" @@ -415,6 +491,12 @@ version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ac0150caa2ae65ca5bd83f25c7de183dea78d4d366469f148435e2acfbad0da" +[[package]] +name = "canonical-path" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e9e01327e6c86e92ec72b1c798d4a94810f147209bbe3ffab6a86954937a6f" + [[package]] name = "cc" version = "1.1.37" @@ -502,7 +584,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.87", ] [[package]] @@ -520,12 +602,47 @@ dependencies = [ "cc", ] +[[package]] +name = "color-eyre" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55146f5e46f237f7423d74111267d4597b59b0dad0ffaf7303bce9945d843ad5" +dependencies = [ + "backtrace", + "eyre", + "indenter", + "once_cell", + "owo-colors", +] + [[package]] name = "colorchoice" version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" +[[package]] +name = "conflate" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1803bd64597191f7f4afa2dd0437f76b7b3c065d0e4566380012406c28eaac" +dependencies = [ + "conflate_derive", + "num-traits", +] + +[[package]] +name = "conflate_derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9395ace5316656ca6a778aa2c28ab0c4ea2c94a8f7dc942898c20be9b7a9a4b9" +dependencies = [ + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.87", +] + [[package]] name = "console" version = "0.15.8" @@ -540,9 +657,9 @@ dependencies = [ [[package]] name = "cpufeatures" -version = "0.2.14" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "608697df725056feaccfa42cffdaeeec3fccc4ffc38358ecd19b243e716a78e0" +checksum = "0ca741a962e1b0bff6d724a1a0958b686406e853bb14061f218562e1896f95e6" dependencies = [ "libc", ] @@ -592,6 +709,18 @@ dependencies = [ "subtle", ] +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + [[package]] name = "digest" version = "0.9.0" @@ -611,6 +740,18 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "dircmp" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3ca7fa3ba397980657070e679f412acddb7a372f1793ff68ef0bbe708680f0f" +dependencies = [ + "regex", + "sha2 0.10.8", + "thiserror 1.0.69", + "walkdir", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -619,9 +760,15 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.87", ] +[[package]] +name = "doc-comment" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10" + [[package]] name = "dunce" version = "1.0.5" @@ -671,6 +818,16 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "eyre" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec" +dependencies = [ + "indenter", + "once_cell", +] + [[package]] name = "fnv" version = "1.0.7" @@ -686,6 +843,15 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs-err" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a41f105fe1d5b6b34b2055e3dc59bb79b46b48b2040b9e6c7b4b5de097aa41" +dependencies = [ + "autocfg", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -754,7 +920,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.87", ] [[package]] @@ -840,9 +1006,9 @@ dependencies = [ [[package]] name = "gimli" -version = "0.31.1" +version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" +checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" [[package]] name = "glob" @@ -1030,6 +1196,18 @@ dependencies = [ "tower-service", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indenter" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce23b50ad8242c51a442f3ff322d56b02f08852c77e4c0b4d3fd684abc89c683" + [[package]] name = "indexmap" version = "2.6.0" @@ -1214,11 +1392,11 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "miniz_oxide" -version = "0.8.0" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2d80299ef12ff69b16a84bb182e3b9df68b5a91574d3d4fa6e41b65deec4df1" +checksum = "b8a240ddb74feaf34a79a7add65a741f3167852fba007066dcac1ca548d89c08" dependencies = [ - "adler2", + "adler", ] [[package]] @@ -1297,11 +1475,20 @@ dependencies = [ "winapi", ] +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "object" -version = "0.36.5" +version = "0.32.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aedf0a2d09c573ed1d8d85b30c119153926a2b36dce0ab28322c09a117a4683e" +checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441" dependencies = [ "memchr", ] @@ -1324,6 +1511,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" +[[package]] +name = "owo-colors" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1b04fb49957986fdce4d6ee7a65027d55d4b6d2265e5848bbb507b58ccfdb6f" + [[package]] name = "parking_lot" version = "0.12.3" @@ -1390,7 +1583,7 @@ dependencies = [ "pest_meta", "proc-macro2", "quote", - "syn", + "syn 2.0.87", ] [[package]] @@ -1421,7 +1614,7 @@ checksum = "3c0f5fad0874fc7abcd4d750e76917eaebbecaa2c20bde22e1dbeeba8beb758c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.87", ] [[package]] @@ -1445,6 +1638,43 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "predicates" +version = "3.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e9086cc7640c29a356d1a29fd134380bee9d8f79a17410aa76e7ad295f42c97" +dependencies = [ + "anstyle", + "difflib", + "predicates-core", +] + +[[package]] +name = "predicates-core" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae8177bee8e75d6846599c6b9ff679ed51e882816914eec639944d7c9aa11931" + +[[package]] +name = "predicates-tree" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b740d195ed3166cd147c8047ec98db0e22ec019eb8eeb76d343b795304fb13" +dependencies = [ + "predicates-core", + "termtree", +] + +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + [[package]] name = "prettyplease" version = "0.2.25" @@ -1452,7 +1682,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "64d1ec885c64d0457d564db4ec299b2dae3f9c02808b8ad9c3a089c591b18033" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.87", ] [[package]] @@ -1464,6 +1694,28 @@ dependencies = [ "toml_edit", ] +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.87", +] + [[package]] name = "proc-macro2" version = "1.0.89" @@ -1591,7 +1843,7 @@ checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" dependencies = [ "aho-corasick", "memchr", - "regex-automata 0.4.8", + "regex-automata 0.4.9", "regex-syntax 0.8.5", ] @@ -1606,9 +1858,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.8" +version = "0.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368758f23274712b504848e9d5a6f010445cc8b87a7cdb4d7cbee666c1288da3" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" dependencies = [ "aho-corasick", "memchr", @@ -1685,7 +1937,7 @@ dependencies = [ "regex", "relative-path", "rustc_version", - "syn", + "syn 2.0.87", "unicode-ident", ] @@ -1733,7 +1985,10 @@ dependencies = [ name = "rustic_server" version = "0.1.1" dependencies = [ + "abscissa_core", + "abscissa_tokio", "anyhow", + "assert_cmd", "async-trait", "axum", "axum-auth", @@ -1743,6 +1998,8 @@ dependencies = [ "axum-server", "base64 0.22.1", "clap", + "conflate", + "dircmp", "displaydoc", "futures", "futures-util", @@ -1751,9 +2008,12 @@ dependencies = [ "http-range", "inquire", "insta", + "once_cell", "pin-project", + "pretty_assertions", "rand 0.8.5", "rstest", + "rustls", "serde", "serde_derive", "serde_json", @@ -1765,6 +2025,7 @@ dependencies = [ "tower 0.5.1", "tracing", "tracing-subscriber", + "uuid", "walkdir", ] @@ -1788,6 +2049,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eee87ff5d9b36712a58574e12e9f0ea80f915a5b0ac518d322b24a465617925e" dependencies = [ "aws-lc-rs", + "log", "once_cell", "rustls-pki-types", "rustls-webpki", @@ -1849,30 +2111,43 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "serde", + "zeroize", +] + [[package]] name = "semver" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" +dependencies = [ + "serde", +] [[package]] name = "serde" -version = "1.0.214" +version = "1.0.215" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f55c3193aca71c12ad7890f1785d2b73e1b9f63a0bbc353c08ef26fe03fc56b5" +checksum = "6513c1ad0b11a9376da888e3e0baa0077f1aed55c17f50e7b2397136129fb88f" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.214" +version = "1.0.215" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de523f781f095e28fa605cdce0f8307e451cc0fd14e2eb4cd2e98a355b147766" +checksum = "ad1e866f866923f252f05c889987993144fb74e722403468a4ebd70c3cd756c0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.87", ] [[package]] @@ -2086,7 +2361,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn", + "syn 2.0.87", ] [[package]] @@ -2095,6 +2370,17 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[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.87" @@ -2118,6 +2404,33 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7065abeca94b6a8a577f9bd45aa0867a2238b74e8eb67cf10d492bc39351394" +[[package]] +name = "synstructure" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f36bdaa60a83aca3921b5259d5400cbf5e90fc51931376a9bd4a0eb79aa7210f" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", + "unicode-xid", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "termtree" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3369f5ac52d5eb6ab48c6b4ffdc8efbcad6b89c765749064ba298f2c68a16a76" + [[package]] name = "thiserror" version = "1.0.69" @@ -2144,7 +2457,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.87", ] [[package]] @@ -2155,7 +2468,7 @@ checksum = "f077553d607adc1caf65430528a576c757a71ed73944b66ebb58ef2bbd243568" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.87", ] [[package]] @@ -2205,7 +2518,7 @@ checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.87", ] [[package]] @@ -2329,7 +2642,7 @@ checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.87", ] [[package]] @@ -2401,6 +2714,12 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "untrusted" version = "0.9.0" @@ -2413,6 +2732,15 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8c5f0a0af699448548ad1a2fbf920fb4bee257eae39953ba95cb84891a0446a" +dependencies = [ + "getrandom", +] + [[package]] name = "valuable" version = "0.1.0" @@ -2425,6 +2753,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[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" @@ -2647,6 +2984,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + [[package]] name = "zerocopy" version = "0.7.35" @@ -2665,7 +3008,7 @@ checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.87", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 37b7587..3be1dd2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,7 @@ license = false eula = false [dependencies] +abscissa_tokio = "0.8.0" anyhow = "1" async-trait = "0.1" # FIXME: Add "headers" feature to Axum? @@ -48,6 +49,7 @@ axum-macros = "0.4" axum-range = "0.4" axum-server = { version = "0.7", features = ["tls-rustls"] } clap = { version = "4", features = ["derive"] } +conflate = "0.3.1" displaydoc = "0.2" # enum_dispatch = "0.3.12" futures = "0.3" @@ -67,11 +69,29 @@ tokio-util = { version = "0.7", features = ["io", "io-util"] } toml = "0.8" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } +uuid = { version = "1.11.0", features = ["v4"] } walkdir = "2" +[dependencies.abscissa_core] +version = "0.8.1" +# optional: use `gimli` to capture backtraces +# see https://github.com/rust-lang/backtrace-rs/issues/189 +# features = ["gimli-backtrace"] + +[dependencies.rustls] +version = "0.23.16" +features = ["logging", "std", "tls12"] +default-features = false + [dev-dependencies] +abscissa_core = { version = "0.8.1", features = ["testing"] } +anyhow = "1" +assert_cmd = "2" base64 = "0.22" +dircmp = "0.2" insta = { version = "1", features = ["redactions", "ron"] } +once_cell = "1.2" +pretty_assertions = "1" rstest = "0.23" # reqwest = "0.11.18" # serial_test = "*" diff --git a/config/README.md b/config/README.md index c43bde8..23a35e9 100644 --- a/config/README.md +++ b/config/README.md @@ -6,10 +6,10 @@ This folder contains a few configuration files as an example. - access control list (acl.toml) - server configuration (rustic_server.toml) -- basic http credential authentication (.htaccess) +- basic http credential authentication (.htpasswd) See also the rustic configuration, described in: -https://github.com/rustic-rs/rustic/tree/main/config + # Server config file `rustic_server.toml` @@ -71,15 +71,15 @@ The `access_type` can have values: Todo: Describe "default" tag in the file. -# user credential file `.htaccess` +# user credential file `.htpasswd` -This file is formatted as a vanilla `Apache` `.htacces` file. +This file is formatted as a vanilla `Apache` `.htpasswd` file. Using the server configuration file, this file may have any name, but requires valid formatting. A **path** to this file can be entered on the command line when starting the -server. In that case the file name has to be `.htaccess`. +server. In that case the file name has to be `.htpasswd`. The server binary allows this file to be created from the command line. Execute `rustic_server --help` for details. diff --git a/config/rustic_config.toml b/config/rustic_profile.toml similarity index 100% rename from config/rustic_config.toml rename to config/rustic_profile.toml diff --git a/config/rustic_server.toml b/config/rustic_server.toml index 9d21ec2..852882d 100644 --- a/config/rustic_server.toml +++ b/config/rustic_server.toml @@ -1,15 +1,23 @@ [server] -host_dns_name = "127.0.0.1" -port = 8000 +listen = "127.0.0.1:8000" -[repos] -storage_path = "./test_data/test_repos/" +[storage] +data-dir = "./test_data/test_repos/" -[authorization] -auth_path = "/test_data/test_repo/htaccess" -use_auth = true +[auth] +disable-auth = false +htpasswd-file = "/test_data/test_repo/.htpasswd" -[access_control] -acl_path = "/test_data/test_repo/acl.toml" -private_repo = true -append_only = false +[acl] +acl-path = "/test_data/test_repo/acl.toml" +private-repo = true +append-only = false + +[tls] +tls = true +tls-cert = "/test_data/test_repo/cert.pem" +tls-key = "/test_data/test_repo/key.pem" + +[log] +log-level = "info" +log-file = "/test_data/test_repo/rustic.log" diff --git a/maskfile.md b/maskfile.md index 8b2fa8c..b488d28 100644 --- a/maskfile.md +++ b/maskfile.md @@ -316,3 +316,117 @@ PowerShell: [Diagnostics.Process]::Start("mask", "lint").WaitForExit() [Diagnostics.Process]::Start("cargo", "test --all-features").WaitForExit() ``` + +## test-restic + +> Run a restic test against the server + +Bash: + +```bash +export RESTIC_REPOSITORY=rest:http://127.0.0.1:8000/ci_repo +export RESTIC_PASSWORD=restic +export RESTIC_REST_USERNAME=restic +export RESTIC_REST_PASSWORD=restic +restic init +restic backup tests/fixtures/test_data/test_repo_source +restic backup tests/fixtures/test_data/test_repo_source +restic check +restic forget --keep-last 1 --prune +``` + +PowerShell: + +```powershell +$env:RESTIC_REPOSITORY = "rest:http://127.0.0.1:8000/ci_repo"; +$env:RESTIC_PASSWORD = "restic"; +$env:RESTIC_REST_USERNAME = "restic"; +$env:RESTIC_REST_PASSWORD = "restic"; +restic init +restic backup tests/fixtures/test_data/test_repo_source +restic backup tests/fixtures/test_data/test_repo_source +restic check +restic forget --keep-last 1 --prune +``` + +## test-server + +> Run our server for testing + +Bash: + +```bash +cargo run -- serve -c tests/fixtures/test_data/rustic_server.toml -v +``` + +PowerShell: + +```powershell +[Diagnostics.Process]::Start("cargo", "run -- serve -c tests/fixtures/test_data/rustic_server.toml -v").WaitForExit() +``` + + + +## test-restic-server + +> Run a restic server for testing + +Bash: + +```bash +tests/fixtures/rest_server/rest-server.exe --path ./tests/generated/test_storage/ --htpasswd-file ./tests/fixtures/test_data/.htpasswd --log ./tests/fixtures/rest_server/response2.log +``` + +PowerShell: + +```powershell +[Diagnostics.Process]::Start(".\\tests\\fixtures\\rest_server\\rest-server.exe", "--path .\\tests\\generated\\test_storage\\ --htpasswd-file .\\tests\\fixtures\\test_data\\.htpasswd --log .\\tests\\fixtures\\rest_server\\response2.log").WaitForExit() +``` + +## loop-test-server + +> Run our server for testing in a loop + +PowerShell: + +```powershell +watchexec --stop-signal "CTRL+C" -r -w src -w tests -- "cargo run -- serve -c tests/fixtures/test_data/rustic_server.toml -v" +``` + +## hurl + +> Run a hurl test against the server + +Bash: + +```bash +hurl -i tests/fixtures/hurl/endpoints.hurl +``` + +PowerShell: + +```powershell +hurl -i tests/fixtures/hurl/endpoints.hurl +``` + +## debug-test (test) + +> Run a single test with debug output + +- test + - flags: -t, --test + - type: string + - desc: Only run the specified test target + - required + +Bash: + +```bash +$env:RUST_LOG="debug"; cargo test --package rustic_server --lib -- $test --exact --nocapture --show-output +``` + +PowerShell: + +```powershell +$env:RUST_LOG="debug"; cargo test --package rustic_server --lib -- $test --exact --nocapture --show-output +``` diff --git a/src/acl.rs b/src/acl.rs index c4806bb..154283e 100644 --- a/src/acl.rs +++ b/src/acl.rs @@ -1,40 +1,85 @@ -use std::{collections::HashMap, fs, path::PathBuf, sync::OnceLock}; +use std::{collections::BTreeMap, fs, path::PathBuf, sync::OnceLock}; use serde_derive::{Deserialize, Serialize}; +use tracing::debug; use crate::{ - error::{ErrorKind, Result}, + config::AclSettings, + error::{ApiErrorKind, ApiResult, AppResult}, typed_path::TpeKind, }; -//Static storage of our credentials +// Static storage of our credentials pub static ACL: OnceLock = OnceLock::new(); -pub fn init_acl(acl: Acl) -> Result<()> { +pub fn init_acl(acl: Acl) -> AppResult<()> { let _ = ACL.get_or_init(|| acl); Ok(()) } -// Access Types -#[derive(Debug, Clone, PartialEq, PartialOrd, Serialize, Deserialize)] +/// Access Types +/// +// IMPORTANT: The order of the variants is important, as it is used +// to determine the access level! Don't change it! +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Serialize, Deserialize, Copy)] pub enum AccessType { - Nothing, + /// No access + NoAccess, + + /// Force unlock + /// + /// # Note + /// + /// This is a special access type that allows a user to unlock a lock + /// without having to have the Modify access type. + ForceUnlock, + + /// Read-only access Read, + + /// Append access + /// + /// Can be used to add new data to a repository Append, + + /// Modify access + /// + /// Can be used to modify data in a repository, also delete data Modify, } pub trait AclChecker: Send + Sync + 'static { - fn allowed(&self, user: &str, path: &str, tpe: Option, access: AccessType) -> bool; + fn is_allowed(&self, user: &str, path: &str, tpe: Option, access: AccessType) -> bool; +} + +/// ACL for a repo +#[derive(Clone, Serialize, Deserialize, Debug, Default)] +pub struct RepoAcl(BTreeMap); + +impl RepoAcl { + pub fn new() -> Self { + Self::default() + } +} + +impl std::ops::DerefMut for RepoAcl { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } } -// ACL for a repo -type RepoAcl = HashMap; +impl std::ops::Deref for RepoAcl { + type Target = BTreeMap; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} // Acl holds ACLs for all repos #[derive(Clone, Serialize, Deserialize, Debug)] pub struct Acl { - repos: HashMap, + repos: BTreeMap, append_only: bool, private_repo: bool, } @@ -42,7 +87,7 @@ pub struct Acl { impl Default for Acl { fn default() -> Self { Self { - repos: HashMap::new(), + repos: BTreeMap::new(), append_only: true, private_repo: true, } @@ -51,9 +96,9 @@ impl Default for Acl { // read_toml is a helper func that reads the given file in toml // into a Hashmap mapping each user to the whole passwd line -fn read_toml(file_path: &PathBuf) -> Result> { +fn read_toml(file_path: &PathBuf) -> ApiResult> { let s = fs::read_to_string(file_path).map_err(|err| { - ErrorKind::InternalError(format!( + ApiErrorKind::InternalError(format!( "Could not read toml file: {} at {:?}", err, file_path )) @@ -61,13 +106,17 @@ fn read_toml(file_path: &PathBuf) -> Result> { // make the contents static in memory let s = Box::leak(s.into_boxed_str()); - let mut repos: HashMap = toml::from_str(s) - .map_err(|err| ErrorKind::InternalError(format!("Could not parse TOML: {}", err)))?; + let repos: BTreeMap = toml::from_str(s) + .map_err(|err| ApiErrorKind::InternalError(format!("Could not parse TOML: {}", err)))?; + + // TODO: What is this for? + // // copy key "default" into "" - if let Some(default) = repos.get("default") { - let default = default.clone(); - repos.insert("".to_owned(), default); - } + // if let Some(default) = repos.get("default") { + // let default = default.clone(); + // let _ = repos.insert("".to_owned(), default); + // } + Ok(repos) } @@ -76,10 +125,10 @@ impl Acl { append_only: bool, private_repo: bool, file_path: Option, - ) -> Result { + ) -> ApiResult { let repos = match file_path { Some(file_path) => read_toml(&file_path)?, - None => HashMap::new(), + None => BTreeMap::new(), }; Ok(Self { append_only, @@ -88,170 +137,184 @@ impl Acl { }) } + pub fn from_config(settings: &AclSettings) -> ApiResult { + let path = settings.acl_path.clone(); + Self::from_file(settings.append_only, settings.private_repo, path) + } + // The default repo has not been removed from the self.repos list, so we do not need to add here // But we still need to remove the ""-tag that was added during the from_file() - pub fn to_file(&self, pth: &PathBuf) -> Result<()> { - let mut clone = self.repos.clone(); - clone.remove(""); - let toml_string = toml::to_string(&clone).map_err(|err| { - ErrorKind::InternalError(format!( + pub fn to_file(&self, pth: &PathBuf) -> ApiResult<()> { + let repos = self.repos.clone(); + + // TODO: What is this for? Why do we need an empty string key? + // clone.remove(""); + + let toml_string = toml::to_string(&repos).map_err(|err| { + ApiErrorKind::InternalError(format!( "Could not serialize ACL config to TOML value: {}", err )) })?; fs::write(pth, toml_string).map_err(|err| { - ErrorKind::WritingToFileFailed(format!("Could not write ACL file: {}", err)) + ApiErrorKind::WritingToFileFailed(format!("Could not write ACL file: {}", err)) })?; Ok(()) } - // If we do not have a key with ""-value then "default" is also not a key - // Since we guarantee this during the reading of a acl-file - pub fn default_repo_access(&mut self, user: &str, access: AccessType) { - if !self.repos.contains_key("") { - let mut acl = RepoAcl::new(); - acl.insert(user.into(), access); - self.repos.insert("default".to_owned(), acl.clone()); - self.repos.insert("".to_owned(), acl); - } else { - self.repos - .get_mut("default") - .unwrap() - .insert(user.into(), access.clone()); - self.repos - .get_mut("") - .unwrap() - .insert(user.into(), access.clone()); - } - } + // TODO: What is this for? It's unsued and also not using the Entry API + // + // pub fn default_repo_access(&mut self, user: &str, access: AccessType) { + // // If we do not have a key with ""-value then "default" is also not a key + // // Since we guarantee this during the reading of a acl-file + // if !self.repos.contains_key("default") { + // let mut acl = RepoAcl::new(); + // acl.insert(user.into(), access); + // self.repos.insert("default".to_owned(), acl.clone()); + // self.repos.insert("".to_owned(), acl); + // } else { + // self.repos + // .get_mut("default") + // .unwrap() + // .insert(user.into(), access.clone()); + // self.repos + // .get_mut("") + // .unwrap() + // .insert(user.into(), access.clone()); + // } + // } } impl AclChecker for Acl { // allowed yields whether these access to {path, tpe, access} is allowed by user - fn allowed(&self, user: &str, path: &str, tpe: Option, access: AccessType) -> bool { + #[tracing::instrument(level = "debug", skip(self))] + fn is_allowed( + &self, + user: &str, + path: &str, + tpe: Option, + access_type: AccessType, + // _force_unlock: bool, + ) -> bool { // Access to locks is always treated as Read - let access = if tpe == Some(TpeKind::Locks) { + // FIXME: This is a bit of a hack, we should probably have a separate access type for locks + // FIXME: to be able to force remove them with `unlock` + let access = if tpe.is_some_and(|v| v == TpeKind::Locks) { AccessType::Read } else { - access + access_type }; - match self.repos.get(path) { - // We have ACLs for this repo, use them! - Some(repo_acl) => match repo_acl.get(user) { - Some(user_access) => user_access >= &access, - None => false, - }, - // Use standards defined by flags --private-repo and --append-only - None => { - (user == path || !self.private_repo) - && (access != AccessType::Modify || !self.append_only) - } - } + self.repos.get(path).map_or_else(|| { + let is_user_path = user == path; + let is_not_private_repo = !self.private_repo; + let is_not_modify_access = access != AccessType::Modify; + let is_not_append_only = !self.append_only; + + debug!( + "is_user_path: {is_user_path}, is_not_private_repo: {is_not_private_repo}, is_not_modify_access: {is_not_modify_access}, is_not_append_only: {is_not_append_only}", + ); + + // If the user is the path, and the repo is not private, or the user has modify access + // or the repo is not append only, then allow the access + (is_user_path || is_not_private_repo) && (is_not_modify_access || is_not_append_only) + }, |repo_acl| matches!(repo_acl.get(user), Some(user_access) if user_access >= &access)) } } #[cfg(test)] mod tests { - use super::AccessType::*; + use super::AccessType::{Append, Modify, Read}; use super::*; + use crate::test_helpers::server_config; + use rstest::rstest; + use std::env; - #[test] + #[rstest] fn test_static_acl_access_passes() { - let cwd = env::current_dir().unwrap(); - let acl = PathBuf::new() - .join(cwd) - .join("tests") - .join("fixtures") - .join("test_data") - .join("acl.toml"); - - dbg!(&acl); - - let auth = Acl::from_file(false, true, Some(acl)).unwrap(); + let auth = Acl::from_config(&server_config().acl).unwrap(); init_acl(auth).unwrap(); let acl = ACL.get().unwrap(); assert!(&acl.private_repo); assert!(!&acl.append_only); let access = acl.repos.get("test_repo").unwrap(); - let access_type = access.get("test").unwrap(); + let access_type = access.get("restic").unwrap(); assert_eq!(access_type, &Append); } #[test] fn test_allowed_flags_passes() { - let mut acl = Acl { - repos: HashMap::new(), - append_only: true, - private_repo: true, - }; - assert!(!acl.allowed("bob", "sam", Some(TpeKind::Keys), Read)); - assert!(!acl.allowed("bob", "sam", Some(TpeKind::Data), Read)); - assert!(!acl.allowed("bob", "sam", Some(TpeKind::Data), Append)); - assert!(!acl.allowed("bob", "sam", Some(TpeKind::Data), Modify)); - assert!(!acl.allowed("bob", "bob", Some(TpeKind::Data), Modify)); - assert!(acl.allowed("bob", "bob", Some(TpeKind::Locks), Modify)); - assert!(acl.allowed("bob", "bob", Some(TpeKind::Keys), Append)); - assert!(acl.allowed("bob", "bob", Some(TpeKind::Data), Append)); - assert!(acl.allowed("", "", Some(TpeKind::Data), Append)); - assert!(!acl.allowed("bob", "", Some(TpeKind::Data), Read)); + let mut acl = Acl::default(); + + assert!(!acl.is_allowed("bob", "sam", Some(TpeKind::Keys), Read)); + assert!(!acl.is_allowed("bob", "sam", Some(TpeKind::Data), Read)); + assert!(!acl.is_allowed("bob", "sam", Some(TpeKind::Data), Append)); + assert!(!acl.is_allowed("bob", "sam", Some(TpeKind::Data), Modify)); + assert!(!acl.is_allowed("bob", "bob", Some(TpeKind::Data), Modify)); + assert!(acl.is_allowed("bob", "bob", Some(TpeKind::Locks), Modify)); + assert!(acl.is_allowed("bob", "bob", Some(TpeKind::Keys), Append)); + assert!(acl.is_allowed("bob", "bob", Some(TpeKind::Data), Append)); + assert!(acl.is_allowed("", "", Some(TpeKind::Data), Append)); + assert!(!acl.is_allowed("bob", "", Some(TpeKind::Data), Read)); acl.append_only = false; - assert!(!acl.allowed("bob", "sam", Some(TpeKind::Data), Modify)); - assert!(acl.allowed("bob", "bob", Some(TpeKind::Data), Modify)); + assert!(!acl.is_allowed("bob", "sam", Some(TpeKind::Data), Modify)); + assert!(acl.is_allowed("bob", "bob", Some(TpeKind::Data), Modify)); acl.private_repo = false; - assert!(acl.allowed("bob", "sam", Some(TpeKind::Data), Modify)); - assert!(acl.allowed("bob", "bob", Some(TpeKind::Data), Modify)); - assert!(acl.allowed("bob", "", Some(TpeKind::Data), Modify)); + assert!(acl.is_allowed("bob", "sam", Some(TpeKind::Data), Modify)); + assert!(acl.is_allowed("bob", "bob", Some(TpeKind::Data), Modify)); + assert!(acl.is_allowed("bob", "", Some(TpeKind::Data), Modify)); } #[test] fn test_repo_acl_passes() { let mut acl = Acl::default(); - let mut acl_all = HashMap::new(); - acl_all.insert("bob".to_string(), Modify); - acl_all.insert("sam".to_string(), Append); - acl_all.insert("paul".to_string(), Read); - acl.repos.insert("all".to_string(), acl_all); + let mut acl_all = RepoAcl::new(); + let _ = acl_all.insert("bob".to_string(), Modify); + let _ = acl_all.insert("sam".to_string(), Append); + let _ = acl_all.insert("paul".to_string(), Read); + let _ = acl.repos.insert("all".to_string(), acl_all); + + let mut acl_bob = RepoAcl::new(); + let _ = acl_bob.insert("bob".to_string(), Modify); + let _ = acl.repos.insert("bob".to_string(), acl_bob); - let mut acl_bob = HashMap::new(); - acl_bob.insert("bob".to_string(), Modify); - acl.repos.insert("bob".to_string(), acl_bob); + let mut acl_sam = RepoAcl::new(); + let _ = acl_sam.insert("sam".to_string(), Append); + let _ = acl_sam.insert("bob".to_string(), Read); + let _ = acl.repos.insert("sam".to_string(), acl_sam); - let mut acl_sam = HashMap::new(); - acl_sam.insert("sam".to_string(), Append); - acl_sam.insert("bob".to_string(), Read); - acl.repos.insert("sam".to_string(), acl_sam); + insta::assert_debug_snapshot!(acl); // test ACLs for repo all - assert!(acl.allowed("bob", "all", Some(TpeKind::Keys), Modify)); - assert!(!acl.allowed("sam", "all", Some(TpeKind::Keys), Modify)); - assert!(acl.allowed("sam", "all", Some(TpeKind::Keys), Append)); - assert!(acl.allowed("sam", "all", Some(TpeKind::Locks), Modify)); - assert!(!acl.allowed("paul", "all", Some(TpeKind::Data), Append)); - assert!(acl.allowed("paul", "all", Some(TpeKind::Data), Read)); - assert!(acl.allowed("paul", "all", Some(TpeKind::Locks), Modify)); - assert!(!acl.allowed("attack", "all", Some(TpeKind::Data), Modify)); + assert!(acl.is_allowed("bob", "all", Some(TpeKind::Keys), Modify)); + assert!(!acl.is_allowed("sam", "all", Some(TpeKind::Keys), Modify)); + assert!(acl.is_allowed("sam", "all", Some(TpeKind::Keys), Append)); + assert!(acl.is_allowed("sam", "all", Some(TpeKind::Locks), Modify)); + assert!(!acl.is_allowed("paul", "all", Some(TpeKind::Data), Append)); + assert!(acl.is_allowed("paul", "all", Some(TpeKind::Data), Read)); + assert!(acl.is_allowed("paul", "all", Some(TpeKind::Locks), Modify)); + assert!(!acl.is_allowed("attack", "all", Some(TpeKind::Data), Modify)); // test ACLs for repo bob - assert!(acl.allowed("bob", "bob", Some(TpeKind::Data), Modify)); - assert!(!acl.allowed("sam", "bob", Some(TpeKind::Data), Read)); - assert!(!acl.allowed("attack", "bob", Some(TpeKind::Locks), Modify)); + assert!(acl.is_allowed("bob", "bob", Some(TpeKind::Data), Modify)); + assert!(!acl.is_allowed("sam", "bob", Some(TpeKind::Data), Read)); + assert!(!acl.is_allowed("attack", "bob", Some(TpeKind::Locks), Modify)); // test ACLs for repo sam - assert!(!acl.allowed("sam", "sam", Some(TpeKind::Data), Modify)); - assert!(acl.allowed("sam", "sam", Some(TpeKind::Data), Append)); - assert!(!acl.allowed("bob", "sam", Some(TpeKind::Keys), Append)); - assert!(acl.allowed("bob", "sam", Some(TpeKind::Keys), Read)); - assert!(!acl.allowed("attack", "sam", Some(TpeKind::Locks), Read)); + assert!(!acl.is_allowed("sam", "sam", Some(TpeKind::Data), Modify)); + assert!(acl.is_allowed("sam", "sam", Some(TpeKind::Data), Append)); + assert!(!acl.is_allowed("bob", "sam", Some(TpeKind::Keys), Append)); + assert!(acl.is_allowed("bob", "sam", Some(TpeKind::Keys), Read)); + assert!(!acl.is_allowed("attack", "sam", Some(TpeKind::Locks), Read)); // test ACLs for repo paul => fall back to flags - assert!(!acl.allowed("paul", "paul", Some(TpeKind::Data), Modify)); - assert!(acl.allowed("paul", "paul", Some(TpeKind::Data), Append)); - assert!(!acl.allowed("sam", "paul", Some(TpeKind::Data), Read)); + assert!(!acl.is_allowed("paul", "paul", Some(TpeKind::Data), Modify)); + assert!(acl.is_allowed("paul", "paul", Some(TpeKind::Data), Append)); + assert!(!acl.is_allowed("sam", "paul", Some(TpeKind::Data), Read)); } } diff --git a/src/application.rs b/src/application.rs new file mode 100644 index 0000000..797d699 --- /dev/null +++ b/src/application.rs @@ -0,0 +1,91 @@ +//! `RusticServer` Abscissa Application + +use crate::{commands::EntryPoint, config::RusticServerConfig}; +use abscissa_core::{ + application::{self, AppCell}, + config::{self, CfgCell}, + trace, Application, FrameworkError, StandardPaths, +}; +use abscissa_tokio::TokioComponent; + +/// Application state +pub static RUSTIC_SERVER_APP: AppCell = AppCell::new(); + +/// `RusticServer` Application +#[derive(Debug)] +pub struct RusticServerApp { + /// Application configuration. + config: CfgCell, + + /// Application state. + state: application::State, +} + +/// Initialize a new application instance. +/// +/// By default no configuration is loaded, and the framework state is +/// initialized to a default, empty state (no components, threads, etc). +impl Default for RusticServerApp { + fn default() -> Self { + Self { + config: CfgCell::default(), + state: application::State::default(), + } + } +} + +impl Application for RusticServerApp { + /// Entrypoint command for this application. + type Cmd = EntryPoint; + + /// Application configuration. + type Cfg = RusticServerConfig; + + /// Paths to resources within the application. + type Paths = StandardPaths; + + /// Accessor for application configuration. + fn config(&self) -> config::Reader { + self.config.read() + } + + /// Borrow the application state immutably. + fn state(&self) -> &application::State { + &self.state + } + + /// Register all components used by this application. + /// + /// If you would like to add additional components to your application + /// beyond the default ones provided by the framework, this is the place + /// to do so. + fn register_components(&mut self, command: &Self::Cmd) -> Result<(), FrameworkError> { + let mut components = self.framework_components(command)?; + + // Create `TokioComponent` and add it to your app's components here: + components.push(Box::new(TokioComponent::new()?)); + + self.state.components_mut().register(components) + } + + /// Post-configuration lifecycle callback. + /// + /// Called regardless of whether config is loaded to indicate this is the + /// time in app lifecycle when configuration would be loaded if + /// possible. + fn after_config(&mut self, config: Self::Cfg) -> Result<(), FrameworkError> { + // Configure components + self.state.components_mut().after_config(&config)?; + self.config.set_once(config); + Ok(()) + } + + /// Get tracing configuration from command-line options + fn tracing_config(&self, command: &EntryPoint) -> trace::Config { + if command.verbose { + trace::Config::verbose() + } else { + trace::Config::default() + } + } +} diff --git a/src/auth.rs b/src/auth.rs index a6e94b1..77df8b3 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -1,109 +1,109 @@ -use std::{collections::HashMap, fs, io, path::PathBuf}; +use std::{borrow::Borrow, path::PathBuf}; +use abscissa_core::SecretString; use axum::{extract::FromRequestParts, http::request::Parts}; use axum_auth::AuthBasic; use serde_derive::Deserialize; use std::sync::OnceLock; -use crate::error::{ErrorKind, Result}; +use crate::{ + config::HtpasswdSettings, + error::{ApiErrorKind, ApiResult, AppResult}, + htpasswd::{CredentialMap, Htpasswd}, +}; //Static storage of our credentials pub static AUTH: OnceLock = OnceLock::new(); -pub(crate) fn init_auth(auth: Auth) -> Result<()> { +pub(crate) fn init_auth(auth: Auth) -> AppResult<()> { let _ = AUTH.get_or_init(|| auth); Ok(()) } -pub trait AuthChecker: Send + Sync + 'static { - fn verify(&self, user: &str, passwd: &str) -> bool; +#[derive(Debug, Default, Clone)] +pub struct Auth { + users: Option, } -/// read_htpasswd is a helper func that reads the given file in .httpasswd format -/// into a Hashmap mapping each user to the whole passwd line -fn read_htpasswd(file_path: &PathBuf) -> io::Result> { - let s = fs::read_to_string(file_path)?; - // make the contents static in memory - let s = Box::leak(s.into_boxed_str()); - - let mut user_map = HashMap::new(); - for line in s.lines() { - let user = line.split(':').collect::>()[0]; - user_map.insert(user, line); +impl From for Auth { + fn from(users: CredentialMap) -> Self { + Self { users: Some(users) } } - Ok(user_map) } -#[derive(Debug, Default, Clone)] -pub struct Auth { - users: Option>, +impl From for Auth { + fn from(htpasswd: Htpasswd) -> Self { + Self { + users: Some(htpasswd.credentials), + } + } } impl Auth { - pub fn from_file(no_auth: bool, path: &PathBuf) -> io::Result { - Ok(Self { - users: match no_auth { - true => None, - false => Some(read_htpasswd(path)?), - }, + pub fn from_file(disable_auth: bool, path: &PathBuf) -> AppResult { + Ok(if disable_auth { + Self::default() + } else { + Htpasswd::from_file(path)?.into() }) } -} -impl AuthChecker for Auth { + pub fn from_config(settings: &HtpasswdSettings) -> AppResult { + let path = settings.htpasswd_file_or_default(&PathBuf::new()); + Self::from_file(settings.is_disabled(), &path) + } + // verify verifies user/passwd against the credentials saved in users. // returns true if Auth::users is None. - fn verify(&self, user: &str, passwd: &str) -> bool { - match &self.users { - Some(users) => { - matches!(users.get(user), Some(passwd_data) if htpasswd_verify::Htpasswd::from(*passwd_data).check(user, passwd)) - } - None => true, - } + pub fn verify(&self, user: impl Into, passwd: impl Into) -> bool { + let user = user.into(); + let passwd = passwd.into(); + + self.users.as_ref().map_or(true, |users| matches!(users.get(&user), Some(passwd_data) if htpasswd_verify::Htpasswd::from(passwd_data.to_string().borrow()).check(user, passwd))) } } -#[derive(Deserialize)] +#[derive(Deserialize, Debug)] pub struct AuthFromRequest { pub(crate) user: String, - pub(crate) _password: String, + pub(crate) _password: SecretString, } #[async_trait::async_trait] impl FromRequestParts for AuthFromRequest { - type Rejection = ErrorKind; + type Rejection = ApiErrorKind; // FIXME: We also have a configuration flag do run without authentication // This must be handled here too ... otherwise we get an Auth header missing error. - async fn from_request_parts( - parts: &mut Parts, - state: &S, - ) -> std::result::Result { + async fn from_request_parts(parts: &mut Parts, state: &S) -> ApiResult { let checker = AUTH.get().unwrap(); + let auth_result = AuthBasic::from_request_parts(parts, state).await; - tracing::debug!("Got authentication result ...:{:?}", &auth_result); + + tracing::debug!(?auth_result, "[AUTH]"); + return match auth_result { Ok(auth) => { let AuthBasic((user, passw)) = auth; - let password = passw.unwrap_or_else(|| "".to_string()); + let password = passw.unwrap_or_else(String::new); if checker.verify(user.as_str(), password.as_str()) { Ok(Self { user, - _password: password, + _password: password.into(), }) } else { - Err(ErrorKind::UserAuthenticationError(user)) + Err(ApiErrorKind::UserAuthenticationError(user)) } } Err(_) => { - let user = "".to_string(); + let user = String::new(); if checker.verify("", "") { return Ok(Self { user, - _password: "".to_string(), + _password: String::new().into(), }); } - Err(ErrorKind::AuthenticationHeaderError) + Err(ApiErrorKind::AuthenticationHeaderError) } }; } @@ -112,50 +112,41 @@ impl FromRequestParts for AuthFromRequest { #[cfg(test)] mod test { use super::*; - use crate::test_helpers::{basic_auth_header_value, init_test_environment}; + + use crate::test_helpers::{basic_auth_header_value, init_test_environment, server_config}; + use anyhow::Result; - use axum::body::Body; - use axum::http::{Method, Request, StatusCode}; - use axum::routing::get; - use axum::Router; + use axum::{ + body::Body, + http::{Method, Request, StatusCode}, + routing::get, + Router, + }; use http_body_util::BodyExt; - use std::env; + use rstest::{fixture, rstest}; use tower::ServiceExt; - #[test] - fn test_auth_passes() -> Result<()> { - let cwd = env::current_dir()?; - let htaccess = PathBuf::new() - .join(cwd) - .join("tests") - .join("fixtures") - .join("test_data") - .join("htaccess"); - let auth = Auth::from_file(false, &htaccess)?; - assert!(auth.verify("test", "test_pw")); - assert!(!auth.verify("test", "__test_pw")); - - Ok(()) + #[fixture] + fn auth() -> Auth { + let htpasswd = PathBuf::from("tests/fixtures/test_data/.htpasswd"); + Auth::from_file(false, &htpasswd).unwrap() } - #[test] - fn test_auth_from_file_passes() { - let cwd = env::current_dir().unwrap(); - let htaccess = PathBuf::new() - .join(cwd) - .join("tests") - .join("fixtures") - .join("test_data") - .join("htaccess"); + #[rstest] + fn test_auth_passes(auth: Auth) -> Result<()> { + assert!(auth.verify("restic", "restic")); + assert!(!auth.verify("restic", "_restic")); - dbg!(&htaccess); + Ok(()) + } - let auth = Auth::from_file(false, &htaccess).unwrap(); + #[rstest] + fn test_auth_from_file_passes(auth: Auth) { init_auth(auth).unwrap(); let auth = AUTH.get().unwrap(); - assert!(auth.verify("test", "test_pw")); - assert!(!auth.verify("test", "__test_pw")); + assert!(auth.verify("restic", "restic")); + assert!(!auth.verify("restic", "_restic")); } async fn format_auth_basic(AuthBasic((id, password)): AuthBasic) -> String { @@ -169,7 +160,7 @@ mod test { /// The requests which should be returned OK #[tokio::test] async fn test_authentication_passes() { - init_test_environment(); + init_test_environment(server_config()); // ----------------------------------------- // Try good basic @@ -207,7 +198,7 @@ mod test { .method(Method::GET) .header( "Authorization", - basic_auth_header_value("test", Some("test_pw")), + basic_auth_header_value("restic", Some("restic")), ) .body(Body::empty()) .unwrap(); @@ -218,12 +209,12 @@ mod test { let body = resp.into_parts().1; let byte_vec = body.collect().await.unwrap().to_bytes(); let body_str = String::from_utf8(byte_vec.to_vec()).unwrap(); - assert_eq!(body_str, String::from("User = test")); + assert_eq!(body_str, String::from("User = restic")); } #[tokio::test] async fn test_fail_authentication_passes() { - init_test_environment(); + init_test_environment(server_config()); // ----------------------------------------- // Try wrong password rustic_server @@ -235,7 +226,7 @@ mod test { .method(Method::GET) .header( "Authorization", - basic_auth_header_value("test", Some("__test_pw")), + basic_auth_header_value("restic", Some("_restic")), ) .body(Body::empty()) .unwrap(); diff --git a/src/bin/rustic-server.rs b/src/bin/rustic-server.rs index d7c6e7c..d1992f2 100644 --- a/src/bin/rustic-server.rs +++ b/src/bin/rustic-server.rs @@ -1,53 +1,11 @@ -use anyhow::Result; -use clap::{Parser, Subcommand}; -use rustic_server::commands::auth::HtAccessCmd; +//! Main entry point for RusticServer -use rustic_server::commands::serve::{serve, Opts}; +#![deny(warnings, missing_docs, trivial_casts, unused_qualifications)] +#![forbid(unsafe_code)] -#[tokio::main] -async fn main() -> Result<()> { - let cmd = RusticServer::parse(); - cmd.exec().await?; - Ok(()) -} - -/// rustic_server -/// A REST server built in rust for use with rustic and restic. -#[derive(Parser)] -#[command(version, bin_name = "rustic_server", disable_help_subcommand = false)] -struct RusticServer { - #[command(subcommand)] - command: Commands, -} - -#[derive(Subcommand)] -enum Commands { - /// Start the REST web-server. - Serve(Opts), - /// Modify credentials in the credential access file. - Auth(HtAccessCmd), - // Create a configuration from scratch. - //Config, -} +use rustic_server::application::RUSTIC_SERVER_APP; -/// The server configuration file should point us to the `.htaccess` file. -/// If not we complain to the user. -/// -/// To be nice, if the `.htaccess` file pointed to does not exist, then we create it. -/// We do so, even if it is not called `.htaccess`. -impl RusticServer { - pub async fn exec(self) -> Result<()> { - match self.command { - Commands::Auth(cmd) => { - cmd.exec()?; - } - // Commands::Config => { - // rustic_server_configuration()?; - // } - Commands::Serve(opts) => { - serve(opts).await?; - } - } - Ok(()) - } +/// Boot RusticServer +fn main() { + abscissa_core::boot(&RUSTIC_SERVER_APP); } diff --git a/src/commands.rs b/src/commands.rs index 27d5bb7..74352bf 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -1,2 +1,117 @@ -pub mod auth; -pub mod serve; +//! `RusticServer` Subcommands +//! +//! This is where you specify the subcommands of your application. +//! +//! The default application comes with two subcommands: +//! +//! - `start`: launches the application +//! - `--version`: print application version +//! +//! See the `impl Configurable` below for how to specify the path to the +//! application's configuration file. + +mod auth; +mod serve; + +use crate::{ + commands::{auth::AuthCmd, serve::ServeCmd}, + config::RusticServerConfig, +}; +use abscissa_core::{config::Override, Command, Configurable, FrameworkError, Runnable}; +use clap::builder::{ + styling::{AnsiColor, Effects}, + Styles, +}; +use std::path::PathBuf; + +/// `RusticServer` Configuration Filename +pub const CONFIG_FILE: &str = "rustic_server.toml"; + +/// `RusticServer` Subcommands +/// Subcommands need to be listed in an enum. +#[derive(clap::Parser, Command, Debug, Runnable)] +pub enum RusticServerCmd { + /// Authentication for users. Add, update, delete, or list users. + Auth(AuthCmd), + + /// Start a server with the specified configuration + Serve(ServeCmd), +} + +fn styles() -> Styles { + Styles::styled() + .header(AnsiColor::Red.on_default() | Effects::BOLD) + .usage(AnsiColor::Red.on_default() | Effects::BOLD) + .literal(AnsiColor::Blue.on_default() | Effects::BOLD) + .placeholder(AnsiColor::Green.on_default()) +} + +/// Entry point for the application. It needs to be a struct to allow using subcommands! +#[derive(clap::Parser, Command, Debug)] +#[command(author, about, version)] +#[command(author, about, name="rustic-server", styles=styles(), version = env!("CARGO_PKG_VERSION"))] +pub struct EntryPoint { + #[command(subcommand)] + cmd: RusticServerCmd, + + /// Enable verbose logging + #[arg(short, long, global = true)] + pub verbose: bool, + + /// Use the specified config file + #[arg(short, long, global = true)] + pub config: Option, +} + +impl Runnable for EntryPoint { + fn run(&self) { + self.cmd.run(); + } +} + +/// This trait allows you to define how application configuration is loaded. +impl Configurable for EntryPoint { + /// Location of the configuration file + fn config_path(&self) -> Option { + // Check if the config file exists, and if it does not, ignore it. + // If you'd like for a missing configuration file to be a hard error + // instead, always return `Some(CONFIG_FILE)` here. + let filename = self + .config + .as_ref() + .map(PathBuf::from) + .unwrap_or_else(|| CONFIG_FILE.into()); + + if filename.exists() { + Some(filename) + } else { + None + } + } + + /// Apply changes to the config after it's been loaded, e.g. overriding + /// values in a config file using command-line options. + /// + /// This can be safely deleted if you don't want to override config + /// settings from command-line options. + fn process_config( + &self, + config: RusticServerConfig, + ) -> Result { + match &self.cmd { + RusticServerCmd::Serve(cmd) => cmd.override_config(config), + _ => Ok(config), + } + } +} + +#[cfg(test)] +mod tests { + use crate::commands::EntryPoint; + use clap::CommandFactory; + + #[test] + fn verify_cli() { + EntryPoint::command().debug_assert(); + } +} diff --git a/src/commands/auth.rs b/src/commands/auth.rs index 47d6138..7dd81b7 100644 --- a/src/commands/auth.rs +++ b/src/commands/auth.rs @@ -1,104 +1,50 @@ -use crate::config::auth_file::HtAccess; -use anyhow::Result; -use clap::{Args, Parser, Subcommand}; -use std::fs; +//! `auth` subcommand + use std::path::PathBuf; -use std::process::exit; -#[derive(Parser)] -#[command()] -pub struct HtAccessCmd { +use abscissa_core::{status_err, Application, Command, Runnable, Shutdown}; +use anyhow::{bail, Result}; +use clap::{Args, Parser, Subcommand}; + +use crate::{htpasswd::Htpasswd, prelude::RUSTIC_SERVER_APP}; + +/// `auth` subcommand +/// +/// The `Parser` proc macro generates an option parser based on the struct +/// definition, and is defined in the `clap` crate. See their documentation +/// for a more comprehensive example: +/// +/// +#[derive(Command, Debug, Parser)] +pub struct AuthCmd { #[command(subcommand)] command: Commands, } -/// The server configuration file should point us to the `.htaccess` file. -/// If not we complain to the user. -/// -/// To be nice, if the `.htaccess` file pointed to does not exist, then we create it. -/// We do so, even if it is not called `.htaccess`. -impl HtAccessCmd { - pub fn exec(&self) -> Result<()> { - match &self.command { - Commands::Add(arg) => { - add(arg)?; - } - Commands::Update(arg) => { - update(arg)?; - } - Commands::Delete(arg) => { - delete(arg)?; - } - Commands::List(arg) => { - print(arg)?; - } - }; - Ok(()) - } - - fn check(path: &PathBuf) { - //Check - if path.exists() { - if !path.is_file() { - println!( - "Error: Given path leads to a folder, not a file: \n\t{}", - path.to_string_lossy() - ); - exit(0); - } - match fs::OpenOptions::new() - //Test: "open for writing" (fail fast) - .create(false) - .truncate(false) - .append(true) - .open(path) - { - Ok(_) => {} - Err(e) => { - println!( - "No write access to the htaccess file.{}", - path.to_string_lossy() - ); - println!("Got error: {}.", e); - exit(0); - } - } - } else { - //"touch server_config file" (fail fast) - match fs::OpenOptions::new() - .create(true) - .truncate(false) - .write(true) - .open(path) - { - Ok(_) => {} - Err(e) => { - println!( - "Failed to create empty server configuration file.{}", - &path.to_string_lossy() - ); - println!("Got error: {}.", e); - exit(0); - } - } +impl Runnable for AuthCmd { + /// Start the application. + fn run(&self) { + if let Err(err) = self.inner_run() { + status_err!("{}", err); + RUSTIC_SERVER_APP.shutdown(Shutdown::Crash); } } } -#[derive(Subcommand)] +#[derive(Subcommand, Debug)] enum Commands { - /// Add a new credential to the .htaccess file. + /// Add a new credential to the .htpasswd file. /// If the username already exists it will update the password only. Add(AddArg), /// Change the password for an existing user. Update(AddArg), - /// Delete an existing credential from the .htaccess file. + /// Delete an existing credential from the .htpasswd file. Delete(DelArg), - /// List all users known in the .htaccess file. + /// List all users known in the .htpasswd file. List(PrintArg), } -#[derive(Args)] +#[derive(Args, Debug)] struct AddArg { ///Path to authorization file #[arg(short = 'f')] @@ -111,7 +57,7 @@ struct AddArg { password: String, } -#[derive(Args)] +#[derive(Args, Debug)] struct DelArg { ///Path to authorization file #[arg(short = 'f')] @@ -121,27 +67,93 @@ struct DelArg { user: String, } -#[derive(Args)] +#[derive(Args, Debug)] struct PrintArg { ///Path to authorization file #[arg(short = 'f')] pub config_path: PathBuf, } +/// The server configuration file should point us to the `.htpasswd` file. +/// If not we complain to the user. +/// +/// To be nice, if the `.htpasswd` file pointed to does not exist, then we create it. +/// We do so, even if it is not called `.htpasswd`. +impl AuthCmd { + pub fn inner_run(&self) -> Result<()> { + match &self.command { + Commands::Add(arg) => { + add(arg)?; + } + Commands::Update(arg) => { + update(arg)?; + } + Commands::Delete(arg) => { + delete(arg)?; + } + Commands::List(arg) => { + print(arg)?; + } + }; + Ok(()) + } +} + +fn check(path: &PathBuf) -> Result<()> { + //Check + if path.exists() { + if !path.is_file() { + bail!( + "Error: Given path leads to a folder, not a file: {}", + path.to_string_lossy() + ); + } + + if let Err(err) = std::fs::OpenOptions::new() + //Test: "open for writing" (fail fast) + .create(false) + .truncate(false) + .append(true) + .open(path) + { + bail!( + "No write access to the htpasswd file: {} due to {}", + path.to_string_lossy(), + err + ); + }; + } else { + //"touch server_config file" (fail fast) + if let Err(err) = std::fs::OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .open(path) + { + bail!( + "Failed to create empty server configuration file: {} due to {}", + &path.to_string_lossy(), + err + ); + }; + }; + + Ok(()) +} + fn add(arg: &AddArg) -> Result<()> { let ht_access_path = PathBuf::from(&arg.config_path); - HtAccessCmd::check(&ht_access_path); - let mut ht_access = HtAccess::from_file(&ht_access_path)?; + check(&ht_access_path)?; + let mut ht_access = Htpasswd::from_file(&ht_access_path)?; if ht_access.users().contains(&arg.user.to_string()) { - println!( + bail!( "User '{}' exists; use update to change password. No changes were made.", arg.user.as_str() ); - exit(0); } - ht_access.update(arg.user.as_str(), arg.password.as_str()); + let _ = ht_access.update(arg.user.as_str(), arg.password.as_str()); ht_access.to_file()?; Ok(()) @@ -149,43 +161,42 @@ fn add(arg: &AddArg) -> Result<()> { fn update(arg: &AddArg) -> Result<()> { let ht_access_path = PathBuf::from(&arg.config_path); - HtAccessCmd::check(&ht_access_path); - let mut ht_access = HtAccess::from_file(&ht_access_path)?; + check(&ht_access_path)?; + let mut ht_access = Htpasswd::from_file(&ht_access_path)?; if !ht_access.credentials.contains_key(arg.user.as_str()) { - println!( + bail!( "I can not find a user with name {}. Use add command?", arg.user.as_str() ); - exit(0); } - ht_access.update(arg.user.as_str(), arg.password.as_str()); + let _ = ht_access.update(arg.user.as_str(), arg.password.as_str()); ht_access.to_file()?; Ok(()) } fn delete(arg: &DelArg) -> Result<()> { let ht_access_path = PathBuf::from(&arg.config_path); - HtAccessCmd::check(&ht_access_path); - let mut ht_access = HtAccess::from_file(&ht_access_path)?; + check(&ht_access_path)?; + let mut ht_access = Htpasswd::from_file(&ht_access_path)?; if ht_access.users().contains(&arg.user.to_string()) { println!("Deleting user with name {}.", arg.user.as_str()); - ht_access.delete(arg.user.as_str()); + let _ = ht_access.delete(arg.user.as_str()); ht_access.to_file()?; } else { println!( "Could not find a user with name {}. No changes were made.", arg.user.as_str() - ) + ); }; Ok(()) } fn print(arg: &PrintArg) -> Result<()> { let ht_access_path = PathBuf::from(&arg.config_path); - HtAccessCmd::check(&ht_access_path); - let ht_access = HtAccess::from_file(&ht_access_path)?; + check(&ht_access_path)?; + let ht_access = Htpasswd::from_file(&ht_access_path)?; println!("Listing users in the access file for a rustic_server."); println!( diff --git a/src/commands/serve.rs b/src/commands/serve.rs index 419fc3a..f3482c0 100644 --- a/src/commands/serve.rs +++ b/src/commands/serve.rs @@ -1,155 +1,129 @@ -use std::{ - net::{SocketAddr, ToSocketAddrs}, - path::PathBuf, - str::FromStr, -}; +//! `serve` subcommand +use abscissa_core::{ + config::Override, + status_err, + tracing::{debug, info}, + Application, Command, FrameworkError, Runnable, Shutdown, +}; +use anyhow::Result; use clap::Parser; +use conflate::Merge; use crate::{ acl::Acl, auth::Auth, - config::server::ServerConfiguration, - error::{ErrorKind, Result}, - log::{init_trace_from, init_tracing}, + config::RusticServerConfig, + error::{AppResult, ErrorKind}, + prelude::RUSTIC_SERVER_APP, storage::LocalStorage, web::start_web_server, }; -pub async fn serve(opts: Opts) -> Result<()> { - match &opts.config { - Some(config) => { - let config_path = PathBuf::from(config); - let server_config = ServerConfiguration::from_file(&config_path)?; +/// `serve` subcommand +/// +/// The `Parser` proc macro generates an option parser based on the struct +/// definition, and is defined in the `clap` crate. See their documentation +/// for a more comprehensive example: +/// +/// +#[derive(Command, Debug, Parser)] +pub struct ServeCmd { + /// Server settings + #[clap(flatten)] + context: RusticServerConfig, +} - if let Some(level) = server_config.log_level { - init_trace_from(&level); - } else { - init_tracing(); +impl Override for ServeCmd { + fn override_config( + &self, + mut config: RusticServerConfig, + ) -> Result { + config.merge(self.context.clone()); + Ok(config) + } +} + +impl Runnable for ServeCmd { + /// Start the application. + fn run(&self) { + if let Err(tokio_err) = abscissa_tokio::run(&RUSTIC_SERVER_APP, async { + if let Err(err) = self.inner_run().await { + status_err!("{}", err); + RUSTIC_SERVER_APP.shutdown(Shutdown::Crash); } + }) { + status_err!("{}", tokio_err); + RUSTIC_SERVER_APP.shutdown(Shutdown::Crash); + }; + } +} - let root = server_config.server.common_root_path.clone(); - - // Repository storage - //----------------------------------- - let storage_path = if root.is_empty() { - PathBuf::from(server_config.repos.storage_path) - } else { - assert!(!server_config.repos.storage_path.starts_with('/')); - PathBuf::from(root.clone()).join(server_config.repos.storage_path) - }; - let storage = LocalStorage::try_new(&storage_path).map_err(|err| { - ErrorKind::GeneralStorageError(format!("Could not create storage: {}", err)) - })?; +impl ServeCmd { + pub async fn inner_run(&self) -> AppResult<()> { + let server_config = RUSTIC_SERVER_APP.config(); - // Authorization user/password - //----------------------------------- - let auth_config = server_config.authorization; - let no_auth = !auth_config.use_auth; - let path = match auth_config.auth_path { - None => PathBuf::new(), - Some(p) => { - if root.is_empty() { - PathBuf::from(p) - } else { - assert!(!p.starts_with('/')); - PathBuf::from(root.clone()).join(p) - } - } - }; - let auth = Auth::from_file(no_auth, &path).map_err(|err| { - ErrorKind::InternalError(format!("Could not read file: {} at {:?}", err, path)) - })?; + debug!("Successfully loaded configuration: {:?}", server_config); - // Access control to the repositories - //----------------------------------- - let acl_config = server_config.access_control; - let path = acl_config.acl_path.map(|p| { - if root.is_empty() { - PathBuf::from(p) - } else { - assert!(!p.starts_with('/')); - PathBuf::from(root.clone()).join(p) - } - }); - let acl = Acl::from_file(acl_config.append_only, acl_config.private_repo, path)?; - - // Server definition - //----------------------------------- - let s_addr = server_config.server; - let s_str = format!("{}:{}", s_addr.host_dns_name, s_addr.port); - tracing::info!("[serve] Listening on: {}", &s_str); - let socket = s_str.to_socket_addrs().unwrap().next().unwrap(); - start_web_server(acl, auth, storage, socket, false, None, opts.key).await?; - } - None => { - init_trace_from(&opts.log); + let Some(data_dir) = &server_config.storage.data_dir else { + return Err(ErrorKind::MissingUserInput + .context("No data directory specified".to_string()) + .into()); + }; - let storage = LocalStorage::try_new(&opts.path).map_err(|err| { - ErrorKind::GeneralStorageError(format!("Could not create storage: {}", err)) - })?; + debug!("Data directory: {:?}", data_dir); + + if !data_dir.exists() { + debug!("Creating data directory: {:?}", data_dir); - let auth = - Auth::from_file(opts.no_auth, &opts.path.join(".htpasswd")).map_err(|err| { - ErrorKind::InternalError(format!( - "Could not read auth file: {} at {:?}", - err, opts.path - )) - })?; - let acl = Acl::from_file(opts.append_only, opts.private_repo, opts.acl)?; - - start_web_server( - acl, - auth, - storage, - SocketAddr::from_str(&opts.listen).unwrap(), - false, - None, - opts.key, - ) - .await?; + std::fs::create_dir_all(data_dir).map_err(|err| { + ErrorKind::GeneralStorageError + .context(format!("Could not create data directory: {}", err)) + })?; } - } - Ok(()) -} + let storage = LocalStorage::try_new(data_dir).map_err(|err| { + ErrorKind::GeneralStorageError.context(format!("Could not create storage: {}", err)) + })?; + + debug!("Successfully created storage: {:?}", storage); + + let auth = Auth::from_config(&server_config.auth).map_err(|err| { + ErrorKind::GeneralStorageError + .context(format!("Could not create `htpasswd` due to {err}",)) + })?; + + debug!("Successfully created auth: {:?}", auth); + + let acl = Acl::from_config(&server_config.acl).map_err(|err| { + ErrorKind::GeneralStorageError.context(format!("Could not create ACL due to {err}")) + })?; -/// A REST server build in rust for use with restic -#[derive(Parser)] -#[command(name = "rustic-server")] -#[command(bin_name = "rustic-server")] -pub struct Opts { - /// Server configuration file; Overrides all other options. - #[arg(short, long)] - pub config: Option, - /// listen address - #[arg(short, long, default_value = "localhost:8000")] - pub listen: String, - /// data directory - #[arg(short, long, default_value = "/tmp/restic")] - pub path: PathBuf, - /// disable .htpasswd authentication - #[arg(long)] - pub no_auth: bool, - /// Full path including file name to read from. Governs per-repo ACLs - #[arg(long)] - pub acl: Option, - /// set standard acl to append only mode - #[arg(long)] - pub append_only: bool, - /// set standard acl to only access private repos - #[arg(long)] - pub private_repo: bool, - /// turn on TLS support - #[arg(long)] - pub tls: bool, - /// TLS certificate path - #[arg(long)] - pub cert: Option, - /// TLS key path - #[arg(long)] - pub key: Option, - /// logging level (Off/Error/Warn/Info/Debug/Trace) - #[arg(long, default_value = "Info")] - pub log: String, + debug!("Successfully created acl: {:?}", acl); + + let socket = server_config.server.listen.parse().map_err(|err| { + ErrorKind::GeneralStorageError + .context(format!("Could not create socket address: {err}")) + })?; + + info!("[serve] Starting web server ..."); + + _ = tokio::spawn(async move { + tokio::signal::ctrl_c().await.unwrap(); + info!("[serve] Shutting down ..."); + RUSTIC_SERVER_APP.shutdown(Shutdown::Graceful); + }); + + start_web_server( + acl, + auth, + storage, + socket, + &server_config.tls, + &server_config.log, + ) + .await?; + + Ok(()) + } } diff --git a/src/config.rs b/src/config.rs index 170f0bd..6aaf84a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,2 +1,285 @@ -pub mod auth_file; -pub mod server; +//! `RusticServer` Config +//! +//! See instructions in `commands.rs` to specify the path to your +//! application's configuration file and/or command-line options +//! for specifying it. + +use std::{ + fs, + path::{Path, PathBuf}, +}; + +use clap::Parser; +use conflate::Merge; +use serde::{Deserialize, Serialize}; + +use crate::error::{AppResult, ErrorKind}; + +/// `RusticServer` Configuration +#[derive(Clone, Debug, Deserialize, Serialize, Default, Merge, Parser)] +#[serde(deny_unknown_fields, rename_all = "kebab-case", default)] +pub struct RusticServerConfig { + /// Server settings + #[clap(flatten)] + pub server: ConnectionSettings, + + /// Storage settings + #[clap(flatten)] + pub storage: StorageSettings, + + /// Htpasswd settings + #[clap(flatten)] + pub auth: HtpasswdSettings, + + /// Acl Settings + #[clap(flatten)] + pub acl: AclSettings, + + /// Optional TLS Settings + #[clap(flatten)] + pub tls: TlsSettings, + + /// Optional Logging settings + #[clap(flatten)] + pub log: LogSettings, +} + +#[derive(Clone, Serialize, Deserialize, Debug, Merge, Parser)] +#[serde(deny_unknown_fields, default, rename_all = "kebab-case")] +pub struct ConnectionSettings { + /// IP address and port to bind to + #[arg(long, default_value = "127.0.0.1:8000")] + #[merge(skip)] + pub listen: String, +} + +impl Default for ConnectionSettings { + fn default() -> Self { + Self { + listen: "127.0.0.1:8000".to_string(), + } + } +} + +#[derive(Clone, Serialize, Deserialize, Debug, Default, Merge, Parser)] +#[serde(deny_unknown_fields, default, rename_all = "kebab-case")] +pub struct LogSettings { + #[merge(strategy = conflate::option::overwrite_none)] + #[clap(skip)] + pub log_level: Option, + + /// Write HTTP requests in the combined log format to the specified filename + /// + /// If provided, the application will write logs to the specified file. + /// If `None`, logging will be disabled or will use a default logging mechanism. + #[merge(strategy = conflate::option::overwrite_none)] + #[arg(long = "log")] + pub log_file: Option, +} + +impl LogSettings { + pub const fn is_disabled(&self) -> bool { + self.log_file.is_none() + } +} + +#[derive(Clone, Serialize, Deserialize, Debug, Merge, Parser)] +#[serde(deny_unknown_fields, default, rename_all = "kebab-case")] +pub struct StorageSettings { + /// Optional path to the data directory + /// + /// If `None`, the default directory will be used. + #[arg(long = "path", default_value = "/tmp/restic")] + #[merge(strategy = conflate::option::overwrite_none)] + pub data_dir: Option, + + /// Optional maximum size of the repository in Bytes + #[arg(short, long)] + #[merge(strategy = conflate::option::overwrite_none)] + pub max_size: Option, +} + +impl Default for StorageSettings { + fn default() -> Self { + Self { + data_dir: Some("/tmp/restic".into()), + max_size: None, + } + } +} + +#[derive(Clone, Serialize, Deserialize, Debug, Default, Merge, Parser)] +#[serde(deny_unknown_fields, rename_all = "kebab-case")] +pub struct TlsSettings { + /// Enable TLS support + #[arg(long)] + #[merge(strategy = conflate::bool::overwrite_false)] + pub tls: bool, + + /// Optional path to the TLS key file + #[arg(long, requires = "tls")] + #[merge(strategy = conflate::option::overwrite_none)] + pub tls_key: Option, + + /// Optional path to the TLS certificate file + #[arg(long, requires = "tls")] + #[merge(strategy = conflate::option::overwrite_none)] + pub tls_cert: Option, +} + +// TODO: This assumes that it makes no sense to have one but not the other +// So we if acl_path is given, we require the auth_path too. +#[derive(Clone, Serialize, Deserialize, Debug, Default, Merge, Parser)] +#[serde(deny_unknown_fields, rename_all = "kebab-case")] +pub struct HtpasswdSettings { + /// Disable .htpasswd authentication + #[arg(long = "no-auth")] + #[merge(strategy = conflate::bool::overwrite_false)] + pub disable_auth: bool, + + /// Optional location of .htpasswd file (default: "/.htpasswd") + #[arg(long)] + #[merge(strategy = conflate::option::overwrite_none)] + pub htpasswd_file: Option, +} + +impl HtpasswdSettings { + pub fn htpasswd_file_or_default(&self, data_dir: &Path) -> PathBuf { + self.htpasswd_file.clone().unwrap_or_else(|| { + let mut path = data_dir.to_path_buf(); + path.push(".htpasswd"); + path + }) + } + + pub const fn is_disabled(&self) -> bool { + self.disable_auth + } +} + +// This assumes that it makes no sense to have one but not the other +// So we if acl_path is given, we require the auth_path too. +#[derive(Clone, Serialize, Deserialize, Debug, Default, Merge, Parser)] +#[serde(deny_unknown_fields, rename_all = "kebab-case")] +pub struct AclSettings { + /// Full path including file name to read from. Governs per-repo ACLs. + #[arg(long)] + #[merge(strategy = conflate::option::overwrite_none)] + pub acl_path: Option, + + /// Users can only access their private repo + #[arg(long)] + #[merge(strategy = conflate::bool::overwrite_false)] + pub private_repo: bool, + + /// Enable append only mode + #[arg(long)] + #[merge(strategy = conflate::bool::overwrite_false)] + pub append_only: bool, +} + +impl RusticServerConfig { + pub fn from_file(pth: &Path) -> AppResult { + let s = fs::read_to_string(pth)?; + + let config: Self = toml::from_str(&s).map_err(|err| { + ErrorKind::Io.context(format!( + "Could not parse file: {} due to {}", + pth.to_string_lossy(), + err + )) + })?; + + Ok(config) + } + + pub fn to_file(&self, pth: &Path) -> AppResult<()> { + let toml_string = toml::to_string(&self).map_err(|err| { + ErrorKind::Io.context(format!( + "Could not serialize configuration to toml due to {}", + err + )) + })?; + + fs::write(pth, toml_string)?; + + Ok(()) + } +} + +#[cfg(test)] +mod test { + use std::path::{Path, PathBuf}; + + use anyhow::Result; + use insta::assert_debug_snapshot; + use rstest::{fixture, rstest}; + + use crate::config::RusticServerConfig; + + #[fixture] + fn rustic_server_config() -> PathBuf { + Path::new("tests") + .join("fixtures") + .join("test_data") + .join("rustic_server.toml") + } + + #[rstest] + fn test_file_read(rustic_server_config: PathBuf) -> Result<()> { + let config = RusticServerConfig::from_file(&rustic_server_config)?; + assert_debug_snapshot!(config); + Ok(()) + } + + // #[test] + // fn test_file_write() -> Result<()> { + // let server_path = Path::new("tmp_test_data").join("rustic"); + // fs::create_dir_all(&server_path)?; + + // let server = Server { + // host_dns_name: "127.0.0.1".to_string(), + // port: 2222, + // common_root_path: "".into(), + // }; + + // let tls: Option = Some(TLS { + // key_path: "somewhere".to_string(), + // cert_path: "somewhere/else".to_string(), + // }); + + // let repos: Repos = Repos { + // storage_path: server_path.join("repos").to_string_lossy().into(), + // }; + + // let auth = Authorization { + // auth_path: Some("auth_path".to_string()), + // use_auth: true, + // }; + + // let access = AccessControl { + // acl_path: Some("acl_path".to_string()), + // private_repo: true, + // append_only: true, + // }; + + // let log = "debug".to_string(); + + // // Try to write + // let config = ServerConfiguration { + // log_level: Some(log), + // server, + // repos, + // tls, + // authorization: auth, + // access_control: access, + // }; + // let config_file = server_path.join("rustic_server.toml"); + // config.to_file(&config_file)?; + + // // Try to read + // let _tmp_config = ServerConfiguration::from_file(&config_file)?; + + // Ok(()) + // } +} diff --git a/src/config/auth_file.rs b/src/config/auth_file.rs deleted file mode 100644 index d3b7e3a..0000000 --- a/src/config/auth_file.rs +++ /dev/null @@ -1,193 +0,0 @@ -use std::{ - collections::HashMap, - fmt::{Display, Formatter}, - fs::{self, read_to_string}, - io::Write, - path::PathBuf, -}; - -use htpasswd_verify::md5::{format_hash, md5_apr1_encode}; -use rand::{distributions::Alphanumeric, thread_rng, Rng}; - -use crate::error::{ErrorKind, Result}; - -pub mod constants { - pub(super) const SALT_LEN: usize = 8; -} - -#[derive(Clone)] -pub struct HtAccess { - pub path: PathBuf, - pub credentials: HashMap, -} - -impl HtAccess { - pub fn from_file(pth: &PathBuf) -> Result { - let mut c: HashMap = HashMap::new(); - if pth.exists() { - read_to_string(pth) - .map_err(|err| { - ErrorKind::InternalError(format!( - "Could not read HtAccess file: {} at {:?}", - err, pth - )) - })? - .lines() // split the string into an iterator of string slices - .map(String::from) // make each slice into a string - .for_each(|line| match Credential::from_line(line) { - None => {} - Some(cred) => { - c.insert(cred.name.clone(), cred); - } - }) - } - Ok(HtAccess { - path: pth.clone(), - credentials: c, - }) - } - - pub fn get(&self, name: &str) -> Option<&Credential> { - self.credentials.get(name) - } - - pub fn users(&self) -> Vec { - self.credentials.keys().cloned().collect() - } - - /// Update can be used for both new, and existing credentials - pub fn update(&mut self, name: &str, pass: &str) { - let cred = Credential::new(name, pass); - self.insert(cred); - } - - /// Removes one credential by username - pub fn delete(&mut self, name: &str) { - self.credentials.remove(name); - } - - fn insert(&mut self, cred: Credential) { - self.credentials.insert(cred.name.clone(), cred); - } - - pub fn to_file(&self) -> Result<()> { - let mut file = fs::OpenOptions::new() - .create(true) - .truncate(false) - .write(true) - .open(&self.path) - .map_err(|err| { - ErrorKind::OpeningFileFailed(format!( - "Could not open HtAccess file: {} at {:?}", - err, self.path - )) - })?; - - for (_n, c) in self.credentials.iter() { - let _e = file.write(c.to_line().as_bytes()).map_err(|err| { - ErrorKind::WritingToFileFailed(format!( - "Could not write to HtAccess file: {} at {:?}", - err, self.path - )) - }); - } - Ok(()) - } -} - -#[derive(Clone)] -pub struct Credential { - name: String, - hash_val: Option, - pw: Option, -} - -impl Credential { - pub fn new(name: &str, pass: &str) -> Self { - let salt: String = thread_rng() - .sample_iter(&Alphanumeric) - .take(constants::SALT_LEN) - .map(char::from) - .collect(); - let hash = md5_apr1_encode(pass, salt.as_str()); - let hash = format_hash(hash.as_str(), salt.as_str()); - - Credential { - name: name.into(), - hash_val: Some(hash), - pw: Some(pass.into()), - } - } - - /// Returns a credential struct from a htaccess file line - /// Of cause without password :-) - pub fn from_line(line: String) -> Option { - let spl: Vec<&str> = line.split(':').collect(); - if !spl.is_empty() { - return Some(Credential { - name: spl.first().unwrap().to_string(), - hash_val: Some(spl.get(1).unwrap().to_string()), - pw: None, - }); - } - None - } - - pub fn to_line(&self) -> String { - if self.hash_val.is_some() { - format!( - "{}:{}\n", - self.name.as_str(), - self.hash_val.as_ref().unwrap() - ) - } else { - "".into() - } - } -} - -impl Display for Credential { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - writeln!(f, "Struct: Credential")?; - writeln!(f, "\tUser: {}", self.name.as_str())?; - writeln!(f, "\tHash: {}", self.hash_val.as_ref().unwrap())?; - if self.pw.is_none() { - writeln!(f, "\tPassword: None")?; - } else { - writeln!(f, "\tPassword: {}", &self.pw.as_ref().unwrap())?; - } - Ok(()) - } -} - -#[cfg(test)] -mod test { - use crate::auth::{Auth, AuthChecker}; - use crate::config::auth_file::HtAccess; - use anyhow::Result; - use std::fs; - use std::path::Path; - - #[test] - fn test_htaccess() -> Result<()> { - let htaccess_pth = Path::new("tmp_test_data").join("rustic"); - fs::create_dir_all(&htaccess_pth).unwrap(); - - let ht_file = htaccess_pth.join("htaccess"); - - let mut ht = HtAccess::from_file(&ht_file)?; - ht.update("Administrator", "stuff"); - ht.update("backup-user", "its_me"); - ht.to_file()?; - - let ht = HtAccess::from_file(&ht_file)?; - assert!(ht.get("Administrator").is_some()); - assert!(ht.get("backup-user").is_some()); - - let auth = Auth::from_file(false, &ht_file).unwrap(); - assert!(auth.verify("Administrator", "stuff")); - assert!(auth.verify("backup-user", "its_me")); - - Ok(()) - } -} diff --git a/src/config/server.rs b/src/config/server.rs deleted file mode 100644 index 84e9539..0000000 --- a/src/config/server.rs +++ /dev/null @@ -1,166 +0,0 @@ -use std::{fs, path::Path}; - -use serde_derive::{Deserialize, Serialize}; - -use crate::error::{ErrorKind, Result}; - -#[derive(Clone, Serialize, Deserialize, Debug)] -pub struct ServerConfiguration { - pub server: Server, - pub repos: Repos, - pub tls: Option, - pub authorization: Authorization, - pub access_control: AccessControl, - pub log_level: Option, -} - -#[derive(Clone, Serialize, Deserialize, Debug)] -pub struct Repos { - pub storage_path: String, -} - -// This assumes that it makes no sense to have one but not the other -// So we if acl_path is given, we require the auth_path too. -#[derive(Clone, Serialize, Deserialize, Debug)] -pub struct AccessControl { - pub acl_path: Option, - //if not private all repo are accessible for any user - pub private_repo: bool, - //force access to append only for all - pub append_only: bool, -} - -// This assumes that it makes no sense to have one but not the other -// So we if acl_path is given, we require the auth_path too. -#[derive(Clone, Serialize, Deserialize, Debug)] -pub struct Authorization { - pub auth_path: Option, - //use authorization file - pub use_auth: bool, -} - -#[derive(Clone, Serialize, Deserialize, Debug)] -pub struct Server { - pub host_dns_name: String, - pub port: usize, - pub common_root_path: String, -} - -#[derive(Clone, Serialize, Deserialize, Debug)] -pub struct TLS { - pub key_path: String, - pub cert_path: String, -} - -impl ServerConfiguration { - pub fn from_file(pth: &Path) -> Result { - let s = fs::read_to_string(pth).map_err(|err| { - ErrorKind::InternalError(format!( - "Could not read server config file: {} at {:?}", - err, pth - )) - })?; - let config: ServerConfiguration = toml::from_str(&s).map_err(|err| { - ErrorKind::InternalError(format!("Could not parse TOML file: {}", err)) - })?; - Ok(config) - } - - pub fn to_file(&self, pth: &Path) -> Result<()> { - let toml_string = toml::to_string(&self).map_err(|err| { - ErrorKind::InternalError(format!( - "Could not serialize SeverConfig to TOML value: {}", - err - )) - })?; - fs::write(pth, toml_string).map_err(|err| { - ErrorKind::InternalError(format!("Could not write ServerConfig to file: {}", err)) - })?; - Ok(()) - } -} - -#[cfg(test)] -mod test { - use std::{ - fs, - path::{Path, PathBuf}, - }; - - use anyhow::Result; - use rstest::*; - - use super::{AccessControl, Authorization, Repos, Server, ServerConfiguration, TLS}; - - #[fixture] - fn rustic_server_config() -> PathBuf { - Path::new("tests") - .join("fixtures") - .join("test_data") - .join("rustic_server.toml") - } - - #[test] - fn test_file_read() -> Result<()> { - let config_path = rustic_server_config(); - let config = ServerConfiguration::from_file(&config_path)?; - - assert_eq!(config.server.host_dns_name, "127.0.0.1"); - assert_eq!( - config.repos.storage_path, - "rustic_server/tests/fixtures/test_data/test_repos/" - ); - Ok(()) - } - - #[test] - fn test_file_write() -> Result<()> { - let server_path = Path::new("tmp_test_data").join("rustic"); - fs::create_dir_all(&server_path)?; - - let server = Server { - host_dns_name: "127.0.0.1".to_string(), - port: 2222, - common_root_path: "".into(), - }; - - let tls: Option = Some(TLS { - key_path: "somewhere".to_string(), - cert_path: "somewhere/else".to_string(), - }); - - let repos: Repos = Repos { - storage_path: server_path.join("repos").to_string_lossy().into(), - }; - - let auth = Authorization { - auth_path: Some("auth_path".to_string()), - use_auth: true, - }; - - let access = AccessControl { - acl_path: Some("acl_path".to_string()), - private_repo: true, - append_only: true, - }; - - let log = "debug".to_string(); - - // Try to write - let config = ServerConfiguration { - log_level: Some(log), - server, - repos, - tls, - authorization: auth, - access_control: access, - }; - let config_file = server_path.join("rustic_server.toml"); - config.to_file(&config_file)?; - - // Try to read - let _tmp_config = ServerConfiguration::from_file(&config_file)?; - - Ok(()) - } -} diff --git a/src/error.rs b/src/error.rs index 4ea86b5..02e262d 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,29 +1,61 @@ +//! Error types + +use abscissa_core::error::{BoxError, Context}; use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; +use std::{ + fmt::{self, Display}, + io, + ops::Deref, + result::Result, +}; -pub type Result = std::result::Result; +pub type AppResult = Result; +pub type ApiResult = Result; -#[derive(Debug, thiserror::Error, displaydoc::Display)] +/// Kinds of errors +#[derive(Clone, Debug, Eq, thiserror::Error, PartialEq, Copy)] pub enum ErrorKind { - /// Internal server error: {0} + /// Error in configuration file + #[error("config error")] + Config, + + /// Input/output error + #[error("I/O error")] + Io, + + /// General storage error + #[error("storage error")] + GeneralStorageError, + + /// Missing user input + #[error("missing user input")] + MissingUserInput, +} + +#[derive(Debug, thiserror::Error, displaydoc::Display)] +pub enum ApiErrorKind { + /// Internal server error: `{0}` InternalError(String), - /// Bad request: {0} + /// Bad request: `{0}` BadRequest(String), - /// Filename {0} not allowed + /// Filename `{0}` not allowed FilenameNotAllowed(String), - /// Path {0} not allowed + /// Path `{0}` is ambiguous with internal types and not allowed + AmbiguousPath(String), + /// Path `{0}` not allowed PathNotAllowed(String), - /// Path {0} is not valid + /// Path `{0}` is not valid InvalidPath(String), - /// Path {0} is not valid unicode + /// Path `{0}` is not valid unicode NonUnicodePath(String), - /// Creating directory failed: {0} + /// Creating directory failed: `{0}` CreatingDirectoryFailed(String), /// Not yet implemented NotImplemented, - /// File not found: {0} + /// File not found: `{0}` FileNotFound(String), - /// Fetting file metadata failed: {0} + /// Getting file metadata failed: `{0}` GettingFileMetadataFailed(String), /// Range not valid RangeNotValid, @@ -35,121 +67,127 @@ pub enum ErrorKind { GeneralRange, /// Conversion from length to u64 failed ConversionToU64Failed, - /// Opening file failed: {0} + /// Opening file failed: `{0}` OpeningFileFailed(String), - /// Writing file failed: {0} + /// Writing file failed: `{0}` WritingToFileFailed(String), - /// Finalizing file failed: {0} + /// Finalizing file failed: `{0}` FinalizingFileFailed(String), /// Getting file handle failed GettingFileHandleFailed, - /// Removing file failed: {0} + /// Removing file failed: `{0}` RemovingFileFailed(String), /// Reading from stream failed ReadingFromStreamFailed, - /// Removing repository folder failed: {0} + /// Removing repository folder failed: `{0}` RemovingRepositoryFailed(String), /// Bad authentication header AuthenticationHeaderError, - /// Failed to authenticate user: {0} + /// Failed to authenticate user: `{0}` UserAuthenticationError(String), - /// General Storage error: {0} + /// General Storage error: `{0}` GeneralStorageError(String), + /// Invalid API version: `{0}` + InvalidApiVersion(String), } -impl IntoResponse for ErrorKind { +impl IntoResponse for ApiErrorKind { fn into_response(self) -> Response { let response = match self { - ErrorKind::InternalError(err) => ( + Self::InvalidApiVersion(err) => ( + StatusCode::BAD_REQUEST, + format!("Invalid API version: {err}"), + ), + Self::InternalError(err) => ( StatusCode::INTERNAL_SERVER_ERROR, format!("Internal server error: {}", err), ), - ErrorKind::BadRequest(err) => ( + Self::BadRequest(err) => ( StatusCode::BAD_REQUEST, format!("Internal server error: {}", err), ), - ErrorKind::FilenameNotAllowed(filename) => ( + Self::FilenameNotAllowed(filename) => ( StatusCode::FORBIDDEN, format!("filename {filename} not allowed"), ), - ErrorKind::PathNotAllowed(path) => { + Self::AmbiguousPath(path) => ( + StatusCode::FORBIDDEN, + format!("path {path} is ambiguous with internal types and not allowed"), + ), + Self::PathNotAllowed(path) => { (StatusCode::FORBIDDEN, format!("path {path} not allowed")) } - ErrorKind::NonUnicodePath(path) => ( + Self::NonUnicodePath(path) => ( StatusCode::BAD_REQUEST, format!("path {path} is not valid unicode"), ), - ErrorKind::InvalidPath(path) => { + Self::InvalidPath(path) => { (StatusCode::BAD_REQUEST, format!("path {path} is not valid")) } - ErrorKind::CreatingDirectoryFailed(err) => ( + Self::CreatingDirectoryFailed(err) => ( StatusCode::INTERNAL_SERVER_ERROR, format!("error creating dir: {:?}", err), ), - ErrorKind::NotImplemented => ( + Self::NotImplemented => ( StatusCode::NOT_IMPLEMENTED, "not yet implemented".to_string(), ), - ErrorKind::FileNotFound(path) => { - (StatusCode::NOT_FOUND, format!("file not found: {path}")) - } - ErrorKind::GettingFileMetadataFailed(err) => ( + Self::FileNotFound(path) => (StatusCode::NOT_FOUND, format!("file not found: {path}")), + Self::GettingFileMetadataFailed(err) => ( StatusCode::INTERNAL_SERVER_ERROR, format!("error getting file metadata: {err}"), ), - ErrorKind::RangeNotValid => (StatusCode::BAD_REQUEST, "range not valid".to_string()), - ErrorKind::SeekingFileFailed => ( + Self::RangeNotValid => (StatusCode::BAD_REQUEST, "range not valid".to_string()), + Self::SeekingFileFailed => ( StatusCode::INTERNAL_SERVER_ERROR, "error seeking file".to_string(), ), - ErrorKind::MultipartRangeNotImplemented => ( + Self::MultipartRangeNotImplemented => ( StatusCode::NOT_IMPLEMENTED, "multipart range not implemented".to_string(), ), - ErrorKind::ConversionToU64Failed => ( + Self::ConversionToU64Failed => ( StatusCode::INTERNAL_SERVER_ERROR, "error converting length to u64".to_string(), ), - ErrorKind::OpeningFileFailed(err) => ( + Self::OpeningFileFailed(err) => ( StatusCode::INTERNAL_SERVER_ERROR, format!("error opening file: {err}"), ), - ErrorKind::WritingToFileFailed(err) => ( + Self::WritingToFileFailed(err) => ( StatusCode::INTERNAL_SERVER_ERROR, format!("error writing file: {err}"), ), - ErrorKind::FinalizingFileFailed(err) => ( + Self::FinalizingFileFailed(err) => ( StatusCode::INTERNAL_SERVER_ERROR, format!("error finalizing file: {err}"), ), - ErrorKind::GettingFileHandleFailed => ( + Self::GettingFileHandleFailed => ( StatusCode::INTERNAL_SERVER_ERROR, "error getting file handle".to_string(), ), - ErrorKind::RemovingFileFailed(err) => ( + Self::RemovingFileFailed(err) => ( StatusCode::INTERNAL_SERVER_ERROR, format!("error removing file: {err}"), ), - ErrorKind::GeneralRange => { - (StatusCode::INTERNAL_SERVER_ERROR, "range error".to_string()) - } - ErrorKind::ReadingFromStreamFailed => ( + Self::GeneralRange => (StatusCode::INTERNAL_SERVER_ERROR, "range error".to_string()), + Self::ReadingFromStreamFailed => ( StatusCode::INTERNAL_SERVER_ERROR, "error reading from stream".to_string(), ), - ErrorKind::RemovingRepositoryFailed(err) => ( + Self::RemovingRepositoryFailed(err) => ( StatusCode::INTERNAL_SERVER_ERROR, format!("error removing repository folder: {:?}", err), ), - ErrorKind::AuthenticationHeaderError => ( + Self::AuthenticationHeaderError => ( StatusCode::FORBIDDEN, "Bad authentication header".to_string(), ), - ErrorKind::UserAuthenticationError(err) => ( + Self::UserAuthenticationError(err) => ( StatusCode::FORBIDDEN, format!("Failed to authenticate user: {:?}", err), ), - ErrorKind::GeneralStorageError(err) => ( + Self::GeneralStorageError(err) => ( StatusCode::INTERNAL_SERVER_ERROR, format!("Storage error: {:?}", err), ), @@ -158,3 +196,52 @@ impl IntoResponse for ErrorKind { response.into_response() } } + +impl ErrorKind { + /// Create an error context from this error + pub fn context(self, source: impl Into) -> Context { + Context::new(self, Some(source.into())) + } +} + +/// Error type +#[derive(Debug)] +pub struct Error(Box>); + +impl Deref for Error { + type Target = Context; + + fn deref(&self) -> &Context { + &self.0 + } +} + +impl Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} + +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + self.0.source() + } +} + +impl From for Error { + fn from(kind: ErrorKind) -> Self { + Context::new(kind, None).into() + } +} + +impl From> for Error { + fn from(context: Context) -> Self { + Self(Box::new(context)) + } +} + +impl From for Error { + fn from(err: io::Error) -> Self { + ErrorKind::Io.context(err).into() + } +} diff --git a/src/handlers/access_check.rs b/src/handlers/access_check.rs index 612d710..13ebc80 100644 --- a/src/handlers/access_check.rs +++ b/src/handlers/access_check.rs @@ -1,22 +1,23 @@ use std::path::Path; use axum::{http::StatusCode, response::IntoResponse}; +use tracing::debug; // used for using auto-generated TpeKind variant names use strum::VariantNames; use crate::{ acl::{AccessType, AclChecker, ACL}, - error::{ErrorKind, Result}, + error::{ApiErrorKind, ApiResult}, typed_path::TpeKind, }; -pub(crate) fn check_auth_and_acl( +pub fn check_auth_and_acl( user: String, tpe: impl Into>, path: &Path, - append: AccessType, -) -> Result { + access_type: AccessType, +) -> ApiResult { let tpe = tpe.into(); // don't allow paths that includes any of the defined types @@ -25,7 +26,8 @@ pub(crate) fn check_auth_and_acl( if let Some(part) = part.to_str() { for tpe_i in TpeKind::VARIANTS.iter() { if &part == tpe_i { - return Err(ErrorKind::PathNotAllowed(path.display().to_string())); + debug!("PathNotAllowed: {:?}", part); + return Err(ApiErrorKind::AmbiguousPath(path.display().to_string())); } } } @@ -35,13 +37,13 @@ pub(crate) fn check_auth_and_acl( let path = if let Some(path) = path.to_str() { path } else { - return Err(ErrorKind::NonUnicodePath(path.display().to_string())); + return Err(ApiErrorKind::NonUnicodePath(path.display().to_string())); }; - let allowed = acl.allowed(&user, path, tpe, append); - tracing::debug!("[auth] user: {user}, path: {path}, tpe: {tpe:?}, allowed: {allowed}"); + let allowed = acl.is_allowed(&user, path, tpe, access_type); + tracing::debug!(name: "auth", %user, %path, "type" = ?tpe, allowed); match allowed { true => Ok(StatusCode::OK), - false => Err(ErrorKind::PathNotAllowed(path.to_string())), + false => Err(ApiErrorKind::PathNotAllowed(path.to_string())), } } diff --git a/src/handlers/file_config.rs b/src/handlers/file_config.rs index b713b58..132cab5 100644 --- a/src/handlers/file_config.rs +++ b/src/handlers/file_config.rs @@ -1,14 +1,15 @@ use std::path::{Path, PathBuf}; -use axum::{extract::Request, response::IntoResponse}; +use axum::{extract::Request, http::header, response::IntoResponse}; use axum_extra::{headers::Range, TypedHeader}; +use axum_macros::debug_handler; use axum_range::{KnownSize, Ranged}; use crate::typed_path::PathParts; use crate::{ acl::AccessType, auth::AuthFromRequest, - error::{ErrorKind, Result}, + error::{ApiErrorKind, ApiResult}, handlers::{ access_check::check_auth_and_acl, file_exchange::{check_name, get_save_file, save_body}, @@ -19,113 +20,136 @@ use crate::{ /// has_config /// Interface: HEAD {repo}/config -pub(crate) async fn has_config( +#[debug_handler] +pub async fn has_config( RepositoryConfigPath { repo }: RepositoryConfigPath, - auth: AuthFromRequest, -) -> Result { + AuthFromRequest { user, .. }: AuthFromRequest, +) -> ApiResult { let tpe = TpeKind::Config; - tracing::debug!("[has_config] repository path: {repo}, tpe: {tpe}"); - let path = std::path::Path::new(&repo); - check_auth_and_acl(auth.user, tpe, path, AccessType::Read)?; + + tracing::debug!(path = %repo, "type" = %tpe, "[has_config]"); + + let path = Path::new(&repo); + + let _ = check_auth_and_acl(user, tpe, path, AccessType::Read)?; let storage = STORAGE.get().unwrap(); - let file = storage.filename(path, tpe.into_str(), None); - if file.exists() { - Ok(()) + + let path_to_storage = storage.filename(path, tpe.into_str(), None); + + if path_to_storage.exists() { + let file = storage.open_file(path, tpe.into_str(), None).await?; + + let length = file + .metadata() + .await + .map_err(|err| ApiErrorKind::GettingFileMetadataFailed(format!("{err:?}")))? + .len() + .to_string(); + + Ok([(header::CONTENT_LENGTH, length)]) } else { - Err(ErrorKind::FileNotFound(repo)) + Err(ApiErrorKind::FileNotFound(repo)) } } -/// get_config +/// `get_config` /// Interface: GET {repo}/config -pub(crate) async fn get_config( +pub async fn get_config( path: P, auth: AuthFromRequest, range: Option>, -) -> Result { +) -> ApiResult { let tpe = TpeKind::Config; + let repo = path.repo().unwrap(); + tracing::debug!("[get_config] repository path: {repo}, tpe: {tpe}"); - check_name(tpe, None)?; + let _ = check_name(tpe, None)?; let path = Path::new(&repo); - check_auth_and_acl(auth.user, tpe, path, AccessType::Read)?; + let _ = check_auth_and_acl(auth.user, tpe, path, AccessType::Read)?; let storage = STORAGE.get().unwrap(); let file = storage.open_file(path, tpe.into_str(), None).await?; let body = KnownSize::file(file) .await - .map_err(|err| ErrorKind::GettingFileMetadataFailed(format!("{err:?}")))?; + .map_err(|err| ApiErrorKind::GettingFileMetadataFailed(format!("{err:?}")))?; let range = range.map(|TypedHeader(range)| range); Ok(Ranged::new(range, body).into_response()) } -/// add_config +/// `add_config` /// Interface: POST {repo}/config -pub(crate) async fn add_config( +pub async fn add_config( path: P, auth: AuthFromRequest, request: Request, -) -> Result { +) -> ApiResult { let tpe = TpeKind::Config; let repo = path.repo().unwrap(); tracing::debug!("[add_config] repository path: {repo}, tpe: {tpe}"); let path = PathBuf::from(&repo); - let file = get_save_file(auth.user, path, tpe, None).await?; + let file = get_save_file(auth.user, path, Some(tpe), None).await?; let stream = request.into_body().into_data_stream(); - save_body(file, stream).await?; + let _ = save_body(file, stream).await?; Ok(()) } -/// delete_config +/// `delete_config` /// Interface: DELETE {repo}/config -pub(crate) async fn delete_config( +#[allow(dead_code)] +pub async fn delete_config( path: P, auth: AuthFromRequest, -) -> Result { +) -> ApiResult { let tpe = TpeKind::Config; let repo = path.repo().unwrap(); tracing::debug!("[delete_config] repository path: {repo}, tpe: {tpe}"); - check_name(tpe, None)?; + let _ = check_name(tpe, None)?; let path = Path::new(&repo); - check_auth_and_acl(auth.user, tpe, path, AccessType::Append)?; + let _ = check_auth_and_acl(auth.user, tpe, path, AccessType::Append)?; let storage = STORAGE.get().unwrap(); - storage.remove_file(path, tpe.into_str(), None).await?; + storage + .remove_file(path, tpe.into_str(), None) + .await + .map_err(|err| ApiErrorKind::RemovingFileFailed(format!("{err:?}")))?; Ok(()) } #[cfg(test)] mod test { - use crate::handlers::file_config::{add_config, delete_config, get_config, has_config}; - use crate::handlers::repository::{create_repository, delete_repository}; - use crate::log::print_request_response; - use crate::test_helpers::{ - basic_auth_header_value, init_test_environment, request_uri_for_test, + use crate::{ + handlers::{ + file_config::{add_config, delete_config, get_config, has_config}, + repository::{create_repository, delete_repository}, + }, + log::print_request_response, + test_helpers::{ + basic_auth_header_value, init_test_environment, request_uri_for_test, server_config, + }, + typed_path::{RepositoryConfigPath, RepositoryPath}, }; - use crate::typed_path::{RepositoryConfigPath, RepositoryPath}; - use axum::http::Method; + + use std::{fs, path::PathBuf}; + use axum::{ body::Body, - http::{Request, StatusCode}, - }; - use axum::{middleware, Router}; - use axum_extra::routing::{ - RouterExt, // for `Router::typed_*` + http::{Method, Request, StatusCode}, + middleware, Router, }; + use axum_extra::routing::RouterExt; // for `Router::typed_*` use http_body_util::BodyExt; - use std::path::PathBuf; - use std::{env, fs}; use tower::ServiceExt; #[tokio::test] async fn test_fixture_has_config_passes() { - init_test_environment(); + init_test_environment(server_config()); // ----------------------- // NOT CONFIG @@ -139,7 +163,7 @@ mod test { .method(Method::HEAD) .header( "Authorization", - basic_auth_header_value("test", Some("test_pw")), + basic_auth_header_value("restic", Some("restic")), ) .body(Body::empty()) .unwrap(); @@ -160,7 +184,7 @@ mod test { .method(Method::HEAD) .header( "Authorization", - basic_auth_header_value("test", Some("test_pw")), + basic_auth_header_value("restic", Some("restic")), ) .body(Body::empty()) .unwrap(); @@ -172,21 +196,19 @@ mod test { #[tokio::test] async fn test_add_delete_config_passes() { - init_test_environment(); + init_test_environment(server_config()); // ----------------------- //Start with a clean slate // ----------------------- let repo = "repo_remove_me_2".to_string(); //Start with a clean slate ... - let cwd = env::current_dir().unwrap(); let path = PathBuf::new() - .join(cwd) .join("tests") - .join("fixtures") - .join("test_data") - .join("test_repos") + .join("generated") + .join("test_storage") .join(&repo); + if path.exists() { fs::remove_dir_all(&path).unwrap(); assert!(!path.exists()); @@ -196,7 +218,7 @@ mod test { // ----------------------- // Create a new repository // ----------------------- - let repo_name_uri = ["/", &repo, "?create=true"].concat(); + let repo_name_uri = ["/", &repo, "/", "?create=true"].concat(); let app = Router::new() .typed_post(create_repository::) .layer(middleware::from_fn(print_request_response)); @@ -222,7 +244,7 @@ mod test { .method(Method::POST) .header( "Authorization", - basic_auth_header_value("test", Some("test_pw")), + basic_auth_header_value("restic", Some("restic")), ) .body(body) .unwrap(); @@ -230,9 +252,13 @@ mod test { let resp = app.oneshot(request).await.unwrap(); assert_eq!(resp.status(), StatusCode::OK); + let conf_pth = path.join("config"); + assert!(conf_pth.exists()); + let conf_str = fs::read_to_string(conf_pth).unwrap(); + assert_eq!(&conf_str, &test_vec); // ----------------------- @@ -281,7 +307,7 @@ mod test { // ----------------------- // CLEAN UP DELETE REPO // ----------------------- - let repo_name_uri = ["/", &repo].concat(); + let repo_name_uri = ["/", &repo, "/"].concat(); let app = Router::new() .typed_delete(delete_repository::) .layer(middleware::from_fn(print_request_response)); @@ -292,4 +318,32 @@ mod test { assert_eq!(resp.status(), StatusCode::OK); assert!(!path.exists()); } + + #[tokio::test] + async fn test_get_config_passes() { + init_test_environment(server_config()); + + let path = PathBuf::new() + .join("tests") + .join("generated") + .join("test_storage") + .join("test_repo") + .join("config"); + + let test_vec = fs::read(path).unwrap(); + + let app = Router::new() + .typed_get(get_config::) + .layer(middleware::from_fn(print_request_response)); + + let uri = "/test_repo/config"; + let request = request_uri_for_test(uri, Method::GET); + let resp = app.clone().oneshot(request).await.unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let (_parts, body) = resp.into_parts(); + let byte_vec = body.collect().await.unwrap().to_bytes(); + let body_str = byte_vec.to_vec(); + assert_eq!(body_str, test_vec); + } } diff --git a/src/handlers/file_exchange.rs b/src/handlers/file_exchange.rs index 90d85fb..7b51a69 100644 --- a/src/handlers/file_exchange.rs +++ b/src/handlers/file_exchange.rs @@ -1,9 +1,10 @@ use std::{ io, path::{Path, PathBuf}, + result::Result, }; -use axum::{body::Bytes, extract::Request, response::IntoResponse, BoxError}; +use axum::{body::Bytes, extract::Request, http::StatusCode, response::IntoResponse, BoxError}; use axum_extra::{headers::Range, TypedHeader}; use axum_range::{KnownSize, Ranged}; use futures::{Stream, TryStreamExt}; @@ -14,56 +15,56 @@ use tokio_util::io::StreamReader; use crate::{ acl::AccessType, auth::AuthFromRequest, - error::{ErrorKind, Result}, + error::{ApiErrorKind, ApiResult}, handlers::{access_check::check_auth_and_acl, file_helpers::Finalizer}, storage::STORAGE, typed_path::{PathParts, TpeKind}, }; -/// add_file +/// `add_file` /// Interface: POST {path}/{type}/{name} -/// Background info: https://github.com/tokio-rs/axum/blob/main/examples/stream-to-file/src/main.rs -/// Future on ranges: https://www.rfc-editor.org/rfc/rfc9110.html#name-partial-put -pub(crate) async fn add_file( +/// Background info: +/// Future on ranges: +pub async fn add_file( path: P, auth: AuthFromRequest, request: Request, -) -> Result { +) -> ApiResult { let (path, tpe, name) = path.parts(); tracing::debug!("[get_file] path: {path:?}, tpe: {tpe:?}, name: {name:?}"); let path_str = path.unwrap_or_default(); //credential & access check executed in get_save_file() - let path = std::path::PathBuf::from(&path_str); + let path = PathBuf::from(&path_str); let file = get_save_file(auth.user, path, tpe, name).await?; let stream = request.into_body().into_data_stream(); - save_body(file, stream).await?; + let _ = save_body(file, stream).await?; //FIXME: Do we need to check if the file exists here? (For now it seems we should get an error if NOK) Ok(()) } -/// delete_file +/// `delete_file` /// Interface: DELETE {path}/{type}/{name} -pub(crate) async fn delete_file( +pub async fn delete_file( path: P, auth: AuthFromRequest, -) -> Result { +) -> ApiResult { let (path, tpe, name) = path.parts(); tracing::debug!("[delete_file] path: {path:?}, tpe: {tpe:?}, name: {name:?}"); let path_str = path.unwrap_or_default(); let path = Path::new(&path_str); - check_name(tpe, name.as_deref())?; - check_auth_and_acl(auth.user, tpe, path, AccessType::Append)?; + let _ = check_name(tpe, name.as_deref())?; + let _ = check_auth_and_acl(auth.user, tpe, path, AccessType::Append)?; let tpe = if let Some(tpe) = tpe { tpe.into_str() } else { - return Err(ErrorKind::InternalError("tpe is not valid".to_string())); + return Err(ApiErrorKind::InternalError("tpe is not valid".to_string())); }; let storage = STORAGE.get().unwrap(); @@ -73,37 +74,48 @@ pub(crate) async fn delete_file( Ok(()) } -/// get_file +/// `get_file` /// Interface: GET {path}/{type}/{name} -pub(crate) async fn get_file( +pub async fn get_file( path: P, auth: AuthFromRequest, range: Option>, -) -> Result { +) -> ApiResult { let (path, tpe, name) = path.parts(); - tracing::debug!("[get_file] path: {path:?}, tpe: {tpe:?}, name: {name:?}"); + tracing::debug!(?path, "type" = ?tpe, ?name, "[get_file]"); + + let _ = check_name(tpe, name.as_deref())?; - check_name(tpe, name.as_deref())?; let path_str = path.unwrap_or_default(); + let path = Path::new(&path_str); - check_auth_and_acl(auth.user, tpe, path, AccessType::Read)?; + let _ = check_auth_and_acl(auth.user, tpe, path, AccessType::Read)?; let tpe = if let Some(tpe) = tpe { tpe.into_str() } else { - return Err(ErrorKind::InternalError("tpe is not valid".to_string())); + return Err(ApiErrorKind::InternalError("tpe is not valid".to_string())); }; let storage = STORAGE.get().unwrap(); + let file = storage.open_file(path, tpe, name.as_deref()).await?; let body = KnownSize::file(file) .await - .map_err(|err| ErrorKind::GettingFileMetadataFailed(format!("{err:?}")))?; + .map_err(|err| ApiErrorKind::GettingFileMetadataFailed(format!("{err:?}")))?; + let range = range.map(|TypedHeader(range)| range); - Ok(Ranged::new(range, body).into_response()) + + let status_code = if range.is_some() { + StatusCode::PARTIAL_CONTENT + } else { + StatusCode::OK + }; + + Ok((status_code, Ranged::new(range, body)).into_response()) } //============================================================================== @@ -112,22 +124,21 @@ pub(crate) async fn get_file( //============================================================================== /// Returns a stream for the given path in the repository. -pub(crate) async fn get_save_file( +pub async fn get_save_file( user: String, path: PathBuf, - tpe: impl Into>, + tpe: Option, name: Option, -) -> Result { - let tpe = tpe.into(); +) -> ApiResult { tracing::debug!("[get_save_file] path: {path:?}, tpe: {tpe:?}, name: {name:?}"); - check_name(tpe, name.as_deref())?; - check_auth_and_acl(user, tpe, path.as_path(), AccessType::Append)?; + let _ = check_name(tpe, name.as_deref())?; + let _ = check_auth_and_acl(user, tpe, path.as_path(), AccessType::Append)?; let tpe = if let Some(tpe) = tpe { tpe.into_str() } else { - return Err(ErrorKind::InternalError("tpe is not valid".to_string())); + return Err(ApiErrorKind::InternalError("tpe is not valid".to_string())); }; let storage = STORAGE.get().unwrap(); @@ -135,12 +146,12 @@ pub(crate) async fn get_save_file( } /// saves the content in the HTML request body to a file stream. -pub(crate) async fn save_body( - mut write_stream: impl AsyncWrite + Unpin + Finalizer, +pub async fn save_body( + mut write_stream: impl AsyncWrite + Unpin + Finalizer + Send, stream: S, -) -> Result +) -> ApiResult where - S: Stream>, + S: Stream> + Send, E: Into, { // Convert the stream into an `AsyncRead`. @@ -149,18 +160,17 @@ where pin_mut!(body_reader); let byte_count = match tokio::io::copy(&mut body_reader, &mut write_stream).await { Ok(b) => b, - Err(err) => return Err(ErrorKind::FinalizingFileFailed(format!("{:?}", err))), + Err(err) => return Err(ApiErrorKind::FinalizingFileFailed(format!("{:?}", err))), }; tracing::debug!("[file written] bytes: {byte_count}"); - write_stream - .finalize() - .await - .map_err(|err| ErrorKind::FinalizingFileFailed(format!("Could not finalize file: {}", err))) + write_stream.finalize().await.map_err(|err| { + ApiErrorKind::FinalizingFileFailed(format!("Could not finalize file: {}", err)) + }) } #[cfg(test)] -fn check_string_sha256(_name: &str) -> bool { +const fn check_string_sha256(_name: &str) -> bool { true } @@ -177,16 +187,16 @@ fn check_string_sha256(name: &str) -> bool { true } -pub(crate) fn check_name( +pub fn check_name( tpe: impl Into>, name: Option<&str>, -) -> Result { +) -> ApiResult { let tpe = tpe.into(); match (tpe, name) { (Some(TpeKind::Config), _) => Ok(()), (_, Some(name)) if check_string_sha256(name) => Ok(()), - _ => Err(ErrorKind::FilenameNotAllowed( + _ => Err(ApiErrorKind::FilenameNotAllowed( name.unwrap_or_default().to_string(), )), } @@ -194,46 +204,41 @@ pub(crate) fn check_name( #[cfg(test)] mod test { - use crate::test_helpers::{ - basic_auth_header_value, init_test_environment, request_uri_for_test, - }; - use crate::typed_path::RepositoryConfigPath; - use crate::{handlers::file_config::get_config, log::print_request_response}; use crate::{ handlers::file_exchange::{add_file, delete_file, get_file}, + log::print_request_response, + test_helpers::{ + basic_auth_header_value, init_test_environment, request_uri_for_test, server_config, + }, typed_path::RepositoryTpeNamePath, }; - use axum::http::{header, Method}; + + use std::{fs, path::PathBuf}; + use axum::{ body::Body, - http::{Request, StatusCode}, - }; - use axum::{middleware, Router}; - use axum_extra::routing::{ - RouterExt, // for `Router::typed_*` + http::{header, Method, Request, StatusCode}, + middleware, Router, }; + use axum_extra::routing::RouterExt; // for `Router::typed_*` use http_body_util::BodyExt; - use std::path::PathBuf; - use std::{env, fs}; use tower::ServiceExt; #[tokio::test] async fn test_add_delete_file_passes() { - init_test_environment(); + init_test_environment(server_config()); let file_name = "__add_file_test_adds_this_one__"; //Start with a clean slate ... - let cwd = env::current_dir().unwrap(); let path = PathBuf::new() - .join(cwd) .join("tests") - .join("fixtures") - .join("test_data") - .join("test_repos") + .join("generated") + .join("test_storage") .join("test_repo") .join("keys") .join(file_name); + if path.exists() { fs::remove_file(&path).unwrap(); assert!(!path.exists()); @@ -254,7 +259,7 @@ mod test { .method(Method::POST) .header( "Authorization", - basic_auth_header_value("test", Some("test_pw")), + basic_auth_header_value("restic", Some("restic")), ) .body(body) .unwrap(); @@ -280,7 +285,7 @@ mod test { .method(Method::DELETE) .header( "Authorization", - basic_auth_header_value("test", Some("test_pw")), + basic_auth_header_value("restic", Some("restic")), ) .body(body) .unwrap(); @@ -293,20 +298,19 @@ mod test { #[tokio::test] async fn test_get_file_passes() { - init_test_environment(); + init_test_environment(server_config()); let file_name = "__get_file_test_adds_this_two__"; + //Start with a clean slate ... - let cwd = env::current_dir().unwrap(); let path = PathBuf::new() - .join(cwd) .join("tests") - .join("fixtures") - .join("test_data") - .join("test_repos") + .join("generated") + .join("test_storage") .join("test_repo") .join("keys") .join(file_name); + if path.exists() { tracing::debug!("[server_get_file_tester] test file found and removed"); fs::remove_file(&path).unwrap(); @@ -319,14 +323,17 @@ mod test { .layer(middleware::from_fn(print_request_response)); let test_vec = "Hello Sweet World".to_string(); + let body = Body::new(test_vec.clone()); + let uri = ["/test_repo/keys/", file_name].concat(); + let request = Request::builder() .uri(uri) .method(Method::POST) .header( "Authorization", - basic_auth_header_value("test", Some("test_pw")), + basic_auth_header_value("restic", Some("restic")), ) .body(body) .unwrap(); @@ -334,8 +341,11 @@ mod test { let resp = app.oneshot(request).await.unwrap(); assert_eq!(resp.status(), StatusCode::OK); + assert!(path.exists()); + let body = fs::read_to_string(&path).unwrap(); + assert_eq!(body, test_vec); // Now we can start to test @@ -347,28 +357,33 @@ mod test { .layer(middleware::from_fn(print_request_response)); let uri = ["/test_repo/keys/", file_name].concat(); + let request = request_uri_for_test(&uri, Method::GET); + let resp = app.clone().oneshot(request).await.unwrap(); assert_eq!(resp.status(), StatusCode::OK); + let (_parts, body) = resp.into_parts(); + let byte_vec = body.collect().await.unwrap().to_bytes(); + let body_str = String::from_utf8(byte_vec.to_vec()).unwrap(); + assert_eq!(body_str, test_vec); //---------------------------------------- // Read a partial file //---------------------------------------- - // let test_vec = "Hello Sweet World".to_string(); - let uri = ["/test_repo/keys/", file_name].concat(); + let request = Request::builder() .uri(uri) .method(Method::GET) .header(header::RANGE, "bytes=6-12") .header( "Authorization", - basic_auth_header_value("test", Some("test_pw")), + basic_auth_header_value("restic", Some("restic")), ) .body(Body::empty()) .unwrap(); @@ -378,9 +393,13 @@ mod test { let test_vec = "Sweet W".to_string(); // bytes 6 - 13 from in the file assert_eq!(resp.status(), StatusCode::PARTIAL_CONTENT); + let (_parts, body) = resp.into_parts(); + let byte_vec = body.collect().await.unwrap().to_bytes(); + let body_str = String::from_utf8(byte_vec.to_vec()).unwrap(); + assert_eq!(body_str, test_vec); //---------------------------------------------- @@ -391,40 +410,12 @@ mod test { .layer(middleware::from_fn(print_request_response)); let uri = ["/test_repo/keys/", file_name].concat(); + let request = request_uri_for_test(&uri, Method::DELETE); + let resp = app.oneshot(request).await.unwrap(); assert_eq!(resp.status(), StatusCode::OK); assert!(!path.exists()); } - - #[tokio::test] - async fn test_get_config_passes() { - init_test_environment(); - - let cwd = env::current_dir().unwrap(); - let path = PathBuf::new() - .join(cwd) - .join("tests") - .join("fixtures") - .join("test_data") - .join("test_repos") - .join("test_repo") - .join("config"); - let test_vec = fs::read(path).unwrap(); - - let app = Router::new() - .typed_get(get_config::) - .layer(middleware::from_fn(print_request_response)); - - let uri = "/test_repo/config"; - let request = request_uri_for_test(uri, Method::GET); - let resp = app.clone().oneshot(request).await.unwrap(); - - assert_eq!(resp.status(), StatusCode::OK); - let (_parts, body) = resp.into_parts(); - let byte_vec = body.collect().await.unwrap().to_bytes(); - let body_str = byte_vec.to_vec(); - assert_eq!(body_str, test_vec); - } } diff --git a/src/handlers/file_helpers.rs b/src/handlers/file_helpers.rs index 661559c..9b1e06b 100644 --- a/src/handlers/file_helpers.rs +++ b/src/handlers/file_helpers.rs @@ -4,6 +4,7 @@ use std::{ io::Result as IoResult, path::PathBuf, pin::Pin, + result::Result, task::{Context, Poll}, }; @@ -13,10 +14,11 @@ use tokio::{ io::AsyncWrite, }; -use crate::error::{ErrorKind, Result}; +use crate::error::{ApiErrorKind, ApiResult}; // helper struct which is like a async_std|tokio::fs::File but removes the file // if finalize() was not called. +#[derive(Debug)] pub struct WriteOrDeleteFile { file: File, path: PathBuf, @@ -25,22 +27,35 @@ pub struct WriteOrDeleteFile { #[async_trait::async_trait] pub trait Finalizer { - async fn finalize(&mut self) -> Result<()>; + async fn finalize(&mut self) -> ApiResult<()>; } impl WriteOrDeleteFile { - pub async fn new(file_path: PathBuf) -> Result { - tracing::debug!("[WriteOrDeleteFile] path: {file_path:?}"); + pub async fn new(path: PathBuf) -> ApiResult { + tracing::debug!("[WriteOrDeleteFile] path: {path:?}"); + + if !path.exists() { + let parent = path.parent().ok_or_else(|| { + ApiErrorKind::WritingToFileFailed("Could not get parent directory".to_string()) + })?; + + fs::create_dir_all(parent).map_err(|err| { + ApiErrorKind::WritingToFileFailed(format!("Could not create directory: {}", err)) + })?; + } + + let file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&path) + .await + .map_err(|err| { + ApiErrorKind::WritingToFileFailed(format!("Could not write to file: {}", err)) + })?; + Ok(Self { - file: OpenOptions::new() - .write(true) - .create_new(true) - .open(&file_path) - .await - .map_err(|err| { - ErrorKind::WritingToFileFailed(format!("Could not write to file: {}", err)) - })?, - path: file_path, + file, + path, finalized: false, }) } @@ -48,9 +63,9 @@ impl WriteOrDeleteFile { #[async_trait::async_trait] impl Finalizer for WriteOrDeleteFile { - async fn finalize(&mut self) -> Result<()> { + async fn finalize(&mut self) -> ApiResult<()> { self.file.sync_all().await.map_err(|err| { - ErrorKind::FinalizingFileFailed(format!("Could not sync file: {}", err)) + ApiErrorKind::FinalizingFileFailed(format!("Could not sync file: {}", err)) })?; self.finalized = true; Ok(()) @@ -84,7 +99,7 @@ impl Drop for WriteOrDeleteFile { pub struct IteratorAdapter(RefCell); impl IteratorAdapter { - pub fn new(iterator: I) -> Self { + pub const fn new(iterator: I) -> Self { Self(RefCell::new(iterator)) } } @@ -94,7 +109,7 @@ where I: Iterator, I::Item: Serialize, { - fn serialize(&self, serializer: S) -> std::result::Result + fn serialize(&self, serializer: S) -> Result where S: Serializer, { diff --git a/src/handlers/file_length.rs b/src/handlers/file_length.rs index f49bacb..67bca13 100644 --- a/src/handlers/file_length.rs +++ b/src/handlers/file_length.rs @@ -1,12 +1,12 @@ use std::path::Path; use axum::{http::header, response::IntoResponse}; -use axum_extra::headers::HeaderMap; +// use axum_extra::headers::HeaderMap; use crate::{ acl::AccessType, auth::AuthFromRequest, - error::{ErrorKind, Result}, + error::{ApiErrorKind, ApiResult}, handlers::access_check::check_auth_and_acl, storage::STORAGE, typed_path::PathParts, @@ -14,68 +14,77 @@ use crate::{ /// Length /// Interface: HEAD {path}/{type}/{name} -pub(crate) async fn file_length( +pub async fn file_length( path: P, auth: AuthFromRequest, -) -> Result { +) -> ApiResult { let (path, tpe, name) = path.parts(); tracing::debug!("[length] path: {path:?}, tpe: {tpe:?}, name: {name:?}"); + let path_str = path.unwrap_or_default(); + let path = Path::new(&path_str); - check_auth_and_acl(auth.user, tpe, path, AccessType::Read)?; + + let _ = check_auth_and_acl(auth.user, tpe, path, AccessType::Read)?; let tpe = if let Some(tpe) = tpe { tpe.into_str() } else { - return Err(ErrorKind::InternalError("tpe is not valid".to_string())); + return Err(ApiErrorKind::InternalError("tpe is not valid".to_string())); }; let storage = STORAGE.get().unwrap(); + let file = storage.filename(path, tpe, name.as_deref()); - let res = if file.exists() { + + if file.exists() { let storage = STORAGE.get().unwrap(); - let file = match storage.open_file(path, tpe, name.as_deref()).await { - Ok(file) => file, - Err(_) => { - return Err(ErrorKind::FileNotFound(path_str)); - } - }; - let length = match file.metadata().await { - Ok(meta) => meta.len(), - Err(err) => { - return Err(ErrorKind::GettingFileMetadataFailed(format!( - "path: {path:?}, tpe: {tpe}, name: {name:?}, err: {err}", - ))); - } - }; - let mut headers = HeaderMap::new(); - headers.insert(header::CONTENT_LENGTH, length.into()); - Ok(headers) - } else { - Err(ErrorKind::FileNotFound(path_str)) - }; - res + let file = storage + .open_file(path, tpe, name.as_deref()) + .await + .map_err(|err| { + ApiErrorKind::OpeningFileFailed(format!("Could not open file: {err}")) + })?; + + let length = file + .metadata() + .await + .map_err(|err| { + ApiErrorKind::GettingFileMetadataFailed(format!( + "path: {path:?}, tpe: {tpe}, name: {name:?}, err: {err}" + )) + })? + .len() + .to_string(); + + Ok([(header::CONTENT_LENGTH, length)]) + } else { + Err(ApiErrorKind::FileNotFound(path_str)) + } } #[cfg(test)] mod test { - use crate::log::print_request_response; - use crate::test_helpers::{init_test_environment, request_uri_for_test}; - use crate::{handlers::file_length::file_length, typed_path::RepositoryTpeNamePath}; - use axum::http::StatusCode; - use axum::http::{header, Method}; - use axum::{middleware, Router}; - use axum_extra::routing::{ - RouterExt, // for `Router::typed_*` + use axum::{ + http::{header, Method, StatusCode}, + middleware, Router, }; + use axum_extra::routing::RouterExt; // for `Router::typed_*` use http_body_util::BodyExt; use tower::ServiceExt; // for `call`, `oneshot`, and `ready` + use crate::{ + handlers::file_length::file_length, + log::print_request_response, + test_helpers::{init_test_environment, request_uri_for_test, server_config}, + typed_path::RepositoryTpeNamePath, + }; + #[tokio::test] async fn test_get_file_length_passes() { - init_test_environment(); + init_test_environment(server_config()); // ---------------------------------- // File exists @@ -85,8 +94,10 @@ mod test { .layer(middleware::from_fn(print_request_response)); let uri = - "/test_repo/keys/2e734da3fccb98724ece44efca027652ba7a335c224448a68772b41c0d9229d5"; + "/test_repo/keys/3f918b737a2b9f72f044d06d6009eb34e0e8d06668209be3ce86e5c18dac0295"; + let request = request_uri_for_test(uri, Method::HEAD); + let resp = app.oneshot(request).await.unwrap(); assert_eq!(resp.status(), StatusCode::OK); @@ -97,7 +108,8 @@ mod test { .unwrap() .to_str() .unwrap(); - assert_eq!(length, "363"); + + assert_eq!(length, "460"); let b = resp .into_body() @@ -106,6 +118,7 @@ mod test { .unwrap() .to_bytes() .to_vec(); + assert!(b.is_empty()); // ---------------------------------- @@ -116,7 +129,9 @@ mod test { .layer(middleware::from_fn(print_request_response)); let uri = "/test_repo/keys/__I_do_not_exist__"; + let request = request_uri_for_test(uri, Method::HEAD); + let resp = app.oneshot(request).await.unwrap(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); @@ -128,6 +143,7 @@ mod test { .unwrap() .to_bytes() .to_vec(); + assert!(b.is_empty()); } } diff --git a/src/handlers/files_list.rs b/src/handlers/files_list.rs index f588428..fd1b47a 100644 --- a/src/handlers/files_list.rs +++ b/src/handlers/files_list.rs @@ -1,4 +1,4 @@ -use std::path::Path; +use std::{path::Path, str::FromStr}; use axum::{ http::{ @@ -14,15 +14,47 @@ use serde_derive::{Deserialize, Serialize}; use crate::{ acl::AccessType, auth::AuthFromRequest, - error::Result, + error::{ApiErrorKind, ApiResult}, handlers::{access_check::check_auth_and_acl, file_helpers::IteratorAdapter}, storage::STORAGE, typed_path::PathParts, }; -// FIXME: Make it an enum internally -const API_V1: &str = "application/vnd.x.restic.rest.v1"; -const API_V2: &str = "application/vnd.x.restic.rest.v2"; +#[derive(Debug, Clone, Copy)] +enum ApiVersionKind { + V1, + V2, +} + +impl ApiVersionKind { + pub const fn to_static_str(self) -> &'static str { + match self { + Self::V1 => "application/vnd.x.restic.rest.v1", + Self::V2 => "application/vnd.x.restic.rest.v2", + } + } +} + +impl std::fmt::Display for ApiVersionKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::V1 => write!(f, "application/vnd.x.restic.rest.v1"), + Self::V2 => write!(f, "application/vnd.x.restic.rest.v2"), + } + } +} + +impl FromStr for ApiVersionKind { + type Err = ApiErrorKind; + + fn from_str(s: &str) -> Result { + match s { + "application/vnd.x.restic.rest.v1" => Ok(Self::V1), + "application/vnd.x.restic.rest.v2" => Ok(Self::V2), + _ => Err(ApiErrorKind::InvalidApiVersion(s.to_string())), + } + } +} /// List files /// Interface: GET {path}/{type}/ @@ -32,83 +64,107 @@ struct RepoPathEntry { size: u64, } -pub(crate) async fn list_files( +pub async fn list_files( path: P, auth: AuthFromRequest, headers: HeaderMap, -) -> Result { +) -> ApiResult { let (path, tpe, _) = path.parts(); - tracing::debug!("[list_files] path: {path:?}, tpe: {tpe:?}"); + tracing::debug!(?path, "type" = ?tpe, "[list_files]"); + let path = path.unwrap_or_default(); + let path = Path::new(&path); - check_auth_and_acl(auth.user, tpe, path, AccessType::Read)?; + + let _ = check_auth_and_acl(auth.user, tpe, path, AccessType::Read)?; let storage = STORAGE.get().unwrap(); + let read_dir = storage.read_dir(path, tpe.map(|f| f.into())); let mut res = match headers .get(header::ACCEPT) .and_then(|header| header.to_str().ok()) { - Some(API_V2) => { - let read_dir_version = read_dir.map(|e| { + Some(version) if version == ApiVersionKind::V2.to_static_str() => { + let read_dir_version = read_dir.map(|entry| { RepoPathEntry { - name: e.file_name().to_str().unwrap().to_string(), - size: e.metadata().unwrap().len(), - // FIXME: return Err(ErrorKind::GettingFileMetadataFailed.into()); + name: entry.file_name().to_str().unwrap().to_string(), + size: entry.metadata().unwrap().len(), + // FIXME: return Err(WebErrorKind::GettingFileMetadataFailed.into()); } }); + let mut response = Json(&IteratorAdapter::new(read_dir_version)).into_response(); - tracing::debug!("[list_files::dir_content(V2)] {:?}", response.body()); - response.headers_mut().insert( + + tracing::debug!("[list_files::dir_content] Api V2 | {:?}", response.body()); + + let _ = response.headers_mut().insert( header::CONTENT_TYPE, - header::HeaderValue::from_static(API_V2), + header::HeaderValue::from_static(ApiVersionKind::V2.to_static_str()), ); + let status = response.status_mut(); + *status = StatusCode::OK; + response } _ => { let read_dir_version = read_dir.map(|e| e.file_name().to_str().unwrap().to_string()); + let mut response = Json(&IteratorAdapter::new(read_dir_version)).into_response(); - response.headers_mut().insert( + + tracing::debug!( + "[list_files::dir_content] Fallback to V1 | {:?}", + response.body() + ); + + let _ = response.headers_mut().insert( header::CONTENT_TYPE, - header::HeaderValue::from_static(API_V1), + header::HeaderValue::from_static(ApiVersionKind::V1.to_static_str()), ); + let status = response.status_mut(); + *status = StatusCode::OK; + response } }; - res.headers_mut() + + let _ = res + .headers_mut() .insert(AUTHORIZATION, headers.get(AUTHORIZATION).unwrap().clone()); + Ok(res) } #[cfg(test)] mod test { - use crate::log::print_request_response; - use crate::test_helpers::{basic_auth_header_value, init_test_environment}; - use crate::{ - handlers::files_list::{list_files, RepoPathEntry, API_V1, API_V2}, - typed_path::RepositoryTpePath, - }; - use axum::http::header::{ACCEPT, CONTENT_TYPE}; use axum::{ body::Body, - http::{Request, StatusCode}, - }; - use axum::{middleware, Router}; - use axum_extra::routing::{ - RouterExt, // for `Router::typed_*` + http::{ + header::{ACCEPT, CONTENT_TYPE}, + Request, StatusCode, + }, + middleware, Router, }; + use axum_extra::routing::RouterExt; // for `Router::typed_*` use http_body_util::BodyExt; use tower::ServiceExt; // for `call`, `oneshot`, and `ready` + use crate::{ + handlers::files_list::{list_files, ApiVersionKind, RepoPathEntry}, + log::print_request_response, + test_helpers::{basic_auth_header_value, init_test_environment, server_config}, + typed_path::RepositoryTpePath, + }; + #[tokio::test] async fn test_get_list_files_passes() { - init_test_environment(); + init_test_environment(server_config()); // V1 let app = Router::new() @@ -116,11 +172,11 @@ mod test { .layer(middleware::from_fn(print_request_response)); let request = Request::builder() - .uri("/test_repo/keys") - .header(ACCEPT, API_V1) + .uri("/test_repo/keys/") + .header(ACCEPT, ApiVersionKind::V1.to_static_str()) .header( "Authorization", - basic_auth_header_value("test", Some("test_pw")), + basic_auth_header_value("restic", Some("restic")), ) .body(Body::empty()) .unwrap(); @@ -131,8 +187,9 @@ mod test { assert_eq!( resp.headers().get(CONTENT_TYPE).unwrap().to_str().unwrap(), - API_V1 + ApiVersionKind::V1.to_static_str() ); + let b = resp .into_body() .collect() @@ -140,13 +197,17 @@ mod test { .unwrap() .to_bytes() .to_vec(); + assert!(!b.is_empty()); + let body = std::str::from_utf8(&b).unwrap(); + let r: Vec = serde_json::from_str(body).unwrap(); + let mut found = false; for rpe in r { - if rpe == "2e734da3fccb98724ece44efca027652ba7a335c224448a68772b41c0d9229d5" { + if rpe == "3f918b737a2b9f72f044d06d6009eb34e0e8d06668209be3ce86e5c18dac0295" { found = true; break; } @@ -159,11 +220,11 @@ mod test { .layer(middleware::from_fn(print_request_response)); let request = Request::builder() - .uri("/test_repo/keys") - .header(ACCEPT, API_V2) + .uri("/test_repo/keys/") + .header(ACCEPT, ApiVersionKind::V2.to_static_str()) .header( "Authorization", - basic_auth_header_value("test", Some("test_pw")), + basic_auth_header_value("restic", Some("restic")), ) .body(Body::empty()) .unwrap(); @@ -174,7 +235,7 @@ mod test { assert_eq!( resp.headers().get(CONTENT_TYPE).unwrap().to_str().unwrap(), - API_V2 + ApiVersionKind::V2.to_static_str() ); let b = resp .into_body() @@ -183,15 +244,18 @@ mod test { .unwrap() .to_bytes() .to_vec(); + let body = std::str::from_utf8(&b).unwrap(); + let r: Vec = serde_json::from_str(body).unwrap(); + assert!(!r.is_empty()); let mut found = false; for rpe in r { - if rpe.name == "2e734da3fccb98724ece44efca027652ba7a335c224448a68772b41c0d9229d5" { - assert_eq!(rpe.size, 363); + if rpe.name == "3f918b737a2b9f72f044d06d6009eb34e0e8d06668209be3ce86e5c18dac0295" { + assert_eq!(rpe.size, 460); found = true; break; } @@ -200,7 +264,7 @@ mod test { // We may have more files, this does not work... // let rr = r.first().unwrap(); - // assert_eq!( rr.name, "2e734da3fccb98724ece44efca027652ba7a335c224448a68772b41c0d9229d5"); + // assert_eq!( rr.name, "3f918b737a2b9f72f044d06d6009eb34e0e8d06668209be3ce86e5c18dac0295"); // assert_eq!(rr.size, 363); } } diff --git a/src/handlers/repository.rs b/src/handlers/repository.rs index 4e7cbea..b4ebeda 100644 --- a/src/handlers/repository.rs +++ b/src/handlers/repository.rs @@ -4,7 +4,7 @@ use axum::{extract::Query, http::StatusCode, response::IntoResponse}; use serde_derive::Deserialize; use crate::{ - acl::AccessType, auth::AuthFromRequest, error::Result, + acl::AccessType, auth::AuthFromRequest, error::ApiResult, handlers::access_check::check_auth_and_acl, storage::STORAGE, typed_path::TpeKind, }; @@ -12,25 +12,25 @@ use crate::{ use crate::typed_path::PathParts; use strum::VariantNames; -/// Create_repository +/// `Create_repository` /// Interface: POST {path}?create=true #[derive(Default, Deserialize)] #[serde(default)] -pub(crate) struct Create { +pub struct Create { create: bool, } -pub(crate) async fn create_repository( +pub async fn create_repository( path: P, auth: AuthFromRequest, Query(params): Query, -) -> Result { +) -> ApiResult { tracing::debug!( "[create_repository] repository path: {}", path.repo().unwrap() ); let path = PathBuf::new().join(path.repo().unwrap()); - check_auth_and_acl(auth.user, None, &path, AccessType::Append)?; + let _ = check_auth_and_acl(auth.user, None, &path, AccessType::Append)?; let storage = STORAGE.get().unwrap(); match params.create { @@ -42,34 +42,34 @@ pub(crate) async fn create_repository( continue; } - storage.create_dir(&path, Some(tpe)).await? + storage.create_dir(&path, Some(tpe)).await?; } Ok(( StatusCode::OK, - format!("Called create_files with path {:?}\n", &path), + format!("Called create_files with path {:?}", &path), )) } false => Ok(( StatusCode::OK, - format!("Called create_files with path {:?}, create=false\n", &path), + format!("Called create_files with path {:?}, create=false", &path), )), } } -/// Delete_repository +/// `Delete_repository` /// Interface: Delete {path} // FIXME: The input path should at least NOT point to a file in any repository -pub(crate) async fn delete_repository( +pub async fn delete_repository( path: P, auth: AuthFromRequest, -) -> Result { +) -> ApiResult { tracing::debug!( "[delete_repository] repository path: {}", &path.repo().unwrap() ); let path = PathBuf::new().join(path.repo().unwrap()); - check_auth_and_acl(auth.user, None, &path, AccessType::Modify)?; + let _ = check_auth_and_acl(auth.user, None, &path, AccessType::Modify)?; let storage = STORAGE.get().unwrap(); storage.remove_repository(&path).await?; @@ -79,12 +79,15 @@ pub(crate) async fn delete_repository( #[cfg(test)] mod test { - use crate::handlers::repository::{create_repository, delete_repository}; use crate::log::print_request_response; use crate::test_helpers::{ basic_auth_header_value, init_test_environment, request_uri_for_test, }; use crate::typed_path::RepositoryPath; + use crate::{ + handlers::repository::{create_repository, delete_repository}, + test_helpers::server_config, + }; use axum::http::Method; use axum::{ body::Body, @@ -92,7 +95,6 @@ mod test { }; use axum::{middleware, Router}; use axum_extra::routing::RouterExt; - use std::env; use std::path::PathBuf; use tokio::fs; use tower::ServiceExt; @@ -101,30 +103,26 @@ mod test { /// for user test with the correct password #[tokio::test] async fn test_repo_create_delete_passes() { - init_test_environment(); + init_test_environment(server_config()); //Start with a clean slate ... - let cwd = env::current_dir().unwrap(); let path = PathBuf::new() - .join(cwd) .join("tests") - .join("fixtures") - .join("test_data") - .join("test_repos") + .join("generated") + .join("test_storage") .join("repo_remove_me"); + if path.exists() { fs::remove_dir_all(&path).await.unwrap(); assert!(!path.exists()); } - let cwd = env::current_dir().unwrap(); let not_allowed_path = PathBuf::new() - .join(cwd) .join("tests") - .join("fixtures") - .join("test_data") - .join("test_repos") + .join("generated") + .join("test_storage") .join("repo_not_allowed"); + if not_allowed_path.exists() { fs::remove_dir_all(¬_allowed_path).await.unwrap(); assert!(!not_allowed_path.exists()); @@ -133,7 +131,7 @@ mod test { // ------------------------------------ // Create a new repository: {path}?create=true // ------------------------------------ - let repo_name_uri = "/repo_remove_me?create=true".to_string(); + let repo_name_uri = "/repo_remove_me/?create=true".to_string(); let app = Router::new() .typed_post(create_repository::) .layer(middleware::from_fn(print_request_response)); @@ -147,7 +145,7 @@ mod test { // ------------------------------------------ // Create a new repository WITHOUT ACL access // ------------------------------------------ - let repo_name_uri = "/repo_not_allowed?create=true".to_string(); + let repo_name_uri = "/repo_not_allowed/?create=true".to_string(); let app = Router::new() .typed_post(create_repository::) .layer(middleware::from_fn(print_request_response)); @@ -161,7 +159,7 @@ mod test { // ------------------------------------------ // Delete a repository WITHOUT ACL access // ------------------------------------------ - let repo_name_uri = "/repo_remove_me?create=true".to_string(); + let repo_name_uri = "/repo_remove_me/?create=true".to_string(); let app = Router::new() .typed_delete(delete_repository::) .layer(middleware::from_fn(print_request_response)); @@ -171,7 +169,7 @@ mod test { .method(Method::DELETE) .header( "Authorization", - basic_auth_header_value("test", Some("__wrong_password__")), + basic_auth_header_value("restic", Some("__wrong_password__")), ) .body(Body::empty()) .unwrap(); @@ -185,7 +183,7 @@ mod test { // Delete a repository WITH access... // ------------------------------------------ assert!(path.exists()); // pre condition: repo exists - let repo_name_uri = "/repo_remove_me".to_string(); + let repo_name_uri = "/repo_remove_me/".to_string(); let app = Router::new() .typed_delete(delete_repository::) .layer(middleware::from_fn(print_request_response)); diff --git a/src/htpasswd.rs b/src/htpasswd.rs new file mode 100644 index 0000000..c964e8e --- /dev/null +++ b/src/htpasswd.rs @@ -0,0 +1,224 @@ +use std::{ + collections::{btree_map::Entry, BTreeMap}, + fmt::{Display, Formatter}, + fs::{self, read_to_string}, + io::Write, + path::PathBuf, +}; + +use htpasswd_verify::md5::{format_hash, md5_apr1_encode}; +use rand::{distributions::Alphanumeric, thread_rng, Rng}; +use serde::Serialize; + +use crate::error::{ApiErrorKind, ApiResult, AppResult, ErrorKind}; + +pub mod constants { + pub(super) const SALT_LEN: usize = 8; +} + +#[derive(Clone, Debug, Default, Serialize)] +pub struct CredentialMap(BTreeMap); + +impl CredentialMap { + pub fn new() -> Self { + Self::default() + } +} + +impl std::ops::DerefMut for CredentialMap { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl std::ops::Deref for CredentialMap { + type Target = BTreeMap; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +#[derive(Clone, Debug, Default, Serialize)] +pub struct Htpasswd { + pub path: PathBuf, + pub credentials: CredentialMap, +} + +impl Htpasswd { + pub fn new() -> Self { + Self::default() + } + + pub fn from_file(pth: &PathBuf) -> AppResult { + let mut c = CredentialMap::new(); + + if pth.exists() { + read_to_string(pth) + .map_err(|err| { + ErrorKind::Io.context(format!( + "Could not read htpasswd file: {} at {:?}", + err, pth + )) + })? + .lines() // split the string into an iterator of string slices + .map(str::trim) + .map(String::from) // make each slice into a string + .filter_map(|s| Credential::from_line(s).ok()) + .for_each(|cred| { + let _ = c.insert(cred.name.clone(), cred); + }); + } + + Ok(Self { + path: pth.clone(), + credentials: c, + }) + } + + pub fn users(&self) -> Vec { + self.credentials.keys().cloned().collect() + } + + pub fn create(&mut self, name: &str, pass: &str) -> AppResult<()> { + let cred = Credential::new(name, pass); + + self.insert(cred)?; + + Ok(()) + } + + pub fn read(&self, name: &str) -> Option<&Credential> { + self.credentials.get(name) + } + + pub fn update(&mut self, name: &str, pass: &str) -> AppResult<()> { + let cred = Credential::new(name, pass); + + let _ = self + .credentials + .entry(name.to_owned()) + .and_modify(|entry| *entry = cred.clone()) + .or_insert(cred); + + Ok(()) + } + + /// Removes one credential by username + pub fn delete(&mut self, name: &str) -> Option { + self.credentials.remove(name) + } + + pub fn insert(&mut self, cred: Credential) -> AppResult<()> { + let Entry::Vacant(entry) = self.credentials.entry(cred.name.clone()) else { + return Err(ErrorKind::Io + .context(format!( + "Entry already exists, could not insert credential: `{}`. Please use update instead.", + cred.name.as_str() + )) + .into()); + }; + + let _ = entry.insert(cred); + + Ok(()) + } + + pub fn to_file(&self) -> ApiResult<()> { + let mut file = fs::OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .open(&self.path) + .map_err(|err| { + ApiErrorKind::OpeningFileFailed(format!( + "Could not open htpasswd file: {} at {:?}", + err, self.path + )) + })?; + + for (_n, c) in self.credentials.iter() { + let _e = file.write(c.to_string().as_bytes()).map_err(|err| { + ApiErrorKind::WritingToFileFailed(format!( + "Could not write to htpasswd file: {} at {:?}", + err, self.path + )) + }); + } + Ok(()) + } +} + +#[derive(Clone, Debug, Serialize)] +pub struct Credential { + name: String, + hash: String, +} + +impl Credential { + pub fn new(name: &str, pass: &str) -> Self { + let salt: String = thread_rng() + .sample_iter(&Alphanumeric) + .take(constants::SALT_LEN) + .map(char::from) + .collect(); + let hash = md5_apr1_encode(pass, salt.as_str()); + let hash = format_hash(hash.as_str(), salt.as_str()); + + Self { + name: name.into(), + hash, + } + } + + /// Returns a credential struct from a htpasswd file line + pub fn from_line(line: String) -> AppResult { + let split: Vec<&str> = line.split(':').collect(); + + if split.len() != 2 { + return Err(ErrorKind::Io + .context(format!( + "Could not parse htpasswd file line: `{}`. Expected format: `name:hash`", + line + )) + .into()); + } + + Ok(Self { + name: split[0].to_string(), + hash: split[1].to_string(), + }) + } +} + +impl Display for Credential { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + writeln!(f, "{}:{}", self.name, self.hash) + } +} + +#[cfg(test)] +mod test { + use crate::auth::Auth; + use crate::htpasswd::Htpasswd; + use anyhow::Result; + use insta::assert_ron_snapshot; + + #[test] + fn test_htpasswd_passes() -> Result<()> { + let mut htpasswd = Htpasswd::new(); + + let _ = htpasswd.update("Administrator", "stuff"); + let _ = htpasswd.update("backup-user", "its_me"); + + assert_ron_snapshot!(htpasswd, { + ".credentials.*.hash" => "", + }); + + let auth = Auth::from(htpasswd); + assert!(auth.verify("Administrator", "stuff")); + assert!(auth.verify("backup-user", "its_me")); + + Ok(()) + } +} diff --git a/src/lib.rs b/src/lib.rs index 53f7a68..aaedcdc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,16 +1,70 @@ +//! `RusticServer` +//! +//! Application based on the [Abscissa] framework. +//! +//! [Abscissa]: https://github.com/iqlusioninc/abscissa + +#![forbid(unsafe_code)] +#![warn( + // unreachable_pub, // frequently check + // TODO: Activate and create better docs + // missing_docs, + rust_2018_idioms, + trivial_casts, + unused_lifetimes, + unused_qualifications, + // TODO: Activate if you're feeling like fixing stuff + // clippy::pedantic, + // clippy::correctness, + // clippy::suspicious, + // clippy::complexity, + // clippy::perf, + clippy::nursery, + bad_style, + dead_code, + improper_ctypes, + missing_copy_implementations, + missing_debug_implementations, + non_shorthand_field_patterns, + no_mangle_generic_items, + overflowing_literals, + path_statements, + patterns_in_fns_without_body, + trivial_numeric_casts, + unused_results, + unused_extern_crates, + unused_import_braces, + unconditional_recursion, + unused, + unused_allocation, + unused_comparisons, + unused_parens, + while_true, + clippy::cast_lossless, + clippy::default_trait_access, + clippy::doc_markdown, + clippy::manual_string_new, + clippy::match_same_arms, + clippy::semicolon_if_nothing_returned, + clippy::trivially_copy_pass_by_ref +)] + pub mod acl; +pub mod application; pub mod auth; pub mod commands; pub mod config; pub mod error; pub mod handlers; +pub mod htpasswd; pub mod log; +pub mod prelude; pub mod storage; pub mod typed_path; /// Web module /// /// implements a REST server as specified by -/// https://restic.readthedocs.io/en/stable/REST_backend.html?highlight=Rest%20API +/// pub mod web; #[cfg(test)] diff --git a/src/log.rs b/src/log.rs index 848b36d..60c861c 100644 --- a/src/log.rs +++ b/src/log.rs @@ -1,77 +1,79 @@ -use std::str::FromStr; - use axum::{ body::{Body, Bytes}, extract::Request, middleware::Next, response::{IntoResponse, Response}, }; +use axum_macros::debug_middleware; use http_body_util::BodyExt; -use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; - -use crate::error::ErrorKind; -pub fn init_tracing() { - tracing_subscriber::registry() - .with( - tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| "RUSTIC_SERVER_LOG_LEVEL=debug".into()), - ) - .with(tracing_subscriber::fmt::layer()) - .init(); -} - -pub fn init_trace_from(level: &str) { - tracing_subscriber::registry() - .with(tracing_subscriber::EnvFilter::from_str(level).unwrap()) - .with(tracing_subscriber::fmt::layer()) - .init(); -} +use crate::error::ApiErrorKind; -/// router middleware function to print additional information on the request, and response. -/// Usage: -/// app = Router::new().layer(middleware::from_fn(print_request_response)) +/// Router middleware function to print additional information on the request and response. /// +/// # Usage +/// +/// Add this middleware to the router to print the request and response information. +/// +/// ```rust +/// use axum::Router; +/// +/// app = Router::new() +/// .layer(middleware::from_fn(print_request_response)) +/// ``` +#[debug_middleware] pub async fn print_request_response( req: Request, next: Next, -) -> Result { +) -> Result { let (parts, body) = req.into_parts(); - for (k, v) in parts.headers.iter() { - tracing::debug!("request-header: {k:?} -> {v:?} "); - } - tracing::debug!("request-uri: {}", parts.uri); - let bytes = buffer_and_print("request", body).await?; + let uuid = uuid::Uuid::new_v4(); + + tracing::debug!( + id = %uuid, + method = %parts.method, + uri = %parts.uri, + "[REQUEST]", + ); + + tracing::debug!(id = %uuid, headers = ?parts.headers, "[HEADERS]"); + + let bytes = buffer_and_print(&uuid, body).await?; + let req = Request::from_parts(parts, Body::from(bytes)); let res = next.run(req).await; - let (parts, body) = res.into_parts(); - for (k, v) in parts.headers.iter() { - tracing::debug!("reply-header: {k:?} -> {v:?} "); - } - let bytes = buffer_and_print("response", body).await?; + + tracing::debug!( + id = %uuid, + headers = ?parts.headers, + status = %parts.status, + "[RESPONSE]", + ); + + let bytes = buffer_and_print(&uuid, body).await?; let res = Response::from_parts(parts, Body::from(bytes)); Ok(res) } -async fn buffer_and_print(direction: &str, body: B) -> Result +async fn buffer_and_print(uuid: &uuid::Uuid, body: B) -> Result where - B: axum::body::HttpBody, + B: axum::body::HttpBody + Send, B::Error: std::fmt::Display, { let bytes = match body.collect().await { Ok(collected) => collected.to_bytes(), Err(err) => { - return Err(ErrorKind::BadRequest(format!( - "failed to read {direction} body: {err}" + return Err(ApiErrorKind::BadRequest(format!( + "failed to read body: {err}" ))); } }; if let Ok(body) = std::str::from_utf8(&bytes) { - tracing::debug!("{direction} body = {body:?}"); + tracing::debug!(id = %uuid, body = %body, "[BODY]"); } Ok(bytes) diff --git a/src/prelude.rs b/src/prelude.rs new file mode 100644 index 0000000..c365790 --- /dev/null +++ b/src/prelude.rs @@ -0,0 +1,9 @@ +//! Application-local prelude: conveniently import types/functions/macros +//! which are generally useful and should be available in every module with +//! `use crate::prelude::*;` + +/// Abscissa core prelude +pub use abscissa_core::prelude::*; + +/// Application state +pub use crate::application::RUSTIC_SERVER_APP; diff --git a/src/snapshots/rustic_server__acl__tests__repo_acl_passes.snap b/src/snapshots/rustic_server__acl__tests__repo_acl_passes.snap new file mode 100644 index 0000000..afd5c2e --- /dev/null +++ b/src/snapshots/rustic_server__acl__tests__repo_acl_passes.snap @@ -0,0 +1,28 @@ +--- +source: src/acl.rs +expression: acl +--- +Acl { + repos: { + "all": RepoAcl( + { + "bob": Modify, + "paul": Read, + "sam": Append, + }, + ), + "bob": RepoAcl( + { + "bob": Modify, + }, + ), + "sam": RepoAcl( + { + "bob": Read, + "sam": Append, + }, + ), + }, + append_only: true, + private_repo: true, +} diff --git a/src/snapshots/rustic_server__config__test__file_read.snap b/src/snapshots/rustic_server__config__test__file_read.snap new file mode 100644 index 0000000..d77236c --- /dev/null +++ b/src/snapshots/rustic_server__config__test__file_read.snap @@ -0,0 +1,37 @@ +--- +source: src/config.rs +expression: config +--- +RusticServerConfig { + server: ConnectionSettings { + listen: "127.0.0.1:8000", + }, + storage: StorageSettings { + data_dir: Some( + "tests/generated/test_storage/", + ), + max_size: None, + }, + auth: HtpasswdSettings { + disable_auth: false, + htpasswd_file: Some( + "tests/fixtures/test_data/.htpasswd", + ), + }, + acl: AclSettings { + acl_path: Some( + "tests/fixtures/test_data/acl.toml", + ), + private_repo: true, + append_only: false, + }, + tls: TlsSettings { + tls: false, + tls_key: None, + tls_cert: None, + }, + log: LogSettings { + log_level: None, + log_file: None, + }, +} diff --git a/src/snapshots/rustic_server__htpasswd__test__htpasswd_passes.snap b/src/snapshots/rustic_server__htpasswd__test__htpasswd_passes.snap new file mode 100644 index 0000000..be11ecf --- /dev/null +++ b/src/snapshots/rustic_server__htpasswd__test__htpasswd_passes.snap @@ -0,0 +1,17 @@ +--- +source: src/htpasswd.rs +expression: htpasswd +--- +Htpasswd( + path: "", + credentials: CredentialMap({ + "Administrator": Credential( + name: "Administrator", + hash: "", + ), + "backup-user": Credential( + name: "backup-user", + hash: "", + ), + }), +) diff --git a/src/storage.rs b/src/storage.rs index 2e35917..829d5d8 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -7,14 +7,14 @@ use tokio::fs::{create_dir_all, remove_dir_all, remove_file, File}; use walkdir::WalkDir; use crate::{ - error::{ErrorKind, Result}, + error::{ApiErrorKind, ApiResult, AppResult}, handlers::file_helpers::WriteOrDeleteFile, }; //Static storage of our credentials pub static STORAGE: OnceLock> = OnceLock::new(); -pub(crate) fn init_storage(storage: impl Storage) -> Result<()> { +pub(crate) fn init_storage(storage: impl Storage) -> AppResult<()> { let _ = STORAGE.get_or_init(|| Arc::new(storage)); Ok(()) } @@ -22,22 +22,28 @@ pub(crate) fn init_storage(storage: impl Storage) -> Result<()> { #[async_trait::async_trait] //#[enum_dispatch(StorageEnum)] pub trait Storage: Send + Sync + 'static { - async fn create_dir(&self, path: &Path, tpe: Option<&str>) -> Result<()>; + async fn create_dir(&self, path: &Path, tpe: Option<&str>) -> ApiResult<()>; + fn read_dir( &self, path: &Path, tpe: Option<&str>, ) -> Box>; + fn filename(&self, path: &Path, tpe: &str, name: Option<&str>) -> PathBuf; - async fn open_file(&self, path: &Path, tpe: &str, name: Option<&str>) -> Result; + + async fn open_file(&self, path: &Path, tpe: &str, name: Option<&str>) -> ApiResult; + async fn create_file( &self, path: &Path, tpe: &str, name: Option<&str>, - ) -> Result; - async fn remove_file(&self, path: &Path, tpe: &str, name: Option<&str>) -> Result<()>; - async fn remove_repository(&self, path: &Path) -> Result<()>; + ) -> ApiResult; + + async fn remove_file(&self, path: &Path, tpe: &str, name: Option<&str>) -> ApiResult<()>; + + async fn remove_repository(&self, path: &Path) -> ApiResult<()>; } #[derive(Debug, Clone)] @@ -59,7 +65,7 @@ impl Default for LocalStorage { } impl LocalStorage { - pub fn try_new(path: &Path) -> Result { + pub fn try_new(path: &Path) -> AppResult { Ok(Self { path: path.to_path_buf(), }) @@ -68,27 +74,29 @@ impl LocalStorage { #[async_trait::async_trait] impl Storage for LocalStorage { - async fn create_dir(&self, path: &Path, tpe: Option<&str>) -> Result<()> { + async fn create_dir(&self, path: &Path, tpe: Option<&str>) -> ApiResult<()> { match tpe { Some(tpe) if tpe == "data" => { for i in 0..256 { create_dir_all(self.path.join(path).join(tpe).join(format!("{:02x}", i))) .await .map_err(|err| { - ErrorKind::CreatingDirectoryFailed(format!( + ApiErrorKind::CreatingDirectoryFailed(format!( "Could not create directory: {err}" )) - })? + })?; } Ok(()) } Some(tpe) => create_dir_all(self.path.join(path).join(tpe)) .await .map_err(|err| { - ErrorKind::CreatingDirectoryFailed(format!("Could not create directory: {err}")) + ApiErrorKind::CreatingDirectoryFailed(format!( + "Could not create directory: {err}" + )) }), None => create_dir_all(self.path.join(path)).await.map_err(|err| { - ErrorKind::CreatingDirectoryFailed(format!("Could not create directory: {err}")) + ApiErrorKind::CreatingDirectoryFailed(format!("Could not create directory: {err}")) }), } } @@ -99,16 +107,17 @@ impl Storage for LocalStorage { path: &Path, tpe: Option<&str>, ) -> Box> { - let path = if let Some(tpe) = tpe { - self.path.join(path).join(tpe) - } else { - self.path.join(path) - }; + let path = tpe.map_or_else( + || self.path.join(path), + |tpe| self.path.join(path).join(tpe), + ); let walker = WalkDir::new(path) .into_iter() .filter_map(walkdir::Result::ok) + // FIXME: Why do we filter out directories!? .filter(|e| e.file_type().is_file()); + Box::new(walker) } @@ -121,11 +130,11 @@ impl Storage for LocalStorage { } } - async fn open_file(&self, path: &Path, tpe: &str, name: Option<&str>) -> Result { + async fn open_file(&self, path: &Path, tpe: &str, name: Option<&str>) -> ApiResult { let file_path = self.filename(path, tpe, name); - Ok(File::open(file_path) - .await - .map_err(|err| ErrorKind::OpeningFileFailed(format!("Could not open file: {}", err)))?) + Ok(File::open(file_path).await.map_err(|err| { + ApiErrorKind::OpeningFileFailed(format!("Could not open file: {}", err)) + })?) } async fn create_file( @@ -133,25 +142,25 @@ impl Storage for LocalStorage { path: &Path, tpe: &str, name: Option<&str>, - ) -> Result { + ) -> ApiResult { let file_path = self.filename(path, tpe, name); WriteOrDeleteFile::new(file_path).await } - async fn remove_file(&self, path: &Path, tpe: &str, name: Option<&str>) -> Result<()> { + async fn remove_file(&self, path: &Path, tpe: &str, name: Option<&str>) -> ApiResult<()> { let file_path = self.filename(path, tpe, name); - remove_file(file_path) - .await - .map_err(|err| ErrorKind::RemovingFileFailed(format!("Could not remove file: {err}"))) + remove_file(file_path).await.map_err(|err| { + ApiErrorKind::RemovingFileFailed(format!("Could not remove file: {err}")) + }) } - async fn remove_repository(&self, path: &Path) -> Result<()> { + async fn remove_repository(&self, path: &Path) -> ApiResult<()> { tracing::debug!( "Deleting repository: {}", self.path.join(path).to_string_lossy() ); remove_dir_all(self.path.join(path)).await.map_err(|err| { - ErrorKind::RemovingRepositoryFailed(format!("Could not remove repository: {err}")) + ApiErrorKind::RemovingRepositoryFailed(format!("Could not remove repository: {err}")) }) } } @@ -159,20 +168,12 @@ impl Storage for LocalStorage { #[cfg(test)] mod test { use crate::storage::{init_storage, LocalStorage, STORAGE}; - use std::env; use std::path::PathBuf; #[test] fn test_file_access_passes() { - let cwd = env::current_dir().unwrap(); - let repo_path = PathBuf::new() - .join(cwd) - .join("tests") - .join("fixtures") - .join("test_data") - .join("test_repos"); - - let local_storage = LocalStorage::try_new(&repo_path).unwrap(); + let local_storage = + LocalStorage::try_new(&PathBuf::from("tests/generated/test_storage")).unwrap(); init_storage(local_storage).unwrap(); let storage = STORAGE.get().unwrap(); @@ -183,7 +184,7 @@ mod test { let mut found = false; for a in c.into_iter() { let file_name = a.file_name().to_string_lossy(); - if file_name == "2e734da3fccb98724ece44efca027652ba7a335c224448a68772b41c0d9229d5" { + if file_name == "3f918b737a2b9f72f044d06d6009eb34e0e8d06668209be3ce86e5c18dac0295" { found = true; break; } @@ -193,15 +194,8 @@ mod test { #[tokio::test] async fn test_config_access_passes() { - let cwd = env::current_dir().unwrap(); - let repo_path = PathBuf::new() - .join(cwd) - .join("tests") - .join("fixtures") - .join("test_data") - .join("test_repos"); - - let local_storage = LocalStorage::try_new(&repo_path).unwrap(); + let local_storage = + LocalStorage::try_new(&PathBuf::from("tests/generated/test_storage")).unwrap(); init_storage(local_storage).unwrap(); let storage = STORAGE.get().unwrap(); @@ -209,6 +203,6 @@ mod test { // path must not start with slash !! that will skip the self.path from Storage! let path = PathBuf::new().join("test_repo/"); let c = storage.open_file(&path, "", Some("config")).await; - assert!(c.is_ok()) + assert!(c.is_ok()); } } diff --git a/src/test_helpers.rs b/src/test_helpers.rs index 5c2dced..d05e802 100644 --- a/src/test_helpers.rs +++ b/src/test_helpers.rs @@ -1,4 +1,4 @@ -use std::{env, path::PathBuf, sync::Mutex, sync::OnceLock}; +use std::{path::PathBuf, sync::Mutex, sync::OnceLock}; use axum::{ body::Body, @@ -10,6 +10,7 @@ use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use crate::{ acl::{init_acl, Acl}, auth::{init_auth, Auth}, + config::{AclSettings, HtpasswdSettings, RusticServerConfig, StorageSettings}, storage::{init_storage, LocalStorage}, }; @@ -25,7 +26,7 @@ pub fn request_uri_for_test(uri: &str, method: Method) -> axum::http::Request> = OnceLock::new(); fn init_mutex() { - TRACER.get_or_init(|| { + let _ = TRACER.get_or_init(|| { tracing_subscriber::registry() .with( tracing_subscriber::EnvFilter::try_from_default_env() @@ -61,49 +62,34 @@ fn init_mutex() { // test facility for creating a minimum test environment // ------------------------------------------------ -pub(crate) fn init_test_environment() { +pub(crate) fn server_config() -> RusticServerConfig { + let server_config_path = PathBuf::from("tests/fixtures/test_data/rustic_server.toml"); + RusticServerConfig::from_file(&server_config_path).unwrap() +} + +pub(crate) fn init_test_environment(server_config: RusticServerConfig) { init_tracing(); - init_static_htaccess(); - init_static_auth(); - init_static_storage(); + init_static_htpasswd(server_config.auth); + init_static_auth(server_config.acl); + init_static_storage(server_config.storage); } -fn init_static_htaccess() { - let cwd = env::current_dir().unwrap(); - let htaccess = PathBuf::new() - .join(cwd) - .join("tests") - .join("fixtures") - .join("test_data") - .join("htaccess"); - tracing::debug!("[test_init_static_storage] repo: {:?}", &htaccess); - let auth = Auth::from_file(false, &htaccess).unwrap(); +fn init_static_htpasswd(htpasswd_settings: HtpasswdSettings) { + let auth = Auth::from_config(&htpasswd_settings).unwrap(); init_auth(auth).unwrap(); } -fn init_static_auth() { - let cwd = env::current_dir().unwrap(); - let acl_path = PathBuf::new() - .join(cwd) - .join("tests") - .join("fixtures") - .join("test_data") - .join("acl.toml"); - tracing::debug!("[test_init_static_storage] repo: {:?}", &acl_path); - let acl = Acl::from_file(false, true, Some(acl_path)).unwrap(); +fn init_static_auth(acl_settings: AclSettings) { + let acl = Acl::from_config(&acl_settings).unwrap(); init_acl(acl).unwrap(); } -fn init_static_storage() { - let cwd = env::current_dir().unwrap(); - let repo_path = PathBuf::new() - .join(cwd) - .join("tests") - .join("fixtures") - .join("test_data") - .join("test_repos"); - tracing::debug!("[test_init_static_storage] repo: {:?}", &repo_path); - let local_storage = LocalStorage::try_new(&repo_path).unwrap(); +fn init_static_storage(storage_settings: StorageSettings) { + let data_dir = storage_settings + .data_dir + .unwrap_or_else(|| PathBuf::from("tests/generate/test_storage/")); + + let local_storage = LocalStorage::try_new(&data_dir).unwrap(); init_storage(local_storage).unwrap(); } diff --git a/src/typed_path.rs b/src/typed_path.rs index 6f3e69e..e047ec7 100644 --- a/src/typed_path.rs +++ b/src/typed_path.rs @@ -2,7 +2,7 @@ use axum_extra::routing::TypedPath; use serde_derive::{Deserialize, Serialize}; use strum::{AsRefStr, Display, EnumString, IntoStaticStr, VariantNames}; -pub trait PathParts { +pub trait PathParts: Send { fn parts(&self) -> (Option, Option, Option) { (self.repo(), self.tpe(), self.name()) } @@ -55,7 +55,7 @@ impl TpeKind { } // A type safe route with `"/:repo/config"` as its associated path. -#[derive(TypedPath, Deserialize)] +#[derive(TypedPath, Deserialize, Debug)] #[typed_path("/:repo/config")] pub struct RepositoryConfigPath { pub repo: String, @@ -67,9 +67,9 @@ impl PathParts for RepositoryConfigPath { } } -// A type safe route with `"/:repo"` as its associated path. -#[derive(TypedPath, Deserialize)] -#[typed_path("/:repo")] +// A type safe route with `"/:repo/"` as its associated path. +#[derive(TypedPath, Deserialize, Debug)] +#[typed_path("/:repo/")] pub struct RepositoryPath { pub repo: String, } @@ -81,7 +81,7 @@ impl PathParts for RepositoryPath { } // A type safe route with `"/:tpe"` as its associated path. -#[derive(TypedPath, Deserialize)] +#[derive(TypedPath, Deserialize, Debug, Copy, Clone)] #[typed_path("/:tpe")] pub struct TpePath { pub tpe: TpeKind, @@ -93,9 +93,9 @@ impl PathParts for TpePath { } } -// A type safe route with `"/:repo/:tpe"` as its associated path. -#[derive(TypedPath, Deserialize)] -#[typed_path("/:repo/:tpe")] +// A type safe route with `"/:repo/:tpe/"` as its associated path. +#[derive(TypedPath, Deserialize, Debug)] +#[typed_path("/:repo/:tpe/")] pub struct RepositoryTpePath { pub repo: String, pub tpe: TpeKind, @@ -112,7 +112,7 @@ impl PathParts for RepositoryTpePath { } // A type safe route with `"/:tpe/:name"` as its associated path. -#[derive(TypedPath, Deserialize)] +#[derive(TypedPath, Deserialize, Debug)] #[typed_path("/:tpe/:name")] pub struct TpeNamePath { pub tpe: TpeKind, @@ -130,7 +130,7 @@ impl PathParts for TpeNamePath { } // A type safe route with `"/:repo/:tpe/:name"` as its associated path. -#[derive(TypedPath, Deserialize)] +#[derive(TypedPath, Deserialize, Debug)] #[typed_path("/:repo/:tpe/:name")] pub struct RepositoryTpeNamePath { pub repo: String, diff --git a/src/web.rs b/src/web.rs index c181edb..e5c592a 100644 --- a/src/web.rs +++ b/src/web.rs @@ -1,16 +1,16 @@ use std::net::SocketAddr; -use axum::routing::{delete, get, head, post}; use axum::{middleware, Router}; +use axum_extra::routing::RouterExt; use axum_server::tls_rustls::RustlsConfig; use tokio::net::TcpListener; -use tracing::level_filters::LevelFilter; +use tracing::{info, level_filters::LevelFilter}; -use crate::typed_path::{RepositoryConfigPath, RepositoryPath}; use crate::{ acl::{init_acl, Acl}, auth::{init_auth, Auth}, - error::Result, + config::LogSettings, + error::{AppResult, ErrorKind}, handlers::{ file_config::{add_config, delete_config, get_config, has_config}, file_exchange::{add_file, delete_file, get_file}, @@ -20,123 +20,196 @@ use crate::{ }, log::print_request_response, storage::{init_storage, Storage}, - typed_path::{RepositoryTpeNamePath, RepositoryTpePath, TpeNamePath, TpePath}, + typed_path::{RepositoryTpeNamePath, RepositoryTpePath}, +}; +use crate::{ + config::TlsSettings, + typed_path::{RepositoryConfigPath, RepositoryPath}, }; -// TPE_LOCKS is defined, but outside the types[] array. -// This allows us to loop over the types[] when generating "routes" -pub(crate) const TPE_DATA: &str = "data"; -pub(crate) const TPE_KEYS: &str = "keys"; -pub(crate) const TPE_LOCKS: &str = "locks"; -pub(crate) const TPE_SNAPSHOTS: &str = "snapshots"; -pub(crate) const TPE_INDEX: &str = "index"; -pub(crate) const _TPE_CONFIG: &str = "config"; -pub(crate) const TYPES: [&str; 5] = [TPE_DATA, TPE_KEYS, TPE_LOCKS, TPE_SNAPSHOTS, TPE_INDEX]; - +/// Start the web server +/// +/// # Arguments +/// +/// * `acl` - The ACL configuration +/// * `auth` - The Auth configuration +/// * `storage` - The Storage configuration +/// * `socket_address` - The socket address to bind to +/// * `tls` - Enable TLS +/// * `cert` - The certificate file +/// * `key` - The key file pub async fn start_web_server( acl: Acl, auth: Auth, storage: impl Storage, socket_address: SocketAddr, - tls: bool, - cert: Option, - key: Option, -) -> Result<()> { + tls_opts: &TlsSettings, + _log_opts: &LogSettings, +) -> AppResult<()> { init_acl(acl)?; init_auth(auth)?; init_storage(storage)?; - // ------------------------------------- - // Create routing structure - // ------------------------------------- let mut app = Router::new(); + // /:repo/:tpe/:name + app = app + // Returns “200 OK” if the blob with the given name and type is stored in the repository, + // “404 not found” otherwise. If the blob exists, the HTTP header Content-Length + // is set to the file size. + .typed_head(file_length::) + // Returns the content of the blob with the given name and type if it is stored + // in the repository, “404 not found” otherwise. + // If the request specifies a partial read with a Range header field, then the + // status code of the response is 206 instead of 200 and the response only contains + // the specified range. + // + // Response format: binary/octet-stream + .typed_get(get_file::) + // Saves the content of the request body as a blob with the given name and type, + // an HTTP error otherwise. + // + // Request format: binary/octet-stream + .typed_post(add_file::) + // Returns “200 OK” if the blob with the given name and type has been deleted from + // the repository, an HTTP error otherwise. + .typed_delete(delete_file::); + // /:repo/config app = app - .route("/:repo/config", head(has_config)) - .route("/:repo/config", post(add_config::)) - .route("/:repo/config", get(get_config::)) - .route( - "/:repo/config", - delete(delete_config::), - ); + // Returns “200 OK” if the repository has a configuration, an HTTP error otherwise. + .typed_head(has_config) + // Returns the content of the configuration file if the repository has a configuration, + // an HTTP error otherwise. + // + // Response format: binary/octet-stream + .typed_get(get_config::) + // Returns “200 OK” if the configuration of the request body has been saved, + // an HTTP error otherwise. + .typed_post(add_config::) + // Returns “200 OK” if the configuration of the repository has been deleted, + // an HTTP error otherwise. + // Note: This is not part of the API documentation, but it is implemented + // to allow for the deletion of the configuration file during testing. + .typed_delete(delete_config::); - // /:tpe --> note: NO trailing slash - // we loop here over explicit types, to prevent the conflict with paths "/:repo/" - for tpe in TYPES.into_iter() { - let path = format!("/{}", &tpe); - app = app.route(path.as_str(), get(list_files::)); - } + // /:repo/:tpe/ + // # API version 1 + // + // Returns a JSON array containing the names of all the blobs stored for a given type, example: + // + // ```json + // [ + // "245bc4c430d393f74fbe7b13325e30dbde9fb0745e50caad57c446c93d20096b", + // "85b420239efa1132c41cea0065452a40ebc20c6f8e0b132a5b2f5848360973ec", + // "8e2006bb5931a520f3c7009fe278d1ebb87eb72c3ff92a50c30e90f1b8cf3e60", + // "e75c8c407ea31ba399ab4109f28dd18c4c68303d8d86cc275432820c42ce3649" + // ] + // ``` + // + // # API version 2 + // + // Returns a JSON array containing an object for each file of the given type. + // The objects have two keys: name for the file name, and size for the size in bytes. + // + // [ + // { + // "name": "245bc4c430d393f74fbe7b13325e30dbde9fb0745e50caad57c446c93d20096b", + // "size": 2341058 + // }, + // { + // "name": "85b420239efa1132c41cea0065452a40ebc20c6f8e0b132a5b2f5848360973ec", + // "size": 2908900 + // }, + // { + // "name": "8e2006bb5931a520f3c7009fe278d1ebb87eb72c3ff92a50c30e90f1b8cf3e60", + // "size": 3030712 + // }, + // { + // "name": "e75c8c407ea31ba399ab4109f28dd18c4c68303d8d86cc275432820c42ce3649", + // "size": 2804 + // } + // ] + app = app.typed_get(list_files::); // /:repo/ --> note: trailing slash app = app - .route("/:repo/", post(create_repository::)) - .route("/:repo/", delete(delete_repository::)); - - // /:tpe/:name - // we loop here over explicit types, to prevent conflict with paths "/:repo/:tpe" - for tpe in TYPES.into_iter() { - let path = format!("/{}:name", &tpe); - app = app - .route(path.as_str(), head(file_length::)) - .route(path.as_str(), get(get_file::)) - .route(path.as_str(), post(add_file::)) - .route(path.as_str(), delete(delete_file::)); - } + // This request is used to initially create a new repository. + // The server responds with “200 OK” if the repository structure was created + // successfully or already exists, otherwise an error is returned. + .typed_post(create_repository::) + // Deletes the repository on the server side. The server responds with “200 OK” + // if the repository was successfully removed. If this function is not implemented + // the server returns “501 Not Implemented”, if this it is denied by the server it + // returns “403 Forbidden”. + .typed_delete(delete_repository::); - // /:repo/:tpe - app = app.route("/:repo/:tpe", get(list_files::)); + // TODO: This is not reflected in the API documentation? + // TODO: Decide if we want to keep this or not! + // // /:tpe/:name + // // we loop here over explicit types, to prevent conflict with paths "/:repo/:tpe" + // for tpe in constants::TYPES.into_iter() { + // let path = format!("/{}:name", &tpe); + // app = app + // .route(path.as_str(), head(file_length::)) + // .route(path.as_str(), get(get_file::)) + // .route(path.as_str(), post(add_file::)) + // .route(path.as_str(), delete(delete_file::)); + // } + // + // /:tpe --> note: NO trailing slash + // we loop here over explicit types, to prevent the conflict with paths "/:repo/" + // for tpe in constants::TYPES.into_iter() { + // let path = format!("/{}", &tpe); + // app = app.route(path.as_str(), get(list_files::)); + // } - // /:repo/:tpe/:name - app = app - .route( - "/:repo/:tpe/:name", - head(file_length::), - ) - .route("/:repo/:tpe/:name", get(get_file::)) - .route("/:repo/:tpe/:name", post(add_file::)) - .route( - "/:repo/:tpe/:name", - delete(delete_file::), - ); - - // ----------------------------------------------- // Extra logging requested. Handlers will log too - // ---------------------------------------------- - let level_filter = LevelFilter::current(); - match level_filter { + // TODO: Use LogSettings here, this should be set from the cli by `--log` + // TODO: and then needs to go to a file + // e.g. log_opts.is_disabled() or other checks + match LevelFilter::current() { LevelFilter::TRACE | LevelFilter::DEBUG | LevelFilter::INFO => { app = app.layer(middleware::from_fn(print_request_response)); } _ => {} }; - // ----------------------------------------------- + let TlsSettings { + tls, + tls_cert, + tls_key, + } = tls_opts; + // Start server with or without TLS - // ----------------------------------------------- - match tls { - false => { - println!("rustic_server listening on {}", &socket_address); - axum::serve( - TcpListener::bind(socket_address).await.unwrap(), - app.into_make_service(), - ) - .await - .unwrap(); - } - true => { - assert!(cert.is_some()); - assert!(key.is_some()); - let config = RustlsConfig::from_pem_file(cert.unwrap(), key.unwrap()) - .await - .unwrap(); + if !tls { + info!("[serve] Listening on: http://{}", socket_address); - println!("rustic_server listening on {}", &socket_address); - axum_server::bind_rustls(socket_address, config) - .serve(app.into_make_service()) + axum::serve( + TcpListener::bind(socket_address) .await - .unwrap(); - } + .expect("Failed to bind to socket. Please make sure the address is correct."), + app.into_make_service(), + ) + .await + .expect("Failed to start server. Is the address already in use?"); + } else { + let (Some(cert), Some(key)) = (tls_cert.as_ref(), tls_key.as_ref()) else { + return Err(ErrorKind::MissingUserInput + .context("TLS certificate or key not specified".to_string()) + .into()); + }; + + let config = RustlsConfig::from_pem_file(cert, key) + .await + .expect("Failed to load TLS certificate/key. Please make sure the paths are correct."); + + info!("[serve] Listening on: https://{}", socket_address); + + axum_server::bind_rustls(socket_address, config) + .serve(app.into_make_service()) + .await + .expect("Failed to start server. Is the address already in use?"); } Ok(()) } diff --git a/tests/acceptance.rs b/tests/acceptance.rs new file mode 100644 index 0000000..1665c92 --- /dev/null +++ b/tests/acceptance.rs @@ -0,0 +1,101 @@ +//! Acceptance test: runs the application as a subprocess and asserts its +//! output for given argument combinations matches what is expected. +//! +//! Modify and/or delete these as you see fit to test the specific needs of +//! your application. +//! +//! For more information, see: +//! + +// Tip: Deny warnings with `RUSTFLAGS="-D warnings"` environment variable in CI + +#![forbid(unsafe_code)] +#![warn( + missing_docs, + rust_2018_idioms, + trivial_casts, + unused_lifetimes, + unused_qualifications +)] + +use abscissa_core::testing::prelude::*; +use once_cell::sync::Lazy; +use std::io::Read; + +/// Executes your application binary via `cargo run`. +/// +/// Storing this value as a [`Lazy`] static ensures that all instances of +/// the runner acquire a mutex when executing commands and inspecting +/// exit statuses, serializing what would otherwise be multithreaded +/// invocations as `cargo test` executes tests in parallel by default. +pub static RUNNER: Lazy = Lazy::new(CmdRunner::default); + +// /// Use `RusticServerConfig::default()` value if no config or args +// #[test] +// fn start_no_args() { +// let mut runner = RUNNER.clone(); +// let mut cmd = runner.arg("start").capture_stdout().run(); +// cmd.stdout().expect_line("Hello, world!"); +// cmd.wait().unwrap().expect_success(); +// } + +// /// Use command-line argument value +// #[test] +// fn start_with_args() { +// let mut runner = RUNNER.clone(); +// let mut cmd = runner +// .args(&["start", "acceptance", "test"]) +// .capture_stdout() +// .run(); + +// cmd.stdout().expect_line("Hello, acceptance test!"); +// cmd.wait().unwrap().expect_success(); +// } + +// /// Use configured value +// #[test] +// fn start_with_config_no_args() { +// let mut config = RusticServerConfig::default(); +// config.hello.recipient = "configured recipient".to_owned(); +// let expected_line = format!("Hello, {}!", &config.hello.recipient); + +// let mut runner = RUNNER.clone(); +// let mut cmd = runner.config(&config).arg("start").capture_stdout().run(); +// cmd.stdout().expect_line(&expected_line); +// cmd.wait().unwrap().expect_success(); +// } + +// /// Override configured value with command-line argument +// #[test] +// fn start_with_config_and_args() { +// let mut config = RusticServerConfig::default(); +// config.hello.recipient = "configured recipient".to_owned(); + +// let mut runner = RUNNER.clone(); +// let mut cmd = runner +// .config(&config) +// .args(&["start", "acceptance", "test"]) +// .capture_stdout() +// .run(); + +// cmd.stdout().expect_line("Hello, acceptance test!"); +// cmd.wait().unwrap().expect_success(); +// } + +/// Example of a test which matches a regular expression +#[test] +fn version_no_args() { + let mut runner = RUNNER.clone(); + let mut cmd = runner.arg("--version").capture_stdout().run(); + let mut buf = String::new(); + let _ = cmd.stdout().read_to_string(&mut buf); + if buf.contains(env!("CARGO_PKG_VERSION")) { + cmd.wait().unwrap().expect_success(); + } else { + panic!( + "Version mismatch: expected {} but got {}", + env!("CARGO_PKG_VERSION"), + buf + ); + } +} diff --git a/tests/fixtures/hurl/endpoints.hurl b/tests/fixtures/hurl/endpoints.hurl new file mode 100644 index 0000000..63241a2 --- /dev/null +++ b/tests/fixtures/hurl/endpoints.hurl @@ -0,0 +1,95 @@ +# No auth +HEAD http://127.0.0.1:8000/ci_repo/config +HTTP 403 + +# Access a new repository +HEAD http://127.0.0.1:8000/ci_repo/ +[BasicAuth] +hurl: hurl +HTTP 405 + +# Create a new repository +POST http://127.0.0.1:8000/ci_repo/?create=true +[BasicAuth] +hurl: hurl +HTTP 200 + + +HEAD http://127.0.0.1:8000/ci_repo/config +[BasicAuth] +hurl: hurl +HTTP 200 + +# Access to keys +GET http://127.0.0.1:8000/ci_repo/keys/ +[BasicAuth] +hurl: hurl +HTTP 200 +Content-Type: application/vnd.x.restic.rest.v1 + +GET http://127.0.0.1:8000/ci_repo/keys/eb7e523a1916c2cc1c750dc89cd6024f5dd319814c417a3f9081578f8c2c4a76 +RANGE: bytes=0-230 +[BasicAuth] +hurl: hurl +HTTP 206 +content-length: 231 + +# GET http://127.0.0.1:8000/ci_repo/keys/eb7e523a1916c2cc1c750dc89cd6024f5dd319814c417a3f9081578f8c2c4a76 +# [BasicAuth] +# hurl: hurl +# HTTP 200 + +GET http://127.0.0.1:8000/ci_repo/config +[BasicAuth] +hurl: hurl +HTTP 200 + +GET http://127.0.0.1:8000/ci_repo/locks/ +[BasicAuth] +hurl: hurl +HTTP 200 + +# POST http://127.0.0.1:8000/ci_repo/locks/ac4ff62472b009cf71c81199f4fc635152639909cb1143911150db467ca86544 +# [BasicAuth] +# hurl: hurl +# HTTP 200 + +GET http://127.0.0.1:8000/ci_repo/locks/ +[BasicAuth] +hurl: hurl +HTTP 200 + +GET http://127.0.0.1:8000/ci_repo/snapshots/ +[BasicAuth] +hurl: hurl +HTTP 200 + +GET http://127.0.0.1:8000/ci_repo/index/ +[BasicAuth] +hurl: hurl +HTTP 200 + +# POST http://127.0.0.1:8000/ci_repo/data/3b013253cd72fa7e98f9dcd6106f9565933556f1c80a720e1e44dbf3b57af446 +# [BasicAuth] +# hurl: hurl +# HTTP 200 + +# POST http://127.0.0.1:8000/ci_repo/data/89923722810777f3026a7cf9b246eb9613c7e3f64e77e6ccecb4001774c38acf +# [BasicAuth] +# hurl: hurl +# HTTP 200 + +# POST http://127.0.0.1:8000/ci_repo/index/004ebe81e7927131b6dde40bd4595ebf95a355bf26509de83fb9d12b4ab280b4 +# [BasicAuth] +# hurl: hurl +# HTTP 200 + +# POST http://127.0.0.1:8000/ci_repo/snapshots/ddd75013f2d2470d910adadf728c5c8cd6e91cb591bdfbf1cfd7f3af7e32c7eb +# [BasicAuth] +# hurl: hurl +# HTTP 200 + +# DELETE http://127.0.0.1:8000/ci_repo/locks/ac4ff62472b009cf71c81199f4fc635152639909cb1143911150db467ca86544 +# [BasicAuth] +# hurl: hurl +# HTTP 200 diff --git a/tests/fixtures/test_data/.htpasswd b/tests/fixtures/test_data/.htpasswd new file mode 100644 index 0000000..512c419 --- /dev/null +++ b/tests/fixtures/test_data/.htpasswd @@ -0,0 +1,2 @@ +restic:$2y$05$iKXd4X4AKOpBPufMhlSfwOQqrl/nu1A9yAFbKYG742cJz325qeB/a +hurl:$2y$05$63hF2CpYPDYuM3Jlm04hH.TYIxGo6nk1eFjVBHd06X7LLRcTFyMz2 diff --git a/tests/fixtures/test_data/README.md b/tests/fixtures/test_data/README.md index 2a0f156..86764c5 100644 --- a/tests/fixtures/test_data/README.md +++ b/tests/fixtures/test_data/README.md @@ -7,14 +7,14 @@ against the rustic server? ## Basic files for test access to a repository -### `HTACCESS` +### `htpasswd` File governing the access to the server. Without access all is rejected. -htaccess file has one entry: +htpasswd file has one entry: -- user: test -- password: test_pw +- user: restic +- password: restic ### `acl.toml` @@ -23,7 +23,7 @@ repository. Most used seems to be the `test_repo` with members -- user: test +- user: restic - Access level: Read But there are 2 more in the file. ### `rustic_server.toml` @@ -31,9 +31,9 @@ Most used seems to be the `test_repo` with members Server configuration file which allows the `rustic_server` to be started with only a pointer to this file. This file points to: -- HTACCESS file +- htpasswd file - **Note**: that the HTACCESS file does not need to be a hidden file. Rustic + **Note**: that the htpasswd file does not need to be a hidden file. Rustic will use the file you point to. - acl.toml file - path to: repository (where all your backups are) diff --git a/tests/fixtures/test_data/acl.toml b/tests/fixtures/test_data/acl.toml index d7b9ba7..735fd7b 100644 --- a/tests/fixtures/test_data/acl.toml +++ b/tests/fixtures/test_data/acl.toml @@ -1,8 +1,13 @@ [test_repo] -test = "Append" +restic = "Append" +hurl = "Append" [repo_remove_me] -test = "Modify" +restic = "Modify" [repo_remove_me_2] -test = "Modify" +restic = "Modify" + +[ci_repo] +restic = "Modify" +hurl = "Modify" diff --git a/tests/fixtures/test_data/htaccess b/tests/fixtures/test_data/htaccess deleted file mode 100644 index 2aef889..0000000 --- a/tests/fixtures/test_data/htaccess +++ /dev/null @@ -1 +0,0 @@ -test:$apr1$631R5SLJ$yQvTCYnaVXJsHq.pXctqB1 diff --git a/tests/fixtures/test_data/rustic_server.toml b/tests/fixtures/test_data/rustic_server.toml index e3525dc..2431a53 100644 --- a/tests/fixtures/test_data/rustic_server.toml +++ b/tests/fixtures/test_data/rustic_server.toml @@ -1,18 +1,14 @@ -[server] -host_dns_name = "127.0.0.1" -port = 8000 -protocol = "http" -# Adapt to your path to the rustic_server git source repository eg. "/home/rvisser/Software/" -common_root_path = "" +# [server] +# listen = "127.0.0.1:8000" -[repos] -storage_path = "rustic_server/tests/fixtures/test_data/test_repos/" +[storage] +data-dir = "tests/generated/test_storage/" -[authorization] -auth_path = "rustic_server/tests/fixtures/test_data/htaccess" -use_auth = true +[auth] +disable-auth = false +htpasswd-file = "tests/fixtures/test_data/.htpasswd" -[access_control] -acl_path = "rustic_server/tests/fixtures/test_data/acl.toml" -private_repo = true -append_only = false +[acl] +acl-path = "tests/fixtures/test_data/acl.toml" +private-repo = true +append-only = false diff --git a/tests/fixtures/test_data/test_repo_source/my_folder/random_data.bin b/tests/fixtures/test_data/test_repo_source/my_folder/random_data.bin index e468a6f..9e0f96a 100644 Binary files a/tests/fixtures/test_data/test_repo_source/my_folder/random_data.bin and b/tests/fixtures/test_data/test_repo_source/my_folder/random_data.bin differ diff --git a/tests/fixtures/test_data/test_repos/test_repo/config b/tests/fixtures/test_data/test_repos/test_repo/config deleted file mode 100644 index 0a91b7c..0000000 --- a/tests/fixtures/test_data/test_repos/test_repo/config +++ /dev/null @@ -1 +0,0 @@ -0wsz}zB VՅhNX `w)FW'l&9orYp371Ԕ%$PLkG!?Ƙ&qʵ_uλzOw!`NWx_P0E-(? \ No newline at end of file diff --git a/tests/fixtures/test_data/test_repos/test_repo/index/.gitkeep b/tests/fixtures/test_data/test_repos/test_repo/index/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/tests/fixtures/test_data/test_repos/test_repo/keys/2e734da3fccb98724ece44efca027652ba7a335c224448a68772b41c0d9229d5 b/tests/fixtures/test_data/test_repos/test_repo/keys/2e734da3fccb98724ece44efca027652ba7a335c224448a68772b41c0d9229d5 deleted file mode 100644 index 0a402ed..0000000 --- a/tests/fixtures/test_data/test_repos/test_repo/keys/2e734da3fccb98724ece44efca027652ba7a335c224448a68772b41c0d9229d5 +++ /dev/null @@ -1 +0,0 @@ -{"kdf":"scrypt","N":131072,"r":8,"p":1,"data":"Qm1Fk6IigCfeoy26El1UXb1DSLHtnqRLmZpGVXPxC2yTHxF+ML3Cj0m4eJ2InuIBUi5sbnT+Bpv6988ycGSp994GU2sLZQPrtvKg0SqABKYgcMkpKopiBv8nqiOsZ3/gIytEO5voyrewIVKrhOAmcv69AknNpFnCI5VhC77n4soP8U+E/F5TIBvKUPoVq8kGEyJ8ikoIciX/pGeKLH9EZQ==","salt":"cBmZojhWqBAktsSv9X6TKrifriWeT5zM26fpt4nsdbojRbzx31KO+Tj9TThRG4c3VHg7HW8bS/5shEZz4K0MoQ=="} \ No newline at end of file diff --git a/tests/fixtures/test_data/test_repos/test_repo/locks/.gitkeep b/tests/fixtures/test_data/test_repos/test_repo/locks/.gitkeep deleted file mode 100644 index 45adbb2..0000000 --- a/tests/fixtures/test_data/test_repos/test_repo/locks/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -.gitkeep \ No newline at end of file diff --git a/tests/fixtures/test_data/test_repos/test_repo/snapshots/.gitkeep b/tests/fixtures/test_data/test_repos/test_repo/snapshots/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/tests/generated/test_storage/test_repo/config b/tests/generated/test_storage/test_repo/config new file mode 100644 index 0000000..3f89a76 --- /dev/null +++ b/tests/generated/test_storage/test_repo/config @@ -0,0 +1 @@ +KׁJR$q Lo֢̞)}.k1Q{ ?!&,dFou ĽP6?Kt`{i} 4!^%rlMxn{'I.ȋ'D$)J7ߚfpVy?G簄L>Ϋ&!YlB \ No newline at end of file diff --git a/tests/generated/test_storage/test_repo/keys/3f918b737a2b9f72f044d06d6009eb34e0e8d06668209be3ce86e5c18dac0295 b/tests/generated/test_storage/test_repo/keys/3f918b737a2b9f72f044d06d6009eb34e0e8d06668209be3ce86e5c18dac0295 new file mode 100644 index 0000000..a3e28de --- /dev/null +++ b/tests/generated/test_storage/test_repo/keys/3f918b737a2b9f72f044d06d6009eb34e0e8d06668209be3ce86e5c18dac0295 @@ -0,0 +1 @@ +{"created":"2024-11-13T09:36:49.8626939+01:00","username":"TOWERPC\\dailyuse","hostname":"TowerPC","kdf":"scrypt","N":32768,"r":8,"p":7,"salt":"eOkZJZ+hvbGoe3ebzcNcEUXA/VP/e9WK2FUa0yDr96ZrjSoFc9qTbpgt4A3Z7m0NxqkF72D/aAgslDLw8Oe4cQ==","data":"ciGXdAUQBygykL/F+FE+/YnwNdcvdUenJ4ndRVIyOZ7BDs/VJxxEVul0YEykxtNJ0tJc2fruOzcMsSyjZkrATod4Zi3c2D0CJEE9kKeggwNDSJou9TOFjXF8eoZTQrzzeEkDXXPy26wlxhtLSlj4RqR3UgZzLOuT9rp9oUalxuCq7k0CAPz65rqOvUbmE0+krmuefJwhDWXK97E+gDp5iQ=="} \ No newline at end of file