1use crate::hosts::{Hosts, is_format_char, is_valid_display_field};
16use crate::secret::random_bytes;
17use anyhow::Result;
18use hennery_proto::mcp_source::{McpSourceOverride, McpSourcePolicy, source_policy_for};
19use rusqlite::{OptionalExtension, TransactionBehavior, params};
20use std::collections::{BTreeMap, BTreeSet, HashMap};
21
22pub const DEFAULT_COLOUR: &str = "#64748b";
25
26pub const MAX_RULES: usize = 256;
28
29pub const MAX_PATH: usize = 4096;
31
32pub(crate) const DEFAULT_HAT_KEY: &str = "default_hat_id";
35
36fn valid_source_patch(patch: Option<&BTreeMap<String, Option<McpSourcePolicy>>>) -> bool {
37 patch.is_none_or(|patch| {
38 patch.len() <= 128 && patch.keys().all(|agent| crate::session_defaults::valid_id(agent, 128))
39 })
40}
41
42fn next_source_revision(previous: u64) -> Result<u64> {
43 let next = previous
44 .checked_add(1)
45 .filter(|n| *n <= crate::session_defaults::MAX_REVISION as u64);
46 next.ok_or_else(|| anyhow::anyhow!("MCP source revision exhausted"))
47}
48
49fn apply_source_patch(
50 settings: &mut BTreeMap<String, McpSourceOverride>,
51 fallback: McpSourcePolicy,
52 fallback_revision: u64,
53 patch: Option<&BTreeMap<String, Option<McpSourcePolicy>>>,
54) -> Result<()> {
55 for (agent, policy) in patch.into_iter().flatten() {
56 if settings.get(agent).and_then(|setting| setting.policy) == *policy {
57 continue;
58 }
59 let (_, previous) = source_policy_for(fallback, fallback_revision, settings, agent);
60 settings.insert(
61 agent.clone(),
62 McpSourceOverride {
63 policy: *policy,
64 revision: next_source_revision(previous.max(fallback_revision))?,
65 },
66 );
67 }
68 Ok(())
69}
70
71#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct HatRecord {
74 pub mcp_source_overrides: BTreeMap<String, McpSourceOverride>,
75 pub mcp_source_policy: hennery_proto::mcp_source::McpSourcePolicy,
76 pub mcp_policy_revision: u64,
77 pub id: String,
78 pub name: String,
79 pub colour: String,
81 pub created_at: i64,
82 pub default_for_new_hosts: bool,
84 pub purging: bool,
87 pub logo: Option<String>,
90}
91
92#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct PathRule {
95 pub id: String,
96 pub prefix: String,
98 pub hat_id: String,
99 pub verified: bool,
102}
103
104#[derive(Debug, Clone, PartialEq, Eq)]
106pub struct NewRule {
107 pub prefix: String,
108 pub hat_id: String,
109 pub verified: bool,
110}
111
112#[derive(Debug, Clone, PartialEq, Eq)]
115pub struct Resolution {
116 pub hat_id: String,
117 pub rule_id: Option<String>,
118}
119
120#[derive(Debug, Clone, PartialEq, Eq)]
122pub enum SessionHat {
123 Decided(Resolution),
124 Ambiguous(PathRule),
128}
129
130#[derive(Debug, Clone, PartialEq, Eq)]
132pub enum HatChange {
133 Done(HatRecord),
134 NotFound,
135 NameTaken,
137 Purging,
140 Invalid(String),
142}
143
144#[derive(Debug, Clone, PartialEq, Eq)]
146pub enum PurgeStart {
147 Frozen { rules: u64 },
150 NotFound,
152 IsDefault,
155}
156
157#[derive(Debug, Clone, PartialEq, Eq)]
159pub enum RulesChange {
160 Done(Vec<PathRule>),
162 HostNotFound,
163 Invalid(String),
164}
165
166#[derive(Debug, Clone, PartialEq, Eq)]
168pub enum HostChange {
169 Done,
170 NotFound,
171 Invalid(String),
172}
173
174pub fn parse_colour(input: &str) -> Option<String> {
177 let hex = input.strip_prefix('#')?;
178 (hex.len() == 6 && hex.bytes().all(|b| b.is_ascii_hexdigit())).then(|| format!("#{}", hex.to_ascii_lowercase()))
179}
180
181pub fn hat_name_problem(name: &str) -> Option<String> {
184 (!is_valid_display_field(name)).then(|| "a hat's name must be 1 to 64 printable characters".to_string())
185}
186
187pub fn normalize_lexically(path: &str) -> Result<String, String> {
194 if !path.starts_with('/') {
195 return Err("a path must be absolute".into());
196 }
197 if path.len() > MAX_PATH {
198 return Err(format!("a path must be at most {MAX_PATH} bytes"));
199 }
200 if path.chars().any(char::is_control) {
201 return Err("a path must not hold control characters".into());
202 }
203 let mut segments: Vec<&str> = Vec::new();
204 for segment in path.split('/') {
205 match segment {
206 "" | "." => {}
207 ".." => return Err("a path must not hold `..`; give it as the host resolves it".into()),
208 other => segments.push(other),
209 }
210 }
211 Ok(format!("/{}", segments.join("/")))
212}
213
214pub fn is_canonical(path: &str) -> bool {
218 normalize_lexically(path).is_ok_and(|normal| normal == path)
219}
220
221pub fn covers(prefix: &str, path: &str) -> bool {
229 hennery_proto::paths::is_within(path, prefix)
230}
231
232pub fn resolve(rules: &[PathRule], default_hat: &str, path: &str) -> Resolution {
236 rules
237 .iter()
238 .filter(|rule| covers(&rule.prefix, path))
239 .max_by_key(|rule| rule.prefix.len())
240 .map(|rule| Resolution {
241 hat_id: rule.hat_id.clone(),
242 rule_id: Some(rule.id.clone()),
243 })
244 .unwrap_or_else(|| Resolution {
245 hat_id: default_hat.to_string(),
246 rule_id: None,
247 })
248}
249
250pub fn near_miss<'a>(rules: &'a [PathRule], path: &str, decided: &Resolution) -> Option<&'a PathRule> {
259 let decided_len = decided
260 .rule_id
261 .as_ref()
262 .and_then(|id| rules.iter().find(|rule| &rule.id == id))
263 .map_or(0, |rule| rule.prefix.len());
264 let folded_path = path.to_lowercase();
265 rules
266 .iter()
267 .filter(|rule| rule.prefix.len() > decided_len && rule.hat_id != decided.hat_id)
268 .filter(|rule| !covers(&rule.prefix, path) && covers(&rule.prefix.to_lowercase(), &folded_path))
269 .max_by_key(|rule| rule.prefix.len())
270}
271
272fn new_id(kind: &str) -> String {
273 format!("{kind}-{}", hex::encode(random_bytes::<8>()))
274}
275
276fn read_rule(r: &rusqlite::Row<'_>) -> rusqlite::Result<PathRule> {
277 Ok(PathRule {
278 id: r.get(0)?,
279 prefix: r.get(1)?,
280 hat_id: r.get(2)?,
281 verified: r.get(3)?,
282 })
283}
284
285pub(crate) fn rules_of(conn: &rusqlite::Connection, owner: &str, host_id: &str) -> Result<Vec<PathRule>> {
287 let mut stmt = conn.prepare(
288 "SELECT id, prefix, hat_id, verified FROM hat_path_rules
289 WHERE host_id = ?1 AND owner_id = ?2 ORDER BY length(prefix) DESC, prefix",
290 )?;
291 let rows = stmt.query_map([host_id, owner], read_rule)?;
292 Ok(rows.collect::<rusqlite::Result<_>>()?)
293}
294
295fn hat_exists(conn: &rusqlite::Connection, owner: &str, hat_id: &str) -> Result<bool> {
299 Ok(conn
300 .query_row(
301 "SELECT 1 FROM hats h WHERE h.id = ?1 AND h.owner_id = ?2
302 AND NOT EXISTS (SELECT 1 FROM purged_hats p WHERE p.hat_id = h.id AND p.owner_id = ?2)",
303 [hat_id, owner],
304 |_| Ok(()),
305 )
306 .optional()?
307 .is_some())
308}
309
310fn frozen(conn: &rusqlite::Connection, owner: &str, hat_id: &str) -> Result<bool> {
313 Ok(conn
314 .query_row(
315 "SELECT 1 FROM purged_hats WHERE hat_id = ?1 AND owner_id = ?2",
316 [hat_id, owner],
317 |_| Ok(()),
318 )
319 .optional()?
320 .is_some())
321}
322
323fn is_default(conn: &rusqlite::Connection, owner: &str, hat_id: &str) -> Result<bool> {
327 Ok(conn.query_row(
328 "SELECT EXISTS (SELECT 1 FROM settings WHERE owner_id = ?2 AND key = ?3 AND value = ?1)
329 OR EXISTS (SELECT 1 FROM hosts WHERE owner_id = ?2 AND default_hat_id = ?1)",
330 params![hat_id, owner, DEFAULT_HAT_KEY],
331 |r| r.get(0),
332 )?)
333}
334
335fn hats_of(conn: &rusqlite::Connection, owner: &str) -> Result<Vec<HatRecord>> {
336 let mut stmt = conn.prepare(
337 "SELECT h.id, h.name, h.colour, h.created_at, h.id = s.value,
338 EXISTS (SELECT 1 FROM purged_hats p WHERE p.hat_id = h.id AND p.owner_id = ?1), h.logo_etag, h.mcp_source_policy, h.mcp_policy_revision, h.mcp_source_overrides
339 FROM hats h
340 LEFT JOIN settings s ON s.owner_id = h.owner_id AND s.key = ?2
341 WHERE h.owner_id = ?1 ORDER BY h.created_at, h.id",
342 )?;
343 let rows = stmt.query_map([owner, DEFAULT_HAT_KEY], |r| {
344 Ok(HatRecord {
345 id: r.get(0)?,
346 name: r.get(1)?,
347 colour: r.get(2)?,
348 created_at: r.get(3)?,
349 default_for_new_hosts: r.get::<_, Option<bool>>(4)?.unwrap_or(false),
350 purging: r.get(5)?,
351 logo: r.get(6)?,
352 mcp_source_policy: hennery_proto::mcp_source::McpSourcePolicy::parse(&r.get::<_, String>(7)?)
353 .ok_or(rusqlite::Error::InvalidQuery)?,
354 mcp_policy_revision: r.get::<_, i64>(8)? as u64,
355 mcp_source_overrides: serde_json::from_str(&r.get::<_, String>(9)?)
356 .map_err(|_| rusqlite::Error::InvalidQuery)?,
357 })
358 })?;
359 Ok(rows.collect::<rusqlite::Result<_>>()?)
360}
361
362pub(crate) fn hat_in(conn: &rusqlite::Connection, owner: &str, hat_id: &str) -> Result<Option<HatRecord>> {
364 Ok(hats_of(conn, owner)?.into_iter().find(|hat| hat.id == hat_id))
365}
366
367fn name_taken(hats: &[HatRecord], name: &str, except: Option<&str>) -> bool {
370 let name = name.trim().to_lowercase();
371 hats.iter()
372 .any(|hat| Some(hat.id.as_str()) != except && hat.name.to_lowercase() == name)
373}
374
375impl Hosts {
376 pub fn hats(&self) -> Result<Vec<HatRecord>> {
378 hats_of(&self.conn(), self.owner_id())
379 }
380
381 pub fn hat(&self, hat_id: &str) -> Result<Option<HatRecord>> {
382 Ok(self.hats()?.into_iter().find(|hat| hat.id == hat_id))
383 }
384
385 pub fn default_hat_for_new_hosts(&self) -> Result<String> {
387 Ok(self.conn().query_row(
388 "SELECT value FROM settings WHERE owner_id = ?1 AND key = ?2",
389 [self.owner_id(), DEFAULT_HAT_KEY],
390 |r| r.get(0),
391 )?)
392 }
393
394 pub fn create_hat(&self, name: &str, colour: Option<&str>, now: i64) -> Result<HatChange> {
396 self.create_hat_with_policy(name, colour, now, None)
397 }
398
399 pub fn create_hat_with_policy(
400 &self,
401 name: &str,
402 colour: Option<&str>,
403 now: i64,
404 policy: Option<hennery_proto::mcp_source::McpSourcePolicy>,
405 ) -> Result<HatChange> {
406 self.create_hat_with_sources(name, colour, now, policy, None)
407 }
408
409 pub fn create_hat_with_sources(
410 &self,
411 name: &str,
412 colour: Option<&str>,
413 now: i64,
414 policy: Option<McpSourcePolicy>,
415 overrides: Option<&BTreeMap<String, Option<McpSourcePolicy>>>,
416 ) -> Result<HatChange> {
417 if !valid_source_patch(overrides) {
418 return Ok(HatChange::Invalid(
419 "invalid MCP source agent IDs or too many overrides".into(),
420 ));
421 }
422 if let Some(problem) = hat_name_problem(name) {
423 return Ok(HatChange::Invalid(problem));
424 }
425 let Some(colour) = parse_colour(colour.unwrap_or(DEFAULT_COLOUR)) else {
426 return Ok(HatChange::Invalid("a hat's colour must be #rrggbb".into()));
427 };
428 let mut conn = self.conn();
429 let tx = conn.transaction()?;
430 if name_taken(&hats_of(&tx, self.owner_id())?, name, None) {
431 return Ok(HatChange::NameTaken);
432 }
433 let id = new_id("hat");
434 let mut settings = BTreeMap::new();
435 apply_source_patch(&mut settings, policy.unwrap_or_default(), 0, overrides)?;
436 tx.execute(
437 "INSERT INTO hats(id, owner_id, name, colour, created_at, mcp_source_policy, mcp_source_overrides) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
438 params![id, self.owner_id(), name.trim(), colour, now, policy.unwrap_or_default().as_str(), serde_json::to_string(&settings)?],
439 )?;
440 let created = hats_of(&tx, self.owner_id())?.into_iter().find(|hat| hat.id == id);
441 tx.commit()?;
442 Ok(created.map_or(HatChange::NotFound, HatChange::Done))
443 }
444
445 pub fn update_hat(
449 &self,
450 hat_id: &str,
451 name: Option<&str>,
452 colour: Option<&str>,
453 default_for_new_hosts: Option<bool>,
454 ) -> Result<HatChange> {
455 self.update_hat_with_policy(hat_id, name, colour, default_for_new_hosts, None)
456 }
457
458 pub fn update_hat_with_policy(
459 &self,
460 hat_id: &str,
461 name: Option<&str>,
462 colour: Option<&str>,
463 default_for_new_hosts: Option<bool>,
464 policy: Option<hennery_proto::mcp_source::McpSourcePolicy>,
465 ) -> Result<HatChange> {
466 self.update_hat_with_sources(hat_id, name, colour, default_for_new_hosts, policy, None)
467 }
468
469 pub fn update_hat_with_sources(
470 &self,
471 hat_id: &str,
472 name: Option<&str>,
473 colour: Option<&str>,
474 default_for_new_hosts: Option<bool>,
475 policy: Option<McpSourcePolicy>,
476 overrides: Option<&BTreeMap<String, Option<McpSourcePolicy>>>,
477 ) -> Result<HatChange> {
478 if !valid_source_patch(overrides) {
479 return Ok(HatChange::Invalid(
480 "invalid MCP source agent IDs or too many overrides".into(),
481 ));
482 }
483 if let Some(problem) = name.and_then(hat_name_problem) {
484 return Ok(HatChange::Invalid(problem));
485 }
486 let colour = match colour.map(parse_colour) {
487 Some(None) => return Ok(HatChange::Invalid("a hat's colour must be #rrggbb".into())),
488 Some(Some(colour)) => Some(colour),
489 None => None,
490 };
491 if default_for_new_hosts == Some(false) {
492 return Ok(HatChange::Invalid(
493 "make another hat the default for new hosts instead".into(),
494 ));
495 }
496 let mut conn = self.conn();
497 let tx = conn.transaction()?;
498 let hats = hats_of(&tx, self.owner_id())?;
499 let Some(hat) = hats.iter().find(|hat| hat.id == hat_id) else {
500 return Ok(HatChange::NotFound);
501 };
502 if (default_for_new_hosts == Some(true) || policy.is_some() || overrides.is_some_and(|p| !p.is_empty()))
504 && hat.purging
505 {
506 return Ok(HatChange::Purging);
507 }
508 if let Some(name) = name {
509 if name_taken(&hats, name, Some(hat_id)) {
510 return Ok(HatChange::NameTaken);
511 }
512 tx.execute(
513 "UPDATE hats SET name = ?2 WHERE id = ?1 AND owner_id = ?3",
514 params![hat_id, name.trim(), self.owner_id()],
515 )?;
516 }
517 if let Some(colour) = colour {
518 tx.execute(
519 "UPDATE hats SET colour = ?2 WHERE id = ?1 AND owner_id = ?3",
520 params![hat_id, colour, self.owner_id()],
521 )?;
522 }
523 if default_for_new_hosts == Some(true) {
524 let moved = tx.execute(
527 "UPDATE settings SET value = ?1 WHERE owner_id = ?2 AND key = ?3",
528 params![hat_id, self.owner_id(), DEFAULT_HAT_KEY],
529 )?;
530 anyhow::ensure!(moved == 1, "the owner has no default hat setting to move");
531 }
532 let mut settings = hat.mcp_source_overrides.clone();
533 let fallback = policy.unwrap_or(hat.mcp_source_policy);
534 let fallback_revision = if fallback != hat.mcp_source_policy {
535 next_source_revision(
536 settings
537 .values()
538 .map(|s| s.revision)
539 .max()
540 .unwrap_or(0)
541 .max(hat.mcp_policy_revision),
542 )?
543 } else {
544 hat.mcp_policy_revision
545 };
546 apply_source_patch(&mut settings, fallback, fallback_revision, overrides)?;
547 if settings.len() > 128 {
548 return Ok(HatChange::Invalid("too many MCP source overrides".into()));
549 }
550 if fallback != hat.mcp_source_policy || settings != hat.mcp_source_overrides {
551 tx.execute(
552 "UPDATE hats SET mcp_source_policy = ?3, mcp_policy_revision = ?4, mcp_source_overrides = ?5
553 WHERE owner_id = ?1 AND id = ?2",
554 params![
555 self.owner_id(),
556 hat_id,
557 fallback.as_str(),
558 i64::try_from(fallback_revision)?,
559 serde_json::to_string(&settings)?
560 ],
561 )?;
562 }
563 let updated = hats_of(&tx, self.owner_id())?.into_iter().find(|hat| hat.id == hat_id);
564 tx.commit()?;
565 Ok(updated.map_or(HatChange::NotFound, HatChange::Done))
566 }
567
568 pub fn path_rules(&self, host_id: &str) -> Result<Option<Vec<PathRule>>> {
571 let conn = self.conn();
572 if !host_exists(&conn, self.owner_id(), host_id)? {
573 return Ok(None);
574 }
575 Ok(Some(rules_of(&conn, self.owner_id(), host_id)?))
576 }
577
578 pub fn replace_path_rules(&self, host_id: &str, rules: &[NewRule]) -> Result<RulesChange> {
585 if rules.len() > MAX_RULES {
586 return Ok(RulesChange::Invalid(format!(
587 "a host has at most {MAX_RULES} path rules"
588 )));
589 }
590 let mut seen = BTreeSet::new();
591 for rule in rules {
592 if !is_canonical(&rule.prefix) {
593 return Ok(RulesChange::Invalid(format!(
594 "{:?} is not an absolute path in canonical form",
595 rule.prefix
596 )));
597 }
598 if rule.prefix == "/" {
599 return Ok(RulesChange::Invalid(
600 "a rule for / is the host's default hat: change that instead".into(),
601 ));
602 }
603 if rule.prefix.chars().any(is_format_char) {
604 return Ok(RulesChange::Invalid(format!(
605 "{:?} holds an invisible format character",
606 rule.prefix
607 )));
608 }
609 if !seen.insert(rule.prefix.as_str()) {
610 return Ok(RulesChange::Invalid(format!("two rules for {:?}", rule.prefix)));
611 }
612 }
613 let mut conn = self.conn();
614 let tx = conn.transaction()?;
615 if !host_exists(&tx, self.owner_id(), host_id)? {
616 return Ok(RulesChange::HostNotFound);
617 }
618 for rule in rules {
619 if !hat_exists(&tx, self.owner_id(), &rule.hat_id)? {
620 return Ok(RulesChange::Invalid(format!("no hat {:?}", rule.hat_id)));
621 }
622 }
623 let kept: HashMap<String, String> = rules_of(&tx, self.owner_id(), host_id)?
624 .into_iter()
625 .map(|rule| (rule.prefix, rule.id))
626 .collect();
627 tx.execute(
628 "DELETE FROM hat_path_rules WHERE host_id = ?1 AND owner_id = ?2",
629 [host_id, self.owner_id()],
630 )?;
631 for rule in rules {
632 let id = kept.get(&rule.prefix).cloned().unwrap_or_else(|| new_id("rule"));
633 tx.execute(
634 "INSERT INTO hat_path_rules(id, owner_id, host_id, prefix, hat_id, verified)
635 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
636 params![id, self.owner_id(), host_id, rule.prefix, rule.hat_id, rule.verified],
637 )?;
638 }
639 let stored = rules_of(&tx, self.owner_id(), host_id)?;
640 tx.commit()?;
641 Ok(RulesChange::Done(stored))
642 }
643
644 pub fn resolve_hat(&self, host_id: &str, path: &str) -> Result<Option<Resolution>> {
649 anyhow::ensure!(is_canonical(path), "not a canonical path: {path:?}");
650 let conn = self.conn();
651 let Some(default_hat) = host_default_hat(&conn, self.owner_id(), host_id)? else {
652 return Ok(None);
653 };
654 let rules = rules_of(&conn, self.owner_id(), host_id)?;
655 Ok(Some(resolve(&rules, &default_hat, path)))
656 }
657
658 pub fn session_hat(&self, host_id: &str, path: &str) -> Result<Option<SessionHat>> {
665 anyhow::ensure!(is_canonical(path), "not a canonical path: {path:?}");
666 let mut conn = self.conn();
667 let tx = conn.transaction()?;
668 let result = session_hat_in(&tx, self.owner_id(), host_id, path)?;
669 tx.commit()?;
670 Ok(result)
671 }
672
673 pub fn rules_name_other_hats(&self, host_id: &str) -> Result<bool> {
680 let conn = self.conn();
681 let Some(default_hat) = host_default_hat(&conn, self.owner_id(), host_id)? else {
682 return Ok(false);
683 };
684 Ok(rules_of(&conn, self.owner_id(), host_id)?
685 .iter()
686 .any(|rule| rule.hat_id != default_hat))
687 }
688
689 pub fn update_host(&self, host_id: &str, name: Option<&str>, default_hat_id: Option<&str>) -> Result<HostChange> {
692 if let Some(name) = name
693 && !is_valid_display_field(name)
694 {
695 return Ok(HostChange::Invalid(
696 "a host's name must be 1 to 64 printable characters".into(),
697 ));
698 }
699 let mut conn = self.conn();
700 let tx = conn.transaction()?;
701 if !host_exists(&tx, self.owner_id(), host_id)? {
702 return Ok(HostChange::NotFound);
703 }
704 if let Some(name) = name {
705 tx.execute(
706 "UPDATE hosts SET name = ?2 WHERE id = ?1 AND owner_id = ?3",
707 params![host_id, name.trim(), self.owner_id()],
708 )?;
709 }
710 if let Some(hat_id) = default_hat_id {
711 if !hat_exists(&tx, self.owner_id(), hat_id)? {
712 return Ok(HostChange::Invalid(format!("no hat {hat_id:?}")));
713 }
714 tx.execute(
715 "UPDATE hosts SET default_hat_id = ?2 WHERE id = ?1 AND owner_id = ?3",
716 params![host_id, hat_id, self.owner_id()],
717 )?;
718 }
719 tx.commit()?;
720 Ok(HostChange::Done)
721 }
722
723 pub fn is_default_hat(&self, hat_id: &str) -> Result<bool> {
726 is_default(&self.conn(), self.owner_id(), hat_id)
727 }
728
729 pub fn is_frozen(&self, hat_id: &str) -> Result<bool> {
733 frozen(&self.conn(), self.owner_id(), hat_id)
734 }
735
736 pub fn begin_purge(&self, hat_id: &str, now: i64) -> Result<PurgeStart> {
743 let owner = self.owner_id();
744 let mut conn = self.conn();
745 let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
748 if !hats_of(&tx, owner)?.iter().any(|hat| hat.id == hat_id) {
749 return Ok(PurgeStart::NotFound);
750 }
751 if is_default(&tx, owner, hat_id)? {
752 return Ok(PurgeStart::IsDefault);
753 }
754 tx.execute(
755 "INSERT INTO purged_hats(hat_id, owner_id, purged_at) VALUES (?1, ?2, ?3)
756 ON CONFLICT(hat_id) DO NOTHING",
757 params![hat_id, owner, now],
758 )?;
759 anyhow::ensure!(
762 frozen(&tx, owner, hat_id)?,
763 "another owner's purged_hats row names {hat_id}"
764 );
765 let rules = tx.execute(
766 "DELETE FROM hat_path_rules WHERE hat_id = ?1 AND owner_id = ?2",
767 [hat_id, owner],
768 )?;
769 tx.commit()?;
770 Ok(PurgeStart::Frozen { rules: rules as u64 })
771 }
772
773 pub fn finish_purge(&self, hat_id: &str) -> Result<bool> {
780 let deleted = self.conn().execute(
781 "DELETE FROM hats WHERE id = ?1 AND owner_id = ?2
782 AND EXISTS (SELECT 1 FROM purged_hats p WHERE p.hat_id = hats.id AND p.owner_id = ?2)",
783 [hat_id, self.owner_id()],
784 )?;
785 Ok(deleted == 1)
786 }
787
788 pub fn purged_hats(&self) -> Result<Vec<String>> {
792 let conn = self.conn();
793 let mut stmt = conn.prepare("SELECT hat_id FROM purged_hats WHERE owner_id = ?1 ORDER BY purged_at, hat_id")?;
794 let rows = stmt.query_map([self.owner_id()], |r| r.get(0))?;
795 Ok(rows.collect::<rusqlite::Result<_>>()?)
796 }
797
798 pub fn purge_counts(&self, hat_id: &str) -> Result<(u64, u64)> {
802 Ok(self.conn().query_row(
803 "SELECT (SELECT count(*) FROM hat_path_rules WHERE hat_id = ?1 AND owner_id = ?2),
804 (SELECT count(*) FROM project_recents WHERE hat_id = ?1 AND owner_id = ?2)",
805 [hat_id, self.owner_id()],
806 |r| Ok((r.get::<_, i64>(0)? as u64, r.get::<_, i64>(1)? as u64)),
807 )?)
808 }
809}
810
811pub fn session_hat_in(
814 conn: &rusqlite::Transaction<'_>,
815 owner: &str,
816 host_id: &str,
817 path: &str,
818) -> Result<Option<SessionHat>> {
819 anyhow::ensure!(is_canonical(path), "not a canonical path");
820 let Some(default_hat) = host_default_hat(conn, owner, host_id)? else {
821 return Ok(None);
822 };
823 let rules = rules_of(conn, owner, host_id)?;
824 let decided = resolve(&rules, &default_hat, path);
825 Ok(Some(match near_miss(&rules, path, &decided) {
826 Some(rule) => SessionHat::Ambiguous(rule.clone()),
827 None => SessionHat::Decided(decided),
828 }))
829}
830
831fn host_exists(conn: &rusqlite::Connection, owner: &str, host_id: &str) -> Result<bool> {
832 Ok(host_default_hat(conn, owner, host_id)?.is_some())
833}
834
835pub(crate) fn host_default_hat(conn: &rusqlite::Connection, owner: &str, host_id: &str) -> Result<Option<String>> {
836 Ok(conn
837 .query_row(
838 "SELECT default_hat_id FROM hosts WHERE id = ?1 AND owner_id = ?2",
839 [host_id, owner],
840 |r| r.get(0),
841 )
842 .optional()?)
843}
844
845#[cfg(test)]
846mod tests {
847 use super::*;
848
849 #[test]
850 fn agent_source_overrides_have_independent_revisions_and_monotonic_reset() {
851 use McpSourcePolicy::{Legacy, ManagedOnly, ManagedPlusNative};
852 let hosts = Hosts::open_in_memory().unwrap();
853 let patch = BTreeMap::from([("claude".into(), Some(ManagedOnly)), ("codex".into(), Some(Legacy))]);
854 let HatChange::Done(hat) = hosts
855 .create_hat_with_sources("Work", None, 1, None, Some(&patch))
856 .unwrap()
857 else {
858 panic!("created")
859 };
860 let select = |hat: &HatRecord, agent: &str| {
861 source_policy_for(
862 hat.mcp_source_policy,
863 hat.mcp_policy_revision,
864 &hat.mcp_source_overrides,
865 agent,
866 )
867 };
868 assert_eq!(select(&hat, "claude"), (ManagedOnly, 1));
869 assert_eq!(select(&hat, "codex"), (Legacy, 1));
870 assert_eq!(select(&hat, "unknown"), (Legacy, 0));
871 let edit = |agent: &str, policy| {
872 hosts
873 .update_hat_with_sources(
874 &hat.id,
875 None,
876 None,
877 None,
878 None,
879 Some(&BTreeMap::from([(agent.into(), policy)])),
880 )
881 .unwrap()
882 };
883 let HatChange::Done(changed) = edit("claude", Some(ManagedPlusNative)) else {
884 panic!("edited")
885 };
886 assert_eq!(select(&changed, "claude"), (ManagedPlusNative, 2));
887 assert_eq!(select(&changed, "codex"), (Legacy, 1));
888 let HatChange::Done(unchanged) = edit("claude", Some(ManagedPlusNative)) else {
889 panic!("no-op")
890 };
891 assert_eq!(changed, unchanged);
892 let HatChange::Done(fallback) = hosts
893 .update_hat_with_policy(&hat.id, None, None, None, Some(ManagedOnly))
894 .unwrap()
895 else {
896 panic!("fallback")
897 };
898 assert_eq!(select(&fallback, "claude"), (ManagedPlusNative, 2));
899 assert_eq!(select(&fallback, "codex"), (Legacy, 1));
900 let HatChange::Done(reset) = edit("codex", None) else {
901 panic!("reset")
902 };
903 let (policy, revision) = select(&reset, "codex");
904 assert_eq!(policy, ManagedOnly);
905 assert!(revision > fallback.mcp_policy_revision);
906 assert_eq!(reset.mcp_source_overrides["codex"].policy, None);
907 let HatChange::Done(noop_reset) = edit("codex", None) else {
908 panic!("reset no-op")
909 };
910 assert_eq!(reset, noop_reset);
911 let HatChange::Done(legacy) = edit("codex", Some(Legacy)) else {
912 panic!("explicit defaults")
913 };
914 assert_eq!(select(&legacy, "codex"), (Legacy, revision + 1));
915 assert_eq!(select(&legacy, "claude"), (ManagedPlusNative, 2));
916 }
917
918 #[test]
919 fn agent_source_overrides_preserve_owner_purge_and_invalid_id_guards() {
920 let hosts = Hosts::open_in_memory().unwrap();
921 let HatChange::Done(hat) = hosts.create_hat("Work", None, 1).unwrap() else {
922 panic!("created")
923 };
924 let patch = BTreeMap::from([("claude".into(), Some(McpSourcePolicy::ManagedOnly))]);
925 hosts.conn().execute_batch("INSERT INTO owners(id,created_at) VALUES ('foreign',1); INSERT INTO hats(id,owner_id,name,colour,created_at) VALUES ('foreign-hat','foreign','Foreign','#000000',1);").unwrap();
926 assert!(matches!(
927 hosts
928 .update_hat_with_sources("foreign-hat", None, None, None, None, Some(&patch))
929 .unwrap(),
930 HatChange::NotFound
931 ));
932 assert!(hosts.hat("foreign-hat").unwrap().is_none());
933 for id in ["", "invalid\nagent"] {
934 assert!(matches!(
935 hosts
936 .update_hat_with_sources(
937 &hat.id,
938 None,
939 None,
940 None,
941 None,
942 Some(&BTreeMap::from([(id.into(), Some(McpSourcePolicy::Legacy))]))
943 )
944 .unwrap(),
945 HatChange::Invalid(_)
946 ));
947 }
948 let oversized = (0..129)
949 .map(|n| (format!("agent-{n}"), Some(McpSourcePolicy::Legacy)))
950 .collect();
951 assert!(matches!(
952 hosts
953 .update_hat_with_sources(&hat.id, None, None, None, None, Some(&oversized))
954 .unwrap(),
955 HatChange::Invalid(_)
956 ));
957 assert!(matches!(
958 hosts.begin_purge(&hat.id, 2).unwrap(),
959 PurgeStart::Frozen { .. }
960 ));
961 assert!(matches!(
962 hosts
963 .update_hat_with_sources(&hat.id, None, None, None, None, Some(&patch))
964 .unwrap(),
965 HatChange::Purging
966 ));
967 assert!(hosts.hat(&hat.id).unwrap().unwrap().mcp_source_overrides.is_empty());
968 }
969
970 fn rule(id: &str, prefix: &str, hat: &str) -> PathRule {
971 PathRule {
972 id: id.into(),
973 prefix: prefix.into(),
974 hat_id: hat.into(),
975 verified: true,
976 }
977 }
978
979 #[test]
981 fn resolution_matches_whole_segments_and_the_longest_prefix_wins() {
982 let rules = [
983 rule("r-acme", "/p/acme", "hat-acme"),
984 rule("r-secret", "/p/acme/secret", "hat-secret"),
985 rule("r-root", "/srv", "hat-srv"),
986 ];
987 let cases = [
988 ("/p/acme", "hat-acme", Some("r-acme")),
989 ("/p/acme/x", "hat-acme", Some("r-acme")),
990 ("/p/acme-infra", "hat-default", None),
991 ("/p/acme-infra/x", "hat-default", None),
992 ("/p/acme/secret", "hat-secret", Some("r-secret")),
993 ("/p/acme/secret/deep/er", "hat-secret", Some("r-secret")),
994 ("/p/acme/secretive", "hat-acme", Some("r-acme")),
995 ("/p", "hat-default", None),
996 ("/", "hat-default", None),
997 ("/srv/x", "hat-srv", Some("r-root")),
998 ];
999 for (path, hat, rule_id) in cases {
1000 let got = resolve(&rules, "hat-default", path);
1001 assert_eq!((got.hat_id.as_str(), got.rule_id.as_deref()), (hat, rule_id), "{path}");
1002 }
1003 let mut reversed = rules.clone();
1005 reversed.reverse();
1006 assert_eq!(
1007 resolve(&reversed, "hat-default", "/p/acme/secret/x").hat_id,
1008 "hat-secret"
1009 );
1010 }
1011
1012 #[test]
1017 fn a_near_miss_is_a_rule_that_misses_only_by_case() {
1018 let rules = [
1019 PathRule {
1020 verified: false,
1021 ..rule("r-acme", "/Users/me/acme", "hat-x")
1022 },
1023 rule("r-users", "/Users/me", "hat-me"),
1024 rule("r-renamed", "/Users/me/Other", "hat-o"),
1025 rule("r-same", "/Users/me/Same", "hat-me"),
1026 rule("r-short", "/users", "hat-x"),
1027 ];
1028 let decided = |path: &str| resolve(&rules, "d", path);
1029 let miss = |path: &str| near_miss(&rules, path, &decided(path)).map(|r| r.id.clone());
1030 assert_eq!(miss("/Users/me/Acme/x").as_deref(), Some("r-acme"));
1031 assert_eq!(miss("/Users/me/acme/x"), None, "it matches as it is");
1032 assert_eq!(miss("/Users/me/other").as_deref(), Some("r-renamed"));
1033 assert_eq!(miss("/Users/me/same"), None, "the same hat decides anyway");
1034 assert_eq!(miss("/Users/me/x"), None);
1036 assert_eq!(miss("/USERS/x").as_deref(), Some("r-short"));
1037 }
1038
1039 #[test]
1040 fn a_rule_for_the_root_covers_every_path() {
1041 let rules = [rule("r", "/", "hat-root"), rule("r-p", "/p", "hat-p")];
1042 assert_eq!(resolve(&rules, "d", "/x").hat_id, "hat-root");
1043 assert_eq!(resolve(&rules, "d", "/").hat_id, "hat-root");
1044 assert_eq!(resolve(&rules, "d", "/p/x").hat_id, "hat-p");
1045 assert_eq!(resolve(&rules, "d", "/pq").hat_id, "hat-root");
1046 }
1047
1048 #[test]
1050 fn lexical_normalisation_drops_dots_and_slashes_and_refuses_what_is_not_a_path() {
1051 let cases = [
1052 ("/", "/"),
1053 ("//", "/"),
1054 ("/p/acme/", "/p/acme"),
1055 ("/p//acme", "/p/acme"),
1056 ("/p/./acme", "/p/acme"),
1057 ("/p/acme/...", "/p/acme/..."),
1058 ("/p/..acme", "/p/..acme"),
1059 ("/p/ac me", "/p/ac me"),
1060 ];
1061 for (input, normal) in cases {
1062 assert_eq!(normalize_lexically(input).as_deref(), Ok(normal), "{input:?}");
1063 }
1064 for bad in [
1065 "",
1066 "p/acme",
1067 "~/p",
1068 "./p",
1069 "/p/\0x",
1070 "/p/\nx",
1071 "/p/x/../acme",
1072 "/../p",
1073 "/p/acme/..",
1074 ] {
1075 assert!(normalize_lexically(bad).is_err(), "{bad:?}");
1076 }
1077 assert!(normalize_lexically(&format!("/{}", "a".repeat(MAX_PATH))).is_err());
1078 assert!(normalize_lexically(&format!("/{}", "a".repeat(MAX_PATH - 1))).is_ok());
1079 }
1080
1081 #[test]
1082 fn only_a_path_in_canonical_form_is_canonical() {
1083 for good in ["/", "/p", "/p/acme", "/p/acme/..."] {
1084 assert!(is_canonical(good), "{good:?}");
1085 }
1086 for bad in ["", "p", "/p/", "//p", "/p/./x", "/p/../x", "/p/..", "/p\0"] {
1087 assert!(!is_canonical(bad), "{bad:?}");
1088 }
1089 }
1090
1091 #[test]
1095 fn the_migration_gives_every_host_its_owners_default_hat() {
1096 use crate::schema::{COMPONENT, MIGRATIONS};
1097 let mut conn = crate::db::open_in_memory().unwrap();
1098 crate::db::migrate_component(&mut conn, COMPONENT, &MIGRATIONS[..5]).unwrap();
1099 let owner: String = conn.query_row("SELECT id FROM owners", [], |r| r.get(0)).unwrap();
1100 conn.execute(
1101 "INSERT INTO hosts(id, owner_id, name, public_key, platform, host_version, capabilities, created_at,
1102 last_seen_at, revoked_at)
1103 VALUES ('host-old', ?1, 'laptop', 'aa', 'linux', '0.0.0', '[\"park\"]', 1, 2, 3)",
1104 [&owner],
1105 )
1106 .unwrap();
1107 crate::db::migrate_component(&mut conn, COMPONENT, MIGRATIONS).unwrap();
1108
1109 let hats: Vec<(String, String, String)> = conn
1110 .prepare("SELECT id, owner_id, name FROM hats")
1111 .unwrap()
1112 .query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))
1113 .unwrap()
1114 .map(Result::unwrap)
1115 .collect();
1116 assert_eq!(hats.len(), 1, "{hats:?}");
1117 let (hat, hat_owner, name) = &hats[0];
1118 assert_eq!((hat_owner.as_str(), name.as_str()), (owner.as_str(), "Personal"));
1119 let default: String = conn
1120 .query_row(
1121 "SELECT value FROM settings WHERE owner_id = ?1 AND key = 'default_hat_id'",
1122 [&owner],
1123 |r| r.get(0),
1124 )
1125 .unwrap();
1126 assert_eq!(&default, hat);
1127 let host: (String, String, String, String, i64, i64, i64) = conn
1128 .query_row(
1129 "SELECT owner_id, public_key, capabilities, default_hat_id, created_at, last_seen_at, revoked_at
1130 FROM hosts WHERE id = 'host-old'",
1131 [],
1132 |r| {
1133 Ok((
1134 r.get(0)?,
1135 r.get(1)?,
1136 r.get(2)?,
1137 r.get(3)?,
1138 r.get(4)?,
1139 r.get(5)?,
1140 r.get(6)?,
1141 ))
1142 },
1143 )
1144 .unwrap();
1145 assert_eq!(host, (owner, "aa".into(), "[\"park\"]".into(), hat.clone(), 1, 2, 3));
1146 assert!(
1149 conn.execute("UPDATE hosts SET default_hat_id = 'hat-nope' WHERE id = 'host-old'", [])
1150 .is_err()
1151 );
1152 }
1153
1154 #[test]
1159 fn the_schema_keeps_hosts_hats_and_rules_within_one_owner() {
1160 let mut conn = crate::db::open_in_memory().unwrap();
1161 let owner = crate::db::kernel_owner(&mut conn).unwrap();
1162 let mine: String = conn.query_row("SELECT id FROM hats", [], |r| r.get(0)).unwrap();
1163 conn.execute_batch(&format!(
1164 "INSERT INTO owners(id, created_at) VALUES ('owner-other', 1);
1165 INSERT INTO hats(id, owner_id, name, colour, created_at) VALUES ('hat-other', 'owner-other', 'x', '#000000', 1);
1166 INSERT INTO hosts(id, owner_id, name, public_key, platform, host_version, default_hat_id, created_at)
1167 VALUES ('host-1', '{owner}', 'laptop', 'aa', 'linux', '0.0.0', '{mine}', 1);"
1168 ))
1169 .unwrap();
1170 let refused = [
1171 "UPDATE hosts SET default_hat_id = 'hat-other' WHERE id = 'host-1'".to_string(),
1172 format!(
1173 "INSERT INTO hosts(id, owner_id, name, public_key, platform, host_version, default_hat_id, created_at)
1174 VALUES ('host-2', '{owner}', 'laptop', 'bb', 'linux', '0.0.0', 'hat-other', 1)"
1175 ),
1176 format!(
1177 "INSERT INTO hat_path_rules(id, owner_id, host_id, prefix, hat_id, verified)
1178 VALUES ('rule-1', '{owner}', 'host-1', '/p', 'hat-other', 1)"
1179 ),
1180 "INSERT INTO hat_path_rules(id, owner_id, host_id, prefix, hat_id, verified)
1181 VALUES ('rule-2', 'owner-other', 'host-1', '/p', 'hat-other', 1)"
1182 .to_string(),
1183 format!("UPDATE hats SET colour = 'red' WHERE id = '{mine}'"),
1184 format!("UPDATE hats SET colour = '#ABCDEF' WHERE id = '{mine}'"),
1185 format!(
1186 "INSERT INTO hat_path_rules(id, owner_id, host_id, prefix, hat_id, verified)
1187 VALUES ('rule-3', '{owner}', 'host-1', '/p', '{mine}', 2)"
1188 ),
1189 ];
1190 for sql in &refused {
1191 assert!(conn.execute(sql, []).is_err(), "{sql}");
1192 }
1193 conn.execute(
1195 &format!(
1196 "INSERT INTO hat_path_rules(id, owner_id, host_id, prefix, hat_id, verified)
1197 VALUES ('rule-4', '{owner}', 'host-1', '/p', '{mine}', 1)"
1198 ),
1199 [],
1200 )
1201 .unwrap();
1202 }
1203
1204 #[test]
1205 fn a_colour_is_six_hex_digits_and_comes_back_lowercase() {
1206 assert_eq!(parse_colour("#A1b2C3").as_deref(), Some("#a1b2c3"));
1207 for bad in ["a1b2c3", "#a1b2c", "#a1b2c3d", "#g1b2c3", "red", "#a1b2c3;x", ""] {
1208 assert_eq!(parse_colour(bad), None, "{bad:?}");
1209 }
1210 }
1211}