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 } => {
172 secret::decrypt_secret(value, &config.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 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 utils::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}