use crate::decide::{
b64, now_secs, page, param, parse_signing_key, quoted, urlencode, verifying_key_at,
};
use crate::file_root;
use base64::Engine;
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use ikigai_core::{
ActionSpec, ArgRef, ArgSpec, Description, Endpoint, Error, Invocation, Iri, ReprType,
Representation, Request, Result, Verb,
};
use ikigai_intake::html_escape;
pub const CAP_CONTACTBLOCK_MINT: &str = "urn:cap:contactblock:mint";
pub const CONTACT_BLOCKS_SPACE: &str = "urn:space:contact-blocks";
const TTL_SECONDS: i64 = 7 * 24 * 3600;
pub const LABELS: [&str; 2] = ["spam", "ham"];
pub const MAX_ENQUIRY_BYTES: usize = 256 * 1024;
const BLOCK_ACTION: &str = "contact-block";
const LABEL_ACTION: &str = "contact-label";
pub fn private_key_path() -> std::path::PathBuf {
file_root().join("contact-block.key")
}
pub fn public_key_path() -> std::path::PathBuf {
file_root().join("contact-block.pub")
}
fn link_base() -> String {
std::env::var("IKIGAI_CONTACTBLOCK_BASE")
.unwrap_or_else(|_| "https://ikigai-rs.dev/contact-block".to_string())
}
fn label_link_base() -> String {
std::env::var("IKIGAI_CONTACTLABEL_BASE")
.unwrap_or_else(|_| "https://ikigai-rs.dev/contact-label".to_string())
}
fn block_payload(email: &str, enquiry: &str, exp: i64) -> String {
format!("{BLOCK_ACTION}|{email}|{enquiry}|{exp}")
}
fn legacy_block_payload(email: &str, exp: i64) -> String {
format!("{email}|{exp}")
}
fn label_payload(label: &str, enquiry: &str, exp: i64) -> String {
format!("{LABEL_ACTION}|{label}|{enquiry}|{exp}")
}
fn email_shaped(email: &str) -> bool {
(3..=254).contains(&email.len())
&& email.contains('@')
&& !email.chars().any(|c| c.is_control())
&& !email.contains('"')
&& !email.contains('\\')
&& !email.contains('|')
}
fn enquiry_id_shaped(id: &str) -> bool {
id.len() == 64 && id.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
}
fn sign(payload: &str, key: &SigningKey) -> String {
b64().encode(key.sign(payload.as_bytes()).to_bytes())
}
fn signed(payload: &str, token: &str, key: &VerifyingKey) -> bool {
let Ok(raw) = b64().decode(token) else {
return false;
};
let Ok(bytes) = <[u8; 64]>::try_from(raw.as_slice()) else {
return false;
};
key.verify(payload.as_bytes(), &Signature::from_bytes(&bytes))
.is_ok()
}
fn mint_block(email: &str, enquiry: &str, exp: i64, key: &SigningKey) -> String {
sign(&block_payload(email, enquiry, exp), key)
}
fn mint_label(label: &str, enquiry: &str, exp: i64, key: &SigningKey) -> String {
sign(&label_payload(label, enquiry, exp), key)
}
fn block_valid(
email: &str,
enquiry: &str,
exp: i64,
token: &str,
key: &VerifyingKey,
now: i64,
) -> bool {
if !email_shaped(email) || !(enquiry.is_empty() || enquiry_id_shaped(enquiry)) || exp <= now {
return false;
}
signed(&block_payload(email, enquiry, exp), token, key)
|| (enquiry.is_empty() && signed(&legacy_block_payload(email, exp), token, key))
}
fn label_valid(
label: &str,
enquiry: &str,
exp: i64,
token: &str,
key: &VerifyingKey,
now: i64,
) -> bool {
LABELS.contains(&label)
&& enquiry_id_shaped(enquiry)
&& exp > now
&& signed(&label_payload(label, enquiry, exp), token, key)
}
fn read_signing_key(path: &std::path::Path) -> Result<SigningKey> {
let bytes = std::fs::read(path).map_err(|e| {
Error::Endpoint(format!(
"cannot read the contact-block signing key at {}: {e}",
path.display()
))
})?;
parse_signing_key(&bytes)
.map_err(|e| Error::Endpoint(format!("{} is not a signing key: {e}", path.display())))
}
fn enquiry_of(inv: &Invocation<'_>, required: bool) -> Result<Option<String>> {
let text = match inv.inline_arg("enquiry") {
Ok(bytes) => bytes,
Err(Error::MissingArgument(_)) if !required => return Ok(None),
Err(Error::MissingArgument(_)) => {
return Err(Error::MissingArgument("enquiry".to_string()))
}
Err(other) => return Err(other),
};
if text.is_empty() || text.len() > MAX_ENQUIRY_BYTES {
return Err(Error::InvalidArgument {
name: "enquiry".to_string(),
detail: format!(
"the enquiry is {} bytes; a link names an enquiry of 1 to {MAX_ENQUIRY_BYTES} \
bytes (refused, not truncated)",
text.len()
),
});
}
Ok(Some(ikigai_intray::tuple_id(text)))
}
fn enquiry_spec(summary: &str) -> ArgSpec {
ArgSpec::new("enquiry")
.summary(summary)
.class(crate::XSD_STRING)
}
fn url_answer(url: String) -> Representation {
Representation::new(
ReprType::new("text/plain").with_param("charset", "utf-8"),
url.into_bytes(),
)
}
fn require_mint(inv: &Invocation<'_>, what: &str) -> Result<()> {
if inv.capability.allows(CAP_CONTACTBLOCK_MINT) {
Ok(())
} else {
Err(Error::Denied(format!(
"minting a {what} link requires `{CAP_CONTACTBLOCK_MINT}`"
)))
}
}
pub struct ContactBlockLink {
pub key_path: std::path::PathBuf,
}
impl Default for ContactBlockLink {
fn default() -> Self {
Self {
key_path: private_key_path(),
}
}
}
#[async_trait::async_trait]
impl Endpoint for ContactBlockLink {
async fn invoke(&self, inv: &Invocation<'_>) -> Result<Representation> {
require_mint(inv, "block")?;
let email = inv
.inline_str("email")
.map_err(|_| Error::MissingArgument("email".to_string()))?
.trim()
.to_string();
if !email_shaped(&email) {
return Err(Error::InvalidArgument {
name: "email".to_string(),
detail: format!("`{email}` is not an address to block"),
});
}
let enquiry = enquiry_of(inv, false)?.unwrap_or_default();
let key = read_signing_key(&self.key_path)?;
let exp = now_secs() + TTL_SECONDS;
let token = mint_block(&email, &enquiry, exp, &key);
let id = if enquiry.is_empty() {
String::new()
} else {
format!("&id={enquiry}")
};
Ok(url_answer(format!(
"{}?email={}{id}&exp={exp}&t={}\n",
link_base(),
urlencode(&email),
urlencode(&token)
)))
}
fn name(&self) -> &str {
"contactblock-link"
}
fn describe(&self) -> Description {
Description::new("contactblock-link")
.title("Mint a block link for an enquiry sender")
.summary(
"A signed, expiring URL that blocks one address at the edge — ready to drop into \
the enquiry email. Given the enquiry, the block also teaches the spam filter \
that enquiry is spam.",
)
.action(
ActionSpec::new(Verb::Source)
.summary("mint — the signed block link for one address")
.requires(CAP_CONTACTBLOCK_MINT)
.input(
ArgSpec::new("email")
.summary("the sender address to block")
.class(crate::XSD_STRING),
)
.input(enquiry_spec(
"the enquiry tuple exactly as the reactor handed it; the link names it \
by its id in the contact space, never by its text",
).optional())
.output("text/plain; charset=utf-8"),
)
.output("text/plain; charset=utf-8")
}
}
pub struct ContactLabelLink {
pub key_path: std::path::PathBuf,
}
impl Default for ContactLabelLink {
fn default() -> Self {
Self {
key_path: private_key_path(),
}
}
}
#[async_trait::async_trait]
impl Endpoint for ContactLabelLink {
async fn invoke(&self, inv: &Invocation<'_>) -> Result<Representation> {
require_mint(inv, "label")?;
let label = inv
.inline_str("label")
.map_err(|_| Error::MissingArgument("label".to_string()))?
.trim()
.to_string();
if !LABELS.contains(&label.as_str()) {
return Err(Error::InvalidArgument {
name: "label".to_string(),
detail: format!("`{label}` is not a label ({})", LABELS.join(", ")),
});
}
let enquiry = enquiry_of(inv, true)?.unwrap_or_default();
let key = read_signing_key(&self.key_path)?;
let exp = now_secs() + TTL_SECONDS;
let token = mint_label(&label, &enquiry, exp, &key);
Ok(url_answer(format!(
"{}?id={enquiry}&label={label}&exp={exp}&t={}\n",
label_link_base(),
urlencode(&token)
)))
}
fn name(&self) -> &str {
"contactlabel-link"
}
fn describe(&self) -> Description {
Description::new("contactlabel-link")
.title("Mint a spam-filter label link for an enquiry")
.summary(
"A signed, expiring URL that teaches the edge's spam filter one enquiry's label \
(`ham` = not spam, `spam` = spam after all) — ready to drop into the enquiry \
email. Names the enquiry by its id; never carries its text.",
)
.action(
ActionSpec::new(Verb::Source)
.summary("mint — the signed label link for one enquiry")
.requires(CAP_CONTACTBLOCK_MINT)
.input(
ArgSpec::new("label")
.summary("the label the link teaches")
.class(crate::XSD_STRING)
.one_of(LABELS),
)
.input(enquiry_spec(
"the enquiry tuple exactly as the reactor handed it; the link names it \
by its id in the contact space, never by its text",
))
.output("text/plain; charset=utf-8"),
)
.output("text/plain; charset=utf-8")
}
}
fn bad_link() -> Representation {
page(
"That link didn't work",
"<p>It may have expired. A fresh enquiry from that sender carries a new one.</p>",
)
}
fn confirm_form(fields: &[(&str, &str)], button: &str) -> String {
let hidden: String = fields
.iter()
.map(|(name, value)| {
format!(
"<input type=hidden name={name} value=\"{}\">",
html_escape(value)
)
})
.collect();
format!(
"<form method=\"post\" id=\"act\">{hidden}\
<button style=\"font:inherit;padding:.6rem 1.2rem\">{}</button>\
</form>",
html_escape(button)
)
}
pub struct ContactBlock {
pub key_path: std::path::PathBuf,
}
#[async_trait::async_trait]
impl Endpoint for ContactBlock {
async fn invoke(&self, inv: &Invocation<'_>) -> Result<Representation> {
let email = param(inv, "email");
let enquiry = param(inv, "id");
let token = param(inv, "t");
let exp: i64 = param(inv, "exp").parse().unwrap_or(0);
let key = verifying_key_at(&self.key_path)?;
if !block_valid(&email, &enquiry, exp, &token, &key, now_secs()) {
return Ok(bad_link());
}
let teaches = if enquiry.is_empty() {
""
} else {
" The spam filter also learns that this enquiry was spam."
};
match inv.request.verb {
Verb::Source => {
let exp = exp.to_string();
let mut fields = vec![("email", email.as_str())];
if !enquiry.is_empty() {
fields.push(("id", enquiry.as_str()));
}
fields.extend([("exp", exp.as_str()), ("t", token.as_str())]);
Ok(page(
"Block this sender?",
&format!(
"<p>Future enquiries from <code>{shown}</code> will be dropped, \
silently.{teaches}</p>{form}\
<p style=\"color:#666\">They are told nothing; from their side the form \
still accepts. You can undo this on the box.</p>{js}",
shown = html_escape(&email),
form = confirm_form(&fields, "Block"),
js = crate::passkey::DECISION_PASSKEY_JS,
),
))
}
Verb::Sink => {
if crate::passkey::require_passkey(inv).is_err() {
return Ok(page(
"Your passkey is needed",
"<p>Blocking this sender needs a tap on your registered device. Reopen \
the link and try again.</p>",
));
}
let named = if enquiry.is_empty() {
String::new()
} else {
format!(" (enquiry {})", quoted(&enquiry))
};
let tuple = format!(
"((block-email {}){named} (exp {}))",
quoted(&email),
quoted(&exp.to_string())
);
drop_intent(inv, tuple).await?;
Ok(page(
"Blocked",
&format!(
"<p>Enquiries from <code>{}</code> will be dropped once the edge \
picks this up — usually at once.</p>",
html_escape(&email)
),
))
}
other => Err(Error::Endpoint(format!(
"a block link is shown with Source or acted with Sink, not {other:?}"
))),
}
}
fn name(&self) -> &str {
"contact-block"
}
fn describe(&self) -> Description {
let inputs = |spec: ActionSpec| {
spec.input(
ArgSpec::new("email")
.summary("the sender address")
.class(crate::XSD_STRING),
)
.input(
ArgSpec::new("id")
.optional()
.summary(
"the enquiry the block also teaches the spam filter about (its tuple id); \
a link minted without one names none",
)
.class(crate::XSD_STRING),
)
.input(
ArgSpec::new("exp")
.summary("expiry, unix seconds")
.class(crate::XSD_INTEGER),
)
.input(
ArgSpec::new("t")
.summary("the signature")
.class(crate::XSD_STRING),
)
.input(form_body_spec())
.output("text/html; charset=utf-8")
};
Description::new("contact-block")
.title("Block an enquiry sender")
.summary(
"The emailed block link. GET verifies the token and shows what it would block; \
POST records the block for the edge to apply. Records intent only.",
)
.action(inputs(
ActionSpec::new(Verb::Source).summary("show — the address this link would block"),
))
.action(inputs(
ActionSpec::new(Verb::Sink).summary("block — record the block"),
))
.output("text/html; charset=utf-8")
}
}
fn form_body_spec() -> ArgSpec {
ArgSpec::new("content")
.optional()
.class(crate::XSD_STRING)
.summary(
"a urlencoded form body; the inputs above are read from it when they are not named \
arguments",
)
}
async fn drop_intent(inv: &Invocation<'_>, tuple: String) -> Result<()> {
inv.issue(
Request::new(
Verb::Sink,
Iri::parse(CONTACT_BLOCKS_SPACE).expect("literal IRI"),
)
.with_arg("content", ArgRef::Inline(tuple.into_bytes())),
)
.await
.map(|_| ())
}
pub struct ContactLabel {
pub key_path: std::path::PathBuf,
}
#[async_trait::async_trait]
impl Endpoint for ContactLabel {
async fn invoke(&self, inv: &Invocation<'_>) -> Result<Representation> {
let label = param(inv, "label");
let enquiry = param(inv, "id");
let token = param(inv, "t");
let exp: i64 = param(inv, "exp").parse().unwrap_or(0);
let key = verifying_key_at(&self.key_path)?;
if !label_valid(&label, &enquiry, exp, &token, &key, now_secs()) {
return Ok(bad_link());
}
let (title, button, effect) = if label == "ham" {
(
"Not spam?",
"Not spam",
"The spam filter learns that this enquiry was a real one, so enquiries like it \
score lower.",
)
} else {
(
"Spam after all?",
"Spam",
"The spam filter learns that this enquiry was spam, so enquiries like it score \
higher. It does not block the sender: the block link does that.",
)
};
let short = html_escape(&enquiry[..12]);
match inv.request.verb {
Verb::Source => {
let exp = exp.to_string();
let fields = [
("id", enquiry.as_str()),
("label", label.as_str()),
("exp", exp.as_str()),
("t", token.as_str()),
];
Ok(page(
title,
&format!(
"<p>{effect}</p><p>Enquiry <code>{short}</code>.</p>{form}\
<p style=\"color:#666\">The sender is told nothing. Clicking the other \
link in the email later changes the label.</p>{js}",
form = confirm_form(&fields, button),
js = crate::passkey::DECISION_PASSKEY_JS,
),
))
}
Verb::Sink => {
if crate::passkey::require_passkey(inv).is_err() {
return Ok(page(
"Your passkey is needed",
"<p>Labeling this enquiry needs a tap on your registered device. Reopen \
the link and try again.</p>",
));
}
let tuple = format!(
"((label {}) (enquiry {}) (exp {}))",
quoted(&label),
quoted(&enquiry),
quoted(&exp.to_string())
);
drop_intent(inv, tuple).await?;
let done = if label == "ham" {
"Marked as not spam"
} else {
"Marked as spam"
};
Ok(page(
done,
&format!(
"<p>Enquiry <code>{short}</code>: the filter learns this once the edge \
picks it up — usually at once.</p>"
),
))
}
other => Err(Error::Endpoint(format!(
"a label link is shown with Source or acted with Sink, not {other:?}"
))),
}
}
fn name(&self) -> &str {
"contact-label"
}
fn describe(&self) -> Description {
let inputs = |spec: ActionSpec| {
spec.input(
ArgSpec::new("id")
.summary("the enquiry's tuple id in the contact space")
.class(crate::XSD_STRING),
)
.input(
ArgSpec::new("label")
.summary("the label: `ham` (not spam) or `spam`")
.class(crate::XSD_STRING)
.one_of(LABELS),
)
.input(
ArgSpec::new("exp")
.summary("expiry, unix seconds")
.class(crate::XSD_INTEGER),
)
.input(
ArgSpec::new("t")
.summary("the signature")
.class(crate::XSD_STRING),
)
.input(form_body_spec())
.output("text/html; charset=utf-8")
};
Description::new("contact-label")
.title("Label an enquiry for the spam filter")
.summary(
"The emailed label link. GET verifies the token and shows what it would teach; \
POST records the label for the edge to train on. Records intent only.",
)
.action(inputs(
ActionSpec::new(Verb::Source).summary("show — the label this link would teach"),
))
.action(inputs(
ActionSpec::new(Verb::Sink).summary("label — record the label"),
))
.output("text/html; charset=utf-8")
}
}
#[cfg(test)]
mod tests {
use super::*;
fn key() -> SigningKey {
SigningKey::from_bytes(&[11u8; 32])
}
const EMAIL: &str = "price@spam.example";
const NOW: i64 = 1_800_000_000;
const EXP: i64 = NOW + 600;
fn enquiry() -> String {
ikigai_intray::tuple_id(b"((message \"cheap pills\"))")
}
#[test]
fn a_freshly_minted_token_verifies() {
let k = key();
let v = k.verifying_key();
let q = enquiry();
assert!(block_valid(
EMAIL,
&q,
EXP,
&mint_block(EMAIL, &q, EXP, &k),
&v,
NOW
));
assert!(block_valid(
EMAIL,
"",
EXP,
&mint_block(EMAIL, "", EXP, &k),
&v,
NOW
));
for label in LABELS {
assert!(label_valid(
label,
&q,
EXP,
&mint_label(label, &q, EXP, &k),
&v,
NOW
));
}
}
#[test]
fn the_signed_payloads_start_with_their_action() {
assert_eq!(
block_payload("a@example.invalid", "", 1_800_000_000),
"contact-block|a@example.invalid||1800000000"
);
assert_eq!(
label_payload("ham", &"a".repeat(64), 1_800_000_000),
format!("contact-label|ham|{}|1800000000", "a".repeat(64))
);
}
#[test]
fn a_token_is_bound_to_its_address_enquiry_and_expiry() {
let k = key();
let v = k.verifying_key();
let q = enquiry();
let t = mint_block(EMAIL, &q, EXP, &k);
assert!(!block_valid("someone@else.example", &q, EXP, &t, &v, NOW));
assert!(!block_valid(EMAIL, &"b".repeat(64), EXP, &t, &v, NOW));
assert!(!block_valid(EMAIL, "", EXP, &t, &v, NOW));
assert!(!block_valid(EMAIL, &q, EXP + 1, &t, &v, NOW));
}
#[test]
fn a_label_token_is_bound_to_its_label_enquiry_and_expiry() {
let k = key();
let v = k.verifying_key();
let q = enquiry();
let ham = mint_label("ham", &q, EXP, &k);
assert!(!label_valid("spam", &q, EXP, &ham, &v, NOW));
let spam = mint_label("spam", &q, EXP, &k);
assert!(!label_valid("ham", &q, EXP, &spam, &v, NOW));
assert!(!label_valid("ham", &"b".repeat(64), EXP, &ham, &v, NOW));
assert!(!label_valid("ham", &q, EXP + 1, &ham, &v, NOW));
}
#[test]
fn a_block_token_is_never_a_label_token_nor_the_reverse() {
let k = key();
let v = k.verifying_key();
let q = enquiry();
let block = mint_block(EMAIL, &q, EXP, &k);
let legacy = sign(&legacy_block_payload(EMAIL, EXP), &k);
for t in [&block, &legacy] {
for label in LABELS {
assert!(!label_valid(label, &q, EXP, t, &v, NOW), "{label}");
}
}
for label in LABELS {
let t = sign(&block_payload(label, &q, EXP), &k);
assert!(!label_valid(label, &q, EXP, &t, &v, NOW), "{label}");
}
for label in LABELS {
let t = mint_label(label, &q, EXP, &k);
assert!(!block_valid(EMAIL, &q, EXP, &t, &v, NOW));
assert!(!block_valid(EMAIL, "", EXP, &t, &v, NOW));
let forged_email = format!("{LABEL_ACTION}|{label}|{q}");
assert!(!block_valid(&forged_email, "", EXP, &t, &v, NOW));
}
}
#[test]
fn a_link_minted_before_the_action_tag_still_blocks_but_names_no_enquiry() {
let k = key();
let v = k.verifying_key();
let legacy = sign(&legacy_block_payload(EMAIL, EXP), &k);
assert!(block_valid(EMAIL, "", EXP, &legacy, &v, NOW));
assert!(!block_valid(EMAIL, &enquiry(), EXP, &legacy, &v, NOW));
}
#[test]
fn an_expired_token_is_refused() {
let k = key();
let v = k.verifying_key();
let q = enquiry();
let t = mint_block(EMAIL, &q, EXP, &k);
assert!(!block_valid(EMAIL, &q, EXP, &t, &v, EXP + 1));
let l = mint_label("ham", &q, EXP, &k);
assert!(!label_valid("ham", &q, EXP, &l, &v, EXP));
}
#[test]
fn another_key_cannot_sign_a_block_or_a_label() {
let theirs = SigningKey::from_bytes(&[9u8; 32]);
let v = key().verifying_key();
let q = enquiry();
assert!(!block_valid(
EMAIL,
&q,
EXP,
&mint_block(EMAIL, &q, EXP, &theirs),
&v,
NOW
));
assert!(!label_valid(
"ham",
&q,
EXP,
&mint_label("ham", &q, EXP, &theirs),
&v,
NOW
));
}
#[test]
fn garbage_tokens_are_refused_without_panicking() {
let v = key().verifying_key();
let q = enquiry();
for bad in ["", "!!!!", "c2hvcnQ", &"A".repeat(200)] {
assert!(!block_valid(EMAIL, "", EXP, bad, &v, NOW), "{bad}");
assert!(!label_valid("ham", &q, EXP, bad, &v, NOW), "{bad}");
}
}
#[test]
fn a_malformed_address_never_reaches_the_signature_check() {
let k = key();
let v = k.verifying_key();
let t = mint_block(EMAIL, "", EXP, &k);
for bad in [
"no-at-sign",
"x@y\"; DROP",
"back\\slash@x",
"with\nnewline@x",
"pipe|in@x",
"",
] {
assert!(!email_shaped(bad), "should be malformed: {bad:?}");
assert!(!block_valid(bad, "", EXP, &t, &v, NOW), "{bad:?}");
}
assert!(!email_shaped(&format!("{}@x", "a".repeat(300))));
assert!(email_shaped(EMAIL));
}
#[test]
fn an_unknown_label_or_a_malformed_enquiry_is_refused_before_the_signature() {
let k = key();
let v = k.verifying_key();
let q = enquiry();
let odd = mint_label("eggs", &q, EXP, &k);
assert!(!label_valid("eggs", &q, EXP, &odd, &v, NOW));
for bad in [
"abc".to_string(),
q.to_uppercase(),
format!("../{}", &q[3..]),
String::new(),
] {
assert!(!enquiry_id_shaped(&bad), "{bad:?}");
let t = mint_label("ham", &bad, EXP, &k);
assert!(!label_valid("ham", &bad, EXP, &t, &v, NOW), "{bad:?}");
if !bad.is_empty() {
let t = mint_block(EMAIL, &bad, EXP, &k);
assert!(!block_valid(EMAIL, &bad, EXP, &t, &v, NOW), "{bad:?}");
}
}
assert!(enquiry_id_shaped(&q));
}
}
#[cfg(test)]
mod endpoint_tests {
use super::*;
use ed25519_dalek::pkcs8::{spki::der::pem::LineEnding, EncodePrivateKey, EncodePublicKey};
use futures::executor::block_on;
use ikigai_core::{Capability, EndpointSpace, Exact, Kernel};
use std::sync::{Arc, Mutex};
const EMAIL: &str = "price@spam.example";
const ENQUIRY: &str =
"((name \"A Visitor\") (email \"visitor@example.invalid\") (message \"cheap pills\"))";
#[derive(Clone, Default)]
struct Recorder {
dropped: Arc<Mutex<Vec<String>>>,
}
#[async_trait::async_trait]
impl Endpoint for Recorder {
async fn invoke(&self, inv: &Invocation<'_>) -> Result<Representation> {
self.dropped
.lock()
.unwrap()
.push(inv.inline_str("content").unwrap_or("").to_string());
Ok(Representation::new(
ReprType::new("text/plain"),
b"ok".to_vec(),
))
}
fn name(&self) -> &str {
"contact-blocks"
}
fn describe(&self) -> Description {
Description::new("contact-blocks").verb(Verb::Sink)
}
}
struct World {
kernel: Kernel,
dropped: Arc<Mutex<Vec<String>>>,
key: SigningKey,
_dir: std::path::PathBuf,
}
fn world(name: &str) -> World {
let dir =
std::env::temp_dir().join(format!("ikigai-contactblock-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let key = SigningKey::from_bytes(&[11u8; 32]);
let pub_pem = key
.verifying_key()
.to_public_key_pem(LineEnding::LF)
.unwrap();
let key_path = dir.join("contact-block.pub");
std::fs::write(&key_path, pub_pem).unwrap();
let private = dir.join("contact-block.key");
std::fs::write(&private, key.to_pkcs8_pem(LineEnding::LF).unwrap().as_str()).unwrap();
let recorder = Recorder::default();
let kernel = Kernel::new(Arc::new(
EndpointSpace::new()
.bind(
Exact::new("urn:contact-block"),
ContactBlock {
key_path: key_path.clone(),
},
)
.bind(Exact::new("urn:contact-label"), ContactLabel { key_path })
.bind(
Exact::new("urn:contactblock:link"),
ContactBlockLink {
key_path: private.clone(),
},
)
.bind(
Exact::new("urn:contactlabel:link"),
ContactLabelLink { key_path: private },
)
.bind(Exact::new(CONTACT_BLOCKS_SPACE), recorder.clone()),
));
World {
kernel,
dropped: recorder.dropped,
key,
_dir: dir,
}
}
impl World {
fn issue(&self, verb: Verb, iri: &str, args: &[(&str, &str)]) -> Result<String> {
let mut r = Request::new(verb, Iri::parse(iri).unwrap());
for (n, v) in args {
r = r.with_arg(*n, ArgRef::Inline(v.as_bytes().to_vec()));
}
block_on(self.kernel.issue(r, &Capability::root()))
.map(|rep| String::from_utf8(rep.bytes).unwrap())
}
fn call(&self, verb: Verb, email: &str, exp: i64, token: &str) -> String {
self.issue(
verb,
"urn:contact-block",
&[("email", email), ("exp", &exp.to_string()), ("t", token)],
)
.expect("the page should render")
}
fn post_form(&self, iri: &str, body: &str) -> String {
self.issue(Verb::Sink, iri, &[("content", body)])
.expect("the page should render")
}
fn post_block(&self, email: &str, exp: i64, token: &str) -> String {
self.post_form(
"urn:contact-block",
&format!("email={}&exp={exp}&t={token}", urlencode(email)),
)
}
fn post_label(&self, id: &str, label: &str, exp: i64, token: &str) -> String {
self.post_form(
"urn:contact-label",
&format!("id={id}&label={label}&exp={exp}&t={}", urlencode(token)),
)
}
fn valid(&self) -> (i64, String) {
let exp = now_secs() + 600;
(exp, mint_block(EMAIL, "", exp, &self.key))
}
fn dropped(&self) -> Vec<String> {
self.dropped.lock().unwrap().clone()
}
}
fn query(url: &str) -> std::collections::BTreeMap<String, String> {
let (_, q) = url.trim().split_once('?').expect("a query");
q.split('&')
.map(|pair| {
let (k, v) = pair.split_once('=').unwrap();
(k.to_string(), crate::decide::percent_decode(v))
})
.collect()
}
#[test]
fn markup_in_a_signed_address_renders_as_text() {
let w = world("markup");
let hostile = "<b>x</b>&y'@spam.example";
assert!(
email_shaped(hostile),
"the gate admits it, so the page must cope"
);
let escaped = "<b>x</b>&amp;y'@spam.example";
let exp = now_secs() + 600;
let token = mint_block(hostile, "", exp, &w.key);
let get = w.call(Verb::Source, hostile, exp, &token);
assert!(get.contains("Block this sender?"), "{get}");
assert!(!get.contains("<b>"), "markup rendered: {get}");
assert!(get.contains(&format!("<code>{escaped}</code>")), "{get}");
assert!(
get.contains(&format!("name=email value=\"{escaped}\"")),
"the hidden field: {get}"
);
let post = w.post_block(hostile, exp, &token);
assert!(post.contains("Blocked"), "{post}");
assert!(!post.contains("<b>"), "markup rendered: {post}");
assert!(post.contains(&format!("<code>{escaped}</code>")), "{post}");
assert_eq!(
crate::decide::field(&w.dropped()[0], "block-email").as_deref(),
Some(hostile),
"the drop keeps the address exactly"
);
}
#[test]
fn the_confirm_form_escapes_its_values() {
let form = confirm_form(&[("t", "a\"><script>x</script>&")], "Go & <b>");
assert!(!form.contains("<script>"), "{form}");
assert!(!form.contains("<b>"), "{form}");
assert!(
form.contains("name=t value=\"a"><script>x</script>&\""),
"{form}"
);
assert!(form.contains(">Go & <b></button>"), "{form}");
}
#[test]
fn a_get_shows_the_block_but_records_nothing() {
let w = world("get");
let (exp, token) = w.valid();
let html = w.call(Verb::Source, EMAIL, exp, &token);
assert!(html.contains("Block this sender?"), "{html}");
assert!(html.contains(EMAIL), "{html}");
assert!(
html.contains("method=\"post\""),
"offers a POST button: {html}"
);
assert!(w.dropped().is_empty(), "a GET must record nothing");
}
#[test]
fn a_post_records_the_block() {
let w = world("post");
let (exp, token) = w.valid();
let html = w.post_block(EMAIL, exp, &token);
assert!(html.contains("Blocked"), "{html}");
let dropped = w.dropped();
assert_eq!(dropped.len(), 1, "{dropped:?}");
assert_eq!(
crate::decide::field(&dropped[0], "block-email").as_deref(),
Some(EMAIL)
);
assert_eq!(crate::decide::field(&dropped[0], "enquiry"), None);
}
#[test]
fn a_forged_or_expired_token_records_nothing() {
let w = world("forged");
let (exp, _) = w.valid();
let forged = mint_block(EMAIL, "", exp, &SigningKey::from_bytes(&[9u8; 32]));
assert!(w.post_block(EMAIL, exp, &forged).contains("didn't work"));
let stale = now_secs() - 1;
let stale_token = mint_block(EMAIL, "", stale, &w.key);
assert!(w
.post_block(EMAIL, stale, &stale_token)
.contains("didn't work"));
assert!(w.dropped().is_empty(), "neither is recorded");
}
#[test]
fn a_token_for_one_address_cannot_block_another() {
let w = world("swap");
let (exp, token) = w.valid();
let html = w.post_block("victim@example.com", exp, &token);
assert!(html.contains("didn't work"), "{html}");
assert!(w.dropped().is_empty(), "nothing recorded");
}
#[test]
fn a_minted_block_link_names_its_enquiry_and_its_post_records_it() {
let w = world("block-enquiry");
let url = w
.issue(
Verb::Source,
"urn:contactblock:link",
&[("email", EMAIL), ("enquiry", ENQUIRY)],
)
.unwrap();
let q = query(&url);
let id = ikigai_intray::tuple_id(ENQUIRY.as_bytes());
assert_eq!(q.get("id"), Some(&id), "{url}");
assert!(
!url.contains("cheap"),
"the link never carries the text: {url}"
);
let get = w
.issue(
Verb::Source,
"urn:contact-block",
&[
("email", EMAIL),
("id", &q["id"]),
("exp", &q["exp"]),
("t", &q["t"]),
],
)
.unwrap();
assert!(get.contains(&format!("name=id value=\"{id}\"")), "{get}");
assert!(get.contains("spam filter"), "{get}");
let body = format!(
"email={}&id={}&exp={}&t={}",
urlencode(EMAIL),
q["id"],
q["exp"],
urlencode(&q["t"])
);
assert!(w.post_form("urn:contact-block", &body).contains("Blocked"));
let dropped = w.dropped();
assert_eq!(dropped.len(), 1, "{dropped:?}");
assert_eq!(crate::decide::field(&dropped[0], "enquiry"), Some(id));
assert_eq!(
crate::decide::field(&dropped[0], "block-email").as_deref(),
Some(EMAIL)
);
}
#[test]
fn a_minted_label_link_round_trips_into_one_label_drop() {
let w = world("label-roundtrip");
let url = w
.issue(
Verb::Source,
"urn:contactlabel:link",
&[("label", "ham"), ("enquiry", ENQUIRY)],
)
.unwrap();
assert!(
url.starts_with("https://ikigai-rs.dev/contact-label?"),
"{url}"
);
assert!(
!url.contains("cheap"),
"the link never carries the text: {url}"
);
let q = query(&url);
let id = ikigai_intray::tuple_id(ENQUIRY.as_bytes());
assert_eq!(q.get("id"), Some(&id));
assert_eq!(q.get("label").map(String::as_str), Some("ham"));
let get = w
.issue(
Verb::Source,
"urn:contact-label",
&[
("id", &q["id"]),
("label", "ham"),
("exp", &q["exp"]),
("t", &q["t"]),
],
)
.unwrap();
assert!(get.contains("Not spam?"), "{get}");
assert!(get.contains("method=\"post\""), "{get}");
assert!(w.dropped().is_empty(), "a GET must record nothing");
let exp: i64 = q["exp"].parse().unwrap();
let html = w.post_label(&id, "ham", exp, &q["t"]);
assert!(html.contains("Marked as not spam"), "{html}");
let dropped = w.dropped();
assert_eq!(dropped.len(), 1, "{dropped:?}");
assert_eq!(
crate::decide::field(&dropped[0], "label").as_deref(),
Some("ham")
);
assert_eq!(crate::decide::field(&dropped[0], "enquiry"), Some(id));
}
#[test]
fn every_bad_label_link_records_nothing() {
let w = world("label-refusals");
let id = ikigai_intray::tuple_id(ENQUIRY.as_bytes());
let exp = now_secs() + 600;
let good = mint_label("ham", &id, exp, &w.key);
let forged = mint_label("ham", &id, exp, &SigningKey::from_bytes(&[9u8; 32]));
let stale_exp = now_secs() - 1;
let stale = mint_label("ham", &id, stale_exp, &w.key);
let block = mint_block(EMAIL, &id, exp, &w.key);
let other = "c".repeat(64);
let cases: [(&str, &str, &str, i64, &str); 7] = [
("wrong signature", &id, "ham", exp, &forged),
("expired", &id, "ham", stale_exp, &stale),
("swapped action", &id, "spam", exp, &good),
("unknown id", &other, "ham", exp, &good),
("malformed id", "not-a-tuple-id", "ham", exp, &good),
("block token as label", &id, "spam", exp, &block),
("block token as label (ham)", &id, "ham", exp, &block),
];
for (what, id, label, exp, token) in cases {
let html = w.post_label(id, label, exp, token);
assert!(html.contains("didn't work"), "{what}: {html}");
let get = w
.issue(
Verb::Source,
"urn:contact-label",
&[
("id", id),
("label", label),
("exp", &exp.to_string()),
("t", token),
],
)
.unwrap();
assert!(get.contains("didn't work"), "{what}: {get}");
}
assert!(
w.dropped().is_empty(),
"nothing recorded: {:?}",
w.dropped()
);
assert!(w
.post_label(&id, "ham", exp, &good)
.contains("Marked as not spam"));
assert_eq!(w.dropped().len(), 1);
}
#[test]
fn a_label_token_is_never_a_block_token() {
let w = world("label-as-block");
let id = ikigai_intray::tuple_id(ENQUIRY.as_bytes());
let exp = now_secs() + 600;
for label in LABELS {
let t = mint_label(label, &id, exp, &w.key);
let body = format!(
"email={}&id={id}&exp={exp}&t={}",
urlencode(EMAIL),
urlencode(&t)
);
assert!(w
.post_form("urn:contact-block", &body)
.contains("didn't work"));
}
assert!(w.dropped().is_empty());
}
#[test]
fn the_minters_refuse_without_the_grant_and_on_bad_input() {
let w = world("mint-refusals");
let no_grant = Capability::scoped(["urn:cap:email:send"]);
for (iri, args) in [
("urn:contactblock:link", vec![("email", EMAIL)]),
(
"urn:contactlabel:link",
vec![("label", "ham"), ("enquiry", ENQUIRY)],
),
] {
let mut r = Request::new(Verb::Source, Iri::parse(iri).unwrap());
for (n, v) in args {
r = r.with_arg(n, ArgRef::Inline(v.as_bytes().to_vec()));
}
let got = block_on(w.kernel.issue(r, &no_grant));
assert!(matches!(got, Err(Error::Denied(_))), "{iri}: {got:?}");
}
let bad_label = w.issue(
Verb::Source,
"urn:contactlabel:link",
&[("label", "eggs"), ("enquiry", ENQUIRY)],
);
assert!(
matches!(bad_label, Err(Error::InvalidArgument { ref name, .. }) if name == "label"),
"{bad_label:?}"
);
let no_enquiry = w.issue(Verb::Source, "urn:contactlabel:link", &[("label", "ham")]);
assert!(
matches!(no_enquiry, Err(Error::MissingArgument(ref n)) if n == "enquiry"),
"{no_enquiry:?}"
);
let huge = "x".repeat(MAX_ENQUIRY_BYTES + 1);
let too_big = w.issue(
Verb::Source,
"urn:contactlabel:link",
&[("label", "ham"), ("enquiry", &huge)],
);
assert!(
matches!(too_big, Err(Error::InvalidArgument { ref name, .. }) if name == "enquiry"),
"{too_big:?}"
);
}
#[test]
fn a_missing_key_says_not_configured_without_its_path() {
let dir = std::env::temp_dir().join(format!("ikigai-cblock-{}-nokey", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let key_path = dir.join("contact-block.pub");
let kernel = Kernel::new(Arc::new(
EndpointSpace::new()
.bind(
Exact::new("urn:contact-block"),
ContactBlock {
key_path: key_path.clone(),
},
)
.bind(Exact::new("urn:contact-label"), ContactLabel { key_path }),
));
for iri in ["urn:contact-block", "urn:contact-label"] {
let err = block_on(kernel.issue(
Request::new(Verb::Sink, Iri::parse(iri).unwrap()).with_arg(
"content",
ArgRef::Inline(b"email=a%40b.example&id=&exp=99999999999&t=AAAA".to_vec()),
),
&Capability::root(),
))
.expect_err("no key, no page")
.to_string();
assert!(err.contains("not configured"), "{iri}: {err}");
assert!(err.contains("contact-block.pub"), "{iri}: {err}");
assert!(
!err.contains(dir.to_str().unwrap()),
"{iri}: the error names no path: {err}"
);
}
let _ = std::fs::remove_dir_all(&dir);
}
}