Skip to content

Commit 5da1afe

Browse files
author
Claude
committed
feat(a2ml): prove manifest round-trip invertibility (replace True placeholder)
Serializer.roundtripProperty was a vacuous 'roundtripProperty m = True'. A compile-time 'parse (lex (serialize m)) = Right m' theorem is not achievable in Idris2 (the lex stage rests on primitive String ops with no equational theory, and the production parser's if-on-comparison does not reduce at type-check time; it is also simply false for ill-formed manifests, e.g. a non-hex digest). Instead, prove the grammar is invertible with a clean positional token codec over the production types, in new module Ochrance.A2ML.Roundtrip: roundtripManifest : (m : Manifest) -> decodeManifest (encodeManifest m) = Just m by induction (refs list) + case analysis (optional sections), total, with no believe_me/assert_*/postulate. The codec matches token constructors only and delegates enum tags to parseHashAlgorithm-style helpers that do reduce; Nat is encoded in unary to avoid the primitive Integer cast. Also: a real runtime 'roundtripProperty' that exercises the *production* serialize+lex+parse pipeline (the String lexer being the one stage outside the proof's reach), and remove the placeholder from Serializer (it would need to import the parser, creating a cycle). Added Roundtrip to ochrance.ipkg. Verified: builds 17/17; roundtripManifest/refsRT/natRT total; property tests 47/47; the hardened ECHIDNA scan ignores the docstring's pattern mentions.
1 parent 2e36ad6 commit 5da1afe

3 files changed

Lines changed: 262 additions & 16 deletions

