max / audiofiles
7 files changed,
+198 insertions,
-75 deletions
| @@ -607,7 +607,7 @@ impl BrowserState { | |||
| 607 | 607 | ||
| 608 | 608 | // Backfill: persist vectors + apply rules only — no review queue. | |
| 609 | 609 | if !self.backfill_in_progress { | |
| 610 | - | let hash = audiofiles_core::SampleHash::new(result.hash.clone()); | |
| 610 | + | let hash = audiofiles_core::SampleHash::from_trusted(result.hash.clone()); | |
| 611 | 611 | let name = self.backend.sample_original_name(&hash) | |
| 612 | 612 | .unwrap_or_else(|_| hash.to_string()); | |
| 613 | 613 |
| @@ -772,7 +772,7 @@ mod import_and_analysis { | |||
| 772 | 772 | current_name: "test.wav".to_string(), | |
| 773 | 773 | }; | |
| 774 | 774 | state.pending_review_items.push(ReviewItem { | |
| 775 | - | hash: audiofiles_core::SampleHash::new("abc"), | |
| 775 | + | hash: audiofiles_core::SampleHash::from_trusted("abc"), | |
| 776 | 776 | name: "test.wav".to_string(), | |
| 777 | 777 | result: audiofiles_core::analysis::AnalysisResult { | |
| 778 | 778 | hash: "abc".to_string(), |
| @@ -187,7 +187,7 @@ fn map_export_item(row: &rusqlite::Row) -> rusqlite::Result<Option<ExportItem>> | |||
| 187 | 187 | let source_path: Option<String> = row.get(8)?; | |
| 188 | 188 | ||
| 189 | 189 | Ok(Some(ExportItem { | |
| 190 | - | hash: crate::SampleHash::new(hash), | |
| 190 | + | hash: crate::SampleHash::from_trusted(hash), | |
| 191 | 191 | ext, | |
| 192 | 192 | relative_path: PathBuf::from(path), | |
| 193 | 193 | name, | |
| @@ -732,7 +732,7 @@ mod tests { | |||
| 732 | 732 | ||
| 733 | 733 | fn make_item(name: &str, ext: &str) -> ExportItem { | |
| 734 | 734 | ExportItem { | |
| 735 | - | hash: crate::SampleHash::new("deadbeef"), | |
| 735 | + | hash: crate::SampleHash::from_trusted("deadbeef"), | |
| 736 | 736 | ext: ext.to_string(), | |
| 737 | 737 | relative_path: PathBuf::from(name), | |
| 738 | 738 | name: name.to_string(), | |
| @@ -747,7 +747,7 @@ mod tests { | |||
| 747 | 747 | ||
| 748 | 748 | fn make_item_with_analysis(name: &str, ext: &str, bpm: f64, key: &str) -> ExportItem { | |
| 749 | 749 | ExportItem { | |
| 750 | - | hash: crate::SampleHash::new("deadbeef"), | |
| 750 | + | hash: crate::SampleHash::from_trusted("deadbeef"), | |
| 751 | 751 | ext: ext.to_string(), | |
| 752 | 752 | relative_path: PathBuf::from(name), | |
| 753 | 753 | name: name.to_string(), | |
| @@ -969,7 +969,7 @@ mod tests { | |||
| 969 | 969 | ||
| 970 | 970 | // Create an item with a hash that doesn't exist in the store | |
| 971 | 971 | let items = vec![ExportItem { | |
| 972 | - | hash: crate::SampleHash::new("nonexistent_hash_value"), | |
| 972 | + | hash: crate::SampleHash::from_trusted("nonexistent_hash_value"), | |
| 973 | 973 | ext: "wav".to_string(), | |
| 974 | 974 | relative_path: PathBuf::from("missing.wav"), | |
| 975 | 975 | name: "missing.wav".to_string(), |
| @@ -127,7 +127,7 @@ mod tests { | |||
| 127 | 127 | ||
| 128 | 128 | fn item(name: &str) -> ExportItem { | |
| 129 | 129 | ExportItem { | |
| 130 | - | hash: crate::SampleHash::new("a".repeat(64)), | |
| 130 | + | hash: crate::SampleHash::from_trusted("a".repeat(64)), | |
| 131 | 131 | ext: "wav".to_string(), | |
| 132 | 132 | relative_path: PathBuf::from(name), | |
| 133 | 133 | name: name.to_string(), |
| @@ -74,17 +74,23 @@ define_i64_id!(VfsId, NodeId, SmartFolderId, CollectionId); | |||
| 74 | 74 | pub struct SampleHash(String); | |
| 75 | 75 | ||
| 76 | 76 | impl SampleHash { | |
| 77 | - | /// Create from a trusted hash string (e.g. from the database or import pipeline). | |
| 78 | - | pub fn new(s: impl Into<String>) -> Self { | |
| 77 | + | /// Wrap a hash whose origin is already trusted to be a valid SHA-256 digest: | |
| 78 | + | /// the output of `hash_file`/the import pipeline, or a row read back from our | |
| 79 | + | /// own database. This skips validation by design — call it ONLY at those | |
| 80 | + | /// origins. Untrusted text (network, manifests, saved configs) must go through | |
| 81 | + | /// [`SampleHash::validated`] (or arrive via the validating `Deserialize` impl), | |
| 82 | + | /// which is the single gate that enforces the invariant. The name is the | |
| 83 | + | /// review smell: a `from_trusted` on a non-derived string is a bug. | |
| 84 | + | pub fn from_trusted(s: impl Into<String>) -> Self { | |
| 79 | 85 | Self(s.into()) | |
| 80 | 86 | } | |
| 81 | 87 | ||
| 82 | - | /// Validate and create from untrusted input. Returns an error if the hash | |
| 83 | - | /// is not exactly 64 lowercase hex characters. | |
| 88 | + | /// The sole validating constructor for untrusted input. Returns an error if | |
| 89 | + | /// the hash is not exactly 64 lowercase hex characters. | |
| 84 | 90 | /// | |
| 85 | - | /// Lowercase-only matches `store::validate_hash`: the app only ever produces | |
| 86 | - | /// lowercase hashes (`format!("{:x}")`), so the two validators agree on every | |
| 87 | - | /// input rather than diverging on uppercase. | |
| 91 | + | /// `store::validate_hash` delegates here, so there is one rule set rather than | |
| 92 | + | /// two that can drift. Lowercase-only matches how the app produces hashes | |
| 93 | + | /// (`format!("{:x}")`), so every real hash passes and uppercase is rejected. | |
| 88 | 94 | pub fn validated(s: &str) -> std::result::Result<Self, &'static str> { | |
| 89 | 95 | if s.len() != 64 | |
| 90 | 96 | || !s | |
| @@ -136,17 +142,10 @@ impl PartialEq<&str> for SampleHash { | |||
| 136 | 142 | } | |
| 137 | 143 | } | |
| 138 | 144 | ||
| 139 | - | impl From<String> for SampleHash { | |
| 140 | - | fn from(s: String) -> Self { | |
| 141 | - | Self(s) | |
| 142 | - | } | |
| 143 | - | } | |
| 144 | - | ||
| 145 | - | impl From<&str> for SampleHash { | |
| 146 | - | fn from(s: &str) -> Self { | |
| 147 | - | Self(s.to_string()) | |
| 148 | - | } | |
| 149 | - | } | |
| 145 | + | // No blanket `From<String>`/`From<&str>`: those let any raw string become a | |
| 146 | + | // `SampleHash` via `.into()`, silently bypassing validation. Untrusted text goes | |
| 147 | + | // through `validated` (or the validating `Deserialize` below); trusted origins use | |
| 148 | + | // `from_trusted` explicitly. | |
| 150 | 149 | ||
| 151 | 150 | impl serde::Serialize for SampleHash { | |
| 152 | 151 | fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> { | |
| @@ -155,8 +154,12 @@ impl serde::Serialize for SampleHash { | |||
| 155 | 154 | } | |
| 156 | 155 | ||
| 157 | 156 | impl<'de> serde::Deserialize<'de> for SampleHash { | |
| 157 | + | /// Validating: a `SampleHash` deserialized from a saved config / wire payload | |
| 158 | + | /// is the same untrusted boundary as `validated`, so it enforces the same | |
| 159 | + | /// 64-lowercase-hex invariant rather than trusting whatever bytes arrive. | |
| 158 | 160 | fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> { | |
| 159 | - | String::deserialize(deserializer).map(Self) | |
| 161 | + | let s = String::deserialize(deserializer)?; | |
| 162 | + | SampleHash::validated(&s).map_err(serde::de::Error::custom) | |
| 160 | 163 | } | |
| 161 | 164 | } | |
| 162 | 165 | ||
| @@ -167,6 +170,9 @@ impl rusqlite::types::ToSql for SampleHash { | |||
| 167 | 170 | } | |
| 168 | 171 | ||
| 169 | 172 | impl rusqlite::types::FromSql for SampleHash { | |
| 173 | + | /// Lax by design: rows come from our own database, which only ever stores | |
| 174 | + | /// validated hashes, so this is a trusted origin (cf. `from_trusted`). The | |
| 175 | + | /// untrusted gates are `validated` and `Deserialize`. | |
| 170 | 176 | fn column_result(value: rusqlite::types::ValueRef<'_>) -> rusqlite::types::FromSqlResult<Self> { | |
| 171 | 177 | String::column_result(value).map(Self) | |
| 172 | 178 | } | |
| @@ -213,7 +219,7 @@ mod tests { | |||
| 213 | 219 | ||
| 214 | 220 | #[test] | |
| 215 | 221 | fn sample_hash_deref() { | |
| 216 | - | let h = SampleHash::new("abc123"); | |
| 222 | + | let h = SampleHash::from_trusted("abc123"); | |
| 217 | 223 | assert_eq!(&*h, "abc123"); | |
| 218 | 224 | assert_eq!(h.as_str(), "abc123"); | |
| 219 | 225 | } | |
| @@ -244,15 +250,27 @@ mod tests { | |||
| 244 | 250 | ||
| 245 | 251 | #[test] | |
| 246 | 252 | fn sample_hash_serde_roundtrip() { | |
| 247 | - | let h = SampleHash::new("test_hash"); | |
| 253 | + | let h = SampleHash::from_trusted("a".repeat(64)); | |
| 248 | 254 | let json = serde_json::to_string(&h).unwrap(); | |
| 249 | 255 | let parsed: SampleHash = serde_json::from_str(&json).unwrap(); | |
| 250 | 256 | assert_eq!(h, parsed); | |
| 251 | 257 | } | |
| 252 | 258 | ||
| 253 | 259 | #[test] | |
| 260 | + | fn sample_hash_deserialize_rejects_invalid() { | |
| 261 | + | // The untrusted boundary enforces the invariant: a short/non-hex hash in | |
| 262 | + | // a saved config or wire payload fails to deserialize rather than landing | |
| 263 | + | // an invalid SampleHash in memory. | |
| 264 | + | assert!(serde_json::from_str::<SampleHash>("\"deadbeef\"").is_err()); | |
| 265 | + | let upper = format!("\"{}\"", "A".repeat(64)); | |
| 266 | + | assert!(serde_json::from_str::<SampleHash>(&upper).is_err()); | |
| 267 | + | let ok = format!("\"{}\"", "a".repeat(64)); | |
| 268 | + | assert!(serde_json::from_str::<SampleHash>(&ok).is_ok()); | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | #[test] | |
| 254 | 272 | fn sample_hash_eq_str() { | |
| 255 | - | let h = SampleHash::new("abc"); | |
| 273 | + | let h = SampleHash::from_trusted("abc"); | |
| 256 | 274 | assert_eq!(h, "abc"); | |
| 257 | 275 | assert_eq!(h, "abc"); | |
| 258 | 276 | } |
| @@ -73,16 +73,18 @@ pub fn validate_extension(ext: &str) -> Result<()> { | |||
| 73 | 73 | } | |
| 74 | 74 | ||
| 75 | 75 | /// Validate that a hash string is exactly 64 lowercase hex characters (SHA-256). | |
| 76 | + | /// | |
| 77 | + | /// Delegates to [`crate::SampleHash::validated`] so the rule lives in exactly one | |
| 78 | + | /// place — historically these were two parallel validators that could drift | |
| 79 | + | /// (e.g. on uppercase handling). | |
| 76 | 80 | pub fn validate_hash(hash: &str) -> Result<()> { | |
| 77 | - | if hash.len() != 64 || !hash.bytes().all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) | |
| 78 | - | { | |
| 79 | - | return Err(CoreError::HashInvalid(format!( | |
| 81 | + | crate::SampleHash::validated(hash).map(|_| ()).map_err(|_| { | |
| 82 | + | CoreError::HashInvalid(format!( | |
| 80 | 83 | "expected 64 lowercase hex chars, got {:?} ({} chars)", | |
| 81 | 84 | hash, | |
| 82 | 85 | hash.len() | |
| 83 | - | ))); | |
| 84 | - | } | |
| 85 | - | Ok(()) | |
| 86 | + | )) | |
| 87 | + | }) | |
| 86 | 88 | } | |
| 87 | 89 | ||
| 88 | 90 | /// Mark a stored blob read-only (best-effort, cross-platform). A failed chmod | |
| @@ -243,11 +245,7 @@ impl SampleStore { | |||
| 243 | 245 | pub fn sample_path(&self, hash: &str, ext: &str) -> Result<PathBuf> { | |
| 244 | 246 | validate_hash(hash)?; | |
| 245 | 247 | validate_extension(ext)?; | |
| 246 | - | if ext.is_empty() { | |
| 247 | - | Ok(self.root.join(hash)) | |
| 248 | - | } else { | |
| 249 | - | Ok(self.root.join(format!("{hash}.{ext}"))) | |
| 250 | - | } | |
| 248 | + | Ok(store_blob_path(&self.root, hash, ext)) | |
| 251 | 249 | } | |
| 252 | 250 | ||
| 253 | 251 | /// Remove a sample from store and database. CASCADE handles VFS/tag refs. | |
| @@ -593,26 +591,88 @@ pub fn sample_source_path(db: &Database, hash: &str) -> Result<Option<String>> { | |||
| 593 | 591 | }) | |
| 594 | 592 | } | |
| 595 | 593 | ||
| 594 | + | /// Where a sample's bytes live on disk. | |
| 595 | + | /// | |
| 596 | + | /// `Loose` is a loose-files sample (imported by reference): the bytes stay at the | |
| 597 | + | /// user's original `source_path`, never copied into the store. `Store` is a normal | |
| 598 | + | /// content-addressed blob. | |
| 599 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 600 | + | pub enum SampleLocation { | |
| 601 | + | /// Content-addressed store blob at `{store_root}/{hash}[.ext]`. | |
| 602 | + | Store(PathBuf), | |
| 603 | + | /// Loose-files sample at the user's original path. | |
| 604 | + | Loose(PathBuf), | |
| 605 | + | } | |
| 606 | + | ||
| 607 | + | impl SampleLocation { | |
| 608 | + | /// The resolved on-disk path, regardless of which kind. | |
| 609 | + | pub fn path(&self) -> &Path { | |
| 610 | + | match self { | |
| 611 | + | SampleLocation::Store(p) | SampleLocation::Loose(p) => p, | |
| 612 | + | } | |
| 613 | + | } | |
| 614 | + | ||
| 615 | + | /// Consume into the resolved path. | |
| 616 | + | pub fn into_path(self) -> PathBuf { | |
| 617 | + | match self { | |
| 618 | + | SampleLocation::Store(p) | SampleLocation::Loose(p) => p, | |
| 619 | + | } | |
| 620 | + | } | |
| 621 | + | } | |
| 622 | + | ||
| 623 | + | /// Build the content-addressed store blob path for `hash`+`ext` under | |
| 624 | + | /// `store_root`. The single place `{store_root}/{hash}[.ext]` is constructed — no | |
| 625 | + | /// caller hand-rolls this join (it once drifted in the VFS mirror). | |
| 626 | + | pub fn store_blob_path(store_root: &Path, hash: &str, ext: &str) -> PathBuf { | |
| 627 | + | if ext.is_empty() { | |
| 628 | + | store_root.join(hash) | |
| 629 | + | } else { | |
| 630 | + | store_root.join(format!("{hash}.{ext}")) | |
| 631 | + | } | |
| 632 | + | } | |
| 633 | + | ||
| 634 | + | /// Single source of truth for "where does this sample's bytes live": the | |
| 635 | + | /// loose-files `source_path` if the sample has one, otherwise the store blob. | |
| 636 | + | /// | |
| 637 | + | /// Every consumer that needs a sample's file — playback, preview, export, and the | |
| 638 | + | /// VFS mirror — resolves through here (directly or via [`resolve_file_path`]), so a | |
| 639 | + | /// loose-files sample is never pointed at a non-existent store blob. This is a pure | |
| 640 | + | /// mapping (no filesystem access); [`resolve_file_path`] adds the runtime | |
| 641 | + | /// existence-fallback on top. | |
| 642 | + | pub fn sample_location( | |
| 643 | + | store_root: &Path, | |
| 644 | + | hash: &str, | |
| 645 | + | ext: &str, | |
| 646 | + | source_path: Option<&str>, | |
| 647 | + | ) -> SampleLocation { | |
| 648 | + | match source_path { | |
| 649 | + | Some(sp) => SampleLocation::Loose(PathBuf::from(sp)), | |
| 650 | + | None => SampleLocation::Store(store_blob_path(store_root, hash, ext)), | |
| 651 | + | } | |
| 652 | + | } | |
| 653 | + | ||
| 596 | 654 | /// Resolve the actual file path for a sample, checking source_path first. | |
| 597 | 655 | /// | |
| 598 | - | /// For loose-files mode samples (source_path is set), returns the source path if | |
| 599 | - | /// the file exists, otherwise falls back to the store path. For normal samples, | |
| 600 | - | /// returns the store path directly. | |
| 656 | + | /// Builds on [`sample_location`] (the canonical loose-vs-store decision) and adds | |
| 657 | + | /// a runtime existence fallback: for a loose-files sample whose original file has | |
| 658 | + | /// moved, falls back to the store blob if one happens to exist, else returns the | |
| 659 | + | /// source path so the caller surfaces a clean "not found". | |
| 601 | 660 | pub fn resolve_file_path(store: &SampleStore, db: &Database, hash: &str, ext: &str) -> Result<PathBuf> { | |
| 602 | - | if let Some(sp) = sample_source_path(db, hash)? { | |
| 603 | - | let source = PathBuf::from(&sp); | |
| 604 | - | if source.exists() { | |
| 605 | - | return Ok(source); | |
| 606 | - | } | |
| 607 | - | // Fallback: maybe user re-imported in normal mode or placed file manually | |
| 608 | - | let store_path = store.sample_path(hash, ext)?; | |
| 609 | - | if store_path.exists() { | |
| 610 | - | return Ok(store_path); | |
| 661 | + | match sample_location(store.root(), hash, ext, sample_source_path(db, hash)?.as_deref()) { | |
| 662 | + | SampleLocation::Store(_) => store.sample_path(hash, ext), | |
| 663 | + | SampleLocation::Loose(source) => { | |
| 664 | + | if source.exists() { | |
| 665 | + | return Ok(source); | |
| 666 | + | } | |
| 667 | + | // Fallback: maybe user re-imported in normal mode or placed file manually. | |
| 668 | + | let store_path = store.sample_path(hash, ext)?; | |
| 669 | + | if store_path.exists() { | |
| 670 | + | return Ok(store_path); | |
| 671 | + | } | |
| 672 | + | // Return the source path anyway — caller will handle the "not found". | |
| 673 | + | Ok(source) | |
| 611 | 674 | } | |
| 612 | - | // Return the source path anyway — caller will handle the "not found" | |
| 613 | - | return Ok(source); | |
| 614 | 675 | } | |
| 615 | - | store.sample_path(hash, ext) | |
| 616 | 676 | } | |
| 617 | 677 | ||
| 618 | 678 | /// Update the source_path for a sample after verifying the new file's hash matches. |
| @@ -1,5 +1,7 @@ | |||
| 1 | 1 | //! VFS symlink mirror: maintains a real directory tree mirroring the VFS | |
| 2 | - | //! with friendly-named symlinks pointing into the content-addressed store. | |
| 2 | + | //! with friendly-named symlinks pointing at each sample's real bytes — the | |
| 3 | + | //! content-addressed store blob for normal samples, the user's original | |
| 4 | + | //! `source_path` for loose-files samples (resolved via `store::sample_location`). | |
| 3 | 5 | //! | |
| 4 | 6 | //! DAWs and file managers can browse the mirror directory directly. | |
| 5 | 7 | //! Unix only (macOS + Linux). Windows skipped (symlinks need Developer Mode). | |
| @@ -11,7 +13,7 @@ use tracing::{debug, instrument, warn}; | |||
| 11 | 13 | ||
| 12 | 14 | use crate::db::Database; | |
| 13 | 15 | use crate::error::{io_err, Result}; | |
| 14 | - | use crate::store::sample_extension; | |
| 16 | + | use crate::store::{sample_extension, sample_location, sample_source_path}; | |
| 15 | 17 | use crate::vfs::{list_full_tree, NodeType}; | |
| 16 | 18 | ||
| 17 | 19 | /// Configuration for the mirror directory. | |
| @@ -45,12 +47,12 @@ pub fn sync_mirror(db: &Database, config: &MirrorConfig) -> Result<MirrorStats> | |||
| 45 | 47 | // Collect existing mirror entries so we can remove stale ones later. | |
| 46 | 48 | let mut existing = collect_existing_entries(&config.mirror_root); | |
| 47 | 49 | ||
| 48 | - | // Build sample hash → extension map (batch query to avoid N+1). | |
| 50 | + | // Build sample hash → (extension, source_path) map (batch query to avoid N+1). | |
| 49 | 51 | let hashes: Vec<&str> = tree | |
| 50 | 52 | .iter() | |
| 51 | 53 | .filter_map(|n| n.sample_hash.as_deref()) | |
| 52 | 54 | .collect(); | |
| 53 | - | let extensions = batch_extensions(db, &hashes); | |
| 55 | + | let locations = batch_locations(db, &hashes); | |
| 54 | 56 | ||
| 55 | 57 | for node in &tree { | |
| 56 | 58 | let sanitized = sanitize_path(&node.path); | |
| @@ -69,24 +71,26 @@ pub fn sync_mirror(db: &Database, config: &MirrorConfig) -> Result<MirrorStats> | |||
| 69 | 71 | } | |
| 70 | 72 | NodeType::Sample => { | |
| 71 | 73 | if let Some(ref hash) = node.sample_hash { | |
| 72 | - | let ext = extensions | |
| 74 | + | let (ext, source) = locations | |
| 73 | 75 | .iter() | |
| 74 | - | .find(|(h, _)| h.as_str() == hash.as_str()) | |
| 75 | - | .map(|(_, e)| e.as_str()) | |
| 76 | - | .unwrap_or(""); | |
| 77 | - | ||
| 78 | - | let store_file = if ext.is_empty() { | |
| 79 | - | config.store_root.join(hash.as_str()) | |
| 80 | - | } else { | |
| 81 | - | config.store_root.join(format!("{}.{}", hash, ext)) | |
| 82 | - | }; | |
| 76 | + | .find(|(h, _, _)| h.as_str() == hash.as_str()) | |
| 77 | + | .map(|(_, e, s)| (e.as_str(), s.as_deref())) | |
| 78 | + | .unwrap_or(("", None)); | |
| 79 | + | ||
| 80 | + | // Resolve the real on-disk location via the shared primitive: | |
| 81 | + | // loose-files samples point at their source_path, normal | |
| 82 | + | // samples at the store blob. Hand-rolling `store_root.join` | |
| 83 | + | // here is exactly what produced dangling symlinks for | |
| 84 | + | // loose-files samples (the blob never exists for them). | |
| 85 | + | let target = | |
| 86 | + | sample_location(&config.store_root, hash, ext, source).into_path(); | |
| 83 | 87 | ||
| 84 | 88 | // Two distinct samples can sanitize to the same display | |
| 85 | 89 | // path in one directory. The first wins the bare name; the | |
| 86 | 90 | // rest get a disambiguating suffix so no placement is | |
| 87 | - | // silently dropped. A link that already points at our blob | |
| 91 | + | // silently dropped. A link that already points at our target | |
| 88 | 92 | // is left as-is. | |
| 89 | - | let link_path = resolve_link_path(&full_path, &store_file); | |
| 93 | + | let link_path = resolve_link_path(&full_path, &target); | |
| 90 | 94 | ||
| 91 | 95 | // Remove from stale set. | |
| 92 | 96 | existing.remove(&link_path); | |
| @@ -98,7 +102,7 @@ pub fn sync_mirror(db: &Database, config: &MirrorConfig) -> Result<MirrorStats> | |||
| 98 | 102 | std::fs::create_dir_all(parent) | |
| 99 | 103 | .map_err(|e| io_err(parent, e))?; | |
| 100 | 104 | } | |
| 101 | - | create_symlink(&store_file, &link_path)?; | |
| 105 | + | create_symlink(&target, &link_path)?; | |
| 102 | 106 | stats.links_created += 1; | |
| 103 | 107 | } | |
| 104 | 108 | } | |
| @@ -209,15 +213,20 @@ fn sanitize_component(name: &str) -> String { | |||
| 209 | 213 | s | |
| 210 | 214 | } | |
| 211 | 215 | ||
| 212 | - | /// Batch-query file extensions for a set of sample hashes. | |
| 213 | - | fn batch_extensions(db: &Database, hashes: &[&str]) -> Vec<(String, String)> { | |
| 216 | + | /// Batch-query (extension, source_path) for a set of sample hashes. | |
| 217 | + | /// | |
| 218 | + | /// `source_path` is `Some` only for loose-files samples; the mirror needs it to | |
| 219 | + | /// point the symlink at the user's original file rather than a store blob that | |
| 220 | + | /// was never written. | |
| 221 | + | fn batch_locations(db: &Database, hashes: &[&str]) -> Vec<(String, String, Option<String>)> { | |
| 214 | 222 | let mut result = Vec::with_capacity(hashes.len()); | |
| 215 | 223 | // Deduplicate to avoid redundant queries. | |
| 216 | 224 | let mut seen = HashSet::new(); | |
| 217 | 225 | for &hash in hashes { | |
| 218 | 226 | if seen.insert(hash) | |
| 219 | 227 | && let Ok(ext) = sample_extension(db, hash) { | |
| 220 | - | result.push((hash.to_string(), ext)); | |
| 228 | + | let source = sample_source_path(db, hash).ok().flatten(); | |
| 229 | + | result.push((hash.to_string(), ext, source)); | |
| 221 | 230 | } | |
| 222 | 231 | } | |
| 223 | 232 | result | |
| @@ -363,6 +372,42 @@ mod tests { | |||
| 363 | 372 | ||
| 364 | 373 | #[cfg(unix)] | |
| 365 | 374 | #[test] | |
| 375 | + | fn loose_files_sample_symlinks_to_source_path() { | |
| 376 | + | // Regression: the mirror used to hand-roll `store_root.join(hash)` for | |
| 377 | + | // every sample, producing a dangling symlink for loose-files samples | |
| 378 | + | // (their bytes live at source_path, never in the store). It must now | |
| 379 | + | // resolve through `sample_location` and point at the source file. | |
| 380 | + | let (db, mirror_dir, store_dir) = setup(); | |
| 381 | + | let src = TempDir::new().unwrap(); | |
| 382 | + | let source_file = src.path().join("original_kick.wav"); | |
| 383 | + | std::fs::write(&source_file, b"loose audio").unwrap(); | |
| 384 | + | ||
| 385 | + | let hash = "a".repeat(64); | |
| 386 | + | insert_fake_sample(&db, &hash); | |
| 387 | + | db.conn() | |
| 388 | + | .execute( | |
| 389 | + | "UPDATE samples SET source_path = ?1 WHERE hash = ?2", | |
| 390 | + | rusqlite::params![source_file.to_string_lossy(), hash], | |
| 391 | + | ) | |
| 392 | + | .unwrap(); | |
| 393 | + | // Deliberately do NOT create a store blob — there isn't one for loose files. | |
| 394 | + | ||
| 395 | + | let vfs_id = create_vfs(&db, "Library").unwrap(); | |
| 396 | + | create_sample_link(&db, vfs_id, None, "kick.wav", &hash).unwrap(); | |
| 397 | + | ||
| 398 | + | let config = make_config(&mirror_dir, &store_dir); | |
| 399 | + | let stats = sync_mirror(&db, &config).unwrap(); | |
| 400 | + | assert_eq!(stats.links_created, 1); | |
| 401 | + | ||
| 402 | + | let link = mirror_dir.path().join("Library/kick.wav"); | |
| 403 | + | let target = std::fs::read_link(&link).unwrap(); | |
| 404 | + | assert_eq!(target, source_file, "symlink must point at the loose source"); | |
| 405 | + | // And the link must not dangle. | |
| 406 | + | assert!(link.exists(), "loose-files symlink should resolve, not dangle"); | |
| 407 | + | } | |
| 408 | + | ||
| 409 | + | #[cfg(unix)] | |
| 410 | + | #[test] | |
| 366 | 411 | fn stale_entries_removed() { | |
| 367 | 412 | let (db, mirror_dir, store_dir) = setup(); | |
| 368 | 413 | insert_fake_sample(&db, "abc123"); |