use std::fs::OpenOptions;
use std::io::Write;
use std::process::Command;
use std::sync::OnceLock;
use std::sync::atomic::{AtomicU64, Ordering};
#[must_use]
pub fn is_available(command: &str) -> bool {
Command::new(command)
.arg("--version")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.is_ok()
}
#[must_use]
pub fn has_abc2svg() -> bool {
static CACHE: OnceLock<bool> = OnceLock::new();
*CACHE.get_or_init(|| is_available("abc2svg"))
}
#[must_use]
pub fn has_lilypond() -> bool {
static CACHE: OnceLock<bool> = OnceLock::new();
*CACHE.get_or_init(|| is_available("lilypond"))
}
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
fn unique_temp_path(prefix: &str, ext: &str) -> std::path::PathBuf {
let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
let pid = std::process::id();
std::env::temp_dir().join(format!("{prefix}_{pid}_{counter}.{ext}"))
}
fn write_temp_file_exclusive(path: &std::path::Path, content: &str) -> Result<(), String> {
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.open(path)
.map_err(|e| format!("failed to create temp file: {e}"))?;
file.write_all(content.as_bytes())
.map_err(|e| format!("failed to write temp file: {e}"))?;
Ok(())
}
struct TempFileGuard {
path: std::path::PathBuf,
}
impl Drop for TempFileGuard {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.path);
}
}
struct TempDirGuard {
path: std::path::PathBuf,
}
impl Drop for TempDirGuard {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
}
}
#[must_use = "invocation errors should not be silently discarded"]
pub fn invoke_abc2svg(abc_content: &str) -> Result<String, String> {
let sanitized = sanitize_abc_content(abc_content);
let tmp_path = unique_temp_path("chordsketch_abc", "abc");
let _guard = TempFileGuard {
path: tmp_path.clone(),
};
write_temp_file_exclusive(&tmp_path, &sanitized)?;
let output = Command::new("abc2svg")
.arg("tosvg.js")
.arg(&tmp_path)
.output()
.map_err(|e| format!("failed to invoke abc2svg: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("abc2svg exited with error: {stderr}"));
}
let html = String::from_utf8_lossy(&output.stdout);
extract_body_content(&html)
.ok_or_else(|| "failed to extract SVG from abc2svg output".to_string())
}
fn sanitize_abc_content(input: &str) -> String {
let mut output = String::with_capacity(input.len());
let mut in_js_block = false;
for line in input.lines() {
let trimmed = line.trim();
if in_js_block {
if trimmed.eq_ignore_ascii_case("%%endjs") {
in_js_block = false;
}
continue;
}
if trimmed.eq_ignore_ascii_case("%%beginjs") {
in_js_block = true;
continue;
}
if trimmed
.get(..12)
.is_some_and(|s| s.eq_ignore_ascii_case("%%javascript"))
{
let after = &trimmed[12..]; if after.chars().next().is_none_or(|c| c.is_whitespace()) {
continue;
}
}
if trimmed
.get(..4)
.is_some_and(|s| s.eq_ignore_ascii_case("%%js"))
{
let after = &trimmed[4..]; if after.chars().next().is_none_or(|c| c.is_whitespace()) {
continue;
}
}
if !output.is_empty() {
output.push('\n');
}
output.push_str(line);
}
output
}
const DANGEROUS_SCHEME_FUNCTIONS: &[&str] = &[
"system",
"getenv",
"open-input-file",
"open-output-file",
"open-file",
"primitive-load",
"primitive-load-path",
"eval-string",
"load",
"ly:gulp-file",
"ly:gulp-string",
"gulp-string",
"ly:exit",
"ly:system",
"ly:parser-include",
"ly:set-option",
];
fn sanitize_lilypond_content(input: &str) -> String {
let mut output = String::with_capacity(input.len());
for line in input.lines() {
if line_contains_dangerous_scheme(line) {
continue;
}
if !output.is_empty() {
output.push('\n');
}
output.push_str(line);
}
output
}
fn line_contains_dangerous_scheme(line: &str) -> bool {
let lower = line.to_ascii_lowercase();
for sigil in &["#(", "$("] {
let mut search_from = 0;
while let Some(pos) = lower[search_from..].find(sigil) {
let abs_pos = search_from + pos;
let after = &lower[abs_pos + sigil.len()..];
let trimmed = after.trim_start();
for &func in DANGEROUS_SCHEME_FUNCTIONS {
if trimmed.starts_with(func) {
return true;
}
}
search_from = abs_pos + sigil.len();
}
}
false
}
fn extract_body_content(html: &str) -> Option<String> {
let body_open = html.find("<body>")?;
let content_start = body_open + "<body>".len();
let content_end = html
.find("</body>")
.or_else(|| html.find("</html>"))
.unwrap_or(html.len());
if content_start > content_end {
return None;
}
Some(html[content_start..content_end].trim().to_string())
}
#[must_use = "invocation errors should not be silently discarded"]
pub fn invoke_lilypond(ly_content: &str) -> Result<String, String> {
let sanitized = sanitize_lilypond_content(ly_content);
let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
let pid = std::process::id();
let tmp_dir = std::env::temp_dir().join(format!("chordsketch_ly_{pid}_{counter}"));
std::fs::create_dir(&tmp_dir).map_err(|e| format!("failed to create temp directory: {e}"))?;
let _guard = TempDirGuard {
path: tmp_dir.clone(),
};
let ly_path = tmp_dir.join("input.ly");
let output_prefix = tmp_dir.join("output");
write_temp_file_exclusive(&ly_path, &sanitized)?;
let result = Command::new("lilypond")
.arg("-dsafe")
.arg("--svg")
.arg(format!("-o{}", output_prefix.display()))
.arg(&ly_path)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.output()
.map_err(|e| format!("failed to invoke lilypond: {e}"))?;
if !result.status.success() {
let stderr = String::from_utf8_lossy(&result.stderr);
return Err(format!("lilypond exited with error: {stderr}"));
}
let svg_path = tmp_dir.join("output.svg");
std::fs::read_to_string(&svg_path)
.map_err(|e| format!("failed to read lilypond SVG output: {e}"))
}
fn musescore_cmd() -> Option<&'static str> {
static CACHE: OnceLock<Option<&'static str>> = OnceLock::new();
*CACHE.get_or_init(|| {
if is_available("mscore") {
Some("mscore")
} else if is_available("musescore") {
Some("musescore")
} else {
None
}
})
}
#[must_use]
pub fn has_musescore() -> bool {
musescore_cmd().is_some()
}
pub fn sanitize_musicxml_content(input: &str) -> String {
let mut output = String::with_capacity(input.len());
let mut pos = 0;
let bytes = input.as_bytes();
let len = bytes.len();
while pos < len {
if bytes[pos] == b'<' {
if pos + 1 < len && bytes[pos + 1] == b'?' {
pos += 2;
while pos < len {
if bytes[pos] == b'?' && pos + 1 < len && bytes[pos + 1] == b'>' {
pos += 2;
break;
}
pos += 1;
}
} else if pos + 8 < len
&& bytes[pos + 1] == b'!'
&& bytes[pos + 2..pos + 9]
.iter()
.zip(b"DOCTYPE")
.all(|(a, b)| a.to_ascii_uppercase() == *b)
{
pos += 9; let mut bracket_depth = 0u32;
while pos < len {
match bytes[pos] {
b'[' => {
bracket_depth += 1;
pos += 1;
}
b']' => {
bracket_depth = bracket_depth.saturating_sub(1);
pos += 1;
}
b'>' if bracket_depth == 0 => {
pos += 1;
break;
}
_ => {
pos += 1;
}
}
}
} else {
output.push('<');
pos += 1;
}
} else {
let ch = input[pos..].chars().next().expect("valid UTF-8");
output.push(ch);
pos += ch.len_utf8();
}
}
output
}
fn collect_musescore_pages(tmp_dir: &std::path::Path) -> Result<String, String> {
let mut pages = String::new();
let mut page = 1u32;
loop {
let page_path = tmp_dir.join(format!("output-{page}.svg"));
if !page_path.exists() {
break;
}
match std::fs::read_to_string(&page_path) {
Ok(content) => {
pages.push_str("<div class=\"musicxml-page\">");
pages.push_str(content.trim());
pages.push_str("</div>\n");
}
Err(e) => {
return Err(format!("failed to read MuseScore SVG page {page}: {e}"));
}
}
page += 1;
}
Ok(pages)
}
#[must_use = "invocation errors should not be silently discarded"]
pub fn invoke_musescore(musicxml_content: &str) -> Result<String, String> {
let cmd_name = musescore_cmd()
.ok_or_else(|| "MuseScore is not available (install mscore or musescore)".to_string())?;
let sanitized = sanitize_musicxml_content(musicxml_content);
let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
let pid = std::process::id();
let tmp_dir = std::env::temp_dir().join(format!("chordsketch_mxl_{pid}_{counter}"));
std::fs::create_dir(&tmp_dir).map_err(|e| format!("failed to create temp directory: {e}"))?;
let _guard = TempDirGuard {
path: tmp_dir.clone(),
};
let input_path = tmp_dir.join("input.xml");
let output_svg = tmp_dir.join("output.svg");
write_temp_file_exclusive(&input_path, &sanitized)?;
let result = Command::new(cmd_name)
.arg("-o")
.arg(&output_svg)
.arg(&input_path)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.output()
.map_err(|e| format!("failed to invoke {cmd_name}: {e}"))?;
if !result.status.success() {
let stderr = String::from_utf8_lossy(&result.stderr);
return Err(format!("{cmd_name} exited with error: {stderr}"));
}
if output_svg.exists() {
std::fs::read_to_string(&output_svg)
.map_err(|e| format!("failed to read MuseScore SVG output: {e}"))
} else {
let pages = collect_musescore_pages(&tmp_dir)?;
if pages.is_empty() {
return Err(
"MuseScore produced no SVG output (expected output.svg or output-1.svg)"
.to_string(),
);
}
Ok(pages)
}
}
#[must_use]
pub fn has_perl_chordpro() -> bool {
static CACHE: OnceLock<bool> = OnceLock::new();
*CACHE.get_or_init(|| is_available("chordpro"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn nonexistent_tool_returns_false() {
assert!(!is_available("this-command-definitely-does-not-exist-xyz"));
}
#[test]
fn unique_temp_paths_are_distinct() {
let paths: Vec<_> = (0..100)
.map(|_| super::unique_temp_path("test", "tmp"))
.collect();
let unique: std::collections::HashSet<_> = paths.iter().collect();
assert_eq!(unique.len(), paths.len());
}
#[test]
fn write_temp_file_exclusive_prevents_overwrite() {
let path = super::unique_temp_path("test_excl", "tmp");
super::write_temp_file_exclusive(&path, "hello").unwrap();
let _guard = super::TempFileGuard { path: path.clone() };
let result = super::write_temp_file_exclusive(&path, "world");
assert!(result.is_err());
}
#[test]
fn temp_file_guard_removes_file_on_drop() {
let path = super::unique_temp_path("test_guard_file", "tmp");
super::write_temp_file_exclusive(&path, "data").unwrap();
assert!(path.exists(), "file should exist before guard drops");
{
let _guard = super::TempFileGuard { path: path.clone() };
} assert!(
!path.exists(),
"file should be removed after TempFileGuard drops"
);
}
#[test]
fn temp_dir_guard_removes_dir_on_drop() {
let dir =
std::env::temp_dir().join(format!("chordsketch_test_guard_{}", std::process::id()));
std::fs::create_dir(&dir).unwrap();
std::fs::write(dir.join("inner.txt"), "hello").unwrap();
assert!(dir.exists(), "dir should exist before guard drops");
{
let _guard = super::TempDirGuard { path: dir.clone() };
} assert!(
!dir.exists(),
"dir should be removed after TempDirGuard drops"
);
}
#[test]
fn extract_body_content_basic() {
let html = "<html><body>\n<svg>hello</svg>\n</body></html>";
let result = super::extract_body_content(html);
assert_eq!(result, Some("<svg>hello</svg>".to_string()));
}
#[test]
fn extract_body_content_missing_body() {
let html = "<html><div>no body tag</div></html>";
assert!(super::extract_body_content(html).is_none());
}
#[test]
fn extract_body_content_empty_body() {
let html = "<html><body></body></html>";
assert_eq!(super::extract_body_content(html), Some(String::new()));
}
#[test]
#[ignore]
fn abc2svg_detection() {
assert!(has_abc2svg(), "abc2svg not found in PATH");
}
#[test]
#[ignore]
fn invoke_abc2svg_produces_svg() {
let abc = "X:1\nT:Test\nM:4/4\nK:C\nCDEF|GABc|\n";
let result = invoke_abc2svg(abc);
assert!(result.is_ok(), "invoke_abc2svg failed: {:?}", result.err());
let svg = result.unwrap();
assert!(svg.contains("<svg"), "output should contain SVG element");
}
#[test]
fn invoke_abc2svg_fails_gracefully_without_tool() {
if has_abc2svg() {
return;
}
let result = invoke_abc2svg("X:1\n");
assert!(result.is_err());
}
#[test]
#[ignore]
fn lilypond_detection() {
assert!(has_lilypond(), "lilypond not found in PATH");
}
#[test]
#[ignore]
fn invoke_lilypond_produces_svg() {
let ly = "\\relative c' { c4 d e f | g2 g | }\n";
let result = invoke_lilypond(ly);
assert!(result.is_ok(), "invoke_lilypond failed: {:?}", result.err());
let svg = result.unwrap();
assert!(svg.contains("<svg"), "output should contain SVG element");
}
#[test]
fn invoke_lilypond_fails_gracefully_without_tool() {
if has_lilypond() {
return;
}
let result = invoke_lilypond("{ c4 }\n");
assert!(result.is_err());
}
#[test]
#[ignore]
fn invoke_lilypond_blocks_scheme_code() {
if !has_lilypond() {
return;
}
let ly = r#"#(system "echo pwned")
\relative c' { c4 d e f | }
"#;
let result = invoke_lilypond(ly);
assert!(
result.is_err(),
"Lilypond should reject Scheme system calls with -dsafe"
);
}
#[test]
fn sanitize_abc_strips_beginjs_endjs_block() {
let input = "X:1\nK:C\n%%beginjs\nprocess.exit(1);\n%%endjs\nCDEF|\n";
let result = super::sanitize_abc_content(input);
assert_eq!(result, "X:1\nK:C\nCDEF|");
assert!(!result.contains("beginjs"));
assert!(!result.contains("process"));
}
#[test]
fn sanitize_abc_strips_javascript_directive() {
let input = "X:1\nK:C\n%%javascript require('child_process').exec('id')\nCDEF|\n";
let result = super::sanitize_abc_content(input);
assert_eq!(result, "X:1\nK:C\nCDEF|");
assert!(!result.contains("javascript"));
assert!(!result.contains("child_process"));
}
#[test]
fn sanitize_abc_preserves_normal_content() {
let input = "X:1\nT:Test\nM:4/4\nK:C\n%%MIDI program 1\nCDEF|GABc|\n";
let result = super::sanitize_abc_content(input);
assert_eq!(
result,
"X:1\nT:Test\nM:4/4\nK:C\n%%MIDI program 1\nCDEF|GABc|"
);
}
#[test]
fn sanitize_abc_case_insensitive_beginjs() {
let input = "X:1\n%%BeginJS\nalert(1);\n%%EndJS\nK:C\n";
let result = super::sanitize_abc_content(input);
assert_eq!(result, "X:1\nK:C");
}
#[test]
fn sanitize_abc_case_insensitive_javascript() {
let input = "X:1\n%%JAVASCRIPT alert(1)\nK:C\n";
let result = super::sanitize_abc_content(input);
assert_eq!(result, "X:1\nK:C");
let input = "X:1\n%%Javascript require('fs')\nK:C\n";
let result = super::sanitize_abc_content(input);
assert_eq!(result, "X:1\nK:C");
let input = "X:1\n%%jAvAsCrIpT evil()\nK:C\n";
let result = super::sanitize_abc_content(input);
assert_eq!(result, "X:1\nK:C");
let input = "X:1\n%%JAVASCRIPT\nK:C\n";
let result = super::sanitize_abc_content(input);
assert_eq!(result, "X:1\nK:C");
}
#[test]
fn sanitize_abc_does_not_strip_javascript_prefix_in_other_words() {
let input = "X:1\n%%javascriptfoo bar\nK:C\n";
let result = super::sanitize_abc_content(input);
assert!(result.contains("%%javascriptfoo"));
}
#[test]
fn sanitize_abc_strips_js_shorthand_directive() {
let input = "X:1\nK:C\n%%js require('child_process').exec('id')\nCDEF|\n";
let result = super::sanitize_abc_content(input);
assert_eq!(result, "X:1\nK:C\nCDEF|");
assert!(!result.contains("%%js"));
assert!(!result.contains("child_process"));
}
#[test]
fn sanitize_abc_js_case_insensitive() {
let input = "X:1\n%%JS alert(1)\nK:C\n";
let result = super::sanitize_abc_content(input);
assert_eq!(result, "X:1\nK:C");
let input = "X:1\n%%Js alert(1)\nK:C\n";
let result = super::sanitize_abc_content(input);
assert_eq!(result, "X:1\nK:C");
let input = "X:1\n%%js\nK:C\n";
let result = super::sanitize_abc_content(input);
assert_eq!(result, "X:1\nK:C");
let input = "X:1\n%%js\talert(1)\nK:C\n";
let result = super::sanitize_abc_content(input);
assert_eq!(result, "X:1\nK:C");
assert!(!result.contains("alert"));
}
#[test]
fn sanitize_abc_js_other_ascii_whitespace_separators() {
let input = "X:1\n%%javascript\x0bevil()\nK:C\n";
let result = super::sanitize_abc_content(input);
assert_eq!(result, "X:1\nK:C");
assert!(!result.contains("evil"));
let input = "X:1\n%%javascript\x0cevil()\nK:C\n";
let result = super::sanitize_abc_content(input);
assert_eq!(result, "X:1\nK:C");
assert!(!result.contains("evil"));
let input = "X:1\n%%js\x0bevil()\nK:C\n";
let result = super::sanitize_abc_content(input);
assert_eq!(result, "X:1\nK:C");
assert!(!result.contains("evil"));
let input = "X:1\n%%js\x0cevil()\nK:C\n";
let result = super::sanitize_abc_content(input);
assert_eq!(result, "X:1\nK:C");
assert!(!result.contains("evil"));
}
#[test]
fn sanitize_abc_js_no_panic_on_multibyte_char_at_boundary() {
let input = "X:1\n%%jé injected\nK:C\n";
let result = super::sanitize_abc_content(input);
assert!(result.contains("%%jé"));
let input2 = "X:1\n%%javascripé evil\nK:C\n";
let result2 = super::sanitize_abc_content(input2);
assert!(result2.contains("%%javascripé"));
}
#[test]
fn sanitize_abc_does_not_strip_js_prefix_in_other_words() {
let input = "X:1\n%%json {\"key\": \"value\"}\nK:C\n";
let result = super::sanitize_abc_content(input);
assert!(result.contains("%%json"));
}
#[test]
fn sanitize_lilypond_strips_system_call() {
let input = "\\relative c' { c4 d e f }\n#(system \"echo pwned\")\n";
let result = super::sanitize_lilypond_content(input);
assert!(!result.contains("system"));
assert!(result.contains("\\relative"));
}
#[test]
fn sanitize_lilypond_strips_getenv() {
let input = "\\relative c' { c4 }\n#(getenv \"HOME\")\n";
let result = super::sanitize_lilypond_content(input);
assert!(!result.contains("getenv"));
}
#[test]
fn sanitize_lilypond_strips_file_access() {
let input = "#(open-input-file \"/etc/passwd\")\n\\relative c' { c4 }\n";
let result = super::sanitize_lilypond_content(input);
assert!(!result.contains("open-input-file"));
assert!(result.contains("\\relative"));
}
#[test]
fn sanitize_lilypond_strips_ly_system() {
let input = "\\relative c' { c4 }\n#(ly:system \"rm -rf /\")\n";
let result = super::sanitize_lilypond_content(input);
assert!(!result.contains("ly:system"));
}
#[test]
fn sanitize_lilypond_case_insensitive() {
let input = "#(SYSTEM \"echo pwned\")\n\\relative c' { c4 }\n";
let result = super::sanitize_lilypond_content(input);
assert!(!result.contains("SYSTEM"));
}
#[test]
fn sanitize_lilypond_preserves_normal_content() {
let input = "\\version \"2.24.0\"\n\\relative c' {\n c4 d e f |\n g2 g |\n}\n";
let result = super::sanitize_lilypond_content(input);
assert_eq!(
result,
"\\version \"2.24.0\"\n\\relative c' {\n c4 d e f |\n g2 g |\n}"
);
}
#[test]
fn sanitize_lilypond_strips_eval_string() {
let input = "#(eval-string \"(system \\\"id\\\")\")\n\\relative c' { c4 }\n";
let result = super::sanitize_lilypond_content(input);
assert!(!result.contains("eval-string"));
}
#[test]
fn sanitize_lilypond_strips_gulp_file() {
let input = "#(ly:gulp-file \"/etc/passwd\")\n\\relative c' { c4 }\n";
let result = super::sanitize_lilypond_content(input);
assert!(!result.contains("ly:gulp-file"));
}
#[test]
fn sanitize_lilypond_strips_gulp_string() {
let input = "#(ly:gulp-string \"/etc/passwd\")\n\\relative c' { c4 }\n";
let result = super::sanitize_lilypond_content(input);
assert!(!result.contains("ly:gulp-string"));
assert!(result.contains("\\relative"));
}
#[test]
fn sanitize_lilypond_preserves_ly_format() {
let input = "#(ly:format \"~a\" \"x\")\n\\relative c' { c4 }\n";
let result = super::sanitize_lilypond_content(input);
assert!(
result.contains("ly:format"),
"ly:format on its own must be preserved; got {result:?}"
);
assert!(
result.contains("\\relative"),
"surrounding music content must also survive"
);
}
#[test]
fn sanitize_lilypond_strips_ly_exit() {
let input = "#(ly:exit 0)\n\\relative c' { c4 }\n";
let result = super::sanitize_lilypond_content(input);
assert!(!result.contains("ly:exit"));
}
#[test]
fn sanitize_lilypond_strips_dollar_sign_system() {
let input = "$(system \"echo pwned\")\n\\relative c' { c4 }\n";
let result = super::sanitize_lilypond_content(input);
assert!(!result.contains("system"));
assert!(result.contains("\\relative"));
}
#[test]
fn sanitize_lilypond_strips_dollar_sign_with_space() {
let input = "$( system \"echo pwned\")\n\\relative c' { c4 }\n";
let result = super::sanitize_lilypond_content(input);
assert!(!result.contains("system"));
}
#[test]
fn sanitize_lilypond_strips_dollar_sign_getenv() {
let input = "$(getenv \"HOME\")\n\\relative c' { c4 }\n";
let result = super::sanitize_lilypond_content(input);
assert!(!result.contains("getenv"));
}
#[test]
fn sanitize_lilypond_strips_dollar_sign_ly_system() {
let input = "$(ly:system \"rm -rf /\")\n\\relative c' { c4 }\n";
let result = super::sanitize_lilypond_content(input);
assert!(!result.contains("ly:system"));
}
#[test]
fn sanitize_lilypond_dollar_sign_case_insensitive() {
let input = "$(SYSTEM \"echo pwned\")\n\\relative c' { c4 }\n";
let result = super::sanitize_lilypond_content(input);
assert!(!result.contains("SYSTEM"));
}
#[test]
fn sanitize_lilypond_strips_multi_space_scheme() {
let input = "#( system \"rm -rf /\")\n\\relative c' { c4 }\n";
let result = super::sanitize_lilypond_content(input);
assert!(!result.contains("system"));
let input2 = "$( getenv \"SECRET\")\n\\relative c' { c4 }\n";
let result2 = super::sanitize_lilypond_content(input2);
assert!(!result2.contains("getenv"));
}
#[test]
fn sanitize_lilypond_preserves_normal_dollar_sign() {
let input = "\\relative c' { c4$\\markup{test} }\n";
let result = super::sanitize_lilypond_content(input);
assert_eq!(result, "\\relative c' { c4$\\markup{test} }");
}
#[test]
fn sanitize_lilypond_strips_load() {
let input = "#(load \"/etc/lilypond-init.scm\")\n\\relative c' { c4 }\n";
let result = super::sanitize_lilypond_content(input);
assert!(!result.contains("load"));
}
#[test]
fn sanitize_lilypond_strips_ly_parser_include() {
let input = "#(ly:parser-include \"/etc/passwd\")\n\\relative c' { c4 }\n";
let result = super::sanitize_lilypond_content(input);
assert!(!result.contains("ly:parser-include"));
}
#[test]
fn sanitize_lilypond_strips_ly_set_option() {
let input = "#(ly:set-option 'safe #f)\n\\relative c' { c4 }\n";
let result = super::sanitize_lilypond_content(input);
assert!(!result.contains("ly:set-option"));
}
#[test]
#[ignore]
fn perl_chordpro_detection() {
assert!(has_perl_chordpro(), "chordpro (Perl) not found in PATH");
}
#[test]
fn sanitize_musicxml_strips_processing_instruction() {
let input = r#"<?xml version="1.0" encoding="UTF-8"?><root/>"#;
let result = super::sanitize_musicxml_content(input);
assert!(!result.contains("<?xml"), "PI should be stripped");
assert!(result.contains("<root/>"), "element content should remain");
}
#[test]
fn sanitize_musicxml_strips_doctype() {
let input =
r#"<!DOCTYPE score-partwise PUBLIC "-//foo//bar" "http://evil.example/"><root/>"#;
let result = super::sanitize_musicxml_content(input);
assert!(!result.contains("DOCTYPE"), "DOCTYPE should be stripped");
assert!(result.contains("<root/>"), "element content should remain");
}
#[test]
fn sanitize_musicxml_strips_doctype_case_insensitive() {
let input = "<!doctype foo><root/>";
let result = super::sanitize_musicxml_content(input);
assert!(
!result.contains("doctype"),
"doctype (lowercase) should be stripped"
);
assert!(result.contains("<root/>"));
}
#[test]
fn sanitize_musicxml_preserves_normal_elements() {
let input =
"<score-partwise><part id=\"P1\"><measure number=\"1\"/></part></score-partwise>";
let result = super::sanitize_musicxml_content(input);
assert_eq!(result, input, "normal MusicXML should be preserved");
}
#[test]
fn sanitize_musicxml_preserves_utf8_text() {
let input = "<part>café naïve résumé</part>";
let result = super::sanitize_musicxml_content(input);
assert_eq!(result, input, "UTF-8 text content should be preserved");
}
#[test]
fn sanitize_musicxml_strips_multiple_pis() {
let input = "<?foo bar?><?baz quux?><root/>";
let result = super::sanitize_musicxml_content(input);
assert!(!result.contains("<?"), "all PIs should be stripped");
assert!(result.contains("<root/>"));
}
#[test]
fn sanitize_musicxml_strips_doctype_with_internal_subset() {
let input = "<!DOCTYPE score-partwise [\
<!ELEMENT score-partwise EMPTY>\
]><score-partwise/>";
let result = super::sanitize_musicxml_content(input);
assert!(!result.contains("DOCTYPE"), "DOCTYPE should be stripped");
assert!(
!result.contains(']'),
"residual `]` from internal subset should not appear"
);
assert!(
result.contains("<score-partwise/>"),
"element content should remain"
);
}
struct TempDirGuard(std::path::PathBuf);
impl Drop for TempDirGuard {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn make_test_temp_dir(label: &str) -> (std::path::PathBuf, TempDirGuard) {
let dir = std::env::temp_dir().join(format!(
"test_ms_{label}_{}_{}_collect",
std::process::id(),
super::TEMP_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
));
std::fs::create_dir_all(&dir).expect("failed to create test temp dir");
let guard = TempDirGuard(dir.clone());
(dir, guard)
}
#[test]
fn collect_musescore_pages_single_page() {
let (dir, _guard) = make_test_temp_dir("single");
std::fs::write(dir.join("output-1.svg"), "<svg>page1</svg>").unwrap();
let result = super::collect_musescore_pages(&dir).unwrap();
assert!(
result.contains("musicxml-page"),
"page should be wrapped in musicxml-page div"
);
assert!(
result.contains("<svg>page1</svg>"),
"SVG content should be present"
);
}
#[test]
fn collect_musescore_pages_multi_page() {
let (dir, _guard) = make_test_temp_dir("multi");
std::fs::write(dir.join("output-1.svg"), "<svg>page1</svg>").unwrap();
std::fs::write(dir.join("output-2.svg"), "<svg>page2</svg>").unwrap();
let result = super::collect_musescore_pages(&dir).unwrap();
assert_eq!(
result.matches("musicxml-page").count(),
2,
"two pages should produce two wrapper divs"
);
assert!(
result.contains("<svg>page1</svg>"),
"page 1 SVG should be present"
);
assert!(
result.contains("<svg>page2</svg>"),
"page 2 SVG should be present"
);
let p1 = result.find("<svg>page1</svg>").unwrap();
let p2 = result.find("<svg>page2</svg>").unwrap();
assert!(p1 < p2, "page 1 should appear before page 2");
}
#[test]
fn collect_musescore_pages_empty_dir_returns_empty_string() {
let (dir, _guard) = make_test_temp_dir("empty");
let result = super::collect_musescore_pages(&dir).unwrap();
assert!(result.is_empty(), "no page files → empty string");
}
#[test]
fn invoke_musescore_fails_gracefully_without_tool() {
if has_musescore() {
return; }
let mxl = "<score-partwise/>";
let result = invoke_musescore(mxl);
assert!(result.is_err(), "should fail without musescore installed");
}
#[test]
#[ignore]
fn musescore_detection() {
assert!(has_musescore(), "mscore or musescore not found in PATH");
}
#[test]
#[ignore]
fn invoke_musescore_produces_svg() {
let mxl = r#"<?xml version="1.0" encoding="UTF-8"?>
<score-partwise version="3.1">
<part-list>
<score-part id="P1"><part-name>Music</part-name></score-part>
</part-list>
<part id="P1">
<measure number="1">
<note>
<pitch><step>C</step><octave>4</octave></pitch>
<duration>4</duration><type>whole</type>
</note>
</measure>
</part>
</score-partwise>"#;
let result = invoke_musescore(mxl);
assert!(
result.is_ok(),
"invoke_musescore failed: {:?}",
result.err()
);
let svg = result.unwrap();
assert!(
svg.contains("<svg") || svg.contains("<div class=\"musicxml-page\">"),
"output should contain SVG or page wrapper"
);
}
}