1031 lines
35 KiB
Rust
1031 lines
35 KiB
Rust
// SPDX-License-Identifier: Prosperity-3.0.0
|
|
// Copyright Scientific Computing Studio
|
|
// Source: https://git.scient.ing/education/coursebank
|
|
|
|
//! Rendering an assessment for a Quarto course website, with solutions gated
|
|
//! behind a per-page password.
|
|
//!
|
|
//! This is the path that publishes to the web rather than to Canvas or a printed
|
|
//! exam. It produces three things for one assessment:
|
|
//!
|
|
//! 1. A `_questions.qmd` partial: the questions as Quarto fenced divs, with an
|
|
//! empty, hidden `.qsol` slot per item. A page includes it with
|
|
//! `{{< include _questions.qmd >}}`. It carries no answers.
|
|
//! 2. A `<id>-solutions.json` bundle: every solution rendered to an HTML fragment,
|
|
//! then encrypted. The ciphertext ships in the page, but the plaintext never
|
|
//! does, so a student cannot read answers from the source or the network tab.
|
|
//! 3. A password: a fresh, random, per-bundle password that decrypts the bundle.
|
|
//! It is printed for the instructor and stored nowhere, so it cannot be
|
|
//! recovered from the files. Hand it out, and rotate it after a due date.
|
|
//!
|
|
//! The browser side is [`assets`]: `questions.css` styles the questions and the
|
|
//! unlocked solutions, and `solutions.js` derives the key from the typed password,
|
|
//! decrypts, and injects each fragment. The crypto here matches that script byte
|
|
//! for byte: PBKDF2-HMAC-SHA256 at 250,000 iterations derives a 256-bit key, and
|
|
//! AES-256-GCM encrypts each fragment under a fresh 96-bit IV with the 128-bit tag
|
|
//! appended to the ciphertext. Get any parameter wrong and a correct password
|
|
//! would fail to authenticate.
|
|
//!
|
|
//! Two rules carry over from the other exporters. A question paper never contains
|
|
//! the answer: the `.qsol` slot is empty in the qmd and the answer lives only in
|
|
//! the encrypted bundle. And option order comes from the form's seed, so the
|
|
//! printed letters in the questions and the letters the solution refers to are the
|
|
//! same order.
|
|
|
|
use crate::assessment::{AssessmentFile, Form, Placement};
|
|
use crate::catalog::Catalog;
|
|
use crate::course::{CourseFile, Reference};
|
|
use crate::error::{Error, Result};
|
|
use crate::item::{Choice, Citation, Item, Solution};
|
|
use crate::markup;
|
|
use crate::select;
|
|
use crate::taxonomy::Format;
|
|
|
|
use aes_gcm::Aes256Gcm;
|
|
use aes_gcm::aead::generic_array::GenericArray;
|
|
use aes_gcm::aead::{Aead, KeyInit};
|
|
use base64::Engine as _;
|
|
use base64::engine::general_purpose::STANDARD as B64;
|
|
use serde::ser::SerializeMap;
|
|
use serde::{Serialize, Serializer};
|
|
|
|
/// PBKDF2 iteration count. Must match `solutions.js` and `make_solutions.py`.
|
|
const ITERATIONS: u32 = 250_000;
|
|
|
|
/// Crockford base32 without the ambiguous `i`, `l`, `o`, `u`. Thirty-two symbols
|
|
/// means five bits each, so sixteen of them carry eighty bits of entropy.
|
|
const PW_ALPHABET: &[u8; 32] = b"0123456789abcdefghjkmnpqrstvwxyz";
|
|
|
|
/// The bundled browser assets, installed once per site (not per page).
|
|
///
|
|
/// # Returns
|
|
///
|
|
/// Pairs of file name and verbatim contents: the stylesheet and the unlock
|
|
/// script. Write them into the site's static directory and wire them in
|
|
/// `_quarto.yml`.
|
|
pub fn assets() -> [(&'static str, &'static str); 2] {
|
|
[
|
|
(
|
|
"questions.css",
|
|
include_str!("../assets/site/questions.css"),
|
|
),
|
|
("solutions.js", include_str!("../assets/site/solutions.js")),
|
|
]
|
|
}
|
|
|
|
/// How to render one assessment for the site.
|
|
#[derive(Debug, Clone)]
|
|
pub struct Options {
|
|
/// The form whose option order to print.
|
|
pub form: Form,
|
|
/// A password to encrypt with. `None` generates a fresh random one, which is
|
|
/// the intended path; pass `Some` only to re-encrypt a bundle with a known
|
|
/// password.
|
|
pub password: Option<String>,
|
|
}
|
|
|
|
impl Options {
|
|
/// Builds options for a form, generating the password.
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// * `form` - the form whose option order to print.
|
|
///
|
|
/// # Returns
|
|
///
|
|
/// Options that will generate a fresh password at render time.
|
|
pub fn new(form: Form) -> Options {
|
|
Options {
|
|
form,
|
|
password: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Everything one render produces, ready to write.
|
|
#[derive(Debug, Clone)]
|
|
pub struct Rendered {
|
|
/// The assessment id, used for the bundle file name and the gate's
|
|
/// `data-bundle` attribute.
|
|
pub page: String,
|
|
/// The `_questions.qmd` partial.
|
|
pub questions_qmd: String,
|
|
/// The `<id>-solutions.json` bundle, serialized and newline-terminated.
|
|
pub solutions_json: String,
|
|
/// The password that decrypts the bundle. Print it; it is stored nowhere.
|
|
pub password: String,
|
|
}
|
|
|
|
/// Renders an assessment into the questions partial and the encrypted bundle.
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// * `catalog` - the loaded course, for items, objectives, and references.
|
|
/// * `record` - the assembled assessment.
|
|
/// * `opts` - the form and an optional password.
|
|
///
|
|
/// # Returns
|
|
///
|
|
/// The partial, the serialized bundle, and the password.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns [`Error::Unresolved`] when a placement names an item the catalog does
|
|
/// not hold, and [`Error::Other`] on a CSPRNG, encryption, or serialization
|
|
/// failure.
|
|
pub fn render(catalog: &Catalog, record: &AssessmentFile, opts: Options) -> Result<Rendered> {
|
|
let page = record.assessment.id.clone();
|
|
let questions_qmd = questions_qmd(catalog, record, &opts.form, &page)?;
|
|
let fragments = solution_fragments(catalog, record, &opts.form)?;
|
|
let password = match opts.password {
|
|
Some(p) => p,
|
|
None => gen_password()?,
|
|
};
|
|
let bundle = build_bundle(&page, &password, &fragments)?;
|
|
let json = serde_json::to_string_pretty(&bundle)
|
|
.map_err(|e| Error::other(format!("could not serialize the solutions bundle: {e}")))?;
|
|
Ok(Rendered {
|
|
page,
|
|
questions_qmd,
|
|
solutions_json: format!("{json}\n"),
|
|
password,
|
|
})
|
|
}
|
|
|
|
/// Builds the `_questions.qmd` partial.
|
|
fn questions_qmd(
|
|
catalog: &Catalog,
|
|
record: &AssessmentFile,
|
|
form: &Form,
|
|
page: &str,
|
|
) -> Result<String> {
|
|
let mut out = String::new();
|
|
out.push_str(
|
|
"<!-- _questions.qmd — generated by `coursebank export site`. No YAML front\n\
|
|
\x20 matter, no title, no prose. Include from the page with:\n\
|
|
\x20 {{< include _questions.qmd >}} -->\n\n",
|
|
);
|
|
out.push_str(&format!(
|
|
"::: {{.solutions-gate data-bundle=\"{page}-solutions.json\"}}\n:::\n\n"
|
|
));
|
|
|
|
let mut number = 0usize;
|
|
let default_points = catalog.course.policy.points_per_item;
|
|
let layout = select::layout(record, form);
|
|
for placement in layout.iter().filter(|p| !p.dropped) {
|
|
let item = &catalog.require(&placement.item)?.item;
|
|
number += 1;
|
|
out.push_str(&question_block(
|
|
number,
|
|
placement,
|
|
item,
|
|
form,
|
|
default_points,
|
|
));
|
|
out.push('\n');
|
|
}
|
|
|
|
// Leave exactly one trailing newline.
|
|
while out.ends_with("\n\n") {
|
|
out.pop();
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
/// One `.q` block: head, stem, choices or a writing box, then the empty slot.
|
|
fn question_block(
|
|
number: usize,
|
|
placement: &Placement,
|
|
item: &Item,
|
|
form: &Form,
|
|
default_points: f64,
|
|
) -> String {
|
|
let id = &item.id;
|
|
let points = placement
|
|
.points
|
|
.unwrap_or_else(|| item.points(default_points));
|
|
let unit = if (points - 1.0).abs() < f64::EPSILON {
|
|
"point"
|
|
} else {
|
|
"points"
|
|
};
|
|
|
|
let mut b = String::new();
|
|
b.push_str(&format!("::: {{.q #{id}}}\n"));
|
|
b.push_str(":::: {.q-head}\n");
|
|
b.push_str(&format!(
|
|
"[Question {number}]{{.q-num}} [{}]{{.q-kind}} [{} {unit}]{{.q-points}}\n",
|
|
kind_label(item.format),
|
|
trim_number(points),
|
|
));
|
|
b.push_str("::::\n\n");
|
|
|
|
b.push_str(":::: {.q-stem}\n");
|
|
b.push_str(&markup::to_markdown(&item.stem));
|
|
b.push_str("\n::::\n\n");
|
|
|
|
if item.has_options() {
|
|
b.push_str(":::: {.q-choices}\n");
|
|
let order = select::option_order(form, &placement.item, item.options.len());
|
|
for (position, &source) in order.iter().enumerate() {
|
|
b.push_str(&format!(
|
|
"{}. {}\n",
|
|
position + 1,
|
|
markup::to_markdown(&item.options[source].text)
|
|
));
|
|
}
|
|
b.push_str("::::\n\n");
|
|
} else {
|
|
b.push_str(":::: {.q-response aria-hidden=\"true\"}\n::::\n\n");
|
|
}
|
|
|
|
b.push_str(&format!(
|
|
":::: {{.qsol data-solution-for=\"{id}\" hidden=\"true\"}}\n::::\n"
|
|
));
|
|
b.push_str(":::\n");
|
|
b
|
|
}
|
|
|
|
/// The human label for a format, shown in the question head.
|
|
fn kind_label(format: Format) -> &'static str {
|
|
match format {
|
|
Format::SingleBestAnswer => "Single best answer",
|
|
Format::MultipleResponse => "Multiple response",
|
|
Format::TrueFalse => "True or false",
|
|
Format::OpenResponse => "Open response",
|
|
}
|
|
}
|
|
|
|
// --- the solution fragments ---
|
|
|
|
/// Renders each item's solution to an HTML fragment, in printed order.
|
|
///
|
|
/// An item with nothing to show (an open-response question whose solution is
|
|
/// empty) is left out, so its slot simply never unlocks.
|
|
fn solution_fragments(
|
|
catalog: &Catalog,
|
|
record: &AssessmentFile,
|
|
form: &Form,
|
|
) -> Result<Vec<(String, String)>> {
|
|
let mut out = Vec::new();
|
|
let layout = select::layout(record, form);
|
|
for placement in layout.iter().filter(|p| !p.dropped) {
|
|
let item = &catalog.require(&placement.item)?.item;
|
|
if let Some(html) = fragment(&catalog.course, placement, item, form) {
|
|
out.push((item.id.clone(), html));
|
|
}
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
/// The fragment for one item, or `None` when there is nothing to show.
|
|
fn fragment(
|
|
course: &CourseFile,
|
|
placement: &Placement,
|
|
item: &Item,
|
|
form: &Form,
|
|
) -> Option<String> {
|
|
if item.has_options() {
|
|
Some(choice_fragment(course, placement, item, form))
|
|
} else {
|
|
open_fragment(course, item)
|
|
}
|
|
}
|
|
|
|
/// A single-best-answer or multiple-response fragment: the key, the model answer,
|
|
/// the explanation, then per-distractor feedback.
|
|
fn choice_fragment(course: &CourseFile, placement: &Placement, item: &Item, form: &Form) -> String {
|
|
let order = select::option_order(form, &placement.item, item.options.len());
|
|
let printed: Vec<(usize, &Choice)> = order
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(position, &source)| (position, &item.options[source]))
|
|
.collect();
|
|
|
|
let mut out = String::new();
|
|
|
|
let keyed: Vec<String> = printed
|
|
.iter()
|
|
.filter(|(_, c)| c.correct)
|
|
.map(|(position, c)| {
|
|
format!(
|
|
"<b>{}</b> — {}",
|
|
letter(*position),
|
|
inline_html(&c.text)
|
|
)
|
|
})
|
|
.collect();
|
|
out.push_str(
|
|
"<p class=\"sol-answer\"><span class=\"badge badge-correct\">Correct answer</span> ",
|
|
);
|
|
out.push_str(&keyed.join("; "));
|
|
out.push_str("</p>\n");
|
|
|
|
if let Some(solution) = item.solution.as_ref() {
|
|
if let Some(model) = &solution.model_answer {
|
|
out.push_str(&format!(
|
|
"<div class=\"sol-model\">{}</div>\n",
|
|
block_html(model)
|
|
));
|
|
}
|
|
if let Some(explanation) = &solution.explanation {
|
|
out.push_str(&explain_html(explanation));
|
|
}
|
|
}
|
|
|
|
let distractors: Vec<(usize, &Choice)> = printed
|
|
.iter()
|
|
.copied()
|
|
.filter(|(_, c)| !c.correct)
|
|
.collect();
|
|
if distractors
|
|
.iter()
|
|
.any(|(_, c)| c.misconception.is_some() || why_wrong(c).is_some())
|
|
{
|
|
out.push_str("<p class=\"sol-feedback-title\">Why the other options miss</p>\n");
|
|
out.push_str("<ul class=\"sol-feedback\">\n");
|
|
for (position, c) in &distractors {
|
|
if c.misconception.is_none() && why_wrong(c).is_none() {
|
|
continue;
|
|
}
|
|
out.push_str(&format!(
|
|
" <li><span class=\"opt\">{}</span>\n",
|
|
letter(*position)
|
|
));
|
|
out.push_str(" <div class=\"opt-body\">\n");
|
|
if let Some(mis) = &c.misconception {
|
|
out.push_str(&format!(
|
|
" <span class=\"opt-mis\">{}</span>\n",
|
|
inline_html(mis)
|
|
));
|
|
}
|
|
if let Some(why) = why_wrong(c) {
|
|
out.push_str(&format!(
|
|
" <span class=\"opt-why\">{}</span>\n",
|
|
inline_html(why)
|
|
));
|
|
}
|
|
out.push_str(" </div></li>\n");
|
|
}
|
|
out.push_str("</ul>\n");
|
|
}
|
|
|
|
push_reference(&mut out, course, item.solution.as_ref());
|
|
out
|
|
}
|
|
|
|
/// An open-response fragment: the model answer, the explanation, the rubric, and
|
|
/// the accepted variants.
|
|
fn open_fragment(course: &CourseFile, item: &Item) -> Option<String> {
|
|
let solution = item.solution.as_ref().filter(|s| !s.is_empty())?;
|
|
let mut out = String::new();
|
|
|
|
if let Some(model) = &solution.model_answer {
|
|
out.push_str(&format!(
|
|
"<div class=\"sol-model\">{}</div>\n",
|
|
block_html(model)
|
|
));
|
|
}
|
|
|
|
if let Some(explanation) = &solution.explanation {
|
|
out.push_str(&explain_html(explanation));
|
|
}
|
|
|
|
if !solution.rubric.is_empty() {
|
|
let caption = match solution.rubric_points() {
|
|
Some(total) => {
|
|
let unit = if (total - 1.0).abs() < f64::EPSILON {
|
|
"point"
|
|
} else {
|
|
"points"
|
|
};
|
|
format!("Rubric — {} {unit}", trim_number(total))
|
|
}
|
|
None => "Rubric".to_string(),
|
|
};
|
|
out.push_str("<table class=\"sol-rubric\">\n");
|
|
out.push_str(&format!(" <caption>{caption}</caption>\n"));
|
|
out.push_str(
|
|
" <thead><tr><th scope=\"col\">Pts</th><th scope=\"col\">Criterion</th></tr></thead>\n",
|
|
);
|
|
out.push_str(" <tbody>\n");
|
|
for criterion in &solution.rubric {
|
|
let pts = criterion.points.map(trim_number).unwrap_or_default();
|
|
out.push_str(&format!(
|
|
" <tr><td>{pts}</td><td>{}</td></tr>\n",
|
|
inline_html(&criterion.description)
|
|
));
|
|
}
|
|
out.push_str(" </tbody>\n</table>\n");
|
|
}
|
|
|
|
if !solution.accepted.is_empty() {
|
|
let joined = solution
|
|
.accepted
|
|
.iter()
|
|
.map(|a| inline_html(a))
|
|
.collect::<Vec<_>>()
|
|
.join("; ");
|
|
out.push_str(&format!(
|
|
"<p class=\"sol-accepted\"><span class=\"badge\">Also accepted</span> {joined}</p>\n"
|
|
));
|
|
}
|
|
|
|
push_reference(&mut out, course, Some(solution));
|
|
Some(out)
|
|
}
|
|
|
|
/// The student-facing reason a distractor is wrong: the instructor explanation
|
|
/// first, then any student feedback.
|
|
fn why_wrong(choice: &Choice) -> Option<&str> {
|
|
choice
|
|
.explanation
|
|
.as_deref()
|
|
.or(choice.feedback_student.as_deref())
|
|
}
|
|
|
|
/// Appends the `Source:` line when the solution carries review citations.
|
|
fn push_reference(out: &mut String, course: &CourseFile, solution: Option<&Solution>) {
|
|
let Some(solution) = solution else { return };
|
|
if solution.review.is_empty() {
|
|
return;
|
|
}
|
|
let sources = solution
|
|
.review
|
|
.iter()
|
|
.map(|c| cite_html(course, c))
|
|
.collect::<Vec<_>>()
|
|
.join("; ");
|
|
out.push_str(&format!("<p class=\"sol-ref\">Source: {sources}</p>\n"));
|
|
}
|
|
|
|
/// Renders one citation to HTML, linking it when a URL resolves.
|
|
fn cite_html(course: &CourseFile, citation: &Citation) -> String {
|
|
if let Some(text) = &citation.text {
|
|
if citation.reference.is_none() {
|
|
return markup::escape_html(text);
|
|
}
|
|
}
|
|
let Some(key) = &citation.reference else {
|
|
return markup::escape_html(&citation.display());
|
|
};
|
|
let Some(reference) = course.references.get(key) else {
|
|
return markup::escape_html(&citation.display());
|
|
};
|
|
let label = reference.label.as_deref().unwrap_or(key);
|
|
let locator = citation.locator.as_deref().unwrap_or("");
|
|
let body = if locator.is_empty() {
|
|
markup::escape_html(label)
|
|
} else {
|
|
format!(
|
|
"{} {}",
|
|
markup::escape_html(label),
|
|
markup::escape_html(locator)
|
|
)
|
|
};
|
|
match resolve_url(citation, reference) {
|
|
Some(url) => format!("<a href=\"{}\">{body}</a>", markup::escape_html(&url)),
|
|
None => body,
|
|
}
|
|
}
|
|
|
|
/// Resolves a citation's link, from an explicit URL or a path joined to the
|
|
/// reference's base URL.
|
|
fn resolve_url(citation: &Citation, reference: &Reference) -> Option<String> {
|
|
if let Some(url) = &citation.url {
|
|
return Some(url.clone());
|
|
}
|
|
let path = citation.path.as_deref()?;
|
|
let base = reference.base_url.as_deref()?;
|
|
Some(match (base.ends_with('/'), path.starts_with('/')) {
|
|
(true, true) => format!("{base}{}", &path[1..]),
|
|
(false, false) => format!("{base}/{path}"),
|
|
_ => format!("{base}{path}"),
|
|
})
|
|
}
|
|
|
|
// --- math-aware markup ---
|
|
|
|
/// One run of source text, split on math delimiters.
|
|
enum Segment<'a> {
|
|
/// Prose, formatted with the shared escape, symbol, and inline rules.
|
|
Text(&'a str),
|
|
/// A math span, passed through with its interior escaped for the browser.
|
|
Math { inner: &'a str, display: bool },
|
|
}
|
|
|
|
/// Splits source into prose and `$…$` or `$$…$$` math runs.
|
|
///
|
|
/// The split runs on raw source so a formatting rule can never reach inside math:
|
|
/// an underscore in `$q_p$` is a subscript, not the start of an emphasis span.
|
|
fn split_math(src: &str) -> Vec<Segment<'_>> {
|
|
let mut segments = Vec::new();
|
|
let mut rest = src;
|
|
while let Some(at) = rest.find('$') {
|
|
if at > 0 {
|
|
segments.push(Segment::Text(&rest[..at]));
|
|
}
|
|
let after = &rest[at..];
|
|
if let Some(display) = after.strip_prefix("$$") {
|
|
if let Some(end) = display.find("$$") {
|
|
segments.push(Segment::Math {
|
|
inner: &display[..end],
|
|
display: true,
|
|
});
|
|
rest = &display[end + 2..];
|
|
continue;
|
|
}
|
|
}
|
|
let inline = &after[1..];
|
|
match inline.find('$') {
|
|
Some(end) => {
|
|
segments.push(Segment::Math {
|
|
inner: &inline[..end],
|
|
display: false,
|
|
});
|
|
rest = &inline[end + 1..];
|
|
}
|
|
None => {
|
|
// An unterminated `$` is treated as ordinary text.
|
|
segments.push(Segment::Text(after));
|
|
rest = "";
|
|
}
|
|
}
|
|
}
|
|
if !rest.is_empty() {
|
|
segments.push(Segment::Text(rest));
|
|
}
|
|
segments
|
|
}
|
|
|
|
/// Formats a prose run: HTML-escape, then the symbol table and inline markup.
|
|
fn format_prose(text: &str) -> String {
|
|
markup::apply_inline(&markup::apply_symbols(&markup::escape_html(text), true))
|
|
}
|
|
|
|
/// Emits a math run with its delimiters, escaping the interior so the browser
|
|
/// hands MathJax clean text (a `<` inside math becomes `<`, which the DOM
|
|
/// decodes back before MathJax reads it).
|
|
fn render_math(inner: &str, display: bool) -> String {
|
|
let delim = if display { "$$" } else { "$" };
|
|
format!("{delim}{}{delim}", markup::escape_html(inner))
|
|
}
|
|
|
|
/// Converts authoring markup to an inline HTML string, preserving math.
|
|
///
|
|
/// No paragraph wrapping: the caller supplies the surrounding element.
|
|
fn inline_html(src: &str) -> String {
|
|
let mut out = String::new();
|
|
for segment in split_math(src.trim()) {
|
|
match segment {
|
|
Segment::Text(text) => out.push_str(&format_prose(text)),
|
|
Segment::Math { inner, display } => out.push_str(&render_math(inner, display)),
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Like [`inline_html`], but wraps each blank-line-separated paragraph in `<p>`.
|
|
fn block_html(src: &str) -> String {
|
|
src.split("\n\n")
|
|
.map(str::trim)
|
|
.filter(|p| !p.is_empty())
|
|
.map(|p| format!("<p>{}</p>", inline_html(p)))
|
|
.collect::<Vec<_>>()
|
|
.join("")
|
|
}
|
|
|
|
/// Renders a solution explanation as one `<p class="sol-explain">` per blank-line-
|
|
/// separated paragraph. A single-paragraph explanation emits exactly one such
|
|
/// paragraph, unchanged from before; a multi-paragraph one keeps its breaks, and
|
|
/// every paragraph carries the class `questions.css` already styles.
|
|
fn explain_html(src: &str) -> String {
|
|
src.split("\n\n")
|
|
.map(str::trim)
|
|
.filter(|p| !p.is_empty())
|
|
.map(|p| format!("<p class=\"sol-explain\">{}</p>\n", inline_html(p)))
|
|
.collect()
|
|
}
|
|
|
|
// --- the encrypted bundle -----
|
|
|
|
/// The encrypted solutions bundle, matching the `solutions.js` v1 format.
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct Bundle {
|
|
v: u8,
|
|
page: String,
|
|
kdf: Kdf,
|
|
cipher: &'static str,
|
|
items: OrderedItems,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
struct Kdf {
|
|
name: &'static str,
|
|
hash: &'static str,
|
|
iterations: u32,
|
|
salt: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
struct Enc {
|
|
iv: String,
|
|
ct: String,
|
|
}
|
|
|
|
/// Item entries serialized as a JSON object in insertion order, so the bundle
|
|
/// lists solutions in the order the questions appear rather than sorted by id.
|
|
#[derive(Debug, Clone)]
|
|
struct OrderedItems(Vec<(String, Enc)>);
|
|
|
|
impl Serialize for OrderedItems {
|
|
fn serialize<S: Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
|
|
let mut map = serializer.serialize_map(Some(self.0.len()))?;
|
|
for (id, enc) in &self.0 {
|
|
map.serialize_entry(id, enc)?;
|
|
}
|
|
map.end()
|
|
}
|
|
}
|
|
|
|
/// Generates a random per-bundle password.
|
|
///
|
|
/// Sixteen Crockford base32 symbols, grouped in fours, for eighty bits of entropy
|
|
/// from the system CSPRNG, for example `k7m4-9p2q-r8tx-3wn6`.
|
|
///
|
|
/// # Returns
|
|
///
|
|
/// The password, to print for the instructor.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns [`Error::Other`] if the system CSPRNG is unavailable.
|
|
pub fn gen_password() -> Result<String> {
|
|
let mut raw = [0u8; 16];
|
|
csprng(&mut raw)?;
|
|
// Thirty-two divides 256, so the modulo is unbiased.
|
|
let symbols: Vec<u8> = raw
|
|
.iter()
|
|
.map(|b| PW_ALPHABET[(*b % 32) as usize])
|
|
.collect();
|
|
let groups: Vec<String> = symbols
|
|
.chunks(4)
|
|
.map(|c| String::from_utf8_lossy(c).into_owned())
|
|
.collect();
|
|
Ok(groups.join("-"))
|
|
}
|
|
|
|
/// Encrypts rendered fragments into a bundle.
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// * `page` - the assessment id, stored as `page` and echoed by the script.
|
|
/// * `password` - the password to derive the key from.
|
|
/// * `fragments` - item id and solution HTML, in the order to list them.
|
|
///
|
|
/// # Returns
|
|
///
|
|
/// The bundle, ready to serialize as `<page>-solutions.json`.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns [`Error::Other`] on a CSPRNG or encryption failure.
|
|
pub fn build_bundle(page: &str, password: &str, fragments: &[(String, String)]) -> Result<Bundle> {
|
|
let mut salt = [0u8; 16];
|
|
csprng(&mut salt)?;
|
|
let key = derive_key(password, &salt);
|
|
let cipher = Aes256Gcm::new_from_slice(&key)
|
|
.map_err(|_| Error::other("the derived AES key was the wrong length"))?;
|
|
|
|
let mut items = Vec::with_capacity(fragments.len());
|
|
for (id, html) in fragments {
|
|
let mut iv = [0u8; 12];
|
|
csprng(&mut iv)?;
|
|
let nonce = GenericArray::from_slice(&iv);
|
|
let ct = cipher
|
|
.encrypt(nonce, html.as_bytes())
|
|
.map_err(|_| Error::other("AES-GCM encryption failed"))?;
|
|
items.push((
|
|
id.clone(),
|
|
Enc {
|
|
iv: B64.encode(iv),
|
|
ct: B64.encode(ct),
|
|
},
|
|
));
|
|
}
|
|
|
|
Ok(Bundle {
|
|
v: 1,
|
|
page: page.to_string(),
|
|
kdf: Kdf {
|
|
name: "PBKDF2",
|
|
hash: "SHA-256",
|
|
iterations: ITERATIONS,
|
|
salt: B64.encode(salt),
|
|
},
|
|
cipher: "AES-GCM",
|
|
items: OrderedItems(items),
|
|
})
|
|
}
|
|
|
|
/// Derives the AES-256 key with PBKDF2-HMAC-SHA256.
|
|
fn derive_key(password: &str, salt: &[u8]) -> [u8; 32] {
|
|
let mut key = [0u8; 32];
|
|
pbkdf2::pbkdf2_hmac::<sha2::Sha256>(password.as_bytes(), salt, ITERATIONS, &mut key);
|
|
key
|
|
}
|
|
|
|
/// Fills a buffer with cryptographically secure random bytes.
|
|
fn csprng(buf: &mut [u8]) -> Result<()> {
|
|
getrandom::getrandom(buf).map_err(|e| Error::other(format!("system CSPRNG unavailable: {e}")))
|
|
}
|
|
|
|
// --- small helpers -------
|
|
|
|
/// Formats a point value: no decimal when whole, at most two places otherwise.
|
|
fn trim_number(value: f64) -> String {
|
|
if value.fract() == 0.0 {
|
|
format!("{}", value as i64)
|
|
} else {
|
|
let s = format!("{value:.2}");
|
|
s.trim_end_matches('0').trim_end_matches('.').to_string()
|
|
}
|
|
}
|
|
|
|
/// The printed letter for a position: 0 is A, 1 is B, and so on.
|
|
fn letter(position: usize) -> char {
|
|
char::from(b'A' + (position % 26) as u8)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn form() -> Form {
|
|
Form {
|
|
id: "A".into(),
|
|
seed: 0,
|
|
shuffle_items: false,
|
|
shuffle_options: false,
|
|
}
|
|
}
|
|
|
|
fn catalog(tag: &str) -> Catalog {
|
|
let dir = std::env::temp_dir().join(format!("cb-site-{tag}-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
std::fs::create_dir_all(dir.join("banks")).unwrap();
|
|
std::fs::write(
|
|
dir.join("course.yaml"),
|
|
r#"
|
|
course: { code: BIOSC 1000, title: Biochemistry, term: 2026f }
|
|
references:
|
|
kkw:
|
|
label: KKW
|
|
title: The molecules of life
|
|
base_url: https://example.org/kkw/
|
|
lectures:
|
|
L1.1: { title: Enthalpy }
|
|
learning_objectives:
|
|
lo-enthalpy:
|
|
text: Define enthalpy and explain the constant-pressure result.
|
|
lectures: [L1.1]
|
|
order: 1
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
std::fs::write(
|
|
dir.join("banks").join("b.yaml"),
|
|
r#"
|
|
bank: { id: b, title: Bank }
|
|
items:
|
|
- id: q-mcq
|
|
status: draft
|
|
level: 2
|
|
format: single_best_answer
|
|
stem: "The heat at constant pressure equals a change in what?"
|
|
learning_targets: [lo-enthalpy]
|
|
options:
|
|
- { id: A, text: "Enthalpy, $\\Delta H$", correct: true, feedback_student: "Right, $q_p = \\Delta H$." }
|
|
- { id: B, text: "Internal energy, $\\Delta U$", misconception: "Uses the constant-volume result.", explanation: "That holds only at constant volume." }
|
|
solution:
|
|
model_answer: "The change in enthalpy, $\\Delta H$."
|
|
review:
|
|
- { ref: kkw, locator: "§6.3" }
|
|
- id: q-open
|
|
status: draft
|
|
level: 3
|
|
format: open_response
|
|
stem: "Show why $q_p = \\Delta H$."
|
|
learning_targets: [lo-enthalpy]
|
|
solution:
|
|
model_answer: "From $H = U + PV$ at constant pressure, $q_p = \\Delta H$."
|
|
explanation: |
|
|
At constant pressure the pressure-volume work is folded into H, so the heat equals the change in H.
|
|
|
|
That is why a calorimeter run at constant pressure reads the enthalpy change directly.
|
|
rubric:
|
|
- { description: "States $H = U + PV$.", points: 1 }
|
|
- { description: "Reaches $q_p = \\Delta H$.", points: 1 }
|
|
accepted: ["$q_p = \\Delta H$ via $H = U + PV$"]
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
Catalog::load(&dir).expect("catalog loads")
|
|
}
|
|
|
|
fn record() -> AssessmentFile {
|
|
use crate::assessment::{Assessment, Kind, Platform};
|
|
AssessmentFile {
|
|
schema_version: "1.0".into(),
|
|
assessment: Assessment {
|
|
id: "a1.1".into(),
|
|
title: "Homework 1".into(),
|
|
term: None,
|
|
kind: Kind::Homework,
|
|
date: None,
|
|
platform: Platform::Other,
|
|
minutes_allowed: None,
|
|
attempts: None,
|
|
shuffle: None,
|
|
scoring_policy: None,
|
|
instructions: None,
|
|
notes: None,
|
|
},
|
|
blueprint: None,
|
|
forms: Vec::new(),
|
|
items: vec![
|
|
Placement {
|
|
number: 1,
|
|
item: "b::q-mcq".into(),
|
|
version: None,
|
|
fingerprint: None,
|
|
points: Some(1.0),
|
|
bonus: false,
|
|
key: vec!["A".into()],
|
|
level: None,
|
|
learning_targets: Vec::new(),
|
|
credit_overrides: Default::default(),
|
|
dropped: false,
|
|
dropped_as: None,
|
|
},
|
|
Placement {
|
|
number: 2,
|
|
item: "b::q-open".into(),
|
|
version: None,
|
|
fingerprint: None,
|
|
points: Some(2.0),
|
|
bonus: false,
|
|
key: Vec::new(),
|
|
level: None,
|
|
learning_targets: Vec::new(),
|
|
credit_overrides: Default::default(),
|
|
dropped: false,
|
|
dropped_as: None,
|
|
},
|
|
],
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn questions_partial_has_no_front_matter_and_no_answers() {
|
|
let out = questions_qmd(&catalog("qmd"), &record(), &form(), "a1.1").unwrap();
|
|
assert!(!out.starts_with("---"), "a partial carries no front matter");
|
|
assert!(out.contains("::: {.solutions-gate data-bundle=\"a1.1-solutions.json\"}"));
|
|
assert!(out.contains("::: {.q #q-mcq}"));
|
|
assert!(
|
|
out.contains("[Question 1]{.q-num} [Single best answer]{.q-kind} [1 point]{.q-points}")
|
|
);
|
|
// Choices are printed without letters; the correct flag never appears.
|
|
assert!(out.contains("1. Enthalpy, $\\Delta H$"));
|
|
assert!(!out.contains("correct"));
|
|
// The open-response question gets a writing box, both get an empty slot.
|
|
assert!(out.contains(":::: {.q-response aria-hidden=\"true\"}"));
|
|
assert!(out.contains(":::: {.qsol data-solution-for=\"q-mcq\" hidden=\"true\"}"));
|
|
assert!(out.contains("[2 points]{.q-points}"));
|
|
// No model answer leaks into the questions.
|
|
assert!(!out.contains("sol-model"));
|
|
}
|
|
|
|
#[test]
|
|
fn a_choice_fragment_marks_the_key_and_explains_the_distractor() {
|
|
let cat = catalog("choice");
|
|
let rec = record();
|
|
let frags = solution_fragments(&cat, &rec, &form()).unwrap();
|
|
let mcq = &frags.iter().find(|(id, _)| id == "q-mcq").unwrap().1;
|
|
assert!(mcq.contains("<span class=\"badge badge-correct\">Correct answer</span>"));
|
|
assert!(mcq.contains("<b>A</b> — Enthalpy, $\\Delta H$"));
|
|
assert!(mcq.contains(
|
|
"<div class=\"sol-model\"><p>The change in enthalpy, $\\Delta H$.</p></div>"
|
|
));
|
|
assert!(mcq.contains("<span class=\"opt\">B</span>"));
|
|
assert!(mcq.contains("<span class=\"opt-mis\">Uses the constant-volume result.</span>"));
|
|
assert!(mcq.contains("<span class=\"opt-why\">That holds only at constant volume.</span>"));
|
|
assert!(mcq.contains("<p class=\"sol-ref\">Source: KKW §6.3</p>"));
|
|
}
|
|
|
|
#[test]
|
|
fn an_open_fragment_has_a_rubric_table_with_a_total() {
|
|
let cat = catalog("open");
|
|
let rec = record();
|
|
let frags = solution_fragments(&cat, &rec, &form()).unwrap();
|
|
let open = &frags.iter().find(|(id, _)| id == "q-open").unwrap().1;
|
|
assert!(open.contains("<caption>Rubric — 2 points</caption>"));
|
|
assert!(open.contains("<td>1</td><td>States $H = U + PV$.</td>"));
|
|
assert!(open.contains("<span class=\"badge\">Also accepted</span>"));
|
|
}
|
|
|
|
#[test]
|
|
fn an_open_fragment_shows_the_model_answer_and_the_explanation() {
|
|
// Both fields render, in that order, so an open-response solution reads as
|
|
// the answer followed by the reasoning, the same as a choice fragment. A
|
|
// multi-paragraph explanation keeps its breaks as separate paragraphs.
|
|
let cat = catalog("open-explain");
|
|
let rec = record();
|
|
let frags = solution_fragments(&cat, &rec, &form()).unwrap();
|
|
let open = &frags.iter().find(|(id, _)| id == "q-open").unwrap().1;
|
|
assert!(
|
|
open.contains("<div class=\"sol-model\">"),
|
|
"model answer shown:\n{open}"
|
|
);
|
|
assert_eq!(
|
|
open.matches("<p class=\"sol-explain\">").count(),
|
|
2,
|
|
"each explanation paragraph is its own styled <p>:\n{open}"
|
|
);
|
|
assert!(
|
|
open.contains("folded into H"),
|
|
"first paragraph present:\n{open}"
|
|
);
|
|
assert!(
|
|
open.contains("reads the enthalpy change directly"),
|
|
"second paragraph present:\n{open}"
|
|
);
|
|
let model_at = open.find("sol-model").unwrap();
|
|
let explain_at = open.find("sol-explain").unwrap();
|
|
assert!(
|
|
model_at < explain_at,
|
|
"model answer comes before the explanation"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn inline_html_keeps_math_verbatim_and_escapes_prose() {
|
|
// A subscript inside math survives; angle brackets outside math are escaped.
|
|
assert_eq!(inline_html("value $q_p$ < 5"), "value $q_p$ < 5");
|
|
// Bold outside math becomes a tag; a dollar-math run is passed through.
|
|
assert_eq!(
|
|
inline_html("**H** is $H = U + PV$"),
|
|
"<strong>H</strong> is $H = U + PV$"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_bundle_round_trips_through_the_kdf_and_cipher() {
|
|
use aes_gcm::aead::generic_array::GenericArray;
|
|
let fragments = vec![
|
|
("q-mcq".to_string(), "<p>alpha</p>".to_string()),
|
|
("q-open".to_string(), "<p>beta</p>".to_string()),
|
|
];
|
|
let bundle = build_bundle("a1.1", "enthalpy2026", &fragments).unwrap();
|
|
assert_eq!(bundle.v, 1);
|
|
assert_eq!(bundle.page, "a1.1");
|
|
assert_eq!(bundle.cipher, "AES-GCM");
|
|
assert_eq!(bundle.kdf.iterations, ITERATIONS);
|
|
// Insertion order is preserved in the serialized object.
|
|
let json = serde_json::to_string(&bundle).unwrap();
|
|
assert!(json.find("q-mcq").unwrap() < json.find("q-open").unwrap());
|
|
|
|
// Decrypt the first item the way the browser would and check the plaintext.
|
|
let salt = B64.decode(&bundle.kdf.salt).unwrap();
|
|
let key = derive_key("enthalpy2026", &salt);
|
|
let cipher = Aes256Gcm::new_from_slice(&key).unwrap();
|
|
let (_, enc) = &bundle.items.0[0];
|
|
let iv = B64.decode(&enc.iv).unwrap();
|
|
let ct = B64.decode(&enc.ct).unwrap();
|
|
let pt = cipher
|
|
.decrypt(GenericArray::from_slice(&iv), ct.as_ref())
|
|
.unwrap();
|
|
assert_eq!(String::from_utf8(pt).unwrap(), "<p>alpha</p>");
|
|
}
|
|
|
|
#[test]
|
|
fn a_generated_password_has_the_expected_shape() {
|
|
let pw = gen_password().unwrap();
|
|
assert_eq!(pw.len(), 19); // 16 symbols + 3 dashes
|
|
assert_eq!(pw.matches('-').count(), 3);
|
|
assert!(
|
|
pw.chars()
|
|
.all(|c| c == '-' || PW_ALPHABET.contains(&(c as u8)))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_two_browser_assets_are_bundled() {
|
|
let assets = assets();
|
|
assert_eq!(assets[0].0, "questions.css");
|
|
assert_eq!(assets[1].0, "solutions.js");
|
|
assert!(assets[0].1.contains(".qsol"));
|
|
assert!(assets[1].1.contains("AES-GCM"));
|
|
}
|
|
}
|