Published source
Download source ZIP
CH-J Tools
Browse directories and files for a specific application release.
CH-J / Server Manager
1704 files
0.1.2-beta
Added: · Built:
1
//! JSON-lines application log (port of `logging/logger.js` + `logService.js`).2
//! Field names that look like secrets are redacted recursively; callers must still3
//! never pass secrets inside free-text messages.5
use crate::core::error::CoreResult;6
use once_cell::sync::Lazy;7
use parking_lot::Mutex;8
use regex::Regex;9
use serde::Serialize;10
use serde_json::{json, Map, Value};11
use std::fs::{self, OpenOptions};12
use std::io::{Read, Seek, SeekFrom, Write};13
use std::path::{Path, PathBuf};15
static SENSITIVE_KEYS: Lazy<Regex> =16
Lazy::new(|| Regex::new(r"(?i)pass(word|phrase)?|private.?key|secret|token|credential").unwrap());18
pub const DEFAULT_TAIL: u64 = 256 * 1024;19
pub const MAX_TAIL: u64 = 1024 * 1024;21
pub fn sanitize(value: &Value, depth: usize) -> Value {22
if depth > 5 {23
return Value::String("[depth-limit]".into());24
}25
match value {26
Value::Array(items) => Value::Array(items.iter().take(50).map(|v| sanitize(v, depth + 1)).collect()),27
Value::Object(map) => {28
let mut out = Map::new();29
for (key, entry) in map {30
if SENSITIVE_KEYS.is_match(key) {31
out.insert(key.clone(), Value::String("[redacted]".into()));32
} else {33
out.insert(key.clone(), sanitize(entry, depth + 1));34
}35
}36
Value::Object(out)37
}38
other => other.clone(),39
}40
}42
pub struct Logger {43
path: PathBuf,44
lock: Mutex<()>,45
}47
impl Logger {48
pub fn new(root: &Path) -> Self {49
let dir = root.join("logs");50
let _ = fs::create_dir_all(&dir);51
Self { path: dir.join("core.log"), lock: Mutex::new(()) }52
}54
pub fn path(&self) -> &Path {55
&self.path56
}58
pub fn write(&self, level: &str, message: &str, data: Value) {59
let record = json!({60
"timestamp": chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),61
"level": level,62
"message": message,63
"data": sanitize(&if data.is_null() { json!({}) } else { data }, 0),64
});65
let line = format!("{record}\n");66
let _guard = self.lock.lock();67
let mut options = OpenOptions::new();68
options.create(true).append(true);69
#[cfg(unix)]70
{71
use std::os::unix::fs::OpenOptionsExt;72
options.mode(0o600);73
}74
if let Ok(mut file) = options.open(&self.path) {75
let _ = file.write_all(line.as_bytes());76
}77
if cfg!(debug_assertions) {78
eprintln!("[{}] {} {}", level.to_uppercase(), message, record["data"]);79
}80
}82
pub fn info(&self, message: &str, data: Value) {83
self.write("info", message, data)84
}85
pub fn warn(&self, message: &str, data: Value) {86
self.write("warn", message, data)87
}88
pub fn error(&self, message: &str, data: Value) {89
self.write("error", message, data)90
}91
}93
#[derive(Serialize)]94
#[serde(rename_all = "camelCase")]95
pub struct LogTail {96
pub text: String,97
pub truncated: bool,98
pub size: u64,99
pub read_at: String,100
}102
pub fn normalize_limit(value: Option<&Value>) -> u64 {103
match value.and_then(Value::as_f64) {104
Some(n) if n.fract() == 0.0 && n > 0.0 && n <= 9_007_199_254_740_991.0 => (n as u64).min(MAX_TAIL),105
_ => DEFAULT_TAIL,106
}107
}109
pub fn read_tail(path: &Path, max_bytes: u64) -> CoreResult<LogTail> {110
let now = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);111
let meta = match fs::metadata(path) {112
Ok(meta) => meta,113
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {114
return Ok(LogTail { text: String::new(), truncated: false, size: 0, read_at: now })115
}116
Err(e) => return Err(e.into()),117
};118
if !meta.is_file() {119
return Err(crate::core::error::CoreError::plain("Application log is not a file."));120
}121
let size = meta.len();122
let length = size.min(max_bytes);123
let offset = size - length;124
let mut file = fs::File::open(path)?;125
file.seek(SeekFrom::Start(offset))?;126
let mut buffer = Vec::with_capacity(length as usize);127
file.take(length).read_to_end(&mut buffer)?;128
let mut text = String::from_utf8_lossy(&buffer).to_string();129
if offset > 0 {130
text = match text.find('\n') {131
Some(index) => text[index + 1..].to_string(),132
None => String::new(),133
};134
}135
Ok(LogTail { text, truncated: offset > 0, size, read_at: now })136
}138
#[cfg(test)]139
mod tests {140
use super::*;142
#[test]143
fn redacts_sensitive_fields() {144
let value = sanitize(&json!({"password": "x", "nested": {"privateKey": "k", "ok": 1}, "sudoPassword": "y"}), 0);145
assert_eq!(value["password"], "[redacted]");146
assert_eq!(value["sudoPassword"], "[redacted]");147
assert_eq!(value["nested"]["privateKey"], "[redacted]");148
assert_eq!(value["nested"]["ok"], 1);149
}151
#[test]152
fn tail_drops_partial_first_line() {153
let dir = tempfile::tempdir().unwrap();154
let logger = Logger::new(dir.path());155
for i in 0..100 {156
logger.info(&format!("line {i}"), json!({}));157
}158
let tail = read_tail(logger.path(), 300).unwrap();159
assert!(tail.truncated);160
assert!(tail.text.starts_with('{'));161
}162
}SHA-256
a845698d6600eae886f9fb7a7d90794430309988c72600e8fe17d7cc744466d6CH-J Proprietary Software License 1.14 · Source is provided under the CH-J Proprietary Software License 1.14. Its availability does not change the license terms or grant additional rights.
Archive SHA-256
ac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f