secretenv_registry_mutate/lib.rs
1// Copyright (C) 2026 Mandeep Patel
2// SPDX-License-Identifier: AGPL-3.0-only
3
4//! Shared registry-document mutation helpers.
5//!
6//! Centralizes the `list + edit + serialize + set` transaction that
7//! `secretenv-cli` and `secretenv-mcp` previously duplicated when
8//! applying alias insert / remove operations to a registry's
9//! primary source. Per v0.16 Phase 7 architecture C-2 + code-review
10//! Medium: extracted in v0.16.2 (Phase 1b D.2b) so both consumers
11//! share the same writer.
12//!
13//! # What this crate is — and what it isn't
14//!
15//! This crate centralizes the **transaction body**: the
16//! list-current-map → mutate-map → serialize → write-back chain
17//! that's identical between CLI and MCP.
18//!
19//! It does NOT centralize:
20//!
21//! - **Registry-source selection** — CLI honors `SECRETENV_REGISTRY`
22//! env var + accepts URI-form selections; MCP intentionally rejects
23//! URI-form and only accepts named registries (`[registries.<name>]`).
24//! Each caller keeps its own `pick_*_source` helper.
25//! - **Target-URI validation** — exposed here as
26//! [`validate_target_uri`] but called by each caller before
27//! committing to the transaction (the CLI's user-facing error
28//! text + the MCP's structured `WriteFailed` outcome want
29//! different framing).
30//! - **Idempotency policy** — [`AliasChange::Remove`] carries a
31//! `required` flag: CLI's `registry unset` bails when the alias
32//! is absent (`required: true`); MCP's `delete_alias` treats an
33//! absent alias as success (`required: false`).
34//!
35//! # SEC-INV-02 compliance
36//!
37//! This crate depends on `secretenv-core` with the default-features
38//! set (no opt-in to `value-access`) — the registry-document write
39//! path takes a serialized `&str` (an alias→URI map, NOT a
40//! `Secret<T>` value). `Backend::set(uri, &str)` is the
41//! value-free trait method per SEC-INV-02; passing it a registry
42//! document is the structurally-safe call.
43
44use std::collections::BTreeMap;
45
46use anyhow::{anyhow, bail, Context, Result};
47use secretenv_core::{Backend, BackendRegistry, BackendUri};
48
49/// Describes the change to apply to one registry document.
50///
51/// Constructed by the caller (CLI or MCP) after it has resolved
52/// the source URI + backend via its own selection helper. Passed
53/// to [`apply_change`].
54///
55/// `#[non_exhaustive]` per v0.16.2 Phase 7 architecture review:
56/// new variants (e.g. a future `Bulk { changes: Vec<...> }`) can
57/// be added in a patch release without forcing every downstream
58/// `match` site to update.
59#[derive(Debug, Clone)]
60#[non_exhaustive]
61pub enum AliasChange {
62 /// Insert (or update if already present) `alias → target_uri`.
63 Insert {
64 /// Alias name being added/updated (e.g. `"STRIPE_API_KEY"`).
65 alias: String,
66 /// Direct backend URI the alias should resolve to.
67 target_uri: String,
68 },
69 /// Remove `alias` from the document. When `required = true`,
70 /// an absent alias bails with an error (CLI behavior). When
71 /// `required = false`, absent-alias is treated as success
72 /// (MCP behavior).
73 Remove {
74 /// Alias name to remove.
75 alias: String,
76 /// CLI-mode `registry unset` semantics: bail if absent.
77 /// MCP-mode `delete_alias` semantics: idempotent on absent.
78 required: bool,
79 },
80}
81
82/// Validate that `target_uri` is a legal alias destination:
83///
84/// 1. Parses as a [`BackendUri`].
85/// 2. Is NOT a `secretenv://` alias (chains rejected — direct
86/// backend URI only).
87/// 3. References a backend instance configured in
88/// `[backends.<name>]` (already present in `backends`).
89///
90/// Callers run this before [`apply_change`] so the failure mode is
91/// "rejected before any read/write" rather than "wrote a value the
92/// caller can't actually use later". The MCP boundary surfaces the
93/// failure as a structured `WriteFailed` outcome; the CLI surfaces
94/// it as a `bail!` from `registry_set`.
95///
96/// # Errors
97///
98/// - Target URI fails to parse.
99/// - Target URI is a `secretenv://` alias.
100/// - Target backend instance is not configured.
101pub fn validate_target_uri(target_uri: &str, backends: &BackendRegistry) -> Result<()> {
102 let target = BackendUri::parse(target_uri)
103 .with_context(|| format!("target '{target_uri}' is not a valid URI"))?;
104 if target.is_alias() {
105 bail!("target must be a direct backend URI, not a secretenv:// alias");
106 }
107 if backends.get(&target.scheme).is_none() {
108 bail!(
109 "target '{target_uri}' references backend instance '{}' which is not configured",
110 target.scheme
111 );
112 }
113 Ok(())
114}
115
116/// Apply `change` to the registry document at `source_uri`.
117///
118/// Transaction body:
119///
120/// 1. `backend.list(source_uri).await` — read the current
121/// alias→URI map from the backing storage.
122/// 2. Mutate the map per `change` (insert / remove with the
123/// documented `required` semantics).
124/// 3. [`secretenv_core::serialize_registry_doc`] with the
125/// backend's `registry_format()` — re-emit the canonical bytes.
126/// 4. `backend.set(source_uri, &serialized).await` — write back.
127///
128/// `registry_label` is used only in error messages (it's the
129/// operator-facing name, e.g. `"default"`); the actual storage
130/// location is `source_uri`.
131///
132/// # Errors
133///
134/// - `backend.list` fails (storage unreachable, auth, etc.).
135/// - `change` is [`AliasChange::Remove`] with `required: true` and
136/// the alias is absent from the current document.
137/// - Serialization fails (should not happen for valid maps).
138/// - `backend.set` fails (storage write error).
139pub async fn apply_change(
140 backend: &dyn Backend,
141 source_uri: &BackendUri,
142 registry_label: &str,
143 change: AliasChange,
144) -> Result<()> {
145 let current = backend
146 .list(source_uri)
147 .await
148 .with_context(|| format!("reading registry document for registry `{registry_label}`"))?;
149 let mut map: BTreeMap<String, String> = current.into_iter().collect();
150
151 match change {
152 AliasChange::Insert { alias, target_uri } => {
153 map.insert(alias, target_uri);
154 }
155 AliasChange::Remove { alias, required } => {
156 if map.remove(&alias).is_none() && required {
157 return Err(anyhow!(
158 "alias '{alias}' not found in registry at '{}'",
159 source_uri.raw
160 ));
161 }
162 }
163 }
164
165 let serialized = secretenv_core::serialize_registry_doc(backend.registry_format(), &map)?;
166 backend.set(source_uri, &serialized).await.with_context(|| {
167 format!("writing updated registry document for registry `{registry_label}`")
168 })?;
169 Ok(())
170}