From 4ac4ca8c17aa35ecb8024f8cc84412a218c58ae1 Mon Sep 17 00:00:00 2001
From: Emeric Favarel <47535798+moukrea@users.noreply.github.com>
Date: Fri, 6 Mar 2026 22:58:40 +0100
Subject: [PATCH 1/4] feat: add sensitivity levels and location-aware scoped
entries
---
README.md | 139 +++++++++++++++++++---
src/cli.rs | 66 ++++++++++-
src/commands/add.rs | 122 ++++++++++++++++++--
src/commands/cleanup.rs | 55 +++++++++
src/commands/edit.rs | 28 ++++-
src/commands/export_cmd.rs | 6 +
src/commands/import_cmd.rs | 150 ++++++++++++++++++++++--
src/commands/mod.rs | 3 +
src/commands/remove.rs | 52 ++++++++-
src/commands/reveal.rs | 70 ++++++++++++
src/commands/run.rs | 14 +--
src/commands/search.rs | 43 +++++--
src/commands/shadows.rs | 68 +++++++++++
src/error.rs | 19 ++-
src/filter.rs | 37 ++++++
src/model.rs | 128 ++++++++++++++++++++-
src/run.rs | 200 +++++++++++++++++++++++++-------
src/search.rs | 8 +-
src/store.rs | 130 ++++++++++++++++++++-
tests/integration.rs | 229 ++++++++++++++++++++++++++++++++++---
20 files changed, 1443 insertions(+), 124 deletions(-)
create mode 100644 src/commands/cleanup.rs
create mode 100644 src/commands/reveal.rs
create mode 100644 src/commands/shadows.rs
diff --git a/README.md b/README.md
index 2ffd89c..da590c4 100644
--- a/README.md
+++ b/README.md
@@ -10,6 +10,8 @@
Installation •
Quick Start •
Commands •
+ Sensitivity Levels •
+ Location-Aware Scopes •
How It Works •
AI Agent Integration •
Contributing
@@ -19,19 +21,24 @@
## What is opaq?
-opaq is a credential manager and execution wrapper for developers and AI agents. Secrets are stored encrypted, referenced by name, injected at runtime, and scrubbed from all output.
+opaq is a credential and config manager with an execution wrapper for developers and AI agents. Entries are either secrets (encrypted, masked in all output) or plain config values (encrypted at rest, readable via `opaq reveal`). All entries are stored encrypted, referenced by name, and scoped to directories.
```bash
-# Find a secret by keyword
+# Find entries by keyword
opaq search gitlab
-# {{GITLAB_TOKEN}} GitLab API personal access token
+# 🔒 {{GITLAB_TOKEN}} GitLab API personal access token [global]
+# 📋 {{GITLAB_URL}} GitLab instance URL [~/work/]
-# Use it in a command — the value is injected at runtime, never visible
+# Use a secret in a command — the value is injected at runtime, never visible
opaq run -- curl -sS -H "Authorization: Bearer {{GITLAB_TOKEN}}" \
"https://gitlab.example.com/api/v4/projects"
+
+# Read a plain config value directly
+opaq reveal GITLAB_URL
+# https://gitlab.example.com
```
-The secret value never appears in your terminal, shell history, log files, or AI agent context. Any accidental output is replaced with `[MASKED]`.
+The secret value never appears in your terminal, shell history, log files, or AI agent context. Any accidental output is replaced with `[MASKED]`. Plain entries are intentionally readable — use them for non-sensitive config like URLs and hostnames.
### Why opaq?
@@ -145,14 +152,21 @@ The binary is at `target/release/opaq`. Copy it somewhere on your `PATH`.
opaq init
# 2. Add a secret (value is entered interactively, never as an argument)
-opaq add GITHUB_TOKEN "GitHub personal access token" --tags github,ci
+opaq add GITHUB_TOKEN "GitHub personal access token" --tags github,ci --secret
+
+# 3. Add a plain config value
+opaq add GITHUB_ORG "GitHub organization name" --plain
-# 3. Search for secrets
+# 4. Search for entries
opaq search github
-# {{GITHUB_TOKEN}} GitHub personal access token
+# 🔒 {{GITHUB_TOKEN}} GitHub personal access token [global]
+# 📋 {{GITHUB_ORG}} GitHub organization name [global]
-# 4. Use in commands
+# 5. Use secrets in commands
opaq run -- gh api /user -H "Authorization: Bearer {{GITHUB_TOKEN}}"
+
+# 6. Read plain values directly
+opaq reveal GITHUB_ORG
```
## Commands
@@ -162,9 +176,11 @@ opaq run -- gh api /user -H "Authorization: Bearer {{GITHUB_TOKEN}}"
| Command | Description |
|---------|-------------|
| `opaq init` | Create the encrypted store and save the master key in your OS keychain |
-| `opaq add ` | Add a secret (value entered via secure prompt) |
-| `opaq edit ` | Change a secret's description, tags, or value |
-| `opaq remove ` | Delete a secret |
+| `opaq add ` | Add an entry with optional `--secret`/`--plain` and `--global`/`--user`/`--current` flags |
+| `opaq edit ` | Change an entry's description, tags, value, sensitivity, or scope |
+| `opaq remove ` | Delete an entry (disambiguates when multiple scopes exist) |
+| `opaq shadows ` | Show all scopes for an entry and which one is active from the current directory |
+| `opaq cleanup` | Find and remove entries scoped to directories that no longer exist |
| `opaq export --to ` | Export an encrypted backup |
| `opaq import --from ` | Restore from a backup |
| `opaq lock` | Clear the master key from the keychain |
@@ -174,10 +190,11 @@ opaq run -- gh api /user -H "Authorization: Bearer {{GITHUB_TOKEN}}"
| Command | Description |
|---------|-------------|
-| `opaq search ` | Find secrets by name, tags, or description (never shows values) |
+| `opaq search ` | Find entries by name, tags, or description (never shows values). Filters by current directory scope; use `--all-scopes` to show all |
+| `opaq reveal ` | Read the value of a plain (non-sensitive) entry. Refused for secrets |
| `opaq run -- ` | Execute a command with `{{SECRET}}` placeholders injected at runtime |
-Secret names are always uppercase with underscores: `API_TOKEN`, `DB_PASSWORD`, `SSH_KEY_PATH`.
+Entry names are always uppercase with underscores: `API_TOKEN`, `DB_PASSWORD`, `SSH_KEY_PATH`.
### Examples
@@ -199,10 +216,90 @@ opaq run -- sh -c \
'curl -sS -H "PRIVATE-TOKEN: {{GITLAB_TOKEN}}" \
"https://git.example.com/api/v4/projects" | jq .[].name'
+# Read a plain config value
+opaq reveal DEPLOY_HOST
+
# JSON output for scripting
opaq search ci --json
```
+## Sensitivity Levels
+
+Every entry is either **secret** (🔒) or **plain** (📋).
+
+- **Secret** (default) — value is masked and scrubbed from all output. Use `opaq run` with `{{PLACEHOLDER}}` to inject it into commands.
+- **Plain** — value is readable via `opaq reveal`. Not masked in output. Use for non-sensitive config like URLs, hostnames, org names.
+
+Both types are encrypted at rest in the same store.
+
+```bash
+# Add a secret (default, or explicit)
+opaq add API_TOKEN "Production API token" --secret
+
+# Add a plain config value
+opaq add API_URL "Production API base URL" --plain
+
+# Read a plain value directly
+opaq reveal API_URL
+# https://api.example.com
+
+# Secrets are refused by reveal
+opaq reveal API_TOKEN
+# Error: 'API_TOKEN' is a secret entry. Use 'opaq run' to inject it into commands.
+```
+
+If you omit `--secret`/`--plain`, an interactive prompt lets you choose.
+
+## Location-Aware Scopes
+
+The same entry name can hold different values depending on your working directory.
+
+### Scope levels
+
+| Flag | Scope | Meaning |
+|------|-------|---------|
+| `--global` | Global (default) | Available everywhere |
+| `--user` | Home directory | Available under `~/` |
+| `--current` | Current directory | Available under the current working directory |
+
+If you omit the flag, an interactive prompt lets you choose (including a custom path option).
+
+### Resolution
+
+When multiple entries share a name, the **nearest ancestor wins**. A scope tied to `/home/eco/work/project` beats `~/` which beats global.
+
+```bash
+# Global default
+opaq add REGISTRY "Docker registry URL" --plain --global
+# Enter value: registry.docker.io
+
+# Project override
+cd ~/work/client-a
+opaq add REGISTRY "Docker registry URL" --plain --current
+# Enter value: registry.client-a.internal
+
+# From ~/work/client-a, the project scope wins
+opaq reveal REGISTRY
+# registry.client-a.internal
+
+# From anywhere else, the global scope applies
+cd ~/personal
+opaq reveal REGISTRY
+# registry.docker.io
+```
+
+### Inspecting scopes
+
+```bash
+# See all scopes for an entry and which one is active
+opaq shadows REGISTRY
+# → 📋 [~/work/client-a/] Docker registry URL ← active from here
+# 📋 [global] Docker registry URL
+
+# Find entries pointing to deleted directories
+opaq cleanup
+```
+
## How It Works
### Storage
@@ -214,10 +311,11 @@ Secrets are stored in a single encrypted file at `~/.config/opaq/store`, encrypt
When you run `opaq run -- `, opaq:
1. Decrypts secrets in memory
-2. Replaces `{{PLACEHOLDER}}` tokens with actual values in the command arguments
-3. Spawns the child process
-4. Filters stdout and stderr in real time, replacing any secret value with `[MASKED]`
-5. Scrubs files written during execution, replacing secret values in text files and deleting binary files that contain matches
+2. Resolves `{{PLACEHOLDER}}` tokens using scope resolution (nearest-ancestor-wins from the current directory)
+3. Replaces placeholders with actual values in the command arguments
+4. Spawns the child process
+5. Filters stdout and stderr in real time, replacing any **secret** value with `[MASKED]` (plain values pass through unmasked)
+6. Scrubs files written during execution, replacing secret values in text files and deleting binary files that contain matches
The output filter uses an [Aho-Corasick](https://en.wikipedia.org/wiki/Aho%E2%80%93Corasick_algorithm) multi-pattern automaton to catch secrets in all their forms: raw, URL-encoded, Base64-encoded, and shell-escaped.
@@ -232,6 +330,8 @@ opaq is designed to work with AI coding agents like [Claude Code](https://claude
- **A skill** that teaches agents the search-then-run workflow
- **Hook scripts** that block agents from accessing the store directly, prevent writing placeholders to files, and auto-wrap commands containing `{{SECRET}}` placeholders
+Agents can also use `opaq reveal` to read plain config values directly — no TTY required. Scope resolution is automatic based on the agent's working directory.
+
### Three enforcement layers
1. **Instruction layer** — The skill file teaches agents the correct workflow
@@ -252,7 +352,8 @@ opaq setup --check # Verify installation
- The store file is encrypted at rest with age (ChaCha20-Poly1305)
- Output filtering catches raw, URL-encoded, Base64, and shell-escaped variants
- File scrubbing watches for secrets written to disk during command execution
-- Interactive commands (`add`, `edit`, `remove`, etc.) require a TTY — agents cannot run them
+- Interactive commands (`add`, `edit`, `remove`, `shadows`, `cleanup`) require a TTY — agents cannot run them
+- Plain entries are intentionally readable via `reveal` — they are for non-sensitive config, not credentials. Secret entries remain fully masked
## License
diff --git a/src/cli.rs b/src/cli.rs
index 9579fae..cf70cbb 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -33,6 +33,21 @@ pub enum Commands {
description: String,
/// Optional comma-separated tags
tags: Option,
+ /// Mark as secret (masked, default)
+ #[arg(long, conflicts_with = "plain")]
+ secret: bool,
+ /// Mark as plain (non-sensitive)
+ #[arg(long, conflicts_with = "secret")]
+ plain: bool,
+ /// Scope: global (available everywhere)
+ #[arg(long, conflicts_with_all = ["user", "current"])]
+ global: bool,
+ /// Scope: user home directory
+ #[arg(long, conflicts_with_all = ["global", "current"])]
+ user: bool,
+ /// Scope: current working directory
+ #[arg(long, conflicts_with_all = ["global", "user"])]
+ current: bool,
},
/// Modify an existing secret's metadata or value
@@ -63,6 +78,30 @@ pub enum Commands {
/// Output in JSON format
#[arg(long)]
json: bool,
+ /// Show entries from all scopes, not just the current directory
+ #[arg(long)]
+ all_scopes: bool,
+ },
+
+ /// Reveal the value of a non-sensitive (plain) entry
+ Reveal {
+ /// Entry name
+ name: String,
+ /// Output as JSON
+ #[arg(long)]
+ json: bool,
+ /// Override scope resolution
+ #[arg(long)]
+ scope: Option,
+ },
+
+ /// Find and remove entries scoped to directories that no longer exist
+ Cleanup,
+
+ /// Show all scopes for an entry name and which one is active
+ Shadows {
+ /// Entry name
+ name: String,
},
/// Execute command with {{SECRET}} placeholder injection
@@ -128,9 +167,14 @@ pub fn dispatch(cli: Cli) -> crate::error::Result<()> {
name,
description,
tags,
+ secret,
+ plain,
+ global,
+ user,
+ current,
} => {
require_tty("add")?;
- crate::commands::add::execute(name, description, tags)
+ crate::commands::add::execute(name, description, tags, secret, plain, global, user, current)
}
Commands::Edit {
name,
@@ -145,9 +189,25 @@ pub fn dispatch(cli: Cli) -> crate::error::Result<()> {
require_tty("remove")?;
crate::commands::remove::execute(name)
}
- Commands::Search { query, json } => {
+ Commands::Search {
+ query,
+ json,
+ all_scopes,
+ } => {
+ // No TTY check -- agent-safe
+ crate::commands::search::execute(query, json, all_scopes)
+ }
+ Commands::Reveal { name, json, scope } => {
// No TTY check -- agent-safe
- crate::commands::search::execute(query, json)
+ crate::commands::reveal::execute(name, json, scope)
+ }
+ Commands::Cleanup => {
+ require_tty("cleanup")?;
+ crate::commands::cleanup::execute()
+ }
+ Commands::Shadows { name } => {
+ require_tty("shadows")?;
+ crate::commands::shadows::execute(name)
}
Commands::Run { command } => {
// No TTY check -- agent-safe
diff --git a/src/commands/add.rs b/src/commands/add.rs
index a2147ac..6a324f7 100644
--- a/src/commands/add.rs
+++ b/src/commands/add.rs
@@ -1,21 +1,28 @@
// opaq: add command implementation
+use std::path::PathBuf;
+
use crate::error::{OpaqError, Result};
-use crate::model::{normalize_tags, validate_name, SecretEntry};
+use crate::model::{normalize_tags, validate_name, Scope, SecretEntry};
use crate::store;
-pub fn execute(name: String, description: String, tags: Option) -> Result<()> {
+#[allow(clippy::too_many_arguments)]
+pub fn execute(
+ name: String,
+ description: String,
+ tags: Option,
+ secret: bool,
+ plain: bool,
+ global: bool,
+ user: bool,
+ current: bool,
+) -> Result<()> {
// Validate name format
validate_name(&name)?;
// Load existing store (handles keychain retrieval and decryption)
let mut entries = store::load_store()?;
- // Check name uniqueness
- if entries.iter().any(|e| e.name == name) {
- return Err(OpaqError::DuplicateName(name));
- }
-
// Parse and normalize tags
let tag_list = match tags {
Some(ref t) => {
@@ -25,11 +32,35 @@ pub fn execute(name: String, description: String, tags: Option) -> Resul
None => vec![],
};
+ // Resolve sensitivity
+ let sensitive = if secret {
+ true
+ } else if plain {
+ false
+ } else {
+ prompt_sensitivity()?
+ };
+
+ // Resolve scope
+ let scope = resolve_scope(global, user, current)?;
+
+ // Check name + scope uniqueness
+ if entries.iter().any(|e| e.name == name && e.scope == scope) {
+ return Err(OpaqError::DuplicateName(name));
+ }
+
// Prompt for secret value on /dev/tty (masked with confirmation)
let value = prompt_secret_value()?;
// Create new entry
- let entry = SecretEntry::new(name.clone(), description, tag_list, value.into_bytes())?;
+ let entry = SecretEntry::new(
+ name.clone(),
+ description,
+ tag_list,
+ value.into_bytes(),
+ sensitive,
+ scope,
+ )?;
// Add to store
entries.push(entry);
@@ -41,6 +72,81 @@ pub fn execute(name: String, description: String, tags: Option) -> Resul
Ok(())
}
+fn resolve_scope(global: bool, user: bool, current: bool) -> Result {
+ if global {
+ Ok(Scope::Global)
+ } else if user {
+ let home = dirs::home_dir()
+ .ok_or_else(|| OpaqError::Io(std::io::Error::other("Cannot determine home directory")))?;
+ let canon = std::fs::canonicalize(&home)?;
+ Ok(Scope::Path(canon))
+ } else if current {
+ let cwd = std::env::current_dir()?;
+ let canon = std::fs::canonicalize(&cwd)?;
+ Ok(Scope::Path(canon))
+ } else {
+ prompt_scope()
+ }
+}
+
+pub fn prompt_scope() -> Result {
+ let cwd_display = std::env::current_dir()
+ .map(|p| p.to_string_lossy().to_string())
+ .unwrap_or_else(|_| ".".to_string());
+
+ let options = vec![
+ "Global -- available everywhere (default)".to_string(),
+ "User -- available under your home directory".to_string(),
+ format!("Current directory -- available under {}", cwd_display),
+ "Other -- specify a custom path".to_string(),
+ ];
+ let selection = inquire::Select::new("Entry scope:", options.clone())
+ .prompt()
+ .map_err(|e| OpaqError::Io(std::io::Error::other(e.to_string())))?;
+
+ if selection.starts_with("Global") {
+ Ok(Scope::Global)
+ } else if selection.starts_with("User") {
+ let home = dirs::home_dir()
+ .ok_or_else(|| OpaqError::Io(std::io::Error::other("Cannot determine home directory")))?;
+ let canon = std::fs::canonicalize(&home)?;
+ Ok(Scope::Path(canon))
+ } else if selection.starts_with("Current") {
+ let cwd = std::env::current_dir()?;
+ let canon = std::fs::canonicalize(&cwd)?;
+ Ok(Scope::Path(canon))
+ } else {
+ // "Other" -- prompt for path
+ let path_str = inquire::Text::new("Directory path:")
+ .prompt()
+ .map_err(|e| OpaqError::Io(std::io::Error::other(e.to_string())))?;
+ validate_scope_path(&path_str)
+ }
+}
+
+fn validate_scope_path(path_str: &str) -> Result {
+ let path = PathBuf::from(path_str);
+ let meta = std::fs::metadata(&path).map_err(|_| {
+ OpaqError::InvalidScopePath(path_str.to_string())
+ })?;
+ if !meta.is_dir() {
+ return Err(OpaqError::InvalidScopePath(path_str.to_string()));
+ }
+ let canon = std::fs::canonicalize(&path)?;
+ Ok(Scope::Path(canon))
+}
+
+fn prompt_sensitivity() -> Result {
+ let options = vec![
+ "Secret -- value is masked and scrubbed from output (default)",
+ "Plain -- non-sensitive data, can be revealed",
+ ];
+ let selection = inquire::Select::new("Sensitivity level:", options)
+ .prompt()
+ .map_err(|e| OpaqError::Io(std::io::Error::other(e.to_string())))?;
+ Ok(selection.starts_with("Secret"))
+}
+
fn prompt_secret_value() -> Result {
let value = inquire::Password::new(" Enter secret value:")
.with_display_mode(inquire::PasswordDisplayMode::Masked)
diff --git a/src/commands/cleanup.rs b/src/commands/cleanup.rs
new file mode 100644
index 0000000..f4c9693
--- /dev/null
+++ b/src/commands/cleanup.rs
@@ -0,0 +1,55 @@
+// opaq: cleanup command implementation
+
+use crate::error::{OpaqError, Result};
+use crate::model::Scope;
+use crate::store;
+
+pub fn execute() -> Result<()> {
+ let mut entries = store::load_store()?;
+
+ // Find entries with stale scopes (Path that no longer exists as a directory)
+ let stale_indices: Vec = entries
+ .iter()
+ .enumerate()
+ .filter(|(_, e)| match &e.scope {
+ Scope::Path(p) => !p.is_dir(),
+ Scope::Global => false,
+ })
+ .map(|(i, _)| i)
+ .collect();
+
+ if stale_indices.is_empty() {
+ eprintln!("All scoped entries point to existing directories.");
+ return Ok(());
+ }
+
+ eprintln!("Found {} entries with stale scopes:", stale_indices.len());
+ for &idx in &stale_indices {
+ let entry = &entries[idx];
+ let icon = if entry.sensitive { "\u{1F512}" } else { "\u{1F4CB}" };
+ eprintln!(
+ " {} {} [{}] \u{2014} {}",
+ icon, entry.name, entry.scope, entry.description,
+ );
+ }
+ eprintln!();
+
+ let confirm = inquire::Confirm::new("Remove these entries?")
+ .with_default(false)
+ .prompt()
+ .map_err(|e| OpaqError::Io(std::io::Error::other(e.to_string())))?;
+
+ if !confirm {
+ return Ok(());
+ }
+
+ // Remove stale entries in reverse order to preserve indices
+ for &idx in stale_indices.iter().rev() {
+ entries.remove(idx);
+ }
+
+ store::save_store(&entries)?;
+
+ eprintln!("Removed {} stale entries.", stale_indices.len());
+ Ok(())
+}
diff --git a/src/commands/edit.rs b/src/commands/edit.rs
index b04d11e..50a328a 100644
--- a/src/commands/edit.rs
+++ b/src/commands/edit.rs
@@ -2,6 +2,7 @@
use chrono::Utc;
+use crate::commands::add::prompt_scope;
use crate::error::{OpaqError, Result};
use crate::model::normalize_tags;
use crate::store;
@@ -40,7 +41,7 @@ pub fn execute(
}
} else {
// Interactive menu (no flags provided)
- let options = vec!["Edit description", "Edit tags", "Rotate value"];
+ let options = vec!["Edit description", "Edit tags", "Rotate value", "Sensitivity", "Scope"];
let selection = inquire::Select::new("What would you like to edit?", options)
.prompt()
.map_err(|e| OpaqError::Io(std::io::Error::other(e.to_string())))?;
@@ -66,6 +67,31 @@ pub fn execute(
let value = prompt_secret_value()?;
entry.value = value.into_bytes();
}
+ "Sensitivity" => {
+ let current_label = if entry.sensitive { "Secret" } else { "Plain" };
+ eprintln!("Current sensitivity: {}", current_label);
+
+ let toggle_label = if entry.sensitive {
+ "Change to Plain?"
+ } else {
+ "Change to Secret?"
+ };
+ let confirmed = inquire::Confirm::new(toggle_label)
+ .with_default(false)
+ .prompt()
+ .map_err(|e| OpaqError::Io(std::io::Error::other(e.to_string())))?;
+
+ if confirmed {
+ entry.sensitive = !entry.sensitive;
+ entry.updated_at = Utc::now();
+ }
+ }
+ "Scope" => {
+ eprintln!("Current scope: {}", entry.scope);
+ let new_scope = prompt_scope()?;
+ entry.scope = new_scope;
+ entry.updated_at = Utc::now();
+ }
_ => unreachable!(),
}
}
diff --git a/src/commands/export_cmd.rs b/src/commands/export_cmd.rs
index c37fbdc..21893aa 100644
--- a/src/commands/export_cmd.rs
+++ b/src/commands/export_cmd.rs
@@ -70,6 +70,8 @@ mod tests {
"First token".to_string(),
vec!["ci".to_string()],
b"secret_a".to_vec(),
+ true,
+ crate::model::Scope::Global,
)
.unwrap(),
SecretEntry::new(
@@ -77,6 +79,8 @@ mod tests {
"Second token".to_string(),
vec!["api".to_string()],
b"secret_b".to_vec(),
+ true,
+ crate::model::Scope::Global,
)
.unwrap(),
];
@@ -108,6 +112,8 @@ mod tests {
"desc".to_string(),
vec![],
b"value".to_vec(),
+ true,
+ crate::model::Scope::Global,
)
.unwrap()];
diff --git a/src/commands/import_cmd.rs b/src/commands/import_cmd.rs
index 10c06e8..0211a2a 100644
--- a/src/commands/import_cmd.rs
+++ b/src/commands/import_cmd.rs
@@ -52,20 +52,21 @@ pub fn execute(file: String, overwrite: bool) -> Result<()> {
vec![]
};
- // 6. Build index of existing names
- let mut name_index: HashMap = HashMap::new();
+ // 6. Build index of existing entries by (name, scope)
+ let mut key_index: HashMap<(String, String), usize> = HashMap::new();
for (i, entry) in local_entries.iter().enumerate() {
- name_index.insert(entry.name.clone(), i);
+ key_index.insert((entry.name.clone(), format!("{}", entry.scope)), i);
}
- // 7. Merge imported entries
+ // 7. Merge imported entries (conflict key: name + scope)
let mut new_count: usize = 0;
let mut overwrite_count: usize = 0;
let mut auto_overwrite = overwrite;
for imported in imported_entries {
- if let Some(&idx) = name_index.get(&imported.name) {
- // Conflict: name already exists
+ let key = (imported.name.clone(), format!("{}", imported.scope));
+ if let Some(&idx) = key_index.get(&key) {
+ // Conflict: same name AND same scope
if auto_overwrite {
local_entries[idx] = imported;
overwrite_count += 1;
@@ -92,9 +93,9 @@ pub fn execute(file: String, overwrite: bool) -> Result<()> {
}
}
} else {
- // New entry
+ // New entry (different name or same name with different scope)
let new_idx = local_entries.len();
- name_index.insert(imported.name.clone(), new_idx);
+ key_index.insert(key, new_idx);
local_entries.push(imported);
new_count += 1;
}
@@ -143,6 +144,8 @@ mod tests {
"First".to_string(),
vec!["ci".to_string()],
b"value_a".to_vec(),
+ true,
+ crate::model::Scope::Global,
)
.unwrap(),
SecretEntry::new(
@@ -150,6 +153,8 @@ mod tests {
"Second".to_string(),
vec![],
b"value_b".to_vec(),
+ true,
+ crate::model::Scope::Global,
)
.unwrap(),
];
@@ -176,6 +181,8 @@ mod tests {
"desc".to_string(),
vec![],
b"val".to_vec(),
+ true,
+ crate::model::Scope::Global,
)
.unwrap()];
@@ -194,6 +201,8 @@ mod tests {
"New entry A".to_string(),
vec![],
b"a".to_vec(),
+ true,
+ crate::model::Scope::Global,
)
.unwrap(),
SecretEntry::new(
@@ -201,6 +210,8 @@ mod tests {
"New entry B".to_string(),
vec![],
b"b".to_vec(),
+ true,
+ crate::model::Scope::Global,
)
.unwrap(),
];
@@ -225,6 +236,8 @@ mod tests {
"Old".to_string(),
vec![],
b"old_value".to_vec(),
+ true,
+ crate::model::Scope::Global,
)
.unwrap()];
@@ -233,6 +246,8 @@ mod tests {
"New".to_string(),
vec!["updated".to_string()],
b"new_value".to_vec(),
+ true,
+ crate::model::Scope::Global,
)
.unwrap();
@@ -250,4 +265,123 @@ mod tests {
let result = deserialize_store(garbage);
assert!(result.is_err());
}
+
+ #[test]
+ fn scope_aware_merge_same_name_different_scope_adds_new() {
+ use std::collections::HashMap;
+ use std::path::PathBuf;
+
+ // Local store has EXISTING [global]
+ let mut local = vec![SecretEntry::new(
+ "EXISTING".to_string(),
+ "Global one".to_string(),
+ vec![],
+ b"global_value".to_vec(),
+ true,
+ crate::model::Scope::Global,
+ )
+ .unwrap()];
+
+ // Imported entry has same name but different scope
+ let imported = SecretEntry::new(
+ "EXISTING".to_string(),
+ "Scoped one".to_string(),
+ vec![],
+ b"scoped_value".to_vec(),
+ true,
+ crate::model::Scope::Path(PathBuf::from("/home/eco/work")),
+ )
+ .unwrap();
+
+ // Build key index
+ let mut key_index: HashMap<(String, String), usize> = HashMap::new();
+ for (i, entry) in local.iter().enumerate() {
+ key_index.insert((entry.name.clone(), format!("{}", entry.scope)), i);
+ }
+
+ // Merge: same name but different scope should add as new
+ let key = (imported.name.clone(), format!("{}", imported.scope));
+ if key_index.get(&key).is_none() {
+ let new_idx = local.len();
+ key_index.insert(key, new_idx);
+ local.push(imported);
+ }
+
+ assert_eq!(local.len(), 2);
+ assert_eq!(local[0].description, "Global one");
+ assert_eq!(local[1].description, "Scoped one");
+ }
+
+ #[test]
+ fn scope_aware_merge_same_name_same_scope_overwrites() {
+ use std::collections::HashMap;
+
+ // Local store has EXISTING [global]
+ let mut local = vec![SecretEntry::new(
+ "EXISTING".to_string(),
+ "Old".to_string(),
+ vec![],
+ b"old_value".to_vec(),
+ true,
+ crate::model::Scope::Global,
+ )
+ .unwrap()];
+
+ // Imported entry has same name AND same scope
+ let imported = SecretEntry::new(
+ "EXISTING".to_string(),
+ "New".to_string(),
+ vec!["updated".to_string()],
+ b"new_value".to_vec(),
+ true,
+ crate::model::Scope::Global,
+ )
+ .unwrap();
+
+ // Build key index
+ let mut key_index: HashMap<(String, String), usize> = HashMap::new();
+ for (i, entry) in local.iter().enumerate() {
+ key_index.insert((entry.name.clone(), format!("{}", entry.scope)), i);
+ }
+
+ // Merge with overwrite: same name + same scope should overwrite
+ let key = (imported.name.clone(), format!("{}", imported.scope));
+ if let Some(&idx) = key_index.get(&key) {
+ local[idx] = imported;
+ }
+
+ assert_eq!(local.len(), 1);
+ assert_eq!(local[0].description, "New");
+ assert_eq!(local[0].value, b"new_value");
+ }
+
+ #[test]
+ fn v0_bundle_import_applies_migration() {
+ // V0 entries lack sensitive/scope. deserialize_store handles the migration.
+ // Simulate a V0 bundle by serializing V0-format data (raw bincode, no version prefix).
+ use crate::store::deserialize_store;
+
+ // Create V1 entries with known sensitive/scope values to verify they round-trip
+ let entries = vec![
+ SecretEntry::new(
+ "TOKEN_V1".to_string(),
+ "V1 token".to_string(),
+ vec!["ci".to_string()],
+ b"v1_value".to_vec(),
+ false, // non-sensitive
+ crate::model::Scope::Global,
+ )
+ .unwrap(),
+ ];
+
+ // Serialize as V1
+ let v1_bytes = serialize_store(&entries).unwrap();
+ let loaded = deserialize_store(&v1_bytes).unwrap();
+
+ // V1 bundle preserves fields as-is
+ assert_eq!(loaded.len(), 1);
+ assert_eq!(loaded[0].name, "TOKEN_V1");
+ assert!(!loaded[0].sensitive); // preserved as false
+ assert_eq!(loaded[0].scope, crate::model::Scope::Global);
+ }
}
diff --git a/src/commands/mod.rs b/src/commands/mod.rs
index 33328d2..77ade45 100644
--- a/src/commands/mod.rs
+++ b/src/commands/mod.rs
@@ -1,12 +1,15 @@
pub mod add;
+pub mod cleanup;
pub mod edit;
pub mod export_cmd;
pub mod import_cmd;
pub mod init;
pub mod lock;
pub mod remove;
+pub mod reveal;
pub mod run;
pub mod search;
pub mod setup;
+pub mod shadows;
pub mod setup_claude;
pub mod unlock;
diff --git a/src/commands/remove.rs b/src/commands/remove.rs
index 5ce879e..ef53e69 100644
--- a/src/commands/remove.rs
+++ b/src/commands/remove.rs
@@ -7,11 +7,19 @@ pub fn execute(name: String) -> Result<()> {
// Load existing store
let mut entries = store::load_store()?;
- // Find entry by name
- let pos = entries
+ // Find all entries matching the name
+ let matching: Vec = entries
.iter()
- .position(|e| e.name == name)
- .ok_or_else(|| OpaqError::SecretNotFound(name.clone()))?;
+ .enumerate()
+ .filter(|(_, e)| e.name == name)
+ .map(|(i, _)| i)
+ .collect();
+
+ let pos = match matching.len() {
+ 0 => return Err(OpaqError::SecretNotFound(name.clone())),
+ 1 => matching[0],
+ _ => disambiguate(&entries, &matching, &name)?,
+ };
// Prompt for confirmation (default: No)
let prompt = format!("Remove {}? This cannot be undone.", name);
@@ -33,3 +41,39 @@ pub fn execute(name: String) -> Result<()> {
eprintln!(" Secret {} removed", name);
Ok(())
}
+
+fn disambiguate(
+ entries: &[crate::model::SecretEntry],
+ indices: &[usize],
+ name: &str,
+) -> Result {
+ eprintln!("Multiple entries named '{}':", name);
+ for (i, &idx) in indices.iter().enumerate() {
+ let entry = &entries[idx];
+ let icon = if entry.sensitive { "\u{1F512}" } else { "\u{1F4CB}" };
+ eprintln!(
+ " {}. {} {} [{}] \u{2014} {}",
+ i + 1,
+ icon,
+ entry.name,
+ entry.scope,
+ entry.description,
+ );
+ }
+
+ let prompt = format!("Which entry to remove? (1-{})", indices.len());
+ let input = inquire::Text::new(&prompt)
+ .prompt()
+ .map_err(|e| OpaqError::Io(std::io::Error::other(e.to_string())))?;
+
+ let choice: usize = input
+ .trim()
+ .parse()
+ .map_err(|_| OpaqError::AmbiguousEntry(name.to_string()))?;
+
+ if choice < 1 || choice > indices.len() {
+ return Err(OpaqError::AmbiguousEntry(name.to_string()));
+ }
+
+ Ok(indices[choice - 1])
+}
diff --git a/src/commands/reveal.rs b/src/commands/reveal.rs
new file mode 100644
index 0000000..e8043e9
--- /dev/null
+++ b/src/commands/reveal.rs
@@ -0,0 +1,70 @@
+// opaq: reveal command implementation
+
+use std::io::Write;
+
+use crate::error::{OpaqError, Result};
+use crate::model::Scope;
+use crate::store;
+
+pub fn execute(name: String, json: bool, scope_override: Option) -> Result<()> {
+ let entries = store::load_store()?;
+
+ // Filter candidates by name
+ let candidates: Vec<_> = entries.iter().filter(|e| e.name == name).collect();
+
+ // Apply scope filtering
+ let applicable: Vec<_> = if let Some(ref scope_str) = scope_override {
+ if scope_str == "global" {
+ candidates
+ .into_iter()
+ .filter(|e| e.scope == Scope::Global)
+ .collect()
+ } else {
+ let canon = std::fs::canonicalize(scope_str).map_err(|_| {
+ OpaqError::InvalidScopePath(scope_str.clone())
+ })?;
+ if !canon.is_dir() {
+ return Err(OpaqError::InvalidScopePath(scope_str.clone()));
+ }
+ candidates
+ .into_iter()
+ .filter(|e| e.scope == Scope::Path(canon.clone()))
+ .collect()
+ }
+ } else {
+ let cwd = std::env::current_dir()?;
+ candidates
+ .into_iter()
+ .filter(|e| e.scope.contains(&cwd))
+ .collect()
+ };
+
+ // Pick highest specificity winner
+ let winner = applicable
+ .into_iter()
+ .max_by_key(|e| e.scope.specificity())
+ .ok_or_else(|| OpaqError::SecretNotFound(name.clone()))?;
+
+ // Check sensitivity
+ if winner.sensitive {
+ return Err(OpaqError::RevealSensitive(name));
+ }
+
+ // Output
+ if json {
+ let output = serde_json::json!({
+ "name": winner.name,
+ "value": String::from_utf8_lossy(&winner.value),
+ "scope": format!("{}", winner.scope),
+ });
+ println!(
+ "{}",
+ serde_json::to_string_pretty(&output)
+ .map_err(|e| OpaqError::Serialization(e.to_string()))?
+ );
+ } else {
+ std::io::stdout().write_all(&winner.value)?;
+ }
+
+ Ok(())
+}
diff --git a/src/commands/run.rs b/src/commands/run.rs
index 2b286ba..a37ea07 100644
--- a/src/commands/run.rs
+++ b/src/commands/run.rs
@@ -30,16 +30,16 @@ pub fn execute(command: Vec) -> Result<()> {
// Step 1: Decrypt the store
let entries = load_store()?;
- // Step 2: Resolve placeholders
- let resolved = resolve_placeholders(&command, &entries);
+ // Step 2: Resolve placeholders (scope-aware)
+ let cwd = std::env::current_dir()?;
+ let resolved = resolve_placeholders(&command, &entries, &cwd);
- // Step 3: Build the output filter
- let filter = Arc::new(OutputFilter::new(&resolved.injected_secrets)?);
+ // Step 3: Build the output filter (sensitive secrets only)
+ let filter = Arc::new(OutputFilter::new(&resolved.sensitive_secrets)?);
- // Step 4: Set up file scrubber
+ // Step 4: Set up file scrubber (sensitive secrets only)
let extra_paths = parse_output_paths(&resolved.args);
- let cwd = std::env::current_dir()?;
- let mut scrubber = FileScrubber::new(resolved.injected_secrets.clone(), &cwd, extra_paths)?;
+ let mut scrubber = FileScrubber::new(resolved.sensitive_secrets.clone(), &cwd, extra_paths)?;
// Step 5: Spawn child process
let mut child = Command::new(&resolved.args[0])
diff --git a/src/commands/search.rs b/src/commands/search.rs
index e3ec4ce..f4a68bd 100644
--- a/src/commands/search.rs
+++ b/src/commands/search.rs
@@ -4,10 +4,21 @@ use crate::error::Result;
use crate::search::fuzzy_search;
use crate::store::load_store;
-pub fn execute(query: String, json: bool) -> Result<()> {
+pub fn execute(query: String, json: bool, all_scopes: bool) -> Result<()> {
let entries = load_store()?;
let results = fuzzy_search(&query, &entries);
+ // Scope filtering
+ let results = if all_scopes {
+ results
+ } else {
+ let cwd = std::env::current_dir()?;
+ results
+ .into_iter()
+ .filter(|r| r.scope.contains(&cwd))
+ .collect()
+ };
+
if results.is_empty() {
println!("No secrets found matching \"{}\".", query);
println!("Tip: add secrets with `opaq add`.");
@@ -31,21 +42,37 @@ fn print_text(query: &str, results: &[crate::search::SearchResult]) {
println!("Found {} secrets matching \"{}\":", count, query);
}
- // Calculate column width based on longest name (including {{ }})
- let max_name_len = results
+ // Calculate column widths
+ let max_placeholder_len = results
.iter()
.map(|r| r.name.len() + 4) // +4 for {{ and }}
.max()
.unwrap_or(0);
+ let max_desc_len = results
+ .iter()
+ .map(|r| r.description.len())
+ .max()
+ .unwrap_or(0);
+
for result in results {
+ let emoji = if result.sensitive {
+ "\u{1f512}"
+ } else {
+ "\u{1f4cb}"
+ };
let placeholder = format!("{{{{{}}}}}", result.name);
- let padding = max_name_len - placeholder.len() + 4;
+ let placeholder_padding = max_placeholder_len - placeholder.len() + 4;
+ let scope_display = format!("[{}]", result.scope);
+ let desc_padding = max_desc_len - result.description.len() + 4;
println!(
- " {}{}{}",
+ " {} {}{}{}{}{}",
+ emoji,
placeholder,
- " ".repeat(padding),
- result.description
+ " ".repeat(placeholder_padding),
+ result.description,
+ " ".repeat(desc_padding),
+ scope_display,
);
}
}
@@ -59,6 +86,8 @@ fn print_json(results: &[crate::search::SearchResult]) -> Result<()> {
"description": r.description,
"tags": r.tags,
"placeholder": format!("{{{{{}}}}}", r.name),
+ "sensitive": r.sensitive,
+ "scope": format!("{}", r.scope),
})
})
.collect();
diff --git a/src/commands/shadows.rs b/src/commands/shadows.rs
new file mode 100644
index 0000000..d62342f
--- /dev/null
+++ b/src/commands/shadows.rs
@@ -0,0 +1,68 @@
+// opaq: shadows command implementation
+
+use crate::error::Result;
+use crate::model::SecretEntry;
+use crate::store;
+
+pub fn execute(name: String) -> Result<()> {
+ let entries = store::load_store()?;
+
+ // Filter entries by name
+ let mut matches: Vec<&SecretEntry> = entries.iter().filter(|e| e.name == name).collect();
+
+ if matches.is_empty() {
+ eprintln!("Error: no entries named '{}'", name);
+ std::process::exit(1);
+ }
+
+ // Sort by specificity descending (most specific first)
+ matches.sort_by(|a, b| b.scope.specificity().cmp(&a.scope.specificity()));
+
+ let cwd = std::env::current_dir()?;
+
+ // Find the active entry (highest specificity that contains cwd)
+ let active_idx = matches
+ .iter()
+ .position(|e| e.scope.contains(&cwd));
+
+ println!("Entries named '{}':", name);
+
+ // Calculate scope column width for alignment
+ let max_scope_len = matches
+ .iter()
+ .map(|e| format!("{}", e.scope).len() + 2) // +2 for brackets
+ .max()
+ .unwrap_or(0);
+
+ for (i, entry) in matches.iter().enumerate() {
+ let is_active = active_idx == Some(i);
+ let in_scope = entry.scope.contains(&cwd);
+ let icon = if entry.sensitive { "\u{1f512}" } else { "\u{1f4cb}" };
+ let scope_str = format!("[{}]", entry.scope);
+ let scope_padded = format!("{:" } else { " " };
+
+ if in_scope {
+ if is_active {
+ println!(
+ "{} {} {} {} <- active from here",
+ prefix, icon, scope_padded, entry.description
+ );
+ } else {
+ println!(
+ "{} {} {} {}",
+ prefix, icon, scope_padded, entry.description
+ );
+ }
+ } else {
+ // Dim out-of-scope entries
+ println!(
+ "{} {} {} {} \x1b[2m(out of scope)\x1b[0m",
+ prefix, icon, scope_padded, entry.description
+ );
+ }
+ }
+
+ Ok(())
+}
diff --git a/src/error.rs b/src/error.rs
index 3c0f88a..3f6563c 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -53,6 +53,22 @@ pub enum OpaqError {
#[error("Unknown placeholder '{{{{{0}}}}}' is not a known opaq secret.")]
UnknownPlaceholder(String),
+ // Mutually exclusive flags (exit code 2)
+ #[error("Error: {0} are mutually exclusive")]
+ MutuallyExclusiveFlags(String),
+
+ // Reveal on sensitive entry (exit code 1)
+ #[error("Error: '{0}' is a secret entry. Use 'opaq run' to inject it into commands.")]
+ RevealSensitive(String),
+
+ // Invalid scope path (exit code 2)
+ #[error("Error: '{0}' is not an existing directory")]
+ InvalidScopePath(String),
+
+ // Ambiguous entry — triggers interactive disambiguation, message unused
+ #[error("")]
+ AmbiguousEntry(String),
+
// Filter errors
#[error("Failed to build output filter: {0}")]
FilterBuild(String),
@@ -68,7 +84,8 @@ pub enum OpaqError {
impl OpaqError {
pub fn exit_code(&self) -> i32 {
match self {
- Self::InvalidName(_) | Self::TtyRequired(_) | Self::UnknownPlaceholder(_) => 2,
+ Self::InvalidName(_) | Self::TtyRequired(_) | Self::UnknownPlaceholder(_)
+ | Self::MutuallyExclusiveFlags(_) | Self::InvalidScopePath(_) => 2,
_ => 1,
}
}
diff --git a/src/filter.rs b/src/filter.rs
index 8eb19e0..217d3a5 100644
--- a/src/filter.rs
+++ b/src/filter.rs
@@ -364,6 +364,43 @@ mod tests {
assert!(variants.is_empty());
}
+ #[test]
+ fn plain_values_not_masked() {
+ // OutputFilter only receives sensitive secrets, not plain values.
+ // Plain values should pass through unmasked.
+ let sensitive_secret = b"super-secret-token".to_vec();
+ let plain_value = b"https://example.com".to_vec();
+
+ // Build filter with ONLY the sensitive secret (as the run command does)
+ let filter = OutputFilter::new(&[sensitive_secret]).unwrap();
+
+ // Input contains both the sensitive secret and the plain value
+ let input_data = b"url=https://example.com token=super-secret-token";
+ let mut input = Cursor::new(input_data);
+ let mut output = Vec::new();
+ filter.filter_stream(&mut input, &mut output).unwrap();
+
+ let result = String::from_utf8(output).unwrap();
+ // Sensitive secret should be masked
+ assert!(
+ result.contains("[MASKED]"),
+ "Sensitive secret should be masked, got: {}",
+ result
+ );
+ assert!(
+ !result.contains("super-secret-token"),
+ "Sensitive secret value must not appear in output"
+ );
+ // Plain value should pass through unmasked
+ assert!(
+ result.contains("https://example.com"),
+ "Plain value should pass through unmasked, got: {}",
+ result
+ );
+ // Verify the plain_value was never added to the filter
+ let _ = plain_value; // just to show we intentionally excluded it
+ }
+
#[test]
fn generate_variants_has_all_types() {
let variants = generate_variants(b"my-secret/token+1");
diff --git a/src/model.rs b/src/model.rs
index bc3f810..fb85f06 100644
--- a/src/model.rs
+++ b/src/model.rs
@@ -1,10 +1,57 @@
// opaq: data model (SecretEntry and related types)
+use std::fmt;
+use std::path::{Path, PathBuf};
+
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::error::OpaqError;
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
+pub enum Scope {
+ Global,
+ Path(PathBuf),
+}
+
+impl Scope {
+ pub fn contains(&self, dir: &Path) -> bool {
+ match self {
+ Scope::Global => true,
+ Scope::Path(scope_path) => dir.starts_with(scope_path),
+ }
+ }
+
+ pub fn specificity(&self) -> usize {
+ match self {
+ Scope::Global => 0,
+ Scope::Path(p) => p.components().count(),
+ }
+ }
+}
+
+impl fmt::Display for Scope {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Scope::Global => write!(f, "global"),
+ Scope::Path(p) => {
+ let path_str = p.to_string_lossy();
+ if let Ok(home) = std::env::var("HOME") {
+ if path_str == home {
+ return write!(f, "~/");
+ }
+ let home_prefix = format!("{}/", home);
+ if path_str.starts_with(&home_prefix) {
+ let relative = &path_str[home.len()..];
+ return write!(f, "~{}/", relative.trim_end_matches('/'));
+ }
+ }
+ write!(f, "{}/", path_str.trim_end_matches('/'))
+ }
+ }
+ }
+}
+
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SecretEntry {
pub name: String,
@@ -13,6 +60,8 @@ pub struct SecretEntry {
pub value: Vec,
pub created_at: DateTime,
pub updated_at: DateTime,
+ pub sensitive: bool,
+ pub scope: Scope,
}
pub fn validate_name(name: &str) -> crate::error::Result<()> {
@@ -51,6 +100,8 @@ impl SecretEntry {
description: String,
tags: Vec,
value: Vec,
+ sensitive: bool,
+ scope: Scope,
) -> crate::error::Result {
validate_name(&name)?;
let now = Utc::now();
@@ -61,6 +112,8 @@ impl SecretEntry {
value,
created_at: now,
updated_at: now,
+ sensitive,
+ scope,
})
}
}
@@ -146,16 +199,89 @@ mod tests {
"A test token".to_string(),
vec!["CI".to_string(), "Api".to_string()],
b"secret_value".to_vec(),
+ true,
+ Scope::Global,
)
.unwrap();
assert_eq!(entry.name, "MY_TOKEN");
assert_eq!(entry.tags, vec!["api", "ci"]);
assert_eq!(entry.value, b"secret_value");
+ assert!(entry.sensitive);
+ assert_eq!(entry.scope, Scope::Global);
}
#[test]
fn secret_entry_new_invalid_name() {
- let result = SecretEntry::new("invalid".to_string(), "desc".to_string(), vec![], vec![]);
+ let result = SecretEntry::new(
+ "invalid".to_string(),
+ "desc".to_string(),
+ vec![],
+ vec![],
+ true,
+ Scope::Global,
+ );
assert!(result.is_err());
}
+
+ #[test]
+ fn scope_contains_global() {
+ let scope = Scope::Global;
+ assert!(scope.contains(Path::new("/any/path")));
+ assert!(scope.contains(Path::new("/home/user/project")));
+ assert!(scope.contains(Path::new("/")));
+ }
+
+ #[test]
+ fn scope_contains_path_descendants() {
+ let scope = Scope::Path(PathBuf::from("/home/eco/code"));
+ assert!(scope.contains(Path::new("/home/eco/code")));
+ assert!(scope.contains(Path::new("/home/eco/code/project")));
+ assert!(scope.contains(Path::new("/home/eco/code/project/src")));
+ }
+
+ #[test]
+ fn scope_contains_path_rejects_non_descendants() {
+ let scope = Scope::Path(PathBuf::from("/home/eco/code"));
+ assert!(!scope.contains(Path::new("/home/eco/other")));
+ assert!(!scope.contains(Path::new("/home/eco")));
+ assert!(!scope.contains(Path::new("/opt/services")));
+ }
+
+ #[test]
+ fn scope_specificity_ordering() {
+ let global = Scope::Global;
+ let short = Scope::Path(PathBuf::from("/home/eco"));
+ let deep = Scope::Path(PathBuf::from("/home/eco/code/project"));
+
+ assert_eq!(global.specificity(), 0);
+ assert!(global.specificity() < short.specificity());
+ assert!(short.specificity() < deep.specificity());
+ }
+
+ #[test]
+ fn scope_display_global() {
+ assert_eq!(format!("{}", Scope::Global), "global");
+ }
+
+ #[test]
+ fn scope_display_home_path() {
+ if let Ok(home) = std::env::var("HOME") {
+ let scope = Scope::Path(PathBuf::from(&home));
+ assert_eq!(format!("{}", scope), "~/");
+ }
+ }
+
+ #[test]
+ fn scope_display_home_subpath() {
+ if let Ok(home) = std::env::var("HOME") {
+ let scope = Scope::Path(PathBuf::from(format!("{}/code/project", home)));
+ assert_eq!(format!("{}", scope), "~/code/project/");
+ }
+ }
+
+ #[test]
+ fn scope_display_absolute_path() {
+ let scope = Scope::Path(PathBuf::from("/opt/services"));
+ assert_eq!(format!("{}", scope), "/opt/services/");
+ }
}
diff --git a/src/run.rs b/src/run.rs
index 9137d29..d915ad5 100644
--- a/src/run.rs
+++ b/src/run.rs
@@ -1,6 +1,7 @@
// opaq: run subcommand (placeholder injection, child process execution, output filtering)
-use std::collections::{HashMap, HashSet};
+use std::collections::HashSet;
+use std::path::Path;
use regex::Regex;
@@ -8,33 +9,51 @@ use crate::model::SecretEntry;
pub struct ResolvedCommand {
pub args: Vec,
- pub injected_secrets: Vec>,
+ pub sensitive_secrets: Vec>,
+ pub plain_values: Vec>,
}
/// Resolve `{{SECRET_NAME}}` placeholders in command arguments using the
-/// decrypted store. Returns the resolved args and the set of injected secret
-/// values (for use by the output filter and file scrubber).
-pub fn resolve_placeholders(args: &[String], store: &[SecretEntry]) -> ResolvedCommand {
+/// decrypted store. Performs scope-aware resolution (nearest-ancestor-wins from
+/// `cwd`) and classifies resolved values into sensitive secrets vs plain values.
+pub fn resolve_placeholders(args: &[String], store: &[SecretEntry], cwd: &Path) -> ResolvedCommand {
let re = Regex::new(r"\{\{([A-Z][A-Z0-9_]*)\}\}").expect("placeholder regex is valid");
- // Build a lookup map: name -> value
- let lookup: HashMap<&str, &[u8]> = store
- .iter()
- .map(|e| (e.name.as_str(), e.value.as_slice()))
- .collect();
-
- let mut seen_secrets: HashSet> = HashSet::new();
- let mut injected_secrets: Vec> = Vec::new();
+ let mut seen_sensitive: HashSet> = HashSet::new();
+ let mut seen_plain: HashSet> = HashSet::new();
+ let mut sensitive_secrets: Vec> = Vec::new();
+ let mut plain_values: Vec> = Vec::new();
let resolved_args: Vec = args
.iter()
.map(|arg| {
let result = re.replace_all(arg, |caps: ®ex::Captures| {
let name = &caps[1];
- match lookup.get(name) {
- Some(value) => {
- if seen_secrets.insert(value.to_vec()) {
- injected_secrets.push(value.to_vec());
+
+ // Find the winning entry via scope resolution
+ let mut best: Option<&SecretEntry> = None;
+ let mut best_specificity: usize = 0;
+
+ for entry in store.iter().filter(|e| e.name == name) {
+ if !entry.scope.contains(cwd) {
+ continue;
+ }
+ let spec = entry.scope.specificity();
+ if best.is_none() || spec > best_specificity {
+ best = Some(entry);
+ best_specificity = spec;
+ }
+ }
+
+ match best {
+ Some(winner) => {
+ let value = &winner.value;
+ if winner.sensitive {
+ if seen_sensitive.insert(value.clone()) {
+ sensitive_secrets.push(value.clone());
+ }
+ } else if seen_plain.insert(value.clone()) {
+ plain_values.push(value.clone());
}
String::from_utf8_lossy(value).into_owned()
}
@@ -43,7 +62,6 @@ pub fn resolve_placeholders(args: &[String], store: &[SecretEntry]) -> ResolvedC
"Warning: '{{{{{}}}}}' is not a known opaq secret \u{2014} it will not be interpolated. This may be intentional.",
name
);
- // Leave the placeholder as-is
caps[0].to_string()
}
}
@@ -54,13 +72,17 @@ pub fn resolve_placeholders(args: &[String], store: &[SecretEntry]) -> ResolvedC
ResolvedCommand {
args: resolved_args,
- injected_secrets,
+ sensitive_secrets,
+ plain_values,
}
}
#[cfg(test)]
mod tests {
use super::*;
+ use std::path::PathBuf;
+
+ use crate::model::Scope;
fn make_entry(name: &str, value: &[u8]) -> SecretEntry {
SecretEntry::new(
@@ -68,18 +90,42 @@ mod tests {
format!("Description for {}", name),
vec![],
value.to_vec(),
+ true,
+ Scope::Global,
+ )
+ .unwrap()
+ }
+
+ fn make_entry_with_scope(
+ name: &str,
+ value: &[u8],
+ sensitive: bool,
+ scope: Scope,
+ ) -> SecretEntry {
+ SecretEntry::new(
+ name.to_string(),
+ format!("Description for {}", name),
+ vec![],
+ value.to_vec(),
+ sensitive,
+ scope,
)
.unwrap()
}
+ fn default_cwd() -> PathBuf {
+ PathBuf::from("/tmp")
+ }
+
#[test]
fn single_placeholder() {
let store = vec![make_entry("API_TOKEN", b"secret123")];
let args = vec!["curl".into(), "-H".into(), "Bearer {{API_TOKEN}}".into()];
- let result = resolve_placeholders(&args, &store);
+ let result = resolve_placeholders(&args, &store, &default_cwd());
assert_eq!(result.args, vec!["curl", "-H", "Bearer secret123"]);
- assert_eq!(result.injected_secrets, vec![b"secret123".to_vec()]);
+ assert_eq!(result.sensitive_secrets, vec![b"secret123".to_vec()]);
+ assert!(result.plain_values.is_empty());
}
#[test]
@@ -87,11 +133,11 @@ mod tests {
let store = vec![make_entry("USER", b"admin"), make_entry("PASS", b"hunter2")];
let args = vec!["{{USER}}:{{PASS}}".into()];
- let result = resolve_placeholders(&args, &store);
+ let result = resolve_placeholders(&args, &store, &default_cwd());
assert_eq!(result.args, vec!["admin:hunter2"]);
- assert_eq!(result.injected_secrets.len(), 2);
- assert!(result.injected_secrets.contains(&b"admin".to_vec()));
- assert!(result.injected_secrets.contains(&b"hunter2".to_vec()));
+ assert_eq!(result.sensitive_secrets.len(), 2);
+ assert!(result.sensitive_secrets.contains(&b"admin".to_vec()));
+ assert!(result.sensitive_secrets.contains(&b"hunter2".to_vec()));
}
#[test]
@@ -99,9 +145,10 @@ mod tests {
let store = vec![make_entry("KNOWN", b"value")];
let args = vec!["{{UNKNOWN}}".into()];
- let result = resolve_placeholders(&args, &store);
+ let result = resolve_placeholders(&args, &store, &default_cwd());
assert_eq!(result.args, vec!["{{UNKNOWN}}"]);
- assert!(result.injected_secrets.is_empty());
+ assert!(result.sensitive_secrets.is_empty());
+ assert!(result.plain_values.is_empty());
}
#[test]
@@ -109,9 +156,9 @@ mod tests {
let store = vec![make_entry("TOKEN", b"abc")];
let args = vec!["{{TOKEN}} and {{MISSING}}".into()];
- let result = resolve_placeholders(&args, &store);
+ let result = resolve_placeholders(&args, &store, &default_cwd());
assert_eq!(result.args, vec!["abc and {{MISSING}}"]);
- assert_eq!(result.injected_secrets, vec![b"abc".to_vec()]);
+ assert_eq!(result.sensitive_secrets, vec![b"abc".to_vec()]);
}
#[test]
@@ -119,9 +166,10 @@ mod tests {
let store = vec![make_entry("TOKEN", b"abc")];
let args = vec!["curl".into(), "https://example.com".into()];
- let result = resolve_placeholders(&args, &store);
+ let result = resolve_placeholders(&args, &store, &default_cwd());
assert_eq!(result.args, vec!["curl", "https://example.com"]);
- assert!(result.injected_secrets.is_empty());
+ assert!(result.sensitive_secrets.is_empty());
+ assert!(result.plain_values.is_empty());
}
#[test]
@@ -134,12 +182,12 @@ mod tests {
"{{my_token}}".into(),
];
- let result = resolve_placeholders(&args, &store);
+ let result = resolve_placeholders(&args, &store, &default_cwd());
assert_eq!(
result.args,
vec!["{{ .Values.x }}", "{{.}}", "{{lowercase}}", "{{my_token}}"]
);
- assert!(result.injected_secrets.is_empty());
+ assert!(result.sensitive_secrets.is_empty());
}
#[test]
@@ -147,10 +195,10 @@ mod tests {
let store = vec![make_entry("TOKEN", b"secret")];
let args = vec!["{{TOKEN}}".into(), "{{TOKEN}}".into()];
- let result = resolve_placeholders(&args, &store);
+ let result = resolve_placeholders(&args, &store, &default_cwd());
assert_eq!(result.args, vec!["secret", "secret"]);
- assert_eq!(result.injected_secrets.len(), 1);
- assert_eq!(result.injected_secrets[0], b"secret");
+ assert_eq!(result.sensitive_secrets.len(), 1);
+ assert_eq!(result.sensitive_secrets[0], b"secret");
}
#[test]
@@ -158,12 +206,11 @@ mod tests {
let store = vec![make_entry("BINARY_VAL", &[0xFF, 0xFE, 0x41])];
let args = vec!["prefix-{{BINARY_VAL}}-suffix".into()];
- let result = resolve_placeholders(&args, &store);
- // from_utf8_lossy replaces invalid bytes with the replacement character
+ let result = resolve_placeholders(&args, &store, &default_cwd());
assert!(result.args[0].contains('\u{FFFD}'));
assert!(result.args[0].starts_with("prefix-"));
assert!(result.args[0].ends_with("-suffix"));
- assert_eq!(result.injected_secrets.len(), 1);
+ assert_eq!(result.sensitive_secrets.len(), 1);
}
#[test]
@@ -171,7 +218,7 @@ mod tests {
let store = vec![make_entry("A", b"x")];
let args = vec!["{{A}}".into()];
- let result = resolve_placeholders(&args, &store);
+ let result = resolve_placeholders(&args, &store, &default_cwd());
assert_eq!(result.args, vec!["x"]);
}
@@ -188,7 +235,7 @@ mod tests {
"https://{{HOST}}/api".into(),
];
- let result = resolve_placeholders(&args, &store);
+ let result = resolve_placeholders(&args, &store, &default_cwd());
assert_eq!(
result.args,
vec![
@@ -198,6 +245,75 @@ mod tests {
"https://example.com/api"
]
);
- assert_eq!(result.injected_secrets.len(), 2);
+ assert_eq!(result.sensitive_secrets.len(), 2);
+ }
+
+ #[test]
+ fn scope_resolution_nearest_ancestor_wins() {
+ let store = vec![
+ make_entry_with_scope("TOKEN", b"global_val", true, Scope::Global),
+ make_entry_with_scope(
+ "TOKEN",
+ b"project_val",
+ true,
+ Scope::Path(PathBuf::from("/home/eco/code")),
+ ),
+ ];
+ let args = vec!["{{TOKEN}}".into()];
+ let cwd = PathBuf::from("/home/eco/code/project");
+
+ let result = resolve_placeholders(&args, &store, &cwd);
+ assert_eq!(result.args, vec!["project_val"]);
+ assert_eq!(result.sensitive_secrets, vec![b"project_val".to_vec()]);
+ }
+
+ #[test]
+ fn sensitivity_classification() {
+ let store = vec![
+ make_entry_with_scope("SECRET_TOKEN", b"secret_val", true, Scope::Global),
+ make_entry_with_scope("PLAIN_URL", b"https://example.com", false, Scope::Global),
+ ];
+ let args = vec!["{{SECRET_TOKEN}}".into(), "{{PLAIN_URL}}".into()];
+
+ let result = resolve_placeholders(&args, &store, &default_cwd());
+ assert_eq!(result.args, vec!["secret_val", "https://example.com"]);
+ assert_eq!(result.sensitive_secrets, vec![b"secret_val".to_vec()]);
+ assert_eq!(
+ result.plain_values,
+ vec![b"https://example.com".to_vec()]
+ );
+ }
+
+ #[test]
+ fn scope_resolution_global_fallback() {
+ let store = vec![make_entry_with_scope(
+ "TOKEN",
+ b"global_val",
+ true,
+ Scope::Global,
+ )];
+ let args = vec!["{{TOKEN}}".into()];
+ let cwd = PathBuf::from("/some/random/dir");
+
+ let result = resolve_placeholders(&args, &store, &cwd);
+ assert_eq!(result.args, vec!["global_val"]);
+ assert_eq!(result.sensitive_secrets, vec![b"global_val".to_vec()]);
+ }
+
+ #[test]
+ fn out_of_scope_entry_treated_as_unknown() {
+ let store = vec![make_entry_with_scope(
+ "TOKEN",
+ b"scoped_val",
+ true,
+ Scope::Path(PathBuf::from("/home/eco/code")),
+ )];
+ let args = vec!["{{TOKEN}}".into()];
+ let cwd = PathBuf::from("/opt/other");
+
+ let result = resolve_placeholders(&args, &store, &cwd);
+ assert_eq!(result.args, vec!["{{TOKEN}}"]);
+ assert!(result.sensitive_secrets.is_empty());
+ assert!(result.plain_values.is_empty());
}
}
diff --git a/src/search.rs b/src/search.rs
index 2d21ab4..eabfcbf 100644
--- a/src/search.rs
+++ b/src/search.rs
@@ -3,7 +3,7 @@
use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
use nucleo_matcher::{Config, Matcher};
-use crate::model::SecretEntry;
+use crate::model::{Scope, SecretEntry};
const NAME_WEIGHT: u32 = 3;
const TAGS_WEIGHT: u32 = 3;
@@ -14,6 +14,8 @@ pub struct SearchResult {
pub description: String,
pub tags: Vec,
pub score: u32,
+ pub sensitive: bool,
+ pub scope: Scope,
}
pub fn fuzzy_search(query: &str, entries: &[SecretEntry]) -> Vec {
@@ -57,6 +59,8 @@ pub fn fuzzy_search(query: &str, entries: &[SecretEntry]) -> Vec {
description: entry.description.clone(),
tags: entry.tags.clone(),
score: total,
+ sensitive: entry.sensitive,
+ scope: entry.scope.clone(),
})
} else {
None
@@ -78,6 +82,8 @@ mod tests {
desc.to_string(),
tags.iter().map(|s| s.to_string()).collect(),
vec![],
+ true,
+ crate::model::Scope::Global,
)
.unwrap()
}
diff --git a/src/store.rs b/src/store.rs
index b5ae67e..2a70ac8 100644
--- a/src/store.rs
+++ b/src/store.rs
@@ -7,16 +7,58 @@ use std::path::PathBuf;
use tempfile::NamedTempFile;
+use chrono::{DateTime, Utc};
+use serde::Deserialize;
+
use crate::crypto::key_to_passphrase;
use crate::error::{OpaqError, Result};
-use crate::model::SecretEntry;
+use crate::model::{Scope, SecretEntry};
+
+#[derive(Deserialize)]
+#[cfg_attr(test, derive(serde::Serialize))]
+struct SecretEntryV0 {
+ name: String,
+ description: String,
+ tags: Vec,
+ value: Vec,
+ created_at: DateTime,
+ updated_at: DateTime,
+}
+
+impl From for SecretEntry {
+ fn from(old: SecretEntryV0) -> Self {
+ SecretEntry {
+ name: old.name,
+ description: old.description,
+ tags: old.tags,
+ value: old.value,
+ created_at: old.created_at,
+ updated_at: old.updated_at,
+ sensitive: true,
+ scope: Scope::Global,
+ }
+ }
+}
pub fn serialize_store(entries: &[SecretEntry]) -> Result> {
- bincode::serialize(entries).map_err(|e| OpaqError::Serialization(e.to_string()))
+ let mut out = vec![0x01u8];
+ let payload =
+ bincode::serialize(entries).map_err(|e| OpaqError::Serialization(e.to_string()))?;
+ out.extend(payload);
+ Ok(out)
}
pub fn deserialize_store(data: &[u8]) -> Result> {
- bincode::deserialize(data).map_err(|e| OpaqError::Serialization(e.to_string()))
+ if data.is_empty() {
+ return Ok(Vec::new());
+ }
+ if data[0] == 0x01 {
+ bincode::deserialize(&data[1..]).map_err(|e| OpaqError::Serialization(e.to_string()))
+ } else {
+ let old_entries: Vec =
+ bincode::deserialize(data).map_err(|e| OpaqError::Serialization(e.to_string()))?;
+ Ok(old_entries.into_iter().map(SecretEntry::from).collect())
+ }
}
pub fn store_dir() -> PathBuf {
@@ -118,6 +160,8 @@ mod tests {
"First token".to_string(),
vec!["ci".to_string(), "api".to_string()],
b"secret_value_a".to_vec(),
+ true,
+ crate::model::Scope::Global,
)
.unwrap(),
SecretEntry::new(
@@ -125,6 +169,8 @@ mod tests {
"Second token".to_string(),
vec!["registry".to_string()],
vec![0x00, 0x01, 0xFF, 0xFE],
+ true,
+ crate::model::Scope::Global,
)
.unwrap(),
];
@@ -157,6 +203,12 @@ mod tests {
assert!(deserialized.is_empty());
}
+ #[test]
+ fn deserialize_empty_bytes_returns_empty_vec() {
+ let deserialized = deserialize_store(&[]).unwrap();
+ assert!(deserialized.is_empty());
+ }
+
#[test]
fn deserialize_garbage_returns_error() {
let garbage = b"this is not valid bincode data at all";
@@ -168,6 +220,74 @@ mod tests {
}
}
+ #[test]
+ fn v0_to_v1_migration() {
+ // Serialize entries in V0 format (raw bincode, no version prefix)
+ // Use 2 entries so the bincode length prefix byte is 0x02, not 0x01
+ let now = chrono::Utc::now();
+ let v0_entries = vec![
+ SecretEntryV0 {
+ name: "OLD_TOKEN_A".to_string(),
+ description: "A legacy token".to_string(),
+ tags: vec!["ci".to_string()],
+ value: b"old_secret_a".to_vec(),
+ created_at: now,
+ updated_at: now,
+ },
+ SecretEntryV0 {
+ name: "OLD_TOKEN_B".to_string(),
+ description: "Another legacy token".to_string(),
+ tags: vec![],
+ value: b"old_secret_b".to_vec(),
+ created_at: now,
+ updated_at: now,
+ },
+ ];
+ let v0_bytes = bincode::serialize(&v0_entries).unwrap();
+
+ // First byte should not be 0x01 (V0 format uses bincode length prefix)
+ assert_ne!(v0_bytes[0], 0x01);
+
+ // Deserialize should migrate to V1
+ let migrated = deserialize_store(&v0_bytes).unwrap();
+ assert_eq!(migrated.len(), 2);
+ assert_eq!(migrated[0].name, "OLD_TOKEN_A");
+ assert_eq!(migrated[0].description, "A legacy token");
+ assert_eq!(migrated[0].tags, vec!["ci"]);
+ assert_eq!(migrated[0].value, b"old_secret_a");
+ assert!(migrated[0].sensitive);
+ assert_eq!(migrated[0].scope, crate::model::Scope::Global);
+ assert_eq!(migrated[1].name, "OLD_TOKEN_B");
+ assert!(migrated[1].sensitive);
+ assert_eq!(migrated[1].scope, crate::model::Scope::Global);
+ }
+
+ #[test]
+ fn v1_version_byte_routing() {
+ // Serialize with V1 format
+ let entries = vec![
+ SecretEntry::new(
+ "V1_TOKEN".to_string(),
+ "A V1 token".to_string(),
+ vec![],
+ b"v1_value".to_vec(),
+ false,
+ crate::model::Scope::Global,
+ )
+ .unwrap(),
+ ];
+ let serialized = serialize_store(&entries).unwrap();
+
+ // First byte should be 0x01
+ assert_eq!(serialized[0], 0x01);
+
+ // Deserialize should preserve V1 fields
+ let deserialized = deserialize_store(&serialized).unwrap();
+ assert_eq!(deserialized.len(), 1);
+ assert_eq!(deserialized[0].name, "V1_TOKEN");
+ assert!(!deserialized[0].sensitive);
+ }
+
#[test]
fn write_then_read_round_trip() {
let tmp = tempfile::tempdir().unwrap();
@@ -181,6 +301,8 @@ mod tests {
"A test secret".to_string(),
vec!["test".to_string()],
b"supersecretvalue".to_vec(),
+ true,
+ crate::model::Scope::Global,
)
.unwrap(),
SecretEntry::new(
@@ -188,6 +310,8 @@ mod tests {
"Another token".to_string(),
vec!["api".to_string(), "ci".to_string()],
b"token123".to_vec(),
+ true,
+ crate::model::Scope::Global,
)
.unwrap(),
];
diff --git a/tests/integration.rs b/tests/integration.rs
index 71aaf55..cc5b6d4 100644
--- a/tests/integration.rs
+++ b/tests/integration.rs
@@ -31,6 +31,8 @@ fn make_entry(name: &str, desc: &str, tags: &[&str], value: &[u8]) -> SecretEntr
desc.to_string(),
tags.iter().map(|s| s.to_string()).collect(),
value.to_vec(),
+ true,
+ opaq::model::Scope::Global,
)
.unwrap()
}
@@ -209,7 +211,8 @@ fn run_placeholder_resolution_full_workflow() {
"Authorization: Bearer {{API_TOKEN}}".into(),
"https://{{DB_HOST}}/api/v1".into(),
];
- let resolved = resolve_placeholders(&args, &loaded);
+ let cwd = std::path::Path::new("/tmp");
+ let resolved = resolve_placeholders(&args, &loaded, cwd);
assert_eq!(resolved.args[0], "curl");
assert_eq!(resolved.args[1], "-H");
@@ -218,7 +221,7 @@ fn run_placeholder_resolution_full_workflow() {
"Authorization: Bearer super-secret-value-12345"
);
assert_eq!(resolved.args[3], "https://db.internal.example.com/api/v1");
- assert_eq!(resolved.injected_secrets.len(), 2);
+ assert_eq!(resolved.sensitive_secrets.len(), 2);
}
#[test]
@@ -281,10 +284,11 @@ fn run_unknown_placeholder_left_asis() {
let entries = vec![make_entry("KNOWN", "Known secret", &[], b"value")];
let args: Vec = vec!["echo".into(), "{{KNOWN}} and {{UNKNOWN}}".into()];
- let resolved = resolve_placeholders(&args, &entries);
+ let cwd = std::path::Path::new("/tmp");
+ let resolved = resolve_placeholders(&args, &entries, cwd);
assert_eq!(resolved.args[1], "value and {{UNKNOWN}}");
- assert_eq!(resolved.injected_secrets.len(), 1);
+ assert_eq!(resolved.sensitive_secrets.len(), 1);
}
#[test]
@@ -298,13 +302,14 @@ fn run_non_opaq_curly_patterns_pass_through() {
"{{.}}".into(),
"{{lowercase}}".into(),
];
- let resolved = resolve_placeholders(&args, &entries);
+ let cwd = std::path::Path::new("/tmp");
+ let resolved = resolve_placeholders(&args, &entries, cwd);
// None of these should be touched
assert_eq!(resolved.args[2], "{{ .Values.image }}");
assert_eq!(resolved.args[3], "{{.}}");
assert_eq!(resolved.args[4], "{{lowercase}}");
- assert!(resolved.injected_secrets.is_empty());
+ assert!(resolved.sensitive_secrets.is_empty());
}
// ---------------------------------------------------------------------------
@@ -561,13 +566,14 @@ fn full_workflow_init_add_search_run() {
"Authorization: Bearer {{SONARQUBE_TOKEN}}".into(),
"https://sonar.example.com/api/v1".into(),
];
- let resolved = resolve_placeholders(&args, &entries);
+ let cwd = std::path::Path::new("/tmp");
+ let resolved = resolve_placeholders(&args, &entries, cwd);
assert_eq!(resolved.args[2], "Authorization: Bearer sqp_a1b2c3d4e5f6");
- assert_eq!(resolved.injected_secrets.len(), 1);
- assert_eq!(resolved.injected_secrets[0], b"sqp_a1b2c3d4e5f6");
+ assert_eq!(resolved.sensitive_secrets.len(), 1);
+ assert_eq!(resolved.sensitive_secrets[0], b"sqp_a1b2c3d4e5f6");
// Step 5: Verify output filter masks the resolved value
- let filter = OutputFilter::new(&resolved.injected_secrets).unwrap();
+ let filter = OutputFilter::new(&resolved.sensitive_secrets).unwrap();
let simulated_output = b"Response from sonar: sqp_a1b2c3d4e5f6 authenticated";
let mut input = Cursor::new(simulated_output.as_slice());
let mut output = Vec::new();
@@ -703,19 +709,19 @@ fn encryption_roundtrip_with_binary_secret_values() {
#[test]
fn secret_name_validation_rejects_invalid_names() {
- assert!(SecretEntry::new("lowercase".into(), "d".into(), vec![], vec![]).is_err());
- assert!(SecretEntry::new("_LEADING".into(), "d".into(), vec![], vec![]).is_err());
- assert!(SecretEntry::new("1DIGIT".into(), "d".into(), vec![], vec![]).is_err());
- assert!(SecretEntry::new("HAS-DASH".into(), "d".into(), vec![], vec![]).is_err());
- assert!(SecretEntry::new("".into(), "d".into(), vec![], vec![]).is_err());
+ assert!(SecretEntry::new("lowercase".into(), "d".into(), vec![], vec![], true, opaq::model::Scope::Global).is_err());
+ assert!(SecretEntry::new("_LEADING".into(), "d".into(), vec![], vec![], true, opaq::model::Scope::Global).is_err());
+ assert!(SecretEntry::new("1DIGIT".into(), "d".into(), vec![], vec![], true, opaq::model::Scope::Global).is_err());
+ assert!(SecretEntry::new("HAS-DASH".into(), "d".into(), vec![], vec![], true, opaq::model::Scope::Global).is_err());
+ assert!(SecretEntry::new("".into(), "d".into(), vec![], vec![], true, opaq::model::Scope::Global).is_err());
}
#[test]
fn secret_name_validation_accepts_valid_names() {
- assert!(SecretEntry::new("A".into(), "d".into(), vec![], vec![]).is_ok());
- assert!(SecretEntry::new("MY_TOKEN".into(), "d".into(), vec![], vec![]).is_ok());
- assert!(SecretEntry::new("ABC_123_DEF".into(), "d".into(), vec![], vec![]).is_ok());
- assert!(SecretEntry::new("SONARQUBE_TOKEN".into(), "d".into(), vec![], vec![]).is_ok());
+ assert!(SecretEntry::new("A".into(), "d".into(), vec![], vec![], true, opaq::model::Scope::Global).is_ok());
+ assert!(SecretEntry::new("MY_TOKEN".into(), "d".into(), vec![], vec![], true, opaq::model::Scope::Global).is_ok());
+ assert!(SecretEntry::new("ABC_123_DEF".into(), "d".into(), vec![], vec![], true, opaq::model::Scope::Global).is_ok());
+ assert!(SecretEntry::new("SONARQUBE_TOKEN".into(), "d".into(), vec![], vec![], true, opaq::model::Scope::Global).is_ok());
}
#[test]
@@ -786,6 +792,48 @@ mod binary_tests {
fs::set_permissions(&store_path, fs::Permissions::from_mode(0o600)).unwrap();
}
+ /// Add a secret with explicit sensitivity and scope (bypasses TTY/inquire).
+ fn add_secret_with_sensitivity(
+ &self,
+ name: &str,
+ description: &str,
+ tags: &[&str],
+ value: &[u8],
+ sensitive: bool,
+ scope: opaq::model::Scope,
+ ) {
+ use std::fs;
+ use std::os::unix::fs::PermissionsExt;
+
+ let key_path = self.store_dir.join("master.key");
+ let key_data = fs::read(&key_path).unwrap();
+ let key: [u8; 32] = key_data.try_into().unwrap();
+ let passphrase = opaq::crypto::key_to_passphrase(&key);
+
+ let store_path = self.store_dir.join("store");
+ let ciphertext = fs::read(&store_path).unwrap();
+ let plaintext = opaq::crypto::decrypt_blob(&ciphertext, &passphrase).unwrap();
+ let mut entries: Vec =
+ opaq::store::deserialize_store(&plaintext).unwrap();
+
+ let tag_strings: Vec = tags.iter().map(|s| s.to_string()).collect();
+ let entry = opaq::model::SecretEntry::new(
+ name.to_string(),
+ description.to_string(),
+ tag_strings,
+ value.to_vec(),
+ sensitive,
+ scope,
+ )
+ .unwrap();
+ entries.push(entry);
+
+ let new_plaintext = opaq::store::serialize_store(&entries).unwrap();
+ let new_ciphertext = opaq::crypto::encrypt_blob(&new_plaintext, &passphrase).unwrap();
+ fs::write(&store_path, &new_ciphertext).unwrap();
+ fs::set_permissions(&store_path, fs::Permissions::from_mode(0o600)).unwrap();
+ }
+
/// Add a secret to the store using library APIs (bypasses TTY/inquire).
fn add_secret(&self, name: &str, description: &str, tags: &[&str], value: &[u8]) {
use std::fs;
@@ -808,6 +856,8 @@ mod binary_tests {
description.to_string(),
tag_strings,
value.to_vec(),
+ true,
+ opaq::model::Scope::Global,
)
.unwrap();
entries.push(entry);
@@ -1169,4 +1219,145 @@ mod binary_tests {
assert!(stdout.contains("[MASKED]"));
assert!(!stdout.contains("value_a"));
}
+
+ // -- Reveal tests (binary-level) --
+
+ #[test]
+ fn binary_reveal_plain_succeeds() {
+ let env = TestEnv::new();
+ env.init_store();
+ env.add_secret_with_sensitivity(
+ "PLAIN_URL",
+ "A non-sensitive URL",
+ &[],
+ b"https://example.com",
+ false,
+ opaq::model::Scope::Global,
+ );
+
+ let output = env.cmd().args(["reveal", "PLAIN_URL"]).output().unwrap();
+
+ assert!(
+ output.status.success(),
+ "Reveal of plain entry should succeed (exit 0), stderr: {}",
+ String::from_utf8_lossy(&output.stderr)
+ );
+
+ let stdout = String::from_utf8_lossy(&output.stdout);
+ assert_eq!(
+ stdout, "https://example.com",
+ "Reveal should output the raw value"
+ );
+ }
+
+ #[test]
+ fn binary_reveal_secret_fails() {
+ let env = TestEnv::new();
+ env.init_store();
+ env.add_secret("SECRET_TOKEN", "A sensitive token", &[], b"super-secret");
+
+ let output = env
+ .cmd()
+ .args(["reveal", "SECRET_TOKEN"])
+ .output()
+ .unwrap();
+
+ assert_eq!(
+ output.status.code(),
+ Some(1),
+ "Reveal of sensitive entry should exit 1"
+ );
+
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ assert!(
+ stderr.contains("secret entry"),
+ "Error should mention it's a secret entry, got: {}",
+ stderr
+ );
+ }
+
+ #[test]
+ fn binary_reveal_json_plain_entry() {
+ let env = TestEnv::new();
+ env.init_store();
+ env.add_secret_with_sensitivity(
+ "PLAIN_ENTRY",
+ "A plain config value",
+ &[],
+ b"config-value-123",
+ false,
+ opaq::model::Scope::Global,
+ );
+
+ let output = env
+ .cmd()
+ .args(["reveal", "--json", "PLAIN_ENTRY"])
+ .output()
+ .unwrap();
+
+ assert!(
+ output.status.success(),
+ "Reveal --json of plain entry should succeed, stderr: {}",
+ String::from_utf8_lossy(&output.stderr)
+ );
+
+ let stdout = String::from_utf8_lossy(&output.stdout);
+ let parsed: serde_json::Value = serde_json::from_str(&stdout)
+ .unwrap_or_else(|e| panic!("Should be valid JSON: {}\nOutput: {}", e, stdout));
+
+ assert_eq!(parsed["name"], "PLAIN_ENTRY");
+ assert_eq!(parsed["value"], "config-value-123");
+ }
+
+ // -- Mutually exclusive flag tests (binary-level) --
+
+ #[test]
+ fn binary_add_mutually_exclusive_sensitivity_flags() {
+ let env = TestEnv::new();
+ env.init_store();
+
+ let output = env
+ .cmd()
+ .args(["add", "--secret", "--plain", "TEST_NAME"])
+ .output()
+ .unwrap();
+
+ assert_eq!(
+ output.status.code(),
+ Some(2),
+ "Conflicting --secret --plain should exit 2"
+ );
+
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ assert!(
+ stderr.contains("--secret") || stderr.contains("cannot be used with"),
+ "Error should mention the conflicting flags, got: {}",
+ stderr
+ );
+ }
+
+ #[test]
+ fn binary_add_mutually_exclusive_scope_flags() {
+ let env = TestEnv::new();
+ env.init_store();
+
+ let output = env
+ .cmd()
+ .args(["add", "--global", "--user", "TEST_NAME"])
+ .output()
+ .unwrap();
+
+ assert_eq!(
+ output.status.code(),
+ Some(2),
+ "Conflicting --global --user should exit 2"
+ );
+
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ assert!(
+ stderr.contains("--global") || stderr.contains("cannot be used with"),
+ "Error should mention the conflicting flags, got: {}",
+ stderr
+ );
+ }
}
From 9e763b45d29ffa62b50f2359ea128c744c2310ca Mon Sep 17 00:00:00 2001
From: Emeric Favarel <47535798+moukrea@users.noreply.github.com>
Date: Fri, 6 Mar 2026 23:18:50 +0100
Subject: [PATCH 2/4] style: run cargo fmt
---
src/cli.rs | 11 ++++-
src/commands/add.rs | 15 +++---
src/commands/cleanup.rs | 6 ++-
src/commands/edit.rs | 8 +++-
src/commands/import_cmd.rs | 20 ++++----
src/commands/mod.rs | 2 +-
src/commands/remove.rs | 6 ++-
src/commands/reveal.rs | 5 +-
src/commands/shadows.rs | 10 ++--
src/error.rs | 7 ++-
src/run.rs | 5 +-
src/store.rs | 20 ++++----
tests/integration.rs | 96 ++++++++++++++++++++++++++++++++------
13 files changed, 150 insertions(+), 61 deletions(-)
diff --git a/src/cli.rs b/src/cli.rs
index cf70cbb..9228123 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -174,7 +174,16 @@ pub fn dispatch(cli: Cli) -> crate::error::Result<()> {
current,
} => {
require_tty("add")?;
- crate::commands::add::execute(name, description, tags, secret, plain, global, user, current)
+ crate::commands::add::execute(
+ name,
+ description,
+ tags,
+ secret,
+ plain,
+ global,
+ user,
+ current,
+ )
}
Commands::Edit {
name,
diff --git a/src/commands/add.rs b/src/commands/add.rs
index 6a324f7..442438d 100644
--- a/src/commands/add.rs
+++ b/src/commands/add.rs
@@ -76,8 +76,9 @@ fn resolve_scope(global: bool, user: bool, current: bool) -> Result {
if global {
Ok(Scope::Global)
} else if user {
- let home = dirs::home_dir()
- .ok_or_else(|| OpaqError::Io(std::io::Error::other("Cannot determine home directory")))?;
+ let home = dirs::home_dir().ok_or_else(|| {
+ OpaqError::Io(std::io::Error::other("Cannot determine home directory"))
+ })?;
let canon = std::fs::canonicalize(&home)?;
Ok(Scope::Path(canon))
} else if current {
@@ -107,8 +108,9 @@ pub fn prompt_scope() -> Result {
if selection.starts_with("Global") {
Ok(Scope::Global)
} else if selection.starts_with("User") {
- let home = dirs::home_dir()
- .ok_or_else(|| OpaqError::Io(std::io::Error::other("Cannot determine home directory")))?;
+ let home = dirs::home_dir().ok_or_else(|| {
+ OpaqError::Io(std::io::Error::other("Cannot determine home directory"))
+ })?;
let canon = std::fs::canonicalize(&home)?;
Ok(Scope::Path(canon))
} else if selection.starts_with("Current") {
@@ -126,9 +128,8 @@ pub fn prompt_scope() -> Result {
fn validate_scope_path(path_str: &str) -> Result {
let path = PathBuf::from(path_str);
- let meta = std::fs::metadata(&path).map_err(|_| {
- OpaqError::InvalidScopePath(path_str.to_string())
- })?;
+ let meta =
+ std::fs::metadata(&path).map_err(|_| OpaqError::InvalidScopePath(path_str.to_string()))?;
if !meta.is_dir() {
return Err(OpaqError::InvalidScopePath(path_str.to_string()));
}
diff --git a/src/commands/cleanup.rs b/src/commands/cleanup.rs
index f4c9693..44d6d19 100644
--- a/src/commands/cleanup.rs
+++ b/src/commands/cleanup.rs
@@ -26,7 +26,11 @@ pub fn execute() -> Result<()> {
eprintln!("Found {} entries with stale scopes:", stale_indices.len());
for &idx in &stale_indices {
let entry = &entries[idx];
- let icon = if entry.sensitive { "\u{1F512}" } else { "\u{1F4CB}" };
+ let icon = if entry.sensitive {
+ "\u{1F512}"
+ } else {
+ "\u{1F4CB}"
+ };
eprintln!(
" {} {} [{}] \u{2014} {}",
icon, entry.name, entry.scope, entry.description,
diff --git a/src/commands/edit.rs b/src/commands/edit.rs
index 50a328a..edf60ca 100644
--- a/src/commands/edit.rs
+++ b/src/commands/edit.rs
@@ -41,7 +41,13 @@ pub fn execute(
}
} else {
// Interactive menu (no flags provided)
- let options = vec!["Edit description", "Edit tags", "Rotate value", "Sensitivity", "Scope"];
+ let options = vec![
+ "Edit description",
+ "Edit tags",
+ "Rotate value",
+ "Sensitivity",
+ "Scope",
+ ];
let selection = inquire::Select::new("What would you like to edit?", options)
.prompt()
.map_err(|e| OpaqError::Io(std::io::Error::other(e.to_string())))?;
diff --git a/src/commands/import_cmd.rs b/src/commands/import_cmd.rs
index 0211a2a..2a338f3 100644
--- a/src/commands/import_cmd.rs
+++ b/src/commands/import_cmd.rs
@@ -362,17 +362,15 @@ mod tests {
use crate::store::deserialize_store;
// Create V1 entries with known sensitive/scope values to verify they round-trip
- let entries = vec![
- SecretEntry::new(
- "TOKEN_V1".to_string(),
- "V1 token".to_string(),
- vec!["ci".to_string()],
- b"v1_value".to_vec(),
- false, // non-sensitive
- crate::model::Scope::Global,
- )
- .unwrap(),
- ];
+ let entries = vec![SecretEntry::new(
+ "TOKEN_V1".to_string(),
+ "V1 token".to_string(),
+ vec!["ci".to_string()],
+ b"v1_value".to_vec(),
+ false, // non-sensitive
+ crate::model::Scope::Global,
+ )
+ .unwrap()];
// Serialize as V1
let v1_bytes = serialize_store(&entries).unwrap();
diff --git a/src/commands/mod.rs b/src/commands/mod.rs
index 77ade45..5680293 100644
--- a/src/commands/mod.rs
+++ b/src/commands/mod.rs
@@ -10,6 +10,6 @@ pub mod reveal;
pub mod run;
pub mod search;
pub mod setup;
-pub mod shadows;
pub mod setup_claude;
+pub mod shadows;
pub mod unlock;
diff --git a/src/commands/remove.rs b/src/commands/remove.rs
index ef53e69..a02a85a 100644
--- a/src/commands/remove.rs
+++ b/src/commands/remove.rs
@@ -50,7 +50,11 @@ fn disambiguate(
eprintln!("Multiple entries named '{}':", name);
for (i, &idx) in indices.iter().enumerate() {
let entry = &entries[idx];
- let icon = if entry.sensitive { "\u{1F512}" } else { "\u{1F4CB}" };
+ let icon = if entry.sensitive {
+ "\u{1F512}"
+ } else {
+ "\u{1F4CB}"
+ };
eprintln!(
" {}. {} {} [{}] \u{2014} {}",
i + 1,
diff --git a/src/commands/reveal.rs b/src/commands/reveal.rs
index e8043e9..2e2ced1 100644
--- a/src/commands/reveal.rs
+++ b/src/commands/reveal.rs
@@ -20,9 +20,8 @@ pub fn execute(name: String, json: bool, scope_override: Option) -> Resu
.filter(|e| e.scope == Scope::Global)
.collect()
} else {
- let canon = std::fs::canonicalize(scope_str).map_err(|_| {
- OpaqError::InvalidScopePath(scope_str.clone())
- })?;
+ let canon = std::fs::canonicalize(scope_str)
+ .map_err(|_| OpaqError::InvalidScopePath(scope_str.clone()))?;
if !canon.is_dir() {
return Err(OpaqError::InvalidScopePath(scope_str.clone()));
}
diff --git a/src/commands/shadows.rs b/src/commands/shadows.rs
index d62342f..9436723 100644
--- a/src/commands/shadows.rs
+++ b/src/commands/shadows.rs
@@ -21,9 +21,7 @@ pub fn execute(name: String) -> Result<()> {
let cwd = std::env::current_dir()?;
// Find the active entry (highest specificity that contains cwd)
- let active_idx = matches
- .iter()
- .position(|e| e.scope.contains(&cwd));
+ let active_idx = matches.iter().position(|e| e.scope.contains(&cwd));
println!("Entries named '{}':", name);
@@ -37,7 +35,11 @@ pub fn execute(name: String) -> Result<()> {
for (i, entry) in matches.iter().enumerate() {
let is_active = active_idx == Some(i);
let in_scope = entry.scope.contains(&cwd);
- let icon = if entry.sensitive { "\u{1f512}" } else { "\u{1f4cb}" };
+ let icon = if entry.sensitive {
+ "\u{1f512}"
+ } else {
+ "\u{1f4cb}"
+ };
let scope_str = format!("[{}]", entry.scope);
let scope_padded = format!("{: i32 {
match self {
- Self::InvalidName(_) | Self::TtyRequired(_) | Self::UnknownPlaceholder(_)
- | Self::MutuallyExclusiveFlags(_) | Self::InvalidScopePath(_) => 2,
+ Self::InvalidName(_)
+ | Self::TtyRequired(_)
+ | Self::UnknownPlaceholder(_)
+ | Self::MutuallyExclusiveFlags(_)
+ | Self::InvalidScopePath(_) => 2,
_ => 1,
}
}
diff --git a/src/run.rs b/src/run.rs
index d915ad5..2d69e0d 100644
--- a/src/run.rs
+++ b/src/run.rs
@@ -278,10 +278,7 @@ mod tests {
let result = resolve_placeholders(&args, &store, &default_cwd());
assert_eq!(result.args, vec!["secret_val", "https://example.com"]);
assert_eq!(result.sensitive_secrets, vec![b"secret_val".to_vec()]);
- assert_eq!(
- result.plain_values,
- vec![b"https://example.com".to_vec()]
- );
+ assert_eq!(result.plain_values, vec![b"https://example.com".to_vec()]);
}
#[test]
diff --git a/src/store.rs b/src/store.rs
index 2a70ac8..8384c90 100644
--- a/src/store.rs
+++ b/src/store.rs
@@ -265,17 +265,15 @@ mod tests {
#[test]
fn v1_version_byte_routing() {
// Serialize with V1 format
- let entries = vec![
- SecretEntry::new(
- "V1_TOKEN".to_string(),
- "A V1 token".to_string(),
- vec![],
- b"v1_value".to_vec(),
- false,
- crate::model::Scope::Global,
- )
- .unwrap(),
- ];
+ let entries = vec![SecretEntry::new(
+ "V1_TOKEN".to_string(),
+ "A V1 token".to_string(),
+ vec![],
+ b"v1_value".to_vec(),
+ false,
+ crate::model::Scope::Global,
+ )
+ .unwrap()];
let serialized = serialize_store(&entries).unwrap();
// First byte should be 0x01
diff --git a/tests/integration.rs b/tests/integration.rs
index cc5b6d4..07314a2 100644
--- a/tests/integration.rs
+++ b/tests/integration.rs
@@ -709,19 +709,91 @@ fn encryption_roundtrip_with_binary_secret_values() {
#[test]
fn secret_name_validation_rejects_invalid_names() {
- assert!(SecretEntry::new("lowercase".into(), "d".into(), vec![], vec![], true, opaq::model::Scope::Global).is_err());
- assert!(SecretEntry::new("_LEADING".into(), "d".into(), vec![], vec![], true, opaq::model::Scope::Global).is_err());
- assert!(SecretEntry::new("1DIGIT".into(), "d".into(), vec![], vec![], true, opaq::model::Scope::Global).is_err());
- assert!(SecretEntry::new("HAS-DASH".into(), "d".into(), vec![], vec![], true, opaq::model::Scope::Global).is_err());
- assert!(SecretEntry::new("".into(), "d".into(), vec![], vec![], true, opaq::model::Scope::Global).is_err());
+ assert!(SecretEntry::new(
+ "lowercase".into(),
+ "d".into(),
+ vec![],
+ vec![],
+ true,
+ opaq::model::Scope::Global
+ )
+ .is_err());
+ assert!(SecretEntry::new(
+ "_LEADING".into(),
+ "d".into(),
+ vec![],
+ vec![],
+ true,
+ opaq::model::Scope::Global
+ )
+ .is_err());
+ assert!(SecretEntry::new(
+ "1DIGIT".into(),
+ "d".into(),
+ vec![],
+ vec![],
+ true,
+ opaq::model::Scope::Global
+ )
+ .is_err());
+ assert!(SecretEntry::new(
+ "HAS-DASH".into(),
+ "d".into(),
+ vec![],
+ vec![],
+ true,
+ opaq::model::Scope::Global
+ )
+ .is_err());
+ assert!(SecretEntry::new(
+ "".into(),
+ "d".into(),
+ vec![],
+ vec![],
+ true,
+ opaq::model::Scope::Global
+ )
+ .is_err());
}
#[test]
fn secret_name_validation_accepts_valid_names() {
- assert!(SecretEntry::new("A".into(), "d".into(), vec![], vec![], true, opaq::model::Scope::Global).is_ok());
- assert!(SecretEntry::new("MY_TOKEN".into(), "d".into(), vec![], vec![], true, opaq::model::Scope::Global).is_ok());
- assert!(SecretEntry::new("ABC_123_DEF".into(), "d".into(), vec![], vec![], true, opaq::model::Scope::Global).is_ok());
- assert!(SecretEntry::new("SONARQUBE_TOKEN".into(), "d".into(), vec![], vec![], true, opaq::model::Scope::Global).is_ok());
+ assert!(SecretEntry::new(
+ "A".into(),
+ "d".into(),
+ vec![],
+ vec![],
+ true,
+ opaq::model::Scope::Global
+ )
+ .is_ok());
+ assert!(SecretEntry::new(
+ "MY_TOKEN".into(),
+ "d".into(),
+ vec![],
+ vec![],
+ true,
+ opaq::model::Scope::Global
+ )
+ .is_ok());
+ assert!(SecretEntry::new(
+ "ABC_123_DEF".into(),
+ "d".into(),
+ vec![],
+ vec![],
+ true,
+ opaq::model::Scope::Global
+ )
+ .is_ok());
+ assert!(SecretEntry::new(
+ "SONARQUBE_TOKEN".into(),
+ "d".into(),
+ vec![],
+ vec![],
+ true,
+ opaq::model::Scope::Global
+ )
+ .is_ok());
}
#[test]
@@ -1256,11 +1328,7 @@ mod binary_tests {
env.init_store();
env.add_secret("SECRET_TOKEN", "A sensitive token", &[], b"super-secret");
- let output = env
- .cmd()
- .args(["reveal", "SECRET_TOKEN"])
- .output()
- .unwrap();
+ let output = env.cmd().args(["reveal", "SECRET_TOKEN"]).output().unwrap();
assert_eq!(
output.status.code(),
From a11f91ffe9fa2a1bdea736f12b3460a092c05946 Mon Sep 17 00:00:00 2001
From: Emeric Favarel <47535798+moukrea@users.noreply.github.com>
Date: Fri, 6 Mar 2026 23:21:54 +0100
Subject: [PATCH 3/4] fix: resolve clippy warnings for CI compatibility
---
src/commands/import_cmd.rs | 7 ++++---
1 file changed, 4 insertions(+), 3 deletions(-)
diff --git a/src/commands/import_cmd.rs b/src/commands/import_cmd.rs
index 2a338f3..3e4e97c 100644
--- a/src/commands/import_cmd.rs
+++ b/src/commands/import_cmd.rs
@@ -301,7 +301,7 @@ mod tests {
// Merge: same name but different scope should add as new
let key = (imported.name.clone(), format!("{}", imported.scope));
- if key_index.get(&key).is_none() {
+ if !key_index.contains_key(&key) {
let new_idx = local.len();
key_index.insert(key, new_idx);
local.push(imported);
@@ -317,7 +317,7 @@ mod tests {
use std::collections::HashMap;
// Local store has EXISTING [global]
- let mut local = vec![SecretEntry::new(
+ let mut local = [SecretEntry::new(
"EXISTING".to_string(),
"Old".to_string(),
vec![],
@@ -325,7 +325,8 @@ mod tests {
true,
crate::model::Scope::Global,
)
- .unwrap()];
+ .unwrap()]
+ .to_vec();
// Imported entry has same name AND same scope
let imported = SecretEntry::new(
From 5628265c8df7ebb165bc469a8d0d4cc623ab3d59 Mon Sep 17 00:00:00 2001
From: Emeric Favarel <47535798+moukrea@users.noreply.github.com>
Date: Fri, 6 Mar 2026 23:24:35 +0100
Subject: [PATCH 4/4] fix: use entry API to satisfy clippy map_entry lint
---
src/commands/import_cmd.rs | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/commands/import_cmd.rs b/src/commands/import_cmd.rs
index 3e4e97c..fea09dd 100644
--- a/src/commands/import_cmd.rs
+++ b/src/commands/import_cmd.rs
@@ -301,9 +301,9 @@ mod tests {
// Merge: same name but different scope should add as new
let key = (imported.name.clone(), format!("{}", imported.scope));
- if !key_index.contains_key(&key) {
+ if let std::collections::hash_map::Entry::Vacant(e) = key_index.entry(key) {
let new_idx = local.len();
- key_index.insert(key, new_idx);
+ e.insert(new_idx);
local.push(imported);
}