2692 lines
95 KiB
Rust
2692 lines
95 KiB
Rust
#![forbid(unsafe_code)]
|
|
#![deny(clippy::disallowed_types)]
|
|
|
|
use std::{
|
|
error::Error,
|
|
ffi::OsString,
|
|
fmt,
|
|
io::{BufRead as _, IsTerminal as _, Read as _, Write},
|
|
path::{Path, PathBuf},
|
|
process::ExitCode,
|
|
sync::{
|
|
Arc,
|
|
atomic::{AtomicBool, Ordering},
|
|
},
|
|
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
|
|
};
|
|
|
|
use ironstorage::{
|
|
command::{
|
|
CliAction, CommandRequest, EXIT_CONFIG, EXIT_FAILURE, EXIT_SUCCESS, EXIT_UNAVAILABLE,
|
|
GeneratedPresentation, GitConfigRequest, GitRemoteRequest, GitRequest, HelpTopic,
|
|
InputPlan, OtpInputSource, OtpRequest, OtpUriPresentation, completion_script, help_text,
|
|
otp_version_text, parse_from, version_text,
|
|
},
|
|
config::Config,
|
|
crypto::KeyStore,
|
|
generate::{GeneratorConfig, PasswordGenerator},
|
|
git::{GitChangeKind, GitIdentity, GitRepository, PullOutcome},
|
|
mutation::{
|
|
MutationError, NoGitTreeCommitter, TreeCommit, TreeCommitError, TreeCommitter, TreeMutator,
|
|
},
|
|
otp::{OtpError, OtpInput, OtpService},
|
|
presentation::{
|
|
ClipboardError, ClipboardTimeout, ClipboardWait, NativeClipboardManager, QrError, QrMatrix,
|
|
},
|
|
read::{PresentationChannel, ShowOutput, ShowResult, VaultReader},
|
|
recipient::{
|
|
NoGitCommitter, PolicyCommit, PolicyCommitError, PolicyCommitter, RecipientPolicyManager,
|
|
},
|
|
repository::{DirectoryPath, Repository, SecretBytes},
|
|
secret_store::{
|
|
NativeSecretStore, OpenPgpPassphrasePrompt, OpenPgpPassphrasePromptError,
|
|
SecretCachePolicy, SecretProtectionPolicy, SecretStore, SecretStoreBackend,
|
|
},
|
|
write::{
|
|
EntryCommit, EntryCommitError, EntryCommitter, InsertContent, NoGitEntryCommitter,
|
|
OverwriteDecision, VaultWriter,
|
|
},
|
|
};
|
|
|
|
#[allow(dead_code)]
|
|
mod editor;
|
|
|
|
fn main() -> ExitCode {
|
|
match run() {
|
|
Ok(code) => ExitCode::from(code),
|
|
Err(()) => ExitCode::from(ironstorage::command::EXIT_FAILURE),
|
|
}
|
|
}
|
|
|
|
fn run() -> Result<u8, ()> {
|
|
let stdout = std::io::stdout();
|
|
let stderr = std::io::stderr();
|
|
run_with(std::env::args_os(), stdout.lock(), stderr.lock())
|
|
}
|
|
|
|
fn run_with<I, T, O, E>(arguments: I, mut stdout: O, mut stderr: E) -> Result<u8, ()>
|
|
where
|
|
I: IntoIterator<Item = T>,
|
|
T: Into<OsString> + Clone,
|
|
O: Write,
|
|
E: Write,
|
|
{
|
|
let action = match parse_from(arguments) {
|
|
Ok(action) => action,
|
|
Err(error) => {
|
|
stderr
|
|
.write_all(error.to_string().as_bytes())
|
|
.map_err(|_| ())?;
|
|
return Ok(error.exit_code());
|
|
}
|
|
};
|
|
match action {
|
|
CliAction::Display(text) => {
|
|
stdout.write_all(text.as_bytes()).map_err(|_| ())?;
|
|
Ok(EXIT_SUCCESS)
|
|
}
|
|
CliAction::Run(invocation) => match invocation.request() {
|
|
CommandRequest::Help { topic } => {
|
|
stdout
|
|
.write_all(help_text(*topic).as_bytes())
|
|
.map_err(|_| ())?;
|
|
Ok(EXIT_SUCCESS)
|
|
}
|
|
CommandRequest::Version => {
|
|
stdout
|
|
.write_all(version_text().as_bytes())
|
|
.map_err(|_| ())?;
|
|
Ok(EXIT_SUCCESS)
|
|
}
|
|
CommandRequest::Otp(OtpRequest::Help) => {
|
|
stdout
|
|
.write_all(help_text(Some(HelpTopic::Otp)).as_bytes())
|
|
.map_err(|_| ())?;
|
|
Ok(EXIT_SUCCESS)
|
|
}
|
|
CommandRequest::Otp(OtpRequest::Version) => {
|
|
stdout
|
|
.write_all(otp_version_text().as_bytes())
|
|
.map_err(|_| ())?;
|
|
Ok(EXIT_SUCCESS)
|
|
}
|
|
CommandRequest::Otp(OtpRequest::Validate { uri }) => match OtpService::validate(uri) {
|
|
Ok(()) => Ok(EXIT_SUCCESS),
|
|
Err(error) => operation_error(&mut stderr, error),
|
|
},
|
|
CommandRequest::Completion { shell } => {
|
|
stdout
|
|
.write_all(&completion_script(*shell))
|
|
.map_err(|_| ())?;
|
|
Ok(EXIT_SUCCESS)
|
|
}
|
|
request => match Config::load(invocation.config()) {
|
|
Ok(config) if needs_secret_store(request) => {
|
|
let mut secrets = match NativeSecretStore::system(
|
|
SecretCachePolicy::Disabled,
|
|
SecretProtectionPolicy::device_unlocked(),
|
|
)
|
|
.and_then(|store| {
|
|
store.unlock()?;
|
|
Ok(store.with_openpgp_passphrase_prompt(NativeOpenPgpPassphrasePrompt))
|
|
}) {
|
|
Ok(secrets) => secrets,
|
|
Err(error) => {
|
|
writeln!(stderr, "{error}").map_err(|_| ())?;
|
|
return Ok(EXIT_UNAVAILABLE);
|
|
}
|
|
};
|
|
execute_secure(&config, request, &mut secrets, &mut stdout, &mut stderr)
|
|
}
|
|
Ok(config) => execute_local(&config, request, &mut stdout, &mut stderr),
|
|
Err(error) => {
|
|
writeln!(stderr, "{error}").map_err(|_| ())?;
|
|
Ok(EXIT_CONFIG)
|
|
}
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
fn needs_secret_store(request: &CommandRequest) -> bool {
|
|
matches!(
|
|
request,
|
|
CommandRequest::Show(_)
|
|
| CommandRequest::Init(_)
|
|
| CommandRequest::Grep(_)
|
|
| CommandRequest::Insert(_)
|
|
| CommandRequest::Edit(_)
|
|
| CommandRequest::Generate(_)
|
|
| CommandRequest::Remove(_)
|
|
| CommandRequest::Move(_)
|
|
| CommandRequest::Copy(_)
|
|
| CommandRequest::Otp(
|
|
OtpRequest::Code(_)
|
|
| OtpRequest::Insert(_)
|
|
| OtpRequest::Append(_)
|
|
| OtpRequest::Uri(_),
|
|
)
|
|
| CommandRequest::Git(
|
|
GitRequest::Diff { .. }
|
|
| GitRequest::Fetch { .. }
|
|
| GitRequest::Pull { .. }
|
|
| GitRequest::Push { .. }
|
|
| GitRequest::Sync { .. },
|
|
)
|
|
)
|
|
}
|
|
|
|
fn execute_local<O: Write, E: Write>(
|
|
config: &Config,
|
|
request: &CommandRequest,
|
|
stdout: &mut O,
|
|
stderr: &mut E,
|
|
) -> Result<u8, ()> {
|
|
let repository = match Repository::open(config.vault()) {
|
|
Ok(repository) => repository,
|
|
Err(error) => return operation_error(stderr, error),
|
|
};
|
|
match request {
|
|
CommandRequest::List(request) => {
|
|
let directory = match DirectoryPath::parse(request.path.as_deref().unwrap_or_default())
|
|
{
|
|
Ok(directory) => directory,
|
|
Err(error) => return operation_error(stderr, error),
|
|
};
|
|
let keys = match KeyStore::load(config.key_material()) {
|
|
Ok(keys) => keys,
|
|
Err(error) => return operation_error(stderr, error),
|
|
};
|
|
match VaultReader::new(&repository, &keys).list(&directory) {
|
|
Ok(tree) => {
|
|
stdout
|
|
.write_all(tree.render_plain().as_bytes())
|
|
.map_err(|_| ())?;
|
|
Ok(EXIT_SUCCESS)
|
|
}
|
|
Err(error) => operation_error(stderr, error),
|
|
}
|
|
}
|
|
CommandRequest::Find(request) => {
|
|
let keys = match KeyStore::load(config.key_material()) {
|
|
Ok(keys) => keys,
|
|
Err(error) => return operation_error(stderr, error),
|
|
};
|
|
match VaultReader::new(&repository, &keys).find(&request.terms) {
|
|
Ok(results) => {
|
|
stdout
|
|
.write_all(results.render_plain().as_bytes())
|
|
.map_err(|_| ())?;
|
|
Ok(EXIT_SUCCESS)
|
|
}
|
|
Err(error) => operation_error(stderr, error),
|
|
}
|
|
}
|
|
CommandRequest::Git(request) => execute_local_git(&repository, request, stdout, stderr),
|
|
_ => operation_error(stderr, "the command requires authenticated secret access"),
|
|
}
|
|
}
|
|
|
|
fn execute_local_git<O: Write, E: Write>(
|
|
repository: &Repository,
|
|
request: &GitRequest,
|
|
stdout: &mut O,
|
|
stderr: &mut E,
|
|
) -> Result<u8, ()> {
|
|
if matches!(request, GitRequest::Init) {
|
|
return match GitRepository::init(repository, git_identity()) {
|
|
Ok(_) => Ok(EXIT_SUCCESS),
|
|
Err(error) => operation_error(stderr, error),
|
|
};
|
|
}
|
|
let mut git = match GitRepository::open(repository, git_identity()) {
|
|
Ok(git) => git,
|
|
Err(error) => return operation_error(stderr, error),
|
|
};
|
|
let result = match request {
|
|
GitRequest::Status => git.status().and_then(|status| {
|
|
if status.is_clean() {
|
|
writeln!(stdout, "clean").map_err(|_| output_git_error())?;
|
|
} else {
|
|
for change in status.staged() {
|
|
writeln!(
|
|
stdout,
|
|
"staged\t{}\t{}",
|
|
git_change_name(change.kind()),
|
|
change.path().display()
|
|
)
|
|
.map_err(|_| output_git_error())?;
|
|
}
|
|
for change in status.unstaged() {
|
|
writeln!(
|
|
stdout,
|
|
"unstaged\t{}\t{}",
|
|
git_change_name(change.kind()),
|
|
change.path().display()
|
|
)
|
|
.map_err(|_| output_git_error())?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}),
|
|
GitRequest::Log { maximum } => git.log(maximum.map(usize::from)).and_then(|entries| {
|
|
for entry in entries {
|
|
writeln!(stdout, "commit {}", entry.id()).map_err(|_| output_git_error())?;
|
|
writeln!(
|
|
stdout,
|
|
"Author: {} <{}>",
|
|
entry.author_name(),
|
|
entry.author_email()
|
|
)
|
|
.map_err(|_| output_git_error())?;
|
|
writeln!(stdout, "Date: {}", entry.timestamp()).map_err(|_| output_git_error())?;
|
|
writeln!(stdout, "\n {}\n", entry.message().trim_end())
|
|
.map_err(|_| output_git_error())?;
|
|
}
|
|
Ok(())
|
|
}),
|
|
GitRequest::Diff { paths } => git
|
|
.diff(&paths.iter().map(PathBuf::from).collect::<Vec<_>>())
|
|
.and_then(|entries| {
|
|
for entry in entries {
|
|
let kind = match (entry.old(), entry.current()) {
|
|
(None, Some(_)) => "added",
|
|
(Some(_), None) => "deleted",
|
|
_ => "modified",
|
|
};
|
|
writeln!(stdout, "{kind}\t{}", entry.path().display())
|
|
.map_err(|_| output_git_error())?;
|
|
}
|
|
Ok(())
|
|
}),
|
|
GitRequest::Add { paths } => {
|
|
git.stage(&paths.iter().map(PathBuf::from).collect::<Vec<_>>())
|
|
}
|
|
GitRequest::Commit { message } => git.commit(message).and_then(|id| {
|
|
writeln!(stdout, "{id}").map_err(|_| output_git_error())?;
|
|
Ok(())
|
|
}),
|
|
GitRequest::Remote(request) => match request {
|
|
GitRemoteRequest::List => {
|
|
for remote in git.remotes() {
|
|
writeln!(stdout, "{remote}").map_err(|_| ())?;
|
|
}
|
|
return Ok(EXIT_SUCCESS);
|
|
}
|
|
GitRemoteRequest::GetUrl { name } => git.remote_url(name).and_then(|url| {
|
|
writeln!(stdout, "{url}").map_err(|_| output_git_error())?;
|
|
Ok(())
|
|
}),
|
|
GitRemoteRequest::Add { name, url } => git.add_remote(name, url),
|
|
GitRemoteRequest::SetUrl { name, url } => git.set_remote_url(name, url),
|
|
GitRemoteRequest::Remove { name } => git.remove_remote(name),
|
|
},
|
|
GitRequest::Config(request) => match request {
|
|
GitConfigRequest::Get { key } => git.config_get(key).and_then(|value| {
|
|
if let Some(value) = value {
|
|
writeln!(stdout, "{value}").map_err(|_| output_git_error())?;
|
|
}
|
|
Ok(())
|
|
}),
|
|
GitConfigRequest::Set { key, value } => git.config_set(key, value),
|
|
},
|
|
GitRequest::Init
|
|
| GitRequest::Fetch { .. }
|
|
| GitRequest::Pull { .. }
|
|
| GitRequest::Push { .. }
|
|
| GitRequest::Sync { .. } => unreachable!("routed before local Git execution"),
|
|
};
|
|
match result {
|
|
Ok(()) => Ok(EXIT_SUCCESS),
|
|
Err(error) => operation_error(stderr, error),
|
|
}
|
|
}
|
|
|
|
fn git_change_name(kind: GitChangeKind) -> &'static str {
|
|
match kind {
|
|
GitChangeKind::Added => "added",
|
|
GitChangeKind::Modified => "modified",
|
|
GitChangeKind::Deleted => "deleted",
|
|
}
|
|
}
|
|
|
|
fn output_git_error() -> ironstorage::git::GitError {
|
|
ironstorage::git::GitError::Io {
|
|
operation: "write command output",
|
|
path: PathBuf::from("stdout"),
|
|
}
|
|
}
|
|
|
|
fn git_identity() -> GitIdentity {
|
|
GitIdentity::new("IronStorage", "ironstorage@localhost")
|
|
.expect("the built-in Git identity is valid")
|
|
}
|
|
|
|
fn execute_secure<B: SecretStoreBackend, O: Write, E: Write>(
|
|
config: &Config,
|
|
request: &CommandRequest,
|
|
secrets: &mut SecretStore<B>,
|
|
stdout: &mut O,
|
|
stderr: &mut E,
|
|
) -> Result<u8, ()> {
|
|
execute_secure_with_services(
|
|
config,
|
|
request,
|
|
secrets,
|
|
&mut NativePresentation,
|
|
&mut NativeOtpInteraction,
|
|
current_unix_seconds,
|
|
stdout,
|
|
stderr,
|
|
)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn execute_secure_with<B: SecretStoreBackend, P: CliPresentation, O: Write, E: Write>(
|
|
config: &Config,
|
|
request: &CommandRequest,
|
|
secrets: &mut SecretStore<B>,
|
|
presentation: &mut P,
|
|
stdout: &mut O,
|
|
stderr: &mut E,
|
|
) -> Result<u8, ()> {
|
|
execute_secure_with_services(
|
|
config,
|
|
request,
|
|
secrets,
|
|
presentation,
|
|
&mut UnavailableOtpInteraction,
|
|
current_unix_seconds,
|
|
stdout,
|
|
stderr,
|
|
)
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn execute_secure_with_services<
|
|
B: SecretStoreBackend,
|
|
P: CliPresentation,
|
|
I: CliInteraction,
|
|
O: Write,
|
|
E: Write,
|
|
>(
|
|
config: &Config,
|
|
request: &CommandRequest,
|
|
secrets: &mut SecretStore<B>,
|
|
presentation: &mut P,
|
|
interaction: &mut I,
|
|
clock: impl Fn() -> Result<u64, OtpInteractionError>,
|
|
stdout: &mut O,
|
|
stderr: &mut E,
|
|
) -> Result<u8, ()> {
|
|
let repository = match Repository::open(config.vault()) {
|
|
Ok(repository) => repository,
|
|
Err(error) => return operation_error(stderr, error),
|
|
};
|
|
let keys = match KeyStore::load(config.key_material()) {
|
|
Ok(keys) => keys,
|
|
Err(error) => return operation_error(stderr, error),
|
|
};
|
|
match request {
|
|
CommandRequest::Init(request) => {
|
|
let path = request.path.as_deref().unwrap_or_default();
|
|
let mut committer = match automatic_committer(&repository, path) {
|
|
Ok(committer) => committer,
|
|
Err(error) => return operation_error(stderr, error),
|
|
};
|
|
match RecipientPolicyManager::new(&repository, &keys).apply_init(
|
|
request,
|
|
None,
|
|
secrets,
|
|
&mut committer,
|
|
) {
|
|
Ok(_) => Ok(EXIT_SUCCESS),
|
|
Err(error) => operation_error(stderr, error),
|
|
}
|
|
}
|
|
CommandRequest::Show(request) => {
|
|
match VaultReader::new(&repository, &keys).execute_show(request, secrets) {
|
|
Ok(ShowOutput::Display(ShowResult::Entry(secret))) => {
|
|
stdout.write_all(secret.expose()).map_err(|_| ())?;
|
|
Ok(EXIT_SUCCESS)
|
|
}
|
|
Ok(ShowOutput::Display(ShowResult::Directory(tree))) => {
|
|
stdout
|
|
.write_all(tree.render_plain().as_bytes())
|
|
.map_err(|_| ())?;
|
|
Ok(EXIT_SUCCESS)
|
|
}
|
|
Ok(ShowOutput::Present(secret)) => {
|
|
let description = format!("{} line {}", secret.entry(), secret.line());
|
|
let result = match secret.channel() {
|
|
PresentationChannel::Clipboard => presentation.clipboard(
|
|
secret.contents(),
|
|
config.clipboard_timeout(),
|
|
&description,
|
|
stdout,
|
|
),
|
|
PresentationChannel::QrCode => {
|
|
presentation.qr_code(secret.contents(), stdout)
|
|
}
|
|
};
|
|
match result {
|
|
Ok(()) => Ok(EXIT_SUCCESS),
|
|
Err(error) => operation_error(stderr, error),
|
|
}
|
|
}
|
|
Err(error) => operation_error(stderr, error),
|
|
}
|
|
}
|
|
CommandRequest::Grep(request) => {
|
|
match VaultReader::new(&repository, &keys).grep(request, secrets) {
|
|
Ok(results) => {
|
|
stdout
|
|
.write_all(results.render_plain().expose())
|
|
.map_err(|_| ())?;
|
|
Ok(if results.is_empty() {
|
|
EXIT_FAILURE
|
|
} else {
|
|
EXIT_SUCCESS
|
|
})
|
|
}
|
|
Err(error) => operation_error(stderr, error),
|
|
}
|
|
}
|
|
CommandRequest::Insert(request) => {
|
|
let writer = VaultWriter::new(&repository, &keys);
|
|
let exists = match writer.entry_exists(&request.entry) {
|
|
Ok(exists) => exists,
|
|
Err(error) => return operation_error(stderr, error),
|
|
};
|
|
let overwrite = if exists && !request.force {
|
|
match interaction.confirm(
|
|
&format!(
|
|
"An entry already exists for {}. Overwrite it?",
|
|
request.entry
|
|
),
|
|
stderr,
|
|
) {
|
|
Ok(decision) => decision,
|
|
Err(error) => return operation_error(stderr, error),
|
|
}
|
|
} else {
|
|
OverwriteDecision::Allow
|
|
};
|
|
if overwrite == OverwriteDecision::Decline {
|
|
return operation_error(stderr, "entry overwrite was declined");
|
|
}
|
|
let contents = match interaction.read_insert(
|
|
request.input_plan(interaction.standard_input_is_terminal()),
|
|
&request.entry,
|
|
stderr,
|
|
) {
|
|
Ok(contents) => contents,
|
|
Err(error) => return operation_error(stderr, error),
|
|
};
|
|
let mut committer = match automatic_committer(&repository, &request.entry) {
|
|
Ok(committer) => committer,
|
|
Err(error) => return operation_error(stderr, error),
|
|
};
|
|
match writer.insert(request, contents, overwrite, None, &mut committer) {
|
|
Ok(_) => Ok(EXIT_SUCCESS),
|
|
Err(error) => operation_error(stderr, error),
|
|
}
|
|
}
|
|
CommandRequest::Edit(request) => {
|
|
let writer = VaultWriter::new(&repository, &keys);
|
|
let session = match writer.begin_edit(request, secrets) {
|
|
Ok(session) => session,
|
|
Err(error) => return operation_error(stderr, error),
|
|
};
|
|
let (replacement, editor_name) = match interaction.edit(session.plaintext(), config) {
|
|
Ok(replacement) => replacement,
|
|
Err(error) => return operation_error(stderr, error),
|
|
};
|
|
let mut committer = match automatic_committer(&repository, &request.entry) {
|
|
Ok(committer) => committer,
|
|
Err(error) => return operation_error(stderr, error),
|
|
};
|
|
match writer.finish_edit(session, replacement, &editor_name, None, &mut committer) {
|
|
Ok(_) => Ok(EXIT_SUCCESS),
|
|
Err(error) => operation_error(stderr, error),
|
|
}
|
|
}
|
|
CommandRequest::Generate(request) => {
|
|
let overwrite = if !request.force
|
|
&& !request.in_place
|
|
&& match VaultWriter::new(&repository, &keys).entry_exists(&request.entry) {
|
|
Ok(exists) => exists,
|
|
Err(error) => return operation_error(stderr, error),
|
|
} {
|
|
match interaction.confirm(
|
|
&format!(
|
|
"An entry already exists for {}. Overwrite it?",
|
|
request.entry
|
|
),
|
|
stderr,
|
|
) {
|
|
Ok(decision) => decision,
|
|
Err(error) => return operation_error(stderr, error),
|
|
}
|
|
} else {
|
|
OverwriteDecision::Allow
|
|
};
|
|
let mut committer = match automatic_committer(&repository, &request.entry) {
|
|
Ok(committer) => committer,
|
|
Err(error) => return operation_error(stderr, error),
|
|
};
|
|
let outcome =
|
|
match PasswordGenerator::new(&repository, &keys, GeneratorConfig::pass_defaults())
|
|
.generate(request, overwrite, None, secrets, &mut committer)
|
|
{
|
|
Ok(outcome) => outcome,
|
|
Err(error) => return operation_error(stderr, error),
|
|
};
|
|
let result = match request.presentation {
|
|
GeneratedPresentation::Terminal => stdout
|
|
.write_all(outcome.password().expose())
|
|
.and_then(|()| stdout.write_all(b"\n"))
|
|
.map_err(|_| PresentationFailure::Output),
|
|
GeneratedPresentation::Clipboard => presentation.clipboard(
|
|
outcome.password(),
|
|
config.clipboard_timeout(),
|
|
&format!("generated password for {}", request.entry),
|
|
stdout,
|
|
),
|
|
GeneratedPresentation::QrCode => presentation.qr_code(outcome.password(), stdout),
|
|
};
|
|
match result {
|
|
Ok(()) => Ok(EXIT_SUCCESS),
|
|
Err(error) => operation_error(stderr, error),
|
|
}
|
|
}
|
|
CommandRequest::Remove(request) => {
|
|
let decision = if request.force {
|
|
OverwriteDecision::Allow
|
|
} else {
|
|
match interaction.confirm(&format!("Remove {}?", request.entry), stderr) {
|
|
Ok(decision) => decision,
|
|
Err(error) => return operation_error(stderr, error),
|
|
}
|
|
};
|
|
let mut committer = match automatic_committer(&repository, &request.entry) {
|
|
Ok(committer) => committer,
|
|
Err(error) => return operation_error(stderr, error),
|
|
};
|
|
match TreeMutator::new(&repository, &keys).remove(request, decision, &mut committer) {
|
|
Ok(_) => Ok(EXIT_SUCCESS),
|
|
Err(error) => operation_error(stderr, error),
|
|
}
|
|
}
|
|
CommandRequest::Move(request) => execute_transfer(
|
|
&repository,
|
|
&keys,
|
|
TransferRequest::Move(request),
|
|
secrets,
|
|
interaction,
|
|
stderr,
|
|
),
|
|
CommandRequest::Copy(request) => execute_transfer(
|
|
&repository,
|
|
&keys,
|
|
TransferRequest::Copy(request),
|
|
secrets,
|
|
interaction,
|
|
stderr,
|
|
),
|
|
CommandRequest::Otp(request) => execute_otp(
|
|
config,
|
|
request,
|
|
secrets,
|
|
presentation,
|
|
interaction,
|
|
clock,
|
|
stdout,
|
|
stderr,
|
|
),
|
|
CommandRequest::Git(request) => {
|
|
if let GitRequest::Diff { paths } = request {
|
|
let git = match GitRepository::open(&repository, git_identity()) {
|
|
Ok(git) => git,
|
|
Err(error) => return operation_error(stderr, error),
|
|
};
|
|
return match git.render_diff(
|
|
&paths.iter().map(PathBuf::from).collect::<Vec<_>>(),
|
|
&keys,
|
|
secrets,
|
|
) {
|
|
Ok(diff) => {
|
|
stdout.write_all(diff.expose()).map_err(|_| ())?;
|
|
Ok(EXIT_SUCCESS)
|
|
}
|
|
Err(error) => operation_error(stderr, error),
|
|
};
|
|
}
|
|
let requested_remote = match request {
|
|
GitRequest::Fetch { remote }
|
|
| GitRequest::Pull { remote, .. }
|
|
| GitRequest::Push { remote, .. }
|
|
| GitRequest::Sync { remote } => remote.as_deref(),
|
|
_ => unreachable!("local Git requests do not require secret access"),
|
|
};
|
|
let configured = match select_remote(config, requested_remote) {
|
|
Some(configured) => configured,
|
|
None => {
|
|
stderr
|
|
.write_all(b"the requested HTTPS Git remote is not configured\n")
|
|
.map_err(|_| ())?;
|
|
return Ok(EXIT_CONFIG);
|
|
}
|
|
};
|
|
let git = match GitRepository::open(&repository, git_identity()) {
|
|
Ok(git) => git,
|
|
Err(error) => return operation_error(stderr, error),
|
|
};
|
|
let result = match request {
|
|
GitRequest::Fetch { .. } => git.fetch(configured, secrets).map(|_| None),
|
|
GitRequest::Pull { branch, .. } => git
|
|
.pull(configured, branch.as_deref(), secrets)
|
|
.map(|outcome| Some(pull_outcome_name(outcome).to_owned())),
|
|
GitRequest::Push { branch, .. } => git
|
|
.push(configured, branch.as_deref(), secrets)
|
|
.map(|outcome| Some(format!("{} {}", outcome.remote(), outcome.new_id()))),
|
|
GitRequest::Sync { .. } => git.sync(configured, secrets).map(|(pull, push)| {
|
|
Some(format!(
|
|
"{}; {} {}",
|
|
pull_outcome_name(pull),
|
|
push.remote(),
|
|
push.new_id()
|
|
))
|
|
}),
|
|
_ => unreachable!("local Git requests do not require secret access"),
|
|
};
|
|
match result {
|
|
Ok(Some(message)) => {
|
|
writeln!(stdout, "{message}").map_err(|_| ())?;
|
|
Ok(EXIT_SUCCESS)
|
|
}
|
|
Ok(None) => Ok(EXIT_SUCCESS),
|
|
Err(error) => operation_error(stderr, error),
|
|
}
|
|
}
|
|
CommandRequest::List(_)
|
|
| CommandRequest::Find(_)
|
|
| CommandRequest::Completion { .. }
|
|
| CommandRequest::Help { .. }
|
|
| CommandRequest::Version => unreachable!("request is handled before secure execution"),
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
enum TransferRequest<'a> {
|
|
Move(&'a ironstorage::command::MoveRequest),
|
|
Copy(&'a ironstorage::command::CopyRequest),
|
|
}
|
|
|
|
impl TransferRequest<'_> {
|
|
fn source(&self) -> &str {
|
|
match self {
|
|
Self::Move(request) => &request.source,
|
|
Self::Copy(request) => &request.source,
|
|
}
|
|
}
|
|
|
|
fn destination(&self) -> &str {
|
|
match self {
|
|
Self::Move(request) => &request.destination,
|
|
Self::Copy(request) => &request.destination,
|
|
}
|
|
}
|
|
|
|
fn force(&self) -> bool {
|
|
match self {
|
|
Self::Move(request) => request.force,
|
|
Self::Copy(request) => request.force,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn execute_transfer<B: SecretStoreBackend, I: CliInteraction, E: Write>(
|
|
repository: &Repository,
|
|
keys: &KeyStore,
|
|
request: TransferRequest<'_>,
|
|
secrets: &mut SecretStore<B>,
|
|
interaction: &mut I,
|
|
stderr: &mut E,
|
|
) -> Result<u8, ()> {
|
|
let mut committer = match automatic_committer(repository, request.source()) {
|
|
Ok(committer) => committer,
|
|
Err(error) => return operation_error(stderr, error),
|
|
};
|
|
let mut decision = if request.force() {
|
|
OverwriteDecision::Allow
|
|
} else {
|
|
OverwriteDecision::Decline
|
|
};
|
|
loop {
|
|
let mutator = TreeMutator::new(repository, keys);
|
|
let result = match request {
|
|
TransferRequest::Move(request) => {
|
|
mutator.move_tree(request, decision, None, secrets, &mut committer)
|
|
}
|
|
TransferRequest::Copy(request) => {
|
|
mutator.copy(request, decision, None, secrets, &mut committer)
|
|
}
|
|
};
|
|
match result {
|
|
Ok(_) => return Ok(EXIT_SUCCESS),
|
|
Err(MutationError::Cancelled)
|
|
if decision == OverwriteDecision::Decline && !request.force() =>
|
|
{
|
|
decision = match interaction.confirm(
|
|
&format!(
|
|
"An entry already exists at {}. Overwrite it?",
|
|
request.destination()
|
|
),
|
|
stderr,
|
|
) {
|
|
Ok(decision) => decision,
|
|
Err(error) => return operation_error(stderr, error),
|
|
};
|
|
if decision == OverwriteDecision::Decline {
|
|
return operation_error(stderr, MutationError::Cancelled);
|
|
}
|
|
}
|
|
Err(error) => return operation_error(stderr, error),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn pull_outcome_name(outcome: PullOutcome) -> &'static str {
|
|
match outcome {
|
|
PullOutcome::UpToDate => "up to date",
|
|
PullOutcome::FastForward => "fast-forward",
|
|
PullOutcome::Merged => "merged",
|
|
}
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn execute_otp<B: SecretStoreBackend, P: CliPresentation, I: OtpInteraction, O: Write, E: Write>(
|
|
config: &Config,
|
|
request: &OtpRequest,
|
|
secrets: &mut SecretStore<B>,
|
|
presentation: &mut P,
|
|
interaction: &mut I,
|
|
clock: impl Fn() -> Result<u64, OtpInteractionError>,
|
|
stdout: &mut O,
|
|
stderr: &mut E,
|
|
) -> Result<u8, ()> {
|
|
let repository = match Repository::open(config.vault()) {
|
|
Ok(repository) => repository,
|
|
Err(error) => return operation_error(stderr, error),
|
|
};
|
|
let keys = match KeyStore::load(config.key_material()) {
|
|
Ok(keys) => keys,
|
|
Err(error) => return operation_error(stderr, error),
|
|
};
|
|
let service = OtpService::new(&repository, &keys);
|
|
match request {
|
|
OtpRequest::Code(request) => {
|
|
let timestamp = match clock() {
|
|
Ok(timestamp) => timestamp,
|
|
Err(error) => return operation_error(stderr, error),
|
|
};
|
|
let mut committer =
|
|
match automatic_committer(&repository, request.entry.trim_end_matches('/')) {
|
|
Ok(committer) => committer,
|
|
Err(error) => return operation_error(stderr, error),
|
|
};
|
|
let outcome =
|
|
match service.code(&request.entry, timestamp, None, secrets, &mut committer) {
|
|
Ok(outcome) => outcome,
|
|
Err(error) => return operation_error(stderr, error),
|
|
};
|
|
if request.clipboard {
|
|
match presentation.clipboard(
|
|
outcome.code(),
|
|
config.clipboard_timeout(),
|
|
&format!("OTP code for {}", request.entry),
|
|
stdout,
|
|
) {
|
|
Ok(()) => Ok(EXIT_SUCCESS),
|
|
Err(error) => operation_error(stderr, error),
|
|
}
|
|
} else {
|
|
stdout
|
|
.write_all(outcome.code().expose())
|
|
.and_then(|()| stdout.write_all(b"\n"))
|
|
.map_err(|_| ())?;
|
|
Ok(EXIT_SUCCESS)
|
|
}
|
|
}
|
|
OtpRequest::Insert(request) => {
|
|
let input = match interaction.read_input(
|
|
request.input_plan(interaction.standard_input_is_terminal()),
|
|
&request.source,
|
|
request.entry.as_deref().unwrap_or("this token"),
|
|
stderr,
|
|
) {
|
|
Ok(input) => input,
|
|
Err(error) => return operation_error(stderr, error),
|
|
};
|
|
let plan = match service.prepare_insert(request, input) {
|
|
Ok(plan) => plan,
|
|
Err(error) => return operation_error(stderr, error),
|
|
};
|
|
let path_decision = if plan.requires_path_confirmation() {
|
|
match interaction.confirm(&format!("Insert into {}?", plan.path()), stderr) {
|
|
Ok(decision) => decision,
|
|
Err(error) => return operation_error(stderr, error),
|
|
}
|
|
} else {
|
|
OverwriteDecision::Allow
|
|
};
|
|
if path_decision == OverwriteDecision::Decline {
|
|
return operation_error(stderr, OtpError::Cancelled);
|
|
}
|
|
let overwrite = if plan.requires_overwrite_confirmation() {
|
|
match interaction.confirm(
|
|
&format!("An entry already exists for {}. Overwrite it?", plan.path()),
|
|
stderr,
|
|
) {
|
|
Ok(decision) => decision,
|
|
Err(error) => return operation_error(stderr, error),
|
|
}
|
|
} else {
|
|
OverwriteDecision::Allow
|
|
};
|
|
let mut committer = match automatic_committer(&repository, &plan.path().to_string()) {
|
|
Ok(committer) => committer,
|
|
Err(error) => return operation_error(stderr, error),
|
|
};
|
|
match service.finish_insert(plan, path_decision, overwrite, None, &mut committer) {
|
|
Ok(_) => Ok(EXIT_SUCCESS),
|
|
Err(error) => operation_error(stderr, error),
|
|
}
|
|
}
|
|
OtpRequest::Append(request) => {
|
|
let session = match service.begin_append(request, secrets) {
|
|
Ok(session) => session,
|
|
Err(error) => return operation_error(stderr, error),
|
|
};
|
|
let replace = if session.requires_replace_confirmation() {
|
|
match interaction.confirm(
|
|
&format!(
|
|
"An OTP secret already exists for {}. Overwrite it?",
|
|
session.path()
|
|
),
|
|
stderr,
|
|
) {
|
|
Ok(decision) => decision,
|
|
Err(error) => return operation_error(stderr, error),
|
|
}
|
|
} else {
|
|
OverwriteDecision::Allow
|
|
};
|
|
if replace == OverwriteDecision::Decline {
|
|
return operation_error(stderr, OtpError::Cancelled);
|
|
}
|
|
let path = session.path().to_string();
|
|
let input = match interaction.read_input(
|
|
request.input_plan(interaction.standard_input_is_terminal()),
|
|
&request.source,
|
|
&request.entry,
|
|
stderr,
|
|
) {
|
|
Ok(input) => input,
|
|
Err(error) => return operation_error(stderr, error),
|
|
};
|
|
let mut committer = match automatic_committer(&repository, &path) {
|
|
Ok(committer) => committer,
|
|
Err(error) => return operation_error(stderr, error),
|
|
};
|
|
match service.finish_append(session, input, replace, None, &mut committer) {
|
|
Ok(_) => Ok(EXIT_SUCCESS),
|
|
Err(error) => operation_error(stderr, error),
|
|
}
|
|
}
|
|
OtpRequest::Uri(request) => {
|
|
let uri = match service.uri(&request.entry, secrets) {
|
|
Ok(uri) => uri,
|
|
Err(error) => return operation_error(stderr, error),
|
|
};
|
|
let result = match request.presentation {
|
|
OtpUriPresentation::Terminal => stdout
|
|
.write_all(uri.encoded().expose())
|
|
.and_then(|()| stdout.write_all(b"\n"))
|
|
.map_err(|_| PresentationFailure::Output),
|
|
OtpUriPresentation::Clipboard => presentation.clipboard(
|
|
uri.encoded(),
|
|
config.clipboard_timeout(),
|
|
&format!("OTP key URI for {}", request.entry),
|
|
stdout,
|
|
),
|
|
OtpUriPresentation::QrCode => presentation.qr_code(uri.encoded(), stdout),
|
|
};
|
|
match result {
|
|
Ok(()) => Ok(EXIT_SUCCESS),
|
|
Err(error) => operation_error(stderr, error),
|
|
}
|
|
}
|
|
OtpRequest::Validate { .. } | OtpRequest::Help | OtpRequest::Version => {
|
|
Ok(EXIT_UNAVAILABLE)
|
|
}
|
|
}
|
|
}
|
|
|
|
trait OtpInteraction {
|
|
fn standard_input_is_terminal(&self) -> bool;
|
|
|
|
fn read_input(
|
|
&mut self,
|
|
plan: InputPlan,
|
|
source: &OtpInputSource,
|
|
prompt: &str,
|
|
stderr: &mut dyn Write,
|
|
) -> Result<OtpInput, OtpInteractionError>;
|
|
|
|
fn confirm(
|
|
&mut self,
|
|
prompt: &str,
|
|
stderr: &mut dyn Write,
|
|
) -> Result<OverwriteDecision, OtpInteractionError>;
|
|
}
|
|
|
|
trait CliInteraction: OtpInteraction {
|
|
fn read_insert(
|
|
&mut self,
|
|
plan: InputPlan,
|
|
entry: &str,
|
|
stderr: &mut dyn Write,
|
|
) -> Result<InsertContent, CliInteractionError>;
|
|
|
|
fn edit(
|
|
&mut self,
|
|
plaintext: &SecretBytes,
|
|
config: &Config,
|
|
) -> Result<(SecretBytes, String), CliInteractionError>;
|
|
}
|
|
|
|
struct NativeOtpInteraction;
|
|
|
|
struct NativeOpenPgpPassphrasePrompt;
|
|
|
|
impl OpenPgpPassphrasePrompt for NativeOpenPgpPassphrasePrompt {
|
|
fn request_passphrase(
|
|
&mut self,
|
|
key: &ironstorage::crypto::KeyInfo,
|
|
) -> Result<SecretBytes, OpenPgpPassphrasePromptError> {
|
|
if !std::io::stdin().is_terminal() {
|
|
return Err(OpenPgpPassphrasePromptError::Unavailable);
|
|
}
|
|
rpassword::prompt_password(format!(
|
|
"Enter OpenPGP passphrase for {}: ",
|
|
key.fingerprint()
|
|
))
|
|
.map(|value| SecretBytes::new(value.into_bytes()))
|
|
.map_err(|error| match error.kind() {
|
|
std::io::ErrorKind::Interrupted | std::io::ErrorKind::UnexpectedEof => {
|
|
OpenPgpPassphrasePromptError::Cancelled
|
|
}
|
|
_ => OpenPgpPassphrasePromptError::Unavailable,
|
|
})
|
|
}
|
|
}
|
|
|
|
impl OtpInteraction for NativeOtpInteraction {
|
|
fn standard_input_is_terminal(&self) -> bool {
|
|
std::io::stdin().is_terminal()
|
|
}
|
|
|
|
fn read_input(
|
|
&mut self,
|
|
plan: InputPlan,
|
|
source: &OtpInputSource,
|
|
prompt: &str,
|
|
stderr: &mut dyn Write,
|
|
) -> Result<OtpInput, OtpInteractionError> {
|
|
let subject = match source {
|
|
OtpInputSource::Uri => "otpauth:// URI",
|
|
OtpInputSource::Secret { .. } => "secret",
|
|
};
|
|
match plan {
|
|
InputPlan::HiddenConfirmed => {
|
|
let first = rpassword::prompt_password(format!("Enter {subject} for {prompt}: "))
|
|
.map_err(|_| OtpInteractionError::Input)?
|
|
.into_bytes();
|
|
let confirmation =
|
|
rpassword::prompt_password(format!("Retype {subject} for {prompt}: "))
|
|
.map_err(|_| OtpInteractionError::Input)?
|
|
.into_bytes();
|
|
OtpInput::hidden(first, confirmation).map_err(Into::into)
|
|
}
|
|
InputPlan::EchoedLine => {
|
|
write!(stderr, "Enter {subject} for {prompt}: ")
|
|
.and_then(|()| stderr.flush())
|
|
.map_err(|_| OtpInteractionError::Output)?;
|
|
OtpInput::line(read_standard_input_line()?).map_err(Into::into)
|
|
}
|
|
InputPlan::StandardInputLine => {
|
|
OtpInput::line(read_standard_input_line()?).map_err(Into::into)
|
|
}
|
|
InputPlan::StandardInputToEnd => Err(OtpInteractionError::Input),
|
|
}
|
|
}
|
|
|
|
fn confirm(
|
|
&mut self,
|
|
prompt: &str,
|
|
stderr: &mut dyn Write,
|
|
) -> Result<OverwriteDecision, OtpInteractionError> {
|
|
loop {
|
|
write!(stderr, "{prompt} [y/N] ")
|
|
.and_then(|()| stderr.flush())
|
|
.map_err(|_| OtpInteractionError::Output)?;
|
|
let answer = read_standard_input_line()?;
|
|
match answer.as_slice() {
|
|
b"y" | b"Y" | b"yes" | b"YES" | b"Yes" => {
|
|
return Ok(OverwriteDecision::Allow);
|
|
}
|
|
b"" | b"n" | b"N" | b"no" | b"NO" | b"No" => {
|
|
return Ok(OverwriteDecision::Decline);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl CliInteraction for NativeOtpInteraction {
|
|
fn read_insert(
|
|
&mut self,
|
|
plan: InputPlan,
|
|
entry: &str,
|
|
stderr: &mut dyn Write,
|
|
) -> Result<InsertContent, CliInteractionError> {
|
|
match plan {
|
|
InputPlan::HiddenConfirmed => {
|
|
let first = rpassword::prompt_password(format!("Enter password for {entry}: "))
|
|
.map_err(|_| CliInteractionError::Input)?
|
|
.into_bytes();
|
|
let confirmation =
|
|
rpassword::prompt_password(format!("Retype password for {entry}: "))
|
|
.map_err(|_| CliInteractionError::Input)?
|
|
.into_bytes();
|
|
InsertContent::hidden(first, confirmation).map_err(Into::into)
|
|
}
|
|
InputPlan::EchoedLine => {
|
|
write!(stderr, "Enter password for {entry}: ")
|
|
.and_then(|()| stderr.flush())
|
|
.map_err(|_| CliInteractionError::Output)?;
|
|
InsertContent::echoed(read_cli_input_line()?).map_err(Into::into)
|
|
}
|
|
InputPlan::StandardInputLine => {
|
|
InsertContent::echoed(read_cli_input_line()?).map_err(Into::into)
|
|
}
|
|
InputPlan::StandardInputToEnd => {
|
|
let mut contents = Vec::new();
|
|
std::io::stdin()
|
|
.lock()
|
|
.read_to_end(&mut contents)
|
|
.map_err(|_| CliInteractionError::Input)?;
|
|
Ok(InsertContent::multiline(contents))
|
|
}
|
|
}
|
|
}
|
|
|
|
fn edit(
|
|
&mut self,
|
|
plaintext: &SecretBytes,
|
|
config: &Config,
|
|
) -> Result<(SecretBytes, String), CliInteractionError> {
|
|
let resolved = config
|
|
.resolve_editor()
|
|
.map_err(CliInteractionError::EditorResolution)?;
|
|
let name = resolved.command().program().to_owned();
|
|
let replacement =
|
|
editor::edit_replacement(plaintext, &resolved, &mut editor::NativeEditorHost)
|
|
.map_err(CliInteractionError::Editor)?;
|
|
Ok((replacement, name))
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
struct UnavailableOtpInteraction;
|
|
|
|
#[cfg(test)]
|
|
impl OtpInteraction for UnavailableOtpInteraction {
|
|
fn standard_input_is_terminal(&self) -> bool {
|
|
false
|
|
}
|
|
|
|
fn read_input(
|
|
&mut self,
|
|
_plan: InputPlan,
|
|
_source: &OtpInputSource,
|
|
_prompt: &str,
|
|
_stderr: &mut dyn Write,
|
|
) -> Result<OtpInput, OtpInteractionError> {
|
|
Err(OtpInteractionError::Input)
|
|
}
|
|
|
|
fn confirm(
|
|
&mut self,
|
|
_prompt: &str,
|
|
_stderr: &mut dyn Write,
|
|
) -> Result<OverwriteDecision, OtpInteractionError> {
|
|
Err(OtpInteractionError::Input)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
impl CliInteraction for UnavailableOtpInteraction {
|
|
fn read_insert(
|
|
&mut self,
|
|
_plan: InputPlan,
|
|
_entry: &str,
|
|
_stderr: &mut dyn Write,
|
|
) -> Result<InsertContent, CliInteractionError> {
|
|
Err(CliInteractionError::Input)
|
|
}
|
|
|
|
fn edit(
|
|
&mut self,
|
|
_plaintext: &SecretBytes,
|
|
_config: &Config,
|
|
) -> Result<(SecretBytes, String), CliInteractionError> {
|
|
Err(CliInteractionError::Input)
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
enum CliInteractionError {
|
|
Write(ironstorage::write::WriteError),
|
|
EditorResolution(ironstorage::config::EditorError),
|
|
Editor(editor::CliEditorError),
|
|
Input,
|
|
Output,
|
|
}
|
|
|
|
impl fmt::Display for CliInteractionError {
|
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
match self {
|
|
Self::Write(error) => error.fmt(formatter),
|
|
Self::EditorResolution(error) => error.fmt(formatter),
|
|
Self::Editor(error) => error.fmt(formatter),
|
|
Self::Input => formatter.write_str("command input could not be read"),
|
|
Self::Output => formatter.write_str("command prompt could not be written"),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<ironstorage::write::WriteError> for CliInteractionError {
|
|
fn from(error: ironstorage::write::WriteError) -> Self {
|
|
Self::Write(error)
|
|
}
|
|
}
|
|
|
|
impl Error for CliInteractionError {}
|
|
|
|
#[derive(Debug)]
|
|
enum OtpInteractionError {
|
|
Otp(OtpError),
|
|
Input,
|
|
Output,
|
|
Clock,
|
|
}
|
|
|
|
impl fmt::Display for OtpInteractionError {
|
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
match self {
|
|
Self::Otp(error) => error.fmt(formatter),
|
|
Self::Input => formatter.write_str("OTP input could not be read"),
|
|
Self::Output => formatter.write_str("OTP prompt could not be written"),
|
|
Self::Clock => formatter.write_str("the system clock is before the Unix epoch"),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<OtpError> for OtpInteractionError {
|
|
fn from(error: OtpError) -> Self {
|
|
Self::Otp(error)
|
|
}
|
|
}
|
|
|
|
impl Error for OtpInteractionError {}
|
|
|
|
fn read_standard_input_line() -> Result<Vec<u8>, OtpInteractionError> {
|
|
let mut value = Vec::new();
|
|
let read = std::io::stdin()
|
|
.lock()
|
|
.read_until(b'\n', &mut value)
|
|
.map_err(|_| OtpInteractionError::Input)?;
|
|
if read == 0 {
|
|
return Err(OtpInteractionError::Input);
|
|
}
|
|
if value.ends_with(b"\n") {
|
|
value.pop();
|
|
}
|
|
if value.ends_with(b"\r") {
|
|
value.pop();
|
|
}
|
|
Ok(value)
|
|
}
|
|
|
|
fn read_cli_input_line() -> Result<Vec<u8>, CliInteractionError> {
|
|
let mut value = Vec::new();
|
|
let read = std::io::stdin()
|
|
.lock()
|
|
.read_until(b'\n', &mut value)
|
|
.map_err(|_| CliInteractionError::Input)?;
|
|
if read == 0 {
|
|
return Err(CliInteractionError::Input);
|
|
}
|
|
if value.ends_with(b"\n") {
|
|
value.pop();
|
|
}
|
|
if value.ends_with(b"\r") {
|
|
value.pop();
|
|
}
|
|
Ok(value)
|
|
}
|
|
|
|
fn current_unix_seconds() -> Result<u64, OtpInteractionError> {
|
|
SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.map(|duration| duration.as_secs())
|
|
.map_err(|_| OtpInteractionError::Clock)
|
|
}
|
|
|
|
enum AutomaticCommitter {
|
|
Git(Box<GitRepository>),
|
|
None {
|
|
entry: NoGitEntryCommitter,
|
|
policy: NoGitCommitter,
|
|
tree: NoGitTreeCommitter,
|
|
},
|
|
}
|
|
|
|
impl EntryCommitter for AutomaticCommitter {
|
|
fn commit(&mut self, change: &EntryCommit) -> Result<(), EntryCommitError> {
|
|
match self {
|
|
Self::Git(git) => EntryCommitter::commit(git.as_mut(), change),
|
|
Self::None { entry, .. } => entry.commit(change),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl PolicyCommitter for AutomaticCommitter {
|
|
fn commit(&mut self, change: &PolicyCommit) -> Result<(), PolicyCommitError> {
|
|
match self {
|
|
Self::Git(git) => PolicyCommitter::commit(git.as_mut(), change),
|
|
Self::None { policy, .. } => policy.commit(change),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl TreeCommitter for AutomaticCommitter {
|
|
fn commit(&mut self, change: &TreeCommit) -> Result<(), TreeCommitError> {
|
|
match self {
|
|
Self::Git(git) => TreeCommitter::commit(git.as_mut(), change),
|
|
Self::None { tree, .. } => tree.commit(change),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn automatic_committer(
|
|
repository: &Repository,
|
|
entry: &str,
|
|
) -> Result<AutomaticCommitter, ironstorage::git::GitError> {
|
|
match GitRepository::open_innermost(repository, Path::new(entry), git_identity()) {
|
|
Ok(git) => Ok(AutomaticCommitter::Git(Box::new(git))),
|
|
Err(ironstorage::git::GitError::NotRepository) => Ok(AutomaticCommitter::None {
|
|
entry: NoGitEntryCommitter,
|
|
policy: NoGitCommitter,
|
|
tree: NoGitTreeCommitter,
|
|
}),
|
|
Err(error) => Err(error),
|
|
}
|
|
}
|
|
|
|
trait CliPresentation {
|
|
fn clipboard(
|
|
&mut self,
|
|
value: &SecretBytes,
|
|
timeout: ClipboardTimeout,
|
|
description: &str,
|
|
stdout: &mut dyn Write,
|
|
) -> Result<(), PresentationFailure>;
|
|
|
|
fn qr_code(
|
|
&mut self,
|
|
value: &SecretBytes,
|
|
stdout: &mut dyn Write,
|
|
) -> Result<(), PresentationFailure>;
|
|
}
|
|
|
|
struct NativePresentation;
|
|
|
|
impl CliPresentation for NativePresentation {
|
|
fn clipboard(
|
|
&mut self,
|
|
value: &SecretBytes,
|
|
timeout: ClipboardTimeout,
|
|
description: &str,
|
|
stdout: &mut dyn Write,
|
|
) -> Result<(), PresentationFailure> {
|
|
let cancelled = Arc::new(AtomicBool::new(false));
|
|
let signal = Arc::clone(&cancelled);
|
|
ctrlc::set_handler(move || signal.store(true, Ordering::SeqCst))
|
|
.map_err(|_| PresentationFailure::CancellationHandler)?;
|
|
let mut clipboard = NativeClipboardManager::system(timeout)?;
|
|
let mut output_failed = false;
|
|
let result = clipboard.copy_with(value, |duration| {
|
|
if writeln!(
|
|
stdout,
|
|
"Copied {description} to clipboard. Will restore or clear in {} seconds.",
|
|
duration.as_secs()
|
|
)
|
|
.and_then(|()| stdout.flush())
|
|
.is_err()
|
|
{
|
|
output_failed = true;
|
|
return ClipboardWait::Cancelled;
|
|
}
|
|
wait_for_clipboard(duration, &cancelled)
|
|
});
|
|
if output_failed {
|
|
return Err(PresentationFailure::Output);
|
|
}
|
|
result.map(|_| ()).map_err(Into::into)
|
|
}
|
|
|
|
fn qr_code(
|
|
&mut self,
|
|
value: &SecretBytes,
|
|
stdout: &mut dyn Write,
|
|
) -> Result<(), PresentationFailure> {
|
|
let matrix = QrMatrix::encode(value)?;
|
|
let rendered = matrix.render_terminal();
|
|
stdout
|
|
.write_all(rendered.expose())
|
|
.map_err(|_| PresentationFailure::Output)
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
enum PresentationFailure {
|
|
Clipboard(ClipboardError),
|
|
Qr(QrError),
|
|
CancellationHandler,
|
|
Output,
|
|
}
|
|
|
|
impl fmt::Display for PresentationFailure {
|
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
match self {
|
|
Self::Clipboard(error) => error.fmt(formatter),
|
|
Self::Qr(error) => error.fmt(formatter),
|
|
Self::CancellationHandler => {
|
|
formatter.write_str("clipboard cancellation handling is unavailable")
|
|
}
|
|
Self::Output => formatter.write_str("presentation output could not be written"),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn wait_for_clipboard(duration: Duration, cancelled: &AtomicBool) -> ClipboardWait {
|
|
let deadline = Instant::now() + duration;
|
|
loop {
|
|
if cancelled.load(Ordering::SeqCst) {
|
|
return ClipboardWait::Cancelled;
|
|
}
|
|
let remaining = deadline.saturating_duration_since(Instant::now());
|
|
if remaining.is_zero() {
|
|
return ClipboardWait::Elapsed;
|
|
}
|
|
std::thread::sleep(remaining.min(Duration::from_millis(50)));
|
|
}
|
|
}
|
|
|
|
impl Error for PresentationFailure {}
|
|
|
|
impl From<ClipboardError> for PresentationFailure {
|
|
fn from(error: ClipboardError) -> Self {
|
|
Self::Clipboard(error)
|
|
}
|
|
}
|
|
|
|
impl From<QrError> for PresentationFailure {
|
|
fn from(error: QrError) -> Self {
|
|
Self::Qr(error)
|
|
}
|
|
}
|
|
|
|
fn select_remote<'a>(
|
|
config: &'a Config,
|
|
requested: Option<&str>,
|
|
) -> Option<&'a ironstorage::config::GitRemote> {
|
|
match requested {
|
|
Some(requested) => config
|
|
.git_remotes()
|
|
.iter()
|
|
.find(|remote| remote.name().as_str() == requested),
|
|
None => config.git_remotes().first(),
|
|
}
|
|
}
|
|
|
|
fn operation_error<E: Write>(stderr: &mut E, error: impl std::fmt::Display) -> Result<u8, ()> {
|
|
writeln!(stderr, "{error}").map_err(|_| ())?;
|
|
Ok(EXIT_FAILURE)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::{
|
|
collections::{BTreeMap, VecDeque},
|
|
error::Error,
|
|
ffi::OsString,
|
|
fs,
|
|
sync::{Arc, Mutex},
|
|
};
|
|
|
|
use ironstorage::{
|
|
command::{
|
|
CommandRequest, CopyRequest, EXIT_CONFIG, EXIT_FAILURE, EXIT_SUCCESS, EXIT_UNAVAILABLE,
|
|
EXIT_USAGE, EditRequest, FindRequest, GenerateRequest, GeneratedPresentation,
|
|
GitRequest, GrepRequest, InitRequest, InputPlan, InsertInput, InsertRequest,
|
|
ListRequest, MoveRequest, OtpAppendRequest, OtpCodeRequest, OtpInputSource,
|
|
OtpInsertRequest, OtpRequest, OtpUriPresentation, OtpUriRequest, Presentation,
|
|
RemoveRequest, ShowRequest,
|
|
},
|
|
config::Config,
|
|
crypto::KeyInfo,
|
|
git::GitCredentialProvider as _,
|
|
otp::OtpInput,
|
|
presentation::{ClipboardTimeout, QrMatrix},
|
|
repository::{EntryPath, Repository, SecretBytes},
|
|
secret_store::{
|
|
OpenPgpPassphrasePrompt, OpenPgpPassphrasePromptError, SecretCachePolicy,
|
|
SecretLocator, SecretProtection, SecretProtectionPolicy, SecretReference, SecretStore,
|
|
SecretStoreBackend, SecretStoreError,
|
|
},
|
|
write::{InsertContent, OverwriteDecision},
|
|
};
|
|
|
|
use super::{
|
|
CliPresentation, OtpInteraction, OtpInteractionError, PresentationFailure, execute_local,
|
|
execute_secure, execute_secure_with, execute_secure_with_services, run_with,
|
|
wait_for_clipboard,
|
|
};
|
|
|
|
type TestResult = Result<(), Box<dyn Error>>;
|
|
|
|
#[derive(Clone, Default)]
|
|
struct MemoryBackend(Arc<Mutex<BTreeMap<SecretLocator, SecretBytes>>>);
|
|
|
|
#[derive(Clone, Default)]
|
|
struct MemoryOpenPgpPrompt(Arc<Mutex<MemoryOpenPgpPromptState>>);
|
|
|
|
#[derive(Default)]
|
|
struct MemoryOpenPgpPromptState {
|
|
responses: VecDeque<Result<Vec<u8>, OpenPgpPassphrasePromptError>>,
|
|
requests: Vec<String>,
|
|
}
|
|
|
|
impl MemoryOpenPgpPrompt {
|
|
fn with_passphrase(passphrase: &[u8]) -> Self {
|
|
let prompt = Self::default();
|
|
prompt
|
|
.0
|
|
.lock()
|
|
.expect("test mutex")
|
|
.responses
|
|
.push_back(Ok(passphrase.to_vec()));
|
|
prompt
|
|
}
|
|
|
|
fn requests(&self) -> Vec<String> {
|
|
self.0.lock().expect("test mutex").requests.clone()
|
|
}
|
|
}
|
|
|
|
impl OpenPgpPassphrasePrompt for MemoryOpenPgpPrompt {
|
|
fn request_passphrase(
|
|
&mut self,
|
|
key: &KeyInfo,
|
|
) -> Result<SecretBytes, OpenPgpPassphrasePromptError> {
|
|
let mut state = self.0.lock().expect("test mutex");
|
|
state.requests.push(key.fingerprint().as_str().to_owned());
|
|
state
|
|
.responses
|
|
.pop_front()
|
|
.unwrap_or(Err(OpenPgpPassphrasePromptError::Unavailable))
|
|
.map(SecretBytes::new)
|
|
}
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct MemoryPresentation {
|
|
clipboard: Vec<Vec<u8>>,
|
|
qr: Vec<Vec<u8>>,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct MemoryOtpInteraction {
|
|
terminal: bool,
|
|
inputs: VecDeque<OtpInput>,
|
|
insert_inputs: VecDeque<ironstorage::write::InsertContent>,
|
|
edit_replacements: VecDeque<SecretBytes>,
|
|
decisions: VecDeque<OverwriteDecision>,
|
|
plans: Vec<InputPlan>,
|
|
prompts: Vec<String>,
|
|
}
|
|
|
|
impl OtpInteraction for MemoryOtpInteraction {
|
|
fn standard_input_is_terminal(&self) -> bool {
|
|
self.terminal
|
|
}
|
|
|
|
fn read_input(
|
|
&mut self,
|
|
plan: InputPlan,
|
|
_source: &OtpInputSource,
|
|
prompt: &str,
|
|
_stderr: &mut dyn std::io::Write,
|
|
) -> Result<OtpInput, OtpInteractionError> {
|
|
self.plans.push(plan);
|
|
self.prompts.push(prompt.to_owned());
|
|
self.inputs.pop_front().ok_or(OtpInteractionError::Input)
|
|
}
|
|
|
|
fn confirm(
|
|
&mut self,
|
|
prompt: &str,
|
|
_stderr: &mut dyn std::io::Write,
|
|
) -> Result<OverwriteDecision, OtpInteractionError> {
|
|
self.prompts.push(prompt.to_owned());
|
|
self.decisions.pop_front().ok_or(OtpInteractionError::Input)
|
|
}
|
|
}
|
|
|
|
impl super::CliInteraction for MemoryOtpInteraction {
|
|
fn read_insert(
|
|
&mut self,
|
|
_plan: InputPlan,
|
|
_entry: &str,
|
|
_stderr: &mut dyn std::io::Write,
|
|
) -> Result<ironstorage::write::InsertContent, super::CliInteractionError> {
|
|
self.insert_inputs
|
|
.pop_front()
|
|
.ok_or(super::CliInteractionError::Input)
|
|
}
|
|
|
|
fn edit(
|
|
&mut self,
|
|
_plaintext: &SecretBytes,
|
|
_config: &Config,
|
|
) -> Result<(SecretBytes, String), super::CliInteractionError> {
|
|
self.edit_replacements
|
|
.pop_front()
|
|
.map(|replacement| (replacement, "fixture-editor".to_owned()))
|
|
.ok_or(super::CliInteractionError::Input)
|
|
}
|
|
}
|
|
|
|
impl CliPresentation for MemoryPresentation {
|
|
fn clipboard(
|
|
&mut self,
|
|
value: &SecretBytes,
|
|
timeout: ClipboardTimeout,
|
|
description: &str,
|
|
stdout: &mut dyn std::io::Write,
|
|
) -> Result<(), PresentationFailure> {
|
|
self.clipboard.push(value.expose().to_vec());
|
|
writeln!(
|
|
stdout,
|
|
"Copied {description} for {} seconds.",
|
|
timeout.duration().as_secs()
|
|
)
|
|
.map_err(|_| PresentationFailure::Output)
|
|
}
|
|
|
|
fn qr_code(
|
|
&mut self,
|
|
value: &SecretBytes,
|
|
stdout: &mut dyn std::io::Write,
|
|
) -> Result<(), PresentationFailure> {
|
|
self.qr.push(value.expose().to_vec());
|
|
let matrix = QrMatrix::encode(value)?;
|
|
stdout
|
|
.write_all(matrix.render_terminal().expose())
|
|
.map_err(|_| PresentationFailure::Output)
|
|
}
|
|
}
|
|
|
|
impl SecretStoreBackend for MemoryBackend {
|
|
fn create(
|
|
&self,
|
|
locator: &SecretLocator,
|
|
_protection: SecretProtection,
|
|
value: &[u8],
|
|
) -> Result<(), SecretStoreError> {
|
|
let mut values = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?;
|
|
if values.contains_key(locator) {
|
|
return Err(SecretStoreError::AlreadyExists);
|
|
}
|
|
values.insert(locator.clone(), SecretBytes::new(value.to_vec()));
|
|
Ok(())
|
|
}
|
|
|
|
fn retrieve(
|
|
&self,
|
|
locator: &SecretLocator,
|
|
_protection: SecretProtection,
|
|
) -> Result<SecretBytes, SecretStoreError> {
|
|
self.0
|
|
.lock()
|
|
.map_err(|_| SecretStoreError::Unavailable)?
|
|
.get(locator)
|
|
.map(|value| SecretBytes::new(value.expose().to_vec()))
|
|
.ok_or(SecretStoreError::Missing)
|
|
}
|
|
|
|
fn replace(
|
|
&self,
|
|
locator: &SecretLocator,
|
|
_protection: SecretProtection,
|
|
value: &[u8],
|
|
) -> Result<(), SecretStoreError> {
|
|
let mut values = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?;
|
|
let existing = values.get_mut(locator).ok_or(SecretStoreError::Missing)?;
|
|
*existing = SecretBytes::new(value.to_vec());
|
|
Ok(())
|
|
}
|
|
|
|
fn delete(
|
|
&self,
|
|
locator: &SecretLocator,
|
|
_protection: SecretProtection,
|
|
) -> Result<(), SecretStoreError> {
|
|
self.0
|
|
.lock()
|
|
.map_err(|_| SecretStoreError::Unavailable)?
|
|
.remove(locator)
|
|
.map(drop)
|
|
.ok_or(SecretStoreError::Missing)
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn clipboard_wait_is_interruptible_without_skipping_storage_cleanup() {
|
|
let cancelled = std::sync::atomic::AtomicBool::new(true);
|
|
assert_eq!(
|
|
wait_for_clipboard(std::time::Duration::from_secs(60), &cancelled),
|
|
ironstorage::presentation::ClipboardWait::Cancelled
|
|
);
|
|
let elapsed = std::sync::atomic::AtomicBool::new(false);
|
|
assert_eq!(
|
|
wait_for_clipboard(std::time::Duration::from_millis(1), &elapsed),
|
|
ironstorage::presentation::ClipboardWait::Elapsed
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn help_and_usage_errors_have_stable_streams_and_exit_codes() -> TestResult {
|
|
let mut stdout = Vec::new();
|
|
let mut stderr = Vec::new();
|
|
let code = run_with(["ironstorage", "--help"], &mut stdout, &mut stderr)
|
|
.expect("writing to memory cannot fail");
|
|
assert_eq!(code, EXIT_SUCCESS);
|
|
assert!(String::from_utf8(stdout)?.contains("Usage"));
|
|
assert!(stderr.is_empty());
|
|
|
|
let mut stdout = Vec::new();
|
|
let mut stderr = Vec::new();
|
|
let code = run_with(
|
|
["ironstorage", "insert", "--echo", "--multiline", "entry"],
|
|
&mut stdout,
|
|
&mut stderr,
|
|
)
|
|
.expect("writing to memory cannot fail");
|
|
assert_eq!(code, EXIT_USAGE);
|
|
assert!(stdout.is_empty());
|
|
assert!(String::from_utf8(stderr)?.contains("cannot be used with"));
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn configuration_and_unavailable_operations_have_stable_exit_codes() -> TestResult {
|
|
let temporary = tempfile::tempdir()?;
|
|
let missing = temporary.path().join("missing.toml");
|
|
let mut stdout = Vec::new();
|
|
let mut stderr = Vec::new();
|
|
let code = run_with(
|
|
[
|
|
OsString::from("ironstorage"),
|
|
OsString::from("--config"),
|
|
missing.into_os_string(),
|
|
OsString::from("show"),
|
|
OsString::from("entry"),
|
|
],
|
|
&mut stdout,
|
|
&mut stderr,
|
|
)
|
|
.expect("writing to memory cannot fail");
|
|
assert_eq!(code, EXIT_CONFIG);
|
|
assert!(stdout.is_empty());
|
|
assert!(String::from_utf8(stderr)?.contains("configuration file not found"));
|
|
|
|
let keys = temporary.path().join("keys");
|
|
fs::create_dir(&keys)?;
|
|
let config = temporary.path().join("config.toml");
|
|
fs::write(
|
|
&config,
|
|
"vault = 'vault'\ndefault_key = 'alice'\nkey_material = 'keys'\n",
|
|
)?;
|
|
let mut stdout = Vec::new();
|
|
let mut stderr = Vec::new();
|
|
let code = run_with(
|
|
[
|
|
OsString::from("ironstorage"),
|
|
OsString::from("--config"),
|
|
config.into_os_string(),
|
|
OsString::from("show"),
|
|
OsString::from("entry"),
|
|
],
|
|
&mut stdout,
|
|
&mut stderr,
|
|
)
|
|
.expect("writing to memory cannot fail");
|
|
assert_eq!(code, EXIT_UNAVAILABLE);
|
|
assert!(stdout.is_empty());
|
|
assert!(String::from_utf8(stderr)?.contains("secret store is unavailable"));
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn cli_session_unlocks_protected_entries_and_https_credentials_by_reference() -> TestResult {
|
|
const FINGERPRINT: &str = "7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30";
|
|
let fixtures = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
|
.join("../../crates/storage/tests/fixtures/compatibility");
|
|
let temporary = tempfile::tempdir()?;
|
|
let config_path = temporary.path().join("config.toml");
|
|
fs::write(
|
|
&config_path,
|
|
format!(
|
|
"vault = {:?}\ndefault_key = {:?}\nkey_material = {:?}\n[[git.remotes]]\nname = 'origin'\nurl = 'https://example.test/store.git'\nserver_id = 'fixture-server'\napplication_id = 'fixture-app'\n",
|
|
fixtures.join("stores/basic"),
|
|
FINGERPRINT,
|
|
fixtures.join("keys"),
|
|
),
|
|
)?;
|
|
let config = Config::load(Some(&config_path))?;
|
|
let mut secrets = SecretStore::new(
|
|
MemoryBackend::default(),
|
|
SecretCachePolicy::Disabled,
|
|
SecretProtectionPolicy::device_unlocked(),
|
|
);
|
|
secrets.unlock()?;
|
|
secrets.create(
|
|
&SecretReference::openpgp_passphrase(FINGERPRINT)?,
|
|
SecretBytes::new(b"fixture-alice-passphrase".to_vec()),
|
|
)?;
|
|
let mut stdout = Vec::new();
|
|
let mut stderr = Vec::new();
|
|
assert_eq!(
|
|
execute_secure(
|
|
&config,
|
|
&CommandRequest::Show(ShowRequest {
|
|
entry: Some("email/personal".to_owned()),
|
|
presentation: Presentation::Terminal,
|
|
}),
|
|
&mut secrets,
|
|
&mut stdout,
|
|
&mut stderr,
|
|
)
|
|
.expect("memory output cannot fail"),
|
|
EXIT_SUCCESS
|
|
);
|
|
assert_eq!(
|
|
stdout,
|
|
fs::read(fixtures.join("expected/basic/email/personal.txt"))?
|
|
);
|
|
assert!(stderr.is_empty());
|
|
|
|
let remote = &config.git_remotes()[0];
|
|
secrets.create(
|
|
&SecretReference::https_git_credential(
|
|
remote.server_id().as_str(),
|
|
remote.application_id().as_str(),
|
|
"fixture-account",
|
|
)?,
|
|
SecretBytes::new(b"fixture-token".to_vec()),
|
|
)?;
|
|
let credential = secrets.credential(remote.server_id(), remote.application_id())?;
|
|
assert_eq!(credential.username(), "fixture-account");
|
|
assert_eq!(credential.password(), b"fixture-token");
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn cli_prompts_for_a_missing_openpgp_passphrase_then_stores_and_reuses_it() -> TestResult {
|
|
const FINGERPRINT: &str = "7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30";
|
|
const PASSPHRASE: &[u8] = b"fixture-alice-passphrase";
|
|
let fixtures = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
|
.join("../../crates/storage/tests/fixtures/compatibility");
|
|
let temporary = tempfile::tempdir()?;
|
|
let config_path = temporary.path().join("config.toml");
|
|
fs::write(
|
|
&config_path,
|
|
format!(
|
|
"vault = {:?}\ndefault_key = {:?}\nkey_material = {:?}\n",
|
|
fixtures.join("stores/basic"),
|
|
FINGERPRINT,
|
|
fixtures.join("keys"),
|
|
),
|
|
)?;
|
|
let config = Config::load(Some(&config_path))?;
|
|
let prompt = MemoryOpenPgpPrompt::with_passphrase(PASSPHRASE);
|
|
let mut secrets = SecretStore::new(
|
|
MemoryBackend::default(),
|
|
SecretCachePolicy::Disabled,
|
|
SecretProtectionPolicy::device_unlocked(),
|
|
)
|
|
.with_openpgp_passphrase_prompt(prompt.clone());
|
|
secrets.unlock()?;
|
|
let request = CommandRequest::Show(ShowRequest {
|
|
entry: Some("email/personal".to_owned()),
|
|
presentation: Presentation::Terminal,
|
|
});
|
|
|
|
for _ in 0..2 {
|
|
let mut stdout = Vec::new();
|
|
let mut stderr = Vec::new();
|
|
assert_eq!(
|
|
execute_secure(&config, &request, &mut secrets, &mut stdout, &mut stderr)
|
|
.expect("memory output cannot fail"),
|
|
EXIT_SUCCESS
|
|
);
|
|
assert_eq!(
|
|
stdout,
|
|
fs::read(fixtures.join("expected/basic/email/personal.txt"))?
|
|
);
|
|
assert!(stderr.is_empty());
|
|
}
|
|
|
|
assert_eq!(prompt.requests(), [FINGERPRINT.to_owned()]);
|
|
assert_eq!(
|
|
secrets
|
|
.retrieve(&SecretReference::openpgp_passphrase(FINGERPRINT)?)?
|
|
.expose(),
|
|
PASSPHRASE
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn cli_clipboard_and_qr_paths_never_emit_plaintext() -> TestResult {
|
|
const FINGERPRINT: &str = "7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30";
|
|
let fixtures = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
|
.join("../../crates/storage/tests/fixtures/compatibility");
|
|
let temporary = tempfile::tempdir()?;
|
|
let config_path = temporary.path().join("config.toml");
|
|
fs::write(
|
|
&config_path,
|
|
format!(
|
|
"vault = {:?}\ndefault_key = {:?}\nkey_material = {:?}\nclipboard_timeout_seconds = 1\n",
|
|
fixtures.join("stores/basic"),
|
|
FINGERPRINT,
|
|
fixtures.join("keys"),
|
|
),
|
|
)?;
|
|
let config = Config::load(Some(&config_path))?;
|
|
let mut secrets = fixture_secrets(FINGERPRINT)?;
|
|
let mut presentation = MemoryPresentation::default();
|
|
|
|
for channel in [
|
|
Presentation::Clipboard {
|
|
line: std::num::NonZeroUsize::new(1).expect("non-zero"),
|
|
},
|
|
Presentation::QrCode {
|
|
line: std::num::NonZeroUsize::new(1).expect("non-zero"),
|
|
},
|
|
] {
|
|
let mut stdout = Vec::new();
|
|
let mut stderr = Vec::new();
|
|
assert_eq!(
|
|
execute_secure_with(
|
|
&config,
|
|
&CommandRequest::Show(ShowRequest {
|
|
entry: Some("email/personal".to_owned()),
|
|
presentation: channel,
|
|
}),
|
|
&mut secrets,
|
|
&mut presentation,
|
|
&mut stdout,
|
|
&mut stderr,
|
|
)
|
|
.expect("memory output cannot fail"),
|
|
EXIT_SUCCESS
|
|
);
|
|
assert!(
|
|
!stdout
|
|
.windows(b"correct horse fixture".len())
|
|
.any(|part| { part == b"correct horse fixture" })
|
|
);
|
|
assert!(stderr.is_empty());
|
|
}
|
|
assert_eq!(
|
|
presentation.clipboard,
|
|
vec![b"correct horse fixture".to_vec()]
|
|
);
|
|
assert_eq!(presentation.qr, vec![b"correct horse fixture".to_vec()]);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn cli_generated_passwords_use_clipboard_and_qr_without_plaintext_output() -> TestResult {
|
|
const FINGERPRINT: &str = "7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30";
|
|
let fixtures = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
|
.join("../../crates/storage/tests/fixtures/compatibility");
|
|
let temporary = tempfile::tempdir()?;
|
|
let vault = temporary.path().join("vault");
|
|
fs::create_dir(&vault)?;
|
|
fs::copy(fixtures.join("stores/basic/.gpg-id"), vault.join(".gpg-id"))?;
|
|
fs::copy(
|
|
fixtures.join("stores/basic/.gpg-id.sig"),
|
|
vault.join(".gpg-id.sig"),
|
|
)?;
|
|
let config_path = temporary.path().join("config.toml");
|
|
fs::write(
|
|
&config_path,
|
|
format!(
|
|
"vault = {:?}\ndefault_key = {:?}\nkey_material = {:?}\nclipboard_timeout_seconds = 1\n",
|
|
vault,
|
|
FINGERPRINT,
|
|
fixtures.join("keys"),
|
|
),
|
|
)?;
|
|
let config = Config::load(Some(&config_path))?;
|
|
let mut secrets = fixture_secrets(FINGERPRINT)?;
|
|
let mut presentation = MemoryPresentation::default();
|
|
|
|
for (entry, channel) in [
|
|
("generated/clip", GeneratedPresentation::Clipboard),
|
|
("generated/qr", GeneratedPresentation::QrCode),
|
|
] {
|
|
let mut stdout = Vec::new();
|
|
let mut stderr = Vec::new();
|
|
assert_eq!(
|
|
execute_secure_with(
|
|
&config,
|
|
&CommandRequest::Generate(GenerateRequest {
|
|
entry: entry.to_owned(),
|
|
length: std::num::NonZeroUsize::new(16),
|
|
no_symbols: true,
|
|
force: false,
|
|
in_place: false,
|
|
presentation: channel,
|
|
}),
|
|
&mut secrets,
|
|
&mut presentation,
|
|
&mut stdout,
|
|
&mut stderr,
|
|
)
|
|
.expect("memory output cannot fail"),
|
|
EXIT_SUCCESS
|
|
);
|
|
let presented = match channel {
|
|
GeneratedPresentation::Clipboard => presentation.clipboard.last(),
|
|
GeneratedPresentation::QrCode => presentation.qr.last(),
|
|
GeneratedPresentation::Terminal => None,
|
|
}
|
|
.expect("generated secret was presented");
|
|
assert_eq!(presented.len(), 16);
|
|
assert!(presented.iter().all(u8::is_ascii_alphanumeric));
|
|
assert!(
|
|
!stdout
|
|
.windows(presented.len())
|
|
.any(|part| part == presented)
|
|
);
|
|
assert!(stderr.is_empty());
|
|
assert!(vault.join(format!("{entry}.gpg")).is_file());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn otp_validate_runs_without_configuration_and_redacts_invalid_input() -> TestResult {
|
|
let valid = "otpauth://totp/account?secret=JBSWY3DPEHPK3PXP";
|
|
let mut stdout = Vec::new();
|
|
let mut stderr = Vec::new();
|
|
assert_eq!(
|
|
run_with(
|
|
["ironstorage", "otp", "validate", valid],
|
|
&mut stdout,
|
|
&mut stderr,
|
|
)
|
|
.expect("memory output cannot fail"),
|
|
EXIT_SUCCESS
|
|
);
|
|
assert!(stdout.is_empty());
|
|
assert!(stderr.is_empty());
|
|
|
|
let invalid = "otpauth://totp/account?secret=PRIVATE-NOT-BASE32";
|
|
assert_eq!(
|
|
run_with(
|
|
["ironstorage", "otp", "validate", invalid],
|
|
&mut stdout,
|
|
&mut stderr,
|
|
)
|
|
.expect("memory output cannot fail"),
|
|
EXIT_FAILURE
|
|
);
|
|
assert!(
|
|
!stderr
|
|
.windows(invalid.len())
|
|
.any(|part| part == invalid.as_bytes())
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn cli_otp_code_and_uri_use_secret_safe_presentation_channels() -> TestResult {
|
|
const FINGERPRINT: &str = "7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30";
|
|
let fixtures = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
|
.join("../../crates/storage/tests/fixtures/compatibility");
|
|
let temporary = tempfile::tempdir()?;
|
|
let config_path = temporary.path().join("config.toml");
|
|
fs::write(
|
|
&config_path,
|
|
format!(
|
|
"vault = {:?}\ndefault_key = {:?}\nkey_material = {:?}\nclipboard_timeout_seconds = 1\n",
|
|
fixtures.join("stores/basic"),
|
|
FINGERPRINT,
|
|
fixtures.join("keys"),
|
|
),
|
|
)?;
|
|
let config = Config::load(Some(&config_path))?;
|
|
let mut secrets = fixture_secrets(FINGERPRINT)?;
|
|
let mut presentation = MemoryPresentation::default();
|
|
let mut interaction = MemoryOtpInteraction::default();
|
|
|
|
let mut stdout = Vec::new();
|
|
let mut stderr = Vec::new();
|
|
assert_eq!(
|
|
execute_secure_with_services(
|
|
&config,
|
|
&CommandRequest::Otp(OtpRequest::Code(OtpCodeRequest {
|
|
entry: "otp/totp".to_owned(),
|
|
clipboard: true,
|
|
})),
|
|
&mut secrets,
|
|
&mut presentation,
|
|
&mut interaction,
|
|
|| Ok(59),
|
|
&mut stdout,
|
|
&mut stderr,
|
|
)
|
|
.expect("memory output cannot fail"),
|
|
EXIT_SUCCESS
|
|
);
|
|
let code = presentation.clipboard.last().expect("clipboard code");
|
|
assert_eq!(code.len(), 6);
|
|
assert!(code.iter().all(u8::is_ascii_digit));
|
|
assert!(!stdout.windows(code.len()).any(|part| part == code));
|
|
assert!(stderr.is_empty());
|
|
|
|
stdout.clear();
|
|
let uri = fs::read(fixtures.join("expected/basic/otp/totp.txt"))?;
|
|
let uri = uri
|
|
.split(|byte| *byte == b'\n')
|
|
.find(|line| line.starts_with(b"otpauth://"))
|
|
.expect("fixture URI");
|
|
assert_eq!(
|
|
execute_secure_with_services(
|
|
&config,
|
|
&CommandRequest::Otp(OtpRequest::Uri(OtpUriRequest {
|
|
entry: "otp/totp".to_owned(),
|
|
presentation: OtpUriPresentation::QrCode,
|
|
})),
|
|
&mut secrets,
|
|
&mut presentation,
|
|
&mut interaction,
|
|
|| Ok(59),
|
|
&mut stdout,
|
|
&mut stderr,
|
|
)
|
|
.expect("memory output cannot fail"),
|
|
EXIT_SUCCESS
|
|
);
|
|
assert_eq!(presentation.qr.last().map(Vec::as_slice), Some(uri));
|
|
assert!(!stdout.windows(uri.len()).any(|part| part == uri));
|
|
assert!(stderr.is_empty());
|
|
|
|
stdout.clear();
|
|
assert_eq!(
|
|
execute_secure_with_services(
|
|
&config,
|
|
&CommandRequest::Otp(OtpRequest::Uri(OtpUriRequest {
|
|
entry: "otp/totp".to_owned(),
|
|
presentation: OtpUriPresentation::Clipboard,
|
|
})),
|
|
&mut secrets,
|
|
&mut presentation,
|
|
&mut interaction,
|
|
|| Ok(59),
|
|
&mut stdout,
|
|
&mut stderr,
|
|
)
|
|
.expect("memory output cannot fail"),
|
|
EXIT_SUCCESS
|
|
);
|
|
assert_eq!(presentation.clipboard.last().map(Vec::as_slice), Some(uri));
|
|
assert!(!stdout.windows(uri.len()).any(|part| part == uri));
|
|
assert!(stderr.is_empty());
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn cli_otp_insert_append_and_hotp_increment_mutate_encrypted_entries() -> TestResult {
|
|
const FINGERPRINT: &str = "7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30";
|
|
let fixtures = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
|
.join("../../crates/storage/tests/fixtures/compatibility");
|
|
let temporary = tempfile::tempdir()?;
|
|
let vault = temporary.path().join("vault");
|
|
fs::create_dir_all(vault.join("email"))?;
|
|
fs::create_dir_all(vault.join("otp"))?;
|
|
for path in [
|
|
".gpg-id",
|
|
".gpg-id.sig",
|
|
"email/personal.gpg",
|
|
"otp/hotp.gpg",
|
|
] {
|
|
fs::copy(fixtures.join("stores/basic").join(path), vault.join(path))?;
|
|
}
|
|
let config_path = temporary.path().join("config.toml");
|
|
fs::write(
|
|
&config_path,
|
|
format!(
|
|
"vault = {:?}\ndefault_key = {:?}\nkey_material = {:?}\nclipboard_timeout_seconds = 1\n",
|
|
vault,
|
|
FINGERPRINT,
|
|
fixtures.join("keys"),
|
|
),
|
|
)?;
|
|
let config = Config::load(Some(&config_path))?;
|
|
let mut secrets = fixture_secrets(FINGERPRINT)?;
|
|
let mut presentation = MemoryPresentation::default();
|
|
let mut interaction = MemoryOtpInteraction {
|
|
terminal: true,
|
|
..MemoryOtpInteraction::default()
|
|
};
|
|
interaction.inputs.push_back(OtpInput::hidden(
|
|
b"JBSWY3DPEHPK3PXP".to_vec(),
|
|
b"JBSWY3DPEHPK3PXP".to_vec(),
|
|
)?);
|
|
interaction.decisions.push_back(OverwriteDecision::Allow);
|
|
let mut stdout = Vec::new();
|
|
let mut stderr = Vec::new();
|
|
assert_eq!(
|
|
execute_secure_with_services(
|
|
&config,
|
|
&CommandRequest::Otp(OtpRequest::Insert(OtpInsertRequest {
|
|
entry: None,
|
|
force: false,
|
|
echo: false,
|
|
source: OtpInputSource::Secret {
|
|
issuer: Some("Issuer".to_owned()),
|
|
account: Some("account".to_owned()),
|
|
},
|
|
})),
|
|
&mut secrets,
|
|
&mut presentation,
|
|
&mut interaction,
|
|
|| Ok(0),
|
|
&mut stdout,
|
|
&mut stderr,
|
|
)
|
|
.expect("memory output cannot fail"),
|
|
EXIT_SUCCESS
|
|
);
|
|
assert_eq!(interaction.plans, vec![InputPlan::HiddenConfirmed]);
|
|
assert!(vault.join("Issuer/account.gpg").is_file());
|
|
|
|
interaction.terminal = false;
|
|
interaction.inputs.push_back(OtpInput::line(
|
|
b"otpauth://totp/New:alice?secret=JBSWY3DPEHPK3PXP&issuer=New".to_vec(),
|
|
)?);
|
|
assert_eq!(
|
|
execute_secure_with_services(
|
|
&config,
|
|
&CommandRequest::Otp(OtpRequest::Append(OtpAppendRequest {
|
|
entry: "email/personal".to_owned(),
|
|
force: false,
|
|
echo: false,
|
|
source: OtpInputSource::Uri,
|
|
})),
|
|
&mut secrets,
|
|
&mut presentation,
|
|
&mut interaction,
|
|
|| Ok(0),
|
|
&mut stdout,
|
|
&mut stderr,
|
|
)
|
|
.expect("memory output cannot fail"),
|
|
EXIT_SUCCESS
|
|
);
|
|
assert_eq!(
|
|
interaction.plans.last(),
|
|
Some(&InputPlan::StandardInputLine)
|
|
);
|
|
|
|
stdout.clear();
|
|
assert_eq!(
|
|
execute_secure_with_services(
|
|
&config,
|
|
&CommandRequest::Otp(OtpRequest::Code(OtpCodeRequest {
|
|
entry: "otp/hotp".to_owned(),
|
|
clipboard: false,
|
|
})),
|
|
&mut secrets,
|
|
&mut presentation,
|
|
&mut interaction,
|
|
|| Ok(0),
|
|
&mut stdout,
|
|
&mut stderr,
|
|
)
|
|
.expect("memory output cannot fail"),
|
|
EXIT_SUCCESS
|
|
);
|
|
assert_eq!(stdout.len(), 9);
|
|
assert!(stdout[..8].iter().all(u8::is_ascii_digit));
|
|
|
|
let repository = Repository::open(&vault)?;
|
|
let keys = ironstorage::crypto::KeyStore::load(fixtures.join("keys"))?;
|
|
let derived = keys.decrypt(
|
|
&repository.read_entry(&EntryPath::parse("Issuer/account")?)?,
|
|
&mut secrets,
|
|
)?;
|
|
assert_eq!(
|
|
derived.expose(),
|
|
b"otpauth://totp/Issuer:account?secret=JBSWY3DPEHPK3PXP&issuer=Issuer\n"
|
|
);
|
|
let appended = keys.decrypt(
|
|
&repository.read_entry(&EntryPath::parse("email/personal")?)?,
|
|
&mut secrets,
|
|
)?;
|
|
assert!(
|
|
appended
|
|
.expose()
|
|
.windows(14)
|
|
.any(|part| part == b"otpauth://totp")
|
|
);
|
|
let hotp = keys.decrypt(
|
|
&repository.read_entry(&EntryPath::parse("otp/hotp")?)?,
|
|
&mut secrets,
|
|
)?;
|
|
assert!(hotp.expose().windows(9).any(|part| part == b"counter=1"));
|
|
assert!(stderr.is_empty());
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn complete_cli_dispatch_runs_storage_workflows_and_automatic_commits() -> TestResult {
|
|
const FINGERPRINT: &str = "7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30";
|
|
let fixtures = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
|
.join("../../crates/storage/tests/fixtures/compatibility");
|
|
let temporary = tempfile::tempdir()?;
|
|
let vault = temporary.path().join("vault");
|
|
fs::create_dir_all(vault.join("email"))?;
|
|
for path in [".gpg-id", ".gpg-id.sig", "email/personal.gpg"] {
|
|
fs::copy(fixtures.join("stores/basic").join(path), vault.join(path))?;
|
|
}
|
|
let config_path = temporary.path().join("config.toml");
|
|
fs::write(
|
|
&config_path,
|
|
format!(
|
|
"vault = {:?}\ndefault_key = {:?}\nkey_material = {:?}\n",
|
|
vault,
|
|
FINGERPRINT,
|
|
fixtures.join("keys"),
|
|
),
|
|
)?;
|
|
let config = Config::load(Some(&config_path))?;
|
|
let mut stdout = Vec::new();
|
|
let mut stderr = Vec::new();
|
|
|
|
assert_eq!(
|
|
execute_local(
|
|
&config,
|
|
&CommandRequest::Git(GitRequest::Init),
|
|
&mut stdout,
|
|
&mut stderr,
|
|
)
|
|
.expect("memory output cannot fail"),
|
|
EXIT_SUCCESS
|
|
);
|
|
stdout.clear();
|
|
assert_eq!(
|
|
execute_local(
|
|
&config,
|
|
&CommandRequest::List(ListRequest { path: None }),
|
|
&mut stdout,
|
|
&mut stderr,
|
|
)
|
|
.expect("memory output cannot fail"),
|
|
EXIT_SUCCESS
|
|
);
|
|
assert!(stdout.windows(5).any(|part| part == b"email"));
|
|
stdout.clear();
|
|
assert_eq!(
|
|
execute_local(
|
|
&config,
|
|
&CommandRequest::Find(FindRequest {
|
|
terms: vec!["PERSONAL".to_owned()],
|
|
}),
|
|
&mut stdout,
|
|
&mut stderr,
|
|
)
|
|
.expect("memory output cannot fail"),
|
|
EXIT_SUCCESS
|
|
);
|
|
assert!(stdout.windows(8).any(|part| part == b"personal"));
|
|
|
|
let mut secrets = fixture_secrets(FINGERPRINT)?;
|
|
let mut presentation = MemoryPresentation::default();
|
|
let mut interaction = MemoryOtpInteraction::default();
|
|
stdout.clear();
|
|
assert_eq!(
|
|
execute_secure_with_services(
|
|
&config,
|
|
&CommandRequest::Grep(GrepRequest {
|
|
pattern: "alice@example\\.test".to_owned(),
|
|
ignore_case: false,
|
|
invert_match: false,
|
|
line_number: false,
|
|
fixed_strings: false,
|
|
}),
|
|
&mut secrets,
|
|
&mut presentation,
|
|
&mut interaction,
|
|
|| Ok(0),
|
|
&mut stdout,
|
|
&mut stderr,
|
|
)
|
|
.expect("memory output cannot fail"),
|
|
EXIT_SUCCESS
|
|
);
|
|
assert!(stdout.windows(14).any(|part| part == b"email/personal"));
|
|
|
|
assert_eq!(
|
|
execute_secure_with_services(
|
|
&config,
|
|
&CommandRequest::Init(InitRequest {
|
|
path: Some("nested".to_owned()),
|
|
key_identities: vec![FINGERPRINT.to_owned()],
|
|
}),
|
|
&mut secrets,
|
|
&mut presentation,
|
|
&mut interaction,
|
|
|| Ok(0),
|
|
&mut stdout,
|
|
&mut stderr,
|
|
)
|
|
.expect("memory output cannot fail"),
|
|
EXIT_SUCCESS
|
|
);
|
|
interaction
|
|
.insert_inputs
|
|
.push_back(InsertContent::echoed(b"first secret".to_vec())?);
|
|
assert_eq!(
|
|
execute_secure_with_services(
|
|
&config,
|
|
&CommandRequest::Insert(InsertRequest {
|
|
entry: "nested/new".to_owned(),
|
|
input: InsertInput::EchoedLine,
|
|
force: false,
|
|
}),
|
|
&mut secrets,
|
|
&mut presentation,
|
|
&mut interaction,
|
|
|| Ok(0),
|
|
&mut stdout,
|
|
&mut stderr,
|
|
)
|
|
.expect("memory output cannot fail"),
|
|
EXIT_SUCCESS
|
|
);
|
|
interaction
|
|
.edit_replacements
|
|
.push_back(SecretBytes::new(b"edited secret\nmetadata".to_vec()));
|
|
assert_eq!(
|
|
execute_secure_with_services(
|
|
&config,
|
|
&CommandRequest::Edit(EditRequest {
|
|
entry: "nested/new".to_owned(),
|
|
}),
|
|
&mut secrets,
|
|
&mut presentation,
|
|
&mut interaction,
|
|
|| Ok(0),
|
|
&mut stdout,
|
|
&mut stderr,
|
|
)
|
|
.expect("memory output cannot fail"),
|
|
EXIT_SUCCESS
|
|
);
|
|
for request in [
|
|
CommandRequest::Copy(CopyRequest {
|
|
source: "nested/new".to_owned(),
|
|
destination: "nested/copied".to_owned(),
|
|
force: false,
|
|
}),
|
|
CommandRequest::Move(MoveRequest {
|
|
source: "nested/copied".to_owned(),
|
|
destination: "nested/moved".to_owned(),
|
|
force: false,
|
|
}),
|
|
] {
|
|
assert_eq!(
|
|
execute_secure_with_services(
|
|
&config,
|
|
&request,
|
|
&mut secrets,
|
|
&mut presentation,
|
|
&mut interaction,
|
|
|| Ok(0),
|
|
&mut stdout,
|
|
&mut stderr,
|
|
)
|
|
.expect("memory output cannot fail"),
|
|
EXIT_SUCCESS
|
|
);
|
|
}
|
|
interaction.decisions.push_back(OverwriteDecision::Allow);
|
|
assert_eq!(
|
|
execute_secure_with_services(
|
|
&config,
|
|
&CommandRequest::Remove(RemoveRequest {
|
|
entry: "nested/moved".to_owned(),
|
|
recursive: false,
|
|
force: false,
|
|
}),
|
|
&mut secrets,
|
|
&mut presentation,
|
|
&mut interaction,
|
|
|| Ok(0),
|
|
&mut stdout,
|
|
&mut stderr,
|
|
)
|
|
.expect("memory output cannot fail"),
|
|
EXIT_SUCCESS
|
|
);
|
|
|
|
let repository = Repository::open(&vault)?;
|
|
let keys = ironstorage::crypto::KeyStore::load(fixtures.join("keys"))?;
|
|
let edited = keys.decrypt(
|
|
&repository.read_entry(&EntryPath::parse("nested/new")?)?,
|
|
&mut secrets,
|
|
)?;
|
|
assert_eq!(edited.expose(), b"edited secret\nmetadata");
|
|
assert!(!vault.join("nested/moved.gpg").exists());
|
|
|
|
stdout.clear();
|
|
assert_eq!(
|
|
execute_local(
|
|
&config,
|
|
&CommandRequest::Git(GitRequest::Status),
|
|
&mut stdout,
|
|
&mut stderr,
|
|
)
|
|
.expect("memory output cannot fail"),
|
|
EXIT_SUCCESS
|
|
);
|
|
assert_eq!(stdout, b"clean\n");
|
|
stdout.clear();
|
|
assert_eq!(
|
|
execute_local(
|
|
&config,
|
|
&CommandRequest::Git(GitRequest::Log { maximum: None }),
|
|
&mut stdout,
|
|
&mut stderr,
|
|
)
|
|
.expect("memory output cannot fail"),
|
|
EXIT_SUCCESS
|
|
);
|
|
let log = String::from_utf8(stdout.clone())?;
|
|
for message in [
|
|
"Set GPG id",
|
|
"Add given password",
|
|
"Edit password",
|
|
"Copy nested/new",
|
|
"Rename nested/copied",
|
|
"Remove nested/moved",
|
|
] {
|
|
assert!(log.contains(message), "missing automatic commit {message}");
|
|
}
|
|
let entry = EntryPath::parse("nested/new")?;
|
|
let recipients = ironstorage::recipient::RecipientPolicyManager::new(&repository, &keys)
|
|
.resolve_for_entry(&entry, None)?;
|
|
let replacement = keys.encrypt(
|
|
SecretBytes::new(b"changed secret\nmetadata".to_vec()),
|
|
recipients.recipients(),
|
|
)?;
|
|
repository.write_entry(&entry, &replacement)?;
|
|
stdout.clear();
|
|
assert_eq!(
|
|
execute_secure_with_services(
|
|
&config,
|
|
&CommandRequest::Git(GitRequest::Diff {
|
|
paths: vec!["nested/new.gpg".to_owned()],
|
|
}),
|
|
&mut secrets,
|
|
&mut presentation,
|
|
&mut interaction,
|
|
|| Ok(0),
|
|
&mut stdout,
|
|
&mut stderr,
|
|
)
|
|
.expect("memory output cannot fail"),
|
|
EXIT_SUCCESS
|
|
);
|
|
assert!(
|
|
stdout
|
|
.windows(b"-edited secret\n".len())
|
|
.any(|part| part == b"-edited secret\n")
|
|
);
|
|
assert!(
|
|
stdout
|
|
.windows(b"+changed secret\n".len())
|
|
.any(|part| part == b"+changed secret\n")
|
|
);
|
|
assert!(!stdout.windows(5).any(|part| part == b"-----"));
|
|
assert!(stderr.is_empty());
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn completion_output_and_source_process_boundary_are_audited() -> TestResult {
|
|
let mut stdout = Vec::new();
|
|
let mut stderr = Vec::new();
|
|
assert_eq!(
|
|
run_with(
|
|
["ironstorage", "completion", "bash"],
|
|
&mut stdout,
|
|
&mut stderr,
|
|
)
|
|
.expect("memory output cannot fail"),
|
|
EXIT_SUCCESS
|
|
);
|
|
assert!(stdout.windows(11).any(|part| part == b"ironstorage"));
|
|
assert!(stderr.is_empty());
|
|
|
|
let workspace = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
|
|
let mut rust_sources = Vec::new();
|
|
collect_rust_sources(&workspace.join("apps"), &mut rust_sources)?;
|
|
collect_rust_sources(&workspace.join("crates"), &mut rust_sources)?;
|
|
let unsafe_block = ["unsafe", " {"].concat();
|
|
let unsafe_function = ["unsafe", " fn"].concat();
|
|
let unsafe_implementation = ["unsafe", " impl"].concat();
|
|
let command_constructor = ["Command", "::new("].concat();
|
|
let process_command = ["process", "::Command"].concat();
|
|
for source in rust_sources {
|
|
let contents = fs::read_to_string(&source)?;
|
|
assert!(
|
|
!contents.contains(&unsafe_block),
|
|
"forbidden block in {}",
|
|
source.display()
|
|
);
|
|
assert!(
|
|
!contents.contains(&unsafe_function),
|
|
"forbidden function in {}",
|
|
source.display()
|
|
);
|
|
assert!(
|
|
!contents.contains(&unsafe_implementation),
|
|
"forbidden implementation in {}",
|
|
source.display()
|
|
);
|
|
if contents.contains(&command_constructor) || contents.contains(&process_command) {
|
|
assert!(
|
|
source.ends_with("apps/cli/src/editor.rs"),
|
|
"process launch outside editor adapter: {}",
|
|
source.display()
|
|
);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn collect_rust_sources(
|
|
directory: &std::path::Path,
|
|
output: &mut Vec<std::path::PathBuf>,
|
|
) -> std::io::Result<()> {
|
|
for entry in fs::read_dir(directory)? {
|
|
let path = entry?.path();
|
|
if path.is_dir() {
|
|
collect_rust_sources(&path, output)?;
|
|
} else if path.extension().and_then(std::ffi::OsStr::to_str) == Some("rs") {
|
|
output.push(path);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn fixture_secrets(fingerprint: &str) -> Result<SecretStore<MemoryBackend>, SecretStoreError> {
|
|
let secrets = SecretStore::new(
|
|
MemoryBackend::default(),
|
|
SecretCachePolicy::Disabled,
|
|
SecretProtectionPolicy::device_unlocked(),
|
|
);
|
|
secrets.unlock()?;
|
|
secrets.create(
|
|
&SecretReference::openpgp_passphrase(fingerprint)?,
|
|
SecretBytes::new(b"fixture-alice-passphrase".to_vec()),
|
|
)?;
|
|
Ok(secrets)
|
|
}
|
|
}
|