Dev (#1)
Sync README to GitHub / sync (push) Successful in 12s
CI / check (push) Successful in 8m31s
Deploy docs / deploy (push) Successful in 6m44s
Nightly / nightly (push) Successful in 10m33s

Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
2026-08-07 15:48:11 -04:00
parent 228a0da47f
commit abc0bdf621
79 changed files with 31370 additions and 0 deletions
+252
View File
@@ -0,0 +1,252 @@
// SPDX-License-Identifier: Prosperity-3.0.0
// Copyright Scientific Computing Studio
// Source: https://git.scient.ing/education/coursebank
//! A minimal calendar date, serialized as `YYYY-MM-DD`.
//!
//! Course data is full of dates: when a lecture ran, when an item was authored,
//! when an exam was administered. Those dates need to sort, subtract, and round
//! trip through YAML exactly as written, but they never need time zones or
//! clock time. That is a small enough job to do without a dependency, so this
//! module implements it directly on top of the proleptic Gregorian calendar.
//!
//! The derived [`Ord`] is chronological because the fields are declared
//! most-significant first.
use std::fmt;
use std::str::FromStr;
use std::time::{SystemTime, UNIX_EPOCH};
use serde::de::{self, Visitor};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::error::{Error, Result};
/// A calendar date with no time or zone.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Date {
/// Proleptic Gregorian year.
pub year: i32,
/// Month, 1 through 12.
pub month: u32,
/// Day of month, 1 through the length of the month.
pub day: u32,
}
impl Date {
/// Builds a date, checking that it exists on the calendar.
///
/// # Arguments
///
/// * `year` - the proleptic Gregorian year.
/// * `month` - month, 1 through 12.
/// * `day` - day of month.
///
/// # Returns
///
/// The date.
///
/// # Errors
///
/// Returns [`Error::BadDate`] when the month or day is out of range,
/// including February 30 and non-leap February 29.
pub fn new(year: i32, month: u32, day: u32) -> Result<Date> {
if !(1..=12).contains(&month) || day < 1 || day > days_in_month(year, month) {
return Err(Error::BadDate(format!("{year:04}-{month:02}-{day:02}")));
}
Ok(Date { year, month, day })
}
/// Today's date in UTC, read from the system clock.
///
/// UTC rather than local time keeps the value reproducible on any machine
/// that touches the course repository, which matters because these dates
/// end up committed.
///
/// # Returns
///
/// Today's date, or 1970-01-01 if the clock is set before the epoch.
pub fn today() -> Date {
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
Date::from_days_since_epoch(secs.div_euclid(86_400))
}
/// Days since 1970-01-01, negative before it.
///
/// Uses Howard Hinnant's `days_from_civil`, which is exact for the whole
/// proleptic Gregorian range.
///
/// # Returns
///
/// The signed day count.
pub fn days_since_epoch(&self) -> i64 {
let y = if self.month <= 2 {
self.year as i64 - 1
} else {
self.year as i64
};
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = y - era * 400;
let m = self.month as i64;
let d = self.day as i64;
let mp = if m > 2 { m - 3 } else { m + 9 };
let doy = (153 * mp + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146_097 + doe - 719_468
}
/// The inverse of [`Date::days_since_epoch`].
///
/// # Arguments
///
/// * `z` - days since 1970-01-01.
///
/// # Returns
///
/// The corresponding date.
pub fn from_days_since_epoch(z: i64) -> Date {
let z = z + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
Date {
year: (if m <= 2 { y + 1 } else { y }) as i32,
month: m as u32,
day: d as u32,
}
}
/// Whole days from `self` to `other`, positive when `other` is later.
///
/// # Arguments
///
/// * `other` - the date to measure to.
///
/// # Returns
///
/// The signed difference in days.
pub fn days_until(&self, other: Date) -> i64 {
other.days_since_epoch() - self.days_since_epoch()
}
}
/// Length of a month, accounting for leap years.
fn days_in_month(year: i32, month: u32) -> u32 {
match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 if is_leap(year) => 29,
2 => 28,
_ => 0,
}
}
/// Whether a proleptic Gregorian year is a leap year.
fn is_leap(year: i32) -> bool {
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
}
impl fmt::Display for Date {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{:04}-{:02}-{:02}", self.year, self.month, self.day)
}
}
impl FromStr for Date {
type Err = Error;
fn from_str(s: &str) -> Result<Date> {
let t = s.trim();
let parts: Vec<&str> = t.split('-').collect();
if parts.len() != 3 {
return Err(Error::BadDate(t.to_string()));
}
let year: i32 = parts[0]
.parse()
.map_err(|_| Error::BadDate(t.to_string()))?;
let month: u32 = parts[1]
.parse()
.map_err(|_| Error::BadDate(t.to_string()))?;
let day: u32 = parts[2]
.parse()
.map_err(|_| Error::BadDate(t.to_string()))?;
Date::new(year, month, day)
}
}
impl Serialize for Date {
fn serialize<S: Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
s.serialize_str(&self.to_string())
}
}
impl<'de> Deserialize<'de> for Date {
fn deserialize<D: Deserializer<'de>>(d: D) -> std::result::Result<Date, D::Error> {
struct V;
impl<'a> Visitor<'a> for V {
type Value = Date;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("a date in YYYY-MM-DD form")
}
fn visit_str<E: de::Error>(self, v: &str) -> std::result::Result<Date, E> {
v.parse::<Date>().map_err(de::Error::custom)
}
}
d.deserialize_str(V)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trips_through_string() {
let d: Date = "2026-04-23".parse().unwrap();
assert_eq!(d.year, 2026);
assert_eq!(d.month, 4);
assert_eq!(d.day, 23);
assert_eq!(d.to_string(), "2026-04-23");
}
#[test]
fn epoch_and_back() {
for iso in ["1970-01-01", "2000-02-29", "2026-04-23", "1969-12-31"] {
let d: Date = iso.parse().unwrap();
assert_eq!(
Date::from_days_since_epoch(d.days_since_epoch()),
d,
"{iso}"
);
}
assert_eq!("1970-01-01".parse::<Date>().unwrap().days_since_epoch(), 0);
}
#[test]
fn rejects_impossible_dates() {
assert!("2026-02-30".parse::<Date>().is_err());
assert!("2025-02-29".parse::<Date>().is_err());
assert!("2024-02-29".parse::<Date>().is_ok());
assert!("2026-13-01".parse::<Date>().is_err());
assert!("2026-4-23-1".parse::<Date>().is_err());
}
#[test]
fn orders_chronologically() {
let a: Date = "2025-12-31".parse().unwrap();
let b: Date = "2026-01-01".parse().unwrap();
assert!(a < b);
assert_eq!(a.days_until(b), 1);
assert_eq!(b.days_until(a), -1);
}
}
+257
View File
@@ -0,0 +1,257 @@
// SPDX-License-Identifier: Prosperity-3.0.0
// Copyright Scientific Computing Studio
// Source: https://git.scient.ing/education/coursebank
//! Content fingerprints and student pseudonyms.
//!
//! Two different jobs need two different hashes, and conflating them would be a
//! privacy bug.
//!
//! [`fingerprint`] answers "is this the same question I used last time?". It
//! only needs to be stable and short, so it uses FNV-1a. It is not a security
//! primitive and is never applied to anything about a person.
//!
//! [`pseudonym`] answers "can I keep a response table under version control
//! without publishing who answered what?". Student identifiers are low entropy
//! (a seven-digit number is a ten-million-item dictionary), so an unkeyed hash
//! of one is trivially reversible and would provide no protection at all. It
//! therefore uses HMAC-SHA-256 under a secret salt that lives outside the
//! repository. Both primitives are implemented here so the crate needs no
//! cryptography dependency.
/// FNV-1a 64-bit offset basis.
const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
/// FNV-1a 64-bit prime.
const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
/// A short, stable content fingerprint rendered as 16 lowercase hex digits.
///
/// Used to detect that a question was silently edited between two
/// administrations, which invalidates pooling their statistics.
///
/// # Arguments
///
/// * `parts` - the canonical content pieces, hashed in order with a separator
/// so that reordering or regrouping them changes the result.
///
/// # Returns
///
/// The fingerprint as a hex string.
pub fn fingerprint<'a, I>(parts: I) -> String
where
I: IntoIterator<Item = &'a str>,
{
let mut h = FNV_OFFSET;
for part in parts {
for b in part.as_bytes() {
h ^= *b as u64;
h = h.wrapping_mul(FNV_PRIME);
}
// A byte that cannot appear in the inputs, so concatenation is unambiguous.
h ^= 0x1f;
h = h.wrapping_mul(FNV_PRIME);
}
format!("{h:016x}")
}
/// A keyed pseudonym for a student identifier.
///
/// # Arguments
///
/// * `salt` - a secret of at least 16 bytes, kept out of version control.
/// * `id` - the institutional identifier or email to replace.
/// * `len` - how many hex characters to keep; 16 gives a 64-bit tag, which is
/// ample for a cohort and short enough to read in a table.
///
/// # Returns
///
/// The truncated hex tag, prefixed with `s-`.
pub fn pseudonym(salt: &[u8], id: &str, len: usize) -> String {
let mac = hmac_sha256(salt, id.trim().to_lowercase().as_bytes());
let hex: String = mac.iter().map(|b| format!("{b:02x}")).collect();
format!("s-{}", &hex[..len.min(hex.len())])
}
/// HMAC-SHA-256.
///
/// # Arguments
///
/// * `key` - the secret key, of any length.
/// * `msg` - the message to authenticate.
///
/// # Returns
///
/// The 32-byte tag.
pub fn hmac_sha256(key: &[u8], msg: &[u8]) -> [u8; 32] {
const BLOCK: usize = 64;
let mut k = [0u8; BLOCK];
if key.len() > BLOCK {
k[..32].copy_from_slice(&sha256(key));
} else {
k[..key.len()].copy_from_slice(key);
}
let mut inner = Vec::with_capacity(BLOCK + msg.len());
let mut outer = Vec::with_capacity(BLOCK + 32);
for b in k.iter() {
inner.push(b ^ 0x36);
outer.push(b ^ 0x5c);
}
inner.extend_from_slice(msg);
outer.extend_from_slice(&sha256(&inner));
sha256(&outer)
}
/// SHA-256 round constants.
#[rustfmt::skip]
const K: [u32; 64] = [
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
];
/// SHA-256 of a byte slice.
///
/// # Arguments
///
/// * `msg` - the message to digest.
///
/// # Returns
///
/// The 32-byte digest.
pub fn sha256(msg: &[u8]) -> [u8; 32] {
let mut h: [u32; 8] = [
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab,
0x5be0cd19,
];
// Pad to a multiple of 64 bytes: 0x80, zeros, then the 64-bit bit length.
let mut data = msg.to_vec();
let bit_len = (msg.len() as u64).wrapping_mul(8);
data.push(0x80);
while data.len() % 64 != 56 {
data.push(0);
}
data.extend_from_slice(&bit_len.to_be_bytes());
let mut w = [0u32; 64];
for chunk in data.chunks(64) {
for (i, w_i) in w.iter_mut().enumerate().take(16) {
let j = i * 4;
*w_i = u32::from_be_bytes([chunk[j], chunk[j + 1], chunk[j + 2], chunk[j + 3]]);
}
for i in 16..64 {
let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);
w[i] = w[i - 16]
.wrapping_add(s0)
.wrapping_add(w[i - 7])
.wrapping_add(s1);
}
let mut v = h;
for i in 0..64 {
let s1 = v[4].rotate_right(6) ^ v[4].rotate_right(11) ^ v[4].rotate_right(25);
let ch = (v[4] & v[5]) ^ ((!v[4]) & v[6]);
let t1 = v[7]
.wrapping_add(s1)
.wrapping_add(ch)
.wrapping_add(K[i])
.wrapping_add(w[i]);
let s0 = v[0].rotate_right(2) ^ v[0].rotate_right(13) ^ v[0].rotate_right(22);
let maj = (v[0] & v[1]) ^ (v[0] & v[2]) ^ (v[1] & v[2]);
let t2 = s0.wrapping_add(maj);
v[7] = v[6];
v[6] = v[5];
v[5] = v[4];
v[4] = v[3].wrapping_add(t1);
v[3] = v[2];
v[2] = v[1];
v[1] = v[0];
v[0] = t1.wrapping_add(t2);
}
for i in 0..8 {
h[i] = h[i].wrapping_add(v[i]);
}
}
let mut out = [0u8; 32];
for i in 0..8 {
out[i * 4..i * 4 + 4].copy_from_slice(&h[i].to_be_bytes());
}
out
}
/// Renders bytes as lowercase hex.
///
/// # Arguments
///
/// * `bytes` - the bytes to render.
///
/// # Returns
///
/// The hex string.
pub fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sha256_matches_known_vectors() {
assert_eq!(
hex(&sha256(b"")),
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
assert_eq!(
hex(&sha256(b"abc")),
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
// Longer than one block, to exercise the multi-chunk path.
assert_eq!(
hex(&sha256(
b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq"
)),
"248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1"
);
}
#[test]
fn hmac_matches_rfc4231_case_2() {
// RFC 4231 test case 2: key "Jefe", data "what do ya want for nothing?".
assert_eq!(
hex(&hmac_sha256(b"Jefe", b"what do ya want for nothing?")),
"5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
);
}
#[test]
fn fingerprint_is_order_sensitive_and_unambiguous() {
assert_ne!(fingerprint(["a", "b"]), fingerprint(["b", "a"]));
// Separator prevents "ab" + "c" colliding with "a" + "bc".
assert_ne!(fingerprint(["ab", "c"]), fingerprint(["a", "bc"]));
assert_eq!(fingerprint(["a", "b"]), fingerprint(["a", "b"]));
assert_eq!(fingerprint(["x"]).len(), 16);
}
#[test]
fn pseudonym_is_keyed_and_normalized() {
let a = pseudonym(b"salt-one-0123456", "4496395", 16);
let b = pseudonym(b"salt-two-0123456", "4496395", 16);
assert_ne!(a, b, "different salts must give different pseudonyms");
assert_eq!(
pseudonym(b"salt-one-0123456", " SCD62@pitt.edu ", 16),
pseudonym(b"salt-one-0123456", "scd62@pitt.edu", 16)
);
assert!(a.starts_with("s-"));
assert_eq!(a.len(), 18);
}
}
+382
View File
@@ -0,0 +1,382 @@
// SPDX-License-Identifier: Prosperity-3.0.0
// Copyright Scientific Computing Studio
// Source: https://git.scient.ing/education/coursebank
//! Converting the authoring markup into HTML, plain text, and Typst.
//!
//! Stems are written in a small markup that is a subset of Typst with a few
//! Markdown conveniences, because chemistry and biology questions need
//! subscripts, arrows, and Greek letters, and typing HTML entities into YAML by
//! hand is miserable.
//!
//! There is no regular expression engine here. Every rule is a scan, which keeps
//! the dependency list short and makes the escaping order explicit: HTML is
//! escaped *first*, then symbol substitutions run, so that a substitution
//! producing `&rarr;` is not itself escaped into `&amp;rarr;`.
/// Symbol substitutions, applied after HTML escaping.
///
/// Ordered longest-first within each family so `#sym.arrow.r` is not consumed by
/// a shorter prefix.
const SYMBOLS: &[(&str, &str, &str)] = &[
// (source token, html, plain text)
("#sym.gt.eq", "&ge;", "\u{2265}"),
("#sym.lt.eq", "&le;", "\u{2264}"),
("#sym.eq.not", "&ne;", "\u{2260}"),
("#sym.plus.minus", "&plusmn;", "\u{00b1}"),
("#sym.arrow.r", "&rarr;", "\u{2192}"),
("#sym.arrow.l", "&larr;", "\u{2190}"),
("#sym.arrow.lr", "&harr;", "\u{2194}"),
("#sym.rightarrow", "&rarr;", "\u{2192}"),
("#sym.leftarrow", "&larr;", "\u{2190}"),
("#sym.times", "&times;", "\u{00d7}"),
("#sym.dot", "&middot;", "\u{00b7}"),
("#sym.degree", "&deg;", "\u{00b0}"),
("#sym.infinity", "&infin;", "\u{221e}"),
("#sym.approx", "&asymp;", "\u{2248}"),
("#sym.alpha", "&alpha;", "\u{03b1}"),
("#sym.beta", "&beta;", "\u{03b2}"),
("#sym.gamma", "&gamma;", "\u{03b3}"),
("#sym.delta.cap", "&Delta;", "\u{0394}"),
("#sym.delta", "&delta;", "\u{03b4}"),
("#sym.epsilon", "&epsilon;", "\u{03b5}"),
("#sym.lambda", "&lambda;", "\u{03bb}"),
("#sym.mu", "&mu;", "\u{03bc}"),
("#sym.pi", "&pi;", "\u{03c0}"),
("#sym.sigma", "&sigma;", "\u{03c3}"),
("#sym.tau", "&tau;", "\u{03c4}"),
("#sym.phi", "&phi;", "\u{03c6}"),
("#sym.omega", "&omega;", "\u{03c9}"),
];
/// Converts authoring markup to an HTML fragment.
///
/// Handles paragraphs, bold, italic, inline code, subscripts, superscripts, and
/// the symbol table. Anything unrecognized passes through escaped, so a stray
/// `<script>` in a stem cannot become markup in a Canvas quiz.
///
/// # Arguments
///
/// * `src` - the authoring source.
///
/// # Returns
///
/// An HTML fragment, with each paragraph wrapped in `<p>`.
pub fn to_html(src: &str) -> String {
let escaped = escape_html(src);
let symbolized = apply_symbols(&escaped, true);
let inline = apply_inline(&symbolized);
let paragraphs: Vec<String> = inline
.split("\n\n")
.map(|p| p.trim())
.filter(|p| !p.is_empty())
.map(|p| {
let joined = p
.lines()
.map(|l| l.trim())
.filter(|l| !l.is_empty())
.collect::<Vec<_>>()
.join(" ");
format!("<p>{joined}</p>")
})
.collect();
if paragraphs.is_empty() {
String::new()
} else {
paragraphs.join("\n")
}
}
/// Converts authoring markup to plain text.
///
/// Used for CSV columns, terminal output, and any place a fragment of HTML would
/// be noise.
///
/// # Arguments
///
/// * `src` - the authoring source.
///
/// # Returns
///
/// Plain text with markup removed and symbols rendered as Unicode.
pub fn to_plain(src: &str) -> String {
let symbolized = apply_symbols(src, false);
let mut out = strip_inline(&symbolized);
out = out
.lines()
.map(|l| l.trim())
.filter(|l| !l.is_empty())
.collect::<Vec<_>>()
.join(" ");
out.trim().to_string()
}
/// Passes authoring markup through for Typst.
///
/// The markup is already a Typst subset, so this only normalizes whitespace and
/// escapes the few characters Typst treats specially in content mode.
///
/// # Arguments
///
/// * `src` - the authoring source.
///
/// # Returns
///
/// Typst content-mode markup.
pub fn to_typst(src: &str) -> String {
let mut out = String::with_capacity(src.len());
for ch in src.trim().chars() {
match ch {
// A bare `@` or `<` starts a Typst reference or label.
'@' => out.push_str("\\@"),
'<' => out.push_str("\\<"),
'>' => out.push_str("\\>"),
_ => out.push(ch),
}
}
out
}
/// Escapes the five XML-significant characters.
///
/// # Arguments
///
/// * `s` - the text to escape.
///
/// # Returns
///
/// The escaped text.
pub fn escape_html(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
'&' => out.push_str("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
'\'' => out.push_str("&apos;"),
_ => out.push(ch),
}
}
out
}
/// Applies the symbol table.
///
/// # Arguments
///
/// * `s` - the text.
/// * `html` - whether to emit HTML entities rather than Unicode.
///
/// # Returns
///
/// The substituted text.
fn apply_symbols(s: &str, html: bool) -> String {
let mut out = s.to_string();
for (token, entity, plain) in SYMBOLS {
if out.contains(token) {
out = out.replace(token, if html { entity } else { plain });
}
}
out
}
/// Applies inline markup rules, producing HTML.
///
/// # Arguments
///
/// * `s` - escaped, symbol-substituted text.
///
/// # Returns
///
/// The text with inline markup converted.
fn apply_inline(s: &str) -> String {
let mut out = s.to_string();
// Bracketed forms first: their contents may contain other markup characters.
out = wrap_bracket(&out, "#sub[", "<sub>", "</sub>");
out = wrap_bracket(&out, "#sup[", "<sup>", "</sup>");
out = wrap_delimited(&out, "`", "<code>", "</code>");
out = wrap_delimited(&out, "**", "<strong>", "</strong>");
out = wrap_delimited(&out, "*", "<em>", "</em>");
out = wrap_delimited(&out, "_", "<em>", "</em>");
out
}
/// Removes inline markup without replacing it.
///
/// # Arguments
///
/// * `s` - the text.
///
/// # Returns
///
/// The text with markup delimiters stripped.
fn strip_inline(s: &str) -> String {
let mut out = s.to_string();
out = wrap_bracket(&out, "#sub[", "", "");
out = wrap_bracket(&out, "#sup[", "", "");
out = wrap_delimited(&out, "`", "", "");
out = wrap_delimited(&out, "**", "", "");
out = wrap_delimited(&out, "*", "", "");
out = wrap_delimited(&out, "_", "", "");
out
}
/// Replaces `open...]` spans with wrapped content.
///
/// # Arguments
///
/// * `s` - the text.
/// * `open` - the opening token, e.g. `"#sub["`.
/// * `pre` - text to emit before the content.
/// * `post` - text to emit after the content.
///
/// # Returns
///
/// The rewritten text. Unclosed spans are left alone.
fn wrap_bracket(s: &str, open: &str, pre: &str, post: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut rest = s;
loop {
match rest.find(open) {
None => {
out.push_str(rest);
return out;
}
Some(i) => {
let after = &rest[i + open.len()..];
match after.find(']') {
None => {
out.push_str(rest);
return out;
}
Some(j) => {
out.push_str(&rest[..i]);
out.push_str(pre);
out.push_str(&after[..j]);
out.push_str(post);
rest = &after[j + 1..];
}
}
}
}
}
}
/// Replaces paired `delim...delim` spans with wrapped content.
///
/// A delimiter with no partner is emitted literally, so an apostrophe-heavy stem
/// or a lone asterisk does not swallow the rest of the text.
///
/// # Arguments
///
/// * `s` - the text.
/// * `delim` - the delimiter, e.g. `"**"`.
/// * `pre` - text to emit before the content.
/// * `post` - text to emit after the content.
///
/// # Returns
///
/// The rewritten text.
fn wrap_delimited(s: &str, delim: &str, pre: &str, post: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut rest = s;
loop {
match rest.find(delim) {
None => {
out.push_str(rest);
return out;
}
Some(i) => {
let after = &rest[i + delim.len()..];
match after.find(delim) {
None => {
out.push_str(rest);
return out;
}
Some(0) => {
// Empty span such as `**`; emit literally and move on.
out.push_str(&rest[..i + delim.len()]);
rest = after;
}
Some(j) => {
out.push_str(&rest[..i]);
out.push_str(pre);
out.push_str(&after[..j]);
out.push_str(post);
rest = &after[j + delim.len()..];
}
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn escapes_before_substituting() {
// The entity produced by the symbol table must survive escaping.
assert_eq!(to_html("a #sym.arrow.r b"), "<p>a &rarr; b</p>");
// A literal ampersand is escaped.
assert_eq!(to_html("Tris & HCl"), "<p>Tris &amp; HCl</p>");
}
#[test]
fn refuses_to_pass_through_html() {
let out = to_html("<script>alert(1)</script>");
assert!(!out.contains("<script>"));
assert!(out.contains("&lt;script&gt;"));
}
#[test]
fn converts_inline_markup() {
assert_eq!(to_html("**bold**"), "<p><strong>bold</strong></p>");
assert_eq!(to_html("*em*"), "<p><em>em</em></p>");
assert_eq!(to_html("`code`"), "<p><code>code</code></p>");
assert_eq!(to_html("H#sub[2]O"), "<p>H<sub>2</sub>O</p>");
assert_eq!(to_html("x#sup[2]"), "<p>x<sup>2</sup></p>");
}
#[test]
fn bold_wins_over_italic() {
assert_eq!(
to_html("**strong** and *weak*"),
"<p><strong>strong</strong> and <em>weak</em></p>"
);
}
#[test]
fn unpaired_delimiters_are_literal() {
assert_eq!(to_html("2 * 3 = 6"), "<p>2 * 3 = 6</p>");
assert_eq!(to_html("a_b"), "<p>a_b</p>");
}
#[test]
fn splits_paragraphs_and_joins_wrapped_lines() {
let out = to_html("first line\ncontinued\n\nsecond paragraph");
assert_eq!(out, "<p>first line continued</p>\n<p>second paragraph</p>");
}
#[test]
fn empty_input_yields_empty_output() {
assert_eq!(to_html(" \n "), "");
assert_eq!(to_plain(""), "");
}
#[test]
fn plain_text_uses_unicode_and_drops_markup() {
assert_eq!(to_plain("K#sub[m] #sym.approx 5 mM"), "Km \u{2248} 5 mM");
assert_eq!(to_plain("**bold** text"), "bold text");
}
#[test]
fn typst_escapes_reference_starters() {
assert_eq!(to_typst("a @ b"), "a \\@ b");
assert_eq!(to_typst("x < y"), "x \\< y");
}
}
+173
View File
@@ -0,0 +1,173 @@
// SPDX-License-Identifier: Prosperity-3.0.0
// Copyright Scientific Computing Studio
// Source: https://git.scient.ing/education/coursebank
//! A small deterministic random number generator.
//!
//! Every random choice this tool makes must be reproducible: if you regenerate
//! form B of an exam a month later, it has to come out identically, or the
//! answer key you already printed is wrong. So there is no system entropy
//! anywhere in the crate. Seeds are explicit, and a seed can be derived from a
//! string like `"exam-4-2026s/form-B"` so the caller never has to invent one.
//!
//! The generator is SplitMix64: two lines of arithmetic, excellent statistical
//! properties for shuffling, and identical output on every platform.
/// A seeded SplitMix64 generator.
#[derive(Debug, Clone)]
pub struct Rng {
state: u64,
}
impl Rng {
/// Creates a generator from a numeric seed.
///
/// # Arguments
///
/// * `seed` - any value; every seed gives a distinct stream.
///
/// # Returns
///
/// The generator.
pub fn new(seed: u64) -> Rng {
Rng { state: seed }
}
/// Creates a generator from a label, so callers can seed on meaning.
///
/// # Arguments
///
/// * `label` - a stable string such as an assessment id plus a form id.
///
/// # Returns
///
/// The generator.
pub fn from_label(label: &str) -> Rng {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for b in label.as_bytes() {
h ^= *b as u64;
h = h.wrapping_mul(0x0000_0100_0000_01b3);
}
Rng::new(h)
}
/// The next 64 random bits.
///
/// # Returns
///
/// A uniformly distributed `u64`.
pub fn next_u64(&mut self) -> u64 {
self.state = self.state.wrapping_add(0x9e37_79b9_7f4a_7c15);
let mut z = self.state;
z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
z ^ (z >> 31)
}
/// A uniform integer in `[0, n)`.
///
/// Rejection sampling removes the modulo bias, which matters because a
/// biased shuffle would systematically favor certain answer positions.
///
/// # Arguments
///
/// * `n` - the exclusive upper bound; returns 0 when `n` is 0.
///
/// # Returns
///
/// The sampled integer.
pub fn below(&mut self, n: u64) -> u64 {
if n == 0 {
return 0;
}
let zone = u64::MAX - (u64::MAX % n) - 1;
loop {
let x = self.next_u64();
if x <= zone {
return x % n;
}
}
}
/// A uniform float in `[0, 1)`.
///
/// # Returns
///
/// The sampled float.
pub fn unit(&mut self) -> f64 {
// 53 bits of mantissa is the whole precision of f64.
(self.next_u64() >> 11) as f64 / (1u64 << 53) as f64
}
/// Shuffles a slice in place with a Fisher-Yates pass.
///
/// # Arguments
///
/// * `items` - the slice to permute.
pub fn shuffle<T>(&mut self, items: &mut [T]) {
if items.len() < 2 {
return;
}
for i in (1..items.len()).rev() {
let j = self.below(i as u64 + 1) as usize;
items.swap(i, j);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn same_seed_gives_same_stream() {
let a: Vec<u64> = (0..8).map(|_| Rng::new(42).next_u64()).collect();
assert!(a.iter().all(|x| *x == a[0]), "fresh generators agree");
let mut r1 = Rng::new(7);
let mut r2 = Rng::new(7);
for _ in 0..64 {
assert_eq!(r1.next_u64(), r2.next_u64());
}
}
#[test]
fn different_labels_diverge() {
let mut a = Rng::from_label("exam-4/form-A");
let mut b = Rng::from_label("exam-4/form-B");
assert_ne!(a.next_u64(), b.next_u64());
}
#[test]
fn shuffle_is_a_permutation_and_reproducible() {
let mut v: Vec<u32> = (0..50).collect();
let mut w = v.clone();
Rng::from_label("seed").shuffle(&mut v);
Rng::from_label("seed").shuffle(&mut w);
assert_eq!(v, w, "same label reproduces the same order");
let mut sorted = v.clone();
sorted.sort_unstable();
assert_eq!(sorted, (0..50).collect::<Vec<u32>>());
assert_ne!(v, sorted, "50 elements should not shuffle back to sorted");
}
#[test]
fn below_stays_in_range() {
let mut r = Rng::new(1);
for _ in 0..1000 {
assert!(r.below(5) < 5);
}
assert_eq!(r.below(1), 0);
assert_eq!(r.below(0), 0);
}
#[test]
fn unit_is_in_the_unit_interval() {
let mut r = Rng::new(3);
for _ in 0..1000 {
let u = r.unit();
assert!((0.0..1.0).contains(&u));
}
}
}
+228
View File
@@ -0,0 +1,228 @@
// SPDX-License-Identifier: Prosperity-3.0.0
// Copyright Scientific Computing Studio
// Source: https://git.scient.ing/education/coursebank
//! Reading and writing the YAML and JSON files the tool owns.
//!
//! Two small conveniences live here. First, every read and write attaches the
//! path to its error, because "invalid type: found string" is useless without
//! knowing which of forty bank files produced it. Second, [`flexible_string`]
//! lets `schema_version: 1.0` parse as the string `"1.0"`. YAML reads an
//! unquoted `1.0` as a float, and being told to go back and add quotation marks
//! is a poor first experience of a schema.
use std::fmt;
use std::fs;
use std::path::Path;
use serde::de::{self, DeserializeOwned, Visitor};
use serde::{Deserializer, Serialize};
use crate::error::{Error, Result};
/// Deserializes a YAML file into any schema type.
///
/// # Arguments
///
/// * `path` - the file to read.
///
/// # Returns
///
/// The deserialized value.
///
/// # Errors
///
/// Returns [`Error::Io`] when the file cannot be read and [`Error::Yaml`] when
/// it does not match the target schema.
pub fn read<T: DeserializeOwned>(path: &Path) -> Result<T> {
let text = fs::read_to_string(path).map_err(|e| Error::io(path, e))?;
serde_yaml_ng::from_str(&text).map_err(|source| Error::Yaml {
path: path.to_path_buf(),
source,
})
}
/// Serializes a value to a YAML file, creating parent directories as needed.
///
/// # Arguments
///
/// * `path` - the destination file.
/// * `value` - the value to write.
///
/// # Errors
///
/// Returns [`Error::Io`] on a write failure, or [`Error::Other`] if the value
/// cannot be represented as YAML.
pub fn write<T: Serialize>(path: &Path, value: &T) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| Error::io(parent, e))?;
}
let text = serde_yaml_ng::to_string(value).map_err(Error::other)?;
fs::write(path, text).map_err(|e| Error::io(path, e))
}
/// Deserializes a JSON file into any type.
///
/// Used only for importing legacy banks and for reading emitted schemas back in
/// tests; the tool's own files are YAML.
///
/// # Arguments
///
/// * `path` - the file to read.
///
/// # Returns
///
/// The deserialized value.
///
/// # Errors
///
/// Returns [`Error::Io`] or [`Error::Json`].
pub fn read_json<T: DeserializeOwned>(path: &Path) -> Result<T> {
let text = fs::read_to_string(path).map_err(|e| Error::io(path, e))?;
serde_json::from_str(&text).map_err(|source| Error::Json {
path: path.to_path_buf(),
source,
})
}
/// Writes a value as pretty-printed JSON.
///
/// # Arguments
///
/// * `path` - the destination file.
/// * `value` - the value to write.
///
/// # Errors
///
/// Returns [`Error::Io`] on a write failure.
pub fn write_json<T: Serialize>(path: &Path, value: &T) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| Error::io(parent, e))?;
}
let text = serde_json::to_string_pretty(value).map_err(Error::other)?;
fs::write(path, format!("{text}\n")).map_err(|e| Error::io(path, e))
}
/// Writes text to a file, creating parent directories as needed.
///
/// # Arguments
///
/// * `path` - the destination file.
/// * `text` - the contents.
///
/// # Errors
///
/// Returns [`Error::Io`] on a write failure.
pub fn write_text(path: &Path, text: &str) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| Error::io(parent, e))?;
}
fs::write(path, text).map_err(|e| Error::io(path, e))
}
/// Lists the `*.yaml` and `*.yml` files in a directory, sorted by name.
///
/// Sorting makes every downstream output deterministic, which matters when the
/// outputs are committed.
///
/// # Arguments
///
/// * `dir` - the directory to scan.
///
/// # Returns
///
/// The paths, empty when the directory does not exist.
///
/// # Errors
///
/// Returns [`Error::Io`] when the directory exists but cannot be read.
pub fn list_yaml(dir: &Path) -> Result<Vec<std::path::PathBuf>> {
if !dir.exists() {
return Ok(Vec::new());
}
let mut out = Vec::new();
for entry in fs::read_dir(dir).map_err(|e| Error::io(dir, e))? {
let entry = entry.map_err(|e| Error::io(dir, e))?;
let path = entry.path();
let is_yaml = path
.extension()
.and_then(|e| e.to_str())
.map(|e| e.eq_ignore_ascii_case("yaml") || e.eq_ignore_ascii_case("yml"))
.unwrap_or(false);
if is_yaml && path.is_file() {
out.push(path);
}
}
out.sort();
Ok(out)
}
/// Deserializes a scalar as a string, whether it was written quoted or not.
///
/// # Arguments
///
/// * `d` - the deserializer.
///
/// # Returns
///
/// The value as a string.
///
/// # Errors
///
/// Returns a deserialization error for non-scalar input.
pub fn flexible_string<'de, D>(d: D) -> std::result::Result<String, D::Error>
where
D: Deserializer<'de>,
{
struct V;
impl<'a> Visitor<'a> for V {
type Value = String;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("a version such as \"1.0\"")
}
fn visit_str<E: de::Error>(self, v: &str) -> std::result::Result<String, E> {
Ok(v.to_string())
}
fn visit_f64<E: de::Error>(self, v: f64) -> std::result::Result<String, E> {
// 1.0 must render as "1.0", not "1".
Ok(format!("{v:.1}"))
}
fn visit_u64<E: de::Error>(self, v: u64) -> std::result::Result<String, E> {
Ok(format!("{v}.0"))
}
fn visit_i64<E: de::Error>(self, v: i64) -> std::result::Result<String, E> {
Ok(format!("{v}.0"))
}
}
d.deserialize_any(V)
}
#[cfg(test)]
mod tests {
use super::*;
use serde::Deserialize;
#[derive(Deserialize)]
struct Versioned {
#[serde(deserialize_with = "flexible_string")]
v: String,
}
#[test]
fn flexible_string_accepts_quoted_and_bare_versions() {
for (src, want) in [
("v: \"1.0\"", "1.0"),
("v: 1.0", "1.0"),
("v: 2", "2.0"),
("v: \"1.10\"", "1.10"),
] {
let got: Versioned = serde_yaml_ng::from_str(src).expect(src);
assert_eq!(got.v, want, "{src}");
}
}
}
+246
View File
@@ -0,0 +1,246 @@
// SPDX-License-Identifier: Prosperity-3.0.0
// Copyright Scientific Computing Studio
// Source: https://git.scient.ing/education/coursebank
//! A minimal ZIP writer, stored (uncompressed) entries only.
//!
//! Canvas needs a `.zip` to import a QTI package, and that is the only reason
//! this crate needs ZIP at all. Writing ~120 lines of well-understood format
//! beats taking a dependency whose API has changed shape several times, and it
//! buys two things worth having: the archives are byte-for-byte reproducible,
//! because the timestamp is fixed rather than read from the clock, and a QTI
//! package diffs cleanly in a course repository.
//!
//! Entries are stored rather than deflated. A quiz package is a few tens of
//! kilobytes of XML, so compression saves nothing that matters, and the
//! Gradescope and Canvas exports in the wild are stored too.
use std::io::Write;
use std::path::Path;
use crate::error::{Error, Result};
/// CRC-32 (IEEE 802.3) of a byte slice.
///
/// # Arguments
///
/// * `data` - the bytes to checksum.
///
/// # Returns
///
/// The checksum.
fn crc32(data: &[u8]) -> u32 {
let mut table = [0u32; 256];
for (i, entry) in table.iter_mut().enumerate() {
let mut c = i as u32;
for _ in 0..8 {
c = if c & 1 != 0 {
0xEDB8_8320 ^ (c >> 1)
} else {
c >> 1
};
}
*entry = c;
}
let mut crc = 0xFFFF_FFFFu32;
for b in data {
crc = table[((crc ^ *b as u32) & 0xFF) as usize] ^ (crc >> 8);
}
crc ^ 0xFFFF_FFFF
}
/// One file to place in the archive.
struct Entry {
/// The path inside the archive, always with forward slashes.
name: String,
/// The file contents.
data: Vec<u8>,
/// CRC-32 of `data`.
crc: u32,
/// Byte offset of this entry's local header.
offset: u32,
}
/// Builds a ZIP archive in memory.
#[derive(Default)]
pub struct ZipBuilder {
entries: Vec<Entry>,
body: Vec<u8>,
}
/// The fixed DOS timestamp used for every entry: 1980-01-01 00:00:00.
///
/// A real clock value would make otherwise identical packages differ, which
/// defeats the point of committing them.
const DOS_TIME: u16 = 0;
/// The DOS date for 1980-01-01.
const DOS_DATE: u16 = 0x0021;
impl ZipBuilder {
/// Creates an empty archive.
pub fn new() -> ZipBuilder {
ZipBuilder::default()
}
/// Adds a file to the archive.
///
/// # Arguments
///
/// * `name` - the path inside the archive.
/// * `data` - the contents.
pub fn add(&mut self, name: &str, data: impl Into<Vec<u8>>) {
let data = data.into();
let crc = crc32(&data);
let offset = self.body.len() as u32;
let name = name.replace('\\', "/");
let name_bytes = name.as_bytes();
// Local file header.
self.body.extend_from_slice(&0x0403_4b50u32.to_le_bytes());
self.body.extend_from_slice(&20u16.to_le_bytes()); // version needed
self.body.extend_from_slice(&0u16.to_le_bytes()); // flags
self.body.extend_from_slice(&0u16.to_le_bytes()); // method: stored
self.body.extend_from_slice(&DOS_TIME.to_le_bytes());
self.body.extend_from_slice(&DOS_DATE.to_le_bytes());
self.body.extend_from_slice(&crc.to_le_bytes());
self.body
.extend_from_slice(&(data.len() as u32).to_le_bytes());
self.body
.extend_from_slice(&(data.len() as u32).to_le_bytes());
self.body
.extend_from_slice(&(name_bytes.len() as u16).to_le_bytes());
self.body.extend_from_slice(&0u16.to_le_bytes()); // extra field length
self.body.extend_from_slice(name_bytes);
self.body.extend_from_slice(&data);
self.entries.push(Entry {
name,
data,
crc,
offset,
});
}
/// Adds a text file to the archive.
///
/// # Arguments
///
/// * `name` - the path inside the archive.
/// * `text` - the contents.
pub fn add_text(&mut self, name: &str, text: &str) {
self.add(name, text.as_bytes().to_vec());
}
/// Serializes the archive.
///
/// # Returns
///
/// The complete ZIP file bytes.
pub fn finish(self) -> Vec<u8> {
let mut out = self.body;
let cd_offset = out.len() as u32;
for e in &self.entries {
let name = e.name.as_bytes();
out.extend_from_slice(&0x0201_4b50u32.to_le_bytes());
out.extend_from_slice(&20u16.to_le_bytes()); // version made by
out.extend_from_slice(&20u16.to_le_bytes()); // version needed
out.extend_from_slice(&0u16.to_le_bytes()); // flags
out.extend_from_slice(&0u16.to_le_bytes()); // method: stored
out.extend_from_slice(&DOS_TIME.to_le_bytes());
out.extend_from_slice(&DOS_DATE.to_le_bytes());
out.extend_from_slice(&e.crc.to_le_bytes());
out.extend_from_slice(&(e.data.len() as u32).to_le_bytes());
out.extend_from_slice(&(e.data.len() as u32).to_le_bytes());
out.extend_from_slice(&(name.len() as u16).to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes()); // extra
out.extend_from_slice(&0u16.to_le_bytes()); // comment
out.extend_from_slice(&0u16.to_le_bytes()); // disk number
out.extend_from_slice(&0u16.to_le_bytes()); // internal attrs
out.extend_from_slice(&0u32.to_le_bytes()); // external attrs
out.extend_from_slice(&e.offset.to_le_bytes());
out.extend_from_slice(name);
}
let cd_size = out.len() as u32 - cd_offset;
// End of central directory.
out.extend_from_slice(&0x0605_4b50u32.to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes()); // this disk
out.extend_from_slice(&0u16.to_le_bytes()); // disk with cd
out.extend_from_slice(&(self.entries.len() as u16).to_le_bytes());
out.extend_from_slice(&(self.entries.len() as u16).to_le_bytes());
out.extend_from_slice(&cd_size.to_le_bytes());
out.extend_from_slice(&cd_offset.to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes()); // comment length
out
}
/// Writes the archive to a file.
///
/// # Arguments
///
/// * `path` - the destination.
///
/// # Errors
///
/// Returns [`Error::Io`] on a write failure.
pub fn write_to(self, path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| Error::io(parent, e))?;
}
let bytes = self.finish();
let mut f = std::fs::File::create(path).map_err(|e| Error::io(path, e))?;
f.write_all(&bytes).map_err(|e| Error::io(path, e))?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn crc32_matches_the_known_vector() {
// The canonical check value for CRC-32/ISO-HDLC over "123456789".
assert_eq!(crc32(b"123456789"), 0xCBF4_3926);
assert_eq!(crc32(b""), 0);
}
#[test]
fn produces_a_recognizable_archive() {
let mut z = ZipBuilder::new();
z.add_text("imsmanifest.xml", "<manifest/>");
z.add_text("quiz.xml", "<questestinterop/>");
let bytes = z.finish();
assert_eq!(&bytes[0..4], b"PK\x03\x04", "starts with a local header");
// End-of-central-directory signature appears near the end.
let eocd = bytes
.windows(4)
.rposition(|w| w == b"PK\x05\x06")
.expect("has an end-of-central-directory record");
assert_eq!(
u16::from_le_bytes([bytes[eocd + 10], bytes[eocd + 11]]),
2,
"records two entries"
);
assert!(bytes.windows(15).any(|w| w == b"imsmanifest.xml"));
}
#[test]
fn output_is_byte_for_byte_reproducible() {
let build = || {
let mut z = ZipBuilder::new();
z.add_text("a.xml", "<a/>");
z.finish()
};
assert_eq!(build(), build());
}
#[test]
fn empty_archive_is_valid() {
let bytes = ZipBuilder::new().finish();
assert_eq!(&bytes[0..4], b"PK\x05\x06");
assert_eq!(bytes.len(), 22);
}
}