Skip to content

Commit

Permalink
CLI: Infer format by extention.
Browse files Browse the repository at this point in the history
  • Loading branch information
gibbz00 committed Jan 3, 2024
1 parent bc55ba8 commit 8ffb2c0
Show file tree
Hide file tree
Showing 6 changed files with 135 additions and 56 deletions.
14 changes: 4 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,6 @@
- [ ] INI
- [ ] ENV
- [ ] BINARY
- [ ] Specify:
- [ ] By flag: `--file-format`.
- [ ] Infer by extension.
- [X] In library.
- [ ] Partial encryption
- [ ] CLI flag: `--{un,}encrypted-{suffix,regex} <pattern>`.
- [ ] `.rops.yaml`: `partial_encryption.{un,}encrypted.{ match: {regex,suffix}, pattern: "<pattern>" }`.
Expand Down Expand Up @@ -146,17 +142,13 @@ Many integrations already store their keys in a dedicated location. `rops` does
### Key management

- Key id retrieval
- [ ] As a CLI argument.
- [ ] In the `.rops.yaml` config.
- [ ] Specify with a `--config/-c` flag.
- [ ] Specify with a `$ROPS_CONFIG` environment variable.
- Private key retrieval
- [X] By an environment variable.
- [X] Multiple keys per variable.
- [ ] By key file location.
- [ ] Specify with a `--key-file INTEGRATION PATH` flag.
- [ ] Specify with a `$ROPS_INTEGRATION_KEY_FILE` environment variable.
- [ ] Official as fallback.
- [ ] Official as fallback.
- [ ] Built-in default location.
- Changes
- [ ] Update keys
Expand All @@ -177,13 +169,15 @@ Many integrations already store their keys in a dedicated location. `rops` does

- [ ] In place `$EDITOR` support (fallback to `vim`).
- [ ] Encrypt/Decrypt with `--in-place/-i`
- [ ] Encrypt/Decrypt with stdin.
- [ ] Show metadata `--show-metadata/-s`. Note that directly modifying the metadata will most likely break its integrity and prevent future decryption.

### Misc

