coding_agent_search/pages/
qr.rs1#![allow(unexpected_cfgs)]
24
25use anyhow::{Context, Result, bail};
26use base64::prelude::*;
27use chrono::Utc;
28use rand::Rng;
29use std::fs::OpenOptions;
30use std::io::Write;
31use std::path::{Path, PathBuf};
32use tracing::info;
33use zeroize::Zeroize;
34
35const RECOVERY_SECRET_BYTES: usize = 32;
37
38#[derive(Clone)]
43pub struct RecoverySecret {
44 bytes: Vec<u8>,
46 encoded: String,
48}
49
50impl std::fmt::Debug for RecoverySecret {
51 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52 f.debug_struct("RecoverySecret")
54 .field("entropy_bits", &self.entropy_bits())
55 .field("encoded", &"[REDACTED]")
56 .finish()
57 }
58}
59
60impl RecoverySecret {
61 pub fn generate() -> Self {
65 let mut bytes = vec![0u8; RECOVERY_SECRET_BYTES];
66 let mut rng = rand::rng();
67 rng.fill_bytes(&mut bytes);
68 let encoded = BASE64_URL_SAFE_NO_PAD.encode(&bytes);
69 Self { bytes, encoded }
70 }
71
72 pub fn from_bytes(bytes: Vec<u8>) -> Option<Self> {
77 if bytes.len() < 24 {
78 return None;
79 }
80 let encoded = BASE64_URL_SAFE_NO_PAD.encode(&bytes);
81 Some(Self { bytes, encoded })
82 }
83
84 pub fn from_encoded(encoded: &str) -> Result<Self> {
86 let bytes = BASE64_URL_SAFE_NO_PAD
87 .decode(encoded)
88 .context("Invalid base64url encoding")?;
89 if bytes.len() < 24 {
90 bail!("Recovery secret too short (minimum 192 bits for long-term security)");
91 }
92 Ok(Self {
93 bytes,
94 encoded: encoded.to_string(),
95 })
96 }
97
98 pub fn as_bytes(&self) -> &[u8] {
100 &self.bytes
101 }
102
103 pub fn encoded(&self) -> &str {
105 &self.encoded
106 }
107
108 pub fn entropy_bits(&self) -> usize {
110 self.bytes.len() * 8
111 }
112}
113
114impl Drop for RecoverySecret {
115 fn drop(&mut self) {
116 self.bytes.zeroize();
118 let mut encoded_bytes = std::mem::take(&mut self.encoded).into_bytes();
120 encoded_bytes.zeroize();
121 }
122}
123
124pub struct RecoveryArtifacts {
126 pub secret: RecoverySecret,
128 pub secret_text: String,
130 pub qr_png: Vec<u8>,
132 pub qr_svg: String,
134}
135
136impl Drop for RecoveryArtifacts {
137 fn drop(&mut self) {
138 let mut text_bytes = std::mem::take(&mut self.secret_text).into_bytes();
140 text_bytes.zeroize();
141 self.qr_png.zeroize();
142 let mut svg_bytes = std::mem::take(&mut self.qr_svg).into_bytes();
143 svg_bytes.zeroize();
144 }
146}
147
148impl RecoveryArtifacts {
149 pub fn generate(archive_name: &str) -> Result<Self> {
154 let secret = RecoverySecret::generate();
155 let timestamp = Utc::now().to_rfc3339();
156
157 let secret_text = format!(
159 r#"CASS RECOVERY SECRET
160====================
161
162Archive: {archive_name}
163Created: {timestamp}
164
165Secret: {secret}
166
167IMPORTANT:
168- This secret unlocks your archive if you forget your password
169- Store securely (password manager, encrypted USB, safe)
170- NEVER deploy this file with the public site
171- The QR code encodes the same secret
172
173[QR code path: qr-code.png]
174"#,
175 archive_name = archive_name,
176 timestamp = timestamp,
177 secret = secret.encoded(),
178 );
179
180 let qr_png = generate_qr_png(secret.encoded())?;
182 let qr_svg = generate_qr_svg(secret.encoded())?;
183
184 info!(
185 entropy_bits = secret.entropy_bits(),
186 encoded_len = secret.encoded().len(),
187 "Generated recovery secret"
188 );
189
190 Ok(Self {
191 secret,
192 secret_text,
193 qr_png,
194 qr_svg,
195 })
196 }
197
198 pub fn write_to_dir(&self, dir: &Path) -> Result<()> {
202 ensure_recovery_artifact_dir(dir)?;
203
204 let secret_path = dir.join("recovery-secret.txt");
206 write_recovery_artifact(&secret_path, self.secret_text.as_bytes())
207 .context("Failed to write recovery-secret.txt")?;
208
209 let png_path = dir.join("qr-code.png");
211 write_recovery_artifact(&png_path, &self.qr_png).context("Failed to write qr-code.png")?;
212
213 let svg_path = dir.join("qr-code.svg");
215 write_recovery_artifact(&svg_path, self.qr_svg.as_bytes())
216 .context("Failed to write qr-code.svg")?;
217
218 info!(
219 dir = %dir.display(),
220 "Wrote recovery artifacts: recovery-secret.txt, qr-code.png, qr-code.svg"
221 );
222
223 Ok(())
224 }
225}
226
227fn ensure_recovery_artifact_dir(dir: &Path) -> Result<()> {
228 match std::fs::symlink_metadata(dir) {
229 Ok(metadata) => {
230 let file_type = metadata.file_type();
231 if file_type.is_symlink() {
232 bail!(
233 "Recovery artifact directory must not be a symlink: {}",
234 dir.display()
235 );
236 }
237 if !file_type.is_dir() {
238 bail!(
239 "Recovery artifact path must be a directory: {}",
240 dir.display()
241 );
242 }
243 Ok(())
244 }
245 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
246 std::fs::create_dir_all(dir).context("Failed to create private directory")?;
247 ensure_recovery_artifact_dir(dir)
248 }
249 Err(err) => Err(err)
250 .with_context(|| format!("Failed to inspect recovery artifact dir {}", dir.display())),
251 }
252}
253
254fn reject_recovery_artifact_symlink(path: &Path) -> Result<()> {
255 match std::fs::symlink_metadata(path) {
256 Ok(metadata) => {
257 let file_type = metadata.file_type();
258 if file_type.is_symlink() {
259 bail!(
260 "Recovery artifact file must not be a symlink: {}",
261 path.display()
262 );
263 }
264 if file_type.is_dir() {
265 bail!(
266 "Recovery artifact path must be a regular file, not a directory: {}",
267 path.display()
268 );
269 }
270 Ok(())
271 }
272 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
273 Err(err) => Err(err)
274 .with_context(|| format!("Failed to inspect recovery artifact {}", path.display())),
275 }
276}
277
278fn recovery_artifact_temp_path(path: &Path, attempt: usize) -> PathBuf {
279 let file_name = path
280 .file_name()
281 .and_then(|name| name.to_str())
282 .unwrap_or("artifact");
283 path.with_file_name(format!(
284 ".{file_name}.tmp.{}.{}",
285 std::process::id(),
286 attempt
287 ))
288}
289
290fn write_recovery_artifact(path: &Path, contents: &[u8]) -> Result<()> {
291 let parent = path.parent().unwrap_or_else(|| Path::new("."));
292 ensure_recovery_artifact_dir(parent)?;
293 reject_recovery_artifact_symlink(path)?;
294
295 let mut temp_path = None;
296 let mut file = None;
297 for attempt in 0..100 {
298 let candidate = recovery_artifact_temp_path(path, attempt);
299 match OpenOptions::new()
300 .write(true)
301 .create_new(true)
302 .open(&candidate)
303 {
304 Ok(opened) => {
305 temp_path = Some(candidate);
306 file = Some(opened);
307 break;
308 }
309 Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue,
310 Err(err) => {
311 return Err(err).with_context(|| {
312 format!(
313 "Failed to create temporary recovery artifact {}",
314 candidate.display()
315 )
316 });
317 }
318 }
319 }
320
321 let temp_path = temp_path.ok_or_else(|| {
322 anyhow::anyhow!(
323 "Failed to allocate a temporary recovery artifact path for {}",
324 path.display()
325 )
326 })?;
327 let mut file = file.expect("temp_path is set only with an open file");
328 let write_result = (|| -> Result<()> {
329 file.write_all(contents).with_context(|| {
330 format!(
331 "Failed to write temporary recovery artifact {}",
332 temp_path.display()
333 )
334 })?;
335 file.sync_all().with_context(|| {
336 format!(
337 "Failed to sync temporary recovery artifact {}",
338 temp_path.display()
339 )
340 })?;
341 Ok(())
342 })();
343
344 if let Err(err) = write_result {
345 let _ = std::fs::remove_file(&temp_path);
346 return Err(err);
347 }
348 drop(file);
349
350 if let Err(err) = std::fs::rename(&temp_path, path) {
351 let _ = std::fs::remove_file(&temp_path);
352 return Err(err)
353 .with_context(|| format!("Failed to install recovery artifact {}", path.display()));
354 }
355 Ok(())
356}
357
358pub fn generate_qr_png(data: &str) -> Result<Vec<u8>> {
362 #[cfg(feature = "qr")]
363 {
364 use image::Luma;
365 use qrcode::QrCode;
366
367 let code = QrCode::new(data.as_bytes()).context("Failed to create QR code")?;
368 let image = code.render::<Luma<u8>>().build();
369
370 let mut png_bytes = Vec::new();
371 image::DynamicImage::ImageLuma8(image)
372 .write_to(
373 &mut std::io::Cursor::new(&mut png_bytes),
374 image::ImageFormat::Png,
375 )
376 .context("Failed to encode PNG")?;
377
378 Ok(png_bytes)
379 }
380
381 #[cfg(not(feature = "qr"))]
382 {
383 let _ = data;
384 bail!("QR code generation requires the 'qr' feature to be enabled")
385 }
386}
387
388pub fn generate_qr_svg(data: &str) -> Result<String> {
392 #[cfg(feature = "qr")]
393 {
394 use qrcode::QrCode;
395 use qrcode::render::svg;
396
397 let code = QrCode::new(data.as_bytes()).context("Failed to create QR code")?;
398 let svg = code
399 .render()
400 .min_dimensions(200, 200)
401 .dark_color(svg::Color("#000000"))
402 .light_color(svg::Color("#ffffff"))
403 .build();
404
405 Ok(svg)
406 }
407
408 #[cfg(not(feature = "qr"))]
409 {
410 let _ = data;
411 bail!("QR code generation requires the 'qr' feature to be enabled")
412 }
413}
414
415pub struct QrGenerator;
417
418impl Default for QrGenerator {
419 fn default() -> Self {
420 Self::new()
421 }
422}
423
424impl QrGenerator {
425 pub fn new() -> Self {
426 Self
427 }
428
429 pub fn generate(&self, data: &str, output_path: &Path) -> Result<()> {
430 let png_data = generate_qr_png(data)?;
431 write_recovery_artifact(output_path, &png_data)?;
432 Ok(())
433 }
434}
435
436#[cfg(test)]
437mod tests {
438 use super::*;
439 use tempfile::TempDir;
440
441 #[test]
442 fn test_recovery_secret_generation() {
443 let secret = RecoverySecret::generate();
444
445 assert_eq!(secret.entropy_bits(), 256);
447 assert_eq!(secret.as_bytes().len(), 32);
448
449 assert!(!secret.encoded().is_empty());
451 assert!(!secret.encoded().contains('+')); assert!(!secret.encoded().contains('/')); }
454
455 #[test]
456 fn test_recovery_secret_round_trip() {
457 let secret1 = RecoverySecret::generate();
458 let encoded = secret1.encoded().to_string();
459
460 let secret2 = RecoverySecret::from_encoded(&encoded).expect("decode should work");
461 assert_eq!(secret1.as_bytes(), secret2.as_bytes());
462 }
463
464 #[test]
465 fn test_recovery_secret_minimum_entropy() {
466 let short_bytes = vec![0u8; 23]; assert!(RecoverySecret::from_bytes(short_bytes).is_none());
469
470 let min_bytes = vec![0u8; 24]; assert!(RecoverySecret::from_bytes(min_bytes).is_some());
473 }
474
475 #[test]
476 fn test_recovery_secret_deterministic_encoding() {
477 let bytes = vec![1u8; 32];
479 let secret1 = RecoverySecret::from_bytes(bytes.clone()).unwrap();
480 let secret2 = RecoverySecret::from_bytes(bytes).unwrap();
481 assert_eq!(secret1.encoded(), secret2.encoded());
482 }
483
484 #[test]
485 #[cfg(unix)]
486 fn test_recovery_artifacts_write_to_dir_rejects_symlinked_secret_file() {
487 use std::os::unix::fs::symlink;
488
489 let tmp = TempDir::new().expect("create temp dir");
490 let private_dir = tmp.path().join("private");
491 let outside = tmp.path().join("outside");
492 std::fs::create_dir_all(&private_dir).unwrap();
493 std::fs::create_dir_all(&outside).unwrap();
494 let protected = outside.join("protected-secret.txt");
495 std::fs::write(&protected, "do not overwrite").unwrap();
496 symlink(&protected, private_dir.join("recovery-secret.txt")).unwrap();
497
498 let secret = RecoverySecret::from_bytes(vec![1u8; 32]).unwrap();
499 let artifacts = RecoveryArtifacts {
500 secret,
501 secret_text: "safe secret text".to_string(),
502 qr_png: b"png".to_vec(),
503 qr_svg: "<svg></svg>".to_string(),
504 };
505
506 let err = artifacts.write_to_dir(&private_dir).unwrap_err();
507 let rendered = format!("{err:#}");
508
509 assert!(
510 rendered.contains("must not be a symlink"),
511 "unexpected error: {err:#}"
512 );
513 assert_eq!(
514 std::fs::read_to_string(&protected).unwrap(),
515 "do not overwrite"
516 );
517 assert!(
518 std::fs::symlink_metadata(private_dir.join("recovery-secret.txt"))
519 .unwrap()
520 .file_type()
521 .is_symlink(),
522 "rejected recovery secret symlink should be left intact"
523 );
524 }
525
526 #[test]
527 #[cfg(feature = "qr")]
528 fn test_qr_png_generation() {
529 let data = "test-secret-data-12345";
530 let png = generate_qr_png(data).expect("PNG generation should work");
531
532 assert!(png.len() > 100);
534 assert_eq!(&png[0..8], b"\x89PNG\r\n\x1a\n");
535 }
536
537 #[test]
538 #[cfg(feature = "qr")]
539 fn test_qr_svg_generation() {
540 let data = "test-secret-data-12345";
541 let svg = generate_qr_svg(data).expect("SVG generation should work");
542
543 assert!(svg.contains("<svg"));
545 assert!(svg.contains("</svg>"));
546 }
547
548 #[test]
549 #[cfg(feature = "qr")]
550 fn test_recovery_artifacts_generation() {
551 let artifacts =
552 RecoveryArtifacts::generate("test-archive").expect("Artifacts generation should work");
553
554 assert_eq!(artifacts.secret.entropy_bits(), 256);
556
557 assert!(artifacts.secret_text.contains(artifacts.secret.encoded()));
559 assert!(artifacts.secret_text.contains("test-archive"));
560 assert!(artifacts.secret_text.contains("CASS RECOVERY SECRET"));
561
562 assert!(artifacts.qr_png.len() > 100);
564 assert_eq!(&artifacts.qr_png[0..8], b"\x89PNG\r\n\x1a\n");
565
566 assert!(artifacts.qr_svg.contains("<svg"));
568 }
569
570 #[test]
571 #[cfg(feature = "qr")]
572 fn test_recovery_artifacts_write_to_dir() {
573 let tmp = TempDir::new().expect("create temp dir");
574 let private_dir = tmp.path().join("private");
575
576 let artifacts =
577 RecoveryArtifacts::generate("test-archive").expect("Artifacts generation should work");
578
579 artifacts
580 .write_to_dir(&private_dir)
581 .expect("Writing should work");
582
583 assert!(private_dir.join("recovery-secret.txt").exists());
585 assert!(private_dir.join("qr-code.png").exists());
586 assert!(private_dir.join("qr-code.svg").exists());
587
588 let secret_content =
590 std::fs::read_to_string(private_dir.join("recovery-secret.txt")).unwrap();
591 assert!(secret_content.contains(artifacts.secret.encoded()));
592 }
593
594 #[test]
595 #[cfg(feature = "qr")]
596 fn test_qr_code_encodes_exact_secret() {
597 let artifacts =
599 RecoveryArtifacts::generate("test-archive").expect("Artifacts generation should work");
600
601 let png1 = generate_qr_png(artifacts.secret.encoded()).unwrap();
605 let png2 = generate_qr_png(artifacts.secret.encoded()).unwrap();
606 assert_eq!(png1, png2, "Same input should produce same output");
607 }
608}