Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 140 additions & 16 deletions apps/desktop/src-tauri/src/fs/assets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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,
Expand Down Expand Up @@ -77,31 +85,105 @@ 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),
_ => (name, ""),
}
}

/// 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<String>,
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<Option<String>> {
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),
}
Comment thread
cursor[bot] marked this conversation as resolved.
}
}

fn sha256_hex_prefix(path: &Path) -> AppResult<String> {
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<String> {
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 => {
Expand Down Expand Up @@ -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();
Expand Down
149 changes: 134 additions & 15 deletions apps/desktop/src-tauri/src/fs/import_assets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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 {
Expand Down Expand Up @@ -519,21 +517,21 @@ pub(super) fn plan_asset_name(
staged: &Path,
taken: &std::collections::HashSet<String>,
) -> AppResult<PlannedAssetName> {
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 });
}
}
Comment on lines +529 to +534

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Run the legacy reuse scan after checking the first digest candidate.

The legacy scan runs only when a digest candidate is free. If all digest candidates exist with different bytes, plan_asset_name returns the exhaustion error even when a legacy numbered file has identical bytes.

Check the current digest candidate for exact-byte reuse first. Then run legacy_numbered_reuse_name once after sequential probing, before selecting a free digest candidate.

Proposed fix
     let mut candidates = super::assets::NameCandidates::new(desired, staged.to_path_buf());
     let mut probe_count = 0;
+    let mut legacy_reuse_checked = false;
     while let Some(candidate) = candidates.next()? {
         probe_count += 1;
         if taken.contains(&candidate) {
             continue;
         }
         let target = assets_dir.join(&candidate);
+        if target.is_file() && same_file_bytes(&target, staged)? {
+            return Ok(PlannedAssetName {
+                name: candidate,
+                reuse: true,
+            });
+        }
+        if !legacy_reuse_checked
+            && probe_count > super::assets::SEQUENTIAL_NAME_PROBES
+        {
+            legacy_reuse_checked = true;
+            if let Some(name) = legacy_numbered_reuse_name(assets_dir, desired, staged, taken)? {
+                return Ok(PlannedAssetName { name, reuse: true });
+            }
+        }
         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,
             });
         }
-        if target.is_file() && same_file_bytes(&target, staged)? {
-            return Ok(PlannedAssetName {
-                name: candidate,
-                reuse: true,
-            });
-        }
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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 });
}
}
let mut candidates = super::assets::NameCandidates::new(desired, staged.to_path_buf());
let mut probe_count = 0;
let mut legacy_reuse_checked = false;
while let Some(candidate) = candidates.next()? {
probe_count += 1;
if taken.contains(&candidate) {
continue;
}
let target = assets_dir.join(&candidate);
if target.is_file() && same_file_bytes(&target, staged)? {
return Ok(PlannedAssetName {
name: candidate,
reuse: true,
});
}
if !legacy_reuse_checked
&& probe_count > super::assets::SEQUENTIAL_NAME_PROBES
{
legacy_reuse_checked = true;
if let Some(name) = legacy_numbered_reuse_name(assets_dir, desired, staged, taken)? {
return Ok(PlannedAssetName { name, reuse: true });
}
}
if !target.exists() && !super::io::file_occupied(&target) {
return Ok(PlannedAssetName {
name: candidate,
reuse: false,
});
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop/src-tauri/src/fs/import_assets.rs` around lines 529 - 534,
Update plan_asset_name so each digest candidate is checked for exact-byte reuse
before deciding it is occupied, preserving immediate reuse when bytes match.
After sequential probing completes, invoke legacy_numbered_reuse_name once
regardless of whether all digest candidates were occupied, and only then select
or report exhaustion for a free digest candidate.

return Ok(PlannedAssetName {
name: candidate,
reuse: false,
Expand All @@ -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<String>,
) -> AppResult<Option<String>> {
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<bool> {
Expand All @@ -559,7 +578,23 @@ pub(super) fn same_file_bytes(existing: &Path, staged: &Path) -> AppResult<bool>
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);
Comment on lines +585 to +595

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

curl -fsSL https://doc.rust-lang.org/std/io/trait.Read.html |
  rg -n -C 2 'smaller than the length of the buffer|shorter'

Repository: team-reflect/reflect-open

Length of output: 163


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

printf '%s\n' '--- target function context ---'
sed -n '530,620p' apps/desktop/src-tauri/src/fs/import_assets.rs

printf '%s\n' '--- related symbols and call sites ---'
rg -n -C 3 'same_file_bytes|existing_meta|existing_buffer|staged_buffer|asset reuse|reuse' apps/desktop/src-tauri/src/fs/import_assets.rs

printf '%s\n' '--- Read::read documentation available locally ---'
rustc --print sysroot 2>/dev/null || true
rg -n -C 3 'smaller than the length of the buffer|short read|shorter than' "$(rustc --print sysroot 2>/dev/null)/share/doc/rust/html/std/io/trait.Read.html" 2>/dev/null || true

Repository: team-reflect/reflect-open

Length of output: 9042


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

printf '%s\n' '--- Rust Read contract ---'
curl -fsSL https://doc.rust-lang.org/std/io/trait.Read.html |
  rg -n -C 4 'It is not an error|fewer bytes|less than|short read|number of bytes' |
  head -n 80

printf '%s\n' '--- read_exact contract ---'
curl -fsSL https://doc.rust-lang.org/std/io/trait.Read.html |
  rg -n -C 4 'read_exact|UnexpectedEof' |
  head -n 80

printf '%s\n' '--- relevant tests ---'
sed -n '740,885p' apps/desktop/src-tauri/src/fs/import_assets.rs

Repository: team-reflect/reflect-open

Length of output: 33925


Do not treat different short-read lengths as different file contents.

Read::read permits short reads before EOF. Two reads from identical files can return different lengths, causing same_file_bytes to return false before comparing all bytes.

Use read_exact on equal fixed-size slices until existing_meta.len() is consumed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop/src-tauri/src/fs/import_assets.rs` around lines 585 - 595,
Update same_file_bytes to compare files using read_exact on equal fixed-size
buffer slices, iterating until existing_meta.len() bytes are consumed. Do not
return false merely because individual reads have different lengths; compare
each fully read chunk and preserve the true result only after all bytes match.

}
}
}

/// Persist a staged download at its planned name. The plan already verified
Expand Down Expand Up @@ -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());
}
}
Loading