Skip to main content

cli/env/
broker.rs

1//! Local policy store and shared wire snapshots for SSH secret brokering.
2
3use super::workspace;
4use crate::config::Config;
5use anyhow::{Context, Result, bail};
6use serde::{Deserialize, Serialize};
7use sha2::{Digest, Sha256};
8use std::collections::{BTreeMap, BTreeSet};
9use std::path::{Path, PathBuf};
10
11pub const POLICY_VERSION: u32 = 1;
12const POLICY_FILE: &str = "ssh-secret-broker.toml";
13const RESERVED_REMOTE_ENV: &[&str] = &[
14    "SHINE_SSH_SESSION",
15    "SHINE_SSH_TOKEN",
16    "SHINE_SSH_REMOTE_SOCK",
17    "SHINE_TERMINAL_THEME",
18];
19
20#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
21pub struct WorkspaceSnapshot {
22    pub workspace_path: String,
23    pub workspace_contents: String,
24    pub mode: String,
25    pub override_process_env: bool,
26    pub sources: Vec<SourceSnapshot>,
27}
28
29#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
30pub struct SourceSnapshot {
31    /// Path relative to the workspace root where possible, otherwise absolute.
32    pub path: String,
33    pub contents: String,
34}
35
36#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
37pub struct PolicyStore {
38    #[serde(default = "policy_version")]
39    pub version: u32,
40    #[serde(default, rename = "policy")]
41    pub policies: Vec<BrokerPolicy>,
42}
43
44impl Default for PolicyStore {
45    fn default() -> Self {
46        Self {
47            version: POLICY_VERSION,
48            policies: Vec::new(),
49        }
50    }
51}
52
53#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
54pub struct BrokerPolicy {
55    pub name: String,
56    pub ssh_target: String,
57    #[serde(default, skip_serializing_if = "String::is_empty")]
58    pub project: String,
59    pub workspace_sha256: String,
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub remote_workspace: Option<String>,
62    #[serde(default)]
63    pub allow: Vec<BrokerAllow>,
64}
65
66#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
67pub struct BrokerAllow {
68    pub mode: String,
69    pub argv: Vec<String>,
70    pub release: Vec<String>,
71    pub sources: Vec<BrokerSource>,
72}
73
74#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
75pub struct BrokerSource {
76    pub path: String,
77    pub sha256: String,
78    #[serde(default)]
79    pub declared_secrets: Vec<String>,
80}
81
82#[derive(Clone, Debug)]
83pub struct MatchedPolicy {
84    pub policy_name: String,
85    pub project: String,
86    pub release: Vec<String>,
87}
88
89fn policy_version() -> u32 {
90    POLICY_VERSION
91}
92
93pub fn policy_path(config: &Config) -> PathBuf {
94    config.shine_dir().join(POLICY_FILE)
95}
96
97pub fn sha256(bytes: &[u8]) -> String {
98    format!("{:x}", Sha256::digest(bytes))
99}
100
101pub async fn load_store(config: &Config) -> Result<PolicyStore> {
102    load_store_from(&policy_path(config)).await
103}
104
105pub async fn load_stores(config: &Config, overrides: &[PathBuf]) -> Result<PolicyStore> {
106    let mut merged = load_store(config).await?;
107    for path in overrides {
108        let mut extra = load_store_from(path).await?;
109        merged.policies.append(&mut extra.policies);
110    }
111    validate_store(&merged)?;
112    Ok(merged)
113}
114
115pub async fn load_store_from(path: &Path) -> Result<PolicyStore> {
116    validate_policy_file(path, true).await?;
117    let contents = match tokio::fs::read_to_string(path).await {
118        Ok(contents) => contents,
119        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
120            return Ok(PolicyStore {
121                version: POLICY_VERSION,
122                policies: Vec::new(),
123            });
124        }
125        Err(error) => return Err(error).with_context(|| format!("reading {}", path.display())),
126    };
127    let store: PolicyStore =
128        toml::from_str(&contents).with_context(|| format!("parsing {}", path.display()))?;
129    validate_store(&store)?;
130    Ok(store)
131}
132
133async fn save_store(config: &Config, store: &PolicyStore) -> Result<()> {
134    validate_store(store)?;
135    let path = policy_path(config);
136    validate_policy_file(&path, true).await?;
137    let contents = toml::to_string_pretty(store).context("serializing SSH secret broker policy")?;
138    secure_atomic_write(&path, contents.as_bytes()).await?;
139    validate_policy_file(&path, false).await
140}
141
142async fn secure_atomic_write(path: &Path, contents: &[u8]) -> Result<()> {
143    use tokio::io::AsyncWriteExt;
144    let parent = path.parent().unwrap_or_else(|| Path::new("."));
145    tokio::fs::create_dir_all(parent).await?;
146    let temp = parent.join(format!(".shine-broker-write-{}", uuid::Uuid::new_v4()));
147    #[cfg(unix)]
148    let std_file = {
149        use std::os::unix::fs::OpenOptionsExt;
150        std::fs::OpenOptions::new()
151            .write(true)
152            .create_new(true)
153            .mode(0o600)
154            .open(&temp)
155            .with_context(|| format!("creating {}", temp.display()))?
156    };
157    #[cfg(not(unix))]
158    let std_file = std::fs::OpenOptions::new()
159        .write(true)
160        .create_new(true)
161        .open(&temp)
162        .with_context(|| format!("creating {}", temp.display()))?;
163    let mut file = tokio::fs::File::from_std(std_file);
164    if let Err(error) = async {
165        file.write_all(contents).await?;
166        file.sync_all().await
167    }
168    .await
169    {
170        let _ = tokio::fs::remove_file(&temp).await;
171        return Err(error).with_context(|| format!("writing {}", temp.display()));
172    }
173    drop(file);
174    crate::persist::finalize_temp(&temp, path).await
175}
176
177async fn validate_policy_file(path: &Path, allow_missing: bool) -> Result<()> {
178    let metadata = match tokio::fs::symlink_metadata(path).await {
179        Ok(metadata) => metadata,
180        Err(error) if allow_missing && error.kind() == std::io::ErrorKind::NotFound => {
181            return Ok(());
182        }
183        Err(error) => return Err(error).with_context(|| format!("inspecting {}", path.display())),
184    };
185    if metadata.file_type().is_symlink() || !metadata.file_type().is_file() {
186        bail!(
187            "SSH secret broker policy must be a regular file, not a symlink: {}",
188            path.display()
189        );
190    }
191    #[cfg(unix)]
192    {
193        use std::os::unix::fs::MetadataExt;
194        let expected_uid = unsafe { libc::geteuid() };
195        if metadata.uid() != expected_uid {
196            bail!(
197                "SSH secret broker policy is not owned by the current user: {}",
198                path.display()
199            );
200        }
201        if metadata.mode() & 0o077 != 0 {
202            bail!(
203                "SSH secret broker policy permissions are too broad (expected 0600): {}",
204                path.display()
205            );
206        }
207    }
208    Ok(())
209}
210
211fn validate_store(store: &PolicyStore) -> Result<()> {
212    if store.version != POLICY_VERSION {
213        bail!(
214            "unsupported SSH secret broker policy version {}",
215            store.version
216        );
217    }
218    let mut names = BTreeSet::new();
219    let mut selectors = BTreeSet::new();
220    for policy in &store.policies {
221        validate_name(&policy.name, "policy name")?;
222        if !names.insert(policy.name.clone()) {
223            bail!("duplicate SSH secret broker policy name: {}", policy.name);
224        }
225        if policy.ssh_target.trim().is_empty() {
226            bail!("policy {} has an empty ssh_target", policy.name);
227        }
228        validate_wire_string(&policy.ssh_target, "ssh target")?;
229        if let Some(remote_workspace) = &policy.remote_workspace {
230            validate_wire_string(remote_workspace, "remote workspace")?;
231            if !Path::new(remote_workspace).is_absolute() {
232                bail!(
233                    "policy {} remote_workspace must be an absolute path",
234                    policy.name
235                );
236            }
237        }
238        validate_digest(&policy.workspace_sha256)?;
239        if policy.allow.is_empty() {
240            bail!(
241                "policy {} must contain at least one allow entry",
242                policy.name
243            );
244        }
245        for allow in &policy.allow {
246            workspace::validate_broker_mode(&allow.mode)?;
247            if allow.argv.is_empty() {
248                bail!("policy {} contains an empty argv", policy.name);
249            }
250            validate_wire_strings(&allow.argv, "argv")?;
251            validate_release(&allow.release)?;
252            if allow.sources.is_empty() {
253                bail!(
254                    "policy {} contains an allow entry with no sources",
255                    policy.name
256                );
257            }
258            let mut source_paths = BTreeSet::new();
259            for source in &allow.sources {
260                if source.path.is_empty() || !source_paths.insert(source.path.clone()) {
261                    bail!(
262                        "policy {} contains an empty or duplicate source path",
263                        policy.name
264                    );
265                }
266                validate_wire_string(&source.path, "source path")?;
267                validate_digest(&source.sha256)?;
268                validate_release(&source.declared_secrets)?;
269            }
270            if !allow.release.iter().all(|key| {
271                allow
272                    .sources
273                    .iter()
274                    .any(|item| item.declared_secrets.contains(key))
275            }) {
276                bail!(
277                    "policy {} releases a key not declared by its sources",
278                    policy.name
279                );
280            }
281            let selector = format!(
282                "{}\0{}\0{}\0{:?}\0{:?}",
283                policy.ssh_target, policy.workspace_sha256, allow.mode, allow.argv, allow.sources
284            );
285            if !selectors.insert(selector) {
286                bail!("multiple policy entries have the same exact request selector");
287            }
288        }
289    }
290    Ok(())
291}
292
293fn validate_name(value: &str, what: &str) -> Result<()> {
294    if value.is_empty()
295        || !value
296            .chars()
297            .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.'))
298    {
299        bail!("{what} must contain only letters, digits, dots, hyphens, and underscores");
300    }
301    Ok(())
302}
303
304fn validate_digest(value: &str) -> Result<()> {
305    if value.len() != 64 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) {
306        bail!("SHA-256 digest must be 64 hexadecimal characters");
307    }
308    Ok(())
309}
310
311fn validate_release(values: &[String]) -> Result<()> {
312    let mut unique = BTreeSet::new();
313    for value in values {
314        super::validate_env_key(value)?;
315        if RESERVED_REMOTE_ENV.contains(&value.as_str()) {
316            bail!("cannot release into shine-managed SSH variable {value}");
317        }
318        if !unique.insert(value) {
319            bail!("duplicate secret key: {value}");
320        }
321    }
322    Ok(())
323}
324
325pub fn validate_wire_strings(values: &[String], what: &str) -> Result<()> {
326    if values.len() > 128 {
327        bail!("{what} contains too many values");
328    }
329    for value in values {
330        validate_wire_string(value, what)?;
331    }
332    Ok(())
333}
334
335pub fn validate_wire_string(value: &str, what: &str) -> Result<()> {
336    if value.len() > 4096 {
337        bail!("{what} exceeds the 4096-byte limit");
338    }
339    if value
340        .chars()
341        .any(|ch| ch == '\0' || (ch.is_control() && !matches!(ch, '\t')))
342    {
343        bail!("{what} contains a disallowed control character");
344    }
345    Ok(())
346}
347
348#[allow(clippy::too_many_arguments)] // Mirrors one policy's explicit identity fields.
349pub async fn policy_from_workspace(
350    name: &str,
351    ssh_target: &str,
352    project: &str,
353    workspace_path: &Path,
354    remote_workspace: Option<&str>,
355    mode: &str,
356    release: &[String],
357    release_all_declared: bool,
358    argv: &[String],
359) -> Result<BrokerPolicy> {
360    validate_name(name, "policy name")?;
361    if let Some(remote_workspace) = remote_workspace {
362        validate_wire_string(remote_workspace, "remote workspace")?;
363        if !Path::new(remote_workspace).is_absolute() {
364            bail!("remote workspace must be an absolute path");
365        }
366    }
367    let snapshot = workspace::snapshot_for_broker(Some(workspace_path), mode).await?;
368    let release = resolve_release(&snapshot, release, release_all_declared)?;
369    let allow = allow_from_snapshot(&snapshot, &release, argv)?;
370    Ok(BrokerPolicy {
371        name: name.to_string(),
372        ssh_target: ssh_target.to_string(),
373        project: project.to_string(),
374        workspace_sha256: sha256(snapshot.workspace_contents.as_bytes()),
375        remote_workspace: remote_workspace.map(str::to_string),
376        allow: vec![allow],
377    })
378}
379
380pub fn allow_from_snapshot(
381    snapshot: &WorkspaceSnapshot,
382    release: &[String],
383    argv: &[String],
384) -> Result<BrokerAllow> {
385    validate_release(release)?;
386    validate_wire_strings(argv, "argv")?;
387    let mut sources = Vec::with_capacity(snapshot.sources.len());
388    let mut all_declared = BTreeSet::new();
389    for source in &snapshot.sources {
390        let declared_secrets =
391            workspace::declared_secrets_from_source(&source.path, &source.contents)?;
392        all_declared.extend(declared_secrets.iter().cloned());
393        sources.push(BrokerSource {
394            path: source.path.clone(),
395            sha256: sha256(source.contents.as_bytes()),
396            declared_secrets,
397        });
398    }
399    for key in release {
400        if !all_declared.contains(key) {
401            bail!("release key {key} is not declared by the selected workspace sources");
402        }
403    }
404    Ok(BrokerAllow {
405        mode: snapshot.mode.clone(),
406        argv: argv.to_vec(),
407        release: release.to_vec(),
408        sources,
409    })
410}
411
412pub fn resolve_release(
413    snapshot: &WorkspaceSnapshot,
414    requested: &[String],
415    release_all_declared: bool,
416) -> Result<Vec<String>> {
417    if release_all_declared {
418        if !requested.is_empty() {
419            bail!("--release and --release-all-declared are mutually exclusive");
420        }
421        let mut declared = BTreeSet::new();
422        for source in &snapshot.sources {
423            declared.extend(workspace::declared_secrets_from_source(
424                &source.path,
425                &source.contents,
426            )?);
427        }
428        if declared.is_empty() {
429            bail!("--release-all-declared found no declared workspace secrets");
430        }
431        return Ok(declared.into_iter().collect());
432    }
433    if requested.is_empty() {
434        bail!("pass at least one --release KEY or --release-all-declared");
435    }
436    validate_release(requested)?;
437    Ok(requested.to_vec())
438}
439
440pub fn match_workspace_request(
441    store: &PolicyStore,
442    ssh_target: &str,
443    snapshot: &WorkspaceSnapshot,
444    argv: &[String],
445) -> Result<MatchedPolicy> {
446    validate_snapshot(snapshot)?;
447    validate_wire_strings(argv, "argv")?;
448    let workspace_digest = sha256(snapshot.workspace_contents.as_bytes());
449    let actual_sources = snapshot
450        .sources
451        .iter()
452        .map(|source| {
453            Ok(BrokerSource {
454                path: source.path.clone(),
455                sha256: sha256(source.contents.as_bytes()),
456                declared_secrets: workspace::declared_secrets_from_source(
457                    &source.path,
458                    &source.contents,
459                )?,
460            })
461        })
462        .collect::<Result<Vec<_>>>()?;
463
464    let mut matches = Vec::new();
465    for policy in &store.policies {
466        if policy.ssh_target != ssh_target || policy.workspace_sha256 != workspace_digest {
467            continue;
468        }
469        if let Some(expected) = &policy.remote_workspace
470            && expected != &snapshot.workspace_path
471        {
472            continue;
473        }
474        for allow in &policy.allow {
475            if allow.mode == snapshot.mode && allow.argv == argv && allow.sources == actual_sources
476            {
477                matches.push(MatchedPolicy {
478                    policy_name: policy.name.clone(),
479                    project: policy.project.clone(),
480                    release: allow.release.clone(),
481                });
482            }
483        }
484    }
485    match matches.len() {
486        1 => Ok(matches.remove(0)),
487        0 => bail!("no SSH secret broker policy matches this workspace request"),
488        _ => bail!("multiple SSH secret broker policies match this workspace request"),
489    }
490}
491
492pub fn validate_snapshot(snapshot: &WorkspaceSnapshot) -> Result<()> {
493    validate_wire_string(&snapshot.workspace_path, "workspace path")?;
494    validate_wire_string(&snapshot.mode, "mode")?;
495    if snapshot.workspace_contents.len() > 256 * 1024 {
496        bail!("workspace contents exceed the 256 KiB limit");
497    }
498    if snapshot.sources.len() > 64 {
499        bail!("workspace request contains too many sources");
500    }
501    let total = snapshot.sources.iter().try_fold(0usize, |sum, source| {
502        validate_wire_string(&source.path, "source path")?;
503        sum.checked_add(source.contents.len())
504            .context("source size overflow")
505    })?;
506    if total > 768 * 1024 {
507        bail!("workspace source contents exceed the 768 KiB limit");
508    }
509    Ok(())
510}
511
512#[allow(clippy::too_many_arguments)] // Mirrors the explicit policy identity fields at the CLI boundary.
513pub async fn handle_policy_add(
514    config: &Config,
515    name: &str,
516    ssh_target: &str,
517    project: &str,
518    workspace_path: &Path,
519    remote_workspace: Option<&str>,
520    mode: &str,
521    release: &[String],
522    release_all_declared: bool,
523    argv: &[String],
524) -> Result<()> {
525    let policy = policy_from_workspace(
526        name,
527        ssh_target,
528        project,
529        workspace_path,
530        remote_workspace,
531        mode,
532        release,
533        release_all_declared,
534        argv,
535    )
536    .await?;
537    let mut store = load_store(config).await?;
538    if store.policies.iter().any(|item| item.name == name) {
539        bail!("policy {name} already exists; use `shine env broker policy update {name}`");
540    }
541    store.policies.push(policy);
542    save_store(config, &store).await?;
543    println!("added SSH secret broker policy {name}");
544    Ok(())
545}
546
547#[allow(clippy::too_many_arguments)] // Mirrors the explicit policy identity fields at the CLI boundary.
548pub async fn handle_policy_update(
549    config: &Config,
550    name: &str,
551    ssh_target: &str,
552    project: &str,
553    workspace_path: &Path,
554    remote_workspace: Option<&str>,
555    mode: &str,
556    release: &[String],
557    release_all_declared: bool,
558    argv: &[String],
559) -> Result<()> {
560    let policy = policy_from_workspace(
561        name,
562        ssh_target,
563        project,
564        workspace_path,
565        remote_workspace,
566        mode,
567        release,
568        release_all_declared,
569        argv,
570    )
571    .await?;
572    let mut store = load_store(config).await?;
573    let existing = store
574        .policies
575        .iter_mut()
576        .find(|item| item.name == name)
577        .with_context(|| format!("policy {name} does not exist"))?;
578    let old = toml::to_string_pretty(&*existing)?;
579    let new = toml::to_string_pretty(&policy)?;
580    if old == new {
581        println!("policy {name} is current");
582        return Ok(());
583    }
584    println!(
585        "{}",
586        similar::TextDiff::from_lines(&old, &new).unified_diff()
587    );
588    let confirmed = dialoguer::Confirm::new()
589        .with_prompt(format!("Replace SSH secret broker policy {name}?"))
590        .default(false)
591        .interact()
592        .context("reading policy update confirmation")?;
593    if !confirmed {
594        bail!("policy update cancelled");
595    }
596    *existing = policy;
597    save_store(config, &store).await?;
598    println!("updated SSH secret broker policy {name}");
599    Ok(())
600}
601
602pub async fn handle_policy_list(config: &Config) -> Result<()> {
603    let store = load_store(config).await?;
604    if store.policies.is_empty() {
605        println!("No SSH secret broker policies configured.");
606    }
607    for policy in store.policies {
608        println!(
609            "{}: {} ({})",
610            policy.name, policy.ssh_target, policy.project
611        );
612    }
613    Ok(())
614}
615
616pub async fn handle_policy_info(config: &Config, name: &str) -> Result<()> {
617    let store = load_store(config).await?;
618    let policy = store
619        .policies
620        .iter()
621        .find(|item| item.name == name)
622        .with_context(|| format!("policy {name} does not exist"))?;
623    print!("{}", toml::to_string_pretty(policy)?);
624    Ok(())
625}
626
627pub async fn handle_policy_remove(config: &Config, name: &str) -> Result<()> {
628    let mut store = load_store(config).await?;
629    let before = store.policies.len();
630    store.policies.retain(|item| item.name != name);
631    if store.policies.len() == before {
632        bail!("policy {name} does not exist");
633    }
634    save_store(config, &store).await?;
635    println!("removed SSH secret broker policy {name}");
636    Ok(())
637}
638
639pub async fn handle_policy_diff(
640    config: &Config,
641    name: &str,
642    workspace_path: &Path,
643    mode: &str,
644    release: &[String],
645    release_all_declared: bool,
646    argv: &[String],
647) -> Result<()> {
648    let store = load_store(config).await?;
649    let existing = store
650        .policies
651        .iter()
652        .find(|item| item.name == name)
653        .with_context(|| format!("policy {name} does not exist"))?;
654    let candidate = policy_from_workspace(
655        name,
656        &existing.ssh_target,
657        &existing.project,
658        workspace_path,
659        existing.remote_workspace.as_deref(),
660        mode,
661        release,
662        release_all_declared,
663        argv,
664    )
665    .await?;
666    let old = toml::to_string_pretty(existing)?;
667    let new = toml::to_string_pretty(&candidate)?;
668    if old == new {
669        println!("policy {name} is current");
670    } else {
671        println!(
672            "{}",
673            similar::TextDiff::from_lines(&old, &new).unified_diff()
674        );
675    }
676    Ok(())
677}
678
679pub async fn handle_describe(
680    workspace_path: Option<&Path>,
681    mode: &str,
682    release: &[String],
683    release_all_declared: bool,
684    argv: &[String],
685) -> Result<()> {
686    let snapshot = workspace::snapshot_for_broker(workspace_path, mode).await?;
687    let release = resolve_release(&snapshot, release, release_all_declared)?;
688    let allow = allow_from_snapshot(&snapshot, &release, argv)?;
689    if crate::ssh::broker_session_available() {
690        let summary = crate::ssh::describe_broker_workspace(snapshot, &release, argv).await?;
691        println!("{summary}");
692        return Ok(());
693    }
694    print_description(&snapshot, &allow);
695    Ok(())
696}
697
698fn print_description(snapshot: &WorkspaceSnapshot, allow: &BrokerAllow) {
699    println!(
700        "workspace_sha256 = \"{}\"",
701        sha256(snapshot.workspace_contents.as_bytes())
702    );
703    println!("mode = {:?}", allow.mode);
704    println!("argv = {:?}", allow.argv);
705    println!("release = {:?}", allow.release);
706    for source in &allow.sources {
707        println!(
708            "source {} {} {:?}",
709            source.path, source.sha256, source.declared_secrets
710        );
711    }
712}
713
714#[derive(Clone, Debug)]
715pub struct RemoteEnrollmentPlan {
716    pub name: String,
717    pub candidate: BrokerPolicy,
718    pub previous: Option<BrokerPolicy>,
719}
720
721impl RemoteEnrollmentPlan {
722    pub fn action_label(&self) -> String {
723        if self.previous.is_some() {
724            format!("update local policy {}", self.name)
725        } else {
726            format!("create local policy {}", self.name)
727        }
728    }
729
730    pub fn diff(&self) -> Result<Option<String>> {
731        let Some(previous) = &self.previous else {
732            return Ok(None);
733        };
734        let old = toml::to_string_pretty(previous)?;
735        let new = toml::to_string_pretty(&self.candidate)?;
736        Ok(Some(
737            similar::TextDiff::from_lines(&old, &new)
738                .unified_diff()
739                .to_string(),
740        ))
741    }
742}
743
744pub fn plan_remote_enrollment(
745    store: &PolicyStore,
746    ssh_target: &str,
747    snapshot: &WorkspaceSnapshot,
748    release: &[String],
749    argv: &[String],
750    update_policy: Option<&str>,
751) -> Result<RemoteEnrollmentPlan> {
752    let allow = allow_from_snapshot(snapshot, release, argv)?;
753    if let Some(name) = update_policy {
754        validate_name(name, "policy name")?;
755        let existing = store
756            .policies
757            .iter()
758            .find(|policy| policy.name == name)
759            .with_context(|| format!("policy {name} does not exist"))?;
760        if existing.ssh_target != ssh_target {
761            bail!(
762                "policy {name} targets {}, not the current SSH target {ssh_target}",
763                existing.ssh_target
764            );
765        }
766        if let Some(expected) = &existing.remote_workspace
767            && expected != &snapshot.workspace_path
768        {
769            bail!(
770                "policy {name} requires remote workspace {expected}, but the request came from {}",
771                snapshot.workspace_path
772            );
773        }
774        let matching = existing
775            .allow
776            .iter()
777            .enumerate()
778            .filter(|(_, item)| item.mode == snapshot.mode && item.argv == argv)
779            .map(|(index, _)| index)
780            .collect::<Vec<_>>();
781        let [index] = matching.as_slice() else {
782            bail!(
783                "policy {name} must contain exactly one allow entry with mode {} and the requested argv before it can be updated from remote metadata",
784                snapshot.mode
785            );
786        };
787        let mut candidate = existing.clone();
788        candidate.workspace_sha256 = sha256(snapshot.workspace_contents.as_bytes());
789        candidate.allow[*index] = allow;
790        let mut proposed = store.clone();
791        let target = proposed
792            .policies
793            .iter_mut()
794            .find(|policy| policy.name == name)
795            .expect("existing policy was cloned");
796        *target = candidate.clone();
797        validate_store(&proposed)?;
798        return Ok(RemoteEnrollmentPlan {
799            name: name.to_string(),
800            candidate,
801            previous: Some(existing.clone()),
802        });
803    }
804
805    let project = Path::new(&snapshot.workspace_path)
806        .parent()
807        .and_then(Path::file_name)
808        .and_then(|value| value.to_str())
809        .unwrap_or("project");
810    let program = argv.first().map(String::as_str).unwrap_or("command");
811    let raw_name = format!("{ssh_target}-{project}-{}-{program}", snapshot.mode);
812    let name = raw_name
813        .chars()
814        .map(|ch| {
815            if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
816                ch
817            } else {
818                '-'
819            }
820        })
821        .collect::<String>();
822    validate_name(&name, "generated policy name")?;
823    let policy = BrokerPolicy {
824        name: name.clone(),
825        ssh_target: ssh_target.to_string(),
826        project: project.to_string(),
827        workspace_sha256: sha256(snapshot.workspace_contents.as_bytes()),
828        remote_workspace: None,
829        allow: vec![allow],
830    };
831    if store.policies.iter().any(|item| item.name == name) {
832        bail!("policy {name} already exists; inspect or update it explicitly");
833    }
834    let mut proposed = store.clone();
835    proposed.policies.push(policy.clone());
836    validate_store(&proposed)?;
837    Ok(RemoteEnrollmentPlan {
838        name,
839        candidate: policy,
840        previous: None,
841    })
842}
843
844pub async fn apply_remote_enrollment(
845    config: &Config,
846    plan: &RemoteEnrollmentPlan,
847) -> Result<String> {
848    let mut store = load_store(config).await?;
849    if let Some(previous) = &plan.previous {
850        let current = store
851            .policies
852            .iter_mut()
853            .find(|policy| policy.name == plan.name)
854            .with_context(|| format!("policy {} no longer exists", plan.name))?;
855        if current != previous {
856            bail!(
857                "policy {} changed after the enrollment preview; inspect and retry",
858                plan.name
859            );
860        }
861        *current = plan.candidate.clone();
862    } else {
863        if store.policies.iter().any(|item| item.name == plan.name) {
864            bail!(
865                "policy {} was created after the enrollment preview; inspect it explicitly",
866                plan.name
867            );
868        }
869        store.policies.push(plan.candidate.clone());
870    }
871    save_store(config, &store).await?;
872    Ok(plan.name.clone())
873}
874
875pub fn decrypt_workspace_snapshot<'a>(
876    config: &'a Config,
877    snapshot: &'a WorkspaceSnapshot,
878    release: &'a [String],
879) -> impl std::future::Future<Output = Result<BTreeMap<String, String>>> + 'a {
880    workspace::decrypt_broker_snapshot(config, snapshot, release)
881}
882
883#[cfg(test)]
884mod tests {
885    use super::*;
886
887    fn snapshot() -> WorkspaceSnapshot {
888        WorkspaceSnapshot {
889            workspace_path: std::env::temp_dir()
890                .join("srv/api/shine.workspace.toml")
891                .to_string_lossy()
892                .into_owned(),
893            workspace_contents: "version = 1\n[env]\nfiles = [\"env/development.toml\"]\n".into(),
894            mode: "development".into(),
895            override_process_env: false,
896            sources: vec![SourceSnapshot {
897                path: "env/development.toml".into(),
898                contents: "version = 1\n[plain]\nPUBLIC = \"x\"\n[secret]\nAPI_TOKEN = true\nNPM_TOKEN = true\n[payload]\ndata = \"ciphertext\"\n".into(),
899            }],
900        }
901    }
902
903    #[test]
904    fn allow_separates_declared_keys_from_release_subset() {
905        let snapshot = snapshot();
906        let allow = allow_from_snapshot(
907            &snapshot,
908            &["API_TOKEN".into()],
909            &["bun".into(), "test".into()],
910        )
911        .unwrap();
912
913        assert_eq!(allow.release, ["API_TOKEN"]);
914        assert_eq!(
915            allow.sources[0].declared_secrets,
916            ["API_TOKEN", "NPM_TOKEN"]
917        );
918    }
919
920    #[test]
921    fn all_declared_release_expands_to_a_stable_explicit_list() {
922        let snapshot = snapshot();
923        let release = resolve_release(&snapshot, &[], true).unwrap();
924        assert_eq!(release, ["API_TOKEN", "NPM_TOKEN"]);
925
926        assert!(resolve_release(&snapshot, &["API_TOKEN".into()], true).is_err());
927        assert!(resolve_release(&snapshot, &[], false).is_err());
928    }
929
930    #[test]
931    fn trusted_remote_update_replaces_one_allow_and_preserves_policy_identity() {
932        let original = snapshot();
933        let allow = allow_from_snapshot(
934            &original,
935            &["API_TOKEN".into()],
936            &["bun".into(), "test".into()],
937        )
938        .unwrap();
939        let store = PolicyStore {
940            version: POLICY_VERSION,
941            policies: vec![BrokerPolicy {
942                name: "dev-api".into(),
943                ssh_target: "dev".into(),
944                project: "friendly-api-name".into(),
945                workspace_sha256: sha256(original.workspace_contents.as_bytes()),
946                remote_workspace: Some(original.workspace_path.clone()),
947                allow: vec![allow],
948            }],
949        };
950        let mut changed = original.clone();
951        changed
952            .workspace_contents
953            .push_str("# new workspace revision\n");
954        changed.sources[0].contents = changed.sources[0]
955            .contents
956            .replace("[payload]", "NEW_TOKEN = true\n[payload]");
957        let release = resolve_release(&changed, &[], true).unwrap();
958
959        let plan = plan_remote_enrollment(
960            &store,
961            "dev",
962            &changed,
963            &release,
964            &["bun".into(), "test".into()],
965            Some("dev-api"),
966        )
967        .unwrap();
968
969        assert!(plan.previous.is_some());
970        assert_eq!(plan.candidate.project, "friendly-api-name");
971        assert_eq!(
972            plan.candidate.remote_workspace.as_deref(),
973            Some(original.workspace_path.as_str())
974        );
975        assert_eq!(
976            plan.candidate.allow[0].release,
977            ["API_TOKEN", "NEW_TOKEN", "NPM_TOKEN"]
978        );
979        assert!(plan.diff().unwrap().unwrap().contains("NEW_TOKEN"));
980        assert!(
981            plan_remote_enrollment(
982                &store,
983                "other-host",
984                &changed,
985                &release,
986                &["bun".into(), "test".into()],
987                Some("dev-api"),
988            )
989            .is_err()
990        );
991    }
992
993    #[tokio::test]
994    async fn trusted_remote_update_applies_once_and_rejects_a_stale_preview() {
995        let dir = crate::test_support::make_temp_dir("shine-broker-remote-update").await;
996        let config = Config::new_for_test(&dir);
997        let original = snapshot();
998        let allow = allow_from_snapshot(
999            &original,
1000            &["API_TOKEN".into()],
1001            &["bun".into(), "test".into()],
1002        )
1003        .unwrap();
1004        let store = PolicyStore {
1005            version: POLICY_VERSION,
1006            policies: vec![BrokerPolicy {
1007                name: "dev-api".into(),
1008                ssh_target: "dev".into(),
1009                project: "api".into(),
1010                workspace_sha256: sha256(original.workspace_contents.as_bytes()),
1011                remote_workspace: None,
1012                allow: vec![allow],
1013            }],
1014        };
1015        save_store(&config, &store).await.unwrap();
1016
1017        let mut changed = original;
1018        changed.sources[0].contents = changed.sources[0]
1019            .contents
1020            .replace("[payload]", "NEW_TOKEN = true\n[payload]");
1021        let release = resolve_release(&changed, &[], true).unwrap();
1022        let plan = plan_remote_enrollment(
1023            &store,
1024            "dev",
1025            &changed,
1026            &release,
1027            &["bun".into(), "test".into()],
1028            Some("dev-api"),
1029        )
1030        .unwrap();
1031
1032        apply_remote_enrollment(&config, &plan).await.unwrap();
1033        let loaded = load_store(&config).await.unwrap();
1034        assert_eq!(loaded.policies[0], plan.candidate);
1035        assert!(apply_remote_enrollment(&config, &plan).await.is_err());
1036        tokio::fs::remove_dir_all(dir).await.unwrap();
1037    }
1038
1039    #[test]
1040    fn exact_policy_match_rejects_changed_argv_or_source() {
1041        let snapshot = snapshot();
1042        let allow = allow_from_snapshot(
1043            &snapshot,
1044            &["API_TOKEN".into()],
1045            &["bun".into(), "test".into()],
1046        )
1047        .unwrap();
1048        let store = PolicyStore {
1049            version: POLICY_VERSION,
1050            policies: vec![BrokerPolicy {
1051                name: "dev-api".into(),
1052                ssh_target: "dev".into(),
1053                project: "api".into(),
1054                workspace_sha256: sha256(snapshot.workspace_contents.as_bytes()),
1055                remote_workspace: None,
1056                allow: vec![allow],
1057            }],
1058        };
1059
1060        let matched =
1061            match_workspace_request(&store, "dev", &snapshot, &["bun".into(), "test".into()])
1062                .unwrap();
1063        assert_eq!(matched.release, ["API_TOKEN"]);
1064
1065        assert!(
1066            match_workspace_request(
1067                &store,
1068                "dev",
1069                &snapshot,
1070                &["bun".into(), "run".into(), "test".into()],
1071            )
1072            .is_err()
1073        );
1074        let mut changed = snapshot.clone();
1075        changed.sources[0].contents.push_str("# changed\n");
1076        assert!(
1077            match_workspace_request(&store, "dev", &changed, &["bun".into(), "test".into()],)
1078                .is_err()
1079        );
1080    }
1081
1082    #[test]
1083    fn wire_display_fields_reject_control_characters_and_limits() {
1084        assert!(validate_wire_string("safe", "field").is_ok());
1085        assert!(validate_wire_string("evil\u{1b}[2J", "field").is_err());
1086        assert!(validate_wire_string(&"x".repeat(4097), "field").is_err());
1087        assert!(validate_wire_strings(&vec!["x".into(); 129], "argv").is_err());
1088    }
1089
1090    #[tokio::test]
1091    async fn policy_store_is_written_with_private_permissions() {
1092        let dir = crate::test_support::make_temp_dir("shine-broker-policy").await;
1093        let config = Config::new_for_test(&dir);
1094        let snapshot = snapshot();
1095        let allow = allow_from_snapshot(
1096            &snapshot,
1097            &["API_TOKEN".into()],
1098            &["bun".into(), "test".into()],
1099        )
1100        .unwrap();
1101        let store = PolicyStore {
1102            version: POLICY_VERSION,
1103            policies: vec![BrokerPolicy {
1104                name: "dev-api".into(),
1105                ssh_target: "dev".into(),
1106                project: "api".into(),
1107                workspace_sha256: sha256(snapshot.workspace_contents.as_bytes()),
1108                remote_workspace: None,
1109                allow: vec![allow],
1110            }],
1111        };
1112        save_store(&config, &store).await.unwrap();
1113        let loaded = load_store(&config).await.unwrap();
1114        assert_eq!(loaded, store);
1115        #[cfg(unix)]
1116        {
1117            use std::os::unix::fs::PermissionsExt;
1118            let mode = tokio::fs::metadata(policy_path(&config))
1119                .await
1120                .unwrap()
1121                .permissions()
1122                .mode();
1123            assert_eq!(mode & 0o777, 0o600);
1124        }
1125        tokio::fs::remove_dir_all(&dir).await.unwrap();
1126    }
1127
1128    #[tokio::test]
1129    async fn additional_local_policy_files_are_merged_and_validated() {
1130        let dir = crate::test_support::make_temp_dir("shine-broker-policy-merge").await;
1131        let config = Config::new_for_test(&dir);
1132        let snapshot = snapshot();
1133        let allow = allow_from_snapshot(
1134            &snapshot,
1135            &["API_TOKEN".into()],
1136            &["bun".into(), "test".into()],
1137        )
1138        .unwrap();
1139        let extra = PolicyStore {
1140            version: POLICY_VERSION,
1141            policies: vec![BrokerPolicy {
1142                name: "extra-api".into(),
1143                ssh_target: "dev".into(),
1144                project: "api".into(),
1145                workspace_sha256: sha256(snapshot.workspace_contents.as_bytes()),
1146                remote_workspace: None,
1147                allow: vec![allow],
1148            }],
1149        };
1150        let path = dir.join("extra.toml");
1151        tokio::fs::write(&path, toml::to_string(&extra).unwrap())
1152            .await
1153            .unwrap();
1154        #[cfg(unix)]
1155        {
1156            use std::os::unix::fs::PermissionsExt;
1157            tokio::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
1158                .await
1159                .unwrap();
1160        }
1161
1162        let merged = load_stores(&config, std::slice::from_ref(&path))
1163            .await
1164            .unwrap();
1165        assert_eq!(merged.policies.len(), 1);
1166        assert_eq!(merged.policies[0].name, "extra-api");
1167        tokio::fs::remove_dir_all(&dir).await.unwrap();
1168    }
1169
1170    #[cfg(unix)]
1171    #[tokio::test]
1172    async fn policy_store_rejects_symlink_and_broad_permissions() {
1173        use std::os::unix::fs::{PermissionsExt, symlink};
1174        let dir = crate::test_support::make_temp_dir("shine-broker-policy-safety").await;
1175        let config = Config::new_for_test(&dir);
1176        let path = policy_path(&config);
1177        tokio::fs::create_dir_all(path.parent().unwrap())
1178            .await
1179            .unwrap();
1180        let target = dir.join("real-policy.toml");
1181        tokio::fs::write(&target, "version = 1\n").await.unwrap();
1182        symlink(&target, &path).unwrap();
1183        assert!(
1184            load_store(&config)
1185                .await
1186                .unwrap_err()
1187                .to_string()
1188                .contains("symlink")
1189        );
1190        tokio::fs::remove_file(&path).await.unwrap();
1191        tokio::fs::write(&path, "version = 1\n").await.unwrap();
1192        tokio::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644))
1193            .await
1194            .unwrap();
1195        assert!(
1196            load_store(&config)
1197                .await
1198                .unwrap_err()
1199                .to_string()
1200                .contains("too broad")
1201        );
1202        tokio::fs::remove_dir_all(&dir).await.unwrap();
1203    }
1204}