Skip to main content

cli/
trust.rs

1use crate::{config::Config, core_runtime};
2use anyhow::{Context, Result, bail};
3use shine_core::persist::atomic_write_private;
4use shine_core::trust::{
5    TRUST_STORE_SCHEMA_VERSION, TrustGrantV1, TrustRequirementV1, TrustStoreV1, evaluate_trust,
6};
7use std::io::IsTerminal;
8use std::path::{Path, PathBuf};
9
10const TRUST_STORE_FILE: &str = "trust.toml";
11
12pub(crate) async fn load_store(config: &Config) -> Result<TrustStoreV1> {
13    load_store_path(&trust_store_path(config)).await
14}
15
16pub async fn handle_list(config: &Config) -> Result<()> {
17    let store = load_store(config).await?;
18    if store.grants.is_empty() {
19        println!("No external-code trust grants.");
20        return Ok(());
21    }
22    for grant in store.grants {
23        println!(
24            "{}\t{}\t{}",
25            grant.target,
26            grant.capability.as_str(),
27            short_digest(&grant.code_digest.as_hex())
28        );
29    }
30    Ok(())
31}
32
33pub async fn handle_inspect(config: &Config, target: &str) -> Result<()> {
34    let runtime = core_runtime::from_config(config).await?;
35    let report = runtime.external_code_requirements(target).await?;
36    if report.requirements.is_empty() {
37        println!("{target} has no external executable-code requirements.");
38        return Ok(());
39    }
40    for requirement in &report.requirements {
41        render_requirement(
42            requirement,
43            evaluate_trust(&runtime.context().trust_grants, requirement),
44        );
45    }
46    Ok(())
47}
48
49pub async fn handle_grant(config: &Config, target: &str, yes: bool) -> Result<()> {
50    let runtime = core_runtime::from_config(config).await?;
51    let report = runtime.external_code_requirements(target).await?;
52    if report.requirements.is_empty() {
53        bail!("{target} has no external executable code to trust");
54    }
55    if report
56        .requirements
57        .iter()
58        .any(|requirement| requirement.permissions.is_empty())
59    {
60        bail!(
61            "{target} external code has no valid permission declaration; fix and validate the Preset before granting trust"
62        );
63    }
64    for requirement in &report.requirements {
65        render_requirement(
66            requirement,
67            evaluate_trust(&runtime.context().trust_grants, requirement),
68        );
69    }
70    if !yes {
71        if !(std::io::stdin().is_terminal() && std::io::stdout().is_terminal()) {
72            bail!("trust enrollment requires an interactive terminal or explicit --yes");
73        }
74        if !dialoguer::Confirm::new()
75            .with_prompt("Trust this target's current external code?")
76            .default(false)
77            .interact()?
78        {
79            bail!("external-code trust was not granted");
80        }
81    }
82    let mut store = load_store(config).await?;
83    for requirement in report.requirements {
84        store.grants.retain(|grant| {
85            grant.target != requirement.target || grant.capability != requirement.capability
86        });
87        store
88            .grants
89            .push(TrustGrantV1::for_reviewed_requirement(&requirement));
90    }
91    store.grants.sort_by(|left, right| {
92        (&left.target, left.capability.as_str()).cmp(&(&right.target, right.capability.as_str()))
93    });
94    save_store(config, &store).await?;
95    println!("Trusted current external code for {target}.");
96    Ok(())
97}
98
99pub async fn handle_revoke(config: &Config, target: &str) -> Result<()> {
100    validate_target(target)?;
101    let mut store = load_store(config).await?;
102    let before = store.grants.len();
103    store.grants.retain(|grant| grant.target != target);
104    if store.grants.len() == before {
105        println!("No external-code trust grants matched {target}.");
106        return Ok(());
107    }
108    save_store(config, &store).await?;
109    println!("Revoked external-code trust for {target}.");
110    Ok(())
111}
112
113async fn load_store_path(path: &Path) -> Result<TrustStoreV1> {
114    match tokio::fs::symlink_metadata(path).await {
115        Ok(metadata) => {
116            if metadata.file_type().is_symlink() || !metadata.is_file() {
117                bail!("trust store must be a regular file: {}", path.display());
118            }
119            #[cfg(unix)]
120            {
121                use std::os::unix::fs::PermissionsExt;
122                if metadata.permissions().mode() & 0o077 != 0 {
123                    bail!(
124                        "trust store permissions are too broad; expected 0600: {}",
125                        path.display()
126                    );
127                }
128            }
129        }
130        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
131            return Ok(TrustStoreV1::default());
132        }
133        Err(error) => return Err(error).with_context(|| format!("inspecting {}", path.display())),
134    }
135    let contents = tokio::fs::read_to_string(path).await?;
136    let store: TrustStoreV1 = toml::from_str(&contents)
137        .with_context(|| format!("parsing trust store {}", path.display()))?;
138    if store.schema_version != TRUST_STORE_SCHEMA_VERSION {
139        bail!(
140            "unsupported trust store schema version {}",
141            store.schema_version
142        );
143    }
144    Ok(store)
145}
146
147async fn save_store(config: &Config, store: &TrustStoreV1) -> Result<()> {
148    let encoded = toml::to_string_pretty(store).context("serializing trust store")?;
149    atomic_write_private(&trust_store_path(config), encoded.as_bytes()).await
150}
151
152fn trust_store_path(config: &Config) -> PathBuf {
153    config.shine_dir().join(TRUST_STORE_FILE)
154}
155
156fn validate_target(target: &str) -> Result<()> {
157    let valid_prefix = target.starts_with("app/") || target.starts_with("sys/");
158    let suffix = target
159        .split_once('/')
160        .map(|(_, suffix)| suffix)
161        .unwrap_or_default();
162    if !valid_prefix
163        || suffix.is_empty()
164        || suffix.contains(['/', '\\'])
165        || suffix == "."
166        || suffix == ".."
167    {
168        bail!("trust target must be canonical app/<category> or sys/<item>: {target}");
169    }
170    Ok(())
171}
172
173fn render_requirement(
174    requirement: &TrustRequirementV1,
175    decision: shine_core::trust::TrustDecisionV1,
176) {
177    println!("External code trust:");
178    println!("  Target:      {}", requirement.target);
179    println!("  Capability:  {}", requirement.capability.as_str());
180    println!("  Code digest: {}", requirement.code_digest.as_hex());
181    println!("  Permissions:");
182    if requirement.permissions.is_empty() {
183        println!("    none");
184    } else {
185        for permission in requirement.permissions.iter() {
186            println!("    {permission:?}");
187        }
188    }
189    println!("  Status:      {}", decision.code());
190}
191
192fn short_digest(digest: &str) -> &str {
193    digest.get(..12).unwrap_or(digest)
194}
195
196#[cfg(test)]
197pub(crate) async fn grant_current_for_test(config: &Config, target: &str) {
198    let runtime = core_runtime::from_config(config).await.unwrap();
199    let report = runtime.external_code_requirements(target).await.unwrap();
200    let mut store = load_store(config).await.unwrap();
201    for requirement in report.requirements {
202        store.grants.retain(|grant| {
203            grant.target != requirement.target || grant.capability != requirement.capability
204        });
205        store
206            .grants
207            .push(TrustGrantV1::for_reviewed_requirement(&requirement));
208    }
209    save_store(config, &store).await.unwrap();
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215
216    #[test]
217    fn trust_targets_must_be_canonical_and_target_local() {
218        assert!(validate_target("app/demo").is_ok());
219        assert!(validate_target("sys/mise").is_ok());
220        assert!(validate_target("demo").is_err());
221        assert!(validate_target("app/demo/other").is_err());
222    }
223
224    #[cfg(unix)]
225    #[tokio::test]
226    async fn trust_store_rejects_broad_permissions() {
227        use std::os::unix::fs::PermissionsExt;
228
229        let dir = crate::test_support::make_temp_dir("shine-trust-store").await;
230        let path = dir.join(TRUST_STORE_FILE);
231        tokio::fs::write(
232            &path,
233            toml::to_string_pretty(&TrustStoreV1::default()).unwrap(),
234        )
235        .await
236        .unwrap();
237        tokio::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644))
238            .await
239            .unwrap();
240
241        assert!(load_store_path(&path).await.is_err());
242        tokio::fs::remove_dir_all(dir).await.unwrap();
243    }
244}