From 31cb732f0697208ef9a8d325a79688612087185a Mon Sep 17 00:00:00 2001 From: "Dustin J. Mitchell" Date: Sun, 7 Apr 2024 21:43:46 -0400 Subject: [PATCH] Import from https://github.com/GothenburgBitFactory/taskwarrior Specifically, from f89cc35a4aa8b928cc63502f3dde4d47447b5551, with addition of README.md, CONTRIBUTING.md, and a few other ancillary files. --- .gitignore | 1 + CODE_OF_CONDUCT.md | 76 ++ CONTRIBUTING.md | 20 + Cargo.lock | 1916 +++++++++++++++++++++++++++ Cargo.toml | 25 + LICENSE | 21 + README.md | 10 + src/api/add_snapshot.rs | 207 +++ src/api/add_version.rs | 233 ++++ src/api/get_child_version.rs | 170 +++ src/api/get_snapshot.rs | 115 ++ src/api/mod.rs | 61 + src/bin/taskchampion-sync-server.rs | 77 ++ src/lib.rs | 72 + src/server.rs | 1048 +++++++++++++++ src/storage/inmemory.rs | 286 ++++ src/storage/mod.rs | 95 ++ src/storage/sqlite.rs | 451 +++++++ 18 files changed, 4884 insertions(+) create mode 100644 .gitignore create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 src/api/add_snapshot.rs create mode 100644 src/api/add_version.rs create mode 100644 src/api/get_child_version.rs create mode 100644 src/api/get_snapshot.rs create mode 100644 src/api/mod.rs create mode 100644 src/bin/taskchampion-sync-server.rs create mode 100644 src/lib.rs create mode 100644 src/server.rs create mode 100644 src/storage/inmemory.rs create mode 100644 src/storage/mod.rs create mode 100644 src/storage/sqlite.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2f7896d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..807def5 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,76 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to making participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, sex characteristics, gender identity and expression, +level of experience, education, socio-economic status, nationality, personal +appearance, race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or + advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic + address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct, or to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing a project or community include using an official project e-mail +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting the project team at dustin@cs.uchicago.edu. All +complaints will be reviewed and investigated and will result in a response that +is deemed necessary and appropriate to the circumstances. The project team is +obligated to maintain confidentiality with regard to the reporter of an incident. +Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, +available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see +https://www.contributor-covenant.org/faq diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..d4a35e5 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,20 @@ +# Welcome + +TaskChampion sync-server is very open to contributions, and we'd love to have your help! + +A good starting point might be one of the issues tagged with ["good first issue"][first]. + +[first]: https://github.com/taskchampion/taskchampion-sync-server/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22 + +# Development Guide + +This repository is a typical Rust application. +To work on it, you'll need to [install a recent version of Rust](https://www.rust-lang.org/tools/install) (the latest stable is always a good choice). +Once you've done that, run `cargo build` at the top level of this repository to build the binary. +Alternately, run `cargo test` to run the test suite. + +## Making a Pull Request + +We expect contributors to follow the [GitHub Flow](https://guides.github.com/introduction/flow/). +Aside from that, we have no particular requirements on pull requests. +Make your patch, double-check that it's complete (tests? docs? documentation comments?), and make a new pull request. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..9337135 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1916 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "actix-codec" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57a7559404a7f3573127aab53c08ce37a6c6a315c374a31070f3c91cd1b4a7fe" +dependencies = [ + "bitflags 1.3.2", + "bytes", + "futures-core", + "futures-sink", + "log", + "memchr", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "actix-http" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2079246596c18b4a33e274ae10c0e50613f4d32a4198e09c7b93771013fed74" +dependencies = [ + "actix-codec", + "actix-rt", + "actix-service", + "actix-utils", + "ahash 0.8.3", + "base64", + "bitflags 1.3.2", + "brotli", + "bytes", + "bytestring", + "derive_more", + "encoding_rs", + "flate2", + "futures-core", + "h2", + "http", + "httparse", + "httpdate", + "itoa", + "language-tags", + "local-channel", + "mime", + "percent-encoding", + "pin-project-lite", + "rand", + "sha1", + "smallvec", + "tokio", + "tokio-util", + "tracing", + "zstd", +] + +[[package]] +name = "actix-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465a6172cf69b960917811022d8f29bc0b7fa1398bc4f78b3c466673db1213b6" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "actix-router" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d66ff4d247d2b160861fa2866457e85706833527840e4133f8f49aa423a38799" +dependencies = [ + "bytestring", + "http", + "regex", + "serde", + "tracing", +] + +[[package]] +name = "actix-rt" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15265b6b8e2347670eb363c47fc8c75208b4a4994b27192f345fcbe707804f3e" +dependencies = [ + "actix-macros", + "futures-core", + "tokio", +] + +[[package]] +name = "actix-server" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e8613a75dd50cc45f473cee3c34d59ed677c0f7b44480ce3b8247d7dc519327" +dependencies = [ + "actix-rt", + "actix-service", + "actix-utils", + "futures-core", + "futures-util", + "mio", + "num_cpus", + "socket2 0.4.9", + "tokio", + "tracing", +] + +[[package]] +name = "actix-service" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b894941f818cfdc7ccc4b9e60fa7e53b5042a2e8567270f9147d5591893373a" +dependencies = [ + "futures-core", + "paste", + "pin-project-lite", +] + +[[package]] +name = "actix-utils" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a1dcdff1466e3c2488e1cb5c36a71822750ad43839937f85d2f4d9f8b705d8" +dependencies = [ + "local-waker", + "pin-project-lite", +] + +[[package]] +name = "actix-web" +version = "4.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd3cb42f9566ab176e1ef0b8b3a896529062b4efc6be0123046095914c4c1c96" +dependencies = [ + "actix-codec", + "actix-http", + "actix-macros", + "actix-router", + "actix-rt", + "actix-server", + "actix-service", + "actix-utils", + "actix-web-codegen", + "ahash 0.7.6", + "bytes", + "bytestring", + "cfg-if", + "cookie", + "derive_more", + "encoding_rs", + "futures-core", + "futures-util", + "http", + "itoa", + "language-tags", + "log", + "mime", + "once_cell", + "pin-project-lite", + "regex", + "serde", + "serde_json", + "serde_urlencoded", + "smallvec", + "socket2 0.4.9", + "time 0.3.20", + "url", +] + +[[package]] +name = "actix-web-codegen" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2262160a7ae29e3415554a3f1fc04c764b1540c116aa524683208078b7a75bc9" +dependencies = [ + "actix-router", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "addr2line" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" + +[[package]] +name = "ahash" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcb51a0695d8f838b1ee009b3fbf66bda078cd64590202a864a8f3e8c4315c47" +dependencies = [ + "getrandom", + "once_cell", + "version_check", +] + +[[package]] +name = "ahash" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c99f64d1e06488f620f932677e24bc6e2897582980441ae90a671415bd7ec2f" +dependencies = [ + "cfg-if", + "getrandom", + "once_cell", + "version_check", +] + +[[package]] +name = "aho-corasick" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2969dcb958b36655471fc61f7e416fa76033bdd4bfed0678d8fee1e2d07a1f0" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35ef4730490ad1c4eae5c4325b2a95f521d023e5c885853ff7aca0a6a1631db3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "697ed7edc0f1711de49ce108c541623a0af97c6c60b2f6e2b65229847ac843c2" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ca84f3628370c59db74ee214b3263d58f9aadd9b4fe7e711fd87dc452b7f163" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is-terminal", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41ed9a86bf92ae6580e0a31281f65a1b1d867c0cc68d5346e2ae128dddfa6a7d" + +[[package]] +name = "anstyle-parse" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e765fd216e48e067936442276d1d57399e37bce53c264d6fefbe298080cb57ee" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca11d4be1bab0c8bc8734a9aa7bf4ee8316d462a08c6ac5052f888fef5b494b" +dependencies = [ + "windows-sys 0.48.0", +] + +[[package]] +name = "anstyle-wincon" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180abfa45703aebe0093f79badacc01b8fd4ea2e35118747e5811127f926e188" +dependencies = [ + "anstyle", + "windows-sys 0.48.0", +] + +[[package]] +name = "anyhow" +version = "1.0.66" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "216261ddc8289130e551ddcd5ce8a064710c0d064a4d2895c67151c92b5443f6" + +[[package]] +name = "autocfg" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" + +[[package]] +name = "backtrace" +version = "0.3.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2089b7e3f35b9dd2d0ed921ead4f6d318c27680d4a5bd167b3ee120edb105837" +dependencies = [ + "addr2line", + "cc", + "cfg-if", + "libc", + "miniz_oxide 0.7.1", + "object", + "rustc-demangle", +] + +[[package]] +name = "base64" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a4ddaa51a5bc52a6948f74c06d20aaaddb71924eab79b8c97a8c556e942d6a" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "487f1e0fcbe47deb8b0574e646def1c903389d95241dd1bbcc6ce4a715dfc0c1" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "brotli" +version = "3.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a0b1dbcc8ae29329621f8d4f0d835787c1c38bb1401979b49d13b0b305ff68" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ad2d4653bf5ca36ae797b1f4bb4dbddb60ce49ca4aed8a2ce4829f60425b80" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bumpalo" +version = "3.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d261e256854913907f67ed06efbc3338dfe6179796deefc1ff763fc1aee5535" + +[[package]] +name = "bytes" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2bd12c1caf447e69cd4528f47f94d203fd2582878ecb9e9465484c4148a8223" + +[[package]] +name = "bytestring" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90706ba19e97b90786e19dc0d5e2abd80008d99d4c0c5d1ad0b5e72cec7c494d" +dependencies = [ + "bytes", +] + +[[package]] +name = "cc" +version = "1.0.73" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fff2a6927b3bb87f9595d67196a70493f627687a71d87a0d692242c33f58c11" +dependencies = [ + "jobserver", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "chrono" +version = "0.4.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfd4d1b31faaa3a89d7934dbded3111da0d2ef28e3ebccdb4f0179f5929d1ef1" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-integer", + "num-traits", + "serde", + "time 0.1.43", + "wasm-bindgen", + "winapi", +] + +[[package]] +name = "clap" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93aae7a4192245f70fe75dd9157fc7b4a5bf53e88d30bd4396f7d8f9284d5acc" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f423e341edefb78c9caba2d9c7f7687d0e72e89df3ce3394554754393ac3990" +dependencies = [ + "anstream", + "anstyle", + "bitflags 1.3.2", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_lex" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2da6da31387c7e4ef160ffab6d5e7f00c42626fe39aea70a7b0f1773f7dd6c1b" + +[[package]] +name = "colorchoice" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" + +[[package]] +name = "convert_case" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" + +[[package]] +name = "cookie" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e859cd57d0710d9e06c381b550c06e76992472a8c6d527aecd2fc673dcc231fb" +dependencies = [ + "percent-encoding", + "time 0.3.20", + "version_check", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5827cebf4670468b8772dd191856768aedcb1b0278a04f989f7766351917b9dc" + +[[package]] +name = "cpufeatures" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59a6001667ab124aebae2a495118e11d30984c3a653e99d86d58971708cf5e4b" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b540bd8bc810d3885c6ea91e2018302f68baba2129ab3e88f32389ee9370880d" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "derive_more" +version = "0.99.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fb810d30a7c1953f91334de7244731fc3f3c10d7fe163338a35b9f640960321" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn", +] + +[[package]] +name = "diff" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e25ea47919b1560c4e3b7fe0aaab9becf5b84a10325ddf7db0f0ba5e1026499" + +[[package]] +name = "digest" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8168378f4e5023e7218c89c891c0fd8ecdb5e5e4f18cb78f38cf245dd021e76f" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "encoding_rs" +version = "0.8.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9852635589dc9f9ea1b6fe9f05b50ef208c85c834a562f0c6abb1c475736ec2b" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "env_logger" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85cdab6a89accf66733ad5a1693a4dcced6aeff64602b634530dd73c1f3ee9f0" +dependencies = [ + "humantime", + "is-terminal", + "log", + "regex", + "termcolor", +] + +[[package]] +name = "errno" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bcfec3a70f97c962c307b2d2c56e358cf1d00b558d74262b5f929ee8cc7e73a" +dependencies = [ + "errno-dragonfly", + "libc", + "windows-sys 0.48.0", +] + +[[package]] +name = "errno-dragonfly" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa68f1b12764fab894d2755d2518754e71b4fd80ecfb822714a1206c2aab39bf" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "fallible-iterator" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fastrand" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3fcf0cee53519c866c09b5de1f6c56ff9d647101f81c1964fa632e148896cdf" +dependencies = [ + "instant", +] + +[[package]] +name = "flate2" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f82b0f4c27ad9f8bfd1f3208d882da2b09c301bc1c828fd3a00d0216d2fbbff6" +dependencies = [ + "crc32fast", + "miniz_oxide 0.5.1", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38390104763dc37a5145a53c29c63c1290b5d316d6086ec32c293f6736051bb0" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ba265a92256105f45b719605a571ffe2d1f0fea3807304b522c1d778f79eed" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04909a7a7e4633ae6c4a9ab280aeb86da1236243a77b694a49eacd659a4bd3ac" + +[[package]] +name = "futures-executor" +version = "0.3.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7acc85df6714c176ab5edf386123fafe217be88c0840ec11f199441134a074e2" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00f5fb52a06bdcadeb54e8d3671f8888a39697dcb0b81b23b55174030427f4eb" + +[[package]] +name = "futures-macro" +version = "0.3.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdfb8ce053d86b91919aad980c220b1fb8401a9394410e1c289ed7e66b61835d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39c15cf1a4aa79df40f1bb462fb39676d0ad9e366c2a33b590d7c66f4f81fcf9" + +[[package]] +name = "futures-task" +version = "0.3.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ffb393ac5d9a6eaa9d3fdf37ae2776656b706e200c8e16b1bdb227f5198e6ea" + +[[package]] +name = "futures-util" +version = "0.3.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "197676987abd2f9cadff84926f410af1c183608d36641465df73ae8211dc65d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd48d33ec7f05fbfa152300fdad764757cbded343c1aa1cff2fbaf4134851803" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c85e1d9ab2eadba7e5040d4e09cbd6d072b76a557ad64e797c2cb9d4da21d7e4" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "gimli" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" + +[[package]] +name = "h2" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b91535aa35fea1523ad1b86cb6b53c28e0ae566ba4a460f4457e936cad7c6f" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab5ef0d4909ef3724cc8cce6ccc8572c5c817592e9285f5464f8e86f8bd3726e" + +[[package]] +name = "hashbrown" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "607c8a29735385251a339424dd462993c0fed8fa09d378f259377df08c126022" +dependencies = [ + "ahash 0.7.6", +] + +[[package]] +name = "hashlink" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d452c155cb93fecdfb02a73dd57b5d8e442c2063bd7aac72f1bc5e4263a43086" +dependencies = [ + "hashbrown 0.12.2", +] + +[[package]] +name = "hermit-abi" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + +[[package]] +name = "hermit-abi" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fed44880c466736ef9a5c5b5facefb5ed0785676d0c02d612db14e54f0d84286" + +[[package]] +name = "http" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd6effc99afb63425aff9b05836f029929e345a6148a14b7ecd5ab67af944482" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "496ce29bb5a52785b44e0f7ca2847ae0bb839c9bd28f69acac9b99d461c0c04c" + +[[package]] +name = "httpdate" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4a1e36c821dbe04574f602848a19f742f4fb3c98d40449f11bcad18d6b17421" + +[[package]] +name = "humantime" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" + +[[package]] +name = "iana-time-zone" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c495f162af0bf17656d0014a0eded5f3cd2f365fdd204548c2869db89359dc7" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "js-sys", + "once_cell", + "wasm-bindgen", + "winapi", +] + +[[package]] +name = "idna" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "indexmap" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f647032dfaa1f8b6dc29bd3edb7bbef4861b8b8007ebb118d6db284fd59f6ee" +dependencies = [ + "autocfg", + "hashbrown 0.11.2", +] + +[[package]] +name = "instant" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5bbe824c507c5da5956355e86a746d82e0e1464f65d862cc5e71da70e94b2c" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "io-lifetimes" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eae7b9aee968036d54dce06cebaefd919e4472e753296daccd6d344e3e2df0c2" +dependencies = [ + "hermit-abi 0.3.1", + "libc", + "windows-sys 0.48.0", +] + +[[package]] +name = "is-terminal" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adcf93614601c8129ddf72e2d5633df827ba6551541c6d8c59520a371475be1f" +dependencies = [ + "hermit-abi 0.3.1", + "io-lifetimes", + "rustix", + "windows-sys 0.48.0", +] + +[[package]] +name = "itoa" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112c678d4050afce233f4f2852bb2eb519230b3cf12f33585275537d7e41578d" + +[[package]] +name = "jobserver" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "936cfd212a0155903bcbc060e316fb6cc7cbf2e1907329391ebadc1fe0ce77c2" +dependencies = [ + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "258451ab10b34f8af53416d1fdab72c22e805f0c92a1136d59470ec0b11138b2" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "language-tags" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388" + +[[package]] +name = "lazy_static" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" + +[[package]] +name = "libc" +version = "0.2.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "302d7ab3130588088d277783b1e2d2e10c9e9e4a16dd9050e6ec93fb3e7048f4" + +[[package]] +name = "libsqlite3-sys" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afc22eff61b133b115c6e8c74e818c628d6d5e7a502afea6f64dee076dd94326" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef53942eb7bf7ff43a617b3e2c1c4a5ecf5944a7c1bc12d7ee39bbb15e5c1519" + +[[package]] +name = "local-channel" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f303ec0e94c6c54447f84f3b0ef7af769858a9c4ef56ef2a986d3dcd4c3fc9c" +dependencies = [ + "futures-core", + "futures-sink", + "futures-util", + "local-waker", +] + +[[package]] +name = "local-waker" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34f76eb3611940e0e7d53a9aaa4e6a3151f69541a282fd0dad5571420c53ff1" + +[[package]] +name = "lock_api" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "327fa5b6a6940e4699ec49a9beae1ea4845c6bab9314e4f84ac68742139d8c53" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abb12e687cfb44aa40f41fc3978ef76448f9b6038cad6aef4259d3c095a2382e" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "memchr" +version = "2.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f665ee40bc4a3c5590afb1e9677db74a508659dfd71e126420da8274909a0167" + +[[package]] +name = "mime" +version = "0.3.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a60c7ce501c71e03a9c9c0d35b861413ae925bd979cc7a4e30d060069aaac8d" + +[[package]] +name = "miniz_oxide" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2b29bd4bc3f33391105ebee3589c19197c4271e3e5a9ec9bfe8127eeff8f082" +dependencies = [ + "adler", +] + +[[package]] +name = "miniz_oxide" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7810e0be55b428ada41041c41f32c9f1a42817901b4ccf45fa3d4b6561e74c7" +dependencies = [ + "adler", +] + +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.48.0", +] + +[[package]] +name = "num-integer" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225d3389fb3509a24c93f5c29eb6bde2586b98d9f016636dff58d7c6f7569cd9" +dependencies = [ + "autocfg", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "578ede34cf02f8924ab9447f50c28075b4d3e5b269972345e7e0372b38c6cdcd" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19e64526ebdee182341572e50e9ad03965aa510cd94427a4549448f285e957a1" +dependencies = [ + "hermit-abi 0.1.19", + "libc", +] + +[[package]] +name = "object" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf5f9dd3933bd50a9e1f149ec995f39ae2c496d31fd772c1fd45ebc27e902b0" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" + +[[package]] +name = "parking_lot" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9069cbb9f99e3a5083476ccb29ceb1de18b9118cafa53e90c9551235de2b9521" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.2.13", + "smallvec", + "windows-sys 0.45.0", +] + +[[package]] +name = "paste" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f746c4065a8fa3fe23974dd82f15431cc8d40779821001404d10d2e79ca7d79" + +[[package]] +name = "percent-encoding" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" + +[[package]] +name = "pin-project-lite" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8afb450f006bf6385ca15ef45d71d2288452bc3683ce2e2cacc0d18e4be60b58" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1df8c4ec4b0627e53bdf214615ad287367e482558cf84b109250b37464dc03ae" + +[[package]] +name = "ppv-lite86" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb9f9e6e233e5c4a35559a617bf40a4ec447db2e84c20b55a6f83167b7e57872" + +[[package]] +name = "pretty_assertions" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af7cee1a6c8a5b9208b3cb1061f10c0cb689087b3d8ce85fb9d2dd7a29b6ba66" +dependencies = [ + "diff", + "yansi", +] + +[[package]] +name = "proc-macro2" +version = "1.0.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec2b086b7a862cf4de201096214fa870344cf922b2b30c167badb3af3195406" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ab9c7eadfd8df19006f1cf1a4aed13540ed5cbc047010ece5826e10825488" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d34f1408f55294453790c48b2f1ebbb1c5b4b7563eb1f418bcfcfdbb06ebb4e7" +dependencies = [ + "getrandom", +] + +[[package]] +name = "redox_syscall" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62f25bc4c7e55e0b0b7a1d43fb893f4fa1361d0abe38b9ce4f323c2adfe6ef42" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567664f262709473930a4bf9e51bf2ebf3348f2e748ccc50dea20646858f8f29" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "regex" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "380b951a9c5e80ddfd6136919eef32310721aa4aacd4889a8d39124b026ab343" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f804c7828047e88b2d32e2d7fe5a105da8ee3264f01902f796c8e067dc2483f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08c74e62047bb2de4ff487b251e4a92e24f48745648451635cec7d591162d9f" + +[[package]] +name = "rusqlite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "549b9d036d571d42e6e85d1c1425e2ac83491075078ca9a15be021c56b1641f2" +dependencies = [ + "bitflags 2.0.2", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" + +[[package]] +name = "rustc_version" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa0f585226d2e68097d4f95d113b15b83a82e819ab25717ec0590d9584ef366" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.37.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4eb579851244c2c03e7c24f501c3432bed80b8f720af1d6e5b0e0f01555a035" +dependencies = [ + "bitflags 1.3.2", + "errno", + "io-lifetimes", + "libc", + "linux-raw-sys", + "windows-sys 0.48.0", +] + +[[package]] +name = "ryu" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3f6f92acf49d1b98f7a81226834412ada05458b7364277387724a237f062695" + +[[package]] +name = "scopeguard" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" + +[[package]] +name = "semver" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cb243bdfdb5936c8dc3c45762a19d12ab4550cdc753bc247637d4ec35a040fd" + +[[package]] +name = "serde" +version = "1.0.147" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d193d69bae983fc11a79df82342761dfbf28a99fc8d203dca4c3c1b590948965" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.147" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f1d362ca8fc9c3e3a7484440752472d68a6caa98f1ab81d99b5dfe517cec852" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.87" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce777b7b150d76b9cf60d28b55f5847135a003f7d7350c6be7a773508ce7d45" +dependencies = [ + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f04293dc80c3993519f2d7f6f511707ee7094fe0c6d3406feb330cdb3540eba3" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e51e73328dc4ac0c7ccbda3a494dfa03df1de2f46018127f60c693f2648455b0" +dependencies = [ + "libc", +] + +[[package]] +name = "slab" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb703cfe953bccee95685111adeedb76fabe4e97549a58d16f03ea7b9367bb32" + +[[package]] +name = "smallvec" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2dd574626839106c320a323308629dcb1acfc96e32a8cba364ddc61ac23ee83" + +[[package]] +name = "socket2" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64a4a911eed85daf18834cfaa86a79b7d266ff93ff5ba14005426219480ed662" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "socket2" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5fac59a5cb5dd637972e5fca70daf0523c9067fcdc4842f053dae04a18f8e9" +dependencies = [ + "libc", + "windows-sys 0.48.0", +] + +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + +[[package]] +name = "syn" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae548ec36cf198c0ef7710d3c230987c2d6d7bd98ad6edc0274462724c585ce" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "taskchampion-sync-server" +version = "0.4.1" +dependencies = [ + "actix-rt", + "actix-web", + "anyhow", + "chrono", + "clap", + "env_logger", + "futures", + "log", + "pretty_assertions", + "rusqlite", + "serde", + "serde_json", + "tempfile", + "thiserror", + "uuid", +] + +[[package]] +name = "tempfile" +version = "3.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31c0432476357e58790aaa47a8efb0c5138f137343f3b5f23bd36a27e3b0a6d6" +dependencies = [ + "autocfg", + "cfg-if", + "fastrand", + "redox_syscall 0.3.5", + "rustix", + "windows-sys 0.48.0", +] + +[[package]] +name = "termcolor" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bab24d30b911b2376f3a13cc2cd443142f0c81dda04c118693e35b3835757755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10deb33631e3c9018b9baf9dcbbc4f737320d2b576bac10f6aefa048fa407e3e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "982d17546b47146b28f7c22e3d08465f6b8903d0ea13c1660d9d84a6e7adcdbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "time" +version = "0.1.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca8a50ef2360fbd1eeb0ecd46795a87a19024eb4b53c5dc916ca1fd95fe62438" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "time" +version = "0.3.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0cbfecb4d19b5ea75bb31ad904eb5b9fa13f21079c3b92017ebdf4999a5890" +dependencies = [ + "itoa", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e153e1f1acaef8acc537e68b44906d2db6436e2b35ac2c6b42640fff91f00fd" + +[[package]] +name = "time-macros" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd80a657e71da814b8e5d60d3374fc6d35045062245d80224748ae522dd76f36" +dependencies = [ + "time-core", +] + +[[package]] +name = "tinyvec" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cda74da7e1a664f795bb1f8a87ec406fb89a02522cf6e50620d016add6dbbf5c" + +[[package]] +name = "tokio" +version = "1.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1adbebffeca75fcfd058afa480fb6c0b81e165a0323f9c9d39c9697e37c46787" +dependencies = [ + "backtrace", + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.5.5", + "windows-sys 0.48.0", +] + +[[package]] +name = "tokio-util" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5427d89453009325de0d8f342c9490009f76e999cb7672d77e46267448f7e6b2" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", + "tracing", +] + +[[package]] +name = "tracing" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0ecdcb44a79f0fe9844f0c4f33a342cbcbb5117de8001e6ba0dc2351327d09" +dependencies = [ + "cfg-if", + "log", + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f54c8ca710e81886d498c2fd3331b56c93aa248d49de2222ad2742247c60072f" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "typenum" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcf81ac59edc17cc8697ff311e8f5ef2d99fcbd9817b34cec66f90b6c3dfd987" + +[[package]] +name = "unicode-bidi" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f2528f27a9eb2b21e69c95319b30bd0efd85d09c379741b0f78ea1d86be2416" + +[[package]] +name = "unicode-ident" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ceab39d59e4c9499d4e5a8ee0e2735b891bb7308ac83dfb4e80cad195c9f6f3" + +[[package]] +name = "unicode-normalization" +version = "0.1.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "url" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31e6302e3bb753d46e83516cae55ae196fc0c309407cf11ab35cc51a4c2a4633" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", +] + +[[package]] +name = "utf8parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" + +[[package]] +name = "uuid" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a183cf7feeba97b4dd1c0d46788634f6221d87fa961b305bed08c851829efcc0" +dependencies = [ + "getrandom", + "serde", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasm-bindgen" +version = "0.2.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7652e3f6c4706c8d9cd54832c4a4ccb9b5336e2c3bd154d5cccfbf1c1f5f7d" +dependencies = [ + "cfg-if", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "662cd44805586bd52971b9586b1df85cdbbd9112e4ef4d8f41559c334dc6ac3f" +dependencies = [ + "bumpalo", + "log", + "once_cell", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b260f13d3012071dfb1512849c033b1925038373aea48ced3012c09df952c602" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be8e654bdd9b79216c2929ab90721aa82faf65c48cdf08bdc4e7f51357b80da" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6598dd0bd3c7d51095ff6531a5b23e02acdc81804e30d8f07afb77b7215a140a" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" +dependencies = [ + "winapi", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.0", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b1eb6f0cd7c80c79759c929114ef071b87354ce476d9d94271031c0497adfd5" +dependencies = [ + "windows_aarch64_gnullvm 0.48.0", + "windows_aarch64_msvc 0.48.0", + "windows_i686_gnu 0.48.0", + "windows_i686_msvc 0.48.0", + "windows_x86_64_gnu 0.48.0", + "windows_x86_64_gnullvm 0.48.0", + "windows_x86_64_msvc 0.48.0", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91ae572e1b79dba883e0d315474df7305d12f569b400fcf90581b06062f7e1bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2ef27e0d7bdfcfc7b868b317c1d32c641a6fe4629c171b8928c7b08d98d7cf3" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622a1962a7db830d6fd0a69683c80a18fda201879f0f447f065a3b7467daa241" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4542c6e364ce21bf45d69fdd2a8e455fa38d316158cfd43b3ac1c5b1b19f8e00" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2b8a661f7628cbd23440e50b05d705db3686f894fc9580820623656af974b1" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7896dbc1f41e08872e9d5e8f8baa8fdd2677f29468c4e156210174edc7f7b953" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a515f5799fe4961cb532f983ce2b23082366b898e52ffbce459c86f67c8378a" + +[[package]] +name = "yansi" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09041cd90cf85f7f8b2df60c646f853b7f535ce68f85244eb6731cf89fa498ec" + +[[package]] +name = "zstd" +version = "0.12.3+zstd.1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76eea132fb024e0e13fd9c2f5d5d595d8a967aa72382ac2f9d39fcc95afd0806" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "6.0.5+zstd.1.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56d9e60b4b1758206c238a10165fbcae3ca37b01744e394c463463f6529d23b" +dependencies = [ + "libc", + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.8+zstd.1.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5556e6ee25d32df2586c098bbfa278803692a20d0ab9565e049480d52707ec8c" +dependencies = [ + "cc", + "libc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..a0170c8 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "taskchampion-sync-server" +version = "0.4.1" +authors = ["Dustin J. Mitchell "] +edition = "2021" +publish = false + +[dependencies] +uuid = { version = "^1.8.0", features = ["serde", "v4"] } +actix-web = "^4.3.1" +anyhow = "1.0" +thiserror = "1.0" +futures = "^0.3.25" +serde_json = "^1.0" +serde = { version = "^1.0.147", features = ["derive"] } +clap = { version = "^4.3.0", features = ["string"] } +log = "^0.4.17" +env_logger = "^0.10.0" +rusqlite = { version = "0.29", features = ["bundled"] } +chrono = { version = "^0.4.22", features = ["serde"] } + +[dev-dependencies] +actix-rt = "2" +tempfile = "3" +pretty_assertions = "1" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f0c9756 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Dustin J. Mitchell + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..851460d --- /dev/null +++ b/README.md @@ -0,0 +1,10 @@ +TaskChampion Sync-Server +------------------------ + +TaskChampion is the task database [Taskwarrior](https://github.com/GothenburgBitFactory/taskwarrior) uses to store and sync tasks. +This repository implements a sync server against which Taskwarrior and other applications embedding TaskChampion can sync. + +## Status + +This repository was spun off from Taskwarrior itself after the 3.0.0 release. +It is still under development and currently best described as a refernce implementation of the Taskchampion sync protocol. diff --git a/src/api/add_snapshot.rs b/src/api/add_snapshot.rs new file mode 100644 index 0000000..d92174c --- /dev/null +++ b/src/api/add_snapshot.rs @@ -0,0 +1,207 @@ +use crate::api::{client_id_header, failure_to_ise, ServerState, SNAPSHOT_CONTENT_TYPE}; +use crate::server::{add_snapshot, VersionId, NIL_VERSION_ID}; +use actix_web::{error, post, web, HttpMessage, HttpRequest, HttpResponse, Result}; +use futures::StreamExt; +use std::sync::Arc; + +/// Max snapshot size: 100MB +const MAX_SIZE: usize = 100 * 1024 * 1024; + +/// Add a new snapshot, after checking prerequisites. The snapshot should be transmitted in the +/// request entity body and must have content-type `application/vnd.taskchampion.snapshot`. The +/// content can be encoded in any of the formats supported by actix-web. +/// +/// On success, the response is a 200 OK. Even in a 200 OK, the snapshot may not appear in a +/// subsequent `GetSnapshot` call. +/// +/// Returns other 4xx or 5xx responses on other errors. +#[post("/v1/client/add-snapshot/{version_id}")] +pub(crate) async fn service( + req: HttpRequest, + server_state: web::Data>, + path: web::Path, + mut payload: web::Payload, +) -> Result { + let version_id = path.into_inner(); + + // check content-type + if req.content_type() != SNAPSHOT_CONTENT_TYPE { + return Err(error::ErrorBadRequest("Bad content-type")); + } + + let client_id = client_id_header(&req)?; + + // read the body in its entirety + let mut body = web::BytesMut::new(); + while let Some(chunk) = payload.next().await { + let chunk = chunk?; + // limit max size of in-memory payload + if (body.len() + chunk.len()) > MAX_SIZE { + return Err(error::ErrorBadRequest("Snapshot over maximum allowed size")); + } + body.extend_from_slice(&chunk); + } + + if body.is_empty() { + return Err(error::ErrorBadRequest("No snapshot supplied")); + } + + // note that we do not open the transaction until the body has been read + // completely, to avoid blocking other storage access while that data is + // in transit. + let mut txn = server_state.storage.txn().map_err(failure_to_ise)?; + + // get, or create, the client + let client = match txn.get_client(client_id).map_err(failure_to_ise)? { + Some(client) => client, + None => { + txn.new_client(client_id, NIL_VERSION_ID) + .map_err(failure_to_ise)?; + txn.get_client(client_id).map_err(failure_to_ise)?.unwrap() + } + }; + + add_snapshot( + txn, + &server_state.config, + client_id, + client, + version_id, + body.to_vec(), + ) + .map_err(failure_to_ise)?; + Ok(HttpResponse::Ok().body("")) +} + +#[cfg(test)] +mod test { + use super::*; + use crate::api::CLIENT_ID_HEADER; + use crate::storage::{InMemoryStorage, Storage}; + use crate::Server; + use actix_web::{http::StatusCode, test, App}; + use pretty_assertions::assert_eq; + use uuid::Uuid; + + #[actix_rt::test] + async fn test_success() -> anyhow::Result<()> { + let client_id = Uuid::new_v4(); + let version_id = Uuid::new_v4(); + let storage: Box = Box::new(InMemoryStorage::new()); + + // set up the storage contents.. + { + let mut txn = storage.txn().unwrap(); + txn.new_client(client_id, version_id).unwrap(); + txn.add_version(client_id, version_id, NIL_VERSION_ID, vec![])?; + } + + let server = Server::new(Default::default(), storage); + let app = App::new().configure(|sc| server.config(sc)); + let mut app = test::init_service(app).await; + + let uri = format!("/v1/client/add-snapshot/{}", version_id); + let req = test::TestRequest::post() + .uri(&uri) + .insert_header(("Content-Type", "application/vnd.taskchampion.snapshot")) + .insert_header((CLIENT_ID_HEADER, client_id.to_string())) + .set_payload(b"abcd".to_vec()) + .to_request(); + let resp = test::call_service(&mut app, req).await; + assert_eq!(resp.status(), StatusCode::OK); + + // read back that snapshot + let uri = "/v1/client/snapshot"; + let req = test::TestRequest::get() + .uri(uri) + .append_header((CLIENT_ID_HEADER, client_id.to_string())) + .to_request(); + let resp = test::call_service(&mut app, req).await; + assert_eq!(resp.status(), StatusCode::OK); + + use actix_web::body::MessageBody; + let bytes = resp.into_body().try_into_bytes().unwrap(); + assert_eq!(bytes.as_ref(), b"abcd"); + + Ok(()) + } + + #[actix_rt::test] + async fn test_not_added_200() { + let client_id = Uuid::new_v4(); + let version_id = Uuid::new_v4(); + let storage: Box = Box::new(InMemoryStorage::new()); + + // set up the storage contents.. + { + let mut txn = storage.txn().unwrap(); + txn.new_client(client_id, NIL_VERSION_ID).unwrap(); + } + + let server = Server::new(Default::default(), storage); + let app = App::new().configure(|sc| server.config(sc)); + let mut app = test::init_service(app).await; + + // add a snapshot for a nonexistent version + let uri = format!("/v1/client/add-snapshot/{}", version_id); + let req = test::TestRequest::post() + .uri(&uri) + .append_header(("Content-Type", "application/vnd.taskchampion.snapshot")) + .append_header((CLIENT_ID_HEADER, client_id.to_string())) + .set_payload(b"abcd".to_vec()) + .to_request(); + let resp = test::call_service(&mut app, req).await; + assert_eq!(resp.status(), StatusCode::OK); + + // read back, seeing no snapshot + let uri = "/v1/client/snapshot"; + let req = test::TestRequest::get() + .uri(uri) + .append_header((CLIENT_ID_HEADER, client_id.to_string())) + .to_request(); + let resp = test::call_service(&mut app, req).await; + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + } + + #[actix_rt::test] + async fn test_bad_content_type() { + let client_id = Uuid::new_v4(); + let version_id = Uuid::new_v4(); + let storage: Box = Box::new(InMemoryStorage::new()); + let server = Server::new(Default::default(), storage); + let app = App::new().configure(|sc| server.config(sc)); + let mut app = test::init_service(app).await; + + let uri = format!("/v1/client/add-snapshot/{}", version_id); + let req = test::TestRequest::post() + .uri(&uri) + .append_header(("Content-Type", "not/correct")) + .append_header((CLIENT_ID_HEADER, client_id.to_string())) + .set_payload(b"abcd".to_vec()) + .to_request(); + let resp = test::call_service(&mut app, req).await; + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + } + + #[actix_rt::test] + async fn test_empty_body() { + let client_id = Uuid::new_v4(); + let version_id = Uuid::new_v4(); + let storage: Box = Box::new(InMemoryStorage::new()); + let server = Server::new(Default::default(), storage); + let app = App::new().configure(|sc| server.config(sc)); + let mut app = test::init_service(app).await; + + let uri = format!("/v1/client/add-snapshot/{}", version_id); + let req = test::TestRequest::post() + .uri(&uri) + .append_header(( + "Content-Type", + "application/vnd.taskchampion.history-segment", + )) + .append_header((CLIENT_ID_HEADER, client_id.to_string())) + .to_request(); + let resp = test::call_service(&mut app, req).await; + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + } +} diff --git a/src/api/add_version.rs b/src/api/add_version.rs new file mode 100644 index 0000000..7428d94 --- /dev/null +++ b/src/api/add_version.rs @@ -0,0 +1,233 @@ +use crate::api::{ + client_id_header, failure_to_ise, ServerState, HISTORY_SEGMENT_CONTENT_TYPE, + PARENT_VERSION_ID_HEADER, SNAPSHOT_REQUEST_HEADER, VERSION_ID_HEADER, +}; +use crate::server::{add_version, AddVersionResult, SnapshotUrgency, VersionId, NIL_VERSION_ID}; +use actix_web::{error, post, web, HttpMessage, HttpRequest, HttpResponse, Result}; +use futures::StreamExt; +use std::sync::Arc; + +/// Max history segment size: 100MB +const MAX_SIZE: usize = 100 * 1024 * 1024; + +/// Add a new version, after checking prerequisites. The history segment should be transmitted in +/// the request entity body and must have content-type +/// `application/vnd.taskchampion.history-segment`. The content can be encoded in any of the +/// formats supported by actix-web. +/// +/// On success, the response is a 200 OK with the new version ID in the `X-Version-Id` header. If +/// the version cannot be added due to a conflict, the response is a 409 CONFLICT with the expected +/// parent version ID in the `X-Parent-Version-Id` header. +/// +/// If included, a snapshot request appears in the `X-Snapshot-Request` header with value +/// `urgency=low` or `urgency=high`. +/// +/// Returns other 4xx or 5xx responses on other errors. +#[post("/v1/client/add-version/{parent_version_id}")] +pub(crate) async fn service( + req: HttpRequest, + server_state: web::Data>, + path: web::Path, + mut payload: web::Payload, +) -> Result { + let parent_version_id = path.into_inner(); + + // check content-type + if req.content_type() != HISTORY_SEGMENT_CONTENT_TYPE { + return Err(error::ErrorBadRequest("Bad content-type")); + } + + let client_id = client_id_header(&req)?; + + // read the body in its entirety + let mut body = web::BytesMut::new(); + while let Some(chunk) = payload.next().await { + let chunk = chunk?; + // limit max size of in-memory payload + if (body.len() + chunk.len()) > MAX_SIZE { + return Err(error::ErrorBadRequest("overflow")); + } + body.extend_from_slice(&chunk); + } + + if body.is_empty() { + return Err(error::ErrorBadRequest("Empty body")); + } + + // note that we do not open the transaction until the body has been read + // completely, to avoid blocking other storage access while that data is + // in transit. + let mut txn = server_state.storage.txn().map_err(failure_to_ise)?; + + // get, or create, the client + let client = match txn.get_client(client_id).map_err(failure_to_ise)? { + Some(client) => client, + None => { + txn.new_client(client_id, NIL_VERSION_ID) + .map_err(failure_to_ise)?; + txn.get_client(client_id).map_err(failure_to_ise)?.unwrap() + } + }; + + let (result, snap_urgency) = add_version( + txn, + &server_state.config, + client_id, + client, + parent_version_id, + body.to_vec(), + ) + .map_err(failure_to_ise)?; + + Ok(match result { + AddVersionResult::Ok(version_id) => { + let mut rb = HttpResponse::Ok(); + rb.append_header((VERSION_ID_HEADER, version_id.to_string())); + match snap_urgency { + SnapshotUrgency::None => {} + SnapshotUrgency::Low => { + rb.append_header((SNAPSHOT_REQUEST_HEADER, "urgency=low")); + } + SnapshotUrgency::High => { + rb.append_header((SNAPSHOT_REQUEST_HEADER, "urgency=high")); + } + }; + rb.finish() + } + AddVersionResult::ExpectedParentVersion(parent_version_id) => { + let mut rb = HttpResponse::Conflict(); + rb.append_header((PARENT_VERSION_ID_HEADER, parent_version_id.to_string())); + rb.finish() + } + }) +} + +#[cfg(test)] +mod test { + use crate::api::CLIENT_ID_HEADER; + use crate::storage::{InMemoryStorage, Storage}; + use crate::Server; + use actix_web::{http::StatusCode, test, App}; + use pretty_assertions::assert_eq; + use uuid::Uuid; + + #[actix_rt::test] + async fn test_success() { + let client_id = Uuid::new_v4(); + let version_id = Uuid::new_v4(); + let parent_version_id = Uuid::new_v4(); + let storage: Box = Box::new(InMemoryStorage::new()); + + // set up the storage contents.. + { + let mut txn = storage.txn().unwrap(); + txn.new_client(client_id, Uuid::nil()).unwrap(); + } + + let server = Server::new(Default::default(), storage); + let app = App::new().configure(|sc| server.config(sc)); + let mut app = test::init_service(app).await; + + let uri = format!("/v1/client/add-version/{}", parent_version_id); + let req = test::TestRequest::post() + .uri(&uri) + .append_header(( + "Content-Type", + "application/vnd.taskchampion.history-segment", + )) + .append_header((CLIENT_ID_HEADER, client_id.to_string())) + .set_payload(b"abcd".to_vec()) + .to_request(); + let resp = test::call_service(&mut app, req).await; + assert_eq!(resp.status(), StatusCode::OK); + + // the returned version ID is random, but let's check that it's not + // the passed parent version ID, at least + let new_version_id = resp.headers().get("X-Version-Id").unwrap(); + assert!(new_version_id != &version_id.to_string()); + + // Shapshot should be requested, since there is no existing snapshot + let snapshot_request = resp.headers().get("X-Snapshot-Request").unwrap(); + assert_eq!(snapshot_request, "urgency=high"); + + assert_eq!(resp.headers().get("X-Parent-Version-Id"), None); + } + + #[actix_rt::test] + async fn test_conflict() { + let client_id = Uuid::new_v4(); + let version_id = Uuid::new_v4(); + let parent_version_id = Uuid::new_v4(); + let storage: Box = Box::new(InMemoryStorage::new()); + + // set up the storage contents.. + { + let mut txn = storage.txn().unwrap(); + txn.new_client(client_id, version_id).unwrap(); + } + + let server = Server::new(Default::default(), storage); + let app = App::new().configure(|sc| server.config(sc)); + let mut app = test::init_service(app).await; + + let uri = format!("/v1/client/add-version/{}", parent_version_id); + let req = test::TestRequest::post() + .uri(&uri) + .append_header(( + "Content-Type", + "application/vnd.taskchampion.history-segment", + )) + .append_header((CLIENT_ID_HEADER, client_id.to_string())) + .set_payload(b"abcd".to_vec()) + .to_request(); + let resp = test::call_service(&mut app, req).await; + assert_eq!(resp.status(), StatusCode::CONFLICT); + assert_eq!(resp.headers().get("X-Version-Id"), None); + assert_eq!( + resp.headers().get("X-Parent-Version-Id").unwrap(), + &version_id.to_string() + ); + } + + #[actix_rt::test] + async fn test_bad_content_type() { + let client_id = Uuid::new_v4(); + let parent_version_id = Uuid::new_v4(); + let storage: Box = Box::new(InMemoryStorage::new()); + let server = Server::new(Default::default(), storage); + let app = App::new().configure(|sc| server.config(sc)); + let mut app = test::init_service(app).await; + + let uri = format!("/v1/client/add-version/{}", parent_version_id); + let req = test::TestRequest::post() + .uri(&uri) + .append_header(("Content-Type", "not/correct")) + .append_header((CLIENT_ID_HEADER, client_id.to_string())) + .set_payload(b"abcd".to_vec()) + .to_request(); + let resp = test::call_service(&mut app, req).await; + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + } + + #[actix_rt::test] + async fn test_empty_body() { + let client_id = Uuid::new_v4(); + let parent_version_id = Uuid::new_v4(); + let storage: Box = Box::new(InMemoryStorage::new()); + let server = Server::new(Default::default(), storage); + let app = App::new().configure(|sc| server.config(sc)); + let mut app = test::init_service(app).await; + + let uri = format!("/v1/client/add-version/{}", parent_version_id); + let req = test::TestRequest::post() + .uri(&uri) + .append_header(( + "Content-Type", + "application/vnd.taskchampion.history-segment", + )) + .append_header((CLIENT_ID_HEADER, client_id.to_string())) + .to_request(); + let resp = test::call_service(&mut app, req).await; + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + } +} diff --git a/src/api/get_child_version.rs b/src/api/get_child_version.rs new file mode 100644 index 0000000..cb0e74f --- /dev/null +++ b/src/api/get_child_version.rs @@ -0,0 +1,170 @@ +use crate::api::{ + client_id_header, failure_to_ise, ServerState, HISTORY_SEGMENT_CONTENT_TYPE, + PARENT_VERSION_ID_HEADER, VERSION_ID_HEADER, +}; +use crate::server::{get_child_version, GetVersionResult, VersionId}; +use actix_web::{error, get, web, HttpRequest, HttpResponse, Result}; +use std::sync::Arc; + +/// Get a child version. +/// +/// On succcess, the response is the same sequence of bytes originally sent to the server, +/// with content-type `application/vnd.taskchampion.history-segment`. The `X-Version-Id` and +/// `X-Parent-Version-Id` headers contain the corresponding values. +/// +/// If no such child exists, returns a 404 with no content. +/// Returns other 4xx or 5xx responses on other errors. +#[get("/v1/client/get-child-version/{parent_version_id}")] +pub(crate) async fn service( + req: HttpRequest, + server_state: web::Data>, + path: web::Path, +) -> Result { + let parent_version_id = path.into_inner(); + + let mut txn = server_state.storage.txn().map_err(failure_to_ise)?; + + let client_id = client_id_header(&req)?; + + let client = txn + .get_client(client_id) + .map_err(failure_to_ise)? + .ok_or_else(|| error::ErrorNotFound("no such client"))?; + + return match get_child_version( + txn, + &server_state.config, + client_id, + client, + parent_version_id, + ) + .map_err(failure_to_ise)? + { + GetVersionResult::Success { + version_id, + parent_version_id, + history_segment, + } => Ok(HttpResponse::Ok() + .content_type(HISTORY_SEGMENT_CONTENT_TYPE) + .append_header((VERSION_ID_HEADER, version_id.to_string())) + .append_header((PARENT_VERSION_ID_HEADER, parent_version_id.to_string())) + .body(history_segment)), + GetVersionResult::NotFound => Err(error::ErrorNotFound("no such version")), + GetVersionResult::Gone => Err(error::ErrorGone("version has been deleted")), + }; +} + +#[cfg(test)] +mod test { + use crate::api::CLIENT_ID_HEADER; + use crate::server::NIL_VERSION_ID; + use crate::storage::{InMemoryStorage, Storage}; + use crate::Server; + use actix_web::{http::StatusCode, test, App}; + use pretty_assertions::assert_eq; + use uuid::Uuid; + + #[actix_rt::test] + async fn test_success() { + let client_id = Uuid::new_v4(); + let version_id = Uuid::new_v4(); + let parent_version_id = Uuid::new_v4(); + let storage: Box = Box::new(InMemoryStorage::new()); + + // set up the storage contents.. + { + let mut txn = storage.txn().unwrap(); + txn.new_client(client_id, Uuid::new_v4()).unwrap(); + txn.add_version(client_id, version_id, parent_version_id, b"abcd".to_vec()) + .unwrap(); + } + + let server = Server::new(Default::default(), storage); + let app = App::new().configure(|sc| server.config(sc)); + let mut app = test::init_service(app).await; + + let uri = format!("/v1/client/get-child-version/{}", parent_version_id); + let req = test::TestRequest::get() + .uri(&uri) + .append_header((CLIENT_ID_HEADER, client_id.to_string())) + .to_request(); + let resp = test::call_service(&mut app, req).await; + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!( + resp.headers().get("X-Version-Id").unwrap(), + &version_id.to_string() + ); + assert_eq!( + resp.headers().get("X-Parent-Version-Id").unwrap(), + &parent_version_id.to_string() + ); + assert_eq!( + resp.headers().get("Content-Type").unwrap(), + &"application/vnd.taskchampion.history-segment".to_string() + ); + + use actix_web::body::MessageBody; + let bytes = resp.into_body().try_into_bytes().unwrap(); + assert_eq!(bytes.as_ref(), b"abcd"); + } + + #[actix_rt::test] + async fn test_client_not_found() { + let client_id = Uuid::new_v4(); + let parent_version_id = Uuid::new_v4(); + let storage: Box = Box::new(InMemoryStorage::new()); + let server = Server::new(Default::default(), storage); + let app = App::new().configure(|sc| server.config(sc)); + let mut app = test::init_service(app).await; + + let uri = format!("/v1/client/get-child-version/{}", parent_version_id); + let req = test::TestRequest::get() + .uri(&uri) + .append_header((CLIENT_ID_HEADER, client_id.to_string())) + .to_request(); + let resp = test::call_service(&mut app, req).await; + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + assert_eq!(resp.headers().get("X-Version-Id"), None); + assert_eq!(resp.headers().get("X-Parent-Version-Id"), None); + } + + #[actix_rt::test] + async fn test_version_not_found_and_gone() { + let client_id = Uuid::new_v4(); + let parent_version_id = Uuid::new_v4(); + let storage: Box = Box::new(InMemoryStorage::new()); + + // create the client, but not the version + { + let mut txn = storage.txn().unwrap(); + txn.new_client(client_id, Uuid::new_v4()).unwrap(); + } + let server = Server::new(Default::default(), storage); + let app = App::new().configure(|sc| server.config(sc)); + let mut app = test::init_service(app).await; + + // the child of an unknown parent_version_id is GONE + let uri = format!("/v1/client/get-child-version/{}", parent_version_id); + let req = test::TestRequest::get() + .uri(&uri) + .append_header((CLIENT_ID_HEADER, client_id.to_string())) + .to_request(); + let resp = test::call_service(&mut app, req).await; + assert_eq!(resp.status(), StatusCode::GONE); + assert_eq!(resp.headers().get("X-Version-Id"), None); + assert_eq!(resp.headers().get("X-Parent-Version-Id"), None); + + // but the child of the nil parent_version_id is NOT FOUND, since + // there is no snapshot. The tests in crate::server test more + // corner cases. + let uri = format!("/v1/client/get-child-version/{}", NIL_VERSION_ID); + let req = test::TestRequest::get() + .uri(&uri) + .append_header((CLIENT_ID_HEADER, client_id.to_string())) + .to_request(); + let resp = test::call_service(&mut app, req).await; + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + assert_eq!(resp.headers().get("X-Version-Id"), None); + assert_eq!(resp.headers().get("X-Parent-Version-Id"), None); + } +} diff --git a/src/api/get_snapshot.rs b/src/api/get_snapshot.rs new file mode 100644 index 0000000..4135257 --- /dev/null +++ b/src/api/get_snapshot.rs @@ -0,0 +1,115 @@ +use crate::api::{ + client_id_header, failure_to_ise, ServerState, SNAPSHOT_CONTENT_TYPE, VERSION_ID_HEADER, +}; +use crate::server::get_snapshot; +use actix_web::{error, get, web, HttpRequest, HttpResponse, Result}; +use std::sync::Arc; + +/// Get a snapshot. +/// +/// If a snapshot for this client exists, it is returned with content-type +/// `application/vnd.taskchampion.snapshot`. The `X-Version-Id` header contains the version of the +/// snapshot. +/// +/// If no snapshot exists, returns a 404 with no content. Returns other 4xx or 5xx responses on +/// other errors. +#[get("/v1/client/snapshot")] +pub(crate) async fn service( + req: HttpRequest, + server_state: web::Data>, +) -> Result { + let mut txn = server_state.storage.txn().map_err(failure_to_ise)?; + + let client_id = client_id_header(&req)?; + + let client = txn + .get_client(client_id) + .map_err(failure_to_ise)? + .ok_or_else(|| error::ErrorNotFound("no such client"))?; + + if let Some((version_id, data)) = + get_snapshot(txn, &server_state.config, client_id, client).map_err(failure_to_ise)? + { + Ok(HttpResponse::Ok() + .content_type(SNAPSHOT_CONTENT_TYPE) + .append_header((VERSION_ID_HEADER, version_id.to_string())) + .body(data)) + } else { + Err(error::ErrorNotFound("no snapshot")) + } +} + +#[cfg(test)] +mod test { + use crate::api::CLIENT_ID_HEADER; + use crate::storage::{InMemoryStorage, Snapshot, Storage}; + use crate::Server; + use actix_web::{http::StatusCode, test, App}; + use chrono::{TimeZone, Utc}; + use pretty_assertions::assert_eq; + use uuid::Uuid; + + #[actix_rt::test] + async fn test_not_found() { + let client_id = Uuid::new_v4(); + let storage: Box = Box::new(InMemoryStorage::new()); + + // set up the storage contents.. + { + let mut txn = storage.txn().unwrap(); + txn.new_client(client_id, Uuid::new_v4()).unwrap(); + } + + let server = Server::new(Default::default(), storage); + let app = App::new().configure(|sc| server.config(sc)); + let mut app = test::init_service(app).await; + + let uri = "/v1/client/snapshot"; + let req = test::TestRequest::get() + .uri(uri) + .append_header((CLIENT_ID_HEADER, client_id.to_string())) + .to_request(); + let resp = test::call_service(&mut app, req).await; + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + } + + #[actix_rt::test] + async fn test_success() { + let client_id = Uuid::new_v4(); + let version_id = Uuid::new_v4(); + let snapshot_data = vec![1, 2, 3, 4]; + let storage: Box = Box::new(InMemoryStorage::new()); + + // set up the storage contents.. + { + let mut txn = storage.txn().unwrap(); + txn.new_client(client_id, Uuid::new_v4()).unwrap(); + txn.set_snapshot( + client_id, + Snapshot { + version_id, + versions_since: 3, + timestamp: Utc.ymd(2001, 9, 9).and_hms(1, 46, 40), + }, + snapshot_data.clone(), + ) + .unwrap(); + } + + let server = Server::new(Default::default(), storage); + let app = App::new().configure(|sc| server.config(sc)); + let mut app = test::init_service(app).await; + + let uri = "/v1/client/snapshot"; + let req = test::TestRequest::get() + .uri(uri) + .append_header((CLIENT_ID_HEADER, client_id.to_string())) + .to_request(); + let resp = test::call_service(&mut app, req).await; + assert_eq!(resp.status(), StatusCode::OK); + + use actix_web::body::MessageBody; + let bytes = resp.into_body().try_into_bytes().unwrap(); + assert_eq!(bytes.as_ref(), snapshot_data); + } +} diff --git a/src/api/mod.rs b/src/api/mod.rs new file mode 100644 index 0000000..bb5001f --- /dev/null +++ b/src/api/mod.rs @@ -0,0 +1,61 @@ +use crate::server::ClientId; +use crate::storage::Storage; +use crate::ServerConfig; +use actix_web::{error, http::StatusCode, web, HttpRequest, Result, Scope}; + +mod add_snapshot; +mod add_version; +mod get_child_version; +mod get_snapshot; + +/// The content-type for history segments (opaque blobs of bytes) +pub(crate) const HISTORY_SEGMENT_CONTENT_TYPE: &str = + "application/vnd.taskchampion.history-segment"; + +/// The content-type for snapshots (opaque blobs of bytes) +pub(crate) const SNAPSHOT_CONTENT_TYPE: &str = "application/vnd.taskchampion.snapshot"; + +/// The header name for version ID +pub(crate) const VERSION_ID_HEADER: &str = "X-Version-Id"; + +/// The header name for client id +pub(crate) const CLIENT_ID_HEADER: &str = "X-Client-Id"; + +/// The header name for parent version ID +pub(crate) const PARENT_VERSION_ID_HEADER: &str = "X-Parent-Version-Id"; + +/// The header name for parent version ID +pub(crate) const SNAPSHOT_REQUEST_HEADER: &str = "X-Snapshot-Request"; + +/// The type containing a reference to the persistent state for the server +pub(crate) struct ServerState { + pub(crate) storage: Box, + pub(crate) config: ServerConfig, +} + +pub(crate) fn api_scope() -> Scope { + web::scope("") + .service(get_child_version::service) + .service(add_version::service) + .service(get_snapshot::service) + .service(add_snapshot::service) +} + +/// Convert a failure::Error to an Actix ISE +fn failure_to_ise(err: anyhow::Error) -> impl actix_web::ResponseError { + error::InternalError::new(err, StatusCode::INTERNAL_SERVER_ERROR) +} + +/// Get the client id +fn client_id_header(req: &HttpRequest) -> Result { + fn badrequest() -> error::Error { + error::ErrorBadRequest("bad x-client-id") + } + if let Some(client_id_hdr) = req.headers().get(CLIENT_ID_HEADER) { + let client_id = client_id_hdr.to_str().map_err(|_| badrequest())?; + let client_id = ClientId::parse_str(client_id).map_err(|_| badrequest())?; + Ok(client_id) + } else { + Err(badrequest()) + } +} diff --git a/src/bin/taskchampion-sync-server.rs b/src/bin/taskchampion-sync-server.rs new file mode 100644 index 0000000..64210e4 --- /dev/null +++ b/src/bin/taskchampion-sync-server.rs @@ -0,0 +1,77 @@ +#![deny(clippy::all)] + +use actix_web::{middleware::Logger, App, HttpServer}; +use clap::{arg, builder::ValueParser, value_parser, Command}; +use std::ffi::OsString; +use taskchampion_sync_server::storage::SqliteStorage; +use taskchampion_sync_server::{Server, ServerConfig}; + +#[actix_web::main] +async fn main() -> anyhow::Result<()> { + env_logger::init(); + let defaults = ServerConfig::default(); + let default_snapshot_versions = defaults.snapshot_versions.to_string(); + let default_snapshot_days = defaults.snapshot_days.to_string(); + let matches = Command::new("taskchampion-sync-server") + .version(env!("CARGO_PKG_VERSION")) + .about("Server for TaskChampion") + .arg( + arg!(-p --port "Port on which to serve") + .help("Port on which to serve") + .value_parser(value_parser!(usize)) + .default_value("8080"), + ) + .arg( + arg!(-d --"data-dir" "Directory in which to store data") + .value_parser(ValueParser::os_string()) + .default_value("/var/lib/taskchampion-sync-server"), + ) + .arg( + arg!(--"snapshot-versions" "Target number of versions between snapshots") + .value_parser(value_parser!(u32)) + .default_value(default_snapshot_versions), + ) + .arg( + arg!(--"snapshot-days" "Target number of days between snapshots") + .value_parser(value_parser!(i64)) + .default_value(default_snapshot_days), + ) + .get_matches(); + + let data_dir: &OsString = matches.get_one("data-dir").unwrap(); + let port: usize = *matches.get_one("port").unwrap(); + let snapshot_versions: u32 = *matches.get_one("snapshot-versions").unwrap(); + let snapshot_days: i64 = *matches.get_one("snapshot-days").unwrap(); + + let config = ServerConfig::from_args(snapshot_days, snapshot_versions)?; + let server = Server::new(config, Box::new(SqliteStorage::new(data_dir)?)); + + log::warn!("Serving on port {}", port); + HttpServer::new(move || { + App::new() + .wrap(Logger::default()) + .configure(|cfg| server.config(cfg)) + }) + .bind(format!("0.0.0.0:{}", port))? + .run() + .await?; + Ok(()) +} + +#[cfg(test)] +mod test { + use super::*; + use actix_web::{test, App}; + use taskchampion_sync_server::storage::InMemoryStorage; + + #[actix_rt::test] + async fn test_index_get() { + let server = Server::new(Default::default(), Box::new(InMemoryStorage::new())); + let app = App::new().configure(|sc| server.config(sc)); + let mut app = test::init_service(app).await; + + let req = test::TestRequest::get().uri("/").to_request(); + let resp = test::call_service(&mut app, req).await; + assert!(resp.status().is_success()); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..5ba4658 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,72 @@ +#![deny(clippy::all)] + +mod api; +mod server; +pub mod storage; + +use crate::storage::Storage; +use actix_web::{get, middleware, web, Responder}; +use api::{api_scope, ServerState}; +use std::sync::Arc; + +pub use server::ServerConfig; + +#[get("/")] +async fn index() -> impl Responder { + format!("TaskChampion sync server v{}", env!("CARGO_PKG_VERSION")) +} + +/// A Server represents a sync server. +#[derive(Clone)] +pub struct Server { + server_state: Arc, +} + +impl Server { + /// Create a new sync server with the given storage implementation. + pub fn new(config: ServerConfig, storage: Box) -> Self { + Self { + server_state: Arc::new(ServerState { config, storage }), + } + } + + /// Get an Actix-web service for this server. + pub fn config(&self, cfg: &mut web::ServiceConfig) { + cfg.service( + web::scope("") + .app_data(web::Data::new(self.server_state.clone())) + .wrap( + middleware::DefaultHeaders::new().add(("Cache-Control", "no-store, max-age=0")), + ) + .service(index) + .service(api_scope()), + ); + } +} + +#[cfg(test)] +mod test { + use super::*; + use crate::storage::InMemoryStorage; + use actix_web::{test, App}; + use pretty_assertions::assert_eq; + + pub(crate) fn init_logging() { + let _ = env_logger::builder().is_test(true).try_init(); + } + + #[actix_rt::test] + async fn test_cache_control() { + let server = Server::new(Default::default(), Box::new(InMemoryStorage::new())); + let app = App::new().configure(|sc| server.config(sc)); + let mut app = test::init_service(app).await; + + let req = test::TestRequest::get().uri("/").to_request(); + let resp = test::call_service(&mut app, req).await; + assert!(resp.status().is_success()); + assert_eq!( + resp.headers().get("Cache-Control").unwrap(), + &"no-store, max-age=0".to_string() + ) + } +} diff --git a/src/server.rs b/src/server.rs new file mode 100644 index 0000000..61e349e --- /dev/null +++ b/src/server.rs @@ -0,0 +1,1048 @@ +//! This module implements the core logic of the server: handling transactions, upholding +//! invariants, and so on. This does not implement the HTTP-specific portions; those +//! are in [`crate::api`]. See the protocol documentation for details. +use crate::storage::{Client, Snapshot, StorageTxn}; +use chrono::Utc; +use uuid::Uuid; + +/// The distinguished value for "no version" +pub const NIL_VERSION_ID: VersionId = Uuid::nil(); + +/// Number of versions to search back from the latest to find the +/// version for a newly-added snapshot. Snapshots for versions older +/// than this will be rejected. +const SNAPSHOT_SEARCH_LEN: i32 = 5; + +pub(crate) type HistorySegment = Vec; +pub(crate) type ClientId = Uuid; +pub(crate) type VersionId = Uuid; + +/// ServerConfig contains configuration parameters for the server. +pub struct ServerConfig { + /// Target number of days between snapshots. + pub snapshot_days: i64, + + /// Target number of versions between snapshots. + pub snapshot_versions: u32, +} + +impl Default for ServerConfig { + fn default() -> Self { + ServerConfig { + snapshot_days: 14, + snapshot_versions: 100, + } + } +} + +impl ServerConfig { + pub fn from_args(snapshot_days: i64, snapshot_versions: u32) -> anyhow::Result { + Ok(ServerConfig { + snapshot_days, + snapshot_versions, + }) + } +} + +/// Response to get_child_version. See the protocol documentation. +#[derive(Clone, PartialEq, Debug)] +pub(crate) enum GetVersionResult { + NotFound, + Gone, + Success { + version_id: Uuid, + parent_version_id: Uuid, + history_segment: HistorySegment, + }, +} + +/// Implementation of the GetChildVersion protocol transaction +pub(crate) fn get_child_version<'a>( + mut txn: Box, + _config: &ServerConfig, + client_id: ClientId, + client: Client, + parent_version_id: VersionId, +) -> anyhow::Result { + // If a version with parentVersionId equal to the requested parentVersionId exists, it is returned. + if let Some(version) = txn.get_version_by_parent(client_id, parent_version_id)? { + return Ok(GetVersionResult::Success { + version_id: version.version_id, + parent_version_id: version.parent_version_id, + history_segment: version.history_segment, + }); + } + + // If the requested parentVersionId is the nil UUID .. + if parent_version_id == NIL_VERSION_ID { + return Ok(match client.snapshot { + // ..and snapshotVersionId is nil, the response is _not-found_ (the client has no + // versions). + None => GetVersionResult::NotFound, + // ..and snapshotVersionId is not nil, the response is _gone_ (the first version has + // been deleted). + Some(_) => GetVersionResult::Gone, + }); + } + + // If a version with versionId equal to the requested parentVersionId exists, the response is _not-found_ (the client is up-to-date) + if txn.get_version(client_id, parent_version_id)?.is_some() { + return Ok(GetVersionResult::NotFound); + } + + // Otherwise, the response is _gone_ (the requested version has been deleted). + Ok(GetVersionResult::Gone) +} + +/// Response to add_version +#[derive(Clone, PartialEq, Debug)] +pub(crate) enum AddVersionResult { + /// OK, version added with the given ID + Ok(VersionId), + /// Rejected; expected a version with the given parent version + ExpectedParentVersion(VersionId), +} + +/// Urgency of a snapshot for a client; used to create the `X-Snapshot-Request` header. +#[derive(PartialEq, Debug, Clone, Copy, Eq, PartialOrd, Ord)] +pub(crate) enum SnapshotUrgency { + /// Don't need a snapshot right now. + None, + + /// A snapshot would be good, but can wait for other replicas to provide it. + Low, + + /// A snapshot is needed right now. + High, +} + +impl SnapshotUrgency { + /// Calculate the urgency for a snapshot based on its age in days + fn for_days(config: &ServerConfig, days: i64) -> Self { + if days >= config.snapshot_days * 3 / 2 { + SnapshotUrgency::High + } else if days >= config.snapshot_days { + SnapshotUrgency::Low + } else { + SnapshotUrgency::None + } + } + + /// Calculate the urgency for a snapshot based on its age in versions + fn for_versions_since(config: &ServerConfig, versions_since: u32) -> Self { + if versions_since >= config.snapshot_versions * 3 / 2 { + SnapshotUrgency::High + } else if versions_since >= config.snapshot_versions { + SnapshotUrgency::Low + } else { + SnapshotUrgency::None + } + } +} + +/// Implementation of the AddVersion protocol transaction +pub(crate) fn add_version<'a>( + mut txn: Box, + config: &ServerConfig, + client_id: ClientId, + client: Client, + parent_version_id: VersionId, + history_segment: HistorySegment, +) -> anyhow::Result<(AddVersionResult, SnapshotUrgency)> { + log::debug!( + "add_version(client_id: {}, parent_version_id: {})", + client_id, + parent_version_id, + ); + + // check if this version is acceptable, under the protection of the transaction + if client.latest_version_id != NIL_VERSION_ID && parent_version_id != client.latest_version_id { + log::debug!("add_version request rejected: mismatched latest_version_id"); + return Ok(( + AddVersionResult::ExpectedParentVersion(client.latest_version_id), + SnapshotUrgency::None, + )); + } + + // invent a version ID + let version_id = Uuid::new_v4(); + log::debug!( + "add_version request accepted: new version_id: {}", + version_id + ); + + // update the DB + txn.add_version(client_id, version_id, parent_version_id, history_segment)?; + txn.commit()?; + + // calculate the urgency + let time_urgency = match client.snapshot { + None => SnapshotUrgency::High, + Some(Snapshot { timestamp, .. }) => { + SnapshotUrgency::for_days(config, (Utc::now() - timestamp).num_days()) + } + }; + + let version_urgency = match client.snapshot { + None => SnapshotUrgency::High, + Some(Snapshot { versions_since, .. }) => { + SnapshotUrgency::for_versions_since(config, versions_since) + } + }; + + Ok(( + AddVersionResult::Ok(version_id), + std::cmp::max(time_urgency, version_urgency), + )) +} + +/// Implementation of the AddSnapshot protocol transaction +pub(crate) fn add_snapshot<'a>( + mut txn: Box, + _config: &ServerConfig, + client_id: ClientId, + client: Client, + version_id: VersionId, + data: Vec, +) -> anyhow::Result<()> { + log::debug!( + "add_snapshot(client_id: {}, version_id: {})", + client_id, + version_id, + ); + + // NOTE: if the snapshot is rejected, this function logs about it and returns + // Ok(()), as there's no reason to report an errot to the client / user. + + let last_snapshot = client.snapshot.map(|snap| snap.version_id); + if Some(version_id) == last_snapshot { + log::debug!( + "rejecting snapshot for version {}: already exists", + version_id + ); + return Ok(()); + } + + // look for this version in the history of this client, starting at the latest version, and + // only iterating for a limited number of versions. + let mut search_len = SNAPSHOT_SEARCH_LEN; + let mut vid = client.latest_version_id; + + loop { + if vid == version_id && version_id != NIL_VERSION_ID { + // the new snapshot is for a recent version, so proceed + break; + } + + if Some(vid) == last_snapshot { + // the new snapshot is older than the last snapshot, so ignore it + log::debug!( + "rejecting snapshot for version {}: newer snapshot already exists or no such version", + version_id + ); + return Ok(()); + } + + search_len -= 1; + if search_len <= 0 || vid == NIL_VERSION_ID { + // this should not happen in normal operation, so warn about it + log::warn!( + "rejecting snapshot for version {}: version is too old or no such version", + version_id + ); + return Ok(()); + } + + // get the parent version ID + if let Some(parent) = txn.get_version(client_id, vid)? { + vid = parent.parent_version_id; + } else { + // this version does not exist; "this should not happen" but if it does, + // we don't need a snapshot earlier than the missing version. + log::warn!( + "rejecting snapshot for version {}: newer versions have already been deleted", + version_id + ); + return Ok(()); + } + } + + log::warn!("accepting snapshot for version {}", version_id); + txn.set_snapshot( + client_id, + Snapshot { + version_id, + timestamp: Utc::now(), + versions_since: 0, + }, + data, + )?; + txn.commit()?; + Ok(()) +} + +/// Implementation of the GetSnapshot protocol transaction +pub(crate) fn get_snapshot<'a>( + mut txn: Box, + _config: &ServerConfig, + client_id: ClientId, + client: Client, +) -> anyhow::Result)>> { + Ok(if let Some(snap) = client.snapshot { + txn.get_snapshot_data(client_id, snap.version_id)? + .map(|data| (snap.version_id, data)) + } else { + None + }) +} + +#[cfg(test)] +mod test { + use super::*; + use crate::storage::{InMemoryStorage, Snapshot, Storage}; + use crate::test::init_logging; + use chrono::{Duration, TimeZone, Utc}; + use pretty_assertions::assert_eq; + + #[test] + fn snapshot_urgency_max() { + use SnapshotUrgency::*; + assert_eq!(std::cmp::max(None, None), None); + assert_eq!(std::cmp::max(None, Low), Low); + assert_eq!(std::cmp::max(None, High), High); + assert_eq!(std::cmp::max(Low, None), Low); + assert_eq!(std::cmp::max(Low, Low), Low); + assert_eq!(std::cmp::max(Low, High), High); + assert_eq!(std::cmp::max(High, None), High); + assert_eq!(std::cmp::max(High, Low), High); + assert_eq!(std::cmp::max(High, High), High); + } + + #[test] + fn snapshot_urgency_for_days() { + use SnapshotUrgency::*; + let config = ServerConfig::default(); + assert_eq!(SnapshotUrgency::for_days(&config, 0), None); + assert_eq!( + SnapshotUrgency::for_days(&config, config.snapshot_days), + Low + ); + assert_eq!( + SnapshotUrgency::for_days(&config, config.snapshot_days * 2), + High + ); + } + + #[test] + fn snapshot_urgency_for_versions_since() { + use SnapshotUrgency::*; + let config = ServerConfig::default(); + assert_eq!(SnapshotUrgency::for_versions_since(&config, 0), None); + assert_eq!( + SnapshotUrgency::for_versions_since(&config, config.snapshot_versions), + Low + ); + assert_eq!( + SnapshotUrgency::for_versions_since(&config, config.snapshot_versions * 2), + High + ); + } + + #[test] + fn get_child_version_not_found_initial() -> anyhow::Result<()> { + init_logging(); + + let storage = InMemoryStorage::new(); + let mut txn = storage.txn()?; + let client_id = Uuid::new_v4(); + txn.new_client(client_id, NIL_VERSION_ID)?; + + // when no snapshot exists, the first version is NotFound + let client = txn.get_client(client_id)?.unwrap(); + assert_eq!( + get_child_version( + txn, + &ServerConfig::default(), + client_id, + client, + NIL_VERSION_ID + )?, + GetVersionResult::NotFound + ); + Ok(()) + } + + #[test] + fn get_child_version_gone_initial() -> anyhow::Result<()> { + init_logging(); + + let storage = InMemoryStorage::new(); + let mut txn = storage.txn()?; + let client_id = Uuid::new_v4(); + + txn.new_client(client_id, Uuid::new_v4())?; + txn.set_snapshot( + client_id, + Snapshot { + version_id: Uuid::new_v4(), + versions_since: 0, + timestamp: Utc.ymd(2001, 9, 9).and_hms(1, 46, 40), + }, + vec![1, 2, 3], + )?; + + // when a snapshot exists, the first version is GONE + let client = txn.get_client(client_id)?.unwrap(); + assert_eq!( + get_child_version( + txn, + &ServerConfig::default(), + client_id, + client, + NIL_VERSION_ID + )?, + GetVersionResult::Gone + ); + Ok(()) + } + + #[test] + fn get_child_version_not_found_up_to_date() -> anyhow::Result<()> { + init_logging(); + + let storage = InMemoryStorage::new(); + let mut txn = storage.txn()?; + let client_id = Uuid::new_v4(); + + // add a parent version, but not the requested child version + let parent_version_id = Uuid::new_v4(); + txn.new_client(client_id, parent_version_id)?; + txn.add_version(client_id, parent_version_id, NIL_VERSION_ID, vec![])?; + + let client = txn.get_client(client_id)?.unwrap(); + assert_eq!( + get_child_version( + txn, + &ServerConfig::default(), + client_id, + client, + parent_version_id + )?, + GetVersionResult::NotFound + ); + Ok(()) + } + + #[test] + fn get_child_version_gone() -> anyhow::Result<()> { + init_logging(); + + let storage = InMemoryStorage::new(); + let mut txn = storage.txn()?; + let client_id = Uuid::new_v4(); + + // make up a parent version id, but neither that version + // nor its child exists (presumed to have been deleted) + let parent_version_id = Uuid::new_v4(); + txn.new_client(client_id, Uuid::new_v4())?; + + let client = txn.get_client(client_id)?.unwrap(); + assert_eq!( + get_child_version( + txn, + &ServerConfig::default(), + client_id, + client, + parent_version_id + )?, + GetVersionResult::Gone + ); + Ok(()) + } + + #[test] + fn get_child_version_found() -> anyhow::Result<()> { + init_logging(); + + let storage = InMemoryStorage::new(); + let mut txn = storage.txn()?; + let client_id = Uuid::new_v4(); + let version_id = Uuid::new_v4(); + let parent_version_id = Uuid::new_v4(); + let history_segment = b"abcd".to_vec(); + + txn.new_client(client_id, version_id)?; + txn.add_version( + client_id, + version_id, + parent_version_id, + history_segment.clone(), + )?; + + let client = txn.get_client(client_id)?.unwrap(); + assert_eq!( + get_child_version( + txn, + &ServerConfig::default(), + client_id, + client, + parent_version_id + )?, + GetVersionResult::Success { + version_id, + parent_version_id, + history_segment, + } + ); + Ok(()) + } + + /// Utility setup function for add_version tests + fn av_setup( + storage: &InMemoryStorage, + num_versions: u32, + snapshot_version: Option, + snapshot_days_ago: Option, + ) -> anyhow::Result<(Uuid, Vec)> { + init_logging(); + let mut txn = storage.txn()?; + let client_id = Uuid::new_v4(); + let mut versions = vec![]; + + let mut version_id = Uuid::nil(); + txn.new_client(client_id, Uuid::nil())?; + for vnum in 0..num_versions { + let parent_version_id = version_id; + version_id = Uuid::new_v4(); + versions.push(version_id); + txn.add_version( + client_id, + version_id, + parent_version_id, + vec![0, 0, vnum as u8], + )?; + if Some(vnum) == snapshot_version { + txn.set_snapshot( + client_id, + Snapshot { + version_id, + versions_since: 0, + timestamp: Utc::now() - Duration::days(snapshot_days_ago.unwrap_or(0)), + }, + vec![vnum as u8], + )?; + } + } + + Ok((client_id, versions)) + } + + /// Utility function to check the results of an add_version call + fn av_success_check( + storage: &InMemoryStorage, + client_id: Uuid, + existing_versions: &[Uuid], + result: (AddVersionResult, SnapshotUrgency), + expected_history: Vec, + expected_urgency: SnapshotUrgency, + ) -> anyhow::Result<()> { + if let AddVersionResult::Ok(new_version_id) = result.0 { + // check that it invented a new version ID + for v in existing_versions { + assert_ne!(&new_version_id, v); + } + + // verify that the storage was updated + let mut txn = storage.txn()?; + let client = txn.get_client(client_id)?.unwrap(); + assert_eq!(client.latest_version_id, new_version_id); + + let parent_version_id = existing_versions.last().cloned().unwrap_or_else(Uuid::nil); + let version = txn.get_version(client_id, new_version_id)?.unwrap(); + assert_eq!(version.version_id, new_version_id); + assert_eq!(version.parent_version_id, parent_version_id); + assert_eq!(version.history_segment, expected_history); + } else { + panic!("did not get Ok from add_version: {:?}", result); + } + + assert_eq!(result.1, expected_urgency); + + Ok(()) + } + + #[test] + fn add_version_conflict() -> anyhow::Result<()> { + let storage = InMemoryStorage::new(); + let (client_id, versions) = av_setup(&storage, 3, None, None)?; + + let mut txn = storage.txn()?; + let client = txn.get_client(client_id)?.unwrap(); + + // try to add a child of a version other than the latest + assert_eq!( + add_version( + txn, + &ServerConfig::default(), + client_id, + client, + versions[1], + vec![3, 6, 9] + )? + .0, + AddVersionResult::ExpectedParentVersion(versions[2]) + ); + + // verify that the storage wasn't updated + txn = storage.txn()?; + assert_eq!( + txn.get_client(client_id)?.unwrap().latest_version_id, + versions[2] + ); + assert_eq!(txn.get_version_by_parent(client_id, versions[2])?, None); + + Ok(()) + } + + #[test] + fn add_version_with_existing_history() -> anyhow::Result<()> { + let storage = InMemoryStorage::new(); + let (client_id, versions) = av_setup(&storage, 1, None, None)?; + + let mut txn = storage.txn()?; + let client = txn.get_client(client_id)?.unwrap(); + + let result = add_version( + txn, + &ServerConfig::default(), + client_id, + client, + versions[0], + vec![3, 6, 9], + )?; + + av_success_check( + &storage, + client_id, + &versions, + result, + vec![3, 6, 9], + // urgency=high because there are no snapshots yet + SnapshotUrgency::High, + )?; + + Ok(()) + } + + #[test] + fn add_version_with_no_history() -> anyhow::Result<()> { + let storage = InMemoryStorage::new(); + let (client_id, versions) = av_setup(&storage, 0, None, None)?; + + let mut txn = storage.txn()?; + let client = txn.get_client(client_id)?.unwrap(); + + let parent_version_id = Uuid::nil(); + let result = add_version( + txn, + &ServerConfig::default(), + client_id, + client, + parent_version_id, + vec![3, 6, 9], + )?; + + av_success_check( + &storage, + client_id, + &versions, + result, + vec![3, 6, 9], + // urgency=high because there are no snapshots yet + SnapshotUrgency::High, + )?; + + Ok(()) + } + + #[test] + fn add_version_success_recent_snapshot() -> anyhow::Result<()> { + let storage = InMemoryStorage::new(); + let (client_id, versions) = av_setup(&storage, 1, Some(0), None)?; + + let mut txn = storage.txn()?; + let client = txn.get_client(client_id)?.unwrap(); + + let result = add_version( + txn, + &ServerConfig::default(), + client_id, + client, + versions[0], + vec![1, 2, 3], + )?; + + av_success_check( + &storage, + client_id, + &versions, + result, + vec![1, 2, 3], + // no snapshot request since the previous version has a snapshot + SnapshotUrgency::None, + )?; + + Ok(()) + } + + #[test] + fn add_version_success_aged_snapshot() -> anyhow::Result<()> { + let storage = InMemoryStorage::new(); + // one snapshot, but it was 50 days ago + let (client_id, versions) = av_setup(&storage, 1, Some(0), Some(50))?; + + let mut txn = storage.txn()?; + let client = txn.get_client(client_id)?.unwrap(); + + let result = add_version( + txn, + &ServerConfig::default(), + client_id, + client, + versions[0], + vec![1, 2, 3], + )?; + + av_success_check( + &storage, + client_id, + &versions, + result, + vec![1, 2, 3], + // urgency=high due to days since the snapshot + SnapshotUrgency::High, + )?; + + Ok(()) + } + + #[test] + fn add_version_success_snapshot_many_versions_ago() -> anyhow::Result<()> { + let storage = InMemoryStorage::new(); + // one snapshot, but it was 50 versions ago + let (client_id, versions) = av_setup(&storage, 50, Some(0), None)?; + + let mut txn = storage.txn()?; + let client = txn.get_client(client_id)?.unwrap(); + + let result = add_version( + txn, + &ServerConfig { + snapshot_versions: 30, + ..ServerConfig::default() + }, + client_id, + client, + versions[49], + vec![1, 2, 3], + )?; + + av_success_check( + &storage, + client_id, + &versions, + result, + vec![1, 2, 3], + // urgency=high due to number of versions since the snapshot + SnapshotUrgency::High, + )?; + + Ok(()) + } + + #[test] + fn add_snapshot_success_latest() -> anyhow::Result<()> { + init_logging(); + + let storage = InMemoryStorage::new(); + let mut txn = storage.txn()?; + let client_id = Uuid::new_v4(); + let version_id = Uuid::new_v4(); + + // set up a task DB with one version in it + txn.new_client(client_id, version_id)?; + txn.add_version(client_id, version_id, NIL_VERSION_ID, vec![])?; + + // add a snapshot for that version + let client = txn.get_client(client_id)?.unwrap(); + add_snapshot( + txn, + &ServerConfig::default(), + client_id, + client, + version_id, + vec![1, 2, 3], + )?; + + // verify the snapshot + let mut txn = storage.txn()?; + let client = txn.get_client(client_id)?.unwrap(); + let snapshot = client.snapshot.unwrap(); + assert_eq!(snapshot.version_id, version_id); + assert_eq!(snapshot.versions_since, 0); + assert_eq!( + txn.get_snapshot_data(client_id, version_id).unwrap(), + Some(vec![1, 2, 3]) + ); + + Ok(()) + } + + #[test] + fn add_snapshot_success_older() -> anyhow::Result<()> { + init_logging(); + + let storage = InMemoryStorage::new(); + let mut txn = storage.txn()?; + let client_id = Uuid::new_v4(); + let version_id_1 = Uuid::new_v4(); + let version_id_2 = Uuid::new_v4(); + + // set up a task DB with two versions in it + txn.new_client(client_id, version_id_2)?; + txn.add_version(client_id, version_id_1, NIL_VERSION_ID, vec![])?; + txn.add_version(client_id, version_id_2, version_id_1, vec![])?; + + // add a snapshot for version 1 + let client = txn.get_client(client_id)?.unwrap(); + add_snapshot( + txn, + &ServerConfig::default(), + client_id, + client, + version_id_1, + vec![1, 2, 3], + )?; + + // verify the snapshot + let mut txn = storage.txn()?; + let client = txn.get_client(client_id)?.unwrap(); + let snapshot = client.snapshot.unwrap(); + assert_eq!(snapshot.version_id, version_id_1); + assert_eq!(snapshot.versions_since, 0); + assert_eq!( + txn.get_snapshot_data(client_id, version_id_1).unwrap(), + Some(vec![1, 2, 3]) + ); + + Ok(()) + } + + #[test] + fn add_snapshot_fails_no_such() -> anyhow::Result<()> { + init_logging(); + + let storage = InMemoryStorage::new(); + let mut txn = storage.txn()?; + let client_id = Uuid::new_v4(); + let version_id_1 = Uuid::new_v4(); + let version_id_2 = Uuid::new_v4(); + + // set up a task DB with two versions in it + txn.new_client(client_id, version_id_2)?; + txn.add_version(client_id, version_id_1, NIL_VERSION_ID, vec![])?; + txn.add_version(client_id, version_id_2, version_id_1, vec![])?; + + // add a snapshot for unknown version + let client = txn.get_client(client_id)?.unwrap(); + let version_id_unk = Uuid::new_v4(); + add_snapshot( + txn, + &ServerConfig::default(), + client_id, + client, + version_id_unk, + vec![1, 2, 3], + )?; + + // verify the snapshot does not exist + let mut txn = storage.txn()?; + let client = txn.get_client(client_id)?.unwrap(); + assert!(client.snapshot.is_none()); + + Ok(()) + } + + #[test] + fn add_snapshot_fails_too_old() -> anyhow::Result<()> { + init_logging(); + + let storage = InMemoryStorage::new(); + let mut txn = storage.txn()?; + let client_id = Uuid::new_v4(); + let mut version_id = Uuid::new_v4(); + let mut parent_version_id = Uuid::nil(); + let mut version_ids = vec![]; + + // set up a task DB with 10 versions in it (oldest to newest) + txn.new_client(client_id, Uuid::nil())?; + for _ in 0..10 { + txn.add_version(client_id, version_id, parent_version_id, vec![])?; + version_ids.push(version_id); + parent_version_id = version_id; + version_id = Uuid::new_v4(); + } + + // add a snapshot for the earliest of those + let client = txn.get_client(client_id)?.unwrap(); + add_snapshot( + txn, + &ServerConfig::default(), + client_id, + client, + version_ids[0], + vec![1, 2, 3], + )?; + + // verify the snapshot does not exist + let mut txn = storage.txn()?; + let client = txn.get_client(client_id)?.unwrap(); + assert!(client.snapshot.is_none()); + + Ok(()) + } + + #[test] + fn add_snapshot_fails_newer_exists() -> anyhow::Result<()> { + init_logging(); + + let storage = InMemoryStorage::new(); + let mut txn = storage.txn()?; + let client_id = Uuid::new_v4(); + let mut version_id = Uuid::new_v4(); + let mut parent_version_id = Uuid::nil(); + let mut version_ids = vec![]; + + // set up a task DB with 5 versions in it (oldest to newest) and a snapshot of the middle + // one + txn.new_client(client_id, Uuid::nil())?; + for _ in 0..5 { + txn.add_version(client_id, version_id, parent_version_id, vec![])?; + version_ids.push(version_id); + parent_version_id = version_id; + version_id = Uuid::new_v4(); + } + txn.set_snapshot( + client_id, + Snapshot { + version_id: version_ids[2], + versions_since: 2, + timestamp: Utc.ymd(2001, 9, 9).and_hms(1, 46, 40), + }, + vec![1, 2, 3], + )?; + + // add a snapshot for the earliest of those + let client = txn.get_client(client_id)?.unwrap(); + add_snapshot( + txn, + &ServerConfig::default(), + client_id, + client, + version_ids[0], + vec![9, 9, 9], + )?; + + // verify the snapshot was not replaced + let mut txn = storage.txn()?; + let client = txn.get_client(client_id)?.unwrap(); + let snapshot = client.snapshot.unwrap(); + assert_eq!(snapshot.version_id, version_ids[2]); + assert_eq!(snapshot.versions_since, 2); + assert_eq!( + txn.get_snapshot_data(client_id, version_ids[2]).unwrap(), + Some(vec![1, 2, 3]) + ); + + Ok(()) + } + + #[test] + fn add_snapshot_fails_nil_version() -> anyhow::Result<()> { + init_logging(); + + let storage = InMemoryStorage::new(); + let mut txn = storage.txn()?; + let client_id = Uuid::new_v4(); + + // just set up the client + txn.new_client(client_id, NIL_VERSION_ID)?; + + // add a snapshot for the nil version + let client = txn.get_client(client_id)?.unwrap(); + add_snapshot( + txn, + &ServerConfig::default(), + client_id, + client, + NIL_VERSION_ID, + vec![9, 9, 9], + )?; + + // verify the snapshot does not exist + let mut txn = storage.txn()?; + let client = txn.get_client(client_id)?.unwrap(); + assert!(client.snapshot.is_none()); + + Ok(()) + } + + #[test] + fn get_snapshot_found() -> anyhow::Result<()> { + init_logging(); + + let storage = InMemoryStorage::new(); + let mut txn = storage.txn()?; + let client_id = Uuid::new_v4(); + let data = vec![1, 2, 3]; + let snapshot_version_id = Uuid::new_v4(); + + txn.new_client(client_id, snapshot_version_id)?; + txn.set_snapshot( + client_id, + Snapshot { + version_id: snapshot_version_id, + versions_since: 3, + timestamp: Utc.ymd(2001, 9, 9).and_hms(1, 46, 40), + }, + data.clone(), + )?; + + let client = txn.get_client(client_id)?.unwrap(); + assert_eq!( + get_snapshot(txn, &ServerConfig::default(), client_id, client)?, + Some((snapshot_version_id, data)) + ); + + Ok(()) + } + + #[test] + fn get_snapshot_not_found() -> anyhow::Result<()> { + init_logging(); + + let storage = InMemoryStorage::new(); + let mut txn = storage.txn()?; + let client_id = Uuid::new_v4(); + + txn.new_client(client_id, NIL_VERSION_ID)?; + let client = txn.get_client(client_id)?.unwrap(); + + assert_eq!( + get_snapshot(txn, &ServerConfig::default(), client_id, client)?, + None + ); + + Ok(()) + } +} diff --git a/src/storage/inmemory.rs b/src/storage/inmemory.rs new file mode 100644 index 0000000..3b17720 --- /dev/null +++ b/src/storage/inmemory.rs @@ -0,0 +1,286 @@ +use super::{Client, Snapshot, Storage, StorageTxn, Uuid, Version}; +use std::collections::HashMap; +use std::sync::{Mutex, MutexGuard}; + +struct Inner { + /// Clients, indexed by client_id + clients: HashMap, + + /// Snapshot data, indexed by client id + snapshots: HashMap>, + + /// Versions, indexed by (client_id, version_id) + versions: HashMap<(Uuid, Uuid), Version>, + + /// Child versions, indexed by (client_id, parent_version_id) + children: HashMap<(Uuid, Uuid), Uuid>, +} + +pub struct InMemoryStorage(Mutex); + +impl InMemoryStorage { + #[allow(clippy::new_without_default)] + pub fn new() -> Self { + Self(Mutex::new(Inner { + clients: HashMap::new(), + snapshots: HashMap::new(), + versions: HashMap::new(), + children: HashMap::new(), + })) + } +} + +struct InnerTxn<'a>(MutexGuard<'a, Inner>); + +/// In-memory storage for testing and experimentation. +/// +/// NOTE: this does not implement transaction rollback. +impl Storage for InMemoryStorage { + fn txn<'a>(&'a self) -> anyhow::Result> { + Ok(Box::new(InnerTxn(self.0.lock().expect("poisoned lock")))) + } +} + +impl<'a> StorageTxn for InnerTxn<'a> { + fn get_client(&mut self, client_id: Uuid) -> anyhow::Result> { + Ok(self.0.clients.get(&client_id).cloned()) + } + + fn new_client(&mut self, client_id: Uuid, latest_version_id: Uuid) -> anyhow::Result<()> { + if self.0.clients.get(&client_id).is_some() { + return Err(anyhow::anyhow!("Client {} already exists", client_id)); + } + self.0.clients.insert( + client_id, + Client { + latest_version_id, + snapshot: None, + }, + ); + Ok(()) + } + + fn set_snapshot( + &mut self, + client_id: Uuid, + snapshot: Snapshot, + data: Vec, + ) -> anyhow::Result<()> { + let client = self + .0 + .clients + .get_mut(&client_id) + .ok_or_else(|| anyhow::anyhow!("no such client"))?; + client.snapshot = Some(snapshot); + self.0.snapshots.insert(client_id, data); + Ok(()) + } + + fn get_snapshot_data( + &mut self, + client_id: Uuid, + version_id: Uuid, + ) -> anyhow::Result>> { + // sanity check + let client = self.0.clients.get(&client_id); + let client = client.ok_or_else(|| anyhow::anyhow!("no such client"))?; + if Some(&version_id) != client.snapshot.as_ref().map(|snap| &snap.version_id) { + return Err(anyhow::anyhow!("unexpected snapshot_version_id")); + } + Ok(self.0.snapshots.get(&client_id).cloned()) + } + + fn get_version_by_parent( + &mut self, + client_id: Uuid, + parent_version_id: Uuid, + ) -> anyhow::Result> { + if let Some(parent_version_id) = self.0.children.get(&(client_id, parent_version_id)) { + Ok(self + .0 + .versions + .get(&(client_id, *parent_version_id)) + .cloned()) + } else { + Ok(None) + } + } + + fn get_version( + &mut self, + client_id: Uuid, + version_id: Uuid, + ) -> anyhow::Result> { + Ok(self.0.versions.get(&(client_id, version_id)).cloned()) + } + + fn add_version( + &mut self, + client_id: Uuid, + version_id: Uuid, + parent_version_id: Uuid, + history_segment: Vec, + ) -> anyhow::Result<()> { + // TODO: verify it doesn't exist (`.entry`?) + let version = Version { + version_id, + parent_version_id, + history_segment, + }; + + if let Some(client) = self.0.clients.get_mut(&client_id) { + client.latest_version_id = version_id; + if let Some(ref mut snap) = client.snapshot { + snap.versions_since += 1; + } + } else { + return Err(anyhow::anyhow!("Client {} does not exist", client_id)); + } + + self.0 + .children + .insert((client_id, parent_version_id), version_id); + self.0.versions.insert((client_id, version_id), version); + + Ok(()) + } + + fn commit(&mut self) -> anyhow::Result<()> { + Ok(()) + } +} + +#[cfg(test)] +mod test { + use super::*; + use chrono::Utc; + + #[test] + fn test_get_client_empty() -> anyhow::Result<()> { + let storage = InMemoryStorage::new(); + let mut txn = storage.txn()?; + let maybe_client = txn.get_client(Uuid::new_v4())?; + assert!(maybe_client.is_none()); + Ok(()) + } + + #[test] + fn test_client_storage() -> anyhow::Result<()> { + let storage = InMemoryStorage::new(); + let mut txn = storage.txn()?; + + let client_id = Uuid::new_v4(); + let latest_version_id = Uuid::new_v4(); + txn.new_client(client_id, latest_version_id)?; + + let client = txn.get_client(client_id)?.unwrap(); + assert_eq!(client.latest_version_id, latest_version_id); + assert!(client.snapshot.is_none()); + + let latest_version_id = Uuid::new_v4(); + txn.add_version(client_id, latest_version_id, Uuid::new_v4(), vec![1, 1])?; + + let client = txn.get_client(client_id)?.unwrap(); + assert_eq!(client.latest_version_id, latest_version_id); + assert!(client.snapshot.is_none()); + + let snap = Snapshot { + version_id: Uuid::new_v4(), + timestamp: Utc::now(), + versions_since: 4, + }; + txn.set_snapshot(client_id, snap.clone(), vec![1, 2, 3])?; + + let client = txn.get_client(client_id)?.unwrap(); + assert_eq!(client.latest_version_id, latest_version_id); + assert_eq!(client.snapshot.unwrap(), snap); + + Ok(()) + } + + #[test] + fn test_gvbp_empty() -> anyhow::Result<()> { + let storage = InMemoryStorage::new(); + let mut txn = storage.txn()?; + let maybe_version = txn.get_version_by_parent(Uuid::new_v4(), Uuid::new_v4())?; + assert!(maybe_version.is_none()); + Ok(()) + } + + #[test] + fn test_add_version_and_get_version() -> anyhow::Result<()> { + let storage = InMemoryStorage::new(); + let mut txn = storage.txn()?; + + let client_id = Uuid::new_v4(); + let version_id = Uuid::new_v4(); + let parent_version_id = Uuid::new_v4(); + let history_segment = b"abc".to_vec(); + + txn.new_client(client_id, parent_version_id)?; + txn.add_version( + client_id, + version_id, + parent_version_id, + history_segment.clone(), + )?; + + let expected = Version { + version_id, + parent_version_id, + history_segment, + }; + + let version = txn + .get_version_by_parent(client_id, parent_version_id)? + .unwrap(); + assert_eq!(version, expected); + + let version = txn.get_version(client_id, version_id)?.unwrap(); + assert_eq!(version, expected); + + Ok(()) + } + + #[test] + fn test_snapshots() -> anyhow::Result<()> { + let storage = InMemoryStorage::new(); + let mut txn = storage.txn()?; + + let client_id = Uuid::new_v4(); + + txn.new_client(client_id, Uuid::new_v4())?; + assert!(txn.get_client(client_id)?.unwrap().snapshot.is_none()); + + let snap = Snapshot { + version_id: Uuid::new_v4(), + timestamp: Utc::now(), + versions_since: 3, + }; + txn.set_snapshot(client_id, snap.clone(), vec![9, 8, 9])?; + + assert_eq!( + txn.get_snapshot_data(client_id, snap.version_id)?.unwrap(), + vec![9, 8, 9] + ); + assert_eq!(txn.get_client(client_id)?.unwrap().snapshot, Some(snap)); + + let snap2 = Snapshot { + version_id: Uuid::new_v4(), + timestamp: Utc::now(), + versions_since: 10, + }; + txn.set_snapshot(client_id, snap2.clone(), vec![0, 2, 4, 6])?; + + assert_eq!( + txn.get_snapshot_data(client_id, snap2.version_id)?.unwrap(), + vec![0, 2, 4, 6] + ); + assert_eq!(txn.get_client(client_id)?.unwrap().snapshot, Some(snap2)); + + // check that mismatched version is detected + assert!(txn.get_snapshot_data(client_id, Uuid::new_v4()).is_err()); + + Ok(()) + } +} diff --git a/src/storage/mod.rs b/src/storage/mod.rs new file mode 100644 index 0000000..a2e468f --- /dev/null +++ b/src/storage/mod.rs @@ -0,0 +1,95 @@ +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +#[cfg(debug_assertions)] +mod inmemory; + +#[cfg(debug_assertions)] +pub use inmemory::InMemoryStorage; + +mod sqlite; +pub use self::sqlite::SqliteStorage; + +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct Client { + /// The latest version for this client (may be the nil version) + pub latest_version_id: Uuid, + /// Data about the latest snapshot for this client + pub snapshot: Option, +} + +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct Snapshot { + /// ID of the version at which this snapshot was made + pub version_id: Uuid, + + /// Timestamp at which this snapshot was set + pub timestamp: DateTime, + + /// Number of versions since this snapshot was made + pub versions_since: u32, +} + +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct Version { + pub version_id: Uuid, + pub parent_version_id: Uuid, + pub history_segment: Vec, +} + +pub trait StorageTxn { + /// Get information about the given client + fn get_client(&mut self, client_id: Uuid) -> anyhow::Result>; + + /// Create a new client with the given latest_version_id + fn new_client(&mut self, client_id: Uuid, latest_version_id: Uuid) -> anyhow::Result<()>; + + /// Set the client's most recent snapshot. + fn set_snapshot( + &mut self, + client_id: Uuid, + snapshot: Snapshot, + data: Vec, + ) -> anyhow::Result<()>; + + /// Get the data for the most recent snapshot. The version_id + /// is used to verify that the snapshot is for the correct version. + fn get_snapshot_data( + &mut self, + client_id: Uuid, + version_id: Uuid, + ) -> anyhow::Result>>; + + /// Get a version, indexed by parent version id + fn get_version_by_parent( + &mut self, + client_id: Uuid, + parent_version_id: Uuid, + ) -> anyhow::Result>; + + /// Get a version, indexed by its own version id + fn get_version(&mut self, client_id: Uuid, version_id: Uuid) + -> anyhow::Result>; + + /// Add a version (that must not already exist), and + /// - update latest_version_id + /// - increment snapshot.versions_since + fn add_version( + &mut self, + client_id: Uuid, + version_id: Uuid, + parent_version_id: Uuid, + history_segment: Vec, + ) -> anyhow::Result<()>; + + /// Commit any changes made in the transaction. It is an error to call this more than + /// once. It is safe to skip this call for read-only operations. + fn commit(&mut self) -> anyhow::Result<()>; +} + +/// A trait for objects able to act as storage. Most of the interesting behavior is in the +/// [`crate::storage::StorageTxn`] trait. +pub trait Storage: Send + Sync { + /// Begin a transaction + fn txn<'a>(&'a self) -> anyhow::Result>; +} diff --git a/src/storage/sqlite.rs b/src/storage/sqlite.rs new file mode 100644 index 0000000..a8571bb --- /dev/null +++ b/src/storage/sqlite.rs @@ -0,0 +1,451 @@ +use super::{Client, Snapshot, Storage, StorageTxn, Uuid, Version}; +use anyhow::Context; +use chrono::{TimeZone, Utc}; +use rusqlite::types::{FromSql, ToSql}; +use rusqlite::{params, Connection, OptionalExtension}; +use std::path::Path; + +#[derive(Debug, thiserror::Error)] +enum SqliteError { + #[error("Failed to create SQLite transaction")] + CreateTransactionFailed, +} + +/// Newtype to allow implementing `FromSql` for foreign `uuid::Uuid` +struct StoredUuid(Uuid); + +/// Conversion from Uuid stored as a string (rusqlite's uuid feature stores as binary blob) +impl FromSql for StoredUuid { + fn column_result(value: rusqlite::types::ValueRef<'_>) -> rusqlite::types::FromSqlResult { + let u = Uuid::parse_str(value.as_str()?) + .map_err(|_| rusqlite::types::FromSqlError::InvalidType)?; + Ok(StoredUuid(u)) + } +} + +/// Store Uuid as string in database +impl ToSql for StoredUuid { + fn to_sql(&self) -> rusqlite::Result> { + let s = self.0.to_string(); + Ok(s.into()) + } +} + +/// An on-disk storage backend which uses SQLite +pub struct SqliteStorage { + db_file: std::path::PathBuf, +} + +impl SqliteStorage { + fn new_connection(&self) -> anyhow::Result { + Ok(Connection::open(&self.db_file)?) + } + + pub fn new>(directory: P) -> anyhow::Result { + std::fs::create_dir_all(&directory)?; + let db_file = directory.as_ref().join("taskchampion-sync-server.sqlite3"); + + let o = SqliteStorage { db_file }; + + { + let mut con = o.new_connection()?; + let txn = con.transaction()?; + + let queries = vec![ + "CREATE TABLE IF NOT EXISTS clients ( + client_id STRING PRIMARY KEY, + latest_version_id STRING, + snapshot_version_id STRING, + versions_since_snapshot INTEGER, + snapshot_timestamp INTEGER, + snapshot BLOB);", + "CREATE TABLE IF NOT EXISTS versions (version_id STRING PRIMARY KEY, client_id STRING, parent_version_id STRING, history_segment BLOB);", + "CREATE INDEX IF NOT EXISTS versions_by_parent ON versions (parent_version_id);", + ]; + for q in queries { + txn.execute(q, []) + .context("Error while creating SQLite tables")?; + } + txn.commit()?; + } + + Ok(o) + } +} + +impl Storage for SqliteStorage { + fn txn<'a>(&'a self) -> anyhow::Result> { + let con = self.new_connection()?; + let t = Txn { con }; + Ok(Box::new(t)) + } +} + +struct Txn { + con: Connection, +} + +impl Txn { + fn get_txn(&mut self) -> Result { + self.con + .transaction() + .map_err(|_e| SqliteError::CreateTransactionFailed) + } + + /// Implementation for queries from the versions table + fn get_version_impl( + &mut self, + query: &'static str, + client_id: Uuid, + version_id_arg: Uuid, + ) -> anyhow::Result> { + let t = self.get_txn()?; + let r = t + .query_row( + query, + params![&StoredUuid(version_id_arg), &StoredUuid(client_id)], + |r| { + let version_id: StoredUuid = r.get("version_id")?; + let parent_version_id: StoredUuid = r.get("parent_version_id")?; + + Ok(Version { + version_id: version_id.0, + parent_version_id: parent_version_id.0, + history_segment: r.get("history_segment")?, + }) + }, + ) + .optional() + .context("Error getting version")?; + Ok(r) + } +} + +impl StorageTxn for Txn { + fn get_client(&mut self, client_id: Uuid) -> anyhow::Result> { + let t = self.get_txn()?; + let result: Option = t + .query_row( + "SELECT + latest_version_id, + snapshot_timestamp, + versions_since_snapshot, + snapshot_version_id + FROM clients + WHERE client_id = ? + LIMIT 1", + [&StoredUuid(client_id)], + |r| { + let latest_version_id: StoredUuid = r.get(0)?; + let snapshot_timestamp: Option = r.get(1)?; + let versions_since_snapshot: Option = r.get(2)?; + let snapshot_version_id: Option = r.get(3)?; + + // if all of the relevant fields are non-NULL, return a snapshot + let snapshot = match ( + snapshot_timestamp, + versions_since_snapshot, + snapshot_version_id, + ) { + (Some(ts), Some(vs), Some(v)) => Some(Snapshot { + version_id: v.0, + timestamp: Utc.timestamp(ts, 0), + versions_since: vs, + }), + _ => None, + }; + Ok(Client { + latest_version_id: latest_version_id.0, + snapshot, + }) + }, + ) + .optional() + .context("Error getting client")?; + + Ok(result) + } + + fn new_client(&mut self, client_id: Uuid, latest_version_id: Uuid) -> anyhow::Result<()> { + let t = self.get_txn()?; + + t.execute( + "INSERT OR REPLACE INTO clients (client_id, latest_version_id) VALUES (?, ?)", + params![&StoredUuid(client_id), &StoredUuid(latest_version_id)], + ) + .context("Error creating/updating client")?; + t.commit()?; + Ok(()) + } + + fn set_snapshot( + &mut self, + client_id: Uuid, + snapshot: Snapshot, + data: Vec, + ) -> anyhow::Result<()> { + let t = self.get_txn()?; + + t.execute( + "UPDATE clients + SET + snapshot_version_id = ?, + snapshot_timestamp = ?, + versions_since_snapshot = ?, + snapshot = ? + WHERE client_id = ?", + params![ + &StoredUuid(snapshot.version_id), + snapshot.timestamp.timestamp(), + snapshot.versions_since, + data, + &StoredUuid(client_id), + ], + ) + .context("Error creating/updating snapshot")?; + t.commit()?; + Ok(()) + } + + fn get_snapshot_data( + &mut self, + client_id: Uuid, + version_id: Uuid, + ) -> anyhow::Result>> { + let t = self.get_txn()?; + let r = t + .query_row( + "SELECT snapshot, snapshot_version_id FROM clients WHERE client_id = ?", + params![&StoredUuid(client_id)], + |r| { + let v: StoredUuid = r.get("snapshot_version_id")?; + let d: Vec = r.get("snapshot")?; + Ok((v.0, d)) + }, + ) + .optional() + .context("Error getting snapshot")?; + r.map(|(v, d)| { + if v != version_id { + return Err(anyhow::anyhow!("unexpected snapshot_version_id")); + } + + Ok(d) + }) + .transpose() + } + + fn get_version_by_parent( + &mut self, + client_id: Uuid, + parent_version_id: Uuid, + ) -> anyhow::Result> { + self.get_version_impl( + "SELECT version_id, parent_version_id, history_segment FROM versions WHERE parent_version_id = ? AND client_id = ?", + client_id, + parent_version_id) + } + + fn get_version( + &mut self, + client_id: Uuid, + version_id: Uuid, + ) -> anyhow::Result> { + self.get_version_impl( + "SELECT version_id, parent_version_id, history_segment FROM versions WHERE version_id = ? AND client_id = ?", + client_id, + version_id) + } + + fn add_version( + &mut self, + client_id: Uuid, + version_id: Uuid, + parent_version_id: Uuid, + history_segment: Vec, + ) -> anyhow::Result<()> { + let t = self.get_txn()?; + + t.execute( + "INSERT INTO versions (version_id, client_id, parent_version_id, history_segment) VALUES(?, ?, ?, ?)", + params![ + StoredUuid(version_id), + StoredUuid(client_id), + StoredUuid(parent_version_id), + history_segment + ] + ) + .context("Error adding version")?; + t.execute( + "UPDATE clients + SET + latest_version_id = ?, + versions_since_snapshot = versions_since_snapshot + 1 + WHERE client_id = ?", + params![StoredUuid(version_id), StoredUuid(client_id),], + ) + .context("Error updating client for new version")?; + + t.commit()?; + Ok(()) + } + + fn commit(&mut self) -> anyhow::Result<()> { + // FIXME: Note the queries aren't currently run in a + // transaction, as storing the transaction object and a pooled + // connection in the `Txn` object is complex. + // https://github.com/taskchampion/taskchampion/pull/206#issuecomment-860336073 + Ok(()) + } +} + +#[cfg(test)] +mod test { + use super::*; + use chrono::DateTime; + use pretty_assertions::assert_eq; + use tempfile::TempDir; + + #[test] + fn test_emtpy_dir() -> anyhow::Result<()> { + let tmp_dir = TempDir::new()?; + let non_existant = tmp_dir.path().join("subdir"); + let storage = SqliteStorage::new(non_existant)?; + let mut txn = storage.txn()?; + let maybe_client = txn.get_client(Uuid::new_v4())?; + assert!(maybe_client.is_none()); + Ok(()) + } + + #[test] + fn test_get_client_empty() -> anyhow::Result<()> { + let tmp_dir = TempDir::new()?; + let storage = SqliteStorage::new(tmp_dir.path())?; + let mut txn = storage.txn()?; + let maybe_client = txn.get_client(Uuid::new_v4())?; + assert!(maybe_client.is_none()); + Ok(()) + } + + #[test] + fn test_client_storage() -> anyhow::Result<()> { + let tmp_dir = TempDir::new()?; + let storage = SqliteStorage::new(tmp_dir.path())?; + let mut txn = storage.txn()?; + + let client_id = Uuid::new_v4(); + let latest_version_id = Uuid::new_v4(); + txn.new_client(client_id, latest_version_id)?; + + let client = txn.get_client(client_id)?.unwrap(); + assert_eq!(client.latest_version_id, latest_version_id); + assert!(client.snapshot.is_none()); + + let latest_version_id = Uuid::new_v4(); + txn.add_version(client_id, latest_version_id, Uuid::new_v4(), vec![1, 1])?; + + let client = txn.get_client(client_id)?.unwrap(); + assert_eq!(client.latest_version_id, latest_version_id); + assert!(client.snapshot.is_none()); + + let snap = Snapshot { + version_id: Uuid::new_v4(), + timestamp: "2014-11-28T12:00:09Z".parse::>().unwrap(), + versions_since: 4, + }; + txn.set_snapshot(client_id, snap.clone(), vec![1, 2, 3])?; + + let client = txn.get_client(client_id)?.unwrap(); + assert_eq!(client.latest_version_id, latest_version_id); + assert_eq!(client.snapshot.unwrap(), snap); + + Ok(()) + } + + #[test] + fn test_gvbp_empty() -> anyhow::Result<()> { + let tmp_dir = TempDir::new()?; + let storage = SqliteStorage::new(tmp_dir.path())?; + let mut txn = storage.txn()?; + let maybe_version = txn.get_version_by_parent(Uuid::new_v4(), Uuid::new_v4())?; + assert!(maybe_version.is_none()); + Ok(()) + } + + #[test] + fn test_add_version_and_get_version() -> anyhow::Result<()> { + let tmp_dir = TempDir::new()?; + let storage = SqliteStorage::new(tmp_dir.path())?; + let mut txn = storage.txn()?; + + let client_id = Uuid::new_v4(); + let version_id = Uuid::new_v4(); + let parent_version_id = Uuid::new_v4(); + let history_segment = b"abc".to_vec(); + txn.add_version( + client_id, + version_id, + parent_version_id, + history_segment.clone(), + )?; + + let expected = Version { + version_id, + parent_version_id, + history_segment, + }; + + let version = txn + .get_version_by_parent(client_id, parent_version_id)? + .unwrap(); + assert_eq!(version, expected); + + let version = txn.get_version(client_id, version_id)?.unwrap(); + assert_eq!(version, expected); + + Ok(()) + } + + #[test] + fn test_snapshots() -> anyhow::Result<()> { + let tmp_dir = TempDir::new()?; + let storage = SqliteStorage::new(tmp_dir.path())?; + let mut txn = storage.txn()?; + + let client_id = Uuid::new_v4(); + + txn.new_client(client_id, Uuid::new_v4())?; + assert!(txn.get_client(client_id)?.unwrap().snapshot.is_none()); + + let snap = Snapshot { + version_id: Uuid::new_v4(), + timestamp: "2013-10-08T12:00:09Z".parse::>().unwrap(), + versions_since: 3, + }; + txn.set_snapshot(client_id, snap.clone(), vec![9, 8, 9])?; + + assert_eq!( + txn.get_snapshot_data(client_id, snap.version_id)?.unwrap(), + vec![9, 8, 9] + ); + assert_eq!(txn.get_client(client_id)?.unwrap().snapshot, Some(snap)); + + let snap2 = Snapshot { + version_id: Uuid::new_v4(), + timestamp: "2014-11-28T12:00:09Z".parse::>().unwrap(), + versions_since: 10, + }; + txn.set_snapshot(client_id, snap2.clone(), vec![0, 2, 4, 6])?; + + assert_eq!( + txn.get_snapshot_data(client_id, snap2.version_id)?.unwrap(), + vec![0, 2, 4, 6] + ); + assert_eq!(txn.get_client(client_id)?.unwrap().snapshot, Some(snap2)); + + // check that mismatched version is detected + assert!(txn.get_snapshot_data(client_id, Uuid::new_v4()).is_err()); + + Ok(()) + } +}