Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 72 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

29 changes: 29 additions & 0 deletions crates/dpp-render/src/carrier.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,35 @@

use serde_json::Value;

/// The quiet zone every rendering of a carrier must leave around the symbol,
/// in modules.
///
/// ISO/IEC 18004 requires four modules of blank margin on all four sides of a
/// QR symbol. It is not decoration: the quiet zone is what lets a scanner find
/// the symbol's edge, and a code printed flush to other artwork — or to the
/// edge of a label — is out of spec whether or not any particular decoder is
/// lenient enough to read it anyway.
///
/// # Why this is a shared constant and not a number in each renderer
///
/// It was a number in each renderer, and they disagreed. The SVG rendered for
/// the passport page applied four modules; the PNG served by the resolver's
/// `/qr` route applied **none**, sizing its image at exactly `width * scale`.
/// The divergence ran the wrong way round, too — the screen rendering, which a
/// browser surrounds with white page anyway, was the compliant one, while the
/// downloadable PNG an operator would actually print onto a label was the
/// symbol with no margin at all.
///
/// Software decoders mostly tolerate a missing quiet zone, which is exactly why
/// nothing caught it: a round-trip test that only asks "does this decode?" is
/// satisfied by a symbol no hand scanner would read against a busy background.
/// So the geometry is asserted directly, per renderer, against this constant.
///
/// The two renderers stay separate — they are split by output class, and
/// `lib.rs` records the intent to revisit that — but the property neither is
/// allowed to get wrong now has one home.
pub const QR_QUIET_ZONE_MODULES: u32 = 4;

/// Build the GS1 Digital Link URI a carrier (QR/Data Matrix) for this
/// passport should encode.
///
Expand Down
2 changes: 1 addition & 1 deletion crates/dpp-render/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,5 @@ mod page;
mod remainder;
mod sections;

pub use carrier::carrier_uri;
pub use carrier::{QR_QUIET_ZONE_MODULES, carrier_uri};
pub use page::{SnapshotNotice, build_qr_svg, render_page};
56 changes: 54 additions & 2 deletions crates/dpp-render/src/page.rs
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ pub fn build_qr_svg(carrier_uri: &str) -> String {
let width = code.width();
let colors = code.to_colors();
let module_size = 4u32;
let quiet = 4u32; // quiet zone in modules
let quiet = crate::carrier::QR_QUIET_ZONE_MODULES;
let total = (width as u32 + quiet * 2) * module_size;

let mut rects = String::with_capacity(colors.len() * 48);
Expand Down Expand Up @@ -337,13 +337,65 @@ mod tests {
assert!(html.contains("<script>"));
}

/// Renamed from `build_qr_svg_encodes_the_carrier_uri`, which claimed more
/// than it checked: the URI it found was the one in the `<title>`, not
/// anything about the modules. What the symbol encodes is covered by the
/// resolver's PNG round-trip; both renderings come from the same
/// `QrCode::new`, so the encoding is exercised once rather than twice.
#[test]
fn build_qr_svg_encodes_the_carrier_uri() {
fn build_qr_svg_names_the_carrier_uri_in_its_title() {
let svg = build_qr_svg("https://id.odal-node.io/01/09506000134352/21/abc");
assert!(svg.starts_with("<svg"));
assert!(svg.contains("id.odal-node.io"));
}

/// The SVG reserves the four-module quiet zone on all sides.
///
/// Read off the geometry: the `viewBox` must be wider than the symbol by
/// exactly two quiet zones, and no drawn module may fall inside the margin.
/// The PNG renderer had this wrong while this one had it right, which is
/// why the value is now a shared constant and why both sides assert it.
#[test]
fn build_qr_svg_reserves_the_quiet_zone() {
let uri = "https://id.odal-node.io/01/09506000134352/21/abc";
let svg = build_qr_svg(uri);
let module_size = 4u32;
let quiet_px = crate::carrier::QR_QUIET_ZONE_MODULES * module_size;

let total: u32 = svg
.split("viewBox=\"0 0 ")
.nth(1)
.and_then(|s| s.split(' ').next())
.and_then(|s| s.parse().ok())
.expect("the svg must declare a square viewBox");

let symbol_px = QrCode::new(uri.as_bytes()).unwrap().width() as u32 * module_size;
assert_eq!(
total,
symbol_px + quiet_px * 2,
"the viewBox must reserve a quiet zone on both sides"
);

// No module is drawn inside the margin.
for rect in svg.split("<rect x=\"").skip(1) {
let x: u32 = rect.split('"').next().unwrap().parse().unwrap();
let y: u32 = rect
.split("y=\"")
.nth(1)
.and_then(|s| s.split('"').next())
.and_then(|s| s.parse().ok())
.expect("every module rect carries a y");
assert!(
x >= quiet_px && y >= quiet_px,
"module at ({x},{y}) intrudes into the {quiet_px}px quiet zone"
);
assert!(
x + module_size <= total - quiet_px && y + module_size <= total - quiet_px,
"module at ({x},{y}) crosses the far quiet-zone edge"
);
}
}

#[test]
fn build_qr_svg_escapes_the_title() {
let svg = build_qr_svg("https://id.odal-node.io/\"><script>alert(1)</script>");
Expand Down
5 changes: 5 additions & 0 deletions crates/dpp-resolver/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,8 @@ metrics-exporter-prometheus = { workspace = true }
tower = { version = "0.5", features = ["util"] }
ed25519-dalek = { workspace = true }
rand = { workspace = true }
# Test-only QR decoder. The round-trip it enables is the only thing that can
# tell "this endpoint returned a PNG" apart from "this endpoint returned a PNG
# encoding the right URL" — the distinction the previous tests could not make,
# and the one a `qrcode` or `image` bump can silently change.
rqrr = "0.10"
Loading