- [ ] [Sub-process secret passing](https://github.com/getsops/sops#218passing-secrets-to-other-processes)
- [ ] Storing file comments.

#### `rops` exclusives
- [X] Encrypt/Decrypt with stdin.
- [ ] Compute an additional MAC over active integration keys to prevent against manual removal without rotating the secret data key.

### Preliminary non-goals
Expand Down
40 changes: 29 additions & 11 deletions crates/cli/src/args.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,23 @@
use std::path::PathBuf;

use clap::{Args, Parser, ValueEnum};
use clap::{Args, Parser, ValueEnum, ValueHint};
use rops::*;

#[derive(Parser)]
pub struct CliArgs {
#[command(subcommand)]
pub cmd: CliCommand,
/// Required if no file argument is given, may otherwise be inferred by file extension.
#[arg(long, short, global = true)]
pub format: Option<Format>,
/// Input may alternatively be supplied through stdin.
#[arg(global = true, value_hint = ValueHint::FilePath)]
pub file: Option<PathBuf>,
}

// use rops::RopsFileBuilder;
#[derive(Parser)]
pub enum CliArgs {
pub enum CliCommand {
Encrypt(EncryptArgs),
Decrypt(DecryptArgs),
}
Expand All @@ -15,20 +27,26 @@ pub struct EncryptArgs {
/// Space separated list of public age keys
#[arg(long = "age")]
pub age_keys: Vec<<AgeIntegration as Integration>::KeyId>,
#[arg(long, short)]
pub format: Format,
/// Input may alternatively be supplied through stdin
pub file: Option<PathBuf>,
}

#[derive(Args)]
pub struct DecryptArgs {
#[arg(long, short)]
pub format: Format,
}
pub struct DecryptArgs {}

#[derive(Clone, ValueEnum)]
#[derive(Debug, Clone, Copy, PartialEq, ValueEnum)]
pub enum Format {
#[value(alias = "yml")]
Yaml,
Json,
}

#[cfg(test)]
mod tests {
use clap::CommandFactory;

use super::*;

#[test]
fn verify_args() {
CliArgs::command().debug_assert()
}
}
16 changes: 15 additions & 1 deletion crates/cli/src/error.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,21 @@
#[derive(Debug, thiserror::Error)]
use std::path::PathBuf;

use thiserror::Error;

#[derive(Debug, PartialEq, Error)]
pub enum RopsCliError {
#[error("multiple inputs; recieved content from stdin when a file path was provided")]
MultipleInputs,
#[error("missing input; neither a file path nor stdin were provided")]
MissingInput,
#[error("unable to determine input format; {0}")]
UndeterminedFormat(#[from] UndeterminedFormatError),
}

#[derive(Debug, PartialEq, Error)]
pub enum UndeterminedFormatError {
#[error("found neither format nor file arguments")]
FoundNeither,
#[error("unable to determine file extension for {0} when no format argument was found")]
NoFileExtention(PathBuf),
}
2 changes: 1 addition & 1 deletion crates/cli/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
mod error;
pub use error::RopsCliError;
pub use error::{RopsCliError, UndeterminedFormatError};

mod args;
pub use args::*;
Expand Down
104 changes: 79 additions & 25 deletions crates/cli/src/run.rs
Original file line number Diff line number Diff line change
@@ -1,37 +1,23 @@
use std::io::{IsTerminal, Read};
use std::{
io::{IsTerminal, Read},
path::Path,
};

use anyhow::bail;
use clap::Parser;
use clap::{Parser, ValueEnum};
use rops::{AgeIntegration, EncryptedFile, FileFormat, JsonFileFormat, RopsFile, RopsFileBuilder, YamlFileFormat};

use crate::*;

pub fn run() -> anyhow::Result<()> {
let args = CliArgs::parse();

match args {
CliArgs::Encrypt(encrypt_args) => {
let mut stdin_guard = std::io::stdin().lock();

let plaintext_string = match &encrypt_args.file {
Some(plaintext_path) => {
if !stdin_guard.is_terminal() {
bail!(RopsCliError::MultipleInputs)
}
drop(stdin_guard);
std::fs::read_to_string(plaintext_path)?
}
None => {
if stdin_guard.is_terminal() {
bail!(RopsCliError::MissingInput)
}
let mut stdin_string = String::new();
stdin_guard.read_to_string(&mut stdin_string)?;
stdin_string
}
};
match args.cmd {
CliCommand::Encrypt(encrypt_args) => {
let explicit_file_path = args.file.as_deref();
let plaintext_string = get_plaintext_string(explicit_file_path)?;

match encrypt_args.format {
match get_format(explicit_file_path, args.format)? {
Format::Yaml => {
let encrypted_rops_file = encrypt_rops_file::<YamlFileFormat>(&plaintext_string, encrypt_args)?;
println!("{}", encrypted_rops_file);
Expand All @@ -52,8 +38,76 @@ pub fn run() -> anyhow::Result<()> {
.map_err(Into::into)
}
}
CliArgs::Decrypt(_) => todo!(),
CliCommand::Decrypt(_) => todo!(),
}

Ok(())
}

fn get_plaintext_string(file_path: Option<&Path>) -> anyhow::Result<String> {
let mut stdin_guard = std::io::stdin().lock();

let plaintext_string = match &file_path {
Some(plaintext_path) => {
if !stdin_guard.is_terminal() {
bail!(RopsCliError::MultipleInputs)
}
drop(stdin_guard);
std::fs::read_to_string(plaintext_path)?
}
None => {
if stdin_guard.is_terminal() {
bail!(RopsCliError::MissingInput)
}
let mut stdin_string = String::new();
stdin_guard.read_to_string(&mut stdin_string)?;
stdin_string
}
};

Ok(plaintext_string)
}

fn get_format(explicit_file_path: Option<&Path>, explicit_format: Option<Format>) -> Result<Format, RopsCliError> {
match explicit_format {
Some(format) => Ok(format),
None => match explicit_file_path {
Some(file_path) => file_path
.extension()
.and_then(|file_extension| <Format as ValueEnum>::from_str(file_extension.to_str().expect("invalid unicode"), true).ok())
.ok_or_else(|| UndeterminedFormatError::NoFileExtention(file_path.to_path_buf()).into()),
None => Err(UndeterminedFormatError::FoundNeither.into()),
},
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn infers_format_by_extesion() {
assert_eq!(Format::Yaml, get_format(Some(Path::new("test.yaml")), None).unwrap())
}

#[test]
fn infers_format_by_extesion_alias() {
assert_eq!(Format::Yaml, get_format(Some(Path::new("test.yml")), None).unwrap())
}

#[test]
fn both_missing_is_undetermined_format() {
assert_eq!(
RopsCliError::UndeterminedFormat(UndeterminedFormatError::FoundNeither),
get_format(None, None).unwrap_err()
)
}

#[test]
fn errors_on_missing_file_extension() {
assert!(matches!(
get_format(Some(Path::new("test")), None).unwrap_err(),
RopsCliError::UndeterminedFormat(UndeterminedFormatError::NoFileExtention(_))
))
}
}
15 changes: 7 additions & 8 deletions crates/cli/tests/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ mod encrypt {

#[test]
fn disallows_both_stdin_and_file() {
let mut cmd = Command::package_command().encrypt_args();
let mut cmd = Command::package_command().encrypt();
cmd.arg("/tmp/file.txt");

let output = cmd.output().unwrap();
Expand All @@ -22,7 +22,7 @@ mod encrypt {

#[test]
fn disallows_missing_stdin_and_file() {
let mut cmd = Command::package_command().encrypt_args();
let mut cmd = Command::package_command().encrypt();

let output = cmd.spawn().unwrap().wait_with_output().unwrap();
output.assert_failure();
Expand All @@ -31,7 +31,7 @@ mod encrypt {

#[test]
fn encrypts_from_stdin() {
let mut cmd = Command::package_command().encrypt_args();
let mut cmd = Command::package_command().encrypt();
cmd.stdin(Stdio::piped());

let mut child = cmd.spawn().unwrap();
Expand All @@ -45,9 +45,8 @@ mod encrypt {

#[test]
fn encrypts_from_file() {
let mut cmd = Command::package_command().encrypt_args();
let mut cmd = Command::package_command().encrypt();
cmd.arg(age_example_plaintext_path().into_os_string());

assert_encrypted_output(cmd.spawn().unwrap().wait_with_output().unwrap());
}

Expand All @@ -66,9 +65,9 @@ mod encrypt {
}

#[rustfmt::skip]
trait EncryptCommand { fn encrypt_args(self) -> Self; }
trait EncryptCommand { fn encrypt(self) -> Self; }
impl EncryptCommand for Command {
fn encrypt_args(mut self) -> Self {
fn encrypt(mut self) -> Self {
self.arg("encrypt");
self.args(["--age", &<AgeIntegration as Integration>::KeyId::mock_display()]);
self.args(["--format", "yaml"]);
Expand Down Expand Up @@ -146,7 +145,7 @@ mod command_utils {
}
}

pub trait OutputExitAssertions {
pub trait OutputExitAssertions: OutputString {
fn assert_success(&self);
fn assert_failure(&self);
}
Expand Down

0 comments on commit 8ffb2c0

Please sign in to comment.