Fuzz Run 20260811-1: sc-sha-python maturin adapter boundary

Sprint M.2 sc-compose/sc-sha integration -- bounded adversarial fuzz of bindings/sc-sha-python (FFI boundary) and boundaries/sc-sha-python/python-adapter.toml (SCB-DEPENDENCY-001 bypass spot-check)

Generated: 2026-08-11T17:30:00Z

Source: worktree /Users/randlee/Documents/github/sc-compose-worktrees/integrate/phase-M @ 47572e9 (integrate/phase-M, Sprint M.2 merged)

drift

Summary

Two bounded probes ran against the merged Sprint M.2 (47572e9) bindings/sc-sha-python maturin adapter. ffi-shape-probe fuzzed the two exposed FFI operations with malformed/adversarial Python inputs and found 2 confirmed, reproducible bugs in error-shape stability (no crashes or panics). boundary-differential-probe spot-checked the boundaries/sc-sha-python/python-adapter.toml dependency boundary (QM-014 from M.2's QA) for a genuinely new bypass across three vectors not covered by the existing fixture, and found none -- the boundary held.

Fuzz run descriptionIterationsPassResult
ffi-shape-probe: sc-sha-python FFI malformed-input / error-mapping fuzz 52 50/52 drift
boundary-differential-probe: SCB-DEPENDENCY-001 bypass spot-check (3 new vectors) 3 3/3 pass

Adversarial fuzz worker

ffi-shape-probe

Session 20260811-1 · Worker ffi-shape-probe

FAIL

Fuzz run descriptionIterationsPassResult
sc-sha-python FFI boundary: malformed/adversarial inputs across calculate_hash/calculate_composition_hash, result/error mapping vs sc-sha error codes 52 50/52 2 confirmed bugs (1 important, 1 minor)

Inputs exercised

CaseTemplate / inputOutcome
hash_invalid_utf8calculate_hash({utf8_file_bytes: b'\xff\xfe'})ScShaError SC_SHA_INVALID_UTF8 (correct)
hash_str_valuecalculate_hash({utf8_file_bytes: 'hello'})ScShaError SC_SHA_INVALID_INPUT (correct)
manifest_digest_non_hexcalculate_composition_hash(node.sha256='z'*64)ScShaError code=SC_SHA_INVALID_MANIFEST, expected SC_SHA_INVALID_DIGEST per docs/error-code-registry.md -- FUZZ-SCSHA-001
manifest_self_referentialmanifest dict with edges key aliased back to the manifest dict itselfScShaError SC_SHA_INVALID_MANIFEST, no panic (correct)
manifest_large_nodes2000-node manifest, no recursion/DoSok, bounded time (correct)
hash_error_str_reprstr(ScShaError) after calculate_hash({utf8_file_bytes: 'nope'})str(e) == "('SC_SHA_INVALID_INPUT', 'utf8_file_bytes must be bytes; encode text as UTF-8 explicitly')" (tuple repr, not the message) -- FUZZ-SCSHA-002

Findings

FUZZ-SCSHA-001

Minimal template / frontmatter
n/a (Python FFI call, no template)
Input
sc_sha.calculate_composition_hash({'schema': 'sc-sha/manifest/v1', 'nodes': [{'source': {'kind': 'local_path', 'value': 'a'}, 'sha256': 'not-hex'}], 'edges': []})
Expected
docs/error-code-registry.md lines 55-57 documents SC_SHA_INVALID_DIGEST as the stable machine-readable code for 'a manifest digest is not exactly 64 hexadecimal characters', sourced from TemplateSha256::from_hex(). bindings/sc-sha-python/src/lib.rs's own parse_digest() (added in QA round R1/QM-004) delegates directly to that same TemplateSha256::from_hex(), so its ShaError::InvalidDigestHex.code() == "SC_SHA_INVALID_DIGEST" is the correct, already-defined stable code for this condition.
Observed
Raised sc_sha.ScShaError with .code == "SC_SHA_INVALID_MANIFEST" and .message == "sha256 must contain exactly 64 hexadecimal characters". Any Python caller branching on error.code per the documented registry contract (e.g. to retry, log a metric, or surface a specific UI message for a corrupt digest) receives the generic manifest-shape code instead, indistinguishable from a missing/wrong-typed field.
Requirement / ADR
docs/error-code-registry.md rows for SC_SHA_INVALID_DIGEST (line 56) and ADR-0018 (sc-sha hash ownership) SHA-R-series stable-error-code requirements; also directly regresses the intent of QM-004's fix (delegate parse_digest to sc_sha::TemplateSha256::from_hex so the adapter stops duplicating hex-decode logic) by discarding the resulting error code at the call site.
Requirement / ADR follow-up
No new ADR/requirement needed -- this is a contract the repo already documents and already fixed the duplication for; the mapping in bindings/sc-sha-python/src/lib.rs simply needs to stop overwriting it.
Root cause
bindings/sc-sha-python/src/lib.rs::parse_digest (lines 99-106) maps sc_sha::ShaError to a bare &'static str message and drops error.code() entirely. Its sole caller, parse_nodes (line 133-134), then wraps that message with a hardcoded "SC_SHA_INVALID_MANIFEST" code: `parse_digest(&string_field(py, node, "sha256")?).map_err(|message| error(py, "SC_SHA_INVALID_MANIFEST", message))?;`. This is inconsistent with calculate_hash_py/calculate_composition_hash_py, which both correctly forward `e.code()` from ShaError/CompositionError to the Python exception.
Recommended fix
Change parse_digest to return Result<TemplateSha256, sc_sha::ShaError> (preserving the typed error), and have parse_nodes call `error(py, e.code(), e.to_string())` the same way calculate_hash_py does, instead of hardcoding SC_SHA_INVALID_MANIFEST. Add a regression test in bindings/sc-sha-python/tests/test_compatibility.py asserting error.code == "SC_SHA_INVALID_DIGEST" for an invalid-hex sha256 field inside a manifest node.

FUZZ-SCSHA-002

Minimal template / frontmatter
n/a (Python FFI call, no template)
Input
try: sc_sha.calculate_hash({'utf8_file_bytes': 'nope'})\nexcept sc_sha.ScShaError as e: str(e)
Expected
No explicit requirement pins str(ScShaError)'s exact format; the reasonable expectation for a public exception type exposing a stable .message attribute is that str(e) is at least as readable as .message, matching ordinary Python exception ergonomics (e.g. requests.HTTPError, json.JSONDecodeError) where str(e) is the human-readable message, not a raw args tuple.
Observed
str(e) == "('SC_SHA_INVALID_INPUT', 'utf8_file_bytes must be bytes; encode text as UTF-8 explicitly')" -- Python's default BaseException.__str__ renders the 2-tuple args as its repr, since ScShaError.__new__ passes (code, message) straight through to PyException's args. Code that does `except ScShaError as e: log.error(str(e))` or relies on unittest/pytest's default failure formatting gets this tuple-repr instead of a clean sentence.
Requirement / ADR
No ADR/requirement explicitly covers ScShaError's __str__ contract; bindings/sc-sha-python/python/sc_sha/__init__.pyi only declares .code and .message as typed attributes and is silent on __str__.
Requirement / ADR follow-up
Recommend adding one sentence to bindings/sc-sha-python's README/.pyi docstring documenting str(ScShaError) as the tuple-repr and directing callers to .message for a plain-text string, or fixing __str__ directly (see recommended fix) and then documenting the resulting format -- either is acceptable, but the current silent gap should close one way or the other.
Root cause
ScShaError (bindings/sc-sha-python/src/lib.rs lines 12-27) is a #[pyclass(extends = PyException)] whose #[new] takes (code, message) and stores both as PyO3-managed extra fields, but never overrides __str__/__repr__ at the Python level, so PyException's default __str__ (str(self.args) when len(args) != 1) takes over.
Recommended fix
Add a #[pymethods] fn __str__(&self) -> String { self.message.clone() } (and optionally __repr__ mirroring code+message) to ScShaError in bindings/sc-sha-python/src/lib.rs, and add a Python-side regression test asserting str(err) == err.message for at least one raised ScShaError.

Adversarial fuzz worker

boundary-differential-probe

Session 20260811-1 · Worker boundary-differential-probe

PASS

Fuzz run descriptionIterationsPassResult
boundaries/sc-sha-python/python-adapter.toml SCB-DEPENDENCY-001 boundary lint: novel dependency-edge bypass attempts (dependency renaming, dev-dependencies, target-cfg dependencies) beyond QM-014's 4-class fixture 3 3/3 no bypass found (boundary holds)

Inputs exercised

CaseTemplate / inputOutcome
bypass-aliassc-sha-python depends on sc-compose/sc-composer via renamed keys (composer_util = {path=..,package="sc-composer"}, compose_util = {path=..,package="sc-compose"})sc-lint lint sc-boundary still emits SCB-DEPENDENCY-001 for both sc-compose and sc-composer (resolves by real package name, not the Cargo.toml key)
bypass-devdepsc-compose/sc-composer/atmcore/unrelated-runtime moved from [dependencies] to [dev-dependencies] onlysc-lint lint sc-boundary still emits all 4 SCB-DEPENDENCY-001 findings
bypass-targetforbidden deps split across [target.'cfg(unix)'.dependencies] and [target.'cfg(windows)'.dependencies]sc-lint lint sc-boundary still emits all 4 SCB-DEPENDENCY-001 findings

Recommendations

Metadata

Run ID20260811-1
SprintM.2 (sc-compose / sc-sha integration)
Worktree/Users/randlee/Documents/github/sc-compose-worktrees/integrate/phase-M @ 47572e9
Fuzz plandocs/phase-M/fuzz-plan-m-2-sc-compose-integration.md (PR #369)
Seed157
Workersffi-shape-probe, boundary-differential-probe
Confirmed bugs (new)2 (FUZZ-SCSHA-001 Important, FUZZ-SCSHA-002 Minor)
Already-tracked findings0 (searched gh issue list --search "fuzz"/"sc-sha"/"ScShaError"/"SC_SHA" -- no match)
New bypasses found0 (3 novel SCB-DEPENDENCY-001 vectors tested, all caught)