1use core::fmt;
4
5use std::path::Path;
6
7use crate::config::AppConfig;
8
9use super::NormalizeError;
10
11fn expand_tilde(
24 value: &str,
25 home: Option<&Path>,
26 name: &str,
27 field: &'static str,
28) -> Result<String, NormalizeError> {
29 expand_home_tilde(value, home).map_err(|err| match err {
30 TildeError::OtherUser => NormalizeError::TildeUser {
31 name: name.to_string(),
32 field,
33 value: value.to_string(),
34 },
35 TildeError::NoHome => NormalizeError::NoHomeForTilde {
36 name: name.to_string(),
37 field,
38 },
39 })
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum TildeError {
46 OtherUser,
51 NoHome,
53}
54
55impl fmt::Display for TildeError {
56 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
57 match self {
58 Self::OtherUser => write!(
59 f,
60 "shep expands only `~/` (your own home); another user's home needs a \
61 passwd lookup whose answer depends on who the process runs as"
62 ),
63 Self::NoHome => write!(f, "begins with `~/` but no home directory could be found"),
64 }
65 }
66}
67
68impl core::error::Error for TildeError {}
69
70pub fn expand_home_tilde(value: &str, home: Option<&Path>) -> Result<String, TildeError> {
81 let Some(rest) = value.strip_prefix('~') else {
82 return Ok(value.to_string());
83 };
84 if !(rest.is_empty() || rest.starts_with('/')) {
86 return Err(TildeError::OtherUser);
87 }
88 let Some(home) = home else {
89 return Err(TildeError::NoHome);
90 };
91 let joined = home.join(rest.trim_start_matches('/'));
94 Ok(joined.to_string_lossy().into_owned())
95}
96
97#[cfg(test)]
105const PATH_FIELDS: &[&str] = &["script", "cwd", "out_file", "err_file"];
106
107pub(super) fn expand_paths(app: &mut AppConfig, home: Option<&Path>) -> Result<(), NormalizeError> {
112 let name = app.name.clone();
113 app.script = expand_tilde(&app.script, home, &name, "script")?;
114 for (field, slot) in [
115 ("cwd", &mut app.cwd),
116 ("out_file", &mut app.out_file),
117 ("err_file", &mut app.err_file),
118 ] {
119 if let Some(value) = slot {
120 *slot = Some(expand_tilde(value, home, &name, field)?);
121 }
122 }
123 Ok(())
124}
125
126#[cfg(test)]
127mod tests {
128 use super::*;
129 use crate::config::normalize::shep_home_fixture;
130 use crate::config::normalize::{normalize, normalize_with_home};
131 use crate::paths::SHEP_HOME_VAR;
132
133 #[test]
138 fn a_secret_in_either_log_path_is_refused() {
139 for (field, set) in [
140 (
141 "out_file",
142 (|app: &mut AppConfig| {
143 app.out_file = Some("/var/log/{{secret:LOG_KEY}}.log".to_string());
144 }) as fn(&mut AppConfig),
145 ),
146 ("err_file", |app| {
147 app.err_file = Some("/var/log/{{secret:vercel/LOG_KEY}}.log".to_string());
148 }),
149 ] {
150 let mut app = AppConfig::minimal("web", "/srv/server.js");
151 set(&mut app);
152 let err = normalize_with_home(app, Some(Path::new("/home/ada")), shep_home_fixture())
153 .expect_err("a log path may not hold a secret");
154 assert!(
155 matches!(&err, NormalizeError::SecretInLogPath { field: got, .. } if *got == field),
156 "{field}: {err:?}"
157 );
158 let rendered = err.to_string();
159 assert!(rendered.contains(field), "names the field: {rendered}");
160 assert!(
161 !rendered.contains("LOG_KEY"),
162 "and never echoes the key back: {rendered}"
163 );
164 }
165 }
166
167 #[test]
171 fn shep_home_with_no_shep_home_is_refused_in_every_templated_field() {
172 for (field, mutate) in [
173 (
174 "out_file",
175 (|app: &mut AppConfig| {
176 app.out_file = Some("{{SHEP_HOME}}/logs/out.log".to_string());
177 }) as fn(&mut AppConfig),
178 ),
179 ("err_file", |app| {
180 app.err_file = Some("{{SHEP_HOME}}/logs/err.log".to_string());
181 }),
182 ("env.DATA_DIR", |app| {
183 app.env
184 .insert("DATA_DIR".to_string(), "{{SHEP_HOME}}/data".to_string());
185 }),
186 ("args[0]", |app| {
187 app.args = vec!["--state={{SHEP_HOME}}/state".to_string()];
188 }),
189 ] {
190 let mut app = AppConfig::minimal("web", "/srv/server.js");
191 mutate(&mut app);
192 let err = normalize_with_home(app, Some(Path::new("/home/ada")), None)
193 .expect_err("nothing to expand it against");
194 assert!(
195 matches!(&err, NormalizeError::NoShepHome { field: got, .. } if got == field),
196 "{field}: {err:?}"
197 );
198 let rendered = err.to_string();
199 assert!(
202 rendered.contains(SHEP_HOME_VAR),
203 "names the fix: {rendered}"
204 );
205 assert!(
206 !rendered.contains('\u{2014}') && !rendered.contains('\u{2013}'),
207 "no em or en dash in copy a user reads: {rendered}"
208 );
209 }
210 }
211
212 #[test]
213 fn shep_home_is_accepted_once_there_is_one() {
214 let mut app = AppConfig::minimal("web", "/srv/server.js");
215 app.out_file = Some("{{SHEP_HOME}}/logs/{{name}}-{{instance}}-out.log".to_string());
216 app.env
217 .insert("DATA_DIR".to_string(), "{{SHEP_HOME}}/data".to_string());
218 app.args = vec!["--state={{SHEP_HOME}}/state".to_string()];
219 let resolved = normalize_with_home(app, Some(Path::new("/home/ada")), shep_home_fixture())
220 .expect("every field accepts it");
221 assert_eq!(
224 resolved.config().out_file.as_deref(),
225 Some("{{SHEP_HOME}}/logs/{{name}}-{{instance}}-out.log")
226 );
227 }
228
229 #[test]
233 fn a_shep_home_log_path_still_needs_an_instance_token() {
234 let mut app = AppConfig::minimal("web", "/srv/server.js");
235 app.instances = 2;
236 app.out_file = Some("{{SHEP_HOME}}/logs/{{name}}-out.log".to_string());
237 let err = normalize_with_home(app, Some(Path::new("/home/ada")), shep_home_fixture())
238 .expect_err("both slots render one path");
239 assert!(matches!(
240 err,
241 NormalizeError::SharedLogPath {
242 field: "out_file",
243 ..
244 }
245 ));
246
247 let mut app = AppConfig::minimal("web", "/srv/server.js");
248 app.instances = 2;
249 app.out_file = Some("{{SHEP_HOME}}/logs/{{name}}-{{instance}}-out.log".to_string());
250 assert!(
251 normalize_with_home(app, Some(Path::new("/home/ada")), shep_home_fixture()).is_ok(),
252 "the slot tells them apart"
253 );
254 }
255
256 #[test]
257 fn an_explicit_log_path_shared_by_every_instance_is_refused() {
258 let mut app = AppConfig::minimal("web", "./srv");
259 app.instances = 3;
260 app.out_file = Some("/var/log/web.log".to_string());
261 let err = normalize(app).unwrap_err();
262 let rendered = err.to_string();
263 assert!(rendered.contains("out_file"), "names the field: {rendered}");
264 assert!(
265 rendered.contains("{{instance}}") && rendered.contains("merge_logs"),
266 "and both ways out: {rendered}"
267 );
268 assert!(
269 !rendered.contains('\u{2014}') && !rendered.contains('\u{2013}'),
270 "no em or en dash in copy a user reads: {rendered}"
271 );
272 }
273
274 #[test]
275 fn the_three_ways_out_of_the_shared_log_refusal_all_work() {
276 let mut templated = AppConfig::minimal("web", "./srv");
278 templated.instances = 3;
279 templated.out_file = Some("/var/log/web-{{instance}}.log".to_string());
280 assert!(normalize(templated).is_ok());
281
282 let mut merged = AppConfig::minimal("web", "./srv");
284 merged.instances = 3;
285 merged.out_file = Some("/var/log/web.log".to_string());
286 merged.merge_logs = true;
287 assert!(normalize(merged).is_ok());
288
289 let mut single = AppConfig::minimal("web", "./srv");
291 single.out_file = Some("/var/log/web.log".to_string());
292 assert!(normalize(single).is_ok());
293 }
294
295 #[test]
296 fn an_escaped_template_in_a_log_path_does_not_satisfy_the_refusal() {
297 let mut app = AppConfig::minimal("web", "./srv");
300 app.instances = 3;
301 app.out_file = Some("/var/log/web-{{{{instance}}}}.log".to_string());
302 assert!(normalize(app).is_err());
303 }
304
305 #[test]
306 fn a_name_only_template_does_not_resolve_the_collision() {
307 let mut app = AppConfig::minimal("web", "./srv");
311 app.instances = 3;
312 app.out_file = Some("/var/log/{{name}}.log".to_string());
313 assert!(normalize(app).is_err());
314 }
315
316 #[test]
322 fn a_secret_in_a_log_path_is_refused() {
323 for field in ["out_file", "err_file"] {
324 let mut app = AppConfig::minimal("web", "./srv");
325 let path = Some("/var/log/{{secret:TOKEN}}.log".to_string());
326 if field == "out_file" {
327 app.out_file = path;
328 } else {
329 app.err_file = path;
330 }
331 match normalize(app).unwrap_err() {
332 NormalizeError::SecretInLogPath { name, field: got } => {
333 assert_eq!(name, "web");
334 assert_eq!(got, field);
335 }
336 other => panic!("expected SecretInLogPath for {field}, got {other:?}"),
337 }
338 }
339 }
340
341 #[test]
345 fn the_positional_tokens_still_render_in_a_log_path() {
346 let mut app = AppConfig::minimal("web", "./srv");
347 app.instances = 2;
348 app.out_file = Some("/var/log/{{name}}-{{instance}}.out".to_string());
349 app.err_file = Some("/var/log/{{name}}-{{instance}}.err".to_string());
350 assert!(normalize(app).is_ok());
351 }
352
353 #[test]
357 fn a_namespaced_secret_in_a_log_path_is_refused_too() {
358 let mut app = AppConfig::minimal("web", "./srv");
359 app.err_file = Some("/var/log/{{secret:vercel/TOKEN}}.log".to_string());
360 assert!(matches!(
361 normalize(app).unwrap_err(),
362 NormalizeError::SecretInLogPath { .. }
363 ));
364 }
365
366 #[test]
367 fn a_bad_template_in_a_log_path_is_reported_as_bad_template_not_shared_path() {
368 let mut app = AppConfig::minimal("web", "./srv");
369 app.instances = 3;
370 app.out_file = Some("/var/log/web-{{instnace}}.log".to_string());
371 match normalize(app).unwrap_err() {
372 NormalizeError::BadTemplate { field, reason, .. } => {
373 assert_eq!(field, "out_file");
374 assert!(reason.contains("instnace"), "{reason}");
375 }
376 other => panic!("expected BadTemplate, got {other:?}"),
377 }
378 }
379
380 #[test]
381 fn a_typo_in_an_env_template_is_refused_and_names_the_field() {
382 let mut app = AppConfig::minimal("web", "./srv");
383 app.env
384 .insert("WORKER".to_string(), "w-{{instnace}}".to_string());
385 let err = normalize(app).unwrap_err();
386 let rendered = err.to_string();
387 assert!(rendered.contains("instnace"), "names the typo: {rendered}");
388 assert!(rendered.contains("WORKER"), "and the field: {rendered}");
389 }
390
391 #[test]
392 fn a_typo_in_an_arg_template_is_refused_too() {
393 let mut app = AppConfig::minimal("web", "./srv");
394 app.args = vec!["--port".to_string(), "91{{slot}}".to_string()];
395 let err = normalize(app).unwrap_err();
396 assert!(err.to_string().contains("slot"), "{err}");
397 }
398
399 #[test]
403 fn every_path_field_expands_a_leading_tilde() {
404 let home = Path::new("/home/ada");
405 let mut app = AppConfig::minimal("web", "~/app/server.js");
406 app.cwd = Some("~/app".to_string());
407 app.out_file = Some("~/logs/out.log".to_string());
408 app.err_file = Some("~/logs/err.log".to_string());
409
410 let resolved = normalize_with_home(app, Some(home), Some(&home.join(".shep")))
411 .expect("all four expand");
412 let c = resolved.config();
413 let expect = |rest: &str| home.join(rest).to_string_lossy().into_owned();
417 assert_eq!(c.script, expect("app/server.js"));
418 assert_eq!(c.cwd.as_deref(), Some(expect("app").as_str()));
419 assert_eq!(c.out_file.as_deref(), Some(expect("logs/out.log").as_str()));
420 assert_eq!(c.err_file.as_deref(), Some(expect("logs/err.log").as_str()));
421 }
422
423 #[test]
427 fn the_path_field_list_matches_what_expand_paths_walks() {
428 let home = Path::new("/home/ada");
429 let mut app = AppConfig::minimal("web", "~/s");
430 app.cwd = Some("~/c".to_string());
431 app.out_file = Some("~/o".to_string());
432 app.err_file = Some("~/e".to_string());
433
434 let resolved =
435 normalize_with_home(app, Some(home), Some(&home.join(".shep"))).expect("expands");
436 let c = resolved.config();
437 let expanded = [
438 ("script", Some(c.script.as_str())),
439 ("cwd", c.cwd.as_deref()),
440 ("out_file", c.out_file.as_deref()),
441 ("err_file", c.err_file.as_deref()),
442 ];
443 assert_eq!(
444 expanded.len(),
445 PATH_FIELDS.len(),
446 "PATH_FIELDS and this test must name the same set"
447 );
448 for (field, value) in expanded {
449 assert!(
450 PATH_FIELDS.contains(&field),
451 "`{field}` is not in PATH_FIELDS"
452 );
453 assert!(
454 value.is_some_and(|v| v.starts_with("/home/ada")),
455 "`{field}` was not expanded: {value:?}"
456 );
457 }
458 }
459
460 #[test]
463 fn a_path_without_a_tilde_is_left_exactly_as_written() {
464 let app = AppConfig::minimal("web", "./server.js");
465 let resolved = normalize_with_home(app, Some(Path::new("/home/ada")), shep_home_fixture())
466 .expect("no tilde, no change");
467 assert_eq!(resolved.config().script, "./server.js");
468 }
469
470 #[test]
473 fn another_users_home_is_refused_rather_than_resolved() {
474 let app = AppConfig::minimal("web", "~deploy/app/server.js");
475 let err = normalize_with_home(app, Some(Path::new("/home/ada")), shep_home_fixture())
476 .expect_err("~user/ must not resolve");
477 assert!(
478 matches!(err, NormalizeError::TildeUser { field, .. } if field == "script"),
479 "the refusal names the field: {err:?}"
480 );
481 let rendered = err.to_string();
482 assert!(
483 rendered.contains("~/"),
484 "and says what IS supported: {rendered}"
485 );
486 assert!(
487 !rendered.contains('\u{2014}') && !rendered.contains('\u{2013}'),
488 "no em or en dash in copy a user reads: {rendered}"
489 );
490 }
491
492 #[test]
495 fn a_dollar_variable_is_not_expanded() {
496 let app = AppConfig::minimal("web", "$HOME/server.js");
497 let resolved = normalize_with_home(app, Some(Path::new("/home/ada")), shep_home_fixture())
498 .expect("left alone");
499 assert_eq!(resolved.config().script, "$HOME/server.js");
500 }
501
502 #[test]
505 fn a_tilde_with_no_home_is_an_error_not_a_literal_path() {
506 let app = AppConfig::minimal("web", "~/server.js");
507 let err = normalize_with_home(app, None, None).expect_err("nothing to expand against");
508 assert!(
509 matches!(err, NormalizeError::NoHomeForTilde { .. }),
510 "{err:?}"
511 );
512 }
513
514 #[test]
518 fn expand_home_tilde_covers_its_four_documented_cases() {
519 let home = Path::new("/home/ada");
520 assert_eq!(
521 expand_home_tilde("~/bin/dog", Some(home)).unwrap(),
522 home.join("bin/dog").to_string_lossy()
523 );
524 assert_eq!(
525 expand_home_tilde("/opt/bin/dog", Some(home)).unwrap(),
526 "/opt/bin/dog",
527 "a value with no leading ~ is returned unchanged"
528 );
529 assert_eq!(
530 expand_home_tilde("~/bin/dog", None).unwrap_err(),
531 TildeError::NoHome
532 );
533 assert_eq!(
534 expand_home_tilde("~deploy/bin/dog", Some(home)).unwrap_err(),
535 TildeError::OtherUser
536 );
537 }
538
539 #[test]
544 fn the_two_home_remedies_name_the_variable_this_platform_spells() {
545 let tilde = NormalizeError::NoHomeForTilde {
546 name: "web".to_string(),
547 field: "cwd",
548 }
549 .to_string();
550 let templated = NormalizeError::NoShepHome {
551 name: "web".to_string(),
552 field: "out_file".to_string(),
553 }
554 .to_string();
555
556 let (tilde_expected, templated_expected) = if cfg!(windows) {
560 (
561 "`web`: cwd begins with `~/` but no home directory could be found. \
562 Set %USERPROFILE%, or write the path out in full.",
563 "`web`: out_file carries `{{SHEP_HOME}}` but no shep home could be \
564 found. Set %SHEP_HOME%, or write the path out in full.",
565 )
566 } else {
567 (
568 "`web`: cwd begins with `~/` but no home directory could be found. \
569 Set $HOME, or write the path out in full.",
570 "`web`: out_file carries `{{SHEP_HOME}}` but no shep home could be \
571 found. Set $SHEP_HOME, or write the path out in full.",
572 )
573 };
574 assert_eq!(tilde, tilde_expected);
575 assert_eq!(templated, templated_expected);
576 }
577}