Skip to main content

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}