aven_core/sync/
device_labels.rs1use std::collections::BTreeMap;
2
3use anyhow::{Result, ensure};
4use serde_json::json;
5use sqlx::Row;
6
7use crate::change_log::op_type;
8use crate::db::{Database, insert_change};
9
10pub const MAX_DEVICE_LABEL_CHARS: usize = 64;
11pub const MAX_DEVICE_LABEL_BYTES: usize = 256;
12
13pub(crate) fn validate_device_label(label: &str) -> Result<()> {
14 ensure!(
15 !label.is_empty()
16 && label.chars().count() <= MAX_DEVICE_LABEL_CHARS
17 && label.len() <= MAX_DEVICE_LABEL_BYTES
18 && !label.chars().any(char::is_control),
19 "error invalid-sync-change device-label"
20 );
21 Ok(())
22}
23
24impl Database {
25 pub async fn publish_device_label(&self, device: [u8; 32], label: &str) -> Result<()> {
28 validate_device_label(label)?;
29 let device_id = hex::encode(device);
30 let mut conn = self.acquire_writer().await?;
31 let exists: bool = sqlx::query_scalar(
32 "SELECT EXISTS(
33 SELECT 1 FROM changes
34 WHERE op_type = 'publish_device_label' AND entity_id = ?
35 )",
36 )
37 .bind(&device_id)
38 .fetch_one(&mut *conn)
39 .await?;
40 if exists {
41 return Ok(());
42 }
43 insert_change(
44 &mut conn,
45 "device",
46 &device_id,
47 None,
48 op_type::PUBLISH_DEVICE_LABEL,
49 json!({ "label": label }),
50 None,
51 )
52 .await?;
53 Ok(())
54 }
55
56 pub async fn device_labels(&self) -> Result<BTreeMap<[u8; 32], String>> {
59 let mut conn = self.acquire_reader().await?;
60 let rows = sqlx::query(
61 "SELECT entity_id, payload
62 FROM changes
63 WHERE op_type = 'publish_device_label'
64 ORDER BY (server_seq IS NULL), server_seq, local_seq",
65 )
66 .fetch_all(&mut *conn)
67 .await?;
68 let mut labels = BTreeMap::new();
69 for row in rows {
70 let device_id: String = row.try_get("entity_id")?;
71 let payload: String = row.try_get("payload")?;
72 let Ok(bytes) = hex::decode(&device_id) else {
73 continue;
74 };
75 let Ok(device) = <[u8; 32]>::try_from(bytes) else {
76 continue;
77 };
78 let Some(label) = serde_json::from_str::<serde_json::Value>(&payload)?
79 .get("label")
80 .and_then(|value| value.as_str())
81 .map(str::to_owned)
82 else {
83 continue;
84 };
85 labels.entry(device).or_insert(label);
86 }
87 Ok(labels)
88 }
89}
90
91#[cfg(test)]
92mod tests {
93 use super::*;
94
95 #[tokio::test]
96 async fn publishes_once_and_reads_the_label() {
97 let directory = tempfile::tempdir().unwrap();
98 let database = Database::open(&directory.path().join("aven.sqlite"))
99 .await
100 .unwrap();
101 let device = [0xab; 32];
102 database
103 .publish_device_label(device, "Office Mac")
104 .await
105 .unwrap();
106 database
107 .publish_device_label(device, "Renamed Mac")
108 .await
109 .unwrap();
110 database
111 .publish_device_label([0xcd; 32], "Linux")
112 .await
113 .unwrap();
114 let mut conn = database.acquire_reader().await.unwrap();
115 let count: i64 =
116 sqlx::query_scalar("SELECT count(*) FROM changes WHERE op_type='publish_device_label'")
117 .fetch_one(&mut *conn)
118 .await
119 .unwrap();
120 assert_eq!(count, 2);
121 assert_eq!(
122 database.device_labels().await.unwrap(),
123 BTreeMap::from([(device, "Office Mac".into()), ([0xcd; 32], "Linux".into()),])
124 );
125 }
126
127 #[test]
128 fn rejects_unsafe_or_oversized_labels() {
129 for (label, valid) in [
130 ("Linux laptop".into(), true),
131 ("".into(), false),
132 ("bad\nname".into(), false),
133 ("x".repeat(64), true),
134 ("x".repeat(65), false),
135 ("🦀".repeat(64), true),
136 ("🦀".repeat(65), false),
137 ] {
138 assert_eq!(validate_device_label(&label).is_ok(), valid, "{label:?}");
139 }
140 }
141}