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