1use 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 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}