diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 2e118075d..00cff0f47 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -29,7 +29,7 @@ jobs: - uses: actions-rs/toolchain@v1 with: - toolchain: "1.73.0" # MSRV + toolchain: "1.78.0" # MSRV override: true components: clippy diff --git a/.github/workflows/rust-tests.yml b/.github/workflows/rust-tests.yml index ef80c6254..c40a3b553 100644 --- a/.github/workflows/rust-tests.yml +++ b/.github/workflows/rust-tests.yml @@ -16,6 +16,8 @@ jobs: features: - "" - "server-sync" + - "server-gcp" + - "server-aws" - "tls-native-roots" name: "taskchampion ${{ matrix.features == '' && 'with no features' || format('with features {0}', matrix.features) }}" @@ -53,7 +55,7 @@ jobs: strategy: matrix: rust: - - "1.73.0" # MSRV + - "1.78.0" # MSRV - "stable" os: - ubuntu-latest diff --git a/Cargo.lock b/Cargo.lock index a10fb1bff..1467f977b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -104,6 +104,380 @@ version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" +[[package]] +name = "aws-config" +version = "1.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b49afaa341e8dd8577e1a2200468f98956d6eda50bcf4a53246cc00174ba924" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-sdk-sso", + "aws-sdk-ssooidc", + "aws-sdk-sts", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "hex", + "http 0.2.12", + "ring", + "time", + "tokio", + "tracing", + "url", + "zeroize", +] + +[[package]] +name = "aws-credential-types" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60e8f6b615cb5fc60a98132268508ad104310f0cfb25a1c22eee76efdf9154da" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "zeroize", +] + +[[package]] +name = "aws-runtime" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a10d5c055aa540164d9561a0e2e74ad30f0dcf7393c3a92f6733ddf9c5762468" +dependencies = [ + "aws-credential-types", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "http-body 0.4.6", + "once_cell", + "percent-encoding", + "pin-project-lite", + "tracing", + "uuid", +] + +[[package]] +name = "aws-sdk-s3" +version = "1.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f43850204a109a5eea1ea93951cf0440268cef98b0d27dfef4534949e23735f7" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-checksums", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "bytes", + "fastrand", + "hex", + "hmac", + "http 0.2.12", + "http-body 0.4.6", + "lru", + "once_cell", + "percent-encoding", + "regex-lite", + "sha2", + "tracing", + "url", +] + +[[package]] +name = "aws-sdk-sso" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09677244a9da92172c8dc60109b4a9658597d4d298b188dd0018b6a66b410ca4" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "http 0.2.12", + "once_cell", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-ssooidc" +version = "1.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81fea2f3a8bb3bd10932ae7ad59cc59f65f270fc9183a7e91f501dc5efbef7ee" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "http 0.2.12", + "once_cell", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-sts" +version = "1.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ada54e5f26ac246dc79727def52f7f8ed38915cb47781e2a72213957dc3a7d5" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-query", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "http 0.2.12", + "once_cell", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sigv4" +version = "1.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5619742a0d8f253be760bfbb8e8e8368c69e3587e4637af5754e488a611499b1" +dependencies = [ + "aws-credential-types", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "crypto-bigint 0.5.5", + "form_urlencoded", + "hex", + "hmac", + "http 0.2.12", + "http 1.1.0", + "once_cell", + "p256", + "percent-encoding", + "ring", + "sha2", + "subtle", + "time", + "tracing", + "zeroize", +] + +[[package]] +name = "aws-smithy-async" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62220bc6e97f946ddd51b5f1361f78996e704677afc518a4ff66b7a72ea1378c" +dependencies = [ + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "aws-smithy-checksums" +version = "0.60.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba1a71073fca26775c8b5189175ea8863afb1c9ea2cceb02a5de5ad9dfbaa795" +dependencies = [ + "aws-smithy-http", + "aws-smithy-types", + "bytes", + "crc32c", + "crc32fast", + "hex", + "http 0.2.12", + "http-body 0.4.6", + "md-5", + "pin-project-lite", + "sha1", + "sha2", + "tracing", +] + +[[package]] +name = "aws-smithy-eventstream" +version = "0.60.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cef7d0a272725f87e51ba2bf89f8c21e4df61b9e49ae1ac367a6d69916ef7c90" +dependencies = [ + "aws-smithy-types", + "bytes", + "crc32fast", +] + +[[package]] +name = "aws-smithy-http" +version = "0.60.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c8bc3e8fdc6b8d07d976e301c02fe553f72a39b7a9fea820e023268467d7ab6" +dependencies = [ + "aws-smithy-eventstream", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "bytes-utils", + "futures-core", + "http 0.2.12", + "http-body 0.4.6", + "once_cell", + "percent-encoding", + "pin-project-lite", + "pin-utils", + "tracing", +] + +[[package]] +name = "aws-smithy-json" +version = "0.60.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4683df9469ef09468dad3473d129960119a0d3593617542b7d52086c8486f2d6" +dependencies = [ + "aws-smithy-types", +] + +[[package]] +name = "aws-smithy-query" +version = "0.60.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2fbd61ceb3fe8a1cb7352e42689cec5335833cd9f94103a61e98f9bb61c64bb" +dependencies = [ + "aws-smithy-types", + "urlencoding", +] + +[[package]] +name = "aws-smithy-runtime" +version = "1.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be28bd063fa91fd871d131fc8b68d7cd4c5fa0869bea68daca50dcb1cbd76be2" +dependencies = [ + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "fastrand", + "h2", + "http 0.2.12", + "http-body 0.4.6", + "http-body 1.0.1", + "httparse", + "hyper", + "hyper-rustls", + "once_cell", + "pin-project-lite", + "pin-utils", + "rustls 0.21.12", + "tokio", + "tracing", +] + +[[package]] +name = "aws-smithy-runtime-api" +version = "1.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92165296a47a812b267b4f41032ff8069ab7ff783696d217f0994a0d7ab585cd" +dependencies = [ + "aws-smithy-async", + "aws-smithy-types", + "bytes", + "http 0.2.12", + "http 1.1.0", + "pin-project-lite", + "tokio", + "tracing", + "zeroize", +] + +[[package]] +name = "aws-smithy-types" +version = "1.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fbd94a32b3a7d55d3806fe27d98d3ad393050439dd05eb53ece36ec5e3d3510" +dependencies = [ + "base64-simd", + "bytes", + "bytes-utils", + "futures-core", + "http 0.2.12", + "http 1.1.0", + "http-body 0.4.6", + "http-body 1.0.1", + "http-body-util", + "itoa", + "num-integer", + "pin-project-lite", + "pin-utils", + "ryu", + "serde", + "time", + "tokio", + "tokio-util", +] + +[[package]] +name = "aws-smithy-xml" +version = "0.60.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab0b0166827aa700d3dc519f72f8b3a91c35d0b8d042dc5d643a91e6f80648fc" +dependencies = [ + "xmlparser", +] + +[[package]] +name = "aws-types" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5221b91b3e441e6675310829fd8984801b772cb1546ef6c0e54dec9f1ac13fef" +dependencies = [ + "aws-credential-types", + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "rustc_version", + "tracing", +] + [[package]] name = "backtrace" version = "0.3.74" @@ -119,6 +493,12 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "base16ct" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349a06037c7bf932dd7e7d1f653678b2038b9ad46a74102f1fc7bd7872678cce" + [[package]] name = "base64" version = "0.21.7" @@ -131,6 +511,16 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" +dependencies = [ + "outref", + "vsimd", +] + [[package]] name = "base64ct" version = "1.6.0" @@ -191,6 +581,16 @@ version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "325918d6fe32f23b19878fe4b34794ae41fc19ddbe53b10571a4874d44ffd39b" +[[package]] +name = "bytes-utils" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35" +dependencies = [ + "bytes", + "either", +] + [[package]] name = "cc" version = "1.2.2" @@ -252,6 +652,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32c" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a47af21622d091a8f0fb295b88bc886ac74efcc613efc19f5d0b21de5c89e47" +dependencies = [ + "rustc_version", +] + [[package]] name = "crc32fast" version = "1.4.2" @@ -261,6 +670,28 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crypto-bigint" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2b4b23cddf68b89b8f8069890e8c270d54e2d5fe1b143820234805e4cb17ef" +dependencies = [ + "generic-array", + "rand_core", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "rand_core", + "subtle", +] + [[package]] name = "crypto-common" version = "0.1.6" @@ -271,6 +702,16 @@ dependencies = [ "typenum", ] +[[package]] +name = "der" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a467a65c5e759bce6e65eaf91cc29f466cdc57cb65777bd646872a8a1fd4de" +dependencies = [ + "const-oid", + "zeroize", +] + [[package]] name = "der" version = "0.7.9" @@ -306,6 +747,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -319,6 +761,44 @@ dependencies = [ "syn 2.0.90", ] +[[package]] +name = "ecdsa" +version = "0.14.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413301934810f597c1d19ca71c8710e99a3f1ba28a0d2ebc01551a2daeea3c5c" +dependencies = [ + "der 0.6.1", + "elliptic-curve", + "rfc6979", + "signature", +] + +[[package]] +name = "either" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" + +[[package]] +name = "elliptic-curve" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7bb888ab5300a19b8e5bceef25ac745ad065f3c9f7efc6de1b91958110891d3" +dependencies = [ + "base16ct", + "crypto-bigint 0.4.9", + "der 0.6.1", + "digest", + "ff", + "generic-array", + "group", + "pkcs8 0.9.0", + "rand_core", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "encoding_rs" version = "0.8.35" @@ -362,6 +842,16 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "486f806e73c5707928240ddc295403b1b93c96a02038563881c4a2fd84b81ac4" +[[package]] +name = "ff" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d013fc25338cc558c5c2cfbad646908fb23591e2404481826742b651c9af7160" +dependencies = [ + "rand_core", + "subtle", +] + [[package]] name = "flate2" version = "1.0.35" @@ -378,6 +868,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f81ec6369c545a7d40e4589b5597581fa1c441fe1cce96dd1de43159910a36a2" + [[package]] name = "form_urlencoded" version = "1.2.1" @@ -561,7 +1057,7 @@ dependencies = [ "hex", "once_cell", "percent-encoding", - "pkcs8", + "pkcs8 0.10.2", "regex", "reqwest", "ring", @@ -584,6 +1080,17 @@ dependencies = [ "async-trait", ] +[[package]] +name = "group" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfbfb3a6cfbd390d5c9564ab283a0349b9b9fcd46a706c1eb10e0db70bfbac7" +dependencies = [ + "ff", + "rand_core", + "subtle", +] + [[package]] name = "h2" version = "0.3.26" @@ -595,7 +1102,7 @@ dependencies = [ "futures-core", "futures-sink", "futures-util", - "http", + "http 0.2.12", "indexmap", "slab", "tokio", @@ -618,6 +1125,11 @@ name = "hashbrown" version = "0.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] [[package]] name = "hashlink" @@ -640,6 +1152,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "home" version = "0.5.9" @@ -660,6 +1181,17 @@ dependencies = [ "itoa", ] +[[package]] +name = "http" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b9ddb458710bc376481b842f5da65cdf31522de232c1ca8146abce2a358258" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + [[package]] name = "http-body" version = "0.4.6" @@ -667,7 +1199,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" dependencies = [ "bytes", - "http", + "http 0.2.12", + "pin-project-lite", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http 1.1.0", +] + +[[package]] +name = "http-body-util" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793429d76616a256bcb62c2a2ec2bed781c8307e797e2598c50010f2bee2544f" +dependencies = [ + "bytes", + "futures-util", + "http 1.1.0", + "http-body 1.0.1", "pin-project-lite", ] @@ -694,8 +1249,8 @@ dependencies = [ "futures-core", "futures-util", "h2", - "http", - "http-body", + "http 0.2.12", + "http-body 0.4.6", "httparse", "httpdate", "itoa", @@ -714,9 +1269,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" dependencies = [ "futures-util", - "http", + "http 0.2.12", "hyper", + "log", "rustls 0.21.12", + "rustls-native-certs 0.6.3", "tokio", "tokio-rustls", ] @@ -987,6 +1544,25 @@ version = "0.4.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.2", +] + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + [[package]] name = "memchr" version = "2.7.4" @@ -1085,6 +1661,23 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" +[[package]] +name = "outref" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4030760ffd992bef45b0ae3f10ce1aba99e33464c90d14dd7c039884963ddc7a" + +[[package]] +name = "p256" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51f44edd08f51e2ade572f141051021c5af22677e42b7dd28a88155151c33594" +dependencies = [ + "ecdsa", + "elliptic-curve", + "sha2", +] + [[package]] name = "parking_lot" version = "0.12.3" @@ -1145,14 +1738,24 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkcs8" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eca2c590a5f85da82668fa685c09ce2888b9430e83299debf1f34b65fd4a4ba" +dependencies = [ + "der 0.6.1", + "spki 0.6.0", +] + [[package]] name = "pkcs8" version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" dependencies = [ - "der", - "spki", + "der 0.7.9", + "spki 0.7.3", ] [[package]] @@ -1301,6 +1904,12 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "regex-lite" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53a49587ad06b26609c52e423de037e7f57f20d53535d66e08c695f347df952a" + [[package]] name = "regex-syntax" version = "0.8.5" @@ -1319,8 +1928,8 @@ dependencies = [ "futures-core", "futures-util", "h2", - "http", - "http-body", + "http 0.2.12", + "http-body 0.4.6", "hyper", "hyper-rustls", "ipnet", @@ -1351,6 +1960,17 @@ dependencies = [ "winreg", ] +[[package]] +name = "rfc6979" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7743f17af12fa0b03b803ba12cd6a8d9483a587e89c69445e3909655c0b9fabb" +dependencies = [ + "crypto-bigint 0.4.9", + "hmac", + "zeroize", +] + [[package]] name = "ring" version = "0.17.8" @@ -1461,6 +2081,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9aace74cb666635c918e9c12bc0d348266037aa8eb599b5cba565709a8dff00" +dependencies = [ + "openssl-probe", + "rustls-pemfile 1.0.4", + "schannel", + "security-framework", +] + [[package]] name = "rustls-native-certs" version = "0.7.3" @@ -1568,6 +2200,20 @@ dependencies = [ "untrusted", ] +[[package]] +name = "sec1" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be24c1842290c45df0a7bf069e0c268a747ad05a192f2fd7dcfdbc1cba40928" +dependencies = [ + "base16ct", + "der 0.6.1", + "generic-array", + "pkcs8 0.9.0", + "subtle", + "zeroize", +] + [[package]] name = "security-framework" version = "2.11.1" @@ -1641,6 +2287,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.8" @@ -1658,6 +2315,25 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook-registry" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" +dependencies = [ + "libc", +] + +[[package]] +name = "signature" +version = "1.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74233d3b3b2f6d4b006dc19dee745e73e2a6bfb6f93607cd3b02bd5b00797d7c" +dependencies = [ + "digest", + "rand_core", +] + [[package]] name = "simple_asn1" version = "0.6.2" @@ -1701,6 +2377,16 @@ version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +[[package]] +name = "spki" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67cf02bbac7a337dc36e4f5a693db6c21e7863f45070f7064577eb4367a3212b" +dependencies = [ + "base64ct", + "der 0.6.1", +] + [[package]] name = "spki" version = "0.7.3" @@ -1708,7 +2394,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" dependencies = [ "base64ct", - "der", + "der 0.7.9", ] [[package]] @@ -1804,9 +2490,12 @@ dependencies = [ [[package]] name = "taskchampion" -version = "0.9.1-pre" +version = "1.0.0-pre" dependencies = [ "anyhow", + "aws-config", + "aws-credential-types", + "aws-sdk-s3", "byteorder", "chrono", "flate2", @@ -1915,6 +2604,7 @@ dependencies = [ "mio", "parking_lot", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.52.0", @@ -2039,7 +2729,7 @@ dependencies = [ "log", "once_cell", "rustls 0.23.19", - "rustls-native-certs", + "rustls-native-certs 0.7.3", "rustls-pki-types", "url", "webpki-roots 0.26.7", @@ -2098,6 +2788,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + [[package]] name = "wait-timeout" version = "0.2.0" @@ -2407,6 +3103,12 @@ version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" +[[package]] +name = "xmlparser" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" + [[package]] name = "xtask" version = "0.7.0" diff --git a/Cargo.toml b/Cargo.toml index 036e34b96..b1ed80d55 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,9 @@ resolver = "2" # Cargo.toml's in the members with `foo.workspace = true`. [workspace.dependencies] anyhow = "1.0" +aws-sdk-s3 = "1" +aws-config = { version = "1", features = ["behavior-version-latest"] } +aws-credential-types = { version = "1", features = ["hardcoded-credentials"] } byteorder = "1.5" chrono = { version = "^0.4.38", features = ["serde"] } ffizz-header = "0.5" diff --git a/taskchampion/Cargo.toml b/taskchampion/Cargo.toml index 4b1aa6b3d..16859199e 100644 --- a/taskchampion/Cargo.toml +++ b/taskchampion/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "taskchampion" -version = "0.9.1-pre" +version = "1.0.0-pre" authors = ["Dustin J. Mitchell "] description = "Personal task-tracking" homepage = "https://gothenburgbitfactory.github.io/taskchampion/" @@ -9,17 +9,19 @@ repository = "https://github.com/GothenburgBitFactory/taskchampion" readme = "src/crate-doc.md" license = "MIT" edition = "2021" -rust-version = "1.73.0" +rust-version = "1.78.0" [features] default = ["sync", "bundled"] # Support for all sync solutions -sync = ["server-sync", "server-gcp"] +sync = ["server-sync", "server-gcp", "server-aws"] # Support for sync to a server server-sync = ["encryption", "dep:ureq", "dep:url"] # Support for sync to GCP server-gcp = ["cloud", "encryption", "dep:google-cloud-storage", "dep:tokio"] +# Support for sync to AWS +server-aws = ["cloud", "encryption", "dep:aws-sdk-s3", "dep:aws-config", "dep:aws-credential-types", "dep:tokio"] # (private) Support for sync protocol encryption encryption = ["dep:ring"] # (private) Generic support for cloud sync @@ -33,29 +35,35 @@ tls-native-roots = ["ureq/native-certs"] all-features = true [dependencies] -uuid.workspace = true -serde.workspace = true -serde_json.workspace = true -chrono.workspace = true anyhow.workspace = true -thiserror.workspace = true -ureq.workspace = true +aws-config.workspace = true +aws-credential-types.workspace = true +aws-sdk-s3.workspace = true +byteorder.workspace = true +chrono.workspace = true +flate2.workspace = true +google-cloud-storage.workspace = true log.workspace = true +ring.workspace = true rusqlite.workspace = true -strum.workspace = true +serde_json.workspace = true +serde.workspace = true strum_macros.workspace = true -flate2.workspace = true -byteorder.workspace = true -ring.workspace = true -google-cloud-storage.workspace = true +strum.workspace = true +thiserror.workspace = true tokio.workspace = true +ureq.workspace = true url.workspace = true +uuid.workspace = true +aws-config.optional = true +aws-credential-types.optional = true +aws-sdk-s3.optional = true google-cloud-storage.optional = true +ring.optional = true tokio.optional = true ureq.optional = true url.optional = true -ring.optional = true [dev-dependencies] proptest.workspace = true diff --git a/taskchampion/src/crate-doc.md b/taskchampion/src/crate-doc.md index a16a0d8f8..caadece8d 100644 --- a/taskchampion/src/crate-doc.md +++ b/taskchampion/src/crate-doc.md @@ -35,6 +35,7 @@ Users can define their own server impelementations. Support for some optional functionality is controlled by feature flags. + * `server-aws` - sync to Amazon Web Services * `server-gcp` - sync to Google Cloud Platform * `server-sync` - sync to the taskchampion-sync-server * `sync` - enables all of the sync features above diff --git a/taskchampion/src/errors.rs b/taskchampion/src/errors.rs index 589e9290b..6ff7fe8a6 100644 --- a/taskchampion/src/errors.rs +++ b/taskchampion/src/errors.rs @@ -42,6 +42,10 @@ other_error!(crate::storage::sqlite::SqliteError); other_error!(google_cloud_storage::http::Error); #[cfg(feature = "server-gcp")] other_error!(google_cloud_storage::client::google_cloud_auth::error::Error); +#[cfg(feature = "server-aws")] +other_error!(aws_sdk_s3::Error); +#[cfg(feature = "server-aws")] +other_error!(aws_sdk_s3::primitives::ByteStreamError); /// Convert ureq errors more carefully #[cfg(feature = "server-sync")] diff --git a/taskchampion/src/server/cloud/aws.rs b/taskchampion/src/server/cloud/aws.rs new file mode 100644 index 000000000..786a71e36 --- /dev/null +++ b/taskchampion/src/server/cloud/aws.rs @@ -0,0 +1,628 @@ +#![allow(unreachable_code, unused_variables)] +use super::service::{ObjectInfo, Service}; +use crate::errors::Result; +use aws_config::{ + meta::region::RegionProviderChain, profile::ProfileFileCredentialsProvider, BehaviorVersion, + Region, +}; +use aws_credential_types::Credentials; +use aws_sdk_s3::{ + self as s3, + error::ProvideErrorMetadata, + operation::{get_object::GetObjectOutput, list_objects_v2::ListObjectsV2Output}, +}; +use std::future::Future; +use tokio::runtime::Runtime; + +/// A [`Service`] implementation based on the Google Cloud Storage service. +pub(in crate::server) struct AwsService { + client: s3::Client, + rt: Runtime, + bucket: String, +} + +/// Credential configuration for access to the AWS service. +/// +/// These credentials must have a least the following policy, with BUCKETNAME replaced by +/// the bucket name: +/// +/// ```json +/// { +/// "Version": "2012-10-17", +/// "Statement": [ +/// { +/// "Sid": "TaskChampion", +/// "Effect": "Allow", +/// "Action": [ +/// "s3:PutObject", +/// "s3:GetObject", +/// "s3:ListBucket", +/// "s3:DeleteObject" +/// ], +/// "Resource": [ +/// "arn:aws:s3:::BUCKETNAME", +/// "arn:aws:s3:::BUCKETNAME/*" +/// ] +/// } +/// ] +/// } +/// ``` +#[non_exhaustive] +pub enum AwsCredentials { + /// A pair of access key ID and secret access key. + AccessKey { + access_key_id: String, + secret_access_key: String, + }, + /// A named profile from the profile files in the user's home directory. + Profile { profile_name: String }, + /// Use the [default credential + /// sources](https://docs.rs/aws-config/latest/aws_config/default_provider/credentials/struct.DefaultCredentialsChain.html), + /// such as enviroment variables, the default profile, or the task/instance IAM role. + Default, +} + +impl AwsService { + pub(in crate::server) fn new( + region: String, + bucket: String, + creds: AwsCredentials, + ) -> Result { + let rt = Runtime::new()?; + + let config = + rt.block_on(async { + let mut config_provider = aws_config::defaults(BehaviorVersion::v2024_03_28()); + match creds { + AwsCredentials::AccessKey { + access_key_id, + secret_access_key, + } => { + config_provider = config_provider.credentials_provider( + Credentials::from_keys(access_key_id, secret_access_key, None), + ); + } + AwsCredentials::Profile { profile_name } => { + config_provider = config_provider.credentials_provider( + ProfileFileCredentialsProvider::builder() + .profile_name(profile_name) + .build(), + ); + } + AwsCredentials::Default => { + // Just use the default. + } + } + config_provider + .region(RegionProviderChain::first_try(Region::new(region))) + .load() + .await + }); + + let client = s3::client::Client::new(&config); + Ok(Self { client, rt, bucket }) + } + + fn block_on>>(&self, fut: F) -> Result { + self.rt.block_on(fut) + } +} + +/// Convert an object name from bytes to a string. +fn name_to_string(name: &[u8]) -> String { + String::from_utf8(name.to_vec()).expect("non-UTF8 object name") +} + +/// Convert an error that can be converted to `s3::Error` (but not [`crate::Error`] into +/// `s3::Error`. One such error is SdkError, which has type parameters that are difficult to +/// constrain in order to write `From> for crate::Error`. +fn aws_err>(err: E) -> s3::Error { + err.into() +} + +/// Convert a `NoSuchKey` error into `Ok(None)`, and `Ok(..)` into `Ok(Some(..))`. +#[allow(clippy::result_large_err)] // s3::Error is large, it's not our fault! +fn if_key_exists( + res: std::result::Result, +) -> std::result::Result, s3::Error> { + res + // convert Result to Result, E> + .map(Some) + // handle NoSuchKey + .or_else(|err| match err { + s3::Error::NoSuchKey(_) => Ok(None), + err => Err(err), + }) +} + +/// Get the body of a `get_object` result. +async fn get_body(get_res: GetObjectOutput) -> Result> { + Ok(get_res.body.collect().await?.to_vec()) +} + +impl Service for AwsService { + fn put(&mut self, name: &[u8], value: &[u8]) -> Result<()> { + self.block_on(async { + let name = name_to_string(name); + self.client + .put_object() + .bucket(self.bucket.clone()) + .key(name) + .body(value.to_vec().into()) + .send() + .await + .map_err(aws_err)?; + Ok(()) + }) + } + + fn get(&mut self, name: &[u8]) -> Result>> { + self.block_on(async { + let name = name_to_string(name); + let Some(get_res) = if_key_exists( + self.client + .get_object() + .bucket(self.bucket.clone()) + .key(name) + .send() + .await + .map_err(aws_err), + )? + else { + return Ok(None); + }; + Ok(Some(get_body(get_res).await?)) + }) + } + + fn del(&mut self, name: &[u8]) -> Result<()> { + self.block_on(async { + let name = name_to_string(name); + self.client + .delete_object() + .bucket(self.bucket.clone()) + .key(name) + .send() + .await + .map_err(aws_err)?; + Ok(()) + }) + } + + fn list<'a>(&'a mut self, prefix: &[u8]) -> Box> + 'a> { + let prefix = name_to_string(prefix); + Box::new(ObjectIterator { + service: self, + prefix, + last_response: None, + next_index: 0, + }) + } + + fn compare_and_swap( + &mut self, + name: &[u8], + existing_value: Option>, + new_value: Vec, + ) -> Result { + self.block_on(async { + let name = name_to_string(name); + let get_res = if_key_exists( + self.client + .get_object() + .bucket(self.bucket.clone()) + .key(name.clone()) + .send() + .await + .map_err(aws_err), + )?; + + // Check the expectation and gather the e_tag for the existing value. + let e_tag; + if let Some(get_res) = get_res { + // If a value was not expected but one exists, that expectation has not been met. + let Some(existing_value) = existing_value else { + return Ok(false); + }; + e_tag = get_res.e_tag.clone(); + let body = get_body(get_res).await?; + if body != existing_value { + return Ok(false); + } + } else { + // If a value was expected but none exists, that expectation has not been met. + if existing_value.is_some() { + return Ok(false); + } + e_tag = None; + }; + + // When testing, an object named "$pfx-racing-delete" is deleted between get_object and + // put_object. + #[cfg(test)] + if name.ends_with("-racing-delete") { + println!("deleting object {name}"); + self.client + .delete_object() + .bucket(self.bucket.clone()) + .key(name.clone()) + .send() + .await + .map_err(aws_err)?; + } + + // When testing, if the object is named "$pfx-racing-put" then the value "CHANGED" is + // written to it between get_object and put_object. + #[cfg(test)] + if name.ends_with("-racing-put") { + println!("changing object {name}"); + self.client + .put_object() + .bucket(self.bucket.clone()) + .key(name.clone()) + .body(b"CHANGED".to_vec().into()) + .send() + .await + .map_err(aws_err)?; + } + + // Try to put the object, using an appropriate conditional. + let mut put_builder = self.client.put_object(); + if let Some(e_tag) = e_tag { + put_builder = put_builder.if_match(e_tag); + } else { + put_builder = put_builder.if_none_match("*"); + } + match put_builder + .bucket(self.bucket.clone()) + .key(name) + .body(new_value.to_vec().into()) + .send() + .await + .map_err(aws_err) + { + Ok(_) => Ok(true), + // If the key disappears, S3 returns 404. + Err(err) if err.code() == Some("NoSuchKey") => Ok(false), + // PreconditionFailed occurs if the file changed unexpectedly + Err(err) if err.code() == Some("PreconditionFailed") => Ok(false), + // Docs describe this as a "conflicting operation" with no further details. + Err(err) if err.code() == Some("ConditionalRequestConflict") => Ok(false), + Err(e) => Err(e.into()), + } + }) + } +} + +/// An Iterator returning names of objects from `list_objects_v2`. +/// +/// This handles response pagination by fetching one page at a time. +struct ObjectIterator<'a> { + service: &'a mut AwsService, + prefix: String, + last_response: Option, + next_index: usize, +} + +impl ObjectIterator<'_> { + fn fetch_batch(&mut self) -> Result<()> { + let mut continuation_token = None; + if let Some(ref resp) = self.last_response { + continuation_token.clone_from(&resp.next_continuation_token); + } + self.last_response = None; + self.last_response = Some(self.service.block_on(async { + // Use the default max_keys in production, but a smaller value in testing so + // we can test the pagination. + #[cfg(test)] + let max_keys = Some(8); + #[cfg(not(test))] + let max_keys = None; + + Ok(self + .service + .client + .list_objects_v2() + .bucket(self.service.bucket.clone()) + .prefix(self.prefix.clone()) + .set_max_keys(max_keys) + .set_continuation_token(continuation_token) + .send() + .await + .map_err(aws_err)?) + })?); + self.next_index = 0; + Ok(()) + } +} + +impl Iterator for ObjectIterator<'_> { + type Item = Result; + fn next(&mut self) -> Option { + // If the iterator is just starting, fetch the first response. + if self.last_response.is_none() { + if let Err(e) = self.fetch_batch() { + return Some(Err(e)); + } + } + if let Some(ref result) = self.last_response { + if let Some(ref items) = result.contents { + if self.next_index < items.len() { + // Return a result from the existing response. + let obj = &items[self.next_index]; + self.next_index += 1; + // Use `last_modified` as a proxy for creation time, since most objects + // are not updated after they are created. + let creation = obj.last_modified.map(|t| t.secs()).unwrap_or(0); + let creation: u64 = creation.try_into().unwrap_or(0); + let name = obj.key.as_ref().expect("object has no key").clone(); + return Some(Ok(ObjectInfo { + name: name.as_bytes().to_vec(), + creation, + })); + } else if result.next_continuation_token.is_some() { + // Fetch the next page and try again. + if let Err(e) = self.fetch_batch() { + return Some(Err(e)); + } + return self.next(); + } + } + } + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + use uuid::Uuid; + + /// Make a service, as well as a function to put a unique prefix on an object name, so that + /// tests do not interfere with one another. + /// + /// The service is only created if the following environment variables are set: + /// * `AWS_TEST_REGION` - region containing the test bucket + /// * `AWS_TEST_BUCKET` - test bucket + /// * `AWS_TEST_ACCESS_KEY_ID` / `AWS_TEST_SECRET_ACCESS_KEY` - credentials for access to the + /// bucket. + /// + /// Set up the bucket with a lifecyle policy to delete objects with age > 1 day. While passing + /// tests should correctly clean up after themselves, failing tests may leave objects in the + /// bucket. + /// + /// When the environment variables are not set, this returns false and the test does not run. + /// Note that the Rust test runner will still show "ok" for the test, as there is no way to + /// indicate anything else. + fn make_service() -> Option<(AwsService, impl Fn(&str) -> Vec)> { + let Ok(region) = std::env::var("AWS_TEST_REGION") else { + return None; + }; + + let Ok(bucket) = std::env::var("AWS_TEST_BUCKET") else { + return None; + }; + + let Ok(access_key_id) = std::env::var("AWS_TEST_ACCESS_KEY_ID") else { + return None; + }; + + let Ok(secret_access_key) = std::env::var("AWS_TEST_SECRET_ACCESS_KEY") else { + return None; + }; + + let prefix = Uuid::new_v4(); + Some(( + AwsService::new( + region, + bucket, + AwsCredentials::AccessKey { + access_key_id, + secret_access_key, + }, + ) + .unwrap(), + move |n: &_| format!("{}-{}", prefix.as_simple(), n).into_bytes(), + )) + } + + #[test] + fn put_and_get() { + let Some((mut svc, pfx)) = make_service() else { + return; + }; + svc.put(&pfx("testy"), b"foo").unwrap(); + let got = svc.get(&pfx("testy")).unwrap(); + assert_eq!(got, Some(b"foo".to_vec())); + + // Clean up. + svc.del(&pfx("testy")).unwrap(); + } + + #[test] + fn get_missing() { + let Some((mut svc, pfx)) = make_service() else { + return; + }; + let got = svc.get(&pfx("testy")).unwrap(); + assert_eq!(got, None); + } + + #[test] + fn del() { + let Some((mut svc, pfx)) = make_service() else { + return; + }; + svc.put(&pfx("testy"), b"data").unwrap(); + svc.del(&pfx("testy")).unwrap(); + let got = svc.get(&pfx("testy")).unwrap(); + assert_eq!(got, None); + } + + #[test] + fn del_missing() { + // Deleting an object that does not exist is not an error. + let Some((mut svc, pfx)) = make_service() else { + return; + }; + + assert!(svc.del(&pfx("testy")).is_ok()); + } + + #[test] + fn list() { + let Some((mut svc, pfx)) = make_service() else { + return; + }; + let mut names: Vec<_> = (0..20).map(|i| pfx(&format!("pp-{i:02}"))).collect(); + names.sort(); + // Create 20 objects that will be listed. + for n in &names { + svc.put(n, b"data").unwrap(); + } + // And another object that should not be included in the list. + svc.put(&pfx("xxx"), b"data").unwrap(); + + let got_objects: Vec<_> = svc.list(&pfx("pp-")).collect::>().unwrap(); + let mut got_names: Vec<_> = got_objects.into_iter().map(|oi| oi.name).collect(); + got_names.sort(); + assert_eq!( + got_names + .iter() + .map(|b| String::from_utf8(b.to_vec()).unwrap()) + .collect::>(), + names + .iter() + .map(|b| String::from_utf8(b.to_vec()).unwrap()) + .collect::>() + ); + + // Clean up. + for n in got_names { + svc.del(&n).unwrap(); + } + svc.del(&pfx("xxx")).unwrap(); + } + + #[test] + fn compare_and_swap_create() { + let Some((mut svc, pfx)) = make_service() else { + return; + }; + + assert!(svc + .compare_and_swap(&pfx("testy"), None, b"bar".to_vec()) + .unwrap()); + let got = svc.get(&pfx("testy")).unwrap(); + assert_eq!(got, Some(b"bar".to_vec())); + + // Clean up. + svc.del(&pfx("testy")).unwrap(); + } + + #[test] + fn compare_and_swap_matches() { + let Some((mut svc, pfx)) = make_service() else { + return; + }; + + // Create the existing file, with two generations. + svc.put(&pfx("testy"), b"foo1").unwrap(); + svc.put(&pfx("testy"), b"foo2").unwrap(); + assert!(svc + .compare_and_swap(&pfx("testy"), Some(b"foo2".to_vec()), b"bar".to_vec()) + .unwrap()); + let got = svc.get(&pfx("testy")).unwrap(); + assert_eq!(got, Some(b"bar".to_vec())); + + // Clean up. + svc.del(&pfx("testy")).unwrap(); + } + + #[test] + fn compare_and_swap_expected_no_file() { + let Some((mut svc, pfx)) = make_service() else { + return; + }; + + svc.put(&pfx("testy"), b"foo1").unwrap(); + assert!(!svc + .compare_and_swap(&pfx("testy"), None, b"bar".to_vec()) + .unwrap()); + let got = svc.get(&pfx("testy")).unwrap(); + assert_eq!(got, Some(b"foo1".to_vec())); + + // Clean up. + svc.del(&pfx("testy")).unwrap(); + } + + #[test] + fn compare_and_swap_mismatch() { + let Some((mut svc, pfx)) = make_service() else { + return; + }; + + // Create the existing file, with two generations. + svc.put(&pfx("testy"), b"foo1").unwrap(); + svc.put(&pfx("testy"), b"foo2").unwrap(); + assert!(!svc + .compare_and_swap(&pfx("testy"), Some(b"foo1".to_vec()), b"bar".to_vec()) + .unwrap()); + let got = svc.get(&pfx("testy")).unwrap(); + assert_eq!(got, Some(b"foo2".to_vec())); + + // Clean up. + svc.del(&pfx("testy")).unwrap(); + } + + #[test] + fn compare_and_swap_changes() { + let Some((mut svc, pfx)) = make_service() else { + return; + }; + + // Create the existing file, but since it is named "racing-put" it will change just + // before the `put_object` call. + svc.put(&pfx("racing-put"), b"foo1").unwrap(); + assert!(!svc + .compare_and_swap(&pfx("racing-put"), Some(b"foo1".to_vec()), b"bar".to_vec()) + .unwrap()); + let got = svc.get(&pfx("racing-put")).unwrap(); + assert_eq!(got, Some(b"CHANGED".to_vec())); + } + + #[test] + fn compare_and_swap_disappears() { + let Some((mut svc, pfx)) = make_service() else { + return; + }; + + // Create the existing file, but since it is named "racing-delete" it will disappear just + // before the `put_object` call. + svc.put(&pfx("racing-delete"), b"foo1").unwrap(); + assert!(!svc + .compare_and_swap( + &pfx("racing-delete"), + Some(b"foo1".to_vec()), + b"bar".to_vec() + ) + .unwrap()); + let got = svc.get(&pfx("racing-delete")).unwrap(); + assert_eq!(got, None); + } + + #[test] + fn compare_and_swap_appears() { + let Some((mut svc, pfx)) = make_service() else { + return; + }; + + // Create the existing file, but since it is named "racing-put" the file will appear just + // before the `put_object` call. + assert!(!svc + .compare_and_swap(&pfx("racing-put"), None, b"bar".to_vec()) + .unwrap()); + let got = svc.get(&pfx("racing-put")).unwrap(); + assert_eq!(got, Some(b"CHANGED".to_vec())); + } +} diff --git a/taskchampion/src/server/cloud/mod.rs b/taskchampion/src/server/cloud/mod.rs index 970ced75c..a42d48167 100644 --- a/taskchampion/src/server/cloud/mod.rs +++ b/taskchampion/src/server/cloud/mod.rs @@ -14,3 +14,6 @@ pub(in crate::server) use server::CloudServer; #[cfg(feature = "server-gcp")] pub(in crate::server) mod gcp; + +#[cfg(feature = "server-aws")] +pub(in crate::server) mod aws; diff --git a/taskchampion/src/server/config.rs b/taskchampion/src/server/config.rs index 17f59361e..65d991ea8 100644 --- a/taskchampion/src/server/config.rs +++ b/taskchampion/src/server/config.rs @@ -1,5 +1,7 @@ use super::types::Server; use crate::errors::Result; +#[cfg(feature = "server-aws")] +use crate::server::cloud::aws::{AwsCredentials, AwsService}; #[cfg(feature = "server-gcp")] use crate::server::cloud::gcp::GcpService; #[cfg(feature = "cloud")] @@ -12,6 +14,10 @@ use std::path::PathBuf; use uuid::Uuid; /// The configuration for a replica's access to a sync server. +/// +/// This enum is non-exhaustive, as users should only be constructing required +/// variants, not matching on it. +#[non_exhaustive] pub enum ServerConfig { /// A local task database, for situations with a single replica. Local { @@ -65,6 +71,22 @@ pub enum ServerConfig { /// be any suitably un-guessable string of bytes. encryption_secret: Vec, }, + /// An Amazon Web Servicesstorage bucket. + #[cfg(feature = "server-aws")] + Aws { + /// Region in which the bucket is located. + region: String, + /// Bucket in which to store the task data. + /// + /// This bucket must not be used for any other purpose. No special bucket configuration is + /// required. + bucket: String, + /// Credential configuration for access to the bucket. + credentials: AwsCredentials, + /// Private encryption secret used to encrypt all data sent to the server. This can + /// be any suitably un-guessable string of bytes. + encryption_secret: Vec, + }, } impl ServerConfig { @@ -87,6 +109,16 @@ impl ServerConfig { GcpService::new(bucket, credential_path)?, encryption_secret, )?), + #[cfg(feature = "server-aws")] + ServerConfig::Aws { + region, + bucket, + credentials, + encryption_secret, + } => Box::new(CloudServer::new( + AwsService::new(region, bucket, credentials)?, + encryption_secret, + )?), }) } }