Skip to main content

shep_core/config/normalize/
tilde.rs

1//! `~/` expansion, and the walk over every `AppConfig` field that holds a path.
2
3use core::fmt;
4
5use std::path::Path;
6
7use crate::config::AppConfig;
8
9use super::NormalizeError;
10
11/// Expands a leading `~/` against `home`, and refuses `~user/`.
12///
13/// `~/` only: `~user/...` needs a passwd lookup whose answer depends on who
14/// the daemon runs as, and `$VAR` is never expanded here or anywhere. A
15/// value with no leading `~` is returned unchanged. Thin wrapper over
16/// [`expand_home_tilde`], attaching the sheep name and field
17/// [`NormalizeError`] carries; `shep-cli`'s `shep adopt` calls
18/// [`expand_home_tilde`] directly instead.
19///
20/// # Errors
21/// - [`NormalizeError::TildeUser`] if the path names another user's home.
22/// - [`NormalizeError::NoHomeForTilde`] if `~/` is used and `home` is `None`.
23fn 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/// Why [`expand_home_tilde`] refused a value, with no per-field context
43/// attached, for a caller that has none to give.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum TildeError {
46    /// The value names another user's home (`~user/...`). Refused rather
47    /// than resolved: answering it means a passwd lookup, and under a
48    /// systemd unit the answer depends on who the process runs as rather
49    /// than on who wrote the value.
50    OtherUser,
51    /// The value begins `~/` and no home directory could be determined.
52    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
70/// Expands a leading `~/` in `value` against `home`
71///
72/// Accepts `~` alone or `~/...`. `~user/` is refused, since resolving it
73/// takes a passwd lookup whose answer depends on who the process runs as.
74/// `$VAR` is never expanded. A value with no leading `~` comes back
75/// unchanged.
76///
77/// # Errors
78/// - [`TildeError::OtherUser`] if the value names another user's home.
79/// - [`TildeError::NoHome`] if `~/` is used and `home` is `None`.
80pub 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    // `~` alone, or `~/...`. Anything else after the tilde names a user.
85    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    // `join` would discard `home` for a rest that still looks absolute, so
92    // the separator is trimmed and the two halves are concatenated instead.
93    let joined = home.join(rest.trim_start_matches('/'));
94    Ok(joined.to_string_lossy().into_owned())
95}
96
97/// Every field of an [`AppConfig`] that carries a filesystem path.
98///
99/// Named once, and walked by [`expand_paths`] and by its own test, so a
100/// fifth path field added later fails that test until it is handled.
101/// Expanding `~/` in some path fields and not others would be worse than
102/// expanding in none: it teaches that tildes work and then fails somewhere
103/// the operator has no reason to suspect.
104#[cfg(test)]
105const PATH_FIELDS: &[&str] = &["script", "cwd", "out_file", "err_file"];
106
107/// Expands `~/` in every path field of `app`, in place.
108///
109/// # Errors
110/// Whatever [`expand_tilde`] refuses, named with the field that carried it.
111pub(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    /// A resolved secret in a log path becomes a filename on disk and is
134    /// reported by `shep flock` and `shep describe`, so it is refused at
135    /// config time rather than rendered. Nothing pinned this before, in
136    /// either field.
137    #[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    /// The token is refused where nothing could expand it, rather than
168    /// reaching a child as a filename with braces in it. Every templated
169    /// field kind, since one shared validator sees all four.
170    #[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            // The constant, not the literal: which spelling is right here
200            // is platform-dependent, and has its own test.
201            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        // Stored as written: the token expands at spawn, per instance, not
222        // here. Unlike `~`, which `expand_paths` resolves in place.
223        assert_eq!(
224            resolved.config().out_file.as_deref(),
225            Some("{{SHEP_HOME}}/logs/{{name}}-{{instance}}-out.log")
226        );
227    }
228
229    /// The collision check reads the rendered path, and `{{SHEP_HOME}}`
230    /// renders alike for every slot, so a path carrying only that one still
231    /// collides. Same rule `{{name}}` alone already falls under.
232    #[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        // A slot in the path.
277        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        // Asking for the merge on purpose.
283        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        // One instance cannot collide with itself.
290        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        // `{{{{instance}}}}` spells the token but renders to one literal path
298        // for every instance, so a substring check would wave it through.
299        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        // `{{name}}` is the same for every instance, so a path carrying only it
308        // still puts every instance on one file. Presence of a token is not the
309        // test; rendering differently is.
310        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    /// A resolved `env` value reaches the child and nothing else. A
317    /// resolved log path reaches `ProcessInfo`, every bus event carrying
318    /// one, `shep flock`, `shep describe`, `shep lookout` and a filename
319    /// under `$SHEP_HOME/logs`, which is four more places than the design
320    /// says a plaintext secret ever lives.
321    #[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    /// The refusal is the `secret:` prefix alone: a multi-instance app
342    /// needs `{{instance}}` in its log paths, and `{{name}}` is how the
343    /// other two path fields are written.
344    #[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    /// A namespaced reference is the same refusal: `SecretRef::parse` reads
354    /// both shapes, and only one of them being refused would be a hole
355    /// nobody could guess at.
356    #[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    /// All four path fields expand `~/`, and expanding some but not others
400    /// would be worse than expanding none: it teaches that tildes work and
401    /// then fails where the operator has no reason to suspect it.
402    #[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        // Expectations are built with `join` rather than written as literals:
414        // the separator is `/` here and `\` on Windows, and hardcoding one
415        // turned CI's three Windows legs red when this test first landed.
416        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    /// The anti-drift half. A fifth path field added to `AppConfig` fails
424    /// here until `expand_paths` handles it, which is the only thing keeping
425    /// the "all four or none" rule true over time.
426    #[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    /// A path with no tilde is untouched, so this is a no-op for every
461    /// absolute and relative path anyone already has.
462    #[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    /// `~user/` needs a passwd lookup whose answer depends on who the daemon
471    /// runs as, so it is refused rather than guessed at.
472    #[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    /// `$VAR` is not expanded, here or anywhere. A config file that expands
493    /// variables has to answer whose environment it means.
494    #[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    /// `~/` with no home to expand against is an error naming the field
503    /// rather than a path containing a literal tilde.
504    #[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    /// Pins [`expand_home_tilde`]'s own contract, apart from
515    /// [`expand_tilde`]'s wrapping into a [`NormalizeError`]: `shep-cli`'s
516    /// `shep adopt` calls it directly, with no app name or field to attach.
517    #[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    /// fails if either remedy names a variable in a spelling the reader's
540    /// own shell does not use. Both sentences tell an operator what to set,
541    /// and `%SHEP_HOME%` is ordinary text to a unix shell exactly as
542    /// `$SHEP_HOME` is to `cmd.exe`.
543    #[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        // Whole messages, spelled out rather than read back from the
557        // constants under test: a fragment passes while the prose around
558        // it regresses, and a constant agrees with itself.
559        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}