From e4a3eaae08a73f3237bb9a0ca0069dc7dc8bc86e Mon Sep 17 00:00:00 2001 From: Ross Andrews Date: Sun, 10 May 2026 18:55:21 -0500 Subject: [PATCH] phase 5 --- CLAUDE.md | 62 ++----------------------------------------------- plans/phase1.md | 21 +++++++++++++++++ plans/phase2.md | 15 ++++++++++++ plans/phase3.md | 15 ++++++++++++ plans/phase4.md | 6 +++++ plans/phase5.md | 13 +++++++++++ src/error.rs | 4 ++++ src/parsing.rs | 57 ++++++++++++++++++++++++++++++++++++--------- 8 files changed, 122 insertions(+), 71 deletions(-) create mode 100644 plans/phase1.md create mode 100644 plans/phase2.md create mode 100644 plans/phase3.md create mode 100644 plans/phase4.md create mode 100644 plans/phase5.md diff --git a/CLAUDE.md b/CLAUDE.md index 26c931b..73a230d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,66 +52,8 @@ the user (me) will review the code, write tests, and possibly make changes, befo At to time should Claude delete or modify any tests whatsoever. -### Phase 1 - -Write code that uses `clap` to parse the command line arguments and create a `DrawerOperation` struct from them. The -`DrawerOperation` struct will contain: - -- An `operation_type` (`OperationType`, enum of `Open` or `Close`) -- A `drawer_file` (`Path` for the drawer file to open or close) -- A `key_path` (`Path` to the key we'll use, which is either the value of the `-i` flag or the value of the `$DRAWER_KEY` -env var) -- A `target_path` (`Path` to the contents we're going to put into or take out of the drawer) -- A `force` (bool for whether or not `-f` was passed) - -In order for this to be testable, it needs to be a function that returns a `Result`. The -main function should call that and then do nothing with the result. - -Certain things should cause the function to return `Err`: - -- Not passing a drawer file -- Passing a subcommand other than `open` or `close` -- Passing any flags but `-f` or `-i` -- Not passing `-i` when the `$DRAWER_KEY` environment variable is undefined - -### Phase 2 - -Phase 2 is about error handling. We want to add some variants to `DrawerError` and a `validate` method to `DrawerOperation` -that will catch various problems and ensure that we can do the operation correctly. Here are some error cases in no particular order: - -- If the command is `Open` and the drawer file does not exist. -- If the command is `Close` and the target path either does not exist, is not a directory, or is not readable. -- If the `key` is `EnvVar` but the `$DRAWER_KEY` environment variable is not set, or is not set to a path that exists and is readable. -- If the `key` is `Path()` but the path does not exist or is not readable. -- If the command is `Open` and the target is a non-empty directory that exists, and the `force` flag is false -- If the command is `Close` and the target contains the drawer file (at any level; the target is any parent of the drawer file's path) - -The `validate` method should take `&self` and return `Result<(), DrawerError>`. Let's have `main`, which is currently -creating a `DrawerOperation` and not using it, also call `validate` and print out the error if there is one. This means -we also need to `impl Display` on `DrawerError`. - -### Phase 3 - -For phase 3 we want to start implementing the `close` command. We'll get the tar part working but not worry about encryption yet. - -- Make a new method `perform` on `DrawerOperation` which takes `&self` and returns `Result<(), DrawerError>`. -- `perform` should first call `validate` and return the error if there is one. -- After that, it should call either `open(&self)` or `close(&self)` depending on the operation type. For now, `open` will just be a `todo!()` -- The new `close` method should create a vec consisting of the contents of the target directory as a tarball (use the tar create we depend on) -- After creating the vec, it should write it out to the drawer file path. - -I'm going to add some test data to the project under `tests/` but it's just random data to test, ignore the contents of all those files. - -### Phase 3a - -As a minor improvement, make the `perform_close` method also gzip the contents of the tarball. - -### Phase 4 - -For phase 4, add encryption. We'll have to add a new error type for the given key not being a valid key for `age`. - -Now, after we create the tar but before we write it to disk, we need to encrypt the array, so that plaintext is never -written to disk. +The implementation plans for each phase are in markdown files under the `plans/` directory. Claude should read these but +should not modify them. ## Commands diff --git a/plans/phase1.md b/plans/phase1.md new file mode 100644 index 0000000..1209ff6 --- /dev/null +++ b/plans/phase1.md @@ -0,0 +1,21 @@ +# Phase 1 + +Write code that uses `clap` to parse the command line arguments and create a `DrawerOperation` struct from them. The +`DrawerOperation` struct will contain: + +- An `operation_type` (`OperationType`, enum of `Open` or `Close`) +- A `drawer_file` (`Path` for the drawer file to open or close) +- A `key_path` (`Path` to the key we'll use, which is either the value of the `-i` flag or the value of the `$DRAWER_KEY` + env var) +- A `target_path` (`Path` to the contents we're going to put into or take out of the drawer) +- A `force` (bool for whether or not `-f` was passed) + +In order for this to be testable, it needs to be a function that returns a `Result`. The +main function should call that and then do nothing with the result. + +Certain things should cause the function to return `Err`: + +- Not passing a drawer file +- Passing a subcommand other than `open` or `close` +- Passing any flags but `-f` or `-i` +- Not passing `-i` when the `$DRAWER_KEY` environment variable is undefined diff --git a/plans/phase2.md b/plans/phase2.md new file mode 100644 index 0000000..fd7d760 --- /dev/null +++ b/plans/phase2.md @@ -0,0 +1,15 @@ +# Phase 2 + +Phase 2 is about error handling. We want to add some variants to `DrawerError` and a `validate` method to `DrawerOperation` +that will catch various problems and ensure that we can do the operation correctly. Here are some error cases in no particular order: + +- If the command is `Open` and the drawer file does not exist. +- If the command is `Close` and the target path either does not exist, is not a directory, or is not readable. +- If the `key` is `EnvVar` but the `$DRAWER_KEY` environment variable is not set, or is not set to a path that exists and is readable. +- If the `key` is `Path()` but the path does not exist or is not readable. +- If the command is `Open` and the target is a non-empty directory that exists, and the `force` flag is false +- If the command is `Close` and the target contains the drawer file (at any level; the target is any parent of the drawer file's path) + +The `validate` method should take `&self` and return `Result<(), DrawerError>`. Let's have `main`, which is currently +creating a `DrawerOperation` and not using it, also call `validate` and print out the error if there is one. This means +we also need to `impl Display` on `DrawerError`. diff --git a/plans/phase3.md b/plans/phase3.md new file mode 100644 index 0000000..d70bf52 --- /dev/null +++ b/plans/phase3.md @@ -0,0 +1,15 @@ +# Phase 3 + +For phase 3 we want to start implementing the `close` command. We'll get the tar part working but not worry about encryption yet. + +- Make a new method `perform` on `DrawerOperation` which takes `&self` and returns `Result<(), DrawerError>`. +- `perform` should first call `validate` and return the error if there is one. +- After that, it should call either `open(&self)` or `close(&self)` depending on the operation type. For now, `open` will just be a `todo!()` +- The new `close` method should create a vec consisting of the contents of the target directory as a tarball (use the tar create we depend on) +- After creating the vec, it should write it out to the drawer file path. + +I'm going to add some test data to the project under `tests/` but it's just random data to test, ignore the contents of all those files. + +# Phase 3a + +As a minor improvement, make the `perform_close` method also gzip the contents of the tarball. diff --git a/plans/phase4.md b/plans/phase4.md new file mode 100644 index 0000000..acc00b3 --- /dev/null +++ b/plans/phase4.md @@ -0,0 +1,6 @@ +# Phase 4 + +For phase 4, add encryption. We'll have to add a new error type for the given key not being a valid key for `age`. + +Now, after we create the tar but before we write it to disk, we need to encrypt the array, so that plaintext is never +written to disk. diff --git a/plans/phase5.md b/plans/phase5.md new file mode 100644 index 0000000..f52817e --- /dev/null +++ b/plans/phase5.md @@ -0,0 +1,13 @@ +# Phase 5 + +For phase 5, implement the `open` command. This will provide an implementation for the `perform_open` method, and will +also need another couple error variants, unless they already exist: + +- The given key might not open the given drawer file +- The drawer file might not decrypt to a valid tarball + +The code for the open command is pretty much the same as the close command, in reverse order: + +- Read the drawer file into an array +- Decrypt it with the key (failing if it won't decrypt, or if it's not a valid tarball) +- Expand it with the `tar` crate into the target path \ No newline at end of file diff --git a/src/error.rs b/src/error.rs index be45c6b..103d188 100644 --- a/src/error.rs +++ b/src/error.rs @@ -15,6 +15,8 @@ pub enum DrawerError { WriteFailed(PathBuf), KeyNotValidSsh, EncryptFailed, + DecryptFailed(PathBuf, PathBuf), + UntarFailed, } impl fmt::Display for DrawerError { @@ -32,6 +34,8 @@ impl fmt::Display for DrawerError { DrawerError::WriteFailed(p) => write!(f, "failed to write drawer file: {}", p.display()), DrawerError::KeyNotValidSsh => write!(f, "key is not a valid SSH key"), DrawerError::EncryptFailed => write!(f, "encryption failed"), + DrawerError::DecryptFailed(drawer, key) => write!(f, "failed to decrypt {} with key {}", drawer.display(), key.display()), + DrawerError::UntarFailed => write!(f, "drawer file does not contain a valid archive"), } } } diff --git a/src/parsing.rs b/src/parsing.rs index a7a3c67..0df4500 100644 --- a/src/parsing.rs +++ b/src/parsing.rs @@ -1,7 +1,9 @@ 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 crate::error::DrawerError; @@ -105,7 +107,40 @@ impl DrawerOperation { } fn perform_open(&self) -> Result<(), DrawerError> { - todo!() + let encrypted = std::fs::read(&self.drawer_file) + .map_err(|_| DrawerError::DrawerFileNotFound(self.drawer_file.clone()))?; + + let decrypted = self.decrypt(encrypted)?; + + let cursor = std::io::Cursor::new(decrypted); + let gz = GzDecoder::new(cursor); + let mut archive = tar::Archive::new(gz); + archive.unpack(&self.target_path) + .map_err(|_| DrawerError::UntarFailed)?; + Ok(()) + } + + fn decrypt(&self, encrypted: Vec) -> Result, 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 = 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> { @@ -121,12 +156,14 @@ impl DrawerOperation { .map_err(|_| DrawerError::TarFailed)?; } - 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::EncryptFailed)?; + 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, DrawerError> { + let (_, identity) = self.identity()?; let recipient = age::ssh::Recipient::try_from(identity) .map_err(|_| DrawerError::EncryptFailed)?; @@ -136,11 +173,9 @@ impl DrawerOperation { ).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.write_all(buf).map_err(|_| DrawerError::EncryptFailed)?; writer.finish().map_err(|_| DrawerError::EncryptFailed)?; - - std::fs::write(&self.drawer_file, &encrypted) - .map_err(|_| DrawerError::WriteFailed(self.drawer_file.clone())) + Ok(encrypted) } pub fn validate(&self) -> Result<(), DrawerError> {