use std::collections::{HashMap, HashSet};
use std::fs::OpenOptions;
use std::io::Write as _;
#[cfg(unix)]
use std::os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _};
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::{Context, Result, bail};
use suno_core::{Action, Clip, Failure, LineageStore, Manifest, Plan, render_library_index};
use crate::download::write_atomic;
pub const MANIFEST_NAME: &str = ".suno-manifest.json";
pub const INDEX_NAME: &str = "suno-index.json";
pub const GRAPH_NAME: &str = ".suno-lineage.json";
const LOCK_NAME: &str = ".suno.lock";
const FAILURES_NAME: &str = ".suno-failures.log";
const AUDIT_NAME: &str = ".suno-audit.log";
#[cfg(unix)]
const PRIVATE_FILE_MODE: u32 = 0o600;
pub fn config_path(override_path: Option<&Path>) -> Option<PathBuf> {
override_path
.map(Path::to_path_buf)
.or_else(default_config_path)
}
pub fn default_config_path() -> Option<PathBuf> {
if cfg!(windows) {
std::env::var_os("APPDATA").map(|base| PathBuf::from(base).join("suno").join("config.toml"))
} else {
std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")))
.map(|base| base.join("suno").join("config.toml"))
}
}
pub fn load_manifest(dest: &Path) -> Result<Manifest> {
let path = dest.join(MANIFEST_NAME);
match std::fs::read(&path) {
Ok(bytes) => serde_json::from_slice(&bytes)
.with_context(|| format!("the manifest at {} is corrupt", path.display())),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(Manifest::new()),
Err(err) => Err(err).with_context(|| format!("could not read {}", path.display())),
}
}
pub fn save_manifest(dest: &Path, manifest: &Manifest) -> Result<()> {
let bytes = serde_json::to_vec_pretty(manifest).context("could not serialise the manifest")?;
let path = dest.join(MANIFEST_NAME);
write_atomic(&path, &bytes).context("could not write the manifest")?;
set_private_file_permissions(&path).context("could not secure the manifest")
}
pub fn load_graph(dest: &Path) -> Result<LineageStore> {
let path = dest.join(GRAPH_NAME);
match std::fs::read(&path) {
Ok(bytes) => serde_json::from_slice(&bytes)
.with_context(|| format!("the lineage store at {} is corrupt", path.display())),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(LineageStore::new()),
Err(err) => Err(err).with_context(|| format!("could not read {}", path.display())),
}
}
pub fn save_graph(dest: &Path, store: &LineageStore) -> Result<()> {
let bytes =
serde_json::to_vec_pretty(store).context("could not serialise the lineage store")?;
let path = dest.join(GRAPH_NAME);
write_atomic(&path, &bytes).context("could not write the lineage store")?;
set_private_file_permissions(&path).context("could not secure the lineage store")
}
pub fn save_index(
dest: &Path,
manifest: &Manifest,
store: &LineageStore,
clips: &HashMap<&str, &Clip>,
) -> Result<()> {
let contents = render_library_index(manifest, store, clips);
write_atomic(&dest.join(INDEX_NAME), contents.as_bytes())
.context("could not write the library index")
}
pub struct LockGuard {
path: PathBuf,
}
impl Drop for LockGuard {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.path);
}
}
pub fn acquire_lock(dest: &Path) -> Result<LockGuard> {
let path = dest.join(LOCK_NAME);
let mut opts = OpenOptions::new();
opts.write(true).create_new(true);
#[cfg(unix)]
opts.mode(PRIVATE_FILE_MODE);
match opts.open(&path) {
Ok(mut file) => {
let _ = writeln!(file, "{}", std::process::id());
if let Err(err) = set_private_file_permissions(&path) {
return Err(err)
.with_context(|| format!("could not secure lock {}", path.display()));
}
Ok(LockGuard { path })
}
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => bail!(
"another suno run is active (lock at {}); remove it if no run is in progress",
path.display()
),
Err(err) => Err(err).with_context(|| format!("could not create lock {}", path.display())),
}
}
pub fn append_failures(
dest: &Path,
failures: &[Failure],
clips: &HashMap<&str, &Clip>,
) -> Result<()> {
if failures.is_empty() {
return Ok(());
}
let now = iso_utc(unix_now());
let mut buf = String::new();
for failure in failures {
let (title, url) = clips
.get(failure.clip_id.as_str())
.map(|clip| (clip.title.as_str(), clip.audio_url.as_str()))
.unwrap_or(("", ""));
buf.push_str(&format!(
"{now}\t{}\t{title}\t{url}\t{}\n",
failure.clip_id, failure.reason
));
}
append(&dest.join(FAILURES_NAME), &buf)
}
pub fn append_audit(
dest: &Path,
plan: &Plan,
failed: &HashSet<&str>,
rename_owner: &HashMap<&str, &str>,
) -> Result<()> {
let now = iso_utc(unix_now());
let mut buf = String::new();
for action in &plan.actions {
match action {
Action::Delete { path, clip_id } if !failed.contains(clip_id.as_str()) => {
buf.push_str(&format!("{now}\tDELETE\t{clip_id}\t{path}\t\n"));
}
Action::DeleteArtifact { path, owner_id, .. }
if !failed.contains(owner_id.as_str()) =>
{
buf.push_str(&format!("{now}\tDELETE\t{owner_id}\t{path}\t\n"));
}
Action::Rename { from, to } => {
let owner = rename_owner
.get(to.as_str())
.copied()
.unwrap_or(to.as_str());
if !failed.contains(owner) {
buf.push_str(&format!("{now}\tRENAME\t\t{from}\t{to}\n"));
}
}
_ => {}
}
}
if buf.is_empty() {
return Ok(());
}
append(&dest.join(AUDIT_NAME), &buf)
}
pub fn append_owner_pin(
dest: &Path,
action: &str,
user_id: &str,
display_name: &str,
) -> Result<()> {
let now = iso_utc(unix_now());
let line = format!("{now}\tOWNER\t{action}\t{user_id}\t{display_name}\n");
append(&dest.join(AUDIT_NAME), &line)
}
fn append(path: &Path, text: &str) -> Result<()> {
let mut opts = OpenOptions::new();
opts.create(true).append(true);
#[cfg(unix)]
opts.mode(PRIVATE_FILE_MODE);
let mut file = opts
.open(path)
.with_context(|| format!("could not open {}", path.display()))?;
file.write_all(text.as_bytes())
.with_context(|| format!("could not append to {}", path.display()))?;
set_private_file_permissions(path)
}
#[cfg(unix)]
fn set_private_file_permissions(path: &Path) -> Result<()> {
std::fs::set_permissions(path, std::fs::Permissions::from_mode(PRIVATE_FILE_MODE))
.with_context(|| format!("could not set permissions on {}", path.display()))?;
Ok(())
}
#[cfg(not(unix))]
fn set_private_file_permissions(_path: &Path) -> Result<()> {
Ok(())
}
fn unix_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn iso_utc(secs: u64) -> String {
let days = secs / 86_400;
let rem = secs % 86_400;
let (hour, minute, second) = (rem / 3_600, (rem % 3_600) / 60, rem % 60);
let (year, month, day) = days_to_civil(days);
format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
}
fn days_to_civil(days: u64) -> (i64, u32, u32) {
let z = days as i64 + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let day = (doy - (153 * mp + 2) / 5 + 1) as u32;
let month = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;
let year = if month <= 2 { y + 1 } else { y };
(year, month, day)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU64, Ordering};
use suno_core::{AudioFormat, ManifestEntry, Resolution, ResolveStatus, RootInfo};
static SEQ: AtomicU64 = AtomicU64::new(0);
fn temp_dir(tag: &str) -> PathBuf {
let seq = SEQ.fetch_add(1, Ordering::Relaxed);
let dir = Path::new("target").join(format!(
"logs-{tag}-{}-{seq}-{}",
std::process::id(),
unix_now()
));
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn iso_utc_formats_known_instants() {
assert_eq!(iso_utc(0), "1970-01-01T00:00:00Z");
assert_eq!(iso_utc(1_710_080_521), "2024-03-10T14:22:01Z");
}
#[test]
fn load_missing_manifest_is_empty() {
let dir = temp_dir("missing");
assert!(load_manifest(&dir).unwrap().is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn save_then_load_roundtrips() {
let dir = temp_dir("roundtrip");
let mut manifest = Manifest::new();
manifest.insert(
"clip",
ManifestEntry {
path: "a.flac".to_owned(),
format: AudioFormat::Flac,
meta_hash: "m".to_owned(),
art_hash: "a".to_owned(),
size: 10,
preserve: true,
..Default::default()
},
);
save_manifest(&dir, &manifest).unwrap();
let back = load_manifest(&dir).unwrap();
assert_eq!(back, manifest);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn corrupt_manifest_errors() {
let dir = temp_dir("corrupt");
std::fs::write(dir.join(MANIFEST_NAME), b"not json {{{").unwrap();
assert!(load_manifest(&dir).is_err());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn lock_is_exclusive_and_released_on_drop() {
let dir = temp_dir("lock");
let guard = acquire_lock(&dir).unwrap();
assert!(acquire_lock(&dir).is_err());
drop(guard);
assert!(acquire_lock(&dir).is_ok());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn audit_log_records_only_successful_deletes_and_renames() {
let dir = temp_dir("audit");
let plan = Plan {
actions: vec![
Action::Delete {
path: "gone.flac".to_owned(),
clip_id: "g".to_owned(),
},
Action::Delete {
path: "kept.flac".to_owned(),
clip_id: "k".to_owned(),
},
Action::Rename {
from: "old.flac".to_owned(),
to: "new.flac".to_owned(),
},
Action::Rename {
from: "bad.flac".to_owned(),
to: "worse.flac".to_owned(),
},
],
};
let failed: HashSet<&str> = ["k", "r2"].into_iter().collect();
let rename_owner: HashMap<&str, &str> = [("new.flac", "r1"), ("worse.flac", "r2")]
.into_iter()
.collect();
append_audit(&dir, &plan, &failed, &rename_owner).unwrap();
let log = std::fs::read_to_string(dir.join(AUDIT_NAME)).unwrap();
assert!(log.contains("DELETE\tg\tgone.flac"));
assert!(!log.contains("kept.flac"));
assert!(log.contains("RENAME\t\told.flac\tnew.flac"));
assert!(!log.contains("worse.flac"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn failures_log_records_id_title_url_reason() {
let dir = temp_dir("failures");
let clip = Clip {
id: "x".to_owned(),
title: "Boom Track".to_owned(),
audio_url: "https://cdn1.suno.ai/x.mp3".to_owned(),
..Default::default()
};
let mut clips: HashMap<&str, &Clip> = HashMap::new();
clips.insert("x", &clip);
let failures = vec![Failure {
clip_id: "x".to_owned(),
reason: "timeout".to_owned(),
}];
append_failures(&dir, &failures, &clips).unwrap();
let log = std::fs::read_to_string(dir.join(FAILURES_NAME)).unwrap();
assert!(log.contains("\tx\tBoom Track\thttps://cdn1.suno.ai/x.mp3\ttimeout"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn empty_failures_writes_nothing() {
let dir = temp_dir("nofail");
append_failures(&dir, &[], &HashMap::new()).unwrap();
assert!(!dir.join(FAILURES_NAME).exists());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn load_missing_graph_is_empty() {
let dir = temp_dir("graph-missing");
let store = load_graph(&dir).unwrap();
assert!(store.is_empty());
assert_eq!(store.schema_version, 1);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn save_then_load_graph_roundtrips() {
let dir = temp_dir("graph-roundtrip");
let clip = Clip {
id: "child".to_owned(),
title: "Cover".to_owned(),
clip_type: "gen".to_owned(),
task: "cover".to_owned(),
cover_clip_id: "root".to_owned(),
edited_clip_id: "root".to_owned(),
..Default::default()
};
let mut roots = HashMap::new();
roots.insert(
"child".to_owned(),
RootInfo {
root_id: "root".to_owned(),
root_title: "Original".to_owned(),
status: ResolveStatus::Resolved,
},
);
let resolution = Resolution {
roots,
gap_filled: Vec::new(),
};
let mut store = LineageStore::new();
store.update(&[clip], &resolution, "2024-01-01T00:00:00Z");
save_graph(&dir, &store).unwrap();
let back = load_graph(&dir).unwrap();
assert_eq!(back, store);
assert!(back.node("child").is_some());
assert_eq!(back.get_root("child").unwrap().root_id, "root");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn owner_pin_persists_across_save_and_load() {
let dir = temp_dir("graph-owner");
let mut store = LineageStore::new();
store.pin_owner(suno_core::Owner {
user_id: "user_abc123456".to_owned(),
display_name: "Alice".to_owned(),
});
save_graph(&dir, &store).unwrap();
let back = load_graph(&dir).unwrap();
assert_eq!(back.owner().unwrap().user_id, "user_abc123456");
assert_eq!(back.owner().unwrap().display_name, "Alice");
append_owner_pin(&dir, "PIN", "user_abc123456", "Alice").unwrap();
let log = std::fs::read_to_string(dir.join(AUDIT_NAME)).unwrap();
assert!(log.contains("OWNER\tPIN\tuser_abc123456\tAlice"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn save_index_writes_visible_parseable_json() {
let dir = temp_dir("index");
let mut manifest = Manifest::new();
manifest.insert(
"clip",
ManifestEntry {
path: "Album/Song.flac".to_owned(),
format: AudioFormat::Flac,
size: 123,
..Default::default()
},
);
let store = LineageStore::new();
let clip = Clip {
id: "clip".to_owned(),
title: "Song".to_owned(),
display_name: "alice".to_owned(),
..Default::default()
};
let mut clips: HashMap<&str, &Clip> = HashMap::new();
clips.insert("clip", &clip);
save_index(&dir, &manifest, &store, &clips).unwrap();
let path = dir.join(INDEX_NAME);
assert_eq!(INDEX_NAME, "suno-index.json");
assert!(!INDEX_NAME.starts_with('.'));
let bytes = std::fs::read(&path).unwrap();
let value: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(value["schema_version"], 1);
assert_eq!(value["clips"][0]["id"], "clip");
assert_eq!(value["clips"][0]["path"], "Album/Song.flac");
assert_eq!(value["clips"][0]["artist"], "alice");
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn state_files_use_private_permissions() {
use std::os::unix::fs::PermissionsExt;
fn mode(path: &Path) -> u32 {
std::fs::metadata(path).unwrap().permissions().mode() & 0o777
}
let dir = temp_dir("private-perms");
save_manifest(&dir, &Manifest::new()).unwrap();
save_graph(&dir, &LineageStore::new()).unwrap();
let failures = vec![Failure {
clip_id: "x".to_owned(),
reason: "boom".to_owned(),
}];
append_failures(&dir, &failures, &HashMap::new()).unwrap();
let plan = Plan {
actions: vec![Action::Delete {
path: "gone.flac".to_owned(),
clip_id: "x".to_owned(),
}],
};
append_audit(&dir, &plan, &HashSet::new(), &HashMap::new()).unwrap();
let lock = acquire_lock(&dir).unwrap();
assert_eq!(mode(&dir.join(MANIFEST_NAME)), 0o600);
assert_eq!(mode(&dir.join(GRAPH_NAME)), 0o600);
assert_eq!(mode(&dir.join(FAILURES_NAME)), 0o600);
assert_eq!(mode(&dir.join(AUDIT_NAME)), 0o600);
assert_eq!(mode(&dir.join(LOCK_NAME)), 0o600);
drop(lock);
let _ = std::fs::remove_dir_all(&dir);
}
}