commit 35d21546630f1cda8776a3a68044a101e865b15d
parent 0f0a526afe4d2d8a86d1bdf494f5d1afec0df992
Author: triesap <tyson@radroots.org>
Date: Wed, 23 Sep 2026 19:50:36 +0000
H005A: complete the required fail-closed hasher controls
- Add a bounded primary/fallback hasher seam on the test-only digest helpers.
- Reject a declared input set with one missing input, never a partial digest.
- Reject present valid inputs with a failing hasher stage and no fallback.
- Prove a supported fallback returns the same checked digest as the default.
Diffstat:
2 files changed, 133 insertions(+), 9 deletions(-)
diff --git a/tests/measurement_process_helper.mojo b/tests/measurement_process_helper.mojo
@@ -509,7 +509,10 @@ def _path_basename(path: String) -> String:
def _checked_file_digests(
- var files: List[String], mut guard: CleanupGuard
+ var files: List[String],
+ mut guard: CleanupGuard,
+ primary_hasher: String = "shasum",
+ fallback_hasher: String = "sha256sum",
) raises -> List[String]:
"""Per-file sha256 of explicit argv paths, fail-closed.
@@ -519,6 +522,12 @@ def _checked_file_digests(
an unavailable hasher raises instead of yielding a valid empty digest. This
replaces the former status-masking shell pipeline whose final stage could
succeed on empty input and report the empty-input digest as success.
+
+ ``primary_hasher``/``fallback_hasher`` name the two checked stages. The
+ defaults are the supported host hashers; the parameters exist only as a
+ bounded test seam so the failed-hasher-stage and fallback controls can run
+ against present, valid regular-file inputs without altering live tools or
+ host settings (ADR-0021 MP01).
"""
if len(files) == 0:
raise Error("measurement: refusing to digest an empty input list")
@@ -528,14 +537,14 @@ def _checked_file_digests(
for index in range(len(files)):
shasum_args.append(files[index])
var out = run_capture(
- "shasum", shasum_args^, MEASUREMENT_SAMPLE_DEADLINE_MS, guard
+ primary_hasher, shasum_args^, MEASUREMENT_SAMPLE_DEADLINE_MS, guard
)
if out.exit_code != 0:
var sum_args = List[String]()
for index in range(len(files)):
sum_args.append(files[index])
out = run_capture(
- "sha256sum", sum_args^, MEASUREMENT_SAMPLE_DEADLINE_MS, guard
+ fallback_hasher, sum_args^, MEASUREMENT_SAMPLE_DEADLINE_MS, guard
)
if out.exit_code != 0:
raise Error(
@@ -584,19 +593,26 @@ def sha256_text(
def sha256_file_set(
- label: String, var files: List[String], mut guard: CleanupGuard
+ label: String,
+ var files: List[String],
+ mut guard: CleanupGuard,
+ primary_hasher: String = "shasum",
+ fallback_hasher: String = "sha256sum",
) raises -> String:
"""Deterministic, path-independent digest of an explicit file set.
Each declared file's exact content digest is checked first, then the
manifest text ``<basename> <digest>`` (in declared order) is hashed, so the
result depends only on the declared files' content and names, never on the
- checkout location.
+ checkout location. ``primary_hasher``/``fallback_hasher`` are the bounded
+ test seam documented on ``_checked_file_digests``.
"""
var names = List[String]()
for index in range(len(files)):
names.append(_path_basename(files[index]))
- var digests = _checked_file_digests(files^, guard)
+ var digests = _checked_file_digests(
+ files^, guard, primary_hasher, fallback_hasher
+ )
var canonical = ""
for index in range(len(names)):
canonical += names[index] + " " + digests[index] + "\n"
@@ -673,7 +689,10 @@ def source_dirty_status(
def tooling_manifest_sha256(
- source_root: String, mut guard: CleanupGuard
+ source_root: String,
+ mut guard: CleanupGuard,
+ primary_hasher: String = "shasum",
+ fallback_hasher: String = "sha256sum",
) raises -> String:
"""Content digest of the measurement tooling that produced the evidence.
@@ -682,10 +701,13 @@ def tooling_manifest_sha256(
digest ties the emitted evidence to the reviewed tooling revision and its
imported helper closure without requiring the working tree to be committed
at capture time. A missing input or failed hasher stage is a bounded error,
- never a valid empty digest.
+ never a valid empty digest. The optional hasher parameters are the bounded
+ test seam described on ``_checked_file_digests``.
"""
var files = measurement_tooling_files(source_root)
- return sha256_file_set("tooling manifest", files^, guard)
+ return sha256_file_set(
+ "tooling manifest", files^, guard, primary_hasher, fallback_hasher
+ )
def source_identity(
diff --git a/tests/test_measurement_contract.mojo b/tests/test_measurement_contract.mojo
@@ -173,6 +173,43 @@ def _run_sh_failure(
return ""
+def _failing_hasher(root: String, mut guard: CleanupGuard) raises -> String:
+ """Owned hasher stand-in that fails without touching live tools or host
+ settings (ADR-0021 MP01). The script is created inside the isolated owned
+ temp root and exits 7 with an explicit stage error.
+ """
+ var path = root + "/failing-hasher.sh"
+ Path(path).write_text(
+ "#!/bin/sh\nprintf 'failing hasher stage\\n' >&2\nexit 7\n"
+ )
+ var chmod_args = List[String]()
+ chmod_args.append("+x")
+ chmod_args.append(path)
+ var chmodded = run_capture("chmod", chmod_args^, 20000, guard)
+ assert_equal(chmodded.exit_code, 0)
+ return path^
+
+
+def _copy_tooling_files(
+ root: String, mut guard: CleanupGuard, start: Int = 0
+) raises:
+ """Copy declared tooling inputs into an isolated owned ``tests`` root.
+
+ ``start`` > 0 deliberately omits the leading declared inputs, producing a
+ present-but-partial input set for the missing-one-input control.
+ """
+ var tests_dir = root + "/tests"
+ _ = std.os.makedirs(tests_dir, exist_ok=True)
+ var sources = measurement_tooling_files(".")
+ assert_true(len(sources) > start + 1)
+ var cp_args = List[String]()
+ for index in range(start, len(sources)):
+ cp_args.append(sources[index])
+ cp_args.append(tests_dir)
+ var copied = run_capture("cp", cp_args^, 20000, guard)
+ assert_equal(copied.exit_code, 0)
+
+
# ── Positive persistent measurement ─────────────────────────────────────────
@@ -1007,6 +1044,71 @@ def test_measurement_tooling_manifest_rejects_missing_inputs() raises:
guard.assert_clean()
+def test_measurement_digest_rejects_missing_one_input() raises:
+ # ADR-0021 MP01: the expressly required missing-one-input control. Seven of
+ # the eight declared tooling inputs are present valid regular files and one
+ # declared path is absent, so the checked pipeline must fail instead of
+ # returning a digest for the partial present set. The all-input-missing and
+ # failed-Git controls do not cover this case.
+ var guard = CleanupGuard()
+ with SafeTempDir() as root:
+ _copy_tooling_files(root, guard, 1)
+ var expected = tooling_manifest_sha256(".", guard)
+ assert_equal(expected.byte_length(), 64)
+ var message = ""
+ var reported = ""
+ try:
+ reported = tooling_manifest_sha256(root, guard)
+ except e:
+ message = String(e)
+ assert_true(message.find("sha256") >= 0)
+ assert_true(message.find("e3b0c442") < 0)
+ # No partial-set digest may be returned as a valid identity.
+ assert_true(reported != expected)
+ assert_equal(reported, "")
+ guard.assert_clean()
+
+
+def test_measurement_digest_rejects_total_hasher_failure() raises:
+ # ADR-0021 MP01: the expressly required failed-hasher-command control. All
+ # declared inputs are present valid regular files and both checked hasher
+ # stages fail, so the pipeline must reject. A different missing/unreadable
+ # input would not substitute for this control; the cause text must show the
+ # real nonzero hasher exit rather than only an unavailable command.
+ var guard = CleanupGuard()
+ with SafeTempDir() as root:
+ _copy_tooling_files(root, guard)
+ var failing = _failing_hasher(root, guard)
+ var message = ""
+ var reported = ""
+ try:
+ reported = tooling_manifest_sha256(root, guard, failing, failing)
+ except e:
+ message = String(e)
+ assert_true(message.find("sha256 unavailable") >= 0)
+ assert_true(message.find("exited=7") >= 0)
+ assert_true(message.find("e3b0c442") < 0)
+ assert_equal(reported, "")
+ guard.assert_clean()
+
+
+def test_measurement_digest_fallback_hasher_checked_correct() raises:
+ # ADR-0021 MP01: when the primary hasher stage fails but the supported
+ # fallback succeeds, the result must be the same checked digest as the
+ # default path — the fallback may never silently report a partial or empty
+ # identity.
+ var guard = CleanupGuard()
+ with SafeTempDir() as root:
+ _copy_tooling_files(root, guard)
+ var failing = _failing_hasher(root, guard)
+ var expected = tooling_manifest_sha256(root, guard)
+ assert_equal(expected.byte_length(), 64)
+ var fallback = tooling_manifest_sha256(root, guard, failing)
+ assert_equal(fallback.byte_length(), 64)
+ assert_true(fallback == expected)
+ guard.assert_clean()
+
+
def test_measurement_source_manifest_rejects_missing_repository() raises:
# ADR-0020 MC02: a failed git stage must be an error, not an empty digest.
var guard = CleanupGuard()