Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/swarm-controller/src/matrix_account/operator_admin.rs
atlas 962c1548eb swarm-controller: revoke matrix admin from former operators
The operator admin sync now also takes homeserver admin away. It records
each user id it sends `make-user-admin` for in
`operator-matrix-admins.json` under the controller's state directory
(written to a temporary file and renamed over it; a missing file is an
empty set). On each pass, a recorded account that is no longer in
authelia's `admins` group but is still joined to `#admins:<server>` is
sent `!admin users force-leave-room <user> <admin room id>` as `@swarm`,
over the same `/_matrix` path the grant uses. Leaving that room is what
ends tuwunel admin.

It never revokes an admin it didn't grant, `@swarm`, tuwunel's server
user (`@conduit:<server>`), an agent's account (the store's
`hive-agent-*` cert roles, the same roster the agent account mint uses),
or anyone in the new `services.hyperhive.swarm.matrix.adminSync.ignoreUsers`
list. That list reaches the daemon as
`SWARM_CONTROLLER_MATRIX_ADMIN_SYNC_IGNORE_USERS`. It also never sends a
revoke that would leave `#admins` with no member besides `@swarm` and the
server user.

A revoke is sent at most once per account per controller process, like a
grant. A recorded account that is out of both the group and the room is
dropped from the file.

Closes #4799
2026-10-05 12:39:02 +02:00

777 lines
26 KiB
Rust

