Skip to main content

clawdentity_core/pairing/
qr.rs

1use 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
18/// TODO(clawdentity): document `encode_ticket_qr_png`.
19pub 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
39/// TODO(clawdentity): document `decode_ticket_from_png`.
40pub 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/// TODO(clawdentity): document `persist_pairing_qr`.
68#[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}