Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions TODO
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# Implement a new extensible storage format that supports

## grouping secrets into folders
✅ **COMPLETED**
- Hierarchical folder structure with unlimited nesting depth
- Everything is in the root folder (/) by default
- Commands: `mkdir`, `cd`, `pwd` for folder navigation
- Full path support: `/work/projects/api_key`
- Relative paths: `../other_folder`, `./subfolder`
- Keys can be moved between folders using `mv` command
- Comprehensive test coverage for nested folders

## encryption domains
⚠️ **IN PROGRESS - Refactoring to unified structure**
- ✅ `FolderItem` enum with Secret/Folder/EncryptedFolder variants
- ✅ Unified `items` BTreeMap in Folder (no separate secrets/subfolders)
- ✅ Helper methods on FolderItem for ergonomic access
- Default encryption domain (0) uses the master key (same as storage v4)

⏳ **TODO: Simplified encryption approach**
- ✅ Non-default domains encrypt secrets/folders with a custom key requiring password
- ✅ Can be attached to individual secrets or entire folders
- ✅ Encrypted folders are VISIBLE as encrypted folders (no hiding with placeholder values)
- ✅ Domain keys are session-only (not persisted to disk)
- ✅ Critical: `decrypted_folder` field never serialized (verified by test)
- When domain locked: items show "**LOCKED**" indicator
- When domain unlocked: items decrypt lazily on access (folders navigable, secrets readable)
- Delete operation works uniformly for secrets, folders, and encrypted folders

**Command Semantics**
Domain Management:
- `unlock <domain>` - Unlocks encryption domain by asking for password, loads cypher into memory
- Domain remains unlocked for the session (until process exits)
- Domain keys never persisted to disk (re-enter password each session)
- No `unlock <item>` command - items decrypt automatically when accessed if domain unlocked

Changing Encryption Domains:
- `lock <item> <domain>` - Changes item's encryption domain and re-encrypts
- Re-encryption uses the cypher from the target domain (must be unlocked first)
- Works for both individual secrets and entire folders

Accessing Encrypted Content:
- Decryption happens lazily on first access (not during domain unlock)
- If domain locked: items show "**LOCKED**" when accessed
- If domain unlocked: items decrypt automatically on access
- Encrypted folders become navigable once their domain is unlocked

## move command
✅ **COMPLETED: move command**
- `mv SOURCE_PATTERN DESTINATION` - shell-like move behavior
- Single match: destination can be folder or full path (move+rename)
- Multiple matches: destination must be a folder
- Recursive pattern matching with regex support
- Preserves full key history during move
- Collision detection with pre-validation

⏳ **TODO: encryption domain support for move**
- Encrypted folders must be unlocked to allow moving/renaming keys from them
- Re-encryption should be performed with key from corresponding encryption domain when moving between domains

## support SecretType and optional metadata HashMap
⚠️ **PARTIALLY COMPLETED**
- Data structures exist: `SecretEntry` has `secret_type: SecretType` and `metadata: HashMap<String, String>` fields
- Currently only `SecretType::Utf8String` is implemented
- Metadata field exists but not exposed in CLI

⏳ **TODO**
- Add `SecretType::File` variant for file storage support
- Implement CLI commands to set/view metadata
- Add file upload/download functionality
- Support for binary data storage
112 changes: 86 additions & 26 deletions src/cli/completer.rs
Original file line number Diff line number Diff line change
@@ -1,19 +1,76 @@
use std::sync::{Arc, Mutex};

use crate::Storage;
use crate::StorageV5;
use crate::cli::utils::{format_full_path, parse_key_path};
use rustyline::Helper;
use rustyline::completion::{Completer, Pair};
use rustyline::highlight::Highlighter;
use rustyline::hint::Hinter;
use rustyline::validate::Validator;

pub struct CypherCompleter {
storage: Arc<Mutex<Storage>>,
storage: Arc<Mutex<StorageV5>>,
current_path: Arc<Mutex<String>>,
}

