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(())
}
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");
write_temp_file_exclusive(&tmp_path, &sanitized)?;
let output = Command::new("abc2svg")
.arg("tosvg.js")
.arg(&tmp_path)
.output()
.map_err(|e| {
let _ = std::fs::remove_file(&tmp_path);
format!("failed to invoke abc2svg: {e}")
})?;
let _ = std::fs::remove_file(&tmp_path);
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.len() >= 12 && trimmed[..12].eq_ignore_ascii_case("%%javascript") {
let after = &trimmed[12..];
if after.is_empty() || after.starts_with(' ') || after.starts_with('\t') {
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: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())
}
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 ly_path = tmp_dir.join("input.ly");
let output_prefix = tmp_dir.join("output");
std::fs::write(&ly_path, &sanitized).map_err(|e| {
let _ = std::fs::remove_dir_all(&tmp_dir);
format!("failed to write temp file: {e}")
})?;
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| {
let _ = std::fs::remove_dir_all(&tmp_dir);
format!("failed to invoke lilypond: {e}")
})?;
if !result.status.success() {
let stderr = String::from_utf8_lossy(&result.stderr);
let _ = std::fs::remove_dir_all(&tmp_dir);
return Err(format!("lilypond exited with error: {stderr}"));
}
let svg_path = tmp_dir.join("output.svg");
let svg = std::fs::read_to_string(&svg_path).map_err(|e| {
let _ = std::fs::remove_dir_all(&tmp_dir);
format!("failed to read lilypond SVG output: {e}")
})?;
let _ = std::fs::remove_dir_all(&tmp_dir);
Ok(svg)
}
#[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 result = super::write_temp_file_exclusive(&path, "world");
assert!(result.is_err());
let _ = std::fs::remove_file(&path);
}
#[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_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_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");
}
}