1#![cfg_attr(not(feature = "hap-server"), allow(dead_code))]
3
4use std::collections::BTreeMap;
5use std::fs::{self, File};
6use std::io::{Read, Write};
7use std::path::{Path, PathBuf};
8use std::sync::atomic::{AtomicBool, AtomicU16, Ordering};
9use std::sync::RwLock;
10
11use ed25519_dalek::{SigningKey, VerifyingKey};
12use serde::{Deserialize, Serialize};
13use sha2_11::Sha512;
14use srp::ClientG3072;
15use tempfile::Builder;
16use tokio::sync::watch;
17use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing};
18
19use crate::error::HapError;
20
21const STORE_VERSION: u32 = 2;
22const MAX_STORE_BYTES: u64 = 1024 * 1024;
23const MAX_CONTROLLER_ID_BYTES: usize = 64;
24const MAX_CONTROLLERS: usize = 16;
25const MAX_PAIR_SETUP_FAILURES: u16 = 100;
26const SRP_USERNAME: &[u8] = b"Pair-Setup";
27
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(deny_unknown_fields)]
31pub struct ControllerPairing {
32 pub controller_id: String,
34 pub public_key: [u8; 32],
36 pub admin: bool,
38}
39
40impl ControllerPairing {
41 pub fn validate(&self) -> Result<(), HapError> {
43 let len = self.controller_id.len();
44 if len == 0
45 || len > MAX_CONTROLLER_ID_BYTES
46 || self.controller_id.chars().any(char::is_control)
47 {
48 return Err(HapError::InvalidPairingRecord(
49 "controller_id must contain 1..=64 bytes and no control characters".into(),
50 ));
51 }
52 VerifyingKey::from_bytes(&self.public_key).map_err(|_| {
53 HapError::InvalidPairingRecord("controller public key is not valid Ed25519".into())
54 })?;
55 Ok(())
56 }
57}
58
59#[derive(Zeroize, ZeroizeOnDrop)]
65pub struct SetupCode {
66 bytes: [u8; 10],
67}
68
69impl SetupCode {
70 pub fn parse(value: &str) -> Result<Self, HapError> {
71 let bytes: [u8; 10] = value.as_bytes().try_into().map_err(|_| {
72 HapError::InvalidPairingRecord("setup code must use the XXX-XX-XXX format".into())
73 })?;
74 if bytes[3] != b'-'
75 || bytes[6] != b'-'
76 || bytes
77 .iter()
78 .enumerate()
79 .any(|(index, byte)| index != 3 && index != 6 && !byte.is_ascii_digit())
80 {
81 return Err(HapError::InvalidPairingRecord(
82 "setup code must use the XXX-XX-XXX format".into(),
83 ));
84 }
85 let digits: Vec<u8> = bytes.iter().copied().filter(u8::is_ascii_digit).collect();
86 if is_trivial_setup_code(&digits) {
87 return Err(HapError::InvalidPairingRecord(
88 "setup code is prohibited because it is trivial".into(),
89 ));
90 }
91 Ok(Self { bytes })
92 }
93
94 pub fn expose(&self) -> &str {
96 std::str::from_utf8(&self.bytes).expect("validated setup code is ASCII")
97 }
98
99 pub(crate) fn as_bytes(&self) -> &[u8] {
100 &self.bytes
101 }
102
103 fn generate() -> Result<Self, HapError> {
104 const RANGE: u32 = 100_000_000;
105 const ACCEPT_BELOW: u32 = (u32::MAX / RANGE) * RANGE;
106 loop {
107 let mut random = [0u8; 4];
108 getrandom::getrandom(&mut random)
109 .map_err(|error| HapError::PairingStore(format!("generate setup code: {error}")))?;
110 let sample = u32::from_le_bytes(random);
111 if sample >= ACCEPT_BELOW {
112 continue;
113 }
114 let digits = format!("{:08}", sample % RANGE);
115 if is_trivial_setup_code(digits.as_bytes()) {
116 continue;
117 }
118 return Self::parse(&format!(
119 "{}-{}-{}",
120 &digits[..3],
121 &digits[3..5],
122 &digits[5..]
123 ));
124 }
125 }
126}
127
128impl std::fmt::Debug for SetupCode {
129 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
130 formatter.write_str("SetupCode([REDACTED])")
131 }
132}
133
134fn is_trivial_setup_code(digits: &[u8]) -> bool {
135 digits == b"12345678"
136 || digits == b"87654321"
137 || (digits.len() == 8 && digits.iter().all(|digit| *digit == digits[0]))
138}
139
140pub struct PairingStoreProvisioning {
142 pub store: PairingStore,
143 pub setup_code: Option<SetupCode>,
145}
146
147#[derive(Clone, Serialize, Deserialize)]
148#[serde(deny_unknown_fields)]
149struct StoredAccessory {
150 device_id: String,
151 signing_seed: [u8; 32],
152}
153
154impl std::fmt::Debug for StoredAccessory {
160 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
161 formatter
162 .debug_struct("StoredAccessory")
163 .field("device_id", &self.device_id)
164 .field("signing_seed", &"[REDACTED]")
165 .finish()
166 }
167}
168
169#[derive(Clone, Serialize, Deserialize)]
170#[serde(deny_unknown_fields)]
171struct StoredSetup {
172 salt: [u8; 16],
173 verifier: Vec<u8>,
174}
175
176impl std::fmt::Debug for StoredSetup {
180 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
181 formatter.write_str("StoredSetup([REDACTED])")
182 }
183}
184
185impl Drop for StoredAccessory {
186 fn drop(&mut self) {
187 self.signing_seed.zeroize();
188 }
189}
190
191impl Drop for StoredSetup {
192 fn drop(&mut self) {
193 self.salt.zeroize();
194 self.verifier.zeroize();
195 }
196}
197
198#[derive(Debug, Clone, Serialize, Deserialize)]
199#[serde(deny_unknown_fields)]
200struct PairingFile {
201 version: u32,
202 accessory: StoredAccessory,
203 setup: StoredSetup,
204 controllers: Vec<ControllerPairing>,
205}
206
207#[derive(Debug, Clone)]
208struct StoreState {
209 accessory: StoredAccessory,
210 setup: StoredSetup,
211 controllers: BTreeMap<String, ControllerPairing>,
212}
213
214#[derive(Debug)]
216pub struct PairingStore {
217 path: PathBuf,
218 state: RwLock<StoreState>,
219 pair_setup_active: AtomicBool,
220 pair_setup_failures: AtomicU16,
221 changes: watch::Sender<u64>,
222}
223
224impl PairingStore {
225 pub fn open(path: impl Into<PathBuf>) -> Result<Self, HapError> {
227 let path = path.into();
228 if !path.exists() {
229 return Err(HapError::PairingStore(format!(
230 "{} does not exist; use PairingStore::load_or_create for first provisioning",
231 path.display()
232 )));
233 }
234 Self::from_state(path.clone(), load_file(&path)?)
235 }
236
237 pub fn load_or_create(path: impl Into<PathBuf>) -> Result<PairingStoreProvisioning, HapError> {
240 let path = path.into();
241 if path.exists() {
242 return Ok(PairingStoreProvisioning {
243 store: Self::open(path)?,
244 setup_code: None,
245 });
246 }
247 let setup_code = SetupCode::generate()?;
248 let state = new_state(&setup_code, None)?;
249 persist_file(&path, &state, false)?;
250 Ok(PairingStoreProvisioning {
251 store: Self::from_state(path, state)?,
252 setup_code: Some(setup_code),
253 })
254 }
255
256 pub fn create(
259 path: impl Into<PathBuf>,
260 setup_code: SetupCode,
261 device_id: Option<String>,
262 ) -> Result<Self, HapError> {
263 let path = path.into();
264 if path.exists() {
265 return Err(HapError::PairingStore(format!(
266 "{} already exists",
267 path.display()
268 )));
269 }
270 let state = new_state(&setup_code, device_id)?;
271 persist_file(&path, &state, false)?;
272 Self::from_state(path, state)
273 }
274
275 fn from_state(path: PathBuf, state: StoreState) -> Result<Self, HapError> {
276 validate_state(&state)?;
277 let (changes, _) = watch::channel(0);
278 Ok(Self {
279 path,
280 state: RwLock::new(state),
281 pair_setup_active: AtomicBool::new(false),
282 pair_setup_failures: AtomicU16::new(0),
283 changes,
284 })
285 }
286
287 pub fn path(&self) -> &Path {
288 &self.path
289 }
290
291 pub fn accessory_id(&self) -> Result<String, HapError> {
292 Ok(self.read_state()?.accessory.device_id.clone())
293 }
294
295 pub fn accessory_public_key(&self) -> Result<[u8; 32], HapError> {
296 Ok(self.signing_key()?.verifying_key().to_bytes())
297 }
298
299 pub fn list(&self) -> Result<Vec<ControllerPairing>, HapError> {
300 Ok(self.read_state()?.controllers.values().cloned().collect())
301 }
302
303 pub fn get(&self, controller_id: &str) -> Result<Option<ControllerPairing>, HapError> {
304 Ok(self.read_state()?.controllers.get(controller_id).cloned())
305 }
306
307 pub fn is_paired(&self) -> Result<bool, HapError> {
308 Ok(!self.read_state()?.controllers.is_empty())
309 }
310
311 pub(crate) fn add_initial(&self, pairing: ControllerPairing) -> Result<(), HapError> {
313 pairing.validate()?;
314 if !pairing.admin {
315 return Err(HapError::InvalidPairingRecord(
316 "the first controller must be an administrator".into(),
317 ));
318 }
319 self.update(|state| {
320 if !state.controllers.is_empty() {
321 return Err(HapError::PairingAlreadyExists(
322 pairing.controller_id.clone(),
323 ));
324 }
325 state
326 .controllers
327 .insert(pairing.controller_id.clone(), pairing);
328 Ok(())
329 })?;
330 self.pair_setup_failures.store(0, Ordering::Release);
331 Ok(())
332 }
333
334 pub(crate) fn upsert(&self, pairing: ControllerPairing) -> Result<(), HapError> {
336 pairing.validate()?;
337 self.update(|state| {
338 if let Some(existing) = state.controllers.get(&pairing.controller_id) {
339 if existing.public_key != pairing.public_key {
340 return Err(HapError::InvalidPairingRecord(
341 "existing pairing public key cannot be replaced".into(),
342 ));
343 }
344 } else if state.controllers.len() >= MAX_CONTROLLERS {
345 return Err(HapError::PairingCapacity);
346 }
347 state
348 .controllers
349 .insert(pairing.controller_id.clone(), pairing);
350 Ok(())
351 })
352 }
353
354 pub(crate) fn remove_hap(&self, controller_id: &str) -> Result<bool, HapError> {
357 let mut removed = false;
358 self.update(|state| {
359 removed = state.controllers.remove(controller_id).is_some();
360 if removed
361 && !state.controllers.is_empty()
362 && !state.controllers.values().any(|pairing| pairing.admin)
363 {
364 state.controllers.clear();
365 }
366 Ok(())
367 })?;
368 Ok(removed)
369 }
370
371 pub(crate) fn signing_key(&self) -> Result<SigningKey, HapError> {
372 Ok(SigningKey::from_bytes(
373 &self.read_state()?.accessory.signing_seed,
374 ))
375 }
376
377 pub(crate) fn setup_record(&self) -> Result<([u8; 16], Vec<u8>), HapError> {
378 let state = self.read_state()?;
379 Ok((state.setup.salt, state.setup.verifier.clone()))
380 }
381
382 pub(crate) fn try_begin_pair_setup(&self) -> bool {
383 self.pair_setup_active
384 .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
385 .is_ok()
386 }
387
388 pub(crate) fn end_pair_setup(&self) {
389 self.pair_setup_active.store(false, Ordering::Release);
390 }
391
392 pub(crate) fn pair_setup_locked_out(&self) -> bool {
393 self.pair_setup_failures.load(Ordering::Acquire) >= MAX_PAIR_SETUP_FAILURES
394 }
395
396 pub(crate) fn record_pair_setup_failure(&self) {
397 let _ = self.pair_setup_failures.fetch_update(
398 Ordering::AcqRel,
399 Ordering::Acquire,
400 |attempts| Some(attempts.saturating_add(1)),
401 );
402 }
403
404 pub(crate) fn subscribe_changes(&self) -> watch::Receiver<u64> {
405 self.changes.subscribe()
406 }
407
408 fn read_state(&self) -> Result<std::sync::RwLockReadGuard<'_, StoreState>, HapError> {
409 self.state
410 .read()
411 .map_err(|_| HapError::PairingStore("pairing store lock poisoned".into()))
412 }
413
414 fn update(
415 &self,
416 mutate: impl FnOnce(&mut StoreState) -> Result<(), HapError>,
417 ) -> Result<(), HapError> {
418 let mut guard = self
419 .state
420 .write()
421 .map_err(|_| HapError::PairingStore("pairing store lock poisoned".into()))?;
422 let mut next = guard.clone();
423 mutate(&mut next)?;
424 validate_state(&next)?;
425 persist_file(&self.path, &next, true)?;
426 *guard = next;
427 self.changes.send_modify(|revision| *revision += 1);
428 Ok(())
429 }
430}
431
432fn new_state(setup_code: &SetupCode, device_id: Option<String>) -> Result<StoreState, HapError> {
433 let mut signing_seed = [0u8; 32];
434 let mut salt = [0u8; 16];
435 getrandom::getrandom(&mut signing_seed)
436 .and_then(|_| getrandom::getrandom(&mut salt))
437 .map_err(|error| HapError::PairingStore(format!("generate accessory identity: {error}")))?;
438 let device_id = match device_id {
439 Some(device_id) => device_id,
440 None => generate_device_id()?,
441 };
442 let verifier =
443 ClientG3072::<Sha512>::new().compute_verifier(SRP_USERNAME, setup_code.as_bytes(), &salt);
444 let state = StoreState {
445 accessory: StoredAccessory {
446 device_id,
447 signing_seed,
448 },
449 setup: StoredSetup { salt, verifier },
450 controllers: BTreeMap::new(),
451 };
452 validate_state(&state)?;
453 Ok(state)
454}
455
456fn generate_device_id() -> Result<String, HapError> {
457 let mut bytes = [0u8; 6];
458 getrandom::getrandom(&mut bytes)
459 .map_err(|error| HapError::PairingStore(format!("generate device ID: {error}")))?;
460 Ok(bytes
461 .iter()
462 .map(|byte| format!("{byte:02X}"))
463 .collect::<Vec<_>>()
464 .join(":"))
465}
466
467fn validate_device_id(device_id: &str) -> Result<(), HapError> {
468 let parts: Vec<&str> = device_id.split(':').collect();
469 if parts.len() != 6
470 || parts
471 .iter()
472 .any(|part| part.len() != 2 || !part.bytes().all(|byte| byte.is_ascii_hexdigit()))
473 {
474 return Err(HapError::InvalidPairingRecord(
475 "accessory device ID must be six colon-separated hexadecimal octets".into(),
476 ));
477 }
478 Ok(())
479}
480
481fn validate_state(state: &StoreState) -> Result<(), HapError> {
482 validate_device_id(&state.accessory.device_id)?;
483 SigningKey::from_bytes(&state.accessory.signing_seed);
484 if state.setup.verifier.len() != 384 {
485 return Err(HapError::InvalidPairingRecord(
486 "SRP verifier must contain exactly 384 bytes".into(),
487 ));
488 }
489 if state.controllers.len() > MAX_CONTROLLERS {
490 return Err(HapError::InvalidPairingRecord(
491 "too many persisted controller pairings".into(),
492 ));
493 }
494 for (id, pairing) in &state.controllers {
495 pairing.validate()?;
496 if id != &pairing.controller_id {
497 return Err(HapError::InvalidPairingRecord(
498 "controller map key does not match identifier".into(),
499 ));
500 }
501 }
502 if !state.controllers.is_empty() && !state.controllers.values().any(|pairing| pairing.admin) {
503 return Err(HapError::InvalidPairingRecord(
504 "persisted pairings have no administrator".into(),
505 ));
506 }
507 Ok(())
508}
509
510fn load_file(path: &Path) -> Result<StoreState, HapError> {
511 let metadata = fs::symlink_metadata(path)
512 .map_err(|error| HapError::PairingStore(format!("metadata {}: {error}", path.display())))?;
513 if metadata.file_type().is_symlink() || !metadata.is_file() {
514 return Err(HapError::PairingStore(format!(
515 "{} must be a regular, non-symlink file",
516 path.display()
517 )));
518 }
519 if metadata.len() > MAX_STORE_BYTES {
520 return Err(HapError::PairingStore(format!(
521 "{} exceeds the {MAX_STORE_BYTES}-byte limit",
522 path.display()
523 )));
524 }
525 validate_permissions(path, &metadata)?;
526 let mut bytes = Zeroizing::new(Vec::with_capacity(metadata.len() as usize));
527 File::open(path)
528 .and_then(|file| file.take(MAX_STORE_BYTES + 1).read_to_end(bytes.as_mut()))
529 .map_err(|error| HapError::PairingStore(format!("read {}: {error}", path.display())))?;
530 if bytes.len() as u64 > MAX_STORE_BYTES {
531 return Err(HapError::PairingStore(
532 "pairing store exceeds size limit".into(),
533 ));
534 }
535 let file: PairingFile = serde_json::from_slice(bytes.as_slice())
536 .map_err(|error| HapError::PairingStore(format!("parse {}: {error}", path.display())))?;
537 if file.version != STORE_VERSION {
538 return Err(HapError::PairingStore(format!(
539 "unsupported pairing store version {}; v1 controller-only stores cannot be used because they lack accessory identity and setup material",
540 file.version
541 )));
542 }
543 let mut controllers = BTreeMap::new();
544 for pairing in file.controllers {
545 pairing.validate()?;
546 let id = pairing.controller_id.clone();
547 if controllers.insert(id.clone(), pairing).is_some() {
548 return Err(HapError::InvalidPairingRecord(format!(
549 "duplicate controller_id {id}"
550 )));
551 }
552 }
553 let state = StoreState {
554 accessory: file.accessory,
555 setup: file.setup,
556 controllers,
557 };
558 validate_state(&state)?;
559 Ok(state)
560}
561
562fn persist_file(path: &Path, state: &StoreState, overwrite: bool) -> Result<(), HapError> {
563 let parent = path
564 .parent()
565 .filter(|parent| !parent.as_os_str().is_empty())
566 .unwrap_or(Path::new("."));
567 create_private_dir(parent)?;
568 let payload = Zeroizing::new(
569 serde_json::to_vec_pretty(&PairingFile {
570 version: STORE_VERSION,
571 accessory: state.accessory.clone(),
572 setup: state.setup.clone(),
573 controllers: state.controllers.values().cloned().collect(),
574 })
575 .map_err(|error| HapError::PairingStore(format!("serialize pairings: {error}")))?,
576 );
577 let mut temp = Builder::new()
578 .prefix(".homecore-hap-security-")
579 .tempfile_in(parent)
580 .map_err(|error| HapError::PairingStore(format!("create temporary store: {error}")))?;
581 set_private_file_permissions(temp.as_file())?;
582 temp.write_all(&payload)
583 .and_then(|_| temp.flush())
584 .and_then(|_| temp.as_file().sync_all())
585 .map_err(|error| HapError::PairingStore(format!("write temporary store: {error}")))?;
586 if overwrite {
587 temp.persist(path).map_err(|error| {
588 HapError::PairingStore(format!("replace {}: {}", path.display(), error.error))
589 })?;
590 } else {
591 temp.persist_noclobber(path).map_err(|error| {
592 HapError::PairingStore(format!("create {}: {}", path.display(), error.error))
593 })?;
594 }
595 #[cfg(unix)]
596 File::open(parent)
597 .and_then(|directory| directory.sync_all())
598 .map_err(|error| HapError::PairingStore(format!("sync {}: {error}", parent.display())))?;
599 Ok(())
600}
601
602fn create_private_dir(path: &Path) -> Result<(), HapError> {
603 let created = !path.exists();
604 if created {
605 fs::create_dir_all(path).map_err(|error| {
606 HapError::PairingStore(format!("create {}: {error}", path.display()))
607 })?;
608 }
609 #[cfg(unix)]
610 if created {
611 use std::os::unix::fs::PermissionsExt;
612 fs::set_permissions(path, fs::Permissions::from_mode(0o700)).map_err(|error| {
613 HapError::PairingStore(format!("chmod {}: {error}", path.display()))
614 })?;
615 }
616 Ok(())
617}
618
619fn set_private_file_permissions(_file: &File) -> Result<(), HapError> {
620 #[cfg(unix)]
621 {
622 use std::os::unix::fs::PermissionsExt;
623 _file
624 .set_permissions(fs::Permissions::from_mode(0o600))
625 .map_err(|error| HapError::PairingStore(format!("chmod temporary store: {error}")))?;
626 }
627 Ok(())
628}
629
630fn validate_permissions(path: &Path, metadata: &fs::Metadata) -> Result<(), HapError> {
631 #[cfg(unix)]
632 {
633 use std::os::unix::fs::PermissionsExt;
634 let mode = metadata.permissions().mode();
635 if mode & 0o077 != 0 {
636 return Err(HapError::InsecurePermissions {
637 path: path.to_path_buf(),
638 mode: mode & 0o777,
639 });
640 }
641 }
642 let _ = (path, metadata);
643 Ok(())
644}
645
646#[cfg(test)]
647mod tests {
648 use super::*;
649
650 fn store(directory: &tempfile::TempDir) -> PairingStore {
651 PairingStore::create(
652 directory.path().join("pairings.json"),
653 SetupCode::parse("518-26-003").unwrap(),
654 Some("AA:BB:CC:DD:EE:FF".into()),
655 )
656 .unwrap()
657 }
658
659 fn pairing(id: &str, byte: u8, admin: bool) -> ControllerPairing {
660 ControllerPairing {
661 controller_id: id.into(),
662 public_key: SigningKey::from_bytes(&[byte; 32])
663 .verifying_key()
664 .to_bytes(),
665 admin,
666 }
667 }
668
669 #[test]
670 fn first_provisioning_returns_code_but_restart_does_not() {
671 let directory = tempfile::tempdir().unwrap();
672 let path = directory.path().join("pairings.json");
673 let provisioned = PairingStore::load_or_create(&path).unwrap();
674 let id = provisioned.store.accessory_id().unwrap();
675 assert!(provisioned.setup_code.is_some());
676 drop(provisioned);
677 let reopened = PairingStore::load_or_create(path).unwrap();
678 assert!(reopened.setup_code.is_none());
679 assert_eq!(reopened.store.accessory_id().unwrap(), id);
680 }
681
682 #[test]
683 fn identity_and_pairings_survive_atomic_restart() {
684 let directory = tempfile::tempdir().unwrap();
685 let path = directory.path().join("pairings.json");
686 let store = store(&directory);
687 let public_key = store.accessory_public_key().unwrap();
688 store.add_initial(pairing("controller-1", 7, true)).unwrap();
689 drop(store);
690 let reopened = PairingStore::open(path).unwrap();
691 assert_eq!(reopened.accessory_public_key().unwrap(), public_key);
692 assert_eq!(
693 reopened.list().unwrap(),
694 vec![pairing("controller-1", 7, true)]
695 );
696 }
697
698 #[test]
699 fn prohibited_setup_codes_are_rejected_and_debug_is_redacted() {
700 assert!(SetupCode::parse("111-11-111").is_err());
701 assert!(SetupCode::parse("123-45-678").is_err());
702 let code = SetupCode::parse("518-26-003").unwrap();
703 assert_eq!(format!("{code:?}"), "SetupCode([REDACTED])");
704 }
705
706 #[test]
711 fn stored_accessory_and_setup_debug_never_print_secret_material() {
712 let accessory = StoredAccessory {
713 device_id: "AA:BB:CC:DD:EE:FF".into(),
714 signing_seed: [0x42; 32],
715 };
716 let rendered = format!("{accessory:?}");
717 assert!(rendered.contains("device_id"));
718 assert!(rendered.contains("AA:BB:CC:DD:EE:FF"));
719 assert!(!rendered.contains("66"), "hex of 0x42 must not leak: {rendered}");
720 assert_eq!(
721 rendered,
722 "StoredAccessory { device_id: \"AA:BB:CC:DD:EE:FF\", signing_seed: \"[REDACTED]\" }"
723 );
724
725 let setup = StoredSetup { salt: [0x7a; 16], verifier: vec![0x13; 8] };
726 assert_eq!(format!("{setup:?}"), "StoredSetup([REDACTED])");
727
728 let state = StoreState {
731 accessory,
732 setup,
733 controllers: std::collections::BTreeMap::new(),
734 };
735 let rendered_state = format!("{state:?}");
736 assert!(!rendered_state.contains("0x42"));
737 assert!(rendered_state.contains("[REDACTED]"));
738 }
739
740 #[test]
741 fn removing_last_admin_clears_all_pairings() {
742 let directory = tempfile::tempdir().unwrap();
743 let store = store(&directory);
744 store.add_initial(pairing("admin", 1, true)).unwrap();
745 store.upsert(pairing("member", 2, false)).unwrap();
746 assert!(store.remove_hap("admin").unwrap());
747 assert!(store.list().unwrap().is_empty());
748 }
749
750 #[test]
751 fn malformed_or_legacy_records_fail_closed() {
752 let directory = tempfile::tempdir().unwrap();
753 let path = directory.path().join("pairings.json");
754 fs::write(&path, br#"{"version":1,"controllers":[]}"#).unwrap();
755 #[cfg(unix)]
756 {
757 use std::os::unix::fs::PermissionsExt;
758 fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap();
759 }
760 assert!(PairingStore::open(path).is_err());
761 }
762
763 #[cfg(unix)]
764 #[test]
765 fn permissive_existing_file_is_rejected() {
766 use std::os::unix::fs::PermissionsExt;
767 let directory = tempfile::tempdir().unwrap();
768 let path = directory.path().join("pairings.json");
769 let _ = store(&directory);
770 fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
771 assert!(matches!(
772 PairingStore::open(path),
773 Err(HapError::InsecurePermissions { .. })
774 ));
775 }
776}