Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 55 additions & 11 deletions src/commands/backup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<u64> {
let config = RUSTIC_APP.config();
let snapshots = self.get_snapshots_to_backup()?;

Expand Down Expand Up @@ -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)
}
})
}
Expand Down Expand Up @@ -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<u64> {
let config = RUSTIC_APP.config();
let snapshot_opts = &config.backup.snapshots;
if let Some(path) = &self.as_path {
Expand Down Expand Up @@ -515,7 +541,7 @@ impl BackupCmd {
}

info!("backup of {source} done.");
Ok(())
Ok(snap.summary.as_ref().map_or(0, |s| s.error_count))
}
}

Expand All @@ -539,6 +565,24 @@ struct JsonProgressSummary {
snapshot_id: Option<SnapshotId>,
}

#[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);
Expand Down
37 changes: 37 additions & 0 deletions src/config/progress_options.rs
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ struct NonInteractiveState {
position: u64,
length: Option<u64>,
last_log: Instant,
error_count: u64,
}

impl NonInteractiveState {
Expand Down Expand Up @@ -259,6 +260,7 @@ impl NonInteractiveProgress {
position: 0,
length: None,
last_log: now,
error_count: 0,
})),
start: now,
interval,
Expand Down Expand Up @@ -343,6 +345,20 @@ struct JsonProgressStatus {
total_bytes: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
bytes_done: Option<u64>,
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 {
Expand All @@ -354,6 +370,7 @@ impl JsonProgress {
position: 0,
length: None,
last_log: now,
error_count: 0,
})),
start: now,
interval,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -427,4 +445,23 @@ impl RusticProgress for JsonProgress {

self.log_progress(&state);
}

fn error(&self, item: &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.to_string(),
};

let mut stderr = std::io::stderr().lock();
_ = serde_json::to_writer(&mut stderr, &error);
_ = writeln!(stderr);
}
}
119 changes: 119 additions & 0 deletions tests/backup_restore.rs
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,125 @@ fn test_backup_records_cli_version_in_snapshot() -> TestResult<()> {
Ok(())
}

#[cfg(unix)]
fn unreadable_backup_source() -> TestResult<Option<(TempDir, std::path::PathBuf)>> {
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<serde_json::Value> = 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<serde_json::Value> = 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()?;
Expand Down