Skip to main content

hennery_kernel/
hats.rs

1//! Hats (kernel spec §5; umbrella §8): the isolation boundary a session
2//! belongs to. A hat is `{id, name, colour}`; every host has a default hat,
3//! and per-host path rules (`prefix → hat`) pick another for the paths
4//! under them. The registry keeps them beside the hosts, on the same
5//! connection (`Hosts`), so a host and its default hat always change
6//! together. Every query names the database's owner (kernel spec §1).
7//!
8//! Resolution (kernel spec §5.2) is a pure function of a host's rules, its
9//! default hat and a canonical path (`resolve`): rules match by path
10//! segment, the longest prefix wins, and no match means the host's default
11//! hat. Canonicalisation itself happens on the host, where the filesystem
12//! is; here a path is only ever checked to be in canonical form
13//! (`is_canonical`), never trusted to be.
14
15use 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
22/// The colour a hat gets when none is given: slate, neither a warning nor
23/// a brand.
24pub const DEFAULT_COLOUR: &str = "#64748b";
25
26/// The most path rules one host has (plan 5a decision 6).
27pub const MAX_RULES: usize = 256;
28
29/// The longest path accepted, in bytes: Linux's `PATH_MAX`.
30pub const MAX_PATH: usize = 4096;
31
32/// The `settings` key naming the hat a newly paired host gets (plan 5a
33/// decision 2).
34pub(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/// One hat, as `GET /api/hats` lists it.
72#[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    /// `#rrggbb`, lowercase.
80    pub colour: String,
81    pub created_at: i64,
82    /// The hat newly paired hosts get as their default (kernel spec §4.1).
83    pub default_for_new_hosts: bool,
84    /// Its purge began and has not finished: it is frozen (plan 9c
85    /// decision 10c, A12).
86    pub purging: bool,
87    /// Its logo's `ETag` (`logo::Logo::etag`), if it has a logo (plan
88    /// 4d-B2). The bytes are read only by `Hosts::hat_logo`.
89    pub logo: Option<String>,
90}
91
92/// One stored path rule of a host (kernel spec §5.1).
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct PathRule {
95    pub id: String,
96    /// Canonical: absolute, no `.` or `..`, no trailing slash.
97    pub prefix: String,
98    pub hat_id: String,
99    /// The host resolved the prefix when the rule was saved. An unverified
100    /// rule is stored as typed, normalised lexically (kernel spec §5.2).
101    pub verified: bool,
102}
103
104/// A rule to store, its prefix already in canonical form.
105#[derive(Debug, Clone, PartialEq, Eq)]
106pub struct NewRule {
107    pub prefix: String,
108    pub hat_id: String,
109    pub verified: bool,
110}
111
112/// Which hat a path resolves to on a host, and the rule that decided it
113/// (`None`: no rule matched, so the host's default hat).
114#[derive(Debug, Clone, PartialEq, Eq)]
115pub struct Resolution {
116    pub hat_id: String,
117    pub rule_id: Option<String>,
118}
119
120/// The hat a session's canonical cwd gets (`Hosts::session_hat`).
121#[derive(Debug, Clone, PartialEq, Eq)]
122pub enum SessionHat {
123    Decided(Resolution),
124    /// A rule would cover the path but for case, and would give another hat
125    /// than what decided it (`near_miss`): the hat is in doubt, so the
126    /// session does not start or resume.
127    Ambiguous(PathRule),
128}
129
130/// The outcome of `Hosts::create_hat` and `Hosts::update_hat`.
131#[derive(Debug, Clone, PartialEq, Eq)]
132pub enum HatChange {
133    Done(HatRecord),
134    NotFound,
135    /// Another hat of the owner has this name, in any case.
136    NameTaken,
137    /// The hat is frozen for its purge, and cannot become the default for
138    /// new hosts (plan 9c A2); nothing changed.
139    Purging,
140    /// The request is not acceptable (why); nothing changed.
141    Invalid(String),
142}
143
144/// The outcome of `Hosts::begin_purge`.
145#[derive(Debug, Clone, PartialEq, Eq)]
146pub enum PurgeStart {
147    /// The hat is frozen (now, or by a purge before): this many of its path
148    /// rules went now.
149    Frozen { rules: u64 },
150    /// No such hat of the owner's, or its purge is done.
151    NotFound,
152    /// It is the hat new hosts get, or a host's default hat, a revoked
153    /// host's included (plan 9c decision 10a, A2): nothing changed.
154    IsDefault,
155}
156
157/// The outcome of `Hosts::replace_path_rules`.
158#[derive(Debug, Clone, PartialEq, Eq)]
159pub enum RulesChange {
160    /// The host's rules now, longest prefix first.
161    Done(Vec<PathRule>),
162    HostNotFound,
163    Invalid(String),
164}
165
166/// The outcome of `Hosts::update_host`.
167#[derive(Debug, Clone, PartialEq, Eq)]
168pub enum HostChange {
169    Done,
170    NotFound,
171    Invalid(String),
172}
173
174/// A hat's colour as given, if it is `#rrggbb`: returned lowercase. The
175/// frontend applies it as a CSS custom property, so nothing else may pass.
176pub 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
181/// Why `name` cannot name a hat, if it cannot: the rule for host names (1
182/// to 64 printable characters once trimmed, no invisible format characters).
183pub 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
187/// `path` in canonical form by its text alone (kernel spec §5.2's
188/// unverified rule): absolute, empty and `.` segments dropped, no trailing
189/// slash. Refuses a relative path, a `..` segment (the review's O1: by its
190/// text alone it can disagree with the filesystem when a parent is a
191/// symlink), a NUL or other control character, and a path longer than
192/// `MAX_PATH`. Symlinks are not resolved: only the host can do that.
193pub 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
214/// Whether `path` is already in the form `normalize_lexically` gives. A
215/// path from a host is checked with this before it is matched against any
216/// rule: the host is authenticated, but its answer is still its own words.
217pub fn is_canonical(path: &str) -> bool {
218    normalize_lexically(path).is_ok_and(|normal| normal == path)
219}
220
221/// Whether the rule `prefix` covers `path`: it is the path, or a parent of
222/// it by whole segments (`/p/acme` covers `/p/acme/x`, never
223/// `/p/acme-infra`). Both are canonical.
224///
225/// The one segment rule, shared with the host's browse fence
226/// (`hennery_proto::paths::is_within`, plan 6c's A9), so the fence and the
227/// hat rules cannot disagree.
228pub fn covers(prefix: &str, path: &str) -> bool {
229    hennery_proto::paths::is_within(path, prefix)
230}
231
232/// The hat `path` (canonical) resolves to among a host's `rules`, with
233/// `default_hat` when no rule covers it (kernel spec §5.2): the longest
234/// covering prefix wins. Two rules never share a prefix on one host.
235pub 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
250/// A rule that does not cover `path` byte for byte but would if case were
251/// ignored, that would then win over `decided` (a longer prefix than its
252/// rule's, or any prefix when no rule decided), and that names another hat
253/// (the review's P4). A case-insensitive volume is where that happens: the
254/// rule's prefix holds another case than the directory has now, because it
255/// was saved before the directory existed, or the directory was renamed
256/// since (the review's B2, which is why verified rules count too). Its
257/// sessions would fall silently to another hat (plan 5c decision 3).
258pub 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
285/// A host's rules, longest prefix first, inside the caller's lock.
286pub(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
295/// Whether `hat_id` is one of `owner`'s hats and not frozen for its purge:
296/// a frozen hat is no hat for a rule or a host's default (plan 9c decision
297/// 10c, A2).
298fn 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
310/// Whether `owner` froze `hat_id` for its purge (plan 9c decision 10c): its
311/// `purged_hats` row, which outlives the hat's (A7).
312fn 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
323/// Whether `hat_id` is the hat `owner`'s new hosts get or the default hat of
324/// any of their hosts, a revoked one's included: the hosts' foreign key
325/// holds it either way (plan 9c decision 10a, A2).
326fn 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
362/// `owner`'s hat `hat_id`, inside the caller's lock or transaction.
363pub(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
367/// Whether another of the owner's hats than `except` is named `name`,
368/// ignoring case (plan 5a decision 4).
369fn 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    /// Every hat of the owner, oldest first.
377    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    /// The hat newly paired hosts get as their default.
386    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    /// A new hat, `colour` defaulting to `DEFAULT_COLOUR`.
395    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    /// Rename a hat, change its colour, or make it the one new hosts get.
446    /// A hat stops being that default only by another taking its place, so
447    /// `default_for_new_hosts` is `Some(true)` or absent.
448    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        // Its name and colour still change; it is still listed (A12).
503        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            // Otherwise the transaction rolls back: the name and colour
525            // changes above don't apply either.
526            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    /// A host's path rules, longest prefix first; `None` for an unknown
569    /// host.
570    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    /// Replace a host's path rules with `rules`, in one transaction (kernel
579    /// spec §8: the full set). Each prefix must already be canonical and
580    /// each hat the owner's; no two rules share a prefix. `/` is refused:
581    /// it is what the host's default hat is for (the review's O2). So is a
582    /// prefix with an invisible format character, which could display as
583    /// another path (O6). A rule whose prefix was there before keeps its id.
584    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    /// The hat `path` resolves to on `host_id` (kernel spec §5.2), from
645    /// one read of the host's default hat and its rules; `None` for an
646    /// unknown host. `path` must be canonical: anything else is an error,
647    /// never a guess.
648    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    /// The hat a session whose canonical cwd is `path` gets on `host_id`
659    /// (umbrella §8.2), unless a rule that misses only by case makes it
660    /// ambiguous (`near_miss`); `None` for an unknown host. `path` must be
661    /// canonical. The host's default hat and its rules are read in one
662    /// transaction, so a rules change or a default change committed in
663    /// between cannot mix the old one with the new (plan 5a's final review).
664    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    /// Whether one of the host's rules names a hat other than its default:
674    /// rules only. This is half of whether the host is mixed (umbrella
675    /// §8.5), which the gateway's fallback needs; the other half is a live
676    /// session of another hat than the default (a default changed since it
677    /// started), which only the sessions module knows (the review's A1).
678    /// `false` for an unknown host.
679    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    /// Rename a host, or change its default hat (kernel spec §4.3), in one
690    /// transaction. The hat must be the owner's.
691    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    /// Whether `hat_id` is the hat new hosts get or a host's default hat, a
724    /// revoked host's included (plan 9c decision 10a): what a purge refuses.
725    pub fn is_default_hat(&self, hat_id: &str) -> Result<bool> {
726        is_default(&self.conn(), self.owner_id(), hat_id)
727    }
728
729    /// Whether a purge of `hat_id` began (plan 9c decision 10c): from then on
730    /// nothing starts, resumes or moves in or out of it, and it is no hat for
731    /// a rule or a host's default. Stays true once the purge is done (A7).
732    pub fn is_frozen(&self, hat_id: &str) -> Result<bool> {
733        frozen(&self.conn(), self.owner_id(), hat_id)
734    }
735
736    /// Freeze `hat_id` for its purge (kernel spec §5.5; plan 9c decision
737    /// 10c), in one transaction that re-checks it is no default (A2):
738    /// record it in `purged_hats`, unless a purge before did, and delete
739    /// every rule that names it, on every host. Again on a frozen hat it
740    /// freezes again, and takes any rule that names it, so a purge that
741    /// stopped resumes from here.
742    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        // Immediate: what is read here must still hold at the commit, and a
746        // writer on another connection (the sessions store) waits for it.
747        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        // Hat ids are unique across owners, so a row of another owner's for
760        // this id is no freeze of this hat.
761        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    /// The last step of `hat_id`'s purge (plan 9c decision 10e): delete the
774    /// hat's row, only while it is frozen; its project recents go with it
775    /// (`ON DELETE CASCADE`). Its `purged_hats` row stays (A7). `false` if
776    /// there was no frozen hat to delete. Whatever still references the hat
777    /// (a rule, a host's default, a module's row) fails it, by its foreign
778    /// key: the hooks ran first.
779    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    /// Every hat the owner purged or is purging, the oldest purge first:
789    /// what `forget_hat` tells a host after its handshake (plan 9c decision
790    /// 12, A7).
791    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    /// How many path rules name `hat_id`, and how many project recents it
799    /// has, on every host: what a purge would delete of the kernel's (plan
800    /// 9c decision 11).
801    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
811/// Resolve placement in the caller's mutation transaction. Delegated allocation
812/// and dispatch use this rather than retaining a preflight path-rule decision.
813pub 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    /// Kernel spec §11: segment match, longest prefix, default fallback.
980    #[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        // The order the rules come in does not matter.
1004        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    /// Plan 5c decision 3: a rule that misses only by case, would win, and
1013    /// names another hat is a near miss, verified or not (the review's B2:
1014    /// a directory renamed since the rule was saved); a shorter one, one
1015    /// that matches anyway, or one of the deciding hat (P4) is not.
1016    #[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        // `/users` is shorter than `/Users/me`, which decided: no doubt.
1035        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    /// The review's O1: a `..` is refused, never applied by its text.
1049    #[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    /// Plan 5a decision 3: a database as 3c left it gets one default hat
1092    /// per owner, named in `settings`, and every host keeps everything it
1093    /// had and gains that hat as its default.
1094    #[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        // The rebuilt table keeps its constraints: a default hat that is
1147        // not a hat is refused.
1148        assert!(
1149            conn.execute("UPDATE hosts SET default_hat_id = 'hat-nope' WHERE id = 'host-old'", [])
1150                .is_err()
1151        );
1152    }
1153
1154    /// The review's A3: a host's default hat, and a rule's host and hat,
1155    /// are the owner's own, by the schema itself, whatever a statement
1156    /// forgets. The colour and the verified flag are checked there too
1157    /// (O4).
1158    #[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        // The control: the same rule, all the owner's, is stored.
1194        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}