Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(commands): Add option stdin_command to be used in CLI and config file #266

Merged
merged 15 commits into from
Sep 21, 2024
23 changes: 22 additions & 1 deletion crates/core/src/backend.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
//! Module for backend related functionality.
pub(crate) mod cache;
pub(crate) mod childstdout;
pub(crate) mod decrypt;
pub(crate) mod dry_run;
pub(crate) mod hotcold;
Expand All @@ -22,7 +23,7 @@
use serde_derive::{Deserialize, Serialize};

use crate::{
backend::node::Node,
backend::node::{Metadata, Node, NodeType},
error::{BackendAccessErrorKind, RusticErrorKind},
id::Id,
RusticResult,
Expand Down Expand Up @@ -424,6 +425,18 @@
pub open: Option<O>,
}

impl<O> ReadSourceEntry<O> {
fn from_path(path: PathBuf, open: Option<O>) -> RusticResult<Self> {
let node = Node::new_node(
path.file_name()
.ok_or_else(|| BackendAccessErrorKind::PathNotAllowed(path.clone()))?,

Check warning on line 432 in crates/core/src/backend.rs

View check run for this annotation

Codecov / codecov/patch

crates/core/src/backend.rs#L432

Added line #L432 was not covered by tests
NodeType::File,
Metadata::default(),

Check warning on line 434 in crates/core/src/backend.rs

View check run for this annotation

Codecov / codecov/patch

crates/core/src/backend.rs#L434

Added line #L434 was not covered by tests
);
Ok(Self { path, node, open })
}
}

/// Trait for backends that can read and open sources.
/// This trait is implemented by all backends that can read data and open from a source.
pub trait ReadSourceOpen {
Expand All @@ -442,6 +455,14 @@
fn open(self) -> RusticResult<Self::Reader>;
}

/// blanket implementation for readers
impl<T: Read + Send + 'static> ReadSourceOpen for T {
type Reader = T;
fn open(self) -> RusticResult<Self::Reader> {
Ok(self)

Check warning on line 462 in crates/core/src/backend.rs

View check run for this annotation

Codecov / codecov/patch

crates/core/src/backend.rs#L461-L462

Added lines #L461 - L462 were not covered by tests
}
}

/// Trait for backends that can read from a source.
///
/// This trait is implemented by all backends that can read data from a source.
Expand Down
78 changes: 78 additions & 0 deletions crates/core/src/backend/childstdout.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
use std::{
iter::{once, Once},
path::PathBuf,
process::{Child, ChildStdout, Command, Stdio},
sync::Mutex,
};

use crate::{
backend::{ReadSource, ReadSourceEntry},
error::{RepositoryErrorKind, RusticResult},
CommandInput,
};

/// The `ChildStdoutSource` is a `ReadSource` when spawning a child process and reading its stdout
#[derive(Debug)]
pub struct ChildStdoutSource {
/// The path of the stdin entry.
path: PathBuf,
/// The child process
///
/// # Note
///
/// This is in a Mutex as we want to take out `ChildStdout`
/// in the `entries` method - but this method only gets a
/// reference of self.
process: Mutex<Child>,
/// the command which is called
command: CommandInput,
}

impl ChildStdoutSource {
/// Creates a new `ChildSource`.
pub fn new(cmd: &CommandInput, path: PathBuf) -> RusticResult<Self> {
let process = Command::new(cmd.command())
.args(cmd.args())
.stdout(Stdio::piped())
.spawn()
.map_err(|err| {

Check warning on line 38 in crates/core/src/backend/childstdout.rs

View check run for this annotation

Codecov / codecov/patch

crates/core/src/backend/childstdout.rs#L38

Added line #L38 was not covered by tests
RepositoryErrorKind::CommandExecutionFailed(
"stdin-command".into(),
"call".into(),
err,
)
.into()
});

let process = cmd.on_failure().display_result(process)?;

Ok(Self {
path,
process: Mutex::new(process),
command: cmd.clone(),
})
}

/// Finishes the `ChildSource`
pub fn finish(self) -> RusticResult<()> {
let status = self.process.lock().unwrap().wait();
self.command
.on_failure()
.handle_status(status, "stdin-command", "call")?;
Ok(())
}
}

