From 3bc5cc959ad5acce31ea402364df2ba9791162cd Mon Sep 17 00:00:00 2001 From: Alex-Emmet Date: Fri, 24 Jul 2026 01:36:14 +0200 Subject: [PATCH] [WIP] paths --- Cargo.lock | 5 + flake.nix | 55 +- iota-cli/src/ipc_client.rs | 3 +- iota-core/Cargo.toml | 1 + iota-core/src/main.rs | 11 +- iota-daemon-lib/src/ipc_server.rs | 18 +- iota-daemon/Cargo.toml | 1 + iota-daemon/src/main.rs | 94 +++- iota-installer/src/lib.rs | 14 +- iota-logger/Cargo.toml | 1 + iota-logger/src/lib.rs | 39 +- iota-paths/src/lib.rs | 569 ++++++++++++++++---- iota-storage/Cargo.toml | 1 + iota-storage/src/util/config_util.rs | 65 ++- iota-storage/src/util/db.rs | 11 +- iota-updater/Cargo.toml | 1 + iota-updater/src/transaction.rs | 16 +- iota-util/src/file_util.rs | 115 ++-- iota/src/main.rs | 33 +- omikron-connector/src/omikron_connection.rs | 35 +- 20 files changed, 832 insertions(+), 256 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 10d0d16..c5d5a73 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2222,6 +2222,7 @@ dependencies = [ "dashmap", "iota-cli", "iota-logger", + "iota-paths", "iota-state", "iota-storage", "iota-terms", @@ -2250,6 +2251,7 @@ dependencies = [ "iota-paths", "iota-state", "iota-storage", + "iota-util", "omikron-connector", "tokio", "tokio-util", @@ -2301,6 +2303,7 @@ dependencies = [ name = "iota-logger" version = "0.1.0" dependencies = [ + "iota-paths", "iota-state", "iota-util", "json", @@ -2345,6 +2348,7 @@ dependencies = [ "hex", "hkdf 0.12.4", "iota-logger", + "iota-paths", "iota-state", "iota-util", "json", @@ -2390,6 +2394,7 @@ dependencies = [ "hex", "hkdf 0.12.4", "iota-logger", + "iota-paths", "json", "mtp", "once_cell", diff --git a/flake.nix b/flake.nix index 2e560e2..d7bf620 100644 --- a/flake.nix +++ b/flake.nix @@ -108,22 +108,26 @@ cfg = config.services.iota; defaultPackage = self.packages.${pkgs.stdenv.hostPlatform.system}.iota-daemon or (throw "iota: no pre-built package for system ${pkgs.stdenv.hostPlatform.system}"); + configFormat = pkgs.formats.yaml {}; configFile = if cfg.settingsFile != null then cfg.settingsFile - else pkgs.writeText "iota-config.json" (builtins.toJSON cfg.settings); + else configFormat.generate "iota-config.yaml" cfg.settings; descriptionText = "Tensamin Iota daemon"; in { options.services.iota = { enable = lib.mkEnableOption "Enable the Iota service."; - dataDir = lib.mkOption { + stateDir = lib.mkOption { type = lib.types.str; - default = cfg.package.passthru.dataDir or "/var/lib/iota"; - defaultText = lib.literalExpression ''config.services.iota.package.passthru.dataDir or "/var/lib/iota"''; - description = "Directory where Iota stores its data, config, and certificates."; + default = "/var/lib/iota"; + description = "Persistent mutable Iota state."; }; + cacheDir = lib.mkOption { type = lib.types.str; default = "/var/cache/iota"; }; + runtimeDir = lib.mkOption { type = lib.types.str; default = "/run/iota"; }; + logDir = lib.mkOption { type = lib.types.str; default = "/var/log/iota"; }; + assetDir = lib.mkOption { type = lib.types.str; default = "${cfg.package}/share/iota/web"; }; certFile = lib.mkOption { type = lib.types.nullOr lib.types.path; @@ -164,13 +168,13 @@ settings = lib.mkOption { type = lib.types.attrs; default = {}; - description = "Configuration attributes for Iota, written to config.json."; + description = "Configuration attributes for Iota, written to YAML."; }; settingsFile = lib.mkOption { type = lib.types.nullOr lib.types.path; default = null; - description = "Path to an existing config.json file to use instead of generating from settings."; + description = "Path to an existing YAML file to use instead of generating from settings."; }; }; @@ -178,7 +182,7 @@ users.users.iota = { isSystemUser = true; group = "iota"; - home = cfg.dataDir; + home = cfg.stateDir; createHome = true; description = "Iota service user"; shell = pkgs.bash; @@ -194,6 +198,7 @@ SocketMode = "0660"; SocketUser = "iota"; SocketGroup = "iota"; + DirectoryMode = "0750"; Backlog = 5; RemoveOnStop = "true"; NonBlocking = true; @@ -210,28 +215,18 @@ Type = "simple"; User = "iota"; Group = "iota"; - WorkingDirectory = cfg.dataDir; - ExecStart = "${cfg.package}/bin/iota-daemon"; - ExecStartPre = [ - ("+" - + pkgs.writeShellScript "iota-setup" '' - mkdir -p ${cfg.dataDir}/certs - - ${lib.optionalString (cfg.certFile != null) "ln -sf ${cfg.certFile} ${cfg.dataDir}/certs/cert.pem"} - ${lib.optionalString (cfg.keyFile != null) "ln -sf ${cfg.keyFile} ${cfg.dataDir}/certs/cert.key"} - - install -m 644 ${configFile} ${cfg.dataDir}/config.json - - chown -R iota:iota ${cfg.dataDir} - '') - ]; - Restart = "on-failure"; RestartSec = "5s"; RuntimeDirectory = "iota"; RuntimeDirectoryMode = "0750"; + StateDirectory = "iota"; + StateDirectoryMode = "0750"; + CacheDirectory = "iota"; + CacheDirectoryMode = "0750"; + LogsDirectory = "iota"; + LogsDirectoryMode = "0750"; # Exit code 75 = restart requested RestartPreventExitStatus = "0"; @@ -248,7 +243,8 @@ ProtectHome = true; PrivateTmp = true; NoNewPrivileges = true; - ReadWritePaths = [cfg.dataDir]; + ReadWritePaths = [cfg.stateDir cfg.cacheDir cfg.runtimeDir cfg.logDir]; + ReadOnlyPaths = [configFile cfg.assetDir]; ProtectKernelTunables = true; ProtectKernelModules = true; ProtectControlGroups = true; @@ -259,7 +255,14 @@ Environment = [ "BIND_ADDRESS=${cfg.bindAddress}" "IOTA_SOCKET=/run/iota/iota.sock" - "IOTA_DATA_DIR=${cfg.dataDir}" + "IOTA_CONFIG_FILE=${configFile}" + "IOTA_STATE_DIR=${cfg.stateDir}" + "IOTA_CACHE_DIR=${cfg.cacheDir}" + "IOTA_RUNTIME_DIR=${cfg.runtimeDir}" + "IOTA_LOG_DIR=${cfg.logDir}" + "IOTA_ASSET_DIR=${cfg.assetDir}" + "IOTA_DEPLOYMENT_MODE=system_socket_activated" + "IOTA_SUPERVISOR=systemd" ]; } // lib.optionalAttrs (cfg.environmentFiles != []) { diff --git a/iota-cli/src/ipc_client.rs b/iota-cli/src/ipc_client.rs index 186d756..88a1eba 100644 --- a/iota-cli/src/ipc_client.rs +++ b/iota-cli/src/ipc_client.rs @@ -387,8 +387,7 @@ impl IpcClient { }; match result { Ok(message) => self.apply(message).await, - Err(error) => { - eprintln!("IPC reader for generation {generation} stopped: {error}"); + Err(_) => { self.mark_disconnected(generation).await; break; } diff --git a/iota-core/Cargo.toml b/iota-core/Cargo.toml index 763b231..03250d9 100644 --- a/iota-core/Cargo.toml +++ b/iota-core/Cargo.toml @@ -12,6 +12,7 @@ iota-storage = { path = "../iota-storage" } iota-terms = { path = "../iota-terms" } iota-updater = { path = "../iota-updater" } iota-util = { path = "../iota-util" } +iota-paths = { path = "../iota-paths" } omikron-connector = { path = "../omikron-connector" } web-server = { path = "../web-server" } web-ui = { path = "../web-ui" } diff --git a/iota-core/src/main.rs b/iota-core/src/main.rs index 4e0f782..22a942f 100644 --- a/iota-core/src/main.rs +++ b/iota-core/src/main.rs @@ -23,7 +23,16 @@ async fn main() { *state.reload.write().await = false; *state.shutdown.write().await = false; - let ipc = IpcClient::connect("/run/iota/iota.sock") + let socket = match iota_paths::IotaPaths::resolve(iota_paths::Scope::User) + .expect("resolve Iota user paths") + .ipc_endpoint + { + iota_paths::IpcEndpoint::UnixSocket(path) => path, + iota_paths::IpcEndpoint::WindowsPipe(_) => { + panic!("Windows IPC client transport is not implemented yet") + } + }; + let ipc = IpcClient::connect(socket) .await .expect("iota-daemon must be running before starting iota-core"); let session = start_tui(ipc).expect("interactive terminal initialization failed"); diff --git a/iota-daemon-lib/src/ipc_server.rs b/iota-daemon-lib/src/ipc_server.rs index bc99cbc..b6ea1cb 100644 --- a/iota-daemon-lib/src/ipc_server.rs +++ b/iota-daemon-lib/src/ipc_server.rs @@ -44,10 +44,22 @@ impl IpcServer { let listener = match activated_listener()? { Some(listener) => listener, None => { - if let Some(parent) = path.parent() { - tokio::fs::create_dir_all(parent).await?; + let parent = path.parent().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "IPC socket has no parent directory", + ) + })?; + if !parent.is_dir() { + return Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + format!("IPC runtime directory does not exist: {}", parent.display()), + )); } - let lock_path = path.with_extension("sock.lock"); + let lock_path = path + .parent() + .unwrap_or_else(|| Path::new("/tmp")) + .join("daemon.lock"); let lock = File::options() .create(true) .mode(0o600) diff --git a/iota-daemon/Cargo.toml b/iota-daemon/Cargo.toml index 2c5e976..074f91b 100644 --- a/iota-daemon/Cargo.toml +++ b/iota-daemon/Cargo.toml @@ -10,6 +10,7 @@ iota-logger = { path = "../iota-logger" } iota-state = { path = "../iota-state" } iota-paths = { path = "../iota-paths" } iota-storage = { path = "../iota-storage" } +iota-util = { path = "../iota-util" } omikron-connector = { path = "../omikron-connector" } web-server = { path = "../web-server" } tokio = { version = "1.50.0", features = ["full"] } diff --git a/iota-daemon/src/main.rs b/iota-daemon/src/main.rs index 5239baf..12d24fd 100644 --- a/iota-daemon/src/main.rs +++ b/iota-daemon/src/main.rs @@ -10,8 +10,33 @@ use std::time::Duration; use tokio::sync::{broadcast, watch}; #[tokio::main(flavor = "multi_thread")] async fn main() -> ExitCode { - logger::startup(); - iota_storage::util::config_util::load_config(); + let scope = match std::env::var("IOTA_DEPLOYMENT_MODE").ok().as_deref() { + Some("system_socket_activated") | Some("system_always_on") => iota_paths::Scope::System, + _ => iota_paths::Scope::User, + }; + let paths = match iota_paths::IotaPaths::resolve(scope) { + Ok(paths) => paths, + Err(error) => { + eprintln!("Cannot resolve Iota paths: {error}"); + return ExitCode::FAILURE; + } + }; + if let Err(error) = paths.migrate_legacy_layout() { + eprintln!("Cannot migrate legacy Iota layout: {error}"); + return ExitCode::FAILURE; + } + if let Err(error) = paths.prepare_writable_directories() { + eprintln!("Cannot prepare Iota directories: {error}"); + return ExitCode::FAILURE; + } + iota_util::file_util::configure_storage_directory(paths.storage_dir.clone()); + iota_storage::util::config_util::configure_config_path(paths.config_file.clone()); + iota_storage::util::config_util::load_config_from(&paths.config_file); + omikron_connector::omikron_connection::configure_identity_path(paths.keyring_file()); + match paths.scope { + iota_paths::Scope::User => logger::startup_with_log_dir(Some(paths.log_dir.clone())), + iota_paths::Scope::System => logger::startup_with_log_dir(None), + } let runtime = Arc::new(DaemonRuntime::new()); // --- IPC infrastructure --- @@ -36,7 +61,13 @@ async fn main() -> ExitCode { // Bind before migration and service startup: a successful bind is the // readiness boundary visible to clients and socket activation. - let socket = iota_paths::socket_path(iota_paths::SocketScope::User); + let socket = match &paths.ipc_endpoint { + iota_paths::IpcEndpoint::UnixSocket(path) => path.clone(), + iota_paths::IpcEndpoint::WindowsPipe(_) => { + eprintln!("Windows named-pipe daemon transport is not implemented yet"); + return ExitCode::FAILURE; + } + }; let omikron = match omikron_connector::omikron_connection::connect_initial( runtime.cancellation.clone(), runtime.state.active_tasks.clone(), @@ -65,18 +96,22 @@ async fn main() -> ExitCode { } }; let services = DaemonServices::new(omikron); - let ipc_server = - match IpcServer::bind(socket, runtime.clone(), services, log_tx.clone(), state_rx).await { - Ok(server) => server, - Err(error) => { - eprintln!("Cannot bind daemon IPC socket: {error}"); - return ExitCode::FAILURE; - } - }; - eprintln!( - "iota-daemon IPC listener ready at {}", - iota_paths::socket_path(iota_paths::SocketScope::User).display() - ); + let ipc_server = match IpcServer::bind( + socket.clone(), + runtime.clone(), + services, + log_tx.clone(), + state_rx, + ) + .await + { + Ok(server) => server, + Err(error) => { + eprintln!("Cannot bind daemon IPC socket: {error}"); + return ExitCode::FAILURE; + } + }; + eprintln!("iota-daemon IPC listener ready at {}", socket.display()); runtime.set_component_healthy(iota_ipc::ComponentId::Ipc, None); let listener_runtime = runtime.clone(); runtime @@ -128,13 +163,17 @@ async fn main() -> ExitCode { .parse() .unwrap_or(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)), port: web.port, - asset_dir: std::path::PathBuf::from(web.asset_dir), + asset_dir: resolve_config_path(&paths.config_file, &web.asset_dir, &paths.asset_dir), tls: web .certificate .zip(web.key) .map(|(certificate, key)| web_server::TlsConfig { - certificate: certificate.into(), - key: key.into(), + certificate: resolve_config_path( + &paths.config_file, + &certificate, + &paths.config_dir, + ), + key: resolve_config_path(&paths.config_file, &key, &paths.config_dir), }), required: web.required, }; @@ -196,3 +235,22 @@ async fn main() -> ExitCode { log!("iota-daemon exited (code: {})", exit_code); ExitCode::from(exit_code as u8) } + +fn resolve_config_path( + config_file: &std::path::Path, + value: &str, + default: &std::path::Path, +) -> std::path::PathBuf { + if value.is_empty() { + return default.to_path_buf(); + } + let path = std::path::PathBuf::from(value); + if path.is_absolute() { + path + } else { + config_file + .parent() + .expect("absolute configuration file has a parent") + .join(path) + } +} diff --git a/iota-installer/src/lib.rs b/iota-installer/src/lib.rs index f290cf0..3860835 100644 --- a/iota-installer/src/lib.rs +++ b/iota-installer/src/lib.rs @@ -81,7 +81,7 @@ pub fn install_linux_bundle_with_operator(bundle: &Path, operator: Option<&str>) ] { install( &staging.path().join("systemd").join(unit), - &format!("/etc/systemd/system/{unit}"), + &format!("/usr/local/lib/systemd/system/{unit}"), "0644", )?; } @@ -114,7 +114,7 @@ pub fn install_linux_bundle_with_operator(bundle: &Path, operator: Option<&str>) "{}/current/bin/iota-daemon", iota_paths::install_root().display() ), - "/usr/local/lib/iota/iota-daemon", + "/usr/local/libexec/iota/iota-daemon", ], )?; run("systemd-sysusers", &[])?; @@ -130,8 +130,12 @@ pub fn install_linux_bundle_with_operator(bundle: &Path, operator: Option<&str>) run("systemctl", &["enable", "--now", "iota-daemon.socket"])?; run("systemctl", &["is-active", "iota-daemon.socket"])?; run("systemctl", &["is-enabled", "iota-daemon.socket"])?; - if !Path::new("/run/iota/iota.sock").exists() { - bail!("systemd socket is active but /run/iota/iota.sock was not created"); + let socket = iota_paths::socket_path(iota_paths::Scope::System); + if !socket.exists() { + bail!( + "systemd socket is active but {} was not created", + socket.display() + ); } Ok(()) } @@ -175,7 +179,7 @@ mod tests { let service = include_str!("../../systemd/iota-daemon.service"); let socket = include_str!("../../systemd/iota-daemon.socket"); let sysusers = include_str!("../../systemd/sysusers.d/iota.conf"); - assert!(service.contains("ExecStart=/usr/local/lib/iota/iota-daemon")); + assert!(service.contains("ExecStart=/usr/local/libexec/iota/iota-daemon")); assert!(service.contains("User=iota")); assert!(service.contains("Group=iota")); assert!(socket.contains("SocketUser=iota")); diff --git a/iota-logger/Cargo.toml b/iota-logger/Cargo.toml index 530f2f6..5287ecb 100644 --- a/iota-logger/Cargo.toml +++ b/iota-logger/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.0" edition = "2024" [dependencies] +iota-paths = { path = "../iota-paths" } iota-state = { path = "../iota-state" } iota-util = { path = "../iota-util" } diff --git a/iota-logger/src/lib.rs b/iota-logger/src/lib.rs index 8dd7f11..fa1e2f0 100644 --- a/iota-logger/src/lib.rs +++ b/iota-logger/src/lib.rs @@ -1,7 +1,6 @@ use std::{ fs::{self, OpenOptions}, io::Write, - path::Path, sync::{OnceLock, atomic::Ordering, mpsc}, thread, time::{SystemTime, UNIX_EPOCH}, @@ -56,6 +55,15 @@ struct LogMessage { /* The logger owns file persistence while consumers receive rendered entries * through a process-local broadcast subscription. */ pub fn startup() { + startup_with_log_dir(Some( + iota_paths::IotaPaths::resolve(iota_paths::Scope::User) + .expect("resolve Iota user paths") + .log_dir, + )); +} + +/// `None` keeps logging on stderr only (the systemd default). +pub fn startup_with_log_dir(log_dir: Option) { let (tx, rx) = mpsc::channel::(); if LOGGER.set(tx).is_err() { return; @@ -64,22 +72,15 @@ pub fn startup() { let _ = LOG_BROADCASTER.set(broadcast_tx.clone()); thread::spawn(move || { - let working_dir = iota_util::file_util::get_directory(); - let base_dir = Path::new(&working_dir); - let log_dir = base_dir.join("logs"); - fs::create_dir_all(&log_dir).expect("Failed to create log directory"); - - let start_ts = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs(); - - let path = log_dir.join(format!("log_{}.txt", start_ts)); - let mut file = OpenOptions::new() - .create(true) - .append(true) - .open(path) - .expect("Failed to open log file"); + let mut file = log_dir.and_then(|log_dir| { + fs::create_dir_all(&log_dir).ok()?; + let start_ts = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs(); + OpenOptions::new() + .create(true) + .append(true) + .open(log_dir.join(format!("log_{start_ts}.txt"))) + .ok() + }); for msg in rx { let resolved_message = if let Some(key) = msg.translation_key { @@ -105,7 +106,9 @@ pub fn startup() { ); let line2 = format!(" {}", timestamp); - let _ = writeln!(file, "{}\n{}", line1, line2); + if let Some(file) = file.as_mut() { + let _ = writeln!(file, "{}\n{}", line1, line2); + } let _ = writeln!(std::io::stderr(), "{}\n{}", line1, line2); diff --git a/iota-paths/src/lib.rs b/iota-paths/src/lib.rs index 4461e1e..35a51bd 100644 --- a/iota-paths/src/lib.rs +++ b/iota-paths/src/lib.rs @@ -1,118 +1,412 @@ -use std::path::PathBuf; +//! Platform and deployment aware locations used by Iota. +//! +//! This module deliberately keeps environment handling in one place. In +//! particular, an override is never interpreted relative to the process +//! working directory. +use std::env; +use std::fmt; +use std::path::{Path, PathBuf}; -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum SocketScope { +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Scope { User, System, } -fn home_dir() -> PathBuf { - std::env::var_os("HOME") - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from(".")) +/// Compatibility name retained for callers which have not yet been migrated. +pub type SocketScope = Scope; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum IpcEndpoint { + UnixSocket(PathBuf), + WindowsPipe(String), } -pub fn data_dir() -> PathBuf { - if let Some(path) = std::env::var_os("IOTA_DATA_DIR") { - return PathBuf::from(path); - } - - #[cfg(target_os = "linux")] - { - return std::env::var_os("XDG_STATE_HOME") - .map(PathBuf::from) - .unwrap_or_else(|| home_dir().join(".local/state")) - .join("iota"); - } - #[cfg(target_os = "macos")] - { - return home_dir().join("Library/Application Support/Iota"); - } - #[cfg(target_os = "windows")] - { - return std::env::var_os("LOCALAPPDATA") - .map(PathBuf::from) - .unwrap_or_else(home_dir) - .join("Tensamin/Iota"); - } - #[allow(unreachable_code)] - home_dir().join(".iota") +#[derive(Debug)] +pub enum PathError { + MissingPlatformDirectory(&'static str), + EmptyOverride(&'static str), + RelativeOverride { + variable: &'static str, + value: PathBuf, + }, + InvalidPipeName(String), + UnsupportedScope, } -pub fn config_dir() -> PathBuf { - if let Some(path) = std::env::var_os("IOTA_CONFIG_DIR") { - return PathBuf::from(path); - } - #[cfg(target_os = "linux")] - { - return std::env::var_os("XDG_CONFIG_HOME") - .map(PathBuf::from) - .unwrap_or_else(|| home_dir().join(".config")) - .join("iota"); - } - #[cfg(target_os = "macos")] - { - return home_dir().join("Library/Application Support/Iota"); - } - #[cfg(target_os = "windows")] - { - return std::env::var_os("APPDATA") - .map(PathBuf::from) - .unwrap_or_else(home_dir) - .join("Tensamin/Iota"); - } - #[allow(unreachable_code)] - home_dir().join(".iota") -} - -pub fn socket_override() -> Option { - std::env::var_os("IOTA_SOCKET").map(PathBuf::from) -} - -pub fn socket_path(scope: SocketScope) -> PathBuf { - if let Some(path) = socket_override() { - return path; - } - match scope { - SocketScope::User => data_dir().join("iota.sock"), - SocketScope::System => PathBuf::from("/run/iota/iota.sock"), +impl fmt::Display for PathError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::MissingPlatformDirectory(name) => write!(f, "missing platform directory: {name}"), + Self::EmptyOverride(name) => write!(f, "{name} must not be empty"), + Self::RelativeOverride { variable, value } => { + write!(f, "{variable} must be absolute, got {}", value.display()) + } + Self::InvalidPipeName(name) => write!(f, "invalid Windows pipe name: {name}"), + Self::UnsupportedScope => write!(f, "this path scope is unsupported on this platform"), + } } } +impl std::error::Error for PathError {} -pub fn socket_lock_path(scope: SocketScope) -> PathBuf { - let socket = socket_path(scope); - PathBuf::from(format!("{}.lock", socket.display())) +#[derive(Clone, Debug)] +pub struct IotaPaths { + pub scope: Scope, + pub config_dir: PathBuf, + pub config_file: PathBuf, + pub state_dir: PathBuf, + pub storage_dir: PathBuf, + pub identity_dir: PathBuf, + pub cache_dir: PathBuf, + pub runtime_dir: Option, + pub log_dir: PathBuf, + /// Directory containing static web assets (not its parent). + pub asset_dir: PathBuf, + pub install_root: PathBuf, + pub ipc_endpoint: IpcEndpoint, } -pub fn daemon_executable() -> PathBuf { - if let Some(path) = std::env::var_os("IOTA_DAEMON_PATH") { - return PathBuf::from(path); +impl IotaPaths { + pub fn resolve(scope: Scope) -> Result { + let defaults = Defaults::for_scope(scope)?; + let config_dir = override_first(&["IOTA_CONFIG_DIR"])?.unwrap_or(defaults.config_dir); + // IOTA_DATA_DIR is intentionally only a compatibility alias. Parse it + // exactly like every other override; do not hide an invalid value. + let state_dir = + override_first(&["IOTA_STATE_DIR", "IOTA_DATA_DIR"])?.unwrap_or(defaults.state_dir); + let cache_dir = override_first(&["IOTA_CACHE_DIR"])?.unwrap_or(defaults.cache_dir); + let runtime_dir = override_first(&["IOTA_RUNTIME_DIR"])?.or(defaults.runtime_dir); + let log_dir = override_first(&["IOTA_LOG_DIR"])?.unwrap_or(defaults.log_dir); + let asset_dir = override_first(&["IOTA_ASSET_DIR", "IOTA_WEB_ASSET_DIR"])? + .unwrap_or(defaults.asset_dir); + let install_root = override_first(&["IOTA_INSTALL_ROOT"])?.unwrap_or(defaults.install_root); + let config_file = override_first(&["IOTA_CONFIG_FILE"])? + .unwrap_or_else(|| config_dir.join("config.yaml")); + let ipc_endpoint = resolve_ipc(scope, runtime_dir.as_deref(), defaults.ipc_endpoint)?; + Ok(Self { + scope, + config_dir, + config_file, + storage_dir: state_dir.join("storage"), + identity_dir: state_dir.join("identity"), + state_dir, + cache_dir, + runtime_dir, + log_dir, + asset_dir, + install_root, + ipc_endpoint, + }) } - if let Ok(exe) = std::env::current_exe() { - if let Some(path) = exe.parent().map(|p| p.join("iota-daemon")) { - if path.is_file() { - return path; + + pub fn database_file(&self) -> PathBuf { + self.storage_dir.join("messages.sqlite3") + } + pub fn keyring_file(&self) -> PathBuf { + self.identity_dir.join("iota.mk") + } + pub fn update_staging_dir(&self) -> PathBuf { + self.cache_dir.join("updates/staging") + } + pub fn update_status_file(&self) -> PathBuf { + self.state_dir.join("update-status.json") + } + pub fn update_lock_file(&self) -> Result { + self.runtime_dir + .as_ref() + .map(|p| p.join("update.lock")) + .ok_or(PathError::MissingPlatformDirectory("runtime directory")) + } + pub fn daemon_lock_file(&self) -> Result { + self.runtime_dir + .as_ref() + .map(|p| p.join("daemon.lock")) + .ok_or(PathError::MissingPlatformDirectory("runtime directory")) + } + pub fn prepare_writable_directories(&self) -> std::io::Result<()> { + for directory in [ + &self.state_dir, + &self.storage_dir, + &self.identity_dir, + &self.cache_dir, + &self.log_dir, + ] { + create_directory(directory, self.scope == Scope::User)?; + } + if let Some(runtime) = &self.runtime_dir { + create_directory(runtime, self.scope == Scope::User)?; + } + Ok(()) + } + + /// Move the pre-v2 resources that were all placed directly below the + /// state root. This is deliberately idempotent: an existing destination + /// is never overwritten and the marker is only written after the moves. + pub fn migrate_legacy_layout(&self) -> std::io::Result<()> { + let marker = self.state_dir.join("path-layout-v2.json"); + if marker.exists() { + return Ok(()); + } + move_if_absent(&self.state_dir.join("config.yaml"), &self.config_file)?; + move_if_absent(&self.state_dir.join("certs"), &self.config_dir.join("tls"))?; + for suffix in [ + "messages.sqlite3", + "messages.sqlite3-wal", + "messages.sqlite3-shm", + ] { + move_if_absent(&self.state_dir.join(suffix), &self.storage_dir.join(suffix))?; + } + for name in ["users", "communities"] { + move_if_absent(&self.state_dir.join(name), &self.storage_dir.join(name))?; + } + move_if_absent(&self.state_dir.join("iota.mk"), &self.keyring_file())?; + move_if_absent( + &self.state_dir.join("update-staging"), + &self.update_staging_dir(), + )?; + // Runtime objects must not survive a layout migration or reboot. + for name in ["update.lock", "iota.sock", "iota.sock.lock"] { + let path = self.state_dir.join(name); + if path.is_file() || path.is_symlink() { + let _ = std::fs::remove_file(path); } } + std::fs::create_dir_all(&self.state_dir)?; + std::fs::write(marker, "{\"version\":2}\n") } - #[cfg(target_os = "linux")] - { - let installed = PathBuf::from("/usr/local/lib/iota/iota-daemon"); - if installed.is_file() { - return installed; - } - } - PathBuf::from("iota-daemon") } -pub fn updater_executable() -> PathBuf { - std::env::var_os("IOTA_UPDATER_PATH") - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from("iota-updater")) +fn move_if_absent(source: &Path, destination: &Path) -> std::io::Result<()> { + if !source.exists() || destination.exists() { + return Ok(()); + } + let metadata = std::fs::symlink_metadata(source)?; + if metadata.file_type().is_symlink() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("refusing symlink migration source {}", source.display()), + )); + } + if let Some(parent) = destination.parent() { + std::fs::create_dir_all(parent)?; + } + match std::fs::rename(source, destination) { + Ok(()) => Ok(()), + Err(error) if error.raw_os_error() == Some(libc_exdev()) => { + copy_recursively(source, destination)?; + if source.is_dir() { + std::fs::remove_dir_all(source) + } else { + std::fs::remove_file(source) + } + } + Err(error) => Err(error), + } } + +// EXDEV is stable on Unix. A literal is used on non-Unix where the fallback +// copy is harmlessly skipped because rename normally remains on one volume. +#[cfg(unix)] +fn libc_exdev() -> i32 { + 18 +} +#[cfg(not(unix))] +fn libc_exdev() -> i32 { + -1 +} +fn copy_recursively(source: &Path, destination: &Path) -> std::io::Result<()> { + if source.is_dir() { + std::fs::create_dir_all(destination)?; + for entry in std::fs::read_dir(source)? { + let entry = entry?; + copy_recursively(&entry.path(), &destination.join(entry.file_name()))?; + } + Ok(()) + } else { + std::fs::copy(source, destination).map(|_| ()) + } +} + +struct Defaults { + config_dir: PathBuf, + state_dir: PathBuf, + cache_dir: PathBuf, + runtime_dir: Option, + log_dir: PathBuf, + asset_dir: PathBuf, + install_root: PathBuf, + ipc_endpoint: IpcEndpoint, +} +impl Defaults { + fn for_scope(scope: Scope) -> Result { + match scope { + Scope::System => { + #[cfg(target_os = "linux")] + { + Ok(Self { + config_dir: "/etc/iota".into(), + state_dir: "/var/lib/iota".into(), + cache_dir: "/var/cache/iota".into(), + runtime_dir: Some("/run/iota".into()), + log_dir: "/var/log/iota".into(), + asset_dir: "/usr/local/share/iota/web".into(), + install_root: "/usr/local/libexec/iota".into(), + ipc_endpoint: IpcEndpoint::UnixSocket("/run/iota/iota.sock".into()), + }) + } + #[cfg(not(target_os = "linux"))] + { + Err(PathError::UnsupportedScope) + } + } + Scope::User => user_defaults(), + } + } +} + +#[cfg(unix)] +fn user_defaults() -> Result { + let home = + absolute_env("HOME")?.ok_or(PathError::MissingPlatformDirectory("home directory"))?; + let config_base = xdg_or_home("XDG_CONFIG_HOME", &home, ".config")?; + let state_base = xdg_or_home("XDG_STATE_HOME", &home, ".local/state")?; + let cache_base = xdg_or_home("XDG_CACHE_HOME", &home, ".cache")?; + let data_base = xdg_or_home("XDG_DATA_HOME", &home, ".local/share")?; + let runtime = absolute_env("XDG_RUNTIME_DIR")? + .ok_or(PathError::MissingPlatformDirectory("XDG_RUNTIME_DIR"))? + .join("iota"); + Ok(Defaults { + config_dir: config_base.join("iota"), + state_dir: state_base.join("iota"), + cache_dir: cache_base.join("iota"), + runtime_dir: Some(runtime.clone()), + log_dir: state_base.join("iota/logs"), + asset_dir: data_base.join("iota/web"), + install_root: data_base.join("iota/bin"), + ipc_endpoint: IpcEndpoint::UnixSocket(runtime.join("iota.sock")), + }) +} +#[cfg(windows)] +fn user_defaults() -> Result { + let config = absolute_env("APPDATA")? + .ok_or(PathError::MissingPlatformDirectory("Roaming AppData"))? + .join("Tensamin/Iota/config"); + let local = absolute_env("LOCALAPPDATA")? + .ok_or(PathError::MissingPlatformDirectory("Local AppData"))? + .join("Tensamin/Iota"); + Ok(Defaults { + config_dir: config, + state_dir: local.join("state"), + cache_dir: local.join("cache"), + runtime_dir: None, + log_dir: local.join("logs"), + asset_dir: local.join("data"), + install_root: local.join("bin"), + ipc_endpoint: IpcEndpoint::WindowsPipe(r"\\.\pipe\Tensamin.Iota.User".into()), + }) +} + +fn xdg_or_home(variable: &'static str, home: &Path, fallback: &str) -> Result { + Ok(absolute_env(variable)?.unwrap_or_else(|| home.join(fallback))) +} +fn absolute_env(name: &'static str) -> Result, PathError> { + let Some(value) = env::var_os(name) else { + return Ok(None); + }; + if value.is_empty() { + return Err(PathError::EmptyOverride(name)); + } + let path = PathBuf::from(value); + if !path.is_absolute() { + return Err(PathError::RelativeOverride { + variable: name, + value: path, + }); + } + Ok(Some(path)) +} +fn override_first(names: &[&'static str]) -> Result, PathError> { + for name in names { + if let Some(value) = absolute_env(name)? { + return Ok(Some(value)); + } + } + Ok(None) +} +fn resolve_ipc( + scope: Scope, + runtime: Option<&Path>, + default: IpcEndpoint, +) -> Result { + #[cfg(unix)] + { + if let Some(path) = absolute_env("IOTA_SOCKET")? { + return Ok(IpcEndpoint::UnixSocket(path)); + } + if scope == Scope::User { + return runtime + .map(|dir| IpcEndpoint::UnixSocket(dir.join("iota.sock"))) + .ok_or(PathError::MissingPlatformDirectory("XDG_RUNTIME_DIR")); + } + } + #[cfg(windows)] + { + if let Some(name) = env::var_os("IOTA_PIPE") { + let name = name.to_string_lossy().into_owned(); + if !name.starts_with(r"\\.\pipe\") { + return Err(PathError::InvalidPipeName(name)); + } + return Ok(IpcEndpoint::WindowsPipe(name)); + } + } + Ok(default) +} +fn create_directory(path: &Path, private: bool) -> std::io::Result<()> { + std::fs::create_dir_all(path)?; + #[cfg(unix)] + if private { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))?; + } + Ok(()) +} + +// Compatibility helpers. New code should resolve IotaPaths once and pass it +// to its dependencies instead of calling these independently. +pub fn data_dir() -> PathBuf { + IotaPaths::resolve(Scope::User) + .expect("resolve Iota user paths") + .state_dir +} +pub fn config_dir() -> PathBuf { + IotaPaths::resolve(Scope::User) + .expect("resolve Iota user paths") + .config_dir +} +pub fn socket_override() -> Option { + absolute_env("IOTA_SOCKET").ok().flatten() +} +pub fn socket_path(scope: SocketScope) -> PathBuf { + match IotaPaths::resolve(scope) + .expect("resolve Iota paths") + .ipc_endpoint + { + IpcEndpoint::UnixSocket(path) => path, + IpcEndpoint::WindowsPipe(_) => panic!("Windows IPC endpoint is not a filesystem path"), + } +} +pub fn socket_lock_path(scope: SocketScope) -> PathBuf { + IotaPaths::resolve(scope) + .expect("resolve Iota paths") + .daemon_lock_file() + .expect("runtime directory") +} +/// The compatibility installation helpers describe the machine installation, +/// not a user's data directory. Per-user launchers should keep an +/// `IotaPaths` instance and use its `install_root` directly. pub fn install_root() -> PathBuf { - std::env::var_os("IOTA_INSTALL_ROOT") - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from("/usr/local/lib/iota")) + IotaPaths::resolve(Scope::System) + .expect("resolve Iota system paths") + .install_root } pub fn versions_dir() -> PathBuf { install_root().join("versions") @@ -121,36 +415,91 @@ pub fn current_version_link() -> PathBuf { install_root().join("current") } pub fn updater_lock_path() -> PathBuf { - data_dir().join("update.lock") + IotaPaths::resolve(Scope::User) + .expect("resolve Iota user paths") + .update_lock_file() + .expect("runtime directory") } pub fn updater_status_path() -> PathBuf { - data_dir().join("update-status.json") + IotaPaths::resolve(Scope::User) + .expect("resolve Iota user paths") + .update_status_file() } pub fn updater_staging_dir() -> PathBuf { - data_dir().join("update-staging") + IotaPaths::resolve(Scope::User) + .expect("resolve Iota user paths") + .update_staging_dir() } pub fn web_asset_dir() -> PathBuf { - std::env::var_os("IOTA_WEB_ASSET_DIR") - .map(PathBuf::from) - .unwrap_or_else(|| data_dir().join("web")) + IotaPaths::resolve(Scope::User) + .expect("resolve Iota user paths") + .asset_dir +} +pub fn daemon_executable() -> PathBuf { + absolute_env("IOTA_DAEMON_PATH") + .expect("valid IOTA_DAEMON_PATH") + .unwrap_or_else(|| install_root().join("current/bin/iota-daemon")) +} +pub fn updater_executable() -> PathBuf { + absolute_env("IOTA_UPDATER_PATH") + .expect("valid IOTA_UPDATER_PATH") + .unwrap_or_else(|| install_root().join("current/bin/iota-updater")) } - pub fn daemon_endpoints() -> Vec { - if let Some(path) = socket_override() { - return vec![path]; - } - vec![ - socket_path(SocketScope::User), - socket_path(SocketScope::System), - ] + vec![socket_path(Scope::User), socket_path(Scope::System)] } #[cfg(test)] mod tests { use super::*; + use std::sync::atomic::{AtomicU64, Ordering}; + + static TEST_ID: AtomicU64 = AtomicU64::new(0); + #[test] + fn system_layout_is_fhs() { + let p = IotaPaths::resolve(Scope::System).unwrap(); + assert_eq!(p.config_file, PathBuf::from("/etc/iota/config.yaml")); + assert_eq!( + p.database_file(), + PathBuf::from("/var/lib/iota/storage/messages.sqlite3") + ); + assert_eq!( + p.update_staging_dir(), + PathBuf::from("/var/cache/iota/updates/staging") + ); + } #[test] - fn explicit_data_directory_wins() { - assert!(!data_dir().as_os_str().is_empty()); + fn migration_moves_state_resources_without_overwriting_destination() { + let root = std::env::temp_dir().join(format!( + "iota-paths-test-{}-{}", + std::process::id(), + TEST_ID.fetch_add(1, Ordering::Relaxed) + )); + let state = root.join("state"); + let config = root.join("config"); + let paths = IotaPaths { + scope: Scope::User, + config_dir: config.clone(), + config_file: config.join("config.yaml"), + storage_dir: state.join("storage"), + identity_dir: state.join("identity"), + cache_dir: root.join("cache"), + runtime_dir: Some(root.join("runtime")), + log_dir: state.join("logs"), + asset_dir: root.join("data/web"), + install_root: root.join("bin"), + ipc_endpoint: IpcEndpoint::UnixSocket(root.join("runtime/iota.sock")), + state_dir: state.clone(), + }; + std::fs::create_dir_all(state.join("users")).unwrap(); + std::fs::write(state.join("messages.sqlite3"), b"db").unwrap(); + std::fs::write(state.join("config.yaml"), b"web: {}\n").unwrap(); + paths.migrate_legacy_layout().unwrap(); + assert!(paths.database_file().is_file()); + assert!(paths.storage_dir.join("users").is_dir()); + assert!(paths.config_file.is_file()); + assert!(state.join("path-layout-v2.json").is_file()); + let _ = std::fs::remove_dir_all(root); } } diff --git a/iota-storage/Cargo.toml b/iota-storage/Cargo.toml index 24d7603..50f7cc6 100644 --- a/iota-storage/Cargo.toml +++ b/iota-storage/Cargo.toml @@ -7,6 +7,7 @@ edition = "2024" iota-logger = { path = "../iota-logger" } iota-state = { path = "../iota-state" } iota-util = { path = "../iota-util" } +iota-paths = { path = "../iota-paths" } mtp = { git = "https://git.methanium.net/Methanium/mtp.git" } diff --git a/iota-storage/src/util/config_util.rs b/iota-storage/src/util/config_util.rs index 5685208..380ba6c 100644 --- a/iota-storage/src/util/config_util.rs +++ b/iota-storage/src/util/config_util.rs @@ -1,8 +1,10 @@ use arc_swap::ArcSwap; -use iota_util::file_util::{load_file, save_file}; use once_cell::sync::Lazy; use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::{Path, PathBuf}; use std::sync::Arc; +use std::sync::OnceLock; pub static CONFIG: Lazy> = Lazy::new(|| ArcSwap::new(Arc::new(IotaConfig::default()))); @@ -19,11 +21,11 @@ pub struct IotaConfig { pub omikron_host: Option, #[serde(skip_serializing_if = "Option::is_none")] pub omikron_port: Option, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde(skip_serializing)] pub keyring: Option, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde(skip_serializing)] pub public_key: Option, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde(skip_serializing)] pub private_key: Option, #[serde(default = "default_read_receipts_enabled")] pub read_receipts_enabled: bool, @@ -61,7 +63,7 @@ fn default_web_bind() -> String { "127.0.0.1".into() } fn default_web_asset_dir() -> String { - "web".into() + String::new() } impl Default for WebSettings { fn default() -> Self { @@ -102,17 +104,27 @@ impl Default for IotaConfig { } pub fn load_config() { - let s = load_file("", "config.yaml"); - if s.is_empty() { - return; - } + load_config_from(&default_config_path()); +} + +/// Loading is intentionally side-effect free: a missing configuration means +/// documented defaults, not a newly-created file. +pub fn load_config_from(path: &Path) { + let s = match fs::read_to_string(path) { + Ok(contents) => contents, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return, + Err(error) => { + eprintln!("Failed to read {}: {error}", path.display()); + return; + } + }; match serde_yaml::from_str::(&s) { Ok(parsed) => { CONFIG.store(Arc::new(parsed)); } Err(e) => { - eprintln!("Failed to parse config.yaml: {}. Content: '{}'", e, s); + eprintln!("Failed to parse {}: {e}", path.display()); } } } @@ -123,14 +135,45 @@ pub fn clear_config() { } pub fn save_config() { + save_config_to(&default_config_path()); +} + +pub fn save_config_to(path: &Path) { if let Ok(yaml) = serde_yaml::to_string(&**CONFIG.load()) { - save_file("", "config.yaml", &yaml); + if let Some(parent) = path.parent() { + if let Err(error) = fs::create_dir_all(parent) { + eprintln!( + "Cannot create configuration directory {}: {error}", + parent.display() + ); + return; + } + } + let temporary = path.with_extension("yaml.tmp"); + if let Err(error) = fs::write(&temporary, yaml).and_then(|_| fs::rename(&temporary, path)) { + eprintln!("Cannot save {}: {error}", path.display()); + let _ = fs::remove_file(temporary); + } } } +fn default_config_path() -> PathBuf { + if let Some(path) = CONFIG_PATH.get() { + return path.clone(); + } + iota_paths::IotaPaths::resolve(iota_paths::Scope::User) + .expect("resolve Iota user paths") + .config_file +} + pub fn modify_config(f: impl FnOnce(&mut IotaConfig)) { let mut cfg = IotaConfig::clone(&**CONFIG.load()); f(&mut cfg); CONFIG.store(Arc::new(cfg)); save_config(); } +static CONFIG_PATH: OnceLock = OnceLock::new(); + +pub fn configure_config_path(path: PathBuf) { + let _ = CONFIG_PATH.set(path); +} diff --git a/iota-storage/src/util/db.rs b/iota-storage/src/util/db.rs index dfe9d2d..63debe3 100644 --- a/iota-storage/src/util/db.rs +++ b/iota-storage/src/util/db.rs @@ -1,4 +1,3 @@ -use iota_util::file_util::get_directory; use once_cell::sync::Lazy; use r2d2::ManageConnection; use rusqlite::Connection; @@ -56,10 +55,12 @@ where f(&conn) } -fn db_file_path(db_name: &str) -> String { - let mut p = PathBuf::from(get_directory()); - p.push(format!("{db_name}.sqlite3")); - p.to_string_lossy().to_string() +fn db_file_path(db_name: &str) -> PathBuf { + let storage_dir = iota_util::file_util::storage_directory(); + // Creating storage belongs to initialization/connection setup, never to a + // configuration read. + std::fs::create_dir_all(&storage_dir).expect("create Iota storage directory"); + storage_dir.join(format!("{db_name}.sqlite3")) } fn run_migrations(pool: &r2d2::Pool) -> Result<(), StorageError> { diff --git a/iota-updater/Cargo.toml b/iota-updater/Cargo.toml index d483f7c..d089180 100644 --- a/iota-updater/Cargo.toml +++ b/iota-updater/Cargo.toml @@ -5,6 +5,7 @@ edition = "2024" [dependencies] iota-logger = { path = "../iota-logger" } +iota-paths = { path = "../iota-paths" } mtp = { git = "https://git.methanium.net/Methanium/mtp.git" } diff --git a/iota-updater/src/transaction.rs b/iota-updater/src/transaction.rs index 9dfcd4e..342b2f8 100644 --- a/iota-updater/src/transaction.rs +++ b/iota-updater/src/transaction.rs @@ -9,22 +9,32 @@ use std::{ pub struct UpdateTransaction { pub root: PathBuf, pub staging: PathBuf, + pub lock_file: PathBuf, } impl UpdateTransaction { pub fn new(root: impl Into) -> Self { let root = root.into(); Self { staging: root.join(".staging"), + lock_file: root.join("update.lock"), root, } } + pub fn from_paths(paths: &iota_paths::IotaPaths) -> Result { + Ok(Self { + root: paths.install_root.clone(), + staging: paths.update_staging_dir(), + lock_file: paths.update_lock_file().map_err(|e| anyhow::anyhow!(e))?, + }) + } pub fn acquire(&self) -> Result { - fs::create_dir_all(&self.root)?; - let path = self.root.join("update.lock"); + if let Some(parent) = self.lock_file.parent() { + fs::create_dir_all(parent)?; + } let file = fs::OpenOptions::new() .write(true) .create_new(true) - .open(path) + .open(&self.lock_file) .context("update already in progress")?; Ok(file) } diff --git a/iota-util/src/file_util.rs b/iota-util/src/file_util.rs index fe4a8ba..8f87450 100755 --- a/iota-util/src/file_util.rs +++ b/iota-util/src/file_util.rs @@ -3,6 +3,7 @@ use std::ffi::OsStr; use std::fs::{self, File}; use std::io::{self, BufReader, Read}; use std::path::{Path, PathBuf}; +use std::sync::OnceLock; use sysinfo::System; use tokio::io::AsyncWriteExt; use uuid::Uuid; @@ -40,34 +41,19 @@ pub fn delete_user_directory(user_id: i64) { } pub fn load_file_buf(path: &str, name: &str) -> io::Result> { - let dir = Path::new(&get_directory()).join(path); - let file_path = dir.join(name); - - // Ensure the directory exists, create if necessary - if !dir.exists() { - if let Err(_) = fs::create_dir_all(&dir) { - return Err(io::Error::new( - io::ErrorKind::NotFound, - "Directory creation failed", - )); - } - } - - // Create the file if it doesn't exist - if !file_path.exists() { - return Err(io::Error::new( - io::ErrorKind::NotFound, - "File creation failed", - )); - } + let file_path = storage_file(path, name)?; // Open the file and return a BufReader for efficient reading let file = File::open(&file_path)?; Ok(BufReader::new(file)) } pub fn has_file(path: &str, name: &str) -> bool { - let dir = Path::new(&get_directory()).join(path); - let file_path = dir.join(name); + let Ok(file_path) = storage_file(path, name) else { + return false; + }; + let Some(dir) = file_path.parent() else { + return false; + }; if !dir.exists() { return false; @@ -80,7 +66,9 @@ pub fn has_file(path: &str, name: &str) -> bool { true } pub fn has_dir(path: &str) -> bool { - let dir = Path::new(&get_directory()).join(path); + let Ok(dir) = storage_child(path) else { + return false; + }; if !dir.exists() { return false; @@ -90,8 +78,12 @@ pub fn has_dir(path: &str) -> bool { } pub fn load_file(path: &str, name: &str) -> String { - let dir = Path::new(&get_directory()).join(path); - let file_path = dir.join(name); + let Ok(file_path) = storage_file(path, name) else { + return String::new(); + }; + let Some(dir) = file_path.parent() else { + return String::new(); + }; if !dir.exists() { return String::new(); @@ -109,15 +101,17 @@ pub fn load_file(path: &str, name: &str) -> String { } pub fn load_file_vec(path: &str, name: &str) -> Result, std::io::Error> { - let dir = Path::new(&get_directory()).join(path); - let file_path = dir.join(name); - - std::fs::read(file_path) + std::fs::read(storage_file(path, name)?) } pub fn save_file(path: &str, name: &str, value: &str) { - let dir = Path::new(&get_directory()).join(path); - let file_path = dir.join(name); + let Ok(file_path) = storage_file(path, name) else { + eprintln!("[IMPORTANT] Refusing unsafe storage path"); + return; + }; + let Some(dir) = file_path.parent() else { + return; + }; if !dir.exists() { if let Err(e) = fs::create_dir_all(&dir) { @@ -149,7 +143,9 @@ pub fn save_file(path: &str, name: &str, value: &str) { } pub fn get_children(path: &str) -> Vec { - let dir = Path::new(&get_directory()).join(path); + let Ok(dir) = storage_child(path) else { + return Vec::new(); + }; let mut children = Vec::new(); if let Ok(entries) = fs::read_dir(&dir) { for entry in entries { @@ -161,8 +157,61 @@ pub fn get_children(path: &str) -> Vec { children } +static STORAGE_DIRECTORY: OnceLock = OnceLock::new(); + +/// Set by the daemon immediately after resolving `IotaPaths`. This keeps the +/// legacy storage helpers working while preventing them from independently +/// discovering a different (user-scope) directory in a system daemon. +pub fn configure_storage_directory(path: PathBuf) { + let _ = STORAGE_DIRECTORY.set(path); +} + +pub fn storage_directory() -> PathBuf { + STORAGE_DIRECTORY.get().cloned().unwrap_or_else(|| { + iota_paths::IotaPaths::resolve(iota_paths::Scope::User) + .expect("resolve Iota user paths") + .storage_dir + }) +} + +/// Resolve a user supplied storage fragment without allowing it to escape the +/// resolved storage root. Legacy call sites may use nested fragments, but +/// never absolute paths or `..` components. +pub fn storage_child(path: impl AsRef) -> io::Result { + let path = path.as_ref(); + if path.is_absolute() + || path.components().any(|c| { + matches!( + c, + std::path::Component::ParentDir + | std::path::Component::RootDir + | std::path::Component::Prefix(_) + ) + }) + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "unsafe storage path", + )); + } + Ok(storage_directory().join(path)) +} + +pub fn storage_file(path: impl AsRef, name: impl AsRef) -> io::Result { + let name = name.as_ref(); + if name.components().count() != 1 || name.is_absolute() || name == Path::new(".") { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "unsafe storage file name", + )); + } + storage_child(path).map(|dir| dir.join(name)) +} + pub fn get_directory() -> String { - iota_paths::data_dir().to_string_lossy().to_string() + // Legacy helpers are storage-only. Configuration, keys, logs and runtime + // files must use their dedicated path APIs instead. + storage_directory().to_string_lossy().into_owned() } // Helper to download the zip file content to a file on disk diff --git a/iota/src/main.rs b/iota/src/main.rs index e71c2d5..0e85d36 100644 --- a/iota/src/main.rs +++ b/iota/src/main.rs @@ -1,7 +1,5 @@ use iota_cli::{ - ipc_client::IpcClient, - screens::main_screen::MainScreen, - theme, + ipc_client::IpcClient, screens::main_screen::MainScreen, theme, ui::start_bootstrap_tui_with_theme, }; use iota_ipc::{LocalRequest, ResponseResult}; @@ -32,13 +30,28 @@ async fn main() -> ExitCode { async fn run() -> Result<(), StartupError> { let invocation = CliInvocation::parse(std::env::args().skip(1)).map_err(StartupError::InvalidCommand)?; - let mut endpoints_iter = iota_paths::daemon_endpoints().into_iter(); - let local_endpoint = endpoints_iter - .next() - .expect("path layer always returns an endpoint"); - let system_endpoint = endpoints_iter - .next() - .unwrap_or_else(|| local_endpoint.clone()); + let local_endpoint = match iota_paths::IotaPaths::resolve(iota_paths::Scope::User) + .map_err(|error| StartupError::Other(format!("Cannot resolve user IPC path: {error}")))? + .ipc_endpoint + { + iota_paths::IpcEndpoint::UnixSocket(path) => path, + iota_paths::IpcEndpoint::WindowsPipe(name) => { + return Err(StartupError::Other(format!( + "Windows IPC endpoint {name} is not supported by this client build" + ))); + } + }; + let system_endpoint = match iota_paths::IotaPaths::resolve(iota_paths::Scope::System) + .map_err(|error| StartupError::Other(format!("Cannot resolve system IPC path: {error}")))? + .ipc_endpoint + { + iota_paths::IpcEndpoint::UnixSocket(path) => path, + iota_paths::IpcEndpoint::WindowsPipe(name) => { + return Err(StartupError::Other(format!( + "Windows IPC endpoint {name} is not supported by this client build" + ))); + } + }; let endpoints = daemon_setup_flow::DaemonEndpoints { local: local_endpoint, system: system_endpoint, diff --git a/omikron-connector/src/omikron_connection.rs b/omikron-connector/src/omikron_connection.rs index 54038da..6e745e6 100755 --- a/omikron-connector/src/omikron_connection.rs +++ b/omikron-connector/src/omikron_connection.rs @@ -10,6 +10,7 @@ use mtp::client::{Client, ClientConfig, Policy, Receiver, SendMode, Sender}; use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; use mtp::crypto::{Keyring, PublicKeyBundle}; use std::env; +use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::{Arc, LazyLock}; use std::time::{Duration, Instant}; @@ -92,6 +93,19 @@ async fn is_read_receipts_enabled() -> bool { // ============================================================================ const IOTA_KEYRING_PATH: &str = "iota.mk"; +static IDENTITY_PATH: std::sync::OnceLock = std::sync::OnceLock::new(); + +/// Must be called by the daemon before any Omikron connection is attempted. +/// It keeps identity material independent from the working directory. +pub fn configure_identity_path(path: PathBuf) { + let _ = IDENTITY_PATH.set(path); +} +fn identity_path() -> &'static Path { + IDENTITY_PATH + .get() + .map(PathBuf::as_path) + .unwrap_or_else(|| Path::new(IOTA_KEYRING_PATH)) +} const OMIKRON_PUBLIC_KEY_PATH: &str = "omikron.mpkb"; const RECONNECT_DELAY: Duration = Duration::from_secs(5); const MAX_RECONNECT_DELAY: Duration = Duration::from_secs(300); @@ -436,7 +450,8 @@ impl OmikronConnection { * that still read it directly. */ async fn load_or_migrate_keyring(&self) -> Keyring { - if let Ok(kr) = mtp::files::load_keyring_raw(IOTA_KEYRING_PATH) { + let path = identity_path(); + if let Ok(kr) = mtp::files::load_keyring_raw(path) { return kr; } @@ -448,18 +463,18 @@ impl OmikronConnection { "WARNING: No existing keyring found. Neither {} nor config.json \ contain a keyring; generating a new identity. If you already had \ an Iota identity, restore {} from a backup to avoid losing access.", - IOTA_KEYRING_PATH, - IOTA_KEYRING_PATH + path.display(), + path.display() ); crypto_helper::generate_keyring() }); - if let Err(e) = mtp::files::save_keyring_raw(&keyring, IOTA_KEYRING_PATH) { - log!("Failed to persist {}: {}", IOTA_KEYRING_PATH, e); + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + if let Err(e) = mtp::files::save_keyring_raw(&keyring, path) { + log!("Failed to persist {}: {}", path.display(), e); } - - let b64 = crypto_helper::keyring_to_base64(&keyring); - modify_config(|cfg| cfg.keyring = Some(b64)); keyring } @@ -1050,9 +1065,7 @@ impl OmikronConnection { if let Some(app_pub_bundle) = iota_util::crypto_helper::public_key_bundle_from_base64(&app_public_key) { - let kr_str = CONFIG.load().keyring.clone().unwrap_or_default(); - - if let Some(keyring) = keyring_from_base64(&kr_str) { + if let Ok(keyring) = mtp::files::load_keyring_raw(identity_path()) { if let Ok(encrypted_challenge) = crypto_util::encrypt_challenge(&challenge, &app_pub_bundle) {