1use 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
21pub 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#[derive(Debug)]
31pub enum Error {
32 Io(std::io::Error),
34 InvalidKey(String),
36 Http(String),
38 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
61pub 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
74fn 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
88fn public_key_b64u(key: &str) -> Result<String> {
91 let sk = signing_key(key)?;
92 Ok(b64u_encode(sk.verifying_key().as_bytes()))
93}
94
95fn 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
106fn 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
114pub 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 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
183fn 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
201pub 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
221pub 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#[derive(Debug, Default, Clone)]
238pub struct ListOptions {
239 pub limit: Option<u32>,
241 pub cursor: Option<String>,
243 pub poll: bool,
245}
246
247#[derive(Debug, Clone)]
249pub struct Header {
250 pub name: String,
252 pub value: String,
254 pub value_bytes: Vec<u8>,
256}
257
258#[derive(Debug, Clone)]
260pub struct Delivery {
261 pub id: String,
263 pub received_at_unix_ms: u128,
265 pub method: String,
267 pub path: String,
269 pub query: Option<String>,
271 pub headers: Vec<Header>,
273 pub body_bytes: Vec<u8>,
275}
276
277impl Delivery {
278 pub fn text(&self) -> String {
280 String::from_utf8_lossy(&self.body_bytes).into_owned()
281 }
282
283 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#[derive(Debug, Clone)]
292pub struct DeliveryResponse {
293 pub count: u64,
295 pub requests: Vec<Delivery>,
297 pub cursor: Option<String>,
299}
300
301#[derive(Debug, Clone, Deserialize)]
303pub struct BatchResponse {
304 #[serde(default)]
306 pub acked: u64,
307 #[serde(default)]
309 pub released: u64,
310 #[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
349pub struct CcMeClient {
351 base_url: String,
352 private_key: String,
353 public_key: String,
354 secret_key: SecretKey,
355}
356
357impl CcMeClient {
358 pub fn new(private_key: String, base_url: Option<&str>) -> Result<Self> {
360 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 fn inbox_path(&self) -> String {
373 format!("/i/{}", self.public_key)
374 }
375
376 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 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(¶ms.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 pub fn webmention_url(&self) -> String {
416 self.protocol_url("webmention")
417 }
418
419 pub fn websub_url(&self) -> String {
421 self.protocol_url("websub")
422 }
423
424 pub fn slack_url(&self) -> String {
426 self.protocol_url("slack")
427 }
428
429 pub fn pingback_url(&self) -> String {
431 self.protocol_url("pingback")
432 }
433
434 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 pub fn cloud_events_url(&self) -> String {
445 self.protocol_url("cloudevents")
446 }
447
448 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 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 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 pub fn ack(&self, ids: &[String]) -> Result<BatchResponse> {
488 self.post_ids("ack", ids)
489 }
490
491 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 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
602fn encode_path_segment(value: &str) -> String {
604 encode_query_value(value)
605}
606
607#[derive(Deserialize)]
608struct ErrorBody {
609 error: Option<String>,
610}
611
612fn 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 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 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 #[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 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 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 assert!(b64u_decode("not valid!!").is_err());
722 }
723
724 #[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 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 let short = b64u_encode(&[0u8; 31]);
748 assert!(matches!(
749 private_key_bytes(&short),
750 Err(Error::InvalidKey(_))
751 ));
752 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 let expected = ed25519_pubkey_b64u();
772 assert_eq!(public_key_b64u(&key_b64u()).unwrap(), expected);
773 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 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 std::fs::write(&path, b64u_encode(b"too-short")).unwrap();
851 assert!(matches!(
852 private_key(Some(&path)),
853 Err(Error::InvalidKey(_))
854 ));
855 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 #[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 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 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 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 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 let ha = client.sign("POST", "/p", b"a").unwrap();
927 let hb = client.sign("POST", "/p", b"b").unwrap();
928 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 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 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 #[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 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 assert_eq!(
1012 client.inbox_url(&ListOptions {
1013 poll: true,
1014 ..Default::default()
1015 }),
1016 format!("https://cc.me/i/{pk}?p=")
1017 );
1018 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 #[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 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 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 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 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 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 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 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}