Skip to main content

syncweb_core/
indexing.rs

1//! Opt-in local indexing for synchronized folders.
2//!
3//! The indexer deliberately owns its database and event consumers. The core
4//! synchronization path does not depend on the index being enabled or healthy.
5
6pub mod catalog;
7pub mod denylist;
8pub mod links;
9pub mod parallel;
10pub mod reputation;
11pub mod resilience;
12pub mod wot;
13pub use catalog::{Catalog, CatalogMetadata, CatalogRecord, CatalogService};
14pub use denylist::{Denied, DenyReason, Denylist, DenylistRule, DenylistService, FilterContext, FilterList};
15pub use links::{
16    CapabilityLink, ContentLink, ImmutableLink, Link, LinkResolution, LinkResolver, Mirror, MutableLink,
17    MutablePointer, NameLink, PrivateLink, ProviderFetch, REVOCATION_GOSSIP_TOPIC, ResolveOptions, ResolvedLink,
18    SignedMutablePointer, SyncwebLink, current_epoch_seconds, fetch_from_mirrors, revocation_topic_id,
19};
20pub use parallel::{ParallelDownloadConfig, TryParallelResult};
21pub use reputation::{
22    ProviderReputation, ProviderReputationStore, ProviderTrustSignal, ReputationConfig, TrustSignalKind,
23    trust_stream_topic,
24};
25pub use resilience::{
26    AvailabilityHealth, BanRecord, BanSource, FailureRecord, FetchFailure, FetchFailureKind, FetchWait, LeaseUpdate,
27    ProviderLease, ProviderLeaseTracker, ReplicationBudget, ReplicationResult, ResilienceConfig, ResilienceService,
28    consistent_hashing_selection, jitter_delay, resilience_topic, validate_bounded_fetch, validate_bounded_stream,
29    validate_fetch_stream, xor_distance,
30};
31pub use wot::{
32    Attestation, AttestationKind, MetadataEntry, ModerationAction, ModerationContext, ModerationDecision,
33    ModerationRecord, ModerationScope, ProviderTrustAction, ProviderTrustDecision, ProviderTrustRecord,
34    RevocationRecord, TrustDecision, TrustDelegation, TrustPolicy, WotMetadata, WotService,
35};
36
37use std::{
38    collections::HashMap,
39    path::{Path, PathBuf},
40    str::FromStr,
41    sync::{Arc, Mutex},
42    time::{SystemTime, UNIX_EPOCH},
43};
44
45use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
46use iroh::PublicKey;
47use iroh_blobs::Hash;
48use iroh_docs::{Entry, NamespaceId, engine::LiveEvent};
49use n0_future::StreamExt;
50use rusqlite::{Connection, OptionalExtension, params};
51use serde::{Deserialize, Serialize};
52use tokio::{sync::broadcast, task::JoinHandle};
53
54use crate::{
55    error::{Result, SyncwebError},
56    folder::SyncwebFolder,
57    gossip::SignedGossipMessage,
58};
59
60/// Current indexing database schema version.
61pub const SCHEMA_VERSION: &str = "1";
62const EVENT_CAPACITY: usize = 256;
63
64/// A content entry known to the local indexing service.
65#[derive(Clone, Debug, Eq, PartialEq)]
66#[non_exhaustive]
67pub struct IndexedEntry {
68    pub namespace_id: NamespaceId,
69    pub key: Vec<u8>,
70    pub hash: Hash,
71    pub size: u64,
72}
73
74/// Core synchronization events that do not add or remove an indexed entry.
75#[derive(Clone, Copy, Debug, Eq, PartialEq)]
76#[non_exhaustive]
77pub enum CoreIndexingEvent {
78    ContentReady { hash: Hash },
79    PendingContentReady,
80    NeighborUp,
81    NeighborDown,
82    SyncFinished,
83}
84
85/// Events emitted by an [`IndexingService`].
86#[derive(Clone, Debug, Eq, PartialEq)]
87#[non_exhaustive]
88pub enum IndexingEvent {
89    FolderEnabled {
90        namespace_id: NamespaceId,
91    },
92    FolderDisabled {
93        namespace_id: NamespaceId,
94    },
95    EntryIndexed(IndexedEntry),
96    Core {
97        namespace_id: NamespaceId,
98        event: CoreIndexingEvent,
99    },
100    Error {
101        namespace_id: NamespaceId,
102        message: String,
103    },
104}
105
106/// A folder opt-in returned by [`IndexingService::enable_folder`].
107#[derive(Clone, Copy, Debug, Eq, PartialEq)]
108#[non_exhaustive]
109pub struct IndexingHandle {
110    namespace_id: NamespaceId,
111}
112
113impl IndexingHandle {
114    #[must_use]
115    pub const fn namespace_id(self) -> NamespaceId {
116        self.namespace_id
117    }
118}
119
120/// Thread-safe `SQLite` database used by the indexing service.
121#[derive(Clone)]
122pub struct IndexingDatabase {
123    connection: Arc<Mutex<Connection>>,
124    path: Arc<PathBuf>,
125}
126
127impl std::fmt::Debug for IndexingDatabase {
128    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129        formatter
130            .debug_struct("IndexingDatabase")
131            .field("path", &self.path)
132            .finish_non_exhaustive()
133    }
134}
135
136impl IndexingDatabase {
137    /// Open or create an indexing database.
138    ///
139    /// # Errors
140    ///
141    /// Returns an error if the database cannot be opened or initialized.
142    pub fn open(path: impl AsRef<Path>) -> Result<Self> {
143        let database_path = path.as_ref().to_path_buf();
144        if database_path.as_os_str() != ":memory:"
145            && let Some(parent) = database_path.parent()
146            && !parent.as_os_str().is_empty()
147        {
148            std::fs::create_dir_all(parent)
149                .map_err(|error| SyncwebError::operation("failed to create indexing database directory", error))?;
150        }
151        let connection = Connection::open(&database_path)
152            .map_err(|error| SyncwebError::operation("failed to open indexing database", error))?;
153        initialize_connection(&connection)?;
154        Ok(Self {
155            connection: Arc::new(Mutex::new(connection)),
156            path: Arc::new(database_path),
157        })
158    }
159
160    /// Create an isolated in-memory indexing database.
161    ///
162    /// # Errors
163    ///
164    /// Returns an error if `SQLite` cannot initialize the schema.
165    pub fn in_memory() -> Result<Self> {
166        Self::open(":memory:")
167    }
168
169    #[must_use]
170    pub fn path(&self) -> &Path {
171        self.path.as_path()
172    }
173
174    /// Return the persisted indexing schema version.
175    ///
176    /// # Errors
177    ///
178    /// Returns an error if the schema metadata cannot be read.
179    pub fn schema_version(&self) -> Result<String> {
180        self.with_connection(|connection| {
181            connection
182                .query_row(
183                    "SELECT value FROM index_metadata WHERE key = 'schema_version'",
184                    [],
185                    |row| row.get(0),
186                )
187                .map_err(|error| database_error("failed to read indexing schema version", error))
188        })
189    }
190
191    /// Return whether the `SQLite` FTS5 virtual table is available.
192    ///
193    /// # Errors
194    ///
195    /// Returns an error if the schema cannot be queried.
196    pub fn has_fts5(&self) -> Result<bool> {
197        self.has_table("indexed_entries_fts")
198    }
199
200    /// Return the names of schema objects owned by the index.
201    ///
202    /// # Errors
203    ///
204    /// Returns an error if `SQLite` cannot query its schema.
205    pub fn schema_objects(&self) -> Result<Vec<String>> {
206        self.with_connection(|connection| {
207            let mut statement = connection
208                .prepare("SELECT name FROM sqlite_master WHERE name NOT LIKE 'sqlite_%' ORDER BY name")
209                .map_err(|error| database_error("failed to prepare schema query", error))?;
210            let names = statement
211                .query_map([], |row| row.get::<_, String>(0))
212                .map_err(|error| database_error("failed to query schema objects", error))?
213                .collect::<std::result::Result<Vec<_>, _>>()
214                .map_err(|error| database_error("failed to read schema objects", error))?;
215            Ok(names)
216        })
217    }
218
219    /// Return whether a schema table or view exists.
220    ///
221    /// # Errors
222    ///
223    /// Returns an error if `SQLite` cannot query its schema.
224    pub fn has_table(&self, name: &str) -> Result<bool> {
225        self.with_connection(|connection| {
226            let exists = connection
227                .query_row(
228                    "SELECT EXISTS(
229                        SELECT 1 FROM sqlite_master
230                        WHERE name = ?1 AND type IN ('table', 'view')
231                    )",
232                    [name],
233                    |row| row.get(0),
234                )
235                .map_err(|error| database_error("failed to query schema object", error))?;
236            Ok(exists)
237        })
238    }
239
240    /// Return the number of opted-in folders.
241    ///
242    /// # Errors
243    ///
244    /// Returns an error if `SQLite` cannot query the database.
245    pub fn folder_count(&self) -> Result<usize> {
246        self.with_connection(|connection| {
247            let count = connection
248                .query_row("SELECT COUNT(*) FROM indexed_folders", [], |row| row.get::<_, i64>(0))
249                .map_err(|error| database_error("failed to count indexed folders", error))?;
250            usize::try_from(count).map_err(|error| database_error("indexed folder count is invalid", error))
251        })
252    }
253
254    /// Return the number of indexed entries.
255    ///
256    /// # Errors
257    ///
258    /// Returns an error if `SQLite` cannot query the database.
259    pub fn entry_count(&self) -> Result<usize> {
260        self.with_connection(|connection| {
261            let count = connection
262                .query_row("SELECT COUNT(*) FROM indexed_entries", [], |row| row.get::<_, i64>(0))
263                .map_err(|error| database_error("failed to count indexed entries", error))?;
264            usize::try_from(count).map_err(|error| database_error("indexed entry count is invalid", error))
265        })
266    }
267
268    /// Return whether a namespace is currently opted into indexing.
269    ///
270    /// # Errors
271    ///
272    /// Returns an error if `SQLite` cannot query the database.
273    pub fn is_folder_enabled(&self, namespace_id: NamespaceId) -> Result<bool> {
274        self.with_connection(|connection| {
275            let enabled = connection
276                .query_row(
277                    "SELECT EXISTS(
278                        SELECT 1 FROM indexed_folders WHERE namespace_id = ?1
279                    )",
280                    [namespace_id.to_string()],
281                    |row| row.get(0),
282                )
283                .map_err(|error| database_error("failed to query indexed folder", error))?;
284            Ok(enabled)
285        })
286    }
287
288    /// Insert or update an entry in the index.
289    ///
290    /// # Errors
291    ///
292    /// Returns an error if the database cannot persist the entry.
293    pub fn upsert_entry(
294        &self,
295        namespace_id: NamespaceId,
296        key: impl AsRef<[u8]>,
297        hash: Hash,
298        size: u64,
299    ) -> Result<IndexedEntry> {
300        let key_bytes = key.as_ref().to_vec();
301        let namespace = namespace_id.to_string();
302        let hash_bytes = hash.as_bytes().to_vec();
303        let size_value =
304            i64::try_from(size).map_err(|error| database_error("indexed entry size is too large", error))?;
305        let key_text = String::from_utf8_lossy(&key_bytes).into_owned();
306        let title = Path::new(&key_text)
307            .file_name()
308            .and_then(|name| name.to_str())
309            .unwrap_or(&key_text)
310            .to_owned();
311        self.with_connection(|connection| {
312            let transaction = connection
313                .unchecked_transaction()
314                .map_err(|error| database_error("failed to begin indexing transaction", error))?;
315            let existing_id = transaction
316                .query_row(
317                    "SELECT id FROM indexed_entries WHERE namespace_id = ?1 AND entry_key = ?2",
318                    params![namespace, key_bytes],
319                    |row| row.get::<_, i64>(0),
320                )
321                .optional()
322                .map_err(|error| database_error("failed to find indexed entry", error))?;
323            let id = if let Some(id) = existing_id {
324                transaction
325                    .execute(
326                        "UPDATE indexed_entries
327                         SET content_hash = ?1, content_len = ?2, updated_at = ?3
328                         WHERE id = ?4",
329                        params![hash_bytes, size_value, now_seconds(), id],
330                    )
331                    .map_err(|error| database_error("failed to update indexed entry", error))?;
332                transaction
333                    .execute("DELETE FROM indexed_entries_fts WHERE rowid = ?1", [id])
334                    .map_err(|error| database_error("failed to refresh indexed search entry", error))?;
335                id
336            } else {
337                transaction
338                    .execute(
339                        "INSERT INTO indexed_entries
340                         (namespace_id, entry_key, content_hash, content_len, updated_at)
341                         VALUES (?1, ?2, ?3, ?4, ?5)",
342                        params![namespace, key_bytes, hash_bytes, size_value, now_seconds()],
343                    )
344                    .map_err(|error| database_error("failed to insert indexed entry", error))?;
345                transaction.last_insert_rowid()
346            };
347            transaction
348                .execute(
349                    "INSERT INTO indexed_entries_fts(rowid, namespace_id, entry_key, title, tags)
350                     VALUES (?1, ?2, ?3, ?4, ?5)",
351                    params![id, namespace, key_text, title, ""],
352                )
353                .map_err(|error| database_error("failed to insert indexed search entry", error))?;
354            transaction
355                .commit()
356                .map_err(|error| database_error("failed to commit indexed entry", error))?;
357            Ok(IndexedEntry {
358                namespace_id,
359                key: key_bytes,
360                hash,
361                size,
362            })
363        })
364    }
365
366    /// Remove an indexed entry if it exists.
367    ///
368    /// # Errors
369    ///
370    /// Returns an error if the database cannot remove the entry.
371    pub fn remove_entry(&self, namespace_id: NamespaceId, key: impl AsRef<[u8]>) -> Result<bool> {
372        self.with_connection(|connection| {
373            let transaction = connection
374                .unchecked_transaction()
375                .map_err(|error| database_error("failed to begin indexing transaction", error))?;
376            let id = transaction
377                .query_row(
378                    "SELECT id FROM indexed_entries WHERE namespace_id = ?1 AND entry_key = ?2",
379                    params![namespace_id.to_string(), key.as_ref()],
380                    |row| row.get::<_, i64>(0),
381                )
382                .optional()
383                .map_err(|error| database_error("failed to find indexed entry", error))?;
384            let Some(entry_id) = id else {
385                transaction
386                    .commit()
387                    .map_err(|error| database_error("failed to commit indexing transaction", error))?;
388                return Ok(false);
389            };
390            transaction
391                .execute("DELETE FROM indexed_entries_fts WHERE rowid = ?1", [entry_id])
392                .map_err(|error| database_error("failed to remove indexed search entry", error))?;
393            transaction
394                .execute("DELETE FROM indexed_entries WHERE id = ?1", [entry_id])
395                .map_err(|error| database_error("failed to remove indexed entry", error))?;
396            transaction
397                .commit()
398                .map_err(|error| database_error("failed to commit indexing transaction", error))?;
399            Ok(true)
400        })
401    }
402
403    /// Search indexed paths and metadata using `SQLite` FTS5.
404    ///
405    /// An empty query returns the most recently updated entries. FTS5 query
406    /// syntax is passed through for non-empty queries.
407    ///
408    /// # Errors
409    ///
410    /// Returns an error if the query is invalid or `SQLite` cannot read results.
411    pub fn search(&self, query: &str, limit: usize) -> Result<Vec<IndexedEntry>> {
412        if limit == 0 {
413            return Ok(Vec::new());
414        }
415        let limit_value = i64::try_from(limit).map_err(|error| database_error("search limit is invalid", error))?;
416        self.with_connection(|connection| {
417            let mut output = Vec::new();
418            if query.trim().is_empty() {
419                let mut statement = connection
420                    .prepare(
421                        "SELECT namespace_id, entry_key, content_hash, content_len
422                         FROM indexed_entries ORDER BY updated_at DESC, id DESC LIMIT ?1",
423                    )
424                    .map_err(|error| database_error("failed to prepare index query", error))?;
425                let rows = statement
426                    .query_map([limit_value], indexed_entry_from_row)
427                    .map_err(|error| database_error("failed to query indexed entries", error))?;
428                for row in rows {
429                    output.push(row.map_err(|error| database_error("failed to read indexed entry", error))?);
430                }
431            } else {
432                let mut statement = connection
433                    .prepare(
434                        "SELECT e.namespace_id, e.entry_key, e.content_hash, e.content_len
435                         FROM indexed_entries_fts f
436                         JOIN indexed_entries e ON e.id = f.rowid
437                         WHERE indexed_entries_fts MATCH ?1
438                         ORDER BY bm25(indexed_entries_fts), e.id DESC
439                         LIMIT ?2",
440                    )
441                    .map_err(|error| database_error("failed to prepare full-text query", error))?;
442                let rows = statement
443                    .query_map(params![query, limit_value], indexed_entry_from_row)
444                    .map_err(|error| database_error("failed to query full-text index", error))?;
445                for row in rows {
446                    output.push(row.map_err(|error| database_error("failed to read full-text result", error))?);
447                }
448            }
449            Ok(output)
450        })
451    }
452
453    /// Register a catalog that is published or subscribed to locally.
454    ///
455    /// # Errors
456    ///
457    /// Returns an error if the catalog metadata cannot be stored.
458    pub fn enable_catalog(&self, namespace_id: NamespaceId, label: &str) -> Result<()> {
459        self.with_connection(|connection| {
460            connection
461                .execute(
462                    "INSERT INTO indexed_catalogs(namespace_id, label, subscribed_at)
463                     VALUES (?1, ?2, ?3)
464                     ON CONFLICT(namespace_id) DO UPDATE SET label = excluded.label",
465                    params![namespace_id.to_string(), label, now_seconds()],
466                )
467                .map_err(|error| database_error("failed to enable indexed catalog", error))?;
468            Ok(())
469        })
470    }
471
472    /// Return the number of known catalogs.
473    ///
474    /// # Errors
475    ///
476    /// Returns an error if `SQLite` cannot query the database.
477    pub fn catalog_count(&self) -> Result<usize> {
478        self.with_connection(|connection| {
479            let count = connection
480                .query_row("SELECT COUNT(*) FROM indexed_catalogs", [], |row| row.get::<_, i64>(0))
481                .map_err(|error| database_error("failed to count indexed catalogs", error))?;
482            usize::try_from(count).map_err(|error| database_error("indexed catalog count is invalid", error))
483        })
484    }
485
486    /// Return all known catalogs with their namespace identifiers and labels.
487    ///
488    /// # Errors
489    ///
490    /// Returns an error if the catalog metadata cannot be read.
491    pub fn load_catalogs(&self) -> Result<Vec<(NamespaceId, String)>> {
492        use std::str::FromStr;
493        self.with_connection(|connection| {
494            let mut statement = connection
495                .prepare("SELECT namespace_id, label FROM indexed_catalogs ORDER BY subscribed_at DESC")
496                .map_err(|error| database_error("failed to prepare catalog listing", error))?;
497            let rows = statement
498                .query_map([], |row| {
499                    let namespace: String = row.get(0)?;
500                    let label: String = row.get(1)?;
501                    Ok((namespace, label))
502                })
503                .map_err(|error| database_error("failed to read indexed catalogs", error))?;
504            let mut catalogs = Vec::new();
505            for row in rows {
506                let (namespace, label) =
507                    row.map_err(|error| database_error("failed to read indexed catalog record", error))?;
508                let namespace_id = NamespaceId::from_str(&namespace)
509                    .map_err(|error| database_error("indexed catalog namespace is invalid", error))?;
510                catalogs.push((namespace_id, label));
511            }
512            Ok(catalogs)
513        })
514    }
515
516    /// Return the number of records imported from catalogs.
517    ///
518    /// # Errors
519    ///
520    /// Returns an error if `SQLite` cannot query the database.
521    pub fn catalog_entry_count(&self) -> Result<usize> {
522        self.with_connection(|connection| {
523            let count = connection
524                .query_row("SELECT COUNT(*) FROM indexed_catalog_entries", [], |row| {
525                    row.get::<_, i64>(0)
526                })
527                .map_err(|error| database_error("failed to count indexed catalog entries", error))?;
528            usize::try_from(count).map_err(|error| database_error("indexed catalog entry count is invalid", error))
529        })
530    }
531
532    /// Insert or update a record received from a catalog.
533    ///
534    /// # Errors
535    ///
536    /// Returns an error if the catalog is not registered or the record cannot
537    /// be persisted.
538    pub fn upsert_catalog_record(&self, record: &CatalogRecord) -> Result<CatalogRecord> {
539        record.validate()?;
540        let catalog_namespace = record.catalog_namespace_id.to_string();
541        let folder_namespace = record.folder_namespace_id.to_string();
542        let hash_bytes = record.hash.as_bytes().to_vec();
543        let size =
544            i64::try_from(record.size).map_err(|error| database_error("catalog record size is too large", error))?;
545        let tags = serde_json::to_string(&record.tags)
546            .map_err(|error| database_error("failed to serialize catalog record tags", error))?;
547        let key = record.key.clone();
548        let publisher = record.publisher.clone();
549        self.with_connection(|connection| {
550            let transaction = connection
551                .unchecked_transaction()
552                .map_err(|error| database_error("failed to begin catalog transaction", error))?;
553            let catalog_exists = transaction
554                .query_row(
555                    "SELECT EXISTS(
556                        SELECT 1 FROM indexed_catalogs WHERE namespace_id = ?1
557                    )",
558                    [&catalog_namespace],
559                    |row| row.get::<_, bool>(0),
560                )
561                .map_err(|error| database_error("failed to find indexed catalog", error))?;
562            if !catalog_exists {
563                return Err(SyncwebError::InvalidConfig(format!(
564                    "catalog is not enabled: {catalog_namespace}"
565                )));
566            }
567            let existing_id = transaction
568                .query_row(
569                    "SELECT id FROM indexed_catalog_entries
570                     WHERE catalog_namespace_id = ?1
571                       AND folder_namespace_id = ?2
572                       AND entry_key = ?3",
573                    params![catalog_namespace, folder_namespace, key],
574                    |row| row.get::<_, i64>(0),
575                )
576                .optional()
577                .map_err(|error| database_error("failed to find indexed catalog record", error))?;
578            let id = if let Some(id) = existing_id {
579                transaction
580                    .execute(
581                        "UPDATE indexed_catalog_entries
582                         SET content_hash = ?1, content_len = ?2, folder_name = ?3,
583                             title = ?4, tags = ?5, publisher = ?6, updated_at = ?7
584                         WHERE id = ?8",
585                        params![
586                            hash_bytes,
587                            size,
588                            record.folder_name,
589                            record.title,
590                            tags,
591                            publisher,
592                            now_seconds(),
593                            id
594                        ],
595                    )
596                    .map_err(|error| database_error("failed to update indexed catalog record", error))?;
597                transaction
598                    .execute("DELETE FROM indexed_catalog_entries_fts WHERE rowid = ?1", [id])
599                    .map_err(|error| database_error("failed to refresh catalog search record", error))?;
600                id
601            } else {
602                transaction
603                    .execute(
604                        "INSERT INTO indexed_catalog_entries
605                         (catalog_namespace_id, folder_namespace_id, entry_key, content_hash,
606                          content_len, folder_name, title, tags, publisher, updated_at)
607                         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
608                        params![
609                            catalog_namespace,
610                            folder_namespace,
611                            key,
612                            hash_bytes,
613                            size,
614                            record.folder_name,
615                            record.title,
616                            tags,
617                            publisher,
618                            now_seconds()
619                        ],
620                    )
621                    .map_err(|error| database_error("failed to insert indexed catalog record", error))?;
622                transaction.last_insert_rowid()
623            };
624            transaction
625                .execute(
626                    "INSERT INTO indexed_catalog_entries_fts(
627                         rowid, catalog_namespace_id, folder_namespace_id, entry_key,
628                        folder_name, title, tags, publisher, content_hash
629                     )
630                    VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
631                    params![
632                        id,
633                        record.catalog_namespace_id.to_string(),
634                        record.folder_namespace_id.to_string(),
635                        String::from_utf8_lossy(&record.key),
636                        record.folder_name,
637                        record.title,
638                        tags,
639                        publisher,
640                        record.hash.to_string()
641                    ],
642                )
643                .map_err(|error| database_error("failed to insert catalog search record", error))?;
644            transaction
645                .commit()
646                .map_err(|error| database_error("failed to commit catalog record", error))?;
647            Ok(record.clone())
648        })
649    }
650
651    /// Search all subscribed catalogs with `SQLite` FTS5.
652    ///
653    /// An empty query returns the most recently updated catalog records.
654    ///
655    /// # Errors
656    ///
657    /// Returns an error if the query is invalid or `SQLite` cannot read results.
658    pub fn search_catalogs(&self, query: &str, limit: usize) -> Result<Vec<CatalogRecord>> {
659        if limit == 0 {
660            return Ok(Vec::new());
661        }
662        let limit_value =
663            i64::try_from(limit).map_err(|error| database_error("catalog search limit is invalid", error))?;
664        self.with_connection(|connection| {
665            let sql = if query.trim().is_empty() {
666                "SELECT catalog_namespace_id, folder_namespace_id, entry_key, content_hash,
667                        content_len, folder_name, title, tags, publisher
668                 FROM indexed_catalog_entries
669                 ORDER BY updated_at DESC, id DESC LIMIT ?1"
670            } else {
671                "SELECT e.catalog_namespace_id, e.folder_namespace_id, e.entry_key, e.content_hash,
672                        e.content_len, e.folder_name, e.title, e.tags, e.publisher
673                 FROM indexed_catalog_entries_fts f
674                 JOIN indexed_catalog_entries e ON e.id = f.rowid
675                 WHERE indexed_catalog_entries_fts MATCH ?1
676                 ORDER BY bm25(indexed_catalog_entries_fts), e.id DESC
677                 LIMIT ?2"
678            };
679            let mut statement = connection
680                .prepare(sql)
681                .map_err(|error| database_error("failed to prepare catalog search", error))?;
682            let rows = if query.trim().is_empty() {
683                statement
684                    .query_map([rusqlite::types::Value::Integer(limit_value)], catalog_record_from_row)
685                    .map_err(|error| database_error("failed to query catalog records", error))?
686            } else {
687                statement
688                    .query_map(params![query, limit_value], catalog_record_from_row)
689                    .map_err(|error| database_error("failed to query catalog records", error))?
690            };
691            rows.collect::<std::result::Result<Vec<_>, _>>()
692                .map_err(|error| database_error("failed to read catalog records", error))
693        })
694    }
695
696    /// Search all known catalogs. This is an alias for [`Self::search_catalogs`].
697    ///
698    /// # Errors
699    ///
700    /// Returns an error if the query is invalid or `SQLite` cannot read results.
701    pub fn global_search(&self, query: &str, limit: usize) -> Result<Vec<CatalogRecord>> {
702        self.search_catalogs(query, limit)
703    }
704
705    /// Search all known catalogs. Alias for [`Self::search_catalogs`].
706    ///
707    /// # Errors
708    ///
709    /// Returns an error if the query is invalid or `SQLite` cannot read results.
710    pub fn catalog_search(&self, query: &str, limit: usize) -> Result<Vec<CatalogRecord>> {
711        self.search_catalogs(query, limit)
712    }
713
714    /// Append a signed Web-of-Trust metadata entry to the local index.
715    ///
716    /// # Errors
717    ///
718    /// Returns an error if the entry is invalid or `SQLite` cannot persist it.
719    pub fn append_wot_metadata(&self, entry: &wot::MetadataEntry) -> Result<bool> {
720        entry.validate()?;
721        let hash_bytes = entry.content.as_bytes().to_vec();
722        let sequence =
723            i64::try_from(entry.sequence).map_err(|error| database_error("metadata sequence is too large", error))?;
724        let created_at = i64::try_from(entry.created_at)
725            .map_err(|error| database_error("metadata timestamp is too large", error))?;
726        let key = entry.key.clone();
727        let value = entry.value.clone();
728        let author = entry.author.clone();
729        let signature = entry
730            .signature
731            .clone()
732            .ok_or_else(|| SyncwebError::InvalidConfig("Web-of-Trust metadata must be signed".to_owned()))?;
733        self.with_connection(|connection| {
734            let transaction = connection
735                .unchecked_transaction()
736                .map_err(|error| database_error("failed to begin Web-of-Trust metadata transaction", error))?;
737            let inserted = transaction
738                .execute(
739                    "INSERT INTO wot_metadata
740                     (content_hash, metadata_key, metadata_value, author, sequence, created_at, signature)
741                     VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
742                     ON CONFLICT(content_hash, metadata_key, author, sequence) DO NOTHING",
743                    params![hash_bytes, key, value, author, sequence, created_at, signature],
744                )
745                .map_err(|error| database_error("failed to append Web-of-Trust metadata", error))?;
746            if inserted == 1 {
747                let rowid = transaction.last_insert_rowid();
748                transaction
749                    .execute(
750                        "INSERT INTO wot_metadata_fts(rowid, content_hash, metadata_key, metadata_value, author)
751                         VALUES (?1, ?2, ?3, ?4, ?5)",
752                        params![rowid, entry.content.to_string(), entry.key, entry.value, entry.author],
753                    )
754                    .map_err(|error| database_error("failed to index Web-of-Trust metadata", error))?;
755            }
756            transaction
757                .commit()
758                .map_err(|error| database_error("failed to commit Web-of-Trust metadata", error))?;
759            Ok(inserted == 1)
760        })
761    }
762
763    /// Search accepted Web-of-Trust metadata using `SQLite` FTS5.
764    ///
765    /// # Errors
766    ///
767    /// Returns an error if the query is invalid or metadata cannot be read.
768    pub fn search_wot_metadata(&self, query: &str, limit: usize) -> Result<Vec<wot::MetadataEntry>> {
769        if limit == 0 {
770            return Ok(Vec::new());
771        }
772        let limit_value =
773            i64::try_from(limit).map_err(|error| database_error("metadata search limit is invalid", error))?;
774        self.with_connection(|connection| {
775            let mut statement = if query.trim().is_empty() {
776                connection
777                    .prepare(
778                        "SELECT content_hash, metadata_key, metadata_value, author, sequence, created_at, signature
779                         FROM wot_metadata ORDER BY created_at DESC, id DESC LIMIT ?1",
780                    )
781                    .map_err(|error| database_error("failed to prepare metadata query", error))?
782            } else {
783                connection
784                    .prepare(
785                        "SELECT m.content_hash, m.metadata_key, m.metadata_value, m.author,
786                                m.sequence, m.created_at, m.signature
787                         FROM wot_metadata_fts f
788                         JOIN wot_metadata m ON m.id = f.rowid
789                         WHERE wot_metadata_fts MATCH ?1
790                         ORDER BY bm25(wot_metadata_fts), m.id DESC
791                         LIMIT ?2",
792                    )
793                    .map_err(|error| database_error("failed to prepare metadata full-text query", error))?
794            };
795            let rows = if query.trim().is_empty() {
796                statement
797                    .query_map([limit_value], wot_metadata_from_row)
798                    .map_err(|error| database_error("failed to query metadata", error))?
799            } else {
800                statement
801                    .query_map(params![query, limit_value], wot_metadata_from_row)
802                    .map_err(|error| database_error("failed to query metadata full-text index", error))?
803            };
804            rows.collect::<std::result::Result<Vec<_>, _>>()
805                .map_err(|error| database_error("failed to read metadata search results", error))
806        })
807    }
808
809    /// Remove a catalog and all records imported from it.
810    ///
811    /// # Errors
812    ///
813    /// Returns an error if the catalog cannot be removed.
814    pub fn disable_catalog(&self, namespace_id: NamespaceId) -> Result<()> {
815        self.with_connection(|connection| {
816            let transaction = connection
817                .unchecked_transaction()
818                .map_err(|error| database_error("failed to begin catalog removal", error))?;
819            let namespace = namespace_id.to_string();
820            let mut statement = transaction
821                .prepare("SELECT id FROM indexed_catalog_entries WHERE catalog_namespace_id = ?1")
822                .map_err(|error| database_error("failed to find catalog records", error))?;
823            let ids = statement
824                .query_map([namespace.as_str()], |row| row.get::<_, i64>(0))
825                .map_err(|error| database_error("failed to list catalog records", error))?
826                .collect::<std::result::Result<Vec<_>, _>>()
827                .map_err(|error| database_error("failed to read catalog record IDs", error))?;
828            drop(statement);
829            for id in ids {
830                transaction
831                    .execute("DELETE FROM indexed_catalog_entries_fts WHERE rowid = ?1", [id])
832                    .map_err(|error| database_error("failed to remove catalog search record", error))?;
833            }
834            transaction
835                .execute(
836                    "DELETE FROM indexed_catalog_entries WHERE catalog_namespace_id = ?1",
837                    [namespace.as_str()],
838                )
839                .map_err(|error| database_error("failed to remove catalog records", error))?;
840            transaction
841                .execute(
842                    "DELETE FROM indexed_catalogs WHERE namespace_id = ?1",
843                    [namespace.as_str()],
844                )
845                .map_err(|error| database_error("failed to disable indexed catalog", error))?;
846            transaction
847                .commit()
848                .map_err(|error| database_error("failed to commit catalog removal", error))?;
849            Ok(())
850        })
851    }
852
853    /// Register a namespace for local indexing.
854    ///
855    /// # Errors
856    ///
857    /// Returns an error if the folder metadata cannot be stored.
858    pub fn enable_folder(&self, namespace_id: NamespaceId, label: &str) -> Result<()> {
859        self.with_connection(|connection| {
860            connection
861                .execute(
862                    "INSERT INTO indexed_folders(namespace_id, label, enabled_at)
863                     VALUES (?1, ?2, ?3)
864                     ON CONFLICT(namespace_id) DO UPDATE SET label = excluded.label",
865                    params![namespace_id.to_string(), label, now_seconds()],
866                )
867                .map_err(|error| database_error("failed to enable indexed folder", error))?;
868            Ok(())
869        })
870    }
871
872    /// Remove a namespace and its indexed entries.
873    ///
874    /// # Errors
875    ///
876    /// Returns an error if the folder metadata or entries cannot be removed.
877    pub fn disable_folder(&self, namespace_id: NamespaceId) -> Result<()> {
878        self.with_connection(|connection| {
879            let transaction = connection
880                .unchecked_transaction()
881                .map_err(|error| database_error("failed to begin folder removal", error))?;
882            let namespace = namespace_id.to_string();
883            let mut statement = transaction
884                .prepare("SELECT id FROM indexed_entries WHERE namespace_id = ?1")
885                .map_err(|error| database_error("failed to find folder entries", error))?;
886            let ids = statement
887                .query_map([namespace.as_str()], |row| row.get::<_, i64>(0))
888                .map_err(|error| database_error("failed to list folder entries", error))?
889                .collect::<std::result::Result<Vec<_>, _>>()
890                .map_err(|error| database_error("failed to read folder entries", error))?;
891            drop(statement);
892            for id in ids {
893                transaction
894                    .execute("DELETE FROM indexed_entries_fts WHERE rowid = ?1", [id])
895                    .map_err(|error| database_error("failed to remove folder search entry", error))?;
896            }
897            transaction
898                .execute(
899                    "DELETE FROM indexed_entries WHERE namespace_id = ?1",
900                    [namespace.as_str()],
901                )
902                .map_err(|error| database_error("failed to remove folder entries", error))?;
903            transaction
904                .execute(
905                    "DELETE FROM indexed_folders WHERE namespace_id = ?1",
906                    [namespace.as_str()],
907                )
908                .map_err(|error| database_error("failed to disable indexed folder", error))?;
909            transaction
910                .commit()
911                .map_err(|error| database_error("failed to commit folder removal", error))?;
912            Ok(())
913        })
914    }
915
916    fn with_connection<T>(&self, operation: impl FnOnce(&mut Connection) -> Result<T>) -> Result<T> {
917        let mut connection = self
918            .connection
919            .lock()
920            .map_err(|error| SyncwebError::operation("indexing database lock poisoned", error))?;
921        operation(&mut connection)
922    }
923
924    /// Persist denylist rules to the database.
925    ///
926    /// # Errors
927    ///
928    /// Returns an error if the rules cannot be saved.
929    pub fn save_denylist_rules(&self, rules: &[DenylistRule]) -> Result<()> {
930        self.with_connection(|connection| {
931            connection
932                .execute("DELETE FROM denylist_rules", [])
933                .map_err(|error| database_error("failed to clear denylist rules", error))?;
934            let now = now_seconds();
935            for rule in rules {
936                let (rule_type, rule_value, namespace_id) = match rule {
937                    DenylistRule::Device(d) => ("device", d.as_bytes().to_vec(), None::<String>),
938                    DenylistRule::Hash(h) => ("hash", h.as_bytes().to_vec(), None),
939                    DenylistRule::File { namespace_id: ns, key } => ("file", key.clone(), ns.map(|n| n.to_string())),
940                };
941                connection
942                    .execute(
943                        "INSERT OR REPLACE INTO denylist_rules(rule_type, rule_value, namespace_id, updated_at)
944                     VALUES (?1, ?2, ?3, ?4)",
945                        params![rule_type, rule_value, namespace_id, now],
946                    )
947                    .map_err(|error| database_error("failed to save denylist rule", error))?;
948            }
949            Ok(())
950        })
951    }
952
953    /// Load persisted denylist rules from the database.
954    ///
955    /// # Errors
956    ///
957    /// Returns an error if the rules cannot be read.
958    pub fn load_denylist_rules(&self) -> Result<Vec<DenylistRule>> {
959        self.with_connection(|connection| {
960            let mut stmt = connection
961                .prepare("SELECT rule_type, rule_value, namespace_id FROM denylist_rules ORDER BY updated_at")
962                .map_err(|error| database_error("failed to prepare denylist query", error))?;
963            let rules = stmt
964                .query_map([], |row| {
965                    let rule_type: String = row.get(0)?;
966                    let rule_value: Vec<u8> = row.get(1)?;
967                    let namespace_id: Option<String> = row.get(2)?;
968                    match rule_type.as_str() {
969                        "device" => Ok(DenylistRule::Device(String::from_utf8_lossy(&rule_value).to_string())),
970                        "hash" => {
971                            let arr: [u8; 32] = rule_value.try_into().map_err(|error: Vec<u8>| {
972                                rusqlite::Error::ToSqlConversionFailure(Box::new(SyncwebError::operation(
973                                    "invalid hash length",
974                                    format!("expected 32 bytes, got {}", error.len()),
975                                )))
976                            })?;
977                            Ok(DenylistRule::Hash(Hash::from(arr)))
978                        }
979                        "file" => {
980                            let ns = namespace_id.and_then(|s| s.parse::<NamespaceId>().ok());
981                            Ok(DenylistRule::File {
982                                namespace_id: ns,
983                                key: rule_value,
984                            })
985                        }
986                        _ => Err(rusqlite::Error::ToSqlConversionFailure(Box::new(
987                            SyncwebError::operation("unknown rule type", rule_type),
988                        ))),
989                    }
990                })
991                .map_err(|error| database_error("failed to query denylist rules", error))?
992                .collect::<std::result::Result<Vec<_>, _>>()
993                .map_err(|error| database_error("failed to read denylist rules", error))?;
994            Ok(rules)
995        })
996    }
997
998    /// Load persisted filter list subscriptions from the database.
999    ///
1000    /// # Errors
1001    ///
1002    /// Returns an error if the filter lists cannot be read.
1003    pub fn load_filter_lists(&self) -> Result<Vec<denylist::FilterList>> {
1004        self.with_connection(|connection| {
1005            let mut stmt = connection
1006                .prepare("SELECT namespace_id, sequence, publisher, payload, updated_at FROM filter_lists ORDER BY updated_at")
1007                .map_err(|error| database_error("failed to prepare filter lists query", error))?;
1008            let lists = stmt
1009                .query_map([], |row| {
1010                    let payload: Vec<u8> = row.get(3)?;
1011                    let list: denylist::FilterList = serde_json::from_slice(&payload).map_err(|error| {
1012                        rusqlite::Error::ToSqlConversionFailure(Box::new(SyncwebError::operation(
1013                            "failed to deserialize filter list",
1014                            error,
1015                        )))
1016                    })?;
1017                    Ok(list)
1018                })
1019                .map_err(|error| database_error("failed to query filter lists", error))?
1020                .collect::<std::result::Result<Vec<_>, _>>()
1021                .map_err(|error| database_error("failed to read filter list rows", error))?;
1022            Ok(lists)
1023        })
1024    }
1025
1026    /// Upsert a filter list subscription.
1027    ///
1028    /// # Errors
1029    ///
1030    /// Returns an error if the filter list cannot be persisted.
1031    pub fn upsert_filter_list(&self, list: &denylist::FilterList) -> Result<()> {
1032        let payload = list.to_bytes()?;
1033        self.with_connection(|connection| {
1034            connection
1035                .execute(
1036                    "INSERT OR REPLACE INTO filter_lists(namespace_id, sequence, publisher, payload, updated_at)
1037                 VALUES (?1, ?2, ?3, ?4, ?5)",
1038                    params![
1039                        list.namespace_id.to_string(),
1040                        i64::try_from(list.sequence).unwrap_or(i64::MAX),
1041                        list.publisher,
1042                        payload,
1043                        now_seconds(),
1044                    ],
1045                )
1046                .map_err(|error| database_error("failed to upsert filter list", error))?;
1047            Ok(())
1048        })
1049    }
1050
1051    /// Load stable links from the database.
1052    ///
1053    /// # Errors
1054    ///
1055    /// Returns an error if the links cannot be read.
1056    pub fn load_links(&self) -> Result<(Vec<links::MutablePointer>, Vec<String>, Vec<links::PrivateLink>)> {
1057        self.with_connection(|connection| {
1058            let mut stmt = connection
1059                .prepare("SELECT link, kind, payload, updated_at FROM stable_links ORDER BY updated_at")
1060                .map_err(|error| database_error("failed to prepare links query", error))?;
1061            let mut pointers = Vec::new();
1062            let rows = stmt
1063                .query_map([], |row| {
1064                    let kind: String = row.get(1)?;
1065                    let payload: Vec<u8> = row.get(2)?;
1066                    Ok((kind, payload))
1067                })
1068                .map_err(|error| database_error("failed to query stable links", error))?
1069                .collect::<std::result::Result<Vec<_>, _>>()
1070                .map_err(|error| database_error("failed to read link rows", error))?;
1071            for (kind, payload) in rows {
1072                if kind.as_str() == "mutable"
1073                    && let Ok(p) = serde_json::from_slice::<links::MutablePointer>(&payload)
1074                {
1075                    pointers.push(p);
1076                }
1077            }
1078
1079            let mut mirror_stmt = connection
1080                .prepare("SELECT ticket FROM link_mirrors ORDER BY priority")
1081                .map_err(|error| database_error("failed to prepare mirror query", error))?;
1082            let mirrors = mirror_stmt
1083                .query_map([], |row| row.get::<_, String>(0))
1084                .map_err(|error| database_error("failed to query mirrors", error))?
1085                .collect::<std::result::Result<Vec<_>, _>>()
1086                .map_err(|error| database_error("failed to read mirror rows", error))?;
1087
1088            let mut revoked = Vec::new();
1089            if let Ok(mut rev_stmt) = connection.prepare("SELECT manifest, capability, revoked_at FROM revoked_links")
1090                && let Ok(rev_rows) = rev_stmt.query_map([], |row| {
1091                    let manifest_bytes: Vec<u8> = row.get(0)?;
1092                    let capability_blob: Vec<u8> = row.get(1)?;
1093                    if manifest_bytes.len() != 32 {
1094                        return Err(rusqlite::Error::InvalidColumnType(
1095                            0,
1096                            "manifest must be 32 bytes".to_owned(),
1097                            rusqlite::types::Type::Blob,
1098                        ));
1099                    }
1100                    if capability_blob.len() != 32 {
1101                        return Err(rusqlite::Error::InvalidColumnType(
1102                            1,
1103                            "capability must be 32 bytes".to_owned(),
1104                            rusqlite::types::Type::Blob,
1105                        ));
1106                    }
1107                    let Ok(manifests) = manifest_bytes.try_into() else {
1108                        return Err(rusqlite::Error::InvalidColumnType(
1109                            0,
1110                            "manifest conversion failed despite length check".to_owned(),
1111                            rusqlite::types::Type::Blob,
1112                        ));
1113                    };
1114                    let hash = Hash::from_bytes(manifests);
1115                    let Ok(cap) = capability_blob.try_into() else {
1116                        return Err(rusqlite::Error::InvalidColumnType(
1117                            1,
1118                            "capability conversion failed despite length check".to_owned(),
1119                            rusqlite::types::Type::Blob,
1120                        ));
1121                    };
1122                    Ok((hash, cap))
1123                })
1124            {
1125                for row in rev_rows.flatten() {
1126                    if let Ok(link) = PrivateLink::new(row.0, row.1, u64::MAX) {
1127                        revoked.push(link);
1128                    }
1129                }
1130            }
1131            Ok((pointers, mirrors, revoked))
1132        })
1133    }
1134
1135    /// Save stable links to the database.
1136    ///
1137    /// # Errors
1138    ///
1139    /// Returns an error if the links cannot be persisted.
1140    pub fn save_links(
1141        &self,
1142        pointers: &[links::MutablePointer],
1143        mirrors: &[String],
1144        revoked: &[links::PrivateLink],
1145    ) -> Result<()> {
1146        self.with_connection(|connection| {
1147            connection.execute("DELETE FROM stable_links", [])
1148                .map_err(|error| database_error("failed to clear stable links", error))?;
1149            connection.execute("DELETE FROM link_mirrors", [])
1150                .map_err(|error| database_error("failed to clear link mirrors", error))?;
1151            connection.execute("DELETE FROM revoked_links", [])
1152                .map_err(|error| database_error("failed to clear revoked links", error))?;
1153            let now = now_seconds();
1154            for pointer in pointers {
1155                let payload = serde_json::to_vec(pointer)
1156                    .map_err(|error| database_error("failed to serialize pointer", error))?;
1157                connection.execute(
1158                    "INSERT OR REPLACE INTO stable_links(link, kind, publisher, alias, content_hash, sequence, version, payload, updated_at)
1159                     VALUES (?1, 'mutable', ?2, ?3, NULL, ?4, ?5, ?6, ?7)",
1160                    params![
1161                        format!("mutable:{}:{}", pointer.publisher, pointer.alias),
1162                        pointer.publisher.to_string(),
1163                        pointer.alias,
1164                        i64::try_from(pointer.sequence).unwrap_or(i64::MAX),
1165                         pointer.version.as_deref(),
1166                        payload,
1167                        now,
1168                    ],
1169                ).map_err(|error| database_error("failed to save pointer", error))?;
1170            }
1171            for uri in mirrors {
1172                connection.execute(
1173                    "INSERT INTO link_mirrors(link, provider, ticket, priority, updated_at)
1174                     VALUES ('', 'local', ?1, 0, ?2)",
1175                    params![uri, now],
1176                ).map_err(|error| database_error("failed to save mirror", error))?;
1177            }
1178            for link in revoked {
1179                connection.execute(
1180                    "INSERT OR REPLACE INTO revoked_links(manifest, capability, revoked_at)
1181                     VALUES (?1, ?2, ?3)",
1182                    params![link.manifest.as_bytes(), link.capability.as_slice(), now],
1183                ).map_err(|error| database_error("failed to save revoked link", error))?;
1184            }
1185            Ok(())
1186        })
1187    }
1188
1189    /// Upsert a single mutable pointer to the stable links table.
1190    ///
1191    /// # Errors
1192    ///
1193    /// Returns an error if the pointer cannot be persisted.
1194    pub fn upsert_link_pointer(&self, pointer: &links::MutablePointer) -> Result<()> {
1195        let payload =
1196            serde_json::to_vec(pointer).map_err(|error| database_error("failed to serialize pointer", error))?;
1197        self.with_connection(|connection| {
1198            connection.execute(
1199                "INSERT OR REPLACE INTO stable_links(link, kind, publisher, alias, content_hash, sequence, version, payload, updated_at)
1200                 VALUES (?1, 'mutable', ?2, ?3, NULL, ?4, ?5, ?6, ?7)",
1201                params![
1202                    format!("mutable:{}:{}", pointer.publisher, pointer.alias),
1203                    pointer.publisher.to_string(),
1204                    pointer.alias,
1205                    i64::try_from(pointer.sequence).unwrap_or(i64::MAX),
1206                    pointer.version.as_deref(),
1207                    payload,
1208                    now_seconds(),
1209                ],
1210            )
1211            .map_err(|error| database_error("failed to upsert link pointer", error))?;
1212            Ok(())
1213        })
1214    }
1215
1216    /// Insert a single mirror ticket.
1217    ///
1218    /// # Errors
1219    ///
1220    /// Returns an error if the mirror cannot be persisted.
1221    pub fn upsert_link_mirror(&self, ticket: &str) -> Result<()> {
1222        self.with_connection(|connection| {
1223            connection
1224                .execute(
1225                    "INSERT OR REPLACE INTO link_mirrors(link, provider, ticket, priority, updated_at)
1226                 VALUES ('', 'local', ?1, 0, ?2)",
1227                    params![ticket, now_seconds()],
1228                )
1229                .map_err(|error| database_error("failed to upsert link mirror", error))?;
1230            Ok(())
1231        })
1232    }
1233
1234    /// Save provider leases to the database.
1235    ///
1236    /// # Errors
1237    ///
1238    /// Returns an error if the leases cannot be persisted.
1239    pub fn save_leases(&self, leases: &[resilience::ProviderLease]) -> Result<()> {
1240        self.with_connection(|connection| {
1241            connection.execute("DELETE FROM provider_leases", [])
1242                .map_err(|error| database_error("failed to clear provider leases", error))?;
1243            for lease in leases {
1244                connection.execute(
1245                    "INSERT INTO provider_leases(provider, content_hash, ticket, sequence, issued_at, expires_at, signature)
1246                     VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
1247                    params![
1248                        lease.provider.to_string(),
1249                        lease.hash.as_bytes().to_vec(),
1250                        lease.ticket,
1251                        i64::try_from(lease.sequence).unwrap_or(i64::MAX),
1252                        i64::try_from(lease.issued_at).unwrap_or(i64::MAX),
1253                        i64::try_from(lease.expires_at).unwrap_or(i64::MAX),
1254                        lease.signature,
1255                    ],
1256                ).map_err(|error| database_error("failed to save lease", error))?;
1257            }
1258            Ok(())
1259        })
1260    }
1261
1262    /// Load provider leases from the database.
1263    ///
1264    /// # Errors
1265    ///
1266    /// Returns an error if the leases cannot be read.
1267    pub fn load_leases(&self) -> Result<Vec<resilience::ProviderLease>> {
1268        self.with_connection(|connection| {
1269            let mut stmt = connection
1270                .prepare(
1271                    "SELECT provider, content_hash, ticket, sequence, issued_at, expires_at, signature
1272                 FROM provider_leases ORDER BY issued_at",
1273                )
1274                .map_err(|error| database_error("failed to prepare leases query", error))?;
1275            let leases = stmt
1276                .query_map([], |row| {
1277                    let provider_str: String = row.get(0)?;
1278                    let hash_bytes: Vec<u8> = row.get(1)?;
1279                    let arr: [u8; 32] = hash_bytes.try_into().map_err(|error: Vec<u8>| {
1280                        rusqlite::Error::ToSqlConversionFailure(Box::new(SyncwebError::operation(
1281                            "invalid hash length",
1282                            format!("expected 32 bytes, got {}", error.len()),
1283                        )))
1284                    })?;
1285                    let provider = provider_str.parse::<PublicKey>().map_err(|error| {
1286                        rusqlite::Error::ToSqlConversionFailure(Box::new(SyncwebError::operation(
1287                            "invalid provider",
1288                            error,
1289                        )))
1290                    })?;
1291                    Ok(resilience::ProviderLease {
1292                        hash: Hash::from(arr),
1293                        provider,
1294                        ticket: row.get(2)?,
1295                        sequence: u64::try_from(row.get::<_, i64>(3)?).unwrap_or_default(),
1296                        issued_at: u64::try_from(row.get::<_, i64>(4)?).unwrap_or_default(),
1297                        expires_at: u64::try_from(row.get::<_, i64>(5)?).unwrap_or_default(),
1298                        signature: row.get(6)?,
1299                    })
1300                })
1301                .map_err(|error| database_error("failed to query leases", error))?
1302                .collect::<std::result::Result<Vec<_>, _>>()
1303                .map_err(|error| database_error("failed to read lease rows", error))?;
1304            Ok(leases)
1305        })
1306    }
1307
1308    /// Save trust delegations to the database.
1309    ///
1310    /// # Errors
1311    ///
1312    /// Returns an error if the delegations cannot be persisted.
1313    pub fn save_trust_delegations(&self, delegations: &[wot::TrustDelegation]) -> Result<()> {
1314        self.with_connection(|connection| {
1315            connection.execute("DELETE FROM trust_delegations", [])
1316                .map_err(|error| database_error("failed to clear trust delegations", error))?;
1317            for d in delegations {
1318                connection.execute(
1319                    "INSERT INTO trust_delegations(delegator, delegate, scope, max_depth, sequence, issued_at, expires_at, revoked_at, signature)
1320                     VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
1321                    params![
1322                        d.delegator, d.delegate,
1323                        d.scope.map(|h| h.as_bytes().to_vec()),
1324                        d.max_depth.map(i64::from),
1325                        i64::try_from(d.sequence).unwrap_or(i64::MAX),
1326                        i64::try_from(d.issued_at).unwrap_or(i64::MAX),
1327                        i64::try_from(d.expires_at).unwrap_or(i64::MAX),
1328                        d.revoked_at.map(|v| i64::try_from(v).unwrap_or(i64::MAX)),
1329                        d.signature,
1330                    ],
1331                ).map_err(|error| database_error("failed to save delegation", error))?;
1332            }
1333            Ok(())
1334        })
1335    }
1336
1337    /// Load trust delegations from the database.
1338    ///
1339    /// # Errors
1340    ///
1341    /// Returns an error if the delegations cannot be read.
1342    pub fn load_trust_delegations(&self) -> Result<Vec<wot::TrustDelegation>> {
1343        self.with_connection(|connection| {
1344            let mut stmt = connection
1345                .prepare(
1346                    "SELECT delegator, delegate, scope, max_depth, sequence, issued_at, expires_at, revoked_at, signature
1347                 FROM trust_delegations ORDER BY issued_at",
1348                )
1349                .map_err(|error| database_error("failed to prepare delegations query", error))?;
1350            let delegations = stmt
1351                .query_map([], |row| {
1352                    let scope: Option<Vec<u8>> = row.get(2)?;
1353                    let hash = scope.and_then(|b| {
1354                        let arr: [u8; 32] = b.try_into().ok()?;
1355                        Some(Hash::from(arr))
1356                    });
1357                    let max_depth: Option<i64> = row.get(3)?;
1358                    let revoked_at: Option<i64> = row.get(7)?;
1359                    Ok(wot::TrustDelegation {
1360                        delegator: row.get::<_, String>(0)?,
1361                        delegate: row.get::<_, String>(1)?,
1362                        scope: hash,
1363                        max_depth: max_depth.map(|v| u32::try_from(v).unwrap_or(u32::MAX)),
1364                        sequence: u64::try_from(row.get::<_, i64>(4)?).unwrap_or_default(),
1365                        issued_at: u64::try_from(row.get::<_, i64>(5)?).unwrap_or_default(),
1366                        expires_at: u64::try_from(row.get::<_, i64>(6)?).unwrap_or_default(),
1367                        revoked_at: revoked_at.map(|v| u64::try_from(v).unwrap_or(u64::MAX)),
1368                        signature: row.get(8)?,
1369                    })
1370                })
1371                .map_err(|error| database_error("failed to query delegations", error))?
1372                .collect::<std::result::Result<Vec<_>, _>>()
1373                .map_err(|error| database_error("failed to read delegation rows", error))?;
1374            Ok(delegations)
1375        })
1376    }
1377
1378    /// Save moderation records to the database.
1379    ///
1380    /// # Errors
1381    ///
1382    /// Returns an error if the records cannot be persisted.
1383    pub fn save_moderation_records(&self, records: &[wot::ModerationRecord]) -> Result<()> {
1384        self.with_connection(|connection| {
1385            connection.execute("DELETE FROM moderation_records_v2", [])
1386                .map_err(|error| database_error("failed to clear moderation records", error))?;
1387            for r in records {
1388                let scope_json = serde_json::to_string(&r.scope)
1389                    .map_err(|error| database_error("failed to serialize scope", error))?;
1390                connection.execute(
1391                    "INSERT INTO moderation_records_v2(content_hash, moderator, action, scope_json, sequence, created_at, reason, signature)
1392                     VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
1393                    params![
1394                        r.content.as_bytes().to_vec(), r.moderator,
1395                        format!("{:?}", r.action), scope_json,
1396                        i64::try_from(r.sequence).unwrap_or(i64::MAX),
1397                        i64::try_from(r.created_at).unwrap_or(i64::MAX),
1398                        r.reason,
1399                        r.signature,
1400                    ],
1401                ).map_err(|error| database_error("failed to save moderation record", error))?;
1402            }
1403            Ok(())
1404        })
1405    }
1406
1407    /// Load moderation records from the database.
1408    ///
1409    /// # Errors
1410    ///
1411    /// Returns an error if the records cannot be read.
1412    pub fn load_moderation_records(&self) -> Result<Vec<wot::ModerationRecord>> {
1413        self.with_connection(|connection| {
1414            let mut stmt = connection
1415                .prepare(
1416                    "SELECT content_hash, moderator, action, scope_json, sequence, created_at, reason, signature
1417                 FROM moderation_records_v2 ORDER BY created_at",
1418                )
1419                .map_err(|error| database_error("failed to prepare moderation query", error))?;
1420            let records = stmt
1421                .query_map([], |row| {
1422                    let hash_bytes: Vec<u8> = row.get(0)?;
1423                    let arr: [u8; 32] = hash_bytes.try_into().map_err(|error: Vec<u8>| {
1424                        rusqlite::Error::ToSqlConversionFailure(Box::new(SyncwebError::operation(
1425                            "invalid hash length",
1426                            format!("expected 32 bytes, got {}", error.len()),
1427                        )))
1428                    })?;
1429                    let scope_json: String = row.get(3)?;
1430                    let scope: wot::ModerationScope = serde_json::from_str(&scope_json)
1431                        .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
1432                    let action_str: String = row.get(2)?;
1433                    let action = match action_str.as_str() {
1434                        "Show" => wot::ModerationAction::Show,
1435                        "Hide" => wot::ModerationAction::Hide,
1436                        "Warn" => wot::ModerationAction::Warn,
1437                        "Quarantine" => wot::ModerationAction::Quarantine,
1438                        "Restore" => wot::ModerationAction::Restore,
1439                        _ => {
1440                            return Err(rusqlite::Error::ToSqlConversionFailure(Box::new(
1441                                SyncwebError::operation("unknown moderation action", action_str),
1442                            )));
1443                        }
1444                    };
1445                    Ok(wot::ModerationRecord {
1446                        content: Hash::from(arr),
1447                        moderator: row.get::<_, String>(1)?,
1448                        action,
1449                        scope,
1450                        sequence: u64::try_from(row.get::<_, i64>(4)?).unwrap_or_default(),
1451                        created_at: u64::try_from(row.get::<_, i64>(5)?).unwrap_or_default(),
1452                        reason: row.get::<_, String>(6)?,
1453                        signature: row.get(7)?,
1454                    })
1455                })
1456                .map_err(|error| database_error("failed to query moderation records", error))?
1457                .collect::<std::result::Result<Vec<_>, _>>()
1458                .map_err(|error| database_error("failed to read moderation rows", error))?;
1459            Ok(records)
1460        })
1461    }
1462
1463    /// Save attestations to the database.
1464    ///
1465    /// # Errors
1466    ///
1467    /// Returns an error if the attestations cannot be persisted.
1468    pub fn save_attestations(&self, attestations: &[wot::Attestation]) -> Result<()> {
1469        self.with_connection(|connection| {
1470            connection.execute("DELETE FROM attestations_v2", [])
1471                .map_err(|error| database_error("failed to clear attestations", error))?;
1472            for a in attestations {
1473                let (kind, kind_other) = match &a.kind {
1474                    wot::AttestationKind::License => ("license", None),
1475                    wot::AttestationKind::Provenance => ("provenance", None),
1476                    wot::AttestationKind::Derivative => ("derivative", None),
1477                    wot::AttestationKind::Other(v) => ("other", Some(v.as_str())),
1478                };
1479                connection.execute(
1480                    "INSERT INTO attestations_v2(content_hash, issuer, kind, kind_other, value, sequence, issued_at, signature)
1481                     VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
1482                    params![
1483                        a.content.as_bytes().to_vec(), a.issuer,
1484                        kind, kind_other, a.value,
1485                        i64::try_from(a.sequence).unwrap_or(i64::MAX),
1486                        i64::try_from(a.issued_at).unwrap_or(i64::MAX),
1487                        a.signature,
1488                    ],
1489                ).map_err(|error| database_error("failed to save attestation", error))?;
1490            }
1491            Ok(())
1492        })
1493    }
1494
1495    /// Load attestations from the database.
1496    ///
1497    /// # Errors
1498    ///
1499    /// Returns an error if the attestations cannot be read.
1500    pub fn load_attestations(&self) -> Result<Vec<wot::Attestation>> {
1501        self.with_connection(|connection| {
1502            let mut stmt = connection
1503                .prepare(
1504                    "SELECT content_hash, issuer, kind, kind_other, value, sequence, issued_at, signature
1505                 FROM attestations_v2 ORDER BY issued_at",
1506                )
1507                .map_err(|error| database_error("failed to prepare attestations query", error))?;
1508            let attestations = stmt
1509                .query_map([], |row| {
1510                    let hash_bytes: Vec<u8> = row.get(0)?;
1511                    let arr: [u8; 32] = hash_bytes.try_into().map_err(|error: Vec<u8>| {
1512                        rusqlite::Error::ToSqlConversionFailure(Box::new(SyncwebError::operation(
1513                            "invalid hash length",
1514                            format!("expected 32 bytes, got {}", error.len()),
1515                        )))
1516                    })?;
1517                    let kind_str: String = row.get(2)?;
1518                    let kind_other: Option<String> = row.get(3)?;
1519                    let kind = match kind_str.as_str() {
1520                        "license" => wot::AttestationKind::License,
1521                        "provenance" => wot::AttestationKind::Provenance,
1522                        "derivative" => wot::AttestationKind::Derivative,
1523                        "other" => wot::AttestationKind::Other(kind_other.unwrap_or_default()),
1524                        _ => {
1525                            return Err(rusqlite::Error::ToSqlConversionFailure(Box::new(
1526                                SyncwebError::operation("unknown attestation kind", kind_str),
1527                            )));
1528                        }
1529                    };
1530                    Ok(wot::Attestation {
1531                        content: Hash::from(arr),
1532                        issuer: row.get::<_, String>(1)?,
1533                        kind,
1534                        value: row.get::<_, String>(4)?,
1535                        sequence: u64::try_from(row.get::<_, i64>(5)?).unwrap_or_default(),
1536                        issued_at: u64::try_from(row.get::<_, i64>(6)?).unwrap_or_default(),
1537                        signature: row.get(7)?,
1538                    })
1539                })
1540                .map_err(|error| database_error("failed to query attestations", error))?
1541                .collect::<std::result::Result<Vec<_>, _>>()
1542                .map_err(|error| database_error("failed to read attestation rows", error))?;
1543            Ok(attestations)
1544        })
1545    }
1546
1547    /// Save content reports to the database.
1548    ///
1549    /// # Errors
1550    ///
1551    /// Returns an error if the reports cannot be persisted.
1552    pub fn save_content_reports(&self, reports: &[ReportRecord]) -> Result<()> {
1553        self.with_connection(|connection| {
1554            connection
1555                .execute("DELETE FROM content_reports_v2", [])
1556                .map_err(|error| database_error("failed to clear content reports", error))?;
1557            for r in reports {
1558                let reporter = r.reporter.as_deref().unwrap_or("cli");
1559                connection
1560                    .execute(
1561                        "INSERT INTO content_reports_v2(content_hash, reporter, reason, scope, created_at, signature)
1562                     VALUES (?1, ?2, ?3, 'global', ?4, ?5)",
1563                        params![
1564                            r.content.as_bytes().to_vec(),
1565                            reporter,
1566                            r.reason,
1567                            i64::try_from(r.created_at).unwrap_or(i64::MAX),
1568                            r.signature,
1569                        ],
1570                    )
1571                    .map_err(|error| database_error("failed to save content report", error))?;
1572            }
1573            Ok(())
1574        })
1575    }
1576
1577    /// Load content reports from the database.
1578    ///
1579    /// # Errors
1580    ///
1581    /// Returns an error if the reports cannot be read.
1582    pub fn load_content_reports(&self) -> Result<Vec<ReportRecord>> {
1583        self.with_connection(|connection| {
1584            let mut stmt = connection
1585                .prepare(
1586                    "SELECT content_hash, reason, created_at, reporter, signature
1587                     FROM content_reports_v2 ORDER BY created_at",
1588                )
1589                .map_err(|error| database_error("failed to prepare reports query", error))?;
1590            let reports = stmt
1591                .query_map([], |row| {
1592                    let hash_bytes: Vec<u8> = row.get(0)?;
1593                    let arr: [u8; 32] = hash_bytes.try_into().map_err(|error: Vec<u8>| {
1594                        rusqlite::Error::ToSqlConversionFailure(Box::new(SyncwebError::operation(
1595                            "invalid hash length",
1596                            format!("expected 32 bytes, got {}", error.len()),
1597                        )))
1598                    })?;
1599                    Ok(ReportRecord {
1600                        content: Hash::from(arr),
1601                        reason: row.get::<_, String>(1)?,
1602                        created_at: u64::try_from(row.get::<_, i64>(2)?).unwrap_or_default(),
1603                        reporter: row.get::<_, Option<String>>(3)?,
1604                        signature: row.get::<_, Option<String>>(4)?,
1605                    })
1606                })
1607                .map_err(|error| database_error("failed to query content reports", error))?
1608                .collect::<std::result::Result<Vec<_>, _>>()
1609                .map_err(|error| database_error("failed to read report rows", error))?;
1610            Ok(reports)
1611        })
1612    }
1613
1614    /// Save provider bans to the database.
1615    ///
1616    /// # Errors
1617    ///
1618    /// Returns an error if the bans cannot be persisted.
1619    pub fn save_provider_bans(&self, bans: &[resilience::BanRecord]) -> Result<()> {
1620        self.with_connection(|connection| {
1621            connection
1622                .execute("DELETE FROM provider_bans_v2", [])
1623                .map_err(|error| database_error("failed to clear provider bans", error))?;
1624            for b in bans {
1625                connection
1626                    .execute(
1627                        "INSERT INTO provider_bans_v2(provider, content_hash, banned_at, expires_at, reason, source)
1628                     VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
1629                        params![
1630                            b.provider.to_string(),
1631                            b.hash.map(|h| h.as_bytes().to_vec()),
1632                            i64::try_from(b.banned_at).unwrap_or(i64::MAX),
1633                            b.expires_at.map(|v| i64::try_from(v).unwrap_or(i64::MAX)),
1634                            b.reason,
1635                            format!("{:?}", b.source),
1636                        ],
1637                    )
1638                    .map_err(|error| database_error("failed to save provider ban", error))?;
1639            }
1640            Ok(())
1641        })
1642    }
1643
1644    /// Load provider bans from the database.
1645    ///
1646    /// # Errors
1647    ///
1648    /// Returns an error if the bans cannot be read.
1649    pub fn load_provider_bans(&self) -> Result<Vec<resilience::BanRecord>> {
1650        self.with_connection(|connection| {
1651            let mut stmt = connection
1652                .prepare(
1653                    "SELECT provider, content_hash, banned_at, expires_at, reason, source
1654                 FROM provider_bans_v2 ORDER BY banned_at",
1655                )
1656                .map_err(|error| database_error("failed to prepare bans query", error))?;
1657            let bans = stmt
1658                .query_map([], |row| {
1659                    let provider_str: String = row.get(0)?;
1660                    let content_hash: Option<Vec<u8>> = row.get(1)?;
1661                    let source_str: String = row.get(5)?;
1662                    let provider = provider_str.parse::<PublicKey>().map_err(|error| {
1663                        rusqlite::Error::ToSqlConversionFailure(Box::new(SyncwebError::operation(
1664                            "invalid provider",
1665                            error,
1666                        )))
1667                    })?;
1668                    let hash = content_hash.and_then(|b| {
1669                        let arr: [u8; 32] = b.try_into().ok()?;
1670                        Some(Hash::from(arr))
1671                    });
1672                    let source = match source_str.as_str() {
1673                        "Automated" => resilience::BanSource::Automated,
1674                        "WoT" => resilience::BanSource::WoT,
1675                        _ => resilience::BanSource::Manual,
1676                    };
1677                    Ok(resilience::BanRecord {
1678                        provider,
1679                        hash,
1680                        banned_at: u64::try_from(row.get::<_, i64>(2)?).unwrap_or_default(),
1681                        expires_at: row
1682                            .get::<_, Option<i64>>(3)?
1683                            .map(|v| u64::try_from(v).unwrap_or_default()),
1684                        reason: row.get::<_, String>(4)?,
1685                        source,
1686                    })
1687                })
1688                .map_err(|error| database_error("failed to query provider bans", error))?
1689                .collect::<std::result::Result<Vec<_>, _>>()
1690                .map_err(|error| database_error("failed to read ban rows", error))?;
1691            Ok(bans)
1692        })
1693    }
1694
1695    /// Insert or replace a single provider lease.
1696    ///
1697    /// # Errors
1698    ///
1699    /// Returns an error if the lease cannot be persisted.
1700    pub fn insert_provider_lease(&self, lease: &resilience::ProviderLease) -> Result<()> {
1701        self.with_connection(|connection| {
1702            connection.execute(
1703                "INSERT OR REPLACE INTO provider_leases(provider, content_hash, ticket, sequence, issued_at, expires_at, signature)
1704                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
1705                params![
1706                    lease.provider.to_string(),
1707                    lease.hash.as_bytes().to_vec(),
1708                    lease.ticket,
1709                    i64::try_from(lease.sequence).unwrap_or(i64::MAX),
1710                    i64::try_from(lease.issued_at).unwrap_or(i64::MAX),
1711                    i64::try_from(lease.expires_at).unwrap_or(i64::MAX),
1712                    lease.signature,
1713                ],
1714            )
1715            .map_err(|error| database_error("failed to insert provider lease", error))?;
1716            Ok(())
1717        })
1718    }
1719
1720    /// Load active (non-expired) provider leases from the database.
1721    ///
1722    /// # Errors
1723    ///
1724    /// Returns an error if the leases cannot be read.
1725    pub fn load_active_leases(&self, now: u64) -> Result<Vec<resilience::ProviderLease>> {
1726        self.with_connection(|connection| {
1727            let mut stmt = connection
1728                .prepare(
1729                    "SELECT provider, content_hash, ticket, sequence, issued_at, expires_at, signature
1730                 FROM provider_leases WHERE expires_at > ?1 ORDER BY issued_at",
1731                )
1732                .map_err(|error| database_error("failed to prepare active leases query", error))?;
1733            let leases = stmt
1734                .query_map(params![i64::try_from(now).unwrap_or(i64::MAX)], |row| {
1735                    let provider_str: String = row.get(0)?;
1736                    let hash_bytes: Vec<u8> = row.get(1)?;
1737                    let arr: [u8; 32] = hash_bytes.try_into().map_err(|error: Vec<u8>| {
1738                        rusqlite::Error::ToSqlConversionFailure(Box::new(SyncwebError::operation(
1739                            "invalid hash length",
1740                            format!("expected 32 bytes, got {}", error.len()),
1741                        )))
1742                    })?;
1743                    let provider = provider_str.parse::<PublicKey>().map_err(|error| {
1744                        rusqlite::Error::ToSqlConversionFailure(Box::new(SyncwebError::operation(
1745                            "invalid provider",
1746                            error,
1747                        )))
1748                    })?;
1749                    Ok(resilience::ProviderLease {
1750                        hash: Hash::from(arr),
1751                        provider,
1752                        ticket: row.get(2)?,
1753                        sequence: u64::try_from(row.get::<_, i64>(3)?).unwrap_or_default(),
1754                        issued_at: u64::try_from(row.get::<_, i64>(4)?).unwrap_or_default(),
1755                        expires_at: u64::try_from(row.get::<_, i64>(5)?).unwrap_or_default(),
1756                        signature: row.get(6)?,
1757                    })
1758                })
1759                .map_err(|error| database_error("failed to query active leases", error))?
1760                .collect::<std::result::Result<Vec<_>, _>>()
1761                .map_err(|error| database_error("failed to read lease rows", error))?;
1762            Ok(leases)
1763        })
1764    }
1765
1766    /// Insert or replace a single provider ban record.
1767    ///
1768    /// # Errors
1769    ///
1770    /// Returns an error if the ban cannot be persisted.
1771    pub fn insert_provider_ban(&self, ban: &resilience::BanRecord) -> Result<()> {
1772        self.with_connection(|connection| {
1773            connection
1774                .execute(
1775                    "INSERT OR REPLACE INTO provider_bans_v2(provider, content_hash, banned_at, expires_at, reason, source)
1776                 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
1777                    params![
1778                        ban.provider.to_string(),
1779                        ban.hash.map(|h| h.as_bytes().to_vec()),
1780                        i64::try_from(ban.banned_at).unwrap_or(i64::MAX),
1781                        ban.expires_at.map(|v| i64::try_from(v).unwrap_or(i64::MAX)),
1782                        ban.reason,
1783                        format!("{:?}", ban.source),
1784                    ],
1785                )
1786                .map_err(|error| database_error("failed to insert provider ban", error))?;
1787            Ok(())
1788        })
1789    }
1790
1791    /// Load active (non-expired) provider bans from the database.
1792    ///
1793    /// # Errors
1794    ///
1795    /// Returns an error if the bans cannot be read.
1796    pub fn load_active_bans(&self, now: u64) -> Result<Vec<resilience::BanRecord>> {
1797        self.with_connection(|connection| {
1798            let mut stmt = connection
1799                .prepare(
1800                    "SELECT provider, content_hash, banned_at, expires_at, reason, source
1801                 FROM provider_bans_v2 WHERE expires_at IS NULL OR expires_at > ?1 ORDER BY banned_at",
1802                )
1803                .map_err(|error| database_error("failed to prepare active bans query", error))?;
1804            let bans = stmt
1805                .query_map(params![i64::try_from(now).unwrap_or(i64::MAX)], |row| {
1806                    let provider_str: String = row.get(0)?;
1807                    let content_hash: Option<Vec<u8>> = row.get(1)?;
1808                    let source_str: String = row.get(5)?;
1809                    let provider = provider_str.parse::<PublicKey>().map_err(|error| {
1810                        rusqlite::Error::ToSqlConversionFailure(Box::new(SyncwebError::operation(
1811                            "invalid provider",
1812                            error,
1813                        )))
1814                    })?;
1815                    let hash = content_hash.and_then(|b| {
1816                        let arr: [u8; 32] = b.try_into().ok()?;
1817                        Some(Hash::from(arr))
1818                    });
1819                    let source = match source_str.as_str() {
1820                        "Automated" => resilience::BanSource::Automated,
1821                        "WoT" => resilience::BanSource::WoT,
1822                        _ => resilience::BanSource::Manual,
1823                    };
1824                    Ok(resilience::BanRecord {
1825                        provider,
1826                        hash,
1827                        banned_at: u64::try_from(row.get::<_, i64>(2)?).unwrap_or_default(),
1828                        expires_at: row
1829                            .get::<_, Option<i64>>(3)?
1830                            .map(|v| u64::try_from(v).unwrap_or_default()),
1831                        reason: row.get::<_, String>(4)?,
1832                        source,
1833                    })
1834                })
1835                .map_err(|error| database_error("failed to query active provider bans", error))?
1836                .collect::<std::result::Result<Vec<_>, _>>()
1837                .map_err(|error| database_error("failed to read ban rows", error))?;
1838            Ok(bans)
1839        })
1840    }
1841
1842    /// Load all provider reputations from the database.
1843    ///
1844    /// # Errors
1845    ///
1846    /// Returns an error if the reputations cannot be read.
1847    pub fn load_all_reputations(&self) -> Result<HashMap<PublicKey, reputation::ProviderReputation>> {
1848        self.with_connection(|connection| {
1849            let mut stmt = connection
1850                .prepare(
1851                    "SELECT provider, total_fetches, successful_fetches, failed_fetches,
1852                     consecutive_failures, last_success_at, last_failure_at
1853                 FROM provider_reputation",
1854                )
1855                .map_err(|error| database_error("failed to prepare reputations query", error))?;
1856            let rows = stmt
1857                .query_map([], |row| {
1858                    let provider_str: String = row.get(0)?;
1859                    let provider = provider_str.parse::<PublicKey>().map_err(|error| {
1860                        rusqlite::Error::ToSqlConversionFailure(Box::new(SyncwebError::operation(
1861                            "invalid provider",
1862                            error,
1863                        )))
1864                    })?;
1865                    let rep = reputation::ProviderReputation {
1866                        provider,
1867                        total_fetches: u64::try_from(row.get::<_, i64>(1)?).unwrap_or_default(),
1868                        successful_fetches: u64::try_from(row.get::<_, i64>(2)?).unwrap_or_default(),
1869                        failed_fetches: u64::try_from(row.get::<_, i64>(3)?).unwrap_or_default(),
1870                        consecutive_failures: u32::try_from(row.get::<_, i64>(4)?).unwrap_or_default(),
1871                        last_success_at: row
1872                            .get::<_, Option<i64>>(5)?
1873                            .map(|v| u64::try_from(v).unwrap_or_default()),
1874                        last_failure_at: row
1875                            .get::<_, Option<i64>>(6)?
1876                            .map(|v| u64::try_from(v).unwrap_or_default()),
1877                    };
1878                    Ok((provider, rep))
1879                })
1880                .map_err(|error| database_error("failed to query reputations", error))?
1881                .collect::<std::result::Result<HashMap<_, _>, _>>()
1882                .map_err(|error| database_error("failed to read reputation rows", error))?;
1883            Ok(rows)
1884        })
1885    }
1886
1887    /// Load all signal sequence numbers from the database.
1888    ///
1889    /// # Errors
1890    ///
1891    /// Returns an error if the sequences cannot be read.
1892    pub fn load_signal_sequences(&self) -> Result<HashMap<(PublicKey, PublicKey), u64>> {
1893        self.with_connection(|connection| {
1894            let mut stmt = connection
1895                .prepare("SELECT reporter, provider, last_sequence FROM provider_signal_sequences")
1896                .map_err(|error| database_error("failed to prepare signal sequences query", error))?;
1897            let rows = stmt
1898                .query_map([], |row| {
1899                    let reporter_str: String = row.get(0)?;
1900                    let provider_str: String = row.get(1)?;
1901                    let reporter = reporter_str.parse::<PublicKey>().map_err(|error| {
1902                        rusqlite::Error::ToSqlConversionFailure(Box::new(SyncwebError::operation(
1903                            "invalid reporter",
1904                            error,
1905                        )))
1906                    })?;
1907                    let provider = provider_str.parse::<PublicKey>().map_err(|error| {
1908                        rusqlite::Error::ToSqlConversionFailure(Box::new(SyncwebError::operation(
1909                            "invalid provider",
1910                            error,
1911                        )))
1912                    })?;
1913                    Ok((
1914                        (reporter, provider),
1915                        u64::try_from(row.get::<_, i64>(2)?).unwrap_or_default(),
1916                    ))
1917                })
1918                .map_err(|error| database_error("failed to query signal sequences", error))?
1919                .collect::<std::result::Result<HashMap<_, _>, _>>()
1920                .map_err(|error| database_error("failed to read signal sequence rows", error))?;
1921            Ok(rows)
1922        })
1923    }
1924
1925    /// Upsert a provider reputation record.
1926    ///
1927    /// # Errors
1928    ///
1929    /// Returns an error if the record cannot be persisted.
1930    pub fn upsert_reputation(
1931        &self,
1932        provider: &PublicKey,
1933        rep: &reputation::ProviderReputation,
1934        auto_ban_until: Option<u64>,
1935        auto_ban_count: u32,
1936    ) -> Result<()> {
1937        self.with_connection(|connection| {
1938            connection
1939                .execute(
1940                    "INSERT OR REPLACE INTO provider_reputation
1941                 (provider, total_fetches, successful_fetches, failed_fetches,
1942                  consecutive_failures, last_success_at, last_failure_at,
1943                  auto_ban_until, auto_ban_count)
1944                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
1945                    params![
1946                        provider.to_string(),
1947                        i64::try_from(rep.total_fetches).unwrap_or(i64::MAX),
1948                        i64::try_from(rep.successful_fetches).unwrap_or(i64::MAX),
1949                        i64::try_from(rep.failed_fetches).unwrap_or(i64::MAX),
1950                        i64::from(rep.consecutive_failures),
1951                        rep.last_success_at.map(|v| i64::try_from(v).unwrap_or(i64::MAX)),
1952                        rep.last_failure_at.map(|v| i64::try_from(v).unwrap_or(i64::MAX)),
1953                        auto_ban_until.map(|v| i64::try_from(v).unwrap_or(i64::MAX)),
1954                        i64::from(auto_ban_count),
1955                    ],
1956                )
1957                .map_err(|error| database_error("failed to upsert reputation", error))?;
1958            Ok(())
1959        })
1960    }
1961
1962    /// Delete stale reputation records last active before the given TTL.
1963    ///
1964    /// # Errors
1965    ///
1966    /// Returns an error if the deletion fails.
1967    pub fn delete_stale_reputations(&self, now: u64, ttl_secs: u64) -> Result<()> {
1968        self.with_connection(|connection| {
1969            let cutoff = i64::try_from(now.saturating_sub(ttl_secs)).unwrap_or(i64::MIN);
1970            connection
1971                .execute(
1972                    "DELETE FROM provider_reputation
1973                 WHERE (last_success_at IS NULL OR last_success_at < ?1)
1974                   AND (last_failure_at IS NULL OR last_failure_at < ?1)",
1975                    params![cutoff],
1976                )
1977                .map_err(|error| database_error("failed to delete stale reputations", error))?;
1978            Ok(())
1979        })
1980    }
1981
1982    /// Load auto-ban information for all providers.
1983    ///
1984    /// Returns a map of provider to (`auto_ban_until`, `auto_ban_count`).
1985    ///
1986    /// # Errors
1987    ///
1988    /// Returns an error if the data cannot be read.
1989    pub fn load_auto_bans(&self) -> Result<HashMap<PublicKey, (Option<u64>, u32)>> {
1990        self.with_connection(|connection| {
1991            let mut stmt = connection
1992                .prepare("SELECT provider, auto_ban_until, auto_ban_count FROM provider_reputation")
1993                .map_err(|error| database_error("failed to prepare auto bans query", error))?;
1994            let rows = stmt
1995                .query_map([], |row| {
1996                    let provider_str: String = row.get(0)?;
1997                    let provider = provider_str.parse::<PublicKey>().map_err(|error| {
1998                        rusqlite::Error::ToSqlConversionFailure(Box::new(SyncwebError::operation(
1999                            "invalid provider",
2000                            error,
2001                        )))
2002                    })?;
2003                    Ok((
2004                        provider,
2005                        (
2006                            row.get::<_, Option<i64>>(1)?
2007                                .map(|v| u64::try_from(v).unwrap_or_default()),
2008                            u32::try_from(row.get::<_, i64>(2)?).unwrap_or_default(),
2009                        ),
2010                    ))
2011                })
2012                .map_err(|error| database_error("failed to query auto bans", error))?
2013                .collect::<std::result::Result<HashMap<_, _>, _>>()
2014                .map_err(|error| database_error("failed to read auto ban rows", error))?;
2015            Ok(rows)
2016        })
2017    }
2018
2019    /// Upsert a provider signal sequence tracker.
2020    ///
2021    /// # Errors
2022    ///
2023    /// Returns an error if the sequence cannot be persisted.
2024    pub fn upsert_signal_sequence(&self, reporter: &PublicKey, provider: &PublicKey, sequence: u64) -> Result<()> {
2025        self.with_connection(|connection| {
2026            connection
2027                .execute(
2028                    "INSERT OR REPLACE INTO provider_signal_sequences(reporter, provider, last_sequence)
2029                 VALUES (?1, ?2, ?3)",
2030                    params![
2031                        reporter.to_string(),
2032                        provider.to_string(),
2033                        i64::try_from(sequence).unwrap_or(i64::MAX),
2034                    ],
2035                )
2036                .map_err(|error| database_error("failed to upsert signal sequence", error))?;
2037            Ok(())
2038        })
2039    }
2040
2041    /// Save provider trust records to the database.
2042    ///
2043    /// # Errors
2044    ///
2045    /// Returns an error if the records cannot be persisted.
2046    pub fn save_provider_trust_records(&self, records: &[wot::ProviderTrustRecord]) -> Result<()> {
2047        self.with_connection(|connection| {
2048            connection.execute("DELETE FROM provider_trust_records_v2", [])
2049                .map_err(|error| database_error("failed to clear provider trust records", error))?;
2050            for r in records {
2051                let action_str = format!("{:?}", r.action);
2052                connection.execute(
2053                    "INSERT INTO provider_trust_records_v2(provider, action, scope, issuer, sequence, issued_at, expires_at, reason, signature)
2054                     VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
2055                    params![
2056                        r.provider.to_string(), action_str,
2057                        r.scope.map(|h| h.as_bytes().to_vec()),
2058                        r.issuer,
2059                        i64::try_from(r.sequence).unwrap_or(i64::MAX),
2060                        i64::try_from(r.issued_at).unwrap_or(i64::MAX),
2061                        r.expires_at.map(|v| i64::try_from(v).unwrap_or(i64::MAX)),
2062                        r.reason,
2063                        r.signature,
2064                    ],
2065                ).map_err(|error| database_error("failed to save provider trust record", error))?;
2066            }
2067            Ok(())
2068        })
2069    }
2070
2071    /// Load provider trust records from the database.
2072    ///
2073    /// # Errors
2074    ///
2075    /// Returns an error if the records cannot be read.
2076    pub fn load_provider_trust_records(&self) -> Result<Vec<wot::ProviderTrustRecord>> {
2077        self.with_connection(|connection| {
2078            let mut stmt = connection
2079                .prepare(
2080                    "SELECT provider, action, scope, issuer, sequence, issued_at, expires_at, reason, signature
2081                 FROM provider_trust_records_v2 ORDER BY issued_at",
2082                )
2083                .map_err(|error| database_error("failed to prepare trust records query", error))?;
2084            let records = stmt
2085                .query_map([], |row| {
2086                    let provider_str: String = row.get(0)?;
2087                    let action_str: String = row.get(1)?;
2088                    let scope: Option<Vec<u8>> = row.get(2)?;
2089                    let provider = provider_str.parse::<PublicKey>().map_err(|error| {
2090                        rusqlite::Error::ToSqlConversionFailure(Box::new(SyncwebError::operation(
2091                            "invalid provider",
2092                            error,
2093                        )))
2094                    })?;
2095                    let action = match action_str.as_str() {
2096                        "Trust" => wot::ProviderTrustAction::Trust,
2097                        "Distrust" => wot::ProviderTrustAction::Distrust,
2098                        "Vouch" => wot::ProviderTrustAction::Vouch,
2099                        "Warn" => wot::ProviderTrustAction::Warn,
2100                        _ => {
2101                            return Err(rusqlite::Error::ToSqlConversionFailure(Box::new(
2102                                SyncwebError::operation("unknown trust action", action_str),
2103                            )));
2104                        }
2105                    };
2106                    let hash = scope.and_then(|b| {
2107                        let arr: [u8; 32] = b.try_into().ok()?;
2108                        Some(Hash::from(arr))
2109                    });
2110                    Ok(wot::ProviderTrustRecord {
2111                        provider,
2112                        action,
2113                        scope: hash,
2114                        issuer: row.get::<_, String>(3)?,
2115                        sequence: u64::try_from(row.get::<_, i64>(4)?).unwrap_or_default(),
2116                        issued_at: u64::try_from(row.get::<_, i64>(5)?).unwrap_or_default(),
2117                        expires_at: row
2118                            .get::<_, Option<i64>>(6)?
2119                            .map(|v| u64::try_from(v).unwrap_or_default()),
2120                        reason: row.get::<_, String>(7)?,
2121                        signature: row.get(8)?,
2122                    })
2123                })
2124                .map_err(|error| database_error("failed to query provider trust records", error))?
2125                .collect::<std::result::Result<Vec<_>, _>>()
2126                .map_err(|error| database_error("failed to read trust record rows", error))?;
2127            Ok(records)
2128        })
2129    }
2130
2131    /// Save provider trust signals to the database.
2132    ///
2133    /// # Errors
2134    ///
2135    /// Returns an error if the signals cannot be persisted.
2136    pub fn save_provider_trust_signals(&self, signals: &[reputation::ProviderTrustSignal]) -> Result<()> {
2137        self.with_connection(|connection| {
2138            connection.execute("DELETE FROM provider_trust_signals_v2", [])
2139                .map_err(|error| database_error("failed to clear provider trust signals", error))?;
2140            for s in signals {
2141                let kind_str = format!("{:?}", s.signal);
2142                connection.execute(
2143                    "INSERT INTO provider_trust_signals_v2(reporter, provider, signal_kind, content_hash, sequence, timestamp, signature)
2144                     VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
2145                    params![
2146                        s.reporter.to_string(), s.provider.to_string(),
2147                        kind_str,
2148                        s.hash.map(|h| h.as_bytes().to_vec()),
2149                        i64::try_from(s.sequence).unwrap_or(i64::MAX),
2150                        i64::try_from(s.timestamp).unwrap_or(i64::MAX),
2151                        s.signature,
2152                    ],
2153                ).map_err(|error| database_error("failed to save trust signal", error))?;
2154            }
2155            Ok(())
2156        })
2157    }
2158
2159    /// Load provider trust signals from the database.
2160    ///
2161    /// # Errors
2162    ///
2163    /// Returns an error if the signals cannot be read.
2164    pub fn load_provider_trust_signals(&self) -> Result<Vec<reputation::ProviderTrustSignal>> {
2165        self.with_connection(|connection| {
2166            let mut stmt = connection
2167                .prepare(
2168                    "SELECT reporter, provider, signal_kind, content_hash, sequence, timestamp, signature
2169                 FROM provider_trust_signals_v2 ORDER BY timestamp",
2170                )
2171                .map_err(|error| database_error("failed to prepare trust signals query", error))?;
2172            let signals = stmt
2173                .query_map([], |row| {
2174                    let reporter_str: String = row.get(0)?;
2175                    let provider_str: String = row.get(1)?;
2176                    let kind_str: String = row.get(2)?;
2177                    let content_hash: Option<Vec<u8>> = row.get(3)?;
2178                    let reporter = reporter_str.parse::<PublicKey>().map_err(|error| {
2179                        rusqlite::Error::ToSqlConversionFailure(Box::new(SyncwebError::operation(
2180                            "invalid reporter",
2181                            error,
2182                        )))
2183                    })?;
2184                    let provider = provider_str.parse::<PublicKey>().map_err(|error| {
2185                        rusqlite::Error::ToSqlConversionFailure(Box::new(SyncwebError::operation(
2186                            "invalid provider",
2187                            error,
2188                        )))
2189                    })?;
2190                    let kind = match kind_str.as_str() {
2191                        "ObservedSuccess" => reputation::TrustSignalKind::ObservedSuccess,
2192                        "ObservedFailure" => reputation::TrustSignalKind::ObservedFailure,
2193                        "ObservedCorruption" => reputation::TrustSignalKind::ObservedCorruption,
2194                        _ => {
2195                            return Err(rusqlite::Error::ToSqlConversionFailure(Box::new(
2196                                SyncwebError::operation("unknown signal kind", kind_str),
2197                            )));
2198                        }
2199                    };
2200                    let hash = content_hash.and_then(|b| {
2201                        let arr: [u8; 32] = b.try_into().ok()?;
2202                        Some(Hash::from(arr))
2203                    });
2204                    Ok(reputation::ProviderTrustSignal {
2205                        provider,
2206                        signal: kind,
2207                        hash,
2208                        reporter,
2209                        timestamp: u64::try_from(row.get::<_, i64>(5)?).unwrap_or_default(),
2210                        sequence: u64::try_from(row.get::<_, i64>(4)?).unwrap_or_default(),
2211                        signature: row.get(6)?,
2212                    })
2213                })
2214                .map_err(|error| database_error("failed to query trust signals", error))?
2215                .collect::<std::result::Result<Vec<_>, _>>()
2216                .map_err(|error| database_error("failed to read trust signal rows", error))?;
2217            Ok(signals)
2218        })
2219    }
2220
2221    /// Save trust streams to the database.
2222    ///
2223    /// # Errors
2224    ///
2225    /// Returns an error if the streams cannot be persisted.
2226    pub fn save_trust_streams(&self, streams: &[String]) -> Result<()> {
2227        self.with_connection(|connection| {
2228            connection
2229                .execute("DELETE FROM trust_streams", [])
2230                .map_err(|error| database_error("failed to clear trust streams", error))?;
2231            let now = now_seconds();
2232            for ns in streams {
2233                connection
2234                    .execute(
2235                        "INSERT INTO trust_streams(namespace, subscribed_at) VALUES (?1, ?2)",
2236                        params![ns, now],
2237                    )
2238                    .map_err(|error| database_error("failed to save trust stream", error))?;
2239            }
2240            Ok(())
2241        })
2242    }
2243
2244    /// Load trust streams from the database.
2245    ///
2246    /// # Errors
2247    ///
2248    /// Returns an error if the streams cannot be read.
2249    pub fn load_trust_streams(&self) -> Result<Vec<String>> {
2250        self.with_connection(|connection| {
2251            let mut stmt = connection
2252                .prepare("SELECT namespace FROM trust_streams ORDER BY subscribed_at")
2253                .map_err(|error| database_error("failed to prepare trust streams query", error))?;
2254            let streams = stmt
2255                .query_map([], |row| row.get::<_, String>(0))
2256                .map_err(|error| database_error("failed to query trust streams", error))?
2257                .collect::<std::result::Result<Vec<_>, _>>()
2258                .map_err(|error| database_error("failed to read trust stream rows", error))?;
2259            Ok(streams)
2260        })
2261    }
2262}
2263
2264/// The opt-in indexing service for synchronized folders.
2265#[derive(Clone)]
2266pub struct IndexingService {
2267    database: IndexingDatabase,
2268    events: broadcast::Sender<IndexingEvent>,
2269    tasks: Arc<Mutex<HashMap<NamespaceId, JoinHandle<()>>>>,
2270    denylist: denylist::DenylistService,
2271}
2272
2273impl std::fmt::Debug for IndexingService {
2274    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2275        formatter
2276            .debug_struct("IndexingService")
2277            .field("database", &self.database)
2278            .finish_non_exhaustive()
2279    }
2280}
2281
2282impl IndexingService {
2283    /// Start an indexing service backed by `path`.
2284    ///
2285    /// # Errors
2286    ///
2287    /// Returns an error if the database cannot be opened or initialized.
2288    pub fn new(path: impl AsRef<Path>) -> Result<Self> {
2289        Self::with_database(IndexingDatabase::open(path)?)
2290    }
2291
2292    /// Start an in-memory indexing service.
2293    ///
2294    /// # Errors
2295    ///
2296    /// Returns an error if `SQLite` cannot initialize the schema.
2297    pub fn in_memory() -> Result<Self> {
2298        Self::with_database(IndexingDatabase::in_memory()?)
2299    }
2300
2301    /// Start a service from an already opened database.
2302    ///
2303    /// # Errors
2304    ///
2305    /// Returns an error if the denylist rules cannot be loaded from the database.
2306    pub fn with_database(database: IndexingDatabase) -> Result<Self> {
2307        let (events, _) = broadcast::channel(EVENT_CAPACITY);
2308        let denylist = denylist::DenylistService::with_database(database.clone())?;
2309        Ok(Self {
2310            database,
2311            events,
2312            tasks: Arc::new(Mutex::new(HashMap::new())),
2313            denylist,
2314        })
2315    }
2316
2317    /// Create a catalog service backed by this indexing service.
2318    #[must_use]
2319    pub fn catalog_service(
2320        &self,
2321        docs: &crate::node::docs_engine::DocsEngine,
2322        blobs: &crate::node::blob_store::BlobStore,
2323        author: iroh_docs::AuthorId,
2324    ) -> CatalogService {
2325        CatalogService::new(self, docs, blobs, author)
2326    }
2327
2328    /// Create a lease-based resilience service for this indexer.
2329    #[must_use]
2330    pub fn resilience_service(&self, config: resilience::ResilienceConfig) -> resilience::ResilienceService {
2331        resilience::ResilienceService::with_database(self.database.clone(), config)
2332    }
2333
2334    /// Create a resilience service using this indexer's local `WoT` policy.
2335    #[must_use]
2336    pub fn resilience_service_with_wot(
2337        &self,
2338        config: resilience::ResilienceConfig,
2339        wot: wot::WotService,
2340    ) -> resilience::ResilienceService {
2341        resilience::ResilienceService::with_database(self.database.clone(), config).with_trust_policy(wot)
2342    }
2343
2344    /// Create a local Web-of-Trust metadata service for this indexer.
2345    #[must_use]
2346    pub fn wot_service(&self, policy: wot::TrustPolicy) -> wot::WotService {
2347        wot::WotService::new(self, policy)
2348    }
2349
2350    /// Return the thread-safe local denylist used by indexing hooks.
2351    #[must_use]
2352    pub fn denylist_service(&self) -> denylist::DenylistService {
2353        self.denylist.clone()
2354    }
2355
2356    /// Alias for [`Self::denylist_service`].
2357    #[must_use]
2358    pub fn denylist(&self) -> denylist::DenylistService {
2359        self.denylist_service()
2360    }
2361
2362    #[must_use]
2363    pub const fn database(&self) -> &IndexingDatabase {
2364        &self.database
2365    }
2366
2367    #[must_use]
2368    pub const fn db(&self) -> &IndexingDatabase {
2369        &self.database
2370    }
2371
2372    /// Search entries in folders enabled for local indexing.
2373    ///
2374    /// # Errors
2375    ///
2376    /// Returns an error if the query is invalid or `SQLite` cannot read results.
2377    pub fn search_local(&self, query: &str, limit: usize) -> Result<Vec<IndexedEntry>> {
2378        self.database.search(query, limit)
2379    }
2380
2381    /// Search records imported from subscribed catalogs.
2382    ///
2383    /// # Errors
2384    ///
2385    /// Returns an error if the query is invalid or `SQLite` cannot read results.
2386    pub fn search_global(&self, query: &str, limit: usize) -> Result<Vec<CatalogRecord>> {
2387        self.database.search_catalogs(query, limit)
2388    }
2389
2390    /// Search records imported from subscribed catalogs.
2391    ///
2392    /// This is the service-level search used by `indexing search`.
2393    ///
2394    /// # Errors
2395    ///
2396    /// Returns an error if the query is invalid or `SQLite` cannot read results.
2397    pub fn search(&self, query: &str, limit: usize) -> Result<Vec<CatalogRecord>> {
2398        self.search_global(query, limit)
2399    }
2400
2401    /// Subscribe to indexing and core-engine events.
2402    #[must_use]
2403    pub fn subscribe(&self) -> broadcast::Receiver<IndexingEvent> {
2404        self.events.subscribe()
2405    }
2406
2407    /// Subscribe to indexing and core-engine events.
2408    #[must_use]
2409    pub fn subscribe_events(&self) -> broadcast::Receiver<IndexingEvent> {
2410        self.subscribe()
2411    }
2412
2413    /// Opt a folder into indexing and begin consuming its document events.
2414    ///
2415    /// Existing document entries are indexed before the method returns.
2416    ///
2417    /// # Errors
2418    ///
2419    /// Returns an error if the folder cannot be registered, read, or watched.
2420    pub async fn enable_folder(&self, folder: &SyncwebFolder) -> Result<IndexingHandle> {
2421        let namespace_id = folder.namespace_id();
2422        if self
2423            .tasks
2424            .lock()
2425            .map_err(|error| SyncwebError::operation("indexing task lock poisoned", error))?
2426            .get(&namespace_id)
2427            .is_some_and(|task| !task.is_finished())
2428        {
2429            return Ok(IndexingHandle { namespace_id });
2430        }
2431
2432        let live_events = folder.docs_engine().watch(folder.doc()).await?;
2433        self.database.enable_folder(namespace_id, &namespace_id.to_string())?;
2434        send_event(&self.events, IndexingEvent::FolderEnabled { namespace_id });
2435
2436        for entry in folder.docs_engine().list_latest(folder.doc()).await? {
2437            let indexed = self.index_entry(namespace_id, &entry)?;
2438            send_event(&self.events, IndexingEvent::EntryIndexed(indexed));
2439        }
2440
2441        let database = self.database.clone();
2442        let events = self.events.clone();
2443        let task = tokio::spawn(async move {
2444            consume_folder_events(namespace_id, live_events, database, events).await;
2445        });
2446        self.tasks
2447            .lock()
2448            .map_err(|error| SyncwebError::operation("indexing task lock poisoned", error))?
2449            .insert(namespace_id, task);
2450        Ok(IndexingHandle { namespace_id })
2451    }
2452
2453    /// Stop indexing a folder and remove its local index entries.
2454    ///
2455    /// # Errors
2456    ///
2457    /// Returns an error if the folder index cannot be removed.
2458    pub async fn disable_folder(&self, namespace_id: NamespaceId) -> Result<()> {
2459        let task_handle = self
2460            .tasks
2461            .lock()
2462            .map_err(|error| SyncwebError::operation("indexing task lock poisoned", error))?
2463            .remove(&namespace_id);
2464        if let Some(task) = task_handle {
2465            task.abort();
2466        }
2467        let database = self.database.clone();
2468        tokio::task::spawn_blocking(move || database.disable_folder(namespace_id))
2469            .await
2470            .map_err(|error| SyncwebError::operation("indexing folder removal task failed", error))??;
2471        send_event(&self.events, IndexingEvent::FolderDisabled { namespace_id });
2472        Ok(())
2473    }
2474
2475    /// Return whether an indexing task is active for a folder.
2476    ///
2477    /// # Errors
2478    ///
2479    /// Returns an error if the task registry lock is poisoned.
2480    pub fn is_folder_enabled(&self, namespace_id: NamespaceId) -> Result<bool> {
2481        let active_task = self
2482            .tasks
2483            .lock()
2484            .map_err(|error| SyncwebError::operation("indexing task lock poisoned", error))?
2485            .get(&namespace_id)
2486            .is_some_and(|task| !task.is_finished());
2487        Ok(active_task || self.database.is_folder_enabled(namespace_id)?)
2488    }
2489
2490    /// Index an entry supplied by another catalog or an application.
2491    ///
2492    /// This method does not require a folder task, but the namespace must
2493    /// already be enabled.
2494    ///
2495    /// # Errors
2496    ///
2497    /// Returns an error if the namespace is not enabled or the entry cannot be
2498    /// stored.
2499    pub fn index_entry(&self, namespace_id: NamespaceId, entry: &Entry) -> Result<IndexedEntry> {
2500        if !self.database.is_folder_enabled(namespace_id)? {
2501            return Err(SyncwebError::FolderNotFound(namespace_id.to_string()));
2502        }
2503        self.database
2504            .upsert_entry(namespace_id, entry.key(), entry.content_hash(), entry.content_len())
2505    }
2506}
2507
2508async fn consume_folder_events(
2509    namespace_id: NamespaceId,
2510    mut live_events: impl n0_future::Stream<Item = Result<LiveEvent>> + Send + Unpin + 'static,
2511    database: IndexingDatabase,
2512    events: broadcast::Sender<IndexingEvent>,
2513) {
2514    while let Some(event_result) = live_events.next().await {
2515        let event = match event_result {
2516            Ok(event) => event,
2517            Err(error) => {
2518                send_event(
2519                    &events,
2520                    IndexingEvent::Error {
2521                        namespace_id,
2522                        message: error.to_string(),
2523                    },
2524                );
2525                break;
2526            }
2527        };
2528        match event {
2529            LiveEvent::InsertLocal { entry } | LiveEvent::InsertRemote { entry, .. } => {
2530                match database.upsert_entry(namespace_id, entry.key(), entry.content_hash(), entry.content_len()) {
2531                    Ok(indexed) => send_event(&events, IndexingEvent::EntryIndexed(indexed)),
2532                    Err(error) => {
2533                        send_event(
2534                            &events,
2535                            IndexingEvent::Error {
2536                                namespace_id,
2537                                message: error.to_string(),
2538                            },
2539                        );
2540                    }
2541                }
2542            }
2543            LiveEvent::ContentReady { hash } => send_event(
2544                &events,
2545                IndexingEvent::Core {
2546                    namespace_id,
2547                    event: CoreIndexingEvent::ContentReady { hash },
2548                },
2549            ),
2550            LiveEvent::PendingContentReady => send_event(
2551                &events,
2552                IndexingEvent::Core {
2553                    namespace_id,
2554                    event: CoreIndexingEvent::PendingContentReady,
2555                },
2556            ),
2557            LiveEvent::NeighborUp(_) => send_event(
2558                &events,
2559                IndexingEvent::Core {
2560                    namespace_id,
2561                    event: CoreIndexingEvent::NeighborUp,
2562                },
2563            ),
2564            LiveEvent::NeighborDown(_) => send_event(
2565                &events,
2566                IndexingEvent::Core {
2567                    namespace_id,
2568                    event: CoreIndexingEvent::NeighborDown,
2569                },
2570            ),
2571            LiveEvent::SyncFinished(_) => send_event(
2572                &events,
2573                IndexingEvent::Core {
2574                    namespace_id,
2575                    event: CoreIndexingEvent::SyncFinished,
2576                },
2577            ),
2578        }
2579    }
2580}
2581
2582fn indexed_entry_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<IndexedEntry> {
2583    let namespace = row.get::<_, String>(0)?;
2584    let namespace_id = NamespaceId::from_str(&namespace).map_err(|error| {
2585        let conversion_error = std::io::Error::new(std::io::ErrorKind::InvalidData, error.to_string());
2586        rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(conversion_error))
2587    })?;
2588    let key = row.get(1)?;
2589    let hash_bytes = row.get::<_, Vec<u8>>(2)?;
2590    let hash_array = <[u8; 32]>::try_from(hash_bytes.as_slice())
2591        .map_err(|error| rusqlite::Error::FromSqlConversionFailure(2, rusqlite::types::Type::Blob, Box::new(error)))?;
2592    let size_value = row.get::<_, i64>(3)?;
2593    let size = u64::try_from(size_value).map_err(|error| {
2594        rusqlite::Error::FromSqlConversionFailure(3, rusqlite::types::Type::Integer, Box::new(error))
2595    })?;
2596    Ok(IndexedEntry {
2597        namespace_id,
2598        key,
2599        hash: Hash::from_bytes(hash_array),
2600        size,
2601    })
2602}
2603
2604fn catalog_record_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<CatalogRecord> {
2605    let catalog_namespace = row.get::<_, String>(0)?;
2606    let catalog_namespace_id = NamespaceId::from_str(&catalog_namespace).map_err(|error| {
2607        let conversion_error = std::io::Error::new(std::io::ErrorKind::InvalidData, error.to_string());
2608        rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(conversion_error))
2609    })?;
2610    let folder_namespace = row.get::<_, String>(1)?;
2611    let folder_namespace_id = NamespaceId::from_str(&folder_namespace).map_err(|error| {
2612        let conversion_error = std::io::Error::new(std::io::ErrorKind::InvalidData, error.to_string());
2613        rusqlite::Error::FromSqlConversionFailure(1, rusqlite::types::Type::Text, Box::new(conversion_error))
2614    })?;
2615    let hash_bytes = row.get::<_, Vec<u8>>(3)?;
2616    let hash_array = <[u8; 32]>::try_from(hash_bytes.as_slice())
2617        .map_err(|error| rusqlite::Error::FromSqlConversionFailure(3, rusqlite::types::Type::Blob, Box::new(error)))?;
2618    let size_value = row.get::<_, i64>(4)?;
2619    let size = u64::try_from(size_value).map_err(|error| {
2620        rusqlite::Error::FromSqlConversionFailure(4, rusqlite::types::Type::Integer, Box::new(error))
2621    })?;
2622    let tags_json = row.get::<_, String>(7)?;
2623    let tags = serde_json::from_str(&tags_json)
2624        .map_err(|error| rusqlite::Error::FromSqlConversionFailure(7, rusqlite::types::Type::Text, Box::new(error)))?;
2625    Ok(CatalogRecord {
2626        catalog_namespace_id,
2627        folder_namespace_id,
2628        key: row.get(2)?,
2629        hash: Hash::from_bytes(hash_array),
2630        size,
2631        folder_name: row.get(5)?,
2632        title: row.get(6)?,
2633        tags,
2634        publisher: row.get(8)?,
2635    })
2636}
2637
2638fn wot_metadata_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<wot::MetadataEntry> {
2639    let hash_bytes = row.get::<_, Vec<u8>>(0)?;
2640    let hash_array = <[u8; 32]>::try_from(hash_bytes.as_slice())
2641        .map_err(|error| rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Blob, Box::new(error)))?;
2642    let sequence_value = row.get::<_, i64>(4)?;
2643    let sequence = u64::try_from(sequence_value).map_err(|error| {
2644        rusqlite::Error::FromSqlConversionFailure(4, rusqlite::types::Type::Integer, Box::new(error))
2645    })?;
2646    let created_at_value = row.get::<_, i64>(5)?;
2647    let created_at = u64::try_from(created_at_value).map_err(|error| {
2648        rusqlite::Error::FromSqlConversionFailure(5, rusqlite::types::Type::Integer, Box::new(error))
2649    })?;
2650    Ok(wot::MetadataEntry {
2651        content: Hash::from_bytes(hash_array),
2652        key: row.get(1)?,
2653        value: row.get(2)?,
2654        author: row.get(3)?,
2655        sequence,
2656        created_at,
2657        signature: row.get(6)?,
2658    })
2659}
2660
2661const SCHEMA_PART1: &str = "PRAGMA journal_mode = WAL;
2662     CREATE TABLE IF NOT EXISTS index_metadata (
2663         key TEXT PRIMARY KEY NOT NULL,
2664         value TEXT NOT NULL
2665     );
2666     CREATE TABLE IF NOT EXISTS indexed_folders (
2667         namespace_id TEXT PRIMARY KEY NOT NULL,
2668         label TEXT NOT NULL,
2669         enabled_at INTEGER NOT NULL
2670     );
2671     CREATE TABLE IF NOT EXISTS indexed_entries (
2672         id INTEGER PRIMARY KEY,
2673         namespace_id TEXT NOT NULL REFERENCES indexed_folders(namespace_id) ON DELETE CASCADE,
2674         entry_key BLOB NOT NULL,
2675         content_hash BLOB NOT NULL CHECK(length(content_hash) = 32),
2676         content_len INTEGER NOT NULL CHECK(content_len >= 0),
2677         updated_at INTEGER NOT NULL,
2678         UNIQUE(namespace_id, entry_key)
2679     );
2680     CREATE INDEX IF NOT EXISTS indexed_entries_namespace
2681         ON indexed_entries(namespace_id);
2682     CREATE VIRTUAL TABLE IF NOT EXISTS indexed_entries_fts USING fts5(
2683         namespace_id UNINDEXED,
2684         entry_key,
2685         title,
2686         tags,
2687         tokenize = 'unicode61'
2688     );
2689     CREATE TABLE IF NOT EXISTS indexed_catalogs (
2690         namespace_id TEXT PRIMARY KEY NOT NULL,
2691         label TEXT NOT NULL,
2692         subscribed_at INTEGER NOT NULL
2693     );
2694     CREATE TABLE IF NOT EXISTS indexed_catalog_entries (
2695         id INTEGER PRIMARY KEY,
2696         catalog_namespace_id TEXT NOT NULL REFERENCES indexed_catalogs(namespace_id) ON DELETE CASCADE,
2697         folder_namespace_id TEXT NOT NULL,
2698         entry_key BLOB NOT NULL,
2699         content_hash BLOB NOT NULL CHECK(length(content_hash) = 32),
2700         content_len INTEGER NOT NULL CHECK(content_len >= 0),
2701         folder_name TEXT NOT NULL,
2702         title TEXT NOT NULL,
2703         tags TEXT NOT NULL,
2704         publisher TEXT NOT NULL,
2705         updated_at INTEGER NOT NULL,
2706         UNIQUE(catalog_namespace_id, folder_namespace_id, entry_key)
2707     );
2708     CREATE INDEX IF NOT EXISTS indexed_catalog_entries_catalog
2709         ON indexed_catalog_entries(catalog_namespace_id);
2710     CREATE VIRTUAL TABLE IF NOT EXISTS indexed_catalog_entries_fts USING fts5(
2711         catalog_namespace_id UNINDEXED,
2712         folder_namespace_id UNINDEXED,
2713         entry_key,
2714         folder_name,
2715         title,
2716         tags,
2717         publisher,
2718         content_hash,
2719         tokenize = 'unicode61'
2720     );
2721     CREATE TABLE IF NOT EXISTS wot_metadata (
2722         id INTEGER PRIMARY KEY,
2723         content_hash BLOB NOT NULL CHECK(length(content_hash) = 32),
2724         metadata_key TEXT NOT NULL,
2725         metadata_value TEXT NOT NULL,
2726         author TEXT NOT NULL,
2727         sequence INTEGER NOT NULL CHECK(sequence > 0),
2728         created_at INTEGER NOT NULL CHECK(created_at >= 0),
2729         signature TEXT NOT NULL,
2730         UNIQUE(content_hash, metadata_key, author, sequence)
2731     );
2732     CREATE INDEX IF NOT EXISTS wot_metadata_content
2733         ON wot_metadata(content_hash);
2734     CREATE VIRTUAL TABLE IF NOT EXISTS wot_metadata_fts USING fts5(
2735         content_hash UNINDEXED,
2736         metadata_key,
2737         metadata_value,
2738         author,
2739         tokenize = 'unicode61'
2740     );";
2741
2742const SCHEMA_PART2: &str = "CREATE TABLE IF NOT EXISTS stable_links (
2743     link TEXT PRIMARY KEY NOT NULL,
2744     kind TEXT NOT NULL,
2745     publisher TEXT,
2746     alias TEXT,
2747     content_hash BLOB CHECK(content_hash IS NULL OR length(content_hash) = 32),
2748     sequence INTEGER NOT NULL CHECK(sequence >= 0),
2749     version TEXT,
2750     payload BLOB NOT NULL,
2751     updated_at INTEGER NOT NULL
2752 );
2753CREATE TABLE IF NOT EXISTS link_mirrors (
2754link TEXT NOT NULL,
2755provider TEXT NOT NULL,
2756ticket TEXT NOT NULL,
2757priority INTEGER NOT NULL DEFAULT 0,
2758updated_at INTEGER NOT NULL,
2759PRIMARY KEY(link, provider)
2760);
2761CREATE TABLE IF NOT EXISTS revoked_links (
2762     manifest BLOB NOT NULL CHECK(length(manifest) = 32),
2763     capability TEXT NOT NULL,
2764     revoked_at INTEGER NOT NULL,
2765     PRIMARY KEY(manifest, capability)
2766);
2767     CREATE TABLE IF NOT EXISTS denylist_rules (
2768         rule_type TEXT NOT NULL,
2769         rule_value BLOB NOT NULL,
2770         namespace_id TEXT,
2771         updated_at INTEGER NOT NULL,
2772         PRIMARY KEY(rule_type, rule_value, namespace_id)
2773     );
2774     CREATE TABLE IF NOT EXISTS filter_lists (
2775         namespace_id TEXT PRIMARY KEY NOT NULL,
2776         sequence INTEGER NOT NULL CHECK(sequence > 0),
2777         publisher TEXT NOT NULL,
2778         payload BLOB NOT NULL,
2779         updated_at INTEGER NOT NULL
2780     );
2781     CREATE TABLE IF NOT EXISTS moderation_records (
2782         content_hash BLOB NOT NULL CHECK(length(content_hash) = 32),
2783         scope TEXT NOT NULL,
2784         sequence INTEGER NOT NULL CHECK(sequence > 0),
2785         payload BLOB NOT NULL,
2786         updated_at INTEGER NOT NULL,
2787         PRIMARY KEY(content_hash, scope)
2788     );
2789     CREATE TABLE IF NOT EXISTS provider_leases (
2790         provider TEXT NOT NULL,
2791         content_hash BLOB NOT NULL CHECK(length(content_hash) = 32),
2792         ticket TEXT NOT NULL,
2793         sequence INTEGER NOT NULL,
2794         issued_at INTEGER NOT NULL,
2795         expires_at INTEGER NOT NULL,
2796         signature TEXT,
2797         PRIMARY KEY(provider, content_hash)
2798     );
2799     CREATE INDEX IF NOT EXISTS idx_provider_leases_hash ON provider_leases(content_hash);";
2800
2801const SCHEMA_PART3: &str = "CREATE TABLE IF NOT EXISTS provider_reputation (
2802     provider TEXT PRIMARY KEY NOT NULL,
2803     total_fetches INTEGER NOT NULL DEFAULT 0,
2804     successful_fetches INTEGER NOT NULL DEFAULT 0,
2805     failed_fetches INTEGER NOT NULL DEFAULT 0,
2806     consecutive_failures INTEGER NOT NULL DEFAULT 0,
2807     last_success_at INTEGER,
2808     last_failure_at INTEGER,
2809     auto_ban_until INTEGER,
2810     auto_ban_count INTEGER NOT NULL DEFAULT 0
2811 );
2812 CREATE TABLE IF NOT EXISTS provider_signal_sequences (
2813     reporter TEXT NOT NULL,
2814     provider TEXT NOT NULL,
2815     last_sequence INTEGER NOT NULL,
2816     PRIMARY KEY(reporter, provider)
2817 );
2818 CREATE TABLE IF NOT EXISTS trust_delegations (
2819     delegator TEXT NOT NULL,
2820     delegate TEXT NOT NULL,
2821     scope BLOB CHECK(scope IS NULL OR length(scope) = 32),
2822     max_depth INTEGER,
2823     sequence INTEGER NOT NULL,
2824     issued_at INTEGER NOT NULL,
2825     expires_at INTEGER NOT NULL,
2826     revoked_at INTEGER,
2827     signature TEXT,
2828     PRIMARY KEY(delegator, delegate, sequence)
2829 );
2830     CREATE TABLE IF NOT EXISTS moderation_records_v2 (
2831         id INTEGER PRIMARY KEY AUTOINCREMENT,
2832         content_hash BLOB NOT NULL CHECK(length(content_hash) = 32),
2833         moderator TEXT NOT NULL,
2834         action TEXT NOT NULL,
2835         scope_json TEXT NOT NULL,
2836         sequence INTEGER NOT NULL,
2837         created_at INTEGER NOT NULL,
2838         reason TEXT NOT NULL,
2839         signature TEXT
2840     );
2841     CREATE TABLE IF NOT EXISTS attestations_v2 (
2842         content_hash BLOB NOT NULL CHECK(length(content_hash) = 32),
2843         issuer TEXT NOT NULL,
2844         kind TEXT NOT NULL,
2845         kind_other TEXT,
2846         value TEXT NOT NULL,
2847         sequence INTEGER NOT NULL,
2848         issued_at INTEGER NOT NULL,
2849         signature TEXT,
2850         PRIMARY KEY(content_hash, issuer, kind)
2851     );
2852      CREATE TABLE IF NOT EXISTS content_reports_v2 (
2853          content_hash BLOB NOT NULL CHECK(length(content_hash) = 32),
2854          reporter TEXT NOT NULL,
2855          reason TEXT NOT NULL,
2856          scope TEXT NOT NULL DEFAULT 'global',
2857          created_at INTEGER NOT NULL,
2858          signature TEXT,
2859          PRIMARY KEY(content_hash, reporter, reason)
2860      );
2861     CREATE TABLE IF NOT EXISTS provider_trust_records_v2 (
2862         id INTEGER PRIMARY KEY AUTOINCREMENT,
2863         provider TEXT NOT NULL,
2864         action TEXT NOT NULL,
2865         scope BLOB CHECK(scope IS NULL OR length(scope) = 32),
2866         issuer TEXT NOT NULL,
2867         sequence INTEGER NOT NULL,
2868         issued_at INTEGER NOT NULL,
2869         expires_at INTEGER,
2870         reason TEXT NOT NULL,
2871         signature TEXT
2872     );
2873     CREATE TABLE IF NOT EXISTS provider_bans_v2 (
2874         provider TEXT NOT NULL,
2875         content_hash BLOB CHECK(content_hash IS NULL OR length(content_hash) = 32),
2876         banned_at INTEGER NOT NULL,
2877         expires_at INTEGER,
2878         reason TEXT NOT NULL,
2879         source TEXT NOT NULL,
2880         PRIMARY KEY(provider, content_hash)
2881     );
2882     CREATE TABLE IF NOT EXISTS provider_trust_signals_v2 (
2883         reporter TEXT NOT NULL,
2884         provider TEXT NOT NULL,
2885         signal_kind TEXT NOT NULL,
2886         content_hash BLOB CHECK(content_hash IS NULL OR length(content_hash) = 32),
2887         sequence INTEGER NOT NULL,
2888         timestamp INTEGER NOT NULL,
2889         signature TEXT,
2890         PRIMARY KEY(reporter, provider, sequence)
2891     );
2892     CREATE INDEX IF NOT EXISTS idx_trust_signals_provider_v2 ON provider_trust_signals_v2(provider);
2893     CREATE INDEX IF NOT EXISTS idx_trust_signals_ts_v2 ON provider_trust_signals_v2(timestamp);
2894     CREATE TABLE IF NOT EXISTS trust_streams (
2895         namespace TEXT PRIMARY KEY,
2896         subscribed_at INTEGER NOT NULL
2897     );";
2898
2899fn initialize_connection(connection: &Connection) -> Result<()> {
2900    connection
2901        .busy_timeout(std::time::Duration::from_secs(5))
2902        .map_err(|error| database_error("failed to configure indexing database", error))?;
2903    connection
2904        .pragma_update(None, "foreign_keys", "ON")
2905        .map_err(|error| database_error("failed to enable indexing foreign keys", error))?;
2906    initialize_schema(connection)?;
2907    connection
2908        .execute(
2909            "INSERT INTO index_metadata(key, value)
2910             VALUES ('schema_version', ?1)
2911             ON CONFLICT(key) DO UPDATE SET value = excluded.value",
2912            [SCHEMA_VERSION],
2913        )
2914        .map_err(|error| database_error("failed to persist indexing schema version", error))?;
2915    Ok(())
2916}
2917
2918fn initialize_schema(connection: &Connection) -> Result<()> {
2919    for part in [SCHEMA_PART1, SCHEMA_PART2, SCHEMA_PART3] {
2920        connection
2921            .execute_batch(part)
2922            .map_err(|error| database_error("failed to initialize indexing database schema", error))?;
2923    }
2924    Ok(())
2925}
2926
2927fn now_seconds() -> i64 {
2928    SystemTime::now()
2929        .duration_since(UNIX_EPOCH)
2930        .map_or(0, |duration| i64::try_from(duration.as_secs()).unwrap_or(i64::MAX))
2931}
2932
2933fn database_error(context: &'static str, error: impl std::fmt::Display) -> SyncwebError {
2934    SyncwebError::operation(context, error)
2935}
2936
2937fn send_event(events: &broadcast::Sender<IndexingEvent>, event: IndexingEvent) {
2938    let _ = events.send(event);
2939}
2940
2941/// Deterministic gossip topic for broadcasting signed moderation reports.
2942pub const REPORT_GOSSIP_TOPIC: &[u8] = b"syncweb/reports/v1";
2943
2944/// A signed content report stored in the indexing database.
2945#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
2946#[non_exhaustive]
2947pub struct ReportRecord {
2948    pub content: Hash,
2949    pub reason: String,
2950    pub created_at: u64,
2951    pub reporter: Option<String>,
2952    pub signature: Option<String>,
2953}
2954
2955impl ReportRecord {
2956    #[must_use]
2957    pub const fn new(content: Hash, reason: String, created_at: u64) -> Self {
2958        Self {
2959            content,
2960            reason,
2961            created_at,
2962            reporter: None,
2963            signature: None,
2964        }
2965    }
2966
2967    /// Sign this report with the node's Ed25519 signing key.
2968    ///
2969    /// Sets `reporter` to the hex-encoded verifying key and `signature` to
2970    /// the hex-encoded Ed25519 signature over the canonical payload.
2971    ///
2972    /// # Errors
2973    ///
2974    /// Returns an error if the canonical signing payload cannot be serialized.
2975    pub fn sign_with(mut self, signing_key: &SigningKey) -> Result<Self> {
2976        let payload = self.signing_payload()?;
2977        let sig = signing_key.sign(&payload);
2978        self.reporter = Some(hex::encode(signing_key.verifying_key().to_bytes()));
2979        self.signature = Some(hex::encode(sig.to_bytes()));
2980        Ok(self)
2981    }
2982
2983    /// Verify the report's signature against the given public key.
2984    ///
2985    /// # Errors
2986    ///
2987    /// Returns an error if the signature is missing, malformed, or invalid.
2988    pub fn verify(&self, public_key: &VerifyingKey) -> Result<()> {
2989        let sig_hex = self
2990            .signature
2991            .as_ref()
2992            .ok_or_else(|| SyncwebError::MissingSignature("report has no signature".into()))?;
2993        let sig_bytes = hex::decode(sig_hex).map_err(|e| SyncwebError::operation("report signature hex decode", e))?;
2994        let sig_len = sig_bytes.len();
2995        let sig: [u8; 64] = sig_bytes.try_into().map_err(|_v| {
2996            SyncwebError::InvalidSignature(format!(
2997                "report signature has wrong length (expected 64, got {sig_len})"
2998            ))
2999        })?;
3000        let signature = Signature::from_bytes(&sig);
3001        public_key
3002            .verify(&self.signing_payload()?, &signature)
3003            .map_err(|e| SyncwebError::InvalidSignature(format!("report signature does not match: {e}")))
3004    }
3005
3006    /// Canonical payload for signing: `(content_hash_bytes, reason, created_at)`.
3007    ///
3008    /// The reporter and signature fields are excluded — the reporter IS the
3009    /// signer, and the signature cannot sign itself.
3010    fn signing_payload(&self) -> Result<Vec<u8>> {
3011        let canonical = (self.content.as_bytes(), &self.reason, self.created_at);
3012        serde_json::to_vec(&canonical).map_err(|e| SyncwebError::operation("canonical report serialization", e))
3013    }
3014}
3015
3016impl SignedGossipMessage for ReportRecord {
3017    fn verify_signature(&self) -> Result<()> {
3018        let reporter_hex = self.reporter.as_ref().ok_or_else(|| {
3019            SyncwebError::MissingSignature("report has no reporter; unsigned reports must not be gossiped".into())
3020        })?;
3021        let bytes = hex::decode(reporter_hex).map_err(|e| SyncwebError::operation("decode reporter key", e))?;
3022        let key_len = bytes.len();
3023        let key_bytes: [u8; 32] = bytes.try_into().map_err(|_v| {
3024            SyncwebError::InvalidSignature(format!("reporter key has wrong length (expected 32, got {key_len})"))
3025        })?;
3026        let public_key = VerifyingKey::from_bytes(&key_bytes)
3027            .map_err(|e| SyncwebError::InvalidSignature(format!("invalid reporter key: {e}")))?;
3028        self.verify(&public_key)
3029    }
3030}