shine-cli 2.1.1

Give personal automation a reviewable lifecycle
Documentation
use crate::{config::Config, core_runtime};
use anyhow::{Context, Result, bail};
use shine_core::persist::atomic_write_private;
use shine_core::trust::{
    TRUST_STORE_SCHEMA_VERSION, TrustGrantV1, TrustRequirementV1, TrustStoreV1, evaluate_trust,
};
use std::io::IsTerminal;
use std::path::{Path, PathBuf};

const TRUST_STORE_FILE: &str = "trust.toml";

pub(crate) async fn load_store(config: &Config) -> Result<TrustStoreV1> {
    load_store_path(&trust_store_path(config)).await
}

pub async fn handle_list(config: &Config) -> Result<()> {
    let store = load_store(config).await?;
    if store.grants.is_empty() {
        println!("No external-code trust grants.");
        return Ok(());
    }
    for grant in store.grants {
        println!(
            "{}\t{}\t{}",
            grant.target,
            grant.capability.as_str(),
            short_digest(&grant.code_digest.as_hex())
        );
    }
    Ok(())
}

pub async fn handle_inspect(config: &Config, target: &str) -> Result<()> {
    let runtime = core_runtime::from_config(config).await?;
    let report = runtime.external_code_requirements(target).await?;
    if report.requirements.is_empty() {
        println!("{target} has no external executable-code requirements.");
        return Ok(());
    }
    for requirement in &report.requirements {
        render_requirement(
            requirement,
            evaluate_trust(&runtime.context().trust_grants, requirement),
        );
    }
    Ok(())
}

pub async fn handle_grant(config: &Config, target: &str, yes: bool) -> Result<()> {
    let runtime = core_runtime::from_config(config).await?;
    let report = runtime.external_code_requirements(target).await?;
    if report.requirements.is_empty() {
        bail!("{target} has no external executable code to trust");
    }
    validate_grant_requirements(target, &report.requirements)?;
    for requirement in &report.requirements {
        render_requirement(
            requirement,
            evaluate_trust(&runtime.context().trust_grants, requirement),
        );
    }
    if !yes {
        if !(std::io::stdin().is_terminal() && std::io::stdout().is_terminal()) {
            bail!("trust enrollment requires an interactive terminal or explicit --yes");
        }
        if !dialoguer::Confirm::new()
            .with_prompt("Trust this target's current external code?")
            .default(false)
            .interact()?
        {
            bail!("external-code trust was not granted");
        }
    }
    let mut store = load_store(config).await?;
    for requirement in report.requirements {
        store.grants.retain(|grant| {
            grant.target != requirement.target || grant.capability != requirement.capability
        });
        store
            .grants
            .push(TrustGrantV1::for_reviewed_requirement(&requirement));
    }
    store.grants.sort_by(|left, right| {
        (&left.target, left.capability.as_str()).cmp(&(&right.target, right.capability.as_str()))
    });
    save_store(config, &store).await?;
    println!("Trusted current external code for {target}.");
    Ok(())
}

fn validate_grant_requirements(target: &str, requirements: &[TrustRequirementV1]) -> Result<()> {
    if requirements
        .iter()
        .any(|requirement| !requirement.permissions_declared)
    {
        bail!(
            "{target} external code has no valid permission declaration; fix and validate the Preset before granting trust"
        );
    }
    Ok(())
}

pub async fn handle_revoke(config: &Config, target: &str) -> Result<()> {
    validate_target(target)?;
    let mut store = load_store(config).await?;
    let before = store.grants.len();
    store.grants.retain(|grant| grant.target != target);
    if store.grants.len() == before {
        println!("No external-code trust grants matched {target}.");
        return Ok(());
    }
    save_store(config, &store).await?;
    println!("Revoked external-code trust for {target}.");
    Ok(())
}

async fn load_store_path(path: &Path) -> Result<TrustStoreV1> {
    match tokio::fs::symlink_metadata(path).await {
        Ok(metadata) => {
            if metadata.file_type().is_symlink() || !metadata.is_file() {
                bail!("trust store must be a regular file: {}", path.display());
            }
            #[cfg(unix)]
            {
                use std::os::unix::fs::PermissionsExt;
                if metadata.permissions().mode() & 0o077 != 0 {
                    bail!(
                        "trust store permissions are too broad; expected 0600: {}",
                        path.display()
                    );
                }
            }
        }
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
            return Ok(TrustStoreV1::default());
        }
        Err(error) => return Err(error).with_context(|| format!("inspecting {}", path.display())),
    }
    let contents = tokio::fs::read_to_string(path).await?;
    let store: TrustStoreV1 = toml::from_str(&contents)
        .with_context(|| format!("parsing trust store {}", path.display()))?;
    if store.schema_version != TRUST_STORE_SCHEMA_VERSION {
        bail!(
            "unsupported trust store schema version {}",
            store.schema_version
        );
    }
    Ok(store)
}

