shep_core/config/flockfile.rs
1//! Flockfile: discovery and multi-format parsing
2//!
3//! One document shape across formats: a list of app tables under the `app`
4//! key (`[[app]]` in TOML). Parsing is strict serde, no code execution;
5//! `.js` configs are the CLI's job (it shells out to node and feeds the
6//! resulting JSON through [`FlockFormat::Json`]).
7
8use core::fmt;
9
10use std::collections::{BTreeMap, BTreeSet};
11use std::path::{Path, PathBuf};
12
13#[cfg(feature = "schema")]
14use schemars::Schema;
15
16use serde::{Deserialize, Serialize};
17
18use crate::config::AppConfig;
19
20/// A parsed Flockfile: the declared flock
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct Flockfile {
23 /// App entries in declaration order
24 pub apps: Vec<AppConfig>,
25}
26
27/// One app as the document declared it: the validated config, plus the
28/// keys the document literally wrote.
29///
30/// The key set cannot be recovered from [`AppConfig`] afterwards:
31/// `#[serde(default)]` gives every field a value, so a document naming
32/// four keys deserializes identically to one naming forty.
33/// [`Flockfile::parse_declared`] carries the claim out for a later merge
34/// that keys on what a template declares, not on its values.
35///
36/// `Serialize`/`Deserialize` since this type travels inside a wire
37/// request, keyed on the same claim. Derived `Debug` is safe: `config`
38/// redacts its own `env`, and `declared_env` holds only key names.
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40pub struct DeclaredApp {
41 /// The app, validated the same way [`Flockfile::parse`] validates one
42 pub config: AppConfig,
43 /// Top-level keys this app's table wrote, whatever their values
44 pub declared: BTreeSet<String>,
45 /// Keys inside this app's `env` table. Empty when `env` was not declared.
46 pub declared_env: BTreeSet<String>,
47}
48
49// Application entries are locked to the `app` key: a typo'd key must fail
50// loudly. `$schema` and `dog` are the two keys explicitly let in beside
51// it; a future schema key is added the same explicit way, so older
52// binaries reject newer Flockfiles by design rather than ignore them.
53#[derive(Deserialize)]
54#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
55// `rename` sets `schema_name`, which schemars uses as the root schema's
56// `title`. The type is called `RawFlockfile` because it is the
57// pre-validation twin of `Flockfile`; the document an operator writes is a
58// Flockfile, and that is what the title has to say.
59#[cfg_attr(feature = "schema", schemars(rename = "Flockfile"))]
60#[serde(deny_unknown_fields)]
61struct RawFlockfile {
62 /// The editor's schema hint, read and discarded.
63 ///
64 /// This is the "future schema key" the comment above anticipated, added
65 /// HERE explicitly rather than by relaxing `deny_unknown_fields`: a
66 /// typo'd key must still fail loudly, and exactly one more key is now
67 /// legal. shep does not validate against the named schema and makes no
68 /// promise about it — it is a hint for the operator's editor, which is
69 /// the only consumer that ever reads it.
70 ///
71 /// TOML Flockfiles do not need it: taplo's `#:schema <url>` directive is
72 /// a comment, invisible to serde. JSON and JSON5 have no comment an
73 /// editor agrees to look in, which is why this field exists at all.
74 #[serde(default, rename = "$schema")]
75 schema: Option<String>,
76 /// A dog's own per-app configuration, read and discarded.
77 ///
78 /// Added the same way `$schema` was, explicitly rather than by relaxing
79 /// `deny_unknown_fields`, so a typo'd key still fails loudly and exactly
80 /// one more key is legal.
81 ///
82 /// It exists because the alternative is a Flockfile no daemon will accept.
83 /// A dog that needs per-app configuration has nowhere to put it: shep-deploy
84 /// wants a build command for the app it deploys, which belongs beside that
85 /// app's declaration and nowhere else, and a Flockfile carrying one was
86 /// refused outright by `shep start`. Measured 2026-08-28 against shep
87 /// 0.1.8: an operator following shep-deploy's own README could not register
88 /// their app at all. `unknown field `build`, expected `$schema` or `app``.
89 ///
90 /// It must BE a table. shep does not read what is inside it, does not
91 /// validate it, and makes no promise about it. Those are two different
92 /// claims and only the second one is a promise not to care: the dog that
93 /// owns a key under this table is the only thing that understands it, and
94 /// shep refusing a document because it does not recognise another
95 /// program's config is a coupling neither side wants.
96 ///
97 /// Nested under one key rather than allowing loose top-level keys, so
98 /// exactly one name is reserved and a typo anywhere else still fails.
99 ///
100 /// A map of ignored values rather than `IgnoredAny`, which would have
101 /// accepted `dog = 5` and `dog = ["a"]` as happily as a table. Not reading
102 /// what a dog wrote is the point; not caring whether it wrote a table at
103 /// all is a different thing, and it would have made the one key this file
104 /// adds the one key where a typo does not fail loudly.
105 #[serde(default)]
106 #[cfg_attr(
107 feature = "schema",
108 schemars(with = "Option<BTreeMap<String, serde_json::Value>>")
109 )]
110 dog: Option<BTreeMap<String, serde::de::IgnoredAny>>,
111 #[serde(default, rename = "app")]
112 apps: Vec<AppConfig>,
113}
114
115/// The committed Flockfile JSON Schema.
116///
117/// `include_str!` makes the file a compile-time input: deleting it fails
118/// the build, and changing `AppConfig` fails
119/// `the_committed_schema_is_current` with the command that fixes it.
120///
121/// Lives inside this package, not the repository root: `cargo package`
122/// packs only files under the package directory, and a root-relative
123/// `include_str!` would fail every `cargo install shep`.
124///
125/// `#[allow(dead_code)]`: its only reader is `#[cfg(test)]`, so a plain
126/// `cargo build`/`clippy` sees no reader and would otherwise flag it dead.
127#[cfg(feature = "schema")]
128pub const COMMITTED: &str = include_str!("../../assets/flockfile.schema.json");
129
130/// How to regenerate the committed copy. Named in the drift test's own
131/// failure message, so a red test is self-service.
132///
133/// Same `#[allow(dead_code)]` reasoning as [`COMMITTED`] just above: its one
134/// reader is that same test.
135#[cfg(feature = "schema")]
136#[allow(dead_code)]
137const REGENERATE: &str =
138 "cargo run --bin shep -- schema > crates/shep-core/assets/flockfile.schema.json";
139
140/// Renders the Flockfile JSON Schema: the document grammar, pretty-printed
141/// with a trailing newline so the committed file is a well-formed text file.
142///
143/// Generated from `RawFlockfile`, the type serde actually deserializes a
144/// Flockfile into, so the schema and the parser cannot drift.
145/// `AppConfig` supplies the per-app half and lands in `$defs`.
146///
147/// Describes the deserializer, not the normalizer:
148/// `AppConfig::kill_signal` stays a plain string here even though
149/// `config::normalize` accepts only four spellings, since the schema's
150/// job is what serde parses, not what a later validation step accepts.
151#[cfg(feature = "schema")]
152#[track_caller]
153#[must_use]
154pub fn flockfile_schema_string() -> String {
155 let schema = flockfile_schema_json();
156 let mut rendered =
157 serde_json::to_string_pretty(&schema).expect("a schemars Schema always serializes");
158 rendered.push('\n');
159 rendered
160}
161
162/// Returns the Flockfile JSON Schema.
163///
164/// Generated from `RawFlockfile`, the type serde actually deserializes a
165/// Flockfile into, so the schema and the parser cannot drift. Describes
166/// the deserializer, not the normalizer: `AppConfig::kill_signal` stays a
167/// plain string here even though `config::normalize` accepts only four
168/// spellings.
169///
170/// # Panics
171/// Never in practice: schemars produces a `serde_json::Value` tree, which
172/// `to_string_pretty` cannot fail on. `#[track_caller]` so a future
173/// fallible change reports the caller.
174#[cfg(feature = "schema")]
175#[track_caller]
176#[must_use]
177pub fn flockfile_schema_json() -> Schema {
178 schemars::schema_for!(RawFlockfile)
179}
180
181/// Input format of a Flockfile
182#[derive(Debug, Clone, Copy, PartialEq, Eq)]
183pub enum FlockFormat {
184 /// `Flockfile.toml`: `[[app]]` tables
185 Toml,
186 /// `.yaml`/`.yml`
187 Yaml,
188 /// Strict JSON
189 Json,
190 /// JSON5 (comments, trailing commas)
191 Json5,
192}
193
194impl FlockFormat {
195 /// Maps a file extension to its format (`None` = unsupported, e.g. `.js`)
196 #[must_use]
197 pub fn from_path(path: &Path) -> Option<Self> {
198 match path.extension()?.to_str()? {
199 "toml" => Some(Self::Toml),
200 "yaml" | "yml" => Some(Self::Yaml),
201 "json" => Some(Self::Json),
202 "json5" => Some(Self::Json5),
203 _ => None,
204 }
205 }
206}
207
208impl Flockfile {
209 /// Parses Flockfile source text in the given format.
210 ///
211 /// # Errors
212 /// - Format variants ([`FlockfileError::Toml`] etc.): backend parse
213 /// failure, carrying the backend's message. Json5 additionally rejects
214 /// sources nested past a depth of 64 before reaching the backend
215 /// parser, whose recursive-descent stack-overflows on deep input
216 /// rather than returning an error.
217 /// - [`FlockfileError::NoApps`]: parsed fine but declared no apps.
218 /// - [`FlockfileError::UnknownKeys`]: named a key no field claims.
219 pub fn parse(source: &str, format: FlockFormat) -> Result<Self, FlockfileError> {
220 let raw = parse_raw_denying_unknown(source, format)?;
221 let RawFlockfile {
222 schema: _schema,
223 // Discarded by name. Whatever a dog wrote under `[dog]` is that
224 // dog's to read out of the file itself; shep only had to stop
225 // refusing the document for containing it.
226 dog: _dog,
227 apps,
228 } = raw;
229 if apps.is_empty() {
230 return Err(FlockfileError::NoApps);
231 }
232 Ok(Self { apps })
233 }
234
235 /// Parses `text` and reports, per app, which keys the document wrote.
236 ///
237 /// Runs the same per-format parse and validation [`Flockfile::parse`]
238 /// does, then separately deserializes the same source into a
239 /// [`serde_json::Value`] and reads each app table's keys off it:
240 /// `AppConfig`'s `#[serde(default)]` erases which keys a document
241 /// actually named, which is exactly what the value pass recovers.
242 ///
243 /// # Errors
244 /// Every error [`Flockfile::parse`] returns, for the same inputs.
245 pub fn parse_declared(
246 text: &str,
247 format: FlockFormat,
248 ) -> Result<Vec<DeclaredApp>, FlockfileError> {
249 // Same reasoning as `Flockfile::parse`: this reads a Flockfile off
250 // disk (the reload/muster path in shep-cli), not a value off the
251 // wire, so a typo here must still be loud.
252 let raw = parse_raw_denying_unknown(text, format)?;
253 let RawFlockfile {
254 schema: _schema,
255 dog: _dog,
256 apps,
257 } = raw;
258 if apps.is_empty() {
259 return Err(FlockfileError::NoApps);
260 }
261
262 // A document that reached this point already parsed successfully
263 // into `RawFlockfile` above, so the same source deserializing into a
264 // generic `Value` cannot fail for a reason the `RawFlockfile` pass
265 // would not already have caught.
266 let value = parse_into::<serde_json::Value>(text, format)?;
267 let tables: Vec<Option<&serde_json::Map<String, serde_json::Value>>> = value
268 .get("app")
269 .and_then(serde_json::Value::as_array)
270 .map(|apps| apps.iter().map(serde_json::Value::as_object).collect())
271 .unwrap_or_default();
272
273 Ok(apps
274 .into_iter()
275 .enumerate()
276 .map(|(index, config)| {
277 let table = tables.get(index).copied().flatten();
278 let declared = table
279 .map(|t| t.keys().cloned().collect())
280 .unwrap_or_default();
281 let declared_env = table
282 .and_then(|t| t.get("env"))
283 .and_then(serde_json::Value::as_object)
284 .map(|e| e.keys().cloned().collect())
285 .unwrap_or_default();
286 DeclaredApp {
287 config,
288 declared,
289 declared_env,
290 }
291 })
292 .collect())
293 }
294}
295
296// Shared by `Flockfile::parse` and `parse_declared`: both read a Flockfile
297// off disk (never a value off the wire), so both refuse a typo the same
298// way. `deny_unknown_fields` used to live on `AppConfig` itself, which made
299// every new Flockfile field a protocol event: the same type rides the
300// wire, where an unknown field means a newer peer rather than a typo. The
301// denial belongs here instead.
302fn parse_raw_denying_unknown(
303 source: &str,
304 format: FlockFormat,
305) -> Result<RawFlockfile, FlockfileError> {
306 let mut unknown = Vec::new();
307 let raw: RawFlockfile = parse_into_ignoring(source, format, |path| {
308 // `dog` is a map of `IgnoredAny` by design (see its doc comment):
309 // shep does not read or validate what a dog wrote there, so
310 // serde_ignored's callback for a key inside it is not a typo, it
311 // is the field doing exactly what it is for.
312 if !path.starts_with("dog.") {
313 unknown.push(path.to_string());
314 }
315 })?;
316 if !unknown.is_empty() {
317 return Err(FlockfileError::UnknownKeys { keys: unknown });
318 }
319 Ok(raw)
320}
321
322// Its one caller deserializes into `serde_json::Value`, which claims every
323// key, so `parse_into_ignoring`'s callback never fires here; delegating
324// costs nothing behaviorally and drops a second four-arm format dispatch.
325fn parse_into<T: serde::de::DeserializeOwned>(
326 source: &str,
327 format: FlockFormat,
328) -> Result<T, FlockfileError> {
329 parse_into_ignoring(source, format, |_| {})
330}
331
332// The one four-arm format dispatch, shared by `parse_into` (an empty
333// `on_ignored`) and `parse_raw_denying_unknown` (a real one). Each format's
334// `Deserializer` routes through `serde_ignored::deserialize`, calling
335// `on_ignored` once per key the target type did not claim, recursing into
336// nested structs (a Flockfile's `readiness_probe`/`liveness_probe` tables
337// included). The real callback is used only by `Flockfile::parse` and
338// `parse_declared`: those are the two places a document really is a
339// hand-written file, where an unrecognized key means a typo. Everywhere else
340// the same `AppConfig`/`ProbeConfig` shape rides the wire, where it means a
341// newer peer instead, which is why the two types dropped
342// `deny_unknown_fields` rather than this function replacing it everywhere.
343fn parse_into_ignoring<T: serde::de::DeserializeOwned>(
344 source: &str,
345 format: FlockFormat,
346 mut on_ignored: impl FnMut(&str),
347) -> Result<T, FlockfileError> {
348 match format {
349 FlockFormat::Toml => serde_ignored::deserialize(toml::Deserializer::new(source), |path| {
350 on_ignored(&path.to_string());
351 })
352 .map_err(|e| FlockfileError::Toml(e.to_string())),
353 FlockFormat::Yaml => serde_saphyr::with_deserializer_from_str(source, |de| {
354 serde_ignored::deserialize(de, |path| on_ignored(&path.to_string()))
355 })
356 .map_err(|e| FlockfileError::Yaml(e.to_string())),
357 FlockFormat::Json => {
358 let mut de = serde_json::Deserializer::from_str(source);
359 let value = serde_ignored::deserialize(&mut de, |path| on_ignored(&path.to_string()))
360 .map_err(|e| FlockfileError::Json(e.to_string()))?;
361 // `serde_json::from_str` checks this too, to catch trailing
362 // garbage after a value that otherwise parsed fine.
363 de.end().map_err(|e| FlockfileError::Json(e.to_string()))?;
364 Ok(value)
365 }
366 FlockFormat::Json5 => {
367 if json5_nesting_depth(source) > MAX_JSON5_NESTING_DEPTH {
368 return Err(FlockfileError::Json5(
369 "nesting depth exceeds 64".to_string(),
370 ));
371 }
372 let mut de = json5::Deserializer::from_str(source)
373 .map_err(|e| FlockfileError::Json5(e.to_string()))?;
374 serde_ignored::deserialize(&mut de, |path| on_ignored(&path.to_string()))
375 .map_err(|e| FlockfileError::Json5(e.to_string()))
376 }
377 }
378}
379
380// json5's recursive-descent parser stack-overflows (SIGABRT, uncatchable)
381// around ~4500 levels of nesting. 64 is far beyond the deepest legitimate
382// Flockfile nesting (4) and comfortably clear of the crash threshold.
383const MAX_JSON5_NESTING_DEPTH: u32 = 64;
384
385// Scans for the maximum concurrently-open `[`/`{` depth, skipping quoted
386// strings and `//`/`/* */` comments so bracket-like characters inside
387// them don't distort the count. Fails closed: an unterminated string or
388// comment returns `u32::MAX`, which always exceeds the depth cap.
389fn json5_nesting_depth(source: &str) -> u32 {
390 let mut depth: u32 = 0;
391 let mut max_depth: u32 = 0;
392 let mut in_string: Option<char> = None;
393 let mut chars = source.chars().peekable();
394 while let Some(c) = chars.next() {
395 if let Some(quote) = in_string {
396 match c {
397 '\\' => {
398 chars.next(); // skip the escaped character
399 }
400 q if q == quote => in_string = None,
401 _ => {}
402 }
403 continue;
404 }
405 match c {
406 '/' if chars.peek() == Some(&'/') => {
407 chars.next(); // consume the second '/'
408 for c2 in chars.by_ref() {
409 if c2 == '\n' {
410 break;
411 }
412 }
413 }
414 '/' if chars.peek() == Some(&'*') => {
415 chars.next(); // consume the '*'
416 let mut prev = '\0';
417 let mut closed = false;
418 for c2 in chars.by_ref() {
419 if prev == '*' && c2 == '/' {
420 closed = true;
421 break;
422 }
423 prev = c2;
424 }
425 if !closed {
426 return u32::MAX; // unterminated block comment
427 }
428 }
429 '"' | '\'' => in_string = Some(c),
430 '[' | '{' => {
431 depth = depth.saturating_add(1);
432 max_depth = max_depth.max(depth);
433 }
434 ']' | '}' => depth = depth.saturating_sub(1),
435 _ => {}
436 }
437 }
438 if in_string.is_some() {
439 return u32::MAX; // unterminated string
440 }
441 max_depth
442}
443
444const DISCOVERY_ORDER: [&str; 10] = [
445 "Flockfile.toml",
446 "Flockfile.yaml",
447 "Flockfile.yml",
448 "Flockfile.json",
449 "Flockfile.json5",
450 "flockfile.toml",
451 "flockfile.yaml",
452 "flockfile.yml",
453 "flockfile.json",
454 "flockfile.json5",
455];
456
457/// Finds the Flockfile in a directory (spec §5 ten-name order)
458#[must_use]
459pub fn discover(dir: &Path) -> Option<PathBuf> {
460 DISCOVERY_ORDER
461 .iter()
462 .map(|name| dir.join(name))
463 .find(|p| p.is_file())
464}
465
466/// Error type returned from [`Flockfile::parse`].
467///
468/// `#[non_exhaustive]`: an out-of-tree consumer could otherwise match
469/// this exhaustively and a new variant would break them silently. Growth
470/// is anticipated per backend, not per format: `.js` Flockfiles never
471/// appear here, since shep-core never executes anything. The node bridge
472/// lives in shep-cli, which feeds its output back through
473/// [`FlockFormat::Json`].
474#[non_exhaustive]
475#[derive(Debug, Clone, PartialEq, Eq)]
476pub enum FlockfileError {
477 /// TOML backend rejected the source (carries its message)
478 Toml(String),
479 /// YAML backend rejected the source
480 Yaml(String),
481 /// JSON backend rejected the source
482 Json(String),
483 /// JSON5 backend rejected the source
484 Json5(String),
485 /// The document parsed but declared no apps
486 NoApps,
487 /// The document named one or more keys no field claims.
488 ///
489 /// A Flockfile is hand-written, unlike the same [`AppConfig`]/
490 /// [`ProbeConfig`](crate::config::ProbeConfig) shape riding the wire,
491 /// where an unknown field means a newer peer rather than a typo.
492 /// `keys` names every offending key (dotted path for a nested one, e.g.
493 /// `app.0.readiness_probe.<the misspelled key>`) so one refusal lists
494 /// every typo instead of one refusal per run.
495 UnknownKeys {
496 /// Every key the document named that no field claimed.
497 keys: Vec<String>,
498 },
499}
500
501impl fmt::Display for FlockfileError {
502 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
503 match self {
504 Self::Toml(m) => write!(f, "invalid TOML Flockfile: {m}"),
505 Self::Yaml(m) => write!(f, "invalid YAML Flockfile: {m}"),
506 Self::Json(m) => write!(f, "invalid JSON Flockfile: {m}"),
507 Self::Json5(m) => write!(f, "invalid JSON5 Flockfile: {m}"),
508 Self::NoApps => f.write_str("Flockfile declares no apps"),
509 Self::UnknownKeys { keys } => {
510 write!(f, "Flockfile names unrecognized key")?;
511 if keys.len() != 1 {
512 f.write_str("s")?;
513 }
514 write!(f, ": {}", keys.join(", "))
515 }
516 }
517 }
518}
519
520impl core::error::Error for FlockfileError {}
521
522#[cfg(test)]
523mod tests {
524 use super::*;
525
526 #[test]
527 fn toml_array_of_tables() {
528 let src = r#"
529[[app]]
530name = "web"
531script = "./srv"
532
533[[app]]
534name = "worker"
535script = "python3"
536args = ["job.py"]
537"#;
538 let flock = Flockfile::parse(src, FlockFormat::Toml).unwrap();
539 assert_eq!(flock.apps.len(), 2);
540 assert_eq!(flock.apps[1].name, "worker");
541 }
542
543 #[test]
544 fn json_and_json5_and_yaml() {
545 let json = r#"{ "app": [{ "name": "web", "script": "./srv" }] }"#;
546 assert_eq!(
547 Flockfile::parse(json, FlockFormat::Json)
548 .unwrap()
549 .apps
550 .len(),
551 1
552 );
553
554 let json5 = r#"{ app: [{ name: "web", script: "./srv" }], /* comment */ }"#;
555 assert_eq!(
556 Flockfile::parse(json5, FlockFormat::Json5)
557 .unwrap()
558 .apps
559 .len(),
560 1
561 );
562
563 let yaml = "app:\n - name: web\n script: ./srv\n";
564 assert_eq!(
565 Flockfile::parse(yaml, FlockFormat::Yaml)
566 .unwrap()
567 .apps
568 .len(),
569 1
570 );
571 }
572
573 #[test]
574 fn empty_app_list_is_an_error() {
575 assert_eq!(
576 Flockfile::parse("app: []\n", FlockFormat::Yaml).unwrap_err(),
577 FlockfileError::NoApps
578 );
579 }
580
581 #[test]
582 fn parse_errors_carry_the_backend_message() {
583 match Flockfile::parse("not toml [[", FlockFormat::Toml).unwrap_err() {
584 FlockfileError::Toml(msg) => assert!(!msg.is_empty()),
585 other => panic!("expected Toml error, got {other:?}"),
586 }
587 }
588
589 #[test]
590 fn format_from_path() {
591 use std::path::Path;
592 assert_eq!(
593 FlockFormat::from_path(Path::new("Flockfile.toml")),
594 Some(FlockFormat::Toml)
595 );
596 assert_eq!(
597 FlockFormat::from_path(Path::new("f.yml")),
598 Some(FlockFormat::Yaml)
599 );
600 assert_eq!(
601 FlockFormat::from_path(Path::new("f.json5")),
602 Some(FlockFormat::Json5)
603 );
604 assert_eq!(FlockFormat::from_path(Path::new("f.js")), None);
605 }
606
607 #[test]
608 fn discover_prefers_toml_then_capitalized() {
609 // tempdir gives RAII cleanup instead of a manual remove_dir_all, so
610 // a failing assertion above can't leak the directory.
611 let dir = tempfile::tempdir().unwrap();
612 std::fs::write(dir.path().join("flockfile.json"), "{}").unwrap();
613 std::fs::write(dir.path().join("Flockfile.yaml"), "").unwrap();
614 assert_eq!(
615 discover(dir.path()),
616 Some(dir.path().join("Flockfile.yaml"))
617 );
618 std::fs::write(dir.path().join("Flockfile.toml"), "").unwrap();
619 assert_eq!(
620 discover(dir.path()),
621 Some(dir.path().join("Flockfile.toml"))
622 );
623 }
624
625 /// fails if a `.js` name is ever added to the discovery order. Reading
626 /// one runs node on it, and discovery is the path with no operator in
627 /// the loop, so it must never reach node.
628 #[test]
629 fn discovery_never_names_a_js_file_and_stays_ten_names() {
630 assert_eq!(DISCOVERY_ORDER.len(), 10);
631 for name in DISCOVERY_ORDER {
632 assert!(
633 !name.ends_with(".js"),
634 "{name} would let `shep start` execute a repo's JavaScript"
635 );
636 assert!(FlockFormat::from_path(Path::new(name)).is_some());
637 }
638 }
639
640 #[test]
641 fn yaml_deep_nesting_is_rejected_without_crashing() {
642 // 5000-deep flow-style nesting must return Err from serde-saphyr,
643 // never overflow the stack.
644 let deep = "[".repeat(5000);
645 let result = Flockfile::parse(&deep, FlockFormat::Yaml);
646 assert!(matches!(result, Err(FlockfileError::Yaml(_))));
647 }
648
649 #[test]
650 fn yaml_alias_bomb_is_bounded() {
651 // Billion-laughs shape: each level aliases the previous twice. The
652 // backend must reject it or resolve it bounded; completing
653 // quickly is the assertion.
654 let mut bomb = String::from("a: &a [\"x\",\"x\"]\n");
655 for i in 1..9 {
656 bomb.push_str(&format!(
657 "{c}: &{c} [*{p},*{p}]\n",
658 c = (b'a' + i) as char,
659 p = (b'a' + i - 1) as char
660 ));
661 }
662 let result = Flockfile::parse(&bomb, FlockFormat::Yaml);
663 assert!(result.is_err(), "alias bomb must not produce a valid flock");
664 }
665
666 #[test]
667 fn json5_beyond_max_nesting_depth_is_rejected_without_crashing() {
668 // json5 stack-overflows (SIGABRT) around ~4500 levels, so the depth
669 // guard must reject this before calling into it. 5000 unclosed `[`
670 // is nonsense JSON5, but the guard runs before any real parsing.
671 let src = "[".repeat(5000);
672 assert_eq!(
673 Flockfile::parse(&src, FlockFormat::Json5).unwrap_err(),
674 FlockfileError::Json5("nesting depth exceeds 64".to_string())
675 );
676 }
677
678 #[test]
679 fn json5_nesting_depth_counts_concurrently_open_brackets() {
680 let nested = format!("{}{}", "[".repeat(10), "]".repeat(10));
681 assert_eq!(json5_nesting_depth(&nested), 10);
682 }
683
684 #[test]
685 fn json5_nesting_depth_ignores_brackets_inside_strings() {
686 let src = r#"{ "a": "[[[[[[[[[[", "b": "esc\"aped [ too" }"#;
687 assert_eq!(json5_nesting_depth(src), 1); // only the outer `{`
688 }
689
690 #[test]
691 fn json5_legitimately_nested_doc_still_parses() {
692 // A probe object nested inside an app object inside the app array
693 // inside the root object: depth 4, the deepest a real Flockfile
694 // schema allows, well under the depth-64 guard.
695 let src = r#"{
696 app: [{
697 name: "web",
698 script: "./srv",
699 readiness_probe: { kind: "http", target: "http://localhost/x" },
700 }],
701 }"#;
702 let flock = Flockfile::parse(src, FlockFormat::Json5).unwrap();
703 assert_eq!(flock.apps.len(), 1);
704 }
705
706 #[test]
707 fn json5_line_comment_apostrophe_does_not_hide_deep_nesting() {
708 // Regression: a `'` inside a `//` comment must not flip the scanner
709 // into string mode and make it ignore every bracket that follows,
710 // letting an over-deep document slip past the guard.
711 let src = format!("// don't nest\n{}", "[".repeat(5000));
712 assert_eq!(
713 Flockfile::parse(&src, FlockFormat::Json5).unwrap_err(),
714 FlockfileError::Json5("nesting depth exceeds 64".to_string())
715 );
716 }
717
718 #[test]
719 fn json5_block_comment_apostrophe_does_not_hide_deep_nesting() {
720 let src = format!("/* it's fine */\n{}", "[".repeat(5000));
721 assert_eq!(
722 Flockfile::parse(&src, FlockFormat::Json5).unwrap_err(),
723 FlockfileError::Json5("nesting depth exceeds 64".to_string())
724 );
725 }
726
727 #[test]
728 fn json5_benign_comment_does_not_undercount_a_real_document() {
729 // Same depth-4 document as `json5_legitimately_nested_doc_still_parses`,
730 // plus a comment (apostrophe included) that must be skipped cleanly
731 // rather than throwing off the count.
732 let src = r#"{
733 /* it's the app list */
734 app: [{
735 name: "web",
736 script: "./srv",
737 readiness_probe: { kind: "http", target: "http://localhost/x" },
738 }],
739 }"#;
740 let flock = Flockfile::parse(src, FlockFormat::Json5).unwrap();
741 assert_eq!(flock.apps.len(), 1);
742 }
743
744 /// Resolves a `$ref` into `$defs`, one hop, and returns the subschema.
745 /// Everything with a `schema_name` is referenced rather than inlined, so
746 /// an assertion that does not follow the ref is asserting about a
747 /// `{"$ref": …}` object and passes or fails for the wrong reason.
748 #[cfg(feature = "schema")]
749 fn resolved<'a>(
750 root: &'a serde_json::Value,
751 node: &'a serde_json::Value,
752 ) -> &'a serde_json::Value {
753 match node.get("$ref").and_then(serde_json::Value::as_str) {
754 Some(r) => {
755 let name = r
756 .strip_prefix("#/$defs/")
757 .expect("every $ref in this schema points into $defs");
758 &root["$defs"][name]
759 }
760 None => node,
761 }
762 }
763
764 /// fails whenever the Flockfile grammar changes and the committed schema
765 /// does not. That includes a doc-comment edit: schemars reads `///` into
766 /// `description`, which becomes hover text in the operator's editor, so
767 /// a docs-only change is a real schema change, and regenerating is the
768 /// correct response, not a sign anything broke.
769 #[cfg(feature = "schema")]
770 #[test]
771 fn the_committed_schema_is_current() {
772 assert_eq!(
773 flockfile_schema_string(),
774 COMMITTED,
775 "crates/shep-core/assets/flockfile.schema.json is stale. Regenerate it:\n {REGENERATE}\n\
776 A doc-comment edit on AppConfig counts; schemars puts doc comments \
777 into `description`."
778 );
779 }
780
781 /// fails if the artefact goes back to describing one app. The document is
782 /// `{"app": [ … ]}`; a schema whose own `required` names `name` and
783 /// `script` is an AppConfig schema under a Flockfile filename, and every
784 /// real Flockfile would fail against it.
785 #[cfg(feature = "schema")]
786 #[test]
787 fn the_schema_describes_a_document_not_one_app() {
788 let schema: serde_json::Value = serde_json::from_str(&flockfile_schema_string()).unwrap();
789 assert!(schema["properties"]["app"].is_object(), "{schema}");
790 assert_eq!(schema["properties"]["app"]["type"], "array", "{schema}");
791 assert!(
792 schema["properties"]["name"].is_null(),
793 "root must not be an app: {schema}"
794 );
795 assert!(schema["$defs"]["AppConfig"].is_object(), "{schema}");
796 }
797
798 /// fails if the schema starts describing `normalize`'s grammar instead of
799 /// serde's. The four signal names belong to a validation step elsewhere;
800 /// a schema that listed them would be describing something it cannot see.
801 #[cfg(feature = "schema")]
802 #[test]
803 fn kill_signal_stays_an_unconstrained_string() {
804 let schema: serde_json::Value = serde_json::from_str(&flockfile_schema_string()).unwrap();
805 let field = resolved(
806 &schema,
807 &schema["$defs"]["AppConfig"]["properties"]["kill_signal"],
808 );
809 let types = field["type"]
810 .as_array()
811 .unwrap_or_else(|| panic!("kill_signal must carry a type array: {field}"));
812 assert!(
813 types.iter().any(|t| t == "string"),
814 "kill_signal must accept a string: {field}"
815 );
816 assert!(
817 field.get("enum").is_none(),
818 "kill_signal must not become an enum of the four signal names: {field}"
819 );
820 assert!(
821 field.get("pattern").is_none(),
822 "kill_signal must not become pattern-constrained: {field}"
823 );
824 }
825
826 /// fails if MemSize or UpDuration reverts to a derive and starts
827 /// describing its inner integer. Follows the `$ref`: the fields are
828 /// references into `$defs`, not inline schemas.
829 #[cfg(feature = "schema")]
830 #[test]
831 fn duration_and_memory_fields_are_string_shaped() {
832 let schema: serde_json::Value = serde_json::from_str(&flockfile_schema_string()).unwrap();
833 let app = &schema["$defs"]["AppConfig"]["properties"];
834
835 // `min_uptime: UpDuration` (not `Option`) is a bare `$ref`.
836 let min_uptime = resolved(&schema, &app["min_uptime"]);
837 assert_eq!(min_uptime["type"], "string", "{min_uptime}");
838 assert_eq!(min_uptime["pattern"], r"^\d+(ms|h|m|s)?$", "{min_uptime}");
839
840 // `max_memory: Option<MemSize>` is a `$ref` under `anyOf` beside `"null"`.
841 let any_of = app["max_memory"]["anyOf"]
842 .as_array()
843 .unwrap_or_else(|| panic!("max_memory must be anyOf: {}", app["max_memory"]));
844 let ref_node = any_of
845 .iter()
846 .find(|v| v.get("$ref").is_some())
847 .unwrap_or_else(|| panic!("max_memory's anyOf must carry a $ref: {any_of:?}"));
848 let max_memory = resolved(&schema, ref_node);
849 assert_eq!(max_memory["type"], "string", "{max_memory}");
850 assert_eq!(max_memory["pattern"], r"^\d+(G|M|K)?$", "{max_memory}");
851 }
852
853 /// fails if a Flockfile carrying a dog's own configuration is refused.
854 ///
855 /// A dog with per-app configuration has nowhere else to put it: it
856 /// belongs beside the app's declaration, in the repository the dog
857 /// deploys.
858 ///
859 /// The contents are not validated: shep does not know what a dog's
860 /// keys mean, and refusing a document for not recognising another
861 /// program's config is a coupling neither side wants.
862 #[test]
863 fn a_dog_table_is_accepted_and_ignored() {
864 let src = r#"
865[dog.deploy]
866command = "npm run build"
867artifacts = ["dist/app.js"]
868
869[dog.some-other-dog]
870anything = { nested = true, count = 3 }
871
872[[app]]
873name = "web"
874script = "./srv"
875"#;
876 let flock =
877 Flockfile::parse(src, FlockFormat::Toml).expect("a dog's table is not an error");
878 assert_eq!(flock.apps.len(), 1);
879 assert_eq!(flock.apps[0].name, "web");
880 }
881
882 /// fails if `dog` accepts something that is not a table.
883 ///
884 /// Not reading what a dog wrote does not mean not caring whether it
885 /// wrote a table: that would make this the one key where a typo does
886 /// not fail loudly.
887 #[test]
888 fn a_dog_that_is_not_a_table_is_refused() {
889 for value in ["5", "\"nope\"", "[1, 2]", "true"] {
890 let src = format!("dog = {value}\n\n[[app]]\nname = \"web\"\nscript = \"./srv\"\n");
891 assert!(
892 Flockfile::parse(&src, FlockFormat::Toml).is_err(),
893 "`dog = {value}` is not a table and must be refused"
894 );
895 }
896 }
897
898 /// fails if a typo anywhere else stops failing loudly.
899 ///
900 /// Exactly one more key is legal, which is the whole reason the table is
901 /// nested under one name rather than allowing loose top-level keys.
902 #[test]
903 fn a_key_that_is_not_dog_still_fails() {
904 let src = r#"
905[build]
906command = "npm run build"
907
908[[app]]
909name = "web"
910script = "./srv"
911"#;
912 let err = Flockfile::parse(src, FlockFormat::Toml)
913 .expect_err("an unknown top-level key must still be refused");
914 assert!(
915 format!("{err}").contains("build"),
916 "the refusal must name the key: {err}"
917 );
918 }
919
920 #[test]
921 fn a_schema_key_is_accepted_and_ignored() {
922 let src = r#"{ "$schema": "./flockfile.schema.json",
923 "app": [{ "name": "web", "script": "./srv" }] }"#;
924 let flock = Flockfile::parse(src, FlockFormat::Json).unwrap();
925 assert_eq!(flock.apps.len(), 1);
926 }
927
928 /// fails if the new field is implemented by relaxing
929 /// `deny_unknown_fields` instead of naming one more key, which would
930 /// silently accept every typo the document lock exists to catch.
931 #[test]
932 fn one_more_key_is_legal_and_no_others_are() {
933 let src = r#"{ "schema": "x", "app": [{ "name": "w", "script": "./s" }] }"#;
934 assert!(
935 matches!(
936 Flockfile::parse(src, FlockFormat::Json),
937 Err(FlockfileError::Json(_))
938 ),
939 "bare `schema` (no $) must still be an unknown field"
940 );
941 }
942
943 #[test]
944 fn a_toml_flockfile_takes_the_key_too() {
945 let src = "\"$schema\" = \"./flockfile.schema.json\"\n\
946 [[app]]\nname = \"web\"\nscript = \"./srv\"\n";
947 assert_eq!(
948 Flockfile::parse(src, FlockFormat::Toml).unwrap().apps.len(),
949 1
950 );
951 }
952
953 /// fails if the declared key set is inferred from values rather than read
954 /// from the document. `autorestart = true` is also the default, so a
955 /// parser that reports "fields that differ from Default" would miss it,
956 /// and a later file load would then overwrite an operator who had
957 /// turned it off.
958 #[test]
959 fn declared_reports_keys_the_document_wrote_even_at_their_default() {
960 let text = r#"
961[[app]]
962name = "web"
963script = "./srv"
964autorestart = true
965"#;
966 let apps = Flockfile::parse_declared(text, FlockFormat::Toml).unwrap();
967 assert_eq!(apps.len(), 1);
968 let declared = &apps[0].declared;
969 assert!(declared.contains("autorestart"), "declared: {declared:?}");
970 assert!(declared.contains("name"));
971 assert!(declared.contains("script"));
972 assert!(
973 !declared.contains("max_memory"),
974 "a key nobody wrote is not declared"
975 );
976 assert_eq!(declared.len(), 3);
977 }
978
979 /// fails if env keys are not reported separately. `env` is the only map
980 /// of user-supplied keys in `AppConfig`, so the merge treats it one
981 /// level deeper than every other field.
982 #[test]
983 fn declared_env_reports_the_keys_inside_the_env_table() {
984 let text = r#"
985[[app]]
986name = "web"
987script = "./srv"
988env = { DB_HOST = "", NODE_ENV = "production" }
989"#;
990 let apps = Flockfile::parse_declared(text, FlockFormat::Toml).unwrap();
991 assert_eq!(
992 apps[0].declared_env.iter().collect::<Vec<_>>(),
993 vec!["DB_HOST", "NODE_ENV"]
994 );
995 assert!(apps[0].declared.contains("env"));
996 }
997
998 /// A typo in a Flockfile must still be loud. This is the whole reason
999 /// `deny_unknown_fields` was there.
1000 #[test]
1001 fn a_misspelled_flockfile_key_is_refused_and_named() {
1002 let err = Flockfile::parse(
1003 "[[app]]\nname = \"web\"\nscript = \"./srv\"\nmax_restrts = 5\n",
1004 FlockFormat::Toml,
1005 )
1006 .expect_err("a typo must be refused");
1007 let FlockfileError::UnknownKeys { keys } = err else {
1008 panic!("expected UnknownKeys, got {err:?}");
1009 };
1010 assert!(
1011 keys.iter().any(|k| k.contains("max_restrts")),
1012 "got {keys:?}"
1013 );
1014 }
1015
1016 /// Nesting is why this uses serde_ignored rather than a key list.
1017 ///
1018 /// `kind`/`target` are supplied alongside the typo: both are required by
1019 /// `ProbeConfig` with no default, and omitting them would surface a
1020 /// missing-field error instead of the unknown-key one this test means to
1021 /// exercise.
1022 #[test]
1023 fn a_misspelled_key_inside_a_probe_is_also_named() {
1024 let err = Flockfile::parse(
1025 "[[app]]\nname = \"web\"\nscript = \"./srv\"\n[app.readiness_probe]\nkind = \"http\"\ntarget = \"http://localhost/x\"\ntimeuot = \"5s\"\n",
1026 FlockFormat::Toml,
1027 )
1028 .expect_err("a nested typo must be refused");
1029 let FlockfileError::UnknownKeys { keys } = err else {
1030 panic!("expected UnknownKeys, got {err:?}");
1031 };
1032 // The variant alone would pass on any path at all, including one
1033 // from a different app. Nesting is the reason this parse goes
1034 // through `serde_ignored` rather than a flat key list, so the
1035 // path is the thing worth asserting.
1036 assert!(
1037 keys.iter()
1038 .any(|key| key.contains("readiness_probe") && key.contains("timeuot")),
1039 "the nested path should name both the probe and the key: {keys:?}"
1040 );
1041 }
1042
1043 /// The design goal is one refusal naming every typo, not one refusal
1044 /// per run. Two misspelled keys in one document must both show up in
1045 /// the single [`FlockfileError::UnknownKeys`] this produces.
1046 #[test]
1047 fn two_misspelled_keys_are_both_named_in_one_error() {
1048 let err = Flockfile::parse(
1049 "[[app]]\nname = \"web\"\nscript = \"./srv\"\nmax_restrts = 5\ninstences = 2\n",
1050 FlockFormat::Toml,
1051 )
1052 .expect_err("both typos must be refused");
1053 let FlockfileError::UnknownKeys { keys } = err else {
1054 panic!("expected UnknownKeys, got {err:?}");
1055 };
1056 assert!(
1057 keys.iter().any(|k| k.contains("max_restrts")),
1058 "got {keys:?}"
1059 );
1060 assert!(keys.iter().any(|k| k.contains("instences")), "got {keys:?}");
1061 }
1062
1063 /// fails if a format other than TOML loses the unknown-key refusal.
1064 /// `parse_into_ignoring` dispatches over all four; a regression here
1065 /// means a format bypassed `serde_ignored` on the way through.
1066 #[test]
1067 fn a_misspelled_key_is_refused_in_every_parse_format() {
1068 let cases: [(FlockFormat, &str); 4] = [
1069 (
1070 FlockFormat::Toml,
1071 "[[app]]\nname = \"web\"\nscript = \"./srv\"\nmax_restrts = 5\n",
1072 ),
1073 (
1074 FlockFormat::Yaml,
1075 "app:\n - name: web\n script: ./srv\n max_restrts: 5\n",
1076 ),
1077 (
1078 FlockFormat::Json,
1079 r#"{"app":[{"name":"web","script":"./srv","max_restrts":5}]}"#,
1080 ),
1081 (
1082 FlockFormat::Json5,
1083 "{ app: [{ name: \"web\", script: \"./srv\", max_restrts: 5 }] }",
1084 ),
1085 ];
1086 for (format, text) in cases {
1087 let err = Flockfile::parse(text, format).expect_err("a typo must be refused");
1088 assert!(
1089 matches!(err, FlockfileError::UnknownKeys { .. }),
1090 "{format:?}: got {err:?}"
1091 );
1092 }
1093 }
1094
1095 /// fails if a format other than TOML loses the key set. All four go
1096 /// through one generic intermediate, so a regression here means the
1097 /// intermediate was bypassed for a format.
1098 #[test]
1099 fn declared_survives_every_parse_format() {
1100 let cases: [(FlockFormat, &str); 4] = [
1101 (
1102 FlockFormat::Toml,
1103 "[[app]]\nname = \"web\"\nscript = \"./srv\"\nautorestart = true\n",
1104 ),
1105 (
1106 FlockFormat::Yaml,
1107 "app:\n - name: web\n script: ./srv\n autorestart: true\n",
1108 ),
1109 (
1110 FlockFormat::Json,
1111 r#"{"app":[{"name":"web","script":"./srv","autorestart":true}]}"#,
1112 ),
1113 (
1114 FlockFormat::Json5,
1115 "{ app: [{ name: \"web\", script: \"./srv\", autorestart: true }] }",
1116 ),
1117 ];
1118 for (format, text) in cases {
1119 let apps = Flockfile::parse_declared(text, format)
1120 .unwrap_or_else(|e| panic!("{format:?} failed to parse: {e}"));
1121 assert!(
1122 apps[0].declared.contains("autorestart"),
1123 "{format:?}: declared {:?}",
1124 apps[0].declared
1125 );
1126 }
1127 }
1128}