From c431ac28ef6732ae7ba2aec452a0c911e4ff7c6d Mon Sep 17 00:00:00 2001 From: shah Date: Mon, 26 Oct 2026 12:13:22 -0500 Subject: [PATCH 01/14] Add PNG chunk parser and printer, with proved round trip --- Main.lean | 34 +++++- Png.lean | 1 + Png/Header.lean | 141 +++++++++++++++++++++++ Proofs.lean | 1 + Proofs/Header.lean | 139 ++++++++++++++++++++++ Proofs/Verify.lean | 4 + README.md | 14 ++- SPEC.md | 49 +++++++- tests/TEST-RESULTS.md | 18 ++- tests/run_tests.py | 262 +++++++++++++++++++++++++++++++++++++++++- 10 files changed, 637 insertions(+), 26 deletions(-) create mode 100644 Png/Header.lean create mode 100644 Proofs/Header.lean diff --git a/Main.lean b/Main.lean index 188deba..e0c810b 100644 --- a/Main.lean +++ b/Main.lean @@ -7,10 +7,18 @@ parse 137,80,78,... prints ok:137,80,78,... or none - The one operation is `parse`. Its input is the byte codes of a - PNG file. Its answer is `ok:` and the byte codes of the - canonical reprint of the parsed chunk list, or `none` when the - file is rejected. + The operations are `parse` and `header`. The input of each is + the byte codes of a PNG file. `parse` answers `ok:` and the + byte codes of the canonical reprint of the parsed chunk list, + or `none` when the file is rejected. `header` reads the file + at both layers, the chunk list and the IHDR header decoded + from the first chunk's data (slice 2, `Png/Header.lean`), and + answers `ok:` and the codes of a text line naming the header + fields: the width, an `x`, the height, then ` d` and the bit + depth, ` t` and the colour type, ` i` and the interlace + method, as in `1920x1080 d8 t6 i0`. It answers `none` when + the file is rejected at either layer. The codes are the + character codes of that text. A request is an operation, then optionally one space and its numbers; with no numbers after the space, the input is empty. A @@ -48,6 +56,17 @@ def toBytes (ns : List Nat) : Option (List UInt8) := def showCodes (bs : List UInt8) : String := ",".intercalate (bs.map fun b => toString b.toNat) +/-- The byte codes of a text: the code of each character. The + texts built here hold digits, letters, and spaces only, so + every code is a byte. -/ +def textCodes (s : String) : List UInt8 := + s.toList.map fun c => c.toNat.toUInt8 + +/-- The header line: the fields of a decoded IHDR header as one + text, in the shape `1920x1080 d8 t6 i0`. -/ +def headerText (h : Ihdr) : String := + s!"{h.width}x{h.height} d{h.bitDepth.toNat} t{h.colorType.toNat} i{h.interlace.toNat}" + /-- The answer to one request, or `none` when the request is malformed. -/ def answer (op : String) (ns : List Nat) : Option String := @@ -59,6 +78,13 @@ def answer (op : String) (ns : List Nat) : Option String := match parse bs with | some v => some ("ok:" ++ showCodes (print v)) | none => some "none" + | "header" => + match toBytes ns with + | none => none + | some bs => + match parseFile bs with + | some (h, _) => some ("ok:" ++ showCodes (textCodes (headerText h))) + | none => some "none" | _ => none /-- The answer to one line: an operation, then optionally one space diff --git a/Png.lean b/Png.lean index e4d85f9..8da7389 100644 --- a/Png.lean +++ b/Png.lean @@ -1,3 +1,4 @@ import Png.Crc32 import Png.Value import Png.Parse +import Png.Header diff --git a/Png/Header.lean b/Png/Header.lean new file mode 100644 index 0000000..4178d74 --- /dev/null +++ b/Png/Header.lean @@ -0,0 +1,141 @@ +/- + Slice 2: the IHDR header, decoded. Slice 1 reads the chunk + layer and checks that the first chunk has type IHDR and 13 + data bytes; it does not look inside those bytes. This file + reads them. + + The 13 bytes hold: the width and the height, four bytes each, + big endian; then the bit depth, the colour type, the + compression method, the filter method, and the interlace + method, one byte each. + + The compression and filter bytes carry no choice in PNG: each + must be zero. So the header value does not hold them. They are + checked on reading and written as zero on printing. The + interlace byte is 0 (no interlace) or 1 (Adam7), and is held. + + A header is accepted only when the width and the height are + both above zero, the compression and filter bytes are zero, + the interlace byte is 0 or 1, and the bit depth is one the + colour type allows. The allowed pairs are the ones the PNG + specification names: + + colour type 0 (greyscale): depths 1, 2, 4, 8, 16 + colour type 2 (truecolour): depths 8, 16 + colour type 3 (indexed colour): depths 1, 2, 4, 8 + colour type 4 (greyscale, alpha): depths 8, 16 + colour type 6 (truecolour, alpha): depths 8, 16 + + Colour types other than 0, 2, 3, 4, 6 are not PNG colour + types, and allow no depth. +-/ + +import Png.Parse + +namespace Png + +/-- The decoded IHDR header: width and height as numbers, the + bit depth, the colour type, and the interlace method as + bytes. The compression and filter bytes are not held: each + is zero in every accepted file, and printing writes zero. -/ +structure Ihdr where + width : Nat + height : Nat + bitDepth : UInt8 + colorType : UInt8 + interlace : UInt8 + deriving DecidableEq, Repr + +/-- The bit depths a colour type allows, by the colour type's + number. A number that is not a PNG colour type allows none. -/ +def allowedDepths (ct : Nat) : List Nat := + if ct = 0 then [1, 2, 4, 8, 16] + else if ct = 2 then [8, 16] + else if ct = 3 then [1, 2, 4, 8] + else if ct = 4 then [8, 16] + else if ct = 6 then [8, 16] + else [] + +/-- Does this depth go with this colour type? The two numbers + are the byte values. -/ +def pairAllowed (ct bd : Nat) : Bool := + decide (bd ∈ allowedDepths ct) + +/-- The allowed (colour type, bit depth) pairs, written out in + full. The checker above is built from the per-type table; + the proof in `Proofs/Header.lean` shows the two agree on + every colour type byte and every bit depth byte. -/ +def allowedPairs : List (Nat × Nat) := + [(0, 1), (0, 2), (0, 4), (0, 8), (0, 16), + (2, 8), (2, 16), + (3, 1), (3, 2), (3, 4), (3, 8), + (4, 8), (4, 16), + (6, 8), (6, 16)] + +/-- Does this bit depth go with this colour type? The two + arguments are the file's bytes. -/ +def checkPair (ct bd : UInt8) : Bool := pairAllowed ct.toNat bd.toNat + +/-- A header is well formed when the width and the height are + above zero and below 2^32, the interlace byte is 0 or 1, and + the bit depth is one the colour type allows. -/ +def WFHeader (h : Ihdr) : Prop := + 0 < h.width ∧ h.width < 4294967296 ∧ + 0 < h.height ∧ h.height < 4294967296 ∧ + (h.interlace = 0 ∨ h.interlace = 1) ∧ + checkPair h.colorType h.bitDepth = true + +/-- The 13 IHDR data bytes of a header: width and height big + endian, then bit depth, colour type, compression zero, + filter zero, interlace. -/ +def printIhdr (h : Ihdr) : List UInt8 := + be32 h.width ++ be32 h.height ++ [h.bitDepth, h.colorType, 0, 0, h.interlace] + +/-- Read 13 bytes as a header. Anything but exactly 13 bytes is + rejected, and so is a header that breaks any of the rules at + the top of this file: a width or height of zero, a nonzero + compression or filter byte, an interlace byte above 1, or a + bit depth the colour type does not allow. -/ +def parseIhdr (bs : List UInt8) : Option Ihdr := + match bs with + | w1 :: w2 :: w3 :: w4 :: h1 :: h2 :: h3 :: h4 :: bd :: ct :: comp :: filt :: int :: rest => + if rest ≠ [] then none + else if u32 w1 w2 w3 w4 = 0 ∨ u32 h1 h2 h3 h4 = 0 then none + else if comp ≠ 0 ∨ filt ≠ 0 then none + else if int > 1 then none + else if checkPair ct bd then + some ⟨u32 w1 w2 w3 w4, u32 h1 h2 h3 h4, bd, ct, int⟩ + else none + | _ => none + +/-- The decoded header of a chunk list: the first chunk's data + read as a header, or `none` when there is no first chunk or + its data does not read as one. -/ +def headerOf (doc : Doc) : Option Ihdr := + match doc with + | c :: _ => parseIhdr c.data + | [] => none + +/-- Read a whole file at both layers: the chunk list of slice 1, + and the header decoded from the first chunk's data. A file + whose first chunk is not a valid IHDR, in the slice 2 sense, + is rejected here even when slice 1 accepts it. -/ +def parseFile (bs : List UInt8) : Option (Ihdr × Doc) := + match parse bs with + | some doc => (headerOf doc).map fun h => (h, doc) + | none => none + +/-- A document is fully well formed when it is well formed at + the chunk layer (the `WF` of slice 1), its first chunk's + data is the printed form of some header, and that header is + well formed. Stated with the witnesses out, in the shape of + `WF`. -/ +def WFFull (doc : Doc) : Prop := + ∃ (hd : Ihdr) (head : Chunk) (mid : List Chunk), + doc = head :: mid ++ [iendChunk] ∧ + head.typeBytes = ihdrType ∧ head.data = printIhdr hd ∧ + WFHeader hd ∧ + (∀ c ∈ doc, WFChunk c) ∧ + (∀ c ∈ head :: mid, isIEND c = false) + +end Png diff --git a/Proofs.lean b/Proofs.lean index b877d25..e63d117 100644 --- a/Proofs.lean +++ b/Proofs.lean @@ -1,2 +1,3 @@ import Proofs.RoundTrip +import Proofs.Header import Proofs.Verify diff --git a/Proofs/Header.lean b/Proofs/Header.lean new file mode 100644 index 0000000..f7d442d --- /dev/null +++ b/Proofs/Header.lean @@ -0,0 +1,139 @@ +/- + The slice 2 proofs: the IHDR header round trip, the exactness + of the (colour type, bit depth) table, and the file level + round trip that joins the header to the chunk layer of + slice 1. + + The table claim is `pairAllowed_iff`, with its byte form + `checkPair_iff`: the checker's answer agrees with the written + out list of allowed pairs for every colour type number and + every bit depth number. Colour types are bytes and bit depths + are bytes, so the numbers 0 to 255 are the whole of both, and + the proof settles the question for all of them: it splits on + the five colour types the PNG specification names, and in + each case the two sides come down to the same list of depths; + for every other colour type number both sides are empty. No + case is left to computation over the whole grid. + + The header round trip is `parseIhdr_print`. The file level + claim is `parseFile_print`: a fully well formed document + (chunk layer well formed, first chunk the printed form of a + well formed header) prints and reads back whole, header + included. +-/ + +import Png +import Proofs.RoundTrip + +namespace Png.Proofs + +open Png + +/-- One step of the header reader, stated for rewriting: 13 + bytes at the front of the input, with whatever follows. -/ +theorem parseIhdr_step (w1 w2 w3 w4 h1 h2 h3 h4 bd ct comp filt int : UInt8) + (rest : List UInt8) : + parseIhdr (w1 :: w2 :: w3 :: w4 :: h1 :: h2 :: h3 :: h4 + :: bd :: ct :: comp :: filt :: int :: rest) = + if rest ≠ [] then none + else if u32 w1 w2 w3 w4 = 0 ∨ u32 h1 h2 h3 h4 = 0 then none + else if comp ≠ 0 ∨ filt ≠ 0 then none + else if int > 1 then none + else if checkPair ct bd then + some ⟨u32 w1 w2 w3 w4, u32 h1 h2 h3 h4, bd, ct, int⟩ + else none := rfl + +/-- The printed form of a header is 13 bytes. -/ +theorem printIhdr_length (h : Ihdr) : (printIhdr h).length = 13 := by + simp [printIhdr, be32] + +/-- The header round trip: reading the printed bytes of a well + formed header gives the header back. -/ +theorem parseIhdr_print (h : Ihdr) (hwf : WFHeader h) : + parseIhdr (printIhdr h) = some h := by + obtain ⟨w, hh, bd, ct, il⟩ := h + obtain ⟨hw1, hw2, hh1, hh2, hil, hpair⟩ := hwf + have hw1' : 0 < w := hw1 + have hh1' : 0 < hh := hh1 + have hnl : ¬ (il > 1) := by + rcases hil with rfl | rfl <;> decide + have e : printIhdr ⟨w, hh, bd, ct, il⟩ = + (w / 16777216).toUInt8 :: (w / 65536).toUInt8 + :: (w / 256).toUInt8 :: w.toUInt8 + :: (hh / 16777216).toUInt8 :: (hh / 65536).toUInt8 + :: (hh / 256).toUInt8 :: hh.toUInt8 + :: bd :: ct :: 0 :: 0 :: il :: [] := rfl + rw [e, parseIhdr_step, u32_be32 w hw2, u32_be32 hh hh2] + rw [if_neg (by simp : ¬ (([] : List UInt8) ≠ []))] + rw [if_neg (by omega : ¬ (w = 0 ∨ hh = 0))] + rw [if_neg (by decide : ¬ ((0 : UInt8) ≠ 0 ∨ (0 : UInt8) ≠ 0))] + rw [if_neg hnl, if_pos hpair] + +/-- The table, exactly: a depth goes with a colour type under + the checker if and only if the pair is in the written out + list of allowed pairs. Stated for all numbers, so in + particular for every colour type byte and every bit depth + byte. -/ +theorem pairAllowed_iff : ∀ n m : Nat, + pairAllowed n m = true ↔ (n, m) ∈ allowedPairs := by + intro n m + by_cases h0 : n = 0 + · subst h0 + simp only [pairAllowed, decide_eq_true_eq] + show m ∈ [1, 2, 4, 8, 16] ↔ (0, m) ∈ allowedPairs + simp [allowedPairs] + by_cases h2 : n = 2 + · subst h2 + simp only [pairAllowed, decide_eq_true_eq] + show m ∈ [8, 16] ↔ (2, m) ∈ allowedPairs + simp [allowedPairs] + by_cases h3 : n = 3 + · subst h3 + simp only [pairAllowed, decide_eq_true_eq] + show m ∈ [1, 2, 4, 8] ↔ (3, m) ∈ allowedPairs + simp [allowedPairs] + by_cases h4 : n = 4 + · subst h4 + simp only [pairAllowed, decide_eq_true_eq] + show m ∈ [8, 16] ↔ (4, m) ∈ allowedPairs + simp [allowedPairs] + by_cases h6 : n = 6 + · subst h6 + simp only [pairAllowed, decide_eq_true_eq] + show m ∈ [8, 16] ↔ (6, m) ∈ allowedPairs + simp [allowedPairs] + · have hdep : allowedDepths n = [] := by + unfold allowedDepths + rw [if_neg h0, if_neg h2, if_neg h3, if_neg h4, if_neg h6] + simp only [pairAllowed, decide_eq_true_eq, hdep] + simp [allowedPairs, h0, h2, h3, h4, h6] + +/-- The table, in the byte form the parser uses: the check on + two bytes holds if and only if the pair of their values is + in the written out list. -/ +theorem checkPair_iff (ct bd : UInt8) : + checkPair ct bd = true ↔ (ct.toNat, bd.toNat) ∈ allowedPairs := + pairAllowed_iff ct.toNat bd.toNat + +/-- A fully well formed document is well formed at the chunk + layer. -/ +theorem wfFull_wf {doc : Doc} (h : WFFull doc) : WF doc := by + obtain ⟨hd, head, mid, hdoc, htype, hdata, -, hall, hnot⟩ := h + exact ⟨head, mid, hdoc, htype, + by rw [hdata]; exact printIhdr_length hd, hall, hnot⟩ + +/-- The file level round trip: printing a fully well formed + document and reading the file back at both layers gives the + document back, with its header. -/ +theorem parseFile_print {doc : Doc} (h : WFFull doc) : + ∃ hd : Ihdr, parseFile (print doc) = some (hd, doc) := by + have hwf : WF doc := wfFull_wf h + obtain ⟨hd, head, mid, hdoc, htype, hdata, hwfhd, hall, hnot⟩ := h + refine ⟨hd, ?_⟩ + unfold parseFile + rw [parse_print hwf] + show (headerOf doc).map (fun x => (x, doc)) = some (hd, doc) + rw [hdoc] + simp [headerOf, hdata, parseIhdr_print hd hwfhd] + +end Png.Proofs diff --git a/Proofs/Verify.lean b/Proofs/Verify.lean index fe6388e..975c7df 100644 --- a/Proofs/Verify.lean +++ b/Proofs/Verify.lean @@ -7,5 +7,9 @@ -/ import Proofs.RoundTrip +import Proofs.Header #print axioms Png.Proofs.parse_print +#print axioms Png.Proofs.parseIhdr_print +#print axioms Png.Proofs.checkPair_iff +#print axioms Png.Proofs.parseFile_print diff --git a/README.md b/README.md index 25a3027..ba3fc10 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,23 @@ # png: a PNG chunk parser and printer, with a proved round trip -A PNG chunk parser and printer in Lean. It reads the chunk layer of a PNG file: the signature, then each chunk's length, type, data, and CRC-32 check. It does not decode pixels, and it does not inflate IDAT data. The main claim is proved: parsing what the printer wrote returns the same chunk list. The tests run the parser against real PNG files built with Python's zlib and against a strict oracle written from the rules; see `tests/TEST-RESULTS.md` for the counts. +A PNG chunk parser and printer in Lean. It reads the chunk layer of a PNG file: the signature, then each chunk's length, type, data, and CRC-32 check. Slice 2 adds a decoder for the IHDR header: the width, height, bit depth, colour type, and interlace method held in the first chunk's 13 data bytes, checked against the validity rules of the PNG specification. It does not decode pixels, and it does not inflate IDAT data. The main claims are proved: parsing what the printer wrote returns the same chunk list, reading the printed bytes of a well formed header returns the header, and the bit depth table the checker uses is exactly the table the specification names. The tests run the parser against real PNG files built with Python's zlib and against a strict oracle written from the rules; see `tests/TEST-RESULTS.md` for the counts. Status: in progress. The specification has not yet been approved by a reviewer apart from its author, and no statement file (`Spec.lean`) has been added by a reviewer, so this is not verified. ## What is proved -The definitions are in `Png/Value.lean` (the chunk type, the printer, and the well-formedness rules), `Png/Parse.lean` (the parser), and `Png/Crc32.lean` (the CRC-32). The proofs are in `Proofs/RoundTrip.lean`. +The definitions are in `Png/Value.lean` (the chunk type, the printer, and the well-formedness rules), `Png/Parse.lean` (the parser), `Png/Crc32.lean` (the CRC-32), and `Png/Header.lean` (the IHDR header value, its reader and printer, and the file level read). The proofs are in `Proofs/RoundTrip.lean` and `Proofs/Header.lean`. | Claim | Theorem | |-------|---------| | Parsing the printed form of a well formed chunk list gives the list back. | `Png.Proofs.parse_print` | | Reading back one printed chunk, with any bytes after it, gives the chunk and those bytes untouched. | `Png.Proofs.parseChunk_printChunk` | | Four bytes printed from a value below 2^32 read back as the value. | `Png.Proofs.u32_be32` | +| Reading the printed bytes of a well formed IHDR header gives the header back. | `Png.Proofs.parseIhdr_print` | +| The depth checker agrees with the written out table of allowed (colour type, bit depth) pairs on every byte pair. | `Png.Proofs.checkPair_iff` | +| Printing a fully well formed document and reading it back at both layers gives the document back, header included. | `Png.Proofs.parseFile_print` | -A chunk list is well formed when it is a first chunk, then any middle chunks, then the IEND chunk; the first chunk has type IHDR and 13 data bytes; no chunk before the last is the IEND chunk; every chunk type is four ASCII letters; and every data length is below 2^31. `parse_print` checks against two of the standard axioms (`propext`, `Quot.sound`), which a reader can confirm with `#print axioms Png.Proofs.parse_print`. +A chunk list is well formed when it is a first chunk, then any middle chunks, then the IEND chunk; the first chunk has type IHDR and 13 data bytes; no chunk before the last is the IEND chunk; every chunk type is four ASCII letters; and every data length is below 2^31. A header is well formed when its width and height are above zero and below 2^32, its interlace byte is 0 or 1, and its bit depth is one its colour type allows. Each of these theorems checks against two of the standard axioms (`propext`, `Quot.sound`), which a reader can confirm with `#print axioms` on the theorem's name. The command-line tool in `Main.lean` calls these functions directly. @@ -22,7 +25,8 @@ The command-line tool in `Main.lean` calls these functions directly. - That the parser accepts every file the rules in `SPEC.md` allow, and rejects every file they forbid. The round trip does not need either, so neither is proved. The test suite checks that behaviour instead, and the results file records the outcome, including any failure. - That the CRC-32 here is the standard one. The round trip needs no such fact, since the parser and the printer compute the same function. The differential tests against Python zlib's `crc32` are what tie this function to the standard. -- Anything inside a chunk's data. The IHDR fields (width, height, bit depth, colour type) are not read, past the check that IHDR holds 13 bytes. IDAT data is not inflated. No pixel is ever decoded. +- That the header reader accepts every header the rules in `SPEC.md` allow, and rejects every header they forbid, beyond what the round trip and the table theorem cover. The tests check that behaviour instead: hand built headers for every colour type and allowed depth, the full colour type and depth enumeration against the table, and the rejection cases. +- Anything inside a chunk's data past the IHDR header. The data of every other chunk type is opaque here. IDAT data is not inflated. No pixel is ever decoded. - Chunk order past the first and last. Which types may appear, how many times, and in what order between IHDR and IEND is not checked. A chunk of type IEND that carries data is read as an ordinary chunk. ## What you must trust @@ -44,7 +48,7 @@ lake build echo "parse 137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,0,73,69,78,68,174,66,96,130" | .lake/build/bin/png ``` -That request is a whole minimal PNG file as byte codes: the signature, an IHDR chunk for a 1 by 1 image, and IEND. The answer is `ok:` followed by the same codes, since the file is valid and reprints byte for byte. A file the parser rejects, such as the single code `120`, answers `none`. A malformed request answers `error`. See `SPEC.md` for the full protocol. +That request is a whole minimal PNG file as byte codes: the signature, an IHDR chunk for a 1 by 1 image, and IEND. The answer is `ok:` followed by the same codes, since the file is valid and reprints byte for byte. A file the parser rejects, such as the single code `120`, answers `none`. A malformed request answers `error`. The same file asked through `header`, with the word `header` in place of `parse`, answers `ok:49,120,49,32,100,56,32,116,50,32,105,48`: the codes of the text `1x1 d8 t2 i0`, the decoded header fields. See `SPEC.md` for the full protocol. ## Re-check it diff --git a/SPEC.md b/SPEC.md index ab1d12b..4c33fdd 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1,6 +1,6 @@ # Specification: png -A PNG chunk parser and printer in Lean. This slice reads the chunk layer only: it does not decode pixels, and it does not inflate IDAT data. A reviewer who did not write this specification approves it, together with any statement file a reviewer adds later. No such file exists yet, so nothing here counts toward the verified bar yet. +A PNG chunk parser and printer in Lean, with (slice 2) a decoder for the IHDR header. It does not decode pixels, and it does not inflate IDAT data. A reviewer who did not write this specification approves it, together with any statement file a reviewer adds later. No such file exists yet, so nothing here counts toward the verified bar yet. Bytes are `List UInt8` throughout. @@ -26,7 +26,42 @@ The CRC-32 is the one PNG and ZIP share: the ISO 3309 polynomial, reflected, the 3. Reading stops at a chunk whose type is IEND and whose data is empty. That chunk must be the end of the input: any byte after it rejects the file, and running out of input before such a chunk rejects the file. 4. The first chunk read has type IHDR and a data length of 13. -A chunk of type IEND that carries data is an ordinary chunk at this layer: reading does not stop at it. No other rule about chunk order is checked: which types may appear, how many times, or in what order past the first and last, is outside this slice. The contents of the IHDR data, past its length, are not checked: width, height, bit depth, and colour type are read as data like any other. +A chunk of type IEND that carries data is an ordinary chunk at this layer: reading does not stop at it. No other rule about chunk order is checked: which types may appear, how many times, or in what order past the first and last, is outside this slice. At this layer the contents of the IHDR data, past its length, are not checked: width, height, bit depth, and colour type are read as data like any other. The header read of slice 2, below, is a second read on top of this one and does check them. + +## The IHDR header (slice 2) + +The first chunk's 13 data bytes hold, in order: + +- the width: four bytes, big endian. +- the height: four bytes, big endian. +- the bit depth: one byte. +- the colour type: one byte. +- the compression method: one byte. +- the filter method: one byte. +- the interlace method: one byte. + +The decoded header value holds the width and the height as numbers, and the bit depth, the colour type, and the interlace method as bytes. The compression and filter bytes are not held: the PNG specification gives them no choice (the only compression method is 0, the only filter method is 0), so reading checks that each is zero and printing writes zero. + +A header reads only when all of these hold: + +- the width is above zero, and the height is above zero. +- the compression byte is zero, and the filter byte is zero. +- the interlace byte is 0 or 1. +- the bit depth is one the colour type allows, by this table from the PNG specification: + +| Colour type | Meaning | Allowed bit depths | +|---|---|---| +| 0 | greyscale | 1, 2, 4, 8, 16 | +| 2 | truecolour | 8, 16 | +| 3 | indexed colour | 1, 2, 4, 8 | +| 4 | greyscale with alpha | 8, 16 | +| 6 | truecolour with alpha | 8, 16 | + +A colour type other than 0, 2, 3, 4, or 6 allows no bit depth, and reading anything but exactly 13 bytes is a rejection. + +Printing a header writes the 13 bytes back: width and height big endian, then bit depth, colour type, compression zero, filter zero, interlace. + +The file level read (`parseFile`) runs the chunk parse of slice 1 and then reads the first chunk's data as a header. A file whose first chunk is not a valid IHDR in this sense is rejected by that read even when the chunk layer accepts it: a file with a width of zero is the plainest case. ## Printing @@ -34,7 +69,13 @@ The printer writes the signature, then each chunk as its length, its type bytes, ## The claim -The proved claim is a round trip: for every well formed chunk list, parsing what the printer wrote returns the same list. Well formed means the list is a first chunk, then any middle chunks, then the IEND chunk; the first chunk has type IHDR and 13 data bytes; no chunk before the last is the IEND chunk (type IEND with no data); every chunk type is four ASCII letters; and every data length is below 2^31. +The proved claim of slice 1 is a round trip: for every well formed chunk list, parsing what the printer wrote returns the same list. Well formed means the list is a first chunk, then any middle chunks, then the IEND chunk; the first chunk has type IHDR and 13 data bytes; no chunk before the last is the IEND chunk (type IEND with no data); every chunk type is four ASCII letters; and every data length is below 2^31. + +Slice 2 adds three proved claims: + +- The header round trip: reading the printed bytes of a well formed header gives the header back. Well formed means the width and height are above zero and below 2^32, the interlace byte is 0 or 1, and the bit depth is one the colour type allows. +- The table is exact: the depth checker agrees with the table above, written out as a list of (colour type, bit depth) pairs, on every colour type byte and every bit depth byte. +- The file level round trip: a document that is well formed at the chunk layer and whose first chunk's data is the printed form of a well formed header prints and reads back whole, header included. No claim is made that the parser accepts every file the rules above allow, or rejects every file they forbid, beyond what the round trip needs. The test suite checks that behaviour against real PNG files built with Python's zlib and against a strict reading of these rules, and the results record the counts. @@ -42,4 +83,4 @@ The round trip needs no fact about CRC-32 itself: the parser and the printer com ## The command-line tool -`Main.lean` reads one request per line. The only operation is `parse`: the request is the word `parse`, a space, and the byte codes of a PNG file as comma-separated decimal numbers. The answer is `ok:` followed by the byte codes of the canonical reprint of the parsed chunk list, or `none` when the file is rejected. A malformed request line prints `error`. A code above 255 makes the request malformed, since no byte holds it. +`Main.lean` reads one request per line. The operations are `parse` and `header`: the request is the operation word, a space, and the byte codes of a PNG file as comma-separated decimal numbers. The `parse` answer is `ok:` followed by the byte codes of the canonical reprint of the parsed chunk list, or `none` when the file is rejected. The `header` answer is `ok:` followed by the codes of a text line naming the decoded header fields, or `none` when the file is rejected at either layer. The text is the width in decimal, the letter `x`, the height in decimal, a space, the letter `d`, the bit depth in decimal, a space, the letter `t`, the colour type in decimal, a space, the letter `i`, and the interlace method in decimal: for a 1920 by 1080 file at bit depth 8, colour type 6, no interlace, the text is `1920x1080 d8 t6 i0`, and the answer carries the character codes of that text. A malformed request line prints `error`. A code above 255 makes the request malformed, since no byte holds it. diff --git a/tests/TEST-RESULTS.md b/tests/TEST-RESULTS.md index f9ac030..66fb8f0 100644 --- a/tests/TEST-RESULTS.md +++ b/tests/TEST-RESULTS.md @@ -16,15 +16,13 @@ Written by `tests/run_tests.py`. Rerun it to check these numbers. - Mutations of a 1140 byte file (300 sampled single byte flips): 300 inputs, 300 agreements - Malformed requests answered error: 12 of 12 - Requests with no codes answered none: 2 of 2 +- Real PNG files through `header`: 4 of 4 decoded to the fields they were built with +- Hand-built headers, every colour type and allowed depth: 15 of 15 accepted with their fields +- Header enumeration, 14 colour types by 9 depths against the allowed table: 126 of 126 agreements +- Header mutations rejected: 6 of 6 +- Random well formed headers: 200 of 200 decoded to the fields they were built with +- Random header files through `parse`: 200 of 200 reprinted byte for byte +- Malformed header requests answered error: 3 of 3 +- Header requests with no codes answered none: 2 of 2 - Named differences: none - Failures: 0 - -## Axiom check - -Recorded on 2026-10-26 against the head of this pull request, on Lean 4.35.0. The line below is the output printed for `Proofs/Verify.lean` during a clean `lake build` (17 jobs). - -```text -'Png.Proofs.parse_print' depends on axioms: [propext, Quot.sound] -``` - -No other axiom is named, and sorryAx is not named. diff --git a/tests/run_tests.py b/tests/run_tests.py index d3901ef..694c2a1 100644 --- a/tests/run_tests.py +++ b/tests/run_tests.py @@ -1,7 +1,9 @@ #!/usr/bin/env python3 """Tests for the Lean PNG chunk parser and printer. -Four parts. +Five parts. Parts 1 to 4 are the slice 1 suite, unchanged. +Part 5 is slice 2: the IHDR header read through the `header` +request. 1. Real PNG files built with Python zlib: an IHDR chunk (the width, height, bit depth, and colour type are stated per file @@ -30,6 +32,19 @@ 4. Malformed request lines must be answered `error`. +5. The header read (slice 2). The same real files are asked + through `header`, and their decoded fields are checked. Hand + built headers cover every colour type with each depth the + PNG specification allows it. An enumeration crosses colour + types 0 to 12 and 255 with depths 0, 1, 2, 3, 4, 5, 8, 16, + and 255, settled by the allowed table written in this + harness. Mutations (a zero width, a zero height, a + compression byte of 1, a filter byte of 1, an interlace byte + of 2, a first chunk of length 12) must be rejected. Random + well formed headers are built into whole files and checked + through `header` for their fields and through `parse` for a + byte for byte reprint. + python3 tests/run_tests.py [--seed N] [--count N] The tool is driven one batch process per chunk of requests, input @@ -244,9 +259,9 @@ def real_png(width, height, colour_type, rng, split_idat=False, extra=()): return b"".join(parts) -def run_real(seed): +def real_files(seed): rng = random.Random(seed) - files = [ + return [ ("1x1, bit depth 8, colour type 2 (RGB)", real_png(1, 1, 2, rng)), ("4x3, bit depth 8, colour type 6 (RGBA)", real_png(4, 3, 6, rng)), ( @@ -258,6 +273,10 @@ def run_real(seed): real_png(7, 5, 2, rng, extra=[(b"tEXt", b"Title\x00A small test")]), ), ] + + +def run_real(seed): + files = real_files(seed) lines = [] failures = [] answers, problems = ask_all([request(data) for _, data in files]) @@ -496,6 +515,237 @@ def run_malformed(): return lines, failures +# ---------------------------------------------------------------- the +# header read (slice 2). The strict oracle for this part is the +# allowed table below, written from the PNG specification: a +# colour type allows exactly these bit depths. + + +ALLOWED = {0: [1, 2, 4, 8, 16], 2: [8, 16], 3: [1, 2, 4, 8], 4: [8, 16], 6: [8, 16]} + + +def ihdr_full(width, height, bit_depth, colour_type, comp=0, filt=0, interlace=0): + return struct.pack( + ">IIBBBBB", width, height, bit_depth, colour_type, comp, filt, interlace + ) + + +def header_request(data): + return "header " + codes(data) + + +def header_line(width, height, bit_depth, colour_type, interlace): + text = f"{width}x{height} d{bit_depth} t{colour_type} i{interlace}" + return "ok:" + codes(text.encode("ascii")) + + +def header_file(ihdr_bytes, idat_data=b"\x00\x01\x02\x03"): + return ( + SIG + + make_chunk(b"IHDR", ihdr_bytes) + + make_chunk(b"IDAT", zlib.compress(idat_data)) + + make_chunk(b"IEND", b"") + ) + + +def run_real_headers(seed): + """The real files of part 1, asked through `header`. Their + (width, height, bit depth, colour type, interlace) are known + from how they were built.""" + known = [(1, 1, 8, 2, 0), (4, 3, 8, 6, 0), (16, 16, 8, 0, 0), (7, 5, 8, 2, 0)] + files = real_files(seed) + lines = [] + failures = [] + answers, problems = ask_all([header_request(data) for _, data in files]) + for problem in problems: + failures.append(f"real headers: tool problem: {problem}") + agree = 0 + for (name, _), fields, ans in zip(files, known, answers): + want = header_line(*fields) + if ans == want: + agree += 1 + else: + failures.append(f"real header {name}: want {want!r}, got {ans!r}") + lines.append( + f"Real PNG files through `header`: {agree} of {len(files)} decoded " + "to the fields they were built with" + ) + return lines, failures + + +def run_header_types(): + """One file per allowed (colour type, bit depth) pair, the + interlace byte alternating, each checked for its fields.""" + cases = [] + for ctype in sorted(ALLOWED): + for depth in ALLOWED[ctype]: + interlace = (len(cases) % 2) + cases.append((3, 2, depth, ctype, interlace)) + lines = [] + failures = [] + answers, problems = ask_all( + [ + header_request( + header_file(ihdr_full(w, h, d, t, interlace=i)) + ) + for (w, h, d, t, i) in cases + ] + ) + for problem in problems: + failures.append(f"header types: tool problem: {problem}") + agree = 0 + for fields, ans in zip(cases, answers): + want = header_line(*fields) + if ans == want: + agree += 1 + else: + failures.append(f"header type {fields}: want {want!r}, got {ans!r}") + lines.append( + f"Hand-built headers, every colour type and allowed depth: " + f"{agree} of {len(cases)} accepted with their fields" + ) + return lines, failures + + +def run_header_grid(): + """The enumeration: colour types 0 to 12 and 255, against + depths 0, 1, 2, 3, 4, 5, 8, 16, 255. The table settles each.""" + types = list(range(13)) + [255] + depths = [0, 1, 2, 3, 4, 5, 8, 16, 255] + cases = [(t, d) for t in types for d in depths] + lines = [] + failures = [] + answers, problems = ask_all( + [ + header_request(header_file(ihdr_full(2, 2, d, t))) + for t, d in cases + ] + ) + for problem in problems: + failures.append(f"header grid: tool problem: {problem}") + agree = 0 + for (t, d), ans in zip(cases, answers): + want = header_line(2, 2, d, t, 0) if d in ALLOWED.get(t, []) else "none" + if ans == want: + agree += 1 + else: + failures.append(f"header grid type {t} depth {d}: want {want!r}, got {ans!r}") + lines.append( + f"Header enumeration, {len(types)} colour types by {len(depths)} " + f"depths against the allowed table: {agree} of {len(cases)} agreements" + ) + return lines, failures + + +def run_header_mutations(): + """Each of these files is one change away from a valid one, + and each must be rejected by the header read. The CRCs are + recomputed, so the rejection is for the header rule itself + (except the length 12 case, which the chunk layer rejects).""" + good = ihdr_full(4, 3, 8, 6) + cases = [ + ("a width of zero", header_file(ihdr_full(0, 3, 8, 6))), + ("a height of zero", header_file(ihdr_full(4, 0, 8, 6))), + ("a compression byte of 1", header_file(ihdr_full(4, 3, 8, 6, comp=1))), + ("a filter byte of 1", header_file(ihdr_full(4, 3, 8, 6, filt=1))), + ("an interlace byte of 2", header_file(ihdr_full(4, 3, 8, 6, interlace=2))), + ( + "a first chunk of length 12", + SIG + + make_chunk(b"IHDR", good[:12]) + + make_chunk(b"IDAT", zlib.compress(b"\x00\x01\x02\x03")) + + make_chunk(b"IEND", b""), + ), + ] + lines = [] + failures = [] + answers, problems = ask_all([header_request(data) for _, data in cases]) + for problem in problems: + failures.append(f"header mutations: tool problem: {problem}") + agree = 0 + for (name, _), ans in zip(cases, answers): + if ans == "none": + agree += 1 + else: + failures.append(f"header mutation {name}: want none, got {ans!r}") + lines.append(f"Header mutations rejected: {agree} of {len(cases)}") + return lines, failures + + +def run_header_random(seed, count=200): + """Random well formed headers, built into whole files. Each + file is checked through `header` for its fields and through + `parse` for a byte for byte reprint.""" + rng = random.Random(seed + 11) + cases = [] + for _ in range(count): + ctype = rng.choice(sorted(ALLOWED)) + depth = rng.choice(ALLOWED[ctype]) + width = rng.choice([1, 2, 17, 640, 65535, rng.randrange(1, 100001)]) + height = rng.choice([1, 3, 42, 480, 65535, rng.randrange(1, 100001)]) + interlace = rng.randrange(0, 2) + fields = (width, height, depth, ctype, interlace) + cases.append( + (fields, header_file(ihdr_full(width, height, depth, ctype, interlace=interlace))) + ) + lines = [] + failures = [] + answers, problems = ask_all([header_request(data) for _, data in cases]) + for problem in problems: + failures.append(f"random headers: tool problem: {problem}") + answers2, problems2 = ask_all([request(data) for _, data in cases]) + for problem in problems2: + failures.append(f"random headers, parse: tool problem: {problem}") + agree = 0 + for (fields, _), ans in zip(cases, answers): + want = header_line(*fields) + if ans == want: + agree += 1 + else: + failures.append(f"random header {fields}: want {want!r}, got {ans!r}") + lines.append( + f"Random well formed headers: {agree} of {len(cases)} decoded " + "to the fields they were built with" + ) + agree2 = 0 + for (_, data), ans in zip(cases, answers2): + want = "ok:" + codes(data) + if ans == want: + agree2 += 1 + else: + failures.append(f"random header file: want byte for byte reprint, got {ans!r}") + lines.append( + f"Random header files through `parse`: {agree2} of {len(cases)} " + "reprinted byte for byte" + ) + return lines, failures + + +def run_header_malformed(): + lines = [] + failures = [] + bad = ["header 256", "header 12a", "header 97 98"] + answers, problems = ask_all(bad) + for problem in problems: + failures.append(f"header malformed: tool problem: {problem}") + bad_ok = 0 + for line, ans in zip(bad, answers): + if ans == "error": + bad_ok += 1 + else: + failures.append(f"header malformed {line!r}: want error, got {ans!r}") + lines.append(f"Malformed header requests answered error: {bad_ok} of {len(bad)}") + none_ok = 0 + answers, _ = ask_all(["header", "header "]) + for ans in answers: + if ans == "none": + none_ok += 1 + else: + failures.append(f"empty header request: want none, got {ans!r}") + lines.append(f"Header requests with no codes answered none: {none_ok} of 2") + return lines, failures + + def main(): ap = argparse.ArgumentParser() ap.add_argument("--seed", type=int, default=SEED_DEFAULT) @@ -523,6 +773,12 @@ def main(): run_random(args.seed, args.count), run_mutations(args.seed), run_malformed(), + run_real_headers(args.seed), + run_header_types(), + run_header_grid(), + run_header_mutations(), + run_header_random(args.seed), + run_header_malformed(), ): lines, failures = part all_lines += lines From 3442498c8fac7ed1e7c2aafe658cc8d42e211779 Mon Sep 17 00:00:00 2001 From: shah Date: Tue, 27 Oct 2026 19:50:12 -0500 Subject: [PATCH 02/14] Cap width and height below 2^31 and restore the axiom record, per review --- Png/Header.lean | 13 +++++++----- Proofs/Header.lean | 6 +++++- README.md | 2 +- SPEC.md | 4 ++-- tests/TEST-RESULTS.md | 12 ++++++++++- tests/run_tests.py | 47 +++++++++++++++++++++++++++++++++++++------ 6 files changed, 68 insertions(+), 16 deletions(-) diff --git a/Png/Header.lean b/Png/Header.lean index 4178d74..52efc3c 100644 --- a/Png/Header.lean +++ b/Png/Header.lean @@ -15,7 +15,8 @@ interlace byte is 0 (no interlace) or 1 (Adam7), and is held. A header is accepted only when the width and the height are - both above zero, the compression and filter bytes are zero, + both above zero and below 2^31 (the PNG specification caps + each of them at 2^31 - 1), the compression and filter bytes are zero, the interlace byte is 0 or 1, and the bit depth is one the colour type allows. The allowed pairs are the ones the PNG specification names: @@ -77,11 +78,11 @@ def allowedPairs : List (Nat × Nat) := def checkPair (ct bd : UInt8) : Bool := pairAllowed ct.toNat bd.toNat /-- A header is well formed when the width and the height are - above zero and below 2^32, the interlace byte is 0 or 1, and + above zero and below 2^31, the interlace byte is 0 or 1, and the bit depth is one the colour type allows. -/ def WFHeader (h : Ihdr) : Prop := - 0 < h.width ∧ h.width < 4294967296 ∧ - 0 < h.height ∧ h.height < 4294967296 ∧ + 0 < h.width ∧ h.width < 2147483648 ∧ + 0 < h.height ∧ h.height < 2147483648 ∧ (h.interlace = 0 ∨ h.interlace = 1) ∧ checkPair h.colorType h.bitDepth = true @@ -93,7 +94,8 @@ def printIhdr (h : Ihdr) : List UInt8 := /-- Read 13 bytes as a header. Anything but exactly 13 bytes is rejected, and so is a header that breaks any of the rules at - the top of this file: a width or height of zero, a nonzero + the top of this file: a width or height of zero or of 2^31 + or more, a nonzero compression or filter byte, an interlace byte above 1, or a bit depth the colour type does not allow. -/ def parseIhdr (bs : List UInt8) : Option Ihdr := @@ -101,6 +103,7 @@ def parseIhdr (bs : List UInt8) : Option Ihdr := | w1 :: w2 :: w3 :: w4 :: h1 :: h2 :: h3 :: h4 :: bd :: ct :: comp :: filt :: int :: rest => if rest ≠ [] then none else if u32 w1 w2 w3 w4 = 0 ∨ u32 h1 h2 h3 h4 = 0 then none + else if u32 w1 w2 w3 w4 ≥ 2147483648 ∨ u32 h1 h2 h3 h4 ≥ 2147483648 then none else if comp ≠ 0 ∨ filt ≠ 0 then none else if int > 1 then none else if checkPair ct bd then diff --git a/Proofs/Header.lean b/Proofs/Header.lean index f7d442d..4fcd6c6 100644 --- a/Proofs/Header.lean +++ b/Proofs/Header.lean @@ -37,6 +37,7 @@ theorem parseIhdr_step (w1 w2 w3 w4 h1 h2 h3 h4 bd ct comp filt int : UInt8) :: bd :: ct :: comp :: filt :: int :: rest) = if rest ≠ [] then none else if u32 w1 w2 w3 w4 = 0 ∨ u32 h1 h2 h3 h4 = 0 then none + else if u32 w1 w2 w3 w4 ≥ 2147483648 ∨ u32 h1 h2 h3 h4 ≥ 2147483648 then none else if comp ≠ 0 ∨ filt ≠ 0 then none else if int > 1 then none else if checkPair ct bd then @@ -55,6 +56,8 @@ theorem parseIhdr_print (h : Ihdr) (hwf : WFHeader h) : obtain ⟨hw1, hw2, hh1, hh2, hil, hpair⟩ := hwf have hw1' : 0 < w := hw1 have hh1' : 0 < hh := hh1 + have hw2' : w < 2147483648 := hw2 + have hh2' : hh < 2147483648 := hh2 have hnl : ¬ (il > 1) := by rcases hil with rfl | rfl <;> decide have e : printIhdr ⟨w, hh, bd, ct, il⟩ = @@ -63,9 +66,10 @@ theorem parseIhdr_print (h : Ihdr) (hwf : WFHeader h) : :: (hh / 16777216).toUInt8 :: (hh / 65536).toUInt8 :: (hh / 256).toUInt8 :: hh.toUInt8 :: bd :: ct :: 0 :: 0 :: il :: [] := rfl - rw [e, parseIhdr_step, u32_be32 w hw2, u32_be32 hh hh2] + rw [e, parseIhdr_step, u32_be32 w (by omega), u32_be32 hh (by omega)] rw [if_neg (by simp : ¬ (([] : List UInt8) ≠ []))] rw [if_neg (by omega : ¬ (w = 0 ∨ hh = 0))] + rw [if_neg (by omega : ¬ (w ≥ 2147483648 ∨ hh ≥ 2147483648))] rw [if_neg (by decide : ¬ ((0 : UInt8) ≠ 0 ∨ (0 : UInt8) ≠ 0))] rw [if_neg hnl, if_pos hpair] diff --git a/README.md b/README.md index ba3fc10..2029841 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ The definitions are in `Png/Value.lean` (the chunk type, the printer, and the we | The depth checker agrees with the written out table of allowed (colour type, bit depth) pairs on every byte pair. | `Png.Proofs.checkPair_iff` | | Printing a fully well formed document and reading it back at both layers gives the document back, header included. | `Png.Proofs.parseFile_print` | -A chunk list is well formed when it is a first chunk, then any middle chunks, then the IEND chunk; the first chunk has type IHDR and 13 data bytes; no chunk before the last is the IEND chunk; every chunk type is four ASCII letters; and every data length is below 2^31. A header is well formed when its width and height are above zero and below 2^32, its interlace byte is 0 or 1, and its bit depth is one its colour type allows. Each of these theorems checks against two of the standard axioms (`propext`, `Quot.sound`), which a reader can confirm with `#print axioms` on the theorem's name. +A chunk list is well formed when it is a first chunk, then any middle chunks, then the IEND chunk; the first chunk has type IHDR and 13 data bytes; no chunk before the last is the IEND chunk; every chunk type is four ASCII letters; and every data length is below 2^31. A header is well formed when its width and height are above zero and below 2^31, its interlace byte is 0 or 1, and its bit depth is one its colour type allows. Each of these theorems checks against two of the standard axioms (`propext`, `Quot.sound`), which a reader can confirm with `#print axioms` on the theorem's name. The command-line tool in `Main.lean` calls these functions directly. diff --git a/SPEC.md b/SPEC.md index 4c33fdd..9ae055d 100644 --- a/SPEC.md +++ b/SPEC.md @@ -44,7 +44,7 @@ The decoded header value holds the width and the height as numbers, and the bit A header reads only when all of these hold: -- the width is above zero, and the height is above zero. +- the width is above zero and below 2^31, and the height is above zero and below 2^31. The PNG specification caps each of them at 2^31 - 1. - the compression byte is zero, and the filter byte is zero. - the interlace byte is 0 or 1. - the bit depth is one the colour type allows, by this table from the PNG specification: @@ -73,7 +73,7 @@ The proved claim of slice 1 is a round trip: for every well formed chunk list, p Slice 2 adds three proved claims: -- The header round trip: reading the printed bytes of a well formed header gives the header back. Well formed means the width and height are above zero and below 2^32, the interlace byte is 0 or 1, and the bit depth is one the colour type allows. +- The header round trip: reading the printed bytes of a well formed header gives the header back. Well formed means the width and height are above zero and below 2^31, the interlace byte is 0 or 1, and the bit depth is one the colour type allows. - The table is exact: the depth checker agrees with the table above, written out as a list of (colour type, bit depth) pairs, on every colour type byte and every bit depth byte. - The file level round trip: a document that is well formed at the chunk layer and whose first chunk's data is the printed form of a well formed header prints and reads back whole, header included. diff --git a/tests/TEST-RESULTS.md b/tests/TEST-RESULTS.md index 66fb8f0..18dc907 100644 --- a/tests/TEST-RESULTS.md +++ b/tests/TEST-RESULTS.md @@ -19,10 +19,20 @@ Written by `tests/run_tests.py`. Rerun it to check these numbers. - Real PNG files through `header`: 4 of 4 decoded to the fields they were built with - Hand-built headers, every colour type and allowed depth: 15 of 15 accepted with their fields - Header enumeration, 14 colour types by 9 depths against the allowed table: 126 of 126 agreements -- Header mutations rejected: 6 of 6 +- Header mutations rejected: 10 of 10 - Random well formed headers: 200 of 200 decoded to the fields they were built with - Random header files through `parse`: 200 of 200 reprinted byte for byte - Malformed header requests answered error: 3 of 3 - Header requests with no codes answered none: 2 of 2 + +## Axiom check + +The output of `#print axioms` for the four named theorems, from `lake env lean Proofs/Verify.lean`: + +- 'Png.Proofs.parse_print' depends on axioms: [propext, Quot.sound] +- 'Png.Proofs.parseIhdr_print' depends on axioms: [propext, Quot.sound] +- 'Png.Proofs.checkPair_iff' depends on axioms: [propext, Quot.sound] +- 'Png.Proofs.parseFile_print' depends on axioms: [propext, Quot.sound] + - Named differences: none - Failures: 0 diff --git a/tests/run_tests.py b/tests/run_tests.py index 694c2a1..86d7af8 100644 --- a/tests/run_tests.py +++ b/tests/run_tests.py @@ -38,12 +38,13 @@ PNG specification allows it. An enumeration crosses colour types 0 to 12 and 255 with depths 0, 1, 2, 3, 4, 5, 8, 16, and 255, settled by the allowed table written in this - harness. Mutations (a zero width, a zero height, a - compression byte of 1, a filter byte of 1, an interlace byte - of 2, a first chunk of length 12) must be rejected. Random - well formed headers are built into whole files and checked - through `header` for their fields and through `parse` for a - byte for byte reprint. + harness. Mutations (a zero width, a zero height, a width of + 2^31, a height of 2^31, a width of 2^32 - 1, a height of + 2^32 - 1, a compression byte of 1, a filter byte of 1, an + interlace byte of 2, a first chunk of length 12) must be + rejected. Random well formed headers are built into whole + files and checked through `header` for their fields and + through `parse` for a byte for byte reprint. python3 tests/run_tests.py [--seed N] [--count N] @@ -646,6 +647,10 @@ def run_header_mutations(): cases = [ ("a width of zero", header_file(ihdr_full(0, 3, 8, 6))), ("a height of zero", header_file(ihdr_full(4, 0, 8, 6))), + ("a width of 2^31", header_file(ihdr_full(2**31, 3, 8, 6))), + ("a height of 2^31", header_file(ihdr_full(4, 2**31, 8, 6))), + ("a width of 2^32 - 1", header_file(ihdr_full(2**32 - 1, 3, 8, 6))), + ("a height of 2^32 - 1", header_file(ihdr_full(4, 2**32 - 1, 8, 6))), ("a compression byte of 1", header_file(ihdr_full(4, 3, 8, 6, comp=1))), ("a filter byte of 1", header_file(ihdr_full(4, 3, 8, 6, filt=1))), ("an interlace byte of 2", header_file(ihdr_full(4, 3, 8, 6, interlace=2))), @@ -792,6 +797,25 @@ def main(): timeout=120, ).stdout.strip() + # The axiom check: Proofs/Verify.lean prints the axioms of + # the four named theorems with `#print axioms`. Its output + # goes into the results file, in the Axiom check section. + axiom_run = subprocess.run( + ["lake", "env", "lean", "Proofs/Verify.lean"], + cwd=ROOT, + capture_output=True, + text=True, + timeout=600, + ) + axiom_lines = [ + line for line in axiom_run.stdout.strip().splitlines() if line.strip() + ] + if axiom_run.returncode != 0 or len(axiom_lines) != 4: + all_failures.append( + "axiom check: running Proofs/Verify.lean gave " + f"status {axiom_run.returncode} and {len(axiom_lines)} lines" + ) + out = ["# Test results", ""] out.append("Written by `tests/run_tests.py`. Rerun it to check these numbers.") out.append("") @@ -803,6 +827,17 @@ def main(): out.append(f"- Seed: {args.seed}") for line in all_lines: out.append(f"- {line}") + out.append("") + out.append("## Axiom check") + out.append("") + out.append( + "The output of `#print axioms` for the four named theorems, " + "from `lake env lean Proofs/Verify.lean`:" + ) + out.append("") + for line in axiom_lines: + out.append(f"- {line}") + out.append("") out.append("- Named differences: none") out.append(f"- Failures: {len(all_failures)}") for failure in all_failures[:50]: From 437849bf67c868665aba4e3b1c0092a5b78e6405 Mon Sep 17 00:00:00 2001 From: shah Date: Tue, 27 Oct 2026 20:47:24 -0500 Subject: [PATCH 03/14] Add the bar file set: Spec.lean, comparator.json, bar.toml, CI workflow --- .github/workflows/bar.yml | 21 +++++ Proofs.lean | 1 + Proofs/SpecProofs.lean | 139 +++++++++++++++++++++++++++++ README.md | 182 +++++++++++++++++++++++++++++--------- SPEC.md | 27 +++++- Spec.lean | 164 ++++++++++++++++++++++++++++++++++ bar.toml | 49 ++++++++++ comparator.json | 1 + lakefile.toml | 3 + 9 files changed, 545 insertions(+), 42 deletions(-) create mode 100644 .github/workflows/bar.yml create mode 100644 Proofs/SpecProofs.lean create mode 100644 Spec.lean create mode 100644 bar.toml create mode 100644 comparator.json diff --git a/.github/workflows/bar.yml b/.github/workflows/bar.yml new file mode 100644 index 0000000..32457ed --- /dev/null +++ b/.github/workflows/bar.yml @@ -0,0 +1,21 @@ +name: verified bar + +on: + push: + pull_request: + # Rerun when the spec reviewer approves, or an approval is dismissed. + pull_request_review: + types: [submitted, dismissed] + +jobs: + bar: + # pull-requests: read lets the check confirm the spec approval on GitHub. + permissions: + contents: read + pull-requests: read + # Both lines pin the same commit of Proven-Commons/mission: bar v2, as + # proposed in its pull request #2. Move both to a release tag once + # mission publishes one. + uses: Proven-Commons/mission/.github/workflows/verified-bar.yml@6ba85c27abaaf07905c9316aa96dc29f7b5847a2 + with: + bar-ref: 6ba85c27abaaf07905c9316aa96dc29f7b5847a2 diff --git a/Proofs.lean b/Proofs.lean index e63d117..5a51e70 100644 --- a/Proofs.lean +++ b/Proofs.lean @@ -1,3 +1,4 @@ import Proofs.RoundTrip import Proofs.Header import Proofs.Verify +import Proofs.SpecProofs diff --git a/Proofs/SpecProofs.lean b/Proofs/SpecProofs.lean new file mode 100644 index 0000000..f0809d9 --- /dev/null +++ b/Proofs/SpecProofs.lean @@ -0,0 +1,139 @@ +/- + Proofs of the statements in `Spec.lean`, under the same names. + + The round trips and the table are the theorems proved in + `Proofs/RoundTrip.lean` and `Proofs/Header.lean`. The refusals + are single inputs, checked in the kernel with `decide +kernel`: + no native evaluation, which the bar forbids. +-/ + +import Png +import Proofs.RoundTrip +import Proofs.Header + +open Png + +theorem Spec.parse_print {doc : Doc} (h : WF doc) : + parse (print doc) = some doc := + Png.Proofs.parse_print h + +theorem Spec.parseIhdr_print (h : Ihdr) (hwf : WFHeader h) : + parseIhdr (printIhdr h) = some h := + Png.Proofs.parseIhdr_print h hwf + +theorem Spec.checkPair_iff (ct bd : UInt8) : + checkPair ct bd = true ↔ + (ct.toNat, bd.toNat) ∈ allowedPairs := + Png.Proofs.checkPair_iff ct bd + +theorem Spec.parseFile_print {doc : Doc} (h : WFFull doc) : + ∃ hd : Ihdr, parseFile (print doc) = some (hd, doc) := + Png.Proofs.parseFile_print h + + +theorem Spec.rejects_bad_signature : + (parse ([118, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, + 68, 82, 0, 0, 0, 4, 0, 0, 0, 3, 8, 6, 0, 0, 0, 180, 244, 174, + 198, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130] + : List UInt8)).isSome = false := by + decide +kernel + + +theorem Spec.rejects_bad_crc : + (parse ([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, + 68, 82, 0, 0, 0, 4, 0, 0, 0, 3, 8, 6, 0, 0, 0, 180, 244, 174, + 57, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130] + : List UInt8)).isSome = false := by + decide +kernel + + +theorem Spec.rejects_truncated_input : + (parse ([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, + 68, 82, 0, 0, 0, 4] : List UInt8)).isSome = false := by + decide +kernel + + +theorem Spec.rejects_first_chunk_length_12 : + (parse ([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 12, 73, 72, + 68, 82, 0, 0, 0, 4, 0, 0, 0, 3, 8, 6, 0, 0, 240, 122, 129, + 218, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130] + : List UInt8)).isSome = false := by + decide +kernel + + +theorem Spec.rejects_zero_width : + (parseFile ([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, + 72, 68, 82, 0, 0, 0, 0, 0, 0, 0, 3, 8, 6, 0, 0, 0, 189, 31, + 14, 188, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130] + : List UInt8)).isSome = false := by + decide +kernel + + +theorem Spec.rejects_zero_height : + (parseFile ([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, + 72, 68, 82, 0, 0, 0, 4, 0, 0, 0, 0, 8, 6, 0, 0, 0, 50, 96, + 220, 104, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130] + : List UInt8)).isSome = false := by + decide +kernel + + +theorem Spec.rejects_width_at_cap : + (parseFile ([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, + 72, 68, 82, 128, 0, 0, 0, 0, 0, 0, 3, 8, 6, 0, 0, 0, 29, 117, + 43, 171, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130] + : List UInt8)).isSome = false := by + decide +kernel + + +theorem Spec.rejects_height_at_cap : + (parseFile ([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, + 72, 68, 82, 0, 0, 0, 4, 128, 0, 0, 0, 8, 6, 0, 0, 0, 84, 53, + 220, 39, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130] + : List UInt8)).isSome = false := by + decide +kernel + + +theorem Spec.rejects_illegal_depth : + (parseFile ([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, + 72, 68, 82, 0, 0, 0, 4, 0, 0, 0, 3, 4, 2, 0, 0, 0, 254, 102, + 212, 144, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130] + : List UInt8)).isSome = false := by + decide +kernel + + +theorem Spec.rejects_unknown_colour_type : + (parseFile ([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, + 72, 68, 82, 0, 0, 0, 4, 0, 0, 0, 3, 8, 1, 0, 0, 0, 41, 35, + 150, 127, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130] + : List UInt8)).isSome = false := by + decide +kernel + + +theorem Spec.rejects_unknown_interlace : + (parseFile ([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, + 72, 68, 82, 0, 0, 0, 4, 0, 0, 0, 3, 8, 6, 0, 0, 2, 90, 250, + 207, 234, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130] + : List UInt8)).isSome = false := by + decide +kernel + + +theorem Spec.rejects_nonzero_compression : + (parseFile ([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, + 72, 68, 82, 0, 0, 0, 4, 0, 0, 0, 3, 8, 6, 1, 0, 0, 181, 54, + 196, 241, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130] + : List UInt8)).isSome = false := by + decide +kernel + + +theorem Spec.rejects_nonzero_filter : + (parseFile ([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, + 72, 68, 82, 0, 0, 0, 4, 0, 0, 0, 3, 8, 6, 0, 1, 0, 173, 239, + 159, 135, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130] + : List UInt8)).isSome = false := by + decide +kernel + + +theorem Spec.rejects_header_wrong_length : + (parseIhdr ([0, 0, 0, 4, 0, 0, 0, 3, 8, 6, 0, 0] + : List UInt8)).isSome = false := by + decide +kernel diff --git a/README.md b/README.md index 2029841..a1feb33 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,111 @@ -# png: a PNG chunk parser and printer, with a proved round trip +# png: a PNG chunk parser and printer, with proved round trips + +Status: in-progress + +A parser and printer for the chunk layer of PNG files, in Lean, +following the PNG specification. It reads the signature, then +each chunk's length, type, data, and CRC-32 check. Slice 2 adds +a decoder for the IHDR header: the width, height, bit depth, +colour type, and interlace method held in the first chunk's 13 +data bytes, checked against the validity rules of the PNG +specification. It does not decode pixels, and it does not +inflate IDAT data. The rules are written out in `SPEC.md`. + +The statements a reviewer approves are in `Spec.lean`, with +their proofs in `Proofs/SpecProofs.lean`. No reviewer has +approved them yet, so this project is not called verified. + +## How to run it + +`lake build` builds the library, the proofs, and the command +line tool. The tool reads one request per line: the word +`parse` or `header`, a space, and the file as comma-separated +decimal byte codes. `parse` answers `ok:` and the byte codes +of the canonical reprint of the parsed chunk list, or `none` +when the file is rejected. `header` reads the file at both +layers, the chunk list and the IHDR header decoded from the +first chunk's data, and answers `ok:` and the codes of a text +line naming the header fields, or `none` when the file is +rejected at either layer. A malformed request answers +`error`. -A PNG chunk parser and printer in Lean. It reads the chunk layer of a PNG file: the signature, then each chunk's length, type, data, and CRC-32 check. Slice 2 adds a decoder for the IHDR header: the width, height, bit depth, colour type, and interlace method held in the first chunk's 13 data bytes, checked against the validity rules of the PNG specification. It does not decode pixels, and it does not inflate IDAT data. The main claims are proved: parsing what the printer wrote returns the same chunk list, reading the printed bytes of a well formed header returns the header, and the bit depth table the checker uses is exactly the table the specification names. The tests run the parser against real PNG files built with Python's zlib and against a strict oracle written from the rules; see `tests/TEST-RESULTS.md` for the counts. +``` +lake build +echo "parse 137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,0,73,69,78,68,174,66,96,130" | .lake/build/bin/png +``` -Status: in progress. The specification has not yet been approved by a reviewer apart from its author, and no statement file (`Spec.lean`) has been added by a reviewer, so this is not verified. +That request is a whole minimal PNG file as byte codes: the +signature, an IHDR chunk for a 1 by 1 image, and IEND. The +answer is `ok:` followed by the same codes, since the file is +valid and reprints byte for byte. A file the parser rejects, +such as the single code `120`, answers `none`. The same file +asked through `header`, with the word `header` in place of +`parse`, answers `ok:49,120,49,32,100,56,32,116,50,32,105,48`: +the codes of the text `1x1 d8 t2 i0`, the decoded header +fields. See `SPEC.md` for the full protocol. + +`python3 tests/run_tests.py` rebuilds the tool, runs the real +files, the hand built cases, the differential runs, and the +header sections, and writes the outcome to +`tests/TEST-RESULTS.md`. ## What is proved -The definitions are in `Png/Value.lean` (the chunk type, the printer, and the well-formedness rules), `Png/Parse.lean` (the parser), `Png/Crc32.lean` (the CRC-32), and `Png/Header.lean` (the IHDR header value, its reader and printer, and the file level read). The proofs are in `Proofs/RoundTrip.lean` and `Proofs/Header.lean`. +Each claim below names the theorem behind it. The theorems +are stated in `Spec.lean` (the challenge file, where they end +in `sorry`) and proved in `Proofs/SpecProofs.lean`, under the +same names. `comparator.json` lists all of them. | Claim | Theorem | |-------|---------| -| Parsing the printed form of a well formed chunk list gives the list back. | `Png.Proofs.parse_print` | -| Reading back one printed chunk, with any bytes after it, gives the chunk and those bytes untouched. | `Png.Proofs.parseChunk_printChunk` | -| Four bytes printed from a value below 2^32 read back as the value. | `Png.Proofs.u32_be32` | -| Reading the printed bytes of a well formed IHDR header gives the header back. | `Png.Proofs.parseIhdr_print` | -| The depth checker agrees with the written out table of allowed (colour type, bit depth) pairs on every byte pair. | `Png.Proofs.checkPair_iff` | -| Printing a fully well formed document and reading it back at both layers gives the document back, header included. | `Png.Proofs.parseFile_print` | - -A chunk list is well formed when it is a first chunk, then any middle chunks, then the IEND chunk; the first chunk has type IHDR and 13 data bytes; no chunk before the last is the IEND chunk; every chunk type is four ASCII letters; and every data length is below 2^31. A header is well formed when its width and height are above zero and below 2^31, its interlace byte is 0 or 1, and its bit depth is one its colour type allows. Each of these theorems checks against two of the standard axioms (`propext`, `Quot.sound`), which a reader can confirm with `#print axioms` on the theorem's name. - -The command-line tool in `Main.lean` calls these functions directly. +| Parsing the printed form of a well formed chunk list gives the list back. | `Spec.parse_print` | +| Reading the printed bytes of a well formed IHDR header gives the header back. | `Spec.parseIhdr_print` | +| The depth checker agrees with the written out table of allowed (colour type, bit depth) pairs on every byte pair. | `Spec.checkPair_iff` | +| Printing a fully well formed document and reading it back at both layers gives the document back, header included. | `Spec.parseFile_print` | +| A file whose first signature byte is wrong is refused. | `Spec.rejects_bad_signature` | +| A file whose IHDR chunk carries a CRC that does not match its type and data is refused. | `Spec.rejects_bad_crc` | +| A file cut short inside its first chunk is refused. | `Spec.rejects_truncated_input` | +| A file whose first chunk holds 12 data bytes is refused: an IHDR chunk holds 13. | `Spec.rejects_first_chunk_length_12` | +| A file with a width of zero is refused. | `Spec.rejects_zero_width` | +| A file with a height of zero is refused. | `Spec.rejects_zero_height` | +| A file with a width of 2^31 is refused: the width stays below 2^31. | `Spec.rejects_width_at_cap` | +| A file with a height of 2^31 is refused: the height stays below 2^31. | `Spec.rejects_height_at_cap` | +| A file with bit depth 4 on colour type 2 is refused: that colour type allows depths 8 and 16 only. | `Spec.rejects_illegal_depth` | +| A file with colour type 1 is refused: 1 is not a PNG colour type, and allows no bit depth. | `Spec.rejects_unknown_colour_type` | +| A file with an interlace byte of 2 is refused: the byte is 0 or 1. | `Spec.rejects_unknown_interlace` | +| A file with a compression byte of 1 is refused: the only compression method is 0. | `Spec.rejects_nonzero_compression` | +| A file with a filter byte of 1 is refused: the only filter method is 0. | `Spec.rejects_nonzero_filter` | +| The header reader refuses anything but exactly 13 bytes: here a valid header's first 12 bytes. | `Spec.rejects_header_wrong_length` | + +A chunk list is well formed when it is a first chunk, then +any middle chunks, then the IEND chunk; the first chunk has +type IHDR and 13 data bytes; no chunk before the last is the +IEND chunk; every chunk type is four ASCII letters; and every +data length is below 2^31. A header is well formed when its +width and height are above zero and below 2^31, its interlace +byte is 0 or 1, and its bit depth is one its colour type +allows. A document is fully well formed when it is well +formed at the chunk layer and its first chunk's data is the +printed form of a well formed header. + +The headline theorems rest on the proofs in +`Proofs/RoundTrip.lean` and `Proofs/Header.lean`, where they +are `Png.Proofs.parse_print`, `Png.Proofs.parseIhdr_print`, +`Png.Proofs.checkPair_iff`, and `Png.Proofs.parseFile_print`. +Each checks against two of the standard axioms (`propext`, +`Quot.sound`), which a reader can confirm with `#print +axioms`, or by running `lake env lean Proofs/Verify.lean`. +The refusal theorems are single inputs checked in the kernel +with `decide +kernel`. + +The definitions are in `Png/Value.lean` (the chunk type, the +printer, and the well formedness rules), `Png/Parse.lean` +(the parser), `Png/Crc32.lean` (the CRC-32), and +`Png/Header.lean` (the IHDR header value, its reader and +printer, and the file level read). The command line tool in +`Main.lean` calls `Png.parse`, `Png.print`, and +`Png.parseFile` directly, the same functions the theorems are +about. ## What is not proved @@ -31,33 +117,47 @@ The command-line tool in `Main.lean` calls these functions directly. ## What you must trust -- The Lean kernel, which checks the proofs. -- The Lean compiler, with the C compiler and linker shipped with it, and the runtime, which turn the proved definitions into the program that runs. -- Lean's core library, including the compiled code it puts in place of some definitions the proofs reason about. -- The operating system and hardware it runs on. -- Python's standard library, including zlib, as the oracle in the tests. The results file names the versions used. - -This project has no dependencies beyond Lean itself. - -## Run it - -Needs Linux and [elan](https://github.com/leanprover/elan). Build, then feed request lines to the tool: - -``` -lake build -echo "parse 137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,0,73,69,78,68,174,66,96,130" | .lake/build/bin/png -``` - -That request is a whole minimal PNG file as byte codes: the signature, an IHDR chunk for a 1 by 1 image, and IEND. The answer is `ok:` followed by the same codes, since the file is valid and reprints byte for byte. A file the parser rejects, such as the single code `120`, answers `none`. A malformed request answers `error`. The same file asked through `header`, with the word `header` in place of `parse`, answers `ok:49,120,49,32,100,56,32,116,50,32,105,48`: the codes of the text `1x1 d8 t2 i0`, the decoded header fields. See `SPEC.md` for the full protocol. +- The Lean kernel (Lean 4.35.0, pinned in + `lean-toolchain`), which checks every proof. +- The Lean compiler, with the C compiler and linker shipped + with it, which turns the proved definitions into the + program that runs. +- The Lean runtime, which runs that program. +- The hardware the program runs on. +- Every dependency in `lake-manifest.json`: the manifest + lists no packages, so there are no library dependencies to + trust beyond the Lean toolchain itself. +- Lean's core library, including the compiled code it puts + in place of some definitions the proofs reason about. +- The operating system the program runs on. +- That `SPEC.md` states the reading of the PNG specification + you intend. The proofs are about the parser, printer, and + header reader in this repo following each other exactly; + the fidelity of the spec to the PNG specification is a + reading, pinned in `SPEC.md` where the PNG specification + leaves a choice open. +- Python's standard library, including zlib, as the oracle + in the tests. The results file names the versions used. +- The line handling in `Main.lean` (splitting a request + line, reading decimal codes). The tests drive it, and no + proof covers it. +- There are no exceptions to item 4 of the bar: the project + uses none of the features it forbids. ## Re-check it -Needs Linux, elan, and Python 3.11 or later. - -``` -lake build -grep -rn "sorry" --include="*.lean" Png Proofs Main.lean Png.lean Proofs.lean -python3 tests/run_tests.py -``` - -The grep should print nothing: there is no `sorry` anywhere. The test script rebuilds the tool, builds real PNG files with Python zlib, runs hand-built chunk cases, runs the differential tests with a fixed seed, and writes the counts to `tests/TEST-RESULTS.md`. +1. `lake comparator --paranoid`: builds the challenge file + `Spec.lean` and the proofs in a sandbox and checks that + each theorem named in `comparator.json` proves exactly + the statement in the challenge file, uses no axiom but + the three standard ones, and is accepted by Lean's kernel + and every other kernel shipped with Lean. +2. `python3 tests/run_tests.py`: the real files, the hand + built cases, the differential runs against the oracle, + the mutations, and the header sections, with counts and + any failures written to `tests/TEST-RESULTS.md`. +3. `lake build`: the library, the proofs, and the tool build + clean, and `lake env lean Proofs/Verify.lean` prints the + axiom list for the headline theorems. +4. Read `SPEC.md` against the PNG specification and check + the pinned readings are the ones you want. diff --git a/SPEC.md b/SPEC.md index 9ae055d..7515d58 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1,6 +1,6 @@ # Specification: png -A PNG chunk parser and printer in Lean, with (slice 2) a decoder for the IHDR header. It does not decode pixels, and it does not inflate IDAT data. A reviewer who did not write this specification approves it, together with any statement file a reviewer adds later. No such file exists yet, so nothing here counts toward the verified bar yet. +A PNG chunk parser and printer in Lean, with (slice 2) a decoder for the IHDR header. It does not decode pixels, and it does not inflate IDAT data. The claims below are stated as Lean theorems in `Spec.lean`. A reviewer who did not write this specification or those statements approves them; no reviewer has yet, so nothing here counts toward the verified bar. Bytes are `List UInt8` throughout. @@ -81,6 +81,31 @@ No claim is made that the parser accepts every file the rules above allow, or re The round trip needs no fact about CRC-32 itself: the parser and the printer compute the same function of the same bytes, so a printed file passes its own CRC checks by construction. That the function is the standard CRC-32 rests on the differential tests against Python zlib's `crc32`. +## Properties and their theorems + +Each property above names its theorem. The theorems are stated in `Spec.lean` and proved in `Proofs/SpecProofs.lean`, under the same names. `comparator.json` lists all of them. + +| Property | Theorem | +|----------|---------| +| Parsing the printed form of a well formed chunk list gives the list back | `Spec.parse_print` | +| Reading the printed bytes of a well formed IHDR header gives the header back | `Spec.parseIhdr_print` | +| The depth checker agrees with the written out table of allowed (colour type, bit depth) pairs on every byte pair | `Spec.checkPair_iff` | +| Printing a fully well formed document and reading it back at both layers gives the document back, header included | `Spec.parseFile_print` | +| A file whose first signature byte is wrong is refused | `Spec.rejects_bad_signature` | +| A file whose IHDR chunk carries a CRC that does not match its type and data is refused | `Spec.rejects_bad_crc` | +| A file cut short inside its first chunk is refused | `Spec.rejects_truncated_input` | +| A file whose first chunk holds 12 data bytes is refused: an IHDR chunk holds 13 | `Spec.rejects_first_chunk_length_12` | +| A file with a width of zero is refused | `Spec.rejects_zero_width` | +| A file with a height of zero is refused | `Spec.rejects_zero_height` | +| A file with a width of 2^31 is refused: the width stays below 2^31 | `Spec.rejects_width_at_cap` | +| A file with a height of 2^31 is refused: the height stays below 2^31 | `Spec.rejects_height_at_cap` | +| A file with bit depth 4 on colour type 2 is refused: that colour type allows depths 8 and 16 only | `Spec.rejects_illegal_depth` | +| A file with colour type 1 is refused: 1 is not a PNG colour type, and allows no bit depth | `Spec.rejects_unknown_colour_type` | +| A file with an interlace byte of 2 is refused: the byte is 0 or 1 | `Spec.rejects_unknown_interlace` | +| A file with a compression byte of 1 is refused: the only compression method is 0 | `Spec.rejects_nonzero_compression` | +| A file with a filter byte of 1 is refused: the only filter method is 0 | `Spec.rejects_nonzero_filter` | +| The header reader refuses anything but exactly 13 bytes: here a valid header's first 12 bytes | `Spec.rejects_header_wrong_length` | + ## The command-line tool `Main.lean` reads one request per line. The operations are `parse` and `header`: the request is the operation word, a space, and the byte codes of a PNG file as comma-separated decimal numbers. The `parse` answer is `ok:` followed by the byte codes of the canonical reprint of the parsed chunk list, or `none` when the file is rejected. The `header` answer is `ok:` followed by the codes of a text line naming the decoded header fields, or `none` when the file is rejected at either layer. The text is the width in decimal, the letter `x`, the height in decimal, a space, the letter `d`, the bit depth in decimal, a space, the letter `t`, the colour type in decimal, a space, the letter `i`, and the interlace method in decimal: for a 1920 by 1080 file at bit depth 8, colour type 6, no interlace, the text is `1920x1080 d8 t6 i0`, and the answer carries the character codes of that text. A malformed request line prints `error`. A code above 255 makes the request malformed, since no byte holds it. diff --git a/Spec.lean b/Spec.lean new file mode 100644 index 0000000..7ccb420 --- /dev/null +++ b/Spec.lean @@ -0,0 +1,164 @@ +/- + The specification as Lean statements: the challenge file. + + A reviewer who did not write this file approves it, and `bar.toml` + records the SHA-256 it had then. The proofs live in `Proofs` + (`Proofs/SpecProofs.lean`); `lake comparator` checks that each one + proves exactly the statement written here. The `sorry` below is + expected in this file only. + + The statements use only the definitions in `Png/Value.lean`, + `Png/Parse.lean`, `Png/Crc32.lean`, and `Png/Header.lean`, the + code under proof named in `bar.toml`. No definition of their own + is needed, so this project has no `Spec/Defs.lean`. +-/ + +import Png + +open Png + +/-! ## The round trips and the table + +The statements the slice stands on. Each one is the statement of +the proved theorem of the same shape in `Proofs/RoundTrip.lean` +or `Proofs/Header.lean`, over the same well formedness +predicates, `Png.WF`, `Png.WFHeader`, and `Png.WFFull`. -/ + +/-- Parsing the printed form of a well formed chunk list gives + the list back. So a printer that wrote nothing, or wrote + something shorter, fails this statement. -/ +theorem Spec.parse_print {doc : Doc} (h : WF doc) : + parse (print doc) = some doc := sorry + +/-- Reading the printed bytes of a well formed IHDR header gives + the header back. -/ +theorem Spec.parseIhdr_print (h : Ihdr) (hwf : WFHeader h) : + parseIhdr (printIhdr h) = some h := sorry + +/-- The depth checker agrees with the written out table of + allowed (colour type, bit depth) pairs on every byte pair. -/ +theorem Spec.checkPair_iff (ct bd : UInt8) : + checkPair ct bd = true ↔ + (ct.toNat, bd.toNat) ∈ allowedPairs := sorry + +/-- Printing a fully well formed document and reading it back at + both layers gives the document back, with its header. -/ +theorem Spec.parseFile_print {doc : Doc} (h : WFFull doc) : + ∃ hd : Ihdr, parseFile (print doc) = some (hd, doc) := sorry + +/-! ## Refused inputs + +Single inputs the reader refuses, one for each headline rule of +`SPEC.md`. Each input is one of the named rejection cases in the +test harness: the chunk layer cases stand as the harness builds +them for its `parse` requests, and the header cases are whole +files whose CRCs are recomputed, as the harness builds them for +its `header` requests, so the refusal is for the header rule +itself. The differential tests cover these rules on many more +inputs; these statements pin them so a reader of the +specification can check each rule against one concrete case. -/ + +/-- A file whose first signature byte is wrong is refused. -/ +theorem Spec.rejects_bad_signature : + (parse ([118, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, + 68, 82, 0, 0, 0, 4, 0, 0, 0, 3, 8, 6, 0, 0, 0, 180, 244, 174, + 198, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130] + : List UInt8)).isSome = false := sorry + +/-- A file whose IHDR chunk carries a CRC that does not match + its type and data is refused. -/ +theorem Spec.rejects_bad_crc : + (parse ([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, + 68, 82, 0, 0, 0, 4, 0, 0, 0, 3, 8, 6, 0, 0, 0, 180, 244, 174, + 57, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130] + : List UInt8)).isSome = false := sorry + +/-- A file cut short inside its first chunk is refused: the + first twenty bytes of a valid file, and no more. -/ +theorem Spec.rejects_truncated_input : + (parse ([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, + 68, 82, 0, 0, 0, 4] : List UInt8)).isSome = false := sorry + +/-- A file whose first chunk holds 12 data bytes is refused: an + IHDR chunk holds 13. -/ +theorem Spec.rejects_first_chunk_length_12 : + (parse ([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 12, 73, 72, + 68, 82, 0, 0, 0, 4, 0, 0, 0, 3, 8, 6, 0, 0, 240, 122, 129, + 218, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130] + : List UInt8)).isSome = false := sorry + +/-- A file with a width of zero is refused. -/ +theorem Spec.rejects_zero_width : + (parseFile ([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, + 72, 68, 82, 0, 0, 0, 0, 0, 0, 0, 3, 8, 6, 0, 0, 0, 189, 31, + 14, 188, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130] + : List UInt8)).isSome = false := sorry + +/-- A file with a height of zero is refused. -/ +theorem Spec.rejects_zero_height : + (parseFile ([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, + 72, 68, 82, 0, 0, 0, 4, 0, 0, 0, 0, 8, 6, 0, 0, 0, 50, 96, + 220, 104, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130] + : List UInt8)).isSome = false := sorry + +/-- A file with a width of 2^31 is refused: the width stays + below 2^31. -/ +theorem Spec.rejects_width_at_cap : + (parseFile ([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, + 72, 68, 82, 128, 0, 0, 0, 0, 0, 0, 3, 8, 6, 0, 0, 0, 29, 117, + 43, 171, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130] + : List UInt8)).isSome = false := sorry + +/-- A file with a height of 2^31 is refused: the height stays + below 2^31. -/ +theorem Spec.rejects_height_at_cap : + (parseFile ([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, + 72, 68, 82, 0, 0, 0, 4, 128, 0, 0, 0, 8, 6, 0, 0, 0, 84, 53, + 220, 39, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130] + : List UInt8)).isSome = false := sorry + +/-- A file with bit depth 4 on colour type 2 is refused: that + colour type allows depths 8 and 16 only. -/ +theorem Spec.rejects_illegal_depth : + (parseFile ([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, + 72, 68, 82, 0, 0, 0, 4, 0, 0, 0, 3, 4, 2, 0, 0, 0, 254, 102, + 212, 144, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130] + : List UInt8)).isSome = false := sorry + +/-- A file with colour type 1 is refused: 1 is not a PNG colour + type, and allows no bit depth. -/ +theorem Spec.rejects_unknown_colour_type : + (parseFile ([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, + 72, 68, 82, 0, 0, 0, 4, 0, 0, 0, 3, 8, 1, 0, 0, 0, 41, 35, + 150, 127, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130] + : List UInt8)).isSome = false := sorry + +/-- A file with an interlace byte of 2 is refused: the byte is + 0 or 1. -/ +theorem Spec.rejects_unknown_interlace : + (parseFile ([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, + 72, 68, 82, 0, 0, 0, 4, 0, 0, 0, 3, 8, 6, 0, 0, 2, 90, 250, + 207, 234, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130] + : List UInt8)).isSome = false := sorry + +/-- A file with a compression byte of 1 is refused: the only + compression method is 0. -/ +theorem Spec.rejects_nonzero_compression : + (parseFile ([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, + 72, 68, 82, 0, 0, 0, 4, 0, 0, 0, 3, 8, 6, 1, 0, 0, 181, 54, + 196, 241, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130] + : List UInt8)).isSome = false := sorry + +/-- A file with a filter byte of 1 is refused: the only filter + method is 0. -/ +theorem Spec.rejects_nonzero_filter : + (parseFile ([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, + 72, 68, 82, 0, 0, 0, 4, 0, 0, 0, 3, 8, 6, 0, 1, 0, 173, 239, + 159, 135, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130] + : List UInt8)).isSome = false := sorry + +/-- The header reader refuses anything but exactly 13 bytes: + here a valid header's first 12 bytes. -/ +theorem Spec.rejects_header_wrong_length : + (parseIhdr ([0, 0, 0, 4, 0, 0, 0, 3, 8, 6, 0, 0] + : List UInt8)).isSome = false := sorry diff --git a/bar.toml b/bar.toml new file mode 100644 index 0000000..ce857db --- /dev/null +++ b/bar.toml @@ -0,0 +1,49 @@ +# Read by bar/check.py in Proven-Commons/mission. +# See playbook/verified-bar.md there for what each field means. + +[project] +name = "png" +# "in-progress" until every bar item passes, then "verified". +status = "in-progress" +page = "README.md" +# GitHub handles of everyone who wrote the specification. +spec_authors = ["abu-aska"] +# The program people run. A change to it voids the approval. +entry_points = ["Main.lean"] + +[spec] +files = ["SPEC.md", "Spec.lean"] +# The code under proof: the functions the statements are about. +code = [ + # The data types the statements are about. + "Png.Chunk", "Png.Doc", "Png.Ihdr", + # The byte forms and the printer. + "Png.signature", "Png.Chunk.typeBytes", "Png.isLetter", + "Png.ihdrType", "Png.iendType", "Png.iendChunk", "Png.isIEND", + "Png.be32", "Png.u32", "Png.chunkCrc", "Png.printChunk", + "Png.print", + # The CRC-32 the chunk checks use. + "Png.Crc32.step", "Png.Crc32.step8", "Png.Crc32.table", + "Png.Crc32.update", "Png.Crc32.crc32", + # Well formedness at the chunk layer. + "Png.WFChunk", "Png.WF", + # The chunk readers and the parser. + "Png.get4", "Png.parseChunk", "Png.parseChunks", "Png.parse", + # The IHDR header: the allowed table, the check, well + # formedness, the printer, the reader, and the file level read. + "Png.allowedDepths", "Png.pairAllowed", "Png.allowedPairs", + "Png.checkPair", "Png.WFHeader", "Png.printIhdr", + "Png.parseIhdr", "Png.headerOf", "Png.parseFile", "Png.WFFull", +] + +# One entry per approval, added in a pull request the reviewer approves. +# [[spec.approval]] +# reviewer = "github-handle" +# date = "2026-11-02" +# sha256 = "..." +# record = "https://github.com/Proven-Commons/png/pull/" + +[tests] +results = "tests/TEST-RESULTS.md" +command = "python3 tests/run_tests.py" +files = ["tests/run_tests.py"] diff --git a/comparator.json b/comparator.json new file mode 100644 index 0000000..46b2955 --- /dev/null +++ b/comparator.json @@ -0,0 +1 @@ +{"challenge_module":"Spec","permitted_axioms":["propext","Classical.choice","Quot.sound"],"solution_module":"Proofs","theorem_names":["Spec.parse_print","Spec.parseIhdr_print","Spec.checkPair_iff","Spec.parseFile_print","Spec.rejects_bad_signature","Spec.rejects_bad_crc","Spec.rejects_truncated_input","Spec.rejects_first_chunk_length_12","Spec.rejects_zero_width","Spec.rejects_zero_height","Spec.rejects_width_at_cap","Spec.rejects_height_at_cap","Spec.rejects_illegal_depth","Spec.rejects_unknown_colour_type","Spec.rejects_unknown_interlace","Spec.rejects_nonzero_compression","Spec.rejects_nonzero_filter","Spec.rejects_header_wrong_length"]} \ No newline at end of file diff --git a/lakefile.toml b/lakefile.toml index 33d541c..03a3e2b 100644 --- a/lakefile.toml +++ b/lakefile.toml @@ -8,6 +8,9 @@ name = "Png" [[lean_lib]] name = "Proofs" +[[lean_lib]] +name = "Spec" + [[lean_exe]] name = "png" root = "Main" From bb03cb186e578b6ebcfbf2e50581dff9a1bc1aa1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 19:42:42 +0000 Subject: [PATCH 04/14] parseFile_print: return the document's own header, not some header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both reviews of png#3 found that parseFile_print concluded `∃ hd, parseFile (print doc) = some (hd, doc)`, which a reader that answers a fixed header, or swaps width and height, also meets. WFFull now takes the header as a parameter, `WFFull hd doc`, and parseFile_print concludes `parseFile (print doc) = some (hd, doc)` in Proofs/Header.lean, Spec.lean and Proofs/SpecProofs.lean. The proof is the old one without the `refine ⟨hd, ?_⟩` step. Checked outside the repo: the wrongRead probe from the review (every header replaced by 1x1 d8 t0 i0) and a reader that swaps width and height no longer meet the statement. Lean rejects the review's proof script, and both readers provably fail it on a 2x1 header. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AovXVgJBHGiN44TxQM4dcd --- Png/Header.lean | 15 ++++++++------- Proofs/Header.lean | 21 ++++++++++----------- Proofs/SpecProofs.lean | 4 ++-- README.md | 12 ++++++++---- SPEC.md | 4 ++-- Spec.lean | 11 +++++++---- 6 files changed, 37 insertions(+), 30 deletions(-) diff --git a/Png/Header.lean b/Png/Header.lean index 52efc3c..4274cdf 100644 --- a/Png/Header.lean +++ b/Png/Header.lean @@ -128,13 +128,14 @@ def parseFile (bs : List UInt8) : Option (Ihdr × Doc) := | some doc => (headerOf doc).map fun h => (h, doc) | none => none -/-- A document is fully well formed when it is well formed at - the chunk layer (the `WF` of slice 1), its first chunk's - data is the printed form of some header, and that header is - well formed. Stated with the witnesses out, in the shape of - `WF`. -/ -def WFFull (doc : Doc) : Prop := - ∃ (hd : Ihdr) (head : Chunk) (mid : List Chunk), +/-- A document is fully well formed, with header `hd`, when it + is well formed at the chunk layer (the `WF` of slice 1), its + first chunk's data is the printed form of `hd`, and `hd` is + well formed. The header is a parameter, not a witness, so a + statement over `WFFull hd doc` can say which header a reader + must return. Stated in the shape of `WF`. -/ +def WFFull (hd : Ihdr) (doc : Doc) : Prop := + ∃ (head : Chunk) (mid : List Chunk), doc = head :: mid ++ [iendChunk] ∧ head.typeBytes = ihdrType ∧ head.data = printIhdr hd ∧ WFHeader hd ∧ diff --git a/Proofs/Header.lean b/Proofs/Header.lean index 4fcd6c6..b997635 100644 --- a/Proofs/Header.lean +++ b/Proofs/Header.lean @@ -17,9 +17,9 @@ The header round trip is `parseIhdr_print`. The file level claim is `parseFile_print`: a fully well formed document - (chunk layer well formed, first chunk the printed form of a - well formed header) prints and reads back whole, header - included. + with header `hd` (chunk layer well formed, first chunk the + printed form of the well formed header `hd`) prints and reads + back whole, and the header the read returns is `hd` itself. -/ import Png @@ -121,19 +121,18 @@ theorem checkPair_iff (ct bd : UInt8) : /-- A fully well formed document is well formed at the chunk layer. -/ -theorem wfFull_wf {doc : Doc} (h : WFFull doc) : WF doc := by - obtain ⟨hd, head, mid, hdoc, htype, hdata, -, hall, hnot⟩ := h +theorem wfFull_wf {hd : Ihdr} {doc : Doc} (h : WFFull hd doc) : WF doc := by + obtain ⟨head, mid, hdoc, htype, hdata, -, hall, hnot⟩ := h exact ⟨head, mid, hdoc, htype, by rw [hdata]; exact printIhdr_length hd, hall, hnot⟩ /-- The file level round trip: printing a fully well formed - document and reading the file back at both layers gives the - document back, with its header. -/ -theorem parseFile_print {doc : Doc} (h : WFFull doc) : - ∃ hd : Ihdr, parseFile (print doc) = some (hd, doc) := by + document with header `hd` and reading the file back at both + layers gives back `hd` itself and the document. -/ +theorem parseFile_print {hd : Ihdr} {doc : Doc} (h : WFFull hd doc) : + parseFile (print doc) = some (hd, doc) := by have hwf : WF doc := wfFull_wf h - obtain ⟨hd, head, mid, hdoc, htype, hdata, hwfhd, hall, hnot⟩ := h - refine ⟨hd, ?_⟩ + obtain ⟨head, mid, hdoc, htype, hdata, hwfhd, hall, hnot⟩ := h unfold parseFile rw [parse_print hwf] show (headerOf doc).map (fun x => (x, doc)) = some (hd, doc) diff --git a/Proofs/SpecProofs.lean b/Proofs/SpecProofs.lean index f0809d9..c2b175e 100644 --- a/Proofs/SpecProofs.lean +++ b/Proofs/SpecProofs.lean @@ -26,8 +26,8 @@ theorem Spec.checkPair_iff (ct bd : UInt8) : (ct.toNat, bd.toNat) ∈ allowedPairs := Png.Proofs.checkPair_iff ct bd -theorem Spec.parseFile_print {doc : Doc} (h : WFFull doc) : - ∃ hd : Ihdr, parseFile (print doc) = some (hd, doc) := +theorem Spec.parseFile_print {hd : Ihdr} {doc : Doc} (h : WFFull hd doc) : + parseFile (print doc) = some (hd, doc) := Png.Proofs.parseFile_print h diff --git a/README.md b/README.md index a1feb33..c427a32 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ same names. `comparator.json` lists all of them. | Parsing the printed form of a well formed chunk list gives the list back. | `Spec.parse_print` | | Reading the printed bytes of a well formed IHDR header gives the header back. | `Spec.parseIhdr_print` | | The depth checker agrees with the written out table of allowed (colour type, bit depth) pairs on every byte pair. | `Spec.checkPair_iff` | -| Printing a fully well formed document and reading it back at both layers gives the document back, header included. | `Spec.parseFile_print` | +| Printing a fully well formed document whose first chunk holds the printed header `hd`, and reading it back at both layers, gives back `hd` itself and the document. | `Spec.parseFile_print` | | A file whose first signature byte is wrong is refused. | `Spec.rejects_bad_signature` | | A file whose IHDR chunk carries a CRC that does not match its type and data is refused. | `Spec.rejects_bad_crc` | | A file cut short inside its first chunk is refused. | `Spec.rejects_truncated_input` | @@ -84,9 +84,13 @@ IEND chunk; every chunk type is four ASCII letters; and every data length is below 2^31. A header is well formed when its width and height are above zero and below 2^31, its interlace byte is 0 or 1, and its bit depth is one its colour type -allows. A document is fully well formed when it is well -formed at the chunk layer and its first chunk's data is the -printed form of a well formed header. +allows. A document is fully well formed with header `hd` +(`WFFull hd doc`) when it is well formed at the chunk layer and +its first chunk's data is the printed form of `hd`, a well +formed header. The header is a parameter of the predicate, so +the file level round trip names the header the read must +return: a reader that answered a fixed header, or swapped the +width and the height, would not meet it. The headline theorems rest on the proofs in `Proofs/RoundTrip.lean` and `Proofs/Header.lean`, where they diff --git a/SPEC.md b/SPEC.md index 7515d58..6e7b389 100644 --- a/SPEC.md +++ b/SPEC.md @@ -75,7 +75,7 @@ Slice 2 adds three proved claims: - The header round trip: reading the printed bytes of a well formed header gives the header back. Well formed means the width and height are above zero and below 2^31, the interlace byte is 0 or 1, and the bit depth is one the colour type allows. - The table is exact: the depth checker agrees with the table above, written out as a list of (colour type, bit depth) pairs, on every colour type byte and every bit depth byte. -- The file level round trip: a document that is well formed at the chunk layer and whose first chunk's data is the printed form of a well formed header prints and reads back whole, header included. +- The file level round trip: a document that is well formed at the chunk layer and whose first chunk's data is the printed form of a well formed header `hd` prints and reads back whole, and the header the read returns is `hd` itself, not merely some header. No claim is made that the parser accepts every file the rules above allow, or rejects every file they forbid, beyond what the round trip needs. The test suite checks that behaviour against real PNG files built with Python's zlib and against a strict reading of these rules, and the results record the counts. @@ -90,7 +90,7 @@ Each property above names its theorem. The theorems are stated in `Spec.lean` an | Parsing the printed form of a well formed chunk list gives the list back | `Spec.parse_print` | | Reading the printed bytes of a well formed IHDR header gives the header back | `Spec.parseIhdr_print` | | The depth checker agrees with the written out table of allowed (colour type, bit depth) pairs on every byte pair | `Spec.checkPair_iff` | -| Printing a fully well formed document and reading it back at both layers gives the document back, header included | `Spec.parseFile_print` | +| Printing a fully well formed document whose first chunk holds the printed header `hd`, and reading it back at both layers, gives back `hd` itself and the document | `Spec.parseFile_print` | | A file whose first signature byte is wrong is refused | `Spec.rejects_bad_signature` | | A file whose IHDR chunk carries a CRC that does not match its type and data is refused | `Spec.rejects_bad_crc` | | A file cut short inside its first chunk is refused | `Spec.rejects_truncated_input` | diff --git a/Spec.lean b/Spec.lean index 7ccb420..ed97445 100644 --- a/Spec.lean +++ b/Spec.lean @@ -41,10 +41,13 @@ theorem Spec.checkPair_iff (ct bd : UInt8) : checkPair ct bd = true ↔ (ct.toNat, bd.toNat) ∈ allowedPairs := sorry -/-- Printing a fully well formed document and reading it back at - both layers gives the document back, with its header. -/ -theorem Spec.parseFile_print {doc : Doc} (h : WFFull doc) : - ∃ hd : Ihdr, parseFile (print doc) = some (hd, doc) := sorry +/-- Printing a fully well formed document with header `hd` and + reading it back at both layers gives back that header, `hd` + itself, and the document. So a reader that returned some + other header, a fixed one or one with the width and height + swapped, fails this statement. -/ +theorem Spec.parseFile_print {hd : Ihdr} {doc : Doc} (h : WFFull hd doc) : + parseFile (print doc) = some (hd, doc) := sorry /-! ## Refused inputs From cdbd8046b1f72f1bb5099fcbb074ace9d9261813 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 19:49:02 +0000 Subject: [PATCH 05/14] Refuse a second IHDR chunk in the file level read The PNG specification (third edition, 11.2) allows one IHDR chunk, and it comes first. parseFile accepted a later IHDR and `header` answered the first one's fields, as review 1 item 4 found. - headerOf answers none when any chunk after the first has type IHDR (new Png.isIHDR), so parseFile and the `header` request refuse the file. The chunk layer, `parse`, is unchanged and still reads a second IHDR as an ordinary chunk. - WFFull gains the matching clause: no chunk after the first has type IHDR. parseFile_print still holds, with the clause used to show the later chunks hold no IHDR. - Spec.rejects_second_ihdr pins the review's file (IHDR 1x1, IHDR 2x2, IDAT, IEND), checked with decide +kernel. - tests: a chunk order section with both cases (a second IHDR right after the first, and one after IDAT), each refused by `header` and read by `parse`, plus two IEND-with-data cases. - SPEC.md and README.md say so. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AovXVgJBHGiN44TxQM4dcd --- Png/Header.lean | 31 +++++++++++++++------ Proofs/Header.lean | 13 +++++++-- Proofs/SpecProofs.lean | 11 ++++++++ README.md | 3 +- SPEC.md | 5 +++- Spec.lean | 12 ++++++++ comparator.json | 2 +- tests/run_tests.py | 63 ++++++++++++++++++++++++++++++++++++++++-- 8 files changed, 122 insertions(+), 18 deletions(-) diff --git a/Png/Header.lean b/Png/Header.lean index 4274cdf..cba0b12 100644 --- a/Png/Header.lean +++ b/Png/Header.lean @@ -29,6 +29,11 @@ Colour types other than 0, 2, 3, 4, 6 are not PNG colour types, and allow no depth. + + The file level read also turns a file away when a chunk after + the first has type IHDR: the PNG specification allows one IHDR + chunk, and it comes first. The chunk layer of slice 1 does not + look at this; only the file level read does. -/ import Png.Parse @@ -111,18 +116,24 @@ def parseIhdr (bs : List UInt8) : Option Ihdr := else none | _ => none +/-- Is this chunk an IHDR chunk, by its type bytes? -/ +def isIHDR (c : Chunk) : Bool := decide (c.typeBytes = ihdrType) + /-- The decoded header of a chunk list: the first chunk's data - read as a header, or `none` when there is no first chunk or - its data does not read as one. -/ + read as a header. It is `none` when there is no first chunk, + when any later chunk is also of type IHDR (the PNG + specification allows one IHDR chunk, and it comes first), or + when the first chunk's data does not read as a header. -/ def headerOf (doc : Doc) : Option Ihdr := match doc with - | c :: _ => parseIhdr c.data + | c :: rest => if rest.any isIHDR then none else parseIhdr c.data | [] => none /-- Read a whole file at both layers: the chunk list of slice 1, and the header decoded from the first chunk's data. A file whose first chunk is not a valid IHDR, in the slice 2 sense, - is rejected here even when slice 1 accepts it. -/ + or that holds a second IHDR chunk, is rejected here even when + slice 1 accepts it. -/ def parseFile (bs : List UInt8) : Option (Ihdr × Doc) := match parse bs with | some doc => (headerOf doc).map fun h => (h, doc) @@ -130,16 +141,18 @@ def parseFile (bs : List UInt8) : Option (Ihdr × Doc) := /-- A document is fully well formed, with header `hd`, when it is well formed at the chunk layer (the `WF` of slice 1), its - first chunk's data is the printed form of `hd`, and `hd` is - well formed. The header is a parameter, not a witness, so a - statement over `WFFull hd doc` can say which header a reader - must return. Stated in the shape of `WF`. -/ + first chunk's data is the printed form of `hd`, `hd` is well + formed, and no chunk after the first has type IHDR. The + header is a parameter, not a witness, so a statement over + `WFFull hd doc` can say which header a reader must return. + Stated in the shape of `WF`. -/ def WFFull (hd : Ihdr) (doc : Doc) : Prop := ∃ (head : Chunk) (mid : List Chunk), doc = head :: mid ++ [iendChunk] ∧ head.typeBytes = ihdrType ∧ head.data = printIhdr hd ∧ WFHeader hd ∧ (∀ c ∈ doc, WFChunk c) ∧ - (∀ c ∈ head :: mid, isIEND c = false) + (∀ c ∈ head :: mid, isIEND c = false) ∧ + (∀ c ∈ mid, c.typeBytes ≠ ihdrType) end Png diff --git a/Proofs/Header.lean b/Proofs/Header.lean index b997635..2053317 100644 --- a/Proofs/Header.lean +++ b/Proofs/Header.lean @@ -122,7 +122,7 @@ theorem checkPair_iff (ct bd : UInt8) : /-- A fully well formed document is well formed at the chunk layer. -/ theorem wfFull_wf {hd : Ihdr} {doc : Doc} (h : WFFull hd doc) : WF doc := by - obtain ⟨head, mid, hdoc, htype, hdata, -, hall, hnot⟩ := h + obtain ⟨head, mid, hdoc, htype, hdata, -, hall, hnot, -⟩ := h exact ⟨head, mid, hdoc, htype, by rw [hdata]; exact printIhdr_length hd, hall, hnot⟩ @@ -132,11 +132,18 @@ theorem wfFull_wf {hd : Ihdr} {doc : Doc} (h : WFFull hd doc) : WF doc := by theorem parseFile_print {hd : Ihdr} {doc : Doc} (h : WFFull hd doc) : parseFile (print doc) = some (hd, doc) := by have hwf : WF doc := wfFull_wf h - obtain ⟨head, mid, hdoc, htype, hdata, hwfhd, hall, hnot⟩ := h + obtain ⟨head, mid, hdoc, htype, hdata, hwfhd, hall, hnot, hone⟩ := h + have hany : (mid ++ [iendChunk]).any isIHDR = false := by + rw [List.any_eq_false] + intro c hc + rcases List.mem_append.mp hc with hm | hm + · simpa [isIHDR] using hone c hm + · rw [List.mem_singleton.mp hm] + decide unfold parseFile rw [parse_print hwf] show (headerOf doc).map (fun x => (x, doc)) = some (hd, doc) rw [hdoc] - simp [headerOf, hdata, parseIhdr_print hd hwfhd] + simp [headerOf, hany, hdata, parseIhdr_print hd hwfhd] end Png.Proofs diff --git a/Proofs/SpecProofs.lean b/Proofs/SpecProofs.lean index c2b175e..ce0321f 100644 --- a/Proofs/SpecProofs.lean +++ b/Proofs/SpecProofs.lean @@ -133,6 +133,17 @@ theorem Spec.rejects_nonzero_filter : decide +kernel +theorem Spec.rejects_second_ihdr : + (parseFile ([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, + 72, 68, 82, 0, 0, 0, 1, 0, 0, 0, 1, 8, 2, 0, 0, 0, 144, 119, + 83, 222, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 2, 0, 0, 0, 2, + 8, 2, 0, 0, 0, 253, 212, 154, 115, 0, 0, 0, 12, 73, 68, 65, 84, + 120, 156, 99, 96, 96, 96, 0, 0, 0, 4, 0, 1, 246, 23, 56, 85, 0, + 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130] + : List UInt8)).isSome = false := by + decide +kernel + + theorem Spec.rejects_header_wrong_length : (parseIhdr ([0, 0, 0, 4, 0, 0, 0, 3, 8, 6, 0, 0] : List UInt8)).isSome = false := by diff --git a/README.md b/README.md index c427a32..42804c8 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,7 @@ same names. `comparator.json` lists all of them. | A file with an interlace byte of 2 is refused: the byte is 0 or 1. | `Spec.rejects_unknown_interlace` | | A file with a compression byte of 1 is refused: the only compression method is 0. | `Spec.rejects_nonzero_compression` | | A file with a filter byte of 1 is refused: the only filter method is 0. | `Spec.rejects_nonzero_filter` | +| A file with a second IHDR chunk is refused by the file level read: the PNG specification allows one IHDR chunk. | `Spec.rejects_second_ihdr` | | The header reader refuses anything but exactly 13 bytes: here a valid header's first 12 bytes. | `Spec.rejects_header_wrong_length` | A chunk list is well formed when it is a first chunk, then @@ -117,7 +118,7 @@ about. - That the CRC-32 here is the standard one. The round trip needs no such fact, since the parser and the printer compute the same function. The differential tests against Python zlib's `crc32` are what tie this function to the standard. - That the header reader accepts every header the rules in `SPEC.md` allow, and rejects every header they forbid, beyond what the round trip and the table theorem cover. The tests check that behaviour instead: hand built headers for every colour type and allowed depth, the full colour type and depth enumeration against the table, and the rejection cases. - Anything inside a chunk's data past the IHDR header. The data of every other chunk type is opaque here. IDAT data is not inflated. No pixel is ever decoded. -- Chunk order past the first and last. Which types may appear, how many times, and in what order between IHDR and IEND is not checked. A chunk of type IEND that carries data is read as an ordinary chunk. +- Chunk order past the first and last, except for IHDR. Which types may appear, how many times, and in what order between IHDR and IEND is not checked, with one exception: the file level read (`header`) refuses a second IHDR chunk. The chunk layer (`parse`) does not, and reads it as an ordinary chunk. A chunk of type IEND that carries data is read as an ordinary chunk at both layers. ## What you must trust diff --git a/SPEC.md b/SPEC.md index 6e7b389..fef0a4d 100644 --- a/SPEC.md +++ b/SPEC.md @@ -26,7 +26,7 @@ The CRC-32 is the one PNG and ZIP share: the ISO 3309 polynomial, reflected, the 3. Reading stops at a chunk whose type is IEND and whose data is empty. That chunk must be the end of the input: any byte after it rejects the file, and running out of input before such a chunk rejects the file. 4. The first chunk read has type IHDR and a data length of 13. -A chunk of type IEND that carries data is an ordinary chunk at this layer: reading does not stop at it. No other rule about chunk order is checked: which types may appear, how many times, or in what order past the first and last, is outside this slice. At this layer the contents of the IHDR data, past its length, are not checked: width, height, bit depth, and colour type are read as data like any other. The header read of slice 2, below, is a second read on top of this one and does check them. +A chunk of type IEND that carries data is an ordinary chunk at this layer: reading does not stop at it. No other rule about chunk order is checked at this layer: which types may appear, how many times, or in what order past the first and last, is outside it. The file level read of slice 2, below, adds one such rule: a single IHDR chunk. At this layer the contents of the IHDR data, past its length, are not checked: width, height, bit depth, and colour type are read as data like any other. The header read of slice 2, below, is a second read on top of this one and does check them. ## The IHDR header (slice 2) @@ -63,6 +63,8 @@ Printing a header writes the 13 bytes back: width and height big endian, then bi The file level read (`parseFile`) runs the chunk parse of slice 1 and then reads the first chunk's data as a header. A file whose first chunk is not a valid IHDR in this sense is rejected by that read even when the chunk layer accepts it: a file with a width of zero is the plainest case. +The file level read also rejects a file in which any chunk after the first has type IHDR. The PNG specification allows one IHDR chunk, and it comes first (PNG third edition, 11.2), so a second one, wherever it stands, is a rejection; the header is never read from it. The chunk layer alone (`parse`) still reads such a file, since it does not count chunks. + ## Printing The printer writes the signature, then each chunk as its length, its type bytes, its data, and the CRC it computes over the type bytes and the data. A file the parser accepts is reprinted byte for byte: the lengths and CRCs it carried were checked against the bytes, so printing writes the same values back. @@ -104,6 +106,7 @@ Each property above names its theorem. The theorems are stated in `Spec.lean` an | A file with an interlace byte of 2 is refused: the byte is 0 or 1 | `Spec.rejects_unknown_interlace` | | A file with a compression byte of 1 is refused: the only compression method is 0 | `Spec.rejects_nonzero_compression` | | A file with a filter byte of 1 is refused: the only filter method is 0 | `Spec.rejects_nonzero_filter` | +| A file with a second IHDR chunk is refused by the file level read | `Spec.rejects_second_ihdr` | | The header reader refuses anything but exactly 13 bytes: here a valid header's first 12 bytes | `Spec.rejects_header_wrong_length` | ## The command-line tool diff --git a/Spec.lean b/Spec.lean index ed97445..fa9fffa 100644 --- a/Spec.lean +++ b/Spec.lean @@ -160,6 +160,18 @@ theorem Spec.rejects_nonzero_filter : 159, 135, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130] : List UInt8)).isSome = false := sorry +/-- A file with a second IHDR chunk is refused by the file level + read: here a 1 by 1 IHDR, then a 2 by 2 IHDR, then IDAT and + IEND. The PNG specification allows one IHDR chunk. -/ +theorem Spec.rejects_second_ihdr : + (parseFile ([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, + 72, 68, 82, 0, 0, 0, 1, 0, 0, 0, 1, 8, 2, 0, 0, 0, 144, 119, + 83, 222, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 2, 0, 0, 0, 2, + 8, 2, 0, 0, 0, 253, 212, 154, 115, 0, 0, 0, 12, 73, 68, 65, 84, + 120, 156, 99, 96, 96, 96, 0, 0, 0, 4, 0, 1, 246, 23, 56, 85, 0, + 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130] + : List UInt8)).isSome = false := sorry + /-- The header reader refuses anything but exactly 13 bytes: here a valid header's first 12 bytes. -/ theorem Spec.rejects_header_wrong_length : diff --git a/comparator.json b/comparator.json index 46b2955..64f193b 100644 --- a/comparator.json +++ b/comparator.json @@ -1 +1 @@ -{"challenge_module":"Spec","permitted_axioms":["propext","Classical.choice","Quot.sound"],"solution_module":"Proofs","theorem_names":["Spec.parse_print","Spec.parseIhdr_print","Spec.checkPair_iff","Spec.parseFile_print","Spec.rejects_bad_signature","Spec.rejects_bad_crc","Spec.rejects_truncated_input","Spec.rejects_first_chunk_length_12","Spec.rejects_zero_width","Spec.rejects_zero_height","Spec.rejects_width_at_cap","Spec.rejects_height_at_cap","Spec.rejects_illegal_depth","Spec.rejects_unknown_colour_type","Spec.rejects_unknown_interlace","Spec.rejects_nonzero_compression","Spec.rejects_nonzero_filter","Spec.rejects_header_wrong_length"]} \ No newline at end of file +{"challenge_module":"Spec","permitted_axioms":["propext","Classical.choice","Quot.sound"],"solution_module":"Proofs","theorem_names":["Spec.parse_print","Spec.parseIhdr_print","Spec.checkPair_iff","Spec.parseFile_print","Spec.rejects_bad_signature","Spec.rejects_bad_crc","Spec.rejects_truncated_input","Spec.rejects_first_chunk_length_12","Spec.rejects_zero_width","Spec.rejects_zero_height","Spec.rejects_width_at_cap","Spec.rejects_height_at_cap","Spec.rejects_illegal_depth","Spec.rejects_unknown_colour_type","Spec.rejects_unknown_interlace","Spec.rejects_nonzero_compression","Spec.rejects_nonzero_filter","Spec.rejects_second_ihdr","Spec.rejects_header_wrong_length"]} \ No newline at end of file diff --git a/tests/run_tests.py b/tests/run_tests.py index 86d7af8..982a40a 100644 --- a/tests/run_tests.py +++ b/tests/run_tests.py @@ -42,9 +42,11 @@ 2^31, a height of 2^31, a width of 2^32 - 1, a height of 2^32 - 1, a compression byte of 1, a filter byte of 1, an interlace byte of 2, a first chunk of length 12) must be - rejected. Random well formed headers are built into whole - files and checked through `header` for their fields and - through `parse` for a byte for byte reprint. + rejected. A second IHDR chunk must be rejected by `header` + and read by `parse`; an IEND chunk with data is an ordinary + chunk at both layers. Random well formed headers are built + into whole files and checked through `header` for their + fields and through `parse` for a byte for byte reprint. python3 tests/run_tests.py [--seed N] [--count N] @@ -677,6 +679,60 @@ def run_header_mutations(): return lines, failures +def run_header_chunk_order(): + """Chunk order cases the file level read settles. A second + IHDR chunk, right after the first or after IDAT, is refused + by `header` (the PNG specification allows one IHDR chunk), + while `parse`, the chunk layer, still reads it as an ordinary + chunk. An IEND chunk that carries data is an ordinary chunk at + both layers: before IDAT, with a real IEND at the end, the + file reads; as the last chunk, with no real IEND after it, + the file is refused.""" + ihdr1 = make_chunk(b"IHDR", ihdr_full(1, 1, 8, 2)) + ihdr2 = make_chunk(b"IHDR", ihdr_full(2, 2, 8, 2)) + idat = make_chunk(b"IDAT", zlib.compress(b"\x00\x00\x00\x00")) + iend = make_chunk(b"IEND", b"") + iend_data = make_chunk(b"IEND", b"\x01\x02") + # (name, file, header answer, chunk layer accepts?) + cases = [ + ("a second IHDR right after the first", SIG + ihdr1 + ihdr2 + idat + iend, "none", True), + ("a second IHDR after IDAT", SIG + ihdr1 + idat + ihdr2 + iend, "none", True), + ( + "an IEND chunk with data before IDAT, a real IEND at the end", + SIG + ihdr1 + iend_data + idat + iend, + header_line(1, 1, 8, 2, 0), + True, + ), + ("an IEND chunk with data as the last chunk", SIG + ihdr1 + idat + iend_data, "none", False), + ] + lines = [] + failures = [] + answers, problems = ask_all([header_request(data) for _, data, _, _ in cases]) + for problem in problems: + failures.append(f"chunk order: tool problem: {problem}") + agree = 0 + for (name, _, want, _), ans in zip(cases, answers): + if ans == want: + agree += 1 + else: + failures.append(f"chunk order {name}, header: want {want!r}, got {ans!r}") + lines.append(f"Chunk order cases through `header`: {agree} of {len(cases)} agreements") + answers, problems = ask_all([request(data) for _, data, _, _ in cases]) + for problem in problems: + failures.append(f"chunk order, parse: tool problem: {problem}") + agree = 0 + for (name, data, _, chunk_ok), ans in zip(cases, answers): + want = ("ok:" + codes(data)) if chunk_ok else "none" + if ans == want: + agree += 1 + else: + failures.append(f"chunk order {name}, parse: want {want[:60]!r}, got {ans!r}") + lines.append( + f"The same cases through `parse`, the chunk layer: {agree} of {len(cases)} agreements" + ) + return lines, failures + + def run_header_random(seed, count=200): """Random well formed headers, built into whole files. Each file is checked through `header` for its fields and through @@ -782,6 +838,7 @@ def main(): run_header_types(), run_header_grid(), run_header_mutations(), + run_header_chunk_order(), run_header_random(args.seed), run_header_malformed(), ): From 10f2017cd7e8575f022741260e2c871a7af295eb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 19:54:57 +0000 Subject: [PATCH 06/14] Axiom check: read the axiom names, and cover every named theorem tests/run_tests.py failed the axiom check only on a nonzero exit or a line count other than 4. It never read the axiom names, so a proof that came to depend on sorryAx still gave "Failures: 0" (review of 437849b). - The harness now reads each `#print axioms` line and fails on any axiom but propext, Classical.choice and Quot.sound, and on a theorem with no line. - Proofs/Verify.lean prints the axioms of the four headline theorems and of every theorem comparator.json names; the harness takes the list from comparator.json, so a new theorem cannot be left out. - A self-test runs the check on planted output first: a sorryAx, an axiom of a proof's own, and a missing line must each fail. Checked by hand: with one decide +kernel proof in SpecProofs.lean replaced by sorry, the harness exits 1 with "depends on sorryAx". Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AovXVgJBHGiN44TxQM4dcd --- Proofs/Verify.lean | 32 ++++++++++++-- tests/run_tests.py | 108 ++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 125 insertions(+), 15 deletions(-) diff --git a/Proofs/Verify.lean b/Proofs/Verify.lean index 975c7df..ae8156f 100644 --- a/Proofs/Verify.lean +++ b/Proofs/Verify.lean @@ -1,15 +1,39 @@ /- - Axiom check for the headline theorem. Building this file prints - the axioms the proof depends on. The expected output is only the - three standard Lean axioms: `propext`, `Classical.choice`, + Axiom check. Building this file prints the axioms each proof + depends on: the headline theorems in `Png.Proofs`, then every + theorem `comparator.json` names, as proved in + `Proofs/SpecProofs.lean`. The expected output is only the three + standard Lean axioms: `propext`, `Classical.choice`, `Quot.sound`, or a subset of them. Any other name here breaks - the verified bar. + the verified bar. `tests/run_tests.py` reads this output and + fails on any other name, or on a theorem with no line. -/ import Proofs.RoundTrip import Proofs.Header +import Proofs.SpecProofs #print axioms Png.Proofs.parse_print #print axioms Png.Proofs.parseIhdr_print #print axioms Png.Proofs.checkPair_iff #print axioms Png.Proofs.parseFile_print + +#print axioms Spec.parse_print +#print axioms Spec.parseIhdr_print +#print axioms Spec.checkPair_iff +#print axioms Spec.parseFile_print +#print axioms Spec.rejects_bad_signature +#print axioms Spec.rejects_bad_crc +#print axioms Spec.rejects_truncated_input +#print axioms Spec.rejects_first_chunk_length_12 +#print axioms Spec.rejects_zero_width +#print axioms Spec.rejects_zero_height +#print axioms Spec.rejects_width_at_cap +#print axioms Spec.rejects_height_at_cap +#print axioms Spec.rejects_illegal_depth +#print axioms Spec.rejects_unknown_colour_type +#print axioms Spec.rejects_unknown_interlace +#print axioms Spec.rejects_nonzero_compression +#print axioms Spec.rejects_nonzero_filter +#print axioms Spec.rejects_second_ihdr +#print axioms Spec.rejects_header_wrong_length diff --git a/tests/run_tests.py b/tests/run_tests.py index 982a40a..6362a58 100644 --- a/tests/run_tests.py +++ b/tests/run_tests.py @@ -57,7 +57,9 @@ """ import argparse +import json import random +import re import struct import subprocess import sys @@ -807,6 +809,78 @@ def run_header_malformed(): return lines, failures +# ---------------------------------------------------------------- the +# axiom check. Proofs/Verify.lean prints `#print axioms` for the +# headline theorems in Png.Proofs and for every theorem +# comparator.json names. Each must have its line, and each may +# depend on these three axioms and no other: a `sorryAx`, or any +# axiom a proof adds of its own, is a failure. + +AXIOMS_ALLOWED = ("propext", "Classical.choice", "Quot.sound") + +HEADLINE = [ + "Png.Proofs.parse_print", + "Png.Proofs.parseIhdr_print", + "Png.Proofs.checkPair_iff", + "Png.Proofs.parseFile_print", +] + + +def axiom_names(): + """The theorems the axiom check covers: the headline theorems, + then every theorem comparator.json names.""" + config = json.loads((ROOT / "comparator.json").read_text(encoding="utf-8")) + return HEADLINE + list(config["theorem_names"]) + + +def read_axioms(text): + """The axioms of each theorem in `#print axioms` output, by + name. A list may run over several lines.""" + found = {} + for m in re.finditer(r"'([^']+)' depends on axioms: \[([^\]]*)\]", text): + found[m.group(1)] = [a.strip() for a in m.group(2).split(",") if a.strip()] + for m in re.finditer(r"'([^']+)' does not depend on any axioms", text): + found[m.group(1)] = [] + return found + + +def axiom_failures(found, names): + failures = [] + for name in names: + if name not in found: + failures.append(f"axiom check: no `#print axioms` output for {name}") + continue + extra = [a for a in found[name] if a not in AXIOMS_ALLOWED] + if extra: + failures.append( + f"axiom check: {name} depends on {', '.join(extra)}, " + "beyond propext, Classical.choice, and Quot.sound" + ) + return failures + + +def axiom_self_test(): + """The check itself, on planted output: a theorem on sorryAx, one + on an axiom of its own, and a missing line must each fail, and + clean output must pass. Returns the failures of the check.""" + names = ["A.clean", "A.none", "A.sorry", "A.own"] + planted = ( + "'A.clean' depends on axioms: [propext,\n Classical.choice, Quot.sound]\n" + "'A.none' does not depend on any axioms\n" + "'A.sorry' depends on axioms: [propext, sorryAx]\n" + "'A.own' depends on axioms: [Quot.sound, A.myAxiom]\n" + ) + got = axiom_failures(read_axioms(planted), names + ["A.missing"]) + want = [ + "axiom check: A.sorry depends on sorryAx, beyond propext, Classical.choice, and Quot.sound", + "axiom check: A.own depends on A.myAxiom, beyond propext, Classical.choice, and Quot.sound", + "axiom check: no `#print axioms` output for A.missing", + ] + if got != want: + return [f"axiom check self-test: planted output gave {got!r}"] + return [] + + def main(): ap = argparse.ArgumentParser() ap.add_argument("--seed", type=int, default=SEED_DEFAULT) @@ -854,9 +928,12 @@ def main(): timeout=120, ).stdout.strip() - # The axiom check: Proofs/Verify.lean prints the axioms of - # the four named theorems with `#print axioms`. Its output - # goes into the results file, in the Axiom check section. + # The axiom check: Proofs/Verify.lean prints the axioms of the + # headline theorems and of every theorem comparator.json names, + # with `#print axioms`. The names in that output are read, and + # any axiom beyond the three standard ones is a failure. The + # output goes into the results file, in the Axiom check section. + all_failures += axiom_self_test() axiom_run = subprocess.run( ["lake", "env", "lean", "Proofs/Verify.lean"], cwd=ROOT, @@ -864,14 +941,18 @@ def main(): text=True, timeout=600, ) - axiom_lines = [ - line for line in axiom_run.stdout.strip().splitlines() if line.strip() - ] - if axiom_run.returncode != 0 or len(axiom_lines) != 4: + if axiom_run.returncode != 0: all_failures.append( - "axiom check: running Proofs/Verify.lean gave " - f"status {axiom_run.returncode} and {len(axiom_lines)} lines" + f"axiom check: running Proofs/Verify.lean exited {axiom_run.returncode}" ) + covered = axiom_names() + found = read_axioms(axiom_run.stdout) + all_failures += axiom_failures(found, covered) + axiom_lines = [ + f"'{name}' depends on axioms: [{', '.join(found[name])}]" + for name in covered + if name in found + ] out = ["# Test results", ""] out.append("Written by `tests/run_tests.py`. Rerun it to check these numbers.") @@ -888,8 +969,13 @@ def main(): out.append("## Axiom check") out.append("") out.append( - "The output of `#print axioms` for the four named theorems, " - "from `lake env lean Proofs/Verify.lean`:" + f"The output of `#print axioms` for the {len(HEADLINE)} headline theorems " + f"and the {len(covered) - len(HEADLINE)} theorems `comparator.json` names, " + "from `lake env lean Proofs/Verify.lean`. The harness reads the names, and " + "fails on any axiom but " + + ", ".join(f"`{a}`" for a in AXIOMS_ALLOWED) + + ". A self-test on planted output (a `sorryAx`, an axiom of a proof's own, " + "a missing line) runs first." ) out.append("") for line in axiom_lines: From 7222ffa10d920b9e13dd6f75feca0f7c18e0feac Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 19:58:14 +0000 Subject: [PATCH 07/14] Prove the converse of each round trip, so rejection is proved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review 1 item 3 and the review of 437849b: no theorem tied the rejection branches of parseIhdr to the reader, and README.md said the round trip and the table theorem covered rejection, which neither does. New theorems, each on propext and Quot.sound only: - Png.Proofs.parseIhdr_sound (Proofs/Header.lean): parseIhdr bs = some h → WFHeader h ∧ printIhdr h = bs. - Png.Proofs.parse_sound (Proofs/Sound.lean): the chunk parser accepts only the printed form of a well formed chunk list. - Png.Proofs.parseFile_sound (Proofs/Sound.lean): the file level read accepts only `print doc` for a doc that is WFFull with the header it returns. - parseIhdr_iff, parse_iff and parseFile_iff join each to its round trip. Spec.lean states the three soundness theorems as Spec.parse_sound, Spec.parseIhdr_sound and Spec.parseFile_sound, proved in Proofs/SpecProofs.lean and named in comparator.json. README.md and SPEC.md now say each reader is pinned to its rule in both directions, and the "not proved" list keeps only what is left: that the rules are the PNG specification's. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AovXVgJBHGiN44TxQM4dcd --- Proofs.lean | 1 + Proofs/Header.lean | 94 +++++++++++++++- Proofs/Sound.lean | 242 +++++++++++++++++++++++++++++++++++++++++ Proofs/SpecProofs.lean | 20 +++- Proofs/Verify.lean | 7 ++ README.md | 34 +++++- SPEC.md | 13 ++- Spec.lean | 30 +++++ comparator.json | 2 +- tests/run_tests.py | 3 + 10 files changed, 431 insertions(+), 15 deletions(-) create mode 100644 Proofs/Sound.lean diff --git a/Proofs.lean b/Proofs.lean index 5a51e70..454834d 100644 --- a/Proofs.lean +++ b/Proofs.lean @@ -1,4 +1,5 @@ import Proofs.RoundTrip import Proofs.Header +import Proofs.Sound import Proofs.Verify import Proofs.SpecProofs diff --git a/Proofs/Header.lean b/Proofs/Header.lean index 2053317..0ce8d53 100644 --- a/Proofs/Header.lean +++ b/Proofs/Header.lean @@ -15,11 +15,15 @@ for every other colour type number both sides are empty. No case is left to computation over the whole grid. - The header round trip is `parseIhdr_print`. The file level - claim is `parseFile_print`: a fully well formed document + The header round trip is `parseIhdr_print`, and its converse + is `parseIhdr_sound`: a header the reader accepts is well + formed, and the bytes it read are its printed form. The file + level claim is `parseFile_print`: a fully well formed document with header `hd` (chunk layer well formed, first chunk the - printed form of the well formed header `hd`) prints and reads - back whole, and the header the read returns is `hd` itself. + printed form of the well formed header `hd`, no second IHDR) + prints and reads back whole, and the header the read returns + is `hd` itself. Its converse, `parseFile_sound`, is in + `Proofs/Sound.lean`. -/ import Png @@ -73,6 +77,88 @@ theorem parseIhdr_print (h : Ihdr) (hwf : WFHeader h) : rw [if_neg (by decide : ¬ ((0 : UInt8) ≠ 0 ∨ (0 : UInt8) ≠ 0))] rw [if_neg hnl, if_pos hpair] +/-- Four bytes read big endian and printed again are the same + four bytes. -/ +theorem be32_u32 (a b c d : UInt8) : be32 (u32 a b c d) = [a, b, c, d] := by + have ha := a.toNat_lt + have hb := b.toNat_lt + have hc := c.toNat_lt + have hd := d.toNat_lt + simp only [be32, u32, List.cons.injEq, and_true] + refine ⟨?_, ?_, ?_, ?_⟩ <;> apply UInt8.toNat_inj.mp <;> rw [toUInt8_toNat] <;> omega + +/-- Four bytes read big endian give a value below 2^32. -/ +theorem u32_lt (a b c d : UInt8) : u32 a b c d < 4294967296 := by + have ha := a.toNat_lt + have hb := b.toNat_lt + have hc := c.toNat_lt + have hd := d.toNat_lt + simp only [u32] + omega + +/-- A byte that is not above 1 is 0 or 1. -/ +theorem uint8_le_one {i : UInt8} (h : ¬ i > 1) : i = 0 ∨ i = 1 := by + have h' : ¬ (1 : UInt8).toNat < i.toNat := + fun x => h (UInt8.lt_iff_toNat_lt.mpr x) + have : i.toNat = 0 ∨ i.toNat = 1 := by simp at h'; omega + rcases this with e | e + · left; exact UInt8.toNat_inj.mp (by simpa using e) + · right; exact UInt8.toNat_inj.mp (by simpa using e) + +/-- The header reader is sound: a header it accepts is well + formed, and the bytes it read are that header's printed form. + So every rejection rule holds of what it accepts: the width + and the height are above zero and below 2^31, the interlace + byte is 0 or 1, the depth is one the colour type allows (by + `checkPair_iff`, a pair of the written out table), there were + exactly 13 bytes, and the compression and filter bytes were + zero, since `printIhdr` writes zero there. -/ +theorem parseIhdr_sound {bs : List UInt8} {h : Ihdr} + (hp : parseIhdr bs = some h) : WFHeader h ∧ printIhdr h = bs := by + unfold parseIhdr at hp + split at hp + · rename_i w1 w2 w3 w4 h1 h2 h3 h4 bd ct comp filt int rest + split at hp + · cases hp + rename_i hr + split at hp + · cases hp + rename_i hz + split at hp + · cases hp + rename_i hb + split at hp + · cases hp + rename_i hc + split at hp + · cases hp + rename_i hi + split at hp + · rename_i hpair + cases hp + have hr' : rest = [] := Decidable.of_not_not hr + have hcomp : comp = 0 := Decidable.of_not_not (not_or.mp hc).1 + have hfilt : filt = 0 := Decidable.of_not_not (not_or.mp hc).2 + subst hr' hcomp hfilt + refine ⟨⟨(by omega : 0 < u32 w1 w2 w3 w4), + (by omega : u32 w1 w2 w3 w4 < 2147483648), + (by omega : 0 < u32 h1 h2 h3 h4), + (by omega : u32 h1 h2 h3 h4 < 2147483648), + uint8_le_one hi, hpair⟩, ?_⟩ + show be32 (u32 w1 w2 w3 w4) ++ be32 (u32 h1 h2 h3 h4) + ++ [bd, ct, 0, 0, int] = _ + rw [be32_u32, be32_u32] + rfl + · cases hp + · cases hp + +/-- The header reader, exactly: it accepts the bytes `bs` with + header `h` if and only if `h` is well formed and `bs` is its + printed form. -/ +theorem parseIhdr_iff (bs : List UInt8) (h : Ihdr) : + parseIhdr bs = some h ↔ WFHeader h ∧ printIhdr h = bs := + ⟨parseIhdr_sound, fun ⟨hwf, hp⟩ => hp ▸ parseIhdr_print h hwf⟩ + /-- The table, exactly: a depth goes with a colour type under the checker if and only if the pair is in the written out list of allowed pairs. Stated for all numbers, so in diff --git a/Proofs/Sound.lean b/Proofs/Sound.lean new file mode 100644 index 0000000..91cb494 --- /dev/null +++ b/Proofs/Sound.lean @@ -0,0 +1,242 @@ +/- + Soundness: the converse of the round trips. Whatever the + readers accept is the printed form of a well formed value, and + the value they return is that value. + + - `parse_sound`: a file the chunk parser accepts is the printed + form of the chunk list it returns, and that list is well + formed (`WF`). + - `parseFile_sound`: a file the file level read accepts is the + printed form of the chunk list it returns, and that list is + fully well formed with the header it returns (`WFFull`). + + With the round trips `parse_print` and `parseFile_print`, each + reader is pinned exactly (`parse_iff`, `parseFile_iff`): it + accepts a file if and only if the file is the printed form of a + well formed value, and then returns that value. So every rule + the well formedness predicates state is a rejection rule the + reader keeps: a file that breaks one is refused. + + The steps follow the readers: four bytes read and printed again + are the same bytes (`be32_u32`, in `Proofs/Header.lean`); one + chunk the chunk reader accepts is a well formed chunk printed in + front of the rest of the input; a list of chunks read to IEND is + the printed list, ending in the IEND chunk; then the signature + and the IHDR check at the top level, and the header on top. +-/ + +import Png +import Proofs.RoundTrip +import Proofs.Header + +namespace Png.Proofs + +open Png + +/-- Four bytes the reader takes off the front of the input are + the printed form of the value it returns, and that value is + below 2^32. -/ +theorem get4_sound {bs : List UInt8} {n : Nat} {r : List UInt8} + (h : get4 bs = some (n, r)) : n < 4294967296 ∧ be32 n ++ r = bs := by + unfold get4 at h + split at h + · rename_i a b c d rest + simp only [Option.some.injEq, Prod.mk.injEq] at h + obtain ⟨rfl, rfl⟩ := h + exact ⟨u32_lt a b c d, by rw [be32_u32]; rfl⟩ + · cases h + +/-- One chunk the chunk reader accepts is a well formed chunk, + and the input was its printed form followed by the rest the + reader returns. -/ +theorem parseChunk_sound {bs : List UInt8} {c : Chunk} {r : List UInt8} + (h : parseChunk bs = some (c, r)) : WFChunk c ∧ printChunk c ++ r = bs := by + unfold parseChunk at h + split at h + · cases h + rename_i len rest1 hg + obtain ⟨_, hbs⟩ := get4_sound hg + split at h + · cases h + rename_i hlen + split at h + · rename_i t1 t2 t3 t4 rest2 + split at h + · rename_i hletters + dsimp only at h + split at h + · cases h + rename_i hdl + split at h + · cases h + rename_i crcVal rest3 hg2 + split at h + · rename_i hcrc + simp only [Option.some.injEq, Prod.mk.injEq] at h + obtain ⟨rfl, rfl⟩ := h + obtain ⟨_, hrest2⟩ := get4_sound hg2 + have hl : (rest2.take len).length = len := Decidable.of_not_not hdl + simp only [Bool.and_eq_true] at hletters + obtain ⟨⟨⟨l1, l2⟩, l3⟩, l4⟩ := hletters + refine ⟨⟨l1, l2, l3, l4, ?_⟩, ?_⟩ + · show (rest2.take len).length < 2147483648 + rw [hl] + exact Nat.lt_of_not_le hlen + rw [← hbs] + show be32 (rest2.take len).length ++ [t1, t2, t3, t4] ++ rest2.take len + ++ be32 (Crc32.crc32 ([t1, t2, t3, t4] ++ rest2.take len)).toNat ++ rest3 + = be32 len ++ (t1 :: t2 :: t3 :: t4 :: rest2) + rw [hl, ← hcrc, List.append_assoc, List.append_assoc, List.append_assoc, + hrest2, List.take_append_drop] + rfl + · cases h + · cases h + · cases h + +/-- A chunk the reader takes as the IEND chunk is the IEND + chunk. -/ +theorem isIEND_eq {c : Chunk} (h : isIEND c = true) : c = iendChunk := by + obtain ⟨t1, t2, t3, t4, data⟩ := c + simp only [isIEND, Chunk.typeBytes, iendType, Bool.and_eq_true, decide_eq_true_eq, + List.cons.injEq, and_true, List.isEmpty_iff] at h + obtain ⟨⟨rfl, rfl, rfl, rfl⟩, rfl⟩ := h + rfl + +/-- A chunk list the list reader accepts, at any fuel, is the + printed form of the list, every chunk in it is well formed, + and it ends in the IEND chunk with no IEND chunk before. -/ +theorem parseChunks_sound : ∀ (n : Nat) (bs : List UInt8) (cs : Doc), + parseChunks n bs = some cs → + cs.flatMap printChunk = bs ∧ (∀ c ∈ cs, WFChunk c) ∧ + ∃ pre : Doc, cs = pre ++ [iendChunk] ∧ ∀ c ∈ pre, isIEND c = false := by + intro n + induction n with + | zero => intro bs cs h; cases h + | succ n ih => + intro bs cs h + rw [parseChunks_succ] at h + split at h + · cases h + rename_i c rest hc + obtain ⟨hwf, hpr⟩ := parseChunk_sound hc + split at h + · rename_i hend + split at h + · rename_i hrest + simp only [Option.some.injEq] at h + subst h hrest + refine ⟨?_, ?_, [], ?_, ?_⟩ + · simpa using hpr + · intro x hx + rw [List.mem_singleton.mp hx] + exact hwf + · rw [isIEND_eq hend] + rfl + · intro x hx + cases hx + · cases h + · rename_i hnend + split at h + · cases h + rename_i cs' hcs + simp only [Option.some.injEq] at h + subst h + obtain ⟨hflat, hall, pre, hpre, hnot⟩ := ih rest cs' hcs + refine ⟨?_, ?_, c :: pre, ?_, ?_⟩ + · rw [List.flatMap_cons, hflat] + exact hpr + · intro x hx + rcases List.mem_cons.mp hx with rfl | hx + · exact hwf + · exact hall x hx + · rw [hpre] + rfl + · intro x hx + rcases List.mem_cons.mp hx with rfl | hx + · exact Bool.eq_false_iff.mpr hnend + · exact hnot x hx + +/-- The chunk parser is sound: a file it accepts is the printed + form of the chunk list it returns, and that list is well + formed. -/ +theorem parse_sound {bs : List UInt8} {doc : Doc} (h : parse bs = some doc) : + WF doc ∧ print doc = bs := by + unfold parse at h + split at h + · rename_i hsig + split at h + · rename_i c cs hcs + split at h + · rename_i hhead + simp only [Option.some.injEq] at h + subst h + obtain ⟨hflat, hall, pre, hpre, hnot⟩ := parseChunks_sound _ _ _ hcs + obtain ⟨htype, hlen⟩ := hhead + have hprint : print (c :: cs) = bs := by + show signature ++ (c :: cs).flatMap printChunk = bs + rw [hflat, ← hsig] + exact List.take_append_drop 8 bs + cases pre with + | nil => + have hc : c = iendChunk := (List.cons.inj hpre).1 + rw [hc] at htype + exact absurd htype (by decide) + | cons head mid => + have hpre' : c :: cs = head :: (mid ++ [iendChunk]) := hpre + have hc : c = head := (List.cons.inj hpre').1 + subst hc + exact ⟨⟨c, mid, hpre, htype, hlen, hall, hnot⟩, hprint⟩ + · cases h + · cases h + · cases h + +/-- The chunk parser, exactly: it accepts the file `bs` with the + chunk list `doc` if and only if `doc` is well formed and `bs` + is its printed form. -/ +theorem parse_iff (bs : List UInt8) (doc : Doc) : + parse bs = some doc ↔ WF doc ∧ print doc = bs := + ⟨parse_sound, fun ⟨hwf, hp⟩ => hp ▸ parse_print hwf⟩ + +/-- The file level read is sound: a file it accepts, with header + `hd` and chunk list `doc`, is the printed form of `doc`, and + `doc` is fully well formed with header `hd`. So the header it + returns is the one the first chunk's data is the printed form + of, and no later chunk is an IHDR chunk. -/ +theorem parseFile_sound {bs : List UInt8} {hd : Ihdr} {doc : Doc} + (h : parseFile bs = some (hd, doc)) : WFFull hd doc ∧ print doc = bs := by + unfold parseFile at h + split at h + · rename_i d hd' + cases hh : headerOf d with + | none => + rw [hh] at h + cases h + | some x => + rw [hh] at h + simp only [Option.map_some, Option.some.injEq, Prod.mk.injEq] at h + obtain ⟨hx, rfl⟩ := h + rw [hx] at hh + obtain ⟨hwf, hpr⟩ := parse_sound hd' + obtain ⟨head, mid, hdoc, htype, -, hall, hnot⟩ := hwf + rw [hdoc] at hh + have hh' : (if (mid ++ [iendChunk]).any isIHDR then none + else parseIhdr head.data) = some hd := hh + split at hh' + · cases hh' + rename_i hany + obtain ⟨hwfh, hprint⟩ := parseIhdr_sound hh' + refine ⟨⟨head, mid, hdoc, htype, hprint.symm, hwfh, hall, hnot, ?_⟩, hpr⟩ + intro c hc hct + apply hany + rw [List.any_eq_true] + exact ⟨c, List.mem_append_left _ hc, by simp [isIHDR, hct]⟩ + · cases h + +/-- The file level read, exactly: it accepts the file `bs` with + header `hd` and chunk list `doc` if and only if `doc` is fully + well formed with header `hd` and `bs` is its printed form. -/ +theorem parseFile_iff (bs : List UInt8) (hd : Ihdr) (doc : Doc) : + parseFile bs = some (hd, doc) ↔ WFFull hd doc ∧ print doc = bs := + ⟨parseFile_sound, fun ⟨hwf, hp⟩ => hp ▸ parseFile_print hwf⟩ + +end Png.Proofs diff --git a/Proofs/SpecProofs.lean b/Proofs/SpecProofs.lean index ce0321f..ab143dd 100644 --- a/Proofs/SpecProofs.lean +++ b/Proofs/SpecProofs.lean @@ -1,8 +1,9 @@ /- Proofs of the statements in `Spec.lean`, under the same names. - The round trips and the table are the theorems proved in - `Proofs/RoundTrip.lean` and `Proofs/Header.lean`. The refusals + The round trips, the table, and the soundness statements are + the theorems proved in `Proofs/RoundTrip.lean`, + `Proofs/Header.lean`, and `Proofs/Sound.lean`. The refusals are single inputs, checked in the kernel with `decide +kernel`: no native evaluation, which the bar forbids. -/ @@ -10,6 +11,7 @@ import Png import Proofs.RoundTrip import Proofs.Header +import Proofs.Sound open Png @@ -31,6 +33,20 @@ theorem Spec.parseFile_print {hd : Ihdr} {doc : Doc} (h : WFFull hd doc) : Png.Proofs.parseFile_print h +theorem Spec.parse_sound {bs : List UInt8} {doc : Doc} + (h : parse bs = some doc) : WF doc ∧ print doc = bs := + Png.Proofs.parse_sound h + +theorem Spec.parseIhdr_sound {bs : List UInt8} {h : Ihdr} + (hp : parseIhdr bs = some h) : WFHeader h ∧ printIhdr h = bs := + Png.Proofs.parseIhdr_sound hp + +theorem Spec.parseFile_sound {bs : List UInt8} {hd : Ihdr} {doc : Doc} + (h : parseFile bs = some (hd, doc)) : + WFFull hd doc ∧ print doc = bs := + Png.Proofs.parseFile_sound h + + theorem Spec.rejects_bad_signature : (parse ([118, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 4, 0, 0, 0, 3, 8, 6, 0, 0, 0, 180, 244, 174, diff --git a/Proofs/Verify.lean b/Proofs/Verify.lean index ae8156f..778bd8a 100644 --- a/Proofs/Verify.lean +++ b/Proofs/Verify.lean @@ -11,17 +11,24 @@ import Proofs.RoundTrip import Proofs.Header +import Proofs.Sound import Proofs.SpecProofs #print axioms Png.Proofs.parse_print #print axioms Png.Proofs.parseIhdr_print #print axioms Png.Proofs.checkPair_iff #print axioms Png.Proofs.parseFile_print +#print axioms Png.Proofs.parse_sound +#print axioms Png.Proofs.parseIhdr_sound +#print axioms Png.Proofs.parseFile_sound #print axioms Spec.parse_print #print axioms Spec.parseIhdr_print #print axioms Spec.checkPair_iff #print axioms Spec.parseFile_print +#print axioms Spec.parse_sound +#print axioms Spec.parseIhdr_sound +#print axioms Spec.parseFile_sound #print axioms Spec.rejects_bad_signature #print axioms Spec.rejects_bad_crc #print axioms Spec.rejects_truncated_input diff --git a/README.md b/README.md index 42804c8..373a44b 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,9 @@ same names. `comparator.json` lists all of them. | Reading the printed bytes of a well formed IHDR header gives the header back. | `Spec.parseIhdr_print` | | The depth checker agrees with the written out table of allowed (colour type, bit depth) pairs on every byte pair. | `Spec.checkPair_iff` | | Printing a fully well formed document whose first chunk holds the printed header `hd`, and reading it back at both layers, gives back `hd` itself and the document. | `Spec.parseFile_print` | +| A file the chunk parser accepts is the printed form of the chunk list it returns, and that list is well formed. | `Spec.parse_sound` | +| A header the header reader accepts is well formed, and the bytes it read are its printed form. So a header that breaks any header rule is refused. | `Spec.parseIhdr_sound` | +| A file the file level read accepts is the printed form of the chunk list it returns, and that list is fully well formed with the header the read returns. So a file that breaks any rule of either layer, or holds a second IHDR chunk, is refused. | `Spec.parseFile_sound` | | A file whose first signature byte is wrong is refused. | `Spec.rejects_bad_signature` | | A file whose IHDR chunk carries a CRC that does not match its type and data is refused. | `Spec.rejects_bad_crc` | | A file cut short inside its first chunk is refused. | `Spec.rejects_truncated_input` | @@ -93,13 +96,33 @@ the file level round trip names the header the read must return: a reader that answered a fixed header, or swapped the width and the height, would not meet it. +Each reader is pinned to its rule in both directions. The +round trip says it accepts the printed form of every well +formed value and returns that value; the soundness theorem +says it accepts nothing else. So the chunk parser accepts a +file exactly when the file is `print doc` for a well formed +`doc`, and then returns `doc` (`Spec.parse_print` with +`Spec.parse_sound`); the header reader accepts 13 bytes +exactly when they are `printIhdr h` for a well formed `h` +(`Spec.parseIhdr_print` with `Spec.parseIhdr_sound`); and the +file level read accepts a file exactly when it is `print doc` +for a `doc` fully well formed with header `hd`, and then +returns `hd` and `doc` (`Spec.parseFile_print` with +`Spec.parseFile_sound`). Every rule in these predicates is +therefore a proved rejection rule. The named refusal theorems +below the soundness theorems pin single inputs on top. + The headline theorems rest on the proofs in -`Proofs/RoundTrip.lean` and `Proofs/Header.lean`, where they -are `Png.Proofs.parse_print`, `Png.Proofs.parseIhdr_print`, -`Png.Proofs.checkPair_iff`, and `Png.Proofs.parseFile_print`. +`Proofs/RoundTrip.lean`, `Proofs/Header.lean`, and +`Proofs/Sound.lean`, where they are `Png.Proofs.parse_print`, +`Png.Proofs.parseIhdr_print`, `Png.Proofs.checkPair_iff`, +`Png.Proofs.parseFile_print`, `Png.Proofs.parse_sound`, +`Png.Proofs.parseIhdr_sound`, and `Png.Proofs.parseFile_sound`. Each checks against two of the standard axioms (`propext`, `Quot.sound`), which a reader can confirm with `#print -axioms`, or by running `lake env lean Proofs/Verify.lean`. +axioms`, or by running `lake env lean Proofs/Verify.lean`; +the test harness reads that output for every theorem +`comparator.json` names, and records it. The refusal theorems are single inputs checked in the kernel with `decide +kernel`. @@ -114,9 +137,8 @@ about. ## What is not proved -- That the parser accepts every file the rules in `SPEC.md` allow, and rejects every file they forbid. The round trip does not need either, so neither is proved. The test suite checks that behaviour instead, and the results file records the outcome, including any failure. +- That the well formedness rules (`WF`, `WFHeader`, `WFFull`, and the table of allowed pairs) are the PNG specification's rules. The proofs tie each reader to its rule exactly, in both directions, but that the rule says what the PNG specification says is a reading: `SPEC.md` states it in words, and the tests compare the readers against a strict oracle written in the harness from those rules. - That the CRC-32 here is the standard one. The round trip needs no such fact, since the parser and the printer compute the same function. The differential tests against Python zlib's `crc32` are what tie this function to the standard. -- That the header reader accepts every header the rules in `SPEC.md` allow, and rejects every header they forbid, beyond what the round trip and the table theorem cover. The tests check that behaviour instead: hand built headers for every colour type and allowed depth, the full colour type and depth enumeration against the table, and the rejection cases. - Anything inside a chunk's data past the IHDR header. The data of every other chunk type is opaque here. IDAT data is not inflated. No pixel is ever decoded. - Chunk order past the first and last, except for IHDR. Which types may appear, how many times, and in what order between IHDR and IEND is not checked, with one exception: the file level read (`header`) refuses a second IHDR chunk. The chunk layer (`parse`) does not, and reads it as an ordinary chunk. A chunk of type IEND that carries data is read as an ordinary chunk at both layers. diff --git a/SPEC.md b/SPEC.md index fef0a4d..d5e5280 100644 --- a/SPEC.md +++ b/SPEC.md @@ -77,9 +77,15 @@ Slice 2 adds three proved claims: - The header round trip: reading the printed bytes of a well formed header gives the header back. Well formed means the width and height are above zero and below 2^31, the interlace byte is 0 or 1, and the bit depth is one the colour type allows. - The table is exact: the depth checker agrees with the table above, written out as a list of (colour type, bit depth) pairs, on every colour type byte and every bit depth byte. -- The file level round trip: a document that is well formed at the chunk layer and whose first chunk's data is the printed form of a well formed header `hd` prints and reads back whole, and the header the read returns is `hd` itself, not merely some header. +- The file level round trip: a document that is well formed at the chunk layer, whose first chunk's data is the printed form of a well formed header `hd`, and in which no later chunk has type IHDR, prints and reads back whole, and the header the read returns is `hd` itself, not merely some header. -No claim is made that the parser accepts every file the rules above allow, or rejects every file they forbid, beyond what the round trip needs. The test suite checks that behaviour against real PNG files built with Python's zlib and against a strict reading of these rules, and the results record the counts. +Each round trip has a proved converse, so each reader is pinned to its rule in both directions: + +- The chunk parser is sound: a file it accepts is the printed form of the chunk list it returns, and that list is well formed. With the round trip of slice 1, `parse` accepts a file exactly when it is the printed form of a well formed chunk list, and then returns that list. +- The header reader is sound: a header it accepts is well formed, and the 13 bytes it read are that header's printed form. So a width or height of zero or of 2^31 or more, a nonzero compression or filter byte, an interlace byte above 1, a depth the colour type does not allow (by the table claim), and any length but 13 are all refused. +- The file level read is sound: a file it accepts is the printed form of the chunk list it returns, that list is well formed at the chunk layer, its first chunk's data is the printed form of the header the read returns, that header is well formed, and no later chunk has type IHDR. + +So every rule of this specification that the well formedness rules state is a proved rejection rule. What is not proved is that those rules are the PNG specification's: that is the reading this document pins. The test suite checks the readers against real PNG files built with Python's zlib and against a strict reading of these rules written in the harness, and the results record the counts. The round trip needs no fact about CRC-32 itself: the parser and the printer compute the same function of the same bytes, so a printed file passes its own CRC checks by construction. That the function is the standard CRC-32 rests on the differential tests against Python zlib's `crc32`. @@ -93,6 +99,9 @@ Each property above names its theorem. The theorems are stated in `Spec.lean` an | Reading the printed bytes of a well formed IHDR header gives the header back | `Spec.parseIhdr_print` | | The depth checker agrees with the written out table of allowed (colour type, bit depth) pairs on every byte pair | `Spec.checkPair_iff` | | Printing a fully well formed document whose first chunk holds the printed header `hd`, and reading it back at both layers, gives back `hd` itself and the document | `Spec.parseFile_print` | +| A file the chunk parser accepts is the printed form of the chunk list it returns, and that list is well formed | `Spec.parse_sound` | +| A header the header reader accepts is well formed, and the bytes it read are its printed form | `Spec.parseIhdr_sound` | +| A file the file level read accepts is the printed form of the chunk list it returns, and that list is fully well formed with the header the read returns | `Spec.parseFile_sound` | | A file whose first signature byte is wrong is refused | `Spec.rejects_bad_signature` | | A file whose IHDR chunk carries a CRC that does not match its type and data is refused | `Spec.rejects_bad_crc` | | A file cut short inside its first chunk is refused | `Spec.rejects_truncated_input` | diff --git a/Spec.lean b/Spec.lean index fa9fffa..5340939 100644 --- a/Spec.lean +++ b/Spec.lean @@ -49,6 +49,36 @@ theorem Spec.checkPair_iff (ct bd : UInt8) : theorem Spec.parseFile_print {hd : Ihdr} {doc : Doc} (h : WFFull hd doc) : parseFile (print doc) = some (hd, doc) := sorry +/-! ## What the readers accept + +The converse of the round trips: each reader accepts only the +printed form of a well formed value, and returns that value. +With the round trips above, this pins each reader exactly, so +every rule the well formedness predicates state is a rule the +reader keeps when it refuses a file. -/ + +/-- A file the chunk parser accepts is the printed form of the + chunk list it returns, and that list is well formed. -/ +theorem Spec.parse_sound {bs : List UInt8} {doc : Doc} + (h : parse bs = some doc) : WF doc ∧ print doc = bs := sorry + +/-- A header the header reader accepts is well formed, and the + bytes it read are that header's printed form. So a width or + height of zero or of 2^31 or more, a nonzero compression or + filter byte, an interlace byte above 1, a depth the colour + type does not allow, and any length but 13 are all refused. -/ +theorem Spec.parseIhdr_sound {bs : List UInt8} {h : Ihdr} + (hp : parseIhdr bs = some h) : WFHeader h ∧ printIhdr h = bs := sorry + +/-- A file the file level read accepts, with header `hd` and + chunk list `doc`, is the printed form of `doc`, and `doc` is + fully well formed with header `hd`: its first chunk's data is + the printed form of `hd`, and no later chunk is an IHDR + chunk. -/ +theorem Spec.parseFile_sound {bs : List UInt8} {hd : Ihdr} {doc : Doc} + (h : parseFile bs = some (hd, doc)) : + WFFull hd doc ∧ print doc = bs := sorry + /-! ## Refused inputs Single inputs the reader refuses, one for each headline rule of diff --git a/comparator.json b/comparator.json index 64f193b..50966c4 100644 --- a/comparator.json +++ b/comparator.json @@ -1 +1 @@ -{"challenge_module":"Spec","permitted_axioms":["propext","Classical.choice","Quot.sound"],"solution_module":"Proofs","theorem_names":["Spec.parse_print","Spec.parseIhdr_print","Spec.checkPair_iff","Spec.parseFile_print","Spec.rejects_bad_signature","Spec.rejects_bad_crc","Spec.rejects_truncated_input","Spec.rejects_first_chunk_length_12","Spec.rejects_zero_width","Spec.rejects_zero_height","Spec.rejects_width_at_cap","Spec.rejects_height_at_cap","Spec.rejects_illegal_depth","Spec.rejects_unknown_colour_type","Spec.rejects_unknown_interlace","Spec.rejects_nonzero_compression","Spec.rejects_nonzero_filter","Spec.rejects_second_ihdr","Spec.rejects_header_wrong_length"]} \ No newline at end of file +{"challenge_module":"Spec","permitted_axioms":["propext","Classical.choice","Quot.sound"],"solution_module":"Proofs","theorem_names":["Spec.parse_print","Spec.parseIhdr_print","Spec.checkPair_iff","Spec.parseFile_print","Spec.parse_sound","Spec.parseIhdr_sound","Spec.parseFile_sound","Spec.rejects_bad_signature","Spec.rejects_bad_crc","Spec.rejects_truncated_input","Spec.rejects_first_chunk_length_12","Spec.rejects_zero_width","Spec.rejects_zero_height","Spec.rejects_width_at_cap","Spec.rejects_height_at_cap","Spec.rejects_illegal_depth","Spec.rejects_unknown_colour_type","Spec.rejects_unknown_interlace","Spec.rejects_nonzero_compression","Spec.rejects_nonzero_filter","Spec.rejects_second_ihdr","Spec.rejects_header_wrong_length"]} \ No newline at end of file diff --git a/tests/run_tests.py b/tests/run_tests.py index 6362a58..1ee16b3 100644 --- a/tests/run_tests.py +++ b/tests/run_tests.py @@ -823,6 +823,9 @@ def run_header_malformed(): "Png.Proofs.parseIhdr_print", "Png.Proofs.checkPair_iff", "Png.Proofs.parseFile_print", + "Png.Proofs.parse_sound", + "Png.Proofs.parseIhdr_sound", + "Png.Proofs.parseFile_sound", ] From a21e22d6c20fe1b369521ef44d5232fba7fa1b07 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 20:06:30 +0000 Subject: [PATCH 08/14] Move well formedness and the table into Spec/Defs.lean Bar reviewer checklist step 4: definitions used only to give the statements meaning belong in the spec files, not the bar.toml [spec] code list. A code entry is pinned by name only, so WF, WFHeader or WFFull could be changed after an approval without changing the spec hash, and every statement over them would change meaning with it. - Spec/Defs.lean (namespace Spec) now holds iendChunk, WFChunk, WF, allowedPairs, WFHeader and WFFull, with their bodies unchanged, except that WFHeader states the depth rule as membership in allowedPairs instead of through the code's checkPair. The two are equal by checkPair_iff, which the proofs now use where they took checkPair before. - Png/Value.lean and Png/Header.lean keep only code. - bar.toml: Spec/Defs.lean joins the spec files; the moved names leave the code list; Png.isIHDR joins it, with the constructors and fields of Chunk and Ihdr that the spec definitions use, as the statement walk needs. zero-aska joins spec_authors, since this carry-forward changed the specification under that account. - lakefile.toml builds Spec by default, so `lake build` checks the challenge file compiles. check.py --offline: the statement walk reports nothing outside the spec files and the code list, and lake comparator --paranoid accepts all 22 theorems under every bundled kernel. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AovXVgJBHGiN44TxQM4dcd --- Png/Header.lean | 41 +++-------------- Png/Value.lean | 26 ++--------- Proofs/Header.lean | 99 +++++++++++++++++++++--------------------- Proofs/RoundTrip.lean | 3 +- Proofs/Sound.lean | 3 +- Proofs/SpecProofs.lean | 3 +- README.md | 22 ++++++---- SPEC.md | 2 +- Spec.lean | 14 +++--- Spec/Defs.lean | 79 +++++++++++++++++++++++++++++++++ bar.toml | 36 +++++++++------ lakefile.toml | 2 +- 12 files changed, 191 insertions(+), 139 deletions(-) create mode 100644 Spec/Defs.lean diff --git a/Png/Header.lean b/Png/Header.lean index cba0b12..3e2e4ff 100644 --- a/Png/Header.lean +++ b/Png/Header.lean @@ -34,6 +34,11 @@ the first has type IHDR: the PNG specification allows one IHDR chunk, and it comes first. The chunk layer of slice 1 does not look at this; only the file level read does. + + The written out table of allowed pairs, and well formedness of + a header and of a whole document, give the statements their + meaning and no code uses them, so they are part of the + specification, in `Spec/Defs.lean`. -/ import Png.Parse @@ -67,30 +72,10 @@ def allowedDepths (ct : Nat) : List Nat := def pairAllowed (ct bd : Nat) : Bool := decide (bd ∈ allowedDepths ct) -/-- The allowed (colour type, bit depth) pairs, written out in - full. The checker above is built from the per-type table; - the proof in `Proofs/Header.lean` shows the two agree on - every colour type byte and every bit depth byte. -/ -def allowedPairs : List (Nat × Nat) := - [(0, 1), (0, 2), (0, 4), (0, 8), (0, 16), - (2, 8), (2, 16), - (3, 1), (3, 2), (3, 4), (3, 8), - (4, 8), (4, 16), - (6, 8), (6, 16)] - /-- Does this bit depth go with this colour type? The two arguments are the file's bytes. -/ def checkPair (ct bd : UInt8) : Bool := pairAllowed ct.toNat bd.toNat -/-- A header is well formed when the width and the height are - above zero and below 2^31, the interlace byte is 0 or 1, and - the bit depth is one the colour type allows. -/ -def WFHeader (h : Ihdr) : Prop := - 0 < h.width ∧ h.width < 2147483648 ∧ - 0 < h.height ∧ h.height < 2147483648 ∧ - (h.interlace = 0 ∨ h.interlace = 1) ∧ - checkPair h.colorType h.bitDepth = true - /-- The 13 IHDR data bytes of a header: width and height big endian, then bit depth, colour type, compression zero, filter zero, interlace. -/ @@ -139,20 +124,4 @@ def parseFile (bs : List UInt8) : Option (Ihdr × Doc) := | some doc => (headerOf doc).map fun h => (h, doc) | none => none -/-- A document is fully well formed, with header `hd`, when it - is well formed at the chunk layer (the `WF` of slice 1), its - first chunk's data is the printed form of `hd`, `hd` is well - formed, and no chunk after the first has type IHDR. The - header is a parameter, not a witness, so a statement over - `WFFull hd doc` can say which header a reader must return. - Stated in the shape of `WF`. -/ -def WFFull (hd : Ihdr) (doc : Doc) : Prop := - ∃ (head : Chunk) (mid : List Chunk), - doc = head :: mid ++ [iendChunk] ∧ - head.typeBytes = ihdrType ∧ head.data = printIhdr hd ∧ - WFHeader hd ∧ - (∀ c ∈ doc, WFChunk c) ∧ - (∀ c ∈ head :: mid, isIEND c = false) ∧ - (∀ c ∈ mid, c.typeBytes ≠ ihdrType) - end Png diff --git a/Png/Value.lean b/Png/Value.lean index 6870cce..e94031a 100644 --- a/Png/Value.lean +++ b/Png/Value.lean @@ -14,6 +14,10 @@ length, type, data, and a CRC it computes as it goes. Its output is the canonical form the tests compare against: a file this parser accepts is reprinted byte for byte. + + Well formedness, the rule the round trip is stated over, is not + here: it gives the statements their meaning and no code uses it, + so it is part of the specification, in `Spec/Defs.lean`. -/ import Png.Crc32 @@ -48,9 +52,6 @@ def ihdrType : List UInt8 := [73, 72, 68, 82] /-- The type bytes of the last chunk of every PNG file. -/ def iendType : List UInt8 := [73, 69, 78, 68] -/-- The IEND chunk itself: type IEND, no data. -/ -def iendChunk : Chunk := ⟨73, 69, 78, 68, []⟩ - /-- Is this chunk the IEND chunk: type IEND and no data? A chunk of type IEND that carries data is an ordinary chunk here. -/ def isIEND (c : Chunk) : Bool := @@ -80,23 +81,4 @@ def printChunk (c : Chunk) : List UInt8 := def print (doc : Doc) : List UInt8 := signature ++ doc.flatMap printChunk -/-- The per-chunk part of well formedness: each type byte is an - ASCII letter, and the data length stays below 2^31, as the PNG - specification bounds chunk lengths. -/ -def WFChunk (c : Chunk) : Prop := - isLetter c.t1 = true ∧ isLetter c.t2 = true ∧ - isLetter c.t3 = true ∧ isLetter c.t4 = true ∧ - c.data.length < 2147483648 - -/-- A document is well formed when it is a first chunk, then any - middle chunks, then the IEND chunk; the first chunk has type - IHDR and 13 data bytes; no chunk before the last is the IEND - chunk; and every chunk is a well formed chunk. -/ -def WF (doc : Doc) : Prop := - ∃ (head : Chunk) (mid : List Chunk), - doc = head :: mid ++ [iendChunk] ∧ - head.typeBytes = ihdrType ∧ head.data.length = 13 ∧ - (∀ c ∈ doc, WFChunk c) ∧ - (∀ c ∈ head :: mid, isIEND c = false) - end Png diff --git a/Proofs/Header.lean b/Proofs/Header.lean index 0ce8d53..29f7962 100644 --- a/Proofs/Header.lean +++ b/Proofs/Header.lean @@ -27,11 +27,12 @@ -/ import Png +import Spec.Defs import Proofs.RoundTrip namespace Png.Proofs -open Png +open Png Spec /-- One step of the header reader, stated for rewriting: 13 bytes at the front of the input, with whatever follows. -/ @@ -52,6 +53,52 @@ theorem parseIhdr_step (w1 w2 w3 w4 h1 h2 h3 h4 bd ct comp filt int : UInt8) theorem printIhdr_length (h : Ihdr) : (printIhdr h).length = 13 := by simp [printIhdr, be32] +/-- The table, exactly: a depth goes with a colour type under + the checker if and only if the pair is in the written out + list of allowed pairs. Stated for all numbers, so in + particular for every colour type byte and every bit depth + byte. -/ +theorem pairAllowed_iff : ∀ n m : Nat, + pairAllowed n m = true ↔ (n, m) ∈ allowedPairs := by + intro n m + by_cases h0 : n = 0 + · subst h0 + simp only [pairAllowed, decide_eq_true_eq] + show m ∈ [1, 2, 4, 8, 16] ↔ (0, m) ∈ allowedPairs + simp [allowedPairs] + by_cases h2 : n = 2 + · subst h2 + simp only [pairAllowed, decide_eq_true_eq] + show m ∈ [8, 16] ↔ (2, m) ∈ allowedPairs + simp [allowedPairs] + by_cases h3 : n = 3 + · subst h3 + simp only [pairAllowed, decide_eq_true_eq] + show m ∈ [1, 2, 4, 8] ↔ (3, m) ∈ allowedPairs + simp [allowedPairs] + by_cases h4 : n = 4 + · subst h4 + simp only [pairAllowed, decide_eq_true_eq] + show m ∈ [8, 16] ↔ (4, m) ∈ allowedPairs + simp [allowedPairs] + by_cases h6 : n = 6 + · subst h6 + simp only [pairAllowed, decide_eq_true_eq] + show m ∈ [8, 16] ↔ (6, m) ∈ allowedPairs + simp [allowedPairs] + · have hdep : allowedDepths n = [] := by + unfold allowedDepths + rw [if_neg h0, if_neg h2, if_neg h3, if_neg h4, if_neg h6] + simp only [pairAllowed, decide_eq_true_eq, hdep] + simp [allowedPairs, h0, h2, h3, h4, h6] + +/-- The table, in the byte form the parser uses: the check on + two bytes holds if and only if the pair of their values is + in the written out list. -/ +theorem checkPair_iff (ct bd : UInt8) : + checkPair ct bd = true ↔ (ct.toNat, bd.toNat) ∈ allowedPairs := + pairAllowed_iff ct.toNat bd.toNat + /-- The header round trip: reading the printed bytes of a well formed header gives the header back. -/ theorem parseIhdr_print (h : Ihdr) (hwf : WFHeader h) : @@ -75,7 +122,7 @@ theorem parseIhdr_print (h : Ihdr) (hwf : WFHeader h) : rw [if_neg (by omega : ¬ (w = 0 ∨ hh = 0))] rw [if_neg (by omega : ¬ (w ≥ 2147483648 ∨ hh ≥ 2147483648))] rw [if_neg (by decide : ¬ ((0 : UInt8) ≠ 0 ∨ (0 : UInt8) ≠ 0))] - rw [if_neg hnl, if_pos hpair] + rw [if_neg hnl, if_pos ((checkPair_iff ct bd).mpr hpair)] /-- Four bytes read big endian and printed again are the same four bytes. -/ @@ -144,7 +191,7 @@ theorem parseIhdr_sound {bs : List UInt8} {h : Ihdr} (by omega : u32 w1 w2 w3 w4 < 2147483648), (by omega : 0 < u32 h1 h2 h3 h4), (by omega : u32 h1 h2 h3 h4 < 2147483648), - uint8_le_one hi, hpair⟩, ?_⟩ + uint8_le_one hi, (checkPair_iff ct bd).mp hpair⟩, ?_⟩ show be32 (u32 w1 w2 w3 w4) ++ be32 (u32 h1 h2 h3 h4) ++ [bd, ct, 0, 0, int] = _ rw [be32_u32, be32_u32] @@ -159,52 +206,6 @@ theorem parseIhdr_iff (bs : List UInt8) (h : Ihdr) : parseIhdr bs = some h ↔ WFHeader h ∧ printIhdr h = bs := ⟨parseIhdr_sound, fun ⟨hwf, hp⟩ => hp ▸ parseIhdr_print h hwf⟩ -/-- The table, exactly: a depth goes with a colour type under - the checker if and only if the pair is in the written out - list of allowed pairs. Stated for all numbers, so in - particular for every colour type byte and every bit depth - byte. -/ -theorem pairAllowed_iff : ∀ n m : Nat, - pairAllowed n m = true ↔ (n, m) ∈ allowedPairs := by - intro n m - by_cases h0 : n = 0 - · subst h0 - simp only [pairAllowed, decide_eq_true_eq] - show m ∈ [1, 2, 4, 8, 16] ↔ (0, m) ∈ allowedPairs - simp [allowedPairs] - by_cases h2 : n = 2 - · subst h2 - simp only [pairAllowed, decide_eq_true_eq] - show m ∈ [8, 16] ↔ (2, m) ∈ allowedPairs - simp [allowedPairs] - by_cases h3 : n = 3 - · subst h3 - simp only [pairAllowed, decide_eq_true_eq] - show m ∈ [1, 2, 4, 8] ↔ (3, m) ∈ allowedPairs - simp [allowedPairs] - by_cases h4 : n = 4 - · subst h4 - simp only [pairAllowed, decide_eq_true_eq] - show m ∈ [8, 16] ↔ (4, m) ∈ allowedPairs - simp [allowedPairs] - by_cases h6 : n = 6 - · subst h6 - simp only [pairAllowed, decide_eq_true_eq] - show m ∈ [8, 16] ↔ (6, m) ∈ allowedPairs - simp [allowedPairs] - · have hdep : allowedDepths n = [] := by - unfold allowedDepths - rw [if_neg h0, if_neg h2, if_neg h3, if_neg h4, if_neg h6] - simp only [pairAllowed, decide_eq_true_eq, hdep] - simp [allowedPairs, h0, h2, h3, h4, h6] - -/-- The table, in the byte form the parser uses: the check on - two bytes holds if and only if the pair of their values is - in the written out list. -/ -theorem checkPair_iff (ct bd : UInt8) : - checkPair ct bd = true ↔ (ct.toNat, bd.toNat) ∈ allowedPairs := - pairAllowed_iff ct.toNat bd.toNat - /-- A fully well formed document is well formed at the chunk layer. -/ theorem wfFull_wf {hd : Ihdr} {doc : Doc} (h : WFFull hd doc) : WF doc := by diff --git a/Proofs/RoundTrip.lean b/Proofs/RoundTrip.lean index f55d727..a1945ee 100644 --- a/Proofs/RoundTrip.lean +++ b/Proofs/RoundTrip.lean @@ -17,10 +17,11 @@ -/ import Png +import Spec.Defs namespace Png.Proofs -open Png +open Png Spec /-- A byte printed from a number reads back as the number modulo 256. -/ diff --git a/Proofs/Sound.lean b/Proofs/Sound.lean index 91cb494..a145629 100644 --- a/Proofs/Sound.lean +++ b/Proofs/Sound.lean @@ -26,12 +26,13 @@ -/ import Png +import Spec.Defs import Proofs.RoundTrip import Proofs.Header namespace Png.Proofs -open Png +open Png Spec /-- Four bytes the reader takes off the front of the input are the printed form of the value it returns, and that value is diff --git a/Proofs/SpecProofs.lean b/Proofs/SpecProofs.lean index ab143dd..8ddbcc8 100644 --- a/Proofs/SpecProofs.lean +++ b/Proofs/SpecProofs.lean @@ -9,11 +9,12 @@ -/ import Png +import Spec.Defs import Proofs.RoundTrip import Proofs.Header import Proofs.Sound -open Png +open Png Spec theorem Spec.parse_print {doc : Doc} (h : WF doc) : parse (print doc) = some doc := diff --git a/README.md b/README.md index 373a44b..01e6d49 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,10 @@ data bytes, checked against the validity rules of the PNG specification. It does not decode pixels, and it does not inflate IDAT data. The rules are written out in `SPEC.md`. -The statements a reviewer approves are in `Spec.lean`, with -their proofs in `Proofs/SpecProofs.lean`. No reviewer has -approved them yet, so this project is not called verified. +The statements a reviewer approves are in `Spec.lean`, over +the definitions in `Spec/Defs.lean`, with their proofs in +`Proofs/SpecProofs.lean`. No reviewer has approved them yet, +so this project is not called verified. ## How to run it @@ -126,11 +127,16 @@ the test harness reads that output for every theorem The refusal theorems are single inputs checked in the kernel with `decide +kernel`. -The definitions are in `Png/Value.lean` (the chunk type, the -printer, and the well formedness rules), `Png/Parse.lean` -(the parser), `Png/Crc32.lean` (the CRC-32), and -`Png/Header.lean` (the IHDR header value, its reader and -printer, and the file level read). The command line tool in +The code is in `Png/Value.lean` (the chunk type and the +printer), `Png/Parse.lean` (the parser), `Png/Crc32.lean` (the +CRC-32), and `Png/Header.lean` (the IHDR header value, its +reader and printer, and the file level read). The well +formedness rules (`Spec.WFChunk`, `Spec.WF`, `Spec.WFHeader`, +`Spec.WFFull`) and the written out table of allowed pairs +(`Spec.allowedPairs`) give the statements their meaning and no +code uses them, so they are part of the specification, in +`Spec/Defs.lean`, and a change to them voids an approval. The +command line tool in `Main.lean` calls `Png.parse`, `Png.print`, and `Png.parseFile` directly, the same functions the theorems are about. diff --git a/SPEC.md b/SPEC.md index d5e5280..c1e6ad0 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1,6 +1,6 @@ # Specification: png -A PNG chunk parser and printer in Lean, with (slice 2) a decoder for the IHDR header. It does not decode pixels, and it does not inflate IDAT data. The claims below are stated as Lean theorems in `Spec.lean`. A reviewer who did not write this specification or those statements approves them; no reviewer has yet, so nothing here counts toward the verified bar. +A PNG chunk parser and printer in Lean, with (slice 2) a decoder for the IHDR header. It does not decode pixels, and it does not inflate IDAT data. The claims below are stated as Lean theorems in `Spec.lean`, over the well formedness rules and the table of allowed pairs in `Spec/Defs.lean`. A reviewer who did not write this specification or those statements approves them; no reviewer has yet, so nothing here counts toward the verified bar. Bytes are `List UInt8` throughout. diff --git a/Spec.lean b/Spec.lean index 5340939..276c463 100644 --- a/Spec.lean +++ b/Spec.lean @@ -7,22 +7,26 @@ proves exactly the statement written here. The `sorry` below is expected in this file only. - The statements use only the definitions in `Png/Value.lean`, + The statements use the definitions in `Png/Value.lean`, `Png/Parse.lean`, `Png/Crc32.lean`, and `Png/Header.lean`, the - code under proof named in `bar.toml`. No definition of their own - is needed, so this project has no `Spec/Defs.lean`. + code under proof named in `bar.toml`, and the well formedness + predicates and the written out table of allowed pairs + (`Spec.WF`, `Spec.WFHeader`, `Spec.WFFull`, `Spec.allowedPairs`, + and what they use), which are in `Spec/Defs.lean`. That file is + part of the specification too. -/ import Png +import Spec.Defs -open Png +open Png Spec /-! ## The round trips and the table The statements the slice stands on. Each one is the statement of the proved theorem of the same shape in `Proofs/RoundTrip.lean` or `Proofs/Header.lean`, over the same well formedness -predicates, `Png.WF`, `Png.WFHeader`, and `Png.WFFull`. -/ +predicates, `Spec.WF`, `Spec.WFHeader`, and `Spec.WFFull`. -/ /-- Parsing the printed form of a well formed chunk list gives the list back. So a printer that wrote nothing, or wrote diff --git a/Spec/Defs.lean b/Spec/Defs.lean new file mode 100644 index 0000000..0fa5a6d --- /dev/null +++ b/Spec/Defs.lean @@ -0,0 +1,79 @@ +/- + The definitions the statements in `Spec.lean` use besides the + code under proof: the IEND chunk as a value, well formedness at + the chunk layer (`WFChunk`, `WF`), the written out table of + allowed (colour type, bit depth) pairs, well formedness of a + header (`WFHeader`), and full well formedness of a document + with its header (`WFFull`). They give the statements their + meaning, and the readers and printers do not use them, so they + are part of the specification, with `Spec.lean` and `SPEC.md`, + not of the code (`bar.toml` lists the code). A change here + changes the spec hash, and so voids an approval. +-/ + +import Png + +namespace Spec + +open Png + +/-- The IEND chunk itself: type IEND, no data. -/ +def iendChunk : Chunk := ⟨73, 69, 78, 68, []⟩ + +/-- The per-chunk part of well formedness: each type byte is an + ASCII letter, and the data length stays below 2^31, as the PNG + specification bounds chunk lengths. -/ +def WFChunk (c : Chunk) : Prop := + isLetter c.t1 = true ∧ isLetter c.t2 = true ∧ + isLetter c.t3 = true ∧ isLetter c.t4 = true ∧ + c.data.length < 2147483648 + +/-- A document is well formed when it is a first chunk, then any + middle chunks, then the IEND chunk; the first chunk has type + IHDR and 13 data bytes; no chunk before the last is the IEND + chunk; and every chunk is a well formed chunk. -/ +def WF (doc : Doc) : Prop := + ∃ (head : Chunk) (mid : List Chunk), + doc = head :: mid ++ [iendChunk] ∧ + head.typeBytes = ihdrType ∧ head.data.length = 13 ∧ + (∀ c ∈ doc, WFChunk c) ∧ + (∀ c ∈ head :: mid, isIEND c = false) + +/-- The allowed (colour type, bit depth) pairs, written out in + full, as the PNG specification's table gives them. The + checker in `Png/Header.lean` is built from a per-type table; + `Spec.checkPair_iff` says the two agree on every colour type + byte and every bit depth byte. -/ +def allowedPairs : List (Nat × Nat) := + [(0, 1), (0, 2), (0, 4), (0, 8), (0, 16), + (2, 8), (2, 16), + (3, 1), (3, 2), (3, 4), (3, 8), + (4, 8), (4, 16), + (6, 8), (6, 16)] + +/-- A header is well formed when the width and the height are + above zero and below 2^31, the interlace byte is 0 or 1, and + the (colour type, bit depth) pair is in the table above. -/ +def WFHeader (h : Ihdr) : Prop := + 0 < h.width ∧ h.width < 2147483648 ∧ + 0 < h.height ∧ h.height < 2147483648 ∧ + (h.interlace = 0 ∨ h.interlace = 1) ∧ + (h.colorType.toNat, h.bitDepth.toNat) ∈ allowedPairs + +/-- A document is fully well formed, with header `hd`, when it + is well formed at the chunk layer (as in `WF`), its first + chunk's data is the printed form of `hd`, `hd` is well + formed, and no chunk after the first has type IHDR. The + header is a parameter, not a witness, so a statement over + `WFFull hd doc` can say which header a reader must return. + Stated in the shape of `WF`. -/ +def WFFull (hd : Ihdr) (doc : Doc) : Prop := + ∃ (head : Chunk) (mid : List Chunk), + doc = head :: mid ++ [iendChunk] ∧ + head.typeBytes = ihdrType ∧ head.data = printIhdr hd ∧ + WFHeader hd ∧ + (∀ c ∈ doc, WFChunk c) ∧ + (∀ c ∈ head :: mid, isIEND c = false) ∧ + (∀ c ∈ mid, c.typeBytes ≠ ihdrType) + +end Spec diff --git a/bar.toml b/bar.toml index ce857db..37cc46d 100644 --- a/bar.toml +++ b/bar.toml @@ -6,34 +6,42 @@ name = "png" # "in-progress" until every bar item passes, then "verified". status = "in-progress" page = "README.md" -# GitHub handles of everyone who wrote the specification. -spec_authors = ["abu-aska"] +# GitHub handles of everyone who wrote the specification. zero-aska +# changed it when carrying png#3 forward (the header in WFFull, the +# soundness statements, the single IHDR rule, and Spec/Defs.lean). +spec_authors = ["abu-aska", "zero-aska"] # The program people run. A change to it voids the approval. entry_points = ["Main.lean"] [spec] -files = ["SPEC.md", "Spec.lean"] -# The code under proof: the functions the statements are about. +files = ["SPEC.md", "Spec.lean", "Spec/Defs.lean"] +# The code under proof: the functions the statements are about. Well +# formedness (Spec.WFChunk, Spec.WF, Spec.WFHeader, Spec.WFFull), the +# IEND chunk as a value, and the written out table of allowed pairs only +# give the statements their meaning, so they are in the spec files +# above, not here. code = [ - # The data types the statements are about. - "Png.Chunk", "Png.Doc", "Png.Ihdr", + # The data types the statements are about, their constructors, and + # their fields. + "Png.Chunk", "Png.Chunk.mk", "Png.Chunk.t1", "Png.Chunk.t2", + "Png.Chunk.t3", "Png.Chunk.t4", "Png.Chunk.data", "Png.Doc", + "Png.Ihdr", "Png.Ihdr.mk", "Png.Ihdr.width", "Png.Ihdr.height", + "Png.Ihdr.bitDepth", "Png.Ihdr.colorType", "Png.Ihdr.interlace", # The byte forms and the printer. "Png.signature", "Png.Chunk.typeBytes", "Png.isLetter", - "Png.ihdrType", "Png.iendType", "Png.iendChunk", "Png.isIEND", + "Png.ihdrType", "Png.iendType", "Png.isIEND", "Png.be32", "Png.u32", "Png.chunkCrc", "Png.printChunk", "Png.print", # The CRC-32 the chunk checks use. "Png.Crc32.step", "Png.Crc32.step8", "Png.Crc32.table", "Png.Crc32.update", "Png.Crc32.crc32", - # Well formedness at the chunk layer. - "Png.WFChunk", "Png.WF", # The chunk readers and the parser. "Png.get4", "Png.parseChunk", "Png.parseChunks", "Png.parse", - # The IHDR header: the allowed table, the check, well - # formedness, the printer, the reader, and the file level read. - "Png.allowedDepths", "Png.pairAllowed", "Png.allowedPairs", - "Png.checkPair", "Png.WFHeader", "Png.printIhdr", - "Png.parseIhdr", "Png.headerOf", "Png.parseFile", "Png.WFFull", + # The IHDR header: the depth check, the printer, the reader, and + # the file level read with its single IHDR rule. + "Png.allowedDepths", "Png.pairAllowed", "Png.checkPair", + "Png.printIhdr", "Png.parseIhdr", "Png.isIHDR", "Png.headerOf", + "Png.parseFile", ] # One entry per approval, added in a pull request the reviewer approves. diff --git a/lakefile.toml b/lakefile.toml index c6d0450..b16f5bb 100644 --- a/lakefile.toml +++ b/lakefile.toml @@ -1,7 +1,7 @@ name = "png" version = "0.1.0" license = "Apache-2.0" -defaultTargets = ["Png", "Proofs", "png"] +defaultTargets = ["Png", "Spec", "Proofs", "png"] [[lean_lib]] name = "Png" From 83ebfc1cc3e9f9407a6981ee59f3c19e99788dbc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 20:09:02 +0000 Subject: [PATCH 09/14] Proofs: use the current names for if_pos, if_neg, if_false Lean 4.35 deprecates if_pos, if_neg and if_false in favour of ite_eq_left, ite_eq_right and ite_false, which have the same statements. The build now warns only on the expected sorry in each statement of the challenge file, Spec.lean. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AovXVgJBHGiN44TxQM4dcd --- Proofs/Header.lean | 12 ++++++------ Proofs/RoundTrip.lean | 12 ++++++------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/Proofs/Header.lean b/Proofs/Header.lean index 29f7962..57950b0 100644 --- a/Proofs/Header.lean +++ b/Proofs/Header.lean @@ -88,7 +88,7 @@ theorem pairAllowed_iff : ∀ n m : Nat, simp [allowedPairs] · have hdep : allowedDepths n = [] := by unfold allowedDepths - rw [if_neg h0, if_neg h2, if_neg h3, if_neg h4, if_neg h6] + rw [ite_eq_right h0, ite_eq_right h2, ite_eq_right h3, ite_eq_right h4, ite_eq_right h6] simp only [pairAllowed, decide_eq_true_eq, hdep] simp [allowedPairs, h0, h2, h3, h4, h6] @@ -118,11 +118,11 @@ theorem parseIhdr_print (h : Ihdr) (hwf : WFHeader h) : :: (hh / 256).toUInt8 :: hh.toUInt8 :: bd :: ct :: 0 :: 0 :: il :: [] := rfl rw [e, parseIhdr_step, u32_be32 w (by omega), u32_be32 hh (by omega)] - rw [if_neg (by simp : ¬ (([] : List UInt8) ≠ []))] - rw [if_neg (by omega : ¬ (w = 0 ∨ hh = 0))] - rw [if_neg (by omega : ¬ (w ≥ 2147483648 ∨ hh ≥ 2147483648))] - rw [if_neg (by decide : ¬ ((0 : UInt8) ≠ 0 ∨ (0 : UInt8) ≠ 0))] - rw [if_neg hnl, if_pos ((checkPair_iff ct bd).mpr hpair)] + rw [ite_eq_right (by simp : ¬ (([] : List UInt8) ≠ []))] + rw [ite_eq_right (by omega : ¬ (w = 0 ∨ hh = 0))] + rw [ite_eq_right (by omega : ¬ (w ≥ 2147483648 ∨ hh ≥ 2147483648))] + rw [ite_eq_right (by decide : ¬ ((0 : UInt8) ≠ 0 ∨ (0 : UInt8) ≠ 0))] + rw [ite_eq_right hnl, ite_eq_left ((checkPair_iff ct bd).mpr hpair)] /-- Four bytes read big endian and printed again are the same four bytes. -/ diff --git a/Proofs/RoundTrip.lean b/Proofs/RoundTrip.lean index a1945ee..5938a29 100644 --- a/Proofs/RoundTrip.lean +++ b/Proofs/RoundTrip.lean @@ -101,19 +101,19 @@ theorem parseChunk_printChunk (c : Chunk) (r : List UInt8) :: ((c.data ++ be32 (chunkCrc c).toNat) ++ r) := rfl rw [e, parseChunk_step, u32_be32 c.data.length hlen32] have hge : ¬ c.data.length ≥ 2147483648 := by omega - rw [if_neg hge] + rw [ite_eq_right hge] have hletters : (isLetter c.t1 && isLetter c.t2 && isLetter c.t3 && isLetter c.t4) = true := by simp [h1, h2, h3, h4] - rw [if_pos hletters] + rw [ite_eq_left hletters] rw [List.append_assoc, List.take_left, List.drop_left, get4_be32 _ hcrc32] - rw [if_neg (by simp : ¬ c.data.length ≠ c.data.length)] + rw [ite_eq_right (by simp : ¬ c.data.length ≠ c.data.length)] show (if (chunkCrc c).toNat = (Crc32.crc32 ([c.t1, c.t2, c.t3, c.t4] ++ c.data)).toNat then some (⟨c.t1, c.t2, c.t3, c.t4, c.data⟩, r) else none) = some (c, r) - rw [if_pos (show (chunkCrc c).toNat + rw [ite_eq_left (show (chunkCrc c).toNat = (Crc32.crc32 ([c.t1, c.t2, c.t3, c.t4] ++ c.data)).toNat from rfl)] /-- One step of the chunk list reader, stated for rewriting. -/ @@ -172,7 +172,7 @@ theorem parseChunks_printChunks : ∀ (pre : Doc) (last : Chunk) = printChunk c ++ (pre ++ [last]).flatMap printChunk := by simp [List.flatMap_cons] rw [hform, parseChunks_succ, hpc] - simp only [hcnot, Bool.false_eq_true, if_false, hI, + simp only [hcnot, Bool.false_eq_true, ite_false, hI, List.cons_append] /-- The signature at the front of a printed document. -/ @@ -224,6 +224,6 @@ theorem parse_print {doc : Doc} (h : WF doc) : parse (print doc) = some doc := b show (if head.typeBytes = ihdrType ∧ head.data.length = 13 then some (head :: mid ++ [iendChunk]) else none) = some (head :: mid ++ [iendChunk]) - rw [if_pos ⟨htype, hlen13⟩] + rw [ite_eq_left ⟨htype, hlen13⟩] end Png.Proofs From 87c4781daaf1cab04e3a5a945982c59bfae6021e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 20:13:29 +0000 Subject: [PATCH 10/14] Tests: compare the header read with libpng; say what IEND with data does Review 1 item 5b and the review of 437849b: the header sections had no original-reader oracle, only the ALLOWED table in the harness. libpng 1.6.43 is installed here, so the harness now uses it. - tests/libpng_header.c reads a file with png_read_info ("info") or decodes it whole, image and all, through png_read_end ("full"), and answers the header fields in the shape `header` uses. libpng's default width and height limits (one million) are raised to 2^31 - 1, the PNG specification's cap. - run_tests.py builds it when a C compiler and libpng are present, and otherwise records the section as skipped. 389 files: the real files, the colour type and depth grid, every allowed pair with and without Adam7 at three sizes (with an all zero image), the header rule cases, 150 random headers, and the chunk order cases. Each must agree with `header` field for field, or both refuse, except for three named differences, each a rule this reader leaves out on purpose or where libpng is more lenient: indexed colour with no PLTE, an IEND chunk with data before IDAT, and a file that ends in an IEND chunk with data. - A header bounds section: width or height 2^31 - 1 is accepted (review 1 asked for cases at 2^31 - 1, 2^31 and 2^32 - 1). - The record gains the Inputs field check.py reads (every request of the sections up to the libpng comparison, which runs only where libpng is installed and is counted on its own line), and the README status line says "in progress", as check.py looks for. - SPEC.md says what an IEND chunk with data does: it is an ordinary chunk at both layers, so a file ending in one has no end and is refused, and one followed by a real IEND is read. README.md lists it, and libpng's two differences, under what is not proved. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AovXVgJBHGiN44TxQM4dcd --- README.md | 25 ++-- SPEC.md | 4 +- tests/TEST-RESULTS.md | 47 ++++++- tests/libpng_header.c | 166 ++++++++++++++++++++++ tests/run_tests.py | 320 ++++++++++++++++++++++++++++++++++++++++-- 5 files changed, 538 insertions(+), 24 deletions(-) create mode 100644 tests/libpng_header.c diff --git a/README.md b/README.md index 01e6d49..78ae79a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # png: a PNG chunk parser and printer, with proved round trips -Status: in-progress +Status: in progress. No reviewer has approved the specification yet. A parser and printer for the chunk layer of PNG files, in Lean, following the PNG specification. It reads the signature, then @@ -47,8 +47,9 @@ fields. See `SPEC.md` for the full protocol. `python3 tests/run_tests.py` rebuilds the tool, runs the real files, the hand built cases, the differential runs, and the -header sections, and writes the outcome to -`tests/TEST-RESULTS.md`. +header sections, compares the header read with libpng where a +C compiler and libpng are installed (`tests/libpng_header.c`), +and writes the outcome to `tests/TEST-RESULTS.md`. ## What is proved @@ -143,10 +144,11 @@ about. ## What is not proved -- That the well formedness rules (`WF`, `WFHeader`, `WFFull`, and the table of allowed pairs) are the PNG specification's rules. The proofs tie each reader to its rule exactly, in both directions, but that the rule says what the PNG specification says is a reading: `SPEC.md` states it in words, and the tests compare the readers against a strict oracle written in the harness from those rules. +- That the well formedness rules (`WF`, `WFHeader`, `WFFull`, and the table of allowed pairs) are the PNG specification's rules. The proofs tie each reader to its rule exactly, in both directions, but that the rule says what the PNG specification says is a reading: `SPEC.md` states it in words, and the tests compare the readers against a strict oracle written in the harness from those rules, and the header read against libpng, file by file. Where libpng and this reader differ, the test record names each difference and the rule behind it. - That the CRC-32 here is the standard one. The round trip needs no such fact, since the parser and the printer compute the same function. The differential tests against Python zlib's `crc32` are what tie this function to the standard. - Anything inside a chunk's data past the IHDR header. The data of every other chunk type is opaque here. IDAT data is not inflated. No pixel is ever decoded. -- Chunk order past the first and last, except for IHDR. Which types may appear, how many times, and in what order between IHDR and IEND is not checked, with one exception: the file level read (`header`) refuses a second IHDR chunk. The chunk layer (`parse`) does not, and reads it as an ordinary chunk. A chunk of type IEND that carries data is read as an ordinary chunk at both layers. +- Chunk order past the first and last, except for IHDR. Which types may appear, how many times, and in what order between IHDR and IEND is not checked, with one exception: the file level read (`header`) refuses a second IHDR chunk. The chunk layer (`parse`) does not, and reads it as an ordinary chunk. So an indexed colour file with no PLTE chunk is accepted, where libpng refuses it. +- A chunk of type IEND that carries data. The PNG specification gives IEND no data, but this reader reads such a chunk as an ordinary chunk, at both layers: the file is accepted when an IEND chunk with no data ends it, and refused when none does. libpng refuses such a chunk before IDAT, and reads a file that ends in one, with a warning. `SPEC.md` states this reading, and the test record names both differences. ## What you must trust @@ -170,7 +172,9 @@ about. reading, pinned in `SPEC.md` where the PNG specification leaves a choice open. - Python's standard library, including zlib, as the oracle - in the tests. The results file names the versions used. + in the tests, and libpng as the oracle for the header read + where it is installed. The results file names the versions + used. - The line handling in `Main.lean` (splitting a request line, reading decimal codes). The tests drive it, and no proof covers it. @@ -187,10 +191,13 @@ about. and every other kernel shipped with Lean. 2. `python3 tests/run_tests.py`: the real files, the hand built cases, the differential runs against the oracle, - the mutations, and the header sections, with counts and - any failures written to `tests/TEST-RESULTS.md`. + the mutations, the header sections, and the libpng + comparison of the header read, with counts, named + differences, and any failures written to + `tests/TEST-RESULTS.md`. 3. `lake build`: the library, the proofs, and the tool build clean, and `lake env lean Proofs/Verify.lean` prints the - axiom list for the headline theorems. + axiom list for the headline theorems and for every theorem + `comparator.json` names. 4. Read `SPEC.md` against the PNG specification and check the pinned readings are the ones you want. diff --git a/SPEC.md b/SPEC.md index c1e6ad0..3c57d75 100644 --- a/SPEC.md +++ b/SPEC.md @@ -26,7 +26,7 @@ The CRC-32 is the one PNG and ZIP share: the ISO 3309 polynomial, reflected, the 3. Reading stops at a chunk whose type is IEND and whose data is empty. That chunk must be the end of the input: any byte after it rejects the file, and running out of input before such a chunk rejects the file. 4. The first chunk read has type IHDR and a data length of 13. -A chunk of type IEND that carries data is an ordinary chunk at this layer: reading does not stop at it. No other rule about chunk order is checked at this layer: which types may appear, how many times, or in what order past the first and last, is outside it. The file level read of slice 2, below, adds one such rule: a single IHDR chunk. At this layer the contents of the IHDR data, past its length, are not checked: width, height, bit depth, and colour type are read as data like any other. The header read of slice 2, below, is a second read on top of this one and does check them. +A chunk of type IEND that carries data is an ordinary chunk, here and in the file level read below: reading does not stop at it. The PNG specification gives IEND an empty data field, so such a chunk is not the IEND chunk, and this reading follows from that: a file in which an IEND chunk with data is followed, at the end, by an IEND chunk with no data is accepted, and a file whose last chunk is an IEND chunk with data has no end and is rejected. libpng reads both differently (it refuses an IEND chunk before IDAT as out of place, and it reads a file that ends in an IEND chunk with data, with a warning); the test record names both differences. No other rule about chunk order is checked at this layer: which types may appear, how many times, or in what order past the first and last, is outside it. The file level read of slice 2, below, adds one such rule: a single IHDR chunk. At this layer the contents of the IHDR data, past its length, are not checked: width, height, bit depth, and colour type are read as data like any other. The header read of slice 2, below, is a second read on top of this one and does check them. ## The IHDR header (slice 2) @@ -85,7 +85,7 @@ Each round trip has a proved converse, so each reader is pinned to its rule in b - The header reader is sound: a header it accepts is well formed, and the 13 bytes it read are that header's printed form. So a width or height of zero or of 2^31 or more, a nonzero compression or filter byte, an interlace byte above 1, a depth the colour type does not allow (by the table claim), and any length but 13 are all refused. - The file level read is sound: a file it accepts is the printed form of the chunk list it returns, that list is well formed at the chunk layer, its first chunk's data is the printed form of the header the read returns, that header is well formed, and no later chunk has type IHDR. -So every rule of this specification that the well formedness rules state is a proved rejection rule. What is not proved is that those rules are the PNG specification's: that is the reading this document pins. The test suite checks the readers against real PNG files built with Python's zlib and against a strict reading of these rules written in the harness, and the results record the counts. +So every rule of this specification that the well formedness rules state is a proved rejection rule. What is not proved is that those rules are the PNG specification's: that is the reading this document pins. The test suite checks the readers against real PNG files built with Python's zlib and against a strict reading of these rules written in the harness, checks the header read against libpng file by file where libpng is installed, and records the counts, with each difference from libpng named. The round trip needs no fact about CRC-32 itself: the parser and the printer compute the same function of the same bytes, so a printed file passes its own CRC checks by construction. That the function is the standard CRC-32 rests on the differential tests against Python zlib's `crc32`. diff --git a/tests/TEST-RESULTS.md b/tests/TEST-RESULTS.md index 18dc907..453e601 100644 --- a/tests/TEST-RESULTS.md +++ b/tests/TEST-RESULTS.md @@ -2,9 +2,11 @@ Written by `tests/run_tests.py`. Rerun it to check these numbers. -- Oracle: Python 3.12.3 `zlib` (crc32 and compress), plus a strict reading of the plan rules written in the harness +- Oracle: Python 3.13.17 `zlib` (crc32 and compress), plus a strict reading of the plan rules written in the harness; for the header read, also libpng where it is installed (below) - Lean: Lean (version 4.35.0, x86_64-unknown-linux-gnu, commit eb6cafb7290e8bd5f2937522d8d804f58f02bafc, Release) - Seed: 20261026 +- Inputs: 1483 +- Inputs counts every request of the sections below, up to the libpng comparison, which is counted on its own line - Real PNG files (zlib built): 4 of 4 accepted and reprinted byte for byte - Real PNG file: 1x1, bit depth 8, colour type 2 (RGB), 69 bytes - Real PNG file: 4x3, bit depth 8, colour type 6 (RGBA), 119 bytes @@ -20,19 +22,58 @@ Written by `tests/run_tests.py`. Rerun it to check these numbers. - Hand-built headers, every colour type and allowed depth: 15 of 15 accepted with their fields - Header enumeration, 14 colour types by 9 depths against the allowed table: 126 of 126 agreements - Header mutations rejected: 10 of 10 +- Chunk order cases through `header`: 4 of 4 agreements +- The same cases through `parse`, the chunk layer: 4 of 4 agreements +- Headers at the cap, width or height 2^31 - 1, accepted: 3 of 3 - Random well formed headers: 200 of 200 decoded to the fields they were built with - Random header files through `parse`: 200 of 200 reprinted byte for byte - Malformed header requests answered error: 3 of 3 - Header requests with no codes answered none: 2 of 2 +## libpng comparison of the header read + +Each file is read by libpng (`tests/libpng_header.c`, built against the system libpng, with its default width and height limits raised to 2^31 - 1) and by the tool's `header` request, and the two answers must agree: the same fields, or both refuse. The files: the real files, the colour type and depth grid, every allowed pair with and without Adam7 at three sizes, the header rule cases, 150 random well formed headers, and the chunk order cases. Whole files are decoded by libpng, image and all, except for the header rule cases, which libpng reads up to IDAT. + +- libpng header oracle (built 1.6.43, running 1.6.43, `tests/libpng_header.c`): 389 files, 386 agreements, 3 named differences +- Named differences: 3 +- Named difference, an IEND chunk with data before IDAT, a real IEND at the end (1): libpng refuses the file (IEND out of place); this reader accepts it; an IEND chunk that carries data is an ordinary chunk here (SPEC.md, Chunks) +- Named difference, an IEND chunk with data as the last chunk (1): libpng reads the file, with a warning; this reader refuses it; the file has no IEND chunk with empty data, which the PNG specification requires (SPEC.md, Chunks) +- Named difference, indexed colour with no PLTE chunk (1): libpng refuses the file (no PLTE before IDAT); this reader accepts it; which chunks must appear between IHDR and IEND is not checked (SPEC.md, Chunks) + ## Axiom check -The output of `#print axioms` for the four named theorems, from `lake env lean Proofs/Verify.lean`: +The output of `#print axioms` for the 7 headline theorems and the 22 theorems `comparator.json` names, from `lake env lean Proofs/Verify.lean`. The harness reads the names, and fails on any axiom but `propext`, `Classical.choice`, `Quot.sound`. A self-test on planted output (a `sorryAx`, an axiom of a proof's own, a missing line) runs first. - 'Png.Proofs.parse_print' depends on axioms: [propext, Quot.sound] - 'Png.Proofs.parseIhdr_print' depends on axioms: [propext, Quot.sound] - 'Png.Proofs.checkPair_iff' depends on axioms: [propext, Quot.sound] - 'Png.Proofs.parseFile_print' depends on axioms: [propext, Quot.sound] +- 'Png.Proofs.parse_sound' depends on axioms: [propext, Quot.sound] +- 'Png.Proofs.parseIhdr_sound' depends on axioms: [propext, Quot.sound] +- 'Png.Proofs.parseFile_sound' depends on axioms: [propext, Quot.sound] +- 'Spec.parse_print' depends on axioms: [propext, Quot.sound] +- 'Spec.parseIhdr_print' depends on axioms: [propext, Quot.sound] +- 'Spec.checkPair_iff' depends on axioms: [propext, Quot.sound] +- 'Spec.parseFile_print' depends on axioms: [propext, Quot.sound] +- 'Spec.parse_sound' depends on axioms: [propext, Quot.sound] +- 'Spec.parseIhdr_sound' depends on axioms: [propext, Quot.sound] +- 'Spec.parseFile_sound' depends on axioms: [propext, Quot.sound] +- 'Spec.rejects_bad_signature' depends on axioms: [propext, Quot.sound] +- 'Spec.rejects_bad_crc' depends on axioms: [propext, Quot.sound] +- 'Spec.rejects_truncated_input' depends on axioms: [propext, Quot.sound] +- 'Spec.rejects_first_chunk_length_12' depends on axioms: [propext, Quot.sound] +- 'Spec.rejects_zero_width' depends on axioms: [propext, Quot.sound] +- 'Spec.rejects_zero_height' depends on axioms: [propext, Quot.sound] +- 'Spec.rejects_width_at_cap' depends on axioms: [propext, Quot.sound] +- 'Spec.rejects_height_at_cap' depends on axioms: [propext, Quot.sound] +- 'Spec.rejects_illegal_depth' depends on axioms: [propext, Quot.sound] +- 'Spec.rejects_unknown_colour_type' depends on axioms: [propext, Quot.sound] +- 'Spec.rejects_unknown_interlace' depends on axioms: [propext, Quot.sound] +- 'Spec.rejects_nonzero_compression' depends on axioms: [propext, Quot.sound] +- 'Spec.rejects_nonzero_filter' depends on axioms: [propext, Quot.sound] +- 'Spec.rejects_second_ihdr' depends on axioms: [propext, Quot.sound] +- 'Spec.rejects_header_wrong_length' depends on axioms: [propext, Quot.sound] + +## Outcome -- Named differences: none - Failures: 0 diff --git a/tests/libpng_header.c b/tests/libpng_header.c new file mode 100644 index 0000000..ee24ca0 --- /dev/null +++ b/tests/libpng_header.c @@ -0,0 +1,166 @@ +/* + The libpng oracle for the header tests in run_tests.py. + + Reads one request per line and prints one answer per line. A + request is a mode, a space, and the bytes of a PNG file in hex. + + info read the file with png_read_info, which reads the + signature and every chunk up to the first IDAT, + and checks the IHDR fields (png_check_IHDR). + full the same, then decode the image (png_read_image) + and read the rest of the file to IEND + (png_read_end), so chunks after IDAT are seen too. + version print the libpng version this was built against + and the one it runs with. + + The answer is `ok:` and the header fields as text, in the shape + the Lean tool's `header` request uses (`1920x1080 d8 t6 i0`), or + `none:` and libpng's error message. Warnings do not change the + answer; the count of them is added as ` warnings:N` when there + are any. + + libpng's default limits on width and height (one million each) + are raised to 2^31 - 1, the PNG specification's own cap, so that + the comparison is with the specification's rule and not with a + library default. Everything else is libpng's default handling. + + Built by tests/run_tests.py when a C compiler and the libpng + headers are present; the header oracle section is skipped, and + says so, when they are not. +*/ + +#include +#include +#include +#include + +struct source { + const unsigned char *data; + size_t len; + size_t pos; +}; + +static char error_text[512]; +static int warnings; + +static void read_bytes(png_structp png, png_bytep out, png_size_t n) { + struct source *src = (struct source *)png_get_io_ptr(png); + if (n > src->len - src->pos) { + png_error(png, "input ends early"); + } + memcpy(out, src->data + src->pos, n); + src->pos += n; +} + +static void on_error(png_structp png, png_const_charp msg) { + snprintf(error_text, sizeof error_text, "%s", msg); + png_longjmp(png, 1); +} + +static void on_warning(png_structp png, png_const_charp msg) { + (void)png; + (void)msg; + warnings++; +} + +static int hex_value(int c) { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; +} + +/* Rows of the decoded image; global so the error path can free them. */ +static png_bytep *rows; +static png_uint_32 row_count; + +static void free_rows(void) { + if (rows) { + for (png_uint_32 i = 0; i < row_count; i++) free(rows[i]); + free(rows); + } + rows = NULL; + row_count = 0; +} + +static void answer(int full, const unsigned char *data, size_t len) { + png_structp png; + png_infop info; + struct source src = {data, len, 0}; + png_uint_32 width, height; + int depth, ctype, interlace; + + error_text[0] = 0; + warnings = 0; + png = png_create_read_struct(PNG_LIBPNG_VER_STRING, NULL, on_error, on_warning); + if (!png) { + printf("none:could not create a read struct\n"); + return; + } + info = png_create_info_struct(png); + if (!info) { + png_destroy_read_struct(&png, NULL, NULL); + printf("none:could not create an info struct\n"); + return; + } + if (setjmp(png_jmpbuf(png))) { + free_rows(); + png_destroy_read_struct(&png, &info, NULL); + printf("none:%s\n", error_text); + return; + } + png_set_user_limits(png, 0x7fffffffU, 0x7fffffffU); + png_set_read_fn(png, &src, read_bytes); + png_read_info(png, info); + png_get_IHDR(png, info, &width, &height, &depth, &ctype, &interlace, NULL, NULL); + if (full) { + png_size_t rowbytes; + png_set_interlace_handling(png); + png_read_update_info(png, info); + rowbytes = png_get_rowbytes(png, info); + rows = (png_bytep *)calloc(height, sizeof *rows); + if (!rows) png_error(png, "out of memory"); + row_count = height; + for (png_uint_32 i = 0; i < height; i++) { + rows[i] = (png_bytep)malloc(rowbytes ? rowbytes : 1); + if (!rows[i]) png_error(png, "out of memory"); + } + png_read_image(png, rows); + png_read_end(png, NULL); + free_rows(); + } + png_destroy_read_struct(&png, &info, NULL); + printf("ok:%lux%lu d%d t%d i%d", (unsigned long)width, (unsigned long)height, depth, + ctype, interlace); + if (warnings) printf(" warnings:%d", warnings); + printf("\n"); +} + +int main(void) { + static char line[1 << 20]; + static unsigned char bytes[1 << 19]; + while (fgets(line, sizeof line, stdin)) { + size_t n = strlen(line); + while (n && (line[n - 1] == '\n' || line[n - 1] == '\r')) line[--n] = 0; + if (strcmp(line, "version") == 0) { + printf("ok:built %s, running %s\n", PNG_LIBPNG_VER_STRING, png_get_libpng_ver(NULL)); + } else if (strncmp(line, "info ", 5) == 0 || strncmp(line, "full ", 5) == 0) { + const char *hex = line + 5; + size_t len = strlen(hex); + size_t count = 0; + int bad = (len % 2 != 0) || (len / 2 > sizeof bytes); + for (size_t i = 0; !bad && i < len; i += 2) { + int hi = hex_value(hex[i]); + int lo = hex_value(hex[i + 1]); + if (hi < 0 || lo < 0) bad = 1; + else bytes[count++] = (unsigned char)(hi * 16 + lo); + } + if (bad) printf("error\n"); + else answer(line[0] == 'f', bytes, count); + } else { + printf("error\n"); + } + fflush(stdout); + } + return 0; +} diff --git a/tests/run_tests.py b/tests/run_tests.py index 1ee16b3..9c32399 100644 --- a/tests/run_tests.py +++ b/tests/run_tests.py @@ -1,9 +1,10 @@ #!/usr/bin/env python3 """Tests for the Lean PNG chunk parser and printer. -Five parts. Parts 1 to 4 are the slice 1 suite, unchanged. +Six parts. Parts 1 to 4 are the slice 1 suite, unchanged. Part 5 is slice 2: the IHDR header read through the `header` -request. +request. Part 6 compares that read with libpng, where libpng is +installed. 1. Real PNG files built with Python zlib: an IHDR chunk (the width, height, bit depth, and colour type are stated per file @@ -42,11 +43,23 @@ 2^31, a height of 2^31, a width of 2^32 - 1, a height of 2^32 - 1, a compression byte of 1, a filter byte of 1, an interlace byte of 2, a first chunk of length 12) must be - rejected. A second IHDR chunk must be rejected by `header` - and read by `parse`; an IEND chunk with data is an ordinary - chunk at both layers. Random well formed headers are built - into whole files and checked through `header` for their - fields and through `parse` for a byte for byte reprint. + rejected, and a width or height of 2^31 - 1 accepted. A + second IHDR chunk must be rejected by `header` and read by + `parse`; an IEND chunk with data is an ordinary chunk at both + layers. Random well formed headers are built into whole + files and checked through `header` for their fields and + through `parse` for a byte for byte reprint. + +6. The libpng comparison. tests/libpng_header.c is built + against the system libpng (skipped, and said so, when there + is no C compiler or no libpng). Each file is read by libpng + and by `header`, and the answers must agree field for field, + or both refuse, except in the named differences listed in + LIBPNG_NAMED. The files: the real files, the colour type and + depth grid, every allowed pair with and without Adam7 at + three sizes (with an all zero image libpng decodes), the + header rule cases, 150 random well formed headers, and the + chunk order cases. python3 tests/run_tests.py [--seed N] [--count N] @@ -60,6 +73,7 @@ import json import random import re +import shutil import struct import subprocess import sys @@ -156,8 +170,13 @@ def drain(stream, buf, cap): return answers, problem +ASKED = [0] + + def ask_all(lines): - """Chunked run_tool. Answers align with lines, or None entries.""" + """Chunked run_tool. Answers align with lines, or None entries. + Every request counts toward the record's Inputs.""" + ASKED[0] += len(lines) answers = [] problems = [] for i in range(0, len(lines), CHUNK): @@ -784,6 +803,252 @@ def run_header_random(seed, count=200): return lines, failures +def run_header_bounds(): + """The largest width and height the rules allow, 2^31 - 1, are + accepted with their fields: the cap is exact on both sides.""" + top = 2**31 - 1 + cases = [(top, 3, 8, 6, 0), (4, top, 8, 6, 0), (top, top, 8, 0, 1)] + lines = [] + failures = [] + answers, problems = ask_all( + [header_request(header_file(ihdr_full(w, h, d, t, interlace=i))) for (w, h, d, t, i) in cases] + ) + for problem in problems: + failures.append(f"header bounds: tool problem: {problem}") + agree = 0 + for fields, ans in zip(cases, answers): + want = header_line(*fields) + if ans == want: + agree += 1 + else: + failures.append(f"header bound {fields}: want {want!r}, got {ans!r}") + lines.append(f"Headers at the cap, width or height 2^31 - 1, accepted: {agree} of {len(cases)}") + return lines, failures + + +# ---------------------------------------------------------------- the +# libpng oracle for the header read. tests/libpng_header.c is built +# against the system libpng, when there is one, and each file below +# is read by libpng and by the Lean tool's `header` request. The +# answers must agree, field for field, except in the named +# differences listed here, each of which is a rule this reader +# leaves out on purpose (SPEC.md says so) or a place libpng is more +# lenient than the PNG specification. + +LIBPNG_SRC = ROOT / "tests" / "libpng_header.c" +LIBPNG_EXE = ROOT / ".lake" / "oracle" / "libpng_header" + +CHANNELS = {0: 1, 2: 3, 3: 1, 4: 2, 6: 4} +ADAM7 = [(0, 0, 8, 8), (4, 0, 8, 8), (0, 4, 4, 8), (2, 0, 4, 4), (0, 2, 2, 4), (1, 0, 2, 2), (0, 1, 1, 2)] + +# Named differences: case name -> (what libpng answers, why). +LIBPNG_NAMED = { + "indexed colour with no PLTE chunk": ( + "libpng refuses the file (no PLTE before IDAT); this reader accepts it", + "which chunks must appear between IHDR and IEND is not checked (SPEC.md, Chunks)", + ), + "an IEND chunk with data before IDAT, a real IEND at the end": ( + "libpng refuses the file (IEND out of place); this reader accepts it", + "an IEND chunk that carries data is an ordinary chunk here (SPEC.md, Chunks)", + ), + "an IEND chunk with data as the last chunk": ( + "libpng reads the file, with a warning; this reader refuses it", + "the file has no IEND chunk with empty data, which the PNG specification requires (SPEC.md, Chunks)", + ), +} + + +def build_libpng(): + """Build the libpng oracle. Returns (version text, None) or + (None, the reason it could not be built).""" + cc = shutil.which("cc") or shutil.which("gcc") or shutil.which("clang") + if not cc: + return None, "no C compiler" + flags = ["-lpng16"] + if shutil.which("pkg-config"): + pc = subprocess.run( + ["pkg-config", "--cflags", "--libs", "libpng"], capture_output=True, text=True + ) + if pc.returncode == 0 and pc.stdout.split(): + flags = pc.stdout.split() + LIBPNG_EXE.parent.mkdir(parents=True, exist_ok=True) + build = subprocess.run( + [cc, "-O1", "-o", str(LIBPNG_EXE), str(LIBPNG_SRC), *flags], + capture_output=True, + text=True, + ) + if build.returncode != 0: + tail = " / ".join(build.stderr.strip().splitlines()[-2:]) + return None, f"libpng oracle did not build: {tail}" + run = subprocess.run([str(LIBPNG_EXE)], input="version\n", capture_output=True, text=True) + if run.returncode != 0 or not run.stdout.startswith("ok:"): + return None, "libpng oracle did not run" + return run.stdout[3:].strip(), None + + +def ask_libpng(requests): + run = subprocess.run( + [str(LIBPNG_EXE)], + input="".join(r + "\n" for r in requests), + capture_output=True, + text=True, + timeout=TIMEOUT, + ) + answers = run.stdout.split("\n") + if answers and answers[-1] == "": + answers.pop() + if run.returncode != 0 or len(answers) != len(requests): + return None + return answers + + +def raw_image(width, height, depth, colour_type, interlace): + """Filtered scanlines of an all zero image: a filter byte of 0, + then zero pixels, for each row of each Adam7 pass when the + image is interlaced.""" + bits = CHANNELS.get(colour_type, 1) * depth + + def rows(w, h): + return (b"\x00" * (1 + (w * bits + 7) // 8)) * h + + if interlace != 1: + return rows(width, height) + out = b"" + for x0, y0, dx, dy in ADAM7: + pw = (width - x0 + dx - 1) // dx if width > x0 else 0 + ph = (height - y0 + dy - 1) // dy if height > y0 else 0 + if pw and ph: + out += rows(pw, ph) + return out + + +def oracle_png(width, height, depth, colour_type, interlace=0, comp=0, filt=0, plte=True, raw=None): + """A whole file whose image data decodes, for colour types and + depths the table allows: indexed colour gets a one entry PLTE + chunk, and the IDAT chunk holds an all zero image.""" + if raw is None: + if colour_type in CHANNELS and depth in ALLOWED.get(colour_type, []): + raw = raw_image(width, height, depth, colour_type, interlace) + else: + raw = b"\x00" + parts = [SIG, make_chunk(b"IHDR", ihdr_full(width, height, depth, colour_type, comp, filt, interlace))] + if colour_type == 3 and plte: + parts.append(make_chunk(b"PLTE", b"\x00\x00\x00")) + parts.append(make_chunk(b"IDAT", zlib.compress(raw))) + parts.append(make_chunk(b"IEND", b"")) + return b"".join(parts) + + +def libpng_cases(seed): + """(name, mode, file) for the libpng comparison. `full` reads the + whole file and decodes the image; `info` reads up to IDAT and is + used where the image would be too large to decode.""" + cases = [] + for name, data in real_files(seed): + cases.append((f"real file: {name}", "full", data)) + for t in list(range(13)) + [255]: + for d in [0, 1, 2, 3, 4, 5, 8, 16, 255]: + cases.append((f"grid: colour type {t}, depth {d}", "full", oracle_png(3, 2, d, t))) + for t in sorted(ALLOWED): + for d in ALLOWED[t]: + for i in (0, 1): + for w, h in ((1, 1), (3, 2), (9, 9)): + cases.append((f"allowed pair: {w}x{h} d{d} t{t} i{i}", "full", oracle_png(w, h, d, t, i))) + top = 2**31 - 1 + z = b"\x00" # read up to IDAT only, so the image data is not looked at + for name, data in [ + ("a width of zero", oracle_png(0, 3, 8, 6, raw=z)), + ("a height of zero", oracle_png(4, 0, 8, 6, raw=z)), + ("a width of 2^31", oracle_png(2**31, 3, 8, 6, raw=z)), + ("a height of 2^31", oracle_png(4, 2**31, 8, 6, raw=z)), + ("a width of 2^32 - 1", oracle_png(2**32 - 1, 3, 8, 6, raw=z)), + ("a height of 2^32 - 1", oracle_png(4, 2**32 - 1, 8, 6, raw=z)), + ("a compression byte of 1", oracle_png(4, 3, 8, 6, comp=1, raw=z)), + ("a filter byte of 1", oracle_png(4, 3, 8, 6, filt=1, raw=z)), + ("an interlace byte of 2", oracle_png(4, 3, 8, 6, interlace=2, raw=z)), + ("a width of 2^31 - 1", oracle_png(top, 3, 8, 6, raw=z)), + ("a height of 2^31 - 1", oracle_png(4, top, 8, 6, raw=z)), + ("a width and height of 2^31 - 1, Adam7", oracle_png(top, top, 8, 0, 1, raw=z)), + ]: + cases.append((f"header rule: {name}", "info", data)) + for name, cut in [("an IHDR chunk of 12 bytes", 12), ("an IHDR chunk of 14 bytes", 14)]: + body = (ihdr_full(4, 3, 8, 6) + b"\x00")[:cut] + data = ( + SIG + + make_chunk(b"IHDR", body) + + make_chunk(b"IDAT", zlib.compress(raw_image(4, 3, 8, 6, 0))) + + make_chunk(b"IEND", b"") + ) + cases.append((f"header rule: {name}", "info", data)) + rng = random.Random(seed + 13) + for k in range(150): + t = rng.choice(sorted(ALLOWED)) + d = rng.choice(ALLOWED[t]) + w, h, i = rng.randrange(1, 40), rng.randrange(1, 40), rng.randrange(0, 2) + cases.append((f"random: {w}x{h} d{d} t{t} i{i}", "full", oracle_png(w, h, d, t, i))) + ihdr1 = make_chunk(b"IHDR", ihdr_full(1, 1, 8, 2)) + ihdr2 = make_chunk(b"IHDR", ihdr_full(2, 2, 8, 2)) + idat = make_chunk(b"IDAT", zlib.compress(raw_image(1, 1, 8, 2, 0))) + iend = make_chunk(b"IEND", b"") + iend_data = make_chunk(b"IEND", b"\x01\x02") + cases += [ + ("a second IHDR right after the first", "full", SIG + ihdr1 + ihdr2 + idat + iend), + ("a second IHDR after IDAT", "full", SIG + ihdr1 + idat + ihdr2 + iend), + ("an IEND chunk with data before IDAT, a real IEND at the end", "full", SIG + ihdr1 + iend_data + idat + iend), + ("an IEND chunk with data as the last chunk", "full", SIG + ihdr1 + idat + iend_data), + ("indexed colour with no PLTE chunk", "full", oracle_png(3, 2, 8, 3, plte=False)), + ] + return cases + + +def lean_header_text(ans): + """The Lean tool's `header` answer as the same text libpng's + answer uses: `ok:` and the fields, or `none`.""" + if ans is None or not ans.startswith("ok:"): + return ans + try: + return "ok:" + bytes(int(c) for c in ans[3:].split(",")).decode("ascii") + except ValueError: + return ans + + +def run_libpng(seed): + """Returns (lines, failures, inputs, named): the record lines, + the failures, the count of files compared, and the named + differences met, with their counts.""" + version, why = build_libpng() + if version is None: + return [f"libpng header oracle: skipped, {why}"], [], 0, {} + cases = libpng_cases(seed) + lp = ask_libpng([f"{mode} {data.hex()}" for _, mode, data in cases]) + if lp is None: + return [f"libpng header oracle ({version}): did not answer"], ["libpng oracle did not answer"], 0, {} + lean, problems = ask_all([header_request(data) for _, _, data in cases]) + failures = [f"libpng comparison: tool problem: {p}" for p in problems] + agree = 0 + named = {} + for (name, mode, _), want, got in zip(cases, lp, lean): + theirs = want.split(" warnings:")[0] + ours = lean_header_text(got) + same = (theirs.startswith("ok:") and ours == theirs) or ( + theirs.startswith("none:") and ours == "none" + ) + if same: + agree += 1 + elif name in LIBPNG_NAMED: + named[name] = named.get(name, 0) + 1 + else: + failures.append(f"libpng comparison {name} ({mode}): libpng {want!r}, this reader {ours!r}") + lines = [ + f"libpng header oracle ({version}, `tests/libpng_header.c`): {len(cases)} files, " + f"{agree} agreements, {sum(named.values())} named differences" + ] + for name in LIBPNG_NAMED: + if name in [c[0] for c in cases] and name not in named: + failures.append(f"libpng comparison {name}: the named difference did not occur") + return lines, failures, len(cases), named + + def run_header_malformed(): lines = [] failures = [] @@ -916,12 +1181,20 @@ def main(): run_header_grid(), run_header_mutations(), run_header_chunk_order(), + run_header_bounds(), run_header_random(args.seed), run_header_malformed(), ): lines, failures = part all_lines += lines all_failures += failures + # The record's Inputs: every request of the sections above. The + # libpng comparison below runs only where libpng is installed, so + # it is counted on its own line, and a run without libpng records + # the same Inputs. + inputs = ASKED[0] + libpng_lines, libpng_failures, libpng_inputs, named = run_libpng(args.seed) + all_failures += libpng_failures lean_version = subprocess.run( ["lake", "env", "lean", "--version"], @@ -962,13 +1235,39 @@ def main(): out.append("") out.append( f"- Oracle: Python {sys.version.split()[0]} `zlib` (crc32 and compress), " - "plus a strict reading of the plan rules written in the harness" + "plus a strict reading of the plan rules written in the harness; " + "for the header read, also libpng where it is installed (below)" ) out.append(f"- Lean: {lean_version}") out.append(f"- Seed: {args.seed}") + out.append(f"- Inputs: {inputs}") + out.append( + "- Inputs counts every request of the sections below, up to the libpng " + "comparison, which is counted on its own line" + ) for line in all_lines: out.append(f"- {line}") out.append("") + out.append("## libpng comparison of the header read") + out.append("") + out.append( + "Each file is read by libpng (`tests/libpng_header.c`, built against the " + "system libpng, with its default width and height limits raised to " + "2^31 - 1) and by the tool's `header` request, and the two answers must " + "agree: the same fields, or both refuse. The files: the real files, the " + "colour type and depth grid, every allowed pair with and without Adam7 at " + "three sizes, the header rule cases, 150 random well formed headers, and " + "the chunk order cases. Whole files are decoded by libpng, image and all, " + "except for the header rule cases, which libpng reads up to IDAT." + ) + out.append("") + for line in libpng_lines: + out.append(f"- {line}") + out.append(f"- Named differences: {sum(named.values())}") + for name, count in named.items(): + what, why = LIBPNG_NAMED[name] + out.append(f"- Named difference, {name} ({count}): {what}; {why}") + out.append("") out.append("## Axiom check") out.append("") out.append( @@ -984,7 +1283,8 @@ def main(): for line in axiom_lines: out.append(f"- {line}") out.append("") - out.append("- Named differences: none") + out.append("## Outcome") + out.append("") out.append(f"- Failures: {len(all_failures)}") for failure in all_failures[:50]: out.append(f"- FAILURE: {failure}") From 0c71c0780626c36fd6dbd7f4a1666c37b4b68292 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 20:22:57 +0000 Subject: [PATCH 11/14] Docs: name the single IHDR rule in WFFull, and libpng's IEND cases exactly README.md's account of WFFull now lists the single IHDR clause, and the reflow of one paragraph. SPEC.md and README.md say which IEND case libpng refuses (an IEND chunk with data before IDAT) and which it reads with a warning (a file ending in one), as the test record shows, and SPEC.md names Spec.parse_sound for the byte for byte reprint claim. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AovXVgJBHGiN44TxQM4dcd --- README.md | 17 +++++++++-------- SPEC.md | 4 ++-- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 78ae79a..872a84e 100644 --- a/README.md +++ b/README.md @@ -91,9 +91,10 @@ data length is below 2^31. A header is well formed when its width and height are above zero and below 2^31, its interlace byte is 0 or 1, and its bit depth is one its colour type allows. A document is fully well formed with header `hd` -(`WFFull hd doc`) when it is well formed at the chunk layer and +(`WFFull hd doc`) when it is well formed at the chunk layer, its first chunk's data is the printed form of `hd`, a well -formed header. The header is a parameter of the predicate, so +formed header, and no chunk after the first has type IHDR. +The header is a parameter of the predicate, so the file level round trip names the header the read must return: a reader that answered a fixed header, or swapped the width and the height, would not meet it. @@ -112,7 +113,8 @@ for a `doc` fully well formed with header `hd`, and then returns `hd` and `doc` (`Spec.parseFile_print` with `Spec.parseFile_sound`). Every rule in these predicates is therefore a proved rejection rule. The named refusal theorems -below the soundness theorems pin single inputs on top. +in the table are worked examples of those rules, one input +each. The headline theorems rest on the proofs in `Proofs/RoundTrip.lean`, `Proofs/Header.lean`, and @@ -137,10 +139,9 @@ formedness rules (`Spec.WFChunk`, `Spec.WF`, `Spec.WFHeader`, (`Spec.allowedPairs`) give the statements their meaning and no code uses them, so they are part of the specification, in `Spec/Defs.lean`, and a change to them voids an approval. The -command line tool in -`Main.lean` calls `Png.parse`, `Png.print`, and -`Png.parseFile` directly, the same functions the theorems are -about. +command line tool in `Main.lean` calls `Png.parse`, +`Png.print`, and `Png.parseFile` directly, the same functions +the theorems are about. ## What is not proved @@ -148,7 +149,7 @@ about. - That the CRC-32 here is the standard one. The round trip needs no such fact, since the parser and the printer compute the same function. The differential tests against Python zlib's `crc32` are what tie this function to the standard. - Anything inside a chunk's data past the IHDR header. The data of every other chunk type is opaque here. IDAT data is not inflated. No pixel is ever decoded. - Chunk order past the first and last, except for IHDR. Which types may appear, how many times, and in what order between IHDR and IEND is not checked, with one exception: the file level read (`header`) refuses a second IHDR chunk. The chunk layer (`parse`) does not, and reads it as an ordinary chunk. So an indexed colour file with no PLTE chunk is accepted, where libpng refuses it. -- A chunk of type IEND that carries data. The PNG specification gives IEND no data, but this reader reads such a chunk as an ordinary chunk, at both layers: the file is accepted when an IEND chunk with no data ends it, and refused when none does. libpng refuses such a chunk before IDAT, and reads a file that ends in one, with a warning. `SPEC.md` states this reading, and the test record names both differences. +- A chunk of type IEND that carries data. The PNG specification gives IEND no data, but this reader reads such a chunk as an ordinary chunk, at both layers: the file is accepted when an IEND chunk with no data ends it, and refused when none does. libpng refuses such a chunk when it comes before IDAT, and reads a file that ends in one, with a warning. `SPEC.md` states this reading, and the test record names both differences. ## What you must trust diff --git a/SPEC.md b/SPEC.md index 3c57d75..efdaddb 100644 --- a/SPEC.md +++ b/SPEC.md @@ -26,7 +26,7 @@ The CRC-32 is the one PNG and ZIP share: the ISO 3309 polynomial, reflected, the 3. Reading stops at a chunk whose type is IEND and whose data is empty. That chunk must be the end of the input: any byte after it rejects the file, and running out of input before such a chunk rejects the file. 4. The first chunk read has type IHDR and a data length of 13. -A chunk of type IEND that carries data is an ordinary chunk, here and in the file level read below: reading does not stop at it. The PNG specification gives IEND an empty data field, so such a chunk is not the IEND chunk, and this reading follows from that: a file in which an IEND chunk with data is followed, at the end, by an IEND chunk with no data is accepted, and a file whose last chunk is an IEND chunk with data has no end and is rejected. libpng reads both differently (it refuses an IEND chunk before IDAT as out of place, and it reads a file that ends in an IEND chunk with data, with a warning); the test record names both differences. No other rule about chunk order is checked at this layer: which types may appear, how many times, or in what order past the first and last, is outside it. The file level read of slice 2, below, adds one such rule: a single IHDR chunk. At this layer the contents of the IHDR data, past its length, are not checked: width, height, bit depth, and colour type are read as data like any other. The header read of slice 2, below, is a second read on top of this one and does check them. +A chunk of type IEND that carries data is an ordinary chunk, here and in the file level read below: reading does not stop at it. The PNG specification gives IEND an empty data field, so such a chunk is not the IEND chunk, and this reading follows from that: a file in which an IEND chunk with data is followed, at the end, by an IEND chunk with no data is accepted, and a file whose last chunk is an IEND chunk with data has no end and is rejected. libpng differs on two such files: it refuses an IEND chunk with data that comes before IDAT, as out of place, where this reader reads past it; and it reads a file that ends in an IEND chunk with data, with a warning, where this reader refuses it. The test record names both differences. No other rule about chunk order is checked at this layer: which types may appear, how many times, or in what order past the first and last, is outside it. The file level read of slice 2, below, adds one such rule: a single IHDR chunk. At this layer the contents of the IHDR data, past its length, are not checked: width, height, bit depth, and colour type are read as data like any other. The header read of slice 2, below, is a second read on top of this one and does check them. ## The IHDR header (slice 2) @@ -67,7 +67,7 @@ The file level read also rejects a file in which any chunk after the first has t ## Printing -The printer writes the signature, then each chunk as its length, its type bytes, its data, and the CRC it computes over the type bytes and the data. A file the parser accepts is reprinted byte for byte: the lengths and CRCs it carried were checked against the bytes, so printing writes the same values back. +The printer writes the signature, then each chunk as its length, its type bytes, its data, and the CRC it computes over the type bytes and the data. A file the parser accepts is reprinted byte for byte: the lengths and CRCs it carried were checked against the bytes, so printing writes the same values back. This is proved: a file the parser accepts is the printed form of the chunk list read from it (`Spec.parse_sound`). ## The claim From 39ae72a6efb5f7831a2508711b70e7dbb1d21960 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 21:38:19 +0000 Subject: [PATCH 12/14] State the byte layout the printers write, and two accepted inputs The round trips and their converses tie each reader to its printer, but say nothing of the bytes the printer writes. So a change that swapped the width and the height in both printIhdr and parseIhdr kept the spec hash and met every statement, and the tool then answered 1080x1920 for a 1920x1080 file. New statements in Spec.lean, proved in Proofs/Layout.lean and Proofs/SpecProofs.lean: - Spec.print_layout: print writes the signature, then each chunk as its length, its four type bytes, its data, and its CRC-32, the length and the CRC four bytes big endian. - Spec.printIhdr_layout: printIhdr writes the width, then the height, four bytes big endian each, then bit depth, colour type, zero, zero, interlace. - Spec.isLetter_iff, Spec.ihdrType_eq, Spec.isIEND_iff: the letter test, the IHDR type bytes, and the IEND chunk test the rules use. - Spec.reads_real_file_header: the real 4x3 test file is read as width 4, height 3, depth 8, colour type 6, no interlace. - Spec.reads_header_1920x1080: the 13 bytes of a 1920x1080 header are read as width 1920 and height 1080. Four bytes big endian is Spec.bigEndian4, a new spec definition in Spec/Defs.lean. The parseFile_print docstring now says its guarantee is relative to printIhdr. comparator.json names 29 theorems. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AovXVgJBHGiN44TxQM4dcd --- Proofs.lean | 1 + Proofs/Layout.lean | 70 +++++++++++++++++++++++++ Proofs/SpecProofs.lean | 55 ++++++++++++++++++-- Proofs/Verify.lean | 12 +++++ Spec.lean | 114 ++++++++++++++++++++++++++++++++++++++--- Spec/Defs.lean | 13 ++++- comparator.json | 2 +- 7 files changed, 254 insertions(+), 13 deletions(-) create mode 100644 Proofs/Layout.lean diff --git a/Proofs.lean b/Proofs.lean index 454834d..09df056 100644 --- a/Proofs.lean +++ b/Proofs.lean @@ -1,5 +1,6 @@ import Proofs.RoundTrip import Proofs.Header import Proofs.Sound +import Proofs.Layout import Proofs.Verify import Proofs.SpecProofs diff --git a/Proofs/Layout.lean b/Proofs/Layout.lean new file mode 100644 index 0000000..9cb2c9a --- /dev/null +++ b/Proofs/Layout.lean @@ -0,0 +1,70 @@ +/- + The byte layout: the bytes the chunk printer and the header + printer write, stated with `Spec.bigEndian4`, the four bytes of + a number most significant first, and the type bytes and the + letter test the chunk rules use. The round trips and their + converses tie each reader to its printer; these tie each + printer to the layout of the PNG specification, so that a + printer and a reader that agreed on some other layout (the + height before the width, say) would not meet the statements. + + The code writes four byte numbers with `be32`, which takes each + byte with `Nat.toUInt8`; `bigEndian4` writes them with + `UInt8.ofNat` of the base 256 digit. `be32_eq_bigEndian4` says + the two agree on every number. +-/ + +import Png +import Spec.Defs + +namespace Png.Proofs + +open Png Spec + +/-- A number's byte is the byte of its last base 256 digit. -/ +theorem toUInt8_eq_ofNat_mod (n : Nat) : n.toUInt8 = UInt8.ofNat (n % 256) := by + apply UInt8.toNat_inj.mp + simp [Nat.toUInt8] + +/-- The code's four bytes big endian are the specification's. -/ +theorem be32_eq_bigEndian4 (n : Nat) : be32 n = bigEndian4 n := by + unfold be32 bigEndian4 + rw [toUInt8_eq_ofNat_mod (n / 16777216), toUInt8_eq_ofNat_mod (n / 65536), + toUInt8_eq_ofNat_mod (n / 256), toUInt8_eq_ofNat_mod n] + +/-- The chunk layout: the signature, then each chunk as its + length, its four type bytes in order, its data, and its + CRC-32, the length and the CRC four bytes big endian. -/ +theorem print_layout (doc : Doc) : + print doc = [137, 80, 78, 71, 13, 10, 26, 10] ++ + doc.flatMap (fun c => + bigEndian4 c.data.length ++ [c.t1, c.t2, c.t3, c.t4] ++ c.data ++ + bigEndian4 (Crc32.crc32 ([c.t1, c.t2, c.t3, c.t4] ++ c.data)).toNat) := by + unfold print signature + congr 1 + apply congrArg (List.flatMap · doc) + funext c + simp only [printChunk, chunkCrc, Chunk.typeBytes, be32_eq_bigEndian4] + +/-- The IHDR layout: width, height, four bytes big endian each, + then bit depth, colour type, compression zero, filter zero, + and interlace. -/ +theorem printIhdr_layout (h : Ihdr) : + printIhdr h = bigEndian4 h.width ++ bigEndian4 h.height ++ + [h.bitDepth, h.colorType, 0, 0, h.interlace] := by + simp only [printIhdr, be32_eq_bigEndian4] + +/-- The letter test: A to Z, or a to z, by the byte's value. -/ +theorem isLetter_iff (b : UInt8) : + isLetter b = true ↔ + (65 ≤ b.toNat ∧ b.toNat ≤ 90) ∨ (97 ≤ b.toNat ∧ b.toNat ≤ 122) := by + simp [isLetter, UInt8.le_iff_toNat_le] + +/-- The IEND chunk test: type bytes I, E, N, D, and no data. -/ +theorem isIEND_iff (c : Chunk) : + isIEND c = true ↔ [c.t1, c.t2, c.t3, c.t4] = [73, 69, 78, 68] ∧ c.data = [] := by + unfold isIEND iendType Chunk.typeBytes + rw [Bool.and_eq_true, List.isEmpty_iff] + exact and_congr_left fun _ => decide_eq_true_iff + +end Png.Proofs diff --git a/Proofs/SpecProofs.lean b/Proofs/SpecProofs.lean index 8ddbcc8..eeb3ab0 100644 --- a/Proofs/SpecProofs.lean +++ b/Proofs/SpecProofs.lean @@ -1,10 +1,11 @@ /- Proofs of the statements in `Spec.lean`, under the same names. - The round trips, the table, and the soundness statements are - the theorems proved in `Proofs/RoundTrip.lean`, - `Proofs/Header.lean`, and `Proofs/Sound.lean`. The refusals - are single inputs, checked in the kernel with `decide +kernel`: + The round trips, the table, the soundness statements, and the + byte layout are the theorems proved in `Proofs/RoundTrip.lean`, + `Proofs/Header.lean`, `Proofs/Sound.lean`, and + `Proofs/Layout.lean`. The accepted and refused inputs are + single inputs, checked in the kernel with `decide +kernel`: no native evaluation, which the bar forbids. -/ @@ -13,6 +14,7 @@ import Spec.Defs import Proofs.RoundTrip import Proofs.Header import Proofs.Sound +import Proofs.Layout open Png Spec @@ -48,6 +50,51 @@ theorem Spec.parseFile_sound {bs : List UInt8} {hd : Ihdr} {doc : Doc} Png.Proofs.parseFile_sound h +theorem Spec.print_layout (doc : Doc) : + print doc = [137, 80, 78, 71, 13, 10, 26, 10] ++ + doc.flatMap (fun c => + bigEndian4 c.data.length ++ [c.t1, c.t2, c.t3, c.t4] ++ c.data ++ + bigEndian4 (Crc32.crc32 ([c.t1, c.t2, c.t3, c.t4] ++ c.data)).toNat) := + Png.Proofs.print_layout doc + +theorem Spec.printIhdr_layout (h : Ihdr) : + printIhdr h = bigEndian4 h.width ++ bigEndian4 h.height ++ + [h.bitDepth, h.colorType, 0, 0, h.interlace] := + Png.Proofs.printIhdr_layout h + +theorem Spec.isLetter_iff (b : UInt8) : + isLetter b = true ↔ + (65 ≤ b.toNat ∧ b.toNat ≤ 90) ∨ (97 ≤ b.toNat ∧ b.toNat ≤ 122) := + Png.Proofs.isLetter_iff b + +theorem Spec.ihdrType_eq : ihdrType = [73, 72, 68, 82] := rfl + +theorem Spec.isIEND_iff (c : Chunk) : + isIEND c = true ↔ [c.t1, c.t2, c.t3, c.t4] = [73, 69, 78, 68] ∧ c.data = [] := + Png.Proofs.isIEND_iff c + + +theorem Spec.reads_real_file_header : + (parseFile ([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, + 72, 68, 82, 0, 0, 0, 4, 0, 0, 0, 3, 8, 6, 0, 0, 0, 180, 244, + 174, 198, 0, 0, 0, 62, 73, 68, 65, 84, 120, 156, 1, 51, 0, 204, + 255, 0, 248, 207, 45, 4, 205, 181, 169, 174, 98, 138, 255, 174, + 190, 27, 111, 191, 0, 254, 105, 254, 66, 198, 209, 157, 111, + 200, 221, 165, 145, 35, 173, 191, 75, 0, 216, 164, 157, 244, + 188, 239, 173, 101, 28, 78, 104, 79, 114, 243, 122, 31, 237, + 218, 28, 90, 62, 15, 23, 102, 0, 0, 0, 0, 73, 69, 78, 68, 174, + 66, 96, 130] : List UInt8)).map Prod.fst = + some { width := 4, height := 3, bitDepth := 8, colorType := 6, interlace := 0 } := by + decide +kernel + + +theorem Spec.reads_header_1920x1080 : + parseIhdr ([0, 0, 7, 128, 0, 0, 4, 56, 8, 2, 0, 0, 0] + : List UInt8) = + some { width := 1920, height := 1080, bitDepth := 8, colorType := 2, interlace := 0 } := by + decide +kernel + + theorem Spec.rejects_bad_signature : (parse ([118, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 4, 0, 0, 0, 3, 8, 6, 0, 0, 0, 180, 244, 174, diff --git a/Proofs/Verify.lean b/Proofs/Verify.lean index 778bd8a..f58d026 100644 --- a/Proofs/Verify.lean +++ b/Proofs/Verify.lean @@ -12,6 +12,7 @@ import Proofs.RoundTrip import Proofs.Header import Proofs.Sound +import Proofs.Layout import Proofs.SpecProofs #print axioms Png.Proofs.parse_print @@ -21,6 +22,10 @@ import Proofs.SpecProofs #print axioms Png.Proofs.parse_sound #print axioms Png.Proofs.parseIhdr_sound #print axioms Png.Proofs.parseFile_sound +#print axioms Png.Proofs.print_layout +#print axioms Png.Proofs.printIhdr_layout +#print axioms Png.Proofs.isLetter_iff +#print axioms Png.Proofs.isIEND_iff #print axioms Spec.parse_print #print axioms Spec.parseIhdr_print @@ -29,6 +34,13 @@ import Proofs.SpecProofs #print axioms Spec.parse_sound #print axioms Spec.parseIhdr_sound #print axioms Spec.parseFile_sound +#print axioms Spec.print_layout +#print axioms Spec.printIhdr_layout +#print axioms Spec.isLetter_iff +#print axioms Spec.ihdrType_eq +#print axioms Spec.isIEND_iff +#print axioms Spec.reads_real_file_header +#print axioms Spec.reads_header_1920x1080 #print axioms Spec.rejects_bad_signature #print axioms Spec.rejects_bad_crc #print axioms Spec.rejects_truncated_input diff --git a/Spec.lean b/Spec.lean index 276c463..3805acf 100644 --- a/Spec.lean +++ b/Spec.lean @@ -10,10 +10,11 @@ The statements use the definitions in `Png/Value.lean`, `Png/Parse.lean`, `Png/Crc32.lean`, and `Png/Header.lean`, the code under proof named in `bar.toml`, and the well formedness - predicates and the written out table of allowed pairs - (`Spec.WF`, `Spec.WFHeader`, `Spec.WFFull`, `Spec.allowedPairs`, - and what they use), which are in `Spec/Defs.lean`. That file is - part of the specification too. + predicates, the written out table of allowed pairs, and four + bytes big endian (`Spec.WF`, `Spec.WFHeader`, `Spec.WFFull`, + `Spec.allowedPairs`, `Spec.bigEndian4`, and what they use), + which are in `Spec/Defs.lean`. That file is part of the + specification too. -/ import Png @@ -47,9 +48,14 @@ theorem Spec.checkPair_iff (ct bd : UInt8) : /-- Printing a fully well formed document with header `hd` and reading it back at both layers gives back that header, `hd` - itself, and the document. So a reader that returned some - other header, a fixed one or one with the width and height - swapped, fails this statement. -/ + itself, and the document. This holds relative to the printer: + the first chunk's data is `printIhdr hd`. So a reader that + returned some other header than the one `printIhdr` wrote, a + fixed one or one with the width and height swapped, fails + this statement. A change that swapped them in both the + printer and the reader would still meet it; that change fails + `Spec.printIhdr_layout`, which fixes the bytes the printer + writes, and the accepted inputs below. -/ theorem Spec.parseFile_print {hd : Ihdr} {doc : Doc} (h : WFFull hd doc) : parseFile (print doc) = some (hd, doc) := sorry @@ -83,6 +89,100 @@ theorem Spec.parseFile_sound {bs : List UInt8} {hd : Ihdr} {doc : Doc} (h : parseFile bs = some (hd, doc)) : WFFull hd doc ∧ print doc = bs := sorry +/-! ## The byte layout + +The round trips and their converses tie each reader to its +printer, but say nothing of the bytes the printer writes: a +printer and a reader that both put the height before the width, +or both wrote lengths least significant byte first, would meet +them. The statements here fix those bytes: the chunk layout, the +13 bytes of the header, the letters a chunk type may use, and the +type bytes of IHDR and IEND. With the round trips and the +converses above, they fix what each reader accepts and returns +for every input: the chunk parser accepts exactly the bytes +`Spec.print_layout` gives for a well formed chunk list, and the +header reader accepts exactly the bytes `Spec.printIhdr_layout` +gives for a well formed header, and returns that header. The one +function they leave as the code has it is the CRC-32. -/ + +/-- The bytes `print` writes for a chunk list: the eight + signature bytes, then each chunk in order as its data length + in four bytes big endian, its four type bytes, its data, and + the CRC-32 of its type bytes and data in four bytes big + endian. This is the chunk layout of the PNG specification. + The CRC-32 is the function in `Png/Crc32.lean`. -/ +theorem Spec.print_layout (doc : Doc) : + print doc = [137, 80, 78, 71, 13, 10, 26, 10] ++ + doc.flatMap (fun c => + bigEndian4 c.data.length ++ [c.t1, c.t2, c.t3, c.t4] ++ c.data ++ + bigEndian4 (Crc32.crc32 ([c.t1, c.t2, c.t3, c.t4] ++ c.data)).toNat) := sorry + +/-- The 13 bytes `printIhdr` writes for a header, in order: the + width in four bytes big endian, the height in four bytes big + endian, the bit depth, the colour type, a compression byte of + zero, a filter byte of zero, and the interlace byte. This is + the IHDR layout of the PNG specification. With + `Spec.parseIhdr_sound`, the width the header reader returns + is the value of the first four bytes it read, and the height + the value of the next four. -/ +theorem Spec.printIhdr_layout (h : Ihdr) : + printIhdr h = bigEndian4 h.width ++ bigEndian4 h.height ++ + [h.bitDepth, h.colorType, 0, 0, h.interlace] := sorry + +/-- The letter test the chunk rules use (`Spec.WFChunk`, and the + parser) holds of the ASCII letters, A to Z (65 to 90) and a + to z (97 to 122), and of no other byte. -/ +theorem Spec.isLetter_iff (b : UInt8) : + isLetter b = true ↔ + (65 ≤ b.toNat ∧ b.toNat ≤ 90) ∨ (97 ≤ b.toNat ∧ b.toNat ≤ 122) := sorry + +/-- The type bytes the chunk rules require of the first chunk + are I, H, D, R, in ASCII. -/ +theorem Spec.ihdrType_eq : ihdrType = [73, 72, 68, 82] := sorry + +/-- The IEND chunk, at which reading stops and which ends a well + formed chunk list, is a chunk with type bytes I, E, N, D, in + ASCII, and no data. -/ +theorem Spec.isIEND_iff (c : Chunk) : + isIEND c = true ↔ [c.t1, c.t2, c.t3, c.t4] = [73, 69, 78, 68] ∧ c.data = [] := sorry + +/-! ## Accepted inputs + +Two inputs the readers accept, with the header each returns. The +width and the height differ in both, so a reader that swapped +them fails each statement. The test harness reads both inputs +from this file and checks the header each statement gives +against its own reading of the bytes, against the tool's `header` +answer, and, where libpng is installed, against libpng's. -/ + +/-- A real PNG file is read with its own header: 4 by 3, bit + depth 8, colour type 6 (truecolour with alpha), no interlace. + The file has an IHDR chunk, an IDAT chunk holding the + zlib compressed scanlines of random pixels, and IEND. It is + the 4 by 3 file among the test suite's real files. The test + harness checks that zlib inflates its IDAT data to three + scanlines of that width, and has libpng decode it in full. -/ +theorem Spec.reads_real_file_header : + (parseFile ([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, + 72, 68, 82, 0, 0, 0, 4, 0, 0, 0, 3, 8, 6, 0, 0, 0, 180, 244, + 174, 198, 0, 0, 0, 62, 73, 68, 65, 84, 120, 156, 1, 51, 0, 204, + 255, 0, 248, 207, 45, 4, 205, 181, 169, 174, 98, 138, 255, 174, + 190, 27, 111, 191, 0, 254, 105, 254, 66, 198, 209, 157, 111, + 200, 221, 165, 145, 35, 173, 191, 75, 0, 216, 164, 157, 244, + 188, 239, 173, 101, 28, 78, 104, 79, 114, 243, 122, 31, 237, + 218, 28, 90, 62, 15, 23, 102, 0, 0, 0, 0, 73, 69, 78, 68, 174, + 66, 96, 130] : List UInt8)).map Prod.fst = + some { width := 4, height := 3, bitDepth := 8, colorType := 6, interlace := 0 } := sorry + +/-- The header reader reads the 13 bytes of a 1920 by 1080 + header, bit depth 8, colour type 2 (truecolour), no + interlace, as width 1920 (the first four bytes, 0, 0, 7, 128) + and height 1080 (the next four, 0, 0, 4, 56). -/ +theorem Spec.reads_header_1920x1080 : + parseIhdr ([0, 0, 7, 128, 0, 0, 4, 56, 8, 2, 0, 0, 0] + : List UInt8) = + some { width := 1920, height := 1080, bitDepth := 8, colorType := 2, interlace := 0 } := sorry + /-! ## Refused inputs Single inputs the reader refuses, one for each headline rule of diff --git a/Spec/Defs.lean b/Spec/Defs.lean index 0fa5a6d..749652e 100644 --- a/Spec/Defs.lean +++ b/Spec/Defs.lean @@ -1,6 +1,7 @@ /- The definitions the statements in `Spec.lean` use besides the - code under proof: the IEND chunk as a value, well formedness at + code under proof: four bytes big endian (`bigEndian4`), the + IEND chunk as a value, well formedness at the chunk layer (`WFChunk`, `WF`), the written out table of allowed (colour type, bit depth) pairs, well formedness of a header (`WFHeader`), and full well formedness of a document @@ -17,6 +18,16 @@ namespace Spec open Png +/-- A number as four bytes, most significant first (big endian): + its base 256 digits, from the 2^24 place down to the units. + The PNG specification writes every four byte number this way: + chunk lengths, CRCs, and the width and height in IHDR. A + number of 2^32 or more keeps only its lowest four digits; + the numbers PNG writes are below 2^31. -/ +def bigEndian4 (n : Nat) : List UInt8 := + [UInt8.ofNat (n / 16777216 % 256), UInt8.ofNat (n / 65536 % 256), + UInt8.ofNat (n / 256 % 256), UInt8.ofNat (n % 256)] + /-- The IEND chunk itself: type IEND, no data. -/ def iendChunk : Chunk := ⟨73, 69, 78, 68, []⟩ diff --git a/comparator.json b/comparator.json index 50966c4..91ceee9 100644 --- a/comparator.json +++ b/comparator.json @@ -1 +1 @@ -{"challenge_module":"Spec","permitted_axioms":["propext","Classical.choice","Quot.sound"],"solution_module":"Proofs","theorem_names":["Spec.parse_print","Spec.parseIhdr_print","Spec.checkPair_iff","Spec.parseFile_print","Spec.parse_sound","Spec.parseIhdr_sound","Spec.parseFile_sound","Spec.rejects_bad_signature","Spec.rejects_bad_crc","Spec.rejects_truncated_input","Spec.rejects_first_chunk_length_12","Spec.rejects_zero_width","Spec.rejects_zero_height","Spec.rejects_width_at_cap","Spec.rejects_height_at_cap","Spec.rejects_illegal_depth","Spec.rejects_unknown_colour_type","Spec.rejects_unknown_interlace","Spec.rejects_nonzero_compression","Spec.rejects_nonzero_filter","Spec.rejects_second_ihdr","Spec.rejects_header_wrong_length"]} \ No newline at end of file +{"challenge_module":"Spec","permitted_axioms":["propext","Classical.choice","Quot.sound"],"solution_module":"Proofs","theorem_names":["Spec.parse_print","Spec.parseIhdr_print","Spec.checkPair_iff","Spec.parseFile_print","Spec.parse_sound","Spec.parseIhdr_sound","Spec.parseFile_sound","Spec.print_layout","Spec.printIhdr_layout","Spec.isLetter_iff","Spec.ihdrType_eq","Spec.isIEND_iff","Spec.reads_real_file_header","Spec.reads_header_1920x1080","Spec.rejects_bad_signature","Spec.rejects_bad_crc","Spec.rejects_truncated_input","Spec.rejects_first_chunk_length_12","Spec.rejects_zero_width","Spec.rejects_zero_height","Spec.rejects_width_at_cap","Spec.rejects_height_at_cap","Spec.rejects_illegal_depth","Spec.rejects_unknown_colour_type","Spec.rejects_unknown_interlace","Spec.rejects_nonzero_compression","Spec.rejects_nonzero_filter","Spec.rejects_second_ihdr","Spec.rejects_header_wrong_length"]} \ No newline at end of file From e285b21a1144fafeb9b600d14547f340aa555b42 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 21:38:19 +0000 Subject: [PATCH 13/14] Tests and docs: check the accepted inputs of Spec.lean, name the layout The harness reads the two accepted inputs from Spec.lean and checks the header each states three ways: its own big endian reading of the bytes (with zlib for the CRCs and the image size), the tool's header answer, and libpng decoding the whole file. The axiom check covers the four new Png.Proofs layout lemmas. The record is regenerated: 1,485 inputs, 0 failures. README.md and SPEC.md name the new theorems, say the round trips hold relative to the printers and that the layout theorems fix the bytes, and list under what is not proved that the layout is the PNG specification's and that the CRC-32 function is the standard one. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AovXVgJBHGiN44TxQM4dcd --- README.md | 83 ++++++++++++++------ SPEC.md | 35 +++++++-- tests/TEST-RESULTS.md | 18 ++++- tests/run_tests.py | 178 ++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 276 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 872a84e..62df42b 100644 --- a/README.md +++ b/README.md @@ -47,8 +47,9 @@ fields. See `SPEC.md` for the full protocol. `python3 tests/run_tests.py` rebuilds the tool, runs the real files, the hand built cases, the differential runs, and the -header sections, compares the header read with libpng where a -C compiler and libpng are installed (`tests/libpng_header.c`), +header sections, checks the two accepted inputs stated in +`Spec.lean`, compares the header read with libpng where a C +compiler and libpng are installed (`tests/libpng_header.c`), and writes the outcome to `tests/TEST-RESULTS.md`. ## What is proved @@ -67,6 +68,13 @@ same names. `comparator.json` lists all of them. | A file the chunk parser accepts is the printed form of the chunk list it returns, and that list is well formed. | `Spec.parse_sound` | | A header the header reader accepts is well formed, and the bytes it read are its printed form. So a header that breaks any header rule is refused. | `Spec.parseIhdr_sound` | | A file the file level read accepts is the printed form of the chunk list it returns, and that list is fully well formed with the header the read returns. So a file that breaks any rule of either layer, or holds a second IHDR chunk, is refused. | `Spec.parseFile_sound` | +| The chunk printer writes the eight signature bytes, then each chunk as its data length in four bytes big endian, its four type bytes, its data, and the CRC-32 of its type bytes and data in four bytes big endian. | `Spec.print_layout` | +| The header printer writes the width in four bytes big endian, then the height in four bytes big endian, then the bit depth, the colour type, a compression byte of zero, a filter byte of zero, and the interlace byte. | `Spec.printIhdr_layout` | +| The letter test the chunk rules use holds of the ASCII letters A to Z and a to z, and of no other byte. | `Spec.isLetter_iff` | +| The type bytes required of the first chunk are I, H, D, R. | `Spec.ihdrType_eq` | +| The IEND chunk, where reading stops, is a chunk with type bytes I, E, N, D and no data. | `Spec.isIEND_iff` | +| A real 4 by 3 PNG file (an IHDR, an IDAT with zlib compressed pixels, and IEND) is read with its own header: width 4, height 3, bit depth 8, colour type 6, no interlace. | `Spec.reads_real_file_header` | +| The 13 bytes of a 1920 by 1080 header are read as width 1920 and height 1080, bit depth 8, colour type 2, no interlace. | `Spec.reads_header_1920x1080` | | A file whose first signature byte is wrong is refused. | `Spec.rejects_bad_signature` | | A file whose IHDR chunk carries a CRC that does not match its type and data is refused. | `Spec.rejects_bad_crc` | | A file cut short inside its first chunk is refused. | `Spec.rejects_truncated_input` | @@ -96,8 +104,17 @@ its first chunk's data is the printed form of `hd`, a well formed header, and no chunk after the first has type IHDR. The header is a parameter of the predicate, so the file level round trip names the header the read must -return: a reader that answered a fixed header, or swapped the -width and the height, would not meet it. +return. That holds relative to the printer: the read must +return the header whose `printIhdr` bytes the first chunk +holds. So a reader that answered a fixed header, or swapped the +width and the height while the printer did not, would not meet +it. A change that swapped them in both the printer and the +reader would still meet the round trips. What stops it is +`Spec.printIhdr_layout`, which fixes the bytes the printer +writes (the width first, then the height, each four bytes big +endian), and the two accepted inputs, +`Spec.reads_real_file_header` and `Spec.reads_header_1920x1080`, +whose widths and heights differ. Each reader is pinned to its rule in both directions. The round trip says it accepts the printed form of every well @@ -116,27 +133,44 @@ therefore a proved rejection rule. The named refusal theorems in the table are worked examples of those rules, one input each. +The round trips and the soundness theorems are stated in terms +of the printers. The layout theorems fix what the printers +write: `Spec.print_layout` the chunk layout, and +`Spec.printIhdr_layout` the 13 header bytes. `Spec.isLetter_iff`, +`Spec.ihdrType_eq`, and `Spec.isIEND_iff` fix the letters and +type bytes the rules name. Together with the round trips and the +soundness theorems, they fix which bytes each reader accepts and +what it returns, for every input. The one function they leave as +the code has it is the CRC-32 (see below). + The headline theorems rest on the proofs in -`Proofs/RoundTrip.lean`, `Proofs/Header.lean`, and -`Proofs/Sound.lean`, where they are `Png.Proofs.parse_print`, -`Png.Proofs.parseIhdr_print`, `Png.Proofs.checkPair_iff`, -`Png.Proofs.parseFile_print`, `Png.Proofs.parse_sound`, -`Png.Proofs.parseIhdr_sound`, and `Png.Proofs.parseFile_sound`. -Each checks against two of the standard axioms (`propext`, -`Quot.sound`), which a reader can confirm with `#print -axioms`, or by running `lake env lean Proofs/Verify.lean`; -the test harness reads that output for every theorem -`comparator.json` names, and records it. -The refusal theorems are single inputs checked in the kernel -with `decide +kernel`. +`Proofs/RoundTrip.lean`, `Proofs/Header.lean`, +`Proofs/Sound.lean`, and `Proofs/Layout.lean`, where they are +`Png.Proofs.parse_print`, `Png.Proofs.parseIhdr_print`, +`Png.Proofs.checkPair_iff`, `Png.Proofs.parseFile_print`, +`Png.Proofs.parse_sound`, `Png.Proofs.parseIhdr_sound`, +`Png.Proofs.parseFile_sound`, `Png.Proofs.print_layout`, +`Png.Proofs.printIhdr_layout`, `Png.Proofs.isLetter_iff`, and +`Png.Proofs.isIEND_iff`. Each checks against at most two of the +standard axioms (`propext`, `Quot.sound`), which a reader can +confirm with `#print axioms`, or by running `lake env lean +Proofs/Verify.lean`; the test harness reads that output for +every theorem `comparator.json` names, and records it. +The refusal theorems and the two accepted inputs are single +inputs checked in the kernel with `decide +kernel`, and +`Spec.ihdrType_eq` holds by definition. The test harness reads +the two accepted inputs from `Spec.lean` and checks the header +each states against its own big endian reading of the bytes, +the tool's `header` answer, and libpng's. The code is in `Png/Value.lean` (the chunk type and the printer), `Png/Parse.lean` (the parser), `Png/Crc32.lean` (the CRC-32), and `Png/Header.lean` (the IHDR header value, its reader and printer, and the file level read). The well formedness rules (`Spec.WFChunk`, `Spec.WF`, `Spec.WFHeader`, -`Spec.WFFull`) and the written out table of allowed pairs -(`Spec.allowedPairs`) give the statements their meaning and no +`Spec.WFFull`), the written out table of allowed pairs +(`Spec.allowedPairs`), and four bytes big endian +(`Spec.bigEndian4`) give the statements their meaning and no code uses them, so they are part of the specification, in `Spec/Defs.lean`, and a change to them voids an approval. The command line tool in `Main.lean` calls `Png.parse`, @@ -145,8 +179,8 @@ the theorems are about. ## What is not proved -- That the well formedness rules (`WF`, `WFHeader`, `WFFull`, and the table of allowed pairs) are the PNG specification's rules. The proofs tie each reader to its rule exactly, in both directions, but that the rule says what the PNG specification says is a reading: `SPEC.md` states it in words, and the tests compare the readers against a strict oracle written in the harness from those rules, and the header read against libpng, file by file. Where libpng and this reader differ, the test record names each difference and the rule behind it. -- That the CRC-32 here is the standard one. The round trip needs no such fact, since the parser and the printer compute the same function. The differential tests against Python zlib's `crc32` are what tie this function to the standard. +- That the well formedness rules (`WF`, `WFHeader`, `WFFull`, and the table of allowed pairs) and the byte layout the layout theorems state (`Spec.print_layout`, `Spec.printIhdr_layout`, with `Spec.bigEndian4`) are the PNG specification's. The proofs tie each reader to its rule and each printer to the stated layout exactly, but that the rule and the layout say what the PNG specification says is a reading: `SPEC.md` states it in words, and the tests compare the readers against a strict oracle written in the harness from those rules, and the header read against libpng, file by file, the two accepted inputs of `Spec.lean` among them. Where libpng and this reader differ, the test record names each difference and the rule behind it. +- That the CRC-32 here is the standard one. `Spec.print_layout` states the chunk layout with the code's own CRC-32 function (`Png.Crc32.crc32`), and no theorem says that function is the standard CRC-32. The round trip needs no such fact, since the parser and the printer compute the same function. The differential tests against Python zlib's `crc32` are what tie this function to the standard. Two statements pin it on single inputs: `Spec.reads_real_file_header` holds only if the function gives the three CRCs that zlib wrote into that file, and `Spec.rejects_bad_crc` holds only if it does not give the wrong CRC that its file carries. - Anything inside a chunk's data past the IHDR header. The data of every other chunk type is opaque here. IDAT data is not inflated. No pixel is ever decoded. - Chunk order past the first and last, except for IHDR. Which types may appear, how many times, and in what order between IHDR and IEND is not checked, with one exception: the file level read (`header`) refuses a second IHDR chunk. The chunk layer (`parse`) does not, and reads it as an ordinary chunk. So an indexed colour file with no PLTE chunk is accepted, where libpng refuses it. - A chunk of type IEND that carries data. The PNG specification gives IEND no data, but this reader reads such a chunk as an ordinary chunk, at both layers: the file is accepted when an IEND chunk with no data ends it, and refused when none does. libpng refuses such a chunk when it comes before IDAT, and reads a file that ends in one, with a warning. `SPEC.md` states this reading, and the test record names both differences. @@ -177,8 +211,8 @@ the theorems are about. where it is installed. The results file names the versions used. - The line handling in `Main.lean` (splitting a request - line, reading decimal codes). The tests drive it, and no - proof covers it. + line, reading decimal codes) and the text of the `header` + answer. The tests drive them, and no proof covers them. - There are no exceptions to item 4 of the bar: the project uses none of the features it forbids. @@ -192,8 +226,9 @@ the theorems are about. and every other kernel shipped with Lean. 2. `python3 tests/run_tests.py`: the real files, the hand built cases, the differential runs against the oracle, - the mutations, the header sections, and the libpng - comparison of the header read, with counts, named + the mutations, the header sections, the accepted inputs + stated in `Spec.lean`, and the libpng comparison of the + header read, with counts, named differences, and any failures written to `tests/TEST-RESULTS.md`. 3. `lake build`: the library, the proofs, and the tool build diff --git a/SPEC.md b/SPEC.md index efdaddb..db6f9f3 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1,6 +1,6 @@ # Specification: png -A PNG chunk parser and printer in Lean, with (slice 2) a decoder for the IHDR header. It does not decode pixels, and it does not inflate IDAT data. The claims below are stated as Lean theorems in `Spec.lean`, over the well formedness rules and the table of allowed pairs in `Spec/Defs.lean`. A reviewer who did not write this specification or those statements approves them; no reviewer has yet, so nothing here counts toward the verified bar. +A PNG chunk parser and printer in Lean, with (slice 2) a decoder for the IHDR header. It does not decode pixels, and it does not inflate IDAT data. The claims below are stated as Lean theorems in `Spec.lean`, over the well formedness rules, the table of allowed pairs, and four bytes big endian (`Spec.bigEndian4`) in `Spec/Defs.lean`. A reviewer who did not write this specification or those statements approves them; no reviewer has yet, so nothing here counts toward the verified bar. Bytes are `List UInt8` throughout. @@ -17,14 +17,16 @@ After the signature come chunks until the input ends. One chunk on the wire is: - the data: that many bytes. - a CRC: four bytes, big endian, holding the CRC-32 of the type bytes and the data bytes together. +Four bytes big endian means the base 256 digits of the number, most significant first (`Spec.bigEndian4`). This layout is the one the printer writes, by `Spec.print_layout`, and an ASCII letter is a byte from 65 to 90 or from 97 to 122, by `Spec.isLetter_iff`. + The CRC-32 is the one PNG and ZIP share: the ISO 3309 polynomial, reflected, the register starting at all ones and inverted at the end. The implementation is the one in `Png/Crc32.lean`, shaped like the ZIP reader's in Proven-Commons/zip. ## The checks, in the order the parser makes them 1. The first eight bytes are the signature. 2. For each chunk, from the front: at least four bytes remain for the length; the length is below 2^31; at least four bytes remain for the type; each type byte is an ASCII letter; at least the length plus four bytes remain for the data and the CRC; the stored CRC equals the CRC-32 computed over the type bytes and the data. Any failure rejects the whole file. There is no partial read. -3. Reading stops at a chunk whose type is IEND and whose data is empty. That chunk must be the end of the input: any byte after it rejects the file, and running out of input before such a chunk rejects the file. -4. The first chunk read has type IHDR and a data length of 13. +3. Reading stops at a chunk whose type is IEND (the bytes 73, 69, 78, 68) and whose data is empty (`Spec.isIEND_iff`). That chunk must be the end of the input: any byte after it rejects the file, and running out of input before such a chunk rejects the file. +4. The first chunk read has type IHDR (the bytes 73, 72, 68, 82, by `Spec.ihdrType_eq`) and a data length of 13. A chunk of type IEND that carries data is an ordinary chunk, here and in the file level read below: reading does not stop at it. The PNG specification gives IEND an empty data field, so such a chunk is not the IEND chunk, and this reading follows from that: a file in which an IEND chunk with data is followed, at the end, by an IEND chunk with no data is accepted, and a file whose last chunk is an IEND chunk with data has no end and is rejected. libpng differs on two such files: it refuses an IEND chunk with data that comes before IDAT, as out of place, where this reader reads past it; and it reads a file that ends in an IEND chunk with data, with a warning, where this reader refuses it. The test record names both differences. No other rule about chunk order is checked at this layer: which types may appear, how many times, or in what order past the first and last, is outside it. The file level read of slice 2, below, adds one such rule: a single IHDR chunk. At this layer the contents of the IHDR data, past its length, are not checked: width, height, bit depth, and colour type are read as data like any other. The header read of slice 2, below, is a second read on top of this one and does check them. @@ -40,6 +42,8 @@ The first chunk's 13 data bytes hold, in order: - the filter method: one byte. - the interlace method: one byte. +This order is stated as `Spec.printIhdr_layout`, for the bytes the printer writes. With the soundness of the header reader (`Spec.parseIhdr_sound`), the width the reader returns is the value of the first four bytes it read, big endian, and the height the value of the next four. `Spec.reads_header_1920x1080` and `Spec.reads_real_file_header` are worked examples, each with a width and a height that differ. + The decoded header value holds the width and the height as numbers, and the bit depth, the colour type, and the interlace method as bytes. The compression and filter bytes are not held: the PNG specification gives them no choice (the only compression method is 0, the only filter method is 0), so reading checks that each is zero and printing writes zero. A header reads only when all of these hold: @@ -59,7 +63,7 @@ A header reads only when all of these hold: A colour type other than 0, 2, 3, 4, or 6 allows no bit depth, and reading anything but exactly 13 bytes is a rejection. -Printing a header writes the 13 bytes back: width and height big endian, then bit depth, colour type, compression zero, filter zero, interlace. +Printing a header writes the 13 bytes back: width and height big endian, then bit depth, colour type, compression zero, filter zero, interlace (`Spec.printIhdr_layout`). The file level read (`parseFile`) runs the chunk parse of slice 1 and then reads the first chunk's data as a header. A file whose first chunk is not a valid IHDR in this sense is rejected by that read even when the chunk layer accepts it: a file with a width of zero is the plainest case. @@ -67,7 +71,7 @@ The file level read also rejects a file in which any chunk after the first has t ## Printing -The printer writes the signature, then each chunk as its length, its type bytes, its data, and the CRC it computes over the type bytes and the data. A file the parser accepts is reprinted byte for byte: the lengths and CRCs it carried were checked against the bytes, so printing writes the same values back. This is proved: a file the parser accepts is the printed form of the chunk list read from it (`Spec.parse_sound`). +The printer writes the signature, then each chunk as its length, its type bytes, its data, and the CRC it computes over the type bytes and the data (`Spec.print_layout`). A file the parser accepts is reprinted byte for byte: the lengths and CRCs it carried were checked against the bytes, so printing writes the same values back. This is proved: a file the parser accepts is the printed form of the chunk list read from it (`Spec.parse_sound`). ## The claim @@ -85,9 +89,19 @@ Each round trip has a proved converse, so each reader is pinned to its rule in b - The header reader is sound: a header it accepts is well formed, and the 13 bytes it read are that header's printed form. So a width or height of zero or of 2^31 or more, a nonzero compression or filter byte, an interlace byte above 1, a depth the colour type does not allow (by the table claim), and any length but 13 are all refused. - The file level read is sound: a file it accepts is the printed form of the chunk list it returns, that list is well formed at the chunk layer, its first chunk's data is the printed form of the header the read returns, that header is well formed, and no later chunk has type IHDR. -So every rule of this specification that the well formedness rules state is a proved rejection rule. What is not proved is that those rules are the PNG specification's: that is the reading this document pins. The test suite checks the readers against real PNG files built with Python's zlib and against a strict reading of these rules written in the harness, checks the header read against libpng file by file where libpng is installed, and records the counts, with each difference from libpng named. +So every rule of this specification that the well formedness rules state is a proved rejection rule. + +The round trips and their converses are stated in terms of the printers, so on their own they would also hold for a printer and a reader that agreed on some other layout, such as the height before the width. The layout claims close that: + +- The chunk printer writes the layout of the Chunks section above (`Spec.print_layout`). +- The header printer writes the 13 bytes in the order of the IHDR section above (`Spec.printIhdr_layout`). +- The letter test, the IHDR type bytes, and the IEND chunk test are the ones the checks above name (`Spec.isLetter_iff`, `Spec.ihdrType_eq`, `Spec.isIEND_iff`). + +With the round trips and their converses, these fix which bytes each reader accepts and what it returns, for every input, up to the CRC-32 function, which the chunk layout names as the code has it. Two accepted inputs are stated as worked examples: a real 4 by 3 file is read as width 4, height 3, bit depth 8, colour type 6, no interlace (`Spec.reads_real_file_header`), and the 13 bytes of a 1920 by 1080 header as width 1920 and height 1080 (`Spec.reads_header_1920x1080`). The test harness reads both from `Spec.lean` and checks them against its own reading of the bytes and against libpng. + +What is not proved is that the rules and the layout are the PNG specification's: that is the reading this document pins. The test suite checks the readers against real PNG files built with Python's zlib and against a strict reading of these rules written in the harness, checks the header read against libpng file by file where libpng is installed, and records the counts, with each difference from libpng named. -The round trip needs no fact about CRC-32 itself: the parser and the printer compute the same function of the same bytes, so a printed file passes its own CRC checks by construction. That the function is the standard CRC-32 rests on the differential tests against Python zlib's `crc32`. +The round trip needs no fact about CRC-32 itself: the parser and the printer compute the same function of the same bytes, so a printed file passes its own CRC checks by construction. That the function is the standard CRC-32 is not proved: it rests on the differential tests against Python zlib's `crc32`, and on single inputs, since `Spec.reads_real_file_header` holds only if the function gives the three CRCs zlib wrote into that file. ## Properties and their theorems @@ -102,6 +116,13 @@ Each property above names its theorem. The theorems are stated in `Spec.lean` an | A file the chunk parser accepts is the printed form of the chunk list it returns, and that list is well formed | `Spec.parse_sound` | | A header the header reader accepts is well formed, and the bytes it read are its printed form | `Spec.parseIhdr_sound` | | A file the file level read accepts is the printed form of the chunk list it returns, and that list is fully well formed with the header the read returns | `Spec.parseFile_sound` | +| The chunk printer writes the signature, then each chunk as its length (four bytes big endian), its type bytes, its data, and its CRC-32 (four bytes big endian) | `Spec.print_layout` | +| The header printer writes the width and the height, four bytes big endian each, then the bit depth, the colour type, zero, zero, and the interlace byte | `Spec.printIhdr_layout` | +| The letter test holds of the bytes 65 to 90 and 97 to 122, and of no other | `Spec.isLetter_iff` | +| The IHDR type bytes are 73, 72, 68, 82 | `Spec.ihdrType_eq` | +| The IEND chunk is type bytes 73, 69, 78, 68 with no data | `Spec.isIEND_iff` | +| A real 4 by 3 file is read with header width 4, height 3, bit depth 8, colour type 6, no interlace | `Spec.reads_real_file_header` | +| The 13 bytes of a 1920 by 1080 header are read as width 1920, height 1080 | `Spec.reads_header_1920x1080` | | A file whose first signature byte is wrong is refused | `Spec.rejects_bad_signature` | | A file whose IHDR chunk carries a CRC that does not match its type and data is refused | `Spec.rejects_bad_crc` | | A file cut short inside its first chunk is refused | `Spec.rejects_truncated_input` | diff --git a/tests/TEST-RESULTS.md b/tests/TEST-RESULTS.md index 453e601..7a930d2 100644 --- a/tests/TEST-RESULTS.md +++ b/tests/TEST-RESULTS.md @@ -5,7 +5,7 @@ Written by `tests/run_tests.py`. Rerun it to check these numbers. - Oracle: Python 3.13.17 `zlib` (crc32 and compress), plus a strict reading of the plan rules written in the harness; for the header read, also libpng where it is installed (below) - Lean: Lean (version 4.35.0, x86_64-unknown-linux-gnu, commit eb6cafb7290e8bd5f2937522d8d804f58f02bafc, Release) - Seed: 20261026 -- Inputs: 1483 +- Inputs: 1485 - Inputs counts every request of the sections below, up to the libpng comparison, which is counted on its own line - Real PNG files (zlib built): 4 of 4 accepted and reprinted byte for byte - Real PNG file: 1x1, bit depth 8, colour type 2 (RGB), 69 bytes @@ -29,6 +29,9 @@ Written by `tests/run_tests.py`. Rerun it to check these numbers. - Random header files through `parse`: 200 of 200 reprinted byte for byte - Malformed header requests answered error: 3 of 3 - Header requests with no codes answered none: 2 of 2 +- Accepted inputs stated in Spec.lean, read from that file: 2 of 2 agree with the harness's own reading of the bytes +- Accepted inputs stated in Spec.lean, through `header`: 2 of 2 answered with the stated header +- Accepted inputs stated in Spec.lean, through libpng (built 1.6.43, running 1.6.43), decoded in full: 2 of 2 read with the stated header ## libpng comparison of the header read @@ -42,7 +45,7 @@ Each file is read by libpng (`tests/libpng_header.c`, built against the system l ## Axiom check -The output of `#print axioms` for the 7 headline theorems and the 22 theorems `comparator.json` names, from `lake env lean Proofs/Verify.lean`. The harness reads the names, and fails on any axiom but `propext`, `Classical.choice`, `Quot.sound`. A self-test on planted output (a `sorryAx`, an axiom of a proof's own, a missing line) runs first. +The output of `#print axioms` for the 11 headline theorems and the 29 theorems `comparator.json` names, from `lake env lean Proofs/Verify.lean`. The harness reads the names, and fails on any axiom but `propext`, `Classical.choice`, `Quot.sound`. A self-test on planted output (a `sorryAx`, an axiom of a proof's own, a missing line) runs first. - 'Png.Proofs.parse_print' depends on axioms: [propext, Quot.sound] - 'Png.Proofs.parseIhdr_print' depends on axioms: [propext, Quot.sound] @@ -51,6 +54,10 @@ The output of `#print axioms` for the 7 headline theorems and the 22 theorems `c - 'Png.Proofs.parse_sound' depends on axioms: [propext, Quot.sound] - 'Png.Proofs.parseIhdr_sound' depends on axioms: [propext, Quot.sound] - 'Png.Proofs.parseFile_sound' depends on axioms: [propext, Quot.sound] +- 'Png.Proofs.print_layout' depends on axioms: [propext, Quot.sound] +- 'Png.Proofs.printIhdr_layout' depends on axioms: [propext] +- 'Png.Proofs.isLetter_iff' depends on axioms: [propext] +- 'Png.Proofs.isIEND_iff' depends on axioms: [propext] - 'Spec.parse_print' depends on axioms: [propext, Quot.sound] - 'Spec.parseIhdr_print' depends on axioms: [propext, Quot.sound] - 'Spec.checkPair_iff' depends on axioms: [propext, Quot.sound] @@ -58,6 +65,13 @@ The output of `#print axioms` for the 7 headline theorems and the 22 theorems `c - 'Spec.parse_sound' depends on axioms: [propext, Quot.sound] - 'Spec.parseIhdr_sound' depends on axioms: [propext, Quot.sound] - 'Spec.parseFile_sound' depends on axioms: [propext, Quot.sound] +- 'Spec.print_layout' depends on axioms: [propext, Quot.sound] +- 'Spec.printIhdr_layout' depends on axioms: [propext] +- 'Spec.isLetter_iff' depends on axioms: [propext] +- 'Spec.ihdrType_eq' depends on axioms: [] +- 'Spec.isIEND_iff' depends on axioms: [propext] +- 'Spec.reads_real_file_header' depends on axioms: [propext, Quot.sound] +- 'Spec.reads_header_1920x1080' depends on axioms: [propext, Quot.sound] - 'Spec.rejects_bad_signature' depends on axioms: [propext, Quot.sound] - 'Spec.rejects_bad_crc' depends on axioms: [propext, Quot.sound] - 'Spec.rejects_truncated_input' depends on axioms: [propext, Quot.sound] diff --git a/tests/run_tests.py b/tests/run_tests.py index 9c32399..422e409 100644 --- a/tests/run_tests.py +++ b/tests/run_tests.py @@ -1,9 +1,10 @@ #!/usr/bin/env python3 """Tests for the Lean PNG chunk parser and printer. -Six parts. Parts 1 to 4 are the slice 1 suite, unchanged. +Seven parts. Parts 1 to 4 are the slice 1 suite, unchanged. Part 5 is slice 2: the IHDR header read through the `header` -request. Part 6 compares that read with libpng, where libpng is +request. Part 6 checks the accepted inputs stated in Spec.lean. +Part 7 compares the header read with libpng, where libpng is installed. 1. Real PNG files built with Python zlib: an IHDR chunk (the @@ -50,7 +51,17 @@ files and checked through `header` for their fields and through `parse` for a byte for byte reprint. -6. The libpng comparison. tests/libpng_header.c is built +6. The accepted inputs stated in Spec.lean + (Spec.reads_real_file_header, a whole file, and + Spec.reads_header_1920x1080, 13 header bytes, wrapped in a + file with an all zero image), read from that file. The + stated header must match the harness's own big endian + reading of the bytes, the image data must inflate to the + size that header gives, the tool's `header` answer must be + the stated header, and so must libpng's, decoding the whole + file, where libpng is installed. + +7. The libpng comparison. tests/libpng_header.c is built against the system libpng (skipped, and said so, when there is no C compiler or no libpng). Each file is read by libpng and by `header`, and the answers must agree field for field, @@ -858,9 +869,18 @@ def run_header_bounds(): } +LIBPNG_BUILT = [] + + def build_libpng(): - """Build the libpng oracle. Returns (version text, None) or - (None, the reason it could not be built).""" + """Build the libpng oracle, once per run. Returns (version text, + None) or (None, the reason it could not be built).""" + if not LIBPNG_BUILT: + LIBPNG_BUILT.append(build_libpng_once()) + return LIBPNG_BUILT[0] + + +def build_libpng_once(): cc = shutil.which("cc") or shutil.which("gcc") or shutil.which("clang") if not cc: return None, "no C compiler" @@ -1074,6 +1094,149 @@ def run_header_malformed(): return lines, failures +# ---------------------------------------------------------------- the +# accepted inputs stated in Spec.lean. Two statements there give an +# input and the header the reader returns for it: +# Spec.reads_real_file_header (a whole file) and +# Spec.reads_header_1920x1080 (13 header bytes). The bytes and the +# fields are read from Spec.lean itself, so the check is of the +# statements as written, and each is checked three ways: the +# harness's own reading of the bytes (big endian width and height, +# zlib for the CRCs and the image data), the tool's `header` answer, +# and libpng's answer where libpng is installed. + +SPEC_LEAN = ROOT / "Spec.lean" + + +def lean_bytes(text): + return bytes(int(x) for x in text.replace("\n", " ").split(",")) + + +HEADER_FIELDS = ("width", "height", "bitDepth", "colorType", "interlace") + + +def lean_fields(text): + """The header a statement gives, written as a structure + instance with its five fields named, as (width, height, bit + depth, colour type, interlace).""" + got = dict( + (k, int(v)) for k, v in re.findall(r"(\w+)\s*:=\s*([0-9]+)", text) + ) + if sorted(got) != sorted(HEADER_FIELDS): + return None + return tuple(got[k] for k in HEADER_FIELDS) + + +def spec_examples(): + """(theorem, file, the 13 header bytes, the stated fields), read + from Spec.lean. The header only statement is wrapped in a whole + file: its 13 bytes as the IHDR data, an IDAT chunk holding an + all zero image of that size, and IEND.""" + src = SPEC_LEAN.read_text(encoding="utf-8") + found = [] + m = re.search( + r"theorem Spec\.reads_real_file_header :\s*\(parseFile \(\[([0-9,\s]*)\]\s*" + r":\s*List UInt8\)\)\.map Prod\.fst =\s*some \{([^}]*)\}", + src, + ) + if m and lean_fields(m.group(2)): + data = lean_bytes(m.group(1)) + found.append(("Spec.reads_real_file_header", data, data[16:29], lean_fields(m.group(2)))) + m = re.search( + r"theorem Spec\.reads_header_1920x1080 :\s*parseIhdr \(\[([0-9,\s]*)\]\s*" + r":\s*List UInt8\) =\s*some \{([^}]*)\}", + src, + ) + if m and lean_fields(m.group(2)): + ihdr = lean_bytes(m.group(1)) + w, h, d, t, i = lean_fields(m.group(2)) + data = ( + SIG + + make_chunk(b"IHDR", ihdr) + + make_chunk(b"IDAT", zlib.compress(raw_image(w, h, d, t, i))) + + make_chunk(b"IEND", b"") + ) + found.append(("Spec.reads_header_1920x1080", data, ihdr, (w, h, d, t, i))) + return found + + +def own_reading(data, ihdr, fields): + """The harness's own reading of a stated input: None when it + agrees with the stated fields, else what is wrong.""" + w, h, d, t, i = fields + if len(ihdr) != 13: + return f"the header is {len(ihdr)} bytes, not 13" + got = struct.unpack(">IIBBBBB", ihdr) + if got != (w, h, d, t, 0, 0, i): + return f"the 13 bytes read big endian are {got}, not {(w, h, d, t, 0, 0, i)}" + chunks = oracle_parse(data) + if chunks is None: + return "the strict oracle refuses the file" + if chunks[0] != (b"IHDR", ihdr): + return "the file's IHDR data is not the stated 13 bytes" + image = zlib.decompress(b"".join(c for typ, c in chunks if typ == b"IDAT")) + want = len(raw_image(w, h, d, t, i)) + if len(image) != want: + return f"the image data inflates to {len(image)} bytes, not {want}" + return None + + +def run_spec_examples(): + lines = [] + failures = [] + cases = spec_examples() + if len(cases) != 2: + failures.append( + f"Spec.lean accepted inputs: found {len(cases)} of the 2 statements; " + "the harness could not read them" + ) + own_ok = 0 + for name, data, ihdr, fields in cases: + wrong = own_reading(data, ihdr, fields) + if wrong is None: + own_ok += 1 + else: + failures.append(f"{name}: {wrong}") + lines.append( + f"Accepted inputs stated in Spec.lean, read from that file: {own_ok} of " + f"{len(cases)} agree with the harness's own reading of the bytes" + ) + answers, problems = ask_all([header_request(data) for _, data, _, _ in cases]) + failures += [f"Spec.lean accepted inputs: tool problem: {p}" for p in problems] + tool_ok = 0 + for (name, _, _, fields), ans in zip(cases, answers): + want = header_line(*fields) + if ans == want: + tool_ok += 1 + else: + failures.append(f"{name} through `header`: want {want!r}, got {ans!r}") + lines.append( + f"Accepted inputs stated in Spec.lean, through `header`: {tool_ok} of " + f"{len(cases)} answered with the stated header" + ) + version, why = build_libpng() + if version is None: + lines.append(f"Accepted inputs stated in Spec.lean, through libpng: skipped, {why}") + return lines, failures + lp = ask_libpng([f"full {data.hex()}" for _, data, _, _ in cases]) + if lp is None: + failures.append("Spec.lean accepted inputs: the libpng oracle did not answer") + return lines, failures + lp_ok = 0 + for (name, _, _, fields), got in zip(cases, lp): + w, h, d, t, i = fields + want = f"ok:{w}x{h} d{d} t{t} i{i}" + if got.split(" warnings:")[0] == want: + lp_ok += 1 + else: + failures.append(f"{name} through libpng: want {want!r}, got {got!r}") + lines.append( + f"Accepted inputs stated in Spec.lean, through libpng ({version}), " + f"decoded in full: {lp_ok} of {len(cases)} read with the stated header" + ) + return lines, failures + + # ---------------------------------------------------------------- the # axiom check. Proofs/Verify.lean prints `#print axioms` for the # headline theorems in Png.Proofs and for every theorem @@ -1091,6 +1254,10 @@ def run_header_malformed(): "Png.Proofs.parse_sound", "Png.Proofs.parseIhdr_sound", "Png.Proofs.parseFile_sound", + "Png.Proofs.print_layout", + "Png.Proofs.printIhdr_layout", + "Png.Proofs.isLetter_iff", + "Png.Proofs.isIEND_iff", ] @@ -1184,6 +1351,7 @@ def main(): run_header_bounds(), run_header_random(args.seed), run_header_malformed(), + run_spec_examples(), ): lines, failures = part all_lines += lines From 3c64230ff4bcc4ac14369b3133770630da867303 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 02:43:05 +0000 Subject: [PATCH 14/14] State the type bytes the chunk rules compare, and name IEND's fields The layout statements fixed the bytes the printers write, but the well formedness rules compare a chunk's type with `ihdrType` through `Chunk.typeBytes`, which is code no statement fixed. A change that made `typeBytes` answer I, H, D, R for a chunk typed A, A, A, A, and had the printer, the CRC, and the IEND test use the four bytes directly, kept every proof in RoundTrip, Header, Sound, and Layout building unchanged, and the readers then accepted a file whose first chunk is AAAA, not IHDR. - Spec.typeBytes_eq: a chunk's type bytes are its four type bytes in order. Proved by definition in Proofs/SpecProofs.lean. - Spec.iendChunk in Spec/Defs.lean names its fields, so its value does not depend on the order `Png.Chunk` declares them in. comparator.json names 30 theorems. README.md and SPEC.md name the new theorem. The test record is regenerated: 1,485 inputs, 0 failures. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AovXVgJBHGiN44TxQM4dcd --- Proofs/SpecProofs.lean | 3 +++ Proofs/Verify.lean | 1 + README.md | 20 +++++++++++--------- SPEC.md | 3 ++- Spec.lean | 11 +++++++++-- Spec/Defs.lean | 6 ++++-- comparator.json | 2 +- tests/TEST-RESULTS.md | 3 ++- 8 files changed, 33 insertions(+), 16 deletions(-) diff --git a/Proofs/SpecProofs.lean b/Proofs/SpecProofs.lean index eeb3ab0..c38d928 100644 --- a/Proofs/SpecProofs.lean +++ b/Proofs/SpecProofs.lean @@ -69,6 +69,9 @@ theorem Spec.isLetter_iff (b : UInt8) : theorem Spec.ihdrType_eq : ihdrType = [73, 72, 68, 82] := rfl +theorem Spec.typeBytes_eq (c : Chunk) : + c.typeBytes = [c.t1, c.t2, c.t3, c.t4] := rfl + theorem Spec.isIEND_iff (c : Chunk) : isIEND c = true ↔ [c.t1, c.t2, c.t3, c.t4] = [73, 69, 78, 68] ∧ c.data = [] := Png.Proofs.isIEND_iff c diff --git a/Proofs/Verify.lean b/Proofs/Verify.lean index f58d026..f6d95a6 100644 --- a/Proofs/Verify.lean +++ b/Proofs/Verify.lean @@ -38,6 +38,7 @@ import Proofs.SpecProofs #print axioms Spec.printIhdr_layout #print axioms Spec.isLetter_iff #print axioms Spec.ihdrType_eq +#print axioms Spec.typeBytes_eq #print axioms Spec.isIEND_iff #print axioms Spec.reads_real_file_header #print axioms Spec.reads_header_1920x1080 diff --git a/README.md b/README.md index 62df42b..67104ed 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,7 @@ same names. `comparator.json` lists all of them. | The header printer writes the width in four bytes big endian, then the height in four bytes big endian, then the bit depth, the colour type, a compression byte of zero, a filter byte of zero, and the interlace byte. | `Spec.printIhdr_layout` | | The letter test the chunk rules use holds of the ASCII letters A to Z and a to z, and of no other byte. | `Spec.isLetter_iff` | | The type bytes required of the first chunk are I, H, D, R. | `Spec.ihdrType_eq` | +| The type bytes the chunk rules compare are a chunk's four type bytes, in the order the printer writes them. | `Spec.typeBytes_eq` | | The IEND chunk, where reading stops, is a chunk with type bytes I, E, N, D and no data. | `Spec.isIEND_iff` | | A real 4 by 3 PNG file (an IHDR, an IDAT with zlib compressed pixels, and IEND) is read with its own header: width 4, height 3, bit depth 8, colour type 6, no interlace. | `Spec.reads_real_file_header` | | The 13 bytes of a 1920 by 1080 header are read as width 1920 and height 1080, bit depth 8, colour type 2, no interlace. | `Spec.reads_header_1920x1080` | @@ -137,11 +138,12 @@ The round trips and the soundness theorems are stated in terms of the printers. The layout theorems fix what the printers write: `Spec.print_layout` the chunk layout, and `Spec.printIhdr_layout` the 13 header bytes. `Spec.isLetter_iff`, -`Spec.ihdrType_eq`, and `Spec.isIEND_iff` fix the letters and -type bytes the rules name. Together with the round trips and the -soundness theorems, they fix which bytes each reader accepts and -what it returns, for every input. The one function they leave as -the code has it is the CRC-32 (see below). +`Spec.ihdrType_eq`, `Spec.typeBytes_eq`, and `Spec.isIEND_iff` +fix the letters and type bytes the rules name. Together with the +round trips and the soundness theorems, they fix which bytes each +reader accepts and what it returns, for every input. The one +function they leave as the code has it is the CRC-32 (see +below). The headline theorems rest on the proofs in `Proofs/RoundTrip.lean`, `Proofs/Header.lean`, @@ -158,10 +160,10 @@ Proofs/Verify.lean`; the test harness reads that output for every theorem `comparator.json` names, and records it. The refusal theorems and the two accepted inputs are single inputs checked in the kernel with `decide +kernel`, and -`Spec.ihdrType_eq` holds by definition. The test harness reads -the two accepted inputs from `Spec.lean` and checks the header -each states against its own big endian reading of the bytes, -the tool's `header` answer, and libpng's. +`Spec.ihdrType_eq` and `Spec.typeBytes_eq` hold by definition. +The test harness reads the two accepted inputs from `Spec.lean` +and checks the header each states against its own big endian +reading of the bytes, the tool's `header` answer, and libpng's. The code is in `Png/Value.lean` (the chunk type and the printer), `Png/Parse.lean` (the parser), `Png/Crc32.lean` (the diff --git a/SPEC.md b/SPEC.md index db6f9f3..ce88a69 100644 --- a/SPEC.md +++ b/SPEC.md @@ -95,7 +95,7 @@ The round trips and their converses are stated in terms of the printers, so on t - The chunk printer writes the layout of the Chunks section above (`Spec.print_layout`). - The header printer writes the 13 bytes in the order of the IHDR section above (`Spec.printIhdr_layout`). -- The letter test, the IHDR type bytes, and the IEND chunk test are the ones the checks above name (`Spec.isLetter_iff`, `Spec.ihdrType_eq`, `Spec.isIEND_iff`). +- The letter test, the IHDR type bytes, the type bytes a chunk is compared by, and the IEND chunk test are the ones the checks above name (`Spec.isLetter_iff`, `Spec.ihdrType_eq`, `Spec.typeBytes_eq`, `Spec.isIEND_iff`). With the round trips and their converses, these fix which bytes each reader accepts and what it returns, for every input, up to the CRC-32 function, which the chunk layout names as the code has it. Two accepted inputs are stated as worked examples: a real 4 by 3 file is read as width 4, height 3, bit depth 8, colour type 6, no interlace (`Spec.reads_real_file_header`), and the 13 bytes of a 1920 by 1080 header as width 1920 and height 1080 (`Spec.reads_header_1920x1080`). The test harness reads both from `Spec.lean` and checks them against its own reading of the bytes and against libpng. @@ -120,6 +120,7 @@ Each property above names its theorem. The theorems are stated in `Spec.lean` an | The header printer writes the width and the height, four bytes big endian each, then the bit depth, the colour type, zero, zero, and the interlace byte | `Spec.printIhdr_layout` | | The letter test holds of the bytes 65 to 90 and 97 to 122, and of no other | `Spec.isLetter_iff` | | The IHDR type bytes are 73, 72, 68, 82 | `Spec.ihdrType_eq` | +| The type bytes the rules compare are a chunk's four type bytes, in order | `Spec.typeBytes_eq` | | The IEND chunk is type bytes 73, 69, 78, 68 with no data | `Spec.isIEND_iff` | | A real 4 by 3 file is read with header width 4, height 3, bit depth 8, colour type 6, no interlace | `Spec.reads_real_file_header` | | The 13 bytes of a 1920 by 1080 header are read as width 1920, height 1080 | `Spec.reads_header_1920x1080` | diff --git a/Spec.lean b/Spec.lean index 3805acf..fbae444 100644 --- a/Spec.lean +++ b/Spec.lean @@ -96,8 +96,9 @@ printer, but say nothing of the bytes the printer writes: a printer and a reader that both put the height before the width, or both wrote lengths least significant byte first, would meet them. The statements here fix those bytes: the chunk layout, the -13 bytes of the header, the letters a chunk type may use, and the -type bytes of IHDR and IEND. With the round trips and the +13 bytes of the header, the letters a chunk type may use, the +type bytes the rules compare, and the type bytes of IHDR and +IEND. With the round trips and the converses above, they fix what each reader accepts and returns for every input: the chunk parser accepts exactly the bytes `Spec.print_layout` gives for a well formed chunk list, and the @@ -140,6 +141,12 @@ theorem Spec.isLetter_iff (b : UInt8) : are I, H, D, R, in ASCII. -/ theorem Spec.ihdrType_eq : ihdrType = [73, 72, 68, 82] := sorry +/-- The type bytes the chunk rules compare with `ihdrType` + (`Spec.WF`, `Spec.WFFull`, and the readers) are a chunk's four + type bytes, in the order the chunk printer writes them. -/ +theorem Spec.typeBytes_eq (c : Chunk) : + c.typeBytes = [c.t1, c.t2, c.t3, c.t4] := sorry + /-- The IEND chunk, at which reading stops and which ends a well formed chunk list, is a chunk with type bytes I, E, N, D, in ASCII, and no data. -/ diff --git a/Spec/Defs.lean b/Spec/Defs.lean index 749652e..6853a70 100644 --- a/Spec/Defs.lean +++ b/Spec/Defs.lean @@ -28,8 +28,10 @@ def bigEndian4 (n : Nat) : List UInt8 := [UInt8.ofNat (n / 16777216 % 256), UInt8.ofNat (n / 65536 % 256), UInt8.ofNat (n / 256 % 256), UInt8.ofNat (n % 256)] -/-- The IEND chunk itself: type IEND, no data. -/ -def iendChunk : Chunk := ⟨73, 69, 78, 68, []⟩ +/-- The IEND chunk itself: type bytes I, E, N, D, in ASCII, and + no data. The fields are named, so the value does not depend + on the order in which `Png.Chunk` declares them. -/ +def iendChunk : Chunk := { t1 := 73, t2 := 69, t3 := 78, t4 := 68, data := [] } /-- The per-chunk part of well formedness: each type byte is an ASCII letter, and the data length stays below 2^31, as the PNG diff --git a/comparator.json b/comparator.json index 91ceee9..702b595 100644 --- a/comparator.json +++ b/comparator.json @@ -1 +1 @@ -{"challenge_module":"Spec","permitted_axioms":["propext","Classical.choice","Quot.sound"],"solution_module":"Proofs","theorem_names":["Spec.parse_print","Spec.parseIhdr_print","Spec.checkPair_iff","Spec.parseFile_print","Spec.parse_sound","Spec.parseIhdr_sound","Spec.parseFile_sound","Spec.print_layout","Spec.printIhdr_layout","Spec.isLetter_iff","Spec.ihdrType_eq","Spec.isIEND_iff","Spec.reads_real_file_header","Spec.reads_header_1920x1080","Spec.rejects_bad_signature","Spec.rejects_bad_crc","Spec.rejects_truncated_input","Spec.rejects_first_chunk_length_12","Spec.rejects_zero_width","Spec.rejects_zero_height","Spec.rejects_width_at_cap","Spec.rejects_height_at_cap","Spec.rejects_illegal_depth","Spec.rejects_unknown_colour_type","Spec.rejects_unknown_interlace","Spec.rejects_nonzero_compression","Spec.rejects_nonzero_filter","Spec.rejects_second_ihdr","Spec.rejects_header_wrong_length"]} \ No newline at end of file +{"challenge_module":"Spec","permitted_axioms":["propext","Classical.choice","Quot.sound"],"solution_module":"Proofs","theorem_names":["Spec.parse_print","Spec.parseIhdr_print","Spec.checkPair_iff","Spec.parseFile_print","Spec.parse_sound","Spec.parseIhdr_sound","Spec.parseFile_sound","Spec.print_layout","Spec.printIhdr_layout","Spec.isLetter_iff","Spec.ihdrType_eq","Spec.typeBytes_eq","Spec.isIEND_iff","Spec.reads_real_file_header","Spec.reads_header_1920x1080","Spec.rejects_bad_signature","Spec.rejects_bad_crc","Spec.rejects_truncated_input","Spec.rejects_first_chunk_length_12","Spec.rejects_zero_width","Spec.rejects_zero_height","Spec.rejects_width_at_cap","Spec.rejects_height_at_cap","Spec.rejects_illegal_depth","Spec.rejects_unknown_colour_type","Spec.rejects_unknown_interlace","Spec.rejects_nonzero_compression","Spec.rejects_nonzero_filter","Spec.rejects_second_ihdr","Spec.rejects_header_wrong_length"]} \ No newline at end of file diff --git a/tests/TEST-RESULTS.md b/tests/TEST-RESULTS.md index 7a930d2..8eb154e 100644 --- a/tests/TEST-RESULTS.md +++ b/tests/TEST-RESULTS.md @@ -45,7 +45,7 @@ Each file is read by libpng (`tests/libpng_header.c`, built against the system l ## Axiom check -The output of `#print axioms` for the 11 headline theorems and the 29 theorems `comparator.json` names, from `lake env lean Proofs/Verify.lean`. The harness reads the names, and fails on any axiom but `propext`, `Classical.choice`, `Quot.sound`. A self-test on planted output (a `sorryAx`, an axiom of a proof's own, a missing line) runs first. +The output of `#print axioms` for the 11 headline theorems and the 30 theorems `comparator.json` names, from `lake env lean Proofs/Verify.lean`. The harness reads the names, and fails on any axiom but `propext`, `Classical.choice`, `Quot.sound`. A self-test on planted output (a `sorryAx`, an axiom of a proof's own, a missing line) runs first. - 'Png.Proofs.parse_print' depends on axioms: [propext, Quot.sound] - 'Png.Proofs.parseIhdr_print' depends on axioms: [propext, Quot.sound] @@ -69,6 +69,7 @@ The output of `#print axioms` for the 11 headline theorems and the 29 theorems ` - 'Spec.printIhdr_layout' depends on axioms: [propext] - 'Spec.isLetter_iff' depends on axioms: [propext] - 'Spec.ihdrType_eq' depends on axioms: [] +- 'Spec.typeBytes_eq' depends on axioms: [] - 'Spec.isIEND_iff' depends on axioms: [propext] - 'Spec.reads_real_file_header' depends on axioms: [propext, Quot.sound] - 'Spec.reads_header_1920x1080' depends on axioms: [propext, Quot.sound]