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    validate_grant_requirements(target, &report.requirements)?;
56    for requirement in &report.requirements {
57        render_requirement(
58            requirement,
59            evaluate_trust(&runtime.context().trust_grants, requirement),
60        );
61    }
62    if !yes {
63        if !(std::io::stdin().is_terminal() && std::io::stdout().is_terminal()) {
64            bail!("trust enrollment requires an interactive terminal or explicit --yes");
65        }
66        if !dialoguer::Confirm::new()
67            .with_prompt("Trust this target's current external code?")
68            .default(false)
69            .interact()?
70        {
71            bail!("external-code trust was not granted");
72        }
73    }
74    let mut store = load_store(config).await?;
75    for requirement in report.requirements {
76        store.grants.retain(|grant| {
77            grant.target != requirement.target || grant.capability != requirement.capability
78        });
79        store
80            .grants
81            .push(TrustGrantV1::for_reviewed_requirement(&requirement));
82    }
83    store.grants.sort_by(|left, right| {
84        (&left.target, left.capability.as_str()).cmp(&(&right.target, right.capability.as_str()))
85    });
86    save_store(config, &store).await?;
87    println!("Trusted current external code for {target}.");
88    Ok(())
89}
90
91fn validate_grant_requirements(target: &str, requirements: &[TrustRequirementV1]) -> Result<()> {
92    if requirements
93        .iter()
94        .any(|requirement| !requirement.permissions_declared)
95    {
96        bail!(
97            "{target} external code has no valid permission declaration; fix and validate the Preset before granting trust"
98        );
99    }
100    Ok(())
101}
102
103pub async fn handle_revoke(config: &Config, target: &str) -> Result<()> {
104    validate_target(target)?;
105    let mut store = load_store(config).await?;
106    let before = store.grants.len();
107    store.grants.retain(|grant| grant.target != target);
108    if store.grants.len() == before {
109        println!("No external-code trust grants matched {target}.");
110        return Ok(());
111    }
112    save_store(config, &store).await?;
113    println!("Revoked external-code trust for {target}.");
114    Ok(())
115}
116
117async fn load_store_path(path: &Path) -> Result<TrustStoreV1> {
118    match tokio::fs::symlink_metadata(path).await {
119        Ok(metadata) => {
120            if metadata.file_type().is_symlink() || !metadata.is_file() {
121                bail!("trust store must be a regular file: {}", path.display());
122            }
123            #[cfg(unix)]
124            {
125                use std::os::unix::fs::PermissionsExt;
126                if metadata.permissions().mode() & 0o077 != 0 {
127                    bail!(
128                        "trust store permissions are too broad; expected 0600: {}",
129                        path.display()
130                    );
131                }
132            }
133        }
134        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
135            return Ok(TrustStoreV1::default());
136        }
137        Err(error) => return Err(error).with_context(|| format!("inspecting {}", path.display())),
138    }
139    let contents = tokio::fs::read_to_string(path).await?;
140    let store: TrustStoreV1 = toml::from_str(&contents)
141        .with_context(|| format!("parsing trust store {}", path.display()))?;
142    if store.schema_version != TRUST_STORE_SCHEMA_VERSION {
143        bail!(
144            "unsupported trust store schema version {}",
145            store.schema_version
146        );
147    }
148    Ok(store)
149}
150
151async fn save_store(config: &Config, store: &TrustStoreV1) -> Result<()> {
152    let encoded = toml::to_string_pretty(store).context("serializing trust store")?;
153    atomic_write_private(&trust_store_path(config), encoded.as_bytes()).await
154}
155
156fn trust_store_path(config: &Config) -> PathBuf {
157    config.shine_dir().join(TRUST_STORE_FILE)
158}
159
160fn validate_target(target: &str) -> Result<()> {
161    let valid_prefix = target.starts_with("app/") || target.starts_with("sys/");
162    let suffix = target
163        .split_once('/')
164        .map(|(_, suffix)| suffix)
165        .unwrap_or_default();
166    if !valid_prefix
167        || suffix.is_empty()
168        || suffix.contains(['/', '\\'])
169        || suffix == "."
170        || suffix == ".."
171    {
172        bail!("trust target must be canonical app/<category> or sys/<item>: {target}");
173    }
174    Ok(())
175}
176
177fn render_requirement(
178    requirement: &TrustRequirementV1,
179    decision: shine_core::trust::TrustDecisionV1,
180) {
181    println!("External code trust:");
182    println!("  Target:      {}", requirement.target);
183    println!("  Capability:  {}", requirement.capability.as_str());
184    println!("  Code digest: {}", requirement.code_digest.as_hex());
185    println!("  Permissions:");
186    if requirement.permissions.is_empty() {
187        println!("    none");
188    } else {
189        for permission in requirement.permissions.iter() {
190            println!("    {permission:?}");
191        }
192    }
193    println!("  Status:      {}", decision.code());
194}
195
196fn short_digest(digest: &str) -> &str {
197    digest.get(..12).unwrap_or(digest)
198}
199
200#[cfg(test)]
201pub(crate) async fn grant_current_for_test(config: &Config, target: &str) {
202    let runtime = core_runtime::from_config(config).await.unwrap();
203    let report = runtime.external_code_requirements(target).await.unwrap();
204    let mut store = load_store(config).await.unwrap();
205    for requirement in report.requirements {
206        store.grants.retain(|grant| {
207            grant.target != requirement.target || grant.capability != requirement.capability
208        });
209        store
210            .grants
211            .push(TrustGrantV1::for_reviewed_requirement(&requirement));
212    }
213    save_store(config, &store).await.unwrap();
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219    use shine_core::plan::{PermissionSetV1, SnapshotDigestV1};
220    use shine_core::trust::TrustCapabilityV1;
221
222    fn requirement(permissions_declared: bool) -> TrustRequirementV1 {
223        TrustRequirementV1 {
224            target: "sys/package-only".to_string(),
225            capability: TrustCapabilityV1::SysProfileCode,
226            code_digest: SnapshotDigestV1::builder("code").finish(),
227            permissions_declared,
228            permissions: PermissionSetV1::default(),
229        }
230    }
231
232    #[test]
233    fn trust_targets_must_be_canonical_and_target_local() {
234        assert!(validate_target("app/demo").is_ok());
235        assert!(validate_target("sys/mise").is_ok());
236        assert!(validate_target("demo").is_err());
237        assert!(validate_target("app/demo/other").is_err());
238    }
239
240    #[test]
241    fn explicit_empty_permission_declaration_is_grantable() {
242        assert!(validate_grant_requirements("sys/package-only", &[requirement(true)]).is_ok());
243    }
244
245    #[test]
246    fn missing_permission_declaration_remains_ungrantable() {
247        let error =
248            validate_grant_requirements("sys/package-only", &[requirement(false)]).unwrap_err();
249        assert!(
250            error
251                .to_string()
252                .contains("no valid permission declaration")
253        );
254    }
255
256    #[cfg(unix)]
257    #[tokio::test]
258    async fn trust_store_rejects_broad_permissions() {
259        use std::os::unix::fs::PermissionsExt;
260
261        let dir = crate::test_support::make_temp_dir("shine-trust-store").await;
262        let path = dir.join(TRUST_STORE_FILE);
263        tokio::fs::write(
264            &path,
265            toml::to_string_pretty(&TrustStoreV1::default()).unwrap(),
266        )
267        .await
268        .unwrap();
269        tokio::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644))
270            .await
271            .unwrap();
272
273        assert!(load_store_path(&path).await.is_err());
274        tokio::fs::remove_dir_all(dir).await.unwrap();
275    }
276}