1use zeroize::Zeroizing;
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
26pub enum ConfigTier {
27 Project,
30 User,
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
39pub enum EnvVarSyntax {
40 Shell,
43 Windows,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
53pub enum EnvExpansionError {
54 #[error("environment variable {name:?} referenced in config value is not set")]
57 UndefinedVar {
58 name: String,
60 },
61 #[error("env-var expansion is not permitted in a project-tier config value")]
67 NotAllowedInProjectTier,
68}
69
70const SHELL_PLACEHOLDER_START: &str = "${";
74
75enum Segment<'a> {
82 Literal(&'a str),
83 Value(Zeroizing<String>),
84}
85
86fn assemble(segments: &[Segment<'_>]) -> Zeroizing<String> {
87 let total_len: usize = segments
88 .iter()
89 .map(|segment| match segment {
90 Segment::Literal(s) => s.len(),
91 Segment::Value(v) => v.len(),
92 })
93 .sum();
94 let mut out = Zeroizing::new(String::with_capacity(total_len));
95 for segment in segments {
96 match segment {
97 Segment::Literal(s) => out.push_str(s),
98 Segment::Value(v) => out.push_str(v.as_str()),
99 }
100 }
101 out
102}
103
104#[allow(clippy::string_slice)]
107fn scan_shell(
108 raw: &str,
109 lookup: &impl Fn(&str) -> Option<Zeroizing<String>>,
110) -> Result<Zeroizing<String>, EnvExpansionError> {
111 let mut segments = Vec::new();
112 let mut rest = raw;
113 while let Some(start) = rest.find(SHELL_PLACEHOLDER_START) {
114 if start > 0 {
115 segments.push(Segment::Literal(&rest[..start]));
116 }
117 let after = &rest[start + SHELL_PLACEHOLDER_START.len()..];
118 let Some(end) = after.find('}') else {
119 segments.push(Segment::Literal(&rest[start..]));
121 rest = "";
122 break;
123 };
124 let var_name = &after[..end];
125 let value = lookup(var_name).ok_or_else(|| EnvExpansionError::UndefinedVar {
126 name: var_name.to_string(),
127 })?;
128 segments.push(Segment::Value(value));
129 rest = &after[end + 1..];
130 }
131 if !rest.is_empty() {
132 segments.push(Segment::Literal(rest));
133 }
134 Ok(assemble(&segments))
135}
136
137#[allow(clippy::string_slice)]
140fn scan_windows(
141 raw: &str,
142 lookup: &impl Fn(&str) -> Option<Zeroizing<String>>,
143) -> Result<Zeroizing<String>, EnvExpansionError> {
144 let mut segments = Vec::new();
145 let mut rest = raw;
146 while let Some(pct) = rest.find('%') {
147 let literal = &rest[..pct];
148 let after = &rest[pct + 1..];
149 if let Some(end) = after.find('%') {
150 let name = &after[..end];
151 if !name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
152 if !literal.is_empty() {
153 segments.push(Segment::Literal(literal));
154 }
155 let value = lookup(name).ok_or_else(|| EnvExpansionError::UndefinedVar {
156 name: name.to_string(),
157 })?;
158 segments.push(Segment::Value(value));
159 rest = &after[end + 1..];
160 continue;
161 }
162 }
163 segments.push(Segment::Literal(&rest[..=pct]));
166 rest = &rest[pct + 1..];
167 }
168 if !rest.is_empty() {
169 segments.push(Segment::Literal(rest));
170 }
171 Ok(assemble(&segments))
172}
173
174fn windows_has_placeholder(raw: &str) -> bool {
181 let detected = std::cell::Cell::new(false);
182 let _: Result<_, _> = scan_windows(raw, &|_name| {
183 detected.set(true);
184 None
185 });
186 detected.get()
187}
188
189pub fn expand_env_vars(
232 raw: &str,
233 syntax: EnvVarSyntax,
234 tier: ConfigTier,
235 lookup: impl Fn(&str) -> Option<Zeroizing<String>>,
236) -> Result<Zeroizing<String>, EnvExpansionError> {
237 if tier == ConfigTier::Project {
238 let has_placeholder = match syntax {
239 EnvVarSyntax::Shell => raw.contains(SHELL_PLACEHOLDER_START),
240 EnvVarSyntax::Windows => windows_has_placeholder(raw),
241 };
242 if has_placeholder {
243 return Err(EnvExpansionError::NotAllowedInProjectTier);
244 }
245 return Ok(Zeroizing::new(raw.to_owned()));
246 }
247
248 match syntax {
249 EnvVarSyntax::Shell => scan_shell(raw, &lookup),
250 EnvVarSyntax::Windows => scan_windows(raw, &lookup),
251 }
252}
253
254#[must_use]
267pub fn process_env(name: &str) -> Option<Zeroizing<String>> {
268 std::env::var(name).ok().map(Zeroizing::new)
269}
270
271#[cfg(test)]
272mod tests {
273 use super::*;
274
275 fn z(s: &str) -> Zeroizing<String> {
276 Zeroizing::new(s.to_string())
277 }
278
279 #[test]
282 fn shell_no_placeholder_passthrough() {
283 assert_eq!(
284 expand_env_vars(
285 "https://npm.example/",
286 EnvVarSyntax::Shell,
287 ConfigTier::User,
288 |_| None
289 )
290 .unwrap()
291 .as_str(),
292 "https://npm.example/"
293 );
294 }
295
296 #[test]
297 fn shell_defined_var_expands() {
298 let result = expand_env_vars(
299 "${NPM_REGISTRY}/",
300 EnvVarSyntax::Shell,
301 ConfigTier::User,
302 |name| (name == "NPM_REGISTRY").then(|| z("https://npm.mycorp.example")),
303 );
304 assert_eq!(result.unwrap().as_str(), "https://npm.mycorp.example/");
305 }
306
307 #[test]
308 fn shell_undefined_var_errors() {
309 let result = expand_env_vars(
310 "${UNDEFINED_VAR}",
311 EnvVarSyntax::Shell,
312 ConfigTier::User,
313 |_| None,
314 );
315 assert_eq!(
316 result,
317 Err(EnvExpansionError::UndefinedVar {
318 name: "UNDEFINED_VAR".to_string()
319 })
320 );
321 }
322
323 #[test]
324 fn shell_unterminated_placeholder_is_literal() {
325 assert_eq!(
326 expand_env_vars("a${B", EnvVarSyntax::Shell, ConfigTier::User, |_| {
327 panic!("lookup must not be called")
328 })
329 .unwrap()
330 .as_str(),
331 "a${B"
332 );
333 }
334
335 #[test]
336 fn shell_empty_name_is_undefined() {
337 let result = expand_env_vars("${}", EnvVarSyntax::Shell, ConfigTier::User, |name| {
338 assert_eq!(name, "");
339 None
340 });
341 assert_eq!(
342 result,
343 Err(EnvExpansionError::UndefinedVar {
344 name: String::new()
345 })
346 );
347 }
348
349 #[test]
350 fn shell_adjacent_placeholders() {
351 let lookup = |name: &str| match name {
352 "A" => Some(z("1")),
353 "B" => Some(z("2")),
354 _ => None,
355 };
356 assert_eq!(
357 expand_env_vars("${A}${B}", EnvVarSyntax::Shell, ConfigTier::User, lookup)
358 .unwrap()
359 .as_str(),
360 "12"
361 );
362 }
363
364 #[test]
365 fn shell_dollar_before_placeholder() {
366 let lookup = |name: &str| (name == "A").then(|| z("1"));
367 assert_eq!(
368 expand_env_vars("$${A}", EnvVarSyntax::Shell, ConfigTier::User, lookup)
369 .unwrap()
370 .as_str(),
371 "$1"
372 );
373 }
374
375 #[test]
376 fn shell_trailing_brace_is_literal() {
377 let lookup = |name: &str| (name == "A").then(|| z("1"));
378 assert_eq!(
379 expand_env_vars("${A}}", EnvVarSyntax::Shell, ConfigTier::User, lookup)
380 .unwrap()
381 .as_str(),
382 "1}"
383 );
384 }
385
386 #[test]
387 fn shell_surrounding_literal_text() {
388 let lookup = |name: &str| (name == "A").then(|| z("1"));
389 assert_eq!(
390 expand_env_vars("pre${A}post", EnvVarSyntax::Shell, ConfigTier::User, lookup)
391 .unwrap()
392 .as_str(),
393 "pre1post"
394 );
395 }
396
397 #[test]
398 fn shell_looked_up_value_is_not_recursively_expanded() {
399 let lookup = |name: &str| (name == "A").then(|| z("${B}"));
400 assert_eq!(
401 expand_env_vars("${A}", EnvVarSyntax::Shell, ConfigTier::User, lookup)
402 .unwrap()
403 .as_str(),
404 "${B}"
405 );
406 }
407
408 #[test]
409 fn shell_first_unset_var_short_circuits() {
410 let calls = std::cell::RefCell::new(Vec::new());
411 let lookup = |name: &str| {
412 calls.borrow_mut().push(name.to_string());
413 None
414 };
415 let result = expand_env_vars("${A}${B}", EnvVarSyntax::Shell, ConfigTier::User, lookup);
416 assert_eq!(
417 result,
418 Err(EnvExpansionError::UndefinedVar {
419 name: "A".to_string()
420 })
421 );
422 assert_eq!(*calls.borrow(), vec!["A".to_string()]);
423 }
424
425 #[test]
428 fn windows_set_and_unset() {
429 let set = expand_env_vars(
430 "%CORP_FEED_PAT%",
431 EnvVarSyntax::Windows,
432 ConfigTier::User,
433 |name| (name == "CORP_FEED_PAT").then(|| z("secret-pat")),
434 );
435 assert_eq!(set.unwrap().as_str(), "secret-pat");
436
437 let unset = expand_env_vars(
438 "%CORP_FEED_PAT%",
439 EnvVarSyntax::Windows,
440 ConfigTier::User,
441 |_| None,
442 );
443 assert_eq!(
444 unset,
445 Err(EnvExpansionError::UndefinedVar {
446 name: "CORP_FEED_PAT".to_string()
447 })
448 );
449 }
450
451 #[test]
452 fn windows_edge_cases() {
453 let lookup = |name: &str| match name {
454 "A" => Some(z("1")),
455 "B" => Some(z("2")),
456 _ => None,
457 };
458 assert_eq!(
459 expand_env_vars(
460 "pre-%A%-post",
461 EnvVarSyntax::Windows,
462 ConfigTier::User,
463 lookup
464 )
465 .unwrap()
466 .as_str(),
467 "pre-1-post"
468 );
469 assert_eq!(
470 expand_env_vars("%A%%B%", EnvVarSyntax::Windows, ConfigTier::User, lookup)
471 .unwrap()
472 .as_str(),
473 "12"
474 );
475 assert_eq!(
476 expand_env_vars(
477 "abc%1bad!name%def",
478 EnvVarSyntax::Windows,
479 ConfigTier::User,
480 lookup
481 )
482 .unwrap()
483 .as_str(),
484 "abc%1bad!name%def"
485 );
486 assert_eq!(
487 expand_env_vars("abc%A", EnvVarSyntax::Windows, ConfigTier::User, lookup)
488 .unwrap()
489 .as_str(),
490 "abc%A"
491 );
492 assert_eq!(
493 expand_env_vars("%%", EnvVarSyntax::Windows, ConfigTier::User, lookup)
494 .unwrap()
495 .as_str(),
496 "%%"
497 );
498 assert_eq!(
499 expand_env_vars(
500 "pre-%UNSET%-post",
501 EnvVarSyntax::Windows,
502 ConfigTier::User,
503 lookup
504 ),
505 Err(EnvExpansionError::UndefinedVar {
506 name: "UNSET".to_string()
507 })
508 );
509 }
510
511 #[test]
512 fn windows_malformed_then_well_formed() {
513 let lookup = |name: &str| (name == "A").then(|| z("1"));
514 assert_eq!(
515 expand_env_vars("%x!%A%", EnvVarSyntax::Windows, ConfigTier::User, lookup)
516 .unwrap()
517 .as_str(),
518 "%x!1"
519 );
520 }
521
522 #[test]
523 fn windows_looked_up_value_is_not_recursively_expanded() {
524 let lookup = |name: &str| (name == "A").then(|| z("%B%"));
525 assert_eq!(
526 expand_env_vars("%A%", EnvVarSyntax::Windows, ConfigTier::User, lookup)
527 .unwrap()
528 .as_str(),
529 "%B%"
530 );
531 }
532
533 #[test]
534 fn windows_first_unset_var_short_circuits() {
535 let calls = std::cell::RefCell::new(Vec::new());
536 let lookup = |name: &str| {
537 calls.borrow_mut().push(name.to_string());
538 None
539 };
540 let result = expand_env_vars("%A%%B%", EnvVarSyntax::Windows, ConfigTier::User, lookup);
541 assert_eq!(
542 result,
543 Err(EnvExpansionError::UndefinedVar {
544 name: "A".to_string()
545 })
546 );
547 assert_eq!(*calls.borrow(), vec!["A".to_string()]);
548 }
549
550 #[test]
553 fn shell_project_tier_rejects_unterminated_placeholder() {
554 let result = expand_env_vars("a${B", EnvVarSyntax::Shell, ConfigTier::Project, |_| {
555 panic!("lookup must not be called for a project-tier value")
556 });
557 assert_eq!(result, Err(EnvExpansionError::NotAllowedInProjectTier));
558 }
559
560 #[test]
561 fn shell_project_tier_rejects_defined_var() {
562 let result = expand_env_vars("${PATH}", EnvVarSyntax::Shell, ConfigTier::Project, |_| {
563 panic!("lookup must not be called for a project-tier value")
564 });
565 assert_eq!(result, Err(EnvExpansionError::NotAllowedInProjectTier));
566 }
567
568 #[test]
569 fn shell_project_tier_no_placeholder_is_passthrough() {
570 let result = expand_env_vars(
571 "https://npm.example/",
572 EnvVarSyntax::Shell,
573 ConfigTier::Project,
574 |_| panic!("lookup must not be called"),
575 );
576 assert_eq!(result.unwrap().as_str(), "https://npm.example/");
577 }
578
579 #[test]
580 fn windows_project_tier_rejects_well_formed_placeholder() {
581 let result = expand_env_vars("%A%", EnvVarSyntax::Windows, ConfigTier::Project, |_| {
582 panic!("lookup must not be called for a project-tier value")
583 });
584 assert_eq!(result, Err(EnvExpansionError::NotAllowedInProjectTier));
585 }
586
587 #[test]
588 fn windows_project_tier_rejects_malformed_then_well_formed() {
589 let result = expand_env_vars("%x!%A%", EnvVarSyntax::Windows, ConfigTier::Project, |_| {
590 panic!("lookup must not be called for a project-tier value")
591 });
592 assert_eq!(result, Err(EnvExpansionError::NotAllowedInProjectTier));
593 }
594
595 #[test]
596 fn windows_project_tier_percent_encoding_not_rejected() {
597 let result = expand_env_vars("%20", EnvVarSyntax::Windows, ConfigTier::Project, |_| {
598 panic!("lookup must not be called: %20 is not a well-formed placeholder")
599 });
600 assert_eq!(result.unwrap().as_str(), "%20");
601 }
602
603 #[test]
606 fn process_env_unset_var_is_none() {
607 assert!(process_env("DEPS_LSP_CONFIG_TRUST_TEST_UNSET_VAR_XYZ").is_none());
608 }
609}