heddle-cli 0.20.3

An AI-native version control system
// SPDX-License-Identifier: Apache-2.0
//! `heddle env` — broker-backed confidential-runtime profiles.

use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};

use anyhow::{Context, Result, anyhow};
use crypto::Signer;
use repo::Repository;
use env_store::{DecryptPurpose, DecryptRequest, PolicyBroker, EnvStore, SlotWrite};
use serde::Serialize;

use super::advice::RecoveryAdvice;
use super::next_action::{NextActionValidationContext, write_full_command_json};
use crate::cli::{
    Cli, EnvCommands, EnvCreateArgs, EnvListArgs, EnvRunArgs, should_output_json,
};

pub fn cmd_env(cli: &Cli, command: EnvCommands) -> Result<()> {
    let repo = cli.open_repo()?;
    match command {
        EnvCommands::Create(args) => cmd_env_create(cli, &repo, args),
        EnvCommands::List(_) => cmd_env_list(cli, &repo, EnvListArgs {}),
        EnvCommands::Run(args) => cmd_env_run(&repo, args),
    }
}

#[derive(Serialize)]
struct EnvCreateOutput {
    output_kind: &'static str,
    profile: String,
    slots: Vec<String>,
}

#[derive(Serialize)]
struct EnvListOutput {
    output_kind: &'static str,
    profiles: Vec<EnvProfileRow>,
}

#[derive(Serialize)]
struct EnvProfileRow {
    name: String,
    slots: Vec<String>,
    lifecycle: String,
}

fn cmd_env_create(cli: &Cli, repo: &Repository, args: EnvCreateArgs) -> Result<()> {
    let store = EnvStore::open(repo.heddle_dir()).map_err(map_profile_error)?;
    let signer = require_signer(repo)?;
    let attribution = repo
        .get_attribution()
        .context("resolve current attribution")?;
    let recipient = store
        .default_or_create_software_recipient(&signer)
        .map_err(map_profile_error)?;
    let mut slots = Vec::new();
    for name in &args.from_env {
        let value = std::env::var(name).map_err(|_| {
            anyhow!(RecoveryAdvice::safety_refusal(
                "env_store_slot_not_found",
                format!("environment variable {name} is not set"),
                format!("Export `{name}` in this process, then retry `heddle env create`."),
                format!("{name} was not present in the creating process"),
                "no runtime profile would be written",
                "the worktree and existing profiles were left unchanged",
                "heddle env list",
                vec!["heddle env list".to_string()],
            ))
        })?;
        slots.push(SlotWrite {
            name: name.clone(),
            value: value.into_bytes(),
        });
    }
    let slot_names: Vec<String> = slots.iter().map(|slot| slot.name.clone()).collect();
    let profile = store
        .create_profile(
            &args.name,
            slots,
            recipient.recipient_id,
            attribution,
            &signer,
        )
        .map_err(map_profile_error)?;
    if should_output_json(cli, Some(repo.config())) {
        return write_full_command_json(
            &EnvCreateOutput {
                output_kind: "env_create",
                profile: profile.name,
                slots: slot_names,
            },
            NextActionValidationContext::new(&["env", "create"], repo.capability()),
        );
    }
    eprintln!(
        "created {} ({})",
        profile.name,
        if slot_names.is_empty() {
            "no slots".to_string()
        } else {
            slot_names.join(" ")
        }
    );
    Ok(())
}

fn cmd_env_list(cli: &Cli, repo: &Repository, _args: EnvListArgs) -> Result<()> {
    let store = EnvStore::open(repo.heddle_dir()).map_err(map_profile_error)?;
    let profiles = store.list_profiles().map_err(map_profile_error)?;
    if should_output_json(cli, Some(repo.config())) {
        return write_full_command_json(
            &EnvListOutput {
                output_kind: "env_list",
                profiles: profiles
                    .iter()
                    .map(|profile| EnvProfileRow {
                        name: profile.name.clone(),
                        slots: profile.slot_names.clone(),
                        lifecycle: profile.lifecycle.to_string(),
                    })
                    .collect(),
            },
            NextActionValidationContext::new(&["env", "list"], repo.capability()),
        );
    }
    if profiles.is_empty() {
        eprintln!("no runtime profiles");
        return Ok(());
    }
    for profile in profiles {
        eprintln!(
            "{}  {}  ({})",
            profile.name,
            profile.slot_names.join(" "),
            profile.lifecycle
        );
    }
    Ok(())
}

