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::client::{RommClient, SaveUploadOptions};
17use crate::commands::OutputFormat;
18use crate::endpoints::device::{
19    DeviceSchema, GetDevice, ListDevices, RegisterDevice, SyncMode as EndpointSyncMode,
20};
21use crate::endpoints::sync::{
22    CompleteSyncSession, GetSyncSession, ListSyncSessions, NegotiateSync, SyncNegotiateResponse,
23    TriggerPushPull,
24};
25use crate::feature_compat::{save_sync_compatibility, SAVE_SYNC_UNSUPPORTED_MESSAGE};
26use crate::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
197#[derive(Debug, Clone, Copy, Default)]
198struct RunCounts {
199    uploaded: u64,
200    downloaded: u64,
201    no_op: u64,
202    conflicts_skipped: u64,
203    completed: u64,
204    failed: u64,
205}
206
207pub async fn handle(
208    cmd: SyncCommand,
209    client: &RommClient,
210    presentation: CliPresentation,
211) -> Result<()> {
212    let format = presentation.format;
213    preflight_save_sync_compatibility(client, format).await?;
214
215    match cmd.action {
216        SyncAction::Device(device_cmd) => handle_device(device_cmd, client, format).await,
217        SyncAction::Plan(args) => handle_plan(args, client, format).await,
218        SyncAction::Run(args) => handle_run(args, client, format).await,
219        SyncAction::Sessions(cmd) => handle_sessions(cmd, client, format).await,
220        SyncAction::PushPull { device_id } => {
221            let out = client.call(&TriggerPushPull { device_id }).await?;
222            print_output(format, &out)
223        }
224    }
225}
226
227async fn preflight_save_sync_compatibility(
228    client: &RommClient,
229    format: OutputFormat,
230) -> Result<()> {
231    let openapi = match client.fetch_openapi_json().await {
232        Ok(body) => body,
233        Err(e) => {
234            tracing::warn!(
235                "Skipping save-sync compatibility preflight: {}",
236                e.redacted_for_log()
237            );
238            return Ok(());
239        }
240    };
241    let registry = match EndpointRegistry::from_openapi_json(&openapi) {
242        Ok(registry) => registry,
243        Err(e) => {
244            tracing::warn!(
245                "Skipping save-sync compatibility preflight; OpenAPI parse failed: {e:#}"
246            );
247            return Ok(());
248        }
249    };
250    let compat = save_sync_compatibility(&registry);
251    if compat.supported {
252        return Ok(());
253    }
254
255    if matches!(format, OutputFormat::Json) {
256        println!(
257            "{}",
258            serde_json::to_string_pretty(&json!({
259                "error": "save_sync_unsupported",
260                "message": SAVE_SYNC_UNSUPPORTED_MESSAGE,
261                "missing_endpoints": compat
262                    .missing
263                    .iter()
264                    .map(|ep| ep.label())
265                    .collect::<Vec<_>>()
266            }))?
267        );
268    }
269
270    anyhow::bail!("{}", compat.unsupported_message())
271}
272
273async fn handle_device(
274    cmd: SyncDeviceCommand,
275    client: &RommClient,
276    format: OutputFormat,
277) -> Result<()> {
278    match cmd.action {
279        SyncDeviceAction::Register {
280            name,
281            platform,
282            client: client_name,
283            client_version,
284            hostname,
285            mac_address,
286            ip_address,
287            sync_mode,
288            sync_config_json,
289            allow_duplicate,
290            reset_syncs,
291        } => {
292            let sync_config = match sync_config_json {
293                Some(raw) => Some(
294                    serde_json::from_str::<serde_json::Value>(&raw)
295                        .with_context(|| "invalid --sync-config-json (must be a JSON object)")?,
296                ),
297                None => None,
298            };
299            if let Some(v) = &sync_config {
300                if !v.is_object() {
301                    anyhow::bail!("--sync-config-json must decode to a JSON object");
302                }
303            }
304
305            let body = json!({
306                "name": name,
307                "platform": platform,
308                "client": client_name,
309                "client_version": client_version,
310                "hostname": hostname,
311                "mac_address": mac_address,
312                "ip_address": ip_address,
313                "sync_mode": EndpointSyncMode::from(sync_mode),
314                "sync_config": sync_config,
315                "allow_existing": true,
316                "allow_duplicate": allow_duplicate,
317                "reset_syncs": reset_syncs
318            });
319            let created = client.call(&RegisterDevice { body }).await?;
320            print_output(format, &created)
321        }
322        SyncDeviceAction::List => {
323            let rows: Vec<DeviceSchema> = client.call(&ListDevices).await?;
324            print_output(format, &rows)
325        }
326        SyncDeviceAction::Get { device_id } => {
327            let row = client.call(&GetDevice { device_id }).await?;
328            print_output(format, &row)
329        }
330    }
331}
332
333async fn handle_plan(args: SyncPlanArgs, client: &RommClient, format: OutputFormat) -> Result<()> {
334    let prepared = load_manifest_and_prepare(&args.manifest)?;
335    let negotiate = negotiate(client, &args.device_id, &prepared).await?;
336    print_output(format, &negotiate)
337}
338
339async fn handle_run(args: SyncRunArgs, client: &RommClient, format: OutputFormat) -> Result<()> {
340    let prepared = load_manifest_and_prepare(&args.manifest)?;
341    let prepared_by_key = prepared_by_key(&prepared)?;
342    let negotiate = negotiate(client, &args.device_id, &prepared).await?;
343
344    let download_base = match args.download_dir {
345        Some(dir) => dir,
346        None => args
347            .manifest
348            .parent()
349            .map(Path::to_path_buf)
350            .unwrap_or_else(|| PathBuf::from(".")),
351    };
352    std::fs::create_dir_all(&download_base).with_context(|| {
353        format!(
354            "failed to create download directory {}",
355            download_base.display()
356        )
357    })?;
358
359    let mut counts = RunCounts::default();
360    let mut hard_conflict = false;
361    for op in &negotiate.operations {
362        match op.action.as_str() {
363            "upload" => {
364                let key = (op.rom_id, op.file_name.clone());
365                let Some(local) = prepared_by_key.get(&key) else {
366                    counts.failed += 1;
367                    eprintln!(
368                        "Missing local manifest entry for upload operation rom_id={} file_name={}",
369                        op.rom_id, op.file_name
370                    );
371                    continue;
372                };
373                let options = SaveUploadOptions {
374                    emulator: local.client.emulator.as_deref(),
375                    slot: local.client.slot.as_deref(),
376                    device_id: Some(args.device_id.as_str()),
377                    session_id: Some(negotiate.session_id),
378                    overwrite: false,
379                };
380                match client
381                    .upload_save_file_with_options(local.client.rom_id, &local.path, &options)
382                    .await
383                {
384                    Ok(_) => {
385                        counts.uploaded += 1;
386                        counts.completed += 1;
387                    }
388                    Err(err) => {
389                        counts.failed += 1;
390                        eprintln!(
391                            "Upload failed for rom_id={} file_name={}: {:#}",
392                            local.client.rom_id, local.client.file_name, err
393                        );
394                    }
395                }
396            }
397            "download" => {
398                let Some(save_id) = op.save_id else {
399                    counts.failed += 1;
400                    eprintln!(
401                        "Download operation missing save_id for rom_id={} file_name={}",
402                        op.rom_id, op.file_name
403                    );
404                    continue;
405                };
406                match client
407                    .download_save_content(
408                        save_id,
409                        Some(args.device_id.as_str()),
410                        Some(negotiate.session_id),
411                    )
412                    .await
413                {
414                    Ok(bytes) => {
415                        let target = download_base.join(&op.file_name);
416                        if let Some(parent) = target.parent() {
417                            std::fs::create_dir_all(parent).with_context(|| {
418                                format!("failed to create parent folder {}", parent.display())
419                            })?;
420                        }
421                        let mut f = File::create(&target).with_context(|| {
422                            format!("failed to create download file {}", target.display())
423                        })?;
424                        f.write_all(&bytes).with_context(|| {
425                            format!("failed to write download file {}", target.display())
426                        })?;
427                        counts.downloaded += 1;
428                        counts.completed += 1;
429                    }
430                    Err(err) => {
431                        counts.failed += 1;
432                        eprintln!("Download failed for save_id={save_id}: {err:#}");
433                    }
434                }
435            }
436            "no_op" => {
437                counts.no_op += 1;
438            }
439            "conflict" => match args.conflict {
440                ConflictPolicy::Skip => {
441                    counts.conflicts_skipped += 1;
442                }
443                ConflictPolicy::Fail => {
444                    counts.failed += 1;
445                    hard_conflict = true;
446                    eprintln!(
447                        "Conflict for rom_id={} file_name={}: {}",
448                        op.rom_id, op.file_name, op.reason
449                    );
450                }
451            },
452            other => {
453                counts.failed += 1;
454                eprintln!(
455                    "Unknown sync operation '{}' for rom_id={} file_name={}",
456                    other, op.rom_id, op.file_name
457                );
458            }
459        }
460    }
461
462    let completion = client
463        .call(&CompleteSyncSession {
464            session_id: negotiate.session_id,
465            body: json!({
466                "operations_completed": counts.completed,
467                "operations_failed": counts.failed
468            }),
469        })
470        .await?;
471
472    if matches!(format, OutputFormat::Json) {
473        let out = json!({
474            "negotiate": negotiate,
475            "counts": {
476                "uploaded": counts.uploaded,
477                "downloaded": counts.downloaded,
478                "no_op": counts.no_op,
479                "conflicts_skipped": counts.conflicts_skipped,
480                "completed": counts.completed,
481                "failed": counts.failed
482            },
483            "completion": completion
484        });
485        println!("{}", serde_json::to_string_pretty(&out)?);
486    } else {
487        println!(
488            "session={} uploaded={} downloaded={} no_op={} conflicts_skipped={} completed={} failed={}",
489            completion.session.id,
490            counts.uploaded,
491            counts.downloaded,
492            counts.no_op,
493            counts.conflicts_skipped,
494            counts.completed,
495            counts.failed
496        );
497    }
498
499    if hard_conflict || counts.failed > 0 {
500        anyhow::bail!(
501            "sync completed with {} failed operation(s); session {} marked complete",
502            counts.failed,
503            completion.session.id
504        );
505    }
506
507    Ok(())
508}
509
510async fn handle_sessions(
511    cmd: SyncSessionsCommand,
512    client: &RommClient,
513    format: OutputFormat,
514) -> Result<()> {
515    match cmd.action {
516        SyncSessionsAction::List { device_id, limit } => {
517            let out = client.call(&ListSyncSessions { device_id, limit }).await?;
518            print_output(format, &out)
519        }
520        SyncSessionsAction::Get { session_id } => {
521            let out = client.call(&GetSyncSession { session_id }).await?;
522            print_output(format, &out)
523        }
524    }
525}
526
527async fn negotiate(
528    client: &RommClient,
529    device_id: &str,
530    prepared: &[PreparedSave],
531) -> Result<SyncNegotiateResponse> {
532    let saves: Vec<ClientSaveState> = prepared.iter().map(|p| p.client.clone()).collect();
533    client
534        .call(&NegotiateSync {
535            body: json!({
536                "device_id": device_id,
537                "saves": saves
538            }),
539        })
540        .await
541        .map_err(Into::into)
542}
543
544fn print_output<T: Serialize>(format: OutputFormat, value: &T) -> Result<()> {
545    match format {
546        OutputFormat::Json | OutputFormat::Text => {
547            println!("{}", serde_json::to_string_pretty(value)?);
548        }
549    }
550    Ok(())
551}
552
553fn prepared_by_key(prepared: &[PreparedSave]) -> Result<HashMap<(u64, String), PreparedSave>> {
554    let mut map = HashMap::new();
555    for item in prepared {
556        let key = (item.client.rom_id, item.client.file_name.clone());
557        if map.insert(key.clone(), item.clone()).is_some() {
558            anyhow::bail!(
559                "duplicate manifest mapping for rom_id={} file_name={}",
560                key.0,
561                key.1
562            );
563        }
564    }
565    Ok(map)
566}
567
568fn load_manifest_and_prepare(manifest_path: &Path) -> Result<Vec<PreparedSave>> {
569    let raw = std::fs::read_to_string(manifest_path)
570        .with_context(|| format!("read manifest {}", manifest_path.display()))?;
571    let manifest: SyncManifest = serde_json::from_str(&raw)
572        .with_context(|| format!("parse manifest {}", manifest_path.display()))?;
573    let base_dir = manifest_path
574        .parent()
575        .map(Path::to_path_buf)
576        .unwrap_or_else(|| PathBuf::from("."));
577
578    let mut out = Vec::new();
579    for row in manifest.saves {
580        let path = if row.path.is_absolute() {
581            row.path.clone()
582        } else {
583            base_dir.join(&row.path)
584        };
585        if !path.is_file() {
586            anyhow::bail!("manifest save path is not a file: {}", path.display());
587        }
588        let file_name = match row.file_name {
589            Some(name) if !name.trim().is_empty() => name.trim().to_string(),
590            _ => path
591                .file_name()
592                .and_then(|n| n.to_str())
593                .ok_or_else(|| {
594                    anyhow!("save path must have a unicode filename: {}", path.display())
595                })?
596                .to_string(),
597        };
598        let meta = std::fs::metadata(&path)
599            .with_context(|| format!("read metadata for {}", path.display()))?;
600        let updated_at = format_system_time_utc_rfc3339(
601            meta.modified()
602                .with_context(|| format!("read modified timestamp for {}", path.display()))?,
603        )?;
604        let content_hash = compute_content_hash(&path)?;
605        out.push(PreparedSave {
606            path,
607            client: ClientSaveState {
608                rom_id: row.rom_id,
609                file_name,
610                slot: row.slot.filter(|s| !s.trim().is_empty()),
611                emulator: row.emulator.filter(|s| !s.trim().is_empty()),
612                content_hash,
613                updated_at,
614                file_size_bytes: meta.len(),
615            },
616        });
617    }
618
619    Ok(out)
620}
621
622fn format_system_time_utc_rfc3339(t: SystemTime) -> Result<String> {
623    let odt: OffsetDateTime = t.into();
624    let utc = odt.to_offset(UtcOffset::UTC);
625    utc.format(&Rfc3339)
626        .map_err(|e| anyhow!("format timestamp as RFC3339: {e}"))
627}
628
629fn compute_content_hash(path: &Path) -> Result<String> {
630    if let Ok(file) = File::open(path) {
631        if ZipArchive::new(file).is_ok() {
632            return compute_zip_hash(path);
633        }
634    }
635    compute_file_hash(path)
636}
637
638fn compute_file_hash(path: &Path) -> Result<String> {
639    let mut file =
640        File::open(path).with_context(|| format!("open file for hashing {}", path.display()))?;
641    let mut ctx = md5::Context::new();
642    let mut buf = [0u8; 8192];
643    loop {
644        let n = file
645            .read(&mut buf)
646            .with_context(|| format!("read file for hashing {}", path.display()))?;
647        if n == 0 {
648            break;
649        }
650        ctx.consume(&buf[..n]);
651    }
652    Ok(format!("{:x}", ctx.finalize()))
653}
654
655fn compute_zip_hash(path: &Path) -> Result<String> {
656    let file = File::open(path)
657        .with_context(|| format!("open zip file for hashing {}", path.display()))?;
658    let mut archive = ZipArchive::new(file)
659        .with_context(|| format!("read zip archive for hashing {}", path.display()))?;
660    let mut row_hashes = BTreeMap::new();
661    for i in 0..archive.len() {
662        let mut entry = archive
663            .by_index(i)
664            .with_context(|| format!("read zip entry {} in {}", i, path.display()))?;
665        if entry.is_dir() {
666            continue;
667        }
668        let name = entry.name().to_string();
669        let mut ctx = md5::Context::new();
670        let mut buf = [0u8; 8192];
671        loop {
672            let n = entry
673                .read(&mut buf)
674                .with_context(|| format!("hash zip entry {} in {}", name, path.display()))?;
675            if n == 0 {
676                break;
677            }
678            ctx.consume(&buf[..n]);
679        }
680        row_hashes.insert(name, format!("{:x}", ctx.finalize()));
681    }
682    let combined = row_hashes
683        .into_iter()
684        .map(|(name, hash)| format!("{name}:{hash}"))
685        .collect::<Vec<_>>()
686        .join("\n");
687    Ok(format!("{:x}", md5::compute(combined.as_bytes())))
688}
689
690#[cfg(test)]
691mod tests {
692    use super::*;
693    use std::fs;
694    use std::io::Write;
695
696    use zip::write::SimpleFileOptions;
697    use zip::ZipWriter;
698
699    fn temp_path(name: &str) -> PathBuf {
700        let nanos = SystemTime::now()
701            .duration_since(SystemTime::UNIX_EPOCH)
702            .expect("unix epoch")
703            .as_nanos();
704        std::env::temp_dir().join(format!("romm-cli-sync-test-{nanos}-{name}"))
705    }
706
707    #[test]
708    fn file_hash_matches_md5_hex() {
709        let path = temp_path("plain.sav");
710        fs::write(&path, b"abc123").expect("write");
711        let got = compute_file_hash(&path).expect("hash");
712        let expected = format!("{:x}", md5::compute(b"abc123"));
713        assert_eq!(got, expected);
714        let _ = fs::remove_file(path);
715    }
716
717    #[test]
718    fn zip_hash_matches_sorted_entry_scheme() {
719        let path = temp_path("archive.zip");
720        {
721            let f = File::create(&path).expect("create");
722            let mut writer = ZipWriter::new(f);
723            writer
724                .start_file("b.sav", SimpleFileOptions::default())
725                .expect("start file");
726            writer.write_all(b"bbb").expect("write b");
727            writer
728                .start_file("a.sav", SimpleFileOptions::default())
729                .expect("start file");
730            writer.write_all(b"aaa").expect("write a");
731            writer.finish().expect("finish");
732        }
733
734        let hash = compute_zip_hash(&path).expect("hash zip");
735        let a = format!("{:x}", md5::compute(b"aaa"));
736        let b = format!("{:x}", md5::compute(b"bbb"));
737        let combined = format!("a.sav:{a}\nb.sav:{b}");
738        let expected = format!("{:x}", md5::compute(combined.as_bytes()));
739        assert_eq!(hash, expected);
740        let _ = fs::remove_file(path);
741    }
742
743    #[test]
744    fn duplicate_manifest_keys_fail() {
745        let p = temp_path("dup.sav");
746        fs::write(&p, b"x").expect("write");
747        let prepared = vec![
748            PreparedSave {
749                path: p.clone(),
750                client: ClientSaveState {
751                    rom_id: 1,
752                    file_name: "same.sav".into(),
753                    slot: None,
754                    emulator: None,
755                    content_hash: "h1".into(),
756                    updated_at: "2026-01-01T00:00:00Z".into(),
757                    file_size_bytes: 1,
758                },
759            },
760            PreparedSave {
761                path: p.clone(),
762                client: ClientSaveState {
763                    rom_id: 1,
764                    file_name: "same.sav".into(),
765                    slot: None,
766                    emulator: None,
767                    content_hash: "h2".into(),
768                    updated_at: "2026-01-01T00:00:01Z".into(),
769                    file_size_bytes: 1,
770                },
771            },
772        ];
773        let err = prepared_by_key(&prepared).expect_err("duplicate should fail");
774        assert!(err.to_string().contains("duplicate manifest mapping"));
775        let _ = fs::remove_file(p);
776    }
777}