impl ReadSource for ChildStdoutSource {
type Open = ChildStdout;
type Iter = Once<RusticResult<ReadSourceEntry<ChildStdout>>>;

fn size(&self) -> RusticResult<Option<u64>> {
Ok(None)

Check warning on line 71 in crates/core/src/backend/childstdout.rs

View check run for this annotation

Codecov / codecov/patch

crates/core/src/backend/childstdout.rs#L70-L71

Added lines #L70 - L71 were not covered by tests
}

fn entries(&self) -> Self::Iter {
let open = self.process.lock().unwrap().stdout.take();
once(ReadSourceEntry::from_path(self.path.clone(), open))
}
}
60 changes: 11 additions & 49 deletions crates/core/src/backend/stdin.rs
Original file line number Diff line number Diff line change
@@ -1,51 +1,33 @@
use std::{io::stdin, path::PathBuf};
use std::{
io::{stdin, Stdin},
iter::{once, Once},
path::PathBuf,
};

use crate::{
backend::{
node::{Metadata, Node, NodeType},
ReadSource, ReadSourceEntry, ReadSourceOpen,
},
backend::{ReadSource, ReadSourceEntry},
error::RusticResult,
};

/// The `StdinSource` is a `ReadSource` for stdin.
#[derive(Debug, Clone)]
pub struct StdinSource {
/// Whether we have already yielded the stdin entry.
finished: bool,
/// The path of the stdin entry.
path: PathBuf,
}

impl StdinSource {
/// Creates a new `StdinSource`.
pub const fn new(path: PathBuf) -> Self {
Self {
finished: false,
path,
}
}
}

/// The `OpenStdin` is a `ReadSourceOpen` for stdin.
#[derive(Debug, Copy, Clone)]
pub struct OpenStdin();

impl ReadSourceOpen for OpenStdin {
/// The reader type.
type Reader = std::io::Stdin;

/// Opens stdin.
fn open(self) -> RusticResult<Self::Reader> {
Ok(stdin())
Self { path }
}
}

