Skip to content

Commit 5947453

Browse files
MagicalTuxclaude
andcommitted
qr: the medium never sees a partial word
`Z` compresses the whole file before cutting it up, so a single code is a slice of one deflate stream and cannot be expanded on its own -- the stream is staged whole and expanded afterwards. What can be fixed is what reaches the part while that happens. **Every write is now a whole number of aligned words.** A short write makes the area read the word back to merge with, and the expansion is the one place in a QR transfer where reads and writes really are interleaved at full speed rather than a part at a time -- everywhere else there are hundreds of milliseconds between accesses. The output is sequential, so three bytes of carry is the whole fix: an odd tail waits for the front of the next chunk to complete its word. The last word may be padded, because nothing follows the image's end. A test walks every write the expansion made and checks both the offset and the length. Our own sender goes back to a multiple of twenty: five for base32's groups, four for the device's words. This is not the refusal that was removed -- the device still accepts whatever part size a sender chose, because another wallet's BBQr is not ours to dictate and its driver merges an odd edge rather than refusing. It is only that when the sender *is* ours there is no reason to make it merge anything, and then the medium is not read even once during the whole transfer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 143b07b commit 5947453

3 files changed

Lines changed: 101 additions & 15 deletions

File tree

‎crates/catcard-upgrade/src/expand.rs‎

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,15 @@ pub fn inflate<A: StagingArea>(
7171
// can see the other half-done.
7272
let mut read_at = 0u32;
7373
let mut write_at = 0u32;
74+
// The tail of the last chunk, when it did not end on a word boundary.
75+
//
76+
// **The medium only ever sees whole aligned words.** A partial word would make the
77+
// area read the word back to merge with, and this is the one place in the transfer
78+
// where reads and writes really are interleaved at full speed rather than a part at
79+
// a time. The output is sequential, so three bytes of carry is the whole fix: the
80+
// odd tail waits for the front of the next chunk to complete its word.
81+
let mut carry = [0u8; 4];
82+
let mut carried = 0usize;
7483

7584
let outcome = {
7685
let reader = Reader::new(chunk, |buf: &mut [u8]| {
@@ -86,13 +95,45 @@ pub fn inflate<A: StagingArea>(
8695
});
8796
let stream = Stream::new(window, max as u64, |data: &[u8]| {
8897
let mut area = cell.try_borrow_mut().map_err(|_| ZError::Io)?;
89-
area.write(write_at, data).map_err(|_| ZError::Io)?;
90-
write_at += data.len() as u32;
98+
let mut data = data;
99+
100+
// Finish the word left over from last time before anything else.
101+
if carried > 0 {
102+
let take = data.len().min(4 - carried);
103+
carry[carried..carried + take].copy_from_slice(&data[..take]);
104+
carried += take;
105+
data = &data[take..];
106+
if carried < 4 {
107+
return Ok(());
108+
}
109+
area.write(write_at, &carry).map_err(|_| ZError::Io)?;
110+
write_at += 4;
111+
carried = 0;
112+
}
113+
114+
let whole = data.len() & !3;
115+
if whole > 0 {
116+
area.write(write_at, &data[..whole])
117+
.map_err(|_| ZError::Io)?;
118+
write_at += whole as u32;
119+
}
120+
carried = data.len() - whole;
121+
carry[..carried].copy_from_slice(&data[whole..]);
91122
Ok(())
92123
});
93124
minizlib::inflate(reader, stream)
94125
};
95126

127+
// The last few bytes of the image, if it does not end on a word boundary. Nothing
128+
// follows them, so the word they complete holds nothing that matters and the
129+
// padding is past the image's length.
130+
if outcome.is_ok() && carried > 0 {
131+
carry[carried..].fill(0);
132+
cell.borrow_mut()
133+
.write(write_at, &carry)
134+
.map_err(|_| Error::Storage)?;
135+
}
136+
96137
match outcome {
97138
Ok(n) => Ok(n as u32),
98139
Err(ZError::WindowTooSmall) => Err(Error::WindowTooSmall),

‎crates/catcard-upgrade/src/tests.rs‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -893,6 +893,39 @@ mod expanding {
893893
);
894894
}
895895

896+
/// **The medium never sees a partial word.** A short write would make the area read
897+
/// the word back to merge with, and the expansion is the one place in a QR transfer
898+
/// where reads and writes are interleaved at full speed rather than a part at a
899+
/// time. Every write but the last is a whole number of aligned words; the last may
900+
/// be padded, because nothing follows the image's end.
901+
#[test]
902+
fn every_write_is_whole_aligned_words() {
903+
let image = firmwareish(200 * 1024 + 3);
904+
let stream = deflate(&image, SENDER_WINDOW);
905+
let mut mem = staged(&stream);
906+
let mut window = vec![0u8; 8 * 1024];
907+
let mut chunk = vec![0u8; 1024];
908+
let n = expand::inflate(
909+
&mut mem,
910+
FROM,
911+
stream.len() as u32,
912+
image.len() as u32 + 4,
913+
&mut window,
914+
&mut chunk,
915+
)
916+
.expect("expands");
917+
assert_eq!(n as usize, image.len());
918+
919+
for &(offset, len) in &mem.writes {
920+
if offset == FROM {
921+
continue; // the staging write this test did itself
922+
}
923+
assert_eq!(offset % 4, 0, "wrote {len} at unaligned {offset}");
924+
assert_eq!(len % 4, 0, "wrote a partial word of {len} at {offset}");
925+
}
926+
assert_eq!(&mem.bytes[..image.len()], &image[..]);
927+
}
928+
896929
/// **Raw deflate carries no checksum**, so a damaged stream can expand to exactly
897930
/// the right length and simply be the wrong bytes -- there is nothing in the format
898931
/// to notice. That is not a gap to be closed here: the image's signature is checked

‎tools/catcard-image/src/qrpage.rs‎

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -31,15 +31,19 @@ const MAX_VERSION: u8 = 40;
3131

3232
/// Bytes per part, unless the caller says otherwise.
3333
///
34-
/// **A multiple of five**, which is base32's own constraint: eight characters carry five
35-
/// bytes, so a part that is not the last must be a whole number of groups or the parts
36-
/// after it do not decode on their own.
34+
/// **A multiple of twenty.** Five is base32's own constraint -- eight characters carry
35+
/// five bytes, so a part that is not the last must be a whole number of groups or the
36+
/// parts after it do not decode on their own. Four is the device's: parts land in PSRAM,
37+
/// which takes whole aligned words, and a part whose length is not a multiple of four
38+
/// puts every part after it at an unaligned offset.
3739
///
38-
/// 1275 because the device's scanner buffer holds 2048 characters and 1275 bytes is
39-
/// 2048 of them with the header -- the largest part that fits. There is no alignment
40-
/// rule on top of that: parts land in PSRAM through a driver that paces them, and at one
41-
/// part every few hundred milliseconds the odd unaligned edge has all the time it needs.
42-
pub const DEFAULT_PART: usize = 1275;
40+
/// The device does **not** require this -- it cannot, since another wallet's BBQr parts
41+
/// are whatever size that wallet chose, and its driver merges an odd edge rather than
42+
/// refusing. But when the sender is ours there is no reason to make it: every part lands
43+
/// as whole words and the medium is never read during the transfer at all.
44+
///
45+
/// 1260 is the largest such size whose line still fits the device's scanner buffer.
46+
pub const DEFAULT_PART: usize = 1260;
4347

4448
/// Compress the image if that makes fewer codes, and say which was used.
4549
///
@@ -282,19 +286,27 @@ mod tests {
282286
assert_eq!(b64(b"foobar"), "Zm9vYmFy");
283287
}
284288

285-
/// The default is the largest part whose line still fits the device's scanner
286-
/// buffer, and a whole number of base32 groups. Both fail quietly if they drift: an
287-
/// over-long line is simply never read, and a part that is not a whole number of
288-
/// groups puts every part after the first at the wrong offset.
289+
/// The default is the largest part that satisfies both formats and still fits the
290+
/// device's scanner buffer. All three fail quietly if they drift: an over-long line
291+
/// is simply never read, a part that is not a whole number of base32 groups puts
292+
/// every part after the first at the wrong offset, and one that is not a whole
293+
/// number of words makes the device merge every part edge instead of writing it.
289294
#[test]
290295
fn the_default_part_is_the_largest_that_fits() {
291296
assert_eq!(
292297
DEFAULT_PART % 5,
293298
0,
294299
"base32 packs five bytes to eight chars"
295300
);
296-
assert_eq!(fits(Encoding::Base32, SCANNER_BUFFER), DEFAULT_PART);
301+
assert_eq!(DEFAULT_PART % 4, 0, "the device stages whole words");
297302
assert!(part_len(Encoding::Base32, DEFAULT_PART) <= SCANNER_BUFFER);
303+
// The largest such size that fits: the next one up does not.
304+
assert!(part_len(Encoding::Base32, DEFAULT_PART + 20) > SCANNER_BUFFER);
305+
// And it is what base32 alone would allow, rounded down to a whole word.
306+
assert_eq!(
307+
fits(Encoding::Base32, SCANNER_BUFFER) / 20 * 20,
308+
DEFAULT_PART
309+
);
298310
// `Z` is base32 underneath, so the line arithmetic is the same either way.
299311
assert_eq!(
300312
part_len(Encoding::Zlib, DEFAULT_PART),

0 commit comments

Comments
 (0)