async fn save_store(config: &Config, store: &TrustStoreV1) -> Result<()> {
    let encoded = toml::to_string_pretty(store).context("serializing trust store")?;
    atomic_write_private(&trust_store_path(config), encoded.as_bytes()).await
}

fn trust_store_path(config: &Config) -> PathBuf {
    config.shine_dir().join(TRUST_STORE_FILE)
}

fn validate_target(target: &str) -> Result<()> {
    let valid_prefix = target.starts_with("app/") || target.starts_with("sys/");
    let suffix = target
        .split_once('/')
        .map(|(_, suffix)| suffix)
        .unwrap_or_default();
    if !valid_prefix
        || suffix.is_empty()
        || suffix.contains(['/', '\\'])
        || suffix == "."
        || suffix == ".."
    {
        bail!("trust target must be canonical app/<category> or sys/<item>: {target}");
    }
    Ok(())
}

fn render_requirement(
    requirement: &TrustRequirementV1,
    decision: shine_core::trust::TrustDecisionV1,
) {
    println!("External code trust:");
    println!("  Target:      {}", requirement.target);
    println!("  Capability:  {}", requirement.capability.as_str());
    println!("  Code digest: {}", requirement.code_digest.as_hex());
    println!("  Permissions:");
    if requirement.permissions.is_empty() {
        println!("    none");
    } else {
        for permission in requirement.permissions.iter() {
            println!("    {permission:?}");
        }
    }
    println!("  Status:      {}", decision.code());
}

fn short_digest(digest: &str) -> &str {
    digest.get(..12).unwrap_or(digest)
}

#[cfg(test)]
pub(crate) async fn grant_current_for_test(config: &Config, target: &str) {
    let runtime = core_runtime::from_config(config).await.unwrap();
    let report = runtime.external_code_requirements(target).await.unwrap();
    let mut store = load_store(config).await.unwrap();
    for requirement in report.requirements {
        store.grants.retain(|grant| {
            grant.target != requirement.target || grant.capability != requirement.capability
        });
        store
            .grants
            .push(TrustGrantV1::for_reviewed_requirement(&requirement));
    }
    save_store(config, &store).await.unwrap();
}

#[cfg(test)]
mod tests {
    use super::*;
    use shine_core::plan::{PermissionSetV1, SnapshotDigestV1};
    use shine_core::trust::TrustCapabilityV1;

    fn requirement(permissions_declared: bool) -> TrustRequirementV1 {
        TrustRequirementV1 {
            target: "sys/package-only".to_string(),
            capability: TrustCapabilityV1::SysProfileCode,
            code_digest: SnapshotDigestV1::builder("code").finish(),
            permissions_declared,
            permissions: PermissionSetV1::default(),
        }
    }

    #[test]
    fn trust_targets_must_be_canonical_and_target_local() {
        assert!(validate_target("app/demo").is_ok());
        assert!(validate_target("sys/mise").is_ok());
        assert!(validate_target("demo").is_err());
        assert!(validate_target("app/demo/other").is_err());
    }

    #[test]
    fn explicit_empty_permission_declaration_is_grantable() {
        assert!(validate_grant_requirements("sys/package-only", &[requirement(true)]).is_ok());
    }

    #[test]
    fn missing_permission_declaration_remains_ungrantable() {
        let error =
            validate_grant_requirements("sys/package-only", &[requirement(false)]).unwrap_err();
        assert!(
            error
                .to_string()
                .contains("no valid permission declaration")
        );
    }

    #[cfg(unix)]
    #[tokio::test]
    async fn trust_store_rejects_broad_permissions() {
        use std::os::unix::fs::PermissionsExt;

        let dir = crate::test_support::make_temp_dir("shine-trust-store").await;
        let path = dir.join(TRUST_STORE_FILE);
        tokio::fs::write(
            &path,
            toml::to_string_pretty(&TrustStoreV1::default()).unwrap(),
        )
        .await
        .unwrap();
        tokio::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644))
            .await
            .unwrap();

        assert!(load_store_path(&path).await.is_err());
        tokio::fs::remove_dir_all(dir).await.unwrap();
    }
}