[WIP] paths

This commit is contained in:
Alex-Emmet 2026-07-24 01:36:14 +02:00
commit 3bc5cc959a
20 changed files with 817 additions and 241 deletions

5
Cargo.lock generated
View file

@ -2222,6 +2222,7 @@ dependencies = [
"dashmap", "dashmap",
"iota-cli", "iota-cli",
"iota-logger", "iota-logger",
"iota-paths",
"iota-state", "iota-state",
"iota-storage", "iota-storage",
"iota-terms", "iota-terms",
@ -2250,6 +2251,7 @@ dependencies = [
"iota-paths", "iota-paths",
"iota-state", "iota-state",
"iota-storage", "iota-storage",
"iota-util",
"omikron-connector", "omikron-connector",
"tokio", "tokio",
"tokio-util", "tokio-util",
@ -2301,6 +2303,7 @@ dependencies = [
name = "iota-logger" name = "iota-logger"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"iota-paths",
"iota-state", "iota-state",
"iota-util", "iota-util",
"json", "json",
@ -2345,6 +2348,7 @@ dependencies = [
"hex", "hex",
"hkdf 0.12.4", "hkdf 0.12.4",
"iota-logger", "iota-logger",
"iota-paths",
"iota-state", "iota-state",
"iota-util", "iota-util",
"json", "json",
@ -2390,6 +2394,7 @@ dependencies = [
"hex", "hex",
"hkdf 0.12.4", "hkdf 0.12.4",
"iota-logger", "iota-logger",
"iota-paths",
"json", "json",
"mtp", "mtp",
"once_cell", "once_cell",

View file

@ -108,22 +108,26 @@
cfg = config.services.iota; 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}"); 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 = configFile =
if cfg.settingsFile != null if cfg.settingsFile != null
then cfg.settingsFile 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"; descriptionText = "Tensamin Iota daemon";
in { in {
options.services.iota = { options.services.iota = {
enable = lib.mkEnableOption "Enable the Iota service."; enable = lib.mkEnableOption "Enable the Iota service.";
dataDir = lib.mkOption { stateDir = lib.mkOption {
type = lib.types.str; type = lib.types.str;
default = cfg.package.passthru.dataDir or "/var/lib/iota"; default = "/var/lib/iota";
defaultText = lib.literalExpression ''config.services.iota.package.passthru.dataDir or "/var/lib/iota"''; description = "Persistent mutable Iota state.";
description = "Directory where Iota stores its data, config, and certificates.";
}; };
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 { certFile = lib.mkOption {
type = lib.types.nullOr lib.types.path; type = lib.types.nullOr lib.types.path;
@ -164,13 +168,13 @@
settings = lib.mkOption { settings = lib.mkOption {
type = lib.types.attrs; type = lib.types.attrs;
default = {}; default = {};
description = "Configuration attributes for Iota, written to config.json."; description = "Configuration attributes for Iota, written to YAML.";
}; };
settingsFile = lib.mkOption { settingsFile = lib.mkOption {
type = lib.types.nullOr lib.types.path; type = lib.types.nullOr lib.types.path;
default = null; 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 = { users.users.iota = {
isSystemUser = true; isSystemUser = true;
group = "iota"; group = "iota";
home = cfg.dataDir; home = cfg.stateDir;
createHome = true; createHome = true;
description = "Iota service user"; description = "Iota service user";
shell = pkgs.bash; shell = pkgs.bash;
@ -194,6 +198,7 @@
SocketMode = "0660"; SocketMode = "0660";
SocketUser = "iota"; SocketUser = "iota";
SocketGroup = "iota"; SocketGroup = "iota";
DirectoryMode = "0750";
Backlog = 5; Backlog = 5;
RemoveOnStop = "true"; RemoveOnStop = "true";
NonBlocking = true; NonBlocking = true;
@ -210,28 +215,18 @@
Type = "simple"; Type = "simple";
User = "iota"; User = "iota";
Group = "iota"; Group = "iota";
WorkingDirectory = cfg.dataDir;
ExecStart = "${cfg.package}/bin/iota-daemon"; 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"; Restart = "on-failure";
RestartSec = "5s"; RestartSec = "5s";
RuntimeDirectory = "iota"; RuntimeDirectory = "iota";
RuntimeDirectoryMode = "0750"; RuntimeDirectoryMode = "0750";
StateDirectory = "iota";
StateDirectoryMode = "0750";
CacheDirectory = "iota";
CacheDirectoryMode = "0750";
LogsDirectory = "iota";
LogsDirectoryMode = "0750";
# Exit code 75 = restart requested # Exit code 75 = restart requested
RestartPreventExitStatus = "0"; RestartPreventExitStatus = "0";
@ -248,7 +243,8 @@
ProtectHome = true; ProtectHome = true;
PrivateTmp = true; PrivateTmp = true;
NoNewPrivileges = true; NoNewPrivileges = true;
ReadWritePaths = [cfg.dataDir]; ReadWritePaths = [cfg.stateDir cfg.cacheDir cfg.runtimeDir cfg.logDir];
ReadOnlyPaths = [configFile cfg.assetDir];
ProtectKernelTunables = true; ProtectKernelTunables = true;
ProtectKernelModules = true; ProtectKernelModules = true;
ProtectControlGroups = true; ProtectControlGroups = true;
@ -259,7 +255,14 @@
Environment = [ Environment = [
"BIND_ADDRESS=${cfg.bindAddress}" "BIND_ADDRESS=${cfg.bindAddress}"
"IOTA_SOCKET=/run/iota/iota.sock" "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 != []) { // lib.optionalAttrs (cfg.environmentFiles != []) {

View file

@ -387,8 +387,7 @@ impl IpcClient {
}; };
match result { match result {
Ok(message) => self.apply(message).await, Ok(message) => self.apply(message).await,
Err(error) => { Err(_) => {
eprintln!("IPC reader for generation {generation} stopped: {error}");
self.mark_disconnected(generation).await; self.mark_disconnected(generation).await;
break; break;
} }

View file

@ -12,6 +12,7 @@ iota-storage = { path = "../iota-storage" }
iota-terms = { path = "../iota-terms" } iota-terms = { path = "../iota-terms" }
iota-updater = { path = "../iota-updater" } iota-updater = { path = "../iota-updater" }
iota-util = { path = "../iota-util" } iota-util = { path = "../iota-util" }
iota-paths = { path = "../iota-paths" }
omikron-connector = { path = "../omikron-connector" } omikron-connector = { path = "../omikron-connector" }
web-server = { path = "../web-server" } web-server = { path = "../web-server" }
web-ui = { path = "../web-ui" } web-ui = { path = "../web-ui" }

View file

@ -23,7 +23,16 @@ async fn main() {
*state.reload.write().await = false; *state.reload.write().await = false;
*state.shutdown.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 .await
.expect("iota-daemon must be running before starting iota-core"); .expect("iota-daemon must be running before starting iota-core");
let session = start_tui(ipc).expect("interactive terminal initialization failed"); let session = start_tui(ipc).expect("interactive terminal initialization failed");

View file

@ -44,10 +44,22 @@ impl IpcServer {
let listener = match activated_listener()? { let listener = match activated_listener()? {
Some(listener) => listener, Some(listener) => listener,
None => { None => {
if let Some(parent) = path.parent() { let parent = path.parent().ok_or_else(|| {
tokio::fs::create_dir_all(parent).await?; 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() let lock = File::options()
.create(true) .create(true)
.mode(0o600) .mode(0o600)

View file

@ -10,6 +10,7 @@ iota-logger = { path = "../iota-logger" }
iota-state = { path = "../iota-state" } iota-state = { path = "../iota-state" }
iota-paths = { path = "../iota-paths" } iota-paths = { path = "../iota-paths" }
iota-storage = { path = "../iota-storage" } iota-storage = { path = "../iota-storage" }
iota-util = { path = "../iota-util" }
omikron-connector = { path = "../omikron-connector" } omikron-connector = { path = "../omikron-connector" }
web-server = { path = "../web-server" } web-server = { path = "../web-server" }
tokio = { version = "1.50.0", features = ["full"] } tokio = { version = "1.50.0", features = ["full"] }

View file

@ -10,8 +10,33 @@ use std::time::Duration;
use tokio::sync::{broadcast, watch}; use tokio::sync::{broadcast, watch};
#[tokio::main(flavor = "multi_thread")] #[tokio::main(flavor = "multi_thread")]
async fn main() -> ExitCode { async fn main() -> ExitCode {
logger::startup(); let scope = match std::env::var("IOTA_DEPLOYMENT_MODE").ok().as_deref() {
iota_storage::util::config_util::load_config(); 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()); let runtime = Arc::new(DaemonRuntime::new());
// --- IPC infrastructure --- // --- IPC infrastructure ---
@ -36,7 +61,13 @@ async fn main() -> ExitCode {
// Bind before migration and service startup: a successful bind is the // Bind before migration and service startup: a successful bind is the
// readiness boundary visible to clients and socket activation. // 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( let omikron = match omikron_connector::omikron_connection::connect_initial(
runtime.cancellation.clone(), runtime.cancellation.clone(),
runtime.state.active_tasks.clone(), runtime.state.active_tasks.clone(),
@ -65,18 +96,22 @@ async fn main() -> ExitCode {
} }
}; };
let services = DaemonServices::new(omikron); let services = DaemonServices::new(omikron);
let ipc_server = let ipc_server = match IpcServer::bind(
match IpcServer::bind(socket, runtime.clone(), services, log_tx.clone(), state_rx).await { socket.clone(),
runtime.clone(),
services,
log_tx.clone(),
state_rx,
)
.await
{
Ok(server) => server, Ok(server) => server,
Err(error) => { Err(error) => {
eprintln!("Cannot bind daemon IPC socket: {error}"); eprintln!("Cannot bind daemon IPC socket: {error}");
return ExitCode::FAILURE; return ExitCode::FAILURE;
} }
}; };
eprintln!( eprintln!("iota-daemon IPC listener ready at {}", socket.display());
"iota-daemon IPC listener ready at {}",
iota_paths::socket_path(iota_paths::SocketScope::User).display()
);
runtime.set_component_healthy(iota_ipc::ComponentId::Ipc, None); runtime.set_component_healthy(iota_ipc::ComponentId::Ipc, None);
let listener_runtime = runtime.clone(); let listener_runtime = runtime.clone();
runtime runtime
@ -128,13 +163,17 @@ async fn main() -> ExitCode {
.parse() .parse()
.unwrap_or(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)), .unwrap_or(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)),
port: web.port, 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 tls: web
.certificate .certificate
.zip(web.key) .zip(web.key)
.map(|(certificate, key)| web_server::TlsConfig { .map(|(certificate, key)| web_server::TlsConfig {
certificate: certificate.into(), certificate: resolve_config_path(
key: key.into(), &paths.config_file,
&certificate,
&paths.config_dir,
),
key: resolve_config_path(&paths.config_file, &key, &paths.config_dir),
}), }),
required: web.required, required: web.required,
}; };
@ -196,3 +235,22 @@ async fn main() -> ExitCode {
log!("iota-daemon exited (code: {})", exit_code); log!("iota-daemon exited (code: {})", exit_code);
ExitCode::from(exit_code as u8) 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)
}
}

View file

@ -81,7 +81,7 @@ pub fn install_linux_bundle_with_operator(bundle: &Path, operator: Option<&str>)
] { ] {
install( install(
&staging.path().join("systemd").join(unit), &staging.path().join("systemd").join(unit),
&format!("/etc/systemd/system/{unit}"), &format!("/usr/local/lib/systemd/system/{unit}"),
"0644", "0644",
)?; )?;
} }
@ -114,7 +114,7 @@ pub fn install_linux_bundle_with_operator(bundle: &Path, operator: Option<&str>)
"{}/current/bin/iota-daemon", "{}/current/bin/iota-daemon",
iota_paths::install_root().display() iota_paths::install_root().display()
), ),
"/usr/local/lib/iota/iota-daemon", "/usr/local/libexec/iota/iota-daemon",
], ],
)?; )?;
run("systemd-sysusers", &[])?; 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", &["enable", "--now", "iota-daemon.socket"])?;
run("systemctl", &["is-active", "iota-daemon.socket"])?; run("systemctl", &["is-active", "iota-daemon.socket"])?;
run("systemctl", &["is-enabled", "iota-daemon.socket"])?; run("systemctl", &["is-enabled", "iota-daemon.socket"])?;
if !Path::new("/run/iota/iota.sock").exists() { let socket = iota_paths::socket_path(iota_paths::Scope::System);
bail!("systemd socket is active but /run/iota/iota.sock was not created"); if !socket.exists() {
bail!(
"systemd socket is active but {} was not created",
socket.display()
);
} }
Ok(()) Ok(())
} }
@ -175,7 +179,7 @@ mod tests {
let service = include_str!("../../systemd/iota-daemon.service"); let service = include_str!("../../systemd/iota-daemon.service");
let socket = include_str!("../../systemd/iota-daemon.socket"); let socket = include_str!("../../systemd/iota-daemon.socket");
let sysusers = include_str!("../../systemd/sysusers.d/iota.conf"); 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("User=iota"));
assert!(service.contains("Group=iota")); assert!(service.contains("Group=iota"));
assert!(socket.contains("SocketUser=iota")); assert!(socket.contains("SocketUser=iota"));

View file

@ -4,6 +4,7 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
iota-paths = { path = "../iota-paths" }
iota-state = { path = "../iota-state" } iota-state = { path = "../iota-state" }
iota-util = { path = "../iota-util" } iota-util = { path = "../iota-util" }

View file

@ -1,7 +1,6 @@
use std::{ use std::{
fs::{self, OpenOptions}, fs::{self, OpenOptions},
io::Write, io::Write,
path::Path,
sync::{OnceLock, atomic::Ordering, mpsc}, sync::{OnceLock, atomic::Ordering, mpsc},
thread, thread,
time::{SystemTime, UNIX_EPOCH}, time::{SystemTime, UNIX_EPOCH},
@ -56,6 +55,15 @@ struct LogMessage {
/* The logger owns file persistence while consumers receive rendered entries /* The logger owns file persistence while consumers receive rendered entries
* through a process-local broadcast subscription. */ * through a process-local broadcast subscription. */
pub fn startup() { 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<std::path::PathBuf>) {
let (tx, rx) = mpsc::channel::<LogMessage>(); let (tx, rx) = mpsc::channel::<LogMessage>();
if LOGGER.set(tx).is_err() { if LOGGER.set(tx).is_err() {
return; return;
@ -64,22 +72,15 @@ pub fn startup() {
let _ = LOG_BROADCASTER.set(broadcast_tx.clone()); let _ = LOG_BROADCASTER.set(broadcast_tx.clone());
thread::spawn(move || { thread::spawn(move || {
let working_dir = iota_util::file_util::get_directory(); let mut file = log_dir.and_then(|log_dir| {
let base_dir = Path::new(&working_dir); fs::create_dir_all(&log_dir).ok()?;
let log_dir = base_dir.join("logs"); let start_ts = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs();
fs::create_dir_all(&log_dir).expect("Failed to create log directory"); OpenOptions::new()
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) .create(true)
.append(true) .append(true)
.open(path) .open(log_dir.join(format!("log_{start_ts}.txt")))
.expect("Failed to open log file"); .ok()
});
for msg in rx { for msg in rx {
let resolved_message = if let Some(key) = msg.translation_key { let resolved_message = if let Some(key) = msg.translation_key {
@ -105,7 +106,9 @@ pub fn startup() {
); );
let line2 = format!(" {}", timestamp); let line2 = format!(" {}", timestamp);
if let Some(file) = file.as_mut() {
let _ = writeln!(file, "{}\n{}", line1, line2); let _ = writeln!(file, "{}\n{}", line1, line2);
}
let _ = writeln!(std::io::stderr(), "{}\n{}", line1, line2); let _ = writeln!(std::io::stderr(), "{}\n{}", line1, line2);

View file

@ -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)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SocketScope { pub enum Scope {
User, User,
System, System,
} }
fn home_dir() -> PathBuf { /// Compatibility name retained for callers which have not yet been migrated.
std::env::var_os("HOME") pub type SocketScope = Scope;
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(".")) #[derive(Clone, Debug, Eq, PartialEq)]
pub enum IpcEndpoint {
UnixSocket(PathBuf),
WindowsPipe(String),
} }
pub fn data_dir() -> PathBuf { #[derive(Debug)]
if let Some(path) = std::env::var_os("IOTA_DATA_DIR") { pub enum PathError {
return PathBuf::from(path); MissingPlatformDirectory(&'static str),
EmptyOverride(&'static str),
RelativeOverride {
variable: &'static str,
value: PathBuf,
},
InvalidPipeName(String),
UnsupportedScope,
}
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 {}
#[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<PathBuf>,
pub log_dir: PathBuf,
/// Directory containing static web assets (not its parent).
pub asset_dir: PathBuf,
pub install_root: PathBuf,
pub ipc_endpoint: IpcEndpoint,
} }
impl IotaPaths {
pub fn resolve(scope: Scope) -> Result<Self, PathError> {
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,
})
}
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<PathBuf, PathError> {
self.runtime_dir
.as_ref()
.map(|p| p.join("update.lock"))
.ok_or(PathError::MissingPlatformDirectory("runtime directory"))
}
pub fn daemon_lock_file(&self) -> Result<PathBuf, PathError> {
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")
}
}
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<PathBuf>,
log_dir: PathBuf,
asset_dir: PathBuf,
install_root: PathBuf,
ipc_endpoint: IpcEndpoint,
}
impl Defaults {
fn for_scope(scope: Scope) -> Result<Self, PathError> {
match scope {
Scope::System => {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
{ {
return std::env::var_os("XDG_STATE_HOME") Ok(Self {
.map(PathBuf::from) config_dir: "/etc/iota".into(),
.unwrap_or_else(|| home_dir().join(".local/state")) 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<Defaults, PathError> {
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"); .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(target_os = "macos")] #[cfg(windows)]
{ fn user_defaults() -> Result<Defaults, PathError> {
return home_dir().join("Library/Application Support/Iota"); let config = absolute_env("APPDATA")?
} .ok_or(PathError::MissingPlatformDirectory("Roaming AppData"))?
#[cfg(target_os = "windows")] .join("Tensamin/Iota/config");
{ let local = absolute_env("LOCALAPPDATA")?
return std::env::var_os("LOCALAPPDATA") .ok_or(PathError::MissingPlatformDirectory("Local AppData"))?
.map(PathBuf::from)
.unwrap_or_else(home_dir)
.join("Tensamin/Iota"); .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()),
})
} }
#[allow(unreachable_code)]
home_dir().join(".iota") fn xdg_or_home(variable: &'static str, home: &Path, fallback: &str) -> Result<PathBuf, PathError> {
Ok(absolute_env(variable)?.unwrap_or_else(|| home.join(fallback)))
}
fn absolute_env(name: &'static str) -> Result<Option<PathBuf>, 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<Option<PathBuf>, 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<IpcEndpoint, PathError> {
#[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 { pub fn config_dir() -> PathBuf {
if let Some(path) = std::env::var_os("IOTA_CONFIG_DIR") { IotaPaths::resolve(Scope::User)
return PathBuf::from(path); .expect("resolve Iota user paths")
.config_dir
} }
#[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<PathBuf> { pub fn socket_override() -> Option<PathBuf> {
std::env::var_os("IOTA_SOCKET").map(PathBuf::from) absolute_env("IOTA_SOCKET").ok().flatten()
} }
pub fn socket_path(scope: SocketScope) -> PathBuf { pub fn socket_path(scope: SocketScope) -> PathBuf {
if let Some(path) = socket_override() { match IotaPaths::resolve(scope)
return path; .expect("resolve Iota paths")
} .ipc_endpoint
match scope {
SocketScope::User => data_dir().join("iota.sock"),
SocketScope::System => PathBuf::from("/run/iota/iota.sock"),
}
}
pub fn socket_lock_path(scope: SocketScope) -> PathBuf {
let socket = socket_path(scope);
PathBuf::from(format!("{}.lock", socket.display()))
}
pub fn daemon_executable() -> PathBuf {
if let Some(path) = std::env::var_os("IOTA_DAEMON_PATH") {
return PathBuf::from(path);
}
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;
}
}
}
#[cfg(target_os = "linux")]
{ {
let installed = PathBuf::from("/usr/local/lib/iota/iota-daemon"); IpcEndpoint::UnixSocket(path) => path,
if installed.is_file() { IpcEndpoint::WindowsPipe(_) => panic!("Windows IPC endpoint is not a filesystem path"),
return installed;
} }
} }
PathBuf::from("iota-daemon") pub fn socket_lock_path(scope: SocketScope) -> PathBuf {
} IotaPaths::resolve(scope)
.expect("resolve Iota paths")
pub fn updater_executable() -> PathBuf { .daemon_lock_file()
std::env::var_os("IOTA_UPDATER_PATH") .expect("runtime directory")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("iota-updater"))
} }
/// 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 { pub fn install_root() -> PathBuf {
std::env::var_os("IOTA_INSTALL_ROOT") IotaPaths::resolve(Scope::System)
.map(PathBuf::from) .expect("resolve Iota system paths")
.unwrap_or_else(|| PathBuf::from("/usr/local/lib/iota")) .install_root
} }
pub fn versions_dir() -> PathBuf { pub fn versions_dir() -> PathBuf {
install_root().join("versions") install_root().join("versions")
@ -121,36 +415,91 @@ pub fn current_version_link() -> PathBuf {
install_root().join("current") install_root().join("current")
} }
pub fn updater_lock_path() -> PathBuf { 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 { 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 { 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 { pub fn web_asset_dir() -> PathBuf {
std::env::var_os("IOTA_WEB_ASSET_DIR") IotaPaths::resolve(Scope::User)
.map(PathBuf::from) .expect("resolve Iota user paths")
.unwrap_or_else(|| data_dir().join("web")) .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<PathBuf> { pub fn daemon_endpoints() -> Vec<PathBuf> {
if let Some(path) = socket_override() { vec![socket_path(Scope::User), socket_path(Scope::System)]
return vec![path];
}
vec![
socket_path(SocketScope::User),
socket_path(SocketScope::System),
]
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; 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] #[test]
fn explicit_data_directory_wins() { fn migration_moves_state_resources_without_overwriting_destination() {
assert!(!data_dir().as_os_str().is_empty()); 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);
} }
} }

View file

@ -7,6 +7,7 @@ edition = "2024"
iota-logger = { path = "../iota-logger" } iota-logger = { path = "../iota-logger" }
iota-state = { path = "../iota-state" } iota-state = { path = "../iota-state" }
iota-util = { path = "../iota-util" } iota-util = { path = "../iota-util" }
iota-paths = { path = "../iota-paths" }
mtp = { git = "https://git.methanium.net/Methanium/mtp.git" } mtp = { git = "https://git.methanium.net/Methanium/mtp.git" }

View file

@ -1,8 +1,10 @@
use arc_swap::ArcSwap; use arc_swap::ArcSwap;
use iota_util::file_util::{load_file, save_file};
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
use std::sync::OnceLock;
pub static CONFIG: Lazy<ArcSwap<IotaConfig>> = pub static CONFIG: Lazy<ArcSwap<IotaConfig>> =
Lazy::new(|| ArcSwap::new(Arc::new(IotaConfig::default()))); Lazy::new(|| ArcSwap::new(Arc::new(IotaConfig::default())));
@ -19,11 +21,11 @@ pub struct IotaConfig {
pub omikron_host: Option<String>, pub omikron_host: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub omikron_port: Option<u16>, pub omikron_port: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing)]
pub keyring: Option<String>, pub keyring: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing)]
pub public_key: Option<String>, pub public_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing)]
pub private_key: Option<String>, pub private_key: Option<String>,
#[serde(default = "default_read_receipts_enabled")] #[serde(default = "default_read_receipts_enabled")]
pub read_receipts_enabled: bool, pub read_receipts_enabled: bool,
@ -61,7 +63,7 @@ fn default_web_bind() -> String {
"127.0.0.1".into() "127.0.0.1".into()
} }
fn default_web_asset_dir() -> String { fn default_web_asset_dir() -> String {
"web".into() String::new()
} }
impl Default for WebSettings { impl Default for WebSettings {
fn default() -> Self { fn default() -> Self {
@ -102,17 +104,27 @@ impl Default for IotaConfig {
} }
pub fn load_config() { pub fn load_config() {
let s = load_file("", "config.yaml"); load_config_from(&default_config_path());
if s.is_empty() { }
/// 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; return;
} }
};
match serde_yaml::from_str::<IotaConfig>(&s) { match serde_yaml::from_str::<IotaConfig>(&s) {
Ok(parsed) => { Ok(parsed) => {
CONFIG.store(Arc::new(parsed)); CONFIG.store(Arc::new(parsed));
} }
Err(e) => { Err(e) => {
eprintln!("Failed to parse config.yaml: {}. Content: '{}'", e, s); eprintln!("Failed to parse {}: {e}", path.display());
} }
} }
} }
@ -123,9 +135,35 @@ pub fn clear_config() {
} }
pub fn save_config() { pub fn save_config() {
if let Ok(yaml) = serde_yaml::to_string(&**CONFIG.load()) { save_config_to(&default_config_path());
save_file("", "config.yaml", &yaml);
} }
pub fn save_config_to(path: &Path) {
if let Ok(yaml) = serde_yaml::to_string(&**CONFIG.load()) {
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)) { pub fn modify_config(f: impl FnOnce(&mut IotaConfig)) {
@ -134,3 +172,8 @@ pub fn modify_config(f: impl FnOnce(&mut IotaConfig)) {
CONFIG.store(Arc::new(cfg)); CONFIG.store(Arc::new(cfg));
save_config(); save_config();
} }
static CONFIG_PATH: OnceLock<PathBuf> = OnceLock::new();
pub fn configure_config_path(path: PathBuf) {
let _ = CONFIG_PATH.set(path);
}

View file

@ -1,4 +1,3 @@
use iota_util::file_util::get_directory;
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use r2d2::ManageConnection; use r2d2::ManageConnection;
use rusqlite::Connection; use rusqlite::Connection;
@ -56,10 +55,12 @@ where
f(&conn) f(&conn)
} }
fn db_file_path(db_name: &str) -> String { fn db_file_path(db_name: &str) -> PathBuf {
let mut p = PathBuf::from(get_directory()); let storage_dir = iota_util::file_util::storage_directory();
p.push(format!("{db_name}.sqlite3")); // Creating storage belongs to initialization/connection setup, never to a
p.to_string_lossy().to_string() // 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<SqliteManager>) -> Result<(), StorageError> { fn run_migrations(pool: &r2d2::Pool<SqliteManager>) -> Result<(), StorageError> {

View file

@ -5,6 +5,7 @@ edition = "2024"
[dependencies] [dependencies]
iota-logger = { path = "../iota-logger" } iota-logger = { path = "../iota-logger" }
iota-paths = { path = "../iota-paths" }
mtp = { git = "https://git.methanium.net/Methanium/mtp.git" } mtp = { git = "https://git.methanium.net/Methanium/mtp.git" }

View file

@ -9,22 +9,32 @@ use std::{
pub struct UpdateTransaction { pub struct UpdateTransaction {
pub root: PathBuf, pub root: PathBuf,
pub staging: PathBuf, pub staging: PathBuf,
pub lock_file: PathBuf,
} }
impl UpdateTransaction { impl UpdateTransaction {
pub fn new(root: impl Into<PathBuf>) -> Self { pub fn new(root: impl Into<PathBuf>) -> Self {
let root = root.into(); let root = root.into();
Self { Self {
staging: root.join(".staging"), staging: root.join(".staging"),
lock_file: root.join("update.lock"),
root, root,
} }
} }
pub fn from_paths(paths: &iota_paths::IotaPaths) -> Result<Self> {
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::File> { pub fn acquire(&self) -> Result<fs::File> {
fs::create_dir_all(&self.root)?; if let Some(parent) = self.lock_file.parent() {
let path = self.root.join("update.lock"); fs::create_dir_all(parent)?;
}
let file = fs::OpenOptions::new() let file = fs::OpenOptions::new()
.write(true) .write(true)
.create_new(true) .create_new(true)
.open(path) .open(&self.lock_file)
.context("update already in progress")?; .context("update already in progress")?;
Ok(file) Ok(file)
} }

View file

@ -3,6 +3,7 @@ use std::ffi::OsStr;
use std::fs::{self, File}; use std::fs::{self, File};
use std::io::{self, BufReader, Read}; use std::io::{self, BufReader, Read};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use sysinfo::System; use sysinfo::System;
use tokio::io::AsyncWriteExt; use tokio::io::AsyncWriteExt;
use uuid::Uuid; 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<BufReader<File>> { pub fn load_file_buf(path: &str, name: &str) -> io::Result<BufReader<File>> {
let dir = Path::new(&get_directory()).join(path); let file_path = storage_file(path, name)?;
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",
));
}
// Open the file and return a BufReader for efficient reading // Open the file and return a BufReader for efficient reading
let file = File::open(&file_path)?; let file = File::open(&file_path)?;
Ok(BufReader::new(file)) Ok(BufReader::new(file))
} }
pub fn has_file(path: &str, name: &str) -> bool { pub fn has_file(path: &str, name: &str) -> bool {
let dir = Path::new(&get_directory()).join(path); let Ok(file_path) = storage_file(path, name) else {
let file_path = dir.join(name); return false;
};
let Some(dir) = file_path.parent() else {
return false;
};
if !dir.exists() { if !dir.exists() {
return false; return false;
@ -80,7 +66,9 @@ pub fn has_file(path: &str, name: &str) -> bool {
true true
} }
pub fn has_dir(path: &str) -> bool { 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() { if !dir.exists() {
return false; return false;
@ -90,8 +78,12 @@ pub fn has_dir(path: &str) -> bool {
} }
pub fn load_file(path: &str, name: &str) -> String { pub fn load_file(path: &str, name: &str) -> String {
let dir = Path::new(&get_directory()).join(path); let Ok(file_path) = storage_file(path, name) else {
let file_path = dir.join(name); return String::new();
};
let Some(dir) = file_path.parent() else {
return String::new();
};
if !dir.exists() { if !dir.exists() {
return String::new(); 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<Vec<u8>, std::io::Error> { pub fn load_file_vec(path: &str, name: &str) -> Result<Vec<u8>, std::io::Error> {
let dir = Path::new(&get_directory()).join(path); std::fs::read(storage_file(path, name)?)
let file_path = dir.join(name);
std::fs::read(file_path)
} }
pub fn save_file(path: &str, name: &str, value: &str) { pub fn save_file(path: &str, name: &str, value: &str) {
let dir = Path::new(&get_directory()).join(path); let Ok(file_path) = storage_file(path, name) else {
let file_path = dir.join(name); eprintln!("[IMPORTANT] Refusing unsafe storage path");
return;
};
let Some(dir) = file_path.parent() else {
return;
};
if !dir.exists() { if !dir.exists() {
if let Err(e) = fs::create_dir_all(&dir) { 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<String> { pub fn get_children(path: &str) -> Vec<String> {
let dir = Path::new(&get_directory()).join(path); let Ok(dir) = storage_child(path) else {
return Vec::new();
};
let mut children = Vec::new(); let mut children = Vec::new();
if let Ok(entries) = fs::read_dir(&dir) { if let Ok(entries) = fs::read_dir(&dir) {
for entry in entries { for entry in entries {
@ -161,8 +157,61 @@ pub fn get_children(path: &str) -> Vec<String> {
children children
} }
static STORAGE_DIRECTORY: OnceLock<PathBuf> = 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<Path>) -> io::Result<PathBuf> {
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<Path>, name: impl AsRef<Path>) -> io::Result<PathBuf> {
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 { 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 // Helper to download the zip file content to a file on disk

View file

@ -1,7 +1,5 @@
use iota_cli::{ use iota_cli::{
ipc_client::IpcClient, ipc_client::IpcClient, screens::main_screen::MainScreen, theme,
screens::main_screen::MainScreen,
theme,
ui::start_bootstrap_tui_with_theme, ui::start_bootstrap_tui_with_theme,
}; };
use iota_ipc::{LocalRequest, ResponseResult}; use iota_ipc::{LocalRequest, ResponseResult};
@ -32,13 +30,28 @@ async fn main() -> ExitCode {
async fn run() -> Result<(), StartupError> { async fn run() -> Result<(), StartupError> {
let invocation = let invocation =
CliInvocation::parse(std::env::args().skip(1)).map_err(StartupError::InvalidCommand)?; CliInvocation::parse(std::env::args().skip(1)).map_err(StartupError::InvalidCommand)?;
let mut endpoints_iter = iota_paths::daemon_endpoints().into_iter(); let local_endpoint = match iota_paths::IotaPaths::resolve(iota_paths::Scope::User)
let local_endpoint = endpoints_iter .map_err(|error| StartupError::Other(format!("Cannot resolve user IPC path: {error}")))?
.next() .ipc_endpoint
.expect("path layer always returns an endpoint"); {
let system_endpoint = endpoints_iter iota_paths::IpcEndpoint::UnixSocket(path) => path,
.next() iota_paths::IpcEndpoint::WindowsPipe(name) => {
.unwrap_or_else(|| local_endpoint.clone()); 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 { let endpoints = daemon_setup_flow::DaemonEndpoints {
local: local_endpoint, local: local_endpoint,
system: system_endpoint, system: system_endpoint,

View file

@ -10,6 +10,7 @@ use mtp::client::{Client, ClientConfig, Policy, Receiver, SendMode, Sender};
use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue};
use mtp::crypto::{Keyring, PublicKeyBundle}; use mtp::crypto::{Keyring, PublicKeyBundle};
use std::env; use std::env;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::{Arc, LazyLock}; use std::sync::{Arc, LazyLock};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
@ -92,6 +93,19 @@ async fn is_read_receipts_enabled() -> bool {
// ============================================================================ // ============================================================================
const IOTA_KEYRING_PATH: &str = "iota.mk"; const IOTA_KEYRING_PATH: &str = "iota.mk";
static IDENTITY_PATH: std::sync::OnceLock<PathBuf> = 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 OMIKRON_PUBLIC_KEY_PATH: &str = "omikron.mpkb";
const RECONNECT_DELAY: Duration = Duration::from_secs(5); const RECONNECT_DELAY: Duration = Duration::from_secs(5);
const MAX_RECONNECT_DELAY: Duration = Duration::from_secs(300); const MAX_RECONNECT_DELAY: Duration = Duration::from_secs(300);
@ -436,7 +450,8 @@ impl OmikronConnection {
* that still read it directly. * that still read it directly.
*/ */
async fn load_or_migrate_keyring(&self) -> Keyring { 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; return kr;
} }
@ -448,18 +463,18 @@ impl OmikronConnection {
"WARNING: No existing keyring found. Neither {} nor config.json \ "WARNING: No existing keyring found. Neither {} nor config.json \
contain a keyring; generating a new identity. If you already had \ contain a keyring; generating a new identity. If you already had \
an Iota identity, restore {} from a backup to avoid losing access.", an Iota identity, restore {} from a backup to avoid losing access.",
IOTA_KEYRING_PATH, path.display(),
IOTA_KEYRING_PATH path.display()
); );
crypto_helper::generate_keyring() crypto_helper::generate_keyring()
}); });
if let Err(e) = mtp::files::save_keyring_raw(&keyring, IOTA_KEYRING_PATH) { if let Some(parent) = path.parent() {
log!("Failed to persist {}: {}", IOTA_KEYRING_PATH, e); 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 keyring
} }
@ -1050,9 +1065,7 @@ impl OmikronConnection {
if let Some(app_pub_bundle) = if let Some(app_pub_bundle) =
iota_util::crypto_helper::public_key_bundle_from_base64(&app_public_key) iota_util::crypto_helper::public_key_bundle_from_base64(&app_public_key)
{ {
let kr_str = CONFIG.load().keyring.clone().unwrap_or_default(); if let Ok(keyring) = mtp::files::load_keyring_raw(identity_path()) {
if let Some(keyring) = keyring_from_base64(&kr_str) {
if let Ok(encrypted_challenge) = if let Ok(encrypted_challenge) =
crypto_util::encrypt_challenge(&challenge, &app_pub_bundle) crypto_util::encrypt_challenge(&challenge, &app_pub_bundle)
{ {