clawdentity_core/pairing/
qr.rs1use std::fs;
2use std::path::{Path, PathBuf};
3
4use image::Luma;
5use qrcode::QrCode;
6
7use crate::error::{CoreError, Result};
8
9pub const PAIRING_QR_DIR_NAME: &str = "pairing";
10pub const PAIRING_QR_MAX_AGE_SECONDS: i64 = 900;
11
12fn parse_qr_issued_at_seconds(file_name: &str) -> Option<i64> {
13 let without_ext = file_name.strip_suffix(".png")?;
14 let (_, maybe_seconds) = without_ext.rsplit_once("-pair-")?;
15 maybe_seconds.parse::<i64>().ok()
16}
17
18pub fn encode_ticket_qr_png(ticket: &str) -> Result<Vec<u8>> {
20 let ticket = ticket.trim();
21 if ticket.is_empty() {
22 return Err(CoreError::InvalidInput(
23 "pairing ticket is required".to_string(),
24 ));
25 }
26 let code = QrCode::new(ticket.as_bytes())
27 .map_err(|error| CoreError::InvalidInput(error.to_string()))?;
28 let image = code.render::<Luma<u8>>().max_dimensions(512, 512).build();
29 let mut bytes = Vec::<u8>::new();
30 image::DynamicImage::ImageLuma8(image)
31 .write_to(
32 &mut std::io::Cursor::new(&mut bytes),
33 image::ImageFormat::Png,
34 )
35 .map_err(|error| CoreError::InvalidInput(error.to_string()))?;
36 Ok(bytes)
37}
38
39pub fn decode_ticket_from_png(image_bytes: &[u8]) -> Result<String> {
41 let image = image::load_from_memory(image_bytes)
42 .map_err(|error| CoreError::InvalidInput(error.to_string()))?;
43 let luma = image.to_luma8();
44 let mut decoder = quircs::Quirc::default();
45 let codes = decoder.identify(luma.width() as usize, luma.height() as usize, luma.as_raw());
46
47 for code in codes {
48 let Ok(code) = code else {
49 continue;
50 };
51 let Ok(decoded) = code.decode() else {
52 continue;
53 };
54 let text = String::from_utf8(decoded.payload)
55 .map_err(|error| CoreError::InvalidInput(error.to_string()))?;
56 let trimmed = text.trim();
57 if !trimmed.is_empty() {
58 return Ok(trimmed.to_string());
59 }
60 }
61
62 Err(CoreError::InvalidInput(
63 "no pairing QR code found in image".to_string(),
64 ))
65}
66
67#[allow(clippy::too_many_lines)]
69pub fn persist_pairing_qr(
70 config_dir: &Path,
71 agent_name: &str,
72 ticket: &str,
73 qr_output: Option<&Path>,
74 now_unix_seconds: i64,
75) -> Result<PathBuf> {
76 let ticket = ticket.trim();
77 if ticket.is_empty() {
78 return Err(CoreError::InvalidInput(
79 "pairing ticket is required".to_string(),
80 ));
81 }
82
83 let base_dir = config_dir.join(PAIRING_QR_DIR_NAME);
84 if base_dir.exists() {
85 for entry in fs::read_dir(&base_dir).map_err(|source| CoreError::Io {
86 path: base_dir.clone(),
87 source,
88 })? {
89 let entry = entry.map_err(|source| CoreError::Io {
90 path: base_dir.clone(),
91 source,
92 })?;
93 let path = entry.path();
94 if !path.is_file() {
95 continue;
96 }
97 let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else {
98 continue;
99 };
100 let Some(issued_at) = parse_qr_issued_at_seconds(file_name) else {
101 continue;
102 };
103 if issued_at + PAIRING_QR_MAX_AGE_SECONDS > now_unix_seconds {
104 continue;
105 }
106 let _ = fs::remove_file(&path);
107 }
108 }
109
110 let output_path = match qr_output {
111 Some(path) => path.to_path_buf(),
112 None => base_dir.join(format!("{agent_name}-pair-{now_unix_seconds}.png")),
113 };
114
115 if let Some(parent) = output_path.parent() {
116 fs::create_dir_all(parent).map_err(|source| CoreError::Io {
117 path: parent.to_path_buf(),
118 source,
119 })?;
120 }
121
122 let bytes = encode_ticket_qr_png(ticket)?;
123 fs::write(&output_path, bytes).map_err(|source| CoreError::Io {
124 path: output_path.clone(),
125 source,
126 })?;
127
128 Ok(output_path)
129}
130
131#[cfg(test)]
132mod tests {
133 use tempfile::TempDir;
134
135 use super::{decode_ticket_from_png, encode_ticket_qr_png, persist_pairing_qr};
136
137 #[test]
138 fn encode_and_decode_round_trip() {
139 let ticket = "clwpair1_dGVzdA";
140 let png = encode_ticket_qr_png(ticket).expect("encode");
141 let decoded = decode_ticket_from_png(&png).expect("decode");
142 assert_eq!(decoded, ticket);
143 }
144
145 #[test]
146 fn persist_pairing_qr_writes_png() {
147 let temp = TempDir::new().expect("temp dir");
148 let path = persist_pairing_qr(temp.path(), "alpha", "clwpair1_dGVzdA", None, 1_700_000_000)
149 .expect("persist");
150 assert!(path.exists());
151 }
152}