diff --git a/core/src/activity.rs b/core/src/activity.rs index efe79b0..0a864a9 100644 --- a/core/src/activity.rs +++ b/core/src/activity.rs @@ -128,9 +128,64 @@ impl ActivitySnapshot { }) } - #[cfg(not(target_os = "macos"))] + #[cfg(target_os = "linux")] + pub(crate) fn capture(cancel: &AtomicBool) -> Result { + let euid = unsafe { libc::geteuid() }; + let proc_dir = std::fs::read_dir("/proc").map_err(|e| format!("Cannot read /proc: {e}"))?; + let mut working_directories = Vec::new(); + let mut executable_paths = Vec::new(); + let self_pid = std::process::id(); + + for entry in proc_dir { + safety::cancelled(cancel)?; + let Ok(entry) = entry else { continue }; + let file_name = entry.file_name(); + let Some(name_str) = file_name.to_str() else { + continue; + }; + let Ok(pid) = name_str.parse::() else { + continue; + }; + + use std::os::unix::fs::MetadataExt; + let Ok(metadata) = entry.metadata() else { + continue; + }; + if metadata.uid() != euid { + continue; + } + + let exe_link = format!("/proc/{pid}/exe"); + if let Some(exe) = std::fs::read_link(&exe_link) + .ok() + .filter(|p| p.is_absolute()) + { + executable_paths.push(exe); + } + + if pid == self_pid { + continue; + } + + let cwd_link = format!("/proc/{pid}/cwd"); + if let Some(cwd) = std::fs::read_link(&cwd_link) + .ok() + .filter(|p| p.is_absolute()) + { + working_directories.push(cwd); + } + } + + Ok(Self { + working_directories, + executable_paths, + running_app_bundle_ids: OnceCell::new(), + }) + } + + #[cfg(not(any(target_os = "macos", target_os = "linux")))] pub(crate) fn capture(_cancel: &AtomicBool) -> Result { - Err("Reliable activity checks are supported only by the native macOS engine".into()) + Err("Reliable activity checks are supported only on macOS and Linux".into()) } /// Reuse one bounded-age snapshot across nearby gates. A failed capture is @@ -232,9 +287,14 @@ impl ActivitySnapshot { native::bundle_identifier, ) } - #[cfg(not(target_os = "macos"))] + #[cfg(target_os = "linux")] + { + let _ = cancel; + Ok(BTreeSet::new()) + } + #[cfg(not(any(target_os = "macos", target_os = "linux")))] { - Err("Reliable activity checks are supported only by the native macOS engine".into()) + Err("Reliable activity checks are supported only on macOS and Linux".into()) } }); match identifiers { diff --git a/core/src/cleanup.rs b/core/src/cleanup.rs index 153a7f5..87ab662 100644 --- a/core/src/cleanup.rs +++ b/core/src/cleanup.rs @@ -2006,7 +2006,7 @@ mod tests { assert_eq!(std::fs::read(outside).unwrap(), [0x61; 8192]); } - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", target_os = "linux"))] fn review_fixture(large: bool) -> (tempfile::TempDir, Store, Root, Candidate) { let temp = tempfile::tempdir().unwrap(); let base = temp.path().canonicalize().unwrap(); @@ -2035,6 +2035,7 @@ mod tests { } else { file.write_all(b"disposable reviewed payload").unwrap(); } + file.sync_all().unwrap(); } let modified = std::time::SystemTime::now() - Duration::from_secs(8 * 86_400); for path in [ @@ -2073,28 +2074,28 @@ mod tests { (temp, store, root, candidate) } - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", target_os = "linux"))] struct FakeTrashState { destination: PathBuf, calls: usize, } - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", target_os = "linux"))] thread_local! { static FAKE_TRASH_STATE: RefCell> = const { RefCell::new(None) }; } - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", target_os = "linux"))] struct FakeTrashGuard; - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", target_os = "linux"))] impl Drop for FakeTrashGuard { fn drop(&mut self) { FAKE_TRASH_STATE.with(|state| *state.borrow_mut() = None); } } - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", target_os = "linux"))] fn install_fake_trash(destination: PathBuf) -> FakeTrashGuard { FAKE_TRASH_STATE.with(|state| { let mut state = state.borrow_mut(); @@ -2110,14 +2111,14 @@ mod tests { FakeTrashGuard } - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", target_os = "linux"))] fn fake_trash_calls() -> usize { FAKE_TRASH_STATE.with(|state| state.borrow().as_ref().map_or(0, |state| state.calls)) } /// Test Trash never invokes AppKit or touches the user's Trash. It only /// moves the staged file to a preconfigured sibling inside the temp fixture. - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", target_os = "linux"))] unsafe extern "C" fn fake_trash( input: *const libc::c_char, output: *mut libc::c_char, @@ -2150,7 +2151,7 @@ mod tests { }) } - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", target_os = "linux"))] struct DuplicateCleanupFixture { store: Store, root: Root, @@ -2160,7 +2161,7 @@ mod tests { _temp: tempfile::TempDir, } - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", target_os = "linux"))] fn write_old_installer(path: &Path) { let mut file = File::create(path).unwrap(); let block = vec![0x5a; 1024 * 1024]; @@ -2177,7 +2178,7 @@ mod tests { .unwrap(); } - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", target_os = "linux"))] fn duplicate_cleanup_fixture() -> DuplicateCleanupFixture { let temp = tempfile::Builder::new() .prefix("chippytea-duplicate-cleanup-") @@ -2239,14 +2240,14 @@ mod tests { } } - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", target_os = "linux"))] fn read_prefix(path: &Path) -> [u8; 8] { let mut prefix = [0; 8]; File::open(path).unwrap().read_exact(&mut prefix).unwrap(); prefix } - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", target_os = "linux"))] fn assert_no_cleanup_credit(store: &Store, receipt: &Receipt) { assert_eq!((receipt.credited_bytes, receipt.coins), (0, 0)); let wallet = store.wallet().unwrap(); @@ -2261,7 +2262,7 @@ mod tests { ); } - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", target_os = "linux"))] #[test] fn duplicate_guard_restores_copy_when_keeper_changes_after_staging() { let mut fixture = duplicate_cleanup_fixture(); @@ -2308,7 +2309,7 @@ mod tests { assert_no_cleanup_credit(&fixture.store, &receipt); } - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", target_os = "linux"))] #[test] fn duplicate_guard_restores_copy_when_cancelled_after_staging() { let mut fixture = duplicate_cleanup_fixture(); @@ -2351,7 +2352,7 @@ mod tests { assert_no_cleanup_credit(&fixture.store, &receipt); } - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", target_os = "linux"))] #[test] fn duplicate_guard_fake_trash_preserves_the_verified_keeper() { let mut fixture = duplicate_cleanup_fixture(); @@ -2462,7 +2463,7 @@ mod tests { (temp, store, root, candidate, interpreter) } - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", target_os = "linux"))] #[test] fn venv_with_internal_symlink_is_measured_and_permanently_removed() { let (_temp, mut store, root, candidate, interpreter) = venv_review_fixture(); @@ -2484,7 +2485,7 @@ mod tests { ); } - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", target_os = "linux"))] #[test] fn manifest_storage_failure_preserves_contents_and_rolls_back_parent_evidence() { let (_temp, mut store, root, candidate) = review_fixture(false); @@ -2519,7 +2520,7 @@ mod tests { assert_eq!(store.wallet().unwrap().credited_bytes, 0); } - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", target_os = "linux"))] #[test] fn cancellation_during_manifest_capture_never_stages_the_artifact() { let (_temp, mut store, root, candidate) = review_fixture(false); @@ -2559,7 +2560,7 @@ mod tests { assert_eq!(store.wallet().unwrap().credited_bytes, 0); } - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", target_os = "linux"))] #[test] fn changed_contents_after_manifest_commit_are_preserved_by_staged_verification() { let (_temp, mut store, root, candidate) = review_fixture(true); @@ -2594,7 +2595,7 @@ mod tests { assert_eq!(store.wallet().unwrap().credited_bytes, 0); } - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", target_os = "linux"))] #[test] fn completed_cleanup_reports_real_entry_counts_and_preserves_project_sources() { let (_temp, mut store, root, candidate) = review_fixture(true); @@ -2633,7 +2634,7 @@ mod tests { ); } - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", target_os = "linux"))] #[test] fn cancellation_after_removal_releases_manifest_snapshot_and_persists_recovery() { let (temp, mut store, root, candidate) = review_fixture(true); diff --git a/core/src/lib.rs b/core/src/lib.rs index 42781d5..9de55a0 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -1,3 +1,5 @@ +#![allow(clippy::unnecessary_cast, clippy::nonminimal_bool)] + pub mod accounting; mod activity; pub mod cleanup; @@ -4381,7 +4383,19 @@ mod controller_tests { std::thread::sleep(Duration::from_millis(1)); } drop(engine); - Engine::open(&temp.path().join("library.sqlite"), None).unwrap() + let deadline = Instant::now() + Duration::from_secs(2); + loop { + match Engine::open(&temp.path().join("library.sqlite"), None) { + Ok(engine) => return engine, + Err(err) + if err == "This chippytea library is already open in another process." + && Instant::now() < deadline => + { + std::thread::sleep(Duration::from_millis(5)); + } + Err(err) => panic!("{err}"), + } + } } // Seed derived rows without allocating cleanup-sized payloads or making diff --git a/core/src/scan_worker.rs b/core/src/scan_worker.rs index 14597be..1eb1bda 100644 --- a/core/src/scan_worker.rs +++ b/core/src/scan_worker.rs @@ -1489,6 +1489,7 @@ mod tests { use std::os::unix::fs::PermissionsExt; let directory = tempfile::tempdir().unwrap(); + std::fs::set_permissions(directory.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); let executable = directory.path().join("chippytea-cli"); let helper = directory.path().join("chippytea-scan-helper"); std::fs::write(&executable, b"cli").unwrap(); @@ -1535,6 +1536,15 @@ mod tests { let helpers = bundle.join("Contents/Helpers"); std::fs::create_dir_all(&macos).unwrap(); std::fs::create_dir_all(&helpers).unwrap(); + for context in [ + directory.path(), + &bundle, + &bundle.join("Contents"), + &macos, + &helpers, + ] { + std::fs::set_permissions(context, std::fs::Permissions::from_mode(0o755)).unwrap(); + } let executable = macos.join("Chippytea"); let helper = helpers.join("chippytea-scan-helper"); std::fs::write(&executable, b"app").unwrap(); diff --git a/core/src/scanner.rs b/core/src/scanner.rs index 8df19c4..d10ca71 100644 --- a/core/src/scanner.rs +++ b/core/src/scanner.rs @@ -4009,7 +4009,7 @@ mod tests { .unwrap(); } - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", target_os = "linux"))] fn eligible_revalidation_fixture() -> (tempfile::TempDir, Root, PathBuf, Candidate) { let (temp, root, project) = fixture(); let path = CString::new(root.path.as_os_str().as_bytes()).unwrap(); @@ -4155,7 +4155,7 @@ mod tests { } } - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", target_os = "linux"))] #[test] fn aged_eligible_artifact_matches_full_metadata_discovery() { let (_temp, root, _, candidate) = eligible_revalidation_fixture(); @@ -4644,7 +4644,7 @@ mod tests { ); } - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", target_os = "linux"))] #[test] fn revalidation_rejects_ownership_changes_during_the_measurement() { let (_temp, root, project, candidate) = eligible_revalidation_fixture(); @@ -4669,7 +4669,7 @@ mod tests { assert!(candidate.path.join("payload").is_file()); } - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", target_os = "linux"))] #[test] fn revalidation_rejects_new_or_nearer_git_tracking_during_measurement() { for existing_outer_repository in [false, true] { @@ -4699,7 +4699,7 @@ mod tests { } } - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", target_os = "linux"))] #[test] fn revalidation_rejects_project_activity_started_during_measurement() { struct RunningChild(std::process::Child); @@ -6457,12 +6457,21 @@ mod tests { .saturating_add(second_stats.logical_bytes) .saturating_sub(shared_meta.identity.size) ); + let dir_allocated = safety::metadata(&first).unwrap().allocated + + safety::metadata(&first.join("node_modules")) + .unwrap() + .allocated + + safety::metadata(&second).unwrap().allocated + + safety::metadata(&second.join("node_modules")) + .unwrap() + .allocated; assert_eq!( stats.allocated_bytes, first_stats .allocated_bytes .saturating_add(second_stats.allocated_bytes) .saturating_sub(shared_meta.allocated) + .saturating_sub(dir_allocated) ); assert_eq!(rows.iter().filter(|row| !row.provisional).count(), 2); assert!(rows.iter().filter(|row| !row.provisional).all(|row| { @@ -7766,7 +7775,7 @@ mod tests { ); } - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", target_os = "linux"))] #[test] fn aged_allocated_venv_with_internal_symlink_is_suggested_and_revalidates() { let (_temp, root, project, venv) = venv_fixture(); diff --git a/core/tests/hardlinks.rs b/core/tests/hardlinks.rs index 82e2e43..4140c5e 100644 --- a/core/tests/hardlinks.rs +++ b/core/tests/hardlinks.rs @@ -1,4 +1,5 @@ -#![cfg(target_os = "macos")] +#![cfg(any(target_os = "macos", target_os = "linux"))] +#![allow(clippy::useless_conversion, clippy::unnecessary_cast)] use chippytea_core::{Engine, model::*}; use serde_json::json; diff --git a/core/tests/support/mod.rs b/core/tests/support/mod.rs index 4d5f665..618dc55 100644 --- a/core/tests/support/mod.rs +++ b/core/tests/support/mod.rs @@ -34,6 +34,11 @@ fn stage_helper() -> io::Result<()> { let directory = executable .parent() .ok_or_else(|| io::Error::other("Test executable has no parent"))?; + let mut dir_perms = fs::metadata(directory)?.permissions(); + if dir_perms.mode() & 0o022 != 0 { + dir_perms.set_mode(dir_perms.mode() & !0o022); + fs::set_permissions(directory, dir_perms)?; + } let destination = directory.join("chippytea-scan-helper"); let mut temporary = tempfile::NamedTempFile::new_in(directory)?; temporary.write_all(&bytes)?;