Skip to main content

kcode_k1_access_profile_store/
lib.rs

1use std::{
2    fs,
3    io::ErrorKind,
4    path::{Path, PathBuf},
5    sync::{
6        Arc, Mutex,
7        atomic::{AtomicBool, Ordering},
8    },
9    time::{Duration, Instant},
10};
11
12use kcode_k1_access_profile_types::{decode_profile, encode_profile};
13use kcode_k1_transaction::Transaction;
14use kcode_k1_txn_ordering::K1TxnOrdering;
15use rusqlite::{
16    Connection, ErrorCode, OpenFlags, OptionalExtension, Row, TransactionBehavior, params,
17};
18
19pub use kcode_k1_access_profile_types::{
20    AuthorizationProfile, ProfileId, ProfileRevision, TxId, UserId,
21};
22
23const DATABASE_FILE: &str = "profiles.sqlite3";
24const SUBSYSTEM: &str = "k1-profile-subsystem";
25const METADATA_SQL: &str = "CREATE TABLE metadata(schema_version INTEGER NOT NULL CHECK(schema_version=1), last_applied_txid BLOB CHECK(last_applied_txid IS NULL OR (typeof(last_applied_txid)='blob' AND length(last_applied_txid)=12)))";
26const PROFILES_SQL: &str = "CREATE TABLE profiles(profile_id BLOB PRIMARY KEY NOT NULL CHECK(typeof(profile_id)='blob' AND length(profile_id)=12), owner BLOB NOT NULL CHECK(typeof(owner)='blob' AND length(owner)=12), revision BLOB NOT NULL CHECK(typeof(revision)='blob' AND length(revision)=12), profile BLOB NOT NULL CHECK(typeof(profile)='blob')) WITHOUT ROWID";
27const OWNER_INDEX_SQL: &str = "CREATE INDEX profiles_owner ON profiles(owner)";
28const CREATE_SCHEMA: &str = "BEGIN IMMEDIATE;
29CREATE TABLE metadata(schema_version INTEGER NOT NULL CHECK(schema_version=1), last_applied_txid BLOB CHECK(last_applied_txid IS NULL OR (typeof(last_applied_txid)='blob' AND length(last_applied_txid)=12)));
30CREATE TABLE profiles(profile_id BLOB PRIMARY KEY NOT NULL CHECK(typeof(profile_id)='blob' AND length(profile_id)=12), owner BLOB NOT NULL CHECK(typeof(owner)='blob' AND length(owner)=12), revision BLOB NOT NULL CHECK(typeof(revision)='blob' AND length(revision)=12), profile BLOB NOT NULL CHECK(typeof(profile)='blob')) WITHOUT ROWID;
31CREATE INDEX profiles_owner ON profiles(owner);
32INSERT INTO metadata(schema_version, last_applied_txid) VALUES(1, NULL);
33COMMIT;";
34
35#[derive(Clone, Debug, Eq, PartialEq)]
36pub struct SavedProfile {
37    profile_id: ProfileId,
38    owner: UserId,
39    revision: ProfileRevision,
40    profile: AuthorizationProfile,
41}
42
43impl SavedProfile {
44    pub const fn profile_id(&self) -> ProfileId {
45        self.profile_id
46    }
47
48    pub const fn owner(&self) -> UserId {
49        self.owner
50    }
51
52    pub const fn revision(&self) -> ProfileRevision {
53        self.revision
54    }
55
56    pub fn profile(&self) -> &AuthorizationProfile {
57        &self.profile
58    }
59}
60
61#[derive(Clone, Debug, Eq, PartialEq)]
62pub enum ProfileAction {
63    Create {
64        owner: UserId,
65        profile: AuthorizationProfile,
66    },
67    Replace {
68        profile_id: ProfileId,
69        actor: UserId,
70        profile: AuthorizationProfile,
71    },
72    Delete {
73        profile_id: ProfileId,
74        actor: UserId,
75    },
76}
77
78#[derive(Clone, Debug, Eq, PartialEq)]
79pub enum ApplyOutcome {
80    Applied(ProfileRevision),
81    Unchanged(ProfileRevision),
82    Rejected(String),
83}
84
85pub struct ProfileStore {
86    database: PathBuf,
87    ordering: Arc<K1TxnOrdering>,
88    apply_connection: Mutex<Connection>,
89    available: AtomicBool,
90}
91
92impl ProfileStore {
93    pub fn open(root: &Path, ordering: Arc<K1TxnOrdering>) -> Result<(Self, Option<TxId>), String> {
94        let started = Instant::now();
95        let result = Self::open_inner(root, ordering);
96        if started.elapsed() > Duration::from_millis(100) {
97            let outcome = if result.is_ok() { "ready" } else { "error" };
98            eprintln!(
99                "level=warn module=kcode-k1-access-profile-store operation=open elapsed_us={} outcome={outcome}",
100                started.elapsed().as_micros()
101            );
102        }
103        result
104    }
105
106    fn open_inner(
107        root: &Path,
108        ordering: Arc<K1TxnOrdering>,
109    ) -> Result<(Self, Option<TxId>), String> {
110        fs::create_dir_all(root)
111            .map_err(|error| format!("profile root is unavailable: {error}"))?;
112        let database = root.join(DATABASE_FILE);
113        let loaded = load_database(&database, &ordering);
114        let (connection, cursor) = match loaded {
115            Ok(value) => value,
116            Err(OpenIssue::Fatal(error)) => return Err(error),
117            Err(OpenIssue::Corrupt) => {
118                remove_database(root)?;
119                match load_database(&database, &ordering) {
120                    Ok(value) => value,
121                    Err(OpenIssue::Fatal(error)) => return Err(error),
122                    Err(OpenIssue::Corrupt) => {
123                        return Err("profile database rebuild failed".to_owned());
124                    }
125                }
126            }
127        };
128        Ok((
129            Self {
130                database,
131                ordering,
132                apply_connection: Mutex::new(connection),
133                available: AtomicBool::new(true),
134            },
135            cursor,
136        ))
137    }
138
139    pub fn apply(
140        &self,
141        callback_txid: TxId,
142        action: ProfileAction,
143    ) -> Result<ApplyOutcome, String> {
144        self.require_available()?;
145        match validate_transaction(&self.ordering, callback_txid) {
146            Ok(()) => {}
147            Err(OpenIssue::Corrupt) => {
148                return self.disable("profile callback contradicts KTO");
149            }
150            Err(OpenIssue::Fatal(error)) => return self.disable(error),
151        }
152        let encoded_profile = match &action {
153            ProfileAction::Create { profile, .. } | ProfileAction::Replace { profile, .. } => {
154                Some(encode_profile(profile)?)
155            }
156            ProfileAction::Delete { .. } => None,
157        };
158        let mut connection = match self.apply_connection.lock() {
159            Ok(connection) => connection,
160            Err(_) => return self.disable("profile apply lane is unavailable"),
161        };
162        self.require_available()?;
163        let transaction = match connection.transaction_with_behavior(TransactionBehavior::Immediate)
164        {
165            Ok(transaction) => transaction,
166            Err(error) => return self.disable(format!("profile persistence failed: {error}")),
167        };
168        let outcome = match apply_action(&transaction, callback_txid, action, encoded_profile) {
169            Ok(outcome) => outcome,
170            Err(error) => return self.disable(error),
171        };
172        if let Err(error) = transaction.commit() {
173            return self.disable(format!("profile persistence failed: {error}"));
174        }
175        Ok(outcome)
176    }
177
178    pub fn get_for_user(
179        &self,
180        user: UserId,
181        profile_id: ProfileId,
182    ) -> Result<Option<SavedProfile>, String> {
183        self.require_available()?;
184        match query_profile(&self.database, user, profile_id) {
185            Ok(profile) => Ok(profile),
186            Err(error) => self.disable(error),
187        }
188    }
189
190    pub fn list_for_user(&self, user: UserId) -> Result<Vec<SavedProfile>, String> {
191        self.require_available()?;
192        match query_profiles(&self.database, user) {
193            Ok(profiles) => Ok(profiles),
194            Err(error) => self.disable(error),
195        }
196    }
197
198    pub fn clear(&self) -> Result<(), String> {
199        self.require_available()?;
200        let mut connection = match self.apply_connection.lock() {
201            Ok(connection) => connection,
202            Err(_) => return self.disable("profile apply lane is unavailable"),
203        };
204        self.require_available()?;
205        let transaction = match connection.transaction_with_behavior(TransactionBehavior::Immediate)
206        {
207            Ok(transaction) => transaction,
208            Err(error) => return self.disable(format!("profile persistence failed: {error}")),
209        };
210        if let Err(error) = transaction.execute("DELETE FROM profiles", []) {
211            return self.disable(format!("profile persistence failed: {error}"));
212        }
213        match transaction.execute("UPDATE metadata SET last_applied_txid=NULL", []) {
214            Ok(1) => {}
215            Ok(_) => return self.disable("profile persistence contradiction"),
216            Err(error) => return self.disable(format!("profile persistence failed: {error}")),
217        }
218        if let Err(error) = transaction.commit() {
219            return self.disable(format!("profile persistence failed: {error}"));
220        }
221        Ok(())
222    }
223
224    fn require_available(&self) -> Result<(), String> {
225        if self.available.load(Ordering::Acquire) {
226            Ok(())
227        } else {
228            Err("profile store is unavailable".to_owned())
229        }
230    }
231
232    fn disable<T>(&self, error: impl Into<String>) -> Result<T, String> {
233        self.available.store(false, Ordering::Release);
234        Err(error.into())
235    }
236}
237
238fn apply_action(
239    transaction: &rusqlite::Transaction<'_>,
240    callback_txid: TxId,
241    action: ProfileAction,
242    encoded_profile: Option<Vec<u8>>,
243) -> Result<ApplyOutcome, String> {
244    let outcome = match action {
245        ProfileAction::Create { owner, .. } => {
246            let profile_id = ProfileId::new(callback_txid);
247            if load_saved(transaction, profile_id)?.is_some() {
248                ApplyOutcome::Rejected("profile already exists".to_owned())
249            } else {
250                let profile = encoded_profile
251                    .as_deref()
252                    .ok_or("profile encoding is unavailable")?;
253                let profile_id_bytes = callback_txid.into_bytes();
254                let owner_bytes = owner.as_tx_id().into_bytes();
255                let changed = transaction
256                    .execute(
257                        "INSERT INTO profiles(profile_id, owner, revision, profile) VALUES(?1, ?2, ?3, ?4)",
258                        params![profile_id_bytes.as_slice(), owner_bytes.as_slice(), profile_id_bytes.as_slice(), profile],
259                    )
260                    .map_err(|error| format!("profile persistence failed: {error}"))?;
261                if changed != 1 {
262                    return Err("profile persistence contradiction".to_owned());
263                }
264                ApplyOutcome::Applied(ProfileRevision::new(profile_id, callback_txid))
265            }
266        }
267        ProfileAction::Replace {
268            profile_id,
269            actor,
270            profile,
271        } => match load_saved(transaction, profile_id)? {
272            Some(current) if current.owner() == actor => {
273                if current.profile() == &profile {
274                    ApplyOutcome::Unchanged(current.revision())
275                } else {
276                    let profile = encoded_profile
277                        .as_deref()
278                        .ok_or("profile encoding is unavailable")?;
279                    let profile_id_bytes = profile_id.txid().into_bytes();
280                    let revision_bytes = callback_txid.into_bytes();
281                    let changed = transaction
282                        .execute(
283                            "UPDATE profiles SET revision=?1, profile=?2 WHERE profile_id=?3",
284                            params![
285                                revision_bytes.as_slice(),
286                                profile,
287                                profile_id_bytes.as_slice()
288                            ],
289                        )
290                        .map_err(|error| format!("profile persistence failed: {error}"))?;
291                    if changed != 1 {
292                        return Err("profile persistence contradiction".to_owned());
293                    }
294                    ApplyOutcome::Applied(ProfileRevision::new(profile_id, callback_txid))
295                }
296            }
297            _ => ApplyOutcome::Rejected("profile is unavailable".to_owned()),
298        },
299        ProfileAction::Delete { profile_id, actor } => match load_saved(transaction, profile_id)? {
300            Some(current) if current.owner() == actor => {
301                let profile_id_bytes = profile_id.txid().into_bytes();
302                let changed = transaction
303                    .execute(
304                        "DELETE FROM profiles WHERE profile_id=?1",
305                        params![profile_id_bytes.as_slice()],
306                    )
307                    .map_err(|error| format!("profile persistence failed: {error}"))?;
308                if changed != 1 {
309                    return Err("profile persistence contradiction".to_owned());
310                }
311                ApplyOutcome::Applied(ProfileRevision::new(profile_id, callback_txid))
312            }
313            _ => ApplyOutcome::Rejected("profile is unavailable".to_owned()),
314        },
315    };
316    let cursor = callback_txid.into_bytes();
317    let changed = transaction
318        .execute(
319            "UPDATE metadata SET last_applied_txid=?1",
320            params![cursor.as_slice()],
321        )
322        .map_err(|error| format!("profile persistence failed: {error}"))?;
323    if changed != 1 {
324        return Err("profile persistence contradiction".to_owned());
325    }
326    Ok(outcome)
327}
328
329fn load_saved(
330    transaction: &rusqlite::Transaction<'_>,
331    profile_id: ProfileId,
332) -> Result<Option<SavedProfile>, String> {
333    let profile_id_bytes = profile_id.txid().into_bytes();
334    let raw = transaction
335        .query_row(
336            "SELECT profile_id, owner, revision, profile FROM profiles WHERE profile_id=?1",
337            params![profile_id_bytes.as_slice()],
338            raw_profile,
339        )
340        .optional()
341        .map_err(|error| format!("profile persistence failed: {error}"))?;
342    raw.map(decode_saved).transpose()
343}
344
345fn query_profile(
346    database: &Path,
347    user: UserId,
348    profile_id: ProfileId,
349) -> Result<Option<SavedProfile>, String> {
350    let connection = open_query(database)?;
351    let user_bytes = user.as_tx_id().into_bytes();
352    let profile_id_bytes = profile_id.txid().into_bytes();
353    let raw = connection
354        .query_row(
355            "SELECT profile_id, owner, revision, profile FROM profiles WHERE profile_id=?1 AND owner=?2",
356            params![profile_id_bytes.as_slice(), user_bytes.as_slice()],
357            raw_profile,
358        )
359        .optional()
360        .map_err(|error| format!("profile query failed: {error}"))?;
361    raw.map(decode_saved).transpose()
362}
363
364fn query_profiles(database: &Path, user: UserId) -> Result<Vec<SavedProfile>, String> {
365    let connection = open_query(database)?;
366    let user_bytes = user.as_tx_id().into_bytes();
367    let mut statement = connection
368        .prepare("SELECT profile_id, owner, revision, profile FROM profiles WHERE owner=?1")
369        .map_err(|error| format!("profile query failed: {error}"))?;
370    let mut rows = statement
371        .query(params![user_bytes.as_slice()])
372        .map_err(|error| format!("profile query failed: {error}"))?;
373    let mut profiles = Vec::new();
374    while let Some(row) = rows
375        .next()
376        .map_err(|error| format!("profile query failed: {error}"))?
377    {
378        let raw = raw_profile(row).map_err(|error| format!("profile query failed: {error}"))?;
379        profiles.push(decode_saved(raw)?);
380    }
381    Ok(profiles)
382}
383
384fn open_query(database: &Path) -> Result<Connection, String> {
385    let connection = Connection::open_with_flags(
386        database,
387        OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
388    )
389    .map_err(|error| format!("profile query failed: {error}"))?;
390    connection
391        .busy_timeout(Duration::ZERO)
392        .map_err(|error| format!("profile query failed: {error}"))?;
393    Ok(connection)
394}
395
396struct RawProfile {
397    profile_id: Vec<u8>,
398    owner: Vec<u8>,
399    revision: Vec<u8>,
400    profile: Vec<u8>,
401}
402
403fn raw_profile(row: &Row<'_>) -> rusqlite::Result<RawProfile> {
404    Ok(RawProfile {
405        profile_id: row.get(0)?,
406        owner: row.get(1)?,
407        revision: row.get(2)?,
408        profile: row.get(3)?,
409    })
410}
411
412fn decode_saved(raw: RawProfile) -> Result<SavedProfile, String> {
413    let profile_id = ProfileId::new(TxId::from_bytes(bytes_12(raw.profile_id)?));
414    let owner = UserId::from_tx_id(TxId::from_bytes(bytes_12(raw.owner)?));
415    let revision_txid = TxId::from_bytes(bytes_12(raw.revision)?);
416    let profile = decode_profile(&raw.profile).map_err(|_| "profile persistence contradiction")?;
417    Ok(SavedProfile {
418        profile_id,
419        owner,
420        revision: ProfileRevision::new(profile_id, revision_txid),
421        profile,
422    })
423}
424
425fn bytes_12(bytes: Vec<u8>) -> Result<[u8; 12], String> {
426    bytes
427        .try_into()
428        .map_err(|_| "profile persistence contradiction".to_owned())
429}
430
431enum OpenIssue {
432    Corrupt,
433    Fatal(String),
434}
435
436fn load_database(
437    database: &Path,
438    ordering: &K1TxnOrdering,
439) -> Result<(Connection, Option<TxId>), OpenIssue> {
440    let connection = connect(database)?;
441    let object_count: i64 = connection
442        .query_row(
443            "SELECT count(*) FROM sqlite_schema WHERE name NOT LIKE 'sqlite_%'",
444            [],
445            |row| row.get(0),
446        )
447        .map_err(sql_issue)?;
448    if object_count == 0 {
449        connection.execute_batch(CREATE_SCHEMA).map_err(sql_issue)?;
450    }
451    validate_schema(&connection)?;
452    let cursor = validate_rows(&connection, ordering)?;
453    Ok((connection, cursor))
454}
455
456fn connect(database: &Path) -> Result<Connection, OpenIssue> {
457    let connection = Connection::open(database).map_err(sql_issue)?;
458    connection.busy_timeout(Duration::ZERO).map_err(sql_issue)?;
459    let journal: String = connection
460        .query_row("PRAGMA journal_mode=WAL", [], |row| row.get(0))
461        .map_err(sql_issue)?;
462    if !journal.eq_ignore_ascii_case("wal") {
463        return Err(OpenIssue::Fatal("SQLite WAL is unavailable".to_owned()));
464    }
465    connection
466        .execute_batch("PRAGMA synchronous=FULL;")
467        .map_err(sql_issue)?;
468    let synchronous: i64 = connection
469        .query_row("PRAGMA synchronous", [], |row| row.get(0))
470        .map_err(sql_issue)?;
471    if synchronous != 2 {
472        return Err(OpenIssue::Fatal(
473            "SQLite FULL synchronization is unavailable".to_owned(),
474        ));
475    }
476    Ok(connection)
477}
478
479fn validate_schema(connection: &Connection) -> Result<(), OpenIssue> {
480    let integrity: String = connection
481        .query_row("PRAGMA integrity_check", [], |row| row.get(0))
482        .map_err(sql_issue)?;
483    if integrity != "ok" {
484        return Err(OpenIssue::Corrupt);
485    }
486    let mut statement = connection
487        .prepare("SELECT name, sql FROM sqlite_schema WHERE name NOT LIKE 'sqlite_%'")
488        .map_err(sql_issue)?;
489    let mut rows = statement.query([]).map_err(sql_issue)?;
490    let mut metadata = false;
491    let mut profiles = false;
492    let mut owner_index = false;
493    let mut count = 0;
494    while let Some(row) = rows.next().map_err(sql_issue)? {
495        let name: String = row.get(0).map_err(|_| OpenIssue::Corrupt)?;
496        let sql: String = row.get(1).map_err(|_| OpenIssue::Corrupt)?;
497        count += 1;
498        match name.as_str() {
499            "metadata" if sql == METADATA_SQL => metadata = true,
500            "profiles" if sql == PROFILES_SQL => profiles = true,
501            "profiles_owner" if sql == OWNER_INDEX_SQL => owner_index = true,
502            _ => return Err(OpenIssue::Corrupt),
503        }
504    }
505    if count != 3 || !metadata || !profiles || !owner_index {
506        return Err(OpenIssue::Corrupt);
507    }
508    Ok(())
509}
510
511fn validate_rows(
512    connection: &Connection,
513    ordering: &K1TxnOrdering,
514) -> Result<Option<TxId>, OpenIssue> {
515    let metadata_count: i64 = connection
516        .query_row("SELECT count(*) FROM metadata", [], |row| row.get(0))
517        .map_err(sql_issue)?;
518    if metadata_count != 1 {
519        return Err(OpenIssue::Corrupt);
520    }
521    let (version, cursor_bytes): (i64, Option<Vec<u8>>) = connection
522        .query_row(
523            "SELECT schema_version, last_applied_txid FROM metadata",
524            [],
525            |row| Ok((row.get(0)?, row.get(1)?)),
526        )
527        .map_err(|_| OpenIssue::Corrupt)?;
528    if version != 1 {
529        return Err(OpenIssue::Corrupt);
530    }
531    let cursor = cursor_bytes
532        .map(|bytes| bytes_12(bytes).map(TxId::from_bytes))
533        .transpose()
534        .map_err(|_| OpenIssue::Corrupt)?;
535    if let Some(id) = cursor {
536        validate_transaction(ordering, id)?;
537    }
538    let mut statement = connection
539        .prepare("SELECT profile_id, owner, revision, profile FROM profiles")
540        .map_err(sql_issue)?;
541    let mut rows = statement.query([]).map_err(sql_issue)?;
542    let mut any_profile = false;
543    while let Some(row) = rows.next().map_err(sql_issue)? {
544        any_profile = true;
545        let raw = raw_profile(row).map_err(|_| OpenIssue::Corrupt)?;
546        let saved = decode_saved(raw).map_err(|_| OpenIssue::Corrupt)?;
547        validate_transaction(ordering, saved.profile_id().txid())?;
548        validate_transaction(ordering, saved.revision().txid())?;
549    }
550    if any_profile && cursor.is_none() {
551        return Err(OpenIssue::Corrupt);
552    }
553    Ok(cursor)
554}
555
556fn validate_transaction(ordering: &K1TxnOrdering, id: TxId) -> Result<(), OpenIssue> {
557    let bytes = ordering
558        .get_txn(id)
559        .map_err(|error| OpenIssue::Fatal(format!("KTO query failed: {error}")))?
560        .ok_or(OpenIssue::Corrupt)?;
561    if !id.verify(&bytes) {
562        return Err(OpenIssue::Corrupt);
563    }
564    let transaction = Transaction::parse(&bytes).map_err(|_| OpenIssue::Corrupt)?;
565    if transaction.subsystem().as_str() != SUBSYSTEM {
566        return Err(OpenIssue::Corrupt);
567    }
568    Ok(())
569}
570
571fn sql_issue(error: rusqlite::Error) -> OpenIssue {
572    match error.sqlite_error_code() {
573        Some(ErrorCode::DatabaseCorrupt | ErrorCode::NotADatabase) => OpenIssue::Corrupt,
574        _ => OpenIssue::Fatal(format!("SQLite is unavailable: {error}")),
575    }
576}
577
578fn remove_database(root: &Path) -> Result<(), String> {
579    for file in [
580        "profiles.sqlite3-wal",
581        "profiles.sqlite3-shm",
582        DATABASE_FILE,
583    ] {
584        match fs::remove_file(root.join(file)) {
585            Ok(()) => {}
586            Err(error) if error.kind() == ErrorKind::NotFound => {}
587            Err(error) => return Err(format!("profile database cannot be rebuilt: {error}")),
588        }
589    }
590    Ok(())
591}