Skip to content

Commit 9febc85

Browse files
committed
feat(yaml): accept the .kyaml extension in every YAML loader
KYAML is the estate's YAML dialect (standards YAML-POLICY §3.4, D280), and estate-owned configuration uses the .kyaml extension (D307). KYAML is a strict subset of YAML, so serde-saphyr already parses it; only the extension dispatch was missing. - crate::yaml gains EXTENSIONS = [yaml, yml, kyaml] plus is_extension and has_extension, so the list lives in one place. - Attack profiles, ambush timelines, amuck specs, the diff report loader and a2ml bundles dispatch through it. - assemblyline::is_source_file includes kyaml, so editing a .kyaml file changes the repo fingerprint and triggers a rescan. Tests load real KYAML (generated with `yq -o kyaml`, mikefarah v4.54.1) through the timeline, profile and amuck loaders, and pin the extension set. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PFaqv6iBXrt3W5Akd1Fq5k
1 parent 323a2cf commit 9febc85

8 files changed

Lines changed: 115 additions & 8 deletions

File tree

‎CHANGELOG.adoc‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,15 @@
22

33
=== [Unreleased]
44

5+
==== Added — `.kyaml` input (estate YAML policy D280/D307)
6+
7+
* Attack profiles, ambush timelines, amuck specs, `+diff+` reports and
8+
a2ml bundles now accept the `+.kyaml+` extension alongside `+.yaml+` and
9+
`+.yml+`. KYAML is a strict subset of YAML, so the same parser reads it.
10+
The extension list lives in one place, `+crate::yaml::EXTENSIONS+`.
11+
* `+assemblyline+` fingerprints `+.kyaml+` files, so editing one triggers a
12+
rescan.
13+
514
==== Fixed — YAML parser replaced with safe Rust (RUSTSEC-2024-0449)
615

716
* *`+serde_yaml+` → `+serde-saphyr+` 2.0.* `+serde_yaml+` 0.9 is

