diff --git a/crates/picloud-cli/src/manifest.rs b/crates/picloud-cli/src/manifest.rs index c05e7f9..706a685 100644 --- a/crates/picloud-cli/src/manifest.rs +++ b/crates/picloud-cli/src/manifest.rs @@ -29,6 +29,7 @@ use serde::{Deserialize, Serialize}; pub const MANIFEST_FILE: &str = "picloud.toml"; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct Manifest { /// An app node declares `[app]`; a group node declares `[group]` (Phase 5). /// Exactly one is present (enforced by [`Manifest::parse`]). @@ -209,6 +210,7 @@ pub struct OverlayApp { } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct ManifestApp { pub slug: String, pub name: String, @@ -227,6 +229,7 @@ pub struct ManifestApp { /// `pic groups create`); the manifest reconciles its content, not the tree /// shape. In a nested project the parent is inferred from the directory tree. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct ManifestGroup { pub slug: String, pub name: String, @@ -624,6 +627,29 @@ mod tests { assert_eq!(m, Manifest::parse(&text).unwrap()); } + #[test] + fn rejects_misplaced_or_unknown_keys() { + // §5.5 footgun guard: `extension_points` is an `[app]`/`[group]` node + // key. Placed at top level (before any table header) TOML reads it as a + // root key — `deny_unknown_fields` must reject it loudly rather than + // silently drop it (the silent-drop bug that bit in C5). + let misplaced = "extension_points = [\"theme\"]\n[app]\nslug = \"x\"\nname = \"X\"\n"; + assert!( + Manifest::parse(misplaced).is_err(), + "a top-level extension_points must be rejected, not silently ignored" + ); + // A typo'd key inside [app] is likewise a hard error, not a drop. + let typo = "[app]\nslug = \"x\"\nname = \"X\"\nextention_points = [\"theme\"]\n"; + assert!( + Manifest::parse(typo).is_err(), + "an unknown key in [app] must be rejected" + ); + // The correct node-key placement still parses. + let ok = "[app]\nslug = \"x\"\nname = \"X\"\nextension_points = [\"theme\"]\n"; + let m = Manifest::parse(ok).expect("node-key extension_points must parse"); + assert_eq!(m.extension_points(), ["theme"]); + } + #[test] fn group_manifest_parses_and_rejects_app_only_blocks() { // A [group] node: scripts + vars, no [app].