impl ReadSource for StdinSource {
/// The open type.
type Open = OpenStdin;
type Open = Stdin;
/// The iterator type.
type Iter = Self;
type Iter = Once<RusticResult<ReadSourceEntry<Stdin>>>;

/// Returns the size of the source.
fn size(&self) -> RusticResult<Option<u64>> {
Expand All @@ -54,27 +36,7 @@

/// Returns an iterator over the source.
fn entries(&self) -> Self::Iter {
self.clone()
}
}

impl Iterator for StdinSource {
type Item = RusticResult<ReadSourceEntry<OpenStdin>>;

fn next(&mut self) -> Option<Self::Item> {
if self.finished {
return None;
}
self.finished = true;

Some(Ok(ReadSourceEntry {
path: self.path.clone(),
node: Node::new_node(
self.path.file_name().unwrap(),
NodeType::File,
Metadata::default(),
),
open: Some(OpenStdin()),
}))
let open = Some(stdin());
once(ReadSourceEntry::from_path(self.path.clone(), open))

Check warning on line 40 in crates/core/src/backend/stdin.rs

View check run for this annotation

Codecov / codecov/patch

crates/core/src/backend/stdin.rs#L39-L40

Added lines #L39 - L40 were not covered by tests
}
}
38 changes: 29 additions & 9 deletions crates/core/src/commands/backup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use crate::{
archiver::{parent::Parent, Archiver},
backend::{
childstdout::ChildStdoutSource,
dry_run::DryRunBackend,
ignore::{LocalSource, LocalSourceFilterOptions, LocalSourceSaveOptions},
stdin::StdinSource,
Expand All @@ -23,6 +24,7 @@
PathList, SnapshotFile,
},
repository::{IndexedIds, IndexedTree, Repository},
CommandInput,
};

#[cfg(feature = "clap")]
Expand Down Expand Up @@ -141,6 +143,10 @@
#[cfg_attr(feature = "merge", merge(skip))]
pub stdin_filename: String,

/// Call the given command and use its output as stdin
#[cfg_attr(feature = "clap", clap(long, value_name = "COMMAND"))]
pub stdin_command: Option<CommandInput>,

/// Manually set backup path in snapshot
#[cfg_attr(feature = "clap", clap(long, value_name = "PATH", value_hint = ValueHint::DirPath))]
pub as_path: Option<PathBuf>,
Expand Down Expand Up @@ -246,15 +252,29 @@

let snap = if backup_stdin {
let path = &backup_path[0];
let src = StdinSource::new(path.clone());
archiver.archive(
&src,
path,
as_path.as_ref(),
opts.parent_opts.skip_identical_parent,
opts.no_scan,
&p,
)?
if let Some(command) = &opts.stdin_command {
let src = ChildStdoutSource::new(command, path.clone())?;
let res = archiver.archive(
&src,
path,
as_path.as_ref(),

Check warning on line 260 in crates/core/src/commands/backup.rs

View check run for this annotation

Codecov / codecov/patch

crates/core/src/commands/backup.rs#L258-L260

Added lines #L258 - L260 were not covered by tests
opts.parent_opts.skip_identical_parent,
opts.no_scan,
&p,

Check warning on line 263 in crates/core/src/commands/backup.rs

View check run for this annotation

Codecov / codecov/patch

crates/core/src/commands/backup.rs#L263

Added line #L263 was not covered by tests
)?;
src.finish()?;
res
} else {
let src = StdinSource::new(path.clone());
archiver.archive(
&src,
path,
as_path.as_ref(),
opts.parent_opts.skip_identical_parent,
opts.no_scan,
&p,

Check warning on line 275 in crates/core/src/commands/backup.rs

View check run for this annotation

Codecov / codecov/patch

crates/core/src/commands/backup.rs#L268-L275

Added lines #L268 - L275 were not covered by tests
)?
}
} else {
let src = LocalSource::new(
opts.ignore_save_opts,
Expand Down
2 changes: 2 additions & 0 deletions crates/core/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,8 @@ pub enum BackendAccessErrorKind {
RemovingDataFromBackendFailed,
/// failed to list files on Backend
ListingFilesOnBackendFailed,
/// Path is not allowed: `{0:?}`
PathNotAllowed(PathBuf),
}

/// [`ConfigFileErrorKind`] describes the errors that can be returned for `ConfigFile`s
Expand Down
26 changes: 19 additions & 7 deletions crates/core/src/repository/command_input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -165,20 +165,32 @@

impl OnFailure {
fn eval<T>(self, res: RusticResult<T>) -> RusticResult<Option<T>> {
match res {
Err(err) => match self {
let res = self.display_result(res);
match (res, self) {
(Err(err), Self::Error) => Err(err),
(Err(_), _) => Ok(None),

Check warning on line 171 in crates/core/src/repository/command_input.rs

View check run for this annotation

Codecov / codecov/patch

crates/core/src/repository/command_input.rs#L170-L171

Added lines #L170 - L171 were not covered by tests
(Ok(res), _) => Ok(Some(res)),
}
}

/// Displays a result depending on the defined error handling which still yielding the same result
/// # Note
///
/// This can be used where an error might occur, but in that
/// case we have to abort.
pub fn display_result<T>(self, res: RusticResult<T>) -> RusticResult<T> {
if let Err(err) = &res {
match self {

Check warning on line 183 in crates/core/src/repository/command_input.rs

View check run for this annotation

Codecov / codecov/patch

crates/core/src/repository/command_input.rs#L183

Added line #L183 was not covered by tests
Self::Error => {
error!("{err}");
Err(err)
}
Self::Warn => {
warn!("{err}");
Ok(None)
}
Self::Ignore => Ok(None),
},
Ok(res) => Ok(Some(res)),
Self::Ignore => {}

Check warning on line 190 in crates/core/src/repository/command_input.rs

View check run for this annotation

Codecov / codecov/patch

crates/core/src/repository/command_input.rs#L190

Added line #L190 was not covered by tests
}
}
res
}

/// Handle a status of a called command depending on the defined error handling
Expand Down
30 changes: 30 additions & 0 deletions crates/core/tests/integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,36 @@ fn test_backup_dry_run_with_tar_gz_passes(
Ok(())
}

#[rstest]
fn test_backup_stdin_command(
set_up_repo: Result<RepoOpen>,
insta_snapshotfile_redaction: Settings,
) -> Result<()> {
// Fixtures
let repo = set_up_repo?.to_indexed_ids()?;
let paths = PathList::from_string("-")?;

let cmd: CommandInput = "echo test".parse()?;
let opts = BackupOptions::default()
.stdin_filename("test")
.stdin_command(cmd);
// backup data from cmd
let snapshot = repo.backup(&opts, &paths, SnapshotFile::default())?;
insta_snapshotfile_redaction.bind(|| {
assert_with_win("stdin-command-summary", &snapshot);
});

// re-read index
let repo = repo.to_indexed()?;

// check content
let node = repo.node_from_snapshot_path("latest:test", |_| true)?;
let mut content = Vec::new();
repo.dump(&node, &mut content)?;
assert_eq!(content, b"test\n");
Ok(())
}

#[rstest]
fn test_ls(
tar_gz_testdata: Result<TestSource>,
Expand Down
Loading