Fuzz Run 20260811-2: sc-compose include resolver

Adversarial fuzz campaign against expansion.rs / directive.rs / path.rs / fingerprint.rs

Generated: 2026-08-11T00:00:00Z

Source: sc-adversarial-fuzz-coordinator, integrate/phase-M @ 47572e9

FAIL

Summary

Bounded adversarial fuzzing against the production @<path> include-graph resolver (crates/sc-composer/src/include/expansion.rs, directive.rs, path.rs) and the fingerprint/hash path (fingerprint.rs, crates/sc-sha), run against the Sprint M.2 merge at integrate/phase-M commit 47572e9. Three focused workers ran 95 total bounded cases (34 + 38 + 23); two workers confirmed a total of 4 distinct bugs, all independently reproduced 3/3 times by the coordinator. No panics, hangs, or crashes were observed; all findings are silent-incorrectness or misleading-diagnostic defects. None duplicate an existing tracked GitHub issue.

Fuzz run descriptionIterationsPassResult
Conditional-candidate enumeration and diamond-dependency dedup edge cases in the @<path> include resolver 34 32/34 FAIL
Negative-contract and confinement-boundary probes against resolve_include_path's relative/root-relative fallback 38 36/38 FAIL
Manifest/composition-hash determinism and metamorphic relations for the include-graph fingerprint 23 23/23 PASS

Adversarial fuzz worker

shape-probe

Session m2-include-fuzz-20260811-1 · Worker shape-probe

FAIL

Fuzz run descriptionIterationsPassResult
Conditional-candidate enumeration and diamond-dependency dedup edge cases in the @<path> include resolver 34 32/34 FAIL

Inputs exercised

CaseTemplate / inputOutcome
FUZZ-0013-way chained ternary @<{{ "a" if x else "b" if y else "c" }}>FAIL: mis-resolves to garbage candidate path, ERR_INCLUDE_NOT_FOUND with leaked parser internals
FUZZ-0022-way diamond sharing a frontmatter-less leaf (a.md, b.md both @<shared.md>)FAIL: ERR_VAL_MISSING_FRONTMATTER emitted twice for shared.md instead of once
same-file-conditional-with-filterBoth ternary candidates resolve to the same file; condition contains a Jinja filterPASS: renders correctly, {% if %}/{% else %}/{% endif %} wrapper stays valid Jinja
mismatched-quote-literalConditional literal with mismatched outer quote style ("a.md' if x else 'b.md")PASS: whole span is itself one valid quoted literal per quoted_literal's own rules; falls back to Static as designed

Findings

FUZZ-001

Minimal template / frontmatter
@<{{ "a.md" if x else "b.md" if y else "c.md" }}>
Input
a.md="A\n", b.md="B\n", c.md="C\n" (any values for x/y; --var x=true --var y=false)
Expected
A 3-way chained Jinja ternary inside @<{{ ... }}> is either resolved correctly to one of the three candidates, or rejected cleanly as IncludeDirective::Dynamic (ERR_INCLUDE_DYNAMIC_UNRESOLVED), matching how the single-level "a" if x else "b" form is already handled.
Observed
parse_conditional_expression() (directive.rs) splits only once on " if " and once on " else ". For the else-branch text '"b.md" if y else "c.md"', quoted_literal() sees a string that starts and ends with a double-quote byte and accepts the ENTIRE span -- including the embedded ' if y else ' -- as one opaque literal. The parser therefore builds a Conditional{condition:"x", candidates:["a.md", "b.md\" if y else \"c.md"]} instead of failing, and expansion tries to open the literal garbage path 'b.md" if y else "c.md', producing ERR_INCLUDE_NOT_FOUND with a confusing, internals-leaking path instead of a clean dynamic-unresolved diagnostic. Reproduced deterministically 3/3 runs via `cargo run -p sc-compose -- render --mode file`.
Requirement / ADR
docs/requirements.md FR-3 (Include Expansion) requires deterministic expansion order and that "Include failures must produce actionable diagnostics with include-chain context." No requirement or ADR currently covers N-way (3+) ternary chaining inside @<{{ ... }}>; the only documented/tested conditional shape is the single-level "a" if cond else "b" form (crates/sc-composer/src/include.rs::conditional_path_candidates_are_exhaustive_and_renderable).
Requirement / ADR follow-up
No new requirement or ADR is needed -- this is a narrow parser-correctness gap inside an already-specified feature (conditional-candidate enumeration), not a request for a new chained-ternary feature. Recommend hardening quoted_literal()/parse_conditional_expression() to reject (fall back to Dynamic) any literal whose de-quoted content still contains " if " or " else ", so unsupported chaining fails with ERR_INCLUDE_DYNAMIC_UNRESOLVED per FR-3's actionable-diagnostics clause instead of resolving to a garbage path.
Root cause
crates/sc-composer/src/include/directive.rs: quoted_literal() accepts any first/last-quote-matched span as a literal without checking for embedded Jinja control keywords once dequoted; parse_conditional_expression() only performs a single split_once(" if ")/split_once(" else ") pass, so 3+-way ternary chains fall through to this over-permissive literal check instead of being rejected as Dynamic.
Recommended fix
Add a guard in quoted_literal() (or its caller in parse_conditional_expression) rejecting any candidate literal whose content contains " if " or " else " after unquoting, forcing multi-way ternary chains to resolve to IncludeDirective::Dynamic and surface ERR_INCLUDE_DYNAMIC_UNRESOLVED. Add a regression test in crates/sc-composer/src/include.rs asserting parse_include_directive() on a 3-way chained ternary returns Dynamic, not a Conditional with a garbled candidate.

