The previous commit's message says "Elixir green"; the run it cited had one failure, which the builder read only after committing (tablet 35's lesson, repeated). The failure: tablet38_l1_integrity_test's on_exit stopped a witness linked to the test process after it had already exited (seed 221974). The teardown now tolerates that. Full suite with that seed: 607 tests, 0 failures, 46 excluded Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
277 lines
9.8 KiB
Elixir
277 lines
9.8 KiB
Elixir
defmodule VavPipeline.Tablet38L1IntegrityTest do
|
|
@moduledoc """
|
|
Tablet 38 (2026-10-04) — the integrity check did not check integrity.
|
|
|
|
`L1Reader.verify_integrity/0` compared the hash recorded in
|
|
`integrity.sha256` with the hash compiled into the module. It never read
|
|
a verse. Every test of it passed, because every test asked it about an
|
|
untouched L1 — the one question a check that checks nothing answers
|
|
correctly.
|
|
|
|
These tests change the scroll and ask again.
|
|
"""
|
|
|
|
use ExUnit.Case, async: false
|
|
|
|
import Plug.Test
|
|
import Plug.Conn
|
|
|
|
alias VavPipeline.{HaltMonitor, L1Integrity, L1Reader, MannaLedger}
|
|
alias VavPipeline.BigdeiKodesh.Router
|
|
|
|
@opts Router.init([])
|
|
|
|
# ============================================================
|
|
# The hash itself
|
|
# ============================================================
|
|
describe "compute_tree_hash/1" do
|
|
test "of the real scroll is the pinned hash, and the hash build_l1.py recorded" do
|
|
assert {:ok, hash} = L1Reader.compute_tree_hash()
|
|
assert hash == L1Reader.expected_hash()
|
|
|
|
recorded = L1Reader.l1_root() |> Path.join("integrity.sha256") |> File.read!() |> String.trim()
|
|
assert hash == recorded
|
|
end
|
|
|
|
test "hashes path then bytes, directories then files, in plain string order" do
|
|
root = small_scroll()
|
|
|
|
# The order build_l1.py walks: the torah root's own files, then each
|
|
# directory by path; within a directory, names as strings — so
|
|
# 10.json comes before 2.json.
|
|
expected =
|
|
[
|
|
{"index.json", "INDEX"},
|
|
{"alef/1/1.json", "one"},
|
|
{"alef/1/10.json", "ten"},
|
|
{"alef/1/2.json", "two"},
|
|
{"alef/2/1.json", "second chapter"}
|
|
]
|
|
|> Enum.reduce(:crypto.hash_init(:sha256), fn {rel, bytes}, ctx ->
|
|
ctx |> :crypto.hash_update(rel) |> :crypto.hash_update(bytes)
|
|
end)
|
|
|> :crypto.hash_final()
|
|
|> Base.encode16(case: :lower)
|
|
|
|
assert {:ok, ^expected} = L1Reader.compute_tree_hash(root)
|
|
end
|
|
|
|
test "one changed byte, one added file, one renamed file — each changes the hash" do
|
|
root = small_scroll()
|
|
{:ok, original} = L1Reader.compute_tree_hash(root)
|
|
verse = Path.join([root, "torah", "alef", "1", "2.json"])
|
|
|
|
File.write!(verse, "twO")
|
|
assert {:ok, changed} = L1Reader.compute_tree_hash(root)
|
|
assert changed != original
|
|
|
|
File.write!(verse, "two")
|
|
assert {:ok, ^original} = L1Reader.compute_tree_hash(root)
|
|
|
|
File.write!(Path.join([root, "torah", "alef", "1", "3.json"]), "an added verse")
|
|
assert {:ok, added} = L1Reader.compute_tree_hash(root)
|
|
assert added != original
|
|
File.rm!(Path.join([root, "torah", "alef", "1", "3.json"]))
|
|
|
|
File.rename!(verse, Path.join([root, "torah", "alef", "1", "20.json"]))
|
|
assert {:ok, renamed} = L1Reader.compute_tree_hash(root)
|
|
assert renamed != original
|
|
end
|
|
|
|
test "a scroll that is not there is an error, not a hash" do
|
|
assert {:error, reason} = L1Reader.compute_tree_hash("/nonexistent/l1")
|
|
assert reason =~ "not a directory"
|
|
end
|
|
end
|
|
|
|
# ============================================================
|
|
# The witness
|
|
# ============================================================
|
|
describe "L1Integrity" do
|
|
test "reads the scroll when it starts, and again when asked" do
|
|
root = small_scroll()
|
|
{:ok, pinned} = L1Reader.compute_tree_hash(root)
|
|
|
|
{:ok, pid} =
|
|
L1Integrity.start_link(name: :tablet38_witness, l1_root: root, expected_hash: pinned, interval: :never)
|
|
|
|
on_exit(fn -> if Process.alive?(pid), do: GenServer.stop(pid) end)
|
|
|
|
assert :ok = L1Integrity.status(:tablet38_witness)
|
|
assert %{computed: ^pinned, expected: ^pinned} = L1Integrity.reading(:tablet38_witness)
|
|
|
|
# Someone alters a verse. The last reading still says tov — it is a
|
|
# reading, not a guard — until the scroll is read again.
|
|
File.write!(Path.join([root, "torah", "alef", "1", "1.json"]), "0ne")
|
|
assert {:error, reason} = L1Integrity.recheck(:tablet38_witness)
|
|
assert reason =~ "L1_INTEGRITY_FAILED"
|
|
assert reason =~ "expected #{pinned}"
|
|
assert {:error, _} = L1Integrity.status(:tablet38_witness)
|
|
|
|
# Restored, it is tov again.
|
|
File.write!(Path.join([root, "torah", "alef", "1", "1.json"]), "one")
|
|
assert :ok = L1Integrity.recheck(:tablet38_witness)
|
|
end
|
|
|
|
test "re-reads on its own at the interval" do
|
|
root = small_scroll()
|
|
{:ok, pinned} = L1Reader.compute_tree_hash(root)
|
|
|
|
{:ok, pid} =
|
|
L1Integrity.start_link(name: :tablet38_periodic, l1_root: root, expected_hash: pinned, interval: 50)
|
|
|
|
on_exit(fn -> if Process.alive?(pid), do: GenServer.stop(pid) end)
|
|
|
|
assert :ok = L1Integrity.status(:tablet38_periodic)
|
|
File.write!(Path.join([root, "torah", "index.json"]), "INDEX, altered")
|
|
|
|
assert eventually(fn -> match?({:error, _}, L1Integrity.status(:tablet38_periodic)) end)
|
|
|
|
# And it keeps re-reading: restored, it is tov again without being asked.
|
|
File.write!(Path.join([root, "torah", "index.json"]), "INDEX")
|
|
assert eventually(fn -> L1Integrity.status(:tablet38_periodic) == :ok end)
|
|
end
|
|
|
|
test "answers from the last reading while a periodic re-reading is under way" do
|
|
root = small_scroll()
|
|
{:ok, pinned} = L1Reader.compute_tree_hash(root)
|
|
|
|
{:ok, pid} =
|
|
L1Integrity.start_link(name: :tablet38_busy, l1_root: root, expected_hash: pinned, interval: :never)
|
|
|
|
on_exit(fn -> if Process.alive?(pid), do: GenServer.stop(pid) end)
|
|
|
|
# Trigger a periodic reading by hand; the process must not be busy
|
|
# with it — a status call is answered at once, from the last reading.
|
|
send(pid, :read)
|
|
{micros, status} = :timer.tc(fn -> L1Integrity.status(:tablet38_busy) end)
|
|
assert status == :ok
|
|
assert micros < 50_000
|
|
end
|
|
end
|
|
|
|
# ============================================================
|
|
# The whole path: an altered scroll stops the vessel
|
|
# ============================================================
|
|
describe "with a scroll that does not match its pin" do
|
|
setup do
|
|
if HaltMonitor.halted?(), do: HaltMonitor.resolve("tablet38 test setup")
|
|
MannaLedger.reset()
|
|
write_provision(20)
|
|
|
|
# Sanity: with the real scroll, all is well.
|
|
assert :ok = L1Reader.verify_integrity()
|
|
|
|
# Replace the supervised witness with one that reads an altered
|
|
# copy — the recorded hash file is untouched and still "matches",
|
|
# which is exactly the case the old check could not see.
|
|
root = small_scroll()
|
|
:ok = Supervisor.terminate_child(VavPipeline.Supervisor, L1Integrity)
|
|
|
|
{:ok, pid} =
|
|
L1Integrity.start_link(l1_root: root, expected_hash: L1Reader.expected_hash(), interval: :never)
|
|
|
|
on_exit(fn ->
|
|
# The witness is linked to the test process and may already be
|
|
# gone when on_exit runs (seed 221974, 2026-10-15): stopping a
|
|
# process that has exited is not a failure of the scroll.
|
|
try do
|
|
if Process.alive?(pid), do: GenServer.stop(pid)
|
|
catch
|
|
:exit, _ -> :ok
|
|
end
|
|
|
|
{:ok, _} = Supervisor.restart_child(VavPipeline.Supervisor, L1Integrity)
|
|
MannaLedger.reset()
|
|
File.rm(VavPipeline.Manna.provision_file())
|
|
end)
|
|
|
|
:ok
|
|
end
|
|
|
|
test "verify_integrity fails, though integrity.sha256 still holds the pinned hash" do
|
|
recorded = L1Reader.l1_root() |> Path.join("integrity.sha256") |> File.read!() |> String.trim()
|
|
assert recorded == L1Reader.expected_hash()
|
|
|
|
assert {:error, reason} = L1Reader.verify_integrity()
|
|
assert reason =~ "L1_INTEGRITY_FAILED"
|
|
assert reason =~ "the scroll on disk hashes to"
|
|
end
|
|
|
|
test "health says l1_hash: fail and the vessel is not ready" do
|
|
conn = conn(:get, "/kodesh/health") |> Router.call(@opts)
|
|
body = Jason.decode!(conn.resp_body)
|
|
|
|
assert conn.status == 503
|
|
assert body["checks"]["l1_hash"] == "fail"
|
|
end
|
|
|
|
test "no cycle runs on it — /peah/ask does not answer from an altered scroll" do
|
|
conn =
|
|
conn(:post, "/peah/ask", Jason.encode!(%{question: "בראשית ברא אלהים"}))
|
|
|> put_req_header("content-type", "application/json")
|
|
|> Router.call(@opts)
|
|
|
|
refute conn.status == 200
|
|
refute Map.has_key?(Jason.decode!(conn.resp_body), "answer")
|
|
end
|
|
end
|
|
|
|
# ------------------------------------------------------------
|
|
# Helpers
|
|
# ------------------------------------------------------------
|
|
|
|
defp small_scroll do
|
|
root = Path.join(System.tmp_dir!(), "tablet38_#{System.unique_integer([:positive])}")
|
|
on_exit(fn -> File.rm_rf!(root) end)
|
|
|
|
files = %{
|
|
"index.json" => "INDEX",
|
|
"alef/1/1.json" => "one",
|
|
"alef/1/2.json" => "two",
|
|
"alef/1/10.json" => "ten",
|
|
"alef/2/1.json" => "second chapter"
|
|
}
|
|
|
|
for {rel, bytes} <- files do
|
|
path = Path.join([root, "torah", rel])
|
|
File.mkdir_p!(Path.dirname(path))
|
|
File.write!(path, bytes)
|
|
end
|
|
|
|
root
|
|
end
|
|
|
|
defp eventually(fun, tries \\ 100) do
|
|
cond do
|
|
fun.() -> true
|
|
tries == 0 -> false
|
|
true -> Process.sleep(20) && eventually(fun, tries - 1)
|
|
end
|
|
end
|
|
|
|
defp write_provision(compute_units) do
|
|
today = Date.utc_today() |> Date.to_iso8601()
|
|
expires = DateTime.utc_now() |> DateTime.add(6 * 3600) |> DateTime.to_iso8601()
|
|
|
|
provision = %{
|
|
"date" => today,
|
|
"provision" => %{
|
|
"compute_units" => compute_units,
|
|
"memory_mb" => 512,
|
|
"api_calls_external" => 5,
|
|
"storage_delta_mb" => 2,
|
|
"sanhedrin_panels_max" => 2
|
|
},
|
|
"type" => "regular",
|
|
"expires_at" => expires,
|
|
"written_by" => "tablet38_test",
|
|
"written_at" => DateTime.utc_now() |> DateTime.to_iso8601()
|
|
}
|
|
|
|
path = VavPipeline.Manna.provision_file()
|
|
File.mkdir_p!(Path.dirname(path))
|
|
File.write!(path, Jason.encode!(provision))
|
|
end
|
|
end
|