Skip to main content

shep_core/config/normalize/
resolved_app.rs

1use std::collections::BTreeSet;
2use std::path::Path;
3
4use crate::config::{AppConfig, CronParseError, CronSchedule, KillSignal, LevelMatcher};
5use crate::secrets;
6use crate::values::UpDuration;
7
8use super::{NormalizeError, tilde, validate};
9
10/// A validated app config: only obtainable via [`normalize`].
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct ResolvedApp {
13    pub(super) config: AppConfig,
14}
15
16impl ResolvedApp {
17    /// Borrow the validated configuration
18    #[must_use]
19    pub fn config(&self) -> &AppConfig {
20        &self.config
21    }
22
23    /// Unwrap the validated configuration (consumes the proof token)
24    #[must_use]
25    pub fn into_config(self) -> AppConfig {
26        self.config
27    }
28}
29
30/// Validates one app config
31///
32/// # Errors
33///
34/// - [`NormalizeError::MissingName`]: `name` is empty.
35/// - [`NormalizeError::InvalidName`]: `name` contains a path separator or a colon, or is `.`/`..`.
36/// - [`NormalizeError::InvalidEnvironment`]: `environment` is `all`, the secrets store's every-environment slot, or falls outside the store's name grammar.
37/// - [`NormalizeError::ReservedEnvVar`]: `env` sets `SHEP_INSTANCE`, `SHEP_NAME` or `SHEP_ENVIRONMENT`, which shep injects itself.
38/// - [`NormalizeError::MissingScript`]: `script` is empty.
39/// - [`NormalizeError::ZeroInstances`]: `instances == 0`.
40/// - [`NormalizeError::InvalidCron`]: `cron_restart` is not valid in croner's dialect.
41/// - [`NormalizeError::InvalidTimezone`]: `cron_timezone` is not a name in the IANA time-zone database.
42/// - [`NormalizeError::InvalidProbe`]: a `readiness_probe`/`liveness_probe` target [`ProbeTarget::parse`](crate::config::ProbeTarget::parse) rejects.
43/// - [`NormalizeError::ZeroFailureThreshold`]: a probe's `failure_threshold` is explicitly `0`.
44/// - [`NormalizeError::IntervalBelowMinimum`]: a probe's `interval` is under the floor its own loop honours.
45/// - [`NormalizeError::ZeroMaxMemory`]: `max_memory` is `0`.
46/// - [`NormalizeError::ActionTimeoutTooLong`]: `action_timeout` is at or above the ceiling no RPC caller could wait past.
47/// - [`NormalizeError::InvalidKillSignal`]: `kill_signal` names a signal the daemon's stop ladder cannot send.
48/// - [`NormalizeError::WatchWithoutCwd`]: `watch` is `true` with no `cwd` set.
49/// - [`NormalizeError::ZeroWatchDelay`]: `watch_delay` is `0`.
50/// - [`NormalizeError::InvalidWatchGlob`]: a `watch_options` or `ignore_watch` pattern globset will not compile.
51/// - [`NormalizeError::InvalidLevelRule`]: a `level_rules` entry has an empty pattern, one regex will not compile, or the list is longer than the ceiling on how many rules an app may declare.
52/// - [`NormalizeError::BadTemplate`]: an `env`/`args`/log-path value carries an undefined or unclosed `{{...}}` token.
53/// - [`NormalizeError::SecretInLogPath`]: `out_file` or `err_file` carries a `{{secret:...}}`.
54/// - [`NormalizeError::SharedLogPath`]: `out_file` or `err_file` renders to the same path for two instances.
55/// - [`NormalizeError::TildeUser`]: a path field names another user's `~user` home.
56/// - [`NormalizeError::NoHomeForTilde`]: a path field expands `~/` but no home directory could be found.
57/// - [`NormalizeError::NoShepHome`]: a templated value carries `{{SHEP_HOME}}` but no shep home could be found.
58/// - [`NormalizeError::SelfDependency`]: an app names itself in `depends_on`.
59/// - [`NormalizeError::InstanceDependency`]: a `depends_on` entry is written `name:slot`.
60pub fn normalize(app: AppConfig) -> Result<ResolvedApp, NormalizeError> {
61    let home = std::env::home_dir();
62    let shep_home = crate::paths::shep_home(&|key| std::env::var(key).ok(), home.as_deref());
63    normalize_with_home(app, home.as_deref(), shep_home.as_deref())
64}
65
66/// Validates a whole flock, rejecting duplicate sheep names
67///
68/// # Errors
69///
70/// Everything [`normalize`] returns, plus
71/// [`NormalizeError::DuplicateName`]: two apps share a `name`.
72/// [`NormalizeError::DependencyCycle`]: two apps in the document depend on each other.
73pub fn normalize_all(apps: Vec<AppConfig>) -> Result<Vec<ResolvedApp>, NormalizeError> {
74    let mut seen = BTreeSet::new();
75    let resolved: Vec<ResolvedApp> = apps
76        .into_iter()
77        .map(|app| {
78            if !seen.insert(app.name.clone()) {
79                return Err(NormalizeError::DuplicateName(app.name));
80            }
81            normalize(app)
82        })
83        .collect::<Result<_, _>>()?;
84    // Document-local only: a name this document does not hold is left to the
85    // daemon, which is the only place that knows the whole flock.
86    let nodes: Vec<crate::config::graph::BootNode> = resolved
87        .iter()
88        .map(|app| crate::config::graph::BootNode {
89            name: app.config().name.clone(),
90            depends_on: app.config().depends_on.clone(),
91            kind: crate::config::graph::NodeKind::Sheep,
92        })
93        .collect();
94    if let Some(cycle) = crate::config::graph::plan(&nodes).cycles.into_iter().next() {
95        return Err(NormalizeError::DependencyCycle(cycle));
96    }
97    Ok(resolved)
98}
99
100/// Shortest `interval` a `liveness_probe` may name.
101///
102/// The daemon's liveness loop floors whatever it is handed at this same
103/// value, so a smaller number would be silently honoured as this one with
104/// no warning.
105///
106/// One second is a floor no legitimate configuration wants to be under: for
107/// [`ProbeKind::Exec`](crate::config::ProbeKind::Exec) a shorter interval is
108/// that many process spawns per second, per sheep, for as long as it runs.
109pub(super) const MIN_LIVENESS_INTERVAL: UpDuration = UpDuration::from_millis(1_000);
110
111/// Shortest `interval` a `readiness_probe` may name.
112///
113/// A whole second lower than [`MIN_LIVENESS_INTERVAL`]: the readiness wait
114/// honours its `interval` exactly as written, bounded by `listen_timeout`,
115/// so only zero needs rejecting. A fast app polling every 20ms to leave
116/// `starting` sooner must not lose that.
117pub(super) const MIN_READINESS_INTERVAL: UpDuration = UpDuration::from_millis(1);
118
119/// Longest `action_timeout` an app may name.
120///
121/// The daemon clamps every RPC deadline to `MAX_DEADLINE_MS` (60s, in
122/// shep-daemon's `rpc` module), so a value at or above that line could
123/// never be honoured by any caller. Set 2s under the clamp so the daemon
124/// still has room to build the `TimedOut` row and send it back after the
125/// wait gives up.
126const MAX_ACTION_TIMEOUT: UpDuration = UpDuration::from_millis(58_000);
127
128/// Parameters so the `~/` expansion above is testable without mutating the
129/// process environment, which is racy under a parallel `cargo test`. This is
130/// also the seam that matters for correctness rather than only for tests:
131/// the daemon may run as a different user than the CLI, so `~` has to be
132/// resolved where the config is normalised, not where it is executed.
133///
134/// `shep_home` arrives the same way and for the same reason: it is the
135/// directory `{{SHEP_HOME}}` expands to, and reading it inside the renderer
136/// would resolve it wherever the value happened to be rendered.
137///
138/// [`normalize`], with both home directories supplied rather than read.
139///
140/// # Errors
141/// The same set [`normalize`] documents.
142pub fn normalize_with_home(
143    mut app: AppConfig,
144    home: Option<&Path>,
145    shep_home: Option<&Path>,
146) -> Result<ResolvedApp, NormalizeError> {
147    if app.name.is_empty() {
148        return Err(NormalizeError::MissingName);
149    }
150    if app.name.contains(['/', '\\', ':']) || app.name == "." || app.name == ".." {
151        return Err(NormalizeError::InvalidName(app.name));
152    }
153    if let Some(environment) = &app.environment
154        && (environment == secrets::ALL_ENVIRONMENTS || !secrets::is_name(environment))
155    {
156        return Err(NormalizeError::InvalidEnvironment {
157            name: app.name.clone(),
158            value: environment.clone(),
159        });
160    }
161    for var in ["SHEP_INSTANCE", "SHEP_NAME", "SHEP_ENVIRONMENT"] {
162        if app.env.contains_key(var) {
163            return Err(NormalizeError::ReservedEnvVar {
164                name: app.name.clone(),
165                var,
166            });
167        }
168    }
169    for (key, value) in &app.env {
170        validate::validate_template(&app.name, &format!("env.{key}"), value, shep_home)?;
171    }
172    for (index, value) in app.args.iter().enumerate() {
173        validate::validate_template(&app.name, &format!("args[{index}]"), value, shep_home)?;
174    }
175    for (field, value) in [("out_file", &app.out_file), ("err_file", &app.err_file)] {
176        if let Some(value) = value {
177            validate::validate_template(&app.name, field, value, shep_home)?;
178            if crate::config::template::holds_secret(value) {
179                return Err(NormalizeError::SecretInLogPath {
180                    name: app.name.clone(),
181                    field,
182                });
183            }
184        }
185    }
186    if app.script.is_empty() {
187        return Err(NormalizeError::MissingScript);
188    }
189    // After the emptiness checks, so a missing script is reported as missing
190    // rather than as a path problem, and before every check below that reads
191    // a path.
192    tilde::expand_paths(&mut app, home)?;
193    if app.instances == 0 {
194        return Err(NormalizeError::ZeroInstances);
195    }
196    // After the template validation above, so a malformed `out_file`/`err_file`
197    // is reported as a bad template rather than as a shared path.
198    if app.instances > 1 && !app.merge_logs {
199        for (field, path) in [("out_file", &app.out_file), ("err_file", &app.err_file)] {
200            // Rendered rather than searched for a substring: an escaped
201            // `{{{{instance}}}}` contains the token's spelling but renders to
202            // one literal path for every instance, which is exactly the
203            // collision this refuses. Two slots that render alike collide.
204            if let Some(path) = path
205                && crate::config::template::render_positional(path, &app.name, 0, shep_home)
206                    == crate::config::template::render_positional(path, &app.name, 1, shep_home)
207            {
208                return Err(NormalizeError::SharedLogPath {
209                    name: app.name.clone(),
210                    field,
211                });
212            }
213        }
214    }
215    if let Some(pattern) = &app.cron_restart {
216        CronSchedule::parse(pattern, app.cron_timezone.as_deref()).map_err(|e| match e {
217            CronParseError::Pattern { pattern, reason } => {
218                NormalizeError::InvalidCron { pattern, reason }
219            }
220            CronParseError::Timezone { name } => NormalizeError::InvalidTimezone { name },
221        })?;
222    } else if let Some(tz_name) = &app.cron_timezone {
223        // A Flockfile can carry `cron_timezone` with no `cron_restart` to
224        // pair it with, still a typo the user wants to hear about.
225        crate::config::cron::parse_timezone_name(tz_name).ok_or_else(|| {
226            NormalizeError::InvalidTimezone {
227                name: tz_name.clone(),
228            }
229        })?;
230    }
231    validate::validate_probe(
232        app.readiness_probe.as_ref(),
233        "readiness_probe",
234        MIN_READINESS_INTERVAL,
235    )?;
236    validate::validate_probe(
237        app.liveness_probe.as_ref(),
238        "liveness_probe",
239        MIN_LIVENESS_INTERVAL,
240    )?;
241    if app.max_memory.is_some_and(|limit| limit.bytes() == 0) {
242        // A ceiling every live process is already over: the enforcer would
243        // report a breach on every reading, and the automatic restart that
244        // follows resets the restart budget rather than spending it, so
245        // `max_restarts` can never end the loop.
246        return Err(NormalizeError::ZeroMaxMemory { name: app.name });
247    }
248    if let Some(name) = &app.kill_signal
249        && KillSignal::parse(name).is_none()
250    {
251        // Rejected rather than clamped: a typo silently falling back to
252        // SIGTERM would cost every stop and reload for the life of the
253        // process, with no evidence but a detached daemon's log.
254        return Err(NormalizeError::InvalidKillSignal {
255            name: app.name,
256            value: name.clone(),
257        });
258    }
259    if app.action_timeout > MAX_ACTION_TIMEOUT {
260        // Rejected rather than clamped: every value above the ceiling is
261        // equally unreachable by any caller, so there is no honest lowered
262        // value to silently fall back to.
263        return Err(NormalizeError::ActionTimeoutTooLong {
264            name: app.name,
265            value: app.action_timeout,
266            max: MAX_ACTION_TIMEOUT,
267        });
268    }
269    if app.watch && app.cwd.is_none() {
270        // `watch` asked for a feature the daemon has no directory to arm:
271        // there is no cwd in the Flockfile, and defaulting to the daemon's
272        // own cwd risks watching the whole filesystem under a systemd unit
273        // with no `WorkingDirectory=`.
274        return Err(NormalizeError::WatchWithoutCwd { name: app.name });
275    }
276    if app.watch_delay == Some(UpDuration::from_millis(0)) {
277        // notify's debouncer derives its own poll tick as `watch_delay / 4`
278        // on a dedicated OS thread, so a zero turns that thread into
279        // `loop { sleep(0); lock(); }`, a CPU-spinning busy loop.
280        return Err(NormalizeError::ZeroWatchDelay { name: app.name });
281    }
282    // Both lists are checked whether or not `watch` is on, so a typo'd
283    // pattern is named now rather than the day `watch` flips on and nothing
284    // happens.
285    validate::validate_watch_globs(&app.name, "watch_options", &app.watch_options)?;
286    validate::validate_watch_globs(&app.name, "ignore_watch", &app.ignore_watch)?;
287    // Compiled to reject, and the result discarded: whichever client
288    // classifies this sheep's lines compiles its own. A pattern refused
289    // here can never reach one, which is the point of refusing it at config
290    // time rather than at render time, where nobody would read the message.
291    LevelMatcher::compile(&app.level_rules).map_err(|err| NormalizeError::InvalidLevelRule {
292        name: app.name.clone(),
293        reason: err.to_string(),
294    })?;
295    let mut seen = BTreeSet::new();
296    let mut deduped = Vec::with_capacity(app.depends_on.len());
297    for target in &app.depends_on {
298        if target == &app.name {
299            return Err(NormalizeError::SelfDependency(app.name.clone()));
300        }
301        if target.contains(':') {
302            return Err(NormalizeError::InstanceDependency {
303                sheep: app.name.clone(),
304                target: target.clone(),
305            });
306        }
307        if seen.insert(target.clone()) {
308            deduped.push(target.clone());
309        }
310    }
311    app.depends_on = deduped;
312    Ok(ResolvedApp { config: app })
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318    use crate::config::{LevelRule, LevelRuleError, LineLevel};
319
320    /// `reuse_port` loads because reload's overlap mode is chosen from it:
321    /// refusing it would deny an operator the only way to ask for an
322    /// overlapping reload.
323    #[test]
324    fn reuse_port_loads_now_that_reload_reads_it() {
325        let mut app = AppConfig::minimal("web", "./server");
326        app.reuse_port = true;
327
328        let resolved = normalize(app).expect("reuse_port is implemented");
329        assert!(resolved.config().reuse_port);
330    }
331
332    /// The default is off, so every Flockfile that does not mention it keeps
333    /// loading.
334    #[test]
335    fn an_app_that_never_mentions_reuse_port_still_normalizes() {
336        let resolved = normalize(AppConfig::minimal("web", "./server"))
337            .expect("the common case must be untouched");
338        assert!(!resolved.config().reuse_port);
339    }
340    use crate::config::AppConfig;
341
342    #[test]
343    fn a_colon_in_a_name_is_refused_because_it_is_the_instance_separator() {
344        let err = normalize(AppConfig::minimal("web:2", "./srv")).unwrap_err();
345        assert_eq!(err, NormalizeError::InvalidName("web:2".to_string()));
346
347        let rendered = err.to_string();
348        assert!(rendered.contains(':'), "says which character: {rendered}");
349        // Spec D3 requires the error to name the character and suggest `-`.
350        assert!(
351            rendered.contains("`-`"),
352            "suggests the stand-in: {rendered}"
353        );
354        assert!(
355            !rendered.contains('\u{2014}') && !rendered.contains('\u{2013}'),
356            "no em or en dash in copy a user reads: {rendered}"
357        );
358
359        assert!(normalize(AppConfig::minimal("web-2", "./srv")).is_ok());
360    }
361
362    #[test]
363    fn the_reserved_env_vars_are_refused_rather_than_overwritten() {
364        for var in ["SHEP_INSTANCE", "SHEP_NAME", "SHEP_ENVIRONMENT"] {
365            let mut app = AppConfig::minimal("web", "./srv");
366            app.env.insert(var.to_string(), "mine".to_string());
367            let err = normalize(app).unwrap_err();
368            let rendered = err.to_string();
369            assert!(rendered.contains(var), "names the variable: {rendered}");
370            assert!(
371                !rendered.contains('\u{2014}') && !rendered.contains('\u{2013}'),
372                "no em or en dash in copy a user reads: {rendered}"
373            );
374        }
375    }
376
377    #[test]
378    fn valid_minimal_config_normalizes() {
379        let resolved = normalize(AppConfig::minimal("web", "./srv")).unwrap();
380        assert_eq!(resolved.config().name, "web");
381    }
382
383    #[test]
384    fn names_that_reach_the_filesystem_are_rejected() {
385        // A name becomes a log/pid file stem via Path::join; a slash-prefixed
386        // or dotdot name would escape $SHEP_HOME. Reject at the config boundary.
387        for bad in ["/etc/passwd", "..", ".", "a/b", "a\\b"] {
388            assert_eq!(
389                normalize(AppConfig::minimal(bad, "./srv")).unwrap_err(),
390                NormalizeError::InvalidName(bad.to_string())
391            );
392        }
393        assert!(normalize(AppConfig::minimal("web-1", "./srv")).is_ok());
394    }
395
396    #[test]
397    fn missing_name_and_script_are_distinct_errors() {
398        assert_eq!(
399            normalize(AppConfig::minimal("", "./srv")).unwrap_err(),
400            NormalizeError::MissingName
401        );
402        assert_eq!(
403            normalize(AppConfig::minimal("web", "")).unwrap_err(),
404            NormalizeError::MissingScript
405        );
406    }
407
408    #[test]
409    fn zero_instances_rejected() {
410        let mut app = AppConfig::minimal("web", "./srv");
411        app.instances = 0;
412        assert_eq!(normalize(app).unwrap_err(), NormalizeError::ZeroInstances);
413    }
414
415    #[test]
416    fn bad_cron_pattern_rejected_with_pattern_and_reason_carried_through() {
417        // fails if the reason is not carried through from croner.
418        let mut app = AppConfig::minimal("web", "./srv");
419        app.cron_restart = Some("not a cron".to_string());
420        match normalize(app).unwrap_err() {
421            NormalizeError::InvalidCron { pattern, reason } => {
422                assert_eq!(pattern, "not a cron");
423                assert!(!reason.is_empty());
424            }
425            other => panic!("expected InvalidCron, got {other:?}"),
426        }
427    }
428
429    #[test]
430    fn five_tokens_of_garbage_cron_pattern_rejected() {
431        // fails if the validator only counts whitespace-separated tokens
432        // instead of checking each field's range: five numeric-looking
433        // tokens, all out of range.
434        let mut app = AppConfig::minimal("web", "./srv");
435        app.cron_restart = Some("99 99 99 99 99".to_string());
436        match normalize(app).unwrap_err() {
437            NormalizeError::InvalidCron { pattern, .. } => {
438                assert_eq!(pattern, "99 99 99 99 99");
439            }
440            other => panic!("expected InvalidCron, got {other:?}"),
441        }
442    }
443
444    #[test]
445    fn bad_cron_timezone_rejected_alongside_a_valid_cron_restart() {
446        // fails if the `cron_restart` branch maps CronParseError::Timezone to
447        // anything but NormalizeError::InvalidTimezone. CronSchedule::parse
448        // resolves the zone before the pattern, so a valid pattern paired
449        // with a bad zone is the only input that reaches that arm.
450        let mut app = AppConfig::minimal("web", "./srv");
451        app.cron_restart = Some("0 3 * * *".to_string());
452        app.cron_timezone = Some("Mars/Olympus".to_string());
453        match normalize(app).unwrap_err() {
454            NormalizeError::InvalidTimezone { name } => assert_eq!(name, "Mars/Olympus"),
455            other => panic!("expected InvalidTimezone, got {other:?}"),
456        }
457    }
458
459    #[test]
460    fn cron_timezone_validated_even_without_cron_restart() {
461        // fails if timezone validation is skipped when there's no pattern to
462        // pair it with: a Flockfile with only a bad `cron_timezone` is a
463        // typo the user wants to hear about.
464        let mut app = AppConfig::minimal("web", "./srv");
465        app.cron_timezone = Some("Mars/Olympus".to_string());
466        match normalize(app).unwrap_err() {
467            NormalizeError::InvalidTimezone { name } => assert_eq!(name, "Mars/Olympus"),
468            other => panic!("expected InvalidTimezone, got {other:?}"),
469        }
470    }
471
472    #[test]
473    fn duplicate_names_rejected_across_a_flock() {
474        let apps = vec![
475            AppConfig::minimal("web", "./a"),
476            AppConfig::minimal("web", "./b"),
477        ];
478        assert_eq!(
479            normalize_all(apps).unwrap_err(),
480            NormalizeError::DuplicateName("web".to_string())
481        );
482    }
483
484    #[test]
485    fn a_cycle_inside_one_document_is_refused_and_named() {
486        // fails if normalize_all accepts a cycle, or reports it without a path
487        let mut a = AppConfig::minimal("a", "./a");
488        a.depends_on = vec!["b".to_string()];
489        let mut b = AppConfig::minimal("b", "./b");
490        b.depends_on = vec!["a".to_string()];
491        match normalize_all(vec![a, b]) {
492            Err(NormalizeError::DependencyCycle(cycle)) => {
493                let rendered = NormalizeError::DependencyCycle(cycle).to_string();
494                assert!(rendered.contains("a"), "{rendered}");
495                assert!(rendered.contains("b"), "{rendered}");
496                assert!(
497                    rendered.contains(" -> "),
498                    "the path must be shown: {rendered}"
499                );
500            }
501            other => panic!("expected DependencyCycle, got {other:?}"),
502        }
503    }
504
505    #[test]
506    fn a_dependency_on_an_app_outside_the_document_is_accepted() {
507        // fails if the document-local check refuses a cross-repository
508        // dependency, which would make depends_on unusable across Flockfiles
509        let mut api = AppConfig::minimal("api", "./api");
510        api.depends_on = vec!["db-in-another-repo".to_string()];
511        normalize_all(vec![api]).expect("an unresolved name is not a document error");
512    }
513
514    #[test]
515    fn zero_max_memory_rejected() {
516        // fails if `max_memory` is never inspected: zero is a ceiling every
517        // live process is already over, so the enforcer breaches on every
518        // reading and the automatic restart that follows resets the
519        // restart budget instead of spending it.
520        let mut app = AppConfig::minimal("web", "./srv");
521        app.max_memory = Some(crate::values::MemSize::from_bytes(0));
522        let err = normalize(app).unwrap_err();
523        assert_eq!(
524            err,
525            NormalizeError::ZeroMaxMemory {
526                name: "web".to_string()
527            }
528        );
529        // fails if the message regresses to a bare variant name with no
530        // explanation.
531        assert!(err.to_string().contains("max_memory"), "{err}");
532    }
533
534    #[test]
535    fn a_nonzero_max_memory_is_accepted() {
536        // fails if the check fires on `max_memory` being set at all rather
537        // than on its being zero: that would refuse every app that
538        // configures a limit, which is the whole feature
539        let mut app = AppConfig::minimal("web", "./srv");
540        app.max_memory = Some("512M".parse().unwrap());
541        assert!(normalize(app).is_ok());
542    }
543
544    /// fails if a `kill_signal` shep cannot send is accepted here: that puts
545    /// SIGTERM on the wire for the life of the process with nothing but one
546    /// daemon log line to say so.
547    #[test]
548    fn a_kill_signal_shep_cannot_send_is_refused_by_name() {
549        let mut app = AppConfig::minimal("web", "./srv");
550        app.kill_signal = Some("SIGUSR1".to_string());
551
552        let err = normalize(app).unwrap_err();
553
554        assert_eq!(
555            err,
556            NormalizeError::InvalidKillSignal {
557                name: "web".to_string(),
558                value: "SIGUSR1".to_string(),
559            }
560        );
561        // The message has to name the accepted set, because the operator's next
562        // move is picking a different word and there is nowhere else to look.
563        let rendered = err.to_string();
564        assert!(rendered.contains("SIGUSR1"), "{rendered}");
565        assert!(rendered.contains("SIGTERM"), "{rendered}");
566        assert!(rendered.contains("SIGUSR2"), "{rendered}");
567    }
568
569    /// fails if the four supported names, their bare forms, or a lowercase
570    /// spelling stop being accepted. This is the compatibility half: every
571    /// spelling `stop_signal` accepted before this task must still normalize.
572    #[test]
573    fn every_spelling_the_daemon_already_accepted_still_normalizes() {
574        for name in [
575            "SIGTERM", "TERM", "sigterm", "term", "SIGINT", "INT", "SIGQUIT", "QUIT", "SIGUSR2",
576            "USR2", "sigusr2",
577        ] {
578            let mut app = AppConfig::minimal("web", "./srv");
579            app.kill_signal = Some(name.to_string());
580            assert!(
581                normalize(app).is_ok(),
582                "`{name}` was accepted before this task and must still be"
583            );
584        }
585    }
586
587    /// fails if an unset `kill_signal` is refused: the overwhelmingly common
588    /// case, and the one a validation pass is most likely to break by
589    /// treating `None` as an empty string.
590    #[test]
591    fn an_unset_kill_signal_is_not_a_config_error() {
592        let app = AppConfig::minimal("web", "./srv");
593        assert!(app.kill_signal.is_none());
594        assert!(normalize(app).is_ok());
595    }
596
597    #[test]
598    fn a_sheep_cannot_claim_the_all_environment() {
599        // `all` is the store's every-environment slot. A sheep resolving in
600        // it would read that slot twice and never its own.
601        let mut app = AppConfig::minimal("web", "./srv");
602        app.environment = Some("all".to_string());
603        let err = normalize(app).unwrap_err();
604        assert!(err.to_string().contains("all"), "{err}");
605    }
606
607    #[test]
608    fn an_environment_outside_the_grammar_is_refused() {
609        for bad in ["", "has space", "has/slash"] {
610            let mut app = AppConfig::minimal("web", "./srv");
611            app.environment = Some(bad.to_string());
612            assert!(normalize(app).is_err(), "{bad:?} must be refused");
613        }
614    }
615
616    #[test]
617    fn action_timeout_past_the_ceiling_is_rejected() {
618        // fails if `action_timeout` is never inspected. One millisecond over
619        // the ceiling is deliberate: a test at a round number like 60s could
620        // pass by coincidence if the check used the wrong constant entirely
621        // (`MAX_DEADLINE_MS` itself, say, instead of the margin under it).
622        let mut app = AppConfig::minimal("web", "./srv");
623        app.action_timeout = UpDuration::from_millis(MAX_ACTION_TIMEOUT.as_millis() + 1);
624        let err = normalize(app).unwrap_err();
625        assert_eq!(
626            err,
627            NormalizeError::ActionTimeoutTooLong {
628                name: "web".to_string(),
629                value: UpDuration::from_millis(MAX_ACTION_TIMEOUT.as_millis() + 1),
630                max: MAX_ACTION_TIMEOUT,
631            }
632        );
633        // fails if the message regresses to a bare variant name with no
634        // explanation.
635        assert!(err.to_string().contains("action_timeout"), "{err}");
636    }
637
638    #[test]
639    fn action_timeout_at_the_ceiling_is_accepted() {
640        // fails if the comparison is `>=` rather than `>`: the ceiling
641        // itself still leaves the daemon its full margin under the hard
642        // clamp, so it is not one of the values nothing could ever satisfy.
643        let mut app = AppConfig::minimal("web", "./srv");
644        app.action_timeout = MAX_ACTION_TIMEOUT;
645        assert!(normalize(app).is_ok());
646    }
647
648    #[test]
649    fn the_default_action_timeout_is_accepted() {
650        // fails if `AppConfig::default()`'s own value ever drifts past the
651        // ceiling normalize enforces: the one combination that must never
652        // reject the config nobody customized.
653        assert!(normalize(AppConfig::minimal("web", "./srv")).is_ok());
654    }
655
656    #[test]
657    fn zero_watch_delay_rejected() {
658        // fails if `watch_delay` is never inspected. notify's debouncer
659        // derives its poll tick as `watch_delay / 4` and sleeps it on its own
660        // OS thread, so zero is `loop { sleep(0); lock(); }`, a CPU-spinning
661        // busy loop.
662        let mut app = AppConfig::minimal("web", "./srv");
663        app.watch = true;
664        app.cwd = Some("/srv/web".to_string());
665        app.watch_delay = Some(UpDuration::from_millis(0));
666        let err = normalize(app).unwrap_err();
667        assert_eq!(
668            err,
669            NormalizeError::ZeroWatchDelay {
670                name: "web".to_string()
671            }
672        );
673        // fails if the message regresses to a bare variant name with no
674        // explanation.
675        assert!(err.to_string().contains("watch_delay"), "{err}");
676    }
677
678    #[test]
679    fn a_zero_watch_delay_is_rejected_with_watch_off() {
680        // fails if the check is nested inside the `watch` block: an app
681        // carrying `watch_delay = "0"` with `watch = false` would normalize
682        // clean, and the spin would arrive the day someone flips `watch =
683        // true`
684        let mut app = AppConfig::minimal("web", "./srv");
685        app.watch_delay = Some(UpDuration::from_millis(0));
686        assert!(matches!(
687            normalize(app).unwrap_err(),
688            NormalizeError::ZeroWatchDelay { .. }
689        ));
690    }
691
692    #[test]
693    fn a_nonzero_watch_delay_is_accepted() {
694        // fails if the check fires on `watch_delay` being set at all rather
695        // than on its being zero: that would refuse every app that tunes
696        // its own debounce
697        let mut app = AppConfig::minimal("web", "./srv");
698        app.watch = true;
699        app.cwd = Some("/srv/web".to_string());
700        app.watch_delay = Some(UpDuration::from_millis(1));
701        assert!(normalize(app).is_ok());
702    }
703
704    #[test]
705    fn watch_true_without_cwd_rejected_naming_the_app() {
706        // fails if a validator never looks at `watch`, or looks at it but
707        // carries no app name, leaving the user unable to tell which
708        // Flockfile entry to edit
709        let mut app = AppConfig::minimal("web", "./srv");
710        app.watch = true;
711        let err = normalize(app).unwrap_err();
712        assert_eq!(
713            err,
714            NormalizeError::WatchWithoutCwd {
715                name: "web".to_string()
716            }
717        );
718        // fails if the message regresses to a bare variant name with no
719        // explanation.
720        assert!(err.to_string().contains("no cwd to watch"), "{err}");
721    }
722
723    #[test]
724    fn watch_true_with_cwd_accepted() {
725        // fails if the check fires on `watch` alone, ignoring that a cwd was
726        // actually provided
727        let mut app = AppConfig::minimal("web", "./srv");
728        app.watch = true;
729        app.cwd = Some("/srv/web".to_string());
730        assert!(normalize(app).is_ok());
731    }
732
733    #[test]
734    fn level_rules_survive_normalization_in_the_order_they_were_written() {
735        let mut app = AppConfig::minimal("web", "./srv");
736        app.level_rules = vec![
737            LevelRule {
738                pattern: r"\[ERROR\]".to_string(),
739                level: LineLevel::Error,
740            },
741            LevelRule {
742                pattern: r"\[WARN\]".to_string(),
743                level: LineLevel::Warn,
744            },
745        ];
746        let resolved = normalize(app.clone()).unwrap();
747        assert_eq!(resolved.config().level_rules, app.level_rules);
748    }
749
750    /// The whole reason patterns are compiled here: a Flockfile that reaches
751    /// the shepherd with a pattern nobody can compile would fail at draw
752    /// time, in a client, where the message reaches nobody.
753    #[test]
754    fn a_level_rule_pattern_that_will_not_compile_is_rejected() {
755        let mut app = AppConfig::minimal("web", "./srv");
756        app.level_rules = vec![LevelRule {
757            pattern: "[unclosed".to_string(),
758            level: LineLevel::Error,
759        }];
760        let err = normalize(app).unwrap_err();
761        let rendered = err.to_string();
762        for expected in ["web", "[unclosed"] {
763            assert!(
764                rendered.contains(expected),
765                "{expected} missing: {rendered}"
766            );
767        }
768    }
769
770    /// An empty pattern matches every line, so the rule would claim the feed
771    /// and leave every rule after it dead.
772    #[test]
773    fn an_empty_level_rule_pattern_is_rejected() {
774        let mut app = AppConfig::minimal("web", "./srv");
775        app.level_rules = vec![LevelRule {
776            pattern: String::new(),
777            level: LineLevel::Warn,
778        }];
779        let err = normalize(app).unwrap_err();
780        assert_eq!(
781            err,
782            NormalizeError::InvalidLevelRule {
783                name: "web".to_string(),
784                reason: LevelRuleError::EmptyPattern {
785                    level: LineLevel::Warn
786                }
787                .to_string(),
788            }
789        );
790    }
791
792    #[test]
793    fn watch_options_without_watch_or_cwd_accepted() {
794        // fails if the check is keyed on `watch_options` being non-empty
795        // rather than on `watch` being true: that would reject a Flockfile
796        // that never asked to be watched
797        let mut app = AppConfig::minimal("web", "./srv");
798        app.watch_options = vec!["src/**".to_string()];
799        assert!(normalize(app).is_ok());
800    }
801
802    #[test]
803    fn a_sheep_that_depends_on_itself_is_refused_by_name() {
804        // fails if the self-edge check is missing, or if it reports a bare
805        // cycle rather than naming the sheep
806        let mut app = AppConfig::minimal("api", "./api");
807        app.depends_on = vec!["api".to_string()];
808        match normalize(app) {
809            Err(NormalizeError::SelfDependency(name)) => assert_eq!(name, "api"),
810            other => panic!("expected SelfDependency, got {other:?}"),
811        }
812    }
813
814    #[test]
815    fn a_dependency_on_one_instance_is_refused_naming_the_app_form() {
816        // fails if `name:slot` is accepted as a dependency target
817        let mut app = AppConfig::minimal("api", "./api");
818        app.depends_on = vec!["db:2".to_string()];
819        match normalize(app) {
820            Err(NormalizeError::InstanceDependency { sheep, target }) => {
821                assert_eq!(sheep, "api");
822                assert_eq!(target, "db:2");
823            }
824            other => panic!("expected InstanceDependency, got {other:?}"),
825        }
826        let rendered = NormalizeError::InstanceDependency {
827            sheep: "api".to_string(),
828            target: "db:2".to_string(),
829        }
830        .to_string();
831        assert!(
832            rendered.contains("`db`"),
833            "the refusal must name the app-level form: {rendered}"
834        );
835    }
836
837    #[test]
838    fn duplicate_dependencies_dedupe_rather_than_refusing() {
839        // fails if a repeated name is an error, or if it survives into the
840        // normalized config twice
841        let mut app = AppConfig::minimal("api", "./api");
842        app.depends_on = vec!["db".to_string(), "db".to_string()];
843        let resolved = normalize(app).expect("a repeated name is not an error");
844        assert_eq!(resolved.config().depends_on, vec!["db".to_string()]);
845    }
846
847    #[test]
848    fn an_ordinary_dependency_list_survives_normalize() {
849        // fails if the field is dropped or reordered
850        let mut app = AppConfig::minimal("api", "./api");
851        app.depends_on = vec!["db".to_string(), "cache".to_string()];
852        let resolved = normalize(app).expect("an ordinary list normalizes");
853        assert_eq!(
854            resolved.config().depends_on,
855            vec!["db".to_string(), "cache".to_string()]
856        );
857    }
858}