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}