Compare commits
10 Commits
becab7abd6
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| ad41fa2460 | |||
| 16d8bd6cf6 | |||
| 8e4c2762cd | |||
| 04d9f22d41 | |||
| b6124537f9 | |||
| 4dd0c79b20 | |||
| 365e4bddcc | |||
| 2f1a13b6c8 | |||
| df5bfa85b4 | |||
| 7f7fcf6fa6 |
+155
@@ -0,0 +1,155 @@
|
||||
.TH DRAWER 1 "2026-05-15" "drawer" "User Commands"
|
||||
.SH NAME
|
||||
drawer \- encrypt and decrypt project directories
|
||||
.SH SYNOPSIS
|
||||
.B drawer open
|
||||
.RB [ \-i
|
||||
.IR key ]
|
||||
.RB [ \-f ]
|
||||
.I drawer_file
|
||||
.RI [ target_path ]
|
||||
.br
|
||||
.B drawer close
|
||||
.RB [ \-i
|
||||
.IR key ]
|
||||
.I target_path
|
||||
.RI [ drawer_file ]
|
||||
.br
|
||||
.B drawer info
|
||||
.RB [ \-i
|
||||
.IR key ]
|
||||
.I drawer_file
|
||||
.br
|
||||
.B drawer key
|
||||
.I filename
|
||||
.SH DESCRIPTION
|
||||
.B drawer
|
||||
stores project directories as encrypted archive files called drawers.
|
||||
Each drawer file is a gzip-compressed tar archive encrypted with an
|
||||
SSH public key using the age encryption format.
|
||||
Only the corresponding SSH private key can open it.
|
||||
.PP
|
||||
Drawer files must use the
|
||||
.B .drawer
|
||||
extension.
|
||||
The SSH key to use is specified with
|
||||
.B \-i
|
||||
or the environment variable
|
||||
.BR DRAWER_KEY .
|
||||
.SH SUBCOMMANDS
|
||||
.SS open
|
||||
Decrypt
|
||||
.I drawer_file
|
||||
and expand its contents into
|
||||
.IR target_path .
|
||||
.PP
|
||||
If
|
||||
.I target_path
|
||||
is omitted it is inferred: a single-file drawer extracts to a file
|
||||
named after the stored entry; a directory drawer extracts to a
|
||||
directory named after the drawer file stem.
|
||||
The inferred path is printed to standard output.
|
||||
.PP
|
||||
Fails if
|
||||
.I target_path
|
||||
already exists and is non-empty unless
|
||||
.B \-f
|
||||
is given.
|
||||
.TP
|
||||
.BI \-i " key"
|
||||
Path to the SSH private key. Overrides
|
||||
.BR DRAWER_KEY .
|
||||
.TP
|
||||
.B \-f
|
||||
Force extraction even if
|
||||
.I target_path
|
||||
is non-empty.
|
||||
.SS close
|
||||
Compress and encrypt
|
||||
.I target_path
|
||||
and write the result to
|
||||
.IR drawer_file .
|
||||
.I target_path
|
||||
may be a file or a directory.
|
||||
.PP
|
||||
If
|
||||
.I drawer_file
|
||||
is omitted it defaults to
|
||||
.I target_path
|
||||
with a
|
||||
.B .drawer
|
||||
extension appended.
|
||||
The inferred drawer file path is printed to standard output.
|
||||
.PP
|
||||
Fails if
|
||||
.I drawer_file
|
||||
would be written inside
|
||||
.IR target_path .
|
||||
.TP
|
||||
.BI \-i " key"
|
||||
Path to the SSH private key. Overrides
|
||||
.BR DRAWER_KEY .
|
||||
.SS info
|
||||
Decrypt
|
||||
.I drawer_file
|
||||
and print a one-line summary: whether it contains a single file or a
|
||||
directory, and the size or file count.
|
||||
.TP
|
||||
.BI \-i " key"
|
||||
Path to the SSH private key. Overrides
|
||||
.BR DRAWER_KEY .
|
||||
.SS key
|
||||
Generate a new Ed25519 SSH key and write it to
|
||||
.IR filename .
|
||||
Fails if
|
||||
.I filename
|
||||
already exists or its parent directory does not exist.
|
||||
.SH ENVIRONMENT
|
||||
.TP
|
||||
.B DRAWER_KEY
|
||||
Path to the SSH private key used for encryption and decryption.
|
||||
Overridden by
|
||||
.BR \-i .
|
||||
.SH EXAMPLES
|
||||
Generate a new key:
|
||||
.PP
|
||||
.RS
|
||||
.B drawer key ~/.keys/mykey
|
||||
.RE
|
||||
.PP
|
||||
Close a directory into a drawer (drawer file inferred):
|
||||
.PP
|
||||
.RS
|
||||
.B drawer close ~/projects/myproject
|
||||
.RE
|
||||
.PP
|
||||
Close with an explicit drawer file path:
|
||||
.PP
|
||||
.RS
|
||||
.B drawer close ~/projects/myproject ~/archive/myproject.drawer
|
||||
.RE
|
||||
.PP
|
||||
Open a drawer (target directory inferred):
|
||||
.PP
|
||||
.RS
|
||||
.B drawer open ~/archive/myproject.drawer
|
||||
.RE
|
||||
.PP
|
||||
Open a drawer into an explicit directory:
|
||||
.PP
|
||||
.RS
|
||||
.B drawer open -i ~/.keys/mykey ~/archive/myproject.drawer ~/projects/myproject
|
||||
.RE
|
||||
.PP
|
||||
Show what is inside a drawer:
|
||||
.PP
|
||||
.RS
|
||||
.B drawer info -i ~/.keys/mykey ~/archive/myproject.drawer
|
||||
.RE
|
||||
.SH EXIT STATUS
|
||||
.B drawer
|
||||
exits 0 on success and 1 on any error, printing a diagnostic message
|
||||
to standard error.
|
||||
.SH SEE ALSO
|
||||
.BR ssh-keygen (1),
|
||||
.BR age (1)
|
||||
@@ -0,0 +1,72 @@
|
||||
use std::path::PathBuf;
|
||||
use flate2::Compression;
|
||||
use flate2::write::GzEncoder;
|
||||
use clap::Parser;
|
||||
use crate::error::DrawerError;
|
||||
use crate::operation::Operation;
|
||||
use crate::utils::*;
|
||||
|
||||
#[derive(Parser, Debug, PartialEq)]
|
||||
pub struct CloseOperation {
|
||||
pub target_path: PathBuf,
|
||||
pub drawer_file: Option<PathBuf>,
|
||||
#[arg(short = 'i')]
|
||||
pub key: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Operation for CloseOperation {
|
||||
fn validate(&self) -> Result<(), DrawerError> {
|
||||
let drawer_file = self.drawer_file.as_ref().cloned()
|
||||
.unwrap_or_else(|| self.target_path.with_extension("drawer"));
|
||||
validate_drawer_extension(&drawer_file)?;
|
||||
validate_ssh_key(&self.key)?;
|
||||
let meta = std::fs::metadata(&self.target_path)
|
||||
.map_err(|_| DrawerError::TargetInvalid(self.target_path.clone()))?;
|
||||
if !meta.is_dir() && !meta.is_file() {
|
||||
return Err(DrawerError::TargetInvalid(self.target_path.clone()));
|
||||
}
|
||||
if meta.is_dir() && std::fs::read_dir(&self.target_path).is_err() {
|
||||
return Err(DrawerError::TargetInvalid(self.target_path.clone()));
|
||||
}
|
||||
let canon_target = self.target_path.canonicalize()
|
||||
.unwrap_or_else(|_| self.target_path.clone());
|
||||
let drawer_parent = parent_or_dot(&drawer_file);
|
||||
let canon_drawer = drawer_parent.canonicalize()
|
||||
.unwrap_or_else(|_| drawer_parent.to_path_buf())
|
||||
.join(drawer_file.file_name().unwrap_or_default());
|
||||
if canon_drawer.starts_with(&canon_target) {
|
||||
return Err(DrawerError::DrawerInsideTarget);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn execute(&self) -> Result<(), DrawerError> {
|
||||
let drawer_file = self.drawer_file.as_ref().cloned()
|
||||
.unwrap_or_else(|| self.target_path.with_extension("drawer"));
|
||||
if self.drawer_file.is_none() {
|
||||
println!("(creating {})", drawer_file.display());
|
||||
}
|
||||
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
{
|
||||
let gz = GzEncoder::new(&mut buf, Compression::default());
|
||||
let mut archive = tar::Builder::new(gz);
|
||||
if self.target_path.is_dir() {
|
||||
archive.append_dir_all(".", &self.target_path)
|
||||
.map_err(|_| DrawerError::TarFailed)?;
|
||||
} else {
|
||||
let name = self.target_path.file_name().unwrap_or_default();
|
||||
archive.append_path_with_name(&self.target_path, 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, &drawer_file)?;
|
||||
std::fs::write(&drawer_file, &encrypted)
|
||||
.map_err(|_| DrawerError::WriteFailed(drawer_file.clone()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
use std::path::PathBuf;
|
||||
use clap::Parser;
|
||||
use crate::error::DrawerError;
|
||||
use crate::operation::Operation;
|
||||
use crate::utils::*;
|
||||
|
||||
#[derive(Parser, Debug, PartialEq)]
|
||||
pub struct InfoOperation {
|
||||
pub drawer_file: PathBuf,
|
||||
#[arg(short = 'i')]
|
||||
pub key: Option<PathBuf>,
|
||||
}
|
||||
|
||||
enum ArchiveInfo {
|
||||
SingleFile { size: u64 },
|
||||
Folder { file_count: usize },
|
||||
}
|
||||
|
||||
fn archive_info(data: &[u8]) -> Result<ArchiveInfo, DrawerError> {
|
||||
let mut archive = archive_reader_for(data);
|
||||
let mut entries = archive.entries().map_err(|_| DrawerError::UntarFailed)?;
|
||||
|
||||
let first = entries.next()
|
||||
.ok_or(DrawerError::UntarFailed)?
|
||||
.map_err(|_| DrawerError::UntarFailed)?;
|
||||
|
||||
if first.header().entry_type().is_file() {
|
||||
let size = first.header().size().map_err(|_| DrawerError::UntarFailed)?;
|
||||
if entries.next().is_none() {
|
||||
return Ok(ArchiveInfo::SingleFile { size });
|
||||
}
|
||||
}
|
||||
|
||||
let mut archive = archive_reader_for(data);
|
||||
let file_count = archive.entries()
|
||||
.map_err(|_| DrawerError::UntarFailed)?
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.header().entry_type().is_file())
|
||||
.count();
|
||||
Ok(ArchiveInfo::Folder { file_count })
|
||||
}
|
||||
|
||||
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 execute(&self) -> Result<(), DrawerError> {
|
||||
let encrypted = std::fs::read(&self.drawer_file)
|
||||
.map_err(|_| DrawerError::DrawerFileNotFound(self.drawer_file.clone()))?;
|
||||
let decrypted = decrypt(encrypted, &self.key, &self.drawer_file)?;
|
||||
let info = archive_info(&decrypted)?;
|
||||
match info {
|
||||
ArchiveInfo::SingleFile { size } =>
|
||||
println!("{} (file, {} bytes)", self.drawer_file.display(), size),
|
||||
ArchiveInfo::Folder { file_count } =>
|
||||
println!("{} (folder, {} files)", self.drawer_file.display(), file_count),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
use std::path::PathBuf;
|
||||
use clap::Parser;
|
||||
use ssh_key::{Algorithm, LineEnding, PrivateKey};
|
||||
use crate::error::DrawerError;
|
||||
use crate::operation::Operation;
|
||||
use crate::utils::parent_or_dot;
|
||||
|
||||
#[derive(Parser, Debug, PartialEq)]
|
||||
pub struct KeyOperation {
|
||||
pub filename: PathBuf,
|
||||
}
|
||||
|
||||
impl Operation for KeyOperation {
|
||||
fn validate(&self) -> Result<(), DrawerError> {
|
||||
if self.filename.exists() {
|
||||
return Err(DrawerError::KeyOutputFailed(self.filename.clone()));
|
||||
}
|
||||
if !parent_or_dot(&self.filename).exists() {
|
||||
return Err(DrawerError::KeyOutputFailed(self.filename.clone()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn execute(&self) -> Result<(), DrawerError> {
|
||||
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()))
|
||||
}
|
||||
}
|
||||
+48
-35
@@ -2,13 +2,23 @@ use crate::parsing::parse_args;
|
||||
|
||||
mod parsing;
|
||||
mod error;
|
||||
mod utils;
|
||||
mod operation;
|
||||
mod open_operation;
|
||||
mod close_operation;
|
||||
mod key_operation;
|
||||
mod info_operation;
|
||||
|
||||
fn main() {
|
||||
match parse_args(std::env::args_os()) {
|
||||
Err(e) => eprintln!("{e}"),
|
||||
Ok(op) => {
|
||||
if let Err(e) = op.perform() {
|
||||
Err(e) => {
|
||||
eprintln!("{e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
Ok(cmd) => {
|
||||
if let Err(e) = cmd.perform() {
|
||||
eprintln!("{e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,23 +26,19 @@ fn main() {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::error::DrawerError;
|
||||
use crate::parsing::DrawerOperation;
|
||||
use crate::parsing::KeyType::*;
|
||||
use crate::parsing::OperationType::*;
|
||||
use std::path::PathBuf;
|
||||
use crate::parsing::{CloseOperation, Command, OpenOperation};
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_basic_close() {
|
||||
let op = parse_args(vec!["drawer", "close", "blah.drawer", "-i", "foo.key", "blah"]);
|
||||
let op = parse_args(vec!["drawer", "close", "blah", "-i", "foo.key", "blah.drawer"]);
|
||||
assert_eq!(op, Ok(
|
||||
DrawerOperation {
|
||||
operation_type: Close,
|
||||
drawer_file: "blah.drawer".into(),
|
||||
key: Path("foo.key".into()),
|
||||
target_path: Some("blah".into()),
|
||||
force: false,
|
||||
}
|
||||
Command::Close(CloseOperation{
|
||||
drawer_file: Some("blah.drawer".into()),
|
||||
key: Some(PathBuf::from("foo.key")),
|
||||
target_path: "blah".into(),
|
||||
})
|
||||
))
|
||||
}
|
||||
|
||||
@@ -40,45 +46,52 @@ mod tests {
|
||||
fn test_basic_open() {
|
||||
let op = parse_args(vec!["drawer", "open", "blah.drawer", "-i", "foo.key", "blah"]);
|
||||
assert_eq!(op, Ok(
|
||||
DrawerOperation {
|
||||
operation_type: Open,
|
||||
Command::Open(OpenOperation {
|
||||
drawer_file: "blah.drawer".into(),
|
||||
key: Path("foo.key".into()),
|
||||
key: Some(PathBuf::from("foo.key")),
|
||||
target_path: Some("blah".into()),
|
||||
force: false,
|
||||
}
|
||||
))
|
||||
)))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inferred_open() {
|
||||
let op = parse_args(vec!["drawer", "open", "blah.drawer", "-i", "foo.key"]);
|
||||
assert_eq!(op, Ok(
|
||||
DrawerOperation {
|
||||
operation_type: Open,
|
||||
Command::Open(OpenOperation {
|
||||
drawer_file: "blah.drawer".into(),
|
||||
key: Path("foo.key".into()),
|
||||
key: Some(PathBuf::from("foo.key")),
|
||||
target_path: None,
|
||||
force: false,
|
||||
}
|
||||
))
|
||||
)))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_key_env_variable() {
|
||||
let op = parse_args(vec!["drawer", "open", "blah.drawer", "blah"]);
|
||||
assert_eq!(op.unwrap().key, EnvVar)
|
||||
if let Ok(Command::Open(open)) = op {
|
||||
assert_eq!(open.key, None);
|
||||
} else {
|
||||
assert!(false, "expected Open command");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_drawer_filenames() {
|
||||
let op = parse_args(vec!["drawer", "open", "blah.notadrawer", "blah"]);
|
||||
assert_eq!(op.unwrap().validate(), Err(DrawerError::DrawerFileInvalidExtension("blah.notadrawer".into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_missing_key() {
|
||||
let op = parse_args(vec!["drawer", "open", "blah.drawer", "-i", "notthere", "blah"]);
|
||||
assert_eq!(op.unwrap().validate(), Err(DrawerError::KeyInvalid("notthere".into())));
|
||||
}
|
||||
// #[test]
|
||||
// fn test_drawer_filenames() {
|
||||
// let op = parse_args(vec!["drawer", "open", "blah.notadrawer", "blah"]).unwrap();
|
||||
// if let Command::Open(open) = op {
|
||||
//
|
||||
// } else {
|
||||
// assert!(false, "expected Open command");
|
||||
// }
|
||||
// assert_eq!(op.unwrap().validate(), Err(DrawerError::DrawerFileInvalidExtension("blah.notadrawer".into())));
|
||||
// }
|
||||
//
|
||||
// #[test]
|
||||
// fn test_missing_key() {
|
||||
// let op = parse_args(vec!["drawer", "open", "blah.drawer", "-i", "notthere", "blah"]);
|
||||
// assert_eq!(op.unwrap().validate(), Err(DrawerError::KeyInvalid("notthere".into())));
|
||||
// }
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use clap::Parser;
|
||||
use crate::error::DrawerError;
|
||||
use crate::operation::Operation;
|
||||
use crate::utils::*;
|
||||
|
||||
#[derive(Parser, Debug, PartialEq)]
|
||||
pub struct OpenOperation {
|
||||
pub drawer_file: PathBuf,
|
||||
pub target_path: Option<PathBuf>,
|
||||
#[arg(short = 'i')]
|
||||
pub key: Option<PathBuf>,
|
||||
#[arg(short = 'f')]
|
||||
pub force: bool,
|
||||
}
|
||||
|
||||
fn single_file_entry(data: &[u8]) -> Option<PathBuf> {
|
||||
let mut archive = archive_reader_for(data);
|
||||
let mut entries = archive.entries().ok()?;
|
||||
let entry = entries.next()?.ok()?;
|
||||
if !entry.header().entry_type().is_file() {
|
||||
return None;
|
||||
}
|
||||
if entries.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
Some(entry.path().ok()?.into_owned())
|
||||
}
|
||||
|
||||
fn unpack_single_file(data: &[u8], target: &Path) -> Result<(), DrawerError> {
|
||||
let mut archive = archive_reader_for(data);
|
||||
let mut entry = archive.entries()
|
||||
.map_err(|_| DrawerError::UntarFailed)?
|
||||
.next()
|
||||
.ok_or(DrawerError::UntarFailed)?
|
||||
.map_err(|_| DrawerError::UntarFailed)?;
|
||||
entry.unpack(target).map_err(|_| DrawerError::UntarFailed)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn unpack_directory(data: &[u8], target: &Path) -> Result<(), DrawerError> {
|
||||
let mut archive = archive_reader_for(data);
|
||||
archive.unpack(target).map_err(|_| DrawerError::UntarFailed)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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 execute(&self) -> Result<(), DrawerError> {
|
||||
let encrypted = std::fs::read(&self.drawer_file)
|
||||
.map_err(|_| DrawerError::DrawerFileNotFound(self.drawer_file.clone()))?;
|
||||
let decrypted = decrypt(encrypted, &self.key, &self.drawer_file)?;
|
||||
|
||||
let sf = single_file_entry(&decrypted);
|
||||
let target = self.target_path.as_ref().cloned()
|
||||
.unwrap_or_else(|| default_target(&self.drawer_file, sf.as_deref()));
|
||||
|
||||
if self.target_path.is_none() {
|
||||
println!("(assuming into {})", target.display());
|
||||
}
|
||||
|
||||
if !self.force && target.exists() {
|
||||
if target.is_file() {
|
||||
return Err(DrawerError::TargetNotEmpty(target.clone()));
|
||||
}
|
||||
let mut entries = std::fs::read_dir(&target)
|
||||
.map_err(|_| DrawerError::TargetInvalid(target.clone()))?;
|
||||
if entries.next().is_some() {
|
||||
return Err(DrawerError::TargetNotEmpty(target.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
if sf.is_some() {
|
||||
unpack_single_file(&decrypted, &target)
|
||||
} else {
|
||||
unpack_directory(&decrypted, &target)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
use crate::error::DrawerError;
|
||||
|
||||
pub trait Operation {
|
||||
fn validate(&self) -> Result<(), DrawerError>;
|
||||
fn execute(&self) -> Result<(), DrawerError>;
|
||||
fn perform(&self) -> Result<(), DrawerError> {
|
||||
self.validate()?;
|
||||
self.execute()
|
||||
}
|
||||
}
|
||||
+24
-402
@@ -1,36 +1,11 @@
|
||||
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::parsing::KeyType::*;
|
||||
|
||||
#[derive(Debug, Copy, Clone, PartialEq)]
|
||||
pub enum OperationType {
|
||||
Open,
|
||||
Close,
|
||||
Key,
|
||||
Info,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct DrawerOperation {
|
||||
pub operation_type: OperationType,
|
||||
pub drawer_file: PathBuf,
|
||||
pub key: KeyType,
|
||||
pub target_path: Option<PathBuf>,
|
||||
pub force: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum KeyType {
|
||||
EnvVar, Path(PathBuf)
|
||||
}
|
||||
use crate::operation::Operation;
|
||||
pub use crate::open_operation::OpenOperation;
|
||||
pub use crate::close_operation::CloseOperation;
|
||||
pub use crate::key_operation::KeyOperation;
|
||||
pub use crate::info_operation::InfoOperation;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "drawer", about = "Encrypt and decrypt project directories as drawer files")]
|
||||
@@ -39,388 +14,35 @@ struct Cli {
|
||||
command: Command,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Command {
|
||||
#[derive(Debug, PartialEq, Subcommand)]
|
||||
pub enum Command {
|
||||
/// Decrypt and expand a drawer file into a directory
|
||||
Open {
|
||||
drawer_file: PathBuf,
|
||||
target_path: Option<PathBuf>,
|
||||
#[arg(short = 'i')]
|
||||
key: Option<PathBuf>,
|
||||
#[arg(short = 'f')]
|
||||
force: bool,
|
||||
},
|
||||
Open(OpenOperation),
|
||||
/// Compress and encrypt a directory into a drawer file
|
||||
Close {
|
||||
drawer_file: PathBuf,
|
||||
target_path: Option<PathBuf>,
|
||||
#[arg(short = 'i')]
|
||||
key: Option<PathBuf>,
|
||||
},
|
||||
Close(CloseOperation),
|
||||
/// Generate a new SSH key and save it to a file
|
||||
Key {
|
||||
filename: PathBuf,
|
||||
},
|
||||
Key(KeyOperation),
|
||||
/// Show information about a drawer file
|
||||
Info {
|
||||
drawer_file: PathBuf,
|
||||
#[arg(short = 'i')]
|
||||
key: Option<PathBuf>,
|
||||
},
|
||||
Info(InfoOperation),
|
||||
}
|
||||
|
||||
pub fn parse_args<I, T>(args: I) -> Result<DrawerOperation, DrawerError>
|
||||
impl Command {
|
||||
pub fn perform(&self) -> Result<(), DrawerError> {
|
||||
match self {
|
||||
Command::Open(op) => op.perform(),
|
||||
Command::Close(op) => op.perform(),
|
||||
Command::Key(op) => op.perform(),
|
||||
Command::Info(op) => op.perform(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_args<I, T>(args: I) -> Result<Command, DrawerError>
|
||||
where
|
||||
I: IntoIterator<Item = T>,
|
||||
T: Into<OsString> + Clone,
|
||||
{
|
||||
let cli = Cli::try_parse_from(args)
|
||||
.map_err(|e| DrawerError::ParseError(e.to_string()))?;
|
||||
|
||||
match cli.command {
|
||||
Command::Open { drawer_file, target_path, key, force } => {
|
||||
let key = resolve_key(key);
|
||||
Ok(DrawerOperation {
|
||||
operation_type: OperationType::Open,
|
||||
drawer_file,
|
||||
key,
|
||||
target_path,
|
||||
force,
|
||||
})
|
||||
}
|
||||
Command::Close { drawer_file, target_path, key } => {
|
||||
let key = resolve_key(key);
|
||||
Ok(DrawerOperation {
|
||||
operation_type: OperationType::Close,
|
||||
drawer_file,
|
||||
key,
|
||||
target_path,
|
||||
force: false,
|
||||
})
|
||||
}
|
||||
Command::Key { filename } => {
|
||||
Ok(DrawerOperation {
|
||||
operation_type: OperationType::Key,
|
||||
drawer_file: filename,
|
||||
key: KeyType::EnvVar,
|
||||
target_path: None,
|
||||
force: false,
|
||||
})
|
||||
}
|
||||
Command::Info { drawer_file, key } => {
|
||||
let key = resolve_key(key);
|
||||
Ok(DrawerOperation {
|
||||
operation_type: OperationType::Info,
|
||||
drawer_file,
|
||||
key,
|
||||
target_path: None,
|
||||
force: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_key(key: Option<PathBuf>) -> KeyType {
|
||||
if let Some(k) = key {
|
||||
return Path(k);
|
||||
}
|
||||
EnvVar
|
||||
}
|
||||
|
||||
enum ArchiveInfo {
|
||||
SingleFile { size: u64 },
|
||||
Folder { file_count: usize },
|
||||
}
|
||||
|
||||
fn archive_info(data: &[u8]) -> Result<ArchiveInfo, DrawerError> {
|
||||
let cursor = std::io::Cursor::new(data);
|
||||
let gz = GzDecoder::new(cursor);
|
||||
let mut archive = tar::Archive::new(gz);
|
||||
let mut entries = archive.entries().map_err(|_| DrawerError::UntarFailed)?;
|
||||
|
||||
let first = entries.next()
|
||||
.ok_or(DrawerError::UntarFailed)?
|
||||
.map_err(|_| DrawerError::UntarFailed)?;
|
||||
|
||||
if first.header().entry_type().is_file() {
|
||||
let size = first.header().size().map_err(|_| DrawerError::UntarFailed)?;
|
||||
if entries.next().is_none() {
|
||||
return Ok(ArchiveInfo::SingleFile { size });
|
||||
}
|
||||
}
|
||||
|
||||
let cursor = std::io::Cursor::new(data);
|
||||
let gz = GzDecoder::new(cursor);
|
||||
let mut archive = tar::Archive::new(gz);
|
||||
let file_count = archive.entries()
|
||||
.map_err(|_| DrawerError::UntarFailed)?
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.header().entry_type().is_file())
|
||||
.count();
|
||||
Ok(ArchiveInfo::Folder { file_count })
|
||||
}
|
||||
|
||||
fn single_file_entry(data: &[u8]) -> Option<PathBuf> {
|
||||
let cursor = std::io::Cursor::new(data);
|
||||
let gz = GzDecoder::new(cursor);
|
||||
let mut archive = tar::Archive::new(gz);
|
||||
let mut entries = archive.entries().ok()?;
|
||||
let entry = entries.next()?.ok()?;
|
||||
if !entry.header().entry_type().is_file() {
|
||||
return None;
|
||||
}
|
||||
if entries.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
Some(entry.path().ok()?.into_owned())
|
||||
}
|
||||
|
||||
impl DrawerOperation {
|
||||
// Returns the default target when no target_path is specified.
|
||||
// For close and for directory drawers on open: the drawer file stem.
|
||||
// For single-file drawers on open: the filename stored in the archive.
|
||||
fn default_target(&self, single_file: Option<&std::path::Path>) -> PathBuf {
|
||||
match single_file {
|
||||
Some(p) => PathBuf::from(p.file_name().unwrap_or_default()),
|
||||
None => PathBuf::from(self.drawer_file.file_stem().unwrap_or_default()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn perform(&self) -> Result<(), DrawerError> {
|
||||
self.validate()?;
|
||||
match self.operation_type {
|
||||
OperationType::Open => self.perform_open(),
|
||||
OperationType::Close => self.perform_close(),
|
||||
OperationType::Key => self.perform_key(),
|
||||
OperationType::Info => self.perform_info(),
|
||||
}
|
||||
}
|
||||
|
||||
fn perform_open(&self) -> Result<(), DrawerError> {
|
||||
let encrypted = std::fs::read(&self.drawer_file)
|
||||
.map_err(|_| DrawerError::DrawerFileNotFound(self.drawer_file.clone()))?;
|
||||
|
||||
let decrypted = self.decrypt(encrypted)?;
|
||||
|
||||
let sf = single_file_entry(&decrypted);
|
||||
let target = self.target_path.as_ref().cloned()
|
||||
.unwrap_or_else(|| self.default_target(sf.as_deref()));
|
||||
|
||||
if self.target_path.is_none() {
|
||||
println!("(assuming into {})", target.display());
|
||||
}
|
||||
|
||||
if !self.force && target.exists() {
|
||||
if target.is_file() {
|
||||
return Err(DrawerError::TargetNotEmpty(target.clone()));
|
||||
}
|
||||
let mut entries = std::fs::read_dir(&target)
|
||||
.map_err(|_| DrawerError::TargetInvalid(target.clone()))?;
|
||||
if entries.next().is_some() {
|
||||
return Err(DrawerError::TargetNotEmpty(target.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
if sf.is_some() {
|
||||
let cursor = std::io::Cursor::new(&decrypted);
|
||||
let gz = GzDecoder::new(cursor);
|
||||
let mut archive = tar::Archive::new(gz);
|
||||
let mut entry = archive.entries()
|
||||
.map_err(|_| DrawerError::UntarFailed)?
|
||||
.next()
|
||||
.ok_or(DrawerError::UntarFailed)?
|
||||
.map_err(|_| DrawerError::UntarFailed)?;
|
||||
entry.unpack(&target).map_err(|_| DrawerError::UntarFailed)?;
|
||||
} else {
|
||||
let cursor = std::io::Cursor::new(decrypted);
|
||||
let gz = GzDecoder::new(cursor);
|
||||
let mut archive = tar::Archive::new(gz);
|
||||
archive.unpack(&target).map_err(|_| DrawerError::UntarFailed)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn decrypt(&self, encrypted: Vec<u8>) -> Result<Vec<u8>, DrawerError> {
|
||||
let (key_path, identity) = self.identity()?;
|
||||
|
||||
let decryptor = age::Decryptor::new(encrypted.as_slice())
|
||||
.map_err(|_| DrawerError::DecryptFailed(self.drawer_file.clone(), key_path.clone()))?;
|
||||
let mut decrypted: Vec<u8> = Vec::new();
|
||||
let mut stream = decryptor.decrypt(std::iter::once(&identity as &dyn age::Identity))
|
||||
.map_err(|_| DrawerError::DecryptFailed(self.drawer_file.clone(), key_path.clone()))?;
|
||||
std::io::Read::read_to_end(&mut stream, &mut decrypted)
|
||||
.map_err(|_| DrawerError::DecryptFailed(self.drawer_file.clone(), key_path.clone()))?;
|
||||
Ok(decrypted)
|
||||
}
|
||||
|
||||
fn identity(&self) -> Result<(PathBuf, Identity), DrawerError> {
|
||||
let key_path = self.key_path()?;
|
||||
let file = std::fs::File::open(&key_path)
|
||||
.map_err(|_| DrawerError::KeyInvalid(key_path.clone()))?;
|
||||
let reader = std::io::BufReader::new(file);
|
||||
let identity = age::ssh::Identity::from_buffer(reader, None)
|
||||
.map_err(|_| DrawerError::DecryptFailed(self.drawer_file.clone(), key_path.clone()))?;
|
||||
Ok((key_path, identity))
|
||||
}
|
||||
|
||||
fn perform_close(&self) -> Result<(), DrawerError> {
|
||||
let target = self.target_path.as_ref().cloned()
|
||||
.unwrap_or_else(|| self.default_target(None));
|
||||
|
||||
let mut buf: Vec<u8> = 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 = self.encrypt(&buf)?;
|
||||
|
||||
std::fs::write(&self.drawer_file, &encrypted)
|
||||
.map_err(|_| DrawerError::WriteFailed(self.drawer_file.clone()))
|
||||
}
|
||||
|
||||
fn encrypt(&self, buf: &[u8]) -> Result<Vec<u8>, DrawerError> {
|
||||
let (_, identity) = self.identity()?;
|
||||
let recipient = age::ssh::Recipient::try_from(identity)
|
||||
.map_err(|_| DrawerError::EncryptFailed)?;
|
||||
|
||||
let mut encrypted: Vec<u8> = 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 validate(&self) -> Result<(), DrawerError> {
|
||||
if self.operation_type != OperationType::Key {
|
||||
if self.drawer_file.extension().and_then(|e| e.to_str()) != Some("drawer") {
|
||||
return Err(DrawerError::DrawerFileInvalidExtension(self.drawer_file.clone()));
|
||||
}
|
||||
self.validate_key()?;
|
||||
}
|
||||
match self.operation_type {
|
||||
OperationType::Open | OperationType::Info => self.validate_drawer_file(),
|
||||
OperationType::Close => self.validate_close(),
|
||||
OperationType::Key => self.validate_key_gen(),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_key_gen(&self) -> Result<(), DrawerError> {
|
||||
self.validate_drawer_file()?;
|
||||
let parent = match self.drawer_file.parent() {
|
||||
None => Path::new("."),
|
||||
Some(p) => {
|
||||
if p == Path::new("") {
|
||||
Path::new(".")
|
||||
} else {
|
||||
p
|
||||
}
|
||||
},
|
||||
};
|
||||
if !parent.exists() {
|
||||
return Err(DrawerError::KeyOutputFailed(self.drawer_file.clone()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn key_path(&self) -> Result<PathBuf, DrawerError> {
|
||||
match &self.key {
|
||||
EnvVar => {
|
||||
let val = std::env::var("DRAWER_KEY").map_err(|_| DrawerError::NoKeyPath)?;
|
||||
Ok(PathBuf::from(val))
|
||||
}
|
||||
Path(p) => Ok(p.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_key(&self) -> Result<(), DrawerError> {
|
||||
let key_path = self.key_path()?;
|
||||
let file = std::fs::File::open(&key_path)
|
||||
.map_err(|_| DrawerError::KeyInvalid(key_path.clone()))?;
|
||||
let reader = std::io::BufReader::new(file);
|
||||
let identity = age::ssh::Identity::from_buffer(reader, None)
|
||||
.map_err(|_| DrawerError::KeyNotValidSsh)?;
|
||||
age::ssh::Recipient::try_from(identity)
|
||||
.map_err(|_| DrawerError::KeyNotValidSsh)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Ensure the drawer file actually exists
|
||||
fn validate_drawer_file(&self) -> Result<(), DrawerError> {
|
||||
if !self.drawer_file.exists() {
|
||||
return Err(DrawerError::DrawerFileNotFound(self.drawer_file.clone()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn perform_info(&self) -> Result<(), DrawerError> {
|
||||
let encrypted = std::fs::read(&self.drawer_file)
|
||||
.map_err(|_| DrawerError::DrawerFileNotFound(self.drawer_file.clone()))?;
|
||||
let decrypted = self.decrypt(encrypted)?;
|
||||
let info = archive_info(&decrypted)?;
|
||||
match info {
|
||||
ArchiveInfo::SingleFile { size } =>
|
||||
println!("{} (file, {} bytes)", self.drawer_file.display(), size),
|
||||
ArchiveInfo::Folder { file_count } =>
|
||||
println!("{} (folder, {} files)", self.drawer_file.display(), file_count),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn perform_key(&self) -> Result<(), DrawerError> {
|
||||
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.drawer_file, pem.as_bytes())
|
||||
.map_err(|_| DrawerError::KeyOutputFailed(self.drawer_file.clone()))
|
||||
}
|
||||
|
||||
fn validate_close(&self) -> Result<(), DrawerError> {
|
||||
let target = self.target_path.as_ref().cloned()
|
||||
.unwrap_or_else(|| self.default_target(None));
|
||||
if self.target_path.is_none() {
|
||||
println!("(assuming from {})", target.display());
|
||||
}
|
||||
let meta = std::fs::metadata(&target)
|
||||
.map_err(|_| DrawerError::TargetInvalid(target.clone()))?;
|
||||
if !meta.is_dir() && !meta.is_file() {
|
||||
return Err(DrawerError::TargetInvalid(target.clone()));
|
||||
}
|
||||
if meta.is_dir() && std::fs::read_dir(&target).is_err() {
|
||||
return Err(DrawerError::TargetInvalid(target.clone()));
|
||||
}
|
||||
let canon_target = target.canonicalize()
|
||||
.unwrap_or_else(|_| target.clone());
|
||||
let drawer_parent = match self.drawer_file.parent() {
|
||||
None => Path::new("."),
|
||||
Some(p) if p == Path::new("") => {
|
||||
Path::new(".")
|
||||
},
|
||||
Some(p) => p,
|
||||
};
|
||||
let canon_drawer = drawer_parent.canonicalize()
|
||||
.unwrap_or_else(|_| drawer_parent.to_path_buf())
|
||||
.join(self.drawer_file.file_name().unwrap_or_default());
|
||||
if canon_drawer.starts_with(&canon_target) {
|
||||
return Err(DrawerError::DrawerInsideTarget);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Ok(cli.command)
|
||||
}
|
||||
|
||||
+288
@@ -0,0 +1,288 @@
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use age::ssh::Identity;
|
||||
use flate2::read::GzDecoder;
|
||||
use crate::error::DrawerError;
|
||||
|
||||
/// Returns the parent directory of `p`, or `"."` if `p` has no parent or an empty parent.
|
||||
pub fn parent_or_dot(p: &Path) -> &Path {
|
||||
match p.parent() {
|
||||
None => Path::new("."),
|
||||
Some(p) if p == Path::new("") => Path::new("."),
|
||||
Some(p) => p,
|
||||
}
|
||||
}
|
||||
|
||||
/// Wraps `data` in a gzip-decompressing tar archive reader.
|
||||
pub fn archive_reader_for(data: &[u8]) -> tar::Archive<GzDecoder<std::io::Cursor<&[u8]>>> {
|
||||
tar::Archive::new(GzDecoder::new(std::io::Cursor::new(data)))
|
||||
}
|
||||
|
||||
/// Resolves the key path from the explicit `-i` argument or the `$DRAWER_KEY` environment variable.
|
||||
pub fn key_path(key: &Option<PathBuf>) -> Result<PathBuf, DrawerError> {
|
||||
if let Some(k) = key {
|
||||
return Ok(k.clone());
|
||||
}
|
||||
let val = std::env::var("DRAWER_KEY").map_err(|_| DrawerError::NoKeyPath)?;
|
||||
Ok(PathBuf::from(val))
|
||||
}
|
||||
|
||||
/// Loads the SSH identity from disk, returning both the resolved key path and the parsed identity.
|
||||
pub fn identity(key: &Option<PathBuf>, 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))
|
||||
}
|
||||
|
||||
/// Decrypts `encrypted` using the SSH key, returning the plaintext bytes.
|
||||
pub fn decrypt(encrypted: Vec<u8>, key: &Option<PathBuf>, drawer_file: &Path) -> Result<Vec<u8>, 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<u8> = 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)
|
||||
}
|
||||
|
||||
/// Encrypts `buf` to the SSH public key derived from the identity, returning the ciphertext.
|
||||
pub fn encrypt(buf: &[u8], key: &Option<PathBuf>, drawer_file: &Path) -> Result<Vec<u8>, DrawerError> {
|
||||
let (_, id) = identity(key, drawer_file)?;
|
||||
let recipient = age::ssh::Recipient::try_from(id)
|
||||
.map_err(|_| DrawerError::EncryptFailed)?;
|
||||
let mut encrypted: Vec<u8> = 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)
|
||||
}
|
||||
|
||||
/// Returns the default open/close target path: the single file's name for single-file archives,
|
||||
/// or the drawer file's stem for directory archives.
|
||||
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()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors if `drawer_file` does not have the `.drawer` extension.
|
||||
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(())
|
||||
}
|
||||
|
||||
/// Errors if `drawer_file` does not exist on disk.
|
||||
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(())
|
||||
}
|
||||
|
||||
/// Errors if the key cannot be loaded as a valid SSH identity usable for age encryption.
|
||||
pub fn validate_ssh_key(key: &Option<PathBuf>) -> 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(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const TEST_KEY: &[u8] = include_bytes!("../tests/fixtures/test.key");
|
||||
const TEST_DRAWER: &[u8] = include_bytes!("../tests/fixtures/test.drawer");
|
||||
|
||||
fn temp_path(suffix: &str) -> PathBuf {
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.subsec_nanos();
|
||||
std::env::temp_dir().join(format!("drawer_test_{nanos}{suffix}"))
|
||||
}
|
||||
|
||||
fn write_fixture(data: &[u8], suffix: &str) -> PathBuf {
|
||||
let p = temp_path(suffix);
|
||||
std::fs::write(&p, data).unwrap();
|
||||
p
|
||||
}
|
||||
|
||||
fn make_test_archive() -> Vec<u8> {
|
||||
use flate2::{write::GzEncoder, Compression};
|
||||
let mut buf = Vec::new();
|
||||
let gz = GzEncoder::new(&mut buf, Compression::default());
|
||||
let mut ar = tar::Builder::new(gz);
|
||||
let data = b"hello archive";
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_size(data.len() as u64);
|
||||
header.set_mode(0o644);
|
||||
header.set_cksum();
|
||||
ar.append_data(&mut header, "hello.txt", data.as_ref()).unwrap();
|
||||
ar.into_inner().unwrap().finish().unwrap();
|
||||
buf
|
||||
}
|
||||
|
||||
// --- parent_or_dot ---
|
||||
|
||||
#[test]
|
||||
fn parent_or_dot_with_parent() {
|
||||
assert_eq!(parent_or_dot(Path::new("foo/bar")), Path::new("foo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_or_dot_no_directory_component() {
|
||||
assert_eq!(parent_or_dot(Path::new("foo")), Path::new("."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_or_dot_empty_path() {
|
||||
assert_eq!(parent_or_dot(Path::new("")), Path::new("."));
|
||||
}
|
||||
|
||||
// --- default_target ---
|
||||
|
||||
#[test]
|
||||
fn default_target_single_file() {
|
||||
let result = default_target(Path::new("unused.drawer"), Some(Path::new("archive/file.txt")));
|
||||
assert_eq!(result, PathBuf::from("file.txt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_target_directory_stem() {
|
||||
let result = default_target(Path::new("myproject.drawer"), None);
|
||||
assert_eq!(result, PathBuf::from("myproject"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_target_directory_stem_nested_drawer() {
|
||||
let result = default_target(Path::new("a/b/myproject.drawer"), None);
|
||||
assert_eq!(result, PathBuf::from("myproject"));
|
||||
}
|
||||
|
||||
// --- validate_drawer_extension ---
|
||||
|
||||
#[test]
|
||||
fn validate_extension_valid() {
|
||||
assert!(validate_drawer_extension(Path::new("foo.drawer")).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_extension_wrong() {
|
||||
assert!(validate_drawer_extension(Path::new("foo.tar")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_extension_missing() {
|
||||
assert!(validate_drawer_extension(Path::new("foo")).is_err());
|
||||
}
|
||||
|
||||
// --- key_path ---
|
||||
|
||||
#[test]
|
||||
fn key_path_explicit() {
|
||||
let result = key_path(&Some(PathBuf::from("/some/key")));
|
||||
assert_eq!(result.unwrap(), PathBuf::from("/some/key"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_path_none_no_env_var() {
|
||||
// Remove the env var for this check; if it happens to be set in the
|
||||
// test environment this test would be misleading, so we clear it first.
|
||||
unsafe { std::env::remove_var("DRAWER_KEY") };
|
||||
assert!(key_path(&None).is_err());
|
||||
}
|
||||
|
||||
// --- validate_drawer_file_exists ---
|
||||
|
||||
#[test]
|
||||
fn validate_file_exists_present() {
|
||||
assert!(validate_drawer_file_exists(Path::new("Cargo.toml")).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_file_exists_missing() {
|
||||
assert!(validate_drawer_file_exists(
|
||||
Path::new("/tmp/drawer_definitely_missing_xyz123.drawer")
|
||||
).is_err());
|
||||
}
|
||||
|
||||
// --- archive_reader_for ---
|
||||
|
||||
#[test]
|
||||
fn archive_reader_for_reads_entry() {
|
||||
let data = make_test_archive();
|
||||
let mut archive = archive_reader_for(&data);
|
||||
let entries: Vec<_> = archive.entries().unwrap()
|
||||
.filter_map(|e| e.ok())
|
||||
.collect();
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].path().unwrap().as_ref(), Path::new("hello.txt"));
|
||||
}
|
||||
|
||||
// --- validate_ssh_key ---
|
||||
|
||||
#[test]
|
||||
fn validate_ssh_key_valid() {
|
||||
let key = write_fixture(TEST_KEY, ".key");
|
||||
assert!(validate_ssh_key(&Some(key)).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_ssh_key_garbage() {
|
||||
let key = write_fixture(b"not a real key", ".key");
|
||||
assert!(validate_ssh_key(&Some(key)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_ssh_key_no_path() {
|
||||
unsafe { std::env::remove_var("DRAWER_KEY") };
|
||||
assert!(validate_ssh_key(&None).is_err());
|
||||
}
|
||||
|
||||
// --- identity ---
|
||||
|
||||
#[test]
|
||||
fn identity_loads_valid_key() {
|
||||
let key = write_fixture(TEST_KEY, ".key");
|
||||
let result = identity(&Some(key.clone()), Path::new("dummy.drawer"));
|
||||
let (returned_path, _id) = result.unwrap();
|
||||
assert_eq!(returned_path, key);
|
||||
}
|
||||
|
||||
// --- encrypt / decrypt ---
|
||||
|
||||
#[test]
|
||||
fn encrypt_decrypt_round_trip() {
|
||||
let key = write_fixture(TEST_KEY, ".key");
|
||||
let plaintext = b"round trip test";
|
||||
let ciphertext = encrypt(plaintext, &Some(key.clone()), Path::new("dummy.drawer")).unwrap();
|
||||
let decrypted = decrypt(ciphertext, &Some(key), Path::new("dummy.drawer")).unwrap();
|
||||
assert_eq!(decrypted, plaintext);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decrypt_test_drawer_fixture() {
|
||||
let key = write_fixture(TEST_KEY, ".key");
|
||||
let result = decrypt(TEST_DRAWER.to_vec(), &Some(key), Path::new("test.drawer"));
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
DRAWER=./target/debug/drawer
|
||||
TEST_DIR="$(cd "$(dirname "$0")/tests/testcontents" && 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" "$TEST_DIR" "$DRAWER_FILE"
|
||||
err "close: refuse without key" DRAWER_KEY="" $DRAWER close "$TEST_DIR" "$DRAWER_FILE"
|
||||
err "close: refuse bad key" $DRAWER close -i "$WORK/notakey" "$TEST_DIR" "$DRAWER_FILE"
|
||||
err "close: refuse bad extension" $DRAWER close -i "$KEY" "$TEST_DIR" "$WORK/test.notadrawer"
|
||||
|
||||
# ── 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" "$TEST_DIR" "$INFER_DRAWER"
|
||||
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"
|
||||
|
||||
# ── close: inferred drawer file ──────────────────────────────────────────────
|
||||
|
||||
INFER_CLOSE_SRC="$WORK/myproject"
|
||||
INFER_CLOSE_DRAWER="$WORK/myproject.drawer"
|
||||
cp -r "$TEST_DIR" "$INFER_CLOSE_SRC"
|
||||
|
||||
ok "close: inferred drawer file" $DRAWER close -i "$KEY" "$INFER_CLOSE_SRC"
|
||||
ok "close: drawer file created" test -f "$INFER_CLOSE_DRAWER"
|
||||
|
||||
INFER_CLOSE_OUT="$WORK/myproject_out"
|
||||
mkdir -p "$INFER_CLOSE_OUT"
|
||||
ok "close: inferred round-trip open" $DRAWER open -i "$KEY" "$INFER_CLOSE_DRAWER" "$INFER_CLOSE_OUT"
|
||||
ok "close: inferred round-trip file1" test -f "$INFER_CLOSE_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_SRC" "$SINGLE_DRAWER"
|
||||
$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 ────────────────────────────────────────
|
||||
|
||||
err "close: refuse drawer inside target" $DRAWER close -i "$KEY" "$TEST_DIR" "$TEST_DIR/test.drawer"
|
||||
|
||||
# ── 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."
|
||||
Vendored
BIN
Binary file not shown.
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
QyNTUxOQAAACDX3jE2g37mBoQTky3PYwlrgyJQ2rfBZQoEVXNDhMRl3AAAAIjd219V3dtf
|
||||
VQAAAAtzc2gtZWQyNTUxOQAAACDX3jE2g37mBoQTky3PYwlrgyJQ2rfBZQoEVXNDhMRl3A
|
||||
AAAEB8nkWm+dRgovs/PoFmnFPWinK757HlccvMUgXk3UMtl9feMTaDfuYGhBOTLc9jCWuD
|
||||
IlDat8FlCgRVc0OExGXcAAAAAAECAwQF
|
||||
-----END OPENSSH PRIVATE KEY-----
|
||||
Reference in New Issue
Block a user