From 2f1a13b6c848abee6e36606a449a6fedf32f6142 Mon Sep 17 00:00:00 2001 From: Ross Andrews Date: Thu, 14 May 2026 19:07:48 -0500 Subject: [PATCH] tests --- src/main.rs | 7 +- src/parsing.rs | 191 +++++++++++++++++-------------------------------- src/utils.rs | 82 +++++++++++++++++++++ test.sh | 122 +++++++++++++++++++++++++++++++ 4 files changed, 275 insertions(+), 127 deletions(-) create mode 100644 src/utils.rs create mode 100755 test.sh diff --git a/src/main.rs b/src/main.rs index 13df3f8..af5ec98 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,13 +2,18 @@ use crate::parsing::parse_args; mod parsing; mod error; +mod utils; fn main() { match parse_args(std::env::args_os()) { - Err(e) => eprintln!("{e}"), + Err(e) => { + eprintln!("{e}"); + std::process::exit(1); + } Ok(cmd) => { if let Err(e) = cmd.perform() { eprintln!("{e}"); + std::process::exit(1); } } } diff --git a/src/parsing.rs b/src/parsing.rs index cb12a5e..6237bc9 100644 --- a/src/parsing.rs +++ b/src/parsing.rs @@ -1,13 +1,12 @@ use std::ffi::OsString; -use std::io::Write; use std::path::{Path, PathBuf}; -use age::ssh::Identity; use flate2::Compression; use flate2::read::GzDecoder; use flate2::write::GzEncoder; use clap::{Parser, Subcommand}; use ssh_key::{Algorithm, LineEnding, PrivateKey}; use crate::error::DrawerError; +use crate::utils::*; #[derive(Parser)] #[command(name = "drawer", about = "Encrypt and decrypt project directories as drawer files")] @@ -28,6 +27,11 @@ pub enum Command { Info(InfoOperation), } +pub trait Operation { + fn perform(&self) -> Result<(), DrawerError>; + fn validate(&self) -> Result<(), DrawerError>; +} + impl Command { pub fn perform(&self) -> Result<(), DrawerError> { match self { @@ -79,84 +83,6 @@ where Ok(cli.command) } -fn key_path(key: &Option) -> Result { - if let Some(k) = key { - return Ok(k.clone()); - } - let val = std::env::var("DRAWER_KEY").map_err(|_| DrawerError::NoKeyPath)?; - Ok(PathBuf::from(val)) -} - -fn identity(key: &Option, drawer_file: &Path) -> Result<(PathBuf, Identity), DrawerError> { - let kp = key_path(key)?; - let file = std::fs::File::open(&kp) - .map_err(|_| DrawerError::KeyInvalid(kp.clone()))?; - let reader = std::io::BufReader::new(file); - let id = age::ssh::Identity::from_buffer(reader, None) - .map_err(|_| DrawerError::DecryptFailed(drawer_file.to_path_buf(), kp.clone()))?; - Ok((kp, id)) -} - -fn decrypt(encrypted: Vec, key: &Option, drawer_file: &Path) -> Result, DrawerError> { - let (kp, id) = identity(key, drawer_file)?; - let decryptor = age::Decryptor::new(encrypted.as_slice()) - .map_err(|_| DrawerError::DecryptFailed(drawer_file.to_path_buf(), kp.clone()))?; - let mut decrypted: Vec = Vec::new(); - let mut stream = decryptor.decrypt(std::iter::once(&id as &dyn age::Identity)) - .map_err(|_| DrawerError::DecryptFailed(drawer_file.to_path_buf(), kp.clone()))?; - std::io::Read::read_to_end(&mut stream, &mut decrypted) - .map_err(|_| DrawerError::DecryptFailed(drawer_file.to_path_buf(), kp.clone()))?; - Ok(decrypted) -} - -fn encrypt(buf: &[u8], key: &Option, drawer_file: &Path) -> Result, DrawerError> { - let (_, id) = identity(key, drawer_file)?; - let recipient = age::ssh::Recipient::try_from(id) - .map_err(|_| DrawerError::EncryptFailed)?; - let mut encrypted: Vec = Vec::new(); - let encryptor = age::Encryptor::with_recipients( - std::iter::once(&recipient as &dyn age::Recipient) - ).map_err(|_| DrawerError::EncryptFailed)?; - let mut writer = encryptor.wrap_output(&mut encrypted) - .map_err(|_| DrawerError::EncryptFailed)?; - writer.write_all(buf).map_err(|_| DrawerError::EncryptFailed)?; - writer.finish().map_err(|_| DrawerError::EncryptFailed)?; - Ok(encrypted) -} - -fn default_target(drawer_file: &Path, single_file: Option<&Path>) -> PathBuf { - match single_file { - Some(p) => PathBuf::from(p.file_name().unwrap_or_default()), - None => PathBuf::from(drawer_file.file_stem().unwrap_or_default()), - } -} - -fn validate_drawer_extension(drawer_file: &Path) -> Result<(), DrawerError> { - if drawer_file.extension().and_then(|e| e.to_str()) != Some("drawer") { - return Err(DrawerError::DrawerFileInvalidExtension(drawer_file.to_path_buf())); - } - Ok(()) -} - -fn validate_drawer_file_exists(drawer_file: &Path) -> Result<(), DrawerError> { - if !drawer_file.exists() { - return Err(DrawerError::DrawerFileNotFound(drawer_file.to_path_buf())); - } - Ok(()) -} - -fn validate_ssh_key(key: &Option) -> Result<(), DrawerError> { - let kp = key_path(key)?; - let file = std::fs::File::open(&kp) - .map_err(|_| DrawerError::KeyInvalid(kp.clone()))?; - let reader = std::io::BufReader::new(file); - let id = age::ssh::Identity::from_buffer(reader, None) - .map_err(|_| DrawerError::KeyNotValidSsh)?; - age::ssh::Recipient::try_from(id) - .map_err(|_| DrawerError::KeyNotValidSsh)?; - Ok(()) -} - enum ArchiveInfo { SingleFile { size: u64 }, Folder { file_count: usize }, @@ -205,11 +131,16 @@ fn single_file_entry(data: &[u8]) -> Option { Some(entry.path().ok()?.into_owned()) } -impl OpenOperation { - pub fn perform(&self) -> Result<(), DrawerError> { +impl Operation for OpenOperation { + fn validate(&self) -> Result<(), DrawerError> { validate_drawer_extension(&self.drawer_file)?; validate_ssh_key(&self.key)?; validate_drawer_file_exists(&self.drawer_file)?; + Ok(()) + } + + fn perform(&self) -> Result<(), DrawerError> { + self.validate()?; let encrypted = std::fs::read(&self.drawer_file) .map_err(|_| DrawerError::DrawerFileNotFound(self.drawer_file.clone()))?; @@ -254,39 +185,10 @@ impl OpenOperation { } } -impl CloseOperation { - pub fn perform(&self) -> Result<(), DrawerError> { +impl Operation for CloseOperation { + fn validate(&self) -> Result<(), DrawerError> { validate_drawer_extension(&self.drawer_file)?; validate_ssh_key(&self.key)?; - self.validate_close()?; - - let target = self.target_path.as_ref().cloned() - .unwrap_or_else(|| default_target(&self.drawer_file, None)); - - let mut buf: Vec = Vec::new(); - { - let gz = GzEncoder::new(&mut buf, Compression::default()); - let mut archive = tar::Builder::new(gz); - if target.is_dir() { - archive.append_dir_all(".", &target) - .map_err(|_| DrawerError::TarFailed)?; - } else { - let name = target.file_name().unwrap_or_default(); - archive.append_path_with_name(&target, name) - .map_err(|_| DrawerError::TarFailed)?; - } - let gz = archive.into_inner() - .map_err(|_| DrawerError::TarFailed)?; - gz.finish() - .map_err(|_| DrawerError::TarFailed)?; - } - - let encrypted = encrypt(&buf, &self.key, &self.drawer_file)?; - std::fs::write(&self.drawer_file, &encrypted) - .map_err(|_| DrawerError::WriteFailed(self.drawer_file.clone())) - } - - fn validate_close(&self) -> Result<(), DrawerError> { let target = self.target_path.as_ref().cloned() .unwrap_or_else(|| default_target(&self.drawer_file, None)); if self.target_path.is_none() { @@ -315,20 +217,42 @@ impl CloseOperation { } Ok(()) } + + fn perform(&self) -> Result<(), DrawerError> { + self.validate()?; + + let target = self.target_path.as_ref().cloned() + .unwrap_or_else(|| default_target(&self.drawer_file, None)); + + let mut buf: Vec = Vec::new(); + { + let gz = GzEncoder::new(&mut buf, Compression::default()); + let mut archive = tar::Builder::new(gz); + if target.is_dir() { + archive.append_dir_all(".", &target) + .map_err(|_| DrawerError::TarFailed)?; + } else { + let name = target.file_name().unwrap_or_default(); + archive.append_path_with_name(&target, name) + .map_err(|_| DrawerError::TarFailed)?; + } + let gz = archive.into_inner() + .map_err(|_| DrawerError::TarFailed)?; + gz.finish() + .map_err(|_| DrawerError::TarFailed)?; + } + + let encrypted = encrypt(&buf, &self.key, &self.drawer_file)?; + std::fs::write(&self.drawer_file, &encrypted) + .map_err(|_| DrawerError::WriteFailed(self.drawer_file.clone())) + } } -impl KeyOperation { - pub fn perform(&self) -> Result<(), DrawerError> { - self.validate()?; - let private_key = PrivateKey::random(&mut rand::rngs::OsRng, Algorithm::Ed25519) - .map_err(|_| DrawerError::KeyGenerateFailed)?; - let pem = private_key.to_openssh(LineEnding::LF) - .map_err(|_| DrawerError::KeyGenerateFailed)?; - std::fs::write(&self.filename, pem.as_bytes()) - .map_err(|_| DrawerError::KeyOutputFailed(self.filename.clone())) - } - +impl Operation for KeyOperation { fn validate(&self) -> Result<(), DrawerError> { + if self.filename.exists() { + return Err(DrawerError::KeyOutputFailed(self.filename.clone())); + } let parent = match self.filename.parent() { None => Path::new("."), Some(p) if p == Path::new("") => Path::new("."), @@ -339,13 +263,28 @@ impl KeyOperation { } Ok(()) } + + fn perform(&self) -> Result<(), DrawerError> { + self.validate()?; + let private_key = PrivateKey::random(&mut rand::rngs::OsRng, Algorithm::Ed25519) + .map_err(|_| DrawerError::KeyGenerateFailed)?; + let pem = private_key.to_openssh(LineEnding::LF) + .map_err(|_| DrawerError::KeyGenerateFailed)?; + std::fs::write(&self.filename, pem.as_bytes()) + .map_err(|_| DrawerError::KeyOutputFailed(self.filename.clone())) + } } -impl InfoOperation { - pub fn perform(&self) -> Result<(), DrawerError> { +impl Operation for InfoOperation { + fn validate(&self) -> Result<(), DrawerError> { validate_drawer_extension(&self.drawer_file)?; validate_ssh_key(&self.key)?; validate_drawer_file_exists(&self.drawer_file)?; + Ok(()) + } + + fn perform(&self) -> Result<(), DrawerError> { + self.validate()?; let encrypted = std::fs::read(&self.drawer_file) .map_err(|_| DrawerError::DrawerFileNotFound(self.drawer_file.clone()))?; diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..275aa40 --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,82 @@ +use std::io::Write; +use std::path::{Path, PathBuf}; +use age::ssh::Identity; +use crate::error::DrawerError; + +pub fn key_path(key: &Option) -> Result { + if let Some(k) = key { + return Ok(k.clone()); + } + let val = std::env::var("DRAWER_KEY").map_err(|_| DrawerError::NoKeyPath)?; + Ok(PathBuf::from(val)) +} + +pub fn identity(key: &Option, drawer_file: &Path) -> Result<(PathBuf, Identity), DrawerError> { + let kp = key_path(key)?; + let file = std::fs::File::open(&kp) + .map_err(|_| DrawerError::KeyInvalid(kp.clone()))?; + let reader = std::io::BufReader::new(file); + let id = age::ssh::Identity::from_buffer(reader, None) + .map_err(|_| DrawerError::DecryptFailed(drawer_file.to_path_buf(), kp.clone()))?; + Ok((kp, id)) +} + +pub fn decrypt(encrypted: Vec, key: &Option, drawer_file: &Path) -> Result, DrawerError> { + let (kp, id) = identity(key, drawer_file)?; + let decryptor = age::Decryptor::new(encrypted.as_slice()) + .map_err(|_| DrawerError::DecryptFailed(drawer_file.to_path_buf(), kp.clone()))?; + let mut decrypted: Vec = Vec::new(); + let mut stream = decryptor.decrypt(std::iter::once(&id as &dyn age::Identity)) + .map_err(|_| DrawerError::DecryptFailed(drawer_file.to_path_buf(), kp.clone()))?; + std::io::Read::read_to_end(&mut stream, &mut decrypted) + .map_err(|_| DrawerError::DecryptFailed(drawer_file.to_path_buf(), kp.clone()))?; + Ok(decrypted) +} + +pub fn encrypt(buf: &[u8], key: &Option, drawer_file: &Path) -> Result, DrawerError> { + let (_, id) = identity(key, drawer_file)?; + let recipient = age::ssh::Recipient::try_from(id) + .map_err(|_| DrawerError::EncryptFailed)?; + let mut encrypted: Vec = Vec::new(); + let encryptor = age::Encryptor::with_recipients( + std::iter::once(&recipient as &dyn age::Recipient) + ).map_err(|_| DrawerError::EncryptFailed)?; + let mut writer = encryptor.wrap_output(&mut encrypted) + .map_err(|_| DrawerError::EncryptFailed)?; + writer.write_all(buf).map_err(|_| DrawerError::EncryptFailed)?; + writer.finish().map_err(|_| DrawerError::EncryptFailed)?; + Ok(encrypted) +} + +pub fn default_target(drawer_file: &Path, single_file: Option<&Path>) -> PathBuf { + match single_file { + Some(p) => PathBuf::from(p.file_name().unwrap_or_default()), + None => PathBuf::from(drawer_file.file_stem().unwrap_or_default()), + } +} + +pub fn validate_drawer_extension(drawer_file: &Path) -> Result<(), DrawerError> { + if drawer_file.extension().and_then(|e| e.to_str()) != Some("drawer") { + return Err(DrawerError::DrawerFileInvalidExtension(drawer_file.to_path_buf())); + } + Ok(()) +} + +pub fn validate_drawer_file_exists(drawer_file: &Path) -> Result<(), DrawerError> { + if !drawer_file.exists() { + return Err(DrawerError::DrawerFileNotFound(drawer_file.to_path_buf())); + } + Ok(()) +} + +pub fn validate_ssh_key(key: &Option) -> Result<(), DrawerError> { + let kp = key_path(key)?; + let file = std::fs::File::open(&kp) + .map_err(|_| DrawerError::KeyInvalid(kp.clone()))?; + let reader = std::io::BufReader::new(file); + let id = age::ssh::Identity::from_buffer(reader, None) + .map_err(|_| DrawerError::KeyNotValidSsh)?; + age::ssh::Recipient::try_from(id) + .map_err(|_| DrawerError::KeyNotValidSsh)?; + Ok(()) +} diff --git a/test.sh b/test.sh new file mode 100755 index 0000000..567495f --- /dev/null +++ b/test.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +set -euo pipefail + +DRAWER=./target/debug/drawer +TEST_DIR="$(cd "$(dirname "$0")/test" && pwd)" +WORK="$TMPDIR/drawer_test_$$" +mkdir -p "$WORK" + +pass() { echo "PASS: $1"; } +fail() { echo "FAIL: $1"; exit 1; } + +# Expect a command to succeed +ok() { + local label="$1"; shift + local rc=0 + "$@" 2>/dev/null || rc=$? + if [ "$rc" -eq 0 ]; then + pass "$label" + else + fail "$label (exited $rc)" + fi +} + +# Expect a command to fail +err() { + local label="$1"; shift + local rc=0 + "$@" 2>/dev/null || rc=$? + if [ "$rc" -eq 0 ]; then + fail "$label (expected failure but succeeded)" + else + pass "$label" + fi +} + +cargo build -q + +# ── key ────────────────────────────────────────────────────────────────────── + +KEY="$WORK/test.key" + +ok "key: generate" $DRAWER key "$KEY" +err "key: refuse existing file" $DRAWER key "$KEY" +err "key: refuse missing parent" $DRAWER key "$WORK/nosuchdir/test.key" + +# ── close ──────────────────────────────────────────────────────────────────── + +DRAWER_FILE="$WORK/test.drawer" + +ok "close: pack test dir" $DRAWER close -i "$KEY" "$DRAWER_FILE" "$TEST_DIR" +err "close: refuse without key" DRAWER_KEY="" $DRAWER close "$DRAWER_FILE" "$TEST_DIR" +err "close: refuse bad key" $DRAWER close -i "$WORK/notakey" "$DRAWER_FILE" "$TEST_DIR" +err "close: refuse bad extension" $DRAWER close -i "$KEY" "$WORK/test.notadrawer" "$TEST_DIR" + +# ── open ───────────────────────────────────────────────────────────────────── + +OUT="$WORK/out" +mkdir -p "$OUT" + +ok "open: unpack drawer" $DRAWER open -i "$KEY" "$DRAWER_FILE" "$OUT" +ok "open: file1.md present" test -f "$OUT/file1.md" +ok "open: file2.md present" test -f "$OUT/file2.md" +ok "open: contents match file1.md" diff "$TEST_DIR/file1.md" "$OUT/file1.md" +ok "open: contents match file2.md" diff "$TEST_DIR/file2.md" "$OUT/file2.md" + +err "open: refuse non-empty target" $DRAWER open -i "$KEY" "$DRAWER_FILE" "$OUT" +ok "open: force into non-empty" $DRAWER open -i "$KEY" -f "$DRAWER_FILE" "$OUT" + +err "open: refuse bad extension" $DRAWER open -i "$KEY" "$WORK/test.notadrawer" "$OUT" +err "open: refuse missing drawer" $DRAWER open -i "$KEY" "$WORK/nosuch.drawer" "$OUT" +err "open: refuse without key" $DRAWER open "$DRAWER_FILE" "$OUT" +err "open: refuse bad key" $DRAWER open -i "$WORK/notakey" "$DRAWER_FILE" "$OUT" + +# ── open: inferred target path ─────────────────────────────────────────────── + +INFER_DRAWER="$WORK/inferred.drawer" +INFER_OUT="$WORK/inferred" + +ok "close: inferred source" $DRAWER close -i "$KEY" "$INFER_DRAWER" "$TEST_DIR" +mkdir -p "$INFER_OUT" +ok "open: inferred target" $DRAWER open -i "$KEY" "$INFER_DRAWER" "$INFER_OUT" +ok "open: inferred file1.md" test -f "$INFER_OUT/file1.md" + +# ── info ───────────────────────────────────────────────────────────────────── + +$DRAWER info -i "$KEY" "$DRAWER_FILE" 2>/dev/null | grep -q "folder, 2 files" \ + && pass "info: reports folder" || fail "info: reports folder" +err "info: refuse bad extension" $DRAWER info -i "$KEY" "$WORK/test.notadrawer" +err "info: refuse missing drawer" $DRAWER info -i "$KEY" "$WORK/nosuch.drawer" +err "info: refuse without key" $DRAWER info "$DRAWER_FILE" + +# ── single-file drawer ─────────────────────────────────────────────────────── + +SINGLE_SRC="$TEST_DIR/file1.md" +SINGLE_DRAWER="$WORK/file1.drawer" +SINGLE_OUT_DIR="$WORK/single_out" +mkdir -p "$SINGLE_OUT_DIR" + +ok "close: single file" $DRAWER close -i "$KEY" "$SINGLE_DRAWER" "$SINGLE_SRC" +$DRAWER info -i "$KEY" "$SINGLE_DRAWER" 2>/dev/null | grep -q "file, " \ + && pass "info: reports single file" || fail "info: reports single file" +ok "open: single file" $DRAWER open -i "$KEY" "$SINGLE_DRAWER" "$SINGLE_OUT_DIR/file1.md" +ok "open: single file contents" diff "$SINGLE_SRC" "$SINGLE_OUT_DIR/file1.md" + +# ── close: drawer-inside-target guard ──────────────────────────────────────── + +# Pending +# err "close: refuse drawer inside target" $DRAWER close -i "$KEY" "$TEST_DIR/../test.drawer" "$TEST_DIR" + +# ── env var key ────────────────────────────────────────────────────────────── + +ENV_OUT="$WORK/env_out" +mkdir -p "$ENV_OUT" + +ok "open: key from DRAWER_KEY env" env DRAWER_KEY="$KEY" $DRAWER open "$DRAWER_FILE" "$ENV_OUT" +ok "open: env key file1.md" test -f "$ENV_OUT/file1.md" + +# ───────────────────────────────────────────────────────────────────────────── + +rm -rf "$WORK" +echo "" +echo "All tests passed."