Skip to main content

cli/env/
proxy.rs

1//! PATH shims that inject a deliberately small allow-list of shine env values.
2
3use super::{EnvConfig, parse_env_specs, resolve_stored_value};
4use crate::config::{Config, EnvProxyRule};
5use crate::{persist::atomic_write, secret, shell_quote};
6use anyhow::{Context, Result, bail};
7use serde::{Deserialize, Serialize};
8use std::{
9    collections::BTreeMap,
10    ffi::OsString,
11    path::{Path, PathBuf},
12};
13use tokio::process::Command;
14
15const MARKER: &str = "shine-env-proxy";
16
17#[derive(Default, Serialize, Deserialize)]
18struct ProxyManifest {
19    entries: BTreeMap<String, PathBuf>,
20}
21
22pub async fn install(config: &Config, command: &str, with: &[String], project: bool) -> Result<()> {
23    validate_command(command)?;
24    parse_env_specs(with)?;
25    if project && !config.is_project_config() {
26        bail!("--project requires a shine.config.toml in the current directory or an ancestor");
27    }
28    let target = find_target(command, config.bin_dir())?;
29    let path = if project {
30        config.config_path()
31    } else {
32        &config.shine_dir().join("config.toml")
33    };
34    install_shim(config.bin_dir(), command, &target).await?;
35    upsert_rule(
36        path,
37        EnvProxyRule {
38            command: command.into(),
39            with: with.to_vec(),
40            enabled: true,
41        },
42    )
43    .await?;
44    let mut manifest = load_manifest(config.shine_dir()).await?;
45    manifest.entries.insert(command.into(), target.clone());
46    save_manifest(config.shine_dir(), &manifest).await?;
47    println!(
48        "installed transparent proxy {command} -> {}",
49        target.display()
50    );
51    Ok(())
52}
53
54pub async fn list(config: &Config) -> Result<()> {
55    if config.env_proxy.is_empty() {
56        println!("No transparent command proxies configured.");
57    }
58    for rule in &config.env_proxy {
59        println!(
60            "{}: {} ({})",
61            rule.command,
62            rule.with.join(", "),
63            if rule.enabled { "enabled" } else { "disabled" }
64        );
65    }
66    Ok(())
67}
68
69pub async fn set_enabled(
70    config: &Config,
71    command: &str,
72    enabled: bool,
73    project: bool,
74) -> Result<()> {
75    validate_command(command)?;
76    if project && !config.is_project_config() {
77        bail!("--project requires a shine.config.toml in the current directory or an ancestor");
78    }
79    let global_path = config.shine_dir().join("config.toml");
80    let path = if project {
81        config.config_path()
82    } else {
83        &global_path
84    };
85    let inherited = config
86        .env_proxy
87        .iter()
88        .find(|rule| rule.command == command)
89        .cloned();
90    mutate_rules(path, |rules| {
91        if let Some(rule) = rules.iter_mut().find(|rule| rule.command == command) {
92            rule.enabled = enabled;
93        } else if project {
94            let mut rule = inherited.with_context(|| {
95                format!("{command} is not configured as an env proxy in the active configuration")
96            })?;
97            rule.enabled = enabled;
98            rules.push(rule);
99        } else {
100            bail!(
101                "{command} is not configured as an env proxy in {}",
102                path.display()
103            );
104        }
105        Ok(())
106    })
107    .await?;
108    println!(
109        "{} transparent proxy {command}",
110        if enabled { "enabled" } else { "disabled" }
111    );
112    Ok(())
113}
114
115pub async fn uninstall(config: &Config, command: &str) -> Result<()> {
116    validate_command(command)?;
117    let path = config.shine_dir().join("config.toml");
118    remove_rule(&path, command).await?;
119    let mut manifest = load_manifest(config.shine_dir()).await?;
120    let shim = config.bin_dir().join(command);
121    if shim.is_file() {
122        let body = tokio::fs::read_to_string(&shim).await.unwrap_or_default();
123        if body.contains(MARKER) {
124            tokio::fs::remove_file(&shim).await?;
125        } else {
126            bail!(
127                "refusing to remove {}: it is not a shine env proxy",
128                shim.display()
129            );
130        }
131    }
132    #[cfg(windows)]
133    for ext in ["cmd", "ps1"] {
134        let candidate = config.bin_dir().join(format!("{command}.{ext}"));
135        if candidate.is_file() {
136            let body = tokio::fs::read_to_string(&candidate)
137                .await
138                .unwrap_or_default();
139            if body.contains(MARKER) {
140                tokio::fs::remove_file(candidate).await?;
141            }
142        }
143    }
144    manifest.entries.remove(command);
145    save_manifest(config.shine_dir(), &manifest).await?;
146    println!("removed transparent proxy {command}");
147    Ok(())
148}
149
150pub async fn exec(config: &Config, target: &Path, command: &str, args: &[OsString]) -> Result<()> {
151    let rule = config
152        .env_proxy
153        .iter()
154        .find(|rule| rule.command == command)
155        .with_context(|| {
156            format!("{command} is not configured as a transparent env proxy in the active config")
157        })?;
158    if !target.is_file() {
159        bail!(
160            "proxy target {} no longer exists; rerun `shine env proxy install {command} --with ...`",
161            target.display()
162        );
163    }
164    if !rule.enabled {
165        return run_target(target, args, BTreeMap::new()).await;
166    }
167    let env = EnvConfig::load_or_init(config).await?;
168    let mut injected = BTreeMap::new();
169    for spec in parse_env_specs(&rule.with)? {
170        let value = match resolve_stored_value(&env, &spec.source)? {
171            super::StoredValue::Secret { key, value } => secret::decrypt_with_config(value, config)
172                .await
173                .with_context(|| format!("decrypting {key}"))?,
174            super::StoredValue::Plaintext(value) => value.to_string(),
175        };
176        injected.insert(spec.target, value);
177    }
178    run_target(target, args, injected).await
179}
180
181async fn run_target(
182    target: &Path,
183    args: &[OsString],
184    injected: BTreeMap<String, String>,
185) -> Result<()> {
186    let status = Command::new(target)
187        .args(args)
188        .envs(injected)
189        .status()
190        .await
191        .with_context(|| format!("running proxy target {}", target.display()))?;
192    if status.success() {
193        return Ok(());
194    }
195    std::process::exit(status.code().unwrap_or(1));
196}
197
198fn validate_command(command: &str) -> Result<()> {
199    if command.is_empty()
200        || !command
201            .chars()
202            .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.'))
203        || command == "."
204        || command == ".."
205    {
206        bail!("proxy command must be a bare command name: {command}");
207    }
208    Ok(())
209}
210
211fn find_target(command: &str, shine_bin: &Path) -> Result<PathBuf> {
212    let paths = std::env::var_os("PATH").context("PATH is not set")?;
213    for dir in std::env::split_paths(&paths) {
214        if dir == shine_bin {
215            continue;
216        }
217        let candidate = dir.join(command);
218        if candidate.is_file() {
219            // Do not canonicalize here. Cargo (and other rustup proxies) are
220            // symlinks whose filename is their dispatch identity: resolving
221            // `.../cargo` to `.../rustup` makes rustup see `argv[0] == rustup`
222            // and reject Cargo's arguments. Keep the executable path exactly
223            // as PATH selected it, merely making relative PATH segments absolute.
224            return absolute_path(candidate);
225        }
226        #[cfg(windows)]
227        {
228            let candidate = dir.join(format!("{command}.exe"));
229            if candidate.is_file() {
230                return absolute_path(candidate);
231            }
232        }
233    }
234    bail!(
235        "{command} is not installed on PATH outside {}",
236        shine_bin.display()
237    )
238}
239
240fn absolute_path(path: PathBuf) -> Result<PathBuf> {
241    if path.is_absolute() {
242        Ok(path)
243    } else {
244        Ok(std::env::current_dir()
245            .context("reading current directory")?
246            .join(path))
247    }
248}
249
250async fn install_shim(bin_dir: &Path, command: &str, target: &Path) -> Result<()> {
251    tokio::fs::create_dir_all(bin_dir).await?;
252    let path = bin_dir.join(command);
253    if path.exists() {
254        let body = tokio::fs::read_to_string(&path).await.unwrap_or_default();
255        if !body.contains(MARKER) {
256            bail!(
257                "{} already exists and is not a shine env proxy",
258                path.display()
259            );
260        }
261    }
262    let target_string = target.to_string_lossy().into_owned();
263    let target = shell_quote::single_quote(&target_string);
264    let command_q = shell_quote::single_quote(command);
265    atomic_write(&path, format!("#!/bin/sh\n# {MARKER}\nexec shine env proxy exec --target {target} {command_q} \"$@\"\n").as_bytes()).await?;
266    #[cfg(unix)]
267    {
268        use std::os::unix::fs::PermissionsExt;
269        tokio::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).await?;
270    }
271    #[cfg(windows)]
272    {
273        install_windows_shims(bin_dir, command, &target_string).await?;
274    }
275    Ok(())
276}
277
278#[cfg(windows)]
279async fn install_windows_shims(bin_dir: &Path, command: &str, target: &str) -> Result<()> {
280    atomic_write(&bin_dir.join(format!("{command}.cmd")), format!("@echo off\r\nREM {MARKER}\r\nshine env proxy exec --target \"{target}\" {command} %*\r\n").as_bytes()).await?;
281    let target_ps = target.replace('\'', "''");
282    atomic_write(&bin_dir.join(format!("{command}.ps1")), format!("# {MARKER}\n& shine env proxy exec --target '{target_ps}' {command} @args\nexit $LASTEXITCODE\n").as_bytes()).await
283}
284
285async fn upsert_rule(path: &Path, rule: EnvProxyRule) -> Result<()> {
286    mutate_rules(path, |rules| {
287        rules.retain(|r| r.command != rule.command);
288        rules.push(rule);
289        Ok(())
290    })
291    .await
292}
293async fn remove_rule(path: &Path, command: &str) -> Result<()> {
294    mutate_rules(path, |rules| {
295        rules.retain(|r| r.command != command);
296        Ok(())
297    })
298    .await
299}
300async fn mutate_rules(
301    path: &Path,
302    change: impl FnOnce(&mut Vec<EnvProxyRule>) -> Result<()>,
303) -> Result<()> {
304    let text = tokio::fs::read_to_string(path).await.unwrap_or_default();
305    let mut table: toml::Table =
306        toml::from_str(&text).with_context(|| format!("parsing {}", path.display()))?;
307    let mut rules: Vec<EnvProxyRule> = table
308        .get("env_proxy")
309        .map(|v| v.clone().try_into())
310        .transpose()?
311        .unwrap_or_default();
312    change(&mut rules)?;
313    if rules.is_empty() {
314        table.remove("env_proxy");
315    } else {
316        table.insert("env_proxy".into(), toml::Value::try_from(rules)?);
317    }
318    let mut doc: toml_edit::DocumentMut = text
319        .parse()
320        .with_context(|| format!("parsing {}", path.display()))?;
321    shine_core::migration::sync_table(doc.as_table_mut(), &table);
322    atomic_write(path, doc.to_string().as_bytes()).await
323}
324
325fn manifest_path(shine_dir: &Path) -> PathBuf {
326    shine_dir.join("proxy-manifest.toml")
327}
328
329async fn load_manifest(shine_dir: &Path) -> Result<ProxyManifest> {
330    let path = manifest_path(shine_dir);
331    match tokio::fs::read_to_string(&path).await {
332        Ok(contents) => {
333            toml::from_str(&contents).with_context(|| format!("parsing {}", path.display()))
334        }
335        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(ProxyManifest::default()),
336        Err(error) => Err(error).with_context(|| format!("reading {}", path.display())),
337    }
338}
339
340async fn save_manifest(shine_dir: &Path, manifest: &ProxyManifest) -> Result<()> {
341    let path = manifest_path(shine_dir);
342    atomic_write(&path, toml::to_string_pretty(manifest)?.as_bytes()).await
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348
349    #[test]
350    fn proxy_command_must_be_a_bare_name() {
351        assert!(validate_command("gh").is_ok());
352        assert!(validate_command("tool-name").is_ok());
353        assert!(validate_command("../gh").is_err());
354        assert!(validate_command("a/b").is_err());
355    }
356
357    #[test]
358    fn absolute_target_preserves_symlink_dispatch_name() {
359        let relative = PathBuf::from("bin/cargo");
360        let resolved = absolute_path(relative).unwrap();
361        assert!(resolved.ends_with("bin/cargo"));
362        assert!(!resolved.ends_with("rustup"));
363    }
364
365    #[tokio::test]
366    async fn rule_mutation_replaces_only_matching_command() {
367        let dir = crate::test_support::make_temp_dir("shine-env-proxy").await;
368        let path = dir.join("config.toml");
369        tokio::fs::write(
370            &path,
371            "[[env_proxy]]\ncommand = \"gh\"\nwith = [\"OLD\"]\n\n[[env_proxy]]\ncommand = \"docker\"\nwith = [\"DOCKER_TOKEN\"]\n",
372        )
373        .await
374        .unwrap();
375        upsert_rule(
376            &path,
377            EnvProxyRule {
378                command: "gh".into(),
379                with: vec!["GH_TOKEN".into()],
380                enabled: true,
381            },
382        )
383        .await
384        .unwrap();
385        let parsed: toml::Table =
386            toml::from_str(&tokio::fs::read_to_string(&path).await.unwrap()).unwrap();
387        let rules: Vec<EnvProxyRule> = parsed["env_proxy"].clone().try_into().unwrap();
388        assert_eq!(rules.len(), 2);
389        assert_eq!(
390            rules.iter().find(|rule| rule.command == "gh").unwrap().with,
391            ["GH_TOKEN"]
392        );
393        assert!(rules.iter().any(|rule| rule.command == "docker"));
394        tokio::fs::remove_dir_all(dir).await.unwrap();
395    }
396
397    #[test]
398    fn legacy_rule_defaults_to_enabled() {
399        let rule: EnvProxyRule =
400            toml::from_str("command = \"gh\"\nwith = [\"GH_TOKEN\"]\n").unwrap();
401        assert!(rule.enabled);
402    }
403}