refactor(rust): move inline tests and the test-only hires decoder into submodules

Inline test modules of tags/write.rs, lyrics/payloads.rs and
extensions/src/files.rs become sibling tests.rs files, and the
cfg(test) reference decoder of media/hires moves into hires/reference.rs.
The artist-mode helpers of tags/write.rs move to write/artists.rs.
No behavior or public API change; the supplement_tests module of
payloads.rs is now named tests.
This commit is contained in:
zarzet committed 2026-10-18 23:28:19 +07:00
1 parent 92adce43ef
commit 3f904004ff
9 files changed
+1243 -1242

No files matched your search

+1 -376
View File
@@ -662,379 +662,4 @@ pub fn format_kpoe(response: &KpoeResponse, multi_person: bool, word_timing: boo
}
#[cfg(test)]
mod supplement_tests {
use super::*;
#[test]
fn spotiflac_apple_preserves_syllables_and_separate_background_groups() {
let raw = serde_json::json!({
"type": "Syllable",
"elrcMultiPerson": "[00:01.00]Lead\n[00:02.00]Echo",
"content": [{
"timestamp": 1009, "endtime": 4000, "agent": "v3", "oppositeTurn": true,
"text": [
{"text": "To", "part": true, "timestamp": 1009, "endtime": 1500},
{"text": "gether", "timestamp": 1500, "endtime": 4000}
],
"backgroundLines": [
{"timestamp": 2000, "endtime": 2500, "text": [
{"text": "Echo", "timestamp": 2000, "endtime": 2500}
]},
{"timestamp": 3000, "endtime": 3500, "text": [
{"text": "Again", "timestamp": 3000, "endtime": 3500}
]}
]
}]
})
.to_string();
for words in [false, true] {
let text = format_spotiflac_apple(&raw, true, words).unwrap();
assert!(text.starts_with("[00:01.009]v1:"));
assert_eq!(text.matches("[bg:").count(), 2);
let lyrics = LyricsResponse::from_text(
&text,
"SpotiFLAC-Mobile Lyrics",
"SpotiFLAC-Mobile Lyrics",
);
assert_eq!(lyrics.lines().len(), 1);
assert_eq!(lyrics.lines()[0].start_time_ms, 1009);
if words {
assert!(text.contains("To<00:01.50><00:01.50>gether"));
} else {
assert!(text.contains("Together"));
}
let solo = format_spotiflac_apple(&raw, false, words).unwrap();
assert!(!solo.contains("[bg:"));
assert!(!solo.contains("v1:"));
}
}
#[test]
fn spotiflac_apple_handles_line_only_unsynced_and_invalid_responses() {
let mut raw = serde_json::json!({
"type": "Line", "content": [{
"timestamp": 1500, "text": [{"text": "Whole"}, {"text": "line"}]
}]
});
assert_eq!(
format_spotiflac_apple(&raw.to_string(), false, true).unwrap(),
"[00:01.50]Whole line"
);
raw["type"] = "Unsynced".into();
raw["content"][0]["timestamp"] = serde_json::Value::Null;
let text = format_spotiflac_apple(&raw.to_string(), true, true).unwrap();
assert_eq!(text, "Whole line");
assert_eq!(
LyricsResponse::from_text(&text, "Example", "Example").sync_type,
"UNSYNCED"
);
for raw in [
"<html>Error</html>",
r#"{"error":{"code":"APPLE_AUTH_REQUIRED"}}"#,
"{}",
] {
assert!(format_spotiflac_apple(raw, true, true).is_err());
}
}
#[test]
fn apple_agents_restore_vocal_sides_without_reformatting_word_times() {
let mut raw = serde_json::json!({
"type": "Syllable",
"elrcMultiPerson": "[00:01.01]v1: <00:01.009>Lead<00:02.00>\n[bg:<00:01.50>Echo<00:02.50>]\n[00:03.00]v2: <00:03.00>Guest<00:04.00>\n[00:05.00]v2: <00:05.00>Together<00:06.00>\n[00:07.00]v2: Third",
"content": [
{"timestamp": 1009, "agent": "lead"},
{"timestamp": 3000, "agent": "guest"},
{"timestamp": 5000, "agent": "all"},
{"timestamp": 7000, "agent": "third"}
],
"metadata": {"agents": [
{"id": "lead", "type": "person"},
{"id": "guest", "type": "person"},
{"id": "all", "type": "group"},
{"id": "third", "type": "person"}
]}
});
let text = format_apple(&raw.to_string(), true, true).unwrap();
assert_eq!(
text,
"[00:01.01]v1: <00:01.009>Lead<00:02.00>\n[bg:<00:01.50>Echo<00:02.50>]\n[00:03.00]v2: <00:03.00>Guest<00:04.00>\n[00:05.00]v1: <00:05.00>Together<00:06.00>\n[00:07.00]v3: Third"
);
let lyrics = LyricsResponse::from_text(&text, "Apple Music", "Apple Music");
let stored = lrc::with_metadata(&lyrics, "Track", "Artist");
assert!(stored.contains("[00:05.00]v1: <00:05.00>Together<00:06.00>"));
assert!(stored.contains("[bg:<00:01.50>Echo<00:02.50>]"));
// Some responses carry vocal labels only in the regular eLRC field.
// It must use the same agent correction as the multi-person field.
raw["elrc"] = raw
.as_object_mut()
.unwrap()
.remove("elrcMultiPerson")
.unwrap();
assert_eq!(format_apple(&raw.to_string(), true, true).unwrap(), text);
assert_eq!(
format_apple(&raw.to_string(), false, true).unwrap(),
raw["elrc"].as_str().unwrap()
);
}
#[test]
fn apple_v3_does_not_depend_on_agent_order_or_group_metadata() {
for agents in [
serde_json::Value::Null,
serde_json::json!([
{"id": "v1", "type": "person"},
{"id": "v3", "type": "person"},
{"id": "v2", "type": "person"}
]),
] {
let raw = serde_json::json!({
"type": "Syllable",
"elrcMultiPerson": "[00:01.00]v2:Together\n[00:02.00]v1:Guest",
"content": [{"timestamp": 1000, "agent": "v3"}, {"timestamp": 2000, "agent": "v2"}],
"metadata": {"agents": agents}
});
assert_eq!(
format_apple(&raw.to_string(), true, true).unwrap(),
"[00:01.00]v1:Together\n[00:02.00]v2:Guest"
);
}
}
#[test]
fn apple_content_fallback_honors_agents_only_when_multi_person_is_enabled() {
let raw = serde_json::json!({
"type": "Syllable",
"content": [{"timestamp": 1000, "oppositeTurn": true, "agent": "group", "text": [
{"text": "Together", "timestamp": 1000, "endtime": 2000}
]}],
"metadata": {"agents": [{"id": "group", "type": "group"}]}
})
.to_string();
for timing in [false, true] {
assert!(
format_apple(&raw, true, timing)
.unwrap()
.starts_with("[00:01.00]v1:")
);
assert!(!format_apple(&raw, false, timing).unwrap().contains("v1:"));
assert!(!format_apple(&raw, false, timing).unwrap().contains("v2:"));
}
}
#[test]
fn apple_voice_correction_preserves_unknown_or_ambiguous_lines() {
let text = "[00:01.00]v2: Unknown\n[00:02.00]v2: Too far\n[00:03.00]v2: Ambiguous\n[00:04.00]v2: Exact";
let mut raw = serde_json::json!({"elrcMultiPerson": text});
assert_eq!(format_apple(&raw.to_string(), true, true).unwrap(), text);
raw["metadata"] = serde_json::json!({"agents": [
{"id": "v1", "type": "person"}, {"id": "v2", "type": "person"},
{"id": "v3", "type": "group"}
]});
raw["content"] = serde_json::json!([
{"timestamp": 1000, "agent": "unknown"},
{"timestamp": 2011, "agent": "v3"},
{"timestamp": 3000, "agent": "v3"},
{"timestamp": 3000, "agent": "v2"},
{"timestamp": 3999, "agent": "v2"},
{"timestamp": 4000, "agent": "v3"}
]);
assert_eq!(
format_apple(&raw.to_string(), true, true).unwrap(),
text.replace("[00:04.00]v2:", "[00:04.00]v1:")
);
}
fn payload(lang: &str) -> serde_json::Value {
serde_json::json!({
"type": "Syllable",
"elrc": "[00:01.010]<00:01.009>Original <00:01.307>\n[00:02.000]Next",
"content": [{"timestamp": 1009, "text": [
{"text": "Original", "timestamp": 1009, "endtime": 1307}
]}, {"timestamp": 2001, "text": [{"text": "Next"}]}],
"metadata": {
"language": lang,
"transliterations": [{"lang": format!("{lang}-Latn"), "lines": [
{"timestamp": 1009, "text": "Romanized"},
{"timestamp": 2001, "text": "Next romanized"}
]}],
"translations": [{"lang": "en-US", "lines": [
{"timestamp": 1009, "text": "English"},
{"timestamp": 2001, "text": "Next English"}
]}]
}
})
}
#[test]
fn supplements_follow_output_timestamps_in_both_apple_modes() {
for lang in ["ja", "ko", "zh-Hans"] {
for word_timing in [false, true] {
let raw = payload(lang).to_string();
let text = format_apple(&raw, false, word_timing).unwrap();
let mut lyrics = LyricsResponse::from_text(&text, "Apple Music", "Apple Music");
apple_supplements(&raw, &mut lyrics);
assert_eq!(lyrics.lines()[0].romanization.as_deref(), Some("Romanized"));
assert_eq!(
lyrics.lines()[1].translation.as_deref(),
Some("Next English")
);
let start = if word_timing { 1010 } else { 1009 };
assert_eq!(lyrics.lines()[0].start_time_ms, start);
let exported = lrc::with_metadata(&lyrics, "Track", "Artist");
assert!(exported.contains(&format!("[x-romaji:{start}:Um9tYW5pemVk]")));
assert!(exported.contains(&lrc::timestamp(start)));
if word_timing {
assert!(exported.contains("<00:01.307>"));
}
// Persistence uses this same JSON response contract.
let restored: LyricsResponse =
serde_json::from_str(&serde_json::to_string(&lyrics).unwrap()).unwrap();
assert_eq!(restored, lyrics);
}
}
}
#[test]
fn nearest_matching_is_bounded_and_exact_matches_win() {
let mut raw = payload("ja");
raw["metadata"]["transliterations"][0]["lines"] = serde_json::json!([
{"timestamp": 1009, "text": "Rounded"},
{"timestamp": 1010, "text": "Exact"},
{"timestamp": 1001, "text": "Less accurate"},
{"timestamp": 2011, "text": "Too far"},
{"timestamp": 2990, "text": "Boundary"},
{"timestamp": 4005, "text": "Tie"}
]);
let mut lyrics = LyricsResponse::from_text(
"[00:01.010]A\n[00:02.00]B\n[00:03.00]C\n[00:04.00]D\n[00:04.010]E",
"",
"",
);
apple_supplements(&raw.to_string(), &mut lyrics);
let actual: Vec<_> = lyrics
.lines()
.iter()
.map(|line| line.romanization.as_deref())
.collect();
assert_eq!(
actual,
[Some("Exact"), None, Some("Boundary"), Some("Tie"), None]
);
}
#[test]
fn malformed_optional_lines_do_not_hide_other_supplements() {
let mut raw = payload("ja");
raw["metadata"]["transliterations"][0]["lines"] = serde_json::json!([
null, {"text": "Missing time"}, {"timestamp": "1009", "text": "Bad time"},
{"timestamp": 1009, "text": []}, {"timestamp": -1, "text": "Negative"},
{"timestamp": 2001, "text": " Next romanized "}
]);
let mut lyrics = LyricsResponse::from_text("[00:01.01]A\n[00:02.00]B", "", "");
apple_supplements(&raw.to_string(), &mut lyrics);
assert!(lyrics.lines()[0].romanization.is_none());
assert_eq!(lyrics.lines()[0].translation.as_deref(), Some("English"));
assert_eq!(
lyrics.lines()[1].romanization.as_deref(),
Some("Next romanized")
);
}
#[test]
fn language_selection_prefers_song_language_and_usable_groups() {
let mut raw = payload("ja");
raw["metadata"]["transliterations"] = serde_json::json!([
{"lang": "ja-Latn", "lines": []},
{"lang": "ko-Latn", "lines": [{"timestamp": 1009, "text": "Other language"}]},
{"lang": "ja-Kana", "lines": [{"timestamp": 1009, "text": "Not Latin"}]},
{"lang": "ja-Latn", "lines": [{"timestamp": 1009, "text": "Romanized"}]}
]);
raw["metadata"]["translations"] = serde_json::json!([
{"lang": "en", "lines": null},
{"lang": "fr", "lines": [{"timestamp": 1009, "text": "French"}]},
{"lang": "en-GB", "lines": [{"timestamp": 1009, "text": "English"}]}
]);
let mut lyrics = LyricsResponse::from_text("[00:01.01]Original", "", "");
apple_supplements(&raw.to_string(), &mut lyrics);
assert_eq!(lyrics.lines()[0].romanization.as_deref(), Some("Romanized"));
assert_eq!(lyrics.lines()[0].translation.as_deref(), Some("English"));
}
#[test]
fn absent_or_invalid_metadata_preserves_original_lyrics() {
for raw in [
"{}",
"not json",
r#"{"metadata":null}"#,
r#"{"metadata":{"translations":5}}"#,
] {
let mut lyrics = LyricsResponse::from_text("[00:01.00]Original", "", "");
let original = lyrics.clone();
apple_supplements(raw, &mut lyrics);
assert_eq!(lyrics, original);
}
let mut plain = LyricsResponse::from_text("Original", "", "");
apple_supplements(&payload("ja").to_string(), &mut plain);
assert!(plain.lines()[0].romanization.is_none());
}
#[test]
fn romanization_spans_keep_syllables_spaces_and_original_word_times() {
let mut raw = payload("ja");
raw["metadata"]["transliterations"][0]["lines"][0] = serde_json::json!({
"timestamp": 1009, "text": "Roma nized", "spans": [
{"text": "Ro", "begin": 1009, "end": 1107},
{"text": "ma", "begin": 1107, "end": 1307},
{"text": "nized", "begin": 2003, "end": 2497}
]
});
let mut lyrics = LyricsResponse::from_text("[00:01.010]Original", "", "");
apple_supplements(&raw.to_string(), &mut lyrics);
let words = lyrics.lines()[0].romanization_words.as_ref().unwrap();
assert_eq!(
words
.iter()
.map(|word| word.text.as_str())
.collect::<Vec<_>>(),
["Ro", "ma ", "nized"]
);
assert_eq!(words[0].start_time_ms, 1009);
assert_eq!(words[1].end_time_ms, 1307);
assert_eq!(words[2].start_time_ms, 2003);
assert_eq!(words[2].end_time_ms, 2497);
let output = lrc::with_metadata(&lyrics, "Track", "Artist");
let encoded = output
.lines()
.find_map(|line| {
line.strip_prefix("[x-romaji-words:1010:")
.and_then(|line| line.strip_suffix(']'))
})
.unwrap();
use base64::Engine;
let decoded = base64::engine::general_purpose::STANDARD
.decode(encoded)
.unwrap();
let restored: Vec<LyricsWord> = serde_json::from_slice(&decoded).unwrap();
assert_eq!(&restored, words);
}
#[test]
fn invalid_romanization_timing_falls_back_to_readable_text() {
for spans in [
serde_json::json!([{"text": "Other", "begin": 1009, "end": 1307}]),
serde_json::json!([{"text": "Romanized", "begin": 1009, "end": 900}]),
serde_json::json!([{"text": "Romanized", "begin": 1009}]),
serde_json::json!([{"text": "Ro", "begin": 1009, "end": 1307}]),
] {
let mut raw = payload("ja");
raw["metadata"]["transliterations"][0]["lines"][0]["spans"] = spans;
let mut lyrics = LyricsResponse::from_text("[00:01.010]Original", "", "");
apple_supplements(&raw.to_string(), &mut lyrics);
assert_eq!(lyrics.lines()[0].romanization.as_deref(), Some("Romanized"));
assert!(lyrics.lines()[0].romanization_words.is_none());
}
}
}
mod tests;
@@ -0,0 +1,371 @@
use super::*;
#[test]
fn spotiflac_apple_preserves_syllables_and_separate_background_groups() {
let raw = serde_json::json!({
"type": "Syllable",
"elrcMultiPerson": "[00:01.00]Lead\n[00:02.00]Echo",
"content": [{
"timestamp": 1009, "endtime": 4000, "agent": "v3", "oppositeTurn": true,
"text": [
{"text": "To", "part": true, "timestamp": 1009, "endtime": 1500},
{"text": "gether", "timestamp": 1500, "endtime": 4000}
],
"backgroundLines": [
{"timestamp": 2000, "endtime": 2500, "text": [
{"text": "Echo", "timestamp": 2000, "endtime": 2500}
]},
{"timestamp": 3000, "endtime": 3500, "text": [
{"text": "Again", "timestamp": 3000, "endtime": 3500}
]}
]
}]
})
.to_string();
for words in [false, true] {
let text = format_spotiflac_apple(&raw, true, words).unwrap();
assert!(text.starts_with("[00:01.009]v1:"));
assert_eq!(text.matches("[bg:").count(), 2);
let lyrics =
LyricsResponse::from_text(&text, "SpotiFLAC-Mobile Lyrics", "SpotiFLAC-Mobile Lyrics");
assert_eq!(lyrics.lines().len(), 1);
assert_eq!(lyrics.lines()[0].start_time_ms, 1009);
if words {
assert!(text.contains("To<00:01.50><00:01.50>gether"));
} else {
assert!(text.contains("Together"));
}
let solo = format_spotiflac_apple(&raw, false, words).unwrap();
assert!(!solo.contains("[bg:"));
assert!(!solo.contains("v1:"));
}
}
#[test]
fn spotiflac_apple_handles_line_only_unsynced_and_invalid_responses() {
let mut raw = serde_json::json!({
"type": "Line", "content": [{
"timestamp": 1500, "text": [{"text": "Whole"}, {"text": "line"}]
}]
});
assert_eq!(
format_spotiflac_apple(&raw.to_string(), false, true).unwrap(),
"[00:01.50]Whole line"
);
raw["type"] = "Unsynced".into();
raw["content"][0]["timestamp"] = serde_json::Value::Null;
let text = format_spotiflac_apple(&raw.to_string(), true, true).unwrap();
assert_eq!(text, "Whole line");
assert_eq!(
LyricsResponse::from_text(&text, "Example", "Example").sync_type,
"UNSYNCED"
);
for raw in [
"<html>Error</html>",
r#"{"error":{"code":"APPLE_AUTH_REQUIRED"}}"#,
"{}",
] {
assert!(format_spotiflac_apple(raw, true, true).is_err());
}
}
#[test]
fn apple_agents_restore_vocal_sides_without_reformatting_word_times() {
let mut raw = serde_json::json!({
"type": "Syllable",
"elrcMultiPerson": "[00:01.01]v1: <00:01.009>Lead<00:02.00>\n[bg:<00:01.50>Echo<00:02.50>]\n[00:03.00]v2: <00:03.00>Guest<00:04.00>\n[00:05.00]v2: <00:05.00>Together<00:06.00>\n[00:07.00]v2: Third",
"content": [
{"timestamp": 1009, "agent": "lead"},
{"timestamp": 3000, "agent": "guest"},
{"timestamp": 5000, "agent": "all"},
{"timestamp": 7000, "agent": "third"}
],
"metadata": {"agents": [
{"id": "lead", "type": "person"},
{"id": "guest", "type": "person"},
{"id": "all", "type": "group"},
{"id": "third", "type": "person"}
]}
});
let text = format_apple(&raw.to_string(), true, true).unwrap();
assert_eq!(
text,
"[00:01.01]v1: <00:01.009>Lead<00:02.00>\n[bg:<00:01.50>Echo<00:02.50>]\n[00:03.00]v2: <00:03.00>Guest<00:04.00>\n[00:05.00]v1: <00:05.00>Together<00:06.00>\n[00:07.00]v3: Third"
);
let lyrics = LyricsResponse::from_text(&text, "Apple Music", "Apple Music");
let stored = lrc::with_metadata(&lyrics, "Track", "Artist");
assert!(stored.contains("[00:05.00]v1: <00:05.00>Together<00:06.00>"));
assert!(stored.contains("[bg:<00:01.50>Echo<00:02.50>]"));
// Some responses carry vocal labels only in the regular eLRC field.
// It must use the same agent correction as the multi-person field.
raw["elrc"] = raw
.as_object_mut()
.unwrap()
.remove("elrcMultiPerson")
.unwrap();
assert_eq!(format_apple(&raw.to_string(), true, true).unwrap(), text);
assert_eq!(
format_apple(&raw.to_string(), false, true).unwrap(),
raw["elrc"].as_str().unwrap()
);
}
#[test]
fn apple_v3_does_not_depend_on_agent_order_or_group_metadata() {
for agents in [
serde_json::Value::Null,
serde_json::json!([
{"id": "v1", "type": "person"},
{"id": "v3", "type": "person"},
{"id": "v2", "type": "person"}
]),
] {
let raw = serde_json::json!({
"type": "Syllable",
"elrcMultiPerson": "[00:01.00]v2:Together\n[00:02.00]v1:Guest",
"content": [{"timestamp": 1000, "agent": "v3"}, {"timestamp": 2000, "agent": "v2"}],
"metadata": {"agents": agents}
});
assert_eq!(
format_apple(&raw.to_string(), true, true).unwrap(),
"[00:01.00]v1:Together\n[00:02.00]v2:Guest"
);
}
}
#[test]
fn apple_content_fallback_honors_agents_only_when_multi_person_is_enabled() {
let raw = serde_json::json!({
"type": "Syllable",
"content": [{"timestamp": 1000, "oppositeTurn": true, "agent": "group", "text": [
{"text": "Together", "timestamp": 1000, "endtime": 2000}
]}],
"metadata": {"agents": [{"id": "group", "type": "group"}]}
})
.to_string();
for timing in [false, true] {
assert!(
format_apple(&raw, true, timing)
.unwrap()
.starts_with("[00:01.00]v1:")
);
assert!(!format_apple(&raw, false, timing).unwrap().contains("v1:"));
assert!(!format_apple(&raw, false, timing).unwrap().contains("v2:"));
}
}
#[test]
fn apple_voice_correction_preserves_unknown_or_ambiguous_lines() {
let text = "[00:01.00]v2: Unknown\n[00:02.00]v2: Too far\n[00:03.00]v2: Ambiguous\n[00:04.00]v2: Exact";
let mut raw = serde_json::json!({"elrcMultiPerson": text});
assert_eq!(format_apple(&raw.to_string(), true, true).unwrap(), text);
raw["metadata"] = serde_json::json!({"agents": [
{"id": "v1", "type": "person"}, {"id": "v2", "type": "person"},
{"id": "v3", "type": "group"}
]});
raw["content"] = serde_json::json!([
{"timestamp": 1000, "agent": "unknown"},
{"timestamp": 2011, "agent": "v3"},
{"timestamp": 3000, "agent": "v3"},
{"timestamp": 3000, "agent": "v2"},
{"timestamp": 3999, "agent": "v2"},
{"timestamp": 4000, "agent": "v3"}
]);
assert_eq!(
format_apple(&raw.to_string(), true, true).unwrap(),
text.replace("[00:04.00]v2:", "[00:04.00]v1:")
);
}
fn payload(lang: &str) -> serde_json::Value {
serde_json::json!({
"type": "Syllable",
"elrc": "[00:01.010]<00:01.009>Original <00:01.307>\n[00:02.000]Next",
"content": [{"timestamp": 1009, "text": [
{"text": "Original", "timestamp": 1009, "endtime": 1307}
]}, {"timestamp": 2001, "text": [{"text": "Next"}]}],
"metadata": {
"language": lang,
"transliterations": [{"lang": format!("{lang}-Latn"), "lines": [
{"timestamp": 1009, "text": "Romanized"},
{"timestamp": 2001, "text": "Next romanized"}
]}],
"translations": [{"lang": "en-US", "lines": [
{"timestamp": 1009, "text": "English"},
{"timestamp": 2001, "text": "Next English"}
]}]
}
})
}
#[test]
fn supplements_follow_output_timestamps_in_both_apple_modes() {
for lang in ["ja", "ko", "zh-Hans"] {
for word_timing in [false, true] {
let raw = payload(lang).to_string();
let text = format_apple(&raw, false, word_timing).unwrap();
let mut lyrics = LyricsResponse::from_text(&text, "Apple Music", "Apple Music");
apple_supplements(&raw, &mut lyrics);
assert_eq!(lyrics.lines()[0].romanization.as_deref(), Some("Romanized"));
assert_eq!(
lyrics.lines()[1].translation.as_deref(),
Some("Next English")
);
let start = if word_timing { 1010 } else { 1009 };
assert_eq!(lyrics.lines()[0].start_time_ms, start);
let exported = lrc::with_metadata(&lyrics, "Track", "Artist");
assert!(exported.contains(&format!("[x-romaji:{start}:Um9tYW5pemVk]")));
assert!(exported.contains(&lrc::timestamp(start)));
if word_timing {
assert!(exported.contains("<00:01.307>"));
}
// Persistence uses this same JSON response contract.
let restored: LyricsResponse =
serde_json::from_str(&serde_json::to_string(&lyrics).unwrap()).unwrap();
assert_eq!(restored, lyrics);
}
}
}
#[test]
fn nearest_matching_is_bounded_and_exact_matches_win() {
let mut raw = payload("ja");
raw["metadata"]["transliterations"][0]["lines"] = serde_json::json!([
{"timestamp": 1009, "text": "Rounded"},
{"timestamp": 1010, "text": "Exact"},
{"timestamp": 1001, "text": "Less accurate"},
{"timestamp": 2011, "text": "Too far"},
{"timestamp": 2990, "text": "Boundary"},
{"timestamp": 4005, "text": "Tie"}
]);
let mut lyrics = LyricsResponse::from_text(
"[00:01.010]A\n[00:02.00]B\n[00:03.00]C\n[00:04.00]D\n[00:04.010]E",
"",
"",
);
apple_supplements(&raw.to_string(), &mut lyrics);
let actual: Vec<_> = lyrics
.lines()
.iter()
.map(|line| line.romanization.as_deref())
.collect();
assert_eq!(
actual,
[Some("Exact"), None, Some("Boundary"), Some("Tie"), None]
);
}
#[test]
fn malformed_optional_lines_do_not_hide_other_supplements() {
let mut raw = payload("ja");
raw["metadata"]["transliterations"][0]["lines"] = serde_json::json!([
null, {"text": "Missing time"}, {"timestamp": "1009", "text": "Bad time"},
{"timestamp": 1009, "text": []}, {"timestamp": -1, "text": "Negative"},
{"timestamp": 2001, "text": " Next romanized "}
]);
let mut lyrics = LyricsResponse::from_text("[00:01.01]A\n[00:02.00]B", "", "");
apple_supplements(&raw.to_string(), &mut lyrics);
assert!(lyrics.lines()[0].romanization.is_none());
assert_eq!(lyrics.lines()[0].translation.as_deref(), Some("English"));
assert_eq!(
lyrics.lines()[1].romanization.as_deref(),
Some("Next romanized")
);
}
#[test]
fn language_selection_prefers_song_language_and_usable_groups() {
let mut raw = payload("ja");
raw["metadata"]["transliterations"] = serde_json::json!([
{"lang": "ja-Latn", "lines": []},
{"lang": "ko-Latn", "lines": [{"timestamp": 1009, "text": "Other language"}]},
{"lang": "ja-Kana", "lines": [{"timestamp": 1009, "text": "Not Latin"}]},
{"lang": "ja-Latn", "lines": [{"timestamp": 1009, "text": "Romanized"}]}
]);
raw["metadata"]["translations"] = serde_json::json!([
{"lang": "en", "lines": null},
{"lang": "fr", "lines": [{"timestamp": 1009, "text": "French"}]},
{"lang": "en-GB", "lines": [{"timestamp": 1009, "text": "English"}]}
]);
let mut lyrics = LyricsResponse::from_text("[00:01.01]Original", "", "");
apple_supplements(&raw.to_string(), &mut lyrics);
assert_eq!(lyrics.lines()[0].romanization.as_deref(), Some("Romanized"));
assert_eq!(lyrics.lines()[0].translation.as_deref(), Some("English"));
}
#[test]
fn absent_or_invalid_metadata_preserves_original_lyrics() {
for raw in [
"{}",
"not json",
r#"{"metadata":null}"#,
r#"{"metadata":{"translations":5}}"#,
] {
let mut lyrics = LyricsResponse::from_text("[00:01.00]Original", "", "");
let original = lyrics.clone();
apple_supplements(raw, &mut lyrics);
assert_eq!(lyrics, original);
}
let mut plain = LyricsResponse::from_text("Original", "", "");
apple_supplements(&payload("ja").to_string(), &mut plain);
assert!(plain.lines()[0].romanization.is_none());
}
#[test]
fn romanization_spans_keep_syllables_spaces_and_original_word_times() {
let mut raw = payload("ja");
raw["metadata"]["transliterations"][0]["lines"][0] = serde_json::json!({
"timestamp": 1009, "text": "Roma nized", "spans": [
{"text": "Ro", "begin": 1009, "end": 1107},
{"text": "ma", "begin": 1107, "end": 1307},
{"text": "nized", "begin": 2003, "end": 2497}
]
});
let mut lyrics = LyricsResponse::from_text("[00:01.010]Original", "", "");
apple_supplements(&raw.to_string(), &mut lyrics);
let words = lyrics.lines()[0].romanization_words.as_ref().unwrap();
assert_eq!(
words
.iter()
.map(|word| word.text.as_str())
.collect::<Vec<_>>(),
["Ro", "ma ", "nized"]
);
assert_eq!(words[0].start_time_ms, 1009);
assert_eq!(words[1].end_time_ms, 1307);
assert_eq!(words[2].start_time_ms, 2003);
assert_eq!(words[2].end_time_ms, 2497);
let output = lrc::with_metadata(&lyrics, "Track", "Artist");
let encoded = output
.lines()
.find_map(|line| {
line.strip_prefix("[x-romaji-words:1010:")
.and_then(|line| line.strip_suffix(']'))
})
.unwrap();
use base64::Engine;
let decoded = base64::engine::general_purpose::STANDARD
.decode(encoded)
.unwrap();
let restored: Vec<LyricsWord> = serde_json::from_slice(&decoded).unwrap();
assert_eq!(&restored, words);
}
#[test]
fn invalid_romanization_timing_falls_back_to_readable_text() {
for spans in [
serde_json::json!([{"text": "Other", "begin": 1009, "end": 1307}]),
serde_json::json!([{"text": "Romanized", "begin": 1009, "end": 900}]),
serde_json::json!([{"text": "Romanized", "begin": 1009}]),
serde_json::json!([{"text": "Ro", "begin": 1009, "end": 1307}]),
] {
let mut raw = payload("ja");
raw["metadata"]["transliterations"][0]["lines"][0]["spans"] = spans;
let mut lyrics = LyricsResponse::from_text("[00:01.010]Original", "", "");
apple_supplements(&raw.to_string(), &mut lyrics);
assert_eq!(lyrics.lines()[0].romanization.as_deref(), Some("Romanized"));
assert!(lyrics.lines()[0].romanization_words.is_none());
}
}
+2 -144
View File
@@ -25,6 +25,8 @@ mod fft;
mod replacement;
pub use replacement::replacement_preserves_audio;
#[cfg(test)]
mod reference;
#[cfg(test)]
mod tests;
use evidence::*;
@@ -161,18 +163,6 @@ impl HiResCheckResult {
}
}
/// Reference decoder retained for streaming parity tests.
#[cfg(test)]
#[derive(Default)]
struct Window {
signal: Vec<f32>,
/// OR of all raw integer samples, right-justified at the declared depth.
or_bits: u32,
/// This channel's raw integer samples, for the upsampling-artifact
/// tests; empty for float PCM.
samples: Vec<i32>,
}
struct ChannelAnalysis {
stft: StftAccumulator,
integers: IntegerEvidence,
@@ -499,20 +489,6 @@ impl<'a> Source<'a> {
}
}
#[cfg(test)]
fn read_window(
&mut self,
start_frame: u64,
frames: u64,
channel: u32,
check: &dyn Fn() -> Result<(), String>,
) -> Result<Window, String> {
match self {
Source::Flac(reader) => read_flac_window(reader, start_frame, frames, channel, check),
Source::Wav(wav) => wav.read_window(start_frame, frames, channel, check),
}
}
fn analyze_window(
&mut self,
start_frame: u64,
@@ -586,60 +562,6 @@ impl<'a> Source<'a> {
}
}
/// Decodes [start_frame, start_frame + frames). claxon hands back samples
/// right-justified with wasted bits already shifted back in and inter-channel
/// decorrelation undone, so each value is the stored sample. It cannot seek,
/// so the frames before the window are decoded and skipped.
#[cfg(test)]
fn read_flac_window(
reader: &mut claxon::FlacReader<BufReader<&mut File>>,
start_frame: u64,
frames: u64,
channel: u32,
check: &dyn Fn() -> Result<(), String>,
) -> Result<Window, String> {
let bits = reader.streaminfo().bits_per_sample;
let scale = 1.0 / 2f64.powi(bits as i32 - 1);
let capacity = usize::try_from(frames).unwrap_or(0);
let mut out = Window {
signal: Vec::with_capacity(capacity),
samples: Vec::with_capacity(capacity),
..Window::default()
};
let end = start_frame + frames;
let mut blocks = reader.blocks();
let mut buffer = Vec::new();
let mut decoded = 0u64;
while (out.signal.len() as u64) < frames {
if decoded.is_multiple_of(64) {
check()?;
}
decoded += 1;
let block = match blocks.read_next_or_eof(buffer) {
Ok(Some(block)) => block,
Ok(None) => break,
// A truncated tail still leaves a usable window.
Err(_) if !out.signal.is_empty() => break,
Err(e) => return Err(e.to_string()),
};
let first = block.time();
let n = u64::from(block.duration());
if first + n > start_frame {
let from = start_frame.saturating_sub(first);
let to = n.min(end - first);
for i in from..to {
let i = i as usize;
let v = block.channel(channel)[i];
out.or_bits |= v as u32;
out.samples.push(v);
out.signal.push((f64::from(v) * scale) as f32);
}
}
buffer = block.into_buffer();
}
Ok(out)
}
const WAV_FORMAT_PCM: u16 = 1;
const WAV_FORMAT_FLOAT: u16 = 3;
const WAV_FORMAT_EXTENSIBLE: u16 = 0xFFFE;
@@ -738,70 +660,6 @@ impl<'a> WavSource<'a> {
u64::from(self.channels * self.container_bits / 8)
}
#[cfg(test)]
fn read_window(
&mut self,
start_frame: u64,
frames: u64,
channel: u32,
check: &dyn Fn() -> Result<(), String>,
) -> Result<Window, String> {
let total = self.data_size / self.frame_bytes();
let frames = frames.min(total.saturating_sub(start_frame));
let mut out = Window::default();
if frames == 0 {
return Ok(out);
}
let frame_bytes = self.frame_bytes() as usize;
self.file
.seek(SeekFrom::Start(
self.data_offset + start_frame * frame_bytes as u64,
))
.map_err(|e| e.to_string())?;
let capacity = usize::try_from(frames).unwrap_or(0);
out.signal.reserve(capacity);
if !self.is_float {
out.samples.reserve(capacity);
}
let bytes_per_sample = (self.container_bits / 8) as usize;
let scale = 1.0 / 2f64.powi(self.container_bits as i32 - 1);
let mut reader = BufReader::with_capacity(1 << 16, &mut *self.file);
let mut frame = vec![0u8; frame_bytes];
for index in 0..frames {
if index.is_multiple_of(65_536) {
check()?;
}
if reader.read_exact(&mut frame).is_err() {
break;
}
for (c, b) in frame.chunks_exact(bytes_per_sample).enumerate() {
if c != channel as usize {
continue;
}
if self.is_float {
let v = if self.container_bits == 32 {
f64::from(f32::from_le_bytes([b[0], b[1], b[2], b[3]]))
} else {
f64::from_le_bytes([b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7]])
};
out.signal.push(v as f32);
continue;
}
let v: i32 = match self.container_bits {
8 => i32::from(b[0]) - 128, // 8-bit WAV is unsigned
16 => i32::from(i16::from_le_bytes([b[0], b[1]])),
24 => i32::from_le_bytes([0, b[0], b[1], b[2]]) >> 8,
_ => i32::from_le_bytes([b[0], b[1], b[2], b[3]]),
};
out.or_bits |= v as u32;
out.samples.push(v);
out.signal.push((f64::from(v) * scale) as f32);
}
}
Ok(out)
}
fn analyze_window(
&mut self,
start_frame: u64,
@@ -0,0 +1,146 @@
//! Reference decoder retained for streaming parity tests.
use super::*;
#[derive(Default)]
pub(super) struct Window {
pub(super) signal: Vec<f32>,
/// OR of all raw integer samples, right-justified at the declared depth.
pub(super) or_bits: u32,
/// This channel's raw integer samples, for the upsampling-artifact
/// tests; empty for float PCM.
pub(super) samples: Vec<i32>,
}
impl<'a> Source<'a> {
pub(super) fn read_window(
&mut self,
start_frame: u64,
frames: u64,
channel: u32,
check: &dyn Fn() -> Result<(), String>,
) -> Result<Window, String> {
match self {
Source::Flac(reader) => read_flac_window(reader, start_frame, frames, channel, check),
Source::Wav(wav) => wav.read_window(start_frame, frames, channel, check),
}
}
}
/// Decodes [start_frame, start_frame + frames). claxon hands back samples
/// right-justified with wasted bits already shifted back in and inter-channel
/// decorrelation undone, so each value is the stored sample. It cannot seek,
/// so the frames before the window are decoded and skipped.
fn read_flac_window(
reader: &mut claxon::FlacReader<BufReader<&mut File>>,
start_frame: u64,
frames: u64,
channel: u32,
check: &dyn Fn() -> Result<(), String>,
) -> Result<Window, String> {
let bits = reader.streaminfo().bits_per_sample;
let scale = 1.0 / 2f64.powi(bits as i32 - 1);
let capacity = usize::try_from(frames).unwrap_or(0);
let mut out = Window {
signal: Vec::with_capacity(capacity),
samples: Vec::with_capacity(capacity),
..Window::default()
};
let end = start_frame + frames;
let mut blocks = reader.blocks();
let mut buffer = Vec::new();
let mut decoded = 0u64;
while (out.signal.len() as u64) < frames {
if decoded.is_multiple_of(64) {
check()?;
}
decoded += 1;
let block = match blocks.read_next_or_eof(buffer) {
Ok(Some(block)) => block,
Ok(None) => break,
// A truncated tail still leaves a usable window.
Err(_) if !out.signal.is_empty() => break,
Err(e) => return Err(e.to_string()),
};
let first = block.time();
let n = u64::from(block.duration());
if first + n > start_frame {
let from = start_frame.saturating_sub(first);
let to = n.min(end - first);
for i in from..to {
let i = i as usize;
let v = block.channel(channel)[i];
out.or_bits |= v as u32;
out.samples.push(v);
out.signal.push((f64::from(v) * scale) as f32);
}
}
buffer = block.into_buffer();
}
Ok(out)
}
impl<'a> WavSource<'a> {
pub(super) fn read_window(
&mut self,
start_frame: u64,
frames: u64,
channel: u32,
check: &dyn Fn() -> Result<(), String>,
) -> Result<Window, String> {
let total = self.data_size / self.frame_bytes();
let frames = frames.min(total.saturating_sub(start_frame));
let mut out = Window::default();
if frames == 0 {
return Ok(out);
}
let frame_bytes = self.frame_bytes() as usize;
self.file
.seek(SeekFrom::Start(
self.data_offset + start_frame * frame_bytes as u64,
))
.map_err(|e| e.to_string())?;
let capacity = usize::try_from(frames).unwrap_or(0);
out.signal.reserve(capacity);
if !self.is_float {
out.samples.reserve(capacity);
}
let bytes_per_sample = (self.container_bits / 8) as usize;
let scale = 1.0 / 2f64.powi(self.container_bits as i32 - 1);
let mut reader = BufReader::with_capacity(1 << 16, &mut *self.file);
let mut frame = vec![0u8; frame_bytes];
for index in 0..frames {
if index.is_multiple_of(65_536) {
check()?;
}
if reader.read_exact(&mut frame).is_err() {
break;
}
for (c, b) in frame.chunks_exact(bytes_per_sample).enumerate() {
if c != channel as usize {
continue;
}
if self.is_float {
let v = if self.container_bits == 32 {
f64::from(f32::from_le_bytes([b[0], b[1], b[2], b[3]]))
} else {
f64::from_le_bytes([b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7]])
};
out.signal.push(v as f32);
continue;
}
let v: i32 = match self.container_bits {
8 => i32::from(b[0]) - 128, // 8-bit WAV is unsigned
16 => i32::from(i16::from_le_bytes([b[0], b[1]])),
24 => i32::from_le_bytes([0, b[0], b[1], b[2]]) >> 8,
_ => i32::from_le_bytes([b[0], b[1], b[2], b[3]]),
};
out.or_bits |= v as u32;
out.samples.push(v);
out.signal.push((f64::from(v) * scale) as f32);
}
}
Ok(out)
}
}
+7 -480
View File
@@ -2,6 +2,7 @@
//! output and publishes it only after this operation succeeds and is synced.
mod ape;
mod artists;
mod id3;
mod mp4;
mod ogg;
@@ -10,10 +11,12 @@ mod riff;
pub(super) use id3::cover as embedded_cover;
use super::{CheckedReader, bytes, exact, pair, seek};
use crate::matching::{lowercase, uppercase};
use crate::matching::uppercase;
use artists::{
artist_mode_fields, primary_artist, primary_artist_mode, split_artists, tag_separator,
};
use regex::Regex;
use std::borrow::Cow;
use std::collections::{BTreeMap, BTreeSet};
use std::collections::BTreeMap;
use std::io::{BufReader, Cursor, Read, Seek, SeekFrom, Write};
use std::sync::LazyLock;
@@ -659,93 +662,6 @@ fn metadata_fields(metadata: &super::AudioMetadata, fields: &Fields) -> Fields {
result
}
fn primary_artist_mode(mode: &str) -> bool {
mode.trim().eq_ignore_ascii_case("primary")
}
/// The first credited artist, or the trimmed value when nothing separates it.
/// Dart and Kotlin share this exact separator set so every writer agrees;
/// Vorbis split mode also recognizes these featured-credit variants.
fn primary_artist(value: &str) -> String {
static SEPARATOR: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)\s*[,;&]\s*|\s+x\s+|\s+(?:feat(?:uring|ured|ure)?|ft|with)\.?(?:\s+|$)")
.unwrap()
});
let value = value.trim();
SEPARATOR
.split(value)
.map(str::trim)
.find(|part| !part.is_empty())
.unwrap_or(value)
.to_owned()
}
/// Applies the "primary" artist tag mode to editor fields before any format
/// writer runs, so ID3, MP4, APE, RIFF and Vorbis all store the same name.
fn artist_mode_fields(fields: &Fields) -> Cow<'_, Fields> {
let primary = fields
.get("artist_tag_mode")
.is_some_and(|mode| primary_artist_mode(mode));
if !primary
&& ["artist", "album_artist", "composer", "genre"]
.iter()
.all(|key| tag_separator(fields, key).is_empty())
{
return Cow::Borrowed(fields);
}
let mut fields = fields.clone();
for key in ["artist", "album_artist", "composer", "genre"] {
let separator = tag_separator(&fields, key).to_owned();
if let Some(value) = fields.get_mut(key) {
if primary && matches!(key, "artist" | "album_artist") && !value.trim().is_empty() {
*value = primary_artist(value);
}
*value = super::format_metadata_tag_value(value, &separator);
}
}
Cow::Owned(fields)
}
fn tag_separator<'a>(fields: &'a Fields, key: &str) -> &'a str {
let key = match key {
"ARTIST" | "artist" => "artist_separator",
"ALBUMARTIST" | "album_artist" => "album_artist_separator",
"COMPOSER" | "composer" => "composer_separator",
"GENRE" | "genre" => "genre_separator",
_ => return "",
};
fields
.get(key)
.filter(|value| super::separators::valid_separator(value))
.map(String::as_str)
.unwrap_or_default()
}
fn split_artists(value: &str) -> Vec<String> {
static SPLIT: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"(?i)\s*(?:,|;|&|\bx\b)\s*|\s+\b(?:feat(?:uring|ured|ure)?|ft|with)\.?(?:\s+|$)",
)
.unwrap()
});
let value = value.trim();
if value.is_empty() {
return Vec::new();
}
let mut seen = BTreeSet::new();
let mut result: Vec<String> = SPLIT
.split(value)
.map(str::trim)
.filter(|part| !part.is_empty())
.filter(|part| seen.insert(lowercase(part)))
.map(str::to_owned)
.collect();
if result.is_empty() {
result.push(value.into());
}
result
}
fn cover_mime(data: &[u8]) -> &'static str {
if data.starts_with(b"\x89PNG\r\n\x1a\n") {
"image/png"
@@ -838,393 +754,4 @@ fn picture(
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::Cell;
use std::io;
use std::rc::Rc;
struct CountingReader {
data: Cursor<Vec<u8>>,
bytes_read: Rc<Cell<usize>>,
}
impl Read for CountingReader {
fn read(&mut self, buffer: &mut [u8]) -> io::Result<usize> {
let count = self.data.read(buffer)?;
self.bytes_read.set(self.bytes_read.get() + count);
Ok(count)
}
}
impl Seek for CountingReader {
fn seek(&mut self, offset: SeekFrom) -> io::Result<u64> {
self.data.seek(offset)
}
}
/// Shared with the Dart and Kotlin primary-artist tests; keep in sync.
#[test]
fn split_artist_credits_recognize_variants_without_splitting_band_words() {
assert_eq!(
split_artists("One Featured Two; Three"),
vec!["One", "Two", "Three"]
);
assert_eq!(split_artists("One feature Two"), vec!["One", "Two"]);
assert_eq!(
split_artists("Florence and the Machine"),
vec!["Florence and the Machine"]
);
assert_eq!(
split_artists("Artist Without Fear"),
vec!["Artist Without Fear"]
);
}
#[test]
fn primary_artist_uses_the_shared_separator_set() {
for (input, expected) in [
("Calle 24, Chino Pacas", "Calle 24"),
("Calle 24 & Chino Pacas", "Calle 24"),
("Artist A; Artist B", "Artist A"),
("Artist A feat. Artist B", "Artist A"),
("Artist A Feat Artist B", "Artist A"),
("Artist A ft. Artist B", "Artist A"),
("Artist A featuring Artist B", "Artist A"),
("Artist A featured Artist B", "Artist A"),
("Artist A feature Artist B", "Artist A"),
("Artist A with Artist B", "Artist A"),
("Artist A x Artist B", "Artist A"),
("Artist A X Artist B", "Artist A"),
(" , Artist A, Artist B", "Artist A"),
("Malcolm X", "Malcolm X"),
("Artist Without Fear", "Artist Without Fear"),
("Maxx", "Maxx"),
("AC/DC", "AC/DC"),
(" Various Artists ", "Various Artists"),
("", ""),
] {
assert_eq!(primary_artist(input), expected, "{input:?}");
}
}
fn editable_flac() -> (Vec<u8>, usize, Fields) {
let mut comments = Vec::new();
put_string(&mut comments, b"SpotiFLAC");
comments.extend(2_u32.to_le_bytes());
put_string(&mut comments, b"CUSTOM=preserve");
put_string(&mut comments, b"TITLE=Example");
let blocks = [
(0, vec![0; 34]),
(1, vec![0; 16]),
(4, comments),
(6, picture(b"original cover", &|| Ok(())).unwrap().unwrap()),
];
let mut input = b"fLaC".to_vec();
for (index, (kind, data)) in blocks.iter().enumerate() {
input.push(kind | if index + 1 == blocks.len() { 0x80 } else { 0 });
input.extend(&(data.len() as u32).to_be_bytes()[1..]);
input.extend(data);
}
let audio_start = input.len();
input.extend([0xff, 0xf8]);
input.resize(audio_start + 1024 * 1024, 0x55);
let fields = Fields::from([("title".into(), "Example".into())]);
(input, audio_start, fields)
}
#[test]
fn unchanged_flac_tags_do_not_copy_audio_or_write_output() {
for cover in [None, Some(b"original cover".as_slice())] {
let (input, audio_start, fields) = editable_flac();
let bytes_read = Rc::new(Cell::new(0));
let mut source = CountingReader {
data: Cursor::new(input),
bytes_read: bytes_read.clone(),
};
let mut output = Vec::new();
assert!(
!rewrite_flac_tags_if_changed(
&mut source,
&mut output,
&fields,
cover,
&|| Ok(()),
)
.unwrap()
);
assert!(output.is_empty());
// One header parse, two frame-sync bytes, one exact comparison.
assert_eq!(bytes_read.get(), audio_start * 2 + 2);
}
}
#[test]
fn changed_flac_tags_match_full_rewrite_and_preserve_audio() {
let (input, audio_start, _) = editable_flac();
for (title, cover) in [
("Changed", None), // Equal-length header with different bytes.
("A longer title", None),
("Example", Some(b"different artwork".as_slice())),
] {
let fields = Fields::from([("title".into(), title.into())]);
let mut expected = Vec::new();
rewrite_audio_tags(
&mut Cursor::new(&input),
&mut expected,
"flac",
&fields,
cover,
&|| Ok(()),
)
.unwrap();
let mut output = Vec::new();
assert!(
rewrite_flac_tags_if_changed(
&mut Cursor::new(&input),
&mut output,
&fields,
cover,
&|| Ok(()),
)
.unwrap()
);
assert_eq!(output, expected);
assert!(output.ends_with(&input[audio_start..]));
}
}
#[test]
fn unchanged_flac_still_validates_frames_and_observes_cancellation() {
let (input, audio_start, fields) = editable_flac();
let bytes_read = Rc::new(Cell::new(0));
let mut source = CountingReader {
data: Cursor::new(input.clone()),
bytes_read: bytes_read.clone(),
};
let mut output = Vec::new();
let error = rewrite_flac_tags_if_changed(&mut source, &mut output, &fields, None, &|| {
if bytes_read.get() >= audio_start + 2 {
Err("cancelled".into())
} else {
Ok(())
}
})
.unwrap_err();
assert_eq!(error, "cancelled");
assert!(output.is_empty());
let mut invalid = input;
invalid[audio_start] = 0;
assert!(
rewrite_flac_tags_if_changed(
&mut Cursor::new(invalid),
&mut output,
&fields,
None,
&|| Ok(()),
)
.unwrap_err()
.contains("sync code")
);
assert!(output.is_empty());
}
fn flac_with_pictures(lengths: &[usize]) -> (Vec<u8>, usize) {
let mut comments = Vec::new();
put_string(&mut comments, b"Example vendor");
comments.extend(1_u32.to_le_bytes());
put_string(&mut comments, b"CUSTOM=preserve");
let mut blocks = vec![
(0, vec![0; 34]),
(2, b"unknown block".to_vec()),
(4, comments),
];
blocks.extend(lengths.iter().map(|length| (6, vec![0x55; *length])));
let mut input = b"fLaC".to_vec();
for (index, (kind, data)) in blocks.iter().enumerate() {
input.push(kind | if index + 1 == blocks.len() { 0x80 } else { 0 });
input.extend(&(data.len() as u32).to_be_bytes()[1..]);
input.extend(data);
}
let audio_start = input.len();
input.extend([0xff, 0xf8, 0x12, 0x34, 0x56]);
(input, audio_start)
}
#[test]
fn replacement_flac_artwork_preserves_other_blocks_and_audio() {
let (input, audio_start) = flac_with_pictures(&[131_073, 262_145]);
let (without_pictures, _) = flac_with_pictures(&[]);
let cover = Some(b"replacement artwork".as_slice());
for embed in [false, true] {
let fields = Fields::from([(
if embed { "TITLE" } else { "title" }.into(),
"Changed title".into(),
)]);
let rewrite = |data: &[u8], cover| {
let mut output = Vec::new();
if embed {
embed_flac_metadata(
&mut Cursor::new(data),
&mut output,
&fields,
"",
cover,
&|| Ok(()),
)
.unwrap();
} else {
rewrite_audio_tags(
&mut Cursor::new(data),
&mut output,
"flac",
&fields,
cover,
&|| Ok(()),
)
.unwrap();
}
output
};
let output = rewrite(&input, cover);
assert_eq!(output, rewrite(&without_pictures, cover));
assert!(output.ends_with(&input[audio_start..]));
let retained = rewrite(&input, None);
assert!(
retained
.windows(131_073)
.any(|bytes| bytes == [0x55; 131_073])
);
assert!(retained.ends_with(&input[audio_start..]));
}
}
#[test]
fn discarded_flac_picture_preserves_eof_and_cancellation() {
let (input, audio_start) = flac_with_pictures(&[131_073]);
let picture_start = audio_start - 131_073;
let fields = Fields::new();
for available in [0, 1, 65536, 65537, 131_072] {
let truncated = &input[..picture_start + available];
let mut expected = Vec::new();
let error = rewrite_audio_tags(
&mut Cursor::new(truncated),
&mut expected,
"flac",
&fields,
None,
&|| Ok(()),
)
.unwrap_err();
let mut output = Vec::new();
assert_eq!(
rewrite_audio_tags(
&mut Cursor::new(truncated),
&mut output,
"flac",
&fields,
Some(b"replacement artwork"),
&|| Ok(()),
)
.unwrap_err(),
error
);
assert!(output.is_empty());
}
let bytes_read = Rc::new(Cell::new(0));
let mut source = CountingReader {
data: Cursor::new(input),
bytes_read: bytes_read.clone(),
};
let mut output = Vec::new();
assert_eq!(
rewrite_flac_tags_if_changed(
&mut source,
&mut output,
&fields,
Some(b"replacement artwork"),
&|| {
if bytes_read.get() >= picture_start + 65536 {
Err("cancelled".into())
} else {
Ok(())
}
},
)
.unwrap_err(),
"cancelled"
);
assert!(bytes_read.get() < audio_start);
assert!(output.is_empty());
}
#[test]
fn discarded_flac_pictures_still_count_toward_metadata_limit() {
let (input, _) = flac_with_pictures(&vec![0; 65534]);
let mut output = Vec::new();
assert_eq!(
rewrite_audio_tags(
&mut Cursor::new(input),
&mut output,
"flac",
&Fields::new(),
Some(b"replacement artwork"),
&|| Ok(()),
)
.unwrap_err(),
"FLAC metadata block count exceeds 65536"
);
assert!(output.is_empty());
}
#[test]
fn oversized_flac_cover_reencodes_or_omits_invalid_image() {
let image = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(
3,
2,
image::Rgba([30, 60, 90, 255]),
));
let mut encoded = Cursor::new(Vec::new());
image
.write_to(&mut encoded, image::ImageFormat::Png)
.unwrap();
let mut cover = encoded.into_inner();
// Valid PNG with trailing data forces the same size fallback as a
// large artwork file without an expensive image-generation fixture.
cover.resize(16_000_001, 0);
let block = picture(&cover, &|| Ok(())).unwrap().unwrap();
let mut reader = Cursor::new(block);
let mut number = || {
let mut value = [0; 4];
reader.read_exact(&mut value).unwrap();
u32::from_be_bytes(value)
};
assert_eq!(number(), 3);
assert_eq!(number(), 10);
assert_eq!(bytes(&mut reader, 10).unwrap(), b"image/jpeg");
assert_eq!(bytes(&mut reader, 4).unwrap(), 11_u32.to_be_bytes());
assert_eq!(bytes(&mut reader, 11).unwrap(), b"Front Cover");
assert_eq!(bytes(&mut reader, 4).unwrap(), 3_u32.to_be_bytes());
assert_eq!(bytes(&mut reader, 4).unwrap(), 2_u32.to_be_bytes());
assert_eq!(bytes(&mut reader, 4).unwrap(), 24_u32.to_be_bytes());
assert_eq!(bytes(&mut reader, 4).unwrap(), [0; 4]);
let length = u32::from_be_bytes(bytes(&mut reader, 4).unwrap().try_into().unwrap());
assert!(length < 16_000_000);
let jpeg = bytes(&mut reader, length as usize).unwrap();
assert_eq!(
image::ImageReader::new(Cursor::new(jpeg))
.with_guessed_format()
.unwrap()
.into_dimensions()
.unwrap(),
(3, 2)
);
cover.fill(0);
assert!(picture(&cover, &|| Ok(())).unwrap().is_none());
assert_eq!(
picture(b"small cover", &|| Err("cancelled".into())).unwrap_err(),
"cancelled"
);
}
}
mod tests;
@@ -0,0 +1,93 @@
use super::Fields;
use crate::matching::lowercase;
use regex::Regex;
use std::borrow::Cow;
use std::collections::BTreeSet;
use std::sync::LazyLock;
pub(super) fn primary_artist_mode(mode: &str) -> bool {
mode.trim().eq_ignore_ascii_case("primary")
}
/// The first credited artist, or the trimmed value when nothing separates it.
/// Dart and Kotlin share this exact separator set so every writer agrees;
/// Vorbis split mode also recognizes these featured-credit variants.
pub(super) fn primary_artist(value: &str) -> String {
static SEPARATOR: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)\s*[,;&]\s*|\s+x\s+|\s+(?:feat(?:uring|ured|ure)?|ft|with)\.?(?:\s+|$)")
.unwrap()
});
let value = value.trim();
SEPARATOR
.split(value)
.map(str::trim)
.find(|part| !part.is_empty())
.unwrap_or(value)
.to_owned()
}
/// Applies the "primary" artist tag mode to editor fields before any format
/// writer runs, so ID3, MP4, APE, RIFF and Vorbis all store the same name.
pub(super) fn artist_mode_fields(fields: &Fields) -> Cow<'_, Fields> {
let primary = fields
.get("artist_tag_mode")
.is_some_and(|mode| primary_artist_mode(mode));
if !primary
&& ["artist", "album_artist", "composer", "genre"]
.iter()
.all(|key| tag_separator(fields, key).is_empty())
{
return Cow::Borrowed(fields);
}
let mut fields = fields.clone();
for key in ["artist", "album_artist", "composer", "genre"] {
let separator = tag_separator(&fields, key).to_owned();
if let Some(value) = fields.get_mut(key) {
if primary && matches!(key, "artist" | "album_artist") && !value.trim().is_empty() {
*value = primary_artist(value);
}
*value = super::super::format_metadata_tag_value(value, &separator);
}
}
Cow::Owned(fields)
}
pub(super) fn tag_separator<'a>(fields: &'a Fields, key: &str) -> &'a str {
let key = match key {
"ARTIST" | "artist" => "artist_separator",
"ALBUMARTIST" | "album_artist" => "album_artist_separator",
"COMPOSER" | "composer" => "composer_separator",
"GENRE" | "genre" => "genre_separator",
_ => return "",
};
fields
.get(key)
.filter(|value| super::super::separators::valid_separator(value))
.map(String::as_str)
.unwrap_or_default()
}
pub(super) fn split_artists(value: &str) -> Vec<String> {
static SPLIT: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"(?i)\s*(?:,|;|&|\bx\b)\s*|\s+\b(?:feat(?:uring|ured|ure)?|ft|with)\.?(?:\s+|$)",
)
.unwrap()
});
let value = value.trim();
if value.is_empty() {
return Vec::new();
}
let mut seen = BTreeSet::new();
let mut result: Vec<String> = SPLIT
.split(value)
.map(str::trim)
.filter(|part| !part.is_empty())
.filter(|part| seen.insert(lowercase(part)))
.map(str::to_owned)
.collect();
if result.is_empty() {
result.push(value.into());
}
result
}
@@ -0,0 +1,382 @@
use super::*;
use std::cell::Cell;
use std::io;
use std::rc::Rc;
struct CountingReader {
data: Cursor<Vec<u8>>,
bytes_read: Rc<Cell<usize>>,
}
impl Read for CountingReader {
fn read(&mut self, buffer: &mut [u8]) -> io::Result<usize> {
let count = self.data.read(buffer)?;
self.bytes_read.set(self.bytes_read.get() + count);
Ok(count)
}
}
impl Seek for CountingReader {
fn seek(&mut self, offset: SeekFrom) -> io::Result<u64> {
self.data.seek(offset)
}
}
/// Shared with the Dart and Kotlin primary-artist tests; keep in sync.
#[test]
fn split_artist_credits_recognize_variants_without_splitting_band_words() {
assert_eq!(
split_artists("One Featured Two; Three"),
vec!["One", "Two", "Three"]
);
assert_eq!(split_artists("One feature Two"), vec!["One", "Two"]);
assert_eq!(
split_artists("Florence and the Machine"),
vec!["Florence and the Machine"]
);
assert_eq!(
split_artists("Artist Without Fear"),
vec!["Artist Without Fear"]
);
}
#[test]
fn primary_artist_uses_the_shared_separator_set() {
for (input, expected) in [
("Calle 24, Chino Pacas", "Calle 24"),
("Calle 24 & Chino Pacas", "Calle 24"),
("Artist A; Artist B", "Artist A"),
("Artist A feat. Artist B", "Artist A"),
("Artist A Feat Artist B", "Artist A"),
("Artist A ft. Artist B", "Artist A"),
("Artist A featuring Artist B", "Artist A"),
("Artist A featured Artist B", "Artist A"),
("Artist A feature Artist B", "Artist A"),
("Artist A with Artist B", "Artist A"),
("Artist A x Artist B", "Artist A"),
("Artist A X Artist B", "Artist A"),
(" , Artist A, Artist B", "Artist A"),
("Malcolm X", "Malcolm X"),
("Artist Without Fear", "Artist Without Fear"),
("Maxx", "Maxx"),
("AC/DC", "AC/DC"),
(" Various Artists ", "Various Artists"),
("", ""),
] {
assert_eq!(primary_artist(input), expected, "{input:?}");
}
}
fn editable_flac() -> (Vec<u8>, usize, Fields) {
let mut comments = Vec::new();
put_string(&mut comments, b"SpotiFLAC");
comments.extend(2_u32.to_le_bytes());
put_string(&mut comments, b"CUSTOM=preserve");
put_string(&mut comments, b"TITLE=Example");
let blocks = [
(0, vec![0; 34]),
(1, vec![0; 16]),
(4, comments),
(6, picture(b"original cover", &|| Ok(())).unwrap().unwrap()),
];
let mut input = b"fLaC".to_vec();
for (index, (kind, data)) in blocks.iter().enumerate() {
input.push(kind | if index + 1 == blocks.len() { 0x80 } else { 0 });
input.extend(&(data.len() as u32).to_be_bytes()[1..]);
input.extend(data);
}
let audio_start = input.len();
input.extend([0xff, 0xf8]);
input.resize(audio_start + 1024 * 1024, 0x55);
let fields = Fields::from([("title".into(), "Example".into())]);
(input, audio_start, fields)
}
#[test]
fn unchanged_flac_tags_do_not_copy_audio_or_write_output() {
for cover in [None, Some(b"original cover".as_slice())] {
let (input, audio_start, fields) = editable_flac();
let bytes_read = Rc::new(Cell::new(0));
let mut source = CountingReader {
data: Cursor::new(input),
bytes_read: bytes_read.clone(),
};
let mut output = Vec::new();
assert!(
!rewrite_flac_tags_if_changed(&mut source, &mut output, &fields, cover, &|| Ok(()),)
.unwrap()
);
assert!(output.is_empty());
// One header parse, two frame-sync bytes, one exact comparison.
assert_eq!(bytes_read.get(), audio_start * 2 + 2);
}
}
#[test]
fn changed_flac_tags_match_full_rewrite_and_preserve_audio() {
let (input, audio_start, _) = editable_flac();
for (title, cover) in [
("Changed", None), // Equal-length header with different bytes.
("A longer title", None),
("Example", Some(b"different artwork".as_slice())),
] {
let fields = Fields::from([("title".into(), title.into())]);
let mut expected = Vec::new();
rewrite_audio_tags(
&mut Cursor::new(&input),
&mut expected,
"flac",
&fields,
cover,
&|| Ok(()),
)
.unwrap();
let mut output = Vec::new();
assert!(
rewrite_flac_tags_if_changed(
&mut Cursor::new(&input),
&mut output,
&fields,
cover,
&|| Ok(()),
)
.unwrap()
);
assert_eq!(output, expected);
assert!(output.ends_with(&input[audio_start..]));
}
}
#[test]
fn unchanged_flac_still_validates_frames_and_observes_cancellation() {
let (input, audio_start, fields) = editable_flac();
let bytes_read = Rc::new(Cell::new(0));
let mut source = CountingReader {
data: Cursor::new(input.clone()),
bytes_read: bytes_read.clone(),
};
let mut output = Vec::new();
let error = rewrite_flac_tags_if_changed(&mut source, &mut output, &fields, None, &|| {
if bytes_read.get() >= audio_start + 2 {
Err("cancelled".into())
} else {
Ok(())
}
})
.unwrap_err();
assert_eq!(error, "cancelled");
assert!(output.is_empty());
let mut invalid = input;
invalid[audio_start] = 0;
assert!(
rewrite_flac_tags_if_changed(
&mut Cursor::new(invalid),
&mut output,
&fields,
None,
&|| Ok(()),
)
.unwrap_err()
.contains("sync code")
);
assert!(output.is_empty());
}
fn flac_with_pictures(lengths: &[usize]) -> (Vec<u8>, usize) {
let mut comments = Vec::new();
put_string(&mut comments, b"Example vendor");
comments.extend(1_u32.to_le_bytes());
put_string(&mut comments, b"CUSTOM=preserve");
let mut blocks = vec![
(0, vec![0; 34]),
(2, b"unknown block".to_vec()),
(4, comments),
];
blocks.extend(lengths.iter().map(|length| (6, vec![0x55; *length])));
let mut input = b"fLaC".to_vec();
for (index, (kind, data)) in blocks.iter().enumerate() {
input.push(kind | if index + 1 == blocks.len() { 0x80 } else { 0 });
input.extend(&(data.len() as u32).to_be_bytes()[1..]);
input.extend(data);
}
let audio_start = input.len();
input.extend([0xff, 0xf8, 0x12, 0x34, 0x56]);
(input, audio_start)
}
#[test]
fn replacement_flac_artwork_preserves_other_blocks_and_audio() {
let (input, audio_start) = flac_with_pictures(&[131_073, 262_145]);
let (without_pictures, _) = flac_with_pictures(&[]);
let cover = Some(b"replacement artwork".as_slice());
for embed in [false, true] {
let fields = Fields::from([(
if embed { "TITLE" } else { "title" }.into(),
"Changed title".into(),
)]);
let rewrite = |data: &[u8], cover| {
let mut output = Vec::new();
if embed {
embed_flac_metadata(
&mut Cursor::new(data),
&mut output,
&fields,
"",
cover,
&|| Ok(()),
)
.unwrap();
} else {
rewrite_audio_tags(
&mut Cursor::new(data),
&mut output,
"flac",
&fields,
cover,
&|| Ok(()),
)
.unwrap();
}
output
};
let output = rewrite(&input, cover);
assert_eq!(output, rewrite(&without_pictures, cover));
assert!(output.ends_with(&input[audio_start..]));
let retained = rewrite(&input, None);
assert!(
retained
.windows(131_073)
.any(|bytes| bytes == [0x55; 131_073])
);
assert!(retained.ends_with(&input[audio_start..]));
}
}
#[test]
fn discarded_flac_picture_preserves_eof_and_cancellation() {
let (input, audio_start) = flac_with_pictures(&[131_073]);
let picture_start = audio_start - 131_073;
let fields = Fields::new();
for available in [0, 1, 65536, 65537, 131_072] {
let truncated = &input[..picture_start + available];
let mut expected = Vec::new();
let error = rewrite_audio_tags(
&mut Cursor::new(truncated),
&mut expected,
"flac",
&fields,
None,
&|| Ok(()),
)
.unwrap_err();
let mut output = Vec::new();
assert_eq!(
rewrite_audio_tags(
&mut Cursor::new(truncated),
&mut output,
"flac",
&fields,
Some(b"replacement artwork"),
&|| Ok(()),
)
.unwrap_err(),
error
);
assert!(output.is_empty());
}
let bytes_read = Rc::new(Cell::new(0));
let mut source = CountingReader {
data: Cursor::new(input),
bytes_read: bytes_read.clone(),
};
let mut output = Vec::new();
assert_eq!(
rewrite_flac_tags_if_changed(
&mut source,
&mut output,
&fields,
Some(b"replacement artwork"),
&|| {
if bytes_read.get() >= picture_start + 65536 {
Err("cancelled".into())
} else {
Ok(())
}
},
)
.unwrap_err(),
"cancelled"
);
assert!(bytes_read.get() < audio_start);
assert!(output.is_empty());
}
#[test]
fn discarded_flac_pictures_still_count_toward_metadata_limit() {
let (input, _) = flac_with_pictures(&vec![0; 65534]);
let mut output = Vec::new();
assert_eq!(
rewrite_audio_tags(
&mut Cursor::new(input),
&mut output,
"flac",
&Fields::new(),
Some(b"replacement artwork"),
&|| Ok(()),
)
.unwrap_err(),
"FLAC metadata block count exceeds 65536"
);
assert!(output.is_empty());
}
#[test]
fn oversized_flac_cover_reencodes_or_omits_invalid_image() {
let image = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(
3,
2,
image::Rgba([30, 60, 90, 255]),
));
let mut encoded = Cursor::new(Vec::new());
image
.write_to(&mut encoded, image::ImageFormat::Png)
.unwrap();
let mut cover = encoded.into_inner();
// Valid PNG with trailing data forces the same size fallback as a
// large artwork file without an expensive image-generation fixture.
cover.resize(16_000_001, 0);
let block = picture(&cover, &|| Ok(())).unwrap().unwrap();
let mut reader = Cursor::new(block);
let mut number = || {
let mut value = [0; 4];
reader.read_exact(&mut value).unwrap();
u32::from_be_bytes(value)
};
assert_eq!(number(), 3);
assert_eq!(number(), 10);
assert_eq!(bytes(&mut reader, 10).unwrap(), b"image/jpeg");
assert_eq!(bytes(&mut reader, 4).unwrap(), 11_u32.to_be_bytes());
assert_eq!(bytes(&mut reader, 11).unwrap(), b"Front Cover");
assert_eq!(bytes(&mut reader, 4).unwrap(), 3_u32.to_be_bytes());
assert_eq!(bytes(&mut reader, 4).unwrap(), 2_u32.to_be_bytes());
assert_eq!(bytes(&mut reader, 4).unwrap(), 24_u32.to_be_bytes());
assert_eq!(bytes(&mut reader, 4).unwrap(), [0; 4]);
let length = u32::from_be_bytes(bytes(&mut reader, 4).unwrap().try_into().unwrap());
assert!(length < 16_000_000);
let jpeg = bytes(&mut reader, length as usize).unwrap();
assert_eq!(
image::ImageReader::new(Cursor::new(jpeg))
.with_guessed_format()
.unwrap()
.into_dimensions()
.unwrap(),
(3, 2)
);
cover.fill(0);
assert!(picture(&cover, &|| Ok(())).unwrap().is_none());
assert_eq!(
picture(b"small cover", &|| Err("cancelled".into())).unwrap_err(),
"cancelled"
);
}
+1 -242
View File
@@ -702,245 +702,4 @@ pub(crate) fn clean(path: &Path) -> PathBuf {
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{Seek, SeekFrom, Write};
#[cfg(unix)]
#[test]
fn native_reads_reject_links_at_every_component_and_never_write_or_wait_on_special_files() {
use std::io::Read;
use std::os::unix::{fs::symlink, net::UnixListener};
let root = tempfile::tempdir().unwrap();
let files = Arc::new(FileRegistry::default())
.extension(root.path())
.unwrap();
std::fs::create_dir_all(root.path().join("album/disc")).unwrap();
std::fs::write(root.path().join("album/disc/song.wav"), b"original").unwrap();
#[cfg(any(target_os = "linux", target_os = "android"))]
{
use std::os::unix::fs::PermissionsExt;
let parent = root.path().join("album");
std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o111)).unwrap();
let result = files
.resolve("album/disc/song.wav")
.unwrap()
.open_native_read();
std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o700)).unwrap();
assert!(result.is_ok(), "execute-only parent: {result:?}");
}
let mut file = files
.resolve("album/disc/song.wav")
.unwrap()
.open_native_read()
.unwrap();
let mut data = Vec::new();
file.read_to_end(&mut data).unwrap();
assert_eq!(data, b"original");
assert!(file.write_all(b"changed").is_err());
for (target, link) in [
("album", "album-link"),
("album/disc/song.wav", "song-link.wav"),
("missing.wav", "dangling.wav"),
] {
symlink(target, root.path().join(link)).unwrap();
}
for path in ["album-link/disc/song.wav", "song-link.wav", "dangling.wav"] {
assert!(
files
.resolve(path)
.unwrap()
.open_native_read()
.unwrap_err()
.to_string()
.contains("symlinks")
);
}
assert_eq!(
files
.resolve("missing.wav")
.unwrap()
.open_native_read()
.unwrap_err()
.kind(),
io::ErrorKind::NotFound
);
assert!(
std::process::Command::new("mkfifo")
.arg(root.path().join("pipe.wav"))
.status()
.unwrap()
.success()
);
let _socket = UnixListener::bind(root.path().join("socket.wav")).unwrap();
for path in ["pipe.wav", "socket.wav"] {
assert!(
files
.resolve(path)
.unwrap()
.open_native_read()
.unwrap_err()
.to_string()
.contains("special files")
);
}
assert_eq!(
std::fs::read(root.path().join("album/disc/song.wav")).unwrap(),
b"original"
);
}
#[test]
fn exclusive_publication_preserves_racing_output_and_cleans_cancelled_stage() {
let root = tempfile::tempdir().unwrap();
let files = Arc::new(FileRegistry::default())
.extension(root.path())
.unwrap();
let path = files.resolve("track.flac").unwrap();
let mut stage = path.stage().unwrap();
stage.file.write_all(b"new audio").unwrap();
assert!(!path.absolute.exists());
let error = stage
.publish_new(&|| {
// A different publisher creates the destination after the
// planner's existence check but before the publication syscall.
std::fs::write(&path.absolute, b"other publisher").unwrap();
Ok(())
})
.unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::AlreadyExists);
assert_eq!(std::fs::read(&path.absolute).unwrap(), b"other publisher");
let cancelled = files.resolve("cancelled.flac").unwrap();
let mut stage = cancelled.stage().unwrap();
stage.file.write_all(b"cancelled audio").unwrap();
assert_eq!(
stage
.publish_new(&|| Err("cancelled".into()))
.unwrap_err()
.to_string(),
"cancelled"
);
assert!(!cancelled.absolute.exists());
path.remove().unwrap();
let mut stage = path.stage().unwrap();
stage.file.write_all(b"complete audio").unwrap();
stage.publish_new(&|| Ok(())).unwrap();
assert_eq!(std::fs::read(&path.absolute).unwrap(), b"complete audio");
assert_eq!(std::fs::read_dir(root.path()).unwrap().count(), 1);
}
#[cfg(unix)]
#[test]
fn stage_from_links_same_filesystem_source_and_survives_source_cleanup() {
use cap_std::fs::MetadataExt;
let root = tempfile::tempdir().unwrap();
let files = Arc::new(FileRegistry::default())
.extension(root.path())
.unwrap();
let source_path = files.resolve("source.bin").unwrap();
std::fs::write(&source_path.absolute, b"source audio").unwrap();
let source = source_path.open(OpenOptions::new().read(true)).unwrap();
let destination = files.resolve("destination.bin").unwrap();
let (stage, promoted) = destination.stage_from(&source_path, &source).unwrap();
assert!(promoted);
let source_metadata = source.metadata().unwrap();
let stage_metadata = stage.file.metadata().unwrap();
assert_eq!(source_metadata.dev(), stage_metadata.dev());
assert_eq!(source_metadata.ino(), stage_metadata.ino());
stage.publish_new(&|| Ok(())).unwrap();
drop(source);
source_path.remove().unwrap();
assert_eq!(
std::fs::read(&destination.absolute).unwrap(),
b"source audio"
);
assert_eq!(std::fs::read_dir(root.path()).unwrap().count(), 1);
}
#[cfg(unix)]
#[test]
fn stage_from_copies_open_source_after_path_replacement() {
let root = tempfile::tempdir().unwrap();
let files = Arc::new(FileRegistry::default())
.extension(root.path())
.unwrap();
let source_path = files.resolve("source.bin").unwrap();
std::fs::write(&source_path.absolute, b"original audio").unwrap();
let mut source = source_path.open(OpenOptions::new().read(true)).unwrap();
std::fs::rename(&source_path.absolute, root.path().join("source.old")).unwrap();
std::fs::write(&source_path.absolute, b"replacement audio").unwrap();
let destination = files.resolve("destination.bin").unwrap();
let (mut stage, promoted) = destination.stage_from(&source_path, &source).unwrap();
assert!(!promoted);
source.seek(SeekFrom::Start(0)).unwrap();
std::io::copy(&mut source, &mut stage.file).unwrap();
stage.publish_new(&|| Ok(())).unwrap();
assert_eq!(
std::fs::read(&destination.absolute).unwrap(),
b"original audio"
);
}
#[cfg(unix)]
#[test]
fn stage_from_copies_open_source_after_path_becomes_symlink() {
use std::os::unix::fs::symlink;
let root = tempfile::tempdir().unwrap();
let files = Arc::new(FileRegistry::default())
.extension(root.path())
.unwrap();
let source_path = files.resolve("source.bin").unwrap();
std::fs::write(&source_path.absolute, b"original audio").unwrap();
let mut source = source_path.open(OpenOptions::new().read(true)).unwrap();
std::fs::rename(&source_path.absolute, root.path().join("source.old")).unwrap();
symlink("source.old", &source_path.absolute).unwrap();
let destination = files.resolve("destination.bin").unwrap();
let (mut stage, promoted) = destination.stage_from(&source_path, &source).unwrap();
assert!(!promoted);
source.seek(SeekFrom::Start(0)).unwrap();
std::io::copy(&mut source, &mut stage.file).unwrap();
stage.publish_new(&|| Ok(())).unwrap();
assert_eq!(
std::fs::read(&destination.absolute).unwrap(),
b"original audio"
);
}
#[cfg(unix)]
#[test]
fn stage_from_preserves_existing_destination_and_cleans_cancelled_stage() {
let root = tempfile::tempdir().unwrap();
let files = Arc::new(FileRegistry::default())
.extension(root.path())
.unwrap();
let source_path = files.resolve("source.bin").unwrap();
std::fs::write(&source_path.absolute, b"source audio").unwrap();
let source = source_path.open(OpenOptions::new().read(true)).unwrap();
let destination = files.resolve("destination.bin").unwrap();
std::fs::write(&destination.absolute, b"existing audio").unwrap();
let (stage, promoted) = destination.stage_from(&source_path, &source).unwrap();
assert!(promoted);
let error = stage.publish_new(&|| Ok(())).unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::AlreadyExists);
assert_eq!(
std::fs::read(&destination.absolute).unwrap(),
b"existing audio"
);
assert_eq!(std::fs::read_dir(root.path()).unwrap().count(), 2);
std::fs::remove_file(&destination.absolute).unwrap();
let (stage, promoted) = destination.stage_from(&source_path, &source).unwrap();
assert!(promoted);
let error = stage.publish_new(&|| Err("cancelled".into())).unwrap_err();
assert_eq!(error.to_string(), "cancelled");
assert!(!destination.absolute.exists());
assert_eq!(std::fs::read_dir(root.path()).unwrap().count(), 1);
}
}
mod tests;
@@ -0,0 +1,240 @@
use super::*;
use std::io::{Seek, SeekFrom, Write};
#[cfg(unix)]
#[test]
fn native_reads_reject_links_at_every_component_and_never_write_or_wait_on_special_files() {
use std::io::Read;
use std::os::unix::{fs::symlink, net::UnixListener};
let root = tempfile::tempdir().unwrap();
let files = Arc::new(FileRegistry::default())
.extension(root.path())
.unwrap();
std::fs::create_dir_all(root.path().join("album/disc")).unwrap();
std::fs::write(root.path().join("album/disc/song.wav"), b"original").unwrap();
#[cfg(any(target_os = "linux", target_os = "android"))]
{
use std::os::unix::fs::PermissionsExt;
let parent = root.path().join("album");
std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o111)).unwrap();
let result = files
.resolve("album/disc/song.wav")
.unwrap()
.open_native_read();
std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o700)).unwrap();
assert!(result.is_ok(), "execute-only parent: {result:?}");
}
let mut file = files
.resolve("album/disc/song.wav")
.unwrap()
.open_native_read()
.unwrap();
let mut data = Vec::new();
file.read_to_end(&mut data).unwrap();
assert_eq!(data, b"original");
assert!(file.write_all(b"changed").is_err());
for (target, link) in [
("album", "album-link"),
("album/disc/song.wav", "song-link.wav"),
("missing.wav", "dangling.wav"),
] {
symlink(target, root.path().join(link)).unwrap();
}
for path in ["album-link/disc/song.wav", "song-link.wav", "dangling.wav"] {
assert!(
files
.resolve(path)
.unwrap()
.open_native_read()
.unwrap_err()
.to_string()
.contains("symlinks")
);
}
assert_eq!(
files
.resolve("missing.wav")
.unwrap()
.open_native_read()
.unwrap_err()
.kind(),
io::ErrorKind::NotFound
);
assert!(
std::process::Command::new("mkfifo")
.arg(root.path().join("pipe.wav"))
.status()
.unwrap()
.success()
);
let _socket = UnixListener::bind(root.path().join("socket.wav")).unwrap();
for path in ["pipe.wav", "socket.wav"] {
assert!(
files
.resolve(path)
.unwrap()
.open_native_read()
.unwrap_err()
.to_string()
.contains("special files")
);
}
assert_eq!(
std::fs::read(root.path().join("album/disc/song.wav")).unwrap(),
b"original"
);
}
#[test]
fn exclusive_publication_preserves_racing_output_and_cleans_cancelled_stage() {
let root = tempfile::tempdir().unwrap();
let files = Arc::new(FileRegistry::default())
.extension(root.path())
.unwrap();
let path = files.resolve("track.flac").unwrap();
let mut stage = path.stage().unwrap();
stage.file.write_all(b"new audio").unwrap();
assert!(!path.absolute.exists());
let error = stage
.publish_new(&|| {
// A different publisher creates the destination after the
// planner's existence check but before the publication syscall.
std::fs::write(&path.absolute, b"other publisher").unwrap();
Ok(())
})
.unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::AlreadyExists);
assert_eq!(std::fs::read(&path.absolute).unwrap(), b"other publisher");
let cancelled = files.resolve("cancelled.flac").unwrap();
let mut stage = cancelled.stage().unwrap();
stage.file.write_all(b"cancelled audio").unwrap();
assert_eq!(
stage
.publish_new(&|| Err("cancelled".into()))
.unwrap_err()
.to_string(),
"cancelled"
);
assert!(!cancelled.absolute.exists());
path.remove().unwrap();
let mut stage = path.stage().unwrap();
stage.file.write_all(b"complete audio").unwrap();
stage.publish_new(&|| Ok(())).unwrap();
assert_eq!(std::fs::read(&path.absolute).unwrap(), b"complete audio");
assert_eq!(std::fs::read_dir(root.path()).unwrap().count(), 1);
}
#[cfg(unix)]
#[test]
fn stage_from_links_same_filesystem_source_and_survives_source_cleanup() {
use cap_std::fs::MetadataExt;
let root = tempfile::tempdir().unwrap();
let files = Arc::new(FileRegistry::default())
.extension(root.path())
.unwrap();
let source_path = files.resolve("source.bin").unwrap();
std::fs::write(&source_path.absolute, b"source audio").unwrap();
let source = source_path.open(OpenOptions::new().read(true)).unwrap();
let destination = files.resolve("destination.bin").unwrap();
let (stage, promoted) = destination.stage_from(&source_path, &source).unwrap();
assert!(promoted);
let source_metadata = source.metadata().unwrap();
let stage_metadata = stage.file.metadata().unwrap();
assert_eq!(source_metadata.dev(), stage_metadata.dev());
assert_eq!(source_metadata.ino(), stage_metadata.ino());
stage.publish_new(&|| Ok(())).unwrap();
drop(source);
source_path.remove().unwrap();
assert_eq!(
std::fs::read(&destination.absolute).unwrap(),
b"source audio"
);
assert_eq!(std::fs::read_dir(root.path()).unwrap().count(), 1);
}
#[cfg(unix)]
#[test]
fn stage_from_copies_open_source_after_path_replacement() {
let root = tempfile::tempdir().unwrap();
let files = Arc::new(FileRegistry::default())
.extension(root.path())
.unwrap();
let source_path = files.resolve("source.bin").unwrap();
std::fs::write(&source_path.absolute, b"original audio").unwrap();
let mut source = source_path.open(OpenOptions::new().read(true)).unwrap();
std::fs::rename(&source_path.absolute, root.path().join("source.old")).unwrap();
std::fs::write(&source_path.absolute, b"replacement audio").unwrap();
let destination = files.resolve("destination.bin").unwrap();
let (mut stage, promoted) = destination.stage_from(&source_path, &source).unwrap();
assert!(!promoted);
source.seek(SeekFrom::Start(0)).unwrap();
std::io::copy(&mut source, &mut stage.file).unwrap();
stage.publish_new(&|| Ok(())).unwrap();
assert_eq!(
std::fs::read(&destination.absolute).unwrap(),
b"original audio"
);
}
#[cfg(unix)]
#[test]
fn stage_from_copies_open_source_after_path_becomes_symlink() {
use std::os::unix::fs::symlink;
let root = tempfile::tempdir().unwrap();
let files = Arc::new(FileRegistry::default())
.extension(root.path())
.unwrap();
let source_path = files.resolve("source.bin").unwrap();
std::fs::write(&source_path.absolute, b"original audio").unwrap();
let mut source = source_path.open(OpenOptions::new().read(true)).unwrap();
std::fs::rename(&source_path.absolute, root.path().join("source.old")).unwrap();
symlink("source.old", &source_path.absolute).unwrap();
let destination = files.resolve("destination.bin").unwrap();
let (mut stage, promoted) = destination.stage_from(&source_path, &source).unwrap();
assert!(!promoted);
source.seek(SeekFrom::Start(0)).unwrap();
std::io::copy(&mut source, &mut stage.file).unwrap();
stage.publish_new(&|| Ok(())).unwrap();
assert_eq!(
std::fs::read(&destination.absolute).unwrap(),
b"original audio"
);
}
#[cfg(unix)]
#[test]
fn stage_from_preserves_existing_destination_and_cleans_cancelled_stage() {
let root = tempfile::tempdir().unwrap();
let files = Arc::new(FileRegistry::default())
.extension(root.path())
.unwrap();
let source_path = files.resolve("source.bin").unwrap();
std::fs::write(&source_path.absolute, b"source audio").unwrap();
let source = source_path.open(OpenOptions::new().read(true)).unwrap();
let destination = files.resolve("destination.bin").unwrap();
std::fs::write(&destination.absolute, b"existing audio").unwrap();
let (stage, promoted) = destination.stage_from(&source_path, &source).unwrap();
assert!(promoted);
let error = stage.publish_new(&|| Ok(())).unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::AlreadyExists);
assert_eq!(
std::fs::read(&destination.absolute).unwrap(),
b"existing audio"
);
assert_eq!(std::fs::read_dir(root.path()).unwrap().count(), 2);
std::fs::remove_file(&destination.absolute).unwrap();
let (stage, promoted) = destination.stage_from(&source_path, &source).unwrap();
assert!(promoted);
let error = stage.publish_new(&|| Err("cancelled".into())).unwrap_err();
assert_eq!(error.to_string(), "cancelled");
assert!(!destination.absolute.exists());
assert_eq!(std::fs::read_dir(root.path()).unwrap().count(), 1);
}