Skip to content

Commit 51180f7

Browse files
JSKittyclaude
andcommitted
fix(files): a re-received file finds the bytes already on disk
The pre-download disk check compared the plaintext on disk against the wire size, which is the CIPHERTEXT length — 16 AES-GCM bytes bigger — so the branch was dead and every re-received file downloaded content it already had. The gate now accepts both forms, and the ledger's recorded paths join the candidates, covering collision-suffixed downloads and files sent from arbitrary locations. Content is still hash-verified before any path is trusted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 358dd10 commit 51180f7

2 files changed

Lines changed: 58 additions & 1 deletion

File tree

‎crates/vector-core/src/db/attachments.rs‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,23 @@ pub struct ReusableUpload {
219219
/// CLAIMS this hash (a hostile sender's fabricated imeta) never qualifies —
220220
/// our own sends set it by construction. Own uploads win the ordering:
221221
/// their blobs live on our servers under our delete authority.
222+
/// Paths where these bytes already landed: every `downloaded=1` row for the
223+
/// hash, wherever the file was saved (collision suffixes included — the name
224+
/// on the message says nothing about the name on disk). Rows are CLAIMS: the
225+
/// caller must hash-verify a path before treating it as the content.
226+
pub fn downloaded_paths_by_hash(hash: &str) -> Result<Vec<String>, String> {
227+
let conn = crate::db::get_db_connection_guard_static()?;
228+
let mut stmt = conn
229+
.prepare("SELECT DISTINCT path FROM attachments WHERE hash = ?1 AND downloaded = 1 AND path != ''")
230+
.map_err(|e| e.to_string())?;
231+
let rows = stmt
232+
.query_map([hash], |r| r.get::<_, String>(0))
233+
.map_err(|e| e.to_string())?
234+
.filter_map(|r| r.ok())
235+
.collect();
236+
Ok(rows)
237+
}
238+
222239
pub fn find_reusable_by_hash(hash: &str) -> Result<Option<ReusableUpload>, String> {
223240
if hash.is_empty() {
224241
return Ok(None);
@@ -365,6 +382,28 @@ mod tests {
365382
assert!(find_reusable_by_hash("H9").unwrap().is_none());
366383
}
367384

385+
/// A re-received file must resolve to the copy already on disk, wherever
386+
/// it landed — the ledger's path, not the message's display name, is the
387+
/// authority. Undownloaded and pathless rows stay invisible.
388+
#[tokio::test]
389+
async fn the_ledger_knows_where_the_bytes_already_landed() {
390+
let (_tmp, _guard) = init_test_db();
391+
let mut on_disk = att("HP", "https://b.example/p", true, true);
392+
on_disk.path = "/dl/counter-strike-1.xdc".to_string();
393+
save("chat_a", "evt_disk", true, on_disk).await;
394+
let mut ghost = att("HP", "https://b.example/p2", true, false);
395+
ghost.path = "/dl/never-finished.xdc".to_string();
396+
save("chat_b", "evt_ghost", false, ghost).await;
397+
save("chat_c", "evt_pathless", true, att("HP", "https://b.example/p3", true, true)).await;
398+
399+
assert_eq!(
400+
downloaded_paths_by_hash("HP").unwrap(),
401+
vec!["/dl/counter-strike-1.xdc".to_string()],
402+
"only the downloaded row with a real path answers"
403+
);
404+
assert!(downloaded_paths_by_hash("H9").unwrap().is_empty());
405+
}
406+
368407
/// One blob, many messages: the shared copy must outlive every send but
369408
/// the last. The gate answers from the DB, not memory — an old message
370409
/// windowed out of STATE still holds its reference here.

‎src-tauri/src/commands/attachments.rs‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -355,8 +355,15 @@ pub async fn download_attachment(npub: String, msg_id: String, attachment_id: St
355355
// and correctly falls through to a real download. Size gates
356356
// the read so an obvious mismatch skips the full hash.
357357
let content_matches = |p: &std::path::PathBuf| {
358+
// The wire `size` is the CIPHERTEXT length; the disk file is
359+
// plaintext, 16 AES-GCM tag bytes shorter. Accept either form
360+
// (plaintext references carry the plaintext size) — an exact
361+
// equality here kept this whole branch dead and every
362+
// re-received file downloading bytes it already had.
358363
let size_ok = attachment.size == 0
359-
|| std::fs::metadata(p).map(|m| m.len() == attachment.size).unwrap_or(false);
364+
|| std::fs::metadata(p)
365+
.map(|m| m.len() == attachment.size || m.len() + 16 == attachment.size)
366+
.unwrap_or(false);
360367
size_ok
361368
&& std::fs::read(p)
362369
.map(|b| util::calculate_file_hash(&b) == expected_hash)
@@ -367,6 +374,17 @@ pub async fn download_attachment(npub: String, msg_id: String, attachment_id: St
367374
} else {
368375
name_path.filter(|p| p.exists() && content_matches(p))
369376
};
377+
// Third candidate: wherever the ledger says these bytes already
378+
// landed — a collision-suffixed download, or a file we SENT from
379+
// an arbitrary path. Rows are claims; content_matches still
380+
// hash-verifies before anything is trusted.
381+
let file_path = file_path.or_else(|| {
382+
vector_core::db::attachments::downloaded_paths_by_hash(&expected_hash)
383+
.unwrap_or_default()
384+
.into_iter()
385+
.map(std::path::PathBuf::from)
386+
.find(|p| p.exists() && content_matches(p))
387+
});
370388
if let Some(file_path) = file_path {
371389
// File already exists! Update the state and return success
372390
attachment.set_downloaded(true);

0 commit comments

Comments
 (0)