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}