diff --git a/apps/desktop/src-tauri/src/fs/assets.rs b/apps/desktop/src-tauri/src/fs/assets.rs index d59036428..10b42ba17 100644 --- a/apps/desktop/src-tauri/src/fs/assets.rs +++ b/apps/desktop/src-tauri/src/fs/assets.rs @@ -20,11 +20,12 @@ use std::collections::HashMap; use std::fs; -use std::io::Write; -use std::path::Path; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Mutex; +use sha2::{Digest, Sha256}; use tauri::ipc::{InvokeBody, Request}; use tauri::State; @@ -36,8 +37,15 @@ use super::{root_for_generation, GraphState}; /// Header carrying the upload id on `asset_upload_append` calls — raw-body /// requests have no JSON args, so the id travels out-of-band. const UPLOAD_ID_HEADER: &str = "x-upload-id"; -/// Collision probes before giving up, mirroring `probeNotePath`'s cap. -const MAX_NAME_PROBES: u32 = 1000; +/// Sequential `-2`-style probes before candidates switch to content-digest +/// names. Small on purpose: everyday collisions stay readable, while a +/// densely collided stem jumps to digests instead of marching toward the +/// probe cap — a V1 import can carry thousands of pastes all named +/// `image.png`. +pub(super) const SEQUENTIAL_NAME_PROBES: u32 = 8; +/// Hard cap on collision probes. With digest candidates in the sequence this +/// is a backstop against pathological inputs, not a limit real graphs reach. +pub(super) const MAX_NAME_PROBES: u32 = 1000; struct Upload { generation: u64, @@ -77,7 +85,7 @@ fn ensure_asset_name(name: &str) -> AppResult<()> { /// Split `name` into (stem, `.ext`) for suffix probing; the extension stays /// attached through collisions (`report.pdf` → `report-2.pdf`). -fn split_name(name: &str) -> (&str, &str) { +pub(super) fn split_name(name: &str) -> (&str, &str) { match name.rfind('.') { // A leading dot is a hidden file, not an extension. Some(idx) if idx > 0 => name.split_at(idx), @@ -85,23 +93,97 @@ fn split_name(name: &str) -> (&str, &str) { } } -/// Persist `temp` under `assets_dir` as `desired`, probing `-2`, `-3`, … -/// suffixes until a name is free. `persist_noclobber` is the collision check -/// *and* the claim (`O_EXCL` semantics), so two concurrent intakes of the -/// same name can never clobber each other. Returns the winning filename. +/// Candidate names for one collision-probe sequence: the desired name, then +/// readable `-2`…`-8` suffixes, then names carrying the first 8 hex chars of +/// the file's sha256 (`image-3f9ab2c1.png`, `image-3f9ab2c1-2.png`, …). +/// Content digests make candidates effectively unique per distinct file, so +/// a graph already dense with one stem resolves in a couple of probes instead +/// of exhausting the cap — and they are deterministic, so planning the same +/// bytes again re-derives the same name (which is how a re-import finds the +/// file it wrote last time). +pub(super) struct NameCandidates<'a> { + stem: &'a str, + ext: &'a str, + contents: PathBuf, + digest: Option, + attempt: u32, +} + +impl<'a> NameCandidates<'a> { + /// `contents` is the staged file the digest candidates hash; it is read + /// lazily, only once sequential probing runs dry. + pub(super) fn new(desired: &'a str, contents: PathBuf) -> Self { + let (stem, ext) = split_name(desired); + Self { + stem, + ext, + contents, + digest: None, + attempt: 0, + } + } + + /// The next candidate filename, or `None` once [`MAX_NAME_PROBES`] is + /// exhausted. + pub(super) fn next(&mut self) -> AppResult> { + self.attempt += 1; + let (stem, ext) = (self.stem, self.ext); + match self.attempt { + 1 => Ok(Some(format!("{stem}{ext}"))), + attempt @ 2..=SEQUENTIAL_NAME_PROBES => Ok(Some(format!("{stem}-{attempt}{ext}"))), + attempt if attempt <= MAX_NAME_PROBES => { + if self.digest.is_none() { + self.digest = Some(sha256_hex_prefix(&self.contents)?); + } + let digest = self.digest.as_deref().expect("digest just computed"); + let round = attempt - SEQUENTIAL_NAME_PROBES; + Ok(Some(if round == 1 { + format!("{stem}-{digest}{ext}") + } else { + format!("{stem}-{digest}-{round}{ext}") + })) + } + _ => Ok(None), + } + } +} + +fn sha256_hex_prefix(path: &Path) -> AppResult { + use std::fmt::Write as FmtWrite; + + let mut file = fs::File::open(path)?; + let mut hasher = Sha256::new(); + let mut buffer = [0_u8; 64 * 1024]; + loop { + let bytes_read = file.read(&mut buffer)?; + if bytes_read == 0 { + break; + } + hasher.update(&buffer[..bytes_read]); + } + + let digest = hasher.finalize(); + let mut hex = String::with_capacity(8); + for byte in &digest[..4] { + write!(&mut hex, "{byte:02x}").expect("writing to String cannot fail"); + } + Ok(hex) +} + +/// Persist `temp` under `assets_dir` as `desired`, probing [`NameCandidates`] +/// until a name is free. `persist_noclobber` is the collision check *and* the +/// claim (`O_EXCL` semantics), so two concurrent intakes of the same name can +/// never clobber each other. Like [`persist_exact`], fsyncs before the rename +/// — durability is the persist helpers' job, never their callers. Returns the +/// winning filename. fn persist_unique( mut temp: tempfile::NamedTempFile, assets_dir: &Path, desired: &str, ) -> AppResult { temp.as_file().sync_all()?; - let (stem, ext) = split_name(desired); - for attempt in 1..=MAX_NAME_PROBES { - let candidate = if attempt == 1 { - desired.to_string() - } else { - format!("{stem}-{attempt}{ext}") - }; + let mut candidates = NameCandidates::new(desired, temp.path().to_path_buf()); + while let Some(candidate) = candidates.next()? { match temp.persist_noclobber(assets_dir.join(&candidate)) { Ok(_) => return Ok(candidate), Err(err) if err.error.kind() == std::io::ErrorKind::AlreadyExists => { @@ -354,6 +436,48 @@ mod tests { assert_eq!(fs::read(assets.join("report-2.pdf")).unwrap(), b"second"); } + #[test] + fn persist_switches_to_digest_names_when_sequential_probes_exhaust() { + let graph = tempdir().unwrap(); + bootstrap(graph.path()).unwrap(); + let assets = graph.path().join("assets"); + fs::write(assets.join("image.png"), b"existing").unwrap(); + for suffix in 2..=SEQUENTIAL_NAME_PROBES { + fs::write(assets.join(format!("image-{suffix}.png")), b"existing").unwrap(); + } + let temp = temp_in(graph.path(), b"fresh screenshot"); + let expected_digest = sha256_hex_prefix(temp.path()).unwrap(); + let name = persist_unique(temp, &assets, "image.png").unwrap(); + let digest = name + .strip_prefix("image-") + .and_then(|rest| rest.strip_suffix(".png")) + .unwrap(); + assert_eq!(digest, expected_digest); + assert_eq!(fs::read(assets.join(&name)).unwrap(), b"fresh screenshot"); + } + + #[test] + fn sha256_hex_prefix_hashes_large_files_incrementally() { + use std::fmt::Write as FmtWrite; + + let graph = tempdir().unwrap(); + let mut temp = tempfile::NamedTempFile::new_in(graph.path()).unwrap(); + let mut hasher = Sha256::new(); + let chunk = [7_u8; 1024]; + for _ in 0..100 { + temp.write_all(&chunk).unwrap(); + hasher.update(chunk); + } + + let digest = hasher.finalize(); + let mut expected = String::with_capacity(8); + for byte in &digest[..4] { + write!(&mut expected, "{byte:02x}").unwrap(); + } + + assert_eq!(sha256_hex_prefix(temp.path()).unwrap(), expected); + } + #[test] fn persist_suffixes_extensionless_names() { let graph = tempdir().unwrap(); diff --git a/apps/desktop/src-tauri/src/fs/import_assets.rs b/apps/desktop/src-tauri/src/fs/import_assets.rs index 98a36bc6d..668a5c8d0 100644 --- a/apps/desktop/src-tauri/src/fs/import_assets.rs +++ b/apps/desktop/src-tauri/src/fs/import_assets.rs @@ -13,7 +13,7 @@ //! can simply retry. use std::collections::HashMap; -use std::io::Write; +use std::io::{Read, Write}; use std::path::Path; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; @@ -33,8 +33,6 @@ const CONNECT_TIMEOUT: Duration = Duration::from_secs(15); /// a large video on a slow connection still finishes. const READ_TIMEOUT: Duration = Duration::from_secs(60); const CONCURRENT_DOWNLOADS: usize = 6; -/// Collision probes before giving up, mirroring `persist_unique`'s cap. -const MAX_NAME_PROBES: u32 = 1000; /// One remote-asset URL occurrence inside a markdown file. pub(super) struct RemoteSpan { @@ -519,21 +517,21 @@ pub(super) fn plan_asset_name( staged: &Path, taken: &std::collections::HashSet, ) -> AppResult { - let (stem, extension) = match desired.rfind('.') { - Some(index) if index > 0 => desired.split_at(index), - _ => (desired, ""), - }; - for attempt in 1..=MAX_NAME_PROBES { - let candidate = if attempt == 1 { - desired.to_string() - } else { - format!("{stem}-{attempt}{extension}") - }; + let mut candidates = super::assets::NameCandidates::new(desired, staged.to_path_buf()); + let mut probe_count = 0; + while let Some(candidate) = candidates.next()? { + probe_count += 1; if taken.contains(&candidate) { continue; } let target = assets_dir.join(&candidate); if !target.exists() && !super::io::file_occupied(&target) { + if probe_count > super::assets::SEQUENTIAL_NAME_PROBES { + if let Some(name) = legacy_numbered_reuse_name(assets_dir, desired, staged, taken)? + { + return Ok(PlannedAssetName { name, reuse: true }); + } + } return Ok(PlannedAssetName { name: candidate, reuse: false, @@ -547,10 +545,31 @@ pub(super) fn plan_asset_name( } } Err(AppError::io(format!( - "no free asset name after {MAX_NAME_PROBES} probes for {desired}" + "no free asset name after {} probes for {desired}", + super::assets::MAX_NAME_PROBES ))) } +fn legacy_numbered_reuse_name( + assets_dir: &Path, + desired: &str, + staged: &Path, + taken: &std::collections::HashSet, +) -> AppResult> { + let (stem, ext) = super::assets::split_name(desired); + for suffix in (super::assets::SEQUENTIAL_NAME_PROBES + 1)..=super::assets::MAX_NAME_PROBES { + let candidate = format!("{stem}-{suffix}{ext}"); + if taken.contains(&candidate) { + continue; + } + let target = assets_dir.join(&candidate); + if target.is_file() && same_file_bytes(&target, staged)? { + return Ok(Some(candidate)); + } + } + Ok(None) +} + /// Whether two files hold identical bytes (length check first, so comparing /// large attachments is cheap in the common differing case). pub(super) fn same_file_bytes(existing: &Path, staged: &Path) -> AppResult { @@ -559,7 +578,23 @@ pub(super) fn same_file_bytes(existing: &Path, staged: &Path) -> AppResult if existing_meta.len() != staged_meta.len() { return Ok(false); } - Ok(std::fs::read(existing)? == std::fs::read(staged)?) + let mut existing_file = std::fs::File::open(existing)?; + let mut staged_file = std::fs::File::open(staged)?; + let mut existing_buffer = [0_u8; 64 * 1024]; + let mut staged_buffer = [0_u8; 64 * 1024]; + loop { + let existing_read = existing_file.read(&mut existing_buffer)?; + let staged_read = staged_file.read(&mut staged_buffer)?; + if existing_read != staged_read { + return Ok(false); + } + if existing_read == 0 { + return Ok(true); + } + if existing_buffer[..existing_read] != staged_buffer[..staged_read] { + return Ok(false); + } + } } /// Persist a staged download at its planned name. The plan already verified @@ -757,4 +792,88 @@ mod tests { assert_eq!(planned.name, "photo-3.webp"); assert!(!planned.reuse); } + + /// A years-long V1 graph can hold thousands of pastes all named + /// `image.png`; once the sequential suffixes are dense the plan must jump + /// to digest names instead of exhausting the probe cap. + #[test] + fn plan_switches_to_digest_names_when_sequential_probes_exhaust() { + let dir = tempdir().unwrap(); + let assets = dir.path().join("assets"); + fs::create_dir_all(&assets).unwrap(); + let staged = dir.path().join("staged"); + fs::write(&staged, b"screenshot bytes").unwrap(); + + let mut taken = HashSet::from(["image.png".to_string()]); + for suffix in 2..=crate::fs::assets::SEQUENTIAL_NAME_PROBES { + taken.insert(format!("image-{suffix}.png")); + } + + let planned = plan_asset_name(&assets, "image.png", &staged, &taken).unwrap(); + let digest = planned + .name + .strip_prefix("image-") + .and_then(|rest| rest.strip_suffix(".png")) + .unwrap(); + assert_eq!(digest.len(), 8); + assert!(digest.chars().all(|ch| ch.is_ascii_hexdigit())); + assert!(!planned.reuse); + + // Re-importing the same bytes re-derives the same digest name and + // reuses the file already on disk. + fs::write(assets.join(&planned.name), b"screenshot bytes").unwrap(); + let again = plan_asset_name(&assets, "image.png", &staged, &taken).unwrap(); + assert_eq!(again.name, planned.name); + assert!(again.reuse); + + // A different file lands beside it under its own digest. + let other = dir.path().join("other"); + fs::write(&other, b"a different screenshot").unwrap(); + let planned_other = plan_asset_name(&assets, "image.png", &other, &taken).unwrap(); + assert_ne!(planned_other.name, planned.name); + assert!(!planned_other.reuse); + } + + #[test] + fn plan_reuses_legacy_numbered_names_after_digest_switch() { + let dir = tempdir().unwrap(); + let assets = dir.path().join("assets"); + fs::create_dir_all(&assets).unwrap(); + let staged = dir.path().join("staged"); + fs::write(&staged, b"same old screenshot").unwrap(); + fs::write(assets.join("image-9.png"), b"same old screenshot").unwrap(); + + let mut taken = HashSet::from(["image.png".to_string()]); + for suffix in 2..=crate::fs::assets::SEQUENTIAL_NAME_PROBES { + taken.insert(format!("image-{suffix}.png")); + } + + let planned = plan_asset_name(&assets, "image.png", &staged, &taken).unwrap(); + assert_eq!(planned.name, "image-9.png"); + assert!(planned.reuse); + } + + #[test] + fn same_file_bytes_compares_large_files_incrementally() { + let dir = tempdir().unwrap(); + let first = dir.path().join("first"); + let second = dir.path().join("second"); + let different = dir.path().join("different"); + let mut first_file = fs::File::create(&first).unwrap(); + let mut second_file = fs::File::create(&second).unwrap(); + let mut different_file = fs::File::create(&different).unwrap(); + let chunk = [11_u8; 1024]; + let different_chunk = [12_u8; 1024]; + for _ in 0..100 { + first_file.write_all(&chunk).unwrap(); + second_file.write_all(&chunk).unwrap(); + different_file.write_all(&different_chunk).unwrap(); + } + drop(first_file); + drop(second_file); + drop(different_file); + + assert!(same_file_bytes(&first, &second).unwrap()); + assert!(!same_file_bytes(&first, &different).unwrap()); + } }