diff --git a/Cargo.lock b/Cargo.lock index c34874e0f..5faddfe67 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2425,11 +2425,11 @@ dependencies = [ [[package]] name = "hashlink" -version = "0.10.0" +version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb" dependencies = [ - "hashbrown 0.15.5", + "hashbrown 0.17.1", ] [[package]] @@ -3221,9 +3221,9 @@ dependencies = [ [[package]] name = "libsqlite3-sys" -version = "0.32.0" +version = "0.38.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbb8270bb4060bd76c6e96f20c52d80620f1d82a3470885694e41e0f81ef6fe7" +checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8" dependencies = [ "cc", "pkg-config", @@ -3466,6 +3466,7 @@ dependencies = [ "rcgen", "reqwest", "roxmltree", + "rusqlite", "rustix", "same-file", "secrecy", @@ -3519,6 +3520,7 @@ dependencies = [ "sha2", "shlex 1.3.0", "smallvec", + "static_assertions", "subtle", "tempfile", "thiserror 2.0.18", @@ -3681,6 +3683,7 @@ dependencies = [ "base64", "chrono", "futures-util", + "libc", "lpm-common", "lpm-http", "lpm-tunnel", @@ -3693,6 +3696,7 @@ dependencies = [ "serde_json", "subtle", "tempfile", + "thiserror 2.0.18", "tokio", "tower", "tower-http", @@ -6247,6 +6251,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rsqlite-vfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" +dependencies = [ + "hashbrown 0.16.1", + "thiserror 2.0.18", +] + [[package]] name = "rtoolbox" version = "0.0.5" @@ -6259,9 +6273,9 @@ dependencies = [ [[package]] name = "rusqlite" -version = "0.34.0" +version = "0.40.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37e34486da88d8e051c7c0e23c3f15fd806ea8546260aa2fec247e97242ec143" +checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" dependencies = [ "bitflags 2.13.0", "fallible-iterator", @@ -6269,6 +6283,7 @@ dependencies = [ "hashlink", "libsqlite3-sys", "smallvec", + "sqlite-wasm-rs", ] [[package]] @@ -6891,6 +6906,18 @@ dependencies = [ "der", ] +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" diff --git a/crates/lpm-cli/Cargo.toml b/crates/lpm-cli/Cargo.toml index bf484dff3..2a5a738f3 100644 --- a/crates/lpm-cli/Cargo.toml +++ b/crates/lpm-cli/Cargo.toml @@ -245,6 +245,8 @@ wiremock = "0.6" assert_cmd = "2" insta = { version = "1", features = ["json"] } lpm-vault = { workspace = true, features = ["acceptance-test-hooks"] } +# Damages a test project's capture database to exercise disk failures. +rusqlite = { version = "0.40", features = ["bundled"] } # Phase 46 P4 Chunk 2: generate synthetic x509 certs with URI SANs in # unit tests so the SAN-extractor is exercised against deterministic diff --git a/crates/lpm-cli/src/commands/cache_prune.rs b/crates/lpm-cli/src/commands/cache_prune.rs index 9e9f1d7e1..c026b854c 100644 --- a/crates/lpm-cli/src/commands/cache_prune.rs +++ b/crates/lpm-cli/src/commands/cache_prune.rs @@ -303,7 +303,7 @@ fn run_all_virtual_stores_locked( ) -> Result { let free_before = flags .apply - .then(|| filesystem_available_bytes(v3_store.paths().root())) + .then(|| lpm_common::filesystem_available_bytes(v3_store.paths().root())) .flatten(); let stores = [v2_store, v3_store]; let directories = [ @@ -353,7 +353,7 @@ fn run_all_virtual_stores_locked( } let mut summary = merge_prune_summaries(v2, v3); summary.observed_physical_bytes_freed = free_before.and_then(|before| { - filesystem_available_bytes(v3_store.paths().root())?.checked_sub(before) + lpm_common::filesystem_available_bytes(v3_store.paths().root())?.checked_sub(before) }); Ok(summary) } @@ -1498,35 +1498,6 @@ fn dir_size(dir: &Path) -> std::io::Result { Ok(total) } -#[cfg(unix)] -fn filesystem_available_bytes(path: &Path) -> Option { - use std::ffi::CString; - use std::os::unix::ffi::OsStrExt; - - let mut probe = path.to_path_buf(); - while !probe.try_exists().ok()? { - if !probe.pop() { - return None; - } - } - let path = CString::new(probe.as_os_str().as_bytes()).ok()?; - let mut stats = std::mem::MaybeUninit::::uninit(); - // SAFETY: `path` is NUL-terminated and `stats` points to writable, - // properly aligned storage that is read only after a successful call. - if unsafe { libc::statvfs(path.as_ptr(), stats.as_mut_ptr()) } != 0 { - return None; - } - // SAFETY: `statvfs` returned success and initialized the output structure. - let stats = unsafe { stats.assume_init() }; - let bytes = u128::from(stats.f_bavail).checked_mul(u128::from(stats.f_frsize))?; - u64::try_from(bytes).ok() -} - -#[cfg(not(unix))] -fn filesystem_available_bytes(_path: &Path) -> Option { - None -} - /// LRU + crash-recovery orphan detection for the compat-island cache at /// `~/.lpm/store/v2/compat/`. /// diff --git a/crates/lpm-cli/src/commands/dev.rs b/crates/lpm-cli/src/commands/dev.rs index eac8232c7..724b488a6 100644 --- a/crates/lpm-cli/src/commands/dev.rs +++ b/crates/lpm-cli/src/commands/dev.rs @@ -707,9 +707,10 @@ fn publish_initial_runtime( && let Err(finalize_error) = publication.finalize() { let error = rollback_inspector_session_after( - LpmError::Tunnel(format!( - "failed to publish the inspector session: {finalize_error}" - )), + crate::commands::tunnel::inspector_write_error( + "publish the inspector session", + finalize_error, + ), published_inspector_session, ); return Err(rollback_published_proxy_after( @@ -748,9 +749,33 @@ fn rollback_inspector_session_after( }; match publication.rollback() { Ok(()) => error, - Err(rollback_error) => LpmError::Tunnel(format!( - "{error}; restoring the inspector session also failed: {rollback_error}" + Err(rollback_error) => error_also( + error, + &format!("restoring the inspector session also failed: {rollback_error}"), + ), + } +} + +/// An error's message, without the prefix its kind adds. +fn error_detail(error: &LpmError) -> String { + match error { + LpmError::Io(error) => error.to_string(), + LpmError::Tunnel(message) => message.clone(), + other => other.to_string(), + } +} + +/// `first` with `also` added to its message. A disk error stays one when a +/// tunnel cleanup fails after it, and a tunnel error stays a tunnel's; any +/// other is reported as the tunnel's, as it was raised running one. +fn error_also(first: LpmError, also: &str) -> LpmError { + match first { + LpmError::Io(error) => LpmError::Io(std::io::Error::new( + error.kind(), + format!("{error}; {also}"), )), + LpmError::Tunnel(message) => LpmError::Tunnel(format!("{message}; {also}")), + other => LpmError::Tunnel(format!("{other}; {also}")), } } @@ -960,7 +985,7 @@ fn publish_tunnel_connection( prepare_tunnel_connection(inspector_state, publication)? .finalize() .map_err(|error| { - LpmError::Tunnel(format!("failed to publish the inspector session: {error}")) + crate::commands::tunnel::inspector_write_error("publish the inspector session", error) })?; render_tunnel_connection(publication); Ok(()) @@ -979,9 +1004,7 @@ fn prepare_tunnel_connection( publication.session.tunnel_url.clone(), ) .map_err(|error| { - LpmError::Tunnel(format!( - "failed to persist inspector session start: {error}" - )) + crate::commands::tunnel::inspector_write_error("persist inspector session start", error) }) } @@ -1554,12 +1577,8 @@ pub async fn run( // It runs alongside the dashboard's webhook tab (which is // text-only) so the user can pick whichever fits the moment. // Quiet by default — we never auto-open a browser tab here. - let inspector_db = lpm_inspect::db::InspectorDb::open(project_dir).map_err(|error| { - LpmError::Tunnel(format!( - "failed to open this project's .lpm/inspector.db: {error}. \ - Repair or move the corrupt database and retry" - )) - })?; + let inspector_db = crate::commands::tunnel::open_inspector_db(project_dir)?; + let capture_store = crate::commands::tunnel::CaptureStore::of(&inspector_db); let inspector_state = lpm_inspect::state::InspectorState::with_db_pending(inspector_db); multi_inspector_state = Some(inspector_state.clone()); if !no_inspect { @@ -1588,7 +1607,7 @@ pub async fn run( let (webhook_tx, mut webhook_rx) = tokio::sync::mpsc::channel::(64); - crate::commands::tunnel::warn_capture_persistence(!quiet); + crate::commands::tunnel::warn_capture_persistence(project_dir, capture_store, !quiet).await; // Dashboard webhook channel: when --dashboard is active, webhooks are // forwarded to the dashboard TUI via a std::sync channel. @@ -3266,14 +3285,8 @@ async fn finalize_multi_service_tunnel_after( None => Ok(()), }; let persistence_result = if let Some(state) = inspector_state { - let end_result = state.end_session().await.map_err(|error| { - LpmError::Tunnel(format!("failed to persist inspector session end: {error}")) - }); - let flush_result = state.flush().await.map_err(|error| { - LpmError::Tunnel(format!( - "failed to commit captures to .lpm/inspector.db: {error}" - )) - }); + let (end_result, flush_result) = + crate::commands::tunnel::finish_capture_session(&state).await; combine_tunnel_cleanup_results(end_result, flush_result) } else { Ok(()) @@ -3295,7 +3308,7 @@ fn combine_tunnel_cleanup_results( match (first, second) { (Ok(()), Ok(())) => Ok(()), (Err(error), Ok(())) | (Ok(()), Err(error)) => Err(error), - (Err(first), Err(second)) => Err(LpmError::Tunnel(format!("{first}; {second}"))), + (Err(first), Err(second)) => Err(error_also(first, &error_detail(&second))), } } @@ -3309,9 +3322,13 @@ fn compose_tunnel_runtime_and_cleanup( (Err(primary), Ok(())) => Err(primary), (Err(primary), Err(cleanup_error)) => { dev_ui::warn(&format!("tunnel capture cleanup failed: {cleanup_error}")); - Err(LpmError::Tunnel(format!( - "{primary}; tunnel capture cleanup also failed: {cleanup_error}" - ))) + Err(error_also( + primary, + &format!( + "tunnel capture cleanup also failed: {}", + error_detail(&cleanup_error) + ), + )) } } } @@ -4862,6 +4879,138 @@ mod tests { assert!(error.contains("inspector session end failed"), "{error}"); } + /// An inspector whose capture database lost its tables after it was + /// opened, so its every write fails as the disk's would. + fn inspector_whose_writes_fail( + project: &Path, + ) -> ( + lpm_inspect::state::InspectorState, + lpm_inspect::state::PublishedInspectorSession, + ) { + let db = lpm_inspect::db::InspectorDb::open(project).unwrap(); + let state = lpm_inspect::state::InspectorState::with_db(3000, db); + state + .start_session_immediate("running".to_string(), None, 3000, None) + .unwrap(); + let publication = state + .start_session_reversible( + "published".to_string(), + None, + 3000, + None, + "https://example.lpm.fyi".to_string(), + ) + .unwrap(); + rusqlite::Connection::open(project.join(".lpm/inspector.db")) + .unwrap() + .execute_batch( + "PRAGMA foreign_keys=OFF; DROP TABLE requests_fts; DROP TABLE requests; \ + DROP TABLE pending_sessions; DROP TABLE sessions;", + ) + .unwrap(); + (state, publication) + } + + fn ready_publication() -> TunnelReadyPublication { + TunnelReadyPublication { + session: lpm_tunnel::TunnelSession { + tunnel_url: "https://example.lpm.fyi".to_string(), + domain: "example.lpm.fyi".to_string(), + session_id: "session".to_string(), + local_port: 3000, + plan: None, + base_domain: None, + domain_kind: None, + limits: None, + }, + local_target_url: "http://127.0.0.1:3000".to_string(), + tunnel_auth: None, + usage: None, + } + } + + #[tokio::test] + async fn capture_store_failures_stay_disk_errors_through_dev_s_tunnel_lifecycle() { + let project = TempDir::new().unwrap(); + let (state, publication) = inspector_whose_writes_fail(project.path()); + + let started = prepare_tunnel_connection(&state, &ready_publication()) + .map(drop) + .unwrap_err(); + let rolled_back = rollback_inspector_session_after( + LpmError::Io(std::io::Error::other("disk full")), + Some(publication), + ); + state + .push(lpm_tunnel::webhook::CapturedWebhook { + id: "queued".to_string(), + timestamp: "2026-08-14T12:00:00Z".to_string(), + method: "POST".to_string(), + path: "/hook".to_string(), + request_headers: HashMap::new(), + request_body: b"request".to_vec(), + response_status: 202, + response_headers: HashMap::new(), + response_body: b"accepted".to_vec(), + duration_ms: 1, + provider: None, + summary: "queued capture".to_string(), + signature_diagnostic: None, + auto_acked: false, + response_body_incomplete: false, + }) + .await; + let finished = finalize_multi_service_tunnel_after(Ok(()), None, Some(state), None) + .await + .unwrap_err(); + + assert!( + matches!(&started, LpmError::Io(error) if error.to_string().contains("persist inspector session start")), + "{started:?}" + ); + assert!( + matches!(&rolled_back, LpmError::Io(error) if error.to_string().contains("disk full") + && error.to_string().contains("restoring the inspector session also failed")), + "{rolled_back:?}" + ); + assert!( + matches!(&finished, LpmError::Io(error) if error.to_string().contains("persist inspector session end") + && error.to_string().contains("commit captures")), + "{finished:?}" + ); + } + + #[test] + fn a_cleanup_failure_after_another_keeps_the_first_one_s_kind() { + let io = || { + LpmError::Io(std::io::Error::new( + std::io::ErrorKind::StorageFull, + "disk full", + )) + }; + let tunnel = || LpmError::Tunnel("relay closed".to_string()); + + let disk_first = combine_tunnel_cleanup_results(Err(io()), Err(tunnel())).unwrap_err(); + let tunnel_first = combine_tunnel_cleanup_results(Err(tunnel()), Err(io())).unwrap_err(); + let primary_disk = + compose_tunnel_runtime_and_cleanup(Err(io()), Err(tunnel())).unwrap_err(); + + assert!( + matches!(&disk_first, LpmError::Io(error) if error.kind() == std::io::ErrorKind::StorageFull + && error.to_string() == "disk full; relay closed"), + "{disk_first:?}" + ); + assert!( + matches!(&tunnel_first, LpmError::Tunnel(message) if message == "relay closed; disk full"), + "{tunnel_first:?}" + ); + assert!( + matches!(&primary_disk, LpmError::Io(error) + if error.to_string() == "disk full; tunnel capture cleanup also failed: relay closed"), + "{primary_disk:?}" + ); + } + #[test] fn tunnel_shutdown_boundary_returns_only_after_task_acknowledgement() { let (boundary, shutdown_rx, completion) = TunnelShutdownBoundary::new(); diff --git a/crates/lpm-cli/src/commands/install/gitignore.rs b/crates/lpm-cli/src/commands/install/gitignore.rs index 0b77cf964..6d1c270ab 100644 --- a/crates/lpm-cli/src/commands/install/gitignore.rs +++ b/crates/lpm-cli/src/commands/install/gitignore.rs @@ -107,11 +107,9 @@ pub(super) fn maintain_project_linker_layout(project_dir: &Path, json_output: bo /// — ensure `.gitignore` contains an entry for /// `.lpm/wrappers/`. /// -/// Mirrors [`ensure_skills_gitignore`] (and its siblings in -/// `lpm-vault::ensure_lpm_dir_gitignore` and -/// `commands::npmrc::ensure_lpm_dir_gitignore`) — runtime "ensure -/// once" pattern, idempotent, OpenOptions-append to minimize the -/// read-then-write TOCTOU window. +/// Mirrors [`ensure_skills_gitignore`] — runtime "ensure once" pattern, +/// idempotent, OpenOptions-append to minimize the read-then-write TOCTOU +/// window. /// /// Why runtime instead of template emission: existing projects also /// need the entry, and the repo has a precedent for runtime diff --git a/crates/lpm-cli/src/commands/npmrc.rs b/crates/lpm-cli/src/commands/npmrc.rs index d77229baf..5308d6e5f 100644 --- a/crates/lpm-cli/src/commands/npmrc.rs +++ b/crates/lpm-cli/src/commands/npmrc.rs @@ -535,9 +535,19 @@ pub(super) fn reject_tracked_npmrc(project_dir: &Path) -> Result<(), LpmError> { ))) } -fn hardened_git_command(project_dir: &Path, args: &[&str]) -> std::process::Command { +/// A git command for a project that may be untrusted: no `GIT_*` variable +/// from the environment, no `core.fsmonitor` program, which reading the +/// index would run, and no repository found as a bare one, which a cloned +/// project can contain with its own config. +pub(crate) fn hardened_git_command(project_dir: &Path, args: &[&str]) -> std::process::Command { let mut command = std::process::Command::new("git"); command + .args([ + "-c", + "core.fsmonitor=false", + "-c", + "safe.bareRepository=explicit", + ]) .args(args) .current_dir(project_dir) .env("LC_ALL", "C"); diff --git a/crates/lpm-cli/src/commands/tunnel.rs b/crates/lpm-cli/src/commands/tunnel.rs index b10b82c9e..58a7389b7 100644 --- a/crates/lpm-cli/src/commands/tunnel.rs +++ b/crates/lpm-cli/src/commands/tunnel.rs @@ -3,7 +3,7 @@ use lpm_common::{LpmError, format_bytes}; use lpm_registry::RegistryClient; use std::fmt::Display; use std::io::IsTerminal; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -472,9 +472,10 @@ pub(crate) async fn run_start( }; let inspector_db = open_inspector_db(project_dir)?; + let store = CaptureStore::of(&inspector_db); let inspector_state = lpm_inspect::state::InspectorState::with_db_for_target(local_target.clone(), inspector_db); - warn_capture_persistence(show_capture_warning); + warn_capture_persistence(project_dir, store, show_capture_warning).await; let inspector_handle = if no_inspect { None } else { @@ -573,11 +574,7 @@ pub(crate) async fn run_start( let name = session_name_owned.clone(); state .start_session_immediate(session_id, domain, local, name) - .map_err(|error| { - LpmError::Tunnel(format!( - "failed to persist inspector session start: {error}" - )) - })?; + .map_err(|error| inspector_write_error("persist inspector session start", error))?; tokio::spawn(async move { state.set_tunnel_url(url).await; }); @@ -713,14 +710,7 @@ pub(crate) async fn run_start( let websocket_result = ws_consumer .await .map_err(|error| LpmError::Tunnel(format!("WebSocket capture task failed: {error}"))); - let session_result = inspector_state.end_session().await.map_err(|error| { - LpmError::Tunnel(format!("failed to persist inspector session end: {error}")) - }); - let flush_result = inspector_state.flush().await.map_err(|error| { - LpmError::Tunnel(format!( - "failed to commit captures to .lpm/inspector.db: {error}" - )) - }); + let (session_result, flush_result) = finish_capture_session(&inspector_state).await; let inspector_result = match inspector_handle { Some(handle) => handle.shutdown().await, None => Ok(()), @@ -742,24 +732,698 @@ pub(crate) async fn run_start( } } -pub(crate) fn warn_capture_persistence(show_human_warning: bool) { - tracing::warn!( - target: "lpm_cli::tunnel", +/// Warns that captures persist, and says how git treats them. +pub(crate) async fn warn_capture_persistence( + project_dir: &Path, + store: CaptureStore, + show_human_warning: bool, +) { + let state = capture_git_state(project_dir).await; + for warning in capture_warnings(state.as_ref(), ignore_file_git_skips(project_dir), store) { + capture_warning(&warning, show_human_warning); + } +} + +/// The capture warnings: that captures persist, kept out of git only where +/// git confirms it ignores them; files a repository tracks, which an ignore +/// rule can't keep from being committed, with the command for each +/// repository; and files git neither tracks nor ignores. +fn capture_warnings( + state: Option<&CaptureGitState>, + ignore_file_skipped: bool, + store: CaptureStore, +) -> Vec { + let kept_out = state.is_some_and(CaptureGitState::kept_out); + let access = match store { + CaptureStore::OwnerOnly => "owner-only on Unix; project ACLs on Windows", + CaptureStore::OpenToOthers => { + "open to other users, as this filesystem's file modes don't keep them out" + } + }; + let mut warnings = vec![format!( "tunnel webhook capture persists full request/response bodies and headers \ - (incl. Authorization, Cookie, *-Signature) in .lpm/inspector.db — the \ - database uses mode 0600 on Unix and inherits project ACLs on Windows; \ - it survives commits / backups / IDE indexing. \ - Add `.lpm/inspector.db*` to .gitignore if you haven't already." - ); + (incl. Authorization, Cookie, *-Signature) in .lpm/inspector.db ({access}){}", + if kept_out { "; git ignores it" } else { "" } + )]; + let Some(state) = state else { + return warnings; + }; + for repository in &state.unanswered { + warnings.push( + match repository { + CaptureRepository::Project => { + "git didn't answer for the project's repository (it couldn't run, took \ + over 2 s, refused, or stopped at a filesystem boundary or a broken .git \ + file), so check that it doesn't track .lpm/inspector.db*" + } + CaptureRepository::Lpm => { + "git didn't answer in .lpm (it couldn't run, took over 2 s, or stopped \ + short of the project's repository, as at a filesystem boundary or a \ + broken .git file), so check that git ignores and doesn't track \ + .lpm/inspector.db*" + } + } + .to_string(), + ); + } + if state.lpm_unasked { + warnings.push( + "git couldn't be asked about .lpm, so check that git ignores and doesn't \ + track inspector.db*" + .to_string(), + ); + } + if !state.project_tracked.is_empty() || !state.lpm_tracked.is_empty() { + let tracked: Vec = state + .project_tracked + .iter() + .cloned() + .chain(state.lpm_tracked.iter().map(|name| format!(".lpm/{name}"))) + .collect(); + let commands: Vec = [ + ( + &state.project_tracked, + CaptureRepository::Project, + state.project_spelling_unknown, + ), + (&state.lpm_tracked, CaptureRepository::Lpm, false), + ] + .into_iter() + .filter(|(names, _, _)| !names.is_empty()) + .map(|(names, repository, spelling_unknown)| { + format!( + "`{}`", + if spelling_unknown { + untrack_every_capture(repository) + } else { + untrack_command(names, repository) + } + ) + }) + .collect(); + warnings.push(format!( + "git tracks {}, so captures there can be committed and stay in its history; \ + run {}, commit, and rotate any credentials they held", + listed(&tracked), + commands.join(" and ") + )); + } + if !state.unignored.is_empty() { + let unignored: Vec = state + .unignored + .iter() + .map(|name| format!(".lpm/{name}")) + .collect(); + warnings.push(format!( + "git doesn't ignore {}; {}", + listed(&unignored), + if ignore_file_skipped { + "replace .lpm/.gitignore, which git doesn't read as a file of the \ + project's, with a file that holds `inspector.db*`" + } else { + "add `inspector.db*` to .lpm/.gitignore" + } + )); + } + warnings +} + +/// Whether git skips `.lpm/.gitignore`: a link, which git doesn't follow, +/// or an entry that isn't a regular file, such as a directory. +fn ignore_file_git_skips(project_dir: &Path) -> bool { + std::fs::symlink_metadata(project_dir.join(".lpm/.gitignore")) + .is_ok_and(|metadata| metadata.file_type().is_symlink() || !metadata.is_file()) +} + +/// The human warning, or the same text on the log when it isn't shown, so +/// it isn't printed twice. +fn capture_warning(message: &str, show_human_warning: bool) { if show_human_warning { - install_ui::warn( - "tunnel webhook capture persists full request/response bodies and headers \ - in .lpm/inspector.db (mode 0600 on Unix; project ACLs on Windows). \ - Add `.lpm/inspector.db*` to .gitignore.", + install_ui::warn_untrusted(message); + } else { + tracing::warn!( + target: "lpm_cli::tunnel", + "{}", + lpm_common::sanitize_terminal_inline(message) ); } } +/// Who the capture database a writer opened lets read it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum CaptureStore { + OwnerOnly, + /// On a filesystem whose file modes don't keep other users out, which + /// no replacement improves: one without Unix modes, or on macOS one + /// that ignores ownership. + OpenToOthers, +} + +impl CaptureStore { + pub(crate) fn of(db: &lpm_inspect::db::InspectorDb) -> Self { + if db.open_to_others() { + Self::OpenToOthers + } else { + Self::OwnerOnly + } + } +} + +/// Capture files a git query listed as tracked, and as neither tracked nor +/// ignored, by name relative to the directory it was asked in, or to its +/// repository's top with `--full-name`. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +struct CaptureGitFiles { + tracked: Vec, + unignored: Vec, +} + +/// How git treats the capture files. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +struct CaptureGitState { + /// Those the project's repository tracks, by name in the project. + project_tracked: Vec, + /// Whether `project_tracked` is spelled as git in `.lpm` lists it, when + /// the project's query didn't answer: the index may spell the directory + /// otherwise, as an upstream `.LPM/` on a case-insensitive disk. + project_spelling_unknown: bool, + /// Those a repository of `.lpm`'s own tracks, by name in `.lpm`. + lpm_tracked: Vec, + /// Those git neither tracks nor ignores, by name in `.lpm`. + unignored: Vec, + /// Whether git refused to answer for `.lpm`, as for one that looks like a + /// bare repository. + lpm_unasked: bool, + /// The repositories whose query couldn't run, was refused in a + /// repository, stopped short of one, or didn't answer in time, so what + /// the other found stands but git's ignoring the files isn't confirmed. + unanswered: Vec, +} + +impl CaptureGitState { + fn kept_out(&self) -> bool { + !self.lpm_unasked + && self.unanswered.is_empty() + && self.project_tracked.is_empty() + && self.lpm_tracked.is_empty() + && self.unignored.is_empty() + } +} + +/// The repository whose index a tracked capture file is in. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CaptureRepository { + Project, + Lpm, +} + +const CAPTURES_PATHSPEC: &str = ":(glob,icase)inspector.db*"; + +const PROJECT_CAPTURES_PATHSPEC: &str = ":(glob,icase).lpm/inspector.db*"; + +const CAPTURE_GIT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2); + +/// Why git didn't say how it treats the capture files. +#[derive(Debug, PartialEq, Eq)] +enum CaptureGitFailure { + /// Git ran and found no repository there or in any directory above. + NoRepository, + /// Git ran and stopped looking before it found one: at a filesystem + /// boundary, or at a `.git` file that doesn't point at a repository, + /// which no git command there gets past. A repository above can still + /// hold the directory. + Stopped, + /// Git ran and refused, for a repository it won't use, such as one + /// another user owns. + Refused, + /// Git couldn't be run, or didn't answer in time. + Unavailable, +} + +/// How git's `fatal: ` line starts, with `LC_ALL=C`, where it found no +/// repository, and where it stopped looking. +const NO_REPOSITORY_MESSAGE: &str = "not a git repository (or any of the parent directories)"; +const STOPPED_MESSAGES: [&str; 5] = [ + "not a git repository (or any parent up to mount", + "not a git repository: ", + "invalid gitfile format", + "no path in gitfile", + "too large to be a .git file", +]; + +/// Reads git's failure from the start of its `fatal: ` line, as the rest can +/// quote a path, and without regard to case, as releases before 2.17 write +/// "Not a git repository". +fn capture_git_failure(stderr: &[u8]) -> CaptureGitFailure { + let message = String::from_utf8_lossy(stderr); + for line in message.lines() { + let Some(reason) = line.strip_prefix("fatal: ") else { + continue; + }; + let starts = |start: &str| { + reason + .get(..start.len()) + .is_some_and(|text| text.eq_ignore_ascii_case(start)) + }; + if starts(NO_REPOSITORY_MESSAGE) { + return CaptureGitFailure::NoRepository; + } + if STOPPED_MESSAGES.into_iter().any(starts) || unreadable_git_file(reason, starts) { + return CaptureGitFailure::Stopped; + } + } + CaptureGitFailure::Refused +} + +/// Whether git's `fatal: ` line says it couldn't open or read a `.git` +/// file, which it names: "error opening ''" and "error reading +/// ", told from git's other errors that start the same way, such as +/// "error reading from ", by the name. +fn unreadable_git_file(reason: &str, starts: impl Fn(&str) -> bool) -> bool { + let names_a_git_file = |path: &str| path.ends_with("/.git") || path == ".git"; + if starts("error opening '") { + // The path is quoted, then ": " and the system's error; the path + // itself can hold a quote. + return reason["error opening '".len()..] + .rsplit_once("': ") + .is_some_and(|(path, _)| names_a_git_file(path)); + } + starts("error reading ") && names_a_git_file(reason["error reading ".len()..].trim_end()) +} + +/// A git query of the capture files: the directory it runs in, its +/// pathspec, whether it lists files neither tracked nor ignored too, and +/// whether it names them from their repository's top. +#[derive(Debug, Clone, PartialEq, Eq)] +struct CaptureGitQuery { + directory: PathBuf, + pathspec: &'static str, + unignored: bool, + full_name: bool, +} + +/// How git treats the capture files in `project_dir`, matching their names +/// without regard to case, as a case-insensitive disk does. Two queries run +/// at once: the project's repository, for the capture files its index holds +/// whatever the case of their directory; and git in `.lpm`, for those +/// neither tracked nor ignored there, and for those a repository of `.lpm`'s +/// own tracks, such as a submodule, whose files the project's index doesn't +/// list. Git names the files in `.lpm` from their repository's top, so a +/// name without a directory is one a repository of `.lpm`'s own tracks. +/// What one query confirms stands when the other doesn't answer in time. +/// None when neither answers: neither is a repository, git can't be run, or +/// it doesn't answer in time. +async fn capture_git_state(project_dir: &Path) -> Option { + capture_git_state_by(project_dir, |query| { + capture_git_output(capture_git_command(&query), CAPTURE_GIT_TIMEOUT) + }) + .await +} + +async fn capture_git_state_by(project_dir: &Path, ask: Ask) -> Option +where + Ask: Fn(CaptureGitQuery) -> Answer, + Answer: std::future::Future>, +{ + let lpm_dir = project_dir.join(".lpm"); + let lpm_exists = lpm_dir.is_dir(); + let project = ask(CaptureGitQuery { + directory: project_dir.to_path_buf(), + pathspec: PROJECT_CAPTURES_PATHSPEC, + unignored: false, + full_name: false, + }); + let inside = async { + if lpm_exists { + Some( + ask(CaptureGitQuery { + directory: lpm_dir.clone(), + pathspec: CAPTURES_PATHSPEC, + unignored: true, + full_name: true, + }) + .await, + ) + } else { + None + } + }; + let (project, inside) = tokio::join!(project, inside); + + let mut state = CaptureGitState::default(); + // Whether git found a repository the project is in; only where git + // didn't run, answer, or get past a filesystem boundary or a broken + // `.git` file is that read from the `.git` entries. + let in_repository = match &project { + Ok(_) | Err(CaptureGitFailure::Refused) => true, + Err(CaptureGitFailure::NoRepository) => false, + Err(CaptureGitFailure::Stopped | CaptureGitFailure::Unavailable) => project_dir + .ancestors() + .any(|directory| marks_a_repository(&directory.join(".git"))), + }; + let project_answered = match project { + Ok(files) => { + state.project_tracked = files.tracked; + true + } + // In a repository, a query that fails is no answer, git refusing + // included, as for one another user owns, run as root. + Err(_) if in_repository => { + state.unanswered.push(CaptureRepository::Project); + false + } + Err(_) => false, + }; + // `.lpm` has a repository of its own where git refused to use one there, + // and, where git couldn't be run there, where its `.git` entry says so. + let own_repository = match &inside { + Some(Err(CaptureGitFailure::Refused)) => true, + Some(Err(CaptureGitFailure::Unavailable)) => marks_a_repository(&lpm_dir.join(".git")), + _ => false, + }; + let inside_answered = match inside { + Some(Ok(files)) => { + let (own, project_files): (Vec, Vec) = files + .tracked + .into_iter() + .partition(|name| !name.contains('/')); + state.lpm_tracked = own; + // Git in `.lpm` lists what the project's repository tracks + // there, which the project's query has when it answers; when it + // doesn't, they're what's known of them. + if !project_answered { + state.project_tracked = project_files + .iter() + .map(|name| format!(".lpm/{}", file_name(name))) + .collect(); + state.project_spelling_unknown = !state.project_tracked.is_empty(); + } + state.unignored = files + .unignored + .iter() + .map(|name| file_name(name).to_owned()) + .collect(); + true + } + // Git in `.lpm` stopped short of the project's repository, as at a + // filesystem boundary, or found none there, so whether it ignores + // the files there isn't known. + Some(Err(CaptureGitFailure::NoRepository | CaptureGitFailure::Stopped)) => { + state.unanswered.push(CaptureRepository::Lpm); + false + } + Some(Err(CaptureGitFailure::Refused)) => { + state.lpm_unasked = true; + false + } + Some(Err(CaptureGitFailure::Unavailable)) => { + state.unanswered.push(CaptureRepository::Lpm); + false + } + None => false, + }; + (project_answered || inside_answered || in_repository || own_repository).then_some(state) +} + +/// The part of a name git lists after its last directory. +fn file_name(name: &str) -> &str { + name.rsplit_once('/').map_or(name, |(_, file)| file) +} + +/// The largest `.git` file git reads; it stops at a larger one. +const GITFILE_LIMIT: u64 = 1024 * 1024; + +/// Whether a `.git` entry marks a repository as git 2.53 reads one, for when +/// git itself can't be asked, or stopped: a directory, or a link to one, +/// that's a git directory ([`is_git_directory`]), or a file, or a link to +/// one, pointing at one as git reads it: `gitdir: ` and the rest of the +/// file, but for the line breaks that end it, a path from the file's +/// directory. Git looks past a directory that isn't a git directory, and any +/// other entry, and stops at a file that doesn't point at one, or is too +/// large to be a pointer. +fn marks_a_repository(git: &Path) -> bool { + match std::fs::metadata(git) { + Ok(metadata) if metadata.is_dir() => is_git_directory(git), + Ok(metadata) if metadata.is_file() => { + let Ok((contents, _)) = + lpm_common::read_regular_file_capped_with_metadata(git, GITFILE_LIMIT) + else { + return false; + }; + let Some(pointer) = contents + .strip_prefix(b"gitdir: ") + .map(trim_line_breaks) + .map(until_nul) + .filter(|path| !path.is_empty()) + else { + return false; + }; + let directory = git.parent().unwrap_or(Path::new(".")); + is_git_directory(&directory.join(path_from_bytes(pointer))) + } + _ => false, + } +} + +/// `bytes` up to a NUL, where git, reading them as a C string, stops. +fn until_nul(bytes: &[u8]) -> &[u8] { + bytes + .iter() + .position(|byte| *byte == 0) + .map_or(bytes, |end| &bytes[..end]) +} + +/// A path from the bytes of a file git reads; on Windows, where a path +/// isn't bytes, only UTF-8 reads as one. +fn path_from_bytes(bytes: &[u8]) -> PathBuf { + #[cfg(unix)] + { + use std::os::unix::ffi::OsStrExt as _; + + PathBuf::from(std::ffi::OsStr::from_bytes(bytes)) + } + #[cfg(not(unix))] + { + PathBuf::from(String::from_utf8_lossy(bytes).into_owned()) + } +} + +/// `bytes` without the line breaks, CR or LF, that end it. +fn trim_line_breaks(bytes: &[u8]) -> &[u8] { + let end = bytes + .iter() + .rposition(|byte| !matches!(byte, b'\n' | b'\r')) + .map_or(0, |last| last + 1); + &bytes[..end] +} + +/// Whether `directory` is a git directory as git's `is_git_directory` +/// reads one: its `HEAD` names a branch or a commit ([`names_a_head`]), and +/// its common directory, which a worktree's `commondir` file names, has +/// `objects` and `refs` it can search. +fn is_git_directory(directory: &Path) -> bool { + if !names_a_head(&directory.join("HEAD")) { + return false; + } + // Git fails on a `commondir` file it can't read, an empty one, or one + // naming a path that doesn't resolve. + let common = match lpm_common::read_regular_file_capped_with_metadata( + &directory.join("commondir"), + GITFILE_LIMIT, + ) { + Ok((contents, _)) => { + let path = until_nul(trim_line_breaks(&contents)); + if path.is_empty() { + return false; + } + match std::fs::canonicalize(directory.join(path_from_bytes(path))) { + Ok(common) => common, + Err(_) => return false, + } + } + Err(lpm_common::BoundedReadError::NotFound { .. }) => directory.to_path_buf(), + Err(_) => return false, + }; + searchable(&common.join("objects")) && searchable(&common.join("refs")) +} + +/// Whether a `HEAD` file names a branch or a commit as git's +/// `validate_headref` reads one: a link into `refs/`, or a file starting +/// with `ref:` and a name in `refs/` after any spaces, or with an object ID +/// in hex, of which git reads the first 255 bytes. +fn names_a_head(head: &Path) -> bool { + match std::fs::symlink_metadata(head) { + Ok(metadata) if metadata.file_type().is_symlink() => std::fs::read_link(head) + .is_ok_and(|target| target.as_os_str().as_encoded_bytes().starts_with(b"refs/")), + Ok(_) => { + let Some(contents) = first_bytes(head, 255) else { + return false; + }; + contents.strip_prefix(b"ref:").is_some_and(|name| { + // Git's own isspace: a space, tab, LF or CR, not a vertical + // tab or form feed. + let start = name + .iter() + .position(|byte| !matches!(byte, b' ' | b'\t' | b'\n' | b'\r')) + .unwrap_or(name.len()); + name[start..].starts_with(b"refs/") + }) || contents + .get(..40) + .is_some_and(|id| id.iter().all(u8::is_ascii_hexdigit)) + } + Err(_) => false, + } +} + +/// Up to `limit` bytes from the start of a regular file, opened without +/// blocking on a special file. +fn first_bytes(path: &Path, limit: u64) -> Option> { + let mut options = std::fs::OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt as _; + + options.custom_flags(libc::O_NONBLOCK); + } + let file = options.open(path).ok()?; + if !file.metadata().ok()?.is_file() { + return None; + } + let mut contents = Vec::new(); + std::io::Read::read_to_end(&mut std::io::Read::take(file, limit), &mut contents).ok()?; + Some(contents) +} + +/// Whether git can search `path`, as its `access(X_OK)` checks. +fn searchable(path: &Path) -> bool { + #[cfg(unix)] + { + rustix::fs::access(path, rustix::fs::Access::EXEC_OK).is_ok() + } + #[cfg(not(unix))] + { + path.is_dir() + } +} + +fn capture_git_command(query: &CaptureGitQuery) -> tokio::process::Command { + let mut args = vec!["ls-files", "-z", "-t", "--cached"]; + if query.unignored { + args.extend(["--others", "--exclude-standard"]); + } + if query.full_name { + args.push("--full-name"); + } + args.extend(["--", query.pathspec]); + tokio::process::Command::from(crate::commands::npmrc::hardened_git_command( + &query.directory, + &args, + )) +} + +/// Runs a capture query's git command, giving up after `timeout` and +/// killing it, so a slow repository delays the tunnel's start by that much +/// at most. +async fn capture_git_output( + mut command: tokio::process::Command, + timeout: std::time::Duration, +) -> Result { + command + .stdin(std::process::Stdio::null()) + .stderr(std::process::Stdio::piped()) + .kill_on_drop(true); + let output = tokio::time::timeout(timeout, command.output()) + .await + .map_err(|_| CaptureGitFailure::Unavailable)? + .map_err(|_| CaptureGitFailure::Unavailable)?; + if !output.status.success() { + return Err(capture_git_failure(&output.stderr)); + } + Ok(parse_capture_git_files(&output.stdout)) +} + +fn parse_capture_git_files(stdout: &[u8]) -> CaptureGitFiles { + let mut files = CaptureGitFiles::default(); + for record in stdout.split(|byte| *byte == 0) { + // `-t` tags each name: `?` for one git neither tracks nor ignores, + // a letter for one it tracks. + let (Some(&tag), Some(name)) = (record.first(), record.get(2..)) else { + continue; + }; + let name = String::from_utf8_lossy(name).into_owned(); + // A conflicted file is listed once for each stage, one after another. + let list = if tag == b'?' { + &mut files.unignored + } else { + &mut files.tracked + }; + if list.last() != Some(&name) { + list.push(name); + } + } + files +} + +const LISTED_FILES: usize = 5; + +/// Whether a name holds only characters no shell treats specially and no +/// terminal reads as anything but text, so it can go into the warning and +/// its command as it is. +fn is_plain(name: &str) -> bool { + !name.is_empty() + && name + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '/' | '_' | '-')) +} + +/// At most `LISTED_FILES` plain names, quoted; other names are only +/// counted, so text from the repository can't read as part of the warning. +fn listed(names: &[String]) -> String { + let shown: Vec = names + .iter() + .filter(|name| is_plain(name)) + .take(LISTED_FILES) + .map(|name| format!("'{name}'")) + .collect(); + let rest = names.len() - shown.len(); + match (shown.is_empty(), rest) { + (true, 1) => "1 capture file".to_string(), + (true, count) => format!("{count} capture files"), + (false, 0) => shown.join(", "), + (false, count) => format!("{} and {count} more", shown.join(", ")), + } +} + +/// The command, run in the project directory, that stops `repository` +/// tracking `tracked`: their names when the warning can list them all, and +/// otherwise the repository's capture pathspec, which every shell reads +/// alike in double quotes. +fn untrack_command(tracked: &[String], repository: CaptureRepository) -> String { + if tracked.len() <= LISTED_FILES && tracked.iter().all(|name| is_plain(name)) { + format!("{} {}", untrack_prefix(repository), tracked.join(" ")) + } else { + untrack_every_capture(repository) + } +} + +/// The command that stops `repository` tracking every capture file, by the +/// capture pathspec, matched without regard to case. +fn untrack_every_capture(repository: CaptureRepository) -> String { + let pathspec = match repository { + CaptureRepository::Project => PROJECT_CAPTURES_PATHSPEC, + CaptureRepository::Lpm => CAPTURES_PATHSPEC, + }; + format!("{} \"{pathspec}\"", untrack_prefix(repository)) +} + +fn untrack_prefix(repository: CaptureRepository) -> &'static str { + match repository { + CaptureRepository::Project => "git rm --cached --", + CaptureRepository::Lpm => "git -C .lpm rm --cached --", + } +} + fn compose_tunnel_control_and_connect( control_result: Result<(), LpmError>, connect_result: Result<(), LpmError>, @@ -940,7 +1604,7 @@ async fn run_domains(client: &RegistryClient, json_output: bool) -> Result<(), L /// against any other local service); `Some(n)` binds that exact port and /// fails loudly on `AddrInUse`. async fn run_inspect_ui(project_dir: &Path, inspect_port: Option) -> Result<(), LpmError> { - let state = project_inspector_state(project_dir)?; + let state = project_inspector_state(project_dir, true)?; let handle = lpm_inspect::start(state, inspect_port.unwrap_or(0)).await?; if let Err(error) = lpm_common::browser::open(&handle.url) { install_ui::warn_untrusted(&format!("failed to open inspector in browser: {error}")); @@ -965,8 +1629,9 @@ async fn run_inspect_ui(project_dir: &Path, inspect_port: Option) -> Result fn project_inspector_state( project_dir: &Path, + show_human_warning: bool, ) -> Result { - let db = open_inspector_db(project_dir)?; + let db = open_inspector_db_for_review(project_dir, show_human_warning)?; let sessions = lpm_runner::dev_session::discover_active_sessions()?; let local_target = observer_replay_target(&sessions); Ok(lpm_inspect::state::InspectorState::with_db_observer_for_target(local_target, db)) @@ -995,7 +1660,7 @@ async fn run_inspect( args: &[String], json_output: bool, ) -> Result<(), LpmError> { - let db = open_inspector_db(project_dir)?; + let db = open_inspector_db_for_review(project_dir, !json_output)?; let last = parse_flag_usize(args, "--last", "-n").unwrap_or(20); let filter_provider = parse_flag_str(args, "--filter"); @@ -1090,7 +1755,7 @@ async fn run_replay( default_port: Option, json_output: bool, ) -> Result<(), LpmError> { - let db = open_inspector_db(project_dir)?; + let db = open_inspector_db_for_review(project_dir, !json_output)?; let local_target = match parse_flag_u16(args, "--port", "-p")? { Some(port) => lpm_common::LocalTarget::loopback(lpm_common::LocalScheme::Http, port), None => resolve_local_target(default_port)?, @@ -1210,10 +1875,10 @@ async fn run_replay( /// --status — filter by status class /// --clear — delete all webhook logs async fn run_log(project_dir: &Path, args: &[String], json_output: bool) -> Result<(), LpmError> { - let db = open_inspector_db(project_dir)?; + let db = open_inspector_db_for_review(project_dir, !json_output)?; if args.contains(&"--clear".to_string()) { - db.clear_history().await.map_err(inspector_query_error)?; + clear_captures(&db).await?; if json_output { println!( "{}", @@ -1260,17 +1925,88 @@ async fn run_log(project_dir: &Path, args: &[String], json_output: bool) -> Resu // ── Helper functions ──────────────────────────────────────────────── -fn open_inspector_db(project_dir: &Path) -> Result { - lpm_inspect::db::InspectorDb::open(project_dir).map_err(|error| { - LpmError::Tunnel(format!( - "failed to open this project's .lpm/inspector.db: {error}. \ - Repair or move the corrupt database and retry" - )) - }) +/// Opens the project's capture database to record captures. +pub(crate) fn open_inspector_db( + project_dir: &Path, +) -> Result { + lpm_inspect::db::InspectorDb::open(project_dir).map_err(inspector_open_error) +} + +/// Opens the project's capture database to read and manage the captures it +/// holds, warning when other users could open it. +fn open_inspector_db_for_review( + project_dir: &Path, + show_human_warning: bool, +) -> Result { + let db = + lpm_inspect::db::InspectorDb::open_for_review(project_dir).map_err(inspector_open_error)?; + if let Some(warning) = review_warning(db.open_to_others(), db.ownership_ignored()) { + capture_warning(warning, show_human_warning); + } + Ok(db) +} + +/// What a review tells of a capture database other users could open, and +/// whether a capture writer's run can change it. +fn review_warning(open_to_others: bool, ownership_ignored: bool) -> Option<&'static str> { + if !open_to_others { + None + } else if ownership_ignored { + Some(CAPTURES_ON_A_SHARED_VOLUME) + } else { + Some(CAPTURES_OPEN_TO_OTHERS) + } +} + +const CAPTURES_OPEN_TO_OTHERS: &str = ".lpm/inspector.db is open to other users, so they may \ + read its captures; an `lpm tunnel` or `lpm dev --tunnel` run in this project makes it \ + owner-only where file modes keep other users out"; + +const CAPTURES_ON_A_SHARED_VOLUME: &str = ".lpm/inspector.db is open to other users, so they \ + may read its captures: its volume ignores ownership, so every user owns its files whatever \ + their mode; keep the project on a volume that keeps ownership"; + +/// The capture database's open failures are local, so they're reported as +/// such, not as a tunnel's; each says what to do. +fn inspector_open_error(error: lpm_inspect::db::OpenError) -> LpmError { + LpmError::Io(std::io::Error::new( + error.kind(), + format!("failed to open this project's .lpm/inspector.db: {error}"), + )) +} + +/// A failure writing `.lpm/inspector.db`, which is the disk's, not the +/// tunnel connection's. +pub(crate) fn inspector_write_error(what: &str, error: impl Display) -> LpmError { + LpmError::Io(std::io::Error::other(format!("failed to {what}: {error}"))) } fn inspector_query_error(error: impl Display) -> LpmError { - LpmError::Tunnel(format!("failed to read .lpm/inspector.db: {error}")) + LpmError::Io(std::io::Error::other(format!( + "failed to read .lpm/inspector.db: {error}" + ))) +} + +async fn clear_captures(db: &lpm_inspect::db::InspectorDb) -> Result<(), LpmError> { + db.clear_history() + .await + .map_err(|error| inspector_write_error("clear .lpm/inspector.db", error)) +} + +/// Ends the inspector session and commits the captures still queued: the +/// session end's result, then the commit's. +pub(crate) async fn finish_capture_session( + state: &lpm_inspect::state::InspectorState, +) -> (Result<(), LpmError>, Result<(), LpmError>) { + let ended = state + .end_session() + .await + .map_err(|error| inspector_write_error("persist inspector session end", error)); + let committed = state + .flush() + .await + .map_err(|error| inspector_write_error("commit captures to .lpm/inspector.db", error)); + (ended, committed) } async fn read_capture_entries( @@ -1825,6 +2561,1461 @@ fn is_valid_tunnel_domain(domain: &str) -> bool { mod tests { use super::*; + fn git(directory: &Path, args: &[&str]) { + let output = std::process::Command::new("git") + .args(args) + .current_dir(directory) + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("GIT_CONFIG_NOSYSTEM", "1") + .output() + .unwrap(); + assert!(output.status.success(), "git {args:?}: {output:?}"); + } + + fn capture_project(files: &[&str]) -> tempfile::TempDir { + let project = tempfile::tempdir().unwrap(); + git(project.path(), &["init", "--quiet"]); + // The check runs git with the user's own configuration, so a global + // ignore file mustn't hide the files these tests make. + let no_excludes = project.path().join(".git/no-excludes"); + std::fs::write(&no_excludes, b"").unwrap(); + git( + project.path(), + &["config", "core.excludesFile", no_excludes.to_str().unwrap()], + ); + for name in files { + let path = project.path().join(name); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, b"x").unwrap(); + } + project + } + + fn names(names: &[&str]) -> Vec { + names.iter().map(|name| (*name).to_string()).collect() + } + + #[tokio::test] + async fn tells_capture_files_git_tracks_from_those_it_neither_tracks_nor_ignores() { + let project = capture_project(&[ + ".lpm/inspector.db", + ".lpm/inspector.db-wal", + ".lpm/inspector.db-shm", + ".lpm/added-sources.json", + ".lpm/inspector.db.d/notes.md", + ]); + git( + project.path(), + &["add", ".lpm/inspector.db", ".lpm/added-sources.json"], + ); + + let state = capture_git_state(project.path()).await.unwrap(); + + assert_eq!(state.project_tracked, names(&[".lpm/inspector.db"])); + assert!(state.lpm_tracked.is_empty()); + let mut unignored = state.unignored; + unignored.sort(); + assert_eq!(unignored, names(&["inspector.db-shm", "inspector.db-wal"])); + } + + #[tokio::test] + async fn finds_capture_files_ignored_by_lpm_gitignore_kept_out() { + let project = capture_project(&[".lpm/inspector.db", ".lpm/inspector.db-wal"]); + std::fs::write(project.path().join(".lpm/.gitignore"), "inspector.db*\n").unwrap(); + + let state = capture_git_state(project.path()).await.unwrap(); + + assert_eq!(state, CaptureGitState::default()); + assert!(state.kept_out()); + } + + #[tokio::test] + async fn names_tracked_capture_files_whatever_their_case() { + let project = capture_project(&[".lpm/Inspector.DB"]); + git(project.path(), &["add", ".lpm/Inspector.DB"]); + + let state = capture_git_state(project.path()).await.unwrap(); + + assert_eq!(state.project_tracked, names(&[".lpm/Inspector.DB"])); + } + + #[tokio::test] + async fn names_a_capture_the_project_tracks_under_another_case_of_lpm() { + // As a repository committed on a case-insensitive disk carries it. + let project = capture_project(&[".LPM/inspector.db"]); + git(project.path(), &["add", ".LPM/inspector.db"]); + + let state = capture_git_state(project.path()).await.unwrap(); + + assert_eq!(state.project_tracked, names(&[".LPM/inspector.db"])); + assert!(!state.kept_out()); + } + + #[tokio::test] + async fn asks_a_repository_inside_lpm_about_the_files_it_keeps() { + let project = capture_project(&[".lpm/inspector.db"]); + let lpm_dir = project.path().join(".lpm"); + git(&lpm_dir, &["init", "--quiet"]); + git(&lpm_dir, &["add", "inspector.db"]); + + let state = capture_git_state(project.path()).await.unwrap(); + + assert!(state.project_tracked.is_empty()); + assert_eq!(state.lpm_tracked, names(&["inspector.db"])); + assert_eq!( + untrack_command(&state.lpm_tracked, CaptureRepository::Lpm), + "git -C .lpm rm --cached -- inspector.db" + ); + } + + #[tokio::test] + async fn names_the_project_s_capture_and_a_repository_inside_lpm_s_alike() { + let project = capture_project(&[".lpm/inspector.db"]); + git(project.path(), &["add", ".lpm/inspector.db"]); + let lpm_dir = project.path().join(".lpm"); + git(&lpm_dir, &["init", "--quiet"]); + git(&lpm_dir, &["add", "inspector.db"]); + + let state = capture_git_state(project.path()).await.unwrap(); + + assert_eq!(state.project_tracked, names(&[".lpm/inspector.db"])); + assert_eq!(state.lpm_tracked, names(&["inspector.db"])); + let warnings = capture_warnings(Some(&state), false, CaptureStore::OwnerOnly); + assert!( + warnings[1].contains( + "run `git rm --cached -- .lpm/inspector.db` and \ + `git -C .lpm rm --cached -- inspector.db`" + ), + "{warnings:?}" + ); + } + + #[tokio::test] + async fn names_a_capture_the_project_tracks_when_lpm_points_git_elsewhere() { + let project = capture_project(&[".lpm/inspector.db"]); + git(project.path(), &["add", ".lpm/inspector.db"]); + // As a downloaded archive can carry: git inside `.lpm` uses the + // project's repository with `.lpm` as its working tree. + std::fs::write(project.path().join(".lpm/.git"), "gitdir: ../.git\n").unwrap(); + + let state = capture_git_state(project.path()).await.unwrap(); + + assert_eq!(state.project_tracked, names(&[".lpm/inspector.db"])); + assert!(!state.kept_out()); + } + + #[tokio::test] + async fn lists_a_capture_once_when_lpm_holds_an_empty_git_directory() { + let project = capture_project(&[".lpm/inspector.db"]); + git(project.path(), &["add", ".lpm/inspector.db"]); + std::fs::create_dir(project.path().join(".lpm/.git")).unwrap(); + + let state = capture_git_state(project.path()).await.unwrap(); + + assert_eq!(state.project_tracked, names(&[".lpm/inspector.db"])); + assert!(state.lpm_tracked.is_empty(), "{state:?}"); + assert!(state.unanswered.is_empty(), "{state:?}"); + } + + #[tokio::test] + async fn says_nothing_of_git_entries_git_takes_for_no_repository() { + for entry in ["empty directory", "gitdir:../elsewhere/.git\n"] { + let project = tempfile::tempdir().unwrap(); + std::fs::create_dir(project.path().join(".lpm")).unwrap(); + std::fs::write(project.path().join(".lpm/inspector.db"), b"x").unwrap(); + let git_entry = project.path().join(".git"); + if entry == "empty directory" { + std::fs::create_dir(&git_entry).unwrap(); + } else { + std::fs::write(&git_entry, entry).unwrap(); + } + + assert_eq!(capture_git_state(project.path()).await, None, "{entry:?}"); + } + } + + #[cfg(unix)] + #[tokio::test] + async fn says_when_git_refuses_to_answer_for_lpm() { + let project = capture_project(&[".lpm/inspector.db"]); + git(project.path(), &["add", ".lpm/inspector.db"]); + // An `.lpm` that looks like a bare repository, which the check's + // git refuses to use. + let lpm_dir = project.path().join(".lpm"); + for directory in ["objects", "refs"] { + std::fs::create_dir(lpm_dir.join(directory)).unwrap(); + } + std::fs::write(lpm_dir.join("HEAD"), "ref: refs/heads/main\n").unwrap(); + + let state = capture_git_state(project.path()).await.unwrap(); + + assert!(state.lpm_unasked); + assert_eq!(state.project_tracked, names(&[".lpm/inspector.db"])); + assert!(!state.kept_out()); + } + + #[tokio::test] + async fn says_nothing_of_git_outside_a_repository() { + let project = tempfile::tempdir().unwrap(); + std::fs::create_dir(project.path().join(".lpm")).unwrap(); + std::fs::write(project.path().join(".lpm/inspector.db"), b"x").unwrap(); + + assert_eq!(capture_git_state(project.path()).await, None); + } + + #[cfg(unix)] + #[tokio::test] + async fn runs_no_fsmonitor_program_from_the_repository() { + let project = capture_project(&[".lpm/inspector.db"]); + let marker = project.path().join("fsmonitor-ran"); + git( + project.path(), + &[ + "config", + "core.fsmonitor", + &format!("touch '{}'; false", marker.display()), + ], + ); + + capture_git_state(project.path()).await; + + assert!(!marker.exists()); + } + + #[cfg(unix)] + #[tokio::test] + async fn refuses_a_bare_repository_a_project_carries() { + let project = tempfile::tempdir().unwrap(); + let web = project.path().join("web"); + for directory in ["objects", "refs", ".lpm"] { + std::fs::create_dir_all(web.join(directory)).unwrap(); + } + std::fs::write(web.join("HEAD"), "ref: refs/heads/main\n").unwrap(); + let marker = project.path().join("fsmonitor-ran"); + std::fs::write( + web.join("config"), + format!( + "[core]\n\tbare = false\n\tworktree = .\n\tfsmonitor = touch '{}'; false\n", + marker.display() + ), + ) + .unwrap(); + std::fs::write(web.join(".lpm/inspector.db"), b"x").unwrap(); + + // Git without `safe.bareRepository=explicit` would use it, so its + // refusal here leaves the files unconfirmed. + let state = capture_git_state(&web).await.unwrap(); + assert_eq!(state.unanswered, vec![CaptureRepository::Project]); + assert!(state.lpm_unasked); + assert!(!marker.exists()); + } + + /// Asks with canned answers, by whether a query is the project's, + /// recording each query. + async fn state_from_answers( + project_dir: &Path, + project: Result, + inside: Result, + ) -> (Option, Vec) { + let answers = std::sync::Mutex::new((Some(project), Some(inside))); + let asked = std::sync::Mutex::new(Vec::new()); + let state = capture_git_state_by(project_dir, |query| { + let answer = { + let mut answers = answers.lock().unwrap(); + if query.unignored { + answers.1.take() + } else { + answers.0.take() + } + } + .unwrap(); + asked.lock().unwrap().push(query); + async move { answer } + }) + .await; + (state, asked.into_inner().unwrap()) + } + + fn files(tracked: &[&str], unignored: &[&str]) -> CaptureGitFiles { + CaptureGitFiles { + tracked: names(tracked), + unignored: names(unignored), + } + } + + #[tokio::test] + async fn asks_the_project_s_index_and_git_in_lpm_together() { + let project = tempfile::tempdir().unwrap(); + let lpm_dir = project.path().join(".lpm"); + std::fs::create_dir(&lpm_dir).unwrap(); + + let (state, mut asked) = state_from_answers( + project.path(), + Ok(files(&[".lpm/inspector.db"], &[])), + Ok(files(&[".lpm/inspector.db"], &[".lpm/inspector.db-wal"])), + ) + .await; + + asked.sort_by_key(|query| query.unignored); + assert_eq!( + asked, + vec![ + CaptureGitQuery { + directory: project.path().to_path_buf(), + pathspec: PROJECT_CAPTURES_PATHSPEC, + unignored: false, + full_name: false, + }, + CaptureGitQuery { + directory: lpm_dir, + pathspec: CAPTURES_PATHSPEC, + unignored: true, + full_name: true, + }, + ] + ); + // Git in `.lpm` names the project's files from its top, and the + // project's query has them. + assert_eq!( + state, + Some(CaptureGitState { + project_tracked: names(&[".lpm/inspector.db"]), + project_spelling_unknown: false, + lpm_tracked: Vec::new(), + unignored: names(&["inspector.db-wal"]), + lpm_unasked: false, + unanswered: Vec::new(), + }) + ); + } + + #[tokio::test] + async fn keeps_the_project_s_names_for_its_tracked_files_when_both_answer() { + let project = tempfile::tempdir().unwrap(); + std::fs::create_dir(project.path().join(".lpm")).unwrap(); + + let (state, _) = state_from_answers( + project.path(), + Ok(files(&[".LPM/inspector.db"], &[])), + Ok(files(&[".LPM/inspector.db"], &[])), + ) + .await; + + assert_eq!( + state.unwrap().project_tracked, + names(&[".LPM/inspector.db"]) + ); + } + + #[tokio::test] + async fn counts_what_a_repository_of_lpm_s_own_tracks() { + let project = tempfile::tempdir().unwrap(); + std::fs::create_dir(project.path().join(".lpm")).unwrap(); + + let (state, _) = state_from_answers( + project.path(), + Err(CaptureGitFailure::NoRepository), + Ok(files(&["inspector.db"], &["inspector.db-wal"])), + ) + .await; + + let state = state.unwrap(); + assert_eq!(state.lpm_tracked, names(&["inspector.db"])); + assert!(state.project_tracked.is_empty()); + assert_eq!(state.unignored, names(&["inspector.db-wal"])); + assert!(state.unanswered.is_empty()); + } + + #[tokio::test] + async fn says_nothing_of_git_outside_a_repository_when_neither_query_answers() { + let project = tempfile::tempdir().unwrap(); + std::fs::create_dir(project.path().join(".lpm")).unwrap(); + + for (project_answer, inside_answer) in [ + ( + Err(CaptureGitFailure::Unavailable), + Err(CaptureGitFailure::Unavailable), + ), + ( + Err(CaptureGitFailure::NoRepository), + Err(CaptureGitFailure::Unavailable), + ), + ( + Err(CaptureGitFailure::NoRepository), + Err(CaptureGitFailure::NoRepository), + ), + ( + Err(CaptureGitFailure::NoRepository), + Err(CaptureGitFailure::Stopped), + ), + ( + Err(CaptureGitFailure::Stopped), + Err(CaptureGitFailure::Stopped), + ), + ] { + let (state, _) = + state_from_answers(project.path(), project_answer, inside_answer).await; + assert_eq!(state, None); + } + + // A repository git refused in `.lpm`, where the project's query found + // none, is `.lpm`'s own, so it's asked to be checked. + let (refused, _) = state_from_answers( + project.path(), + Err(CaptureGitFailure::NoRepository), + Err(CaptureGitFailure::Refused), + ) + .await; + let refused = refused.unwrap(); + assert!(refused.lpm_unasked, "{refused:?}"); + assert!(refused.unanswered.is_empty(), "{refused:?}"); + + // Git in `.lpm` answered, so only the project's query went unanswered, + // and outside a repository there was nothing for it to find. + let (answered, _) = state_from_answers( + project.path(), + Err(CaptureGitFailure::Unavailable), + Ok(CaptureGitFiles::default()), + ) + .await; + let answered = answered.unwrap(); + assert!(answered.unanswered.is_empty(), "{answered:?}"); + assert!(answered.kept_out()); + } + + #[tokio::test] + async fn names_lpm_s_own_repository_when_git_there_doesn_t_answer() { + let project = tempfile::tempdir().unwrap(); + repository_entry(&project.path().join(".lpm")); + + let (state, _) = state_from_answers( + project.path(), + Err(CaptureGitFailure::NoRepository), + Err(CaptureGitFailure::Unavailable), + ) + .await; + + let state = state.unwrap(); + assert_eq!(state.unanswered, vec![CaptureRepository::Lpm]); + let warnings = warnings_for(Some(state), false); + assert!( + warnings + .iter() + .any(|warning| warning.starts_with("git didn't answer in .lpm")), + "{warnings:?}" + ); + } + + #[tokio::test] + async fn names_each_query_a_repository_leaves_unanswered_even_when_neither_answers() { + let project = tempfile::tempdir().unwrap(); + repository_entry(project.path()); + std::fs::create_dir(project.path().join(".lpm")).unwrap(); + + // As for a repository another user owns, run as root. + let (refused, _) = state_from_answers( + project.path(), + Err(CaptureGitFailure::Refused), + Err(CaptureGitFailure::Refused), + ) + .await; + let refused = refused.unwrap(); + assert_eq!(refused.unanswered, vec![CaptureRepository::Project]); + assert!(refused.lpm_unasked); + let warnings = warnings_for(Some(refused), false); + assert!( + warnings.iter().any(|warning| warning + .starts_with("git didn't answer for the project's repository (it couldn't run, took over 2 s, refused,")), + "{warnings:?}" + ); + + let (slow, _) = state_from_answers( + project.path(), + Err(CaptureGitFailure::Unavailable), + Err(CaptureGitFailure::Unavailable), + ) + .await; + let slow = slow.unwrap(); + assert_eq!( + slow.unanswered, + vec![CaptureRepository::Project, CaptureRepository::Lpm] + ); + assert!(!slow.kept_out()); + let warnings = warnings_for(Some(slow), false); + assert!( + warnings + .iter() + .any(|warning| warning.starts_with("git didn't answer in .lpm")), + "{warnings:?}" + ); + } + + #[tokio::test] + async fn takes_a_project_inside_a_repository_s_subdirectory_as_in_that_repository() { + let repository = tempfile::tempdir().unwrap(); + repository_entry(repository.path()); + let project = repository.path().join("packages/app"); + std::fs::create_dir_all(project.join(".lpm")).unwrap(); + + // Git didn't run, so the `.git` entries are what's known. + let (state, _) = state_from_answers( + &project, + Err(CaptureGitFailure::Unavailable), + Err(CaptureGitFailure::Refused), + ) + .await; + + assert_eq!(state.unwrap().unanswered, vec![CaptureRepository::Project]); + } + + #[tokio::test] + async fn takes_whose_index_a_capture_is_in_from_the_name_git_gives_it() { + let project = tempfile::tempdir().unwrap(); + // Git looks past an empty `.git` directory, whatever it holds. + std::fs::create_dir_all(project.path().join(".lpm/.git")).unwrap(); + + for (listed, own) in [ + ("inspector.db", true), + (".lpm/inspector.db", false), + ("apps/web/.lpm/inspector.db", false), + ] { + let (state, _) = state_from_answers( + project.path(), + Err(CaptureGitFailure::Unavailable), + Ok(files(&[listed], &[])), + ) + .await; + let state = state.unwrap(); + let (lpm_tracked, project_tracked) = if own { + (names(&["inspector.db"]), Vec::new()) + } else { + (Vec::new(), names(&[".lpm/inspector.db"])) + }; + assert_eq!( + (state.lpm_tracked, state.project_tracked), + (lpm_tracked, project_tracked), + "{listed}" + ); + } + } + + #[tokio::test] + async fn names_lpm_unanswered_where_git_there_stops_short_of_the_project_s_repository() { + let project = tempfile::tempdir().unwrap(); + std::fs::create_dir(project.path().join(".lpm")).unwrap(); + + // As at a filesystem boundary between the project and `.lpm`. + for inside in [CaptureGitFailure::Stopped, CaptureGitFailure::NoRepository] { + let (state, _) = + state_from_answers(project.path(), Ok(CaptureGitFiles::default()), Err(inside)) + .await; + + let state = state.unwrap(); + assert_eq!(state.unanswered, vec![CaptureRepository::Lpm]); + assert!(!state.lpm_unasked); + assert!(!state.kept_out()); + let warnings = warnings_for(Some(state), false); + assert!( + warnings.iter().any(|warning| warning + == "git didn't answer in .lpm (it couldn't run, took over 2 s, or stopped \ + short of the project's repository, as at a filesystem boundary or a \ + broken .git file), so check that git ignores and doesn't track \ + .lpm/inspector.db*"), + "{warnings:?}" + ); + } + } + + #[tokio::test] + async fn reads_the_git_entries_where_git_stops_short_of_the_project_s_repository() { + let repository = tempfile::tempdir().unwrap(); + repository_entry(repository.path()); + let project = repository.path().join("app"); + std::fs::create_dir_all(project.join(".lpm")).unwrap(); + + let (state, _) = state_from_answers( + &project, + Err(CaptureGitFailure::Stopped), + Err(CaptureGitFailure::Stopped), + ) + .await; + + assert_eq!( + state.unwrap().unanswered, + vec![CaptureRepository::Project, CaptureRepository::Lpm] + ); + let outside = tempfile::tempdir().unwrap(); + std::fs::create_dir(outside.path().join(".lpm")).unwrap(); + let (state, _) = state_from_answers( + outside.path(), + Err(CaptureGitFailure::Stopped), + Err(CaptureGitFailure::Stopped), + ) + .await; + assert_eq!(state, None); + } + + #[tokio::test] + async fn asks_for_a_check_where_a_broken_git_file_stops_git_inside_a_repository() { + let repository = capture_project(&["app/.lpm/inspector.db"]); + git(repository.path(), &["add", "app/.lpm/inspector.db"]); + let project = repository.path().join("app"); + for entry in ["not a pointer\n", "gitdir: \n", "gitdir: ../gone/.git\n"] { + std::fs::write(project.join(".git"), entry).unwrap(); + + let state = capture_git_state(&project).await.unwrap(); + + assert_eq!( + state.unanswered, + vec![CaptureRepository::Project, CaptureRepository::Lpm], + "{entry:?}" + ); + let warnings = warnings_for(Some(state), false); + assert!(!warnings[0].contains("git ignores it"), "{entry:?}"); + assert!( + warnings[1].contains("a broken .git file"), + "{entry:?}: {warnings:?}" + ); + } + } + + #[tokio::test] + async fn says_nothing_of_git_for_an_lpm_git_entry_git_stops_at_or_looks_past() { + for entry in ["gitdir: ../gone/.git\n", "HEAD only"] { + let project = tempfile::tempdir().unwrap(); + let lpm_git = project.path().join(".lpm/.git"); + std::fs::create_dir_all(lpm_git.parent().unwrap()).unwrap(); + std::fs::write(project.path().join(".lpm/inspector.db"), b"x").unwrap(); + if entry == "HEAD only" { + head_only_entry(&project.path().join(".lpm")); + } else { + std::fs::write(&lpm_git, entry).unwrap(); + } + + let state = capture_git_state(project.path()).await; + + assert_eq!(state, None, "{entry:?}"); + assert!( + !warnings_for(state, false)[0].contains("git ignores it"), + "{entry:?}" + ); + } + } + + #[tokio::test] + async fn takes_lpm_for_a_repository_of_its_own_where_git_refused_or_couldn_t_run_there() { + let project = tempfile::tempdir().unwrap(); + repository_entry(&project.path().join(".lpm")); + + for (inside, unanswered) in [ + ( + CaptureGitFailure::Unavailable, + Some((vec![CaptureRepository::Lpm], false)), + ), + (CaptureGitFailure::Refused, Some((Vec::new(), true))), + (CaptureGitFailure::Stopped, None), + (CaptureGitFailure::NoRepository, None), + ] { + let (state, _) = state_from_answers( + project.path(), + Err(CaptureGitFailure::NoRepository), + Err(inside), + ) + .await; + assert_eq!( + state.map(|state| (state.unanswered, state.lpm_unasked)), + unanswered + ); + } + } + + #[tokio::test] + async fn asks_for_a_check_of_an_lpm_repository_git_refuses_outside_any_project_repository() { + let project = tempfile::tempdir().unwrap(); + let lpm_dir = project.path().join(".lpm"); + std::fs::create_dir(&lpm_dir).unwrap(); + git(&lpm_dir, &["init", "--quiet"]); + std::fs::write(lpm_dir.join("inspector.db"), b"x").unwrap(); + git(&lpm_dir, &["add", "inspector.db"]); + // A repository whose format git doesn't know, which it refuses. + git(&lpm_dir, &["config", "core.repositoryformatversion", "1"]); + git(&lpm_dir, &["config", "extensions.lpmUnknown", "true"]); + + let state = capture_git_state(project.path()).await; + + let warnings = warnings_for(state, false); + assert!(!warnings[0].contains("git ignores it"), "{warnings:?}"); + assert!( + warnings.iter().any(|warning| warning + == "git couldn't be asked about .lpm, so check that git ignores and doesn't \ + track inspector.db*"), + "{warnings:?}" + ); + } + + #[tokio::test] + async fn says_nothing_of_git_for_a_project_whose_own_git_file_points_nowhere_outside_any_repository() + { + for entry in ["gitdir: ../gone/.git\n", "gitdir: \n", "not a pointer\n"] { + let project = tempfile::tempdir().unwrap(); + std::fs::create_dir(project.path().join(".lpm")).unwrap(); + std::fs::write(project.path().join(".lpm/inspector.db"), b"x").unwrap(); + std::fs::write(project.path().join(".git"), entry).unwrap(); + + assert_eq!(capture_git_state(project.path()).await, None, "{entry:?}"); + } + } + + #[cfg(unix)] + #[tokio::test] + async fn says_nothing_of_git_for_a_project_whose_own_git_file_it_can_t_read_outside_any_repository() + { + use std::os::unix::fs::PermissionsExt as _; + + if lpm_common::current_uid() == 0 { + return; + } + let project = tempfile::tempdir().unwrap(); + std::fs::create_dir(project.path().join(".lpm")).unwrap(); + let git_file = project.path().join(".git"); + std::fs::write(&git_file, "gitdir: elsewhere\n").unwrap(); + std::fs::set_permissions(&git_file, std::fs::Permissions::from_mode(0o000)).unwrap(); + + let state = capture_git_state(project.path()).await; + + std::fs::set_permissions(&git_file, std::fs::Permissions::from_mode(0o600)).unwrap(); + assert_eq!(state, None); + } + + /// Makes `directory/.git` a git directory as `git init` leaves one: + /// `HEAD`, `objects` and `refs`. + fn repository_entry(directory: &Path) { + for name in ["objects", "refs/heads"] { + std::fs::create_dir_all(directory.join(".git").join(name)).unwrap(); + } + std::fs::write(directory.join(".git/HEAD"), "ref: refs/heads/main\n").unwrap(); + } + + /// Makes `directory/.git` a directory holding only `HEAD`, which git + /// looks past. + fn head_only_entry(directory: &Path) { + std::fs::create_dir_all(directory.join(".git")).unwrap(); + std::fs::write(directory.join(".git/HEAD"), "ref: refs/heads/main\n").unwrap(); + } + + #[test] + fn takes_a_git_entry_for_a_repository_s_only_as_git_would() { + let directory = tempfile::tempdir().unwrap(); + let git = directory.path().join(".git"); + + repository_entry(&directory.path().join("elsewhere")); + let absolute = format!( + "gitdir: {}\n", + directory.path().join("elsewhere/.git").display() + ); + for (contents, repository) in [ + ("", false), + ("not a pointer\n", false), + ("gitdir:elsewhere/.git\n", false), + ("gitdir: \n", false), + ("gitdir: gone/.git\n", false), + ("gitdir: elsewhere/.git\n", true), + ("gitdir: elsewhere/.git\r\n", true), + ("gitdir: elsewhere/.git\n\n\r\n", true), + (absolute.as_str(), true), + // Git reads the whole file as the path: a second line, or a + // space at its end, is part of it. + ("gitdir: elsewhere/.git\nsecond line\n", false), + ("gitdir: elsewhere/.git \n", false), + // A directory without `HEAD`, `objects` and `refs` isn't one. + ("gitdir: elsewhere\n", false), + ] { + std::fs::write(&git, contents).unwrap(); + assert_eq!(marks_a_repository(&git), repository, "{contents:?}"); + } + // Git reads a pointer of up to 1 MiB, and stops at a larger file. + let mut padded = b"gitdir: elsewhere/.git".to_vec(); + padded.resize(1024 * 1024, b'\n'); + std::fs::write(&git, &padded).unwrap(); + assert!(marks_a_repository(&git)); + padded.push(b'\n'); + std::fs::write(&git, &padded).unwrap(); + assert!(!marks_a_repository(&git)); + std::fs::remove_file(&git).unwrap(); + + std::fs::create_dir(&git).unwrap(); + assert!(!marks_a_repository(&git)); + head_only_entry(directory.path()); + assert!(!marks_a_repository(&git)); + repository_entry(directory.path()); + assert!(marks_a_repository(&git)); + + #[cfg(unix)] + { + let linked = directory.path().join("linked"); + std::fs::create_dir(&linked).unwrap(); + std::os::unix::fs::symlink(&git, linked.join(".git")).unwrap(); + assert!(marks_a_repository(&linked.join(".git"))); + // Git follows a link to a `.git` file too. + let pointer = directory.path().join("pointer"); + std::fs::write(&pointer, "gitdir: elsewhere/.git\n").unwrap(); + let linked_file = directory.path().join("linked-file"); + std::fs::create_dir(&linked_file).unwrap(); + std::os::unix::fs::symlink(&pointer, linked_file.join(".git")).unwrap(); + assert!(!marks_a_repository(&linked_file.join(".git"))); + std::os::unix::fs::symlink( + directory.path().join("elsewhere"), + linked_file.join("elsewhere"), + ) + .unwrap(); + assert!(marks_a_repository(&linked_file.join(".git"))); + } + assert!(!marks_a_repository(&directory.path().join("gone/.git"))); + + // Git reads a pointer as a C string, so it ends at a NUL. + let nul_dir = tempfile::tempdir().unwrap(); + repository_entry(&nul_dir.path().join("elsewhere")); + std::fs::write( + nul_dir.path().join(".git"), + b"gitdir: elsewhere/.git\0junk\n", + ) + .unwrap(); + assert!(marks_a_repository(&nul_dir.path().join(".git"))); + + // A pointer with no path is none, even in a git directory, which the + // empty path would otherwise name. + let bare = tempfile::tempdir().unwrap(); + for name in ["objects", "refs"] { + std::fs::create_dir(bare.path().join(name)).unwrap(); + } + std::fs::write(bare.path().join("HEAD"), "ref: refs/heads/main\n").unwrap(); + std::fs::write(bare.path().join(".git"), "gitdir: \n").unwrap(); + assert!(!marks_a_repository(&bare.path().join(".git"))); + } + + #[test] + fn takes_a_git_directory_only_as_git_s_is_git_directory_would() { + let directory = tempfile::tempdir().unwrap(); + let git = directory.path().join(".git"); + repository_entry(directory.path()); + let head = git.join("HEAD"); + // Git reads the first 255 bytes of it. + let within = format!("ref:{}refs/heads/main\n", " ".repeat(240)); + let past = format!("ref:{}refs/heads/main\n", " ".repeat(260)); + for (contents, repository) in [ + ("ref: refs/heads/main\n", true), + ("ref:refs/heads/main", true), + ("ref: \t\r\n refs/heads/main\n", true), + ("ref: \x0b refs/heads/main\n", false), + ("ref: \x0c refs/heads/main\n", false), + ("ref: heads/main\n", false), + (within.as_str(), true), + (past.as_str(), false), + ("refs/heads/main\n", false), + ("0123456789abcdef0123456789ABCDEF01234567\n", true), + ("0123456789abcdef0123456789abcdef01234567", true), + ("0123456789abcdef0123456789abcdef0123456\n", false), + ("0123456789abcdef0123456789abcdef0123456g\n", false), + ("", false), + ] { + std::fs::write(&head, contents).unwrap(); + assert_eq!(marks_a_repository(&git), repository, "{contents:?}"); + } + std::fs::write(&head, "ref: refs/heads/main\n").unwrap(); + + for missing in ["objects", "refs"] { + let aside = directory.path().join("aside"); + std::fs::rename(git.join(missing), &aside).unwrap(); + assert!(!marks_a_repository(&git), "{missing}"); + std::fs::rename(&aside, git.join(missing)).unwrap(); + } + assert!(marks_a_repository(&git)); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + + std::fs::remove_file(&head).unwrap(); + std::os::unix::fs::symlink("refs/heads/main", &head).unwrap(); + assert!(marks_a_repository(&git)); + std::fs::remove_file(&head).unwrap(); + std::os::unix::fs::symlink("../elsewhere", &head).unwrap(); + assert!(!marks_a_repository(&git)); + std::fs::remove_file(&head).unwrap(); + std::fs::write(&head, "ref: refs/heads/main\n").unwrap(); + + // Git checks it can search them, unless run as root. + if lpm_common::current_uid() != 0 { + let objects = git.join("objects"); + std::fs::set_permissions(&objects, std::fs::Permissions::from_mode(0o600)).unwrap(); + assert!(!marks_a_repository(&git)); + std::fs::set_permissions(&objects, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + } + } + + #[test] + fn takes_a_worktree_s_git_directory_by_its_common_directory() { + let directory = tempfile::tempdir().unwrap(); + repository_entry(directory.path()); + let worktree_git = directory.path().join(".git/worktrees/feature"); + std::fs::create_dir_all(&worktree_git).unwrap(); + std::fs::write(worktree_git.join("HEAD"), "ref: refs/heads/feature\n").unwrap(); + let checkout = directory.path().join("feature"); + std::fs::create_dir(&checkout).unwrap(); + std::fs::write( + checkout.join(".git"), + format!("gitdir: {}\n", worktree_git.display()), + ) + .unwrap(); + + // Without `commondir`, the worktree's own directory has no + // `objects` or `refs`. + assert!(!marks_a_repository(&checkout.join(".git"))); + for (common, repository) in [ + ("../..\n", true), + ("../..\r\n", true), + ("../../objects\n", false), + ("../gone\n", false), + // Git fails on an empty one. + ("\n", false), + ("", false), + ] { + std::fs::write(worktree_git.join("commondir"), common).unwrap(); + assert_eq!( + marks_a_repository(&checkout.join(".git")), + repository, + "{common:?}" + ); + } + std::fs::write( + worktree_git.join("commondir"), + format!("{}\n", directory.path().join(".git").display()), + ) + .unwrap(); + assert!(marks_a_repository(&checkout.join(".git"))); + } + + #[test] + fn reads_git_s_failure_from_the_start_of_its_fatal_line() { + for (stderr, failure) in [ + ( + "fatal: not a git repository (or any of the parent directories): .git\n", + CaptureGitFailure::NoRepository, + ), + ( + "fatal: Not a git repository (or any of the parent directories): .git\n", + CaptureGitFailure::NoRepository, + ), + ( + "fatal: not a git repository (or any parent up to mount point /mnt)\n\ + Stopping at filesystem boundary (GIT_DISCOVERY_ACROSS_FILESYSTEM not set).\n", + CaptureGitFailure::Stopped, + ), + ( + "fatal: Not a git repository (or any parent up to mount point /mnt)\n", + CaptureGitFailure::Stopped, + ), + ( + "fatal: invalid gitfile format: /work/app/.git\n", + CaptureGitFailure::Stopped, + ), + ( + "fatal: Invalid gitfile format: /work/app/.git\n", + CaptureGitFailure::Stopped, + ), + ( + "fatal: no path in gitfile: /work/app/.git\n", + CaptureGitFailure::Stopped, + ), + ( + "fatal: No path in gitfile: /work/app/.git\n", + CaptureGitFailure::Stopped, + ), + ( + "fatal: not a git repository: /work/gone/.git\n", + CaptureGitFailure::Stopped, + ), + ( + "fatal: error opening '/work/app/.git': Permission denied\n", + CaptureGitFailure::Stopped, + ), + ( + "fatal: too large to be a .git file: '/work/app/.git'\n", + CaptureGitFailure::Stopped, + ), + ( + "fatal: error reading /work/app/.git\n", + CaptureGitFailure::Stopped, + ), + ( + "fatal: Error reading /work/app/.git\n", + CaptureGitFailure::Stopped, + ), + ( + "fatal: error reading from /work/app/.git/index: Input/output error\n", + CaptureGitFailure::Refused, + ), + ( + "fatal: error reading directory '/work/app/src'\n", + CaptureGitFailure::Refused, + ), + ( + "fatal: error opening '/work/app/.git/index': Permission denied\n", + CaptureGitFailure::Refused, + ), + ( + "fatal: error opening '/work/it's/app/.git': Permission denied\n", + CaptureGitFailure::Stopped, + ), + ( + "fatal: error opening '/work/app/x.git': Permission denied\n", + CaptureGitFailure::Refused, + ), + ( + "fatal: error reading /work/app/x.git\n", + CaptureGitFailure::Refused, + ), + ("fatal: error reading .git\n", CaptureGitFailure::Stopped), + ( + "warning: unable to access '/home/user/.config/git/attributes'\n\ + fatal: not a git repository (or any of the parent directories): .git\n", + CaptureGitFailure::NoRepository, + ), + ( + "fatal: detected dubious ownership in repository at \ + '/work/not a git repository (or any of the parent directories)'\n", + CaptureGitFailure::Refused, + ), + ( + "warning: not a git repository (or any of the parent directories)\n", + CaptureGitFailure::Refused, + ), + ( + "fatal: detected dubious ownership in repository at '/work/app'\n", + CaptureGitFailure::Refused, + ), + ( + "fatal: cannot use bare repository '/work/app' (safe.bareRepository is 'explicit')\n", + CaptureGitFailure::Refused, + ), + ("", CaptureGitFailure::Refused), + ] { + assert_eq!(capture_git_failure(stderr.as_bytes()), failure, "{stderr}"); + } + } + + #[tokio::test] + async fn keeps_what_one_query_confirms_when_the_other_doesn_t_answer_in_time() { + let project = tempfile::tempdir().unwrap(); + repository_entry(project.path()); + std::fs::create_dir(project.path().join(".lpm")).unwrap(); + + let (tracked, _) = state_from_answers( + project.path(), + Ok(files(&[".lpm/inspector.db"], &[])), + Err(CaptureGitFailure::Unavailable), + ) + .await; + let (quiet, _) = state_from_answers( + project.path(), + Err(CaptureGitFailure::Unavailable), + Ok(CaptureGitFiles::default()), + ) + .await; + + let tracked = tracked.unwrap(); + assert_eq!(tracked.project_tracked, names(&[".lpm/inspector.db"])); + let warnings = warnings_for(Some(tracked), false); + assert!(warnings[0].ends_with(")"), "{warnings:?}"); + assert!( + warnings.iter().any( + |warning| warning.starts_with("git tracks '.lpm/inspector.db'") + && warning.contains("git rm --cached") + ), + "{warnings:?}" + ); + assert!( + warnings + .iter() + .any(|warning| warning.starts_with("git didn't answer in .lpm")), + "{warnings:?}" + ); + let quiet = warnings_for(quiet, false); + assert!(!quiet[0].contains("git ignores it"), "{quiet:?}"); + assert!( + quiet.iter().any( + |warning| warning.starts_with("git didn't answer for the project's repository") + ) + ); + } + + #[tokio::test] + async fn reports_what_git_in_lpm_finds_tracked_when_the_project_s_query_doesn_t_answer() { + let project = tempfile::tempdir().unwrap(); + repository_entry(project.path()); + std::fs::create_dir(project.path().join(".lpm")).unwrap(); + + let (state, _) = state_from_answers( + project.path(), + Err(CaptureGitFailure::Unavailable), + Ok(files(&[".lpm/inspector.db"], &[])), + ) + .await; + + let state = state.unwrap(); + assert_eq!(state.project_tracked, names(&[".lpm/inspector.db"])); + assert_eq!(state.unanswered, vec![CaptureRepository::Project]); + assert!(state.project_spelling_unknown); + let warnings = warnings_for(Some(state), false); + // Git in `.lpm` may spell the directory otherwise than the index, so + // the command matches every capture file without regard to case. + let command = format!("`git rm --cached -- \"{PROJECT_CAPTURES_PATHSPEC}\"`"); + assert!( + warnings.iter().any( + |warning| warning.starts_with("git tracks '.lpm/inspector.db'") + && warning.contains(&command) + ), + "{warnings:?}" + ); + + let (own, _) = state_from_answers( + project.path(), + Err(CaptureGitFailure::Unavailable), + Ok(files(&["inspector.db"], &[])), + ) + .await; + + let own = own.unwrap(); + assert!(own.project_tracked.is_empty(), "{own:?}"); + assert_eq!(own.lpm_tracked, names(&["inspector.db"])); + } + + #[test] + fn a_review_says_whether_a_tunnel_run_can_make_the_database_owner_only() { + assert_eq!(review_warning(false, false), None); + assert_eq!(review_warning(false, true), None); + assert!( + review_warning(true, false) + .unwrap() + .ends_with("makes it owner-only where file modes keep other users out") + ); + let shared = review_warning(true, true).unwrap(); + assert!( + shared.contains("its volume ignores ownership") + && !shared.contains("makes it owner-only"), + "{shared}" + ); + } + + fn warnings_for(state: Option, ignore_file_skipped: bool) -> Vec { + capture_warnings(state.as_ref(), ignore_file_skipped, CaptureStore::OwnerOnly) + } + + fn state( + project_tracked: &[&str], + lpm_tracked: &[&str], + unignored: &[&str], + ) -> CaptureGitState { + CaptureGitState { + project_tracked: names(project_tracked), + project_spelling_unknown: false, + lpm_tracked: names(lpm_tracked), + unignored: names(unignored), + lpm_unasked: false, + unanswered: Vec::new(), + } + } + + #[test] + fn says_git_ignores_captures_only_when_every_query_says_so() { + assert!(warnings_for(Some(state(&[], &[], &[])), false)[0].ends_with("; git ignores it")); + for unconfirmed in [ + None, + Some(CaptureGitState { + lpm_unasked: true, + unanswered: Vec::new(), + ..state(&[], &[], &[]) + }), + Some(state(&[".lpm/inspector.db"], &[], &[])), + Some(state(&[], &["inspector.db"], &[])), + Some(state(&[], &[], &["inspector.db-wal"])), + Some(CaptureGitState { + unanswered: vec![CaptureRepository::Project], + ..state(&[], &[], &[]) + }), + Some(CaptureGitState { + unanswered: vec![CaptureRepository::Lpm], + ..state(&[], &[], &[]) + }), + ] { + assert!( + !warnings_for(unconfirmed.clone(), false)[0].contains("git ignores it"), + "{unconfirmed:?}" + ); + } + } + + #[test] + fn says_when_git_couldnt_be_asked_about_lpm() { + let warnings = warnings_for( + Some(CaptureGitState { + lpm_unasked: true, + unanswered: Vec::new(), + ..state(&[], &[], &[]) + }), + false, + ); + + assert_eq!(warnings.len(), 2); + assert!(warnings[1].starts_with("git couldn't be asked about .lpm")); + assert_eq!(warnings_for(Some(state(&[], &[], &[])), false).len(), 1); + assert_eq!(warnings_for(None, false).len(), 1); + } + + #[test] + fn names_tracked_captures_and_the_command_that_stops_tracking_them() { + let warnings = warnings_for(Some(state(&[".lpm/inspector.db"], &[], &[])), false); + + assert_eq!(warnings.len(), 2); + assert_eq!( + warnings[1], + "git tracks '.lpm/inspector.db', so captures there can be committed and stay in \ + its history; run `git rm --cached -- .lpm/inspector.db`, commit, and rotate any \ + credentials they held" + ); + } + + #[test] + fn advises_on_captures_git_neither_tracks_nor_ignores() { + let added = warnings_for(Some(state(&[], &[], &["inspector.db-wal"])), false); + let replaced = warnings_for(Some(state(&[], &[], &["inspector.db-wal"])), true); + + assert_eq!( + added[1], + "git doesn't ignore '.lpm/inspector.db-wal'; add `inspector.db*` to .lpm/.gitignore" + ); + assert_eq!( + replaced[1], + "git doesn't ignore '.lpm/inspector.db-wal'; replace .lpm/.gitignore, which git \ + doesn't read as a file of the project's, with a file that holds `inspector.db*`" + ); + } + + #[test] + fn reports_a_capture_database_it_cant_open_yet_as_busy_with_its_own_advice() { + let error = inspector_open_error(lpm_inspect::db::OpenError::Files(std::io::Error::new( + std::io::ErrorKind::ResourceBusy, + "inspector.db is open to other users, and another process has it open; retry", + ))); + + let LpmError::Io(error) = error else { + panic!("{error:?}"); + }; + assert_eq!(error.kind(), std::io::ErrorKind::ResourceBusy); + assert_eq!( + error.to_string(), + "failed to open this project's .lpm/inspector.db: inspector.db is open to other \ + users, and another process has it open; retry" + ); + } + + #[test] + fn the_capture_warning_says_when_the_filesystem_leaves_captures_open_to_others() { + let open = capture_warnings(None, false, CaptureStore::OpenToOthers); + let owner_only = capture_warnings(None, false, CaptureStore::OwnerOnly); + + assert!( + open[0].contains( + "(open to other users, as this filesystem's file modes don't keep them out)" + ), + "{open:?}" + ); + assert!(!open[0].contains("owner-only"), "{open:?}"); + assert!(owner_only[0].contains("(owner-only on Unix; project ACLs on Windows)")); + } + + #[tokio::test] + async fn reports_a_failed_clear_as_a_write() { + let project = tempfile::tempdir().unwrap(); + let db = lpm_inspect::db::InspectorDb::open(project.path()).unwrap(); + rusqlite::Connection::open(project.path().join(".lpm/inspector.db")) + .unwrap() + .execute_batch("DROP TABLE requests_fts;") + .unwrap(); + + let error = clear_captures(&db).await.unwrap_err(); + + assert_eq!(error.error_code(), "io"); + assert!( + error + .to_string() + .contains("failed to clear .lpm/inspector.db: no such table: requests_fts"), + "{error}" + ); + } + + #[test] + fn reports_inspector_database_reads_as_disk_errors() { + let error = inspector_query_error("database disk image is malformed"); + + assert_eq!(error.error_code(), "io"); + assert!( + error + .to_string() + .contains("failed to read .lpm/inspector.db: database disk image is malformed") + ); + } + + #[test] + fn reports_inspector_database_writes_as_disk_errors() { + let error = inspector_write_error("commit captures to .lpm/inspector.db", "disk I/O error"); + + assert_eq!(error.error_code(), "io"); + assert!( + error + .to_string() + .contains("failed to commit captures to .lpm/inspector.db: disk I/O error") + ); + } + + #[test] + fn lists_at_most_five_names_quoted() { + let many: Vec = (0..7) + .map(|index| format!(".lpm/inspector.db{index}")) + .collect(); + + assert_eq!( + listed(&many), + "'.lpm/inspector.db0', '.lpm/inspector.db1', '.lpm/inspector.db2', \ + '.lpm/inspector.db3', '.lpm/inspector.db4' and 2 more" + ); + } + + #[test] + fn lists_names_with_other_characters_only_by_count() { + assert_eq!( + listed(&names(&[ + ".lpm/inspector.db", + ".lpm/inspector.db\u{202e}bd.x", + ".lpm/it\u{2019}s.db", + ])), + "'.lpm/inspector.db' and 2 more" + ); + assert_eq!(listed(&names(&[".lpm/inspector db"])), "1 capture file"); + assert_eq!( + listed(&names(&[".lpm/inspector db", ".lpm/inspector\u{200b}.db"])), + "2 capture files" + ); + } + + #[test] + fn untracks_listed_names_or_every_capture_file_past_the_list() { + let names = |count: usize, prefix: &str| -> Vec { + (0..count) + .map(|index| format!("{prefix}inspector.db{index}")) + .collect() + }; + + assert_eq!( + untrack_command(&names(5, ".lpm/"), CaptureRepository::Project), + "git rm --cached -- .lpm/inspector.db0 .lpm/inspector.db1 \ + .lpm/inspector.db2 .lpm/inspector.db3 .lpm/inspector.db4" + ); + assert_eq!( + untrack_command(&names(6, ".lpm/"), CaptureRepository::Project), + "git rm --cached -- \":(glob,icase).lpm/inspector.db*\"" + ); + assert_eq!( + untrack_command(&names(6, ""), CaptureRepository::Lpm), + "git -C .lpm rm --cached -- \":(glob,icase)inspector.db*\"" + ); + } + + #[test] + fn untracks_every_capture_file_when_a_name_has_other_characters() { + for name in [ + "inspector db", + "inspector\u{201d}.db", + "inspector\u{2019}.db", + "!'$(touch x)'.db", + "inspector\".db", + "%PATH%.db", + "", + ] { + assert_eq!( + untrack_command(&names(&["inspector.db", name]), CaptureRepository::Lpm), + "git -C .lpm rm --cached -- \":(glob,icase)inspector.db*\"", + "{name:?}" + ); + } + } + + #[cfg(unix)] + #[test] + fn tells_an_lpm_gitignore_git_skips_from_one_it_reads() { + let project = tempfile::tempdir().unwrap(); + let lpm_dir = project.path().join(".lpm"); + std::fs::create_dir(&lpm_dir).unwrap(); + assert!(!ignore_file_git_skips(project.path())); + + std::fs::write(lpm_dir.join(".gitignore"), "skills/\n").unwrap(); + assert!(!ignore_file_git_skips(project.path())); + + std::fs::remove_file(lpm_dir.join(".gitignore")).unwrap(); + std::fs::create_dir(lpm_dir.join(".gitignore")).unwrap(); + assert!(ignore_file_git_skips(project.path())); + + std::fs::remove_dir(lpm_dir.join(".gitignore")).unwrap(); + std::os::unix::fs::symlink(project.path().join("elsewhere"), lpm_dir.join(".gitignore")) + .unwrap(); + assert!(ignore_file_git_skips(project.path())); + } + + #[test] + fn lists_a_conflicted_capture_file_once() { + let files = parse_capture_git_files( + b"M inspector.db\0M inspector.db\0M inspector.db\0? inspector.db-wal\0", + ); + + assert_eq!(files.tracked, names(&["inspector.db"])); + assert_eq!(files.unignored, names(&["inspector.db-wal"])); + } + + #[cfg(unix)] + #[tokio::test] + async fn gives_up_on_a_slow_repository_and_stops_its_git() { + let directory = tempfile::tempdir().unwrap(); + let pid_file = directory.path().join("pid"); + let started = std::time::Instant::now(); + let mut command = tokio::process::Command::new("sh"); + command + .arg("-c") + .arg("echo $$ > \"$1\"; exec sleep 5") + .arg("sh") + .arg(&pid_file); + + let state = capture_git_output(command, std::time::Duration::from_millis(300)).await; + + assert_eq!(state, Err(CaptureGitFailure::Unavailable)); + assert!(started.elapsed() < std::time::Duration::from_secs(2)); + let pid = std::fs::read_to_string(&pid_file).unwrap(); + let stopped = |pid: &str| { + let output = std::process::Command::new("ps") + .args(["-o", "stat=", "-p", pid.trim()]) + .output() + .unwrap(); + let status = String::from_utf8_lossy(&output.stdout); + // Gone, or killed and not yet reaped. + status.trim().is_empty() || status.trim_start().starts_with('Z') + }; + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + while !stopped(&pid) && std::time::Instant::now() < deadline { + std::thread::sleep(std::time::Duration::from_millis(20)); + } + assert!(stopped(&pid), "the slow command is still running"); + } + #[test] fn non_interactive_tunnel_auth_output_never_contains_the_secret() { let output = tunnel_auth_header_summary("super-secret", false); @@ -2147,7 +4338,7 @@ mod tests { writer.push(webhook.clone()).await; writer.flush().await.unwrap(); - let observer = project_inspector_state(project.path()).unwrap(); + let observer = project_inspector_state(project.path(), false).unwrap(); let mut observed = observer.get_all_persisted().await.unwrap(); assert_eq!( diff --git a/crates/lpm-common/Cargo.toml b/crates/lpm-common/Cargo.toml index cd9ada7ab..de4d28da2 100644 --- a/crates/lpm-common/Cargo.toml +++ b/crates/lpm-common/Cargo.toml @@ -49,6 +49,7 @@ windows-sys = { version = "0.60", features = [ ] } [dev-dependencies] +static_assertions = "1" tempfile = "3" [lints] diff --git a/crates/lpm-common/src/bounded_read.rs b/crates/lpm-common/src/bounded_read.rs index f420cafcd..2a10c41f4 100644 --- a/crates/lpm-common/src/bounded_read.rs +++ b/crates/lpm-common/src/bounded_read.rs @@ -241,8 +241,11 @@ pub fn read_text_file_capped(path: &Path, limit: u64) -> Result Result { +/// Open a file for reading without following a linked leaf or blocking on a +/// special file: the handle is the entry at `path` itself, so checks made on +/// it hold for what the handle reads or changes, even if the path is +/// replaced concurrently. +pub fn open_nofollow(path: &Path) -> io::Result { let mut options = std::fs::OpenOptions::new(); options.read(true); #[cfg(unix)] @@ -258,7 +261,120 @@ pub fn read_text_file_capped_nofollow(path: &Path, limit: u64) -> Result u32 { + // SAFETY: geteuid takes no arguments, can't fail, and touches no memory + // of the caller's. + unsafe { libc::geteuid() } +} + +/// How many names the open file has, as hard links, on Unix and on Windows, +/// where the standard library doesn't say. +pub fn link_count(file: &std::fs::File) -> io::Result { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt as _; + + Ok(file.metadata()?.nlink()) + } + #[cfg(windows)] + { + Ok(u64::from(handle_information(file)?.nNumberOfLinks)) + } + #[cfg(not(any(unix, windows)))] + { + let _ = file; + Ok(1) + } +} + +#[cfg(windows)] +fn handle_information( + file: &std::fs::File, +) -> io::Result { + use std::os::windows::io::AsRawHandle as _; + use windows_sys::Win32::Storage::FileSystem::{ + BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle, + }; + + // SAFETY: an all-zero BY_HANDLE_FILE_INFORMATION is a valid value of its + // plain integer fields, and the call only writes into it. + let mut information: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() }; + // SAFETY: the handle is the open file's, valid while `file` lives. + if unsafe { GetFileInformationByHandle(file.as_raw_handle(), &mut information) } == 0 { + return Err(io::Error::last_os_error()); + } + Ok(information) +} + +/// Read a bounded regular file unless the entry at `path` is a link: a +/// symbolic link, or on Windows a junction or another name-surrogate reparse +/// point. Unlike [`read_text_file_capped_nofollow`], other reparse points, +/// such as a synced folder's cloud placeholders, are read through, from the +/// same file the check opened. The metadata is the read file's. +pub fn read_file_capped_unlinked( + path: &Path, + limit: u64, +) -> Result<(Vec, Metadata), BoundedReadError> { + let io_error = |source| BoundedReadError::Io { + path: path.to_path_buf(), + source, + }; + let file = open_nofollow(path).map_err(|source| { + if source.kind() == io::ErrorKind::NotFound { + BoundedReadError::NotFound { + path: path.to_path_buf(), + } + } else { + io_error(source) + } + })?; + let metadata = file.metadata().map_err(io_error)?; + if !metadata.is_file() { + return Err(io_error(io::Error::new( + io::ErrorKind::InvalidData, + "a link or not a regular file", + ))); + } + #[cfg(windows)] + let file = if metadata_is_link_or_reparse(&metadata) { + open_reparse_point_target(&file, path).map_err(io_error)? + } else { + file + }; + read_file_capped_from_open_file(file, path, limit) +} + +/// Opens a reparse point that isn't a link through its filter, as a cloud +/// placeholder must be read, and checks the file opened is the one `checked` +/// holds, so an entry swapped in between isn't read. +#[cfg(windows)] +fn open_reparse_point_target(checked: &std::fs::File, path: &Path) -> io::Result { + let target = std::fs::File::open(path)?; + let identity = + |information: windows_sys::Win32::Storage::FileSystem::BY_HANDLE_FILE_INFORMATION| { + ( + information.dwVolumeSerialNumber, + information.nFileIndexHigh, + information.nFileIndexLow, + ) + }; + if identity(handle_information(checked)?) != identity(handle_information(&target)?) { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "the file changed while it was opened", + )); + } + Ok(target) +} + +/// Read bounded UTF-8 text without following a linked leaf or blocking on a special file. +pub fn read_text_file_capped_nofollow(path: &Path, limit: u64) -> Result { + let file = open_nofollow(path).map_err(|source| { if source.kind() == io::ErrorKind::NotFound { BoundedReadError::NotFound { path: path.to_path_buf(), @@ -567,4 +683,57 @@ mod tests { Err(BoundedReadError::TooLarge { limit: 5, .. }) )); } + + #[test] + fn unlinked_read_returns_a_regular_file_s_bytes_and_metadata() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(".gitignore"); + std::fs::write(&path, b"skills/\n\xff").unwrap(); + + let (bytes, metadata) = read_file_capped_unlinked(&path, 32).unwrap(); + + assert_eq!(bytes, b"skills/\n\xff"); + assert_eq!(metadata.len(), 9); + assert!(matches!( + read_file_capped_unlinked(&dir.path().join("missing"), 32), + Err(BoundedReadError::NotFound { .. }) + )); + } + + #[cfg(unix)] + #[test] + fn unlinked_read_refuses_a_link_and_a_fifo_without_blocking() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("target"); + std::fs::write(&target, b"outside").unwrap(); + let link = dir.path().join("link"); + std::os::unix::fs::symlink(&target, &link).unwrap(); + let fifo = dir.path().join("fifo"); + let fifo_name = std::ffi::CString::new(fifo.as_os_str().as_encoded_bytes()).unwrap(); + // SAFETY: the name is a valid NUL-terminated path for the call. + assert_eq!(unsafe { libc::mkfifo(fifo_name.as_ptr(), 0o600) }, 0); + + for path in [&link, &fifo] { + assert!( + matches!( + read_file_capped_unlinked(path, 32), + Err(BoundedReadError::Io { .. }) + ), + "{}", + path.display() + ); + } + } + + #[test] + fn link_count_counts_a_file_s_hard_links() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("file"); + std::fs::write(&path, b"").unwrap(); + assert_eq!(link_count(&File::open(&path).unwrap()).unwrap(), 1); + + std::fs::hard_link(&path, dir.path().join("other-name")).unwrap(); + + assert_eq!(link_count(&File::open(&path).unwrap()).unwrap(), 2); + } } diff --git a/crates/lpm-common/src/lib.rs b/crates/lpm-common/src/lib.rs index c15f11cbf..66d75191d 100644 --- a/crates/lpm-common/src/lib.rs +++ b/crates/lpm-common/src/lib.rs @@ -17,9 +17,13 @@ pub mod integrity; pub mod known_projects; pub mod local_target; pub mod lpm_json; +#[cfg(target_os = "macos")] +pub mod macos_acl; pub mod npmrc; pub mod package_instance; pub mod package_name; +#[cfg(unix)] +pub mod path_trust; pub mod paths; pub mod peer; pub mod platform; @@ -28,6 +32,9 @@ pub mod project_glob; pub mod provenance; pub mod symlink; pub mod terminal; +// The 64-bit system call's groups are 32-bit; 32-bit systems have two calls. +#[cfg(all(target_os = "linux", target_pointer_width = "64"))] +pub mod thread_identity; mod utf8_bom; #[cfg(unix)] @@ -37,14 +44,17 @@ pub use atomic_write::{ write_file_atomic_in_dir_with_options, write_file_atomic_with, write_file_atomic_with_options, }; #[cfg(unix)] +pub use bounded_read::current_uid; +#[cfg(unix)] pub use bounded_read::permissions_are_owner_only; pub use bounded_read::{ BoundedReadError, CONFIG_FILE_SIZE_CAP_BYTES, NPMRC_FILE_SIZE_CAP_BYTES, - TLS_MATERIAL_AGGREGATE_CAP_BYTES, TLS_MATERIAL_FILE_SIZE_CAP_BYTES, read_file_capped, - read_file_capped_from_open_file, read_regular_file_capped_with_metadata, read_stream_capped, - read_text_file_capped, read_text_file_capped_from_open_file, - read_text_file_capped_from_open_file_with_known_size, read_text_file_capped_nofollow, - read_text_file_capped_with_metadata, read_text_regular_file_capped_with_metadata, + TLS_MATERIAL_AGGREGATE_CAP_BYTES, TLS_MATERIAL_FILE_SIZE_CAP_BYTES, link_count, open_nofollow, + read_file_capped, read_file_capped_from_open_file, read_file_capped_unlinked, + read_regular_file_capped_with_metadata, read_stream_capped, read_text_file_capped, + read_text_file_capped_from_open_file, read_text_file_capped_from_open_file_with_known_size, + read_text_file_capped_nofollow, read_text_file_capped_with_metadata, + read_text_regular_file_capped_with_metadata, }; pub use command_name::{PortableCommandNameError, validate_portable_command_name}; pub use error::{ @@ -70,10 +80,11 @@ pub use paths::{ ExclusiveLockHandle, FsKind, GLOBAL_INSTALL_PATH_BUDGET, INSTALL_READY_MARKER, LpmRoot, ProjectLockDirectory, ProjectLockKind, SharedLockHandle, SingleFileExclusiveLockHandle, SingleFileSharedLockHandle, acquire_exclusive_lock, acquire_single_file_exclusive_lock, - acquire_single_file_exclusive_lock_from_file, acquire_single_file_shared_lock, + acquire_single_file_exclusive_lock_from_file, + acquire_single_file_exclusive_lock_from_file_within, acquire_single_file_shared_lock, acquire_single_file_shared_lock_from_file, as_extended_path, check_install_path_budget, - is_local_fs, parent_install_lock_environment, project_install_lock, project_publish_lock, - try_acquire_capability_exclusive_lock, try_acquire_exclusive_lock, + is_local_fs, open_lock_file_nofollow, parent_install_lock_environment, project_install_lock, + project_publish_lock, try_acquire_capability_exclusive_lock, try_acquire_exclusive_lock, try_acquire_single_file_exclusive_lock_from_file, try_with_exclusive_lock, with_capability_exclusive_lock, with_exclusive_lock, with_exclusive_lock_async, with_project_exclusive_lock, with_project_exclusive_lock_async, with_project_shared_lock, @@ -310,6 +321,112 @@ pub fn is_loopback_host(host: &str) -> bool { false } +/// The bytes free to this user on the filesystem holding `path`, or its +/// nearest existing ancestor; None where it can't be read. +pub fn filesystem_available_bytes(path: &std::path::Path) -> Option { + filesystem_space(path).map(|space| space.available) +} + +/// The bytes a write by this user can take on the filesystem holding +/// `path`, or its nearest existing ancestor: what it reports free, less +/// what APFS keeps back from writes (see [`apfs_held_back`]). None where it +/// can't be read. +pub fn filesystem_writable_bytes(path: &std::path::Path) -> Option { + filesystem_space(path).map(|space| space.available.saturating_sub(space.held_back)) +} + +/// What a filesystem reports free to this user, and how much of that it +/// keeps back from writes. +struct FilesystemSpace { + available: u64, + held_back: u64, +} + +#[cfg(unix)] +fn filesystem_space(path: &std::path::Path) -> Option { + use std::ffi::CString; + use std::os::unix::ffi::OsStrExt; + + let mut probe = path.to_path_buf(); + while !probe.try_exists().ok()? { + if !probe.pop() { + return None; + } + } + let path = CString::new(probe.as_os_str().as_bytes()).ok()?; + // macOS's `statvfs` counts blocks in 32 bits, which can't hold the free + // blocks of a volume with more than 16 TiB free; its `statfs` counts in + // 64 bits. + #[cfg(target_os = "macos")] + let (available, held_back) = { + let mut stats = std::mem::MaybeUninit::::uninit(); + // SAFETY: `path` is NUL-terminated and `stats` points to writable, + // properly aligned storage that is read only after a successful call. + if unsafe { libc::statfs(path.as_ptr(), stats.as_mut_ptr()) } != 0 { + return None; + } + // SAFETY: `statfs` returned success and initialized the output structure. + let stats = unsafe { stats.assume_init() }; + let block = u128::from(stats.f_bsize); + let available = u128::from(stats.f_bavail).checked_mul(block)?; + let apfs = stats + .f_fstypename + .iter() + .take_while(|&&byte| byte != 0) + .map(|&byte| byte as u8) + .eq(*b"apfs"); + let held_back = if apfs { + let size = u128::from(stats.f_blocks).checked_mul(block)?; + apfs_held_back(u64::try_from(size).unwrap_or(u64::MAX)) + } else { + 0 + }; + (available, held_back) + }; + // `f_bavail` already leaves out what's reserved for root. + #[cfg(not(target_os = "macos"))] + let (available, held_back) = { + let mut stats = std::mem::MaybeUninit::::uninit(); + // SAFETY: `path` is NUL-terminated and `stats` points to writable, + // properly aligned storage that is read only after a successful call. + if unsafe { libc::statvfs(path.as_ptr(), stats.as_mut_ptr()) } != 0 { + return None; + } + // SAFETY: `statvfs` returned success and initialized the output structure. + let stats = unsafe { stats.assume_init() }; + ( + u128::from(stats.f_bavail).checked_mul(u128::from(stats.f_frsize))?, + 0, + ) + }; + Some(FilesystemSpace { + available: u64::try_from(available).ok()?, + held_back, + }) +} + +#[cfg(not(unix))] +fn filesystem_space(_path: &std::path::Path) -> Option { + None +} + +/// What APFS reports free on a volume of `size` bytes but refuses writes: +/// a stream of writes to one file stops with about 2% of the volume and +/// 460 KB more still reported free, and never more than 32 MiB and 80 KiB, +/// as measured on volumes of 64 MB to 8 GB. Rounded up by about half a MiB, +/// which also covers the blocks a file's own metadata takes. That's the +/// least a write needs: on a fresh volume, writes flushed as they go, or +/// spread over many files, stopped up to about 16 MB sooner, so a write +/// counted as fitting can still fail, as on a full disk. APFS sometimes +/// keeps back less: one run on macOS 26 let a stream take 0.7 MB more of a +/// 64 MB volume, so a write can also fit in a little more than this counts. +#[cfg(any(target_os = "macos", test))] +fn apfs_held_back(size: u64) -> u64 { + (size / 50) + .saturating_add(1024 * 1024) + .min(33 * 1024 * 1024) +} + /// Format bytes into a human-readable string (e.g., "1.2 KB", "3.4 MB"). pub fn format_bytes(bytes: u64) -> String { if bytes < 1024 { @@ -327,6 +444,163 @@ pub fn format_bytes(bytes: u64) -> String { mod tests { use super::*; + /// `df`'s figure for the filesystem holding `path`: its size and the + /// space free to this user, in bytes. + #[cfg(unix)] + fn df(path: &std::path::Path) -> (u64, u64) { + let output = std::process::Command::new("/bin/df") + .arg("-Pk") + .arg(path) + .output() + .unwrap(); + assert!(output.status.success()); + let output = String::from_utf8(output.stdout).unwrap(); + let fields: Vec = output + .lines() + .nth(1) + .unwrap() + .split_whitespace() + .skip(1) + .take(3) + .map(|field| field.parse().unwrap()) + .collect(); + (fields[0] * 1024, fields[2] * 1024) + } + + #[cfg(unix)] + #[test] + fn reads_the_space_free_to_this_user_as_df_does() { + let directory = tempfile::tempdir().unwrap(); + let (size, free) = df(directory.path()); + + let available = filesystem_available_bytes(directory.path()).unwrap(); + + // Other writers move the figure a little between the two reads; the + // space reserved for root, which this user can't use, is far more. + assert!( + available.abs_diff(free) <= size / 200, + "{available} against df's {free} of {size}" + ); + let unmade = filesystem_available_bytes(&directory.path().join("not/yet/made")).unwrap(); + assert!(unmade.abs_diff(available) <= size / 200); + } + + #[test] + fn apfs_holds_back_at_least_what_measured_volumes_kept_from_writes() { + // A volume's size, and what it still reported free once a write + // failed. + for (size, kept) in [ + (104_816_640, 2_564_096), + (314_531_840, 6_758_400), + (524_247_040, 10_952_704), + (1_073_700_864, 21_946_368), + (2_147_442_688, 33_636_352), + (4_085_211_136, 33_636_352), + (8_380_178_432, 33_636_352), + ] { + let held_back = apfs_held_back(size); + assert!(held_back >= kept, "{size}: {held_back} < {kept}"); + assert!( + held_back - kept < 1024 * 1024, + "{size}: {held_back} past {kept}" + ); + } + assert_eq!(apfs_held_back(u64::MAX), 33 * 1024 * 1024); + } + + #[cfg(unix)] + #[test] + fn on_any_filesystem_but_apfs_nothing_reported_free_counts_as_kept_back() { + let directory = tempfile::tempdir().unwrap(); + let space = filesystem_space(directory.path()).unwrap(); + if cfg!(target_os = "macos") && space.held_back > 0 { + return; + } + + assert_eq!(space.held_back, 0); + let writable = filesystem_writable_bytes(directory.path()).unwrap(); + let available = filesystem_available_bytes(directory.path()).unwrap(); + let (size, _) = df(directory.path()); + assert!(writable.abs_diff(available) <= size / 200); + } + + #[cfg(target_os = "macos")] + #[test] + fn on_apfs_a_stream_of_writes_takes_what_it_counts_writable_but_not_all_reported_free() { + use std::io::Write as _; + + let directory = tempfile::tempdir().unwrap(); + let image = directory.path().join("volume.sparseimage"); + let mount_point = directory.path().join("mounted"); + std::fs::create_dir(&mount_point).unwrap(); + let hdiutil = |arguments: &[&std::ffi::OsStr]| { + let output = std::process::Command::new("/usr/bin/hdiutil") + .args(arguments) + .output() + .unwrap(); + assert!( + output.status.success(), + "hdiutil {arguments:?}: {}", + String::from_utf8_lossy(&output.stderr) + ); + }; + hdiutil(&[ + "create".as_ref(), + "-quiet".as_ref(), + "-size".as_ref(), + "64m".as_ref(), + "-fs".as_ref(), + "APFS".as_ref(), + "-type".as_ref(), + "SPARSE".as_ref(), + "-volname".as_ref(), + "lpm-common-test".as_ref(), + image.as_os_str(), + ]); + hdiutil(&[ + "attach".as_ref(), + "-quiet".as_ref(), + "-nobrowse".as_ref(), + "-noautoopen".as_ref(), + "-mountpoint".as_ref(), + mount_point.as_os_str(), + image.as_os_str(), + ]); + struct Detach<'a>(&'a std::path::Path); + impl Drop for Detach<'_> { + fn drop(&mut self) { + let _ = std::process::Command::new("/usr/bin/hdiutil") + .args(["detach", "-force", "-quiet"]) + .arg(self.0) + .status(); + } + } + let _detach = Detach(&mount_point); + + let space = filesystem_space(&mount_point).unwrap(); + assert!(space.held_back > 0); + let writable = filesystem_writable_bytes(&mount_point).unwrap(); + let mut filler = std::fs::File::create(mount_point.join("filler")).unwrap(); + let mut written = 0u64; + for chunk in [64 * 1024, 4096] { + let block = vec![0; chunk]; + while filler.write_all(&block).is_ok() { + written += chunk as u64; + } + } + + // How much of its reserve APFS lets a stream take varies by release + // and run, so how closely the reserve matches is checked against + // measured volumes in `apfs_holds_back_at_least_what_measured_volumes_kept_from_writes`. + assert_eq!(writable, space.available - space.held_back); + assert!(written >= writable, "wrote {written} of {writable}"); + assert!( + written < space.available, + "wrote {written}, all of the {} reported free", + space.available + ); + } + // ── read_capped_state_file ──────────────────────────────────────── /// Files under the cap round-trip transparently. diff --git a/crates/lpm-common/src/macos_acl.rs b/crates/lpm-common/src/macos_acl.rs new file mode 100644 index 000000000..9a47583cd --- /dev/null +++ b/crates/lpm-common/src/macos_acl.rs @@ -0,0 +1,308 @@ +//! Access on macOS that the file mode doesn't show. Extended access control +//! list entries grant it: an `everyone allow read` entry, inherited from a +//! directory, makes a 0600 file readable by any user. So does a volume that +//! ignores ownership, on which every user owns every file. + +use std::ffi::{CString, c_char, c_int, c_void}; +use std::io; +use std::os::fd::{AsFd, AsRawFd as _}; +use std::os::unix::ffi::OsStrExt as _; +use std::path::Path; + +const ACL_TYPE_EXTENDED: c_int = 0x0000_0100; + +const ACL_FIRST_ENTRY: c_int = 0; +const ACL_NEXT_ENTRY: c_int = -1; +const ACL_EXTENDED_ALLOW: c_int = 1; +/// What lets someone change a directory's entries, or who may: adding a +/// file or a subdirectory, deleting a child, and changing its ACL or owner. +const ENTRY_CHANGING_PERMISSIONS: [c_int; 5] = [1 << 2, 1 << 5, 1 << 6, 1 << 12, 1 << 13]; + +unsafe extern "C" { + fn acl_free(value: *mut c_void) -> c_int; + fn acl_get_entry(acl: *mut c_void, entry_id: c_int, entry: *mut *mut c_void) -> c_int; + fn acl_get_fd_np(file_descriptor: c_int, acl_type: c_int) -> *mut c_void; + fn acl_get_perm_np(permset: *mut c_void, perm: c_int) -> c_int; + fn acl_get_permset(entry: *mut c_void, permset: *mut *mut c_void) -> c_int; + fn acl_get_tag_type(entry: *mut c_void, tag: *mut c_int) -> c_int; + fn acl_get_link_np(path: *const c_char, acl_type: c_int) -> *mut c_void; + fn acl_init(entry_count: c_int) -> *mut c_void; + fn acl_set_fd_np(file_descriptor: c_int, acl: *mut c_void, acl_type: c_int) -> c_int; +} + +/// Whether the volume holding the open file or directory ignores ownership, +/// as one mounted `noowners` does, which external drives are by default: +/// every user is then the owner of its files, whatever their mode. +pub fn ignores_ownership(handle: impl AsFd) -> io::Result { + let mut volume = std::mem::MaybeUninit::::uninit(); + // SAFETY: fstatfs fills `volume` from a descriptor that stays open while + // `handle` is borrowed. + if unsafe { libc::fstatfs(handle.as_fd().as_raw_fd(), volume.as_mut_ptr()) } != 0 { + return Err(io::Error::last_os_error()); + } + // SAFETY: fstatfs succeeded, so it filled `volume`. + let flags = unsafe { volume.assume_init() }.f_flags; + Ok(flags & libc::MNT_IGNORE_OWNERSHIP as u32 != 0) +} + +/// Whether the open file or directory has extended ACL entries. +pub fn has_extended_acl(handle: impl AsFd) -> io::Result { + let file_descriptor = handle.as_fd().as_raw_fd(); + // SAFETY: acl_get_fd_np reads a descriptor that stays open while + // `handle` is borrowed. + extended_acl_found(|| unsafe { acl_get_fd_np(file_descriptor, ACL_TYPE_EXTENDED) }) +} + +/// Whether the entry at `path` has extended ACL entries, read without +/// following a link there. It opens nothing, so it leaves alone the locks +/// this process holds on the file, which closing any descriptor of it would +/// drop. An entry that's gone has none. +pub fn has_extended_acl_at(path: &Path) -> io::Result { + let path = CString::new(path.as_os_str().as_bytes()) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?; + // SAFETY: acl_get_link_np reads a NUL-terminated path that outlives the + // call. + extended_acl_found(|| unsafe { acl_get_link_np(path.as_ptr(), ACL_TYPE_EXTENDED) }) +} + +/// Runs an `acl_get_*_np` call and frees what it returns. Null with `ENOENT` +/// means the entry has no extended ACL, or is gone. +fn extended_acl_found(get: impl FnOnce() -> *mut c_void) -> io::Result { + // SAFETY: errno is this thread's own, and the ACL `get` returns, if not + // null, is freed once. + unsafe { + *libc::__error() = 0; + let acl = get(); + if acl.is_null() { + let error = *libc::__error(); + return if error == 0 || error == libc::ENOENT { + Ok(false) + } else { + Err(io::Error::from_raw_os_error(error)) + }; + } + if acl_free(acl) == 0 { + Ok(true) + } else { + Err(io::Error::last_os_error()) + } + } +} + +/// Whether the open directory's extended ACL allows anyone to add, remove +/// or rename its entries, or to change its ACL or owner. Every allow entry +/// counts, whoever it names, as the directory's owner needs none. +pub fn allows_entry_changes(handle: impl AsFd) -> io::Result { + let file_descriptor = handle.as_fd().as_raw_fd(); + // SAFETY: errno is this thread's own; the ACL is freed once, and the + // entries and permission sets read from it are used only before that. + unsafe { + *libc::__error() = 0; + let acl = acl_get_fd_np(file_descriptor, ACL_TYPE_EXTENDED); + if acl.is_null() { + let error = *libc::__error(); + return if error == 0 || error == libc::ENOENT { + Ok(false) + } else { + Err(io::Error::from_raw_os_error(error)) + }; + } + let allows = allow_entry_changes_in(acl); + if acl_free(acl) != 0 && allows.is_ok() { + return Err(io::Error::last_os_error()); + } + allows + } +} + +/// # Safety +/// `acl` must be a live ACL from `acl_get_fd_np`. +unsafe fn allow_entry_changes_in(acl: *mut c_void) -> io::Result { + let mut entry = std::ptr::null_mut(); + let mut entry_id = ACL_FIRST_ENTRY; + // SAFETY: as the caller guarantees; each out-pointer is a local. + unsafe { + while acl_get_entry(acl, entry_id, &mut entry) == 0 { + entry_id = ACL_NEXT_ENTRY; + let mut tag = 0; + if acl_get_tag_type(entry, &mut tag) != 0 { + return Err(io::Error::last_os_error()); + } + if tag != ACL_EXTENDED_ALLOW { + continue; + } + let mut permset = std::ptr::null_mut(); + if acl_get_permset(entry, &mut permset) != 0 { + return Err(io::Error::last_os_error()); + } + for permission in ENTRY_CHANGING_PERMISSIONS { + match acl_get_perm_np(permset, permission) { + 0 => {} + 1 => return Ok(true), + _ => return Err(io::Error::last_os_error()), + } + } + } + } + Ok(false) +} + +/// Removes every extended ACL entry from the open file or directory, and +/// checks that none remain. +pub fn clear_extended_acl(handle: impl AsFd) -> io::Result<()> { + let file_descriptor = handle.as_fd().as_raw_fd(); + // SAFETY: acl_init returns either null or an empty ACL this function + // frees once; acl_set_fd_np only reads it, and writes to a descriptor + // that stays open while `handle` is borrowed. + let error = unsafe { + let acl = acl_init(0); + if acl.is_null() { + return Err(io::Error::last_os_error()); + } + let set = acl_set_fd_np(file_descriptor, acl, ACL_TYPE_EXTENDED); + let error = (set != 0).then(io::Error::last_os_error); + if acl_free(acl) != 0 && error.is_none() { + return Err(io::Error::last_os_error()); + } + error + }; + if let Some(error) = error { + return Err(error); + } + if has_extended_acl(handle)? { + return Err(io::Error::other( + "extended access control remained after removal", + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_volume_that_keeps_ownership_reads_as_such() { + let directory = tempfile::tempdir().unwrap(); + let handle = std::fs::File::open(directory.path()).unwrap(); + + assert!(!ignores_ownership(&handle).unwrap()); + } + + struct AttachedImage(std::path::PathBuf); + + impl Drop for AttachedImage { + fn drop(&mut self) { + let _ = std::process::Command::new("/usr/bin/hdiutil") + .args(["detach", "-force", "-quiet"]) + .arg(&self.0) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status(); + } + } + + fn hdiutil(arguments: &[&std::ffi::OsStr]) { + let output = std::process::Command::new("/usr/bin/hdiutil") + .args(arguments) + .output() + .unwrap(); + assert!( + output.status.success(), + "hdiutil {arguments:?}: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + #[test] + fn a_volume_mounted_without_owners_reads_as_ignoring_ownership() { + let directory = tempfile::tempdir().unwrap(); + let image = directory.path().join("volume.dmg"); + let mount_point = directory.path().join("mounted"); + std::fs::create_dir(&mount_point).unwrap(); + hdiutil(&[ + "create".as_ref(), + "-quiet".as_ref(), + "-size".as_ref(), + "2m".as_ref(), + "-fs".as_ref(), + "HFS+".as_ref(), + "-volname".as_ref(), + "lpm-noowners".as_ref(), + image.as_os_str(), + ]); + hdiutil(&[ + "attach".as_ref(), + "-quiet".as_ref(), + "-nobrowse".as_ref(), + "-noautoopen".as_ref(), + "-owners".as_ref(), + "off".as_ref(), + "-mountpoint".as_ref(), + mount_point.as_os_str(), + image.as_os_str(), + ]); + let attached = AttachedImage(mount_point); + + let handle = std::fs::File::open(&attached.0).unwrap(); + + assert!(ignores_ownership(&handle).unwrap()); + } + + fn add_entry(path: &std::path::Path, entry: &str) { + let status = std::process::Command::new("/bin/chmod") + .args(["+a", entry]) + .arg(path) + .status() + .unwrap(); + assert!(status.success()); + } + + #[test] + fn finds_and_clears_a_file_s_extended_entries() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("captures"); + std::fs::write(&path, b"").unwrap(); + let file = std::fs::File::open(&path).unwrap(); + assert!(!has_extended_acl(&file).unwrap()); + + add_entry(&path, "group:everyone allow read"); + assert!(has_extended_acl(&file).unwrap()); + + clear_extended_acl(&file).unwrap(); + assert!(!has_extended_acl(&file).unwrap()); + } + + #[test] + fn reads_an_entry_s_extended_entries_by_path_without_following_a_link() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("captures"); + let link = directory.path().join("link"); + std::fs::write(&path, b"").unwrap(); + std::os::unix::fs::symlink(&path, &link).unwrap(); + assert!(!has_extended_acl_at(&path).unwrap()); + + add_entry(&path, "group:everyone allow read"); + + assert!(has_extended_acl_at(&path).unwrap()); + assert!(!has_extended_acl_at(&link).unwrap()); + assert!(!has_extended_acl_at(&directory.path().join("gone")).unwrap()); + } + + #[test] + fn clears_a_directory_s_inheritable_entries() { + let directory = tempfile::tempdir().unwrap(); + add_entry( + directory.path(), + "group:everyone allow read,write,file_inherit,directory_inherit", + ); + let handle = std::fs::File::open(directory.path()).unwrap(); + + clear_extended_acl(&handle).unwrap(); + + std::fs::write(directory.path().join("created"), b"").unwrap(); + let created = std::fs::File::open(directory.path().join("created")).unwrap(); + assert!(!has_extended_acl(&handle).unwrap()); + assert!(!has_extended_acl(&created).unwrap()); + } +} diff --git a/crates/lpm-common/src/path_trust.rs b/crates/lpm-common/src/path_trust.rs new file mode 100644 index 000000000..3ef832483 --- /dev/null +++ b/crates/lpm-common/src/path_trust.rs @@ -0,0 +1,781 @@ +//! Whether other users can change what a path leads to. A directory's +//! entries can be renamed, removed or replaced by whoever may write to it, +//! or change its mode or ACL, so files opened by name further down are only +//! as safe as every directory on the way. + +use std::ffi::{CStr, CString, OsStr}; +use std::io; +use std::os::unix::ffi::OsStrExt as _; +use std::os::unix::fs::MetadataExt as _; +use std::path::{Component, Path, PathBuf}; + +/// A directory on a path that a user other than the one trusted, or root, +/// can change the entries of, and why. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Changeable { + pub directory: PathBuf, + pub reason: &'static str, +} + +/// How many links a path may pass through, as Linux allows. +const MAX_LINKS: usize = 40; + +/// The first directory, resolving `path` a component at a time as the +/// kernel does, whose entries a user other than `uid` and root can change: +/// one another user owns; one its group may write, unless its group is +/// `uid`'s own and has no other member, as with user-private groups; one +/// anyone may write; and one whose ACL lets others add or remove entries. +/// A sticky directory, as `/tmp`, is safe even so for an entry `uid` or +/// root owns, which only they may move. A link is followed, and its target +/// resolved the same way. None when every directory consulted is safe. +pub fn first_changeable_directory(path: &Path, uid: u32) -> io::Result> { + let absolute = if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir()?.join(path) + }; + let mut pending: Vec = Vec::new(); + push_components(&mut pending, &absolute); + let mut current = PathBuf::from("/"); + if let Some(found) = changeable(¤t, &std::fs::metadata(¤t)?, uid)? { + return Ok(Some(found)); + } + let mut links = 0; + while let Some(name) = pending.pop() { + if name == OsStr::new("/") { + current = PathBuf::from("/"); + continue; + } + if name == OsStr::new("..") { + current.pop(); + continue; + } + let parent = std::fs::metadata(¤t)?; + let next = current.join(&name); + let entry = std::fs::symlink_metadata(&next)?; + if parent.mode() & 0o1000 != 0 + && entry.uid() != uid + && entry.uid() != 0 + && others_may_add(&parent, uid, Acl::At(¤t))?.is_some() + { + return Ok(Some(Changeable { + directory: current, + reason: "others may add to it, and the entry on the path belongs to another user", + })); + } + if entry.file_type().is_symlink() { + links += 1; + if links > MAX_LINKS { + return Err(io::Error::from_raw_os_error(libc::ELOOP)); + } + let target = std::fs::read_link(&next)?; + push_components(&mut pending, &target); + continue; + } + current = next; + if entry.is_dir() + && let Some(found) = changeable(¤t, &entry, uid)? + { + return Ok(Some(found)); + } + } + Ok(None) +} + +/// Pushes `path`'s components onto `pending` so they pop in order; an +/// absolute path starts from the root again. +fn push_components(pending: &mut Vec, path: &Path) { + for component in path.components().rev() { + match component { + Component::RootDir => pending.push("/".into()), + Component::ParentDir => pending.push("..".into()), + Component::Normal(name) => pending.push(name.to_os_string()), + Component::CurDir | Component::Prefix(_) => {} + } + } +} + +/// Where a directory's ACL is read from: its path, which isn't followed +/// if it's a link, or a handle open on it. +#[derive(Clone, Copy)] +enum Acl<'a> { + At(&'a Path), + Of(&'a std::fs::File), +} + +fn changeable( + directory: &Path, + metadata: &std::fs::Metadata, + uid: u32, +) -> io::Result> { + Ok( + why_changeable(metadata, uid, Acl::At(directory))?.map(|reason| Changeable { + directory: directory.to_path_buf(), + reason, + }), + ) +} + +/// Why users other than `uid` and root can change the entries of the +/// directory `metadata` describes, if they can; in a sticky one, only an +/// entry's owner can move it. +fn why_changeable( + metadata: &std::fs::Metadata, + uid: u32, + acl: Acl<'_>, +) -> io::Result> { + if metadata.uid() != uid && metadata.uid() != 0 { + return Ok(Some("it belongs to another user")); + } + if metadata.mode() & 0o1000 == 0 { + return others_may_add(metadata, uid, acl); + } + #[cfg(target_os = "macos")] + if acl_allows_entry_changes(acl)? { + return Ok(Some(MACOS_ACL_REASON)); + } + Ok(None) +} + +/// Why users other than `uid` and root may add entries to the directory +/// `metadata` describes, sticky or not, if they may. +#[cfg_attr( + not(any(target_os = "linux", target_os = "macos")), + allow(unused_variables) +)] +fn others_may_add( + metadata: &std::fs::Metadata, + uid: u32, + acl: Acl<'_>, +) -> io::Result> { + #[cfg(target_os = "macos")] + if acl_allows_entry_changes(acl)? { + return Ok(Some(MACOS_ACL_REASON)); + } + let mode = metadata.mode(); + if mode & 0o002 != 0 { + return Ok(Some("anyone may write to it")); + } + if mode & 0o020 == 0 { + return Ok(None); + } + // With an ACL, the group's bits are the most any of its entries + // grants, and other users may have entries of their own. + #[cfg(target_os = "linux")] + if has_posix_acl(acl)? { + return Ok(Some("its access control list may let others write to it")); + } + if group_is_private(metadata.gid(), uid)? { + return Ok(None); + } + Ok(Some("its group, which has other members, may write to it")) +} + +#[cfg(target_os = "macos")] +const MACOS_ACL_REASON: &str = "its access control list lets others change its entries"; + +/// Whether a directory's ACL lets others add, remove or rename its +/// entries, whatever its mode, as a macOS ACL can; a Linux ACL can grant +/// no more than its mode's group bits show. +#[cfg(target_os = "macos")] +fn acl_allows_entry_changes(acl: Acl<'_>) -> io::Result { + match acl { + Acl::At(directory) => { + crate::macos_acl::allows_entry_changes(crate::open_nofollow(directory)?) + } + Acl::Of(handle) => crate::macos_acl::allows_entry_changes(handle), + } +} + +/// Whether a directory has an access ACL beyond its mode, which Linux +/// keeps in an extended attribute; one on a filesystem without them has +/// none. +#[cfg(target_os = "linux")] +fn has_posix_acl(acl: Acl<'_>) -> io::Result { + use std::os::fd::AsRawFd as _; + + const NAME: &CStr = c"system.posix_acl_access"; + // SAFETY: the strings are NUL-terminated and outlive the calls, the + // descriptor is open for the call, and a size of 0 only asks the + // attribute's length, writing nothing. + let size = match acl { + Acl::At(directory) => { + let path = CString::new(directory.as_os_str().as_bytes()) + .map_err(|_| io::Error::from(io::ErrorKind::InvalidInput))?; + unsafe { libc::lgetxattr(path.as_ptr(), NAME.as_ptr(), std::ptr::null_mut(), 0) } + } + Acl::Of(handle) => unsafe { + libc::fgetxattr(handle.as_raw_fd(), NAME.as_ptr(), std::ptr::null_mut(), 0) + }, + }; + if size >= 0 { + return Ok(true); + } + let error = io::Error::last_os_error(); + match error.raw_os_error() { + Some(libc::ENODATA | libc::ENOTSUP) => Ok(false), + _ => Err(error), + } +} + +/// A user's name, primary group and every group they're in, from the +/// system's user database; None for a user it doesn't know, as a host's +/// user in a container. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Account { + pub name: CString, + pub gid: u32, +} + +/// The account of `uid`, or None where the user database has none. +pub fn account(uid: u32) -> io::Result> { + let mut buffer = vec![0u8; 1024]; + loop { + let mut entry: libc::passwd = unsafe { std::mem::zeroed() }; + let mut result: *mut libc::passwd = std::ptr::null_mut(); + // SAFETY: `entry`, `buffer` and `result` outlive the call, which + // writes no more than `buffer.len()` bytes into `buffer`. + let error = unsafe { + libc::getpwuid_r( + uid, + &mut entry, + buffer.as_mut_ptr().cast(), + buffer.len(), + &mut result, + ) + }; + if error == libc::ERANGE && buffer.len() < 1 << 20 { + buffer.resize(buffer.len() * 2, 0); + continue; + } + if error != 0 { + return Err(io::Error::from_raw_os_error(error)); + } + if result.is_null() { + return Ok(None); + } + // SAFETY: on success `pw_name` points at a NUL-terminated name + // within `buffer`. + let name = unsafe { CStr::from_ptr(entry.pw_name) }.to_owned(); + return Ok(Some(Account { + name, + gid: entry.pw_gid, + })); + } +} + +/// A group's name and the names in its member list. +struct Group { + name: CString, + members: Vec, +} + +/// Group `gid`, or None for a group the database doesn't know. +fn group(gid: u32) -> io::Result> { + let mut buffer = vec![0u8; 4096]; + loop { + let mut entry: libc::group = unsafe { std::mem::zeroed() }; + let mut result: *mut libc::group = std::ptr::null_mut(); + // SAFETY: as in `account`. + let error = unsafe { + libc::getgrgid_r( + gid, + &mut entry, + buffer.as_mut_ptr().cast(), + buffer.len(), + &mut result, + ) + }; + if error == libc::ERANGE && buffer.len() < 1 << 22 { + buffer.resize(buffer.len() * 2, 0); + continue; + } + if error != 0 { + return Err(io::Error::from_raw_os_error(error)); + } + if result.is_null() { + return Ok(None); + } + // SAFETY: on success `gr_name` points at a NUL-terminated name + // within `buffer`. + let name = unsafe { CStr::from_ptr(entry.gr_name) }.to_owned(); + let mut members = Vec::new(); + let mut member = entry.gr_mem; + // SAFETY: `gr_mem` is a NULL-terminated array of NUL-terminated + // names within `buffer`. + unsafe { + while !member.is_null() && !(*member).is_null() { + members.push(CStr::from_ptr(*member).to_owned()); + member = member.add(1); + } + } + return Ok(Some(Group { name, members })); + } +} + +/// Whether group `gid` is `uid`'s alone, as a user-private group is: +/// `uid`'s primary group, named after them, listing no other member. Users +/// whose primary group it is aren't listed as members, so a group many +/// users share that way, as `users`, isn't taken for one; its name isn't +/// theirs. +pub fn group_is_private(gid: u32, uid: u32) -> io::Result { + let Some(account) = account(uid)? else { + return Ok(false); + }; + if account.gid != gid { + return Ok(false); + } + Ok(group(gid)?.is_some_and(|group| { + group.name == account.name && group.members.iter().all(|member| *member == account.name) + })) +} + +/// The groups `account` is in, its primary group first, as the user +/// database lists them. +#[cfg(target_os = "linux")] +pub fn groups_of(account: &Account) -> io::Result> { + let mut count: libc::c_int = 32; + loop { + let mut groups = vec![0 as libc::gid_t; count as usize]; + let mut wanted = count; + // SAFETY: `groups` has room for `wanted` IDs; the call writes how + // many it found, or needs, into `wanted`. + let found = unsafe { + libc::getgrouplist( + account.name.as_ptr(), + account.gid, + groups.as_mut_ptr(), + &mut wanted, + ) + }; + if found >= 0 { + groups.truncate(wanted as usize); + return Ok(groups); + } + if wanted <= count || wanted > 1 << 16 { + return Err(io::Error::other("the user's groups can't be listed")); + } + count = wanted; + } +} + +/// Gives what root left in `directory` to `uid` and `gid`, as a command run +/// with `sudo` leaves what it makes root's: the regular files `names` +/// there, then `directory`. Only while `directory` is root's and no one +/// else may change its entries, so nothing in it can have been moved there +/// from elsewhere; and only files with no other hard link, each through a +/// handle that doesn't follow a link, so nothing elsewhere is given away. +/// Returns those of `names` root still owns there, other than links. A +/// missing `directory`, or a link, has nothing to give. +pub fn give_root_entries( + directory: &Path, + names: &[&OsStr], + uid: u32, + gid: u32, +) -> io::Result> { + let flags = libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK; + let path = CString::new(directory.as_os_str().as_bytes()) + .map_err(|_| io::Error::from(io::ErrorKind::InvalidInput))?; + // SAFETY: `path` is NUL-terminated and outlives the call. + let directory_fd = unsafe { libc::open(path.as_ptr(), flags | libc::O_DIRECTORY) }; + if directory_fd < 0 { + let error = io::Error::last_os_error(); + if matches!( + error.raw_os_error(), + Some(libc::ENOENT | libc::ELOOP | libc::ENOTDIR) + ) { + return Ok(Vec::new()); + } + return Err(error); + } + // SAFETY: the descriptor was just opened and is owned here. + let directory_handle = + unsafe { ::from_raw_fd(directory_fd) }; + let directory_metadata = directory_handle.metadata()?; + let roots_alone = directory_metadata.uid() == 0 + && others_may_add(&directory_metadata, 0, Acl::Of(&directory_handle))?.is_none(); + let mut kept = Vec::new(); + for name in names { + let c_name = CString::new(name.as_bytes()) + .map_err(|_| io::Error::from(io::ErrorKind::InvalidInput))?; + // SAFETY: the directory descriptor is open and `c_name` is + // NUL-terminated. + let fd = unsafe { libc::openat(directory_fd, c_name.as_ptr(), flags) }; + if fd < 0 { + let error = io::Error::last_os_error(); + // A link is left for the opening to refuse, whoever made it. + if matches!(error.raw_os_error(), Some(libc::ENOENT | libc::ELOOP)) { + continue; + } + return Err(error); + } + // SAFETY: as above. + let handle = unsafe { ::from_raw_fd(fd) }; + let metadata = handle.metadata()?; + if metadata.uid() != 0 { + continue; + } + if roots_alone && metadata.is_file() && metadata.nlink() == 1 { + std::os::unix::fs::fchown(&handle, Some(uid), Some(gid))?; + } else { + kept.push(name.to_os_string()); + } + } + if roots_alone { + std::os::unix::fs::fchown(&directory_handle, Some(uid), Some(gid))?; + } + Ok(kept) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::os::unix::fs::PermissionsExt as _; + + fn set_mode(path: &Path, mode: u32) { + std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).unwrap(); + } + + fn me() -> u32 { + crate::current_uid() + } + + #[test] + fn a_path_through_directories_only_its_user_may_change_is_safe() { + let directory = tempfile::tempdir().unwrap(); + let project = directory.path().join("project"); + std::fs::create_dir(&project).unwrap(); + set_mode(&project, 0o755); + + assert_eq!(first_changeable_directory(&project, me()).unwrap(), None); + } + + #[test] + fn a_directory_anyone_may_write_is_changeable_unless_sticky() { + let directory = tempfile::tempdir().unwrap(); + let shared = directory.path().join("shared"); + let project = shared.join("project"); + std::fs::create_dir_all(&project).unwrap(); + set_mode(&shared, 0o777); + + let found = first_changeable_directory(&project, me()).unwrap().unwrap(); + assert_eq!(found.directory, shared.canonicalize().unwrap()); + assert_eq!(found.reason, "anyone may write to it"); + + set_mode(&shared, 0o1777); + assert_eq!(first_changeable_directory(&project, me()).unwrap(), None); + } + + #[test] + fn a_link_is_followed_and_its_target_checked() { + let directory = tempfile::tempdir().unwrap(); + let open = directory.path().join("open"); + let project = open.join("project"); + std::fs::create_dir_all(&project).unwrap(); + set_mode(&open, 0o777); + let link = directory.path().join("link"); + std::os::unix::fs::symlink(&project, &link).unwrap(); + + let found = first_changeable_directory(&link, me()).unwrap().unwrap(); + assert_eq!(found.directory, open.canonicalize().unwrap()); + } + + #[test] + fn a_link_in_a_directory_anyone_may_write_is_changeable() { + let directory = tempfile::tempdir().unwrap(); + let open = directory.path().join("open"); + let project = directory.path().join("project"); + std::fs::create_dir(&open).unwrap(); + std::fs::create_dir(&project).unwrap(); + let link = open.join("link"); + std::os::unix::fs::symlink(&project, &link).unwrap(); + set_mode(&open, 0o777); + + let found = first_changeable_directory(&link, me()).unwrap().unwrap(); + assert_eq!(found.directory, open.canonicalize().unwrap()); + } + + #[test] + fn another_user_s_directory_is_changeable() { + let directory = tempfile::tempdir().unwrap(); + let (owner, another) = if me() == 0 { + std::os::unix::fs::chown(directory.path(), Some(USER), None).unwrap(); + (USER, ANOTHER_USER) + } else { + (me(), me() + 1) + }; + let metadata = std::fs::metadata(directory.path()).unwrap(); + let acl = Acl::At(directory.path()); + + assert_eq!( + why_changeable(&metadata, another, acl).unwrap(), + Some("it belongs to another user") + ); + assert_eq!(why_changeable(&metadata, owner, acl).unwrap(), None); + } + + #[test] + fn a_group_writable_directory_is_changeable_unless_its_group_is_the_user_s_alone() { + let directory = tempfile::tempdir().unwrap(); + let project = directory.path().join("project"); + std::fs::create_dir(&project).unwrap(); + set_mode(&project, 0o775); + let gid = std::fs::metadata(&project).unwrap().gid(); + + let found = first_changeable_directory(&project, me()).unwrap(); + if group_is_private(gid, me()).unwrap() { + assert_eq!(found, None); + } else { + assert_eq!( + found.unwrap().reason, + "its group, which has other members, may write to it" + ); + } + } + + #[test] + fn a_group_is_private_only_as_the_user_s_primary_group_named_after_them_alone() { + let Some(account) = account(me()).unwrap() else { + return; + }; + let primary = group(account.gid).unwrap(); + assert_eq!( + group_is_private(account.gid, me()).unwrap(), + primary.is_some_and(|primary| primary.name == account.name + && primary.members.iter().all(|member| *member == account.name)) + ); + assert!(!group_is_private(account.gid, me() + 1_000_000).unwrap()); + assert!(!group_is_private(account.gid.wrapping_add(1), me()).unwrap()); + } + + #[cfg(target_os = "macos")] + #[test] + fn an_acl_letting_anyone_add_entries_makes_a_directory_changeable() { + let directory = tempfile::tempdir().unwrap(); + let project = directory.path().join("project"); + std::fs::create_dir(&project).unwrap(); + let chmod = |entry: &str| { + assert!( + std::process::Command::new("/bin/chmod") + .args(["+a", entry]) + .arg(&project) + .status() + .unwrap() + .success() + ); + }; + + chmod("everyone deny delete"); + assert_eq!(first_changeable_directory(&project, me()).unwrap(), None); + chmod("everyone allow add_file"); + assert_eq!( + first_changeable_directory(&project, me()) + .unwrap() + .unwrap() + .reason, + "its access control list lets others change its entries" + ); + } + + #[test] + fn a_user_s_own_entries_are_neither_given_nor_kept() { + if me() == 0 { + return; + } + let directory = tempfile::tempdir().unwrap(); + std::fs::write(directory.path().join("mine"), b"").unwrap(); + + let kept = give_root_entries( + directory.path(), + &[OsStr::new("mine"), OsStr::new("missing")], + me() + 1, + 0, + ) + .unwrap(); + + assert!(kept.is_empty(), "{kept:?}"); + assert_eq!( + std::fs::metadata(directory.path().join("mine")) + .unwrap() + .uid(), + me() + ); + assert_eq!(std::fs::metadata(directory.path()).unwrap().uid(), me()); + } + + #[test] + fn a_missing_directory_or_a_link_has_nothing_to_give() { + let directory = tempfile::tempdir().unwrap(); + let link = directory.path().join("link"); + std::os::unix::fs::symlink(directory.path(), &link).unwrap(); + + for path in [directory.path().join("missing"), link] { + assert!( + give_root_entries(&path, &[OsStr::new("any")], me() + 1, 0) + .unwrap() + .is_empty() + ); + } + } + + /// A user other than root, whose directories root's tests make. + const USER: u32 = 65_000; + const USER_GROUP: u32 = 65_001; + const ANOTHER_USER: u32 = 65_002; + + fn owners(path: &Path) -> (u32, u32) { + let metadata = std::fs::symlink_metadata(path).unwrap(); + (metadata.uid(), metadata.gid()) + } + + /// Runs only as root, as in the root container the gate runs Linux + /// tests in once more; CI's runner isn't root. + #[test] + fn as_root_what_root_alone_made_is_given_but_never_what_leads_elsewhere() { + if me() != 0 { + return; + } + let root = tempfile::tempdir().unwrap(); + set_mode(root.path(), 0o755); + let elsewhere = root.path().join("elsewhere"); + let linked = root.path().join("linked"); + std::fs::write(&elsewhere, b"root's").unwrap(); + std::fs::write(&linked, b"root's").unwrap(); + let directory = root.path().join("lpm"); + std::fs::create_dir(&directory).unwrap(); + set_mode(&directory, 0o755); + std::fs::write(directory.join("made"), b"").unwrap(); + std::fs::hard_link(&linked, directory.join("hard-link")).unwrap(); + std::os::unix::fs::symlink(&elsewhere, directory.join("link")).unwrap(); + std::fs::create_dir(directory.join("folder")).unwrap(); + std::fs::write(directory.join("another's"), b"").unwrap(); + std::os::unix::fs::chown(directory.join("another's"), Some(ANOTHER_USER), None).unwrap(); + let names = [ + "made", + "hard-link", + "link", + "folder", + "another's", + "missing", + ] + .map(OsStr::new); + + let kept = give_root_entries(&directory, &names, USER, USER_GROUP).unwrap(); + + assert_eq!(kept, ["hard-link", "folder"]); + assert_eq!(owners(&directory.join("made")), (USER, USER_GROUP)); + assert_eq!(owners(&directory), (USER, USER_GROUP)); + for unchanged in [ + &linked, + &elsewhere, + &directory.join("link"), + &directory.join("folder"), + ] { + assert_eq!(owners(unchanged), (0, 0), "{}", unchanged.display()); + } + assert_eq!(owners(&directory.join("another's")).0, ANOTHER_USER); + } + + #[test] + fn as_root_nothing_is_given_from_a_directory_others_may_change() { + if me() != 0 { + return; + } + let root = tempfile::tempdir().unwrap(); + set_mode(root.path(), 0o755); + for (owner, mode) in [(0, 0o777), (0, 0o1777), (USER, 0o755)] { + let directory = root.path().join(format!("{owner}-{mode:o}")); + std::fs::create_dir(&directory).unwrap(); + std::fs::write(directory.join("made"), b"").unwrap(); + std::os::unix::fs::chown(&directory, Some(owner), None).unwrap(); + set_mode(&directory, mode); + + let kept = + give_root_entries(&directory, &[OsStr::new("made")], USER, USER_GROUP).unwrap(); + + assert_eq!(kept, ["made"], "{}", directory.display()); + assert_eq!(owners(&directory.join("made")), (0, 0)); + assert_eq!(owners(&directory).0, owner); + } + } + + #[test] + fn as_root_a_sticky_directory_its_group_may_write_leaves_another_user_s_entry_changeable() { + if me() != 0 { + return; + } + let root = tempfile::tempdir().unwrap(); + set_mode(root.path(), 0o755); + let shared = root.path().join("shared"); + let project = shared.join("project"); + std::fs::create_dir_all(&project).unwrap(); + std::os::unix::fs::chown(&shared, Some(0), Some(USER_GROUP)).unwrap(); + set_mode(&shared, 0o1770); + + std::os::unix::fs::chown(&project, Some(ANOTHER_USER), None).unwrap(); + let found = first_changeable_directory(&project, USER).unwrap().unwrap(); + assert_eq!(found.directory, shared); + assert_eq!( + found.reason, + "others may add to it, and the entry on the path belongs to another user" + ); + + std::os::unix::fs::chown(&project, Some(USER), None).unwrap(); + assert_eq!(first_changeable_directory(&project, USER).unwrap(), None); + } + + /// Gives `path` an access ACL letting `uid` write to it, as `setfacl -m + /// u::rwx` does, or says the filesystem keeps none. + #[cfg(target_os = "linux")] + fn let_write(path: &Path, uid: u32) -> bool { + let mut acl = 2u32.to_le_bytes().to_vec(); + for (tag, permissions, id) in [ + (0x01u16, 7u16, u32::MAX), + (0x02, 7, uid), + (0x04, 5, u32::MAX), + (0x10, 7, u32::MAX), + (0x20, 5, u32::MAX), + ] { + acl.extend(tag.to_le_bytes()); + acl.extend(permissions.to_le_bytes()); + acl.extend(id.to_le_bytes()); + } + let c_path = CString::new(path.as_os_str().as_bytes()).unwrap(); + // SAFETY: the strings are NUL-terminated and the value holds + // `acl.len()` bytes, all outliving the call. + let result = unsafe { + libc::setxattr( + c_path.as_ptr(), + c"system.posix_acl_access".as_ptr(), + acl.as_ptr().cast(), + acl.len(), + 0, + ) + }; + if result == 0 { + return true; + } + let error = io::Error::last_os_error(); + assert_eq!(error.raw_os_error(), Some(libc::ENOTSUP), "{error}"); + false + } + + #[cfg(target_os = "linux")] + #[test] + fn an_acl_that_may_let_others_write_makes_a_directory_changeable_whatever_its_group() { + let directory = tempfile::tempdir().unwrap(); + let project = directory.path().join("project"); + std::fs::create_dir(&project).unwrap(); + if !let_write(&project, me() + 1) { + return; + } + + assert_eq!( + first_changeable_directory(&project, me()) + .unwrap() + .unwrap() + .reason, + "its access control list may let others write to it" + ); + } +} diff --git a/crates/lpm-common/src/paths.rs b/crates/lpm-common/src/paths.rs index 904d8c8d4..6a7fd6320 100644 --- a/crates/lpm-common/src/paths.rs +++ b/crates/lpm-common/src/paths.rs @@ -718,6 +718,38 @@ fn open_lock_file(lock_path: &Path) -> std::io::Result { options.open(lock_path) } +/// Opens the lock file at `lock_path`, created owner-only, without following +/// a link there, for a lock in a directory whose entries a repository can +/// supply, such as a project's `.lpm`. Anything but a regular file is +/// refused. +pub fn open_lock_file_nofollow(lock_path: &Path) -> std::io::Result { + let mut options = std::fs::OpenOptions::new(); + options.create(true).read(true).write(true).truncate(false); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt as _; + + options + .mode(0o600) + .custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_NONBLOCK); + } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt as _; + use windows_sys::Win32::Storage::FileSystem::FILE_FLAG_OPEN_REPARSE_POINT; + + options.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT); + } + let file = options.open(lock_path)?; + if !file.metadata()?.is_file() { + return Err(std::io::Error::other(format!( + "refusing lock path that is a link or not a regular file: {}", + lock_path.display() + ))); + } + Ok(file) +} + fn open_capability_lock_file( directory: &cap_std::fs::Dir, name: &OsStr, @@ -1306,6 +1338,34 @@ pub fn acquire_single_file_exclusive_lock_from_file( }) } +/// Acquires an exclusive advisory lock on `file`, waiting at most `timeout` +/// for another holder to release it, with the usual hint once the wait is +/// noticeable. `None` when the lock was still held at the deadline. +pub fn acquire_single_file_exclusive_lock_from_file_within( + file: std::fs::File, + timeout: std::time::Duration, +) -> Result, LpmError> { + let mut data = fd_lock::RwLock::new(file); + let start = std::time::Instant::now(); + let mut hinted = false; + loop { + if try_acquire(&mut data, LockMode::Exclusive)? { + return Ok(Some(SingleFileExclusiveLockHandle { + _data: HeldLock::new(data), + })); + } + let waited = start.elapsed(); + let Some(left) = timeout.checked_sub(waited).filter(|left| !left.is_zero()) else { + return Ok(None); + }; + if !hinted && waited >= LOCK_WAIT_HINT_AFTER { + hinted = true; + default_wait_hint(); + } + std::thread::sleep(LOCK_POLL_INTERVAL.min(left)); + } +} + pub fn try_acquire_single_file_exclusive_lock_from_file( file: std::fs::File, ) -> Result, LpmError> { @@ -2223,6 +2283,93 @@ mod tests { ); } + fn lock_nofollow_within( + lock_path: &Path, + timeout: std::time::Duration, + ) -> Result, LpmError> { + let file = open_lock_file_nofollow(lock_path)?; + acquire_single_file_exclusive_lock_from_file_within(file, timeout) + } + + #[test] + fn nofollow_single_file_lock_shares_the_lock_domain_of_its_path() { + let tmp = TempDir::new().unwrap(); + let lock_path = tmp.path().join("inspector.db-lock"); + let held = lock_nofollow_within(&lock_path, std::time::Duration::ZERO) + .unwrap() + .unwrap(); + + assert!( + try_acquire_single_file_exclusive_lock_from_file(open_lock_file(&lock_path).unwrap()) + .unwrap() + .is_none() + ); + drop(held); + assert!( + try_acquire_single_file_exclusive_lock_from_file(open_lock_file(&lock_path).unwrap()) + .unwrap() + .is_some() + ); + } + + #[cfg(unix)] + #[test] + fn nofollow_single_file_lock_refuses_a_linked_path_and_creates_nothing() { + let tmp = TempDir::new().unwrap(); + let target = tmp.path().join("elsewhere"); + let lock_path = tmp.path().join("inspector.db-lock"); + std::os::unix::fs::symlink(&target, &lock_path).unwrap(); + + assert!(open_lock_file_nofollow(&lock_path).is_err()); + assert!(!target.exists()); + } + + #[cfg(unix)] + #[test] + fn nofollow_single_file_lock_refuses_a_path_that_is_not_a_regular_file() { + use std::os::unix::ffi::OsStrExt as _; + + let tmp = TempDir::new().unwrap(); + let lock_path = tmp.path().join("inspector.db-lock"); + let name = std::ffi::CString::new(lock_path.as_os_str().as_bytes()).unwrap(); + // SAFETY: `name` is a valid NUL-terminated path. + assert_eq!(unsafe { libc::mkfifo(name.as_ptr(), 0o600) }, 0); + + let error = open_lock_file_nofollow(&lock_path).unwrap_err(); + + assert!(error.to_string().contains("not a regular file"), "{error}"); + } + + #[test] + fn a_lock_wait_with_a_deadline_gives_up_at_it_and_takes_a_released_lock() { + let tmp = TempDir::new().unwrap(); + let lock_path = tmp.path().join("inspector.db-lock"); + let held = lock_nofollow_within(&lock_path, std::time::Duration::ZERO) + .unwrap() + .unwrap(); + + let start = std::time::Instant::now(); + let timeout = std::time::Duration::from_millis(250); + assert!(lock_nofollow_within(&lock_path, timeout).unwrap().is_none()); + let waited = start.elapsed(); + assert!(waited >= timeout, "{waited:?}"); + assert!( + waited < timeout + std::time::Duration::from_secs(2), + "{waited:?}" + ); + + let release = std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(150)); + drop(held); + }); + assert!( + lock_nofollow_within(&lock_path, std::time::Duration::from_secs(5)) + .unwrap() + .is_some() + ); + release.join().unwrap(); + } + /// Shared lock blocks an exclusive acquire until released. #[test] fn exclusive_blocks_while_shared_held() { diff --git a/crates/lpm-common/src/thread_identity.rs b/crates/lpm-common/src/thread_identity.rs new file mode 100644 index 000000000..5d664a2a1 --- /dev/null +++ b/crates/lpm-common/src/thread_identity.rs @@ -0,0 +1,207 @@ +//! A thread's own filesystem identity, on Linux, whose kernel keeps each +//! thread's credentials: root can reach files as another user on one thread +//! alone, so a path that user controls leads only where that user could go, +//! however it changes meanwhile. The C library's `setgroups` sets every +//! thread's groups, so the thread's own are set with the system call itself. + +use std::io; + +/// The calling thread reaching files as another user, until this is +/// dropped, which gives the thread back the identity it had, so it can't +/// leave the thread. A thread it starts meanwhile keeps the user's for +/// good, as Linux gives a new thread its creator's. +#[must_use = "the thread acts as the user only while this is held"] +#[derive(Debug)] +pub struct ActingAs { + fsuid: libc::uid_t, + fsgid: libc::gid_t, + groups: Vec, + thread: std::marker::PhantomData<*const ()>, +} + +/// Has the calling thread reach files as `uid`, with `gid` as its group and +/// `groups` as its supplementary groups. The kernel takes root's file +/// capabilities from a thread whose filesystem user isn't root, so its +/// access is the user's. Fails, leaving the thread as it was, where it may +/// not take the identity: without `CAP_SETUID` and `CAP_SETGID`, or for an +/// ID its user namespace doesn't map. +pub fn act_as(uid: u32, gid: u32, groups: &[u32]) -> io::Result { + let acting = ActingAs { + fsuid: filesystem_uid(), + fsgid: filesystem_gid(), + groups: thread_groups()?, + thread: std::marker::PhantomData, + }; + set_thread_groups(groups)?; + // SAFETY: each changes only the calling thread's credentials, and takes + // no pointer. + unsafe { + libc::setfsgid(gid); + libc::setfsuid(uid); + } + if filesystem_uid() != uid || filesystem_gid() != gid || thread_groups()? != groups { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + format!("this process may not reach files as user {uid} and group {gid}"), + )); + } + Ok(acting) +} + +impl Drop for ActingAs { + /// A thread that could take the identity keeps the capabilities to give + /// it back: `CAP_SETUID` and `CAP_SETGID` aren't file capabilities. + fn drop(&mut self) { + // SAFETY: as in `act_as`. + unsafe { + libc::setfsuid(self.fsuid); + libc::setfsgid(self.fsgid); + } + let _ = set_thread_groups(&self.groups); + } +} + +/// The thread's filesystem user ID. `setfsuid` reports no failure, so the +/// current one is read by asking for an ID no user has, which changes +/// nothing and returns it. +pub fn filesystem_uid() -> u32 { + // SAFETY: as in `act_as`; an invalid ID changes nothing. + unsafe { libc::setfsuid(libc::uid_t::MAX) as u32 } +} + +/// The thread's filesystem group ID, read as [`filesystem_uid`] is. +pub fn filesystem_gid() -> u32 { + // SAFETY: as in `act_as`; an invalid ID changes nothing. + unsafe { libc::setfsgid(libc::gid_t::MAX) as u32 } +} + +/// The calling thread's supplementary groups. +pub fn thread_groups() -> io::Result> { + loop { + // SAFETY: a count of 0 only asks how many groups there are. + let count = unsafe { libc::getgroups(0, std::ptr::null_mut()) }; + if count < 0 { + return Err(io::Error::last_os_error()); + } + let mut groups = vec![0; count as usize]; + // SAFETY: `groups` has room for `count` IDs. + let filled = unsafe { libc::getgroups(count, groups.as_mut_ptr()) }; + if filled >= 0 { + groups.truncate(filled as usize); + return Ok(groups); + } + // The groups grew in between; ask again. + if io::Error::last_os_error().raw_os_error() != Some(libc::EINVAL) { + return Err(io::Error::last_os_error()); + } + } +} + +fn set_thread_groups(groups: &[libc::gid_t]) -> io::Result<()> { + // SAFETY: the system call reads `groups.len()` IDs from `groups`, and + // changes only the calling thread's credentials. + let result = unsafe { libc::syscall(libc::SYS_setgroups, groups.len(), groups.as_ptr()) }; + if result == 0 { + Ok(()) + } else { + Err(io::Error::last_os_error()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // Dropped on another thread, it would give that thread the identity + // and leave its own thread the user's. + static_assertions::assert_not_impl_any!(ActingAs: Send, Sync); + + #[test] + fn a_thread_that_may_not_act_as_another_user_is_left_as_it_was() { + if crate::current_uid() == 0 { + return; + } + let before = (filesystem_uid(), filesystem_gid(), thread_groups().unwrap()); + + let error = act_as(crate::current_uid() + 1, before.1 + 1, &[before.1 + 1]).unwrap_err(); + + assert_eq!(error.kind(), io::ErrorKind::PermissionDenied); + assert_eq!( + (filesystem_uid(), filesystem_gid(), thread_groups().unwrap()), + before + ); + } + + /// Whether the calling thread may set user IDs, as root in a container + /// started with `--cap-drop SETUID` may not. + fn thread_may_set_uid() -> bool { + const CAP_SETUID: u32 = 7; + let status = std::fs::read_to_string("/proc/thread-self/status").unwrap(); + let effective = status + .lines() + .find_map(|line| line.strip_prefix("CapEff:")) + .unwrap(); + u64::from_str_radix(effective.trim(), 16).unwrap() & (1 << CAP_SETUID) != 0 + } + + /// Runs only as root without `CAP_SETUID`, as in the container the gate + /// starts with `--cap-drop SETUID`: `setfsuid` fails there without + /// saying so, while the groups can still change. + #[test] + fn as_root_without_the_capability_to_set_users_a_thread_is_left_as_it_was() { + if crate::current_uid() != 0 || thread_may_set_uid() { + return; + } + let before = (filesystem_uid(), filesystem_gid(), thread_groups().unwrap()); + + let error = act_as(65_000, 65_001, &[65_001, 65_002]).unwrap_err(); + + assert_eq!(error.kind(), io::ErrorKind::PermissionDenied); + assert_eq!( + (filesystem_uid(), filesystem_gid(), thread_groups().unwrap()), + before + ); + } + + /// Runs only as root, as in the root container the gate runs Linux + /// tests in once more; CI's runner isn't root. + #[test] + fn as_root_a_thread_reaches_files_as_the_user_and_alone_until_it_lets_go() { + if crate::current_uid() != 0 || !thread_may_set_uid() { + return; + } + let directory = tempfile::tempdir().unwrap(); + let private = directory.path().join("root-only"); + std::fs::write(&private, b"root's").unwrap(); + std::fs::set_permissions( + &private, + ::from_mode(0o600), + ) + .unwrap(); + let before = (filesystem_uid(), filesystem_gid(), thread_groups().unwrap()); + let (ask, asked) = std::sync::mpsc::channel::<()>(); + let elsewhere = private.clone(); + let another_thread = std::thread::spawn(move || { + asked.recv().unwrap(); + std::fs::read(elsewhere) + }); + + { + let _acting = act_as(65_000, 65_001, &[65_001, 65_002]).unwrap(); + assert_eq!((filesystem_uid(), filesystem_gid()), (65_000, 65_001)); + assert_eq!(thread_groups().unwrap(), vec![65_001, 65_002]); + assert_eq!( + std::fs::read(&private).unwrap_err().kind(), + io::ErrorKind::PermissionDenied + ); + ask.send(()).unwrap(); + assert_eq!(another_thread.join().unwrap().unwrap(), b"root's"); + } + + assert_eq!( + (filesystem_uid(), filesystem_gid(), thread_groups().unwrap()), + before + ); + assert_eq!(std::fs::read(&private).unwrap(), b"root's"); + } +} diff --git a/crates/lpm-inspect/Cargo.toml b/crates/lpm-inspect/Cargo.toml index 443744a19..b45d0e33d 100644 --- a/crates/lpm-inspect/Cargo.toml +++ b/crates/lpm-inspect/Cargo.toml @@ -23,10 +23,11 @@ async-stream = "0.3" futures-util = "0.3" base64 = { workspace = true } reqwest = { workspace = true } -rusqlite = { version = "0.34", features = ["bundled"] } +rusqlite = { version = "0.40", features = ["backup", "bundled", "fallible_uint", "limits"] } chrono = { workspace = true } rand = { workspace = true } subtle = "2" +thiserror = { workspace = true } tower = "0.5" [dev-dependencies] @@ -34,5 +35,8 @@ tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } reqwest = { workspace = true } tempfile = "3" +[target.'cfg(unix)'.dev-dependencies] +libc = "0.2" + [lints] workspace = true diff --git a/crates/lpm-inspect/src/db.rs b/crates/lpm-inspect/src/db.rs index 59b21867f..fa9f4ecdb 100644 --- a/crates/lpm-inspect/src/db.rs +++ b/crates/lpm-inspect/src/db.rs @@ -43,6 +43,21 @@ pub struct InspectorDb { queued_capture_bytes: Arc, /// Database file path (for display/diagnostics). pub db_path: PathBuf, + open_to_others: bool, + ownership_ignored: bool, + /// The directory [`Self::open_temp`]'s database is in, removed with the + /// last handle. + #[cfg(test)] + _temp_dir: Option>, + /// The flush task's connection, for tests to look at. + #[cfg(test)] + write_conn: Arc>, +} + +/// A count as SQL takes it, the largest one where it's past `i64`'s range, +/// as a negative `LIMIT` would mean no limit. +fn sql_count(count: usize) -> i64 { + i64::try_from(count).unwrap_or(i64::MAX) } /// A write operation to be batched. @@ -56,101 +71,152 @@ enum DbWrite { } impl InspectorDb { - /// Open (or create) the inspector database at `{project_dir}/.lpm/inspector.db`. - /// - /// Initializes the schema if the database is new. Starts the background - /// write-batching task. - pub fn open(project_dir: &Path) -> Result { - let lpm_dir = project_dir.join(".lpm"); - std::fs::create_dir_all(&lpm_dir) - .map_err(|error| rusqlite::Error::ToSqlConversionFailure(Box::new(error)))?; + /// Opens (or creates) `{project_dir}/.lpm/inspector.db` to record + /// captures, starting the background write-batching task. The capture + /// files are made owner-only first: a database others could open is + /// replaced with an owner-only copy (see [`OpenError`] for when it can't + /// be yet). + pub fn open(project_dir: &Path) -> Result { + let (owner, _identity) = CaptureOwner::of(project_dir)?; + Self::open_project(project_dir, Access::Capture, owner) + .map_err(|error| owner.explain(error)) + } + /// Opens (or creates) `{project_dir}/.lpm/inspector.db` to read and + /// manage the captures recorded there, without recording any. Existing + /// files keep their access, so this works while another process records + /// to the database; [`Self::open_to_others`] says whether others could + /// open it. + pub fn open_for_review(project_dir: &Path) -> Result { + let (owner, _identity) = CaptureOwner::of(project_dir)?; + Self::open_project(project_dir, Access::Review, owner).map_err(|error| owner.explain(error)) + } + + fn open_project( + project_dir: &Path, + access: Access, + owner: CaptureOwner, + ) -> Result { + let lpm_dir = project_dir.join(".lpm"); let db_path = lpm_dir.join("inspector.db"); + let ownership_ignored = prepare_lpm_dir(&lpm_dir, owner, access)?; + #[cfg(test)] + if let Some(meanwhile) = tests::AFTER_LPM_DIR.with_borrow_mut(Option::take) { + meanwhile(); + } + let _opening = lock_capture_files(&db_path)?; + remove_replacement_copy(&db_path)?; + if let Err(error) = ensure_captures_ignored(&lpm_dir, owner) { + tracing::warn!("could not keep tunnel captures out of git: {error}"); + } + // On a volume that ignores ownership, every user owns the files, so + // they're open to others whatever their mode, and a copy is too. + let mut open_to_others = + prepare_capture_files(&db_path, owner, access)? || ownership_ignored; + if open_to_others && access == Access::Capture && !ownership_ignored { + open_to_others = !replace_with_owner_only_copy(&db_path)?; + } + open_to_others |= !create_wal_sidecars(&db_path)?; // Write connection (owned by the background flush task) - let write_conn = Connection::open(&db_path)?; - restrict_database_permissions(&db_path)?; - init_schema(&write_conn)?; + let write_conn = open_capture_connection(&db_path, "NORMAL").map_err(damaged(&db_path))?; + refuse_unknown_schema(&write_conn, &db_path, damaged(&db_path))?; + init_schema(&write_conn).map_err(damaged(&db_path))?; + + // Read connection (shared across API handlers). The busy timeout + // lets low-frequency mutations (tag updates, session renames) wait + // while the write connection flushes a batch. + let read_conn = open_capture_connection(&db_path, "NORMAL").map_err(damaged(&db_path))?; + let control_conn = open_control_connection(&db_path).map_err(damaged(&db_path))?; + if owner.by_root { + for connection in [&write_conn, &read_conn, &control_conn] { + keep_wal_files(connection).map_err(damaged(&db_path))?; + } + } - // Read connection (shared across API handlers). - // busy_timeout ensures low-frequency mutations (tag updates, session renames) - // wait gracefully if the write connection holds a lock during batch flushes. - let read_conn = Connection::open(&db_path)?; - read_conn.execute_batch( - "PRAGMA journal_mode=WAL; - PRAGMA synchronous=NORMAL; - PRAGMA foreign_keys=ON; - PRAGMA busy_timeout=5000;", - )?; - let control_conn = open_control_connection(&db_path)?; + Ok(Self::with_connections( + write_conn, + read_conn, + control_conn, + db_path, + open_to_others, + ownership_ignored, + )) + } + fn with_connections( + write_conn: Connection, + read_conn: Connection, + control_conn: Connection, + db_path: PathBuf, + open_to_others: bool, + ownership_ignored: bool, + ) -> Self { let (write_tx, write_rx) = mpsc::channel(WRITE_QUEUE_CAPACITY); let queued_capture_bytes = Arc::new(AtomicUsize::new(0)); - - // Spawn background flush task let write_conn = Arc::new(Mutex::new(write_conn)); + #[cfg(test)] + let kept_write_conn = Arc::clone(&write_conn); tokio::spawn(flush_task( write_conn, write_rx, Arc::clone(&queued_capture_bytes), )); - Ok(Self { + Self { read_conn: Arc::new(Mutex::new(read_conn)), control_conn: Arc::new(std::sync::Mutex::new(control_conn)), write_tx, queued_capture_bytes, db_path, - }) + open_to_others, + ownership_ignored, + #[cfg(test)] + _temp_dir: None, + #[cfg(test)] + write_conn: kept_write_conn, + } } - /// Open a temporary file-based database (for testing). - /// - /// Each call creates a unique database file so tests don't interfere - /// with each other when running in parallel. - pub fn open_temp() -> Result { - use std::sync::atomic::{AtomicU64, Ordering}; - static COUNTER: AtomicU64 = AtomicU64::new(0); - let id = COUNTER.fetch_add(1, Ordering::Relaxed); - - let nanos = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map_or(0, |duration| duration.as_nanos()); - let tmp = std::env::temp_dir().join(format!( - "lpm-inspect-test-{}-{nanos}-{id}.db", - std::process::id() - )); + /// Opens a database in a directory of its own, removed with the last + /// handle, so tests running in parallel don't share one. + #[cfg(test)] + pub(crate) fn open_temp() -> Result { + let temp_dir = tempfile::Builder::new() + .prefix("lpm-inspect-test-") + .tempdir() + .map_err(|error| rusqlite::Error::ToSqlConversionFailure(Box::new(error)))?; + let tmp = temp_dir.path().join("inspector.db"); let write_conn = Connection::open(&tmp)?; + configure_connection(&write_conn, "NORMAL")?; init_schema(&write_conn)?; - let read_conn = Connection::open(&tmp)?; - read_conn.execute_batch( - "PRAGMA journal_mode=WAL; - PRAGMA synchronous=NORMAL; - PRAGMA foreign_keys=ON; - PRAGMA busy_timeout=5000;", - )?; + configure_connection(&read_conn, "NORMAL")?; let control_conn = open_control_connection(&tmp)?; - let (write_tx, write_rx) = mpsc::channel(WRITE_QUEUE_CAPACITY); - let queued_capture_bytes = Arc::new(AtomicUsize::new(0)); - let write_conn = Arc::new(Mutex::new(write_conn)); - tokio::spawn(flush_task( - write_conn, - write_rx, - Arc::clone(&queued_capture_bytes), - )); - Ok(Self { - read_conn: Arc::new(Mutex::new(read_conn)), - control_conn: Arc::new(std::sync::Mutex::new(control_conn)), - write_tx, - queued_capture_bytes, - db_path: tmp, + _temp_dir: Some(Arc::new(temp_dir)), + ..Self::with_connections(write_conn, read_conn, control_conn, tmp, false, false) }) } + /// Whether other users could open the capture files when this handle + /// opened them: a database [`Self::open_for_review`] found so, or one + /// whose file modes don't keep others out, which [`Self::open`] can't + /// make owner-only: on a filesystem without Unix modes, or a volume that + /// ignores ownership ([`Self::ownership_ignored`]). + pub fn open_to_others(&self) -> bool { + self.open_to_others + } + + /// Whether the capture files' volume ignores ownership, as one mounted + /// `noowners` on macOS does: every user owns its files, so they're open + /// to others whatever their mode, and no open can change that. + pub fn ownership_ignored(&self) -> bool { + self.ownership_ignored + } + /// Insert a captured request (non-blocking — queued for batch write). pub fn insert_request(&self, webhook: CapturedWebhook, session_id: Option) { self.insert_shared_request(Arc::new(webhook), session_id); @@ -203,7 +269,7 @@ impl InspectorDb { .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); let transaction = connection - .transaction() + .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) .map_err(|error| error.to_string())?; insert_request_row(&transaction, &webhook, session_id.as_deref()) .map_err(|error| error.to_string())?; @@ -259,7 +325,8 @@ impl InspectorDb { .control_conn .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); - let transaction = conn.transaction()?; + let transaction = + conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?; let inserted = transaction.execute( "INSERT OR IGNORE INTO sessions (id, name, domain, local_port) VALUES (?1, ?2, ?3, ?4)", @@ -280,7 +347,8 @@ impl InspectorDb { .control_conn .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); - let transaction = conn.transaction()?; + let transaction = + conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?; transaction.execute( "DELETE FROM pending_sessions WHERE session_id = ?1", params![id], @@ -303,7 +371,8 @@ impl InspectorDb { .control_conn .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); - let transaction = conn.transaction()?; + let transaction = + conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?; let committed = transaction.execute( "DELETE FROM pending_sessions WHERE session_id = ?1", params![id], @@ -318,7 +387,8 @@ impl InspectorDb { .control_conn .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); - let transaction = conn.transaction()?; + let transaction = + conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?; transaction.execute( "UPDATE sessions SET ended_at = datetime('now') WHERE id = ?1", params![&id], @@ -362,7 +432,7 @@ impl InspectorDb { LIMIT ?1 OFFSET ?2", )?; - let rows = stmt.query_map(params![limit as i64, offset as i64], |row| { + let rows = stmt.query_map(params![sql_count(limit), sql_count(offset)], |row| { Ok(StoredRequest { id: row.get(0)?, session_id: row.get(1)?, @@ -430,7 +500,10 @@ impl InspectorDb { ORDER BY timestamp DESC, id DESC LIMIT ?1 OFFSET ?2", )?; - let rows = stmt.query_map(params![limit as i64, offset as i64], stored_detail_from_row)?; + let rows = stmt.query_map( + params![sql_count(limit), sql_count(offset)], + stored_detail_from_row, + )?; rows.map(|row| row.and_then(stored_detail_to_webhook)) .collect() @@ -499,7 +572,7 @@ impl InspectorDb { let provider_str = provider.unwrap_or(""); - let rows = stmt.query_map(params![fts_query, provider_str, limit as i64], |row| { + let rows = stmt.query_map(params![fts_query, provider_str, sql_count(limit)], |row| { Ok(StoredRequest { id: row.get(0)?, session_id: row.get(1)?, @@ -540,7 +613,7 @@ impl InspectorDb { LIMIT ?1", )?; - let rows = stmt.query_map(params![limit as i64], |row| { + let rows = stmt.query_map(params![sql_count(limit)], |row| { Ok(StoredSession { id: row.get(0)?, name: row.get(1)?, @@ -667,23 +740,26 @@ impl InspectorDb { LIMIT ?2 OFFSET ?3", )?; - let rows = stmt.query_map(params![session_id, limit as i64, offset as i64], |row| { - Ok(StoredRequest { - id: row.get(0)?, - session_id: row.get(1)?, - timestamp: row.get(2)?, - method: row.get(3)?, - path: row.get(4)?, - status: row.get(5)?, - duration_ms: row.get(6)?, - provider: row.get(7)?, - summary: row.get(8)?, - req_size: row.get(9)?, - res_size: row.get(10)?, - has_signature_issue: row.get(11)?, - auto_acked: row.get(12)?, - }) - })?; + let rows = stmt.query_map( + params![session_id, sql_count(limit), sql_count(offset)], + |row| { + Ok(StoredRequest { + id: row.get(0)?, + session_id: row.get(1)?, + timestamp: row.get(2)?, + method: row.get(3)?, + path: row.get(4)?, + status: row.get(5)?, + duration_ms: row.get(6)?, + provider: row.get(7)?, + summary: row.get(8)?, + req_size: row.get(9)?, + res_size: row.get(10)?, + has_signature_issue: row.get(11)?, + auto_acked: row.get(12)?, + }) + }, + )?; rows.collect() } @@ -733,14 +809,28 @@ impl InspectorDb { /// Remove captured requests and completed sessions from the inspector database. /// /// Open sessions are retained so another process can continue attaching - /// captures without violating the request-to-session foreign key. + /// captures without violating the request-to-session foreign key. The + /// captures leave no trace in the file: the full-text index, which keeps + /// a deleted entry's terms until its segments merge, is rebuilt from its + /// emptied content; `secure_delete` zeroes the freed pages; and the WAL, + /// which still holds the captures' earlier pages, is checkpointed and + /// truncated, unless another connection is reading at that moment. pub async fn clear_history(&self) -> Result<(), rusqlite::Error> { let conn = self.read_conn.lock().await; - let tx = conn.unchecked_transaction()?; + // Immediate: a deferred transaction that reads before it writes gets + // SQLITE_BUSY at once, without waiting, if another connection + // commits in between. + let tx = + rusqlite::Transaction::new_unchecked(&conn, rusqlite::TransactionBehavior::Immediate)?; tx.execute("DELETE FROM requests_fts", [])?; + tx.execute( + "INSERT INTO requests_fts(requests_fts) VALUES ('rebuild')", + [], + )?; tx.execute("DELETE FROM requests", [])?; tx.execute("DELETE FROM sessions WHERE ended_at IS NOT NULL", [])?; - tx.commit() + tx.commit()?; + conn.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |_| Ok(())) } /// Return SQLite's connection-local external-write revision. @@ -754,504 +844,4454 @@ impl InspectorDb { } } -// ── Schema initialization ───────────────────────────────────────────── - -#[cfg(unix)] -fn restrict_database_permissions(path: &Path) -> Result<(), rusqlite::Error> { - use std::os::unix::fs::PermissionsExt; +/// Why the capture database couldn't be opened. +#[derive(Debug, thiserror::Error)] +pub enum OpenError { + /// `.lpm` or a capture file failed a check or a file operation; the + /// message names it, and says what to do. One others could open that + /// another process has open, which [`InspectorDb::open`] can't replace + /// yet, is [`std::io::ErrorKind::ResourceBusy`]. + #[error("{0}")] + Files(#[from] std::io::Error), + /// SQLite can't read the file as a database, or finds it damaged. + #[error( + "{} is damaged or isn't a capture database ({detail}); move it aside {}, and retry", + path.display(), + with_its_sidecars(path) + )] + Damaged { path: PathBuf, detail: String }, + /// Any other SQLite failure. + #[error(transparent)] + Database(#[from] rusqlite::Error), +} - std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) - .map_err(|error| rusqlite::Error::ToSqlConversionFailure(Box::new(error))) +impl OpenError { + /// The I/O failure this is, for callers that report it as one. + pub fn kind(&self) -> std::io::ErrorKind { + match self { + Self::Files(error) => error.kind(), + Self::Damaged { .. } => std::io::ErrorKind::InvalidData, + Self::Database(_) => std::io::ErrorKind::Other, + } + } } -#[cfg(not(unix))] -fn restrict_database_permissions(_path: &Path) -> Result<(), rusqlite::Error> { - Ok(()) +/// Reads SQLite's refusal of a file it can't use as a database as such. +fn damaged(db_path: &Path) -> impl Fn(rusqlite::Error) -> OpenError + '_ { + move |error| match error.sqlite_error_code() { + Some(rusqlite::ErrorCode::NotADatabase | rusqlite::ErrorCode::DatabaseCorrupt) => { + OpenError::Damaged { + path: db_path.to_path_buf(), + detail: error.to_string(), + } + } + _ => OpenError::Database(error), + } } -fn open_control_connection(path: &Path) -> Result { - let connection = Connection::open(path)?; - connection.execute_batch( - "PRAGMA journal_mode=WAL; - PRAGMA synchronous=FULL; - PRAGMA foreign_keys=ON; - PRAGMA busy_timeout=5000;", - )?; - Ok(connection) +/// What an open of the capture database may change. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Access { + /// Records captures: makes `.lpm` and the capture files safe to write + /// them to, replacing a database others could open. + Capture, + /// Reads and manages recorded captures. Changes no existing file's + /// access, and so runs while another process records to the database. + Review, } -fn init_schema(conn: &Connection) -> Result<(), rusqlite::Error> { - conn.execute_batch( - "PRAGMA journal_mode=WAL; - PRAGMA synchronous=NORMAL; - PRAGMA foreign_keys=ON; - PRAGMA busy_timeout=5000; +/// Whose capture files are: the user's own. Root in a project another user +/// owns, as in a container over a mounted checkout, opens them as that user +/// ([`OwnerIdentity`]): that user can move `.lpm` and the names in it at any +/// time, and so could otherwise have what root reads, writes and removes +/// there land anywhere. +#[cfg(unix)] +#[derive(Debug, Clone, Copy)] +struct CaptureOwner { + uid: u32, + /// Whether root opens the capture files as their owner. + by_root: bool, +} - CREATE TABLE IF NOT EXISTS sessions ( - id TEXT PRIMARY KEY, - name TEXT, - domain TEXT, - local_port INTEGER, - started_at TEXT NOT NULL DEFAULT (datetime('now')), - ended_at TEXT - ); +/// What root holds while it opens the capture files as their owner: on +/// Linux, the owner's filesystem identity, taken by the opening thread +/// alone; elsewhere, root doesn't open another user's. +#[cfg(all(target_os = "linux", target_pointer_width = "64"))] +type OwnerIdentity = Option; +#[cfg(not(all(target_os = "linux", target_pointer_width = "64")))] +struct OwnerIdentity; - CREATE TABLE IF NOT EXISTS pending_sessions ( - session_id TEXT PRIMARY KEY, - FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE - ); +/// What an open holds that takes no other user's identity. +#[cfg(all(target_os = "linux", target_pointer_width = "64"))] +const OWN_IDENTITY: OwnerIdentity = None; +#[cfg(not(all(target_os = "linux", target_pointer_width = "64")))] +const OWN_IDENTITY: OwnerIdentity = OwnerIdentity; - CREATE TABLE IF NOT EXISTS requests ( - id TEXT PRIMARY KEY, - session_id TEXT, - timestamp TEXT NOT NULL, - method TEXT NOT NULL, - path TEXT NOT NULL, - status INTEGER, - duration_ms INTEGER, - provider TEXT, - summary TEXT, - signature_diagnostic TEXT, - request_headers TEXT, - request_body BLOB, - response_headers TEXT, - response_body BLOB, - req_size INTEGER, - res_size INTEGER, - tags TEXT, - auto_acked INTEGER DEFAULT 0, - created_at TEXT DEFAULT (datetime('now')), - FOREIGN KEY (session_id) REFERENCES sessions(id) - ); +#[cfg(unix)] +impl CaptureOwner { + /// The capture files' owner, and the identity root holds while it opens + /// them as that owner. The project's path must be one no user but its + /// owner and root can change ([`refuse_changeable_path`]). + fn of(project_dir: &Path) -> std::io::Result<(Self, OwnerIdentity)> { + use std::os::unix::fs::MetadataExt; + + let uid = lpm_common::current_uid(); + let project = + std::fs::metadata(project_dir).map_err(|error| with_path(project_dir, error))?; + let owner = if uid == 0 { project.uid() } else { uid }; + refuse_changeable_path(project_dir, owner)?; + if uid != 0 || owner == 0 { + return Ok(( + Self { + uid: owner, + by_root: false, + }, + OWN_IDENTITY, + )); + } + let identity = act_as_project_owner(project_dir, &project)?; + Ok(( + Self { + uid: owner, + by_root: true, + }, + identity, + )) + } - CREATE INDEX IF NOT EXISTS idx_requests_session ON requests(session_id); - CREATE INDEX IF NOT EXISTS idx_requests_provider ON requests(provider); - CREATE INDEX IF NOT EXISTS idx_requests_status ON requests(status); - CREATE INDEX IF NOT EXISTS idx_requests_timestamp ON requests(timestamp DESC, id DESC); + fn owns(self, metadata: &std::fs::Metadata) -> bool { + use std::os::unix::fs::MetadataExt; - CREATE VIRTUAL TABLE IF NOT EXISTS requests_fts USING fts5( - path, summary, request_body, response_body - );", - )?; - ensure_capture_schema(conn) -} + metadata.uid() == self.uid + } -fn ensure_capture_schema(conn: &Connection) -> Result<(), rusqlite::Error> { - let tx = rusqlite::Transaction::new_unchecked(conn, rusqlite::TransactionBehavior::Immediate)?; - let has_capture_flag = tx - .prepare("PRAGMA table_info(requests)")? - .query_map([], |row| row.get::<_, String>(1))? - .collect::, _>>()? - .iter() - .any(|name| name == "response_body_incomplete"); - if !has_capture_flag { - tx.execute( - "ALTER TABLE requests ADD COLUMN response_body_incomplete INTEGER NOT NULL DEFAULT 0", - [], - )?; + /// Says, of a file root was refused as the project's owner, whose + /// access that was. + fn explain(self, error: OpenError) -> OpenError { + match error { + OpenError::Files(error) + if self.by_root && error.kind() == std::io::ErrorKind::PermissionDenied => + { + OpenError::Files(std::io::Error::new( + error.kind(), + format!( + "{error} (root opens the capture files of user {}'s project as \ + that user)", + self.uid + ), + )) + } + other => other, + } } - tx.commit() } -// ── Background flush task ───────────────────────────────────────────── - -async fn flush_task( - conn: Arc>, - mut rx: mpsc::Receiver, - queued_capture_bytes: Arc, -) { - let mut batch: Vec = Vec::with_capacity(FLUSH_BATCH_SIZE); - let mut pending_error: Option = None; +/// Refuses a project whose path a user other than `owner` and root could +/// change: such a user could move `.lpm`, or a directory above it, while +/// the capture files are opened by name, and have them written elsewhere, +/// or their captures read. +#[cfg(unix)] +fn refuse_changeable_path(project_dir: &Path, owner: u32) -> std::io::Result<()> { + let Some(found) = lpm_common::path_trust::first_changeable_directory(project_dir, owner) + .map_err(|error| with_path(project_dir, error))? + else { + return Ok(()); + }; + Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + format!( + "{} can be changed by other users: {}. They could move .lpm while tunnel \ + captures, which hold request headers and bodies, are opened, so captures are \ + kept only where no one but the project's owner and root can; keep the project \ + under directories only you can change", + found.directory.display(), + found.reason + ), + )) +} - loop { - // Wait for the first write or channel close - match rx.recv().await { - Some(write) => batch.push(write), - None => break, // Channel closed — shutdown +/// The names in `.lpm` root may have left its own, which it gives to the +/// project's owner before opening them as that owner. +#[cfg(all(target_os = "linux", target_pointer_width = "64"))] +const ROOTS_CAPTURE_FILES: [&str; 8] = [ + ".gitignore", + "inspector.db", + "inspector.db-wal", + "inspector.db-shm", + "inspector.db-journal", + "inspector.db-lock", + "inspector.db-copy", + "inspector.db-copy-journal", +]; + +/// Takes the project owner's identity on this thread, with their groups, +/// once root has given them what it left its own in `.lpm`, as an +/// `lpm install` run as root makes `.lpm` root's; what root can't give +/// safely is refused, naming it. An owner the user database doesn't know, +/// as a host's in a container, gets the project's group, or none where +/// that's root's. +#[cfg(all(target_os = "linux", target_pointer_width = "64"))] +fn act_as_project_owner( + project_dir: &Path, + project: &std::fs::Metadata, +) -> std::io::Result { + use lpm_common::path_trust; + use std::os::unix::fs::MetadataExt; + + const NO_GROUP: u32 = 65_534; + let uid = project.uid(); + let refused = |error: std::io::Error| { + std::io::Error::new( + error.kind(), + format!( + "{} belongs to user {uid}, and root records tunnel captures there only as \ + that user, so that it can't be made to write elsewhere, which this process \ + can't do ({error}); run the tunnel as the project's owner", + project_dir.display() + ), + ) + }; + let (gid, groups) = match path_trust::account(uid).map_err(refused)? { + Some(account) => ( + account.gid, + path_trust::groups_of(&account).map_err(refused)?, + ), + None => { + let gid = if project.gid() == 0 { + NO_GROUP + } else { + project.gid() + }; + (gid, vec![gid]) } + }; + let lpm_dir = project_dir.join(".lpm"); + let names = ROOTS_CAPTURE_FILES.map(std::ffi::OsStr::new); + let kept = path_trust::give_root_entries(&lpm_dir, &names, uid, gid) + .map_err(|error| with_path(&lpm_dir, error))?; + // A `.gitignore` root keeps only goes without the captures' line. + if let Some(name) = kept.iter().find(|name| *name != ".gitignore") { + let path = lpm_dir.join(name); + return Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + format!( + "{} belongs to root, which records tunnel captures in user {uid}'s project only as that user, and can't give it to them safely, as others could have put it there or it leads elsewhere; give it to that user (chown {uid}) or remove it", + path.display() + ), + )); + } + lpm_common::thread_identity::act_as(uid, gid, &groups) + .map(Some) + .map_err(refused) +} - // Drain any additional buffered writes (non-blocking) - while batch.len() < FLUSH_BATCH_SIZE { - match rx.try_recv() { - Ok(write) => batch.push(write), - Err(_) => break, - } - } +#[cfg(all(unix, not(all(target_os = "linux", target_pointer_width = "64"))))] +fn act_as_project_owner( + project_dir: &Path, + project: &std::fs::Metadata, +) -> std::io::Result { + use std::os::unix::fs::MetadataExt; + + Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + format!( + "{} belongs to user {}, and root records tunnel captures, which hold request \ + headers and bodies, only in its own projects on this system, as another \ + user could move .lpm to have root write elsewhere; run the tunnel as that \ + user, without sudo", + project_dir.display(), + project.uid() + ), + )) +} - // If we didn't hit batch size, wait briefly for more writes - if batch.len() < FLUSH_BATCH_SIZE { - tokio::time::sleep(std::time::Duration::from_millis(FLUSH_INTERVAL_MS)).await; - while batch.len() < FLUSH_BATCH_SIZE { - match rx.try_recv() { - Ok(write) => batch.push(write), - Err(_) => break, - } - } - } +/// Access to the capture files on Windows is the project's ACLs. +#[cfg(not(unix))] +#[derive(Debug, Clone, Copy)] +struct CaptureOwner { + by_root: bool, +} - flush_pending_batch(&conn, &mut batch, &mut pending_error, &queued_capture_bytes).await; +#[cfg(not(unix))] +impl CaptureOwner { + fn of(_project_dir: &Path) -> std::io::Result<(Self, OwnerIdentity)> { + Ok((Self { by_root: false }, OWN_IDENTITY)) } - // Drain remaining writes on shutdown - while let Ok(write) = rx.try_recv() { - batch.push(write); + fn explain(self, error: OpenError) -> OpenError { + error } - flush_pending_batch(&conn, &mut batch, &mut pending_error, &queued_capture_bytes).await; } -async fn flush_pending_batch( - conn: &Arc>, - batch: &mut Vec, - pending_error: &mut Option, - queued_capture_bytes: &AtomicUsize, -) { - if batch.is_empty() { - return; - } +/// The `.lpm/.gitignore` line that keeps the capture database and its +/// SQLite sidecars out of git, since captures hold request headers and +/// bodies. +const CAPTURES_IGNORE_LINE: &str = "inspector.db*"; - let current_result = { - let conn = conn.lock().await; - match flush_batch(&conn, batch) { - Ok(()) => Ok(()), - Err(error) => { - tracing::warn!("inspector db flush error: {error}"); - Err(error.to_string()) - } - } - }; - if let Err(error) = current_result - && pending_error.is_none() - { - *pending_error = Some(error); - } +/// How long a connection waits for another's lock, and how long opening +/// keeps retrying what SQLite refuses at once while another connection is +/// busy. +const BUSY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); - let has_flush_marker = batch.iter().any(|write| matches!(write, DbWrite::Flush(_))); - let reported_result = pending_error.clone().map_or(Ok(()), Err); - finish_batch(batch, reported_result, queued_capture_bytes); - if has_flush_marker { - *pending_error = None; +/// How long opening waits for another LPM process to finish opening the +/// capture files, which takes longest when one replaces a large database. +const OPEN_LOCK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + +fn open_lock_timeout() -> std::time::Duration { + #[cfg(test)] + if let Some(timeout) = tests::OPEN_LOCK_WAIT.get() { + return timeout; } + OPEN_LOCK_TIMEOUT } -fn finish_batch( - batch: &mut Vec, - flush_result: Result<(), String>, - queued_capture_bytes: &AtomicUsize, -) { - for write in batch.drain(..) { - match write { - DbWrite::InsertRequest { retained_bytes, .. } => { - queued_capture_bytes.fetch_sub(retained_bytes, Ordering::Relaxed); - } - DbWrite::Flush(flushed_tx) => { - let _ = flushed_tx.send(flush_result.clone()); - } - } - } +/// The longest string, blob or row SQLite builds: a capture's two bodies of +/// at most 50 MiB each and its headers fit, and a database from elsewhere +/// can't have SQLite build anything much larger for a capture written to it. +const LONGEST_VALUE_BYTES: i32 = 128 * 1024 * 1024; + +/// The suffixes of the files SQLite keeps beside the capture database. +const SQLITE_SIDECARS: [&str; 3] = ["-wal", "-shm", "-journal"]; + +/// The file beside the capture database whose name ends in `suffix`. +fn capture_file(db_path: &Path, suffix: &str) -> PathBuf { + let mut name = db_path.as_os_str().to_os_string(); + name.push(suffix); + PathBuf::from(name) } -fn flush_batch(conn: &Connection, batch: &[DbWrite]) -> Result<(), rusqlite::Error> { - let tx = conn.unchecked_transaction()?; +/// The capture database and the sidecar files SQLite keeps beside it. +fn capture_files(db_path: &Path) -> [PathBuf; 4] { + [ + db_path.to_path_buf(), + capture_file(db_path, SQLITE_SIDECARS[0]), + capture_file(db_path, SQLITE_SIDECARS[1]), + capture_file(db_path, SQLITE_SIDECARS[2]), + ] +} - for write in batch { - match write { - DbWrite::InsertRequest { - webhook, - session_id, - .. - } => { - insert_request_row(&tx, webhook, session_id.as_deref())?; +fn with_path(path: &Path, error: std::io::Error) -> std::io::Error { + std::io::Error::new(error.kind(), format!("{}: {error}", path.display())) +} + +fn linked_or_not(path: &Path, expected: &str) -> std::io::Error { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!( + "{} is a link or not a {expected}; tunnel captures, which hold request \ + headers and bodies, are kept only in the project's own files", + path.display() + ), + ) +} + +#[cfg(unix)] +fn another_users(path: &Path) -> std::io::Error { + std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + format!( + "{} belongs to another user; tunnel captures, which hold request headers \ + and bodies, are kept only in your own files. Run the tunnel as its owner, \ + or make it yours", + path.display() + ), + ) +} + +/// A sidecar moves with its database, which an open would otherwise find +/// without it and remove; one found without a database moves alone. +fn hard_linked(path: &Path, db_path: &Path) -> std::io::Error { + let advice = if path == db_path { + format!("move it aside {}", with_its_sidecars(db_path)) + } else if db_path.exists() { + format!( + "move {} aside {}", + db_path.display(), + with_its_sidecars(db_path) + ) + } else { + "move it aside".to_owned() + }; + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!( + "{} has other hard links, so captures written to it would change those \ + files too; {advice}, and retry", + path.display() + ), + ) +} + +/// Names the files SQLite keeps beside a capture database, for advice to +/// move it: they go with it, as an open removes those it finds without a +/// database, and a WAL not yet folded into the database holds part of it. +/// Names that still start as the database's keep them in what +/// `.lpm/.gitignore` ignores. +fn with_its_sidecars(db_path: &Path) -> String { + let name = db_path + .file_name() + .unwrap_or(db_path.as_os_str()) + .to_string_lossy(); + format!( + "together with any {name}-wal, {name}-shm and {name}-journal beside it, under \ + names that still start {name} so git goes on ignoring them" + ) +} + +/// Makes sure `.lpm` is a directory of the project's own, creating it if +/// needed. A link, which a cloned repository could point anywhere, such as +/// into a directory it serves, is refused; the metadata of a link, read +/// without following it, is never a directory's. Reparse points other than +/// links, such as a synced folder's placeholders on Windows, read as the +/// directories they stand for. See [`restrict_lpm_dir`] for Unix, and for +/// whether `.lpm`'s volume ignores ownership, which this returns. +fn prepare_lpm_dir(lpm_dir: &Path, owner: CaptureOwner, access: Access) -> std::io::Result { + match std::fs::symlink_metadata(lpm_dir) { + Ok(metadata) if !metadata.is_dir() => return Err(linked_or_not(lpm_dir, "directory")), + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + std::fs::create_dir_all(lpm_dir).map_err(|error| with_path(lpm_dir, error))?; + } + Err(error) => return Err(with_path(lpm_dir, error)), + } + restrict_lpm_dir(lpm_dir, owner, access) +} + +/// `.lpm` must be the capture owner's. A capture writer makes it writable +/// by no one else, and on macOS clears its extended ACL, whose entries can +/// grant what its mode doesn't and pass to the files created in it, so no +/// other user can swap the capture files' names between the checks below +/// and SQLite's opens, or read the files created there. Says whether its +/// volume ignores ownership, as only macOS reports. +#[cfg(unix)] +fn restrict_lpm_dir(lpm_dir: &Path, owner: CaptureOwner, access: Access) -> std::io::Result { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + + let directory = + lpm_common::open_nofollow(lpm_dir).map_err(|error| with_path(lpm_dir, error))?; + let metadata = directory.metadata()?; + if !metadata.is_dir() { + return Err(linked_or_not(lpm_dir, "directory")); + } + if !owner.owns(&metadata) { + return Err(another_users(lpm_dir)); + } + #[cfg(target_os = "macos")] + let ownership_ignored = lpm_common::macos_acl::ignores_ownership(&directory) + .map_err(|error| with_path(lpm_dir, error))?; + #[cfg(not(target_os = "macos"))] + let ownership_ignored = false; + #[cfg(test)] + let ownership_ignored = ownership_ignored || tests::OWNERSHIP_IGNORED.get(); + if access == Access::Review { + return Ok(ownership_ignored); + } + let mode = metadata.mode() & 0o7777; + if mode & 0o022 != 0 { + directory + .set_permissions(std::fs::Permissions::from_mode(mode & !0o022)) + .map_err(|error| with_path(lpm_dir, error))?; + } + #[cfg(target_os = "macos")] + if lpm_common::macos_acl::has_extended_acl(&directory)? { + lpm_common::macos_acl::clear_extended_acl(&directory) + .map_err(|error| with_path(lpm_dir, error))?; + } + Ok(ownership_ignored) +} + +#[cfg(not(unix))] +fn restrict_lpm_dir( + _lpm_dir: &Path, + _owner: CaptureOwner, + _access: Access, +) -> std::io::Result { + Ok(false) +} + +/// Holds other LPM processes off the capture files while this one checks, +/// replaces and opens them. A process that opened the database while it was +/// being replaced would go on writing to the old file. Releases before the +/// lock don't take it. +fn lock_capture_files( + db_path: &Path, +) -> std::io::Result { + let lock_path = capture_file(db_path, "-lock"); + let file = lpm_common::open_lock_file_nofollow(&lock_path) + .map_err(|error| with_path(&lock_path, error))?; + let timeout = open_lock_timeout(); + match lpm_common::acquire_single_file_exclusive_lock_from_file_within(file, timeout) { + Ok(Some(lock)) => Ok(lock), + Ok(None) => Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + format!( + "another LPM process held {} for {} s while opening the captures; retry \ + once it has started", + lock_path.display(), + timeout.as_secs() + ), + )), + Err(error) => Err(std::io::Error::other(format!( + "{}: {error}", + lock_path.display() + ))), + } +} + +/// Removes the copy, and its journal, of a replacement that didn't finish, +/// as they hold captures. Only an open holding the lock makes one, so any +/// found under it is left over. +fn remove_replacement_copy(db_path: &Path) -> std::io::Result<()> { + let copy_path = capture_file(db_path, "-copy"); + remove_if_present(&capture_file(©_path, "-journal"))?; + remove_if_present(©_path) +} + +/// Checks the capture files before SQLite opens them, and says whether +/// others could open them (see [`check_capture_file`]). No capture file may +/// be a link, another user's (see [`CaptureOwner`]), or have other hard +/// links. Sidecars without +/// a database are a removed database's leftovers, which may hold its +/// captures, and are removed; SQLite would only discard them on its next +/// write. A missing database is created owner-only. +fn prepare_capture_files( + db_path: &Path, + owner: CaptureOwner, + access: Access, +) -> std::io::Result { + let mut open_to_others = false; + let mut database_exists = false; + for path in capture_files(db_path) { + match std::fs::symlink_metadata(&path) { + Ok(metadata) if !metadata.is_file() => return Err(linked_or_not(&path, "file")), + Ok(metadata) => { + open_to_others |= check_capture_file(&path, db_path, &metadata, owner, access)?; + database_exists |= path == db_path; } - DbWrite::Flush(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(with_path(&path, error)), } } + if database_exists { + return Ok(open_to_others); + } + for suffix in SQLITE_SIDECARS { + remove_if_present(&capture_file(db_path, suffix))?; + } + Ok(!create_owner_only(db_path)?) +} - tx.commit() +/// Makes the WAL files SQLite would create, when they're missing, +/// owner-only first, and says whether they read that way. A file SQLite +/// creates takes the inheritable ACL entries of its directory on macOS, +/// which a review leaves on `.lpm`. Under the open lock no LPM process has +/// the database open in WAL without them, so none is made under one in +/// use, and SQLite takes empty ones as new. +fn create_wal_sidecars(db_path: &Path) -> std::io::Result { + let mut owner_only = true; + for suffix in ["-wal", "-shm"] { + let path = capture_file(db_path, suffix); + match std::fs::symlink_metadata(&path) { + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + owner_only &= create_owner_only(&path)?; + } + Err(error) => return Err(with_path(&path, error)), + } + } + Ok(owner_only) } -fn insert_request_row( - conn: &Connection, - webhook: &CapturedWebhook, - session_id: Option<&str>, -) -> Result<(), rusqlite::Error> { - let headers_json = serde_json::to_string(&webhook.request_headers).unwrap_or_default(); - let resp_headers_json = serde_json::to_string(&webhook.response_headers).unwrap_or_default(); +fn remove_if_present(path: &Path) -> std::io::Result<()> { + match std::fs::remove_file(path) { + Err(error) if error.kind() != std::io::ErrorKind::NotFound => Err(with_path(path, error)), + _ => Ok(()), + } +} - // Extract text for FTS indexing (only index UTF-8 content) - let req_body_text = std::str::from_utf8(&webhook.request_body) - .unwrap_or("") - .to_string(); - let res_body_text = std::str::from_utf8(&webhook.response_body) - .unwrap_or("") - .to_string(); +/// Checks an existing capture file and says whether others could open it +/// to read: its mode lets its group or anyone read it, or on macOS it has +/// an extended ACL, whose entries can grant what the mode doesn't. A +/// capture writer makes one others couldn't open owner-only. One they could +/// keeps its mode, made only the owner's to read and write as well, until +/// the database is replaced, so a replacement that can't run yet runs on a +/// later open. Modes are read and changed by name, which no other user can +/// swap in a directory only its owner can write, rather than through a +/// handle, whose closing would drop the locks SQLite holds on the file in +/// this process, and which a file without read permission can't have. A +/// file that's gone needs nothing: SQLite removes `-wal` and `-shm` when +/// another process closes its last connection. +#[cfg(unix)] +fn check_capture_file( + path: &Path, + db_path: &Path, + metadata: &std::fs::Metadata, + owner: CaptureOwner, + access: Access, +) -> std::io::Result { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + + if !owner.owns(metadata) { + return Err(another_users(path)); + } + if metadata.nlink() > 1 { + return Err(hard_linked(path, db_path)); + } + let mode = metadata.mode() & 0o7777; + #[cfg(target_os = "macos")] + let extended_acl = + lpm_common::macos_acl::has_extended_acl_at(path).map_err(|error| with_path(path, error))?; + #[cfg(not(target_os = "macos"))] + let extended_acl = false; + let open_to_others = mode & 0o044 != 0 || extended_acl; + if access == Access::Review { + return Ok(open_to_others); + } + let wanted = if open_to_others { mode | 0o600 } else { 0o600 }; + if mode != wanted { + match std::fs::set_permissions(path, std::fs::Permissions::from_mode(wanted)) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(error) => return Err(with_path(path, error)), + } + } + Ok(open_to_others) +} - // Before INSERT OR REPLACE: if this ID already exists, delete its FTS entry. - // INSERT OR REPLACE deletes the old row (and its rowid) then inserts a new one - // with a new rowid, which would orphan the old FTS entry. - let old_rowid: Option = conn - .query_row( - "SELECT rowid FROM requests WHERE id = ?1", - params![webhook.id], - |row| row.get(0), +/// Refuses an existing capture file with other hard links, read through a +/// handle that doesn't follow a link. Access to it is the project's ACLs. +#[cfg(not(unix))] +fn check_capture_file( + path: &Path, + db_path: &Path, + _metadata: &std::fs::Metadata, + _owner: CaptureOwner, + _access: Access, +) -> std::io::Result { + let file = match lpm_common::open_nofollow(path) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(error) => return Err(with_path(path, error)), + }; + if lpm_common::link_count(&file).map_err(|error| with_path(path, error))? > 1 { + return Err(hard_linked(path, db_path)); + } + Ok(false) +} + +/// Creates a capture file only its owner can open, and says whether it +/// reads that way: a filesystem without Unix +/// modes, such as FAT or a Windows drive mounted in Linux, shows the mode it +/// likes. On macOS, extended ACL entries it inherited are cleared. A file +/// that appears at the name meanwhile, made by an LPM release that doesn't +/// take the lock, is refused until a retry checks it; `O_EXCL` also refuses +/// a link there. +#[cfg(unix)] +fn create_owner_only(path: &Path) -> std::io::Result { + use std::os::unix::fs::{MetadataExt, OpenOptionsExt}; + + let file = match std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(path) + { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + return Err(std::io::Error::new( + error.kind(), + format!( + "{} appeared while LPM was creating it; retry", + path.display() + ), + )); + } + Err(error) => return Err(with_path(path, error)), + }; + #[cfg(target_os = "macos")] + if lpm_common::macos_acl::has_extended_acl(&file)? { + lpm_common::macos_acl::clear_extended_acl(&file).map_err(|error| with_path(path, error))?; + } + let mode = file.metadata()?.mode(); + #[cfg(test)] + let mode = if tests::MODELESS_FILESYSTEM.get() { + mode | 0o777 + } else { + mode + }; + Ok(mode & 0o044 == 0) +} + +#[cfg(not(unix))] +fn create_owner_only(_path: &Path) -> std::io::Result { + Ok(true) +} + +/// Replaces a capture database others could open with an owner-only copy, +/// and says whether it did. Making the file owner-only stops new opens, but +/// not a handle another user opened while it was readable, which would go +/// on reading every capture written after; the copy is a new file such a +/// handle doesn't reach. As one others could open may have come from +/// anywhere, its schema and integrity are checked first. A database another +/// process has open isn't replaced, since that process would go on writing +/// to the old file. Nor is one on a filesystem that shows the copy open to +/// others too, which the copy wouldn't improve: that's found first, by +/// making the copy's file, since the checks need the only connection and +/// would otherwise refuse this open whenever another process reads the +/// database. +fn replace_with_owner_only_copy(db_path: &Path) -> Result { + let copy_path = capture_file(db_path, "-copy"); + // Taken before anything is opened, as leaving WAL grows the database. + let needed = room_to_copy(db_path); + let owner_only = create_owner_only(©_path); + #[cfg(test)] + let owner_only = tests::COPY_CREATE_ERROR + .get() + .map_or(owner_only, |kind| Err(kind.into())); + if !matches!(owner_only, Ok(true)) { + let removed = remove_replacement_copy(db_path); + if let Err(error) = owner_only { + return Err(if error.kind() == std::io::ErrorKind::StorageFull { + no_room_to_copy(db_path, needed) + } else { + error.into() + }); + } + removed?; + return Ok(false); + } + let checked = match copy_checked_database(db_path, ©_path, needed) { + Ok(checked) => checked, + Err(error) => { + if let Err(removal) = remove_replacement_copy(db_path) { + tracing::warn!( + "could not remove {}: {removal}", + capture_file(db_path, "-copy").display() + ); + } + return Err(error); + } + }; + #[cfg(test)] + if let Some(attach) = tests::AFTER_COPY.with_borrow_mut(Option::take) { + attach(); + } + for suffix in SQLITE_SIDECARS { + remove_if_present(&capture_file(db_path, suffix))?; + } + std::fs::rename(©_path, db_path).map_err(|error| with_path(db_path, error))?; + // Only now may another connection, as an earlier release's or another + // tool's that opened the database meanwhile, take a lock on it; SQLite + // then refuses its writes to a database moved aside. + drop(checked); + #[cfg(test)] + if let Some(look) = tests::AFTER_RENAME.with_borrow_mut(Option::take) { + look(); + } + Ok(true) +} + +/// Checks a database others could open, and copies it into the owner-only +/// file made for the copy, returning the connection that checked it, which +/// holds the database's exclusive lock until it's dropped: a connection +/// that attaches meanwhile, as an earlier release or another tool can +/// without the opening lock, can't commit a capture the copy lacks. A check +/// that fails leaves the WAL files its connection made: they hold no +/// captures, SQLite removes them as the last connection closes, and such a +/// connection may be using them. +fn copy_checked_database( + db_path: &Path, + copy_path: &Path, + needed: Option, +) -> Result { + ensure_room_to_copy(db_path, needed)?; + #[cfg(test)] + if let Some(attach) = tests::BEFORE_CHECK.with_borrow_mut(Option::take) { + attach(); + } + let database = open_nofollow_connection(db_path).map_err(checking(db_path, needed))?; + refuse_unknown_schema(&database, db_path, checking(db_path, needed))?; + // In WAL, which every LPM connection uses, leaving it needs the only + // connection, so it proves no other LPM process has the database open, + // and folds the WAL into the file. In exclusive locking mode, the lock + // that takes is kept until the connection closes. + database + .pragma_update(None, "locking_mode", "EXCLUSIVE") + .map_err(checking(db_path, needed))?; + match database.query_row("PRAGMA journal_mode=DELETE", [], |row| { + row.get::<_, String>(0) + }) { + Ok(mode) if mode.eq_ignore_ascii_case("delete") => {} + Ok(_) => return Err(in_use(db_path)), + Err(error) if is_busy(&error) => return Err(in_use(db_path)), + Err(error) => return Err(checking(db_path, needed)(error)), + } + refuse_damage(&database, db_path)?; + copy_database(&database, copy_path, db_path, needed)?; + Ok(database) +} + +/// Reads a full disk while the database is checked as the copy's want of +/// room, and any other failure as [`damaged`] does. +fn checking(db_path: &Path, needed: Option) -> impl Fn(rusqlite::Error) -> OpenError + '_ { + move |error| { + if out_of_room(&error) { + no_room_to_copy(db_path, needed) + } else { + damaged(db_path)(error) + } + } +} + +/// Whether SQLite failed for want of disk space: a full disk, or a +/// shared-memory file it couldn't grow, which is how a disk with less free +/// than the file's next 32 KiB fails an open. +fn out_of_room(error: &rusqlite::Error) -> bool { + match error { + rusqlite::Error::SqliteFailure(failure, _) => { + failure.code == rusqlite::ErrorCode::DiskFull + || failure.extended_code == rusqlite::ffi::SQLITE_IOERR_SHMSIZE + } + _ => false, + } +} + +/// The room SQLite's shared-memory file takes while the check reads a +/// database with `wal` bytes of WAL in pages of `page_size`: the WAL index +/// keeps a 32 KiB region for each 4,096 frames, the first region 4,062, +/// and a region more is counted for rounding. +fn shared_memory_room(wal: u64, page_size: u64) -> u64 { + const REGION: u64 = 32 * 1024; + const WAL_HEADER: u64 = 32; + const FRAME_HEADER: u64 = 24; + let frames = wal.saturating_sub(WAL_HEADER) / (page_size + FRAME_HEADER); + let regions = 1 + frames.saturating_sub(4062).div_ceil(4096); + (regions + 1) * REGION +} + +/// The size of the pages the WAL beside `db_path` holds, from its header; +/// where it doesn't read as one, SQLite's smallest, 512 bytes, which counts +/// the most frames in a WAL. The database's own header isn't read: an +/// empty or missing WAL holds no frames, so its page size counts for +/// nothing, and closing a handle of this process's on the database would +/// release the locks SQLite holds on it here. +fn page_size(db_path: &Path) -> u64 { + let mut wal = [0; 12]; + let read = lpm_common::open_nofollow(&capture_file(db_path, "-wal")) + .and_then(|mut file| std::io::Read::read_exact(&mut file, &mut wal)); + if read.is_ok() + && matches!( + u32::from_be_bytes([wal[0], wal[1], wal[2], wal[3]]), + 0x377f_0682 | 0x377f_0683 ) - .ok(); - if let Some(old) = old_rowid { - conn.execute("DELETE FROM requests_fts WHERE rowid = ?1", params![old])?; + { + let size = u64::from(u32::from_be_bytes([wal[8], wal[9], wal[10], wal[11]])); + if size.is_power_of_two() && (512..=65_536).contains(&size) { + return size; + } } + 512 +} - conn.execute( - "INSERT OR REPLACE INTO requests - (id, session_id, timestamp, method, path, status, duration_ms, - provider, summary, signature_diagnostic, - request_headers, request_body, response_headers, response_body, - req_size, res_size, auto_acked, response_body_incomplete) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)", - params![ - webhook.id, - session_id, - webhook.timestamp, - webhook.method, - webhook.path, - webhook.response_status, - webhook.duration_ms, - webhook.provider.map(|p| p.to_string()), - webhook.summary, - webhook.signature_diagnostic, - headers_json, - webhook.request_body, - resp_headers_json, - webhook.response_body, - webhook.request_body.len(), - webhook.response_body.len(), - webhook.auto_acked, - webhook.response_body_incomplete, - ], - )?; +/// The free space replacing a database takes. Leaving WAL folds the WAL into +/// the database, which grows by the WAL at most while the shared-memory file +/// is open; then the WAL and that file are removed, and the copy takes the +/// grown database's size. So it never takes more than the database and its +/// WAL, with the shared-memory file on top. +fn room_to_copy(db_path: &Path) -> Option { + let database = std::fs::metadata(db_path).ok()?.len(); + let wal = std::fs::metadata(capture_file(db_path, "-wal")).map_or(0, |wal| wal.len()); + Some( + database + .saturating_add(wal) + .saturating_add(shared_memory_room(wal, page_size(db_path))), + ) +} + +/// Refuses a replacement its disk can't hold before the database is +/// opened, as SQLite's own failure on a full disk can come as a bare I/O +/// error. +fn ensure_room_to_copy(db_path: &Path, needed: Option) -> Result<(), OpenError> { + let available = lpm_common::filesystem_writable_bytes(db_path); + #[cfg(test)] + let available = tests::AVAILABLE_BYTES.get().or(available); + match (available, needed) { + (Some(available), Some(needed)) if available < needed => { + Err(no_room_to_copy(db_path, Some(needed))) + } + _ => Ok(()), + } +} + +fn in_use(db_path: &Path) -> OpenError { + OpenError::Files(std::io::Error::new( + std::io::ErrorKind::ResourceBusy, + format!( + "{} is open to other users, and another process has it open, so it can't \ + be replaced with an owner-only copy yet; close what has it open, such as \ + `lpm tunnel inspect --ui` or other `lpm tunnel` and `lpm dev` runs in this \ + project, and retry", + db_path.display() + ), + )) +} + +/// Copies the database into the copy's file. A lock another connection +/// holds on the database is waited out for the busy timeout, inside the +/// copy's one step; one held past it leaves the copy unfinished, which is +/// refused rather than taken for the database. The copy takes as much +/// space as the database, free pages included, so a disk without it is +/// named as the cause. +fn copy_database( + database: &Connection, + copy_path: &Path, + db_path: &Path, + needed: Option, +) -> Result<(), OpenError> { + let mut copy = open_nofollow_connection(copy_path)?; + #[cfg(test)] + if let Some(pages) = tests::COPY_PAGE_LIMIT.get() { + copy.pragma_update(None, "max_page_count", pages)?; + } + let backup = rusqlite::backup::Backup::new(database, &mut copy)?; + match backup.step(-1) { + Ok(rusqlite::backup::StepResult::Done) => Ok(()), + Ok(_) => Err(in_use(db_path)), + Err(error) if out_of_room(&error) => Err(no_room_to_copy(db_path, needed)), + Err(error) => Err(damaged(db_path)(error)), + } +} + +/// The refusal of a replacement its disk lacks room for, naming the room +/// taken before the database was opened. +fn no_room_to_copy(db_path: &Path, needed: Option) -> OpenError { + let needed = needed.map_or_else( + || "as much as it takes".to_owned(), + |bytes| format!("about {}", lpm_common::format_bytes(bytes)), + ); + OpenError::Files(std::io::Error::new( + std::io::ErrorKind::StorageFull, + format!( + "{} is open to other users, and replacing it with an owner-only copy needs \ + {needed} free on its disk; free that much, or move it aside {}, and retry", + db_path.display(), + with_its_sidecars(db_path) + ), + )) +} + +/// Refuses a database SQLite finds damaged, in its pages and records or in +/// its full-text index, whose damage a crafted file could aim at SQLite's +/// full-text code: `quick_check` runs the index's own check too. +fn refuse_damage(connection: &Connection, db_path: &Path) -> Result<(), OpenError> { + let pages: String = connection + .query_row("PRAGMA quick_check(1)", [], |row| row.get(0)) + .map_err(damaged(db_path))?; + if pages != "ok" { + return Err(OpenError::Damaged { + path: db_path.to_path_buf(), + detail: lpm_common::sanitize_terminal_inline(&pages).into_owned(), + }); + } + Ok(()) +} + +/// Opens a capture database without following a link at its path, with the +/// settings every capture connection has (see [`harden_connection`]). +fn open_nofollow_connection(path: &Path) -> Result { + let connection = connect_nofollow(path)?; + harden_connection(&connection)?; + Ok(connection) +} + +/// On Unix, SQLite's no-follow open refuses a link anywhere in the path, as +/// macOS's `/var` and `/tmp` are, so it gets the directory's real path, +/// which leaves only the database itself to refuse as a link. On Windows the +/// flag does nothing, and the path is opened as it is. +fn connect_nofollow(path: &Path) -> Result { + #[cfg(unix)] + let path = { + let directory = match path.parent() { + Some(directory) if !directory.as_os_str().is_empty() => directory, + _ => Path::new("."), + }; + std::fs::canonicalize(directory) + .map_err(|error| { + rusqlite::Error::ToSqlConversionFailure(Box::new(with_path(directory, error))) + })? + .join(path.file_name().unwrap_or(path.as_os_str())) + }; + Connection::open_with_flags( + path, + rusqlite::OpenFlags::default() | rusqlite::OpenFlags::SQLITE_OPEN_NOFOLLOW, + ) +} + +/// What every capture connection has. A database from a repository or +/// another tool can't run schema code with side effects, write SQLite's +/// internal tables, or have SQLite build a value larger than +/// [`LONGEST_VALUE_BYTES`]; SQLite checks each page's cells as it reads +/// them; deleted captures are overwritten, not left in free pages; and a +/// busy database is waited for. +fn harden_connection(connection: &Connection) -> Result<(), rusqlite::Error> { + use rusqlite::config::DbConfig; + use rusqlite::limits::Limit; + + connection.set_db_config(DbConfig::SQLITE_DBCONFIG_DEFENSIVE, true)?; + connection.set_db_config(DbConfig::SQLITE_DBCONFIG_TRUSTED_SCHEMA, false)?; + connection.set_limit(Limit::SQLITE_LIMIT_LENGTH, LONGEST_VALUE_BYTES)?; + connection.busy_timeout(BUSY_TIMEOUT)?; + connection.execute_batch("PRAGMA cell_size_check=ON; PRAGMA secure_delete=ON;") +} + +/// Opens a capture connection in WAL, with `synchronous` as its sync level. +fn open_capture_connection(path: &Path, synchronous: &str) -> Result { + let connection = connect_nofollow(path)?; + configure_connection(&connection, synchronous)?; + Ok(connection) +} + +/// Configures a capture connection. Switching it to WAL opens the WAL and +/// its shared memory, by name, under the identity the open holds +/// ([`CaptureOwner`]); the connection keeps them open. +fn configure_connection(connection: &Connection, synchronous: &str) -> Result<(), rusqlite::Error> { + harden_connection(connection)?; + use_wal(connection)?; + connection.execute_batch(&format!( + "PRAGMA synchronous={synchronous}; PRAGMA foreign_keys=ON;" + )) +} + +/// Keeps the WAL and its shared memory when the last connection closes, +/// the WAL emptied rather than removed, for a connection root opened as the +/// capture owner: SQLite removes them by name, which by then can lead +/// anywhere the owner points it. +fn keep_wal_files(connection: &Connection) -> Result<(), rusqlite::Error> { + let mut persist: std::ffi::c_int = 1; + // SAFETY: the handle is this open connection's, the database name is + // NUL-terminated, and SQLite reads and writes the int only during the + // call. + let code = unsafe { + rusqlite::ffi::sqlite3_file_control( + connection.handle(), + c"main".as_ptr(), + rusqlite::ffi::SQLITE_FCNTL_PERSIST_WAL, + (&raw mut persist).cast(), + ) + }; + if code != rusqlite::ffi::SQLITE_OK { + return Err(rusqlite::Error::SqliteFailure( + rusqlite::ffi::Error::new(code), + Some("SQLite can't keep the WAL file".to_owned()), + )); + } + connection.execute_batch("PRAGMA journal_size_limit=0;") +} + +/// Switches the database to WAL. Connections switching a new database at +/// once can get SQLITE_BUSY without the busy timeout being waited, so the +/// switch is retried, a jittered few milliseconds apart, until it passes. +fn use_wal(connection: &Connection) -> Result<(), rusqlite::Error> { + use rand::Rng as _; + + let deadline = std::time::Instant::now() + BUSY_TIMEOUT; + loop { + match connection.query_row("PRAGMA journal_mode=WAL", [], |_| Ok(())) { + Err(error) if is_busy(&error) && std::time::Instant::now() < deadline => { + let pause = rand::thread_rng().gen_range(5..=25); + std::thread::sleep(std::time::Duration::from_millis(pause)); + } + result => return result, + } + } +} + +fn is_busy(error: &rusqlite::Error) -> bool { + matches!( + error.sqlite_error_code(), + Some(rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked) + ) +} + +/// A schema object as SQLite stores it: its type, name, table and SQL, the +/// SQL in its [`canonical_sql`] form. +type SchemaObject = (String, String, String, Option); + +/// SQL as SQLite tokenizes it, for comparing schema text. Whitespace next +/// to a parenthesis or a comma, which are tokens of their own, is dropped, +/// so the text an `ALTER TABLE` splices in reads the same however it's +/// spaced; any other run of whitespace reads as one space, as it may part +/// two tokens that would otherwise read as one (`< =` and `<=`). Quoted +/// text, a string or a quoted name, and comments are kept as written, a +/// line comment with the newline that ends it. Whitespace is SQLite's own: +/// ASCII space, tab, newline, form feed and carriage return. So schema text +/// whose tokens differ for SQLite can't read the same here, but for one +/// case: a parameter's Tcl form, `$x(y)` and its `@`, `:` and `#` kin, takes +/// a parenthesis into its token, so it reads the same as `$x (y)`. SQLite +/// creates no schema with a parameter, but a crafted file's schema can hold +/// one; LPM's schema has none of those characters outside quotes, so such +/// text never reads as LPM's. +fn canonical_sql(sql: &str) -> String { + let separates = |character: char| matches!(character, '(' | ')' | ','); + let mut canonical = String::with_capacity(sql.len()); + let mut spaced = false; + let mut characters = sql.chars().peekable(); + while let Some(character) = characters.next() { + if matches!(character, ' ' | '\t' | '\n' | '\x0c' | '\r') { + spaced = true; + continue; + } + if spaced + && !canonical.is_empty() + && !canonical.ends_with(separates) + && !separates(character) + { + canonical.push(' '); + } + spaced = false; + canonical.push(character); + let end = match (character, characters.peek()) { + ('\'' | '"' | '`', _) => character.to_string(), + ('[', _) => "]".to_owned(), + ('-', Some('-')) => "\n".to_owned(), + ('/', Some('*')) => { + canonical.push('*'); + characters.next(); + "*/".to_owned() + } + _ => continue, + }; + // The span's own text, so `/*/` doesn't read as a closed comment. + let mut span = String::new(); + for kept in characters.by_ref() { + span.push(kept); + if span.ends_with(end.as_str()) { + break; + } + } + canonical.push_str(&span); + } + canonical +} + +/// The database's schema objects. +fn schema_objects(connection: &Connection) -> Result, rusqlite::Error> { + let mut statement = + connection.prepare("SELECT type, name, tbl_name, sql FROM sqlite_master")?; + statement + .query_map([], |row| { + let sql: Option = row.get(3)?; + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + sql.as_deref().map(canonical_sql), + )) + })? + .collect() +} + +/// LPM's own schema objects: those [`CAPTURE_SCHEMA`] creates, which a +/// database from an earlier release may hold as they are, and those after +/// [`ensure_capture_schema`] upgrades them, both built by this SQLite in +/// memory, with the statistics tables `ANALYZE` adds, which run nothing. +/// Anything else, whatever its name, is unknown: SQLite loads an object +/// named like its own from a schema it didn't write. +fn lpm_schema() -> Result, rusqlite::Error> { + let connection = Connection::open_in_memory()?; + connection.execute_batch(CAPTURE_SCHEMA)?; + let mut objects: std::collections::HashSet<_> = + schema_objects(&connection)?.into_iter().collect(); + ensure_capture_schema(&connection)?; + connection.execute_batch("ANALYZE")?; + objects.extend(schema_objects(&connection)?); + Ok(objects) +} + +/// Refuses a database holding a schema object LPM never creates. One from +/// a repository or another tool could otherwise run code of its own on +/// every capture written: a trigger, or a check, default, generated column +/// or index expression that's slow by design. +fn refuse_unknown_schema( + connection: &Connection, + db_path: &Path, + failed: impl Fn(rusqlite::Error) -> OpenError, +) -> Result<(), OpenError> { + let known = lpm_schema()?; + let unknown: Vec = schema_objects(connection) + .map_err(failed)? + .into_iter() + .filter(|object| !known.contains(object)) + .map(|(kind, name, _, _)| format!("{kind} {}", lpm_common::sanitize_terminal_inline(&name))) + .collect(); + if unknown.is_empty() { + return Ok(()); + } + Err(OpenError::Files(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!( + "{} holds schema LPM never creates ({}); move it aside {}, and retry", + db_path.display(), + unknown.join(", "), + with_its_sidecars(db_path) + ), + ))) +} + +/// A `.gitignore` line as git reads it: without one trailing carriage +/// return or trailing spaces that aren't escaped. Leading whitespace stays +/// part of the pattern, as it does for git. +fn git_pattern(line: &[u8]) -> &[u8] { + let line = line.strip_suffix(b"\r").unwrap_or(line); + let mut end = line.len(); + while end > 0 && line[end - 1] == b' ' && !(end >= 2 && line[end - 2] == b'\\') { + end -= 1; + } + &line[..end] +} + +/// Whether these `.gitignore` lines leave the capture files ignored: a rule +/// covering them, unanchored or anchored to `.lpm/` (`inspector.db*`, or `*` +/// as a tool directory's ignore file has), after the last negation that +/// could match one. The lines are bytes, as git reads them. +fn ignores_captures(lines: &[u8]) -> bool { + const COVERING: [&[u8]; 3] = [CAPTURES_IGNORE_LINE.as_bytes(), b"*", b"**"]; + let mut ignored = false; + for line in lines.split(|byte| *byte == b'\n') { + let pattern = git_pattern(line); + let unanchored = pattern.strip_prefix(b"/").unwrap_or(pattern); + if COVERING.contains(&unanchored) { + ignored = true; + } else if let Some(negated) = pattern.strip_prefix(b"!") + && could_name_a_capture_file(negated) + { + ignored = false; + } + } + ignored +} + +/// Whether a negated pattern could match a capture file. Only one of plain +/// characters that doesn't hold `inspector.db` in any case can't: escapes, +/// wildcards and ranges can all spell a capture file's name, and a +/// repository on a case-insensitive disk matches it in any case. +fn could_name_a_capture_file(pattern: &[u8]) -> bool { + const NAME: &[u8] = b"inspector.db"; + let plain = pattern + .iter() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'/' | b'_' | b'-')); + !plain + || pattern + .windows(NAME.len()) + .any(|window| window.eq_ignore_ascii_case(NAME)) +} + +/// Adds [`CAPTURES_IGNORE_LINE`] to `.lpm/.gitignore` unless the file +/// already ignores the capture files. An ignore file inside `.lpm/` holds +/// wherever the project sits in a repository, and leaves the project's own +/// `.gitignore` alone. One that git wouldn't read as a file of the +/// project's, being a link or not a regular file, or that its owner keeps +/// from being written, being read-only or on Unix another user's, is left +/// as it is; the CLI's capture warning then says git doesn't ignore the +/// captures. +fn ensure_captures_ignored(lpm_dir: &Path, owner: CaptureOwner) -> std::io::Result<()> { + let path = lpm_dir.join(".gitignore"); + let (existing, metadata) = match lpm_common::read_file_capped_unlinked( + &path, + lpm_common::CONFIG_FILE_SIZE_CAP_BYTES, + ) { + Ok((bytes, metadata)) => (bytes, Some(metadata)), + Err(lpm_common::BoundedReadError::NotFound { .. }) => (Vec::new(), None), + Err(error) => return Err(std::io::Error::other(error)), + }; + if ignores_captures(&existing) { + return Ok(()); + } + if let Some(metadata) = &metadata { + refuse_kept_from_writing(&path, metadata, owner)?; + } + let newline: &[u8] = if existing.windows(2).any(|pair| pair == b"\r\n") { + b"\r\n" + } else { + b"\n" + }; + let mut updated = existing; + if !updated.is_empty() && !updated.ends_with(b"\n") { + updated.extend_from_slice(newline); + } + updated.extend_from_slice(b"# LPM tunnel captures hold request headers and bodies."); + updated.extend_from_slice(newline); + updated.extend_from_slice(CAPTURES_IGNORE_LINE.as_bytes()); + updated.extend_from_slice(newline); + lpm_common::write_file_atomic_with(&path, lpm_common::AtomicWriteOptions::new(), |file| { + std::io::Write::write_all(file, &updated) + }) +} + +#[cfg_attr(not(unix), allow(unused_variables))] +fn refuse_kept_from_writing( + path: &Path, + metadata: &std::fs::Metadata, + owner: CaptureOwner, +) -> std::io::Result<()> { + #[cfg(unix)] + let read_only = { + use std::os::unix::fs::PermissionsExt; + + if !owner.owns(metadata) { + return Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + format!("{} belongs to another user", path.display()), + )); + } + metadata.permissions().mode() & 0o200 == 0 + }; + #[cfg(not(unix))] + let read_only = metadata.permissions().readonly(); + if read_only { + return Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + format!("{} is read-only", path.display()), + )); + } + Ok(()) +} + +// ── Schema initialization ───────────────────────────────────────────── + +fn open_control_connection(path: &Path) -> Result { + open_capture_connection(path, "FULL") +} + +fn init_schema(conn: &Connection) -> Result<(), rusqlite::Error> { + conn.execute_batch(CAPTURE_SCHEMA)?; + ensure_capture_schema(conn) +} + +/// The capture tables and indexes, as every release has created them; +/// [`ensure_capture_schema`] upgrades them since. +const CAPTURE_SCHEMA: &str = "CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + name TEXT, + domain TEXT, + local_port INTEGER, + started_at TEXT NOT NULL DEFAULT (datetime('now')), + ended_at TEXT + ); + + CREATE TABLE IF NOT EXISTS pending_sessions ( + session_id TEXT PRIMARY KEY, + FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS requests ( + id TEXT PRIMARY KEY, + session_id TEXT, + timestamp TEXT NOT NULL, + method TEXT NOT NULL, + path TEXT NOT NULL, + status INTEGER, + duration_ms INTEGER, + provider TEXT, + summary TEXT, + signature_diagnostic TEXT, + request_headers TEXT, + request_body BLOB, + response_headers TEXT, + response_body BLOB, + req_size INTEGER, + res_size INTEGER, + tags TEXT, + auto_acked INTEGER DEFAULT 0, + created_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (session_id) REFERENCES sessions(id) + ); + + CREATE INDEX IF NOT EXISTS idx_requests_session ON requests(session_id); + CREATE INDEX IF NOT EXISTS idx_requests_provider ON requests(provider); + CREATE INDEX IF NOT EXISTS idx_requests_status ON requests(status); + CREATE INDEX IF NOT EXISTS idx_requests_timestamp ON requests(timestamp DESC, id DESC); + + CREATE VIRTUAL TABLE IF NOT EXISTS requests_fts USING fts5( + path, summary, request_body, response_body + );"; + +fn ensure_capture_schema(conn: &Connection) -> Result<(), rusqlite::Error> { + let tx = rusqlite::Transaction::new_unchecked(conn, rusqlite::TransactionBehavior::Immediate)?; + let has_capture_flag = tx + .prepare("PRAGMA table_info(requests)")? + .query_map([], |row| row.get::<_, String>(1))? + .collect::, _>>()? + .iter() + .any(|name| name == "response_body_incomplete"); + if !has_capture_flag { + tx.execute( + "ALTER TABLE requests ADD COLUMN response_body_incomplete INTEGER NOT NULL DEFAULT 0", + [], + )?; + } + tx.commit() +} + +// ── Background flush task ───────────────────────────────────────────── + +async fn flush_task( + conn: Arc>, + mut rx: mpsc::Receiver, + queued_capture_bytes: Arc, +) { + let mut batch: Vec = Vec::with_capacity(FLUSH_BATCH_SIZE); + let mut pending_error: Option = None; + + loop { + // Wait for the first write or channel close + match rx.recv().await { + Some(write) => batch.push(write), + None => break, // Channel closed — shutdown + } + + // Drain any additional buffered writes (non-blocking) + while batch.len() < FLUSH_BATCH_SIZE { + match rx.try_recv() { + Ok(write) => batch.push(write), + Err(_) => break, + } + } + + // If we didn't hit batch size, wait briefly for more writes + if batch.len() < FLUSH_BATCH_SIZE { + tokio::time::sleep(std::time::Duration::from_millis(FLUSH_INTERVAL_MS)).await; + while batch.len() < FLUSH_BATCH_SIZE { + match rx.try_recv() { + Ok(write) => batch.push(write), + Err(_) => break, + } + } + } + + flush_pending_batch(&conn, &mut batch, &mut pending_error, &queued_capture_bytes).await; + } + + // Drain remaining writes on shutdown + while let Ok(write) = rx.try_recv() { + batch.push(write); + } + flush_pending_batch(&conn, &mut batch, &mut pending_error, &queued_capture_bytes).await; +} + +async fn flush_pending_batch( + conn: &Arc>, + batch: &mut Vec, + pending_error: &mut Option, + queued_capture_bytes: &AtomicUsize, +) { + if batch.is_empty() { + return; + } + + let current_result = { + let conn = conn.lock().await; + match flush_batch(&conn, batch) { + Ok(()) => Ok(()), + Err(error) => { + tracing::warn!("inspector db flush error: {error}"); + Err(error.to_string()) + } + } + }; + if let Err(error) = current_result + && pending_error.is_none() + { + *pending_error = Some(error); + } + + let has_flush_marker = batch.iter().any(|write| matches!(write, DbWrite::Flush(_))); + let reported_result = pending_error.clone().map_or(Ok(()), Err); + finish_batch(batch, reported_result, queued_capture_bytes); + if has_flush_marker { + *pending_error = None; + } +} + +fn finish_batch( + batch: &mut Vec, + flush_result: Result<(), String>, + queued_capture_bytes: &AtomicUsize, +) { + for write in batch.drain(..) { + match write { + DbWrite::InsertRequest { retained_bytes, .. } => { + queued_capture_bytes.fetch_sub(retained_bytes, Ordering::Relaxed); + } + DbWrite::Flush(flushed_tx) => { + let _ = flushed_tx.send(flush_result.clone()); + } + } + } +} + +/// Writes a batch of captures in one transaction, which takes the write +/// lock as it begins: a capture's insert reads before it writes, and a +/// transaction that only took a snapshot can't write once another +/// connection commits, such as a clear or a tag edit, where one that waited +/// for the lock writes after it. +fn flush_batch(conn: &Connection, batch: &[DbWrite]) -> Result<(), rusqlite::Error> { + let tx = rusqlite::Transaction::new_unchecked(conn, rusqlite::TransactionBehavior::Immediate)?; + + for write in batch { + match write { + DbWrite::InsertRequest { + webhook, + session_id, + .. + } => { + insert_request_row(&tx, webhook, session_id.as_deref())?; + } + DbWrite::Flush(_) => {} + } + } + + tx.commit() +} + +fn insert_request_row( + conn: &Connection, + webhook: &CapturedWebhook, + session_id: Option<&str>, +) -> Result<(), rusqlite::Error> { + let headers_json = serde_json::to_string(&webhook.request_headers).unwrap_or_default(); + let resp_headers_json = serde_json::to_string(&webhook.response_headers).unwrap_or_default(); + + // Extract text for FTS indexing (only index UTF-8 content) + let req_body_text = std::str::from_utf8(&webhook.request_body) + .unwrap_or("") + .to_string(); + let res_body_text = std::str::from_utf8(&webhook.response_body) + .unwrap_or("") + .to_string(); + + // Before INSERT OR REPLACE: if this ID already exists, delete its FTS entry. + // INSERT OR REPLACE deletes the old row (and its rowid) then inserts a new one + // with a new rowid, which would orphan the old FTS entry. + let old_rowid: Option = conn + .query_row( + "SELECT rowid FROM requests WHERE id = ?1", + params![webhook.id], + |row| row.get(0), + ) + .ok(); + if let Some(old) = old_rowid { + conn.execute("DELETE FROM requests_fts WHERE rowid = ?1", params![old])?; + } + + conn.execute( + "INSERT OR REPLACE INTO requests + (id, session_id, timestamp, method, path, status, duration_ms, + provider, summary, signature_diagnostic, + request_headers, request_body, response_headers, response_body, + req_size, res_size, auto_acked, response_body_incomplete) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)", + params![ + webhook.id, + session_id, + webhook.timestamp, + webhook.method, + webhook.path, + webhook.response_status, + webhook.duration_ms, + webhook.provider.map(|p| p.to_string()), + webhook.summary, + webhook.signature_diagnostic, + headers_json, + webhook.request_body, + resp_headers_json, + webhook.response_body, + webhook.request_body.len(), + webhook.response_body.len(), + webhook.auto_acked, + webhook.response_body_incomplete, + ], + )?; + + // Get the new rowid and insert into FTS + let new_rowid: i64 = conn.query_row( + "SELECT rowid FROM requests WHERE id = ?1", + params![webhook.id], + |row| row.get(0), + )?; + conn.execute( + "INSERT INTO requests_fts (rowid, path, summary, request_body, response_body) + VALUES (?1, ?2, ?3, ?4, ?5)", + params![ + new_rowid, + webhook.path, + webhook.summary, + req_body_text, + res_body_text + ], + )?; + + Ok(()) +} + +/// Sanitize a user query for FTS5 to prevent syntax errors. +/// +/// FTS5 has special operators (AND, OR, NOT, NEAR, quotes, etc.). +/// We wrap each token in double quotes to treat them as literal strings. +fn sanitize_fts_query(query: &str) -> String { + query + .split_whitespace() + .map(|token| { + // Escape double quotes within tokens + let escaped = token.replace('"', "\"\""); + format!("\"{escaped}\"") + }) + .collect::>() + .join(" ") +} + +// ── Query types ─────────────────────────────────────────────────────── + +/// Status filter for search queries. +pub enum StatusQuery { + /// Exact status code (e.g., 404). + Exact(u16), + /// Status class: 2 = 2xx, 4 = 4xx, 5 = 5xx. + Class(u16), + /// All errors (>= 400). + Error, +} + +/// Compact request row (no bodies) for list views. +#[derive(Debug)] +pub struct StoredRequest { + pub id: String, + pub session_id: Option, + pub timestamp: String, + pub method: String, + pub path: String, + pub status: u16, + pub duration_ms: u64, + pub provider: Option, + pub summary: String, + pub req_size: usize, + pub res_size: usize, + pub has_signature_issue: bool, + pub auto_acked: bool, +} + +/// Full request detail including headers and bodies. +#[derive(Debug)] +pub struct StoredRequestDetail { + pub id: String, + pub session_id: Option, + pub timestamp: String, + pub method: String, + pub path: String, + pub status: u16, + pub duration_ms: u64, + pub provider: Option, + pub summary: String, + pub signature_diagnostic: Option, + pub request_headers: String, + pub request_body: Vec, + pub response_headers: String, + pub response_body: Vec, + pub tags: Option, + pub auto_acked: bool, + pub req_size: usize, + pub res_size: usize, + pub response_body_incomplete: bool, +} + +fn stored_detail_from_row(row: &rusqlite::Row<'_>) -> Result { + Ok(StoredRequestDetail { + id: row.get(0)?, + session_id: row.get(1)?, + timestamp: row.get(2)?, + method: row.get(3)?, + path: row.get(4)?, + status: row.get(5)?, + duration_ms: row.get(6)?, + provider: row.get(7)?, + summary: row.get(8)?, + signature_diagnostic: row.get(9)?, + request_headers: row.get(10)?, + request_body: row.get(11)?, + response_headers: row.get(12)?, + response_body: row.get(13)?, + tags: row.get(14)?, + auto_acked: row.get(15)?, + response_body_incomplete: row.get(18)?, + req_size: row.get(16)?, + res_size: row.get(17)?, + }) +} + +fn stored_detail_to_webhook( + stored: StoredRequestDetail, +) -> Result { + if stored.request_body.len() != stored.req_size || stored.response_body.len() != stored.res_size + { + return Err(rusqlite::Error::FromSqlConversionFailure( + 11, + rusqlite::types::Type::Blob, + Box::new(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "capture body was truncated by an older LPM CLI and cannot be replayed faithfully", + )), + )); + } + + let request_headers: HashMap = serde_json::from_str(&stored.request_headers) + .map_err(|error| { + rusqlite::Error::FromSqlConversionFailure( + 10, + rusqlite::types::Type::Text, + Box::new(error), + ) + })?; + let response_headers: HashMap = serde_json::from_str(&stored.response_headers) + .map_err(|error| { + rusqlite::Error::FromSqlConversionFailure( + 12, + rusqlite::types::Type::Text, + Box::new(error), + ) + })?; + let provider = stored + .provider + .as_deref() + .map(parse_stored_provider) + .transpose()?; + + Ok(CapturedWebhook { + id: stored.id, + timestamp: stored.timestamp, + method: stored.method, + path: stored.path, + request_headers, + request_body: stored.request_body, + response_status: stored.status, + response_headers, + response_body: stored.response_body, + duration_ms: stored.duration_ms, + provider, + summary: stored.summary, + signature_diagnostic: stored.signature_diagnostic, + auto_acked: stored.auto_acked, + response_body_incomplete: stored.response_body_incomplete, + }) +} + +fn parse_stored_provider(provider: &str) -> Result { + match provider.to_ascii_lowercase().as_str() { + "stripe" => Ok(WebhookProvider::Stripe), + "github" => Ok(WebhookProvider::GitHub), + "clerk" => Ok(WebhookProvider::Clerk), + "resend" => Ok(WebhookProvider::Resend), + "sendgrid" => Ok(WebhookProvider::SendGrid), + "twilio" => Ok(WebhookProvider::Twilio), + "svix" => Ok(WebhookProvider::Svix), + _ => Err(rusqlite::Error::FromSqlConversionFailure( + 7, + rusqlite::types::Type::Text, + Box::new(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("unknown stored webhook provider `{provider}`"), + )), + )), + } +} + +/// Session metadata. +#[derive(Debug)] +pub struct StoredSession { + pub id: String, + pub name: Option, + pub domain: Option, + pub local_port: u16, + pub started_at: String, + pub ended_at: Option, + pub request_count: usize, +} + +/// Session detail with summary statistics. +#[derive(Debug)] +pub struct SessionDetail { + pub id: String, + pub name: Option, + pub domain: Option, + pub local_port: u16, + pub started_at: String, + pub ended_at: Option, + pub request_count: usize, + pub failure_count: usize, + pub provider_count: usize, +} + +// ── Tests ───────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use lpm_tunnel::webhook::WebhookProvider; + use std::collections::HashMap; + + /// The advice for the capture database LPM can't use. + const ADVICE: &str = "move it aside together with any inspector.db-wal, \ + inspector.db-shm and inspector.db-journal beside it, under names \ + that still start inspector.db so git goes on ignoring them, and \ + retry"; + + thread_local! { + /// The most pages this thread's database copies may hold, as a + /// full disk would stop them. + pub(super) static COPY_PAGE_LIMIT: std::cell::Cell> = + const { std::cell::Cell::new(None) }; + /// How long this thread's opens wait for the lock, in place of + /// [`OPEN_LOCK_TIMEOUT`], for a test of the wait itself. + pub(super) static OPEN_LOCK_WAIT: std::cell::Cell> = + const { std::cell::Cell::new(None) }; + /// The free space this thread's replacements find on their disk. + pub(super) static AVAILABLE_BYTES: std::cell::Cell> = + const { std::cell::Cell::new(None) }; + /// The failure this thread's replacement copies are created with, as + /// a disk with no blocks left fails them. + pub(super) static COPY_CREATE_ERROR: std::cell::Cell> = + const { std::cell::Cell::new(None) }; + /// Runs once as this thread's next replacement starts its check, as + /// another process can open the database then. + pub(super) static BEFORE_CHECK: std::cell::RefCell>> = + const { std::cell::RefCell::new(None) }; + /// Runs once after this thread's next replacement has copied the + /// database, before the copy takes its name, as a connection + /// without the opening lock can leave files beside it then. + pub(super) static AFTER_COPY: std::cell::RefCell>> = + const { std::cell::RefCell::new(None) }; + /// Runs once after this thread's next replacement's copy takes the + /// database's name, before anything opens it. + pub(super) static AFTER_RENAME: std::cell::RefCell>> = + const { std::cell::RefCell::new(None) }; + /// Runs once after this thread's next open has checked `.lpm`, as + /// the project's owner can move it then. + pub(super) static AFTER_LPM_DIR: std::cell::RefCell>> = + const { std::cell::RefCell::new(None) }; + } + + #[cfg(unix)] + thread_local! { + /// Makes this thread's new capture files read as a filesystem + /// without Unix modes shows them: open to anyone. + pub(super) static MODELESS_FILESYSTEM: std::cell::Cell = const { std::cell::Cell::new(false) }; + /// Makes this thread's `.lpm` read as on a volume that ignores + /// ownership. + pub(super) static OWNERSHIP_IGNORED: std::cell::Cell = const { std::cell::Cell::new(false) }; + } + + /// The capture owner of a project of this user's. + #[cfg(unix)] + fn this_user() -> CaptureOwner { + CaptureOwner { + uid: lpm_common::current_uid(), + by_root: false, + } + } + + const CAPTURES_IGNORED: &str = + "# LPM tunnel captures hold request headers and bodies.\ninspector.db*\n"; + + fn lpm_gitignore(project: &Path) -> String { + std::fs::read_to_string(project.join(".lpm/.gitignore")).unwrap() + } + + #[cfg(unix)] + fn mode(path: &Path) -> u32 { + use std::os::unix::fs::PermissionsExt; + + std::fs::symlink_metadata(path) + .unwrap() + .permissions() + .mode() + & 0o7777 + } + + #[cfg(unix)] + fn set_mode(path: &Path, mode: u32) { + use std::os::unix::fs::PermissionsExt; + + std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).unwrap(); + } + + /// A file of root's, for checks of files that belong to another user; + /// None when the tests run as root. + #[cfg(unix)] + fn another_users_file() -> Option<(PathBuf, std::fs::Metadata)> { + if lpm_common::current_uid() == 0 { + return None; + } + let path = PathBuf::from("/etc/hosts"); + let metadata = std::fs::symlink_metadata(&path).unwrap(); + Some((path, metadata)) + } + + #[tokio::test] + async fn opening_keeps_captures_out_of_git_once() { + let project = tempfile::tempdir().unwrap(); + + drop(InspectorDb::open(project.path()).unwrap()); + drop(InspectorDb::open(project.path()).unwrap()); + + assert_eq!(lpm_gitignore(project.path()), CAPTURES_IGNORED); + } + + #[tokio::test] + async fn opening_adds_the_capture_rule_to_an_lpm_gitignore_of_the_project() { + let project = tempfile::tempdir().unwrap(); + std::fs::create_dir(project.path().join(".lpm")).unwrap(); + std::fs::write(project.path().join(".lpm/.gitignore"), "skills/").unwrap(); + + drop(InspectorDb::open(project.path()).unwrap()); + + assert_eq!( + lpm_gitignore(project.path()), + format!("skills/\n{CAPTURES_IGNORED}") + ); + } + + fn opened_with_gitignore(content: &[u8]) -> (tempfile::TempDir, Vec) { + let project = tempfile::tempdir().unwrap(); + std::fs::create_dir(project.path().join(".lpm")).unwrap(); + std::fs::write(project.path().join(".lpm/.gitignore"), content).unwrap(); + drop(InspectorDb::open(project.path()).unwrap()); + let written = std::fs::read(project.path().join(".lpm/.gitignore")).unwrap(); + (project, written) + } + + #[tokio::test] + async fn opening_leaves_an_lpm_gitignore_git_reads_as_ignoring_captures() { + for content in [ + "inspector.db*\n", + "inspector.db* \n", + "inspector.db*\r\n", + "/inspector.db*\n", + "!inspector.db\ninspector.db*\n", + "*\n!.gitignore\n", + "*\n!skills/\n!/added-sources.json\n!Skills_2-x\n", + "/*\n", + "**\n", + ] { + let (_project, written) = opened_with_gitignore(content.as_bytes()); + assert_eq!(written, content.as_bytes(), "{content:?}"); + } + } + + #[tokio::test] + async fn opening_adds_the_rule_where_git_wouldnt_read_one() { + for content in [ + " inspector.db*\n", + "\tinspector.db*\n", + "inspector.db*\t\n", + "inspector.db*\\ \n", + "inspector.db*\n!inspector.db\n", + "inspector.db*\n!*\n", + "inspector.db*\n!*.db\n", + "inspector.db*\n!inspector*\n", + "inspector.db*\n!**\n", + "inspector.db*\n!inspector.d?\n", + "inspector.db*\n!inspector.d[b]\n", + "inspector.db*\n!inspector.d\\b\n", + "inspector.db*\n!inspector\\.db\n", + "inspector.db*\n!/inspector.d\\b\n", + "inspector.db*\n!inspecto\\r.db-wal\n", + "inspector.db*\n!Inspector.db\n", + "inspector.db*\n!INSPECTOR.DB-WAL\n", + "**\n!INSPECTOR.DB\n", + "*\n!.lpm/inspector.db\n", + "inspector.db\n", + ] { + let (_project, written) = opened_with_gitignore(content.as_bytes()); + assert_eq!( + written, + format!("{content}{CAPTURES_IGNORED}").as_bytes(), + "{content:?}" + ); + } + } + + #[tokio::test] + async fn opening_adds_the_rule_to_an_lpm_gitignore_that_isnt_utf8() { + let content = b"skills/\n\xff\xfe-notes\n"; + + let (_project, written) = opened_with_gitignore(content); + + let mut expected = content.to_vec(); + expected.extend_from_slice(CAPTURES_IGNORED.as_bytes()); + assert_eq!(written, expected); + } + + #[tokio::test] + async fn opening_reads_a_rule_beside_lines_that_arent_utf8() { + let content = b"\xff\xfe\ninspector.db*\n"; + + let (_project, written) = opened_with_gitignore(content); + + assert_eq!(written, content); + } + + #[tokio::test] + async fn opening_adds_the_rule_after_one_with_two_carriage_returns_which_git_reads_as_another() + { + let (_project, written) = opened_with_gitignore(b"inspector.db*\r\r\n"); + + assert_eq!( + written, + b"inspector.db*\r\r\n# LPM tunnel captures hold request headers and bodies.\r\ninspector.db*\r\n" + ); + } + + #[tokio::test] + async fn opening_reads_a_negation_ending_in_a_carriage_return_as_git_does() { + let (_project, written) = opened_with_gitignore(b"inspector.db*\r\n!Inspector.DB\r\n"); + + assert_eq!( + written, + b"inspector.db*\r\n!Inspector.DB\r\n# LPM tunnel captures hold request headers and bodies.\r\ninspector.db*\r\n" + ); + } + + #[tokio::test] + async fn opening_adds_the_rule_with_the_file_s_line_endings() { + let (_project, written) = opened_with_gitignore(b"skills/\r\n"); + + assert_eq!( + written, + b"skills/\r\n# LPM tunnel captures hold request headers and bodies.\r\ninspector.db*\r\n" + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn opening_leaves_a_read_only_lpm_gitignore_alone_even_empty() { + for content in ["skills/\n", ""] { + let project = tempfile::tempdir().unwrap(); + std::fs::create_dir(project.path().join(".lpm")).unwrap(); + let path = project.path().join(".lpm/.gitignore"); + std::fs::write(&path, content).unwrap(); + set_mode(&path, 0o444); + + drop(InspectorDb::open(project.path()).unwrap()); + + assert_eq!(lpm_gitignore(project.path()), content, "{content:?}"); + } + } + + #[cfg(unix)] + #[tokio::test] + async fn opening_leaves_an_lpm_gitignore_its_owner_cant_write_alone() { + let project = tempfile::tempdir().unwrap(); + std::fs::create_dir(project.path().join(".lpm")).unwrap(); + let path = project.path().join(".lpm/.gitignore"); + std::fs::write(&path, "skills/\n").unwrap(); + set_mode(&path, 0o464); + + drop(InspectorDb::open(project.path()).unwrap()); + + assert_eq!(lpm_gitignore(project.path()), "skills/\n"); + } + + #[cfg(unix)] + #[test] + fn leaves_another_user_s_lpm_gitignore_alone() { + let Some((path, metadata)) = another_users_file() else { + return; + }; + + let error = refuse_kept_from_writing(&path, &metadata, this_user()).unwrap_err(); + + assert!( + error.to_string().contains("belongs to another user"), + "{error}" + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn opening_leaves_an_lpm_gitignore_that_isnt_a_regular_file_alone() { + let project = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(project.path().join(".lpm/.gitignore")).unwrap(); + + drop(InspectorDb::open(project.path()).unwrap()); + + assert!(project.path().join(".lpm/.gitignore").is_dir()); + } + + #[tokio::test] + async fn opening_writes_the_rule_before_it_creates_the_database() { + let project = tempfile::tempdir().unwrap(); + // A database path that can't be a file makes the open fail right + // after the rule is written, and before any database exists. + std::fs::create_dir_all(project.path().join(".lpm/inspector.db")).unwrap(); + + assert!(InspectorDb::open(project.path()).is_err()); + + assert_eq!(lpm_gitignore(project.path()), CAPTURES_IGNORED); + } + + #[cfg(unix)] + #[tokio::test] + async fn opening_makes_an_lpm_directory_others_could_write_theirs_alone() { + let project = tempfile::tempdir().unwrap(); + let lpm_dir = project.path().join(".lpm"); + std::fs::create_dir(&lpm_dir).unwrap(); + set_mode(&lpm_dir, 0o777); + + drop(InspectorDb::open(project.path()).unwrap()); + + assert_eq!(mode(&lpm_dir), 0o755); + } + + #[cfg(unix)] + #[test] + fn refuses_another_user_s_lpm_directory() { + if lpm_common::current_uid() == 0 { + return; + } + + let error = restrict_lpm_dir(Path::new("/usr"), this_user(), Access::Review).unwrap_err(); + + assert!( + error.to_string().contains("belongs to another user"), + "{error}" + ); + } + + #[cfg(unix)] + #[test] + fn refuses_another_user_s_capture_file() { + let Some((path, metadata)) = another_users_file() else { + return; + }; + + let error = + check_capture_file(&path, &path, &metadata, this_user(), Access::Review).unwrap_err(); + + assert!( + error.to_string().contains("belongs to another user"), + "{error}" + ); + } + + #[cfg(unix)] + #[test] + fn checking_a_capture_file_that_vanished_needs_nothing() { + let project = tempfile::tempdir().unwrap(); + let path = project.path().join("inspector.db-wal"); + std::fs::write(&path, b"").unwrap(); + set_mode(&path, 0o400); + let metadata = std::fs::symlink_metadata(&path).unwrap(); + std::fs::remove_file(&path).unwrap(); + + assert!( + !check_capture_file(&path, &path, &metadata, this_user(), Access::Capture).unwrap() + ); + } + + #[cfg(unix)] + #[test] + fn preparing_refuses_a_capture_file_with_other_hard_links() { + let project = tempfile::tempdir().unwrap(); + let lpm_dir = project.path().join(".lpm"); + std::fs::create_dir(&lpm_dir).unwrap(); + let served = project.path().join("public.txt"); + std::fs::write(&served, b"").unwrap(); + set_mode(&served, 0o644); + let db_path = lpm_dir.join("inspector.db"); + std::fs::hard_link(&served, &db_path).unwrap(); + + let error = prepare_capture_files(&db_path, this_user(), Access::Capture).unwrap_err(); + + assert!(error.to_string().contains("other hard links"), "{error}"); + assert!(error.to_string().ends_with(ADVICE), "{error}"); + assert_eq!(mode(&served), 0o644); + + // A sidecar with other hard links moves with its database, which an + // open would otherwise find without it and remove. + std::fs::remove_file(&db_path).unwrap(); + std::fs::write(&db_path, b"").unwrap(); + std::fs::hard_link(&served, lpm_dir.join("inspector.db-wal")).unwrap(); + + let error = prepare_capture_files(&db_path, this_user(), Access::Capture).unwrap_err(); + + assert!( + error.to_string().ends_with(&format!( + "move {} aside together with any inspector.db-wal, inspector.db-shm and \ + inspector.db-journal beside it, under names that still start inspector.db \ + so git goes on ignoring them, and retry", + db_path.display() + )), + "{error}" + ); + assert!(lpm_dir.join("inspector.db-wal").exists()); + + // Without a database, the sidecar moves alone. + std::fs::remove_file(&db_path).unwrap(); + let error = prepare_capture_files(&db_path, this_user(), Access::Capture).unwrap_err(); + + assert!( + error.to_string().ends_with(&format!( + "{} has other hard links, so captures written to it would change those \ + files too; move it aside, and retry", + lpm_dir.join("inspector.db-wal").display() + )), + "{error}" + ); + assert!(lpm_dir.join("inspector.db-wal").exists()); + } + + #[cfg(unix)] + #[tokio::test] + async fn opening_refuses_a_linked_lpm_directory_or_capture_file() { + let outside = tempfile::tempdir().unwrap(); + let linked_dir = tempfile::tempdir().unwrap(); + std::os::unix::fs::symlink(outside.path(), linked_dir.path().join(".lpm")).unwrap(); + assert!(InspectorDb::open(linked_dir.path()).is_err()); + assert!(!outside.path().join("inspector.db").exists()); + + for name in ["inspector.db", "inspector.db-wal"] { + let project = tempfile::tempdir().unwrap(); + std::fs::create_dir(project.path().join(".lpm")).unwrap(); + let target = outside.path().join(format!("{name}-target")); + std::fs::write(&target, "outside\n").unwrap(); + set_mode(&target, 0o644); + std::os::unix::fs::symlink(&target, project.path().join(".lpm").join(name)).unwrap(); + + assert!(InspectorDb::open(project.path()).is_err(), "{name}"); + assert_eq!( + std::fs::read_to_string(&target).unwrap(), + "outside\n", + "{name}" + ); + assert_eq!(mode(&target), 0o644, "{name}"); + } + } + + #[cfg(unix)] + #[test] + fn preparing_makes_leftover_capture_files_others_couldnt_open_owner_only() { + let project = tempfile::tempdir().unwrap(); + let lpm_dir = project.path().join(".lpm"); + std::fs::create_dir(&lpm_dir).unwrap(); + for (name, leftover_mode) in [ + ("inspector.db", 0o000), + ("inspector.db-wal", 0o400), + ("inspector.db-shm", 0o733), + ("inspector.db-journal", 0o611), + ] { + let path = lpm_dir.join(name); + std::fs::write(&path, b"").unwrap(); + set_mode(&path, leftover_mode); + } + + assert!( + !prepare_capture_files(&lpm_dir.join("inspector.db"), this_user(), Access::Capture) + .unwrap() + ); + + for name in [ + "inspector.db", + "inspector.db-wal", + "inspector.db-shm", + "inspector.db-journal", + ] { + assert_eq!(mode(&lpm_dir.join(name)), 0o600, "{name}"); + } + } + + #[cfg(unix)] + #[test] + fn preparing_keeps_a_leftover_others_could_open_for_the_database_s_replacement() { + let project = tempfile::tempdir().unwrap(); + let lpm_dir = project.path().join(".lpm"); + std::fs::create_dir(&lpm_dir).unwrap(); + for (name, leftover_mode) in [("inspector.db", 0o600), ("inspector.db-wal", 0o204)] { + let path = lpm_dir.join(name); + std::fs::write(&path, b"").unwrap(); + set_mode(&path, leftover_mode); + } + + assert!( + prepare_capture_files(&lpm_dir.join("inspector.db"), this_user(), Access::Capture) + .unwrap() + ); + + assert_eq!(mode(&lpm_dir.join("inspector.db-wal")), 0o604); + } + + #[cfg(unix)] + #[test] + fn preparing_reads_a_capture_file_its_group_could_read_as_open_to_others() { + let project = tempfile::tempdir().unwrap(); + let lpm_dir = project.path().join(".lpm"); + std::fs::create_dir(&lpm_dir).unwrap(); + for (name, leftover_mode) in [("inspector.db", 0o600), ("inspector.db-shm", 0o440)] { + let path = lpm_dir.join(name); + std::fs::write(&path, b"").unwrap(); + set_mode(&path, leftover_mode); + } + + assert!( + prepare_capture_files(&lpm_dir.join("inspector.db"), this_user(), Access::Capture) + .unwrap() + ); + + assert_eq!(mode(&lpm_dir.join("inspector.db-shm")), 0o640); + } + + #[cfg(unix)] + #[test] + fn preparing_removes_sidecars_left_without_a_database() { + let project = tempfile::tempdir().unwrap(); + let lpm_dir = project.path().join(".lpm"); + std::fs::create_dir(&lpm_dir).unwrap(); + for name in [ + "inspector.db-wal", + "inspector.db-shm", + "inspector.db-journal", + ] { + let path = lpm_dir.join(name); + std::fs::write(&path, b"leftover").unwrap(); + set_mode(&path, 0o644); + } + + assert!( + !prepare_capture_files(&lpm_dir.join("inspector.db"), this_user(), Access::Capture) + .unwrap() + ); + + for name in [ + "inspector.db-wal", + "inspector.db-shm", + "inspector.db-journal", + ] { + assert!(!lpm_dir.join(name).exists(), "{name}"); + } + assert_eq!(mode(&lpm_dir.join("inspector.db")), 0o600); + } + + #[cfg(unix)] + #[tokio::test] + async fn opening_keeps_capture_files_owner_only() { + let project = tempfile::tempdir().unwrap(); + std::fs::create_dir(project.path().join(".lpm")).unwrap(); + let wal = project.path().join(".lpm/inspector.db-wal"); + std::fs::write(&wal, b"").unwrap(); + set_mode(&wal, 0o644); + + drop(InspectorDb::open(project.path()).unwrap()); + + for name in ["inspector.db", "inspector.db-wal"] { + assert_eq!( + mode(&project.path().join(".lpm").join(name)), + 0o600, + "{name}" + ); + } + } + + /// A project whose capture database holds one capture, with no + /// connection left open. + #[cfg(unix)] + fn project_with_a_capture() -> tempfile::TempDir { + let project = tempfile::tempdir().unwrap(); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + runtime.block_on(async { + let db = InspectorDb::open(project.path()).unwrap(); + db.insert_durable_request(Arc::new(make_webhook("kept", 200)), None) + .await + .unwrap(); + }); + // Dropping the runtime drops the flush task and its connection. + drop(runtime); + project + } + + #[cfg(unix)] + #[test] + fn opening_replaces_a_database_others_could_open_with_an_owner_only_copy() { + use std::os::unix::fs::MetadataExt; + + let project = project_with_a_capture(); + let db_path = project.path().join(".lpm/inspector.db"); + set_mode(&db_path, 0o644); + // A handle another user could have opened while the file was + // readable. + let earlier_handle = std::fs::File::open(&db_path).unwrap(); + + let runtime = tokio::runtime::Runtime::new().unwrap(); + let kept = runtime.block_on(async { + let db = InspectorDb::open(project.path()).unwrap(); + db.get_webhook("kept").await.unwrap() + }); + + assert!(kept.is_some()); + assert_eq!(mode(&db_path), 0o600); + let replaced = earlier_handle.metadata().unwrap(); + assert_eq!(replaced.nlink(), 0); + assert_ne!(replaced.ino(), std::fs::metadata(&db_path).unwrap().ino()); + assert!(!project.path().join(".lpm/inspector.db-copy").exists()); + } + + #[cfg(unix)] + #[test] + fn opening_waits_to_replace_a_database_another_connection_has_open() { + let project = project_with_a_capture(); + let db_path = project.path().join(".lpm/inspector.db"); + set_mode(&db_path, 0o644); + let other = Connection::open(&db_path).unwrap(); + other + .query_row("SELECT count(*) FROM requests", [], |row| { + row.get::<_, i64>(0) + }) + .unwrap(); + let runtime = tokio::runtime::Runtime::new().unwrap(); + let started = std::time::Instant::now(); + + let error = runtime + .block_on(async { InspectorDb::open(project.path()).map(drop) }) + .unwrap_err(); + + assert!( + error.to_string().contains("another process has it open") + && error.to_string().contains("`lpm tunnel inspect --ui`"), + "{error}" + ); + assert_eq!(error.kind(), std::io::ErrorKind::ResourceBusy); + assert!(!error.to_string().contains("Move or repair"), "{error}"); + // Leaving WAL tells at once; the copy's own wait is the busy timeout. + assert!(started.elapsed() < std::time::Duration::from_secs(4)); + assert_eq!(mode(&db_path), 0o644); + // The open connection's WAL files aren't the refused check's to remove. + for suffix in ["-wal", "-shm"] { + assert!(capture_file(&db_path, suffix).exists(), "{suffix} removed"); + } + drop(other); + runtime.block_on(async { drop(InspectorDb::open(project.path()).unwrap()) }); + assert_eq!(mode(&db_path), 0o600); + } + + /// Holds the lock openers take while they check, replace and open the + /// capture files. + fn hold_capture_lock(project: &Path) -> lpm_common::SingleFileExclusiveLockHandle { + let file = + lpm_common::open_lock_file_nofollow(&project.join(".lpm/inspector.db-lock")).unwrap(); + lpm_common::acquire_single_file_exclusive_lock_from_file_within( + file, + std::time::Duration::ZERO, + ) + .unwrap() + .unwrap() + } + + #[cfg(unix)] + #[test] + fn reviewing_leaves_a_database_others_could_open_as_it_is_and_says_so() { + use std::os::unix::fs::MetadataExt; + + let project = project_with_a_capture(); + let lpm_dir = project.path().join(".lpm"); + let db_path = lpm_dir.join("inspector.db"); + set_mode(&db_path, 0o644); + set_mode(&lpm_dir, 0o775); + let inode = std::fs::metadata(&db_path).unwrap().ino(); + // A process recording to it, which a writer's open would refuse to + // replace it under. + let recording = Connection::open(&db_path).unwrap(); + recording + .query_row("SELECT count(*) FROM requests", [], |row| { + row.get::<_, i64>(0) + }) + .unwrap(); + + let reviewing = tokio::runtime::Runtime::new().unwrap(); + let (open_to_others, kept) = reviewing.block_on(async { + let db = InspectorDb::open_for_review(project.path()).unwrap(); + (db.open_to_others(), db.get_webhook("kept").await.unwrap()) + }); + // Shutting the runtime down closes the review's connections, as the + // end of its process would. + drop(reviewing); + + assert!(open_to_others); + assert!(kept.is_some()); + assert_eq!(mode(&db_path), 0o644); + assert_eq!(mode(&lpm_dir), 0o775); + assert_eq!(std::fs::metadata(&db_path).unwrap().ino(), inode); + drop(recording); + let open_to_others = tokio::runtime::Runtime::new() + .unwrap() + .block_on(async { InspectorDb::open(project.path()).unwrap().open_to_others() }); + assert!(!open_to_others); + assert_eq!(mode(&db_path), 0o600); + assert_eq!(mode(&lpm_dir), 0o755); + } + + #[cfg(unix)] + #[tokio::test] + async fn reviewing_a_new_project_creates_an_owner_only_database() { + let project = tempfile::tempdir().unwrap(); + + let db = InspectorDb::open_for_review(project.path()).unwrap(); + + assert!(!db.open_to_others()); + assert_eq!(mode(&project.path().join(".lpm/inspector.db")), 0o600); + assert_eq!(lpm_gitignore(project.path()), CAPTURES_IGNORED); + } + + #[cfg(unix)] + #[tokio::test] + async fn reviewing_leaves_capture_files_others_couldnt_open_as_they_are() { + let project = tempfile::tempdir().unwrap(); + drop(InspectorDb::open(project.path()).unwrap()); + let db_path = project.path().join(".lpm/inspector.db"); + set_mode(&db_path, 0o620); + + let db = InspectorDb::open_for_review(project.path()).unwrap(); + + assert!(!db.open_to_others()); + assert_eq!(mode(&db_path), 0o620); + } + + #[tokio::test] + async fn every_open_removes_a_replacement_copy_left_over() { + for open in [InspectorDb::open, InspectorDb::open_for_review] { + let project = tempfile::tempdir().unwrap(); + let lpm_dir = project.path().join(".lpm"); + std::fs::create_dir(&lpm_dir).unwrap(); + std::fs::write(lpm_dir.join("inspector.db-copy"), b"captures").unwrap(); + std::fs::write(lpm_dir.join("inspector.db-copy-journal"), b"captures").unwrap(); + + drop(open(project.path()).unwrap()); + + assert!(!lpm_dir.join("inspector.db-copy").exists()); + assert!(!lpm_dir.join("inspector.db-copy-journal").exists()); + } + } + + #[cfg(unix)] + #[test] + fn a_filesystem_without_modes_keeps_its_database_and_says_it_s_open_to_others() { + use std::os::unix::fs::MetadataExt; + + let project = project_with_a_capture(); + let db_path = project.path().join(".lpm/inspector.db"); + set_mode(&db_path, 0o644); + let inode = std::fs::metadata(&db_path).unwrap().ino(); + let fresh = tempfile::tempdir().unwrap(); + MODELESS_FILESYSTEM.set(true); + + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let (existing, ownership_ignored, kept, new) = runtime.block_on(async { + let db = InspectorDb::open(project.path()).unwrap(); + let kept = db.get_webhook("kept").await.unwrap(); + let new = InspectorDb::open(fresh.path()).unwrap().open_to_others(); + (db.open_to_others(), db.ownership_ignored(), kept, new) + }); + MODELESS_FILESYSTEM.set(false); + + assert!(existing); + assert!(!ownership_ignored); + assert!(new); + assert!(kept.is_some()); + assert_eq!(std::fs::metadata(&db_path).unwrap().ino(), inode); + assert!(!project.path().join(".lpm/inspector.db-copy").exists()); + } + + #[cfg(unix)] + #[test] + fn a_volume_that_ignores_ownership_keeps_its_database_and_says_it_s_open_to_others() { + use std::os::unix::fs::MetadataExt; + + let project = project_with_a_capture(); + let db_path = project.path().join(".lpm/inspector.db"); + set_mode(&db_path, 0o644); + let inode = std::fs::metadata(&db_path).unwrap().ino(); + let fresh = tempfile::tempdir().unwrap(); + let owner_only = project_with_a_capture(); + assert_eq!(mode(&owner_only.path().join(".lpm/inspector.db")), 0o600); + OWNERSHIP_IGNORED.set(true); + + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let (reviewed, existing, kept, new) = runtime.block_on(async { + let reviewed = InspectorDb::open_for_review(owner_only.path()).unwrap(); + let review = InspectorDb::open_for_review(project.path()).unwrap(); + let db = InspectorDb::open(project.path()).unwrap(); + let kept = db.get_webhook("kept").await.unwrap(); + let new = InspectorDb::open(fresh.path()).unwrap(); + drop(review); + let state = |db: &InspectorDb| (db.open_to_others(), db.ownership_ignored()); + (state(&reviewed), state(&db), kept, state(&new)) + }); + OWNERSHIP_IGNORED.set(false); + + assert_eq!(reviewed, (true, true)); + assert_eq!(existing, (true, true)); + assert_eq!(new, (true, true)); + assert!(kept.is_some()); + assert_eq!(std::fs::metadata(&db_path).unwrap().ino(), inode); + assert!(!project.path().join(".lpm/inspector.db-copy").exists()); + } + + /// A disk image attached for a test, detached when dropped. + #[cfg(target_os = "macos")] + struct AttachedImage(std::path::PathBuf); + + #[cfg(target_os = "macos")] + impl Drop for AttachedImage { + fn drop(&mut self) { + let _ = std::process::Command::new("/usr/bin/hdiutil") + .args(["detach", "-force", "-quiet"]) + .arg(&self.0) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status(); + } + } + + /// Attaches a new HFS+ image of `size` in `directory`, keeping or + /// ignoring ownership on it. + #[cfg(target_os = "macos")] + fn attach_image(directory: &Path, size: &str, owners: bool) -> AttachedImage { + let hdiutil = |arguments: &[&std::ffi::OsStr]| { + let output = std::process::Command::new("/usr/bin/hdiutil") + .args(arguments) + .output() + .unwrap(); + assert!( + output.status.success(), + "hdiutil {arguments:?}: {}", + String::from_utf8_lossy(&output.stderr) + ); + }; + let image = directory.join("volume.dmg"); + let mount_point = directory.join("mounted"); + std::fs::create_dir(&mount_point).unwrap(); + hdiutil(&[ + "create".as_ref(), + "-quiet".as_ref(), + "-size".as_ref(), + size.as_ref(), + "-fs".as_ref(), + "HFS+".as_ref(), + "-volname".as_ref(), + "lpm-inspect-test".as_ref(), + image.as_os_str(), + ]); + hdiutil(&[ + "attach".as_ref(), + "-quiet".as_ref(), + "-nobrowse".as_ref(), + "-noautoopen".as_ref(), + "-owners".as_ref(), + if owners { "on" } else { "off" }.as_ref(), + "-mountpoint".as_ref(), + mount_point.as_os_str(), + image.as_os_str(), + ]); + AttachedImage(mount_point) + } + + #[cfg(target_os = "macos")] + #[test] + fn a_project_on_a_volume_mounted_without_owners_keeps_captures_and_says_they_re_open_to_others() + { + let directory = tempfile::tempdir().unwrap(); + let attached = attach_image(directory.path(), "8m", false); + let project = attached.0.join("project"); + std::fs::create_dir(&project).unwrap(); + + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let (created, reopened, kept) = runtime.block_on(async { + let db = InspectorDb::open(&project).unwrap(); + db.insert_durable_request(Arc::new(make_webhook("kept", 200)), None) + .await + .unwrap(); + let created = (db.open_to_others(), db.ownership_ignored()); + drop(db); + let db = InspectorDb::open(&project).unwrap(); + let kept = db.get_webhook("kept").await.unwrap(); + (created, (db.open_to_others(), db.ownership_ignored()), kept) + }); + drop(runtime); + + assert_eq!(created, (true, true)); + assert_eq!(reopened, (true, true)); + assert!(kept.is_some()); + assert!(!project.join(".lpm/inspector.db-copy").exists()); + } + + #[cfg(target_os = "macos")] + #[test] + fn a_disk_that_fills_while_the_database_is_checked_says_how_much_room_the_copy_needs() { + let directory = tempfile::tempdir().unwrap(); + // Room for the retry, which the WAL folded in has grown. + let attached = attach_image(directory.path(), "16m", true); + let project = attached.0.join("project"); + std::fs::create_dir(&project).unwrap(); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + runtime.block_on(async { + let db = InspectorDb::open(&project).unwrap(); + db.insert_durable_request(Arc::new(make_webhook("kept", 200)), None) + .await + .unwrap(); + }); + drop(runtime); + let db_path = project.join(".lpm/inspector.db"); + // A WAL that leaving WAL has to fold into the database, which the + // disk then has no room for. + let writer = Connection::open(&db_path).unwrap(); + writer + .set_db_config( + rusqlite::config::DbConfig::SQLITE_DBCONFIG_NO_CKPT_ON_CLOSE, + true, + ) + .unwrap(); + writer.pragma_update(None, "wal_autocheckpoint", 0).unwrap(); + writer + .execute( + "INSERT INTO requests (id, timestamp, method, path, request_body) \ + VALUES ('bulk', '2026-04-06T12:00:00Z', 'POST', '/', zeroblob(600000))", + [], + ) + .unwrap(); + drop(writer); + set_mode(&db_path, 0o644); + let needed = room_to_copy(&db_path).unwrap(); + let filler = attached.0.join("filler"); + let free = lpm_common::filesystem_available_bytes(&attached.0).unwrap(); + std::fs::write( + &filler, + vec![0; usize::try_from(free - 200 * 1024).unwrap()], + ) + .unwrap(); + + AVAILABLE_BYTES.set(Some(u64::MAX)); + let full = InspectorDb::open(&project).map(drop); + AVAILABLE_BYTES.set(None); + + let error = full.unwrap_err(); + assert_eq!(error.kind(), std::io::ErrorKind::StorageFull, "{error}"); + // The room taken before the WAL was folded in, which grew the database. + let named = format!("needs about {} free", lpm_common::format_bytes(needed)); + assert!(error.to_string().contains(&named), "{error}"); + assert!( + error + .to_string() + .ends_with(&format!("free that much, or {ADVICE}")), + "{error}" + ); + assert_eq!(mode(&db_path), 0o644); + assert!(!capture_file(&db_path, "-copy").exists()); + + std::fs::remove_file(&filler).unwrap(); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let kept = runtime.block_on(async { + let db = InspectorDb::open(&project).unwrap(); + db.get_webhook("kept").await.unwrap() + }); + assert!(kept.is_some()); + assert_eq!(mode(&db_path), 0o600); + } + + #[cfg(target_os = "macos")] + #[test] + fn a_disk_too_full_for_the_check_s_shared_memory_says_how_much_room_the_copy_needs() { + use std::io::Write as _; + + let directory = tempfile::tempdir().unwrap(); + // Room for the retry, which the WAL folded in has grown. + let attached = attach_image(directory.path(), "16m", true); + let project = attached.0.join("project"); + std::fs::create_dir(&project).unwrap(); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + runtime.block_on(async { + let db = InspectorDb::open(&project).unwrap(); + db.insert_durable_request(Arc::new(make_webhook("kept", 200)), None) + .await + .unwrap(); + }); + drop(runtime); + let db_path = project.join(".lpm/inspector.db"); + set_mode(&db_path, 0o644); + // Room for the copy's empty file, but not for SQLite's first 32 KiB + // of shared memory, which the check's first read sizes. + let mut filler = std::fs::File::create(attached.0.join("filler")).unwrap(); + while filler.write_all(&[0; 4096]).is_ok() {} + let filled = filler.metadata().unwrap().len(); + filler.set_len(filled - 8 * 1024).unwrap(); + drop(filler); + + AVAILABLE_BYTES.set(Some(u64::MAX)); + let full = InspectorDb::open(&project).map(drop); + AVAILABLE_BYTES.set(None); + + let error = full.unwrap_err(); + assert_eq!(error.kind(), std::io::ErrorKind::StorageFull, "{error}"); + assert!(error.to_string().contains("free on its disk"), "{error}"); + assert_eq!(mode(&db_path), 0o644); + assert!(!capture_file(&db_path, "-copy").exists()); + // What the check's connection left holds no captures. + let wal = capture_file(&db_path, "-wal"); + assert!(!wal.exists() || std::fs::metadata(&wal).unwrap().len() == 0); + + std::fs::remove_file(attached.0.join("filler")).unwrap(); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let kept = runtime.block_on(async { + let db = InspectorDb::open(&project).unwrap(); + db.get_webhook("kept").await.unwrap() + }); + assert!(kept.is_some()); + assert_eq!(mode(&db_path), 0o600); + } + + #[cfg(unix)] + #[test] + fn a_connection_that_attaches_during_the_check_keeps_its_wal_files() { + let project = project_with_a_capture(); + let db_path = project.path().join(".lpm/inspector.db"); + set_mode(&db_path, 0o644); + // As an earlier release, which takes no opening lock, or another + // tool can, once the room was taken and before the check opens it. + let other = std::rc::Rc::new(std::cell::RefCell::new(None)); + let held = std::rc::Rc::clone(&other); + let path = db_path.clone(); + BEFORE_CHECK.with_borrow_mut(|hook| { + *hook = Some(Box::new(move || { + let connection = Connection::open(&path).unwrap(); + connection + .query_row("SELECT count(*) FROM requests", [], |row| { + row.get::<_, i64>(0) + }) + .unwrap(); + *held.borrow_mut() = Some(connection); + })); + }); + let runtime = tokio::runtime::Runtime::new().unwrap(); + + let error = runtime + .block_on(async { InspectorDb::open(project.path()).map(drop) }) + .unwrap_err(); + + assert_eq!(error.kind(), std::io::ErrorKind::ResourceBusy, "{error}"); + for suffix in ["-wal", "-shm"] { + assert!(capture_file(&db_path, suffix).exists(), "{suffix} removed"); + } + let connection = other.borrow_mut().take().unwrap(); + connection + .execute( + "INSERT INTO requests (id, timestamp, method, path) \ + VALUES ('later', '2026-04-06T12:00:00Z', 'POST', '/')", + [], + ) + .unwrap(); + let reader = Connection::open(&db_path).unwrap(); + let later: i64 = reader + .query_row( + "SELECT count(*) FROM requests WHERE id = 'later'", + [], + |row| row.get(0), + ) + .unwrap(); + drop((connection, reader)); + assert_eq!(later, 1); + let integrity: String = Connection::open(&db_path) + .unwrap() + .query_row("PRAGMA integrity_check", [], |row| row.get(0)) + .unwrap(); + assert_eq!(integrity, "ok"); + } + + /// Commits a capture `id` as a connection of an earlier release, which + /// takes no opening lock, would, giving up at once on a lock. + fn commit_unlocked(connection: &Connection, id: &str) -> rusqlite::Result<()> { + connection.busy_timeout(std::time::Duration::ZERO)?; + let _ = connection.query_row("PRAGMA journal_mode=WAL", [], |row| row.get::<_, String>(0)); + connection + .execute( + "INSERT INTO requests (id, timestamp, method, path) \ + VALUES (?1, '2026-04-06T12:00:00Z', 'POST', '/')", + [id], + ) + .map(drop) + } + + #[cfg(unix)] + fn capture_ids(db_path: &Path) -> Vec { + let reader = Connection::open(db_path).unwrap(); + let mut statement = reader + .prepare("SELECT id FROM requests ORDER BY id") + .unwrap(); + statement + .query_map([], |row| row.get(0)) + .unwrap() + .map(Result::unwrap) + .collect() + } + + #[test] + fn a_user_s_own_captures_leave_no_wal_files_once_closed() { + let project = tempfile::tempdir().unwrap(); + let runtime = tokio::runtime::Runtime::new().unwrap(); + runtime.block_on(async { + let db = InspectorDb::open(project.path()).unwrap(); + assert!(!keeps_wal_files(&*db.write_conn.lock().await)); + assert!(!keeps_wal_files(&*db.read_conn.lock().await)); + assert!(!keeps_wal_files(&db.control_conn.lock().unwrap())); + }); + drop(runtime); + + let db_path = project.path().join(".lpm/inspector.db"); + assert!(db_path.exists()); + assert!(!capture_file(&db_path, "-wal").exists()); + } + + #[cfg(unix)] + #[test] + fn a_capture_committed_while_the_database_is_replaced_is_refused_not_lost() { + let project = project_with_a_capture(); + let db_path = project.path().join(".lpm/inspector.db"); + set_mode(&db_path, 0o644); + let during = std::rc::Rc::new(std::cell::Cell::new(None)); + let committed = std::rc::Rc::clone(&during); + let other = std::rc::Rc::new(std::cell::RefCell::new(None)); + let held = std::rc::Rc::clone(&other); + let path = db_path.clone(); + AFTER_COPY.set(Some(Box::new(move || { + let connection = Connection::open(&path).unwrap(); + committed.set(Some(commit_unlocked(&connection, "during").is_ok())); + *held.borrow_mut() = Some(connection); + }))); + let runtime = tokio::runtime::Runtime::new().unwrap(); + + runtime + .block_on(async { InspectorDb::open(project.path()).map(drop) }) + .unwrap(); + drop(runtime); + // The connection opened while the copy took the database's place + // still has the database it replaced. + let late = commit_unlocked(&other.borrow_mut().take().unwrap(), "late"); + + let ids = capture_ids(&db_path); + let during = during.get().unwrap(); + assert!(!during || ids.contains(&"during".to_owned()), "{ids:?}"); + assert!(late.is_err() || ids.contains(&"late".to_owned()), "{ids:?}"); + assert!(ids.contains(&"kept".to_owned()), "{ids:?}"); + assert_eq!(mode(&db_path), 0o600); + } + + #[cfg(unix)] + #[test] + fn a_project_others_can_change_the_path_to_keeps_no_captures() { + let directory = tempfile::tempdir().unwrap(); + let shared = directory.path().join("shared"); + let project = shared.join("project"); + std::fs::create_dir_all(&project).unwrap(); + set_mode(&shared, 0o777); + let runtime = tokio::runtime::Runtime::new().unwrap(); + + for opened in [ + runtime.block_on(async { InspectorDb::open(&project).map(drop) }), + runtime.block_on(async { InspectorDb::open_for_review(&project).map(drop) }), + ] { + let error = opened.unwrap_err(); + assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied); + let message = error.to_string(); + assert!( + message.contains(&shared.canonicalize().unwrap().display().to_string()) + && message.contains("anyone may write to it"), + "{message}" + ); + } + assert!(!project.join(".lpm").exists()); + + set_mode(&shared, 0o1777); + runtime + .block_on(async { InspectorDb::open(&project).map(drop) }) + .unwrap(); + set_mode(&shared, 0o755); + set_mode(&project, 0o777); + let error = runtime + .block_on(async { InspectorDb::open(&project).map(drop) }) + .unwrap_err(); + assert!( + error.to_string().contains("anyone may write to it"), + "{error}" + ); + } + + #[cfg(unix)] + #[test] + fn a_filesystem_without_modes_lets_a_capture_open_while_a_review_has_the_database_open() { + let project = project_with_a_capture(); + set_mode(&project.path().join(".lpm/inspector.db"), 0o644); + MODELESS_FILESYSTEM.set(true); + + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let opened = runtime.block_on(async { + let review = InspectorDb::open_for_review(project.path()).unwrap(); + let opened = InspectorDb::open(project.path()).map(|db| db.open_to_others()); + drop(review); + opened + }); + MODELESS_FILESYSTEM.set(false); + + assert!(matches!(opened, Ok(true)), "got {opened:?}"); + } + + #[cfg(unix)] + #[test] + fn a_capture_owner_owns_only_its_own_files_even_when_root_opens_them() { + let directory = tempfile::tempdir().unwrap(); + let mine = std::fs::symlink_metadata(directory.path()).unwrap(); + let roots = std::fs::symlink_metadata("/").unwrap(); + for by_root in [false, true] { + let me = CaptureOwner { + uid: lpm_common::current_uid(), + by_root, + }; + let another = CaptureOwner { + uid: lpm_common::current_uid() + 1, + by_root, + }; + + assert!(me.owns(&mine)); + assert!(!another.owns(&mine)); + assert_eq!(me.owns(&roots), lpm_common::current_uid() == 0); + } + } + + /// A project, and a directory beside it, as root makes them: the project + /// given to [`PROJECT_OWNER`], the other root's own. + #[cfg(all(target_os = "linux", target_pointer_width = "64"))] + fn another_user_s_project_beside_root_s_directory() -> (tempfile::TempDir, PathBuf, PathBuf) { + let root = tempfile::tempdir().unwrap(); + set_mode(root.path(), 0o755); + let project = root.path().join("project"); + let elsewhere = root.path().join("elsewhere"); + std::fs::create_dir(&project).unwrap(); + std::fs::create_dir(&elsewhere).unwrap(); + std::os::unix::fs::chown(&project, Some(PROJECT_OWNER), Some(PROJECT_GROUP)).unwrap(); + (root, project, elsewhere) + } + + /// A user other than root, whose projects root's tests open, and the + /// group of those projects: the user database knows neither, as a + /// container's doesn't know the host's users. + #[cfg(all(target_os = "linux", target_pointer_width = "64"))] + const PROJECT_OWNER: u32 = 65_000; + #[cfg(all(target_os = "linux", target_pointer_width = "64"))] + const PROJECT_GROUP: u32 = 65_001; + + /// Whether SQLite keeps `connection`'s WAL files when it closes. + fn keeps_wal_files(connection: &Connection) -> bool { + let mut persist: std::ffi::c_int = -1; + // SAFETY: as in `keep_wal_files`; -1 only asks the setting. + let code = unsafe { + rusqlite::ffi::sqlite3_file_control( + connection.handle(), + c"main".as_ptr(), + rusqlite::ffi::SQLITE_FCNTL_PERSIST_WAL, + (&raw mut persist).cast(), + ) + }; + assert_eq!(code, rusqlite::ffi::SQLITE_OK); + persist == 1 + } + + /// The owner and group of each of `project`'s capture files. + #[cfg(all(target_os = "linux", target_pointer_width = "64"))] + fn capture_owners(project: &Path) -> Vec<(String, (u32, u32))> { + use std::os::unix::fs::MetadataExt; + + let lpm_dir = project.join(".lpm"); + [ + "", + "inspector.db", + "inspector.db-wal", + "inspector.db-shm", + "inspector.db-lock", + ".gitignore", + ] + .into_iter() + .map(|name| { + let metadata = std::fs::metadata(lpm_dir.join(name)).unwrap(); + (name.to_owned(), (metadata.uid(), metadata.gid())) + }) + .collect() + } + + /// Runs only as root, as in the root container the gate runs Linux + /// tests in once more; CI's runner isn't root. + #[cfg(all(target_os = "linux", target_pointer_width = "64"))] + #[test] + fn as_root_another_user_s_captures_are_made_and_kept_as_that_user_s() { + if lpm_common::current_uid() != 0 { + return; + } + let (_root, project, _) = another_user_s_project_beside_root_s_directory(); + + let runtime = tokio::runtime::Runtime::new().unwrap(); + runtime.block_on(async { + let db = InspectorDb::open(&project).unwrap(); + assert!(keeps_wal_files(&*db.write_conn.lock().await)); + assert!(keeps_wal_files(&*db.read_conn.lock().await)); + assert!(keeps_wal_files(&db.control_conn.lock().unwrap())); + drop(db); + drop(InspectorDb::open_for_review(&project).unwrap()); + }); + // The connections close as the flush task ends. + drop(runtime); + + let lpm_dir = project.join(".lpm"); + for (name, owners) in capture_owners(&project) { + assert_eq!(owners, (PROJECT_OWNER, PROJECT_GROUP), "{name}"); + } + assert_eq!( + std::fs::metadata(lpm_dir.join("inspector.db-wal")) + .unwrap() + .len(), + 0 + ); + assert_eq!(lpm_common::thread_identity::filesystem_uid(), 0); + } + + #[cfg(all(target_os = "linux", target_pointer_width = "64"))] + #[test] + fn as_root_another_user_moving_lpm_while_it_opens_can_t_have_root_write_elsewhere() { + if lpm_common::current_uid() != 0 { + return; + } + let (_root, project, elsewhere) = another_user_s_project_beside_root_s_directory(); + for name in ["inspector.db-copy", "inspector.db-wal"] { + std::fs::write(elsewhere.join(name), b"root's").unwrap(); + } + let lpm_dir = project.join(".lpm"); + std::fs::create_dir(&lpm_dir).unwrap(); + std::os::unix::fs::chown(&lpm_dir, Some(PROJECT_OWNER), Some(PROJECT_OWNER)).unwrap(); + let moved = project.join("moved"); + let link = elsewhere.clone(); + AFTER_LPM_DIR.set(Some(Box::new(move || { + std::fs::rename(&lpm_dir, &moved).unwrap(); + std::os::unix::fs::symlink(&link, &lpm_dir).unwrap(); + }))); + + let runtime = tokio::runtime::Runtime::new().unwrap(); + let opened = runtime.block_on(async { InspectorDb::open(&project).map(drop) }); + + assert!(opened.is_err()); + let mut names: Vec<_> = std::fs::read_dir(&elsewhere) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect(); + names.sort(); + assert_eq!(names, ["inspector.db-copy", "inspector.db-wal"]); + for name in names { + assert_eq!(std::fs::read(elsewhere.join(name)).unwrap(), b"root's"); + } + assert_eq!(lpm_common::thread_identity::filesystem_uid(), 0); + } + + #[cfg(all(target_os = "linux", target_pointer_width = "64"))] + #[test] + fn as_root_a_project_of_root_s_group_has_its_captures_in_no_group() { + if lpm_common::current_uid() != 0 { + return; + } + let (_root, project, _) = another_user_s_project_beside_root_s_directory(); + std::os::unix::fs::chown(&project, None, Some(0)).unwrap(); + + let runtime = tokio::runtime::Runtime::new().unwrap(); + runtime.block_on(async { drop(InspectorDb::open(&project).unwrap()) }); + drop(runtime); + + for (name, owners) in capture_owners(&project) { + assert_eq!(owners, (PROJECT_OWNER, 65_534), "{name}"); + } + } + + #[cfg(all(target_os = "linux", target_pointer_width = "64"))] + #[test] + fn as_root_the_capture_files_root_made_in_its_own_lpm_are_given_to_the_project_s_owner() { + if lpm_common::current_uid() != 0 { + return; + } + let (_root, project, _) = another_user_s_project_beside_root_s_directory(); + // As root makes them in a project of its own, before the project + // is given to its user. + std::os::unix::fs::chown(&project, Some(0), Some(0)).unwrap(); + let runtime = tokio::runtime::Runtime::new().unwrap(); + runtime.block_on(async { + let db = InspectorDb::open(&project).unwrap(); + db.insert_durable_request(Arc::new(make_webhook("kept", 200)), None) + .await + .unwrap(); + }); + drop(runtime); + std::os::unix::fs::chown(&project, Some(PROJECT_OWNER), Some(PROJECT_GROUP)).unwrap(); + + let runtime = tokio::runtime::Runtime::new().unwrap(); + let kept = runtime.block_on(async { + InspectorDb::open(&project) + .unwrap() + .get_webhook("kept") + .await + .unwrap() + }); + drop(runtime); + + assert!(kept.is_some()); + for (name, owners) in capture_owners(&project) { + assert_eq!(owners, (PROJECT_OWNER, PROJECT_GROUP), "{name}"); + } + } + + #[cfg(all(target_os = "linux", target_pointer_width = "64"))] + #[test] + fn as_root_a_capture_file_of_root_s_in_the_owner_s_lpm_is_refused_by_name() { + use std::os::unix::fs::MetadataExt; + + if lpm_common::current_uid() != 0 { + return; + } + let (_root, project, _) = another_user_s_project_beside_root_s_directory(); + let lpm_dir = project.join(".lpm"); + std::fs::create_dir(&lpm_dir).unwrap(); + set_mode(&lpm_dir, 0o700); + std::fs::write(lpm_dir.join("inspector.db"), b"").unwrap(); + set_mode(&lpm_dir.join("inspector.db"), 0o600); + std::os::unix::fs::chown(&lpm_dir, Some(PROJECT_OWNER), Some(PROJECT_GROUP)).unwrap(); + + let runtime = tokio::runtime::Runtime::new().unwrap(); + let error = runtime + .block_on(async { InspectorDb::open(&project).map(drop) }) + .unwrap_err(); + + assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied); + let message = error.to_string(); + assert!( + message.contains(&format!( + "{} belongs to root", + lpm_dir.join("inspector.db").display() + )) && message.contains("chown 65000"), + "{message}" + ); + assert_eq!( + std::fs::metadata(lpm_dir.join("inspector.db")) + .unwrap() + .uid(), + 0 + ); + assert_eq!(lpm_common::thread_identity::filesystem_uid(), 0); + } + + #[cfg(all(target_os = "linux", target_pointer_width = "64"))] + #[test] + fn as_root_its_own_project_others_may_write_to_keeps_no_captures() { + if lpm_common::current_uid() != 0 { + return; + } + let (_root, project, _) = another_user_s_project_beside_root_s_directory(); + std::os::unix::fs::chown(&project, Some(0), Some(0)).unwrap(); + set_mode(&project, 0o777); + + let runtime = tokio::runtime::Runtime::new().unwrap(); + let error = runtime + .block_on(async { InspectorDb::open(&project).map(drop) }) + .unwrap_err(); + + assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied); + assert!( + error.to_string().contains("anyone may write to it"), + "{error}" + ); + assert!(!project.join(".lpm").exists()); + } + + #[cfg(target_os = "macos")] + fn add_acl_entry(path: &Path, entry: &str) { + let status = std::process::Command::new("/bin/chmod") + .args(["+a", entry]) + .arg(path) + .status() + .unwrap(); + assert!(status.success()); + } + + #[cfg(target_os = "macos")] + fn has_acl(path: &Path) -> bool { + lpm_common::macos_acl::has_extended_acl_at(path).unwrap() + } + + #[cfg(target_os = "macos")] + #[test] + fn a_capture_file_with_an_extended_acl_is_open_to_others() { + use std::os::unix::fs::MetadataExt; + + let project = project_with_a_capture(); + let db_path = project.path().join(".lpm/inspector.db"); + add_acl_entry(&db_path, "group:everyone allow read"); + let inode = std::fs::metadata(&db_path).unwrap().ino(); + + let reviewing = tokio::runtime::Runtime::new().unwrap(); + let reviewed = reviewing.block_on(async { + InspectorDb::open_for_review(project.path()) + .unwrap() + .open_to_others() + }); + drop(reviewing); + assert!(reviewed); + assert!(has_acl(&db_path)); + + let (open_to_others, kept) = tokio::runtime::Runtime::new().unwrap().block_on(async { + let db = InspectorDb::open(project.path()).unwrap(); + (db.open_to_others(), db.get_webhook("kept").await.unwrap()) + }); + + assert!(!open_to_others); + assert!(kept.is_some()); + assert!(!has_acl(&db_path)); + assert_ne!(std::fs::metadata(&db_path).unwrap().ino(), inode); + } + + #[cfg(target_os = "macos")] + #[tokio::test] + async fn an_extended_acl_lpm_passes_on_reaches_no_capture_file() { + const INHERITED: &str = "group:everyone allow read,write,file_inherit,directory_inherit"; + + let reviewed = tempfile::tempdir().unwrap(); + let reviewed_lpm = reviewed.path().join(".lpm"); + std::fs::create_dir(&reviewed_lpm).unwrap(); + add_acl_entry(&reviewed_lpm, INHERITED); + let db = InspectorDb::open_for_review(reviewed.path()).unwrap(); + db.rename_session("unknown", "renamed").await.unwrap(); + assert!(!db.open_to_others()); + assert!(has_acl(&reviewed_lpm)); + for name in ["inspector.db", "inspector.db-wal", "inspector.db-shm"] { + assert!(!has_acl(&reviewed_lpm.join(name)), "{name}"); + } + + let captured = tempfile::tempdir().unwrap(); + let captured_lpm = captured.path().join(".lpm"); + std::fs::create_dir(&captured_lpm).unwrap(); + add_acl_entry(&captured_lpm, INHERITED); + let db = InspectorDb::open(captured.path()).unwrap(); + db.insert_durable_request(Arc::new(make_webhook("kept", 200)), None) + .await + .unwrap(); + assert!(!db.open_to_others()); + assert!(!has_acl(&captured_lpm)); + for name in ["inspector.db", "inspector.db-wal", "inspector.db-shm"] { + assert!(!has_acl(&captured_lpm.join(name)), "{name}"); + } + } + + #[tokio::test] + async fn a_capture_waits_out_another_writer_rather_than_failing() { + let project = tempfile::tempdir().unwrap(); + let db = InspectorDb::open(project.path()).unwrap(); + let db_path = project.path().join(".lpm/inspector.db"); + let (began, holding) = std::sync::mpsc::channel(); + let holder = std::thread::spawn(move || { + let connection = Connection::open(&db_path).unwrap(); + connection + .execute_batch("BEGIN IMMEDIATE; INSERT INTO sessions (id) VALUES ('held');") + .unwrap(); + began.send(()).unwrap(); + std::thread::sleep(std::time::Duration::from_millis(300)); + connection.execute_batch("COMMIT").unwrap(); + }); + holding.recv().unwrap(); + + let inserted = db + .insert_durable_request(Arc::new(make_webhook("kept", 200)), None) + .await; + holder.join().unwrap(); + + assert_eq!(inserted, Ok(())); + assert!(db.get_webhook("kept").await.unwrap().is_some()); + } + + #[tokio::test] + async fn a_batch_of_captures_waits_out_another_writer_rather_than_failing() { + let project = tempfile::tempdir().unwrap(); + let db = InspectorDb::open(project.path()).unwrap(); + let db_path = project.path().join(".lpm/inspector.db"); + let (began, holding) = std::sync::mpsc::channel(); + let holder = std::thread::spawn(move || { + let connection = Connection::open(&db_path).unwrap(); + connection + .execute_batch("BEGIN IMMEDIATE; INSERT INTO sessions (id) VALUES ('held');") + .unwrap(); + began.send(()).unwrap(); + std::thread::sleep(std::time::Duration::from_millis(300)); + connection.execute_batch("COMMIT").unwrap(); + }); + holding.recv().unwrap(); + + db.insert_request(make_webhook("batched", 200), None); + let flushed = db.flush_pending_writes().await; + holder.join().unwrap(); + + assert_eq!(flushed, Ok(())); + assert!(db.get_webhook("batched").await.unwrap().is_some()); + } + + #[tokio::test] + async fn opening_accepts_a_database_whose_planner_statistics_were_gathered() { + for open in [InspectorDb::open_for_review, InspectorDb::open] { + let project = project_with_a_database(&format!( + "{CAPTURE_SCHEMA} ALTER TABLE requests ADD COLUMN response_body_incomplete \ + INTEGER NOT NULL DEFAULT 0; ANALYZE;" + )); + + open(project.path()).map(drop).unwrap(); + } + } + + /// SQL renaming the schema object `planted` to `name`, which SQLite + /// reserves for its own and refuses to create, through the writable + /// schema a crafted file could have been written with. + fn named_like_sqlite(name: &str) -> String { + format!( + "PRAGMA writable_schema = ON; \ + UPDATE sqlite_master SET name = '{name}', sql = replace(sql, 'planted', '{name}') \ + WHERE name = 'planted'; \ + PRAGMA writable_schema = OFF;" + ) + } + + /// A project whose capture database others could open, built by + /// `build` on a connection that may write SQLite's own tables. + fn project_with_a_database(build: &str) -> tempfile::TempDir { + let project = tempfile::tempdir().unwrap(); + std::fs::create_dir(project.path().join(".lpm")).unwrap(); + let db_path = project.path().join(".lpm/inspector.db"); + Connection::open(&db_path) + .unwrap() + .execute_batch(build) + .unwrap(); + #[cfg(unix)] + set_mode(&db_path, 0o644); + project + } + + #[tokio::test] + async fn opening_refuses_a_database_with_schema_lpm_never_creates() { + let lpm_schema = format!( + "{CAPTURE_SCHEMA} ALTER TABLE requests ADD COLUMN response_body_incomplete \ + INTEGER NOT NULL DEFAULT 0;" + ); + let planted = [ + ( + "trigger copy_requests", + format!( + "{lpm_schema} CREATE TRIGGER copy_requests AFTER INSERT ON requests \ + BEGIN SELECT 1; END;" + ), + ), + ( + "view recent", + format!("{lpm_schema} CREATE VIEW recent AS SELECT * FROM requests;"), + ), + ( + "table requests", + lpm_schema.replace( + "tags TEXT,", + "tags TEXT CHECK (length(hex(zeroblob(1000))) > 0),", + ), + ), + ( + "table requests", + lpm_schema.replace( + "tags TEXT,", + "tags TEXT, slow TEXT GENERATED ALWAYS AS (hex(zeroblob(1000))) VIRTUAL,", + ), + ), + ( + "table sessions", + lpm_schema.replace("name TEXT,", "name TEXT DEFAULT (hex(zeroblob(1000))),"), + ), + ( + "index slow_requests", + format!( + "{lpm_schema} CREATE INDEX slow_requests ON requests(length(request_body));" + ), + ), + ( + "index slow_index", + format!( + "{lpm_schema} CREATE INDEX slow_index ON requests_fts_data(length(block));" + ), + ), + ( + "table extra", + format!("{lpm_schema} CREATE TABLE extra (x);"), + ), + ( + "trigger sqlite_stat_planted", + format!( + "{lpm_schema} CREATE TRIGGER planted AFTER INSERT ON requests \ + BEGIN SELECT 1; END; {}", + named_like_sqlite("sqlite_stat_planted") + ), + ), + ( + "index sqlite_stat_planted", + format!( + "{lpm_schema} CREATE INDEX planted ON requests(length(request_body)); {}", + named_like_sqlite("sqlite_stat_planted") + ), + ), + ( + "table sessions", + lpm_schema.replace("id TEXT PRIMARY KEY,", "id TEXT\u{a0}PRIMARY KEY,"), + ), + ( + "table sessions", + lpm_schema.replacen("datetime('now')", "datetime(' now')", 1), + ), + ]; + for (named, build) in planted { + for open in [InspectorDb::open, InspectorDb::open_for_review] { + let project = project_with_a_database(&build); + + let error = open(project.path()).map(drop).unwrap_err(); + + assert!( + error.to_string().contains("holds schema LPM never creates") + && error.to_string().contains(named) + && error.to_string().ends_with(ADVICE), + "{named}: {error}" + ); + #[cfg(unix)] + assert_eq!( + mode(&project.path().join(".lpm/inspector.db")), + 0o644, + "{named}" + ); + } + } + } + + #[tokio::test] + async fn opening_upgrades_a_database_an_earlier_release_created() { + let earlier = CAPTURE_SCHEMA + .replace( + "CREATE TABLE IF NOT EXISTS pending_sessions ( + session_id TEXT PRIMARY KEY, + FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE + );", + "", + ) + .replace("\n ", "\n\t"); + assert_ne!(earlier, CAPTURE_SCHEMA); + // With the query planner's statistics, which a tool may have added. + let project = project_with_a_database(&format!( + "{earlier} INSERT INTO requests (id, timestamp, method, path) \ + VALUES ('kept', '2026-04-06T12:00:00Z', 'POST', '/'); ANALYZE;" + )); + + let db = InspectorDb::open(project.path()).unwrap(); + + assert_eq!(db.count().await.unwrap(), 1); + let connection = Connection::open(project.path().join(".lpm/inspector.db")).unwrap(); + let tables: i64 = connection + .query_row( + "SELECT count(*) FROM sqlite_master WHERE name = 'pending_sessions'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(tables, 1); + } + + #[test] + fn canonical_sql_drops_the_spaces_around_parentheses_and_commas() { + assert_eq!( + canonical_sql("CREATE TABLE t (\n\t a TEXT ,b\tINT )"), + "CREATE TABLE t(a TEXT,b INT)" + ); + assert_ne!(canonical_sql("summary TEXT"), canonical_sql("summ aryTEXT")); + assert_eq!( + canonical_sql("\n CREATE TABLE t (x)\n"), + "CREATE TABLE t(x)" + ); + } + + #[test] + fn canonical_sql_keeps_quoted_text_as_written() { + for (written, respaced) in [ + ("DEFAULT (datetime('now'))", "DEFAULT (datetime(' now'))"), + ("DEFAULT (datetime('now'))", "DEFAULT (datetime('now '))"), + ("CREATE TABLE \"a b\" (x)", "CREATE TABLE \"a b\" (x)"), + ("CREATE TABLE `a b` (x)", "CREATE TABLE `a\tb` (x)"), + ("CREATE TABLE [a b] (x)", "CREATE TABLE [a\nb] (x)"), + ("DEFAULT ('it''s a')", "DEFAULT ('it''s a')"), + ] { + assert_ne!( + canonical_sql(written), + canonical_sql(respaced), + "{respaced}" + ); + } + assert_eq!( + canonical_sql("DEFAULT ( 'it''s a' ) , b"), + "DEFAULT('it''s a'),b" + ); + assert_eq!( + canonical_sql("CHECK (x IN ('a ]', [b c] ))"), + "CHECK(x IN('a ]',[b c]))" + ); + assert_eq!( + canonical_sql("CREATE TABLE [a]] b] (x)"), + "CREATE TABLE [a]] b](x)" + ); + } + + #[test] + fn canonical_sql_keeps_apart_what_sqlite_reads_apart() { + for (written, respaced) in [ + // One string against two. + ("c 'a''b'", "c 'a' 'b'"), + // Subtraction of a negative against a comment. + ("DEFAULT (1 - -1)", "DEFAULT (1 --1\n)"), + ("x <= 1", "x < = 1"), + // A string against a blob literal. + ("x '00'", "x'00'"), + // A comment through the end against one ended by a newline. + ("DEFAULT (1 --1)", "DEFAULT (1 --1\n)"), + ("a /* x */ b", "a b"), + ] { + assert_ne!( + canonical_sql(written), + canonical_sql(respaced), + "{written:?} against {respaced:?}" + ); + } + assert_eq!( + canonical_sql("a /*/ b */ c -- d\n e"), + "a /*/ b */ c -- d\n e" + ); + assert_eq!( + canonical_sql("a /* ' */ b -- '\n c"), + "a /* ' */ b -- '\n c" + ); + assert_eq!( + canonical_sql("CREATE TABLE t (\n\t a TEXT ,b\tINT )"), + canonical_sql("CREATE TABLE t(a TEXT, b INT)") + ); + } + + #[cfg(unix)] + #[test] + fn copying_onto_a_full_disk_says_how_much_room_the_copy_needs() { + let directory = tempfile::tempdir().unwrap(); + let db_path = directory.path().join("inspector.db"); + let database = Connection::open(&db_path).unwrap(); + database + .execute_batch("CREATE TABLE kept (x); INSERT INTO kept VALUES (zeroblob(100000));") + .unwrap(); + let copy_path = capture_file(&db_path, "-copy"); + assert!(create_owner_only(©_path).unwrap()); + + // The room taken before the database was opened, which this + // database's room now would read as less. + let needed = 3 * 1024 * 1024; + assert!(room_to_copy(&db_path).unwrap() < needed); + + COPY_PAGE_LIMIT.set(Some(1)); + let copied = copy_database(&database, ©_path, &db_path, Some(needed)); + COPY_PAGE_LIMIT.set(None); + + let Err(OpenError::Files(error)) = copied else { + panic!("a full disk must stop the copy, got {copied:?}"); + }; + assert_eq!(error.kind(), std::io::ErrorKind::StorageFull); + assert!( + error + .to_string() + .contains("needs about 3.0 MB free on its disk"), + "{error}" + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn a_disk_without_room_for_the_replacement_says_so_before_the_database_is_opened() { + let project = project_with_a_database(&format!( + "{CAPTURE_SCHEMA} ALTER TABLE requests ADD COLUMN response_body_incomplete \ + INTEGER NOT NULL DEFAULT 0;" + )); + let db_path = project.path().join(".lpm/inspector.db"); + let needed = room_to_copy(&db_path).unwrap(); + + BEFORE_CHECK.set(Some(Box::new(|| {}))); + AVAILABLE_BYTES.set(Some(needed - 1)); + let short = InspectorDb::open(project.path()).map(drop); + AVAILABLE_BYTES.set(None); + let opened = BEFORE_CHECK.with_borrow_mut(Option::take).is_none(); + + let error = short.unwrap_err(); + assert_eq!(error.kind(), std::io::ErrorKind::StorageFull); + assert!( + error.to_string().contains(&format!( + "needs about {} free on its disk", + lpm_common::format_bytes(needed) + )), + "{error}" + ); + assert!( + !opened, + "the database was opened before its room was checked" + ); + assert_eq!(mode(&db_path), 0o644); + assert!(!capture_file(&db_path, "-copy").exists()); + + AVAILABLE_BYTES.set(Some(needed)); + let enough = InspectorDb::open(project.path()).map(drop); + AVAILABLE_BYTES.set(None); + + enough.unwrap(); + assert_eq!(mode(&db_path), 0o600); + } + + #[test] + fn the_room_a_replacement_needs_counts_the_wal_once_with_its_shared_memory() { + let directory = tempfile::tempdir().unwrap(); + let db_path = directory.path().join("inspector.db"); + let mut database = vec![0; 10_000]; + database[16..18].copy_from_slice(&4096u16.to_be_bytes()); + std::fs::write(&db_path, &database).unwrap(); + // Without a WAL, the shared memory holds its first regions alone. + assert_eq!(room_to_copy(&db_path), Some(10_000 + 64 * 1024)); + + // 130,308 frames of the WAL's 4 KiB pages: 32 regions of the WAL + // index, and one more. The WAL's header says what its frames hold. + let mut wal = std::fs::File::create(capture_file(&db_path, "-wal")).unwrap(); + std::io::Write::write_all(&mut wal, &[0x37, 0x7f, 0x06, 0x82, 0, 0x2d, 0xe2, 0x18]) + .unwrap(); + std::io::Write::write_all(&mut wal, &4096u32.to_be_bytes()).unwrap(); + wal.set_len(512 * 1024 * 1024).unwrap(); + assert_eq!( + room_to_copy(&db_path), + Some(10_000 + 512 * 1024 * 1024 + 33 * 32 * 1024) + ); + + // A WAL without a header counts frames of SQLite's smallest pages, + // whatever the database's own are. + wal.set_len(0).unwrap(); + wal.set_len(512 * 1024 * 1024).unwrap(); + assert_eq!(page_size(&db_path), 512); + assert_eq!( + room_to_copy(&db_path), + Some(10_000 + 512 * 1024 * 1024 + 246 * 32 * 1024) + ); + + std::fs::remove_file(&db_path).unwrap(); + assert_eq!(room_to_copy(&db_path), None); + } + + #[test] + fn the_room_check_counts_what_a_write_can_take_rather_than_what_is_reported_free() { + let directory = tempfile::tempdir().unwrap(); + let db_path = directory.path().join("inspector.db"); + std::fs::write(&db_path, b"").unwrap(); + let available = lpm_common::filesystem_available_bytes(&db_path).unwrap(); + let writable = lpm_common::filesystem_writable_bytes(&db_path).unwrap(); + if writable >= available { + return; + } + + // Halfway, so other writers moving the free space by less than half + // of what's held back don't change the answer. + let error = + ensure_room_to_copy(&db_path, Some(writable + (available - writable) / 2)).unwrap_err(); + + assert_eq!(error.kind(), std::io::ErrorKind::StorageFull, "{error}"); + } + + #[test] + fn the_room_for_shared_memory_follows_the_page_size_of_the_wal() { + let directory = tempfile::tempdir().unwrap(); + let db_path = directory.path().join("inspector.db"); + let writer = Connection::open(&db_path).unwrap(); + writer + .execute_batch( + "PRAGMA page_size = 512; PRAGMA journal_mode = WAL; \ + PRAGMA wal_autocheckpoint = 0; CREATE TABLE kept (x); BEGIN;", + ) + .unwrap(); + for _ in 0..12_000 { + writer + .execute("INSERT INTO kept VALUES (zeroblob(400))", []) + .unwrap(); + } + writer.execute_batch("COMMIT;").unwrap(); + let wal = std::fs::metadata(capture_file(&db_path, "-wal")) + .unwrap() + .len(); + let shared_memory = std::fs::metadata(capture_file(&db_path, "-shm")) + .unwrap() + .len(); + + assert_eq!(page_size(&db_path), 512); + // Frames past the first region, which 4 KiB pages would need eight + // times the WAL for. + assert!(shared_memory > 32 * 1024, "{shared_memory}"); + assert!(shared_memory_room(wal, 4096) < shared_memory); + assert!(shared_memory_room(wal, 512) >= shared_memory); + drop(writer); + assert_eq!(page_size(&db_path), 512); + } + + #[test] + fn the_page_size_comes_from_the_wal_alone() { + let directory = tempfile::tempdir().unwrap(); + let db_path = directory.path().join("inspector.db"); + let writer = Connection::open(&db_path).unwrap(); + writer + .execute_batch( + "PRAGMA page_size = 4096; PRAGMA journal_mode = WAL; \ + PRAGMA wal_autocheckpoint = 0; CREATE TABLE kept (x); \ + INSERT INTO kept VALUES (1);", + ) + .unwrap(); + + assert_eq!(page_size(&db_path), 4096); + writer + .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |_| Ok(())) + .unwrap(); + assert_eq!( + std::fs::metadata(capture_file(&db_path, "-wal")) + .unwrap() + .len(), + 0 + ); + // An empty WAL holds no frames; the database's 4 KiB pages aren't + // read. + assert_eq!(page_size(&db_path), 512); + } + + /// Set in the child process [`reading_the_page_size_keeps_this_process_s_locks_on_the_database`] + /// starts, to the database it tries to lock. + #[cfg(unix)] + const LOCKING_CHILD: &str = "LPM_INSPECT_TEST_LOCKING_CHILD"; + + #[cfg(unix)] + #[test] + fn reading_the_page_size_keeps_this_process_s_locks_on_the_database() { + if let Some(db_path) = std::env::var_os(LOCKING_CHILD) { + use std::os::fd::AsRawFd as _; + + // SQLite's locks are on the bytes from 1 GiB: its exclusive + // lock is a write lock on all 512 of them. + const PENDING_BYTE: libc::off_t = 0x4000_0000; + let file = std::fs::File::open(db_path).unwrap(); + // SAFETY: an all-zero `flock` is a valid value to fill in. + let mut lock: libc::flock = unsafe { std::mem::zeroed() }; + lock.l_type = libc::F_WRLCK as _; + lock.l_whence = libc::SEEK_SET as _; + lock.l_start = PENDING_BYTE; + lock.l_len = 512; + // SAFETY: the descriptor is open, and F_GETLK only fills `lock`. + let asked = unsafe { libc::fcntl(file.as_raw_fd(), libc::F_GETLK, &raw mut lock) }; + assert_eq!(asked, 0, "{}", std::io::Error::last_os_error()); + assert_ne!( + i64::from(lock.l_type), + i64::from(libc::F_UNLCK), + "nothing holds a lock on the database" + ); + return; + } + let directory = tempfile::tempdir().unwrap(); + let db_path = directory.path().join("inspector.db"); + let held = Connection::open(&db_path).unwrap(); + held.execute_batch( + "PRAGMA journal_mode = WAL; CREATE TABLE kept (x); INSERT INTO kept VALUES (1);", + ) + .unwrap(); + held.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |_| Ok(())) + .unwrap(); + held.query_row("SELECT count(*) FROM kept", [], |row| row.get::<_, i64>(0)) + .unwrap(); + + page_size(&db_path); + + let child = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "db::tests::reading_the_page_size_keeps_this_process_s_locks_on_the_database", + "--exact", + "--nocapture", + ]) + .env(LOCKING_CHILD, &db_path) + .output() + .unwrap(); + assert!( + child.status.success(), + "another process took the lock:\n{}{}", + String::from_utf8_lossy(&child.stdout), + String::from_utf8_lossy(&child.stderr) + ); + drop(held); + } + + #[cfg(unix)] + #[tokio::test] + async fn a_replacement_removes_files_left_beside_the_database_it_replaces() { + let project = project_with_a_database(&format!( + "{CAPTURE_SCHEMA} ALTER TABLE requests ADD COLUMN response_body_incomplete \ + INTEGER NOT NULL DEFAULT 0;" + )); + let db_path = project.path().join(".lpm/inspector.db"); + let left = [ + capture_file(&db_path, "-wal"), + capture_file(&db_path, "-shm"), + ]; + let journal = capture_file(&db_path, "-journal"); + let leaving = left.clone(); + let journal_left = journal.clone(); + AFTER_COPY.set(Some(Box::new(move || { + for path in leaving.iter().chain([&journal_left]) { + std::fs::write(path, b"left by another connection").unwrap(); + set_mode(path, 0o644); + } + }))); + + // SQLite removes a journal it can't read as it opens the database, + // and plays back one it can, so it's looked for before that. + let journal_at_rename = std::rc::Rc::new(std::cell::Cell::new(None)); + let looked = std::rc::Rc::clone(&journal_at_rename); + AFTER_RENAME.set(Some(Box::new(move || looked.set(Some(journal.exists()))))); + + let db = InspectorDb::open(project.path()); + let copied = AFTER_COPY.with_borrow_mut(Option::take).is_none(); + + let db = db.unwrap(); + assert!(copied); + assert_eq!(journal_at_rename.get(), Some(false)); + assert_eq!(mode(&db_path), 0o600); + for path in &left { + assert_eq!(mode(path), 0o600, "{}", path.display()); + assert_ne!( + std::fs::read(path).unwrap(), + b"left by another connection", + "{}", + path.display() + ); + } + drop(db); + } + + #[cfg(unix)] + #[tokio::test] + async fn a_disk_too_full_to_create_the_copy_says_how_much_room_it_needs() { + for (kind, room) in [ + (std::io::ErrorKind::StorageFull, true), + (std::io::ErrorKind::PermissionDenied, false), + ] { + let project = project_with_a_database(&format!( + "{CAPTURE_SCHEMA} ALTER TABLE requests ADD COLUMN response_body_incomplete \ + INTEGER NOT NULL DEFAULT 0;" + )); + let db_path = project.path().join(".lpm/inspector.db"); + + COPY_CREATE_ERROR.set(Some(kind)); + let opened = InspectorDb::open(project.path()).map(drop); + COPY_CREATE_ERROR.set(None); + + let error = opened.unwrap_err(); + assert_eq!(error.kind(), kind); + assert_eq!( + error.to_string().contains(&format!( + "needs about {} free on its disk", + lpm_common::format_bytes(room_to_copy(&db_path).unwrap()) + )), + room, + "{error}" + ); + assert_eq!(mode(&db_path), 0o644); + assert!(!capture_file(&db_path, "-copy").exists()); + } + } + + #[test] + fn copying_refuses_an_unfinished_copy_when_a_lock_outlasts_the_wait() { + let directory = tempfile::tempdir().unwrap(); + let db_path = directory.path().join("inspector.db"); + let database = Connection::open(&db_path).unwrap(); + database + .execute_batch("CREATE TABLE kept (x); INSERT INTO kept VALUES (1);") + .unwrap(); + database + .busy_timeout(std::time::Duration::from_millis(100)) + .unwrap(); + let holder = Connection::open(&db_path).unwrap(); + holder + .execute_batch("BEGIN EXCLUSIVE; INSERT INTO kept VALUES (2);") + .unwrap(); + let copy_path = capture_file(&db_path, "-copy"); + assert!(create_owner_only(©_path).unwrap()); + + let copied = copy_database(&database, ©_path, &db_path, room_to_copy(&db_path)); + holder.execute_batch("COMMIT").unwrap(); + + assert!(matches!( + copied, + Err(OpenError::Files(ref error)) if error.kind() == std::io::ErrorKind::ResourceBusy + )); + } + + #[test] + fn copying_waits_out_a_lock_another_connection_holds_on_the_database() { + let directory = tempfile::tempdir().unwrap(); + let db_path = directory.path().join("inspector.db"); + let database = Connection::open(&db_path).unwrap(); + database + .execute_batch("CREATE TABLE kept (x); INSERT INTO kept VALUES (1);") + .unwrap(); + let holder = Connection::open(&db_path).unwrap(); + holder + .execute_batch("BEGIN EXCLUSIVE; INSERT INTO kept VALUES (2);") + .unwrap(); + let released = std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(200)); + holder.execute_batch("COMMIT").unwrap(); + }); + + let copy_path = capture_file(&db_path, "-copy"); + assert!(create_owner_only(©_path).unwrap()); + copy_database(&database, ©_path, &db_path, room_to_copy(&db_path)).unwrap(); + released.join().unwrap(); + + let rows: i64 = Connection::open(capture_file(&db_path, "-copy")) + .unwrap() + .query_row("SELECT count(*) FROM kept", [], |row| row.get(0)) + .unwrap(); + assert_eq!(rows, 2); + } + + #[cfg(unix)] + #[test] + fn replacing_refuses_a_database_with_a_damaged_full_text_index() { + let project = project_with_a_capture(); + let db_path = project.path().join(".lpm/inspector.db"); + let damaging = Connection::open(&db_path).unwrap(); + damaging + .execute_batch("UPDATE requests_fts_data SET block = zeroblob(64) WHERE id > 10;") + .unwrap(); + drop(damaging); + set_mode(&db_path, 0o644); + + let runtime = tokio::runtime::Runtime::new().unwrap(); + let error = runtime + .block_on(async { InspectorDb::open(project.path()).map(drop) }) + .unwrap_err(); + + assert!(matches!(error, OpenError::Damaged { .. }), "{error}"); + assert!(error.to_string().ends_with(ADVICE), "{error}"); + assert_eq!(mode(&db_path), 0o644); + assert!(!project.path().join(".lpm/inspector.db-copy").exists()); + } + + #[cfg(unix)] + #[test] + fn opening_takes_a_schema_sqlite_finds_corrupt_for_damage() { + let runtime = tokio::runtime::Runtime::new().unwrap(); + for open in [InspectorDb::open, InspectorDb::open_for_review] { + let project = project_with_a_capture(); + let db_path = project.path().join(".lpm/inspector.db"); + let connection = Connection::open(&db_path).unwrap(); + connection + .execute_batch("PRAGMA journal_mode=DELETE;") + .unwrap(); + drop(connection); + // The b-tree header of page 1, which holds the schema, past the + // 100-byte database header. + let mut file = std::fs::OpenOptions::new() + .write(true) + .open(&db_path) + .unwrap(); + use std::io::{Seek as _, Write as _}; + file.seek(std::io::SeekFrom::Start(100)).unwrap(); + file.write_all(&[0xff; 8]).unwrap(); + drop(file); + set_mode(&db_path, 0o600); + + let error = runtime + .block_on(async { open(project.path()).map(drop) }) + .unwrap_err(); + + assert!( + matches!(&error, OpenError::Damaged { detail, .. } if detail.contains("malformed")), + "{error:?}" + ); + } + } + + #[cfg(unix)] + #[test] + fn replacing_refuses_a_database_with_damaged_pages() { + let project = project_with_a_capture(); + let db_path = project.path().join(".lpm/inspector.db"); + let connection = Connection::open(&db_path).unwrap(); + let (page_size, pages): (u64, u64) = connection + .query_row( + "SELECT * FROM pragma_page_size, pragma_page_count", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + connection + .execute_batch("PRAGMA wal_checkpoint(TRUNCATE);") + .unwrap(); + drop(connection); + assert!(pages > 3); + // The header of page 2, the `sessions` table's root. + let mut file = std::fs::OpenOptions::new() + .write(true) + .open(&db_path) + .unwrap(); + use std::io::{Seek as _, Write as _}; + file.seek(std::io::SeekFrom::Start(page_size)).unwrap(); + file.write_all(&[0xff; 8]).unwrap(); + drop(file); + set_mode(&db_path, 0o644); + + let runtime = tokio::runtime::Runtime::new().unwrap(); + let error = runtime + .block_on(async { InspectorDb::open(project.path()).map(drop) }) + .unwrap_err(); + + assert!(matches!(error, OpenError::Damaged { .. }), "{error}"); + } + + #[cfg(unix)] + #[test] + fn capture_connections_refuse_a_linked_database() { + let directory = tempfile::tempdir().unwrap(); + let target = directory.path().join("elsewhere.db"); + Connection::open(&target) + .unwrap() + .execute_batch("CREATE TABLE t (x);") + .unwrap(); + let link = directory.path().join("inspector.db"); + std::os::unix::fs::symlink(&target, &link).unwrap(); + + assert!(open_nofollow_connection(&link).is_err()); + } + + #[test] + fn capture_connections_trust_no_schema_code() { + let directory = tempfile::tempdir().unwrap(); + + let connection = + open_capture_connection(&directory.path().join("inspector.db"), "NORMAL").unwrap(); + + let trusted: i64 = connection + .query_row("PRAGMA trusted_schema", [], |row| row.get(0)) + .unwrap(); + assert_eq!(trusted, 0); + assert!( + connection + .db_config(rusqlite::config::DbConfig::SQLITE_DBCONFIG_DEFENSIVE) + .unwrap() + ); + for pragma in ["cell_size_check", "secure_delete"] { + let on: i64 = connection + .query_row(&format!("PRAGMA {pragma}"), [], |row| row.get(0)) + .unwrap(); + assert_eq!(on, 1, "{pragma}"); + } + assert_eq!( + connection + .limit(rusqlite::limits::Limit::SQLITE_LIMIT_LENGTH) + .unwrap(), + LONGEST_VALUE_BYTES + ); + assert!( + connection + .query_row( + "SELECT length(zeroblob(?1))", + [LONGEST_VALUE_BYTES + 1], + |row| { row.get::<_, i64>(0) } + ) + .is_err() + ); + } + + #[test] + fn the_largest_capture_fits_the_longest_value() { + let connection = Connection::open_in_memory().unwrap(); + harden_connection(&connection).unwrap(); + init_schema(&connection).unwrap(); + let body = vec![b'x'; 50 * 1024 * 1024]; + let mut webhook = make_webhook("largest", 200); + webhook.request_body.clone_from(&body); + webhook.response_body = body; + + insert_request_row(&connection, &webhook, None).unwrap(); + } + + #[test] + fn bundled_sqlite_has_the_full_text_fix() { + // 3.50.3 fixed CVE-2025-7709, a read past a damaged FTS5 index. + assert!( + rusqlite::version_number() >= 3_050_003, + "{}", + rusqlite::version() + ); + } + + #[test] + fn switching_to_wal_waits_out_a_connection_that_refuses_it_at_once() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("inspector.db"); + let holder = Connection::open(&path).unwrap(); + holder + .execute_batch("CREATE TABLE t (x); BEGIN; SELECT * FROM t;") + .unwrap(); + let switcher = Connection::open(&path).unwrap(); + switcher.busy_timeout(std::time::Duration::ZERO).unwrap(); + let released = std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(100)); + holder.execute_batch("COMMIT").unwrap(); + holder + }); + + use_wal(&switcher).unwrap(); + + let mode: String = switcher + .query_row("PRAGMA journal_mode", [], |row| row.get(0)) + .unwrap(); + assert_eq!(mode, "wal"); + drop(released.join().unwrap()); + } + + #[test] + fn switching_to_wal_gives_up_at_once_on_anything_but_a_busy_database() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("inspector.db"); + std::fs::write(&path, vec![b'x'; 4096]).unwrap(); + let connection = Connection::open(&path).unwrap(); + let started = std::time::Instant::now(); - // Get the new rowid and insert into FTS - let new_rowid: i64 = conn.query_row( - "SELECT rowid FROM requests WHERE id = ?1", - params![webhook.id], - |row| row.get(0), - )?; - conn.execute( - "INSERT INTO requests_fts (rowid, path, summary, request_body, response_body) - VALUES (?1, ?2, ?3, ?4, ?5)", - params![ - new_rowid, - webhook.path, - webhook.summary, - req_body_text, - res_body_text - ], - )?; + assert!(use_wal(&connection).is_err()); - Ok(()) -} + assert!(started.elapsed() < std::time::Duration::from_secs(1)); + } -/// Sanitize a user query for FTS5 to prevent syntax errors. -/// -/// FTS5 has special operators (AND, OR, NOT, NEAR, quotes, etc.). -/// We wrap each token in double quotes to treat them as literal strings. -fn sanitize_fts_query(query: &str) -> String { - query - .split_whitespace() - .map(|token| { - // Escape double quotes within tokens - let escaped = token.replace('"', "\"\""); - format!("\"{escaped}\"") - }) - .collect::>() - .join(" ") -} + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn opening_a_new_database_from_several_openers_at_once_succeeds() { + for _ in 0..10 { + let project = tempfile::tempdir().unwrap(); + let openers: Vec<_> = (0..8) + .map(|_| { + let path = project.path().to_path_buf(); + tokio::task::spawn_blocking(move || InspectorDb::open(&path).map(drop)) + }) + .collect(); + for opener in openers { + opener.await.unwrap().unwrap(); + } + } + } -// ── Query types ─────────────────────────────────────────────────────── + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn opening_waits_while_another_opener_holds_the_capture_lock() { + let project = tempfile::tempdir().unwrap(); + std::fs::create_dir(project.path().join(".lpm")).unwrap(); + let lock = hold_capture_lock(project.path()); + let path = project.path().to_path_buf(); + let opener = tokio::task::spawn_blocking(move || { + InspectorDb::open(&path).map(drop)?; + Ok::<_, OpenError>(std::time::Instant::now()) + }); -/// Status filter for search queries. -pub enum StatusQuery { - /// Exact status code (e.g., 404). - Exact(u16), - /// Status class: 2 = 2xx, 4 = 4xx, 5 = 5xx. - Class(u16), - /// All errors (>= 400). - Error, -} + tokio::time::sleep(std::time::Duration::from_millis(300)).await; + let released = std::time::Instant::now(); + drop(lock); + let opened = opener.await.unwrap().unwrap(); -/// Compact request row (no bodies) for list views. -#[derive(Debug)] -pub struct StoredRequest { - pub id: String, - pub session_id: Option, - pub timestamp: String, - pub method: String, - pub path: String, - pub status: u16, - pub duration_ms: u64, - pub provider: Option, - pub summary: String, - pub req_size: usize, - pub res_size: usize, - pub has_signature_issue: bool, - pub auto_acked: bool, -} + assert!(opened >= released, "opened before the lock was released"); + } -/// Full request detail including headers and bodies. -#[derive(Debug)] -pub struct StoredRequestDetail { - pub id: String, - pub session_id: Option, - pub timestamp: String, - pub method: String, - pub path: String, - pub status: u16, - pub duration_ms: u64, - pub provider: Option, - pub summary: String, - pub signature_diagnostic: Option, - pub request_headers: String, - pub request_body: Vec, - pub response_headers: String, - pub response_body: Vec, - pub tags: Option, - pub auto_acked: bool, - pub req_size: usize, - pub res_size: usize, - pub response_body_incomplete: bool, -} + #[test] + fn opening_gives_up_once_another_opener_holds_the_lock_too_long() { + let project = tempfile::tempdir().unwrap(); + std::fs::create_dir(project.path().join(".lpm")).unwrap(); + let lock = hold_capture_lock(project.path()); + let wait = std::time::Duration::from_secs(1); + let started = std::time::Instant::now(); + + let path = project.path().to_path_buf(); + let error = std::thread::spawn(move || { + OPEN_LOCK_WAIT.set(Some(wait)); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + runtime.block_on(async { InspectorDb::open(&path).map(drop) }) + }) + .join() + .unwrap() + .unwrap_err(); -fn stored_detail_from_row(row: &rusqlite::Row<'_>) -> Result { - Ok(StoredRequestDetail { - id: row.get(0)?, - session_id: row.get(1)?, - timestamp: row.get(2)?, - method: row.get(3)?, - path: row.get(4)?, - status: row.get(5)?, - duration_ms: row.get(6)?, - provider: row.get(7)?, - summary: row.get(8)?, - signature_diagnostic: row.get(9)?, - request_headers: row.get(10)?, - request_body: row.get(11)?, - response_headers: row.get(12)?, - response_body: row.get(13)?, - tags: row.get(14)?, - auto_acked: row.get(15)?, - response_body_incomplete: row.get(18)?, - req_size: row.get(16)?, - res_size: row.get(17)?, - }) -} + let waited = started.elapsed(); + assert!(waited >= wait && waited < wait * 4, "{waited:?}"); + assert_eq!(error.kind(), std::io::ErrorKind::TimedOut); + assert!( + error.to_string().contains("another LPM process held"), + "{error}" + ); + assert!(!project.path().join(".lpm/inspector.db").exists()); + drop(lock); + } -fn stored_detail_to_webhook( - stored: StoredRequestDetail, -) -> Result { - if stored.request_body.len() != stored.req_size || stored.response_body.len() != stored.res_size - { - return Err(rusqlite::Error::FromSqlConversionFailure( - 11, - rusqlite::types::Type::Blob, - Box::new(std::io::Error::new( - std::io::ErrorKind::InvalidData, - "capture body was truncated by an older LPM CLI and cannot be replayed faithfully", - )), - )); + #[cfg(unix)] + #[test] + fn openers_racing_to_replace_a_database_others_could_open_all_succeed() { + for round in 0..30 { + let project = project_with_a_capture(); + let db_path = project.path().join(".lpm/inspector.db"); + set_mode(&db_path, 0o644); + let start = Arc::new(std::sync::Barrier::new(4)); + let openers: Vec<_> = (0..4) + .map(|_| { + let start = Arc::clone(&start); + let project = project.path().to_path_buf(); + std::thread::spawn(move || { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + start.wait(); + runtime.block_on(async { + // Each keeps its database open while the others + // open theirs, as concurrent runs would. + let db = InspectorDb::open(&project)?; + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + Ok::<_, OpenError>(db.open_to_others()) + }) + }) + }) + .collect(); + for opener in openers { + assert!(!opener.join().unwrap().unwrap(), "round {round}"); + } + assert_eq!(mode(&db_path), 0o600, "round {round}"); + } } - let request_headers: HashMap = serde_json::from_str(&stored.request_headers) - .map_err(|error| { - rusqlite::Error::FromSqlConversionFailure( - 10, - rusqlite::types::Type::Text, - Box::new(error), - ) - })?; - let response_headers: HashMap = serde_json::from_str(&stored.response_headers) - .map_err(|error| { - rusqlite::Error::FromSqlConversionFailure( - 12, - rusqlite::types::Type::Text, - Box::new(error), - ) - })?; - let provider = stored - .provider - .as_deref() - .map(parse_stored_provider) - .transpose()?; + #[tokio::test] + async fn clearing_history_leaves_no_capture_on_disk_while_another_handle_stays_open() { + const SECRET: &[u8] = b"zqxcapturedsecretzqx"; - Ok(CapturedWebhook { - id: stored.id, - timestamp: stored.timestamp, - method: stored.method, - path: stored.path, - request_headers, - request_body: stored.request_body, - response_status: stored.status, - response_headers, - response_body: stored.response_body, - duration_ms: stored.duration_ms, - provider, - summary: stored.summary, - signature_diagnostic: stored.signature_diagnostic, - auto_acked: stored.auto_acked, - response_body_incomplete: stored.response_body_incomplete, - }) -} + let project = tempfile::tempdir().unwrap(); + let recording = InspectorDb::open(project.path()).unwrap(); + let mut webhook = make_webhook("secret", 200); + webhook.request_body = SECRET.to_vec(); + webhook.summary = String::from_utf8(SECRET.to_vec()).unwrap(); + webhook.request_headers.insert( + "authorization".into(), + String::from_utf8(SECRET.to_vec()).unwrap(), + ); + recording + .insert_durable_request(Arc::new(webhook), None) + .await + .unwrap(); + let clearing = InspectorDb::open_for_review(project.path()).unwrap(); -fn parse_stored_provider(provider: &str) -> Result { - match provider.to_ascii_lowercase().as_str() { - "stripe" => Ok(WebhookProvider::Stripe), - "github" => Ok(WebhookProvider::GitHub), - "clerk" => Ok(WebhookProvider::Clerk), - "resend" => Ok(WebhookProvider::Resend), - "sendgrid" => Ok(WebhookProvider::SendGrid), - "twilio" => Ok(WebhookProvider::Twilio), - "svix" => Ok(WebhookProvider::Svix), - _ => Err(rusqlite::Error::FromSqlConversionFailure( - 7, - rusqlite::types::Type::Text, - Box::new(std::io::Error::new( - std::io::ErrorKind::InvalidData, - format!("unknown stored webhook provider `{provider}`"), - )), - )), + clearing.clear_history().await.unwrap(); + + for name in ["inspector.db", "inspector.db-wal"] { + let path = project.path().join(".lpm").join(name); + let bytes = std::fs::read(&path).unwrap_or_default(); + assert!( + !bytes.windows(SECRET.len()).any(|window| window == SECRET), + "{name} still holds the capture" + ); + } + assert_eq!(recording.count().await.unwrap(), 0); } -} -/// Session metadata. -#[derive(Debug)] -pub struct StoredSession { - pub id: String, - pub name: Option, - pub domain: Option, - pub local_port: u16, - pub started_at: String, - pub ended_at: Option, - pub request_count: usize, -} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn clearing_history_from_two_handles_at_once_succeeds() { + let project = tempfile::tempdir().unwrap(); + let first = InspectorDb::open(project.path()).unwrap(); + let second = InspectorDb::open(project.path()).unwrap(); + for round in 0..200 { + first + .insert_durable_request(Arc::new(make_webhook(&format!("r{round}"), 200)), None) + .await + .unwrap(); + let (first_clear, second_clear) = (first.clone(), second.clone()); + let (one, two) = tokio::join!( + tokio::spawn(async move { first_clear.clear_history().await }), + tokio::spawn(async move { second_clear.clear_history().await }), + ); + one.unwrap().unwrap(); + two.unwrap().unwrap(); + } + } -/// Session detail with summary statistics. -#[derive(Debug)] -pub struct SessionDetail { - pub id: String, - pub name: Option, - pub domain: Option, - pub local_port: u16, - pub started_at: String, - pub ended_at: Option, - pub request_count: usize, - pub failure_count: usize, - pub provider_count: usize, -} + #[cfg(unix)] + #[tokio::test] + async fn opening_leaves_a_linked_lpm_gitignore_and_its_target_alone() { + let project = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + let target = outside.path().join("profile"); + std::fs::write(&target, "export PATH\n").unwrap(); + std::fs::create_dir(project.path().join(".lpm")).unwrap(); + let link = project.path().join(".lpm/.gitignore"); + std::os::unix::fs::symlink(&target, &link).unwrap(); -// ── Tests ───────────────────────────────────────────────────────────── + drop(InspectorDb::open(project.path()).unwrap()); -#[cfg(test)] -mod tests { - use super::*; - use lpm_tunnel::webhook::WebhookProvider; - use std::collections::HashMap; + assert_eq!(std::fs::read_to_string(&target).unwrap(), "export PATH\n"); + assert!( + std::fs::symlink_metadata(&link) + .unwrap() + .file_type() + .is_symlink() + ); + } #[test] fn capture_schema_upgrade_serializes_concurrent_openers() { @@ -1466,25 +5506,24 @@ mod tests { let project = tempfile::tempdir().unwrap(); let db_path = project.path().join("inspector.db"); let write_conn = Connection::open(&db_path).unwrap(); + configure_connection(&write_conn, "NORMAL").unwrap(); init_schema(&write_conn).unwrap(); let read_conn = Connection::open(&db_path).unwrap(); - read_conn - .execute_batch( - "PRAGMA journal_mode=WAL; - PRAGMA synchronous=NORMAL; - PRAGMA foreign_keys=ON; - PRAGMA busy_timeout=5000;", - ) - .unwrap(); + configure_connection(&read_conn, "NORMAL").unwrap(); let control_conn = open_control_connection(&db_path).unwrap(); let (write_tx, write_rx) = mpsc::channel(1); let queued_capture_bytes = Arc::new(AtomicUsize::new(0)); + let write_conn = Arc::new(Mutex::new(write_conn)); let db = InspectorDb { read_conn: Arc::new(Mutex::new(read_conn)), control_conn: Arc::new(std::sync::Mutex::new(control_conn)), write_tx, queued_capture_bytes: Arc::clone(&queued_capture_bytes), db_path, + open_to_others: false, + ownership_ignored: false, + _temp_dir: None, + write_conn: Arc::clone(&write_conn), }; db.start_session("saturated-session".to_string(), None, 3000) @@ -1496,11 +5535,7 @@ mod tests { assert_eq!(db.write_tx.capacity(), 0); db.end_session("saturated-session".to_string()).unwrap(); - let writer = tokio::spawn(flush_task( - Arc::new(Mutex::new(write_conn)), - write_rx, - queued_capture_bytes, - )); + let writer = tokio::spawn(flush_task(write_conn, write_rx, queued_capture_bytes)); db.flush_pending_writes().await.unwrap(); let session = db.get_session("saturated-session").await.unwrap().unwrap(); @@ -1934,12 +5969,15 @@ mod tests { std::fs::create_dir_all(&lpm_dir).unwrap(); std::fs::write(lpm_dir.join("inspector.db"), b"not a sqlite database").unwrap(); - let error = match InspectorDb::open(project.path()) { - Ok(_) => panic!("corrupt database must not open"), - Err(error) => error, - }; + for open in [InspectorDb::open, InspectorDb::open_for_review] { + let error = match open(project.path()) { + Ok(_) => panic!("corrupt database must not open"), + Err(error) => error, + }; - assert!(error.to_string().contains("database")); + assert!(matches!(error, OpenError::Damaged { .. }), "{error}"); + assert!(error.to_string().ends_with(ADVICE), "{error}"); + } } } diff --git a/crates/lpm-vault/src/storage_transaction.rs b/crates/lpm-vault/src/storage_transaction.rs index fa24d5f06..bdb5d05e6 100644 --- a/crates/lpm-vault/src/storage_transaction.rs +++ b/crates/lpm-vault/src/storage_transaction.rs @@ -5,6 +5,8 @@ use std::path::{Path, PathBuf}; use cap_fs_ext::{DirExt as _, FollowSymlinks, OpenOptionsFollowExt as _, OpenOptionsSyncExt as _}; use cap_std::ambient_authority; use cap_std::fs::{Dir, OpenOptions}; +#[cfg(target_os = "macos")] +use lpm_common::macos_acl; const TRANSACTION_LOCK_NAME: &str = ".vault-keychain.lock"; @@ -94,7 +96,7 @@ impl VaultStorageDirectory { )); } #[cfg(target_os = "macos")] - macos_acl::clear_directory(&directory).map_err(|error| { + macos_acl::clear_extended_acl(&directory).map_err(|error| { format!( "failed to remove extended access control from {}: {error}", display.display() @@ -340,7 +342,7 @@ impl VaultStorageDirectory { { use cap_std::fs::{Permissions, PermissionsExt as _}; #[cfg(target_os = "macos")] - macos_acl::clear_file(file)?; + macos_acl::clear_extended_acl(&*file)?; file.set_permissions(Permissions::from_mode(0o600))?; } file.write_all(contents)?; @@ -426,7 +428,7 @@ impl VaultStorageDirectory { Err(error) => return Err(format!("failed to create {label}: {error}")), }; #[cfg(target_os = "macos")] - macos_acl::clear_file(&file) + macos_acl::clear_extended_acl(&file) .map_err(|error| format!("failed to secure {label}: {error}"))?; self.validate_regular_file(&file, OsStr::new(name), true)?; if let Err(error) = file.write_all(contents).and_then(|()| file.sync_all()) { @@ -536,81 +538,6 @@ impl VaultStorageDirectory { } } -#[cfg(target_os = "macos")] -mod macos_acl { - use std::ffi::{c_int, c_void}; - use std::os::fd::AsRawFd as _; - - const ACL_TYPE_EXTENDED: c_int = 0x0000_0100; - - unsafe extern "C" { - fn acl_free(value: *mut c_void) -> c_int; - fn acl_get_fd_np(file_descriptor: c_int, acl_type: c_int) -> *mut c_void; - fn acl_init(entry_count: c_int) -> *mut c_void; - fn acl_set_fd_np(file_descriptor: c_int, acl: *mut c_void, acl_type: c_int) -> c_int; - } - - pub(super) fn has_extended_acl(file: &cap_std::fs::File) -> std::io::Result { - has_extended_acl_fd(file.as_raw_fd()) - } - - pub(super) fn clear_file(file: &cap_std::fs::File) -> std::io::Result<()> { - clear_extended_acl_fd(file.as_raw_fd()) - } - - pub(super) fn clear_directory(directory: &cap_std::fs::Dir) -> std::io::Result<()> { - clear_extended_acl_fd(directory.as_raw_fd()) - } - - pub(super) fn has_extended_acl_fd(file_descriptor: c_int) -> std::io::Result { - unsafe { - *libc::__error() = 0; - let acl = acl_get_fd_np(file_descriptor, ACL_TYPE_EXTENDED); - if acl.is_null() { - let error = *libc::__error(); - return if error == 0 || error == libc::ENOENT { - Ok(false) - } else { - Err(std::io::Error::from_raw_os_error(error)) - }; - } - if acl_free(acl) == 0 { - Ok(true) - } else { - Err(std::io::Error::last_os_error()) - } - } - } - - fn clear_extended_acl_fd(file_descriptor: c_int) -> std::io::Result<()> { - unsafe { - let acl = acl_init(0); - if acl.is_null() { - return Err(std::io::Error::last_os_error()); - } - let result = acl_set_fd_np(file_descriptor, acl, ACL_TYPE_EXTENDED); - let error = if result == 0 { - None - } else { - Some(std::io::Error::last_os_error()) - }; - let free_result = acl_free(acl); - if let Some(error) = error { - return Err(error); - } - if free_result != 0 { - return Err(std::io::Error::last_os_error()); - } - if has_extended_acl_fd(file_descriptor)? { - return Err(std::io::Error::other( - "extended access control remained after removal", - )); - } - Ok(()) - } - } -} - pub(crate) fn with_vault_transaction( operation: impl FnOnce(&VaultStorageDirectory) -> Result, ) -> Result { @@ -1144,8 +1071,6 @@ mod tests { #[cfg(target_os = "macos")] #[test] fn protected_directory_and_new_files_remove_inherited_access_control() { - use std::os::fd::AsRawFd as _; - with_temp_home(|home| { let directory = home.join(".lpm"); std::fs::create_dir(&directory).expect("create .lpm directory"); @@ -1173,13 +1098,10 @@ mod tests { let created = std::fs::File::open(directory.join(".created-key")) .expect("open created protected file"); assert!( - !macos_acl::has_extended_acl_fd(opened_directory.as_raw_fd()) + !macos_acl::has_extended_acl(&opened_directory) .expect("inspect protected directory ACL") ); - assert!( - !macos_acl::has_extended_acl_fd(created.as_raw_fd()) - .expect("inspect protected file ACL") - ); + assert!(!macos_acl::has_extended_acl(&created).expect("inspect protected file ACL")); }); } } diff --git a/tests/integration/tests/atomic_write_inventory.rs b/tests/integration/tests/atomic_write_inventory.rs index d48d73048..8d733bb47 100644 --- a/tests/integration/tests/atomic_write_inventory.rs +++ b/tests/integration/tests/atomic_write_inventory.rs @@ -158,6 +158,10 @@ fn reviewed_raw_atomic_writer(key: &str) -> bool { } const REVIEWED_RAW_ATOMIC_WRITERS: &[(&str, &str)] = &[ + ( + "crates/lpm-inspect/src/db.rs::replace_with_owner_only_copy", + "renames SQLite's finished backup, written into an exclusively created owner-only file beside the capture database, over a database others could open, under the capture files' lock", + ), ( "crates/lpm-cli/src/cli/install_timeline.rs::export_with", "serializes and flushes an exclusively created same-directory NamedTempFile, then publishes with persist_noclobber without replacing an existing destination", diff --git a/tests/workflows/tests/dev_tunnel.rs b/tests/workflows/tests/dev_tunnel.rs index aaa6c022b..034955bd4 100644 --- a/tests/workflows/tests/dev_tunnel.rs +++ b/tests/workflows/tests/dev_tunnel.rs @@ -1355,8 +1355,9 @@ async fn tunnel_json_reports_a_retry_before_a_successful_reconnection() { "#); } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn tunnel_start_warns_that_capture_history_persists_sensitive_request_data() { +/// Starts `lpm tunnel 5173` against a relay that greets it and closes, in a +/// project `prepare` sets up, and returns its stderr. +async fn tunnel_start_stderr(prepare: impl FnOnce(&Path) + Send + 'static) -> String { let relay = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let relay_url = format!("ws://{}/connect", relay.local_addr().unwrap()); let relay_task = tokio::spawn(async move { @@ -1386,6 +1387,7 @@ async fn tunnel_start_warns_that_capture_history_persists_sensitive_request_data let output_task = tokio::task::spawn_blocking(move || { let project = TempProject::empty(r#"{"name":"tunnel","version":"1.0.0"}"#); + prepare(project.path()); let mut command = lpm_spawnable(&project); command.env("LPM_TUNNEL_RELAY", relay_url).args([ "--token", @@ -1403,16 +1405,116 @@ async fn tunnel_start_warns_that_capture_history_persists_sensitive_request_data "tunnel command failed: {}", String::from_utf8_lossy(&output.stderr) ); - let stderr = String::from_utf8_lossy(&output.stderr); + String::from_utf8_lossy(&output.stderr).into_owned() +} + +fn git(directory: &Path, args: &[&str]) { + let output = std::process::Command::new("git") + .args(args) + .current_dir(directory) + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("GIT_CONFIG_NOSYSTEM", "1") + .output() + .unwrap(); + assert!(output.status.success(), "git {args:?}: {output:?}"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn tunnel_start_warns_that_capture_history_persists_sensitive_request_data() { + let stderr = tunnel_start_stderr(|_| {}).await; + + assert_eq!( + stderr + .matches("tunnel webhook capture persists full request/response bodies and headers") + .count(), + 1, + "human tunnel startup must disclose capture persistence once:\n{stderr}" + ); assert!( - stderr.contains("tunnel webhook capture persists full request/response bodies and headers") - && stderr.contains(".lpm/inspector.db") - && stderr.contains("mode 0600 on Unix") + stderr.contains(".lpm/inspector.db") + && stderr.contains("owner-only on Unix") && stderr.contains("project ACLs on Windows"), "human tunnel startup must disclose capture persistence:\n{stderr}" ); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn tunnel_start_names_capture_files_git_tracks_and_how_to_stop_it() { + let stderr = tunnel_start_stderr(|project| { + git(project, &["init", "--quiet"]); + std::fs::create_dir(project.join(".lpm")).unwrap(); + std::fs::write(project.join(".lpm/inspector.db"), b"").unwrap(); + git(project, &["add", ".lpm/inspector.db"]); + }) + .await; + + assert!( + stderr.contains("git tracks '.lpm/inspector.db'") + && stderr.contains("run `git rm --cached -- .lpm/inspector.db`") + && !stderr.contains("git -C .lpm") + && stderr.contains("rotate any credentials"), + "tunnel startup must name tracked capture files:\n{stderr}" + ); + assert!( + !stderr.contains("git ignores it"), + "a tracked capture isn't ignored by git:\n{stderr}" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn tunnel_start_asks_a_repository_inside_lpm_about_the_captures_it_tracks() { + let stderr = tunnel_start_stderr(|project| { + git(project, &["init", "--quiet"]); + let lpm_dir = project.join(".lpm"); + std::fs::create_dir(&lpm_dir).unwrap(); + git(&lpm_dir, &["init", "--quiet"]); + std::fs::write(lpm_dir.join("inspector.db"), b"").unwrap(); + git(&lpm_dir, &["add", "inspector.db"]); + }) + .await; + + assert!( + stderr.contains("git tracks '.lpm/inspector.db'") + && stderr.contains("git -C .lpm rm --cached -- inspector.db") + && !stderr.contains("git ignores it"), + "a repository inside .lpm must answer for the captures it tracks:\n{stderr}" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn tunnel_start_says_git_ignores_captures_once_it_does() { + let stderr = tunnel_start_stderr(|project| git(project, &["init", "--quiet"])).await; + + assert!( + stderr.contains("; git ignores it") && !stderr.contains("git tracks"), + "{stderr}" + ); +} + +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn tunnel_start_says_to_replace_a_linked_lpm_gitignore_git_doesnt_read() { + let outside = tempfile::tempdir().unwrap(); + let rules = outside.path().join("rules"); + std::fs::write(&rules, "inspector.db*\n").unwrap(); + let stderr = tunnel_start_stderr(move |project| { + git(project, &["init", "--quiet"]); + std::fs::create_dir(project.join(".lpm")).unwrap(); + std::os::unix::fs::symlink(&rules, project.join(".lpm/.gitignore")).unwrap(); + }) + .await; + + assert!( + stderr.contains("git doesn't ignore") + && stderr.contains( + "replace .lpm/.gitignore, which git doesn't read as a file of the project's, \ + with a file that holds `inspector.db*`" + ) + && !stderr.contains("git ignores it"), + "{stderr}" + ); +} + #[test] fn tunnel_claim_without_auth_under_json_emits_error_envelope_on_stdout() { let project = TempProject::empty(r#"{"name":"tunnel","version":"1.0.0"}"#); @@ -1462,6 +1564,45 @@ fn tunnel_list_without_auth_under_json_emits_error_envelope_on_stdout() { assert_eq!(envelope["error_code"], serde_json::json!("tunnel")); } +#[test] +fn tunnel_inspect_reports_a_capture_database_it_cant_open_as_a_disk_error_under_json() { + let project = TempProject::empty(r#"{"name":"tunnel","version":"1.0.0"}"#); + std::fs::create_dir(project.path().join(".lpm")).unwrap(); + std::fs::write(project.path().join(".lpm/inspector.db"), b"not a database").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + std::fs::set_permissions( + project.path().join(".lpm/inspector.db"), + std::fs::Permissions::from_mode(0o600), + ) + .unwrap(); + } + + let output = lpm(&project) + .args(["--json", "tunnel", "inspect"]) + .output() + .expect("failed to run lpm --json tunnel inspect"); + + let stdout = String::from_utf8_lossy(&output.stdout).into_owned(); + let envelope: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap_or_else(|e| { + panic!("--json tunnel inspect error path must emit JSON: {e}\n---\n{stdout}") + }); + assert_eq!(envelope["success"], serde_json::json!(false)); + assert_eq!(envelope["error_code"], serde_json::json!("io")); + assert!( + envelope["error"] + .as_str() + .is_some_and(|message| message.ends_with( + "move it aside together with any inspector.db-wal, inspector.db-shm and \ + inspector.db-journal beside it, under names that still start inspector.db \ + so git goes on ignoring them, and retry" + )), + "{envelope}" + ); +} + #[test] fn tunnel_inspect_without_auth_reads_project_inspector_database_under_json() { let project = TempProject::empty(r#"{"name":"tunnel","version":"1.0.0"}"#); @@ -1503,6 +1644,61 @@ fn tunnel_inspect_without_auth_reads_project_inspector_database_under_json() { assert_eq!(rows[0]["status"], serde_json::json!(402)); } +#[cfg(unix)] +#[test] +fn reading_captures_others_could_open_works_while_another_process_records_and_warns() { + use std::os::unix::fs::PermissionsExt; + + let project = TempProject::empty(r#"{"name":"tunnel","version":"1.0.0"}"#); + let db_path = project.path().join(".lpm/inspector.db"); + let runtime = tokio::runtime::Runtime::new().unwrap(); + let recording = runtime.block_on(async { + let state = lpm_inspect::state::InspectorState::with_db( + 3000, + lpm_inspect::db::InspectorDb::open(project.path()).unwrap(), + ); + state + .push(captured_webhook("wh-kept", "2026-05-22T10:05:00Z", 200)) + .await; + state.flush().await.unwrap(); + state + }); + // As an earlier release or a checkout left it. + std::fs::set_permissions(&db_path, std::fs::Permissions::from_mode(0o644)).unwrap(); + + let human = lpm(&project).args(["tunnel", "log"]).output().unwrap(); + let json = lpm(&project) + .args(["--json", "tunnel", "log"]) + .output() + .unwrap(); + + let rendered = format!( + "{}{}", + String::from_utf8_lossy(&human.stdout), + String::from_utf8_lossy(&human.stderr) + ); + assert!(human.status.success(), "{rendered}"); + assert!(rendered.contains("is open to other users"), "{rendered}"); + assert!( + json.status.success(), + "{}", + String::from_utf8_lossy(&json.stderr) + ); + let rows: serde_json::Value = serde_json::from_slice(&json.stdout).unwrap(); + assert_eq!(rows.as_array().map(Vec::len), Some(1)); + let json_log = String::from_utf8_lossy(&json.stderr); + assert!( + json_log.contains("is open to other users") + && json_log.contains("makes it owner-only where file modes keep other users out"), + "{json_log}" + ); + assert_eq!( + std::fs::metadata(&db_path).unwrap().permissions().mode() & 0o777, + 0o644 + ); + drop(recording); +} + #[test] fn tunnel_inspect_filters_and_details_share_deterministic_project_history() { let project = TempProject::empty(r#"{"name":"tunnel","version":"1.0.0"}"#); @@ -1648,7 +1844,15 @@ fn tunnel_inspect_reports_an_actionable_corrupt_database_error() { String::from_utf8_lossy(&output.stderr) ); assert!(rendered.contains(".lpm/inspector.db")); - assert!(rendered.contains("Repair or move")); + assert!(rendered.contains( + "move it aside together with any inspector.db-wal, inspector.db-shm and \ + inspector.db-journal beside it, under names that still start inspector.db so git \ + goes on ignoring them, and retry" + )); + assert!( + !rendered.contains("network connection"), + "a local database error isn't a network one:\n{rendered}" + ); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)]