|
| 1 | +//! Eligibility filter for downloads-watcher events. |
| 2 | +//! |
| 3 | +//! Decides whether a path observed under the watched root looks like a "real" |
| 4 | +//! completed download we should surface. The watcher (M2b) passes only paths |
| 5 | +//! that are already under the resolved Downloads root, so this function |
| 6 | +//! doesn't re-check that boundary. |
| 7 | +
|
| 8 | +use std::fs; |
| 9 | +use std::path::{Component, Path}; |
| 10 | + |
| 11 | +/// Partial-download filename suffixes browsers emit while a transfer is |
| 12 | +/// in flight. Case-sensitive: browsers always lowercase these. |
| 13 | +const PARTIAL_SUFFIXES: &[&str] = &[".crdownload", ".part", ".download"]; |
| 14 | + |
| 15 | +/// Is `path` an eligible download to surface? |
| 16 | +/// |
| 17 | +/// Returns `false` when any of: |
| 18 | +/// |
| 19 | +/// - Any path component (basename or any ancestor) starts with `.` (hidden). |
| 20 | +/// - The filename ends with a partial-download suffix |
| 21 | +/// (`.crdownload`, `.part`, `.download`), case-sensitive. |
| 22 | +/// - `path` refers to a directory. |
| 23 | +/// - `path` is a broken symlink (errors swallowed, no propagation). |
| 24 | +/// |
| 25 | +/// Returns `true` for regular files and symlinks resolving to a regular file. |
| 26 | +pub fn is_eligible(path: &Path) -> bool { |
| 27 | + if has_hidden_component(path) { |
| 28 | + return false; |
| 29 | + } |
| 30 | + if has_partial_suffix(path) { |
| 31 | + return false; |
| 32 | + } |
| 33 | + // `fs::metadata` follows symlinks, so a symlink to a regular file is |
| 34 | + // treated as a regular file, and a broken symlink errors out and we |
| 35 | + // return `false` without propagating. |
| 36 | + match fs::metadata(path) { |
| 37 | + Ok(meta) => meta.is_file(), |
| 38 | + Err(_) => false, |
| 39 | + } |
| 40 | +} |
| 41 | + |
| 42 | +fn has_hidden_component(path: &Path) -> bool { |
| 43 | + path.components().any(|c| match c { |
| 44 | + Component::Normal(s) => s.to_str().is_some_and(|s| s.starts_with('.')), |
| 45 | + _ => false, |
| 46 | + }) |
| 47 | +} |
| 48 | + |
| 49 | +fn has_partial_suffix(path: &Path) -> bool { |
| 50 | + let Some(name) = path.file_name().and_then(|n| n.to_str()) else { |
| 51 | + return false; |
| 52 | + }; |
| 53 | + PARTIAL_SUFFIXES.iter().any(|suf| name.ends_with(suf)) |
| 54 | +} |
| 55 | + |
| 56 | +#[cfg(test)] |
| 57 | +mod tests { |
| 58 | + use super::*; |
| 59 | + |
| 60 | + use std::os::unix::fs::symlink; |
| 61 | + use std::path::PathBuf; |
| 62 | + |
| 63 | + use tempfile::TempDir; |
| 64 | + |
| 65 | + /// `tempfile::TempDir::new` on macOS creates a `.tmpXXXXX`-named |
| 66 | + /// directory inside `$TMPDIR` (the leading dot hides it in Finder). |
| 67 | + /// That dot trips our hidden-component check on the full path and |
| 68 | + /// shadows every other assertion in this file. Use a non-hidden |
| 69 | + /// prefix instead so positive-path assertions actually exercise the |
| 70 | + /// codepath we care about. |
| 71 | + fn unhidden_tempdir() -> TempDir { |
| 72 | + tempfile::Builder::new() |
| 73 | + .prefix("cmdr-downloads-test-") |
| 74 | + .tempdir() |
| 75 | + .unwrap() |
| 76 | + } |
| 77 | + |
| 78 | + fn touch(dir: &Path, name: &str) -> PathBuf { |
| 79 | + let p = dir.join(name); |
| 80 | + fs::write(&p, b"hi").unwrap(); |
| 81 | + p |
| 82 | + } |
| 83 | + |
| 84 | + #[test] |
| 85 | + fn rejects_hidden_basename() { |
| 86 | + let td = unhidden_tempdir(); |
| 87 | + let root = td.path(); |
| 88 | + let p = touch(root, ".DS_Store"); |
| 89 | + assert!(!is_eligible(&p)); |
| 90 | + } |
| 91 | + |
| 92 | + #[test] |
| 93 | + fn rejects_hidden_ancestor() { |
| 94 | + let td = unhidden_tempdir(); |
| 95 | + let root = td.path(); |
| 96 | + let sub = root.join(".tmp"); |
| 97 | + fs::create_dir(&sub).unwrap(); |
| 98 | + let p = touch(&sub, "foo.zip"); |
| 99 | + assert!(!is_eligible(&p)); |
| 100 | + } |
| 101 | + |
| 102 | + #[test] |
| 103 | + fn rejects_partial_suffixes() { |
| 104 | + let td = unhidden_tempdir(); |
| 105 | + let root = td.path(); |
| 106 | + for name in ["foo.crdownload", "bar.part", "baz.download"] { |
| 107 | + let p = touch(root, name); |
| 108 | + assert!(!is_eligible(&p), "expected {name} to be ineligible"); |
| 109 | + } |
| 110 | + } |
| 111 | + |
| 112 | + #[test] |
| 113 | + fn accepts_regular_file() { |
| 114 | + let td = unhidden_tempdir(); |
| 115 | + let root = td.path(); |
| 116 | + let p = touch(root, "foo.zip"); |
| 117 | + assert!(is_eligible(&p)); |
| 118 | + } |
| 119 | + |
| 120 | + #[test] |
| 121 | + fn rejects_directory() { |
| 122 | + let td = unhidden_tempdir(); |
| 123 | + let root = td.path(); |
| 124 | + let sub = root.join("subdir"); |
| 125 | + fs::create_dir(&sub).unwrap(); |
| 126 | + assert!(!is_eligible(&sub)); |
| 127 | + } |
| 128 | + |
| 129 | + #[test] |
| 130 | + fn accepts_partial_looking_subname() { |
| 131 | + // Only literal trailing suffixes match. A file named |
| 132 | + // `foo.crdownload.zip` is final (the partial suffix is in the middle, |
| 133 | + // not at the end). |
| 134 | + let td = unhidden_tempdir(); |
| 135 | + let root = td.path(); |
| 136 | + let p = touch(root, "foo.crdownload.zip"); |
| 137 | + assert!(is_eligible(&p)); |
| 138 | + } |
| 139 | + |
| 140 | + #[test] |
| 141 | + fn broken_symlink_returns_false_without_panic() { |
| 142 | + let td = unhidden_tempdir(); |
| 143 | + let root = td.path(); |
| 144 | + let link = root.join("dangling"); |
| 145 | + symlink(root.join("does-not-exist"), &link).unwrap(); |
| 146 | + assert!(!is_eligible(&link)); |
| 147 | + } |
| 148 | + |
| 149 | + #[test] |
| 150 | + fn case_sensitive_partial_suffix_check() { |
| 151 | + // Browsers emit lowercase only. Anything else is treated as a real |
| 152 | + // download (the user named it themselves). |
| 153 | + let td = unhidden_tempdir(); |
| 154 | + let root = td.path(); |
| 155 | + let p = touch(root, "foo.CRDOWNLOAD"); |
| 156 | + assert!(is_eligible(&p)); |
| 157 | + } |
| 158 | +} |
0 commit comments