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                    let manifests: [u8; 32] = manifest_bytes.try_into().unwrap_or([0_u8; 32]);
1094                    let hash = Hash::from_bytes(manifests);
1095                    Ok((hash, capability_blob))
1096                })
1097            {
1098                for row in rev_rows.flatten() {
1099                    let cap: [u8; 32] = row.1.try_into().unwrap_or([0_u8; 32]);
1100                    if let Ok(link) = PrivateLink::new(row.0, cap, u64::MAX) {
1101                        revoked.push(link);
1102                    }
1103                }
1104            }
1105            Ok((pointers, mirrors, revoked))
1106        })
1107    }
1108
1109    /// Save stable links to the database.
1110    ///
1111    /// # Errors
1112    ///
1113    /// Returns an error if the links cannot be persisted.
1114    pub fn save_links(
1115        &self,
1116        pointers: &[links::MutablePointer],
1117        mirrors: &[String],
1118        revoked: &[links::PrivateLink],
1119    ) -> Result<()> {
1120        self.with_connection(|connection| {
1121            connection.execute("DELETE FROM stable_links", [])
1122                .map_err(|error| database_error("failed to clear stable links", error))?;
1123            connection.execute("DELETE FROM link_mirrors", [])
1124                .map_err(|error| database_error("failed to clear link mirrors", error))?;
1125            connection.execute("DELETE FROM revoked_links", [])
1126                .map_err(|error| database_error("failed to clear revoked links", error))?;
1127            let now = now_seconds();
1128            for pointer in pointers {
1129                let payload = serde_json::to_vec(pointer)
1130                    .map_err(|error| database_error("failed to serialize pointer", error))?;
1131                connection.execute(
1132                    "INSERT OR REPLACE INTO stable_links(link, kind, publisher, alias, content_hash, sequence, version, payload, updated_at)
1133                     VALUES (?1, 'mutable', ?2, ?3, NULL, ?4, ?5, ?6, ?7)",
1134                    params![
1135                        format!("mutable:{}:{}", pointer.publisher, pointer.alias),
1136                        pointer.publisher.to_string(),
1137                        pointer.alias,
1138                        i64::try_from(pointer.sequence).unwrap_or(i64::MAX),
1139                         pointer.version.as_deref(),
1140                        payload,
1141                        now,
1142                    ],
1143                ).map_err(|error| database_error("failed to save pointer", error))?;
1144            }
1145            for uri in mirrors {
1146                connection.execute(
1147                    "INSERT INTO link_mirrors(link, provider, ticket, priority, updated_at)
1148                     VALUES ('', 'local', ?1, 0, ?2)",
1149                    params![uri, now],
1150                ).map_err(|error| database_error("failed to save mirror", error))?;
1151            }
1152            for link in revoked {
1153                connection.execute(
1154                    "INSERT OR REPLACE INTO revoked_links(manifest, capability, revoked_at)
1155                     VALUES (?1, ?2, ?3)",
1156                    params![link.manifest.as_bytes(), link.capability.as_slice(), now],
1157                ).map_err(|error| database_error("failed to save revoked link", error))?;
1158            }
1159            Ok(())
1160        })
1161    }
1162
1163    /// Upsert a single mutable pointer to the stable links table.
1164    ///
1165    /// # Errors
1166    ///
1167    /// Returns an error if the pointer cannot be persisted.
1168    pub fn upsert_link_pointer(&self, pointer: &links::MutablePointer) -> Result<()> {
1169        let payload =
1170            serde_json::to_vec(pointer).map_err(|error| database_error("failed to serialize pointer", error))?;
1171        self.with_connection(|connection| {
1172            connection.execute(
1173                "INSERT OR REPLACE INTO stable_links(link, kind, publisher, alias, content_hash, sequence, version, payload, updated_at)
1174                 VALUES (?1, 'mutable', ?2, ?3, NULL, ?4, ?5, ?6, ?7)",
1175                params![
1176                    format!("mutable:{}:{}", pointer.publisher, pointer.alias),
1177                    pointer.publisher.to_string(),
1178                    pointer.alias,
1179                    i64::try_from(pointer.sequence).unwrap_or(i64::MAX),
1180                    pointer.version.as_deref(),
1181                    payload,
1182                    now_seconds(),
1183                ],
1184            )
1185            .map_err(|error| database_error("failed to upsert link pointer", error))?;
1186            Ok(())
1187        })
1188    }
1189
1190    /// Insert a single mirror ticket.
1191    ///
1192    /// # Errors
1193    ///
1194    /// Returns an error if the mirror cannot be persisted.
1195    pub fn upsert_link_mirror(&self, ticket: &str) -> Result<()> {
1196        self.with_connection(|connection| {
1197            connection
1198                .execute(
1199                    "INSERT OR REPLACE INTO link_mirrors(link, provider, ticket, priority, updated_at)
1200                 VALUES ('', 'local', ?1, 0, ?2)",
1201                    params![ticket, now_seconds()],
1202                )
1203                .map_err(|error| database_error("failed to upsert link mirror", error))?;
1204            Ok(())
1205        })
1206    }
1207
1208    /// Save provider leases to the database.
1209    ///
1210    /// # Errors
1211    ///
1212    /// Returns an error if the leases cannot be persisted.
1213    pub fn save_leases(&self, leases: &[resilience::ProviderLease]) -> Result<()> {
1214        self.with_connection(|connection| {
1215            connection.execute("DELETE FROM provider_leases", [])
1216                .map_err(|error| database_error("failed to clear provider leases", error))?;
1217            for lease in leases {
1218                connection.execute(
1219                    "INSERT INTO provider_leases(provider, content_hash, ticket, sequence, issued_at, expires_at, signature)
1220                     VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
1221                    params![
1222                        lease.provider.to_string(),
1223                        lease.hash.as_bytes().to_vec(),
1224                        lease.ticket,
1225                        i64::try_from(lease.sequence).unwrap_or(i64::MAX),
1226                        i64::try_from(lease.issued_at).unwrap_or(i64::MAX),
1227                        i64::try_from(lease.expires_at).unwrap_or(i64::MAX),
1228                        lease.signature,
1229                    ],
1230                ).map_err(|error| database_error("failed to save lease", error))?;
1231            }
1232            Ok(())
1233        })
1234    }
1235
1236    /// Load provider leases from the database.
1237    ///
1238    /// # Errors
1239    ///
1240    /// Returns an error if the leases cannot be read.
1241    pub fn load_leases(&self) -> Result<Vec<resilience::ProviderLease>> {
1242        self.with_connection(|connection| {
1243            let mut stmt = connection
1244                .prepare(
1245                    "SELECT provider, content_hash, ticket, sequence, issued_at, expires_at, signature
1246                 FROM provider_leases ORDER BY issued_at",
1247                )
1248                .map_err(|error| database_error("failed to prepare leases query", error))?;
1249            let leases = stmt
1250                .query_map([], |row| {
1251                    let provider_str: String = row.get(0)?;
1252                    let hash_bytes: Vec<u8> = row.get(1)?;
1253                    let arr: [u8; 32] = hash_bytes.try_into().map_err(|error: Vec<u8>| {
1254                        rusqlite::Error::ToSqlConversionFailure(Box::new(SyncwebError::operation(
1255                            "invalid hash length",
1256                            format!("expected 32 bytes, got {}", error.len()),
1257                        )))
1258                    })?;
1259                    let provider = provider_str.parse::<PublicKey>().map_err(|error| {
1260                        rusqlite::Error::ToSqlConversionFailure(Box::new(SyncwebError::operation(
1261                            "invalid provider",
1262                            error,
1263                        )))
1264                    })?;
1265                    Ok(resilience::ProviderLease {
1266                        hash: Hash::from(arr),
1267                        provider,
1268                        ticket: row.get(2)?,
1269                        sequence: u64::try_from(row.get::<_, i64>(3)?).unwrap_or_default(),
1270                        issued_at: u64::try_from(row.get::<_, i64>(4)?).unwrap_or_default(),
1271                        expires_at: u64::try_from(row.get::<_, i64>(5)?).unwrap_or_default(),
1272                        signature: row.get(6)?,
1273                    })
1274                })
1275                .map_err(|error| database_error("failed to query leases", error))?
1276                .collect::<std::result::Result<Vec<_>, _>>()
1277                .map_err(|error| database_error("failed to read lease rows", error))?;
1278            Ok(leases)
1279        })
1280    }
1281
1282    /// Save trust delegations to the database.
1283    ///
1284    /// # Errors
1285    ///
1286    /// Returns an error if the delegations cannot be persisted.
1287    pub fn save_trust_delegations(&self, delegations: &[wot::TrustDelegation]) -> Result<()> {
1288        self.with_connection(|connection| {
1289            connection.execute("DELETE FROM trust_delegations", [])
1290                .map_err(|error| database_error("failed to clear trust delegations", error))?;
1291            for d in delegations {
1292                connection.execute(
1293                    "INSERT INTO trust_delegations(delegator, delegate, scope, max_depth, sequence, issued_at, expires_at, revoked_at, signature)
1294                     VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
1295                    params![
1296                        d.delegator, d.delegate,
1297                        d.scope.map(|h| h.as_bytes().to_vec()),
1298                        d.max_depth.map(i64::from),
1299                        i64::try_from(d.sequence).unwrap_or(i64::MAX),
1300                        i64::try_from(d.issued_at).unwrap_or(i64::MAX),
1301                        i64::try_from(d.expires_at).unwrap_or(i64::MAX),
1302                        d.revoked_at.map(|v| i64::try_from(v).unwrap_or(i64::MAX)),
1303                        d.signature,
1304                    ],
1305                ).map_err(|error| database_error("failed to save delegation", error))?;
1306            }
1307            Ok(())
1308        })
1309    }
1310
1311    /// Load trust delegations from the database.
1312    ///
1313    /// # Errors
1314    ///
1315    /// Returns an error if the delegations cannot be read.
1316    pub fn load_trust_delegations(&self) -> Result<Vec<wot::TrustDelegation>> {
1317        self.with_connection(|connection| {
1318            let mut stmt = connection
1319                .prepare(
1320                    "SELECT delegator, delegate, scope, max_depth, sequence, issued_at, expires_at, revoked_at, signature
1321                 FROM trust_delegations ORDER BY issued_at",
1322                )
1323                .map_err(|error| database_error("failed to prepare delegations query", error))?;
1324            let delegations = stmt
1325                .query_map([], |row| {
1326                    let scope: Option<Vec<u8>> = row.get(2)?;
1327                    let hash = scope.and_then(|b| {
1328                        let arr: [u8; 32] = b.try_into().ok()?;
1329                        Some(Hash::from(arr))
1330                    });
1331                    let max_depth: Option<i64> = row.get(3)?;
1332                    let revoked_at: Option<i64> = row.get(7)?;
1333                    Ok(wot::TrustDelegation {
1334                        delegator: row.get::<_, String>(0)?,
1335                        delegate: row.get::<_, String>(1)?,
1336                        scope: hash,
1337                        max_depth: max_depth.map(|v| u32::try_from(v).unwrap_or(u32::MAX)),
1338                        sequence: u64::try_from(row.get::<_, i64>(4)?).unwrap_or_default(),
1339                        issued_at: u64::try_from(row.get::<_, i64>(5)?).unwrap_or_default(),
1340                        expires_at: u64::try_from(row.get::<_, i64>(6)?).unwrap_or_default(),
1341                        revoked_at: revoked_at.map(|v| u64::try_from(v).unwrap_or(u64::MAX)),
1342                        signature: row.get(8)?,
1343                    })
1344                })
1345                .map_err(|error| database_error("failed to query delegations", error))?
1346                .collect::<std::result::Result<Vec<_>, _>>()
1347                .map_err(|error| database_error("failed to read delegation rows", error))?;
1348            Ok(delegations)
1349        })
1350    }
1351
1352    /// Save moderation records to the database.
1353    ///
1354    /// # Errors
1355    ///
1356    /// Returns an error if the records cannot be persisted.
1357    pub fn save_moderation_records(&self, records: &[wot::ModerationRecord]) -> Result<()> {
1358        self.with_connection(|connection| {
1359            connection.execute("DELETE FROM moderation_records_v2", [])
1360                .map_err(|error| database_error("failed to clear moderation records", error))?;
1361            for r in records {
1362                let scope_json = serde_json::to_string(&r.scope)
1363                    .map_err(|error| database_error("failed to serialize scope", error))?;
1364                connection.execute(
1365                    "INSERT INTO moderation_records_v2(content_hash, moderator, action, scope_json, sequence, created_at, reason, signature)
1366                     VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
1367                    params![
1368                        r.content.as_bytes().to_vec(), r.moderator,
1369                        format!("{:?}", r.action), scope_json,
1370                        i64::try_from(r.sequence).unwrap_or(i64::MAX),
1371                        i64::try_from(r.created_at).unwrap_or(i64::MAX),
1372                        r.reason,
1373                        r.signature,
1374                    ],
1375                ).map_err(|error| database_error("failed to save moderation record", error))?;
1376            }
1377            Ok(())
1378        })
1379    }
1380
1381    /// Load moderation records from the database.
1382    ///
1383    /// # Errors
1384    ///
1385    /// Returns an error if the records cannot be read.
1386    pub fn load_moderation_records(&self) -> Result<Vec<wot::ModerationRecord>> {
1387        self.with_connection(|connection| {
1388            let mut stmt = connection
1389                .prepare(
1390                    "SELECT content_hash, moderator, action, scope_json, sequence, created_at, reason, signature
1391                 FROM moderation_records_v2 ORDER BY created_at",
1392                )
1393                .map_err(|error| database_error("failed to prepare moderation query", error))?;
1394            let records = stmt
1395                .query_map([], |row| {
1396                    let hash_bytes: Vec<u8> = row.get(0)?;
1397                    let arr: [u8; 32] = hash_bytes.try_into().map_err(|error: Vec<u8>| {
1398                        rusqlite::Error::ToSqlConversionFailure(Box::new(SyncwebError::operation(
1399                            "invalid hash length",
1400                            format!("expected 32 bytes, got {}", error.len()),
1401                        )))
1402                    })?;
1403                    let scope_json: String = row.get(3)?;
1404                    let scope: wot::ModerationScope = serde_json::from_str(&scope_json)
1405                        .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
1406                    let action_str: String = row.get(2)?;
1407                    let action = match action_str.as_str() {
1408                        "Show" => wot::ModerationAction::Show,
1409                        "Hide" => wot::ModerationAction::Hide,
1410                        "Warn" => wot::ModerationAction::Warn,
1411                        "Quarantine" => wot::ModerationAction::Quarantine,
1412                        "Restore" => wot::ModerationAction::Restore,
1413                        _ => {
1414                            return Err(rusqlite::Error::ToSqlConversionFailure(Box::new(
1415                                SyncwebError::operation("unknown moderation action", action_str),
1416                            )));
1417                        }
1418                    };
1419                    Ok(wot::ModerationRecord {
1420                        content: Hash::from(arr),
1421                        moderator: row.get::<_, String>(1)?,
1422                        action,
1423                        scope,
1424                        sequence: u64::try_from(row.get::<_, i64>(4)?).unwrap_or_default(),
1425                        created_at: u64::try_from(row.get::<_, i64>(5)?).unwrap_or_default(),
1426                        reason: row.get::<_, String>(6)?,
1427                        signature: row.get(7)?,
1428                    })
1429                })
1430                .map_err(|error| database_error("failed to query moderation records", error))?
1431                .collect::<std::result::Result<Vec<_>, _>>()
1432                .map_err(|error| database_error("failed to read moderation rows", error))?;
1433            Ok(records)
1434        })
1435    }
1436
1437    /// Save attestations to the database.
1438    ///
1439    /// # Errors
1440    ///
1441    /// Returns an error if the attestations cannot be persisted.
1442    pub fn save_attestations(&self, attestations: &[wot::Attestation]) -> Result<()> {
1443        self.with_connection(|connection| {
1444            connection.execute("DELETE FROM attestations_v2", [])
1445                .map_err(|error| database_error("failed to clear attestations", error))?;
1446            for a in attestations {
1447                let (kind, kind_other) = match &a.kind {
1448                    wot::AttestationKind::License => ("license", None),
1449                    wot::AttestationKind::Provenance => ("provenance", None),
1450                    wot::AttestationKind::Derivative => ("derivative", None),
1451                    wot::AttestationKind::Other(v) => ("other", Some(v.as_str())),
1452                };
1453                connection.execute(
1454                    "INSERT INTO attestations_v2(content_hash, issuer, kind, kind_other, value, sequence, issued_at, signature)
1455                     VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
1456                    params![
1457                        a.content.as_bytes().to_vec(), a.issuer,
1458                        kind, kind_other, a.value,
1459                        i64::try_from(a.sequence).unwrap_or(i64::MAX),
1460                        i64::try_from(a.issued_at).unwrap_or(i64::MAX),
1461                        a.signature,
1462                    ],
1463                ).map_err(|error| database_error("failed to save attestation", error))?;
1464            }
1465            Ok(())
1466        })
1467    }
1468
1469    /// Load attestations from the database.
1470    ///
1471    /// # Errors
1472    ///
1473    /// Returns an error if the attestations cannot be read.
1474    pub fn load_attestations(&self) -> Result<Vec<wot::Attestation>> {
1475        self.with_connection(|connection| {
1476            let mut stmt = connection
1477                .prepare(
1478                    "SELECT content_hash, issuer, kind, kind_other, value, sequence, issued_at, signature
1479                 FROM attestations_v2 ORDER BY issued_at",
1480                )
1481                .map_err(|error| database_error("failed to prepare attestations query", error))?;
1482            let attestations = stmt
1483                .query_map([], |row| {
1484                    let hash_bytes: Vec<u8> = row.get(0)?;
1485                    let arr: [u8; 32] = hash_bytes.try_into().map_err(|error: Vec<u8>| {
1486                        rusqlite::Error::ToSqlConversionFailure(Box::new(SyncwebError::operation(
1487                            "invalid hash length",
1488                            format!("expected 32 bytes, got {}", error.len()),
1489                        )))
1490                    })?;
1491                    let kind_str: String = row.get(2)?;
1492                    let kind_other: Option<String> = row.get(3)?;
1493                    let kind = match kind_str.as_str() {
1494                        "license" => wot::AttestationKind::License,
1495                        "provenance" => wot::AttestationKind::Provenance,
1496                        "derivative" => wot::AttestationKind::Derivative,
1497                        "other" => wot::AttestationKind::Other(kind_other.unwrap_or_default()),
1498                        _ => {
1499                            return Err(rusqlite::Error::ToSqlConversionFailure(Box::new(
1500                                SyncwebError::operation("unknown attestation kind", kind_str),
1501                            )));
1502                        }
1503                    };
1504                    Ok(wot::Attestation {
1505                        content: Hash::from(arr),
1506                        issuer: row.get::<_, String>(1)?,
1507                        kind,
1508                        value: row.get::<_, String>(4)?,
1509                        sequence: u64::try_from(row.get::<_, i64>(5)?).unwrap_or_default(),
1510                        issued_at: u64::try_from(row.get::<_, i64>(6)?).unwrap_or_default(),
1511                        signature: row.get(7)?,
1512                    })
1513                })
1514                .map_err(|error| database_error("failed to query attestations", error))?
1515                .collect::<std::result::Result<Vec<_>, _>>()
1516                .map_err(|error| database_error("failed to read attestation rows", error))?;
1517            Ok(attestations)
1518        })
1519    }
1520
1521    /// Save content reports to the database.
1522    ///
1523    /// # Errors
1524    ///
1525    /// Returns an error if the reports cannot be persisted.
1526    pub fn save_content_reports(&self, reports: &[ReportRecord]) -> Result<()> {
1527        self.with_connection(|connection| {
1528            connection
1529                .execute("DELETE FROM content_reports_v2", [])
1530                .map_err(|error| database_error("failed to clear content reports", error))?;
1531            for r in reports {
1532                let reporter = r.reporter.as_deref().unwrap_or("cli");
1533                connection
1534                    .execute(
1535                        "INSERT INTO content_reports_v2(content_hash, reporter, reason, scope, created_at, signature)
1536                     VALUES (?1, ?2, ?3, 'global', ?4, ?5)",
1537                        params![
1538                            r.content.as_bytes().to_vec(),
1539                            reporter,
1540                            r.reason,
1541                            i64::try_from(r.created_at).unwrap_or(i64::MAX),
1542                            r.signature,
1543                        ],
1544                    )
1545                    .map_err(|error| database_error("failed to save content report", error))?;
1546            }
1547            Ok(())
1548        })
1549    }
1550
1551    /// Load content reports from the database.
1552    ///
1553    /// # Errors
1554    ///
1555    /// Returns an error if the reports cannot be read.
1556    pub fn load_content_reports(&self) -> Result<Vec<ReportRecord>> {
1557        self.with_connection(|connection| {
1558            let mut stmt = connection
1559                .prepare(
1560                    "SELECT content_hash, reason, created_at, reporter, signature
1561                     FROM content_reports_v2 ORDER BY created_at",
1562                )
1563                .map_err(|error| database_error("failed to prepare reports query", error))?;
1564            let reports = stmt
1565                .query_map([], |row| {
1566                    let hash_bytes: Vec<u8> = row.get(0)?;
1567                    let arr: [u8; 32] = hash_bytes.try_into().map_err(|error: Vec<u8>| {
1568                        rusqlite::Error::ToSqlConversionFailure(Box::new(SyncwebError::operation(
1569                            "invalid hash length",
1570                            format!("expected 32 bytes, got {}", error.len()),
1571                        )))
1572                    })?;
1573                    Ok(ReportRecord {
1574                        content: Hash::from(arr),
1575                        reason: row.get::<_, String>(1)?,
1576                        created_at: u64::try_from(row.get::<_, i64>(2)?).unwrap_or_default(),
1577                        reporter: row.get::<_, Option<String>>(3)?,
1578                        signature: row.get::<_, Option<String>>(4)?,
1579                    })
1580                })
1581                .map_err(|error| database_error("failed to query content reports", error))?
1582                .collect::<std::result::Result<Vec<_>, _>>()
1583                .map_err(|error| database_error("failed to read report rows", error))?;
1584            Ok(reports)
1585        })
1586    }
1587
1588    /// Save provider bans to the database.
1589    ///
1590    /// # Errors
1591    ///
1592    /// Returns an error if the bans cannot be persisted.
1593    pub fn save_provider_bans(&self, bans: &[resilience::BanRecord]) -> Result<()> {
1594        self.with_connection(|connection| {
1595            connection
1596                .execute("DELETE FROM provider_bans_v2", [])
1597                .map_err(|error| database_error("failed to clear provider bans", error))?;
1598            for b in bans {
1599                connection
1600                    .execute(
1601                        "INSERT INTO provider_bans_v2(provider, content_hash, banned_at, expires_at, reason, source)
1602                     VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
1603                        params![
1604                            b.provider.to_string(),
1605                            b.hash.map(|h| h.as_bytes().to_vec()),
1606                            i64::try_from(b.banned_at).unwrap_or(i64::MAX),
1607                            b.expires_at.map(|v| i64::try_from(v).unwrap_or(i64::MAX)),
1608                            b.reason,
1609                            format!("{:?}", b.source),
1610                        ],
1611                    )
1612                    .map_err(|error| database_error("failed to save provider ban", error))?;
1613            }
1614            Ok(())
1615        })
1616    }
1617
1618    /// Load provider bans from the database.
1619    ///
1620    /// # Errors
1621    ///
1622    /// Returns an error if the bans cannot be read.
1623    pub fn load_provider_bans(&self) -> Result<Vec<resilience::BanRecord>> {
1624        self.with_connection(|connection| {
1625            let mut stmt = connection
1626                .prepare(
1627                    "SELECT provider, content_hash, banned_at, expires_at, reason, source
1628                 FROM provider_bans_v2 ORDER BY banned_at",
1629                )
1630                .map_err(|error| database_error("failed to prepare bans query", error))?;
1631            let bans = stmt
1632                .query_map([], |row| {
1633                    let provider_str: String = row.get(0)?;
1634                    let content_hash: Option<Vec<u8>> = row.get(1)?;
1635                    let source_str: String = row.get(5)?;
1636                    let provider = provider_str.parse::<PublicKey>().map_err(|error| {
1637                        rusqlite::Error::ToSqlConversionFailure(Box::new(SyncwebError::operation(
1638                            "invalid provider",
1639                            error,
1640                        )))
1641                    })?;
1642                    let hash = content_hash.and_then(|b| {
1643                        let arr: [u8; 32] = b.try_into().ok()?;
1644                        Some(Hash::from(arr))
1645                    });
1646                    let source = match source_str.as_str() {
1647                        "Automated" => resilience::BanSource::Automated,
1648                        "WoT" => resilience::BanSource::WoT,
1649                        _ => resilience::BanSource::Manual,
1650                    };
1651                    Ok(resilience::BanRecord {
1652                        provider,
1653                        hash,
1654                        banned_at: u64::try_from(row.get::<_, i64>(2)?).unwrap_or_default(),
1655                        expires_at: row
1656                            .get::<_, Option<i64>>(3)?
1657                            .map(|v| u64::try_from(v).unwrap_or_default()),
1658                        reason: row.get::<_, String>(4)?,
1659                        source,
1660                    })
1661                })
1662                .map_err(|error| database_error("failed to query provider bans", error))?
1663                .collect::<std::result::Result<Vec<_>, _>>()
1664                .map_err(|error| database_error("failed to read ban rows", error))?;
1665            Ok(bans)
1666        })
1667    }
1668
1669    /// Insert or replace a single provider lease.
1670    ///
1671    /// # Errors
1672    ///
1673    /// Returns an error if the lease cannot be persisted.
1674    pub fn insert_provider_lease(&self, lease: &resilience::ProviderLease) -> Result<()> {
1675        self.with_connection(|connection| {
1676            connection.execute(
1677                "INSERT OR REPLACE INTO provider_leases(provider, content_hash, ticket, sequence, issued_at, expires_at, signature)
1678                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
1679                params![
1680                    lease.provider.to_string(),
1681                    lease.hash.as_bytes().to_vec(),
1682                    lease.ticket,
1683                    i64::try_from(lease.sequence).unwrap_or(i64::MAX),
1684                    i64::try_from(lease.issued_at).unwrap_or(i64::MAX),
1685                    i64::try_from(lease.expires_at).unwrap_or(i64::MAX),
1686                    lease.signature,
1687                ],
1688            )
1689            .map_err(|error| database_error("failed to insert provider lease", error))?;
1690            Ok(())
1691        })
1692    }
1693
1694    /// Load active (non-expired) provider leases from the database.
1695    ///
1696    /// # Errors
1697    ///
1698    /// Returns an error if the leases cannot be read.
1699    pub fn load_active_leases(&self, now: u64) -> Result<Vec<resilience::ProviderLease>> {
1700        self.with_connection(|connection| {
1701            let mut stmt = connection
1702                .prepare(
1703                    "SELECT provider, content_hash, ticket, sequence, issued_at, expires_at, signature
1704                 FROM provider_leases WHERE expires_at > ?1 ORDER BY issued_at",
1705                )
1706                .map_err(|error| database_error("failed to prepare active leases query", error))?;
1707            let leases = stmt
1708                .query_map(params![i64::try_from(now).unwrap_or(i64::MAX)], |row| {
1709                    let provider_str: String = row.get(0)?;
1710                    let hash_bytes: Vec<u8> = row.get(1)?;
1711                    let arr: [u8; 32] = hash_bytes.try_into().map_err(|error: Vec<u8>| {
1712                        rusqlite::Error::ToSqlConversionFailure(Box::new(SyncwebError::operation(
1713                            "invalid hash length",
1714                            format!("expected 32 bytes, got {}", error.len()),
1715                        )))
1716                    })?;
1717                    let provider = provider_str.parse::<PublicKey>().map_err(|error| {
1718                        rusqlite::Error::ToSqlConversionFailure(Box::new(SyncwebError::operation(
1719                            "invalid provider",
1720                            error,
1721                        )))
1722                    })?;
1723                    Ok(resilience::ProviderLease {
1724                        hash: Hash::from(arr),
1725                        provider,
1726                        ticket: row.get(2)?,
1727                        sequence: u64::try_from(row.get::<_, i64>(3)?).unwrap_or_default(),
1728                        issued_at: u64::try_from(row.get::<_, i64>(4)?).unwrap_or_default(),
1729                        expires_at: u64::try_from(row.get::<_, i64>(5)?).unwrap_or_default(),
1730                        signature: row.get(6)?,
1731                    })
1732                })
1733                .map_err(|error| database_error("failed to query active leases", error))?
1734                .collect::<std::result::Result<Vec<_>, _>>()
1735                .map_err(|error| database_error("failed to read lease rows", error))?;
1736            Ok(leases)
1737        })
1738    }
1739
1740    /// Insert or replace a single provider ban record.
1741    ///
1742    /// # Errors
1743    ///
1744    /// Returns an error if the ban cannot be persisted.
1745    pub fn insert_provider_ban(&self, ban: &resilience::BanRecord) -> Result<()> {
1746        self.with_connection(|connection| {
1747            connection
1748                .execute(
1749                    "INSERT OR REPLACE INTO provider_bans_v2(provider, content_hash, banned_at, expires_at, reason, source)
1750                 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
1751                    params![
1752                        ban.provider.to_string(),
1753                        ban.hash.map(|h| h.as_bytes().to_vec()),
1754                        i64::try_from(ban.banned_at).unwrap_or(i64::MAX),
1755                        ban.expires_at.map(|v| i64::try_from(v).unwrap_or(i64::MAX)),
1756                        ban.reason,
1757                        format!("{:?}", ban.source),
1758                    ],
1759                )
1760                .map_err(|error| database_error("failed to insert provider ban", error))?;
1761            Ok(())
1762        })
1763    }
1764
1765    /// Load active (non-expired) provider bans from the database.
1766    ///
1767    /// # Errors
1768    ///
1769    /// Returns an error if the bans cannot be read.
1770    pub fn load_active_bans(&self, now: u64) -> Result<Vec<resilience::BanRecord>> {
1771        self.with_connection(|connection| {
1772            let mut stmt = connection
1773                .prepare(
1774                    "SELECT provider, content_hash, banned_at, expires_at, reason, source
1775                 FROM provider_bans_v2 WHERE expires_at IS NULL OR expires_at > ?1 ORDER BY banned_at",
1776                )
1777                .map_err(|error| database_error("failed to prepare active bans query", error))?;
1778            let bans = stmt
1779                .query_map(params![i64::try_from(now).unwrap_or(i64::MAX)], |row| {
1780                    let provider_str: String = row.get(0)?;
1781                    let content_hash: Option<Vec<u8>> = row.get(1)?;
1782                    let source_str: String = row.get(5)?;
1783                    let provider = provider_str.parse::<PublicKey>().map_err(|error| {
1784                        rusqlite::Error::ToSqlConversionFailure(Box::new(SyncwebError::operation(
1785                            "invalid provider",
1786                            error,
1787                        )))
1788                    })?;
1789                    let hash = content_hash.and_then(|b| {
1790                        let arr: [u8; 32] = b.try_into().ok()?;
1791                        Some(Hash::from(arr))
1792                    });
1793                    let source = match source_str.as_str() {
1794                        "Automated" => resilience::BanSource::Automated,
1795                        "WoT" => resilience::BanSource::WoT,
1796                        _ => resilience::BanSource::Manual,
1797                    };
1798                    Ok(resilience::BanRecord {
1799                        provider,
1800                        hash,
1801                        banned_at: u64::try_from(row.get::<_, i64>(2)?).unwrap_or_default(),
1802                        expires_at: row
1803                            .get::<_, Option<i64>>(3)?
1804                            .map(|v| u64::try_from(v).unwrap_or_default()),
1805                        reason: row.get::<_, String>(4)?,
1806                        source,
1807                    })
1808                })
1809                .map_err(|error| database_error("failed to query active provider bans", error))?
1810                .collect::<std::result::Result<Vec<_>, _>>()
1811                .map_err(|error| database_error("failed to read ban rows", error))?;
1812            Ok(bans)
1813        })
1814    }
1815
1816    /// Load all provider reputations from the database.
1817    ///
1818    /// # Errors
1819    ///
1820    /// Returns an error if the reputations cannot be read.
1821    pub fn load_all_reputations(&self) -> Result<HashMap<PublicKey, reputation::ProviderReputation>> {
1822        self.with_connection(|connection| {
1823            let mut stmt = connection
1824                .prepare(
1825                    "SELECT provider, total_fetches, successful_fetches, failed_fetches,
1826                     consecutive_failures, last_success_at, last_failure_at
1827                 FROM provider_reputation",
1828                )
1829                .map_err(|error| database_error("failed to prepare reputations query", error))?;
1830            let rows = stmt
1831                .query_map([], |row| {
1832                    let provider_str: String = row.get(0)?;
1833                    let provider = provider_str.parse::<PublicKey>().map_err(|error| {
1834                        rusqlite::Error::ToSqlConversionFailure(Box::new(SyncwebError::operation(
1835                            "invalid provider",
1836                            error,
1837                        )))
1838                    })?;
1839                    let rep = reputation::ProviderReputation {
1840                        provider,
1841                        total_fetches: u64::try_from(row.get::<_, i64>(1)?).unwrap_or_default(),
1842                        successful_fetches: u64::try_from(row.get::<_, i64>(2)?).unwrap_or_default(),
1843                        failed_fetches: u64::try_from(row.get::<_, i64>(3)?).unwrap_or_default(),
1844                        consecutive_failures: u32::try_from(row.get::<_, i64>(4)?).unwrap_or_default(),
1845                        last_success_at: row
1846                            .get::<_, Option<i64>>(5)?
1847                            .map(|v| u64::try_from(v).unwrap_or_default()),
1848                        last_failure_at: row
1849                            .get::<_, Option<i64>>(6)?
1850                            .map(|v| u64::try_from(v).unwrap_or_default()),
1851                    };
1852                    Ok((provider, rep))
1853                })
1854                .map_err(|error| database_error("failed to query reputations", error))?
1855                .collect::<std::result::Result<HashMap<_, _>, _>>()
1856                .map_err(|error| database_error("failed to read reputation rows", error))?;
1857            Ok(rows)
1858        })
1859    }
1860
1861    /// Load all signal sequence numbers from the database.
1862    ///
1863    /// # Errors
1864    ///
1865    /// Returns an error if the sequences cannot be read.
1866    pub fn load_signal_sequences(&self) -> Result<HashMap<(PublicKey, PublicKey), u64>> {
1867        self.with_connection(|connection| {
1868            let mut stmt = connection
1869                .prepare("SELECT reporter, provider, last_sequence FROM provider_signal_sequences")
1870                .map_err(|error| database_error("failed to prepare signal sequences query", error))?;
1871            let rows = stmt
1872                .query_map([], |row| {
1873                    let reporter_str: String = row.get(0)?;
1874                    let provider_str: String = row.get(1)?;
1875                    let reporter = reporter_str.parse::<PublicKey>().map_err(|error| {
1876                        rusqlite::Error::ToSqlConversionFailure(Box::new(SyncwebError::operation(
1877                            "invalid reporter",
1878                            error,
1879                        )))
1880                    })?;
1881                    let provider = provider_str.parse::<PublicKey>().map_err(|error| {
1882                        rusqlite::Error::ToSqlConversionFailure(Box::new(SyncwebError::operation(
1883                            "invalid provider",
1884                            error,
1885                        )))
1886                    })?;
1887                    Ok((
1888                        (reporter, provider),
1889                        u64::try_from(row.get::<_, i64>(2)?).unwrap_or_default(),
1890                    ))
1891                })
1892                .map_err(|error| database_error("failed to query signal sequences", error))?
1893                .collect::<std::result::Result<HashMap<_, _>, _>>()
1894                .map_err(|error| database_error("failed to read signal sequence rows", error))?;
1895            Ok(rows)
1896        })
1897    }
1898
1899    /// Upsert a provider reputation record.
1900    ///
1901    /// # Errors
1902    ///
1903    /// Returns an error if the record cannot be persisted.
1904    pub fn upsert_reputation(
1905        &self,
1906        provider: &PublicKey,
1907        rep: &reputation::ProviderReputation,
1908        auto_ban_until: Option<u64>,
1909        auto_ban_count: u32,
1910    ) -> Result<()> {
1911        self.with_connection(|connection| {
1912            connection
1913                .execute(
1914                    "INSERT OR REPLACE INTO provider_reputation
1915                 (provider, total_fetches, successful_fetches, failed_fetches,
1916                  consecutive_failures, last_success_at, last_failure_at,
1917                  auto_ban_until, auto_ban_count)
1918                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
1919                    params![
1920                        provider.to_string(),
1921                        i64::try_from(rep.total_fetches).unwrap_or(i64::MAX),
1922                        i64::try_from(rep.successful_fetches).unwrap_or(i64::MAX),
1923                        i64::try_from(rep.failed_fetches).unwrap_or(i64::MAX),
1924                        i64::from(rep.consecutive_failures),
1925                        rep.last_success_at.map(|v| i64::try_from(v).unwrap_or(i64::MAX)),
1926                        rep.last_failure_at.map(|v| i64::try_from(v).unwrap_or(i64::MAX)),
1927                        auto_ban_until.map(|v| i64::try_from(v).unwrap_or(i64::MAX)),
1928                        i64::from(auto_ban_count),
1929                    ],
1930                )
1931                .map_err(|error| database_error("failed to upsert reputation", error))?;
1932            Ok(())
1933        })
1934    }
1935
1936    /// Delete stale reputation records last active before the given TTL.
1937    ///
1938    /// # Errors
1939    ///
1940    /// Returns an error if the deletion fails.
1941    pub fn delete_stale_reputations(&self, now: u64, ttl_secs: u64) -> Result<()> {
1942        self.with_connection(|connection| {
1943            let cutoff = i64::try_from(now.saturating_sub(ttl_secs)).unwrap_or(i64::MIN);
1944            connection
1945                .execute(
1946                    "DELETE FROM provider_reputation
1947                 WHERE (last_success_at IS NULL OR last_success_at < ?1)
1948                   AND (last_failure_at IS NULL OR last_failure_at < ?1)",
1949                    params![cutoff],
1950                )
1951                .map_err(|error| database_error("failed to delete stale reputations", error))?;
1952            Ok(())
1953        })
1954    }
1955
1956    /// Load auto-ban information for all providers.
1957    ///
1958    /// Returns a map of provider to (`auto_ban_until`, `auto_ban_count`).
1959    ///
1960    /// # Errors
1961    ///
1962    /// Returns an error if the data cannot be read.
1963    pub fn load_auto_bans(&self) -> Result<HashMap<PublicKey, (Option<u64>, u32)>> {
1964        self.with_connection(|connection| {
1965            let mut stmt = connection
1966                .prepare("SELECT provider, auto_ban_until, auto_ban_count FROM provider_reputation")
1967                .map_err(|error| database_error("failed to prepare auto bans query", error))?;
1968            let rows = stmt
1969                .query_map([], |row| {
1970                    let provider_str: String = row.get(0)?;
1971                    let provider = provider_str.parse::<PublicKey>().map_err(|error| {
1972                        rusqlite::Error::ToSqlConversionFailure(Box::new(SyncwebError::operation(
1973                            "invalid provider",
1974                            error,
1975                        )))
1976                    })?;
1977                    Ok((
1978                        provider,
1979                        (
1980                            row.get::<_, Option<i64>>(1)?
1981                                .map(|v| u64::try_from(v).unwrap_or_default()),
1982                            u32::try_from(row.get::<_, i64>(2)?).unwrap_or_default(),
1983                        ),
1984                    ))
1985                })
1986                .map_err(|error| database_error("failed to query auto bans", error))?
1987                .collect::<std::result::Result<HashMap<_, _>, _>>()
1988                .map_err(|error| database_error("failed to read auto ban rows", error))?;
1989            Ok(rows)
1990        })
1991    }
1992
1993    /// Upsert a provider signal sequence tracker.
1994    ///
1995    /// # Errors
1996    ///
1997    /// Returns an error if the sequence cannot be persisted.
1998    pub fn upsert_signal_sequence(&self, reporter: &PublicKey, provider: &PublicKey, sequence: u64) -> Result<()> {
1999        self.with_connection(|connection| {
2000            connection
2001                .execute(
2002                    "INSERT OR REPLACE INTO provider_signal_sequences(reporter, provider, last_sequence)
2003                 VALUES (?1, ?2, ?3)",
2004                    params![
2005                        reporter.to_string(),
2006                        provider.to_string(),
2007                        i64::try_from(sequence).unwrap_or(i64::MAX),
2008                    ],
2009                )
2010                .map_err(|error| database_error("failed to upsert signal sequence", error))?;
2011            Ok(())
2012        })
2013    }
2014
2015    /// Save provider trust records to the database.
2016    ///
2017    /// # Errors
2018    ///
2019    /// Returns an error if the records cannot be persisted.
2020    pub fn save_provider_trust_records(&self, records: &[wot::ProviderTrustRecord]) -> Result<()> {
2021        self.with_connection(|connection| {
2022            connection.execute("DELETE FROM provider_trust_records_v2", [])
2023                .map_err(|error| database_error("failed to clear provider trust records", error))?;
2024            for r in records {
2025                let action_str = format!("{:?}", r.action);
2026                connection.execute(
2027                    "INSERT INTO provider_trust_records_v2(provider, action, scope, issuer, sequence, issued_at, expires_at, reason, signature)
2028                     VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
2029                    params![
2030                        r.provider.to_string(), action_str,
2031                        r.scope.map(|h| h.as_bytes().to_vec()),
2032                        r.issuer,
2033                        i64::try_from(r.sequence).unwrap_or(i64::MAX),
2034                        i64::try_from(r.issued_at).unwrap_or(i64::MAX),
2035                        r.expires_at.map(|v| i64::try_from(v).unwrap_or(i64::MAX)),
2036                        r.reason,
2037                        r.signature,
2038                    ],
2039                ).map_err(|error| database_error("failed to save provider trust record", error))?;
2040            }
2041            Ok(())
2042        })
2043    }
2044
2045    /// Load provider trust records from the database.
2046    ///
2047    /// # Errors
2048    ///
2049    /// Returns an error if the records cannot be read.
2050    pub fn load_provider_trust_records(&self) -> Result<Vec<wot::ProviderTrustRecord>> {
2051        self.with_connection(|connection| {
2052            let mut stmt = connection
2053                .prepare(
2054                    "SELECT provider, action, scope, issuer, sequence, issued_at, expires_at, reason, signature
2055                 FROM provider_trust_records_v2 ORDER BY issued_at",
2056                )
2057                .map_err(|error| database_error("failed to prepare trust records query", error))?;
2058            let records = stmt
2059                .query_map([], |row| {
2060                    let provider_str: String = row.get(0)?;
2061                    let action_str: String = row.get(1)?;
2062                    let scope: Option<Vec<u8>> = row.get(2)?;
2063                    let provider = provider_str.parse::<PublicKey>().map_err(|error| {
2064                        rusqlite::Error::ToSqlConversionFailure(Box::new(SyncwebError::operation(
2065                            "invalid provider",
2066                            error,
2067                        )))
2068                    })?;
2069                    let action = match action_str.as_str() {
2070                        "Trust" => wot::ProviderTrustAction::Trust,
2071                        "Distrust" => wot::ProviderTrustAction::Distrust,
2072                        "Vouch" => wot::ProviderTrustAction::Vouch,
2073                        "Warn" => wot::ProviderTrustAction::Warn,
2074                        _ => {
2075                            return Err(rusqlite::Error::ToSqlConversionFailure(Box::new(
2076                                SyncwebError::operation("unknown trust action", action_str),
2077                            )));
2078                        }
2079                    };
2080                    let hash = scope.and_then(|b| {
2081                        let arr: [u8; 32] = b.try_into().ok()?;
2082                        Some(Hash::from(arr))
2083                    });
2084                    Ok(wot::ProviderTrustRecord {
2085                        provider,
2086                        action,
2087                        scope: hash,
2088                        issuer: row.get::<_, String>(3)?,
2089                        sequence: u64::try_from(row.get::<_, i64>(4)?).unwrap_or_default(),
2090                        issued_at: u64::try_from(row.get::<_, i64>(5)?).unwrap_or_default(),
2091                        expires_at: row
2092                            .get::<_, Option<i64>>(6)?
2093                            .map(|v| u64::try_from(v).unwrap_or_default()),
2094                        reason: row.get::<_, String>(7)?,
2095                        signature: row.get(8)?,
2096                    })
2097                })
2098                .map_err(|error| database_error("failed to query provider trust records", error))?
2099                .collect::<std::result::Result<Vec<_>, _>>()
2100                .map_err(|error| database_error("failed to read trust record rows", error))?;
2101            Ok(records)
2102        })
2103    }
2104
2105    /// Save provider trust signals to the database.
2106    ///
2107    /// # Errors
2108    ///
2109    /// Returns an error if the signals cannot be persisted.
2110    pub fn save_provider_trust_signals(&self, signals: &[reputation::ProviderTrustSignal]) -> Result<()> {
2111        self.with_connection(|connection| {
2112            connection.execute("DELETE FROM provider_trust_signals_v2", [])
2113                .map_err(|error| database_error("failed to clear provider trust signals", error))?;
2114            for s in signals {
2115                let kind_str = format!("{:?}", s.signal);
2116                connection.execute(
2117                    "INSERT INTO provider_trust_signals_v2(reporter, provider, signal_kind, content_hash, sequence, timestamp, signature)
2118                     VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
2119                    params![
2120                        s.reporter.to_string(), s.provider.to_string(),
2121                        kind_str,
2122                        s.hash.map(|h| h.as_bytes().to_vec()),
2123                        i64::try_from(s.sequence).unwrap_or(i64::MAX),
2124                        i64::try_from(s.timestamp).unwrap_or(i64::MAX),
2125                        s.signature,
2126                    ],
2127                ).map_err(|error| database_error("failed to save trust signal", error))?;
2128            }
2129            Ok(())
2130        })
2131    }
2132
2133    /// Load provider trust signals from the database.
2134    ///
2135    /// # Errors
2136    ///
2137    /// Returns an error if the signals cannot be read.
2138    pub fn load_provider_trust_signals(&self) -> Result<Vec<reputation::ProviderTrustSignal>> {
2139        self.with_connection(|connection| {
2140            let mut stmt = connection
2141                .prepare(
2142                    "SELECT reporter, provider, signal_kind, content_hash, sequence, timestamp, signature
2143                 FROM provider_trust_signals_v2 ORDER BY timestamp",
2144                )
2145                .map_err(|error| database_error("failed to prepare trust signals query", error))?;
2146            let signals = stmt
2147                .query_map([], |row| {
2148                    let reporter_str: String = row.get(0)?;
2149                    let provider_str: String = row.get(1)?;
2150                    let kind_str: String = row.get(2)?;
2151                    let content_hash: Option<Vec<u8>> = row.get(3)?;
2152                    let reporter = reporter_str.parse::<PublicKey>().map_err(|error| {
2153                        rusqlite::Error::ToSqlConversionFailure(Box::new(SyncwebError::operation(
2154                            "invalid reporter",
2155                            error,
2156                        )))
2157                    })?;
2158                    let provider = provider_str.parse::<PublicKey>().map_err(|error| {
2159                        rusqlite::Error::ToSqlConversionFailure(Box::new(SyncwebError::operation(
2160                            "invalid provider",
2161                            error,
2162                        )))
2163                    })?;
2164                    let kind = match kind_str.as_str() {
2165                        "ObservedSuccess" => reputation::TrustSignalKind::ObservedSuccess,
2166                        "ObservedFailure" => reputation::TrustSignalKind::ObservedFailure,
2167                        "ObservedCorruption" => reputation::TrustSignalKind::ObservedCorruption,
2168                        _ => {
2169                            return Err(rusqlite::Error::ToSqlConversionFailure(Box::new(
2170                                SyncwebError::operation("unknown signal kind", kind_str),
2171                            )));
2172                        }
2173                    };
2174                    let hash = content_hash.and_then(|b| {
2175                        let arr: [u8; 32] = b.try_into().ok()?;
2176                        Some(Hash::from(arr))
2177                    });
2178                    Ok(reputation::ProviderTrustSignal {
2179                        provider,
2180                        signal: kind,
2181                        hash,
2182                        reporter,
2183                        timestamp: u64::try_from(row.get::<_, i64>(5)?).unwrap_or_default(),
2184                        sequence: u64::try_from(row.get::<_, i64>(4)?).unwrap_or_default(),
2185                        signature: row.get(6)?,
2186                    })
2187                })
2188                .map_err(|error| database_error("failed to query trust signals", error))?
2189                .collect::<std::result::Result<Vec<_>, _>>()
2190                .map_err(|error| database_error("failed to read trust signal rows", error))?;
2191            Ok(signals)
2192        })
2193    }
2194
2195    /// Save trust streams to the database.
2196    ///
2197    /// # Errors
2198    ///
2199    /// Returns an error if the streams cannot be persisted.
2200    pub fn save_trust_streams(&self, streams: &[String]) -> Result<()> {
2201        self.with_connection(|connection| {
2202            connection
2203                .execute("DELETE FROM trust_streams", [])
2204                .map_err(|error| database_error("failed to clear trust streams", error))?;
2205            let now = now_seconds();
2206            for ns in streams {
2207                connection
2208                    .execute(
2209                        "INSERT INTO trust_streams(namespace, subscribed_at) VALUES (?1, ?2)",
2210                        params![ns, now],
2211                    )
2212                    .map_err(|error| database_error("failed to save trust stream", error))?;
2213            }
2214            Ok(())
2215        })
2216    }
2217
2218    /// Load trust streams from the database.
2219    ///
2220    /// # Errors
2221    ///
2222    /// Returns an error if the streams cannot be read.
2223    pub fn load_trust_streams(&self) -> Result<Vec<String>> {
2224        self.with_connection(|connection| {
2225            let mut stmt = connection
2226                .prepare("SELECT namespace FROM trust_streams ORDER BY subscribed_at")
2227                .map_err(|error| database_error("failed to prepare trust streams query", error))?;
2228            let streams = stmt
2229                .query_map([], |row| row.get::<_, String>(0))
2230                .map_err(|error| database_error("failed to query trust streams", error))?
2231                .collect::<std::result::Result<Vec<_>, _>>()
2232                .map_err(|error| database_error("failed to read trust stream rows", error))?;
2233            Ok(streams)
2234        })
2235    }
2236}
2237
2238/// The opt-in indexing service for synchronized folders.
2239#[derive(Clone)]
2240pub struct IndexingService {
2241    database: IndexingDatabase,
2242    events: broadcast::Sender<IndexingEvent>,
2243    tasks: Arc<Mutex<HashMap<NamespaceId, JoinHandle<()>>>>,
2244    denylist: denylist::DenylistService,
2245}
2246
2247impl std::fmt::Debug for IndexingService {
2248    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2249        formatter
2250            .debug_struct("IndexingService")
2251            .field("database", &self.database)
2252            .finish_non_exhaustive()
2253    }
2254}
2255
2256impl IndexingService {
2257    /// Start an indexing service backed by `path`.
2258    ///
2259    /// # Errors
2260    ///
2261    /// Returns an error if the database cannot be opened or initialized.
2262    pub fn new(path: impl AsRef<Path>) -> Result<Self> {
2263        Self::with_database(IndexingDatabase::open(path)?)
2264    }
2265
2266    /// Start an in-memory indexing service.
2267    ///
2268    /// # Errors
2269    ///
2270    /// Returns an error if `SQLite` cannot initialize the schema.
2271    pub fn in_memory() -> Result<Self> {
2272        Self::with_database(IndexingDatabase::in_memory()?)
2273    }
2274
2275    /// Start a service from an already opened database.
2276    ///
2277    /// # Errors
2278    ///
2279    /// Returns an error if the denylist rules cannot be loaded from the database.
2280    pub fn with_database(database: IndexingDatabase) -> Result<Self> {
2281        let (events, _) = broadcast::channel(EVENT_CAPACITY);
2282        let denylist = denylist::DenylistService::with_database(database.clone())?;
2283        Ok(Self {
2284            database,
2285            events,
2286            tasks: Arc::new(Mutex::new(HashMap::new())),
2287            denylist,
2288        })
2289    }
2290
2291    /// Create a catalog service backed by this indexing service.
2292    #[must_use]
2293    pub fn catalog_service(
2294        &self,
2295        docs: &crate::node::docs_engine::DocsEngine,
2296        blobs: &crate::node::blob_store::BlobStore,
2297        author: iroh_docs::AuthorId,
2298    ) -> CatalogService {
2299        CatalogService::new(self, docs, blobs, author)
2300    }
2301
2302    /// Create a lease-based resilience service for this indexer.
2303    #[must_use]
2304    pub fn resilience_service(&self, config: resilience::ResilienceConfig) -> resilience::ResilienceService {
2305        resilience::ResilienceService::with_database(self.database.clone(), config)
2306    }
2307
2308    /// Create a resilience service using this indexer's local `WoT` policy.
2309    #[must_use]
2310    pub fn resilience_service_with_wot(
2311        &self,
2312        config: resilience::ResilienceConfig,
2313        wot: wot::WotService,
2314    ) -> resilience::ResilienceService {
2315        resilience::ResilienceService::with_database(self.database.clone(), config).with_trust_policy(wot)
2316    }
2317
2318    /// Create a local Web-of-Trust metadata service for this indexer.
2319    #[must_use]
2320    pub fn wot_service(&self, policy: wot::TrustPolicy) -> wot::WotService {
2321        wot::WotService::new(self, policy)
2322    }
2323
2324    /// Return the thread-safe local denylist used by indexing hooks.
2325    #[must_use]
2326    pub fn denylist_service(&self) -> denylist::DenylistService {
2327        self.denylist.clone()
2328    }
2329
2330    /// Alias for [`Self::denylist_service`].
2331    #[must_use]
2332    pub fn denylist(&self) -> denylist::DenylistService {
2333        self.denylist_service()
2334    }
2335
2336    #[must_use]
2337    pub const fn database(&self) -> &IndexingDatabase {
2338        &self.database
2339    }
2340
2341    #[must_use]
2342    pub const fn db(&self) -> &IndexingDatabase {
2343        &self.database
2344    }
2345
2346    /// Search entries in folders enabled for local indexing.
2347    ///
2348    /// # Errors
2349    ///
2350    /// Returns an error if the query is invalid or `SQLite` cannot read results.
2351    pub fn search_local(&self, query: &str, limit: usize) -> Result<Vec<IndexedEntry>> {
2352        self.database.search(query, limit)
2353    }
2354
2355    /// Search records imported from subscribed catalogs.
2356    ///
2357    /// # Errors
2358    ///
2359    /// Returns an error if the query is invalid or `SQLite` cannot read results.
2360    pub fn search_global(&self, query: &str, limit: usize) -> Result<Vec<CatalogRecord>> {
2361        self.database.search_catalogs(query, limit)
2362    }
2363
2364    /// Search records imported from subscribed catalogs.
2365    ///
2366    /// This is the service-level search used by `indexing search`.
2367    ///
2368    /// # Errors
2369    ///
2370    /// Returns an error if the query is invalid or `SQLite` cannot read results.
2371    pub fn search(&self, query: &str, limit: usize) -> Result<Vec<CatalogRecord>> {
2372        self.search_global(query, limit)
2373    }
2374
2375    /// Subscribe to indexing and core-engine events.
2376    #[must_use]
2377    pub fn subscribe(&self) -> broadcast::Receiver<IndexingEvent> {
2378        self.events.subscribe()
2379    }
2380
2381    /// Subscribe to indexing and core-engine events.
2382    #[must_use]
2383    pub fn subscribe_events(&self) -> broadcast::Receiver<IndexingEvent> {
2384        self.subscribe()
2385    }
2386
2387    /// Opt a folder into indexing and begin consuming its document events.
2388    ///
2389    /// Existing document entries are indexed before the method returns.
2390    ///
2391    /// # Errors
2392    ///
2393    /// Returns an error if the folder cannot be registered, read, or watched.
2394    pub async fn enable_folder(&self, folder: &SyncwebFolder) -> Result<IndexingHandle> {
2395        let namespace_id = folder.namespace_id();
2396        if self
2397            .tasks
2398            .lock()
2399            .map_err(|error| SyncwebError::operation("indexing task lock poisoned", error))?
2400            .get(&namespace_id)
2401            .is_some_and(|task| !task.is_finished())
2402        {
2403            return Ok(IndexingHandle { namespace_id });
2404        }
2405
2406        let live_events = folder.docs_engine().watch(folder.doc()).await?;
2407        self.database.enable_folder(namespace_id, &namespace_id.to_string())?;
2408        send_event(&self.events, IndexingEvent::FolderEnabled { namespace_id });
2409
2410        for entry in folder.docs_engine().list_latest(folder.doc()).await? {
2411            let indexed = self.index_entry(namespace_id, &entry)?;
2412            send_event(&self.events, IndexingEvent::EntryIndexed(indexed));
2413        }
2414
2415        let database = self.database.clone();
2416        let events = self.events.clone();
2417        let task = tokio::spawn(async move {
2418            consume_folder_events(namespace_id, live_events, database, events).await;
2419        });
2420        self.tasks
2421            .lock()
2422            .map_err(|error| SyncwebError::operation("indexing task lock poisoned", error))?
2423            .insert(namespace_id, task);
2424        Ok(IndexingHandle { namespace_id })
2425    }
2426
2427    /// Stop indexing a folder and remove its local index entries.
2428    ///
2429    /// # Errors
2430    ///
2431    /// Returns an error if the folder index cannot be removed.
2432    pub async fn disable_folder(&self, namespace_id: NamespaceId) -> Result<()> {
2433        let task_handle = self
2434            .tasks
2435            .lock()
2436            .map_err(|error| SyncwebError::operation("indexing task lock poisoned", error))?
2437            .remove(&namespace_id);
2438        if let Some(task) = task_handle {
2439            task.abort();
2440        }
2441        let database = self.database.clone();
2442        tokio::task::spawn_blocking(move || database.disable_folder(namespace_id))
2443            .await
2444            .map_err(|error| SyncwebError::operation("indexing folder removal task failed", error))??;
2445        send_event(&self.events, IndexingEvent::FolderDisabled { namespace_id });
2446        Ok(())
2447    }
2448
2449    /// Return whether an indexing task is active for a folder.
2450    ///
2451    /// # Errors
2452    ///
2453    /// Returns an error if the task registry lock is poisoned.
2454    pub fn is_folder_enabled(&self, namespace_id: NamespaceId) -> Result<bool> {
2455        let active_task = self
2456            .tasks
2457            .lock()
2458            .map_err(|error| SyncwebError::operation("indexing task lock poisoned", error))?
2459            .get(&namespace_id)
2460            .is_some_and(|task| !task.is_finished());
2461        Ok(active_task || self.database.is_folder_enabled(namespace_id)?)
2462    }
2463
2464    /// Index an entry supplied by another catalog or an application.
2465    ///
2466    /// This method does not require a folder task, but the namespace must
2467    /// already be enabled.
2468    ///
2469    /// # Errors
2470    ///
2471    /// Returns an error if the namespace is not enabled or the entry cannot be
2472    /// stored.
2473    pub fn index_entry(&self, namespace_id: NamespaceId, entry: &Entry) -> Result<IndexedEntry> {
2474        if !self.database.is_folder_enabled(namespace_id)? {
2475            return Err(SyncwebError::FolderNotFound(namespace_id.to_string()));
2476        }
2477        self.database
2478            .upsert_entry(namespace_id, entry.key(), entry.content_hash(), entry.content_len())
2479    }
2480}
2481
2482async fn consume_folder_events(
2483    namespace_id: NamespaceId,
2484    mut live_events: impl n0_future::Stream<Item = Result<LiveEvent>> + Send + Unpin + 'static,
2485    database: IndexingDatabase,
2486    events: broadcast::Sender<IndexingEvent>,
2487) {
2488    while let Some(event_result) = live_events.next().await {
2489        let event = match event_result {
2490            Ok(event) => event,
2491            Err(error) => {
2492                send_event(
2493                    &events,
2494                    IndexingEvent::Error {
2495                        namespace_id,
2496                        message: error.to_string(),
2497                    },
2498                );
2499                break;
2500            }
2501        };
2502        match event {
2503            LiveEvent::InsertLocal { entry } | LiveEvent::InsertRemote { entry, .. } => {
2504                match database.upsert_entry(namespace_id, entry.key(), entry.content_hash(), entry.content_len()) {
2505                    Ok(indexed) => send_event(&events, IndexingEvent::EntryIndexed(indexed)),
2506                    Err(error) => {
2507                        send_event(
2508                            &events,
2509                            IndexingEvent::Error {
2510                                namespace_id,
2511                                message: error.to_string(),
2512                            },
2513                        );
2514                    }
2515                }
2516            }
2517            LiveEvent::ContentReady { hash } => send_event(
2518                &events,
2519                IndexingEvent::Core {
2520                    namespace_id,
2521                    event: CoreIndexingEvent::ContentReady { hash },
2522                },
2523            ),
2524            LiveEvent::PendingContentReady => send_event(
2525                &events,
2526                IndexingEvent::Core {
2527                    namespace_id,
2528                    event: CoreIndexingEvent::PendingContentReady,
2529                },
2530            ),
2531            LiveEvent::NeighborUp(_) => send_event(
2532                &events,
2533                IndexingEvent::Core {
2534                    namespace_id,
2535                    event: CoreIndexingEvent::NeighborUp,
2536                },
2537            ),
2538            LiveEvent::NeighborDown(_) => send_event(
2539                &events,
2540                IndexingEvent::Core {
2541                    namespace_id,
2542                    event: CoreIndexingEvent::NeighborDown,
2543                },
2544            ),
2545            LiveEvent::SyncFinished(_) => send_event(
2546                &events,
2547                IndexingEvent::Core {
2548                    namespace_id,
2549                    event: CoreIndexingEvent::SyncFinished,
2550                },
2551            ),
2552        }
2553    }
2554}
2555
2556fn indexed_entry_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<IndexedEntry> {
2557    let namespace = row.get::<_, String>(0)?;
2558    let namespace_id = NamespaceId::from_str(&namespace).map_err(|error| {
2559        let conversion_error = std::io::Error::new(std::io::ErrorKind::InvalidData, error.to_string());
2560        rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(conversion_error))
2561    })?;
2562    let key = row.get(1)?;
2563    let hash_bytes = row.get::<_, Vec<u8>>(2)?;
2564    let hash_array = <[u8; 32]>::try_from(hash_bytes.as_slice())
2565        .map_err(|error| rusqlite::Error::FromSqlConversionFailure(2, rusqlite::types::Type::Blob, Box::new(error)))?;
2566    let size_value = row.get::<_, i64>(3)?;
2567    let size = u64::try_from(size_value).map_err(|error| {
2568        rusqlite::Error::FromSqlConversionFailure(3, rusqlite::types::Type::Integer, Box::new(error))
2569    })?;
2570    Ok(IndexedEntry {
2571        namespace_id,
2572        key,
2573        hash: Hash::from_bytes(hash_array),
2574        size,
2575    })
2576}
2577
2578fn catalog_record_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<CatalogRecord> {
2579    let catalog_namespace = row.get::<_, String>(0)?;
2580    let catalog_namespace_id = NamespaceId::from_str(&catalog_namespace).map_err(|error| {
2581        let conversion_error = std::io::Error::new(std::io::ErrorKind::InvalidData, error.to_string());
2582        rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(conversion_error))
2583    })?;
2584    let folder_namespace = row.get::<_, String>(1)?;
2585    let folder_namespace_id = NamespaceId::from_str(&folder_namespace).map_err(|error| {
2586        let conversion_error = std::io::Error::new(std::io::ErrorKind::InvalidData, error.to_string());
2587        rusqlite::Error::FromSqlConversionFailure(1, rusqlite::types::Type::Text, Box::new(conversion_error))
2588    })?;
2589    let hash_bytes = row.get::<_, Vec<u8>>(3)?;
2590    let hash_array = <[u8; 32]>::try_from(hash_bytes.as_slice())
2591        .map_err(|error| rusqlite::Error::FromSqlConversionFailure(3, rusqlite::types::Type::Blob, Box::new(error)))?;
2592    let size_value = row.get::<_, i64>(4)?;
2593    let size = u64::try_from(size_value).map_err(|error| {
2594        rusqlite::Error::FromSqlConversionFailure(4, rusqlite::types::Type::Integer, Box::new(error))
2595    })?;
2596    let tags_json = row.get::<_, String>(7)?;
2597    let tags = serde_json::from_str(&tags_json)
2598        .map_err(|error| rusqlite::Error::FromSqlConversionFailure(7, rusqlite::types::Type::Text, Box::new(error)))?;
2599    Ok(CatalogRecord {
2600        catalog_namespace_id,
2601        folder_namespace_id,
2602        key: row.get(2)?,
2603        hash: Hash::from_bytes(hash_array),
2604        size,
2605        folder_name: row.get(5)?,
2606        title: row.get(6)?,
2607        tags,
2608        publisher: row.get(8)?,
2609    })
2610}
2611
2612fn wot_metadata_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<wot::MetadataEntry> {
2613    let hash_bytes = row.get::<_, Vec<u8>>(0)?;
2614    let hash_array = <[u8; 32]>::try_from(hash_bytes.as_slice())
2615        .map_err(|error| rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Blob, Box::new(error)))?;
2616    let sequence_value = row.get::<_, i64>(4)?;
2617    let sequence = u64::try_from(sequence_value).map_err(|error| {
2618        rusqlite::Error::FromSqlConversionFailure(4, rusqlite::types::Type::Integer, Box::new(error))
2619    })?;
2620    let created_at_value = row.get::<_, i64>(5)?;
2621    let created_at = u64::try_from(created_at_value).map_err(|error| {
2622        rusqlite::Error::FromSqlConversionFailure(5, rusqlite::types::Type::Integer, Box::new(error))
2623    })?;
2624    Ok(wot::MetadataEntry {
2625        content: Hash::from_bytes(hash_array),
2626        key: row.get(1)?,
2627        value: row.get(2)?,
2628        author: row.get(3)?,
2629        sequence,
2630        created_at,
2631        signature: row.get(6)?,
2632    })
2633}
2634
2635const SCHEMA_PART1: &str = "PRAGMA journal_mode = WAL;
2636     CREATE TABLE IF NOT EXISTS index_metadata (
2637         key TEXT PRIMARY KEY NOT NULL,
2638         value TEXT NOT NULL
2639     );
2640     CREATE TABLE IF NOT EXISTS indexed_folders (
2641         namespace_id TEXT PRIMARY KEY NOT NULL,
2642         label TEXT NOT NULL,
2643         enabled_at INTEGER NOT NULL
2644     );
2645     CREATE TABLE IF NOT EXISTS indexed_entries (
2646         id INTEGER PRIMARY KEY,
2647         namespace_id TEXT NOT NULL REFERENCES indexed_folders(namespace_id) ON DELETE CASCADE,
2648         entry_key BLOB NOT NULL,
2649         content_hash BLOB NOT NULL CHECK(length(content_hash) = 32),
2650         content_len INTEGER NOT NULL CHECK(content_len >= 0),
2651         updated_at INTEGER NOT NULL,
2652         UNIQUE(namespace_id, entry_key)
2653     );
2654     CREATE INDEX IF NOT EXISTS indexed_entries_namespace
2655         ON indexed_entries(namespace_id);
2656     CREATE VIRTUAL TABLE IF NOT EXISTS indexed_entries_fts USING fts5(
2657         namespace_id UNINDEXED,
2658         entry_key,
2659         title,
2660         tags,
2661         tokenize = 'unicode61'
2662     );
2663     CREATE TABLE IF NOT EXISTS indexed_catalogs (
2664         namespace_id TEXT PRIMARY KEY NOT NULL,
2665         label TEXT NOT NULL,
2666         subscribed_at INTEGER NOT NULL
2667     );
2668     CREATE TABLE IF NOT EXISTS indexed_catalog_entries (
2669         id INTEGER PRIMARY KEY,
2670         catalog_namespace_id TEXT NOT NULL REFERENCES indexed_catalogs(namespace_id) ON DELETE CASCADE,
2671         folder_namespace_id TEXT NOT NULL,
2672         entry_key BLOB NOT NULL,
2673         content_hash BLOB NOT NULL CHECK(length(content_hash) = 32),
2674         content_len INTEGER NOT NULL CHECK(content_len >= 0),
2675         folder_name TEXT NOT NULL,
2676         title TEXT NOT NULL,
2677         tags TEXT NOT NULL,
2678         publisher TEXT NOT NULL,
2679         updated_at INTEGER NOT NULL,
2680         UNIQUE(catalog_namespace_id, folder_namespace_id, entry_key)
2681     );
2682     CREATE INDEX IF NOT EXISTS indexed_catalog_entries_catalog
2683         ON indexed_catalog_entries(catalog_namespace_id);
2684     CREATE VIRTUAL TABLE IF NOT EXISTS indexed_catalog_entries_fts USING fts5(
2685         catalog_namespace_id UNINDEXED,
2686         folder_namespace_id UNINDEXED,
2687         entry_key,
2688         folder_name,
2689         title,
2690         tags,
2691         publisher,
2692         content_hash,
2693         tokenize = 'unicode61'
2694     );
2695     CREATE TABLE IF NOT EXISTS wot_metadata (
2696         id INTEGER PRIMARY KEY,
2697         content_hash BLOB NOT NULL CHECK(length(content_hash) = 32),
2698         metadata_key TEXT NOT NULL,
2699         metadata_value TEXT NOT NULL,
2700         author TEXT NOT NULL,
2701         sequence INTEGER NOT NULL CHECK(sequence > 0),
2702         created_at INTEGER NOT NULL CHECK(created_at >= 0),
2703         signature TEXT NOT NULL,
2704         UNIQUE(content_hash, metadata_key, author, sequence)
2705     );
2706     CREATE INDEX IF NOT EXISTS wot_metadata_content
2707         ON wot_metadata(content_hash);
2708     CREATE VIRTUAL TABLE IF NOT EXISTS wot_metadata_fts USING fts5(
2709         content_hash UNINDEXED,
2710         metadata_key,
2711         metadata_value,
2712         author,
2713         tokenize = 'unicode61'
2714     );";
2715
2716const SCHEMA_PART2: &str = "CREATE TABLE IF NOT EXISTS stable_links (
2717     link TEXT PRIMARY KEY NOT NULL,
2718     kind TEXT NOT NULL,
2719     publisher TEXT,
2720     alias TEXT,
2721     content_hash BLOB CHECK(content_hash IS NULL OR length(content_hash) = 32),
2722     sequence INTEGER NOT NULL CHECK(sequence >= 0),
2723     version TEXT,
2724     payload BLOB NOT NULL,
2725     updated_at INTEGER NOT NULL
2726 );
2727CREATE TABLE IF NOT EXISTS link_mirrors (
2728link TEXT NOT NULL,
2729provider TEXT NOT NULL,
2730ticket TEXT NOT NULL,
2731priority INTEGER NOT NULL DEFAULT 0,
2732updated_at INTEGER NOT NULL,
2733PRIMARY KEY(link, provider)
2734);
2735CREATE TABLE IF NOT EXISTS revoked_links (
2736     manifest BLOB NOT NULL CHECK(length(manifest) = 32),
2737     capability TEXT NOT NULL,
2738     revoked_at INTEGER NOT NULL,
2739     PRIMARY KEY(manifest, capability)
2740);
2741     CREATE TABLE IF NOT EXISTS denylist_rules (
2742         rule_type TEXT NOT NULL,
2743         rule_value BLOB NOT NULL,
2744         namespace_id TEXT,
2745         updated_at INTEGER NOT NULL,
2746         PRIMARY KEY(rule_type, rule_value, namespace_id)
2747     );
2748     CREATE TABLE IF NOT EXISTS filter_lists (
2749         namespace_id TEXT PRIMARY KEY NOT NULL,
2750         sequence INTEGER NOT NULL CHECK(sequence > 0),
2751         publisher TEXT NOT NULL,
2752         payload BLOB NOT NULL,
2753         updated_at INTEGER NOT NULL
2754     );
2755     CREATE TABLE IF NOT EXISTS moderation_records (
2756         content_hash BLOB NOT NULL CHECK(length(content_hash) = 32),
2757         scope TEXT NOT NULL,
2758         sequence INTEGER NOT NULL CHECK(sequence > 0),
2759         payload BLOB NOT NULL,
2760         updated_at INTEGER NOT NULL,
2761         PRIMARY KEY(content_hash, scope)
2762     );
2763     CREATE TABLE IF NOT EXISTS provider_leases (
2764         provider TEXT NOT NULL,
2765         content_hash BLOB NOT NULL CHECK(length(content_hash) = 32),
2766         ticket TEXT NOT NULL,
2767         sequence INTEGER NOT NULL,
2768         issued_at INTEGER NOT NULL,
2769         expires_at INTEGER NOT NULL,
2770         signature TEXT,
2771         PRIMARY KEY(provider, content_hash)
2772     );
2773     CREATE INDEX IF NOT EXISTS idx_provider_leases_hash ON provider_leases(content_hash);";
2774
2775const SCHEMA_PART3: &str = "CREATE TABLE IF NOT EXISTS provider_reputation (
2776     provider TEXT PRIMARY KEY NOT NULL,
2777     total_fetches INTEGER NOT NULL DEFAULT 0,
2778     successful_fetches INTEGER NOT NULL DEFAULT 0,
2779     failed_fetches INTEGER NOT NULL DEFAULT 0,
2780     consecutive_failures INTEGER NOT NULL DEFAULT 0,
2781     last_success_at INTEGER,
2782     last_failure_at INTEGER,
2783     auto_ban_until INTEGER,
2784     auto_ban_count INTEGER NOT NULL DEFAULT 0
2785 );
2786 CREATE TABLE IF NOT EXISTS provider_signal_sequences (
2787     reporter TEXT NOT NULL,
2788     provider TEXT NOT NULL,
2789     last_sequence INTEGER NOT NULL,
2790     PRIMARY KEY(reporter, provider)
2791 );
2792 CREATE TABLE IF NOT EXISTS trust_delegations (
2793     delegator TEXT NOT NULL,
2794     delegate TEXT NOT NULL,
2795     scope BLOB CHECK(scope IS NULL OR length(scope) = 32),
2796     max_depth INTEGER,
2797     sequence INTEGER NOT NULL,
2798     issued_at INTEGER NOT NULL,
2799     expires_at INTEGER NOT NULL,
2800     revoked_at INTEGER,
2801     signature TEXT,
2802     PRIMARY KEY(delegator, delegate, sequence)
2803 );
2804     CREATE TABLE IF NOT EXISTS moderation_records_v2 (
2805         id INTEGER PRIMARY KEY AUTOINCREMENT,
2806         content_hash BLOB NOT NULL CHECK(length(content_hash) = 32),
2807         moderator TEXT NOT NULL,
2808         action TEXT NOT NULL,
2809         scope_json TEXT NOT NULL,
2810         sequence INTEGER NOT NULL,
2811         created_at INTEGER NOT NULL,
2812         reason TEXT NOT NULL,
2813         signature TEXT
2814     );
2815     CREATE TABLE IF NOT EXISTS attestations_v2 (
2816         content_hash BLOB NOT NULL CHECK(length(content_hash) = 32),
2817         issuer TEXT NOT NULL,
2818         kind TEXT NOT NULL,
2819         kind_other TEXT,
2820         value TEXT NOT NULL,
2821         sequence INTEGER NOT NULL,
2822         issued_at INTEGER NOT NULL,
2823         signature TEXT,
2824         PRIMARY KEY(content_hash, issuer, kind)
2825     );
2826      CREATE TABLE IF NOT EXISTS content_reports_v2 (
2827          content_hash BLOB NOT NULL CHECK(length(content_hash) = 32),
2828          reporter TEXT NOT NULL,
2829          reason TEXT NOT NULL,
2830          scope TEXT NOT NULL DEFAULT 'global',
2831          created_at INTEGER NOT NULL,
2832          signature TEXT,
2833          PRIMARY KEY(content_hash, reporter, reason)
2834      );
2835     CREATE TABLE IF NOT EXISTS provider_trust_records_v2 (
2836         id INTEGER PRIMARY KEY AUTOINCREMENT,
2837         provider TEXT NOT NULL,
2838         action TEXT NOT NULL,
2839         scope BLOB CHECK(scope IS NULL OR length(scope) = 32),
2840         issuer TEXT NOT NULL,
2841         sequence INTEGER NOT NULL,
2842         issued_at INTEGER NOT NULL,
2843         expires_at INTEGER,
2844         reason TEXT NOT NULL,
2845         signature TEXT
2846     );
2847     CREATE TABLE IF NOT EXISTS provider_bans_v2 (
2848         provider TEXT NOT NULL,
2849         content_hash BLOB CHECK(content_hash IS NULL OR length(content_hash) = 32),
2850         banned_at INTEGER NOT NULL,
2851         expires_at INTEGER,
2852         reason TEXT NOT NULL,
2853         source TEXT NOT NULL,
2854         PRIMARY KEY(provider, content_hash)
2855     );
2856     CREATE TABLE IF NOT EXISTS provider_trust_signals_v2 (
2857         reporter TEXT NOT NULL,
2858         provider TEXT NOT NULL,
2859         signal_kind TEXT NOT NULL,
2860         content_hash BLOB CHECK(content_hash IS NULL OR length(content_hash) = 32),
2861         sequence INTEGER NOT NULL,
2862         timestamp INTEGER NOT NULL,
2863         signature TEXT,
2864         PRIMARY KEY(reporter, provider, sequence)
2865     );
2866     CREATE INDEX IF NOT EXISTS idx_trust_signals_provider_v2 ON provider_trust_signals_v2(provider);
2867     CREATE INDEX IF NOT EXISTS idx_trust_signals_ts_v2 ON provider_trust_signals_v2(timestamp);
2868     CREATE TABLE IF NOT EXISTS trust_streams (
2869         namespace TEXT PRIMARY KEY,
2870         subscribed_at INTEGER NOT NULL
2871     );";
2872
2873fn initialize_connection(connection: &Connection) -> Result<()> {
2874    connection
2875        .busy_timeout(std::time::Duration::from_secs(5))
2876        .map_err(|error| database_error("failed to configure indexing database", error))?;
2877    connection
2878        .pragma_update(None, "foreign_keys", "ON")
2879        .map_err(|error| database_error("failed to enable indexing foreign keys", error))?;
2880    initialize_schema(connection)?;
2881    connection
2882        .execute(
2883            "INSERT INTO index_metadata(key, value)
2884             VALUES ('schema_version', ?1)
2885             ON CONFLICT(key) DO UPDATE SET value = excluded.value",
2886            [SCHEMA_VERSION],
2887        )
2888        .map_err(|error| database_error("failed to persist indexing schema version", error))?;
2889    Ok(())
2890}
2891
2892fn initialize_schema(connection: &Connection) -> Result<()> {
2893    for part in [SCHEMA_PART1, SCHEMA_PART2, SCHEMA_PART3] {
2894        connection
2895            .execute_batch(part)
2896            .map_err(|error| database_error("failed to initialize indexing database schema", error))?;
2897    }
2898    Ok(())
2899}
2900
2901fn now_seconds() -> i64 {
2902    SystemTime::now()
2903        .duration_since(UNIX_EPOCH)
2904        .map_or(0, |duration| i64::try_from(duration.as_secs()).unwrap_or(i64::MAX))
2905}
2906
2907fn database_error(context: &'static str, error: impl std::fmt::Display) -> SyncwebError {
2908    SyncwebError::operation(context, error)
2909}
2910
2911fn send_event(events: &broadcast::Sender<IndexingEvent>, event: IndexingEvent) {
2912    let _ = events.send(event);
2913}
2914
2915/// Deterministic gossip topic for broadcasting signed moderation reports.
2916pub const REPORT_GOSSIP_TOPIC: &[u8] = b"syncweb/reports/v1";
2917
2918/// A signed content report stored in the indexing database.
2919#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
2920#[non_exhaustive]
2921pub struct ReportRecord {
2922    pub content: Hash,
2923    pub reason: String,
2924    pub created_at: u64,
2925    pub reporter: Option<String>,
2926    pub signature: Option<String>,
2927}
2928
2929impl ReportRecord {
2930    #[must_use]
2931    pub const fn new(content: Hash, reason: String, created_at: u64) -> Self {
2932        Self {
2933            content,
2934            reason,
2935            created_at,
2936            reporter: None,
2937            signature: None,
2938        }
2939    }
2940
2941    /// Sign this report with the node's Ed25519 signing key.
2942    ///
2943    /// Sets `reporter` to the hex-encoded verifying key and `signature` to
2944    /// the hex-encoded Ed25519 signature over the canonical payload.
2945    ///
2946    /// # Errors
2947    ///
2948    /// Returns an error if the canonical signing payload cannot be serialized.
2949    pub fn sign_with(mut self, signing_key: &SigningKey) -> Result<Self> {
2950        let payload = self.signing_payload()?;
2951        let sig = signing_key.sign(&payload);
2952        self.reporter = Some(hex::encode(signing_key.verifying_key().to_bytes()));
2953        self.signature = Some(hex::encode(sig.to_bytes()));
2954        Ok(self)
2955    }
2956
2957    /// Verify the report's signature against the given public key.
2958    ///
2959    /// # Errors
2960    ///
2961    /// Returns an error if the signature is missing, malformed, or invalid.
2962    pub fn verify(&self, public_key: &VerifyingKey) -> Result<()> {
2963        let sig_hex = self
2964            .signature
2965            .as_ref()
2966            .ok_or_else(|| SyncwebError::MissingSignature("report has no signature".into()))?;
2967        let sig_bytes = hex::decode(sig_hex).map_err(|e| SyncwebError::operation("report signature hex decode", e))?;
2968        let sig_len = sig_bytes.len();
2969        let sig: [u8; 64] = sig_bytes.try_into().map_err(|_v| {
2970            SyncwebError::InvalidSignature(format!(
2971                "report signature has wrong length (expected 64, got {sig_len})"
2972            ))
2973        })?;
2974        let signature = Signature::from_bytes(&sig);
2975        public_key
2976            .verify(&self.signing_payload()?, &signature)
2977            .map_err(|e| SyncwebError::InvalidSignature(format!("report signature does not match: {e}")))
2978    }
2979
2980    /// Canonical payload for signing: `(content_hash_bytes, reason, created_at)`.
2981    ///
2982    /// The reporter and signature fields are excluded — the reporter IS the
2983    /// signer, and the signature cannot sign itself.
2984    fn signing_payload(&self) -> Result<Vec<u8>> {
2985        let canonical = (self.content.as_bytes(), &self.reason, self.created_at);
2986        serde_json::to_vec(&canonical).map_err(|e| SyncwebError::operation("canonical report serialization", e))
2987    }
2988}
2989
2990impl SignedGossipMessage for ReportRecord {
2991    fn verify_signature(&self) -> Result<()> {
2992        match &self.reporter {
2993            Some(reporter_hex) => {
2994                let bytes = hex::decode(reporter_hex).map_err(|e| SyncwebError::operation("decode reporter key", e))?;
2995                let key_len = bytes.len();
2996                let key_bytes: [u8; 32] = bytes.try_into().map_err(|_v| {
2997                    SyncwebError::InvalidSignature(format!(
2998                        "reporter key has wrong length (expected 32, got {key_len})"
2999                    ))
3000                })?;
3001                let public_key = VerifyingKey::from_bytes(&key_bytes)
3002                    .map_err(|e| SyncwebError::InvalidSignature(format!("invalid reporter key: {e}")))?;
3003                self.verify(&public_key)
3004            }
3005            None => {
3006                // Unsigned local report — accept without verification
3007                Ok(())
3008            }
3009        }
3010    }
3011}