1use 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
15pub 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 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}