chore: import upstream snapshot with attribution
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,300 @@
|
||||
//! Tracking of active session process IDs under `~/.jcode/active_pids`.
|
||||
//!
|
||||
//! This is pure filesystem state keyed by session ID, used to discover which
|
||||
//! sessions are currently running (and to map a PID back to its session). It
|
||||
//! lives in the storage crate because it only needs [`jcode_dir`] and is a
|
||||
//! low-level concern shared by session management, dictation, and crash
|
||||
//! recovery, none of which should pull the full `session` module into scope.
|
||||
|
||||
use crate::jcode_dir;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Directory holding one file per active session ID (`~/.jcode/active_pids`).
|
||||
pub fn active_pids_dir() -> Option<PathBuf> {
|
||||
jcode_dir().ok().map(|d| d.join("active_pids"))
|
||||
}
|
||||
|
||||
/// Directory holding per-session "currently streaming" markers. A marker file
|
||||
/// exists only while a session is actively generating a model response. The
|
||||
/// file content is the owning process PID so stale markers (from crashed
|
||||
/// processes) can be detected and ignored.
|
||||
pub fn streaming_pids_dir() -> Option<std::path::PathBuf> {
|
||||
jcode_dir().ok().map(|d| d.join("streaming_pids"))
|
||||
}
|
||||
|
||||
/// Record that `session_id` is owned by process `pid`.
|
||||
pub fn register_active_pid(session_id: &str, pid: u32) {
|
||||
if let Some(dir) = active_pids_dir() {
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let _ = std::fs::write(dir.join(session_id), pid.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove the active-PID record for `session_id`, if present.
|
||||
pub fn unregister_active_pid(session_id: &str) {
|
||||
if let Some(dir) = active_pids_dir() {
|
||||
let _ = std::fs::remove_file(dir.join(session_id));
|
||||
}
|
||||
// A closed session is never streaming.
|
||||
unmark_streaming(session_id);
|
||||
}
|
||||
|
||||
/// Mark a session as actively streaming a model response.
|
||||
pub fn mark_streaming(session_id: &str) {
|
||||
if let Some(dir) = streaming_pids_dir() {
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let _ = std::fs::write(dir.join(session_id), std::process::id().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear the streaming marker for a session (turn finished or interrupted).
|
||||
pub fn unmark_streaming(session_id: &str) {
|
||||
if let Some(dir) = streaming_pids_dir() {
|
||||
let _ = std::fs::remove_file(dir.join(session_id));
|
||||
}
|
||||
}
|
||||
|
||||
/// RAII guard that marks a session as streaming for its lifetime and clears the
|
||||
/// marker on drop. This guarantees the marker is cleared on every exit path
|
||||
/// (normal return, `?` propagation, interrupt, or panic) so the menu bar count
|
||||
/// never gets stuck showing a phantom streaming session.
|
||||
pub struct StreamingGuard {
|
||||
session_id: String,
|
||||
}
|
||||
|
||||
impl StreamingGuard {
|
||||
pub fn new(session_id: impl Into<String>) -> Self {
|
||||
let session_id = session_id.into();
|
||||
mark_streaming(&session_id);
|
||||
Self { session_id }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for StreamingGuard {
|
||||
fn drop(&mut self) {
|
||||
unmark_streaming(&self.session_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the active session ID currently owned by the given process ID.
|
||||
pub fn find_active_session_id_by_pid(pid: u32) -> Option<String> {
|
||||
let dir = active_pids_dir()?;
|
||||
for entry in std::fs::read_dir(dir).ok()? {
|
||||
let entry = entry.ok()?;
|
||||
let session_id = entry.file_name().to_string_lossy().to_string();
|
||||
let stored = std::fs::read_to_string(entry.path()).ok()?;
|
||||
if stored.trim().parse::<u32>().ok()? == pid {
|
||||
return Some(session_id);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// List active session IDs currently tracked in `~/.jcode/active_pids`.
|
||||
pub fn active_session_ids() -> Vec<String> {
|
||||
let Some(dir) = active_pids_dir() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
entries
|
||||
.filter_map(|entry| entry.ok())
|
||||
.map(|entry| entry.file_name().to_string_lossy().to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn process_is_running(pid: u32) -> bool {
|
||||
if pid == 0 {
|
||||
return false;
|
||||
}
|
||||
let result = unsafe { libc::kill(pid as libc::pid_t, 0) };
|
||||
result == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn process_is_running(pid: u32) -> bool {
|
||||
// Best-effort fallback for platforms where this low-level storage crate does
|
||||
// not have a process API. The active PID file is still useful, and stale
|
||||
// entries are cleaned up by higher-level session lifecycle code.
|
||||
pid != 0
|
||||
}
|
||||
|
||||
/// Live snapshot of how many jcode sessions are running, and how many of those
|
||||
/// are actively streaming a model response right now. Used by the menu bar
|
||||
/// indicator (`jcode menubar`) and any other presence UI.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct SessionCounts {
|
||||
/// Number of live sessions (registered PID is still running).
|
||||
pub total: usize,
|
||||
/// Number of live sessions currently streaming a model response.
|
||||
pub streaming: usize,
|
||||
}
|
||||
|
||||
/// Live presence info for one running session, derived from the active-pid
|
||||
/// registry and the streaming markers.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SessionPresence {
|
||||
/// Session ID, e.g. `session_fox_1234567890_deadbeef`.
|
||||
pub session_id: String,
|
||||
/// PID of the process that owns the session.
|
||||
pub pid: u32,
|
||||
/// Whether the session is actively streaming a model response right now.
|
||||
pub streaming: bool,
|
||||
/// When the current streaming turn started (streaming marker mtime), if
|
||||
/// the session is streaming. Lets presence UIs show "working for 2m".
|
||||
pub streaming_since: Option<std::time::SystemTime>,
|
||||
}
|
||||
|
||||
/// Snapshot per-session presence by scanning the active-pid registry and
|
||||
/// streaming markers, skipping any entries whose owning process is no longer
|
||||
/// alive. This is a cheap O(n) scan over a handful of tiny files; used by the
|
||||
/// menu bar indicator and other presence UI.
|
||||
pub fn session_presence() -> Vec<SessionPresence> {
|
||||
let Some(active_dir) = active_pids_dir() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Ok(entries) = std::fs::read_dir(&active_dir) else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let streaming_dir = streaming_pids_dir();
|
||||
let mut sessions = Vec::new();
|
||||
|
||||
for entry in entries.filter_map(|entry| entry.ok()) {
|
||||
let path = entry.path();
|
||||
let session_id = entry.file_name().to_string_lossy().to_string();
|
||||
let Some(pid) = std::fs::read_to_string(&path)
|
||||
.ok()
|
||||
.and_then(|raw| raw.trim().parse::<u32>().ok())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if !process_is_running(pid) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let marker_path = streaming_dir.as_ref().map(|dir| dir.join(&session_id));
|
||||
let streaming = marker_path.as_ref().is_some_and(|marker| {
|
||||
std::fs::read_to_string(marker)
|
||||
.ok()
|
||||
.and_then(|raw| raw.trim().parse::<u32>().ok())
|
||||
.is_some_and(process_is_running)
|
||||
});
|
||||
let streaming_since = if streaming {
|
||||
marker_path
|
||||
.as_ref()
|
||||
.and_then(|marker| std::fs::metadata(marker).ok())
|
||||
.and_then(|meta| meta.modified().ok())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
sessions.push(SessionPresence {
|
||||
session_id,
|
||||
pid,
|
||||
streaming,
|
||||
streaming_since,
|
||||
});
|
||||
}
|
||||
|
||||
sessions
|
||||
}
|
||||
|
||||
/// Compute the current session counts from [`session_presence`].
|
||||
pub fn session_counts() -> SessionCounts {
|
||||
let sessions = session_presence();
|
||||
SessionCounts {
|
||||
total: sessions.len(),
|
||||
streaming: sessions.iter().filter(|s| s.streaming).count(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Serialize tests that mutate `JCODE_HOME`.
|
||||
fn lock_env() -> std::sync::MutexGuard<'static, ()> {
|
||||
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
LOCK.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_counts_counts_live_and_streaming_only() {
|
||||
let _guard = lock_env();
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
jcode_core::env::set_var("JCODE_HOME", temp.path());
|
||||
|
||||
let live = std::process::id();
|
||||
// Pick a PID that is almost certainly dead.
|
||||
let dead = 999_999u32;
|
||||
|
||||
// live + streaming
|
||||
register_active_pid("session_alpha", live);
|
||||
mark_streaming("session_alpha");
|
||||
// live + not streaming
|
||||
register_active_pid("session_beta", live);
|
||||
// dead session (should be ignored entirely)
|
||||
register_active_pid("session_gamma", dead);
|
||||
// live session whose streaming marker points at a dead pid (ignored for streaming)
|
||||
register_active_pid("session_delta", live);
|
||||
if let Some(dir) = streaming_pids_dir() {
|
||||
let _ = std::fs::write(dir.join("session_delta"), dead.to_string());
|
||||
}
|
||||
|
||||
let counts = session_counts();
|
||||
assert_eq!(counts.total, 3, "three live sessions expected");
|
||||
assert_eq!(
|
||||
counts.streaming, 1,
|
||||
"only one live streaming session expected"
|
||||
);
|
||||
|
||||
// Per-session presence reports the same view, keyed by session.
|
||||
let sessions = session_presence();
|
||||
assert_eq!(sessions.len(), 3);
|
||||
let by_id = |id: &str| {
|
||||
sessions
|
||||
.iter()
|
||||
.find(|s| s.session_id == id)
|
||||
.unwrap_or_else(|| panic!("{id} should be present"))
|
||||
};
|
||||
assert!(by_id("session_alpha").streaming);
|
||||
assert!(!by_id("session_beta").streaming);
|
||||
assert!(!by_id("session_delta").streaming);
|
||||
assert_eq!(by_id("session_alpha").pid, live);
|
||||
assert!(!sessions.iter().any(|s| s.session_id == "session_gamma"));
|
||||
|
||||
// Clearing the streaming marker drops the streaming count.
|
||||
unmark_streaming("session_alpha");
|
||||
assert_eq!(session_counts().streaming, 0);
|
||||
|
||||
// Unregistering also clears any leftover streaming marker.
|
||||
register_active_pid("session_epsilon", live);
|
||||
mark_streaming("session_epsilon");
|
||||
assert_eq!(session_counts().streaming, 1);
|
||||
unregister_active_pid("session_epsilon");
|
||||
assert_eq!(session_counts().streaming, 0);
|
||||
|
||||
jcode_core::env::remove_var("JCODE_HOME");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streaming_guard_marks_and_clears_on_drop() {
|
||||
let _guard = lock_env();
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
jcode_core::env::set_var("JCODE_HOME", temp.path());
|
||||
|
||||
register_active_pid("session_guard", std::process::id());
|
||||
assert_eq!(session_counts().streaming, 0);
|
||||
{
|
||||
let _streaming = StreamingGuard::new("session_guard");
|
||||
assert_eq!(session_counts().streaming, 1);
|
||||
}
|
||||
assert_eq!(session_counts().streaming, 0);
|
||||
|
||||
jcode_core::env::remove_var("JCODE_HOME");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
use anyhow::Result;
|
||||
use serde::Serialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
mod active_pids;
|
||||
pub use active_pids::{
|
||||
SessionCounts, SessionPresence, StreamingGuard, active_pids_dir, active_session_ids,
|
||||
find_active_session_id_by_pid, mark_streaming, register_active_pid, session_counts,
|
||||
session_presence, streaming_pids_dir, unmark_streaming, unregister_active_pid,
|
||||
};
|
||||
|
||||
/// Platform-aware runtime directory for sockets and ephemeral state.
|
||||
///
|
||||
/// - Linux: `$XDG_RUNTIME_DIR` (typically `/run/user/<uid>`)
|
||||
/// - macOS: `$TMPDIR` (per-user, e.g. `/var/folders/xx/.../T/`)
|
||||
/// - Fallback: `std::env::temp_dir()`
|
||||
///
|
||||
/// Can be overridden with `$JCODE_RUNTIME_DIR`.
|
||||
pub fn runtime_dir() -> PathBuf {
|
||||
if let Ok(dir) = std::env::var("JCODE_RUNTIME_DIR") {
|
||||
return PathBuf::from(dir);
|
||||
}
|
||||
if let Ok(dir) = std::env::var("XDG_RUNTIME_DIR") {
|
||||
return PathBuf::from(dir);
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
if let Ok(dir) = std::env::var("TMPDIR") {
|
||||
return PathBuf::from(dir);
|
||||
}
|
||||
}
|
||||
|
||||
let dir = fallback_runtime_dir();
|
||||
ensure_private_runtime_dir(&dir);
|
||||
dir
|
||||
}
|
||||
|
||||
fn fallback_runtime_dir() -> PathBuf {
|
||||
std::env::temp_dir().join(format!("jcode-{}", runtime_user_discriminator()))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn runtime_user_discriminator() -> String {
|
||||
unsafe { libc::geteuid() }.to_string()
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn runtime_user_discriminator() -> String {
|
||||
let raw = std::env::var("USERNAME")
|
||||
.or_else(|_| std::env::var("USER"))
|
||||
.unwrap_or_else(|_| "user".to_string());
|
||||
let sanitized: String = raw
|
||||
.chars()
|
||||
.filter(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_'))
|
||||
.take(64)
|
||||
.collect();
|
||||
if sanitized.is_empty() {
|
||||
"user".to_string()
|
||||
} else {
|
||||
sanitized
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_private_runtime_dir(path: &Path) {
|
||||
let _ = std::fs::create_dir_all(path);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let _ = jcode_core::fs::set_directory_permissions_owner_only(path);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn jcode_dir() -> Result<PathBuf> {
|
||||
if let Ok(path) = std::env::var("JCODE_HOME") {
|
||||
return Ok(PathBuf::from(path));
|
||||
}
|
||||
|
||||
let home = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("No home directory"))?;
|
||||
Ok(home.join(".jcode"))
|
||||
}
|
||||
|
||||
pub fn logs_dir() -> Result<PathBuf> {
|
||||
Ok(jcode_dir()?.join("logs"))
|
||||
}
|
||||
|
||||
/// Durable state directory for state that must survive reboots.
|
||||
///
|
||||
/// [`runtime_dir`] typically resolves to a tmpfs (for example
|
||||
/// `/run/user/<uid>` on Linux) that is wiped on reboot, so it must only hold
|
||||
/// sockets and truly ephemeral state. State that has to outlive a reboot,
|
||||
/// such as swarm plans and member records, belongs here instead: it resolves
|
||||
/// to `~/.jcode/state` (respecting `JCODE_HOME`).
|
||||
///
|
||||
/// When `JCODE_RUNTIME_DIR` is set (tests and sandboxed temp servers), it
|
||||
/// takes precedence so isolated runs never touch the real jcode home.
|
||||
pub fn durable_state_dir() -> PathBuf {
|
||||
if let Ok(dir) = std::env::var("JCODE_RUNTIME_DIR") {
|
||||
return PathBuf::from(dir).join("durable-state");
|
||||
}
|
||||
match jcode_dir() {
|
||||
Ok(dir) => dir.join("state"),
|
||||
Err(_) => runtime_dir().join("durable-state"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve jcode's app-owned config directory.
|
||||
///
|
||||
/// Default location is the platform config dir + `jcode` (for example
|
||||
/// `~/.config/jcode` on Linux). When `JCODE_HOME` is set, sandbox this under
|
||||
/// `$JCODE_HOME/config/jcode` so self-dev/tests do not leak into the user's
|
||||
/// real config directory.
|
||||
pub fn app_config_dir() -> Result<PathBuf> {
|
||||
if let Ok(path) = std::env::var("JCODE_HOME") {
|
||||
return Ok(PathBuf::from(path).join("config").join("jcode"));
|
||||
}
|
||||
|
||||
let config_dir =
|
||||
dirs::config_dir().ok_or_else(|| anyhow::anyhow!("No config directory found"))?;
|
||||
Ok(config_dir.join("jcode"))
|
||||
}
|
||||
|
||||
/// Resolve a path under the user's home directory, but sandbox it under
|
||||
/// `$JCODE_HOME/external/` when `JCODE_HOME` is set.
|
||||
///
|
||||
/// This keeps external provider auth files isolated during tests and sandboxed
|
||||
/// runs without changing default on-disk locations for normal users.
|
||||
pub fn user_home_path(relative: impl AsRef<Path>) -> Result<PathBuf> {
|
||||
let relative = relative.as_ref();
|
||||
if relative.is_absolute() {
|
||||
anyhow::bail!(
|
||||
"user_home_path expects a relative path, got {}",
|
||||
relative.display()
|
||||
);
|
||||
}
|
||||
|
||||
if let Ok(path) = std::env::var("JCODE_HOME") {
|
||||
return Ok(PathBuf::from(path).join("external").join(relative));
|
||||
}
|
||||
|
||||
let home = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("No home directory"))?;
|
||||
Ok(home.join(relative))
|
||||
}
|
||||
|
||||
/// Best-effort startup hardening for local config dirs that may store credentials.
|
||||
///
|
||||
/// This intentionally ignores failures so startup does not fail on exotic
|
||||
/// filesystems, but it narrows exposure on typical Unix systems.
|
||||
pub fn harden_user_config_permissions() {
|
||||
if let Some(config_dir) = dirs::config_dir() {
|
||||
let jcode_config_dir = config_dir.join("jcode");
|
||||
if jcode_config_dir.exists() {
|
||||
let _ = jcode_core::fs::set_directory_permissions_owner_only(&jcode_config_dir);
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(jcode_home) = jcode_dir()
|
||||
&& jcode_home.exists()
|
||||
{
|
||||
let _ = jcode_core::fs::set_directory_permissions_owner_only(&jcode_home);
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort hardening for a secret-bearing file and its parent directory.
|
||||
///
|
||||
/// This is used before reading credential files so legacy permissive modes can
|
||||
/// be tightened opportunistically.
|
||||
pub fn harden_secret_file_permissions(path: &Path) {
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = jcode_core::fs::set_directory_permissions_owner_only(parent);
|
||||
}
|
||||
if path.exists() {
|
||||
let _ = jcode_core::fs::set_permissions_owner_only(path);
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate an external auth file managed by another tool before reading it.
|
||||
///
|
||||
/// jcode intentionally avoids mutating these files. We also reject obvious risky
|
||||
/// cases like symlinks so a remembered trust decision stays bound to a real file
|
||||
/// path rather than an arbitrary redirect.
|
||||
pub fn validate_external_auth_file(path: &Path) -> Result<PathBuf> {
|
||||
let metadata = std::fs::symlink_metadata(path).map_err(|e| {
|
||||
anyhow::anyhow!(
|
||||
"Failed to inspect external auth file {}: {}",
|
||||
path.display(),
|
||||
e
|
||||
)
|
||||
})?;
|
||||
if metadata.file_type().is_symlink() {
|
||||
anyhow::bail!(
|
||||
"Refusing to read external auth file via symlink: {}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
if !metadata.is_file() {
|
||||
anyhow::bail!(
|
||||
"External auth path is not a regular file: {}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
std::fs::canonicalize(path).map_err(|e| {
|
||||
anyhow::anyhow!(
|
||||
"Failed to canonicalize external auth file {}: {}",
|
||||
path.display(),
|
||||
e
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn ensure_dir(path: &Path) -> Result<()> {
|
||||
if !path.exists() {
|
||||
std::fs::create_dir_all(path)?;
|
||||
jcode_core::fs::set_directory_permissions_owner_only(path)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn write_text_secret(path: &Path, content: &str) -> Result<()> {
|
||||
write_bytes_inner(path, content.as_bytes(), true)?;
|
||||
if let Some(parent) = path.parent() {
|
||||
jcode_core::fs::set_directory_permissions_owner_only(parent)?;
|
||||
}
|
||||
jcode_core::fs::set_permissions_owner_only(path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn upsert_env_file_value(path: &Path, env_key: &str, value: Option<&str>) -> Result<()> {
|
||||
let existing = std::fs::read_to_string(path).unwrap_or_default();
|
||||
let prefix = format!("{}=", env_key);
|
||||
|
||||
let mut lines = Vec::new();
|
||||
let mut replaced = false;
|
||||
for line in existing.lines() {
|
||||
if line.starts_with(&prefix) {
|
||||
replaced = true;
|
||||
if let Some(value) = value {
|
||||
lines.push(format!("{}={}", env_key, value));
|
||||
}
|
||||
} else {
|
||||
lines.push(line.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if !replaced && let Some(value) = value {
|
||||
lines.push(format!("{}={}", env_key, value));
|
||||
}
|
||||
|
||||
let mut content = lines.join("\n");
|
||||
if !content.is_empty() {
|
||||
content.push('\n');
|
||||
}
|
||||
write_text_secret(path, &content)
|
||||
}
|
||||
|
||||
pub fn write_json<T: Serialize + ?Sized>(path: &Path, value: &T) -> Result<()> {
|
||||
write_json_inner(path, value, true)
|
||||
}
|
||||
|
||||
pub fn write_json_secret<T: Serialize + ?Sized>(path: &Path, value: &T) -> Result<()> {
|
||||
write_json_inner(path, value, true)?;
|
||||
if let Some(parent) = path.parent() {
|
||||
jcode_core::fs::set_directory_permissions_owner_only(parent)?;
|
||||
}
|
||||
jcode_core::fs::set_permissions_owner_only(path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fast JSON write: atomic rename but no fsync. Good for frequent saves where
|
||||
/// durability on power loss is not critical (e.g., session saves during tool execution).
|
||||
/// Data is still safe against process crashes (atomic rename protects against partial writes).
|
||||
pub fn write_json_fast<T: Serialize + ?Sized>(path: &Path, value: &T) -> Result<()> {
|
||||
write_json_inner(path, value, false)
|
||||
}
|
||||
|
||||
/// Atomically write raw bytes to `path` (temp file + rename), fsync'd for
|
||||
/// durability. Used for editing user config files where a torn write would be
|
||||
/// catastrophic.
|
||||
pub fn write_bytes(path: &Path, bytes: &[u8]) -> Result<()> {
|
||||
write_bytes_inner(path, bytes, true)
|
||||
}
|
||||
|
||||
fn write_json_inner<T: Serialize + ?Sized>(path: &Path, value: &T, durable: bool) -> Result<()> {
|
||||
let bytes = serde_json::to_vec(value)?;
|
||||
write_bytes_inner(path, &bytes, durable)
|
||||
}
|
||||
|
||||
fn write_bytes_inner(path: &Path, bytes: &[u8], durable: bool) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
ensure_dir(parent)?;
|
||||
}
|
||||
|
||||
let pid = std::process::id();
|
||||
let nonce: u64 = rand::random();
|
||||
let tmp_path = path.with_extension(format!("tmp.{}.{}", pid, nonce));
|
||||
|
||||
let result = (|| -> Result<()> {
|
||||
let file = std::fs::File::create(&tmp_path)?;
|
||||
let mut writer = std::io::BufWriter::new(file);
|
||||
writer.write_all(bytes)?;
|
||||
let file = writer
|
||||
.into_inner()
|
||||
.map_err(|e| anyhow::anyhow!("flush failed: {}", e))?;
|
||||
|
||||
if durable {
|
||||
file.sync_all()?;
|
||||
}
|
||||
|
||||
if path.exists() {
|
||||
let bak_path = path.with_extension("bak");
|
||||
// Preserve the previous version as .bak without ever leaving the
|
||||
// primary path missing. On Unix, rename(tmp, path) atomically
|
||||
// replaces the destination, so the backup can be a hard link to
|
||||
// the old inode: concurrent readers always see either the old or
|
||||
// the new content, never ENOENT. (The old rename-away approach
|
||||
// opened a window where the primary did not exist, which made
|
||||
// concurrent load-all style readers silently drop entries, e.g.
|
||||
// self-dev build requests "disappearing" from the queue.)
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let _ = std::fs::remove_file(&bak_path);
|
||||
let _ = std::fs::hard_link(path, &bak_path);
|
||||
}
|
||||
// On Windows, rename fails when the destination exists, so the
|
||||
// primary must be moved away first; the brief missing window is
|
||||
// unavoidable without platform-specific replace APIs.
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let _ = std::fs::rename(path, &bak_path);
|
||||
}
|
||||
}
|
||||
|
||||
std::fs::rename(&tmp_path, path)?;
|
||||
|
||||
#[cfg(unix)]
|
||||
if durable
|
||||
&& let Some(parent) = path.parent()
|
||||
&& let Ok(dir) = std::fs::File::open(parent)
|
||||
{
|
||||
let _ = dir.sync_all();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})();
|
||||
|
||||
if result.is_err() {
|
||||
let _ = std::fs::remove_file(&tmp_path);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
pub enum StorageRecoveryEvent<'a> {
|
||||
CorruptPrimary {
|
||||
path: &'a Path,
|
||||
error: &'a serde_json::Error,
|
||||
},
|
||||
RecoveredFromBackup {
|
||||
backup_path: &'a Path,
|
||||
},
|
||||
}
|
||||
|
||||
pub fn read_json<T: DeserializeOwned>(path: &Path) -> Result<T> {
|
||||
read_json_with_recovery_handler(path, |event| match event {
|
||||
StorageRecoveryEvent::CorruptPrimary { path, error } => {
|
||||
eprintln!(
|
||||
"Corrupt JSON at {}, trying backup: {}",
|
||||
path.display(),
|
||||
error
|
||||
);
|
||||
}
|
||||
StorageRecoveryEvent::RecoveredFromBackup { backup_path } => {
|
||||
eprintln!("Recovered from backup: {}", backup_path.display());
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn read_json_with_recovery_handler<T, F>(path: &Path, mut on_recovery: F) -> Result<T>
|
||||
where
|
||||
T: DeserializeOwned,
|
||||
F: FnMut(StorageRecoveryEvent<'_>),
|
||||
{
|
||||
let data = std::fs::read_to_string(path)?;
|
||||
match serde_json::from_str(&data) {
|
||||
Ok(val) => Ok(val),
|
||||
Err(e) => {
|
||||
let bak_path = path.with_extension("bak");
|
||||
if bak_path.exists() {
|
||||
on_recovery(StorageRecoveryEvent::CorruptPrimary { path, error: &e });
|
||||
let bak_data = std::fs::read_to_string(&bak_path)?;
|
||||
match serde_json::from_str(&bak_data) {
|
||||
Ok(val) => {
|
||||
on_recovery(StorageRecoveryEvent::RecoveredFromBackup {
|
||||
backup_path: &bak_path,
|
||||
});
|
||||
let _ = std::fs::copy(&bak_path, path);
|
||||
Ok(val)
|
||||
}
|
||||
Err(bak_err) => Err(anyhow::anyhow!(
|
||||
"Corrupt JSON at {} ({}), backup also corrupt ({})",
|
||||
path.display(),
|
||||
e,
|
||||
bak_err
|
||||
)),
|
||||
}
|
||||
} else {
|
||||
Err(anyhow::anyhow!("Corrupt JSON at {}: {}", path.display(), e))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fast append of a single JSON value followed by a newline.
|
||||
/// Intended for append-only journals where per-write fsync is not required.
|
||||
///
|
||||
/// The entire line (value + trailing newline) is serialized into one buffer
|
||||
/// and appended with a single `write_all`. Streaming the serializer straight
|
||||
/// into the file issued many small writes, so a concurrent reader (or a
|
||||
/// process killed mid-append) could observe a torn half-line, and two
|
||||
/// concurrent appenders could interleave fragments. A single `O_APPEND` write
|
||||
/// of the complete line keeps each journal line intact.
|
||||
pub fn append_json_line_fast<T: Serialize + ?Sized>(path: &Path, value: &T) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
ensure_dir(parent)?;
|
||||
}
|
||||
|
||||
let mut line = serde_json::to_vec(value)?;
|
||||
line.push(b'\n');
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(path)?;
|
||||
file.write_all(&line)?;
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user