‎src/a2ml/mod.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -401,7 +401,7 @@ fn load_payload_for_kind(kind: ReportBundleKind, input: &Path) -> Result<ReportB
401401
fn load_json_or_yaml<T: DeserializeOwned>(path: &Path) -> Result<T> {
402402
let raw = fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
403403
match path.extension().and_then(|e| e.to_str()) {
404-
Some("yaml") | Some("yml") => crate::yaml::from_report_str::<T>(&raw)
404+
Some(ext) if crate::yaml::is_extension(ext) => crate::yaml::from_report_str::<T>(&raw)
405405
.with_context(|| format!("parsing {}", path.display())),
406406
_ => serde_json::from_str::<T>(&raw).with_context(|| format!("parsing {}", path.display())),
407407
}

‎src/ambush/timeline.rs‎

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,9 +69,7 @@ pub fn load_timeline_with_default(
6969
.with_context(|| format!("reading timeline {}", path.display()))?;
7070
buf
7171
};
72-
let spec: TimelineSpec = if path.extension().and_then(|s| s.to_str()) == Some("yaml")
73-
|| path.extension().and_then(|s| s.to_str()) == Some("yml")
74-
{
72+
let spec: TimelineSpec = if crate::yaml::has_extension(path) {
7573
crate::yaml::from_str(&content)
7674
.with_context(|| format!("parsing yaml timeline {}", path.display()))?
7775
} else {
@@ -308,6 +306,24 @@ mod tests {
308306
assert_eq!(plan.events[0].intensity, IntensityLevel::Heavy);
309307
}
310308

309+
#[test]
310+
fn test_load_timeline_kyaml() {
311+
// Output of `yq -o kyaml` for the YAML timeline above.
312+
let dir = TempDir::new().unwrap();
313+
let path = dir.path().join("timeline.kyaml");
314+
std::fs::write(
315+
&path,
316+
"{\n duration: \"15s\",\n tracks: [\n {\n axis: \"memory\",\n events: [\n {\n at: \"2s\",\n for: \"8s\",\n intensity: \"heavy\",\n },\n ],\n },\n ],\n}\n",
317+
)
318+
.unwrap();
319+
320+
let plan = load_timeline_with_default(&path, None).unwrap();
321+
assert_eq!(plan.duration, Duration::from_secs(15));
322+
assert_eq!(plan.events.len(), 1);
323+
assert_eq!(plan.events[0].axis, AttackAxis::Memory);
324+
assert_eq!(plan.events[0].intensity, IntensityLevel::Heavy);
325+
}
326+
311327
#[test]
312328
fn test_timeline_infers_duration_from_events() {
313329
let dir = TempDir::new().unwrap();

‎src/amuck/mod.rs‎

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -324,11 +324,11 @@ fn load_spec(path: &Path) -> Result<MutationSpecFile> {
324324
Some("json") => {
325325
serde_json::from_str(&content).with_context(|| format!("parsing {}", path.display()))?
326326
}
327-
Some("yaml") | Some("yml") => crate::yaml::from_str(&content)
327+
Some(ext) if crate::yaml::is_extension(ext) => crate::yaml::from_str(&content)
328328
.with_context(|| format!("parsing {}", path.display()))?,
329329
_ => {
330330
return Err(anyhow!(
331-
"spec {} must be json/yaml/yml",
331+
"spec {} must be json/yaml/yml/kyaml",
332332
path.to_string_lossy()
333333
));
334334
}
@@ -625,6 +625,26 @@ mod tests {
625625
use super::*;
626626
use tempfile::TempDir;
627627

628+
#[test]
629+
fn load_spec_reads_kyaml() {
630+
// Output of `yq -o kyaml` for a one-combo YAML spec.
631+
let dir = TempDir::new().expect("tempdir should create");
632+
let path = dir.path().join("spec.kyaml");
633+
std::fs::write(
634+
&path,
635+
"{\n combos: [\n {\n name: \"c1\",\n operations: [\n {\n op: \"replace_first\",\n from: \"a\",\n to: \"b\",\n },\n ],\n },\n ],\n}\n",
636+
)
637+
.expect("spec should write");
638+
639+
let spec = load_spec(&path).expect("kyaml spec should load");
640+
assert_eq!(spec.combos.len(), 1);
641+
assert_eq!(spec.combos[0].name.as_deref(), Some("c1"));
642+
assert!(matches!(
643+
spec.combos[0].operations.as_slice(),
644+
[MutationOperation::ReplaceFirst { from, to }] if from == "a" && to == "b"
645+
));
646+
}
647+
628648
#[test]
629649
fn replace_first_changes_only_one_match() {
630650
let mut content = "true true true\n".to_string();

‎src/assemblyline.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,7 @@ fn is_source_file(path: &Path) -> bool {
291291
| "toml"
292292
| "yaml"
293293
| "yml"
294+
| "kyaml"
294295
| "json"
295296
| "aff"
296297
| "affine"

‎src/attack/profile.rs‎

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ impl AttackProfile {
4242
match path.extension().and_then(|ext| ext.to_str()) {
4343
Some("json") => serde_json::from_str(&content)
4444
.with_context(|| format!("parsing json attack profile {}", path.display())),
45-
Some("yaml") | Some("yml") => crate::yaml::from_str(&content)
45+
Some(ext) if crate::yaml::is_extension(ext) => crate::yaml::from_str(&content)
4646
.with_context(|| format!("parsing yaml attack profile {}", path.display())),
4747
_ => Err(anyhow!(
4848
"unsupported attack profile extension for {}",
@@ -51,3 +51,29 @@ impl AttackProfile {
5151
}
5252
}
5353
}
54+
55+
#[cfg(test)]
56+
mod tests {
57+
use super::*;
58+
use tempfile::TempDir;
59+
60+
#[test]
61+
fn loads_kyaml_profile() {
62+
// Output of `yq -o kyaml` for a YAML attack profile.
63+
let dir = TempDir::new().expect("tempdir should create");
64+
let path = dir.path().join("profile.kyaml");
65+
std::fs::write(
66+
&path,
67+
"{\n common_args: [\n \"--config\",\n \"cfg.toml\",\n ],\n axes: {\n cpu: [\n \"--iterations\",\n \"5000\",\n ],\n },\n probe_mode: \"always\",\n}\n",
68+
)
69+
.expect("profile should write");
70+
71+
let profile = AttackProfile::load(&path).expect("kyaml profile should load");
72+
assert_eq!(profile.common_args, ["--config", "cfg.toml"]);
73+
assert_eq!(
74+
profile.axes.get(&AttackAxis::Cpu).map(Vec::as_slice),
75+
Some(["--iterations".to_string(), "5000".to_string()].as_slice())
76+
);
77+
assert_eq!(profile.probe_mode, Some(ProbeMode::Always));
78+
}
79+
}

‎src/report/diff.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ pub fn load_report(path: &Path) -> Result<AssaultReport> {
2929
};
3030
// Diff loader accepts JSON/YAML to match report export formats.
3131
match path.extension().and_then(|ext| ext.to_str()) {
32-
Some("yaml") | Some("yml") => crate::yaml::from_report_str(&content)
32+
Some(ext) if crate::yaml::is_extension(ext) => crate::yaml::from_report_str(&content)
3333
.with_context(|| format!("parsing yaml report {}", path.display())),
3434
_ => serde_json::from_str(&content)
3535
.with_context(|| format!("parsing json report {}", path.display())),

‎src/yaml.rs‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,31 @@
1010
//! scalar-byte budgets proportional to the input length instead: an alias-free document
1111
//! cannot have more nodes than bytes, so the cap still bounds alias
1212
//! expansion while accepting any report the caller's read limit allows.
13+
//!
14+
//! KYAML (the estate's YAML dialect, D280/D307) is a strict subset of
15+
//! YAML, so `.kyaml` files go through the same parser.
1316
1417
use serde::de::DeserializeOwned;
1518
use serde::Serialize;
1619
use serde_saphyr::budget::Budget;
1720
use serde_saphyr::options::Options;
21+
use std::path::Path;
22+
23+
/// File extensions read as YAML. `kyaml` is the estate extension for
24+
/// KYAML-dialect configuration.
25+
pub const EXTENSIONS: [&str; 3] = ["yaml", "yml", "kyaml"];
26+
27+
/// Whether `ext` (without the dot) names a YAML or KYAML file.
28+
pub fn is_extension(ext: &str) -> bool {
29+
EXTENSIONS.contains(&ext)
30+
}
31+
32+
/// Whether `path` has a YAML or KYAML extension.
33+
pub fn has_extension(path: &Path) -> bool {
34+
path.extension()
35+
.and_then(|ext| ext.to_str())
36+
.is_some_and(is_extension)
37+
}
1838

1939
/// Parse a hand-written YAML config with the default resource budget.
2040
pub fn from_str<T: DeserializeOwned>(input: &str) -> Result<T, serde_saphyr::Error> {
@@ -42,6 +62,21 @@ pub fn to_string<T: Serialize>(value: &T) -> Result<String, serde_saphyr::ser::E
4262
mod tests {
4363
use super::*;
4464

65+
#[test]
66+
fn yaml_extensions_include_kyaml() {
67+
for ext in ["yaml", "yml", "kyaml"] {
68+
assert!(is_extension(ext), "{ext}");
69+
assert!(
70+
has_extension(Path::new(&format!("dir/file.{ext}"))),
71+
"{ext}"
72+
);
73+
}
74+
for ext in ["json", "YAML", "kyml", "ncl"] {
75+
assert!(!is_extension(ext), "{ext}");
76+
}
77+
assert!(!has_extension(Path::new("kyaml")));
78+
}
79+
4580
#[test]
4681
fn report_budget_accepts_documents_past_the_default_node_cap() {
4782
let items: Vec<u8> = vec![0; 300_000];

0 commit comments

Comments
 (0)