Skip to main content

shep_core/config/
normalize.rs

1//! Validation and normalization: `AppConfig` -> `ResolvedApp`
2//!
3//! `ResolvedApp` is a proof token: constructing one is only possible through
4//! [`normalize`], so daemon code can require it and skip re-validation.
5
6use core::fmt;
7
8use std::path::Path;
9
10use std::collections::BTreeSet;
11
12use globset::Glob;
13
14use crate::config::{
15    AppConfig, CronParseError, CronSchedule, KillSignal, ProbeConfig, ProbeTarget,
16};
17use crate::values::UpDuration;
18
19/// Shortest `interval` a `liveness_probe` may name.
20///
21/// The daemon's liveness loop floors whatever it is handed at this same
22/// value, so a smaller number would be silently honoured as this one with
23/// no warning.
24///
25/// One second is a floor no legitimate configuration wants to be under: for
26/// [`ProbeKind::Exec`](crate::config::ProbeKind::Exec) a shorter interval is
27/// that many process spawns per second, per sheep, for as long as it runs.
28const MIN_LIVENESS_INTERVAL: UpDuration = UpDuration::from_millis(1_000);
29
30/// Shortest `interval` a `readiness_probe` may name.
31///
32/// A whole second lower than [`MIN_LIVENESS_INTERVAL`]: the readiness wait
33/// honours its `interval` exactly as written, bounded by `listen_timeout`,
34/// so only zero needs rejecting. A fast app polling every 20ms to leave
35/// `starting` sooner must not lose that.
36const MIN_READINESS_INTERVAL: UpDuration = UpDuration::from_millis(1);
37
38/// Longest `action_timeout` an app may name.
39///
40/// The daemon clamps every RPC deadline to `MAX_DEADLINE_MS` (60s, in
41/// shep-daemon's `rpc` module), so a value at or above that line could
42/// never be honoured by any caller. Set 2s under the clamp so the daemon
43/// still has room to build the `TimedOut` row and send it back after the
44/// wait gives up.
45const MAX_ACTION_TIMEOUT: UpDuration = UpDuration::from_millis(58_000);
46
47/// A validated app config: only obtainable via [`normalize`].
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct ResolvedApp {
50    config: AppConfig,
51}
52
53impl ResolvedApp {
54    /// Borrow the validated configuration
55    #[must_use]
56    pub fn config(&self) -> &AppConfig {
57        &self.config
58    }
59
60    /// Unwrap the validated configuration (consumes the proof token)
61    #[must_use]
62    pub fn into_config(self) -> AppConfig {
63        self.config
64    }
65}
66
67/// Expands a leading `~/` against `home`, and refuses `~user/`.
68///
69/// `~/` only: `~user/...` needs a passwd lookup whose answer depends on who
70/// the daemon runs as, and `$VAR` is never expanded here or anywhere. A
71/// value with no leading `~` is returned unchanged. Thin wrapper over
72/// [`expand_home_tilde`], attaching the sheep name and field
73/// [`NormalizeError`] carries; `shep-cli`'s `shep adopt` calls
74/// [`expand_home_tilde`] directly instead.
75///
76/// # Errors
77/// - [`NormalizeError::TildeUser`] if the path names another user's home.
78/// - [`NormalizeError::NoHomeForTilde`] if `~/` is used and `home` is `None`.
79fn expand_tilde(
80    value: &str,
81    home: Option<&Path>,
82    name: &str,
83    field: &'static str,
84) -> Result<String, NormalizeError> {
85    expand_home_tilde(value, home).map_err(|err| match err {
86        TildeError::OtherUser => NormalizeError::TildeUser {
87            name: name.to_string(),
88            field,
89            value: value.to_string(),
90        },
91        TildeError::NoHome => NormalizeError::NoHomeForTilde {
92            name: name.to_string(),
93            field,
94        },
95    })
96}
97
98/// Why [`expand_home_tilde`] refused a value, with no per-field context
99/// attached, for a caller that has none to give.
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101pub enum TildeError {
102    /// The value names another user's home (`~user/...`). Refused rather
103    /// than resolved: answering it means a passwd lookup, and under a
104    /// systemd unit the answer depends on who the process runs as rather
105    /// than on who wrote the value.
106    OtherUser,
107    /// The value begins `~/` and no home directory could be determined.
108    NoHome,
109}
110
111impl fmt::Display for TildeError {
112    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
113        match self {
114            Self::OtherUser => write!(
115                f,
116                "shep expands only `~/` (your own home); another user's home needs a \
117                 passwd lookup whose answer depends on who the process runs as"
118            ),
119            Self::NoHome => write!(f, "begins with `~/` but no home directory could be found"),
120        }
121    }
122}
123
124impl core::error::Error for TildeError {}
125
126/// Expands a leading `~/` in `value` against `home`
127///
128/// Accepts `~` alone or `~/...`. `~user/` is refused, since resolving it
129/// takes a passwd lookup whose answer depends on who the process runs as.
130/// `$VAR` is never expanded. A value with no leading `~` comes back
131/// unchanged.
132///
133/// # Errors
134/// - [`TildeError::OtherUser`] if the value names another user's home.
135/// - [`TildeError::NoHome`] if `~/` is used and `home` is `None`.
136pub fn expand_home_tilde(value: &str, home: Option<&Path>) -> Result<String, TildeError> {
137    let Some(rest) = value.strip_prefix('~') else {
138        return Ok(value.to_string());
139    };
140    // `~` alone, or `~/...`. Anything else after the tilde names a user.
141    if !(rest.is_empty() || rest.starts_with('/')) {
142        return Err(TildeError::OtherUser);
143    }
144    let Some(home) = home else {
145        return Err(TildeError::NoHome);
146    };
147    // `join` would discard `home` for a rest that still looks absolute, so
148    // the separator is trimmed and the two halves are concatenated instead.
149    let joined = home.join(rest.trim_start_matches('/'));
150    Ok(joined.to_string_lossy().into_owned())
151}
152
153/// Every field of an [`AppConfig`] that carries a filesystem path.
154///
155/// Named once, and walked by [`expand_paths`] and by its own test, so a
156/// fifth path field added later fails that test until it is handled.
157/// Expanding `~/` in some path fields and not others would be worse than
158/// expanding in none: it teaches that tildes work and then fails somewhere
159/// the operator has no reason to suspect.
160#[cfg(test)]
161const PATH_FIELDS: &[&str] = &["script", "cwd", "out_file", "err_file"];
162
163/// Expands `~/` in every path field of `app`, in place.
164///
165/// # Errors
166/// Whatever [`expand_tilde`] refuses, named with the field that carried it.
167fn expand_paths(app: &mut AppConfig, home: Option<&Path>) -> Result<(), NormalizeError> {
168    let name = app.name.clone();
169    app.script = expand_tilde(&app.script, home, &name, "script")?;
170    for (field, slot) in [
171        ("cwd", &mut app.cwd),
172        ("out_file", &mut app.out_file),
173        ("err_file", &mut app.err_file),
174    ] {
175        if let Some(value) = slot {
176            *slot = Some(expand_tilde(value, home, &name, field)?);
177        }
178    }
179    Ok(())
180}
181
182/// Validates one app config
183///
184/// # Errors
185///
186/// - [`NormalizeError::MissingName`]: `name` is empty.
187/// - [`NormalizeError::InvalidName`]: `name` contains a path separator or a colon, or is `.`/`..`.
188/// - [`NormalizeError::ReservedEnvVar`]: `env` sets `SHEP_INSTANCE` or `SHEP_NAME`, which shep injects itself.
189/// - [`NormalizeError::IncrementVarRemoved`]: `increment_var` is set; removed in favour of `{{instance}}` templating.
190/// - [`NormalizeError::MissingScript`]: `script` is empty.
191/// - [`NormalizeError::ZeroInstances`]: `instances == 0`.
192/// - [`NormalizeError::InvalidCron`]: `cron_restart` is not valid in croner's dialect.
193/// - [`NormalizeError::InvalidTimezone`]: `cron_timezone` is not a name in the IANA time-zone database.
194/// - [`NormalizeError::InvalidProbe`]: a `readiness_probe`/`liveness_probe` target [`ProbeTarget::parse`] rejects.
195/// - [`NormalizeError::ZeroFailureThreshold`]: a probe's `failure_threshold` is explicitly `0`.
196/// - [`NormalizeError::IntervalBelowMinimum`]: a probe's `interval` is under the floor its own loop honours.
197/// - [`NormalizeError::ZeroMaxMemory`]: `max_memory` is `0`.
198/// - [`NormalizeError::ActionTimeoutTooLong`]: `action_timeout` is at or above the ceiling no RPC caller could wait past.
199/// - [`NormalizeError::InvalidKillSignal`]: `kill_signal` names a signal the daemon's stop ladder cannot send.
200/// - [`NormalizeError::WatchWithoutCwd`]: `watch` is `true` with no `cwd` set.
201/// - [`NormalizeError::ZeroWatchDelay`]: `watch_delay` is `0`.
202/// - [`NormalizeError::InvalidWatchGlob`]: a `watch_options` or `ignore_watch` pattern globset will not compile.
203/// - [`NormalizeError::BadTemplate`]: an `env`/`args`/log-path value carries an undefined or unclosed `{{...}}` token.
204/// - [`NormalizeError::SharedLogPath`]: `out_file` or `err_file` renders to the same path for two instances.
205/// - [`NormalizeError::TildeUser`]: a path field names another user's `~user` home.
206/// - [`NormalizeError::NoHomeForTilde`]: a path field expands `~/` but no home directory could be found.
207pub fn normalize(app: AppConfig) -> Result<ResolvedApp, NormalizeError> {
208    normalize_with_home(app, std::env::home_dir().as_deref())
209}
210
211/// [`normalize`], with the home directory supplied rather than read.
212///
213/// A parameter so the `~/` expansion above is testable without mutating the
214/// process environment, which is racy under a parallel `cargo test`. This is
215/// also the seam that matters for correctness rather than only for tests:
216/// the daemon may run as a different user than the CLI, so `~` has to be
217/// resolved where the config is normalised, not where it is executed.
218///
219/// # Errors
220/// The same set [`normalize`] documents.
221pub fn normalize_with_home(
222    mut app: AppConfig,
223    home: Option<&Path>,
224) -> Result<ResolvedApp, NormalizeError> {
225    if app.name.is_empty() {
226        return Err(NormalizeError::MissingName);
227    }
228    if app.name.contains(['/', '\\', ':']) || app.name == "." || app.name == ".." {
229        return Err(NormalizeError::InvalidName(app.name));
230    }
231    for var in ["SHEP_INSTANCE", "SHEP_NAME"] {
232        if app.env.contains_key(var) {
233            return Err(NormalizeError::ReservedEnvVar {
234                name: app.name.clone(),
235                var,
236            });
237        }
238    }
239    if let Some(var) = app.increment_var.take() {
240        return Err(NormalizeError::IncrementVarRemoved {
241            name: app.name.clone(),
242            var,
243        });
244    }
245    for (key, value) in &app.env {
246        validate_template(&app.name, &format!("env.{key}"), value)?;
247    }
248    for (index, value) in app.args.iter().enumerate() {
249        validate_template(&app.name, &format!("args[{index}]"), value)?;
250    }
251    for (field, value) in [("out_file", &app.out_file), ("err_file", &app.err_file)] {
252        if let Some(value) = value {
253            validate_template(&app.name, field, value)?;
254        }
255    }
256    if app.script.is_empty() {
257        return Err(NormalizeError::MissingScript);
258    }
259    // After the emptiness checks, so a missing script is reported as missing
260    // rather than as a path problem, and before every check below that reads
261    // a path.
262    expand_paths(&mut app, home)?;
263    if app.instances == 0 {
264        return Err(NormalizeError::ZeroInstances);
265    }
266    // After the template validation above, so a malformed `out_file`/`err_file`
267    // is reported as a bad template rather than as a shared path.
268    if app.instances > 1 && !app.merge_logs {
269        for (field, path) in [("out_file", &app.out_file), ("err_file", &app.err_file)] {
270            // Rendered rather than searched for a substring: an escaped
271            // `{{{{instance}}}}` contains the token's spelling but renders to
272            // one literal path for every instance, which is exactly the
273            // collision this refuses. Two slots that render alike collide.
274            if let Some(path) = path
275                && crate::config::template::render(path, &app.name, 0)
276                    == crate::config::template::render(path, &app.name, 1)
277            {
278                return Err(NormalizeError::SharedLogPath {
279                    name: app.name.clone(),
280                    field,
281                });
282            }
283        }
284    }
285    if let Some(pattern) = &app.cron_restart {
286        CronSchedule::parse(pattern, app.cron_timezone.as_deref()).map_err(|e| match e {
287            CronParseError::Pattern { pattern, reason } => {
288                NormalizeError::InvalidCron { pattern, reason }
289            }
290            CronParseError::Timezone { name } => NormalizeError::InvalidTimezone { name },
291        })?;
292    } else if let Some(tz_name) = &app.cron_timezone {
293        // A Flockfile can carry `cron_timezone` with no `cron_restart` to
294        // pair it with, still a typo the user wants to hear about.
295        crate::config::cron::parse_timezone_name(tz_name).ok_or_else(|| {
296            NormalizeError::InvalidTimezone {
297                name: tz_name.clone(),
298            }
299        })?;
300    }
301    validate_probe(
302        app.readiness_probe.as_ref(),
303        "readiness_probe",
304        MIN_READINESS_INTERVAL,
305    )?;
306    validate_probe(
307        app.liveness_probe.as_ref(),
308        "liveness_probe",
309        MIN_LIVENESS_INTERVAL,
310    )?;
311    if app.max_memory.is_some_and(|limit| limit.bytes() == 0) {
312        // A ceiling every live process is already over: the enforcer would
313        // report a breach on every reading, and the automatic restart that
314        // follows resets the restart budget rather than spending it, so
315        // `max_restarts` can never end the loop.
316        return Err(NormalizeError::ZeroMaxMemory { name: app.name });
317    }
318    if let Some(name) = &app.kill_signal
319        && KillSignal::parse(name).is_none()
320    {
321        // Rejected rather than clamped: a typo silently falling back to
322        // SIGTERM would cost every stop and reload for the life of the
323        // process, with no evidence but a detached daemon's log.
324        return Err(NormalizeError::InvalidKillSignal {
325            name: app.name,
326            value: name.clone(),
327        });
328    }
329    if app.action_timeout > MAX_ACTION_TIMEOUT {
330        // Rejected rather than clamped: every value above the ceiling is
331        // equally unreachable by any caller, so there is no honest lowered
332        // value to silently fall back to.
333        return Err(NormalizeError::ActionTimeoutTooLong {
334            name: app.name,
335            value: app.action_timeout,
336            max: MAX_ACTION_TIMEOUT,
337        });
338    }
339    if app.watch && app.cwd.is_none() {
340        // `watch` asked for a feature the daemon has no directory to arm:
341        // there is no cwd in the Flockfile, and defaulting to the daemon's
342        // own cwd risks watching the whole filesystem under a systemd unit
343        // with no `WorkingDirectory=`.
344        return Err(NormalizeError::WatchWithoutCwd { name: app.name });
345    }
346    if app.watch_delay == Some(UpDuration::from_millis(0)) {
347        // notify's debouncer derives its own poll tick as `watch_delay / 4`
348        // on a dedicated OS thread, so a zero turns that thread into
349        // `loop { sleep(0); lock(); }`, a CPU-spinning busy loop.
350        return Err(NormalizeError::ZeroWatchDelay { name: app.name });
351    }
352    // Both lists are checked whether or not `watch` is on, so a typo'd
353    // pattern is named now rather than the day `watch` flips on and nothing
354    // happens.
355    validate_watch_globs(&app.name, "watch_options", &app.watch_options)?;
356    validate_watch_globs(&app.name, "ignore_watch", &app.ignore_watch)?;
357    Ok(ResolvedApp { config: app })
358}
359
360/// Validates one `{{instance}}`/`{{name}}` template value, naming `field` in
361/// any rejection so the user knows which entry to edit.
362///
363/// # Errors
364/// [`NormalizeError::BadTemplate`] if `value` carries a `{{...}}` this
365/// grammar does not define, or a `{{` this value never closes. Both of
366/// [`crate::config::template::validate`]'s own rejections map here, so the
367/// two are told apart by the rendered `reason` the variant carries rather
368/// than by the variant.
369fn validate_template(name: &str, field: &str, value: &str) -> Result<(), NormalizeError> {
370    crate::config::template::validate(value).map_err(|reason| NormalizeError::BadTemplate {
371        name: name.to_string(),
372        field: field.to_string(),
373        reason: reason.to_string(),
374    })
375}
376
377/// Validates one of an app's two watch glob lists, rejecting any pattern
378/// globset will not compile. `field` is the Flockfile field name
379/// (`"watch_options"` or `"ignore_watch"`), carried into any error so the
380/// user knows which list to edit. The compiled globs are discarded: this
381/// function's job is rejection, and the daemon builds its own watch filter
382/// when it arms the watch.
383fn validate_watch_globs(
384    name: &str,
385    field: &'static str,
386    patterns: &[String],
387) -> Result<(), NormalizeError> {
388    for pattern in patterns {
389        Glob::new(pattern).map_err(|err| NormalizeError::InvalidWatchGlob {
390            name: name.to_string(),
391            field,
392            pattern: pattern.clone(),
393            reason: err.to_string(),
394        })?;
395    }
396    Ok(())
397}
398
399/// Validates one probe's target, `failure_threshold` and `interval`, if the
400/// probe is configured. `probe` is the Flockfile field name
401/// (`"readiness_probe"` or `"liveness_probe"`), carried into any error so
402/// the user knows which field to edit; `min_interval` is the floor that
403/// probe's own loop in the daemon honours. Its own parsed [`ProbeTarget`] is
404/// discarded: the daemon re-parses when it arms the probe.
405fn validate_probe(
406    probe: Option<&ProbeConfig>,
407    name: &'static str,
408    min_interval: UpDuration,
409) -> Result<(), NormalizeError> {
410    let Some(probe) = probe else {
411        return Ok(());
412    };
413    ProbeTarget::parse(probe).map_err(|reason| NormalizeError::InvalidProbe {
414        probe: name,
415        reason: reason.to_string(),
416    })?;
417    if probe.failure_threshold == 0 {
418        // Unhealthy before the first probe ever runs would make the liveness
419        // loop restart the sheep immediately and forever.
420        return Err(NormalizeError::ZeroFailureThreshold { probe: name });
421    }
422    if probe.interval < min_interval {
423        // A zero interval would spin either probe loop as fast as
424        // `ProbeKind::Exec` can spawn processes. A small but nonzero
425        // liveness interval is refused too: `spawn_liveness_task` rounds
426        // it up silently, leaving nothing to report the discrepancy.
427        return Err(NormalizeError::IntervalBelowMinimum {
428            probe: name,
429            value: probe.interval,
430            min: min_interval,
431        });
432    }
433    Ok(())
434}
435
436/// Validates a whole flock, rejecting duplicate sheep names
437///
438/// # Errors
439///
440/// Everything [`normalize`] returns, plus
441/// [`NormalizeError::DuplicateName`]: two apps share a `name`.
442pub fn normalize_all(apps: Vec<AppConfig>) -> Result<Vec<ResolvedApp>, NormalizeError> {
443    let mut seen = BTreeSet::new();
444    apps.into_iter()
445        .map(|app| {
446            if !seen.insert(app.name.clone()) {
447                return Err(NormalizeError::DuplicateName(app.name));
448            }
449            normalize(app)
450        })
451        .collect()
452}
453
454/// Error type returned from [`normalize`] and [`normalize_all`]
455///
456/// `#[non_exhaustive]`: every config surface this crate learns to validate
457/// brings its own rejection reasons with it.
458#[non_exhaustive]
459#[derive(Debug, Clone, PartialEq, Eq)]
460pub enum NormalizeError {
461    /// `name` is empty
462    MissingName,
463    /// `name` contains `/`, `\` or `:`, or is `.`/`..`. A path separator
464    /// would escape the shep home, since the name becomes a filesystem path
465    /// stem; a colon is the `name:slot` separator, and is also illegal in a
466    /// Windows filename, which a sheep name becomes part of. Carries the
467    /// name.
468    InvalidName(String),
469    /// An app's `env` sets a variable shep injects itself. Carries the sheep
470    /// name and the variable, so the error names the entry to edit.
471    ReservedEnvVar {
472        /// The sheep name
473        name: String,
474        /// The variable the app tried to set
475        var: &'static str,
476    },
477    /// `increment_var` was removed in favour of `{{instance}}` templating.
478    /// Carries the variable the app named, so the error can show the exact
479    /// line to write instead.
480    IncrementVarRemoved {
481        /// The sheep name
482        name: String,
483        /// The variable the app asked for
484        var: String,
485    },
486    /// `script` is empty
487    MissingScript,
488    /// `instances` is zero
489    ZeroInstances,
490    /// `cron_restart` is not valid in croner's dialect. Carries the pattern
491    /// and the rejection reason.
492    InvalidCron {
493        /// The pattern as the user wrote it
494        pattern: String,
495        /// Why it was rejected
496        reason: String,
497    },
498    /// `cron_timezone` is not a name in the IANA time-zone database
499    InvalidTimezone {
500        /// The value as the user wrote it
501        name: String,
502    },
503    /// Two apps in one flock share this name
504    DuplicateName(String),
505    /// A `readiness_probe` or `liveness_probe` target is malformed. Carries
506    /// which probe and the rendered reason.
507    InvalidProbe {
508        /// `"readiness_probe"` or `"liveness_probe"`, so the error names the
509        /// line the user has to edit.
510        probe: &'static str,
511        /// [`ProbeTarget::parse`]'s rendered rejection reason.
512        reason: String,
513    },
514    /// A `readiness_probe` or `liveness_probe` has `failure_threshold == 0`.
515    ZeroFailureThreshold {
516        /// `"readiness_probe"` or `"liveness_probe"`, so the error names the
517        /// line the user has to edit.
518        probe: &'static str,
519    },
520    /// A `readiness_probe` or `liveness_probe` has an `interval` under the
521    /// floor its own loop in the daemon honours. At `0` that would spin the
522    /// loop as fast as the runtime allows; a `liveness_probe` under a full
523    /// second would instead be silently polled at that second.
524    IntervalBelowMinimum {
525        /// `"readiness_probe"` or `"liveness_probe"`, so the error names the
526        /// line the user has to edit.
527        probe: &'static str,
528        /// The value as the user wrote it.
529        value: UpDuration,
530        /// The floor it failed.
531        min: UpDuration,
532    },
533    /// `max_memory` is `0`, a ceiling every live process is already over, so
534    /// the enforcer would restart the sheep on every poll forever. Carries
535    /// the app name.
536    ZeroMaxMemory {
537        /// The sheep name, so the error names which Flockfile entry to edit.
538        name: String,
539    },
540    /// `action_timeout` is at or above `normalize`'s own ceiling: a wait no
541    /// RPC caller could ever be given enough deadline to outlast, since the
542    /// daemon clamps every deadline a caller can ask for. Carries the app
543    /// name, the value as written, and the ceiling it failed.
544    ActionTimeoutTooLong {
545        /// The sheep name, so the error names which Flockfile entry to edit.
546        name: String,
547        /// The value as the user wrote it.
548        value: UpDuration,
549        /// The ceiling it failed.
550        max: UpDuration,
551    },
552    /// `kill_signal` names a signal the daemon's stop ladder cannot send.
553    /// Carries the app name and the value as written.
554    InvalidKillSignal {
555        /// The sheep name, so the error names which Flockfile entry to edit.
556        name: String,
557        /// The value as the user wrote it.
558        value: String,
559    },
560    /// `watch` is enabled but the app sets no `cwd`, so there is no
561    /// directory to watch. Carries the app name.
562    WatchWithoutCwd {
563        /// The sheep name, so the error names which Flockfile entry to edit.
564        name: String,
565    },
566    /// A path begins `~user/`, naming another user's home.
567    ///
568    /// Refused rather than resolved: answering it means a passwd lookup, and
569    /// under a systemd unit the answer is not obviously the one anyone meant.
570    /// `~/` is supported; this is not.
571    TildeUser {
572        /// The sheep name, so the error names which Flockfile entry to edit.
573        name: String,
574        /// Which field carried it.
575        field: &'static str,
576        /// The path as written.
577        value: String,
578    },
579    /// A path begins `~/` and no home directory could be determined.
580    NoHomeForTilde {
581        /// The sheep name, so the error names which Flockfile entry to edit.
582        name: String,
583        /// Which field carried it.
584        field: &'static str,
585    },
586    /// `watch_delay` is `0`, which would spin the debouncer's own OS thread.
587    /// Carries the app name.
588    ZeroWatchDelay {
589        /// The sheep name, so the error names which Flockfile entry to edit.
590        name: String,
591    },
592    /// A `watch_options` or `ignore_watch` pattern is one globset will not
593    /// compile, so the watch it describes could never be armed.
594    InvalidWatchGlob {
595        /// The sheep name, so the error names which Flockfile entry to edit.
596        name: String,
597        /// `"watch_options"` or `"ignore_watch"`, so the error names which
598        /// of the two lists to edit.
599        field: &'static str,
600        /// The pattern as the user wrote it.
601        pattern: String,
602        /// globset's own rendered reason.
603        reason: String,
604    },
605    /// A value carries a `{{...}}` that is not a template token, or a `{{`
606    /// it never closes. Carries the sheep name, which field held it, and the
607    /// rejection rendered.
608    BadTemplate {
609        /// The sheep name
610        name: String,
611        /// Which field, for example `env.WORKER` or `args[1]`
612        field: String,
613        /// The template grammar's own error, rendered, so this
614        /// variant does not have to restate the grammar's own copy
615        reason: String,
616    },
617    /// An explicit log path renders to the same string for two different
618    /// slots, the app runs more than one instance, and `merge_logs` is off,
619    /// so every instance would write to one file without having asked to.
620    /// A path with no `{{instance}}` is the ordinary case; a `{{name}}`-only
621    /// path and an escaped `{{{{instance}}}}` collide for the same reason.
622    SharedLogPath {
623        /// The sheep name
624        name: String,
625        /// `out_file` or `err_file`
626        field: &'static str,
627    },
628}
629
630impl fmt::Display for NormalizeError {
631    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
632        match self {
633            Self::MissingName => f.write_str("app config is missing a name"),
634            Self::InvalidName(n) => {
635                write!(
636                    f,
637                    "sheep name `{n}` may not contain a path separator or a colon, or be `.` or `..`; use `-` in place of a colon"
638                )
639            }
640            Self::ReservedEnvVar { name, var } => write!(
641                f,
642                "sheep `{name}` sets `{var}` in env, but shep injects it: use a different name, or `{{{{instance}}}}` in your own variable"
643            ),
644            Self::IncrementVarRemoved { name, var } => write!(
645                f,
646                "sheep `{name}` sets `increment_var`, which was removed: write `{var} = \"{{{{instance}}}}\"` under `[app.env]` instead"
647            ),
648            Self::MissingScript => f.write_str("app config is missing a script"),
649            Self::ZeroInstances => f.write_str("instances must be at least 1"),
650            Self::InvalidCron { pattern, reason } => {
651                write!(f, "invalid cron_restart pattern `{pattern}`: {reason}")
652            }
653            Self::InvalidTimezone { name } => {
654                write!(f, "`{name}` is not a recognized IANA timezone")
655            }
656            Self::DuplicateName(n) => write!(f, "duplicate sheep name `{n}`"),
657            Self::InvalidProbe { probe, reason } => write!(f, "{probe}: {reason}"),
658            Self::ZeroFailureThreshold { probe } => {
659                write!(f, "{probe}.failure_threshold must be at least 1")
660            }
661            Self::IntervalBelowMinimum { probe, value, min } => {
662                write!(f, "{probe}.interval is `{value}`: must be at least {min}")
663            }
664            Self::ZeroMaxMemory { name } => {
665                write!(
666                    f,
667                    "sheep `{name}` has max_memory = 0, a limit nothing can stay under"
668                )
669            }
670            Self::ActionTimeoutTooLong { name, value, max } => {
671                write!(
672                    f,
673                    "sheep `{name}` has action_timeout = {value}: must be at most {max}, \
674                     the longest wait any caller's deadline could ever cover"
675                )
676            }
677            Self::InvalidKillSignal { name, value } => {
678                write!(
679                    f,
680                    "`{name}`: kill_signal `{value}` is not one shep can send (accepted: {})",
681                    KillSignal::ACCEPTED.join(", ")
682                )
683            }
684            Self::TildeUser { name, field, value } => write!(
685                f,
686                "`{name}`: {field} is `{value}`, and shep expands only `~/` (your own home). \
687                 Another user's home needs a passwd lookup whose answer depends on who the \
688                 daemon runs as, so write the path out in full instead."
689            ),
690            Self::NoHomeForTilde { name, field } => write!(
691                f,
692                "`{name}`: {field} begins with `~/` but no home directory could be found. \
693                 Set $HOME, or write the path out in full."
694            ),
695            Self::WatchWithoutCwd { name } => {
696                write!(f, "sheep `{name}` has watch = true but no cwd to watch")
697            }
698            Self::ZeroWatchDelay { name } => {
699                write!(
700                    f,
701                    "sheep `{name}` has watch_delay = 0: must be greater than 0"
702                )
703            }
704            Self::InvalidWatchGlob {
705                name,
706                field,
707                pattern,
708                reason,
709            } => write!(
710                f,
711                "sheep `{name}` has an invalid {field} pattern `{pattern}`: {reason}"
712            ),
713            Self::BadTemplate {
714                name,
715                field,
716                reason,
717            } => write!(f, "sheep `{name}`, {field}: {reason}"),
718            Self::SharedLogPath { name, field } => write!(
719                f,
720                "sheep `{name}` runs several instances and sets `{field}` to one path: put `{{{{instance}}}}` in it, or set `merge_logs = true` to share it on purpose"
721            ),
722        }
723    }
724}
725
726impl core::error::Error for NormalizeError {}
727
728#[cfg(test)]
729mod tests {
730    use super::*;
731
732    /// All four path fields expand `~/`, and expanding some but not others
733    /// would be worse than expanding none: it teaches that tildes work and
734    /// then fails where the operator has no reason to suspect it.
735    #[test]
736    fn every_path_field_expands_a_leading_tilde() {
737        let home = Path::new("/home/ada");
738        let mut app = AppConfig::minimal("web", "~/app/server.js");
739        app.cwd = Some("~/app".to_string());
740        app.out_file = Some("~/logs/out.log".to_string());
741        app.err_file = Some("~/logs/err.log".to_string());
742
743        let resolved = normalize_with_home(app, Some(home)).expect("all four expand");
744        let c = resolved.config();
745        // Expectations are built with `join` rather than written as literals:
746        // the separator is `/` here and `\` on Windows, and hardcoding one
747        // turned CI's three Windows legs red when this test first landed.
748        let expect = |rest: &str| home.join(rest).to_string_lossy().into_owned();
749        assert_eq!(c.script, expect("app/server.js"));
750        assert_eq!(c.cwd.as_deref(), Some(expect("app").as_str()));
751        assert_eq!(c.out_file.as_deref(), Some(expect("logs/out.log").as_str()));
752        assert_eq!(c.err_file.as_deref(), Some(expect("logs/err.log").as_str()));
753    }
754
755    /// The anti-drift half. A fifth path field added to `AppConfig` fails
756    /// here until `expand_paths` handles it, which is the only thing keeping
757    /// the "all four or none" rule true over time.
758    #[test]
759    fn the_path_field_list_matches_what_expand_paths_walks() {
760        let home = Path::new("/home/ada");
761        let mut app = AppConfig::minimal("web", "~/s");
762        app.cwd = Some("~/c".to_string());
763        app.out_file = Some("~/o".to_string());
764        app.err_file = Some("~/e".to_string());
765
766        let resolved = normalize_with_home(app, Some(home)).expect("expands");
767        let c = resolved.config();
768        let expanded = [
769            ("script", Some(c.script.as_str())),
770            ("cwd", c.cwd.as_deref()),
771            ("out_file", c.out_file.as_deref()),
772            ("err_file", c.err_file.as_deref()),
773        ];
774        assert_eq!(
775            expanded.len(),
776            PATH_FIELDS.len(),
777            "PATH_FIELDS and this test must name the same set"
778        );
779        for (field, value) in expanded {
780            assert!(
781                PATH_FIELDS.contains(&field),
782                "`{field}` is not in PATH_FIELDS"
783            );
784            assert!(
785                value.is_some_and(|v| v.starts_with("/home/ada")),
786                "`{field}` was not expanded: {value:?}"
787            );
788        }
789    }
790
791    /// A path with no tilde is untouched, so this is a no-op for every
792    /// absolute and relative path anyone already has.
793    #[test]
794    fn a_path_without_a_tilde_is_left_exactly_as_written() {
795        let app = AppConfig::minimal("web", "./server.js");
796        let resolved =
797            normalize_with_home(app, Some(Path::new("/home/ada"))).expect("no tilde, no change");
798        assert_eq!(resolved.config().script, "./server.js");
799    }
800
801    /// `~user/` needs a passwd lookup whose answer depends on who the daemon
802    /// runs as, so it is refused rather than guessed at.
803    #[test]
804    fn another_users_home_is_refused_rather_than_resolved() {
805        let app = AppConfig::minimal("web", "~deploy/app/server.js");
806        let err = normalize_with_home(app, Some(Path::new("/home/ada")))
807            .expect_err("~user/ must not resolve");
808        assert!(
809            matches!(err, NormalizeError::TildeUser { field, .. } if field == "script"),
810            "the refusal names the field: {err:?}"
811        );
812        let rendered = err.to_string();
813        assert!(
814            rendered.contains("~/"),
815            "and says what IS supported: {rendered}"
816        );
817        assert!(
818            !rendered.contains('\u{2014}') && !rendered.contains('\u{2013}'),
819            "no em or en dash in copy a user reads: {rendered}"
820        );
821    }
822
823    /// `$VAR` is not expanded, here or anywhere. A config file that expands
824    /// variables has to answer whose environment it means.
825    #[test]
826    fn a_dollar_variable_is_not_expanded() {
827        let app = AppConfig::minimal("web", "$HOME/server.js");
828        let resolved = normalize_with_home(app, Some(Path::new("/home/ada"))).expect("left alone");
829        assert_eq!(resolved.config().script, "$HOME/server.js");
830    }
831
832    /// `~/` with no home to expand against is an error naming the field
833    /// rather than a path containing a literal tilde.
834    #[test]
835    fn a_tilde_with_no_home_is_an_error_not_a_literal_path() {
836        let app = AppConfig::minimal("web", "~/server.js");
837        let err = normalize_with_home(app, None).expect_err("nothing to expand against");
838        assert!(
839            matches!(err, NormalizeError::NoHomeForTilde { .. }),
840            "{err:?}"
841        );
842    }
843
844    /// Pins [`expand_home_tilde`]'s own contract, apart from
845    /// [`expand_tilde`]'s wrapping into a [`NormalizeError`]: `shep-cli`'s
846    /// `shep adopt` calls it directly, with no app name or field to attach.
847    #[test]
848    fn expand_home_tilde_covers_its_four_documented_cases() {
849        let home = Path::new("/home/ada");
850        assert_eq!(
851            expand_home_tilde("~/bin/dog", Some(home)).unwrap(),
852            home.join("bin/dog").to_string_lossy()
853        );
854        assert_eq!(
855            expand_home_tilde("/opt/bin/dog", Some(home)).unwrap(),
856            "/opt/bin/dog",
857            "a value with no leading ~ is returned unchanged"
858        );
859        assert_eq!(
860            expand_home_tilde("~/bin/dog", None).unwrap_err(),
861            TildeError::NoHome
862        );
863        assert_eq!(
864            expand_home_tilde("~deploy/bin/dog", Some(home)).unwrap_err(),
865            TildeError::OtherUser
866        );
867    }
868
869    /// `reuse_port` loads because reload's overlap mode is chosen from it:
870    /// refusing it would deny an operator the only way to ask for an
871    /// overlapping reload.
872    #[test]
873    fn reuse_port_loads_now_that_reload_reads_it() {
874        let mut app = AppConfig::minimal("web", "./server");
875        app.reuse_port = true;
876
877        let resolved = normalize(app).expect("reuse_port is implemented");
878        assert!(resolved.config().reuse_port);
879    }
880
881    /// The default is off, so every Flockfile that does not mention it keeps
882    /// loading.
883    #[test]
884    fn an_app_that_never_mentions_reuse_port_still_normalizes() {
885        let resolved = normalize(AppConfig::minimal("web", "./server"))
886            .expect("the common case must be untouched");
887        assert!(!resolved.config().reuse_port);
888    }
889    use crate::config::AppConfig;
890
891    #[test]
892    fn a_colon_in_a_name_is_refused_because_it_is_the_instance_separator() {
893        let err = normalize(AppConfig::minimal("web:2", "./srv")).unwrap_err();
894        assert_eq!(err, NormalizeError::InvalidName("web:2".to_string()));
895
896        let rendered = err.to_string();
897        assert!(rendered.contains(':'), "says which character: {rendered}");
898        // Spec D3 requires the error to name the character and suggest `-`.
899        assert!(
900            rendered.contains("`-`"),
901            "suggests the stand-in: {rendered}"
902        );
903        assert!(
904            !rendered.contains('\u{2014}') && !rendered.contains('\u{2013}'),
905            "no em or en dash in copy a user reads: {rendered}"
906        );
907
908        assert!(normalize(AppConfig::minimal("web-2", "./srv")).is_ok());
909    }
910
911    #[test]
912    fn increment_var_is_refused_and_says_what_replaced_it() {
913        let mut app = AppConfig::minimal("web", "./srv");
914        app.increment_var = Some("WORKER_ID".to_string());
915        let err = normalize(app).unwrap_err();
916        let rendered = err.to_string();
917        assert!(rendered.contains("increment_var"), "{rendered}");
918        assert!(
919            rendered.contains("WORKER_ID"),
920            "keeps their name: {rendered}"
921        );
922        assert!(rendered.contains("{{instance}}"), "and the fix: {rendered}");
923        assert!(
924            !rendered.contains('\u{2014}') && !rendered.contains('\u{2013}'),
925            "no em or en dash in copy a user reads: {rendered}"
926        );
927    }
928
929    #[test]
930    fn the_reserved_env_vars_are_refused_rather_than_overwritten() {
931        for var in ["SHEP_INSTANCE", "SHEP_NAME"] {
932            let mut app = AppConfig::minimal("web", "./srv");
933            app.env.insert(var.to_string(), "mine".to_string());
934            let err = normalize(app).unwrap_err();
935            let rendered = err.to_string();
936            assert!(rendered.contains(var), "names the variable: {rendered}");
937            assert!(
938                !rendered.contains('\u{2014}') && !rendered.contains('\u{2013}'),
939                "no em or en dash in copy a user reads: {rendered}"
940            );
941        }
942    }
943
944    #[test]
945    fn valid_minimal_config_normalizes() {
946        let resolved = normalize(AppConfig::minimal("web", "./srv")).unwrap();
947        assert_eq!(resolved.config().name, "web");
948    }
949
950    #[test]
951    fn names_that_reach_the_filesystem_are_rejected() {
952        // A name becomes a log/pid file stem via Path::join; a slash-prefixed
953        // or dotdot name would escape $SHEP_HOME. Reject at the config boundary.
954        for bad in ["/etc/passwd", "..", ".", "a/b", "a\\b"] {
955            assert_eq!(
956                normalize(AppConfig::minimal(bad, "./srv")).unwrap_err(),
957                NormalizeError::InvalidName(bad.to_string())
958            );
959        }
960        assert!(normalize(AppConfig::minimal("web-1", "./srv")).is_ok());
961    }
962
963    #[test]
964    fn missing_name_and_script_are_distinct_errors() {
965        assert_eq!(
966            normalize(AppConfig::minimal("", "./srv")).unwrap_err(),
967            NormalizeError::MissingName
968        );
969        assert_eq!(
970            normalize(AppConfig::minimal("web", "")).unwrap_err(),
971            NormalizeError::MissingScript
972        );
973    }
974
975    #[test]
976    fn zero_instances_rejected() {
977        let mut app = AppConfig::minimal("web", "./srv");
978        app.instances = 0;
979        assert_eq!(normalize(app).unwrap_err(), NormalizeError::ZeroInstances);
980    }
981
982    #[test]
983    fn bad_cron_pattern_rejected_with_pattern_and_reason_carried_through() {
984        // fails if the reason is not carried through from croner.
985        let mut app = AppConfig::minimal("web", "./srv");
986        app.cron_restart = Some("not a cron".to_string());
987        match normalize(app).unwrap_err() {
988            NormalizeError::InvalidCron { pattern, reason } => {
989                assert_eq!(pattern, "not a cron");
990                assert!(!reason.is_empty());
991            }
992            other => panic!("expected InvalidCron, got {other:?}"),
993        }
994    }
995
996    #[test]
997    fn five_tokens_of_garbage_cron_pattern_rejected() {
998        // fails if the validator only counts whitespace-separated tokens
999        // instead of checking each field's range: five numeric-looking
1000        // tokens, all out of range.
1001        let mut app = AppConfig::minimal("web", "./srv");
1002        app.cron_restart = Some("99 99 99 99 99".to_string());
1003        match normalize(app).unwrap_err() {
1004            NormalizeError::InvalidCron { pattern, .. } => {
1005                assert_eq!(pattern, "99 99 99 99 99");
1006            }
1007            other => panic!("expected InvalidCron, got {other:?}"),
1008        }
1009    }
1010
1011    #[test]
1012    fn bad_cron_timezone_rejected_alongside_a_valid_cron_restart() {
1013        // fails if the `cron_restart` branch maps CronParseError::Timezone to
1014        // anything but NormalizeError::InvalidTimezone. CronSchedule::parse
1015        // resolves the zone before the pattern, so a valid pattern paired
1016        // with a bad zone is the only input that reaches that arm.
1017        let mut app = AppConfig::minimal("web", "./srv");
1018        app.cron_restart = Some("0 3 * * *".to_string());
1019        app.cron_timezone = Some("Mars/Olympus".to_string());
1020        match normalize(app).unwrap_err() {
1021            NormalizeError::InvalidTimezone { name } => assert_eq!(name, "Mars/Olympus"),
1022            other => panic!("expected InvalidTimezone, got {other:?}"),
1023        }
1024    }
1025
1026    #[test]
1027    fn cron_timezone_validated_even_without_cron_restart() {
1028        // fails if timezone validation is skipped when there's no pattern to
1029        // pair it with: a Flockfile with only a bad `cron_timezone` is a
1030        // typo the user wants to hear about.
1031        let mut app = AppConfig::minimal("web", "./srv");
1032        app.cron_timezone = Some("Mars/Olympus".to_string());
1033        match normalize(app).unwrap_err() {
1034            NormalizeError::InvalidTimezone { name } => assert_eq!(name, "Mars/Olympus"),
1035            other => panic!("expected InvalidTimezone, got {other:?}"),
1036        }
1037    }
1038
1039    #[test]
1040    fn duplicate_names_rejected_across_a_flock() {
1041        let apps = vec![
1042            AppConfig::minimal("web", "./a"),
1043            AppConfig::minimal("web", "./b"),
1044        ];
1045        assert_eq!(
1046            normalize_all(apps).unwrap_err(),
1047            NormalizeError::DuplicateName("web".to_string())
1048        );
1049    }
1050
1051    fn probe_config(target: &str) -> crate::config::ProbeConfig {
1052        crate::config::ProbeConfig {
1053            kind: crate::config::ProbeKind::Http,
1054            target: target.to_string(),
1055            interval: crate::values::UpDuration::from_millis(10_000),
1056            timeout: crate::values::UpDuration::from_millis(5_000),
1057            failure_threshold: 3,
1058        }
1059    }
1060
1061    #[test]
1062    fn malformed_readiness_probe_target_rejected_naming_the_field() {
1063        // fails if validate_probe is never called for readiness_probe, or if
1064        // it drops which of the two probe fields the rejection came from
1065        let mut app = AppConfig::minimal("web", "./srv");
1066        app.readiness_probe = Some(probe_config("not-a-url"));
1067        match normalize(app).unwrap_err() {
1068            NormalizeError::InvalidProbe { probe, reason } => {
1069                assert_eq!(probe, "readiness_probe");
1070                assert!(!reason.is_empty());
1071            }
1072            other => panic!("expected InvalidProbe, got {other:?}"),
1073        }
1074    }
1075
1076    #[test]
1077    fn malformed_liveness_probe_target_rejected_naming_the_field() {
1078        // fails if only readiness_probe is ever validated, leaving a bad
1079        // liveness_probe target to surface later at the daemon's first poll
1080        let mut app = AppConfig::minimal("web", "./srv");
1081        app.liveness_probe = Some(probe_config("not-a-url"));
1082        match normalize(app).unwrap_err() {
1083            NormalizeError::InvalidProbe { probe, .. } => assert_eq!(probe, "liveness_probe"),
1084            other => panic!("expected InvalidProbe, got {other:?}"),
1085        }
1086    }
1087
1088    #[test]
1089    fn valid_probe_targets_accepted() {
1090        // fails if validate_probe rejects a well-formed target outright
1091        let mut app = AppConfig::minimal("web", "./srv");
1092        app.readiness_probe = Some(probe_config("http://127.0.0.1:8080/healthz"));
1093        assert!(normalize(app).is_ok());
1094    }
1095
1096    #[test]
1097    fn zero_failure_threshold_rejected() {
1098        // fails if failure_threshold is never inspected: a threshold of 0
1099        // means "unhealthy before the first probe ever runs"
1100        let mut app = AppConfig::minimal("web", "./srv");
1101        let mut probe = probe_config("http://127.0.0.1:8080/healthz");
1102        probe.failure_threshold = 0;
1103        app.readiness_probe = Some(probe);
1104        let err = normalize(app).unwrap_err();
1105        assert_eq!(
1106            err,
1107            NormalizeError::ZeroFailureThreshold {
1108                probe: "readiness_probe"
1109            }
1110        );
1111        // fails if the message regresses to a bare variant name with no
1112        // explanation.
1113        assert!(err.to_string().contains("at least 1"), "{err}");
1114    }
1115
1116    #[test]
1117    fn zero_interval_rejected() {
1118        // fails if interval is never inspected: a zero interval would spin
1119        // the readiness wait as fast as the runtime allows for the whole
1120        // `listen_timeout` (`await_ready` does not floor it)
1121        let mut app = AppConfig::minimal("web", "./srv");
1122        let mut probe = probe_config("http://127.0.0.1:8080/healthz");
1123        probe.interval = UpDuration::from_millis(0);
1124        app.readiness_probe = Some(probe);
1125        let err = normalize(app).unwrap_err();
1126        assert_eq!(
1127            err,
1128            NormalizeError::IntervalBelowMinimum {
1129                probe: "readiness_probe",
1130                value: UpDuration::from_millis(0),
1131                min: MIN_READINESS_INTERVAL,
1132            }
1133        );
1134        // fails if the message regresses to a bare variant name with no
1135        // explanation.
1136        assert!(err.to_string().contains("must be at least"), "{err}");
1137    }
1138
1139    #[test]
1140    fn a_liveness_interval_under_the_floor_is_rejected_rather_than_clamped() {
1141        // fails if the liveness check is `interval == 0` rather than a
1142        // floor: a 500ms interval survives equality and is then silently
1143        // rounded up to a full second by `MIN_PROBE_INTERVAL`. Also fails
1144        // if the rejection drops the value the user wrote.
1145        let mut app = AppConfig::minimal("web", "./srv");
1146        let mut probe = probe_config("http://127.0.0.1:8080/healthz");
1147        probe.interval = UpDuration::from_millis(500);
1148        app.liveness_probe = Some(probe);
1149        let err = normalize(app).unwrap_err();
1150        assert_eq!(
1151            err,
1152            NormalizeError::IntervalBelowMinimum {
1153                probe: "liveness_probe",
1154                value: UpDuration::from_millis(500),
1155                min: MIN_LIVENESS_INTERVAL,
1156            }
1157        );
1158        assert!(err.to_string().contains("500"), "{err}");
1159    }
1160
1161    #[test]
1162    fn a_liveness_interval_exactly_at_the_floor_is_accepted() {
1163        // fails if the comparison is `<=` rather than `<`: the floor is a
1164        // value the liveness loop honours exactly, so naming it must not be
1165        // an error.
1166        let mut app = AppConfig::minimal("web", "./srv");
1167        let mut probe = probe_config("http://127.0.0.1:8080/healthz");
1168        probe.interval = MIN_LIVENESS_INTERVAL;
1169        app.liveness_probe = Some(probe);
1170        assert!(normalize(app).is_ok());
1171    }
1172
1173    #[test]
1174    fn a_sub_second_readiness_interval_is_accepted() {
1175        // fails if both probes are validated against the liveness floor: a
1176        // readiness wait is bounded by `listen_timeout` and honours its
1177        // `interval` exactly as written, so a fast app polling every 50ms
1178        // to leave `starting` sooner must not be refused.
1179        let mut app = AppConfig::minimal("web", "./srv");
1180        let mut probe = probe_config("http://127.0.0.1:8080/healthz");
1181        probe.interval = UpDuration::from_millis(50);
1182        app.readiness_probe = Some(probe);
1183        assert!(normalize(app).is_ok());
1184    }
1185
1186    #[test]
1187    fn zero_max_memory_rejected() {
1188        // fails if `max_memory` is never inspected: zero is a ceiling every
1189        // live process is already over, so the enforcer breaches on every
1190        // reading and the automatic restart that follows resets the
1191        // restart budget instead of spending it.
1192        let mut app = AppConfig::minimal("web", "./srv");
1193        app.max_memory = Some(crate::values::MemSize::from_bytes(0));
1194        let err = normalize(app).unwrap_err();
1195        assert_eq!(
1196            err,
1197            NormalizeError::ZeroMaxMemory {
1198                name: "web".to_string()
1199            }
1200        );
1201        // fails if the message regresses to a bare variant name with no
1202        // explanation.
1203        assert!(err.to_string().contains("max_memory"), "{err}");
1204    }
1205
1206    #[test]
1207    fn a_nonzero_max_memory_is_accepted() {
1208        // fails if the check fires on `max_memory` being set at all rather
1209        // than on its being zero: that would refuse every app that
1210        // configures a limit, which is the whole feature
1211        let mut app = AppConfig::minimal("web", "./srv");
1212        app.max_memory = Some("512M".parse().unwrap());
1213        assert!(normalize(app).is_ok());
1214    }
1215
1216    /// fails if a `kill_signal` shep cannot send is accepted here: that puts
1217    /// SIGTERM on the wire for the life of the process with nothing but one
1218    /// daemon log line to say so.
1219    #[test]
1220    fn a_kill_signal_shep_cannot_send_is_refused_by_name() {
1221        let mut app = AppConfig::minimal("web", "./srv");
1222        app.kill_signal = Some("SIGUSR1".to_string());
1223
1224        let err = normalize(app).unwrap_err();
1225
1226        assert_eq!(
1227            err,
1228            NormalizeError::InvalidKillSignal {
1229                name: "web".to_string(),
1230                value: "SIGUSR1".to_string(),
1231            }
1232        );
1233        // The message has to name the accepted set, because the operator's next
1234        // move is picking a different word and there is nowhere else to look.
1235        let rendered = err.to_string();
1236        assert!(rendered.contains("SIGUSR1"), "{rendered}");
1237        assert!(rendered.contains("SIGTERM"), "{rendered}");
1238        assert!(rendered.contains("SIGUSR2"), "{rendered}");
1239    }
1240
1241    /// fails if the four supported names, their bare forms, or a lowercase
1242    /// spelling stop being accepted. This is the compatibility half: every
1243    /// spelling `stop_signal` accepted before this task must still normalize.
1244    #[test]
1245    fn every_spelling_the_daemon_already_accepted_still_normalizes() {
1246        for name in [
1247            "SIGTERM", "TERM", "sigterm", "term", "SIGINT", "INT", "SIGQUIT", "QUIT", "SIGUSR2",
1248            "USR2", "sigusr2",
1249        ] {
1250            let mut app = AppConfig::minimal("web", "./srv");
1251            app.kill_signal = Some(name.to_string());
1252            assert!(
1253                normalize(app).is_ok(),
1254                "`{name}` was accepted before this task and must still be"
1255            );
1256        }
1257    }
1258
1259    /// fails if an unset `kill_signal` is refused: the overwhelmingly common
1260    /// case, and the one a validation pass is most likely to break by
1261    /// treating `None` as an empty string.
1262    #[test]
1263    fn an_unset_kill_signal_is_not_a_config_error() {
1264        let app = AppConfig::minimal("web", "./srv");
1265        assert!(app.kill_signal.is_none());
1266        assert!(normalize(app).is_ok());
1267    }
1268
1269    #[test]
1270    fn action_timeout_past_the_ceiling_is_rejected() {
1271        // fails if `action_timeout` is never inspected. One millisecond over
1272        // the ceiling is deliberate: a test at a round number like 60s could
1273        // pass by coincidence if the check used the wrong constant entirely
1274        // (`MAX_DEADLINE_MS` itself, say, instead of the margin under it).
1275        let mut app = AppConfig::minimal("web", "./srv");
1276        app.action_timeout = UpDuration::from_millis(MAX_ACTION_TIMEOUT.as_millis() + 1);
1277        let err = normalize(app).unwrap_err();
1278        assert_eq!(
1279            err,
1280            NormalizeError::ActionTimeoutTooLong {
1281                name: "web".to_string(),
1282                value: UpDuration::from_millis(MAX_ACTION_TIMEOUT.as_millis() + 1),
1283                max: MAX_ACTION_TIMEOUT,
1284            }
1285        );
1286        // fails if the message regresses to a bare variant name with no
1287        // explanation.
1288        assert!(err.to_string().contains("action_timeout"), "{err}");
1289    }
1290
1291    #[test]
1292    fn action_timeout_at_the_ceiling_is_accepted() {
1293        // fails if the comparison is `>=` rather than `>`: the ceiling
1294        // itself still leaves the daemon its full margin under the hard
1295        // clamp, so it is not one of the values nothing could ever satisfy.
1296        let mut app = AppConfig::minimal("web", "./srv");
1297        app.action_timeout = MAX_ACTION_TIMEOUT;
1298        assert!(normalize(app).is_ok());
1299    }
1300
1301    #[test]
1302    fn the_default_action_timeout_is_accepted() {
1303        // fails if `AppConfig::default()`'s own value ever drifts past the
1304        // ceiling normalize enforces: the one combination that must never
1305        // reject the config nobody customized.
1306        assert!(normalize(AppConfig::minimal("web", "./srv")).is_ok());
1307    }
1308
1309    #[test]
1310    fn zero_watch_delay_rejected() {
1311        // fails if `watch_delay` is never inspected. notify's debouncer
1312        // derives its poll tick as `watch_delay / 4` and sleeps it on its own
1313        // OS thread, so zero is `loop { sleep(0); lock(); }`, a CPU-spinning
1314        // busy loop.
1315        let mut app = AppConfig::minimal("web", "./srv");
1316        app.watch = true;
1317        app.cwd = Some("/srv/web".to_string());
1318        app.watch_delay = Some(UpDuration::from_millis(0));
1319        let err = normalize(app).unwrap_err();
1320        assert_eq!(
1321            err,
1322            NormalizeError::ZeroWatchDelay {
1323                name: "web".to_string()
1324            }
1325        );
1326        // fails if the message regresses to a bare variant name with no
1327        // explanation.
1328        assert!(err.to_string().contains("watch_delay"), "{err}");
1329    }
1330
1331    #[test]
1332    fn a_zero_watch_delay_is_rejected_with_watch_off() {
1333        // fails if the check is nested inside the `watch` block: an app
1334        // carrying `watch_delay = "0"` with `watch = false` would normalize
1335        // clean, and the spin would arrive the day someone flips `watch =
1336        // true`
1337        let mut app = AppConfig::minimal("web", "./srv");
1338        app.watch_delay = Some(UpDuration::from_millis(0));
1339        assert!(matches!(
1340            normalize(app).unwrap_err(),
1341            NormalizeError::ZeroWatchDelay { .. }
1342        ));
1343    }
1344
1345    #[test]
1346    fn a_nonzero_watch_delay_is_accepted() {
1347        // fails if the check fires on `watch_delay` being set at all rather
1348        // than on its being zero: that would refuse every app that tunes
1349        // its own debounce
1350        let mut app = AppConfig::minimal("web", "./srv");
1351        app.watch = true;
1352        app.cwd = Some("/srv/web".to_string());
1353        app.watch_delay = Some(UpDuration::from_millis(1));
1354        assert!(normalize(app).is_ok());
1355    }
1356
1357    #[test]
1358    fn default_failure_threshold_from_toml_accepted() {
1359        // fails if the check fires on the ordinary default instead of only
1360        // an explicit 0. Deserializes a Flockfile snippet that omits
1361        // `failure_threshold`, exercising the real serde default rather
1362        // than `probe_config`'s hardcoded `3`.
1363        let src = r#"
1364name = "web"
1365script = "./srv"
1366
1367[readiness_probe]
1368kind = "http"
1369target = "http://127.0.0.1:8080/healthz"
1370"#;
1371        let app: AppConfig = toml::from_str(src).unwrap();
1372        assert!(normalize(app).is_ok());
1373    }
1374
1375    #[test]
1376    fn watch_true_without_cwd_rejected_naming_the_app() {
1377        // fails if a validator never looks at `watch`, or looks at it but
1378        // carries no app name, leaving the user unable to tell which
1379        // Flockfile entry to edit
1380        let mut app = AppConfig::minimal("web", "./srv");
1381        app.watch = true;
1382        let err = normalize(app).unwrap_err();
1383        assert_eq!(
1384            err,
1385            NormalizeError::WatchWithoutCwd {
1386                name: "web".to_string()
1387            }
1388        );
1389        // fails if the message regresses to a bare variant name with no
1390        // explanation.
1391        assert!(err.to_string().contains("no cwd to watch"), "{err}");
1392    }
1393
1394    #[test]
1395    fn watch_true_with_cwd_accepted() {
1396        // fails if the check fires on `watch` alone, ignoring that a cwd was
1397        // actually provided
1398        let mut app = AppConfig::minimal("web", "./srv");
1399        app.watch = true;
1400        app.cwd = Some("/srv/web".to_string());
1401        assert!(normalize(app).is_ok());
1402    }
1403
1404    #[test]
1405    fn a_watch_options_glob_that_will_not_compile_is_rejected() {
1406        // fails if `watch_options` patterns are never compiled at config
1407        // time. Also fails if the rejection blames the whole list instead
1408        // of the one bad pattern: the valid `src/**` comes first, so
1409        // naming it, or the patterns joined together, is wrong.
1410        let mut app = AppConfig::minimal("web", "./srv");
1411        app.watch = true;
1412        app.cwd = Some("/srv/web".to_string());
1413        app.watch_options = vec!["src/**".to_string(), "[".to_string()];
1414        let err = normalize(app).unwrap_err();
1415        assert_eq!(
1416            err,
1417            NormalizeError::InvalidWatchGlob {
1418                name: "web".to_string(),
1419                field: "watch_options",
1420                pattern: "[".to_string(),
1421                reason: Glob::new("[").unwrap_err().to_string(),
1422            }
1423        );
1424        // fails if the message drops the app name, the list or the pattern:
1425        // the three things that name the Flockfile line to edit.
1426        let rendered = err.to_string();
1427        for expected in ["web", "watch_options", "`[`"] {
1428            assert!(
1429                rendered.contains(expected),
1430                "{expected} missing: {rendered}"
1431            );
1432        }
1433    }
1434
1435    #[test]
1436    fn an_ignore_watch_glob_that_will_not_compile_is_rejected() {
1437        // fails if only `watch_options` is ever compiled, leaving a mistyped
1438        // `ignore_watch` to cost the app its watch at arm time instead
1439        let mut app = AppConfig::minimal("web", "./srv");
1440        app.watch = true;
1441        app.cwd = Some("/srv/web".to_string());
1442        app.ignore_watch = vec!["[".to_string()];
1443        match normalize(app).unwrap_err() {
1444            NormalizeError::InvalidWatchGlob { field, pattern, .. } => {
1445                assert_eq!(field, "ignore_watch");
1446                assert_eq!(pattern, "[");
1447            }
1448            other => panic!("expected InvalidWatchGlob, got {other:?}"),
1449        }
1450    }
1451
1452    #[test]
1453    fn a_glob_that_will_not_compile_is_rejected_with_watch_off() {
1454        // fails if glob validation is nested inside the `watch` check: an app
1455        // carrying a mistyped glob with `watch = false` would then normalize
1456        // clean, and the typo would surface only the day someone flips
1457        // `watch = true`
1458        let mut app = AppConfig::minimal("web", "./srv");
1459        app.watch_options = vec!["[".to_string()];
1460        assert!(matches!(
1461            normalize(app).unwrap_err(),
1462            NormalizeError::InvalidWatchGlob { .. }
1463        ));
1464    }
1465
1466    #[test]
1467    fn well_formed_watch_globs_are_accepted() {
1468        // fails if the check rejects patterns globset compiles happily:
1469        // recursive `**`, a character class, a negated class and a brace
1470        // alternation. Also fails if it is wired to a parser that is not
1471        // globset's, since these are a syntax error to a regex engine.
1472        let mut app = AppConfig::minimal("web", "./srv");
1473        app.watch = true;
1474        app.cwd = Some("/srv/web".to_string());
1475        app.watch_options = vec!["src/**/*.rs".to_string(), "*.[ch]".to_string()];
1476        app.ignore_watch = vec!["target/**".to_string(), "**/[!.]*.{tmp,swp}".to_string()];
1477        assert!(normalize(app).is_ok());
1478    }
1479
1480    #[test]
1481    fn a_typo_in_an_env_template_is_refused_and_names_the_field() {
1482        let mut app = AppConfig::minimal("web", "./srv");
1483        app.env
1484            .insert("WORKER".to_string(), "w-{{instnace}}".to_string());
1485        let err = normalize(app).unwrap_err();
1486        let rendered = err.to_string();
1487        assert!(rendered.contains("instnace"), "names the typo: {rendered}");
1488        assert!(rendered.contains("WORKER"), "and the field: {rendered}");
1489    }
1490
1491    #[test]
1492    fn a_typo_in_an_arg_template_is_refused_too() {
1493        let mut app = AppConfig::minimal("web", "./srv");
1494        app.args = vec!["--port".to_string(), "91{{slot}}".to_string()];
1495        let err = normalize(app).unwrap_err();
1496        assert!(err.to_string().contains("slot"), "{err}");
1497    }
1498
1499    #[test]
1500    fn an_explicit_log_path_shared_by_every_instance_is_refused() {
1501        let mut app = AppConfig::minimal("web", "./srv");
1502        app.instances = 3;
1503        app.out_file = Some("/var/log/web.log".to_string());
1504        let err = normalize(app).unwrap_err();
1505        let rendered = err.to_string();
1506        assert!(rendered.contains("out_file"), "names the field: {rendered}");
1507        assert!(
1508            rendered.contains("{{instance}}") && rendered.contains("merge_logs"),
1509            "and both ways out: {rendered}"
1510        );
1511        assert!(
1512            !rendered.contains('\u{2014}') && !rendered.contains('\u{2013}'),
1513            "no em or en dash in copy a user reads: {rendered}"
1514        );
1515    }
1516
1517    #[test]
1518    fn the_three_ways_out_of_the_shared_log_refusal_all_work() {
1519        // A slot in the path.
1520        let mut templated = AppConfig::minimal("web", "./srv");
1521        templated.instances = 3;
1522        templated.out_file = Some("/var/log/web-{{instance}}.log".to_string());
1523        assert!(normalize(templated).is_ok());
1524
1525        // Asking for the merge on purpose.
1526        let mut merged = AppConfig::minimal("web", "./srv");
1527        merged.instances = 3;
1528        merged.out_file = Some("/var/log/web.log".to_string());
1529        merged.merge_logs = true;
1530        assert!(normalize(merged).is_ok());
1531
1532        // One instance cannot collide with itself.
1533        let mut single = AppConfig::minimal("web", "./srv");
1534        single.out_file = Some("/var/log/web.log".to_string());
1535        assert!(normalize(single).is_ok());
1536    }
1537
1538    #[test]
1539    fn an_escaped_template_in_a_log_path_does_not_satisfy_the_refusal() {
1540        // `{{{{instance}}}}` spells the token but renders to one literal path
1541        // for every instance, so a substring check would wave it through.
1542        let mut app = AppConfig::minimal("web", "./srv");
1543        app.instances = 3;
1544        app.out_file = Some("/var/log/web-{{{{instance}}}}.log".to_string());
1545        assert!(normalize(app).is_err());
1546    }
1547
1548    #[test]
1549    fn a_name_only_template_does_not_resolve_the_collision() {
1550        // `{{name}}` is the same for every instance, so a path carrying only it
1551        // still puts every instance on one file. Presence of a token is not the
1552        // test; rendering differently is.
1553        let mut app = AppConfig::minimal("web", "./srv");
1554        app.instances = 3;
1555        app.out_file = Some("/var/log/{{name}}.log".to_string());
1556        assert!(normalize(app).is_err());
1557    }
1558
1559    #[test]
1560    fn a_bad_template_in_a_log_path_is_reported_as_bad_template_not_shared_path() {
1561        let mut app = AppConfig::minimal("web", "./srv");
1562        app.instances = 3;
1563        app.out_file = Some("/var/log/web-{{instnace}}.log".to_string());
1564        match normalize(app).unwrap_err() {
1565            NormalizeError::BadTemplate { field, reason, .. } => {
1566                assert_eq!(field, "out_file");
1567                assert!(reason.contains("instnace"), "{reason}");
1568            }
1569            other => panic!("expected BadTemplate, got {other:?}"),
1570        }
1571    }
1572
1573    #[test]
1574    fn watch_options_without_watch_or_cwd_accepted() {
1575        // fails if the check is keyed on `watch_options` being non-empty
1576        // rather than on `watch` being true: that would reject a Flockfile
1577        // that never asked to be watched
1578        let mut app = AppConfig::minimal("web", "./srv");
1579        app.watch_options = vec!["src/**".to_string()];
1580        assert!(normalize(app).is_ok());
1581    }
1582}