| 1 | //! Crash/log inspection for native clients (APPS-103). |
| 2 | //! |
| 3 | //! This is a read surface, not a telemetry store: it lists and serves files |
| 4 | //! the runtime already writes to disk — `logs/` rolling logs, `audit.log`, |
| 5 | //! and `crashes/*.log` panic dumps — so a client (including a remote or |
| 6 | //! headless one that cannot see the disk) can package an export locally. |
| 7 | //! There is deliberately no upload route and no second log store. |
| 8 | //! |
| 9 | //! Routes: |
| 10 | //! GET /v1/logs — recent log/audit entries (name, size, modified) |
| 11 | //! GET /v1/logs/{name} — bounded window of one file (?offset, ?limit, ?tail) |
| 12 | //! GET /v1/crashes — crash-dump entries |
| 13 | //! GET /v1/crashes/{name} — bounded window of one dump |
| 14 | //! GET /v1/process — pid, start time, uptime, version, RSS (Linux) |
| 15 | |
| 16 | use std::fs::File; |
| 17 | use std::io::{Read as _, Seek as _, SeekFrom}; |
| 18 | use std::path::{Path as FsPath, PathBuf}; |
| 19 | use std::sync::OnceLock; |
| 20 | use std::time::{Instant, SystemTime}; |
| 21 | |
| 22 | use axum::Json; |
| 23 | use axum::extract::{Path, Query, State}; |
| 24 | use serde::{Deserialize, Serialize}; |
| 25 | use serde_json::{Value, json}; |
| 26 | |
| 27 | use super::workspace::encode_window; |
| 28 | use super::{ApiError, RuntimeApiState}; |
| 29 | |
| 30 | /// Default read window for a file entry. |
| 31 | const READ_LIMIT_DEFAULT: usize = 256 * 1024; |
| 32 | const READ_LIMIT_MAX: usize = 4 * 1024 * 1024; |
| 33 | /// Listing caps — newest first, bounded so a long-lived install cannot |
| 34 | /// produce an unbounded response. |
| 35 | const LOG_LIST_CAP: usize = 64; |
| 36 | const CRASH_LIST_CAP: usize = 64; |
| 37 | |
| 38 | /// When this API server came up. `build_router` stamps it once so process |
| 39 | /// facts describe the serving process, not first-call time. |
| 40 | static SERVER_STARTED: OnceLock<(SystemTime, Instant)> = OnceLock::new(); |
| 41 | |
| 42 | pub(super) fn mark_server_started() { |
| 43 | let _ = SERVER_STARTED.set((SystemTime::now(), Instant::now())); |
| 44 | } |
| 45 | |
| 46 | // --------------------------------------------------------------------------- |
| 47 | // Shared listing + bounded read |
| 48 | // --------------------------------------------------------------------------- |
| 49 | |
| 50 | #[derive(Debug, Serialize)] |
| 51 | struct FileEntry { |
| 52 | name: String, |
| 53 | size: u64, |
| 54 | #[serde(skip_serializing_if = "Option::is_none")] |
| 55 | modified: Option<String>, |
| 56 | } |
| 57 | |
| 58 | #[derive(Deserialize)] |
| 59 | #[serde(deny_unknown_fields)] |
| 60 | pub(super) struct FileReadQuery { |
| 61 | offset: Option<u64>, |
| 62 | limit: Option<usize>, |
| 63 | /// Convenience tail read: last N bytes of the file. |
| 64 | tail: Option<u64>, |
| 65 | } |
| 66 | |
| 67 | fn rfc3339(time: SystemTime) -> String { |
| 68 | chrono::DateTime::<chrono::Utc>::from(time).to_rfc3339() |
| 69 | } |
| 70 | |
| 71 | /// Basenames only — a `{name}` path segment must never reach outside the |
| 72 | /// listing directory. Refuse anything that is not a plain file name. |
| 73 | fn safe_entry_name(raw: &str) -> Result<String, ApiError> { |
| 74 | let name = raw.trim(); |
| 75 | if name.is_empty() |
| 76 | || name.len() > 255 |
| 77 | || name.contains('/') |
| 78 | || name.contains('\\') |
| 79 | || name.contains('\0') |
| 80 | || name == "." |
| 81 | || name == ".." |
| 82 | { |
| 83 | return Err(ApiError::bad_request("name must be a file name")); |
| 84 | } |
| 85 | Ok(name.to_string()) |
| 86 | } |
| 87 | |
| 88 | fn list_files(dir: &FsPath, cap: usize) -> Vec<FileEntry> { |
| 89 | let mut entries: Vec<FileEntry> = Vec::new(); |
| 90 | if let Ok(read_dir) = std::fs::read_dir(dir) { |
| 91 | for entry in read_dir.flatten() { |
| 92 | let Ok(file_type) = entry.file_type() else { |
| 93 | continue; |
| 94 | }; |
| 95 | if !file_type.is_file() { |
| 96 | continue; |
| 97 | } |
| 98 | let Some(name) = entry.file_name().to_str().map(str::to_owned) else { |
| 99 | continue; |
| 100 | }; |
| 101 | let Ok(metadata) = entry.metadata() else { |
| 102 | continue; |
| 103 | }; |
| 104 | entries.push(FileEntry { |
| 105 | name, |
| 106 | size: metadata.len(), |
| 107 | modified: metadata.modified().ok().map(rfc3339), |
| 108 | }); |
| 109 | } |
| 110 | } |
| 111 | entries.sort_by(|a, b| { |
| 112 | b.modified |
| 113 | .cmp(&a.modified) |
| 114 | .then_with(|| a.name.cmp(&b.name)) |
| 115 | }); |
| 116 | entries.truncate(cap); |
| 117 | entries |
| 118 | } |
| 119 | |
| 120 | /// Read `[offset, offset + limit)` of a named file inside `dir` without |
| 121 | /// loading the whole file. Symlinks are never followed. |
| 122 | fn read_named_window(dir: &FsPath, name: &str, query: FileReadQuery) -> Result<Value, ApiError> { |
| 123 | let path = dir.join(name); |
| 124 | // Open first without following a final symlink, then take metadata from |
| 125 | // the handle, so the checked file is the file that is read. |
| 126 | let mut file = open_no_follow(&path).map_err(|error| match error.kind() { |
| 127 | std::io::ErrorKind::NotFound => ApiError::not_found("file not found"), |
| 128 | _ if is_symlink_refusal(&error) => ApiError::forbidden("not a regular file"), |
| 129 | _ => ApiError::internal(format!("file open failed: {error}")), |
| 130 | })?; |
| 131 | let metadata = file |
| 132 | .metadata() |
| 133 | .map_err(|error| ApiError::internal(format!("file access failed: {error}")))?; |
| 134 | if metadata.file_type().is_symlink() || !metadata.is_file() { |
| 135 | return Err(ApiError::forbidden("not a regular file")); |
| 136 | } |
| 137 | let size = metadata.len(); |
| 138 | let limit = query.limit.unwrap_or(READ_LIMIT_DEFAULT); |
| 139 | if !(1..=READ_LIMIT_MAX).contains(&limit) { |
| 140 | return Err(ApiError::bad_request(format!( |
| 141 | "limit must be between 1 and {READ_LIMIT_MAX} bytes" |
| 142 | ))); |
| 143 | } |
| 144 | let offset = match (query.offset, query.tail) { |
| 145 | (Some(_), Some(_)) => { |
| 146 | return Err(ApiError::bad_request( |
| 147 | "offset and tail are mutually exclusive", |
| 148 | )); |
| 149 | } |
| 150 | (Some(offset), None) => offset.min(size), |
| 151 | (None, Some(tail)) => size.saturating_sub(tail.min(size)), |
| 152 | (None, None) => 0, |
| 153 | }; |
| 154 | file.seek(SeekFrom::Start(offset)) |
| 155 | .map_err(|error| ApiError::internal(format!("file seek failed: {error}")))?; |
| 156 | let mut window = Vec::with_capacity(limit.min(64 * 1024)); |
| 157 | file.take(limit as u64) |
| 158 | .read_to_end(&mut window) |
| 159 | .map_err(|error| ApiError::internal(format!("file read failed: {error}")))?; |
| 160 | let truncated = offset as usize + window.len() < size as usize; |
| 161 | let (encoding, content) = encode_window(&window); |
| 162 | Ok(json!({ |
| 163 | "name": name, |
| 164 | "size": size, |
| 165 | "modified": metadata.modified().ok().map(rfc3339), |
| 166 | "offset": offset, |
| 167 | "bytes": window.len(), |
| 168 | "truncated": truncated, |
| 169 | "encoding": encoding, |
| 170 | "content": content, |
| 171 | })) |
| 172 | } |
| 173 | |
| 174 | /// Open `path` read-only without following a symlink in its final component. |
| 175 | /// `O_NONBLOCK` keeps a FIFO planted under the name from hanging the open; |
| 176 | /// the regular-file check on the handle rejects it afterwards. |
| 177 | fn open_no_follow(path: &FsPath) -> std::io::Result<File> { |
| 178 | let mut options = std::fs::OpenOptions::new(); |
| 179 | options.read(true); |
| 180 | #[cfg(unix)] |
| 181 | { |
| 182 | use std::os::unix::fs::OpenOptionsExt as _; |
| 183 | options.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK); |
| 184 | } |
| 185 | #[cfg(windows)] |
| 186 | { |
| 187 | use std::os::windows::fs::OpenOptionsExt as _; |
| 188 | use windows_sys::Win32::Storage::FileSystem::FILE_FLAG_OPEN_REPARSE_POINT; |
| 189 | options.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT); |
| 190 | } |
| 191 | options.open(path) |
| 192 | } |
| 193 | |
| 194 | /// `O_NOFOLLOW` on a symlink fails with `ELOOP` (and `EMLINK` on some BSDs). |
| 195 | fn is_symlink_refusal(error: &std::io::Error) -> bool { |
| 196 | #[cfg(unix)] |
| 197 | { |
| 198 | matches!(error.raw_os_error(), Some(code) if code == libc::ELOOP || code == libc::EMLINK) |
| 199 | } |
| 200 | #[cfg(not(unix))] |
| 201 | { |
| 202 | let _ = error; |
| 203 | false |
| 204 | } |
| 205 | } |
| 206 | |
| 207 | // --------------------------------------------------------------------------- |
| 208 | // Directories |
| 209 | // --------------------------------------------------------------------------- |
| 210 | |
| 211 | /// Log files live under `runtime_log::log_directory()`; the audit trail sits |
| 212 | /// beside them at `<codewhale home>/audit.log[.1]` and is listed as extra |
| 213 | /// entries so one listing covers every text log the runtime writes. |
| 214 | fn log_sources() -> Vec<(PathBuf, Vec<PathBuf>)> { |
| 215 | let mut sources = Vec::new(); |
| 216 | if let Some(dir) = crate::runtime_log::log_directory() { |
| 217 | let mut singles = Vec::new(); |
| 218 | if let Ok(home) = codewhale_config::codewhale_home() { |
| 219 | for name in ["audit.log", "audit.log.1"] { |
| 220 | let path = home.join(name); |
| 221 | if path.is_file() { |
| 222 | singles.push(path); |
| 223 | } |
| 224 | } |
| 225 | } |
| 226 | sources.push((dir, singles)); |
| 227 | } |
| 228 | sources |
| 229 | } |
| 230 | |
| 231 | /// Read the selected profile's crash store. Default profiles also retain |
| 232 | /// access to legacy dumps; explicit profiles never expose ambient diagnostics. |
| 233 | fn crash_dirs() -> Vec<PathBuf> { |
| 234 | let mut dirs = Vec::new(); |
| 235 | if let Ok(home) = codewhale_config::codewhale_home() { |
| 236 | let dir = home.join("crashes"); |
| 237 | if dir.is_dir() { |
| 238 | dirs.push(dir); |
| 239 | } |
| 240 | } |
| 241 | if !codewhale_config::codewhale_home_is_explicit() |
| 242 | && let Ok(home) = codewhale_config::legacy_deepseek_home() |
| 243 | { |
| 244 | let dir = home.join("crashes"); |
| 245 | if dir.is_dir() && !dirs.contains(&dir) { |
| 246 | dirs.push(dir); |
| 247 | } |
| 248 | } |
| 249 | dirs |
| 250 | } |
| 251 | |
| 252 | // --------------------------------------------------------------------------- |
| 253 | // Routes |
| 254 | // --------------------------------------------------------------------------- |
| 255 | |
| 256 | pub(super) async fn list_logs(State(_state): State<RuntimeApiState>) -> Json<Value> { |
| 257 | // Directory walks and per-file stats are blocking syscalls, and a |
| 258 | // diagnostics read must never park a Tokio worker — least of all while |
| 259 | // the thing being diagnosed is the runtime's responsiveness (#6149). |
| 260 | let sources = tokio::task::spawn_blocking(list_log_sources) |
| 261 | .await |
| 262 | .unwrap_or_default(); |
| 263 | Json(json!({ "sources": sources })) |
| 264 | } |
| 265 | |
| 266 | fn list_log_sources() -> Vec<Value> { |
| 267 | let mut sources = Vec::new(); |
| 268 | for (dir, singles) in log_sources() { |
| 269 | let mut entries = list_files(&dir, LOG_LIST_CAP); |
| 270 | for path in singles { |
| 271 | if let Ok(metadata) = std::fs::symlink_metadata(&path) |
| 272 | && metadata.is_file() |
| 273 | && !metadata.file_type().is_symlink() |
| 274 | && let Some(name) = path.file_name().and_then(|name| name.to_str()) |
| 275 | { |
| 276 | entries.push(FileEntry { |
| 277 | name: name.to_string(), |
| 278 | size: metadata.len(), |
| 279 | modified: metadata.modified().ok().map(rfc3339), |
| 280 | }); |
| 281 | } |
| 282 | } |
| 283 | entries.sort_by(|a, b| { |
| 284 | b.modified |
| 285 | .cmp(&a.modified) |
| 286 | .then_with(|| a.name.cmp(&b.name)) |
| 287 | }); |
| 288 | entries.truncate(LOG_LIST_CAP); |
| 289 | sources.push(json!({ |
| 290 | "dir": dir, |
| 291 | "files": entries, |
| 292 | })); |
| 293 | } |
| 294 | sources |
| 295 | } |
| 296 | |
| 297 | pub(super) async fn read_log( |
| 298 | State(_state): State<RuntimeApiState>, |
| 299 | Path(name): Path<String>, |
| 300 | Query(query): Query<FileReadQuery>, |
| 301 | ) -> Result<Json<Value>, ApiError> { |
| 302 | let name = safe_entry_name(&name)?; |
| 303 | let body = tokio::task::spawn_blocking(move || { |
| 304 | // The audit trail is listed beside the log dir; resolve it from the |
| 305 | // codewhale home rather than the log directory. |
| 306 | if name == "audit.log" || name == "audit.log.1" { |
| 307 | let home = codewhale_config::codewhale_home() |
| 308 | .map_err(|error| ApiError::internal(format!("home unavailable: {error}")))?; |
| 309 | return read_named_window(&home, &name, query); |
| 310 | } |
| 311 | let dir = crate::runtime_log::log_directory() |
| 312 | .ok_or_else(|| ApiError::not_found("no log directory"))?; |
| 313 | read_named_window(&dir, &name, query) |
| 314 | }) |
| 315 | .await |
| 316 | .map_err(|_| ApiError::internal("log read failed"))??; |
| 317 | Ok(Json(body)) |
| 318 | } |
| 319 | |
| 320 | pub(super) async fn list_crashes(State(_state): State<RuntimeApiState>) -> Json<Value> { |
| 321 | // Same reason as `list_logs`: `list_files` stats every entry. |
| 322 | let sources = tokio::task::spawn_blocking(list_crash_sources) |
| 323 | .await |
| 324 | .unwrap_or_default(); |
| 325 | Json(json!({ "sources": sources })) |
| 326 | } |
| 327 | |
| 328 | fn list_crash_sources() -> Vec<Value> { |
| 329 | let mut sources = Vec::new(); |
| 330 | for dir in crash_dirs() { |
| 331 | sources.push(json!({ |
| 332 | "dir": dir, |
| 333 | "files": list_files(&dir, CRASH_LIST_CAP), |
| 334 | })); |
| 335 | } |
| 336 | sources |
| 337 | } |
| 338 | |
| 339 | pub(super) async fn read_crash( |
| 340 | State(_state): State<RuntimeApiState>, |
| 341 | Path(name): Path<String>, |
| 342 | Query(query): Query<FileReadQuery>, |
| 343 | ) -> Result<Json<Value>, ApiError> { |
| 344 | let name = safe_entry_name(&name)?; |
| 345 | let body = tokio::task::spawn_blocking(move || { |
| 346 | for dir in crash_dirs() { |
| 347 | let candidate = dir.join(&name); |
| 348 | if std::fs::symlink_metadata(&candidate) |
| 349 | .map(|m| m.is_file() && !m.file_type().is_symlink()) |
| 350 | .unwrap_or(false) |
| 351 | { |
| 352 | return read_named_window(&dir, &name, query); |
| 353 | } |
| 354 | } |
| 355 | Err(ApiError::not_found("crash capture not found")) |
| 356 | }) |
| 357 | .await |
| 358 | .map_err(|_| ApiError::internal("crash read failed"))??; |
| 359 | Ok(Json(body)) |
| 360 | } |
| 361 | |
| 362 | // --------------------------------------------------------------------------- |
| 363 | // GET /v1/process |
| 364 | // --------------------------------------------------------------------------- |
| 365 | |
| 366 | #[cfg(target_os = "linux")] |
| 367 | fn rss_bytes() -> Option<u64> { |
| 368 | let status = std::fs::read_to_string("/proc/self/status").ok()?; |
| 369 | let line = status.lines().find(|line| line.starts_with("VmRSS:"))?; |
| 370 | let kb: u64 = line.split_whitespace().nth(1)?.parse().ok()?; |
| 371 | Some(kb * 1024) |
| 372 | } |
| 373 | |
| 374 | #[cfg(not(target_os = "linux"))] |
| 375 | fn rss_bytes() -> Option<u64> { |
| 376 | None |
| 377 | } |
| 378 | |
| 379 | pub(super) async fn process_info(State(_state): State<RuntimeApiState>) -> Json<Value> { |
| 380 | // `rss_bytes` reads /proc on Linux and `current_exe` hits the filesystem; |
| 381 | // both are blocking, and this route is polled for live health. |
| 382 | let (executable, rss) = |
| 383 | tokio::task::spawn_blocking(|| (std::env::current_exe().ok(), rss_bytes())) |
| 384 | .await |
| 385 | .unwrap_or((None, None)); |
| 386 | let (started_at, uptime_secs) = match SERVER_STARTED.get() { |
| 387 | Some((system, instant)) => (Some(rfc3339(*system)), Some(instant.elapsed().as_secs())), |
| 388 | None => (None, None), |
| 389 | }; |
| 390 | Json(json!({ |
| 391 | "pid": std::process::id(), |
| 392 | "version": env!("CARGO_PKG_VERSION"), |
| 393 | "commit": option_env!("CODEWHALE_BUILD_COMMIT").unwrap_or("unknown"), |
| 394 | "started_at": started_at, |
| 395 | "uptime_seconds": uptime_secs, |
| 396 | "executable": executable, |
| 397 | "rss_bytes": rss, |
| 398 | })) |
| 399 | } |
| 400 | |
| 401 | #[cfg(test)] |
| 402 | mod tests { |
| 403 | use super::*; |
| 404 | #[cfg(unix)] |
| 405 | use axum::http::StatusCode; |
| 406 | |
| 407 | #[test] |
| 408 | fn explicit_profile_never_lists_ambient_crashes() { |
| 409 | use crate::test_support::{EnvVarGuard, lock_test_env}; |
| 410 | let _lock = lock_test_env(); |
| 411 | let tmp = tempfile::tempdir().expect("temp"); |
| 412 | let _home = EnvVarGuard::set("HOME", tmp.path()); |
| 413 | for base in [".codewhale", ".deepseek"] { |
| 414 | std::fs::create_dir_all(tmp.path().join(base).join("crashes")) |
| 415 | .expect("ambient fixture"); |
| 416 | } |
| 417 | let profile = tmp.path().join("selected"); |
| 418 | let _profile = EnvVarGuard::set("CODEWHALE_HOME", &profile); |
| 419 | assert!(crash_dirs().is_empty(), "no fallback for a fresh profile"); |
| 420 | let selected = profile.join("crashes"); |
| 421 | std::fs::create_dir_all(&selected).expect("selected fixture"); |
| 422 | assert_eq!(crash_dirs(), vec![selected]); |
| 423 | } |
| 424 | |
| 425 | #[test] |
| 426 | fn invalid_profile_does_not_fall_back_to_ambient_crashes() { |
| 427 | let _lock = crate::test_support::lock_test_env(); |
| 428 | let _profile = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", "relative-profile"); |
| 429 | assert!(crash_dirs().is_empty()); |
| 430 | } |
| 431 | |
| 432 | fn whole_file() -> FileReadQuery { |
| 433 | FileReadQuery { |
| 434 | offset: None, |
| 435 | limit: None, |
| 436 | tail: None, |
| 437 | } |
| 438 | } |
| 439 | |
| 440 | #[test] |
| 441 | fn named_window_reads_a_regular_file() { |
| 442 | let dir = tempfile::TempDir::new().expect("temp"); |
| 443 | std::fs::write(dir.path().join("a.log"), b"hello").expect("write"); |
| 444 | let body = read_named_window(dir.path(), "a.log", whole_file()).expect("read"); |
| 445 | assert_eq!(body["bytes"], 5); |
| 446 | assert_eq!(body["size"], 5); |
| 447 | } |
| 448 | |
| 449 | #[cfg(unix)] |
| 450 | #[test] |
| 451 | fn named_window_refuses_a_symlink_at_open() { |
| 452 | let dir = tempfile::TempDir::new().expect("temp"); |
| 453 | let outside = tempfile::TempDir::new().expect("temp"); |
| 454 | let secret = outside.path().join("secret"); |
| 455 | std::fs::write(&secret, b"do not serve").expect("write"); |
| 456 | std::os::unix::fs::symlink(&secret, dir.path().join("a.log")).expect("symlink"); |
| 457 | let error = read_named_window(dir.path(), "a.log", whole_file()) |
| 458 | .expect_err("symlink must be refused"); |
| 459 | assert_eq!(error.status, StatusCode::FORBIDDEN); |
| 460 | } |
| 461 | |
| 462 | #[cfg(unix)] |
| 463 | #[test] |
| 464 | fn named_window_refuses_a_fifo_without_blocking() { |
| 465 | let dir = tempfile::TempDir::new().expect("temp"); |
| 466 | let fifo = dir.path().join("a.log"); |
| 467 | let c_path = std::ffi::CString::new(fifo.as_os_str().as_encoded_bytes()).expect("cstr"); |
| 468 | // SAFETY: `c_path` is a valid NUL-terminated path for the call. |
| 469 | assert_eq!(unsafe { libc::mkfifo(c_path.as_ptr(), 0o600) }, 0); |
| 470 | let error = |
| 471 | read_named_window(dir.path(), "a.log", whole_file()).expect_err("fifo must be refused"); |
| 472 | assert_eq!(error.status, StatusCode::FORBIDDEN); |
| 473 | } |
| 474 | } |
| 475 |