Skip to main content

max / audiofiles

Storage: exclude loose-files samples from sync cloud_only/download, unify hash validator Loose-files-mode samples live at source_path and have no blob in the content store, so mark_cloud_only_samples and the download missing-blob query wrongly flagged them cloud_only=1 (un-downloadable). Add AND source_path IS NULL to both queries. Make SampleHash::validated reject uppercase to match store::validate_hash. Regression tests for both. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-21 23:39 UTC
Commit: df955126b780e525d8a5facdb1f4e441eac80ff3
Parent: e9d8ac2
3 files changed, +54 insertions, -5 deletions
@@ -80,10 +80,18 @@ impl SampleHash {
80 80 }
81 81
82 82 /// Validate and create from untrusted input. Returns an error if the hash
83 - /// is not exactly 64 hex characters.
83 + /// is not exactly 64 lowercase hex characters.
84 + ///
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.
84 88 pub fn validated(s: &str) -> std::result::Result<Self, &'static str> {
85 - if s.len() != 64 || !s.bytes().all(|b| b.is_ascii_hexdigit()) {
86 - return Err("hash must be 64 hex characters");
89 + if s.len() != 64
90 + || !s
91 + .bytes()
92 + .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
93 + {
94 + return Err("hash must be 64 lowercase hex characters");
87 95 }
88 96 Ok(Self(s.to_string()))
89 97 }
@@ -228,6 +236,13 @@ mod tests {
228 236 }
229 237
230 238 #[test]
239 + fn sample_hash_validated_rejects_uppercase() {
240 + // Agrees with store::validate_hash: hashes are always produced lowercase.
241 + let hex = "A".repeat(64);
242 + assert!(SampleHash::validated(&hex).is_err());
243 + }
244 +
245 + #[test]
231 246 fn sample_hash_serde_roundtrip() {
232 247 let h = SampleHash::new("test_hash");
233 248 let json = serde_json::to_string(&h).unwrap();
@@ -29,11 +29,14 @@ pub async fn download_missing_blobs(
29 29 let missing: Vec<(String, String)> = tokio::task::spawn_blocking(move || -> Result<_> {
30 30 let conn = open_conn(&p)?;
31 31 let mut stmt = conn.prepare(
32 + // `s.source_path IS NULL` excludes loose-files-mode samples: they
33 + // have no blob in the content store (they live at source_path), so
34 + // there is nothing to download for them.
32 35 "SELECT DISTINCT s.hash, s.file_extension
33 36 FROM samples s
34 37 JOIN vfs_nodes vn ON vn.sample_hash = s.hash
35 38 JOIN vfs v ON v.id = vn.vfs_id
36 - WHERE v.sync_files = 1",
39 + WHERE v.sync_files = 1 AND s.source_path IS NULL",
37 40 )?;
38 41 let rows: Vec<(String, String)> = stmt
39 42 .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
@@ -127,8 +127,11 @@ pub fn enforce_changelog_retention(conn: &Connection) -> Result<i64> {
127 127 /// applying_remote=1 to suppress changelog triggers.
128 128 #[instrument(skip_all)]
129 129 pub fn mark_cloud_only_samples(conn: &Connection, content_dir: &Path) -> Result<i64> {
130 + // `source_path IS NULL` excludes loose-files-mode samples: those live at
131 + // their original on-disk path and are never copied into `content_dir`, so a
132 + // missing blob there is expected and must not flip them to cloud_only.
130 133 let mut stmt = conn.prepare(
131 - "SELECT hash, file_extension FROM samples WHERE cloud_only = 0",
134 + "SELECT hash, file_extension FROM samples WHERE cloud_only = 0 AND source_path IS NULL",
132 135 )?;
133 136 let rows: Vec<(String, String)> = stmt
134 137 .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
@@ -924,6 +927,34 @@ mod tests {
924 927 }
925 928
926 929 #[test]
930 + fn mark_cloud_only_skips_loose_files_samples() {
931 + let db = setup_test_db();
932 + let conn = db.conn();
933 +
934 + // A loose-files sample has source_path set and no blob in content_dir —
935 + // it must NOT be flagged cloud_only just because the blob is absent.
936 + insert_sample(conn, "aaa", "kick.wav", "wav");
937 + conn.execute(
938 + "UPDATE samples SET source_path = '/music/kick.wav' WHERE hash = 'aaa'",
939 + [],
940 + )
941 + .unwrap();
942 +
943 + let tmp = tempfile::tempdir().unwrap();
944 + let marked = mark_cloud_only_samples(conn, tmp.path()).unwrap();
945 + assert_eq!(marked, 0, "loose-files sample must not be marked cloud_only");
946 +
947 + let aaa_co: i32 = conn
948 + .query_row(
949 + "SELECT cloud_only FROM samples WHERE hash = 'aaa'",
950 + [],
951 + |r| r.get(0),
952 + )
953 + .unwrap();
954 + assert_eq!(aaa_co, 0);
955 + }
956 +
957 + #[test]
927 958 fn mark_cloud_only_idempotent() {
928 959 let db = setup_test_db();
929 960 let conn = db.conn();