impl CypherCompleter {
pub const fn new(storage: Arc<Mutex<Storage>>) -> Self {
Self { storage }
pub const fn new(storage: Arc<Mutex<StorageV5>>, current_path: Arc<Mutex<String>>) -> Self {
Self {
storage,
current_path,
}
}

/// Helper function to complete paths with optional keys and/or folders
fn complete_path(
&self,
input: &str,
pos: usize,
include_keys: bool,
include_folders: bool,
) -> (usize, Vec<Pair>) {
let storage = self.storage.lock().expect("able to take a lock");
let current_path = self.current_path.lock().expect("able to lock");

// Parse input to get resolved path and prefix
let (target_path, prefix) = parse_key_path(&current_path, input);

// Extract original dir part to preserve user's input style in completions
let dir_part = input.strip_suffix(prefix).unwrap_or("");

let mut matches: Vec<Pair> = Vec::new();
if let Some(folder) = storage.get_folder(&target_path) {
// Iterate through all items in the folder
for (name, item) in &folder.items {
if !name.starts_with(prefix) {
continue;
}

// Check if we should include this item based on its type
let should_include = match item {
_ if item.is_secret() => include_keys,
_ if item.is_navigable() || item.is_locked() => include_folders,
_ => false,
};

if should_include {
let is_folder = item.is_any_folder();
let full_path = format_full_path(dir_part, name, is_folder);
matches.push(Pair {
display: full_path.clone(),
replacement: full_path,
});
}
}
}

drop(current_path);
drop(storage);

matches.sort_by(|a, b| a.replacement.cmp(&b.replacement));

let start = pos - input.len();
(start, matches)
}
}

Expand All @@ -33,7 +90,8 @@ impl Completer for CypherCompleter {
if parts.is_empty() || (parts.len() == 1 && !line.ends_with(' ')) {
// Complete commands
let commands = [
"put", "get", "copy", "history", "search", "del", "rm", "help",
"put", "get", "copy", "history", "search", "del", "rm", "mkdir", "cd", "pwd", "mv",
"move", "help",
];
let prefix = parts.first().unwrap_or(&"");
let matches: Vec<Pair> = commands
Expand All @@ -49,32 +107,34 @@ impl Completer for CypherCompleter {
return Ok((start, matches));
}

// Complete keys for commands that need them
// Complete arguments for commands
if !parts.is_empty() {
let cmd = parts[0];
match cmd {
"cd" | "mkdir" => {
// Complete with folder names only
if parts.len() == 1 || (parts.len() == 2 && !line.ends_with(' ')) {
let input = if parts.len() == 2 { parts[1] } else { "" };
return Ok(self.complete_path(input, pos, false, true));
}
}
"get" | "history" | "del" | "rm" | "put" | "copy" | "search" => {
// Only complete if we have exactly 1 argument (the command itself)
// or 2 arguments where the second is incomplete
// Complete with keys and folders
if parts.len() == 1 || (parts.len() == 2 && !line.ends_with(' ')) {
let prefix = if parts.len() == 2 { parts[1] } else { "" };

let storage = self.storage.lock().expect("able to take a lock");
let mut keys: Vec<String> = storage.data.keys().cloned().collect();
drop(storage);
keys.sort();

let matches: Vec<Pair> = keys
.iter()
.filter(|key| key.starts_with(prefix))
.map(|key| Pair {
display: key.clone(),
replacement: key.clone(),
})
.collect();

let start = pos - prefix.len();
return Ok((start, matches));
let input = if parts.len() == 2 { parts[1] } else { "" };
return Ok(self.complete_path(input, pos, true, true));
}
}
"mv" | "move" => {
// First argument: complete with keys and folders (source)
if parts.len() == 1 || (parts.len() == 2 && !line.ends_with(' ')) {
let input = if parts.len() == 2 { parts[1] } else { "" };
return Ok(self.complete_path(input, pos, true, true));
}
// Second argument: complete with folders only (destination)
else if parts.len() == 2 || (parts.len() == 3 && !line.ends_with(' ')) {
let input = if parts.len() == 3 { parts[2] } else { "" };
return Ok(self.complete_path(input, pos, false, true));
}
}
_ => {}
Expand Down
Loading