Skip to main content

rustfs_cli/commands/
diff.rs

1//! diff command - Compare objects between two locations
2//!
3//! Shows differences between two S3 paths or between local and remote.
4
5use clap::{Args, ValueEnum};
6use rc_core::{
7    AliasManager, ListOptions, ObjectInfo, ObjectStore as _, ParsedPath, RemotePath, parse_path,
8};
9use rc_s3::S3Client;
10use serde::Serialize;
11use std::collections::HashMap;
12use std::path::Path;
13
14use super::object_identity::identity_etag_from_metadata;
15use crate::exit_code::ExitCode;
16use crate::output::{Formatter, OutputConfig};
17
18/// How `rc diff` decides that two objects hold the same data.
19///
20/// These mirror `rc mirror --compare` so the two commands cannot disagree about
21/// whether a pair of objects is already in sync.
22#[derive(Copy, Clone, Debug, Default, Eq, PartialEq, ValueEnum)]
23pub enum CompareMode {
24    /// Same when ETags match, or when sizes match and the second object records
25    /// the first object's ETag in `x-amz-meta-rc-source-etag`.
26    #[default]
27    Auto,
28    /// Same only when both ETags are present and identical.
29    Etag,
30    /// Same when sizes match, ignoring ETag differences.
31    Size,
32}
33
34/// Compare objects between two locations
35#[derive(Args, Debug)]
36pub struct DiffArgs {
37    /// First path (alias/bucket/prefix or local path)
38    pub first: String,
39
40    /// Second path (alias/bucket/prefix or local path)
41    pub second: String,
42
43    /// Recursive comparison
44    #[arg(short, long)]
45    pub recursive: bool,
46
47    /// Show only differences (default: show all)
48    #[arg(long)]
49    pub diff_only: bool,
50
51    /// How to decide that two objects hold the same data
52    #[arg(long, value_enum, default_value_t = CompareMode::Auto)]
53    pub compare: CompareMode,
54}
55
56#[derive(Debug, Serialize, Clone)]
57pub struct DiffEntry {
58    pub key: String,
59    pub status: DiffStatus,
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub first_size: Option<i64>,
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub second_size: Option<i64>,
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub first_modified: Option<String>,
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub second_modified: Option<String>,
68}
69
70#[derive(Debug, Serialize, Clone, PartialEq, Eq)]
71#[serde(rename_all = "lowercase")]
72pub enum DiffStatus {
73    Same,
74    Different,
75    OnlyFirst,
76    OnlySecond,
77}
78
79#[derive(Debug, Serialize)]
80struct DiffOutput {
81    first: String,
82    second: String,
83    entries: Vec<DiffEntry>,
84    summary: DiffSummary,
85}
86
87#[derive(Debug, Serialize)]
88struct DiffSummary {
89    same: usize,
90    different: usize,
91    only_first: usize,
92    only_second: usize,
93    total: usize,
94}
95
96#[derive(Debug, Clone)]
97struct FileInfo {
98    /// Full object key, retained so `auto` compare can HeadObject this entry.
99    key: String,
100    size: Option<i64>,
101    modified: Option<String>,
102    etag: Option<String>,
103    /// Source ETag recorded by a previous `rc mirror` or cross-alias `rc cp`.
104    /// ListObjects never returns user metadata, so this is filled by HeadObject.
105    identity_etag: Option<String>,
106    /// The object changed between LIST and the identity HEAD request.
107    snapshot_conflict: bool,
108}
109
110/// Execute the diff command
111pub async fn execute(args: DiffArgs, output_config: OutputConfig) -> ExitCode {
112    let formatter = Formatter::new(output_config);
113
114    // Parse both paths
115    let first_parsed = parse_path(&args.first);
116    let second_parsed = parse_path(&args.second);
117
118    // Both must be remote for now (local support can be added later)
119    let (first_path, second_path) = match (&first_parsed, &second_parsed) {
120        (Ok(ParsedPath::Remote(f)), Ok(ParsedPath::Remote(s))) => (f.clone(), s.clone()),
121        (Ok(ParsedPath::Local(_)), _) | (_, Ok(ParsedPath::Local(_))) => {
122            formatter.error("Local paths are not yet supported in diff command");
123            return ExitCode::UsageError;
124        }
125        (Err(e), _) => {
126            formatter.error(&format!("Invalid first path: {e}"));
127            return ExitCode::UsageError;
128        }
129        (_, Err(e)) => {
130            formatter.error(&format!("Invalid second path: {e}"));
131            return ExitCode::UsageError;
132        }
133    };
134
135    // Load aliases
136    let alias_manager = match AliasManager::new() {
137        Ok(am) => am,
138        Err(e) => {
139            formatter.error(&format!("Failed to load aliases: {e}"));
140            return ExitCode::GeneralError;
141        }
142    };
143
144    // Create clients for both paths
145    let first_alias = match alias_manager.get(&first_path.alias) {
146        Ok(a) => a,
147        Err(_) => {
148            formatter.error(&format!("Alias '{}' not found", first_path.alias));
149            return ExitCode::NotFound;
150        }
151    };
152
153    let second_alias = match alias_manager.get(&second_path.alias) {
154        Ok(a) => a,
155        Err(_) => {
156            formatter.error(&format!("Alias '{}' not found", second_path.alias));
157            return ExitCode::NotFound;
158        }
159    };
160
161    let first_client = match S3Client::new(first_alias).await {
162        Ok(c) => c,
163        Err(e) => {
164            formatter.error(&format!("Failed to create client for first path: {e}"));
165            return ExitCode::NetworkError;
166        }
167    };
168
169    let second_client = match S3Client::new(second_alias).await {
170        Ok(c) => c,
171        Err(e) => {
172            formatter.error(&format!("Failed to create client for second path: {e}"));
173            return ExitCode::NetworkError;
174        }
175    };
176
177    // List objects from both paths
178    let first_objects = match list_objects_map(&first_client, &first_path, args.recursive).await {
179        Ok(o) => o,
180        Err(e) => {
181            formatter.error(&format!("Failed to list first path: {e}"));
182            return ExitCode::NetworkError;
183        }
184    };
185
186    let mut second_objects =
187        match list_objects_map(&second_client, &second_path, args.recursive).await {
188            Ok(o) => o,
189            Err(e) => {
190                formatter.error(&format!("Failed to list second path: {e}"));
191                return ExitCode::NetworkError;
192            }
193        };
194
195    enrich_second_identity(
196        &second_client,
197        &second_path,
198        &first_objects,
199        &mut second_objects,
200        args.compare,
201    )
202    .await;
203
204    // Compare objects
205    let entries = compare_objects(
206        &first_objects,
207        &second_objects,
208        args.diff_only,
209        args.compare,
210    );
211
212    // Calculate summary
213    let mut summary = DiffSummary {
214        same: 0,
215        different: 0,
216        only_first: 0,
217        only_second: 0,
218        total: entries.len(),
219    };
220
221    for entry in &entries {
222        match entry.status {
223            DiffStatus::Same => summary.same += 1,
224            DiffStatus::Different => summary.different += 1,
225            DiffStatus::OnlyFirst => summary.only_first += 1,
226            DiffStatus::OnlySecond => summary.only_second += 1,
227        }
228    }
229
230    // Determine exit code before moving summary
231    let has_differences =
232        summary.different > 0 || summary.only_first > 0 || summary.only_second > 0;
233
234    if formatter.is_json() {
235        let output = DiffOutput {
236            first: args.first.clone(),
237            second: args.second.clone(),
238            entries,
239            summary,
240        };
241        formatter.json(&output);
242    } else {
243        // Print diff entries
244        for entry in &entries {
245            let status_char = match entry.status {
246                DiffStatus::Same => "=",
247                DiffStatus::Different => "≠",
248                DiffStatus::OnlyFirst => "<",
249                DiffStatus::OnlySecond => ">",
250            };
251
252            let size_info = match entry.status {
253                DiffStatus::Same => entry.first_size.map(format_size).unwrap_or_default(),
254                DiffStatus::Different => {
255                    let first = entry.first_size.map(format_size).unwrap_or_default();
256                    let second = entry.second_size.map(format_size).unwrap_or_default();
257                    format!("{first} → {second}")
258                }
259                DiffStatus::OnlyFirst => entry.first_size.map(format_size).unwrap_or_default(),
260                DiffStatus::OnlySecond => entry.second_size.map(format_size).unwrap_or_default(),
261            };
262
263            formatter.println(&format!(
264                "{status_char} {:<50} {size_info}",
265                formatter.sanitize_text(&entry.key)
266            ));
267        }
268
269        // Print summary
270        formatter.println("");
271        formatter.println(&format!(
272            "Summary: {} same, {} different, {} only in first, {} only in second",
273            summary.same, summary.different, summary.only_first, summary.only_second
274        ));
275    }
276
277    // Return appropriate exit code
278    if has_differences {
279        ExitCode::GeneralError // Indicates differences found
280    } else {
281        ExitCode::Success
282    }
283}
284
285async fn list_objects_map(
286    client: &S3Client,
287    path: &RemotePath,
288    recursive: bool,
289) -> Result<HashMap<String, FileInfo>, rc_core::Error> {
290    let mut objects = HashMap::new();
291    let mut continuation_token: Option<String> = None;
292    let base_prefix = &path.key;
293
294    loop {
295        let options = ListOptions {
296            recursive,
297            max_keys: Some(1000),
298            continuation_token: continuation_token.clone(),
299            ..Default::default()
300        };
301
302        let result = client.list_objects(path, options).await?;
303
304        for item in result.items {
305            if item.is_dir {
306                continue;
307            }
308
309            // Get relative key (remove base prefix)
310            let relative_key = item.key.strip_prefix(base_prefix).unwrap_or(&item.key);
311            let relative_key = relative_key.trim_start_matches('/').to_string();
312
313            let map_key = if relative_key.is_empty() {
314                // Single object case
315                Path::new(&item.key)
316                    .file_name()
317                    .map(|s| s.to_string_lossy().to_string())
318                    .unwrap_or_else(|| item.key.clone())
319            } else {
320                relative_key
321            };
322            objects.insert(
323                map_key,
324                FileInfo {
325                    key: item.key,
326                    size: item.size_bytes,
327                    modified: item.last_modified.map(|t| t.to_string()),
328                    etag: item.etag,
329                    identity_etag: None,
330                    snapshot_conflict: false,
331                },
332            );
333        }
334
335        if result.truncated {
336            continuation_token = result.continuation_token;
337        } else {
338            break;
339        }
340    }
341
342    Ok(objects)
343}
344
345/// Decide whether the second object already holds the first object's data.
346///
347/// A client-streamed copy cannot preserve the source ETag, so `auto` also
348/// accepts a recorded source identity. This is the same rule `rc mirror` uses to
349/// skip a copy, which keeps `diff` from reporting a difference for a pair that
350/// `mirror` considers synchronized.
351fn objects_match(first: &FileInfo, second: &FileInfo, compare: CompareMode) -> bool {
352    if first.snapshot_conflict || second.snapshot_conflict {
353        return false;
354    }
355    let (Some(first_size), Some(second_size)) = (first.size, second.size) else {
356        return false;
357    };
358    if first_size != second_size {
359        return false;
360    }
361    match compare {
362        CompareMode::Size => true,
363        CompareMode::Etag => first.etag.is_some() && first.etag == second.etag,
364        CompareMode::Auto => {
365            if first.etag.is_some() && first.etag == second.etag {
366                return true;
367            }
368            first
369                .etag
370                .as_ref()
371                .zip(second.identity_etag.as_ref())
372                .is_some_and(|(first_etag, identity_etag)| first_etag == identity_etag)
373        }
374    }
375}
376
377/// Whether HeadObject on the second entry could still prove the pair identical.
378///
379/// Restricted to same-size pairs whose listed ETags differ, so an unchanged tree
380/// costs no extra requests.
381fn second_needs_identity_lookup(first: &FileInfo, second: &FileInfo, compare: CompareMode) -> bool {
382    if !matches!(compare, CompareMode::Auto) {
383        return false;
384    }
385    if first.size.is_none() || first.size != second.size {
386        return false;
387    }
388    let Some(first_etag) = first.etag.as_ref() else {
389        return false;
390    };
391    if second.etag.as_ref() == Some(first_etag) {
392        return false;
393    }
394    second.identity_etag.is_none()
395}
396
397/// Fill recorded source identities for entries that could still match.
398///
399/// ListObjects omits user metadata, so the identity has to come from HeadObject.
400/// A failed lookup leaves the entry unenriched and it is reported as different.
401async fn enrich_second_identity(
402    client: &S3Client,
403    path: &RemotePath,
404    first: &HashMap<String, FileInfo>,
405    second: &mut HashMap<String, FileInfo>,
406    compare: CompareMode,
407) {
408    let pending: Vec<String> = second
409        .iter()
410        .filter(|(key, second_info)| {
411            first.get(*key).is_some_and(|first_info| {
412                second_needs_identity_lookup(first_info, second_info, compare)
413            })
414        })
415        .map(|(key, _)| key.clone())
416        .collect();
417
418    for map_key in pending {
419        let Some(second_info) = second.get(&map_key) else {
420            continue;
421        };
422        let object_path = RemotePath::new(&path.alias, &path.bucket, &second_info.key);
423        match client.head_object(&object_path).await {
424            Ok(info) => {
425                let listed_matches = second_snapshot_matches_head(second_info, &info);
426                if let Some(entry) = second.get_mut(&map_key) {
427                    entry.snapshot_conflict = !listed_matches;
428                    if listed_matches {
429                        entry.identity_etag = identity_etag_from_metadata(info.metadata.as_ref());
430                    }
431                }
432            }
433            Err(_) => {
434                if let Some(entry) = second.get_mut(&map_key) {
435                    entry.snapshot_conflict = true;
436                }
437            }
438        }
439    }
440}
441
442fn second_snapshot_matches_head(listed: &FileInfo, head: &ObjectInfo) -> bool {
443    listed.size == head.size_bytes && listed.etag == head.etag
444}
445
446fn compare_objects(
447    first: &HashMap<String, FileInfo>,
448    second: &HashMap<String, FileInfo>,
449    diff_only: bool,
450    compare: CompareMode,
451) -> Vec<DiffEntry> {
452    let mut entries = Vec::new();
453
454    // Check objects in first
455    for (key, first_info) in first {
456        if let Some(second_info) = second.get(key) {
457            // Object exists in both
458            let status = if objects_match(first_info, second_info, compare) {
459                DiffStatus::Same
460            } else {
461                DiffStatus::Different
462            };
463
464            if !diff_only || status != DiffStatus::Same {
465                entries.push(DiffEntry {
466                    key: key.clone(),
467                    status,
468                    first_size: first_info.size,
469                    second_size: second_info.size,
470                    first_modified: first_info.modified.clone(),
471                    second_modified: second_info.modified.clone(),
472                });
473            }
474        } else {
475            // Only in first
476            entries.push(DiffEntry {
477                key: key.clone(),
478                status: DiffStatus::OnlyFirst,
479                first_size: first_info.size,
480                second_size: None,
481                first_modified: first_info.modified.clone(),
482                second_modified: None,
483            });
484        }
485    }
486
487    // Check objects only in second
488    for (key, second_info) in second {
489        if !first.contains_key(key) {
490            entries.push(DiffEntry {
491                key: key.clone(),
492                status: DiffStatus::OnlySecond,
493                first_size: None,
494                second_size: second_info.size,
495                first_modified: None,
496                second_modified: second_info.modified.clone(),
497            });
498        }
499    }
500
501    // Sort by key
502    entries.sort_by(|a, b| a.key.cmp(&b.key));
503    entries
504}
505
506fn format_size(size: i64) -> String {
507    humansize::format_size(size as u64, humansize::BINARY)
508}
509
510#[cfg(test)]
511mod tests {
512    use super::*;
513
514    fn entry(size: i64, etag: Option<&str>) -> FileInfo {
515        FileInfo {
516            key: "prefix/file.txt".to_string(),
517            size: Some(size),
518            modified: None,
519            etag: etag.map(ToOwned::to_owned),
520            identity_etag: None,
521            snapshot_conflict: false,
522        }
523    }
524
525    fn entry_with_identity(size: i64, etag: &str, identity_etag: &str) -> FileInfo {
526        FileInfo {
527            identity_etag: Some(identity_etag.to_string()),
528            ..entry(size, Some(etag))
529        }
530    }
531
532    fn one(key: &str, info: FileInfo) -> HashMap<String, FileInfo> {
533        HashMap::from([(key.to_string(), info)])
534    }
535
536    #[test]
537    fn test_compare_objects_same() {
538        let first = one("file.txt", entry(100, Some("abc123")));
539        let second = one("file.txt", entry(100, Some("abc123")));
540
541        let entries = compare_objects(&first, &second, false, CompareMode::Auto);
542        assert_eq!(entries.len(), 1);
543        assert_eq!(entries[0].status, DiffStatus::Same);
544    }
545
546    #[test]
547    fn test_compare_objects_different() {
548        let first = one("file.txt", entry(100, Some("abc123")));
549        let second = one("file.txt", entry(200, Some("def456")));
550
551        let entries = compare_objects(&first, &second, false, CompareMode::Auto);
552        assert_eq!(entries.len(), 1);
553        assert_eq!(entries[0].status, DiffStatus::Different);
554    }
555
556    #[test]
557    fn identity_head_must_match_the_listed_size_and_etag() {
558        let listed = entry(100, Some("listed-etag"));
559        let mut head = ObjectInfo::file("prefix/file.txt", 100);
560        head.etag = Some("listed-etag".to_string());
561        assert!(second_snapshot_matches_head(&listed, &head));
562
563        head.size_bytes = Some(101);
564        assert!(!second_snapshot_matches_head(&listed, &head));
565        head.size_bytes = Some(100);
566        head.etag = Some("changed-etag".to_string());
567        assert!(!second_snapshot_matches_head(&listed, &head));
568
569        let mut conflicted = listed.clone();
570        conflicted.snapshot_conflict = true;
571        assert!(!objects_match(
572            &entry(100, Some("listed-etag")),
573            &conflicted,
574            CompareMode::Auto
575        ));
576    }
577
578    #[test]
579    fn test_compare_objects_missing_etag_is_different() {
580        let first = one("file.txt", entry(100, None));
581        let second = one("file.txt", entry(100, Some("second-etag")));
582
583        let entries = compare_objects(&first, &second, false, CompareMode::Auto);
584
585        assert_eq!(entries[0].status, DiffStatus::Different);
586    }
587
588    #[test]
589    fn test_compare_objects_only_first() {
590        let first = one("file.txt", entry(100, None));
591        let second = HashMap::new();
592
593        let entries = compare_objects(&first, &second, false, CompareMode::Auto);
594        assert_eq!(entries.len(), 1);
595        assert_eq!(entries[0].status, DiffStatus::OnlyFirst);
596    }
597
598    #[test]
599    fn test_compare_objects_only_second() {
600        let first = HashMap::new();
601        let second = one("file.txt", entry(100, None));
602
603        let entries = compare_objects(&first, &second, false, CompareMode::Auto);
604        assert_eq!(entries.len(), 1);
605        assert_eq!(entries[0].status, DiffStatus::OnlySecond);
606    }
607
608    #[test]
609    fn auto_compare_treats_a_recorded_source_identity_as_same() {
610        let first = one("file.txt", entry(100, Some("source-etag")));
611        let second = one(
612            "file.txt",
613            entry_with_identity(100, "multipart-etag-1", "source-etag"),
614        );
615
616        let entries = compare_objects(&first, &second, false, CompareMode::Auto);
617
618        assert_eq!(
619            entries[0].status,
620            DiffStatus::Same,
621            "auto must agree with mirror --compare auto"
622        );
623    }
624
625    #[test]
626    fn etag_compare_ignores_a_recorded_source_identity() {
627        let first = one("file.txt", entry(100, Some("source-etag")));
628        let second = one(
629            "file.txt",
630            entry_with_identity(100, "multipart-etag-1", "source-etag"),
631        );
632
633        let entries = compare_objects(&first, &second, false, CompareMode::Etag);
634
635        assert_eq!(entries[0].status, DiffStatus::Different);
636    }
637
638    #[test]
639    fn size_compare_ignores_etag_differences() {
640        let first = one("file.txt", entry(100, Some("source-etag")));
641        let second = one("file.txt", entry(100, Some("other-etag")));
642
643        let entries = compare_objects(&first, &second, false, CompareMode::Size);
644
645        assert_eq!(entries[0].status, DiffStatus::Same);
646    }
647
648    #[test]
649    fn auto_compare_reports_a_mismatched_identity_as_different() {
650        let first = one("file.txt", entry(100, Some("source-etag")));
651        let second = one(
652            "file.txt",
653            entry_with_identity(100, "multipart-etag-1", "other-etag"),
654        );
655
656        let entries = compare_objects(&first, &second, false, CompareMode::Auto);
657
658        assert_eq!(entries[0].status, DiffStatus::Different);
659    }
660
661    #[test]
662    fn size_mismatch_is_different_in_every_compare_mode() {
663        let first = one("file.txt", entry(100, Some("source-etag")));
664        let second = one(
665            "file.txt",
666            entry_with_identity(200, "source-etag", "source-etag"),
667        );
668
669        for compare in [CompareMode::Auto, CompareMode::Etag, CompareMode::Size] {
670            let entries = compare_objects(&first, &second, false, compare);
671            assert_eq!(
672                entries[0].status,
673                DiffStatus::Different,
674                "{compare:?} must not call different sizes the same"
675            );
676        }
677    }
678
679    #[test]
680    fn unknown_sizes_are_never_assumed_equal() {
681        let mut missing = entry(100, Some("source-etag"));
682        missing.size = None;
683        let first = one("file.txt", missing.clone());
684        let second = one("file.txt", missing);
685
686        for compare in [CompareMode::Auto, CompareMode::Etag, CompareMode::Size] {
687            let entries = compare_objects(&first, &second, false, compare);
688            assert_eq!(
689                entries[0].status,
690                DiffStatus::Different,
691                "{compare:?} must not assume equality without sizes"
692            );
693        }
694    }
695
696    #[test]
697    fn identity_lookup_is_limited_to_auto_same_size_etag_mismatches() {
698        let source = entry(100, Some("source-etag"));
699        let mismatched = entry(100, Some("other-etag"));
700
701        assert!(second_needs_identity_lookup(
702            &source,
703            &mismatched,
704            CompareMode::Auto
705        ));
706
707        assert!(
708            !second_needs_identity_lookup(
709                &source,
710                &entry(100, Some("source-etag")),
711                CompareMode::Auto
712            ),
713            "matching ETags already prove equality"
714        );
715        assert!(
716            !second_needs_identity_lookup(
717                &source,
718                &entry_with_identity(100, "other-etag", "source-etag"),
719                CompareMode::Auto
720            ),
721            "an entry that already has an identity needs no lookup"
722        );
723        assert!(
724            !second_needs_identity_lookup(
725                &source,
726                &entry(200, Some("other-etag")),
727                CompareMode::Auto
728            ),
729            "different sizes can never match"
730        );
731        assert!(!second_needs_identity_lookup(
732            &source,
733            &mismatched,
734            CompareMode::Etag
735        ));
736        assert!(!second_needs_identity_lookup(
737            &source,
738            &mismatched,
739            CompareMode::Size
740        ));
741
742        let mut unknown_size = source.clone();
743        unknown_size.size = None;
744        assert!(
745            !second_needs_identity_lookup(&unknown_size, &mismatched, CompareMode::Auto),
746            "an unknown source size cannot be reconciled by metadata"
747        );
748
749        let mut no_etag = source.clone();
750        no_etag.etag = None;
751        assert!(
752            !second_needs_identity_lookup(&no_etag, &mismatched, CompareMode::Auto),
753            "without a source ETag there is nothing to match an identity against"
754        );
755    }
756
757    #[test]
758    fn diff_only_hides_matching_entries_in_auto_mode() {
759        let first = HashMap::from([
760            ("same.txt".to_string(), entry(100, Some("source-etag"))),
761            ("changed.txt".to_string(), entry(100, Some("source-etag"))),
762        ]);
763        let second = HashMap::from([
764            (
765                "same.txt".to_string(),
766                entry_with_identity(100, "multipart-etag-1", "source-etag"),
767            ),
768            ("changed.txt".to_string(), entry(100, Some("other-etag"))),
769        ]);
770
771        let entries = compare_objects(&first, &second, true, CompareMode::Auto);
772
773        assert_eq!(entries.len(), 1);
774        assert_eq!(entries[0].key, "changed.txt");
775        assert_eq!(entries[0].status, DiffStatus::Different);
776    }
777}