Skip to main content

atrg_label/
label.rs

1//! Label service: create, store, and manage labels.
2
3use crate::signing::LabelSigner;
4use crate::store::LabelStore;
5use crate::types::{Label, LabelValue, SignedLabel};
6use sqlx::SqlitePool;
7
8/// The main label service that creates, signs, and stores labels.
9pub struct LabelService {
10    /// Persistent label storage.
11    store: LabelStore,
12    /// Signer for producing label signatures.
13    signer: LabelSigner,
14    /// DID of this labeler service.
15    labeler_did: String,
16}
17
18impl LabelService {
19    /// Create a new label service.
20    pub fn new(db: SqlitePool, signer: LabelSigner, labeler_did: String) -> Self {
21        Self {
22            store: LabelStore::new(db),
23            signer,
24            labeler_did,
25        }
26    }
27
28    /// Run migrations to create the labels table.
29    pub async fn migrate(&self) -> anyhow::Result<()> {
30        self.store.migrate().await
31    }
32
33    /// Create and store a label for a subject.
34    ///
35    /// The label is signed and persisted to the store before being returned.
36    pub async fn create_label(
37        &self,
38        subject_uri: &str,
39        value: LabelValue,
40        subject_cid: Option<&str>,
41    ) -> anyhow::Result<SignedLabel> {
42        let label = Label {
43            ver: 1,
44            src: self.labeler_did.clone(),
45            uri: subject_uri.to_string(),
46            cid: subject_cid.map(|s| s.to_string()),
47            val: value.to_string(),
48            neg: false,
49            cts: now_iso8601(),
50            exp: None,
51        };
52
53        let sig = self.signer.sign(&label)?;
54        let signed = SignedLabel { label, sig };
55
56        self.store.insert(&signed).await?;
57        Ok(signed)
58    }
59
60    /// Negate (remove) a previously issued label.
61    ///
62    /// Creates a new label entry with `neg: true`, indicating that the
63    /// specified label value no longer applies to the subject.
64    pub async fn negate_label(
65        &self,
66        subject_uri: &str,
67        value: LabelValue,
68        subject_cid: Option<&str>,
69    ) -> anyhow::Result<SignedLabel> {
70        let label = Label {
71            ver: 1,
72            src: self.labeler_did.clone(),
73            uri: subject_uri.to_string(),
74            cid: subject_cid.map(|s| s.to_string()),
75            val: value.to_string(),
76            neg: true,
77            cts: now_iso8601(),
78            exp: None,
79        };
80
81        let sig = self.signer.sign(&label)?;
82        let signed = SignedLabel { label, sig };
83
84        self.store.insert(&signed).await?;
85        Ok(signed)
86    }
87
88    /// Query labels for a subject URI.
89    pub async fn query_labels(&self, uri: &str) -> anyhow::Result<Vec<SignedLabel>> {
90        self.store.query_by_uri(uri).await
91    }
92
93    /// Query labels since a cursor for subscription streaming.
94    ///
95    /// Returns `(row_id, signed_label)` pairs so callers can track the cursor
96    /// position for subsequent requests.
97    pub async fn query_since(
98        &self,
99        cursor: i64,
100        limit: i64,
101    ) -> anyhow::Result<Vec<(i64, SignedLabel)>> {
102        self.store.query_since(cursor, limit).await
103    }
104}
105
106/// Get the current time as an ISO 8601 string.
107///
108/// Uses `std::time::SystemTime` to avoid adding chrono as a dependency.
109/// The output format is `{epoch_seconds}Z` — a simplified representation.
110// TODO: Use proper ISO 8601 formatting (YYYY-MM-DDTHH:MM:SSZ) in production,
111// either via chrono or a manual computation from epoch seconds.
112fn now_iso8601() -> String {
113    let now = std::time::SystemTime::now()
114        .duration_since(std::time::UNIX_EPOCH)
115        .unwrap_or_default();
116    format!("{}Z", now.as_secs())
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122    use crate::signing::LabelSigner;
123    use sqlx::SqlitePool;
124
125    fn test_signer() -> LabelSigner {
126        LabelSigner::new(b"test-key".to_vec())
127    }
128
129    async fn setup_service() -> LabelService {
130        let db = SqlitePool::connect("sqlite::memory:").await.unwrap();
131        let svc = LabelService::new(db, test_signer(), "did:plc:test-labeler".to_string());
132        svc.migrate().await.unwrap();
133        svc
134    }
135
136    #[test]
137    fn now_iso8601_produces_nonempty_string() {
138        let ts = now_iso8601();
139        assert!(!ts.is_empty());
140        assert!(ts.ends_with('Z'));
141    }
142
143    #[tokio::test]
144    async fn test_new_and_migrate() {
145        let db = SqlitePool::connect("sqlite::memory:").await.unwrap();
146        let svc = LabelService::new(db, test_signer(), "did:plc:labeler".to_string());
147        let result = svc.migrate().await;
148        assert!(result.is_ok());
149    }
150
151    #[tokio::test]
152    async fn test_create_label() {
153        let svc = setup_service().await;
154        let uri = "at://did:plc:user/app.bsky.feed.post/abc";
155
156        let signed = svc.create_label(uri, LabelValue::Spam, None).await.unwrap();
157
158        assert_eq!(signed.label.src, "did:plc:test-labeler");
159        assert_eq!(signed.label.uri, uri);
160        assert_eq!(signed.label.val, "spam");
161        assert!(!signed.label.neg);
162        assert!(!signed.sig.is_empty());
163
164        // Query back from the store.
165        let labels = svc.query_labels(uri).await.unwrap();
166        assert_eq!(labels.len(), 1);
167        assert_eq!(labels[0].label.val, "spam");
168        assert_eq!(labels[0].label.src, "did:plc:test-labeler");
169    }
170
171    #[tokio::test]
172    async fn test_negate_label() {
173        let svc = setup_service().await;
174        let uri = "at://did:plc:user/app.bsky.feed.post/abc";
175
176        let negated = svc.negate_label(uri, LabelValue::Porn, None).await.unwrap();
177
178        assert!(negated.label.neg);
179        assert_eq!(negated.label.val, "porn");
180        assert!(!negated.sig.is_empty());
181
182        let labels = svc.query_labels(uri).await.unwrap();
183        assert_eq!(labels.len(), 1);
184        assert!(labels[0].label.neg);
185    }
186
187    #[tokio::test]
188    async fn test_create_and_query_multiple() {
189        let svc = setup_service().await;
190        let uri_a = "at://did:plc:user/post/a";
191        let uri_b = "at://did:plc:user/post/b";
192
193        svc.create_label(uri_a, LabelValue::Spam, None)
194            .await
195            .unwrap();
196        svc.create_label(uri_a, LabelValue::Nudity, None)
197            .await
198            .unwrap();
199        svc.create_label(uri_b, LabelValue::Impersonation, None)
200            .await
201            .unwrap();
202
203        let labels_a = svc.query_labels(uri_a).await.unwrap();
204        assert_eq!(labels_a.len(), 2);
205        assert_eq!(labels_a[0].label.val, "spam");
206        assert_eq!(labels_a[1].label.val, "nudity");
207
208        let labels_b = svc.query_labels(uri_b).await.unwrap();
209        assert_eq!(labels_b.len(), 1);
210        assert_eq!(labels_b[0].label.val, "impersonation");
211    }
212
213    #[tokio::test]
214    async fn test_query_since_ordering() {
215        let svc = setup_service().await;
216        let uri = "at://did:plc:user/post/1";
217
218        svc.create_label(uri, LabelValue::Spam, None).await.unwrap();
219        svc.create_label(uri, LabelValue::Porn, None).await.unwrap();
220        svc.create_label(uri, LabelValue::Custom("custom".into()), None)
221            .await
222            .unwrap();
223
224        let results = svc.query_since(0, 10).await.unwrap();
225        assert_eq!(results.len(), 3);
226
227        // IDs should be monotonically increasing.
228        assert!(results[0].0 < results[1].0);
229        assert!(results[1].0 < results[2].0);
230
231        // Values should match insertion order.
232        assert_eq!(results[0].1.label.val, "spam");
233        assert_eq!(results[1].1.label.val, "porn");
234        assert_eq!(results[2].1.label.val, "custom");
235    }
236}