max / audiofiles
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(); |