Compare commits

...

10 Commits

Author SHA1 Message Date
randrews ad41fa2460 fixed test 2026-05-15 01:04:52 -05:00
randrews 16d8bd6cf6 man page 2026-05-15 00:58:22 -05:00
randrews 8e4c2762cd reordered close 2026-05-15 00:42:39 -05:00
randrews 04d9f22d41 tests 2026-05-14 23:57:58 -05:00
randrews b6124537f9 comments 2026-05-14 20:05:17 -05:00
randrews 4dd0c79b20 opus refactoring 2026-05-14 19:53:48 -05:00
randrews 365e4bddcc refactoring 2026-05-14 19:30:28 -05:00
randrews 2f1a13b6c8 tests 2026-05-14 19:07:48 -05:00
randrews df5bfa85b4 refactor 2026-05-13 20:31:07 -05:00
randrews 7f7fcf6fa6 wip 2026-05-13 19:54:20 -05:00
14 changed files with 920 additions and 437 deletions
+155
View File
@@ -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)
+72
View File
@@ -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()))
}
}
+64
View File
@@ -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(())
}
}
+32
View File
@@ -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
View File
@@ -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())));
// }
}
+85
View File
@@ -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)
}
}
}
+10
View File
@@ -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
View File
@@ -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
View File
@@ -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());
}
}
Executable
+135
View File
@@ -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."
BIN
View File
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACDX3jE2g37mBoQTky3PYwlrgyJQ2rfBZQoEVXNDhMRl3AAAAIjd219V3dtf
VQAAAAtzc2gtZWQyNTUxOQAAACDX3jE2g37mBoQTky3PYwlrgyJQ2rfBZQoEVXNDhMRl3A
AAAEB8nkWm+dRgovs/PoFmnFPWinK757HlccvMUgXk3UMtl9feMTaDfuYGhBOTLc9jCWuD
IlDat8FlCgRVc0OExGXcAAAAAAECAwQF
-----END OPENSSH PRIVATE KEY-----