FUZZ-002

Minimal template / frontmatter
root.md.j2: "@<a.md>\n@<b.md>\n"; a.md: "@<shared.md>\n"; b.md: "@<shared.md>\n"; shared.md: "{{ my_var }}\n" (no frontmatter)
Input
--var my_var=x (validate --json)
Expected
A file reached twice via a diamond (a.md and b.md both include shared.md) should contribute its frontmatter-derived diagnostics exactly once, mirroring the existing per-file dedup already applied to resolved_files/source_texts/resolved_seen for the identical reason.
Observed
ExpandedTemplate.frontmatters accumulates one entry PER VISIT, not per file: expand_file() in expansion.rs pushes to state.frontmatters unconditionally on every call, unlike the sibling resolved_files/source_texts pushes which are correctly gated behind the `is_new` check. For the 2-way diamond fixture, `sc-compose validate --json` emits the ERR_VAL_MISSING_FRONTMATTER warning for shared.md TWICE (byte-identical diagnostics, same path/message) instead of once. Verified scaling with a fan-out probe: N-way diamond fan-out produces N duplicate diagnostics. Reproduced deterministically 3/3 `validate --json` runs.
Requirement / ADR
No formal requirement or ADR states an exact per-file diagnostic-count invariant, but crates/sc-composer/src/include.rs documents ExpandedTemplate.frontmatters as "Parsed frontmatter values keyed by the file they came from" (implying one logical entry per file), and FR-3a (Frontmatter Across Includes) treats included-file frontmatter as participating once in composition validation, consistent with the existing resolved_files/source_texts/resolved_seen dedup-by-canonical-path pattern used for the same diamond scenario.
Requirement / ADR follow-up
No new requirement or ADR is needed; this is a straightforward implementation gap against the existing per-file accounting intent already honored by the sibling collections in the same function. Recommend gating the frontmatters push behind the same `is_new` check used for resolved_files/source_texts.
Root cause
crates/sc-composer/src/include/expansion.rs expand_file(): `state.frontmatters.push((path.to_path_buf(), parsed.passes().to_vec()))` runs unconditionally on every visit to a path, including cached (is_new == false) diamond re-visits, while the sibling `resolved_files.push` and `source_texts.insert` calls two lines above/below are correctly gated behind `if is_new`. Downstream, missing_frontmatter_warnings_for_path and frontmatter_diagnostics in validation/diagnostics.rs iterate expanded.frontmatters without deduplicating by path, so each diamond occurrence re-emits the same diagnostic.
Recommended fix
Gate `state.frontmatters.push(...)` in expand_file() behind the existing `is_new` boolean, matching resolved_files/source_texts. Add a regression test (crates/sc-composer/src/include.rs or a CLI integration test in crates/sc-compose/tests) building a 2-way diamond with a frontmatter-less shared leaf and asserting `sc-compose validate --json` emits exactly one ERR_VAL_MISSING_FRONTMATTER for the shared path, not one per occurrence.

Adversarial fuzz worker

boundary-probe

Session m2-include-fuzz-20260811-1 · Worker boundary-probe

FAIL

Fuzz run descriptionIterationsPassResult
Negative-contract and confinement-boundary probes against resolve_include_path's relative/root-relative fallback 38 36/38 FAIL