fn cmd_env_run(repo: &Repository, args: EnvRunArgs) -> Result<()> {
    if args.command.is_empty() {
        return Err(anyhow!("env run requires a child command after `--`"));
    }
    let store = EnvStore::open(repo.heddle_dir()).map_err(map_profile_error)?;
    let signer = require_signer(repo)?;
    let attribution = repo
        .get_attribution()
        .context("resolve current attribution")?;
    let mut broker = PolicyBroker::new(store, attribution);
    broker
        .hold_profile_recipients(&args.profile)
        .map_err(map_profile_error)?;
    let now = now_ms()?;
    let ttl_ms = i64::try_from(args.ttl.saturating_mul(1000))
        .map_err(|_| anyhow!("ttl overflow"))?;
    let request = DecryptRequest {
        profile: args.profile.clone(),
        slots: args.slots.clone(),
        expires_at_ms: now.saturating_add(ttl_ms),
        purpose: DecryptPurpose::Run,
        caller: "heddle-env-run".to_string(),
    };
    let grant = broker
        .authorize(&request, &signer)
        .map_err(map_profile_error)?;
    let secrets = broker
        .unwrap_for_run(grant, &signer)
        .map_err(map_profile_error)?;
    let pairs = secrets.into_env_pairs().map_err(map_profile_error)?;
    let mut child = Command::new(&args.command[0]);
    child.args(&args.command[1..]);
    child.current_dir(repo.root());
    for (name, value) in pairs {
        child.env(name, value);
    }
    let status = child.status().context("spawn env run child")?;
    match status.code() {
        Some(0) => Ok(()),
        Some(code) => std::process::exit(code),
        None => Err(anyhow!("child was terminated by a signal")),
    }
}

fn require_signer(repo: &Repository) -> Result<Box<dyn Signer>> {
    let local = repo.heddle_dir().join(repo::identity::LOCAL_IDENTITY_FILE);
    let device = repo::identity::device_identity_path();
    repo::identity::resolve_signer(&local, &device).ok_or_else(|| {
        anyhow!("runtime profiles require a protected local signing identity")
    })
}

fn now_ms() -> Result<i64> {
    let duration = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .context("system clock before epoch")?;
    i64::try_from(duration.as_millis()).context("timestamp overflow")
}

fn map_profile_error(err: env_store::EnvStoreError) -> anyhow::Error {
    let message = err.to_string();
    match err {
        env_store::EnvStoreError::BrokerDenied(
            env_store::BrokerDenialReason::Expired,
        ) => {
            anyhow!(RecoveryAdvice::safety_refusal(
                "env_store_expired",
                message,
                "Retry `heddle env run --profile <name> -- <cmd>`. Use a larger --ttl only if authorize itself is slow.",
                "the broker request or grant was past expires_at",
                "no child would start and no plaintext would be written",
                "the worktree and store were left unchanged",
                "heddle env run --profile <name> -- <cmd>",
                vec!["heddle env run --profile <name> -- <cmd>".to_string()],
            ))
        }
        env_store::EnvStoreError::BrokerDenied(_)
        | env_store::EnvStoreError::InvalidGrant(_) => anyhow!(
            RecoveryAdvice::safety_refusal(
                "env_store_denied",
                message,
                "Ask for a named profile and slots this broker holds, then retry `heddle env run`.",
                "the broker refused an unauthorized, expired, or unscoped request",
                "no plaintext would be returned",
                "the worktree and store were left unchanged",
                "heddle env list",
                vec!["heddle env list".to_string()],
            )
        ),
        env_store::EnvStoreError::ProfileNotFound(_) => anyhow!(
            RecoveryAdvice::safety_refusal(
                "env_store_not_found",
                message,
                "Create the profile with `heddle env create --name <name> --from-env SLOT`, then retry.",
                "no runtime profile with that name exists",
                "no child would start",
                "the worktree and store were left unchanged",
                "heddle env create --name <name> --from-env SLOT",
                vec!["heddle env create --name <name> --from-env SLOT".to_string()],
            )
        ),
        env_store::EnvStoreError::SlotNotFound(_) => anyhow!(
            RecoveryAdvice::safety_refusal(
                "env_store_slot_not_found",
                message,
                "List slot names with `heddle env list`, then pass a slot that exists.",
                "the named slot is not on the profile head",
                "no child would start",
                "the worktree and store were left unchanged",
                "heddle env list",
                vec!["heddle env list".to_string()],
            )
        ),
        other => anyhow!(other),
    }
}