Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Security

- **Frontmatter YAML parsing is bounded (#162).** Adversarial frontmatter can no
longer exhaust the host through the YAML parser (an abort the JS/Python FFI
boundary cannot catch). A block over 1 MiB, or one that would expand to more
than 200,000 YAML nodes (an `&anchor` referenced by many `*alias`es), or one
nesting flow collections deeper than 1024 levels, is rejected with
`mds::resource_limit` before the parser materialises or deep-scans it — on the
CLI, the Rust API, napi, wasm and Python. The deep-nesting guard is checked
before the parser runs because the parser's own depth limits are reported only
after an O(depth²) scan is already paid. `mds::compile_str`/`check_str`/`lint_str_with`
now also reject sources over `MAX_FILE_SIZE` (10 MiB) with `mds::resource_limit`;
the bindings and the CLI already did. `mds lint` and `scan_imports` no longer
swallow these errors (and `scan_imports` now reports more than 256 frontmatter
`imports` entries as `mds::resource_limit` instead of silently omitting them);
plain YAML syntax errors in those two paths stay best-effort as before.
Duplicate-key and syntax-error messages are unchanged.

### Changed

- **`mds watch --debounce` is now a quiet period with a hard cap (#379).**
Expand Down
6 changes: 5 additions & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,11 @@ input. The compiler enforces several defense-in-depth controls:

| Limit | Value | Location |
|-------|-------|----------|
| Max file size | 10 MB per source file | `limits.rs` (`MAX_FILE_SIZE`) |
| Max file size | 10 MB per source file (file, virtual module, or in-memory string) | `limits.rs` (`MAX_FILE_SIZE`) |
| Max frontmatter size | 1 MiB per block | `limits.rs` (`MAX_FRONTMATTER_SIZE`) |
| Max frontmatter YAML nodes | 200,000 per block (alias expansion counted) | `limits.rs` (`MAX_FRONTMATTER_NODES`) |
| Max frontmatter flow-nesting depth | 1024 (checked pre-parse) | `limits.rs` (`MAX_FRONTMATTER_FLOW_DEPTH`) |
| YAML parser nesting depth | 128 | serde_yaml_ng (reported as `mds::yaml`) |
| Max `mds.json` size | 1 MB | `mds-cli/src/main.rs` (`MAX_CONFIG_SIZE`) |
| Max call depth | 128 | `evaluator.rs` (`MAX_CALL_DEPTH`) |
| Max iterations per loop | 100,000 | `evaluator.rs` (`MAX_LOOP_ITERATIONS`) |
Expand Down
158 changes: 93 additions & 65 deletions crates/mds-cli/tests/cli_watch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1260,79 +1260,107 @@ fn watch_debounce_single_rebuild_from_burst() {
/// since the loop never reaches `TickClock::recv_next` while a window is open.
#[test]
fn watch_debounce_cap_rebuilds_while_writes_never_stop() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("hot.mds");
std::fs::write(&src, "---\nname: v0\n---\nHot {{name}}!\n").unwrap();
let out = dir.path().join("hot.md");

// --debounce 200 => cap = max(10 x 200ms, 1s) = 2s.
let (mut child, stderr_tap) = spawn_ready(
mds_bin()
.args([
"watch",
src.to_str().unwrap(),
"--debounce",
"200",
"--poll-interval",
"0",
])
.stdout(Stdio::null()),
);
// The writer thread must keep the stream denser than the 200ms quiet-period window,
// so a rebuild seen WHILE writing provably comes from the cap and not from a quiet
// period that ended on its own. That is a HARNESS precondition, not a property of
// the code under test: on a loaded runner the writer thread can itself be
// descheduled past the window (a 747ms inter-write gap was observed on CI), which
// makes the sample inconclusive rather than failing. Retry the whole measurement a
// bounded number of times, gated ONLY on that precondition — every behaviour
// assertion below still fails hard on the first attempt, so a real regression is
// never retried away.
const MAX_ATTEMPTS: u32 = 4;
const WINDOW: Duration = Duration::from_millis(200);

for attempt in 1..=MAX_ATTEMPTS {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("hot.mds");
std::fs::write(&src, "---\nname: v0\n---\nHot {{name}}!\n").unwrap();
let out = dir.path().join("hot.md");

// --debounce 200 => cap = max(10 x 200ms, 1s) = 2s.
let (mut child, stderr_tap) = spawn_ready(
mds_bin()
.args([
"watch",
src.to_str().unwrap(),
"--debounce",
"200",
"--poll-interval",
"0",
])
.stdout(Stdio::null()),
);

assert!(
wait_for_file_contains(&out, "Hot v0!", TIMEOUT),
"initial compile should produce Hot v0!"
);
assert!(
wait_for_file_contains(&out, "Hot v0!", TIMEOUT),
"initial compile should produce Hot v0!"
);

let writing = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true));
let writer_flag = std::sync::Arc::clone(&writing);
let writer_src = src.clone();
let writer = std::thread::spawn(move || {
let stop_at = Instant::now() + Duration::from_secs(3);
let mut max_gap = Duration::ZERO;
let mut last = Instant::now();
// Doubly bounded: <= 3s of wall clock AND <= 2000 iterations.
for i in 1..=2_000u32 {
if Instant::now() >= stop_at {
break;
let writing = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true));
let writer_flag = std::sync::Arc::clone(&writing);
let writer_src = src.clone();
let writer = std::thread::spawn(move || {
let stop_at = Instant::now() + Duration::from_secs(3);
let mut max_gap = Duration::ZERO;
let mut last = Instant::now();
// Doubly bounded: <= 3s of wall clock AND <= 2000 iterations.
for i in 1..=2_000u32 {
if Instant::now() >= stop_at {
break;
}
write_atomic(
&writer_src,
format!("---\nname: v{i}\n---\nHot {{{{name}}}}!\n"),
);
let now = Instant::now();
max_gap = max_gap.max(now.duration_since(last));
last = now;
std::thread::sleep(Duration::from_millis(5));
}
write_atomic(
&writer_src,
format!("---\nname: v{i}\n---\nHot {{{{name}}}}!\n"),
writer_flag.store(false, std::sync::atomic::Ordering::SeqCst);
max_gap
});

// The cap is 2s; allow the compile that follows it to land inside the bound.
wait_for_stderr_contains_str(&stderr_tap, "Recompiled ", Duration::from_millis(3500));
let rebuilt_while_writing = writing.load(std::sync::atomic::Ordering::SeqCst);

let max_gap = writer.join().expect("writer thread panicked");

// Harness precondition, checked before any behaviour assertion: if the writer
// thread could not sustain a sub-window cadence, this run cannot tell a cap
// rebuild from a quiet-period one. Discard it and retry rather than reporting a
// scheduling hiccup as a product failure.
if max_gap >= WINDOW {
drop(child);
assert!(
attempt < MAX_ATTEMPTS,
"the writer thread could not sustain a sub-{WINDOW:?} write cadence in \
{MAX_ATTEMPTS} attempts (largest gap {max_gap:?}); the runner is too \
loaded to exercise the cap deterministically"
);
let now = Instant::now();
max_gap = max_gap.max(now.duration_since(last));
last = now;
std::thread::sleep(Duration::from_millis(5));
continue;
}
writer_flag.store(false, std::sync::atomic::Ordering::SeqCst);
max_gap
});

// The cap is 2s; allow the compile that follows it to land inside the bound.
wait_for_stderr_contains_str(&stderr_tap, "Recompiled ", Duration::from_millis(3500));
let rebuilt_while_writing = writing.load(std::sync::atomic::Ordering::SeqCst);
assert!(
rebuilt_while_writing,
"a rebuild must happen WHILE the writes are still arriving — that is what the \
cap is for; nothing was seen until the stream stopped"
);

let max_gap = writer.join().expect("writer thread panicked");
assert!(
max_gap < Duration::from_millis(200),
"precondition: no gap in the write stream may reach the 200ms window, else a \
quiet period could legitimately have ended it; largest gap was {max_gap:?}"
);
assert!(
rebuilt_while_writing,
"a rebuild must happen WHILE the writes are still arriving — that is what the \
cap is for; nothing was seen until the stream stopped"
);
let stderr = stderr_tap.finish_text(&mut child);
let rebuilds = count_occurrences(&stderr, "Recompiled ");
assert!(
(1..=4).contains(&rebuilds),
"3s of writes under a 200ms window with a 2s cap is one capped rebuild plus \
the quiet-period rebuild that follows the last write; a fixed 200ms window \
would give ~15. Got {rebuilds}; stderr was:\n{stderr}"
);
return;
}

let stderr = stderr_tap.finish_text(&mut child);
let rebuilds = count_occurrences(&stderr, "Recompiled ");
assert!(
(1..=4).contains(&rebuilds),
"3s of writes under a 200ms window with a 2s cap is one capped rebuild plus \
the quiet-period rebuild that follows the last write; a fixed 200ms window \
would give ~15. Got {rebuilds}; stderr was:\n{stderr}"
);
unreachable!("the loop returns on a conclusive attempt or asserts on the last one");
}

// ── AC-F10: Watch no-arg auto-detect ─────────────────────────────────────
Expand Down
47 changes: 47 additions & 0 deletions crates/mds-cli/tests/common/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,53 @@ pub fn mds_bin() -> std::process::Command {
cmd
}

// ── Frontmatter YAML bounds builders (#162) ──────────────────────────────────

/// Frontmatter size cap (1 MiB) — mirrors `mds-core`'s `MAX_FRONTMATTER_SIZE`.
#[allow(dead_code)]
pub const MAX_FRONTMATTER_SIZE: usize = 1 << 20;

/// Wrap a YAML frontmatter body in `---` fences with a one-line body.
#[allow(dead_code)]
pub fn wrap(yaml: &str) -> String {
format!("---\n{yaml}---\nHi\n")
}

/// A single `k: <sentinel><padding>\n` line whose total byte length is EXACTLY `bytes`.
///
/// The `ZZSENTINELZZ` marker lets the size-cap tests assert the rejection message never
/// echoes the (arbitrarily large) frontmatter content back to the user.
#[allow(dead_code)]
pub fn fm_of_size(bytes: usize) -> String {
const PREFIX: &str = "k: ZZSENTINELZZ";
assert!(bytes > PREFIX.len() + 1, "requested size too small");
let pad = bytes - PREFIX.len() - 1;
let out = format!("{PREFIX}{}\n", "x".repeat(pad));
assert_eq!(
out.len(),
bytes,
"fm_of_size must produce EXACTLY `bytes` bytes"
);
out
}

/// An alias-fan-out bomb: `a: &a [x, x, ...(n)]`, `b: [*a, *a, ...(m)]`. Each `*a`
/// re-expands the `n`-element anchor at deserialise time, so the materialised tree far
/// exceeds the node budget while the SOURCE stays small (~700 KB for n = m = 100 000).
#[allow(dead_code)]
pub fn alias_bomb(n: usize, m: usize) -> String {
let xs = vec!["x"; n].join(", ");
let refs = vec!["*a"; m].join(", ");
format!("a: &a [{xs}]\nb: [{refs}]\n")
}

/// `k: [[[...x...]]]` with `d` nested flow sequences around a scalar (a deep-nest DoS
/// repro; the pre-parse flow-depth guard rejects any `d > 1024`).
#[allow(dead_code)]
pub fn nested_flow_seq(d: usize) -> String {
format!("k: {}x{}\n", "[".repeat(d), "]".repeat(d))
}

// ── Duplicate --vars file key warnings (#326) ────────────────────────────────

/// USER-FACING CONTRACT (#326). `{key}` = dotted/bracketed path, `{path}` = the
Expand Down
Loading
Loading