1use 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
21pub const DEFAULT_COLOUR: &str = "#64748b";
24
25pub const MAX_RULES: usize = 256;
27
28pub const MAX_PATH: usize = 4096;
30
31pub(crate) const DEFAULT_HAT_KEY: &str = "default_hat_id";
34
35#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct HatRecord {
38 pub id: String,
39 pub name: String,
40 pub colour: String,
42 pub created_at: i64,
43 pub default_for_new_hosts: bool,
45 pub purging: bool,
48 pub logo: Option<String>,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct PathRule {
56 pub id: String,
57 pub prefix: String,
59 pub hat_id: String,
60 pub verified: bool,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct NewRule {
68 pub prefix: String,
69 pub hat_id: String,
70 pub verified: bool,
71}
72
73#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct Resolution {
77 pub hat_id: String,
78 pub rule_id: Option<String>,
79}
80
81#[derive(Debug, Clone, PartialEq, Eq)]
83pub enum SessionHat {
84 Decided(Resolution),
85 Ambiguous(PathRule),
89}
90
91#[derive(Debug, Clone, PartialEq, Eq)]
93pub enum HatChange {
94 Done(HatRecord),
95 NotFound,
96 NameTaken,
98 Purging,
101 Invalid(String),
103}
104
105#[derive(Debug, Clone, PartialEq, Eq)]
107pub enum PurgeStart {
108 Frozen { rules: u64 },
111 NotFound,
113 IsDefault,
116}
117
118#[derive(Debug, Clone, PartialEq, Eq)]
120pub enum RulesChange {
121 Done(Vec<PathRule>),
123 HostNotFound,
124 Invalid(String),
125}
126
127#[derive(Debug, Clone, PartialEq, Eq)]
129pub enum HostChange {
130 Done,
131 NotFound,
132 Invalid(String),
133}
134
135pub 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
142pub 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
148pub 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
175pub fn is_canonical(path: &str) -> bool {
179 normalize_lexically(path).is_ok_and(|normal| normal == path)
180}
181
182pub fn covers(prefix: &str, path: &str) -> bool {
190 hennery_proto::paths::is_within(path, prefix)
191}
192
193pub 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
211pub 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
246pub(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
256fn 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
271fn 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
284fn 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
318pub(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
323fn 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 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 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 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 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 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 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 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 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 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 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 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 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 pub fn is_default_hat(&self, hat_id: &str) -> Result<bool> {
593 is_default(&self.conn(), self.owner_id(), hat_id)
594 }
595
596 pub fn is_frozen(&self, hat_id: &str) -> Result<bool> {
600 frozen(&self.conn(), self.owner_id(), hat_id)
601 }
602
603 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 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 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 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 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 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
678pub 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 #[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 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 #[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 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 #[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 #[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 assert!(
895 conn.execute("UPDATE hosts SET default_hat_id = 'hat-nope' WHERE id = 'host-old'", [])
896 .is_err()
897 );
898 }
899
900 #[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 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}