Archipelago — open-source initial import
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
//! What the assistant is allowed to read about this node.
|
||||
//!
|
||||
//! # Why this lives on the node and not in the browser
|
||||
//!
|
||||
//! These grants were stored in `localStorage`, which is scoped to an
|
||||
//! **origin**. A node answers on several: its LAN address, its Tailscale
|
||||
//! address, `<host>.local`, and its hostname. Granting "Media" over the LAN
|
||||
//! and returning over Tailscale showed every switch off again — not reset,
|
||||
//! simply never set *there*. Operator-reported as "the AI Data Access settings
|
||||
//! are not persistent through sessions, often turns them all off", and it made
|
||||
//! a working content path look broken: every scope silently returns nothing
|
||||
//! without a grant, so an ungranted permission is indistinguishable from an
|
||||
//! empty library.
|
||||
//!
|
||||
//! The grant answers "what may the assistant read **about this node**". That is
|
||||
//! a property of the node, not of one browser at one address, so the node is
|
||||
//! where it belongs. Stored here it survives a cache clear, a new device, a
|
||||
//! different browser, and any change of address.
|
||||
//!
|
||||
//! # Why the category list is not validated against a hardcoded set
|
||||
//!
|
||||
//! The authoritative list of categories lives in the UI
|
||||
//! (`AI_PERMISSION_CATEGORIES`) and in the context broker's `fetchAndSanitize`.
|
||||
//! Duplicating it here would create a third copy that silently drops a new
|
||||
//! category on upgrade — the grant would round-trip through an older node and
|
||||
//! come back missing. Unknown strings are stored verbatim and simply never
|
||||
//! match a fetch, which fails closed. **Storing a category grants nothing on
|
||||
//! its own**: the broker checks each category before it fetches, and the node
|
||||
//! re-checks before it answers (T-13-33). This file records intent; it is not
|
||||
//! the enforcement point.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
|
||||
const FILE_PATH: &str = "settings/ai_permissions.json";
|
||||
|
||||
/// Defence against a hand-edited or hostile file turning into unbounded
|
||||
/// memory. Far above any real category count.
|
||||
const MAX_CATEGORIES: usize = 64;
|
||||
const MAX_CATEGORY_LEN: usize = 64;
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AiPermissions {
|
||||
/// Categories the operator has granted. Empty means the assistant reads
|
||||
/// nothing about this node, which is the default: a fresh node grants
|
||||
/// nothing until asked.
|
||||
#[serde(default)]
|
||||
pub granted: Vec<String>,
|
||||
}
|
||||
|
||||
impl AiPermissions {
|
||||
/// Drop anything malformed and de-duplicate. Applied on both read and
|
||||
/// write so a hand-edited file cannot produce a state the UI can never
|
||||
/// display or undo.
|
||||
pub fn sanitized(mut self) -> Self {
|
||||
self.granted.retain(|c| {
|
||||
!c.is_empty()
|
||||
&& c.len() <= MAX_CATEGORY_LEN
|
||||
// Category ids are lowercase kebab (`ai-local`). Anything else
|
||||
// is not something this node will ever match against.
|
||||
&& c.chars()
|
||||
.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')
|
||||
});
|
||||
self.granted.sort();
|
||||
self.granted.dedup();
|
||||
self.granted.truncate(MAX_CATEGORIES);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn is_granted(&self, category: &str) -> bool {
|
||||
self.granted.iter().any(|c| c == category)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn load(data_dir: &Path) -> AiPermissions {
|
||||
let path = data_dir.join(FILE_PATH);
|
||||
match tokio::fs::read(&path).await {
|
||||
Ok(bytes) => serde_json::from_slice::<AiPermissions>(&bytes)
|
||||
.map(AiPermissions::sanitized)
|
||||
.unwrap_or_else(|e| {
|
||||
// Fail closed. An unreadable grant file must not be treated as
|
||||
// "everything allowed" — the assistant simply reads nothing
|
||||
// until the operator sets it again.
|
||||
tracing::warn!(error = %e, "AI permissions unreadable; granting nothing");
|
||||
AiPermissions::default()
|
||||
}),
|
||||
// Absent file is the ordinary first-run case, not an error.
|
||||
Err(_) => AiPermissions::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn save(data_dir: &Path, perms: AiPermissions) -> anyhow::Result<AiPermissions> {
|
||||
let perms = perms.sanitized();
|
||||
let path = data_dir.join(FILE_PATH);
|
||||
if let Some(parent) = path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
}
|
||||
// Temp + rename so a crash mid-write cannot leave a truncated file that
|
||||
// reads as "no grants" on the next boot.
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
tokio::fs::write(&tmp, serde_json::to_vec_pretty(&perms)?).await?;
|
||||
tokio::fs::rename(&tmp, &path).await?;
|
||||
Ok(perms)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_fresh_node_grants_nothing() {
|
||||
assert!(AiPermissions::default().granted.is_empty());
|
||||
assert!(!AiPermissions::default().is_granted("media"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_categories_survive_a_round_trip() {
|
||||
// A newer UI may grant a category this binary has never heard of.
|
||||
// Dropping it would silently revoke the grant on downgrade/upgrade.
|
||||
let p = AiPermissions {
|
||||
granted: vec!["media".into(), "some-future-category".into()],
|
||||
}
|
||||
.sanitized();
|
||||
assert!(p.is_granted("some-future-category"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_entries_are_dropped_not_stored() {
|
||||
let p = AiPermissions {
|
||||
granted: vec![
|
||||
"media".into(),
|
||||
"".into(),
|
||||
"UPPER".into(),
|
||||
"has space".into(),
|
||||
"../../etc/passwd".into(),
|
||||
"x".repeat(500),
|
||||
],
|
||||
}
|
||||
.sanitized();
|
||||
assert_eq!(p.granted, vec!["media".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicates_collapse() {
|
||||
let p = AiPermissions {
|
||||
granted: vec!["media".into(), "media".into(), "files".into()],
|
||||
}
|
||||
.sanitized();
|
||||
assert_eq!(p.granted, vec!["files".to_string(), "media".to_string()]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn absent_file_reads_as_no_grants_rather_than_an_error() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert!(load(dir.path()).await.granted.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_corrupt_file_fails_closed() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join(FILE_PATH);
|
||||
tokio::fs::create_dir_all(path.parent().unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
tokio::fs::write(&path, b"{ not json").await.unwrap();
|
||||
// The dangerous failure would be defaulting to "all granted".
|
||||
assert!(load(dir.path()).await.granted.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn saved_grants_survive_a_reload() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
save(
|
||||
dir.path(),
|
||||
AiPermissions {
|
||||
granted: vec!["media".into(), "files".into()],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let back = load(dir.path()).await;
|
||||
assert!(back.is_granted("media"));
|
||||
assert!(back.is_granted("files"));
|
||||
assert!(!back.is_granted("wallet"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//! User-editable settings that are not part of the initial onboarding
|
||||
//! flow. Each submodule persists a focused slice of preferences to
|
||||
//! `<data_dir>/settings/*.json` and exposes a process-wide handle so
|
||||
//! call sites (deep in the transport / RPC / ingest stacks) don't need
|
||||
//! to thread a data_dir or Arc through the entire call graph.
|
||||
|
||||
pub mod ai_permissions;
|
||||
pub mod session_policy;
|
||||
pub mod transport;
|
||||
@@ -0,0 +1,200 @@
|
||||
//! How long a login lasts, and who gets to say so.
|
||||
//!
|
||||
//! # Why this is configurable rather than a constant
|
||||
//!
|
||||
//! There is no single correct session lifetime. The same node can be a
|
||||
//! wall-mounted TV in a living room that must never ask for a password
|
||||
//! mid-film, and a wallet holding real funds where PCI DSS-style guidance
|
||||
//! says fifteen minutes. Both are legitimate; the operator knows which one
|
||||
//! this node is and we do not.
|
||||
//!
|
||||
//! # The two tokens
|
||||
//!
|
||||
//! * **Session token** — short-lived, refreshed silently on every
|
||||
//! authenticated request. This is what the browser sends; if it leaks, it
|
||||
//! is useful only until [`SessionPolicy::idle_timeout_secs`] of silence.
|
||||
//! * **Login (remember) token** — long-lived, and its *only* power is to
|
||||
//! mint a fresh session token. Kept separate so raising the convenience
|
||||
//! knob does not put a 30-day bearer credential on every request.
|
||||
//!
|
||||
//! Raising the idle timeout therefore does not weaken the credential that
|
||||
//! actually travels; it only changes how long a quiet tab stays usable.
|
||||
//!
|
||||
//! # Why an absolute cap exists at all
|
||||
//!
|
||||
//! Idle timeout alone can be defeated by any page that polls — the
|
||||
//! dashboard polls constantly, so an idle timeout would never fire while a
|
||||
//! tab is open. The absolute cap is what guarantees a login eventually
|
||||
//! ends, which is the property an auditor actually asks about.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
|
||||
const FILE_PATH: &str = "settings/session_policy.json";
|
||||
|
||||
/// Bounds. A setting that can be made meaningless is not a setting, and one
|
||||
/// that can lock the operator out of their own node is a footgun.
|
||||
const MIN_IDLE_SECS: u64 = 60;
|
||||
const MAX_IDLE_SECS: u64 = 90 * 24 * 3600;
|
||||
const MIN_ABSOLUTE_SECS: u64 = 300;
|
||||
const MAX_ABSOLUTE_SECS: u64 = 365 * 24 * 3600;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum DeviceClass {
|
||||
/// Ordinary browser on a phone or laptop. Policy applies as configured.
|
||||
Browser,
|
||||
/// A screen nobody logs into — a wall-mounted dashboard or TV. Being
|
||||
/// signed out mid-view is the failure mode here, not a stale session:
|
||||
/// the device is physically in the home, and there is no keyboard to
|
||||
/// re-authenticate with. Exempt from the idle timeout, still subject to
|
||||
/// the absolute cap so a stolen box does not stay authenticated forever.
|
||||
Kiosk,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SessionPolicy {
|
||||
/// Silence after which a session token stops validating.
|
||||
pub idle_timeout_secs: u64,
|
||||
/// Hard ceiling from login, regardless of activity. `None` = no cap.
|
||||
pub absolute_timeout_secs: Option<u64>,
|
||||
/// Re-prompt for the password before actions that move money, however
|
||||
/// fresh the session is. Independent of the timeouts on purpose: it is
|
||||
/// the control that matters when funds are involved, and it costs the
|
||||
/// operator nothing the rest of the time.
|
||||
pub reauth_for_funds: bool,
|
||||
}
|
||||
|
||||
impl Default for SessionPolicy {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
// A day of silence, matching the previous hard-coded constant so
|
||||
// existing nodes see no behaviour change until someone chooses.
|
||||
idle_timeout_secs: 86_400,
|
||||
// 30 days, aligned with the login token's own lifetime: a
|
||||
// session that outlived the token which could refresh it would
|
||||
// be an oddity.
|
||||
absolute_timeout_secs: Some(30 * 24 * 3600),
|
||||
reauth_for_funds: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SessionPolicy {
|
||||
/// Clamp to the supported range. Applied on load as well as on save, so
|
||||
/// a hand-edited file cannot disable expiry by writing `0`.
|
||||
pub fn sanitized(mut self) -> Self {
|
||||
self.idle_timeout_secs = self.idle_timeout_secs.clamp(MIN_IDLE_SECS, MAX_IDLE_SECS);
|
||||
self.absolute_timeout_secs = self
|
||||
.absolute_timeout_secs
|
||||
.map(|v| v.clamp(MIN_ABSOLUTE_SECS, MAX_ABSOLUTE_SECS))
|
||||
// An absolute cap below the idle timeout would expire sessions
|
||||
// while they are still active, which reads as random logouts.
|
||||
.map(|v| v.max(self.idle_timeout_secs));
|
||||
self
|
||||
}
|
||||
|
||||
/// Idle timeout for a given device, or `None` when idleness is not a
|
||||
/// reason to expire (kiosk screens).
|
||||
pub fn idle_timeout_for(&self, class: DeviceClass) -> Option<u64> {
|
||||
match class {
|
||||
DeviceClass::Browser => Some(self.idle_timeout_secs),
|
||||
DeviceClass::Kiosk => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Has a session expired? `age` is time since login, `idle` since last
|
||||
/// use. Both are checked because either alone is insufficient: idle
|
||||
/// never fires on a polling dashboard, and absolute alone leaves a
|
||||
/// forgotten tab usable for a month.
|
||||
pub fn is_expired(&self, class: DeviceClass, age_secs: u64, idle_secs: u64) -> bool {
|
||||
if let Some(limit) = self.absolute_timeout_secs {
|
||||
if age_secs >= limit {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
match self.idle_timeout_for(class) {
|
||||
Some(limit) => idle_secs >= limit,
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn load(data_dir: &Path) -> SessionPolicy {
|
||||
let path = data_dir.join(FILE_PATH);
|
||||
match tokio::fs::read(&path).await {
|
||||
Ok(bytes) => serde_json::from_slice::<SessionPolicy>(&bytes)
|
||||
.map(SessionPolicy::sanitized)
|
||||
.unwrap_or_else(|e| {
|
||||
tracing::warn!(error = %e, "session policy unreadable; using defaults");
|
||||
SessionPolicy::default()
|
||||
}),
|
||||
Err(_) => SessionPolicy::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn save(data_dir: &Path, policy: SessionPolicy) -> anyhow::Result<SessionPolicy> {
|
||||
let policy = policy.sanitized();
|
||||
let path = data_dir.join(FILE_PATH);
|
||||
if let Some(parent) = path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
}
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
tokio::fs::write(&tmp, serde_json::to_vec_pretty(&policy)?).await?;
|
||||
tokio::fs::rename(&tmp, &path).await?;
|
||||
Ok(policy)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn defaults_match_the_previous_hardcoded_behaviour() {
|
||||
let p = SessionPolicy::default();
|
||||
assert_eq!(p.idle_timeout_secs, 86_400);
|
||||
assert!(p.reauth_for_funds);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expiry_cannot_be_disabled_by_hand_editing_the_file() {
|
||||
let p = SessionPolicy {
|
||||
idle_timeout_secs: 0,
|
||||
absolute_timeout_secs: Some(0),
|
||||
reauth_for_funds: false,
|
||||
}
|
||||
.sanitized();
|
||||
assert!(p.idle_timeout_secs >= MIN_IDLE_SECS);
|
||||
assert!(p.absolute_timeout_secs.unwrap() >= MIN_ABSOLUTE_SECS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absolute_cap_is_never_shorter_than_idle() {
|
||||
// Otherwise a session dies while actively in use, which the operator
|
||||
// experiences as being logged out at random.
|
||||
let p = SessionPolicy {
|
||||
idle_timeout_secs: 7 * 24 * 3600,
|
||||
absolute_timeout_secs: Some(3600),
|
||||
reauth_for_funds: true,
|
||||
}
|
||||
.sanitized();
|
||||
assert_eq!(p.absolute_timeout_secs.unwrap(), p.idle_timeout_secs);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_kiosk_never_expires_from_idleness_but_still_has_a_ceiling() {
|
||||
let p = SessionPolicy::default();
|
||||
let a_week = 7 * 24 * 3600;
|
||||
assert!(!p.is_expired(DeviceClass::Kiosk, 60, a_week));
|
||||
assert!(p.is_expired(DeviceClass::Browser, 60, a_week));
|
||||
// The absolute cap still applies to the TV.
|
||||
assert!(p.is_expired(DeviceClass::Kiosk, 31 * 24 * 3600, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_polling_dashboard_still_eventually_expires() {
|
||||
// idle never grows because the page polls; only the cap saves us.
|
||||
let p = SessionPolicy::default();
|
||||
assert!(p.is_expired(DeviceClass::Browser, 30 * 24 * 3600, 0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
//! Per-service transport preferences.
|
||||
//!
|
||||
//! The user picks, for each peer-to-peer service, whether to prefer
|
||||
//! FIPS (mesh overlay), Tor (hidden service fallback), or leave it on
|
||||
//! Auto (FIPS preferred, Tor fallback — the default). Preferences are
|
||||
//! persisted to `<data_dir>/settings/transport_preferences.json` and
|
||||
//! cached in a process-wide handle so that calls from the transport,
|
||||
//! RPC, and ingest stacks can consult them without threading data_dir
|
||||
//! through every signature.
|
||||
//!
|
||||
//! Services covered (matching the Settings UI):
|
||||
//! - `federation` — state sync, invites, peer notifications
|
||||
//! - `peers` — address/DID rotation broadcast
|
||||
//! - `peer_files` — content download / browse / preview
|
||||
//! - `messaging` — archipelago channel + mesh-typed relay
|
||||
//! - `mesh_file_sharing`— content_ref blob fetches over onion
|
||||
//!
|
||||
//! Unknown files parse as `default()` so a missing or corrupt
|
||||
//! preferences file is equivalent to "Auto everywhere."
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use std::sync::OnceLock;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
const FILE_PATH: &str = "settings/transport_preferences.json";
|
||||
|
||||
/// Which transport to use for a given service.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TransportPref {
|
||||
/// FIPS preferred, Tor fallback. The default.
|
||||
#[default]
|
||||
Auto,
|
||||
/// FIPS only. If FIPS is unavailable or the peer has no npub,
|
||||
/// requests fail rather than leaking over Tor.
|
||||
Fips,
|
||||
/// Tor only. Useful when the user explicitly wants onion anonymity
|
||||
/// for a given surface (e.g., first-contact messaging).
|
||||
Tor,
|
||||
}
|
||||
|
||||
/// Enum of peer-facing services that have a preference knob.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PeerService {
|
||||
Federation,
|
||||
Peers,
|
||||
PeerFiles,
|
||||
Messaging,
|
||||
MeshFileSharing,
|
||||
}
|
||||
|
||||
impl PeerService {
|
||||
#[allow(dead_code)]
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Federation => "federation",
|
||||
Self::Peers => "peers",
|
||||
Self::PeerFiles => "peer_files",
|
||||
Self::Messaging => "messaging",
|
||||
Self::MeshFileSharing => "mesh_file_sharing",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Persisted shape. One field per service so the on-disk file is
|
||||
/// self-describing and trivially diff-able.
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct TransportPreferences {
|
||||
pub federation: TransportPref,
|
||||
pub peers: TransportPref,
|
||||
pub peer_files: TransportPref,
|
||||
pub messaging: TransportPref,
|
||||
pub mesh_file_sharing: TransportPref,
|
||||
}
|
||||
|
||||
impl TransportPreferences {
|
||||
pub fn for_service(&self, s: PeerService) -> TransportPref {
|
||||
match s {
|
||||
PeerService::Federation => self.federation,
|
||||
PeerService::Peers => self.peers,
|
||||
PeerService::PeerFiles => self.peer_files,
|
||||
PeerService::Messaging => self.messaging,
|
||||
PeerService::MeshFileSharing => self.mesh_file_sharing,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_for_service(&mut self, s: PeerService, pref: TransportPref) {
|
||||
match s {
|
||||
PeerService::Federation => self.federation = pref,
|
||||
PeerService::Peers => self.peers = pref,
|
||||
PeerService::PeerFiles => self.peer_files = pref,
|
||||
PeerService::Messaging => self.messaging = pref,
|
||||
PeerService::MeshFileSharing => self.mesh_file_sharing = pref,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Process-wide handle ─────────────────────────────────────────────────
|
||||
|
||||
static HANDLE: OnceLock<RwLock<TransportPreferences>> = OnceLock::new();
|
||||
|
||||
/// Initialise the handle from `<data_dir>/settings/transport_preferences.json`.
|
||||
/// Must be called early in startup — before any call that reads `get()`.
|
||||
/// Idempotent: second call reloads.
|
||||
pub async fn init(data_dir: &Path) -> Result<()> {
|
||||
let prefs = load_from_disk(data_dir).await;
|
||||
match HANDLE.get() {
|
||||
Some(lock) => {
|
||||
*lock.write().await = prefs;
|
||||
}
|
||||
None => {
|
||||
HANDLE
|
||||
.set(RwLock::new(prefs))
|
||||
.map_err(|_| anyhow::anyhow!("transport prefs already initialised"))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read the current preference for a service. Returns `Auto` if the
|
||||
/// handle wasn't initialised (tests, fallbacks).
|
||||
pub async fn get(service: PeerService) -> TransportPref {
|
||||
match HANDLE.get() {
|
||||
Some(lock) => lock.read().await.for_service(service),
|
||||
None => TransportPref::Auto,
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the whole preferences block for the Settings UI.
|
||||
pub async fn snapshot() -> TransportPreferences {
|
||||
match HANDLE.get() {
|
||||
Some(lock) => lock.read().await.clone(),
|
||||
None => TransportPreferences::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Update a single service preference, persist to disk, and update the
|
||||
/// handle. Callers must pass `data_dir` because the on-disk file lives
|
||||
/// under it — the handle alone doesn't know where to write.
|
||||
pub async fn set(data_dir: &Path, service: PeerService, pref: TransportPref) -> Result<()> {
|
||||
let new_prefs = {
|
||||
let lock = HANDLE.get_or_init(|| RwLock::new(TransportPreferences::default()));
|
||||
let mut w = lock.write().await;
|
||||
w.set_for_service(service, pref);
|
||||
w.clone()
|
||||
};
|
||||
save_to_disk(data_dir, &new_prefs).await
|
||||
}
|
||||
|
||||
// ── On-disk I/O ─────────────────────────────────────────────────────────
|
||||
|
||||
async fn load_from_disk(data_dir: &Path) -> TransportPreferences {
|
||||
let path = data_dir.join(FILE_PATH);
|
||||
let s = match tokio::fs::read_to_string(&path).await {
|
||||
Ok(s) => s,
|
||||
Err(_) => return TransportPreferences::default(),
|
||||
};
|
||||
serde_json::from_str(&s).unwrap_or_default()
|
||||
}
|
||||
|
||||
async fn save_to_disk(data_dir: &Path, prefs: &TransportPreferences) -> Result<()> {
|
||||
let path = data_dir.join(FILE_PATH);
|
||||
if let Some(parent) = path.parent() {
|
||||
tokio::fs::create_dir_all(parent)
|
||||
.await
|
||||
.with_context(|| format!("create {}", parent.display()))?;
|
||||
}
|
||||
let body = serde_json::to_string_pretty(prefs).context("serialize TransportPreferences")?;
|
||||
tokio::fs::write(&path, body)
|
||||
.await
|
||||
.with_context(|| format!("write {}", path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_is_all_auto() {
|
||||
let p = TransportPreferences::default();
|
||||
for s in [
|
||||
PeerService::Federation,
|
||||
PeerService::Peers,
|
||||
PeerService::PeerFiles,
|
||||
PeerService::Messaging,
|
||||
PeerService::MeshFileSharing,
|
||||
] {
|
||||
assert_eq!(p.for_service(s), TransportPref::Auto);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_then_read_for_service() {
|
||||
let mut p = TransportPreferences::default();
|
||||
p.set_for_service(PeerService::Federation, TransportPref::Fips);
|
||||
assert_eq!(p.for_service(PeerService::Federation), TransportPref::Fips);
|
||||
assert_eq!(p.for_service(PeerService::Peers), TransportPref::Auto);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_round_trips() {
|
||||
let mut p = TransportPreferences::default();
|
||||
p.set_for_service(PeerService::Messaging, TransportPref::Tor);
|
||||
let s = serde_json::to_string(&p).unwrap();
|
||||
let back: TransportPreferences = serde_json::from_str(&s).unwrap();
|
||||
assert_eq!(back.for_service(PeerService::Messaging), TransportPref::Tor);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_names_round_trip() {
|
||||
for s in [
|
||||
PeerService::Federation,
|
||||
PeerService::Peers,
|
||||
PeerService::PeerFiles,
|
||||
PeerService::Messaging,
|
||||
PeerService::MeshFileSharing,
|
||||
] {
|
||||
let json = serde_json::to_value(s).unwrap();
|
||||
assert_eq!(json.as_str().unwrap(), s.as_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user