//! Squid's allow-list files: the destination names the swarm lets this hive's //! agents reach through the egress proxy. //! //! swarm-controller keeps one list for the whole swarm //! ([`swarm_wire::egress_allow`]); each entry is for every agent or for one. //! Every [`INTERVAL`] this reads it and renders two files for squid: //! //! - [`ENV_ALLOWED_FILE`]: the swarm-wide names, one per line, which //! `nix/host-modules/hive-squid.nix` reads as part of `allowed_dst`. //! - [`ENV_AGENT_ALLOW_FILE`]: squid configuration `include`d where the base //! list is allowed: for each of this hive's agents with entries, an `acl` //! for its squid user, an `acl` for its names and the `http_access allow` //! joining them, then the rules of the agents in `log` mode ([`log_rules`]). //! //! Squid reads both when it parses its configuration, so the path unit in //! `nix/host-modules/hive-squid.nix` reloads it when either changes. //! //! The names `services.hyperhive.deploy.hive-controller.squid.allowedDomains` lists ([`ENV_SEED_FILE`]) //! are always allowed: the swarm-wide names written are those plus the swarm's, //! with the per-agent rules on top. Until the swarm has a list the files hold //! just those names. A file from the swarm's list starts with [`SWARM_MARKER`]; //! the queue not answering for the bucket is no withdrawal, so a file with the //! marker is left alone then. //! //! Every entry is checked again with //! [`swarm_wire::egress_allow::normalize_domain`], since the names end up in //! squid's configuration, and skipped when it fails. An entry that //! [`ENV_ALWAYS_ALLOWED`] or another entry of its list covers is dropped, //! because squid ignores the narrower of two overlapping names in one ACL. A //! failed read leaves both files as they are. use std::fmt::Write as _; use std::path::{Path, PathBuf}; use std::time::Duration; use anyhow::{Context, Result}; use swarm_queue_client::egress_allow::{self, Read}; use swarm_wire::egress_allow::{AllowList, covered_by, normalize_domain}; use super::squid_users::write_if_changed; use crate::permissions::{self, InternetMode}; use crate::stats::sweep_health::{self, SweepHealth}; /// How often the list is read. pub const INTERVAL: Duration = Duration::from_secs(30); /// At [`INTERVAL`] this is ~90 seconds of failing reads. const FAILURES_BEFORE_WARNING: u32 = 3; /// The swarm-wide names file. Set by `nix/host-modules/hive-c0re` from /// `services.hyperhive.deploy.hive-controller.squid.allowedFile`. pub const ENV_ALLOWED_FILE: &str = "HIVE_SQUID_ALLOWED_FILE"; /// The per-agent squid configuration file. Set from /// `services.hyperhive.deploy.hive-controller.squid.agentAllowFile`. pub const ENV_AGENT_ALLOW_FILE: &str = "HIVE_SQUID_AGENT_ALLOW_FILE"; /// The file of names `services.hyperhive.deploy.hive-controller.squid.allowedDomains` lists, one per /// line, which every rendering of the swarm-wide names includes. pub const ENV_SEED_FILE: &str = "HIVE_SQUID_ALLOWED_SEED_FILE"; /// The names, separated by spaces, that squid allows every authenticated user /// regardless of this list: the base list and the self-check canary. pub const ENV_ALWAYS_ALLOWED: &str = "HIVE_SQUID_ALWAYS_ALLOWED"; /// The first line of a file rendered from the swarm's list. const SWARM_MARKER: &str = "# source: swarm\n"; /// The first line of a file rendered from the seed. const SEED_MARKER: &str = "# source: seed\n"; /// The annotation `log` rules put on a request, as `key=value`. The `note` ACL /// in `nix/host-modules/hive-squid.nix` reads the key. const MARK: &str = "would_deny=1"; /// The ACL that applies [`MARK`]. const MARK_ACL: &str = "log_mark"; /// The two files a pass writes. struct Files<'a> { allowed: &'a Path, agent_allow: &'a Path, } /// What a pass left in the files. #[derive(Debug, PartialEq, Eq)] enum Source { /// The swarm's list. Swarm, /// The seed, since the swarm has no list. Seed, /// The swarm has no list now, but the files hold the one it had. Kept, } fn env_path(name: &str) -> Option { std::env::var_os(name) .filter(|v| !v.is_empty()) .map(PathBuf::from) } /// Read the list now and every [`INTERVAL`] until shutdown, when this host has /// squid's two files. pub async fn follow(client: async_nats::Client, mut shutdown: tokio::sync::watch::Receiver) { let (Some(allowed), Some(agent_allow)) = (env_path(ENV_ALLOWED_FILE), env_path(ENV_AGENT_ALLOW_FILE)) else { return; }; let seed = env_path(ENV_SEED_FILE); let always = std::env::var(ENV_ALWAYS_ALLOWED).unwrap_or_default(); let mut health = SweepHealth::new("squid_allow_render", "warn", FAILURES_BEFORE_WARNING); // The last `Source` that was logged, so a list that stays unset is // announced once rather than every pass. let mut announced: Option<&'static str> = None; loop { let files = Files { allowed: &allowed, agent_allow: &agent_allow, }; match pass(&client, &files, seed.as_deref(), &always).await { Ok(source) => { health.record_ok(); announce(&source, &mut announced); } Err(e) => { tracing::warn!(error = ?e, "squid allow list: the pass failed"); let err = format!("{e:#}"); health.record_err(|ctx| { let age = ctx.since_last_ok.map_or_else( || "no success this session".to_owned(), |d| format!("last ok {} ago", sweep_health::fmt_age(d)), ); format!( "reading the swarm's egress allow-list is failing ({} consecutive, \ {age}) — squid keeps the list last written: {err}", ctx.consecutive ) }); } } tokio::select! { () = tokio::time::sleep(INTERVAL) => {} _ = shutdown.changed() => return, } } } /// Log the first pass of a run that left the seed or the kept files. fn announce(source: &Source, announced: &mut Option<&'static str>) { let (key, message) = match source { Source::Swarm => { *announced = None; return; } Source::Seed => ( "seed", "squid allow list: the swarm has no list, so squid keeps this hive's own names", ), Source::Kept => ( "kept", "squid allow list: the swarm's list is not readable now, so squid keeps the last one written", ), }; if *announced != Some(key) { tracing::info!("{message}"); *announced = Some(key); } } async fn pass( client: &async_nats::Client, files: &Files<'_>, seed: Option<&Path>, always: &str, ) -> Result { // An unconnected client does not fail a JetStream request, it hangs on it. swarm_queue_client::ensure_connected(client)?; let agents: Vec = crate::lifecycle::agents_for_meta_listing() .await .context("enumerating this hive's agents")? .into_iter() .map(|spec| spec.name) .collect(); let perms = permissions::read_at(&permissions::path()) .context("reading the stored swarm permissions")?; let logging = log_agents(&agents, &perms); let read = egress_allow::read(client) .await .map_err(|e| anyhow::anyhow!(e)) .context("reading the swarm's egress allow-list")?; sync_with(read, &agents, &logging, seed, always, files) } /// [`pass`] with the read, the agents and the files given. Each file is only /// written when its rendering differs from what it holds. fn sync_with( read: Read, agents: &[String], logging: &[String], seed: Option<&Path>, always: &str, files: &Files<'_>, ) -> Result { let always: Vec<&str> = always.split_whitespace().collect(); let rendered = match read { Read::Current(list) => render(&list, agents, logging, &always, &seed_names(seed)?), Read::NotSet => { if super::squid_users::read_or_empty(files.allowed)?.starts_with(SWARM_MARKER) { return Ok(Source::Kept); } seeded(seed, logging)? } }; let changed_allowed = write_if_changed(files.allowed, &rendered.allowed)?; let changed_agents = write_if_changed(files.agent_allow, &rendered.agent_allow)?; if changed_allowed || changed_agents { if let Some(counts) = &rendered.counts { tracing::info!(file = %files.allowed.display(), "{}", counts.summary()); if !counts.skipped.is_empty() { tracing::warn!( skipped = ?counts.skipped, "squid allow list: entries the swarm holds were skipped as invalid" ); } } else { tracing::info!( file = %files.allowed.display(), "squid allow list: wrote this hive's own names, no swarm list" ); } } Ok(if rendered.counts.is_some() { Source::Swarm } else { Source::Seed }) } /// What the swarm's list rendered as. struct Counts { /// Swarm-wide names written. swarm: usize, /// Names written across this hive's agents. agent: usize, /// Entries left out because they are not valid names or agents. skipped: Vec, } impl Counts { /// The info line a pass that changed a file logs. fn summary(&self) -> String { format!( "squid allow list: {} swarm, {} agent entries", self.swarm, self.agent ) } } struct Rendered { allowed: String, agent_allow: String, /// `None` for the seed. counts: Option, } /// The names in the seed file, trimmed, without blank lines and comments. The /// file is this hive's own configuration, so its names are not re-checked. fn seed_names(seed: Option<&Path>) -> Result> { let text = match seed { Some(path) => super::squid_users::read_or_empty(path)?, None => String::new(), }; Ok(text .lines() .map(str::trim) .filter(|line| !line.is_empty() && !line.starts_with('#')) .map(str::to_owned) .collect()) } /// The files for the seed: its names as they are, and no per-agent allow rules. fn seeded(seed: Option<&Path>, logging: &[String]) -> Result { let mut allowed = SEED_MARKER.to_owned(); for name in seed_names(seed)? { allowed.push_str(&name); allowed.push('\n'); } Ok(Rendered { allowed, agent_allow: format!("{SEED_MARKER}{}", log_rules(logging)), counts: None, }) } /// `names` sorted, with each one that another covers dropped. fn reduced(mut names: Vec) -> Vec { names.sort(); names.dedup(); let all = names.clone(); names.retain(|n| { let others: Vec<&String> = all.iter().filter(|o| *o != n).collect(); !covered_by(n, &others) }); names } /// The agents among `agents` whose record is in `log` mode, sorted. A name that /// is not an identifier is left out, since the rules put it in squid's /// configuration. fn log_agents(agents: &[String], perms: &permissions::Stored) -> Vec { let mut logging: Vec = agents .iter() .filter(|a| hive_types::Ident::parse(a).is_ok()) .filter(|a| permissions::internet_mode(perms.get(*a)) == InternetMode::Log) .cloned() .collect(); logging.sort(); logging.dedup(); logging } /// The rules that let each of `logging` reach a name `allowed_dst` lacks and /// mark the request `would_deny=1` for `access_log` (`nix/host-modules/hive-squid.nix`). /// Empty for no agents. /// /// These follow every `http_access allow` the agent's own names get, so a name /// the agent is allowed never carries the mark. The mark is the last ACL of its /// rule: squid applies `annotate_transaction` when it is reached, whatever the /// rule decides after it. Everything in squid's configuration before the /// include is a deny that applies to these agents as to any other. fn log_rules(logging: &[String]) -> String { if logging.is_empty() { return String::new(); } let mut rules = format!("acl {MARK_ACL} annotate_transaction {MARK}\n"); for agent in logging { let _ = write!( rules, "acl agent_log_{agent} proxy_auth {agent}\n\ http_access allow agent_log_{agent} !allowed_dst {MARK_ACL}\n" ); } rules } /// The files for `list` and the seed's `own` names, for the agents this hive /// runs, with `logging` of them in `log` mode. fn render( list: &AllowList, agents: &[String], logging: &[String], always: &[&str], own: &[String], ) -> Rendered { let mut skipped = Vec::new(); let mut swarm_wide = Vec::new(); let mut per_agent: std::collections::BTreeMap<&str, Vec> = std::collections::BTreeMap::new(); for entry in &list.entries { let Ok(domain) = normalize_domain(&entry.domain) else { skipped.push(entry.domain.clone()); continue; }; match entry.agent.as_deref() { None => swarm_wide.push(domain), Some(agent) => { if hive_types::Ident::parse(agent).is_err() { skipped.push(format!("{} for {agent}", entry.domain)); } else if let Some(mine) = agents.iter().find(|a| *a == agent) { per_agent.entry(mine.as_str()).or_default().push(domain); } } } } swarm_wide.extend(own.iter().map(|name| name.to_ascii_lowercase())); swarm_wide.retain(|d| !covered_by(d, always)); let swarm_wide = reduced(swarm_wide); let mut allowed = SWARM_MARKER.to_owned(); for name in &swarm_wide { allowed.push_str(name); allowed.push('\n'); } let mut agent_rules = Vec::new(); let mut agent_count = 0; for (agent, mut names) in per_agent { names.retain(|d| !covered_by(d, always) && !covered_by(d, &swarm_wide)); let names = reduced(names); if names.is_empty() { continue; } agent_count += names.len(); agent_rules.push(format!( "acl agent_allow_{agent} proxy_auth {agent}\n\ acl agent_allow_{agent}_dst dstdomain {}\n\ http_access allow agent_allow_{agent} agent_allow_{agent}_dst\n", names.join(" ") )); } Rendered { allowed, agent_allow: format!( "{SWARM_MARKER}{}{}", agent_rules.concat(), log_rules(logging) ), counts: Some(Counts { swarm: swarm_wide.len(), agent: agent_count, skipped, }), } } #[cfg(test)] mod tests { use super::*; use swarm_wire::egress_allow::Entry; fn entry(domain: &str, agent: Option<&str>) -> Entry { Entry { domain: domain.to_owned(), agent: agent.map(str::to_owned), derived: false, } } fn list(entries: &[(&str, Option<&str>)]) -> AllowList { AllowList { entries: entries.iter().map(|(d, a)| entry(d, *a)).collect(), } } fn agents(names: &[&str]) -> Vec { names.iter().map(|n| (*n).to_owned()).collect() } const ALWAYS: &[&str] = &["api.anthropic.com", ".claude.ai", "cache.nixos.org"]; fn rendered(entries: &[(&str, Option<&str>)], mine: &[&str]) -> Rendered { render(&list(entries), &agents(mine), &[], ALWAYS, &[]) } #[test] fn swarm_wide_names_are_sorted_one_per_line_after_the_marker() { let r = rendered( &[ ("github.com", None), ("crates.io", None), ("GitHub.com", None), ], &["atlas"], ); assert_eq!(r.allowed, "# source: swarm\ncrates.io\ngithub.com\n"); let counts = r.counts.expect("a swarm list"); assert_eq!((counts.swarm, counts.agent), (2, 0)); } #[test] fn an_empty_list_renders_an_empty_set_of_names_and_no_rules() { let r = rendered(&[], &["atlas"]); assert_eq!(r.allowed, SWARM_MARKER); assert_eq!(r.agent_allow, SWARM_MARKER); let counts = r.counts.expect("an empty swarm list is still a swarm list"); assert_eq!((counts.swarm, counts.agent), (0, 0)); } #[test] fn an_agents_names_become_a_user_rule_and_other_hives_agents_are_left_out() { let r = rendered( &[ ("pypi.org", Some("atlas")), ("crates.io", Some("atlas")), ("github.com", Some("iris")), ("github.com", Some("../argus")), ], &["atlas", "argus"], ); assert_eq!( r.agent_allow, "# source: swarm\n\ acl agent_allow_atlas proxy_auth atlas\n\ acl agent_allow_atlas_dst dstdomain crates.io pypi.org\n\ http_access allow agent_allow_atlas agent_allow_atlas_dst\n" ); let counts = r.counts.expect("a swarm list"); assert_eq!((counts.swarm, counts.agent), (0, 2)); // The name that could break out of the line is reported, not rendered. assert_eq!(counts.skipped, ["github.com for ../argus"]); assert!(!r.agent_allow.contains("iris"), "another hive's agent"); assert!(!r.agent_allow.contains("argus"), "an invalid name"); } #[test] fn an_entry_that_is_not_a_name_never_reaches_squid_configuration() { let r = rendered( &[ ("github.com", None), ("1.2.3.4", None), ("*.example.com", Some("atlas")), ("example.com\nhttp_access allow all", Some("atlas")), ("example.com\nhttp_access allow all", None), ], &["atlas"], ); assert_eq!(r.allowed, "# source: swarm\ngithub.com\n"); assert_eq!(r.agent_allow, SWARM_MARKER); assert!(!r.allowed.contains("http_access") && !r.agent_allow.contains("http_access")); assert_eq!(r.counts.expect("a swarm list").skipped.len(), 4); } #[test] fn a_name_squid_allows_everyone_or_the_swarm_already_lists_is_dropped() { let r = rendered( &[ ("api.anthropic.com", None), ("a.claude.ai", None), ("cache.nixos.org", Some("atlas")), (".github.com", None), ("api.github.com", None), ("api.github.com", Some("atlas")), ("crates.io", Some("atlas")), ], &["atlas"], ); assert_eq!(r.allowed, "# source: swarm\n.github.com\n"); assert_eq!( r.agent_allow, "# source: swarm\n\ acl agent_allow_atlas proxy_auth atlas\n\ acl agent_allow_atlas_dst dstdomain crates.io\n\ http_access allow agent_allow_atlas agent_allow_atlas_dst\n" ); // Control: the same entries survive when nothing is always allowed. let bare = render( &list(&[("api.anthropic.com", None), ("a.claude.ai", None)]), &agents(&["atlas"]), &[], &[], &[], ); assert_eq!( bare.allowed, "# source: swarm\na.claude.ai\napi.anthropic.com\n" ); } #[test] fn the_log_line_carries_the_counts() { let r = rendered( &[ ("github.com", None), ("crates.io", None), ("pypi.org", Some("atlas")), ("npmjs.org", Some("atlas")), ("docs.rs", Some("argus")), ], &["atlas", "argus"], ); let counts = r.counts.expect("a swarm list"); assert_eq!( counts.summary(), "squid allow list: 2 swarm, 3 agent entries" ); } fn log_list(logging: &[&str]) -> String { render( &list(&[("pypi.org", Some("atlas"))]), &agents(&["atlas", "uma", "iris"]), &agents(logging), ALWAYS, &[], ) .agent_allow } /// A `log` agent's rule allows what `allowed_dst` lacks and carries the /// mark last; it comes after every agent's own allow rule. #[test] fn a_log_agent_is_allowed_past_the_list_and_marked_after_all_own_rules() { let text = log_list(&["uma"]); assert_eq!( text, "# source: swarm\n\ acl agent_allow_atlas proxy_auth atlas\n\ acl agent_allow_atlas_dst dstdomain pypi.org\n\ http_access allow agent_allow_atlas agent_allow_atlas_dst\n\ acl log_mark annotate_transaction would_deny=1\n\ acl agent_log_uma proxy_auth uma\n\ http_access allow agent_log_uma !allowed_dst log_mark\n" ); // The rule of an agent with its own names also follows them. let both = log_list(&["atlas"]); let own = both .find("http_access allow agent_allow_atlas ") .expect("own rule"); let log = both .find("http_access allow agent_log_atlas ") .expect("log rule"); assert!(own < log, "{both}"); // Control: with no log agent there is no rule and no mark. let none = log_list(&[]); assert!( !none.contains("log_mark") && !none.contains("would_deny"), "{none}" ); } /// Every log rule ends in the mark, which squid applies when it is reached; /// a rule that put it earlier would mark requests the agent is allowed. #[test] fn the_mark_is_the_last_acl_of_every_log_rule() { let text = log_list(&["atlas", "iris", "uma"]); let rules: Vec<&str> = text .lines() .filter(|l| l.starts_with("http_access allow agent_log_")) .collect(); assert_eq!(rules.len(), 3, "{text}"); for rule in rules { assert!(rule.ends_with(" !allowed_dst log_mark"), "{rule}"); } assert_eq!( text.matches("annotate_transaction").count(), 1, "defined once" ); } /// Only `deny`-free, `allow`-only rules reach the include: the safety /// denies that precede it in squid's configuration stay the only ones. #[test] fn a_log_agent_adds_allow_rules_and_no_deny() { let text = log_list(&["atlas", "uma"]); assert!(!text.contains("http_access deny"), "{text}"); assert!(text.contains("http_access allow"), "control"); } /// The self-check user cannot be named by a log rule: it is not an agent /// name, so `squid-selfcheck`'s deny canary stays refused. #[test] fn the_selfcheck_users_are_never_log_agents() { for user in [ swarm_secret_client::proxy::SELFCHECK_USER, swarm_secret_client::proxy::SELFCHECK_UNRESTRICTED_USER, ] { assert!(hive_types::Ident::parse(user).is_err(), "{user}"); let perms = permissions::Stored::from([( user.to_owned(), serde_json::from_str(r#"{"unrestricted_internet":false,"internet_log":true}"#) .expect("decodes"), )]); assert!(log_agents(&agents(&[user]), &perms).is_empty(), "{user}"); } // Control: an agent in the same record shape is a log agent. let perms = permissions::Stored::from([( "uma".to_owned(), serde_json::from_str(r#"{"unrestricted_internet":false,"internet_log":true}"#) .expect("decodes"), )]); assert_eq!(log_agents(&agents(&["uma", "atlas"]), &perms), ["uma"]); } /// Only `log` records give rules; restricted, unrestricted and unrecorded /// agents render as they did. #[test] fn only_log_mode_agents_get_rules() { let perms: permissions::Stored = serde_json::from_str( r#"{ "uma": {"unrestricted_internet": false, "internet_log": true}, "atlas": {"unrestricted_internet": false}, "iris": {"unrestricted_internet": true, "internet_log": true} }"#, ) .expect("decodes"); let all = agents(&["uma", "atlas", "iris", "argus"]); assert_eq!(log_agents(&all, &perms), ["uma"]); } /// The seed's file carries the rules too: logging does not depend on the /// swarm having an allow list. #[test] fn the_seed_files_carry_the_log_rules() { let d = dir("forge.example\n"); sync_with( Read::NotSet, &agents(&["uma"]), &agents(&["uma"]), Some(&d.seed()), "", &Files { allowed: &d.allowed(), agent_allow: &d.agent_allow(), }, ) .expect("renders"); let text = text(&d.agent_allow()); assert!(text.starts_with(SEED_MARKER), "{text}"); assert!(text.contains("http_access allow agent_log_uma !allowed_dst log_mark")); } struct Dir(tempfile::TempDir); impl Dir { fn allowed(&self) -> PathBuf { self.0.path().join("allowed") } fn agent_allow(&self) -> PathBuf { self.0.path().join("agent-allow.conf") } fn seed(&self) -> PathBuf { self.0.path().join("seed") } } fn dir(seed: &str) -> Dir { let dir = Dir(tempfile::tempdir().expect("tempdir")); std::fs::write(dir.seed(), seed).expect("seed"); dir } fn sync(d: &Dir, read: Read) -> Result { sync_with( read, &agents(&["atlas"]), &[], Some(&d.seed()), "api.anthropic.com cache.nixos.org", &Files { allowed: &d.allowed(), agent_allow: &d.agent_allow(), }, ) } fn text(path: &Path) -> String { std::fs::read_to_string(path).expect("the file is there") } #[test] fn without_a_swarm_list_the_files_hold_the_seed() { let d = dir("# a comment\nforge.example\n\n loop.example \n"); assert_eq!(sync(&d, Read::NotSet).expect("renders"), Source::Seed); assert_eq!( text(&d.allowed()), "# source: seed\nforge.example\nloop.example\n" ); assert_eq!(text(&d.agent_allow()), SEED_MARKER); // A second pass writes nothing. let ino = std::os::unix::fs::MetadataExt::ino(&std::fs::metadata(d.allowed()).unwrap()); sync(&d, Read::NotSet).expect("renders"); assert_eq!( std::os::unix::fs::MetadataExt::ino(&std::fs::metadata(d.allowed()).unwrap()), ino ); } /// The seed is the hive's own additive list: a swarm list, empty or not, /// renders the seed's names with it. #[test] fn a_swarm_list_adds_to_the_seed_even_when_empty() { let d = dir("forge.example\n"); sync(&d, Read::NotSet).expect("seeded"); assert!(text(&d.allowed()).contains("forge.example"), "control"); let empty = Read::Current(AllowList::default()); assert_eq!(sync(&d, empty).expect("renders"), Source::Swarm); assert_eq!(text(&d.allowed()), "# source: swarm\nforge.example\n"); let named = Read::Current(list(&[("github.com", None)])); sync(&d, named).expect("renders"); assert_eq!( text(&d.allowed()), "# source: swarm\nforge.example\ngithub.com\n" ); assert_eq!(text(&d.agent_allow()), SWARM_MARKER); } #[test] fn a_per_agent_entry_renders_on_top_of_the_seed() { let d = dir("forge.example\n"); let read = Read::Current(list(&[("pypi.org", Some("atlas"))])); sync(&d, read).expect("renders"); assert_eq!(text(&d.allowed()), "# source: swarm\nforge.example\n"); assert_eq!( text(&d.agent_allow()), "# source: swarm\n\ acl agent_allow_atlas proxy_auth atlas\n\ acl agent_allow_atlas_dst dstdomain pypi.org\n\ http_access allow agent_allow_atlas agent_allow_atlas_dst\n" ); } #[test] fn a_seed_name_the_swarm_repeats_or_squid_allows_anyway_is_written_once() { // `cache.nixos.org` is always allowed; `.example.com` covers the // swarm's `api.example.com`. let d = dir("Forge.Example\n.example.com\ncache.nixos.org\n"); let read = Read::Current(list(&[ ("forge.example", None), ("api.example.com", None), ("github.com", None), ])); sync(&d, read).expect("renders"); assert_eq!( text(&d.allowed()), "# source: swarm\n.example.com\nforge.example\ngithub.com\n" ); } #[test] fn no_seed_file_is_no_seed_names() { let d = dir(""); std::fs::remove_file(d.seed()).expect("remove"); let read = Read::Current(list(&[("github.com", None)])); sync_with( read, &agents(&["atlas"]), &[], None, "", &Files { allowed: &d.allowed(), agent_allow: &d.agent_allow(), }, ) .expect("renders"); assert_eq!(text(&d.allowed()), "# source: swarm\ngithub.com\n"); } #[test] fn a_swarm_list_that_stops_answering_is_kept_and_a_seed_is_refreshed() { let d = dir("forge.example\n"); sync(&d, Read::Current(list(&[("github.com", None)]))).expect("renders"); let before = (text(&d.allowed()), text(&d.agent_allow())); assert_eq!(sync(&d, Read::NotSet).expect("renders"), Source::Kept); assert_eq!((text(&d.allowed()), text(&d.agent_allow())), before); // Control: a file that never held the swarm's list follows the seed. let d = dir("forge.example\n"); std::fs::write(d.allowed(), "old.example\n").expect("a tmpfiles-seeded file"); assert_eq!(sync(&d, Read::NotSet).expect("renders"), Source::Seed); assert_eq!(text(&d.allowed()), "# source: seed\nforge.example\n"); } #[test] fn announce_logs_a_run_of_unset_passes_once() { let mut announced = None; announce(&Source::Seed, &mut announced); assert_eq!(announced, Some("seed")); announce(&Source::Swarm, &mut announced); assert_eq!(announced, None, "a list that appears resets the run"); announce(&Source::Kept, &mut announced); assert_eq!(announced, Some("kept")); } #[test] fn the_files_are_group_readable() { use std::os::unix::fs::PermissionsExt as _; let d = dir(""); sync(&d, Read::Current(list(&[("github.com", None)]))).expect("renders"); for file in [d.allowed(), d.agent_allow()] { let mode = std::fs::metadata(file).expect("stat").permissions().mode() & 0o777; assert_eq!(mode, 0o640); } } }