Skip to main content

huddle_core/storage/
repo.rs

1use rusqlite::params;
2use serde::{Deserialize, Serialize};
3use sha2::{Digest, Sha256};
4
5use crate::error::Result;
6use crate::storage::Db;
7
8// =========================================================================
9// Identity (unchanged — single row, our own Ed25519 + vodozemac account)
10// =========================================================================
11
12#[derive(Debug, Clone)]
13pub struct StoredIdentity {
14    pub ed25519_secret: Vec<u8>,
15    pub created_at: i64,
16}
17
18pub fn save_identity(db: &Db, secret: &[u8], created_at: i64) -> Result<()> {
19    let conn = db.lock().unwrap();
20    conn.execute(
21        "INSERT OR REPLACE INTO identity (id, ed25519_secret, olm_account_data, created_at) VALUES (1, ?1, NULL, ?2)",
22        params![secret, created_at],
23    )?;
24    Ok(())
25}
26
27pub fn load_identity(db: &Db) -> Result<Option<StoredIdentity>> {
28    let conn = db.lock().unwrap();
29    let mut stmt = conn.prepare("SELECT ed25519_secret, created_at FROM identity WHERE id = 1")?;
30    let mut rows = stmt.query_map([], |row| {
31        Ok(StoredIdentity {
32            ed25519_secret: row.get(0)?,
33            created_at: row.get(1)?,
34        })
35    })?;
36    match rows.next() {
37        Some(row) => Ok(Some(row?)),
38        None => Ok(None),
39    }
40}
41
42pub fn get_display_name(db: &Db) -> Result<Option<String>> {
43    let conn = db.lock().unwrap();
44    let mut stmt = conn.prepare("SELECT display_name FROM identity WHERE id = 1")?;
45    let mut rows = stmt.query_map([], |row| row.get::<_, Option<String>>(0))?;
46    Ok(rows.next().and_then(|r| r.ok()).flatten())
47}
48
49pub fn set_display_name(db: &Db, name: Option<&str>) -> Result<()> {
50    let conn = db.lock().unwrap();
51    conn.execute(
52        "UPDATE identity SET display_name = ?1 WHERE id = 1",
53        params![name],
54    )?;
55    Ok(())
56}
57
58/// Look up the most-recently-seen display name for a given fingerprint
59/// across all rooms. huddle 0.7.11: pre-0.7.11 the doc comment claimed
60/// per-room scoping ("in a room (or anywhere if room_id is empty)"),
61/// but the function signature takes no room_id. The implementation has
62/// always been room-agnostic — pick the freshest `last_seen` regardless
63/// of which room set the display name. Doc updated to match reality.
64/// Callers that need per-room scoping should use the room_members table
65/// directly with an explicit `room_id` filter.
66pub fn lookup_display_name(db: &Db, fingerprint: &str) -> Result<Option<String>> {
67    let conn = db.lock().unwrap();
68    let mut stmt = conn.prepare(
69        "SELECT display_name FROM room_members
70         WHERE fingerprint = ?1 AND display_name IS NOT NULL
71         ORDER BY last_seen DESC LIMIT 1",
72    )?;
73    let mut rows = stmt.query_map(params![fingerprint], |row| row.get::<_, Option<String>>(0))?;
74    Ok(rows.next().and_then(|r| r.ok()).flatten())
75}
76
77pub fn set_member_display_name(
78    db: &Db,
79    room_id: &str,
80    fingerprint: &str,
81    name: Option<&str>,
82) -> Result<()> {
83    let conn = db.lock().unwrap();
84    conn.execute(
85        "UPDATE room_members SET display_name = ?1 WHERE room_id = ?2 AND fingerprint = ?3",
86        params![name, room_id, fingerprint],
87    )?;
88    Ok(())
89}
90
91// =========================================================================
92// Rooms
93// =========================================================================
94
95/// huddle 0.7: explicit room kind. `Direct` = 1-1 DM (encrypted, no name,
96/// no member-list chrome, no kick/grant). `Group` = N-way room (full
97/// moderation, named, optionally encrypted). Persisted on `rooms.kind` and
98/// echoed on `RoomAnnouncement.kind` (with `#[serde(default)]` so pre-0.7
99/// peers' announcements deserialize as `Group`).
100#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
101#[serde(rename_all = "snake_case")]
102pub enum RoomKind {
103    Direct,
104    #[default]
105    Group,
106}
107
108impl RoomKind {
109    pub fn as_str(&self) -> &'static str {
110        match self {
111            RoomKind::Direct => "direct",
112            RoomKind::Group => "group",
113        }
114    }
115
116    pub fn from_str(s: &str) -> Self {
117        match s {
118            "direct" => RoomKind::Direct,
119            _ => RoomKind::Group,
120        }
121    }
122}
123
124#[derive(Debug, Clone)]
125pub struct StoredRoom {
126    pub id: String,
127    pub name: String,
128    pub creator_fingerprint: String,
129    pub encrypted: bool,
130    pub passphrase_salt: Option<Vec<u8>>,
131    pub created_at: i64,
132    pub last_active: Option<i64>,
133    /// huddle 0.7: explicit room kind. Defaults to `Group` for back-fill
134    /// safety on pre-0.7 databases (the column has `DEFAULT 'group'`).
135    pub kind: RoomKind,
136}
137
138/// Derive a stable room ID from creator fingerprint, name, and creation time.
139pub fn derive_room_id(creator_fp: &str, name: &str, created_at: i64) -> String {
140    let mut hasher = Sha256::new();
141    hasher.update(creator_fp.as_bytes());
142    hasher.update(b"\0");
143    hasher.update(name.as_bytes());
144    hasher.update(b"\0");
145    hasher.update(created_at.to_be_bytes());
146    hex::encode(&hasher.finalize()[..16])
147}
148
149/// Insert a room, or update it in place on id collision. Uses a real
150/// UPSERT (not `INSERT OR REPLACE`) so no implicit DELETE fires — the
151/// `ON DELETE CASCADE` on room_megolm_sessions / room_members /
152/// room_messages / room_attachments must never be triggered here.
153/// `created_at`, `creator_fingerprint`, and `encrypted` are immutable
154/// once set and are deliberately not updated on conflict.
155pub fn insert_room(db: &Db, room: &StoredRoom) -> Result<()> {
156    let conn = db.lock().unwrap();
157    conn.execute(
158        "INSERT INTO rooms (id, name, creator_fingerprint, encrypted, passphrase_salt, created_at, last_active, kind)
159         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
160         ON CONFLICT(id) DO UPDATE SET
161            name = excluded.name,
162            passphrase_salt = excluded.passphrase_salt,
163            last_active = excluded.last_active",
164        params![
165            room.id,
166            room.name,
167            room.creator_fingerprint,
168            room.encrypted as i64,
169            room.passphrase_salt,
170            room.created_at,
171            room.last_active,
172            room.kind.as_str(),
173        ],
174    )?;
175    Ok(())
176}
177
178pub fn get_room(db: &Db, room_id: &str) -> Result<Option<StoredRoom>> {
179    let conn = db.lock().unwrap();
180    let mut stmt = conn.prepare(
181        "SELECT id, name, creator_fingerprint, encrypted, passphrase_salt, created_at, last_active, kind
182         FROM rooms WHERE id = ?1",
183    )?;
184    let mut rows = stmt.query_map(params![room_id], |row| {
185        Ok(StoredRoom {
186            id: row.get(0)?,
187            name: row.get(1)?,
188            creator_fingerprint: row.get(2)?,
189            encrypted: row.get::<_, i64>(3)? != 0,
190            passphrase_salt: row.get(4)?,
191            created_at: row.get(5)?,
192            last_active: row.get(6)?,
193            kind: RoomKind::from_str(&row.get::<_, String>(7).unwrap_or_else(|_| "group".into())),
194        })
195    })?;
196    match rows.next() {
197        Some(row) => Ok(Some(row?)),
198        None => Ok(None),
199    }
200}
201
202pub fn list_rooms(db: &Db) -> Result<Vec<StoredRoom>> {
203    let conn = db.lock().unwrap();
204    let mut stmt = conn.prepare(
205        "SELECT id, name, creator_fingerprint, encrypted, passphrase_salt, created_at, last_active, kind
206         FROM rooms ORDER BY last_active DESC NULLS LAST, created_at DESC",
207    )?;
208    let rows = stmt.query_map([], |row| {
209        Ok(StoredRoom {
210            id: row.get(0)?,
211            name: row.get(1)?,
212            creator_fingerprint: row.get(2)?,
213            encrypted: row.get::<_, i64>(3)? != 0,
214            passphrase_salt: row.get(4)?,
215            created_at: row.get(5)?,
216            last_active: row.get(6)?,
217            kind: RoomKind::from_str(&row.get::<_, String>(7).unwrap_or_else(|_| "group".into())),
218        })
219    })?;
220    Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
221}
222
223/// huddle 0.7: find an existing `RoomKind::Direct` room between `our_fp`
224/// and `partner_fp`. Used by `AppHandle::start_direct` to short-circuit
225/// when the DM already exists locally, so the call is idempotent across
226/// reopens.
227pub fn find_dm_with(db: &Db, our_fp: &str, partner_fp: &str) -> Result<Option<StoredRoom>> {
228    let conn = db.lock().unwrap();
229    let mut stmt = conn.prepare(
230        "SELECT r.id, r.name, r.creator_fingerprint, r.encrypted, r.passphrase_salt,
231                r.created_at, r.last_active, r.kind
232         FROM rooms r
233         WHERE r.kind = 'direct'
234           AND EXISTS (SELECT 1 FROM room_members m
235                       WHERE m.room_id = r.id AND m.fingerprint = ?1)
236           AND EXISTS (SELECT 1 FROM room_members m
237                       WHERE m.room_id = r.id AND m.fingerprint = ?2)
238         LIMIT 1",
239    )?;
240    let mut rows = stmt.query_map(params![our_fp, partner_fp], |row| {
241        Ok(StoredRoom {
242            id: row.get(0)?,
243            name: row.get(1)?,
244            creator_fingerprint: row.get(2)?,
245            encrypted: row.get::<_, i64>(3)? != 0,
246            passphrase_salt: row.get(4)?,
247            created_at: row.get(5)?,
248            last_active: row.get(6)?,
249            kind: RoomKind::from_str(&row.get::<_, String>(7).unwrap_or_else(|_| "group".into())),
250        })
251    })?;
252    match rows.next() {
253        Some(row) => Ok(Some(row?)),
254        None => Ok(None),
255    }
256}
257
258pub fn update_room_last_active(db: &Db, room_id: &str, ts: i64) -> Result<()> {
259    let conn = db.lock().unwrap();
260    conn.execute(
261        "UPDATE rooms SET last_active = ?1 WHERE id = ?2",
262        params![ts, room_id],
263    )?;
264    Ok(())
265}
266
267pub fn set_room_muted(db: &Db, room_id: &str, muted: bool) -> Result<()> {
268    let conn = db.lock().unwrap();
269    conn.execute(
270        "UPDATE rooms SET muted = ?1 WHERE id = ?2",
271        params![muted as i64, room_id],
272    )?;
273    Ok(())
274}
275
276pub fn is_room_muted(db: &Db, room_id: &str) -> Result<bool> {
277    let conn = db.lock().unwrap();
278    let mut stmt = conn.prepare("SELECT muted FROM rooms WHERE id = ?1")?;
279    let mut rows = stmt.query_map(params![room_id], |row| row.get::<_, i64>(0))?;
280    Ok(rows.next().map(|r| r.unwrap_or(0) != 0).unwrap_or(false))
281}
282
283// =========================================================================
284// Room members
285// =========================================================================
286
287#[derive(Debug, Clone)]
288pub struct StoredRoomMember {
289    pub room_id: String,
290    pub peer_id: String,
291    pub fingerprint: String,
292    pub last_seen: Option<i64>,
293    pub verified: bool,
294    /// Base64-encoded Ed25519 public key. Populated from the member's
295    /// `MemberAnnounce.sender_ed25519_pubkey` on first contact; required
296    /// to verify `SignedRoomMessage` envelopes from this fingerprint.
297    /// `None` for pre-Phase-0 rows or for peers running older builds.
298    pub ed25519_pubkey: Option<String>,
299    /// Phase B: `"owner"` or `"member"`. Set on first insert
300    /// (`start_room` sets the creator to `"owner"`); never overwritten
301    /// by re-announcements so OwnerGrant is the only way to promote
302    /// after the fact.
303    pub role: String,
304}
305
306/// Insert a member, or update in place on (room_id, fingerprint) collision.
307/// `verified` and `role` are set only on first insert and intentionally
308/// absent from the conflict-update clause: a re-announcement can never
309/// silently reset a member's verified flag or demote an owner to member.
310/// A genuinely new fingerprint is a new (unverified, member) row.
311/// `peer_id` and `ed25519_pubkey` are only overwritten when the incoming
312/// value is non-null/non-empty — a re-announce that drops the pubkey
313/// field must not erase the one we already learned.
314pub fn upsert_room_member(db: &Db, member: &StoredRoomMember) -> Result<()> {
315    let conn = db.lock().unwrap();
316    conn.execute(
317        "INSERT INTO room_members (room_id, peer_id, fingerprint, last_seen, verified, ed25519_pubkey, role)
318         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
319         ON CONFLICT(room_id, fingerprint) DO UPDATE SET
320            last_seen = excluded.last_seen,
321            peer_id = CASE
322                WHEN excluded.peer_id != '' THEN excluded.peer_id
323                ELSE room_members.peer_id
324            END,
325            ed25519_pubkey = COALESCE(excluded.ed25519_pubkey, room_members.ed25519_pubkey)",
326        params![
327            member.room_id,
328            member.peer_id,
329            member.fingerprint,
330            member.last_seen,
331            member.verified as i64,
332            member.ed25519_pubkey,
333            member.role,
334        ],
335    )?;
336    Ok(())
337}
338
339/// huddle 0.7.1: find an Ed25519 pubkey for a fingerprint across all
340/// rooms we've ever seen the peer in. A peer's identity key is global
341/// (not per-room), so any non-null row works. Used by DM E2E to derive
342/// the ECDH room key without re-asking the network.
343pub fn lookup_peer_ed25519_pubkey(db: &Db, fingerprint: &str) -> Result<Option<String>> {
344    let conn = db.lock().unwrap();
345    let mut stmt = conn.prepare(
346        "SELECT ed25519_pubkey FROM room_members
347         WHERE fingerprint = ?1 AND ed25519_pubkey IS NOT NULL
348         LIMIT 1",
349    )?;
350    let mut rows = stmt.query_map(params![fingerprint], |row| row.get::<_, Option<String>>(0))?;
351    Ok(rows.next().and_then(|r| r.ok()).flatten())
352}
353
354pub fn list_room_members(db: &Db, room_id: &str) -> Result<Vec<StoredRoomMember>> {
355    let conn = db.lock().unwrap();
356    let mut stmt = conn.prepare(
357        "SELECT room_id, peer_id, fingerprint, last_seen, verified, ed25519_pubkey, role FROM room_members WHERE room_id = ?1",
358    )?;
359    let rows = stmt.query_map(params![room_id], |row| {
360        Ok(StoredRoomMember {
361            room_id: row.get(0)?,
362            peer_id: row.get(1)?,
363            fingerprint: row.get(2)?,
364            last_seen: row.get(3)?,
365            verified: row.get::<_, i64>(4).unwrap_or(0) != 0,
366            ed25519_pubkey: row.get(5).ok().flatten(),
367            role: row.get(6).unwrap_or_else(|_| "member".to_string()),
368        })
369    })?;
370    Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
371}
372
373/// Phase B: promote / demote a member's role. Used by the `OwnerGrant`
374/// handler. Callers must verify the grant signature came from an owner
375/// before invoking — the repo function trusts its inputs.
376pub fn set_member_role(db: &Db, room_id: &str, fingerprint: &str, role: &str) -> Result<()> {
377    let conn = db.lock().unwrap();
378    conn.execute(
379        "UPDATE room_members SET role = ?1 WHERE room_id = ?2 AND fingerprint = ?3",
380        params![role, room_id, fingerprint],
381    )?;
382    Ok(())
383}
384
385/// Phase B: list owners of a room — fingerprints with role = 'owner'.
386/// Used for `RoomAnnouncement.owner_fingerprints` and for verifying
387/// that an incoming `OwnerGrant` / `BanMember` came from a current owner.
388pub fn list_room_owners(db: &Db, room_id: &str) -> Result<Vec<String>> {
389    let conn = db.lock().unwrap();
390    let mut stmt = conn.prepare(
391        "SELECT fingerprint FROM room_members WHERE room_id = ?1 AND role = 'owner'",
392    )?;
393    let rows = stmt.query_map(params![room_id], |row| row.get::<_, String>(0))?;
394    Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
395}
396
397/// Phase B: persistent room-level ban. Banned members are ignored on
398/// receive (MemberAnnounce dropped, messages skipped) and excluded from
399/// future session-key wraps. Idempotent.
400pub fn add_room_ban(
401    db: &Db,
402    room_id: &str,
403    banned_fingerprint: &str,
404    banned_by_fingerprint: &str,
405    signature_b64: &str,
406    banned_at: i64,
407) -> Result<()> {
408    let conn = db.lock().unwrap();
409    conn.execute(
410        "INSERT INTO room_bans (room_id, banned_fingerprint, banned_by_fingerprint, signature_b64, banned_at)
411         VALUES (?1, ?2, ?3, ?4, ?5)
412         ON CONFLICT(room_id, banned_fingerprint) DO UPDATE SET
413            banned_by_fingerprint = excluded.banned_by_fingerprint,
414            signature_b64 = excluded.signature_b64,
415            banned_at = excluded.banned_at",
416        params![
417            room_id,
418            banned_fingerprint,
419            banned_by_fingerprint,
420            signature_b64,
421            banned_at,
422        ],
423    )?;
424    Ok(())
425}
426
427pub fn is_member_banned(db: &Db, room_id: &str, fingerprint: &str) -> Result<bool> {
428    let conn = db.lock().unwrap();
429    let count: i64 = conn
430        .query_row(
431            "SELECT COUNT(*) FROM room_bans WHERE room_id = ?1 AND banned_fingerprint = ?2",
432            params![room_id, fingerprint],
433            |r| r.get(0),
434        )
435        .unwrap_or(0);
436    Ok(count > 0)
437}
438
439/// List fingerprints currently banned from a room, newest first. Used
440/// by the `^B` in-room bans view (owners-only) so they can audit who's
441/// been kicked.
442pub fn list_room_bans(db: &Db, room_id: &str) -> Result<Vec<String>> {
443    let conn = db.lock().unwrap();
444    let mut stmt = conn.prepare(
445        "SELECT banned_fingerprint FROM room_bans WHERE room_id = ?1 ORDER BY banned_at DESC",
446    )?;
447    let rows = stmt.query_map(params![room_id], |row| row.get::<_, String>(0))?;
448    Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
449}
450
451/// Look up the persisted Ed25519 pubkey (base64) for a member by their
452/// fingerprint. Defense-in-depth check during `SignedRoomMessage`
453/// verification: when a signed envelope arrives, we re-derive the
454/// fingerprint from the envelope's claimed pubkey AND, if we already
455/// know a pubkey for this fingerprint, refuse to accept a different
456/// one. Mismatch ⇒ identity drift / TOFU violation ⇒ drop the message.
457///
458/// Returns `Ok(None)` if the member exists but pre-dates Phase 0 and
459/// hasn't re-announced with their pubkey yet — caller falls back to
460/// TOFU: accept the envelope's claimed pubkey on first contact and
461/// persist it via `upsert_room_member`.
462pub fn get_member_ed25519_pubkey(
463    db: &Db,
464    room_id: &str,
465    fingerprint: &str,
466) -> Result<Option<String>> {
467    let conn = db.lock().unwrap();
468    let mut stmt = conn.prepare(
469        "SELECT ed25519_pubkey FROM room_members WHERE room_id = ?1 AND fingerprint = ?2",
470    )?;
471    let row = stmt
472        .query_row(params![room_id, fingerprint], |row| {
473            row.get::<_, Option<String>>(0)
474        })
475        .ok();
476    Ok(row.flatten())
477}
478
479pub fn remove_room_member(db: &Db, room_id: &str, fingerprint: &str) -> Result<()> {
480    let conn = db.lock().unwrap();
481    conn.execute(
482        "DELETE FROM room_members WHERE room_id = ?1 AND fingerprint = ?2",
483        params![room_id, fingerprint],
484    )?;
485    Ok(())
486}
487
488/// Mark a member as verified-by-fingerprint. Matches by fingerprint
489/// rather than peer_id so a re-join (new peer_id) keeps verification.
490pub fn set_member_verified(
491    db: &Db,
492    room_id: &str,
493    fingerprint: &str,
494    verified: bool,
495) -> Result<()> {
496    let conn = db.lock().unwrap();
497    conn.execute(
498        "UPDATE room_members SET verified = ?1 WHERE room_id = ?2 AND fingerprint = ?3",
499        params![verified as i64, room_id, fingerprint],
500    )?;
501    Ok(())
502}
503
504pub fn list_verified_fingerprints(db: &Db, room_id: &str) -> Result<Vec<String>> {
505    let conn = db.lock().unwrap();
506    let mut stmt = conn.prepare(
507        "SELECT DISTINCT fingerprint FROM room_members WHERE room_id = ?1 AND verified = 1",
508    )?;
509    let rows = stmt.query_map(params![room_id], |row| row.get::<_, String>(0))?;
510    Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
511}
512
513// =========================================================================
514// Megolm sessions
515// =========================================================================
516
517#[derive(Debug, Clone)]
518pub struct StoredMegolmSession {
519    pub room_id: String,
520    pub sender_fingerprint: String,
521    pub session_id: String,
522    pub session_data: Vec<u8>,
523    pub is_outbound: bool,
524    pub created_at: i64,
525}
526
527pub fn save_megolm_session(db: &Db, session: &StoredMegolmSession) -> Result<()> {
528    let conn = db.lock().unwrap();
529    conn.execute(
530        "INSERT OR REPLACE INTO room_megolm_sessions
531            (room_id, sender_fingerprint, session_id, session_data, is_outbound, created_at)
532         VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
533        params![
534            session.room_id,
535            session.sender_fingerprint,
536            session.session_id,
537            session.session_data,
538            session.is_outbound as i64,
539            session.created_at,
540        ],
541    )?;
542    Ok(())
543}
544
545pub fn load_megolm_sessions_for_room(
546    db: &Db,
547    room_id: &str,
548) -> Result<Vec<StoredMegolmSession>> {
549    let conn = db.lock().unwrap();
550    let mut stmt = conn.prepare(
551        "SELECT room_id, sender_fingerprint, session_id, session_data, is_outbound, created_at
552         FROM room_megolm_sessions WHERE room_id = ?1",
553    )?;
554    let rows = stmt.query_map(params![room_id], |row| {
555        Ok(StoredMegolmSession {
556            room_id: row.get(0)?,
557            sender_fingerprint: row.get(1)?,
558            session_id: row.get(2)?,
559            session_data: row.get(3)?,
560            is_outbound: row.get::<_, i64>(4)? != 0,
561            created_at: row.get(5)?,
562        })
563    })?;
564    Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
565}
566
567// =========================================================================
568// Room messages
569// =========================================================================
570
571#[derive(Debug, Clone)]
572pub struct StoredRoomMessage {
573    pub id: i64,
574    pub room_id: String,
575    pub sender_fingerprint: String,
576    pub direction: String,
577    pub body: String,
578    pub sent_at: i64,
579}
580
581pub fn insert_room_message(
582    db: &Db,
583    room_id: &str,
584    sender_fingerprint: &str,
585    direction: &str,
586    body: &str,
587    sent_at: i64,
588) -> Result<i64> {
589    let conn = db.lock().unwrap();
590    conn.execute(
591        "INSERT INTO room_messages (room_id, sender_fingerprint, direction, body, sent_at)
592         VALUES (?1, ?2, ?3, ?4, ?5)",
593        params![room_id, sender_fingerprint, direction, body, sent_at],
594    )?;
595    Ok(conn.last_insert_rowid())
596}
597
598/// LIKE-based message search within a room. Case-insensitive. The query
599/// is treated as a literal substring — `%`, `_`, and `\` are escaped so
600/// they cannot act as LIKE wildcards.
601pub fn search_room_messages(
602    db: &Db,
603    room_id: &str,
604    query: &str,
605    limit: i64,
606) -> Result<Vec<StoredRoomMessage>> {
607    // Escape `\` first so the escapes added for `%` / `_` aren't doubled.
608    let escaped = query
609        .replace('\\', "\\\\")
610        .replace('%', "\\%")
611        .replace('_', "\\_");
612    let pattern = format!("%{}%", escaped);
613    let conn = db.lock().unwrap();
614    let mut stmt = conn.prepare(
615        "SELECT id, room_id, sender_fingerprint, direction, body, sent_at
616         FROM room_messages
617         WHERE room_id = ?1 AND body LIKE ?2 ESCAPE '\\' COLLATE NOCASE
618         ORDER BY sent_at DESC LIMIT ?3",
619    )?;
620    let rows = stmt.query_map(params![room_id, pattern, limit], |row| {
621        Ok(StoredRoomMessage {
622            id: row.get(0)?,
623            room_id: row.get(1)?,
624            sender_fingerprint: row.get(2)?,
625            direction: row.get(3)?,
626            body: row.get(4)?,
627            sent_at: row.get(5)?,
628        })
629    })?;
630    Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
631}
632
633pub fn get_room_messages(db: &Db, room_id: &str, limit: i64) -> Result<Vec<StoredRoomMessage>> {
634    let conn = db.lock().unwrap();
635    let mut stmt = conn.prepare(
636        "SELECT id, room_id, sender_fingerprint, direction, body, sent_at
637         FROM room_messages WHERE room_id = ?1 ORDER BY sent_at DESC LIMIT ?2",
638    )?;
639    let rows = stmt.query_map(params![room_id, limit], |row| {
640        Ok(StoredRoomMessage {
641            id: row.get(0)?,
642            room_id: row.get(1)?,
643            sender_fingerprint: row.get(2)?,
644            direction: row.get(3)?,
645            body: row.get(4)?,
646            sent_at: row.get(5)?,
647        })
648    })?;
649    let mut msgs: Vec<StoredRoomMessage> = rows.collect::<std::result::Result<Vec<_>, _>>()?;
650    msgs.reverse();
651    Ok(msgs)
652}
653
654// =========================================================================
655// Known peers (manually dialed addresses we want to auto-reconnect to)
656// =========================================================================
657
658#[derive(Debug, Clone)]
659pub struct KnownPeer {
660    pub address: String,
661    pub label: Option<String>,
662    pub last_connected_at: Option<i64>,
663    pub last_attempt_at: Option<i64>,
664    pub created_at: i64,
665    /// Phase A: the peer's Ed25519 fingerprint, learned from Identify
666    /// after the first successful connection. `None` for rows from
667    /// pre-Phase-A and for peers that haven't been reached yet.
668    pub fingerprint: Option<String>,
669    /// Phase A: `true` once the user explicitly trusted this peer
670    /// ("Trust + Accept" on the inbound-dial modal, or any successful
671    /// user-initiated outbound dial). Trusted peers bypass the inbound
672    /// prompt on reconnect.
673    pub trusted: bool,
674}
675
676pub fn upsert_known_peer(db: &Db, peer: &KnownPeer) -> Result<()> {
677    let conn = db.lock().unwrap();
678    conn.execute(
679        "INSERT INTO known_peers (address, label, last_connected_at, last_attempt_at, created_at, fingerprint, trusted)
680         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
681         ON CONFLICT(address) DO UPDATE SET
682           label = COALESCE(excluded.label, known_peers.label),
683           last_connected_at = COALESCE(excluded.last_connected_at, known_peers.last_connected_at),
684           last_attempt_at = COALESCE(excluded.last_attempt_at, known_peers.last_attempt_at),
685           fingerprint = COALESCE(excluded.fingerprint, known_peers.fingerprint),
686           -- trusted is sticky-once-true: a fresh upsert with trusted=false
687           -- (the default on auto-reconnect) must not demote a previously
688           -- trusted row.
689           trusted = CASE
690             WHEN excluded.trusted = 1 THEN 1
691             ELSE known_peers.trusted
692           END",
693        params![
694            peer.address,
695            peer.label,
696            peer.last_connected_at,
697            peer.last_attempt_at,
698            peer.created_at,
699            peer.fingerprint,
700            peer.trusted as i64,
701        ],
702    )?;
703    Ok(())
704}
705
706pub fn list_known_peers(db: &Db) -> Result<Vec<KnownPeer>> {
707    let conn = db.lock().unwrap();
708    let mut stmt = conn.prepare(
709        "SELECT address, label, last_connected_at, last_attempt_at, created_at, fingerprint, trusted
710         FROM known_peers ORDER BY COALESCE(last_connected_at, 0) DESC, created_at DESC",
711    )?;
712    let rows = stmt.query_map([], |row| {
713        Ok(KnownPeer {
714            address: row.get(0)?,
715            label: row.get(1)?,
716            last_connected_at: row.get(2)?,
717            last_attempt_at: row.get(3)?,
718            created_at: row.get(4)?,
719            fingerprint: row.get(5).ok().flatten(),
720            trusted: row.get::<_, i64>(6).unwrap_or(0) != 0,
721        })
722    })?;
723    Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
724}
725
726pub fn forget_known_peer(db: &Db, address: &str) -> Result<()> {
727    let conn = db.lock().unwrap();
728    conn.execute("DELETE FROM known_peers WHERE address = ?1", params![address])?;
729    Ok(())
730}
731
732/// Phase A: look up whether we've already trusted a peer by fingerprint.
733/// Used by the network task when an inbound connection's Identify lands —
734/// trusted fingerprints bypass the user-prompt modal.
735pub fn is_fingerprint_trusted(db: &Db, fingerprint: &str) -> Result<bool> {
736    let conn = db.lock().unwrap();
737    let count: i64 = conn
738        .query_row(
739            "SELECT COUNT(*) FROM known_peers WHERE fingerprint = ?1 AND trusted = 1",
740            params![fingerprint],
741            |r| r.get(0),
742        )
743        .unwrap_or(0);
744    Ok(count > 0)
745}
746
747// =========================================================================
748// huddle 0.7.7: pending friend requests
749// =========================================================================
750
751/// Pending inbound dial that the user hasn't yet acted on. Persisted so a
752/// brief absence (or app restart) doesn't lose the request. Auto-rejected
753/// when older than [`PENDING_FRIEND_REQUEST_TTL_SECS`] (3 days).
754#[derive(Debug, Clone)]
755pub struct PendingFriendRequest {
756    pub fingerprint: String,
757    pub address: String,
758    pub peer_id: String,
759    pub received_at: i64,
760}
761
762/// 3 days, in seconds. Anything older is auto-rejected by the startup
763/// sweep — long enough to cover a weekend away from the keyboard, short
764/// enough that an actively-malicious peer's pending row doesn't linger
765/// indefinitely.
766pub const PENDING_FRIEND_REQUEST_TTL_SECS: i64 = 3 * 24 * 60 * 60;
767
768pub fn upsert_pending_friend_request(db: &Db, req: &PendingFriendRequest) -> Result<()> {
769    let conn = db.lock().unwrap();
770    conn.execute(
771        "INSERT INTO pending_friend_requests (fingerprint, address, peer_id, received_at)
772         VALUES (?1, ?2, ?3, ?4)
773         ON CONFLICT(fingerprint, address) DO UPDATE SET
774           peer_id = excluded.peer_id,
775           received_at = excluded.received_at",
776        params![req.fingerprint, req.address, req.peer_id, req.received_at],
777    )?;
778    Ok(())
779}
780
781pub fn list_pending_friend_requests(db: &Db) -> Result<Vec<PendingFriendRequest>> {
782    let conn = db.lock().unwrap();
783    let mut stmt = conn.prepare(
784        "SELECT fingerprint, address, peer_id, received_at
785         FROM pending_friend_requests
786         ORDER BY received_at DESC",
787    )?;
788    let rows = stmt.query_map([], |row| {
789        Ok(PendingFriendRequest {
790            fingerprint: row.get(0)?,
791            address: row.get(1)?,
792            peer_id: row.get(2)?,
793            received_at: row.get(3)?,
794        })
795    })?;
796    Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
797}
798
799/// Delete every row matching `fingerprint`. Both Accept and Reject paths
800/// clear all of the peer's pending rows at once — accepting one address
801/// implicitly accepts the peer, and we don't want a second row for the
802/// same fp to re-prompt later.
803pub fn delete_pending_friend_requests_for_fp(db: &Db, fingerprint: &str) -> Result<()> {
804    let conn = db.lock().unwrap();
805    conn.execute(
806        "DELETE FROM pending_friend_requests WHERE fingerprint = ?1",
807        params![fingerprint],
808    )?;
809    Ok(())
810}
811
812/// Drop rows older than the TTL. Called once on startup; returns the
813/// number of rows pruned so callers can surface a status hint if any
814/// pending requests aged out while the user was offline.
815pub fn cleanup_expired_pending_friend_requests(db: &Db, now: i64) -> Result<usize> {
816    // huddle 0.7.11: saturating_sub guards against `now < TTL` (occurs
817    // in tests with hand-crafted timestamps and on freshly-reset clocks)
818    // where a plain `now - TTL` would go negative and match every row.
819    let cutoff = now.saturating_sub(PENDING_FRIEND_REQUEST_TTL_SECS);
820    let conn = db.lock().unwrap();
821    let removed = conn.execute(
822        "DELETE FROM pending_friend_requests WHERE received_at < ?1",
823        params![cutoff],
824    )?;
825    Ok(removed)
826}
827
828/// Phase A: persistent blocklist. A fingerprint here means we explicitly
829/// rejected an inbound dial from this peer — every subsequent connection
830/// attempt is auto-disconnected without raising the modal.
831pub fn block_peer(db: &Db, fingerprint: &str, now: i64) -> Result<()> {
832    let conn = db.lock().unwrap();
833    conn.execute(
834        "INSERT INTO blocked_peers (fingerprint, blocked_at) VALUES (?1, ?2)
835         ON CONFLICT(fingerprint) DO UPDATE SET blocked_at = excluded.blocked_at",
836        params![fingerprint, now],
837    )?;
838    Ok(())
839}
840
841/// Phase E: simple app-wide KV. Used for the global
842/// 'verified_only_inbound' toggle and any other future flags.
843pub fn get_setting(db: &Db, key: &str) -> Result<Option<String>> {
844    let conn = db.lock().unwrap();
845    let mut stmt = conn.prepare("SELECT value FROM app_settings WHERE key = ?1")?;
846    let row = stmt
847        .query_row(params![key], |r| r.get::<_, String>(0))
848        .ok();
849    Ok(row)
850}
851
852pub fn set_setting(db: &Db, key: &str, value: &str) -> Result<()> {
853    let conn = db.lock().unwrap();
854    conn.execute(
855        "INSERT INTO app_settings (key, value) VALUES (?1, ?2)
856         ON CONFLICT(key) DO UPDATE SET value = excluded.value",
857        params![key, value],
858    )?;
859    Ok(())
860}
861
862/// Phase E: per-room "only verified members may join" toggle.
863pub fn get_room_verified_only(db: &Db, room_id: &str) -> Result<bool> {
864    let conn = db.lock().unwrap();
865    let v: i64 = conn
866        .query_row(
867            "SELECT verified_only_join FROM rooms WHERE id = ?1",
868            params![room_id],
869            |r| r.get(0),
870        )
871        .unwrap_or(0);
872    Ok(v != 0)
873}
874
875pub fn set_room_verified_only(db: &Db, room_id: &str, on: bool) -> Result<()> {
876    let conn = db.lock().unwrap();
877    conn.execute(
878        "UPDATE rooms SET verified_only_join = ?1 WHERE id = ?2",
879        params![on as i64, room_id],
880    )?;
881    Ok(())
882}
883
884/// Phase G: mark a fingerprint as globally SAS-verified. Idempotent;
885/// re-verifying just refreshes `verified_at`. Used by both sides of
886/// an SAS exchange on receiving the partner's matching `SasConfirm`.
887pub fn add_verified_peer(db: &Db, fingerprint: &str, verified_at: i64) -> Result<()> {
888    let conn = db.lock().unwrap();
889    conn.execute(
890        "INSERT INTO verified_peers (fingerprint, verified_at) VALUES (?1, ?2)
891         ON CONFLICT(fingerprint) DO UPDATE SET verified_at = excluded.verified_at",
892        params![fingerprint, verified_at],
893    )?;
894    Ok(())
895}
896
897/// Phase G + E: is this fingerprint globally SAS-verified? Used by
898/// Phase E's global inbound filter and by the per-room "verified_only"
899/// enforcement.
900pub fn is_globally_verified(db: &Db, fingerprint: &str) -> Result<bool> {
901    let conn = db.lock().unwrap();
902    let count: i64 = conn
903        .query_row(
904            "SELECT COUNT(*) FROM verified_peers WHERE fingerprint = ?1",
905            params![fingerprint],
906            |r| r.get(0),
907        )
908        .unwrap_or(0);
909    Ok(count > 0)
910}
911
912/// huddle 0.7: list every globally SAS-verified fingerprint. Used by
913/// the People pane to render the "Verified" sub-list.
914pub fn list_verified_peers(db: &Db) -> Result<Vec<String>> {
915    let conn = db.lock().unwrap();
916    let mut stmt =
917        conn.prepare("SELECT fingerprint FROM verified_peers ORDER BY verified_at DESC")?;
918    let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
919    Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
920}
921
922/// Phase H: has the first-launch onboarding card been dismissed?
923pub fn is_onboarding_seen(db: &Db) -> Result<bool> {
924    let conn = db.lock().unwrap();
925    let v: i64 = conn
926        .query_row(
927            "SELECT onboarding_seen FROM identity WHERE id = 1",
928            [],
929            |r| r.get(0),
930        )
931        .unwrap_or(0);
932    Ok(v != 0)
933}
934
935pub fn mark_onboarding_seen(db: &Db) -> Result<()> {
936    let conn = db.lock().unwrap();
937    conn.execute(
938        "UPDATE identity SET onboarding_seen = 1 WHERE id = 1",
939        [],
940    )?;
941    Ok(())
942}
943
944/// huddle 0.6: the version string of huddle that this user last
945/// finished onboarding for. Stored under the app_settings KV so
946/// version bumps re-fire the "what's new" card without churning
947/// the identity schema again. `None` means the user hasn't seen
948/// any onboarding yet OR pre-existed the version-tracking change.
949pub fn get_last_seen_onboarding_version(db: &Db) -> Result<Option<String>> {
950    get_setting(db, "last_seen_onboarding_version")
951}
952
953pub fn set_last_seen_onboarding_version(db: &Db, version: &str) -> Result<()> {
954    set_setting(db, "last_seen_onboarding_version", version)
955}
956
957/// huddle 0.6: opt-in flag for the crates.io update check. None means
958/// the user hasn't been asked yet; `Some(true)` enables the background
959/// poll; `Some(false)` disables it.
960pub fn get_update_check_enabled(db: &Db) -> Result<Option<bool>> {
961    Ok(get_setting(db, "update_check_enabled")?
962        .map(|v| v == "1" || v.eq_ignore_ascii_case("true")))
963}
964
965pub fn set_update_check_enabled(db: &Db, enabled: bool) -> Result<()> {
966    set_setting(db, "update_check_enabled", if enabled { "1" } else { "0" })
967}
968
969pub fn is_peer_blocked(db: &Db, fingerprint: &str) -> Result<bool> {
970    let conn = db.lock().unwrap();
971    let count: i64 = conn
972        .query_row(
973            "SELECT COUNT(*) FROM blocked_peers WHERE fingerprint = ?1",
974            params![fingerprint],
975            |r| r.get(0),
976        )
977        .unwrap_or(0);
978    Ok(count > 0)
979}
980
981/// List every fingerprint we've blocked (across all rooms / global
982/// rejection from the inbound-dial modal), newest first. Used by the
983/// Settings modal's "blocked peers" pane to render the unblock action.
984pub fn list_blocked_peers(db: &Db) -> Result<Vec<String>> {
985    let conn = db.lock().unwrap();
986    let mut stmt = conn.prepare(
987        "SELECT fingerprint FROM blocked_peers ORDER BY blocked_at DESC",
988    )?;
989    let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
990    Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
991}
992
993/// Remove a fingerprint from the blocklist. Used by the Settings
994/// modal's "unblock" action so a previously-rejected inbound dial can
995/// reach us again. Counterpart of `block_peer`.
996pub fn unblock_peer(db: &Db, fingerprint: &str) -> Result<()> {
997    let conn = db.lock().unwrap();
998    conn.execute(
999        "DELETE FROM blocked_peers WHERE fingerprint = ?1",
1000        params![fingerprint],
1001    )?;
1002    Ok(())
1003}
1004
1005// =========================================================================
1006// Peer profiles (huddle 0.5)
1007// =========================================================================
1008
1009/// Upsert the cached username for a peer iff the incoming `updated_at` is
1010/// strictly newer than what we have stored — last-write-wins on the
1011/// sender's monotonic ms. A None username here means the peer cleared
1012/// their name; render as `[anonymous]`.
1013pub fn upsert_peer_profile(
1014    db: &Db,
1015    fingerprint: &str,
1016    username: Option<&str>,
1017    updated_at: i64,
1018) -> Result<()> {
1019    let conn = db.lock().unwrap();
1020    conn.execute(
1021        "INSERT INTO peer_profiles (fingerprint, username, updated_at)
1022         VALUES (?1, ?2, ?3)
1023         ON CONFLICT(fingerprint) DO UPDATE SET
1024            username   = excluded.username,
1025            updated_at = excluded.updated_at
1026         WHERE excluded.updated_at > peer_profiles.updated_at",
1027        params![fingerprint, username, updated_at],
1028    )?;
1029    Ok(())
1030}
1031
1032/// Cached username for a peer if we've ever seen a signed ProfileUpdate
1033/// from them. Returns None for unknown peers and for peers who set
1034/// `username = None` (explicit anonymous) — caller renders `[anonymous]`.
1035pub fn get_peer_username(db: &Db, fingerprint: &str) -> Result<Option<String>> {
1036    let conn = db.lock().unwrap();
1037    let mut stmt = conn.prepare(
1038        "SELECT username FROM peer_profiles WHERE fingerprint = ?1",
1039    )?;
1040    let mut rows = stmt.query(params![fingerprint])?;
1041    if let Some(row) = rows.next()? {
1042        Ok(row.get::<_, Option<String>>(0)?)
1043    } else {
1044        Ok(None)
1045    }
1046}
1047
1048/// huddle 0.5.1: every fingerprint that has broadcast the given
1049/// username via a signed ProfileUpdate. Multiple matches are possible
1050/// — usernames aren't unique — so the "add by username" flow asks
1051/// the user to disambiguate via HD- ID when this returns > 1.
1052pub fn find_peers_by_username(db: &Db, username: &str) -> Result<Vec<String>> {
1053    let conn = db.lock().unwrap();
1054    let mut stmt = conn.prepare(
1055        "SELECT fingerprint FROM peer_profiles WHERE username = ?1",
1056    )?;
1057    let rows = stmt.query_map(params![username], |row| row.get::<_, String>(0))?;
1058    let mut out = Vec::new();
1059    for r in rows {
1060        out.push(r?);
1061    }
1062    Ok(out)
1063}
1064
1065// =========================================================================
1066// Room attachments
1067// =========================================================================
1068
1069/// Lifecycle of a file transfer card.
1070#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1071pub enum AttachmentStatus {
1072    Offered,
1073    Downloading,
1074    Ready,
1075    Saved,
1076    Failed,
1077    Cancelled,
1078}
1079
1080impl AttachmentStatus {
1081    pub fn as_str(self) -> &'static str {
1082        match self {
1083            Self::Offered => "offered",
1084            Self::Downloading => "downloading",
1085            Self::Ready => "ready",
1086            Self::Saved => "saved",
1087            Self::Failed => "failed",
1088            Self::Cancelled => "cancelled",
1089        }
1090    }
1091    pub fn from_str(s: &str) -> Option<Self> {
1092        Some(match s {
1093            "offered" => Self::Offered,
1094            "downloading" => Self::Downloading,
1095            "ready" => Self::Ready,
1096            "saved" => Self::Saved,
1097            "failed" => Self::Failed,
1098            "cancelled" => Self::Cancelled,
1099            _ => return None,
1100        })
1101    }
1102}
1103
1104#[derive(Debug, Clone)]
1105pub struct StoredAttachment {
1106    pub id: i64,
1107    pub room_id: String,
1108    pub message_id: Option<i64>,
1109    pub sender_fingerprint: String,
1110    pub file_id: String,
1111    pub name: String,
1112    pub mime: Option<String>,
1113    pub size_bytes: i64,
1114    pub status: AttachmentStatus,
1115    pub cache_path: Option<String>,
1116    pub saved_path: Option<String>,
1117    pub error: Option<String>,
1118    pub encrypted: bool,
1119    pub wrapped_key: Option<String>,
1120    pub nonce: Option<String>,
1121    pub megolm_session_id: Option<String>,
1122    /// SHA-256 of the plaintext (hex), for encrypted attachments. Bound
1123    /// as AEAD associated data so the wrapped key + nonce + ciphertext
1124    /// can't be replayed against different content.
1125    pub content_hash: Option<String>,
1126    pub created_at: i64,
1127}
1128
1129/// Insert (or update on file_id collision within the same room).
1130pub fn upsert_attachment(db: &Db, a: &StoredAttachment) -> Result<()> {
1131    let conn = db.lock().unwrap();
1132    conn.execute(
1133        "INSERT INTO room_attachments
1134            (room_id, message_id, sender_fingerprint, file_id, name, mime,
1135             size_bytes, status, cache_path, saved_path, error,
1136             encrypted, wrapped_key, nonce, megolm_session_id, created_at,
1137             content_hash)
1138         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)
1139         ON CONFLICT(room_id, file_id) DO UPDATE SET
1140            name = excluded.name,
1141            mime = excluded.mime,
1142            size_bytes = excluded.size_bytes,
1143            -- Don't downgrade a more advanced status.
1144            status = CASE
1145                WHEN room_attachments.status IN ('saved','ready')
1146                     AND excluded.status IN ('offered','downloading')
1147                THEN room_attachments.status
1148                ELSE excluded.status
1149            END,
1150            cache_path = COALESCE(excluded.cache_path, room_attachments.cache_path),
1151            saved_path = COALESCE(excluded.saved_path, room_attachments.saved_path),
1152            error      = excluded.error,
1153            wrapped_key = COALESCE(excluded.wrapped_key, room_attachments.wrapped_key),
1154            nonce       = COALESCE(excluded.nonce, room_attachments.nonce),
1155            megolm_session_id = COALESCE(excluded.megolm_session_id, room_attachments.megolm_session_id),
1156            content_hash = COALESCE(excluded.content_hash, room_attachments.content_hash)",
1157        params![
1158            a.room_id,
1159            a.message_id,
1160            a.sender_fingerprint,
1161            a.file_id,
1162            a.name,
1163            a.mime,
1164            a.size_bytes,
1165            a.status.as_str(),
1166            a.cache_path,
1167            a.saved_path,
1168            a.error,
1169            a.encrypted as i64,
1170            a.wrapped_key,
1171            a.nonce,
1172            a.megolm_session_id,
1173            a.created_at,
1174            a.content_hash,
1175        ],
1176    )?;
1177    Ok(())
1178}
1179
1180fn row_to_attachment(row: &rusqlite::Row) -> rusqlite::Result<StoredAttachment> {
1181    let status_s: String = row.get(8)?;
1182    let status = AttachmentStatus::from_str(&status_s).unwrap_or(AttachmentStatus::Failed);
1183    Ok(StoredAttachment {
1184        id: row.get(0)?,
1185        room_id: row.get(1)?,
1186        message_id: row.get(2)?,
1187        sender_fingerprint: row.get(3)?,
1188        file_id: row.get(4)?,
1189        name: row.get(5)?,
1190        mime: row.get(6)?,
1191        size_bytes: row.get(7)?,
1192        status,
1193        cache_path: row.get(9)?,
1194        saved_path: row.get(10)?,
1195        error: row.get(11)?,
1196        encrypted: row.get::<_, i64>(12)? != 0,
1197        wrapped_key: row.get(13)?,
1198        nonce: row.get(14)?,
1199        megolm_session_id: row.get(15)?,
1200        created_at: row.get(16)?,
1201        content_hash: row.get(17)?,
1202    })
1203}
1204
1205pub fn get_attachment(db: &Db, room_id: &str, file_id: &str) -> Result<Option<StoredAttachment>> {
1206    let conn = db.lock().unwrap();
1207    let mut stmt = conn.prepare(
1208        "SELECT id, room_id, message_id, sender_fingerprint, file_id, name, mime,
1209                size_bytes, status, cache_path, saved_path, error,
1210                encrypted, wrapped_key, nonce, megolm_session_id, created_at,
1211                content_hash
1212         FROM room_attachments WHERE room_id = ?1 AND file_id = ?2",
1213    )?;
1214    let mut rows = stmt.query_map(params![room_id, file_id], row_to_attachment)?;
1215    match rows.next() {
1216        Some(r) => Ok(Some(r?)),
1217        None => Ok(None),
1218    }
1219}
1220
1221pub fn list_room_attachments(db: &Db, room_id: &str) -> Result<Vec<StoredAttachment>> {
1222    let conn = db.lock().unwrap();
1223    let mut stmt = conn.prepare(
1224        "SELECT id, room_id, message_id, sender_fingerprint, file_id, name, mime,
1225                size_bytes, status, cache_path, saved_path, error,
1226                encrypted, wrapped_key, nonce, megolm_session_id, created_at,
1227                content_hash
1228         FROM room_attachments WHERE room_id = ?1 ORDER BY created_at ASC",
1229    )?;
1230    let rows = stmt.query_map(params![room_id], row_to_attachment)?;
1231    Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
1232}
1233
1234pub fn update_attachment_status(
1235    db: &Db,
1236    room_id: &str,
1237    file_id: &str,
1238    status: AttachmentStatus,
1239    error: Option<&str>,
1240) -> Result<()> {
1241    let conn = db.lock().unwrap();
1242    conn.execute(
1243        "UPDATE room_attachments SET status = ?1, error = ?2
1244         WHERE room_id = ?3 AND file_id = ?4",
1245        params![status.as_str(), error, room_id, file_id],
1246    )?;
1247    Ok(())
1248}
1249
1250pub fn update_attachment_paths(
1251    db: &Db,
1252    room_id: &str,
1253    file_id: &str,
1254    cache_path: Option<&str>,
1255    saved_path: Option<&str>,
1256) -> Result<()> {
1257    let conn = db.lock().unwrap();
1258    conn.execute(
1259        "UPDATE room_attachments
1260         SET cache_path = COALESCE(?1, cache_path),
1261             saved_path = COALESCE(?2, saved_path)
1262         WHERE room_id = ?3 AND file_id = ?4",
1263        params![cache_path, saved_path, room_id, file_id],
1264    )?;
1265    Ok(())
1266}
1267
1268pub fn delete_attachment(db: &Db, room_id: &str, file_id: &str) -> Result<()> {
1269    let conn = db.lock().unwrap();
1270    conn.execute(
1271        "DELETE FROM room_attachments WHERE room_id = ?1 AND file_id = ?2",
1272        params![room_id, file_id],
1273    )?;
1274    Ok(())
1275}
1276
1277#[cfg(test)]
1278mod tests {
1279    use super::*;
1280    use crate::storage::open_db_in_memory;
1281
1282    fn make_room(name: &str) -> StoredRoom {
1283        let creator_fp = "test-creator-fp";
1284        let created_at = 1000;
1285        StoredRoom {
1286            id: derive_room_id(creator_fp, name, created_at),
1287            name: name.into(),
1288            creator_fingerprint: creator_fp.into(),
1289            encrypted: false,
1290            passphrase_salt: None,
1291            created_at,
1292            last_active: None,
1293            kind: RoomKind::Group,
1294        }
1295    }
1296
1297    #[test]
1298    fn identity_round_trip() {
1299        let db = open_db_in_memory().unwrap();
1300        save_identity(&db, b"secret-bytes-32-chars-long-xxxxx", 1000).unwrap();
1301        let loaded = load_identity(&db).unwrap().unwrap();
1302        assert_eq!(loaded.ed25519_secret, b"secret-bytes-32-chars-long-xxxxx");
1303        assert_eq!(loaded.created_at, 1000);
1304    }
1305
1306    #[test]
1307    fn room_id_is_deterministic() {
1308        let id1 = derive_room_id("creator-fp", "test-room", 1000);
1309        let id2 = derive_room_id("creator-fp", "test-room", 1000);
1310        assert_eq!(id1, id2);
1311        assert_eq!(id1.len(), 32); // 16 bytes hex-encoded
1312    }
1313
1314    #[test]
1315    fn room_id_differs_with_inputs() {
1316        let id1 = derive_room_id("creator-a", "test", 1000);
1317        let id2 = derive_room_id("creator-b", "test", 1000);
1318        let id3 = derive_room_id("creator-a", "test", 1001);
1319        assert_ne!(id1, id2);
1320        assert_ne!(id1, id3);
1321    }
1322
1323    #[test]
1324    fn room_insert_and_get() {
1325        let db = open_db_in_memory().unwrap();
1326        let room = make_room("lunch-talk");
1327        insert_room(&db, &room).unwrap();
1328        let loaded = get_room(&db, &room.id).unwrap().unwrap();
1329        assert_eq!(loaded.name, "lunch-talk");
1330        assert!(!loaded.encrypted);
1331    }
1332
1333    #[test]
1334    fn room_list_orders_by_last_active() {
1335        let db = open_db_in_memory().unwrap();
1336        let mut a = make_room("alpha");
1337        a.last_active = Some(100);
1338        let mut b = make_room("beta");
1339        b.last_active = Some(200);
1340        insert_room(&db, &a).unwrap();
1341        insert_room(&db, &b).unwrap();
1342        let rooms = list_rooms(&db).unwrap();
1343        assert_eq!(rooms[0].name, "beta");
1344        assert_eq!(rooms[1].name, "alpha");
1345    }
1346
1347    #[test]
1348    fn room_member_upsert() {
1349        let db = open_db_in_memory().unwrap();
1350        let room = make_room("r");
1351        insert_room(&db, &room).unwrap();
1352
1353        upsert_room_member(
1354            &db,
1355            &StoredRoomMember {
1356                room_id: room.id.clone(),
1357                peer_id: "peer-x".into(),
1358                fingerprint: "fp-x".into(),
1359                last_seen: Some(500),
1360                verified: false,
1361                ed25519_pubkey: None,
1362                role: "member".into(),
1363            },
1364        )
1365        .unwrap();
1366        let members = list_room_members(&db, &room.id).unwrap();
1367        assert_eq!(members.len(), 1);
1368        assert_eq!(members[0].fingerprint, "fp-x");
1369        assert!(!members[0].verified);
1370    }
1371
1372    #[test]
1373    fn set_and_query_verified() {
1374        let db = open_db_in_memory().unwrap();
1375        let room = make_room("r");
1376        insert_room(&db, &room).unwrap();
1377        upsert_room_member(
1378            &db,
1379            &StoredRoomMember {
1380                room_id: room.id.clone(),
1381                peer_id: "p1".into(),
1382                fingerprint: "fp-1".into(),
1383                last_seen: None,
1384                verified: false,
1385                ed25519_pubkey: None,
1386                role: "member".into(),
1387            },
1388        )
1389        .unwrap();
1390        set_member_verified(&db, &room.id, "fp-1", true).unwrap();
1391        let verified = list_verified_fingerprints(&db, &room.id).unwrap();
1392        assert_eq!(verified, vec!["fp-1".to_string()]);
1393        let m = list_room_members(&db, &room.id).unwrap();
1394        assert!(m[0].verified);
1395    }
1396
1397    #[test]
1398    fn megolm_session_round_trip() {
1399        let db = open_db_in_memory().unwrap();
1400        let room = make_room("r");
1401        insert_room(&db, &room).unwrap();
1402
1403        let session = StoredMegolmSession {
1404            room_id: room.id.clone(),
1405            sender_fingerprint: "fp-sender".into(),
1406            session_id: "session-1".into(),
1407            session_data: vec![1, 2, 3, 4],
1408            is_outbound: true,
1409            created_at: 100,
1410        };
1411        save_megolm_session(&db, &session).unwrap();
1412        let loaded = load_megolm_sessions_for_room(&db, &room.id).unwrap();
1413        assert_eq!(loaded.len(), 1);
1414        assert_eq!(loaded[0].session_data, vec![1, 2, 3, 4]);
1415        assert!(loaded[0].is_outbound);
1416    }
1417
1418    fn make_attachment(room_id: &str, file_id: &str, name: &str) -> StoredAttachment {
1419        StoredAttachment {
1420            id: 0,
1421            room_id: room_id.into(),
1422            message_id: None,
1423            sender_fingerprint: "sender-fp".into(),
1424            file_id: file_id.into(),
1425            name: name.into(),
1426            mime: Some("image/png".into()),
1427            size_bytes: 1234,
1428            status: AttachmentStatus::Offered,
1429            cache_path: None,
1430            saved_path: None,
1431            error: None,
1432            encrypted: false,
1433            wrapped_key: None,
1434            nonce: None,
1435            megolm_session_id: None,
1436            content_hash: None,
1437            created_at: 100,
1438        }
1439    }
1440
1441    #[test]
1442    fn attachment_upsert_and_get() {
1443        let db = open_db_in_memory().unwrap();
1444        let room = make_room("r");
1445        insert_room(&db, &room).unwrap();
1446        let a = make_attachment(&room.id, "file-abc", "photo.png");
1447        upsert_attachment(&db, &a).unwrap();
1448
1449        let loaded = get_attachment(&db, &room.id, "file-abc").unwrap().unwrap();
1450        assert_eq!(loaded.name, "photo.png");
1451        assert_eq!(loaded.status, AttachmentStatus::Offered);
1452        assert_eq!(loaded.size_bytes, 1234);
1453    }
1454
1455    #[test]
1456    fn attachment_status_transitions() {
1457        let db = open_db_in_memory().unwrap();
1458        let room = make_room("r");
1459        insert_room(&db, &room).unwrap();
1460        let a = make_attachment(&room.id, "fid", "f.bin");
1461        upsert_attachment(&db, &a).unwrap();
1462
1463        update_attachment_status(&db, &room.id, "fid", AttachmentStatus::Downloading, None)
1464            .unwrap();
1465        assert_eq!(
1466            get_attachment(&db, &room.id, "fid")
1467                .unwrap()
1468                .unwrap()
1469                .status,
1470            AttachmentStatus::Downloading
1471        );
1472
1473        update_attachment_status(&db, &room.id, "fid", AttachmentStatus::Ready, None).unwrap();
1474        update_attachment_paths(
1475            &db,
1476            &room.id,
1477            "fid",
1478            Some("/cache/fid"),
1479            Some("/Downloads/f.bin"),
1480        )
1481        .unwrap();
1482        let loaded = get_attachment(&db, &room.id, "fid").unwrap().unwrap();
1483        assert_eq!(loaded.status, AttachmentStatus::Ready);
1484        assert_eq!(loaded.cache_path.as_deref(), Some("/cache/fid"));
1485        assert_eq!(loaded.saved_path.as_deref(), Some("/Downloads/f.bin"));
1486    }
1487
1488    #[test]
1489    fn upsert_does_not_downgrade_status() {
1490        let db = open_db_in_memory().unwrap();
1491        let room = make_room("r");
1492        insert_room(&db, &room).unwrap();
1493        let mut a = make_attachment(&room.id, "fid", "f.bin");
1494        a.status = AttachmentStatus::Saved;
1495        upsert_attachment(&db, &a).unwrap();
1496
1497        a.status = AttachmentStatus::Offered;
1498        upsert_attachment(&db, &a).unwrap();
1499        assert_eq!(
1500            get_attachment(&db, &room.id, "fid")
1501                .unwrap()
1502                .unwrap()
1503                .status,
1504            AttachmentStatus::Saved
1505        );
1506    }
1507
1508    #[test]
1509    fn list_attachments_for_room() {
1510        let db = open_db_in_memory().unwrap();
1511        let room = make_room("r");
1512        insert_room(&db, &room).unwrap();
1513        upsert_attachment(&db, &make_attachment(&room.id, "fid-a", "a.bin")).unwrap();
1514        upsert_attachment(&db, &make_attachment(&room.id, "fid-b", "b.bin")).unwrap();
1515        let list = list_room_attachments(&db, &room.id).unwrap();
1516        assert_eq!(list.len(), 2);
1517        assert_eq!(list[0].file_id, "fid-a");
1518        assert_eq!(list[1].file_id, "fid-b");
1519    }
1520
1521    #[test]
1522    fn attachment_status_string_round_trip() {
1523        for &s in &[
1524            AttachmentStatus::Offered,
1525            AttachmentStatus::Downloading,
1526            AttachmentStatus::Ready,
1527            AttachmentStatus::Saved,
1528            AttachmentStatus::Failed,
1529            AttachmentStatus::Cancelled,
1530        ] {
1531            assert_eq!(AttachmentStatus::from_str(s.as_str()), Some(s));
1532        }
1533    }
1534
1535    #[test]
1536    fn room_messages_query_returns_chronological() {
1537        let db = open_db_in_memory().unwrap();
1538        let room = make_room("r");
1539        insert_room(&db, &room).unwrap();
1540
1541        insert_room_message(&db, &room.id, "alice-fp", "in", "hi", 100).unwrap();
1542        insert_room_message(&db, &room.id, "me-fp", "out", "hello", 101).unwrap();
1543        insert_room_message(&db, &room.id, "alice-fp", "in", "bye", 102).unwrap();
1544
1545        let msgs = get_room_messages(&db, &room.id, 10).unwrap();
1546        assert_eq!(msgs.len(), 3);
1547        assert_eq!(msgs[0].body, "hi");
1548        assert_eq!(msgs[1].body, "hello");
1549        assert_eq!(msgs[2].body, "bye");
1550    }
1551
1552    #[test]
1553    fn search_escapes_like_wildcards() {
1554        let db = open_db_in_memory().unwrap();
1555        let room = make_room("r");
1556        insert_room(&db, &room).unwrap();
1557        insert_room_message(&db, &room.id, "fp", "in", "literal percent: 50%", 100).unwrap();
1558        insert_room_message(&db, &room.id, "fp", "in", "no special chars here", 101).unwrap();
1559
1560        // "%" must match a literal "%", not act as a wildcard-matches-all.
1561        let pct = search_room_messages(&db, &room.id, "%", 10).unwrap();
1562        assert_eq!(pct.len(), 1);
1563        assert!(pct[0].body.contains("50%"));
1564
1565        // "_" likewise must not match an arbitrary single character.
1566        let underscore = search_room_messages(&db, &room.id, "_", 10).unwrap();
1567        assert!(underscore.is_empty());
1568    }
1569}