File tree

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
-- SPDX-License-Identifier: MPL-2.0
2+
-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
3+
4+
||| Ochrance.A2ML.Roundtrip — a machine-checked manifest round-trip theorem.
5+
|||
6+
||| The production pipeline `parse . lex . serialize` cannot carry a compile-time
7+
||| `= Right m` theorem in Idris2: the `lex` stage rests on primitive String
8+
||| operations (`pack`/`unpack`/`++`/`parseInteger`) that have no provable
9+
||| equational theory, and the production parser is built from `if`-on-comparison
10+
||| (`expectToken`, `unless`) which the type-checker does not reduce.
11+
|||
12+
||| This module instead proves the grammar itself is *invertible*, with a clean
13+
||| positional token codec over the production `Manifest`/`Ref`/... types:
14+
|||
15+
||| decodeManifest (encodeManifest m) = Just m -- for every m
16+
|||
17+
||| The codec pattern-matches on token *constructors* only (never on String
18+
||| literals or `if`), and delegates enum tags to `parseHashAlgorithm`-style
19+
||| helpers that do reduce — so the inductive proof goes through with no
20+
||| `believe_me`, `assert_*`, or `postulate`. The production lexer/parser are
21+
||| then exercised against this reference at runtime (see `roundtripProperty`).
22+
23+
module Ochrance.A2ML.Roundtrip
24+
25+
import Ochrance.A2ML.Types
26+
import Ochrance.A2ML.Lexer
27+
import Ochrance.A2ML.Parser
28+
import Ochrance.A2ML.Serializer
29+
import Data.List
30+
31+
%default total
32+
33+
--------------------------------------------------------------------------------
34+
-- Enum tag round-trips (each by case analysis; every case is Refl)
35+
--------------------------------------------------------------------------------
36+
37+
algoRT : (a : HashAlgorithm) -> parseHashAlgorithm (show a) = Just a
38+
algoRT SHA256 = Refl
39+
algoRT SHA3_256 = Refl
40+
algoRT BLAKE3 = Refl
41+
42+
showMode : VerificationMode -> String
43+
showMode Lax = "lax"
44+
showMode Checked = "checked"
45+
showMode Attested = "attested"
46+
47+
parseMode : String -> Maybe VerificationMode
48+
parseMode "lax" = Just Lax
49+
parseMode "checked" = Just Checked
50+
parseMode "attested" = Just Attested
51+
parseMode _ = Nothing
52+
53+
modeRT : (m : VerificationMode) -> parseMode (showMode m) = Just m
54+
modeRT Lax = Refl
55+
modeRT Checked = Refl
56+
modeRT Attested = Refl
57+
58+
--------------------------------------------------------------------------------
59+
-- Primitive field codecs (markers are nullary token constructors)
60+
--------------------------------------------------------------------------------
61+
62+
||| Maybe String: present = LBRACE then the string; absent = RBRACE.
63+
encMStr : Maybe String -> List Token
64+
encMStr Nothing = [RBRACE]
65+
encMStr (Just s) = [LBRACE, STRING s]
66+
67+
decMStr : List Token -> Maybe (Maybe String, List Token)
68+
decMStr (RBRACE :: rest) = Just (Nothing, rest)
69+
decMStr (LBRACE :: STRING s :: rest) = Just (Just s, rest)
70+
decMStr _ = Nothing
71+
72+
mstrRT : (x : Maybe String) -> (rest : List Token) -> decMStr (encMStr x ++ rest) = Just (x, rest)
73+
mstrRT Nothing rest = Refl
74+
mstrRT (Just s) rest = Refl
75+
76+
||| Bool: True = LBRACE, False = RBRACE.
77+
encBool : Bool -> List Token
78+
encBool True = [LBRACE]
79+
encBool False = [RBRACE]
80+
81+
decBool : List Token -> Maybe (Bool, List Token)
82+
decBool (LBRACE :: rest) = Just (True, rest)
83+
decBool (RBRACE :: rest) = Just (False, rest)
84+
decBool _ = Nothing
85+
86+
boolRT : (b : Bool) -> (rest : List Token) -> decBool (encBool b ++ rest) = Just (b, rest)
87+
boolRT True rest = Refl
88+
boolRT False rest = Refl
89+
90+
||| Nat in unary (COLON per successor, EQUALS terminator) — proof-friendly,
91+
||| avoiding the primitive Integer<->Nat cast.
92+
encNat : Nat -> List Token
93+
encNat Z = [EQUALS]
94+
encNat (S k) = COLON :: encNat k
95+
96+
decNat : List Token -> Maybe (Nat, List Token)
97+
decNat (EQUALS :: rest) = Just (Z, rest)
98+
decNat (COLON :: more) = case decNat more of
99+
Just (k, rest) => Just (S k, rest)
100+
Nothing => Nothing
101+
decNat _ = Nothing
102+
103+
natRT : (n : Nat) -> (rest : List Token) -> decNat (encNat n ++ rest) = Just (n, rest)
104+
natRT Z rest = Refl
105+
natRT (S k) rest = rewrite natRT k rest in Refl
106+
107+
||| Maybe Nat.
108+
encMNat : Maybe Nat -> List Token
109+
encMNat Nothing = [RBRACE]
110+
encMNat (Just n) = LBRACE :: encNat n
111+
112+
decMNat : List Token -> Maybe (Maybe Nat, List Token)
113+
decMNat (RBRACE :: rest) = Just (Nothing, rest)
114+
decMNat (LBRACE :: more) = case decNat more of
115+
Just (n, rest) => Just (Just n, rest)
116+
Nothing => Nothing
117+
decMNat _ = Nothing
118+
119+
mnatRT : (x : Maybe Nat) -> (rest : List Token) -> decMNat (encMNat x ++ rest) = Just (x, rest)
120+
mnatRT Nothing rest = Refl
121+
mnatRT (Just n) rest = rewrite natRT n rest in Refl
122+
123+
--------------------------------------------------------------------------------
124+
-- References (the recursive heart of the grammar)
125+
--------------------------------------------------------------------------------
126+
127+
encRef : Ref -> List Token
128+
encRef r = [IDENT r.name, COLON, HASH (show r.hash.algorithm) r.hash.value]
129+
130+
||| Reference list, terminated by RBRACE.
131+
encRefs : List Ref -> List Token
132+
encRefs [] = [RBRACE]
133+
encRefs (r :: rs) = encRef r ++ encRefs rs
134+
135+
decRefs : List Token -> Maybe (List Ref, List Token)
136+
decRefs (RBRACE :: rest) = Just ([], rest)
137+
decRefs (IDENT n :: COLON :: HASH a v :: more) =
138+
case parseHashAlgorithm a of
139+
Just algo => case decRefs more of
140+
Just (rs, rest) => Just (MkRef n (MkHash algo v) :: rs, rest)
141+
Nothing => Nothing
142+
Nothing => Nothing
143+
decRefs _ = Nothing
144+
145+
refsRT : (rs : List Ref) -> (rest : List Token) -> decRefs (encRefs rs ++ rest) = Just (rs, rest)
146+
refsRT [] rest = Refl
147+
refsRT (MkRef n (MkHash a v) :: rs) rest =
148+
rewrite algoRT a in
149+
rewrite refsRT rs rest in Refl
150+
151+
--------------------------------------------------------------------------------
152+
-- Attestation (optional)
153+
--------------------------------------------------------------------------------
154+
155+
encMAtt : Maybe Attestation -> List Token
156+
encMAtt Nothing = [RBRACE]
157+
encMAtt (Just a) = [LBRACE, STRING a.witness, STRING a.signature, STRING a.pubkey]
158+
159+
decMAtt : List Token -> Maybe (Maybe Attestation, List Token)
160+
decMAtt (RBRACE :: rest) = Just (Nothing, rest)
161+
decMAtt (LBRACE :: STRING w :: STRING s :: STRING p :: rest) =
162+
Just (Just (MkAttestation w s p), rest)
163+
decMAtt _ = Nothing
164+
165+
mattRT : (x : Maybe Attestation) -> (rest : List Token) -> decMAtt (encMAtt x ++ rest) = Just (x, rest)
166+
mattRT Nothing rest = Refl
167+
mattRT (Just (MkAttestation w s p)) rest = Refl
168+
169+
--------------------------------------------------------------------------------
170+
-- Policy (optional)
171+
--------------------------------------------------------------------------------
172+
173+
encMPol : Maybe Policy -> List Token
174+
encMPol Nothing = [RBRACE]
175+
encMPol (Just p) = LBRACE :: STRING (showMode p.mode) :: (encMNat p.maxAge ++ encBool p.requireSig)
176+
177+
decMPol : List Token -> Maybe (Maybe Policy, List Token)
178+
decMPol (RBRACE :: rest) = Just (Nothing, rest)
179+
decMPol (LBRACE :: STRING modeStr :: more) =
180+
case parseMode modeStr of
181+
Just mode => case decMNat more of
182+
Just (maxAge, more2) => case decBool more2 of
183+
Just (requireSig, rest) => Just (Just (MkPolicy mode maxAge requireSig), rest)
184+
Nothing => Nothing
185+
Nothing => Nothing
186+
Nothing => Nothing
187+
decMPol _ = Nothing
188+
189+
mpolRT : (x : Maybe Policy) -> (rest : List Token) -> decMPol (encMPol x ++ rest) = Just (x, rest)
190+
mpolRT Nothing rest = Refl
191+
mpolRT (Just (MkPolicy mode ma rs)) rest =
192+
rewrite modeRT mode in
193+
rewrite sym (appendAssociative (encMNat ma) (encBool rs) rest) in
194+
rewrite mnatRT ma (encBool rs ++ rest) in
195+
rewrite boolRT rs rest in Refl
196+
197+
--------------------------------------------------------------------------------
198+
-- Whole manifest
199+
--------------------------------------------------------------------------------
200+
201+
encodeManifest : Manifest -> List Token
202+
encodeManifest m =
203+
MANIFEST :: STRING m.manifestData.version :: STRING m.manifestData.subsystem
204+
:: (encMStr m.manifestData.timestamp
205+
++ REFS :: encRefs m.refs
206+
++ encMAtt m.attestation
207+
++ encMPol m.policy
208+
++ [EOF])
209+
210+
decodeManifest : List Token -> Maybe Manifest
211+
decodeManifest (MANIFEST :: STRING ver :: STRING sub :: r0) =
212+
case decMStr r0 of
213+
Just (ts, REFS :: r2) => case decRefs r2 of
214+
Just (rs, r3) => case decMAtt r3 of
215+
Just (att, r4) => case decMPol r4 of
216+
Just (pol, EOF :: []) =>
217+
Just (MkManifest (MkManifestData ver sub ts) rs att pol)
218+
_ => Nothing
219+
Nothing => Nothing
220+
Nothing => Nothing
221+
_ => Nothing
222+
decodeManifest _ = Nothing
223+
224+
||| THEOREM: the reference codec is invertible for every manifest.
225+
export
226+
roundtripManifest : (m : Manifest) -> decodeManifest (encodeManifest m) = Just m
227+
roundtripManifest (MkManifest (MkManifestData ver sub ts) rs att pol) =
228+
rewrite mstrRT ts (REFS :: (encRefs rs ++ encMAtt att ++ encMPol pol ++ [EOF])) in
229+
rewrite refsRT rs (encMAtt att ++ encMPol pol ++ [EOF]) in
230+
rewrite mattRT att (encMPol pol ++ [EOF]) in
231+
rewrite mpolRT pol [EOF] in
232+
Refl
233+
234+
--------------------------------------------------------------------------------
235+
-- Production-pipeline check (runtime)
236+
--------------------------------------------------------------------------------
237+
238+
||| Round-trip check against the *production* pipeline: serialize, then lex and
239+
||| parse, then confirm the result re-serializes identically.
240+
|||
241+
||| `roundtripManifest` above is the formal guarantee (over the reference token
242+
||| codec). This complements it by exercising the real lexer + parser, whose
243+
||| String stage lies outside the proof's reach (primitive `pack`/`unpack` etc.
244+
||| have no equational theory). It holds for well-formed manifests (hex digests,
245+
||| no in-string delimiters) and is exercised by the test suite.
246+
export
247+
roundtripProperty : Manifest -> Bool
248+
roundtripProperty m =
249+
case lex (serialize m) of
250+
Left _ => False
251+
Right toks => case parse toks of
252+
Left _ => False
253+
Right m2 => serialize m2 == serialize m

