Skip to main content

hennery_kernel/
session_defaults.rs

1//! Owner/hat/agent launcher preferences. All writes recheck authorization,
2//! freeze and compare-and-swap revision under the same IMMEDIATE transaction.
3use crate::{
4    hosts::Hosts,
5    operator::{Authenticated, STEP_UP_SECS},
6    secret::unix_now,
7};
8use anyhow::Result;
9use hennery_proto::{
10    frames::ConfigValue,
11    session_defaults::{HatSessionDefaults, PutHatSessionDefaults},
12};
13use rusqlite::{OptionalExtension, TransactionBehavior, params};
14
15/// JavaScript can represent every revision exactly; reserve one for increment.
16pub const MAX_REVISION: i64 = 9_007_199_254_740_990;
17
18#[derive(Debug, PartialEq)]
19pub enum DefaultsChange {
20    Done(HatSessionDefaults),
21    NotFound,
22    Purging,
23    Changed,
24    StepUpRequired,
25    Invalid,
26}
27
28pub fn valid_id(value: &str, max: usize) -> bool {
29    !value.is_empty() && value.len() <= max && !value.chars().any(char::is_control)
30}
31
32pub fn valid_defaults(agent: &str, request: &PutHatSessionDefaults) -> bool {
33    valid_id(agent, 128)
34        && (0..=MAX_REVISION).contains(&request.revision)
35        && request.config.model.as_ref().is_none_or(|v| valid_id(v, 256))
36        && request.config.mode.as_ref().is_none_or(|v| valid_id(v, 256))
37        && request.config.axes.len() <= 64
38        && request.config.axes.iter().all(|(id, value)| {
39            valid_id(id, 256)
40                && id != "model"
41                && id != "mode"
42                && match value {
43                    ConfigValue::Bool(_) => true,
44                    ConfigValue::Id(v) => valid_id(v, 256),
45                }
46        })
47}
48
49impl Hosts {
50    pub fn session_defaults(&self, hat: &str, agent: &str) -> Result<Option<HatSessionDefaults>> {
51        let conn = self.conn();
52        let row: Option<(Option<String>, Option<i64>)> = conn
53            .query_row(
54                "SELECT d.config_json, d.revision FROM hats h
55             LEFT JOIN hat_session_defaults d ON d.hat_id = h.id AND d.owner_id = h.owner_id AND d.agent = ?3
56             WHERE h.id = ?1 AND h.owner_id = ?2",
57                params![hat, self.owner_id(), agent],
58                |r| Ok((r.get(0)?, r.get(1)?)),
59            )
60            .optional()?;
61        row.map(|(json, revision)| {
62            Ok(HatSessionDefaults {
63                hat_id: hat.into(),
64                agent: agent.into(),
65                revision: revision.unwrap_or(0),
66                config: match json {
67                    Some(json) => serde_json::from_str(&json)?,
68                    None => Default::default(),
69                },
70            })
71        })
72        .transpose()
73    }
74
75    pub fn put_session_defaults(
76        &self,
77        hat: &str,
78        agent: &str,
79        request: &PutHatSessionDefaults,
80        auth: &Authenticated,
81    ) -> Result<DefaultsChange> {
82        if !valid_defaults(agent, request) {
83            return Ok(DefaultsChange::Invalid);
84        }
85        let mut conn = self.conn();
86        let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
87        let now = unix_now();
88        let authorized = auth.owner_id == self.owner_id()
89            && tx.query_row(
90                "SELECT EXISTS(SELECT 1 FROM auth_sessions WHERE id_hash = ?1 AND owner_id = ?2
91             AND expires_at > ?3 AND last_step_up_at > ?4)",
92                params![auth.session_id, self.owner_id(), now, now - STEP_UP_SECS],
93                |r| r.get::<_, bool>(0),
94            )?;
95        if !authorized {
96            return Ok(DefaultsChange::StepUpRequired);
97        }
98        let hat_state: Option<bool> = tx
99            .query_row(
100                "SELECT EXISTS(SELECT 1 FROM purged_hats p WHERE p.hat_id = h.id AND p.owner_id = ?2)
101             FROM hats h WHERE h.id = ?1 AND h.owner_id = ?2",
102                params![hat, self.owner_id()],
103                |r| r.get(0),
104            )
105            .optional()?;
106        match hat_state {
107            None => return Ok(DefaultsChange::NotFound),
108            Some(true) => return Ok(DefaultsChange::Purging),
109            Some(false) => {}
110        }
111        let revision: i64 = tx
112            .query_row(
113                "SELECT revision FROM hat_session_defaults WHERE hat_id = ?1 AND owner_id = ?2 AND agent = ?3",
114                params![hat, self.owner_id(), agent],
115                |r| r.get(0),
116            )
117            .optional()?
118            .unwrap_or(0);
119        if revision != request.revision {
120            return Ok(DefaultsChange::Changed);
121        }
122        let stored = HatSessionDefaults {
123            hat_id: hat.into(),
124            agent: agent.into(),
125            revision: revision + 1,
126            config: request.config.clone(),
127        };
128        tx.execute(
129            "INSERT INTO hat_session_defaults(owner_id, hat_id, agent, config_json, revision) VALUES (?1, ?2, ?3, ?4, ?5)
130             ON CONFLICT(owner_id, hat_id, agent) DO UPDATE SET config_json = excluded.config_json, revision = excluded.revision
131             WHERE hat_session_defaults.owner_id = ?1",
132            params![self.owner_id(), hat, agent, serde_json::to_string(&request.config)?, stored.revision],
133        )?;
134        tx.commit()?;
135        Ok(DefaultsChange::Done(stored))
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142    use crate::{db, schema};
143    use hennery_proto::frames::SessionConfig;
144    use rusqlite::Connection;
145    use std::sync::{Arc, Barrier};
146
147    struct Fixture {
148        _dir: tempfile::TempDir,
149        path: std::path::PathBuf,
150        hosts: Hosts,
151        hat: String,
152        auth: Authenticated,
153    }
154    impl Fixture {
155        fn new() -> Self {
156            let dir = tempfile::tempdir().unwrap();
157            let path = dir.path().join("hennery.db");
158            // Upgrade the previous kernel version, then open and reopen it.
159            let mut conn = Connection::open(&path).unwrap();
160            conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap();
161            db::migrate_component(
162                &mut conn,
163                schema::COMPONENT,
164                &schema::MIGRATIONS[..schema::MIGRATIONS.len() - 1],
165            )
166            .unwrap();
167            drop(conn);
168            let hosts = Hosts::open(&path).unwrap();
169            let hat = hosts.hats().unwrap()[0].id.clone();
170            let now = unix_now();
171            hosts.conn().execute("INSERT INTO auth_sessions(id_hash, owner_id, user_agent, created_at, last_seen_at, last_step_up_at, expires_at) VALUES ('auth', ?1, 'test', ?2, ?2, ?2, ?3)", params![hosts.owner_id(), now, now+3600]).unwrap();
172            let auth = Authenticated {
173                session_id: "auth".into(),
174                owner_id: hosts.owner_id().into(),
175                last_step_up_at: Some(now),
176                expires_at: now + 3600,
177                slid: false,
178            };
179            Self {
180                _dir: dir,
181                path,
182                hosts,
183                hat,
184                auth,
185            }
186        }
187        fn put(&self, revision: i64, config: SessionConfig) -> DefaultsChange {
188            self.hosts
189                .put_session_defaults(
190                    &self.hat,
191                    "custom",
192                    &PutHatSessionDefaults { revision, config },
193                    &self.auth,
194                )
195                .unwrap()
196        }
197    }
198    fn config() -> SessionConfig {
199        SessionConfig {
200            model: Some("model-a".into()),
201            mode: Some("full".into()),
202            axes: [("fast".into(), ConfigValue::Bool(false))].into(),
203        }
204    }
205
206    #[test]
207    fn upgrade_reopen_false_sparse_reset_and_revision_aba() {
208        let f = Fixture::new();
209        assert_eq!(f.hosts.session_defaults(&f.hat, "custom").unwrap().unwrap().revision, 0);
210        assert!(matches!(f.put(0, config()), DefaultsChange::Done(v) if v.config == config() && v.revision == 1));
211        let reopened = Hosts::open(&f.path).unwrap();
212        assert_eq!(
213            reopened.session_defaults(&f.hat, "custom").unwrap().unwrap().config,
214            config()
215        );
216        assert!(
217            matches!(f.put(1, SessionConfig::default()), DefaultsChange::Done(v) if v.revision == 2 && v.config.is_empty())
218        );
219        assert_eq!(f.put(0, config()), DefaultsChange::Changed);
220        assert_eq!(f.hosts.session_defaults(&f.hat, "other").unwrap().unwrap().revision, 0);
221    }
222    #[test]
223    fn ownership_freeze_cascade_and_authorization_at_write_boundary() {
224        let f = Fixture::new();
225        f.hosts.conn().execute_batch("INSERT INTO owners(id,created_at) VALUES ('foreign', 0); INSERT INTO hats(id,owner_id,name,colour,created_at) VALUES ('foreign-hat','foreign','Other','#64748b',0);").unwrap();
226        let req = PutHatSessionDefaults {
227            revision: 0,
228            config: config(),
229        };
230        assert!(f.hosts.session_defaults("foreign-hat", "custom").unwrap().is_none());
231        assert_eq!(
232            f.hosts
233                .put_session_defaults("foreign-hat", "custom", &req, &f.auth)
234                .unwrap(),
235            DefaultsChange::NotFound
236        );
237        assert_eq!(
238            f.hosts
239                .put_session_defaults("missing", "custom", &req, &f.auth)
240                .unwrap(),
241            DefaultsChange::NotFound
242        );
243        f.hosts
244            .conn()
245            .execute(
246                "INSERT INTO purged_hats(hat_id,owner_id,purged_at) VALUES (?1,?2,0)",
247                params![f.hat, f.hosts.owner_id()],
248            )
249            .unwrap();
250        assert_eq!(f.put(0, config()), DefaultsChange::Purging);
251        f.hosts
252            .conn()
253            .execute("DELETE FROM purged_hats WHERE hat_id = ?1", [&f.hat])
254            .unwrap();
255        f.hosts
256            .conn()
257            .execute(
258                "UPDATE auth_sessions SET last_step_up_at = ?1",
259                [unix_now() - STEP_UP_SECS],
260            )
261            .unwrap();
262        assert_eq!(f.put(0, config()), DefaultsChange::StepUpRequired);
263        f.hosts
264            .conn()
265            .execute(
266                "UPDATE auth_sessions SET last_step_up_at = ?1, expires_at = ?1",
267                [unix_now()],
268            )
269            .unwrap();
270        assert_eq!(f.put(0, config()), DefaultsChange::StepUpRequired);
271        f.hosts
272            .conn()
273            .execute("UPDATE auth_sessions SET expires_at = ?1", [unix_now() + 3600])
274            .unwrap();
275        assert!(matches!(f.put(0, config()), DefaultsChange::Done(_)));
276        f.hosts
277            .conn()
278            .execute_batch("DELETE FROM settings; DELETE FROM hats;")
279            .unwrap();
280        let count: i64 = f
281            .hosts
282            .conn()
283            .query_row("SELECT count(*) FROM hat_session_defaults", [], |r| r.get(0))
284            .unwrap();
285        assert_eq!(count, 0);
286        assert!(f.hosts.session_defaults(&f.hat, "custom").unwrap().is_none());
287    }
288    #[test]
289    fn only_one_revision_racer_replaces_the_record() {
290        let f = Fixture::new();
291        let barrier = Arc::new(Barrier::new(2));
292        std::thread::scope(|scope| {
293            let handles: Vec<_> = (0..2)
294                .map(|_| {
295                    let barrier = barrier.clone();
296                    let f = &f;
297                    scope.spawn(move || {
298                        let hosts = Hosts::open(&f.path).unwrap();
299                        barrier.wait();
300                        hosts
301                            .put_session_defaults(
302                                &f.hat,
303                                "custom",
304                                &PutHatSessionDefaults {
305                                    revision: 0,
306                                    config: config(),
307                                },
308                                &f.auth,
309                            )
310                            .unwrap()
311                    })
312                })
313                .collect();
314            let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
315            assert_eq!(
316                results.iter().filter(|r| matches!(r, DefaultsChange::Done(_))).count(),
317                1
318            );
319            assert_eq!(
320                results.iter().filter(|r| matches!(r, DefaultsChange::Changed)).count(),
321                1
322            );
323        });
324    }
325    #[test]
326    fn validation_bounds_and_strict_wire_shape() {
327        let mut request = PutHatSessionDefaults {
328            revision: 0,
329            config: config(),
330        };
331        assert!(valid_defaults("custom", &request));
332        for agent in ["".into(), "a".repeat(129), "a\n".into()] {
333            assert!(!valid_defaults(&agent, &request));
334        }
335        for revision in [-1, MAX_REVISION + 1] {
336            request.revision = revision;
337            assert!(!valid_defaults("custom", &request));
338        }
339        request.revision = 0;
340        for model in ["".into(), "m".repeat(257), "bad\0".into()] {
341            request.config.model = Some(model);
342            assert!(!valid_defaults("custom", &request));
343        }
344        request.config = config();
345        for mode in ["".into(), "m".repeat(257), "bad\n".into()] {
346            let mut bad = request.clone();
347            bad.config.mode = Some(mode);
348            assert!(!valid_defaults("custom", &bad));
349        }
350        for id in ["".into(), "a".repeat(257), "bad\n".into(), "model".into()] {
351            let mut bad = request.clone();
352            bad.config.axes.insert(id, ConfigValue::Bool(false));
353            assert!(!valid_defaults("custom", &bad));
354        }
355        for value in ["".into(), "v".repeat(257), "bad\n".into()] {
356            let mut bad = request.clone();
357            bad.config.axes.insert("custom".into(), ConfigValue::Id(value));
358            assert!(!valid_defaults("custom", &bad));
359        }
360        let f = Fixture::new();
361        assert_eq!(f.put(-1, config()), DefaultsChange::Invalid);
362        request.config.axes.insert("mode".into(), ConfigValue::Bool(false));
363        assert!(!valid_defaults("custom", &request));
364        request.config.axes = (0..65).map(|i| (format!("a{i}"), ConfigValue::Bool(false))).collect();
365        assert!(!valid_defaults("custom", &request));
366        for json in [
367            r#"{"revision":0,"config":{"other":true}}"#,
368            r#"{"revision":0,"config":{},"other":true}"#,
369            r#"{"revision":0,"config":{"axes":{"x":1}}}"#,
370            r#"{"revision":0.5,"config":{}}"#,
371        ] {
372            assert!(serde_json::from_str::<PutHatSessionDefaults>(json).is_err());
373        }
374        assert!(
375            serde_json::from_str::<PutHatSessionDefaults>(r#"{"revision":0,"config":{"model":null,"mode":null}}"#)
376                .unwrap()
377                .config
378                .is_empty()
379        );
380    }
381}