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