Skip to main content

cc_me/
lib.rs

1//! Client library for [cc.me](https://cc.me/).
2//!
3//! Mirrors the canonical JavaScript implementation in `client/js/index.js` and
4//! follows the wire protocol described in `client/PROTOCOL.md`. The Rust server
5//! in `src/main.rs` is the source of truth for the wire format; the crypto
6//! crates here are pinned to the exact versions the server locks so the
7//! sealed-box decrypt path interoperates with the server's `PublicKey::seal`.
8
9use std::error::Error as StdError;
10use std::fmt;
11use std::path::Path;
12use std::time::{SystemTime, UNIX_EPOCH};
13
14use base64::engine::general_purpose::URL_SAFE_NO_PAD;
15use base64::Engine;
16use crypto_box::SecretKey;
17use ed25519_dalek::{Signer, SigningKey};
18use serde::Deserialize;
19use sha2::{Digest, Sha256, Sha512};
20
21/// Default cc.me base URL.
22pub const DEFAULT_BASE_URL: &str = "https://cc.me/";
23
24const AUTH_VERSION: &str = "cc-me-v1";
25const AUTH_TIMESTAMP_HEADER: &str = "x-cc-me-timestamp";
26const AUTH_SIGNATURE_HEADER: &str = "x-cc-me-signature";
27const SEALED_BOX_PUBLIC_KEY_BYTES: usize = 32;
28
29/// Errors surfaced by the client.
30#[derive(Debug)]
31pub enum Error {
32    /// I/O error (key file access).
33    Io(std::io::Error),
34    /// The private key was not a valid 32-byte base64url seed.
35    InvalidKey(String),
36    /// An HTTP transport error or a non-2xx response.
37    Http(String),
38    /// A response could not be parsed or decrypted.
39    Protocol(String),
40}
41
42impl fmt::Display for Error {
43    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
44        match self {
45            Error::Io(e) => write!(f, "io error: {e}"),
46            Error::InvalidKey(m) => write!(f, "invalid key: {m}"),
47            Error::Http(m) => write!(f, "{m}"),
48            Error::Protocol(m) => write!(f, "{m}"),
49        }
50    }
51}
52
53impl StdError for Error {}
54
55impl From<std::io::Error> for Error {
56    fn from(e: std::io::Error) -> Self {
57        Error::Io(e)
58    }
59}
60
61/// Convenience result alias.
62pub type Result<T> = std::result::Result<T, Error>;
63
64fn b64u_encode(bytes: &[u8]) -> String {
65    URL_SAFE_NO_PAD.encode(bytes)
66}
67
68fn b64u_decode(value: &str) -> Result<Vec<u8>> {
69    URL_SAFE_NO_PAD
70        .decode(value.trim())
71        .map_err(|e| Error::Protocol(format!("invalid base64url: {e}")))
72}
73
74/// Decode a base64url private key into its 32 seed bytes, validating length.
75fn private_key_bytes(key: &str) -> Result<[u8; 32]> {
76    let bytes = URL_SAFE_NO_PAD
77        .decode(key.trim())
78        .map_err(|e| Error::InvalidKey(format!("not base64url: {e}")))?;
79    bytes
80        .try_into()
81        .map_err(|_| Error::InvalidKey("private key must be 32 bytes of base64url".into()))
82}
83
84fn signing_key(key: &str) -> Result<SigningKey> {
85    Ok(SigningKey::from_bytes(&private_key_bytes(key)?))
86}
87
88/// The base64url Ed25519 public key derived from a private key, used to address
89/// the inbox.
90fn public_key_b64u(key: &str) -> Result<String> {
91    let sk = signing_key(key)?;
92    Ok(b64u_encode(sk.verifying_key().as_bytes()))
93}
94
95/// The recipient X25519 secret key, derived from the Ed25519 seed the same way
96/// libsodium's `crypto_sign_ed25519_sk_to_curve25519` does: the first 32 bytes
97/// of `SHA512(seed)`. `SecretKey::from_bytes` applies X25519 clamping on use.
98fn x25519_secret_key(key: &str) -> Result<SecretKey> {
99    let seed = private_key_bytes(key)?;
100    let hash = Sha512::digest(seed);
101    let mut clamped = [0u8; 32];
102    clamped.copy_from_slice(&hash[..32]);
103    Ok(SecretKey::from_bytes(clamped))
104}
105
106/// Generate a fresh private key: 32 random seed bytes from the OS CSPRNG,
107/// base64url-no-pad encoded.
108fn generate_private_key() -> Result<String> {
109    let mut seed = [0u8; 32];
110    getrandom::fill(&mut seed).map_err(|e| Error::Protocol(format!("randomness failed: {e}")))?;
111    Ok(b64u_encode(&seed))
112}
113
114/// Load or create the private key.
115///
116/// With `None`, a fresh random key is generated and returned (not persisted).
117/// With a path: if the file exists, its trimmed contents are validated, the
118/// file mode is tightened to `0600` (unix), and the key is returned. If it does
119/// not exist, a new key is generated, written with mode `0600`, and returned.
120pub fn private_key(path: Option<&Path>) -> Result<String> {
121    let Some(path) = path else {
122        return generate_private_key();
123    };
124
125    match std::fs::read_to_string(path) {
126        Ok(contents) => {
127            let key = contents.trim().to_string();
128            // Validate.
129            private_key_bytes(&key)?;
130            secure_key_file(path)?;
131            Ok(key)
132        }
133        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
134            let key = generate_private_key()?;
135            write_new_key_file(path, &key)?;
136            Ok(key)
137        }
138        Err(e) => Err(Error::Io(e)),
139    }
140}
141
142#[cfg(unix)]
143fn write_new_key_file(path: &Path, key: &str) -> Result<()> {
144    use std::io::Write;
145    use std::os::unix::fs::OpenOptionsExt;
146    let mut file = std::fs::OpenOptions::new()
147        .write(true)
148        .create_new(true)
149        .mode(0o600)
150        .open(path)?;
151    file.write_all(key.as_bytes())?;
152    file.write_all(b"\n")?;
153    Ok(())
154}
155
156#[cfg(not(unix))]
157fn write_new_key_file(path: &Path, key: &str) -> Result<()> {
158    std::fs::write(path, format!("{key}\n"))?;
159    Ok(())
160}
161
162#[cfg(unix)]
163fn secure_key_file(path: &Path) -> Result<()> {
164    use std::os::unix::fs::PermissionsExt;
165    let perms = std::fs::Permissions::from_mode(0o600);
166    std::fs::set_permissions(path, perms)?;
167    Ok(())
168}
169
170#[cfg(not(unix))]
171fn secure_key_file(_path: &Path) -> Result<()> {
172    Ok(())
173}
174
175fn normalize_base(base_url: &str) -> String {
176    if base_url.ends_with('/') {
177        base_url.to_string()
178    } else {
179        format!("{base_url}/")
180    }
181}
182
183/// Percent-encode a query value (used for the trampoline `at` target and meta
184/// verify token). Encodes everything that is not an unreserved character.
185fn encode_query_value(value: &str) -> String {
186    let mut out = String::with_capacity(value.len());
187    for byte in value.as_bytes() {
188        match byte {
189            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
190                out.push(*byte as char)
191            }
192            other => {
193                out.push('%');
194                out.push_str(&format!("{other:02X}"));
195            }
196        }
197    }
198    out
199}
200
201/// Build the trampoline URL: `{base}/?at={target}` plus any extra params.
202///
203/// `params` are appended in iteration order after `at`.
204pub fn trampoline_url(target: &str, base_url: Option<&str>, params: &[(&str, &str)]) -> String {
205    let base = normalize_base(base_url.unwrap_or(DEFAULT_BASE_URL));
206    let mut url = format!("{base}?at={}", encode_query_value(target));
207    for (k, v) in params {
208        url.push('&');
209        url.push_str(&encode_query_value(k));
210        url.push('=');
211        url.push_str(&encode_query_value(v));
212    }
213    url
214}
215
216#[derive(Deserialize)]
217struct AliasResponse {
218    url: String,
219}
220
221/// Create (or look up) an alias: `POST {base}/c` with `{"at": target}` →
222/// returns the alias URL. Idempotent. No auth.
223pub fn create_alias(target: &str, base_url: Option<&str>) -> Result<String> {
224    let base = normalize_base(base_url.unwrap_or(DEFAULT_BASE_URL));
225    let url = format!("{base}c");
226    let body = serde_json::json!({ "at": target }).to_string();
227    let resp = ureq::post(&url)
228        .set("content-type", "application/json")
229        .send_bytes(body.as_bytes());
230    let text = read_response(resp)?;
231    let parsed: AliasResponse = serde_json::from_str(&text)
232        .map_err(|e| Error::Protocol(format!("invalid alias response: {e}")))?;
233    Ok(parsed.url)
234}
235
236/// Options for listing deliveries (peek/claim).
237#[derive(Debug, Default, Clone)]
238pub struct ListOptions {
239    /// Maximum number of deliveries to return.
240    pub limit: Option<u32>,
241    /// Opaque cursor (peek only).
242    pub cursor: Option<String>,
243    /// Long-poll for deliveries.
244    pub poll: bool,
245}
246
247/// A single header from a decrypted delivery.
248#[derive(Debug, Clone)]
249pub struct Header {
250    /// Lower-cased header name.
251    pub name: String,
252    /// Header value decoded as UTF-8 (lossy).
253    pub value: String,
254    /// Raw header value bytes.
255    pub value_bytes: Vec<u8>,
256}
257
258/// A decrypted delivery (the captured HTTP request).
259#[derive(Debug, Clone)]
260pub struct Delivery {
261    /// Delivery id (matches the envelope id).
262    pub id: String,
263    /// Server receive time, Unix milliseconds.
264    pub received_at_unix_ms: u128,
265    /// HTTP method of the captured request.
266    pub method: String,
267    /// Request path.
268    pub path: String,
269    /// Request query string, if any (without leading `?`).
270    pub query: Option<String>,
271    /// Request headers.
272    pub headers: Vec<Header>,
273    /// Raw request body bytes.
274    pub body_bytes: Vec<u8>,
275}
276
277impl Delivery {
278    /// Body decoded as UTF-8 (lossy).
279    pub fn text(&self) -> String {
280        String::from_utf8_lossy(&self.body_bytes).into_owned()
281    }
282
283    /// Body parsed as JSON.
284    pub fn json(&self) -> Result<serde_json::Value> {
285        serde_json::from_slice(&self.body_bytes)
286            .map_err(|e| Error::Protocol(format!("body is not valid JSON: {e}")))
287    }
288}
289
290/// Response from peek/claim: the count, decrypted deliveries, and (peek) cursor.
291#[derive(Debug, Clone)]
292pub struct DeliveryResponse {
293    /// Number of items returned.
294    pub count: u64,
295    /// Decrypted deliveries.
296    pub requests: Vec<Delivery>,
297    /// Cursor to pass to a subsequent peek, if any.
298    pub cursor: Option<String>,
299}
300
301/// Response from ack/release.
302#[derive(Debug, Clone, Deserialize)]
303pub struct BatchResponse {
304    /// Number acked (ack only).
305    #[serde(default)]
306    pub acked: u64,
307    /// Number released (release only).
308    #[serde(default)]
309    pub released: u64,
310    /// Ids that were not found.
311    #[serde(default)]
312    pub missing: Vec<String>,
313}
314
315#[derive(Deserialize)]
316struct Envelope {
317    id: String,
318    sealed: String,
319}
320
321#[derive(Deserialize)]
322struct RawDeliveryResponse {
323    #[serde(default)]
324    count: u64,
325    #[serde(default)]
326    items: Vec<Envelope>,
327    #[serde(default)]
328    cursor: Option<String>,
329}
330
331#[derive(Deserialize)]
332struct RawCapturedHeader {
333    name: String,
334    value_b64u: String,
335}
336
337#[derive(Deserialize)]
338struct RawCapturedRequest {
339    id: String,
340    received_at_unix_ms: u128,
341    method: String,
342    path: String,
343    #[serde(default)]
344    query: Option<String>,
345    headers: Vec<RawCapturedHeader>,
346    body_b64u: String,
347}
348
349/// A client bound to a single private key and base URL.
350pub struct CcMeClient {
351    base_url: String,
352    private_key: String,
353    public_key: String,
354    secret_key: SecretKey,
355}
356
357impl CcMeClient {
358    /// Build a client. `base_url` defaults to [`DEFAULT_BASE_URL`].
359    pub fn new(private_key: String, base_url: Option<&str>) -> Result<Self> {
360        // Validate up front and cache derived material.
361        let public_key = public_key_b64u(&private_key)?;
362        let secret_key = x25519_secret_key(&private_key)?;
363        Ok(Self {
364            base_url: normalize_base(base_url.unwrap_or(DEFAULT_BASE_URL)),
365            private_key,
366            public_key,
367            secret_key,
368        })
369    }
370
371    /// The inbox base path, `/i/{publicKey}`.
372    fn inbox_path(&self) -> String {
373        format!("/i/{}", self.public_key)
374    }
375
376    /// The inbox URL with optional `l`, `c`, `p` query params (in that order).
377    pub fn inbox_url(&self, options: &ListOptions) -> String {
378        format!(
379            "{}{}",
380            trim_trailing_slash(&self.base_url),
381            self.inbox_query(options)
382        )
383    }
384
385    /// Build the inbox path+query string used both for signing and the wire.
386    fn inbox_query(&self, options: &ListOptions) -> String {
387        let mut path = self.inbox_path();
388        let mut params: Vec<String> = Vec::new();
389        if let Some(limit) = options.limit {
390            params.push(format!("l={limit}"));
391        }
392        if let Some(cursor) = &options.cursor {
393            params.push(format!("c={}", encode_query_value(cursor)));
394        }
395        if options.poll {
396            params.push("p=".to_string());
397        }
398        if !params.is_empty() {
399            path.push('?');
400            path.push_str(&params.join("&"));
401        }
402        path
403    }
404
405    fn protocol_url(&self, protocol: &str) -> String {
406        format!(
407            "{}{}/{}",
408            trim_trailing_slash(&self.base_url),
409            self.inbox_path(),
410            protocol
411        )
412    }
413
414    /// Webmention receiver URL.
415    pub fn webmention_url(&self) -> String {
416        self.protocol_url("webmention")
417    }
418
419    /// WebSub receiver URL.
420    pub fn websub_url(&self) -> String {
421        self.protocol_url("websub")
422    }
423
424    /// Slack receiver URL.
425    pub fn slack_url(&self) -> String {
426        self.protocol_url("slack")
427    }
428
429    /// Pingback receiver URL.
430    pub fn pingback_url(&self) -> String {
431        self.protocol_url("pingback")
432    }
433
434    /// Meta (webhooks) receiver URL, with optional verify token (`?v=`).
435    pub fn meta_url(&self, verify_token: Option<&str>) -> String {
436        let base = self.protocol_url("meta");
437        match verify_token {
438            Some(token) => format!("{base}?v={}", encode_query_value(token)),
439            None => base,
440        }
441    }
442
443    /// CloudEvents receiver URL.
444    pub fn cloud_events_url(&self) -> String {
445        self.protocol_url("cloudevents")
446    }
447
448    /// Discord interaction receiver URL for the given application public key.
449    pub fn discord_url(&self, discord_public_key: &str) -> String {
450        format!(
451            "{}{}/discord/{}",
452            trim_trailing_slash(&self.base_url),
453            self.inbox_path(),
454            encode_path_segment(discord_public_key)
455        )
456    }
457
458    /// Peek at deliveries without reserving them (signed GET).
459    pub fn peek(&self, options: &ListOptions) -> Result<DeliveryResponse> {
460        let path_and_query = self.inbox_query(options);
461        let url = format!("{}{}", trim_trailing_slash(&self.base_url), path_and_query);
462        let headers = self.sign("GET", &path_and_query, b"")?;
463        let mut req = ureq::get(&url);
464        for (k, v) in &headers {
465            req = req.set(k, v);
466        }
467        let text = read_response(req.call())?;
468        self.decrypt_response(&text)
469    }
470
471    /// Claim deliveries, reserving them until ack/release (signed POST).
472    pub fn claim(&self, options: &ListOptions) -> Result<DeliveryResponse> {
473        let mut body = serde_json::Map::new();
474        if let Some(limit) = options.limit {
475            body.insert("limit".into(), serde_json::json!(limit));
476        }
477        if options.poll {
478            body.insert("poll".into(), serde_json::json!(true));
479        }
480        let body = serde_json::Value::Object(body).to_string();
481        let path_and_query = format!("{}/claim", self.inbox_path());
482        let text = self.signed_post(&path_and_query, body.as_bytes())?;
483        self.decrypt_response(&text)
484    }
485
486    /// Acknowledge (consume) the given delivery ids.
487    pub fn ack(&self, ids: &[String]) -> Result<BatchResponse> {
488        self.post_ids("ack", ids)
489    }
490
491    /// Release the given delivery ids back to the queue.
492    pub fn release(&self, ids: &[String]) -> Result<BatchResponse> {
493        self.post_ids("release", ids)
494    }
495
496    fn post_ids(&self, action: &str, ids: &[String]) -> Result<BatchResponse> {
497        let body = serde_json::json!({ "ids": ids }).to_string();
498        let path_and_query = format!("{}/{}", self.inbox_path(), action);
499        let text = self.signed_post(&path_and_query, body.as_bytes())?;
500        serde_json::from_str(&text)
501            .map_err(|e| Error::Protocol(format!("invalid {action} response: {e}")))
502    }
503
504    fn signed_post(&self, path_and_query: &str, body: &[u8]) -> Result<String> {
505        let url = format!("{}{}", trim_trailing_slash(&self.base_url), path_and_query);
506        let headers = self.sign("POST", path_and_query, body)?;
507        let mut req = ureq::post(&url).set("content-type", "application/json");
508        for (k, v) in &headers {
509            req = req.set(k, v);
510        }
511        read_response(req.send_bytes(body))
512    }
513
514    /// Build the two owner-auth headers for a request.
515    ///
516    /// The `path_and_query` bytes signed here MUST equal the bytes sent on the
517    /// wire (protocol consistency rule).
518    fn sign(
519        &self,
520        method: &str,
521        path_and_query: &str,
522        body: &[u8],
523    ) -> Result<Vec<(String, String)>> {
524        let timestamp = SystemTime::now()
525            .duration_since(UNIX_EPOCH)
526            .map_err(|e| Error::Protocol(format!("clock error: {e}")))?
527            .as_secs();
528        let body_hash = b64u_encode(&Sha256::digest(body));
529        let message =
530            format!("{AUTH_VERSION}\n{method}\n{path_and_query}\n{timestamp}\n{body_hash}");
531        let sk = signing_key(&self.private_key)?;
532        let signature = sk.sign(message.as_bytes());
533        Ok(vec![
534            (AUTH_TIMESTAMP_HEADER.to_string(), timestamp.to_string()),
535            (
536                AUTH_SIGNATURE_HEADER.to_string(),
537                b64u_encode(&signature.to_bytes()),
538            ),
539        ])
540    }
541
542    fn decrypt_response(&self, text: &str) -> Result<DeliveryResponse> {
543        let raw: RawDeliveryResponse = serde_json::from_str(text)
544            .map_err(|e| Error::Protocol(format!("invalid delivery response: {e}")))?;
545        let mut requests = Vec::with_capacity(raw.items.len());
546        for envelope in &raw.items {
547            requests.push(self.decrypt_envelope(envelope)?);
548        }
549        Ok(DeliveryResponse {
550            count: raw.count,
551            requests,
552            cursor: raw.cursor,
553        })
554    }
555
556    fn decrypt_envelope(&self, envelope: &Envelope) -> Result<Delivery> {
557        let sealed = b64u_decode(&envelope.sealed)?;
558        if sealed.len() <= SEALED_BOX_PUBLIC_KEY_BYTES {
559            return Err(Error::Protocol("encrypted delivery is too short".into()));
560        }
561        let plaintext = self
562            .secret_key
563            .unseal(&sealed)
564            .map_err(|_| Error::Protocol("failed to decrypt delivery".into()))?;
565        let delivery = decode_captured_request(&plaintext)?;
566        if delivery.id != envelope.id {
567            return Err(Error::Protocol("delivery id mismatch".into()));
568        }
569        Ok(delivery)
570    }
571}
572
573fn decode_captured_request(plaintext: &[u8]) -> Result<Delivery> {
574    let raw: RawCapturedRequest = serde_json::from_slice(plaintext)
575        .map_err(|e| Error::Protocol(format!("invalid delivery payload: {e}")))?;
576    let body_bytes = b64u_decode(&raw.body_b64u)?;
577    let mut headers = Vec::with_capacity(raw.headers.len());
578    for h in &raw.headers {
579        let value_bytes = b64u_decode(&h.value_b64u)?;
580        let value = String::from_utf8_lossy(&value_bytes).into_owned();
581        headers.push(Header {
582            name: h.name.clone(),
583            value,
584            value_bytes,
585        });
586    }
587    Ok(Delivery {
588        id: raw.id,
589        received_at_unix_ms: raw.received_at_unix_ms,
590        method: raw.method,
591        path: raw.path,
592        query: raw.query,
593        headers,
594        body_bytes,
595    })
596}
597
598fn trim_trailing_slash(s: &str) -> &str {
599    s.strip_suffix('/').unwrap_or(s)
600}
601
602/// Percent-encode a single path segment (everything but unreserved).
603fn encode_path_segment(value: &str) -> String {
604    encode_query_value(value)
605}
606
607#[derive(Deserialize)]
608struct ErrorBody {
609    error: Option<String>,
610}
611
612/// Convert a ureq result into a body string, surfacing `{"error": ...}`.
613fn read_response(result: std::result::Result<ureq::Response, ureq::Error>) -> Result<String> {
614    match result {
615        Ok(resp) => resp
616            .into_string()
617            .map_err(|e| Error::Http(format!("failed to read response: {e}"))),
618        Err(ureq::Error::Status(code, resp)) => {
619            let body = resp.into_string().unwrap_or_default();
620            let message = serde_json::from_str::<ErrorBody>(&body)
621                .ok()
622                .and_then(|b| b.error)
623                .unwrap_or_else(|| format!("cc.me request failed with {code}"));
624            Err(Error::Http(message))
625        }
626        Err(ureq::Error::Transport(t)) => Err(Error::Http(format!("transport error: {t}"))),
627    }
628}
629
630#[cfg(test)]
631mod tests {
632    use super::*;
633    use crypto_box::aead::rand_core::{OsRng, TryRngCore};
634    use crypto_box::PublicKey;
635    use curve25519_dalek::edwards::CompressedEdwardsY;
636    use ed25519_dalek::VerifyingKey;
637
638    const SEED: [u8; 32] = [7u8; 32];
639
640    fn key_b64u() -> String {
641        b64u_encode(&SEED)
642    }
643
644    fn ed25519_pubkey_b64u() -> String {
645        let vk = SigningKey::from_bytes(&SEED).verifying_key();
646        b64u_encode(vk.as_bytes())
647    }
648
649    // Reproduces the server's seal path: derive the X25519 public key from the
650    // Ed25519 verifying key the same way `derive_x25519_public_key` does, then
651    // `PublicKey::seal`.
652    fn server_seal(plaintext: &[u8]) -> String {
653        server_seal_for(&SEED, plaintext)
654    }
655
656    fn server_seal_for(seed: &[u8; 32], plaintext: &[u8]) -> String {
657        let vk: VerifyingKey = SigningKey::from_bytes(seed).verifying_key();
658        let edwards = CompressedEdwardsY(vk.to_bytes()).decompress().unwrap();
659        let pk = PublicKey::from_slice(edwards.to_montgomery().as_bytes()).unwrap();
660        let sealed = pk.seal(&mut OsRng.unwrap_err(), plaintext).unwrap();
661        b64u_encode(&sealed)
662    }
663
664    /// Build a sealed envelope-response JSON for a single delivery payload.
665    fn sealed_response(id: &str, plaintext: &serde_json::Value) -> String {
666        let sealed = server_seal(plaintext.to_string().as_bytes());
667        serde_json::json!({
668            "count": 1,
669            "items": [{ "id": id, "sealed": sealed }],
670            "cursor": serde_json::Value::Null,
671        })
672        .to_string()
673    }
674
675    // ====================================================================
676    // base64url round-trips and no-pad behaviour
677    // ====================================================================
678
679    #[test]
680    fn b64u_roundtrip_arbitrary_bytes() {
681        for len in [0usize, 1, 2, 3, 4, 5, 16, 31, 32, 33, 100, 4096] {
682            let data: Vec<u8> = (0..len).map(|i| (i * 31 + 7) as u8).collect();
683            let encoded = b64u_encode(&data);
684            assert_eq!(b64u_decode(&encoded).unwrap(), data, "len {len}");
685        }
686    }
687
688    #[test]
689    fn b64u_has_no_padding() {
690        // 1, 2 input bytes would normally produce '=' padding in standard b64.
691        assert!(!b64u_encode(b"a").contains('='));
692        assert!(!b64u_encode(b"ab").contains('='));
693        assert!(!b64u_encode(b"abcde").contains('='));
694    }
695
696    #[test]
697    fn b64u_uses_url_safe_alphabet() {
698        // 0xFB 0xFF encodes to bytes that exercise the '+'/'/' -> '-'/'_' map.
699        let encoded = b64u_encode(&[0xfb, 0xff, 0xbf]);
700        assert!(!encoded.contains('+'));
701        assert!(!encoded.contains('/'));
702        assert_eq!(b64u_decode(&encoded).unwrap(), vec![0xfb, 0xff, 0xbf]);
703    }
704
705    #[test]
706    fn b64u_empty_is_empty_string() {
707        assert_eq!(b64u_encode(b""), "");
708        assert_eq!(b64u_decode("").unwrap(), Vec::<u8>::new());
709    }
710
711    #[test]
712    fn b64u_decode_trims_whitespace() {
713        let encoded = b64u_encode(b"trimmed");
714        let padded = format!("  {encoded}\n");
715        assert_eq!(b64u_decode(&padded).unwrap(), b"trimmed");
716    }
717
718    #[test]
719    fn b64u_decode_rejects_invalid() {
720        // '!' is not in the base64url alphabet.
721        assert!(b64u_decode("not valid!!").is_err());
722    }
723
724    // ====================================================================
725    // Keys
726    // ====================================================================
727
728    #[test]
729    fn in_memory_private_key_is_32_byte_seed() {
730        let key = private_key(None).unwrap();
731        let bytes = b64u_decode(&key).unwrap();
732        assert_eq!(bytes.len(), 32);
733        // Round-trips through validation.
734        assert_eq!(private_key_bytes(&key).unwrap().to_vec(), bytes);
735    }
736
737    #[test]
738    fn generated_keys_are_random() {
739        let a = private_key(None).unwrap();
740        let b = private_key(None).unwrap();
741        assert_ne!(a, b, "two generated keys should differ");
742    }
743
744    #[test]
745    fn private_key_bytes_rejects_wrong_length() {
746        // 31 bytes.
747        let short = b64u_encode(&[0u8; 31]);
748        assert!(matches!(
749            private_key_bytes(&short),
750            Err(Error::InvalidKey(_))
751        ));
752        // 33 bytes.
753        let long = b64u_encode(&[0u8; 33]);
754        assert!(matches!(
755            private_key_bytes(&long),
756            Err(Error::InvalidKey(_))
757        ));
758    }
759
760    #[test]
761    fn private_key_bytes_rejects_non_base64url() {
762        assert!(matches!(
763            private_key_bytes("definitely not base64!!"),
764            Err(Error::InvalidKey(_))
765        ));
766    }
767
768    #[test]
769    fn fixed_seed_has_deterministic_public_key() {
770        // The Ed25519 public key for the all-7s seed is stable across runs.
771        let expected = ed25519_pubkey_b64u();
772        assert_eq!(public_key_b64u(&key_b64u()).unwrap(), expected);
773        // 32 raw bytes once decoded.
774        assert_eq!(b64u_decode(&expected).unwrap().len(), 32);
775    }
776
777    #[test]
778    fn fixed_seed_has_deterministic_inbox_url() {
779        let client = CcMeClient::new(key_b64u(), Some("https://cc.me/")).unwrap();
780        assert_eq!(
781            client.inbox_url(&ListOptions::default()),
782            format!("https://cc.me/i/{}", ed25519_pubkey_b64u())
783        );
784    }
785
786    #[test]
787    fn private_key_file_has_trailing_newline() {
788        let dir = std::env::temp_dir().join(format!("cc-me-nl-{}", std::process::id()));
789        std::fs::create_dir_all(&dir).unwrap();
790        let path = dir.join("key");
791        let _ = std::fs::remove_file(&path);
792        let key = private_key(Some(&path)).unwrap();
793        let raw = std::fs::read_to_string(&path).unwrap();
794        assert_eq!(raw, format!("{key}\n"));
795        let _ = std::fs::remove_file(&path);
796    }
797
798    #[test]
799    #[cfg(unix)]
800    fn newly_created_key_file_is_0600() {
801        use std::os::unix::fs::PermissionsExt;
802        let dir = std::env::temp_dir().join(format!("cc-me-mode-{}", std::process::id()));
803        std::fs::create_dir_all(&dir).unwrap();
804        let path = dir.join("key");
805        let _ = std::fs::remove_file(&path);
806        private_key(Some(&path)).unwrap();
807        let mode = std::fs::metadata(&path).unwrap().permissions().mode();
808        assert_eq!(mode & 0o777, 0o600);
809        let _ = std::fs::remove_file(&path);
810    }
811
812    #[test]
813    #[cfg(unix)]
814    fn existing_key_file_mode_is_tightened_on_read() {
815        use std::os::unix::fs::PermissionsExt;
816        let dir = std::env::temp_dir().join(format!("cc-me-tighten-{}", std::process::id()));
817        std::fs::create_dir_all(&dir).unwrap();
818        let path = dir.join("key");
819        let _ = std::fs::remove_file(&path);
820        // Write a valid key with loose permissions.
821        std::fs::write(&path, format!("{}\n", key_b64u())).unwrap();
822        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
823        let reused = private_key(Some(&path)).unwrap();
824        assert_eq!(reused, key_b64u());
825        let mode = std::fs::metadata(&path).unwrap().permissions().mode();
826        assert_eq!(mode & 0o777, 0o600, "mode tightened to 0600");
827        let _ = std::fs::remove_file(&path);
828    }
829
830    #[test]
831    fn private_key_file_reused_on_second_call() {
832        let dir = std::env::temp_dir().join(format!("cc-me-reuse-{}", std::process::id()));
833        std::fs::create_dir_all(&dir).unwrap();
834        let path = dir.join("key");
835        let _ = std::fs::remove_file(&path);
836        let first = private_key(Some(&path)).unwrap();
837        let second = private_key(Some(&path)).unwrap();
838        let third = private_key(Some(&path)).unwrap();
839        assert_eq!(first, second);
840        assert_eq!(second, third);
841        let _ = std::fs::remove_file(&path);
842    }
843
844    #[test]
845    fn private_key_file_rejects_malformed_contents() {
846        let dir = std::env::temp_dir().join(format!("cc-me-bad-{}", std::process::id()));
847        std::fs::create_dir_all(&dir).unwrap();
848        let path = dir.join("key");
849        // Wrong length (not 32 bytes once decoded).
850        std::fs::write(&path, b64u_encode(b"too-short")).unwrap();
851        assert!(matches!(
852            private_key(Some(&path)),
853            Err(Error::InvalidKey(_))
854        ));
855        // Not base64url at all.
856        std::fs::write(&path, "this is not a key!!").unwrap();
857        assert!(matches!(
858            private_key(Some(&path)),
859            Err(Error::InvalidKey(_))
860        ));
861        let _ = std::fs::remove_file(&path);
862    }
863
864    #[test]
865    fn client_new_rejects_bad_key() {
866        assert!(matches!(
867            CcMeClient::new("nope!!".into(), None),
868            Err(Error::InvalidKey(_))
869        ));
870    }
871
872    // ====================================================================
873    // Signing
874    // ====================================================================
875
876    #[test]
877    fn canonical_string_format_for_get() {
878        let client = CcMeClient::new(key_b64u(), None).unwrap();
879        let headers = client.sign("GET", "/i/KEY?l=10&p=", b"").unwrap();
880        let ts: u64 = headers[0].1.parse().unwrap();
881        // Empty body hash is SHA256 of zero bytes.
882        let empty_hash = b64u_encode(&Sha256::digest(b""));
883        let message = format!("cc-me-v1\nGET\n/i/KEY?l=10&p=\n{ts}\n{empty_hash}");
884        let vk = SigningKey::from_bytes(&SEED).verifying_key();
885        let sig =
886            ed25519_dalek::Signature::from_slice(&b64u_decode(&headers[1].1).unwrap()).unwrap();
887        use ed25519_dalek::Verifier;
888        vk.verify(message.as_bytes(), &sig).expect("verifies");
889    }
890
891    #[test]
892    fn empty_body_hash_is_sha256_of_empty() {
893        let client = CcMeClient::new(key_b64u(), None).unwrap();
894        let headers = client.sign("GET", "/x", b"").unwrap();
895        let ts: u64 = headers[0].1.parse().unwrap();
896        // sha256("") = e3b0c442... ; base64url-no-pad form:
897        let empty_hash = b64u_encode(&Sha256::digest(b""));
898        assert_eq!(empty_hash, "47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU");
899        let message = format!("cc-me-v1\nGET\n/x\n{ts}\n{empty_hash}");
900        let vk = SigningKey::from_bytes(&SEED).verifying_key();
901        let sig =
902            ed25519_dalek::Signature::from_slice(&b64u_decode(&headers[1].1).unwrap()).unwrap();
903        use ed25519_dalek::Verifier;
904        vk.verify(message.as_bytes(), &sig).unwrap();
905    }
906
907    #[test]
908    fn signature_headers_have_expected_names() {
909        let client = CcMeClient::new(key_b64u(), None).unwrap();
910        let headers = client.sign("POST", "/y", b"body").unwrap();
911        assert_eq!(headers[0].0, "x-cc-me-timestamp");
912        assert_eq!(headers[1].0, "x-cc-me-signature");
913        // Signature is base64url-no-pad of 64 bytes.
914        let sig_bytes = b64u_decode(&headers[1].1).unwrap();
915        assert_eq!(sig_bytes.len(), 64);
916    }
917
918    #[test]
919    fn signature_changes_with_body() {
920        let client = CcMeClient::new(key_b64u(), None).unwrap();
921        // Pin the same timestamp by recomputing manually so only the body differs.
922        let body_hash_a = b64u_encode(&Sha256::digest(b"a"));
923        let body_hash_b = b64u_encode(&Sha256::digest(b"b"));
924        assert_ne!(body_hash_a, body_hash_b);
925        // And the produced signature differs across distinct bodies.
926        let ha = client.sign("POST", "/p", b"a").unwrap();
927        let hb = client.sign("POST", "/p", b"b").unwrap();
928        // With overwhelming probability ts is equal (same second); even if not,
929        // the messages differ, so signatures differ.
930        if ha[0].1 == hb[0].1 {
931            assert_ne!(ha[1].1, hb[1].1);
932        }
933    }
934
935    #[test]
936    fn signed_path_with_query_equals_requested() {
937        // inbox_query builds the exact bytes used for both signing and the wire.
938        let client = CcMeClient::new(key_b64u(), None).unwrap();
939        let opts = ListOptions {
940            limit: Some(7),
941            cursor: Some("c1".into()),
942            poll: true,
943        };
944        let pq = client.inbox_query(&opts);
945        assert_eq!(pq, format!("/i/{}?l=7&c=c1&p=", ed25519_pubkey_b64u()));
946        // The inbox_url is base + that same path+query.
947        assert_eq!(
948            client.inbox_url(&opts),
949            format!("https://cc.me/i/{}?l=7&c=c1&p=", ed25519_pubkey_b64u())
950        );
951    }
952
953    // ====================================================================
954    // URL builders
955    // ====================================================================
956
957    #[test]
958    fn trampoline_default_base() {
959        let url = trampoline_url("https://x/cb", None, &[]);
960        assert_eq!(url, "https://cc.me/?at=https%3A%2F%2Fx%2Fcb");
961    }
962
963    #[test]
964    fn trampoline_base_override_without_trailing_slash() {
965        let url = trampoline_url("t", Some("https://alt.example"), &[]);
966        assert_eq!(url, "https://alt.example/?at=t");
967    }
968
969    #[test]
970    fn trampoline_params_in_order() {
971        let url = trampoline_url(
972            "t",
973            Some("https://cc.me/"),
974            &[("a", "1"), ("b", "2"), ("c", "3")],
975        );
976        assert_eq!(url, "https://cc.me/?at=t&a=1&b=2&c=3");
977    }
978
979    #[test]
980    fn encode_query_value_leaves_unreserved() {
981        assert_eq!(encode_query_value("AZaz09-_.~"), "AZaz09-_.~");
982    }
983
984    #[test]
985    fn encode_query_value_percent_encodes_reserved() {
986        assert_eq!(encode_query_value("a b&c=d?/"), "a%20b%26c%3Dd%3F%2F");
987        assert_eq!(encode_query_value("/"), "%2F");
988    }
989
990    #[test]
991    fn inbox_url_param_order_l_c_p() {
992        let client = CcMeClient::new(key_b64u(), Some("https://cc.me/")).unwrap();
993        let pk = ed25519_pubkey_b64u();
994        assert_eq!(
995            client.inbox_url(&ListOptions {
996                limit: Some(3),
997                cursor: Some("cur".into()),
998                poll: true,
999            }),
1000            format!("https://cc.me/i/{pk}?l=3&c=cur&p=")
1001        );
1002        // Cursor only.
1003        assert_eq!(
1004            client.inbox_url(&ListOptions {
1005                cursor: Some("c".into()),
1006                ..Default::default()
1007            }),
1008            format!("https://cc.me/i/{pk}?c=c")
1009        );
1010        // Poll only -> empty value.
1011        assert_eq!(
1012            client.inbox_url(&ListOptions {
1013                poll: true,
1014                ..Default::default()
1015            }),
1016            format!("https://cc.me/i/{pk}?p=")
1017        );
1018        // Limit only.
1019        assert_eq!(
1020            client.inbox_url(&ListOptions {
1021                limit: Some(1),
1022                ..Default::default()
1023            }),
1024            format!("https://cc.me/i/{pk}?l=1")
1025        );
1026    }
1027
1028    #[test]
1029    fn inbox_url_encodes_cursor_value() {
1030        let client = CcMeClient::new(key_b64u(), Some("https://cc.me/")).unwrap();
1031        let pk = ed25519_pubkey_b64u();
1032        assert_eq!(
1033            client.inbox_url(&ListOptions {
1034                cursor: Some("a b".into()),
1035                ..Default::default()
1036            }),
1037            format!("https://cc.me/i/{pk}?c=a%20b")
1038        );
1039    }
1040
1041    #[test]
1042    fn all_protocol_urls() {
1043        let client = CcMeClient::new(key_b64u(), Some("https://cc.me/")).unwrap();
1044        let pk = ed25519_pubkey_b64u();
1045        assert_eq!(
1046            client.webmention_url(),
1047            format!("https://cc.me/i/{pk}/webmention")
1048        );
1049        assert_eq!(client.websub_url(), format!("https://cc.me/i/{pk}/websub"));
1050        assert_eq!(client.slack_url(), format!("https://cc.me/i/{pk}/slack"));
1051        assert_eq!(
1052            client.pingback_url(),
1053            format!("https://cc.me/i/{pk}/pingback")
1054        );
1055        assert_eq!(
1056            client.cloud_events_url(),
1057            format!("https://cc.me/i/{pk}/cloudevents")
1058        );
1059        assert_eq!(client.meta_url(None), format!("https://cc.me/i/{pk}/meta"));
1060    }
1061
1062    #[test]
1063    fn meta_url_with_and_without_token() {
1064        let client = CcMeClient::new(key_b64u(), Some("https://cc.me/")).unwrap();
1065        let pk = ed25519_pubkey_b64u();
1066        assert_eq!(client.meta_url(None), format!("https://cc.me/i/{pk}/meta"));
1067        assert_eq!(
1068            client.meta_url(Some("tok")),
1069            format!("https://cc.me/i/{pk}/meta?v=tok")
1070        );
1071        assert_eq!(
1072            client.meta_url(Some("a b/c")),
1073            format!("https://cc.me/i/{pk}/meta?v=a%20b%2Fc")
1074        );
1075    }
1076
1077    #[test]
1078    fn discord_url_path_and_encoding() {
1079        let client = CcMeClient::new(key_b64u(), Some("https://cc.me/")).unwrap();
1080        let pk = ed25519_pubkey_b64u();
1081        assert_eq!(
1082            client.discord_url("app"),
1083            format!("https://cc.me/i/{pk}/discord/app")
1084        );
1085        assert_eq!(
1086            client.discord_url("a/b"),
1087            format!("https://cc.me/i/{pk}/discord/a%2Fb")
1088        );
1089    }
1090
1091    #[test]
1092    fn base_url_normalisation_adds_trailing_slash() {
1093        let with = CcMeClient::new(key_b64u(), Some("https://cc.me")).unwrap();
1094        let without = CcMeClient::new(key_b64u(), Some("https://cc.me/")).unwrap();
1095        assert_eq!(
1096            with.inbox_url(&ListOptions::default()),
1097            without.inbox_url(&ListOptions::default())
1098        );
1099    }
1100
1101    #[test]
1102    fn default_base_url_constant() {
1103        assert_eq!(DEFAULT_BASE_URL, "https://cc.me/");
1104        let client = CcMeClient::new(key_b64u(), None).unwrap();
1105        assert!(client
1106            .inbox_url(&ListOptions::default())
1107            .starts_with("https://cc.me/i/"));
1108    }
1109
1110    // ====================================================================
1111    // Sealed-box decryption variants
1112    // ====================================================================
1113
1114    #[test]
1115    fn decrypts_empty_body() {
1116        let id = "m_empty";
1117        let payload = serde_json::json!({
1118            "id": id,
1119            "received_at_unix_ms": 1u64,
1120            "method": "GET",
1121            "path": "/i/x",
1122            "query": serde_json::Value::Null,
1123            "headers": [],
1124            "body_b64u": "",
1125        });
1126        let client = CcMeClient::new(key_b64u(), None).unwrap();
1127        let resp = client
1128            .decrypt_response(&sealed_response(id, &payload))
1129            .unwrap();
1130        let d = &resp.requests[0];
1131        assert!(d.body_bytes.is_empty());
1132        assert_eq!(d.text(), "");
1133        assert!(d.query.is_none());
1134        assert!(d.headers.is_empty());
1135    }
1136
1137    #[test]
1138    fn decrypts_query_none_vs_some() {
1139        let client = CcMeClient::new(key_b64u(), None).unwrap();
1140        // query absent entirely.
1141        let no_query = serde_json::json!({
1142            "id": "m_a", "received_at_unix_ms": 1u64, "method": "GET",
1143            "path": "/p", "headers": [], "body_b64u": "",
1144        });
1145        let d = &client
1146            .decrypt_response(&sealed_response("m_a", &no_query))
1147            .unwrap()
1148            .requests[0];
1149        assert_eq!(d.query, None);
1150
1151        // query present.
1152        let with_query = serde_json::json!({
1153            "id": "m_b", "received_at_unix_ms": 1u64, "method": "GET",
1154            "path": "/p", "query": "x=1", "headers": [], "body_b64u": "",
1155        });
1156        let d = &client
1157            .decrypt_response(&sealed_response("m_b", &with_query))
1158            .unwrap()
1159            .requests[0];
1160        assert_eq!(d.query.as_deref(), Some("x=1"));
1161    }
1162
1163    #[test]
1164    fn decrypts_various_body_sizes() {
1165        let client = CcMeClient::new(key_b64u(), None).unwrap();
1166        for len in [0usize, 1, 16, 1024, 4096, 9000] {
1167            let body: Vec<u8> = (0..len).map(|i| (i % 251) as u8).collect();
1168            let id = format!("m_{len}");
1169            let payload = serde_json::json!({
1170                "id": id, "received_at_unix_ms": 1u64, "method": "POST",
1171                "path": "/p", "headers": [], "body_b64u": b64u_encode(&body),
1172            });
1173            let resp = client
1174                .decrypt_response(&sealed_response(&id, &payload))
1175                .unwrap();
1176            assert_eq!(resp.requests[0].body_bytes, body, "len {len}");
1177        }
1178    }
1179
1180    #[test]
1181    fn decrypts_many_headers_with_value_and_value_bytes() {
1182        let client = CcMeClient::new(key_b64u(), None).unwrap();
1183        let mut headers = Vec::new();
1184        for i in 0..25 {
1185            headers.push(serde_json::json!({
1186                "name": format!("x-h{i}"),
1187                "value_b64u": b64u_encode(format!("v{i}").as_bytes()),
1188            }));
1189        }
1190        let payload = serde_json::json!({
1191            "id": "m_h", "received_at_unix_ms": 1u64, "method": "POST",
1192            "path": "/p", "headers": headers, "body_b64u": "",
1193        });
1194        let resp = client
1195            .decrypt_response(&sealed_response("m_h", &payload))
1196            .unwrap();
1197        let d = &resp.requests[0];
1198        assert_eq!(d.headers.len(), 25);
1199        for (i, h) in d.headers.iter().enumerate() {
1200            assert_eq!(h.name, format!("x-h{i}"));
1201            assert_eq!(h.value, format!("v{i}"));
1202            assert_eq!(h.value_bytes, format!("v{i}").into_bytes());
1203        }
1204    }
1205
1206    #[test]
1207    fn decrypts_non_utf8_header_value_lossily() {
1208        let client = CcMeClient::new(key_b64u(), None).unwrap();
1209        let raw = vec![0xff, 0xfe, 0x41];
1210        let payload = serde_json::json!({
1211            "id": "m_nb", "received_at_unix_ms": 1u64, "method": "GET", "path": "/p",
1212            "headers": [{"name": "x-bin", "value_b64u": b64u_encode(&raw)}],
1213            "body_b64u": "",
1214        });
1215        let resp = client
1216            .decrypt_response(&sealed_response("m_nb", &payload))
1217            .unwrap();
1218        let h = &resp.requests[0].headers[0];
1219        assert_eq!(h.value_bytes, raw);
1220        // value is lossy UTF-8 and still ends with the valid 'A'.
1221        assert!(h.value.ends_with('A'));
1222    }
1223
1224    #[test]
1225    fn json_helper_parses_body() {
1226        let client = CcMeClient::new(key_b64u(), None).unwrap();
1227        let payload = serde_json::json!({
1228            "id": "m_j", "received_at_unix_ms": 1u64, "method": "POST", "path": "/p",
1229            "headers": [], "body_b64u": b64u_encode(br#"{"k":[1,2,3]}"#),
1230        });
1231        let resp = client
1232            .decrypt_response(&sealed_response("m_j", &payload))
1233            .unwrap();
1234        assert_eq!(resp.requests[0].json().unwrap()["k"][1], 2);
1235    }
1236
1237    #[test]
1238    fn json_helper_errors_on_non_json_body() {
1239        let client = CcMeClient::new(key_b64u(), None).unwrap();
1240        let payload = serde_json::json!({
1241            "id": "m_nj", "received_at_unix_ms": 1u64, "method": "POST", "path": "/p",
1242            "headers": [], "body_b64u": b64u_encode(b"not json"),
1243        });
1244        let resp = client
1245            .decrypt_response(&sealed_response("m_nj", &payload))
1246            .unwrap();
1247        assert!(matches!(resp.requests[0].json(), Err(Error::Protocol(_))));
1248    }
1249
1250    #[test]
1251    fn too_short_ciphertext_errors() {
1252        // Sealed box must be longer than the 32-byte ephemeral public key.
1253        let response = serde_json::json!({
1254            "count": 1,
1255            "items": [{ "id": "m_short", "sealed": b64u_encode(&[0u8; 16]) }],
1256        })
1257        .to_string();
1258        let client = CcMeClient::new(key_b64u(), None).unwrap();
1259        let err = client.decrypt_response(&response).unwrap_err();
1260        assert!(matches!(err, Error::Protocol(m) if m.contains("too short")));
1261    }
1262
1263    #[test]
1264    fn exactly_32_byte_ciphertext_errors() {
1265        let response = serde_json::json!({
1266            "count": 1,
1267            "items": [{ "id": "m_32", "sealed": b64u_encode(&[0u8; 32]) }],
1268        })
1269        .to_string();
1270        let client = CcMeClient::new(key_b64u(), None).unwrap();
1271        let err = client.decrypt_response(&response).unwrap_err();
1272        assert!(matches!(err, Error::Protocol(m) if m.contains("too short")));
1273    }
1274
1275    #[test]
1276    fn undecryptable_ciphertext_errors() {
1277        // 33+ bytes of garbage: passes the length check but fails to unseal.
1278        let response = serde_json::json!({
1279            "count": 1,
1280            "items": [{ "id": "m_g", "sealed": b64u_encode(&[3u8; 80]) }],
1281        })
1282        .to_string();
1283        let client = CcMeClient::new(key_b64u(), None).unwrap();
1284        let err = client.decrypt_response(&response).unwrap_err();
1285        assert!(matches!(err, Error::Protocol(m) if m.contains("decrypt")));
1286    }
1287
1288    #[test]
1289    fn ciphertext_for_wrong_recipient_fails_to_decrypt() {
1290        // Seal to a different identity; our client must not be able to open it.
1291        let other_seed = [42u8; 32];
1292        let payload = serde_json::json!({
1293            "id": "m_w", "received_at_unix_ms": 1u64, "method": "GET", "path": "/p",
1294            "headers": [], "body_b64u": "",
1295        })
1296        .to_string();
1297        let sealed = server_seal_for(&other_seed, payload.as_bytes());
1298        let response = serde_json::json!({
1299            "count": 1, "items": [{ "id": "m_w", "sealed": sealed }],
1300        })
1301        .to_string();
1302        let client = CcMeClient::new(key_b64u(), None).unwrap();
1303        assert!(client.decrypt_response(&response).is_err());
1304    }
1305
1306    #[test]
1307    fn decrypts_multiple_deliveries() {
1308        let client = CcMeClient::new(key_b64u(), None).unwrap();
1309        let mut items = Vec::new();
1310        for i in 0..3 {
1311            let id = format!("m_{i}");
1312            let payload = serde_json::json!({
1313                "id": id, "received_at_unix_ms": (i as u64), "method": "GET",
1314                "path": format!("/p/{i}"), "headers": [],
1315                "body_b64u": b64u_encode(format!("body{i}").as_bytes()),
1316            })
1317            .to_string();
1318            items.push(serde_json::json!({ "id": id, "sealed": server_seal(payload.as_bytes()) }));
1319        }
1320        let response = serde_json::json!({ "count": 3, "items": items }).to_string();
1321        let resp = client.decrypt_response(&response).unwrap();
1322        assert_eq!(resp.requests.len(), 3);
1323        for (i, d) in resp.requests.iter().enumerate() {
1324            assert_eq!(d.id, format!("m_{i}"));
1325            assert_eq!(d.text(), format!("body{i}"));
1326        }
1327    }
1328
1329    #[test]
1330    fn empty_delivery_response_decodes() {
1331        let client = CcMeClient::new(key_b64u(), None).unwrap();
1332        let resp = client
1333            .decrypt_response(r#"{"count":0,"items":[],"cursor":null}"#)
1334            .unwrap();
1335        assert_eq!(resp.count, 0);
1336        assert!(resp.requests.is_empty());
1337        assert!(resp.cursor.is_none());
1338    }
1339
1340    #[test]
1341    fn malformed_delivery_response_errors() {
1342        let client = CcMeClient::new(key_b64u(), None).unwrap();
1343        assert!(matches!(
1344            client.decrypt_response("not json"),
1345            Err(Error::Protocol(_))
1346        ));
1347    }
1348
1349    #[test]
1350    fn batch_response_defaults_missing_fields() {
1351        let r: BatchResponse = serde_json::from_str("{}").unwrap();
1352        assert_eq!(r.acked, 0);
1353        assert_eq!(r.released, 0);
1354        assert!(r.missing.is_empty());
1355    }
1356
1357    #[test]
1358    fn error_display_passes_through_http_and_protocol() {
1359        assert_eq!(Error::Http("boom".into()).to_string(), "boom");
1360        assert_eq!(Error::Protocol("oops".into()).to_string(), "oops");
1361        assert!(Error::InvalidKey("k".into())
1362            .to_string()
1363            .contains("invalid key"));
1364    }
1365
1366    #[test]
1367    fn decrypts_a_server_sealed_delivery() {
1368        let id = "m_test123";
1369        let pubkey = ed25519_pubkey_b64u();
1370        let plaintext = serde_json::json!({
1371            "id": id,
1372            "received_at_unix_ms": 1781337600000u64,
1373            "method": "POST",
1374            "path": format!("/i/{pubkey}/slack"),
1375            "query": "a=1&b=2",
1376            "headers": [
1377                {"name": "content-type", "value_b64u": b64u_encode(b"application/json")}
1378            ],
1379            "body_b64u": b64u_encode(b"{\"hello\":\"world\"}"),
1380        })
1381        .to_string();
1382        let sealed = server_seal(plaintext.as_bytes());
1383
1384        let response = serde_json::json!({
1385            "count": 1,
1386            "items": [{ "id": id, "sealed": sealed }],
1387            "cursor": serde_json::Value::Null,
1388        })
1389        .to_string();
1390
1391        let client = CcMeClient::new(key_b64u(), Some("https://cc.me/")).unwrap();
1392        let decoded = client.decrypt_response(&response).unwrap();
1393        assert_eq!(decoded.count, 1);
1394        assert_eq!(decoded.requests.len(), 1);
1395        let d = &decoded.requests[0];
1396        assert_eq!(d.id, id);
1397        assert_eq!(d.method, "POST");
1398        assert_eq!(d.query.as_deref(), Some("a=1&b=2"));
1399        assert_eq!(d.text(), "{\"hello\":\"world\"}");
1400        assert_eq!(d.headers[0].name, "content-type");
1401        assert_eq!(d.headers[0].value, "application/json");
1402        assert_eq!(d.json().unwrap()["hello"], "world");
1403    }
1404
1405    #[test]
1406    fn rejects_id_mismatch() {
1407        let plaintext = serde_json::json!({
1408            "id": "m_real",
1409            "received_at_unix_ms": 1u64,
1410            "method": "GET",
1411            "path": "/i/x",
1412            "query": serde_json::Value::Null,
1413            "headers": [],
1414            "body_b64u": "",
1415        })
1416        .to_string();
1417        let sealed = server_seal(plaintext.as_bytes());
1418        let response = serde_json::json!({
1419            "count": 1,
1420            "items": [{ "id": "m_envelope", "sealed": sealed }],
1421        })
1422        .to_string();
1423        let client = CcMeClient::new(key_b64u(), None).unwrap();
1424        let err = client.decrypt_response(&response).unwrap_err();
1425        assert!(matches!(err, Error::Protocol(m) if m.contains("id mismatch")));
1426    }
1427
1428    #[test]
1429    fn signs_with_canonical_string() {
1430        let client = CcMeClient::new(key_b64u(), None).unwrap();
1431        let headers = client.sign("POST", "/i/KEY/claim", b"{}").unwrap();
1432        let ts: u64 = headers[0].1.parse().unwrap();
1433        let sig_b64u = &headers[1].1;
1434        let body_hash = b64u_encode(&Sha256::digest(b"{}"));
1435        let message = format!("cc-me-v1\nPOST\n/i/KEY/claim\n{ts}\n{body_hash}");
1436        // Verify the signature with the Ed25519 verifying key.
1437        let vk = SigningKey::from_bytes(&SEED).verifying_key();
1438        let sig_bytes = b64u_decode(sig_b64u).unwrap();
1439        let sig = ed25519_dalek::Signature::from_slice(&sig_bytes).expect("valid signature length");
1440        use ed25519_dalek::Verifier;
1441        vk.verify(message.as_bytes(), &sig)
1442            .expect("signature verifies");
1443    }
1444
1445    #[test]
1446    fn builds_urls() {
1447        let client = CcMeClient::new(key_b64u(), Some("https://cc.me/")).unwrap();
1448        let pk = ed25519_pubkey_b64u();
1449        assert_eq!(
1450            client.inbox_url(&ListOptions::default()),
1451            format!("https://cc.me/i/{pk}")
1452        );
1453        assert_eq!(
1454            client.inbox_url(&ListOptions {
1455                limit: Some(10),
1456                poll: true,
1457                ..Default::default()
1458            }),
1459            format!("https://cc.me/i/{pk}?l=10&p=")
1460        );
1461        assert_eq!(
1462            client.webmention_url(),
1463            format!("https://cc.me/i/{pk}/webmention")
1464        );
1465        assert_eq!(
1466            client.meta_url(Some("tok en")),
1467            format!("https://cc.me/i/{pk}/meta?v=tok%20en")
1468        );
1469        assert_eq!(
1470            client.discord_url("app123"),
1471            format!("https://cc.me/i/{pk}/discord/app123")
1472        );
1473    }
1474
1475    #[test]
1476    fn trampoline_encodes_target() {
1477        assert_eq!(
1478            trampoline_url(
1479                "https://x/cb?a=1",
1480                Some("https://cc.me/"),
1481                &[("state", "s 1")]
1482            ),
1483            "https://cc.me/?at=https%3A%2F%2Fx%2Fcb%3Fa%3D1&state=s%201"
1484        );
1485    }
1486
1487    #[test]
1488    fn private_key_roundtrips_through_file() {
1489        let dir = std::env::temp_dir().join(format!("cc-me-test-{}", std::process::id()));
1490        std::fs::create_dir_all(&dir).unwrap();
1491        let path = dir.join("key");
1492        let _ = std::fs::remove_file(&path);
1493        let created = private_key(Some(&path)).unwrap();
1494        let reused = private_key(Some(&path)).unwrap();
1495        assert_eq!(created, reused);
1496        private_key_bytes(&created).unwrap();
1497        #[cfg(unix)]
1498        {
1499            use std::os::unix::fs::PermissionsExt;
1500            let mode = std::fs::metadata(&path).unwrap().permissions().mode();
1501            assert_eq!(mode & 0o777, 0o600);
1502        }
1503        let _ = std::fs::remove_file(&path);
1504    }
1505}