diff --git a/src/commands/backup.rs b/src/commands/backup.rs index 336c920bf..8044ddda5 100644 --- a/src/commands/backup.rs +++ b/src/commands/backup.rs @@ -2,6 +2,7 @@ use std::collections::HashMap; use std::fmt::Display; +use std::io::Write; use std::path::PathBuf; use std::{collections::BTreeMap, env}; @@ -34,10 +35,18 @@ use rustic_core::{ repofile::{SnapshotFile, SnapshotId}, }; +/// restic-compatible exit code: some source data could not be read +const EXIT_INVALID_SOURCE_DATA: i32 = 3; + +const UNREADABLE_SOURCE_WARNING: &str = "Warning: at least one source file could not be read"; + /// `backup` subcommand #[serde_as] #[derive(Clone, Command, Default, Debug, clap::Parser, Serialize, Deserialize, Merge)] #[serde(default, rename_all = "kebab-case", deny_unknown_fields)] +#[command( + after_help = "Exit status:\n 0 backup was successful\n 1 fatal error (no snapshot created)\n 3 some source files could not be read (incomplete snapshot created)" +)] // Note: using cli_sources, sources and snapshots within this struct is a hack to support serde(deny_unknown_fields) // for deserializing the backup options from TOML // Unfortunately we cannot work with nested flattened structures, see @@ -218,15 +227,28 @@ impl Runnable for BackupCmd { RUSTIC_APP.shutdown(Shutdown::Crash); } - if let Err(err) = config.repository.run(|repo| self.inner_run(repo)) { - status_err!("{}", err); - RUSTIC_APP.shutdown(Shutdown::Crash); - }; + match config.repository.run(|repo| self.inner_run(repo)) { + Ok(0) => {} + Ok(_) => { + let json_output = + config.global.progress_options.json_progress || self.json || config.backup.json; + if json_output { + write_json_exit_error(EXIT_INVALID_SOURCE_DATA, UNREADABLE_SOURCE_WARNING); + } else { + warn!("{UNREADABLE_SOURCE_WARNING}"); + } + RUSTIC_APP.shutdown_with_exitcode(Shutdown::Graceful, EXIT_INVALID_SOURCE_DATA); + } + Err(err) => { + status_err!("{}", err); + RUSTIC_APP.shutdown(Shutdown::Crash); + } + } } } impl BackupCmd { - fn inner_run(&self, repo: Repo) -> Result<()> { + fn inner_run(&self, repo: Repo) -> Result { let config = RUSTIC_APP.config(); let snapshots = self.get_snapshots_to_backup()?; @@ -259,16 +281,20 @@ impl BackupCmd { hooks.use_with(|| -> Result<_> { let mut is_err = false; + let mut source_errors = 0; for (opts, sources) in snapshots { - if let Err(err) = opts.backup_snapshot(sources.clone(), &repo) { - error!("error backing up {sources}: {err}"); - is_err = true; + match opts.backup_snapshot(sources.clone(), &repo) { + Ok(n) => source_errors += n, + Err(err) => { + error!("error backing up {sources}: {err}"); + is_err = true; + } } } if is_err { Err(anyhow!("Not all snapshots were generated successfully!")) } else { - Ok(()) + Ok(source_errors) } }) } @@ -413,7 +439,7 @@ impl BackupCmd { Ok(()) } - fn backup_snapshot(mut self, source: PathList, repo: &IndexedIdsRepo) -> Result<()> { + fn backup_snapshot(mut self, source: PathList, repo: &IndexedIdsRepo) -> Result { let config = RUSTIC_APP.config(); let snapshot_opts = &config.backup.snapshots; if let Some(path) = &self.as_path { @@ -515,7 +541,7 @@ impl BackupCmd { } info!("backup of {source} done."); - Ok(()) + Ok(snap.summary.as_ref().map_or(0, |s| s.error_count)) } } @@ -539,6 +565,24 @@ struct JsonProgressSummary { snapshot_id: Option, } +#[derive(Serialize)] +struct JsonExitError { + message_type: &'static str, + code: i32, + message: &'static str, +} + +fn write_json_exit_error(code: i32, message: &'static str) { + let err = JsonExitError { + message_type: "exit_error", + code, + message, + }; + let mut stderr = std::io::stderr().lock(); + _ = serde_json::to_writer(&mut stderr, &err); + _ = writeln!(stderr); +} + fn write_json_progress_summary(snap: &SnapshotFile) -> Result<()> { if let Some(summary) = snap.summary.as_ref() { let snapshot_id = (snap.id != SnapshotId::default()).then_some(snap.id); diff --git a/src/config/progress_options.rs b/src/config/progress_options.rs index 03b427fc9..572342b92 100644 --- a/src/config/progress_options.rs +++ b/src/config/progress_options.rs @@ -216,6 +216,7 @@ struct NonInteractiveState { position: u64, length: Option, last_log: Instant, + error_count: u64, } impl NonInteractiveState { @@ -259,6 +260,7 @@ impl NonInteractiveProgress { position: 0, length: None, last_log: now, + error_count: 0, })), start: now, interval, @@ -343,6 +345,20 @@ struct JsonProgressStatus { total_bytes: Option, #[serde(skip_serializing_if = "Option::is_none")] bytes_done: Option, + error_count: u64, +} + +#[derive(Serialize)] +struct JsonErrorMessage { + message: String, +} + +#[derive(Serialize)] +struct JsonError { + message_type: &'static str, + error: JsonErrorMessage, + during: String, + item: String, } impl JsonProgress { @@ -354,6 +370,7 @@ impl JsonProgress { position: 0, length: None, last_log: now, + error_count: 0, })), start: now, interval, @@ -382,6 +399,7 @@ impl JsonProgress { percent_done, total_bytes: is_bytes.then_some(state.length).flatten(), bytes_done: is_bytes.then_some(state.position), + error_count: state.error_count, }; let mut stdout = std::io::stdout().lock(); @@ -427,4 +445,23 @@ impl RusticProgress for JsonProgress { self.log_progress(&state); } + + fn error(&self, item: Option<&str>, during: &str, message: &str) { + if let Ok(mut state) = self.state.lock() { + state.error_count += 1; + } + + let error = JsonError { + message_type: "error", + error: JsonErrorMessage { + message: message.to_string(), + }, + during: during.to_string(), + item: item.unwrap_or_default().to_string(), + }; + + let mut stderr = std::io::stderr().lock(); + _ = serde_json::to_writer(&mut stderr, &error); + _ = writeln!(stderr); + } } diff --git a/tests/backup_restore.rs b/tests/backup_restore.rs index a8dafe3ca..a14aac834 100644 --- a/tests/backup_restore.rs +++ b/tests/backup_restore.rs @@ -174,6 +174,125 @@ fn test_backup_records_cli_version_in_snapshot() -> TestResult<()> { Ok(()) } +#[cfg(unix)] +fn unreadable_backup_source() -> TestResult> { + use std::fs::{self, File, Permissions}; + use std::os::unix::fs::PermissionsExt; + + let src = tempdir()?; + fs::write(src.path().join("ok.txt"), "ok")?; + let secret = src.path().join("secret.txt"); + fs::write(&secret, "secret")?; + fs::set_permissions(&secret, Permissions::from_mode(0o000))?; + + if File::open(&secret).is_ok() { + fs::set_permissions(&secret, Permissions::from_mode(0o644))?; + return Ok(None); + } + + Ok(Some((src, secret))) +} + +#[cfg(unix)] +#[test] +fn test_backup_unreadable_file_exits_3() -> TestResult<()> { + use std::fs::{self, Permissions}; + use std::os::unix::fs::PermissionsExt; + + let temp_dir = setup()?; + let Some((src, secret)) = unreadable_backup_source()? else { + return Ok(()); + }; + + let result = rustic_runner(&temp_dir)? + .arg("backup") + .arg(src.path()) + .output()?; + + // Restore permissions so TempDir cleanup succeeds + fs::set_permissions(&secret, Permissions::from_mode(0o644))?; + + assert_eq!( + result.status.code(), + Some(3), + "stderr: {}", + String::from_utf8_lossy(&result.stderr) + ); + let stderr = String::from_utf8_lossy(&result.stderr); + assert!( + stderr.contains("at least one source file could not be read"), + "stderr: {stderr}" + ); + + Ok(()) +} + +#[cfg(unix)] +#[test] +fn test_backup_unreadable_file_json_exit_error() -> TestResult<()> { + use std::fs::{self, Permissions}; + use std::os::unix::fs::PermissionsExt; + + let temp_dir = setup()?; + let Some((src, secret)) = unreadable_backup_source()? else { + return Ok(()); + }; + + let password = "test"; + let repo_dir = temp_dir.path().join("repo"); + let result = Command::new(env!("CARGO_BIN_EXE_rustic")) + .arg("-r") + .arg(&repo_dir) + .arg("--password") + .arg(password) + .arg("--json-progress") + .arg("backup") + .arg(src.path()) + .output()?; + + fs::set_permissions(&secret, Permissions::from_mode(0o644))?; + + assert_eq!( + result.status.code(), + Some(3), + "stderr: {}", + String::from_utf8_lossy(&result.stderr) + ); + + let stderr = String::from_utf8_lossy(&result.stderr); + let json_msgs: Vec = stderr + .lines() + .filter_map(|line| serde_json::from_str(line).ok()) + .collect(); + + assert!( + json_msgs.iter().any(|v| v["message_type"] == "error"), + "expected JSON error message in stderr: {stderr}" + ); + let exit_error = json_msgs + .iter() + .find(|v| v["message_type"] == "exit_error") + .expect("expected JSON exit_error in stderr"); + assert_eq!(exit_error["code"], 3); + assert!( + exit_error["message"] + .as_str() + .is_some_and(|m| m.contains("at least one source file could not be read")) + ); + + let stdout = String::from_utf8_lossy(&result.stdout); + let stdout_msgs: Vec = stdout + .lines() + .filter_map(|line| serde_json::from_str(line).ok()) + .collect(); + assert!( + stdout_msgs.iter().any(|v| v["message_type"] == "summary"), + "expected JSON summary on stdout: {stdout}" + ); + + Ok(()) +} + #[test] fn test_backup_and_restore_passes() -> TestResult<()> { let temp_dir = setup()?;