diff --git a/Cargo.lock b/Cargo.lock index 38c528cc6..dbd4759de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -642,6 +642,40 @@ dependencies = [ "typenum", ] +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn", +] + [[package]] name = "der" version = "0.7.10" @@ -691,6 +725,12 @@ dependencies = [ "syn", ] +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "either" version = "1.15.0" @@ -1010,13 +1050,19 @@ dependencies = [ "futures-core", "futures-sink", "http 1.4.0", - "indexmap", + "indexmap 2.13.0", "slab", "tokio", "tokio-util", "tracing", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + [[package]] name = "hashbrown" version = "0.15.5" @@ -1348,7 +1394,7 @@ version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6554f394e990a1af530a528a7fdcad6e01b29cb1b990f89df3ffd62cf15f7828" dependencies = [ - "indexmap", + "indexmap 2.13.0", "js-sys", "num-traits", "thiserror 2.0.18", @@ -1357,6 +1403,12 @@ dependencies = [ "web-sys", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -1378,6 +1430,17 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + [[package]] name = "indexmap" version = "2.13.0" @@ -1927,6 +1990,26 @@ dependencies = [ "bitflags", ] +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "regex" version = "1.12.3" @@ -2203,6 +2286,30 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -2304,6 +2411,37 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.13.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "sha1" version = "0.10.6" @@ -2407,6 +2545,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "strum" version = "0.28.0" @@ -2477,6 +2621,7 @@ dependencies = [ "chrono", "flate2", "getrandom 0.4.2", + "glob", "google-cloud-storage", "httptest", "idb", @@ -2492,6 +2637,7 @@ dependencies = [ "serde", "serde-wasm-bindgen", "serde_json", + "serde_with", "strum", "strum_macros", "tempfile", @@ -2679,7 +2825,7 @@ version = "0.23.10+spec-1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" dependencies = [ - "indexmap", + "indexmap 2.13.0", "toml_datetime", "toml_parser", "winnow", @@ -3029,7 +3175,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap", + "indexmap 2.13.0", "wasm-encoder", "wasmparser", ] @@ -3055,7 +3201,7 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags", "hashbrown 0.15.5", - "indexmap", + "indexmap 2.13.0", "semver", ] @@ -3349,7 +3495,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck", - "indexmap", + "indexmap 2.13.0", "prettyplease", "syn", "wasm-metadata", @@ -3380,7 +3526,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags", - "indexmap", + "indexmap 2.13.0", "log", "serde", "serde_derive", @@ -3399,7 +3545,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap", + "indexmap 2.13.0", "log", "semver", "serde", diff --git a/Cargo.toml b/Cargo.toml index 76cd9db8a..f58626ec3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,7 @@ edition = "2021" rust-version = "1.91.1" [workspace] -members = [ "xtask" ] +members = ["xtask"] resolver = "2" [lib] @@ -24,15 +24,32 @@ crate-type = ["cdylib", "rlib"] default = ["sync", "bundled", "storage", "tls-webpki-roots"] # Support for all sync solutions -sync = ["server-sync", "server-gcp", "server-aws", "server-local"] +sync = ["server-sync", "server-gcp", "server-aws", "server-local", "server-git"] # Support for sync to a server server-sync = ["encryption", "http"] # Support for sync to GCP -server-gcp = ["cloud", "encryption", "http", "dep:google-cloud-storage", "dep:reqwest-middleware"] +server-gcp = [ + "cloud", + "encryption", + "http", + "dep:google-cloud-storage", + "dep:reqwest-middleware", +] # Support for sync to AWS -server-aws = ["cloud", "encryption", "http", "dep:aws-sdk-s3", "dep:aws-config", "dep:aws-credential-types", "dep:aws-smithy-runtime-api", "dep:aws-smithy-types"] +server-aws = [ + "cloud", + "encryption", + "http", + "dep:aws-sdk-s3", + "dep:aws-config", + "dep:aws-credential-types", + "dep:aws-smithy-runtime-api", + "dep:aws-smithy-types", +] # Suppport for sync to another SQLite database on the same machine server-local = ["dep:rusqlite"] +# Support for sync via Git +server-git = ["git-sync"] # Support for all task storage backends (except indexeddb, which only works on WASM builds) storage = ["storage-sqlite"] @@ -45,6 +62,8 @@ storage-indexeddb = ["dep:idb", "dep:web-sys"] encryption = ["dep:ring"] # (private) Generic support for cloud sync cloud = [] +# (private) Support for Git based sync +git-sync = ["dep:serde_with", "dep:glob", "encryption"] # static bundling of dependencies bundled = ["rusqlite/bundled"] # use CA roots built into the library for all HTTPS access @@ -64,12 +83,13 @@ async-trait = "0.1.89" byteorder = "1.5" chrono = { version = "^0.4.44", features = ["serde"] } flate2 = "1" +serde_with = { version = "3.18.0", features = ["base64"], optional = true } idb = { version = "0.6.4", optional = true } log = "^0.4.17" # Reqwest is "stuck" at 0.12 because that's what google-cloud-storage depends on. reqwest = { version = "0.12", default-features = false, optional = true } ring = { version = "0.17", optional = true } -rusqlite = { version = "0.39", features = [ "fallible_uint" ], optional = true} +rusqlite = { version = "0.39", features = ["fallible_uint"], optional = true } serde_json = "^1.0" serde = { version = "^1.0.147", features = ["derive"] } strum = "0.28" @@ -78,11 +98,14 @@ tokio = { version = "1", features = ["macros", "sync", "rt"] } thiserror = "2.0" uuid = { version = "^1.23.0", features = ["serde", "v4"] } url = { version = "2", optional = true } +glob = { version = "0.3.3", optional = true } ## wasm-only dependencies. [target.'cfg(target_arch = "wasm32")'.dependencies] uuid = { version = "1", features = ["js"] } -ring = { version = "0", optional = true, features = ["wasm32_unknown_unknown_js"] } +ring = { version = "0", optional = true, features = [ + "wasm32_unknown_unknown_js", +] } getrandom = { version = "0.4", features = ["wasm_js"] } wasm-bindgen = { version = "0.2" } wasm-bindgen-futures = { version = "0.4" } @@ -101,7 +124,7 @@ web-sys = { version = "0.3.83", optional = true, features = [ "EventTarget", "Event", "console", -]} +] } ## non-wasm dependencies [target.'cfg(not(target_arch = "wasm32"))'.dependencies] @@ -110,7 +133,10 @@ aws-config = { version = "1.8", default-features = false, features = ["rt-tokio" aws-credential-types = { version = "1", default-features = false, features = ["hardcoded-credentials"], optional = true } aws-smithy-runtime-api = { version = "1.11", default-features = false, optional = true } aws-smithy-types = { version = "1.4", default-features = false, optional = true } -google-cloud-storage = { version = "0.24.0", default-features = false, features = ["rustls-tls", "auth"], optional = true } +google-cloud-storage = { version = "0.24.0", default-features = false, features = [ + "rustls-tls", + "auth", +], optional = true } reqwest-middleware = { version = "0.4", optional = true } ## common dev-dependencies diff --git a/src/server/config.rs b/src/server/config.rs index fd2396a0a..b2e8b360a 100644 --- a/src/server/config.rs +++ b/src/server/config.rs @@ -8,12 +8,16 @@ use crate::server::cloud::aws::AwsService; use crate::server::cloud::gcp::GcpService; #[cfg(feature = "cloud")] use crate::server::cloud::CloudServer; +#[cfg(feature = "git-sync")] +use crate::server::gitsync::GitSyncServer; #[cfg(feature = "server-local")] use crate::server::local::LocalServer; #[cfg(feature = "server-sync")] use crate::server::sync::SyncServer; #[cfg(feature = "server-local")] use std::path::PathBuf; +#[cfg(all(feature = "git-sync", not(feature = "server-local")))] +use std::path::PathBuf; #[cfg(feature = "server-sync")] use uuid::Uuid; @@ -118,6 +122,26 @@ pub enum ServerConfig { /// be any suitably un-guessable string of bytes. encryption_secret: Vec, }, + /// A git repository + #[cfg(feature = "git-sync")] + Git { + /// The path to the local repo. + local_path: PathBuf, + /// The branch to use. + branch: String, + /// The remote repo. + /// + /// This can either be a named remote such as `origin` or a full git + /// url such as `git@myserver.com:/path/to/repo.git` + /// If `None` will use `origin` if the local repo has one. + /// Otherwise will operate in local only mode. + remote: String, + /// Don't clone/push/pull to `remote` even if it is defined. + local_only: bool, + /// Private encryption secret used to encrypt all data sent to the server. This can + /// be any suitably un-guessable string of bytes. + encryption_secret: Vec, + }, } impl ServerConfig { @@ -162,6 +186,20 @@ impl ServerConfig { ) .await?, ), + #[cfg(feature = "git-sync")] + ServerConfig::Git { + local_path, + branch, + remote, + local_only, + encryption_secret, + } => Box::new(GitSyncServer::new( + local_path, + branch, + remote, + local_only, + encryption_secret, + )?), }) } } diff --git a/src/server/encryption.rs b/src/server/encryption.rs index aa2c52e85..951a4fd24 100644 --- a/src/server/encryption.rs +++ b/src/server/encryption.rs @@ -27,7 +27,7 @@ impl Cryptor { } /// Generate a suitable random salt. - #[cfg(any(test, feature = "cloud"))] // server-sync uses the clientId as the salt. + #[cfg(any(test, feature = "cloud", feature = "git-sync"))] // server-sync uses the clientId as the salt. pub(super) fn gen_salt() -> Result> { let rng = rand::SystemRandom::new(); let mut salt = [0u8; 16]; @@ -172,6 +172,7 @@ impl<'a> Envelope<'a> { /// A unsealed payload with an attached version_id. The version_id is used to /// validate the context of the payload on unsealing. +#[derive(Debug)] pub(super) struct Unsealed { pub(super) version_id: Uuid, pub(super) payload: Vec, @@ -184,6 +185,7 @@ impl From for Vec { } /// An encrypted payload +#[derive(Debug)] pub(super) struct Sealed { pub(super) version_id: Uuid, pub(super) payload: Vec, diff --git a/src/server/gitsync/mod.rs b/src/server/gitsync/mod.rs new file mode 100644 index 000000000..a0a606032 --- /dev/null +++ b/src/server/gitsync/mod.rs @@ -0,0 +1,1149 @@ +//! Git-backed sync server for TaskChampion. +//! +//! [`GitSyncServer`] implements the [`Server`] trait using a local git repository as its +//! backing store, with optional push/pull to a remote. +//! +//! - Versions are stored as files named `v-{parent_uuid}-{child_uuid}`, containing +//! encrypted [`HistorySegment`] bytes. +//! - Snapshots are stored as a single file named `snapshot`, containing a JSON wrapper +//! around an encrypted full-state blob. +//! - Metadata (`meta`) holds the latest version UUID and the encryption salt as JSON. +//! +//! After each write (`add_version`, `add_snapshot`) the server stages the changed files, +//! creates a commit, and pushes to the remote. If the push is rejected , thecommit is +//! rolled back and the caller receives an [`AddVersionResult::ExpectedParentVersion`] +//! or an [`Error`] so it can retry. +//! +//! After a snapshot is stored, [`GitSyncServer::cleanup`] automatically removes all +//! version files whose history is now captured by the snapshot, keeping the repository +//! compact. +//! +//! Notes and Expectations +//! +//! - Since this shells out to git, it assumes that you havea reasonably functional git +//! setup. I.e. 'git init', 'git add', 'git commit', etc shoud just work. +//! - If you are using a remote, 'git push' and 'git pull' shoud work. +//! - Due to the nature of the version and snapshot history, you probably shouldn't do +//! a lot of the things you normally would with a git repo, like merge, squash, etc. +//! Just let TaskChampion manage it. +//! - If you are planning on using it for other things, it is HIGHLY recommended that you +//! create a 'task' branch and let TaskChampion manage that branch. +//! - This does support both defining a remote and having `local_only` mode set at the same +//! time. The idea is that maybe the remote isn't ready yet, or eithe rtemporarily or +//! permanantly down. Either way, you can use this in local mode in the mean time. +//! - Remember, a remote can be on the same machine as local. This is used for testing. +//! +//! Notes for Reviewers +//! +//! - I haven't done any performance testing, but it seems reasonably quick for manual use. +//! - Currently is uses the same salt for all files. This isn't great security practice, +//! but does seem to be what the other servers are doing. +use crate::errors::Result; +use crate::server::encryption::{Cryptor, Sealed, Unsealed}; +use crate::server::{ + AddVersionResult, GetVersionResult, HistorySegment, Server, Snapshot, SnapshotUrgency, + VersionId, +}; +use crate::Error; +use async_trait::async_trait; +use glob::glob; +use log::info; +use serde::{Deserialize, Serialize}; +use serde_with::{base64::Base64, serde_as}; +use std::collections::{HashMap, HashSet}; +use std::fs::{self, File}; +use std::io::{BufReader, Read}; +use std::path::{Path, PathBuf}; +use std::process::Command; +use uuid::Uuid; + +/// A version record; used as an in-memory carrier between read/write helpers. +#[derive(Debug)] +struct Version { + version_id: VersionId, + parent_version_id: VersionId, + history_segment: HistorySegment, +} + +/// The snapshot record stored in the `snapshot` file on disk. +/// +/// It is overwritten when a newer snapshot is added +#[serde_as] +#[derive(Serialize, Deserialize, Debug)] +struct SnapshotFile { + #[serde(with = "uuid::serde::simple")] + version_id: VersionId, + #[serde_as(as = "Base64")] + payload: Vec, +} + +/// Repository metadata. +#[serde_as] +#[derive(Serialize, Deserialize, Debug)] +struct Meta { + #[serde(with = "uuid::serde::simple")] + latest_version: VersionId, + #[serde_as(as = "Base64")] + salt: Vec, +} + +/// A [`Server`] backed by a local git repository. +/// +/// +/// When `local_only` is `false` the server pushes to and pulls from `remote` on the `branch` +/// branch after each write. Conflict resolution is handled as: commit locally, +/// attempt push, and on rejection pull-and-reset before returning. +pub(crate) struct GitSyncServer { + meta: Meta, + local_path: PathBuf, + branch: String, + remote: String, + local_only: bool, + cryptor: Cryptor, +} + +/// Load and deserialise a [`Meta`] from the given path. +fn load_meta(path: &Path) -> Result { + let file = File::open(path)?; + Ok(serde_json::from_reader(BufReader::new(file))?) +} + +/// Parse a version filename of the form `v-{parent_simple}-{child_simple}` into +/// `(parent_id, child_id)`. Returns `None` if the filename does not match. +fn parse_version_filename(name: &str) -> Option<(Uuid, Uuid)> { + let stem = name.strip_prefix("v-")?; + let (parent_str, child_str) = stem.split_once('-')?; + let parent_id = Uuid::parse_str(parent_str).ok()?; + let child_id = Uuid::parse_str(child_str).ok()?; + Some((parent_id, child_id)) +} + +/// Run a git command in a given directory, returning an error if it exits non-zero. +fn git_cmd(dir: &Path, args: &[&str]) -> Result<()> { + let status = Command::new("git").args(args).current_dir(dir).status()?; + if !status.success() { + return Err(Error::Server(format!( + "git {} failed with status {}", + args.join(" "), + status + ))); + } + Ok(()) +} + +impl GitSyncServer { + /// Create or re-open a git-backed sync server. + /// + /// If `local_path` does not yet exist it is created. If it exists but is not a git + /// repository: + /// - In `local_only` mode a new repo is initialised there. + /// - In remote mode the repo is cloned from `remote`. + pub(crate) fn new( + local_path: PathBuf, + branch: String, + remote: String, + local_only: bool, + encryption_secret: Vec, + ) -> Result { + let meta = Self::init_repo(&local_path, &branch, &remote, local_only)?; + let cryptor = Cryptor::new(&meta.salt, &encryption_secret.into())?; + let server = GitSyncServer { + meta, + local_path, + branch, + remote, + local_only, + cryptor, + }; + Ok(server) + } + + /// Initialise or open the git repository and return the current [`Meta`]. + /// + /// Creates the directory, initialises or clones the repo, switches to `branch`, and + /// creates the `meta` file on first run. + fn init_repo(local_path: &Path, branch: &str, remote: &str, local_only: bool) -> Result { + // Create the local directory if needed. + fs::create_dir_all(local_path)?; + + // Check if path is already a git repo. + let is_repo = Command::new("git") + .arg("rev-parse") + .current_dir(local_path) + .output()? + .status + .success(); + + // Create one if not. + if !is_repo { + if local_only { + info!("Creating new repo at {:?}", local_path); + git_cmd(local_path, &["init"])?; + } else { + info!("Cloning repo from {:?} to {:?}", remote, local_path); + let parent = local_path + .parent() + .ok_or_else(|| Error::Server("local_path has no parent".into()))?; + let dir_name = local_path + .file_name() + .ok_or_else(|| Error::Server("local_path has no file name".into()))? + .to_str() + .ok_or_else(|| Error::Server("local_path is not valid UTF-8".into()))?; + git_cmd(parent, &["clone", remote, dir_name])?; + } + // Set identity so commits work in environments without a global git config. + git_cmd(local_path, &["config", "user.email", "taskchampion@local"])?; + git_cmd(local_path, &["config", "user.name", "taskchampion"])?; + } + + // Switch to the requested branch. Try checking out an existing branch first, + // only create a new one if that fails. + info!("Switching branch to {:?}", branch); + let checkout_ok = Command::new("git") + .args(["checkout", branch]) + .current_dir(local_path) + .stderr(std::process::Stdio::null()) + .status()? + .success(); + if !checkout_ok { + // For a brand-new repo `git checkout -b` also fails, so use + // `git symbolic-ref` to point HEAD at the desired branch without needing a commit. + let has_commits = Command::new("git") + .args(["rev-parse", "HEAD"]) + .current_dir(local_path) + .stderr(std::process::Stdio::null()) + .status()? + .success(); + if has_commits { + git_cmd(local_path, &["checkout", "-b", branch])?; + } else { + git_cmd( + local_path, + &["symbolic-ref", "HEAD", &format!("refs/heads/{}", branch)], + )?; + } + } + + // Check for meta file, create and commit if missing. + let meta_path = local_path.join("meta"); + let meta = match load_meta(&meta_path) { + Ok(m) => m, + Err(_) => { + let m = Meta { + latest_version: Uuid::nil(), + salt: Cryptor::gen_salt()?, + }; + let f = File::create_new(&meta_path)?; + serde_json::to_writer(f, &m)?; + git_cmd(local_path, &["add", "meta"])?; + git_cmd(local_path, &["commit", "-m", "init taskchampion repo"])?; + m + } + }; + + // Remove any untracked files left behind by interrupted writes. + git_cmd( + local_path, + &["clean", "-f", "--", "v-*", "snapshot", "meta"], + )?; + + Ok(meta) + } + + /// Stage the given paths and create a commit. + fn stage_and_commit(&self, paths: &[&Path], message: &str) -> Result<()> { + for path in paths { + let path_str = path + .to_str() + .ok_or_else(|| Error::Server("path is not valid UTF-8".into()))?; + git_cmd(&self.local_path, &["add", path_str])?; + } + git_cmd(&self.local_path, &["commit", "-m", message])?; + Ok(()) + } + + /// Read the meta file from disk and update self.meta. + fn read_meta(&mut self) -> Result<()> { + self.meta = load_meta(&self.local_path.join("meta"))?; + Ok(()) + } + + /// Serialise self.meta to the meta file and return its path. + fn write_meta(&self) -> Result { + let meta_path = self.local_path.join("meta"); + let f = File::create(&meta_path)?; + serde_json::to_writer(f, &self.meta)?; + Ok(meta_path) + } + + /// Fetch and fast-forward to the remote branch. No-op in local-only mode. + /// If the remote branch does not yet exist (e.g. fresh bare repo), this is also a no-op. + fn pull(&self) -> Result<()> { + if self.local_only { + return Ok(()); + } + // Check whether the remote branch exists before fetching. A bare repo with no commits + // has no refs yet, and `git fetch origin ` would fail in that case. + let has_remote_branch = Command::new("git") + .args([ + "ls-remote", + "--exit-code", + "--heads", + &self.remote, + &self.branch, + ]) + .current_dir(&self.local_path) + .stderr(std::process::Stdio::null()) + .status()? + .success(); + if !has_remote_branch { + return Ok(()); + } + git_cmd(&self.local_path, &["fetch", &self.remote, &self.branch])?; + git_cmd(&self.local_path, &["reset", "--hard", "FETCH_HEAD"])?; + // Remove any untracked files left behind by interrupted writes. + git_cmd( + &self.local_path, + &["clean", "-f", "--", "v-*", "snapshot", "meta"], + )?; + Ok(()) + } + + /// Push to the remote branch. Returns `true` on success, `false` if the push is rejected + /// Always returns `true` in local-only mode. + fn push(&self) -> Result { + if self.local_only { + return Ok(true); + } + let output = Command::new("git") + .args(["push", &self.remote, &self.branch]) + .current_dir(&self.local_path) + .output()?; + if !output.status.success() { + log::debug!( + "git push failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + Ok(output.status.success()) + } + + /// Encrypt and write a version file named `v-{parent_version_id}-{version_id}`. + /// + /// Returns the path of the newly written file. + fn add_version_by_parent_version_id(&self, version: &Version) -> Result { + let unsealed = Unsealed { + version_id: version.version_id, + payload: version.history_segment.clone(), + }; + let sealed = self.cryptor.seal(unsealed)?; + let filename = format!( + "v-{}-{}", + version.parent_version_id.simple(), + version.version_id.simple() + ); + let path = self.local_path.join(&filename); + std::fs::write(&path, Vec::::from(sealed))?; + Ok(path) + } + + /// Find, read, and decrypt the version file whose parent matches `parent_version_id`. + fn get_version_by_parent_version_id( + &self, + parent_version_id: &VersionId, + ) -> Result> { + let path_str = self + .local_path + .to_str() + .ok_or_else(|| Error::Server("local_path is not valid UTF-8".into()))?; + let pattern = format!("{}/v-{}-*", path_str, parent_version_id.simple()); + + for entry in glob(&pattern).map_err(|e| Error::Server(format!("{:?}", e)))? { + let path = match entry { + Ok(p) => p, + Err(e) => { + log::warn!("glob entry error: {e}"); + continue; + } + }; + let filename = match path.file_name().and_then(|n| n.to_str()) { + Some(f) => f, + None => { + log::warn!("non-UTF-8 path, skipping"); + continue; + } + }; + let version_id = match parse_version_filename(filename) { + Some((_, child)) => child, + None => { + log::warn!("unexpected filename format: {filename}"); + continue; + } + }; + // Real errors past this point. + let mut buf = Vec::new(); + File::open(&path)?.read_to_end(&mut buf)?; + let unsealed = self.cryptor.unseal(Sealed { + version_id, + payload: buf, + })?; + return Ok(Some(Version { + version_id: unsealed.version_id, + parent_version_id: *parent_version_id, + history_segment: unsealed.payload, + })); + } + Ok(None) + } + + /// Return the [`SnapshotUrgency`] based on the number of version files present. + /// + /// Since cleanup runs after every successful add_snapshot and removes + /// all version files covered by the snapshot, the count here reflects only post-snapshot + /// versions. + fn snapshot_urgency(&self) -> SnapshotUrgency { + let pattern = format!("{}/v-*", self.local_path.display()); + let count = glob(&pattern).map(|g| g.count()).unwrap_or(0); + // TODO: Performance test get_version_by_parent_version_id to help determine + // what reasonable thresholds are. + match count { + 0..=50 => SnapshotUrgency::None, + 51..=100 => SnapshotUrgency::Low, + _ => SnapshotUrgency::High, + } + } + + /// Cleans up the repository by removing version files covered by the current snapshot. + /// + /// Reads the snapshot to determine which version it covers, then walks backward + /// through the version chain from that version. All version files on that chain + /// are deleted, committed, and pushed. If the push is rejected, we reset to the remote state + /// and will retry on the next snapshot. + /// + /// If called with no snapshot present it is a no-op. + fn cleanup(&self) -> Result<()> { + // Read snapshot metadata. The version_id is stored unencrypted in the JSON wrapper, + // so no decryption is needed here. + let snapshot_path = self.local_path.join("snapshot"); + let snapshot_version: Uuid = if let Ok(file) = File::open(&snapshot_path) { + let s: SnapshotFile = serde_json::from_reader(BufReader::new(file))?; + s.version_id + } else { + return Ok(()); + }; + + // Parse all v-* filenames into a child -> (parent, path) map. + let path_str = self + .local_path + .to_str() + .ok_or_else(|| Error::Server("local_path is not valid UTF-8".into()))?; + let pattern = format!("{}/v-*", path_str); + let mut versions: HashMap = HashMap::new(); + for entry in glob(&pattern).map_err(|e| Error::Server(format!("{e:?}")))? { + let path = match entry { + Ok(p) => p, + Err(e) => { + log::warn!("cleanup: glob error: {e}"); + continue; + } + }; + let Some(filename) = path.file_name().and_then(|f| f.to_str()) else { + continue; + }; + let Some((parent_id, child_id)) = parse_version_filename(filename) else { + continue; + }; + versions.insert(child_id, (parent_id, path)); + } + + if versions.is_empty() { + return Ok(()); + } + + // Walk the chain backward from snapshot_version to find all versions whose + // history is now captured by the snapshot. + let mut covered: HashSet = HashSet::new(); + let mut current = snapshot_version; + for _ in 0..=versions.len() { + if !covered.insert(current) { + log::warn!("cleanup: version cycle detected, aborting"); + return Ok(()); + } + match versions.get(¤t) { + Some((parent, _)) => current = *parent, + None => break, + } + } + + // Remove each covered version file from the git index and working tree. + // If any `git rm` fails partway through we `git reset HEAD` to unstage, leaving + // the working tree dirty but the index clean for subsequent operations. + let mut any_removed = false; + let rm_result: Result<()> = (|| { + for (child_id, (_, path)) in &versions { + if covered.contains(child_id) { + let name = path.file_name().and_then(|n| n.to_str()).ok_or_else(|| { + Error::Server("version file path is not valid UTF-8".into()) + })?; + git_cmd(&self.local_path, &["rm", name])?; + any_removed = true; + } + } + Ok(()) + })(); + if let Err(e) = rm_result { + let _ = git_cmd(&self.local_path, &["reset", "HEAD"]); + return Err(e); + } + + if !any_removed { + return Ok(()); + } + + git_cmd( + &self.local_path, + &[ + "commit", + "-m", + "cleanup: remove version files covered by snapshot", + ], + )?; + + // A rejected push means another replica will clean up later, or we will + // on the next snapshot. Reset to remote state so we are in sync. + if !self.push()? { + log::info!("cleanup: push rejected, resetting to remote state"); + self.pull()?; + } + + Ok(()) + } +} + +#[async_trait(?Send)] +impl Server for GitSyncServer { + async fn add_version( + &mut self, + parent_version_id: VersionId, + history_segment: HistorySegment, + ) -> Result<(AddVersionResult, SnapshotUrgency)> { + // Accept any parent when the repo is empty (latest == NIL). + // Otherwise check if parent is in latest. If it isn't, pull recheck. + if self.meta.latest_version != Uuid::nil() && parent_version_id != self.meta.latest_version + { + self.pull()?; + self.read_meta()?; + if parent_version_id != self.meta.latest_version { + return Ok(( + AddVersionResult::ExpectedParentVersion(self.meta.latest_version), + SnapshotUrgency::None, + )); + } + } + + // Create the new version and write it to file. + let version_id = Uuid::new_v4(); + let version = Version { + version_id, + parent_version_id, + history_segment, + }; + let version_path = self.add_version_by_parent_version_id(&version)?; + self.meta.latest_version = version_id; + let meta_path = self.write_meta()?; + + // Commit and push, reverting if push fails. + self.stage_and_commit(&[&version_path, &meta_path], "add version")?; + + if !self.push()? { + // Push was rejected. Undo the commit; pull will reset --hard and git clean + // away the stray version file. + git_cmd(&self.local_path, &["reset", "HEAD~1", "--soft"])?; + self.pull()?; + self.read_meta()?; + return Ok(( + AddVersionResult::ExpectedParentVersion(self.meta.latest_version), + SnapshotUrgency::None, + )); + } + + Ok((AddVersionResult::Ok(version_id), self.snapshot_urgency())) + } + + async fn get_child_version( + &mut self, + parent_version_id: VersionId, + ) -> Result { + if let Some(v) = self.get_version_by_parent_version_id(&parent_version_id)? { + return Ok(GetVersionResult::Version { + version_id: v.version_id, + parent_version_id: v.parent_version_id, + history_segment: v.history_segment, + }); + } + self.pull()?; + match self.get_version_by_parent_version_id(&parent_version_id)? { + Some(v) => Ok(GetVersionResult::Version { + version_id: v.version_id, + parent_version_id: v.parent_version_id, + history_segment: v.history_segment, + }), + None => Ok(GetVersionResult::NoSuchVersion), + } + } + + async fn add_snapshot(&mut self, version_id: VersionId, snapshot: Snapshot) -> Result<()> { + self.pull()?; + // Write the snapshot to a file. + // Note: If another replica has pushed a snapshot for a + // later version in the chain between our pull and our push, we will overwrite it. + // This should be harmless: a replica with newer state will overwrite again, + // and push rejection handles concurrent writes. + let unsealed = Unsealed { + version_id, + payload: snapshot, + }; + let sealed = self.cryptor.seal(unsealed)?; + let snapshot_file = SnapshotFile { + version_id, + payload: Vec::::from(sealed), + }; + let snapshot_path = self.local_path.join("snapshot"); + let f = File::create(&snapshot_path)?; + serde_json::to_writer(f, &snapshot_file)?; + + // Commit and push, reverting if push fails. + self.stage_and_commit(&[&snapshot_path], "add snapshot")?; + + if !self.push()? { + // Push was rejected. Undo the commit, pull and clean to restore state. + git_cmd(&self.local_path, &["reset", "HEAD~1", "--soft"])?; + self.pull()?; + self.read_meta()?; + return Err(Error::Server("Couldn't push to remote.".into())); + } + + // Cleanup is best-effort: the snapshot is already safely stored on the remote. + // A cleanup failure just means version files will linger until the next successful snapshot. + if let Err(e) = self.cleanup() { + log::warn!("snapshot stored but cleanup failed: {e}"); + } + Ok(()) + } + + async fn get_snapshot(&mut self) -> Result> { + self.pull()?; + + let snapshot_path = self.local_path.join("snapshot"); + if let Ok(file) = File::open(&snapshot_path) { + let s: SnapshotFile = serde_json::from_reader(BufReader::new(file))?; + let unsealed = self.cryptor.unseal(Sealed { + version_id: s.version_id, + payload: s.payload, + })?; + Ok(Some((unsealed.version_id, unsealed.payload))) + } else { + Ok(None) + } + } +} + +#[cfg(test)] +mod test { + use crate::server::NIL_VERSION_ID; + + use super::*; + use tempfile::TempDir; + + fn make_server(dir: &Path) -> Result { + GitSyncServer::new( + dir.to_path_buf(), + "main".into(), + "".into(), + true, + b"test-secret".to_vec(), + ) + } + + /// Create a bare repo to act as a remote, then clone it into `clone_dir`. + /// Returns a GitSyncServer backed by the clone, with the bare repo as its remote. + fn make_server_with_remote(bare_dir: &Path, clone_dir: &Path) -> Result { + // Initialise the bare remote. + git_cmd( + bare_dir.parent().unwrap(), + &[ + "init", + "--bare", + bare_dir.file_name().unwrap().to_str().unwrap(), + ], + )?; + let bare_url = bare_dir.to_str().unwrap(); + GitSyncServer::new( + clone_dir.to_path_buf(), + "main".into(), + bare_url.into(), + false, + b"test-secret".to_vec(), + ) + } + + #[test] + fn test_init_creates_repo_and_meta() -> Result<()> { + let tmp = TempDir::new()?; + let server = make_server(tmp.path())?; + assert!(tmp.path().join("meta").exists()); + assert_eq!(server.meta.latest_version, Uuid::nil()); + Ok(()) + } + + #[test] + fn test_init_idempotent() -> Result<()> { + let tmp = TempDir::new()?; + let s1 = make_server(tmp.path())?; + // second call should succeed and load the same meta (same salt) + let s2 = make_server(tmp.path())?; + assert_eq!(s1.meta.salt, s2.meta.salt); + Ok(()) + } + + #[test] + fn test_read_write_meta_roundtrip() -> Result<()> { + let tmp = TempDir::new()?; + let mut server = make_server(tmp.path())?; + + let new_id = Uuid::new_v4(); + server.meta.latest_version = new_id; + server.write_meta()?; + + server.meta.latest_version = Uuid::nil(); // clobber in-memory value + server.read_meta()?; + assert_eq!(server.meta.latest_version, new_id); + Ok(()) + } + + #[test] + fn test_push_and_pull() -> Result<()> { + let tmp = TempDir::new()?; + let bare = tmp.path().join("bare"); + let clone1 = tmp.path().join("clone1"); + let clone2 = tmp.path().join("clone2"); + + // Set up first clone (initialises remote with the meta commit). + let server1 = make_server_with_remote(&bare, &clone1)?; + server1.push()?; + + // Clone a second copy directly via git. + let bare_url = bare.to_str().unwrap(); + git_cmd(tmp.path(), &["clone", bare_url, "clone2"])?; + git_cmd(&clone2, &["config", "user.email", "taskchampion@local"])?; + git_cmd(&clone2, &["config", "user.name", "taskchampion"])?; + + // Write a new file in clone1 and push it. + let new_file = clone1.join("testfile"); + std::fs::write(&new_file, b"hello")?; + server1.stage_and_commit(&[&new_file], "add testfile")?; + assert!(server1.push()?); + + // Build a server for clone2 and pull + // It should see the new file. + let bare_url_str: String = bare_url.into(); + let server2 = GitSyncServer::new( + clone2.clone(), + "main".into(), + bare_url_str, + false, + b"test-secret".to_vec(), + )?; + server2.pull()?; + assert!(clone2.join("testfile").exists()); + Ok(()) + } + + #[tokio::test] + async fn test_add_zero_base() -> Result<()> { + let tmp = TempDir::new()?; + let mut server = make_server(tmp.path())?; + let history = b"1234".to_vec(); + match server.add_version(NIL_VERSION_ID, history.clone()).await?.0 { + AddVersionResult::ExpectedParentVersion(_) => { + panic!("should have accepted the version") + } + AddVersionResult::Ok(version_id) => { + // Verify the version file exists on disk. + let filename = format!("v-{}-{}", NIL_VERSION_ID.simple(), version_id.simple()); + assert!( + tmp.path().join(&filename).exists(), + "version file missing: {filename}" + ); + // Verify meta was updated. + assert_eq!(server.meta.latest_version, version_id); + } + } + Ok(()) + } + + #[tokio::test] + async fn test_add_nonzero_base_forbidden() -> Result<()> { + let tmp = TempDir::new()?; + let mut server = make_server(tmp.path())?; + let history = b"1234".to_vec(); + let parent_version_id = Uuid::new_v4(); + + // Add a first version. + if let (AddVersionResult::ExpectedParentVersion(_), _) = server + .add_version(parent_version_id, history.clone()) + .await? + { + panic!("should have accepted the first version"); + } + + // Try to add another with the same (now stale) parent, should be rejected. + match server.add_version(parent_version_id, history).await?.0 { + AddVersionResult::Ok(_) => panic!("should not have accepted the version"), + AddVersionResult::ExpectedParentVersion(expected) => { + assert_eq!(expected, server.meta.latest_version); + } + } + Ok(()) + } + + #[tokio::test] + async fn test_add_version_conflict_with_remote() -> Result<()> { + let tmp = TempDir::new()?; + let bare = tmp.path().join("bare"); + let clone1 = tmp.path().join("clone1"); + let clone2 = tmp.path().join("clone2"); + + let mut server1 = make_server_with_remote(&bare, &clone1)?; + server1.push()?; + + let bare_url = bare.to_str().unwrap(); + git_cmd(tmp.path(), &["clone", bare_url, "clone2"])?; + git_cmd(&clone2, &["config", "user.email", "taskchampion@local"])?; + git_cmd(&clone2, &["config", "user.name", "taskchampion"])?; + let mut server2 = GitSyncServer::new( + clone2, + "main".into(), + bare_url.into(), + false, + b"test-secret".to_vec(), + )?; + + // server1 adds a version from NIL parent and pushes successfully. + let (result1, _) = server1.add_version(NIL_VERSION_ID, b"v1".to_vec()).await?; + let v1_id = match result1 { + AddVersionResult::Ok(id) => id, + AddVersionResult::ExpectedParentVersion(_) => panic!("server1 add failed"), + }; + + // server2 also tries to add from NIL, should fail with ExpectedParentVersion pointing at v1. + let (result2, _) = server2.add_version(NIL_VERSION_ID, b"v2".to_vec()).await?; + match result2 { + AddVersionResult::Ok(_) => panic!("server2 should have been rejected"), + AddVersionResult::ExpectedParentVersion(expected) => { + assert_eq!(expected, v1_id); + } + } + Ok(()) + } + + #[tokio::test] + async fn test_get_child_version_local() -> Result<()> { + let tmp = TempDir::new()?; + let mut server = make_server(tmp.path())?; + let parent_version_id = Uuid::new_v4(); + + // With no versions written, lookup returns NoSuchVersion. + assert_eq!( + server.get_child_version(parent_version_id).await?, + GetVersionResult::NoSuchVersion + ); + + // After add_version, lookup returns the Version with matching ids and payload. + let history = b"1234".to_vec(); + let AddVersionResult::Ok(version_id) = server + .add_version(parent_version_id, history.clone()) + .await? + .0 + else { + panic!("add_version failed"); + }; + match server.get_child_version(parent_version_id).await? { + GetVersionResult::Version { + version_id: v_id, + parent_version_id: p_id, + history_segment: h_seg, + } => { + assert_eq!(v_id, version_id); + assert_eq!(p_id, parent_version_id); + assert_eq!(h_seg, history); + } + GetVersionResult::NoSuchVersion => panic!("Failed to read version"), + } + + Ok(()) + } + + #[tokio::test] + async fn test_get_child_version_from_remote() -> Result<()> { + let tmp = TempDir::new()?; + let bare = tmp.path().join("bare"); + let clone1 = tmp.path().join("clone1"); + let clone2 = tmp.path().join("clone2"); + + let mut server1 = make_server_with_remote(&bare, &clone1)?; + let bare_url = bare.to_str().unwrap(); + + // server1 adds a version and pushes it. + let (result, _) = server1 + .add_version(NIL_VERSION_ID, b"history".to_vec()) + .await?; + let AddVersionResult::Ok(version_id) = result else { + panic!("server1 add_version failed"); + }; + + let mut server2 = GitSyncServer::new( + clone2, + "main".into(), + bare_url.into(), + false, + b"test-secret".to_vec(), + )?; + + // get_child_version should pull and find the version. + match server2.get_child_version(NIL_VERSION_ID).await? { + GetVersionResult::Version { + version_id: v_id, + history_segment, + .. + } => { + assert_eq!(v_id, version_id); + assert_eq!(history_segment, b"history".to_vec()); + } + GetVersionResult::NoSuchVersion => panic!("should have found the version after pull"), + } + + Ok(()) + } + + #[tokio::test] + async fn test_snapshot_empty() -> Result<()> { + let tmp = TempDir::new()?; + let mut server = make_server(tmp.path())?; + assert_eq!(server.get_snapshot().await?, None); + Ok(()) + } + + #[tokio::test] + async fn test_snapshot_roundtrip() -> Result<()> { + let tmp = TempDir::new()?; + let mut server = make_server(tmp.path())?; + + let version_id = Uuid::new_v4(); + let data = b"my snapshot data".to_vec(); + server.add_snapshot(version_id, data.clone()).await?; + + let result = server.get_snapshot().await?; + assert!(result.is_some(), "expected a snapshot"); + let (got_id, got_data) = result.unwrap(); + assert_eq!(got_id, version_id); + assert_eq!(got_data, data); + Ok(()) + } + + #[tokio::test] + async fn test_snapshot_from_remote() -> Result<()> { + let tmp = TempDir::new()?; + let bare = tmp.path().join("bare"); + let clone1 = tmp.path().join("clone1"); + let clone2 = tmp.path().join("clone2"); + + let mut server1 = make_server_with_remote(&bare, &clone1)?; + let bare_url = bare.to_str().unwrap(); + // server1 stores a snapshot and pushes it. + let version_id = Uuid::new_v4(); + let data = b"snapshot payload".to_vec(); + server1.add_snapshot(version_id, data.clone()).await?; + // server2 should pull and find it. + let mut server2 = GitSyncServer::new( + clone2.clone(), + "main".into(), + bare_url.into(), + false, + b"test-secret".to_vec(), + )?; + let result = server2.get_snapshot().await?; + assert!(result.is_some(), "expected snapshot from remote"); + let (got_id, got_data) = result.unwrap(); + assert_eq!(got_id, version_id); + assert_eq!(got_data, data); + Ok(()) + } + + /// A second `add_snapshot` should overwrite the first: only the latest snapshot is + /// returned by `get_snapshot`. + #[tokio::test] + async fn test_snapshot_overwrite() -> Result<()> { + let tmp = TempDir::new()?; + let mut server = make_server(tmp.path())?; + + let v1 = Uuid::new_v4(); + server.add_snapshot(v1, b"first snapshot".to_vec()).await?; + + let v2 = Uuid::new_v4(); + server.add_snapshot(v2, b"second snapshot".to_vec()).await?; + + let result = server.get_snapshot().await?; + assert!(result.is_some()); + let (got_id, got_data) = result.unwrap(); + assert_eq!(got_id, v2, "expected the second snapshot's version_id"); + assert_eq!(got_data, b"second snapshot".to_vec()); + Ok(()) + } + + /// `add_snapshot` push-failure rollback: install a pre-receive hook on the bare repo that + /// rejects all pushes (while still allowing fetches), forcing a push rejection. + /// After the Err, the working tree must be clean and the snapshot file must be absent. + #[tokio::test] + async fn test_snapshot_push_rejected_rollback() -> Result<()> { + let tmp = TempDir::new()?; + let bare = tmp.path().join("bare"); + let clone1 = tmp.path().join("clone1"); + + let mut server = make_server_with_remote(&bare, &clone1)?; + + // Add a version so the remote branch exists (required for pull's ls-remote check). + server.add_version(NIL_VERSION_ID, b"v1".to_vec()).await?; + + // Install a pre-receive hook that rejects all pushes. + // Fetches still work because hooks only run on the receive side. + let hooks_dir = bare.join("hooks"); + fs::create_dir_all(&hooks_dir)?; + let hook = hooks_dir.join("pre-receive"); + std::fs::write(&hook, b"#!/bin/sh\nexit 1\n")?; + // Make the hook executable. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755))?; + } + + // add_snapshot should pull successfully then fail to push (hook rejects it). + let v1 = Uuid::new_v4(); + let result = server.add_snapshot(v1, b"snap".to_vec()).await; + assert!( + result.is_err(), + "expected Err when push is rejected by hook" + ); + + // The snapshot file must not remain in the working tree. + assert!( + !server.local_path.join("snapshot").exists(), + "snapshot file should be removed after rollback" + ); + + // The git index must be clean (no staged changes left over). + let status = Command::new("git") + .args(["status", "--porcelain"]) + .current_dir(&server.local_path) + .output()?; + assert!( + status.stdout.is_empty(), + "git index should be clean after rollback, got: {}", + String::from_utf8_lossy(&status.stdout) + ); + + Ok(()) + } + + /// After `add_snapshot`, all version files covered by the snapshot should be deleted + /// (cleanup is called automatically by `add_snapshot`). + #[tokio::test] + async fn test_cleanup_removes_version_files() -> Result<()> { + let tmp = TempDir::new()?; + let mut server = make_server(tmp.path())?; + + // Add a chain of three versions. + let (r1, _) = server.add_version(NIL_VERSION_ID, b"v1".to_vec()).await?; + let AddVersionResult::Ok(v1) = r1 else { + panic!("add_version 1 failed"); + }; + let (r2, _) = server.add_version(v1, b"v2".to_vec()).await?; + let AddVersionResult::Ok(v2) = r2 else { + panic!("add_version 2 failed"); + }; + let (r3, _) = server.add_version(v2, b"v3".to_vec()).await?; + let AddVersionResult::Ok(v3) = r3 else { + panic!("add_version 3 failed"); + }; + // Confirm they exist. + assert!(tmp + .path() + .join(format!("v-{}-{}", NIL_VERSION_ID.simple(), v1.simple())) + .exists()); + assert!(tmp + .path() + .join(format!("v-{}-{}", v1.simple(), v2.simple())) + .exists()); + assert!(tmp + .path() + .join(format!("v-{}-{}", v2.simple(), v3.simple())) + .exists()); + + // Snapshot at v3; cleanup should remove all three version files. + server.add_snapshot(v3, b"full state".to_vec()).await?; + + assert!( + !tmp.path() + .join(format!("v-{}-{}", NIL_VERSION_ID.simple(), v1.simple())) + .exists(), + "v1 file should be gone" + ); + assert!( + !tmp.path() + .join(format!("v-{}-{}", v1.simple(), v2.simple())) + .exists(), + "v2 file should be gone" + ); + assert!( + !tmp.path() + .join(format!("v-{}-{}", v2.simple(), v3.simple())) + .exists(), + "v3 file should be gone" + ); + // The snapshot file itself must still exist. + assert!( + tmp.path().join("snapshot").exists(), + "snapshot file should remain" + ); + Ok(()) + } + + /// A corrupted version file should return `Err`. + #[tokio::test] + async fn test_get_child_version_corrupted_file() -> Result<()> { + let tmp = TempDir::new()?; + let mut server = make_server(tmp.path())?; + + // Add a real version so we know the parent UUID. + let parent = Uuid::new_v4(); + let (result, _) = server.add_version(parent, b"good data".to_vec()).await?; + let AddVersionResult::Ok(child) = result else { + panic!("add_version failed"); + }; + + // Overwrite the version file with garbage to simulate corruption. + let filename = format!("v-{}-{}", parent.simple(), child.simple()); + std::fs::write(tmp.path().join(&filename), b"this is not valid ciphertext")?; + + // get_child_version should return Err (decryption failure), not NoSuchVersion. + let result = server.get_child_version(parent).await; + assert!( + result.is_err(), + "expected Err on decryption failure, got: {:?}", + result + ); + + Ok(()) + } +} diff --git a/src/server/mod.rs b/src/server/mod.rs index 33bcc41aa..3c5c93eca 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -31,6 +31,9 @@ mod sync; #[cfg(feature = "cloud")] mod cloud; +#[cfg(feature = "git-sync")] +mod gitsync; + pub use config::*; pub use types::*;