donation item, shop soft open
All checks were successful
Deploy / build-deploy (push) Successful in 4m11s

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jorijn van der Graaf 2026-08-17 11:04:03 +02:00
commit abbd616b40
23 changed files with 2898 additions and 209 deletions

View file

@ -27,6 +27,27 @@ void Check(bool ok, std::string_view what, std::string_view got = {}) {
got.empty() ? "" : " got: ", got);
}
// The euro amount on the table row starting with `prefix`, in minor units, or
// -1 when the row is missing. Reads the rendered document, not the builder's
// internals: FormatEuro prints "€938.21", or "€580" when the cents are zero.
std::int64_t RowMinor(const std::string& md, std::string_view prefix) {
const std::size_t at = md.find(prefix);
if (at == std::string::npos) return -1;
std::size_t i = at + prefix.size();
std::int64_t euros = 0;
bool any = false;
for (; i < md.size() && md[i] >= '0' && md[i] <= '9'; ++i) {
euros = euros * 10 + (md[i] - '0');
any = true;
}
if (!any) return -1;
std::int64_t cents = 0;
if (i + 2 < md.size() && md[i] == '.') {
cents = (md[i + 1] - '0') * 10 + (md[i + 2] - '0');
}
return euros * 100 + cents;
}
} // namespace
int main() {
@ -65,6 +86,77 @@ int main() {
Check(eu.find("€1135.23") != std::string::npos, "invoice: EU total");
Check(eu.find("zero-rated") == std::string::npos, "invoice: EU is not an export");
// Every cent of the amounts table, pinned. This is the document a Dutch
// buyer, an accountant and the Belastingdienst read, so a rounding change
// in Money::NetFromGross must break a test rather than ship a wrong VAT
// figure. Derived by hand from net = (gross*10000 + 6050) / 12100:
// goods 112660 -> (1'126'600'000 + 6050) / 12100 = 93107 -> €931.07
// total 113523 -> (1'135'230'000 + 6050) / 12100 = 93821 -> €938.21
// VAT = 113523 - 93821 = 19702 -> €197.02
// shipping = 93821 - 93107 = 714 -> €7.14
// The shipping line is the REMAINDER of the subtotal, not a rounding of
// its own — that is what makes the columns add up. Rounded independently
// it would print €7.13 ((8'630'000 + 6050) / 12100 = 713) and sit a cent
// below the subtotal, which is why the remainder rule exists: shipping
// absorbs the cent so a signed tax document cannot disagree with itself.
Check(eu.find("| Fairphone 6 — Forest Green | 2 | €931.07 |\n") != std::string::npos,
"invoice: EU item line is net, not the gross the buyer paid");
Check(eu.find("| Shipping | 1 | €7.14 |\n") != std::string::npos,
"invoice: EU shipping line is the subtotal remainder");
Check(eu.find("| Subtotal (ex VAT) | | €938.21 |\n") != std::string::npos,
"invoice: EU subtotal is the net of the gross total");
Check(eu.find("| VAT 21% (NL) | | €197.02 |\n") != std::string::npos,
"invoice: EU VAT line is the amount actually remitted");
Check(eu.find("| **Total (incl. VAT)** | | **€1135.23** |\n") != std::string::npos,
"invoice: EU gross total is what was charged");
// The property the pinned cents above are one instance of, swept across
// the realistic price grid: the three retail prices × every quantity a
// parcel can carry × the range a shipping rate lives in. Before the
// remainder rule, roughly a quarter of these combinations printed lines
// one cent apart from their own subtotal (three independent half-up
// roundings; two errors uniform on [-½,½) cross a boundary with
// probability ¼). Rendered and re-parsed rather than recomputed, so what
// is being held is the document itself:
// item + shipping == subtotal (the remainder rule, by construction)
// subtotal + VAT == total (what the buyer paid, to the cent)
// |shipping - NetFromGross(shipping gross)| <= 1 (the cent stops here)
{
Server::OrderRecord s = o;
std::string broke;
for (const std::int64_t unit : { 57380, 57980, 66538 }) {
for (std::int64_t qty = 1; qty <= 28; ++qty) {
for (std::int64_t ship = 400; ship <= 6000; ship += 97) {
s.quantity = qty;
s.unitMinor = unit;
s.goodsMinor = unit * qty;
s.shippingMinor = ship;
s.totalMinor = s.goodsMinor + ship;
const std::string md = Server::BuildInvoiceMarkdown(s, "P", "");
const std::int64_t item =
RowMinor(md, std::format("| P | {} | €", qty));
const std::int64_t shipping = RowMinor(md, "| Shipping | 1 | €");
const std::int64_t sub = RowMinor(md, "| Subtotal (ex VAT) | | €");
const std::int64_t vat = RowMinor(md, "| VAT 21% (NL) | | €");
const std::int64_t total =
RowMinor(md, "| **Total (incl. VAT)** | | **€");
const bool ok = item >= 0 && shipping >= 0 && sub >= 0
&& vat >= 0 && total == s.totalMinor
&& item + shipping == sub
&& sub + vat == total
&& shipping - Money::NetFromGross(ship) <= 1
&& Money::NetFromGross(ship) - shipping <= 1;
if (!ok && broke.empty()) {
broke = std::format("unit {} qty {} ship {}: {} + {} vs {}",
unit, qty, ship, item, shipping, sub);
}
}
}
}
Check(broke.empty(),
"invoice: EU columns add up across the whole price grid", broke);
}
o.vatIncluded = false;
o.buyer.country = "GB";
o.goodsMinor = 93107;
@ -75,6 +167,18 @@ int main() {
Check(ex.find("art. 146") != std::string::npos, "invoice: export legal basis");
Check(ex.find("€955.02") != std::string::npos, "invoice: export total");
// The mirror image of the EU table: a zero-rated export carries no VAT to
// strip, so every line is the gross that was charged and NetFromGross must
// never touch it. 93107 stays €931.07 (netting it again would print
// €769.48) and 2395 stays €23.95 (€19.79 netted) — the two branches
// swapping their treatment is exactly the accident these pin down.
Check(ex.find("| Fairphone 6 — Forest Green | 2 | €931.07 |\n") != std::string::npos,
"invoice: export item line stays gross");
Check(ex.find("| Shipping | 1 | €23.95 |\n") != std::string::npos,
"invoice: export shipping line stays gross");
Check(ex.find("| **Total** | | **€955.02** |\n") != std::string::npos,
"invoice: export total carries no VAT label");
// ── the order confirmation email ──────────────────────────────────
// Same order, EU shape again; the attachment stands in for the
// clearsigned invoice — the builder must carry it verbatim.
@ -141,6 +245,88 @@ int main() {
o, "F", "", "x", "u", "S", "D").empty(),
"email: header-injecting address yields no message");
// The bare newline is only the loudest of the shapes that would widen the
// envelope. Under `msmtp -t` the To: header IS the recipient list, so
// every address Form::LooksLikeEmail rejects must yield NO message —
// a comma is the cheapest extra-recipient smuggle of the lot, and it is
// barred only because that shared form validator happens to bar it.
// Pinning the coupling here means a future loosening of LooksLikeEmail
// (a legitimate-looking change to a form helper) cannot quietly re-open
// the envelope, and each of these carries a buyer's name and address.
// "…, evil@…" comma, plus a second '@'
// "…> , <evil@…" angle brackets, comma, second '@'
// "…\rBcc: …" bare CR — a header break on its own under CRLF
// "a@b" no dot in the domain
// "" empty, below the minimum length
for (const std::string_view addr : { "a@b.example, evil@x.example",
"a@b.example> , <evil@x.example",
"a@b.example\rBcc: x@y.example",
"a@b",
"" }) {
o.buyer.email = std::string(addr);
Check(Server::BuildOrderConfirmationEmail(
o, "F", "", "x", "u", "S", "D").empty(),
"email: address the envelope check rejects yields no message", addr);
}
// ── the GPG key id alphabet ───────────────────────────────────────
// gGpgKeyId is interpolated straight into a std::system() command line
// between single quotes, so a single accepted quote character is remote
// code execution as the shop user. The alphabet check in
// ConfigureInvoicing is the entire defence. None of this reaches gpg:
// a refused id leaves signing unconfigured, which is what we assert.
Check(!Server::InvoiceSigningConfigured(), "invoice: signing starts unconfigured");
for (const std::string_view bad : { "abc'; touch /tmp/pwned; '",
"0xDEADBEEF BEEF",
"0xDEADBEEF`id`",
"0xDEADBEEF$(id)",
"0xDEADBEEF\nBEEF" }) {
Server::ConfigureInvoicing(std::string(bad));
Check(!Server::InvoiceSigningConfigured(),
"invoice: key id outside the safe alphabet is refused", bad);
}
// The other half of the same contract, which the caller leans on: with no
// signer installed the answer is refusal, never the plaintext. Returning
// the markdown here would serve an UNSIGNED invoice through the path that
// promises a signed one — and the caller cannot tell the difference.
Check(!Server::ClearsignInvoice("# x").has_value(),
"invoice: unconfigured signing yields nullopt, not the plaintext");
// What a fingerprint or a uid email actually needs: alnum plus @ . _ - +.
Server::ConfigureInvoicing("0xDEADBEEF@catcrafts.net");
Check(Server::InvoiceSigningConfigured(),
"invoice: a key id inside the safe alphabet is accepted");
// Put the process back the way we found it — nothing after this line
// should be able to shell out to gpg.
Server::ConfigureInvoicing("");
Check(!Server::InvoiceSigningConfigured(),
"invoice: an empty key id means no signing");
// ── MAIL_FROM is a header, and is guarded like one ────────────────
// MAIL_FROM is written verbatim into the From: header of a message
// delivered with `msmtp -t`, where the headers ARE the envelope: one
// smuggled newline adds a recipient to EVERY order confirmation, and each
// of those carries the buyer's name and full postal address.
Check(!Server::MailConfigured(), "mail: starts unconfigured");
Check(Server::MailFrom().empty(), "mail: no From before configuration");
Server::ConfigureMail(Server::MailConfig{
"true", "Catcrafts <info@catcrafts.net>\nBcc: leak@evil.example" });
// Refused WHOLE, not sanitised: the guard returns before gMail is
// assigned, so the command does not install either. A half-applied config
// would be the dangerous outcome — a mailer that runs with a bad From.
Check(!Server::MailConfigured(),
"mail: a From with a line break rejects the whole config");
Check(Server::MailFrom().empty(),
"mail: a rejected From is never installed", Server::MailFrom());
// kSellerName + kSellerSite, so an operator who sets MAIL_COMMAND and
// forgets MAIL_FROM still sends from an address that exists.
Server::ConfigureMail(Server::MailConfig{ "true", "" });
Check(Server::MailConfigured(), "mail: a clean config installs the command");
Check(Server::MailFrom() == "Catcrafts <info@catcrafts.net>",
"mail: empty MAIL_FROM defaults to the shop inbox", Server::MailFrom());
if (failures != 0) {
std::println(std::cerr, "{} check(s) failed", failures);
return 1;