⎇ 883c14dadec4b7999d6990ee40338553eccfd397
crates/ente-journald-compat/src/journalctl.rs
History
Who touched what
raw
ente-policy-provider, ente-tmpfiles-compat, journald export format
1//! ente-journalctl: query CLI sobre el journal persistido en CAS.
2//!
3//! Lee el index `~/.local/share/ente/journal/index.log` (líneas
4//! `timestamp_ms:source:unit:sha_hex`), filtra, y para cada match
5//! restituye el blob desde CAS y lo imprime.
6//!
7//! Uso:
8//! ente-journalctl # todo el journal
9//! ente-journalctl --unit foo.service # filtra por unit
10//! ente-journalctl --since 60 # últimos 60 segundos
11//! ente-journalctl --grep "panic" # contiene "panic"
12//! ente-journalctl --tail 20 # últimas 20 entries
13//! ente-journalctl --json # output JSON-lines
14
15use std::path::PathBuf;
16
17struct Args {
18 unit: Option<String>,
19 since_secs: Option<u64>,
20 grep: Option<String>,
21 tail: Option<usize>,
22 source: Option<String>,
23 output: OutputFormat,
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27enum OutputFormat {
28 Pretty,
29 Json,
30 /// systemd journal export format: `KEY=value\n` por field, blank line
31 /// entre entries. Documented at https://systemd.io/JOURNAL_EXPORT_FORMATS/
32 /// Compatible con `journalctl --input-format=export`.
33 Export,
34}
35
36fn parse_args() -> Args {
37 let mut args = std::env::args().skip(1);
38 let mut a = Args {
39 unit: None, since_secs: None, grep: None, tail: None,
40 source: None, output: OutputFormat::Pretty,
41 };
42 while let Some(arg) = args.next() {
43 match arg.as_str() {
44 "--unit" | "-u" => a.unit = args.next(),
45 "--since" | "-S" => a.since_secs = args.next().and_then(|s| s.parse().ok()),
46 "--grep" | "-g" => a.grep = args.next(),
47 "--tail" | "-n" => a.tail = args.next().and_then(|s| s.parse().ok()),
48 "--source" => a.source = args.next(),
49 "--json" => a.output = OutputFormat::Json,
50 "--output" | "-o" => {
51 a.output = match args.next().as_deref() {
52 Some("pretty") | None => OutputFormat::Pretty,
53 Some("json") | Some("json-lines") => OutputFormat::Json,
54 Some("export") => OutputFormat::Export,
55 Some(other) => {
56 eprintln!("output desconocido: {other}");
57 eprintln!("válidos: pretty | json | export");
58 std::process::exit(2);
59 }
60 };
61 }
62 "-h" | "--help" => { print_help(); std::process::exit(0); }
63 other => {
64 eprintln!("argumento desconocido: {other}");
65 print_help();
66 std::process::exit(2);
67 }
68 }
69 }
70 a
71}
72
73fn print_help() {
74 eprintln!("ente-journalctl — query CLI del journal persistido en CAS");
75 eprintln!();
76 eprintln!("Filtros:");
77 eprintln!(" --unit, -u <name> Filtra por unidad (e.g. foo.service)");
78 eprintln!(" --source <s> journal | syslog");
79 eprintln!(" --since, -S <secs> Sólo últimos N segundos");
80 eprintln!(" --grep, -g <text> Contiene <text> en el body decoded");
81 eprintln!(" --tail, -n <N> Últimas N entries");
82 eprintln!("Output:");
83 eprintln!(" --output, -o <fmt> pretty | json | export (systemd journal export)");
84 eprintln!(" --json alias de --output json");
85}
86
87fn index_path() -> PathBuf {
88 let base = if let Ok(d) = std::env::var("XDG_DATA_HOME") { d }
89 else if let Ok(h) = std::env::var("HOME") { format!("{h}/.local/share") }
90 else { "/var/lib".into() };
91 PathBuf::from(base).join("ente").join("journal").join("index.log")
92}
93
94fn now_ms() -> u128 {
95 std::time::SystemTime::now()
96 .duration_since(std::time::UNIX_EPOCH)
97 .map(|d| d.as_millis())
98 .unwrap_or(0)
99}
100
101#[derive(Debug)]
102struct IndexEntry {
103 timestamp_ms: u128,
104 source: String,
105 unit: String,
106 sha_hex: String,
107}
108
109fn parse_line(line: &str) -> Option<IndexEntry> {
110 let mut parts = line.splitn(4, ':');
111 let ts: u128 = parts.next()?.parse().ok()?;
112 let source = parts.next()?.to_string();
113 let unit = parts.next()?.to_string();
114 let sha = parts.next()?.to_string();
115 if sha.len() != 64 { return None; }
116 Some(IndexEntry { timestamp_ms: ts, source, unit, sha_hex: sha })
117}
118
119fn parse_sha(hex: &str) -> Option<[u8; 32]> {
120 if hex.len() != 64 { return None; }
121 let mut sha = [0u8; 32];
122 for i in 0..32 {
123 sha[i] = u8::from_str_radix(&hex[i*2..i*2+2], 16).ok()?;
124 }
125 Some(sha)
126}
127
128fn main() -> anyhow::Result<()> {
129 let args = parse_args();
130 let path = index_path();
131 if !path.exists() {
132 eprintln!("index no existe: {} — ¿journald-compat ha corrido?", path.display());
133 std::process::exit(1);
134 }
135 let raw = std::fs::read_to_string(&path)?;
136 let mut entries: Vec<IndexEntry> = raw.lines()
137 .filter_map(parse_line)
138 .collect();
139
140 // Filtros
141 let now = now_ms();
142 if let Some(secs) = args.since_secs {
143 let cutoff = now.saturating_sub(secs as u128 * 1000);
144 entries.retain(|e| e.timestamp_ms >= cutoff);
145 }
146 if let Some(unit) = &args.unit {
147 entries.retain(|e| &e.unit == unit);
148 }
149 if let Some(src) = &args.source {
150 entries.retain(|e| &e.source == src);
151 }
152 // tail después de filtros temporales/identidad pero antes de grep —
153 // grep es post porque requiere cargar bytes del CAS.
154
155 let mut out: Vec<(IndexEntry, String)> = entries.into_iter()
156 .filter_map(|e| {
157 let sha = parse_sha(&e.sha_hex)?;
158 let bytes = ente_cas::resolve(&sha).ok()?;
159 let body = String::from_utf8_lossy(&bytes).into_owned();
160 Some((e, body))
161 })
162 .collect();
163
164 if let Some(g) = &args.grep {
165 out.retain(|(_, body)| body.contains(g.as_str()));
166 }
167 if let Some(n) = args.tail {
168 let len = out.len();
169 if len > n { out.drain(..len - n); }
170 }
171
172 for (e, body) in out {
173 match args.output {
174 OutputFormat::Pretty => print_pretty(&e, &body),
175 OutputFormat::Json => print_json(&e, &body),
176 OutputFormat::Export => print_export(&e, &body),
177 }
178 }
179 Ok(())
180}
181
182fn print_pretty(e: &IndexEntry, body: &str) {
183 let secs = e.timestamp_ms / 1000;
184 let ms = e.timestamp_ms % 1000;
185 let header = if e.unit == "-" {
186 format!("{}.{:03} [{}]", secs, ms, e.source)
187 } else {
188 format!("{}.{:03} [{}] {{{}}}", secs, ms, e.source, e.unit)
189 };
190 println!("{header}");
191 // Si es journald native (KEY=value lines), extraer MESSAGE.
192 if body.contains('=') && body.lines().any(|l| l.contains('=')) {
193 for line in body.lines() {
194 if let Some((k, v)) = line.split_once('=') {
195 if k.trim() == "MESSAGE" {
196 println!(" {v}");
197 return;
198 }
199 }
200 }
201 }
202 for line in body.trim_end().lines() {
203 println!(" {line}");
204 }
205}
206
207/// systemd journal export format. Cada entry es un bloque de líneas
208/// `KEY=value\n` separado por blank line. Para values con newlines o
209/// bytes binarios, el formato usa una variante con length-prefix
210/// (8 bytes LE u64) — por simplicidad sólo emitimos values con texto
211/// que no contienen newlines o caracteres no-printables. Extraemos
212/// MESSAGE/PRIORITY/_SYSTEMD_UNIT del body si es journald native.
213///
214/// Compatible con `journalctl --input-format=export -m`.
215fn print_export(e: &IndexEntry, body: &str) {
216 // Timestamps: __REALTIME_TIMESTAMP en µs, __MONOTONIC_TIMESTAMP también.
217 let realtime_us = e.timestamp_ms.saturating_mul(1000);
218 println!("__CURSOR=s={};t={};x={}",
219 &e.sha_hex[..16], // pseudo-cursor: prefix del SHA
220 realtime_us,
221 &e.sha_hex[..8]);
222 println!("__REALTIME_TIMESTAMP={}", realtime_us);
223 println!("__MONOTONIC_TIMESTAMP={}", realtime_us);
224
225 let host = gethostname_safe();
226 if !host.is_empty() {
227 println!("_HOSTNAME={host}");
228 }
229
230 if e.unit != "-" {
231 println!("_SYSTEMD_UNIT={}", e.unit);
232 }
233 println!("_TRANSPORT={}", match e.source.as_str() {
234 "syslog" => "syslog",
235 "journal" => "journal",
236 _ => "stdout",
237 });
238
239 // Si el body es journald native (KEY=value lines), emitir cada uno
240 // verbatim — son los fields originales del producer. Filtrar líneas
241 // que no son seguras para export (con newlines en value, etc).
242 if body.contains('=') && body.lines().any(|l| l.contains('=')) {
243 for line in body.lines() {
244 if line.contains('=') && line.bytes().all(safe_export_byte) {
245 println!("{line}");
246 }
247 }
248 } else {
249 // Syslog text — empaquetar como MESSAGE.
250 let msg = body.trim_end()
251 .replace('\n', " "); // collapsa newlines
252 if msg.bytes().all(safe_export_byte) {
253 println!("MESSAGE={msg}");
254 }
255 }
256 // Blank line separa entries.
257 println!();
258}
259
260fn safe_export_byte(b: u8) -> bool {
261 // ASCII printable, espacio, tab. No newlines (manejados aparte).
262 (0x20..=0x7E).contains(&b) || b == b'\t'
263}
264
265fn gethostname_safe() -> String {
266 let mut buf = [0u8; 256];
267 let r = unsafe {
268 libc::gethostname(buf.as_mut_ptr() as *mut _, buf.len())
269 };
270 if r != 0 { return String::new(); }
271 let len = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
272 std::str::from_utf8(&buf[..len]).unwrap_or("").to_string()
273}
274
275fn print_json(e: &IndexEntry, body: &str) {
276 // JSON-lines básico, sin dependencia de serde — formato simple y estable.
277 let escaped_body = body
278 .replace('\\', "\\\\")
279 .replace('"', "\\\"")
280 .replace('\n', "\\n")
281 .replace('\r', "\\r")
282 .replace('\t', "\\t");
283 let unit_field = if e.unit == "-" { "null".to_string() }
284 else { format!("\"{}\"", e.unit) };
285 println!(
286 r#"{{"timestamp_ms":{},"source":"{}","unit":{},"sha":"{}","body":"{}"}}"#,
287 e.timestamp_ms, e.source, unit_field, e.sha_hex, escaped_body
288 );
289}