Skip to content
Merged
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
2 changes: 1 addition & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
//! ## Modules
//!
//! - [`client`] — Unix socket client for querying the daemon from CLI subcommands.
//! - [`resolver`] — 1Password CLI integration (`op read` / `op item edit`).
//! - [`resolver`] — 1Password CLI integration (`op read` / `op item edit` / `op item create`).
//! - [`socket`] — Unix socket server implementing the line-based protocol.
//! - [`store`] — In-memory secret store with zeroize-on-drop guarantees.
//! - [`watcher`] — File system watcher for credential auto-sync back to 1Password.
Expand Down
77 changes: 60 additions & 17 deletions src/resolver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,14 @@

use secrecy::SecretString;
use tracing::{debug, error, info, trace};
use zeroize::Zeroize;

use crate::store::SecretStore;

/// Substring the `op` CLI includes in stderr when an item doesn't exist in a vault.
/// Used to distinguish "not found" from other edit failures (auth, network, etc.).
const OP_ITEM_NOT_FOUND_MARKER: &str = "isn't an item";

/// A discovered secret reference mapping an environment variable name to an
/// `op://` URI.
///
Expand Down Expand Up @@ -75,11 +80,7 @@ pub async fn resolve_all(refs: &[SecretRef], store: &mut SecretStore) -> (usize,
match op_read(&r.uri).await {
Ok(value) => {
store.insert_with_uri(r.name.clone(), value, r.uri.clone());
info!(
"resolved {} ({} chars)",
r.name,
store.get(&r.name).map_or(0, |v| v.len())
);
info!("resolved {}", r.name);
ok += 1;
}
Err(e) => {
Expand Down Expand Up @@ -126,26 +127,32 @@ pub async fn op_read(uri: &str) -> Result<SecretString, String> {
Ok(SecretString::from(value.trim_end_matches('\n').to_string()))
}

/// Writes a secret value to 1Password by invoking `op item edit`.
/// Writes a secret value to 1Password, creating the item if it doesn't exist.
///
/// The `uri` must be in `op://vault/item/field` format. This function parses
/// the URI into its components and runs:
/// The `uri` must be in `op://vault/item/field` format. This function first
/// attempts to update the existing item via `op item edit`. If the item does
/// not exist (detected by the `"isn't an item"` error from the `op` CLI),
/// it falls back to creating a new item via `op item create`.
///
/// ```text
/// # Update existing:
/// op item edit <item> <field>=<value> --vault <vault>
///
/// # Create new (fallback):
/// op item create --category=password --title=<item> --vault=<vault> <field>=<value>
/// ```
///
/// # Safety boundary
///
/// This function can **update** existing 1Password fields but cannot create
/// or delete items. The security boundary matches the `op` CLI itself.
/// This function can **update** existing 1Password fields and **create** new
/// items. It cannot delete items. The security boundary matches the `op` CLI.
///
/// # Errors
///
/// Returns a descriptive error string if the URI is malformed, the `op` binary
/// cannot be executed, or `op item edit` exits with a non-zero status.
/// cannot be executed, or both edit and create fail.
pub async fn op_write(uri: &str, value: &str) -> Result<(), String> {
debug!("op item edit for {uri} ({} chars)", value.len());
debug!("op write for {uri}");
let parts: Vec<&str> = uri
.strip_prefix("op://")
.ok_or_else(|| format!("invalid op:// URI: {uri}"))?
Expand All @@ -157,20 +164,56 @@ pub async fn op_write(uri: &str, value: &str) -> Result<(), String> {
}

let (vault, item, field) = (parts[0], parts[1], parts[2]);
let assignment = format!("{field}={value}");
let mut assignment = format!("{field}={value}");

// Try editing the existing item first.
let output = tokio::process::Command::new("op")
.args(["item", "edit", item, &assignment, "--vault", vault])
.output()
.await
.map_err(|e| format!("failed to execute op: {e}"))?;

if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if output.status.success() {
assignment.zeroize();
return Ok(());
}

let stderr = String::from_utf8_lossy(&output.stderr);
let stderr_trimmed = stderr.trim();

// Only fall back to create when the item genuinely doesn't exist.
// The op CLI returns: `"<item>" isn't an item in the "<vault>" vault.`
if !stderr_trimmed.contains(OP_ITEM_NOT_FOUND_MARKER) {
assignment.zeroize();
return Err(format!(
"op item edit failed ({}): {}",
output.status,
stderr.trim()
output.status, stderr_trimmed
));
}

info!("item not found in 1Password, creating: {item} in vault {vault}");

let create_output = tokio::process::Command::new("op")
.args([
"item",
"create",
"--category=password",
&format!("--title={item}"),
&format!("--vault={vault}"),
&assignment,
])
.output()
.await
.map_err(|e| format!("failed to execute op: {e}"))?;

assignment.zeroize();

if !create_output.status.success() {
let create_stderr = String::from_utf8_lossy(&create_output.stderr);
return Err(format!(
"op item create failed ({}): {}",
create_output.status,
create_stderr.trim()
));
}

Expand Down
2 changes: 1 addition & 1 deletion src/socket.rs
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,6 @@ async fn handle_set(line: &str, store: &Arc<RwLock<SecretStore>>) -> String {
);
}

info!("SET {} -> {uri} ({} chars)", name, value.len());
info!("SET {} -> {uri}", name);
"OK\n".to_string()
}