ochrance-core/Ochrance/A2ML/Serializer.idr

Lines changed: 8 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -56,19 +56,11 @@ serialize m =
5656
-- Roundtrip Verification
5757
--------------------------------------------------------------------------------
5858

59-
||| Test roundtrip property: serialize then parse should produce original manifest
60-
||| This is used in property-based tests to verify serialization correctness.
61-
|||
62-
||| Note: Roundtrip equivalence is semantic, not textual.
63-
||| Whitespace/formatting may differ, but structure must match.
64-
public export
65-
roundtripProperty : Manifest -> Bool
66-
roundtripProperty m =
67-
-- In practice, this would call the lexer and parser:
68-
-- case lex (serialize m) of
69-
-- Right tokens => case parse tokens of
70-
-- Right m' => m == m'
71-
-- Left _ => False
72-
-- Left _ => False
73-
-- For now, we rely on external test harness to verify this.
74-
True -- placeholder - actual verification done in test suite
59+
-- Round-trip correctness lives in `Ochrance.A2ML.Roundtrip`:
60+
-- * `roundtripManifest` — a machine-checked theorem that the grammar is
61+
-- invertible: `decodeManifest (encodeManifest m) = Just m` for every `m`.
62+
-- * `roundtripProperty` — a runtime check composing this serializer with the
63+
-- production lexer and parser.
64+
-- Neither can live here: both must reference the parser, and a serializer that
65+
-- imports the parser would create an import cycle. (The previous placeholder
66+
-- `roundtripProperty m = True` asserted nothing and has been removed.)

ochrance.ipkg

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ modules = Ochrance.A2ML.Types
1414
, Ochrance.A2ML.Parser
1515
, Ochrance.A2ML.Validator
1616
, Ochrance.A2ML.Serializer
17+
, Ochrance.A2ML.Roundtrip
1718
, Ochrance.Framework.Interface
1819
, Ochrance.Framework.Proof
1920
, Ochrance.Framework.Error

0 commit comments

Comments
 (0)