matrix: the agent's daemon pulls its linked accounts from bao itself
hive-matrix-daemon now learns which external matrix accounts it has from the swarm secret store, under the agent's own certificate, and the hive push chain for matrix is gone. The daemon lists swarm/agents/<agent>/matrix/ (the `list` its policy grants on its own metadata subtree), reads each account's homeserver from its credential, and brings the accounts up with their tokens from the store. Every two minutes it lists again and exits with 75 when the set of linked accounts changed; the unit restarts on 75 without counting a failure. A listed name whose credential reads as absent is skipped and logged once. At start it removes the matrix-token-<a> / matrix-account-<a>.json pairs a hive delivered (a sidecar marks a pair as delivered; a declared tokenFile keeps its token). Removed: CredentialNotice and the $SWARM.credential.* subject and NATS grant, the controller's publish and its queue precondition on the PUT route, hive-c0re's credential subscription arm and workers/credential.rs, priv_client::write_agent_matrix_token, hive-priv's WriteAgentMatrixToken and its helpers, and the daemon's state-dir account discovery. Kept: WriteAgentGithubToken and the external-forge path (WriteAgentExtraForgeAccount, extra_forges.rs) are untouched, and a declared matrixAccounts tokenFile is still read when the store has no token for that account. Refs #4348
This commit is contained in:
parent
e04616eb70
commit
97fb76ce99
22 changed files with 553 additions and 813 deletions
|
|
@ -2,11 +2,13 @@
|
|||
//!
|
||||
//! A single `hive-matrix-daemon` can serve N matrix accounts (one
|
||||
//! matrix-sdk `Client` each, with its own session/store dir + sync
|
||||
//! loop). Accounts come from the `HIVE_MATRIX_ACCOUNTS` env var (JSON,
|
||||
//! written by the nix harness module from
|
||||
//! loop). Accounts come from two places: the `HIVE_MATRIX_ACCOUNTS` env var
|
||||
//! (JSON, written by the nix harness module from
|
||||
//! `services.hyperhive.agent.matrixAccounts`), which declares the
|
||||
//! **hive-internal account** (named `main`) as an ordinary entry
|
||||
//! alongside any others.
|
||||
//! alongside any others; and the accounts the swarm secret store links to
|
||||
//! this agent ([`discover_linked`]), which an operator linked through the
|
||||
//! swarm UI.
|
||||
//!
|
||||
//! `main` is always present and is always the primary: it is moved to
|
||||
//! index 0 whichever position it was declared at, and if the env var
|
||||
|
|
@ -22,7 +24,7 @@
|
|||
//! `Registry::resolve` / `pick_name`) — the agent can't tell more than one
|
||||
//! account exists, so it must choose explicitly.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
|
|
@ -30,9 +32,9 @@ use anyhow::Context as _;
|
|||
use matrix_sdk::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::paths;
|
||||
use crate::{credential, paths};
|
||||
|
||||
/// One declared matrix account. `homeserver` is optional per account
|
||||
/// One matrix account. `homeserver` is optional per account
|
||||
/// (defaults to the daemon-wide `HIVE_MATRIX_URL`) so accounts on the
|
||||
/// same homeserver need not repeat it.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
|
|
@ -40,9 +42,10 @@ pub struct AccountCfg {
|
|||
/// Logical name the agent uses to address this account
|
||||
/// (`account` arg on the MCP tools). Unique within the daemon.
|
||||
pub name: String,
|
||||
/// Path to the bearer-token file for this account (hive-c0re
|
||||
/// writes it; the daemon reads it).
|
||||
pub token_file: PathBuf,
|
||||
/// The declared bearer-token file, read when the store has no token for
|
||||
/// this account. `None` for an account the store links, whose token only
|
||||
/// the store holds.
|
||||
pub token_file: Option<PathBuf>,
|
||||
/// Per-account matrix-sdk sqlite store dir (crypto keys + cache).
|
||||
pub state_dir: PathBuf,
|
||||
/// Homeserver URL; falls back to [`paths::homeserver_url`] when absent.
|
||||
|
|
@ -54,9 +57,7 @@ impl AccountCfg {
|
|||
/// The effective homeserver URL — this account's own, else the
|
||||
/// daemon-wide `HIVE_MATRIX_URL` — or `None` when neither is set.
|
||||
///
|
||||
/// `None` is a real answer, not a failure: the account is skipped, the
|
||||
/// same way `discover_token_accounts_in` already skips a discovered
|
||||
/// token whose homeserver sidecar is missing.
|
||||
/// `None` is a real answer, not a failure: the account is skipped.
|
||||
#[must_use]
|
||||
pub fn homeserver(&self) -> Option<String> {
|
||||
self.homeserver.clone().or_else(paths::homeserver_url)
|
||||
|
|
@ -67,10 +68,9 @@ impl AccountCfg {
|
|||
/// resolves to when it omits `account`.
|
||||
const HIVE_ACCOUNT: &str = "main";
|
||||
|
||||
/// Build the account list: everything declared in `HIVE_MATRIX_ACCOUNTS`,
|
||||
/// Build the declared account list: everything in `HIVE_MATRIX_ACCOUNTS`,
|
||||
/// with the hive-internal `main` account hoisted to index 0 (= primary)
|
||||
/// or synthesized there when the declaration doesn't carry one, plus any
|
||||
/// dashboard-provisioned accounts discovered on disk.
|
||||
/// or synthesized there when the declaration doesn't carry one.
|
||||
///
|
||||
/// With no `HIVE_MATRIX_ACCOUNTS` set this returns just `main`, so a
|
||||
/// single-account agent is unchanged.
|
||||
|
|
@ -85,19 +85,7 @@ pub fn configured() -> anyhow::Result<Vec<AccountCfg>> {
|
|||
.map_err(|e| anyhow::anyhow!("parse HIVE_MATRIX_ACCOUNTS as JSON array: {e}"))?,
|
||||
None => Vec::new(),
|
||||
};
|
||||
let mut accounts = ensure_hive_account(declared)?;
|
||||
// Append dashboard-provisioned accounts (a `matrix-token-<name>` file +
|
||||
// its `matrix-account-<name>.json` homeserver sidecar) that aren't
|
||||
// already declared in config, so an account logged in via the dashboard
|
||||
// form works without a `matrixAccounts` edit + rebuild. Explicit config
|
||||
// wins on name collision — pass the configured set so discovery skips
|
||||
// those names silently (a statically-configured account keeps its token
|
||||
// on disk but is brought up from config, not discovery, so it must not
|
||||
// log a spurious "no homeserver sidecar" warning).
|
||||
let configured: std::collections::HashSet<String> =
|
||||
accounts.iter().map(|a| a.name.clone()).collect();
|
||||
accounts.extend(discover_token_accounts(&configured));
|
||||
Ok(accounts)
|
||||
ensure_hive_account(declared)
|
||||
}
|
||||
|
||||
/// Put the hive-internal `main` account at index 0 of `declared`, then
|
||||
|
|
@ -135,13 +123,13 @@ fn ensure_hive_account(mut declared: Vec<AccountCfg>) -> anyhow::Result<Vec<Acco
|
|||
0,
|
||||
AccountCfg {
|
||||
name: HIVE_ACCOUNT.to_owned(),
|
||||
token_file: paths::token_file(),
|
||||
token_file: Some(paths::token_file()),
|
||||
state_dir: paths::matrix_state_dir(),
|
||||
homeserver: None,
|
||||
},
|
||||
),
|
||||
}
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut seen = HashSet::new();
|
||||
for a in &declared {
|
||||
if !seen.insert(a.name.as_str()) {
|
||||
anyhow::bail!("duplicate matrix account name {:?}", a.name);
|
||||
|
|
@ -150,88 +138,119 @@ fn ensure_hive_account(mut declared: Vec<AccountCfg>) -> anyhow::Result<Vec<Acco
|
|||
Ok(declared)
|
||||
}
|
||||
|
||||
/// Scan the agent state dir for extra matrix accounts provisioned via the
|
||||
/// dashboard login form: each is a `matrix-token-<name>` file plus a
|
||||
/// `matrix-account-<name>.json` sidecar carrying the homeserver. Returns one
|
||||
/// [`AccountCfg`] per discovered account that has BOTH files — a token
|
||||
/// without a sidecar is skipped, because the homeserver is then unknown and
|
||||
/// defaulting to the hive homeserver would be wrong for an external account.
|
||||
/// Best-effort: an unreadable dir or malformed sidecar yields fewer
|
||||
/// accounts, never an error — explicit `HIVE_MATRIX_ACCOUNTS` config stays
|
||||
/// authoritative.
|
||||
///
|
||||
/// `configured` is the set of names already declared in
|
||||
/// `HIVE_MATRIX_ACCOUNTS`; those are brought up from config regardless of
|
||||
/// their on-disk sidecar, so discovery skips them silently rather than
|
||||
/// warning about a missing sidecar for an account that isn't actually down.
|
||||
fn discover_token_accounts(configured: &std::collections::HashSet<String>) -> Vec<AccountCfg> {
|
||||
let token_path = paths::token_file();
|
||||
let Some(state_dir) = token_path.parent() else {
|
||||
return Vec::new();
|
||||
};
|
||||
discover_token_accounts_in(state_dir, configured)
|
||||
/// The accounts the store links to this agent beyond the declared ones, and
|
||||
/// the linked names that could not be brought up.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Discovery {
|
||||
/// Accounts to bring up, in the order the store lists them.
|
||||
pub accounts: Vec<AccountCfg>,
|
||||
/// Listed names the store holds no credential for.
|
||||
pub missing: Vec<String>,
|
||||
/// Linked accounts stored without a homeserver. Defaulting to the hive's
|
||||
/// would be wrong for an external account, so they are skipped.
|
||||
pub no_homeserver: Vec<String>,
|
||||
}
|
||||
|
||||
/// Body of [`discover_token_accounts`] with the state dir injected, so the
|
||||
/// scan (and the configured-name skip) is unit-testable against a tempdir.
|
||||
fn discover_token_accounts_in(
|
||||
state_dir: &Path,
|
||||
configured: &std::collections::HashSet<String>,
|
||||
) -> Vec<AccountCfg> {
|
||||
let Ok(rd) = std::fs::read_dir(state_dir) else {
|
||||
return Vec::new();
|
||||
/// Ask the store which accounts it links to this agent, as [`AccountCfg`]s
|
||||
/// for every one `declared` does not already name.
|
||||
///
|
||||
/// Empty when the harness names no agent or the container was given no store.
|
||||
///
|
||||
/// # Errors
|
||||
/// The store refusing or failing a read; see [`credential::linked_accounts`].
|
||||
pub async fn discover_linked(declared: &[AccountCfg]) -> anyhow::Result<Discovery> {
|
||||
let Some(agent) = credential::agent_name() else {
|
||||
return Ok(Discovery::default());
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for entry in rd.flatten() {
|
||||
let fname = entry.file_name();
|
||||
let Some(fname) = fname.to_str() else {
|
||||
continue;
|
||||
};
|
||||
// `matrix-token` (no suffix) is the hive account, handled separately;
|
||||
// only `matrix-token-<name>` files are extra accounts.
|
||||
let Some(name) = fname.strip_prefix("matrix-token-") else {
|
||||
continue;
|
||||
};
|
||||
if name.is_empty() {
|
||||
let Some(linked) = credential::linked_accounts(&agent).await? else {
|
||||
return Ok(Discovery::default());
|
||||
};
|
||||
let declared: HashSet<&str> = declared.iter().map(|a| a.name.as_str()).collect();
|
||||
let mut out = linked_cfgs(&state_root(), &declared, linked.found);
|
||||
out.missing = linked.missing;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The pure half of [`discover_linked`], with the state dir injected so it is
|
||||
/// testable without a store.
|
||||
fn linked_cfgs(
|
||||
state_root: &Path,
|
||||
declared: &HashSet<&str>,
|
||||
linked: Vec<credential::Linked>,
|
||||
) -> Discovery {
|
||||
let mut out = Discovery::default();
|
||||
for l in linked {
|
||||
// A declared account is brought up from its declaration.
|
||||
if declared.contains(l.name.as_str()) {
|
||||
continue;
|
||||
}
|
||||
// Already declared in config: it's brought up from `HIVE_MATRIX_ACCOUNTS`,
|
||||
// not discovery, and its on-disk token needs no sidecar. Skip silently so
|
||||
// a statically-configured account doesn't log a spurious missing-sidecar
|
||||
// warning.
|
||||
if configured.contains(name) {
|
||||
continue;
|
||||
}
|
||||
let sidecar = state_dir.join(format!("matrix-account-{name}.json"));
|
||||
let Some(homeserver) = read_account_homeserver(&sidecar) else {
|
||||
tracing::warn!(
|
||||
account = name,
|
||||
sidecar = %sidecar.display(),
|
||||
"matrix: discovered token but no homeserver sidecar; skipping account \
|
||||
(re-link the account from the swarm UI to write it)"
|
||||
);
|
||||
let Some(homeserver) = l.homeserver.filter(|h| !h.is_empty()) else {
|
||||
out.no_homeserver.push(l.name);
|
||||
continue;
|
||||
};
|
||||
out.push(AccountCfg {
|
||||
name: name.to_owned(),
|
||||
token_file: entry.path(),
|
||||
state_dir: state_dir.join(format!("matrix-sdk-state-{name}")),
|
||||
out.accounts.push(AccountCfg {
|
||||
state_dir: state_root.join(format!("matrix-sdk-state-{}", l.name)),
|
||||
name: l.name,
|
||||
token_file: None,
|
||||
homeserver: Some(homeserver),
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Read `{"homeserver": "<url>"}` from a sidecar file. `None` when the file
|
||||
/// is missing, unreadable, not valid JSON, or the `homeserver` field is
|
||||
/// absent / empty.
|
||||
fn read_account_homeserver(path: &Path) -> Option<String> {
|
||||
let raw = std::fs::read_to_string(path).ok()?;
|
||||
let json: serde_json::Value = serde_json::from_str(&raw).ok()?;
|
||||
json.get("homeserver")?
|
||||
.as_str()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
/// This agent's state dir, the parent of the `main` token file.
|
||||
fn state_root() -> PathBuf {
|
||||
paths::token_file()
|
||||
.parent()
|
||||
.map(Path::to_path_buf)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Remove token files a hive delivered into this agent's state dir: each
|
||||
/// `matrix-account-<name>.json` homeserver sidecar, and the
|
||||
/// `matrix-token-<name>` beside it unless `declared` names `<name>`.
|
||||
///
|
||||
/// Nothing in this tree writes those files: an account linked through the
|
||||
/// swarm comes from the store. A sidecar is what marks a pair as delivered
|
||||
/// rather than an operator's: a declared `tokenFile` has none. Best-effort —
|
||||
/// a failure is logged and skipped.
|
||||
pub fn remove_delivered_files(declared: &[AccountCfg]) {
|
||||
let declared: HashSet<&str> = declared.iter().map(|a| a.name.as_str()).collect();
|
||||
remove_delivered_files_in(&state_root(), &declared);
|
||||
}
|
||||
|
||||
/// Body of [`remove_delivered_files`] with the state dir injected.
|
||||
fn remove_delivered_files_in(state_root: &Path, declared: &HashSet<&str>) {
|
||||
let Ok(rd) = std::fs::read_dir(state_root) else {
|
||||
return;
|
||||
};
|
||||
for entry in rd.flatten() {
|
||||
let fname = entry.file_name();
|
||||
let Some(name) = fname
|
||||
.to_str()
|
||||
.and_then(|f| f.strip_prefix("matrix-account-"))
|
||||
.and_then(|f| f.strip_suffix(".json"))
|
||||
.filter(|n| !n.is_empty())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let mut doomed = vec![entry.path()];
|
||||
if !declared.contains(name) {
|
||||
doomed.push(state_root.join(format!("matrix-token-{name}")));
|
||||
}
|
||||
for path in doomed {
|
||||
match std::fs::remove_file(&path) {
|
||||
Ok(()) => {
|
||||
tracing::info!(path = %path.display(), "removed a hive-delivered matrix file");
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => tracing::warn!(
|
||||
path = %path.display(), error = %e,
|
||||
"could not remove a hive-delivered matrix file"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Live status of one matrix account, as reported by [`Registry::list`]
|
||||
|
|
@ -393,10 +412,9 @@ impl Registry {
|
|||
/// file. Idempotent — skips the rewrite when the on-disk content already
|
||||
/// matches, keeping the mtime stable. Used for the boot-time publish.
|
||||
///
|
||||
/// The daemon rebuilds this file fresh on every boot (and is restarted
|
||||
/// by the `matrix-token*` path-watcher when a new account is
|
||||
/// provisioned), so the file lists exactly the accounts that restored at
|
||||
/// the last start. Real-time liveness is conveyed by the file mtime,
|
||||
/// The daemon rebuilds this file fresh on every boot, and restarts itself
|
||||
/// when the store's linked accounts change, so the file lists exactly the
|
||||
/// accounts that restored at the last start. Real-time liveness is conveyed by the file mtime,
|
||||
/// which the daemon advances on a timer via
|
||||
/// [`heartbeat_accounts_snapshot`] — a stalled mtime means the daemon is
|
||||
/// down, so a reader can treat an old snapshot as stale.
|
||||
|
|
@ -454,19 +472,21 @@ fn write_accounts_snapshot_inner(
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashSet;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::{
|
||||
AccountCfg, AccountStatus, discover_token_accounts_in, ensure_hive_account,
|
||||
heartbeat_accounts_snapshot, pick_name, write_accounts_snapshot,
|
||||
AccountCfg, AccountStatus, ensure_hive_account, heartbeat_accounts_snapshot, linked_cfgs,
|
||||
pick_name, remove_delivered_files_in, write_accounts_snapshot,
|
||||
};
|
||||
use crate::credential::Linked;
|
||||
|
||||
/// A declared account, as the nix module would serialize it.
|
||||
fn cfg(name: &str) -> AccountCfg {
|
||||
AccountCfg {
|
||||
name: name.to_owned(),
|
||||
token_file: PathBuf::from(format!("/agents/a/state/matrix-token-{name}")),
|
||||
token_file: Some(PathBuf::from(format!(
|
||||
"/agents/a/state/matrix-token-{name}"
|
||||
))),
|
||||
state_dir: PathBuf::from(format!("/agents/a/state/matrix-sdk-state-{name}")),
|
||||
homeserver: Some(format!("https://{name}.example")),
|
||||
}
|
||||
|
|
@ -482,7 +502,7 @@ mod tests {
|
|||
assert_eq!(out[0].name, "main");
|
||||
assert_eq!(
|
||||
out[0].token_file,
|
||||
PathBuf::from("/agents/a/state/matrix-token-main")
|
||||
Some(PathBuf::from("/agents/a/state/matrix-token-main"))
|
||||
);
|
||||
assert_eq!(out[0].homeserver.as_deref(), Some("https://main.example"));
|
||||
}
|
||||
|
|
@ -521,9 +541,43 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn discovery_skips_configured_names_and_orphan_tokens() {
|
||||
fn linked_accounts_skip_declared_names_and_ones_with_no_homeserver() {
|
||||
let linked = vec![
|
||||
Linked {
|
||||
name: "catgirl".to_owned(),
|
||||
homeserver: Some("https://declared.example".to_owned()),
|
||||
},
|
||||
Linked {
|
||||
name: "bare".to_owned(),
|
||||
homeserver: None,
|
||||
},
|
||||
Linked {
|
||||
name: "good".to_owned(),
|
||||
homeserver: Some("https://good.example".to_owned()),
|
||||
},
|
||||
];
|
||||
let declared: HashSet<&str> = ["main", "catgirl"].into_iter().collect();
|
||||
let out = linked_cfgs(Path::new("/agents/a/state"), &declared, linked);
|
||||
|
||||
let names: Vec<&str> = out.accounts.iter().map(|a| a.name.as_str()).collect();
|
||||
assert_eq!(names, ["good"]);
|
||||
assert_eq!(out.no_homeserver, ["bare"]);
|
||||
let good = &out.accounts[0];
|
||||
// The store holds the token, so there is no file to fall back to; the
|
||||
// session dir keeps the name a hive-delivered account had, so its
|
||||
// crypto store carries over.
|
||||
assert_eq!(good.token_file, None);
|
||||
assert_eq!(
|
||||
good.state_dir,
|
||||
PathBuf::from("/agents/a/state/matrix-sdk-state-good")
|
||||
);
|
||||
assert_eq!(good.homeserver.as_deref(), Some("https://good.example"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delivered_files_go_and_operator_files_stay() {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"hh-acct-disc-{}-{}",
|
||||
"hh-acct-clean-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
|
|
@ -531,33 +585,35 @@ mod tests {
|
|||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
|
||||
// A statically-configured account: token on disk, NO sidecar.
|
||||
let sidecar = r#"{"homeserver":"https://x.example"}"#;
|
||||
// Delivered and not declared: both files go.
|
||||
std::fs::write(dir.join("matrix-token-linked"), "tok").unwrap();
|
||||
std::fs::write(dir.join("matrix-account-linked.json"), sidecar).unwrap();
|
||||
// Delivered and also declared: the sidecar goes, the declared token stays.
|
||||
std::fs::write(dir.join("matrix-token-catgirl"), "tok").unwrap();
|
||||
// An orphan dashboard token: no sidecar, not configured.
|
||||
std::fs::write(dir.join("matrix-token-orphan"), "tok").unwrap();
|
||||
// A fully dashboard-provisioned account: token + sidecar.
|
||||
std::fs::write(dir.join("matrix-token-good"), "tok").unwrap();
|
||||
std::fs::write(
|
||||
dir.join("matrix-account-good.json"),
|
||||
r#"{"homeserver":"https://good.example"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
// The hive account's own token must never be treated as an extra.
|
||||
std::fs::write(dir.join("matrix-account-catgirl.json"), sidecar).unwrap();
|
||||
// An operator's by-hand token has no sidecar, and the hive account's own
|
||||
// token is not an extra: both stay.
|
||||
std::fs::write(dir.join("matrix-token-byhand"), "tok").unwrap();
|
||||
std::fs::write(dir.join("matrix-token"), "tok").unwrap();
|
||||
|
||||
let configured: HashSet<String> = ["main", "catgirl"]
|
||||
.iter()
|
||||
.map(|s| (*s).to_owned())
|
||||
.collect();
|
||||
let mut found: Vec<String> = discover_token_accounts_in(&dir, &configured)
|
||||
.into_iter()
|
||||
.map(|a| a.name)
|
||||
.collect();
|
||||
found.sort();
|
||||
let declared: HashSet<&str> = ["main", "catgirl"].into_iter().collect();
|
||||
remove_delivered_files_in(&dir, &declared);
|
||||
|
||||
// catgirl (configured) + orphan (no sidecar) skipped; only `good` discovered.
|
||||
assert_eq!(found, vec!["good".to_owned()]);
|
||||
let mut left: Vec<String> = std::fs::read_dir(&dir)
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.map(|e| e.file_name().to_string_lossy().into_owned())
|
||||
.collect();
|
||||
left.sort();
|
||||
assert_eq!(
|
||||
left,
|
||||
[
|
||||
"matrix-token",
|
||||
"matrix-token-byhand",
|
||||
"matrix-token-catgirl"
|
||||
]
|
||||
);
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue