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 rusqlite::{OptionalExtension, TransactionBehavior, params};
19use std::collections::{BTreeSet, HashMap};
20
21/// The colour a hat gets when none is given: slate, neither a warning nor
22/// a brand.
23pub const DEFAULT_COLOUR: &str = "#64748b";
24
25/// The most path rules one host has (plan 5a decision 6).
26pub const MAX_RULES: usize = 256;
27
28/// The longest path accepted, in bytes: Linux's `PATH_MAX`.
29pub const MAX_PATH: usize = 4096;
30
31/// The `settings` key naming the hat a newly paired host gets (plan 5a
32/// decision 2).
33pub(crate) const DEFAULT_HAT_KEY: &str = "default_hat_id";
34
35/// One hat, as `GET /api/hats` lists it.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct HatRecord {
38    pub id: String,
39    pub name: String,
40    /// `#rrggbb`, lowercase.
41    pub colour: String,
42    pub created_at: i64,
43    /// The hat newly paired hosts get as their default (kernel spec §4.1).
44    pub default_for_new_hosts: bool,
45    /// Its purge began and has not finished: it is frozen (plan 9c
46    /// decision 10c, A12).
47    pub purging: bool,
48    /// Its logo's `ETag` (`logo::Logo::etag`), if it has a logo (plan
49    /// 4d-B2). The bytes are read only by `Hosts::hat_logo`.
50    pub logo: Option<String>,
51}
52
53/// One stored path rule of a host (kernel spec §5.1).
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct PathRule {
56    pub id: String,
57    /// Canonical: absolute, no `.` or `..`, no trailing slash.
58    pub prefix: String,
59    pub hat_id: String,
60    /// The host resolved the prefix when the rule was saved. An unverified
61    /// rule is stored as typed, normalised lexically (kernel spec §5.2).
62    pub verified: bool,
63}
64
65/// A rule to store, its prefix already in canonical form.
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct NewRule {
68    pub prefix: String,
69    pub hat_id: String,
70    pub verified: bool,
71}
72
73/// Which hat a path resolves to on a host, and the rule that decided it
74/// (`None`: no rule matched, so the host's default hat).
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct Resolution {
77    pub hat_id: String,
78    pub rule_id: Option<String>,
79}
80
81/// The hat a session's canonical cwd gets (`Hosts::session_hat`).
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub enum SessionHat {
84    Decided(Resolution),
85    /// A rule would cover the path but for case, and would give another hat
86    /// than what decided it (`near_miss`): the hat is in doubt, so the
87    /// session does not start or resume.
88    Ambiguous(PathRule),
89}
90
91/// The outcome of `Hosts::create_hat` and `Hosts::update_hat`.
92#[derive(Debug, Clone, PartialEq, Eq)]
93pub enum HatChange {
94    Done(HatRecord),
95    NotFound,
96    /// Another hat of the owner has this name, in any case.
97    NameTaken,
98    /// The hat is frozen for its purge, and cannot become the default for
99    /// new hosts (plan 9c A2); nothing changed.
100    Purging,
101    /// The request is not acceptable (why); nothing changed.
102    Invalid(String),
103}
104
105/// The outcome of `Hosts::begin_purge`.
106#[derive(Debug, Clone, PartialEq, Eq)]
107pub enum PurgeStart {
108    /// The hat is frozen (now, or by a purge before): this many of its path
109    /// rules went now.
110    Frozen { rules: u64 },
111    /// No such hat of the owner's, or its purge is done.
112    NotFound,
113    /// It is the hat new hosts get, or a host's default hat, a revoked
114    /// host's included (plan 9c decision 10a, A2): nothing changed.
115    IsDefault,
116}
117
118/// The outcome of `Hosts::replace_path_rules`.
119#[derive(Debug, Clone, PartialEq, Eq)]
120pub enum RulesChange {
121    /// The host's rules now, longest prefix first.
122    Done(Vec<PathRule>),
123    HostNotFound,
124    Invalid(String),
125}
126
127/// The outcome of `Hosts::update_host`.
128#[derive(Debug, Clone, PartialEq, Eq)]
129pub enum HostChange {
130    Done,
131    NotFound,
132    Invalid(String),
133}
134
135/// A hat's colour as given, if it is `#rrggbb`: returned lowercase. The
136/// frontend applies it as a CSS custom property, so nothing else may pass.
137pub fn parse_colour(input: &str) -> Option<String> {
138    let hex = input.strip_prefix('#')?;
139    (hex.len() == 6 && hex.bytes().all(|b| b.is_ascii_hexdigit())).then(|| format!("#{}", hex.to_ascii_lowercase()))
140}
141
142/// Why `name` cannot name a hat, if it cannot: the rule for host names (1
143/// to 64 printable characters once trimmed, no invisible format characters).
144pub fn hat_name_problem(name: &str) -> Option<String> {
145    (!is_valid_display_field(name)).then(|| "a hat's name must be 1 to 64 printable characters".to_string())
146}
147
148/// `path` in canonical form by its text alone (kernel spec §5.2's
149/// unverified rule): absolute, empty and `.` segments dropped, no trailing
150/// slash. Refuses a relative path, a `..` segment (the review's O1: by its
151/// text alone it can disagree with the filesystem when a parent is a
152/// symlink), a NUL or other control character, and a path longer than
153/// `MAX_PATH`. Symlinks are not resolved: only the host can do that.
154pub fn normalize_lexically(path: &str) -> Result<String, String> {
155    if !path.starts_with('/') {
156        return Err("a path must be absolute".into());
157    }
158    if path.len() > MAX_PATH {
159        return Err(format!("a path must be at most {MAX_PATH} bytes"));
160    }
161    if path.chars().any(char::is_control) {
162        return Err("a path must not hold control characters".into());
163    }
164    let mut segments: Vec<&str> = Vec::new();
165    for segment in path.split('/') {
166        match segment {
167            "" | "." => {}
168            ".." => return Err("a path must not hold `..`; give it as the host resolves it".into()),
169            other => segments.push(other),
170        }
171    }
172    Ok(format!("/{}", segments.join("/")))
173}
174
175/// Whether `path` is already in the form `normalize_lexically` gives. A
176/// path from a host is checked with this before it is matched against any
177/// rule: the host is authenticated, but its answer is still its own words.
178pub fn is_canonical(path: &str) -> bool {
179    normalize_lexically(path).is_ok_and(|normal| normal == path)
180}
181
182/// Whether the rule `prefix` covers `path`: it is the path, or a parent of
183/// it by whole segments (`/p/acme` covers `/p/acme/x`, never
184/// `/p/acme-infra`). Both are canonical.
185///
186/// The one segment rule, shared with the host's browse fence
187/// (`hennery_proto::paths::is_within`, plan 6c's A9), so the fence and the
188/// hat rules cannot disagree.
189pub fn covers(prefix: &str, path: &str) -> bool {
190    hennery_proto::paths::is_within(path, prefix)
191}
192
193/// The hat `path` (canonical) resolves to among a host's `rules`, with
194/// `default_hat` when no rule covers it (kernel spec §5.2): the longest
195/// covering prefix wins. Two rules never share a prefix on one host.
196pub fn resolve(rules: &[PathRule], default_hat: &str, path: &str) -> Resolution {
197    rules
198        .iter()
199        .filter(|rule| covers(&rule.prefix, path))
200        .max_by_key(|rule| rule.prefix.len())
201        .map(|rule| Resolution {
202            hat_id: rule.hat_id.clone(),
203            rule_id: Some(rule.id.clone()),
204        })
205        .unwrap_or_else(|| Resolution {
206            hat_id: default_hat.to_string(),
207            rule_id: None,
208        })
209}
210
211/// A rule that does not cover `path` byte for byte but would if case were
212/// ignored, that would then win over `decided` (a longer prefix than its
213/// rule's, or any prefix when no rule decided), and that names another hat
214/// (the review's P4). A case-insensitive volume is where that happens: the
215/// rule's prefix holds another case than the directory has now, because it
216/// was saved before the directory existed, or the directory was renamed
217/// since (the review's B2, which is why verified rules count too). Its
218/// sessions would fall silently to another hat (plan 5c decision 3).
219pub fn near_miss<'a>(rules: &'a [PathRule], path: &str, decided: &Resolution) -> Option<&'a PathRule> {
220    let decided_len = decided
221        .rule_id
222        .as_ref()
223        .and_then(|id| rules.iter().find(|rule| &rule.id == id))
224        .map_or(0, |rule| rule.prefix.len());
225    let folded_path = path.to_lowercase();
226    rules
227        .iter()
228        .filter(|rule| rule.prefix.len() > decided_len && rule.hat_id != decided.hat_id)
229        .filter(|rule| !covers(&rule.prefix, path) && covers(&rule.prefix.to_lowercase(), &folded_path))
230        .max_by_key(|rule| rule.prefix.len())
231}
232
233fn new_id(kind: &str) -> String {
234    format!("{kind}-{}", hex::encode(random_bytes::<8>()))
235}
236
237fn read_rule(r: &rusqlite::Row<'_>) -> rusqlite::Result<PathRule> {
238    Ok(PathRule {
239        id: r.get(0)?,
240        prefix: r.get(1)?,
241        hat_id: r.get(2)?,
242        verified: r.get(3)?,
243    })
244}
245
246/// A host's rules, longest prefix first, inside the caller's lock.
247pub(crate) fn rules_of(conn: &rusqlite::Connection, owner: &str, host_id: &str) -> Result<Vec<PathRule>> {
248    let mut stmt = conn.prepare(
249        "SELECT id, prefix, hat_id, verified FROM hat_path_rules
250         WHERE host_id = ?1 AND owner_id = ?2 ORDER BY length(prefix) DESC, prefix",
251    )?;
252    let rows = stmt.query_map([host_id, owner], read_rule)?;
253    Ok(rows.collect::<rusqlite::Result<_>>()?)
254}
255
256/// Whether `hat_id` is one of `owner`'s hats and not frozen for its purge:
257/// a frozen hat is no hat for a rule or a host's default (plan 9c decision
258/// 10c, A2).
259fn hat_exists(conn: &rusqlite::Connection, owner: &str, hat_id: &str) -> Result<bool> {
260    Ok(conn
261        .query_row(
262            "SELECT 1 FROM hats h WHERE h.id = ?1 AND h.owner_id = ?2
263                 AND NOT EXISTS (SELECT 1 FROM purged_hats p WHERE p.hat_id = h.id AND p.owner_id = ?2)",
264            [hat_id, owner],
265            |_| Ok(()),
266        )
267        .optional()?
268        .is_some())
269}
270
271/// Whether `owner` froze `hat_id` for its purge (plan 9c decision 10c): its
272/// `purged_hats` row, which outlives the hat's (A7).
273fn frozen(conn: &rusqlite::Connection, owner: &str, hat_id: &str) -> Result<bool> {
274    Ok(conn
275        .query_row(
276            "SELECT 1 FROM purged_hats WHERE hat_id = ?1 AND owner_id = ?2",
277            [hat_id, owner],
278            |_| Ok(()),
279        )
280        .optional()?
281        .is_some())
282}
283
284/// Whether `hat_id` is the hat `owner`'s new hosts get or the default hat of
285/// any of their hosts, a revoked one's included: the hosts' foreign key
286/// holds it either way (plan 9c decision 10a, A2).
287fn is_default(conn: &rusqlite::Connection, owner: &str, hat_id: &str) -> Result<bool> {
288    Ok(conn.query_row(
289        "SELECT EXISTS (SELECT 1 FROM settings WHERE owner_id = ?2 AND key = ?3 AND value = ?1)
290             OR EXISTS (SELECT 1 FROM hosts WHERE owner_id = ?2 AND default_hat_id = ?1)",
291        params![hat_id, owner, DEFAULT_HAT_KEY],
292        |r| r.get(0),
293    )?)
294}
295
296fn hats_of(conn: &rusqlite::Connection, owner: &str) -> Result<Vec<HatRecord>> {
297    let mut stmt = conn.prepare(
298        "SELECT h.id, h.name, h.colour, h.created_at, h.id = s.value,
299                EXISTS (SELECT 1 FROM purged_hats p WHERE p.hat_id = h.id AND p.owner_id = ?1), h.logo_etag
300         FROM hats h
301         LEFT JOIN settings s ON s.owner_id = h.owner_id AND s.key = ?2
302         WHERE h.owner_id = ?1 ORDER BY h.created_at, h.id",
303    )?;
304    let rows = stmt.query_map([owner, DEFAULT_HAT_KEY], |r| {
305        Ok(HatRecord {
306            id: r.get(0)?,
307            name: r.get(1)?,
308            colour: r.get(2)?,
309            created_at: r.get(3)?,
310            default_for_new_hosts: r.get::<_, Option<bool>>(4)?.unwrap_or(false),
311            purging: r.get(5)?,
312            logo: r.get(6)?,
313        })
314    })?;
315    Ok(rows.collect::<rusqlite::Result<_>>()?)
316}
317
318/// `owner`'s hat `hat_id`, inside the caller's lock or transaction.
319pub(crate) fn hat_in(conn: &rusqlite::Connection, owner: &str, hat_id: &str) -> Result<Option<HatRecord>> {
320    Ok(hats_of(conn, owner)?.into_iter().find(|hat| hat.id == hat_id))
321}
322
323/// Whether another of the owner's hats than `except` is named `name`,
324/// ignoring case (plan 5a decision 4).
325fn name_taken(hats: &[HatRecord], name: &str, except: Option<&str>) -> bool {
326    let name = name.trim().to_lowercase();
327    hats.iter()
328        .any(|hat| Some(hat.id.as_str()) != except && hat.name.to_lowercase() == name)
329}
330
331impl Hosts {
332    /// Every hat of the owner, oldest first.
333    pub fn hats(&self) -> Result<Vec<HatRecord>> {
334        hats_of(&self.conn(), self.owner_id())
335    }
336
337    pub fn hat(&self, hat_id: &str) -> Result<Option<HatRecord>> {
338        Ok(self.hats()?.into_iter().find(|hat| hat.id == hat_id))
339    }
340
341    /// The hat newly paired hosts get as their default.
342    pub fn default_hat_for_new_hosts(&self) -> Result<String> {
343        Ok(self.conn().query_row(
344            "SELECT value FROM settings WHERE owner_id = ?1 AND key = ?2",
345            [self.owner_id(), DEFAULT_HAT_KEY],
346            |r| r.get(0),
347        )?)
348    }
349
350    /// A new hat, `colour` defaulting to `DEFAULT_COLOUR`.
351    pub fn create_hat(&self, name: &str, colour: Option<&str>, now: i64) -> Result<HatChange> {
352        if let Some(problem) = hat_name_problem(name) {
353            return Ok(HatChange::Invalid(problem));
354        }
355        let Some(colour) = parse_colour(colour.unwrap_or(DEFAULT_COLOUR)) else {
356            return Ok(HatChange::Invalid("a hat's colour must be #rrggbb".into()));
357        };
358        let mut conn = self.conn();
359        let tx = conn.transaction()?;
360        if name_taken(&hats_of(&tx, self.owner_id())?, name, None) {
361            return Ok(HatChange::NameTaken);
362        }
363        let id = new_id("hat");
364        tx.execute(
365            "INSERT INTO hats(id, owner_id, name, colour, created_at) VALUES (?1, ?2, ?3, ?4, ?5)",
366            params![id, self.owner_id(), name.trim(), colour, now],
367        )?;
368        let created = hats_of(&tx, self.owner_id())?.into_iter().find(|hat| hat.id == id);
369        tx.commit()?;
370        Ok(created.map_or(HatChange::NotFound, HatChange::Done))
371    }
372
373    /// Rename a hat, change its colour, or make it the one new hosts get.
374    /// A hat stops being that default only by another taking its place, so
375    /// `default_for_new_hosts` is `Some(true)` or absent.
376    pub fn update_hat(
377        &self,
378        hat_id: &str,
379        name: Option<&str>,
380        colour: Option<&str>,
381        default_for_new_hosts: Option<bool>,
382    ) -> Result<HatChange> {
383        if let Some(problem) = name.and_then(hat_name_problem) {
384            return Ok(HatChange::Invalid(problem));
385        }
386        let colour = match colour.map(parse_colour) {
387            Some(None) => return Ok(HatChange::Invalid("a hat's colour must be #rrggbb".into())),
388            Some(Some(colour)) => Some(colour),
389            None => None,
390        };
391        if default_for_new_hosts == Some(false) {
392            return Ok(HatChange::Invalid(
393                "make another hat the default for new hosts instead".into(),
394            ));
395        }
396        let mut conn = self.conn();
397        let tx = conn.transaction()?;
398        let hats = hats_of(&tx, self.owner_id())?;
399        let Some(hat) = hats.iter().find(|hat| hat.id == hat_id) else {
400            return Ok(HatChange::NotFound);
401        };
402        // Its name and colour still change; it is still listed (A12).
403        if default_for_new_hosts == Some(true) && hat.purging {
404            return Ok(HatChange::Purging);
405        }
406        if let Some(name) = name {
407            if name_taken(&hats, name, Some(hat_id)) {
408                return Ok(HatChange::NameTaken);
409            }
410            tx.execute(
411                "UPDATE hats SET name = ?2 WHERE id = ?1 AND owner_id = ?3",
412                params![hat_id, name.trim(), self.owner_id()],
413            )?;
414        }
415        if let Some(colour) = colour {
416            tx.execute(
417                "UPDATE hats SET colour = ?2 WHERE id = ?1 AND owner_id = ?3",
418                params![hat_id, colour, self.owner_id()],
419            )?;
420        }
421        if default_for_new_hosts == Some(true) {
422            // Otherwise the transaction rolls back: the name and colour
423            // changes above don't apply either.
424            let moved = tx.execute(
425                "UPDATE settings SET value = ?1 WHERE owner_id = ?2 AND key = ?3",
426                params![hat_id, self.owner_id(), DEFAULT_HAT_KEY],
427            )?;
428            anyhow::ensure!(moved == 1, "the owner has no default hat setting to move");
429        }
430        let updated = hats_of(&tx, self.owner_id())?.into_iter().find(|hat| hat.id == hat_id);
431        tx.commit()?;
432        Ok(updated.map_or(HatChange::NotFound, HatChange::Done))
433    }
434
435    /// A host's path rules, longest prefix first; `None` for an unknown
436    /// host.
437    pub fn path_rules(&self, host_id: &str) -> Result<Option<Vec<PathRule>>> {
438        let conn = self.conn();
439        if !host_exists(&conn, self.owner_id(), host_id)? {
440            return Ok(None);
441        }
442        Ok(Some(rules_of(&conn, self.owner_id(), host_id)?))
443    }
444
445    /// Replace a host's path rules with `rules`, in one transaction (kernel
446    /// spec §8: the full set). Each prefix must already be canonical and
447    /// each hat the owner's; no two rules share a prefix. `/` is refused:
448    /// it is what the host's default hat is for (the review's O2). So is a
449    /// prefix with an invisible format character, which could display as
450    /// another path (O6). A rule whose prefix was there before keeps its id.
451    pub fn replace_path_rules(&self, host_id: &str, rules: &[NewRule]) -> Result<RulesChange> {
452        if rules.len() > MAX_RULES {
453            return Ok(RulesChange::Invalid(format!(
454                "a host has at most {MAX_RULES} path rules"
455            )));
456        }
457        let mut seen = BTreeSet::new();
458        for rule in rules {
459            if !is_canonical(&rule.prefix) {
460                return Ok(RulesChange::Invalid(format!(
461                    "{:?} is not an absolute path in canonical form",
462                    rule.prefix
463                )));
464            }
465            if rule.prefix == "/" {
466                return Ok(RulesChange::Invalid(
467                    "a rule for / is the host's default hat: change that instead".into(),
468                ));
469            }
470            if rule.prefix.chars().any(is_format_char) {
471                return Ok(RulesChange::Invalid(format!(
472                    "{:?} holds an invisible format character",
473                    rule.prefix
474                )));
475            }
476            if !seen.insert(rule.prefix.as_str()) {
477                return Ok(RulesChange::Invalid(format!("two rules for {:?}", rule.prefix)));
478            }
479        }
480        let mut conn = self.conn();
481        let tx = conn.transaction()?;
482        if !host_exists(&tx, self.owner_id(), host_id)? {
483            return Ok(RulesChange::HostNotFound);
484        }
485        for rule in rules {
486            if !hat_exists(&tx, self.owner_id(), &rule.hat_id)? {
487                return Ok(RulesChange::Invalid(format!("no hat {:?}", rule.hat_id)));
488            }
489        }
490        let kept: HashMap<String, String> = rules_of(&tx, self.owner_id(), host_id)?
491            .into_iter()
492            .map(|rule| (rule.prefix, rule.id))
493            .collect();
494        tx.execute(
495            "DELETE FROM hat_path_rules WHERE host_id = ?1 AND owner_id = ?2",
496            [host_id, self.owner_id()],
497        )?;
498        for rule in rules {
499            let id = kept.get(&rule.prefix).cloned().unwrap_or_else(|| new_id("rule"));
500            tx.execute(
501                "INSERT INTO hat_path_rules(id, owner_id, host_id, prefix, hat_id, verified)
502                 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
503                params![id, self.owner_id(), host_id, rule.prefix, rule.hat_id, rule.verified],
504            )?;
505        }
506        let stored = rules_of(&tx, self.owner_id(), host_id)?;
507        tx.commit()?;
508        Ok(RulesChange::Done(stored))
509    }
510
511    /// The hat `path` resolves to on `host_id` (kernel spec §5.2), from
512    /// one read of the host's default hat and its rules; `None` for an
513    /// unknown host. `path` must be canonical: anything else is an error,
514    /// never a guess.
515    pub fn resolve_hat(&self, host_id: &str, path: &str) -> Result<Option<Resolution>> {
516        anyhow::ensure!(is_canonical(path), "not a canonical path: {path:?}");
517        let conn = self.conn();
518        let Some(default_hat) = host_default_hat(&conn, self.owner_id(), host_id)? else {
519            return Ok(None);
520        };
521        let rules = rules_of(&conn, self.owner_id(), host_id)?;
522        Ok(Some(resolve(&rules, &default_hat, path)))
523    }
524
525    /// The hat a session whose canonical cwd is `path` gets on `host_id`
526    /// (umbrella §8.2), unless a rule that misses only by case makes it
527    /// ambiguous (`near_miss`); `None` for an unknown host. `path` must be
528    /// canonical. The host's default hat and its rules are read in one
529    /// transaction, so a rules change or a default change committed in
530    /// between cannot mix the old one with the new (plan 5a's final review).
531    pub fn session_hat(&self, host_id: &str, path: &str) -> Result<Option<SessionHat>> {
532        anyhow::ensure!(is_canonical(path), "not a canonical path: {path:?}");
533        let mut conn = self.conn();
534        let tx = conn.transaction()?;
535        let result = session_hat_in(&tx, self.owner_id(), host_id, path)?;
536        tx.commit()?;
537        Ok(result)
538    }
539
540    /// Whether one of the host's rules names a hat other than its default:
541    /// rules only. This is half of whether the host is mixed (umbrella
542    /// §8.5), which the gateway's fallback needs; the other half is a live
543    /// session of another hat than the default (a default changed since it
544    /// started), which only the sessions module knows (the review's A1).
545    /// `false` for an unknown host.
546    pub fn rules_name_other_hats(&self, host_id: &str) -> Result<bool> {
547        let conn = self.conn();
548        let Some(default_hat) = host_default_hat(&conn, self.owner_id(), host_id)? else {
549            return Ok(false);
550        };
551        Ok(rules_of(&conn, self.owner_id(), host_id)?
552            .iter()
553            .any(|rule| rule.hat_id != default_hat))
554    }
555
556    /// Rename a host, or change its default hat (kernel spec §4.3), in one
557    /// transaction. The hat must be the owner's.
558    pub fn update_host(&self, host_id: &str, name: Option<&str>, default_hat_id: Option<&str>) -> Result<HostChange> {
559        if let Some(name) = name
560            && !is_valid_display_field(name)
561        {
562            return Ok(HostChange::Invalid(
563                "a host's name must be 1 to 64 printable characters".into(),
564            ));
565        }
566        let mut conn = self.conn();
567        let tx = conn.transaction()?;
568        if !host_exists(&tx, self.owner_id(), host_id)? {
569            return Ok(HostChange::NotFound);
570        }
571        if let Some(name) = name {
572            tx.execute(
573                "UPDATE hosts SET name = ?2 WHERE id = ?1 AND owner_id = ?3",
574                params![host_id, name.trim(), self.owner_id()],
575            )?;
576        }
577        if let Some(hat_id) = default_hat_id {
578            if !hat_exists(&tx, self.owner_id(), hat_id)? {
579                return Ok(HostChange::Invalid(format!("no hat {hat_id:?}")));
580            }
581            tx.execute(
582                "UPDATE hosts SET default_hat_id = ?2 WHERE id = ?1 AND owner_id = ?3",
583                params![host_id, hat_id, self.owner_id()],
584            )?;
585        }
586        tx.commit()?;
587        Ok(HostChange::Done)
588    }
589
590    /// Whether `hat_id` is the hat new hosts get or a host's default hat, a
591    /// revoked host's included (plan 9c decision 10a): what a purge refuses.
592    pub fn is_default_hat(&self, hat_id: &str) -> Result<bool> {
593        is_default(&self.conn(), self.owner_id(), hat_id)
594    }
595
596    /// Whether a purge of `hat_id` began (plan 9c decision 10c): from then on
597    /// nothing starts, resumes or moves in or out of it, and it is no hat for
598    /// a rule or a host's default. Stays true once the purge is done (A7).
599    pub fn is_frozen(&self, hat_id: &str) -> Result<bool> {
600        frozen(&self.conn(), self.owner_id(), hat_id)
601    }
602
603    /// Freeze `hat_id` for its purge (kernel spec §5.5; plan 9c decision
604    /// 10c), in one transaction that re-checks it is no default (A2):
605    /// record it in `purged_hats`, unless a purge before did, and delete
606    /// every rule that names it, on every host. Again on a frozen hat it
607    /// freezes again, and takes any rule that names it, so a purge that
608    /// stopped resumes from here.
609    pub fn begin_purge(&self, hat_id: &str, now: i64) -> Result<PurgeStart> {
610        let owner = self.owner_id();
611        let mut conn = self.conn();
612        // Immediate: what is read here must still hold at the commit, and a
613        // writer on another connection (the sessions store) waits for it.
614        let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
615        if !hats_of(&tx, owner)?.iter().any(|hat| hat.id == hat_id) {
616            return Ok(PurgeStart::NotFound);
617        }
618        if is_default(&tx, owner, hat_id)? {
619            return Ok(PurgeStart::IsDefault);
620        }
621        tx.execute(
622            "INSERT INTO purged_hats(hat_id, owner_id, purged_at) VALUES (?1, ?2, ?3)
623             ON CONFLICT(hat_id) DO NOTHING",
624            params![hat_id, owner, now],
625        )?;
626        // Hat ids are unique across owners, so a row of another owner's for
627        // this id is no freeze of this hat.
628        anyhow::ensure!(
629            frozen(&tx, owner, hat_id)?,
630            "another owner's purged_hats row names {hat_id}"
631        );
632        let rules = tx.execute(
633            "DELETE FROM hat_path_rules WHERE hat_id = ?1 AND owner_id = ?2",
634            [hat_id, owner],
635        )?;
636        tx.commit()?;
637        Ok(PurgeStart::Frozen { rules: rules as u64 })
638    }
639
640    /// The last step of `hat_id`'s purge (plan 9c decision 10e): delete the
641    /// hat's row, only while it is frozen; its project recents go with it
642    /// (`ON DELETE CASCADE`). Its `purged_hats` row stays (A7). `false` if
643    /// there was no frozen hat to delete. Whatever still references the hat
644    /// (a rule, a host's default, a module's row) fails it, by its foreign
645    /// key: the hooks ran first.
646    pub fn finish_purge(&self, hat_id: &str) -> Result<bool> {
647        let deleted = self.conn().execute(
648            "DELETE FROM hats WHERE id = ?1 AND owner_id = ?2
649                 AND EXISTS (SELECT 1 FROM purged_hats p WHERE p.hat_id = hats.id AND p.owner_id = ?2)",
650            [hat_id, self.owner_id()],
651        )?;
652        Ok(deleted == 1)
653    }
654
655    /// Every hat the owner purged or is purging, the oldest purge first:
656    /// what `forget_hat` tells a host after its handshake (plan 9c decision
657    /// 12, A7).
658    pub fn purged_hats(&self) -> Result<Vec<String>> {
659        let conn = self.conn();
660        let mut stmt = conn.prepare("SELECT hat_id FROM purged_hats WHERE owner_id = ?1 ORDER BY purged_at, hat_id")?;
661        let rows = stmt.query_map([self.owner_id()], |r| r.get(0))?;
662        Ok(rows.collect::<rusqlite::Result<_>>()?)
663    }
664
665    /// How many path rules name `hat_id`, and how many project recents it
666    /// has, on every host: what a purge would delete of the kernel's (plan
667    /// 9c decision 11).
668    pub fn purge_counts(&self, hat_id: &str) -> Result<(u64, u64)> {
669        Ok(self.conn().query_row(
670            "SELECT (SELECT count(*) FROM hat_path_rules WHERE hat_id = ?1 AND owner_id = ?2),
671                    (SELECT count(*) FROM project_recents WHERE hat_id = ?1 AND owner_id = ?2)",
672            [hat_id, self.owner_id()],
673            |r| Ok((r.get::<_, i64>(0)? as u64, r.get::<_, i64>(1)? as u64)),
674        )?)
675    }
676}
677
678/// Resolve placement in the caller's mutation transaction. Delegated allocation
679/// and dispatch use this rather than retaining a preflight path-rule decision.
680pub fn session_hat_in(
681    conn: &rusqlite::Transaction<'_>,
682    owner: &str,
683    host_id: &str,
684    path: &str,
685) -> Result<Option<SessionHat>> {
686    anyhow::ensure!(is_canonical(path), "not a canonical path");
687    let Some(default_hat) = host_default_hat(conn, owner, host_id)? else {
688        return Ok(None);
689    };
690    let rules = rules_of(conn, owner, host_id)?;
691    let decided = resolve(&rules, &default_hat, path);
692    Ok(Some(match near_miss(&rules, path, &decided) {
693        Some(rule) => SessionHat::Ambiguous(rule.clone()),
694        None => SessionHat::Decided(decided),
695    }))
696}
697
698fn host_exists(conn: &rusqlite::Connection, owner: &str, host_id: &str) -> Result<bool> {
699    Ok(host_default_hat(conn, owner, host_id)?.is_some())
700}
701
702pub(crate) fn host_default_hat(conn: &rusqlite::Connection, owner: &str, host_id: &str) -> Result<Option<String>> {
703    Ok(conn
704        .query_row(
705            "SELECT default_hat_id FROM hosts WHERE id = ?1 AND owner_id = ?2",
706            [host_id, owner],
707            |r| r.get(0),
708        )
709        .optional()?)
710}
711
712#[cfg(test)]
713mod tests {
714    use super::*;
715
716    fn rule(id: &str, prefix: &str, hat: &str) -> PathRule {
717        PathRule {
718            id: id.into(),
719            prefix: prefix.into(),
720            hat_id: hat.into(),
721            verified: true,
722        }
723    }
724
725    /// Kernel spec §11: segment match, longest prefix, default fallback.
726    #[test]
727    fn resolution_matches_whole_segments_and_the_longest_prefix_wins() {
728        let rules = [
729            rule("r-acme", "/p/acme", "hat-acme"),
730            rule("r-secret", "/p/acme/secret", "hat-secret"),
731            rule("r-root", "/srv", "hat-srv"),
732        ];
733        let cases = [
734            ("/p/acme", "hat-acme", Some("r-acme")),
735            ("/p/acme/x", "hat-acme", Some("r-acme")),
736            ("/p/acme-infra", "hat-default", None),
737            ("/p/acme-infra/x", "hat-default", None),
738            ("/p/acme/secret", "hat-secret", Some("r-secret")),
739            ("/p/acme/secret/deep/er", "hat-secret", Some("r-secret")),
740            ("/p/acme/secretive", "hat-acme", Some("r-acme")),
741            ("/p", "hat-default", None),
742            ("/", "hat-default", None),
743            ("/srv/x", "hat-srv", Some("r-root")),
744        ];
745        for (path, hat, rule_id) in cases {
746            let got = resolve(&rules, "hat-default", path);
747            assert_eq!((got.hat_id.as_str(), got.rule_id.as_deref()), (hat, rule_id), "{path}");
748        }
749        // The order the rules come in does not matter.
750        let mut reversed = rules.clone();
751        reversed.reverse();
752        assert_eq!(
753            resolve(&reversed, "hat-default", "/p/acme/secret/x").hat_id,
754            "hat-secret"
755        );
756    }
757
758    /// Plan 5c decision 3: a rule that misses only by case, would win, and
759    /// names another hat is a near miss, verified or not (the review's B2:
760    /// a directory renamed since the rule was saved); a shorter one, one
761    /// that matches anyway, or one of the deciding hat (P4) is not.
762    #[test]
763    fn a_near_miss_is_a_rule_that_misses_only_by_case() {
764        let rules = [
765            PathRule {
766                verified: false,
767                ..rule("r-acme", "/Users/me/acme", "hat-x")
768            },
769            rule("r-users", "/Users/me", "hat-me"),
770            rule("r-renamed", "/Users/me/Other", "hat-o"),
771            rule("r-same", "/Users/me/Same", "hat-me"),
772            rule("r-short", "/users", "hat-x"),
773        ];
774        let decided = |path: &str| resolve(&rules, "d", path);
775        let miss = |path: &str| near_miss(&rules, path, &decided(path)).map(|r| r.id.clone());
776        assert_eq!(miss("/Users/me/Acme/x").as_deref(), Some("r-acme"));
777        assert_eq!(miss("/Users/me/acme/x"), None, "it matches as it is");
778        assert_eq!(miss("/Users/me/other").as_deref(), Some("r-renamed"));
779        assert_eq!(miss("/Users/me/same"), None, "the same hat decides anyway");
780        // `/users` is shorter than `/Users/me`, which decided: no doubt.
781        assert_eq!(miss("/Users/me/x"), None);
782        assert_eq!(miss("/USERS/x").as_deref(), Some("r-short"));
783    }
784
785    #[test]
786    fn a_rule_for_the_root_covers_every_path() {
787        let rules = [rule("r", "/", "hat-root"), rule("r-p", "/p", "hat-p")];
788        assert_eq!(resolve(&rules, "d", "/x").hat_id, "hat-root");
789        assert_eq!(resolve(&rules, "d", "/").hat_id, "hat-root");
790        assert_eq!(resolve(&rules, "d", "/p/x").hat_id, "hat-p");
791        assert_eq!(resolve(&rules, "d", "/pq").hat_id, "hat-root");
792    }
793
794    /// The review's O1: a `..` is refused, never applied by its text.
795    #[test]
796    fn lexical_normalisation_drops_dots_and_slashes_and_refuses_what_is_not_a_path() {
797        let cases = [
798            ("/", "/"),
799            ("//", "/"),
800            ("/p/acme/", "/p/acme"),
801            ("/p//acme", "/p/acme"),
802            ("/p/./acme", "/p/acme"),
803            ("/p/acme/...", "/p/acme/..."),
804            ("/p/..acme", "/p/..acme"),
805            ("/p/ac me", "/p/ac me"),
806        ];
807        for (input, normal) in cases {
808            assert_eq!(normalize_lexically(input).as_deref(), Ok(normal), "{input:?}");
809        }
810        for bad in [
811            "",
812            "p/acme",
813            "~/p",
814            "./p",
815            "/p/\0x",
816            "/p/\nx",
817            "/p/x/../acme",
818            "/../p",
819            "/p/acme/..",
820        ] {
821            assert!(normalize_lexically(bad).is_err(), "{bad:?}");
822        }
823        assert!(normalize_lexically(&format!("/{}", "a".repeat(MAX_PATH))).is_err());
824        assert!(normalize_lexically(&format!("/{}", "a".repeat(MAX_PATH - 1))).is_ok());
825    }
826
827    #[test]
828    fn only_a_path_in_canonical_form_is_canonical() {
829        for good in ["/", "/p", "/p/acme", "/p/acme/..."] {
830            assert!(is_canonical(good), "{good:?}");
831        }
832        for bad in ["", "p", "/p/", "//p", "/p/./x", "/p/../x", "/p/..", "/p\0"] {
833            assert!(!is_canonical(bad), "{bad:?}");
834        }
835    }
836
837    /// Plan 5a decision 3: a database as 3c left it gets one default hat
838    /// per owner, named in `settings`, and every host keeps everything it
839    /// had and gains that hat as its default.
840    #[test]
841    fn the_migration_gives_every_host_its_owners_default_hat() {
842        use crate::schema::{COMPONENT, MIGRATIONS};
843        let mut conn = crate::db::open_in_memory().unwrap();
844        crate::db::migrate_component(&mut conn, COMPONENT, &MIGRATIONS[..5]).unwrap();
845        let owner: String = conn.query_row("SELECT id FROM owners", [], |r| r.get(0)).unwrap();
846        conn.execute(
847            "INSERT INTO hosts(id, owner_id, name, public_key, platform, host_version, capabilities, created_at,
848                               last_seen_at, revoked_at)
849             VALUES ('host-old', ?1, 'laptop', 'aa', 'linux', '0.0.0', '[\"park\"]', 1, 2, 3)",
850            [&owner],
851        )
852        .unwrap();
853        crate::db::migrate_component(&mut conn, COMPONENT, MIGRATIONS).unwrap();
854
855        let hats: Vec<(String, String, String)> = conn
856            .prepare("SELECT id, owner_id, name FROM hats")
857            .unwrap()
858            .query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))
859            .unwrap()
860            .map(Result::unwrap)
861            .collect();
862        assert_eq!(hats.len(), 1, "{hats:?}");
863        let (hat, hat_owner, name) = &hats[0];
864        assert_eq!((hat_owner.as_str(), name.as_str()), (owner.as_str(), "Personal"));
865        let default: String = conn
866            .query_row(
867                "SELECT value FROM settings WHERE owner_id = ?1 AND key = 'default_hat_id'",
868                [&owner],
869                |r| r.get(0),
870            )
871            .unwrap();
872        assert_eq!(&default, hat);
873        let host: (String, String, String, String, i64, i64, i64) = conn
874            .query_row(
875                "SELECT owner_id, public_key, capabilities, default_hat_id, created_at, last_seen_at, revoked_at
876                 FROM hosts WHERE id = 'host-old'",
877                [],
878                |r| {
879                    Ok((
880                        r.get(0)?,
881                        r.get(1)?,
882                        r.get(2)?,
883                        r.get(3)?,
884                        r.get(4)?,
885                        r.get(5)?,
886                        r.get(6)?,
887                    ))
888                },
889            )
890            .unwrap();
891        assert_eq!(host, (owner, "aa".into(), "[\"park\"]".into(), hat.clone(), 1, 2, 3));
892        // The rebuilt table keeps its constraints: a default hat that is
893        // not a hat is refused.
894        assert!(
895            conn.execute("UPDATE hosts SET default_hat_id = 'hat-nope' WHERE id = 'host-old'", [])
896                .is_err()
897        );
898    }
899
900    /// The review's A3: a host's default hat, and a rule's host and hat,
901    /// are the owner's own, by the schema itself, whatever a statement
902    /// forgets. The colour and the verified flag are checked there too
903    /// (O4).
904    #[test]
905    fn the_schema_keeps_hosts_hats_and_rules_within_one_owner() {
906        let mut conn = crate::db::open_in_memory().unwrap();
907        let owner = crate::db::kernel_owner(&mut conn).unwrap();
908        let mine: String = conn.query_row("SELECT id FROM hats", [], |r| r.get(0)).unwrap();
909        conn.execute_batch(&format!(
910            "INSERT INTO owners(id, created_at) VALUES ('owner-other', 1);
911             INSERT INTO hats(id, owner_id, name, colour, created_at) VALUES ('hat-other', 'owner-other', 'x', '#000000', 1);
912             INSERT INTO hosts(id, owner_id, name, public_key, platform, host_version, default_hat_id, created_at)
913                 VALUES ('host-1', '{owner}', 'laptop', 'aa', 'linux', '0.0.0', '{mine}', 1);"
914        ))
915        .unwrap();
916        let refused = [
917            "UPDATE hosts SET default_hat_id = 'hat-other' WHERE id = 'host-1'".to_string(),
918            format!(
919                "INSERT INTO hosts(id, owner_id, name, public_key, platform, host_version, default_hat_id, created_at)
920                 VALUES ('host-2', '{owner}', 'laptop', 'bb', 'linux', '0.0.0', 'hat-other', 1)"
921            ),
922            format!(
923                "INSERT INTO hat_path_rules(id, owner_id, host_id, prefix, hat_id, verified)
924                 VALUES ('rule-1', '{owner}', 'host-1', '/p', 'hat-other', 1)"
925            ),
926            "INSERT INTO hat_path_rules(id, owner_id, host_id, prefix, hat_id, verified)
927             VALUES ('rule-2', 'owner-other', 'host-1', '/p', 'hat-other', 1)"
928                .to_string(),
929            format!("UPDATE hats SET colour = 'red' WHERE id = '{mine}'"),
930            format!("UPDATE hats SET colour = '#ABCDEF' WHERE id = '{mine}'"),
931            format!(
932                "INSERT INTO hat_path_rules(id, owner_id, host_id, prefix, hat_id, verified)
933                 VALUES ('rule-3', '{owner}', 'host-1', '/p', '{mine}', 2)"
934            ),
935        ];
936        for sql in &refused {
937            assert!(conn.execute(sql, []).is_err(), "{sql}");
938        }
939        // The control: the same rule, all the owner's, is stored.
940        conn.execute(
941            &format!(
942                "INSERT INTO hat_path_rules(id, owner_id, host_id, prefix, hat_id, verified)
943                 VALUES ('rule-4', '{owner}', 'host-1', '/p', '{mine}', 1)"
944            ),
945            [],
946        )
947        .unwrap();
948    }
949
950    #[test]
951    fn a_colour_is_six_hex_digits_and_comes_back_lowercase() {
952        assert_eq!(parse_colour("#A1b2C3").as_deref(), Some("#a1b2c3"));
953        for bad in ["a1b2c3", "#a1b2c", "#a1b2c3d", "#g1b2c3", "red", "#a1b2c3;x", ""] {
954            assert_eq!(parse_colour(bad), None, "{bad:?}");
955        }
956    }
957}