Inputs exercised

CaseTemplate / inputOutcome
FUZZ-003Permission-denied nested candidate with a same-named root-relative decoy presentFAIL: silently renders decoy content, exit 0, zero diagnostic
FUZZ-004Permission-denied nested candidate with no root-relative fallback file presentFAIL: reports ERR_INCLUDE_NOT_FOUND on a fabricated root-relative path, masking the real ERR_INCLUDE_PERMISSION_DENIED
empty-and-unterminated-directive@<> (len==3) and unterminated @<no_closing_bracketPASS: both correctly raise ERR_INCLUDE_DYNAMIC_UNRESOLVED, never silently swallowed as literal text
self-include-normalization@<./root.md.j2> and @<sub/../root.md.j2> self-inclusionPASS: both correctly classified ERR_INCLUDE_CYCLE; canonicalization happens before the cycle check
existence-oracle-directory-variantEscape to an existing directory outside root (issue #249 regression variation)PASS: ERR_INCLUDE_ESCAPE, identical shape to the missing-target case; no existence-oracle leak

Findings

FUZZ-003

Minimal template / frontmatter
root.md.j2: "@<a/child.md.j2>\n"; a/child.md.j2: "@<priv/restricted.md>\n"; a/priv/restricted.md: "REAL nested secret"; priv/restricted.md: "DECOY root-level file" (a/priv chmod 000)
Input
sc-compose render --mode file --root <fixture> --file root.md.j2 (no vars needed)
Expected
Per FR-3, include resolution tries (1) the path relative to the containing file, then (2) the path relative to the configured root, in that documented order. When the nested containing-file-relative candidate exists but is rendered inaccessible (EACCES on a parent directory), the resolver must either surface an actionable permission diagnostic naming the intended nested target, or, at minimum, never silently substitute the content of an unrelated same-named root-relative file with zero error and zero identity signal.
Observed
Render succeeds (exit 0) and emits "DECOY root-level file" instead of erroring or resolving the real nested target. resolve_include_path() (path.rs) tries the relative candidate first; on ANY Err from canonicalize_include -- not just NotFound -- it discards that error and silently retries the root-relative candidate. Because a decoy file of the same relative name also exists at the root-relative resolution point, the caller receives wrong file content with no diagnostic distinguishing this from an ordinary successful nested include. Reproduced deterministically 3/3 runs; classified confirmed_bug (severity: high -- silent wrong-content substitution, not merely a diagnostics-quality gap).
Requirement / ADR
docs/requirements.md FR-3 documents the two-step relative-then-root resolution order explicitly, and separately requires "Include failures must produce actionable diagnostics with include-chain context." The two-step order itself is intentional per FR-3; the defect is that classified filesystem errors (permission-denied, filesystem-loop, is-a-directory) from step 1 are discarded identically to a plain not-found, allowing step 2 to silently substitute an unrelated file with no error surfaced at all.
Requirement / ADR follow-up
No new requirement or ADR is needed -- FR-3's two-step resolution order is already correct and intentional; the fallback-swallowing behavior for non-NotFound classified errors is the implementation bug. Recommend fixing resolve_include_path() to only fall through to the root-relative candidate when the relative attempt's error is specifically ErrIncludeNotFound; any other classified error (permission-denied, filesystem-loop, is-a-directory, or confinement-escape) should be surfaced immediately, since a decoy at the root-relative location must never silently stand in for an inaccessible intended nested target.
Root cause
crates/sc-composer/src/include/path.rs resolve_include_path(): `if let Ok(path) = canonicalize_include(&relative_candidate, ...) { return Ok(path); }` unconditionally falls through to the root-relative attempt on ANY Err variant from the first canonicalize_include call, without inspecting the returned DiagnosticCode/error kind. This conflates "relative candidate does not exist, try root-relative next" (the FR-3-documented intentional case) with "relative candidate exists but is inaccessible/escapes/loops" (which should be a terminal, actionable failure).
Recommended fix
In resolve_include_path(), match on the classified error from the first canonicalize_include attempt: only retry the root-relative candidate when the error is ErrIncludeNotFound; propagate ErrIncludePermissionDenied/ErrIncludeFilesystemLoop/ErrIncludeIsADirectory/ErrIncludeEscape immediately instead of retrying. Add a regression test in crates/sc-composer/src/include.rs (adjacent to permission_denied_include_reports_permission_denied) with a same-named decoy at the root-relative location, asserting the render fails with ErrIncludePermissionDenied and the decoy's content is never returned.

FUZZ-004

Minimal template / frontmatter
root.md.j2: "@<a/child.md.j2>\n"; a/child.md.j2: "@<priv/restricted.md>\n"; a/priv/restricted.md: "nested secret" (a/priv chmod 000); no file at root-relative priv/restricted.md
Input
sc-compose render --mode file --root <fixture> --file root.md.j2 (no vars needed)
Expected
The surfaced diagnostic for an include failure should reflect the most specific/actionable classified error for the include exactly as the template author wrote it (relative to its containing file) -- i.e. ERR_INCLUDE_PERMISSION_DENIED naming a/priv/restricted.md -- per FR-3's "actionable diagnostics with include-chain context" requirement.
Observed
The CLI instead reports ERR_INCLUDE_NOT_FOUND naming a root-relative path ("<root>/priv/restricted.md") that was never written in any template and does not correspond to the actual permission-denied cause. The real, already-correctly-classified PermissionDenied error from the relative-candidate attempt is discarded in favor of the generic NotFound from the root-relative fallback attempt. Reproduced deterministically 3/3 runs; same root cause as FUZZ-003, distinct symptom (misleading diagnostic identity/path rather than silent wrong content).
Requirement / ADR
Same requirement grounding as FUZZ-003: FR-3's two-step resolution order is intentional, but its "actionable diagnostics" clause is violated when the more specific first-attempt error is discarded for a less actionable fallback error.
Requirement / ADR follow-up
No new requirement or ADR is needed; covered by the same FR-3 fix recommended for FUZZ-003.
Root cause
Same root cause as FUZZ-003 in crates/sc-composer/src/include/path.rs resolve_include_path(): the first classified error is unconditionally discarded before the root-relative retry, regardless of its diagnostic code.
Recommended fix
Fix jointly with FUZZ-003: preserve and surface the relative candidate's classified error (permission-denied, filesystem-loop, is-a-directory, escape) instead of the root-relative fallback's generic not-found, whenever the relative attempt's failure was not itself a plain not-found. Add a regression test asserting the diagnostic path/code reflects the containing-file-relative target, not the root-relative fallback target, when the two attempts disagree.

Adversarial fuzz worker

differential-probe

Session m2-include-fuzz-20260811-1 · Worker differential-probe

PASS

Fuzz run descriptionIterationsPassResult
Manifest/composition-hash determinism and metamorphic relations for the include-graph fingerprint 23 23/23 PASS

Inputs exercised

CaseTemplate / inputOutcome
cross-process-determinismSame fixture tree rendered in 6 fresh processes (linear+diamond nesting) plus 3 fresh processes at 60-way fan-outPASS: byte-identical source_sha and manifest ordering across all 9 fresh-process runs; 60-node fan-out completed in 8-10ms
rename-changes-identity-content-hash-stableRenamed include target with identical content_hash for the unchanged leafPASS: source_sha differs (path-based identity), shared leaf content_hash identical, stable across 3 runs each
empty-and-dangling-manifest-validationDirect ResolvedTemplateManifest construction: 0-node/0-edge manifest, and an edge referencing endpoints absent from an empty node listPASS: empty manifest hashes deterministically with no panic; dangling edge is rejected deterministically as CompositionError::UnknownEdgeEndpoint in all 3 runs

Recommendations

Metadata

Campaign IDm2-include-fuzz-20260811-1
SprintM.2 (sc-compose sc-sha integration)
Worktree/Users/randlee/Documents/github/sc-compose-worktrees/integrate/phase-M
Baseline ref47572e9 (integrate/phase-M)
Fuzz plandocs/phase-M/fuzz-plan-m-2-sc-compose-integration.md (PR #369)
Targetcrates/sc-composer/src/include/{expansion.rs,directive.rs,fingerprint.rs,path.rs}, crates/sc-sha
Workersshape-probe, boundary-probe, differential-probe (3 of 4 standard roles; template-probe omitted, scope already covered by existing include.rs tests per coordinator triage)
Seed157
Cases per worker (bounded)shape-probe=34, boundary-probe=38, differential-probe=23
Per-worker timeout120s
PromotionDisabled for this run - no tracked test files modified; regression tests intentionally NOT promoted per task constraints
GitHub issue cross-checkgh issue list --repo randlee/sc-compose --search "fuzz" (and targeted searches on #247/#249/#251/#298) - no duplicate found for FUZZ-001..004