1use std::{
24 collections::BTreeMap,
25 path::{Path, PathBuf},
26 process::Command,
27};
28
29use jiff::{Span, Timestamp};
30use miette::{Context as _, IntoDiagnostic as _, Result, miette};
31use serde::{Deserialize, Serialize};
32
33pub const LINUX_KOPIA_USER: &str = "kopia";
35
36pub const LINUX_KOPIA_CONFIG: &str = "/var/lib/kopia/.config/kopia/repository.config";
39
40pub fn find_kopia_binary(override_path: Option<&Path>) -> Option<PathBuf> {
47 if let Some(p) = override_path {
48 return Some(p.to_path_buf());
49 }
50 if let Some(p) = find_in_path("kopia") {
51 return Some(p);
52 }
53 if cfg!(windows) {
54 return find_windows_kopia_binary();
55 }
56 None
57}
58
59fn find_in_path(name: &str) -> Option<PathBuf> {
60 let exe = if cfg!(windows) {
61 format!("{name}.exe")
62 } else {
63 name.to_string()
64 };
65 let path = std::env::var_os("PATH")?;
66 for dir in std::env::split_paths(&path) {
67 let candidate = dir.join(&exe);
68 if candidate.is_file() {
69 return Some(candidate);
70 }
71 }
72 None
73}
74
75pub fn find_windows_kopia_binary() -> Option<PathBuf> {
77 let mut candidates: Vec<PathBuf> = Vec::new();
78 if let Ok(local) = std::env::var("LOCALAPPDATA") {
79 candidates.push(
80 Path::new(&local)
81 .join("Programs")
82 .join("KopiaUI")
83 .join("resources")
84 .join("server")
85 .join("kopia.exe"),
86 );
87 }
88 if let Ok(pf) = std::env::var("ProgramFiles") {
89 candidates.push(
90 Path::new(&pf)
91 .join("KopiaUI")
92 .join("resources")
93 .join("server")
94 .join("kopia.exe"),
95 );
96 }
97 if let Ok(pf86) = std::env::var("ProgramFiles(x86)") {
98 candidates.push(
99 Path::new(&pf86)
100 .join("KopiaUI")
101 .join("resources")
102 .join("server")
103 .join("kopia.exe"),
104 );
105 }
106 candidates.into_iter().find(|p| p.exists())
107}
108
109pub fn find_windows_kopia_config() -> Option<PathBuf> {
111 let appdata = std::env::var("APPDATA").ok()?;
112 let config = Path::new(&appdata).join("kopia").join("repository.config");
113 config.exists().then_some(config)
114}
115
116pub fn current_username() -> Option<String> {
119 whoami::username().ok()
120}
121
122#[derive(Debug)]
124pub enum Elevation {
125 Direct,
128 Sudo,
133 Skip(String),
135}
136
137#[cfg(target_os = "linux")]
149pub fn linux_elevation() -> Elevation {
150 let Some(user) = current_username() else {
151 return Elevation::Skip("could not determine current Unix username".into());
152 };
153
154 if user == LINUX_KOPIA_USER {
155 return Elevation::Direct;
156 }
157
158 match std::fs::metadata(LINUX_KOPIA_CONFIG) {
159 Ok(_) => Elevation::Direct,
160 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Elevation::Direct,
161 Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => Elevation::Sudo,
162 Err(err) => Elevation::Skip(format!("checking {LINUX_KOPIA_CONFIG}: {err}")),
163 }
164}
165
166#[cfg(not(target_os = "linux"))]
167pub fn linux_elevation() -> Elevation {
168 Elevation::Direct
169}
170
171pub fn build_kopia_command(kopia: &Path) -> Result<Command, String> {
179 if cfg!(target_os = "linux") {
180 match linux_elevation() {
181 Elevation::Direct => Ok(Command::new(kopia)),
182 Elevation::Sudo => {
183 let mut c = Command::new("sudo");
184 c.arg("-u").arg(LINUX_KOPIA_USER).arg("--").arg(kopia);
185 Ok(c)
186 }
187 Elevation::Skip(reason) => Err(reason),
188 }
189 } else {
190 Ok(Command::new(kopia))
191 }
192}
193
194#[derive(Debug, Clone, Deserialize, Serialize)]
196#[serde(rename_all = "camelCase")]
197pub struct Snapshot {
198 pub id: String,
199 pub source: SnapshotSource,
200 #[serde(default)]
201 pub description: String,
202 #[serde(default)]
203 pub start_time: Option<Timestamp>,
204 #[serde(default)]
205 pub end_time: Option<Timestamp>,
206 #[serde(default)]
207 pub tags: BTreeMap<String, String>,
208 #[serde(default)]
209 pub root_entry: Option<RootEntry>,
210}
211
212#[derive(Debug, Clone, Deserialize, Serialize)]
213pub struct SnapshotSource {
214 #[serde(default)]
215 pub host: String,
216 #[serde(default, rename = "userName")]
217 pub user_name: String,
218 #[serde(default)]
219 pub path: String,
220}
221
222#[derive(Debug, Clone, Deserialize, Serialize)]
223pub struct RootEntry {
224 #[serde(default, rename = "summ")]
225 pub summary: Option<DirSummary>,
226}
227
228#[derive(Debug, Clone, Deserialize, Serialize)]
229pub struct DirSummary {
230 #[serde(default, rename = "size")]
231 pub total_size: i64,
232 #[serde(default, rename = "files")]
233 pub total_files: i64,
234 #[serde(default, rename = "dirs")]
235 pub total_dirs: i64,
236}
237
238impl Snapshot {
239 pub fn taken_at(&self) -> Option<Timestamp> {
241 self.end_time.or(self.start_time)
242 }
243
244 pub fn total_size(&self) -> Option<i64> {
246 self.root_entry
247 .as_ref()
248 .and_then(|r| r.summary.as_ref())
249 .map(|s| s.total_size)
250 }
251}
252
253#[derive(Debug, Default, Clone)]
255pub struct SnapshotFilter {
256 pub source_host: Option<String>,
258 pub tags: BTreeMap<String, String>,
260 pub path_substr: Option<String>,
262 pub since: Option<Span>,
264 pub limit: Option<usize>,
266}
267
268impl SnapshotFilter {
269 pub fn apply(&self, snapshots: &[Snapshot], now: Timestamp) -> Vec<Snapshot> {
272 let cutoff: Option<Timestamp> = self.since.and_then(|span| now.checked_sub(span).ok());
273
274 let path_substr_lc = self.path_substr.as_ref().map(|s| s.to_lowercase());
275
276 let mut matches: Vec<Snapshot> = snapshots
277 .iter()
278 .filter(|s| {
279 if let Some(host) = &self.source_host
280 && s.source.host != *host
281 {
282 return false;
283 }
284 for (k, v) in &self.tags {
285 if s.tags.get(k) != Some(v) {
286 return false;
287 }
288 }
289 if let Some(needle) = &path_substr_lc
290 && !s.source.path.to_lowercase().contains(needle)
291 {
292 return false;
293 }
294 if let Some(cutoff) = cutoff
295 && s.taken_at().is_none_or(|t| t < cutoff)
296 {
297 return false;
298 }
299 true
300 })
301 .cloned()
302 .collect();
303
304 matches.sort_by_key(|s| std::cmp::Reverse(s.taken_at()));
305
306 if let Some(n) = self.limit {
307 matches.truncate(n);
308 }
309 matches
310 }
311}
312
313pub fn parse_tag_kv(s: &str) -> Result<(String, String), String> {
315 s.split_once(':')
316 .map(|(k, v)| (k.trim().to_string(), v.trim().to_string()))
317 .filter(|(k, v)| !k.is_empty() && !v.is_empty())
318 .ok_or_else(|| format!("expected KEY:VALUE, got `{s}`"))
319}
320
321pub fn build_filter(
324 all: bool,
325 source_host: Option<String>,
326 default_host: Option<String>,
327 tags: &[String],
328 path: Option<String>,
329 since: Option<&str>,
330 limit: Option<usize>,
331) -> Result<SnapshotFilter> {
332 let source_host = if all {
333 None
334 } else {
335 source_host.or(default_host)
336 };
337
338 let mut tag_map = BTreeMap::new();
339 for raw in tags {
340 let (k, v) = parse_tag_kv(raw).map_err(|e| miette!("invalid --tag: {e}"))?;
341 tag_map.insert(k, v);
342 }
343
344 let since = since
345 .map(|s| {
346 s.parse::<Span>()
347 .map_err(|e| miette!("invalid --since duration `{s}`: {e}"))
348 })
349 .transpose()?;
350
351 Ok(SnapshotFilter {
352 source_host,
353 tags: tag_map,
354 path_substr: path,
355 since,
356 limit,
357 })
358}
359
360pub fn fetch_snapshots(bin: &Path) -> Result<Vec<Snapshot>> {
366 let output = Command::new(bin)
367 .args(["snapshot", "list", "--json", "--all"])
368 .env("KOPIA_CHECK_FOR_UPDATES", "false")
369 .output()
370 .into_diagnostic()
371 .wrap_err_with(|| format!("invoking {}", bin.display()))?;
372 if !output.status.success() {
373 let stderr = String::from_utf8_lossy(&output.stderr);
374 return Err(miette!(
375 "kopia snapshot list exited {}: {}",
376 output.status,
377 stderr.trim()
378 ));
379 }
380 serde_json::from_slice(&output.stdout)
381 .into_diagnostic()
382 .wrap_err("decoding kopia snapshot list JSON")
383}
384
385pub fn short_id(id: &str) -> String {
388 const SHORT: usize = 16;
389 if id.len() <= SHORT {
390 id.to_string()
391 } else {
392 id.chars().take(SHORT).collect()
393 }
394}
395
396pub fn format_taken(ts: Timestamp) -> String {
398 ts.strftime("%Y-%m-%d %H:%M").to_string()
399}
400
401pub fn format_tags(tags: &BTreeMap<String, String>) -> String {
403 tags.iter()
404 .map(|(k, v)| format!("{k}={v}"))
405 .collect::<Vec<_>>()
406 .join(", ")
407}
408
409pub fn format_snapshot_line(snap: &Snapshot) -> String {
411 let taken = snap
412 .taken_at()
413 .map(format_taken)
414 .unwrap_or_else(|| "—".into());
415 let source = format!(
416 "{}@{}:{}",
417 snap.source.user_name, snap.source.host, snap.source.path
418 );
419 let size = snap
420 .total_size()
421 .map(human_bytes)
422 .unwrap_or_else(|| "—".into());
423 let tags = format_tags(&snap.tags);
424 if tags.is_empty() {
425 format!("{} {taken} {source} {size}", short_id(&snap.id))
426 } else {
427 format!(
428 "{} {taken} {source} {size} [{tags}]",
429 short_id(&snap.id)
430 )
431 }
432}
433
434pub fn human_bytes(b: i64) -> String {
436 if b < 0 {
437 return "?".into();
438 }
439 const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB", "PB"];
440 let mut value = b as f64;
441 let mut unit = 0;
442 while value >= 1024.0 && unit < UNITS.len() - 1 {
443 value /= 1024.0;
444 unit += 1;
445 }
446 if unit == 0 {
447 format!("{}{}", b, UNITS[0])
448 } else {
449 format!("{value:.1}{}", UNITS[unit])
450 }
451}
452
453#[cfg(feature = "cli")]
454pub use cli::*;
455
456#[cfg(feature = "cli")]
457mod cli {
458 use clap::Args;
459 use dialoguer::Select;
460 use miette::{Context as _, IntoDiagnostic as _, Result, bail};
461
462 use super::*;
463
464 #[derive(Debug, Clone, Args)]
468 pub struct SnapshotSelectorArgs {
469 #[arg(long, value_name = "ID")]
472 pub snapshot: Option<String>,
473
474 #[arg(long, conflicts_with = "snapshot")]
481 pub latest: bool,
482
483 #[arg(long, value_name = "HOST", conflicts_with = "all")]
485 pub source_host: Option<String>,
486
487 #[arg(long, conflicts_with = "source_host")]
489 pub all: bool,
490
491 #[arg(long = "tag", value_name = "KEY:VALUE", value_parser = parse_tag_arg)]
493 pub tags: Vec<String>,
494
495 #[arg(long, value_name = "SUBSTR")]
497 pub path: Option<String>,
498
499 #[arg(long, value_name = "DURATION")]
501 pub since: Option<String>,
502 }
503
504 fn parse_tag_arg(s: &str) -> Result<String, String> {
505 parse_tag_kv(s).map(|_| s.to_string())
506 }
507
508 impl SnapshotSelectorArgs {
509 pub fn resolve(
514 &self,
515 bin: &std::path::Path,
516 default_host: Option<String>,
517 picker_prompt: &str,
518 ) -> Result<Snapshot> {
519 use std::io::IsTerminal as _;
520
521 if let Some(id) = &self.snapshot {
522 return resolve_by_id(bin, id);
523 }
524
525 if self.latest && self.tags.is_empty() && self.path.is_none() {
526 bail!(
527 "--latest requires --tag or --path: a kopia repo has many kinds of snapshots, and the newest unfiltered one would pick an arbitrary type. Narrow with --tag (e.g. --tag area:postgres) or --path."
528 );
529 }
530
531 let snapshots = fetch_snapshots(bin)?;
532 let filter = build_filter(
533 self.all,
534 self.source_host.clone(),
535 default_host,
536 &self.tags,
537 self.path.clone(),
538 self.since.as_deref(),
539 None,
540 )?;
541 let matches = filter.apply(&snapshots, Timestamp::now());
542
543 if matches.is_empty() {
544 bail!("no snapshots match the given filters");
545 }
546
547 if self.latest {
548 return Ok(matches.into_iter().next().expect("non-empty"));
549 }
550
551 let interactive = std::io::stdout().is_terminal() && std::io::stdin().is_terminal();
552 if !interactive {
553 bail!(
554 "no snapshot specified and stdin/stdout isn't a TTY — pass --snapshot, or --latest (with --tag/--path) to pick the newest match"
555 );
556 }
557
558 select_snapshot(&matches, picker_prompt)
559 }
560 }
561
562 fn resolve_by_id(bin: &std::path::Path, id_query: &str) -> Result<Snapshot> {
563 let snapshots = fetch_snapshots(bin)?;
564 let matches: Vec<&Snapshot> = snapshots
565 .iter()
566 .filter(|s| s.id.starts_with(id_query))
567 .collect();
568 match matches.len() {
569 0 => bail!("no snapshot found with id starting `{id_query}`"),
570 1 => Ok(matches[0].clone()),
571 n => bail!("snapshot id `{id_query}` is ambiguous ({n} matches); use a longer prefix"),
572 }
573 }
574
575 pub fn select_snapshot(snapshots: &[Snapshot], prompt: &str) -> Result<Snapshot> {
578 let items: Vec<String> = snapshots.iter().map(format_snapshot_line).collect();
579 let selection = Select::new()
580 .with_prompt(prompt)
581 .items(&items)
582 .default(0)
583 .interact()
584 .into_diagnostic()
585 .wrap_err("interactive picker failed")?;
586 Ok(snapshots[selection].clone())
587 }
588}
589
590#[cfg(test)]
591mod tests {
592 use jiff::ToSpan;
593
594 use super::*;
595
596 fn snapshot(id: &str, host: &str, path: &str, taken: Timestamp) -> Snapshot {
597 Snapshot {
598 id: id.into(),
599 source: SnapshotSource {
600 host: host.into(),
601 user_name: "kopia".into(),
602 path: path.into(),
603 },
604 description: String::new(),
605 start_time: Some(taken),
606 end_time: Some(taken),
607 tags: BTreeMap::new(),
608 root_entry: None,
609 }
610 }
611
612 #[test]
613 fn filter_by_host() {
614 let now = Timestamp::from_second(10_000_000).unwrap();
615 let snaps = vec![
616 snapshot("a", "host-1", "/data", now),
617 snapshot("b", "host-2", "/data", now),
618 ];
619 let filter = SnapshotFilter {
620 source_host: Some("host-1".into()),
621 ..Default::default()
622 };
623 let got = filter.apply(&snaps, now);
624 assert_eq!(got.len(), 1);
625 assert_eq!(got[0].id, "a");
626 }
627
628 #[test]
629 fn filter_by_tags_requires_all_to_match() {
630 let now = Timestamp::from_second(10_000_000).unwrap();
631 let mut tagged = snapshot("t", "h", "/data", now);
632 tagged.tags.insert("area".into(), "postgres".into());
633 tagged.tags.insert("type".into(), "ext4".into());
634 let snaps = vec![tagged, snapshot("u", "h", "/data", now)];
635
636 let mut tags = BTreeMap::new();
637 tags.insert("area".into(), "postgres".into());
638 tags.insert("type".into(), "ext4".into());
639 let filter = SnapshotFilter {
640 tags,
641 ..Default::default()
642 };
643 let got = filter.apply(&snaps, now);
644 assert_eq!(got.len(), 1);
645 assert_eq!(got[0].id, "t");
646 }
647
648 #[test]
649 fn filter_path_substr_case_insensitive() {
650 let now = Timestamp::from_second(10_000_000).unwrap();
651 let snaps = vec![
652 snapshot("a", "h", r"C:\Program Files\PostgreSQL\15", now),
653 snapshot("b", "h", "/var/log/something", now),
654 ];
655 let filter = SnapshotFilter {
656 path_substr: Some("postgresql".into()),
657 ..Default::default()
658 };
659 let got = filter.apply(&snaps, now);
660 assert_eq!(got.len(), 1);
661 assert_eq!(got[0].id, "a");
662 }
663
664 #[test]
665 fn filter_since_drops_old_snapshots() {
666 let now = Timestamp::from_second(10_000_000).unwrap();
667 let snaps = vec![
668 snapshot("recent", "h", "/data", now - 1.hour()),
669 snapshot("old", "h", "/data", now - 30.hours()),
670 ];
671 let filter = SnapshotFilter {
672 since: Some(24.hours()),
673 ..Default::default()
674 };
675 let got = filter.apply(&snaps, now);
676 assert_eq!(got.len(), 1);
677 assert_eq!(got[0].id, "recent");
678 }
679
680 #[test]
681 fn filter_sorts_newest_first() {
682 let now = Timestamp::from_second(10_000_000).unwrap();
683 let snaps = vec![
684 snapshot("older", "h", "/data", now - 2.hours()),
685 snapshot("newer", "h", "/data", now - 1.hour()),
686 ];
687 let filter = SnapshotFilter::default();
688 let got = filter.apply(&snaps, now);
689 assert_eq!(got[0].id, "newer");
690 assert_eq!(got[1].id, "older");
691 }
692
693 #[test]
694 fn filter_limit_truncates_after_sort() {
695 let now = Timestamp::from_second(10_000_000).unwrap();
696 let snaps = vec![
697 snapshot("a", "h", "/data", now - 3.hours()),
698 snapshot("b", "h", "/data", now - 1.hour()),
699 snapshot("c", "h", "/data", now - 2.hours()),
700 ];
701 let filter = SnapshotFilter {
702 limit: Some(2),
703 ..Default::default()
704 };
705 let got = filter.apply(&snaps, now);
706 assert_eq!(got.len(), 2);
707 assert_eq!(got[0].id, "b");
708 assert_eq!(got[1].id, "c");
709 }
710
711 #[test]
712 fn parse_tag_kv_accepts_simple() {
713 assert_eq!(
714 parse_tag_kv("area:postgres").unwrap(),
715 ("area".into(), "postgres".into())
716 );
717 }
718
719 #[test]
720 fn parse_tag_kv_rejects_no_colon() {
721 assert!(parse_tag_kv("area-postgres").is_err());
722 }
723
724 #[test]
725 fn parse_tag_kv_rejects_empty_sides() {
726 assert!(parse_tag_kv(":value").is_err());
727 assert!(parse_tag_kv("key:").is_err());
728 }
729
730 #[test]
731 fn build_filter_all_drops_host() {
732 let filter =
733 build_filter(true, None, Some("ignored".into()), &[], None, None, None).unwrap();
734 assert!(filter.source_host.is_none());
735 }
736
737 #[test]
738 fn build_filter_default_host_used_when_not_overridden() {
739 let filter = build_filter(
740 false,
741 None,
742 Some("default-host".into()),
743 &[],
744 None,
745 None,
746 None,
747 )
748 .unwrap();
749 assert_eq!(filter.source_host.as_deref(), Some("default-host"));
750 }
751
752 #[test]
753 fn build_filter_explicit_host_beats_default() {
754 let filter = build_filter(
755 false,
756 Some("explicit".into()),
757 Some("default".into()),
758 &[],
759 None,
760 None,
761 None,
762 )
763 .unwrap();
764 assert_eq!(filter.source_host.as_deref(), Some("explicit"));
765 }
766
767 #[test]
768 fn build_filter_parses_since() {
769 let filter = build_filter(false, None, None, &[], None, Some("24h"), None).unwrap();
770 assert!(filter.since.is_some());
771 }
772
773 #[test]
774 fn build_filter_rejects_bad_since() {
775 let err =
776 build_filter(false, None, None, &[], None, Some("not-a-duration"), None).unwrap_err();
777 assert!(format!("{err}").contains("--since"));
778 }
779
780 #[test]
781 fn human_bytes_formats_units() {
782 assert_eq!(human_bytes(500), "500B");
783 assert_eq!(human_bytes(2 * 1024), "2.0KB");
784 assert_eq!(human_bytes(3 * 1024 * 1024 + 512 * 1024), "3.5MB");
785 assert_eq!(human_bytes(-1), "?");
786 }
787
788 #[test]
789 fn snapshot_taken_at_falls_back_to_start() {
790 let now = Timestamp::from_second(10_000_000).unwrap();
791 let mut snap = snapshot("a", "h", "/data", now);
792 snap.end_time = None;
793 assert_eq!(snap.taken_at(), Some(now));
794 }
795
796 #[test]
797 fn short_id_truncates_long_ids() {
798 assert_eq!(
799 short_id("kabcdef0123456789aaaaaaaaaaaaaaaa"),
800 "kabcdef012345678"
801 );
802 }
803
804 #[test]
805 fn short_id_passes_short_through() {
806 assert_eq!(short_id("k0000"), "k0000");
807 }
808
809 #[test]
810 fn format_tags_renders_sorted_kv_pairs() {
811 let mut tags = BTreeMap::new();
812 tags.insert("z".into(), "last".into());
813 tags.insert("a".into(), "first".into());
814 assert_eq!(format_tags(&tags), "a=first, z=last");
815 }
816
817 #[test]
818 fn format_tags_empty() {
819 let tags = BTreeMap::new();
820 assert_eq!(format_tags(&tags), "");
821 }
822
823 #[test]
824 fn format_snapshot_line_includes_id_source_and_tags() {
825 let now = Timestamp::from_second(10_000_000).unwrap();
826 let mut s = snapshot("kabc", "host-1", "/data", now);
827 s.tags.insert("area".into(), "postgres".into());
828 let line = format_snapshot_line(&s);
829 assert!(line.contains("kabc"));
830 assert!(line.contains("host-1"));
831 assert!(line.contains("/data"));
832 assert!(line.contains("area=postgres"));
833 }
834
835 #[test]
836 fn format_snapshot_line_omits_brackets_when_no_tags() {
837 let now = Timestamp::from_second(10_000_000).unwrap();
838 let s = snapshot("kabc", "host-1", "/data", now);
839 let line = format_snapshot_line(&s);
840 assert!(!line.contains("[]"));
841 }
842}