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 } => {
172                secret::decrypt_secret(value, &config.resolved_age_identities())
173                    .await
174                    .with_context(|| format!("decrypting {key}"))?
175            }
176            super::StoredValue::Plaintext(value) => value.to_string(),
177        };
178        injected.insert(spec.target, value);
179    }
180    run_target(target, args, injected).await
181}
182
183async fn run_target(
184    target: &Path,
185    args: &[OsString],
186    injected: BTreeMap<String, String>,
187) -> Result<()> {
188    let status = Command::new(target)
189        .args(args)
190        .envs(injected)
191        .status()
192        .await
193        .with_context(|| format!("running proxy target {}", target.display()))?;
194    if status.success() {
195        return Ok(());
196    }
197    std::process::exit(status.code().unwrap_or(1));
198}
199
200fn validate_command(command: &str) -> Result<()> {
201    if command.is_empty()
202        || !command
203            .chars()
204            .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.'))
205        || command == "."
206        || command == ".."
207    {
208        bail!("proxy command must be a bare command name: {command}");
209    }
210    Ok(())
211}
212
213fn find_target(command: &str, shine_bin: &Path) -> Result<PathBuf> {
214    let paths = std::env::var_os("PATH").context("PATH is not set")?;
215    for dir in std::env::split_paths(&paths) {
216        if dir == shine_bin {
217            continue;
218        }
219        let candidate = dir.join(command);
220        if candidate.is_file() {
221            // Do not canonicalize here. Cargo (and other rustup proxies) are
222            // symlinks whose filename is their dispatch identity: resolving
223            // `.../cargo` to `.../rustup` makes rustup see `argv[0] == rustup`
224            // and reject Cargo's arguments. Keep the executable path exactly
225            // as PATH selected it, merely making relative PATH segments absolute.
226            return absolute_path(candidate);
227        }
228        #[cfg(windows)]
229        {
230            let candidate = dir.join(format!("{command}.exe"));
231            if candidate.is_file() {
232                return absolute_path(candidate);
233            }
234        }
235    }
236    bail!(
237        "{command} is not installed on PATH outside {}",
238        shine_bin.display()
239    )
240}
241
242fn absolute_path(path: PathBuf) -> Result<PathBuf> {
243    if path.is_absolute() {
244        Ok(path)
245    } else {
246        Ok(std::env::current_dir()
247            .context("reading current directory")?
248            .join(path))
249    }
250}
251
252async fn install_shim(bin_dir: &Path, command: &str, target: &Path) -> Result<()> {
253    tokio::fs::create_dir_all(bin_dir).await?;
254    let path = bin_dir.join(command);
255    if path.exists() {
256        let body = tokio::fs::read_to_string(&path).await.unwrap_or_default();
257        if !body.contains(MARKER) {
258            bail!(
259                "{} already exists and is not a shine env proxy",
260                path.display()
261            );
262        }
263    }
264    let target_string = target.to_string_lossy().into_owned();
265    let target = shell_quote::single_quote(&target_string);
266    let command_q = shell_quote::single_quote(command);
267    atomic_write(&path, format!("#!/bin/sh\n# {MARKER}\nexec shine env proxy exec --target {target} {command_q} \"$@\"\n").as_bytes()).await?;
268    #[cfg(unix)]
269    {
270        use std::os::unix::fs::PermissionsExt;
271        tokio::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).await?;
272    }
273    #[cfg(windows)]
274    {
275        install_windows_shims(bin_dir, command, &target_string).await?;
276    }
277    Ok(())
278}
279
280#[cfg(windows)]
281async fn install_windows_shims(bin_dir: &Path, command: &str, target: &str) -> Result<()> {
282    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?;
283    let target_ps = target.replace('\'', "''");
284    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
285}
286
287async fn upsert_rule(path: &Path, rule: EnvProxyRule) -> Result<()> {
288    mutate_rules(path, |rules| {
289        rules.retain(|r| r.command != rule.command);
290        rules.push(rule);
291        Ok(())
292    })
293    .await
294}
295async fn remove_rule(path: &Path, command: &str) -> Result<()> {
296    mutate_rules(path, |rules| {
297        rules.retain(|r| r.command != command);
298        Ok(())
299    })
300    .await
301}
302async fn mutate_rules(
303    path: &Path,
304    change: impl FnOnce(&mut Vec<EnvProxyRule>) -> Result<()>,
305) -> Result<()> {
306    let text = tokio::fs::read_to_string(path).await.unwrap_or_default();
307    let mut table: toml::Table =
308        toml::from_str(&text).with_context(|| format!("parsing {}", path.display()))?;
309    let mut rules: Vec<EnvProxyRule> = table
310        .get("env_proxy")
311        .map(|v| v.clone().try_into())
312        .transpose()?
313        .unwrap_or_default();
314    change(&mut rules)?;
315    if rules.is_empty() {
316        table.remove("env_proxy");
317    } else {
318        table.insert("env_proxy".into(), toml::Value::try_from(rules)?);
319    }
320    let mut doc: toml_edit::DocumentMut = text
321        .parse()
322        .with_context(|| format!("parsing {}", path.display()))?;
323    shine_core::migration::sync_table(doc.as_table_mut(), &table);
324    atomic_write(path, doc.to_string().as_bytes()).await
325}
326
327fn manifest_path(shine_dir: &Path) -> PathBuf {
328    shine_dir.join("proxy-manifest.toml")
329}
330
331async fn load_manifest(shine_dir: &Path) -> Result<ProxyManifest> {
332    let path = manifest_path(shine_dir);
333    match tokio::fs::read_to_string(&path).await {
334        Ok(contents) => {
335            toml::from_str(&contents).with_context(|| format!("parsing {}", path.display()))
336        }
337        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(ProxyManifest::default()),
338        Err(error) => Err(error).with_context(|| format!("reading {}", path.display())),
339    }
340}
341
342async fn save_manifest(shine_dir: &Path, manifest: &ProxyManifest) -> Result<()> {
343    let path = manifest_path(shine_dir);
344    atomic_write(&path, toml::to_string_pretty(manifest)?.as_bytes()).await
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350
351    #[test]
352    fn proxy_command_must_be_a_bare_name() {
353        assert!(validate_command("gh").is_ok());
354        assert!(validate_command("tool-name").is_ok());
355        assert!(validate_command("../gh").is_err());
356        assert!(validate_command("a/b").is_err());
357    }
358
359    #[test]
360    fn absolute_target_preserves_symlink_dispatch_name() {
361        let relative = PathBuf::from("bin/cargo");
362        let resolved = absolute_path(relative).unwrap();
363        assert!(resolved.ends_with("bin/cargo"));
364        assert!(!resolved.ends_with("rustup"));
365    }
366
367    #[tokio::test]
368    async fn rule_mutation_replaces_only_matching_command() {
369        let dir = crate::test_support::make_temp_dir("shine-env-proxy").await;
370        let path = dir.join("config.toml");
371        tokio::fs::write(
372            &path,
373            "[[env_proxy]]\ncommand = \"gh\"\nwith = [\"OLD\"]\n\n[[env_proxy]]\ncommand = \"docker\"\nwith = [\"DOCKER_TOKEN\"]\n",
374        )
375        .await
376        .unwrap();
377        upsert_rule(
378            &path,
379            EnvProxyRule {
380                command: "gh".into(),
381                with: vec!["GH_TOKEN".into()],
382                enabled: true,
383            },
384        )
385        .await
386        .unwrap();
387        let parsed: toml::Table =
388            toml::from_str(&tokio::fs::read_to_string(&path).await.unwrap()).unwrap();
389        let rules: Vec<EnvProxyRule> = parsed["env_proxy"].clone().try_into().unwrap();
390        assert_eq!(rules.len(), 2);
391        assert_eq!(
392            rules.iter().find(|rule| rule.command == "gh").unwrap().with,
393            ["GH_TOKEN"]
394        );
395        assert!(rules.iter().any(|rule| rule.command == "docker"));
396        tokio::fs::remove_dir_all(dir).await.unwrap();
397    }
398
399    #[test]
400    fn legacy_rule_defaults_to_enabled() {
401        let rule: EnvProxyRule =
402            toml::from_str("command = \"gh\"\nwith = [\"GH_TOKEN\"]\n").unwrap();
403        assert!(rule.enabled);
404    }
405}