Skip to main content

romm_cli/commands/
sync.rs

1use std::collections::{BTreeMap, HashMap};
2use std::fs::File;
3use std::io::{Read, Write};
4use std::path::{Path, PathBuf};
5use std::time::SystemTime;
6
7use anyhow::{anyhow, Context, Result};
8use clap::{Args, Subcommand, ValueEnum};
9use serde::{Deserialize, Serialize};
10use serde_json::json;
11use time::format_description::well_known::Rfc3339;
12use time::{OffsetDateTime, UtcOffset};
13use zip::ZipArchive;
14
15use crate::cli_presentation::CliPresentation;
16use crate::commands::OutputFormat;
17use romm_api::client::{RommClient, SaveUploadOptions};
18use romm_api::endpoints::device::{
19    DeviceSchema, GetDevice, ListDevices, RegisterDevice, SyncMode as EndpointSyncMode,
20};
21use romm_api::endpoints::sync::{
22    CompleteSyncSession, GetSyncSession, ListSyncSessions, NegotiateSync, SyncNegotiateResponse,
23    TriggerPushPull,
24};
25use romm_api::feature_compat::{save_sync_compatibility, SAVE_SYNC_UNSUPPORTED_MESSAGE};
26use romm_api::openapi::EndpointRegistry;
27
28#[derive(Args, Debug)]
29#[command(after_help = "Examples:\n  \
30      romm-cli sync plan --device-id abc --manifest saves.json\n  \
31      romm-cli sync run --device-id abc --manifest saves.json --download-dir ./saves")]
32pub struct SyncCommand {
33    /// Output as JSON (overrides global --json when set).
34    #[arg(long, global = true)]
35    pub json: bool,
36
37    #[command(subcommand)]
38    pub action: SyncAction,
39}
40
41#[derive(Subcommand, Debug)]
42pub enum SyncAction {
43    /// Manage sync devices.
44    Device(SyncDeviceCommand),
45    /// Negotiate sync operations without modifying files.
46    Plan(SyncPlanArgs),
47    /// Execute one-shot sync operations.
48    Run(SyncRunArgs),
49    /// Inspect sync sessions.
50    Sessions(SyncSessionsCommand),
51    /// Trigger push-pull mode on a registered device.
52    PushPull {
53        /// Device ID
54        device_id: String,
55    },
56}
57
58#[derive(Args, Debug)]
59pub struct SyncDeviceCommand {
60    #[command(subcommand)]
61    pub action: SyncDeviceAction,
62}
63
64#[derive(Subcommand, Debug)]
65pub enum SyncDeviceAction {
66    /// Register a device (`POST /api/devices`).
67    Register {
68        #[arg(long)]
69        name: Option<String>,
70        #[arg(long)]
71        platform: Option<String>,
72        #[arg(long, default_value = "romm-cli")]
73        client: String,
74        #[arg(long)]
75        client_version: Option<String>,
76        #[arg(long)]
77        hostname: Option<String>,
78        #[arg(long)]
79        mac_address: Option<String>,
80        #[arg(long)]
81        ip_address: Option<String>,
82        #[arg(long, value_enum, default_value_t = CliSyncMode::Api)]
83        sync_mode: CliSyncMode,
84        /// Optional JSON object string for `sync_config`.
85        #[arg(long)]
86        sync_config_json: Option<String>,
87        #[arg(long)]
88        allow_duplicate: bool,
89        #[arg(long)]
90        reset_syncs: bool,
91    },
92    /// List devices (`GET /api/devices`).
93    List,
94    /// Get one device (`GET /api/devices/{id}`).
95    Get {
96        /// Device ID
97        device_id: String,
98    },
99}
100
101#[derive(Args, Debug)]
102pub struct SyncPlanArgs {
103    #[arg(long)]
104    pub device_id: String,
105    #[arg(long)]
106    pub manifest: PathBuf,
107}
108
109#[derive(Args, Debug)]
110pub struct SyncRunArgs {
111    #[arg(long)]
112    pub device_id: String,
113    #[arg(long)]
114    pub manifest: PathBuf,
115    /// Folder for downloaded saves (defaults to the manifest directory).
116    #[arg(long)]
117    pub download_dir: Option<PathBuf>,
118    #[arg(long, value_enum, default_value_t = ConflictPolicy::Fail)]
119    pub conflict: ConflictPolicy,
120}
121
122#[derive(Args, Debug)]
123pub struct SyncSessionsCommand {
124    #[command(subcommand)]
125    pub action: SyncSessionsAction,
126}
127
128#[derive(Subcommand, Debug)]
129pub enum SyncSessionsAction {
130    /// List sessions (`GET /api/sync/sessions`).
131    List {
132        #[arg(long)]
133        device_id: Option<String>,
134        #[arg(long)]
135        limit: Option<u32>,
136    },
137    /// Get one session (`GET /api/sync/sessions/{id}`).
138    Get { session_id: u64 },
139}
140
141#[derive(Debug, Clone, Copy, ValueEnum)]
142pub enum CliSyncMode {
143    Api,
144    FileTransfer,
145    PushPull,
146}
147
148impl From<CliSyncMode> for EndpointSyncMode {
149    fn from(value: CliSyncMode) -> Self {
150        match value {
151            CliSyncMode::Api => EndpointSyncMode::Api,
152            CliSyncMode::FileTransfer => EndpointSyncMode::FileTransfer,
153            CliSyncMode::PushPull => EndpointSyncMode::PushPull,
154        }
155    }
156}
157
158#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
159pub enum ConflictPolicy {
160    Fail,
161    Skip,
162}
163
164#[derive(Debug, Clone, Deserialize)]
165struct SyncManifest {
166    saves: Vec<ManifestSave>,
167}
168
169#[derive(Debug, Clone, Deserialize)]
170struct ManifestSave {
171    rom_id: u64,
172    path: PathBuf,
173    file_name: Option<String>,
174    slot: Option<String>,
175    emulator: Option<String>,
176}
177
178#[derive(Debug, Clone, Serialize)]
179struct ClientSaveState {
180    rom_id: u64,
181    file_name: String,
182    #[serde(skip_serializing_if = "Option::is_none")]
183    slot: Option<String>,
184    #[serde(skip_serializing_if = "Option::is_none")]
185    emulator: Option<String>,
186    content_hash: String,
187    updated_at: String,
188    file_size_bytes: u64,
189}
190
191#[derive(Debug, Clone)]
192struct PreparedSave {
193    path: PathBuf,
194    client: ClientSaveState,
195}
196
197fn safe_download_file_name(input: &str, save_id: u64) -> String {
198    let cleaned: String = input
199        .chars()
200        .map(|c| {
201            if c.is_ascii_alphanumeric() || matches!(c, ' ' | '-' | '_' | '.') {
202                c
203            } else {
204                '_'
205            }
206        })
207        .collect();
208    let trimmed = cleaned.trim().trim_matches('.').trim();
209    if trimmed.is_empty() {
210        format!("save-{save_id}.sav")
211    } else {
212        trimmed.to_string()
213    }
214}
215
216#[derive(Debug, Clone, Copy, Default)]
217struct RunCounts {
218    uploaded: u64,
219    downloaded: u64,
220    no_op: u64,
221    conflicts_skipped: u64,
222    completed: u64,
223    failed: u64,
224}
225
226pub async fn handle(
227    cmd: SyncCommand,
228    client: &RommClient,
229    presentation: CliPresentation,
230) -> Result<()> {
231    let format = presentation.format;
232    preflight_save_sync_compatibility(client, format).await?;
233
234    match cmd.action {
235        SyncAction::Device(device_cmd) => handle_device(device_cmd, client, format).await,
236        SyncAction::Plan(args) => handle_plan(args, client, format).await,
237        SyncAction::Run(args) => handle_run(args, client, format).await,
238        SyncAction::Sessions(cmd) => handle_sessions(cmd, client, format).await,
239        SyncAction::PushPull { device_id } => {
240            let out = client.call(&TriggerPushPull { device_id }).await?;
241            print_output(format, &out)
242        }
243    }
244}
245
246async fn preflight_save_sync_compatibility(
247    client: &RommClient,
248    format: OutputFormat,
249) -> Result<()> {
250    let openapi = match client.fetch_openapi_json().await {
251        Ok(body) => body,
252        Err(e) => {
253            tracing::warn!(
254                "Skipping save-sync compatibility preflight: {}",
255                e.redacted_for_log()
256            );
257            return Ok(());
258        }
259    };
260    let registry = match EndpointRegistry::from_openapi_json(&openapi) {
261        Ok(registry) => registry,
262        Err(e) => {
263            tracing::warn!(
264                "Skipping save-sync compatibility preflight; OpenAPI parse failed: {e:#}"
265            );
266            return Ok(());
267        }
268    };
269    let compat = save_sync_compatibility(&registry);
270    if compat.supported {
271        return Ok(());
272    }
273
274    if matches!(format, OutputFormat::Json) {
275        println!(
276            "{}",
277            serde_json::to_string_pretty(&json!({
278                "error": "save_sync_unsupported",
279                "message": SAVE_SYNC_UNSUPPORTED_MESSAGE,
280                "missing_endpoints": compat
281                    .missing
282                    .iter()
283                    .map(|ep| ep.label())
284                    .collect::<Vec<_>>()
285            }))?
286        );
287    }
288
289    anyhow::bail!("{}", compat.unsupported_message())
290}
291
292async fn handle_device(
293    cmd: SyncDeviceCommand,
294    client: &RommClient,
295    format: OutputFormat,
296) -> Result<()> {
297    match cmd.action {
298        SyncDeviceAction::Register {
299            name,
300            platform,
301            client: client_name,
302            client_version,
303            hostname,
304            mac_address,
305            ip_address,
306            sync_mode,
307            sync_config_json,
308            allow_duplicate,
309            reset_syncs,
310        } => {
311            let sync_config = match sync_config_json {
312                Some(raw) => Some(
313                    serde_json::from_str::<serde_json::Value>(&raw)
314                        .with_context(|| "invalid --sync-config-json (must be a JSON object)")?,
315                ),
316                None => None,
317            };
318            if let Some(v) = &sync_config {
319                if !v.is_object() {
320                    anyhow::bail!("--sync-config-json must decode to a JSON object");
321                }
322            }
323
324            let body = json!({
325                "name": name,
326                "platform": platform,
327                "client": client_name,
328                "client_version": client_version,
329                "hostname": hostname,
330                "mac_address": mac_address,
331                "ip_address": ip_address,
332                "sync_mode": EndpointSyncMode::from(sync_mode),
333                "sync_config": sync_config,
334                "allow_existing": true,
335                "allow_duplicate": allow_duplicate,
336                "reset_syncs": reset_syncs
337            });
338            let created = client.call(&RegisterDevice { body }).await?;
339            print_output(format, &created)
340        }
341        SyncDeviceAction::List => {
342            let rows: Vec<DeviceSchema> = client.call(&ListDevices).await?;
343            print_output(format, &rows)
344        }
345        SyncDeviceAction::Get { device_id } => {
346            let row = client.call(&GetDevice { device_id }).await?;
347            print_output(format, &row)
348        }
349    }
350}
351
352async fn handle_plan(args: SyncPlanArgs, client: &RommClient, format: OutputFormat) -> Result<()> {
353    let prepared = load_manifest_and_prepare(&args.manifest)?;
354    let negotiate = negotiate(client, &args.device_id, &prepared).await?;
355    print_output(format, &negotiate)
356}
357
358async fn handle_run(args: SyncRunArgs, client: &RommClient, format: OutputFormat) -> Result<()> {
359    let prepared = load_manifest_and_prepare(&args.manifest)?;
360    let prepared_by_key = prepared_by_key(&prepared)?;
361    let negotiate = negotiate(client, &args.device_id, &prepared).await?;
362
363    let download_base = match args.download_dir {
364        Some(dir) => dir,
365        None => args
366            .manifest
367            .parent()
368            .map(Path::to_path_buf)
369            .unwrap_or_else(|| PathBuf::from(".")),
370    };
371    std::fs::create_dir_all(&download_base).with_context(|| {
372        format!(
373            "failed to create download directory {}",
374            download_base.display()
375        )
376    })?;
377
378    let mut counts = RunCounts::default();
379    let mut hard_conflict = false;
380    for op in &negotiate.operations {
381        match op.action.as_str() {
382            "upload" => {
383                let key = (op.rom_id, op.file_name.clone());
384                let Some(local) = prepared_by_key.get(&key) else {
385                    counts.failed += 1;
386                    eprintln!(
387                        "Missing local manifest entry for upload operation rom_id={} file_name={}",
388                        op.rom_id, op.file_name
389                    );
390                    continue;
391                };
392                let options = SaveUploadOptions {
393                    emulator: local.client.emulator.as_deref(),
394                    slot: local.client.slot.as_deref(),
395                    device_id: Some(args.device_id.as_str()),
396                    session_id: Some(negotiate.session_id),
397                    overwrite: false,
398                };
399                match client
400                    .upload_save_file_with_options(local.client.rom_id, &local.path, &options)
401                    .await
402                {
403                    Ok(_) => {
404                        counts.uploaded += 1;
405                        counts.completed += 1;
406                    }
407                    Err(err) => {
408                        counts.failed += 1;
409                        eprintln!(
410                            "Upload failed for rom_id={} file_name={}: {:#}",
411                            local.client.rom_id, local.client.file_name, err
412                        );
413                    }
414                }
415            }
416            "download" => {
417                let Some(save_id) = op.save_id else {
418                    counts.failed += 1;
419                    eprintln!(
420                        "Download operation missing save_id for rom_id={} file_name={}",
421                        op.rom_id, op.file_name
422                    );
423                    continue;
424                };
425                match client
426                    .download_save_content(
427                        save_id,
428                        Some(args.device_id.as_str()),
429                        Some(negotiate.session_id),
430                    )
431                    .await
432                {
433                    Ok(bytes) => {
434                        let target =
435                            download_base.join(safe_download_file_name(&op.file_name, save_id));
436                        if let Some(parent) = target.parent() {
437                            std::fs::create_dir_all(parent).with_context(|| {
438                                format!("failed to create parent folder {}", parent.display())
439                            })?;
440                        }
441                        let mut f = File::create(&target).with_context(|| {
442                            format!("failed to create download file {}", target.display())
443                        })?;
444                        f.write_all(&bytes).with_context(|| {
445                            format!("failed to write download file {}", target.display())
446                        })?;
447                        counts.downloaded += 1;
448                        counts.completed += 1;
449                    }
450                    Err(err) => {
451                        counts.failed += 1;
452                        eprintln!("Download failed for save_id={save_id}: {err:#}");
453                    }
454                }
455            }
456            "no_op" => {
457                counts.no_op += 1;
458            }
459            "conflict" => match args.conflict {
460                ConflictPolicy::Skip => {
461                    counts.conflicts_skipped += 1;
462                }
463                ConflictPolicy::Fail => {
464                    counts.failed += 1;
465                    hard_conflict = true;
466                    eprintln!(
467                        "Conflict for rom_id={} file_name={}: {}",
468                        op.rom_id, op.file_name, op.reason
469                    );
470                }
471            },
472            other => {
473                counts.failed += 1;
474                eprintln!(
475                    "Unknown sync operation '{}' for rom_id={} file_name={}",
476                    other, op.rom_id, op.file_name
477                );
478            }
479        }
480    }
481
482    let completion = client
483        .call(&CompleteSyncSession {
484            session_id: negotiate.session_id,
485            body: json!({
486                "operations_completed": counts.completed,
487                "operations_failed": counts.failed
488            }),
489        })
490        .await?;
491
492    if matches!(format, OutputFormat::Json) {
493        let out = json!({
494            "negotiate": negotiate,
495            "counts": {
496                "uploaded": counts.uploaded,
497                "downloaded": counts.downloaded,
498                "no_op": counts.no_op,
499                "conflicts_skipped": counts.conflicts_skipped,
500                "completed": counts.completed,
501                "failed": counts.failed
502            },
503            "completion": completion
504        });
505        println!("{}", serde_json::to_string_pretty(&out)?);
506    } else {
507        println!(
508            "session={} uploaded={} downloaded={} no_op={} conflicts_skipped={} completed={} failed={}",
509            completion.session.id,
510            counts.uploaded,
511            counts.downloaded,
512            counts.no_op,
513            counts.conflicts_skipped,
514            counts.completed,
515            counts.failed
516        );
517    }
518
519    if hard_conflict || counts.failed > 0 {
520        anyhow::bail!(
521            "sync completed with {} failed operation(s); session {} marked complete",
522            counts.failed,
523            completion.session.id
524        );
525    }
526
527    Ok(())
528}
529
530async fn handle_sessions(
531    cmd: SyncSessionsCommand,
532    client: &RommClient,
533    format: OutputFormat,
534) -> Result<()> {
535    match cmd.action {
536        SyncSessionsAction::List { device_id, limit } => {
537            let out = client.call(&ListSyncSessions { device_id, limit }).await?;
538            print_output(format, &out)
539        }
540        SyncSessionsAction::Get { session_id } => {
541            let out = client.call(&GetSyncSession { session_id }).await?;
542            print_output(format, &out)
543        }
544    }
545}
546
547async fn negotiate(
548    client: &RommClient,
549    device_id: &str,
550    prepared: &[PreparedSave],
551) -> Result<SyncNegotiateResponse> {
552    let saves: Vec<ClientSaveState> = prepared.iter().map(|p| p.client.clone()).collect();
553    client
554        .call(&NegotiateSync {
555            body: json!({
556                "device_id": device_id,
557                "saves": saves
558            }),
559        })
560        .await
561        .map_err(Into::into)
562}
563
564fn print_output<T: Serialize>(format: OutputFormat, value: &T) -> Result<()> {
565    match format {
566        OutputFormat::Json | OutputFormat::Text => {
567            println!("{}", serde_json::to_string_pretty(value)?);
568        }
569    }
570    Ok(())
571}
572
573fn prepared_by_key(prepared: &[PreparedSave]) -> Result<HashMap<(u64, String), PreparedSave>> {
574    let mut map = HashMap::new();
575    for item in prepared {
576        let key = (item.client.rom_id, item.client.file_name.clone());
577        if map.insert(key.clone(), item.clone()).is_some() {
578            anyhow::bail!(
579                "duplicate manifest mapping for rom_id={} file_name={}",
580                key.0,
581                key.1
582            );
583        }
584    }
585    Ok(map)
586}
587
588fn load_manifest_and_prepare(manifest_path: &Path) -> Result<Vec<PreparedSave>> {
589    let raw = std::fs::read_to_string(manifest_path)
590        .with_context(|| format!("read manifest {}", manifest_path.display()))?;
591    let manifest: SyncManifest = serde_json::from_str(&raw)
592        .with_context(|| format!("parse manifest {}", manifest_path.display()))?;
593    let base_dir = manifest_path
594        .parent()
595        .map(Path::to_path_buf)
596        .unwrap_or_else(|| PathBuf::from("."));
597
598    let mut out = Vec::new();
599    for row in manifest.saves {
600        let path = if row.path.is_absolute() {
601            row.path.clone()
602        } else {
603            base_dir.join(&row.path)
604        };
605        if !path.is_file() {
606            anyhow::bail!("manifest save path is not a file: {}", path.display());
607        }
608        let file_name = match row.file_name {
609            Some(name) if !name.trim().is_empty() => name.trim().to_string(),
610            _ => path
611                .file_name()
612                .and_then(|n| n.to_str())
613                .ok_or_else(|| {
614                    anyhow!("save path must have a unicode filename: {}", path.display())
615                })?
616                .to_string(),
617        };
618        let meta = std::fs::metadata(&path)
619            .with_context(|| format!("read metadata for {}", path.display()))?;
620        let updated_at = format_system_time_utc_rfc3339(
621            meta.modified()
622                .with_context(|| format!("read modified timestamp for {}", path.display()))?,
623        )?;
624        let content_hash = compute_content_hash(&path)?;
625        out.push(PreparedSave {
626            path,
627            client: ClientSaveState {
628                rom_id: row.rom_id,
629                file_name,
630                slot: row.slot.filter(|s| !s.trim().is_empty()),
631                emulator: row.emulator.filter(|s| !s.trim().is_empty()),
632                content_hash,
633                updated_at,
634                file_size_bytes: meta.len(),
635            },
636        });
637    }
638
639    Ok(out)
640}
641
642fn format_system_time_utc_rfc3339(t: SystemTime) -> Result<String> {
643    let odt: OffsetDateTime = t.into();
644    let utc = odt.to_offset(UtcOffset::UTC);
645    utc.format(&Rfc3339)
646        .map_err(|e| anyhow!("format timestamp as RFC3339: {e}"))
647}
648
649fn compute_content_hash(path: &Path) -> Result<String> {
650    if let Ok(file) = File::open(path) {
651        if ZipArchive::new(file).is_ok() {
652            return compute_zip_hash(path);
653        }
654    }
655    compute_file_hash(path)
656}
657
658fn compute_file_hash(path: &Path) -> Result<String> {
659    let mut file =
660        File::open(path).with_context(|| format!("open file for hashing {}", path.display()))?;
661    let mut ctx = md5::Context::new();
662    let mut buf = [0u8; 8192];
663    loop {
664        let n = file
665            .read(&mut buf)
666            .with_context(|| format!("read file for hashing {}", path.display()))?;
667        if n == 0 {
668            break;
669        }
670        ctx.consume(&buf[..n]);
671    }
672    Ok(format!("{:x}", ctx.finalize()))
673}
674
675fn compute_zip_hash(path: &Path) -> Result<String> {
676    let file = File::open(path)
677        .with_context(|| format!("open zip file for hashing {}", path.display()))?;
678    let mut archive = ZipArchive::new(file)
679        .with_context(|| format!("read zip archive for hashing {}", path.display()))?;
680    let mut row_hashes = BTreeMap::new();
681    for i in 0..archive.len() {
682        let mut entry = archive
683            .by_index(i)
684            .with_context(|| format!("read zip entry {} in {}", i, path.display()))?;
685        if entry.is_dir() {
686            continue;
687        }
688        let name = entry.name().to_string();
689        let mut ctx = md5::Context::new();
690        let mut buf = [0u8; 8192];
691        loop {
692            let n = entry
693                .read(&mut buf)
694                .with_context(|| format!("hash zip entry {} in {}", name, path.display()))?;
695            if n == 0 {
696                break;
697            }
698            ctx.consume(&buf[..n]);
699        }
700        row_hashes.insert(name, format!("{:x}", ctx.finalize()));
701    }
702    let combined = row_hashes
703        .into_iter()
704        .map(|(name, hash)| format!("{name}:{hash}"))
705        .collect::<Vec<_>>()
706        .join("\n");
707    Ok(format!("{:x}", md5::compute(combined.as_bytes())))
708}
709
710#[cfg(test)]
711mod tests {
712    use super::*;
713    use std::fs;
714    use std::io::Write;
715
716    use zip::write::SimpleFileOptions;
717    use zip::ZipWriter;
718
719    fn temp_path(name: &str) -> PathBuf {
720        let nanos = SystemTime::now()
721            .duration_since(SystemTime::UNIX_EPOCH)
722            .expect("unix epoch")
723            .as_nanos();
724        std::env::temp_dir().join(format!("romm-cli-sync-test-{nanos}-{name}"))
725    }
726
727    #[test]
728    fn file_hash_matches_md5_hex() {
729        let path = temp_path("plain.sav");
730        fs::write(&path, b"abc123").expect("write");
731        let got = compute_file_hash(&path).expect("hash");
732        let expected = format!("{:x}", md5::compute(b"abc123"));
733        assert_eq!(got, expected);
734        let _ = fs::remove_file(path);
735    }
736
737    #[test]
738    fn zip_hash_matches_sorted_entry_scheme() {
739        let path = temp_path("archive.zip");
740        {
741            let f = File::create(&path).expect("create");
742            let mut writer = ZipWriter::new(f);
743            writer
744                .start_file("b.sav", SimpleFileOptions::default())
745                .expect("start file");
746            writer.write_all(b"bbb").expect("write b");
747            writer
748                .start_file("a.sav", SimpleFileOptions::default())
749                .expect("start file");
750            writer.write_all(b"aaa").expect("write a");
751            writer.finish().expect("finish");
752        }
753
754        let hash = compute_zip_hash(&path).expect("hash zip");
755        let a = format!("{:x}", md5::compute(b"aaa"));
756        let b = format!("{:x}", md5::compute(b"bbb"));
757        let combined = format!("a.sav:{a}\nb.sav:{b}");
758        let expected = format!("{:x}", md5::compute(combined.as_bytes()));
759        assert_eq!(hash, expected);
760        let _ = fs::remove_file(path);
761    }
762
763    #[test]
764    fn duplicate_manifest_keys_fail() {
765        let p = temp_path("dup.sav");
766        fs::write(&p, b"x").expect("write");
767        let prepared = vec![
768            PreparedSave {
769                path: p.clone(),
770                client: ClientSaveState {
771                    rom_id: 1,
772                    file_name: "same.sav".into(),
773                    slot: None,
774                    emulator: None,
775                    content_hash: "h1".into(),
776                    updated_at: "2026-01-01T00:00:00Z".into(),
777                    file_size_bytes: 1,
778                },
779            },
780            PreparedSave {
781                path: p.clone(),
782                client: ClientSaveState {
783                    rom_id: 1,
784                    file_name: "same.sav".into(),
785                    slot: None,
786                    emulator: None,
787                    content_hash: "h2".into(),
788                    updated_at: "2026-01-01T00:00:01Z".into(),
789                    file_size_bytes: 1,
790                },
791            },
792        ];
793        let err = prepared_by_key(&prepared).expect_err("duplicate should fail");
794        assert!(err.to_string().contains("duplicate manifest mapping"));
795        let _ = fs::remove_file(p);
796    }
797
798    #[test]
799    fn safe_download_file_name_removes_path_separators() {
800        assert_eq!(
801            safe_download_file_name("../folder/evil.sav", 12),
802            "_folder_evil.sav"
803        );
804        assert_eq!(
805            safe_download_file_name(r"..\folder\evil.sav", 12),
806            "_folder_evil.sav"
807        );
808    }
809
810    #[test]
811    fn safe_download_file_name_falls_back_when_empty() {
812        assert_eq!(safe_download_file_name("...", 42), "save-42.sav");
813    }
814}