//! Homeserver admin for authelia's operators: every member of the operator
//! group who has an account on the swarm's homeserver is made a homeserver
//! admin, at start and every [`RECONCILE_INTERVAL`] after, and an account this
//! sync made admin loses it once its owner leaves the group.
//!
//! The account an operator's SSO login lands on is named after authelia's
//! `preferred_username`, lowercased: `hive-matrix.nix` sets `userid_claims`,
//! and tuwunel lowercases the claim and refuses one that is not a legal
//! localpart. [`localpart`] applies the same rule.
//!
//! Both commands go to the admin room `#admins:<server>` as `@swarm`, with the
//! swarm appservice token: `@swarm` is the one account the homeserver promotes
//! at boot, and tuwunel runs a command only when its sender is already an
//! admin. Being an admin *is* being joined to that room. The grant,
//! `!admin users make-user-admin <user>`, goes to each operator outside the
//! room who has an account; the revoke is
//! `!admin users force-leave-room <user> <room>`.
//!
//! The revoke reaches only the user ids in [`GRANTED_FILE`], those this sync
//! sent a grant for, and never `@swarm`, the server user, an agent's account
//! or a user id in [`IGNORE_USERS_ENV`]. One that would leave no admin but
//! `@swarm` and the server user is held back and logged.
//!
//! The bot's reply is not read, and each command is sent **at most once per
//! account per controller process**: one still unconfirmed on a later pass is
//! logged as `… failed to take` and not sent again until the controller
//! restarts, since every command stays in the room's history.
use std::collections::BTreeSet;
use std::path::Path;
use std::sync::Arc;
use anyhow::{Context, Result, bail};
use swarm_matrix_client::{self as homeserver, Whoami};
use swarm_secret_client::{client::DEFAULT_CERT_MOUNT, policy};
use super::agent_token::appservice_token;
use crate::auth::AuthBridge;
/// How often [`spawn`] re-checks the operator group.
const RECONCILE_INTERVAL: std::time::Duration = std::time::Duration::from_mins(5);
/// The localpart of tuwunel's admin room alias, `#admins:<server>`.
const ADMIN_ROOM_ALIAS_LOCALPART: &str = "admins";
/// The localpart of tuwunel's server user, which creates the admin room and
/// stays joined to it. tuwunel's default `server_user_localpart`;
/// `hive-matrix.nix` does not set it.
const SERVER_USER_LOCALPART: &str = "conduit";
/// The set of user ids this sync sent a grant for, a JSON array, in the
/// controller's state directory. A missing file is an empty set. A revoked
/// user id stays in it until a pass sees it out of the admin room.
const GRANTED_FILE: &str = "operator-matrix-admins.json";
/// Whitespace-separated user ids the revoke never touches, set from
/// `services.hyperhive.swarm.matrix.adminSync.ignoreUsers`.
const IGNORE_USERS_ENV: &str = "SWARM_CONTROLLER_MATRIX_ADMIN_SYNC_IGNORE_USERS";
/// The localpart an SSO login by authelia user `username` lands on, or `None`
/// when tuwunel would refuse the name and no account can exist for it.
///
/// Lowercased and then checked against the user-id grammar's localpart
/// characters, which is what tuwunel does with the claim.
pub fn localpart(username: &str) -> Option<String> {
let lower = username.to_lowercase();
let legal = !lower.is_empty()
&& lower.bytes().all(|b| {
b.is_ascii_lowercase()
|| b.is_ascii_digit()
|| matches!(b, b'.' | b'_' | b'=' | b'-' | b'/' | b'+')
});
legal.then_some(lower)
}
/// What one pass decided, before any account is looked up.
#[derive(Debug, Default, PartialEq, Eq)]
pub struct Plan {
/// `(authelia username, user id)` for each operator not yet an admin.
pub grant: Vec<(String, String)>,
/// Operators whose username maps to no legal localpart.
pub unmappable: Vec<String>,
/// `(authelia username, user id)` for each operator this process already
/// sent a grant for who is still not an admin. Not sent again.
pub unconfirmed: Vec<(String, String)>,
}
/// Decide which of `operators` need a grant on `server`, given the user ids
/// already joined to the admin room and those this process already sent a
/// grant for.
pub fn plan(
operators: &[String],
server: &str,
admins: &BTreeSet<String>,
sent: &BTreeSet<String>,
) -> Plan {
let mut plan = Plan::default();
for name in operators {
let Some(localpart) = localpart(name) else {
plan.unmappable.push(name.clone());
continue;
};
let user_id = format!("@{localpart}:{server}");
if admins.contains(&user_id) {
continue;
}
if sent.contains(&user_id) {
plan.unconfirmed.push((name.clone(), user_id));
} else {
plan.grant.push((name.clone(), user_id));
}
}
plan
}
/// What one pass decided about the user ids in [`GRANTED_FILE`].
#[derive(Debug, Default, PartialEq, Eq)]
pub struct Revokes {
/// No longer operators and still admins: sent a revoke.
pub revoke: Vec<String>,
/// Those this process already sent a revoke for. Not sent again.
pub unconfirmed: Vec<String>,
/// Held back: revoking them would leave no admin outside `keepers`.
pub last_admin: Vec<String>,
/// Neither operators nor admins: dropped from the set.
pub forget: Vec<String>,
}
/// Decide which of `granted` lose admin, given the operator group's user ids,
/// the admin room's joined members, the accounts never revoked (`exempt`),
/// the accounts that do not count as an admin left behind (`keepers`, a
/// subset of `exempt`), and the user ids this process already sent a revoke
/// for.
pub fn plan_revokes(
granted: &BTreeSet<String>,
operators: &BTreeSet<String>,
admins: &BTreeSet<String>,
exempt: &BTreeSet<String>,
keepers: &BTreeSet<String>,
revoked: &BTreeSet<String>,
) -> Revokes {
let mut out = Revokes::default();
let mut left: BTreeSet<&String> = admins.iter().filter(|a| !keepers.contains(*a)).collect();
for user_id in granted {
if operators.contains(user_id) {
continue;
}
if !admins.contains(user_id) {
out.forget.push(user_id.clone());
continue;
}
if exempt.contains(user_id) {
continue;
}
if revoked.contains(user_id) {
out.unconfirmed.push(user_id.clone());
} else if left.len() <= 1 {
out.last_admin.push(user_id.clone());
} else {
left.remove(user_id);
out.revoke.push(user_id.clone());
}
}
out
}
/// The user ids in [`IGNORE_USERS_ENV`]'s value.
fn parse_ignore_users(raw: &str) -> BTreeSet<String> {
raw.split_whitespace().map(str::to_owned).collect()
}
/// The set stored at `path`; empty when there is no file.
fn load_granted(path: &Path) -> Result<BTreeSet<String>> {
match std::fs::read(path) {
Ok(bytes) => serde_json::from_slice(&bytes)
.with_context(|| format!("parsing the granted operator admins in {}", path.display())),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(BTreeSet::new()),
Err(e) => Err(e).with_context(|| format!("reading {}", path.display())),
}
}
/// Replace the set stored at `path`: written beside it and renamed over it,
/// so a reader sees the old set or the new one, never part of either.
fn save_granted(path: &Path, granted: &BTreeSet<String>) -> Result<()> {
let tmp = path.with_extension("json.tmp");
let bytes = serde_json::to_vec(granted).context("encoding the granted operator admins")?;
let mut file =
std::fs::File::create(&tmp).with_context(|| format!("creating {}", tmp.display()))?;
std::io::Write::write_all(&mut file, &bytes)
.and_then(|()| file.sync_all())
.with_context(|| format!("writing {}", tmp.display()))?;
std::fs::rename(&tmp, path)
.with_context(|| format!("renaming {} to {}", tmp.display(), path.display()))
}
/// The server name of a full user id, `@<localpart>:<server>`.
fn server_name(user_id: &str) -> Option<&str> {
user_id
.strip_prefix('@')?
.split_once(':')
.map(|(_, server)| server)
.filter(|server| !server.is_empty())
}
/// What one pass did.
#[derive(Debug, Default)]
struct Outcome {
granted: usize,
revoked: usize,
no_account: usize,
failed: usize,
}
/// What [`spawn`]'s loop carries from one pass to the next.
#[derive(Debug, Default)]
struct Memory {
/// User ids this process sent a grant for, which a sent grant joins.
sent: BTreeSet<String>,
/// User ids this process sent a revoke for.
revoked: BTreeSet<String>,
}
/// The admin room, and what sending to it takes.
struct AdminRoom<'a> {
http: &'a reqwest::Client,
base: &'a str,
token: &'a str,
id: &'a str,
}
/// One pass: read the operator group, send a grant for each operator who has
/// a matrix account, is not an admin, and is not in `memory.sent`, then a
/// revoke for each account [`plan_revokes`] picks out of the set stored at
/// `granted_path`.
///
/// Fails as a whole when the group, the token, the server name, the admin
/// room, its members, the agent roster or the stored set cannot be read: with
/// any of them missing there is no decision to make.
async fn pass(
bridge: &AuthBridge,
base: &str,
ignore: &BTreeSet<String>,
granted_path: &Path,
memory: &mut Memory,
) -> Result<Outcome> {
let operators = bridge
.list_operators()
.await
.context("reading authelia's operator group")?;
let store = crate::store::connect()
.await
.context("logging in to the swarm secret store")?;
let token = appservice_token(&store).await?;
let http = homeserver::client()?;
let Whoami::User(sender) = homeserver::whoami(&http, base, &token)
.await
.context("asking the homeserver who the swarm appservice token is")?
else {
bail!("the homeserver does not know the swarm appservice token");
};
let server = server_name(&sender)
.with_context(|| format!("the homeserver named the swarm sender {sender:?}"))?;
let room = homeserver::resolve_alias(
&http,
base,
&token,
&format!("#{ADMIN_ROOM_ALIAS_LOCALPART}:{server}"),
)
.await?;
let admins = homeserver::joined_members(&http, base, &token, &room).await?;
let roles = store
.list_cert_roles(DEFAULT_CERT_MOUNT)
.await
.context("listing the store's cert-auth roles")?;
let stored = load_granted(granted_path)?;
let mut granted: BTreeSet<String> = stored.union(&memory.sent).cloned().collect();
let admin_room = AdminRoom {
http: &http,
base,
token: &token,
id: &room,
};
let mut outcome = send_grants(
&admin_room,
&plan(&operators, server, &admins, &memory.sent),
memory,
&mut granted,
)
.await;
let operator_ids = operators
.iter()
.filter_map(|name| localpart(name))
.map(|l| format!("@{l}:{server}"))
.collect();
let keepers: BTreeSet<String> =
[sender.clone(), format!("@{SERVER_USER_LOCALPART}:{server}")].into();
let mut exempt = keepers.clone();
exempt.extend(
policy::agents_from_role_names(&roles)
.into_iter()
.map(|agent| format!("@{agent}:{server}")),
);
exempt.extend(ignore.iter().cloned());
let revokes = plan_revokes(
&granted,
&operator_ids,
&admins,
&exempt,
&keepers,
&memory.revoked,
);
send_revokes(&admin_room, &revokes, memory, &mut granted, &mut outcome).await;
if granted != stored {
save_granted(granted_path, &granted)?;
}
Ok(outcome)
}
/// Log `plan`'s findings and send its grants, adding each one sent to
/// `memory.sent` and `granted`.
async fn send_grants(
room: &AdminRoom<'_>,
plan: &Plan,
memory: &mut Memory,
granted: &mut BTreeSet<String>,
) -> Outcome {
for name in &plan.unmappable {
tracing::warn!(
operator = %name,
"operator's username is no legal matrix localpart, so no SSO login can \
have an account to make admin; skipped"
);
}
for (name, user_id) in &plan.unconfirmed {
tracing::warn!(
operator = %name,
%user_id,
"make-user-admin for an operator failed to take: sent earlier by this process, \
still not in the admin room; not sent again until the controller restarts"
);
}
let mut outcome = Outcome::default();
for (name, user_id) in &plan.grant {
match grant(room.http, room.base, room.token, room.id, user_id).await {
Ok(true) => {
memory.sent.insert(user_id.clone());
memory.revoked.remove(user_id);
granted.insert(user_id.clone());
tracing::info!(
operator = %name,
%user_id,
"sent make-user-admin for an operator who is not a homeserver admin"
);
outcome.granted += 1;
}
Ok(false) => {
tracing::debug!(
operator = %name,
%user_id,
"operator has no matrix account yet; nothing to make admin"
);
outcome.no_account += 1;
}
Err(e) => {
tracing::warn!(
operator = %name,
%user_id,
error = %format!("{e:#}"),
"make-user-admin for an operator failed"
);
outcome.failed += 1;
}
}
}
outcome
}
/// Log `revokes`' findings, drop the forgotten user ids from `granted` and
/// `memory`, and send its revokes, adding each one sent to `memory.revoked`.
async fn send_revokes(
room: &AdminRoom<'_>,
revokes: &Revokes,
memory: &mut Memory,
granted: &mut BTreeSet<String>,
outcome: &mut Outcome,
) {
for user_id in &revokes.forget {
granted.remove(user_id);
memory.sent.remove(user_id);
memory.revoked.remove(user_id);
tracing::debug!(
%user_id,
"granted account is neither an operator nor an admin; no longer tracked"
);
}
for user_id in &revokes.unconfirmed {
tracing::warn!(
%user_id,
"force-leave-room for a former operator failed to take: sent earlier by this \
process, still in the admin room; not sent again until the controller restarts"
);
}
for user_id in &revokes.last_admin {
tracing::warn!(
%user_id,
"former operator is the last homeserver admin besides @swarm and the server \
user; not revoked"
);
}
for user_id in &revokes.revoke {
match homeserver::send_text(
room.http,
room.base,
room.token,
room.id,
&format!("!admin users force-leave-room {user_id} {}", room.id),
)
.await
{
Ok(_) => {
memory.revoked.insert(user_id.clone());
memory.sent.remove(user_id);
tracing::info!(
%user_id,
"sent force-leave-room for a former operator this sync made admin"
);
outcome.revoked += 1;
}
Err(e) => {
tracing::warn!(
%user_id,
error = %format!("{e:#}"),
"force-leave-room for a former operator failed"
);
outcome.failed += 1;
}
}
}
}
/// Send the grant for `user_id` when its account exists; `false` when it
/// does not, and nothing was sent.
async fn grant(
http: &reqwest::Client,
base: &str,
token: &str,
room: &str,
user_id: &str,
) -> Result<bool> {
if !homeserver::user_exists(http, base, token, user_id).await? {
return Ok(false);
}
homeserver::send_text(
http,
base,
token,
room,
&format!("!admin users make-user-admin {user_id}"),
)
.await?;
Ok(true)
}
/// Run [`pass`] now and every [`RECONCILE_INTERVAL`] after. A pass that fails
/// is logged and retried on the next tick; it never stops the daemon.
pub fn spawn(bridge: Arc<AuthBridge>, base: Arc<str>) {
let ignore = parse_ignore_users(&std::env::var(IGNORE_USERS_ENV).unwrap_or_default());
let granted_path = crate::webhook::state_dir().join(GRANTED_FILE);
tokio::spawn(async move {
let mut memory = Memory::default();
let mut ticker = tokio::time::interval(RECONCILE_INTERVAL);
loop {
ticker.tick().await;
match pass(&bridge, &base, &ignore, &granted_path, &mut memory).await {
Ok(outcome) if outcome.failed > 0 => tracing::warn!(
granted = outcome.granted,
revoked = outcome.revoked,
failed = outcome.failed,
retry_in_s = RECONCILE_INTERVAL.as_secs(),
"operator matrix admin: pass incomplete; retrying next tick"
),
Ok(outcome) if outcome.granted > 0 || outcome.revoked > 0 => tracing::info!(
granted = outcome.granted,
revoked = outcome.revoked,
no_account = outcome.no_account,
"operator matrix admin: commands sent"
),
Ok(outcome) => tracing::debug!(
no_account = outcome.no_account,
"operator matrix admin: every operator with an account is an admin"
),
Err(e) => tracing::warn!(
error = %format!("{e:#}"),
retry_in_s = RECONCILE_INTERVAL.as_secs(),
"operator matrix admin: pass failed; retrying next tick"
),
}
}
});
}
#[cfg(test)]
mod tests {
use super::*;
fn names(names: &[&str]) -> Vec<String> {
names.iter().map(|&n| n.to_owned()).collect()
}
fn ids(ids: &[&str]) -> BTreeSet<String> {
ids.iter().map(|&n| n.to_owned()).collect()
}
#[test]
fn an_operator_already_in_the_admin_room_gets_no_command() {
let plan = plan(
&names(&["mara", "vinzenz"]),
"t.local",
&ids(&["@mara:t.local", "@swarm:t.local"]),
&BTreeSet::new(),
);
assert_eq!(
plan.grant,
[("vinzenz".to_owned(), "@vinzenz:t.local".to_owned())]
);
assert!(plan.unmappable.is_empty());
}
#[test]
fn a_pass_with_every_operator_admin_sends_nothing() {
let plan = plan(
&names(&["mara"]),
"t.local",
&ids(&["@mara:t.local"]),
&BTreeSet::new(),
);
assert_eq!(plan, Plan::default());
}
#[test]
fn an_uppercase_username_maps_to_the_lowercased_account() {
// tuwunel lowercases the claim, so `Mara`'s login is `@mara:`, and that
// account being admin already is a match, not a second grant.
let plan = plan(
&names(&["Mara"]),
"t.local",
&ids(&["@mara:t.local"]),
&BTreeSet::new(),
);
assert_eq!(plan, Plan::default());
assert_eq!(localpart("Mara.B_c").as_deref(), Some("mara.b_c"));
}
#[test]
fn a_name_tuwunel_would_refuse_is_reported_not_granted() {
let plan = plan(
&names(&["bad name", "ok"]),
"t.local",
&BTreeSet::new(),
&BTreeSet::new(),
);
assert_eq!(plan.grant, [("ok".to_owned(), "@ok:t.local".to_owned())]);
assert_eq!(plan.unmappable, ["bad name"]);
assert_eq!(localpart(""), None);
assert_eq!(localpart("é"), None);
}
#[test]
fn a_grant_already_sent_by_this_process_is_not_sent_again() {
// The cap on the admin room: a command tuwunel ignored leaves the
// operator outside the room, and that must not produce a second send.
let plan = plan(
&names(&["mara", "vinzenz"]),
"t.local",
&BTreeSet::new(),
&ids(&["@mara:t.local"]),
);
assert_eq!(
plan.grant,
[("vinzenz".to_owned(), "@vinzenz:t.local".to_owned())]
);
assert_eq!(
plan.unconfirmed,
[("mara".to_owned(), "@mara:t.local".to_owned())]
);
}
#[test]
fn a_sent_grant_that_took_is_neither_sent_nor_reported() {
let plan = plan(
&names(&["mara"]),
"t.local",
&ids(&["@mara:t.local"]),
&ids(&["@mara:t.local"]),
);
assert_eq!(plan, Plan::default());
}
#[test]
fn the_server_name_comes_from_the_full_user_id() {
assert_eq!(server_name("@swarm:t.local"), Some("t.local"));
assert_eq!(server_name("@swarm:t.local:8448"), Some("t.local:8448"));
assert_eq!(server_name("@swarm:"), None);
assert_eq!(server_name("swarm"), None);
}
/// `@swarm` and the server user, the accounts `pass` always exempts.
fn keepers() -> BTreeSet<String> {
ids(&["@swarm:t.local", "@conduit:t.local"])
}
#[test]
fn a_former_operator_the_sync_granted_is_revoked() {
let revokes = plan_revokes(
&ids(&["@mara:t.local", "@ex:t.local"]),
&ids(&["@mara:t.local"]),
&ids(&[
"@swarm:t.local",
"@conduit:t.local",
"@mara:t.local",
"@ex:t.local",
]),
&keepers(),
&keepers(),
&BTreeSet::new(),
);
assert_eq!(
revokes,
Revokes {
revoke: vec!["@ex:t.local".to_owned()],
..Revokes::default()
}
);
}
#[test]
fn a_hand_made_admin_is_never_revoked() {
// `@hand` is no operator and an admin, but the sync never granted it.
let revokes = plan_revokes(
&ids(&["@mara:t.local"]),
&ids(&["@mara:t.local"]),
&ids(&["@swarm:t.local", "@mara:t.local", "@hand:t.local"]),
&keepers(),
&keepers(),
&BTreeSet::new(),
);
assert_eq!(revokes, Revokes::default());
}
#[test]
fn an_ignored_user_is_never_revoked() {
let ignore = parse_ignore_users(" @ex:t.local\n@other:t.local ");
assert_eq!(ignore, ids(&["@ex:t.local", "@other:t.local"]));
let mut exempt = keepers();
exempt.extend(ignore);
let revokes = plan_revokes(
&ids(&["@ex:t.local"]),
&BTreeSet::new(),
&ids(&["@swarm:t.local", "@mara:t.local", "@ex:t.local"]),
&exempt,
&keepers(),
&BTreeSet::new(),
);
assert_eq!(revokes, Revokes::default());
}
#[test]
fn swarm_the_server_user_and_agents_are_never_revoked() {
// An operator whose username matches an agent's name shares its
// account; leaving the group must not take the agent's admin.
let mut exempt = keepers();
exempt.insert("@argus:t.local".to_owned());
let revokes = plan_revokes(
&ids(&["@swarm:t.local", "@conduit:t.local", "@argus:t.local"]),
&BTreeSet::new(),
&ids(&[
"@swarm:t.local",
"@conduit:t.local",
"@argus:t.local",
"@mara:t.local",
]),
&exempt,
&keepers(),
&BTreeSet::new(),
);
assert_eq!(revokes, Revokes::default());
}
#[test]
fn the_last_admin_besides_swarm_and_the_server_user_is_not_revoked() {
let revokes = plan_revokes(
&ids(&["@exa:t.local", "@exb:t.local"]),
&BTreeSet::new(),
&ids(&[
"@swarm:t.local",
"@conduit:t.local",
"@exa:t.local",
"@exb:t.local",
]),
&keepers(),
&keepers(),
&BTreeSet::new(),
);
assert_eq!(revokes.revoke, ["@exa:t.local"]);
assert_eq!(revokes.last_admin, ["@exb:t.local"]);
}
#[test]
fn a_revoke_already_sent_by_this_process_is_not_sent_again() {
let revokes = plan_revokes(
&ids(&["@ex:t.local"]),
&BTreeSet::new(),
&ids(&["@swarm:t.local", "@mara:t.local", "@ex:t.local"]),
&keepers(),
&keepers(),
&ids(&["@ex:t.local"]),
);
assert_eq!(
revokes,
Revokes {
unconfirmed: vec!["@ex:t.local".to_owned()],
..Revokes::default()
}
);
}
#[test]
fn a_granted_account_out_of_the_room_and_the_group_is_forgotten() {
// Forgotten, so an admin someone makes of it by hand later is theirs.
let revokes = plan_revokes(
&ids(&["@mara:t.local", "@ex:t.local"]),
&ids(&["@mara:t.local"]),
&ids(&["@swarm:t.local", "@mara:t.local"]),
&keepers(),
&keepers(),
&ids(&["@ex:t.local"]),
);
assert_eq!(
revokes,
Revokes {
forget: vec!["@ex:t.local".to_owned()],
..Revokes::default()
}
);
}
#[test]
fn the_granted_set_survives_a_save_and_a_load() {
let dir = std::env::temp_dir().join(format!("operator-admin-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("mkdir");
let path = dir.join(GRANTED_FILE);
let missing = load_granted(&path);
let set = ids(&["@mara:t.local", "@ex:t.local"]);
save_granted(&path, &set).expect("first save");
let first = load_granted(&path);
save_granted(&path, &ids(&["@mara:t.local"])).expect("second save");
let second = load_granted(&path);
let leftovers: Vec<_> = std::fs::read_dir(&dir)
.expect("list")
.map(|e| e.expect("entry").file_name())
.collect();
std::fs::remove_dir_all(&dir).ok();
assert_eq!(missing.expect("a missing file loads"), BTreeSet::new());
assert_eq!(first.expect("loads"), set);
assert_eq!(second.expect("loads"), ids(&["@mara:t.local"]));
assert_eq!(
leftovers,
[GRANTED_FILE],
"no temporary file is left beside it"
);
}
}