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 #[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 Device(SyncDeviceCommand),
45 Plan(SyncPlanArgs),
47 Run(SyncRunArgs),
49 Sessions(SyncSessionsCommand),
51 PushPull {
53 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 {
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 #[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,
94 Get {
96 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 #[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 {
132 #[arg(long)]
133 device_id: Option<String>,
134 #[arg(long)]
135 limit: Option<u32>,
136 },
137 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(®istry);
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}