Compare commits
10 Commits
becab7abd6
...
ad41fa2460
| 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 parsing;
|
||||||
mod error;
|
mod error;
|
||||||
|
mod utils;
|
||||||
|
mod operation;
|
||||||
|
mod open_operation;
|
||||||
|
mod close_operation;
|
||||||
|
mod key_operation;
|
||||||
|
mod info_operation;
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
match parse_args(std::env::args_os()) {
|
match parse_args(std::env::args_os()) {
|
||||||
Err(e) => eprintln!("{e}"),
|
Err(e) => {
|
||||||
Ok(op) => {
|
|
||||||
if let Err(e) = op.perform() {
|
|
||||||
eprintln!("{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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use crate::error::DrawerError;
|
use std::path::PathBuf;
|
||||||
use crate::parsing::DrawerOperation;
|
use crate::parsing::{CloseOperation, Command, OpenOperation};
|
||||||
use crate::parsing::KeyType::*;
|
|
||||||
use crate::parsing::OperationType::*;
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_basic_close() {
|
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(
|
assert_eq!(op, Ok(
|
||||||
DrawerOperation {
|
Command::Close(CloseOperation{
|
||||||
operation_type: Close,
|
drawer_file: Some("blah.drawer".into()),
|
||||||
drawer_file: "blah.drawer".into(),
|
key: Some(PathBuf::from("foo.key")),
|
||||||
key: Path("foo.key".into()),
|
target_path: "blah".into(),
|
||||||
target_path: Some("blah".into()),
|
})
|
||||||
force: false,
|
|
||||||
}
|
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,45 +46,52 @@ mod tests {
|
|||||||
fn test_basic_open() {
|
fn test_basic_open() {
|
||||||
let op = parse_args(vec!["drawer", "open", "blah.drawer", "-i", "foo.key", "blah"]);
|
let op = parse_args(vec!["drawer", "open", "blah.drawer", "-i", "foo.key", "blah"]);
|
||||||
assert_eq!(op, Ok(
|
assert_eq!(op, Ok(
|
||||||
DrawerOperation {
|
Command::Open(OpenOperation {
|
||||||
operation_type: Open,
|
|
||||||
drawer_file: "blah.drawer".into(),
|
drawer_file: "blah.drawer".into(),
|
||||||
key: Path("foo.key".into()),
|
key: Some(PathBuf::from("foo.key")),
|
||||||
target_path: Some("blah".into()),
|
target_path: Some("blah".into()),
|
||||||
force: false,
|
force: false,
|
||||||
}
|
}
|
||||||
))
|
)))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_inferred_open() {
|
fn test_inferred_open() {
|
||||||
let op = parse_args(vec!["drawer", "open", "blah.drawer", "-i", "foo.key"]);
|
let op = parse_args(vec!["drawer", "open", "blah.drawer", "-i", "foo.key"]);
|
||||||
assert_eq!(op, Ok(
|
assert_eq!(op, Ok(
|
||||||
DrawerOperation {
|
Command::Open(OpenOperation {
|
||||||
operation_type: Open,
|
|
||||||
drawer_file: "blah.drawer".into(),
|
drawer_file: "blah.drawer".into(),
|
||||||
key: Path("foo.key".into()),
|
key: Some(PathBuf::from("foo.key")),
|
||||||
target_path: None,
|
target_path: None,
|
||||||
force: false,
|
force: false,
|
||||||
}
|
}
|
||||||
))
|
)))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_key_env_variable() {
|
fn test_key_env_variable() {
|
||||||
let op = parse_args(vec!["drawer", "open", "blah.drawer", "blah"]);
|
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]
|
// #[test]
|
||||||
fn test_drawer_filenames() {
|
// fn test_drawer_filenames() {
|
||||||
let op = parse_args(vec!["drawer", "open", "blah.notadrawer", "blah"]);
|
// let op = parse_args(vec!["drawer", "open", "blah.notadrawer", "blah"]).unwrap();
|
||||||
assert_eq!(op.unwrap().validate(), Err(DrawerError::DrawerFileInvalidExtension("blah.notadrawer".into())));
|
// if let Command::Open(open) = op {
|
||||||
}
|
//
|
||||||
|
// } else {
|
||||||
#[test]
|
// assert!(false, "expected Open command");
|
||||||
fn test_missing_key() {
|
// }
|
||||||
let op = parse_args(vec!["drawer", "open", "blah.drawer", "-i", "notthere", "blah"]);
|
// assert_eq!(op.unwrap().validate(), Err(DrawerError::DrawerFileInvalidExtension("blah.notadrawer".into())));
|
||||||
assert_eq!(op.unwrap().validate(), Err(DrawerError::KeyInvalid("notthere".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::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 clap::{Parser, Subcommand};
|
||||||
use ssh_key::{Algorithm, LineEnding, PrivateKey};
|
|
||||||
use crate::error::DrawerError;
|
use crate::error::DrawerError;
|
||||||
use crate::parsing::KeyType::*;
|
use crate::operation::Operation;
|
||||||
|
pub use crate::open_operation::OpenOperation;
|
||||||
#[derive(Debug, Copy, Clone, PartialEq)]
|
pub use crate::close_operation::CloseOperation;
|
||||||
pub enum OperationType {
|
pub use crate::key_operation::KeyOperation;
|
||||||
Open,
|
pub use crate::info_operation::InfoOperation;
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(name = "drawer", about = "Encrypt and decrypt project directories as drawer files")]
|
#[command(name = "drawer", about = "Encrypt and decrypt project directories as drawer files")]
|
||||||
@@ -39,388 +14,35 @@ struct Cli {
|
|||||||
command: Command,
|
command: Command,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
#[derive(Debug, PartialEq, Subcommand)]
|
||||||
enum Command {
|
pub enum Command {
|
||||||
/// Decrypt and expand a drawer file into a directory
|
/// Decrypt and expand a drawer file into a directory
|
||||||
Open {
|
Open(OpenOperation),
|
||||||
drawer_file: PathBuf,
|
|
||||||
target_path: Option<PathBuf>,
|
|
||||||
#[arg(short = 'i')]
|
|
||||||
key: Option<PathBuf>,
|
|
||||||
#[arg(short = 'f')]
|
|
||||||
force: bool,
|
|
||||||
},
|
|
||||||
/// Compress and encrypt a directory into a drawer file
|
/// Compress and encrypt a directory into a drawer file
|
||||||
Close {
|
Close(CloseOperation),
|
||||||
drawer_file: PathBuf,
|
|
||||||
target_path: Option<PathBuf>,
|
|
||||||
#[arg(short = 'i')]
|
|
||||||
key: Option<PathBuf>,
|
|
||||||
},
|
|
||||||
/// Generate a new SSH key and save it to a file
|
/// Generate a new SSH key and save it to a file
|
||||||
Key {
|
Key(KeyOperation),
|
||||||
filename: PathBuf,
|
|
||||||
},
|
|
||||||
/// Show information about a drawer file
|
/// Show information about a drawer file
|
||||||
Info {
|
Info(InfoOperation),
|
||||||
drawer_file: PathBuf,
|
|
||||||
#[arg(short = 'i')]
|
|
||||||
key: Option<PathBuf>,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
where
|
||||||
I: IntoIterator<Item = T>,
|
I: IntoIterator<Item = T>,
|
||||||
T: Into<OsString> + Clone,
|
T: Into<OsString> + Clone,
|
||||||
{
|
{
|
||||||
let cli = Cli::try_parse_from(args)
|
let cli = Cli::try_parse_from(args)
|
||||||
.map_err(|e| DrawerError::ParseError(e.to_string()))?;
|
.map_err(|e| DrawerError::ParseError(e.to_string()))?;
|
||||||
|
Ok(cli.command)
|
||||||
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(())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+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