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