From 6b86cfd589eb48d6e56feb624eb938feae787b8e Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Tue, 1 Sep 2026 05:24:12 -0400 Subject: [PATCH 1/2] style: bring the workspace up to the strict clippy gate The strict gate landed in 5ccc405 without the code that satisfies it, so every branch and every fork inherits a red CI that says nothing about the change under review. Signed-off-by: Yordis Prieto --- Cargo.toml | 5 + crates/walgit-bundle/src/lib.rs | 54 +- crates/walgit-bundle/src/ops.rs | 126 ++--- crates/walgit-bundle/src/render.rs | 44 +- crates/walgit-bundle/src/schedule.rs | 12 +- crates/walgit-bundle/src/slots.rs | 62 +-- crates/walgit-bundle/tests/bundle.rs | 72 +-- crates/walgit-cli/src/bundle_cmd.rs | 33 +- crates/walgit-cli/src/compact.rs | 5 +- crates/walgit-cli/src/config_cmd.rs | 2 +- crates/walgit-cli/src/import.rs | 14 +- crates/walgit-cli/src/import_direct.rs | 187 ++++--- crates/walgit-cli/src/lib.rs | 8 +- crates/walgit-cli/src/mirror.rs | 30 +- crates/walgit-cli/src/repo.rs | 11 +- crates/walgit-cli/src/serve.rs | 26 +- crates/walgit-cli/src/synth.rs | 72 ++- crates/walgit-cli/src/wal_cmd.rs | 42 +- crates/walgit-config/src/lib.rs | 162 +++--- crates/walgit-git/src/follow.rs | 18 +- crates/walgit-git/src/lib.rs | 497 ++++++++++--------- crates/walgit-git/src/pkt.rs | 47 +- crates/walgit-git/src/receive.rs | 41 +- crates/walgit-git/src/repair.rs | 7 +- crates/walgit-git/src/upload_gix.rs | 152 +++--- crates/walgit-git/tests/commit_graph.rs | 11 + crates/walgit-git/tests/common/mod.rs | 31 +- crates/walgit-git/tests/connectivity.rs | 11 + crates/walgit-git/tests/ingest.rs | 51 +- crates/walgit-git/tests/ls_refs.rs | 11 + crates/walgit-git/tests/refs.rs | 13 +- crates/walgit-git/tests/refs500k.rs | 22 +- crates/walgit-git/tests/rev_index.rs | 11 + crates/walgit-git/tests/upload_gix_remote.rs | 11 + crates/walgit-git/tests/upload_gix_scale.rs | 39 +- crates/walgit-git/tests/upload_pack.rs | 23 +- crates/walgit-proto/src/lib.rs | 34 +- crates/walgit-server/build.rs | 9 +- crates/walgit-server/src/admin.rs | 45 +- crates/walgit-server/src/auth.rs | 40 +- crates/walgit-server/src/bridge.rs | 50 +- crates/walgit-server/src/bundles.rs | 75 ++- crates/walgit-server/src/cache.rs | 63 +-- crates/walgit-server/src/error.rs | 47 +- crates/walgit-server/src/events.rs | 14 +- crates/walgit-server/src/follow.rs | 123 +++-- crates/walgit-server/src/forward.rs | 35 +- crates/walgit-server/src/instance.rs | 50 +- crates/walgit-server/src/lfs.rs | 72 +-- crates/walgit-server/src/lfs_upstream.rs | 20 +- crates/walgit-server/src/lib.rs | 157 +++--- crates/walgit-server/src/maintain.rs | 83 ++-- crates/walgit-server/src/metrics.rs | 2 +- crates/walgit-server/src/middleware.rs | 7 +- crates/walgit-server/src/ops.rs | 20 +- crates/walgit-server/src/pktline.rs | 10 +- crates/walgit-server/src/policy.rs | 93 ++-- crates/walgit-server/src/prewarm.rs | 31 +- crates/walgit-server/src/rebuild.rs | 24 +- crates/walgit-server/src/settings.rs | 105 ++-- crates/walgit-server/src/smart.rs | 355 +++++++------ crates/walgit-server/src/sse.rs | 49 +- crates/walgit-server/src/static_object.rs | 114 ++--- crates/walgit-server/src/stream.rs | 10 +- crates/walgit-server/src/telemetry.rs | 71 +-- crates/walgit-server/src/tls.rs | 23 +- crates/walgit-server/src/web/api.rs | 379 +++++++------- crates/walgit-server/src/web/login.rs | 68 +-- crates/walgit-server/src/web/mod.rs | 47 +- crates/walgit-server/src/web/objects.rs | 144 +++--- crates/walgit-server/src/web/trailers.rs | 9 +- crates/walgit-server/src/web/ui.rs | 104 ++-- crates/walgit-server/src/web/v1.rs | 28 +- crates/walgit-server/tests/api_v1.rs | 11 + crates/walgit-server/tests/drain.rs | 13 +- crates/walgit-server/tests/e2e.rs | 28 +- crates/walgit-server/tests/events.rs | 13 +- crates/walgit-server/tests/follow.rs | 16 +- crates/walgit-server/tests/harness.rs | 44 +- crates/walgit-server/tests/lfs_upstream.rs | 15 +- crates/walgit-server/tests/maintain.rs | 49 +- crates/walgit-server/tests/routing_prefix.rs | 38 +- crates/walgit-server/tests/sim.rs | 136 ++--- crates/walgit-server/tests/static_http.rs | 17 +- crates/walgit-server/tests/web_api.rs | 24 +- crates/walgit-server/tests/web_ui.rs | 15 + crates/walgit-store/src/coord.rs | 30 +- crates/walgit-store/src/fault.rs | 76 +-- crates/walgit-store/src/gcs.rs | 229 ++++----- crates/walgit-store/src/lib.rs | 45 +- crates/walgit-store/src/memory.rs | 16 +- crates/walgit-store/src/s3.rs | 115 +++-- crates/walgit-store/src/util.rs | 47 +- crates/walgit-store/tests/contract.rs | 81 +-- crates/walgit-wal/src/checkpoint.rs | 66 ++- crates/walgit-wal/src/handle.rs | 129 ++--- crates/walgit-wal/src/lockwait.rs | 2 +- crates/walgit-wal/src/log_reader.rs | 13 +- crates/walgit-wal/src/progress.rs | 5 +- crates/walgit-wal/src/publish.rs | 483 +++++++++--------- crates/walgit-wal/src/registry.rs | 48 +- crates/walgit-wal/src/remote.rs | 131 ++--- crates/walgit-wal/src/state.rs | 17 +- crates/walgit-wal/src/sync.rs | 158 +++--- crates/walgit-wal/src/tasks.rs | 16 +- crates/walgit-wal/tests/wal.rs | 70 ++- 106 files changed, 3685 insertions(+), 3084 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 97c9340..f3a2d38 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -60,6 +60,11 @@ must_use_candidate = "allow" similar_names = "allow" too_many_lines = "allow" +# Every site is an integer widened to f64 for a ratio, a gauge, or a human-readable +# size. There is no lossless spelling to migrate to, so the lint only ever asks for +# an #[allow] at the cast. +cast_precision_loss = "allow" + [workspace.dependencies] walgit-proto = { path = "crates/walgit-proto" } walgit-store = { path = "crates/walgit-store" } diff --git a/crates/walgit-bundle/src/lib.rs b/crates/walgit-bundle/src/lib.rs index 6ff13a4..8818d77 100644 --- a/crates/walgit-bundle/src/lib.rs +++ b/crates/walgit-bundle/src/lib.rs @@ -6,9 +6,9 @@ //! The [`Bundler`] is the public entry point. It depends on a [`BundleSource`] //! trait that provides repo-scoped access (local git repo + [`Prefixed`] store //! + `head_seq`). When `walgit_wal::Registry` lands it will implement -//! `BundleSource` (impl lives in this crate) and the `new` signature will -//! accept `Arc` directly. Until then, [`Bundler::new_with_source`] -//! accepts any `BundleSource` impl (used by tests). +//! `BundleSource` (impl lives in this crate) and the `new` signature will +//! accept `Arc` directly. Until then, [`Bundler::new_with_source`] +//! accepts any `BundleSource` impl (used by tests). //! //! The core operations in [`ops`] take a [`walgit_git::LocalRepo`] + [`Prefixed`] //! store so they are unit-testable with upstream `git` + [`MemoryStore`] without @@ -171,8 +171,8 @@ impl Bundler { Arc::new(Self { source, cfg, - gates: Default::default(), - lease_ttl: Duration::from_secs(30 * 60), + gates: parking_lot::Mutex::default(), + lease_ttl: Duration::from_mins(30), }) } @@ -190,7 +190,6 @@ impl Bundler { } fn find_strategy<'a>( - &self, cfg: &'a Config, name: &str, ) -> Result<&'a walgit_config::BundleStrategy, BundleError> { @@ -235,7 +234,7 @@ impl Bundler { cut: &ops::Cut, ) -> Result { let cfg = self.cfg_for(handle); - let strat = self.find_strategy(cfg, strategy_name)?; + let strat = Self::find_strategy(cfg, strategy_name)?; let store = &handle.store; let refs = slots::default_refs(&cfg.bundles, strat); @@ -295,8 +294,7 @@ impl Bundler { .iter() .filter(|t| { walgit_git::gix_hash::ObjectId::from_hex(t.oid.as_bytes()) - .map(|o| handle.local.has_object(&o)) - .unwrap_or(false) + .is_ok_and(|o| handle.local.has_object(&o)) }) .map(|t| t.oid.clone()) .collect(); @@ -469,7 +467,7 @@ impl Bundler { // them as `too-small` (a later measurement or a build replaces it). let gates = self.gates.lock(); if !gates.is_empty() { - for r in rows.iter_mut() { + for r in &mut rows { if r.status == slots::SlotStatus::Missing && let Some(c) = gates.get(&( handle.local.path().display().to_string(), @@ -584,8 +582,7 @@ impl Bundler { .iter() .filter(|t| { walgit_git::gix_hash::ObjectId::from_hex(t.oid.as_bytes()) - .map(|o| handle.local.has_object(&o)) - .unwrap_or(false) + .is_ok_and(|o| handle.local.has_object(&o)) }) .map(|t| t.oid.clone()) .collect(); @@ -639,12 +636,11 @@ impl Bundler { ) -> Result, BundleError> { let mut handle = self.source.open_repo(id).await?; let cfg = self.cfg_for(&handle).clone(); - let strat = self.find_strategy(&cfg, strategy)?.clone(); + let strat = Self::find_strategy(&cfg, strategy)?.clone(); let strat = &strat; let store = handle.store.clone(); - let lease = match ops::try_acquire_lease(&store, &strat.name, self.lease_ttl).await? { - Some(l) => l, - None => return Ok(None), + let Some(lease) = ops::try_acquire_lease(&store, &strat.name, self.lease_ttl).await? else { + return Ok(None); }; let res: Result, BundleError> = async { let fresh = ops::read_list(&store).await?.unwrap_or_default(); @@ -697,7 +693,7 @@ impl Bundler { } Ok(None) } - Err(BundleError::NoNewObjects) | Err(BundleError::NoRefs) => Ok(None), + Err(BundleError::NoNewObjects | BundleError::NoRefs) => Ok(None), Err(e) => Err(e), } } @@ -736,12 +732,10 @@ impl Bundler { if missing.is_empty() { continue; } - let lease = match ops::try_acquire_lease(store, &strat.name, self.lease_ttl).await? { - Some(l) => l, - None => { - debug!(strategy = %strat.name, "lease held, skipping"); - continue; - } + let Some(lease) = ops::try_acquire_lease(store, &strat.name, self.lease_ttl).await? + else { + debug!(strategy = %strat.name, "lease held, skipping"); + continue; }; let res: Result<(), BundleError> = async { if !prepared { @@ -853,14 +847,10 @@ pub async fn bundle_engine(handle: &walgit_wal::RepoHandle) -> BundleEngine { } } } - let linked = handle - .local() - .packs() - .map(|ps| { - ps.iter() - .any(|p| handle.local().pack_path(&p.checksum).is_symlink()) - }) - .unwrap_or(false); + let linked = handle.local().packs().is_ok_and(|ps| { + ps.iter() + .any(|p| handle.local().pack_path(&p.checksum).is_symlink()) + }); if linked { return BundleEngine::Gix { faulter: None }; } @@ -869,7 +859,7 @@ pub async fn bundle_engine(handle: &walgit_wal::RepoHandle) -> BundleEngine { #[cfg(feature = "wal")] mod wal_impl { - use super::*; + use super::{BundleEngine, BundleError, BundleRepoHandle, BundleSource, RepoId, bundle_engine}; use walgit_wal::{Registry, WalError}; fn wal_err(e: WalError) -> BundleError { diff --git a/crates/walgit-bundle/src/ops.rs b/crates/walgit-bundle/src/ops.rs index 5d818ac..873e86e 100644 --- a/crates/walgit-bundle/src/ops.rs +++ b/crates/walgit-bundle/src/ops.rs @@ -70,7 +70,7 @@ pub(crate) fn filter_refs(snap: &RefSnapshotData, patterns: &[String]) -> (Vec = if patterns.is_empty() { vec!["refs/heads/*", "refs/tags/*", "HEAD"] } else { - patterns.iter().map(|s| s.as_str()).collect() + patterns.iter().map(String::as_str).collect() }; let mut ref_names = Vec::new(); @@ -88,7 +88,7 @@ pub(crate) fn filter_refs(snap: &RefSnapshotData, patterns: &[String]) -> (Vec = po_args.iter().map(|s| s.as_str()).collect(); + let po_args: Vec<&str> = po_args.iter().map(String::as_str).collect(); let mut child = git(&po_args) .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) @@ -244,7 +244,10 @@ pub async fn create_bundle( .map_err(|e| BundleError::Io(e.to_string()))?; { use tokio::io::AsyncWriteExt; - let mut stdin = child.stdin.take().expect("stdin"); + let mut stdin = child + .stdin + .take() + .ok_or_else(|| BundleError::Io("git pack-objects stdin".into()))?; stdin .write_all(revs.as_bytes()) .await @@ -262,7 +265,10 @@ pub async fn create_bundle( .await .map_err(|e| BundleError::Io(e.to_string()))?; } - let mut stdout = child.stdout.take().expect("stdout"); + let mut stdout = child + .stdout + .take() + .ok_or_else(|| BundleError::Io("git pack-objects stdout".into()))?; let mut first = [0u8; 12]; tokio::io::AsyncReadExt::read_exact(&mut stdout, &mut first) .await @@ -326,7 +332,7 @@ pub fn bundle_checksum_file(path: &std::path::Path) -> std::io::Result { if n == 0 { break; } - hasher.update(&buf[..n]); + hasher.update(buf.get(..n).unwrap_or_default()); } Ok(hex::encode(hasher.finalize())) } @@ -454,7 +460,6 @@ where Ok(meta) => return Ok(Some((meta.version, new_list))), Err(StoreError::PreconditionFailed { .. }) => { debug!(attempt, "cas retry: list created by another writer"); - continue; } Err(e) => return Err(e.into()), } @@ -477,7 +482,6 @@ where Ok(new_meta) => return Ok(Some((new_meta.version, new_list))), Err(StoreError::PreconditionFailed { .. }) => { debug!(attempt, "cas retry: list changed by another writer"); - continue; } Err(e) => return Err(e.into()), } @@ -531,13 +535,14 @@ impl LeaseGuard { .delete(&self.key, Some(self.version.clone())) .await { - Ok(()) => Ok(()), - Err(StoreError::PreconditionFailed { .. }) | Err(StoreError::NotFound { .. }) => Ok(()), + Ok(()) | Err(StoreError::PreconditionFailed { .. } | StoreError::NotFound { .. }) => { + Ok(()) + } Err(e) => Err(e.into()), } } - /// CAS-extend the lease's expires_at (heartbeat). + /// CAS-extend the lease's `expires_at` (heartbeat). pub async fn heartbeat(&mut self, ttl: Duration) -> Result<(), BundleError> { let now = SystemTime::now(); let expires = now + ttl; @@ -615,8 +620,7 @@ pub async fn try_acquire_lease( let expired = existing .expires_at .as_ref() - .map(|t| time::to_system(t) <= now) - .unwrap_or(true); + .is_none_or(|t| time::to_system(t) <= now); if !expired { return Ok(None); } @@ -698,7 +702,7 @@ pub async fn hold_lease( /// What a bundle is cut for: a calendar slot with the ref state as of that /// slot (`snapshot`, WAL `seq`), or "now" (legacy: token = max(prev+1, now)). pub struct Cut { - /// Slot epoch seconds = creation_token (0 = no slot: token from `now`). + /// Slot epoch seconds = `creation_token` (0 = no slot: token from `now`). pub slot: u64, /// Ref state to cut from (None = the local copy's current refs). pub snapshot: Option, @@ -724,7 +728,7 @@ pub async fn build_and_upload( // 1. Resolve refs (tips): the slot's ref state, or the local copy's. let snap = match &cut.snapshot { Some(s) => s.clone(), - None => local.refs().map_err(|e| BundleError::Git(e))?, + None => local.refs().map_err(BundleError::Git)?, }; let (ref_names, tips) = filter_refs(&snap, ref_patterns); // A tip whose object this copy cannot resolve (a ref published ahead of a @@ -920,7 +924,7 @@ pub fn unchanged_since<'a>( (a == b).then_some(prev) } -/// Max creation_token across all entries in `list` (0 if empty). +/// Max `creation_token` across all entries in `list` (0 if empty). pub fn max_creation_token(list: &BundleList) -> u64 { list.bundles .iter() @@ -934,7 +938,7 @@ pub async fn delete_pruned(store: &Prefixed, keys_to_delete: &[String]) { let span = tracing::info_span!("bundle.retention", pruned = keys_to_delete.len()); delete_pruned_inner(store, keys_to_delete) .instrument(span) - .await + .await; } async fn delete_pruned_inner(store: &Prefixed, keys_to_delete: &[String]) { @@ -956,46 +960,6 @@ pub fn pruned_diff(old: &BundleList, new: &BundleList) -> Vec { .collect() } -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn rfc3339_compact_format() { - let t = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000); - assert_eq!(rfc3339_compact(t), "20231114T221320Z"); - } - - #[test] - fn checksum_deterministic() { - let data = b"hello world"; - let c1 = bundle_checksum(data); - let c2 = bundle_checksum(data); - assert_eq!(c1, c2); - assert_eq!(c1.len(), 40); - } - - #[test] - fn bundle_key_format() { - let t = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000); - assert_eq!( - bundle_key("weekly", t, "abc123"), - "bundles/weekly/20231114T221320Z-abc123.bundle" - ); - } - - #[test] - fn pattern_matching() { - assert!(matches_pattern("refs/heads/main", "refs/heads/*")); - assert!(matches_pattern("refs/heads/feature/x", "refs/heads/*")); - assert!(!matches_pattern("refs/tags/v1", "refs/heads/*")); - assert!(matches_pattern("HEAD", "HEAD")); - assert!(!matches_pattern("refs/heads/main", "HEAD")); - assert!(matches_pattern("refs/heads/main", "refs/heads/main")); - assert!(!matches_pattern("refs/heads/dev", "refs/heads/main")); - } -} - // --------------------------------------------------------------------------- // Full bundle = header ∘ base pack via server-side compose (no disk, no // index-pack, no bytes through the builder). Used by `walgit import --direct` @@ -1048,6 +1012,10 @@ pub fn full_bundle_header( (h, tips) } +#[allow( + clippy::too_many_arguments, + reason = "one parameter per input the compose needs; a wrapper struct would only be built and destructured at the call sites" +)] /// Publish `bundles//-.bundle` = header ∘ `wal/.pack` /// by compose (falls back to streaming header + `pack_path` when the store /// cannot compose; then `pack_path` must be a local file) and return the entry @@ -1212,3 +1180,43 @@ pub(crate) async fn count_commits( .parse() .unwrap_or(0)) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rfc3339_compact_format() { + let t = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000); + assert_eq!(rfc3339_compact(t), "20231114T221320Z"); + } + + #[test] + fn checksum_deterministic() { + let data = b"hello world"; + let c1 = bundle_checksum(data); + let c2 = bundle_checksum(data); + assert_eq!(c1, c2); + assert_eq!(c1.len(), 40); + } + + #[test] + fn bundle_key_format() { + let t = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000); + assert_eq!( + bundle_key("weekly", t, "abc123"), + "bundles/weekly/20231114T221320Z-abc123.bundle" + ); + } + + #[test] + fn pattern_matching() { + assert!(matches_pattern("refs/heads/main", "refs/heads/*")); + assert!(matches_pattern("refs/heads/feature/x", "refs/heads/*")); + assert!(!matches_pattern("refs/tags/v1", "refs/heads/*")); + assert!(matches_pattern("HEAD", "HEAD")); + assert!(!matches_pattern("refs/heads/main", "HEAD")); + assert!(matches_pattern("refs/heads/main", "refs/heads/main")); + assert!(!matches_pattern("refs/heads/dev", "refs/heads/main")); + } +} diff --git a/crates/walgit-bundle/src/render.rs b/crates/walgit-bundle/src/render.rs index fb170ac..017470b 100644 --- a/crates/walgit-bundle/src/render.rs +++ b/crates/walgit-bundle/src/render.rs @@ -1,9 +1,10 @@ //! Render the bundle list in git's bundle-list config format and protocol v2 //! key=value lines. //! -//! See: https://git-scm.com/docs/bundle-uri and -//! https://git-scm.com/docs/gitprotocol-v2 (bundle-uri command). +//! See: and +//! (bundle-uri command). +use std::fmt::Write as _; use std::time::Duration; use walgit_config::{BundleServe, BundlesConfig}; @@ -21,7 +22,7 @@ fn filename_of(key: &str) -> &str { /// Build the URI for a single bundle entry. /// /// * **Proxy**: `{base_url}/{owner}/{repo}/bundles/{strategy}/{filename}` -/// * **SignedUrl**: `store.signed_get_url(key, ttl)`, falling back to Proxy +/// * **`SignedUrl`**: `store.signed_get_url(key, ttl)`, falling back to Proxy /// if the store doesn't support signed URLs. pub async fn bundle_uri( entry: &BundleEntry, @@ -53,7 +54,10 @@ static SIGNING_WARNED: std::sync::LazyLock St ) } +#[allow( + clippy::too_many_arguments, + reason = "one parameter per input the render needs; a wrapper struct would only be built and destructured at the call sites" +)] /// Render the bundle list as git config text (bundle-list format). /// /// ```ini @@ -131,12 +139,12 @@ pub async fn render_list_text( cfg.signed_url_ttl, ) .await?; - out.push_str("\n"); - out.push_str(&format!("[bundle \"{}\"]\n", entry.id)); - out.push_str(&format!(" uri = {uri}\n")); - out.push_str(&format!(" creationToken = {}\n", entry.creation_token)); + out.push('\n'); + let _ = writeln!(out, "[bundle \"{}\"]", entry.id); + let _ = writeln!(out, " uri = {uri}"); + let _ = writeln!(out, " creationToken = {}", entry.creation_token); if !entry.filter.is_empty() { - out.push_str(&format!(" filter = {}\n", entry.filter)); + let _ = writeln!(out, " filter = {}", entry.filter); } } @@ -205,6 +213,15 @@ pub async fn protocol_v2_lines( Ok(lines) } +/// `serve_via` for one repository (`bundles.signed_url_for` overrides). +fn serve_via_for(cfg: &walgit_config::BundlesConfig, owner: &str, repo: &str) -> BundleServe { + if walgit_config::repo_listed(&cfg.signed_url_for, owner, repo) { + BundleServe::SignedUrl + } else { + cfg.serve_via + } +} + #[cfg(test)] mod tests { use super::*; @@ -303,12 +320,3 @@ mod tests { assert_eq!(filename_of("abc.bundle"), "abc.bundle"); } } - -/// `serve_via` for one repository (`bundles.signed_url_for` overrides). -fn serve_via_for(cfg: &walgit_config::BundlesConfig, owner: &str, repo: &str) -> BundleServe { - if walgit_config::repo_listed(&cfg.signed_url_for, owner, repo) { - BundleServe::SignedUrl - } else { - cfg.serve_via - } -} diff --git a/crates/walgit-bundle/src/schedule.rs b/crates/walgit-bundle/src/schedule.rs index 5c1abdc..16eb671 100644 --- a/crates/walgit-bundle/src/schedule.rs +++ b/crates/walgit-bundle/src/schedule.rs @@ -28,7 +28,7 @@ fn to_chrono(t: SystemTime) -> DateTime { /// Convert a chrono UTC datetime back to [`SystemTime`]. fn to_system(dt: DateTime) -> SystemTime { - UNIX_EPOCH + Duration::from_secs(dt.timestamp().max(0) as u64) + UNIX_EPOCH + Duration::from_secs(u64::try_from(dt.timestamp().max(0)).unwrap_or(0)) } /// Next fire time of `schedule` strictly after `after`, or `None` if the @@ -57,11 +57,9 @@ pub fn is_due(schedule: &Schedule, last_built: Option, now: SystemTi } } -/// Current Unix timestamp in seconds (for creation_token computation). +/// Current Unix timestamp in seconds (for `creation_token` computation). pub fn unix_now(now: SystemTime) -> u64 { - now.duration_since(UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0) + now.duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs()) } #[cfg(test)] @@ -108,7 +106,7 @@ mod tests { let s = parse_schedule("@hourly").unwrap(); let now = SystemTime::now(); // Last built 2 hours ago → next fire was 1 hour ago → due. - let last = now - Duration::from_secs(2 * 3600); + let last = now - Duration::from_hours(2); assert!(is_due(&s, Some(last), now)); } @@ -129,6 +127,6 @@ mod tests { // Next fire should be after t. assert!(next > t); // And within 1 hour (hourly schedule). - assert!(next <= t + Duration::from_secs(3600)); + assert!(next <= t + Duration::from_hours(1)); } } diff --git a/crates/walgit-bundle/src/slots.rs b/crates/walgit-bundle/src/slots.rs index 3afb629..f5cd302 100644 --- a/crates/walgit-bundle/src/slots.rs +++ b/crates/walgit-bundle/src/slots.rs @@ -64,9 +64,7 @@ pub struct SlotPlan { } pub fn epoch(t: SystemTime) -> u64 { - t.duration_since(UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0) + t.duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs()) } pub fn from_epoch(s: u64) -> SystemTime { UNIX_EPOCH + Duration::from_secs(s) @@ -106,7 +104,7 @@ pub fn slot_closed(_strategy: &BundleStrategy, slot: u64, now: SystemTime) -> bo } /// Clock-skew margin before a slot's verdict is treated as final. -pub const SLOT_CLOSE_GRACE: Duration = Duration::from_secs(120); +pub const SLOT_CLOSE_GRACE: Duration = Duration::from_mins(2); /// The newest slot of `strategy` at or before `t` (its most recent fire ≤ t). pub fn last_slot_at_or_before( @@ -146,8 +144,7 @@ pub fn base_for_slot<'a>( ) -> Option<&'a BundleEntry> { entries_of(list, strategy) .into_iter() - .filter(|b| b.creation_token <= slot) - .last() + .rfind(|b| b.creation_token <= slot) } /// The base bundle of an incremental at `slot`, **up the chain**: the newest @@ -184,7 +181,7 @@ pub fn base_for_slot_chain<'a>( /// * `chain = true`: this strategy's own newest bundle before the slot, **if it is newer than /// that base** (dailies chain from the weekly onwards; hourlies restart from every new daily /// instead of chaining across it); else the base. -/// `slot = 0` (a manual cut, "now"): the same with the newest bundles overall. +/// `slot = 0` (a manual cut, "now"): the same with the newest bundles overall. pub fn base_for_incremental<'a>( cfg: &BundlesConfig, list: &'a BundleList, @@ -205,8 +202,7 @@ pub fn base_for_incremental<'a>( } let own = entries_of(list, &strat.name) .into_iter() - .filter(|b| b.creation_token < at) - .last(); + .rfind(|b| b.creation_token < at); // `>=`: at a tie (Sunday's daily and the weekly fire at the same instant, so their tips are the // same objects) the chain continues through its own link. A fresh clone has the weekly's objects // and therefore that link's prerequisites; a stale client walks daily → daily straight across @@ -249,7 +245,7 @@ fn chain_up<'a>(cfg: &'a BundlesConfig, base: &'a str) -> Vec<&'a str> { /// What the planner knows about the repository and this host. #[derive(Debug, Clone, Copy, Default)] pub struct PlanContext { - /// Earliest WAL state (created_at of the first entry / checkpoint); slots + /// Earliest WAL state (`created_at` of the first entry / checkpoint); slots /// before it are `Unavailable`. None = unknown → never unavailable. pub first_state: Option, /// Whether this host can cut a **full** bundle for the repo (a compose of @@ -291,7 +287,7 @@ pub fn plan_with( let mut rows = Vec::new(); for strat in &cfg.strategy { let built = entries_of(list, &strat.name); - let (anchor_excl, _): (SystemTime, ()) = match strat.kind { + let (anchor_excl, ()): (SystemTime, ()) = match strat.kind { BundleKind::Full => match built.last() { // Newest built full: everything after it is a candidate. Some(b) => (from_epoch(b.creation_token), ()), @@ -322,8 +318,7 @@ pub fn plan_with( .iter() .rev() .nth(1) - .map(|prev| prev.creation_token) - .unwrap_or(b.creation_token); + .map_or(b.creation_token, |prev| prev.creation_token); (from_epoch(oldest_relevant), ()) } _ => { @@ -373,11 +368,7 @@ pub fn plan_with( // earliest state (for a large repository, the import) — that is what "weekly = // import state" means; later slots are as-of by construction. let first_full = strat.kind == BundleKind::Full && built.is_empty(); - let unavailable = !first_full - && ctx - .first_state - .map(|t| from_epoch(slot) < t) - .unwrap_or(false); + let unavailable = !first_full && ctx.first_state.is_some_and(|t| from_epoch(slot) < t); let skipped = list.skipped.iter().find(|k| { k.strategy == strat.name && k.slot == slot @@ -402,7 +393,6 @@ pub fn plan_with( .unwrap_or("full bundles need the base pack locally (ssd host)") .into(), ), - BundleKind::Full => SlotStatus::Missing, BundleKind::Incremental if base_id.is_none() => { SlotStatus::Blocked("no base bundle at or before this slot".into()) } @@ -411,7 +401,7 @@ pub fn plan_with( .unwrap_or("the serving copy does not fit this host") .into(), ), - BundleKind::Incremental => SlotStatus::Missing, + BundleKind::Full | BundleKind::Incremental => SlotStatus::Missing, }, }; rows.push(SlotPlan { @@ -451,9 +441,7 @@ pub fn chain_window(cfg: &BundlesConfig, strat: &BundleStrategy) -> usize { let Some(b2) = crate::schedule::next_fire_after(&bs, b1) else { return usize::MAX; }; - slots_between(strat, b1, b2) - .map(|v| v.len()) - .unwrap_or(usize::MAX) + slots_between(strat, b1, b2).map_or(usize::MAX, |v| v.len()) } /// How many bundles of an incremental strategy stay listed: the newest, and the one @@ -522,18 +510,14 @@ pub fn retain(cfg: &BundlesConfig, list: &mut BundleList) -> Vec { let in_group: Vec<&BundleEntry> = v.iter().copied().filter(|b| group_of(b) == g).collect(); if strat.chain { - let base_newest = strat - .base - .as_deref() - .map(|n| { - entries_of(list, n) - .into_iter() - .filter(|b| keep.contains(&b.id) && group_of(b) == g) - .map(|b| b.creation_token) - .max() - .unwrap_or(0) - }) - .unwrap_or(0); + let base_newest = strat.base.as_deref().map_or(0, |n| { + entries_of(list, n) + .into_iter() + .filter(|b| keep.contains(&b.id) && group_of(b) == g) + .map(|b| b.creation_token) + .max() + .unwrap_or(0) + }); // Oldest first so a link's base (the previous link) is decided before it. The first // link of a group may point at a pruned link of the previous group (Monday on Sunday's // daily): its prerequisites are the group's full's tips, so it stays while the full does. @@ -597,7 +581,7 @@ mod tests { /// The D21 shape (every incremental on its base): what the two-newest tests below pin. fn cfg() -> BundlesConfig { let mut c = BundlesConfig::default(); - for s in c.strategy.iter_mut() { + for s in &mut c.strategy { s.chain = false; } c @@ -614,7 +598,7 @@ mod tests { } fn t(s: &str) -> SystemTime { let dt = chrono::DateTime::parse_from_rfc3339(s).unwrap(); - from_epoch(dt.timestamp() as u64) + from_epoch(u64::try_from(dt.timestamp()).unwrap_or(0)) } fn entry(strategy: &str, slot: u64, base_id: &str) -> BundleEntry { BundleEntry { @@ -951,8 +935,8 @@ mod tests { .push(entry("hourly", h2, &format!("hourly-{h1}"))); let pruned = retain(&c, &mut list); let mut kept: Vec<&str> = list.bundles.iter().map(|b| b.id.as_str()).collect(); - kept.sort(); - let mut want = vec![ + kept.sort_unstable(); + let mut want = [ format!("weekly-{w1}"), format!("daily-{}", ds[0]), format!("daily-{}", ds[1]), diff --git a/crates/walgit-bundle/tests/bundle.rs b/crates/walgit-bundle/tests/bundle.rs index 7a3efbf..4b446a4 100644 --- a/crates/walgit-bundle/tests/bundle.rs +++ b/crates/walgit-bundle/tests/bundle.rs @@ -9,6 +9,17 @@ //! - Pruning keeps the chain valid //! - `--bundle-uri` clone works from a file:// bundle list +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing, + clippy::many_single_char_names +)] +// clippy.toml exempts #[test] functions from the panic-path lints, but not the plain +// helper functions beside them in the same file. A panic in a fixture builder is how +// that fixture reports it could not be built, exactly as in the tests it serves. + use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -96,7 +107,7 @@ impl TestRepo { } } -/// Test BundleSource: holds one or more repos. +/// Test `BundleSource`: holds one or more repos. struct TestSource { repos: HashMap)>, } @@ -127,7 +138,7 @@ impl BundleSource for TestSource { local: local.clone(), store: store.clone(), head_seq: head_seq.load(Ordering::Relaxed), - engine: Default::default(), + engine: walgit_bundle::BundleEngine::default(), cfg: None, }) } @@ -144,21 +155,19 @@ async fn run_git(cwd: &Path, args: &[&str]) -> String { .current_dir(cwd) .output() .await - .unwrap_or_else(|e| panic!("git {:?}: {e}", args)); - if !output.status.success() { - panic!( - "git {:?} failed: {}", - args, - String::from_utf8_lossy(&output.stderr) - ); - } + .unwrap_or_else(|e| panic!("git {args:?}: {e}")); + assert!( + output.status.success(), + "git {:?} failed: {}", + args, + String::from_utf8_lossy(&output.stderr) + ); String::from_utf8_lossy(&output.stdout).to_string() } /// Config with a single full strategy "weekly". fn cfg_full_only(keep: usize) -> Config { - let mut cfg = Config::default(); - cfg.bundles = BundlesConfig { + let bundles = BundlesConfig { enabled: true, strategy: vec![BundleStrategy { name: "weekly".into(), @@ -173,9 +182,9 @@ fn cfg_full_only(keep: usize) -> Config { chain: false, }], min_commits: 0, - min_bytes: Default::default(), + min_bytes: walgit_config::ByteSize::default(), serve_via: BundleServe::Proxy, - signed_url_ttl: Duration::from_secs(3600), + signed_url_ttl: Duration::from_hours(1), advertise: true, advertise_filtered: false, require: Vec::new(), @@ -183,13 +192,15 @@ fn cfg_full_only(keep: usize) -> Config { main_only: false, extra_refs: Vec::new(), }; - cfg + Config { + bundles, + ..Default::default() + } } /// Config with weekly (full) + daily (incremental based on weekly). fn cfg_weekly_daily(keep_full: usize, keep_inc: usize) -> Config { - let mut cfg = Config::default(); - cfg.bundles = BundlesConfig { + let bundles = BundlesConfig { enabled: true, strategy: vec![ BundleStrategy { @@ -218,9 +229,9 @@ fn cfg_weekly_daily(keep_full: usize, keep_inc: usize) -> Config { }, ], min_commits: 0, - min_bytes: Default::default(), + min_bytes: walgit_config::ByteSize::default(), serve_via: BundleServe::Proxy, - signed_url_ttl: Duration::from_secs(3600), + signed_url_ttl: Duration::from_hours(1), advertise: true, advertise_filtered: false, require: Vec::new(), @@ -228,7 +239,10 @@ fn cfg_weekly_daily(keep_full: usize, keep_inc: usize) -> Config { main_only: false, extra_refs: Vec::new(), }; - cfg + Config { + bundles, + ..Default::default() + } } /// Download a bundle from the store to a tempdir at the path matching a @@ -264,7 +278,7 @@ async fn get_refs(repo_path: &Path) -> Vec { .await .unwrap(); let s = String::from_utf8_lossy(&output.stdout); - let mut refs: Vec = s.lines().map(|l| l.to_string()).collect(); + let mut refs: Vec = s.lines().map(ToString::to_string).collect(); refs.sort(); refs } @@ -327,7 +341,7 @@ async fn full_bundle_passes_verify() { assert!(!entry.tips.is_empty(), "bundle entry should have tips"); assert!(entry.tips.iter().any(|t| t.name == "refs/heads/main")); assert!(entry.tips.iter().any(|t| t.name == "refs/tags/v1.0")); - assert!(entry.kind == "full"); + assert_eq!(entry.kind, "full"); assert!(entry.base_id.is_empty()); } @@ -393,11 +407,13 @@ async fn incremental_has_prerequisites() { let base_tips: Vec<&str> = base_entry.tips.iter().map(|t| t.oid.as_str()).collect(); for prereq_line in header_lines.iter().filter(|l| l.starts_with('-')) { // Format: "- " - let oid = prereq_line[1..].split_whitespace().next().unwrap_or(""); + let oid = prereq_line + .strip_prefix('-') + .and_then(|l| l.split_whitespace().next()) + .unwrap_or(""); assert!( base_tips.contains(&oid), - "prerequisite {oid} should be in base tips {:?}", - base_tips + "prerequisite {oid} should be in base tips {base_tips:?}" ); } } @@ -555,7 +571,7 @@ async fn run_due_respects_schedule_and_lease() { let future2 = walgit_bundle::schedule::next_fire_after(&schedule, future).unwrap() + Duration::from_secs(1); - ops::hold_lease(&tr.store, "weekly", "test-holder", Duration::from_secs(60)) + ops::hold_lease(&tr.store, "weekly", "test-holder", Duration::from_mins(1)) .await .unwrap(); let built5 = bundler.run_due(&id, future2).await.unwrap(); @@ -589,7 +605,7 @@ async fn pruning_keeps_chain_valid() { tr.push().await; tr.advance_seq(); } - let future = now + Duration::from_secs((i as u64) * 8 * 24 * 3600); + let future = now + Duration::from_secs((u64::try_from(i).unwrap_or(0)) * 8 * 24 * 3600); bundler.run_due(&id, future).await.unwrap(); } @@ -855,7 +871,7 @@ async fn min_commits_gate_skips_small_incrementals() { tr.advance_seq(); match bundler.build(&id, "daily").await { Err(walgit_bundle::BundleError::TooSmall { commits, min }) => { - assert_eq!((commits, min), (2, 3)) + assert_eq!((commits, min), (2, 3)); } other => panic!("expected TooSmall, got {:?}", other.map(|e| e.id)), } diff --git a/crates/walgit-cli/src/bundle_cmd.rs b/crates/walgit-cli/src/bundle_cmd.rs index 442a760..6998cfa 100644 --- a/crates/walgit-cli/src/bundle_cmd.rs +++ b/crates/walgit-cli/src/bundle_cmd.rs @@ -66,14 +66,16 @@ pub async fn run(action: BundleAction, cfg: &Arc) -> Result<()> { { let m = handle.manifest(); let fmt = |t: Option| { - t.map(|t| humantime::format_rfc3339_seconds(t).to_string()) - .unwrap_or_else(|| "-".into()) + t.map_or_else( + || "-".into(), + |t| humantime::format_rfc3339_seconds(t).to_string(), + ) }; let cp = m.checkpoint.as_ref(); println!( "first state {} (checkpoint seq {} created {} first_state_at {} as_of {}; head seq {})", fmt(handle.first_state_time()), - cp.map(|c| c.seq).unwrap_or(0), + cp.map_or(0, |c| c.seq), fmt(cp .and_then(|c| c.created_at.as_ref()) .map(walgit_proto::time::to_system)), @@ -87,8 +89,8 @@ pub async fn run(action: BundleAction, cfg: &Arc) -> Result<()> { ); } println!( - "{:<8} {:<12} {:<20} {}", - "strategy", "kind", "slot (UTC)", "status" + "{:<8} {:<12} {:<20} status", + "strategy", "kind", "slot (UTC)" ); for r in &rows { let when = if r.slot == 0 { @@ -150,14 +152,14 @@ pub async fn run(action: BundleAction, cfg: &Arc) -> Result<()> { u.strategy, when, u.unit, - u.host - .as_deref() - .map(|h| if u.unit.contains(h) { + u.host.as_deref().map_or_else( + || " [no live maintainer]".into(), + |h| if u.unit.contains(h) { String::new() } else { format!(" [{h}]") - }) - .unwrap_or_else(|| " [no live maintainer]".into()) + } + ) ); } } @@ -178,8 +180,7 @@ pub async fn run(action: BundleAction, cfg: &Arc) -> Result<()> { let last = h.last_pass_at.as_ref().map(walgit_proto::time::to_system); let age = last .and_then(|t| std::time::SystemTime::now().duration_since(t).ok()) - .map(|d| d.as_secs()) - .unwrap_or(u64::MAX); + .map_or(u64::MAX, |d| d.as_secs()); println!( " {} ({}, {} cap) — last pass {}s ago ({}), {} passes, last unit: {}", h.host, @@ -285,10 +286,10 @@ pub async fn maintainers( let mut keys = store.list(walgit_proto::keys::MAINTAIN_DIR, None); while let Some(m) = keys.next().await { let m = m?; - if let Some((_, bytes)) = store.get_bytes(&m.key).await? { - if let Ok(hb) = walgit_proto::v1::MaintainerHeartbeat::decode(bytes.as_ref()) { - out.push(hb); - } + if let Some((_, bytes)) = store.get_bytes(&m.key).await? + && let Ok(hb) = walgit_proto::v1::MaintainerHeartbeat::decode(bytes.as_ref()) + { + out.push(hb); } } Ok(out) diff --git a/crates/walgit-cli/src/compact.rs b/crates/walgit-cli/src/compact.rs index 3046e89..29be5fb 100644 --- a/crates/walgit-cli/src/compact.rs +++ b/crates/walgit-cli/src/compact.rs @@ -2,6 +2,7 @@ //! Shares the decision/lease/repack/publish logic with the serve loop and the //! web UI (`walgit_server::ops::compact_repo`). +use std::fmt::Write as _; use std::sync::Arc; use anyhow::{Result, bail}; @@ -51,7 +52,7 @@ pub async fn run( if once { break; } - tokio::time::sleep(std::time::Duration::from_secs(60)).await; + tokio::time::sleep(std::time::Duration::from_mins(1)).await; } Ok(()) } @@ -82,7 +83,7 @@ async fn compact_one( // The weekly bundle is composed from this base with the refs at its // seq: write the checkpoint now so `walgit bundle compose` finds them. let cp = handle.write_checkpoint().await?; - summary.push_str(&format!("; checkpoint at seq {}", cp.seq)); + let _ = write!(summary, "; checkpoint at seq {}", cp.seq); } Ok(summary) } diff --git a/crates/walgit-cli/src/config_cmd.rs b/crates/walgit-cli/src/config_cmd.rs index 7b74602..df2701d 100644 --- a/crates/walgit-cli/src/config_cmd.rs +++ b/crates/walgit-cli/src/config_cmd.rs @@ -7,7 +7,7 @@ use anyhow::Result; use crate::ConfigAction; use walgit_config::Config; -pub async fn run(action: ConfigAction, cfg: &Arc) -> Result<()> { +pub fn run(action: ConfigAction, cfg: &Arc) -> Result<()> { match action { ConfigAction::Check { env_files, strict } => { let mut cfg: Config = (**cfg).clone(); diff --git a/crates/walgit-cli/src/import.rs b/crates/walgit-cli/src/import.rs index 7d5de94..571ccb8 100644 --- a/crates/walgit-cli/src/import.rs +++ b/crates/walgit-cli/src/import.rs @@ -58,11 +58,9 @@ pub fn glob_match(pattern: &str, s: &str) -> bool { } pos = part.len(); } else if i == parts.len() - 1 { - return s.len() >= pos && s[pos..].ends_with(part); - } else if part.is_empty() { - continue; - } else { - match s[pos..].find(part) { + return s.get(pos..).is_some_and(|tail| tail.ends_with(part)); + } else if !part.is_empty() { + match s.get(pos..).and_then(|tail| tail.find(part)) { Some(at) => pos += at + part.len(), None => return false, } @@ -223,7 +221,7 @@ pub async fn run( .await .context("waiting for git pack-objects")?; if !status.success() { - bail!("git pack-objects failed (exit {})", status); + bail!("git pack-objects failed (exit {status})"); } let pack_elapsed_ms = pack_started.elapsed().as_secs_f64() * 1_000.0; @@ -294,7 +292,7 @@ pub async fn run( pack = %new_pack.checksum, pack_size = new_pack.pack_size, has_bitmap = new_pack.has_bitmap, - elapsed_ms = repack_started.elapsed().as_millis() as u64, + elapsed_ms = u64::try_from(repack_started.elapsed().as_millis()).unwrap_or(u64::MAX), "base pack published" ); println!( @@ -446,7 +444,7 @@ async fn import_reusing_packs( f.seek(SeekFrom::Start(8 + 255 * 4))?; let mut b = [0u8; 4]; f.read_exact(&mut b)?; - u32::from_be_bytes(b) as u64 + u64::from(u32::from_be_bytes(b)) }; // Copy (not move) into a staging dir with the FINAL file names, then let // install_pack rename them into objects/pack (it keeps file names). diff --git a/crates/walgit-cli/src/import_direct.rs b/crates/walgit-cli/src/import_direct.rs index 8ffc83e..ce73709 100644 --- a/crates/walgit-cli/src/import_direct.rs +++ b/crates/walgit-cli/src/import_direct.rs @@ -11,6 +11,7 @@ //! re-uploading the pack*: bundle = header object ∘ pack object via compose //! (GCS), so a fresh `git clone` gets its bytes straight from the bucket/CDN. +use std::fmt::Write as _; use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::Arc; @@ -32,6 +33,10 @@ use walgit_store::{ use crate::cli::parse_repo_id; +#[allow( + clippy::struct_excessive_bools, + reason = "one field per CLI flag; the flags are independent" +)] pub struct DirectOptions { pub from: PathBuf, pub repo: String, @@ -83,7 +88,7 @@ struct LocalPack { /// Start over even when the target's manifest moved since an interrupted import began, or /// re-publish a completed import (a new seq superseding the previous one). impl DirectOptions { - fn marker_path(&self, pack_dir: &Path, id: &walgit_git::RepoId) -> PathBuf { + fn marker_path(pack_dir: &Path, id: &walgit_git::RepoId) -> PathBuf { pack_dir .parent() .unwrap_or(pack_dir) @@ -92,6 +97,10 @@ impl DirectOptions { } } +#[allow( + clippy::struct_excessive_bools, + reason = "a report of independent yes/no outcomes; grouping them would only hide what each one means" +)] /// What a run did — the resumability contract in numbers (`tests/import_resume.rs`). #[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct ImportReport { @@ -153,14 +162,14 @@ pub struct ImportMarker { } fn entry_to_hex(e: &BundleEntry) -> String { - e.encode_to_vec() - .iter() - .map(|b| format!("{b:02x}")) - .collect() + e.encode_to_vec().iter().fold(String::new(), |mut acc, b| { + let _ = write!(acc, "{b:02x}"); + acc + }) } fn entry_from_hex(s: &str) -> Option { let bytes: Option> = (0..s.len() / 2) - .map(|i| u8::from_str_radix(&s[2 * i..2 * i + 2], 16).ok()) + .map(|i| u8::from_str_radix(s.get(2 * i..2 * i + 2)?, 16).ok()) .collect(); BundleEntry::decode(bytes?.as_slice()).ok() } @@ -187,7 +196,9 @@ fn read_import_marker(path: &Path) -> Option { } fn write_import_marker(path: &Path, m: &ImportMarker) -> Result<()> { - std::fs::create_dir_all(path.parent().unwrap())?; + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir)?; + } let tmp = path.with_extension("json.tmp"); std::fs::write(&tmp, serde_json::to_vec_pretty(m)?)?; std::fs::rename(&tmp, path)?; @@ -388,7 +399,7 @@ pub async fn run_with_store( m.head_seq, m.packs.len() ); - let marker_path = opts.marker_path(&pack_dir, &id); + let marker_path = DirectOptions::marker_path(&pack_dir, &id); let _ = std::fs::remove_file(&marker_path); report.noop = true; report.seq = m.head_seq; @@ -402,17 +413,18 @@ pub async fn run_with_store( ); } } - let marker_path = opts.marker_path(&pack_dir, &id); + let marker_path = DirectOptions::marker_path(&pack_dir, &id); let current_version = base_version.as_ref().map(|v| v.as_str().to_string()); - let mut marker = match decide_resume( - read_import_marker(&marker_path).as_ref(), + let existing = read_import_marker(&marker_path); + let decision = decide_resume( + existing.as_ref(), &repo_key, &tips, current_version.as_deref(), force, - ) { - ResumeDecision::Resume => { - let m = read_import_marker(&marker_path).unwrap(); + ); + let mut marker = match (decision, existing) { + (ResumeDecision::Resume, Some(m)) => { println!( "resuming import started at manifest {:?} (phase {:?} done, {} object(s) uploaded)", m.base_manifest_version, @@ -422,11 +434,11 @@ pub async fn run_with_store( report.resumed = true; m } - ResumeDecision::Refuse { started_at, now } => bail!( + (ResumeDecision::Refuse { started_at, now }, _) => bail!( "an interrupted import of {id} started when the manifest was {started_at:?}; it is {now:?} now (someone pushed or imported). \ Re-run with --force to start over from the current state (the partial uploads are reused where their checksums match)" ), - ResumeDecision::Fresh => { + (ResumeDecision::Fresh | ResumeDecision::Resume, _) => { if marker_path.exists() { println!( "discarding an interrupted import of a different intent or base (marker {})", @@ -437,8 +449,8 @@ pub async fn run_with_store( repo: repo_key.clone(), tips_hash: tips.clone(), base_manifest_version: current_version.clone(), - base_head_seq: base_manifest.as_ref().map(|m| m.head_seq).unwrap_or(0), - seq: base_manifest.as_ref().map(|m| m.head_seq).unwrap_or(0) + 1, + base_head_seq: base_manifest.as_ref().map_or(0, |m| m.head_seq), + seq: base_manifest.as_ref().map_or(0, |m| m.head_seq) + 1, phase: ImportPhase::Started, uploaded: Vec::new(), history_pack: None, @@ -463,7 +475,7 @@ pub async fn run_with_store( these refs (`git pack-objects --revs` with them) or narrow `--refs`", missing.len(), pack_dir.display(), - missing[0] + missing.first().map_or("", String::as_str) ); println!( "verified: {} ref tip(s){} present in the pack set ({:.1}s)", @@ -485,9 +497,12 @@ pub async fn run_with_store( // ---- side-files: one commit-graph layer next to the base (file presence = done) ---------- if marker.phase < ImportPhase::SideFiles { - if opts.commit_graph && packs[0].commit_graph.is_none() { + if opts.commit_graph + && let Some(base) = packs.first_mut() + && base.commit_graph.is_none() + { let t = Instant::now(); - let side = packs[0].pack.with_extension("commit-graph"); + let side = base.pack.with_extension("commit-graph"); build_commit_graph_layer(&git_dir, &side)?; println!( "commit-graph: {} bytes in {:.1}s -> {}", @@ -495,7 +510,7 @@ pub async fn run_with_store( t.elapsed().as_secs_f64(), side.display() ); - packs[0].commit_graph = Some(side); + base.commit_graph = Some(side); report.built_commit_graph = true; } marker.phase = ImportPhase::SideFiles; @@ -504,6 +519,10 @@ pub async fn run_with_store( } // ---- history pack (D18), reused from the marker / the walgit-history dir ------------- + let base_checksum = packs + .first() + .map(|p| p.checksum.clone()) + .unwrap_or_default(); if opts.history_pack && !packs.iter().any(|p| p.history_of.is_some()) { let dir = pack_dir .parent() @@ -518,39 +537,36 @@ pub async fn run_with_store( // A history pack of this base left by an earlier run whose marker is gone. scan_packs(&dir).ok().and_then(|v| { v.into_iter() - .find(|p| p.history_of.as_deref() == Some(packs[0].checksum.as_str())) + .find(|p| p.history_of.as_deref() == Some(base_checksum.as_str())) .map(|p| p.pack) }) }); - let hp = match reuse { - Some(pack) => { - let v = scan_packs(&dir)?; - let hp = v - .into_iter() - .find(|p| p.pack == pack) - .context("history pack vanished")?; - println!( - "history pack {} reused from {}", - hp.checksum, - hp.pack.display() - ); - hp - } - None => { - let t = Instant::now(); - std::fs::create_dir_all(&dir)?; - let hp = build_history_pack(&git_dir, &dir, &packs[0].checksum)?; - println!( - "history pack {}: {} bytes, {} objects (commits + trees) in {:.1}s -> {}", - hp.checksum, - hp.pack_size, - hp.object_count, - t.elapsed().as_secs_f64(), - hp.pack.display() - ); - report.built_history_pack = true; - hp - } + let hp = if let Some(pack) = reuse { + let v = scan_packs(&dir)?; + let hp = v + .into_iter() + .find(|p| p.pack == pack) + .context("history pack vanished")?; + println!( + "history pack {} reused from {}", + hp.checksum, + hp.pack.display() + ); + hp + } else { + let t = Instant::now(); + std::fs::create_dir_all(&dir)?; + let hp = build_history_pack(&git_dir, &dir, &base_checksum)?; + println!( + "history pack {}: {} bytes, {} objects (commits + trees) in {:.1}s -> {}", + hp.checksum, + hp.pack_size, + hp.object_count, + t.elapsed().as_secs_f64(), + hp.pack.display() + ); + report.built_history_pack = true; + hp }; marker.history_pack = Some(hp.pack.clone()); packs.push(hp); @@ -577,12 +593,12 @@ pub async fn run_with_store( } ); } - if object_packs > 1 || packs[0].bitmap.is_none() { + let base_has_bitmap = packs.first().is_some_and(|p| p.bitmap.is_some()); + if object_packs > 1 || !base_has_bitmap { eprintln!( - "note: {} pack(s), bitmap={} — for fastest serving import ONE pack built with \ + "note: {} pack(s), bitmap={base_has_bitmap} — for fastest serving import ONE pack built with \ `git pack-objects --all --write-bitmap-index /pack`", packs.len(), - packs[0].bitmap.is_some() ); } @@ -646,7 +662,7 @@ pub async fn run_with_store( async move { st.head(&key) .await - .map(|m| m.map(|m| m.size == size).unwrap_or(false)) + .map(|m| m.is_some_and(|m| m.size == size)) } })) .await; @@ -724,7 +740,6 @@ pub async fn run_with_store( // ---- checkpoint refs (small, idempotent re-put) ----------------------------------------- let refs_key = keys::checkpoint_refs_key(seq); - let mut snap = snap; snap.seq = seq; snap.object_format = format.as_str().to_string(); snap.created_at = Some(time::now()); @@ -743,20 +758,17 @@ pub async fn run_with_store( let mut bundle_key = String::new(); let mut bundle_entry: Option = marker.bundle.as_deref().and_then(entry_from_hex); if opts.bundle && bundle_entry.is_none() { - if object_packs != 1 { - eprintln!( - "--bundle needs exactly one object pack (got {object_packs}); skipping bundle" - ); - } else { + if object_packs == 1 { let strategy = opts.bundle_strategy.clone().unwrap_or_else(|| { cfg.bundles .strategy .iter() .find(|s| s.kind == walgit_config::BundleKind::Full) - .map(|s| s.name.clone()) - .unwrap_or_else(|| "import".to_string()) + .map_or_else(|| "import".to_string(), |s| s.name.clone()) }); - let p0 = &packs[0]; + let p0 = packs + .first() + .ok_or_else(|| anyhow::anyhow!("no pack to bundle"))?; match walgit_bundle::ops::compose_full( &repo_store, &p0.checksum, @@ -785,6 +797,10 @@ pub async fn run_with_store( } Err(e) => eprintln!("bundle publish failed (import continues): {e:#}"), } + } else { + eprintln!( + "--bundle needs exactly one object pack (got {object_packs}); skipping bundle" + ); } } if let Some(e) = &bundle_entry { @@ -838,7 +854,7 @@ pub async fn run_with_store( packs: pack_refs, updated_at: Some(time::now()), writer: format!("walgit-import@{}", hostname()), - revision: base_manifest.as_ref().map(|m| m.revision).unwrap_or(0) + 1, + revision: base_manifest.as_ref().map_or(0, |m| m.revision) + 1, settings: None, }; let mode = match base_version { @@ -858,7 +874,7 @@ pub async fn run_with_store( println!( "published {} at seq {} (manifest {})", id, seq, meta.version - ) + ); } Err(StoreError::PreconditionFailed { .. }) => { bail!( @@ -907,8 +923,7 @@ pub async fn run_with_store( fn hostname() -> String { std::fs::read_to_string("/etc/hostname") - .map(|s| s.trim().to_string()) - .unwrap_or_else(|_| "local".into()) + .map_or_else(|_| "local".into(), |s| s.trim().to_string()) } fn count_loose(git_dir: &Path) -> u64 { @@ -1014,7 +1029,11 @@ pub fn verify_refs_in_packs( .stderr(std::process::Stdio::piped()) .spawn() .with_context(|| format!("git {}", args.join(" ")))?; - child.stdin.take().unwrap().write_all(stdin.as_bytes())?; + child + .stdin + .take() + .context("git stdin")? + .write_all(stdin.as_bytes())?; Ok(child.wait_with_output()?) }; // Tips first (cheap, names the exact ref problem). @@ -1080,7 +1099,7 @@ fn scan_packs(dir: &Path) -> Result> { idx, }); } - out.sort_by(|a, b| b.pack_size.cmp(&a.pack_size)); + out.sort_by_key(|p| std::cmp::Reverse(p.pack_size)); Ok(out) } @@ -1109,7 +1128,11 @@ fn build_history_pack(git_dir: &Path, dir: &Path, base: &str) -> Result Result { f.seek(SeekFrom::Start(8 + 255 * 4))?; let mut b = [0u8; 4]; f.read_exact(&mut b)?; - Ok(u32::from_be_bytes(b) as u64) + Ok(u64::from(u32::from_be_bytes(b))) } /// Git bundle header for `snap` (HEAD + refs/heads/* + refs/tags/*), no prerequisites. @@ -1202,7 +1225,7 @@ where .await { Ok(meta) => return Ok((meta.version, new_list)), - Err(StoreError::PreconditionFailed { .. }) => continue, + Err(StoreError::PreconditionFailed { .. }) => {} Err(e) => return Err(e.into()), } } @@ -1275,7 +1298,7 @@ mod tests { let ahead = git(&src, &["rev-parse", "HEAD"]); assert!( - verify_refs_in_packs(&packs, &[main_tip.clone()], true) + verify_refs_in_packs(&packs, std::slice::from_ref(&main_tip), true) .unwrap() .is_empty() ); @@ -1322,7 +1345,7 @@ mod tests { .unwrap(); assert!(out.status.success()); assert!( - verify_refs_in_packs(&packs, &[tip.clone()], false) + verify_refs_in_packs(&packs, std::slice::from_ref(&tip), false) .unwrap() .is_empty(), "tip is there" @@ -1359,8 +1382,8 @@ mod resume_tests { sh(d.path(), &["init", "-q", "-b", "main", "."]); sh(d.path(), &["config", "user.email", "t@t"]); sh(d.path(), &["config", "user.name", "T"]); - for i in 0..3 { - std::fs::write(d.path().join(format!("f{i}")), vec![b'a' + i as u8; 20_000]).unwrap(); + for i in 0u8..3 { + std::fs::write(d.path().join(format!("f{i}")), vec![b'a' + i; 20_000]).unwrap(); sh(d.path(), &["add", "."]); sh(d.path(), &["commit", "-q", "-m", &format!("c{i}")]); } @@ -1465,7 +1488,7 @@ mod resume_tests { .unwrap() .join("objects") .join("pack"); - let marker_path = opts(src.path(), repo).marker_path(&pack_dir, &id); + let marker_path = DirectOptions::marker_path(&pack_dir, &id); // Uploads are counted from the marker's done set (the report is lost with a killed run). let mut prev_uploaded = 0usize; let mut total_uploaded = 0usize; @@ -1528,8 +1551,10 @@ mod resume_tests { .unwrap(); let mut expected = 0usize; for p in &m.packs { - expected += - 2 + p.has_rev as usize + p.has_bitmap as usize + p.has_commit_graph as usize; + expected += 2 + + usize::from(p.has_rev) + + usize::from(p.has_bitmap) + + usize::from(p.has_commit_graph); } assert_eq!( total_uploaded, expected, diff --git a/crates/walgit-cli/src/lib.rs b/crates/walgit-cli/src/lib.rs index 3f30aa1..62271fd 100644 --- a/crates/walgit-cli/src/lib.rs +++ b/crates/walgit-cli/src/lib.rs @@ -200,7 +200,7 @@ enum Command { /// How often to fold the buffer's small packs (`git repack --geometric=2 --write-midx`). #[arg(long, default_value = "1h", value_parser = humantime::parse_duration)] repack_every: std::time::Duration, - /// Where the destination's bearer token comes from: `token` ($WALGIT_TOKEN), `gcloud` (a Google + /// Where the destination's bearer token comes from: `token` ($`WALGIT_TOKEN`), `gcloud` (a Google /// ID token for you) or `gce` (this VM's service account via the metadata server). #[arg(long, value_enum, default_value_t = mirror::Identity::Token)] identity: mirror::Identity, @@ -474,7 +474,7 @@ fn run(config: &std::path::Path, command: Command) -> Result<()> { // Required for rustls 0.23+ — multiple providers in the dep tree; select one. rustls::crypto::aws_lc_rs::default_provider() .install_default() - .expect("install rustls aws_lc_rs provider"); + .map_err(|_| anyhow::anyhow!("a rustls crypto provider is already installed"))?; let cfg = load_config(config); tracing_init(&cfg); @@ -489,14 +489,14 @@ fn run(config: &std::path::Path, command: Command) -> Result<()> { async fn dispatch(command: Command, cfg: Config) -> Result<()> { let cfg = std::sync::Arc::new(cfg); match command { - Command::Config { action } => config_cmd::run(action, &cfg).await, + Command::Config { action } => config_cmd::run(action, &cfg), Command::Synth { out, size, commits, files, seed, - } => synth::run(out, size, commits, files, seed).await, + } => synth::run(&out, size, commits, files, seed), Command::Serve => serve::run(&cfg).await, Command::Compact { repo, diff --git a/crates/walgit-cli/src/mirror.rs b/crates/walgit-cli/src/mirror.rs index 1f8dde4..094d3c7 100644 --- a/crates/walgit-cli/src/mirror.rs +++ b/crates/walgit-cli/src/mirror.rs @@ -86,11 +86,11 @@ pub async fn run(args: MirrorArgs) -> Result<()> { } match outcome.pushed.len() { 0 => debug!( - elapsed_ms = t0.elapsed().as_millis() as u64, + elapsed_ms = u64::try_from(t0.elapsed().as_millis()).unwrap_or(u64::MAX), "mirror: nothing to do" ), n => info!( - elapsed_ms = t0.elapsed().as_millis() as u64, + elapsed_ms = u64::try_from(t0.elapsed().as_millis()).unwrap_or(u64::MAX), refs = n, "mirror: tick done" ), @@ -99,7 +99,7 @@ pub async fn run(args: MirrorArgs) -> Result<()> { Err(e) => { error!( error = format!("{e:#}"), - elapsed_ms = t0.elapsed().as_millis() as u64, + elapsed_ms = u64::try_from(t0.elapsed().as_millis()).unwrap_or(u64::MAX), "mirror: tick failed" ); if args.once { @@ -160,7 +160,7 @@ impl Mirror { }; if before.get(name) != Some(sha) { out.fetched_anything = true; - info!(r#ref = %name, old = before.get(name).map(String::as_str).unwrap_or("-"), new = %sha, "mirror: source moved"); + info!(r#ref = %name, old = before.get(name).map_or("-", String::as_str), new = %sha, "mirror: source moved"); } if self.pushed.get(name) != Some(sha) { candidates.push((name.clone(), sha.clone())); @@ -197,8 +197,7 @@ impl Mirror { Some(old) => self .rev_list_count(old, sha) .await - .map(|n| n.to_string()) - .unwrap_or_else(|_| "?".into()), + .map_or_else(|_| "?".into(), |n| n.to_string()), None => "all".into(), }; info!(r#ref = %name, old = old.as_deref().unwrap_or("-"), new = %sha, commits = %commits, to = %self.to, "mirror: pushing"); @@ -209,7 +208,7 @@ impl Mirror { for (name, sha, _) in to_push { match results.get(&name) { Some(Ok(())) => { - info!(r#ref = %name, sha = %sha, elapsed_ms = t0.elapsed().as_millis() as u64, "mirror: pushed"); + info!(r#ref = %name, sha = %sha, elapsed_ms = u64::try_from(t0.elapsed().as_millis()).unwrap_or(u64::MAX), "mirror: pushed"); self.pushed.insert(name.clone(), sha); out.pushed.push(name); } @@ -344,7 +343,7 @@ impl Mirror { }; results.insert(name.to_string(), outcome); } - if !out.status.success() && results.values().all(|r| r.is_ok()) { + if !out.status.success() && results.values().all(Result::is_ok) { // Failed before any ref status (auth, connection, pack-objects): git said why on stderr. self.token.invalidate(); bail!( @@ -380,7 +379,7 @@ impl Mirror { String::from_utf8_lossy(&out.stderr).trim() ); info!( - elapsed_ms = t0.elapsed().as_millis() as u64, + elapsed_ms = u64::try_from(t0.elapsed().as_millis()).unwrap_or(u64::MAX), "mirror: repacked" ); Ok(()) @@ -416,7 +415,7 @@ struct Token { value: Option<(String, Instant)>, } -const TOKEN_MAX_AGE: Duration = Duration::from_secs(50 * 60); +const TOKEN_MAX_AGE: Duration = Duration::from_mins(50); const METADATA_IDENTITY_URL: &str = "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity"; @@ -506,11 +505,10 @@ async fn gce_identity_token(audience: &str) -> Result { /// `https://git.example.com/acme/monorepo.git` → `https://git.example.com` (the token audience). fn origin_of(url: &str) -> String { - match url.find("://") { - Some(i) => { - let rest = &url[i + 3..]; - let end = rest.find('/').unwrap_or(rest.len()); - url[..i + 3 + end].to_string() + match url.split_once("://") { + Some((scheme, rest)) => { + let host = rest.split('/').next().unwrap_or(rest); + format!("{scheme}://{host}") } None => url.to_string(), } @@ -619,7 +617,7 @@ mod tests { } } - /// Source → buffer → destination over file://: first tick publishes everything, a moved + /// Source → buffer → destination over : first tick publishes everything, a moved /// source is pushed on the next tick, an unchanged source is a no-op (no push), a rewound /// source is rejected without `--force` and followed with it. #[tokio::test] diff --git a/crates/walgit-cli/src/repo.rs b/crates/walgit-cli/src/repo.rs index daeeed7..1d17fb8 100644 --- a/crates/walgit-cli/src/repo.rs +++ b/crates/walgit-cli/src/repo.rs @@ -46,7 +46,7 @@ pub async fn run(action: RepoAction, cfg: &Arc) -> Result<()> { println!("(no repositories)"); } else { for id in repos { - println!("{}", id); + println!("{id}"); } } } @@ -58,8 +58,7 @@ pub async fn run(action: RepoAction, cfg: &Arc) -> Result<()> { let manifest = handle.manifest(); let version = handle .manifest_version() - .map(|v| v.to_string()) - .unwrap_or_else(|| "(none)".into()); + .map_or_else(|| "(none)".into(), |v| v.to_string()); println_kv("repo", &id); println_kv("object_format", &manifest.object_format); @@ -102,19 +101,19 @@ async fn policy(action: PolicyAction, store: &walgit_store::DynStore) -> Result< match action { PolicyAction::Get { repo } => { let id = repo_id(&repo)?; - let policy = policy::load(&store, &id).await?; + let policy = policy::load(store, &id).await?; println!("{}", serde_json::to_string_pretty(&policy)?); } PolicyAction::Set { repo, file } => { let id = repo_id(&repo)?; let bytes = std::fs::read(&file)?; let doc: RepoPolicy = serde_json::from_slice(&bytes)?; - policy::save(&store, &id, &doc).await?; + policy::save(store, &id, &doc).await?; info!(repo = %id, "policy saved"); } PolicyAction::Clear { repo } => { let id = repo_id(&repo)?; - policy::clear(&store, &id).await?; + policy::clear(store, &id).await?; info!(repo = %id, "policy cleared"); } } diff --git a/crates/walgit-cli/src/serve.rs b/crates/walgit-cli/src/serve.rs index 777d289..4caddf3 100644 --- a/crates/walgit-cli/src/serve.rs +++ b/crates/walgit-cli/src/serve.rs @@ -34,7 +34,7 @@ pub async fn run(cfg: &Arc) -> Result<()> { std::fs::create_dir_all(&cfg.cache.dir).ok(); // AppState::new constructs the registry, bundler, auth, semaphores, metrics. - let state = AppState::new(cfg.clone(), store).await?; + let state = AppState::new(cfg, store)?; // Spawn background loops for non-serving roles. let mut bg_handles = Vec::new(); @@ -73,18 +73,22 @@ pub async fn run(cfg: &Arc) -> Result<()> { let shutdown = async { #[cfg(unix)] { - let mut sigterm = signal::unix::signal(signal::unix::SignalKind::terminate()) - .expect("install SIGTERM handler"); - let mut sigint = signal::unix::signal(signal::unix::SignalKind::interrupt()) - .expect("install SIGINT handler"); - tokio::select! { - _ = sigterm.recv() => info!("received SIGTERM, shutting down"), - _ = sigint.recv() => info!("received SIGINT, shutting down"), + if let (Ok(mut sigterm), Ok(mut sigint)) = ( + signal::unix::signal(signal::unix::SignalKind::terminate()), + signal::unix::signal(signal::unix::SignalKind::interrupt()), + ) { + tokio::select! { + _ = sigterm.recv() => info!("received SIGTERM, shutting down"), + _ = sigint.recv() => info!("received SIGINT, shutting down"), + } + } else { + let _ = signal::ctrl_c().await; + info!("received Ctrl-C, shutting down"); } } #[cfg(not(unix))] { - signal::ctrl_c().await.expect("ctrl_c"); + let _ = signal::ctrl_c().await; info!("received Ctrl-C, shutting down"); } }; @@ -106,7 +110,7 @@ async fn compact_loop(registry: Arc, cfg: Arc) { info!("compaction disabled by config, loop exiting"); return; } - let interval = std::time::Duration::from_secs(60); + let interval = std::time::Duration::from_mins(1); loop { tokio::time::sleep(interval).await; if let Err(e) = run_compaction_pass(®istry, &cfg).await { @@ -152,7 +156,7 @@ async fn bundle_loop(bundler: Arc, cfg: Arc) { info!("bundles disabled by config, loop exiting"); return; } - let interval = std::time::Duration::from_secs(60); + let interval = std::time::Duration::from_mins(1); loop { tokio::time::sleep(interval).await; let now = std::time::SystemTime::now(); diff --git a/crates/walgit-cli/src/synth.rs b/crates/walgit-cli/src/synth.rs index d581fb8..55e3175 100644 --- a/crates/walgit-cli/src/synth.rs +++ b/crates/walgit-cli/src/synth.rs @@ -10,15 +10,16 @@ //! **m** — 2 000 commits, 5 000 files, binary blobs, 20 branches, 50 tags //! **l** — 50 000 commits, 50 000 files +use std::fmt::Write as _; use std::io::Write; -use std::path::PathBuf; +use std::path::Path; use std::process::{Command, Stdio}; use anyhow::{Context, Result, bail}; use crate::SynthSize; -/// (commits, files, branches, tags, binary_blobs) +/// (commits, files, branches, tags, `binary_blobs`) fn size_params( size: SynthSize, commits: Option, @@ -32,8 +33,8 @@ fn size_params( (commits.unwrap_or(c), files.unwrap_or(f), br, tg, bin) } -pub async fn run( - out: PathBuf, +pub fn run( + out: &Path, size: SynthSize, commits: Option, files: Option, @@ -42,16 +43,16 @@ pub async fn run( let (n_commits, n_files, n_branches, n_tags, binary) = size_params(size, commits, files); let seed = seed.unwrap_or(42); - if out.exists() && std::fs::read_dir(&out)?.next().is_some() { + if out.exists() && std::fs::read_dir(out)?.next().is_some() { bail!("output directory {} is not empty", out.display()); } - std::fs::create_dir_all(&out)?; + std::fs::create_dir_all(out)?; // git init let _git_dir = out.join(".git"); let status = Command::new("git") .args(["init", "--quiet"]) - .current_dir(&out) + .current_dir(out) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::inherit()) @@ -70,7 +71,7 @@ pub async fn run( ] { Command::new("git") .args(["config", key, val]) - .current_dir(&out) + .current_dir(out) .stdout(Stdio::null()) .stderr(Stdio::null()) .status()?; @@ -90,7 +91,7 @@ pub async fn run( // Pipe the stream to `git fast-import`. let mut child = Command::new("git") .args(["fast-import", "--quiet", "--done"]) - .current_dir(&out) + .current_dir(out) .stdin(Stdio::piped()) .stdout(Stdio::null()) .stderr(Stdio::inherit()) @@ -105,13 +106,13 @@ pub async fn run( let status = child.wait().context("waiting for git fast-import")?; if !status.success() { - bail!("git fast-import failed (exit {})", status); + bail!("git fast-import failed (exit {status})"); } // Checkout the main branch so it's a working tree. Command::new("git") .args(["checkout", "-f", "main"]) - .current_dir(&out) + .current_dir(out) .stdout(Stdio::null()) .stderr(Stdio::null()) .status()?; @@ -119,7 +120,7 @@ pub async fn run( // Verify with git fsck. let fsck = Command::new("git") .args(["fsck", "--full", "--strict"]) - .current_dir(&out) + .current_dir(out) .output() .context("running git fsck")?; if !fsck.status.success() { @@ -132,13 +133,10 @@ pub async fn run( // Print the HEAD commit for verification. let head = Command::new("git") .args(["rev-parse", "HEAD"]) - .current_dir(&out) + .current_dir(out) .output()?; let head = String::from_utf8_lossy(&head.stdout).trim().to_string(); - println!( - "synth OK: {} commits, {} files, HEAD={}", - n_commits, n_files, head - ); + println!("synth OK: {n_commits} commits, {n_files} files, HEAD={head}"); Ok(()) } @@ -160,7 +158,7 @@ impl Rng { x ^= x << 25; x ^= x >> 27; self.0 = x; - x.wrapping_mul(0x2545F4914F6CDD1D) + x.wrapping_mul(0x2545_F491_4F6C_DD1D) } /// [0, n) fn below(&mut self, n: u64) -> u64 { @@ -168,16 +166,12 @@ impl Rng { } /// Fills `buf` with deterministic pseudo-random bytes. fn fill_bytes(&mut self, buf: &mut [u8]) { - let mut i = 0; - while i + 8 <= buf.len() { + for chunk in buf.chunks_mut(8) { let v = self.next_u64().to_le_bytes(); - buf[i..i + 8].copy_from_slice(&v); - i += 8; - } - if i < buf.len() { - let v = self.next_u64().to_le_bytes(); - let rem = buf.len() - i; - buf[i..i + rem].copy_from_slice(&v[..rem]); + let n = chunk.len(); + if let Some(src) = v.get(..n) { + chunk.copy_from_slice(src); + } } } } @@ -241,7 +235,8 @@ fn generate_stream( // How many files to touch in this commit (1..=8, but capped by n_files). let touch = 1 + rng.below(8).min(n_files.max(1)); - let mut file_changes: Vec<(String, Vec)> = Vec::with_capacity(touch as usize); + let mut file_changes: Vec<(String, Vec)> = + Vec::with_capacity(usize::try_from(touch).unwrap_or(usize::MAX)); for _ in 0..touch { let file_idx = rng.below(n_files); @@ -262,7 +257,7 @@ fn generate_stream( let commit_mark = next_mark; next_mark += 1; - let ts = 1262304000 + commit_num * 60; // 2020-01-01 + 1min per commit + let ts = 1_262_304_000 + commit_num * 60; // 2020-01-01 + 1min per commit let ts_str = format!("{ts} +0000"); w.write_str(&format!("commit {main}\n")); @@ -330,13 +325,13 @@ fn message_for(n: u64) -> String { fn generate_file(rng: &mut Rng, file_idx: u64, binary: bool, commit_num: u64) -> (String, Vec) { // Distribute files across directories: dir_0/, dir_1/, ... let dir = file_idx / 100; - let is_binary = binary && (file_idx % 13 == 0); + let is_binary = binary && file_idx.is_multiple_of(13); let ext = if is_binary { "bin" } else { "txt" }; let path = format!("src/dir_{dir}/file_{file_idx:05}.{ext}"); let content = if is_binary { // Binary blob: 256..4096 random bytes. - let len = 256 + rng.below(3840) as usize; + let len = 256 + usize::try_from(rng.below(3840)).unwrap_or(usize::MAX); let mut buf = vec![0u8; len]; rng.fill_bytes(&mut buf); buf @@ -345,10 +340,11 @@ fn generate_file(rng: &mut Rng, file_idx: u64, binary: bool, commit_num: u64) -> let lines = 3 + (rng.next_u64() % 20) as usize; let mut s = String::with_capacity(lines * 40); for i in 0..lines { - s.push_str(&format!( - "line {i} of file {file_idx} at commit {commit_num}: {:016x}\n", + let _ = writeln!( + s, + "line {i} of file {file_idx} at commit {commit_num}: {:016x}", rng.next_u64() - )); + ); } s.into_bytes() }; @@ -419,16 +415,12 @@ mod tests { async fn synth_s_produces_valid_repo() { let tmp = tempfile::tempdir().unwrap(); let out = tmp.path().join("repo"); - run(out.clone(), SynthSize::S, None, None, Some(999)) - .await - .unwrap(); + run(out.as_path(), SynthSize::S, None, None, Some(999)).unwrap(); // Same seed → same HEAD. let tmp2 = tempfile::tempdir().unwrap(); let out2 = tmp2.path().join("repo"); - run(out2, SynthSize::S, None, None, Some(999)) - .await - .unwrap(); + run(&out2, SynthSize::S, None, None, Some(999)).unwrap(); let head1 = git_head(&out).unwrap(); let head2 = git_head2(&tmp2).unwrap(); diff --git a/crates/walgit-cli/src/wal_cmd.rs b/crates/walgit-cli/src/wal_cmd.rs index cb1810c..43a39c4 100644 --- a/crates/walgit-cli/src/wal_cmd.rs +++ b/crates/walgit-cli/src/wal_cmd.rs @@ -31,18 +31,18 @@ pub async fn run(action: WalAction, cfg: &Arc) -> Result<()> { } println!( - "{:<6} {:<10} {:<12} {:<10} {}", - "seq", "kind", "pack", "supersedes", "refs" + "{:<6} {:<10} {:<12} {:<10} refs", + "seq", "kind", "pack", "supersedes" ); for e in &entries { let kind = format!("{:?}", e.kind); let pack = e .pack .as_ref() - .map(|p| p.checksum[..12].to_string()) + .map(|p| p.checksum.get(..12).unwrap_or(&p.checksum).to_string()) .unwrap_or_default(); let supersedes = e.supersedes.len(); - let ref_count = e.txn.as_ref().map(|t| t.updates.len()).unwrap_or(0); + let ref_count = e.txn.as_ref().map_or(0, |t| t.updates.len()); println!( "{:<6} {:<10} {:<12} {:<10} {}", e.seq, kind, pack, supersedes, ref_count @@ -95,7 +95,7 @@ pub async fn run(action: WalAction, cfg: &Arc) -> Result<()> { println!( "{} ({} bytes) in {:.1}s", out.display(), - std::fs::metadata(&out).map(|m| m.len()).unwrap_or(0), + std::fs::metadata(&out).map_or(0, |m| m.len()), t0.elapsed().as_secs_f64() ); } @@ -114,7 +114,7 @@ pub async fn run(action: WalAction, cfg: &Arc) -> Result<()> { if let Some(g) = &commit_graph { let head = std::fs::read(g)?; anyhow::ensure!( - head.len() > 8 && &head[..4] == b"CGPH", + head.len() > 8 && head.starts_with(b"CGPH"), "{} is not a commit-graph file", g.display() ); @@ -148,14 +148,13 @@ pub async fn run(action: WalAction, cfg: &Arc) -> Result<()> { println_kv("writer", &entry.writer); println_kv( "created_at", - &entry - .created_at - .as_ref() - .map(|t| { + entry.created_at.as_ref().map_or_else( + || "(none — predates the field)".into(), + |t| { humantime::format_rfc3339_seconds(walgit_proto::time::to_system(t)) .to_string() - }) - .unwrap_or_else(|| "(none — predates the field)".into()), + }, + ), ); if let Some(pack) = &entry.pack { @@ -214,7 +213,9 @@ pub async fn materialize_at( at_seq: u64, out: &std::path::Path, ) -> Result<()> { - let handle = registry.open(&id).await?; + use walgit_proto::prost::Message; + use walgit_store::ObjectStoreExt; + let handle = registry.open(id).await?; // Read log entries up to at_seq and replay into a fresh LocalRepo. if out.exists() { @@ -229,9 +230,7 @@ pub async fn materialize_at( other => bail!("unknown object format in manifest: {other}"), }; - let local = walgit_git::LocalRepo::init(out, &id, format)?; - use walgit_proto::prost::Message; - use walgit_store::ObjectStoreExt; + let local = walgit_git::LocalRepo::init(out, id, format)?; // Start from the newest checkpoint at or before `at_seq` when the // log before it has been folded (min_seq), else from seq 0. @@ -273,7 +272,7 @@ pub async fn materialize_at( match handle.store().get_bytes(&key).await? { Some((_, bytes)) => { let (es, _) = walgit_proto::frame::decode_entries(&bytes)?; - let last = es.last().map(|e| e.seq).unwrap_or(seq); + let last = es.last().map_or(seq, |e| e.seq); found.extend(es); seq = last + 1; } @@ -283,8 +282,7 @@ pub async fn materialize_at( manifest .checkpoint .as_ref() - .map(|c| c.seq) - .unwrap_or(manifest.min_seq) + .map_or(manifest.min_seq, |c| c.seq) ), } } @@ -316,8 +314,8 @@ pub async fn materialize_at( if src.is_file() && !src.is_symlink() { for ext in ["pack", "idx", "rev", "bitmap", "commit-graph"] { let f = src.with_extension(ext); - if f.is_file() { - std::fs::copy(&f, tmp.join(f.file_name().unwrap()))?; + if let (true, Some(name)) = (f.is_file(), f.file_name()) { + std::fs::copy(&f, tmp.join(name))?; } } println!("pack {}: copied from the local copy", p.checksum); @@ -532,6 +530,6 @@ mod tests { .success() ); // The writer's live copy kept its packs. - assert!(handle.local().packs().unwrap().len() >= 1); + assert!(!handle.local().packs().unwrap().is_empty()); } } diff --git a/crates/walgit-config/src/lib.rs b/crates/walgit-config/src/lib.rs index 9132dc0..c9cc6fe 100644 --- a/crates/walgit-config/src/lib.rs +++ b/crates/walgit-config/src/lib.rs @@ -2,7 +2,11 @@ //! `WALGIT__SECTION__KEY=value` (double underscore = nesting), applied after //! the file is parsed. `PORT` (a serverless host) overrides `server.listen` port. -use std::{net::SocketAddr, path::PathBuf, time::Duration}; +use std::{ + net::{IpAddr, Ipv4Addr, SocketAddr}, + path::PathBuf, + time::Duration, +}; use anyhow::{Context, Result}; pub use bytesize::ByteSize; @@ -11,6 +15,7 @@ pub use std::str::FromStr; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields, default)] +#[derive(Default)] pub struct Config { pub server: ServerConfig, pub store: StoreConfig, @@ -48,8 +53,8 @@ pub struct ServerConfig { pub drain_timeout: Duration, /// Max size of a single pushed pack accepted over HTTP. pub max_push_bytes: ByteSize, - /// Roles this instance performs. a serverless host: fronts get ["serve"], the - /// single maintenance instance ["maintain"] (checkpoint / bundle / compact + /// Roles this instance performs. a serverless host: fronts get `["serve"]`, the + /// single maintenance instance `["maintain"]` (checkpoint / bundle / compact /// loops over every repo; `compact` and `bundle` are its sub-roles). Empty = all. pub roles: Vec, pub auth: AuthConfig, @@ -199,7 +204,7 @@ pub enum AuthMode { None, /// Static tokens from the config (`tokens`), bearer or basic. Token, - /// OpenID Connect: browser sign-in through the issuer, ID tokens as bearers, plus + /// `OpenID` Connect: browser sign-in through the issuer, ID tokens as bearers, plus /// walgit-issued access tokens for git — and `tokens` for robots. Oidc, } @@ -336,6 +341,9 @@ pub struct CacheConfig { pub store_mount: Option, } +// Each bool is one documented TOML key. Grouping them into sub-structs to satisfy +// the lint would change the config file's shape, which is a user-facing contract. +#[allow(clippy::struct_excessive_bools)] #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields, default)] pub struct WalConfig { @@ -449,12 +457,12 @@ fn default_all_repos() -> Vec { impl Default for MaintenanceConfig { fn default() -> Self { MaintenanceConfig { - interval: Duration::from_secs(60), + interval: Duration::from_mins(1), checkpoints: true, max_pack_bytes: ByteSize::b(0), disk: MaintainerDisk::Tmpfs, host: None, - fsck_interval: Duration::from_secs(7 * 24 * 3600), + fsck_interval: Duration::from_hours(168), follow_interval: Duration::from_secs(30), } } @@ -469,6 +477,7 @@ impl Default for MaintenanceConfig { /// everywhere, so the edge's read-only fallback (D29) works. /// * **maintain**: the maintainer loop's units (checkpoints, bundles, compaction, /// fsck/repair) — only on hosts with the `maintain` role. +/// /// Placement is by rule, not by capacity: a repo is either this host's or not. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields, default)] @@ -528,6 +537,9 @@ pub enum RepackEngine { Git, } +// Each bool is one documented TOML key. Grouping them into sub-structs to satisfy +// the lint would change the config file's shape, which is a user-facing contract. +#[allow(clippy::struct_excessive_bools)] #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields, default)] pub struct BundlesConfig { @@ -681,6 +693,9 @@ pub struct UpstreamConfig { pub follow: Vec, } +// Each bool is one documented TOML key. Grouping them into sub-structs to satisfy +// the lint would change the config file's shape, which is a user-facing contract. +#[allow(clippy::struct_excessive_bools)] #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields, default)] pub struct GitConfig { @@ -763,7 +778,7 @@ impl Default for EventsConfig { EventsConfig { webhook_url: None, webhook_secret: None, - sweep_interval: Duration::from_secs(300), + sweep_interval: Duration::from_mins(5), } } } @@ -808,6 +823,18 @@ pub const SETTINGS_SECTIONS: &[&str] = &["bundles", "maintenance", "compaction", /// D24: maximum size of a settings document. pub const SETTINGS_MAX_BYTES: usize = 16 * 1024; +/// Recursively merge `from` into `into`, table by table; non-table values replace. +fn merge(into: &mut toml::Table, from: &toml::Table) { + for (k, v) in from { + match (into.get_mut(k), v) { + (Some(toml::Value::Table(a)), toml::Value::Table(b)) => merge(a, b), + _ => { + into.insert(k.clone(), v.clone()); + } + } + } +} + impl Config { /// D24: the effective configuration for one repository = this (the host's /// walgit.toml ⊕ env) with the repository's settings TOML merged on top. @@ -836,16 +863,6 @@ impl Config { ); } let mut doc: toml::Table = toml::Table::try_from(self).context("serializing config")?; - fn merge(into: &mut toml::Table, from: &toml::Table) { - for (k, v) in from { - match (into.get_mut(k), v) { - (Some(toml::Value::Table(a)), toml::Value::Table(b)) => merge(a, b), - _ => { - into.insert(k.clone(), v.clone()); - } - } - } - } merge(&mut doc, &overrides); let cfg: Config = doc.try_into().context("settings: applying")?; cfg.validate() @@ -857,7 +874,7 @@ impl Config { /// never `upstream.token_env` (that name is host-only). pub fn public_settings_toml(&self) -> Result { let mut doc: toml::Table = toml::Table::try_from(self).context("serializing config")?; - doc.retain(|k, _| SETTINGS_SECTIONS.iter().any(|s| *s == k)); + doc.retain(|k, _| SETTINGS_SECTIONS.contains(&k)); if let Some(toml::Value::Table(u)) = doc.get_mut("upstream") { u.remove("token_env"); } @@ -882,7 +899,7 @@ impl Config { CacheMode::Auto => self.maintenance.disk == MaintainerDisk::Ssd, } } - /// Bundle strategies form chains of calendar slots (docs/BUNDLE_URI_DESIGN.md §4): + /// Bundle strategies form chains of calendar slots (`docs/BUNDLE_URI_DESIGN.md` §4): /// every `schedule` is a 6-field UTC cron (or an `@alias`) that parses; an /// incremental names a `base` that exists and whose chain ends in a full /// strategy; each chain has exactly one full root; `keep >= 1` on fulls. @@ -987,7 +1004,7 @@ fn env_placement_overrides(doc: &toml::Table, vars_seen: &[String]) -> Option = vars_seen .iter() .filter_map(|k| k.strip_prefix("WALGIT__PLACEMENT__")) - .map(|k| k.to_ascii_lowercase()) + .map(str::to_ascii_lowercase) .collect(); if keys.is_empty() { return None; @@ -1009,33 +1026,14 @@ pub fn repo_listed(list: &[String], owner: &str, name: &str) -> bool { }) } -impl Default for Config { - fn default() -> Self { - Config { - server: ServerConfig::default(), - store: StoreConfig::default(), - cache: CacheConfig::default(), - wal: WalConfig::default(), - compaction: CompactionConfig::default(), - maintenance: MaintenanceConfig::default(), - bundles: BundlesConfig::default(), - placement: PlacementConfig::default(), - lfs: LfsConfig::default(), - upstream: UpstreamConfig::default(), - git: GitConfig::default(), - telemetry: TelemetryConfig::default(), - events: EventsConfig::default(), - } - } -} impl Default for ServerConfig { fn default() -> Self { ServerConfig { - listen: "127.0.0.1:8080".parse().unwrap(), + listen: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8080), http2: true, max_concurrent_requests: 512, max_concurrent_per_repo: 64, - request_timeout: Duration::from_secs(3600), + request_timeout: Duration::from_hours(1), drain_timeout: Duration::from_secs(20), max_push_bytes: ByteSize::gib(64), roles: vec![], @@ -1063,8 +1061,8 @@ impl Default for AuthConfig { admin_emails: vec![], admin_domains: vec![], session_secret: None, - session_ttl: Duration::from_secs(30 * 24 * 3600), - access_token_ttl: Duration::from_secs(90 * 24 * 3600), + session_ttl: Duration::from_hours(720), + access_token_ttl: Duration::from_hours(2160), oauth_client_id: None, oauth_client_secret: None, } @@ -1113,7 +1111,7 @@ impl Default for CacheConfig { mode: CacheMode::Auto, max_bytes: ByteSize::gib(20), disk_high_watermark: 0.9, - evict_idle_after: Duration::from_secs(6 * 3600), + evict_idle_after: Duration::from_hours(6), prewarm: vec![], prewarm_parallelism: 2, prewarm_ready_timeout: Duration::ZERO, @@ -1136,7 +1134,7 @@ impl Default for WalConfig { push_broker_token: None, push_broker_buffer_bytes: ByteSize::mib(64), snapshot_every_entries: 256, - checkpoint_interval: Duration::from_secs(3600), + checkpoint_interval: Duration::from_hours(1), checkpoint_tail_bytes: ByteSize::mib(8), cas_max_retries: 16, fsck_objects: true, @@ -1155,8 +1153,8 @@ impl Default for CompactionConfig { factor: 2, trigger_packs: 16, trigger_bytes: ByteSize::gib(1), - lease_ttl: Duration::from_secs(600), - retention_superseded: Duration::from_secs(7 * 24 * 3600), + lease_ttl: Duration::from_mins(10), + retention_superseded: Duration::from_hours(168), engine: RepackEngine::Git, } } @@ -1205,7 +1203,7 @@ impl Default for BundlesConfig { }, ], serve_via: BundleServe::Proxy, - signed_url_ttl: Duration::from_secs(3600), + signed_url_ttl: Duration::from_hours(1), advertise: true, advertise_filtered: false, require: Vec::new(), @@ -1222,7 +1220,7 @@ impl Default for LfsConfig { LfsConfig { enabled: true, serve_via: BundleServe::Proxy, - signed_url_ttl: Duration::from_secs(3600), + signed_url_ttl: Duration::from_hours(1), max_object_bytes: ByteSize::gib(16), } } @@ -1304,8 +1302,8 @@ impl Config { continue; }; vars_seen.push(k.clone()); - let path: Vec = rest.split("__").map(|s| s.to_ascii_lowercase()).collect(); - if path.is_empty() || path.iter().any(|p| p.is_empty()) { + let path: Vec = rest.split("__").map(str::to_ascii_lowercase).collect(); + if path.is_empty() || path.iter().any(String::is_empty) { continue; } let value: toml::Value = v @@ -1320,16 +1318,19 @@ impl Config { path: &[String], value: toml::Value, ) -> std::result::Result<(), String> { - if path.len() == 1 { - cur.insert(path[0].clone(), value); + let Some((head, rest)) = path.split_first() else { + return Err("empty key path".to_string()); + }; + if rest.is_empty() { + cur.insert(head.clone(), value); return Ok(()); } let next = cur - .entry(path[0].clone()) - .or_insert_with(|| toml::Value::Table(Default::default())) + .entry(head.clone()) + .or_insert_with(|| toml::Value::Table(toml::Table::default())) .as_table_mut() - .ok_or_else(|| format!("{} is not a table", path[0]))?; - set(next, &path[1..], value) + .ok_or_else(|| format!("{head} is not a table"))?; + set(next, rest, value) } match set(&mut trial, &path, value) { Err(why) => Some(why), @@ -1342,12 +1343,11 @@ impl Config { }), } }; - match bad { - Some(why) => ignored.push((k, why)), - None => { - doc = trial; - touched = true; - } + if let Some(why) = bad { + ignored.push((k, why)); + } else { + doc = trial; + touched = true; } } // `[placement]` is a host fact set as a GROUP: any WALGIT__PLACEMENT__* override @@ -1371,10 +1371,10 @@ impl Config { self.server.listen.set_port(port); // Standalone / `dev server`: public_url is the origin the browser hits. Keep its // port in lockstep with PORT. A real public_url is left alone. - if let Some(u) = self.server.public_url.as_mut() { - if origin_is_loopback(u) { - *u = rewrite_origin_port(u, port); - } + if let Some(u) = self.server.public_url.as_mut() + && origin_is_loopback(u) + { + *u = rewrite_origin_port(u, port); } } Ok(ignored) @@ -1555,7 +1555,7 @@ impl Config { names.contains(b.as_str()), "bundle strategy {} base {b} does not exist", s.name - ) + ); } (BundleKind::Full, Some(_)) => { anyhow::bail!("bundle strategy {} is full but has a base", s.name) @@ -1622,7 +1622,7 @@ impl Config { } let mut v: Vec = ["localhost", "*.localhost", "127.0.0.1", "::1"] .iter() - .map(|s| s.to_string()) + .map(ToString::to_string) .collect(); if let Some(u) = &self.server.public_url { let host = u @@ -1634,8 +1634,7 @@ impl Config { .trim_start_matches('['); let host = host .rsplit_once(']') - .map(|(h, _)| h) - .unwrap_or_else(|| host.split(':').next().unwrap_or(host)); + .map_or_else(|| host.split(':').next().unwrap_or(host), |(h, _)| h); if !host.is_empty() && !v.iter().any(|h| h == host) { v.push(host.to_string()); } @@ -1655,10 +1654,9 @@ fn origin_host(origin: &str) -> &str { let rest = origin .trim_end_matches('/') .split_once("://") - .map(|(_, r)| r) - .unwrap_or(origin); + .map_or(origin, |(_, r)| r); if let Some(inside) = rest.strip_prefix('[') { - return inside.split_once(']').map(|(h, _)| h).unwrap_or(inside); + return inside.split_once(']').map_or(inside, |(h, _)| h); } rest.split([':', '/']).next().unwrap_or(rest) } @@ -1675,8 +1673,7 @@ fn rewrite_origin_port(origin: &str, port: u16) -> String { }; let host = if rest.starts_with('[') { rest.split_once(']') - .map(|(h, _)| format!("{h}]")) - .unwrap_or_else(|| rest.to_string()) + .map_or_else(|| rest.to_string(), |(h, _)| format!("{h}]")) } else { rest.split([':', '/']).next().unwrap_or(rest).to_string() }; @@ -1754,7 +1751,7 @@ mod tests { /// `[placement]` is set as a group: one PLACEMENT env key replaces the whole /// section (unset keys = defaults), never merges with the file's values. - /// The SSD host 2026-08-21 07:00Z: the baked toml's serve_exclude = ["acme/monorepo"] + /// The SSD host 2026-08-21 07:00Z: the baked toml's `serve_exclude` = `["acme/monorepo"]` /// leaked under an env that set only MAINTAIN* → the host refused its own repo. #[test] fn env_placement_override_replaces_the_whole_section() { @@ -1868,13 +1865,13 @@ mod tests { base.store.bucket = "b".into(); let eff = base .with_settings( - r#" + r" [bundles] min_commits = 3 main_only = false [maintenance] checkpoints = false -"#, +", ) .unwrap(); assert_eq!(eff.bundles.min_commits, 3); @@ -1945,10 +1942,7 @@ audiences = ["walgit-cli", "https://git.example.com"] assert!(err.to_string().contains("session_secret"), "{err}"); let ok = Config::parse("[store]\nbucket = \"b\"\n[server.auth]\nmode = \"oidc\"\nissuer = \"https://login.example.com\"\nanonymous_read = false\nallowed_domains = [\"example.com\"]\noauth_client_id = \"x\"\noauth_client_secret = \"y\"\nsession_secret = \"0123456789abcdef0123456789abcdef\"\n").unwrap(); assert_eq!(ok.server.auth.issuer, "https://login.example.com"); - assert_eq!( - ok.server.auth.access_token_ttl, - Duration::from_secs(90 * 86400) - ); + assert_eq!(ok.server.auth.access_token_ttl, Duration::from_hours(2160)); let err = Config::parse( "[store]\nbucket = \"b\"\n[server]\nlisten = \"0.0.0.0:8080\"\n[server.auth]\nmode = \"none\"\n", ) @@ -1967,7 +1961,7 @@ webhook_secret = "s" "#, ) .unwrap(); - assert_eq!(c.events.sweep_interval, Duration::from_secs(60)); + assert_eq!(c.events.sweep_interval, Duration::from_mins(1)); assert_eq!(c.events.webhook_secret.as_deref(), Some("s")); let err = Config::parse("[events]\nwebhook_url = \"ftp://x\"\n").unwrap_err(); assert!(err.to_string().contains("webhook_url"), "{err}"); diff --git a/crates/walgit-git/src/follow.rs b/crates/walgit-git/src/follow.rs index 2714e25..b224b86 100644 --- a/crates/walgit-git/src/follow.rs +++ b/crates/walgit-git/src/follow.rs @@ -13,6 +13,7 @@ //! credential helper that reads it from the environment — never argv. use std::collections::HashMap; +use std::fmt::Write as _; use std::path::{Path, PathBuf}; use std::process::Stdio; @@ -42,11 +43,11 @@ impl FetchedDelta { /// Fetch `refs` from `upstream` into the scratch for `(owner, name)` under `dir`, /// negotiating from `have` (`ref → oid` we hold; missing = fetch its history). -pub async fn fetch_refs( +pub async fn fetch_refs( upstream: &str, token: Option<&str>, serving_objects: &Path, - have: &HashMap, + have: &HashMap, refs: &[String], scratch: &Path, ) -> Result { @@ -121,8 +122,12 @@ pub async fn fetch_refs( let mut input = String::new(); for r in refs { match have.get(r) { - Some(oid) => input.push_str(&format!("update {} {oid}\n", follow_ref(r))), - None => input.push_str(&format!("delete {}\n", follow_ref(r))), + Some(oid) => { + let _ = writeln!(input, "update {} {oid}", follow_ref(r)); + } + None => { + let _ = writeln!(input, "delete {}", follow_ref(r)); + } } } let mut child = git(&["update-ref", "--stdin"]) @@ -131,7 +136,10 @@ pub async fn fetch_refs( .map_err(GitError::Io)?; { use tokio::io::AsyncWriteExt; - let mut stdin = child.stdin.take().expect("stdin"); + let mut stdin = child + .stdin + .take() + .ok_or_else(|| GitError::Io(std::io::Error::other("git update-ref stdin")))?; stdin .write_all(input.as_bytes()) .await diff --git a/crates/walgit-git/src/lib.rs b/crates/walgit-git/src/lib.rs index d48fa36..e2f8e86 100644 --- a/crates/walgit-git/src/lib.rs +++ b/crates/walgit-git/src/lib.rs @@ -1,6 +1,6 @@ //! Local git repository engine: gix in-process for odb/refs/revwalk/pack //! generation; upstream git subprocess for ingest (`index-pack`), repack, -//! bundle, and the selectable Engine::Git upload-pack fallback. See AGENTS.md +//! bundle, and the selectable `Engine::Git` upload-pack fallback. See AGENTS.md //! D2 and docs/CONTRACT.md walgit-git. pub mod follow; @@ -11,6 +11,7 @@ pub mod upload_gix; pub use upload_gix::ObjectFaulter; use std::collections::{BTreeMap, HashMap, HashSet}; +use std::fmt::Write as _; use std::io::Write; use std::path::{Path, PathBuf}; use std::process::Stdio; @@ -62,6 +63,10 @@ fn ge(e: E) -> GitError { GitError::Gix(Box::new(e)) } +#[allow( + clippy::case_sensitive_file_extension_comparisons, + reason = "git reserves the exact lowercase suffix; an insensitive compare would reject names git accepts" +)] /// Reject ref names that would inject `git update-ref --stdin` commands or /// poison packed-refs (newlines, NULs, git-illegal bytes). pub fn validate_ref_name(name: &str) -> Result<(), GitError> { @@ -163,7 +168,7 @@ impl RepoId { &self.name } - /// `repos///` (walgit_proto::keys::repo_prefix). + /// `repos///` (`walgit_proto::keys::repo_prefix`). pub fn store_prefix(&self) -> String { walgit_proto::keys::repo_prefix(&self.owner, &self.name) } @@ -259,7 +264,6 @@ impl From for ObjectFormat { impl From for ObjectFormat { fn from(k: gix_hash::Kind) -> Self { match k { - gix_hash::Kind::Sha1 => ObjectFormat::Sha1, gix_hash::Kind::Sha256 => ObjectFormat::Sha256, _ => ObjectFormat::Sha1, } @@ -378,13 +382,13 @@ impl LsRefsLine { /// ` peeled:`, then a trailing newline. pub fn render(&self, args: &LsRefsArgs) -> String { let mut s = format!("{} {}", self.oid, self.name); - if args.symrefs || self.oid == "unborn" { - if let Some(t) = &self.symref_target { - s.push_str(&format!(" symref-target:{t}")); - } + if (args.symrefs || self.oid == "unborn") + && let Some(t) = &self.symref_target + { + let _ = write!(s, " symref-target:{t}"); } if args.peel && !self.peeled.is_empty() { - s.push_str(&format!(" peeled:{}", self.peeled)); + let _ = write!(s, " peeled:{}", self.peeled); } s.push('\n'); s @@ -417,6 +421,10 @@ impl Service { } } +#[allow( + clippy::struct_excessive_bools, + reason = "one field per protocol flag the client sent; the flags are independent" +)] #[derive(Debug, Clone)] pub struct UploadPackRequest { pub wants: Vec, @@ -511,7 +519,8 @@ impl RefView { .refs .binary_search_by(|r| r.name.as_str().cmp(name)) .ok() - .map(|i| self.base.refs[i].oid.clone()) + .and_then(|i| self.base.refs.get(i)) + .map(|r| r.oid.clone()) } pub fn head_target(&self) -> &str { self.head_target @@ -578,7 +587,7 @@ fn refs_key(path: &Path, generation: u64) -> RefsKey { let head = std::fs::metadata(path.join("HEAD")).ok(); RefsKey { generation, - packed_len: packed.as_ref().map(|m| m.len()).unwrap_or(0), + packed_len: packed.as_ref().map_or(0, std::fs::Metadata::len), packed_mtime: packed.and_then(|m| m.modified().ok()), head_mtime: head.and_then(|m| m.modified().ok()), } @@ -593,7 +602,7 @@ impl LocalRepo { /// Create a bare repo at `//.git`. pub fn init(root: &Path, id: &RepoId, format: ObjectFormat) -> Result { let path = id.local_dir(root); - std::fs::create_dir_all(path.parent().unwrap_or(root)).map_err(|e| GitError::Io(e))?; + std::fs::create_dir_all(path.parent().unwrap_or(root)).map_err(GitError::Io)?; // `git init --bare [--object-format=...] `. let mut cmd = std::process::Command::new("git"); cmd.arg("init").arg("--bare"); @@ -710,7 +719,7 @@ impl LocalRepo { let t = std::time::Instant::now(); // `iter()` snapshots the store with all indices loaded. let _ = repo.objects.iter(); - let ms = t.elapsed().as_millis() as u64; + let ms = u64::try_from(t.elapsed().as_millis()).unwrap_or(u64::MAX); if ms > 200 { tracing::info!(repo = %self.inner.path.display(), ms, "odb indices loaded"); } @@ -778,7 +787,7 @@ impl LocalRepo { .open(&candidate) { Ok(file) => break (candidate, file), - Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} Err(e) => return Err(GitError::Io(e)), } }; @@ -797,19 +806,19 @@ impl LocalRepo { } empty_check = false; total += n as u64; - if let Some(max) = opts.max_bytes { - if total > max { - drop( - tokio::fs::remove_file(&tmp_path) - .instrument(span.clone()) - .await, - ); - return Err(GitError::InvalidInput(format!( - "pack exceeds max_bytes {max}" - ))); - } + if let Some(max) = opts.max_bytes + && total > max + { + drop( + tokio::fs::remove_file(&tmp_path) + .instrument(span.clone()) + .await, + ); + return Err(GitError::InvalidInput(format!( + "pack exceeds max_bytes {max}" + ))); } - tmp.write_all(&buf[..n]) + tmp.write_all(buf.get(..n).unwrap_or_default()) .instrument(span.clone()) .await .map_err(GitError::Io)?; @@ -883,8 +892,8 @@ impl LocalRepo { let _ = std::fs::remove_file(pack_path.with_extension("rev")); return Ok(None); } - let pack_size = std::fs::metadata(&pack_path).map(|m| m.len()).unwrap_or(0); - let idx_size = std::fs::metadata(&idx_path).map(|m| m.len()).unwrap_or(0); + let pack_size = std::fs::metadata(&pack_path).map_or(0, |m| m.len()); + let idx_size = std::fs::metadata(&idx_path).map_or(0, |m| m.len()); self.refresh_async() .instrument(tracing::info_span!(parent: &span, "git.ingest_pack.refresh")) .await?; @@ -907,13 +916,15 @@ impl LocalRepo { ) -> Result<(), GitError> { let pack_dir = self.objects_pack_dir(); std::fs::create_dir_all(&pack_dir).map_err(GitError::Io)?; - let dst_pack = pack_dir.join(pack.file_name().unwrap()); - let dst_idx = pack_dir.join(idx.file_name().unwrap()); - rename_atomic(pack, &dst_pack)?; - rename_atomic(idx, &dst_idx)?; + let named = |p: &Path| -> Result { + p.file_name().map(|n| pack_dir.join(n)).ok_or_else(|| { + GitError::Protocol(format!("pack file has no name: {}", p.display())) + }) + }; + rename_atomic(pack, &named(pack)?)?; + rename_atomic(idx, &named(idx)?)?; for e in extra { - let dst = pack_dir.join(e.file_name().unwrap()); - rename_atomic(e, &dst)?; + rename_atomic(e, &named(e)?)?; } self.refresh_async().await?; Ok(()) @@ -975,18 +986,19 @@ impl LocalRepo { let ent = ent.map_err(GitError::Io)?; let name = ent.file_name(); let name = name.to_string_lossy(); - if !name.starts_with("pack-") || !name.ends_with(".pack") { + let Some(hex) = name + .strip_prefix("pack-") + .and_then(|n| n.strip_suffix(".pack")) + else { + continue; + }; + let Ok(checksum) = gix_hash::ObjectId::from_hex(hex.as_bytes()) else { continue; - } - let hex = &name["pack-".len()..name.len() - ".pack".len()]; - let checksum = match gix_hash::ObjectId::from_hex(hex.as_bytes()) { - Ok(o) => o, - Err(_) => continue, }; let pack_path = ent.path(); let idx_path = pack_path.with_extension("idx"); - let pack_size = std::fs::metadata(&pack_path).map(|m| m.len()).unwrap_or(0); - let idx_size = std::fs::metadata(&idx_path).map(|m| m.len()).unwrap_or(0); + let pack_size = std::fs::metadata(&pack_path).map_or(0, |m| m.len()); + let idx_size = std::fs::metadata(&idx_path).map_or(0, |m| m.len()); let object_count = idx_object_count(&idx_path).unwrap_or(0); let has_rev = pack_path.with_extension("rev").exists(); let has_bitmap = pack_path.with_extension("bitmap").exists(); @@ -1022,7 +1034,7 @@ impl LocalRepo { } /// The refs, parsed once and shared: `packed-refs` of a 500 k-ref repo is - /// 34 MB and read_refs also peels every tag — 1–2 s per call, which every + /// 34 MB and `read_refs` also peels every tag — 1–2 s per call, which every /// `ls-refs` (prefix or not) paid (2026-08-21, test/refs500k on a serverless host). /// Valid until a ref writer in this process bumps the generation or /// `packed-refs`/`HEAD` change on disk (two stats per call). Sorted by name. @@ -1046,7 +1058,7 @@ impl LocalRepo { c.data = Arc::new(patched); c.pending.clear(); if c.data.refs.len() >= 10_000 { - tracing::debug!(repo = %self.inner.id, refs = c.data.refs.len(), ms = t.elapsed().as_millis() as u64, "refs cache materialized from pending txns"); + tracing::debug!(repo = %self.inner.id, refs = c.data.refs.len(), ms = u64::try_from(t.elapsed().as_millis()).unwrap_or(u64::MAX), "refs cache materialized from pending txns"); } } return Ok(c.data.clone()); @@ -1058,7 +1070,7 @@ impl LocalRepo { .refs_parses .fetch_add(1, std::sync::atomic::Ordering::Relaxed); if data.refs.len() >= 10_000 { - tracing::debug!(repo = %self.inner.id, refs = data.refs.len(), ms = t.elapsed().as_millis() as u64, "refs parsed into the cache"); + tracing::debug!(repo = %self.inner.id, refs = data.refs.len(), ms = u64::try_from(t.elapsed().as_millis()).unwrap_or(u64::MAX), "refs parsed into the cache"); } *self.inner.refs_cache.lock() = Some(RefsCached { key, @@ -1173,14 +1185,14 @@ impl LocalRepo { return Err(GitError::RefConflict { name: u.name.clone(), expected: u.old_oid.clone(), - actual: current.to_string(), + actual: current.clone(), }); } } else if old != cur { return Err(GitError::RefConflict { name: u.name.clone(), expected: u.old_oid.clone(), - actual: current.to_string(), + actual: current.clone(), }); } } @@ -1205,16 +1217,16 @@ impl LocalRepo { if new_zero { // delete if check_old && !old_zero { - input.push_str(&format!("delete {} {}\n", u.name, u.old_oid)); + let _ = writeln!(input, "delete {} {}", u.name, u.old_oid); } else { - input.push_str(&format!("delete {}\n", u.name)); + let _ = writeln!(input, "delete {}", u.name); } } else if check_old && old_zero { - input.push_str(&format!("create {} {}\n", u.name, u.new_oid)); + let _ = writeln!(input, "create {} {}", u.name, u.new_oid); } else if check_old && !old_zero { - input.push_str(&format!("update {} {} {}\n", u.name, u.new_oid, u.old_oid)); + let _ = writeln!(input, "update {} {} {}", u.name, u.new_oid, u.old_oid); } else { - input.push_str(&format!("update {} {}\n", u.name, u.new_oid)); + let _ = writeln!(input, "update {} {}", u.name, u.new_oid); } } @@ -1230,7 +1242,10 @@ impl LocalRepo { .spawn() .and_then(|mut c| { { - let stdin = c.stdin.as_mut().unwrap(); + let stdin = c + .stdin + .as_mut() + .ok_or_else(|| std::io::Error::other("git update-ref stdin"))?; stdin.write_all(input.as_bytes())?; } c.wait_with_output() @@ -1302,7 +1317,7 @@ impl LocalRepo { for u in txns.iter().flat_map(|t| t.updates.iter()) { if !u.new_symbolic_target.is_empty() { if u.name == "HEAD" { - head_target = u.new_symbolic_target.clone(); + head_target.clone_from(&u.new_symbolic_target); } continue; } @@ -1316,13 +1331,14 @@ impl LocalRepo { (pos, false) => { let mut peeled = u.new_peeled.clone(); if peeled.is_empty() && u.name.starts_with("refs/tags/") { - let r = repo.get_or_insert_with(|| { - gix::Repository::from( - &gix::ThreadSafeRepository::open(&self.inner.path) - .expect("repo open"), - ) - }); - if let Ok(oid) = gix_hash::ObjectId::from_hex(u.new_oid.as_bytes()) { + if repo.is_none() { + repo = gix::ThreadSafeRepository::open(&self.inner.path) + .ok() + .map(|r| gix::Repository::from(&r)); + } + if let Some(r) = repo.as_ref() + && let Ok(oid) = gix_hash::ObjectId::from_hex(u.new_oid.as_bytes()) + { peeled = peel_tag(r, oid) .map(|p| p.to_hex().to_string()) .unwrap_or_default(); @@ -1334,7 +1350,11 @@ impl LocalRepo { peeled, }; match pos { - Ok(i) => refs[i] = entry, + Ok(i) => { + if let Some(slot) = refs.get_mut(i) { + *slot = entry; + } + } Err(i) => refs.insert(i, entry), } } @@ -1354,9 +1374,9 @@ impl LocalRepo { let mut refs = snap.refs.clone(); refs.sort_by(|a, b| a.name.cmp(&b.name)); for r in &refs { - content.push_str(&format!("{} {}\n", r.oid, r.name)); + let _ = writeln!(content, "{} {}", r.oid, r.name); if !r.peeled.is_empty() { - content.push_str(&format!("^{}\n", r.peeled)); + let _ = writeln!(content, "^{}", r.peeled); } } // Atomic write. @@ -1412,7 +1432,7 @@ impl LocalRepo { validate_ref_update(u)?; if !u.new_symbolic_target.is_empty() { if u.name == "HEAD" { - head_target = u.new_symbolic_target.clone(); + head_target.clone_from(&u.new_symbolic_target); } continue; } @@ -1474,12 +1494,10 @@ impl LocalRepo { ) -> Result<(), GitError> { use gix_object::Write as _; let hex = oid.to_hex().to_string(); - let path = self - .inner - .path - .join("objects") - .join(&hex[..2]) - .join(&hex[2..]); + let (shard, rest) = hex + .split_at_checked(2) + .ok_or_else(|| GitError::Protocol(format!("short object id {hex}")))?; + let path = self.inner.path.join("objects").join(shard).join(rest); if path.exists() { return Ok(()); } @@ -1492,14 +1510,14 @@ impl LocalRepo { ); store .write_buf_with_known_id(kind, data, oid.to_owned()) - .map_err(|e| GitError::Gix(e))?; + .map_err(GitError::Gix)?; Ok(()) } - /// Every object reachable from tips exists. When stop_at_existing_refs, + /// Every object reachable from tips exists. When `stop_at_existing_refs`, /// objects already reachable from current refs are assumed present and - /// only the new set is verified. Uses gix revwalk with .with_hidden( - /// existing ref tips) for commit traversal and gix_traverse::tree + /// only the new set is verified. Uses gix revwalk with .`with_hidden`( + /// existing ref tips) for commit traversal and `gix_traverse::tree` /// breadthfirst for tree traversal with a seen-set. pub fn check_connectivity( &self, @@ -1580,10 +1598,10 @@ impl LocalRepo { let mut out = Vec::with_capacity(snap.refs.len()); for r in &snap.refs { // Prefer the pre-peeled oid for tags; otherwise peel cheaply via the odb. - let candidate = if !r.peeled.is_empty() { - r.peeled.as_str() - } else { + let candidate = if r.peeled.is_empty() { r.oid.as_str() + } else { + r.peeled.as_str() }; let Ok(oid) = gix_hash::ObjectId::from_hex(candidate.as_bytes()) else { continue; @@ -1622,7 +1640,7 @@ impl LocalRepo { if !seen.insert(cid) { continue; } - if !repo.has_object(&cid) { + if !repo.has_object(cid) { return Err(GitError::MissingObject { oid: cid.to_hex().to_string(), }); @@ -1632,9 +1650,9 @@ impl LocalRepo { .objects .find_commit_iter(&cid, &mut buf) .map_err(|e| GitError::Gix(Box::new(e)))?; - let tree_id = commit.tree_id().map_err(|e| ge(e))?; + let tree_id = commit.tree_id().map_err(ge)?; if seen.insert(tree_id) { - if !repo.has_object(&tree_id) { + if !repo.has_object(tree_id) { return Err(GitError::MissingObject { oid: tree_id.to_hex().to_string(), }); @@ -1686,9 +1704,9 @@ impl LocalRepo { /// protocol v2 fetch with in-process gix pack generation. Handles /// negotiation (ACK common haves that exist, NAK, ready), shallow-info /// (deepen by depth), wanted-refs, then generates the packfile via - /// gix_pack::data::output (count + entries with delta reuse from on-disk + /// `gix_pack::data::output` (count + entries with delta reuse from on-disk /// packs), framed in sideband-64k (channel 1; progress on 2 unless - /// no_progress) with a final flush. UploadPackStats populated with object + /// `no_progress`) with a final flush. `UploadPackStats` populated with object /// count and byte count. pub async fn upload_pack( &self, @@ -1756,7 +1774,8 @@ impl LocalRepo { snap.refs .binary_search_by(|r| r.name.as_str().cmp(head_target.as_str())) .ok() - .map(|i| snap.refs[i].oid.clone()) + .and_then(|i| snap.refs.get(i)) + .map(|r| r.oid.clone()) }; // Prefix selection is O(log n + k) over the name-sorted list: each // prefix is one range (binary search for its start, scan while it @@ -1770,7 +1789,11 @@ impl LocalRepo { .map(|p| { let start = snap.refs.partition_point(|r| r.name.as_str() < p.as_str()); let mut end = start; - while end < snap.refs.len() && snap.refs[end].name.starts_with(p.as_str()) { + while snap + .refs + .get(end) + .is_some_and(|r| r.name.starts_with(p.as_str())) + { end += 1; } (start, end) @@ -1782,7 +1805,7 @@ impl LocalRepo { for (a, b) in ranges { let a = a.max(cursor); if a < b { - out.extend(snap.refs[a..b].iter()); + out.extend(snap.refs.get(a..b).unwrap_or_default()); cursor = b; } } @@ -1832,7 +1855,7 @@ impl LocalRepo { pub fn advertise_refs_v0(&self, service: Service, out: &mut Vec) -> Result<(), GitError> { let snap = self.refs()?; let caps = capabilities_for(service, self.inner.format); - let caps_line = format!("\0{}\n", caps); + let caps_line = format!("\0{caps}\n"); if snap.refs.is_empty() { // No refs: emit the capabilities line with a zero id and @@ -1861,16 +1884,16 @@ impl LocalRepo { } // Include HEAD if it has a resolvable target and isn't already the // first advertised ref (upload-pack advertises HEAD). - if !head_target.is_empty() && service == Service::UploadPack { - if let Some(oid) = snap + if !head_target.is_empty() + && service == Service::UploadPack + && let Some(oid) = snap .refs .iter() .find(|r| r.name == head_target) .map(|r| r.oid.clone()) - { - let head_line = format!("{oid} HEAD\n"); - pkt::encode_data(out, head_line.as_bytes()); - } + { + let head_line = format!("{oid} HEAD\n"); + pkt::encode_data(out, head_line.as_bytes()); } } pkt::encode_flush(out); @@ -1950,7 +1973,7 @@ impl LocalRepo { } } } - let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); + let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect(); let out = self.git(&arg_refs).await?; if !out.status.success() { return Err(GitError::Subprocess { @@ -2026,7 +2049,7 @@ impl LocalRepo { let po_stdout: Stdio = po .stdout .take() - .expect("stdout") + .ok_or_else(|| GitError::Io(std::io::Error::other("git pack-objects stdout")))? .try_into() .map_err(GitError::Io)?; let ip = tokio::process::Command::new("git") @@ -2048,7 +2071,10 @@ impl LocalRepo { .map_err(GitError::Io)?; { use tokio::io::AsyncWriteExt; - let mut stdin = po.stdin.take().expect("stdin"); + let mut stdin = po + .stdin + .take() + .ok_or_else(|| GitError::Io(std::io::Error::other("git pack-objects stdin")))?; stdin .write_all(revs.as_bytes()) .await @@ -2173,17 +2199,24 @@ impl LocalRepo { .map(|p| format!("pack-{}.idx", p.checksum)) .collect(); for h in &history { - if let Some(base) = &h.history_of { - if let Some(b) = packs.iter().find(|p| &p.checksum.to_string() == base) { - let n = format!("pack-{}.idx", b.checksum); - if !names.contains(&n) { - names.push(n); - } + if let Some(base) = &h.history_of + && let Some(b) = packs.iter().find(|p| &p.checksum.to_string() == base) + { + let n = format!("pack-{}.idx", b.checksum); + if !names.contains(&n) { + names.push(n); } } } - let preferred = names[0].clone(); - let input = names.iter().map(|n| format!("{n}\n")).collect::(); + let Some(preferred) = names.first().cloned() else { + let _ = std::fs::remove_file(&midx); + return Ok(()); + }; + let mut input = String::new(); + for n in &names { + input.push_str(n); + input.push('\n'); + } let out = std::process::Command::new("git") .current_dir(&self.inner.path) .env("GIT_DIR", &self.inner.path) @@ -2198,7 +2231,10 @@ impl LocalRepo { .stderr(Stdio::piped()) .spawn() .and_then(|mut c| { - c.stdin.take().unwrap().write_all(input.as_bytes())?; + c.stdin + .take() + .ok_or_else(|| std::io::Error::other("git multi-pack-index stdin"))? + .write_all(input.as_bytes())?; c.wait_with_output() }) .map_err(GitError::Io)?; @@ -2255,7 +2291,7 @@ impl LocalRepo { let tmp = dst.with_extension("commit-graph.tmp"); std::fs::copy(&src, &tmp).map_err(GitError::Io)?; rename_atomic(&tmp, &dst)?; - Ok(std::fs::metadata(&dst).map(|m| m.len()).unwrap_or(0)) + Ok(std::fs::metadata(&dst).map_or(0, |m| m.len())) } /// Hashes listed in `objects/info/commit-graphs/commit-graph-chain` @@ -2285,7 +2321,7 @@ impl LocalRepo { } let hash = commit_graph_layer_hash(&side)?; let chain = self.commit_graph_chain()?; - if chain.first().map(|h| h == &hash).unwrap_or(false) { + if chain.first().is_some_and(|h| h == &hash) { return Ok(true); } let dir = self.commit_graphs_dir(); @@ -2335,7 +2371,7 @@ impl LocalRepo { } let mut input = String::new(); for p in packs { - input.push_str(&format!("pack-{}.idx\n", p.to_hex())); + let _ = writeln!(input, "pack-{}.idx", p.to_hex()); } let mut args = vec!["write", "--split", "--stdin-packs"]; if changed_paths { @@ -2387,7 +2423,7 @@ impl LocalRepo { stderr: String::from_utf8_lossy(&bundle_out.stderr).into_owned(), }); } - let size = std::fs::metadata(out).map(|m| m.len()).unwrap_or(0); + let size = std::fs::metadata(out).map_or(0, |m| m.len()); let pack_offset = locate_pack_offset(out).unwrap_or(size); Ok(BundleInfo { size, pack_offset }) } @@ -2477,7 +2513,7 @@ impl LocalRepo { /// In-process gix upload-pack for protocol v2 fetch. Builds the response /// sections (acknowledgments, shallow-info, wanted-refs, packfile) and - /// generates the pack using gix_pack::data::output. + /// generates the pack using `gix_pack::data::output`. async fn upload_pack_gix( &self, req: UploadPackRequest, @@ -2503,7 +2539,7 @@ impl LocalRepo { stdin_bytes: &[u8], ) -> Result { let path = self.inner.path.clone(); - let args: Vec = args.iter().map(|s| s.to_string()).collect(); + let args: Vec = args.iter().map(ToString::to_string).collect(); let stdin_bytes: Vec = stdin_bytes.to_vec(); let cmd_name = cmd_name.to_string(); let res = tokio::task::spawn_blocking(move || { @@ -2519,7 +2555,10 @@ impl LocalRepo { .stderr(Stdio::piped()); let mut child = cmd.spawn().map_err(GitError::Io)?; { - let stdin = child.stdin.as_mut().unwrap(); + let stdin = child + .stdin + .as_mut() + .ok_or_else(|| GitError::Io(std::io::Error::other("git stdin")))?; stdin.write_all(&stdin_bytes).map_err(GitError::Io)?; } child.wait_with_output().map_err(GitError::Io) @@ -2559,8 +2598,14 @@ impl LocalRepo { .stdout(Stdio::piped()) .stderr(Stdio::piped()); let mut child = cmd.spawn().map_err(GitError::Io)?; - let mut stdin = child.stdin.take().unwrap(); - let mut stdout = child.stdout.take().unwrap(); + let mut stdin = child + .stdin + .take() + .ok_or_else(|| GitError::Io(std::io::Error::other("git upload-pack stdin")))?; + let mut stdout = child + .stdout + .take() + .ok_or_else(|| GitError::Io(std::io::Error::other("git upload-pack stdout")))?; // Copy the request body into stdin first, then close stdin so the // subprocess sees EOF and can finish + exit. Only then drain stdout: // `copy_out` blocks on stdout EOF (subprocess exit), and the subprocess @@ -2630,8 +2675,7 @@ fn unique_suffix() -> String { use std::time::{SystemTime, UNIX_EPOCH}; let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0); + .map_or(0, |d| d.as_nanos()); format!("{}-{}", std::process::id(), nanos) } @@ -2655,11 +2699,13 @@ fn idx_object_count(idx_path: &Path) -> Result { f.read_exact(&mut head).map_err(GitError::Io)?; let is_v2 = &head[..4] == b"\xfftOc"; let fanout_off = if is_v2 { 8 + 255 * 4 } else { 255 * 4 }; - f.seek(std::io::SeekFrom::Start(fanout_off as u64)) - .map_err(GitError::Io)?; + f.seek(std::io::SeekFrom::Start( + u64::try_from(fanout_off).unwrap_or(0), + )) + .map_err(GitError::Io)?; let mut buf = [0u8; 4]; f.read_exact(&mut buf).map_err(GitError::Io)?; - Ok(u32::from_be_bytes(buf) as u64) + Ok(u64::from(u32::from_be_bytes(buf))) } struct IndexPackOutcome { checksum: gix_hash::ObjectId, @@ -2668,8 +2714,8 @@ struct IndexPackOutcome { object_count: u64, /// Time spent copying the pack into index-pack stdin. feed_ms: u64, - /// `exit.t_abs` from GIT_TRACE2_EVENT (whole child). index-pack itself - /// emits no region_leave events today; any that appear (future git) are + /// `exit.t_abs` from `GIT_TRACE2_EVENT` (whole child). index-pack itself + /// emits no `region_leave` events today; any that appear (future git) are /// in `phases`. git_ms: u64, /// Compact `k=ms` list: always `feed` + `git`, plus every TRACE2 @@ -2740,7 +2786,7 @@ fn git_index_pack( let mut file = file; std::io::copy(&mut file, &mut stdin).map_err(GitError::Io)?; } - let feed_ms = feed_started.elapsed().as_millis() as u64; + let feed_ms = u64::try_from(feed_started.elapsed().as_millis()).unwrap_or(u64::MAX); let output = child.wait_with_output().map_err(GitError::Io)?; let trace = std::fs::read_to_string(&trace_path).unwrap_or_default(); let _ = std::fs::remove_file(&trace_path); @@ -2797,6 +2843,11 @@ struct Trace2Phases { regions: Vec<(String, u64)>, } +#[allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + reason = "the saturating float-to-int cast is the intended rounding" +)] fn secs_to_ms(t: f64) -> u64 { let ms = (t * 1000.0).ceil() as u64; if t > 0.0 && ms == 0 { 1 } else { ms } @@ -2807,7 +2858,7 @@ fn json_str_field<'a>(line: &'a str, key: &str) -> Option<&'a str> { let pat = format!("\"{key}\":\""); let rest = line.split_once(&pat)?.1; let end = rest.find('"')?; - Some(&rest[..end]) + rest.get(..end) } fn json_f64_field(line: &str, key: &str) -> Option { @@ -2888,10 +2939,10 @@ fn find_conflict(stderr: &str) -> Option { // git update-ref prints: "cannot lock ref '' ... : ..." or similar. // Best-effort: extract a ref name appearing in a quoted context. for line in stderr.lines() { - if let Some((_, rest)) = line.split_once("cannot lock ref '") { - if let Some((name, _)) = rest.split_once('\'') { - return Some(name.to_string()); - } + if let Some((_, rest)) = line.split_once("cannot lock ref '") + && let Some((name, _)) = rest.split_once('\'') + { + return Some(name.to_string()); } if let Some((_, rest)) = line.split_once("ref ") { // "ref refs/heads/main: expected ..." @@ -2904,6 +2955,10 @@ fn find_conflict(stderr: &str) -> Option { None } +#[allow( + clippy::unnecessary_wraps, + reason = "reading refs is fallible in principle; the Result is the contract callers already handle" +)] pub(crate) fn read_refs(repo_path: &Path) -> Result { // HEAD symbolic target. let head_target = match std::fs::read_to_string(repo_path.join("HEAD")) { @@ -2930,10 +2985,10 @@ pub(crate) fn read_refs(repo_path: &Path) -> Result { continue; } if let Some(rest) = line.strip_prefix('^') { - if let Some(name) = &last { - if let Some((_, peeled)) = map.get_mut(name) { - *peeled = rest.trim().to_string(); - } + if let Some(name) = &last + && let Some((_, peeled)) = map.get_mut(name) + { + *peeled = rest.trim().to_string(); } continue; } @@ -2985,34 +3040,33 @@ pub(crate) fn read_refs(repo_path: &Path) -> Result { } fn walk_loose_refs(dir: &Path, prefix: &str, map: &mut BTreeMap) { - let rd = match std::fs::read_dir(dir) { - Ok(rd) => rd, - Err(_) => return, + let Ok(rd) = std::fs::read_dir(dir) else { + return; }; for ent in rd.flatten() { let path = ent.path(); let name = format!("{prefix}/{}", ent.file_name().to_string_lossy()); if path.is_dir() { walk_loose_refs(&path, &name, map); - } else if path.is_file() { - if let Ok(content) = std::fs::read_to_string(&path) { - let s = content.trim(); - if let Some(t) = s.strip_prefix("ref: ") { - // Symbolic loose ref: resolve target oid later if present. - // We record the target name in the oid slot is wrong; - // instead skip (packed-refs usually has the real value, or - // the symref target is resolved at read time elsewhere). - // For HEAD-only symref we handle separately; loose symrefs - // under refs/ are rare. Record empty oid if unresolved. - let target = t.trim(); - if let Some((o, _)) = map.get(target).cloned() { - map.insert(name, (o, String::new())); - } - continue; - } - if !s.is_empty() { - map.insert(name, (s.to_string(), String::new())); + } else if path.is_file() + && let Ok(content) = std::fs::read_to_string(&path) + { + let s = content.trim(); + if let Some(t) = s.strip_prefix("ref: ") { + // Symbolic loose ref: resolve target oid later if present. + // We record the target name in the oid slot is wrong; + // instead skip (packed-refs usually has the real value, or + // the symref target is resolved at read time elsewhere). + // For HEAD-only symref we handle separately; loose symrefs + // under refs/ are rare. Record empty oid if unresolved. + let target = t.trim(); + if let Some((o, _)) = map.get(target).cloned() { + map.insert(name, (o, String::new())); } + continue; + } + if !s.is_empty() { + map.insert(name, (s.to_string(), String::new())); } } } @@ -3032,12 +3086,11 @@ impl LocalRepo { if u.new_oid.bytes().all(|b| b == b'0') { continue; } - if let Ok(oid) = gix_hash::ObjectId::from_hex(u.new_oid.as_bytes()) { - if let Some(p) = peel_tag(&repo, oid) { - if p != oid { - u.new_peeled = p.to_hex().to_string(); - } - } + if let Ok(oid) = gix_hash::ObjectId::from_hex(u.new_oid.as_bytes()) + && let Some(p) = peel_tag(&repo, oid) + && p != oid + { + u.new_peeled = p.to_hex().to_string(); } } } @@ -3047,9 +3100,8 @@ fn peel_tag(repo: &gix::Repository, oid: gix_hash::ObjectId) -> Option o, - Err(_) => return None, + let Ok(obj) = repo.find_object(cur) else { + return None; }; if obj.kind == gix_object::Kind::Tag { let tag = gix_object::TagRef::from_bytes(&obj.data, kind).ok()?; @@ -3137,7 +3189,7 @@ fn locate_pack_offset(path: &Path) -> Option { if n == 0 { return None; } - if let Some(i) = find_subsequence(&buf[..n], b"PACK") { + if let Some(i) = find_subsequence(buf.get(..n).unwrap_or_default(), b"PACK") { return Some(pos + i as u64); } // Seek back a little to handle boundary splits. @@ -3193,7 +3245,7 @@ struct ConnectivityVisitor<'a> { missing: Option, } -impl<'a> TreeVisit for ConnectivityVisitor<'a> { +impl TreeVisit for ConnectivityVisitor<'_> { fn pop_front_tracked_path_and_set_current(&mut self) {} fn pop_back_tracked_path_and_set_current(&mut self) {} fn push_back_tracked_path_component(&mut self, _c: &gix_object::bstr::BStr) {} @@ -3225,17 +3277,15 @@ impl<'a> TreeVisit for ConnectivityVisitor<'a> { if entry.mode.is_commit() { return std::ops::ControlFlow::Continue(true); } - if self.seen.insert(entry.oid.to_owned()) { - if !self.repo.has_object(entry.oid) { - self.missing = Some(entry.oid.to_owned()); - return std::ops::ControlFlow::Break(()); - } + if self.seen.insert(entry.oid.to_owned()) && !self.repo.has_object(entry.oid) { + self.missing = Some(entry.oid.to_owned()); + return std::ops::ControlFlow::Break(()); } std::ops::ControlFlow::Continue(true) } } -/// Parse a filter spec string into a PackFilter. +/// Parse a filter spec string into a `PackFilter`. #[derive(Debug, Clone)] pub(crate) enum PackFilter { None, @@ -3248,21 +3298,21 @@ pub(crate) fn parse_filter(spec: &str) -> PackFilter { if spec == "blob:none" { return PackFilter::BlobNone; } - if let Some(rest) = spec.strip_prefix("blob:limit=") { - if let Ok(n) = rest.parse::() { - return PackFilter::BlobLimit(n); - } + if let Some(rest) = spec.strip_prefix("blob:limit=") + && let Ok(n) = rest.parse::() + { + return PackFilter::BlobLimit(n); } - if let Some(rest) = spec.strip_prefix("tree:") { - if let Ok(n) = rest.parse::() { - return PackFilter::Tree(n); - } + if let Some(rest) = spec.strip_prefix("tree:") + && let Ok(n) = rest.parse::() + { + return PackFilter::Tree(n); } PackFilter::None } /// Compute the object set for a pack: reachable(wants) - reachable(common -/// haves), honoring filters and include_tag. +/// haves), honoring filters and `include_tag`. /// /// Handles non-commit wants (blobs, trees, tags) for partial-clone lazy fetch /// where the client sends `want ` directly. @@ -3274,7 +3324,7 @@ pub(crate) fn compute_object_set( include_tag: bool, deepen: Option, ) -> Result, GitError> { - let pack_filter = filter.map(parse_filter).unwrap_or(PackFilter::None); + let pack_filter = filter.map_or(PackFilter::None, parse_filter); let mut set: HashSet = HashSet::new(); let mut buf = Vec::new(); let kind = repo.object_hash(); @@ -3292,21 +3342,20 @@ pub(crate) fn compute_object_set( // Follow tag chain to final target. let mut cur = *w; loop { - let obj = repo.find_object(cur).map_err(|e| ge(e))?; + let obj = repo.find_object(cur).map_err(ge)?; if obj.kind != ObjKind::Tag { break; } - let tag = - gix_object::TagRef::from_bytes(&obj.data, kind).map_err(|e| ge(e))?; + let tag = gix_object::TagRef::from_bytes(&obj.data, kind).map_err(ge)?; let target = tag.target(); set.insert(target); cur = target; } // If the final target is a commit, rev-walk from it. - if let Ok(Some(h)) = repo.objects.try_header(&cur) { - if h.kind == ObjKind::Commit { - commit_wants.push(cur); - } + if let Ok(Some(h)) = repo.objects.try_header(&cur) + && h.kind == ObjKind::Commit + { + commit_wants.push(cur); } } ObjKind::Tree => { @@ -3339,10 +3388,10 @@ pub(crate) fn compute_object_set( .rev_walk(commit_wants.iter().copied()) .with_hidden(hidden.iter().copied()) .all() - .map_err(|e| ge(e))?; + .map_err(ge)?; for item in walk { - let info = item.map_err(|e| ge(e))?; + let info = item.map_err(ge)?; let cid = info.id; if !set.insert(cid) { continue; @@ -3353,11 +3402,8 @@ pub(crate) fn compute_object_set( // commit and walking its tree here as well. continue; } - let mut commit = repo - .objects - .find_commit_iter(&cid, &mut buf) - .map_err(|e| ge(e))?; - let tree_id = commit.tree_id().map_err(|e| ge(e))?; + let mut commit = repo.objects.find_commit_iter(&cid, &mut buf).map_err(ge)?; + let tree_id = commit.tree_id().map_err(ge)?; // `tree:0` sends no trees at all, the root included. if matches!(pack_filter, PackFilter::Tree(0)) { @@ -3378,14 +3424,13 @@ pub(crate) fn compute_object_set( if set.contains(&tag_oid) { continue; } - if let Ok(obj) = repo.find_object(tag_oid) { - if obj.kind == ObjKind::Tag { - if let Ok(tag) = gix_object::TagRef::from_bytes(&obj.data, kind) { - let target = tag.target(); - if set.contains(&target) { - set.insert(tag_oid); - } - } + if let Ok(obj) = repo.find_object(tag_oid) + && obj.kind == ObjKind::Tag + && let Ok(tag) = gix_object::TagRef::from_bytes(&obj.data, kind) + { + let target = tag.target(); + if set.contains(&target) { + set.insert(tag_oid); } } } @@ -3408,23 +3453,20 @@ pub(crate) fn walk_tree_with_filter( buf: &mut Vec, ) -> Result<(), GitError> { // Collect entries first to end the mutable borrow of buf before recursing. - let tree_iter = repo - .objects - .find_tree_iter(&tree_id, buf) - .map_err(|e| ge(e))?; + let tree_iter = repo.objects.find_tree_iter(&tree_id, buf).map_err(ge)?; let entries: Vec<(gix_object::tree::EntryMode, gix_hash::ObjectId)> = tree_iter .map(|res| { - let e = res.map_err(|e| ge(e))?; + let e = res.map_err(ge)?; Ok((e.mode, e.oid.to_owned())) }) .collect::>()?; for (mode, oid) in entries { if mode.is_tree() { - if let PackFilter::Tree(max_depth) = filter { - if depth + 1 > *max_depth { - set.insert(oid); - continue; - } + if let PackFilter::Tree(max_depth) = filter + && depth + 1 > *max_depth + { + set.insert(oid); + continue; } if set.insert(oid) { walk_tree_with_filter(repo, oid, set, filter, depth + 1, buf)?; @@ -3437,12 +3479,11 @@ pub(crate) fn walk_tree_with_filter( if matches!(filter, PackFilter::BlobNone) { continue; } - if let PackFilter::BlobLimit(limit) = filter { - if let Ok(Some(hdr)) = repo.objects.try_header(&oid) { - if hdr.size > *limit { - continue; - } - } + if let PackFilter::BlobLimit(limit) = filter + && let Ok(Some(hdr)) = repo.objects.try_header(&oid) + && hdr.size > *limit + { + continue; } set.insert(oid); } @@ -3479,12 +3520,8 @@ pub(crate) fn compute_shallow( if !seen.insert(*cid) { continue; } - let commit = repo - .objects - .find_commit_iter(cid, &mut buf) - .map_err(|e| ge(e))?; - let parents: Vec = - commit.parent_ids().map(|p| p.to_owned()).collect(); + let commit = repo.objects.find_commit_iter(cid, &mut buf).map_err(ge)?; + let parents: Vec = commit.parent_ids().collect(); if d == depth.max(1) { if !parents.is_empty() { shallow.push(*cid); @@ -3504,6 +3541,10 @@ pub(crate) fn compute_shallow( } /// Compute the SHA checksum trailer for a pack header (used for empty packs). +#[allow( + clippy::expect_used, + reason = "a wrong trailer is worse than a panic, and the hasher cannot fail here" +)] pub(crate) fn compute_pack_trailer(data: &[u8], kind: gix_hash::Kind) -> gix_hash::ObjectId { use gix_hash::hasher; let mut h = hasher(kind); @@ -3517,20 +3558,22 @@ pub(crate) fn compute_pack_trailer(data: &[u8], kind: gix_hash::Kind) -> gix_has fn commit_graph_layer_hash(path: &Path) -> Result { let data = std::fs::read(path).map_err(GitError::Io)?; // Header: "CGPH" version(1) hash-version(1) chunks(1) base-graphs(1) - if data.len() < 8 || &data[..4] != b"CGPH" { + if data.len() < 8 || !data.starts_with(b"CGPH") { return Err(GitError::InvalidInput(format!( "{} is not a commit-graph", path.display() ))); } - let len = if data[5] == 2 { 32 } else { 20 }; + let len = if data.get(5) == Some(&2) { 32 } else { 20 }; if data.len() < 8 + len { return Err(GitError::InvalidInput(format!( "{} is truncated", path.display() ))); } - Ok(hex::encode(&data[data.len() - len..])) + Ok(hex::encode( + data.get(data.len() - len..).unwrap_or_default(), + )) } /// Derive a pack's reverse index (`.rev`, RIDX v1) from its `.idx`: header @@ -3551,7 +3594,7 @@ pub fn write_rev_from_idx( let mut by_offset: Vec<(u64, u32)> = index .iter() .enumerate() - .map(|(i, e)| (e.pack_offset, i as u32)) + .map(|(i, e)| (e.pack_offset, u32::try_from(i).unwrap_or(u32::MAX))) .collect(); by_offset.sort_unstable(); let mut out = Vec::with_capacity(12 + 4 * n as usize + 2 * kind.len_in_bytes()); @@ -3644,8 +3687,8 @@ mod index_pack_trace_tests { .spawn() .unwrap(); { - let mut stdin = child.stdin.take().unwrap(); use std::io::Write; + let mut stdin = child.stdin.take().unwrap(); stdin.write_all(b"HEAD\n").unwrap(); } let out = child.wait_with_output().unwrap(); diff --git a/crates/walgit-git/src/pkt.rs b/crates/walgit-git/src/pkt.rs index a5c7dee..8809cb7 100644 --- a/crates/walgit-git/src/pkt.rs +++ b/crates/walgit-git/src/pkt.rs @@ -78,7 +78,7 @@ pub async fn read_pkt_line(r: &mut R) -> Result return Ok(Some(PktLine::Flush)), 1 => return Ok(Some(PktLine::Delim)), @@ -101,8 +101,8 @@ async fn read_exact_or_eof( buf: &mut [u8], ) -> Result { let mut filled = 0; - while filled < buf.len() { - let n = r.read(&mut buf[filled..]).await.map_err(io_to_git)?; + while let Some(dst) = buf.get_mut(filled..).filter(|d| !d.is_empty()) { + let n = r.read(dst).await.map_err(io_to_git)?; if n == 0 { break; } @@ -111,9 +111,9 @@ async fn read_exact_or_eof( Ok(filled) } -fn parse_pkt_len(hdr: &[u8; 4]) -> Result { +fn parse_pkt_len(hdr: [u8; 4]) -> Result { let mut val = 0usize; - for &b in hdr { + for b in hdr { let d = match b { b'0'..=b'9' => b - b'0', b'a'..=b'f' => b - b'a' + 10, @@ -136,17 +136,12 @@ pub async fn write_pkt_line(w: &mut W, data: &[u8]) -> Re w.write_all(b"0004").await.map_err(io_to_git)?; return Ok(()); } - let mut off = 0; - while off < data.len() { - let chunk = (data.len() - off).min(MAX_PKT_DATA); - let total = chunk + 4; + for chunk in data.chunks(MAX_PKT_DATA) { + let total = chunk.len() + 4; w.write_all(pkt_len_hex(total).as_bytes()) .await .map_err(io_to_git)?; - w.write_all(&data[off..off + chunk]) - .await - .map_err(io_to_git)?; - off += chunk; + w.write_all(chunk).await.map_err(io_to_git)?; } Ok(()) } @@ -212,20 +207,14 @@ impl Sideband { self.w.write_all(&[channel]).await.map_err(io_to_git)?; return Ok(()); } - let mut off = 0; - while off < buf.len() { - let chunk = (buf.len() - off).min(MAX); - let total = chunk + 4 + 1; + for chunk in buf.chunks(MAX) { + let total = chunk.len() + 4 + 1; self.w .write_all(pkt_len_hex(total).as_bytes()) .await .map_err(io_to_git)?; self.w.write_all(&[channel]).await.map_err(io_to_git)?; - self.w - .write_all(&buf[off..off + chunk]) - .await - .map_err(io_to_git)?; - off += chunk; + self.w.write_all(chunk).await.map_err(io_to_git)?; } Ok(()) } @@ -251,7 +240,7 @@ pub struct V2Command { impl V2Command { pub fn cap(&self, key: &str) -> Option<&str> { - self.caps.get(key).map(|s| s.as_str()) + self.caps.get(key).map(String::as_str) } pub fn has_cap(&self, key: &str) -> bool { self.caps.contains_key(key) @@ -352,7 +341,7 @@ pub async fn read_ls_refs_args( let line = String::from_utf8_lossy(&data); parse_ls_refs_line(&mut req, line.trim_end()); } - Some(PktLine::Delim) => continue, + Some(PktLine::Delim) => {} Some(PktLine::Flush | PktLine::ResponseEnd) | None => break, } } @@ -385,14 +374,10 @@ fn io_to_git(e: std::io::Error) -> GitError { /// Encode a literal data pkt-line into a buffer (sync helper for building /// advertisement/section bytes). pub fn encode_data(buf: &mut Vec, data: &[u8]) { - let total = data.len() + 4; const HEX: &[u8; 16] = b"0123456789abcdef"; - buf.extend_from_slice(&[ - HEX[(total >> 12) & 0xf], - HEX[(total >> 8) & 0xf], - HEX[(total >> 4) & 0xf], - HEX[total & 0xf], - ]); + let total = data.len() + 4; + let nib = |shift: usize| HEX.get((total >> shift) & 0xf).copied().unwrap_or(b'0'); + buf.extend_from_slice(&[nib(12), nib(8), nib(4), nib(0)]); buf.extend_from_slice(data); } pub fn encode_flush(buf: &mut Vec) { diff --git a/crates/walgit-git/src/receive.rs b/crates/walgit-git/src/receive.rs index 8b57a6a..e57518c 100644 --- a/crates/walgit-git/src/receive.rs +++ b/crates/walgit-git/src/receive.rs @@ -16,6 +16,10 @@ use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt}; use crate::pkt::{self, PktLine}; use crate::{GitError, RefSnapshotData}; +#[allow( + clippy::struct_excessive_bools, + reason = "one field per capability the client advertised; the protocol defines them independently" +)] /// Capabilities negotiated by the client in the first receive-pack command. #[derive(Debug, Default, Clone)] pub struct ReceiveCaps { @@ -64,7 +68,9 @@ impl AsyncRead for PrefixedReader { if !this.prefix.is_empty() { let n = this.prefix.len().min(buf.remaining()); for _ in 0..n { - buf.put_slice(&[this.prefix.pop_front().unwrap()]); + if let Some(b) = this.prefix.pop_front() { + buf.put_slice(&[b]); + } } return std::task::Poll::Ready(Ok(())); } @@ -99,8 +105,9 @@ pub async fn parse( let first = loop { match pkt::read_pkt_line(&mut r).await? { Some(PktLine::Data(b)) if b.starts_with(b"shallow ") => { + let oid = b.get(8..).unwrap_or_default(); caps.shallow - .push(String::from_utf8_lossy(&b[8..]).trim().to_string()); + .push(String::from_utf8_lossy(oid).trim().to_string()); } other => break other, } @@ -115,7 +122,7 @@ pub async fn parse( }; return Ok((txn, caps, PrefixedReader::new(Vec::new(), r))); } - Some(PktLine::Delim) | Some(PktLine::ResponseEnd) => { + Some(PktLine::Delim | PktLine::ResponseEnd) => { return Err(GitError::Protocol( "unexpected delim before commands".into(), )); @@ -134,11 +141,11 @@ pub async fn parse( loop { let line = pkt::read_pkt_line(&mut r).await?; match line { - None | Some(PktLine::Flush) => break, - Some(PktLine::Delim) | Some(PktLine::ResponseEnd) => break, + None | Some(PktLine::Flush | PktLine::Delim | PktLine::ResponseEnd) => break, Some(PktLine::Data(b)) if b.starts_with(b"shallow ") => { + let oid = b.get(8..).unwrap_or_default(); caps.shallow - .push(String::from_utf8_lossy(&b[8..]).trim().to_string()); + .push(String::from_utf8_lossy(oid).trim().to_string()); } Some(PktLine::Data(b)) => { let (update, _) = parse_command_line(&b)?; @@ -152,8 +159,7 @@ pub async fn parse( loop { let line = pkt::read_pkt_line(&mut r).await?; match line { - None | Some(PktLine::Flush) => break, - Some(PktLine::Delim) | Some(PktLine::ResponseEnd) => break, + None | Some(PktLine::Flush | PktLine::Delim | PktLine::ResponseEnd) => break, Some(PktLine::Data(b)) => { push_options.push( String::from_utf8_lossy(&b) @@ -175,10 +181,11 @@ pub async fn parse( fn parse_command_line(b: &[u8]) -> Result<(walgit_proto::v1::RefUpdate, String), GitError> { // First line: " \0". Subsequent lines have no caps. - let (cmd_bytes, caps_bytes) = match b.iter().position(|&c| c == 0) { - Some(idx) => (&b[..idx], &b[idx + 1..]), - None => (b, &b[..0]), - }; + let (cmd_bytes, caps_bytes) = b + .iter() + .position(|&c| c == 0) + .and_then(|idx| Some((b.get(..idx)?, b.get(idx + 1..)?))) + .unwrap_or((b, &[])); let s = String::from_utf8_lossy(cmd_bytes); let s = s.trim_end_matches('\n'); let mut parts = s.splitn(3, ' '); @@ -203,7 +210,7 @@ fn parse_command_line(b: &[u8]) -> Result<(walgit_proto::v1::RefUpdate, String), } fn apply_caps(caps: &mut ReceiveCaps, s: &str) { - for tok in s.split(|c: char| c == ' ' || c == '\n') { + for tok in s.split([' ', '\n']) { let tok = tok.trim(); if tok.is_empty() { continue; @@ -216,9 +223,9 @@ fn apply_caps(caps: &mut ReceiveCaps, s: &str) { "quiet" => caps.quiet = true, "push-options" => caps.push_options = true, "ofs-delta" => caps.ofs_delta = true, - _ if tok.starts_with("agent=") => caps.agent = Some(tok[6..].to_string()), - _ if tok.starts_with("object-format=") => { - caps.object_format = Some(tok[14..].to_string()) + _ if let Some(v) = tok.strip_prefix("agent=") => caps.agent = Some(v.to_string()), + _ if let Some(v) = tok.strip_prefix("object-format=") => { + caps.object_format = Some(v.to_string()); } _ => {} } @@ -298,7 +305,7 @@ pub async fn report_status( /// Convenience: build a [`RefTransaction`] from a ref snapshot diff is not /// provided; callers construct transactions directly. This helper converts a -/// [`RefSnapshotData`] into a transaction that creates all refs (old_oid = +/// [`RefSnapshotData`] into a transaction that creates all refs (`old_oid` = /// zero), useful for materializing a checkpoint. pub fn txn_from_snapshot(snap: &RefSnapshotData) -> walgit_proto::v1::RefTransaction { let mut updates: Vec = snap diff --git a/crates/walgit-git/src/repair.rs b/crates/walgit-git/src/repair.rs index 9888734..20a00ba 100644 --- a/crates/walgit-git/src/repair.rs +++ b/crates/walgit-git/src/repair.rs @@ -110,7 +110,10 @@ pub async fn fetch_objects_as_pack( .map_err(GitError::Io)?; { use tokio::io::AsyncWriteExt; - let mut stdin = child.stdin.take().expect("stdin"); + let mut stdin = child + .stdin + .take() + .ok_or_else(|| GitError::Io(std::io::Error::other("git index-pack stdin")))?; let mut input = oids.join("\n"); input.push('\n'); stdin @@ -141,7 +144,7 @@ pub async fn fetch_objects_as_pack( let mut first_missing = None; for o in oids { match gix_hash::ObjectId::from_hex(o.as_bytes()) { - Ok(id) if index.lookup(&id).is_some() => objects += 1, + Ok(id) if index.lookup(id).is_some() => objects += 1, _ => { first_missing.get_or_insert(o.as_str()); } diff --git a/crates/walgit-git/src/upload_gix.rs b/crates/walgit-git/src/upload_gix.rs index e5fffed..97b9eb1 100644 --- a/crates/walgit-git/src/upload_gix.rs +++ b/crates/walgit-git/src/upload_gix.rs @@ -28,6 +28,7 @@ //! (`PackCopyAndBaseObjects`); loose (faulted) objects are compressed fresh. use std::collections::{HashMap, HashSet}; +use std::fmt::Write as _; use futures::future::BoxFuture; use gix_object::{Find, FindHeader, Kind as ObjKind}; @@ -72,7 +73,7 @@ fn blocking_section(f: impl FnOnce() -> T) -> T { } /// Write one section line, wrapped in a band-1 frame when the client asked -/// for `sideband-all` (flush/delim stay raw, as in git's packet_writer). +/// for `sideband-all` (flush/delim stay raw, as in git's `packet_writer`). fn line(buf: &mut Vec, data: &[u8], sideband_all: bool) { if sideband_all { let mut framed = Vec::with_capacity(data.len() + 1); @@ -207,10 +208,10 @@ impl LocalRepo { ) -> Result { let mut header = String::from("# v2 git bundle\n"); for p in prerequisites { - header.push_str(&format!("-{} \n", p.to_hex())); + let _ = writeln!(header, "-{} ", p.to_hex()); } for (name, oid) in refs { - header.push_str(&format!("{} {name}\n", oid.to_hex())); + let _ = writeln!(header, "{} {name}", oid.to_hex()); } header.push('\n'); out.write_all(header.as_bytes()) @@ -271,7 +272,10 @@ impl LocalRepo { let found = f.fault(&missing).await?; if found < missing.len() { return Err(GitError::MissingObject { - oid: missing[0].to_hex().to_string(), + oid: missing + .first() + .map(|o| o.to_hex().to_string()) + .unwrap_or_default(), }); } self.refresh_async().await?; @@ -279,11 +283,7 @@ impl LocalRepo { } // ---- enumerate (sync, retried after faulting missing objects) ---- - let filter = req - .filter - .as_deref() - .map(parse_filter) - .unwrap_or(PackFilter::None); + let filter = req.filter.as_deref().map_or(PackFilter::None, parse_filter); let has_filter = req.filter.is_some(); let mut rounds = 0usize; let (set, commits, diffed) = loop { @@ -291,7 +291,7 @@ impl LocalRepo { // seconds on big ranges: never on an async worker (D19). let attempt = blocking_section(|| { let repo = self.gix(); - enumerate(&repo, &req, &common_haves, &filter, faulter) + enumerate(&repo, req, common_haves, &filter, faulter) })?; match attempt { Enumerated::Done { @@ -303,7 +303,10 @@ impl LocalRepo { rounds += 1; let Some(f) = faulter else { return Err(GitError::MissingObject { - oid: missing[0].to_hex().to_string(), + oid: missing + .first() + .map(|o| o.to_hex().to_string()) + .unwrap_or_default(), }); }; if rounds > MAX_FAULT_ROUNDS { @@ -320,7 +323,10 @@ impl LocalRepo { let found = f.fault(&missing).await?; if found == 0 { return Err(GitError::MissingObject { - oid: missing[0].to_hex().to_string(), + oid: missing + .first() + .map(|o| o.to_hex().to_string()) + .unwrap_or_default(), }); } self.refresh_async().await?; @@ -354,7 +360,10 @@ impl LocalRepo { if !missing.is_empty() { let Some(f) = faulter else { return Err(GitError::MissingObject { - oid: missing[0].to_hex().to_string(), + oid: missing + .first() + .map(|o| o.to_hex().to_string()) + .unwrap_or_default(), }); }; sink.progress(&format!( @@ -365,7 +374,10 @@ impl LocalRepo { let found = f.fault(&missing).await?; if found < missing.len() { return Err(GitError::MissingObject { - oid: missing[0].to_hex().to_string(), + oid: missing + .first() + .map(|o| o.to_hex().to_string()) + .unwrap_or_default(), }); } self.refresh_async().await?; @@ -419,8 +431,8 @@ impl LocalRepo { .await .map_err(|e| GitError::Protocol(format!("pack generator panicked: {e}")))??; tracing::debug!( - enumerate_ms = t_enum.as_millis() as u64, - total_ms = t_start.elapsed().as_millis() as u64, + enumerate_ms = u64::try_from(t_enum.as_millis()).unwrap_or(u64::MAX), + total_ms = u64::try_from(t_start.elapsed().as_millis()).unwrap_or(u64::MAX), objects = num_objects, bytes, rounds, @@ -429,7 +441,7 @@ impl LocalRepo { sink.progress(&format!("Total {num_objects} objects, {bytes} bytes\n")) .await; Ok(UploadPackStats { - objects: num_objects as u64, + objects: u64::from(num_objects), bytes, }) } @@ -456,7 +468,7 @@ impl PackOut { } PackOut::Sideband { .. } => {} PackOut::Raw(_) => { - tracing::debug!(target: "walgit_git::upload_gix", "{}", text.trim_end()) + tracing::debug!(target: "walgit_git::upload_gix", "{}", text.trim_end()); } } } @@ -494,7 +506,7 @@ struct ChanWriter { impl ChanWriter { fn flush_all(&mut self) -> Result<(), GitError> { if !self.buf.is_empty() { - let chunk = std::mem::replace(&mut self.buf, Vec::new()); + let chunk = std::mem::take(&mut self.buf); self.tx.blocking_send(chunk).map_err(|_| { GitError::Io(std::io::Error::new( std::io::ErrorKind::BrokenPipe, @@ -568,7 +580,9 @@ fn generate_pack_streaming( let thread_limit = if small { Some(1) } else { - std::thread::available_parallelism().map(|n| n.get()).ok() + std::thread::available_parallelism() + .map(std::num::NonZero::get) + .ok() }; let chunk_size = if small { 64 } else { 256 }; let interrupt = std::sync::atomic::AtomicBool::new(false); @@ -592,7 +606,7 @@ fn generate_pack_streaming( out.write_all(&buf).map_err(GitError::Io)?; return Ok(0); } - let num_entries = counts.len() as u32; + let num_entries = u32::try_from(counts.len()).unwrap_or(u32::MAX); let progress: Box = Box::new(gix_features::progress::Discard); let entries = entry::iter_from_counts( @@ -609,14 +623,14 @@ fn generate_pack_streaming( }, ); let entries_in_order = gix_features::parallel::InOrderIter::from(entries); - let mut pack_iter = FromEntriesIter::new( + let pack_iter = FromEntriesIter::new( entries_in_order, out, num_entries, PackVersion::V2, object_hash, ); - while let Some(result) = pack_iter.next() { + for result in pack_iter { result.map_err(ge)?; } Ok(num_entries) @@ -793,19 +807,16 @@ fn enumerate( let mut parent_trees: Vec = Vec::with_capacity(parent_ids.len()); let mut deferred = false; for p in &parent_ids { - match repo.objects.try_find(p, &mut buf).map_err(GitError::Gix)? { - Some(obj) => { - match gix_object::CommitRefIter::from_bytes(obj.data, kind).tree_id() { - Ok(t) => parent_trees.push(Old::Tree(t)), - Err(e) => return Err(ge(e)), - } - } - None => { - // Parent commit not local (base): fault it, diff this - // commit on the retry. - missing.push(*p); - deferred = true; + if let Some(obj) = repo.objects.try_find(p, &mut buf).map_err(GitError::Gix)? { + match gix_object::CommitRefIter::from_bytes(obj.data, kind).tree_id() { + Ok(t) => parent_trees.push(Old::Tree(t)), + Err(e) => return Err(ge(e)), } + } else { + // Parent commit not local (base): fault it, diff this + // commit on the retry. + missing.push(*p); + deferred = true; } } if deferred { @@ -835,24 +846,22 @@ fn enumerate( } // include-tag: annotated tags whose target is in the set. - if req.include_tag { - if let Ok(snap) = crate::read_refs(repo.path()) { - for r in &snap.refs { - let Ok(tag_oid) = gix_hash::ObjectId::from_hex(r.oid.as_bytes()) else { - continue; - }; - if set.contains(&tag_oid) { - continue; - } - if let Ok(Some(obj)) = repo.objects.try_find(&tag_oid, &mut buf) { - if obj.kind == ObjKind::Tag { - if let Ok(tag) = gix_object::TagRef::from_bytes(obj.data, kind) { - if set.contains(&tag.target()) { - set.insert(tag_oid); - } - } - } - } + if req.include_tag + && let Ok(snap) = crate::read_refs(repo.path()) + { + for r in &snap.refs { + let Ok(tag_oid) = gix_hash::ObjectId::from_hex(r.oid.as_bytes()) else { + continue; + }; + if set.contains(&tag_oid) { + continue; + } + if let Ok(Some(obj)) = repo.objects.try_find(&tag_oid, &mut buf) + && obj.kind == ObjKind::Tag + && let Ok(tag) = gix_object::TagRef::from_bytes(obj.data, kind) + && set.contains(&tag.target()) + { + set.insert(tag_oid); } } } @@ -923,15 +932,14 @@ fn diff_tree_new_objects( for o in olds { match o { Old::Absent => old_maps.push(HashMap::new()), - Old::Tree(oid) => match tree_entries(repo, oid, buf)? { - Some(entries) => { - old_maps.push(entries.into_iter().map(|(m, n, o)| (n, (m, o))).collect()) - } - None => { + Old::Tree(oid) => { + if let Some(entries) = tree_entries(repo, oid, buf)? { + old_maps.push(entries.into_iter().map(|(m, n, o)| (n, (m, o))).collect()); + } else { missing.push(*oid); deferred = true; } - }, + } } } if deferred { @@ -953,11 +961,11 @@ fn diff_tree_new_objects( continue; } if mode.is_tree() { - if let PackFilter::Tree(max) = filter { - if depth + 1 > *max { - set.insert(oid); - continue; - } + if let PackFilter::Tree(max) = filter + && depth + 1 > *max + { + set.insert(oid); + continue; } if set.insert(oid) { let sub_olds: Vec = old_maps @@ -973,10 +981,10 @@ fn diff_tree_new_objects( match filter { PackFilter::BlobNone => continue, PackFilter::BlobLimit(limit) => { - if let Ok(Some(h)) = repo.objects.try_header(&oid) { - if h.size > *limit { - continue; - } + if let Ok(Some(h)) = repo.objects.try_header(&oid) + && h.size > *limit + { + continue; } } _ => {} @@ -1022,8 +1030,10 @@ mod frozen_source_tests { // Two packs with distinct content, newest first in gix's load order. let mut blobs = Vec::new(); for (i, words) in ["one pack", "two pack"].iter().enumerate() { + use std::io::Write; let content = format!("{words} {}\n", "x".repeat(300 + i * 50)); let oid = { + use std::io::Write; let mut c = std::process::Command::new("git") .arg("-C") .arg(dir) @@ -1032,7 +1042,6 @@ mod frozen_source_tests { .stdout(std::process::Stdio::piped()) .spawn() .unwrap(); - use std::io::Write; c.stdin .take() .unwrap() @@ -1052,7 +1061,6 @@ mod frozen_source_tests { .stdout(std::process::Stdio::piped()) .spawn() .unwrap(); - use std::io::Write; c.stdin .take() .unwrap() @@ -1111,8 +1119,10 @@ mod frozen_source_tests { // slot (same path). Keep adding packs until that happens. let mut shifted = false; for i in 0..24 { + use std::io::Write; let content = format!("later pack {i} {}\n", "y".repeat(200 + i)); let oid = { + use std::io::Write; let mut c = std::process::Command::new("git") .arg("-C") .arg(dir) @@ -1121,7 +1131,6 @@ mod frozen_source_tests { .stdout(std::process::Stdio::piped()) .spawn() .unwrap(); - use std::io::Write; c.stdin .take() .unwrap() @@ -1140,7 +1149,6 @@ mod frozen_source_tests { .stdout(std::process::Stdio::piped()) .spawn() .unwrap(); - use std::io::Write; c.stdin .take() .unwrap() diff --git a/crates/walgit-git/tests/commit_graph.rs b/crates/walgit-git/tests/commit_graph.rs index 9344717..6d2ad13 100644 --- a/crates/walgit-git/tests/commit_graph.rs +++ b/crates/walgit-git/tests/commit_graph.rs @@ -1,3 +1,14 @@ +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing, + clippy::many_single_char_names +)] +// clippy.toml exempts #[test] functions from the panic-path lints, but not the plain +// helper functions beside them in the same file. A panic in a fixture builder is how +// that fixture reports it could not be built, exactly as in the tests it serves. + mod common; use std::process::Command; diff --git a/crates/walgit-git/tests/common/mod.rs b/crates/walgit-git/tests/common/mod.rs index b22926b..df8873a 100644 --- a/crates/walgit-git/tests/common/mod.rs +++ b/crates/walgit-git/tests/common/mod.rs @@ -1,6 +1,16 @@ //! Shared helpers for walgit-git integration tests: build synthetic repos with //! upstream `git` and produce packs via `git pack-objects`. Each test binary //! uses a subset, so unused-item warnings are expected here. +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing, + clippy::many_single_char_names +)] +// clippy.toml exempts #[test] functions from the panic-path lints, but not the plain +// helper functions beside them in the same file. A panic in a fixture builder is how +// that fixture reports it could not be built, exactly as in the tests it serves. #![allow(dead_code)] use std::path::PathBuf; @@ -14,7 +24,7 @@ pub struct SourceRepo { _tmp: TempDir, } -/// Owned cursor satisfying `AsyncRead + Unpin + Send + 'static` (ingest_pack +/// Owned cursor satisfying `AsyncRead + Unpin + Send + 'static` (`ingest_pack` /// requires `'static`, so a borrowed `&[u8]` won't do). pub fn cursor(b: Vec) -> std::io::Cursor> { std::io::Cursor::new(b) @@ -162,14 +172,13 @@ pub fn run_git(dir: &std::path::Path, args: &[&str]) -> String { .current_dir(dir) .args(args) .output() - .unwrap_or_else(|e| panic!("git {:?}: {e}", args)); - if !out.status.success() { - panic!( - "git {:?} failed: {}", - args, - String::from_utf8_lossy(&out.stderr) - ); - } + .unwrap_or_else(|e| panic!("git {args:?}: {e}")); + assert!( + out.status.success(), + "git {:?} failed: {}", + args, + String::from_utf8_lossy(&out.stderr) + ); String::from_utf8_lossy(&out.stdout).into_owned() } @@ -249,7 +258,7 @@ pub fn extract_packfile(response: &[u8]) -> Vec { } else { &b[..] }; - if line.strip_suffix(b"\n").map_or(false, |s| s == b"packfile") { + if line.strip_suffix(b"\n").is_some_and(|s| s == b"packfile") { in_packfile = true; } continue; @@ -284,7 +293,7 @@ pub fn has_nak(response: &[u8]) -> bool { } /// Count object types in a pack file via `git verify-pack -v` in a fresh bare -/// repo. Returns (num_blobs, num_commits, num_trees, num_tags). +/// repo. Returns (`num_blobs`, `num_commits`, `num_trees`, `num_tags`). pub fn pack_object_types(pack: &[u8]) -> (u64, u64, u64, u64) { let tmp = fresh_bare(); // Write the pack and index it. diff --git a/crates/walgit-git/tests/connectivity.rs b/crates/walgit-git/tests/connectivity.rs index 8f73e90..61e76ea 100644 --- a/crates/walgit-git/tests/connectivity.rs +++ b/crates/walgit-git/tests/connectivity.rs @@ -1,3 +1,14 @@ +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing, + clippy::many_single_char_names +)] +// clippy.toml exempts #[test] functions from the panic-path lints, but not the plain +// helper functions beside them in the same file. A panic in a fixture builder is how +// that fixture reports it could not be built, exactly as in the tests it serves. + mod common; use walgit_git::{GitError, IngestOptions, LocalRepo, ObjectFormat, RepoId, gix_hash}; diff --git a/crates/walgit-git/tests/ingest.rs b/crates/walgit-git/tests/ingest.rs index cbd434e..57bfdd0 100644 --- a/crates/walgit-git/tests/ingest.rs +++ b/crates/walgit-git/tests/ingest.rs @@ -1,5 +1,17 @@ +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing, + clippy::many_single_char_names +)] +// clippy.toml exempts #[test] functions from the panic-path lints, but not the plain +// helper functions beside them in the same file. A panic in a fixture builder is how +// that fixture reports it could not be built, exactly as in the tests it serves. + mod common; +use std::fmt::Write as _; use std::path::Path; use std::io::Write; @@ -41,7 +53,7 @@ async fn ingest_pack_objects_present_fsck_ok() { assert!(ingested.object_count > 0); assert_eq!( ingested.pack_path.file_name().unwrap().to_string_lossy(), - format!("pack-{}.pack", checksum) + format!("pack-{checksum}.pack") ); // Objects present. @@ -232,17 +244,18 @@ async fn ingest_large_delta_pack() { ); let mut stream = String::new(); for i in 1..=2000 { - stream.push_str(&format!( - "commit refs/heads/main\nmark :{i}\nauthor bench {i} +0000\ncommitter bench {i} +0000\n" - )); + let _ = writeln!( + stream, + "commit refs/heads/main\nmark :{i}\nauthor bench {i} +0000\ncommitter bench {i} +0000" + ); let message = format!("commit {i}\n"); - stream.push_str(&format!("data {}\n{}\n", message.len(), message)); + let _ = writeln!(stream, "data {}\n{}", message.len(), message); if i > 1 { - stream.push_str(&format!("from :{}\n", i - 1)); + let _ = writeln!(stream, "from :{}", i - 1); } stream.push_str("M 100644 inline file.txt\n"); let content = format!("content {i} {}\n", "x".repeat(256)); - stream.push_str(&format!("data {}\n{}\n", content.len(), content)); + let _ = writeln!(stream, "data {}\n{}", content.len(), content); } let mut fast_import = Command::new("git") .current_dir(source.path()) @@ -276,7 +289,7 @@ async fn ingest_large_delta_pack() { out.stdout }; assert!(full.starts_with(b"PACK")); - let expected_count = u32::from_be_bytes(full[8..12].try_into().unwrap()) as u64; + let expected_count = u64::from(u32::from_be_bytes(full[8..12].try_into().unwrap())); let full_root = tempfile::TempDir::new().unwrap(); let full_repo = LocalRepo::init( @@ -401,7 +414,10 @@ async fn ingest_failures_name_the_cause_and_leave_nothing_behind() { let src = cm::SourceRepo::new(); // A big blob, then a one-line edit: `pack-objects --thin ^a b` deltas the new blob against the // excluded one, so the thin pack really has an external base (tiny files produce no delta). - let big: String = (0..4000).map(|i| format!("line {i}\n")).collect(); + let mut big = String::new(); + for i in 0..4000 { + let _ = writeln!(big, "line {i}"); + } let a = src.commit_file("big.txt", &big, "big"); let b = src.commit_file("big.txt", &format!("{big}tail\n"), "edit"); let opts = |thin: bool, max_bytes: Option| IngestOptions { @@ -409,15 +425,14 @@ async fn ingest_failures_name_the_cause_and_leave_nothing_behind() { max_bytes, thin, }; - let pack_count = || repo.packs().map(|p| p.len()).unwrap_or(0); + let pack_count = || repo.packs().map_or(0, |p| p.len()); // 1. Oversize: refused while streaming, before index-pack ever runs. let full = src.pack(&[b.as_str()], &[], false); let err = repo .ingest_pack(cm::cursor(full.clone()), opts(false, Some(64))) .await - .err() - .expect("too big"); + .expect_err("too big"); assert!(err.to_string().contains("max_bytes 64"), "{err}"); assert_eq!(pack_count(), 0); @@ -428,8 +443,7 @@ async fn ingest_failures_name_the_cause_and_leave_nothing_behind() { let err = repo .ingest_pack(cm::cursor(corrupt), opts(false, None)) .await - .err() - .expect("corrupt"); + .expect_err("corrupt"); let s = err.to_string(); assert!( s.contains("index-pack") @@ -452,8 +466,7 @@ async fn ingest_failures_name_the_cause_and_leave_nothing_behind() { let err = repo .ingest_pack(cm::cursor(thin.clone()), opts(true, None)) .await - .err() - .expect("no base"); + .expect_err("no base"); assert!(err.to_string().contains("index-pack"), "{err}"); assert_eq!(pack_count(), 0); @@ -461,8 +474,7 @@ async fn ingest_failures_name_the_cause_and_leave_nothing_behind() { let err = repo .ingest_pack(cm::cursor(thin), opts(false, None)) .await - .err() - .expect("thin without fix-thin"); + .expect_err("thin without fix-thin"); assert!(err.to_string().contains("index-pack"), "{err}"); assert_eq!(pack_count(), 0); @@ -472,8 +484,7 @@ async fn ingest_failures_name_the_cause_and_leave_nothing_behind() { let err = repo .ingest_pack(cm::cursor(pack.clone()), opts(false, None)) .await - .err() - .expect("fsck"); + .expect_err("fsck"); assert!(err.to_string().contains("index-pack"), "{err}"); assert_eq!(pack_count(), 0); // …and accepted with fsck off (the knob is `wal.fsck_objects`). diff --git a/crates/walgit-git/tests/ls_refs.rs b/crates/walgit-git/tests/ls_refs.rs index f6a350b..fefcb2a 100644 --- a/crates/walgit-git/tests/ls_refs.rs +++ b/crates/walgit-git/tests/ls_refs.rs @@ -1,3 +1,14 @@ +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing, + clippy::many_single_char_names +)] +// clippy.toml exempts #[test] functions from the panic-path lints, but not the plain +// helper functions beside them in the same file. A panic in a fixture builder is how +// that fixture reports it could not be built, exactly as in the tests it serves. + mod common; use walgit_git::{LocalRepo, LsRefsArgs, ObjectFormat, RepoId, gix_hash}; diff --git a/crates/walgit-git/tests/refs.rs b/crates/walgit-git/tests/refs.rs index 7ff69f7..150e9c5 100644 --- a/crates/walgit-git/tests/refs.rs +++ b/crates/walgit-git/tests/refs.rs @@ -1,3 +1,14 @@ +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing, + clippy::many_single_char_names +)] +// clippy.toml exempts #[test] functions from the panic-path lints, but not the plain +// helper functions beside them in the same file. A panic in a fixture builder is how +// that fixture reports it could not be built, exactly as in the tests it serves. + mod common; use std::time::Instant; @@ -255,7 +266,7 @@ fn ref_view_lookups_are_logarithmic_and_overlay_aware() { let mut view = RefView::new(snap.clone()); assert_eq!( view.get("refs/heads/ref-123456").as_deref(), - Some(format!("{:040x}", 123456).as_str()) + Some(format!("{:040x}", 123_456).as_str()) ); assert_eq!( view.get("HEAD").as_deref(), diff --git a/crates/walgit-git/tests/refs500k.rs b/crates/walgit-git/tests/refs500k.rs index d4239b0..b8584fd 100644 --- a/crates/walgit-git/tests/refs500k.rs +++ b/crates/walgit-git/tests/refs500k.rs @@ -1,5 +1,17 @@ //! `cargo test -p walgit-git --test refs500k -- --ignored --nocapture`: the per-push ref //! bookkeeping at 500 k refs (AGENTS §1.4: cost must not scale with ref count on a hot path). +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing, + clippy::many_single_char_names +)] +// clippy.toml exempts #[test] functions from the panic-path lints, but not the plain +// helper functions beside them in the same file. A panic in a fixture builder is how +// that fixture reports it could not be built, exactly as in the tests it serves. + +use std::fmt::Write as _; use std::io::Write; use std::time::Instant; use walgit_git::{LocalRepo, ObjectFormat, RepoId}; @@ -87,9 +99,9 @@ fn fixture(n_heads: usize, n_tags: usize) -> (tempfile::TempDir, LocalRepo) { names.sort(); for n in &names { if n.starts_with("refs/tags/") { - packed.push_str(&format!("{tag} {n}\n^{c}\n")); + let _ = writeln!(packed, "{tag} {n}\n^{c}"); } else { - packed.push_str(&format!("{c} {n}\n")); + let _ = writeln!(packed, "{c} {n}"); } } std::fs::write(dir.join("packed-refs"), packed).unwrap(); @@ -111,7 +123,7 @@ fn txn(name: &str, old: &str, new: &str) -> walgit_proto::v1::RefTransaction { } #[test] -#[ignore] +#[ignore = "builds a 500k-ref fixture; run with `just test-slow`"] fn push_bookkeeping_at_500k_refs() { let (_root, repo) = fixture(400_000, 100_000); let c2 = commit(repo.path(), "two"); @@ -189,7 +201,7 @@ fn snap_oid(repo: &LocalRepo, name: &str) -> String { /// update, delete of a packed ref, a new annotated tag with its peel, a HEAD symref move). #[test] fn pushes_patch_the_refs_cache_instead_of_reparsing() { - let (_root, repo) = fixture(2_000, 500); + let (root, repo) = fixture(2_000, 500); let c2 = commit(repo.path(), "two"); let zero = "0".repeat(40); let base = repo.refs_arc().unwrap(); @@ -255,7 +267,7 @@ fn pushes_patch_the_refs_cache_instead_of_reparsing() { 1, "pushes never re-parse; one copy folds them" ); - let fresh_handle = LocalRepo::open(_root.path(), &RepoId::new("t", "refs500k").unwrap()) + let fresh_handle = LocalRepo::open(root.path(), &RepoId::new("t", "refs500k").unwrap()) .unwrap() .unwrap(); let fresh = fresh_handle.refs_arc().unwrap(); diff --git a/crates/walgit-git/tests/rev_index.rs b/crates/walgit-git/tests/rev_index.rs index ea7adce..d82de6d 100644 --- a/crates/walgit-git/tests/rev_index.rs +++ b/crates/walgit-git/tests/rev_index.rs @@ -3,6 +3,17 @@ //! (a large repository's 32 GB base: `index-pack --rev-index` re-reads the whole pack — //! 4 GB in 52 min, 2026-08-21) and git accepts the file. +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing, + clippy::many_single_char_names +)] +// clippy.toml exempts #[test] functions from the panic-path lints, but not the plain +// helper functions beside them in the same file. A panic in a fixture builder is how +// that fixture reports it could not be built, exactly as in the tests it serves. + fn git(dir: &std::path::Path, args: &[&str]) -> String { let out = std::process::Command::new("git") .current_dir(dir) diff --git a/crates/walgit-git/tests/upload_gix_remote.rs b/crates/walgit-git/tests/upload_gix_remote.rs index 6999aa6..fc78025 100644 --- a/crates/walgit-git/tests/upload_gix_remote.rs +++ b/crates/walgit-git/tests/upload_gix_remote.rs @@ -4,6 +4,17 @@ //! faulted in per tree level. Mirrors a serverless instance serving acme/monorepo //! with the remote reader and no store mount. +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing, + clippy::many_single_char_names +)] +// clippy.toml exempts #[test] functions from the panic-path lints, but not the plain +// helper functions beside them in the same file. A panic in a fixture builder is how +// that fixture reports it could not be built, exactly as in the tests it serves. + mod common; use std::sync::Arc; diff --git a/crates/walgit-git/tests/upload_gix_scale.rs b/crates/walgit-git/tests/upload_gix_scale.rs index 6d77f23..3f57bcf 100644 --- a/crates/walgit-git/tests/upload_gix_scale.rs +++ b/crates/walgit-git/tests/upload_gix_scale.rs @@ -8,7 +8,7 @@ //! A. remainder: want tip, have base tip, `thin_pack = true` (prod's failing shape), //! B. remainder, `thin_pack = false` (every delta whose base is outside the set re-encoded), //! C. bounded zero-have: `--depth=1 --filter=blob:none` (CI's shape), -//! D. full zero-have (TreeContents expansion). +//! D. full zero-have (`TreeContents` expansion). //! Every output is indexed by stock git with `--strict` (ids recomputed from content), its object //! set compared to `git rev-list --objects` of the source, and the process's max RSS delta is //! bounded by a small multiple of the pack bytes. @@ -16,9 +16,21 @@ //! `cargo test -p walgit-git --test upload_gix_scale` runs the ~30 k-object variant (< 60 s); //! `-- --ignored` runs the ~300 k-object one (`just test-slow`). +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing, + clippy::many_single_char_names +)] +// clippy.toml exempts #[test] functions from the panic-path lints, but not the plain +// helper functions beside them in the same file. A panic in a fixture builder is how +// that fixture reports it could not be built, exactly as in the tests it serves. + mod common; use std::collections::BTreeSet; +use std::fmt::Write as _; use std::io::Write; use std::process::{Command, Stdio}; @@ -28,16 +40,19 @@ mod cm { pub use super::common::*; } +#[allow(unsafe_code)] fn max_rss_kb() -> u64 { + // SAFETY: rusage is a plain C struct of integers, so all-zero is a valid value. let mut ru: libc::rusage = unsafe { std::mem::zeroed() }; - unsafe { libc::getrusage(libc::RUSAGE_SELF, &mut ru) }; + // SAFETY: `ru` is a live, correctly typed rusage that getrusage only writes into. + unsafe { libc::getrusage(libc::RUSAGE_SELF, &raw mut ru) }; // getrusage reports ru_maxrss in KB on Linux but in BYTES on macOS/BSD. // Without this, the memory-bound assertion reads 1024x high on macOS and // fails a passing result (a 16 MB delta shown as "16832 MB"). #[cfg(any(target_os = "macos", target_os = "ios"))] - let kb = (ru.ru_maxrss as u64) / 1024; + let kb = (u64::try_from(ru.ru_maxrss).unwrap_or(0)) / 1024; #[cfg(not(any(target_os = "macos", target_os = "ios")))] - let kb = ru.ru_maxrss as u64; + let kb = u64::try_from(ru.ru_maxrss).unwrap_or(0); kb } @@ -59,7 +74,7 @@ fn synth(commits: usize, files: usize, files_per_commit: usize, dirs: usize) -> { let stdin = child.stdin.as_mut().unwrap(); let mut w = std::io::BufWriter::with_capacity(1 << 20, stdin); - let mut seed = 0x9E3779B97F4A7C15u64; + let mut seed = 0x9E37_79B9_7F4A_7C15_u64; let mut next = || { seed ^= seed << 13; seed ^= seed >> 7; @@ -73,7 +88,7 @@ fn synth(commits: usize, files: usize, files_per_commit: usize, dirs: usize) -> let mut c = format!("file {f}\n"); let words = 200 + (next() % 6000) as usize; for _ in 0..words { - c.push_str(&format!("{:06x} ", next() & 0xffffff)); + let _ = write!(c, "{:06x} ", next() & 0x00ff_ffff); } c.push('\n'); c @@ -94,12 +109,12 @@ fn synth(commits: usize, files: usize, files_per_commit: usize, dirs: usize) -> writeln!(w, "from :{}", c - 1).unwrap(); } for _ in 0..files_per_commit { - let f = (next() as usize) % files; + let f = (usize::try_from(next()).unwrap_or(usize::MAX)) % files; // Mostly appends (small deltas), sometimes a rewrite (a new base in the chain). if next() % 17 == 0 { contents[f] = format!("file {f} rewritten at {c} {:016x}\n", next()); } else { - contents[f].push_str(&format!("line {c} {:016x}\n", next())); + let _ = writeln!(contents[f], "line {c} {:016x}", next()); } let path = format!("d{}/s{}/f{f}.txt", f % dirs, (f / dirs) % 7); writeln!(w, "M 100644 inline {path}").unwrap(); @@ -214,7 +229,7 @@ fn req( } } -/// Entries of type REF_DELTA (7) in a v2 pack: walk the headers, skipping compressed data with a +/// Entries of type `REF_DELTA` (7) in a v2 pack: walk the headers, skipping compressed data with a /// throwaway inflater. A self-contained pack written by pack-copy has none. fn count_ref_deltas(pack: &[u8]) -> usize { use std::io::Read; @@ -249,7 +264,7 @@ fn count_ref_deltas(pack: &[u8]) -> usize { let mut d = flate2::read::ZlibDecoder::new(&pack[pos..]); let mut sink = Vec::new(); d.read_to_end(&mut sink).unwrap(); - pos += d.total_in() as usize; + pos += usize::try_from(d.total_in()).unwrap_or(usize::MAX); } refs } @@ -456,7 +471,7 @@ async fn run_shapes(commits: usize, files: usize, per_commit: usize, dirs: usize took.as_secs_f64() ); assert_eq!( - stats.objects as usize, + usize::try_from(stats.objects).unwrap_or(usize::MAX), ids.len(), "{name}: stats vs indexed entries" ); @@ -508,7 +523,7 @@ async fn gix_engine_packs_are_strict_valid_and_bounded_in_memory_30k() { /// ~300 k objects with long delta chains across two packs: `just test-slow`. #[tokio::test(flavor = "multi_thread", worker_threads = 4)] -#[ignore] +#[ignore = "~300k objects; run with `just test-slow`"] async fn gix_engine_packs_are_strict_valid_and_bounded_in_memory_300k() { run_shapes(12_000, 1_500, 10, 40, 10_000).await; } diff --git a/crates/walgit-git/tests/upload_pack.rs b/crates/walgit-git/tests/upload_pack.rs index af89da3..3b50a02 100644 --- a/crates/walgit-git/tests/upload_pack.rs +++ b/crates/walgit-git/tests/upload_pack.rs @@ -1,3 +1,14 @@ +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing, + clippy::many_single_char_names +)] +// clippy.toml exempts #[test] functions from the panic-path lints, but not the plain +// helper functions beside them in the same file. A panic in a fixture builder is how +// that fixture reports it could not be built, exactly as in the tests it serves. + mod common; use walgit_git::pkt::Protocol; @@ -672,10 +683,10 @@ async fn fetch_skips_gitlink_entries() { // present. let (_blobs, commits, trees, _tags) = cm::pack_object_types(&pack); assert_eq!(commits, 2, "unexpected commit count ({filter:?})"); - if filter != Some("tree:0") { - assert!(trees >= 1, "tree missing ({filter:?})"); - } else { + if filter == Some("tree:0") { assert_eq!(trees, 0, "tree:0 sends no trees"); + } else { + assert!(trees >= 1, "tree missing ({filter:?})"); } let tmp = cm::fresh_bare(); let pack_path = tmp.path().join("objects/pack/pack-test.pack"); @@ -694,12 +705,16 @@ async fn fetch_skips_gitlink_entries() { } } +#[allow( + clippy::case_sensitive_file_extension_comparisons, + reason = "git names these files itself, in lowercase; an ASCII-insensitive compare would accept names this code never writes" +)] /// Engine comparison on a real repository (`WALGIT_BENCH_REPO=`; e.g. `walgit synth --size l`). Prints wall times for a /// diff-sized fetch (want HEAD, have HEAD~50) and a full clone, both engines. /// `cargo test -p walgit-git --test upload_pack bench_fetch_engines -- --ignored --nocapture` #[tokio::test] -#[ignore] +#[ignore = "benchmark; needs WALGIT_BENCH_REPO"] async fn bench_fetch_engines() { let Ok(src_path) = std::env::var("WALGIT_BENCH_REPO") else { eprintln!("WALGIT_BENCH_REPO not set; skipping"); diff --git a/crates/walgit-proto/src/lib.rs b/crates/walgit-proto/src/lib.rs index 128f6f5..c4f8cd8 100644 --- a/crates/walgit-proto/src/lib.rs +++ b/crates/walgit-proto/src/lib.rs @@ -4,6 +4,11 @@ //! every walgit instance and must only evolve backward-compatibly. pub mod v1 { + // prost renders the .proto comments verbatim into doc comments, so bare identifiers + // there trip doc_markdown in code no one can edit. Fixing it would mean backticking + // the schema's own prose to satisfy a lint about generated output. + #![allow(clippy::doc_markdown)] + include!(concat!(env!("OUT_DIR"), "/walgit.v1.rs")); } @@ -104,7 +109,10 @@ pub mod frame { let len = e.encoded_len(); prost::encoding::encode_varint(len as u64, out); out.reserve(len); - e.encode(out).expect("BytesMut has capacity"); + // Infallible: prost only fails to encode when the buffer is short, and the + // reserve above is for exactly the length prost just reported. + #[allow(clippy::expect_used)] + e.encode(out).expect("BytesMut was reserved to encoded_len"); } pub fn encode_entries<'a>(entries: impl IntoIterator) -> Bytes { @@ -120,16 +128,22 @@ pub mod frame { pub fn decode_entries(buf: &[u8]) -> Result<(Vec, usize), prost::DecodeError> { let mut out = Vec::new(); let mut pos = 0usize; - loop { - let mut probe = &buf[pos..]; + while let Some(mut probe) = buf.get(pos..) { let Ok(len) = prost::encoding::decode_varint(&mut probe) else { break; }; - let len = len as usize; + // A frame header from the store may claim any length; on a 32-bit target a + // u64 that does not fit usize is a truncated frame, not a shorter one. + let Ok(len) = usize::try_from(len) else { + break; + }; if probe.remaining() < len { break; } - out.push(LogEntry::decode(&probe[..len])?); + let Some(frame) = probe.get(..len) else { + break; + }; + out.push(LogEntry::decode(frame)?); pos = buf.len() - probe.len() + len; } Ok((out, pos)) @@ -146,12 +160,16 @@ pub mod time { pub fn from_system(t: SystemTime) -> prost_types::Timestamp { let d = t.duration_since(UNIX_EPOCH).unwrap_or_default(); prost_types::Timestamp { - seconds: d.as_secs() as i64, - nanos: d.subsec_nanos() as i32, + seconds: d.as_secs().cast_signed(), + nanos: d.subsec_nanos().cast_signed(), } } pub fn to_system(t: &prost_types::Timestamp) -> SystemTime { - UNIX_EPOCH + Duration::new(t.seconds.max(0) as u64, t.nanos.max(0) as u32) + UNIX_EPOCH + + Duration::new( + t.seconds.max(0).cast_unsigned(), + t.nanos.max(0).cast_unsigned(), + ) } } diff --git a/crates/walgit-server/build.rs b/crates/walgit-server/build.rs index f7a7eb4..d4b4a97 100644 --- a/crates/walgit-server/build.rs +++ b/crates/walgit-server/build.rs @@ -17,8 +17,13 @@ fn main() { println!("cargo:rerun-if-changed={}", dist.display()); let index = dist.join("index.html"); if !index.exists() { - fs::create_dir_all(&dist).expect("create web/dist"); - fs::write(&index, PLACEHOLDER).expect("write placeholder web/dist/index.html"); + // A build script has no error channel: if the placeholder cannot be written, + // rust-embed fails later with a worse message. Aborting here is the contract. + #[allow(clippy::expect_used)] + { + fs::create_dir_all(&dist).expect("create web/dist"); + fs::write(&index, PLACEHOLDER).expect("write placeholder web/dist/index.html"); + } println!( "cargo:warning=web/dist was missing; wrote a placeholder index.html (run `just web-build` for the real UI)" ); diff --git a/crates/walgit-server/src/admin.rs b/crates/walgit-server/src/admin.rs index d9f2863..c08ef09 100644 --- a/crates/walgit-server/src/admin.rs +++ b/crates/walgit-server/src/admin.rs @@ -16,7 +16,11 @@ pub async fn create( headers: &HeaderMap, query: &str, ) -> Result { - let _principal = st.auth.require_write(headers).await.map_err(auth_err)?; + let _principal = st + .auth + .require_write(headers) + .await + .map_err(ApiError::from)?; let format = match query .split('&') .find_map(|part| part.strip_prefix("object_format=")) @@ -35,7 +39,7 @@ pub async fn create( Err(walgit_wal::WalError::AlreadyExists) => { Ok((StatusCode::CONFLICT, "already exists").into_response()) } - Err(e) => Err(wal_err(e)), + Err(e) => Err(ApiError::from(e)), } } @@ -45,15 +49,26 @@ pub async fn delete( route: &RepoRoute, headers: &HeaderMap, ) -> Result { - let _principal = st.auth.require_admin(headers).await.map_err(auth_err)?; - st.registry.delete(&route.id).await.map_err(wal_err)?; + let _principal = st + .auth + .require_admin(headers) + .await + .map_err(ApiError::from)?; + st.registry + .delete(&route.id) + .await + .map_err(ApiError::from)?; Ok((StatusCode::NO_CONTENT, "").into_response()) } /// `GET /` — list repos as text/plain, one `owner/name` per line. pub async fn list_repos(st: &AppState, headers: &HeaderMap) -> Result { - let _ = st.auth.require_read(headers).await.map_err(auth_err)?; - let repos = st.registry.list().await.map_err(wal_err)?; + let _ = st + .auth + .require_read(headers) + .await + .map_err(ApiError::from)?; + let repos = st.registry.list().await.map_err(ApiError::from)?; let body = repos .into_iter() .map(|r| r.to_string()) @@ -69,21 +84,3 @@ pub async fn list_repos(st: &AppState, headers: &HeaderMap) -> Result ApiError { - match e { - crate::auth::AuthError::Invalid | crate::auth::AuthError::Unauthorized => { - ApiError::Unauthorized - } - crate::auth::AuthError::Forbidden => ApiError::Forbidden, - crate::auth::AuthError::Unavailable => { - ApiError::ServiceUnavailable("auth provider unavailable".into()) - } - } -} -fn wal_err(e: walgit_wal::WalError) -> ApiError { - match &e { - walgit_wal::WalError::NotFound => ApiError::NotFound(e.to_string()), - _ => ApiError::Internal(format!("wal: {e}")), - } -} diff --git a/crates/walgit-server/src/auth.rs b/crates/walgit-server/src/auth.rs index 78d4312..0fb9fd9 100644 --- a/crates/walgit-server/src/auth.rs +++ b/crates/walgit-server/src/auth.rs @@ -3,7 +3,7 @@ //! //! * **`token`** — static tokens from the config, presented as `Authorization: //! Bearer ` or as the password of HTTP Basic (any user name). -//! * **`oidc`** — any OpenID Connect issuer. Three credentials are accepted: +//! * **`oidc`** — any `OpenID` Connect issuer. Three credentials are accepted: //! 1. an **ID token** from the issuer in `Authorization: Bearer` (RS256/ES256, //! signature against the issuer's JWKS, `iss`, `exp`, `aud` ∈ `audiences` ∪ //! {`oauth_client_id`}, `email_verified`), for CLIs that can mint one; @@ -11,9 +11,9 @@ //! signed-in browser): HMAC-signed, stateless, the shape git and scripts //! use; also accepted as a Basic password; //! 3. the **session cookie** set by the browser sign-in (`web/login.rs`). -//! Static `tokens` are honoured in this mode too (robots, CI). -//! Every path ends in the same allowlist: `allowed_domains` / `allowed_emails`, -//! `write_domains`. +//! Static `tokens` are honoured in this mode too (robots, CI). +//! Every path ends in the same allowlist: `allowed_domains` / `allowed_emails`, +//! `write_domains`. //! //! An edge in front of walgit may take the client's `Authorization` for its own //! hop credential; it then announces `client-authorization` in @@ -216,7 +216,7 @@ impl JwksSource for HttpOidcSource { .get(reqwest::header::CACHE_CONTROL) .and_then(|v| v.to_str().ok()) .and_then(parse_max_age) - .unwrap_or(Duration::from_secs(300)); + .unwrap_or(Duration::from_mins(5)); let document: JwksDocument = response .error_for_status() .map_err(|e| format!("JWKS response failed: {e}"))? @@ -586,7 +586,7 @@ impl Authenticator { /// Principal from a valid, unexpired session cookie (policy re-applied). fn authenticate_cookie(&self, headers: &HeaderMap) -> Option { let (_, _, email) = self.session_claims(headers)?; - self.principal_for_email(email).ok() + self.principal_for_email(&email).ok() } /// Sliding sessions: a fresh cookie value when the request carries a valid @@ -597,7 +597,7 @@ impl Authenticator { if unix_now()?.saturating_sub(iat) < self.session_ttl.as_secs() / 4 { return None; } - let principal = self.principal_for_email(email).ok()?; + let principal = self.principal_for_email(&email).ok()?; self.session_cookie_value(&principal.name) } @@ -660,7 +660,7 @@ impl Authenticator { } if tok.starts_with(ACCESS_TOKEN_PREFIX) { return Some(match self.access_token_claims(tok) { - Some((_, email)) => self.principal_for_email(email), + Some((_, email)) => self.principal_for_email(&email), None => Err(AuthError::Invalid), }); } @@ -797,11 +797,11 @@ impl Authenticator { return Err(AuthError::Invalid); } tracing::debug!(iss = %claims.iss, aud = ?claims.aud, email = %claims.email, "ID token validated"); - self.principal_for_email(claims.email) + self.principal_for_email(&claims.email) } /// Apply the domain/email allowlist and `write_domains` policy to a verified email. - fn principal_for_email(&self, email: String) -> Result { + fn principal_for_email(&self, email: &str) -> Result { let Some((_, domain)) = email.rsplit_once('@') else { return Err(AuthError::Invalid); }; @@ -817,9 +817,9 @@ impl Authenticator { Some(domains) => domains.iter().any(|d| d == &domain_lower), }; Ok(Principal { - name: email.clone(), + name: email.to_owned(), write, - admin: self.is_admin(&email), + admin: self.is_admin(email), anonymous: false, }) } @@ -921,7 +921,7 @@ fn bearer_token(headers: &HeaderMap) -> Option { /// Value of cookie `name` from the `Cookie` header(s). pub fn cookie_value(headers: &HeaderMap, name: &str) -> Option { - for h in headers.get_all(axum::http::header::COOKIE).iter() { + for h in &headers.get_all(axum::http::header::COOKIE) { let Ok(s) = h.to_str() else { continue }; for part in s.split(';') { let part = part.trim(); @@ -955,12 +955,12 @@ fn base64_decode(s: &str) -> Option> { if b == b'=' { break; } - let val = TABLE.iter().position(|&t| t == b)? as u32; + let val = u32::try_from(TABLE.iter().position(|&t| t == b)?).unwrap_or(u32::MAX); buf = (buf << 6) | val; bits += 6; if bits >= 8 { bits -= 8; - out.push((buf >> bits) as u8); + out.push(u8::try_from((buf >> bits) & 0xFF).unwrap_or(0)); buf &= (1 << bits) - 1; } } @@ -1049,7 +1049,7 @@ mod tests { } // gitleaks:allow — fixed test fixture; never loaded outside this module's OIDC verifier tests. - const PRIVATE_KEY: &[u8] = br#"-----BEGIN PRIVATE KEY----- + const PRIVATE_KEY: &[u8] = br"-----BEGIN PRIVATE KEY----- MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDJETqse41HRBsc 7cfcq3ak4oZWFCoZlcic525A3FfO4qW9BMtRO/iXiyCCHn8JhiL9y8j5JdVP2Q9Z IpfElcFd3/guS9w+5RqQGgCR+H56IVUyHZWtTJbKPcwWXQdNUX0rBFcsBzCRESJL @@ -1077,7 +1077,7 @@ GcZ0izY/30012ajdHY+/QK5lsMoxTnn0skdS+spLxaS5ZEO4qvPVb8RAoCkWMMal 2pOhmquJQVDPDLuZHdrIiKiDM20dy9sMfHygWcZjQ4WSxf/J7T9canLZIXFhHAZT 3wc9h4G8BBCtWN2TN/LsGZdB -----END PRIVATE KEY----- -"#; +"; const MODULUS: &str = "yRE6rHuNR0QbHO3H3Kt2pOKGVhQqGZXInOduQNxXzuKlvQTLUTv4l4sggh5_CYYi_cvI-SXVT9kPWSKXxJXBXd_4LkvcPuUakBoAkfh-eiFVMh2VrUyWyj3MFl0HTVF9KwRXLAcwkREiS3npThHRyIxuy0ZMeZfxVL5arMhw1SRELB8HoGfG_AtH89BIE9jDBHZ9dLelK9a184zAf8LwoPLxvJb3Il5nncqPcSfKDDodMFBIMc4lQzDKL5gvmiXLXB1AGLm8KBjfE8s3L5xqi-yUod-j8MtvIj812dkS4QMiRVN_by2h3ZY8LYVGrqZXZTcgn2ujn8uKjXLZVD5TdQ"; const EXPONENT: &str = "AQAB"; @@ -1131,7 +1131,7 @@ GcZ0izY/30012ajdHY+/QK5lsMoxTnn0skdS+spLxaS5ZEO4qvPVb8RAoCkWMMal n: MODULUS.into(), e: EXPONENT.into(), }], - max_age: Duration::from_secs(3600), + max_age: Duration::from_hours(1), })), }) } @@ -1322,7 +1322,7 @@ GcZ0izY/30012ajdHY+/QK5lsMoxTnn0skdS+spLxaS5ZEO4qvPVb8RAoCkWMMal async fn issued_access_tokens_are_bearers_and_basic_passwords_and_never_cookies() { let mut cfg = config(); cfg.server.auth.session_secret = Some(SECRET.into()); - cfg.server.auth.access_token_ttl = Duration::from_secs(3600); + cfg.server.auth.access_token_ttl = Duration::from_hours(1); let auth = Authenticator::with_key_source(&cfg, source()); let tok = auth.access_token("dev@example.com").unwrap(); assert!(tok.starts_with(ACCESS_TOKEN_PREFIX)); @@ -1431,7 +1431,7 @@ mod session_tests { assert!(unix_now().unwrap().abs_diff(iat) <= 2); assert_eq!( walgit_config::Config::default().server.auth.session_ttl, - Duration::from_secs(30 * 86400) + Duration::from_hours(720) ); } diff --git a/crates/walgit-server/src/bridge.rs b/crates/walgit-server/src/bridge.rs index 30e32cc..3ab6a97 100644 --- a/crates/walgit-server/src/bridge.rs +++ b/crates/walgit-server/src/bridge.rs @@ -161,7 +161,7 @@ impl Bridge { // Another bridge instance advanced it: our emission was a // duplicate (dedup key), theirs stands. Err(StoreError::PreconditionFailed { .. }) => { - tracing::warn!(repo = %id, "events bridge: cursor CAS lost (two bridges?)") + tracing::warn!(repo = %id, "events bridge: cursor CAS lost (two bridges?)"); } Err(e) => return Err(e.into()), } @@ -195,7 +195,7 @@ impl Bridge { } Ok(_) => {} Err(e) => { - tracing::warn!(repo = %id, error = %e, "events bridge: sweep catch-up failed") + tracing::warn!(repo = %id, error = %e, "events bridge: sweep catch-up failed"); } } } @@ -262,19 +262,21 @@ impl Bridge { fn notified_keys(v: &serde_json::Value) -> Vec { let mut keys = Vec::new(); // GCS → Pub/Sub push envelope. - let attrs = &v["message"]["attributes"]; - if attrs["eventType"] == "OBJECT_FINALIZE" - && let Some(k) = attrs["objectId"].as_str() + if let Some(attrs) = v.pointer("/message/attributes") + && attrs.get("eventType").and_then(serde_json::Value::as_str) == Some("OBJECT_FINALIZE") + && let Some(k) = attrs.get("objectId").and_then(serde_json::Value::as_str) { keys.push(k.to_string()); } // S3 event notification (also what MinIO/rustfs/Ceph emit). - if let Some(records) = v["Records"].as_array() { + if let Some(records) = v.get("Records").and_then(serde_json::Value::as_array) { for r in records { - if r["eventName"] - .as_str() + if r.get("eventName") + .and_then(serde_json::Value::as_str) .is_some_and(|e| e.starts_with("ObjectCreated")) - && let Some(k) = r["s3"]["object"]["key"].as_str() + && let Some(k) = r + .pointer("/s3/object/key") + .and_then(serde_json::Value::as_str) { // S3 URL-encodes keys in notifications. keys.push( @@ -285,9 +287,12 @@ fn notified_keys(v: &serde_json::Value) -> Vec { if i == 0 { return part.to_string(); } - match u8::from_str_radix(part.get(..2).unwrap_or(""), 16) { - Ok(b) => format!("{}{}", b as char, &part[2..]), - Err(_) => format!("%{part}"), + match ( + u8::from_str_radix(part.get(..2).unwrap_or(""), 16), + part.get(2..), + ) { + (Ok(b), Some(rest)) => format!("{}{rest}", b as char), + _ => format!("%{part}"), } }) .collect(), @@ -314,7 +319,11 @@ pub async fn http_notify( ) -> Result { use crate::error::ApiError; use axum::response::IntoResponse; - let _ = st.auth.require_read(headers).await.map_err(auth_err)?; + let _ = st + .auth + .require_read(headers) + .await + .map_err(ApiError::from)?; let Some(bridge) = &st.bridge else { return Err(ApiError::NotFound( "events bridge is not enabled here".into(), @@ -341,21 +350,8 @@ pub async fn http_notify( Ok(axum::Json(reports).into_response()) } -fn auth_err(e: crate::auth::AuthError) -> crate::error::ApiError { - use crate::error::ApiError; - match e { - crate::auth::AuthError::Invalid | crate::auth::AuthError::Unauthorized => { - ApiError::Unauthorized - } - crate::auth::AuthError::Forbidden => ApiError::Forbidden, - crate::auth::AuthError::Unavailable => { - ApiError::ServiceUnavailable("auth provider unavailable".into()) - } - } -} - /// `events.sweep_interval` timer (0 = off). -pub fn spawn_sweeper(state: Arc) { +pub fn spawn_sweeper(state: &Arc) { let Some(bridge) = state.bridge.clone() else { return; }; diff --git a/crates/walgit-server/src/bundles.rs b/crates/walgit-server/src/bundles.rs index 7b23cc7..257d651 100644 --- a/crates/walgit-server/src/bundles.rs +++ b/crates/walgit-server/src/bundles.rs @@ -1,6 +1,6 @@ //! Bundle serving: `GET /{repo}/bundles/list` (git bundle-list text, no-cache) //! and `GET|HEAD /{repo}/bundles/{strategy}/{name}` (streamed bundle with -//! strong ETag = store version, immutable caching, Range/If-Range, +//! strong `ETag` = store version, immutable caching, Range/If-Range, //! If-None-Match, HEAD — `static_object`). use axum::http::{HeaderMap, Method, StatusCode}; @@ -29,7 +29,11 @@ pub async fn list( if !st.cfg.bundles.advertise { return Err(ApiError::NotFound("bundles disabled".into())); } - let principal = st.auth.require_read(headers).await.map_err(auth_err)?; + let principal = st + .auth + .require_read(headers) + .await + .map_err(ApiError::from)?; // This principal tried bundle-uri (see `smart::bundle_fallback_allowed`). st.caches.bundle_attempts.insert( format!("{}\0{}", route.id, principal.name), @@ -79,7 +83,7 @@ pub async fn list( .bundles .render_list(&route.id, &base, filter.as_deref(), fulls) .await - .map_err(bundle_err)?; + .map_err(ApiError::from)?; match text { Some(t) => { st.caches @@ -96,17 +100,17 @@ fn render_bundle_list_response(text: String) -> Response { let h = resp.headers_mut(); h.insert( axum::http::header::CONTENT_TYPE, - "text/plain; charset=utf-8".parse().unwrap(), + axum::http::HeaderValue::from_static("text/plain; charset=utf-8"), ); h.insert( axum::http::header::CACHE_CONTROL, - "no-cache".parse().unwrap(), + axum::http::HeaderValue::from_static("no-cache"), ); resp } /// `GET|HEAD /{repo}/bundles/{strategy}/{name}` — streamed from the store -/// with the full immutable-object contract (strong ETag, 304, Range/If-Range, +/// with the full immutable-object contract (strong `ETag`, 304, Range/If-Range, /// HEAD, Content-Length); see `static_object`. pub async fn object( st: &AppState, @@ -115,7 +119,11 @@ pub async fn object( headers: &HeaderMap, peer: Option, ) -> Result { - let _ = st.auth.require_read(headers).await.map_err(auth_err)?; + let _ = st + .auth + .require_read(headers) + .await + .map_err(ApiError::from)?; let handle = open_repo(st, &route.id, false).await?; let store = handle.store().clone(); @@ -145,21 +153,6 @@ pub async fn object( .await } -fn auth_err(e: crate::auth::AuthError) -> ApiError { - match e { - crate::auth::AuthError::Invalid | crate::auth::AuthError::Unauthorized => { - ApiError::Unauthorized - } - crate::auth::AuthError::Forbidden => ApiError::Forbidden, - crate::auth::AuthError::Unavailable => { - ApiError::ServiceUnavailable("auth provider unavailable".into()) - } - } -} -fn bundle_err(e: walgit_bundle::BundleError) -> ApiError { - ApiError::Internal(format!("bundle: {e}")) -} - /// Full bundle = header ∘ the single tier-2 base pack, refs from the checkpoint /// at the base's seq (written now when the base is at head and none exists). /// Full bundle = header (refs at the base's seq) ∘ tier-2 base pack via GCS @@ -182,12 +175,13 @@ pub async fn compose_full_from_base( // The base is the tier-2 pack that is not a derived history pack (D18: // `compact --base` publishes both at tier 2; the weekly composes the base). let bases = walgit_wal::base_packs(&manifest); - anyhow::ensure!( - bases.len() == 1, - "compose needs exactly one tier-2 base pack (found {}; history packs excluded): an imported pack set — the base rebuild unit (`compact --base`) collapses it first", - bases.len() - ); - let base = bases[0].clone(); + let [base] = bases.as_slice() else { + anyhow::bail!( + "compose needs exactly one tier-2 base pack (found {}; history packs excluded): an imported pack set — the base rebuild unit (`compact --base`) collapses it first", + bases.len() + ); + }; + let base = (*base).clone(); let seq = base.seq; let store = handle.store(); // Refs at the base's seq: the checkpoint there when one exists (the rebuild checkpoints right @@ -196,18 +190,17 @@ pub async fn compose_full_from_base( // rig's weekly compose failed every pass for as long as the churn kept refs moving, 2026-08-22); // only a log folded away below the base's seq with no checkpoint before it is unrecoverable. let refs_key = walgit_proto::keys::checkpoint_refs_key(seq); - let snap = match store.get_bytes(&refs_key).await? { - Some((_, bytes)) => walgit_proto::v1::RefSnapshot::decode(bytes.as_ref())?, - None => { - info!( - base_seq = seq, - head = manifest.head_seq, - "no checkpoint at the base's seq: replaying the refs at that seq from the WAL for the compose" - ); - handle.refs_at_seq(seq).await.map_err(|e| { - anyhow::anyhow!("refs at the base's seq {seq} (head {}): {e} — run `walgit compact --base` again so a checkpoint exists at the base", manifest.head_seq) - })? - } + let snap = if let Some((_, bytes)) = store.get_bytes(&refs_key).await? { + walgit_proto::v1::RefSnapshot::decode(bytes.as_ref())? + } else { + info!( + base_seq = seq, + head = manifest.head_seq, + "no checkpoint at the base's seq: replaying the refs at that seq from the WAL for the compose" + ); + handle.refs_at_seq(seq).await.map_err(|e| { + anyhow::anyhow!("refs at the base's seq {seq} (head {}): {e} — run `walgit compact --base` again so a checkpoint exists at the base", manifest.head_seq) + })? }; let list = walgit_bundle::ops::read_list(store) .await? @@ -232,7 +225,7 @@ pub async fn compose_full_from_base( .filter(|p| p.kind == walgit_proto::v1::PackKind::History as i32 && p.derived_from == base.checksum) .max_by_key(|p| p.seq) .cloned() - .ok_or_else(|| anyhow::anyhow!("strategy {strategy} is filtered but base {} has no history pack (D18) to compose; rebuild the base with git.history_pack on", &base.checksum[..12]))?, + .ok_or_else(|| anyhow::anyhow!("strategy {strategy} is filtered but base {} has no history pack (D18) to compose; rebuild the base with git.history_pack on", base.checksum.get(..12).unwrap_or(&base.checksum)))?, None => base.clone(), }; let pack_path = handle diff --git a/crates/walgit-server/src/cache.rs b/crates/walgit-server/src/cache.rs index 4327ebf..cf70dbb 100644 --- a/crates/walgit-server/src/cache.rs +++ b/crates/walgit-server/src/cache.rs @@ -4,11 +4,11 @@ //! Each cache exposes hit/miss counters via the `metrics` crate. //! //! **Justification for `moka`:** these caches need bounded, concurrent, -//! size-based LRU eviction. Implementing LRU eviction on DashMap requires a +//! size-based LRU eviction. Implementing LRU eviction on `DashMap` requires a //! secondary ordering structure and manual locking — error-prone and slower. //! `moka::sync::Cache` provides thread-safe, size-bounded LRU out of the box //! with excellent throughput (bucket-level locking, no global lock on hot -//! path). DashMap remains the right choice for unbounded lookup tables +//! path). `DashMap` remains the right choice for unbounded lookup tables //! (e.g. `RepoSemaphores`); bounded LRU is moka's domain. use moka::sync::Cache; @@ -61,7 +61,7 @@ fn v2_key(repo: &str, version: Option<&Version>, args: &walgit_git::LsRefsArgs) } /// Cache for rendered v0 ref advertisements. -/// Keyed by (repo, manifest_version, service). +/// Keyed by (repo, `manifest_version`, service). #[derive(Clone)] pub struct RefAdvertCache { v0: Cache>, @@ -83,15 +83,12 @@ impl RefAdvertCache { service: walgit_git::Service, ) -> Option> { let key = v0_key(repo, version, service); - match self.v0.get(&key) { - Some(val) => { - metrics::counter!("walgit_cache_ref_advert_hit").increment(1); - Some(val) - } - None => { - metrics::counter!("walgit_cache_ref_advert_miss").increment(1); - None - } + if let Some(val) = self.v0.get(&key) { + metrics::counter!("walgit_cache_ref_advert_hit").increment(1); + Some(val) + } else { + metrics::counter!("walgit_cache_ref_advert_miss").increment(1); + None } } @@ -114,15 +111,12 @@ impl RefAdvertCache { args: &walgit_git::LsRefsArgs, ) -> Option> { let key = v2_key(repo, version, args); - match self.v2_ls_refs.get(&key) { - Some(val) => { - metrics::counter!("walgit_cache_ls_refs_hit").increment(1); - Some(val) - } - None => { - metrics::counter!("walgit_cache_ls_refs_miss").increment(1); - None - } + if let Some(val) = self.v2_ls_refs.get(&key) { + metrics::counter!("walgit_cache_ls_refs_hit").increment(1); + Some(val) + } else { + metrics::counter!("walgit_cache_ls_refs_miss").increment(1); + None } } @@ -163,7 +157,7 @@ pub struct BundleListCache { } /// Idle lifetime of a rendered list (freshness comes from the version key). -pub const BUNDLE_LIST_TTL: std::time::Duration = std::time::Duration::from_secs(600); +pub const BUNDLE_LIST_TTL: std::time::Duration = std::time::Duration::from_mins(10); impl BundleListCache { pub fn new(max_entries: usize) -> Self { @@ -185,15 +179,12 @@ impl BundleListCache { repo: repo.to_string(), list_version: list_version.to_string(), }; - match self.inner.get(&key) { - Some(val) => { - metrics::counter!("walgit_cache_bundle_list_hit").increment(1); - Some(val) - } - None => { - metrics::counter!("walgit_cache_bundle_list_miss").increment(1); - None - } + if let Some(val) = self.inner.get(&key) { + metrics::counter!("walgit_cache_bundle_list_hit").increment(1); + Some(val) + } else { + metrics::counter!("walgit_cache_bundle_list_miss").increment(1); + None } } @@ -298,7 +289,7 @@ struct RefIndexKey { version: String, } -/// Keyed by (repo, manifest_version). +/// Keyed by (repo, `manifest_version`). #[derive(Clone)] pub struct RefIndexCache { inner: Cache>, @@ -361,12 +352,12 @@ impl ServerCaches { api_immutable: Cache::builder() .max_capacity(64 * 1024 * 1024) .weigher(|k: &String, v: &bytes::Bytes| { - (k.len() + v.len()).min(u32::MAX as usize) as u32 + u32::try_from((k.len() + v.len()).min(u32::MAX as usize)).unwrap_or(u32::MAX) }) .build(), bundle_attempts: Cache::builder() .max_capacity(100_000) - .time_to_live(std::time::Duration::from_secs(6 * 3600)) + .time_to_live(std::time::Duration::from_hours(6)) .build(), } } @@ -383,7 +374,7 @@ mod tests { fn make_args(prefixes: &[&str]) -> walgit_git::LsRefsArgs { walgit_git::LsRefsArgs { - ref_prefixes: prefixes.iter().map(|s| s.to_string()).collect(), + ref_prefixes: prefixes.iter().map(ToString::to_string).collect(), symrefs: false, peel: true, unborn: false, @@ -552,7 +543,7 @@ mod tests { } /// Benchmark: measure ref advertisement render time with and without cache - /// for a 50k-ref repo. Run with: cargo test -p walgit-server bench_ref_advert -- --nocapture --ignored + /// for a 50k-ref repo. Run with: cargo test -p walgit-server `bench_ref_advert` -- --nocapture --ignored #[test] #[ignore = "requires git binary and takes ~10s"] fn bench_ref_advert_50k_refs() { diff --git a/crates/walgit-server/src/error.rs b/crates/walgit-server/src/error.rs index ad76313..d1b5bc8 100644 --- a/crates/walgit-server/src/error.rs +++ b/crates/walgit-server/src/error.rs @@ -3,7 +3,7 @@ //! 200 response per the smart HTTP contract. Only transport/auth/routing errors //! become HTTP error statuses. -use axum::http::StatusCode; +use axum::http::{HeaderValue, StatusCode}; use axum::response::{IntoResponse, Response}; #[derive(Debug)] @@ -70,7 +70,7 @@ impl IntoResponse for ApiError { if self.status() == StatusCode::UNAUTHORIZED { resp.headers_mut().insert( axum::http::header::WWW_AUTHENTICATE, - "Bearer realm=\"walgit\"".parse().unwrap(), + HeaderValue::from_static("Bearer realm=\"walgit\""), ); } // 503s are transient by contract (placement refusal during a fallback, @@ -78,7 +78,7 @@ impl IntoResponse for ApiError { if status == StatusCode::SERVICE_UNAVAILABLE { resp.headers_mut().insert( axum::http::header::RETRY_AFTER, - axum::http::HeaderValue::from_static("15"), + HeaderValue::from_static("15"), ); } resp @@ -93,3 +93,44 @@ impl From for ApiError { } } } + +impl From for ApiError { + fn from(e: crate::auth::AuthError) -> Self { + match e { + crate::auth::AuthError::Invalid | crate::auth::AuthError::Unauthorized => { + ApiError::Unauthorized + } + crate::auth::AuthError::Forbidden => ApiError::Forbidden, + crate::auth::AuthError::Unavailable => { + ApiError::ServiceUnavailable("auth provider unavailable".into()) + } + } + } +} + +impl From for ApiError { + fn from(e: walgit_git::GitError) -> Self { + ApiError::Internal(format!("git: {e}")) + } +} + +impl From for ApiError { + fn from(e: walgit_bundle::BundleError) -> Self { + ApiError::Internal(format!("bundle: {e}")) + } +} + +impl From for ApiError { + fn from(e: walgit_wal::WalError) -> Self { + match &e { + walgit_wal::WalError::NotFound => ApiError::NotFound(e.to_string()), + walgit_wal::WalError::TooLarge { .. } => ApiError::ServiceUnavailable(e.to_string()), + // A store call that timed out / was throttled: fail fast, let the + // client retry (never hang the request on the bucket). + walgit_wal::WalError::Store(se) if se.is_retryable() => { + ApiError::ServiceUnavailable(format!("object store: {se}")) + } + _ => ApiError::Internal(format!("wal: {e}")), + } + } +} diff --git a/crates/walgit-server/src/events.rs b/crates/walgit-server/src/events.rs index e11cc99..441feb2 100644 --- a/crates/walgit-server/src/events.rs +++ b/crates/walgit-server/src/events.rs @@ -1,7 +1,7 @@ //! `ref` event shapes and the WAL → event conversion. Contract: //! `docs/EVENTS.md`. The only producer is the bridge (`crate::bridge`): it //! tails each repo's WAL from a durable cursor, converts committed PUSH / -//! REF_UPDATE entries with [`refs_from_entries`], and delivers to every +//! `REF_UPDATE` entries with [`refs_from_entries`], and delivers to every //! [`Sink`] before advancing the cursor. Nothing on the push path knows events //! exist. //! @@ -111,7 +111,7 @@ impl RefEvent { } } -/// `ref` events for the PUSH / REF_UPDATE entries in `entries`, in seq order. +/// `ref` events for the PUSH / `REF_UPDATE` entries in `entries`, in seq order. pub(crate) fn refs_from_entries(repo: &RepoId, entries: &[LogEntry], out: &mut Vec) { let repo = repo.to_string(); for entry in entries { @@ -174,10 +174,14 @@ pub(crate) struct WebhookSink { } impl WebhookSink { + #[allow( + clippy::expect_used, + reason = "the client builds unless the TLS backend is unavailable, and then the process cannot serve at all" + )] pub fn new(url: String, secret: Option) -> Self { WebhookSink { url, - secret: secret.map(|s| s.into_bytes()), + secret: secret.map(String::into_bytes), client: reqwest::Client::builder() .timeout(std::time::Duration::from_secs(10)) .build() @@ -186,6 +190,10 @@ impl WebhookSink { } /// `sha256=` over `body` with the shared secret. + #[allow( + clippy::expect_used, + reason = "HMAC accepts a key of any length, so new_from_slice cannot fail" + )] pub fn signature(secret: &[u8], body: &[u8]) -> String { use hmac::{Hmac, Mac}; let mut mac = Hmac::::new_from_slice(secret).expect("hmac key"); diff --git a/crates/walgit-server/src/follow.rs b/crates/walgit-server/src/follow.rs index 5a97991..cd3a7d4 100644 --- a/crates/walgit-server/src/follow.rs +++ b/crates/walgit-server/src/follow.rs @@ -125,8 +125,8 @@ pub async fn run_pass(state: &Arc) -> anyhow::Result { break; } let handle = state.registry.open(&id).await?; - let _refs = handle.sync_refs().await?; // the manifest carries the settings (D24) - drop(_refs); + // the manifest carries the settings (D24) + drop(handle.sync_refs().await?); let cfg = handle.effective_config(); let Some(upstream) = cfg.upstream.git.clone() else { continue; @@ -145,7 +145,7 @@ pub async fn run_pass(state: &Arc) -> anyhow::Result { // the scratch's alternates while git reads them. let guard = handle.sync().await?; let have = current(&handle, &cfg.upstream.follow)?; - let token = token_for(state, &cfg).await?; + let token = token_for(state, &cfg)?; let delta = walgit_git::follow::fetch_refs( &upstream, token.as_deref(), @@ -170,7 +170,7 @@ pub async fn run_pass(state: &Arc) -> anyhow::Result { let repo = id.to_string(); match fetched { Ok((false, tips, have)) => { - debug!(repo = %id, %upstream, elapsed_ms = t0.elapsed().as_millis() as u64, "follow: in sync"); + debug!(repo = %id, %upstream, elapsed_ms = u64::try_from(t0.elapsed().as_millis()).unwrap_or(u64::MAX), "follow: in sync"); state.follow.set( &repo, "in-sync", @@ -186,57 +186,60 @@ pub async fn run_pass(state: &Arc) -> anyhow::Result { report.behind += 1; let mut params = HashMap::new(); params.insert("prefetched".to_string(), "1".to_string()); - match run_op(state, &id, params).await { - Some(v) => { - let n = v.get("published").and_then(|n| n.as_u64()).unwrap_or(0); - if n > 0 { - report.published += 1; - } - let seq = v.get("seq").and_then(|s| s.as_u64()).unwrap_or(0); - let refused: Vec = v - .get("refused") - .and_then(|r| r.as_array()) - .map(|a| { - a.iter() - .filter_map(|x| x.as_str().map(String::from)) - .collect() - }) - .unwrap_or_default(); - let detail = if refused.is_empty() { - format!("{n} ref(s) published at seq {seq}") - } else { - format!( - "{n} ref(s) published at seq {seq}; refused: {}", - refused.join("; ") - ) - }; - state.follow.set( - &repo, - if n > 0 { "published" } else { "refused" }, - detail, - tips, - have, - ); - } - None => { - report.failed += 1; - // The task's summary names the reason (rewind, unpack, connectivity, publish). - let why = state - .registry - .tasks() - .recent(&repo) - .into_iter() - .find(|t| t.kind == "follow") - .map(|t| t.summary) - .unwrap_or_default(); - state.follow.set(&repo, "refused", why, tips, have); + if let Some(v) = run_op(state, &id, params).await { + let n = v + .get("published") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0); + if n > 0 { + report.published += 1; } + let seq = v + .get("seq") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0); + let refused: Vec = v + .get("refused") + .and_then(|r| r.as_array()) + .map(|a| { + a.iter() + .filter_map(|x| x.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + let detail = if refused.is_empty() { + format!("{n} ref(s) published at seq {seq}") + } else { + format!( + "{n} ref(s) published at seq {seq}; refused: {}", + refused.join("; ") + ) + }; + state.follow.set( + &repo, + if n > 0 { "published" } else { "refused" }, + detail, + tips, + have, + ); + } else { + report.failed += 1; + // The task's summary names the reason (rewind, unpack, connectivity, publish). + let why = state + .registry + .tasks() + .recent(&repo) + .into_iter() + .find(|t| t.kind == "follow") + .map(|t| t.summary) + .unwrap_or_default(); + state.follow.set(&repo, "refused", why, tips, have); } } Err(e) => { report.failed += 1; metrics::counter!("walgit_follow_rounds_total", "repo" => id.to_string(), "outcome" => "fetch-failed").increment(1); - warn!(repo = %id, %upstream, error = format!("{e:#}"), elapsed_ms = t0.elapsed().as_millis() as u64, "follow: fetch from upstream failed"); + warn!(repo = %id, %upstream, error = format!("{e:#}"), elapsed_ms = u64::try_from(t0.elapsed().as_millis()).unwrap_or(u64::MAX), "follow: fetch from upstream failed"); state.follow.set( &repo, "failed", @@ -257,11 +260,10 @@ async fn run_op( params: HashMap, ) -> Option { let task = match crate::ops::start(state.clone(), id.clone(), "follow", params).await { - Ok(t) => t, - Err(crate::ops::StartError::AlreadyRunning(t)) => t, + Ok(t) | Err(crate::ops::StartError::AlreadyRunning(t)) => t, Err(crate::ops::StartError::UnknownOp) => return None, }; - if !task.wait_done(std::time::Duration::from_secs(3600)).await { + if !task.wait_done(std::time::Duration::from_hours(1)).await { warn!(repo = %id, "follow: op still running after 1h; moving on"); return None; } @@ -301,7 +303,7 @@ pub(crate) async fn op( .await .map_err(|e| format!("reading the fetched delta: {e}"))? } else { - let token = token_for(state, &cfg).await.map_err(|e| format!("{e:#}"))?; + let token = token_for(state, &cfg).map_err(|e| format!("{e:#}"))?; log(format!("fetching {} from {upstream}", refs.join(", "))); walgit_git::follow::fetch_refs( &upstream, @@ -344,7 +346,7 @@ pub(crate) async fn op( // completed it from our own objects, so it is not thin). let ingested = match &delta.pack { Some(p) => { - let bytes = tokio::fs::metadata(p).await.map(|m| m.len()).unwrap_or(0); + let bytes = tokio::fs::metadata(p).await.map_or(0, |m| m.len()); log(format!("ingesting {bytes} bytes of objects from upstream")); let file = tokio::fs::File::open(p) .await @@ -435,8 +437,7 @@ pub(crate) async fn op( let (old, new) = planned .iter() .find(|(n, _, _)| n == name) - .map(|(_, o, n)| (o.as_str(), n.as_str())) - .unwrap_or(("", "")); + .map_or(("", ""), |(_, o, n)| (o.as_str(), n.as_str())); match r { Ok(()) => { published += 1; @@ -452,7 +453,7 @@ pub(crate) async fn op( } metrics::counter!("walgit_follow_rounds_total", "repo" => id.to_string(), "outcome" => "published").increment(1); metrics::counter!("walgit_follow_refs_total", "repo" => id.to_string()).increment(published); - info!(repo = %id, seq = res.seq, refs = published, refused = refused.len(), %upstream, elapsed_ms = t0.elapsed().as_millis() as u64, "follow published"); + info!(repo = %id, seq = res.seq, refs = published, refused = refused.len(), %upstream, elapsed_ms = u64::try_from(t0.elapsed().as_millis()).unwrap_or(u64::MAX), "follow published"); let summary = format!( "{published} ref(s) from upstream published at seq {} in {:.1}s{}", res.seq, @@ -483,16 +484,12 @@ fn current( .collect()) } -async fn token_for( - state: &AppState, - cfg: &walgit_config::Config, -) -> anyhow::Result> { +fn token_for(state: &AppState, cfg: &walgit_config::Config) -> anyhow::Result> { match cfg.upstream.token_env.as_deref() { Some(name) => Ok(Some( state .lfs_upstream .secret(name) - .await .map_err(|e| anyhow::anyhow!("upstream token: {e}"))?, )), None => Ok(None), @@ -514,6 +511,6 @@ fn short(oid: &str) -> &str { if oid.is_empty() { "(none)" } else { - &oid[..oid.len().min(12)] + oid.get(..12).unwrap_or(oid) } } diff --git a/crates/walgit-server/src/forward.rs b/crates/walgit-server/src/forward.rs index b3897c1..1b30ce6 100644 --- a/crates/walgit-server/src/forward.rs +++ b/crates/walgit-server/src/forward.rs @@ -52,9 +52,9 @@ pub async fn receive_pack( route.id.name() ); let client = reqwest::Client::new(); - let stream = body.into_data_stream().map(|chunk| { - chunk.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string())) - }); + let stream = body + .into_data_stream() + .map(|chunk| chunk.map_err(|e| std::io::Error::other(e.to_string()))); let mut request = client .post(&endpoint) .body(reqwest::Body::wrap_stream(stream)); @@ -81,22 +81,21 @@ pub async fn receive_pack( .ok() .filter(|v| !v.is_empty()) .or_else(|| broker_token.map(str::to_string).filter(|v| !v.is_empty())); - match token { - Some(token) => request = request.bearer_auth(token), - None => { - tracing::warn!( - elapsed_ms = started.elapsed().as_millis() as u64, - "push broker token unset (wal.push_broker_token / WALGIT_BROKER_TOKEN); falling back" - ); - return ForwardOutcome::Fallback; - } + if let Some(token) = token { + request = request.bearer_auth(token); + } else { + tracing::warn!( + elapsed_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX), + "push broker token unset (wal.push_broker_token / WALGIT_BROKER_TOKEN); falling back" + ); + return ForwardOutcome::Fallback; } } let response = match request.send().await { Ok(response) => response, Err(error) => { - tracing::warn!(%error, elapsed_ms = started.elapsed().as_millis() as u64, "push broker unavailable; falling back"); + tracing::warn!(%error, elapsed_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX), "push broker unavailable; falling back"); return ForwardOutcome::Fallback; } }; @@ -106,7 +105,7 @@ pub async fn receive_pack( ) { tracing::warn!( status = response.status().as_u16(), - elapsed_ms = started.elapsed().as_millis() as u64, + elapsed_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX), "push broker gateway failure; falling back" ); return ForwardOutcome::Fallback; @@ -114,9 +113,9 @@ pub async fn receive_pack( let status = response.status(); let response_headers = response.headers().clone(); - let stream = response.bytes_stream().map(|chunk| { - chunk.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string())) - }); + let stream = response + .bytes_stream() + .map(|chunk| chunk.map_err(|e| std::io::Error::other(e.to_string()))); let mut builder = Response::builder().status(status); for name in [ header::CONTENT_TYPE, @@ -135,7 +134,7 @@ pub async fn receive_pack( metrics::counter!("walgit_push_forwarded_total", "outcome" => outcome).increment(1); tracing::info!( status = status.as_u16(), - elapsed_ms = started.elapsed().as_millis() as u64, + elapsed_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX), "push broker response streamed" ); ForwardOutcome::Response(output) diff --git a/crates/walgit-server/src/instance.rs b/crates/walgit-server/src/instance.rs index a8872c5..c843283 100644 --- a/crates/walgit-server/src/instance.rs +++ b/crates/walgit-server/src/instance.rs @@ -58,14 +58,17 @@ fn cgroup_cpus() -> Option { // cgroup v2: "quota period"; v1: cpu.cfs_quota_us / cpu.cfs_period_us. if let Ok(s) = std::fs::read_to_string("/sys/fs/cgroup/cpu.max") { let mut it = s.split_whitespace(); - if let (Some(q), Some(p)) = (it.next(), it.next()) { - if q != "max" { - if let (Ok(q), Ok(p)) = (q.parse::(), p.parse::()) { - if p > 0.0 { - return Some((q / p).round().max(1.0) as usize); - } - } - } + if let (Some(q), Some(p)) = (it.next(), it.next()) + && q != "max" + && let (Ok(q), Ok(p)) = (q.parse::(), p.parse::()) + && p > 0.0 + { + #[allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + reason = "the saturating float-to-int cast is the intended rounding" + )] + return Some((q / p).round().max(1.0) as usize); } } None @@ -92,7 +95,7 @@ fn gce_machine_type() -> Option { let s = String::from_utf8_lossy(&out.stdout).trim().to_string(); s.rsplit('/') .next() - .map(|m| m.to_string()) + .map(ToString::to_string) .filter(|m| !m.is_empty()) }) .clone() @@ -100,9 +103,9 @@ fn gce_machine_type() -> Option { fn gib(b: u64) -> String { let g = b as f64 / (1u64 << 30) as f64; if g >= 10.0 { - format!("{:.0} GiB", g) + format!("{g:.0} GiB") } else { - format!("{:.1} GiB", g) + format!("{g:.1} GiB") } } @@ -125,8 +128,9 @@ pub fn info(cfg: &walgit_config::Config) -> InstanceInfo { .or_else(|| env("HOSTNAME")) .unwrap_or_else(|| "walgit".into()); let revision = env("WALGIT_REVISION").unwrap_or_default(); - let instance = env("WALGIT_INSTANCE_ID") - .map(|i| { + let instance = env("WALGIT_INSTANCE_ID").map_or_else( + || std::process::id().to_string(), + |i| { i.chars() .rev() .take(6) @@ -134,13 +138,13 @@ pub fn info(cfg: &walgit_config::Config) -> InstanceInfo { .chars() .rev() .collect() - }) - .unwrap_or_else(|| std::process::id().to_string()); + }, + ); let version = match option_env!("WALGIT_BUILD_SHA") { Some(sha) if !sha.is_empty() => format!( "{}+{}", env!("CARGO_PKG_VERSION"), - &sha[..sha.len().min(12)] + sha.get(..12).unwrap_or(sha) ), _ => env!("CARGO_PKG_VERSION").to_string(), }; @@ -153,16 +157,14 @@ pub fn info(cfg: &walgit_config::Config) -> InstanceInfo { .map(|r| format!("{r:?}").to_lowercase()) .collect() }; - let cpus = cgroup_cpus().unwrap_or_else(|| { - std::thread::available_parallelism() - .map(|n| n.get()) - .unwrap_or(1) - }); + let cpus = cgroup_cpus() + .unwrap_or_else(|| std::thread::available_parallelism().map_or(1, std::num::NonZero::get)); let memory_bytes = cgroup_memory_max().or_else(meminfo_total).unwrap_or(0); let shape = match kind { - "ssd" => gce_machine_type() - .map(|m| format!("{m} · {cpus} vCPU · {}", gib(memory_bytes))) - .unwrap_or_else(|| format!("{cpus} vCPU · {}", gib(memory_bytes))), + "ssd" => gce_machine_type().map_or_else( + || format!("{cpus} vCPU · {}", gib(memory_bytes)), + |m| format!("{m} · {cpus} vCPU · {}", gib(memory_bytes)), + ), "serverless" => format!("a serverless host · {cpus} vCPU · {}", gib(memory_bytes)), _ => format!("{cpus} cpus · {}", gib(memory_bytes)), }; diff --git a/crates/walgit-server/src/lfs.rs b/crates/walgit-server/src/lfs.rs index d6b3f29..32a8bd2 100644 --- a/crates/walgit-server/src/lfs.rs +++ b/crates/walgit-server/src/lfs.rs @@ -82,7 +82,11 @@ pub async fn batch( if !st.cfg.lfs.enabled { return Err(ApiError::NotFound("lfs disabled".into())); } - let _ = st.auth.require_read(headers).await.map_err(auth_err)?; + let _ = st + .auth + .require_read(headers) + .await + .map_err(ApiError::from)?; not_served_here(st, &route.id)?; let handle = open_repo(st, &route.id, false).await?; let store = handle.store().clone(); @@ -109,7 +113,7 @@ pub async fn batch( .batch(upstream, cfg.upstream.token_env.as_deref(), &missing) .await } - _ => Default::default(), + _ => std::collections::HashMap::default(), }; let mut objs = Vec::with_capacity(body.objects.len()); @@ -166,7 +170,7 @@ pub async fn batch( .ok() .flatten() .unwrap_or_else(|| format!("{base}/info/lfs/objects/{}", o.oid)), - _ => format!("{base}/info/lfs/objects/{}", o.oid), + walgit_config::BundleServe::Proxy => format!("{base}/info/lfs/objects/{}", o.oid), }; actions.download = Some(Action { href, @@ -203,13 +207,13 @@ pub async fn batch( let mut resp = (StatusCode::OK, json).into_response(); resp.headers_mut().insert( axum::http::header::CONTENT_TYPE, - "application/vnd.git-lfs+json".parse().unwrap(), + axum::http::HeaderValue::from_static("application/vnd.git-lfs+json"), ); Ok(resp) } /// `GET|HEAD /{repo}/info/lfs/objects/{oid}` — stream the object with the full -/// immutable-object contract (strong ETag, 304, Range/If-Range, HEAD, +/// immutable-object contract (strong `ETag`, 304, Range/If-Range, HEAD, /// Content-Length); see `static_object`. LFS objects are sha256-addressed. pub async fn get_object( st: &AppState, @@ -222,7 +226,11 @@ pub async fn get_object( if !st.cfg.lfs.enabled { return Err(ApiError::NotFound("lfs disabled".into())); } - let _ = st.auth.require_read(headers).await.map_err(auth_err)?; + let _ = st + .auth + .require_read(headers) + .await + .map_err(ApiError::from)?; not_served_here(st, &route.id)?; let oid = route_sub_last(&route.subpath)?; require_lfs_oid(oid)?; @@ -262,6 +270,10 @@ pub async fn get_object( } } +#[allow( + clippy::too_many_arguments, + reason = "one parameter per piece of already-parsed request state; a wrapper struct would only be built and destructured at the single call site" +)] /// An object we lack but `lfs.upstream` has: stream it to the client while /// tee-ing into a spool file; after a complete, sha256-verified read the spool /// is `put` into the store (never on a short or mismatching read). No Range on @@ -289,12 +301,12 @@ async fn read_through( return Err(ApiError::NotFound("object not found".into())); }; if *method == axum::http::Method::HEAD { - return Ok(Response::builder() + return Response::builder() .status(StatusCode::OK) .header(axum::http::header::CONTENT_LENGTH, obj.size) .header(axum::http::header::CONTENT_TYPE, "application/octet-stream") .body(Body::empty()) - .unwrap()); + .map_err(|e| ApiError::Internal(e.to_string())); } let (len, mut upstream_body) = st .lfs_upstream @@ -371,13 +383,13 @@ async fn read_through( let _ = tokio::fs::remove_file(&spool_path).await; }); let stream = tokio_stream::wrappers::ReceiverStream::new(rx); - Ok(Response::builder() + Response::builder() .status(StatusCode::OK) .header(axum::http::header::CONTENT_LENGTH, len) .header(axum::http::header::CONTENT_TYPE, "application/octet-stream") .header(axum::http::header::CACHE_CONTROL, "no-store") .body(Body::from_stream(stream)) - .unwrap()) + .map_err(|e| ApiError::Internal(e.to_string())) } /// `PUT /{repo}/info/lfs/objects/{oid}` — stream upload, verify size + sha256. @@ -387,10 +399,16 @@ pub async fn put_object( headers: &HeaderMap, body: Body, ) -> Result { + use sha2::{Digest, Sha256}; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; if !st.cfg.lfs.enabled { return Err(ApiError::NotFound("lfs disabled".into())); } - let _ = st.auth.require_write(headers).await.map_err(auth_err)?; + let _ = st + .auth + .require_write(headers) + .await + .map_err(ApiError::from)?; not_served_here(st, &route.id)?; let oid = route_sub_last(&route.subpath)?; require_lfs_oid(oid)?; @@ -405,8 +423,6 @@ pub async fn put_object( .map_err(|e| ApiError::Internal(e.to_string()))?, ); let mut reader = body_to_async_read(body); - use sha2::{Digest, Sha256}; - use tokio::io::{AsyncReadExt, AsyncWriteExt}; let mut hasher = Sha256::new(); let mut n = 0u64; let mut buf = vec![0u8; 64 * 1024]; @@ -422,8 +438,9 @@ pub async fn put_object( if n > max { return Err(ApiError::PayloadTooLarge); } - hasher.update(&buf[..k]); - file.write_all(&buf[..k]) + let read = buf.get(..k).unwrap_or_default(); + hasher.update(read); + file.write_all(read) .await .map_err(|e| ApiError::Internal(e.to_string()))?; } @@ -441,7 +458,7 @@ pub async fn put_object( PutMode::Overwrite.into(), ) .await - .map_err(store_err)?; + .map_err(ApiError::from)?; Ok(StatusCode::OK.into_response()) } @@ -458,11 +475,15 @@ pub async fn verify( let body: BatchObject = serde_json::from_slice(&body_bytes) .map_err(|e| ApiError::BadRequest(format!("invalid lfs verify: {e}")))?; require_lfs_oid(&body.oid)?; - let _ = st.auth.require_write(headers).await.map_err(auth_err)?; + let _ = st + .auth + .require_write(headers) + .await + .map_err(ApiError::from)?; let handle = open_repo(st, &route.id, false).await?; let store = handle.store().clone(); let key = keys::lfs_key(&body.oid); - let meta = store.head(&key).await.map_err(store_err)?; + let meta = store.head(&key).await.map_err(ApiError::from)?; match meta { Some(m) if m.size == body.size => Ok(StatusCode::OK.into_response()), Some(_) => Err(ApiError::BadRequest("lfs size mismatch".into())), @@ -506,18 +527,3 @@ fn base_url(st: &AppState, route: &RepoRoute, headers: &HeaderMap) -> String { route.id ) } - -fn auth_err(e: crate::auth::AuthError) -> ApiError { - match e { - crate::auth::AuthError::Invalid | crate::auth::AuthError::Unauthorized => { - ApiError::Unauthorized - } - crate::auth::AuthError::Forbidden => ApiError::Forbidden, - crate::auth::AuthError::Unavailable => { - ApiError::ServiceUnavailable("auth provider unavailable".into()) - } - } -} -fn store_err(e: walgit_store::StoreError) -> ApiError { - e.into() -} diff --git a/crates/walgit-server/src/lfs_upstream.rs b/crates/walgit-server/src/lfs_upstream.rs index f058321..8df8723 100644 --- a/crates/walgit-server/src/lfs_upstream.rs +++ b/crates/walgit-server/src/lfs_upstream.rs @@ -79,6 +79,10 @@ impl Default for Upstream { } impl Upstream { + #[allow( + clippy::expect_used, + reason = "the client builds unless the TLS backend is unavailable, and then the process cannot serve at all" + )] pub fn new() -> Self { Self { client: reqwest::Client::builder() @@ -110,7 +114,7 @@ impl Upstream { match result { Ok(m) => m, Err(error) => { - tracing::warn!(%error, elapsed_ms = started.elapsed().as_millis() as u64, "lfs upstream batch failed; treating as absent"); + tracing::warn!(%error, elapsed_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX), "lfs upstream batch failed; treating as absent"); HashMap::new() } } @@ -141,7 +145,7 @@ impl Upstream { .header("Content-Type", "application/vnd.git-lfs+json") .json(&body); if let Some(secret) = token_env { - let token = self.secret(secret).await?; + let token = self.secret(secret)?; let basic = base64::engine::general_purpose::STANDARD.encode(format!("x-access-token:{token}")); req = req.header("Authorization", format!("Basic {basic}")); @@ -156,17 +160,13 @@ impl Upstream { let Some(dl) = o.actions.and_then(|a| a.download) else { continue; }; - if !asked.contains_key(o.oid.as_str()) { + let Some(asked_size) = asked.get(o.oid.as_str()).copied() else { continue; - } + }; out.insert( o.oid.clone(), UpstreamObject { - size: if o.size > 0 { - o.size - } else { - asked[o.oid.as_str()] - }, + size: if o.size > 0 { o.size } else { asked_size }, oid: o.oid, href: dl.href, header: dl.header, @@ -211,7 +211,7 @@ impl Upstream { } /// The upstream token: the value of the environment variable `upstream.token_env` names. - pub async fn secret(&self, env_name: &str) -> anyhow::Result { + pub fn secret(&self, env_name: &str) -> anyhow::Result { let v = std::env::var(env_name).map_err(|_| { anyhow::anyhow!("upstream.token_env {env_name:?} is not set in this host's environment") })?; diff --git a/crates/walgit-server/src/lib.rs b/crates/walgit-server/src/lib.rs index 8df7c6e..5731a3c 100644 --- a/crates/walgit-server/src/lib.rs +++ b/crates/walgit-server/src/lib.rs @@ -77,18 +77,15 @@ pub struct AppState { } impl AppState { - /// Build a full AppState from a config + store (memory or opened backend). - pub async fn new( - cfg: Arc, - store: DynStore, - ) -> anyhow::Result> { + /// Build a full `AppState` from a config + store (memory or opened backend). + pub fn new(cfg: &Arc, store: DynStore) -> anyhow::Result> { let registry = walgit_wal::Registry::new(store.clone(), cfg.clone()); - let bridge = bridge::Bridge::new(&cfg, registry.clone()); + let bridge = bridge::Bridge::new(cfg, registry.clone()); let bundle_source: Arc = Arc::new(RegistryBundleSource(registry.clone())); let bundles = walgit_bundle::Bundler::new_with_source(bundle_source, cfg.clone()); let metrics_handle = metrics::install()?; - let tls = tls::load(&cfg)?; + let tls = tls::load(cfg)?; if let Some(t) = &tls { tracing::info!(fingerprint = %t.fingerprint, mode = ?cfg.server.tls.mode, "TLS terminated in-process"); } @@ -97,10 +94,10 @@ impl AppState { store, registry, bundles, - auth: auth::Authenticator::new(&cfg), + auth: auth::Authenticator::new(cfg), semaphores: middleware::RepoSemaphores::new(cfg.server.max_concurrent_per_repo), inflight: Arc::new(middleware::Inflight::default()), - caches: cache::ServerCaches::new(&cfg), + caches: cache::ServerCaches::new(cfg), metrics_handle, lfs_upstream: lfs_upstream::Upstream::new(), readiness: prewarm::Readiness::new(), @@ -143,7 +140,8 @@ pub fn router(state: Arc) -> Router { state.clone(), web::require_auth, )); - let inner = Router::new() + + Router::new() .merge( web::api::router(state.clone()) .with_state(()) @@ -176,7 +174,7 @@ pub fn router(state: Arc) -> Router { body: Body| async move { bridge::http_notify(&st, &headers, body) .await - .unwrap_or_else(|e| e.into_response()) + .unwrap_or_else(axum::response::IntoResponse::into_response) }, ), ) @@ -194,7 +192,7 @@ pub fn router(state: Arc) -> Router { )) // A panicking handler must only fail its own request (500), never the process. .layer(tower_http::catch_panic::CatchPanicLayer::custom( - panic_response, + |err: Box| panic_response(&*err), )) // `Server: walgit/ (; )` on every response (incl. errors, // SSE, git pkt streams): which machine answered, without logs. The UI @@ -222,26 +220,24 @@ pub fn router(state: Arc) -> Router { state.inflight.clone(), middleware::request_id, )) - .with_state(state); - inner + .with_state(state) } async fn host_from_authority(mut req: Request) -> Request { - if !req.headers().contains_key(axum::http::header::HOST) { - if let Some(auth) = req.uri().authority().map(|a| a.to_string()) { - if let Ok(v) = axum::http::HeaderValue::from_str(&auth) { - req.headers_mut().insert(axum::http::header::HOST, v); - } - } + if !req.headers().contains_key(axum::http::header::HOST) + && let Some(auth) = req.uri().authority().map(ToString::to_string) + && let Ok(v) = axum::http::HeaderValue::from_str(&auth) + { + req.headers_mut().insert(axum::http::header::HOST, v); } req } -fn panic_response(err: Box) -> Response { +fn panic_response(err: &(dyn std::any::Any + Send + 'static)) -> Response { let msg = err .downcast_ref::() .cloned() - .or_else(|| err.downcast_ref::<&str>().map(|s| s.to_string())) + .or_else(|| err.downcast_ref::<&str>().map(ToString::to_string)) .unwrap_or_else(|| "unknown panic".to_string()); tracing::error!(panic = %msg, "request handler panicked"); ( @@ -285,7 +281,7 @@ fn spawn_runtime_watchdog( }) .map(|pages| pages * 4096 / (1024 * 1024)); tracing::warn!( - gap_ms = gap.as_millis() as u64, + gap_ms = u64::try_from(gap.as_millis()).unwrap_or(u64::MAX), inflight, tasks_running, lock_wait_max_ms = walgit_wal::lockwait::max_wait_ms(), @@ -344,14 +340,16 @@ pub(crate) async fn dispatch_route( } (&Method::POST, "git-upload-pack") => { let _permit = acquire(st, route).await; - smart::upload_pack(st, route, &headers, body.take().unwrap()).await + smart::upload_pack(st, route, &headers, body.take().unwrap_or_else(Body::empty)) + .await } (&Method::POST, "git-receive-pack") => { let _permit = acquire(st, route).await; - smart::receive_pack(st, route, &headers, body.take().unwrap()).await + smart::receive_pack(st, route, &headers, body.take().unwrap_or_else(Body::empty)) + .await } (&Method::POST, "info/lfs/objects/batch") => { - let bytes = collect_body(body.take().unwrap()).await?; + let bytes = collect_body(body.take().unwrap_or_else(Body::empty)).await?; lfs::batch(st, route, &headers, bytes).await } (&Method::GET | &Method::HEAD, s) @@ -360,10 +358,10 @@ pub(crate) async fn dispatch_route( lfs::get_object(st, route, &method, &headers, &query, peer).await } (&Method::PUT, s) if s.starts_with("info/lfs/objects/") => { - lfs::put_object(st, route, &headers, body.take().unwrap()).await + lfs::put_object(st, route, &headers, body.take().unwrap_or_else(Body::empty)).await } (&Method::POST, "info/lfs/verify") => { - let bytes = collect_body(body.take().unwrap()).await?; + let bytes = collect_body(body.take().unwrap_or_else(Body::empty)).await?; lfs::verify(st, route, &headers, bytes).await } (&Method::GET, "bundles/list") => { @@ -382,7 +380,7 @@ pub(crate) async fn dispatch_route( // Admin routes reach here only through `/{o}/{r}/api[-browser]/…` (web::v1). (&Method::GET, "policy") => policy::http_get(st, route, &headers).await, (&Method::PUT, "policy") => { - policy::http_put(st, route, &headers, body.take().unwrap()).await + policy::http_put(st, route, &headers, body.take().unwrap_or_else(Body::empty)).await } (&Method::DELETE, "policy") => policy::http_delete(st, route, &headers).await, (&Method::GET, "settings") => settings::http_get(st, route, &headers).await, @@ -394,18 +392,43 @@ pub(crate) async fn dispatch_route( settings::http_describe(st, route, &headers).await } (&Method::PUT, "settings") => { - settings::http_put(st, route, &headers, &query, body.take().unwrap()).await + settings::http_put( + st, + route, + &headers, + &query, + body.take().unwrap_or_else(Body::empty), + ) + .await } (&Method::DELETE, "settings") => settings::http_delete(st, route, &headers).await, (&Method::POST, "settings/validate") => { - settings::http_validate(st, route, &headers, body.take().unwrap()).await + settings::http_validate( + st, + route, + &headers, + body.take().unwrap_or_else(Body::empty), + ) + .await } (&Method::POST, "policy/validate") => { - settings::http_policy_validate(st, route, &headers, body.take().unwrap()).await + settings::http_policy_validate( + st, + route, + &headers, + body.take().unwrap_or_else(Body::empty), + ) + .await } (&Method::POST, "policy/dry-run") => { - settings::http_policy_dry_run(st, route, &headers, &query, body.take().unwrap()) - .await + settings::http_policy_dry_run( + st, + route, + &headers, + &query, + body.take().unwrap_or_else(Body::empty), + ) + .await } _ => Err(ApiError::NotFound(format!("no route for {method} {sub}"))), } @@ -463,7 +486,10 @@ impl TcpAccept { } pub fn local_addr(&self) -> std::io::Result { - self.listeners[0].local_addr() + self.listeners + .first() + .ok_or_else(|| std::io::Error::other("no listener"))? + .local_addr() } pub fn addrs(&self) -> Vec { @@ -507,7 +533,7 @@ impl axum::serve::Listener for NodelayListener { } } -/// Enable TCP_NODELAY on an accepted stream. Applied via `Listener::tap_io` so +/// Enable `TCP_NODELAY` on an accepted stream. Applied via `Listener::tap_io` so /// the connection stays a plain `TcpStream` and axum's blanket `Connected` impl /// for `TapIo` supplies the peer `SocketAddr` to `ConnectInfo` (used by the /// accel-redirect loopback check). Git's receive-pack status is many small @@ -527,7 +553,7 @@ pub async fn serve( let addr = state.cfg.server.listen; let state_for_shutdown = state.clone(); prewarm::spawn(state.clone()); - bridge::spawn_sweeper(state.clone()); + bridge::spawn_sweeper(&state); spawn_runtime_watchdog(state.registry.tasks().clone(), state.inflight.clone()); let app = router(state); let listener = TcpAccept::bind(addr).await?; @@ -572,27 +598,24 @@ pub async fn serve( tokio::time::sleep(std::time::Duration::from_secs(2)).await; }; let serving = async move { - match tls { - Some(t) => { - axum::serve( - tls::TlsListener { - tcp: listener, - acceptor: t.acceptor.clone(), - }, - app, - ) - .with_graceful_shutdown(graceful) - .await - } - None => { - use axum::serve::ListenerExt; - axum::serve( - NodelayListener(listener).tap_io(set_nodelay), - app.into_make_service_with_connect_info::(), - ) - .with_graceful_shutdown(graceful) - .await - } + if let Some(t) = tls { + axum::serve( + tls::TlsListener { + tcp: listener, + acceptor: t.acceptor.clone(), + }, + app, + ) + .with_graceful_shutdown(graceful) + .await + } else { + use axum::serve::ListenerExt; + axum::serve( + NodelayListener(listener).tap_io(set_nodelay), + app.into_make_service_with_connect_info::(), + ) + .with_graceful_shutdown(graceful) + .await } }; // In-flight requests get `server.drain_timeout` from phase 2 on (a stuck @@ -601,7 +624,7 @@ pub async fn serve( let bound = state_for_shutdown.cfg.server.drain_timeout; tokio::select! { r = serving => r?, - _ = async { phase2.notified().await; tokio::time::sleep(bound).await } => { + () = async { phase2.notified().await; tokio::time::sleep(bound).await } => { tracing::warn!(?bound, "shutdown: in-flight requests still open past server.drain_timeout; exiting"); } } @@ -700,18 +723,14 @@ impl walgit_bundle::BundleSource for RegistryBundleSource { }; } Err(e) => { - tracing::warn!(repo = %id, error = %e, "remote reader unavailable for bundle build; using git") + tracing::warn!(repo = %id, error = %e, "remote reader unavailable for bundle build; using git"); } } } - let linked = h - .local() - .packs() - .map(|ps| { - ps.iter() - .any(|p| h.local().pack_path(&p.checksum).is_symlink()) - }) - .unwrap_or(false); + let linked = h.local().packs().is_ok_and(|ps| { + ps.iter() + .any(|p| h.local().pack_path(&p.checksum).is_symlink()) + }); if linked { return walgit_bundle::BundleEngine::Gix { faulter: None }; } @@ -755,7 +774,7 @@ mod listen_tests { .await .unwrap(); let port = m.local_addr().unwrap().port(); - if !m.addrs().iter().any(|a| a.is_ipv6()) { + if !m.addrs().iter().any(std::net::SocketAddr::is_ipv6) { return; // no IPv6 on this host } tokio::net::TcpStream::connect((std::net::Ipv6Addr::LOCALHOST, port)) diff --git a/crates/walgit-server/src/maintain.rs b/crates/walgit-server/src/maintain.rs index 9f12a2f..ae2772e 100644 --- a/crates/walgit-server/src/maintain.rs +++ b/crates/walgit-server/src/maintain.rs @@ -55,7 +55,7 @@ pub async fn run_loop(state: Arc) { let ticker = { let (state, host, last_unit) = (state.clone(), host.clone(), last_unit.clone()); tokio::spawn(async move { - let mut t = tokio::time::interval(std::time::Duration::from_secs(120)); + let mut t = tokio::time::interval(std::time::Duration::from_mins(2)); t.tick().await; loop { t.tick().await; @@ -70,7 +70,7 @@ pub async fn run_loop(state: Arc) { match outcome { Ok(r) => { if let Some(u) = &r.last_unit { - last_unit = u.clone(); + last_unit.clone_from(u); } span.record("repos", r.repos); span.record("units", r.units); @@ -89,7 +89,7 @@ pub async fn run_loop(state: Arc) { } Err(e) => { span.record("outcome", "error"); - warn!(error = %e, "maintenance pass failed") + warn!(error = %e, "maintenance pass failed"); } } metrics::histogram!("walgit_maintain_pass_seconds", "host" => host.clone()) @@ -251,7 +251,7 @@ pub async fn next_unit(state: &Arc, id: &RepoId) -> anyhow::Result id.to_string()) .set(m.head_seq.saturating_sub(cp_seq) as f64); if let Some(t) = m @@ -268,10 +268,10 @@ pub async fn next_unit(state: &Arc, id: &RepoId) -> anyhow::Result, id: &RepoId) -> anyhow::Result 0 => tracing::info!(repo = %id, pruned = n, "bundle retention applied"), Ok(_) => {} Err(e) => { - tracing::warn!(repo = %id, error = %e, "bundle retention failed; the next publish applies it") + tracing::warn!(repo = %id, error = %e, "bundle retention failed; the next publish applies it"); } } match state @@ -307,11 +307,11 @@ pub async fn next_unit(state: &Arc, id: &RepoId) -> anyhow::Result 0 => { - tracing::info!(repo = %id, settled = n, "closed bundle slots settled") + tracing::info!(repo = %id, settled = n, "closed bundle slots settled"); } Ok(_) => {} Err(e) => { - tracing::warn!(repo = %id, error = %e, "settling closed slots failed; units will measure them") + tracing::warn!(repo = %id, error = %e, "settling closed slots failed; units will measure them"); } } let rows = state @@ -371,10 +371,9 @@ pub async fn next_unit(state: &Arc, id: &RepoId) -> anyhow::Result, id: &RepoId) -> anyhow::Result { let at = f.at.as_ref() - .map(walgit_proto::time::to_system) - .unwrap_or(SystemTime::UNIX_EPOCH); + .map_or(SystemTime::UNIX_EPOCH, walgit_proto::time::to_system); let age = SystemTime::now().duration_since(at).unwrap_or_default(); (age >= interval).then(|| format!("last audit {}h ago", age.as_secs() / 3600)) } @@ -505,12 +503,16 @@ pub async fn upcoming( }; let slot = next .duration_since(SystemTime::UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0); + .map_or(0, |d| d.as_secs()); let (unit, host) = match strat.kind { walgit_config::BundleKind::Full => match &base { Some(b) if many || base_predates_window(handle, strat, slot, b.seq).await => { let gib = b.pack_size as f64 / (1u64 << 30) as f64; + #[allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + reason = "the saturating float-to-int cast is the intended rounding" + )] let mins = (gib * 1.0).max(1.0).round() as u64; match &ssd { Some(h) => (format!("base rebuild (repack {gib:.1} GiB, ~{mins} min on {h}) + compose"), Some(h.clone())), @@ -520,7 +522,7 @@ pub async fn upcoming( Some(b) => ( format!( "compose header ∘ base pack-{} (no push since it)", - &b.checksum[..12] + b.checksum.get(..12).unwrap_or(&b.checksum) ), any.clone(), ), @@ -620,7 +622,7 @@ pub async fn run_pass(state: &Arc) -> anyhow::Result { if value .as_ref() .and_then(|v| v.get("built")) - .and_then(|b| b.as_bool()) + .and_then(serde_json::Value::as_bool) .unwrap_or(false) { report.bundles += 1; @@ -688,7 +690,7 @@ pub async fn run_pass(state: &Arc) -> anyhow::Result { } /// Heartbeats older than this are a departed host, not a stale one. -const HEARTBEAT_EXPIRY: std::time::Duration = std::time::Duration::from_secs(24 * 3600); +const HEARTBEAT_EXPIRY: std::time::Duration = std::time::Duration::from_hours(24); /// Every maintainer heartbeat in the bucket (expired ones purged). pub async fn heartbeats( @@ -700,24 +702,24 @@ pub async fn heartbeats( let mut keys = state.store.list(walgit_proto::keys::MAINTAIN_DIR, None); while let Some(m) = keys.next().await { let m = m?; - if let Some((meta, bytes)) = state.store.get_bytes(&m.key).await? { - if let Ok(hb) = walgit_proto::v1::MaintainerHeartbeat::decode(bytes.as_ref()) { - // A host that has not passed for a day is gone: purge its - // heartbeat so the plan shows only live maintainers. - let age = hb - .last_pass_at - .as_ref() - .map(walgit_proto::time::to_system) - .and_then(|t| SystemTime::now().duration_since(t).ok()); - if age.is_some_and(|a| a > HEARTBEAT_EXPIRY) { - if state.cfg.has_role(walgit_config::Role::Maintain) { - info!(host = %hb.host, age_secs = age.map(|a| a.as_secs()).unwrap_or(0), "maintenance: purging expired heartbeat"); - let _ = state.store.delete(&m.key, Some(meta.version)).await; - } - continue; + if let Some((meta, bytes)) = state.store.get_bytes(&m.key).await? + && let Ok(hb) = walgit_proto::v1::MaintainerHeartbeat::decode(bytes.as_ref()) + { + // A host that has not passed for a day is gone: purge its + // heartbeat so the plan shows only live maintainers. + let age = hb + .last_pass_at + .as_ref() + .map(walgit_proto::time::to_system) + .and_then(|t| SystemTime::now().duration_since(t).ok()); + if age.is_some_and(|a| a > HEARTBEAT_EXPIRY) { + if state.cfg.has_role(walgit_config::Role::Maintain) { + info!(host = %hb.host, age_secs = age.map_or(0, |a| a.as_secs()), "maintenance: purging expired heartbeat"); + let _ = state.store.delete(&m.key, Some(meta.version)).await; } - out.push(hb); + continue; } + out.push(hb); } } Ok(out) @@ -784,8 +786,7 @@ async fn run_op_value( ) -> Option { let started = Instant::now(); let task = match crate::ops::start(state.clone(), id.clone(), op, params).await { - Ok(t) => t, - Err(crate::ops::StartError::AlreadyRunning(t)) => t, + Ok(t) | Err(crate::ops::StartError::AlreadyRunning(t)) => t, Err(crate::ops::StartError::UnknownOp) => { warn!(repo = %id, op, "maintenance: cannot start op"); return None; @@ -793,13 +794,13 @@ async fn run_op_value( }; // Bounded: a maintenance op that runs longer than an hour is reported and // left running (it stays discoverable at …/tasks); the pass moves on. - if !task.wait_done(std::time::Duration::from_secs(3600)).await { + if !task.wait_done(std::time::Duration::from_hours(1)).await { warn!(repo = %id, op, "maintenance: op still running after 1h; moving on"); return None; } match task.outcome() { Some(Ok(o)) => { - info!(repo = %id, op, ms = started.elapsed().as_millis() as u64, "maintenance: done"); + info!(repo = %id, op, ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX), "maintenance: done"); Some(o.value.unwrap_or(serde_json::Value::Null)) } Some(Err((_, msg))) => { diff --git a/crates/walgit-server/src/metrics.rs b/crates/walgit-server/src/metrics.rs index 96ef46a..e4cbc47 100644 --- a/crates/walgit-server/src/metrics.rs +++ b/crates/walgit-server/src/metrics.rs @@ -14,10 +14,10 @@ static HANDLE: OnceLock> = OnceLock::new(); /// Install the Prometheus recorder once per process and return a shared handle. /// Safe to call repeatedly (subsequent calls return the same handle). pub fn install() -> anyhow::Result> { + use metrics_exporter_prometheus::PrometheusBuilder; if let Some(h) = HANDLE.get() { return Ok(h.clone()); } - use metrics_exporter_prometheus::PrometheusBuilder; let rec = PrometheusBuilder::new().build_recorder(); let handle = Arc::new(rec.handle()); // set_global_recorder fails if already set; ignore that race — the handle is diff --git a/crates/walgit-server/src/middleware.rs b/crates/walgit-server/src/middleware.rs index 6b41893..c514d53 100644 --- a/crates/walgit-server/src/middleware.rs +++ b/crates/walgit-server/src/middleware.rs @@ -86,8 +86,7 @@ pub async fn request_id( .get(REQUEST_ID_HEADER) .and_then(|v| v.to_str().ok()) .filter(|s| !s.is_empty()) - .map(|s| s.to_string()) - .unwrap_or_else(|| Uuid::new_v4().to_string()); + .map_or_else(|| Uuid::new_v4().to_string(), ToString::to_string); if let Ok(hv) = HeaderValue::from_str(&id) { req.headers_mut().insert(REQUEST_ID_HEADER, hv); } @@ -159,6 +158,10 @@ impl RepoSemaphores { /// Acquire a permit for `repo_key`. The permit guards the git operation; drop /// it to release the slot. + #[allow( + clippy::expect_used, + reason = "the semaphore is owned by this map and is never closed" + )] pub async fn acquire(&self, repo_key: &str) -> tokio::sync::OwnedSemaphorePermit { let sem = self .map diff --git a/crates/walgit-server/src/ops.rs b/crates/walgit-server/src/ops.rs index 3dbe9b2..293d456 100644 --- a/crates/walgit-server/src/ops.rs +++ b/crates/walgit-server/src/ops.rs @@ -141,6 +141,9 @@ pub enum StartError { /// Start `op` for `id` on this instance as a background task and return its /// state (stream it with [`crate::sse::task_stream`]). The op keeps running if /// every client goes away. +// The params map is threaded straight into `run`, which is not generic over the +// hasher, so a generic `S` here would only move the concrete type one call deeper. +#[allow(clippy::implicit_hasher)] pub async fn start( state: Arc, id: RepoId, @@ -201,8 +204,7 @@ pub async fn read_fsck( fn flag(params: &HashMap, key: &str) -> bool { params .get(key) - .map(|v| matches!(v.as_str(), "1" | "true" | "yes" | "on")) - .unwrap_or(false) + .is_some_and(|v| matches!(v.as_str(), "1" | "true" | "yes" | "on")) } async fn run( @@ -276,7 +278,7 @@ async fn run( .map_err(|e| format!("writing fsck.pb: {e}"))?; metrics::gauge!("walgit_repo_missing_objects", "repo" => id.to_string()) .set(missing.len() as f64); - tracing::info!(repo = %id, seq, missing = missing.len(), problems = report.problems, elapsed_ms = t0.elapsed().as_millis() as u64, "fsck recorded"); + tracing::info!(repo = %id, seq, missing = missing.len(), problems = report.problems, elapsed_ms = u64::try_from(t0.elapsed().as_millis()).unwrap_or(u64::MAX), "fsck recorded"); let summary = if report.ok { format!( "fsck clean ({lines} lines, {:.0}s)", @@ -320,7 +322,7 @@ async fn run( serde_json::json!({"missing": 0}), )); } - if fsck.missing_total as usize > fsck.missing.len() { + if usize::try_from(fsck.missing_total).unwrap_or(usize::MAX) > fsck.missing.len() { log(format!( "fsck listed {} of {} missing objects; repairing those, the next fsck finds the rest", fsck.missing.len(), @@ -332,7 +334,6 @@ async fn run( state .lfs_upstream .secret(name) - .await .map_err(|e| format!("upstream token: {e}"))?, ), None => None, @@ -378,7 +379,7 @@ async fn run( .map_err(|e| format!("writing fsck.pb: {e}"))?; metrics::counter!("walgit_repair_objects_total", "repo" => id.to_string()) .increment(pack.objects); - tracing::info!(repo = %id, seq, objects = pack.objects, bytes = pack.bytes, %upstream, elapsed_ms = t0.elapsed().as_millis() as u64, "repair published"); + tracing::info!(repo = %id, seq, objects = pack.objects, bytes = pack.bytes, %upstream, elapsed_ms = u64::try_from(t0.elapsed().as_millis()).unwrap_or(u64::MAX), "repair published"); Ok(( format!( "repaired {} object(s) ({} bytes) from upstream at seq {seq}", @@ -402,7 +403,7 @@ async fn run( .write_rev_index(&oid) .await .map_err(|e| format!("rev-index: {e}"))?; - let bytes = std::fs::metadata(&rev).map(|m| m.len()).unwrap_or(0); + let bytes = std::fs::metadata(&rev).map_or(0, |m| m.len()); log(format!( "pack-{checksum}.rev: {bytes} bytes in {:.1}s; publishing", t0.elapsed().as_secs_f64() @@ -411,7 +412,7 @@ async fn run( .annotate_pack(&checksum, Some(rev), None, None) .await .map_err(|e| format!("rev-index publish: {e}"))?; - tracing::info!(repo = %id, pack = %checksum, bytes, elapsed_ms = t0.elapsed().as_millis() as u64, "rev index published"); + tracing::info!(repo = %id, pack = %checksum, bytes, elapsed_ms = u64::try_from(t0.elapsed().as_millis()).unwrap_or(u64::MAX), "rev index published"); Ok(( format!("pack-{checksum}.rev ({bytes} bytes) published"), serde_json::json!({"pack": checksum, "bytes": bytes}), @@ -445,8 +446,7 @@ async fn run( "building {strategy} slot {slot} ({})", walgit_bundle::slots::from_epoch(slot) .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0) + .map_or(0, |d| d.as_secs()) )); // A FULL slot of a repository that has a tier-2 base is a compose // of that base (header = refs at the base's seq) — never a diff --git a/crates/walgit-server/src/pktline.rs b/crates/walgit-server/src/pktline.rs index 54830f8..2297b66 100644 --- a/crates/walgit-server/src/pktline.rs +++ b/crates/walgit-server/src/pktline.rs @@ -8,15 +8,11 @@ pub const MAX_DATA_LEN: usize = 65516; /// Encode a data line into `buf`. Panics if `data` exceeds [`MAX_DATA_LEN`]. pub fn encode_line(buf: &mut Vec, data: &[u8]) { + const HEX: &[u8; 16] = b"0123456789abcdef"; assert!(data.len() <= MAX_DATA_LEN, "pkt-line too long"); let len = data.len() + 4; - const HEX: &[u8; 16] = b"0123456789abcdef"; - buf.extend_from_slice(&[ - HEX[(len >> 12) & 0xf], - HEX[(len >> 8) & 0xf], - HEX[(len >> 4) & 0xf], - HEX[len & 0xf], - ]); + let nib = |shift: usize| HEX.get((len >> shift) & 0xf).copied().unwrap_or(b'0'); + buf.extend_from_slice(&[nib(12), nib(8), nib(4), nib(0)]); buf.extend_from_slice(data); } diff --git a/crates/walgit-server/src/policy.rs b/crates/walgit-server/src/policy.rs index 59a608f..70c4975 100644 --- a/crates/walgit-server/src/policy.rs +++ b/crates/walgit-server/src/policy.rs @@ -183,9 +183,9 @@ impl RepoPolicy { if !rule_names.insert(&r.name) { return Err(format!("rules: duplicate name {:?}", r.name)); } - let n = r.effect.protect.is_some() as u8 - + r.effect.history.is_some() as u8 - + r.effect.size.is_some() as u8; + let n = u8::from(r.effect.protect.is_some()) + + u8::from(r.effect.history.is_some()) + + u8::from(r.effect.size.is_some()); if n != 1 { return Err(format!( "rule {:?}: effect must have exactly one of protect, history, size", @@ -218,7 +218,7 @@ impl RepoPolicy { fn valid_name(s: &str) -> bool { let b = s.as_bytes(); (1..=63).contains(&b.len()) - && b[0].is_ascii_lowercase() + && b.first().is_some_and(u8::is_ascii_lowercase) && b.iter() .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || *c == b'-') } @@ -226,28 +226,28 @@ fn valid_name(s: &str) -> bool { /// Two overlapping protect rules with non-empty, disjoint bypass lists cannot /// both be satisfied. AND would lock out the intended bot. fn check_overlap_bypass(p: &RepoPolicy) -> Result<(), String> { - let protect: Vec<&Rule> = p + let protect: Vec<(&Rule, &ProtectEffect)> = p .rules .iter() - .filter(|r| r.effect.protect.is_some()) + .filter_map(|r| r.effect.protect.as_ref().map(|pr| (r, pr))) .collect(); - for (i, a) in protect.iter().enumerate() { - for b in &protect[i + 1..] { + for (i, (a, pa)) in protect.iter().enumerate() { + for (b, pb) in protect.iter().skip(i + 1) { if !ref_patterns_may_overlap(&a.match_.refs, &b.match_.refs) { continue; } - let ra = restrict_set(a.effect.protect.as_ref().unwrap()); - let rb = restrict_set(b.effect.protect.as_ref().unwrap()); + let ra = restrict_set(pa); + let rb = restrict_set(pb); if ra.is_disjoint(&rb) { continue; } - let ba = &a.effect.protect.as_ref().unwrap().bypass; - let bb = &b.effect.protect.as_ref().unwrap().bypass; + let ba = &pa.bypass; + let bb = &pb.bypass; if ba.is_empty() || bb.is_empty() { continue; } - let set_a: HashSet<&str> = ba.iter().map(|s| s.as_str()).collect(); - let set_b: HashSet<&str> = bb.iter().map(|s| s.as_str()).collect(); + let set_a: HashSet<&str> = ba.iter().map(String::as_str).collect(); + let set_b: HashSet<&str> = bb.iter().map(String::as_str).collect(); if set_a.is_disjoint(&set_b) { return Err(format!( "protect rules {:?} and {:?} overlap with disjoint bypass lists", @@ -324,18 +324,19 @@ pub fn glob_match(pat: &str, text: &str) -> bool { fn glob_bytes(pat: &[u8], text: &[u8]) -> bool { let mut pi = 0; let mut ti = 0; - while pi < pat.len() { - if pat[pi] == b'*' && pi + 1 < pat.len() && pat[pi + 1] == b'*' { - let mut rest = &pat[pi + 2..]; + let tail = |from: usize| text.get(from..).unwrap_or_default(); + while let Some(&p) = pat.get(pi) { + if p == b'*' && pat.get(pi + 1) == Some(&b'*') { + let mut rest = pat.get(pi + 2..).unwrap_or_default(); if rest.first() == Some(&b'/') { - rest = &rest[1..]; + rest = rest.get(1..).unwrap_or_default(); } if rest.is_empty() { return true; } let mut i = ti; loop { - if glob_bytes(rest, &text[i..]) { + if glob_bytes(rest, tail(i)) { return true; } if i >= text.len() { @@ -343,26 +344,26 @@ fn glob_bytes(pat: &[u8], text: &[u8]) -> bool { } i += 1; } - } else if pat[pi] == b'*' { - let rest = &pat[pi + 1..]; - if glob_bytes(rest, &text[ti..]) { + } else if p == b'*' { + let rest = pat.get(pi + 1..).unwrap_or_default(); + if glob_bytes(rest, tail(ti)) { return true; } - while ti < text.len() && text[ti] != b'/' { + while text.get(ti).is_some_and(|&c| c != b'/') { ti += 1; - if glob_bytes(rest, &text[ti..]) { + if glob_bytes(rest, tail(ti)) { return true; } } return false; - } else if pat[pi] == b'?' { - if ti >= text.len() || text[ti] == b'/' { + } else if p == b'?' { + if text.get(ti).is_none_or(|&c| c == b'/') { return false; } ti += 1; pi += 1; } else { - if ti >= text.len() || text[ti] != pat[pi] { + if text.get(ti) != Some(&p) { return false; } ti += 1; @@ -434,9 +435,11 @@ fn actor_list_matches( if let Some(rest) = p.strip_prefix('^') { let mut seen = HashSet::new(); // Unresolvable exclude still excludes: treat missing group as hit. - if rest.starts_with("group:") && !groups.contains_key(&rest[6..]) { - exc = true; - } else if principal_matches(rest, principal, groups, &mut seen) { + if rest + .strip_prefix("group:") + .is_some_and(|g| !groups.contains_key(g)) + || principal_matches(rest, principal, groups, &mut seen) + { exc = true; } } else { @@ -657,7 +660,11 @@ pub async fn http_get( route: &RepoRoute, headers: &HeaderMap, ) -> Result { - let _ = st.auth.require_read(headers).await.map_err(auth_err)?; + let _ = st + .auth + .require_read(headers) + .await + .map_err(ApiError::from)?; ensure_repo(st, route).await?; let policy = load(&st.store, &route.id).await.map_err(store_err)?; let body = serde_json::to_vec_pretty(&policy) @@ -679,7 +686,11 @@ pub async fn http_put( headers: &HeaderMap, body: axum::body::Body, ) -> Result { - let _ = st.auth.require_admin(headers).await.map_err(auth_err)?; + let _ = st + .auth + .require_admin(headers) + .await + .map_err(ApiError::from)?; ensure_repo(st, route).await?; let bytes = crate::collect_body(body).await?; let policy = parse_bytes(&bytes).map_err(store_err)?; @@ -694,7 +705,11 @@ pub async fn http_delete( route: &RepoRoute, headers: &HeaderMap, ) -> Result { - let _ = st.auth.require_admin(headers).await.map_err(auth_err)?; + let _ = st + .auth + .require_admin(headers) + .await + .map_err(ApiError::from)?; ensure_repo(st, route).await?; clear(&st.store, &route.id).await.map_err(store_err)?; Ok((StatusCode::NO_CONTENT, "").into_response()) @@ -710,18 +725,6 @@ async fn ensure_repo(st: &AppState, route: &RepoRoute) -> Result<(), ApiError> { }) } -fn auth_err(e: crate::auth::AuthError) -> ApiError { - match e { - crate::auth::AuthError::Invalid | crate::auth::AuthError::Unauthorized => { - ApiError::Unauthorized - } - crate::auth::AuthError::Forbidden => ApiError::Forbidden, - crate::auth::AuthError::Unavailable => { - ApiError::ServiceUnavailable("auth provider unavailable".into()) - } - } -} - fn store_err(e: StoreError) -> ApiError { match e { StoreError::InvalidArgument(msg) => ApiError::BadRequest(msg), diff --git a/crates/walgit-server/src/prewarm.rs b/crates/walgit-server/src/prewarm.rs index cf14bf5..e2f7a00 100644 --- a/crates/walgit-server/src/prewarm.rs +++ b/crates/walgit-server/src/prewarm.rs @@ -67,8 +67,8 @@ pub fn spawn(state: Arc) { let _p = sem.acquire().await; let t = Instant::now(); match warm(&st, &r).await { - Ok(summary) => tracing::info!(repo = %r, elapsed_ms = t.elapsed().as_millis() as u64, "prewarm: {summary}"), - Err(e) => tracing::warn!(repo = %r, elapsed_ms = t.elapsed().as_millis() as u64, "prewarm failed: {e}"), + Ok(summary) => tracing::info!(repo = %r, elapsed_ms = u64::try_from(t.elapsed().as_millis()).unwrap_or(u64::MAX), "prewarm: {summary}"), + Err(e) => tracing::warn!(repo = %r, elapsed_ms = u64::try_from(t.elapsed().as_millis()).unwrap_or(u64::MAX), "prewarm failed: {e}"), } st.readiness.pending.fetch_sub(1, Ordering::AcqRel); })); @@ -86,7 +86,7 @@ async fn warm(st: &Arc, repo: &str) -> Result { .parse() .map_err(|e: walgit_git::GitError| e.to_string())?; let handle = st.registry.open(&id).await.map_err(|e| e.to_string())?; - let task = match handle.begin_task("prewarm", Default::default()) { + let task = match handle.begin_task("prewarm", std::collections::HashMap::default()) { walgit_wal::Begin::Started(t) => t, walgit_wal::Begin::AlreadyRunning(_) => return Ok("already warming".into()), }; @@ -142,20 +142,19 @@ async fn warm(st: &Arc, repo: &str) -> Result { .find(|r| r.name == head.head_target) .map(|r| r.oid.clone()); if let (Some(sha), walgit_wal::ObjectAccess::Remote(packs)) = (head_sha.as_deref(), &access) + && let Ok(oid) = gix_hash::ObjectId::from_hex(sha.as_bytes()) { - if let Ok(oid) = gix_hash::ObjectId::from_hex(sha.as_bytes()) { - reporter.notice(format!( - "Reading the root tree of {} from the pack set", - &sha[..12] - )); - let remote = crate::web::objects::Remote::new( - packs.clone(), - handle.local().clone(), - reporter.clone(), - ); - let (_c, tree, _m) = remote.fault_path(&oid, "").await.map_err(|e| e.message())?; - let _ = remote.tree_entries(&tree).await; - } + reporter.notice(format!( + "Reading the root tree of {} from the pack set", + sha.get(..12).unwrap_or(sha) + )); + let remote = crate::web::objects::Remote::new( + packs.clone(), + handle.local().clone(), + reporter.clone(), + ); + let (_c, tree, _m) = remote.fault_path(&oid, "").await.map_err(|e| e.message())?; + let _ = remote.tree_entries(&tree).await; } Ok(format!("warm: {mode}")) } diff --git a/crates/walgit-server/src/rebuild.rs b/crates/walgit-server/src/rebuild.rs index 13fd769..0d2fc2a 100644 --- a/crates/walgit-server/src/rebuild.rs +++ b/crates/walgit-server/src/rebuild.rs @@ -93,7 +93,9 @@ fn read_marker(path: &Path) -> Option { } fn write_marker(path: &Path, m: &Marker) -> anyhow::Result<()> { - std::fs::create_dir_all(path.parent().unwrap())?; + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir)?; + } let tmp = path.with_extension("json.tmp"); std::fs::write(&tmp, serde_json::to_vec_pretty(m)?)?; std::fs::rename(&tmp, path)?; @@ -125,15 +127,25 @@ fn copy_tree(src: &Path, dst: &Path) -> std::io::Result { Ok(bytes) } +/// statvfs field widths differ per platform, so widen through a generic bound rather +/// than a conversion that is redundant on one target and required on another. +fn widen>(v: T) -> u64 { + v.into() +} + +#[allow(unsafe_code)] fn disk_avail(path: &Path) -> Option { use std::ffi::CString; use std::os::unix::ffi::OsStrExt; let c = CString::new(path.as_os_str().as_bytes()).ok()?; + // SAFETY: statvfs is a plain C struct of integers, so all-zero is a valid value. let mut st: libc::statvfs = unsafe { std::mem::zeroed() }; - if unsafe { libc::statvfs(c.as_ptr(), &mut st) } != 0 { + // SAFETY: `c` is a live NUL-terminated CString and `st` is a live, correctly + // typed statvfs that the call only writes into. + if unsafe { libc::statvfs(c.as_ptr(), &raw mut st) } != 0 { return None; } - Some(st.f_bavail as u64 * st.f_frsize as u64) + Some(widen(st.f_bavail) * widen(st.f_frsize)) } /// Hard-link (or copy) every side-file of `pack` from `from` into `into`'s pack dir; existing @@ -145,7 +157,9 @@ fn install_pack( ) -> anyhow::Result<()> { let src = from.pack_path(pack); let dst = into.pack_path(pack); - std::fs::create_dir_all(dst.parent().unwrap())?; + if let Some(dir) = dst.parent() { + std::fs::create_dir_all(dir)?; + } for ext in ["pack", "idx", "rev", "bitmap", "commit-graph", "history"] { let s = src.with_extension(ext); if !s.exists() { @@ -348,7 +362,7 @@ pub async fn rebuild_base( if let Some(p) = already && p.tier == 2 && (p.has_bitmap || info.history_of.is_some()) - && supersedes_left.as_ref().is_none_or(|s| s.is_empty()) + && supersedes_left.as_ref().is_none_or(std::vec::Vec::is_empty) { log(format!( "pack {hex} is already live as tier 2: not re-published" diff --git a/crates/walgit-server/src/settings.rs b/crates/walgit-server/src/settings.rs index 735fd74..50985ff 100644 --- a/crates/walgit-server/src/settings.rs +++ b/crates/walgit-server/src/settings.rs @@ -19,15 +19,6 @@ use serde_json::json; use crate::error::ApiError; use crate::{AppState, RepoRoute}; -fn auth_err(e: crate::auth::AuthError) -> ApiError { - match e { - crate::auth::AuthError::Invalid | crate::auth::AuthError::Unauthorized => { - ApiError::Unauthorized - } - _ => ApiError::Forbidden, - } -} - async fn open(st: &AppState, route: &RepoRoute) -> Result, ApiError> { st.registry.open(&route.id).await.map_err(|e| { if matches!(e, walgit_wal::WalError::NotFound) { @@ -48,7 +39,11 @@ pub async fn http_get( route: &RepoRoute, headers: &HeaderMap, ) -> Result { - let _ = st.auth.require_read(headers).await.map_err(auth_err)?; + let _ = st + .auth + .require_read(headers) + .await + .map_err(ApiError::from)?; let h = open(st, route).await?; h.sync_refs() .await @@ -72,7 +67,11 @@ pub async fn http_effective( route: &RepoRoute, headers: &HeaderMap, ) -> Result { - let _ = st.auth.require_read(headers).await.map_err(auth_err)?; + let _ = st + .auth + .require_read(headers) + .await + .map_err(ApiError::from)?; let h = open(st, route).await?; h.sync_refs() .await @@ -100,7 +99,11 @@ pub async fn http_history( route: &RepoRoute, headers: &HeaderMap, ) -> Result { - let _ = st.auth.require_read(headers).await.map_err(auth_err)?; + let _ = st + .auth + .require_read(headers) + .await + .map_err(ApiError::from)?; let h = open(st, route).await?; h.sync_refs() .await @@ -133,7 +136,11 @@ pub async fn http_put( query: &str, body: axum::body::Body, ) -> Result { - let principal = st.auth.require_admin(headers).await.map_err(auth_err)?; + let principal = st + .auth + .require_admin(headers) + .await + .map_err(ApiError::from)?; let h = open(st, route).await?; let bytes = crate::collect_body(body).await?; if bytes.len() > walgit_config::SETTINGS_MAX_BYTES { @@ -155,7 +162,11 @@ pub async fn http_delete( route: &RepoRoute, headers: &HeaderMap, ) -> Result { - let principal = st.auth.require_admin(headers).await.map_err(auth_err)?; + let principal = st + .auth + .require_admin(headers) + .await + .map_err(ApiError::from)?; let h = open(st, route).await?; publish(&h, "", &principal.name, "clear").await } @@ -179,11 +190,14 @@ fn percent_decode(v: &str) -> String { let mut out = Vec::with_capacity(v.len()); let b = v.as_bytes(); let mut i = 0; - while i < b.len() { - match b[i] { + while let Some(&c) = b.get(i) { + match c { b'+' => out.push(b' '), b'%' if i + 2 < b.len() => { - if let Ok(n) = u8::from_str_radix(&v[i + 1..i + 3], 16) { + if let Some(n) = v + .get(i + 1..i + 3) + .and_then(|h| u8::from_str_radix(h, 16).ok()) + { out.push(n); i += 3; continue; @@ -227,7 +241,11 @@ pub async fn http_describe( route: &RepoRoute, headers: &HeaderMap, ) -> Result { - let _ = st.auth.require_read(headers).await.map_err(auth_err)?; + let _ = st + .auth + .require_read(headers) + .await + .map_err(ApiError::from)?; let h = open(st, route).await?; h.sync_refs() .await @@ -351,15 +369,13 @@ fn human_schedule(expr: &str) -> String { _ => {} } let f: Vec<&str> = expr.split_whitespace().collect(); - if f.len() != 6 { + let [sec, min, hour, dom, mon, dow] = f.as_slice() else { return expr.to_string(); - } - let (sec, min, hour, dom, mon, dow) = (f[0], f[1], f[2], f[3], f[4], f[5]); + }; + let (sec, min, hour, dom, mon, dow) = (*sec, *min, *hour, *dom, *mon, *dow); let hm = match (hour.parse::(), min.parse::()) { (Ok(h), Ok(m)) => format!("at {h:02}:{m:02} UTC"), - _ if hour == "*" && min.parse::().is_ok() => { - format!("every hour at :{:02} UTC", min.parse::().unwrap()) - } + (_, Ok(m)) if hour == "*" => format!("every hour at :{m:02} UTC"), _ => format!("at {hour}:{min}"), }; let day = if dow != "*" && dow != "?" { @@ -394,7 +410,11 @@ pub async fn http_validate( headers: &HeaderMap, body: axum::body::Body, ) -> Result { - let _ = st.auth.require_read(headers).await.map_err(auth_err)?; + let _ = st + .auth + .require_read(headers) + .await + .map_err(ApiError::from)?; let h = open(st, route).await?; let bytes = crate::collect_body(body).await?; let text = std::str::from_utf8(&bytes) @@ -403,14 +423,16 @@ pub async fn http_validate( Ok(eff) => { let preview = walgit_proto::v1::RepoSettings { toml: text.to_string(), - revision: h.settings().map(|s| s.revision + 1).unwrap_or(1), + revision: h.settings().map_or(1, |s| s.revision + 1), author: "(preview)".into(), updated_at: None, message: String::new(), }; let mut d = describe_json(st, &h, &eff, Some(&preview))?; - d["ok"] = json!(true); - d["errors"] = json!([]); + if let Some(obj) = d.as_object_mut() { + obj.insert("ok".into(), json!(true)); + obj.insert("errors".into(), json!([])); + } d } Err(e) => json!({"ok": false, "errors": [format!("{e:#}")]}), @@ -430,7 +452,11 @@ pub async fn http_policy_validate( headers: &HeaderMap, body: axum::body::Body, ) -> Result { - let _ = st.auth.require_read(headers).await.map_err(auth_err)?; + let _ = st + .auth + .require_read(headers) + .await + .map_err(ApiError::from)?; let _ = open(st, route).await?; let bytes = crate::collect_body(body).await?; let out = match crate::policy::parse_document(&bytes) { @@ -454,7 +480,11 @@ pub async fn http_policy_dry_run( query: &str, body: axum::body::Body, ) -> Result { - let _ = st.auth.require_read(headers).await.map_err(auth_err)?; + let _ = st + .auth + .require_read(headers) + .await + .map_err(ApiError::from)?; let h = open(st, route).await?; h.sync_refs() .await @@ -467,7 +497,7 @@ pub async fn http_policy_dry_run( .unwrap_or(20) .clamp(1, 200); let bytes = crate::collect_body(body).await?; - let policy = if bytes.iter().all(|b| b.is_ascii_whitespace()) { + let policy = if bytes.iter().all(u8::is_ascii_whitespace) { crate::policy::load(&st.store, &route.id) .await .map_err(|e| ApiError::Internal(e.to_string()))? @@ -479,17 +509,20 @@ pub async fn http_policy_dry_run( .read_log(m.min_seq.max(1), None) .await .map_err(|e| ApiError::Internal(e.to_string()))?; - let pushes: Vec<&walgit_proto::v1::LogEntry> = entries + let pushes: Vec<( + &walgit_proto::v1::LogEntry, + &walgit_proto::v1::RefTransaction, + )> = entries .iter() .rev() - .filter(|e| e.kind() == walgit_proto::v1::EntryKind::Push && e.txn.is_some()) + .filter(|e| e.kind() == walgit_proto::v1::EntryKind::Push) + .filter_map(|e| e.txn.as_ref().map(|t| (e, t))) .take(last) .collect(); let local = h.local(); let mut results = Vec::new(); let (mut allowed_n, mut denied_n) = (0usize, 0usize); - for e in pushes { - let txn = e.txn.clone().unwrap(); + for (e, txn) in pushes { let principal = e .meta .get("principal") @@ -505,7 +538,7 @@ pub async fn http_policy_dry_run( } } } - let ev = crate::policy::evaluate(&policy, &principal, &txn, |u| forces.contains(&u.name)); + let ev = crate::policy::evaluate(&policy, &principal, txn, |u| forces.contains(&u.name)); let refs: Vec = ev .per_ref .iter() diff --git a/crates/walgit-server/src/smart.rs b/crates/walgit-server/src/smart.rs index 583e231..0b3c90a 100644 --- a/crates/walgit-server/src/smart.rs +++ b/crates/walgit-server/src/smart.rs @@ -1,8 +1,8 @@ //! Git smart HTTP protocol (v0/v2): info/refs, upload-pack, receive-pack. //! //! References: -//! * https://git-scm.com/docs/http-protocol -//! * https://git-scm.com/docs/protocol-v2 +//! * +//! * use std::collections::HashMap; use std::sync::Arc; @@ -64,12 +64,10 @@ pub async fn info_refs( &auth_help_message(st, headers, &e), )); } - return Err(auth_err(e)); + return Err(ApiError::from(e)); } - if is_receive { - if let Some(msg) = push_url_must_be_git(st, route, headers) { - return Ok(git_err_response("git-receive-pack", &msg)); - } + if is_receive && let Some(msg) = push_url_must_be_git(st, route, headers) { + return Ok(git_err_response("git-receive-pack", &msg)); } let service = match service_param.as_str() { @@ -80,7 +78,7 @@ pub async fn info_refs( let handle = open_repo(st, &route.id, is_receive).await?; // Advertisements need refs only: never wait for (or require) the pack set. - let _guard = handle.sync_refs().await.map_err(wal_err)?; + let _guard = handle.sync_refs().await.map_err(ApiError::from)?; let protocol = walgit_git::pkt::Protocol::from_git_protocol_header( headers.get("git-protocol").and_then(|v| v.to_str().ok()), @@ -92,31 +90,28 @@ pub async fn info_refs( pktline::encode_text(&mut buf, &svc_line); pktline::encode_flush(&mut buf); - match (protocol, service) { - (walgit_git::pkt::Protocol::V2, walgit_git::Service::UploadPack) => { - v2_capability_advert(st, &route.id, &handle, &mut buf).await?; - } - _ => { - // v0 (and receive-pack always). - let repo_key = route.id.to_string(); - let ver = handle.manifest_version(); - if let Some(cached) = st - .caches + if let (walgit_git::pkt::Protocol::V2, walgit_git::Service::UploadPack) = (protocol, service) { + v2_capability_advert(st, &route.id, &handle, &mut buf).await?; + } else { + // v0 (and receive-pack always). + let repo_key = route.id.to_string(); + let ver = handle.manifest_version(); + if let Some(cached) = st + .caches + .ref_advert + .get_v0(&repo_key, ver.as_ref(), service) + { + buf.extend_from_slice(&cached); + } else { + let start = buf.len(); + handle + .local() + .advertise_refs_v0(service, &mut buf) + .map_err(ApiError::from)?; + let advert_bytes = buf.get(start..).unwrap_or_default().to_vec(); + st.caches .ref_advert - .get_v0(&repo_key, ver.as_ref(), service) - { - buf.extend_from_slice(&cached); - } else { - let start = buf.len(); - handle - .local() - .advertise_refs_v0(service, &mut buf) - .map_err(git_err)?; - let advert_bytes = buf[start..].to_vec(); - st.caches - .ref_advert - .insert_v0(&repo_key, ver.as_ref(), service, advert_bytes); - } + .insert_v0(&repo_key, ver.as_ref(), service, advert_bytes); } } @@ -126,10 +121,10 @@ pub async fn info_refs( fn parse_query(query: &str, key: &str) -> Option { for pair in query.split('&') { - if let Some((k, v)) = pair.split_once('=') { - if k == key { - return Some(v.to_string()); - } + if let Some((k, v)) = pair.split_once('=') + && k == key + { + return Some(v.to_string()); } } None @@ -162,10 +157,10 @@ async fn v2_capability_advert( walgit_git::ObjectFormat::Sha256 => "sha256", }; pktline::encode_text(buf, &format!("object-format={fmt}\n")); - if st.cfg.bundles.advertise { - if let Ok(Some(_list)) = st.bundles.list(id).await { - pktline::encode_text(buf, "bundle-uri\n"); - } + if st.cfg.bundles.advertise + && let Ok(Some(_list)) = st.bundles.list(id).await + { + pktline::encode_text(buf, "bundle-uri\n"); } pktline::encode_flush(buf); Ok(()) @@ -178,7 +173,10 @@ pub async fn upload_pack( headers: &HeaderMap, body: Body, ) -> Result { - st.auth.require_read(headers).await.map_err(auth_err)?; + st.auth + .require_read(headers) + .await + .map_err(ApiError::from)?; let handle = open_repo(st, &route.id, false).await?; @@ -208,14 +206,14 @@ async fn upload_pack_v2( ) -> Result { let (cmd, reader) = walgit_git::pkt::read_command(reader) .await - .map_err(git_err)?; + .map_err(ApiError::from)?; match cmd.name.as_str() { "ls-refs" => { - let _guard = handle.sync_refs().await.map_err(wal_err)?; + let _guard = handle.sync_refs().await.map_err(ApiError::from)?; let req = walgit_git::pkt::parse_ls_refs(&cmd); let req = walgit_git::pkt::read_ls_refs_args(reader, req) .await - .map_err(git_err)?; + .map_err(ApiError::from)?; let args = walgit_git::LsRefsArgs { ref_prefixes: req.prefixes, symrefs: req.symrefs, @@ -224,24 +222,22 @@ async fn upload_pack_v2( }; let repo_key = route.id.to_string(); let version = handle.manifest_version(); - let lines = - match st - .caches + let lines = if let Some(lines) = + st.caches .ref_advert .get_v2_ls_refs(&repo_key, version.as_ref(), &args) - { - Some(lines) => lines, - None => { - let lines = handle.local().ls_refs(&args).map_err(git_err)?; - st.caches.ref_advert.insert_v2_ls_refs( - &repo_key, - version.as_ref(), - &args, - lines.clone(), - ); - lines - } - }; + { + lines + } else { + let lines = handle.local().ls_refs(&args).map_err(ApiError::from)?; + st.caches.ref_advert.insert_v2_ls_refs( + &repo_key, + version.as_ref(), + &args, + lines.clone(), + ); + lines + }; let mut buf = Vec::with_capacity(1024); for line in &lines { pktline::encode_text(&mut buf, &line.render(&args)); @@ -297,35 +293,32 @@ async fn upload_pack_v2( // list within the hour TRIED bundle-uri — its zero-have fetch is a // bundle download that failed (git never retries one). Let that // clone succeed through upload-pack, once per 6 h, loudly. - match bundle_fallback_allowed(st, headers, route).await { - Some(who) => { - tracing::warn!(repo = %route.id, principal = %who, "bundles.require: one-shot upload-pack fallback for a client whose bundle download failed"); - metrics::counter!("walgit_bundle_fallback_total", "repo" => route.id.to_string()).increment(1); - fallback_warning = Some(format!( - "walgit: WARNING — your git fetched the bundle list but could not apply the bundles \ - (a bundle download failed or was cut; see the warnings above). Serving this clone's \ - full history through upload-pack ONCE (≈ 32 GB for acme/monorepo, minutes of server \ - time); the next such clone within 6 h is refused. Faster next time: retry the clone \ - (bundle downloads are cached at the edge), or the blobless form: \ - git clone --filter=blob:none --bundle-uri={base}/{repo}.git/bundles/list?filter=blob:none {base}/{repo}.git", - base = request_base_url(st, headers), - repo = route.id - )); - } - None => { - let msg = bundles_required_message(st, headers, route); - return Ok(if req.sideband_all { - let mut buf = sideband_pkt(3, &msg); - pktline::encode_flush(&mut buf); - text_response( - "application/x-git-upload-pack-result", - no_cache_headers(), - buf, - ) - } else { - git_err_response("git-upload-pack", &msg) - }); - } + if let Some(who) = bundle_fallback_allowed(st, headers, route).await { + tracing::warn!(repo = %route.id, principal = %who, "bundles.require: one-shot upload-pack fallback for a client whose bundle download failed"); + metrics::counter!("walgit_bundle_fallback_total", "repo" => route.id.to_string()).increment(1); + fallback_warning = Some(format!( + "walgit: WARNING — your git fetched the bundle list but could not apply the bundles \ + (a bundle download failed or was cut; see the warnings above). Serving this clone's \ + full history through upload-pack ONCE (≈ 32 GB for acme/monorepo, minutes of server \ + time); the next such clone within 6 h is refused. Faster next time: retry the clone \ + (bundle downloads are cached at the edge), or the blobless form: \ + git clone --filter=blob:none --bundle-uri={base}/{repo}.git/bundles/list?filter=blob:none {base}/{repo}.git", + base = request_base_url(st, headers), + repo = route.id + )); + } else { + let msg = bundles_required_message(st, headers, route); + return Ok(if req.sideband_all { + let mut buf = sideband_pkt(3, &msg); + pktline::encode_flush(&mut buf); + text_response( + "application/x-git-upload-pack-result", + no_cache_headers(), + buf, + ) + } else { + git_err_response("git-upload-pack", &msg) + }); } } // Narrated fetch: the client accepted sideband-all and wants @@ -343,7 +336,7 @@ async fn upload_pack_v2( "git-upload-pack", &too_large_message(st, headers, route, &e), ), - e => return Err(wal_err(e)), + e => return Err(ApiError::from(e)), }); } let (writer, body) = write_body_pipe(256 * 1024); @@ -373,7 +366,7 @@ async fn upload_pack_v2( )) } "object-info" => { - let _guard = handle.sync().await.map_err(wal_err)?; + let _guard = handle.sync().await.map_err(ApiError::from)?; let req = walgit_git::pkt::parse_object_info(&cmd); let mut sizes_buf = Vec::with_capacity(256); let repo = handle.local().gix(); @@ -381,8 +374,7 @@ async fn upload_pack_v2( let size = gix_hash::ObjectId::from_hex(hex.as_bytes()) .ok() .and_then(|oid| repo.find_object(oid).ok()) - .map(|o| o.data.len() as i64) - .unwrap_or(-1); + .map_or(-1, |o| i64::try_from(o.data.len()).unwrap_or(i64::MAX)); pktline::encode_text(&mut sizes_buf, &format!("size {size}\n")); } pktline::encode_flush(&mut sizes_buf); @@ -393,14 +385,14 @@ async fn upload_pack_v2( )) } "bundle-uri" => { - let _guard = handle.sync_refs().await.map_err(wal_err)?; - let _ = walgit_git::pkt::parse_bundle_uri(&cmd); + let _guard = handle.sync_refs().await.map_err(ApiError::from)?; + let () = walgit_git::pkt::parse_bundle_uri(&cmd); let base = request_base_url(st, headers); let lines = st .bundles .protocol_v2_lines(&route.id, &base) .await - .map_err(bundle_err)?; + .map_err(ApiError::from)?; let mut buf = Vec::with_capacity(256); for l in lines { pktline::encode_text(&mut buf, &l); @@ -478,8 +470,8 @@ fn bundle_narration( out.push("bundle-uri: none of your haves is a bundle tip — your git did not use the bundles (clone with the recipe from the Clone menu, or check transfer.bundleURI)".into()); } else { let bytes: u64 = applied.iter().map(|b| b.size).sum(); - let newest = applied.last().map(|b| b.creation_token).unwrap_or(0); - let when = chrono::DateTime::from_timestamp(newest as i64, 0) + let newest = applied.last().map_or(0, |b| b.creation_token); + let when = chrono::DateTime::from_timestamp(i64::try_from(newest).unwrap_or(i64::MAX), 0) .map(|d| d.format("%Y-%m-%d %H:%MZ").to_string()) .unwrap_or_default(); let names: Vec = applied.iter().map(|b| b.strategy.clone()).collect(); @@ -527,7 +519,7 @@ async fn run_fetch( bytes = stats.bytes, faulted, rounds, - ms = t0.elapsed().as_millis() as u64, + ms = u64::try_from(t0.elapsed().as_millis()).unwrap_or(u64::MAX), "gix fetch over remote-served base" ); return Ok(()); @@ -583,7 +575,9 @@ async fn sync_narrated<'h, W: tokio::io::AsyncWrite + Unpin>( } let sync = handle.sync(); tokio::pin!(sync); - let mut last_bar = std::time::Instant::now() - std::time::Duration::from_secs(1); + let mut last_bar = std::time::Instant::now() + .checked_sub(std::time::Duration::from_secs(1)) + .unwrap_or_else(std::time::Instant::now); loop { tokio::select! { biased; @@ -591,7 +585,7 @@ async fn sync_narrated<'h, W: tokio::io::AsyncWrite + Unpin>( p = rx.recv() => match p { Ok(walgit_wal::Progress::Notice { text }) => { let _ = say(writer, &text).await; } Ok(walgit_wal::Progress::Progress { label, done, total, unit, percent }) => { - if last_bar.elapsed() >= std::time::Duration::from_secs(1) || total.map(|t| done >= t).unwrap_or(false) { + if last_bar.elapsed() >= std::time::Duration::from_secs(1) || total.is_some_and(|t| done >= t) { last_bar = std::time::Instant::now(); let line = match (total, percent) { (Some(t), Some(pc)) if unit == "bytes" => format!("{label}: {pc:.0}% ({} / {})", human(done), human(t)), @@ -613,7 +607,7 @@ async fn sync_narrated<'h, W: tokio::io::AsyncWrite + Unpin>( break (&mut sync).await; } }, - _ = tokio::time::sleep(std::time::Duration::from_secs(5)) => { + () = tokio::time::sleep(std::time::Duration::from_secs(5)) => { let _ = say(writer, &format!("still syncing ({}s)…", t0.elapsed().as_secs())).await; } } @@ -640,8 +634,7 @@ async fn narrated_fetch( .require_read(headers) .await .ok() - .map(|p| p.name) - .unwrap_or_else(|| "anonymous".into()); + .map_or_else(|| "anonymous".into(), |p| p.name); // Nothing that can wait (store reads, syncs) happens before the stream // is open and the first band-2 line is out: the bundle facts are read // inside the task, after the greeting. @@ -742,7 +735,7 @@ async fn narrated_fetch( } }; let local = guard.local().clone(); - let packs = local.packs().map(|p| p.len()).unwrap_or(0); + let packs = local.packs().map_or(0, |p| p.len()); let remote = handle.remote_served(); let _ = say( &mut writer, @@ -790,7 +783,7 @@ async fn upload_pack_v0( if n == 0 { break; } - buf.extend_from_slice(&chunk[..n]); + buf.extend_from_slice(chunk.get(..n).unwrap_or_default()); if buf.len() > MAX { return Err(ApiError::BadRequest("upload-pack request too large".into())); } @@ -799,9 +792,8 @@ async fn upload_pack_v0( // `filter`) — capability words on the first want line also say // "deepen-since", so look at line starts, not substrings. let (mut has_have, mut bounded, mut pos) = (false, false, 0usize); - while pos + 4 <= buf.len() { - let Ok(len) = - usize::from_str_radix(std::str::from_utf8(&buf[pos..pos + 4]).unwrap_or("zz"), 16) + while let Some(hdr) = buf.get(pos..pos + 4) { + let Ok(len) = usize::from_str_radix(std::str::from_utf8(hdr).unwrap_or("zz"), 16) else { break; }; @@ -809,7 +801,9 @@ async fn upload_pack_v0( pos += 4; // flush / delim continue; } - let line = &buf[(pos + 4).min(buf.len())..(pos + len).min(buf.len())]; + let line = buf + .get((pos + 4).min(buf.len())..(pos + len).min(buf.len())) + .unwrap_or_default(); if line.starts_with(b"have ") { has_have = true; } @@ -835,7 +829,7 @@ async fn upload_pack_v0( "git-upload-pack", &too_large_message(st, headers, route, &e), ), - e => return Err(wal_err(e)), + e => return Err(ApiError::from(e)), }); } if !handle.remote_served().is_empty() { @@ -893,7 +887,11 @@ pub async fn receive_pack( headers: &HeaderMap, mut body: Body, ) -> Result { - let principal = st.auth.require_write(headers).await.map_err(auth_err)?; + let principal = st + .auth + .require_write(headers) + .await + .map_err(ApiError::from)?; if let Some(msg) = push_url_must_be_git(st, route, headers) { return refuse_push(body, headers, msg).await; } @@ -1031,7 +1029,9 @@ pub async fn receive_pack( // Parse commands + capabilities first (they need no objects); pack bytes // follow in `pack_reader`. Knowing the capabilities before the sync lets // us narrate the sync on band 2 when the client speaks side-band-64k. - let (txn, caps, pack_reader) = walgit_git::receive::parse(reader).await.map_err(git_err)?; + let (txn, caps, pack_reader) = walgit_git::receive::parse(reader) + .await + .map_err(ApiError::from)?; let pack_reader: Box = Box::new(pack_reader); // Wal's verify_txn treats empty string as the zero oid (create/delete). // receive::parse emits the 40-zero hex; normalize to empty for both ends. @@ -1070,11 +1070,11 @@ pub async fn receive_pack( .get("x-request-id") .and_then(|v| v.to_str().ok()) .filter(|v| !v.is_empty()) - .map(|v| v.to_string()); + .map(ToString::to_string); if !caps.side_band_64k { // No sideband: the response is the report alone, after the work. - let guard = handle.sync().await.map_err(wal_err)?; + let guard = handle.sync().await.map_err(ApiError::from)?; let report = receive_pack_process( st, &handle, @@ -1158,12 +1158,20 @@ pub async fn receive_pack( )) } +#[allow( + clippy::type_complexity, + reason = "the publish result destructured once, right here" +)] +#[allow( + clippy::too_many_arguments, + reason = "one parameter per piece of already-parsed request state; a wrapper struct would only be built and destructured at the single call site" +)] /// Everything after the sync: unpack, connectivity, policy, publish → the /// report-status bytes (already sideband-framed when the client asked). async fn receive_pack_process( st: &AppState, handle: &Arc, - _guard: walgit_wal::ReadGuard<'_>, + guard: walgit_wal::ReadGuard<'_>, txn: walgit_proto::v1::RefTransaction, caps: walgit_git::receive::ReceiveCaps, pack_reader: Box, @@ -1189,31 +1197,30 @@ async fn receive_pack_process( }; // Connectivity check for pushed tips (before we publish anything). - if unpack_err.is_none() && st.cfg.wal.check_connectivity { - if let Ok(Some(_)) = &ingest { - let tips: Vec = txn - .updates - .iter() - .filter(|u| !u.new_oid.is_empty() && !is_zero_oid(&u.new_oid)) - .filter_map(|u| gix_hash::ObjectId::from_hex(u.new_oid.as_bytes()).ok()) - .collect(); - if !tips.is_empty() { - if let Err(e) = local - .check_connectivity_async(&tips, true) - .instrument(tracing::info_span!( - "receive.connectivity", - tips = tips.len() - )) - .await - { - // Every refusal names the reason on each ref: `unpack ng` - // alone makes git print "remote failed to report status". - tracing::warn!(repo = %route_id, error = %e, "receive-pack: connectivity check failed"); - metrics::counter!("walgit_push_refused_total", "reason" => "connectivity") - .increment(1); - return Ok(refusal_report(&caps, &txn, &format!("connectivity: {e}")).await); - } - } + if unpack_err.is_none() + && st.cfg.wal.check_connectivity + && let Ok(Some(_)) = &ingest + { + let tips: Vec = txn + .updates + .iter() + .filter(|u| !u.new_oid.is_empty() && !is_zero_oid(&u.new_oid)) + .filter_map(|u| gix_hash::ObjectId::from_hex(u.new_oid.as_bytes()).ok()) + .collect(); + if !tips.is_empty() + && let Err(e) = local + .check_connectivity_async(&tips, true) + .instrument(tracing::info_span!( + "receive.connectivity", + tips = tips.len() + )) + .await + { + // Every refusal names the reason on each ref: `unpack ng` + // alone makes git print "remote failed to report status". + tracing::warn!(repo = %route_id, error = %e, "receive-pack: connectivity check failed"); + metrics::counter!("walgit_push_refused_total", "reason" => "connectivity").increment(1); + return Ok(refusal_report(&caps, &txn, &format!("connectivity: {e}")).await); } } @@ -1253,11 +1260,11 @@ async fn receive_pack_process( // Release the sync read guard before publishing. `publish_push_synced` // reuses this request's freshness check while still syncing after CAS // conflicts. - drop(_guard); + drop(guard); // Writer-side peel: replicas advertise annotated tags without objects. local.fill_peeled(&mut txn); - let meta = push_meta(&caps, principal, &txn, &request_id); + let meta = push_meta(&caps, principal, &txn, request_id.as_ref()); let pack_ref = match ingest { Ok(Some(p)) => Some(p), _ => None, @@ -1332,7 +1339,9 @@ async fn refuse_push(body: Body, headers: &HeaderMap, msg: String) -> Result) -> Response { let mut resp = (StatusCode::OK, report).into_response(); resp.headers_mut().insert( axum::http::header::CONTENT_TYPE, - "application/x-git-receive-pack-result".parse().unwrap(), + axum::http::HeaderValue::from_static("application/x-git-receive-pack-result"), ); resp } @@ -1371,7 +1380,7 @@ fn push_meta( caps: &walgit_git::receive::ReceiveCaps, principal: &crate::auth::Principal, txn: &walgit_proto::v1::RefTransaction, - request_id: &Option, + request_id: Option<&String>, ) -> HashMap { let mut m = HashMap::new(); m.insert("agent".to_string(), caps.agent.clone().unwrap_or_default()); @@ -1413,12 +1422,14 @@ async fn parse_fetch_request( loop { let line = walgit_git::pkt::read_pkt_line(&mut reader) .await - .map_err(git_err)?; + .map_err(ApiError::from)?; match line { None - | Some(walgit_git::pkt::PktLine::Flush) - | Some(walgit_git::pkt::PktLine::Delim) => break, - Some(walgit_git::pkt::PktLine::ResponseEnd) => break, + | Some( + walgit_git::pkt::PktLine::Flush + | walgit_git::pkt::PktLine::Delim + | walgit_git::pkt::PktLine::ResponseEnd, + ) => break, Some(walgit_git::pkt::PktLine::Data(b)) => { let s = String::from_utf8_lossy(&b); let s = s.trim_end_matches('\n'); @@ -1480,12 +1491,12 @@ pub(crate) async fn open_repo( .registry .open_or_create(id, format) .await - .map_err(wal_err)?) + .map_err(ApiError::from)?) } else { match st.registry.open(id).await { Ok(h) => Ok(h), Err(walgit_wal::WalError::NotFound) => Err(ApiError::NotFound(id.to_string())), - Err(e) => Err(wal_err(e)), + Err(e) => Err(ApiError::from(e)), } } } @@ -1514,9 +1525,11 @@ pub(crate) fn build_response( ) -> Response { let mut resp = (status, body).into_response(); let h = resp.headers_mut(); - h.insert(axum::http::header::CONTENT_TYPE, ct.parse().unwrap()); + if let Ok(v) = axum::http::HeaderValue::from_str(ct) { + h.insert(axum::http::header::CONTENT_TYPE, v); + } for (k, v) in extra { - h.insert(k, v.parse().unwrap()); + h.insert(k, axum::http::HeaderValue::from_static(v)); } resp } @@ -1621,9 +1634,9 @@ fn too_large_message( /// How often one principal may fall back to an upload-pack full clone of a /// `bundles.require` repository. -const FALLBACK_EVERY: std::time::Duration = std::time::Duration::from_secs(6 * 3600); +const FALLBACK_EVERY: std::time::Duration = std::time::Duration::from_hours(6); /// How recent the principal's `bundles/list` fetch must be to count as "tried". -const ATTEMPT_WINDOW: std::time::Duration = std::time::Duration::from_secs(3600); +const ATTEMPT_WINDOW: std::time::Duration = std::time::Duration::from_hours(1); /// D17 amendment: `Some(principal)` when this zero-have full fetch may go to /// upload-pack — the principal fetched the repo's bundle list within the hour @@ -1751,33 +1764,3 @@ fn git_err_response(service: &str, msg: &str) -> Response { buf, ) } - -fn auth_err(e: crate::auth::AuthError) -> ApiError { - match e { - crate::auth::AuthError::Invalid | crate::auth::AuthError::Unauthorized => { - ApiError::Unauthorized - } - crate::auth::AuthError::Forbidden => ApiError::Forbidden, - crate::auth::AuthError::Unavailable => { - ApiError::ServiceUnavailable("auth provider unavailable".into()) - } - } -} -fn git_err(e: walgit_git::GitError) -> ApiError { - ApiError::Internal(format!("git: {e}")) -} -pub(crate) fn wal_err(e: walgit_wal::WalError) -> ApiError { - match &e { - walgit_wal::WalError::NotFound => ApiError::NotFound(e.to_string()), - walgit_wal::WalError::TooLarge { .. } => ApiError::ServiceUnavailable(e.to_string()), - // A store call that timed out / was throttled: fail fast, let the - // client retry (never hang the request on the bucket). - walgit_wal::WalError::Store(se) if se.is_retryable() => { - ApiError::ServiceUnavailable(format!("object store: {se}")) - } - _ => ApiError::Internal(format!("wal: {e}")), - } -} -fn bundle_err(e: walgit_bundle::BundleError) -> ApiError { - ApiError::Internal(format!("bundle: {e}")) -} diff --git a/crates/walgit-server/src/sse.rs b/crates/walgit-server/src/sse.rs index 015fd80..b24daab 100644 --- a/crates/walgit-server/src/sse.rs +++ b/crates/walgit-server/src/sse.rs @@ -20,7 +20,7 @@ use std::convert::Infallible; use std::future::Future; use axum::body::Body; -use axum::http::{HeaderMap, StatusCode, header}; +use axum::http::{HeaderMap, HeaderValue, StatusCode, header}; use axum::response::{IntoResponse, Response}; use bytes::Bytes; use futures::StreamExt; @@ -80,12 +80,12 @@ pub fn sse_response( *resp.status_mut() = StatusCode::OK; resp.headers_mut().insert( header::CONTENT_TYPE, - "text/event-stream; charset=utf-8".parse().unwrap(), + HeaderValue::from_static("text/event-stream; charset=utf-8"), ); resp.headers_mut() - .insert(header::CACHE_CONTROL, "no-store".parse().unwrap()); + .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); resp.headers_mut() - .insert("X-Accel-Buffering", "no".parse().unwrap()); + .insert("X-Accel-Buffering", HeaderValue::from_static("no")); resp } @@ -106,29 +106,40 @@ impl Rendered { etag, } } - /// Plain HTTP response (honours `If-None-Match` when an ETag is set). + /// Plain HTTP response (honours `If-None-Match` when an `ETag` is set). pub fn into_response(self, req: &HeaderMap) -> Response { if let Some(etag) = &self.etag { let hit = req .get(header::IF_NONE_MATCH) .and_then(|v| v.to_str().ok()) - .map(|v| v.split(',').any(|t| t.trim() == etag || t.trim() == "*")) - .unwrap_or(false); + .is_some_and(|v| v.split(',').any(|t| t.trim() == etag || t.trim() == "*")); if hit { let mut r = StatusCode::NOT_MODIFIED.into_response(); - r.headers_mut().insert(header::ETAG, etag.parse().unwrap()); - r.headers_mut() - .insert(header::CACHE_CONTROL, self.cache_control.parse().unwrap()); + if let Ok(v) = HeaderValue::from_str(etag) { + r.headers_mut().insert(header::ETAG, v); + } + r.headers_mut().insert( + header::CACHE_CONTROL, + HeaderValue::from_static(self.cache_control), + ); return r; } } let mut r = (StatusCode::OK, Body::from(self.body)).into_response(); - r.headers_mut() - .insert(header::CONTENT_TYPE, self.content_type.parse().unwrap()); - r.headers_mut() - .insert(header::CACHE_CONTROL, self.cache_control.parse().unwrap()); - if let Some(e) = &self.etag { - r.headers_mut().insert(header::ETAG, e.parse().unwrap()); + r.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static(self.content_type), + ); + r.headers_mut().insert( + header::CACHE_CONTROL, + HeaderValue::from_static(self.cache_control), + ); + if let Some(v) = self + .etag + .as_deref() + .and_then(|e| HeaderValue::from_str(e).ok()) + { + r.headers_mut().insert(header::ETAG, v); } r } @@ -155,7 +166,7 @@ where break; } } - Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {} Err(_) => break, } } @@ -229,13 +240,13 @@ pub fn task_stream(state: std::sync::Arc) -> Respo tokio::select! { r = live.recv() => match r { Ok(p) => { if tx.send(progress_packet(&p)).await.is_err() { return; } } - Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {} Err(_) => break, }, _ = done.changed() => { if *done.borrow() { break; } } - _ = tokio::time::sleep(KEEPALIVE) => { + () = tokio::time::sleep(KEEPALIVE) => { if tx.send(Bytes::from_static(b": keepalive\n\n")).await.is_err() { return; } } } diff --git a/crates/walgit-server/src/static_object.rs b/crates/walgit-server/src/static_object.rs index 7548c00..739881f 100644 --- a/crates/walgit-server/src/static_object.rs +++ b/crates/walgit-server/src/static_object.rs @@ -1,9 +1,9 @@ //! HTTP serving of immutable store objects (bundles, LFS objects, packs) with //! the complete conditional/range contract a CDN or `git` expects: //! -//! * strong `ETag` = the store version (GCS generation / S3 ETag), quoted; +//! * strong `ETag` = the store version (GCS generation / S3 `ETag`), quoted; //! * `If-None-Match` (list or `*`) → `304` with the same validators; -//! * `If-Range` (ETag or ignored date) gating `Range`; +//! * `If-Range` (`ETag` or ignored date) gating `Range`; //! * single byte ranges incl. open-ended (`bytes=N-`) and suffix (`bytes=-N`), //! `206` + `Content-Range`, `416` + `Content-Range: bytes */total`; //! * `HEAD` answered from metadata (no body download); @@ -136,7 +136,7 @@ fn if_none_match_hit(headers: &HeaderMap, version: &Version) -> bool { tags.iter().any(|t| t == "*" || t == cur) } -/// `If-Range`: if it names an ETag that does not match the current version the +/// `If-Range`: if it names an `ETag` that does not match the current version the /// range is ignored and the full body is sent (RFC 9110 §13.1.5). Dates are /// not supported (we have no `Last-Modified`) and therefore also ignored. fn if_range_allows(headers: &HeaderMap, version: &Version) -> bool { @@ -189,13 +189,13 @@ fn base_headers(resp: &mut Response, meta: &ObjectMeta, opts: &ServeOptions<'_>) h.insert(header::CACHE_CONTROL, cache_control(opts)); h.insert(header::ACCEPT_RANGES, HeaderValue::from_static("bytes")); h.insert(header::VARY, HeaderValue::from_static("Accept-Encoding")); - if let Some(name) = opts.filename { - if let Ok(v) = HeaderValue::from_str(&format!( + if let Some(name) = opts.filename + && let Ok(v) = HeaderValue::from_str(&format!( "attachment; filename=\"{}\"", name.replace('"', "") - )) { - h.insert(header::CONTENT_DISPOSITION, v); - } + )) + { + h.insert(header::CONTENT_DISPOSITION, v); } } @@ -217,10 +217,9 @@ fn not_modified(meta_version: &Version, opts: &ServeOptions<'_>) -> Response { fn range_not_satisfiable(meta: &ObjectMeta, opts: &ServeOptions<'_>) -> Response { let mut resp = StatusCode::RANGE_NOT_SATISFIABLE.into_response(); base_headers(&mut resp, meta, opts); - resp.headers_mut().insert( - header::CONTENT_RANGE, - HeaderValue::from_str(&format!("bytes */{}", meta.size)).unwrap(), - ); + if let Ok(v) = HeaderValue::from_str(&format!("bytes */{}", meta.size)) { + resp.headers_mut().insert(header::CONTENT_RANGE, v); + } resp.headers_mut() .insert(header::CONTENT_LENGTH, HeaderValue::from_static("0")); resp @@ -247,44 +246,42 @@ pub async fn serve( && !head && accel_requested(headers) && opts.peer.is_some_and(|p| p.ip().is_loopback()) + && let Some(target) = store.accel_target(key).await { - if let Some(target) = store.accel_target(key).await { - let meta = match store.head(key).await { - Ok(Some(m)) => m, - Ok(None) => return Err(ApiError::NotFound(format!("{key} not found"))), - Err(e) => return Err(e.into()), - }; - if if_none_match_hit(headers, &meta.version) { - return Ok(not_modified(&meta.version, &opts)); - } - let mut resp = StatusCode::OK.into_response(); - base_headers(&mut resp, &meta, &opts); - let h = resp.headers_mut(); - let hv = |s: &str| { - HeaderValue::from_str(s) - .map_err(|e| ApiError::Internal(format!("accel header: {e}"))) - }; - h.insert("x-accel-redirect", HeaderValue::from_static(ACCEL_LOCATION)); - // Where and how the edge fetches. nginx keeps the upstream headers of this answer - // across the internal redirect and never forwards them to the client. - h.insert("x-walgit-store-url", hv(&target.url)?); - if let Some(auth) = &target.authorization { - h.insert("x-walgit-store-authorization", hv(auth)?); - } - // The edge's cache key: the object, not the (possibly presigned, changing) URL. - h.insert( - "x-walgit-store-key", - hv(&walgit_store::util::encode_path(key))?, - ); - h.insert("x-walgit-accel", HeaderValue::from_static(store.backend())); - // nginx keeps only Content-Type/Disposition, Accept-Ranges, Cache-Control and Expires - // of this answer across the internal redirect and would otherwise hand the client - // the bucket's ETag (md5/crc form) — different from the version ETag our HEAD/304 use, - // so `If-Range` would fail and a resumed download get the whole object. The edge - // re-emits this header as the response ETag and hides the bucket's. - h.insert("x-walgit-etag", etag_of(&meta.version)); - return Ok(resp); + let meta = match store.head(key).await { + Ok(Some(m)) => m, + Ok(None) => return Err(ApiError::NotFound(format!("{key} not found"))), + Err(e) => return Err(e.into()), + }; + if if_none_match_hit(headers, &meta.version) { + return Ok(not_modified(&meta.version, &opts)); } + let mut resp = StatusCode::OK.into_response(); + base_headers(&mut resp, &meta, &opts); + let h = resp.headers_mut(); + let hv = |s: &str| { + HeaderValue::from_str(s).map_err(|e| ApiError::Internal(format!("accel header: {e}"))) + }; + h.insert("x-accel-redirect", HeaderValue::from_static(ACCEL_LOCATION)); + // Where and how the edge fetches. nginx keeps the upstream headers of this answer + // across the internal redirect and never forwards them to the client. + h.insert("x-walgit-store-url", hv(&target.url)?); + if let Some(auth) = &target.authorization { + h.insert("x-walgit-store-authorization", hv(auth)?); + } + // The edge's cache key: the object, not the (possibly presigned, changing) URL. + h.insert( + "x-walgit-store-key", + hv(&walgit_store::util::encode_path(key))?, + ); + h.insert("x-walgit-accel", HeaderValue::from_static(store.backend())); + // nginx keeps only Content-Type/Disposition, Accept-Ranges, Cache-Control and Expires + // of this answer across the internal redirect and would otherwise hand the client + // the bucket's ETag (md5/crc form) — different from the version ETag our HEAD/304 use, + // so `If-Range` would fail and a resumed download get the whole object. The edge + // re-emits this header as the response ETag and hides the bucket's. + h.insert("x-walgit-etag", etag_of(&meta.version)); + return Ok(resp); } // HEAD and Range both need the size before deciding what to fetch. For @@ -306,7 +303,11 @@ pub async fn serve( .insert(header::CONTENT_LENGTH, HeaderValue::from(meta.size)); return Ok(resp); } - let spec = range.unwrap(); + let Some(spec) = range else { + return Err(ApiError::Internal( + "range serve without a range spec".into(), + )); + }; if if_range_allows(headers, &meta.version) { let Some(r) = spec.resolve(meta.size) else { return Ok(range_not_satisfiable(&meta, &opts)); @@ -326,16 +327,11 @@ pub async fn serve( (StatusCode::PARTIAL_CONTENT, Body::from_stream(body)).into_response(); base_headers(&mut resp, &meta, &opts); let h = resp.headers_mut(); - h.insert( - header::CONTENT_RANGE, - HeaderValue::from_str(&format!( - "bytes {}-{}/{}", - r.start, - r.end - 1, - total - )) - .unwrap(), - ); + if let Ok(v) = + HeaderValue::from_str(&format!("bytes {}-{}/{}", r.start, r.end - 1, total)) + { + h.insert(header::CONTENT_RANGE, v); + } h.insert(header::CONTENT_LENGTH, HeaderValue::from(r.end - r.start)); Ok(resp) } diff --git a/crates/walgit-server/src/stream.rs b/crates/walgit-server/src/stream.rs index 835be53..7dc976a 100644 --- a/crates/walgit-server/src/stream.rs +++ b/crates/walgit-server/src/stream.rs @@ -12,11 +12,11 @@ use futures::stream::StreamExt; use tokio::io::{AsyncRead, AsyncWrite}; use tokio_util::io::{ReaderStream, StreamReader}; -/// Convert an axum request body into an `AsyncRead`. Map errors to io::Error. +/// Convert an axum request body into an `AsyncRead`. Map errors to `io::Error`. pub fn body_to_async_read(body: Body) -> impl AsyncRead + Unpin + Send { let stream = body .into_data_stream() - .map(|res| res.map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))); + .map(|res| res.map_err(|e| io::Error::other(e.to_string()))); StreamReader::new(stream) } @@ -56,6 +56,12 @@ pub fn write_body_pipe(buf: usize) -> (tokio::io::DuplexStream, Body) { /// small pkt-line responses (report-status, ls-refs) into a buffer. pub struct VecWriter(pub Vec); +impl Default for VecWriter { + fn default() -> Self { + Self::new() + } +} + impl VecWriter { pub fn new() -> Self { Self(Vec::new()) diff --git a/crates/walgit-server/src/telemetry.rs b/crates/walgit-server/src/telemetry.rs index 216ad8c..fcef601 100644 --- a/crates/walgit-server/src/telemetry.rs +++ b/crates/walgit-server/src/telemetry.rs @@ -57,10 +57,10 @@ fn resolve_project_id(cfg: &Config) -> Option { if let Some(p) = &cfg.telemetry.trace_project { return Some(p.clone()); } - if let Ok(p) = std::env::var("GOOGLE_CLOUD_PROJECT") { - if !p.is_empty() { - return Some(p); - } + if let Ok(p) = std::env::var("GOOGLE_CLOUD_PROJECT") + && !p.is_empty() + { + return Some(p); } // Probe metadata only when the documented GCE override is present. Off-GCP, // resolving metadata.google.internal can otherwise stall startup. @@ -123,6 +123,10 @@ struct SpanData { fields: Map, } +#[allow( + clippy::type_complexity, + reason = "a shared test sink; naming the alias would not make the nesting clearer" +)] /// A custom `tracing` layer that emits Cloud Logging structured JSON. /// /// * **Events** produce a JSON line immediately with `severity` = event level. @@ -188,7 +192,7 @@ impl CloudLoggingLayer { } /// Build the base JSON record with standard Cloud Logging fields. - fn base_record(&self, severity: &str, message: &str) -> Map { + fn base_record(severity: &str, message: &str) -> Map { let mut map = Map::new(); map.insert("severity".into(), json!(severity)); map.insert("message".into(), json!(message)); @@ -246,7 +250,7 @@ where .values .get("trace_id") .and_then(|v| v.as_str()) - .map(|s| s.to_string()); + .map(ToString::to_string); let parent_trace = ctx .span(id) .and_then(|s| s.parent()) @@ -289,7 +293,7 @@ where fn on_event(&self, event: &Event<'_>, ctx: Context<'_, S>) { let metadata = event.metadata(); - let severity = level_to_severity(metadata.level()); + let severity = level_to_severity(*metadata.level()); let mut visitor = FieldCollector::default(); event.record(&mut visitor); @@ -298,10 +302,9 @@ where .values .get("message") .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - .unwrap_or_else(|| metadata.name().to_string()); + .map_or_else(|| metadata.name().to_string(), ToString::to_string); - let mut record = self.base_record(severity, &message); + let mut record = Self::base_record(severity, &message); record.insert("target".into(), json!(metadata.target())); // Ancestor span fields (root→leaf), then event fields (override). @@ -325,8 +328,7 @@ where if let Some(t) = visitor.values.get("trace_id").and_then(|v| v.as_str()) { let sid = trace .as_ref() - .map(|t| t.1.clone()) - .unwrap_or_else(generate_span_id); + .map_or_else(generate_span_id, |t| t.1.clone()); trace = Some((t.to_string(), sid)); } if let Some((tid, sid)) = trace { @@ -348,12 +350,13 @@ where return; } let end = data.last_exit.unwrap_or_else(Instant::now); - let elapsed_ms = end.duration_since(data.start).as_millis() as u64; - record = self.base_record(level_to_severity(&data.level), data.name); + let elapsed_ms = + u64::try_from(end.duration_since(data.start).as_millis()).unwrap_or(u64::MAX); + record = Self::base_record(level_to_severity(data.level), data.name); record.insert("elapsed_ms".into(), json!(elapsed_ms)); // Close deferred well past the last poll (a lingering child): say so // separately instead of inflating the work's duration. - let idle_ms = end.elapsed().as_millis() as u64; + let idle_ms = u64::try_from(end.elapsed().as_millis()).unwrap_or(u64::MAX); if idle_ms >= 1000 { record.insert("close_deferred_ms".into(), json!(idle_ms)); } @@ -381,13 +384,12 @@ where // Helpers // --------------------------------------------------------------------------- -fn level_to_severity(level: &Level) -> &'static str { +fn level_to_severity(level: Level) -> &'static str { match level { - &Level::ERROR => "ERROR", - &Level::WARN => "WARNING", - &Level::INFO => "INFO", - &Level::DEBUG => "DEBUG", - &Level::TRACE => "DEBUG", + Level::ERROR => "ERROR", + Level::WARN => "WARNING", + Level::INFO => "INFO", + Level::DEBUG | Level::TRACE => "DEBUG", } } @@ -410,7 +412,7 @@ struct FieldCollector { impl Visit for FieldCollector { fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) { - let s = format!("{:?}", value); + let s = format!("{value:?}"); self.values.insert(field.name().to_string(), json!(s)); } @@ -446,7 +448,7 @@ impl Visit for FieldCollector { /// Parse the `X-Cloud-Trace-Context` header. /// Format: `TRACE_ID/SPAN_ID;o=TRACE_TRUE` -/// Returns the trace_id (32-char hex). +/// Returns the `trace_id` (32-char hex). pub fn parse_x_cloud_trace_context(header: &str) -> Option { let trace_id = header.split('/').next()?; let trimmed = trace_id.trim(); @@ -460,11 +462,11 @@ pub fn parse_x_cloud_trace_context(header: &str) -> Option { /// Parse the W3C `traceparent` header. /// Format: `00-TRACE_ID-PARENT_ID-TRACE_FLAGS` -/// Returns the trace_id (32-char hex). +/// Returns the `trace_id` (32-char hex). pub fn parse_traceparent(header: &str) -> Option { let parts: Vec<&str> = header.split('-').collect(); - if parts.len() >= 4 { - let trace_id = parts[1].trim(); + if let [_, trace_id, _, _, ..] = parts.as_slice() { + let trace_id = trace_id.trim(); if trace_id.len() == 32 && trace_id.chars().all(|c| c.is_ascii_hexdigit()) { return Some(trace_id.to_lowercase()); } @@ -478,15 +480,14 @@ pub fn extract_trace_id(headers: &axum::http::HeaderMap) -> Option { if let Some(v) = headers .get("x-cloud-trace-context") .and_then(|v| v.to_str().ok()) + && let Some(tid) = parse_x_cloud_trace_context(v) { - if let Some(tid) = parse_x_cloud_trace_context(v) { - return Some(tid); - } + return Some(tid); } - if let Some(v) = headers.get("traceparent").and_then(|v| v.to_str().ok()) { - if let Some(tid) = parse_traceparent(v) { - return Some(tid); - } + if let Some(v) = headers.get("traceparent").and_then(|v| v.to_str().ok()) + && let Some(tid) = parse_traceparent(v) + { + return Some(tid); } None } @@ -500,7 +501,7 @@ static PROJECT_ID: OnceLock> = OnceLock::new(); /// Initialise `tracing-subscriber` from `[telemetry]`. /// /// * `log_format` selects JSON (Cloud Logging) or pretty (human). -/// * `log_filter` is the default EnvFilter; `RUST_LOG` overrides it entirely. +/// * `log_filter` is the default `EnvFilter`; `RUST_LOG` overrides it entirely. /// * When JSON, installs a [`CloudLoggingLayer`] that emits structured JSON /// with Cloud Logging trace correlation and span-close performance lines. pub fn tracing_init(cfg: &Config) { @@ -577,7 +578,7 @@ mod tests { assert_eq!(CloudLoggingLayer::span_kind("noprefix"), "other"); } - /// Verify that extract_trace_id works with both header formats. + /// Verify that `extract_trace_id` works with both header formats. #[test] fn extract_trace_id_from_headers() { let mut headers = axum::http::HeaderMap::new(); diff --git a/crates/walgit-server/src/tls.rs b/crates/walgit-server/src/tls.rs index 7114d72..98b3019 100644 --- a/crates/walgit-server/src/tls.rs +++ b/crates/walgit-server/src/tls.rs @@ -42,8 +42,18 @@ pub fn load(cfg: &Config) -> anyhow::Result>> { let (cert_pem, key_pem) = match cfg.server.tls.mode { TlsMode::Off => return Ok(None), TlsMode::Files => { - let cert = cfg.server.tls.cert.as_ref().expect("validated"); - let key = cfg.server.tls.key.as_ref().expect("validated"); + let cert = cfg + .server + .tls + .cert + .as_ref() + .context("server.tls.cert is required when server.tls.mode is \"files\"")?; + let key = cfg + .server + .tls + .key + .as_ref() + .context("server.tls.key is required when server.tls.mode is \"files\"")?; ( std::fs::read_to_string(cert) .with_context(|| format!("reading server.tls.cert {}", cert.display()))?, @@ -63,11 +73,14 @@ pub fn load(cfg: &Config) -> anyhow::Result>> { let key: PrivateKeyDer<'static> = rustls_pemfile::private_key(&mut key_pem.as_bytes()) .context("parsing TLS private key PEM")? .ok_or_else(|| anyhow::anyhow!("TLS key PEM holds no private key"))?; + let leaf = certs + .first() + .context("TLS certificate PEM holds no certificate")?; let fingerprint = { use sha2::Digest; format!( "sha256:{}", - hex::encode(sha2::Sha256::digest(certs[0].as_ref())) + hex::encode(sha2::Sha256::digest(leaf.as_ref())) ) }; let mut sc = rustls::ServerConfig::builder_with_provider(Arc::new( @@ -106,7 +119,7 @@ fn self_signed(dir: &Path, hostnames: &[String]) -> anyhow::Result<(String, Stri rcgen::CertificateParams::new(hostnames.to_vec()).context("certificate params")?; params.distinguished_name.push( rcgen::DnType::CommonName, - hostnames.first().map(String::as_str).unwrap_or("walgit"), + hostnames.first().map_or("walgit", String::as_str), ); params.not_before = rcgen::date_time_ymd(2024, 1, 1); params.not_after = rcgen::date_time_ymd(2124, 1, 1); @@ -137,7 +150,7 @@ fn write_private(path: &Path, body: &str) -> anyhow::Result<()> { } /// `axum::serve::Listener` that wraps every accepted TCP connection in a -/// lazily-handshaking TLS stream (TCP_NODELAY set, like the plain listener). +/// lazily-handshaking TLS stream (`TCP_NODELAY` set, like the plain listener). pub struct TlsListener { pub(crate) tcp: TcpAccept, pub acceptor: TlsAcceptor, diff --git a/crates/walgit-server/src/web/api.rs b/crates/walgit-server/src/web/api.rs index 61d2203..3448088 100644 --- a/crates/walgit-server/src/web/api.rs +++ b/crates/walgit-server/src/web/api.rs @@ -22,7 +22,7 @@ use std::sync::Arc; use axum::{ Router, extract::{Path, Query, State}, - http::{HeaderMap, header}, + http::{HeaderMap, HeaderValue, header}, response::{IntoResponse, Response}, routing::get, }; @@ -33,7 +33,7 @@ use walgit_wal::{ObjectAccess, RepoHandle, Reporter}; use crate::sse::Rendered; use crate::web::objects::{CommitMeta, Remote}; -use crate::{AppState, auth::AuthError, cache::RefIndex, error::ApiError}; +use crate::{AppState, cache::RefIndex, error::ApiError}; const MAX_BLOB: usize = 2 * 1024 * 1024; const IMMUTABLE: &str = "private, max-age=31536000, immutable"; @@ -65,6 +65,10 @@ struct Resolved { path: String, kind: &'static str, } +#[allow( + clippy::struct_field_names, + reason = "field names are the wire format clients read" +)] #[derive(Serialize, Clone)] struct Commit { sha: String, @@ -86,7 +90,7 @@ impl From for Commit { let (body, trailers) = super::trailers::split_trailers(&m.body); Commit { sha: m.id.to_string(), - parents: m.parents.iter().map(|p| p.to_string()).collect(), + parents: m.parents.iter().map(ToString::to_string).collect(), author: m.author, author_email: m.author_email, author_date: m.author_date, @@ -147,6 +151,10 @@ struct Readme { name: String, contents: String, } +#[allow( + clippy::struct_field_names, + reason = "field names are the wire format clients read" +)] #[derive(Serialize)] struct Commits { #[serde(rename = "ref")] @@ -216,13 +224,6 @@ pub fn router(state: Arc) -> Router { /// *after* the repository prefix. No lane-first forms, no aliases (banner). pub const REPO_API_BASES: [&str; 2] = ["/{owner}/{repo}/api", "/{owner}/{repo}/api-browser"]; -pub(crate) fn auth_err(e: AuthError) -> ApiError { - match e { - AuthError::Invalid | AuthError::Unauthorized => ApiError::Unauthorized, - AuthError::Forbidden => ApiError::Forbidden, - AuthError::Unavailable => ApiError::ServiceUnavailable("auth provider unavailable".into()), - } -} fn not_found(msg: impl Into) -> ApiError { ApiError::NotFound(msg.into()) } @@ -239,7 +240,7 @@ pub struct Repo { pub(crate) index: Arc, handle: Arc, access: ObjectAccess, - /// Whether objects are readable (Need::Objects satisfied). + /// Whether objects are readable (`Need::Objects` satisfied). objects: bool, reporter: Reporter, /// Shared render cache (object store) — set for remotely served repos. @@ -265,7 +266,7 @@ impl Repo { .handle .sync_objects() .await - .map_err(crate::smart::wal_err)?; + .map_err(crate::error::ApiError::from)?; drop(guard); self.objects = true; self.access = access; @@ -292,7 +293,10 @@ async fn open( owner: &str, name: &str, ) -> Result, ApiError> { - st.auth.require_read(headers).await.map_err(auth_err)?; + st.auth + .require_read(headers) + .await + .map_err(ApiError::from)?; let id = walgit_git::RepoId::new(owner, name).map_err(|_| not_found("repository"))?; st.registry.open(&id).await.map_err(|e| match e { walgit_wal::WalError::NotFound => not_found("repository"), @@ -308,12 +312,18 @@ async fn view( ) -> Result { let (guard, access, objects) = match need { Need::Refs => ( - handle.sync_refs().await.map_err(crate::smart::wal_err)?, + handle + .sync_refs() + .await + .map_err(crate::error::ApiError::from)?, ObjectAccess::Local, false, ), Need::Objects => { - let (g, a) = handle.sync_objects().await.map_err(crate::smart::wal_err)?; + let (g, a) = handle + .sync_objects() + .await + .map_err(crate::error::ApiError::from)?; (g, a, true) } }; @@ -375,18 +385,19 @@ where metrics::counter!("walgit_api_immutable_hit", "tier" => "memory").increment(1); return Ok(Rendered::json(hit, IMMUTABLE, None).into_response(headers)); } - if slow && st.cfg.cache.shared_render_cache { - if let Ok(walgit_store::GetResult::Object { body, meta }) = handle + if slow + && st.cfg.cache.shared_render_cache + && let Ok(walgit_store::GetResult::Object { body, meta }) = handle .store() .get(&shared_key(key), GetOptions::default()) .await - { - if let Ok(b) = walgit_store::util::collect(body, meta.size as usize).await { - metrics::counter!("walgit_api_immutable_hit", "tier" => "store").increment(1); - st.caches.api_immutable.insert(key.clone(), b.clone()); - return Ok(Rendered::json(b, IMMUTABLE, None).into_response(headers)); - } - } + && let Ok(b) = + walgit_store::util::collect(body, usize::try_from(meta.size).unwrap_or(usize::MAX)) + .await + { + metrics::counter!("walgit_api_immutable_hit", "tier" => "store").increment(1); + st.caches.api_immutable.insert(key.clone(), b.clone()); + return Ok(Rendered::json(b, IMMUTABLE, None).into_response(headers)); } } if slow && crate::sse::wants_sse(headers) { @@ -458,10 +469,13 @@ async fn instance_info( State(st): State>, headers: HeaderMap, ) -> Result { - st.auth.require_read(&headers).await.map_err(auth_err)?; + st.auth + .require_read(&headers) + .await + .map_err(ApiError::from)?; let mut r = axum::Json(crate::instance::info(&st.cfg)).into_response(); r.headers_mut() - .insert(header::CACHE_CONTROL, "no-store".parse().unwrap()); + .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); Ok(r) } @@ -471,7 +485,10 @@ pub(crate) async fn owners( State(st): State>, headers: HeaderMap, ) -> Result { - st.auth.require_read(&headers).await.map_err(auth_err)?; + st.auth + .require_read(&headers) + .await + .map_err(ApiError::from)?; let repos = st.registry.list().await.map_err(internal)?; let mut out: Vec = repos.into_iter().map(|r| r.owner().to_string()).collect(); out.sort(); @@ -483,7 +500,10 @@ pub(crate) async fn owner_repos( headers: HeaderMap, Path(owner): Path, ) -> Result { - st.auth.require_read(&headers).await.map_err(auth_err)?; + st.auth + .require_read(&headers) + .await + .map_err(ApiError::from)?; let repos = st.registry.list().await.map_err(internal)?; let mut out: Vec = repos .into_iter() @@ -511,7 +531,7 @@ async fn refs( None, |r| async move { let head = r.index.head().map(|(name, sha)| RefInfo { name, sha }); - let etag = etag_for(head.as_ref().map(|h| h.sha.as_str()).unwrap_or("unborn")); + let etag = etag_for(head.as_ref().map_or("unborn", |h| h.sha.as_str())); Ok(json_swr(&Refs { head }, Some(&etag))) }, ) @@ -542,7 +562,7 @@ async fn ref_list( let needle = q.q.as_deref() .filter(|s| !s.is_empty()) - .map(|s| s.to_ascii_lowercase()); + .map(str::to_ascii_lowercase); let after = q.after.as_deref().unwrap_or(""); // Byte-sorted: skip straight to the first candidate (> after, >= prefix). let lower = match &prefix { @@ -554,16 +574,16 @@ async fn ref_list( .max(list.partition_point(|(name, _)| name.as_str() <= after)); let mut refs = Vec::with_capacity(n.min(256)); let mut more = false; - for (name, sha) in &list[start..] { - if let Some(p) = &prefix { - if !name.starts_with(p.as_str()) { - break; // sorted: no further names share the prefix - } + for (name, sha) in list.get(start..).unwrap_or_default() { + if let Some(p) = &prefix + && !name.starts_with(p.as_str()) + { + break; // sorted: no further names share the prefix } - if let Some(nd) = &needle { - if !name.to_ascii_lowercase().contains(nd.as_str()) { - continue; - } + if let Some(nd) = &needle + && !name.to_ascii_lowercase().contains(nd.as_str()) + { + continue; } if refs.len() == n { more = true; @@ -587,7 +607,7 @@ async fn ref_list( ))); let mut resp = crate::sse::sse_response(futures::stream::iter(items)); resp.headers_mut() - .insert(header::CACHE_CONTROL, SWR.parse().unwrap()); + .insert(header::CACHE_CONTROL, HeaderValue::from_static(SWR)); return Ok(resp); } Ok(json_swr(&RefPage { refs, more }, None).into_response(&headers)) @@ -612,8 +632,10 @@ async fn resolve_rest(r: &Repo, rest: &str) -> Result { let mut cut_points: Vec = rest.match_indices('/').map(|(i, _)| i).collect(); cut_points.push(rest.len()); for &cut in cut_points.iter().rev() { - let name = &rest[..cut]; - let path = rest[cut..].trim_start_matches('/').to_string(); + let Some((name, tail)) = rest.split_at_checked(cut) else { + continue; + }; + let path = tail.trim_start_matches('/').to_string(); if let Some(sha) = r.index.branch(name) { return Ok(Resolved { ref_name: name.to_string(), @@ -646,15 +668,15 @@ async fn resolve_rest(r: &Repo, rest: &str) -> Result { /// Resolve a single revision name (no path): branch, tag, then git rev-parse. async fn resolve_name(r: &Repo, name: &str) -> Result { - if name.is_empty() || name == "HEAD" { - if let Some((n, sha)) = r.index.head() { - return Ok(Resolved { - ref_name: n, - sha, - path: String::new(), - kind: "branch", - }); - } + if (name.is_empty() || name == "HEAD") + && let Some((n, sha)) = r.index.head() + { + return Ok(Resolved { + ref_name: n, + sha, + path: String::new(), + kind: "branch", + }); } if let Some(sha) = r.index.branch(name) { return Ok(Resolved { @@ -750,7 +772,7 @@ async fn resolve_impl( } /// Split `{ref}/{path}` for tree/blob: a leading full sha is taken verbatim -/// (immutable response); otherwise §3 resolution (SWR + ETag). +/// (immutable response); otherwise §3 resolution (SWR + `ETag`). fn split_addr(rest: &str) -> Option<(Resolved, bool)> { let rest = rest.trim_matches('/'); let (first, path) = match rest.split_once('/') { @@ -850,10 +872,8 @@ async fn tree( move |r| async move { let (res, immutable) = resolve_addr(&r, &rest).await?; let key = tree_key(&r.id, &res.sha, &res.path); - if immutable { - if let Some(hit) = st2.caches.api_immutable.get(&key) { - return Ok(Rendered::json(hit, IMMUTABLE, None)); - } + if immutable && let Some(hit) = st2.caches.api_immutable.get(&key) { + return Ok(Rendered::json(hit, IMMUTABLE, None)); } let body = match r.remote() { Some(remote) => render_tree_remote(&remote, &res).await?, @@ -885,27 +905,27 @@ async fn render_tree( continue; }; let (meta, name) = item.split_at(tab); - let name = &name[1..]; + let name = name.get(1..).unwrap_or_default(); // `ls-tree -l` right-aligns the size with padding spaces. let fields: Vec<&[u8]> = meta .split(|b| *b == b' ') .filter(|f| !f.is_empty()) .collect(); - if fields.len() < 4 { + let [mode, kind, sha, size, ..] = fields.as_slice() else { continue; - } - let kind = String::from_utf8_lossy(fields[1]).to_string(); + }; + let kind = String::from_utf8_lossy(kind).to_string(); let size = if kind == "blob" { - String::from_utf8_lossy(fields[3]).parse().unwrap_or(-1) + String::from_utf8_lossy(size).parse().unwrap_or(-1) } else { -1 }; entries.push(TreeEntry { name: String::from_utf8_lossy(name).to_string(), kind, - mode: String::from_utf8_lossy(fields[0]).to_string(), + mode: String::from_utf8_lossy(mode).to_string(), size, - sha: String::from_utf8_lossy(fields[2]).to_string(), + sha: String::from_utf8_lossy(sha).to_string(), }); } sort_entries(&mut entries); @@ -914,16 +934,14 @@ async fn render_tree( .ok() .and_then(|b| parse_commits(&b).into_iter().next()); let mut readme = None; - if let Some(e) = readme_entry(&entries) { - if let Ok(content) = git(local, vec!["cat-file".into(), "blob".into(), e.sha.clone()]).await - { - if let Ok(s) = String::from_utf8(content) { - readme = Some(Readme { - name: e.name.clone(), - contents: s, - }); - } - } + if let Some(e) = readme_entry(&entries) + && let Ok(content) = git(local, vec!["cat-file".into(), "blob".into(), e.sha.clone()]).await + && let Ok(s) = String::from_utf8(content) + { + readme = Some(Readme { + name: e.name.clone(), + contents: s, + }); } Ok(json_bytes(&Tree { ref_name: res.ref_name.clone(), @@ -977,7 +995,7 @@ async fn render_tree_remote(remote: &Remote, res: &Resolved) -> Result = futures::stream::iter(raw.into_iter()) + let entries: Vec = futures::stream::iter(raw) .map(|e| async move { let kind = match e.mode.kind() { gix_object::tree::EntryKind::Tree => "tree", @@ -990,8 +1008,7 @@ async fn render_tree_remote(remote: &Remote, res: &Resolved) -> Result Result>) = match r.remote() { - Some(remote) => { - let sha = gix_hash::ObjectId::from_hex(res.sha.as_bytes()) - .map_err(|_| not_found("revision"))?; - remote - .reporter - .notice(format!("Reading {} from the WAL pack set", res.path)); - let (_c, target, mode) = remote.fault_path(&sha, &res.path).await?; - if !mode.is_blob_or_symlink() { - return Err(not_found(format!("'{}' is not a file", res.path))); - } - let (_, size) = remote - .kind_and_size(&target) - .await? - .ok_or_else(|| not_found("blob"))?; - if size as usize > MAX_BLOB { - (size as i64, None) - } else { - let o = remote.get(&target).await?; - (size as i64, Some(o.data.to_vec())) - } + let (size, bytes): (i64, Option>) = if let Some(remote) = r.remote() { + let sha = gix_hash::ObjectId::from_hex(res.sha.as_bytes()) + .map_err(|_| not_found("revision"))?; + remote + .reporter + .notice(format!("Reading {} from the WAL pack set", res.path)); + let (_c, target, mode) = remote.fault_path(&sha, &res.path).await?; + if !mode.is_blob_or_symlink() { + return Err(not_found(format!("'{}' is not a file", res.path))); } - None => { - let bytes = git( - &r.local, - vec![ - "cat-file".into(), - "blob".into(), - format!("{}:{}", res.sha, res.path), - ], + let (_, size) = remote + .kind_and_size(&target) + .await? + .ok_or_else(|| not_found("blob"))?; + if usize::try_from(size).unwrap_or(usize::MAX) > MAX_BLOB { + (i64::try_from(size).unwrap_or(i64::MAX), None) + } else { + let o = remote.get(&target).await?; + ( + i64::try_from(size).unwrap_or(i64::MAX), + Some(o.data.to_vec()), ) - .await?; - (bytes.len() as i64, Some(bytes)) } + } else { + let bytes = git( + &r.local, + vec![ + "cat-file".into(), + "blob".into(), + format!("{}:{}", res.sha, res.path), + ], + ) + .await?; + (i64::try_from(bytes.len()).unwrap_or(i64::MAX), Some(bytes)) }; - let is_text = size <= MAX_BLOB as i64 + let is_text = size <= i64::try_from(MAX_BLOB).unwrap_or(i64::MAX) && bytes .as_ref() - .map(|b| !b.contains(&0) && std::str::from_utf8(b).is_ok()) - .unwrap_or(false); + .is_some_and(|b| !b.contains(&0) && std::str::from_utf8(b).is_ok()); if raw && is_text { let etag = etag_for(&res.sha); return Ok(Rendered { @@ -1143,7 +1157,7 @@ async fn blob( etag: (!immutable).then_some(etag), }); } - let b = if size > MAX_BLOB as i64 { + let b = if size > i64::try_from(MAX_BLOB).unwrap_or(i64::MAX) { Blob { ref_name: res.ref_name.clone(), sha: res.sha.clone(), @@ -1227,48 +1241,43 @@ async fn commits( (resolve_name(&r, &reference).await?, false) }; let key = commits_key(&r.id, &res.sha, &path, skip, n); - if immutable { - if let Some(hit) = st2.caches.api_immutable.get(&key) { - return Ok(Rendered::json(hit, IMMUTABLE, None)); - } + if immutable && let Some(hit) = st2.caches.api_immutable.get(&key) { + return Ok(Rendered::json(hit, IMMUTABLE, None)); } - let mut cs: Vec = match r.remote() { - Some(remote) => { - let start = gix_hash::ObjectId::from_hex(res.sha.as_bytes()) - .map_err(|_| not_found("revision"))?; - let label = if path.is_empty() { - "Walking history".to_string() - } else { - format!("Walking history of {path}") - }; - remote.reporter.notice(format!( - "{label} from {} (reading commits from the WAL pack set)", - &res.sha[..12] - )); - let all = remote - .walk( - start, - (!path.is_empty()).then_some(path.as_str()), - skip + n + 1, - &label, - ) - .await?; - all.into_iter().skip(skip).map(Commit::from).collect() - } - None => { - let mut a = vec![ - "log".into(), - format!("--format={}", log_format()), - "--no-color".into(), - format!("--skip={skip}"), - format!("-{count}", count = n.saturating_add(1)), - res.sha.clone(), - ]; - if !path.is_empty() { - a.extend(["--".into(), path.clone()]); - } - parse_commits(&git(&r.local, a).await?) + let mut cs: Vec = if let Some(remote) = r.remote() { + let start = gix_hash::ObjectId::from_hex(res.sha.as_bytes()) + .map_err(|_| not_found("revision"))?; + let label = if path.is_empty() { + "Walking history".to_string() + } else { + format!("Walking history of {path}") + }; + remote.reporter.notice(format!( + "{label} from {} (reading commits from the WAL pack set)", + res.sha.get(..12).unwrap_or(&res.sha) + )); + let all = remote + .walk( + start, + (!path.is_empty()).then_some(path.as_str()), + skip + n + 1, + &label, + ) + .await?; + all.into_iter().skip(skip).map(Commit::from).collect() + } else { + let mut a = vec![ + "log".into(), + format!("--format={}", log_format()), + "--no-color".into(), + format!("--skip={skip}"), + format!("-{count}", count = n.saturating_add(1)), + res.sha.clone(), + ]; + if !path.is_empty() { + a.extend(["--".into(), path.clone()]); } + parse_commits(&git(&r.local, a).await?) }; let more = cs.len() > n; if more { @@ -1314,10 +1323,8 @@ async fn commit_detail( resolve_name(&r, &rev).await?.sha }; let key = commit_key(&r.id, &sha); - if immutable { - if let Some(hit) = st2.caches.api_immutable.get(&key) { - return Ok(Rendered::json(hit, IMMUTABLE, None)); - } + if immutable && let Some(hit) = st2.caches.api_immutable.get(&key) { + return Ok(Rendered::json(hit, IMMUTABLE, None)); } if let Some(remote) = r.remote() { // Fault the commit, its first parent and every object the diff @@ -1326,7 +1333,7 @@ async fn commit_detail( .map_err(|_| not_found("commit"))?; remote.reporter.notice(format!( "Reading commit {} from the WAL pack set", - &sha[..12] + sha.get(..12).unwrap_or(&sha) )); remote.fault_commit_diff(&oid).await?; } @@ -1397,21 +1404,23 @@ fn parse_stats(bytes: &[u8]) -> Vec { .lines() .filter_map(|line| { let f: Vec<&str> = line.split('\t').collect(); - if f.len() < 3 || (!f[0].chars().all(|c| c.is_ascii_digit()) && f[0] != "-") { + let [adds, dels, rename, ..] = f.as_slice() else { + return None; + }; + if !adds.chars().all(|c| c.is_ascii_digit()) && *adds != "-" { return None; } - let path = normalize_rename(f[2]); Some(Stat { - path, - additions: if f[0] == "-" { + path: normalize_rename(rename), + additions: if *adds == "-" { -1 } else { - f[0].parse().unwrap_or(-1) + adds.parse().unwrap_or(-1) }, - deletions: if f[1] == "-" { + deletions: if *dels == "-" { -1 } else { - f[1].parse().unwrap_or(-1) + dels.parse().unwrap_or(-1) }, }) }) @@ -1420,17 +1429,17 @@ fn parse_stats(bytes: &[u8]) -> Vec { /// `git --numstat -M` prints renames as `old => new` or `prefix/{old => new}/suffix`; /// return the new path. fn normalize_rename(s: &str) -> String { - if let (Some(open), Some(close)) = (s.find('{'), s.rfind('}')) { - if open < close { - let inner = &s[open + 1..close]; - if let Some((_, new)) = inner.split_once(" => ") { - let mut out = String::with_capacity(s.len()); - out.push_str(&s[..open]); - out.push_str(new); - out.push_str(&s[close + 1..]); - return out.replace("//", "/"); - } - } + if let (Some(open), Some(close)) = (s.find('{'), s.rfind('}')) + && let Some(inner) = s.get(open + 1..close) + && let Some(head) = s.get(..open) + && let Some(tail) = s.get(close + 1..) + && let Some((_, new)) = inner.split_once(" => ") + { + let mut out = String::with_capacity(s.len()); + out.push_str(head); + out.push_str(new); + out.push_str(tail); + return out.replace("//", "/"); } if let Some((_, new)) = s.split_once(" => ") { return new.to_string(); diff --git a/crates/walgit-server/src/web/login.rs b/crates/walgit-server/src/web/login.rs index 08387c8..9de3a80 100644 --- a/crates/walgit-server/src/web/login.rs +++ b/crates/walgit-server/src/web/login.rs @@ -1,4 +1,4 @@ -//! Browser sign-in: the OpenID Connect authorization-code flow against +//! Browser sign-in: the `OpenID` Connect authorization-code flow against //! `server.auth.issuer`, done by walgit itself. `GET /_auth/login?next=/p` //! redirects to the issuer's `authorization_endpoint` (from discovery), //! `GET /_auth/callback` exchanges the code at the `token_endpoint`, verifies the @@ -11,6 +11,7 @@ //! to paste into the credential helper; `GET` renders the small page that does it. //! Tokens are stateless — rotating `session_secret` revokes them all. +use std::fmt::Write as _; use std::sync::Arc; use axum::{ @@ -71,7 +72,7 @@ fn loopback_origin(st: &AppState, headers: &HeaderMap) -> bool { let base = crate::smart::request_base_url(st, headers); let host = base.split("://").nth(1).unwrap_or(&base); let host = host.split('/').next().unwrap_or(host); - let host = host.rsplit_once(':').map(|(h, _)| h).unwrap_or(host); + let host = host.rsplit_once(':').map_or(host, |(h, _)| h); host == "walgit.localhost" || host == "localhost" || host == "127.0.0.1" || host == "[::1]" } @@ -116,8 +117,7 @@ fn walgit_origin(st: &AppState, headers: &HeaderMap) -> String { fn now() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0) + .map_or(0, |d| d.as_secs()) } fn urlencode(s: &str) -> String { @@ -125,9 +125,11 @@ fn urlencode(s: &str) -> String { for b in s.bytes() { match b { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { - out.push(b as char) + out.push(b as char); + } + _ => { + let _ = write!(out, "%{b:02X}"); } - _ => out.push_str(&format!("%{b:02X}")), } } out @@ -147,17 +149,20 @@ async fn login( ) .into_response(); } - let disco = match st.auth.discovery().await { - Ok(d) => d, - Err(_) => { - return ( - StatusCode::SERVICE_UNAVAILABLE, - "identity provider unavailable (OIDC discovery failed)", - ) - .into_response(); - } + let Ok(disco) = st.auth.discovery().await else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + "identity provider unavailable (OIDC discovery failed)", + ) + .into_response(); + }; + let Some((client_id, _)) = st.auth.oauth_client() else { + return ( + StatusCode::NOT_IMPLEMENTED, + "OAuth client is not configured", + ) + .into_response(); }; - let (client_id, _) = st.auth.oauth_client().unwrap(); let next = safe_next(q.next); let nonce: u64 = rand::random(); let payload = format!("{}\n{nonce:x}\n{next}", now() + STATE_TTL_SECS); @@ -179,7 +184,7 @@ async fn login( ); // Google honours `hd` as a domain hint on its account chooser; other issuers ignore it. if let Some(hd) = st.cfg.server.auth.allowed_domains.first() { - url.push_str(&format!("&hd={}", urlencode(hd))); + let _ = write!(url, "&hd={}", urlencode(hd)); } let mut r = Redirect::to(&url).into_response(); r.headers_mut() @@ -187,6 +192,10 @@ async fn login( r } +#[allow( + clippy::expect_used, + reason = "the client builds unless the TLS backend is unavailable, and then the process cannot serve at all" +)] async fn exchange_code( token_endpoint: &str, form: &[(&str, &str); 5], @@ -196,19 +205,24 @@ async fn exchange_code( .timeout(std::time::Duration::from_secs(15)) .build() .expect("reqwest client"); - let mut last = None; - for attempt in 1u8..=2 { + let mut attempt = 1u8; + loop { match client.post(token_endpoint).form(form).send().await { Ok(r) => return Ok(r), Err(e) if attempt < 2 && (e.is_connect() || e.is_timeout()) => { tracing::warn!(attempt, error = %e, "oauth token exchange retrying"); - last = Some(e); + attempt += 1; tokio::time::sleep(std::time::Duration::from_millis(200)).await; } Err(e) => return Err(e), } } - Err(last.expect("retry left an error")) +} + +fn set_session_cookie(r: &mut Response, cookie: &str) { + if let Ok(v) = HeaderValue::from_str(cookie) { + r.headers_mut().insert(header::SET_COOKIE, v); + } } #[derive(serde::Deserialize)] @@ -306,8 +320,7 @@ async fn callback( // Public origin: the callback ran there; the cookie is already right — go to `next`. if !loopback_origin(&st, &headers) { let mut r = Redirect::to(&next).into_response(); - r.headers_mut() - .insert(header::SET_COOKIE, HeaderValue::from_str(&cookie).unwrap()); + set_session_cookie(&mut r, &cookie); r.headers_mut() .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); return r; @@ -325,8 +338,7 @@ async fn callback( None => next.clone(), }; let mut r = Redirect::to(&dest).into_response(); - r.headers_mut() - .insert(header::SET_COOKIE, HeaderValue::from_str(&cookie).unwrap()); + set_session_cookie(&mut r, &cookie); r.headers_mut() .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); r @@ -359,8 +371,7 @@ async fn claimed( } let cookie = session_set_cookie(&st, &headers, value); let mut r = Redirect::to(&next).into_response(); - r.headers_mut() - .insert(header::SET_COOKIE, HeaderValue::from_str(&cookie).unwrap()); + set_session_cookie(&mut r, &cookie); r.headers_mut() .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); r @@ -373,8 +384,7 @@ async fn logout(State(st): State>, headers: HeaderMap) -> Response cookie_site(&st, secure) ); let mut r = Redirect::to("/").into_response(); - r.headers_mut() - .insert(header::SET_COOKIE, HeaderValue::from_str(&cookie).unwrap()); + set_session_cookie(&mut r, &cookie); r } diff --git a/crates/walgit-server/src/web/mod.rs b/crates/walgit-server/src/web/mod.rs index 21ad9b4..349d7fc 100644 --- a/crates/walgit-server/src/web/mod.rs +++ b/crates/walgit-server/src/web/mod.rs @@ -5,12 +5,13 @@ pub mod trailers; pub mod ui; pub mod v1; +use std::fmt::Write as _; use std::sync::Arc; use axum::{ body::Body, extract::{Request, State}, - http::{StatusCode, header}, + http::{HeaderValue, StatusCode, header}, middleware::Next, response::{IntoResponse, Redirect, Response}, }; @@ -32,25 +33,26 @@ pub async fn canonical_browser_host( || path.starts_with("/services/public"); let browser = is_browser(req.headers()); let get = req.method() == axum::http::Method::GET || req.method() == axum::http::Method::HEAD; - if get && browser && !skip { - if let Some(dest) = walgit_localhost_host( + if get + && browser + && !skip + && let Some(dest) = walgit_localhost_host( req.headers() .get(header::HOST) .and_then(|v| v.to_str().ok()), - ) { - let scheme = if st.cfg.tls_enabled() { - "https" - } else { - "http" - }; - let pq = req - .uri() - .path_and_query() - .map(|p| p.as_str()) - .unwrap_or("/"); - let loc = format!("{scheme}://{dest}{pq}"); - return (StatusCode::FOUND, [(header::LOCATION, loc)]).into_response(); - } + ) + { + let scheme = if st.cfg.tls_enabled() { + "https" + } else { + "http" + }; + let pq = req + .uri() + .path_and_query() + .map_or("/", http::uri::PathAndQuery::as_str); + let loc = format!("{scheme}://{dest}{pq}"); + return (StatusCode::FOUND, [(header::LOCATION, loc)]).into_response(); } next.run(req).await } @@ -112,8 +114,7 @@ pub async fn require_auth( let next_url = req .uri() .path_and_query() - .map(|pq| pq.as_str().to_string()) - .unwrap_or_else(|| "/".to_string()); + .map_or_else(|| "/".to_string(), |pq| pq.as_str().to_string()); let q = url_encode(&next_url); return Redirect::temporary(&format!("/_auth/login?next={q}")).into_response(); } @@ -147,7 +148,7 @@ pub async fn require_auth( if status == StatusCode::UNAUTHORIZED { resp.headers_mut().insert( header::WWW_AUTHENTICATE, - "Bearer realm=\"walgit\"".parse().unwrap(), + HeaderValue::from_static("Bearer realm=\"walgit\""), ); } resp @@ -160,9 +161,11 @@ pub(crate) fn url_encode(s: &str) -> String { for b in s.bytes() { match b { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { - out.push(b as char) + out.push(b as char); + } + _ => { + let _ = write!(out, "%{b:02X}"); } - _ => out.push_str(&format!("%{b:02X}")), } } out diff --git a/crates/walgit-server/src/web/objects.rs b/crates/walgit-server/src/web/objects.rs index aa150dd..094a0f1 100644 --- a/crates/walgit-server/src/web/objects.rs +++ b/crates/walgit-server/src/web/objects.rs @@ -36,10 +36,6 @@ pub struct Remote { fn not_found(m: impl Into) -> ApiError { ApiError::NotFound(m.into()) } -fn wal(e: walgit_wal::WalError) -> ApiError { - ApiError::Internal(format!("remote objects: {e}")) -} - /// A parsed commit (what the walks and renderers need). #[derive(Clone)] pub struct CommitMeta { @@ -81,7 +77,7 @@ impl Remote { self.packs .find(oid) .await - .map_err(wal)? + .map_err(|e| ApiError::Internal(format!("remote objects: {e}")))? .ok_or_else(|| not_found(format!("object {oid} not in the pack set"))) } @@ -127,7 +123,10 @@ impl Remote { &self, oid: &gix_hash::oid, ) -> Result, ApiError> { - self.packs.header(oid).await.map_err(wal) + self.packs + .header(oid) + .await + .map_err(|e| ApiError::Internal(format!("remote objects: {e}"))) } /// `rev-parse --verify ^{commit}` without objects on disk: full or @@ -210,10 +209,6 @@ impl Remote { }; cur = e.oid; mode = Some(e.mode); - if !e.mode.is_tree() { - // more segments after a blob => absent - continue; - } } match mode { None => Ok(Some((cur, gix_object::tree::EntryKind::Tree.into()))), @@ -238,8 +233,7 @@ impl Remote { let entries = self.tree_entries(&cur).await?; let Some(e) = entries.into_iter().find(|e| e.name == seg.as_bytes()) else { return Err(not_found(format!( - "path '{}' does not exist in {}", - path, commit + "path '{path}' does not exist in {commit}" ))); }; cur = e.oid; @@ -248,8 +242,7 @@ impl Remote { self.fault(&cur).await?; } else if i + 1 < segs.len() { return Err(not_found(format!( - "path '{}' does not exist in {}", - path, commit + "path '{path}' does not exist in {commit}" ))); } else if e.mode.is_blob() { // blob: caller decides whether to fault (size check) @@ -349,7 +342,7 @@ impl Remote { .notice(format!("{label}: gave up after {budget} commits")); break; } - if popped % 100 == 0 { + if popped.is_multiple_of(100) { self.reporter .bar(label.to_string(), popped as u64, None, "commits"); } @@ -366,13 +359,12 @@ impl Remote { } else { let mut treesame_parent = None; for par in &meta.parents { - let pm = match metas.get(par) { - Some(m) => m.clone(), - None => { - let m = self.commit(par).await?; - metas.insert(*par, m.clone()); - m - } + let pm = if let Some(m) = metas.get(par) { + m.clone() + } else { + let m = self.commit(par).await?; + metas.insert(*par, m.clone()); + m }; let theirs = self.path_oid(&mut path_cache, pm.tree, p).await?; if theirs == mine { @@ -395,13 +387,12 @@ impl Remote { for par in follow { if seen.insert(par) { seq += 1; - let pm = match metas.get(&par) { - Some(m) => m.clone(), - None => { - let m = self.commit(&par).await?; - metas.insert(par, m.clone()); - m - } + let pm = if let Some(m) = metas.get(&par) { + m.clone() + } else { + let m = self.commit(&par).await?; + metas.insert(par, m.clone()); + m }; heap.push(Item(pm.commit_time, seq, par)); } @@ -415,7 +406,7 @@ impl Remote { /// blobs of changed entries (both sides). Root commits diff against the /// empty tree. pub async fn fault_commit_diff(&self, commit: &gix_hash::oid) -> Result { - let c = self.commit(commit).await?; + let meta = self.commit(commit).await?; self.fault(commit).await?; // The renderer diffs against the first parent only // (`--diff-merges=first-parent`); git still parses every parent and @@ -423,21 +414,22 @@ impl Remote { // the diff for the first parent alone — a merge into a monorepo trunk // otherwise pulls the whole other-branch delta (20 k+ objects, 503). let mut stack: Vec<(Option, Option)> = Vec::new(); - if c.parents.is_empty() { - stack.push((None, Some(c.tree))); + if meta.parents.is_empty() { + stack.push((None, Some(meta.tree))); } - for (i, p) in c.parents.iter().enumerate() { + for (i, p) in meta.parents.iter().enumerate() { let pm = self.commit(p).await?; self.fault(p).await?; if i == 0 { - stack.push((Some(pm.tree), Some(c.tree))); + stack.push((Some(pm.tree), Some(meta.tree))); } else { self.fault(&pm.tree).await?; } } + let hex = meta.id.to_hex().to_string(); self.reporter.notice(format!( "Reading the trees and blobs changed by {}", - &c.id.to_hex().to_string()[..12] + hex.get(..12).unwrap_or(&hex) )); // Level-parallel: every tree pair of the current level is faulted in // one concurrent batch (range reads ~50 ms each; serially a large repository @@ -447,9 +439,9 @@ impl Remote { while !stack.is_empty() { let level = std::mem::take(&mut stack); let mut want: Vec = Vec::new(); - for (a, b) in &level { - want.extend(a.iter().copied()); - want.extend(b.iter().copied()); + for (lhs_tree, rhs_tree) in &level { + want.extend(lhs_tree.iter().copied()); + want.extend(rhs_tree.iter().copied()); } want.sort_unstable(); want.dedup(); @@ -457,28 +449,28 @@ impl Remote { if count > MAX_DIFF_OBJECTS { return Err(ApiError::ServiceUnavailable(format!( "commit {} touches more than {MAX_DIFF_OBJECTS} objects; too large to render from the remote pack set", - c.id + meta.id ))); } self.fault_many(&want).await?; self.reporter .bar("Reading changed objects", count as u64, None, "objects"); let mut blobs: Vec = Vec::new(); - for (a, b) in level { - let ea = match a { - Some(t) => self.tree_entries(&t).await?, + for (lhs_tree, rhs_tree) in level { + let ea = match lhs_tree { + Some(tree) => self.tree_entries(&tree).await?, None => Vec::new(), }; - let eb = match b { - Some(t) => self.tree_entries(&t).await?, + let eb = match rhs_tree { + Some(tree) => self.tree_entries(&tree).await?, None => Vec::new(), }; // Merge-walk by git tree order. let (mut i, mut j) = (0, 0); while i < ea.len() || j < eb.len() { let ord = match (ea.get(i), eb.get(j)) { - (Some(x), Some(y)) => { - tree_cmp(&x.name, x.mode.is_tree(), &y.name, y.mode.is_tree()) + (Some(lhs), Some(rhs)) => { + tree_cmp(&lhs.name, lhs.mode.is_tree(), &rhs.name, rhs.mode.is_tree()) } (Some(_), None) => std::cmp::Ordering::Less, (None, Some(_)) => std::cmp::Ordering::Greater, @@ -486,52 +478,58 @@ impl Remote { }; match ord { std::cmp::Ordering::Equal => { - let (x, y) = (&ea[i], &eb[j]); + let (Some(lhs), Some(rhs)) = (ea.get(i), eb.get(j)) else { + break; + }; i += 1; j += 1; - if x.oid == y.oid && x.mode == y.mode { + if lhs.oid == rhs.oid && lhs.mode == rhs.mode { continue; } - match (x.mode.is_tree(), y.mode.is_tree()) { - (true, true) => stack.push((Some(x.oid), Some(y.oid))), + match (lhs.mode.is_tree(), rhs.mode.is_tree()) { + (true, true) => stack.push((Some(lhs.oid), Some(rhs.oid))), (true, false) => { - stack.push((Some(x.oid), None)); - if y.mode.is_blob_or_symlink() { - blobs.push(y.oid); + stack.push((Some(lhs.oid), None)); + if rhs.mode.is_blob_or_symlink() { + blobs.push(rhs.oid); } } (false, true) => { - stack.push((None, Some(y.oid))); - if x.mode.is_blob_or_symlink() { - blobs.push(x.oid); + stack.push((None, Some(rhs.oid))); + if lhs.mode.is_blob_or_symlink() { + blobs.push(lhs.oid); } } (false, false) => { - if x.mode.is_blob_or_symlink() { - blobs.push(x.oid); + if lhs.mode.is_blob_or_symlink() { + blobs.push(lhs.oid); } - if y.mode.is_blob_or_symlink() && y.oid != x.oid { - blobs.push(y.oid); + if rhs.mode.is_blob_or_symlink() && rhs.oid != lhs.oid { + blobs.push(rhs.oid); } } } } std::cmp::Ordering::Less => { - let x = &ea[i]; + let Some(lhs) = ea.get(i) else { + break; + }; i += 1; - if x.mode.is_tree() { - stack.push((Some(x.oid), None)); - } else if x.mode.is_blob_or_symlink() { - blobs.push(x.oid); + if lhs.mode.is_tree() { + stack.push((Some(lhs.oid), None)); + } else if lhs.mode.is_blob_or_symlink() { + blobs.push(lhs.oid); } } std::cmp::Ordering::Greater => { - let y = &eb[j]; + let Some(rhs) = eb.get(j) else { + break; + }; j += 1; - if y.mode.is_tree() { - stack.push((None, Some(y.oid))); - } else if y.mode.is_blob_or_symlink() { - blobs.push(y.oid); + if rhs.mode.is_tree() { + stack.push((None, Some(rhs.oid))); + } else if rhs.mode.is_blob_or_symlink() { + blobs.push(rhs.oid); } } } @@ -543,7 +541,7 @@ impl Remote { if count > MAX_DIFF_OBJECTS { return Err(ApiError::ServiceUnavailable(format!( "commit {} touches more than {MAX_DIFF_OBJECTS} objects; too large to render from the remote pack set", - c.id + meta.id ))); } self.fault_many(&blobs).await?; @@ -552,14 +550,14 @@ impl Remote { .refresh_async() .await .map_err(|e| ApiError::Internal(e.to_string()))?; - Ok(c) + Ok(meta) } } /// git's tree entry ordering: names compared as if trees had a trailing '/'. fn tree_cmp(a: &[u8], a_tree: bool, b: &[u8], b_tree: bool) -> std::cmp::Ordering { let n = a.len().min(b.len()); - match a[..n].cmp(&b[..n]) { + match a.iter().take(n).cmp(b.iter().take(n)) { std::cmp::Ordering::Equal => {} o => return o, } diff --git a/crates/walgit-server/src/web/trailers.rs b/crates/walgit-server/src/web/trailers.rs index 7e8a333..226c53f 100644 --- a/crates/walgit-server/src/web/trailers.rs +++ b/crates/walgit-server/src/web/trailers.rs @@ -34,7 +34,7 @@ pub fn split_trailers(body: &str) -> (String, Vec) { start = i + 1; } } - let block = &lines[start..]; + let block = lines.get(start..).unwrap_or_default(); if block.is_empty() { return (body.to_string(), Vec::new()); } @@ -48,9 +48,10 @@ pub fn split_trailers(body: &str) -> (String, Vec) { if i == 0 { first_is_trailer = true; } - } else if line.starts_with([' ', '\t']) && !trailers.is_empty() { + } else if line.starts_with([' ', '\t']) + && let Some(last) = trailers.last_mut() + { // Continuation (RFC 822 folding) of the previous trailer's value. - let last = trailers.last_mut().unwrap(); if !last.value.is_empty() { last.value.push(' '); } @@ -66,7 +67,7 @@ pub fn split_trailers(body: &str) -> (String, Vec) { if !ok { return (body.to_string(), Vec::new()); } - let rest = lines[..start].join("\n"); + let rest = lines.get(..start).unwrap_or_default().join("\n"); (rest.trim_end().to_string(), trailers) } diff --git a/crates/walgit-server/src/web/ui.rs b/crates/walgit-server/src/web/ui.rs index f34ccc6..a213f47 100644 --- a/crates/walgit-server/src/web/ui.rs +++ b/crates/walgit-server/src/web/ui.rs @@ -227,7 +227,7 @@ async fn setup_json( /// `GET|HEAD /repos.js` | `/repos.mjs` — the browser SDK (`web/sdk/`, built /// into `web/dist/` by `pnpm run build`). Permanent URL, so `no-cache` + -/// strong ETag (revalidated per deploy), precompressed like every asset. +/// strong `ETag` (revalidated per deploy), precompressed like every asset. pub async fn sdk_asset(req: Request) -> Response { let name = req.uri().path().trim_start_matches('/'); match embedded(name) { @@ -240,6 +240,10 @@ pub async fn sdk_asset(req: Request) -> Response { } } +#[allow( + clippy::case_sensitive_file_extension_comparisons, + reason = "the build writes these asset names itself, always lowercase" +)] /// `GET|HEAD /_ui/{path}` — embedded build output. /// /// * `assets/*` carry a content hash in their name → `immutable` for a year. @@ -287,7 +291,9 @@ fn embedded_response( let mut resp = Response::new(Body::empty()); { let h = resp.headers_mut(); - h.insert(header::ETAG, HeaderValue::from_str(&etag).unwrap()); + if let Ok(v) = HeaderValue::from_str(&etag) { + h.insert(header::ETAG, v); + } h.insert(header::CACHE_CONTROL, HeaderValue::from_static(cache)); h.insert(header::VARY, HeaderValue::from_static("Accept-Encoding")); h.insert( @@ -326,21 +332,21 @@ fn negotiate_encoding( .iter() .filter_map(|v| v.to_str().ok()) .flat_map(|v| v.split(',')) - .map(|t| t.trim()) + .map(str::trim) .filter(|t| !t.is_empty()) .collect::>(); let accepts = |name: &str| { accept.iter().any(|t| { let (coding, q) = t.split_once(';').map_or((*t, None), |(c, q)| (c, Some(q))); coding.trim().eq_ignore_ascii_case(name) - && !q.is_some_and(|q| q.trim().trim_start_matches("q=").trim() == "0") + && q.is_none_or(|q| q.trim().trim_start_matches("q=").trim() != "0") }) }; for (name, ext) in [("br", ".br"), ("gzip", ".gz")] { - if accepts(name) { - if let Some(f) = embedded(&format!("{path}{ext}")) { - return Some((Some(name), f.data)); - } + if accepts(name) + && let Some(f) = embedded(&format!("{path}{ext}")) + { + return Some((Some(name), f.data)); } } None @@ -349,12 +355,12 @@ fn negotiate_encoding( fn content_type(path: &str) -> &'static str { match Path::new(path).extension().and_then(|e| e.to_str()) { Some("css") => "text/css; charset=utf-8", - Some("js") | Some("mjs") => "text/javascript; charset=utf-8", - Some("json") | Some("map") => "application/json; charset=utf-8", + Some("js" | "mjs") => "text/javascript; charset=utf-8", + Some("json" | "map") => "application/json; charset=utf-8", Some("html") => "text/html; charset=utf-8", Some("svg") => "image/svg+xml", Some("png") => "image/png", - Some("jpg") | Some("jpeg") => "image/jpeg", + Some("jpg" | "jpeg") => "image/jpeg", Some("gif") => "image/gif", Some("webp") => "image/webp", Some("ico") => "image/x-icon", @@ -576,7 +582,11 @@ async fn overview( AxumPath((owner, repo)): AxumPath<(String, String)>, headers: HeaderMap, ) -> Result { - state.auth.require_read(&headers).await.map_err(auth_err)?; + state + .auth + .require_read(&headers) + .await + .map_err(ApiError::from)?; let id = walgit_git::RepoId::new(&owner, &repo).map_err(|e| ApiError::NotFound(e.to_string()))?; let handle = state.registry.open(&id).await.map_err(wal_err)?; @@ -592,7 +602,7 @@ async fn overview( .map(|version| version.to_string()) .unwrap_or_default(); let base_url = crate::smart::request_base_url(&state, &headers); - let clone_url = format!("{}/{}.git", base_url, id); + let clone_url = format!("{base_url}/{id}.git"); let recipes = crate::setup::recipes(&state.cfg, &base_url, Some(&id.to_string())); let setup = recipes.setup_text.clone(); @@ -618,7 +628,7 @@ async fn overview( .iter() .filter(|entry| entry.kind() == EntryKind::Push) .filter_map(|entry| entry.created_at.as_ref().map(timestamp)) - .last(); + .next_back(); let mut push_count = 0; let mut compactions = Vec::new(); let mut pack_by_checksum = std::collections::HashMap::new(); @@ -738,9 +748,8 @@ async fn overview( "at the next `{w}` slot, on a maintainer whose capacity holds the pack set ({})", walgit_wal::remote::human_bytes(live_bytes) )), - (Some(_), false) => None, - (None, _) => None, - }, + (Some(_), false) | (None, _) => None, + }, }); } else if fresh >= ecfg.compaction.trigger_packs.max(2) { suggestions.push(Suggestion { @@ -756,7 +765,7 @@ async fn overview( }); } if manifest.head_seq > 0 { - let cp_seq = manifest.checkpoint.as_ref().map(|c| c.seq).unwrap_or(0); + let cp_seq = manifest.checkpoint.as_ref().map_or(0, |c| c.seq); let behind = manifest.head_seq.saturating_sub(cp_seq); if behind >= state.cfg.wal.snapshot_every_entries.max(1) || (cp_seq == 0 && behind > 0) { suggestions.push(Suggestion { @@ -952,7 +961,7 @@ async fn overview( disk: h.disk, max_pack_bytes: h.max_pack_bytes, last_pass_age_secs: age, - alive: age.map(|a| a < 600).unwrap_or(false), + alive: age.is_some_and(|a| a < 600), passes: h.passes, last_unit: h.last_unit, } @@ -1046,7 +1055,11 @@ async fn ops_list( AxumPath((owner, repo)): AxumPath<(String, String)>, headers: HeaderMap, ) -> Result { - state.auth.require_read(&headers).await.map_err(auth_err)?; + state + .auth + .require_read(&headers) + .await + .map_err(ApiError::from)?; let id = walgit_git::RepoId::new(&owner, &repo).map_err(|e| ApiError::NotFound(e.to_string()))?; let body = OpsInfo { @@ -1081,7 +1094,11 @@ async fn ops_start( axum::extract::Query(params): axum::extract::Query>, headers: HeaderMap, ) -> Result { - let principal = state.auth.require_write(&headers).await.map_err(auth_err)?; + let principal = state + .auth + .require_write(&headers) + .await + .map_err(ApiError::from)?; let id = walgit_git::RepoId::new(&owner, &repo).map_err(|e| ApiError::NotFound(e.to_string()))?; // Make sure the repo exists before spawning anything. @@ -1105,7 +1122,11 @@ async fn tasks_list( AxumPath((owner, repo)): AxumPath<(String, String)>, headers: HeaderMap, ) -> Result { - state.auth.require_read(&headers).await.map_err(auth_err)?; + state + .auth + .require_read(&headers) + .await + .map_err(ApiError::from)?; let id = walgit_git::RepoId::new(&owner, &repo).map_err(|e| ApiError::NotFound(e.to_string()))?; let tasks = state.registry.tasks(); @@ -1132,7 +1153,11 @@ async fn task_stream( AxumPath((owner, repo, task_id)): AxumPath<(String, String, String)>, headers: HeaderMap, ) -> Result { - state.auth.require_read(&headers).await.map_err(auth_err)?; + state + .auth + .require_read(&headers) + .await + .map_err(ApiError::from)?; let id = walgit_git::RepoId::new(&owner, &repo).map_err(|e| ApiError::NotFound(e.to_string()))?; let task = state @@ -1168,9 +1193,10 @@ async fn checkpoint_info( .await { Ok(GetResult::Object { meta, body }) => { - let bytes = walgit_store::util::collect(body, meta.size as usize) - .await - .map_err(|e| ApiError::Internal(e.to_string()))?; + let bytes = + walgit_store::util::collect(body, usize::try_from(meta.size).unwrap_or(usize::MAX)) + .await + .map_err(|e| ApiError::Internal(e.to_string()))?; let checkpoint = Checkpoint::decode(bytes.as_ref()) .map_err(|e| ApiError::Internal(e.to_string()))?; ( @@ -1183,8 +1209,9 @@ async fn checkpoint_info( checkpoint.writer, ) } - Ok(GetResult::NotModified { .. }) => (0, String::new(), String::new()), - Err(walgit_store::StoreError::NotFound { .. }) => (0, String::new(), String::new()), + Ok(GetResult::NotModified { .. }) | Err(walgit_store::StoreError::NotFound { .. }) => { + (0, String::new(), String::new()) + } Err(error) => return Err(ApiError::Internal(error.to_string())), }; Ok(Some(BundleInfo { @@ -1261,9 +1288,12 @@ async fn bundle_infos( } fn timestamp(value: &prost_types::Timestamp) -> String { - chrono::DateTime::::from_timestamp(value.seconds, value.nanos as u32) - .map(|date| date.to_rfc3339()) - .unwrap_or_default() + chrono::DateTime::::from_timestamp( + value.seconds, + u32::try_from(value.nanos).unwrap_or(0), + ) + .map(|date| date.to_rfc3339()) + .unwrap_or_default() } async fn repo_size(path: &Path) -> u64 { @@ -1297,15 +1327,3 @@ fn wal_err(error: walgit_wal::WalError) -> ApiError { other => ApiError::Internal(format!("wal: {other}")), } } - -fn auth_err(error: crate::auth::AuthError) -> ApiError { - match error { - crate::auth::AuthError::Invalid | crate::auth::AuthError::Unauthorized => { - ApiError::Unauthorized - } - crate::auth::AuthError::Forbidden => ApiError::Forbidden, - crate::auth::AuthError::Unavailable => { - ApiError::ServiceUnavailable("auth provider unavailable".into()) - } - } -} diff --git a/crates/walgit-server/src/web/v1.rs b/crates/walgit-server/src/web/v1.rs index 49813f8..08d0a7c 100644 --- a/crates/walgit-server/src/web/v1.rs +++ b/crates/walgit-server/src/web/v1.rs @@ -6,10 +6,10 @@ //! * `/{o}/{r}/api/…` — a bearer token or the same-origin session cookie for the same-origin bundled UI; //! * `/{o}/{r}/api-browser/…` — the browser lane for other origins (`credentials: //! "include"`), authenticated by the same session cookie (`SameSite=None`). -//! Same handlers; lanes differ by credential handling and CORS, never by a -//! rewrite. Non-repo: `/api/v1` (discovery), `/api/v1/me`, `/api/v1/authenticate` -//! (+ the `/api-browser/v1/me|authenticate` pair the SDK's popup uses), -//! `/api/v1/owners*`. The SDK (`repos.js`, `web/sdk/`) maps this one to one. +//! Same handlers; lanes differ by credential handling and CORS, never by a +//! rewrite. Non-repo: `/api/v1` (discovery), `/api/v1/me`, `/api/v1/authenticate` +//! (+ the `/api-browser/v1/me|authenticate` pair the SDK's popup uses), +//! `/api/v1/owners*`. The SDK (`repos.js`, `web/sdk/`) maps this one to one. use std::sync::Arc; @@ -78,9 +78,9 @@ fn origin_allowed(cfg: &walgit_config::Config, origin: &str) -> bool { .strip_prefix(scheme) .and_then(|o| o.strip_prefix("://")) .is_some_and(|o| { - o.ends_with(host) - && o.len() > host.len() - && o.as_bytes()[o.len() - host.len() - 1] == b'.' + o.len() > host.len() + && o.strip_suffix(host) + .is_some_and(|label| label.ends_with('.')) && !o.contains('/') }) } else { @@ -270,7 +270,7 @@ async fn me(State(st): State>, headers: HeaderMap) -> Response { r } Ok(_) => ApiError::Unauthorized.into_response(), - Err(e) => crate::web::api::auth_err(e).into_response(), + Err(e) => crate::error::ApiError::from(e).into_response(), } } @@ -287,7 +287,7 @@ async fn authenticate(State(st): State>, headers: HeaderMap) -> Re .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); r } - Err(e) => crate::web::api::auth_err(e).into_response(), + Err(e) => crate::error::ApiError::from(e).into_response(), } } @@ -329,7 +329,7 @@ struct RepoSummary { } /// `GET /{owner}/{repo}/api[-browser]` — one cheap, ref-level summary (SWR + -/// ETag on the head sha). Counts are O(1) from the ref index. +/// `ETag` on the head sha). Counts are O(1) from the ref index. async fn repo_summary( State(st): State>, headers: HeaderMap, @@ -346,7 +346,7 @@ async fn repo_summary( None, move |r| async move { let head = r.index.head().map(|(name, sha)| RefInfo { name, sha }); - let etag = etag_for(head.as_ref().map(|h| h.sha.as_str()).unwrap_or("unborn")); + let etag = etag_for(head.as_ref().map_or("unborn", |h| h.sha.as_str())); let full = format!("{o}/{n}"); Ok(json_swr( &RepoSummary { @@ -393,8 +393,10 @@ async fn repo_admin( let mut sub = String::new(); for lane in ["api-browser", "api"] { let marker = format!("/{owner}/{name}/{lane}"); - if let Some(i) = path.find(&marker) { - sub = path[i + marker.len()..].trim_start_matches('/').to_string(); + if let Some(i) = path.find(&marker) + && let Some(tail) = path.get(i + marker.len()..) + { + sub = tail.trim_start_matches('/').to_string(); break; } } diff --git a/crates/walgit-server/tests/api_v1.rs b/crates/walgit-server/tests/api_v1.rs index db4e3af..9c5b7ae 100644 --- a/crates/walgit-server/tests/api_v1.rs +++ b/crates/walgit-server/tests/api_v1.rs @@ -2,6 +2,17 @@ //! (`/api-browser`), CORS for foreign origins, discovery, `me`, repo summary and //! admin, and the SDK artefact route. +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing, + clippy::many_single_char_names +)] +// clippy.toml exempts #[test] functions from the panic-path lints, but not the plain +// helper functions beside them in the same file. A panic in a fixture builder is how +// that fixture reports it could not be built, exactly as in the tests it serves. + mod harness; use harness::{Server, git_in}; diff --git a/crates/walgit-server/tests/drain.rs b/crates/walgit-server/tests/drain.rs index 03d648a..b9cdf6f 100644 --- a/crates/walgit-server/tests/drain.rs +++ b/crates/walgit-server/tests/drain.rs @@ -6,6 +6,17 @@ //! and fetches are refused with 503 + Retry-After before any work, in-flight //! requests get `server.drain_timeout`. +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing, + clippy::many_single_char_names +)] +// clippy.toml exempts #[test] functions from the panic-path lints, but not the plain +// helper functions beside them in the same file. A panic in a fixture builder is how +// that fixture reports it could not be built, exactly as in the tests it serves. + mod harness; use harness::{Server, git, git_in}; @@ -46,7 +57,7 @@ async fn after_sigterm_new_object_work_is_refused_and_no_unit_starts() -> anyhow .registry .open(&walgit_git::RepoId::new("o", "r")?) .await?; - let unit = match h0.begin_task("compact", Default::default()) { + let unit = match h0.begin_task("compact", std::collections::HashMap::default()) { walgit_wal::Begin::Started(t) => t, walgit_wal::Begin::AlreadyRunning(_) => anyhow::bail!("compact already running"), }; diff --git a/crates/walgit-server/tests/e2e.rs b/crates/walgit-server/tests/e2e.rs index 849f645..2f7a53c 100644 --- a/crates/walgit-server/tests/e2e.rs +++ b/crates/walgit-server/tests/e2e.rs @@ -3,6 +3,17 @@ //! ref delete, tags, partial clone + lazy fetch, ls-remote, and the two-instance //! consistency test (push on A, immediate clone on B). LFS is exercised when //! `git lfs` is present. +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing, + clippy::many_single_char_names +)] +// clippy.toml exempts #[test] functions from the panic-path lints, but not the plain +// helper functions beside them in the same file. A panic in a fixture builder is how +// that fixture reports it could not be built, exactly as in the tests it serves. + mod harness; type TestResult = anyhow::Result<()>; @@ -712,7 +723,7 @@ async fn many_refs_impl(n: usize) -> TestResult { let push_start = Instant::now(); git_in(&src, &["push", "--mirror", "origin"])?; println!("{n}-ref mirror push took {:?}", push_start.elapsed()); - assert!(push_start.elapsed() < std::time::Duration::from_secs(240)); + assert!(push_start.elapsed() < std::time::Duration::from_mins(4)); let start = Instant::now(); let output = Command::new("git") .args(["ls-remote", &server.repo_url("t", "many-refs")]) @@ -981,8 +992,7 @@ fn git_lfs_present() -> bool { Command::new("git") .args(["lfs", "version"]) .output() - .map(|o| o.status.success()) - .unwrap_or(false) + .is_ok_and(|o| o.status.success()) } fn git_supports_sha256() -> bool { @@ -997,8 +1007,7 @@ fn git_supports_sha256() -> bool { dir.path().to_str().unwrap(), ]) .output() - .map(|o| o.status.success()) - .unwrap_or(false) + .is_ok_and(|o| o.status.success()) } /// A front whose `cache.max_bytes` cannot hold a repository's pack set must @@ -1814,6 +1823,7 @@ async fn partial_clone_tree_zero_and_depth_with_filter() -> TestResult { /// unrelated refs request answers in < 1 s meanwhile (prod: every request on /// the instance stalled for minutes, timers included). #[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[allow(unsafe_code)] async fn history_pack_install_does_not_stall_the_runtime() -> TestResult { // git shim: slow only for multi-pack-index. let shim = tempfile::tempdir()?; @@ -1883,7 +1893,7 @@ async fn history_pack_install_does_not_stall_the_runtime() -> TestResult { let small = big .start_sibling_with(|c| { c.cache.prewarm = vec!["t/hist".into()]; - c.cache.prewarm_ready_timeout = std::time::Duration::from_secs(600); + c.cache.prewarm_ready_timeout = std::time::Duration::from_mins(10); }) .await?; walgit_server::prewarm::spawn(small.state.clone()); @@ -1963,15 +1973,17 @@ async fn history_pack_install_does_not_stall_the_runtime() -> TestResult { took.as_secs_f64() >= 3.0, "the shim should have slowed the install: {took:?}" ); + // SAFETY: see above; restores the PATH this test replaced. unsafe { std::env::set_var("PATH", old_path) }; Ok(()) } /// Materialization runs on its own runtime: even an unknown *blocking* call /// inside the install path (simulated by `WALGIT_TEST_BLOCK_INSTALL_MS`, a -/// synchronous sleep in reconcile_packs) must not stall request workers — +/// synchronous sleep in `reconcile_packs`) must not stall request workers — /// refs answer in milliseconds on a single-worker server meanwhile. #[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[allow(unsafe_code)] async fn blocking_work_in_the_install_path_does_not_stall_requests() -> TestResult { // SAFETY: test process; read by the sibling's sync below. unsafe { std::env::set_var("WALGIT_TEST_BLOCK_INSTALL_MS", "2500") }; @@ -2015,6 +2027,7 @@ async fn blocking_work_in_the_install_path_does_not_stall_requests() -> TestResu worst = worst.max(t.elapsed().as_millis()); probes += 1; } + // SAFETY: see above; clears the var this test set. unsafe { std::env::remove_var("WALGIT_TEST_BLOCK_INSTALL_MS") }; let took = install.await?; assert!(took.as_millis() >= 2500, "{took:?}"); @@ -2905,6 +2918,7 @@ async fn stale_cached_credential_is_erased_by_the_401_and_replaced_on_the_next_c /// new version before applying the refs locally let a reader cache the OLD refs under the NEW /// version (reproduced roughly once in six rounds). 12 rounds × 6 pushers. #[tokio::test(flavor = "multi_thread", worker_threads = 8)] +#[allow(unsafe_code)] async fn reads_after_an_acknowledged_push_never_show_the_previous_tip() -> TestResult { // Widen the gap between the publish's two local-commit steps (refs applied; version advertised) // to 150 ms so the reader reliably lands in it: harmless in the right order, the poison window diff --git a/crates/walgit-server/tests/events.rs b/crates/walgit-server/tests/events.rs index b7d8f00..6b91182 100644 --- a/crates/walgit-server/tests/events.rs +++ b/crates/walgit-server/tests/events.rs @@ -1,6 +1,17 @@ //! Events (docs/EVENTS.md): the bridge publishes exactly what the WAL //! committed, from a durable cursor; the GCS-notification wake-up; the sweep; //! a sink failure keeps the cursor. +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing, + clippy::many_single_char_names +)] +// clippy.toml exempts #[test] functions from the panic-path lints, but not the plain +// helper functions beside them in the same file. A panic in a fixture builder is how +// that fixture reports it could not be built, exactly as in the tests it serves. + mod harness; const ZERO_OID: &str = "0000000000000000000000000000000000000000"; @@ -14,7 +25,7 @@ type Captured = std::sync::Arc>>; /// The webhook sink's target: records every event it receives (the bus as /// the test sees it). async fn webhook() -> (String, Captured) { - let captured: Captured = Default::default(); + let captured: Captured = std::sync::Arc::default(); let app = axum::Router::new().route( "/events", axum::routing::post({ diff --git a/crates/walgit-server/tests/follow.rs b/crates/walgit-server/tests/follow.rs index c7037ae..05aaad0 100644 --- a/crates/walgit-server/tests/follow.rs +++ b/crates/walgit-server/tests/follow.rs @@ -3,13 +3,24 @@ //! same PUSH entry a push produces — fast-forward only. The upstream here is a //! second walgit instance (smart HTTP v2 over 127.0.0.1, real `git fetch`). +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing, + clippy::many_single_char_names +)] +// clippy.toml exempts #[test] functions from the panic-path lints, but not the plain +// helper functions beside them in the same file. A panic in a fixture builder is how +// that fixture reports it could not be built, exactly as in the tests it serves. + mod harness; use harness::{Server, git, git_in}; macro_rules! step { ($name:literal, $e:expr) => { - tokio::time::timeout(std::time::Duration::from_secs(60), $e) + tokio::time::timeout(std::time::Duration::from_mins(1), $e) .await .unwrap_or_else(|_| panic!("step timed out: {}", $name)) }; @@ -206,8 +217,7 @@ async fn start_op( ) .await { - Ok(t) => Ok(t), - Err(walgit_server::ops::StartError::AlreadyRunning(t)) => Ok(t), + Ok(t) | Err(walgit_server::ops::StartError::AlreadyRunning(t)) => Ok(t), Err(walgit_server::ops::StartError::UnknownOp) => Err("unknown op".into()), } } diff --git a/crates/walgit-server/tests/harness.rs b/crates/walgit-server/tests/harness.rs index 85ecdb4..784340b 100644 --- a/crates/walgit-server/tests/harness.rs +++ b/crates/walgit-server/tests/harness.rs @@ -1,3 +1,13 @@ +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing, + clippy::many_single_char_names +)] +// clippy.toml exempts #[test] functions from the panic-path lints, but not the plain +// helper functions beside them in the same file. A panic in a fixture builder is how +// that fixture reports it could not be built, exactly as in the tests it serves. #![allow(dead_code)] //! Test harness: spin up walgit-server on a random port backed by the in-memory //! store + a tempdir cache, and drive real upstream `git` against it. @@ -76,7 +86,7 @@ impl Server { cfg.cache.max_bytes = ByteSize::gib(2); cfg.server.listen = "127.0.0.1:0".parse().unwrap(); cfg.server.max_concurrent_per_repo = 8; - cfg.server.request_timeout = std::time::Duration::from_secs(600); + cfg.server.request_timeout = std::time::Duration::from_mins(10); cfg.server.max_push_bytes = ByteSize::gib(2); cfg.wal.fsck_objects = true; cfg.wal.check_connectivity = true; @@ -96,12 +106,11 @@ impl Server { "auto" => cfg.git.upload_pack_engine = walgit_config::UploadPackEngine::Auto, _ => cfg.git.upload_pack_engine = walgit_config::UploadPackEngine::Git, } - if let Ok(ms) = std::env::var("WALGIT_TEST_MEMORY_LATENCY_MS") { - if let Ok(ms) = ms.parse::() { - if let Some(s) = Arc::get_mut(&mut store) { - s.latency = Some(std::time::Duration::from_millis(ms)); - } - } + if let Ok(ms) = std::env::var("WALGIT_TEST_MEMORY_LATENCY_MS") + && let Ok(ms) = ms.parse::() + && let Some(s) = Arc::get_mut(&mut store) + { + s.latency = Some(std::time::Duration::from_millis(ms)); } tweak(&mut cfg); @@ -112,12 +121,12 @@ impl Server { cfg.validate().context("config validate")?; let dyn_store: DynStore = store.clone(); - let state = AppState::new(Arc::new(cfg), dyn_store).await?; + let state = AppState::new(&Arc::new(cfg), dyn_store)?; let registry = state.registry.clone(); let bundles = state.bundles.clone(); // Events bridge sweep timer (no-op unless the bridge is enabled). - walgit_server::bridge::spawn_sweeper(state.clone()); + walgit_server::bridge::spawn_sweeper(&state); let app = router(state.clone()); let (tx, rx) = tokio::sync::oneshot::channel::<()>(); @@ -144,15 +153,14 @@ impl Server { }) } - /// Two instances sharing one MemoryStore, different cache dirs. + /// Two instances sharing one `MemoryStore`, different cache dirs. pub async fn start_pair() -> Result<(Self, Self)> { let mut store = MemoryStore::shared(); - if let Ok(ms) = std::env::var("WALGIT_TEST_MEMORY_LATENCY_MS") { - if let Ok(ms) = ms.parse::() { - if let Some(s) = Arc::get_mut(&mut store) { - s.latency = Some(std::time::Duration::from_millis(ms)); - } - } + if let Ok(ms) = std::env::var("WALGIT_TEST_MEMORY_LATENCY_MS") + && let Ok(ms) = ms.parse::() + && let Some(s) = Arc::get_mut(&mut store) + { + s.latency = Some(std::time::Duration::from_millis(ms)); } let a = Self::start_with(store.clone(), tempfile::tempdir()?).await?; let b = Self::start_with(store.clone(), tempfile::tempdir()?).await?; @@ -204,7 +212,7 @@ impl Server { pub async fn registry_has_packs(&self, owner: &str, repo: &str) -> bool { let id = walgit_git::RepoId::new(owner, repo).unwrap(); match self.registry.open(&id).await { - Ok(h) => h.packs_ready() && !h.local().packs().map(|p| p.is_empty()).unwrap_or(true), + Ok(h) => h.packs_ready() && !h.local().packs().map_or(true, |p| p.is_empty()), Err(_) => false, } } @@ -215,6 +223,8 @@ impl Server { Ok(()) } + // Callers wrap this in the suite's `with_timeout!`, which needs a future. + #[allow(clippy::unused_async)] pub async fn ls_remote(&self, owner: &str, repo: &str) -> Result { let out = Command::new("git") .args(["ls-remote", &self.repo_url(owner, repo)]) diff --git a/crates/walgit-server/tests/lfs_upstream.rs b/crates/walgit-server/tests/lfs_upstream.rs index e813235..cf82e27 100644 --- a/crates/walgit-server/tests/lfs_upstream.rs +++ b/crates/walgit-server/tests/lfs_upstream.rs @@ -7,6 +7,17 @@ //! the upstream and persists them into the store (second GET served locally). //! - upstream lacks it: 404 on download, upload action on upload. +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing, + clippy::many_single_char_names +)] +// clippy.toml exempts #[test] functions from the panic-path lints, but not the plain +// helper functions beside them in the same file. A panic in a fixture builder is how +// that fixture reports it could not be built, exactly as in the tests it serves. + mod harness; use std::sync::Arc; @@ -80,7 +91,7 @@ async fn start_mock(body: Vec) -> Result<(Arc, String)> { body, batches: AtomicUsize::new(0), downloads: AtomicUsize::new(0), - base: Default::default(), + base: std::sync::Mutex::default(), }); let app = Router::new() .route("/lfs/objects/batch", post(mock_batch)) @@ -88,7 +99,7 @@ async fn start_mock(body: Vec) -> Result<(Arc, String)> { .with_state(mock.clone()); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; let base = format!("http://{}", listener.local_addr()?); - *mock.base.lock().unwrap() = base.clone(); + (*mock.base.lock().unwrap()).clone_from(&base); tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); Ok((mock, base)) } diff --git a/crates/walgit-server/tests/maintain.rs b/crates/walgit-server/tests/maintain.rs index ee6c3bd..74d7ceb 100644 --- a/crates/walgit-server/tests/maintain.rs +++ b/crates/walgit-server/tests/maintain.rs @@ -1,6 +1,17 @@ //! The `maintain` role's pass: checkpoint-if-due (refs-level, on an instance //! that cannot hold the packs), bundles-if-due, compaction, all as tasks. +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing, + clippy::many_single_char_names +)] +// clippy.toml exempts #[test] functions from the panic-path lints, but not the plain +// helper functions beside them in the same file. A panic in a fixture builder is how +// that fixture reports it could not be built, exactly as in the tests it serves. + mod harness; use harness::{Server, git, git_in}; @@ -16,6 +27,7 @@ macro_rules! step { #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn pass_checkpoints_due_repos_refs_level_and_reports_tasks() -> anyhow::Result<()> { + use walgit_server::maintain::{Unit, next_unit, run_pass}; // Writer front: count trigger off, so nothing auto-checkpoints on push. let front = step!("start front", Server::start())?; step!("put repo", front.put_repo("o", "r"))?; @@ -111,7 +123,6 @@ async fn pass_checkpoints_due_repos_refs_level_and_reports_tasks() -> anyhow::Re // not due), one unit per pass, next pass moves to the daily chain, and a // re-run after everything is built is idempotent (Idle). let id = walgit_git::RepoId::new("o", "r")?; - use walgit_server::maintain::{Unit, next_unit, run_pass}; assert!( matches!(step!("unit 1", next_unit(&bundler.state, &id))?, Unit::BundleSlot(ref s, _) if s == "weekly") ); @@ -277,7 +288,7 @@ async fn fsck_unit_records_missing_objects_and_repair_unit_fetches_them_from_ups c.maintenance.checkpoints = false; c.compaction.enabled = false; c.bundles.enabled = false; - c.maintenance.fsck_interval = std::time::Duration::from_secs(3600); + c.maintenance.fsck_interval = std::time::Duration::from_hours(1); }) )?; step!("put repo", server.put_repo("o", "r"))?; @@ -357,7 +368,7 @@ async fn fsck_unit_records_missing_objects_and_repair_unit_fetches_them_from_ups }; step!( "move main", - h.publish_push_synced(None, txn, Default::default()) + h.publish_push_synced(None, txn, std::collections::HashMap::default()) )?; // Pass 1: the audit (never audited) → fsck.pb lists the blob; the unit succeeds (a finding, not a failure). @@ -522,7 +533,7 @@ async fn connectivity_failure_is_reported_per_ref_not_as_remote_failure() -> any }; step!( "advertise x", - h.publish_push_synced(None, txn, Default::default()) + h.publish_push_synced(None, txn, std::collections::HashMap::default()) )?; // A new commit on top whose tree still references the missing blob (b.txt // unchanged): git sends commit 3 + its root tree, the server walks into b.txt. @@ -732,11 +743,7 @@ async fn bundle_list_shows_a_bundle_right_after_this_host_builds_it() -> anyhow: .await .map_err(|_| anyhow::anyhow!("op start failed"))?; assert!(t.wait_done(std::time::Duration::from_secs(30)).await); - assert!( - t.outcome().map(|o| o.is_ok()).unwrap_or(false), - "{:?}", - t.outcome() - ); + assert!(t.outcome().is_some_and(|o| o.is_ok()), "{:?}", t.outcome()); let list2 = step!("list 2", server.get_text("/o/r.git/bundles/list", &[]))?; assert!( list2.contains("[bundle \"daily-"), @@ -797,7 +804,7 @@ async fn one_pass_settles_all_closed_empty_slots() -> anyhow::Result<()> { c.maintenance.fsck_interval = std::time::Duration::ZERO; // weekly (full) + hourly on weekly: the closed hours since the weekly are empty. c.bundles.strategy.retain(|s| s.name != "daily"); - for s in c.bundles.strategy.iter_mut() { + for s in &mut c.bundles.strategy { if s.name == "hourly" { s.base = Some("weekly".into()); s.backfill_max = 0; @@ -838,7 +845,7 @@ async fn one_pass_settles_all_closed_empty_slots() -> anyhow::Result<()> { .clone(); let sunday = walgit_bundle::slots::last_slot_at_or_before( &weekly, - now - std::time::Duration::from_secs(36 * 3600), + now - std::time::Duration::from_hours(36), )? .unwrap(); let mut params = std::collections::HashMap::new(); @@ -1020,7 +1027,7 @@ async fn weekly_slot_rebuilds_the_base_then_composes_it_on_an_ssd_maintainer() - }; step!( "import refs", - h.publish_push_synced(None, txn, Default::default()) + h.publish_push_synced(None, txn, std::collections::HashMap::default()) )?; step!("sync after base", h.sync())?; std::fs::write(src.path().join("g.txt"), "two\n")?; @@ -1172,12 +1179,12 @@ async fn weekly_slot_rebuilds_the_base_then_composes_it_on_an_ssd_maintainer() - "the base is the biggest tier-2 pack, not the newest" ); let next_weekly = - walgit_bundle::slots::from_epoch(weekly.slot) + std::time::Duration::from_secs(7 * 86400); + walgit_bundle::slots::from_epoch(weekly.slot) + std::time::Duration::from_hours(168); let up = walgit_server::maintain::upcoming( &h, &h.effective_config(), &walgit_server::maintain::heartbeats(&server.state).await?, - next_weekly - std::time::Duration::from_secs(60), + next_weekly - std::time::Duration::from_mins(1), ) .await; let w = up @@ -1299,7 +1306,7 @@ async fn maintainer_builds_and_publishes_missing_rev_indexes() -> anyhow::Result let task = walgit_server::ops::start(server.state.clone(), id.clone(), "rev-index", params) .await .map_err(|_| anyhow::anyhow!("rev-index op did not start"))?; - assert!(task.wait_done(std::time::Duration::from_secs(60)).await); + assert!(task.wait_done(std::time::Duration::from_mins(1)).await); assert!( matches!(task.outcome(), Some(Ok(_))), "{:?}", @@ -1366,7 +1373,7 @@ async fn identical_incremental_slots_are_skipped_as_unchanged() -> anyhow::Resul c.maintenance.checkpoints = false; c.maintenance.fsck_interval = std::time::Duration::ZERO; c.bundles.strategy.retain(|s| s.name != "daily"); - for s in c.bundles.strategy.iter_mut() { + for s in &mut c.bundles.strategy { if s.name == "hourly" { s.base = Some("weekly".into()); s.backfill_max = 0; @@ -1394,7 +1401,7 @@ async fn identical_incremental_slots_are_skipped_as_unchanged() -> anyhow::Resul let id = walgit_git::RepoId::new("o", "r")?; let h = step!("open", server.state.registry.open(&id))?; let now = std::time::SystemTime::now(); - let hour = std::time::Duration::from_secs(3600); + let hour = std::time::Duration::from_hours(1); // History with explicit times: c1 ten days ago (so a weekly slot with state // exists — a full with no state is cut from now), c2 six hours ago, nothing since. let pack_of = |revs: &str| -> anyhow::Result> { @@ -1436,7 +1443,7 @@ async fn identical_incremental_slots_are_skipped_as_unchanged() -> anyhow::Resul h.publish_push_at( Some(p1), txn("refs/heads/main", "", &c1), - Default::default(), + std::collections::HashMap::default(), now - 240 * hour ) )?; @@ -1446,7 +1453,7 @@ async fn identical_incremental_slots_are_skipped_as_unchanged() -> anyhow::Resul h.publish_push_at( Some(p2), txn("refs/heads/main", &c1, &c2), - Default::default(), + std::collections::HashMap::default(), now - 6 * hour ) )?; @@ -1679,7 +1686,7 @@ async fn blobless_bundle_family_is_composed_from_the_history_pack_and_served_on_ }; step!( "import refs", - h.publish_push_synced(None, txn, Default::default()) + h.publish_push_synced(None, txn, std::collections::HashMap::default()) )?; std::fs::write(src.path().join("f2.txt"), "one and a half\n")?; git_in(src.path(), &["add", "."])?; @@ -1897,7 +1904,7 @@ async fn maintainer_pass_brings_an_overgrown_bundle_list_to_retention() -> anyho c.server.roles = vec![walgit_config::Role::Serve, walgit_config::Role::Maintain]; c.bundles.enabled = true; // The D21 shape this test pins (the default chains the dailies since 2026-08-22). - for s in c.bundles.strategy.iter_mut() { + for s in &mut c.bundles.strategy { s.chain = false; } }) diff --git a/crates/walgit-server/tests/routing_prefix.rs b/crates/walgit-server/tests/routing_prefix.rs index 5c65c55..bc1d3a6 100644 --- a/crates/walgit-server/tests/routing_prefix.rs +++ b/crates/walgit-server/tests/routing_prefix.rs @@ -8,11 +8,20 @@ //! `/api-browser/v1/authenticate`, `/services/api/owners|instance`. //! * Clients must not emit the deleted lane-first repo forms. +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing, + clippy::many_single_char_names +)] +// clippy.toml exempts #[test] functions from the panic-path lints, but not the plain +// helper functions beside them in the same file. A panic in a fixture builder is how +// that fixture reports it could not be built, exactly as in the tests it serves. + use std::fs; use std::path::{Path, PathBuf}; -type TestResult = anyhow::Result<()>; - fn root() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join("../..") } @@ -71,17 +80,22 @@ fn route_literals(src: &str) -> Vec<(usize, String)> { let Some(idx) = t.find(".route(\"") else { continue; }; - let rest = &t[idx + ".route(\"".len()..]; + let Some(rest) = t.get(idx + ".route(\"".len()..) else { + continue; + }; let Some(end) = rest.find('"') else { continue; }; - out.push((i + 1, rest[..end].to_string())); + let Some(route) = rest.get(..end) else { + continue; + }; + out.push((i + 1, route.to_string())); } out } #[test] -fn repo_scoped_routes_start_with_owner_repo() -> TestResult { +fn repo_scoped_routes_start_with_owner_repo() { let files = [ "crates/walgit-server/src/lib.rs", "crates/walgit-server/src/web/api.rs", @@ -101,14 +115,13 @@ fn repo_scoped_routes_start_with_owner_repo() -> TestResult { "repo-scoped routes must start with /{{owner}}/{{repo}} (or be on the D26 allow-list):\n{}", bad.join("\n") ); - Ok(()) } fn forbidden_client_hits(src: &str, rel: &str) -> Vec { let mut hits = Vec::new(); for (i, line) in src.lines().enumerate() { let t = line.trim(); - if t.starts_with("//") || t.starts_with("*") || t.starts_with("/*") { + if t.starts_with("//") || t.starts_with('*') || t.starts_with("/*") { continue; } // Documentation of the alias in comments is fine; code that builds a URL is not. @@ -130,7 +143,7 @@ fn forbidden_client_hits(src: &str, rel: &str) -> Vec { } #[test] -fn clients_emit_prefix_form() -> TestResult { +fn clients_emit_prefix_form() { let mut hits = Vec::new(); hits.extend(forbidden_client_hits( &read("web/src/api.ts"), @@ -155,12 +168,13 @@ fn clients_emit_prefix_form() -> TestResult { "UI/SDK/setup must not emit lane-first repo URLs (/api/v1/repos, /api-browser/v1/repos, /services/api/{{o}}/{{r}}):\n{}", hits.join("\n") ); - Ok(()) } fn walk_ts(dir: &str) -> Vec<(String, String)> { - let mut out = Vec::new(); - let base = root().join(dir); + #[allow( + clippy::case_sensitive_file_extension_comparisons, + reason = "the repository's own sources, whose extensions are lowercase by convention" + )] fn rec(dir: &Path, root: &Path, out: &mut Vec<(String, String)>) { let Ok(rd) = fs::read_dir(dir) else { return }; for e in rd.flatten() { @@ -181,6 +195,8 @@ fn walk_ts(dir: &str) -> Vec<(String, String)> { } } } + let mut out = Vec::new(); + let base = root().join(dir); rec(&base, &root(), &mut out); out } diff --git a/crates/walgit-server/tests/sim.rs b/crates/walgit-server/tests/sim.rs index 65e2026..cef1b2b 100644 --- a/crates/walgit-server/tests/sim.rs +++ b/crates/walgit-server/tests/sim.rs @@ -1,4 +1,4 @@ -//! Simulation tests: safety mode → liveness mode (after TigerBeetle's VOPR, +//! Simulation tests: safety mode → liveness mode (after `TigerBeetle`'s VOPR, //! "Simulation Testing For Liveness", 2023). //! //! A *cluster* is N walgit instances (one `Registry` + cache dir each) that @@ -23,7 +23,19 @@ //! `WALGIT_SIM_SEEDS` (count, default 2). Size: `WALGIT_SIM_PUSHES` per pusher. //! Failing runs print the link traces and the seed. +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing, + clippy::many_single_char_names +)] +// clippy.toml exempts #[test] functions from the panic-path lints, but not the plain +// helper functions beside them in the same file. A panic in a fixture builder is how +// that fixture reports it could not be built, exactly as in the tests it serves. + use std::collections::{BTreeMap, HashMap}; +use std::fmt::Write as _; use std::io::Write; use std::path::Path; use std::process::{Command, Stdio}; @@ -111,7 +123,7 @@ impl WorkRepo { fn pack(&self, head: &str, base: Option<&str>) -> Vec { let mut revs = format!("{head}\n"); if let Some(b) = base { - revs.push_str(&format!("^{b}\n")); + let _ = writeln!(revs, "^{b}"); } let mut child = Command::new("git") .args(["pack-objects", "--stdout", "--revs", "-q"]) @@ -167,7 +179,7 @@ struct Instance { link: Arc, registry: Arc, cfg: Arc, - _cache: tempfile::TempDir, + cache_dir: tempfile::TempDir, } impl Instance { @@ -198,7 +210,7 @@ impl Instance { link, registry, cfg, - _cache: cache, + cache_dir: cache, } } async fn open(&self, id: &RepoId) -> Result> { @@ -218,7 +230,7 @@ struct Cluster { impl Cluster { async fn new(seed: u64, n: usize) -> Result { let truth: DynStore = MemoryStore::shared(); - let id = RepoId::new("sim", &format!("r{seed}"))?; + let id = RepoId::new("sim", format!("r{seed}"))?; let mut c = Cluster { seed, truth, @@ -258,7 +270,7 @@ impl Cluster { let s = self.next_link_seed.fetch_add(1, Ordering::Relaxed); // Take the cache dir out of the old instance without dropping it. let placeholder = tempfile::tempdir().unwrap(); - let cache = std::mem::replace(&mut self.instances[i]._cache, placeholder); + let cache = std::mem::replace(&mut self.instances[i].cache_dir, placeholder); let fresh = Instance::new_at(&self.truth, &name, s, cache, tweak); let old = std::mem::replace(&mut self.instances[i], fresh); drop(old); @@ -282,11 +294,7 @@ impl Cluster { fn dump_traces(&self) -> String { let mut s = String::new(); for i in &self.instances { - s.push_str(&format!( - "--- link {} ({})\n", - i.name, - i.link.stats().summary() - )); + let _ = writeln!(s, "--- link {} ({})", i.name, i.link.stats().summary()); for l in i .link .take_trace() @@ -305,7 +313,7 @@ impl Cluster { } } -/// BundleSource adapter used by the bundle-lease liveness scenario. +/// `BundleSource` adapter used by the bundle-lease liveness scenario. struct SimBundleSource(Arc); #[async_trait::async_trait] @@ -522,7 +530,7 @@ async fn check_truth(c: &Cluster, pushers: &[Pusher]) -> Result<()> { // The checkpoint (if any) folds the log prefix: refs from its RefSnapshot, // entries after it from the tail. Both must exist in the bucket. let prefix = c.repo_prefix(); - let cp_seq = manifest.checkpoint.as_ref().map(|cp| cp.seq).unwrap_or(0); + let cp_seq = manifest.checkpoint.as_ref().map_or(0, |cp| cp.seq); let mut folded: HashMap = HashMap::new(); if cp_seq > 0 { let key = format!( @@ -559,14 +567,14 @@ async fn check_truth(c: &Cluster, pushers: &[Pusher]) -> Result<()> { ); } ensure!( - log.first().map(|e| e.seq > cp_seq).unwrap_or(true), + log.first().is_none_or(|e| e.seq > cp_seq), "log tail starts at {} <= checkpoint {cp_seq}", log[0].seq ); ensure!( - log.last().map(|e| e.seq).unwrap_or(cp_seq) == manifest.head_seq, + log.last().map_or(cp_seq, |e| e.seq) == manifest.head_seq, "log tail {} != manifest.head_seq {}", - log.last().map(|e| e.seq).unwrap_or(cp_seq), + log.last().map_or(cp_seq, |e| e.seq), manifest.head_seq ); // Every ACK after the checkpoint is in the log at its seq with its txn. @@ -615,14 +623,11 @@ async fn check_truth(c: &Cluster, pushers: &[Pusher]) -> Result<()> { // f must be last.new or a commit pushed after it (the ack'd or an // errored-but-committed push along the same chain). let later = log.iter().filter(|e| e.seq > last.seq).any(|e| { - e.txn - .as_ref() - .map(|t| { - t.updates - .iter() - .any(|u| u.name == p.refname && u.new_oid == f) - }) - .unwrap_or(false) + e.txn.as_ref().is_some_and(|t| { + t.updates + .iter() + .any(|u| u.name == p.refname && u.new_oid == f) + }) }) || last.seq <= cp_seq; ensure!( f == last.new || later, @@ -787,8 +792,10 @@ async fn check_core_liveness( .await .map_err(|_| anyhow!("liveness: compaction hung > {bound:?}"))?; match out { - Ok(walgit_server::ops::CompactOutcome::Published { .. }) - | Ok(walgit_server::ops::CompactOutcome::NotTriggered { .. }) => break, + Ok( + walgit_server::ops::CompactOutcome::Published { .. } + | walgit_server::ops::CompactOutcome::NotTriggered { .. }, + ) => break, Ok(walgit_server::ops::CompactOutcome::LeaseHeld) => { ensure!( t.elapsed() < bound, @@ -834,7 +841,7 @@ fn seeds() -> Vec { .ok() .and_then(|s| s.parse().ok()) .unwrap_or(2); - (1..=n).map(|i| 0xC0FFEE + i * 7919).collect() + (1..=n).map(|i| 0x00C0_FFEE + i * 7919).collect() } fn pushes_per_pusher() -> u64 { std::env::var("WALGIT_SIM_PUSHES") @@ -848,8 +855,8 @@ impl Lcg { fn next(&mut self) -> u64 { self.0 = self .0 - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); + .wrapping_mul(6_364_136_223_846_793_005) + .wrapping_add(1_442_695_040_888_963_407); self.0 >> 33 } fn below(&mut self, n: u64) -> u64 { @@ -876,19 +883,19 @@ async fn run_safety_then_liveness(seed: u64) -> Result<()> { let per = pushes_per_pusher(); let op_timeout = Duration::from_secs(10); for round in 0..per { - for p in pushers.iter_mut() { - let i = rng.below(n_instances as u64) as usize; + for p in &mut pushers { + let i = usize::try_from(rng.below(n_instances as u64)).unwrap_or(usize::MAX); let _ = p.push_once(&c.instances[i], &c.id, op_timeout).await?; } // Random crash: replace an instance (its in-flight state is gone). if rng.chance(0.2) { - let i = rng.below(n_instances as u64) as usize; + let i = usize::try_from(rng.below(n_instances as u64)).unwrap_or(usize::MAX); c.restart(i); c.instances[i].link.set(FaultPlan::chaos(0.04)); } // Occasionally somebody checkpoints or compacts under chaos. if round % 4 == 3 { - let i = rng.below(n_instances as u64) as usize; + let i = usize::try_from(rng.below(n_instances as u64)).unwrap_or(usize::MAX); if let Ok(h) = c.instances[i].open(&c.id).await { let _ = tokio::time::timeout(op_timeout, h.write_checkpoint()).await; let cfg = c.instances[i].cfg.clone(); @@ -922,7 +929,7 @@ async fn run_safety_then_liveness(seed: u64) -> Result<()> { // Liveness mode: pick a core of 2, heal it, freeze the rest in nasty states. let mut idx: Vec = (0..n_instances).collect(); for k in (1..idx.len()).rev() { - let j = rng.below(k as u64 + 1) as usize; + let j = usize::try_from(rng.below(k as u64 + 1)).unwrap_or(usize::MAX); idx.swap(k, j); } let core = &idx[..2]; @@ -949,9 +956,10 @@ async fn run_safety_then_liveness(seed: u64) -> Result<()> { }, ]; for (k, &i) in idx[2..].iter().enumerate() { - c.instances[i] - .link - .set(frozen[(k + rng.below(4) as usize) % frozen.len()].clone()); + c.instances[i].link.set( + frozen[(k + usize::try_from(rng.below(4)).unwrap_or(usize::MAX)) % frozen.len()] + .clone(), + ); } // Non-core pushers keep hammering the frozen links in the background (they // may never interfere with the core). @@ -968,7 +976,7 @@ async fn run_safety_then_liveness(seed: u64) -> Result<()> { link, registry: reg, cfg: Arc::new(sim_config(Path::new("/nonexistent"))), - _cache: tempfile::tempdir().unwrap(), + cache_dir: tempfile::tempdir().unwrap(), }; for _ in 0..20 { let _ = p.push_once(&inst, &id, Duration::from_millis(500)).await; @@ -1005,7 +1013,7 @@ async fn sim_safety_then_liveness() { let r = run_safety_then_liveness(seed).await; eprintln!( "[seed {seed}] {:?} in {:.1}s", - r.as_ref().map(|_| "ok"), + r.as_ref().map(|()| "ok"), t.elapsed().as_secs_f64() ); if let Err(e) = r { @@ -1066,7 +1074,9 @@ async fn liveness_compaction_after_lease_holder_dies() -> Result<()> { ); tokio::time::sleep(Duration::from_millis(200)).await; } - other => bail!("unexpected {other:?}"), + other @ walgit_server::ops::CompactOutcome::NotTriggered { .. } => { + bail!("unexpected {other:?}") + } } } eprintln!( @@ -1151,13 +1161,13 @@ async fn liveness_stale_instance_cannot_starve_the_core() -> Result<()> { link: stale_link, registry: stale_reg, cfg: Arc::new(sim_config(Path::new("/nonexistent"))), - _cache: tempfile::tempdir().unwrap(), + cache_dir: tempfile::tempdir().unwrap(), }; let mut n = 0u64; loop { let _ = stale_p.push_once(&inst, &id, Duration::from_secs(2)).await; n += 1; - if n % 10 == 0 { + if n.is_multiple_of(10) { tracing::info!( "stale pusher: {n} attempts, last: {:?}", stale_p.errors.last() @@ -1436,7 +1446,7 @@ async fn liveness_black_holed_instance_is_invisible_to_the_core() -> Result<()> link, registry: reg, cfg: Arc::new(sim_config(Path::new("/nonexistent"))), - _cache: tempfile::tempdir().unwrap(), + cache_dir: tempfile::tempdir().unwrap(), }; for _ in 0..5 { let _ = p1.push_once(&inst, &id, Duration::from_secs(30)).await; @@ -1519,7 +1529,7 @@ async fn liveness_frozen_task_owner_does_not_wedge_readiness() -> Result<()> { Ok(()) } -/// A request ReadGuard is the pin that promises packs remain on disk. Even a +/// A request `ReadGuard` is the pin that promises packs remain on disk. Even a /// leaked guard must make eviction skip the repo; after it drops, eviction may /// reclaim the cache. #[tokio::test] @@ -1533,7 +1543,7 @@ async fn liveness_leaked_read_guard_pins_cache_until_drop() -> Result<()> { let guard = h.sync_full().await?; let path = h.local().path().to_path_buf(); - let report = c.instances[pinned].registry.evict_idle().await?; + let report = c.instances[pinned].registry.evict_idle()?; ensure!( report.evicted == 0, "evicted a repo under an active ReadGuard" @@ -1541,7 +1551,7 @@ async fn liveness_leaked_read_guard_pins_cache_until_drop() -> Result<()> { ensure!(path.exists(), "deleted a pinned repo directory"); drop(guard); - let report = c.instances[pinned].registry.evict_idle().await?; + let report = c.instances[pinned].registry.evict_idle()?; ensure!( report.evicted == 1, "repo was not evictable after guard drop" @@ -1638,7 +1648,7 @@ async fn liveness_bundle_build_after_lease_holder_dies() -> Result<()> { } /// Exact healthy-link request counts defend the critical-path budgets in -/// docs/ROUNDTRIPS.md. MemoryStore has no retries, so deltas are deterministic: +/// docs/ROUNDTRIPS.md. `MemoryStore` has no retries, so deltas are deterministic: /// push = one freshness GET + pack/idx/log PUTs + manifest CAS; warm refs = one /// conditional GET; cold refs = the open's manifest GET + one log tail GET. #[tokio::test] @@ -2194,7 +2204,7 @@ async fn full_rebuild_leaves_exactly_one_base_even_with_a_retained_pack() -> Res cfg.git.history_pack = true; }); let mut p = Pusher::new(0); - let (base1, _hist1) = seed_base_and_history(&c, i, &mut p, 2).await?; + let (_base1, _hist1) = seed_base_and_history(&c, i, &mut p, 2).await?; let h = c.instances[i].open(&c.id).await?; drop(h.sync_full().await?); // Simulate git retaining the old base (a `.keep` git would honour — as a kept pack it is not @@ -2265,7 +2275,6 @@ async fn full_rebuild_leaves_exactly_one_base_even_with_a_retained_pack() -> Res "rebuild superseded {superseded} of {} live packs", before.len() ); - ensure!(base1 != gix_hash::ObjectId::from_hex(fulls[0].checksum.as_bytes())? || true); check_truth(&c, std::slice::from_ref(&p)).await?; Ok(()) } @@ -2282,7 +2291,7 @@ async fn rebuild_attempt( let lines: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); let h = match c.instances[i].open(&c.id).await { Ok(h) => h, - Err(e) => return (Err(e.into()), Vec::new()), + Err(e) => return (Err(e), Vec::new()), }; let out = walgit_server::ops::compact_repo( &h, @@ -2471,8 +2480,8 @@ async fn base_rebuild_resumes_after_a_kill_between_any_two_phases() -> Result<() /// download and caches have something to evict). fn push_blobby(p: &mut Pusher, kb: usize, rng: &mut Lcg) -> String { let mut buf = vec![0u8; kb * 1024]; - for b in buf.iter_mut() { - *b = rng.next() as u8; + for b in &mut buf { + *b = u8::try_from(rng.next() & 0xFF).unwrap_or(0); } std::fs::write(p.work.path().join(format!("blob-{}.bin", p.n + 1)), &buf).unwrap(); p.work.commit(p.n + 1, &format!("p{}", p.idx)) @@ -2541,15 +2550,15 @@ async fn run_task_ownership(seed: u64) -> Result<()> { let repo = c.id.to_string(); // K concurrent object-level syncs; one random caller is aborted after a random delay. - let k = 4 + rng.below(4) as usize; + let k = 4 + usize::try_from(rng.below(4)).unwrap_or(usize::MAX); let mut joins = Vec::new(); for _ in 0..k { let h = h.clone(); joins.push(tokio::spawn(async move { - h.sync().await.map(|g| drop(g)).map_err(|e| e.to_string()) + h.sync().await.map(drop).map_err(|e| e.to_string()) })); } - let victim = rng.below(k as u64) as usize; + let victim = usize::try_from(rng.below(k as u64)).unwrap_or(usize::MAX); let abort_after = Duration::from_millis(rng.below(40)); // Watch the task registry while they run: at most one materialize task at a time. let watcher = { @@ -2629,7 +2638,8 @@ async fn run_task_ownership(seed: u64) -> Result<()> { "late joiner did not see the outcome: {outcome:?}" ); // Downloads: every attempt downloads each pack at most once (+ idx); no N-fold traffic. - let ops = c.instances[j].link.stats().ops.load(Ordering::Relaxed) as usize; + let ops = usize::try_from(c.instances[j].link.stats().ops.load(Ordering::Relaxed)) + .unwrap_or(usize::MAX); let attempts = materializes.len(); let budget = attempts * (live_packs * 4 + 6) + k * 3 + 20; ensure!( @@ -2651,10 +2661,10 @@ async fn sim_task_ownership_under_concurrency_and_owner_crash() { /// Budget-mode cache pressure: four repositories of which the cache holds about two, a /// randomized interleaving of refs-level and object-level reads, one repository pinned by a -/// live ReadGuard throughout, plus one repository whose pack set exceeds `cache.max_bytes`. +/// live `ReadGuard` throughout, plus one repository whose pack set exceeds `cache.max_bytes`. /// Asserted after every step: the pinned repo is never evicted; the too-large repo is refused /// with `TooLarge` (never materialized, never the cause of evicting the others); the cache -/// stays ≤ max_bytes + one pack set; a refs-level read on a cold repo during eviction stays fast. +/// stays ≤ `max_bytes` + one pack set; a refs-level read on a cold repo during eviction stays fast. async fn run_cache_pressure(seed: u64) -> Result<()> { let mut rng = Lcg(seed ^ 0x5EED); let truth: DynStore = MemoryStore::shared(); @@ -2662,7 +2672,7 @@ async fn run_cache_pressure(seed: u64) -> Result<()> { let mut ids = Vec::new(); let mut pushers = Vec::new(); for r in 0..4u32 { - let id = RepoId::new("sim", &format!("cache{seed}-{r}"))?; + let id = RepoId::new("sim", format!("cache{seed}-{r}"))?; writer.registry.create(&id, ObjectFormat::Sha1).await?; let mut p = Pusher::new(r as usize); let new = push_blobby(&mut p, 96, &mut rng); @@ -2694,7 +2704,7 @@ async fn run_cache_pressure(seed: u64) -> Result<()> { pushers.push(p); } // The big one: ~5 × a small repo. - let big = RepoId::new("sim", &format!("cache{seed}-big"))?; + let big = RepoId::new("sim", format!("cache{seed}-big"))?; writer.registry.create(&big, ObjectFormat::Sha1).await?; { let mut p = Pusher::new(9); @@ -2752,7 +2762,7 @@ async fn run_cache_pressure(seed: u64) -> Result<()> { let mut refs_latencies = Vec::new(); let mut total_evicted = 0usize; for step in 0..30u64 { - let r = 1 + rng.below(3) as usize; // repos 1..3 + let r = 1 + usize::try_from(rng.below(3)).unwrap_or(usize::MAX); // repos 1..3 let id = &ids[r]; let h = front.registry.open(id).await?; match rng.below(3) { @@ -2783,7 +2793,7 @@ async fn run_cache_pressure(seed: u64) -> Result<()> { } // Eviction pass (the registry's periodic sweep) and the invariants. let before = Instant::now(); - let report = front.registry.evict_idle().await?; + let report = front.registry.evict_idle()?; total_evicted += report.evicted; let evict_took = before.elapsed(); ensure!( @@ -2820,7 +2830,7 @@ async fn run_cache_pressure(seed: u64) -> Result<()> { ); drop(guard); // Once unpinned, pressure may take it. - let _ = front.registry.evict_idle().await?; + let _ = front.registry.evict_idle()?; let worst = refs_latencies.iter().max().copied().unwrap_or_default(); eprintln!( "cache pressure seed {seed}: max_bytes {max_bytes}, small set {small_set}, worst refs read {worst:?}" diff --git a/crates/walgit-server/tests/static_http.rs b/crates/walgit-server/tests/static_http.rs index ff64fdf..8a3d583 100644 --- a/crates/walgit-server/tests/static_http.rs +++ b/crates/walgit-server/tests/static_http.rs @@ -1,7 +1,18 @@ //! HTTP contract of immutable store objects (LFS here; bundles share the same -//! `static_object` path) and of the embedded UI assets: strong ETags, 304, +//! `static_object` path) and of the embedded UI assets: strong `ETags`, 304, //! Range/If-Range, HEAD, Content-Length, precompressed encodings. +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing, + clippy::many_single_char_names +)] +// clippy.toml exempts #[test] functions from the panic-path lints, but not the plain +// helper functions beside them in the same file. A panic in a fixture builder is how +// that fixture reports it could not be built, exactly as in the tests it serves. + mod harness; use anyhow::Result; @@ -167,6 +178,10 @@ async fn lfs_object_full_http_contract() -> Result<()> { Ok(()) } +#[allow( + clippy::case_sensitive_file_extension_comparisons, + reason = "the build writes these asset names itself, always lowercase" +)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn ui_assets_etag_304_and_precompressed() -> Result<()> { let server = Server::start().await?; diff --git a/crates/walgit-server/tests/web_api.rs b/crates/walgit-server/tests/web_api.rs index 4b55379..470eb93 100644 --- a/crates/walgit-server/tests/web_api.rs +++ b/crates/walgit-server/tests/web_api.rs @@ -1,5 +1,16 @@ //! web/API.md §6 conformance for the read-only JSON API. +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing, + clippy::many_single_char_names +)] +// clippy.toml exempts #[test] functions from the panic-path lints, but not the plain +// helper functions beside them in the same file. A panic in a fixture builder is how +// that fixture reports it could not be built, exactly as in the tests it serves. + mod harness; use harness::{Server, git_in}; @@ -21,7 +32,7 @@ async fn get( .headers() .get("content-type") .and_then(|v| v.to_str().ok()) - .map(|s| s.to_string()); + .map(ToString::to_string); let text = resp.text().await?; Ok((status, text, ct)) } @@ -181,7 +192,11 @@ async fn conformance( let r = json(server, "/o/r/api/resolve/v1.0").await?; assert_eq!(r["kind"], "tag"); assert_eq!(r["sha"], v1_peeled); - let r = json(server, &format!("/o/r/api/resolve/{}/src", &head[..8])).await?; + let r = json( + server, + &format!("/o/r/api/resolve/{}/src", head.get(..8).unwrap_or(head)), + ) + .await?; assert_eq!(r["kind"], "commit"); assert_eq!(r["sha"], head); assert_eq!(r["path"], "src"); @@ -338,9 +353,10 @@ async fn conformance( assert!(m["patch"].as_str().unwrap().contains("diff --git")); assert!(!m["patch"].as_str().unwrap().contains("diff --cc")); // short sha and 404 - let d = json(server, &format!("/o/r/api/commit/{}", &feature[..10])).await?; + let short = feature.get(..10).unwrap_or(feature); + let d = json(server, &format!("/o/r/api/commit/{short}")).await?; assert_eq!(d["commit"]["sha"], feature); - let (_, _, h) = get_h(server, &format!("/o/r/api/commit/{}", &feature[..10]), &[]).await?; + let (_, _, h) = get_h(server, &format!("/o/r/api/commit/{short}"), &[]).await?; assert_eq!(hdr(&h, "etag"), format!("\"{feature}\"")); let (_, _, h) = get_h(server, &format!("/o/r/api/commit/{feature}"), &[]).await?; assert!(hdr(&h, "cache-control").contains("immutable")); diff --git a/crates/walgit-server/tests/web_ui.rs b/crates/walgit-server/tests/web_ui.rs index 276484c..e9f1026 100644 --- a/crates/walgit-server/tests/web_ui.rs +++ b/crates/walgit-server/tests/web_ui.rs @@ -1,3 +1,14 @@ +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing, + clippy::many_single_char_names +)] +// clippy.toml exempts #[test] functions from the panic-path lints, but not the plain +// helper functions beside them in the same file. A panic in a fixture builder is how +// that fixture reports it could not be built, exactly as in the tests it serves. + mod harness; use anyhow::Result; @@ -42,6 +53,10 @@ async fn page_routes_serve_index_without_cache() -> Result<()> { Ok(()) } +#[allow( + clippy::case_sensitive_file_extension_comparisons, + reason = "the build writes these asset names itself, always lowercase" +)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn assets_have_content_type_and_immutable_cache() -> Result<()> { let server = Server::start().await?; diff --git a/crates/walgit-store/src/coord.rs b/crates/walgit-store/src/coord.rs index 365b8e9..cc88860 100644 --- a/crates/walgit-store/src/coord.rs +++ b/crates/walgit-store/src/coord.rs @@ -133,9 +133,8 @@ where T: prost::Message + Default, { match store.get_if_changed(key, known).await { - Err(StoreError::NotFound { .. }) => Ok(None), + Err(StoreError::NotFound { .. }) | Ok(None) => Ok(None), Err(e) => Err(CoordError::Store(e)), - Ok(None) => Ok(None), Ok(Some((meta, bytes))) => { let msg = T::decode(bytes)?; Ok(Some((meta, msg))) @@ -173,6 +172,10 @@ pub struct LeaseGuard { } impl LeaseGuard { + #[allow( + clippy::too_many_arguments, + reason = "constructor arguments; a builder here would add a layer without removing one" + )] fn new( store: DynStore, key: &str, @@ -226,9 +229,9 @@ impl LeaseGuard { .delete(&self.key, Some(self.version.clone())) .await { - Ok(()) - | Err(StoreError::PreconditionFailed { .. }) - | Err(StoreError::NotFound { .. }) => Ok(()), + Ok(()) | Err(StoreError::PreconditionFailed { .. } | StoreError::NotFound { .. }) => { + Ok(()) + } Err(e) => Err(CoordError::Store(e)), } } @@ -286,9 +289,11 @@ impl Drop for LeaseGuard { let key = self.key.clone(); let version = self.version.clone(); if let Ok(handle) = tokio::runtime::Handle::try_current() { - let _ = handle.spawn(async move { + // Detached on purpose: Drop cannot await, and a failed release is + // recovered by the lease expiring. + drop(handle.spawn(async move { let _ = store.delete(&key, Some(version)).await; - }); + })); } } } @@ -327,8 +332,7 @@ pub async fn try_acquire( let expires_at = existing .expires_at .as_ref() - .map(time::to_system) - .unwrap_or(UNIX_EPOCH); + .map_or(UNIX_EPOCH, time::to_system); if now >= expires_at + LEASE_SKEW_TOLERANCE { let epoch = existing.epoch + 1; let lease = make_lease(holder, purpose, now, ttl, epoch); @@ -422,9 +426,9 @@ mod tests { #[tokio::test] async fn cas_update_convergence_64_incrementers() { + const N: u32 = 64; let store = dyn_store(); let key = "counter.pb"; - const N: u32 = 64; let mut handles = Vec::new(); for i in 0..N { @@ -465,9 +469,9 @@ mod tests { #[tokio::test] async fn lease_exclusivity_32_concurrent() { + const N: u32 = 32; let store = dyn_store(); let key = "leases/excl.pb"; - const N: u32 = 32; let mut handles = Vec::new(); for i in 0..N { @@ -475,7 +479,7 @@ mod tests { let k = key.to_string(); handles.push(tokio::spawn(async move { let holder = format!("h{i}"); - try_acquire(s, &k, &holder, "test", Duration::from_secs(60)).await + try_acquire(s, &k, &holder, "test", Duration::from_mins(1)).await })); } let mut successes = 0; @@ -614,7 +618,7 @@ mod tests { let store = dyn_store(); let key = "leases/timeout.pb"; - let _g1 = try_acquire(store.clone(), key, "h1", "test", Duration::from_secs(60)) + let _g1 = try_acquire(store.clone(), key, "h1", "test", Duration::from_mins(1)) .await .unwrap() .unwrap(); diff --git a/crates/walgit-store/src/fault.rs b/crates/walgit-store/src/fault.rs index ac1c988..8359793 100644 --- a/crates/walgit-store/src/fault.rs +++ b/crates/walgit-store/src/fault.rs @@ -108,8 +108,9 @@ impl FaultPlan { ..Default::default() } } + #[must_use] pub fn with_only(mut self, keys: &[&str]) -> Self { - self.only_keys = Some(keys.iter().map(|s| s.to_string()).collect()); + self.only_keys = Some(keys.iter().map(ToString::to_string).collect()); self } } @@ -257,6 +258,10 @@ impl FaultStore { /// Roll the dice for one op. `mutation`: put/delete/compose; `conditional`: /// CAS put/delete or if-none-match get; `body_len`: for truncation. + #[allow( + clippy::panic, + reason = "injecting a crash is what this wrapper is for" + )] async fn decide( &self, op: &str, @@ -300,7 +305,7 @@ impl FaultStore { return Decision::Denied; } if let Some((lo, hi)) = plan.delay { - let span = hi.saturating_sub(lo).as_micros() as u64; + let span = u64::try_from(hi.saturating_sub(lo).as_micros()).unwrap_or(u64::MAX); let extra = self.rng.lock().below(span + 1); tokio::time::sleep(lo + Duration::from_micros(extra)).await; } @@ -311,7 +316,7 @@ impl FaultStore { let mut r = self.rng.lock(); ( [r.f64(), r.f64(), r.f64(), r.f64(), r.f64(), r.f64()], - r.below(1 << 20) as usize, + usize::try_from(r.below(1 << 20)).unwrap_or(usize::MAX), ) }; let d = if roll[0] < plan.p_hang { @@ -543,6 +548,40 @@ pub async fn truth_bytes(store: &DynStore, key: &str) -> Result> { Ok(store.get_bytes(key).await?.map(|(_, b)| b)) } +impl FaultStore { + async fn get_inner(&self, key: &str, opts: GetOptions, conditional: bool) -> Result { + match self.decide("get", key, false, conditional, true).await { + Decision::Hang => hang_forever().await, + Decision::ErrBefore => Err(self.retryable("get", key, "before")), + Decision::Denied => Err(StoreError::NotFound { key: key.into() }), + Decision::Stale => Ok(GetResult::NotModified { + version: opts + .if_none_match + .clone() + .unwrap_or_else(|| Version::new("")), + }), + Decision::Truncate(at) => match self.inner.get(key, opts).await? { + GetResult::Object { meta, body } => { + let size = usize::try_from(meta.size).unwrap_or(usize::MAX); + let at = if size == 0 { 0 } else { at % size }; + let msg = format!( + "fault-store[{}]: injected truncation of {key} at {at}/{size}", + self.name + ); + Ok(GetResult::Object { + meta, + body: truncate_stream(body, at, msg), + }) + } + r @ GetResult::NotModified { .. } => Ok(r), + }, + Decision::Proceed | Decision::ErrAfter | Decision::CasFail => { + self.inner.get(key, opts).await + } + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -611,34 +650,3 @@ mod tests { assert!(r.is_err()); } } - -impl FaultStore { - async fn get_inner(&self, key: &str, opts: GetOptions, conditional: bool) -> Result { - match self.decide("get", key, false, conditional, true).await { - Decision::Hang => hang_forever().await, - Decision::ErrBefore => Err(self.retryable("get", key, "before")), - Decision::Denied => Err(StoreError::NotFound { key: key.into() }), - Decision::Stale => Ok(GetResult::NotModified { - version: opts.if_none_match.clone().unwrap(), - }), - Decision::Truncate(at) => match self.inner.get(key, opts).await? { - GetResult::Object { meta, body } => { - let size = meta.size as usize; - let at = if size == 0 { 0 } else { at % size }; - let msg = format!( - "fault-store[{}]: injected truncation of {key} at {at}/{size}", - self.name - ); - Ok(GetResult::Object { - meta, - body: truncate_stream(body, at, msg), - }) - } - r => Ok(r), - }, - Decision::Proceed | Decision::ErrAfter | Decision::CasFail => { - self.inner.get(key, opts).await - } - } - } -} diff --git a/crates/walgit-store/src/gcs.rs b/crates/walgit-store/src/gcs.rs index 071b63c..dcb34fa 100644 --- a/crates/walgit-store/src/gcs.rs +++ b/crates/walgit-store/src/gcs.rs @@ -9,6 +9,7 @@ use async_trait::async_trait; use bytes::Bytes; use futures::StreamExt; +use std::fmt::Write as _; use google_cloud_auth::credentials::Builder as AuthBuilder; use google_cloud_gax::error::rpc::Code; @@ -39,9 +40,9 @@ const LIST_PAGE_SIZE: i32 = 1000; /// Mid-stream resumes per bulk read before the error is surfaced. const BULK_RESUME_ATTEMPTS: u32 = 5; const META_DEADLINE: std::time::Duration = std::time::Duration::from_secs(10); -const READ_OPEN_DEADLINE: std::time::Duration = std::time::Duration::from_secs(60); +const READ_OPEN_DEADLINE: std::time::Duration = std::time::Duration::from_mins(1); /// Per chunk of a streaming body read (not the whole stream). -const READ_CHUNK_DEADLINE: std::time::Duration = std::time::Duration::from_secs(60); +const READ_CHUNK_DEADLINE: std::time::Duration = std::time::Duration::from_mins(1); const PUT_MIN_DEADLINE: std::time::Duration = std::time::Duration::from_secs(30); /// Uploads get this many bytes per second on top of `PUT_MIN_DEADLINE` (1 MiB/s floor). const PUT_BYTES_PER_SEC: u64 = 1024 * 1024; @@ -56,7 +57,7 @@ fn deadline_error(op: &str, key: &str, deadline: std::time::Duration) -> StoreEr tracing::warn!( op, key, - deadline_ms = deadline.as_millis() as u64, + deadline_ms = u64::try_from(deadline.as_millis()).unwrap_or(u64::MAX), "gcs call exceeded deadline" ); StoreError::retryable(anyhow::anyhow!( @@ -65,7 +66,7 @@ fn deadline_error(op: &str, key: &str, deadline: std::time::Duration) -> StoreEr } /// Run a GCS call under `deadline`; the client error keeps its meaning through -/// `map_error` (NotFound / PreconditionFailed / NotModified), a timeout becomes +/// `map_error` (`NotFound` / `PreconditionFailed` / `NotModified`), a timeout becomes /// [`deadline_error`]. `retries` extra attempts are made only when the deadline /// fired (the call is idempotent for every caller that passes > 0). async fn call( @@ -92,16 +93,17 @@ where op, key, attempt, - deadline_ms = deadline.as_millis() as u64, + deadline_ms = u64::try_from(deadline.as_millis()).unwrap_or(u64::MAX), "gcs call exceeded deadline, retrying" ); attempt += 1; let jitter = 100 - + (std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.subsec_nanos()) - .unwrap_or(0) - % 400) as u64; + + u64::from( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.subsec_nanos()) + % 400, + ); tokio::time::sleep(std::time::Duration::from_millis(jitter)).await; } } @@ -169,6 +171,7 @@ impl GcsStore { /// data (`Storage`) and control (`StorageControl`) clients, allowing /// emulator use. /// Set `telemetry.lock_wait_warn` for the bulk-permit WARN line (default 1 s). + #[must_use] pub fn with_permit_wait_warn(mut self, d: std::time::Duration) -> Self { self.permit_wait_warn = d; self @@ -235,6 +238,10 @@ impl GcsStore { }) } + #[allow( + clippy::case_sensitive_file_extension_comparisons, + reason = "the key space is ours; these suffixes are written by this crate, always lowercase" + )] /// Bulk keys: pack data and side-files, bundles, LFS (everything that is /// large or read by range); the rest is control plane. fn is_bulk_key(key: &str) -> bool { @@ -288,7 +295,10 @@ impl GcsStore { let permit = self.bulk_permits.clone().acquire_owned().await.ok(); let queued = t.elapsed(); if queued.as_millis() > 0 { - tracing::Span::current().record("queued_ms", queued.as_millis() as u64); + tracing::Span::current().record( + "queued_ms", + u64::try_from(queued.as_millis()).unwrap_or(u64::MAX), + ); } metrics::histogram!("walgit_store_bulk_queue_seconds").record(queued.as_secs_f64()); if queued > std::time::Duration::ZERO { @@ -299,13 +309,13 @@ impl GcsStore { tracing::warn!( lock = "gcs_bulk_permit", key, - wait_ms = queued.as_millis() as u64, + wait_ms = u64::try_from(queued.as_millis()).unwrap_or(u64::MAX), "lock wait" ); } metrics::gauge!("walgit_store_bulk_inflight") .set((self.bulk_permits_total - self.bulk_permits.available_permits()) as f64); - (&self.bulk[i], permit) + (self.bulk.get(i).unwrap_or(&self.storage), permit) } else { (&self.storage, None) } @@ -314,7 +324,7 @@ impl GcsStore { fn meta_from_object(obj: &google_cloud_storage::model::Object) -> ObjectMeta { ObjectMeta { key: obj.name.clone(), - size: obj.size as u64, + size: u64::try_from(obj.size).unwrap_or(0), version: gen_version(obj.generation), } } @@ -364,16 +374,16 @@ impl BulkHttp { range: Option>, if_generation_match: Option, ) -> Result<(u64, Option, ByteStream)> { - let (size, generation, first) = self.open(key, range.clone(), if_generation_match).await?; - let end = range.as_ref().map(|r| r.end).unwrap_or(size); - let start = range.as_ref().map(|r| r.start).unwrap_or(0); - let this = self.clone(); - let key_owned = key.to_owned(); struct St { inner: ByteStream, pos: u64, attempts: u32, } + let (size, generation, first) = self.open(key, range.clone(), if_generation_match).await?; + let end = range.as_ref().map_or(size, |r| r.end); + let start = range.as_ref().map_or(0, |r| r.start); + let this = self.clone(); + let key_owned = key.to_owned(); let st = St { inner: first, pos: start, @@ -446,7 +456,7 @@ impl BulkHttp { pub(crate) fn for_tests(endpoint: String, bucket: String) -> Self { BulkHttp { clients: vec![reqwest::Client::new()], - next: Default::default(), + next: std::sync::Arc::default(), creds: None, bucket, permits: std::sync::Arc::new(tokio::sync::Semaphore::new(8)), @@ -489,7 +499,11 @@ impl BulkHttp { .map(|g| format!("&ifGenerationMatch={g}")) .unwrap_or_default() ); - let mut req = self.clients[i].get(&url).headers(headers); + let client = self + .clients + .get(i) + .ok_or_else(|| StoreError::other(anyhow::anyhow!("no bulk http client")))?; + let mut req = client.get(&url).headers(headers); if let Some(r) = &range { req = req.header( reqwest::header::RANGE, @@ -591,13 +605,12 @@ impl GcsStore { let mut builder = client.write_object(self.bucket_resource.clone(), key.to_owned(), b); builder = apply_put_opts(builder, &opts); - builder.send_unbuffered().await + Box::pin(builder.send_unbuffered()).await } PutBody::File(path) => { let small = tokio::fs::metadata(&path) .await - .map(|m| m.len() <= SINGLE_SHOT_PUT_LIMIT) - .unwrap_or(false); + .is_ok_and(|m| m.len() <= SINGLE_SHOT_PUT_LIMIT); if small { let bytes = tokio::fs::read(&path).await.map_err(StoreError::other)?; let (client, _permit) = self.data_client(key, false).await; @@ -607,7 +620,7 @@ impl GcsStore { Bytes::from(bytes), ); builder = apply_put_opts(builder, &opts); - builder.send_unbuffered().await + Box::pin(builder.send_unbuffered()).await } else { let stream = crate::util::file_stream(path, None, FILE_CHUNK_SIZE); let source = StoreStreamSource { @@ -617,16 +630,18 @@ impl GcsStore { let mut builder = client.write_object(self.bucket_resource.clone(), key.to_owned(), source); builder = apply_put_opts(builder, &opts); - builder.send_buffered().await + Box::pin(builder.send_buffered()).await } } PutBody::Stream { len, stream } if len <= SINGLE_SHOT_PUT_LIMIT => { - let bytes = crate::util::collect(stream, len as usize).await?; + let bytes = + crate::util::collect(stream, usize::try_from(len).unwrap_or(usize::MAX)) + .await?; let (client, _permit) = self.data_client(key, false).await; let mut builder = client.write_object(self.bucket_resource.clone(), key.to_owned(), bytes); builder = apply_put_opts(builder, &opts); - builder.send_unbuffered().await + Box::pin(builder.send_unbuffered()).await } PutBody::Stream { stream, .. } => { let source = StoreStreamSource { @@ -636,7 +651,7 @@ impl GcsStore { let mut builder = client.write_object(self.bucket_resource.clone(), key.to_owned(), source); builder = apply_put_opts(builder, &opts); - builder.send_buffered().await + Box::pin(builder.send_buffered()).await } }; @@ -662,40 +677,36 @@ impl ObjectStore for GcsStore { // match the current generation → the object is always "changed" // from the caller's perspective. Skip the precondition and return // the object directly. - match parse_generation(v) { - Some(generation) => { - let req = google_cloud_storage::model::GetObjectRequest::new() - .set_bucket(self.bucket_resource.clone()) - .set_object(key.to_owned()) - .set_if_generation_not_match(generation); - - let result = match call("get", key, META_DEADLINE, READ_RETRIES, || { - self.control.get_object().with_request(req.clone()).send() - }) - .await - { - Ok(obj) => { - let meta = Self::meta_from_object(&obj); - let body = self.read_object_body(key, opts.range.clone()).await?; - Ok(GetResult::Object { meta, body }) - } - Err(e) => { - if e.is_not_modified() { - Ok(GetResult::NotModified { - version: gen_version(generation), - }) - } else { - Err(e.into_store("get", key)) - } + if let Some(generation) = parse_generation(v) { + let req = google_cloud_storage::model::GetObjectRequest::new() + .set_bucket(self.bucket_resource.clone()) + .set_object(key.to_owned()) + .set_if_generation_not_match(generation); + + let result = match call("get", key, META_DEADLINE, READ_RETRIES, || { + self.control.get_object().with_request(req.clone()).send() + }) + .await + { + Ok(obj) => { + let meta = Self::meta_from_object(&obj); + let body = self.read_object_body(key, opts.range.clone()).await?; + Ok(GetResult::Object { meta, body }) + } + Err(e) => { + if e.is_not_modified() { + Ok(GetResult::NotModified { + version: gen_version(generation), + }) + } else { + Err(e.into_store("get", key)) } - }; - return result; - } - None => { - // Non-numeric version: can never match a GCS generation, - // so the object is always "changed" → fall through to read. - } + } + }; + return result; } + // Non-numeric version: can never match a GCS generation, + // so the object is always "changed" → fall through to read. } // Direct read (no if_none_match, or if_none_match with non-numeric @@ -718,9 +729,7 @@ impl ObjectStore for GcsStore { let meta = ObjectMeta { key: key.to_owned(), size, - version: generation - .map(gen_version) - .unwrap_or_else(|| Version::new("")), + version: generation.map_or_else(|| Version::new(""), gen_version), }; return Ok(GetResult::Object { meta, body }); } @@ -750,7 +759,7 @@ impl ObjectStore for GcsStore { let obj = resp.object(); let meta = ObjectMeta { key: key.to_owned(), - size: obj.size as u64, + size: u64::try_from(obj.size).unwrap_or(0), version: gen_version(obj.generation), }; @@ -778,11 +787,11 @@ impl ObjectStore for GcsStore { async fn put(&self, key: &str, body: PutBody, opts: PutOptions) -> Result { let size_hint = match &body { PutBody::Bytes(b) => b.len() as u64, - PutBody::File(p) => tokio::fs::metadata(p).await.map(|m| m.len()).unwrap_or(0), + PutBody::File(p) => tokio::fs::metadata(p).await.map_or(0, |m| m.len()), PutBody::Stream { len, .. } => *len, }; let deadline = put_deadline(size_hint); - match tokio::time::timeout(deadline, self.put_inner(key, body, opts)).await { + match tokio::time::timeout(deadline, Box::pin(self.put_inner(key, body, opts))).await { Ok(r) => r, Err(_) => Err(deadline_error("put", key, deadline)), } @@ -854,24 +863,20 @@ impl ObjectStore for GcsStore { .set_object(key.to_owned()); if let Some(v) = &if_version { - match parse_generation(v) { - Some(generation) => { - req = req.set_if_generation_match(generation); - } - None => { - // Non-numeric version can never match a GCS generation. - // If the object exists → PreconditionFailed; else → NotFound. - if let Some(current) = self.current_generation(key).await { - return Err(StoreError::PreconditionFailed { - key: key.to_owned(), - current: Some(current), - }); - } else { - return Err(StoreError::NotFound { - key: key.to_owned(), - }); - } + if let Some(generation) = parse_generation(v) { + req = req.set_if_generation_match(generation); + } else { + // Non-numeric version can never match a GCS generation. + // If the object exists → PreconditionFailed; else → NotFound. + if let Some(current) = self.current_generation(key).await { + return Err(StoreError::PreconditionFailed { + key: key.to_owned(), + current: Some(current), + }); } + return Err(StoreError::NotFound { + key: key.to_owned(), + }); } } @@ -906,7 +911,7 @@ impl ObjectStore for GcsStore { let control = self.control.clone(); let bucket_resource = self.bucket_resource.clone(); let prefix = prefix.to_owned(); - let start_after = start_after.map(|s| s.to_owned()); + let start_after = start_after.map(ToOwned::to_owned); tokio::spawn(async move { let mut page_token = String::new(); @@ -942,10 +947,10 @@ impl ObjectStore for GcsStore { }; for obj in &resp.objects { - if let Some(ref sa) = skip_key { - if obj.name == *sa { - continue; - } + if let Some(ref sa) = skip_key + && obj.name == *sa + { + continue; } if tx.send(Ok(Self::meta_from_object(obj))).await.is_err() { return; // consumer dropped @@ -1007,7 +1012,7 @@ impl ObjectStore for GcsStore { let authorization = headers .get(http::header::AUTHORIZATION) .and_then(|v| v.to_str().ok()) - .map(|s| s.to_string()); + .map(ToString::to_string); Some(crate::AccelTarget { url: format!( "https://storage.googleapis.com/{}/{}", @@ -1074,10 +1079,7 @@ fn unfold_response( let stream = futures::stream::unfold( (Some(resp), key, permit), |(mut resp, key, permit)| async move { - let r = match resp.as_mut() { - Some(r) => r, - None => return None, - }; + let r = resp.as_mut()?; match tokio::time::timeout(READ_CHUNK_DEADLINE, r.next()).await { Ok(Some(Ok(bytes))) => Some((Ok(bytes), (resp, key, permit))), Ok(Some(Err(e))) => Some((Err(StoreError::other(e)), (resp, key, permit))), @@ -1144,10 +1146,10 @@ where // ---- error mapping ---- fn is_not_found(e: &google_cloud_storage::Error) -> bool { - if let Some(status) = e.status() { - if status.code == Code::NotFound { - return true; - } + if let Some(status) = e.status() + && status.code == Code::NotFound + { + return true; } if let Some(code) = e.http_status_code() { return code == 404; @@ -1158,10 +1160,10 @@ fn is_not_found(e: &google_cloud_storage::Error) -> bool { /// 304 Not Modified: returned when `if_generation_not_match` fails /// (generation IS the same → object unchanged). fn is_not_modified(e: &google_cloud_storage::Error) -> bool { - if let Some(code) = e.http_status_code() { - if code == 304 { - return true; - } + if let Some(code) = e.http_status_code() + && code == 304 + { + return true; } if let Some(status) = e.status() { // GCS returns 304 as FailedPrecondition for if_generation_not_match. @@ -1171,10 +1173,10 @@ fn is_not_modified(e: &google_cloud_storage::Error) -> bool { } fn is_precondition_failed(e: &google_cloud_storage::Error) -> bool { - if let Some(status) = e.status() { - if status.code == Code::FailedPrecondition { - return true; - } + if let Some(status) = e.status() + && status.code == Code::FailedPrecondition + { + return true; } if let Some(code) = e.http_status_code() { // GCS JSON API sometimes returns 412 with Code::Unknown. @@ -1223,7 +1225,7 @@ mod tests { #[test] fn gen_version_formats_decimal() { - assert_eq!(gen_version(1234567890).as_str(), "1234567890"); + assert_eq!(gen_version(1_234_567_890).as_str(), "1234567890"); assert_eq!(gen_version(0).as_str(), "0"); assert_eq!(gen_version(-1).as_str(), "-1"); } @@ -1460,9 +1462,11 @@ fn urlencode(s: &str) -> String { for b in s.bytes() { match b { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { - out.push(b as char) + out.push(b as char); + } + _ => { + let _ = write!(out, "%{b:02X}"); } - _ => out.push_str(&format!("%{b:02X}")), } } out @@ -1534,14 +1538,13 @@ mod resume_tests { .and_then(|v| v.to_str().ok()) .and_then(|v| v.strip_prefix("bytes=")) .and_then(|v| v.split_once('-')) - .map(|(a, b)| { + .map_or((0, d.len()), |(a, b)| { (a.parse::().unwrap(), b.parse::().unwrap() + 1) - }) - .unwrap_or((0, d.len())); + }); let body: Vec = d[start..end].to_vec(); let cut = n < 2; let stream = futures::stream::iter( - body.chunks(100).map(|c| c.to_vec()).collect::>(), + body.chunks(100).map(<[u8]>::to_vec).collect::>(), ) .enumerate() .then(move |(i, c)| async move { diff --git a/crates/walgit-store/src/lib.rs b/crates/walgit-store/src/lib.rs index 05f1740..883d5fd 100644 --- a/crates/walgit-store/src/lib.rs +++ b/crates/walgit-store/src/lib.rs @@ -7,7 +7,7 @@ //! * conditional writes (`Create` = if-absent, `Update(v)` = CAS on version), //! * conditional deletes, range reads, streaming bodies, prefix listing. //! -//! [`Version`] is opaque to callers: GCS generation, S3/rustfs ETag, or a +//! [`Version`] is opaque to callers: GCS generation, S3/rustfs `ETag`, or a //! counter in [`memory::MemoryStore`]. Callers must never parse it. use std::{fmt, ops::Range, pin::Pin, sync::Arc}; @@ -29,7 +29,7 @@ pub mod util; pub type BoxStream<'a, T> = Pin + Send + 'a>>; pub type ByteStream = BoxStream<'static, Result>; -/// Opaque object version (GCS generation / ETag / counter). Compare only for equality. +/// Opaque object version (GCS generation / `ETag` / counter). Compare only for equality. #[derive(Clone, PartialEq, Eq, Hash)] pub struct Version(Arc); @@ -91,7 +91,8 @@ impl GetResult { match self { GetResult::NotModified { .. } => Ok(None), GetResult::Object { meta, body } => { - let b = util::collect(body, meta.size as usize).await?; + let b = + util::collect(body, usize::try_from(meta.size).unwrap_or(usize::MAX)).await?; Ok(Some((meta, b))) } } @@ -104,9 +105,10 @@ impl GetResult { } } -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, Default)] pub enum PutMode { /// Unconditional overwrite. + #[default] Overwrite, /// Only if the object does not exist (if-generation-match: 0 / If-None-Match: *). Create, @@ -145,11 +147,6 @@ pub struct PutOptions { /// set long cache headers. pub immutable: bool, } -impl Default for PutMode { - fn default() -> Self { - PutMode::Overwrite - } -} impl From for PutOptions { fn from(mode: PutMode) -> Self { PutOptions { @@ -370,9 +367,7 @@ impl ObjectStore for Prefixed { let instrument = !self.inner.is_prefixed(); let full_key = self.full(key); // No span at all for nested prefix layers (avoids duplicate lines). - let span = if !instrument { - tracing::Span::none() - } else { + let span = if instrument { tracing::info_span!( "store.get", backend = self.inner.backend(), @@ -382,6 +377,8 @@ impl ObjectStore for Prefixed { outcome = tracing::field::Empty, error = tracing::field::Empty, ) + } else { + tracing::Span::none() }; let result = if instrument { self.inner @@ -425,9 +422,7 @@ impl ObjectStore for Prefixed { let instrument = !self.inner.is_prefixed(); let full_key = self.full(key); // No span at all for nested prefix layers (avoids duplicate lines). - let span = if !instrument { - tracing::Span::none() - } else { + let span = if instrument { tracing::info_span!( "store.head", backend = self.inner.backend(), @@ -437,6 +432,8 @@ impl ObjectStore for Prefixed { outcome = tracing::field::Empty, error = tracing::field::Empty, ) + } else { + tracing::Span::none() }; let result = if instrument { self.inner.head(&full_key).instrument(span.clone()).await @@ -470,14 +467,12 @@ impl ObjectStore for Prefixed { let bytes = match &body { PutBody::Bytes(b) => b.len() as u64, PutBody::Stream { len, .. } => *len, - PutBody::File(p) => std::fs::metadata(p).map(|m| m.len()).unwrap_or(0), + PutBody::File(p) => std::fs::metadata(p).map_or(0, |m| m.len()), }; let instrument = !self.inner.is_prefixed(); let full_key = self.full(key); // No span at all for nested prefix layers (avoids duplicate lines). - let span = if !instrument { - tracing::Span::none() - } else { + let span = if instrument { tracing::info_span!( "store.put", backend = self.inner.backend(), @@ -486,6 +481,8 @@ impl ObjectStore for Prefixed { outcome = tracing::field::Empty, error = tracing::field::Empty, ) + } else { + tracing::Span::none() }; let result = if instrument { self.inner @@ -518,9 +515,7 @@ impl ObjectStore for Prefixed { let instrument = !self.inner.is_prefixed(); let full_key = self.full(key); // No span at all for nested prefix layers (avoids duplicate lines). - let span = if !instrument { - tracing::Span::none() - } else { + let span = if instrument { tracing::info_span!( "store.delete", backend = self.inner.backend(), @@ -528,6 +523,8 @@ impl ObjectStore for Prefixed { outcome = tracing::field::Empty, error = tracing::field::Empty, ) + } else { + tracing::Span::none() }; let result = if instrument { self.inner @@ -561,6 +558,7 @@ impl ObjectStore for Prefixed { prefix: &str, start_after: Option<&str>, ) -> BoxStream<'static, Result> { + use futures::StreamExt; let full_prefix = self.full(prefix); let _span = (!self.inner.is_prefixed()).then(|| { tracing::debug_span!( @@ -570,7 +568,6 @@ impl ObjectStore for Prefixed { ) .entered() }); - use futures::StreamExt; let this = self.clone(); let start_after = start_after.map(|s| self.full(s)); Box::pin( @@ -637,7 +634,7 @@ pub async fn open_store(cfg: &walgit_config::Config) -> anyhow::Result walgit_config::StoreBackend::S3 => { #[cfg(feature = "s3")] { - Arc::new(s3::S3Store::new(&cfg.store).await?) + Arc::new(s3::S3Store::new(&cfg.store)?) } #[cfg(not(feature = "s3"))] { diff --git a/crates/walgit-store/src/memory.rs b/crates/walgit-store/src/memory.rs index 764a52e..234f5e4 100644 --- a/crates/walgit-store/src/memory.rs +++ b/crates/walgit-store/src/memory.rs @@ -63,7 +63,9 @@ impl MemoryStore { async fn body_bytes(body: PutBody) -> Result { Ok(match body { PutBody::Bytes(b) => b, - PutBody::Stream { len, stream } => util::collect(stream, len as usize).await?, + PutBody::Stream { len, stream } => { + util::collect(stream, usize::try_from(len).unwrap_or(usize::MAX)).await? + } PutBody::File(p) => Bytes::from(tokio::fs::read(&p).await.map_err(StoreError::other)?), }) } @@ -115,8 +117,8 @@ impl ObjectStore for MemoryStore { let size = data.len() as u64; let slice = match &opts.range { Some(r) => { - let start = r.start.min(size) as usize; - let end = r.end.min(size) as usize; + let start = usize::try_from(r.start.min(size)).unwrap_or(usize::MAX); + let end = usize::try_from(r.end.min(size)).unwrap_or(usize::MAX); if start > end { return Err(StoreError::InvalidArgument(format!( "bad range {r:?} for size {size}" @@ -151,8 +153,7 @@ impl ObjectStore for MemoryStore { let mut g = self.objects.lock(); let current = g.get(key).map(|(v, _)| v.clone()); match (&opts.mode, ¤t) { - (PutMode::Overwrite, _) => {} - (PutMode::Create, None) => {} + (PutMode::Overwrite, _) | (PutMode::Create, None) => {} (PutMode::Create, Some(v)) => { return Err(StoreError::PreconditionFailed { key: key.into(), @@ -245,8 +246,9 @@ impl ObjectStore for MemoryStore { .range(prefix.to_owned()..) .take_while(|(k, _)| k.starts_with(prefix)) .filter_map(|(k, _)| { - let rest = &k[prefix.len()..]; - rest.find('/').map(|i| format!("{prefix}{}/", &rest[..i])) + let rest = k.strip_prefix(prefix)?; + rest.split_once('/') + .map(|(seg, _)| format!("{prefix}{seg}/")) }) .collect(); out.dedup(); diff --git a/crates/walgit-store/src/s3.rs b/crates/walgit-store/src/s3.rs index 2877ae2..1beb6b9 100644 --- a/crates/walgit-store/src/s3.rs +++ b/crates/walgit-store/src/s3.rs @@ -7,29 +7,29 @@ //! //! ## Version tokens //! -//! S3 ETags are used as opaque `Version` strings. Quotes are stripped +//! S3 `ETags` are used as opaque `Version` strings. Quotes are stripped //! consistently on read and never stored. For non-multipart uploads the -//! ETag is the MD5 of the content; for multipart uploads it is a compound +//! `ETag` is the MD5 of the content; for multipart uploads it is a compound //! hash. Callers never parse the token — equality comparison suffices. //! //! ## Conditional PUT //! //! `PutMode::Create` → `If-None-Match: *` (object must not exist). -//! `PutMode::Update(v)` → `If-Match: ` (CAS on current ETag). +//! `PutMode::Update(v)` → `If-Match: ` (CAS on current `ETag`). //! On failure the SDK returns a `PreconditionFailed` service error; we fill //! `current` via a follow-up HEAD when the SDK doesn't include it. //! //! ## Conditional DELETE //! -//! S3 has no native conditional delete. We emulate via HEAD (read ETag) + +//! S3 has no native conditional delete. We emulate via HEAD (read `ETag`) + //! compare + DELETE, documenting the inherent check-then-act race: a //! concurrent writer could replace the object between HEAD and DELETE. //! Acceptable for walgit's lease-guarded semantics. //! //! ## Multipart upload //! -//! Objects above `cfg.multipart_threshold` use CreateMultipartUpload + -//! UploadPart + CompleteMultipartUpload. CreateMultipartUpload does NOT +//! Objects above `cfg.multipart_threshold` use `CreateMultipartUpload` + +//! `UploadPart` + `CompleteMultipartUpload`. `CreateMultipartUpload` does NOT //! support `If-None-Match`/`If-Match` in the S3 API, so multipart is only //! used for `PutMode::Overwrite`. For walgit's immutable pack objects //! (`PutMode::Create`) we use single-shot PUT when the object is large, @@ -73,7 +73,7 @@ impl S3Store { /// `cfg.s3.access_key_env` / `cfg.s3.secret_key_env` /// (defaults `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`), plus /// `AWS_SESSION_TOKEN` when present. - pub async fn new(cfg: &walgit_config::StoreConfig) -> anyhow::Result { + pub fn new(cfg: &walgit_config::StoreConfig) -> anyhow::Result { let access_key = std::env::var(&cfg.s3.access_key_env).map_err(|_| { anyhow::anyhow!("s3: env var {} not set (access key)", cfg.s3.access_key_env) })?; @@ -118,7 +118,7 @@ impl S3Store { // ---- GET via presigned URL + reqwest (true streaming) --------------- async fn presigned_get(&self, key: &str, opts: &GetOptions) -> Result { - let presigning = PresigningConfig::expires_in(Duration::from_secs(60)) + let presigning = PresigningConfig::expires_in(Duration::from_mins(1)) .map_err(|e| StoreError::other(anyhow::anyhow!("presigning config: {e}")))?; let mut builder = self.client.get_object().bucket(&self.bucket).key(key); @@ -190,7 +190,7 @@ impl S3Store { 404 => Err(StoreError::NotFound { key: key.into() }), 412 => Err(StoreError::PreconditionFailed { key: key.into(), - current: etag.map(|e| Version::new(e)), + current: etag.map(Version::new), }), s if s >= 500 || s == 429 => { Err(StoreError::Retryable(anyhow::anyhow!("s3 get status {s}"))) @@ -212,7 +212,8 @@ async fn body_to_s3(body: PutBody) -> Result<(S3ByteStream, u64)> { // Collect into Bytes: walgit's Stream bodies are small objects // (manifests, leases). Large packs use PutBody::File which // streams via ByteStream::read_from(). - let collected = util::collect(stream, len as usize).await?; + let collected = + util::collect(stream, usize::try_from(len).unwrap_or(usize::MAX)).await?; (S3ByteStream::from(collected), len) } PutBody::File(path) => { @@ -233,19 +234,19 @@ async fn body_to_s3(body: PutBody) -> Result<(S3ByteStream, u64)> { // ---- error classification ---------------------------------------------- -/// Extract the error code string from an SdkError's service error metadata. +/// Extract the error code string from an `SdkError`'s service error metadata. fn err_code(err: &aws_sdk_s3::error::SdkError) -> Option<&str> where E: aws_sdk_s3::error::ProvideErrorMetadata, { - err.as_service_error().map(|e| e.meta().code()).flatten() + err.as_service_error().and_then(|e| e.meta().code()) } fn classify_put_error( key: &str, - err: aws_sdk_s3::error::SdkError, + err: &aws_sdk_s3::error::SdkError, ) -> StoreError { - let code = err_code(&err).unwrap_or(""); + let code = err_code(err).unwrap_or(""); match code { "PreconditionFailed" | "ConditionalRequestConflict" => StoreError::PreconditionFailed { key: key.into(), @@ -256,7 +257,7 @@ fn classify_put_error( } fn classify_list_error( - err: aws_sdk_s3::error::SdkError, + err: &aws_sdk_s3::error::SdkError, ) -> StoreError { StoreError::Other(anyhow::anyhow!("s3 list error: {err}")) } @@ -284,7 +285,7 @@ impl ObjectStore for S3Store { match resp { Ok(out) => { let etag = out.e_tag().map(|s| s.trim_matches('"').to_owned()); - let size = out.content_length().unwrap_or(0) as u64; + let size = u64::try_from(out.content_length().unwrap_or(0)).unwrap_or(0); Ok(Some(ObjectMeta { key: key.into(), size, @@ -321,7 +322,7 @@ impl ObjectStore for S3Store { .bucket(&self.bucket) .key(key) .body(s3_body) - .content_length(len as i64); + .content_length(i64::try_from(len).unwrap_or(i64::MAX)); match &opts.mode { PutMode::Overwrite => {} @@ -348,12 +349,12 @@ impl ObjectStore for S3Store { }) } Err(e) => { - let mut err = classify_put_error(key, e); + let mut err = classify_put_error(key, &e); // Fill `current` via HEAD if we got a PreconditionFailed. - if let StoreError::PreconditionFailed { current: c, .. } = &mut err { - if c.is_none() { - *c = self.head(key).await.ok().flatten().map(|m| m.version); - } + if let StoreError::PreconditionFailed { current: c, .. } = &mut err + && c.is_none() + { + *c = self.head(key).await.ok().flatten().map(|m| m.version); } Err(err) } @@ -415,7 +416,7 @@ impl ObjectStore for S3Store { let client = self.client.clone(); let bucket = self.bucket.clone(); let prefix = prefix.to_owned(); - let start_after = start_after.map(|s| s.to_owned()); + let start_after = start_after.map(ToOwned::to_owned); Box::pin(futures::stream::unfold( ListState { @@ -461,7 +462,7 @@ impl ObjectStore for S3Store { let etag = obj.e_tag().map(|s| s.trim_matches('"').to_owned()); Ok(ObjectMeta { key: obj.key().unwrap_or("").to_owned(), - size: obj.size().unwrap_or(0) as u64, + size: u64::try_from(obj.size().unwrap_or(0)).unwrap_or(0), version: Version::new(etag.as_deref().unwrap_or("")), }) }) @@ -470,14 +471,14 @@ impl ObjectStore for S3Store { state.continuation_token = resp .is_truncated() .unwrap_or(false) - .then(|| resp.next_continuation_token().map(|s| s.to_owned())) + .then(|| resp.next_continuation_token().map(ToOwned::to_owned)) .flatten(); state.buffer = items.into_iter(); let item = state.buffer.next(); item.map(|i| (i, state)) } - Err(err) => Some((Err(classify_list_error(err)), state)), + Err(err) => Some((Err(classify_list_error(&err)), state)), } }, )) @@ -497,7 +498,7 @@ impl ObjectStore for S3Store { if let Some(ct) = &continuation_token { builder = builder.continuation_token(ct); } - let resp = builder.send().await.map_err(classify_list_error)?; + let resp = builder.send().await.map_err(|e| classify_list_error(&e))?; out.extend( resp.common_prefixes() .iter() @@ -506,7 +507,7 @@ impl ObjectStore for S3Store { continuation_token = resp .is_truncated() .unwrap_or(false) - .then(|| resp.next_continuation_token().map(|s| s.to_owned())) + .then(|| resp.next_continuation_token().map(ToOwned::to_owned)) .flatten(); if continuation_token.is_none() { break; @@ -520,7 +521,7 @@ impl ObjectStore for S3Store { /// A presigned GET (1 h): the edge needs no credentials and `Range` stays free (unsigned). async fn accel_target(&self, key: &str) -> Option { let url = self - .signed_get_url(key, Duration::from_secs(3600)) + .signed_get_url(key, Duration::from_hours(1)) .await .ok() .flatten()?; @@ -560,16 +561,18 @@ impl ObjectStore for S3Store { current: None, }); } - // Sizes first: the layout of parts depends on them. - let mut sizes = Vec::with_capacity(sources.len()); + // Sizes first: the layout of parts depends on them. Each span is the + // source's [start, start + size) window in the virtual concatenation. + let mut spans: Vec<(u64, u64, &str)> = Vec::with_capacity(sources.len()); + let mut total: u64 = 0; for src in sources { let m = self .head(src) .await? .ok_or_else(|| StoreError::NotFound { key: src.clone() })?; - sizes.push(m.size); + spans.push((total, m.size, src.as_str())); + total += m.size; } - let total: u64 = sizes.iter().sum(); // The virtual concatenation, cut into parts: a part is [start, end) of the whole. // Runs that lie inside one source and are >= MIN_PART become copies; everything else // (a small source, the tail that pads it to MIN_PART) is read and uploaded. @@ -597,20 +600,22 @@ impl ObjectStore for S3Store { let mut parts: Vec = Vec::new(); let mut part_number = 1i32; let mut pos: u64 = 0; // absolute offset into the concatenation - let offset_of = |i: usize| -> u64 { sizes[..i].iter().sum() }; + let locate = |at: u64| -> Option<(u64, u64, &str)> { + spans.iter().copied().find(|&(s, sz, _)| at < s + sz) + }; let result: Result<()> = async { while pos < total { // Which source does `pos` fall in, and how far does it run? - let i = (0..sources.len()) - .find(|&i| pos < offset_of(i) + sizes[i]) - .unwrap(); - let src_end = offset_of(i) + sizes[i]; + let (src_start, src_size, src_key) = locate(pos).ok_or_else(|| { + StoreError::other(anyhow::anyhow!("compose offset {pos} is past the sources")) + })?; + let src_end = src_start + src_size; let run = src_end - pos; let last_part = src_end == total; if run >= MIN_PART || last_part { // Copy a range of this one source. let len = run.min(COPY_PART); - let from = pos - offset_of(i); + let from = pos - src_start; let part = self .client .upload_part_copy() @@ -621,7 +626,7 @@ impl ObjectStore for S3Store { .copy_source(format!( "{}/{}", self.bucket, - crate::util::encode_path(&sources[i]) + crate::util::encode_path(src_key) )) .copy_source_range(format!("bytes={from}-{}", from + len - 1)) .send() @@ -644,17 +649,19 @@ impl ObjectStore for S3Store { } else { // Too small to copy on its own: read MIN_PART bytes across source boundaries. let want = MIN_PART.min(total - pos); - let mut buf = Vec::with_capacity(want as usize); + let mut buf = Vec::with_capacity(usize::try_from(want).unwrap_or(usize::MAX)); let mut p = pos; while (buf.len() as u64) < want { - let j = (0..sources.len()) - .find(|&j| p < offset_of(j) + sizes[j]) - .unwrap(); - let from = p - offset_of(j); - let take = (sizes[j] - from).min(want - buf.len() as u64); + let (j_start, j_size, j_key) = locate(p).ok_or_else(|| { + StoreError::other(anyhow::anyhow!( + "compose offset {p} is past the sources" + )) + })?; + let from = p - j_start; + let take = (j_size - from).min(want - buf.len() as u64); let (_, bytes) = self .get( - &sources[j], + j_key, GetOptions { range: Some(from..from + take), ..GetOptions::default() @@ -664,7 +671,7 @@ impl ObjectStore for S3Store { .bytes() .await? .ok_or_else(|| StoreError::NotFound { - key: sources[j].clone(), + key: j_key.to_owned(), })?; buf.extend_from_slice(&bytes); p += take; @@ -678,7 +685,7 @@ impl ObjectStore for S3Store { .upload_id(&upload_id) .part_number(part_number) .body(S3ByteStream::from(Bytes::from(buf))) - .content_length(len as i64) + .content_length(i64::try_from(len).unwrap_or(i64::MAX)) .send() .await .map_err(|e| StoreError::Other(anyhow::anyhow!("s3 upload part: {e}")))?; @@ -764,6 +771,7 @@ impl S3Store { len: u64, opts: &PutOptions, ) -> Result { + use tokio::io::AsyncReadExt; let mut create = self .client .create_multipart_upload() @@ -791,17 +799,16 @@ impl S3Store { let mut uploaded_parts: Vec = Vec::new(); let mut remaining = len; - use tokio::io::AsyncReadExt; let mut reader = body.into_async_read(); while remaining > 0 { let this_part = part_size.min(remaining); - let to_read = this_part as usize; + let to_read = usize::try_from(this_part).unwrap_or(usize::MAX); let mut buf = vec![0u8; to_read]; let mut read_total = 0; - while read_total < to_read { - let n = match reader.read(&mut buf[read_total..]).await { + while let Some(dst) = buf.get_mut(read_total..).filter(|d| !d.is_empty()) { + let n = match reader.read(dst).await { Ok(n) => n, Err(e) => { let _ = self.abort_multipart(key, &upload_id).await; @@ -828,7 +835,7 @@ impl S3Store { .upload_id(&upload_id) .part_number(part_number) .body(S3ByteStream::from(Bytes::from(buf))) - .content_length(actual as i64) + .content_length(i64::try_from(actual).unwrap_or(i64::MAX)) .send() .await { diff --git a/crates/walgit-store/src/util.rs b/crates/walgit-store/src/util.rs index f4cc23b..2535709 100644 --- a/crates/walgit-store/src/util.rs +++ b/crates/walgit-store/src/util.rs @@ -1,5 +1,6 @@ use bytes::{Bytes, BytesMut}; use futures::StreamExt; +use std::fmt::Write as _; use crate::{ByteStream, Result, StoreError}; @@ -9,16 +10,15 @@ pub async fn collect(mut body: ByteStream, size_hint: usize) -> Result { let mut buf: Option = None; while let Some(chunk) = body.next().await { let chunk = chunk?; - match (&mut first, &mut buf) { - (None, None) => first = Some(chunk), - (Some(_), None) => { - let f = first.take().unwrap(); - let mut b = BytesMut::with_capacity(size_hint.max(f.len() + chunk.len())); - b.extend_from_slice(&f); - b.extend_from_slice(&chunk); - buf = Some(b); - } - (_, Some(b)) => b.extend_from_slice(&chunk), + if let Some(b) = &mut buf { + b.extend_from_slice(&chunk); + } else if let Some(f) = first.take() { + let mut b = BytesMut::with_capacity(size_hint.max(f.len() + chunk.len())); + b.extend_from_slice(&f); + b.extend_from_slice(&chunk); + buf = Some(b); + } else { + first = Some(chunk); } } Ok(match (first, buf) { @@ -39,11 +39,6 @@ pub fn file_stream( range: Option>, chunk: usize, ) -> ByteStream { - use tokio::io::{AsyncReadExt, AsyncSeekExt}; - return async_stream_file(path, range, chunk) - .map(|r| r.map_err(StoreError::other)) - .boxed(); - fn async_stream_file( path: std::path::PathBuf, range: Option>, @@ -63,10 +58,10 @@ pub fn file_stream( Err(e) => return Some((Err(e), State::Done)), }, }; - if start > 0 { - if let Err(e) = f.seek(std::io::SeekFrom::Start(start)).await { - return Some((Err(e), State::Done)); - } + if start > 0 + && let Err(e) = f.seek(std::io::SeekFrom::Start(start)).await + { + return Some((Err(e), State::Done)); } read_next(f, remaining, chunk).await } @@ -87,7 +82,7 @@ pub fn file_stream( if remaining == 0 { return None; } - let want = (chunk as u64).min(remaining) as usize; + let want = usize::try_from((chunk as u64).min(remaining)).unwrap_or(usize::MAX); let mut buf = BytesMut::with_capacity(want); // read_buf reads at most capacity; loop until we get `want` or EOF. while buf.len() < want { @@ -123,6 +118,10 @@ pub fn file_stream( }, Done, } + use tokio::io::{AsyncReadExt, AsyncSeekExt}; + async_stream_file(path, range, chunk) + .map(|r| r.map_err(StoreError::other)) + .boxed() } /// Exponential backoff with full jitter. `attempt` starts at 0. @@ -134,7 +133,7 @@ pub fn backoff( use rand::Rng; let exp = base.saturating_mul(1u32 << attempt.min(16)); let cap = exp.min(max); - let jitter = rand::rng().random_range(0..=cap.as_millis() as u64); + let jitter = rand::rng().random_range(0..=u64::try_from(cap.as_millis()).unwrap_or(u64::MAX)); std::time::Duration::from_millis(jitter) } @@ -268,9 +267,11 @@ pub fn encode_path(key: &str) -> String { for b in key.bytes() { match b { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'/' => { - out.push(b as char) + out.push(b as char); + } + _ => { + let _ = write!(out, "%{b:02X}"); } - _ => out.push_str(&format!("%{b:02X}")), } } out diff --git a/crates/walgit-store/tests/contract.rs b/crates/walgit-store/tests/contract.rs index 1db260e..2f27f6f 100644 --- a/crates/walgit-store/tests/contract.rs +++ b/crates/walgit-store/tests/contract.rs @@ -8,7 +8,18 @@ //! //! The suite is executed against `MemoryStore` always, and against `S3Store` //! when `WALGIT_TEST_S3_ENDPOINT` is set. `GcsStore` is tested when -//! `WALGIT_TEST_GCS_BUCKET` is set (StoreGcs adds that wrapper). +//! `WALGIT_TEST_GCS_BUCKET` is set (`StoreGcs` adds that wrapper). + +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing, + clippy::many_single_char_names +)] +// clippy.toml exempts #[test] functions from the panic-path lints, but not the plain +// helper functions beside them in the same file. A panic in a fixture builder is how +// that fixture reports it could not be built, exactly as in the tests it serves. use std::ops::Range; use std::sync::Arc; @@ -56,7 +67,7 @@ async fn test_compose(store: &DynStore, key: &str) { let header = Bytes::from_static(b"# v3 git bundle\n@object-format=sha1\n\n"); let mut body = vec![0u8; 6 * 1024 * 1024 + 12345]; for (i, b) in body.iter_mut().enumerate() { - *b = (i % 251) as u8; + *b = u8::try_from(i % 251).unwrap_or(0); } let body = Bytes::from(body); let h = format!("{key}.hdr"); @@ -117,13 +128,14 @@ async fn test_compose(store: &DynStore, key: &str) { // ---- helpers ----------------------------------------------------------- -/// Collect a GetResult body into Bytes, asserting it's an Object. +/// Collect a `GetResult` body into Bytes, asserting it's an Object. async fn collect_body(r: GetResult) -> (walgit_store::ObjectMeta, Bytes) { match r { GetResult::Object { meta, body } => { - let collected = walgit_store::util::collect(body, meta.size as usize) - .await - .expect("body collect"); + let collected = + walgit_store::util::collect(body, usize::try_from(meta.size).unwrap_or(usize::MAX)) + .await + .expect("body collect"); (meta, collected) } GetResult::NotModified { .. } => panic!("expected Object, got NotModified"), @@ -189,7 +201,7 @@ async fn test_put_create_wins_once(store: &DynStore, key: &str) { let _ = store.delete(key, None).await; } -/// Update CAS: winner updates, loser gets PreconditionFailed. +/// Update CAS: winner updates, loser gets `PreconditionFailed`. async fn test_update_cas(store: &DynStore, key: &str) { let _ = store.delete(key, None).await; @@ -266,7 +278,7 @@ async fn test_update_cas(store: &DynStore, key: &str) { let _ = store.delete(key, None).await; } -/// if_none_match: NotModified when unchanged, Object when changed. +/// `if_none_match`: `NotModified` when unchanged, Object when changed. async fn test_get_if_none_match(store: &DynStore, key: &str) { let _ = store.delete(key, None).await; @@ -306,7 +318,7 @@ async fn test_get_if_none_match(store: &DynStore, key: &str) { let _ = store.delete(key, None).await; } -/// if_match mismatch → PreconditionFailed. +/// `if_match` mismatch → `PreconditionFailed`. async fn test_get_if_match_mismatch(store: &DynStore, key: &str) { let _ = store.delete(key, None).await; @@ -463,7 +475,7 @@ async fn test_delete(store: &DynStore, key: &str) { ); } -/// list: ordering, start_after, prefix isolation. +/// list: ordering, `start_after`, prefix isolation. async fn test_list(store: &DynStore, base: &str) { // Clean up any previous data under base. let existing: Vec<_> = store.list(base, None).collect::>().await; @@ -555,6 +567,7 @@ async fn test_list(store: &DynStore, base: &str) { /// 8 MiB streamed put/get roundtrip with checksum. async fn test_large_streamed_roundtrip(store: &DynStore, key: &str) { + use sha1::{Digest, Sha1}; let _ = store.delete(key, None).await; // 8 MiB of pseudo-random but deterministic data. @@ -571,7 +584,6 @@ async fn test_large_streamed_roundtrip(store: &DynStore, key: &str) { let data = Bytes::from(data); // Checksum (SHA-1). - use sha1::{Digest, Sha1}; let mut hasher = Sha1::new(); hasher.update(&data); let expected_checksum = hasher.finalize(); @@ -614,8 +626,8 @@ async fn test_large_streamed_roundtrip(store: &DynStore, key: &str) { } /// Multipart path: put an object above the threshold, verify roundtrip. -/// For MemoryStore this exercises the same code path (no multipart). -/// For S3Store with a small threshold, this triggers multipart upload. +/// For `MemoryStore` this exercises the same code path (no multipart). +/// For `S3Store` with a small threshold, this triggers multipart upload. async fn test_multipart_path(store: &DynStore, key: &str) { let _ = store.delete(key, None).await; @@ -667,12 +679,9 @@ async fn memory_contract() { #[cfg(feature = "s3")] #[tokio::test] async fn s3_contract() { - let endpoint = match std::env::var("WALGIT_TEST_S3_ENDPOINT") { - Ok(v) => v, - Err(_) => { - eprintln!("skipping s3_contract: WALGIT_TEST_S3_ENDPOINT not set"); - return; - } + let Ok(endpoint) = std::env::var("WALGIT_TEST_S3_ENDPOINT") else { + eprintln!("skipping s3_contract: WALGIT_TEST_S3_ENDPOINT not set"); + return; }; let bucket = std::env::var("WALGIT_TEST_BUCKET").unwrap_or_else(|_| "walgit-test".into()); let _access_key = @@ -699,9 +708,7 @@ async fn s3_contract() { ..Default::default() }; - let store = walgit_store::s3::S3Store::new(&cfg) - .await - .expect("S3Store::new"); + let store = walgit_store::s3::S3Store::new(&cfg).expect("S3Store::new"); let store: DynStore = Arc::new(store); run_contract(store.clone(), &prefix).await; @@ -723,12 +730,9 @@ async fn s3_contract() { #[cfg(feature = "gcs")] #[tokio::test] async fn gcs_contract() { - let bucket = match std::env::var("WALGIT_TEST_GCS_BUCKET") { - Ok(v) => v, - Err(_) => { - eprintln!("skipping gcs_contract: WALGIT_TEST_GCS_BUCKET not set"); - return; - } + let Ok(bucket) = std::env::var("WALGIT_TEST_GCS_BUCKET") else { + eprintln!("skipping gcs_contract: WALGIT_TEST_GCS_BUCKET not set"); + return; }; // Install the rustls crypto provider (required for TLS with google-cloud-storage). @@ -776,6 +780,7 @@ async fn gcs_contract() { /// stay under 2 s. `WALGIT_TEST_GCS_BUCKET=walgit-store WALGIT_TEST_GCS_BIG_KEY=`. #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn gcs_control_plane_not_starved_by_bulk() { + const CHUNK: u64 = 32 * 1024 * 1024; let (Ok(bucket), Ok(big_key)) = ( std::env::var("WALGIT_TEST_GCS_BUCKET"), std::env::var("WALGIT_TEST_GCS_BIG_KEY"), @@ -820,13 +825,14 @@ async fn gcs_control_plane_not_starved_by_bulk() { .await; eprintln!("baseline probe {:?}", t.elapsed()); let total = (1u64 << 30).min(size); - const CHUNK: u64 = 32 * 1024 * 1024; let bulk = { let store = store.clone(); let big_key = big_key.clone(); tokio::spawn(async move { let t = std::time::Instant::now(); - let starts: Vec = (0..total).step_by(CHUNK as usize).collect(); + let starts: Vec = (0..total) + .step_by(usize::try_from(CHUNK).unwrap_or(usize::MAX)) + .collect(); let n = futures::stream::iter(starts) .map(|start| { let store = store.clone(); @@ -843,13 +849,14 @@ async fn gcs_control_plane_not_starved_by_bulk() { .await .unwrap(); match r { - GetResult::Object { body, .. } => { - walgit_store::util::collect(body, CHUNK as usize) - .await - .unwrap() - .len() - } - _ => 0, + GetResult::Object { body, .. } => walgit_store::util::collect( + body, + usize::try_from(CHUNK).unwrap_or(usize::MAX), + ) + .await + .unwrap() + .len(), + GetResult::NotModified { .. } => 0, } } }) diff --git a/crates/walgit-wal/src/checkpoint.rs b/crates/walgit-wal/src/checkpoint.rs index e1cfd4d..98fa3c6 100644 --- a/crates/walgit-wal/src/checkpoint.rs +++ b/crates/walgit-wal/src/checkpoint.rs @@ -43,7 +43,7 @@ pub fn checkpoint_due( if head == 0 { return None; } - let cp_seq = manifest.checkpoint.as_ref().map(|c| c.seq).unwrap_or(0); + let cp_seq = manifest.checkpoint.as_ref().map_or(0, |c| c.seq); if cp_seq >= head { return None; } @@ -73,43 +73,38 @@ pub fn checkpoint_due( Some(c) => c.created_at.as_ref().map(time::to_system), None => manifest.updated_at.as_ref().map(time::to_system), }; - if let Some(t) = since { - if std::time::SystemTime::now() + if let Some(t) = since + && std::time::SystemTime::now() .duration_since(t) .unwrap_or_default() >= cfg.checkpoint_interval - { - return Some(CheckpointTrigger::Age); - } + { + return Some(CheckpointTrigger::Age); } } None } /// Write a checkpoint at the current head: refs snapshot + pack set, then -/// CAS manifest (checkpoint=, min_seq=, log_segments trimmed). Idempotent. +/// CAS manifest (checkpoint=, `min_seq`=, `log_segments` trimmed). Idempotent. /// Needs only a **refs-level** sync (manifest + ref state): it works on an /// instance that could never hold the repo's packs. pub(crate) async fn write_checkpoint_impl(handle: &RepoHandle) -> Result { let trigger = checkpoint_due(&handle.manifest(), &handle.cfg.wal) - .map(|t| t.to_string()) - .unwrap_or_else(|| "manual".into()); + .map_or_else(|| "manual".into(), |t| t.to_string()); let span = tracing::info_span!("wal.checkpoint", repo = %handle.id, trigger = %trigger, seq = tracing::field::Empty, refs = tracing::field::Empty, folded = tracing::field::Empty, outcome = tracing::field::Empty); let t0 = std::time::Instant::now(); let r = write_checkpoint_inner(handle) .instrument(span.clone()) .await; - match &r { - Ok(cp) => { - span.record("seq", cp.seq); - span.record("outcome", "ok"); - metrics::histogram!("walgit_checkpoint_seconds").record(t0.elapsed().as_secs_f64()); - metrics::counter!("walgit_checkpoints_total", "outcome" => "ok").increment(1); - } - Err(_) => { - span.record("outcome", "error"); - metrics::counter!("walgit_checkpoints_total", "outcome" => "error").increment(1); - } + if let Ok(cp) = &r { + span.record("seq", cp.seq); + span.record("outcome", "ok"); + metrics::histogram!("walgit_checkpoint_seconds").record(t0.elapsed().as_secs_f64()); + metrics::counter!("walgit_checkpoints_total", "outcome" => "ok").increment(1); + } else { + span.record("outcome", "error"); + metrics::counter!("walgit_checkpoints_total", "outcome" => "error").increment(1); } r } @@ -123,17 +118,17 @@ async fn write_checkpoint_inner(handle: &RepoHandle) -> Result Result Result Result= current_manifest.head_seq { - return Ok(cp.clone()); - } + if let Some(ref cp) = current_manifest.checkpoint + && cp.seq >= current_manifest.head_seq + { + return Ok(cp.clone()); } let mut updated: Manifest = (*current_manifest).clone(); @@ -234,7 +229,7 @@ async fn write_checkpoint_inner(handle: &RepoHandle) -> Result seq updated.log_segments.retain(|s| s.last_seq > seq); updated.updated_at = Some(time::now()); - updated.writer = writer.to_string(); + updated.writer = writer.clone(); updated.revision += 1; let buf = updated.encode_to_vec(); @@ -275,7 +270,6 @@ async fn write_checkpoint_inner(handle: &RepoHandle) -> Result return Err(WalError::Store(e)), } diff --git a/crates/walgit-wal/src/handle.rs b/crates/walgit-wal/src/handle.rs index 2f6acbb..597aeb6 100644 --- a/crates/walgit-wal/src/handle.rs +++ b/crates/walgit-wal/src/handle.rs @@ -1,4 +1,4 @@ -//! RepoHandle: per-repository state, sync, publish, checkpoint. +//! `RepoHandle`: per-repository state, sync, publish, checkpoint. use std::collections::HashMap; use std::sync::{ @@ -128,6 +128,10 @@ impl ObjectAccess { } impl RepoHandle { + #[allow( + clippy::too_many_arguments, + reason = "constructor arguments; a builder here would add a layer without removing one" + )] pub(crate) fn new( id: RepoId, local: LocalRepo, @@ -266,10 +270,10 @@ impl RepoHandle { return Ok((guard, ObjectAccess::Local)); } // Remote: reuse the reader for this manifest revision, else (re)open. - if let Some(r) = self.remote.lock().clone() { - if r.revision == manifest.revision { - return Ok((guard, ObjectAccess::Remote(r))); - } + if let Some(r) = self.remote.lock().clone() + && r.revision == manifest.revision + { + return Ok((guard, ObjectAccess::Remote(r))); } let remote = self.open_remote(&manifest).await?; Ok((guard, ObjectAccess::Remote(remote))) @@ -322,7 +326,7 @@ impl RepoHandle { } Begin::AlreadyRunning(state) => { // Another request is opening it: wait for that task, then reuse. - let _ = state.wait_done(std::time::Duration::from_secs(600)).await; + let _ = state.wait_done(std::time::Duration::from_mins(10)).await; match state.outcome() { Some(Ok(_)) => {} Some(Err((_, m))) => { @@ -644,33 +648,30 @@ impl RepoHandle { // The whole materialization runs on the bulk runtime (own threads): // nothing in it can stall this runtime's request workers. let arc = self.self_arc.get().cloned(); - let res = match arc { - Some(arc) => { - let m = manifest.clone(); - let task_span = task.as_ref().map(|t| t.span()); - crate::sync::on_bulk_runtime(async move { - let work = async { - crate::sync::reconcile_packs(&arc, &m, level).await?; - arc.local.refresh_async().await?; - Ok::<(), WalError>(()) - }; - match task_span { - Some(sp) => work.instrument(sp).await, - None => work.await, - } - }) - .await - } - None => { - let res = async { - crate::sync::reconcile_packs(self, &manifest, level).await?; - self.local.refresh_async().await?; + let res = if let Some(arc) = arc { + let m = manifest.clone(); + let task_span = task.as_ref().map(super::tasks::TaskHandle::span); + crate::sync::on_bulk_runtime(async move { + let work = async { + crate::sync::reconcile_packs(&arc, &m, level).await?; + arc.local.refresh_async().await?; Ok::<(), WalError>(()) }; - match &task { - Some(t) => res.instrument(t.span()).await, - None => res.await, + match task_span { + Some(sp) => work.instrument(sp).await, + None => work.await, } + }) + .await + } else { + let res = async { + crate::sync::reconcile_packs(self, &manifest, level).await?; + self.local.refresh_async().await?; + Ok::<(), WalError>(()) + }; + match &task { + Some(t) => res.instrument(t.span()).await, + None => res.await, } }; *self.active_reporter.lock() = None; @@ -734,8 +735,10 @@ impl RepoHandle { .collect(); } let mount = self.mount_dir(); - if mount.is_none() && self.cfg.cache.store_mount.is_some() { - tracing::warn!(repo = %self.id, mount = %self.cfg.cache.store_mount.as_ref().unwrap().display(), "store mount configured but the repository directory is not visible in it (gcsfuse not up yet?): base packs served remotely until it is"); + if mount.is_none() + && let Some(store_mount) = self.cfg.cache.store_mount.as_ref() + { + tracing::warn!(repo = %self.id, mount = %store_mount.display(), "store mount configured but the repository directory is not visible in it (gcsfuse not up yet?): base packs served remotely until it is"); } manifest .packs @@ -775,10 +778,10 @@ impl RepoHandle { /// `remote-index` task while opening. pub async fn remote_reader(&self) -> Result, WalError> { let manifest = self.manifest(); - if let Some(r) = self.remote.lock().clone() { - if r.revision == manifest.revision { - return Ok(r); - } + if let Some(r) = self.remote.lock().clone() + && r.revision == manifest.revision + { + return Ok(r); } self.open_remote(&manifest).await } @@ -787,7 +790,7 @@ impl RepoHandle { /// by the caller. Packs are never touched here (see `sync_packs_phase`). async fn sync_locked_inner(&self, span: &tracing::Span) -> Result<(), WalError> { let known = self.manifest_version.lock().clone(); - let outcome = crate::sync::freshness_check(&self.store, &known).await?; + let outcome = crate::sync::freshness_check(&self.store, known.as_ref()).await?; match outcome { crate::sync::SyncOutcome::Unchanged => self.update_freshness(), crate::sync::SyncOutcome::Changed { @@ -854,16 +857,13 @@ impl RepoHandle { /// Any local pack that is a symlink into the store mount. fn has_linked_packs(&self) -> bool { - self.local - .packs() - .map(|ps| { - ps.iter() - .any(|p| self.local.pack_path(&p.checksum).is_symlink()) - }) - .unwrap_or(false) + self.local.packs().is_ok_and(|ps| { + ps.iter() + .any(|p| self.local.pack_path(&p.checksum).is_symlink()) + }) } - /// Internal serving sync (no read guard). Used by publish/checkpoint/read_log. + /// Internal serving sync (no read guard). Used by `publish/checkpoint/read_log`. pub(crate) async fn sync_impl(&self) -> Result<(), WalError> { self.sync_impl_level(SyncLevel::Serve).await } @@ -899,14 +899,11 @@ impl RepoHandle { .await; // Read manifest fresh - let (meta, manifest) = match crate::store_proto::get_message::( - &self.store, - walgit_proto::keys::MANIFEST, - ) - .await? - { - Some((m, manifest)) => (m, manifest), - None => return Err(WalError::NotFound), + let Some((meta, manifest)) = + crate::store_proto::get_message::(&self.store, walgit_proto::keys::MANIFEST) + .await? + else { + return Err(WalError::NotFound); }; // Reset state and re-materialize @@ -997,7 +994,7 @@ impl RepoHandle { response: tx, }; - let sender = self.get_or_init_publisher().await; + let sender = self.get_or_init_publisher(); if sender.send(request).is_err() { self.publish_waiters.fetch_sub(1, Ordering::Relaxed); return Err(WalError::Corrupt("publisher channel closed".into())); @@ -1039,16 +1036,16 @@ impl RepoHandle { /// (never a failure on a read path). pub fn effective_config(&self) -> Arc { let settings = self.settings(); - let rev = settings.as_ref().map(|s| s.revision).unwrap_or(0); + let rev = settings.as_ref().map_or(0, |s| s.revision); if rev == 0 { return self.cfg.clone(); } - if let Some((r, c)) = self.effective.lock().as_ref() { - if *r == rev { - return c.clone(); - } + if let Some((r, c)) = self.effective.lock().as_ref() + && *r == rev + { + return c.clone(); } - let toml = settings.as_ref().map(|s| s.toml.as_str()).unwrap_or(""); + let toml = settings.as_ref().map_or("", |s| s.toml.as_str()); let cfg = match self.cfg.with_settings(toml) { Ok(c) => Arc::new(c), Err(e) => { @@ -1223,6 +1220,8 @@ impl RepoHandle { /// Read the checkpoint object's times when the manifest ref has none /// (one 240-byte GET per checkpoint per process; no-op otherwise). pub(crate) async fn learn_checkpoint_times(&self) -> Result<(), WalError> { + use prost::Message; + use walgit_store::ObjectStoreExt; let m = self.manifest(); let Some(cp) = m.checkpoint.as_ref() else { return Ok(()); @@ -1232,8 +1231,6 @@ impl RepoHandle { { return Ok(()); } - use prost::Message; - use walgit_store::ObjectStoreExt; if let Some((_, bytes)) = self.store.get_bytes(&cp.key).await? { let cpo = walgit_proto::v1::Checkpoint::decode(bytes.as_ref()) .map_err(|e| WalError::Corrupt(format!("checkpoint decode: {e}")))?; @@ -1261,7 +1258,7 @@ impl RepoHandle { crate::log_reader::refs_at_seq(self, seq).await } - /// Read log entries [from_seq, to_seq]. + /// Read log entries [`from_seq`, `to_seq`]. pub async fn read_log( &self, from_seq: u64, @@ -1288,7 +1285,11 @@ impl RepoHandle { *self.last_freshness.lock() = Some(Instant::now()); } - async fn get_or_init_publisher(&self) -> mpsc::UnboundedSender { + #[allow( + clippy::expect_used, + reason = "self_arc is set by RepoHandle::new; a silent no-publisher sender would break every push instead" + )] + fn get_or_init_publisher(&self) -> mpsc::UnboundedSender { let mut guard = self.publish_tx.lock(); if let Some(tx) = &*guard { // A publisher task that died (panic mid-batch) leaves a sender to diff --git a/crates/walgit-wal/src/lockwait.rs b/crates/walgit-wal/src/lockwait.rs index 6a0e6d2..5520d74 100644 --- a/crates/walgit-wal/src/lockwait.rs +++ b/crates/walgit-wal/src/lockwait.rs @@ -26,7 +26,7 @@ pub fn record( warn_after: Duration, ) { metrics::histogram!("walgit_lock_wait_seconds", "lock" => lock).record(waited.as_secs_f64()); - let ms = waited.as_millis() as u64; + let ms = u64::try_from(waited.as_millis()).unwrap_or(u64::MAX); { let mut s = STATS.lock(); match s.iter_mut().find(|(l, _, _)| *l == lock) { diff --git a/crates/walgit-wal/src/log_reader.rs b/crates/walgit-wal/src/log_reader.rs index 78f11bc..ca7cf19 100644 --- a/crates/walgit-wal/src/log_reader.rs +++ b/crates/walgit-wal/src/log_reader.rs @@ -6,7 +6,7 @@ use walgit_store::{GetOptions, GetResult, ObjectStore}; use crate::error::WalError; use crate::handle::RepoHandle; -/// Read log entries in [from_seq, to_seq]. If `to_seq` is None, read up to +/// Read log entries in [`from_seq`, `to_seq`]. If `to_seq` is None, read up to /// `manifest.head_seq`. pub(crate) async fn read_log_impl( handle: &RepoHandle, @@ -18,7 +18,7 @@ pub(crate) async fn read_log_impl( // the repo's write lock here would deadlock callers that hold a read // guard (overview, tests), and freshness_ttl=0 makes that the common case. let known = handle.manifest_version.lock().clone(); - let manifest = match crate::sync::freshness_check(&handle.store, &known).await? { + let manifest = match crate::sync::freshness_check(&handle.store, known.as_ref()).await? { crate::sync::SyncOutcome::Unchanged => handle.manifest.read().clone(), crate::sync::SyncOutcome::Changed { manifest, .. } => std::sync::Arc::new(manifest), }; @@ -41,7 +41,8 @@ pub(crate) async fn read_log_impl( let res = handle.store.get(&seg.key, GetOptions::default()).await?; let bytes = match res { GetResult::Object { meta, body } => { - walgit_store::util::collect(body, meta.size as usize).await? + walgit_store::util::collect(body, usize::try_from(meta.size).unwrap_or(usize::MAX)) + .await? } GetResult::NotModified { .. } => continue, }; @@ -118,7 +119,7 @@ async fn replay_refs( handle.learn_checkpoint_times().await?; let times = handle.checkpoint_times(); let cp_time = times.and_then(|t| t.as_of.or(t.created_at)); - cp_time.map(|t| t <= at).unwrap_or(false) + cp_time.is_some_and(|t| t <= at) } }; if usable { @@ -151,7 +152,7 @@ async fn replay_refs( match cut { Cut::Time(at) => { let t = e.created_at.as_ref().map(walgit_proto::time::to_system); - if t.map(|t| t > at).unwrap_or(false) { + if t.is_some_and(|t| t > at) { break; } } @@ -165,7 +166,7 @@ async fn replay_refs( for u in &txn.updates { if !u.new_symbolic_target.is_empty() { if u.name == "HEAD" { - head_target = u.new_symbolic_target.clone(); + head_target.clone_from(&u.new_symbolic_target); } continue; } diff --git a/crates/walgit-wal/src/progress.rs b/crates/walgit-wal/src/progress.rs index 8750db4..e576502 100644 --- a/crates/walgit-wal/src/progress.rs +++ b/crates/walgit-wal/src/progress.rs @@ -140,7 +140,10 @@ impl Throttle { } /// True when an update should be emitted now. pub fn tick(&self, force: bool) -> bool { - let mut last = self.last.lock().unwrap(); + let mut last = self + .last + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); let now = std::time::Instant::now(); if force || last diff --git a/crates/walgit-wal/src/publish.rs b/crates/walgit-wal/src/publish.rs index 5aeac64..ba727d3 100644 --- a/crates/walgit-wal/src/publish.rs +++ b/crates/walgit-wal/src/publish.rs @@ -1,7 +1,7 @@ //! Publish path: linearizable CAS with batching. //! //! Design: -//! Each RepoHandle has a single-flight publisher task. `publish_push` and +//! Each `RepoHandle` has a single-flight publisher task. `publish_push` and //! `publish_ref_update` enqueue a [`PublishRequest`] onto an mpsc channel //! and await a oneshot response. The publisher collects requests within //! `cfg.wal.batch_window` (up to `max_batch`), then processes them as one @@ -62,7 +62,7 @@ pub(crate) struct PublishRequest { /// True when receive-pack already performed the request freshness check. pub(crate) synced: bool, /// Explicit entry time (history replay); None = now. Validated monotonic - /// (>= the head entry's created_at) before the batch is written. + /// (>= the head entry's `created_at`) before the batch is written. pub(crate) created_at: Option, pub(crate) response: oneshot::Sender>, } @@ -138,10 +138,7 @@ pub(crate) async fn put_immutable_create( // Big packs go up striped (parts + server-side compose, ~8 × 100 MB/s): // a large repository's rebuilt base (32.4 GB) took 431 s single-stream at 75 MB/s in the // weekly dry run of 2026-08-21. Small packs (every push) stay one PUT. - let size = tokio::fs::metadata(&path) - .await - .map(|m| m.len()) - .unwrap_or(0); + let size = tokio::fs::metadata(&path).await.map_or(0, |m| m.len()); let put = if size >= PARALLEL_PUT_MIN_BYTES && store.supports_compose() { walgit_store::util::put_file_parallel(store, &key, &path, opts(), PARALLEL_PUT_STRIPES) .await @@ -160,27 +157,26 @@ pub(crate) async fn put_immutable_create( // hiccup) must not leave a referenced object missing. Rare path, // one HEAD; on a miss, write it unconditionally (content-addressed: // whoever wins wrote the same bytes). - match store.head(&key).await? { - Some(_) => Ok(()), - None => { - tracing::warn!( - key, - "create-if-absent reported the object present but HEAD finds nothing; writing it" - ); - store - .put( - &key, - PutBody::File(path), - PutOptions { - mode: PutMode::Overwrite, - immutable: true, - ..Default::default() - }, - ) - .await - .map(|_| ()) - .map_err(WalError::Store) - } + if store.head(&key).await?.is_some() { + Ok(()) + } else { + tracing::warn!( + key, + "create-if-absent reported the object present but HEAD finds nothing; writing it" + ); + store + .put( + &key, + PutBody::File(path), + PutOptions { + mode: PutMode::Overwrite, + immutable: true, + ..Default::default() + }, + ) + .await + .map(|_| ()) + .map_err(WalError::Store) } } Err(e) => Err(WalError::Store(e)), @@ -259,7 +255,7 @@ pub(crate) async fn claim_log_slot( let mut probes = 0u32; let orphan_version = loop { let fresh = read_manifest_fresh(store).await?; - let fresh_head = fresh.as_ref().map(|m| m.head_seq).unwrap_or(0); + let fresh_head = fresh.as_ref().map_or(0, |m| m.head_seq); if fresh_head >= seq { return Ok(ClaimOutcome::Contended); } @@ -277,7 +273,7 @@ pub(crate) async fn claim_log_slot( } }; match orphan_version { - None => continue, // retry the Create at the same seq + None => {} // retry the Create at the same seq Some(v) => { tracing::warn!( key, @@ -285,7 +281,7 @@ pub(crate) async fn claim_log_slot( "orphaned log segment at the head (writer crashed between log PUT and manifest CAS); burning the seq" ); burned.push((key, v)); - if burned.len() as u32 >= MAX_BURN { + if u32::try_from(burned.len()).unwrap_or(u32::MAX) >= MAX_BURN { return Err(WalError::Corrupt(format!( "{MAX_BURN} consecutive orphaned log segments from seq {}", head_seq + 1 @@ -416,10 +412,7 @@ pub(crate) async fn publisher_task( let max_batch = handle.cfg.wal.max_batch; loop { - let first = match rx.recv().await { - Some(r) => r, - None => break, - }; + let Some(first) = rx.recv().await else { break }; let mut batch = Vec::with_capacity(max_batch.min(64)); batch.push(first); @@ -439,7 +432,7 @@ pub(crate) async fn publisher_task( tokio::pin!(deadline); loop { tokio::select! { - _ = &mut deadline => break, + () = &mut deadline => break, maybe_req = rx.recv() => { match maybe_req { Some(r) => { @@ -515,21 +508,21 @@ async fn process_batch(handle: &RepoHandle, batch: Vec) -> Resul // must be monotonic: >= the head entry's time, >= earlier explicit // times in this batch — the WAL's created_at order is history). let mut verified: Vec = Vec::with_capacity(batch.len()); - let mut floor: Option = handle.last_entry_time.lock().clone(); + let mut floor: Option = *handle.last_entry_time.lock(); for req in &batch { let mut per_ref = verify_txn(&req.txn, &working_refs); if let Some(ts) = &req.created_at { let t = time::to_system(ts); - if let Some(f) = floor { - if t < f { - let msg = format!( - "created_at {} is before the WAL head's {} (entries must be monotonic)", - chrono::DateTime::::from(t).to_rfc3339(), - chrono::DateTime::::from(f).to_rfc3339() - ); - for (_, r) in per_ref.iter_mut() { - *r = Err(RefError::Rejected(msg.clone())); - } + if let Some(f) = floor + && t < f + { + let msg = format!( + "created_at {} is before the WAL head's {} (entries must be monotonic)", + chrono::DateTime::::from(t).to_rfc3339(), + chrono::DateTime::::from(f).to_rfc3339() + ); + for (_, r) in &mut per_ref { + *r = Err(RefError::Rejected(msg.clone())); } } if per_ref.iter().all(|(_, r)| r.is_ok()) { @@ -577,7 +570,9 @@ async fn process_batch(handle: &RepoHandle, batch: Vec) -> Resul let mut new_packs = Vec::new(); for (offset, &idx) in valid_indices.iter().enumerate() { let seq = first_seq + offset as u64; - let req = &batch[idx]; + let Some(req) = batch.get(idx) else { + continue; + }; let pack_ref = req.pack.as_ref().map(|p| pack_ref_from_ingested(p, seq)); if let Some(pr) = &pack_ref { new_packs.push(pr.clone()); @@ -590,7 +585,7 @@ async fn process_batch(handle: &RepoHandle, batch: Vec) -> Resul Vec::new(), &req.meta, &writer, - req.created_at.clone(), + req.created_at, )); } (entries, new_packs) @@ -636,12 +631,12 @@ async fn process_batch(handle: &RepoHandle, batch: Vec) -> Resul } Err(e) => { let msg = e.to_string(); - return finish_with_error_msg(batch, &valid_indices, msg, e); + return finish_with_error_msg(batch, &valid_indices, &msg, e); } }; let first_seq = slot.first_seq; let (entries, new_packs) = build(first_seq); - let last_seq = entries.last().unwrap().seq; + let last_seq = entries.last().map_or(first_seq, |e| e.seq); // 6. Build updated manifest let mut updated: Manifest = (*manifest).clone(); @@ -659,7 +654,7 @@ async fn process_batch(handle: &RepoHandle, batch: Vec) -> Resul updated.log_segments.push(seg_ref); updated.log_segments.sort_by_key(|s| s.first_seq); updated.updated_at = Some(time::now()); - updated.writer = writer.to_string(); + updated.writer = writer.clone(); updated.revision += 1; // CAS manifest @@ -696,164 +691,159 @@ async fn process_batch(handle: &RepoHandle, batch: Vec) -> Resul // it and sweeps it; deleting here could race a lost-response // commit that `cas_landed` itself failed to observe. let msg = e.to_string(); - return finish_with_error_msg(batch, &valid_indices, msg, WalError::Store(e)); + return finish_with_error_msg(batch, &valid_indices, &msg, WalError::Store(e)); } Err(e2) => { let msg = format!("{e} (and re-reading the manifest failed: {e2})"); - return finish_with_error_msg(batch, &valid_indices, msg, WalError::Store(e)); + return finish_with_error_msg(batch, &valid_indices, &msg, WalError::Store(e)); } }, }; - match committed { - Some((committed, version)) => { - // Success! Update handle state. A landed-but-errored CAS leaves - // us without the new version: drop our cached one so the next - // sync refetches unconditionally. - let version = match version { - Some(v) => v, - None => match handle.store.head(keys::MANIFEST).await? { - Some(m) => m.version, - None => { - return finish_with_error( - batch, - &valid_indices, - WalError::Corrupt("manifest vanished after commit".into()), - ); - } - }, - }; - // The local commit — ref txns applied, then the new manifest version advertised — happens - // under `sync_mutex`, the lock the refs phase of every sync holds: a sync that already read - // the committed manifest would otherwise replay the same entry concurrently (two - // `git update-ref` on one ref → a lock collision: rig round 2447 of 2450, 2026-08-23) and a - // reader between the two steps would see one without the other. Refs first: the - // advertisement/ls-refs caches are keyed by the manifest version, and the reverse order let - // a reader cache the OLD refs under the NEW version (1 round in 6 on the rig). - // - // The WAL commit already happened (CAS ok) and is the truth: whatever the local apply does, - // every waiter is answered `ok`. A failed apply leaves the version unadvertised, so the next - // sync sees a change and replays the entry — the copy repairs itself. (Answering an error - // here produced a durable push that git reported as failed — "0 winners", commit fetchable.) - let mut local_ok = true; - { - let _sync_guard = crate::lockwait::timed( - "sync_mutex", - &handle.id, - handle.cfg.telemetry.lock_wait_warn, - || handle.sync_mutex.try_lock().ok(), - handle.sync_mutex.lock(), - ) - .await; - for &idx in &valid_indices { - if let Err(e) = handle.local.apply_ref_txn(&batch[idx].txn, false) { - tracing::warn!(repo = %handle.id, seq = last_seq, error = %e, "published (CAS ok), but applying the ref txn to the local copy failed; the next sync replays it"); - metrics::counter!("walgit_publish_local_apply_failed_total") - .increment(1); - local_ok = false; - break; - } + if let Some((committed, version)) = committed { + // Success! Update handle state. A landed-but-errored CAS leaves + // us without the new version: drop our cached one so the next + // sync refetches unconditionally. + let version = match version { + Some(v) => v, + None => match handle.store.head(keys::MANIFEST).await? { + Some(m) => m.version, + None => { + return finish_with_error( + batch, + &valid_indices, + WalError::Corrupt("manifest vanished after commit".into()), + ); } - if local_ok && let Err(e) = handle.local.refresh_async().await { - tracing::warn!(repo = %handle.id, seq = last_seq, error = %e, "published (CAS ok), but refreshing the local copy failed; the next sync repairs it"); + }, + }; + // The local commit — ref txns applied, then the new manifest version advertised — happens + // under `sync_mutex`, the lock the refs phase of every sync holds: a sync that already read + // the committed manifest would otherwise replay the same entry concurrently (two + // `git update-ref` on one ref → a lock collision: rig round 2447 of 2450, 2026-08-23) and a + // reader between the two steps would see one without the other. Refs first: the + // advertisement/ls-refs caches are keyed by the manifest version, and the reverse order let + // a reader cache the OLD refs under the NEW version (1 round in 6 on the rig). + // + // The WAL commit already happened (CAS ok) and is the truth: whatever the local apply does, + // every waiter is answered `ok`. A failed apply leaves the version unadvertised, so the next + // sync sees a change and replays the entry — the copy repairs itself. (Answering an error + // here produced a durable push that git reported as failed — "0 winners", commit fetchable.) + let mut local_ok = true; + { + let _sync_guard = crate::lockwait::timed( + "sync_mutex", + &handle.id, + handle.cfg.telemetry.lock_wait_warn, + || handle.sync_mutex.try_lock().ok(), + handle.sync_mutex.lock(), + ) + .await; + for &idx in &valid_indices { + let Some(req) = batch.get(idx) else { + continue; + }; + if let Err(e) = handle.local.apply_ref_txn(&req.txn, false) { + tracing::warn!(repo = %handle.id, seq = last_seq, error = %e, "published (CAS ok), but applying the ref txn to the local copy failed; the next sync replays it"); + metrics::counter!("walgit_publish_local_apply_failed_total").increment(1); local_ok = false; - } - // Test hook: widen the gap between the two local-commit steps (harmless in this order - // and under this lock; the poison window with the steps reversed and no lock). - if let Some(ms) = std::env::var("WALGIT_TEST_PUBLISH_GAP_MS") - .ok() - .and_then(|v| v.parse::().ok()) - { - tokio::time::sleep(std::time::Duration::from_millis(ms)).await; - } - if local_ok { - *handle.manifest.write() = Arc::new(committed.clone()); - *handle.manifest_version.lock() = Some(version.clone()); - { - let mut state = handle.state.lock(); - state.manifest_version = Some(version.as_str().to_string()); - state.applied_seq = last_seq; - let ready = state.packs_ready(); - state.revision = committed.revision; - if ready { - state.packs_revision = committed.revision; - } - } - if let Err(e) = crate::state::save_state( - handle.local.path(), - &handle.state.lock().clone(), - ) { - tracing::warn!(repo = %handle.id, error = %e, "published (CAS ok), but saving local state failed; the next sync repairs it"); - } - } else { - // Forget the known version so the next sync performs an unconditional GET and - // replays from the last applied seq. - handle.manifest_version.lock().take(); + break; } } - sweep_burned(&handle.store, &slot) - .instrument(span.clone()) - .await; - - for e in &entries { - if let Some(t) = e.created_at.as_ref() { - note_entry_time(handle, e.seq, t); - } + if local_ok && let Err(e) = handle.local.refresh_async().await { + tracing::warn!(repo = %handle.id, seq = last_seq, error = %e, "published (CAS ok), but refreshing the local copy failed; the next sync repairs it"); + local_ok = false; } - // Fold the pushed packs' commits into the local commit-graph - // chain (cheap, incremental; off the client's critical path). - if !new_packs.is_empty() { - if let Some(arc) = handle.self_arc.get().cloned() { - let packs = new_packs.clone(); - tokio::spawn(async move { - let manifest = arc.manifest(); - crate::sync::maintain_commit_graph(&arc, &manifest, &packs).await; - }); - } + // Test hook: widen the gap between the two local-commit steps (harmless in this order + // and under this lock; the poison window with the steps reversed and no lock). + if let Some(ms) = std::env::var("WALGIT_TEST_PUBLISH_GAP_MS") + .ok() + .and_then(|v| v.parse::().ok()) + { + tokio::time::sleep(std::time::Duration::from_millis(ms)).await; } - - // Build all responses (success for valid, rejection for invalid) - let mut responses: Vec = Vec::with_capacity(batch.len()); - for (i, v) in verified.iter().enumerate() { - if v.valid { - let offset = valid_indices.iter().position(|&vi| vi == i).unwrap(); - let seq = first_seq + offset as u64; - responses.push(PublishResult { - seq, - per_ref: v.per_ref.clone(), - }); - } else { - responses.push(PublishResult { - seq: 0, - per_ref: v.per_ref.clone(), - }); + if local_ok { + *handle.manifest.write() = Arc::new(committed.clone()); + *handle.manifest_version.lock() = Some(version.clone()); + { + let mut state = handle.state.lock(); + state.manifest_version = Some(version.as_str().to_string()); + state.applied_seq = last_seq; + let ready = state.packs_ready(); + state.revision = committed.revision; + if ready { + state.packs_revision = committed.revision; + } } + if let Err(e) = + crate::state::save_state(handle.local.path(), &handle.state.lock().clone()) + { + tracing::warn!(repo = %handle.id, error = %e, "published (CAS ok), but saving local state failed; the next sync repairs it"); + } + } else { + // Forget the known version so the next sync performs an unconditional GET and + // replays from the last applied seq. + handle.manifest_version.lock().take(); } + } + sweep_burned(&handle.store, &slot) + .instrument(span.clone()) + .await; - // Consume batch and send responses - for (req, resp) in batch.into_iter().zip(responses) { - let _ = req.response.send(Ok(resp)); + for e in &entries { + if let Some(t) = e.created_at.as_ref() { + note_entry_time(handle, e.seq, t); } + } + // Fold the pushed packs' commits into the local commit-graph + // chain (cheap, incremental; off the client's critical path). + if !new_packs.is_empty() + && let Some(arc) = handle.self_arc.get().cloned() + { + let packs = new_packs.clone(); + tokio::spawn(async move { + let manifest = arc.manifest(); + crate::sync::maintain_commit_graph(&arc, &manifest, &packs).await; + }); + } - // Maybe trigger checkpoint - maybe_trigger_checkpoint(handle, last_seq); - - span.record("seq", last_seq); - span.record("cas_retries", attempts); - return Ok(()); + // Build all responses (success for valid, rejection for invalid) + let mut responses: Vec = Vec::with_capacity(batch.len()); + for (i, v) in verified.iter().enumerate() { + let seq = if v.valid { + valid_indices + .iter() + .position(|&vi| vi == i) + .map_or(0, |offset| first_seq + offset as u64) + } else { + 0 + }; + responses.push(PublishResult { + seq, + per_ref: v.per_ref.clone(), + }); } - None => { - // Lost the CAS: drop exactly the segment we wrote, re-sync, retry. - drop_own_slot(&handle.store, &slot) - .instrument(span.clone()) - .await; - attempts += 1; - if attempts >= max_retries { - span.record("cas_retries", attempts); - return finish_with_error(batch, &valid_indices, WalError::Retry { attempts }); - } - continue; + + // Consume batch and send responses + for (req, resp) in batch.into_iter().zip(responses) { + let _ = req.response.send(Ok(resp)); } + + // Maybe trigger checkpoint + maybe_trigger_checkpoint(handle, last_seq); + + span.record("seq", last_seq); + span.record("cas_retries", attempts); + return Ok(()); + } + // Lost the CAS: drop exactly the segment we wrote, re-sync, retry. + drop_own_slot(&handle.store, &slot) + .instrument(span.clone()) + .await; + attempts += 1; + if attempts >= max_retries { + span.record("cas_retries", attempts); + return finish_with_error(batch, &valid_indices, WalError::Retry { attempts }); } } } @@ -868,7 +858,7 @@ fn finish_all_errors(batch: Vec, err: WalError) -> Result<(), Wa Err(err) } /// Send error responses to valid request senders, then return the error. -/// Converts the error to a string for each sender since WalError is not Clone. +/// Converts the error to a string for each sender since `WalError` is not Clone. fn finish_with_error( batch: Vec, valid_indices: &[usize], @@ -880,7 +870,7 @@ fn finish_with_error( // batch error too rather than dropping the channel ("publisher dropped // response" told the caller nothing). let _ = valid_indices; - for req in batch.into_iter() { + for req in batch { let _ = req.response.send(Err(WalError::Corrupt(msg.clone()))); } Err(err) @@ -889,12 +879,12 @@ fn finish_with_error( fn finish_with_error_msg( batch: Vec, valid_indices: &[usize], - msg: String, + msg: &str, err: WalError, ) -> Result<(), WalError> { let _ = valid_indices; - for req in batch.into_iter() { - let _ = req.response.send(Err(WalError::Corrupt(msg.clone()))); + for req in batch { + let _ = req.response.send(Err(WalError::Corrupt(msg.to_owned()))); } Err(err) } @@ -903,19 +893,19 @@ fn maybe_trigger_checkpoint(handle: &RepoHandle, _head_seq: u64) { // Opportunistic: the writer that crossed a trigger folds the log. The // `maintain` role covers repos nobody pushes to (age trigger). let due = crate::checkpoint::checkpoint_due(&handle.manifest.read(), &handle.cfg.wal); - if let Some(trigger) = due { - if let Some(arc) = handle.self_arc.get().cloned() { - tokio::spawn(async move { - match crate::checkpoint::write_checkpoint_impl(&arc).await { - Ok(cp) => { - tracing::info!(repo = %arc.id, seq = cp.seq, %trigger, "auto checkpoint written") - } - Err(e) => { - tracing::warn!(repo = %arc.id, %trigger, "auto checkpoint failed: {e}") - } + if let Some(trigger) = due + && let Some(arc) = handle.self_arc.get().cloned() + { + tokio::spawn(async move { + match crate::checkpoint::write_checkpoint_impl(&arc).await { + Ok(cp) => { + tracing::info!(repo = %arc.id, seq = cp.seq, %trigger, "auto checkpoint written"); } - }); - } + Err(e) => { + tracing::warn!(repo = %arc.id, %trigger, "auto checkpoint failed: {e}"); + } + } + }); } } @@ -984,7 +974,7 @@ pub(crate) async fn publish_compact_impl( } let pack_ref = pack_ref_from_info(&new_pack, 0, tier); // seq set below - let supersedes_hex: Vec = supersedes.iter().map(|o| o.to_string()).collect(); + let supersedes_hex: Vec = supersedes.iter().map(ToString::to_string).collect(); let mut attempts = 0u32; @@ -1008,7 +998,7 @@ pub(crate) async fn publish_compact_impl( supersedes: supersedes_hex.clone(), checkpoint: None, created_at: Some(entry_time), - writer: writer.to_string(), + writer: writer.clone(), meta: HashMap::new(), settings: None, }; @@ -1035,7 +1025,7 @@ pub(crate) async fn publish_compact_impl( let mut updated: Manifest = (*manifest).clone(); updated.head_seq = seq; let sup_set: std::collections::HashSet<&str> = - supersedes_hex.iter().map(|s| s.as_str()).collect(); + supersedes_hex.iter().map(String::as_str).collect(); updated .packs .retain(|p| !sup_set.contains(p.checksum.as_str()) && p.checksum != pack_ref.checksum); @@ -1055,7 +1045,7 @@ pub(crate) async fn publish_compact_impl( updated.log_segments.push(seg_ref); updated.log_segments.sort_by_key(|s| s.first_seq); updated.updated_at = Some(time::now()); - updated.writer = writer.to_string(); + updated.writer = writer.clone(); updated.revision += 1; let buf = updated.encode_to_vec(); @@ -1088,45 +1078,39 @@ pub(crate) async fn publish_compact_impl( })?; Some((fresh, v)) } - Ok(None) => return Err(WalError::Store(e)), - Err(_) => return Err(WalError::Store(e)), + Ok(None) | Err(_) => return Err(WalError::Store(e)), }, }; - match committed { - Some((committed, version)) => { - *handle.manifest.write() = Arc::new(committed.clone()); - *handle.manifest_version.lock() = Some(version.clone()); - note_entry_time(handle, seq, &entry_time); - { - let mut state = handle.state.lock(); - state.manifest_version = Some(version.as_str().to_string()); - state.applied_seq = seq; - // The publisher's own superseded packs are removed by the next pack sync like - // everyone else's (a scratch-copy base rebuild leaves them in the serving copy; - // a geometric fold already deleted them — the removal is then a no-op). - for s in &supersedes_hex { - if !state.pending_pack_removals.contains(s) { - state.pending_pack_removals.push(s.clone()); - } - } - let ready = state.packs_ready(); - state.revision = committed.revision; - if ready { - state.packs_revision = committed.revision; + if let Some((committed, version)) = committed { + *handle.manifest.write() = Arc::new(committed.clone()); + *handle.manifest_version.lock() = Some(version.clone()); + note_entry_time(handle, seq, &entry_time); + { + let mut state = handle.state.lock(); + state.manifest_version = Some(version.as_str().to_string()); + state.applied_seq = seq; + // The publisher's own superseded packs are removed by the next pack sync like + // everyone else's (a scratch-copy base rebuild leaves them in the serving copy; + // a geometric fold already deleted them — the removal is then a no-op). + for s in &supersedes_hex { + if !state.pending_pack_removals.contains(s) { + state.pending_pack_removals.push(s.clone()); } } - crate::state::save_state(handle.local.path(), &handle.state.lock().clone())?; - sweep_burned(&handle.store, &slot).await; - return Ok(seq); - } - None => { - drop_own_slot(&handle.store, &slot).await; - attempts += 1; - if attempts >= max_retries { - return Err(WalError::Retry { attempts }); + let ready = state.packs_ready(); + state.revision = committed.revision; + if ready { + state.packs_revision = committed.revision; } - continue; } + crate::state::save_state(handle.local.path(), &handle.state.lock().clone())?; + sweep_burned(&handle.store, &slot).await; + return Ok(seq); + } + drop_own_slot(&handle.store, &slot).await; + attempts += 1; + if attempts >= max_retries { + return Err(WalError::Retry { attempts }); } } } @@ -1202,7 +1186,7 @@ pub(crate) async fn annotate_pack_impl( } let pack_ref = p.clone(); updated.updated_at = Some(time::now()); - updated.writer = writer.to_string(); + updated.writer = writer.clone(); updated.revision += 1; let mode = match &known_version { Some(v) => PutMode::Update(v.clone()), @@ -1263,7 +1247,9 @@ pub(crate) async fn add_pack_impl( let checksum = gix_hash::ObjectId::from_hex(hex.as_bytes()) .map_err(|e| WalError::Corrupt(format!("bad pack name {name}: {e}")))?; let dest = handle.local.pack_path(&checksum); - std::fs::create_dir_all(dest.parent().unwrap())?; + if let Some(dir) = dest.parent() { + std::fs::create_dir_all(dir)?; + } for (src, dst) in [(pack, dest.clone()), (idx, dest.with_extension("idx"))] { if !dst.exists() && std::fs::hard_link(src, &dst).is_err() { std::fs::copy(src, &dst)?; @@ -1299,7 +1285,7 @@ pub(crate) async fn publish_settings_impl( handle.sync_impl_level(crate::sync::SyncLevel::Refs).await?; let manifest = handle.manifest.read().clone(); let known_version = handle.manifest_version.lock().clone(); - let revision = manifest.settings.as_ref().map(|s| s.revision).unwrap_or(0) + 1; + let revision = manifest.settings.as_ref().map_or(0, |s| s.revision) + 1; let settings = walgit_proto::v1::RepoSettings { toml: toml_text.to_string(), revision, @@ -1316,7 +1302,7 @@ pub(crate) async fn publish_settings_impl( supersedes: Vec::new(), checkpoint: None, created_at: Some(entry_time), - writer: writer.to_string(), + writer: writer.clone(), meta: HashMap::from([ ("author".to_string(), author.to_string()), ("message".to_string(), message.to_string()), @@ -1353,7 +1339,7 @@ pub(crate) async fn publish_settings_impl( }); updated.log_segments.sort_by_key(|s| s.first_seq); updated.updated_at = Some(time::now()); - updated.writer = writer.to_string(); + updated.writer = writer.clone(); updated.revision += 1; let buf = updated.encode_to_vec(); let mode = match &known_version { @@ -1391,7 +1377,6 @@ pub(crate) async fn publish_settings_impl( if attempts >= max_retries { return Err(WalError::Retry { attempts }); } - continue; } Err(e) => return Err(WalError::Store(e)), } diff --git a/crates/walgit-wal/src/registry.rs b/crates/walgit-wal/src/registry.rs index d248b9d..737f679 100644 --- a/crates/walgit-wal/src/registry.rs +++ b/crates/walgit-wal/src/registry.rs @@ -1,4 +1,4 @@ -//! Registry: process-wide map of RepoId -> Arc. +//! Registry: process-wide map of `RepoId` -> Arc. use std::str::FromStr; use std::sync::Arc; @@ -95,18 +95,17 @@ impl Registry { let prefixed = Prefixed::new(self.store.clone(), prefix); // Read manifest (NotFound if absent) - let (meta, manifest) = match get_message::(&prefixed, keys::MANIFEST).await? { - Some(v) => v, - None => return Err(WalError::NotFound), + let Some((meta, manifest)) = get_message::(&prefixed, keys::MANIFEST).await? + else { + return Err(WalError::NotFound); }; // Open or init local repo (LocalRepo joins owner/name.git onto the root). - let local = match LocalRepo::open(&self.cache_root, id)? { - Some(l) => l, - None => { - let format = parse_object_format(&manifest.object_format); - LocalRepo::init(&self.cache_root, id, format)? - } + let local = if let Some(l) = LocalRepo::open(&self.cache_root, id)? { + l + } else { + let format = parse_object_format(&manifest.object_format); + LocalRepo::init(&self.cache_root, id, format)? }; // Load state @@ -183,7 +182,7 @@ impl Registry { Ok(()) } - /// CAS-create manifest.pb (PutMode::Create). Err(AlreadyExists) on 412. + /// CAS-create manifest.pb (`PutMode::Create`). Err(AlreadyExists) on 412. pub async fn create( &self, id: &RepoId, @@ -341,8 +340,8 @@ impl Registry { Ok(repos) } - /// Disk cache maintenance: evict idle repos beyond cache.max_bytes / evict_idle_after. - pub async fn evict_idle(&self) -> Result { + /// Disk cache maintenance: evict idle repos beyond `cache.max_bytes` / `evict_idle_after`. + pub fn evict_idle(&self) -> Result { let evict_after = self.cfg.cache.evict_idle_after; // D25: budget mode evicts past `cache.max_bytes`; disk mode only under // disk pressure (filesystem of `cache.dir` above `disk_high_watermark`) @@ -355,6 +354,11 @@ impl Registry { if frac <= self.cfg.cache.disk_high_watermark { return Ok(EvictReport::default()); } + #[allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + reason = "the saturating float-to-int cast is the intended rounding" + )] let low = ((self.cfg.cache.disk_high_watermark - 0.10).max(0.0) * total as f64) as u64; // Other data on the filesystem counts against us: target = @@ -385,7 +389,7 @@ impl Registry { // Collect idle repos. In-use checks happen again while evicting: a // request may acquire a ReadGuard after this snapshot. - for entry in self.repos.iter() { + for entry in &self.repos { let handle = entry.value(); let last_access = handle.last_access(); if now.duration_since(last_access) > evict_after { @@ -464,16 +468,26 @@ fn dir_size(path: &std::path::Path) -> u64 { walk(path, &mut std::collections::HashSet::new()) } +/// statvfs field widths differ per platform, so widen through a generic bound rather +/// than a conversion that is redundant on one target and required on another. +fn widen>(v: T) -> u64 { + v.into() +} + /// (used, total) bytes of the filesystem holding `path` (statvfs). +#[allow(unsafe_code)] fn disk_usage(path: &std::path::Path) -> Option<(u64, u64)> { use std::ffi::CString; use std::os::unix::ffi::OsStrExt; let c = CString::new(path.as_os_str().as_bytes()).ok()?; + // SAFETY: statvfs is a plain C struct of integers, so all-zero is a valid value. let mut st: libc::statvfs = unsafe { std::mem::zeroed() }; - if unsafe { libc::statvfs(c.as_ptr(), &mut st) } != 0 { + // SAFETY: `c` is a live NUL-terminated CString and `st` is a live, correctly + // typed statvfs that the call only writes into. + if unsafe { libc::statvfs(c.as_ptr(), &raw mut st) } != 0 { return None; } - let total = st.f_blocks as u64 * st.f_frsize as u64; - let avail = st.f_bavail as u64 * st.f_frsize as u64; + let total = widen(st.f_blocks) * widen(st.f_frsize); + let avail = widen(st.f_bavail) * widen(st.f_frsize); Some((total.saturating_sub(avail), total)) } diff --git a/crates/walgit-wal/src/remote.rs b/crates/walgit-wal/src/remote.rs index 303ed7c..f4cddbc 100644 --- a/crates/walgit-wal/src/remote.rs +++ b/crates/walgit-wal/src/remote.rs @@ -40,7 +40,7 @@ impl BlockCache { cache: moka::future::Cache::builder() .max_capacity(max_bytes.max(BLOCK_SIZE * 4)) .weigher(|_k: &(Arc, u64), v: &Bytes| { - v.len().clamp(1, u32::MAX as usize) as u32 + u32::try_from(v.len().clamp(1, u32::MAX as usize)).unwrap_or(u32::MAX) }) .build(), range_reads: AtomicU64::new(0), @@ -90,7 +90,7 @@ impl BlockCache { return Err(WalError::Corrupt(format!("unexpected 304 for {key}"))); } }; - let b = walgit_store::util::collect(body, (end - start) as usize).await?; + let b = walgit_store::util::collect(body, usize::try_from(end - start).unwrap_or(usize::MAX)).await?; if b.len() as u64 != end - start { return Err(WalError::Corrupt(format!( "short range read for {key}: {start}..{end} got {}", @@ -175,10 +175,10 @@ impl RemotePacks { if let Ok(mut rd) = tokio::fs::read_dir(&dir).await { while let Ok(Some(e)) = rd.next_entry().await { let name = e.file_name().to_string_lossy().to_string(); - if let Some(stem) = name.strip_suffix(".idx") { - if !live.contains(stem) { - let _ = tokio::fs::remove_file(e.path()).await; - } + if let Some(stem) = name.strip_suffix(".idx") + && !live.contains(stem) + { + let _ = tokio::fs::remove_file(e.path()).await; } } } @@ -194,10 +194,8 @@ impl RemotePacks { .join("objects") .join("pack") .join(format!("pack-{}.idx", p.checksum)); - if installed.is_file() { - if std::fs::hard_link(&installed, &dest).is_err() { - let _ = std::fs::copy(&installed, &dest); - } + if installed.is_file() && std::fs::hard_link(&installed, &dest).is_err() { + let _ = std::fs::copy(&installed, &dest); } } let done = Arc::new(AtomicU64::new(0)); @@ -228,7 +226,7 @@ impl RemotePacks { let reporter = reporter.clone(); let throttle = throttle.clone(); tasks.push(tokio::spawn(async move { - let _permit = sem.acquire().await.unwrap(); + let _permit = sem.acquire().await.ok(); let tmp = dir.join(format!("{}.idx.tmp", p.checksum)); let dest = dir.join(format!("{}.idx", p.checksum)); let cb = |delta: u64, _t: u64| { @@ -273,7 +271,7 @@ impl RemotePacks { let size = if p.pack_size > 0 { p.pack_size } else { - store.head(&key).await?.map(|m| m.size).unwrap_or(0) + store.head(&key).await?.map_or(0, |m| m.size) }; packs.push(RemotePack { checksum: p.checksum.clone(), @@ -296,7 +294,8 @@ impl RemotePacks { objects: moka::sync::Cache::builder() .max_capacity(object_cache_bytes.max(8 * 1024 * 1024)) .weigher(|_k: &(usize, u64), v: &Arc| { - (v.data.len() + 64).clamp(1, u32::MAX as usize) as u32 + u32::try_from((v.data.len() + 64).clamp(1, u32::MAX as usize)) + .unwrap_or(u32::MAX) }) .build(), hash, @@ -315,7 +314,10 @@ impl RemotePacks { self.packs.iter().map(|p| p.checksum.as_str()).collect() } pub fn total_objects(&self) -> u64 { - self.packs.iter().map(|p| p.idx.num_objects() as u64).sum() + self.packs + .iter() + .map(|p| u64::from(p.idx.num_objects())) + .sum() } /// Locate an object: (pack index, pack offset). @@ -375,7 +377,9 @@ impl RemotePacks { let (entry, _) = self.read_entry_header(cur.0, cur.1).await?; match entry.header { Header::Blob | Header::Tree | Header::Commit | Header::Tag => { - let kind = entry.header.as_kind().expect("base kind"); + let kind = entry.header.as_kind().ok_or_else(|| { + WalError::Corrupt("pack base entry has no object kind".into()) + })?; return Ok(Some((kind, size.unwrap_or(entry.decompressed_size)))); } Header::OfsDelta { base_distance } => { @@ -411,7 +415,12 @@ impl RemotePacks { if let Some(o) = self.objects.get(&(pi, off)) { return Ok(o); } - let span = tracing::debug_span!("remote.decode", repo = %self.repo, pack = %self.packs[pi].checksum, offset = off, oid_kind = tracing::field::Empty, chain = tracing::field::Empty); + let checksum = self + .packs + .get(pi) + .map(|p| p.checksum.as_str()) + .unwrap_or_default(); + let span = tracing::debug_span!("remote.decode", repo = %self.repo, pack = %checksum, offset = off, oid_kind = tracing::field::Empty, chain = tracing::field::Empty); let r = self.decode_inner(pi, off).instrument(span.clone()).await; if let Ok((o, chain)) = &r { span.record("oid_kind", format!("{:?}", o.kind).to_lowercase()); @@ -436,7 +445,9 @@ impl RemotePacks { match entry.header { Header::Blob | Header::Tree | Header::Commit | Header::Tag => { let o = Arc::new(Obj { - kind: entry.header.as_kind().expect("base kind"), + kind: entry.header.as_kind().ok_or_else(|| { + WalError::Corrupt("pack base entry has no object kind".into()) + })?, data: Bytes::from(data), }); self.objects.insert(cur, o.clone()); @@ -474,7 +485,10 @@ impl RemotePacks { /// Bytes `[off, off+len)` of pack `pi`, assembled from cached blocks /// (missing blocks fetched concurrently). async fn read_at(&self, pi: usize, off: u64, len: u64) -> Result { - let p = &self.packs[pi]; + let p = self + .packs + .get(pi) + .ok_or_else(|| WalError::Corrupt(format!("pack index {pi} out of range")))?; let end = (off + len).min(p.size); if off >= end { return Ok(Bytes::new()); @@ -486,18 +500,17 @@ impl RemotePacks { .block(&self.store, &self.repo, &p.cache_key, &p.key, n, p.size) }); let blocks = futures::future::try_join_all(futs).await?; - if blocks.len() == 1 { - let b = &blocks[0]; - let s = (off - first * BLOCK_SIZE) as usize; - let e = (end - first * BLOCK_SIZE) as usize; + if let [b] = blocks.as_slice() { + let s = usize::try_from(off - first * BLOCK_SIZE).unwrap_or(usize::MAX); + let e = usize::try_from(end - first * BLOCK_SIZE).unwrap_or(usize::MAX); return Ok(b.slice(s..e)); } - let mut out = Vec::with_capacity((end - off) as usize); + let mut out = Vec::with_capacity(usize::try_from(end - off).unwrap_or(usize::MAX)); for (i, b) in blocks.iter().enumerate() { let bstart = (first + i as u64) * BLOCK_SIZE; - let s = off.saturating_sub(bstart) as usize; - let e = (end - bstart).min(b.len() as u64) as usize; - out.extend_from_slice(&b[s..e]); + let s = usize::try_from(off.saturating_sub(bstart)).unwrap_or(usize::MAX); + let e = usize::try_from((end - bstart).min(b.len() as u64)).unwrap_or(usize::MAX); + out.extend_from_slice(b.get(s..e).unwrap_or_default()); } Ok(Bytes::from(out)) } @@ -531,10 +544,13 @@ impl RemotePacks { head: Bytes, ) -> Result, WalError> { use flate2::{Decompress, FlushDecompress, Status}; - let p = &self.packs[pi]; - let size = entry.decompressed_size as usize; + let p = self + .packs + .get(pi) + .ok_or_else(|| WalError::Corrupt(format!("pack index {pi} out of range")))?; + let size = usize::try_from(entry.decompressed_size).unwrap_or(usize::MAX); let data_off = entry.data_offset; - let header_len = (data_off - entry.pack_offset()) as usize; + let header_len = usize::try_from(data_off - entry.pack_offset()).unwrap_or(usize::MAX); // Prefetch: blocks from data_off through data_off + size (+ slack), bounded. { let guess_end = @@ -581,7 +597,7 @@ impl RemotePacks { entry.pack_offset() )) })?; - let consumed = (z.total_in() - before_in) as usize; + let consumed = usize::try_from(z.total_in() - before_in).unwrap_or(usize::MAX); pos += consumed as u64; chunk = chunk.slice(consumed..); if out.len() >= size || status == Status::StreamEnd { @@ -614,7 +630,7 @@ fn varint(d: &[u8], mut i: usize) -> Result<(u64, usize), &'static str> { loop { let b = *d.get(i).ok_or("delta header truncated")?; i += 1; - v |= ((b & 0x7f) as u64) << shift; + v |= u64::from(b & 0x7f) << shift; shift += 7; if b & 0x80 == 0 { return Ok((v, i)); @@ -634,13 +650,12 @@ fn delta_result_size(delta: &[u8]) -> Result { /// Apply a git delta (`base` + `delta` instructions → result). pub fn apply_delta(base: &[u8], delta: &[u8]) -> Result, &'static str> { let (base_size, i) = varint(delta, 0)?; - if base_size as usize != base.len() { + if usize::try_from(base_size).unwrap_or(usize::MAX) != base.len() { return Err("delta base size mismatch"); } let (res_size, mut i) = varint(delta, i)?; - let mut out = Vec::with_capacity(res_size as usize); - while i < delta.len() { - let cmd = delta[i]; + let mut out = Vec::with_capacity(usize::try_from(res_size).unwrap_or(usize::MAX)); + while let Some(&cmd) = delta.get(i) { i += 1; if cmd & 0x80 != 0 { let mut ofs: u64 = 0; @@ -648,7 +663,7 @@ pub fn apply_delta(base: &[u8], delta: &[u8]) -> Result, &'static str> { let mut nb = |shift: u32| -> Result { let b = *delta.get(i).ok_or("delta copy truncated")?; i += 1; - Ok((b as u64) << shift) + Ok(u64::from(b) << shift) }; if cmd & 0x01 != 0 { ofs |= nb(0)?; @@ -675,10 +690,12 @@ pub fn apply_delta(base: &[u8], delta: &[u8]) -> Result, &'static str> { size = 0x10000; } let end = ofs.checked_add(size).ok_or("delta copy overflow")?; - if end as usize > base.len() { + if usize::try_from(end).unwrap_or(usize::MAX) > base.len() { return Err("delta copy out of base bounds"); } - out.extend_from_slice(&base[ofs as usize..end as usize]); + let from = usize::try_from(ofs).unwrap_or(usize::MAX); + let to = usize::try_from(end).unwrap_or(usize::MAX); + out.extend_from_slice(base.get(from..to).ok_or("delta copy out of base bounds")?); } else if cmd != 0 { let n = cmd as usize; let src = delta.get(i..i + n).ok_or("delta insert truncated")?; @@ -705,24 +722,7 @@ pub fn human_bytes(n: u64) -> String { if i == 0 { format!("{n} B") } else { - format!("{v:.1} {}", U[i]) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn delta_roundtrip_insert_and_copy() { - let base = b"hello world, this is the base object"; - // header: base size, result size; then copy 0..5 from base, insert "!!", copy 5..12 - let mut d = vec![base.len() as u8, 5 + 2 + 7]; - d.extend([0x90, 5]); // copy ofs=0 (no ofs bytes), size=5 (0x10 flag) - d.extend([2, b'!', b'!']); - d.extend([0x91, 5, 7]); // copy ofs=5 size=7 - let out = apply_delta(base, &d).unwrap(); - assert_eq!(out, b"hello!! world,"); + format!("{v:.1} {}", U.get(i).copied().unwrap_or("B")) } } @@ -772,8 +772,8 @@ impl walgit_git::ObjectFaulter for Faulter { ); Box::pin( async move { - self.rounds.fetch_add(1, Ordering::Relaxed); const PAR: usize = 32; + self.rounds.fetch_add(1, Ordering::Relaxed); let mut n = 0usize; for chunk in oids.chunks(PAR) { let results = @@ -803,3 +803,20 @@ impl walgit_git::ObjectFaulter for Faulter { ) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn delta_roundtrip_insert_and_copy() { + let base = b"hello world, this is the base object"; + // header: base size, result size; then copy 0..5 from base, insert "!!", copy 5..12 + let mut d = vec![u8::try_from(base.len()).unwrap_or(u8::MAX), 5 + 2 + 7]; + d.extend([0x90, 5]); // copy ofs=0 (no ofs bytes), size=5 (0x10 flag) + d.extend([2, b'!', b'!']); + d.extend([0x91, 5, 7]); // copy ofs=5 size=7 + let out = apply_delta(base, &d).unwrap(); + assert_eq!(out, b"hello!! world,"); + } +} diff --git a/crates/walgit-wal/src/state.rs b/crates/walgit-wal/src/state.rs index 779f0cf..a150b94 100644 --- a/crates/walgit-wal/src/state.rs +++ b/crates/walgit-wal/src/state.rs @@ -1,4 +1,4 @@ -//! Persistent local state for a RepoHandle, stored in the repo dir so restarts +//! Persistent local state for a `RepoHandle`, stored in the repo dir so restarts //! skip already-applied log entries. use std::path::Path; @@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize}; use crate::error::WalError; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct RepoState { /// Opaque version string of the last manifest we applied. pub manifest_version: Option, @@ -39,19 +39,6 @@ impl RepoState { } } -impl Default for RepoState { - fn default() -> Self { - RepoState { - manifest_version: None, - applied_seq: 0, - revision: 0, - packs_revision: 0, - pending_pack_removals: Vec::new(), - remote_served: Vec::new(), - } - } -} - impl RepoState {} const STATE_FILE: &str = "walgit-state.json"; diff --git a/crates/walgit-wal/src/sync.rs b/crates/walgit-wal/src/sync.rs index 978a10d..3fc77e6 100644 --- a/crates/walgit-wal/src/sync.rs +++ b/crates/walgit-wal/src/sync.rs @@ -1,4 +1,4 @@ -//! sync() implementation: freshness check, catch-up, materialization. +//! `sync()` implementation: freshness check, catch-up, materialization. use std::sync::Arc; @@ -11,13 +11,13 @@ use walgit_proto::v1::{EntryKind, LogEntry, Manifest, PackRef, RefSnapshot}; use walgit_store::{GetOptions, GetResult, ObjectStore, Prefixed, Version}; /// A read guard held for the lifetime of a request. While any guard is alive -/// no pack is removed locally (the inner RwLock read guard prevents it). +/// no pack is removed locally (the inner `RwLock` read guard prevents it). pub struct ReadGuard<'a> { pub(crate) _guard: tokio::sync::RwLockReadGuard<'a, ()>, pub(crate) handle: &'a super::handle::RepoHandle, } -impl<'a> ReadGuard<'a> { +impl ReadGuard<'_> { pub fn manifest(&self) -> Arc { self.handle.manifest.read().clone() } @@ -76,6 +76,10 @@ impl PackPlan { } } +#[allow( + clippy::large_enum_variant, + reason = "the large variant is the common one; boxing it would allocate on every successful sync" +)] /// Result of a sync operation, holding either a read guard (common case) or /// indicating the repo was not found. pub(crate) enum SyncOutcome { @@ -89,7 +93,7 @@ pub(crate) enum SyncOutcome { /// Perform a conditional GET on manifest.pb and return the outcome. pub(crate) async fn freshness_check( store: &Prefixed, - known: &Option, + known: Option<&Version>, ) -> Result { match known { Some(v) => match get_message_if_changed::(store, keys::MANIFEST, v).await? { @@ -320,7 +324,7 @@ fn side_files(pack: &PackRef) -> [(bool, &'static str, String); 3] { /// NIC's worth), with bounded memory (PAR * CHUNK). /// `progress(delta_bytes, total_bytes)` is called as chunks land (callers /// throttle). `known_size` skips the happy-path HEAD (ROUNDTRIPS: HEAD ≈ GET; -/// PackRef already carries pack/idx sizes). +/// `PackRef` already carries pack/idx sizes). pub(crate) type ProgressFn<'a> = &'a (dyn Fn(u64, u64) + Send + Sync); fn nonzero(n: u64) -> Option { @@ -380,7 +384,9 @@ pub(crate) async fn download_object( let file = std::fs::File::create(dest)?; file.set_len(size)?; let file = std::sync::Arc::new(file); - let starts: Vec = (0..size).step_by(CHUNK as usize).collect(); + let starts: Vec = (0..size) + .step_by(usize::try_from(CHUNK).unwrap_or(usize::MAX)) + .collect(); let report = &report; futures::stream::iter(starts) .map(|start| { @@ -402,7 +408,11 @@ pub(crate) async fn download_object( return Err(WalError::Corrupt(format!("unexpected 304 for {key}"))); } }; - let bytes = walgit_store::util::collect(body, (end - start) as usize).await?; + let bytes = walgit_store::util::collect( + body, + usize::try_from(end - start).unwrap_or(usize::MAX), + ) + .await?; if bytes.len() as u64 != end - start { return Err(WalError::Corrupt(format!( "short range read for {key}: {}..{} got {}", @@ -438,7 +448,7 @@ pub(crate) async fn apply_delta( // If we have a checkpoint and haven't loaded it yet, load its refs. Its // packs are a subset of `Manifest.packs` and are reconciled below. - let checkpoint_seq = new_manifest.checkpoint.as_ref().map(|c| c.seq).unwrap_or(0); + let checkpoint_seq = new_manifest.checkpoint.as_ref().map_or(0, |c| c.seq); let need_checkpoint_load = checkpoint_seq > 0 && current_state.applied_seq < checkpoint_seq; // The checkpoint's times feed `first_state_time` / `refs_as_of`; old refs @@ -563,10 +573,10 @@ pub(crate) async fn reconcile_packs_inner( } { let mut st = handle.state.lock(); - st.remote_served = remote_served.clone(); + st.remote_served.clone_from(&remote_served); } let remote_set: std::collections::HashSet<&str> = - remote_served.iter().map(|s| s.as_str()).collect(); + remote_served.iter().map(String::as_str).collect(); // History packs (D18) are an accelerator, not a requirement: a fetch can // be served from the linked/remote base right away. They are installed by @@ -613,7 +623,7 @@ pub(crate) async fn reconcile_packs_inner( tracing::info!(repo = %handle.id, pack = %p.checksum, ext, "side-file installed for an installed pack"); } Err(e) => { - tracing::warn!(repo = %handle.id, pack = %p.checksum, ext, error = %e, "side-file download failed") + tracing::warn!(repo = %handle.id, pack = %p.checksum, ext, error = %e, "side-file download failed"); } } } @@ -684,7 +694,7 @@ pub(crate) async fn reconcile_packs_inner( let link_to = link_target(&p); tasks.push(tokio::spawn( async move { - let _permit = sem.acquire().await.unwrap(); + let _permit = sem.acquire().await.ok(); // Per-object progress arrives as absolute (done,total); turn it // into deltas for the shared counter. let cb = |delta: u64, _t: u64| { @@ -739,19 +749,16 @@ pub(crate) async fn reconcile_packs_inner( }) .collect::>()?; if !to_remove.is_empty() { - match handle.rw.try_write() { - Ok(_w) => { - for (_, oid) in &to_remove { - if local.pack_path(oid).exists() { - local.remove_pack(oid)?; - removed += 1; - } + if let Ok(_w) = handle.rw.try_write() { + for (_, oid) in &to_remove { + if local.pack_path(oid).exists() { + local.remove_pack(oid)?; + removed += 1; } } - Err(_) => { - tracing::info!(repo = %handle.id, packs = to_remove.len(), "superseded packs kept for now: readers active; retried on the next sync"); - still_pending.extend(to_remove.iter().map(|(s, _)| s.clone())); - } + } else { + tracing::info!(repo = %handle.id, packs = to_remove.len(), "superseded packs kept for now: readers active; retried on the next sync"); + still_pending.extend(to_remove.iter().map(|(s, _)| s.clone())); } } span.record("removed", removed); @@ -792,10 +799,10 @@ pub(crate) async fn maintain_commit_graph( Ok(Ok(true)) => base_changed = true, Ok(Ok(false)) => {} Ok(Err(e)) => { - tracing::warn!(pack = %p.checksum, error = %e, "commit-graph base install failed") + tracing::warn!(pack = %p.checksum, error = %e, "commit-graph base install failed"); } Err(e) => { - tracing::warn!(pack = %p.checksum, error = %e, "commit-graph base install task failed") + tracing::warn!(pack = %p.checksum, error = %e, "commit-graph base install task failed"); } } } @@ -831,11 +838,11 @@ pub(crate) async fn maintain_commit_graph( { tracing::warn!(repo = %handle.id, error = %e, "commit-graph update failed"); } else { - tracing::info!(repo = %handle.id, packs = packs.len(), ms = started.elapsed().as_millis() as u64, "commit-graph updated"); + tracing::info!(repo = %handle.id, packs = packs.len(), ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX), "commit-graph updated"); } } -/// Replay log entries in (from_seq, to_seq] from the manifest's log segments. +/// Replay log entries in (`from_seq`, `to_seq`] from the manifest's log segments. pub(crate) async fn replay_log( handle: &super::handle::RepoHandle, manifest: &Manifest, @@ -867,9 +874,13 @@ pub(crate) async fn replay_log( async move { let res = store.get(&key, GetOptions::default()).await?; Ok::, WalError>(match res { - GetResult::Object { meta, body } => { - Some(walgit_store::util::collect(body, meta.size as usize).await?) - } + GetResult::Object { meta, body } => Some( + walgit_store::util::collect( + body, + usize::try_from(meta.size).unwrap_or(usize::MAX), + ) + .await?, + ), GetResult::NotModified { .. } => None, }) } @@ -946,9 +957,8 @@ pub(crate) fn apply_entries( EntryKind::Compact => { supersedes.extend(entry.supersedes.iter().cloned()); } - EntryKind::Checkpoint => {} + EntryKind::Checkpoint | EntryKind::Settings => {} // Settings live on the manifest; the entry is history only. - EntryKind::Settings => {} EntryKind::Unspecified => { tracing::warn!(seq = entry.seq, "unspecified log entry kind, skipping"); } @@ -992,6 +1002,47 @@ pub(crate) async fn materialize_from_scratch( .await } +/// The **bulk runtime**: a small dedicated tokio runtime (own worker threads) +/// that runs pack materialization (striped downloads, 32 MiB chunk copies, +/// tmpfs writes, install renames, gix reopen, commit-graph/midx subprocess +/// waits). Whatever inside that path is CPU-heavy or secretly blocking can +/// only delay other bulk work — request workers on the main runtime keep +/// serving refs in milliseconds (prod 2026-08-20: the main runtime stalled +/// 2.6–43 s repeatedly for the whole duration of one repo's 7.5 GB + another's +/// 12 GB materializations; the watchdog caught it, the cause hid among a dozen +/// candidates; isolation makes the question moot). +static BULK_RUNTIME: std::sync::OnceLock = std::sync::OnceLock::new(); + +#[allow( + clippy::expect_used, + reason = "the bulk runtime is built once at startup and there is no caller to hand a failure to" +)] +fn bulk_runtime() -> &'static tokio::runtime::Runtime { + BULK_RUNTIME.get_or_init(|| { + tokio::runtime::Builder::new_multi_thread() + .worker_threads(4) + .thread_name("walgit-bulk") + .enable_all() + .build() + .expect("bulk runtime") + }) +} + +/// Run `fut` on the bulk runtime and await its result from the caller's +/// runtime. The future must be `'static + Send` (use `Arc`). +pub(crate) async fn on_bulk_runtime( + fut: impl std::future::Future> + Send + 'static, +) -> Result { + let span = tracing::Span::current(); + let (tx, rx) = tokio::sync::oneshot::channel(); + bulk_runtime().spawn(async move { + let r = fut.instrument(span).await; + let _ = tx.send(r); + }); + rx.await + .map_err(|_| WalError::Corrupt("bulk runtime task dropped".into()))? +} + #[cfg(test)] mod download_tests { use super::download_object; @@ -1002,12 +1053,12 @@ mod download_tests { // > CHUNK (32 MiB) so the ranged/striped path runs, with a ragged tail. let size = 70 * 1024 * 1024 + 12345; let mut data = vec![0u8; size]; - let mut x: u64 = 0x9E3779B97F4A7C15; - for b in data.iter_mut() { + let mut x: u64 = 0x9E37_79B9_7F4A_7C15; + for b in &mut data { x ^= x << 13; x ^= x >> 7; x ^= x << 17; - *b = x as u8; + *b = u8::try_from(x & 0xFF).unwrap_or(0); } let store = MemoryStore::shared(); store @@ -1032,40 +1083,3 @@ mod download_tests { assert_eq!(std::fs::read(&small).unwrap(), b"tiny"); } } - -/// The **bulk runtime**: a small dedicated tokio runtime (own worker threads) -/// that runs pack materialization (striped downloads, 32 MiB chunk copies, -/// tmpfs writes, install renames, gix reopen, commit-graph/midx subprocess -/// waits). Whatever inside that path is CPU-heavy or secretly blocking can -/// only delay other bulk work — request workers on the main runtime keep -/// serving refs in milliseconds (prod 2026-08-20: the main runtime stalled -/// 2.6–43 s repeatedly for the whole duration of one repo's 7.5 GB + another's -/// 12 GB materializations; the watchdog caught it, the cause hid among a dozen -/// candidates; isolation makes the question moot). -static BULK_RUNTIME: std::sync::OnceLock = std::sync::OnceLock::new(); - -fn bulk_runtime() -> &'static tokio::runtime::Runtime { - BULK_RUNTIME.get_or_init(|| { - tokio::runtime::Builder::new_multi_thread() - .worker_threads(4) - .thread_name("walgit-bulk") - .enable_all() - .build() - .expect("bulk runtime") - }) -} - -/// Run `fut` on the bulk runtime and await its result from the caller's -/// runtime. The future must be `'static + Send` (use `Arc`). -pub(crate) async fn on_bulk_runtime( - fut: impl std::future::Future> + Send + 'static, -) -> Result { - let span = tracing::Span::current(); - let (tx, rx) = tokio::sync::oneshot::channel(); - bulk_runtime().spawn(async move { - let r = fut.instrument(span).await; - let _ = tx.send(r); - }); - rx.await - .map_err(|_| WalError::Corrupt("bulk runtime task dropped".into()))? -} diff --git a/crates/walgit-wal/src/tasks.rs b/crates/walgit-wal/src/tasks.rs index 96caaae..1c76fba 100644 --- a/crates/walgit-wal/src/tasks.rs +++ b/crates/walgit-wal/src/tasks.rs @@ -105,6 +105,10 @@ impl TaskState { pub fn record(&self) -> TaskRecord { self.record.lock().clone() } + #[allow( + clippy::type_complexity, + reason = "returns the snapshot and its subscription together; both halves are used at the single call site" + )] /// Subscribe + snapshot of everything so far (no gap, no duplicates). pub fn attach( &self, @@ -157,7 +161,8 @@ impl TaskState { Progress::Progress { .. } => rec.progress = Some(p.clone()), Progress::Task { .. } => {} } - rec.elapsed_ms = self.started_at.elapsed().as_millis() as u64; + rec.elapsed_ms = + u64::try_from(self.started_at.elapsed().as_millis()).unwrap_or(u64::MAX); } { let mut replay = self.replay.lock(); @@ -389,15 +394,16 @@ impl Tasks { let record = { let mut rec = state.record.lock(); rec.finished = Some(now_rfc3339()); - rec.elapsed_ms = state.started_at.elapsed().as_millis() as u64; + rec.elapsed_ms = + u64::try_from(state.started_at.elapsed().as_millis()).unwrap_or(u64::MAX); match &outcome { Ok((summary, _)) => { rec.ok = Some(true); - rec.summary = summary.clone(); + rec.summary.clone_from(summary); } Err((_, msg)) => { rec.ok = Some(false); - rec.summary = msg.clone(); + rec.summary.clone_from(msg); } } rec.clone() @@ -431,7 +437,7 @@ impl Tasks { pick_from .as_ref() .and_then(|v| v.get(k)) - .and_then(|v| v.as_u64()) + .and_then(serde_json::Value::as_u64) }; let (bytes, objects) = (pick("bytes").or_else(|| pick("size")), pick("objects")); let ok = record.ok.unwrap_or(false); diff --git a/crates/walgit-wal/tests/wal.rs b/crates/walgit-wal/tests/wal.rs index ef7b34e..8e84d79 100644 --- a/crates/walgit-wal/tests/wal.rs +++ b/crates/walgit-wal/tests/wal.rs @@ -1,9 +1,21 @@ //! Integration tests for walgit-wal. //! -//! Uses MemoryStore + real LocalRepo tempdir + upstream git to create +//! Uses `MemoryStore` + real `LocalRepo` tempdir + upstream git to create //! objects/packs. +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing, + clippy::many_single_char_names +)] +// clippy.toml exempts #[test] functions from the panic-path lints, but not the plain +// helper functions beside them in the same file. A panic in a fixture builder is how +// that fixture reports it could not be built, exactly as in the tests it serves. + use std::collections::HashMap; +use std::fmt::Write as _; use std::path::Path; use std::process::{Command, Stdio}; use std::sync::Arc; @@ -116,22 +128,24 @@ impl WorkRepo { /// Create a pack containing objects reachable from `head` but not from `base`. fn create_incremental_pack(&self, head: &str, base: &str) -> Vec { // Use rev-list to enumerate objects, pipe to pack-objects. - let rev_list = Command::new("git") + let mut rev_list = Command::new("git") .args(["rev-list", "--objects", head, "--not", base]) .current_dir(self.path()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .unwrap(); + let rev_list_stdout = rev_list.stdout.take().unwrap(); let pack = Command::new("git") .args(["pack-objects", "--stdout"]) .current_dir(self.path()) - .stdin(rev_list.stdout.unwrap()) + .stdin(rev_list_stdout) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .unwrap(); let out = pack.wait_with_output().unwrap(); + rev_list.wait().unwrap(); assert!( out.status.success(), "pack-objects failed: {}", @@ -832,7 +846,7 @@ async fn test_orphan_log_invisible_and_cleaned() { .store() .put( &orphan_key, - bytes::Bytes::from(orphan_bytes).into(), + orphan_bytes.into(), walgit_store::PutMode::Create.into(), ) .await @@ -950,7 +964,7 @@ async fn test_serve_level_links_base_from_store_mount() { x ^= x << 13; x ^= x >> 7; x ^= x << 17; - body.push_str(&format!("{x:016x}")); + let _ = write!(body, "{x:016x}"); } let c = work.commit(&format!("base_{i}"), &body); let pack = if prev.is_empty() { @@ -1189,10 +1203,12 @@ async fn test_serve_level_links_base_from_store_mount() { fn checkpoint_due_triggers() { use walgit_proto::v1::{CheckpointRef, LogSegmentRef, Manifest}; use walgit_wal::{CheckpointTrigger, checkpoint_due}; - let mut cfg = walgit_config::WalConfig::default(); - cfg.snapshot_every_entries = 10; - cfg.checkpoint_interval = Duration::from_secs(3600); - cfg.checkpoint_tail_bytes = walgit_config::ByteSize::kib(1); + let mut cfg = walgit_config::WalConfig { + snapshot_every_entries: 10, + checkpoint_interval: Duration::from_hours(1), + checkpoint_tail_bytes: walgit_config::ByteSize::kib(1), + ..Default::default() + }; let seg = |first: u64, last: u64, size: u64| LogSegmentRef { key: String::new(), first_seq: first, @@ -1221,7 +1237,7 @@ fn checkpoint_due_triggers() { assert_eq!(checkpoint_due(&m, &cfg), Some(CheckpointTrigger::TailBytes)); m.log_segments = vec![seg(1, 3, 100)]; - let old = std::time::SystemTime::now() - Duration::from_secs(7200); + let old = std::time::SystemTime::now() - Duration::from_hours(2); m.updated_at = Some(walgit_proto::time::from_system(old)); assert_eq!( checkpoint_due(&m, &cfg), @@ -1271,6 +1287,7 @@ fn checkpoint_due_triggers() { /// from checkpoint + tail. #[tokio::test] async fn test_checkpoint_from_refs_level_instance() { + use prost::Message; let cache = tempfile::tempdir().unwrap(); let store = MemoryStore::shared(); let registry = Registry::new(store.clone(), Arc::new(make_config(cache.path(), 0))); @@ -1349,7 +1366,6 @@ async fn test_checkpoint_from_refs_level_instance() { assert_eq!(handle2.checkpoint_due(), None); // The checkpoint object carries the pack inventory with side-file flags. - use prost::Message; let (_, bytes) = walgit_store::ObjectStoreExt::get_bytes(handle2.store(), &cp.key) .await .unwrap() @@ -1393,7 +1409,7 @@ async fn test_serve_level_remote_serves_base_without_mount() { x ^= x << 13; x ^= x >> 7; x ^= x << 17; - body.push_str(&format!("{x:016x}")); + let _ = write!(body, "{x:016x}"); } let c = work.commit(&format!("base_{i}"), &body); let pack = if prev.is_empty() { @@ -1524,7 +1540,7 @@ async fn test_serve_level_remote_serves_base_without_mount() { assert_eq!(stats.objects, 3, "{stats:?}"); let (faulted, rounds) = faulter.stats(); assert!( - faulted >= 1 && faulted <= 3, + (1..=3).contains(&faulted), "faulted {faulted} (parent commit + root tree)" ); assert!(rounds <= 3); @@ -1600,6 +1616,7 @@ async fn test_annotate_pack_retrofits_commit_graph() { /// so `sync_refs()` on a cold instance answers while packs still download. #[tokio::test] async fn test_refs_sync_is_not_blocked_by_pack_materialization() { + use futures::StreamExt; let cache = tempfile::tempdir().unwrap(); let store = MemoryStore::shared(); let registry = Registry::new(store.clone(), Arc::new(make_config(cache.path(), 0))); @@ -1634,7 +1651,6 @@ async fn test_refs_sync_is_not_blocked_by_pack_materialization() { inner.latency = Some(Duration::from_millis(150)); } // Copy the data over. - use futures::StreamExt; let mut keys = store.list("", None); while let Some(m) = keys.next().await { let m = m.unwrap(); @@ -1707,7 +1723,7 @@ async fn test_history_pack_keeps_tree_walks_local() { x ^= x << 13; x ^= x >> 7; x ^= x << 17; - body.push_str(&format!("{x:016x}")); + let _ = write!(body, "{x:016x}"); } std::fs::create_dir_all(work.path().join(format!("d{i}/sub"))).unwrap(); std::fs::write(work.path().join(format!("d{i}/sub/big.bin")), &body).unwrap(); @@ -1930,7 +1946,7 @@ async fn test_history_pack_keeps_tree_walks_local() { /// A long-lived read guard (a clone streaming for minutes) plus a pack /// removal that wants the write lock must not block new refs-level syncs: -/// a queued writer on a tokio RwLock stalls every new reader (prod: info/refs +/// a queued writer on a tokio `RwLock` stalls every new reader (prod: info/refs /// waited 60–680 s behind one 24-minute clone). Removal is try-only now. #[tokio::test] async fn test_refs_sync_never_waits_behind_a_long_read_guard() { @@ -2074,7 +2090,8 @@ async fn test_publish_at_explicit_monotonic_created_at() { let t = |s: &str| { std::time::UNIX_EPOCH + Duration::from_secs( - chrono::DateTime::parse_from_rfc3339(s).unwrap().timestamp() as u64 + u64::try_from(chrono::DateTime::parse_from_rfc3339(s).unwrap().timestamp()) + .unwrap_or(0), ) }; // Slot 1: main = c1 at Aug 10. @@ -2133,7 +2150,7 @@ async fn test_publish_at_explicit_monotonic_created_at() { .iter() .map(|e| e.created_at.as_ref().unwrap().seconds) .collect(); - assert_eq!(times, vec![1786402800, 1786489200, 1786575600]); + assert_eq!(times, vec![1_786_402_800, 1_786_489_200, 1_786_575_600]); // As-of cuts per slot. let (s, seq) = handle.refs_as_of(t("2026-08-11T23:30:00Z")).await.unwrap(); assert_eq!(seq, 2); @@ -2252,7 +2269,8 @@ async fn test_checkpoint_carries_first_state_and_as_of() { let t = |s: &str| { std::time::UNIX_EPOCH + Duration::from_secs( - chrono::DateTime::parse_from_rfc3339(s).unwrap().timestamp() as u64 + u64::try_from(chrono::DateTime::parse_from_rfc3339(s).unwrap().timestamp()) + .unwrap_or(0), ) }; let ingested = ingest_pack_data(&handle, work.create_pack()).await.unwrap(); @@ -2341,6 +2359,8 @@ async fn test_checkpoint_carries_first_state_and_as_of() { /// entry (every slot in between planned as "unavailable" in prod). #[tokio::test] async fn test_first_state_time_uses_the_checkpoint_when_early_entries_are_untimestamped() { + use prost::Message; + use walgit_store::ObjectStoreExt; let cache = tempfile::tempdir().unwrap(); let store = MemoryStore::shared(); let registry = Registry::new(store.clone(), Arc::new(make_config(cache.path(), 0))); @@ -2352,7 +2372,8 @@ async fn test_first_state_time_uses_the_checkpoint_when_early_entries_are_untime let t = |s: &str| { std::time::UNIX_EPOCH + Duration::from_secs( - chrono::DateTime::parse_from_rfc3339(s).unwrap().timestamp() as u64 + u64::try_from(chrono::DateTime::parse_from_rfc3339(s).unwrap().timestamp()) + .unwrap_or(0), ) }; let ingested = ingest_pack_data(&handle, work.create_pack()).await.unwrap(); @@ -2378,8 +2399,6 @@ async fn test_first_state_time_uses_the_checkpoint_when_early_entries_are_untime // Rewrite the bucket the way 2026-08-19 wrote it: checkpoint ref without // first_state_at/as_of, created on 08-02; log entry 2 without created_at. - use prost::Message; - use walgit_store::ObjectStoreExt; let mkey = format!("{}{}", id.store_prefix(), walgit_proto::keys::MANIFEST); let (_, bytes) = store.get_bytes(&mkey).await.unwrap().unwrap(); let mut m = walgit_proto::v1::Manifest::decode(bytes.as_ref()).unwrap(); @@ -2446,6 +2465,8 @@ async fn test_first_state_time_uses_the_checkpoint_when_early_entries_are_untime /// state" and the bundler cut it from today's main (prod 2026-08-21 04:2xZ). #[tokio::test] async fn test_checkpoint_times_come_from_the_object_when_the_ref_has_none() { + use prost::Message; + use walgit_store::ObjectStoreExt; let cache = tempfile::tempdir().unwrap(); let store = MemoryStore::shared(); let registry = Registry::new(store.clone(), Arc::new(make_config(cache.path(), 0))); @@ -2457,7 +2478,8 @@ async fn test_checkpoint_times_come_from_the_object_when_the_ref_has_none() { let t = |s: &str| { std::time::UNIX_EPOCH + Duration::from_secs( - chrono::DateTime::parse_from_rfc3339(s).unwrap().timestamp() as u64 + u64::try_from(chrono::DateTime::parse_from_rfc3339(s).unwrap().timestamp()) + .unwrap_or(0), ) }; let ingested = ingest_pack_data(&handle, work.create_pack()).await.unwrap(); @@ -2482,8 +2504,6 @@ async fn test_checkpoint_times_come_from_the_object_when_the_ref_has_none() { .unwrap(); // Strip the ref's times (08-19 import shape); stamp the object 08-19 21:33Z. - use prost::Message; - use walgit_store::ObjectStoreExt; let mkey = format!("{}{}", id.store_prefix(), walgit_proto::keys::MANIFEST); let (_, bytes) = store.get_bytes(&mkey).await.unwrap().unwrap(); let mut m = walgit_proto::v1::Manifest::decode(bytes.as_ref()).unwrap(); From ed21b5318cd35c6181d6058ab13ceb9fcc64f18a Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Tue, 1 Sep 2026 06:14:24 -0400 Subject: [PATCH 2/2] build(just): keep the fast test tier's watchdog off the compile The watchdog is there to catch a hung test, but each line's package selection resolves features on its own, so a line can spend the whole budget rebuilding dependencies and die before a single test runs. Signed-off-by: Yordis Prieto --- justfile | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/justfile b/justfile index 84f5090..e9ea5de 100644 --- a/justfile +++ b/justfile @@ -8,6 +8,10 @@ t5 := `if command -v timeout >/dev/null 2>&1; then echo "timeout 300"; elif comm t10 := `if command -v timeout >/dev/null 2>&1; then echo "timeout 600"; elif command -v gtimeout >/dev/null 2>&1; then echo "gtimeout 600"; else echo ""; fi` t15 := `if command -v timeout >/dev/null 2>&1; then echo "timeout 900"; elif command -v gtimeout >/dev/null 2>&1; then echo "gtimeout 900"; else echo ""; fi` +# The fast tier's package selections, shared by the build and the run of each line. +fast_pkgs := "-p walgit-store -p walgit-git -p walgit-wal -p walgit-bundle" +server_fast := "-p walgit-server --test web_api --test web_ui --test api_v1 --test static_http --test maintain --test routing_prefix --test lfs_upstream --test drain" + # Default: show available targets. default: @just --list @@ -80,10 +84,16 @@ dev-store-stop: # Fast tier (default, < 1 min): unit tests + the quick integration suites. # Never run `cargo test --workspace --no-fail-fast` interactively: a single # hung test blocks for the whole timeout. Use `just e2e` / `just ci` below. +# Each line builds before it runs, and every package selection resolves features on its own, +# so a line can spend minutes recompiling dependencies the previous line already built. The +# watchdog is here to catch a hung test, not a cold compile: build untimed first, run under it. test: + cargo test --workspace --lib --bins --no-run {{t5}} cargo test --workspace --lib --bins - {{t5}} cargo test -p walgit-store -p walgit-git -p walgit-wal -p walgit-bundle --tests - {{t5}} cargo test -p walgit-server --test web_api --test web_ui --test api_v1 --test static_http --test maintain --test routing_prefix --test lfs_upstream --test drain + cargo test {{fast_pkgs}} --tests --no-run + {{t5}} cargo test {{fast_pkgs}} --tests + cargo test {{server_fast}} --no-run + {{t5}} cargo test {{server_fast}} # Smart-HTTP end-to-end against real git (≈ 20 s) — run when touching smart.rs/receive/upload-pack/wal. e2e *ARGS: