返回 CodeWhale
diagnostics.rs
根目录 / crates / tui / src / runtime_api / diagnostics.rs
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
475 lines RUST