Skip to main content

aven_core/sync/
device_labels.rs

1use 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    /// Publishes this installation's automatic label once. Device labels are
26    /// display metadata; the membership device ID remains the identity.
27    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    /// Reads effective labels from encrypted retained history. Callers pair
57    /// these with verified membership, so removed devices are not displayed.
58    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}