Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 151 additions & 2 deletions crates/noa-app/src/app/config_reload.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ const CONFIG_WATCH_INTERVAL: Duration = Duration::from_secs(3);
pub(super) struct ConfigWatcher {
path: Option<PathBuf>,
signature: Option<ConfigFileSignature>,
/// Files pulled in via `config-file` (transitively, optional-and-absent
/// ones included) with the last observed signature. Editing a
/// split-out child config must reload just like editing the main file
/// (B02, 2026-09 audit). The list is re-derived whenever a watched file
/// changes, even if the resulting configuration cannot be applied.
includes: Vec<(PathBuf, Option<ConfigFileSignature>)>,
next_check: Option<Instant>,
}

Expand Down Expand Up @@ -48,12 +54,14 @@ impl ConfigWatcher {

fn with_path(path: Option<PathBuf>) -> Self {
let signature = path.as_deref().and_then(config_file_signature);
let includes = path.as_deref().map(include_signatures).unwrap_or_default();
let next_check = path
.as_ref()
.map(|_| Instant::now() + CONFIG_WATCH_INTERVAL);
Self {
path,
signature,
includes,
next_check,
}
}
Expand All @@ -72,8 +80,19 @@ impl ConfigWatcher {
let next = now + CONFIG_WATCH_INTERVAL;
self.next_check = Some(next);
let signature = config_file_signature(path);
if signature != self.signature {
self.signature = signature;
let mut changed = signature != self.signature;
self.signature = signature;
for (include, seen) in &mut self.includes {
let current = config_file_signature(include);
if current != *seen {
*seen = current;
changed = true;
}
}
if changed {
// Dependencies belong to the files on disk, even when validation
// fails and the application keeps its previous configuration.
self.includes = include_signatures(path);
ConfigWatchTick::Changed(next)
} else {
ConfigWatchTick::Waiting(next)
Expand All @@ -95,10 +114,21 @@ impl ConfigWatcher {
fn mark_current(&mut self) {
if let Some(path) = self.path.as_deref() {
self.signature = config_file_signature(path);
self.includes = include_signatures(path);
}
}
}

fn include_signatures(path: &Path) -> Vec<(PathBuf, Option<ConfigFileSignature>)> {
noa_config::config_include_paths(path)
.into_iter()
.map(|include| {
let signature = config_file_signature(&include);
(include, signature)
})
.collect()
}

fn config_file_signature(path: &Path) -> Option<ConfigFileSignature> {
let metadata = fs::metadata(path).ok()?;
metadata.is_file().then_some(ConfigFileSignature {
Expand Down Expand Up @@ -809,6 +839,125 @@ mod tests {
));
}

/// Editing only a `config-file` child (main file untouched) must still
/// reload; a `?`-optional include that appears later must too, and a
/// newly added include becomes tracked after `mark_current`.
#[test]
fn watcher_detects_edits_to_included_files() {
let dir = temp_config_path("includes");
fs::create_dir_all(&dir).unwrap();
let path = dir.join("config");
let child = dir.join("child.conf");
fs::write(&child, "font-size = 18\n").unwrap();
fs::write(
&path,
"font-size = 14\nconfig-file = child.conf\nconfig-file = ?optional.conf\n",
)
.unwrap();
let mut watcher = ConfigWatcher::with_path(Some(path.clone()));
let mut now = Instant::now() + CONFIG_WATCH_INTERVAL;
assert!(matches!(watcher.tick(now), ConfigWatchTick::Waiting(_)));

// Child edited, main file's mtime/len unchanged. Force a distinct
// signature via length so a same-second mtime cannot mask it.
fs::write(&child, "font-size = 20\n\n").unwrap();
now += CONFIG_WATCH_INTERVAL;
assert!(matches!(watcher.tick(now), ConfigWatchTick::Changed(_)));
now += CONFIG_WATCH_INTERVAL;
assert!(matches!(watcher.tick(now), ConfigWatchTick::Waiting(_)));

// Optional include comes into existence.
fs::write(dir.join("optional.conf"), "theme = X\n").unwrap();
now += CONFIG_WATCH_INTERVAL;
assert!(matches!(watcher.tick(now), ConfigWatchTick::Changed(_)));

// A new include added to the main file is tracked after reload.
let extra = dir.join("extra.conf");
fs::write(&extra, "font-size = 30\n").unwrap();
fs::write(
&path,
"config-file = child.conf\nconfig-file = extra.conf\n\n\n",
)
.unwrap();
watcher.mark_current();
assert!(watcher.includes.iter().any(|(p, _)| p == &extra));
now += CONFIG_WATCH_INTERVAL;
assert!(matches!(watcher.tick(now), ConfigWatchTick::Waiting(_)));
fs::write(&extra, "font-size = 31\n\n").unwrap();
now += CONFIG_WATCH_INTERVAL;
assert!(matches!(watcher.tick(now), ConfigWatchTick::Changed(_)));

fs::remove_dir_all(&dir).ok();
}

#[test]
fn watcher_detects_new_include_edits_after_failed_reload() {
let dir = temp_config_path("failed-reload");
fs::create_dir_all(&dir).unwrap();
let path = dir.join("config");
let child = dir.join("child.conf");
let grandchild = dir.join("grandchild.conf");
let legacy = dir.join("config.toml");
fs::write(&path, "config-file = child.conf\n").unwrap();
fs::write(&child, "font-size = 18\n").unwrap();
let load = || {
noa_config::load_startup_config_from(
&path,
&legacy,
noa_config::ConfigOverrides::default(),
)
};
assert_eq!(load().unwrap().0.font_size, 18.0);
let mut watcher = ConfigWatcher::with_path(Some(path.clone()));
let mut now = Instant::now() + CONFIG_WATCH_INTERVAL;

fs::write(
&grandchild,
format!(
"client-token-file = {}\n",
dir.join("missing-token").display()
),
)
.unwrap();
fs::write(&child, "font-size = 18\nconfig-file = grandchild.conf\n").unwrap();
assert!(matches!(watcher.tick(now), ConfigWatchTick::Changed(_)));
assert!(
load()
.unwrap_err()
.to_string()
.contains("client-token-file")
);

// A failed reload does not call mark_current. Without another edit
// it should stay idle, while still watching the newly added child.
now += CONFIG_WATCH_INTERVAL;
assert!(matches!(watcher.tick(now), ConfigWatchTick::Waiting(_)));
fs::write(&grandchild, "font-size = 22\n").unwrap();
now += CONFIG_WATCH_INTERVAL;
assert!(matches!(watcher.tick(now), ConfigWatchTick::Changed(_)));
assert_eq!(load().unwrap().0.font_size, 22.0);
watcher.mark_current();

// Removing the include must also remove it from the watch list,
// even when the replacement configuration still fails to load.
fs::write(
&child,
format!(
"client-token-file = {}\n",
dir.join("missing-token").display()
),
)
.unwrap();
now += CONFIG_WATCH_INTERVAL;
assert!(matches!(watcher.tick(now), ConfigWatchTick::Changed(_)));
assert!(load().is_err());
fs::write(&grandchild, "font-size = 24\n\n").unwrap();
now += CONFIG_WATCH_INTERVAL;
assert!(matches!(watcher.tick(now), ConfigWatchTick::Waiting(_)));

fs::remove_dir_all(&dir).unwrap();
}

/// `expedite` (focus gain / settings-panel commit) must make the very
/// next tick stat the file instead of waiting out the slow
/// `CONFIG_WATCH_INTERVAL`, and must only re-time the pending check —
Expand Down
18 changes: 15 additions & 3 deletions crates/noa-app/src/link_open.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,21 @@ pub fn open_uri(uri: &str) {
log::warn!("refusing to open hyperlink with disallowed scheme: {uri}");
return;
}
if let Err(err) = std::process::Command::new("open").arg(uri).spawn() {
log::warn!("failed to open hyperlink {uri}: {err}");
}
// Wait on a detached thread, mirroring `open_path`: a dropped `Child` is
// never reaped by std, so a fire-and-forget `spawn()` here left one
// zombie per opened link for the life of the process (B06, 2026-09
// audit). `open` returns as soon as the handler is launched, so the
// thread is short-lived; the main thread must not block on it.
let uri = uri.to_owned();
std::thread::spawn(
move || match std::process::Command::new("open").arg(&uri).status() {
Ok(status) if !status.success() => {
log::warn!("`open` failed for hyperlink {uri} ({status})");
}
Ok(_) => {}
Err(err) => log::warn!("failed to open hyperlink {uri}: {err}"),
},
);
}

/// Open `path` (already resolved to an absolute filesystem path) with the
Expand Down
61 changes: 59 additions & 2 deletions crates/noa-app/src/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -483,10 +483,26 @@ pub fn save(path: &Path, state: &SessionState) -> std::io::Result<()> {
/// Load and parse the session at `path`, or `None` if it is absent, unreadable,
/// malformed, or a different schema version.
pub fn load(path: &Path) -> Option<SessionState> {
// Refuse to even read an implausibly large file: `load` runs on every
// launch before `window-save-state` is consulted (the scrollback GC needs
// the referenced keys either way), so a corrupt or hostile file must fail
// as a plain "no session" rather than exhaust memory (B05, 2026-09 audit).
let len = fs::metadata(path).ok()?.len();
if len > MAX_SESSION_FILE_BYTES {
log::warn!(
"session: ignoring {} ({len} bytes exceeds the {MAX_SESSION_FILE_BYTES}-byte limit)",
path.display()
);
return None;
}
let source = fs::read_to_string(path).ok()?;
parse(&source)
}

/// Upper bound on a session file `load` will read. A real session is a few
/// KiB per pane; this leaves three orders of magnitude of headroom.
const MAX_SESSION_FILE_BYTES: u64 = 16 * 1024 * 1024;

/// A minimal JSON value model + recursive-descent parser, scoped to what the
/// session schema needs. Not a general JSON library — no number exponent
/// corner cases beyond what `f64::from_str` accepts, and objects preserve
Expand Down Expand Up @@ -559,6 +575,7 @@ mod json {
let mut parser = Parser {
chars: source.chars().collect(),
pos: 0,
depth: 0,
};
parser.skip_whitespace();
let value = parser.parse_value()?;
Expand All @@ -570,9 +587,17 @@ mod json {
Some(value)
}

/// Nesting cap for arrays/objects. The session schema nests a handful of
/// levels plus one per split; a document deeper than this is not a
/// session, and the recursive-descent parser must return `None` instead
/// of recursing until the stack overflows (an abort, not a panic, so it
/// would take the whole launch down).
const MAX_DEPTH: usize = 128;

struct Parser {
chars: Vec<char>,
pos: usize,
depth: usize,
}

impl Parser {
Expand All @@ -595,15 +620,25 @@ mod json {
fn parse_value(&mut self) -> Option<Value> {
self.skip_whitespace();
match self.peek()? {
'{' => self.parse_object(),
'[' => self.parse_array(),
'{' => self.nested(Self::parse_object),
'[' => self.nested(Self::parse_array),
'"' => self.parse_string().map(Value::String),
't' | 'f' => self.parse_bool(),
'n' => self.parse_null(),
_ => self.parse_number(),
}
}

fn nested(&mut self, parse: fn(&mut Self) -> Option<Value>) -> Option<Value> {
if self.depth >= MAX_DEPTH {
return None;
}
self.depth += 1;
let value = parse(self);
self.depth -= 1;
value
}

fn parse_object(&mut self) -> Option<Value> {
self.expect('{')?;
let mut entries = Vec::new();
Expand Down Expand Up @@ -739,6 +774,28 @@ use json::ObjectExt;
mod tests {
use super::*;

#[test]
fn deeply_nested_json_is_rejected_not_overflowed() {
let deep = "[".repeat(100_000);
assert!(json::parse(&deep).is_none());
let deep_closed = format!("{}{}", "[".repeat(100_000), "]".repeat(100_000));
assert!(json::parse(&deep_closed).is_none());
// Realistic nesting still parses.
let ok = format!("{}1{}", "[".repeat(50), "]".repeat(50));
assert!(json::parse(&ok).is_some());
}

#[test]
fn oversized_session_file_is_ignored() {
let dir = std::env::temp_dir().join(format!("noa-session-oversize-{}", std::process::id()));
fs::create_dir_all(&dir).unwrap();
let path = dir.join("session.json");
let file = fs::File::create(&path).unwrap();
file.set_len(MAX_SESSION_FILE_BYTES + 1).unwrap();
assert!(load(&path).is_none());
fs::remove_dir_all(&dir).ok();
}

fn sample() -> SessionState {
SessionState {
focused_window: Some(1),
Expand Down
11 changes: 11 additions & 0 deletions crates/noa-config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1762,6 +1762,17 @@ pub fn scrollback_dir_in(data_dir: &Path) -> PathBuf {
data_dir.join("noa").join("scrollback")
}

/// Every file the config at `path` includes via `config-file`, directly or
/// transitively (optional includes that don't exist yet included), so a
/// live-reload watcher can track edits to split-out config files. An
/// unreadable `path` yields an empty list.
pub fn config_include_paths(path: &Path) -> Vec<PathBuf> {
match fs::read_to_string(path) {
Ok(source) => parser::included_file_paths(path, &source),
Err(_) => Vec::new(),
}
}

pub fn load_overrides_from_path(path: &Path) -> anyhow::Result<(ConfigOverrides, Vec<Diagnostic>)> {
let source = fs::read_to_string(path)
.with_context(|| format!("failed to read config file {}", path.display()))?;
Expand Down
1 change: 1 addition & 0 deletions crates/noa-config/src/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ mod values;

pub use diagnostics::Diagnostic;
pub use directives::{Directive, parse_directives};
pub(crate) use includes::included_file_paths;
pub(crate) use overrides::is_supported_scalar_key;
pub use overrides::parse_overrides;

Expand Down
Loading