Skip to main content

greentic_deployer/cli/
updates.rs

1//! `gtc op updates {enroll,status}` — P1b update-channel client-certificate
2//! enrollment (Phase 1 of the Greentic updater).
3//!
4//! `enroll` mints a fresh key pair + CSR (via `greentic-update`), exchanges it
5//! at the Cert-CA's `/v1/enroll` endpoint for a signed client certificate, and
6//! persists the cert + key + issuing CA (and the CA URL) into the env's
7//! configured secrets backend under the `tls` pack. Running it again overwrites
8//! the stored material — so it is also the manual rotation path. `status` reads
9//! the stored certificate back and reports its serial + validity window.
10//!
11//! Enrollment happens *before* the client holds a certificate, so it cannot use
12//! the mTLS update channel itself: this verb drives `greentic-update::enroll`
13//! over a plain server-auth bootstrap client. Persistence lives here (the
14//! caller), not in `greentic-update`, which stays free of any secrets
15//! dependency — the crate returns raw PEM and the operator persists it.
16
17use std::collections::BTreeMap;
18use std::path::{Path, PathBuf};
19
20use greentic_deploy_spec::{
21    EnvId, Environment, MIN_POLL_INTERVAL_SECS, OnNotifyAction, UpdateChannelConfig,
22};
23use greentic_distributor_client::{CachePolicy, DistClient, DistOptions, ResolvePolicy};
24use greentic_secrets_lib::core::rt;
25use serde::{Deserialize, Serialize};
26use serde_json::{Value, json};
27
28use crate::environment::{
29    EnvironmentStore, LocalFsStore, restore_environment, snapshot_environment,
30    trust_root as store_trust_root,
31};
32
33use super::env_manifest::{ENV_MANIFEST_SCHEMA_V1, EnvManifest};
34use super::secrets::{get_env_secret, put_env_secret, require_secrets_pack};
35use super::{AuditCtx, OpError, OpFlags, OpOutcome, audit_and_record};
36
37const NOUN: &str = "updates";
38
39/// Secrets pack (category) the update-channel TLS material lives under.
40const TLS_PACK: &str = "tls";
41/// Store-canonical secret names (single underscore — the runtime reader
42/// collapses `__` to `_`, so a double-underscore name would never be found).
43const CERT_NAME: &str = "updater_cert";
44const KEY_NAME: &str = "updater_key";
45const CA_NAME: &str = "updater_ca";
46const CA_URL_NAME: &str = "updater_ca_url";
47
48/// Payload for `op updates enroll`.
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct UpdatesEnrollPayload {
51    pub environment_id: String,
52    /// Base URL of the Cert-CA (`greentic-updates-server`). The `/v1/enroll`
53    /// path is appended.
54    pub ca_url: String,
55}
56
57/// Payload for `op updates status`.
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct UpdatesStatusPayload {
60    pub environment_id: String,
61}
62
63/// Payload for `op updates get`. Exactly one plan source is required: `plan_url`
64/// (fetched over the enrolled mTLS channel) or the `plan_file` + `plan_sig_file`
65/// pair (airgap import / local testing).
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct UpdatesGetPayload {
68    pub environment_id: String,
69    /// Fetch the signed plan document + `.sig` sidecar from this base URL over
70    /// the enrolled mTLS channel.
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub plan_url: Option<String>,
73    /// Local plan document (airgap import / testing). Requires `plan_sig_file`.
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub plan_file: Option<PathBuf>,
76    /// DSSE envelope sidecar for `plan_file`.
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub plan_sig_file: Option<PathBuf>,
79}
80
81/// Payload for `op updates apply` — apply a staged plan to its environment.
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct ApplyUpdatesPayload {
84    pub environment_id: String,
85    /// Plan id of the staged plan to apply (from a prior `op updates get`).
86    pub plan_id: String,
87}
88
89/// Payload for `op updates recover` — force a plan stranded in `applying` by a
90/// crashed applier to `failed`, so a fresh `get` + `apply` can proceed. The
91/// `--force` attestation is a CLI-only argument (operator intent, not a
92/// replayable answers field), so it is threaded separately, not carried here.
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct RecoverUpdatesPayload {
95    pub environment_id: String,
96    /// Plan id of the `applying` plan to force-fail (from a prior `op updates get`).
97    pub plan_id: String,
98}
99
100/// Payload for `op updates config-set` — set the update-channel notification
101/// policy (`update-channel.json`). Every behavior field is optional; only those
102/// supplied are changed, the rest keep their stored value (same semantics as
103/// `op config set`). Enrollment/identity is unaffected — this is policy only.
104#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct UpdateConfigSetPayload {
106    pub environment_id: String,
107    /// Master switch for the notification machinery. `None` leaves the stored
108    /// value unchanged; absent file resolves to disabled (deny-by-default).
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    pub enabled: Option<bool>,
111    /// On-notify action: `record-only` or `stage`. `None` leaves the stored
112    /// value unchanged (unset resolves to `stage`).
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub on_notify: Option<String>,
115    /// Fallback poll interval in seconds (rejected below the 60s floor). `None`
116    /// leaves the stored value unchanged (unset resolves to 3600).
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub poll_interval_secs: Option<u64>,
119}
120
121/// Filter for `op updates config-show` — read-only view of the update-channel
122/// policy (stored fields + resolved effective values).
123#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct UpdateConfigShowFilter {
125    pub environment_id: String,
126}
127
128/// The dev-store/Vault secret path for one TLS artifact: `<tenant>/_/tls/<name>`
129/// (`<tenant>/<team>/<pack>/<name>` with the default team `_`).
130fn tls_rel_path(tenant: &str, name: &str) -> String {
131    format!("{tenant}/_/{TLS_PACK}/{name}")
132}
133
134/// Whether a control-plane URL (the Cert-CA for enrollment, or the plan-fetch
135/// endpoint for `get`) is acceptable. HTTPS is always allowed. Plaintext
136/// `http://` is allowed ONLY to a loopback host, for local development: over
137/// plaintext the enrolled mTLS client identity is never presented and a remote
138/// on-path attacker could serve a malicious CA (enrollment) or a stale
139/// validly-signed plan (fetch). A hostname that merely starts with `127.` (e.g.
140/// `127.0.0.1.evil.com`) parses as a domain, not a loopback IP, so it is refused.
141fn control_url_is_acceptable(raw: &str) -> bool {
142    let Ok(parsed) = url::Url::parse(raw) else {
143        return false;
144    };
145    match parsed.scheme() {
146        "https" => true,
147        "http" => match parsed.host() {
148            Some(url::Host::Domain(host)) => host == "localhost",
149            Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
150            Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
151            None => false,
152        },
153        _ => false,
154    }
155}
156
157/// The enrolled certificate's identity is the env's owning tenant, so an owner
158/// is required. Mirrors `vault_seed_put`'s fail-closed tenant guard (a
159/// Vault-backed env is single-tenant at the runtime) so the two write surfaces
160/// agree on the tenant segment.
161fn require_tenant(env: &Environment, env_id: &EnvId) -> Result<String, OpError> {
162    env.host_config
163        .tenant_org_id
164        .clone()
165        .filter(|t| !t.trim().is_empty())
166        .ok_or_else(|| {
167            OpError::InvalidArgument(format!(
168                "env `{env_id}` must be tenant-owned before update-channel enrollment; \
169                 set the owner with `op env update {env_id} --tenant-org <tenant>`"
170            ))
171        })
172}
173
174/// `op updates enroll` — enroll with the Cert-CA and persist the signed client
175/// certificate + key + issuing CA (and the CA URL) into the env secrets backend.
176/// Idempotent by overwrite: re-running mints a fresh identity (manual rotation).
177pub fn enroll(
178    store: &LocalFsStore,
179    flags: &OpFlags,
180    payload: Option<UpdatesEnrollPayload>,
181) -> Result<OpOutcome, OpError> {
182    if flags.schema_only {
183        return Ok(OpOutcome::new(NOUN, "enroll", enroll_schema()));
184    }
185    let payload = resolve_payload::<UpdatesEnrollPayload>(flags, payload)?;
186    let env_id = parse_env_id(&payload.environment_id)?;
187    // Validate the CA URL before the authz gate / any network work.
188    let ca_url = payload.ca_url.trim().to_string();
189    if ca_url.is_empty() {
190        return Err(OpError::InvalidArgument(
191            "ca_url must not be empty".to_string(),
192        ));
193    }
194    if !control_url_is_acceptable(&ca_url) {
195        return Err(OpError::InvalidArgument(
196            "ca_url must be an https:// URL; plaintext http:// is accepted only for a loopback \
197             CA in local development. Enrollment establishes the update-channel trust anchor, so \
198             it must not bootstrap over an unauthenticated channel to a remote host."
199                .to_string(),
200        ));
201    }
202    let ctx = AuditCtx {
203        env_id: env_id.clone(),
204        noun: NOUN,
205        verb: "enroll",
206        // Audit target carries the CA URL and env, never key material.
207        target: json!({"environment_id": env_id.as_str(), "ca_url": ca_url}),
208        idempotency_key: None,
209    };
210    audit_and_record(store, ctx, |_committed| {
211        let env = store.load(&env_id)?;
212        let secrets = require_secrets_pack(&env, &env_id)?;
213        let kind_path = secrets.kind.path();
214        let tenant = require_tenant(&env, &env_id)?;
215
216        // Enrollment predates the client cert, so drive it over a plain
217        // server-auth client (not the mTLS one). Bridge the async call from
218        // this synchronous verb, mirroring `vault_seed_put`.
219        let enrollment = rt::sync_await(async {
220            let client = reqwest::Client::new();
221            greentic_update::enroll::enroll(&client, &ca_url, &tenant, env_id.as_str()).await
222        })
223        .map_err(|e| OpError::Conflict(format!("update-channel enrollment failed: {e}")))?;
224
225        // Validate the CA response before persisting: prove the ca/cert/key
226        // parse and load as an mTLS identity, so structurally-unusable material
227        // is never stored as the update-channel trust anchor. (Chain
228        // verification and the (tenant, env) identity binding are enforced
229        // server-side at mTLS use time in Phase 2.)
230        greentic_update::tls::build_mtls_client(&greentic_update::tls::MtlsConfig {
231            ca_pem: enrollment.ca_pem.clone(),
232            client_cert_pem: enrollment.client_cert_pem.clone(),
233            client_key_pem: enrollment.client_key_pem.clone(),
234        })
235        .map_err(|e| {
236            OpError::Conflict(format!("CA response is not a usable mTLS identity: {e}"))
237        })?;
238
239        let stored = persist_enrollment(
240            store,
241            &env,
242            &env_id,
243            kind_path,
244            &tenant,
245            &ca_url,
246            &enrollment,
247        )?;
248
249        let outcome = OpOutcome::new(
250            NOUN,
251            "enroll",
252            json!({
253                "environment_id": env_id.as_str(),
254                "tenant": tenant,
255                "serial": enrollment.serial,
256                "not_after": enrollment.not_after,
257                "secrets_kind": secrets.kind.to_string(),
258                "stored": stored,
259            }),
260        );
261        Ok((outcome, super::AuditGens::NONE))
262    })
263}
264
265/// Write the enrolled material into the env secrets backend. Returns the list of
266/// `{name, store_uri}` written, for the outcome. Partial failure is recoverable
267/// by re-running `enroll` (each write overwrites).
268fn persist_enrollment(
269    store: &LocalFsStore,
270    env: &Environment,
271    env_id: &EnvId,
272    kind_path: &str,
273    tenant: &str,
274    ca_url: &str,
275    enrollment: &greentic_update::enroll::Enrollment,
276) -> Result<Vec<Value>, OpError> {
277    // The certificate is written LAST as a commit marker: `status` (and the
278    // Phase 2 consumer) key on `updater_cert`, so a failure part-way through
279    // leaves the env reporting not-enrolled rather than half-enrolled. Re-running
280    // `enroll` overwrites the whole set. The dev-store/Vault backends have no
281    // cross-key transaction, so this ordering is the atomicity we can offer.
282    let items = [
283        (KEY_NAME, enrollment.client_key_pem.as_str()),
284        (CA_NAME, enrollment.ca_pem.as_str()),
285        (CA_URL_NAME, ca_url),
286        (CERT_NAME, enrollment.client_cert_pem.as_str()),
287    ];
288    let mut stored = Vec::with_capacity(items.len());
289    for (name, value) in items {
290        let rel_path = tls_rel_path(tenant, name);
291        let (store_uri, _extra) = put_env_secret(store, env, env_id, kind_path, &rel_path, value)?;
292        stored.push(json!({"name": name, "store_uri": store_uri}));
293    }
294    Ok(stored)
295}
296
297/// `op updates status` — report whether the env holds an enrolled update-channel
298/// certificate and, if so, its serial + validity window. Read-only (not
299/// audited), so it never reveals the private key.
300pub fn status(
301    store: &LocalFsStore,
302    flags: &OpFlags,
303    payload: Option<UpdatesStatusPayload>,
304) -> Result<OpOutcome, OpError> {
305    if flags.schema_only {
306        return Ok(OpOutcome::new(NOUN, "status", status_schema()));
307    }
308    let payload = resolve_payload::<UpdatesStatusPayload>(flags, payload)?;
309    let env_id = parse_env_id(&payload.environment_id)?;
310    let env = store.load(&env_id)?;
311    let secrets = require_secrets_pack(&env, &env_id)?;
312    let kind_path = secrets.kind.path();
313    let tenant = require_tenant(&env, &env_id)?;
314
315    let cert_rel = tls_rel_path(&tenant, CERT_NAME);
316    let (cert_pem, _store_uri, _extra) =
317        get_env_secret(store, &env, &env_id, kind_path, &cert_rel)?;
318
319    let body = match cert_pem {
320        None => json!({
321            "environment_id": env_id.as_str(),
322            "tenant": tenant,
323            "secrets_kind": secrets.kind.to_string(),
324            "enrolled": false,
325        }),
326        Some(pem) => {
327            let info = greentic_update::tls::parse_cert_info(&pem).map_err(|e| {
328                OpError::Conflict(format!(
329                    "stored update-channel certificate is unparseable: {e}"
330                ))
331            })?;
332            json!({
333                "environment_id": env_id.as_str(),
334                "tenant": tenant,
335                "secrets_kind": secrets.kind.to_string(),
336                "enrolled": true,
337                "serial": info.serial_hex,
338                "not_before_epoch": info.not_before_epoch,
339                "not_after_epoch": info.not_after_epoch,
340            })
341        }
342    };
343    Ok(OpOutcome::new(NOUN, "status", body))
344}
345
346/// `op updates get` — pull a signed update plan (over the enrolled mTLS channel
347/// or from a local file), verify it against the env trust root, run the
348/// downgrade + compatibility gates, and admit it to the update staging tree.
349///
350/// Read-only with respect to the environment store — the only writes are into
351/// the update staging tree, which keeps its own audit ledger — so this verb is
352/// not wrapped in `audit_and_record` (like `status`).
353///
354/// The gates run *before* any staging write, so a rejected plan leaves nothing
355/// half-staged. Declared artifacts are then fetched into the staging tree
356/// (through the content-addressed `DistClient`, with `put_artifact` re-verifying
357/// each digest fail-closed) and the plan is promoted `downloading → inbox →
358/// staged`; a plan with no artifacts promotes straight away. The outcome's
359/// `stage` field reports where the plan landed.
360pub fn get(
361    store: &LocalFsStore,
362    flags: &OpFlags,
363    payload: Option<UpdatesGetPayload>,
364) -> Result<OpOutcome, OpError> {
365    get_impl(store, flags, payload, None)
366}
367
368/// Body of [`get`], with an optional staging-root override so tests can point
369/// the FSM at a tempdir instead of `~/.greentic/updates` (the crate forbids
370/// `unsafe`, so an env-var override is not available in tests).
371fn get_impl(
372    store: &LocalFsStore,
373    flags: &OpFlags,
374    payload: Option<UpdatesGetPayload>,
375    updates_root_override: Option<&std::path::Path>,
376) -> Result<OpOutcome, OpError> {
377    if flags.schema_only {
378        return Ok(OpOutcome::new(NOUN, "get", get_schema()));
379    }
380    let payload = resolve_payload::<UpdatesGetPayload>(flags, payload)?;
381    let env_id = parse_env_id(&payload.environment_id)?;
382    let env = store.load(&env_id)?;
383
384    // 1. Source the signed plan bytes (mTLS pull or local file pair).
385    let (plan_bytes, envelope_bytes) = load_plan_source(store, &env, &env_id, &payload)?;
386
387    // 2. Verify the DSSE signature + subject digest against the env trust root.
388    //    Closed-by-default: an env with no trusted keys rejects every plan.
389    let env_dir = store.env_dir(&env_id)?;
390    let trust = store_trust_root::load(&env_dir)?;
391    let verified = greentic_update::plan::verify_update_plan(&plan_bytes, &envelope_bytes, &trust)
392        .map_err(|e| OpError::Conflict(format!("update plan failed verification: {e}")))?;
393
394    // 3. The plan must target THIS environment. Two identities must agree — the
395    //    plan header (`plan.env_id`) AND the signed desired-state manifest it
396    //    carries (`target.environment.id`). Both are under the DSSE signature, so
397    //    a divergence means a buggy/compromised signer produced a plan whose
398    //    header names this env while its manifest reconciles another; fail closed
399    //    on either mismatch before touching the staging tree.
400    if verified.plan.env_id != env_id.as_str() {
401        return Err(OpError::InvalidArgument(format!(
402            "plan targets env `{}`, not `{env_id}`",
403            verified.plan.env_id
404        )));
405    }
406    let manifest: EnvManifest =
407        serde_json::from_value(verified.plan.target.clone()).map_err(|e| {
408            OpError::InvalidArgument(format!(
409                "plan target is not a valid {ENV_MANIFEST_SCHEMA_V1}: {e}"
410            ))
411        })?;
412    if manifest.environment.id != env_id.as_str() {
413        return Err(OpError::InvalidArgument(format!(
414            "plan target manifest names env `{}`, not `{env_id}`",
415            manifest.environment.id
416        )));
417    }
418
419    // 4. Admit to staging under a single lock hold — or RESUME an
420    //    already-admitted identical plan. The downgrade guard (monotonic
421    //    sequence) and the compatibility gate run INSIDE `begin_checked`'s
422    //    admission predicate, atomically with the begin writes, so a concurrent
423    //    updater on the same env cannot change the applied set between the check
424    //    and the commit (closes the gate/begin race from #417's review). Both
425    //    gates run before any staging write, so a rejected plan leaves nothing
426    //    half-staged.
427    //
428    //    Loading an existing same-digest plan first makes `get` idempotent /
429    //    resumable: `begin_checked` alone errors `PlanExists` on re-run, so a
430    //    crash after admission but before the promotion transitions would strand
431    //    the plan. A same-id plan with a DIFFERENT digest is refused (a distinct
432    //    plan must not reuse the id).
433    let root = open_updates_root(&env_id, updates_root_override)?;
434    let staged = admit_or_resume(&root, &verified, &plan_bytes, &envelope_bytes)?;
435
436    // 6. Fetch every declared artifact into the staging tree, then promote to
437    //    `staged`. A plan with no artifacts promotes straight away. Both paths
438    //    are idempotent/resumable: a plan already past `downloading` is returned
439    //    as-is (a completed prior run), and `put_artifact` is content-addressed
440    //    and fail-closed on a digest mismatch.
441    let artifacts_total = verified.plan.artifacts.len();
442    let final_stage = if artifacts_total == 0 {
443        advance_to_staged(&staged)?
444    } else {
445        let fetcher = DistArtifactFetcher::new();
446        download_and_stage(
447            &staged,
448            &verified.plan.artifacts,
449            &fetcher,
450            RetryPolicy::default(),
451        )?
452    };
453
454    Ok(OpOutcome::new(
455        NOUN,
456        "get",
457        json!({
458            "environment_id": env_id.as_str(),
459            "plan_id": verified.plan.plan_id,
460            "sequence": verified.plan.sequence,
461            "plan_sha256": verified.plan_sha256,
462            "verified_key_ids": verified.verified_key_ids,
463            "stage": final_stage.as_str(),
464            "artifacts_total": artifacts_total,
465            "plan_dir": staged.dir().display().to_string(),
466        }),
467    ))
468}
469
470/// Open the per-env update staging root (`GREENTIC_UPDATES_DIR` or
471/// `~/.greentic/updates/<env_id>`).
472fn open_updates_root(
473    env_id: &EnvId,
474    root_override: Option<&std::path::Path>,
475) -> Result<greentic_update::staging::UpdatesRoot, OpError> {
476    let opened = match root_override {
477        Some(root) => greentic_update::staging::UpdatesRoot::open_in(root, env_id.as_str()),
478        None => greentic_update::staging::UpdatesRoot::open(env_id.as_str()),
479    };
480    opened.map_err(|e| OpError::Conflict(format!("open update staging root: {e}")))
481}
482
483/// The `begin_checked` admission predicate for `op updates get`: the downgrade
484/// guard (monotonic sequence vs the applied set) and the compatibility gate,
485/// evaluated against the lock-held [`AdmissionFacts`] snapshot so both run
486/// atomically with the begin writes. Returns the `OpError` a rejection surfaces
487/// as; the caller maps `BeginCheckedError::Rejected(op)` straight back to it.
488///
489/// [`AdmissionFacts`]: greentic_update::staging::AdmissionFacts
490fn admit_plan(
491    verified: &greentic_update::plan::VerifiedUpdatePlan,
492    facts: &greentic_update::staging::AdmissionFacts,
493) -> Result<(), OpError> {
494    // Downgrade guard: the plan's sequence must be newer than the highest
495    // already-applied sequence (read under the staging lock).
496    greentic_update::plan::ensure_not_downgrade(&verified.plan, facts.latest_applied_sequence)
497        .map_err(|e| OpError::Conflict(format!("update plan rejected: {e}")))?;
498    // Compatibility gate against the applied set + local runtime facts.
499    let runtime_facts = greentic_update::plan::RuntimeFacts {
500        // The operator CLI is released in lockstep with the runtime it manages,
501        // so its own version is the runtime-version floor we can assert locally.
502        runtime_version: Some(env!("CARGO_PKG_VERSION")),
503        // The operator does not observe the live component ABI; a plan pinning
504        // `compat.abi` is left to apply-time (Phase 3), where the running runtime
505        // reports it. Unknown here ⇒ `check_compat` fails closed on an abi pin.
506        abi: None,
507        applied_plan_ids: &facts.applied_plan_ids,
508    };
509    greentic_update::plan::check_compat(&verified.plan.compat, &runtime_facts)
510        .map_err(|e| OpError::Conflict(format!("update plan incompatible: {e}")))
511}
512
513/// Admit a verified plan to staging, or resume an identical already-staged one.
514///
515/// Fresh admission runs [`admit_plan`] inside `begin_checked`, so the downgrade
516/// and compat gates are atomic with the begin writes. On RESUME (a same-digest
517/// plan already present — the idempotent/crash-recovery path), admission is
518/// **re-run** before any further promotion: a plan stranded at `downloading`/
519/// `inbox` could otherwise be promoted after a newer plan was applied in the
520/// interim, silently bypassing the downgrade guard `begin_checked` makes
521/// authoritative. Terminal `failed`/`rejected` plans are refused (not resumed
522/// as success); already-`staged`/`applying`/`applied` plans passed admission at
523/// begin and are returned as-is.
524///
525/// The resume re-check is best-effort (not held under the staging lock — the
526/// deployer can't; the atomic gate is the fresh `begin_checked`, and apply
527/// re-checks downgrade). Single-operator use is unaffected.
528fn admit_or_resume(
529    root: &greentic_update::staging::UpdatesRoot,
530    verified: &greentic_update::plan::VerifiedUpdatePlan,
531    plan_bytes: &[u8],
532    envelope_bytes: &[u8],
533) -> Result<greentic_update::staging::StagedPlan, OpError> {
534    use greentic_update::staging::UpdateStage;
535    match root
536        .load(&verified.plan.plan_id)
537        .map_err(|e| OpError::Conflict(format!("load staged update plan: {e}")))?
538    {
539        Some(existing) => {
540            if existing.plan_sha256() != verified.plan_sha256 {
541                return Err(OpError::Conflict(format!(
542                    "a different plan is already staged under id `{}`",
543                    verified.plan.plan_id
544                )));
545            }
546            let stage = existing
547                .stage()
548                .map_err(|e| OpError::Conflict(format!("read update staging stage: {e}")))?;
549            match stage {
550                // Terminal outcomes are not "resumable" — report, don't succeed.
551                UpdateStage::Failed | UpdateStage::Rejected => Err(OpError::Conflict(format!(
552                    "plan `{}` is already `{stage}`; not resuming",
553                    verified.plan.plan_id
554                ))),
555                // Stranded mid-flight: re-gate against the CURRENT applied set
556                // before resuming, so a newer applied plan invalidates it.
557                UpdateStage::Downloading | UpdateStage::Inbox => {
558                    admit_plan(verified, &current_admission_facts(root)?)?;
559                    Ok(existing)
560                }
561                // Already admitted AND promoted — its gates ran at begin.
562                UpdateStage::Staged | UpdateStage::Applying | UpdateStage::Applied => Ok(existing),
563            }
564        }
565        None => root
566            .begin_checked(verified, plan_bytes, envelope_bytes, |facts| {
567                admit_plan(verified, facts)
568            })
569            .map_err(|e| match e {
570                greentic_update::staging::BeginCheckedError::Rejected(op) => op,
571                greentic_update::staging::BeginCheckedError::Staging(s) => {
572                    OpError::Conflict(format!("stage update plan: {s}"))
573                }
574            }),
575    }
576}
577
578/// Snapshot the applied-plan set for a resume-time re-gate (best-effort — not
579/// under the staging lock; the atomic gate is `begin_checked`).
580fn current_admission_facts(
581    root: &greentic_update::staging::UpdatesRoot,
582) -> Result<greentic_update::staging::AdmissionFacts, OpError> {
583    let applied: Vec<_> = root
584        .list()
585        .map_err(|e| OpError::Conflict(format!("list staged update plans: {e}")))?
586        .into_iter()
587        .filter(|s| s.stage == greentic_update::staging::UpdateStage::Applied)
588        .collect();
589    Ok(greentic_update::staging::AdmissionFacts {
590        latest_applied_sequence: applied.iter().map(|s| s.sequence).max(),
591        applied_plan_ids: applied.into_iter().map(|s| s.plan_id).collect(),
592    })
593}
594
595/// Promote a plan to `staged`, from wherever it currently sits (`downloading` →
596/// `inbox` → `staged`). Idempotent: an already-`staged` plan is a no-op, so a
597/// resumed partial run converges. Non-`downloading`/`inbox` stages (already
598/// `staged`, or terminal) are left untouched. Used by both the zero-artifact
599/// path and after a successful artifact download.
600fn advance_to_staged(
601    staged: &greentic_update::staging::StagedPlan,
602) -> Result<greentic_update::staging::UpdateStage, OpError> {
603    use greentic_update::staging::UpdateStage;
604    let mut stage = staged
605        .stage()
606        .map_err(|e| OpError::Conflict(format!("read update staging stage: {e}")))?;
607    if stage == UpdateStage::Downloading {
608        stage = staged
609            .transition(UpdateStage::Inbox)
610            .map_err(|e| OpError::Conflict(format!("advance update staging: {e}")))?
611            .stage;
612    }
613    if stage == UpdateStage::Inbox {
614        stage = staged
615            .transition(UpdateStage::Staged)
616            .map_err(|e| OpError::Conflict(format!("advance update staging: {e}")))?
617            .stage;
618    }
619    Ok(stage)
620}
621
622/// `op updates apply` — apply a STAGED update plan to its environment
623/// (Phase 3 of the Greentic updater). **Mutation.**
624///
625/// The staged plan (from `op updates get`) is re-verified end-to-end off the
626/// on-disk staging tree *before* any environment mutation — DSSE signature,
627/// per-artifact checksums, the tamper cross-check against the write-time
628/// digest, the target-env identity, and the downgrade + compat gates against
629/// the current applied set. Re-verification is defense-in-depth: the plan was
630/// verified at `get`, but the bytes sitting on disk are untrusted at apply
631/// time.
632///
633/// A passing plan is then applied under a whole-env snapshot: `staged →
634/// applying`, snapshot the environment (P0b), drive the declarative
635/// [`env_apply`](super::env_apply::apply) pipeline with the plan's signed
636/// target manifest, and on success `applying → applied` (so
637/// `latest_applied_sequence` advances). On ANY apply failure the pre-apply
638/// snapshot is restored and the plan is marked `failed`. A plan stranded in
639/// `applying` from a prior crash is failed closed (re-stage via `op updates
640/// get`). The mutating region runs inside `audit_and_record`.
641///
642/// Scope of this increment: **content add/update only** (the manifest is
643/// upsert-applied — resource removal/prune is deferred). Success means the env
644/// store converged (`env_apply`'s internal verify); live runtime health is not
645/// gated here (the deployer cannot reach `greentic-start`'s health gate). The
646/// binary self-update track is not built; a plan carrying binaries would fail
647/// the manifest parse. Fail-closed guards on the target manifest (see
648/// [`check_applyable_manifest`]): bundles must be `bundle_digest`-pinned (so
649/// `env_apply` verifies the applied bytes against the signed plan), and
650/// `secrets[]` / `messaging_endpoints[]` (which write dev-store secret material)
651/// are applyable only when the effective `Secrets` sink is the P0b-snapshotted
652/// dev-store, so a failed apply can roll those writes back. Concurrent apply on
653/// one env is single-flight: `begin_apply_checked`
654/// admits at most one plan into `applying` per env under the staging lock,
655/// rejecting a second, and runs the downgrade/compat re-gate atomically with the
656/// `staged → applying` transition.
657pub fn apply_updates(
658    store: &LocalFsStore,
659    flags: &OpFlags,
660    payload: Option<ApplyUpdatesPayload>,
661) -> Result<OpOutcome, OpError> {
662    apply_updates_impl(store, flags, payload, None)
663}
664
665/// Body of [`apply_updates`], with an optional staging-root override so tests
666/// can point the FSM at a tempdir instead of `~/.greentic/updates`.
667fn apply_updates_impl(
668    store: &LocalFsStore,
669    flags: &OpFlags,
670    payload: Option<ApplyUpdatesPayload>,
671    updates_root_override: Option<&std::path::Path>,
672) -> Result<OpOutcome, OpError> {
673    use greentic_update::staging::{RetentionPolicy, UpdateStage};
674
675    if flags.schema_only {
676        return Ok(OpOutcome::new(NOUN, "apply", apply_updates_schema()));
677    }
678    let payload = resolve_payload::<ApplyUpdatesPayload>(flags, payload)?;
679    let env_id = parse_env_id(&payload.environment_id)?;
680
681    // Load the staged plan handle (read-only). A missing plan is a plain
682    // NotFound — nothing to apply.
683    let root = open_updates_root(&env_id, updates_root_override)?;
684    let staged = root
685        .load(&payload.plan_id)
686        .map_err(|e| OpError::Conflict(format!("load staged update plan: {e}")))?
687        .ok_or_else(|| {
688            OpError::NotFound(format!(
689                "no staged plan `{}` under env `{env_id}`; run `op updates get` first",
690                payload.plan_id
691            ))
692        })?;
693
694    // Stage gate. Only a `staged` plan is applicable. An `applying` plan is
695    // NOT auto-failed here: the staging lock is not held across the whole apply
696    // (env_apply's own flock serializes the mutation), so `applying` cannot be
697    // told apart from an *active* concurrent apply of the same plan — failing it
698    // would let that apply mutate the env yet be unable to reach `applied`,
699    // leaving the env changed while `latest_applied_sequence` never advances. So
700    // return a retryable conflict and touch nothing; a genuinely stuck plan is
701    // recovered explicitly, not by a racy self-heal. Any other stage (still
702    // downloading, or already terminal) is a plain argument error.
703    let stage = staged
704        .stage()
705        .map_err(|e| OpError::Conflict(format!("read update staging stage: {e}")))?;
706    match stage {
707        UpdateStage::Staged => {}
708        UpdateStage::Applying => {
709            return Err(OpError::Conflict(format!(
710                "plan `{}` is already `applying` on env `{env_id}` (another apply may be in \
711                 progress, or a prior one did not finish); retry once it settles",
712                payload.plan_id
713            )));
714        }
715        other => {
716            return Err(OpError::InvalidArgument(format!(
717                "plan `{}` is `{other}`, not `staged`; only a staged plan can be applied",
718                payload.plan_id
719            )));
720        }
721    }
722
723    // Re-verify the staged plan bytes off disk (DSSE + tamper cross-check +
724    // target-env identity). A rejected plan is dead — mark it `rejected`.
725    let verified = match reverify_staged(store, &staged, &env_id) {
726        Ok(v) => v,
727        Err(e) => {
728            let _ = staged.transition(UpdateStage::Rejected);
729            return Err(e);
730        }
731    };
732
733    // `op updates apply` converges CONTENT (artifacts + target bundles) only. A
734    // plan may also carry binary artifacts, which are installed by the
735    // greentic-start update receiver, not by this verb — warn so an "applied"
736    // result is never read as "binary installed", and a binary-only plan-build
737    // output is not silently applied as a no-op success.
738    if !verified.plan.binaries.is_empty() {
739        tracing::warn!(
740            env_id = %env_id,
741            binary_count = verified.plan.binaries.len(),
742            "update plan carries binary artifact(s) that `op updates apply` does not \
743             install; binary self-update is applied by the greentic-start update receiver"
744        );
745    }
746
747    // The downgrade + compat re-gate moves INTO the `begin_apply_checked`
748    // predicate below, so it runs atomically with the `staged → applying`
749    // transition against a lock-held applied-set snapshot (closing the TOCTOU
750    // where a newer plan applies between the re-gate and the transition).
751
752    // Re-verify every declared artifact's on-disk checksum (fail closed), and
753    // record each verified artifact's content-addressed blob path keyed by its
754    // digest, so bundle entries can be materialized from the local staged set
755    // below (no network re-fetch at apply time).
756    let mut staged_blobs: BTreeMap<String, PathBuf> = BTreeMap::new();
757    for artifact in &verified.plan.artifacts {
758        if let Err(e) = staged.verify_artifact_on_disk(artifact) {
759            let _ = staged.transition(UpdateStage::Rejected);
760            return Err(OpError::Conflict(format!(
761                "staged artifact `{}` failed integrity re-check: {e}",
762                artifact.name
763            )));
764        }
765        // Infallible after the verify above (same digest validation), but keep
766        // it fail-closed rather than unwrapping.
767        let blob = staged.artifact_blob_path(artifact).map_err(|e| {
768            OpError::Conflict(format!(
769                "resolve staged blob path for artifact `{}`: {e}",
770                artifact.name
771            ))
772        })?;
773        staged_blobs.insert(artifact.digest.clone(), blob);
774    }
775
776    // The plan's signed target manifest drives the apply. Point its bundle
777    // artifacts at the already-verified staged blobs so the apply runs from
778    // local disk instead of re-fetching them from the network.
779    let target = materialize_bundles(&verified.plan.target, &staged_blobs);
780    let ctx = AuditCtx {
781        env_id: env_id.clone(),
782        noun: NOUN,
783        verb: "apply",
784        target: json!({
785            "environment_id": env_id.as_str(),
786            "plan_id": verified.plan.plan_id,
787            "sequence": verified.plan.sequence,
788            "plan_sha256": verified.plan_sha256,
789        }),
790        // Applying the same plan twice is a no-op via the FSM (a second apply
791        // hits the terminal-stage gate); key audit dedup on the plan id.
792        idempotency_key: Some(verified.plan.plan_id.clone()),
793    };
794    audit_and_record(store, ctx, |committed| {
795        // Atomically admit this plan into `applying` under one staging-lock hold:
796        // the downgrade/compat re-gate runs against a race-free applied-set
797        // snapshot, a second in-flight apply is rejected (single-flight), and the
798        // `staged → applying` transition commits — all before the lock releases.
799        // This closes the concurrent-apply TOCTOU the best-effort guard only
800        // narrowed; env_apply's own per-env store flock still serializes the
801        // actual mutation below.
802        match root.begin_apply_checked(&verified.plan.plan_id, |facts| admit_plan(&verified, facts))
803        {
804            Ok(_applying) => {}
805            Err(greentic_update::staging::BeginApplyError::Rejected(e)) => {
806                // The downgrade/compat re-gate rejected the plan (a newer plan
807                // applied since staging) — it's dead. Nothing was mutated.
808                let _ = staged.transition(UpdateStage::Rejected);
809                return Err(e);
810            }
811            Err(greentic_update::staging::BeginApplyError::AlreadyApplying {
812                applying, ..
813            }) => {
814                return Err(OpError::Conflict(format!(
815                    "another update plan (`{applying}`) is already applying to env `{env_id}`; \
816                     apply is single-flight per environment"
817                )));
818            }
819            Err(greentic_update::staging::BeginApplyError::Staging(s)) => {
820                // A staging error is usually pre-commit (the target left `staged`
821                // between the pre-check and the lock, or a marker is corrupt —
822                // nothing mutated). But begin_apply_checked writes `state.json`
823                // = `applying` BEFORE appending its audit line, so an audit-append
824                // failure surfaces here AFTER the transition already committed.
825                // Re-read the stage: if this plan reached `applying`, the state is
826                // durable, so mark the op committed for the audit ledger rather
827                // than mis-reporting it as non-mutating.
828                if staged
829                    .stage()
830                    .map(|st| st == UpdateStage::Applying)
831                    .unwrap_or(false)
832                {
833                    committed.mark_committed();
834                }
835                return Err(OpError::Conflict(format!("apply admission failed: {s}")));
836            }
837        }
838        // `applying` is now committed on disk, so every path below must be
839        // fail-closed for the audit ledger.
840        committed.mark_committed();
841
842        // Snapshot the whole env BEFORE any mutation. If this fails, nothing
843        // was mutated — fail the plan, no restore needed.
844        let snap_id = match snapshot_environment(store, &env_id) {
845            Ok(id) => id,
846            Err(e) => {
847                let _ = staged.transition(UpdateStage::Failed);
848                return Err(e.into());
849            }
850        };
851
852        // Drive the declarative apply pipeline with the signed target manifest.
853        match run_manifest_apply(store, &target) {
854            Ok(apply_outcome) => {
855                // Build the success outcome body. Shared by the normal-success
856                // path AND the Case-A recovery (state reached Applied but the
857                // audit-append failed), so it lives in a closure to stay DRY.
858                let build_success_outcome = || {
859                    let mut body = json!({
860                        "environment_id": env_id.as_str(),
861                        "plan_id": verified.plan.plan_id,
862                        "sequence": verified.plan.sequence,
863                        "plan_sha256": verified.plan_sha256,
864                        "snapshot_id": snap_id.to_string(),
865                        "stage": UpdateStage::Applied.as_str(),
866                        "apply_result": apply_outcome.result,
867                    });
868                    // Surface any binary artifacts this content apply did NOT
869                    // install, so the "applied" result is never misread as a
870                    // completed binary self-update (those are applied by the
871                    // greentic-start receiver).
872                    if !verified.plan.binaries.is_empty() {
873                        let not_applied: Vec<Value> = verified
874                            .plan
875                            .binaries
876                            .iter()
877                            .map(|b| {
878                                json!({"name": b.name, "version": b.version, "target": b.target})
879                            })
880                            .collect();
881                        body["binaries_not_applied"] = Value::Array(not_applied);
882                    }
883                    let outcome = OpOutcome::new(NOUN, "apply", body);
884                    Ok((outcome, super::AuditGens::NONE))
885                };
886
887                // Allow test code to inject a fault immediately before the
888                // applying -> applied transition, so the Case-B honest-error
889                // branch is exercisable.
890                #[cfg(test)]
891                run_pre_applied_transition_hook();
892
893                // Retry the applying -> applied transition up to 2 attempts
894                // with a 200ms sleep between. If the transition persistently
895                // fails, re-read the stage: if it already reached Applied
896                // (Case A: state.json committed but audit-append failed), take
897                // the success path. Otherwise (Case B: state.json stuck at
898                // Applying), return an honest error stating the env content IS
899                // applied and pointing at `op updates recover`.
900                for attempt in 0..2u8 {
901                    match staged.transition(UpdateStage::Applied) {
902                        Ok(_) => {
903                            // Best-effort retention of terminal plans (never evicts active).
904                            let _ = root.apply_retention(&RetentionPolicy { keep_terminal: 5 });
905                            return build_success_outcome();
906                        }
907                        Err(e) => {
908                            if attempt == 0 {
909                                tracing::warn!(
910                                    plan_id = %verified.plan.plan_id,
911                                    error = %e,
912                                    "applying -> applied transition failed; retrying in 200ms"
913                                );
914                                std::thread::sleep(std::time::Duration::from_millis(200));
915                            }
916                            // Second attempt failed — fall through to re-read.
917                        }
918                    }
919                }
920
921                // Persistent failure. Re-read the on-disk stage to distinguish
922                // Case A (state IS Applied, audit-append gap) from Case B
923                // (state stuck at Applying).
924                match staged.stage() {
925                    Ok(UpdateStage::Applied) => {
926                        // Case A: the state.json write committed but the
927                        // audit-append (or a subsequent retry's lock acquire)
928                        // failed. The FSM is correct — take the success path.
929                        let _ = root.apply_retention(&RetentionPolicy { keep_terminal: 5 });
930                        build_success_outcome()
931                    }
932                    stage_result => {
933                        // Case B: state.json did not reach Applied — either
934                        // stuck (likely Applying) or unreadable. The env
935                        // content IS applied, but the FSM marker did not
936                        // advance. Do NOT restore the snapshot — the env is
937                        // correct. Return an honest error with recovery
938                        // instructions.
939                        let detail = match stage_result {
940                            Ok(s) => format!(" (current stage: `{}`)", s.as_str()),
941                            Err(e) => format!(" and the current stage is unreadable: {e}"),
942                        };
943                        Err(OpError::Conflict(format!(
944                            "plan `{}` content was applied successfully, but the staging \
945                             marker could not advance to `applied`{}; \
946                             the environment is correct — run \
947                             `op updates recover --force` to un-stick the marker, then \
948                             re-stage with `op updates get` if sequence tracking matters",
949                            verified.plan.plan_id, detail,
950                        )))
951                    }
952                }
953            }
954            Err(apply_err) => {
955                // Roll the whole env back to the pre-apply snapshot, then fail
956                // the plan. The plan is dead either way, but the surfaced error
957                // must tell the TRUTH about whether the rollback actually
958                // completed — never claim "restored" when restore failed and the
959                // env may be partially applied.
960                let restored = restore_environment(store, &env_id, &snap_id);
961                let _ = staged.transition(UpdateStage::Failed);
962                match restored {
963                    Ok(()) => Err(OpError::Conflict(format!(
964                        "apply of plan `{}` failed; environment rolled back to snapshot `{snap_id}`: \
965                         {apply_err}",
966                        verified.plan.plan_id
967                    ))),
968                    Err(restore_err) => {
969                        tracing::error!(
970                            env_id = %env_id,
971                            snapshot_id = %snap_id,
972                            apply_error = %apply_err,
973                            restore_error = %restore_err,
974                            "apply-updates rollback FAILED; environment may be partially applied"
975                        );
976                        Err(OpError::Conflict(format!(
977                            "apply of plan `{}` failed AND automatic rollback FAILED; the \
978                             environment may be partially applied — manual recovery is required \
979                             from snapshot `{snap_id}`. apply error: {apply_err}; rollback error: \
980                             {restore_err}",
981                            verified.plan.plan_id
982                        )))
983                    }
984                }
985            }
986        }
987    })
988}
989
990/// `op updates recover` — force-fail a plan stranded in `applying` by a crashed
991/// applier (Phase 3.1 of the Greentic updater). **Mutation.**
992///
993/// `op updates apply` deliberately refuses to auto-fail an `applying` plan: the
994/// staging lock is not held across the whole apply (env_apply's own flock
995/// serializes the mutation), so on disk a *crashed* applier and an *active*
996/// concurrent apply are indistinguishable — auto-failing the marker could strand
997/// a live apply (env mutated, plan `failed`, `latest_applied_sequence` never
998/// advances). `recover` is the explicit operator escape hatch for the crashed
999/// case: it force-transitions `applying → failed` (a legal FSM edge) under the
1000/// staging lock, and requires `--force` so the operator affirms the applier is
1001/// genuinely dead.
1002///
1003/// Scope: this un-sticks the update FSM only. It does **not** roll back any
1004/// partial environment change the interrupted apply may have made — the P0b
1005/// snapshot id is not durably linked to the plan, so a safe automated rollback is
1006/// not possible here. The success outcome names the env's `snapshots/` directory
1007/// for manual restore, and re-running `op updates get` re-stages the plan for a
1008/// clean apply.
1009pub fn recover_updates(
1010    store: &LocalFsStore,
1011    flags: &OpFlags,
1012    payload: Option<RecoverUpdatesPayload>,
1013    force: bool,
1014) -> Result<OpOutcome, OpError> {
1015    recover_updates_impl(store, flags, payload, force, None)
1016}
1017
1018/// Body of [`recover_updates`], with an optional staging-root override so tests
1019/// can point the FSM at a tempdir instead of `~/.greentic/updates`.
1020fn recover_updates_impl(
1021    store: &LocalFsStore,
1022    flags: &OpFlags,
1023    payload: Option<RecoverUpdatesPayload>,
1024    force: bool,
1025    updates_root_override: Option<&std::path::Path>,
1026) -> Result<OpOutcome, OpError> {
1027    use greentic_update::staging::UpdateStage;
1028
1029    if flags.schema_only {
1030        return Ok(OpOutcome::new(NOUN, "recover", recover_schema()));
1031    }
1032    let payload = resolve_payload::<RecoverUpdatesPayload>(flags, payload)?;
1033    let env_id = parse_env_id(&payload.environment_id)?;
1034
1035    // Load the staged plan handle (read-only). A missing plan is a plain
1036    // NotFound — nothing to recover.
1037    let root = open_updates_root(&env_id, updates_root_override)?;
1038    let staged = root
1039        .load(&payload.plan_id)
1040        .map_err(|e| OpError::Conflict(format!("load staged update plan: {e}")))?
1041        .ok_or_else(|| {
1042            OpError::NotFound(format!(
1043                "no staged plan `{}` under env `{env_id}`; nothing to recover",
1044                payload.plan_id
1045            ))
1046        })?;
1047
1048    // Stage gate. Only an `applying` plan is recoverable. Every other stage is an
1049    // argument error with a stage-specific hint — nothing is mutated, nothing is
1050    // audited (this mirrors how `apply` gates before its audited region).
1051    let state = staged
1052        .state()
1053        .map_err(|e| OpError::Conflict(format!("read update staging state: {e}")))?;
1054    match state.stage {
1055        UpdateStage::Applying => {}
1056        UpdateStage::Staged => {
1057            return Err(OpError::InvalidArgument(format!(
1058                "plan `{}` is `staged`, not `applying`; nothing to recover — apply it with \
1059                 `op updates apply`",
1060                payload.plan_id
1061            )));
1062        }
1063        terminal @ (UpdateStage::Applied | UpdateStage::Failed | UpdateStage::Rejected) => {
1064            return Err(OpError::InvalidArgument(format!(
1065                "plan `{}` is already `{terminal}` (terminal); nothing to recover",
1066                payload.plan_id
1067            )));
1068        }
1069        staging @ (UpdateStage::Downloading | UpdateStage::Inbox) => {
1070            return Err(OpError::InvalidArgument(format!(
1071                "plan `{}` is `{staging}` (still staging); nothing was applied, so there is \
1072                 nothing to recover — re-run `op updates get`",
1073                payload.plan_id
1074            )));
1075        }
1076    }
1077
1078    // The instant the plan entered `applying` — the operator's cue for whether a
1079    // live apply is plausible (seconds ago) or the applier is long dead.
1080    let applying_since = state.updated_at.to_rfc3339();
1081
1082    // Fail closed unless the operator explicitly asserts the applier is dead. On
1083    // disk an `applying` plan cannot be told apart from a live concurrent apply,
1084    // and force-failing a live apply would strand it (env mutated, plan `failed`,
1085    // sequence never advanced). `--force` is that assertion.
1086    if !force {
1087        return Err(OpError::Conflict(format!(
1088            "plan `{}` is `applying` on env `{env_id}` (since {applying_since}); recover \
1089             force-fails it to `failed`, which is UNSAFE if an apply is genuinely in progress. \
1090             If you have confirmed no apply is running for this plan, re-run with `--force`",
1091            payload.plan_id
1092        )));
1093    }
1094
1095    let ctx = AuditCtx {
1096        env_id: env_id.clone(),
1097        noun: NOUN,
1098        verb: "recover",
1099        target: json!({
1100            "environment_id": env_id.as_str(),
1101            "plan_id": payload.plan_id,
1102            "previous_stage": UpdateStage::Applying.as_str(),
1103            "applying_since": applying_since,
1104        }),
1105        // Recovering the same plan twice is a no-op: the second call hits the
1106        // terminal-stage gate above (now `failed`) before reaching this mutation.
1107        idempotency_key: Some(payload.plan_id.clone()),
1108    };
1109    audit_and_record(store, ctx, |committed| {
1110        // The single mutation: force the stranded plan to `failed` under the
1111        // staging lock, which re-reads the on-disk stage before validating the
1112        // `applying → failed` edge (safe against a concurrent transition).
1113        //
1114        // `transition` writes `state.json` BEFORE appending its own staging audit
1115        // line, so an audit-append failure returns `Err` AFTER `failed` already
1116        // committed. Mirror the apply-admission handling: on error, re-read the
1117        // stage; if the plan is now `failed`, the mutation is durable, so mark the
1118        // op committed — the deployer audit boundary must stay fail-closed rather
1119        // than demote the failure to best-effort.
1120        if let Err(e) = staged.transition(UpdateStage::Failed) {
1121            if staged
1122                .stage()
1123                .map(|s| s == UpdateStage::Failed)
1124                .unwrap_or(false)
1125            {
1126                committed.mark_committed();
1127            }
1128            return Err(OpError::Conflict(format!(
1129                "force-fail plan (applying → failed): {e}"
1130            )));
1131        }
1132        committed.mark_committed();
1133        let outcome = OpOutcome::new(
1134            NOUN,
1135            "recover",
1136            json!({
1137                "environment_id": env_id.as_str(),
1138                "plan_id": payload.plan_id,
1139                "previous_stage": UpdateStage::Applying.as_str(),
1140                "stage": UpdateStage::Failed.as_str(),
1141                "applying_since": applying_since,
1142                "note": "recover un-stuck the update FSM (applying → failed); it did NOT roll \
1143                         back any partial environment change from the interrupted apply. Inspect \
1144                         the environment and restore from a snapshot under <env_dir>/snapshots/ if \
1145                         needed. This plan id is now terminal (`failed`) and cannot be re-staged; \
1146                         retry by fetching a fresh plan with `op updates get`.",
1147            }),
1148        );
1149        Ok((outcome, super::AuditGens::NONE))
1150    })
1151}
1152
1153/// `op updates config-set` — set the update-channel notification policy. Only
1154/// the fields supplied are changed; the rest keep their stored value. An absent
1155/// `update-channel.json` is seeded from `disabled` (deny-by-default), so the
1156/// first `config-set` is what turns the channel on.
1157pub fn config_set(
1158    store: &LocalFsStore,
1159    flags: &OpFlags,
1160    payload: Option<UpdateConfigSetPayload>,
1161) -> Result<OpOutcome, OpError> {
1162    if flags.schema_only {
1163        return Ok(OpOutcome::new(NOUN, "config-set", config_set_schema()));
1164    }
1165    let payload = resolve_payload::<UpdateConfigSetPayload>(flags, payload)?;
1166    let env_id = parse_env_id(&payload.environment_id)?;
1167
1168    // Parse/validate every input BEFORE touching the store or the audit log, so
1169    // a malformed value is rejected fail-closed with nothing half-written.
1170    let parsed_on_notify = payload
1171        .on_notify
1172        .as_deref()
1173        .map(|raw| {
1174            OnNotifyAction::parse(raw).ok_or_else(|| {
1175                OpError::InvalidArgument(format!(
1176                    "on_notify {raw:?} is not a valid action (expected `record-only` or `stage`)"
1177                ))
1178            })
1179        })
1180        .transpose()?;
1181    if let Some(secs) = payload.poll_interval_secs
1182        && secs < MIN_POLL_INTERVAL_SECS
1183    {
1184        return Err(OpError::InvalidArgument(format!(
1185            "poll_interval_secs {secs} is below the {MIN_POLL_INTERVAL_SECS}s floor"
1186        )));
1187    }
1188    if !store.exists(&env_id)? {
1189        return Err(OpError::NotFound(format!(
1190            "environment `{env_id}` not found"
1191        )));
1192    }
1193
1194    let mut fields = Vec::new();
1195    if payload.enabled.is_some() {
1196        fields.push("enabled");
1197    }
1198    if parsed_on_notify.is_some() {
1199        fields.push("on_notify");
1200    }
1201    if payload.poll_interval_secs.is_some() {
1202        fields.push("poll_interval_secs");
1203    }
1204
1205    let ctx = AuditCtx {
1206        env_id: env_id.clone(),
1207        noun: NOUN,
1208        verb: "config-set",
1209        target: json!({ "fields": fields }),
1210        idempotency_key: None,
1211    };
1212    audit_and_record(store, ctx, |_committed| {
1213        // One locked transaction (mirrors `op config set` → `update_environment`):
1214        // hold the env flock across validate → read → merge → write so two
1215        // disjoint concurrent `config-set`s can't drop each other's fields, and
1216        // a corrupt/spoofed env directory (which `exists` alone would admit) is
1217        // rejected fail-closed before anything is written.
1218        let cfg = store.transact(&env_id, |locked| -> Result<UpdateChannelConfig, OpError> {
1219            // Validated Environment load under the lock (schema + env-id binding).
1220            locked.load()?;
1221            let mut cfg = locked
1222                .load_update_channel()?
1223                .unwrap_or_else(|| UpdateChannelConfig::disabled(env_id.clone()));
1224            if let Some(enabled) = payload.enabled {
1225                cfg.enabled = Some(enabled);
1226            }
1227            if let Some(on_notify) = parsed_on_notify {
1228                cfg.on_notify = Some(on_notify);
1229            }
1230            if let Some(secs) = payload.poll_interval_secs {
1231                cfg.poll_interval_secs = Some(secs);
1232            }
1233            locked.save_update_channel(&cfg)?;
1234            Ok(cfg)
1235        })?;
1236        let outcome = OpOutcome::new(NOUN, "config-set", config_view(&cfg));
1237        Ok((outcome, super::AuditGens::NONE))
1238    })
1239}
1240
1241/// `op updates config-show` — read the update-channel policy: the raw stored
1242/// fields plus the resolved effective values. Read-only, not audited.
1243pub fn config_show(
1244    store: &LocalFsStore,
1245    flags: &OpFlags,
1246    payload: Option<UpdateConfigShowFilter>,
1247) -> Result<OpOutcome, OpError> {
1248    if flags.schema_only {
1249        return Ok(OpOutcome::new(NOUN, "config-show", config_show_schema()));
1250    }
1251    let payload = resolve_payload::<UpdateConfigShowFilter>(flags, payload)?;
1252    let env_id = parse_env_id(&payload.environment_id)?;
1253    if !store.exists(&env_id)? {
1254        return Err(OpError::NotFound(format!(
1255            "environment `{env_id}` not found"
1256        )));
1257    }
1258    let cfg = store
1259        .load_update_channel(&env_id)?
1260        .unwrap_or_else(|| UpdateChannelConfig::disabled(env_id.clone()));
1261    Ok(OpOutcome::new(NOUN, "config-show", config_view(&cfg)))
1262}
1263
1264/// Render an [`UpdateChannelConfig`] for an op outcome: the raw stored fields
1265/// plus the resolved effective values, so an operator sees both what is set and
1266/// what the runtime will actually do.
1267fn config_view(cfg: &UpdateChannelConfig) -> Value {
1268    json!({
1269        "environment_id": cfg.environment_id.as_str(),
1270        "enabled": cfg.enabled,
1271        "on_notify": cfg.on_notify.map(|a| a.as_str()),
1272        "poll_interval_secs": cfg.poll_interval_secs,
1273        "resolved": {
1274            "enabled": cfg.resolved_enabled(),
1275            "on_notify": cfg.resolved_on_notify().as_str(),
1276            "poll_interval_secs": cfg.resolved_poll_interval_secs(),
1277        }
1278    })
1279}
1280
1281/// Re-verify a staged plan off its on-disk bytes: DSSE signature against the
1282/// env trust root, a hash cross-check against the write-time digest recorded in
1283/// `state.json` (catches a `plan.json` swapped after staging), and the
1284/// target-env identity (both the plan header and the signed manifest must name
1285/// this env). Returns the re-verified plan.
1286fn reverify_staged(
1287    store: &LocalFsStore,
1288    staged: &greentic_update::staging::StagedPlan,
1289    env_id: &EnvId,
1290) -> Result<greentic_update::plan::VerifiedUpdatePlan, OpError> {
1291    let plan_bytes = staged
1292        .plan_bytes()
1293        .map_err(|e| OpError::Conflict(format!("read staged plan bytes: {e}")))?;
1294    let envelope_bytes = staged
1295        .envelope_bytes()
1296        .map_err(|e| OpError::Conflict(format!("read staged plan envelope: {e}")))?;
1297
1298    let env_dir = store.env_dir(env_id)?;
1299    let trust = store_trust_root::load(&env_dir)?;
1300    let verified = greentic_update::plan::verify_update_plan(&plan_bytes, &envelope_bytes, &trust)
1301        .map_err(|e| OpError::Conflict(format!("staged plan failed re-verification: {e}")))?;
1302
1303    // The freshly-hashed plan bytes must match the digest captured at stage
1304    // time; a divergence means `plan.json` changed on disk since it was staged.
1305    if verified.plan_sha256 != staged.plan_sha256() {
1306        return Err(OpError::Conflict(format!(
1307            "staged plan `{}` hash changed since staging (tampered on disk?)",
1308            verified.plan.plan_id
1309        )));
1310    }
1311    // Target-env identity: the plan header AND the signed desired-state manifest
1312    // must both name this env (both are under the DSSE signature).
1313    if verified.plan.env_id != env_id.as_str() {
1314        return Err(OpError::InvalidArgument(format!(
1315            "staged plan targets env `{}`, not `{env_id}`",
1316            verified.plan.env_id
1317        )));
1318    }
1319    let manifest: EnvManifest =
1320        serde_json::from_value(verified.plan.target.clone()).map_err(|e| {
1321            OpError::InvalidArgument(format!(
1322                "plan target is not a valid {ENV_MANIFEST_SCHEMA_V1}: {e}"
1323            ))
1324        })?;
1325    if manifest.environment.id != env_id.as_str() {
1326        return Err(OpError::InvalidArgument(format!(
1327            "plan target manifest names env `{}`, not `{env_id}`",
1328            manifest.environment.id
1329        )));
1330    }
1331    // Fail closed on manifest content this increment cannot apply *safely*. The
1332    // dev-store-secret guard needs the env's `Secrets` binding, the env dir (to
1333    // check the dev-store files aren't symlinked off the tree), and whether the
1334    // dev-store is redirected off the tree by the override.
1335    let env = store.load(env_id)?;
1336    let dev_secrets_path_override =
1337        std::env::var_os(super::secrets::DEV_SECRETS_PATH_ENV).is_some();
1338    check_applyable_manifest(&env, &env_dir, &manifest, dev_secrets_path_override)?;
1339    Ok(verified)
1340}
1341
1342/// Reject a target manifest whose apply/rollback this increment cannot yet
1343/// guarantee. These are fail-closed scope guards, not permanent limits:
1344///
1345/// - **dev-store secret side effects** — `env_apply` writes dev-store secret
1346///   material for `secrets[]` (a `put-secret` step) and for
1347///   `messaging_endpoints[]` (a telegram-class endpoint auto-provisions a
1348///   webhook secret). Those writes are rollback-safe only when they land in the
1349///   dev-store the P0b snapshot captures, so they're allowed **only** when the
1350///   effective `Secrets` sink is that dev-store — see
1351///   [`dev_store_secret_sink_is_snapshotted`]. (Audited against `env_apply`'s
1352///   `StepOp` execute arms: only `PutSecret` and `EndpointAdd` write dev-store
1353///   secrets.)
1354/// - **unpinned bundles** — require a `bundle_digest` on every bundle (and
1355///   revision). The digest is both the integrity pin and the key that
1356///   materializes the bundle from the verified staged blob set (see
1357///   [`materialize_bundles`]); `env_apply` re-verifies the applied bytes against
1358///   it. Unpinned / trust-on-first-use content has no staged blob to bind to and
1359///   can't be applied.
1360///
1361/// `dev_secrets_path_override` is `GREENTIC_DEV_SECRETS_PATH` presence, resolved
1362/// by the caller (the test harness cannot set process env vars safely).
1363fn check_applyable_manifest(
1364    env: &Environment,
1365    env_dir: &Path,
1366    manifest: &EnvManifest,
1367    dev_secrets_path_override: bool,
1368) -> Result<(), OpError> {
1369    // secrets[] / messaging_endpoints[] both write dev-store secret material;
1370    // allow them only when a failed apply's rollback (the P0b snapshot) would
1371    // undo those writes — i.e. the effective sink is the snapshotted dev-store.
1372    if (!manifest.secrets.is_empty() || !manifest.messaging_endpoints.is_empty())
1373        && let Err(reason) =
1374            dev_store_secret_sink_is_snapshotted(env, env_dir, manifest, dev_secrets_path_override)
1375    {
1376        return Err(dev_store_secret_err(reason));
1377    }
1378    for bundle in &manifest.bundles {
1379        match &bundle.revisions {
1380            Some(revisions) => {
1381                for rev in revisions {
1382                    if rev.bundle_digest.is_none() {
1383                        return Err(unpinned_bundle_err(&bundle.bundle_id, Some(&rev.name)));
1384                    }
1385                }
1386            }
1387            None => {
1388                if bundle.bundle_digest.is_none() {
1389                    return Err(unpinned_bundle_err(&bundle.bundle_id, None));
1390                }
1391            }
1392        }
1393    }
1394    Ok(())
1395}
1396
1397/// Whether the dev-store secret writes `env_apply` performs for this manifest
1398/// would land in the dev-store the P0b snapshot captures (and can therefore be
1399/// rolled back). Returns `Err(reason)` naming the first way the sink escapes the
1400/// snapshot; `Ok(())` when it is fully covered. Four escapes:
1401///
1402/// 1. the env's current `Secrets` binding is a non-dev-store backend (e.g.
1403///    Vault) — those values live outside the snapshot;
1404/// 2. the manifest rebinds the `Secrets` slot to a non-dev-store kind —
1405///    `env_apply` applies `packs[]` before `secrets[]`, so the rebind takes
1406///    effect first and redirects the writes;
1407/// 3. `GREENTIC_DEV_SECRETS_PATH` redirects the dev-store off the env tree,
1408///    which the env-dir-relative snapshot cannot reach.
1409/// 4. a dev-store secrets file (or an ancestor under the env dir) is a symlink —
1410///    the snapshot follows the link on capture but restore's atomic rename-over
1411///    replaces the *link* with a regular file, so the external target keeps the
1412///    written secret; refuse rather than leak a write past rollback.
1413///
1414/// Accepted residuals (single-operator scope, not redesigned here): the binding
1415/// and the symlink state are read before `env_apply` takes its own env flock, so
1416/// a concurrent manual `op env` rebind (or symlink plant) between this check and
1417/// the apply reopens the hole (the same class as the apply re-gate race); and
1418/// the guard is uniform across `secrets[]` and `messaging_endpoints[]` even
1419/// though a Vault `EndpointAdd` only stamps a ref (a possible future loosening).
1420fn dev_store_secret_sink_is_snapshotted(
1421    env: &Environment,
1422    env_dir: &Path,
1423    manifest: &EnvManifest,
1424    dev_secrets_path_override: bool,
1425) -> Result<(), &'static str> {
1426    if !crate::cli::env::secrets_backend_is_dev_store(env) {
1427        return Err("the env's Secrets slot is bound to a non-dev-store backend");
1428    }
1429    if manifest_rebinds_secrets_off_dev_store(manifest) {
1430        return Err("the manifest rebinds the Secrets slot to a non-dev-store backend");
1431    }
1432    if dev_secrets_path_override {
1433        return Err(
1434            "GREENTIC_DEV_SECRETS_PATH redirects the dev-store off the snapshotted env tree",
1435        );
1436    }
1437    // Both dev-store candidate files must resolve through plain directories under
1438    // the env dir — a symlinked candidate (or ancestor) escapes the snapshot's
1439    // rollback (see condition 4). Fail closed on a symlink or any IO error.
1440    for rel in [
1441        crate::cli::secrets::DEV_STORE_RELATIVE,
1442        crate::cli::secrets::DEV_STORE_STATE_RELATIVE,
1443    ] {
1444        if crate::path_safety::assert_no_symlink_ancestors(env_dir, &env_dir.join(rel)).is_err() {
1445            return Err("a dev-store secrets file resolves through a symlink outside the env tree");
1446        }
1447    }
1448    Ok(())
1449}
1450
1451/// True if the manifest's `packs[]` binds the `Secrets` slot to a kind whose
1452/// path is not the dev-store. An unparseable kind is treated as a rebind
1453/// (fail-closed); shape validation rejects it later regardless.
1454fn manifest_rebinds_secrets_off_dev_store(manifest: &EnvManifest) -> bool {
1455    manifest.packs.iter().any(|p| {
1456        p.slot == greentic_deploy_spec::CapabilitySlot::Secrets
1457            && greentic_deploy_spec::PackDescriptor::try_new(&p.kind)
1458                .map(|d| d.path() != crate::defaults::DEV_STORE_SECRETS_PATH)
1459                .unwrap_or(true)
1460    })
1461}
1462
1463fn dev_store_secret_err(reason: &str) -> OpError {
1464    OpError::InvalidArgument(format!(
1465        "update plan target declares secrets[] or messaging_endpoints[], but {reason}; env_apply \
1466         writes dev-store secret material that the environment snapshot would not cover, so a \
1467         rollback could not undo it"
1468    ))
1469}
1470
1471fn unpinned_bundle_err(bundle_id: &str, revision: Option<&str>) -> OpError {
1472    let target = match revision {
1473        Some(r) => format!("bundle `{bundle_id}` revision `{r}`"),
1474        None => format!("bundle `{bundle_id}`"),
1475    };
1476    OpError::InvalidArgument(format!(
1477        "update plan target {target} has no bundle_digest; update-plan bundles must be \
1478         digest-pinned so the applied content is verified against the signed plan"
1479    ))
1480}
1481
1482/// Rewrite the target manifest's bundle artifact paths to point at the
1483/// content-addressed blobs already staged and integrity-verified for this plan,
1484/// so `env_apply` reads them off local disk instead of re-fetching from the
1485/// network at apply time. For every bundle (single-revision) or revision whose
1486/// `bundle_digest` is present in `staged_blobs`, its `bundle_path` is set to the
1487/// staged blob's absolute path. A `bundle_source_uri`, if present, is left
1488/// intact — it stays the boot-time pull ref for a K8s worker, which reads the
1489/// local `bundle_path` for the apply and the URI later. A bundle whose digest is
1490/// not staged is left untouched, so apply falls back to its declared remote
1491/// source exactly as before this pass existed.
1492fn materialize_bundles(target: &Value, staged_blobs: &BTreeMap<String, PathBuf>) -> Value {
1493    let mut target = target.clone();
1494    let Some(bundles) = target.get_mut("bundles").and_then(Value::as_array_mut) else {
1495        return target;
1496    };
1497    for bundle in bundles {
1498        match bundle.get_mut("revisions").and_then(Value::as_array_mut) {
1499            // Multi-revision: each revision carries its own digest + path.
1500            Some(revisions) => {
1501                for rev in revisions {
1502                    materialize_entry(rev, staged_blobs);
1503                }
1504            }
1505            // Single-revision: the digest + path live on the bundle itself.
1506            None => materialize_entry(bundle, staged_blobs),
1507        }
1508    }
1509    target
1510}
1511
1512/// Point one bundle/revision object at its staged blob when its `bundle_digest`
1513/// is in `staged_blobs`. A digest with no staged blob is a no-op: the entry
1514/// keeps its declared source and apply pulls it remotely. That fall-through is
1515/// warn-logged because, in a plan whose whole point is offline apply, a bundle
1516/// that still has to reach the network is worth surfacing.
1517fn materialize_entry(entry: &mut Value, staged_blobs: &BTreeMap<String, PathBuf>) {
1518    let Some(digest) = entry.get("bundle_digest").and_then(Value::as_str) else {
1519        return;
1520    };
1521    match staged_blobs.get(digest) {
1522        Some(blob) => {
1523            // Absolute content-addressed path; `env_apply` reads it directly and
1524            // re-verifies the bytes against `bundle_digest` at deploy time.
1525            entry["bundle_path"] = Value::String(blob.to_string_lossy().into_owned());
1526        }
1527        None => {
1528            tracing::warn!(
1529                bundle_digest = %digest,
1530                "update bundle digest not in the staged set; apply will fall back to its \
1531                 declared remote source"
1532            );
1533        }
1534    }
1535}
1536
1537/// Write the plan's signed target manifest to a temp file and drive the
1538/// declarative `env_apply` pipeline non-interactively (`--yes`). The temp file
1539/// is held alive until apply returns.
1540fn run_manifest_apply(store: &LocalFsStore, target: &Value) -> Result<OpOutcome, OpError> {
1541    use std::io::Write as _;
1542
1543    let bytes = serde_json::to_vec(target)
1544        .map_err(|e| OpError::InvalidArgument(format!("serialize plan target manifest: {e}")))?;
1545    let mut tmp = tempfile::Builder::new()
1546        .prefix("greentic-update-target-")
1547        .suffix(".json")
1548        .tempfile()
1549        .map_err(|source| OpError::Io {
1550            path: PathBuf::from("<tempfile>"),
1551            source,
1552        })?;
1553    tmp.write_all(&bytes).map_err(|source| OpError::Io {
1554        path: tmp.path().to_path_buf(),
1555        source,
1556    })?;
1557    tmp.flush().map_err(|source| OpError::Io {
1558        path: tmp.path().to_path_buf(),
1559        source,
1560    })?;
1561
1562    let apply_flags = OpFlags {
1563        schema_only: false,
1564        answers: Some(tmp.path().to_path_buf()),
1565    };
1566    let opts = super::env_apply::ApplyOptions {
1567        mode: super::env_apply::ApplyMode::Apply,
1568        updated_by: Some("apply-updates".to_string()),
1569        yes: true,
1570        non_interactive: true,
1571        ..Default::default()
1572    };
1573    super::env_apply::apply(store, &apply_flags, opts)
1574}
1575
1576/// Fetches an update artifact's bytes by its declared `source`. A seam so the
1577/// download orchestration is unit-testable without a live registry.
1578trait ArtifactFetcher {
1579    fn fetch(&self, artifact: &greentic_update::plan::PlanArtifact) -> Result<Vec<u8>, OpError>;
1580}
1581
1582/// Retry/backoff for a transient artifact fetch: `attempts` total tries with
1583/// exponential backoff from `base_delay`. Tests use a zero delay.
1584#[derive(Clone, Copy)]
1585struct RetryPolicy {
1586    attempts: u32,
1587    base_delay: std::time::Duration,
1588}
1589
1590impl Default for RetryPolicy {
1591    fn default() -> Self {
1592        Self {
1593            attempts: 3,
1594            base_delay: std::time::Duration::from_millis(500),
1595        }
1596    }
1597}
1598
1599/// The production [`ArtifactFetcher`]: resolves and fetches through the
1600/// content-addressed `DistClient` (handles `oci://`, `https://`, `file://`),
1601/// returning the cached bytes. It does not need to trust the transport —
1602/// artifacts are integrity-anchored by the signed plan's digests (`put_artifact`
1603/// re-verifies), not by mTLS. The plan document itself is the mTLS/DSSE-verified
1604/// artifact; its listed content is digest-verified regardless of how it arrives.
1605struct DistArtifactFetcher {
1606    client: DistClient,
1607}
1608
1609impl DistArtifactFetcher {
1610    fn new() -> Self {
1611        Self {
1612            client: DistClient::new(DistOptions::default()),
1613        }
1614    }
1615}
1616
1617impl ArtifactFetcher for DistArtifactFetcher {
1618    fn fetch(&self, artifact: &greentic_update::plan::PlanArtifact) -> Result<Vec<u8>, OpError> {
1619        let source = artifact.source.as_deref().ok_or_else(|| {
1620            OpError::InvalidArgument(format!(
1621                "artifact `{}` declares no source to download (in-band airgap \
1622                 artifacts are not supported by `op updates get`)",
1623                artifact.name
1624            ))
1625        })?;
1626        // Confine sources to remote registry schemes — an explicit `https://` or
1627        // `oci://`. Reject `file://`, bare local paths, and DistClient's other
1628        // schemes: even a signed plan must not make the operator read local
1629        // files or resolve ambiguous bare refs. Digest verification only happens
1630        // AFTER a fetch, so the scheme is the pre-fetch trust boundary.
1631        if !(source.starts_with("https://") || source.starts_with("oci://")) {
1632            return Err(OpError::InvalidArgument(format!(
1633                "artifact `{}` source `{source}` is not an allowed remote scheme \
1634                 (expected `https://` or `oci://`)",
1635                artifact.name
1636            )));
1637        }
1638        rt::sync_await(async {
1639            let parsed = self
1640                .client
1641                .parse_source(source)
1642                .map_err(|e| OpError::Fetch(format!("parse artifact source `{source}`: {e}")))?;
1643            let descriptor = self
1644                .client
1645                .resolve(parsed, ResolvePolicy)
1646                .await
1647                .map_err(|e| {
1648                    OpError::Fetch(format!("resolve artifact `{}`: {e}", artifact.name))
1649                })?;
1650            // Bound the download by the resolver's declared size *before* fetching
1651            // the body (best-effort — `size_bytes` may be 0 if unknown).
1652            reject_oversize(artifact, descriptor.size_bytes)?;
1653            let resolved = self
1654                .client
1655                .fetch(&descriptor, CachePolicy)
1656                .await
1657                .map_err(|e| OpError::Fetch(format!("fetch artifact `{}`: {e}", artifact.name)))?;
1658            // Authoritative cap on the actual bytes before loading them into
1659            // memory (and into `put_artifact`'s digest buffer).
1660            let len = std::fs::metadata(&resolved.local_path)
1661                .map_err(|e| {
1662                    OpError::Fetch(format!(
1663                        "stat fetched artifact `{}` at {}: {e}",
1664                        artifact.name,
1665                        resolved.local_path.display()
1666                    ))
1667                })?
1668                .len();
1669            reject_oversize(artifact, len)?;
1670            std::fs::read(&resolved.local_path).map_err(|e| {
1671                OpError::Fetch(format!(
1672                    "read fetched artifact `{}` at {}: {e}",
1673                    artifact.name,
1674                    resolved.local_path.display()
1675                ))
1676            })
1677        })
1678    }
1679}
1680
1681/// Hard ceiling on a single downloaded artifact — bounds the in-memory read and
1682/// the digest-check buffer (`put_artifact` takes the whole `&[u8]`). Update
1683/// artifacts (packs, wasm, binaries) are far smaller; this only trips on a
1684/// poisoned or oversized source, before the digest gate can reject it.
1685const MAX_ARTIFACT_BYTES: u64 = 512 * 1024 * 1024;
1686
1687fn reject_oversize(
1688    artifact: &greentic_update::plan::PlanArtifact,
1689    size: u64,
1690) -> Result<(), OpError> {
1691    if size > MAX_ARTIFACT_BYTES {
1692        return Err(OpError::Fetch(format!(
1693            "artifact `{}` is {size} bytes, over the {MAX_ARTIFACT_BYTES}-byte cap",
1694            artifact.name
1695        )));
1696    }
1697    Ok(())
1698}
1699
1700/// Fetch one artifact, retrying transient failures with exponential backoff.
1701/// Every fetch error is treated as retryable — the authoritative integrity gate
1702/// is `put_artifact`'s digest check, not the fetch outcome.
1703fn fetch_with_retry(
1704    fetcher: &dyn ArtifactFetcher,
1705    artifact: &greentic_update::plan::PlanArtifact,
1706    retry: RetryPolicy,
1707) -> Result<Vec<u8>, OpError> {
1708    let attempts = retry.attempts.max(1);
1709    let mut delay = retry.base_delay;
1710    let mut last_err = None;
1711    for attempt in 1..=attempts {
1712        match fetcher.fetch(artifact) {
1713            Ok(bytes) => return Ok(bytes),
1714            Err(e) => {
1715                last_err = Some(e);
1716                if attempt < attempts {
1717                    if !delay.is_zero() {
1718                        std::thread::sleep(delay);
1719                    }
1720                    delay = delay.saturating_mul(2);
1721                }
1722            }
1723        }
1724    }
1725    Err(last_err.expect("retry loop runs at least once"))
1726}
1727
1728/// Download every artifact a plan declares into its staging tree, then promote
1729/// `downloading → inbox → staged`. Idempotent/resumable: a plan already past
1730/// `downloading` (a completed prior run) is returned as-is without re-fetching;
1731/// while `downloading`, every artifact is (re-)fetched and handed to
1732/// `put_artifact`, which is content-addressed and fail-closed on a digest
1733/// mismatch — so re-fetching an already-present artifact is safe.
1734fn download_and_stage(
1735    staged: &greentic_update::staging::StagedPlan,
1736    artifacts: &[greentic_update::plan::PlanArtifact],
1737    fetcher: &dyn ArtifactFetcher,
1738    retry: RetryPolicy,
1739) -> Result<greentic_update::staging::UpdateStage, OpError> {
1740    use greentic_update::staging::UpdateStage;
1741    let stage = staged
1742        .stage()
1743        .map_err(|e| OpError::Conflict(format!("read update staging stage: {e}")))?;
1744    // Resume: only fetch while still `downloading`. A plan already promoted or
1745    // terminal has been handled — return its stage unchanged.
1746    if stage != UpdateStage::Downloading {
1747        return Ok(stage);
1748    }
1749    for artifact in artifacts {
1750        let bytes = fetch_with_retry(fetcher, artifact, retry)?;
1751        staged
1752            .put_artifact(artifact, &bytes)
1753            .map_err(|e| OpError::Conflict(format!("stage artifact `{}`: {e}", artifact.name)))?;
1754    }
1755    // Every artifact is present and digest-verified → promote to `staged`.
1756    advance_to_staged(staged)
1757}
1758
1759/// Resolve the `(plan document, DSSE envelope)` byte pair from the payload's
1760/// source. Exactly one of `plan_url` or (`plan_file` + `plan_sig_file`) must be
1761/// set.
1762fn load_plan_source(
1763    store: &LocalFsStore,
1764    env: &Environment,
1765    env_id: &EnvId,
1766    payload: &UpdatesGetPayload,
1767) -> Result<(Vec<u8>, Vec<u8>), OpError> {
1768    match (
1769        &payload.plan_url,
1770        &payload.plan_file,
1771        &payload.plan_sig_file,
1772    ) {
1773        (Some(url), None, None) => {
1774            // The plan is fetched over the enrolled mTLS identity, which is only
1775            // presented over TLS — reject plaintext `http://` (except loopback,
1776            // for a local dev server) so a remote endpoint can't be reached
1777            // without the client cert.
1778            if !control_url_is_acceptable(url) {
1779                return Err(OpError::InvalidArgument(
1780                    "plan_url must be an https:// URL; plaintext http:// is accepted only for a \
1781                     loopback dev server. The enrolled mTLS client identity is presented only over \
1782                     TLS, so a plaintext fetch would bypass it."
1783                        .to_string(),
1784                ));
1785            }
1786            fetch_plan_over_mtls(store, env, env_id, url)
1787        }
1788        (None, Some(plan), Some(sig)) => {
1789            let plan_bytes = std::fs::read(plan).map_err(|source| OpError::Io {
1790                path: plan.clone(),
1791                source,
1792            })?;
1793            let sig_bytes = std::fs::read(sig).map_err(|source| OpError::Io {
1794                path: sig.clone(),
1795                source,
1796            })?;
1797            Ok((plan_bytes, sig_bytes))
1798        }
1799        _ => Err(OpError::InvalidArgument(
1800            "exactly one plan source is required: `plan_url`, or `plan_file` with `plan_sig_file`"
1801                .to_string(),
1802        )),
1803    }
1804}
1805
1806/// Fetch the plan document + `.sig` sidecar over the enrolled mTLS channel,
1807/// using the persisted cert/key/CA (from `enroll`). GETs `<plan_url>` for the
1808/// document and `<plan_url>.sig` for the envelope — the crate's sidecar
1809/// convention (`plan.json` + `plan.json.sig`). Integration-covered: no plan
1810/// server exists until Phase 6, so the local `plan_file` pair is the unit-tested
1811/// source.
1812fn fetch_plan_over_mtls(
1813    store: &LocalFsStore,
1814    env: &Environment,
1815    env_id: &EnvId,
1816    plan_url: &str,
1817) -> Result<(Vec<u8>, Vec<u8>), OpError> {
1818    let secrets = require_secrets_pack(env, env_id)?;
1819    let kind_path = secrets.kind.path();
1820    let tenant = require_tenant(env, env_id)?;
1821
1822    let read_enrolled = |name: &str| -> Result<String, OpError> {
1823        let rel = tls_rel_path(&tenant, name);
1824        let (value, _uri, _extra) = get_env_secret(store, env, env_id, kind_path, &rel)?;
1825        value.ok_or_else(|| {
1826            OpError::NotFound(format!(
1827                "env `{env_id}` is not enrolled for updates (missing `{name}`); \
1828                 run `op updates enroll` first"
1829            ))
1830        })
1831    };
1832    let cert_pem = read_enrolled(CERT_NAME)?;
1833    let key_pem = read_enrolled(KEY_NAME)?;
1834    let ca_pem = read_enrolled(CA_NAME)?;
1835
1836    // Build the `.sig` sidecar URL by mutating the path (not appending to the
1837    // raw string), so a query/fragment on `plan_url` doesn't corrupt it.
1838    let sig_url = {
1839        let mut u = url::Url::parse(plan_url)
1840            .map_err(|e| OpError::InvalidArgument(format!("plan_url: {e}")))?;
1841        let sig_path = format!("{}.sig", u.path());
1842        u.set_path(&sig_path);
1843        u.to_string()
1844    };
1845    rt::sync_await(async {
1846        let client = greentic_update::tls::build_mtls_client(&greentic_update::tls::MtlsConfig {
1847            ca_pem,
1848            client_cert_pem: cert_pem,
1849            client_key_pem: key_pem,
1850        })
1851        .map_err(|e| OpError::Conflict(format!("stored mTLS identity is unusable: {e}")))?;
1852        let plan_bytes = mtls_get(&client, plan_url).await?;
1853        let sig_bytes = mtls_get(&client, &sig_url).await?;
1854        Ok::<(Vec<u8>, Vec<u8>), OpError>((plan_bytes, sig_bytes))
1855    })
1856}
1857
1858/// GET `url` over the mTLS client, returning the body bytes. Non-2xx and
1859/// transport errors both map to [`OpError::Fetch`].
1860async fn mtls_get(client: &reqwest::Client, url: &str) -> Result<Vec<u8>, OpError> {
1861    let resp = client
1862        .get(url)
1863        .send()
1864        .await
1865        .and_then(reqwest::Response::error_for_status)
1866        .map_err(|e| OpError::Fetch(format!("GET {url}: {e}")))?;
1867    let bytes = resp
1868        .bytes()
1869        .await
1870        .map_err(|e| OpError::Fetch(format!("GET {url}: reading body: {e}")))?;
1871    Ok(bytes.to_vec())
1872}
1873
1874// ---------------------------------------------------------------------------
1875// plan-build: build + DSSE-sign an UpdatePlan carrying binary artifacts
1876// ---------------------------------------------------------------------------
1877
1878/// Parse a `--binary` spec string (comma-separated key=value) into a
1879/// [`greentic_update::plan::BinaryArtifact`]. Required keys: `name`, `version`,
1880/// `target`, `digest`. Optional: `source`.
1881/// A `--binary` spec's required key must be present and non-empty.
1882fn require_non_empty(value: Option<String>, key: &str) -> Result<String, OpError> {
1883    let value = value.ok_or_else(|| {
1884        OpError::InvalidArgument(format!("--binary: missing required key `{key}`"))
1885    })?;
1886    if value.is_empty() {
1887        return Err(OpError::InvalidArgument(format!(
1888            "--binary: `{key}` must not be empty"
1889        )));
1890    }
1891    Ok(value)
1892}
1893
1894fn parse_binary_spec(spec: &str) -> Result<greentic_update::plan::BinaryArtifact, OpError> {
1895    let mut name: Option<String> = None;
1896    let mut version: Option<String> = None;
1897    let mut target: Option<String> = None;
1898    let mut digest: Option<String> = None;
1899    let mut source: Option<String> = None;
1900
1901    for part in spec.split(',') {
1902        let part = part.trim();
1903        if part.is_empty() {
1904            continue;
1905        }
1906        let (key, value) = part.split_once('=').ok_or_else(|| {
1907            OpError::InvalidArgument(format!("--binary: expected key=value pair, got `{part}`"))
1908        })?;
1909        match key {
1910            "name" => {
1911                if name.is_some() {
1912                    return Err(OpError::InvalidArgument(
1913                        "--binary: duplicate key `name`".to_string(),
1914                    ));
1915                }
1916                name = Some(value.to_string());
1917            }
1918            "version" => {
1919                if version.is_some() {
1920                    return Err(OpError::InvalidArgument(
1921                        "--binary: duplicate key `version`".to_string(),
1922                    ));
1923                }
1924                version = Some(value.to_string());
1925            }
1926            "target" => {
1927                if target.is_some() {
1928                    return Err(OpError::InvalidArgument(
1929                        "--binary: duplicate key `target`".to_string(),
1930                    ));
1931                }
1932                target = Some(value.to_string());
1933            }
1934            "digest" => {
1935                if digest.is_some() {
1936                    return Err(OpError::InvalidArgument(
1937                        "--binary: duplicate key `digest`".to_string(),
1938                    ));
1939                }
1940                digest = Some(value.to_string());
1941            }
1942            "source" => {
1943                if source.is_some() {
1944                    return Err(OpError::InvalidArgument(
1945                        "--binary: duplicate key `source`".to_string(),
1946                    ));
1947                }
1948                source = Some(value.to_string());
1949            }
1950            unknown => {
1951                return Err(OpError::InvalidArgument(format!(
1952                    "--binary: unknown key `{unknown}` (expected name, version, target, digest, source)"
1953                )));
1954            }
1955        }
1956    }
1957
1958    let name = require_non_empty(name, "name")?;
1959    let version = require_non_empty(version, "version")?;
1960    let target = require_non_empty(target, "target")?;
1961    let digest = require_non_empty(digest, "digest")?;
1962
1963    Ok(greentic_update::plan::BinaryArtifact {
1964        name,
1965        version,
1966        target,
1967        digest,
1968        source,
1969    })
1970}
1971
1972/// `op updates plan-build` — build and DSSE-sign an [`UpdatePlan`] carrying a
1973/// content target (`--target-file`), one or more binary artifacts (`--binary`),
1974/// or both, writing `plan.json` + `plan.json.sig` to the output directory. The
1975/// emitted pair round-trips through
1976/// [`greentic_update::plan::verify_update_plan`] against the env's trust root.
1977///
1978/// This is the producer side of the update path: `--target-file` drives the
1979/// content convergence `op updates apply` performs, `--binary` drives
1980/// `greentic-start`'s stage-only binary self-update. A plan with neither is a
1981/// signed no-op and is rejected.
1982pub fn plan_build(
1983    store: &LocalFsStore,
1984    flags: &OpFlags,
1985    args: crate::cli::dispatch::UpdatesPlanBuildArgs,
1986) -> Result<OpOutcome, OpError> {
1987    use chrono::Utc;
1988    use greentic_update::plan::{
1989        BinaryArtifact, CompatRequirements, OnFail, RollbackKind, RollbackPolicy,
1990        UPDATE_PLAN_SCHEMA_V1, UpdatePlan,
1991    };
1992
1993    if flags.schema_only {
1994        return Ok(OpOutcome::new(NOUN, "plan-build", plan_build_schema()));
1995    }
1996
1997    let env_id_raw = args.env_id.ok_or_else(|| {
1998        OpError::InvalidArgument("env_id is required (positional argument)".to_string())
1999    })?;
2000    let env_id = parse_env_id(&env_id_raw)?;
2001
2002    let sequence = args
2003        .sequence
2004        .ok_or_else(|| OpError::InvalidArgument("--sequence is required".to_string()))?;
2005
2006    // Parse all --binary specs up front, before touching disk.
2007    let binaries: Vec<BinaryArtifact> = args
2008        .binaries
2009        .iter()
2010        .map(|s| parse_binary_spec(s))
2011        .collect::<Result<Vec<_>, _>>()?;
2012
2013    // A plan with neither a content target nor a binary artifact converges
2014    // nothing: `op updates apply` is upsert-only, so the default minimal target
2015    // is a no-op, and there is no binary for the runtime to swap. Refuse to sign
2016    // it rather than mint a plan that reports `applied` without changing anything.
2017    if binaries.is_empty() && args.target_file.is_none() {
2018        return Err(OpError::InvalidArgument(
2019            "at least one --binary or a --target-file is required".to_string(),
2020        ));
2021    }
2022
2023    // Resolve the signing key: explicit --signing-key or the global operator key.
2024    let (priv_pem, key_id) = match &args.signing_key {
2025        Some(key_path) => crate::operator_key::read_signing_key_at(key_path)?,
2026        None => {
2027            let op_key = crate::operator_key::load_existing_only().map_err(|e| {
2028                OpError::InvalidArgument(format!(
2029                    "no --signing-key provided and the global operator key is unavailable: {e}. \
2030                     Create or bootstrap the operator key first, or pass --signing-key <path>."
2031                ))
2032            })?;
2033            (op_key.private_pem, op_key.key_id)
2034        }
2035    };
2036
2037    // Load the env trust root so build_update_plan can verify the key is trusted.
2038    let env_dir = store.env_dir(&env_id)?;
2039    let trust = store_trust_root::load(&env_dir)?;
2040
2041    // Build the plan target from --target-file or a minimal valid env-manifest.
2042    let target: serde_json::Value = match &args.target_file {
2043        Some(path) => {
2044            let bytes = std::fs::read(path).map_err(|source| OpError::Io {
2045                path: path.clone(),
2046                source,
2047            })?;
2048            serde_json::from_slice(&bytes).map_err(|e| {
2049                OpError::InvalidArgument(format!(
2050                    "target file {} is not valid JSON: {e}",
2051                    path.display()
2052                ))
2053            })?
2054        }
2055        None => json!({
2056            "schema": super::env_manifest::ENV_MANIFEST_SCHEMA_V1,
2057            "environment": { "id": env_id.as_str() },
2058        }),
2059    };
2060
2061    // Build the compat requirements.
2062    let mut compat = CompatRequirements::default();
2063    if let Some(min_rt) = args.min_runtime {
2064        compat.min_runtime = Some(min_rt);
2065    }
2066
2067    let plan = UpdatePlan {
2068        schema: UPDATE_PLAN_SCHEMA_V1.to_string(),
2069        plan_id: ulid::Ulid::new().to_string(),
2070        env_id: env_id.to_string(),
2071        sequence,
2072        created_at: Utc::now(),
2073        nonce: ulid::Ulid::new().to_string(),
2074        target,
2075        artifacts: vec![],
2076        binaries,
2077        compat,
2078        rollback: RollbackPolicy {
2079            policy: RollbackKind::Auto,
2080            health_timeout_s: 120,
2081            on_fail: OnFail::Restore,
2082        },
2083    };
2084
2085    let built = greentic_update::plan::build_update_plan(&plan, &priv_pem, &key_id, &trust)
2086        .map_err(|e| {
2087            OpError::Conflict(format!(
2088                "build + sign update plan failed (is the signing key trusted by the env \
2089                 trust root?): {e}"
2090            ))
2091        })?;
2092
2093    // Write plan.json + plan.json.sig to the output directory.
2094    let out_dir = args.out_dir.unwrap_or_else(|| PathBuf::from("."));
2095    std::fs::create_dir_all(&out_dir).map_err(|source| OpError::Io {
2096        path: out_dir.clone(),
2097        source,
2098    })?;
2099    let plan_path = out_dir.join("plan.json");
2100    let sig_path = out_dir.join("plan.json.sig");
2101    std::fs::write(&plan_path, &built.plan_bytes).map_err(|source| OpError::Io {
2102        path: plan_path.clone(),
2103        source,
2104    })?;
2105    std::fs::write(&sig_path, &built.envelope_bytes).map_err(|source| OpError::Io {
2106        path: sig_path.clone(),
2107        source,
2108    })?;
2109
2110    Ok(OpOutcome::new(
2111        NOUN,
2112        "plan-build",
2113        json!({
2114            "environment_id": env_id.as_str(),
2115            "plan_id": plan.plan_id,
2116            "sequence": plan.sequence,
2117            "plan_sha256": built.plan_sha256,
2118            "key_id": built.key_id,
2119            "plan_path": plan_path.display().to_string(),
2120            "sig_path": sig_path.display().to_string(),
2121        }),
2122    ))
2123}
2124
2125fn plan_build_schema() -> Value {
2126    json!({
2127        "$schema": "https://json-schema.org/draft/2020-12/schema",
2128        "title": "UpdatesPlanBuildArgs",
2129        "type": "object",
2130        "required": ["env_id", "sequence"],
2131        "additionalProperties": false,
2132        "anyOf": [
2133            {"required": ["binaries"]},
2134            {"required": ["target_file"]}
2135        ],
2136        "properties": {
2137            "env_id": {"type": "string"},
2138            "sequence": {"type": "integer", "description": "Monotonic plan sequence (anti-rollback)."},
2139            "binaries": {"type": "array", "items": {"type": "string"}, "description": "Binary artifact specs (comma-separated key=value). Required unless target_file is set."},
2140            "signing_key": {"type": ["string", "null"], "description": "PKCS#8 Ed25519 private key PEM path. Default: global operator key."},
2141            "target_file": {"type": ["string", "null"], "description": "JSON file for the plan target (env-manifest.v1). Required unless binaries is set. Default: minimal manifest with schema + env id."},
2142            "min_runtime": {"type": ["string", "null"], "description": "Minimum runtime version (semver) for compat.min_runtime."},
2143            "out_dir": {"type": ["string", "null"], "description": "Output directory for plan.json + plan.json.sig. Default: current dir."}
2144        }
2145    })
2146}
2147
2148fn parse_env_id(raw: &str) -> Result<EnvId, OpError> {
2149    EnvId::try_from(raw).map_err(|e| OpError::InvalidArgument(format!("environment_id: {e}")))
2150}
2151
2152fn resolve_payload<T: serde::de::DeserializeOwned>(
2153    flags: &OpFlags,
2154    payload: Option<T>,
2155) -> Result<T, OpError> {
2156    if let Some(p) = payload {
2157        return Ok(p);
2158    }
2159    if let Some(path) = &flags.answers {
2160        return super::load_answers::<T>(path);
2161    }
2162    Err(OpError::InvalidArgument(
2163        "no payload provided: pass --answers <path> or supply the payload directly".to_string(),
2164    ))
2165}
2166
2167fn enroll_schema() -> Value {
2168    json!({
2169        "$schema": "https://json-schema.org/draft/2020-12/schema",
2170        "title": "UpdatesEnrollPayload",
2171        "type": "object",
2172        "required": ["environment_id", "ca_url"],
2173        "additionalProperties": false,
2174        "properties": {
2175            "environment_id": {"type": "string"},
2176            "ca_url": {"type": "string", "description": "Base URL of the Cert-CA (greentic-updates-server); `/v1/enroll` is appended."}
2177        }
2178    })
2179}
2180
2181fn status_schema() -> Value {
2182    json!({
2183        "$schema": "https://json-schema.org/draft/2020-12/schema",
2184        "title": "UpdatesStatusPayload",
2185        "type": "object",
2186        "required": ["environment_id"],
2187        "additionalProperties": false,
2188        "properties": {
2189            "environment_id": {"type": "string"}
2190        }
2191    })
2192}
2193
2194fn get_schema() -> Value {
2195    json!({
2196        "$schema": "https://json-schema.org/draft/2020-12/schema",
2197        "title": "UpdatesGetPayload",
2198        "type": "object",
2199        "required": ["environment_id"],
2200        "additionalProperties": false,
2201        "properties": {
2202            "environment_id": {"type": "string"},
2203            "plan_url": {"type": "string", "description": "Fetch the signed plan (+ `.sig` sidecar) from this URL over the enrolled mTLS channel."},
2204            "plan_file": {"type": "string", "description": "Local plan document (airgap import / testing); requires plan_sig_file."},
2205            "plan_sig_file": {"type": "string", "description": "DSSE envelope sidecar for plan_file."}
2206        }
2207    })
2208}
2209
2210fn apply_updates_schema() -> Value {
2211    json!({
2212        "$schema": "https://json-schema.org/draft/2020-12/schema",
2213        "title": "ApplyUpdatesPayload",
2214        "type": "object",
2215        "required": ["environment_id", "plan_id"],
2216        "additionalProperties": false,
2217        "properties": {
2218            "environment_id": {"type": "string"},
2219            "plan_id": {"type": "string", "description": "Plan id of the staged plan to apply (from `op updates get`)."}
2220        }
2221    })
2222}
2223
2224fn recover_schema() -> Value {
2225    json!({
2226        "$schema": "https://json-schema.org/draft/2020-12/schema",
2227        "title": "RecoverUpdatesPayload",
2228        "type": "object",
2229        "required": ["environment_id", "plan_id"],
2230        "additionalProperties": false,
2231        "properties": {
2232            "environment_id": {"type": "string"},
2233            "plan_id": {"type": "string", "description": "Plan id of the `applying` plan to force-fail (from `op updates get`). Pass `--force` on the CLI to attest the applier is dead — recover refuses without it."}
2234        }
2235    })
2236}
2237
2238fn config_set_schema() -> Value {
2239    json!({
2240        "$schema": "https://json-schema.org/draft/2020-12/schema",
2241        "title": "UpdateConfigSetPayload",
2242        "type": "object",
2243        "required": ["environment_id"],
2244        "additionalProperties": false,
2245        "properties": {
2246            "environment_id": {"type": "string"},
2247            "enabled": {"type": ["boolean", "null"], "description": "master switch for the update-channel notification machinery; null leaves the stored value unchanged (absent = disabled, deny-by-default)"},
2248            "on_notify": {"type": ["string", "null"], "enum": [null, "record-only", "record_only", "stage"], "description": "action on a verified notification; null leaves the stored value unchanged (unset resolves to `stage`; full self-update is not offered)"},
2249            "poll_interval_secs": {"type": ["integer", "null"], "minimum": MIN_POLL_INTERVAL_SECS, "description": "fallback poll interval in seconds; null leaves the stored value unchanged (unset resolves to 3600)"}
2250        }
2251    })
2252}
2253
2254fn config_show_schema() -> Value {
2255    json!({
2256        "$schema": "https://json-schema.org/draft/2020-12/schema",
2257        "title": "UpdateConfigShowFilter",
2258        "type": "object",
2259        "required": ["environment_id"],
2260        "additionalProperties": false,
2261        "properties": {
2262            "environment_id": {"type": "string"}
2263        }
2264    })
2265}
2266
2267// Test-only fault-injection hook: called immediately before the
2268// `applying -> applied` transition retry loop in `apply_updates_impl`.
2269// Tests install a closure here to sabotage the on-disk plan directory
2270// (e.g. chmod it read-only) so the transition write fails, exercising the
2271// Case-B honest-error branch.
2272#[cfg(test)]
2273thread_local! {
2274    static PRE_APPLIED_TRANSITION_HOOK: std::cell::RefCell<Option<Box<dyn Fn()>>> =
2275        const { std::cell::RefCell::new(None) };
2276}
2277
2278#[cfg(test)]
2279fn run_pre_applied_transition_hook() {
2280    PRE_APPLIED_TRANSITION_HOOK.with(|h| {
2281        if let Some(f) = h.borrow().as_ref() {
2282            f();
2283        }
2284    });
2285}
2286
2287#[cfg(test)]
2288mod tests {
2289    use super::*;
2290    use crate::cli::secrets::{DEV_STORE_KIND_PATH, get_env_secret, put_env_secret};
2291    use crate::cli::tests_common::{make_binding, make_env};
2292    use greentic_deploy_spec::CapabilitySlot;
2293    use tempfile::tempdir;
2294
2295    // --- update-channel config (Phase 4 notification policy) ----------------
2296
2297    fn store_with_env(dir: &std::path::Path, env_id: &str) -> (LocalFsStore, EnvId) {
2298        let store = LocalFsStore::new(dir);
2299        store.save(&make_env(env_id)).unwrap();
2300        (store, EnvId::try_from(env_id).unwrap())
2301    }
2302
2303    #[test]
2304    fn config_show_defaults_to_disabled() {
2305        let dir = tempdir().unwrap();
2306        let (store, env_id) = store_with_env(dir.path(), "local");
2307        let out = config_show(
2308            &store,
2309            &OpFlags::default(),
2310            Some(UpdateConfigShowFilter {
2311                environment_id: "local".into(),
2312            }),
2313        )
2314        .unwrap();
2315        let resolved = &out.result["resolved"];
2316        assert_eq!(resolved["enabled"].as_bool(), Some(false));
2317        assert_eq!(resolved["on_notify"].as_str(), Some("stage"));
2318        assert_eq!(resolved["poll_interval_secs"].as_u64(), Some(3600));
2319        // A show never writes the sidecar.
2320        assert!(store.load_update_channel(&env_id).unwrap().is_none());
2321    }
2322
2323    #[test]
2324    fn config_set_persists_and_round_trips() {
2325        let dir = tempdir().unwrap();
2326        let (store, env_id) = store_with_env(dir.path(), "local");
2327        config_set(
2328            &store,
2329            &OpFlags::default(),
2330            Some(UpdateConfigSetPayload {
2331                environment_id: "local".into(),
2332                enabled: Some(true),
2333                on_notify: Some("record-only".into()),
2334                poll_interval_secs: Some(120),
2335            }),
2336        )
2337        .unwrap();
2338        let cfg = store.load_update_channel(&env_id).unwrap().unwrap();
2339        assert_eq!(cfg.enabled, Some(true));
2340        assert_eq!(cfg.on_notify, Some(OnNotifyAction::RecordOnly));
2341        assert_eq!(cfg.poll_interval_secs, Some(120));
2342        assert!(cfg.resolved_enabled());
2343    }
2344
2345    #[test]
2346    fn config_set_partial_update_preserves_other_fields() {
2347        let dir = tempdir().unwrap();
2348        let (store, env_id) = store_with_env(dir.path(), "local");
2349        let set = |p: UpdateConfigSetPayload| {
2350            config_set(&store, &OpFlags::default(), Some(p)).unwrap();
2351        };
2352        set(UpdateConfigSetPayload {
2353            environment_id: "local".into(),
2354            enabled: Some(true),
2355            on_notify: None,
2356            poll_interval_secs: None,
2357        });
2358        set(UpdateConfigSetPayload {
2359            environment_id: "local".into(),
2360            enabled: None,
2361            on_notify: Some("record-only".into()),
2362            poll_interval_secs: None,
2363        });
2364        let cfg = store.load_update_channel(&env_id).unwrap().unwrap();
2365        assert_eq!(cfg.enabled, Some(true)); // preserved across the second set
2366        assert_eq!(cfg.on_notify, Some(OnNotifyAction::RecordOnly));
2367    }
2368
2369    #[test]
2370    fn config_set_rejects_invalid_on_notify() {
2371        let dir = tempdir().unwrap();
2372        let (store, env_id) = store_with_env(dir.path(), "local");
2373        let err = config_set(
2374            &store,
2375            &OpFlags::default(),
2376            Some(UpdateConfigSetPayload {
2377                environment_id: "local".into(),
2378                enabled: None,
2379                on_notify: Some("apply".into()),
2380                poll_interval_secs: None,
2381            }),
2382        )
2383        .unwrap_err();
2384        assert!(matches!(err, OpError::InvalidArgument(_)), "got {err:?}");
2385        // Fail-closed: nothing was written.
2386        assert!(store.load_update_channel(&env_id).unwrap().is_none());
2387    }
2388
2389    #[test]
2390    fn config_set_rejects_poll_interval_below_floor() {
2391        let dir = tempdir().unwrap();
2392        let (store, _) = store_with_env(dir.path(), "local");
2393        let err = config_set(
2394            &store,
2395            &OpFlags::default(),
2396            Some(UpdateConfigSetPayload {
2397                environment_id: "local".into(),
2398                enabled: None,
2399                on_notify: None,
2400                poll_interval_secs: Some(10),
2401            }),
2402        )
2403        .unwrap_err();
2404        assert!(matches!(err, OpError::InvalidArgument(_)), "got {err:?}");
2405    }
2406
2407    #[test]
2408    fn config_set_unknown_env_is_not_found() {
2409        let dir = tempdir().unwrap();
2410        let store = LocalFsStore::new(dir.path()); // no env saved
2411        let err = config_set(
2412            &store,
2413            &OpFlags::default(),
2414            Some(UpdateConfigSetPayload {
2415                environment_id: "ghost".into(),
2416                enabled: Some(true),
2417                on_notify: None,
2418                poll_interval_secs: None,
2419            }),
2420        )
2421        .unwrap_err();
2422        assert!(matches!(err, OpError::NotFound(_)), "got {err:?}");
2423    }
2424
2425    #[test]
2426    fn config_schema_only_returns_schemas() {
2427        let flags = OpFlags {
2428            schema_only: true,
2429            ..OpFlags::default()
2430        };
2431        let dir = tempdir().unwrap();
2432        let store = LocalFsStore::new(dir.path());
2433        let s = config_set(&store, &flags, None).unwrap();
2434        assert_eq!(s.op, "config-set");
2435        assert!(s.result["properties"]["enabled"].is_object());
2436        let sh = config_show(&store, &flags, None).unwrap();
2437        assert_eq!(sh.op, "config-show");
2438    }
2439
2440    #[test]
2441    fn config_set_concurrent_disjoint_updates_both_survive() {
2442        let dir = tempdir().unwrap();
2443        let (store, env_id) = store_with_env(dir.path(), "local");
2444        // Two operators set disjoint fields at the same time. The env flock held
2445        // across each read-modify-write (via `transact`) serializes them, so the
2446        // later writer observes the earlier writer's field and neither is lost.
2447        std::thread::scope(|s| {
2448            let a = store.clone();
2449            s.spawn(move || {
2450                config_set(
2451                    &a,
2452                    &OpFlags::default(),
2453                    Some(UpdateConfigSetPayload {
2454                        environment_id: "local".into(),
2455                        enabled: Some(true),
2456                        on_notify: None,
2457                        poll_interval_secs: None,
2458                    }),
2459                )
2460                .unwrap();
2461            });
2462            let b = store.clone();
2463            s.spawn(move || {
2464                config_set(
2465                    &b,
2466                    &OpFlags::default(),
2467                    Some(UpdateConfigSetPayload {
2468                        environment_id: "local".into(),
2469                        enabled: None,
2470                        on_notify: Some("record-only".into()),
2471                        poll_interval_secs: None,
2472                    }),
2473                )
2474                .unwrap();
2475            });
2476        });
2477        let cfg = store.load_update_channel(&env_id).unwrap().unwrap();
2478        assert_eq!(cfg.enabled, Some(true));
2479        assert_eq!(cfg.on_notify, Some(OnNotifyAction::RecordOnly));
2480    }
2481
2482    #[test]
2483    fn config_set_rejects_corrupt_environment() {
2484        // A directory whose `environment.json` is present (so `exists` admits it)
2485        // but does not deserialize must be rejected fail-closed under the lock —
2486        // no sidecar is written for an env the store itself would reject.
2487        let dir = tempdir().unwrap();
2488        let store = LocalFsStore::new(dir.path());
2489        let env_id = EnvId::try_from("local").unwrap();
2490        let env_dir = dir.path().join("local");
2491        std::fs::create_dir_all(&env_dir).unwrap();
2492        std::fs::write(
2493            env_dir.join("environment.json"),
2494            b"{ not-valid environment ]",
2495        )
2496        .unwrap();
2497        // The shallow presence check admits the corrupt directory...
2498        assert!(store.exists(&env_id).unwrap());
2499        // ...but the validated load inside the locked transaction rejects it,
2500        // so the call errors and no sidecar is written.
2501        config_set(
2502            &store,
2503            &OpFlags::default(),
2504            Some(UpdateConfigSetPayload {
2505                environment_id: "local".into(),
2506                enabled: Some(true),
2507                on_notify: None,
2508                poll_interval_secs: None,
2509            }),
2510        )
2511        .unwrap_err();
2512        assert!(
2513            !env_dir.join("update-channel.json").exists(),
2514            "sidecar must not be written for a corrupt env"
2515        );
2516    }
2517
2518    // A self-signed X.509 cert (public material only) used to exercise the
2519    // `status` parse path without a running CA.
2520    const TEST_CERT_PEM: &str = r"-----BEGIN CERTIFICATE-----
2521MIIDITCCAgmgAwIBAgIUYapGXgtZrRNo/AWjUTX7ECfZenIwDQYJKoZIhvcNAQEL
2522BQAwIDEeMBwGA1UEAwwVZ3JlZW50aWMtdXBkYXRlci10ZXN0MB4XDTI2MDcwMjA4
2523MjkzNVoXDTM2MDYyOTA4MjkzNVowIDEeMBwGA1UEAwwVZ3JlZW50aWMtdXBkYXRl
2524ci10ZXN0MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtIvlVwfBZr7V
2525GuUjcIgn4Uk+ONcdK2yraA3jhVulpYBepqhsN3bLE/XRPEOWeWdXcpfW/RQSx+sC
2526VFx2HWa0Ogh9pu75TnIxXlNPD/puEpWxJ9JcuLbujeAX1iGecKFUgfdKVFs3vAGG
2527MjN4ntvPt884TeoRlWoFdqY7xzHpWjnV4H/VLGGPo+7QaZKBLk7dCWfkGUTLFQSQ
2528p5utU4xLFdwB7dadhv6ZVp3aOAmfkYu3UuY7/YIYoYGZ6E2dg57UEv9sjbhdLBeO
2529wUpG7zisBhVcYwA9MwK65VzrCD32HCFX99XMf5Gd5VW03j2qHLyQuh4dQqKw2yCG
2530R2143vo4iQIDAQABo1MwUTAdBgNVHQ4EFgQUYIT+qBjsmFV4LvkTOd4NaXxNoGIw
2531HwYDVR0jBBgwFoAUYIT+qBjsmFV4LvkTOd4NaXxNoGIwDwYDVR0TAQH/BAUwAwEB
2532/zANBgkqhkiG9w0BAQsFAAOCAQEABHXHVVGIsmYL0LaQPvRafHqsjVCh8kiLh62b
2533qrCeqSAeXQ7YgQVmmLGV/ZzL+nbC3SoLtT0HrYcOLHsuDLbl534w6M8U7ysliZdf
2534tRtAPghtrI0zcQyXVaq1fPFB0zc/ALB8oq6I7oAwHBs+9n76nfcVRKifsrYqJm6E
25358XeewuLxi7lCULA/FfWteIE4kbx3HqzAG98eGbVebOApyMEAnf111PwjW0VTW4QB
2536L/P4PeKwohc0l4sRjlkvy+o9gnnvgjsTcMPGx1UXFXM/d8AoY1WC20cofmn0RlEd
2537uVbcKfZbU024RZ5zYGS0n3L4l6TVqpqQzrDfXjZNzyq0r/TK8g==
2538-----END CERTIFICATE-----
2539";
2540
2541    fn dev_store_env_with_tenant() -> greentic_deploy_spec::Environment {
2542        let mut env = make_env("local");
2543        env.packs.push(make_binding(
2544            CapabilitySlot::Secrets,
2545            "greentic.secrets.dev-store@1.0.0",
2546        ));
2547        env.host_config.tenant_org_id = Some("acme".to_string());
2548        env
2549    }
2550
2551    // ---- materialize_bundles (pure manifest rewrite) --------------------
2552
2553    #[test]
2554    fn materialize_uri_only_bundle_gets_local_path_and_keeps_uri() {
2555        // A URI-only single-revision bundle (no local path): materializing must
2556        // fill in `bundle_path` so apply reads local, while leaving
2557        // `bundle_source_uri` intact as the boot-time pull ref. This is what
2558        // lets a digest-matched apply run fully offline.
2559        let target = json!({
2560            "bundles": [{
2561                "bundle_id": "b1",
2562                "bundle_source_uri": "oci://registry/example:1",
2563                "bundle_digest": "sha256:aaa",
2564            }],
2565        });
2566        let mut staged = BTreeMap::new();
2567        staged.insert("sha256:aaa".to_string(), PathBuf::from("/staged/aaa/blob"));
2568
2569        let out = materialize_bundles(&target, &staged);
2570        assert_eq!(out["bundles"][0]["bundle_path"], json!("/staged/aaa/blob"));
2571        assert_eq!(
2572            out["bundles"][0]["bundle_source_uri"],
2573            json!("oci://registry/example:1"),
2574            "the boot-time pull ref must survive materialization"
2575        );
2576    }
2577
2578    #[test]
2579    fn materialize_single_revision_with_path_and_uri_overwrites_path_keeps_uri() {
2580        // A valid single-revision shape can carry BOTH a local `bundle_path` and
2581        // a `bundle_source_uri` (the boot-time pull ref). Materializing must
2582        // overwrite the path with the staged blob yet leave the URI intact.
2583        let target = json!({
2584            "bundles": [{
2585                "bundle_id": "b1",
2586                "bundle_path": "orig.gtbundle",
2587                "bundle_source_uri": "oci://registry/example:1",
2588                "bundle_digest": "sha256:aaa",
2589            }],
2590        });
2591        let mut staged = BTreeMap::new();
2592        staged.insert("sha256:aaa".to_string(), PathBuf::from("/staged/aaa/blob"));
2593
2594        let out = materialize_bundles(&target, &staged);
2595        assert_eq!(out["bundles"][0]["bundle_path"], json!("/staged/aaa/blob"));
2596        assert_eq!(
2597            out["bundles"][0]["bundle_source_uri"],
2598            json!("oci://registry/example:1"),
2599            "the boot-time pull ref must survive materialization"
2600        );
2601    }
2602
2603    #[test]
2604    fn materialize_leaves_unmatched_digest_untouched() {
2605        // A bundle whose digest is not in the staged set must keep its declared
2606        // source verbatim — apply falls back to the remote pull.
2607        let target = json!({
2608            "bundles": [{
2609                "bundle_id": "b1",
2610                "bundle_path": "orig.gtbundle",
2611                "bundle_digest": "sha256:zzz",
2612            }],
2613        });
2614        let mut staged = BTreeMap::new();
2615        staged.insert("sha256:aaa".to_string(), PathBuf::from("/staged/aaa/blob"));
2616
2617        let out = materialize_bundles(&target, &staged);
2618        assert_eq!(out["bundles"][0]["bundle_path"], json!("orig.gtbundle"));
2619    }
2620
2621    #[test]
2622    fn materialize_rewrites_each_revision_and_leaves_bundle_level_alone() {
2623        let target = json!({
2624            "bundles": [{
2625                "bundle_id": "b1",
2626                "revisions": [
2627                    { "name": "blue",  "bundle_path": "blue.gtbundle",  "bundle_digest": "sha256:aaa" },
2628                    { "name": "green", "bundle_path": "green.gtbundle", "bundle_digest": "sha256:bbb",
2629                      "bundle_source_uri": "oci://registry/green:1" },
2630                ],
2631            }],
2632        });
2633        let mut staged = BTreeMap::new();
2634        staged.insert("sha256:aaa".to_string(), PathBuf::from("/staged/aaa/blob"));
2635        staged.insert("sha256:bbb".to_string(), PathBuf::from("/staged/bbb/blob"));
2636
2637        let out = materialize_bundles(&target, &staged);
2638        let revs = &out["bundles"][0]["revisions"];
2639        assert_eq!(revs[0]["bundle_path"], json!("/staged/aaa/blob"));
2640        assert_eq!(revs[1]["bundle_path"], json!("/staged/bbb/blob"));
2641        assert_eq!(
2642            revs[1]["bundle_source_uri"],
2643            json!("oci://registry/green:1")
2644        );
2645        // A multi-revision bundle carries no bundle-level path; nothing is added.
2646        assert!(out["bundles"][0].get("bundle_path").is_none());
2647    }
2648
2649    #[test]
2650    fn materialize_target_without_bundles_is_a_noop() {
2651        let target = json!({ "environment": { "id": "local" } });
2652        let out = materialize_bundles(&target, &BTreeMap::new());
2653        assert_eq!(out, target);
2654    }
2655
2656    #[test]
2657    fn enroll_schema_only_returns_payload_schema() {
2658        let dir = tempdir().unwrap();
2659        let store = LocalFsStore::new(dir.path());
2660        let out = enroll(
2661            &store,
2662            &OpFlags {
2663                schema_only: true,
2664                ..OpFlags::default()
2665            },
2666            None,
2667        )
2668        .unwrap();
2669        assert_eq!(out.op, "enroll");
2670        assert_eq!(out.noun, NOUN);
2671        assert!(out.result["properties"]["ca_url"].is_object());
2672    }
2673
2674    #[test]
2675    fn status_schema_only_returns_payload_schema() {
2676        let dir = tempdir().unwrap();
2677        let store = LocalFsStore::new(dir.path());
2678        let out = status(
2679            &store,
2680            &OpFlags {
2681                schema_only: true,
2682                ..OpFlags::default()
2683            },
2684            None,
2685        )
2686        .unwrap();
2687        assert_eq!(out.op, "status");
2688        assert!(out.result["properties"]["environment_id"].is_object());
2689    }
2690
2691    #[test]
2692    fn enroll_rejects_empty_ca_url_before_network() {
2693        let dir = tempdir().unwrap();
2694        let store = LocalFsStore::new(dir.path());
2695        store.save(&dev_store_env_with_tenant()).unwrap();
2696        let err = enroll(
2697            &store,
2698            &OpFlags::default(),
2699            Some(UpdatesEnrollPayload {
2700                environment_id: "local".into(),
2701                ca_url: "   ".into(),
2702            }),
2703        )
2704        .unwrap_err();
2705        assert!(matches!(err, OpError::InvalidArgument(_)));
2706    }
2707
2708    #[test]
2709    fn enroll_rejects_non_http_ca_url() {
2710        let dir = tempdir().unwrap();
2711        let store = LocalFsStore::new(dir.path());
2712        store.save(&dev_store_env_with_tenant()).unwrap();
2713        let err = enroll(
2714            &store,
2715            &OpFlags::default(),
2716            Some(UpdatesEnrollPayload {
2717                environment_id: "local".into(),
2718                ca_url: "ftp://ca.example".into(),
2719            }),
2720        )
2721        .unwrap_err();
2722        assert!(matches!(err, OpError::InvalidArgument(_)));
2723    }
2724
2725    #[test]
2726    fn control_url_is_acceptable_requires_https_or_loopback_http() {
2727        // HTTPS is always acceptable.
2728        assert!(control_url_is_acceptable("https://ca.example"));
2729        assert!(control_url_is_acceptable(
2730            "https://ca.example:8443/v1/enroll"
2731        ));
2732        // Plaintext HTTP only to a genuine loopback host.
2733        assert!(control_url_is_acceptable("http://localhost"));
2734        assert!(control_url_is_acceptable("http://localhost:8080/enroll"));
2735        assert!(control_url_is_acceptable("http://127.0.0.1:9000"));
2736        assert!(control_url_is_acceptable("http://127.5.5.5"));
2737        assert!(control_url_is_acceptable("http://[::1]:8080"));
2738        // Plaintext HTTP to a remote host is refused (trust-anchor MITM risk).
2739        assert!(!control_url_is_acceptable("http://ca.example"));
2740        assert!(!control_url_is_acceptable("http://ca.example:8080/enroll"));
2741        // A hostname that merely starts with "127." is NOT loopback.
2742        assert!(!control_url_is_acceptable("http://127.0.0.1.evil.com"));
2743        // Other schemes and empties are refused.
2744        assert!(!control_url_is_acceptable("ftp://ca.example"));
2745        assert!(!control_url_is_acceptable("ca.example"));
2746        assert!(!control_url_is_acceptable("https://"));
2747        assert!(!control_url_is_acceptable(""));
2748    }
2749
2750    #[test]
2751    fn enroll_rejects_plaintext_remote_ca_url() {
2752        let dir = tempdir().unwrap();
2753        let store = LocalFsStore::new(dir.path());
2754        store.save(&dev_store_env_with_tenant()).unwrap();
2755        let err = enroll(
2756            &store,
2757            &OpFlags::default(),
2758            Some(UpdatesEnrollPayload {
2759                environment_id: "local".into(),
2760                ca_url: "http://ca.example/enroll".into(),
2761            }),
2762        )
2763        .unwrap_err();
2764        assert!(matches!(err, OpError::InvalidArgument(_)));
2765    }
2766
2767    #[test]
2768    fn enroll_requires_tenant_owner() {
2769        // Env with a secrets pack but no tenant owner: enrollment must fail
2770        // closed (the cert identity is the owning tenant) before any network.
2771        let dir = tempdir().unwrap();
2772        let store = LocalFsStore::new(dir.path());
2773        let mut env = make_env("local");
2774        env.packs.push(make_binding(
2775            CapabilitySlot::Secrets,
2776            "greentic.secrets.dev-store@1.0.0",
2777        ));
2778        store.save(&env).unwrap();
2779        let err = enroll(
2780            &store,
2781            &OpFlags::default(),
2782            Some(UpdatesEnrollPayload {
2783                environment_id: "local".into(),
2784                ca_url: "https://ca.example".into(),
2785            }),
2786        )
2787        .unwrap_err();
2788        assert!(matches!(err, OpError::InvalidArgument(_)));
2789    }
2790
2791    #[test]
2792    fn status_reports_not_enrolled_when_no_cert_stored() {
2793        let dir = tempdir().unwrap();
2794        let store = LocalFsStore::new(dir.path());
2795        store.save(&dev_store_env_with_tenant()).unwrap();
2796        let out = status(
2797            &store,
2798            &OpFlags::default(),
2799            Some(UpdatesStatusPayload {
2800                environment_id: "local".into(),
2801            }),
2802        )
2803        .unwrap();
2804        assert_eq!(out.result["enrolled"], false);
2805    }
2806
2807    #[test]
2808    fn status_reports_serial_and_validity_for_stored_cert() {
2809        let dir = tempdir().unwrap();
2810        let store = LocalFsStore::new(dir.path());
2811        let env = dev_store_env_with_tenant();
2812        store.save(&env).unwrap();
2813        let env_id = EnvId::try_from("local").unwrap();
2814        // Seed the cert exactly where `enroll` would persist it.
2815        put_env_secret(
2816            &store,
2817            &env,
2818            &env_id,
2819            DEV_STORE_KIND_PATH,
2820            "acme/_/tls/updater_cert",
2821            TEST_CERT_PEM,
2822        )
2823        .unwrap();
2824        let out = status(
2825            &store,
2826            &OpFlags::default(),
2827            Some(UpdatesStatusPayload {
2828                environment_id: "local".into(),
2829            }),
2830        )
2831        .unwrap();
2832        assert_eq!(out.result["enrolled"], true);
2833        // The reported fields come straight from parse_cert_info of the PEM.
2834        let info = greentic_update::tls::parse_cert_info(TEST_CERT_PEM).unwrap();
2835        assert_eq!(out.result["serial"].as_str().unwrap(), info.serial_hex);
2836        assert_eq!(
2837            out.result["not_after_epoch"].as_i64().unwrap(),
2838            info.not_after_epoch
2839        );
2840    }
2841
2842    #[test]
2843    fn status_requires_tenant_owner() {
2844        let dir = tempdir().unwrap();
2845        let store = LocalFsStore::new(dir.path());
2846        let mut env = make_env("local");
2847        env.packs.push(make_binding(
2848            CapabilitySlot::Secrets,
2849            "greentic.secrets.dev-store@1.0.0",
2850        ));
2851        store.save(&env).unwrap();
2852        let err = status(
2853            &store,
2854            &OpFlags::default(),
2855            Some(UpdatesStatusPayload {
2856                environment_id: "local".into(),
2857            }),
2858        )
2859        .unwrap_err();
2860        assert!(matches!(err, OpError::InvalidArgument(_)));
2861    }
2862
2863    #[test]
2864    fn persist_enrollment_writes_all_four_secrets_then_status_reads_them() {
2865        // Exercises the durable side-effect of `enroll` without a CA: build a
2866        // synthetic Enrollment, persist it, read all four secrets back through
2867        // the same dispatch a reader uses, and confirm `status` finds the cert.
2868        let dir = tempdir().unwrap();
2869        let store = LocalFsStore::new(dir.path());
2870        let env = dev_store_env_with_tenant();
2871        store.save(&env).unwrap();
2872        let env_id = EnvId::try_from("local").unwrap();
2873
2874        let enrollment = greentic_update::enroll::Enrollment {
2875            client_key_pem: "-----BEGIN PRIVATE KEY-----\nKEYMATERIAL\n-----END PRIVATE KEY-----\n"
2876                .to_string(),
2877            client_cert_pem: TEST_CERT_PEM.to_string(),
2878            ca_pem: "-----BEGIN CERTIFICATE-----\nCAMATERIAL\n-----END CERTIFICATE-----\n"
2879                .to_string(),
2880            serial: "61aa465e0b59ad1368fc05a35135fb1027d97a72".to_string(),
2881            not_after: "2036-06-29T08:29:35Z".to_string(),
2882        };
2883        let ca_url = "https://ca.example";
2884
2885        let stored = persist_enrollment(
2886            &store,
2887            &env,
2888            &env_id,
2889            DEV_STORE_KIND_PATH,
2890            "acme",
2891            ca_url,
2892            &enrollment,
2893        )
2894        .unwrap();
2895
2896        // All four artifacts written; the certificate is written LAST (commit marker).
2897        let names: Vec<&str> = stored.iter().map(|e| e["name"].as_str().unwrap()).collect();
2898        assert_eq!(names, vec![KEY_NAME, CA_NAME, CA_URL_NAME, CERT_NAME]);
2899        assert_eq!(
2900            stored[3]["store_uri"].as_str().unwrap(),
2901            "secrets://local/acme/_/tls/updater_cert"
2902        );
2903
2904        // Read each back through get_env_secret (the reader's dispatch).
2905        let read = |name: &str| {
2906            get_env_secret(
2907                &store,
2908                &env,
2909                &env_id,
2910                DEV_STORE_KIND_PATH,
2911                &tls_rel_path("acme", name),
2912            )
2913            .unwrap()
2914            .0
2915        };
2916        assert_eq!(
2917            read(KEY_NAME).as_deref(),
2918            Some(enrollment.client_key_pem.as_str())
2919        );
2920        assert_eq!(read(CERT_NAME).as_deref(), Some(TEST_CERT_PEM));
2921        assert_eq!(read(CA_NAME).as_deref(), Some(enrollment.ca_pem.as_str()));
2922        assert_eq!(read(CA_URL_NAME).as_deref(), Some(ca_url));
2923
2924        // Full producer -> consumer round-trip: `status` finds the persisted cert.
2925        let out = status(
2926            &store,
2927            &OpFlags::default(),
2928            Some(UpdatesStatusPayload {
2929                environment_id: "local".into(),
2930            }),
2931        )
2932        .unwrap();
2933        assert_eq!(out.result["enrolled"], true);
2934        let info = greentic_update::tls::parse_cert_info(TEST_CERT_PEM).unwrap();
2935        assert_eq!(out.result["serial"].as_str().unwrap(), info.serial_hex);
2936    }
2937
2938    // ---- `get` ----
2939
2940    use greentic_distributor_client::signing::{TrustRoot, TrustedKey};
2941
2942    /// Deterministic Ed25519 key: PKCS#8 private PEM + the matching `TrustedKey`.
2943    fn key_pair(seed: u8) -> (String, TrustedKey) {
2944        use ed25519_dalek::SigningKey;
2945        use ed25519_dalek::pkcs8::spki::der::pem::LineEnding;
2946        use ed25519_dalek::pkcs8::{EncodePrivateKey, EncodePublicKey};
2947        use greentic_distributor_client::signing::key_id_for_public_key_pem;
2948
2949        let sk = SigningKey::from_bytes(&[seed; 32]);
2950        let priv_pem = sk.to_pkcs8_pem(LineEnding::LF).unwrap().to_string();
2951        let pub_pem = sk
2952            .verifying_key()
2953            .to_public_key_pem(LineEnding::LF)
2954            .unwrap();
2955        let key_id = key_id_for_public_key_pem(&pub_pem).unwrap();
2956        (
2957            priv_pem,
2958            TrustedKey {
2959                key_id,
2960                public_key_pem: pub_pem,
2961            },
2962        )
2963    }
2964
2965    /// Build + sign an update plan, returning `(plan_bytes, envelope_bytes)`.
2966    /// `build_trust` must contain the signing key (build self-verifies).
2967    #[allow(clippy::too_many_arguments)]
2968    fn signed_plan(
2969        env_id: &str,
2970        plan_id: &str,
2971        sequence: u64,
2972        artifacts: Value,
2973        compat: Value,
2974        priv_pem: &str,
2975        key_id: &str,
2976        build_trust: &TrustRoot,
2977    ) -> (Vec<u8>, Vec<u8>) {
2978        let plan: greentic_update::plan::UpdatePlan = serde_json::from_value(json!({
2979            "schema": "greentic.update-plan.v1",
2980            "plan_id": plan_id,
2981            "env_id": env_id,
2982            "sequence": sequence,
2983            "created_at": "2026-07-02T00:00:00Z",
2984            "nonce": format!("nonce-{plan_id}"),
2985            "target": {"schema": "greentic.env-manifest.v1", "environment": {"id": env_id}},
2986            "artifacts": artifacts,
2987            "compat": compat,
2988            "rollback": {"policy": "auto", "health_timeout_s": 120, "on_fail": "restore"},
2989        }))
2990        .unwrap();
2991        let built =
2992            greentic_update::plan::build_update_plan(&plan, priv_pem, key_id, build_trust).unwrap();
2993        (built.plan_bytes, built.envelope_bytes)
2994    }
2995
2996    /// Save a fresh `local` env and seed its trust root with `tk`.
2997    fn env_trusting(store: &LocalFsStore, tk: &TrustedKey) -> EnvId {
2998        env_trusting_secrets(store, tk, None)
2999    }
3000
3001    /// Like [`env_trusting`] but optionally binds the env's `Secrets` slot to
3002    /// `kind` (e.g. `VAULT_SECRETS_PACK`), so the apply-time dev-store guard
3003    /// sees a non-dev-store backend. `None` leaves the slot unbound (custodial
3004    /// dev-store).
3005    fn env_trusting_secrets(store: &LocalFsStore, tk: &TrustedKey, kind: Option<&str>) -> EnvId {
3006        let mut env = make_env("local");
3007        if let Some(k) = kind {
3008            env.packs.push(make_binding(CapabilitySlot::Secrets, k));
3009        }
3010        store.save(&env).unwrap();
3011        let env_id = EnvId::try_from("local").unwrap();
3012        let env_dir = store.env_dir(&env_id).unwrap();
3013        store_trust_root::add_trusted_key(&env_dir, tk.clone()).unwrap();
3014        env_id
3015    }
3016
3017    #[test]
3018    fn get_schema_only_returns_payload_schema() {
3019        let dir = tempdir().unwrap();
3020        let store = LocalFsStore::new(dir.path());
3021        let out = get(
3022            &store,
3023            &OpFlags {
3024                schema_only: true,
3025                ..OpFlags::default()
3026            },
3027            None,
3028        )
3029        .unwrap();
3030        assert_eq!(out.op, "get");
3031        assert_eq!(out.noun, NOUN);
3032        assert!(out.result["properties"]["plan_url"].is_object());
3033    }
3034
3035    #[test]
3036    fn get_rejects_missing_plan_source() {
3037        let dir = tempdir().unwrap();
3038        let store = LocalFsStore::new(dir.path());
3039        store.save(&make_env("local")).unwrap();
3040        let err = get(
3041            &store,
3042            &OpFlags::default(),
3043            Some(UpdatesGetPayload {
3044                environment_id: "local".into(),
3045                plan_url: None,
3046                plan_file: None,
3047                plan_sig_file: None,
3048            }),
3049        )
3050        .unwrap_err();
3051        assert!(matches!(err, OpError::InvalidArgument(_)));
3052    }
3053
3054    #[test]
3055    fn get_rejects_plan_signed_by_untrusted_key() {
3056        // Env trusts key 7; the plan is signed by key 9 (trusted only at build).
3057        let dir = tempdir().unwrap();
3058        let store = LocalFsStore::new(dir.path());
3059        let (_priv7, tk7) = key_pair(7);
3060        let env_id = env_trusting(&store, &tk7);
3061
3062        let (priv9, tk9) = key_pair(9);
3063        let build_trust = TrustRoot::new(vec![tk9.clone()]);
3064        let (plan_b, sig_b) = signed_plan(
3065            "local",
3066            "plan-x",
3067            1,
3068            json!([]),
3069            json!({}),
3070            &priv9,
3071            &tk9.key_id,
3072            &build_trust,
3073        );
3074        let plan_file = dir.path().join("plan.json");
3075        let sig_file = dir.path().join("plan.json.sig");
3076        std::fs::write(&plan_file, &plan_b).unwrap();
3077        std::fs::write(&sig_file, &sig_b).unwrap();
3078
3079        let err = get(
3080            &store,
3081            &OpFlags::default(),
3082            Some(UpdatesGetPayload {
3083                environment_id: env_id.to_string(),
3084                plan_url: None,
3085                plan_file: Some(plan_file),
3086                plan_sig_file: Some(sig_file),
3087            }),
3088        )
3089        .unwrap_err();
3090        // Closed-by-default: the env trust root does not hold the signer.
3091        assert!(matches!(err, OpError::Conflict(_)));
3092    }
3093
3094    #[test]
3095    fn get_rejects_plan_targeting_another_env() {
3096        let dir = tempdir().unwrap();
3097        let store = LocalFsStore::new(dir.path());
3098        let (priv7, tk7) = key_pair(7);
3099        let env_id = env_trusting(&store, &tk7);
3100
3101        let build_trust = TrustRoot::new(vec![tk7.clone()]);
3102        // Signed by the trusted key, but the plan targets env `other`.
3103        let (plan_b, sig_b) = signed_plan(
3104            "other",
3105            "plan-x",
3106            1,
3107            json!([]),
3108            json!({}),
3109            &priv7,
3110            &tk7.key_id,
3111            &build_trust,
3112        );
3113        let plan_file = dir.path().join("plan.json");
3114        let sig_file = dir.path().join("plan.json.sig");
3115        std::fs::write(&plan_file, &plan_b).unwrap();
3116        std::fs::write(&sig_file, &sig_b).unwrap();
3117
3118        let err = get(
3119            &store,
3120            &OpFlags::default(),
3121            Some(UpdatesGetPayload {
3122                environment_id: env_id.to_string(),
3123                plan_url: None,
3124                plan_file: Some(plan_file),
3125                plan_sig_file: Some(sig_file),
3126            }),
3127        )
3128        .unwrap_err();
3129        assert!(matches!(err, OpError::InvalidArgument(_)));
3130    }
3131
3132    #[test]
3133    fn get_stages_zero_artifact_plan_to_staged() {
3134        let dir = tempdir().unwrap();
3135        let updates_dir = tempdir().unwrap();
3136
3137        let store = LocalFsStore::new(dir.path());
3138        let (priv7, tk7) = key_pair(7);
3139        let env_id = env_trusting(&store, &tk7);
3140
3141        let build_trust = TrustRoot::new(vec![tk7.clone()]);
3142        // No artifacts + unconstrained compat ⇒ the pipeline reaches `staged`.
3143        let (plan_b, sig_b) = signed_plan(
3144            "local",
3145            "plan-happy",
3146            1,
3147            json!([]),
3148            json!({}),
3149            &priv7,
3150            &tk7.key_id,
3151            &build_trust,
3152        );
3153        let plan_file = dir.path().join("plan.json");
3154        let sig_file = dir.path().join("plan.json.sig");
3155        std::fs::write(&plan_file, &plan_b).unwrap();
3156        std::fs::write(&sig_file, &sig_b).unwrap();
3157
3158        // The test seam points the staging FSM at a tempdir (no env-var / unsafe).
3159        let out = get_impl(
3160            &store,
3161            &OpFlags::default(),
3162            Some(UpdatesGetPayload {
3163                environment_id: env_id.to_string(),
3164                plan_url: None,
3165                plan_file: Some(plan_file),
3166                plan_sig_file: Some(sig_file),
3167            }),
3168            Some(updates_dir.path()),
3169        )
3170        .unwrap();
3171
3172        assert_eq!(out.op, "get");
3173        assert_eq!(out.result["stage"], "staged");
3174        assert_eq!(out.result["plan_id"], "plan-happy");
3175        assert_eq!(out.result["artifacts_total"], 0);
3176        assert_eq!(out.result["sequence"], 1);
3177    }
3178
3179    #[test]
3180    fn get_rejects_target_manifest_naming_another_env() {
3181        // plan.env_id matches `local`, but the signed target manifest names
3182        // `other` — a self-inconsistent plan must be refused (fail closed).
3183        let dir = tempdir().unwrap();
3184        let store = LocalFsStore::new(dir.path());
3185        let (priv7, tk7) = key_pair(7);
3186        let env_id = env_trusting(&store, &tk7);
3187        let build_trust = TrustRoot::new(vec![tk7.clone()]);
3188
3189        let plan: greentic_update::plan::UpdatePlan = serde_json::from_value(json!({
3190            "schema": "greentic.update-plan.v1",
3191            "plan_id": "plan-mismatch",
3192            "env_id": "local",
3193            "sequence": 1,
3194            "created_at": "2026-07-02T00:00:00Z",
3195            "nonce": "n",
3196            "target": {"schema": "greentic.env-manifest.v1", "environment": {"id": "other"}},
3197            "artifacts": [],
3198            "compat": {},
3199            "rollback": {"policy": "auto", "health_timeout_s": 120, "on_fail": "restore"},
3200        }))
3201        .unwrap();
3202        let built =
3203            greentic_update::plan::build_update_plan(&plan, &priv7, &tk7.key_id, &build_trust)
3204                .unwrap();
3205        let plan_file = dir.path().join("plan.json");
3206        let sig_file = dir.path().join("plan.json.sig");
3207        std::fs::write(&plan_file, &built.plan_bytes).unwrap();
3208        std::fs::write(&sig_file, &built.envelope_bytes).unwrap();
3209
3210        // Fails at the identity check, before the staging root is touched.
3211        let err = get(
3212            &store,
3213            &OpFlags::default(),
3214            Some(UpdatesGetPayload {
3215                environment_id: env_id.to_string(),
3216                plan_url: None,
3217                plan_file: Some(plan_file),
3218                plan_sig_file: Some(sig_file),
3219            }),
3220        )
3221        .unwrap_err();
3222        assert!(matches!(err, OpError::InvalidArgument(_)));
3223    }
3224
3225    #[test]
3226    fn get_rejects_plaintext_remote_plan_url() {
3227        // A remote plaintext plan_url would fetch without presenting the enrolled
3228        // mTLS identity — rejected before any secret read or network call.
3229        let dir = tempdir().unwrap();
3230        let store = LocalFsStore::new(dir.path());
3231        store.save(&make_env("local")).unwrap();
3232        let err = get(
3233            &store,
3234            &OpFlags::default(),
3235            Some(UpdatesGetPayload {
3236                environment_id: "local".into(),
3237                plan_url: Some("http://updates.example/plan".into()),
3238                plan_file: None,
3239                plan_sig_file: None,
3240            }),
3241        )
3242        .unwrap_err();
3243        assert!(matches!(err, OpError::InvalidArgument(_)));
3244    }
3245
3246    #[test]
3247    fn get_is_idempotent_on_reget() {
3248        // Re-running `get` on the same plan must resume, not error `PlanExists`.
3249        let dir = tempdir().unwrap();
3250        let updates_dir = tempdir().unwrap();
3251        let store = LocalFsStore::new(dir.path());
3252        let (priv7, tk7) = key_pair(7);
3253        let env_id = env_trusting(&store, &tk7);
3254        let build_trust = TrustRoot::new(vec![tk7.clone()]);
3255        let (plan_b, sig_b) = signed_plan(
3256            "local",
3257            "plan-idem",
3258            1,
3259            json!([]),
3260            json!({}),
3261            &priv7,
3262            &tk7.key_id,
3263            &build_trust,
3264        );
3265        let plan_file = dir.path().join("plan.json");
3266        let sig_file = dir.path().join("plan.json.sig");
3267        std::fs::write(&plan_file, &plan_b).unwrap();
3268        std::fs::write(&sig_file, &sig_b).unwrap();
3269
3270        let payload = || UpdatesGetPayload {
3271            environment_id: env_id.to_string(),
3272            plan_url: None,
3273            plan_file: Some(plan_file.clone()),
3274            plan_sig_file: Some(sig_file.clone()),
3275        };
3276        let first = get_impl(
3277            &store,
3278            &OpFlags::default(),
3279            Some(payload()),
3280            Some(updates_dir.path()),
3281        )
3282        .unwrap();
3283        assert_eq!(first.result["stage"], "staged");
3284
3285        let second = get_impl(
3286            &store,
3287            &OpFlags::default(),
3288            Some(payload()),
3289            Some(updates_dir.path()),
3290        )
3291        .unwrap();
3292        assert_eq!(second.result["stage"], "staged");
3293        assert_eq!(second.result["plan_id"], "plan-idem");
3294    }
3295
3296    // ---- Phase 2b: artifact download orchestration -----------------------
3297
3298    /// An [`ArtifactFetcher`] stub: serves canned bytes by artifact name, can
3299    /// fail its first `fail_times` calls (retry testing), and counts calls.
3300    struct StubFetcher {
3301        bytes: std::collections::HashMap<String, Vec<u8>>,
3302        fail_times: std::cell::Cell<u32>,
3303        calls: std::cell::Cell<u32>,
3304    }
3305
3306    impl StubFetcher {
3307        fn serving(entries: &[(&str, &[u8])]) -> Self {
3308            Self {
3309                bytes: entries
3310                    .iter()
3311                    .map(|(n, b)| (n.to_string(), b.to_vec()))
3312                    .collect(),
3313                fail_times: std::cell::Cell::new(0),
3314                calls: std::cell::Cell::new(0),
3315            }
3316        }
3317    }
3318
3319    impl ArtifactFetcher for StubFetcher {
3320        fn fetch(
3321            &self,
3322            artifact: &greentic_update::plan::PlanArtifact,
3323        ) -> Result<Vec<u8>, OpError> {
3324            self.calls.set(self.calls.get() + 1);
3325            let remaining = self.fail_times.get();
3326            if remaining > 0 {
3327                self.fail_times.set(remaining - 1);
3328                return Err(OpError::Fetch("transient".into()));
3329            }
3330            self.bytes
3331                .get(&artifact.name)
3332                .cloned()
3333                .ok_or_else(|| OpError::Fetch(format!("no stub bytes for `{}`", artifact.name)))
3334        }
3335    }
3336
3337    fn digest_of(bytes: &[u8]) -> String {
3338        format!("sha256:{}", greentic_update::plan::sha256_hex(bytes))
3339    }
3340
3341    /// Build+sign a plan carrying `artifacts`, verify it, and admit it to a
3342    /// fresh staging root — returning the `Downloading` StagedPlan.
3343    fn downloading_plan(
3344        updates_dir: &std::path::Path,
3345        artifacts: Value,
3346    ) -> greentic_update::staging::StagedPlan {
3347        let (priv9, tk9) = key_pair(9);
3348        let build_trust = TrustRoot::new(vec![tk9.clone()]);
3349        let (plan_b, sig_b) = signed_plan(
3350            "local",
3351            "plan-dl",
3352            1,
3353            artifacts,
3354            json!({}),
3355            &priv9,
3356            &tk9.key_id,
3357            &build_trust,
3358        );
3359        let verify_trust = TrustRoot::new(vec![tk9]);
3360        let verified =
3361            greentic_update::plan::verify_update_plan(&plan_b, &sig_b, &verify_trust).unwrap();
3362        let root = greentic_update::staging::UpdatesRoot::open_in(updates_dir, "local").unwrap();
3363        root.begin(&verified, &plan_b, &sig_b).unwrap()
3364    }
3365
3366    fn no_delay(attempts: u32) -> RetryPolicy {
3367        RetryPolicy {
3368            attempts,
3369            base_delay: std::time::Duration::ZERO,
3370        }
3371    }
3372
3373    #[test]
3374    fn download_and_stage_fetches_all_and_promotes() {
3375        use greentic_update::staging::UpdateStage;
3376        let updates_dir = tempdir().unwrap();
3377        let (a1, a2) = (b"alpha-bytes".as_slice(), b"beta-bytes".as_slice());
3378        let staged = downloading_plan(
3379            updates_dir.path(),
3380            json!([
3381                {"name": "a1", "version": "1.0.0", "digest": digest_of(a1), "source": "file:///a1"},
3382                {"name": "a2", "version": "1.0.0", "digest": digest_of(a2), "source": "file:///a2"},
3383            ]),
3384        );
3385        let stub = StubFetcher::serving(&[("a1", a1), ("a2", a2)]);
3386        let arts = staged.plan().artifacts.to_vec();
3387
3388        let stage = download_and_stage(&staged, &arts, &stub, no_delay(1)).unwrap();
3389
3390        assert_eq!(stage, UpdateStage::Staged);
3391        assert_eq!(stub.calls.get(), 2, "both artifacts fetched");
3392        // Content-addressed blobs landed under the plan's artifacts dir.
3393        assert_eq!(staged.stage().unwrap(), UpdateStage::Staged);
3394    }
3395
3396    #[test]
3397    fn download_and_stage_digest_mismatch_fails_closed() {
3398        use greentic_update::staging::UpdateStage;
3399        let updates_dir = tempdir().unwrap();
3400        // Plan declares the digest of "correct" but the fetcher returns "wrong".
3401        let staged = downloading_plan(
3402            updates_dir.path(),
3403            json!([
3404                {"name": "a1", "version": "1.0.0", "digest": digest_of(b"correct"), "source": "file:///a1"},
3405            ]),
3406        );
3407        let stub = StubFetcher::serving(&[("a1", b"wrong")]);
3408        let arts = staged.plan().artifacts.to_vec();
3409
3410        let err = download_and_stage(&staged, &arts, &stub, no_delay(1)).unwrap_err();
3411
3412        assert!(matches!(err, OpError::Conflict(m) if m.contains("digest mismatch")));
3413        // Fail-closed: the plan is NOT promoted; nothing half-staged.
3414        assert_eq!(staged.stage().unwrap(), UpdateStage::Downloading);
3415    }
3416
3417    #[test]
3418    fn download_and_stage_resumes_without_refetching() {
3419        use greentic_update::staging::UpdateStage;
3420        let updates_dir = tempdir().unwrap();
3421        let staged = downloading_plan(
3422            updates_dir.path(),
3423            json!([{"name": "a1", "version": "1.0.0", "digest": digest_of(b"x"), "source": "file:///a1"}]),
3424        );
3425        // Simulate a completed prior run: already promoted to `staged`.
3426        staged.transition(UpdateStage::Inbox).unwrap();
3427        staged.transition(UpdateStage::Staged).unwrap();
3428
3429        let stub = StubFetcher::serving(&[("a1", b"x")]);
3430        let arts = staged.plan().artifacts.to_vec();
3431        let stage = download_and_stage(&staged, &arts, &stub, no_delay(1)).unwrap();
3432
3433        assert_eq!(stage, UpdateStage::Staged);
3434        assert_eq!(stub.calls.get(), 0, "already-staged plan must not re-fetch");
3435    }
3436
3437    #[test]
3438    fn fetch_with_retry_retries_transient_then_succeeds() {
3439        let stub = StubFetcher::serving(&[("a1", b"ok")]);
3440        stub.fail_times.set(2); // fail twice, then succeed on the 3rd try
3441        let artifact: greentic_update::plan::PlanArtifact = serde_json::from_value(
3442            json!({"name": "a1", "version": "1.0.0", "digest": digest_of(b"ok"), "source": "file:///a1"}),
3443        )
3444        .unwrap();
3445
3446        let bytes = fetch_with_retry(&stub, &artifact, no_delay(3)).unwrap();
3447
3448        assert_eq!(bytes, b"ok");
3449        assert_eq!(stub.calls.get(), 3);
3450    }
3451
3452    #[test]
3453    fn fetch_with_retry_exhausts_attempts_and_returns_last_error() {
3454        let stub = StubFetcher::serving(&[("a1", b"ok")]);
3455        stub.fail_times.set(99); // never succeeds within the budget
3456        let artifact: greentic_update::plan::PlanArtifact = serde_json::from_value(
3457            json!({"name": "a1", "version": "1.0.0", "digest": digest_of(b"ok"), "source": "file:///a1"}),
3458        )
3459        .unwrap();
3460
3461        let err = fetch_with_retry(&stub, &artifact, no_delay(2)).unwrap_err();
3462
3463        assert!(matches!(err, OpError::Fetch(_)));
3464        assert_eq!(stub.calls.get(), 2, "exactly `attempts` tries");
3465    }
3466
3467    #[test]
3468    fn dist_fetcher_rejects_artifact_without_source() {
3469        // The real fetcher fails closed (before any network) on an artifact that
3470        // declares no `source` — online `get` cannot materialize it.
3471        let artifact: greentic_update::plan::PlanArtifact = serde_json::from_value(
3472            json!({"name": "a1", "version": "1.0.0", "digest": digest_of(b"x")}),
3473        )
3474        .unwrap();
3475        let err = DistArtifactFetcher::new().fetch(&artifact).unwrap_err();
3476        assert!(matches!(err, OpError::InvalidArgument(m) if m.contains("no source")));
3477    }
3478
3479    #[test]
3480    fn dist_fetcher_rejects_disallowed_scheme() {
3481        // A signed plan must not make the operator read local files: file:// and
3482        // bare paths are refused before any resolve/fetch (no network).
3483        for src in ["file:///etc/passwd", "/etc/passwd", "repo://x", "store://y"] {
3484            let artifact: greentic_update::plan::PlanArtifact = serde_json::from_value(
3485                json!({"name": "a1", "version": "1.0.0", "digest": digest_of(b"x"), "source": src}),
3486            )
3487            .unwrap();
3488            let err = DistArtifactFetcher::new().fetch(&artifact).unwrap_err();
3489            assert!(
3490                matches!(err, OpError::InvalidArgument(m) if m.contains("allowed remote scheme")),
3491                "source `{src}` should be rejected by scheme"
3492            );
3493        }
3494    }
3495
3496    #[test]
3497    fn reject_oversize_caps_large_artifacts() {
3498        let artifact: greentic_update::plan::PlanArtifact = serde_json::from_value(
3499            json!({"name": "big", "version": "1.0.0", "digest": digest_of(b"x")}),
3500        )
3501        .unwrap();
3502        assert!(reject_oversize(&artifact, MAX_ARTIFACT_BYTES).is_ok());
3503        assert!(matches!(
3504            reject_oversize(&artifact, MAX_ARTIFACT_BYTES + 1),
3505            Err(OpError::Fetch(_))
3506        ));
3507    }
3508
3509    // ---- Phase 2b: admit_or_resume re-gating (Codex #418) -----------------
3510
3511    fn verify_with(
3512        plan_b: &[u8],
3513        sig_b: &[u8],
3514        tk: &TrustedKey,
3515    ) -> greentic_update::plan::VerifiedUpdatePlan {
3516        greentic_update::plan::verify_update_plan(plan_b, sig_b, &TrustRoot::new(vec![tk.clone()]))
3517            .unwrap()
3518    }
3519
3520    /// Sign + verify a zero-artifact plan for env `local` under key `tk`.
3521    fn signed_local(
3522        plan_id: &str,
3523        sequence: u64,
3524        priv_pem: &str,
3525        tk: &TrustedKey,
3526    ) -> (Vec<u8>, Vec<u8>, greentic_update::plan::VerifiedUpdatePlan) {
3527        let build_trust = TrustRoot::new(vec![tk.clone()]);
3528        let (p, s) = signed_plan(
3529            "local",
3530            plan_id,
3531            sequence,
3532            json!([]),
3533            json!({}),
3534            priv_pem,
3535            &tk.key_id,
3536            &build_trust,
3537        );
3538        let v = verify_with(&p, &s, tk);
3539        (p, s, v)
3540    }
3541
3542    #[test]
3543    fn admit_or_resume_regates_stranded_downgrade() {
3544        use greentic_update::staging::UpdateStage;
3545        let updates_dir = tempdir().unwrap();
3546        let (priv9, tk9) = key_pair(9);
3547        let root =
3548            greentic_update::staging::UpdatesRoot::open_in(updates_dir.path(), "local").unwrap();
3549
3550        // A newer plan (seq 6) is already Applied.
3551        let (pa, sa, va) = signed_local("applied", 6, &priv9, &tk9);
3552        let applied = root.begin(&va, &pa, &sa).unwrap();
3553        applied.transition(UpdateStage::Inbox).unwrap();
3554        applied.transition(UpdateStage::Staged).unwrap();
3555        applied.transition(UpdateStage::Applying).unwrap();
3556        applied.transition(UpdateStage::Applied).unwrap();
3557
3558        // An older plan (seq 5) got stranded at `downloading` before that apply.
3559        let (ps, ss, vs) = signed_local("stale", 5, &priv9, &tk9);
3560        root.begin(&vs, &ps, &ss).unwrap();
3561        assert_eq!(
3562            root.load("stale").unwrap().unwrap().stage().unwrap(),
3563            UpdateStage::Downloading
3564        );
3565
3566        // Resuming it must RE-GATE and reject the now-downgrade — not promote it.
3567        let err = admit_or_resume(&root, &vs, &ps, &ss).unwrap_err();
3568        assert!(matches!(err, OpError::Conflict(m) if m.contains("rejected")));
3569    }
3570
3571    #[test]
3572    fn admit_or_resume_refuses_terminal_plan() {
3573        use greentic_update::staging::UpdateStage;
3574        let updates_dir = tempdir().unwrap();
3575        let (priv9, tk9) = key_pair(9);
3576        let root =
3577            greentic_update::staging::UpdatesRoot::open_in(updates_dir.path(), "local").unwrap();
3578
3579        let (p, s, v) = signed_local("term", 1, &priv9, &tk9);
3580        let staged = root.begin(&v, &p, &s).unwrap();
3581        staged.transition(UpdateStage::Rejected).unwrap();
3582
3583        let err = admit_or_resume(&root, &v, &p, &s).unwrap_err();
3584        assert!(matches!(err, OpError::Conflict(m) if m.contains("not resuming")));
3585    }
3586
3587    #[test]
3588    fn admit_or_resume_returns_promoted_plan_as_is() {
3589        use greentic_update::staging::UpdateStage;
3590        let updates_dir = tempdir().unwrap();
3591        let (priv9, tk9) = key_pair(9);
3592        let root =
3593            greentic_update::staging::UpdatesRoot::open_in(updates_dir.path(), "local").unwrap();
3594
3595        // A fully-staged plan (admission already ran at begin) is idempotently
3596        // returned as-is — NOT re-gated (which could wrongly reject it later).
3597        let (p, s, v) = signed_local("done", 1, &priv9, &tk9);
3598        let staged = root.begin(&v, &p, &s).unwrap();
3599        staged.transition(UpdateStage::Inbox).unwrap();
3600        staged.transition(UpdateStage::Staged).unwrap();
3601
3602        let resumed = admit_or_resume(&root, &v, &p, &s).unwrap();
3603        assert_eq!(resumed.stage().unwrap(), UpdateStage::Staged);
3604    }
3605
3606    // ---- Phase 3: op updates apply ----------------------------------------
3607
3608    /// Build + sign a plan with a custom target manifest (for apply tests that
3609    /// need a non-minimal manifest — e.g. a bundle that fails to resolve).
3610    #[allow(clippy::too_many_arguments)]
3611    fn signed_plan_target(
3612        env_id: &str,
3613        plan_id: &str,
3614        sequence: u64,
3615        target: Value,
3616        priv_pem: &str,
3617        key_id: &str,
3618        build_trust: &TrustRoot,
3619    ) -> (Vec<u8>, Vec<u8>) {
3620        let plan: greentic_update::plan::UpdatePlan = serde_json::from_value(json!({
3621            "schema": "greentic.update-plan.v1",
3622            "plan_id": plan_id,
3623            "env_id": env_id,
3624            "sequence": sequence,
3625            "created_at": "2026-07-02T00:00:00Z",
3626            "nonce": format!("nonce-{plan_id}"),
3627            "target": target,
3628            "artifacts": [],
3629            "compat": {},
3630            "rollback": {"policy": "auto", "health_timeout_s": 120, "on_fail": "restore"},
3631        }))
3632        .unwrap();
3633        let built =
3634            greentic_update::plan::build_update_plan(&plan, priv_pem, key_id, build_trust).unwrap();
3635        (built.plan_bytes, built.envelope_bytes)
3636    }
3637
3638    /// Stage a signed zero-artifact plan for `local` directly to `Staged`
3639    /// (bypasses the network path of `get`, same on-disk result). The env must
3640    /// already trust `tk`.
3641    fn stage_local(
3642        updates_root: &std::path::Path,
3643        plan_id: &str,
3644        sequence: u64,
3645        priv_pem: &str,
3646        tk: &TrustedKey,
3647    ) {
3648        let (p, s, v) = signed_local(plan_id, sequence, priv_pem, tk);
3649        let root = greentic_update::staging::UpdatesRoot::open_in(updates_root, "local").unwrap();
3650        let staged = root.begin(&v, &p, &s).unwrap();
3651        advance_to_staged(&staged).unwrap();
3652    }
3653
3654    /// Load a staged plan's on-disk stage.
3655    fn on_disk_stage(
3656        updates_root: &std::path::Path,
3657        plan_id: &str,
3658    ) -> greentic_update::staging::UpdateStage {
3659        greentic_update::staging::UpdatesRoot::open_in(updates_root, "local")
3660            .unwrap()
3661            .load(plan_id)
3662            .unwrap()
3663            .unwrap()
3664            .stage()
3665            .unwrap()
3666    }
3667
3668    #[test]
3669    fn apply_schema_only_returns_payload_schema() {
3670        let dir = tempdir().unwrap();
3671        let store = LocalFsStore::new(dir.path());
3672        let out = apply_updates(
3673            &store,
3674            &OpFlags {
3675                schema_only: true,
3676                ..OpFlags::default()
3677            },
3678            None,
3679        )
3680        .unwrap();
3681        assert_eq!(out.op, "apply");
3682        assert_eq!(out.noun, NOUN);
3683        assert!(out.result["properties"]["plan_id"].is_object());
3684    }
3685
3686    #[test]
3687    fn apply_plan_not_found_is_not_found() {
3688        let dir = tempdir().unwrap();
3689        let updates_dir = tempdir().unwrap();
3690        let store = LocalFsStore::new(dir.path());
3691        let (_priv7, tk7) = key_pair(7);
3692        env_trusting(&store, &tk7);
3693        let err = apply_updates_impl(
3694            &store,
3695            &OpFlags::default(),
3696            Some(ApplyUpdatesPayload {
3697                environment_id: "local".into(),
3698                plan_id: "ghost".into(),
3699            }),
3700            Some(updates_dir.path()),
3701        )
3702        .unwrap_err();
3703        assert!(matches!(err, OpError::NotFound(_)));
3704    }
3705
3706    #[test]
3707    fn apply_happy_path_zero_artifact_converges_and_marks_applied() {
3708        use greentic_update::staging::UpdateStage;
3709        let dir = tempdir().unwrap();
3710        let updates_dir = tempdir().unwrap();
3711        let store = LocalFsStore::new(dir.path());
3712        let (priv7, tk7) = key_pair(7);
3713        let env_id = env_trusting(&store, &tk7);
3714        stage_local(updates_dir.path(), "plan-1", 1, &priv7, &tk7);
3715
3716        let out = apply_updates_impl(
3717            &store,
3718            &OpFlags::default(),
3719            Some(ApplyUpdatesPayload {
3720                environment_id: "local".into(),
3721                plan_id: "plan-1".into(),
3722            }),
3723            Some(updates_dir.path()),
3724        )
3725        .unwrap();
3726
3727        assert_eq!(out.op, "apply");
3728        assert_eq!(out.result["stage"], "applied");
3729        assert_eq!(out.result["plan_id"], "plan-1");
3730        assert!(out.result["snapshot_id"].as_str().is_some());
3731
3732        // On-disk FSM marker advanced to Applied.
3733        assert_eq!(
3734            on_disk_stage(updates_dir.path(), "plan-1"),
3735            UpdateStage::Applied
3736        );
3737        // A pre-apply snapshot was captured, and a deployer-layer audit event
3738        // was written for the mutation.
3739        let env_dir = store.env_dir(&env_id).unwrap();
3740        assert!(env_dir.join("snapshots").is_dir(), "snapshot must exist");
3741        let audit = std::fs::read_to_string(env_dir.join("audit").join("events.jsonl")).unwrap();
3742        assert!(
3743            audit.contains("plan-1"),
3744            "audit must record the apply: {audit}"
3745        );
3746    }
3747
3748    #[test]
3749    fn apply_annotates_binaries_it_does_not_install() {
3750        use greentic_update::staging::{UpdateStage, UpdatesRoot};
3751        let dir = tempdir().unwrap();
3752        let updates_dir = tempdir().unwrap();
3753        let store = LocalFsStore::new(dir.path());
3754        let (priv7, tk7) = key_pair(7);
3755        env_trusting(&store, &tk7);
3756
3757        // A binary-carrying `plan-build`-style output: no content artifacts, a
3758        // valid minimal target, one binary. `op updates apply` converges the
3759        // (empty) content but MUST surface that it did not install the binary,
3760        // so the "applied" result is never misread as a completed self-update.
3761        let build_trust = TrustRoot::new(vec![tk7.clone()]);
3762        let plan: greentic_update::plan::UpdatePlan = serde_json::from_value(json!({
3763            "schema": "greentic.update-plan.v1",
3764            "plan_id": "plan-bin",
3765            "env_id": "local",
3766            "sequence": 1,
3767            "created_at": "2026-07-02T00:00:00Z",
3768            "nonce": "nonce-plan-bin",
3769            "target": {"schema": "greentic.env-manifest.v1", "environment": {"id": "local"}},
3770            "artifacts": [],
3771            "binaries": [{
3772                "name": "greentic-start",
3773                "version": "1.1.9",
3774                "target": "x86_64-unknown-linux-gnu",
3775                "digest": "sha256:abc123",
3776                "source": "https://example.test/greentic-start.tgz"
3777            }],
3778            "compat": {},
3779            "rollback": {"policy": "auto", "health_timeout_s": 120, "on_fail": "restore"},
3780        }))
3781        .unwrap();
3782        let built =
3783            greentic_update::plan::build_update_plan(&plan, &priv7, &tk7.key_id, &build_trust)
3784                .unwrap();
3785        let verified = verify_with(&built.plan_bytes, &built.envelope_bytes, &tk7);
3786        let root = UpdatesRoot::open_in(updates_dir.path(), "local").unwrap();
3787        let staged = root
3788            .begin(&verified, &built.plan_bytes, &built.envelope_bytes)
3789            .unwrap();
3790        advance_to_staged(&staged).unwrap();
3791
3792        let out = apply_updates_impl(
3793            &store,
3794            &OpFlags::default(),
3795            Some(ApplyUpdatesPayload {
3796                environment_id: "local".into(),
3797                plan_id: "plan-bin".into(),
3798            }),
3799            Some(updates_dir.path()),
3800        )
3801        .unwrap();
3802
3803        // Content still converges + marks applied ...
3804        assert_eq!(out.result["stage"], "applied");
3805        assert_eq!(
3806            on_disk_stage(updates_dir.path(), "plan-bin"),
3807            UpdateStage::Applied
3808        );
3809        // ... but the uninstalled binary is surfaced.
3810        let bins = out.result["binaries_not_applied"]
3811            .as_array()
3812            .expect("binaries_not_applied must be present when the plan carries binaries");
3813        assert_eq!(bins.len(), 1);
3814        assert_eq!(bins[0]["name"], "greentic-start");
3815        assert_eq!(bins[0]["version"], "1.1.9");
3816        assert_eq!(bins[0]["target"], "x86_64-unknown-linux-gnu");
3817    }
3818
3819    #[test]
3820    fn apply_transition_failure_returns_honest_recover_error() {
3821        // Exercise Case B: run_manifest_apply succeeds (env content IS mutated),
3822        // but the applying -> applied transition persistently fails. The error
3823        // must contain "recover" and NOT claim the apply itself failed.
3824        use greentic_update::staging::UpdateStage;
3825        use std::os::unix::fs::PermissionsExt;
3826
3827        let dir = tempdir().unwrap();
3828        let updates_dir = tempdir().unwrap();
3829        let store = LocalFsStore::new(dir.path());
3830        let (priv7, tk7) = key_pair(7);
3831        let env_id = env_trusting(&store, &tk7);
3832        stage_local(updates_dir.path(), "plan-stuck", 1, &priv7, &tk7);
3833
3834        // Resolve the on-disk plan directory so we can chmod it read-only from
3835        // inside the fault hook, preventing `state.json` writes.
3836        let plan_dir = greentic_update::staging::UpdatesRoot::open_in(updates_dir.path(), "local")
3837            .unwrap()
3838            .load("plan-stuck")
3839            .unwrap()
3840            .unwrap()
3841            .dir()
3842            .to_path_buf();
3843
3844        // Install a fault hook that fires AFTER run_manifest_apply succeeds but
3845        // BEFORE the transition retry loop. Making the plan dir read-only
3846        // prevents `state.json` from being rewritten, so the transition fails
3847        // with an I/O error.
3848        let hook_dir = plan_dir.clone();
3849        PRE_APPLIED_TRANSITION_HOOK.with(|h| {
3850            *h.borrow_mut() = Some(Box::new(move || {
3851                std::fs::set_permissions(&hook_dir, std::fs::Permissions::from_mode(0o500))
3852                    .expect("chmod plan dir read-only");
3853            }));
3854        });
3855
3856        let err = apply_updates_impl(
3857            &store,
3858            &OpFlags::default(),
3859            Some(ApplyUpdatesPayload {
3860                environment_id: "local".into(),
3861                plan_id: "plan-stuck".into(),
3862            }),
3863            Some(updates_dir.path()),
3864        )
3865        .unwrap_err();
3866
3867        // Clean up the hook so it does not interfere with other tests.
3868        PRE_APPLIED_TRANSITION_HOOK.with(|h| {
3869            *h.borrow_mut() = None;
3870        });
3871        // Restore write permission so tempdir cleanup succeeds.
3872        std::fs::set_permissions(&plan_dir, std::fs::Permissions::from_mode(0o755)).unwrap();
3873
3874        // The error must mention `recover` and must NOT claim the apply failed.
3875        let msg = format!("{err}");
3876        assert!(
3877            msg.contains("recover"),
3878            "error must point at `op updates recover`: {msg}"
3879        );
3880        assert!(
3881            msg.contains("applied successfully"),
3882            "error must state content was applied: {msg}"
3883        );
3884
3885        // The on-disk stage is stuck at Applying (Case B), NOT Applied.
3886        assert_eq!(
3887            on_disk_stage(updates_dir.path(), "plan-stuck"),
3888            UpdateStage::Applying
3889        );
3890
3891        // The env content WAS applied — a snapshot was captured pre-mutation.
3892        let env_dir = store.env_dir(&env_id).unwrap();
3893        assert!(
3894            env_dir.join("snapshots").is_dir(),
3895            "snapshot must exist (env was mutated)"
3896        );
3897    }
3898
3899    #[test]
3900    fn apply_rejects_already_applied_plan() {
3901        let dir = tempdir().unwrap();
3902        let updates_dir = tempdir().unwrap();
3903        let store = LocalFsStore::new(dir.path());
3904        let (priv7, tk7) = key_pair(7);
3905        env_trusting(&store, &tk7);
3906        stage_local(updates_dir.path(), "plan-1", 1, &priv7, &tk7);
3907
3908        let payload = ApplyUpdatesPayload {
3909            environment_id: "local".into(),
3910            plan_id: "plan-1".into(),
3911        };
3912        apply_updates_impl(
3913            &store,
3914            &OpFlags::default(),
3915            Some(payload.clone()),
3916            Some(updates_dir.path()),
3917        )
3918        .unwrap();
3919        // Re-applying a terminal (Applied) plan is refused by the stage gate.
3920        let err = apply_updates_impl(
3921            &store,
3922            &OpFlags::default(),
3923            Some(payload),
3924            Some(updates_dir.path()),
3925        )
3926        .unwrap_err();
3927        assert!(matches!(err, OpError::InvalidArgument(m) if m.contains("not `staged`")));
3928    }
3929
3930    #[test]
3931    fn apply_rejects_retryably_when_plan_already_applying() {
3932        use greentic_update::staging::UpdateStage;
3933        let dir = tempdir().unwrap();
3934        let updates_dir = tempdir().unwrap();
3935        let store = LocalFsStore::new(dir.path());
3936        let (priv7, tk7) = key_pair(7);
3937        env_trusting(&store, &tk7);
3938        stage_local(updates_dir.path(), "plan-1", 1, &priv7, &tk7);
3939        // A same-plan apply is already in flight (or a prior one did not finish):
3940        // the plan sits in Applying.
3941        greentic_update::staging::UpdatesRoot::open_in(updates_dir.path(), "local")
3942            .unwrap()
3943            .load("plan-1")
3944            .unwrap()
3945            .unwrap()
3946            .transition(UpdateStage::Applying)
3947            .unwrap();
3948
3949        let err = apply_updates_impl(
3950            &store,
3951            &OpFlags::default(),
3952            Some(ApplyUpdatesPayload {
3953                environment_id: "local".into(),
3954                plan_id: "plan-1".into(),
3955            }),
3956            Some(updates_dir.path()),
3957        )
3958        .unwrap_err();
3959        // Retryable conflict — NOT a destructive self-heal. Auto-failing the
3960        // marker here could strand a live concurrent apply (env mutated, marker
3961        // Failed, sequence never advanced).
3962        assert!(matches!(err, OpError::Conflict(m) if m.contains("already `applying`")));
3963        // The plan is left untouched — still Applying.
3964        assert_eq!(
3965            on_disk_stage(updates_dir.path(), "plan-1"),
3966            UpdateStage::Applying
3967        );
3968    }
3969
3970    #[test]
3971    fn apply_rejects_swapped_plan_via_hash_cross_check() {
3972        use greentic_update::staging::UpdateStage;
3973        let dir = tempdir().unwrap();
3974        let updates_dir = tempdir().unwrap();
3975        let store = LocalFsStore::new(dir.path());
3976        let (priv7, tk7) = key_pair(7);
3977        env_trusting(&store, &tk7);
3978        stage_local(updates_dir.path(), "plan-1", 1, &priv7, &tk7);
3979
3980        // Swap BOTH plan.json and its sidecar for a DIFFERENT, validly-signed
3981        // plan (same id, different sequence ⇒ different bytes). `verify_update_plan`
3982        // accepts it, but its hash differs from the digest recorded at staging.
3983        let build_trust = TrustRoot::new(vec![tk7.clone()]);
3984        let (p2, s2) = signed_plan(
3985            "local",
3986            "plan-1",
3987            2,
3988            json!([]),
3989            json!({}),
3990            &priv7,
3991            &tk7.key_id,
3992            &build_trust,
3993        );
3994        let plan_dir = greentic_update::staging::UpdatesRoot::open_in(updates_dir.path(), "local")
3995            .unwrap()
3996            .load("plan-1")
3997            .unwrap()
3998            .unwrap()
3999            .dir()
4000            .to_path_buf();
4001        std::fs::write(plan_dir.join("plan.json"), &p2).unwrap();
4002        std::fs::write(plan_dir.join("plan.json.sig"), &s2).unwrap();
4003
4004        let err = apply_updates_impl(
4005            &store,
4006            &OpFlags::default(),
4007            Some(ApplyUpdatesPayload {
4008                environment_id: "local".into(),
4009                plan_id: "plan-1".into(),
4010            }),
4011            Some(updates_dir.path()),
4012        )
4013        .unwrap_err();
4014        assert!(matches!(err, OpError::Conflict(m) if m.contains("hash changed")));
4015        assert_eq!(
4016            on_disk_stage(updates_dir.path(), "plan-1"),
4017            UpdateStage::Rejected
4018        );
4019    }
4020
4021    #[test]
4022    fn apply_rejects_tampered_artifact_blob() {
4023        use greentic_update::staging::{UpdateStage, UpdatesRoot};
4024        let dir = tempdir().unwrap();
4025        let updates_dir = tempdir().unwrap();
4026        let store = LocalFsStore::new(dir.path());
4027        let (priv7, tk7) = key_pair(7);
4028        env_trusting(&store, &tk7);
4029
4030        let payload = b"the-artifact-bytes";
4031        let art_digest = format!("sha256:{}", greentic_update::plan::sha256_hex(payload));
4032        let build_trust = TrustRoot::new(vec![tk7.clone()]);
4033        let (p, s) = signed_plan(
4034            "local",
4035            "plan-art",
4036            1,
4037            json!([{"name": "pack-a", "version": "1.0.0", "digest": art_digest, "source": "oci://x/y:1"}]),
4038            json!({}),
4039            &priv7,
4040            &tk7.key_id,
4041            &build_trust,
4042        );
4043        let v = verify_with(&p, &s, &tk7);
4044        let root = UpdatesRoot::open_in(updates_dir.path(), "local").unwrap();
4045        let staged = root.begin(&v, &p, &s).unwrap();
4046        staged.put_artifact(&v.plan.artifacts[0], payload).unwrap();
4047        advance_to_staged(&staged).unwrap();
4048        // Corrupt the staged blob after it passed the ingest hash check.
4049        let blob = staged.artifact_blob_path(&v.plan.artifacts[0]).unwrap();
4050        std::fs::write(&blob, b"corrupted").unwrap();
4051
4052        let err = apply_updates_impl(
4053            &store,
4054            &OpFlags::default(),
4055            Some(ApplyUpdatesPayload {
4056                environment_id: "local".into(),
4057                plan_id: "plan-art".into(),
4058            }),
4059            Some(updates_dir.path()),
4060        )
4061        .unwrap_err();
4062        assert!(matches!(err, OpError::Conflict(m) if m.contains("integrity")));
4063        assert_eq!(
4064            on_disk_stage(updates_dir.path(), "plan-art"),
4065            UpdateStage::Rejected
4066        );
4067    }
4068
4069    #[test]
4070    fn apply_regates_downgrade_against_applied_set() {
4071        use greentic_update::staging::UpdateStage;
4072        let dir = tempdir().unwrap();
4073        let updates_dir = tempdir().unwrap();
4074        let store = LocalFsStore::new(dir.path());
4075        let (priv7, tk7) = key_pair(7);
4076        env_trusting(&store, &tk7);
4077
4078        // Both staged BEFORE either applies (so neither is rejected at stage).
4079        stage_local(updates_dir.path(), "plan-a", 2, &priv7, &tk7);
4080        stage_local(updates_dir.path(), "plan-b", 1, &priv7, &tk7);
4081
4082        // Apply the newer plan first ⇒ latest_applied_sequence = 2.
4083        apply_updates_impl(
4084            &store,
4085            &OpFlags::default(),
4086            Some(ApplyUpdatesPayload {
4087                environment_id: "local".into(),
4088                plan_id: "plan-a".into(),
4089            }),
4090            Some(updates_dir.path()),
4091        )
4092        .unwrap();
4093
4094        // Applying the older plan is now a downgrade ⇒ rejected at apply time.
4095        let err = apply_updates_impl(
4096            &store,
4097            &OpFlags::default(),
4098            Some(ApplyUpdatesPayload {
4099                environment_id: "local".into(),
4100                plan_id: "plan-b".into(),
4101            }),
4102            Some(updates_dir.path()),
4103        )
4104        .unwrap_err();
4105        assert!(matches!(err, OpError::Conflict(_)));
4106        assert_eq!(
4107            on_disk_stage(updates_dir.path(), "plan-b"),
4108            UpdateStage::Rejected
4109        );
4110    }
4111
4112    #[test]
4113    fn apply_rejects_concurrent_applying_plan() {
4114        use greentic_update::staging::{UpdateStage, UpdatesRoot};
4115        let dir = tempdir().unwrap();
4116        let updates_dir = tempdir().unwrap();
4117        let store = LocalFsStore::new(dir.path());
4118        let (priv7, tk7) = key_pair(7);
4119        env_trusting(&store, &tk7);
4120
4121        // Two staged plans; park one at `applying` (an in-flight apply on this
4122        // env). begin_apply_checked's single-flight gate must then refuse the
4123        // other — atomically, under the staging lock.
4124        stage_local(updates_dir.path(), "plan-a", 1, &priv7, &tk7);
4125        stage_local(updates_dir.path(), "plan-b", 2, &priv7, &tk7);
4126        let root = UpdatesRoot::open_in(updates_dir.path(), "local").unwrap();
4127        root.load("plan-a")
4128            .unwrap()
4129            .unwrap()
4130            .transition(UpdateStage::Applying)
4131            .unwrap();
4132
4133        let err = apply_updates_impl(
4134            &store,
4135            &OpFlags::default(),
4136            Some(ApplyUpdatesPayload {
4137                environment_id: "local".into(),
4138                plan_id: "plan-b".into(),
4139            }),
4140            Some(updates_dir.path()),
4141        )
4142        .unwrap_err();
4143        assert!(matches!(err, OpError::Conflict(m) if m.contains("single-flight")));
4144        // plan-b stays Staged (single-flight is retryable, not fatal); plan-a is
4145        // untouched — neither the env nor the losing plan was mutated.
4146        assert_eq!(
4147            on_disk_stage(updates_dir.path(), "plan-b"),
4148            UpdateStage::Staged
4149        );
4150        assert_eq!(
4151            on_disk_stage(updates_dir.path(), "plan-a"),
4152            UpdateStage::Applying
4153        );
4154    }
4155
4156    #[test]
4157    fn apply_rolls_back_and_fails_plan_on_apply_error() {
4158        use greentic_update::staging::{UpdateStage, UpdatesRoot};
4159        let dir = tempdir().unwrap();
4160        let updates_dir = tempdir().unwrap();
4161        let store = LocalFsStore::new(dir.path());
4162        let (priv7, tk7) = key_pair(7);
4163        let env_id = env_trusting(&store, &tk7);
4164
4165        // A valid, digest-pinned manifest whose bundle artifact does not exist
4166        // ⇒ env_apply errors at resolve time (after the snapshot is taken).
4167        let bad_target = json!({
4168            "schema": "greentic.env-manifest.v1",
4169            "environment": {"id": "local"},
4170            "bundles": [{
4171                "bundle_id": "b1",
4172                "bundle_path": "/nonexistent/missing.gtbundle",
4173                "bundle_digest": "sha256:0000000000000000000000000000000000000000000000000000000000000000"
4174            }]
4175        });
4176        let build_trust = TrustRoot::new(vec![tk7.clone()]);
4177        let (p, s) = signed_plan_target(
4178            "local",
4179            "plan-bad",
4180            1,
4181            bad_target,
4182            &priv7,
4183            &tk7.key_id,
4184            &build_trust,
4185        );
4186        let v = verify_with(&p, &s, &tk7);
4187        let root = UpdatesRoot::open_in(updates_dir.path(), "local").unwrap();
4188        let staged = root.begin(&v, &p, &s).unwrap();
4189        advance_to_staged(&staged).unwrap();
4190
4191        let err = apply_updates_impl(
4192            &store,
4193            &OpFlags::default(),
4194            Some(ApplyUpdatesPayload {
4195                environment_id: "local".into(),
4196                plan_id: "plan-bad".into(),
4197            }),
4198            Some(updates_dir.path()),
4199        )
4200        .unwrap_err();
4201        // The env was rolled back and the plan failed.
4202        assert!(matches!(err, OpError::Conflict(m) if m.contains("rolled back")));
4203        assert_eq!(
4204            on_disk_stage(updates_dir.path(), "plan-bad"),
4205            UpdateStage::Failed
4206        );
4207        assert!(store.env_dir(&env_id).unwrap().join("snapshots").is_dir());
4208    }
4209
4210    #[test]
4211    fn apply_rejects_secrets_when_backend_not_dev_store() {
4212        use greentic_update::staging::{UpdateStage, UpdatesRoot};
4213        let dir = tempdir().unwrap();
4214        let updates_dir = tempdir().unwrap();
4215        let store = LocalFsStore::new(dir.path());
4216        let (priv7, tk7) = key_pair(7);
4217        // Env's Secrets slot is bound to Vault, so secret writes land outside the
4218        // P0b snapshot ⇒ secrets[] is refused fail-closed, and the plan is left
4219        // Rejected pre-mutation.
4220        env_trusting_secrets(&store, &tk7, Some(crate::defaults::VAULT_SECRETS_PACK));
4221
4222        let target = json!({
4223            "schema": "greentic.env-manifest.v1",
4224            "environment": {"id": "local"},
4225            "secrets": [{"path": "acme/_/tls/foo", "from_env": "FOO"}]
4226        });
4227        let build_trust = TrustRoot::new(vec![tk7.clone()]);
4228        let (p, s) = signed_plan_target(
4229            "local",
4230            "plan-sec",
4231            1,
4232            target,
4233            &priv7,
4234            &tk7.key_id,
4235            &build_trust,
4236        );
4237        let v = verify_with(&p, &s, &tk7);
4238        let root = UpdatesRoot::open_in(updates_dir.path(), "local").unwrap();
4239        let staged = root.begin(&v, &p, &s).unwrap();
4240        advance_to_staged(&staged).unwrap();
4241
4242        let err = apply_updates_impl(
4243            &store,
4244            &OpFlags::default(),
4245            Some(ApplyUpdatesPayload {
4246                environment_id: "local".into(),
4247                plan_id: "plan-sec".into(),
4248            }),
4249            Some(updates_dir.path()),
4250        )
4251        .unwrap_err();
4252        assert!(matches!(err, OpError::InvalidArgument(m) if m.contains("non-dev-store backend")));
4253        // Refused pre-mutation ⇒ marked Rejected, env untouched.
4254        assert_eq!(
4255            on_disk_stage(updates_dir.path(), "plan-sec"),
4256            UpdateStage::Rejected
4257        );
4258    }
4259
4260    #[test]
4261    fn apply_rejects_endpoints_when_backend_not_dev_store() {
4262        use greentic_update::staging::{UpdateStage, UpdatesRoot};
4263        let dir = tempdir().unwrap();
4264        let updates_dir = tempdir().unwrap();
4265        let store = LocalFsStore::new(dir.path());
4266        let (priv7, tk7) = key_pair(7);
4267        // Telegram-class endpoints auto-provision a webhook secret; under a Vault
4268        // Secrets binding that write escapes the snapshot ⇒ refused fail-closed.
4269        env_trusting_secrets(&store, &tk7, Some(crate::defaults::VAULT_SECRETS_PACK));
4270
4271        let target = json!({
4272            "schema": "greentic.env-manifest.v1",
4273            "environment": {"id": "local"},
4274            "messaging_endpoints": [{"name": "tg", "provider_type": "messaging.telegram.bot"}]
4275        });
4276        let build_trust = TrustRoot::new(vec![tk7.clone()]);
4277        let (p, s) = signed_plan_target(
4278            "local",
4279            "plan-ep",
4280            1,
4281            target,
4282            &priv7,
4283            &tk7.key_id,
4284            &build_trust,
4285        );
4286        let v = verify_with(&p, &s, &tk7);
4287        let root = UpdatesRoot::open_in(updates_dir.path(), "local").unwrap();
4288        let staged = root.begin(&v, &p, &s).unwrap();
4289        advance_to_staged(&staged).unwrap();
4290
4291        let err = apply_updates_impl(
4292            &store,
4293            &OpFlags::default(),
4294            Some(ApplyUpdatesPayload {
4295                environment_id: "local".into(),
4296                plan_id: "plan-ep".into(),
4297            }),
4298            Some(updates_dir.path()),
4299        )
4300        .unwrap_err();
4301        assert!(matches!(err, OpError::InvalidArgument(m) if m.contains("non-dev-store backend")));
4302        assert_eq!(
4303            on_disk_stage(updates_dir.path(), "plan-ep"),
4304            UpdateStage::Rejected
4305        );
4306    }
4307
4308    #[test]
4309    fn apply_rejects_unpinned_bundle() {
4310        use greentic_update::staging::{UpdateStage, UpdatesRoot};
4311        let dir = tempdir().unwrap();
4312        let updates_dir = tempdir().unwrap();
4313        let store = LocalFsStore::new(dir.path());
4314        let (priv7, tk7) = key_pair(7);
4315        env_trusting(&store, &tk7);
4316
4317        // A bundle with no bundle_digest is refused: apply-updates requires
4318        // update-plan bundles to be digest-pinned.
4319        let target = json!({
4320            "schema": "greentic.env-manifest.v1",
4321            "environment": {"id": "local"},
4322            "bundles": [{"bundle_id": "b1", "bundle_path": "/some/local.gtbundle"}]
4323        });
4324        let build_trust = TrustRoot::new(vec![tk7.clone()]);
4325        let (p, s) = signed_plan_target(
4326            "local",
4327            "plan-unpinned",
4328            1,
4329            target,
4330            &priv7,
4331            &tk7.key_id,
4332            &build_trust,
4333        );
4334        let v = verify_with(&p, &s, &tk7);
4335        let root = UpdatesRoot::open_in(updates_dir.path(), "local").unwrap();
4336        let staged = root.begin(&v, &p, &s).unwrap();
4337        advance_to_staged(&staged).unwrap();
4338
4339        let err = apply_updates_impl(
4340            &store,
4341            &OpFlags::default(),
4342            Some(ApplyUpdatesPayload {
4343                environment_id: "local".into(),
4344                plan_id: "plan-unpinned".into(),
4345            }),
4346            Some(updates_dir.path()),
4347        )
4348        .unwrap_err();
4349        assert!(matches!(err, OpError::InvalidArgument(m) if m.contains("bundle_digest")));
4350        assert_eq!(
4351            on_disk_stage(updates_dir.path(), "plan-unpinned"),
4352            UpdateStage::Rejected
4353        );
4354    }
4355
4356    // ---- precise dev-store-secret guard (check_applyable_manifest) ----
4357
4358    fn parse_manifest(v: Value) -> EnvManifest {
4359        serde_json::from_value(v).expect("valid env-manifest")
4360    }
4361
4362    fn secrets_manifest() -> EnvManifest {
4363        parse_manifest(json!({
4364            "schema": "greentic.env-manifest.v1",
4365            "environment": {"id": "local"},
4366            "secrets": [{"path": "acme/_/tls/foo", "from_env": "FOO"}]
4367        }))
4368    }
4369
4370    #[test]
4371    fn guard_accepts_secret_writes_on_dev_store_env() {
4372        // No Secrets binding ⇒ custodial dev-store; no manifest rebind; no
4373        // override; no symlinked candidate ⇒ the writes land in the snapshotted
4374        // dev-store, so both secrets[] and messaging_endpoints[] are applyable.
4375        let env = make_env("local");
4376        let td = tempdir().unwrap();
4377        check_applyable_manifest(&env, td.path(), &secrets_manifest(), false).unwrap();
4378
4379        let endpoints = parse_manifest(json!({
4380            "schema": "greentic.env-manifest.v1",
4381            "environment": {"id": "local"},
4382            "messaging_endpoints": [{"name": "tg", "provider_type": "messaging.telegram.bot"}]
4383        }));
4384        check_applyable_manifest(&env, td.path(), &endpoints, false).unwrap();
4385    }
4386
4387    #[test]
4388    fn guard_rejects_secret_writes_when_env_backend_is_vault() {
4389        let mut env = make_env("local");
4390        env.packs.push(make_binding(
4391            CapabilitySlot::Secrets,
4392            crate::defaults::VAULT_SECRETS_PACK,
4393        ));
4394        let td = tempdir().unwrap();
4395        let err =
4396            check_applyable_manifest(&env, td.path(), &secrets_manifest(), false).unwrap_err();
4397        assert!(matches!(err, OpError::InvalidArgument(m) if m.contains("non-dev-store backend")));
4398    }
4399
4400    #[test]
4401    fn guard_rejects_secret_writes_when_manifest_rebinds_secrets_off_dev_store() {
4402        // Env is dev-store, but the manifest rebinds Secrets → Vault; env_apply
4403        // applies packs[] before secrets[], so the write escapes the snapshot.
4404        let env = make_env("local");
4405        let td = tempdir().unwrap();
4406        let m = parse_manifest(json!({
4407            "schema": "greentic.env-manifest.v1",
4408            "environment": {"id": "local"},
4409            "packs": [{"slot": "secrets", "kind": "greentic.secrets.vault@1.0.0", "pack_ref": "vault"}],
4410            "secrets": [{"path": "acme/_/tls/foo", "from_env": "FOO"}]
4411        }));
4412        let err = check_applyable_manifest(&env, td.path(), &m, false).unwrap_err();
4413        assert!(
4414            matches!(err, OpError::InvalidArgument(msg) if msg.contains("rebinds the Secrets slot"))
4415        );
4416    }
4417
4418    #[test]
4419    fn guard_accepts_manifest_rebinding_secrets_to_dev_store() {
4420        // A same-family (dev-store) rebind is not an escape — the sink stays
4421        // snapshotted.
4422        let env = make_env("local");
4423        let td = tempdir().unwrap();
4424        let m = parse_manifest(json!({
4425            "schema": "greentic.env-manifest.v1",
4426            "environment": {"id": "local"},
4427            "packs": [{"slot": "secrets", "kind": "greentic.secrets.dev-store@1.0.0", "pack_ref": "local"}],
4428            "secrets": [{"path": "acme/_/tls/foo", "from_env": "FOO"}]
4429        }));
4430        check_applyable_manifest(&env, td.path(), &m, false).unwrap();
4431    }
4432
4433    #[test]
4434    fn guard_rejects_secret_writes_under_dev_secrets_path_override() {
4435        // GREENTIC_DEV_SECRETS_PATH redirects the dev-store off the env tree; the
4436        // snapshot can't reach it, so secret writes are refused. Passed as a bool
4437        // because the multithreaded harness cannot set process env vars safely.
4438        let env = make_env("local");
4439        let td = tempdir().unwrap();
4440        let err = check_applyable_manifest(&env, td.path(), &secrets_manifest(), true).unwrap_err();
4441        assert!(
4442            matches!(err, OpError::InvalidArgument(m) if m.contains("GREENTIC_DEV_SECRETS_PATH"))
4443        );
4444    }
4445
4446    #[test]
4447    fn guard_rejects_secret_writes_when_dev_store_file_is_symlinked() {
4448        // A pre-existing symlink where the dev-store file resolves escapes the
4449        // snapshot's rollback (capture follows it; restore's rename-over replaces
4450        // the link, leaving the external target's written secret in place).
4451        use std::os::unix::fs::symlink;
4452        let env = make_env("local");
4453        let td = tempdir().unwrap();
4454        let dev = td.path().join(crate::cli::secrets::DEV_STORE_RELATIVE);
4455        std::fs::create_dir_all(dev.parent().unwrap()).unwrap();
4456        let external = td.path().join("external.env");
4457        std::fs::write(&external, b"x").unwrap();
4458        symlink(&external, &dev).unwrap();
4459        let err =
4460            check_applyable_manifest(&env, td.path(), &secrets_manifest(), false).unwrap_err();
4461        assert!(matches!(err, OpError::InvalidArgument(m) if m.contains("symlink")));
4462    }
4463
4464    #[test]
4465    fn guard_ignores_sink_when_manifest_writes_no_secrets() {
4466        // The sink guard fires only for secrets[]/messaging_endpoints[]. A pinned
4467        // bundle on a Vault-backed env is applyable — it writes no dev-store
4468        // secret material.
4469        let mut env = make_env("local");
4470        env.packs.push(make_binding(
4471            CapabilitySlot::Secrets,
4472            crate::defaults::VAULT_SECRETS_PACK,
4473        ));
4474        let td = tempdir().unwrap();
4475        let m = parse_manifest(json!({
4476            "schema": "greentic.env-manifest.v1",
4477            "environment": {"id": "local"},
4478            "bundles": [{"bundle_id": "b1", "bundle_path": "/x.gtbundle", "bundle_digest": "sha256:aa"}]
4479        }));
4480        check_applyable_manifest(&env, td.path(), &m, false).unwrap();
4481    }
4482
4483    // ---- Phase 3.1: op updates recover ------------------------------------
4484
4485    #[test]
4486    fn recover_schema_only_returns_payload_schema() {
4487        let dir = tempdir().unwrap();
4488        let store = LocalFsStore::new(dir.path());
4489        let out = recover_updates(
4490            &store,
4491            &OpFlags {
4492                schema_only: true,
4493                ..OpFlags::default()
4494            },
4495            None,
4496            false,
4497        )
4498        .unwrap();
4499        assert_eq!(out.op, "recover");
4500        assert_eq!(out.noun, NOUN);
4501        assert!(out.result["properties"]["plan_id"].is_object());
4502    }
4503
4504    #[test]
4505    fn recover_plan_not_found_is_not_found() {
4506        let dir = tempdir().unwrap();
4507        let updates_dir = tempdir().unwrap();
4508        let store = LocalFsStore::new(dir.path());
4509        let (_priv7, tk7) = key_pair(7);
4510        env_trusting(&store, &tk7);
4511        let err = recover_updates_impl(
4512            &store,
4513            &OpFlags::default(),
4514            Some(RecoverUpdatesPayload {
4515                environment_id: "local".into(),
4516                plan_id: "ghost".into(),
4517            }),
4518            true,
4519            Some(updates_dir.path()),
4520        )
4521        .unwrap_err();
4522        assert!(matches!(err, OpError::NotFound(_)));
4523    }
4524
4525    #[test]
4526    fn recover_forces_applying_to_failed_and_audits() {
4527        use greentic_update::staging::{UpdateStage, UpdatesRoot};
4528        let dir = tempdir().unwrap();
4529        let updates_dir = tempdir().unwrap();
4530        let store = LocalFsStore::new(dir.path());
4531        let (priv7, tk7) = key_pair(7);
4532        let env_id = env_trusting(&store, &tk7);
4533        stage_local(updates_dir.path(), "plan-1", 1, &priv7, &tk7);
4534        // Strand the plan in `applying`, as a crashed applier would leave it.
4535        UpdatesRoot::open_in(updates_dir.path(), "local")
4536            .unwrap()
4537            .load("plan-1")
4538            .unwrap()
4539            .unwrap()
4540            .transition(UpdateStage::Applying)
4541            .unwrap();
4542
4543        let out = recover_updates_impl(
4544            &store,
4545            &OpFlags::default(),
4546            Some(RecoverUpdatesPayload {
4547                environment_id: "local".into(),
4548                plan_id: "plan-1".into(),
4549            }),
4550            true,
4551            Some(updates_dir.path()),
4552        )
4553        .unwrap();
4554
4555        assert_eq!(out.op, "recover");
4556        assert_eq!(out.result["previous_stage"], "applying");
4557        assert_eq!(out.result["stage"], "failed");
4558        assert!(out.result["applying_since"].as_str().is_some());
4559
4560        // On-disk FSM marker was force-failed.
4561        assert_eq!(
4562            on_disk_stage(updates_dir.path(), "plan-1"),
4563            UpdateStage::Failed
4564        );
4565        // The recovery was recorded in the deployer audit ledger.
4566        let env_dir = store.env_dir(&env_id).unwrap();
4567        let audit = std::fs::read_to_string(env_dir.join("audit").join("events.jsonl")).unwrap();
4568        assert!(
4569            audit.contains("recover") && audit.contains("plan-1"),
4570            "audit must record the recover: {audit}"
4571        );
4572    }
4573
4574    #[test]
4575    fn recover_refuses_without_force() {
4576        use greentic_update::staging::{UpdateStage, UpdatesRoot};
4577        let dir = tempdir().unwrap();
4578        let updates_dir = tempdir().unwrap();
4579        let store = LocalFsStore::new(dir.path());
4580        let (priv7, tk7) = key_pair(7);
4581        env_trusting(&store, &tk7);
4582        stage_local(updates_dir.path(), "plan-1", 1, &priv7, &tk7);
4583        UpdatesRoot::open_in(updates_dir.path(), "local")
4584            .unwrap()
4585            .load("plan-1")
4586            .unwrap()
4587            .unwrap()
4588            .transition(UpdateStage::Applying)
4589            .unwrap();
4590
4591        let err = recover_updates_impl(
4592            &store,
4593            &OpFlags::default(),
4594            Some(RecoverUpdatesPayload {
4595                environment_id: "local".into(),
4596                plan_id: "plan-1".into(),
4597            }),
4598            false,
4599            Some(updates_dir.path()),
4600        )
4601        .unwrap_err();
4602        assert!(matches!(err, OpError::Conflict(m) if m.contains("--force")));
4603        // Fail-closed: the plan is untouched — still Applying.
4604        assert_eq!(
4605            on_disk_stage(updates_dir.path(), "plan-1"),
4606            UpdateStage::Applying
4607        );
4608    }
4609
4610    #[test]
4611    fn recover_rejects_staged_plan() {
4612        use greentic_update::staging::UpdateStage;
4613        let dir = tempdir().unwrap();
4614        let updates_dir = tempdir().unwrap();
4615        let store = LocalFsStore::new(dir.path());
4616        let (priv7, tk7) = key_pair(7);
4617        env_trusting(&store, &tk7);
4618        stage_local(updates_dir.path(), "plan-1", 1, &priv7, &tk7);
4619
4620        let err = recover_updates_impl(
4621            &store,
4622            &OpFlags::default(),
4623            Some(RecoverUpdatesPayload {
4624                environment_id: "local".into(),
4625                plan_id: "plan-1".into(),
4626            }),
4627            true,
4628            Some(updates_dir.path()),
4629        )
4630        .unwrap_err();
4631        assert!(matches!(err, OpError::InvalidArgument(m) if m.contains("not `applying`")));
4632        assert_eq!(
4633            on_disk_stage(updates_dir.path(), "plan-1"),
4634            UpdateStage::Staged
4635        );
4636    }
4637
4638    #[test]
4639    fn recover_rejects_terminal_plan() {
4640        use greentic_update::staging::UpdateStage;
4641        let dir = tempdir().unwrap();
4642        let updates_dir = tempdir().unwrap();
4643        let store = LocalFsStore::new(dir.path());
4644        let (priv7, tk7) = key_pair(7);
4645        env_trusting(&store, &tk7);
4646        stage_local(updates_dir.path(), "plan-1", 1, &priv7, &tk7);
4647        // Apply to completion ⇒ terminal `applied`.
4648        apply_updates_impl(
4649            &store,
4650            &OpFlags::default(),
4651            Some(ApplyUpdatesPayload {
4652                environment_id: "local".into(),
4653                plan_id: "plan-1".into(),
4654            }),
4655            Some(updates_dir.path()),
4656        )
4657        .unwrap();
4658
4659        let err = recover_updates_impl(
4660            &store,
4661            &OpFlags::default(),
4662            Some(RecoverUpdatesPayload {
4663                environment_id: "local".into(),
4664                plan_id: "plan-1".into(),
4665            }),
4666            true,
4667            Some(updates_dir.path()),
4668        )
4669        .unwrap_err();
4670        assert!(matches!(err, OpError::InvalidArgument(m) if m.contains("terminal")));
4671        assert_eq!(
4672            on_disk_stage(updates_dir.path(), "plan-1"),
4673            UpdateStage::Applied
4674        );
4675    }
4676
4677    // ---- plan-build -------------------------------------------------------
4678
4679    /// Write an ephemeral PKCS#8 Ed25519 private key PEM to `dir/key.pem` and
4680    /// return (path, TrustedKey) so callers can seed the env trust root and pass
4681    /// `--signing-key`.
4682    fn write_ephemeral_key(dir: &std::path::Path) -> (PathBuf, TrustedKey) {
4683        let (priv_pem, tk) = key_pair(42);
4684        let key_path = dir.join("key.pem");
4685        std::fs::write(&key_path, &priv_pem).unwrap();
4686        #[cfg(unix)]
4687        {
4688            use std::os::unix::fs::PermissionsExt;
4689            std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600)).unwrap();
4690        }
4691        (key_path, tk)
4692    }
4693
4694    fn plan_build_args(
4695        env_id: &str,
4696        sequence: u64,
4697        binaries: Vec<String>,
4698        signing_key: Option<PathBuf>,
4699        out_dir: PathBuf,
4700    ) -> crate::cli::dispatch::UpdatesPlanBuildArgs {
4701        crate::cli::dispatch::UpdatesPlanBuildArgs {
4702            env_id: Some(env_id.to_string()),
4703            sequence: Some(sequence),
4704            binaries,
4705            signing_key,
4706            target_file: None,
4707            min_runtime: None,
4708            out_dir: Some(out_dir),
4709        }
4710    }
4711
4712    #[test]
4713    fn parse_binary_spec_happy_path() {
4714        let spec = "name=greentic-start,version=1.1.9,target=x86_64-unknown-linux-gnu,digest=sha256:abc123";
4715        let ba = parse_binary_spec(spec).unwrap();
4716        assert_eq!(ba.name, "greentic-start");
4717        assert_eq!(ba.version, "1.1.9");
4718        assert_eq!(ba.target, "x86_64-unknown-linux-gnu");
4719        assert_eq!(ba.digest, "sha256:abc123");
4720        assert_eq!(ba.source, None);
4721    }
4722
4723    #[test]
4724    fn parse_binary_spec_with_source() {
4725        let spec = "name=greentic-start,version=1.1.9,target=x86_64-unknown-linux-gnu,digest=sha256:abc,source=https://example.com/bin.tar.gz";
4726        let ba = parse_binary_spec(spec).unwrap();
4727        assert_eq!(ba.source.as_deref(), Some("https://example.com/bin.tar.gz"));
4728    }
4729
4730    #[test]
4731    fn parse_binary_spec_missing_required_key() {
4732        // Missing `digest`.
4733        let spec = "name=greentic-start,version=1.1.9,target=x86_64-unknown-linux-gnu";
4734        let err = parse_binary_spec(spec).unwrap_err();
4735        match err {
4736            OpError::InvalidArgument(msg) => assert!(
4737                msg.contains("digest"),
4738                "error should name the missing key `digest`, got: {msg}"
4739            ),
4740            other => panic!("expected InvalidArgument, got {other:?}"),
4741        }
4742    }
4743
4744    #[test]
4745    fn parse_binary_spec_unknown_key() {
4746        let spec = "name=x,version=1,target=t,digest=d,flavor=sweet";
4747        let err = parse_binary_spec(spec).unwrap_err();
4748        match err {
4749            OpError::InvalidArgument(msg) => assert!(
4750                msg.contains("flavor"),
4751                "error should name the unknown key `flavor`, got: {msg}"
4752            ),
4753            other => panic!("expected InvalidArgument, got {other:?}"),
4754        }
4755    }
4756
4757    #[test]
4758    fn parse_binary_spec_source_omitted_is_none() {
4759        let spec = "name=x,version=1,target=t,digest=d";
4760        let ba = parse_binary_spec(spec).unwrap();
4761        assert!(ba.source.is_none());
4762    }
4763
4764    #[test]
4765    fn plan_build_round_trip_verifies() {
4766        let dir = tempdir().unwrap();
4767        let out_dir = tempdir().unwrap();
4768        let store = LocalFsStore::new(dir.path());
4769        let (key_path, tk) = write_ephemeral_key(dir.path());
4770        env_trusting(&store, &tk);
4771
4772        let args = plan_build_args(
4773            "local",
4774            1,
4775            vec![
4776                "name=greentic-start,version=1.1.9,target=x86_64-unknown-linux-gnu,digest=sha256:deadbeef,source=https://example.com/bin.tar.gz".to_string(),
4777            ],
4778            Some(key_path),
4779            out_dir.path().to_path_buf(),
4780        );
4781        let outcome = plan_build(&store, &OpFlags::default(), args).unwrap();
4782        assert_eq!(outcome.noun, NOUN);
4783        assert_eq!(outcome.op, "plan-build");
4784
4785        // Read the emitted files and verify against the env trust root.
4786        let plan_bytes = std::fs::read(out_dir.path().join("plan.json")).unwrap();
4787        let sig_bytes = std::fs::read(out_dir.path().join("plan.json.sig")).unwrap();
4788        let env_dir = store.env_dir(&EnvId::try_from("local").unwrap()).unwrap();
4789        let trust = store_trust_root::load(&env_dir).unwrap();
4790        let verified =
4791            greentic_update::plan::verify_update_plan(&plan_bytes, &sig_bytes, &trust).unwrap();
4792
4793        // The decoded plan binaries must match the input spec.
4794        assert_eq!(verified.plan.binaries.len(), 1);
4795        let b = &verified.plan.binaries[0];
4796        assert_eq!(b.name, "greentic-start");
4797        assert_eq!(b.version, "1.1.9");
4798        assert_eq!(b.target, "x86_64-unknown-linux-gnu");
4799        assert_eq!(b.digest, "sha256:deadbeef");
4800        assert_eq!(b.source.as_deref(), Some("https://example.com/bin.tar.gz"));
4801
4802        // Content artifacts are empty (plan-build is binary-only).
4803        assert!(verified.plan.artifacts.is_empty());
4804        // Sequence matches.
4805        assert_eq!(verified.plan.sequence, 1);
4806    }
4807
4808    #[test]
4809    fn plan_build_fail_closed_untrusted_key() {
4810        let dir = tempdir().unwrap();
4811        let out_dir = tempdir().unwrap();
4812        let store = LocalFsStore::new(dir.path());
4813        // Trust key 7, but sign with key 42.
4814        let (_priv7, tk7) = key_pair(7);
4815        env_trusting(&store, &tk7);
4816        let (key_path_42, _tk42) = write_ephemeral_key(dir.path());
4817
4818        let args = plan_build_args(
4819            "local",
4820            1,
4821            vec!["name=x,version=1,target=t,digest=d".to_string()],
4822            Some(key_path_42),
4823            out_dir.path().to_path_buf(),
4824        );
4825        let err = plan_build(&store, &OpFlags::default(), args).unwrap_err();
4826        assert!(
4827            matches!(err, OpError::Conflict(_)),
4828            "expected Conflict for untrusted key, got {err:?}"
4829        );
4830        // No files written.
4831        assert!(!out_dir.path().join("plan.json").exists());
4832    }
4833
4834    #[test]
4835    fn plan_build_min_runtime_threaded_through() {
4836        let dir = tempdir().unwrap();
4837        let out_dir = tempdir().unwrap();
4838        let store = LocalFsStore::new(dir.path());
4839        let (key_path, tk) = write_ephemeral_key(dir.path());
4840        env_trusting(&store, &tk);
4841
4842        let mut args = plan_build_args(
4843            "local",
4844            1,
4845            vec!["name=x,version=1,target=t,digest=d".to_string()],
4846            Some(key_path),
4847            out_dir.path().to_path_buf(),
4848        );
4849        args.min_runtime = Some("1.1.5".to_string());
4850        let _outcome = plan_build(&store, &OpFlags::default(), args).unwrap();
4851
4852        let plan_bytes = std::fs::read(out_dir.path().join("plan.json")).unwrap();
4853        let sig_bytes = std::fs::read(out_dir.path().join("plan.json.sig")).unwrap();
4854        let env_dir = store.env_dir(&EnvId::try_from("local").unwrap()).unwrap();
4855        let trust = store_trust_root::load(&env_dir).unwrap();
4856        let verified =
4857            greentic_update::plan::verify_update_plan(&plan_bytes, &sig_bytes, &trust).unwrap();
4858        assert_eq!(verified.plan.compat.min_runtime.as_deref(), Some("1.1.5"));
4859    }
4860
4861    #[test]
4862    fn plan_build_target_file_threaded_through() {
4863        let dir = tempdir().unwrap();
4864        let out_dir = tempdir().unwrap();
4865        let store = LocalFsStore::new(dir.path());
4866        let (key_path, tk) = write_ephemeral_key(dir.path());
4867        env_trusting(&store, &tk);
4868
4869        let target_file = dir.path().join("target.json");
4870        std::fs::write(
4871            &target_file,
4872            r#"{"schema":"greentic.env-manifest.v1","environment":{"id":"local"}}"#,
4873        )
4874        .unwrap();
4875
4876        let mut args = plan_build_args(
4877            "local",
4878            1,
4879            vec!["name=x,version=1,target=t,digest=d".to_string()],
4880            Some(key_path),
4881            out_dir.path().to_path_buf(),
4882        );
4883        args.target_file = Some(target_file);
4884        let _outcome = plan_build(&store, &OpFlags::default(), args).unwrap();
4885
4886        let plan_bytes = std::fs::read(out_dir.path().join("plan.json")).unwrap();
4887        let sig_bytes = std::fs::read(out_dir.path().join("plan.json.sig")).unwrap();
4888        let env_dir = store.env_dir(&EnvId::try_from("local").unwrap()).unwrap();
4889        let trust = store_trust_root::load(&env_dir).unwrap();
4890        let verified =
4891            greentic_update::plan::verify_update_plan(&plan_bytes, &sig_bytes, &trust).unwrap();
4892        assert_eq!(
4893            verified.plan.target["environment"]["id"].as_str(),
4894            Some("local")
4895        );
4896    }
4897
4898    #[test]
4899    fn plan_build_content_only_plan_verifies() {
4900        let dir = tempdir().unwrap();
4901        let out_dir = tempdir().unwrap();
4902        let store = LocalFsStore::new(dir.path());
4903        let (key_path, tk) = write_ephemeral_key(dir.path());
4904        env_trusting(&store, &tk);
4905
4906        let target_file = dir.path().join("target.json");
4907        std::fs::write(
4908            &target_file,
4909            r#"{"schema":"greentic.env-manifest.v1","environment":{"id":"local"},
4910                "bundles":[{"bundle_id":"app","bundle_path":"/tmp/app.gtbundle","bundle_digest":"sha256:aa"}]}"#,
4911        )
4912        .unwrap();
4913
4914        // No --binary at all: a content-only plan is the ordinary env-update case.
4915        let mut args = plan_build_args(
4916            "local",
4917            2,
4918            vec![],
4919            Some(key_path),
4920            out_dir.path().to_path_buf(),
4921        );
4922        args.target_file = Some(target_file);
4923        plan_build(&store, &OpFlags::default(), args).unwrap();
4924
4925        let plan_bytes = std::fs::read(out_dir.path().join("plan.json")).unwrap();
4926        let sig_bytes = std::fs::read(out_dir.path().join("plan.json.sig")).unwrap();
4927        let env_dir = store.env_dir(&EnvId::try_from("local").unwrap()).unwrap();
4928        let trust = store_trust_root::load(&env_dir).unwrap();
4929        let verified =
4930            greentic_update::plan::verify_update_plan(&plan_bytes, &sig_bytes, &trust).unwrap();
4931        assert!(verified.plan.binaries.is_empty());
4932        assert_eq!(
4933            verified.plan.target["bundles"][0]["bundle_id"].as_str(),
4934            Some("app")
4935        );
4936    }
4937
4938    #[test]
4939    fn plan_build_rejects_plan_with_neither_target_nor_binary() {
4940        let dir = tempdir().unwrap();
4941        let store = LocalFsStore::new(dir.path());
4942        let (key_path, tk) = write_ephemeral_key(dir.path());
4943        env_trusting(&store, &tk);
4944
4945        let args = plan_build_args("local", 1, vec![], Some(key_path), dir.path().to_path_buf());
4946        let err = plan_build(&store, &OpFlags::default(), args).unwrap_err();
4947        assert!(
4948            matches!(&err, OpError::InvalidArgument(m) if m.contains("--binary") && m.contains("--target-file")),
4949            "unexpected error: {err:?}"
4950        );
4951    }
4952
4953    #[test]
4954    fn plan_build_schema_only() {
4955        let dir = tempdir().unwrap();
4956        let store = LocalFsStore::new(dir.path());
4957        let args = plan_build_args(
4958            "local",
4959            1,
4960            vec!["name=x,version=1,target=t,digest=d".to_string()],
4961            None,
4962            dir.path().to_path_buf(),
4963        );
4964        let out = plan_build(
4965            &store,
4966            &OpFlags {
4967                schema_only: true,
4968                ..OpFlags::default()
4969            },
4970            args,
4971        )
4972        .unwrap();
4973        assert_eq!(out.op, "plan-build");
4974        assert_eq!(out.noun, NOUN);
4975        assert!(out.result["properties"]["sequence"].is_object());
4976    }
4977}