tawasuyu

tawasuyu/brahman

Rust 572TOML 217Markdown 68JSON 198326 Definiciones

⎇ 53dbdf0f1deea10d6fc567ea2b43e1e75d6d60cd crates/core/ente-journald-compat/src/journalctl.rs Rust · 289 líneas · 9.9 KB Historia Quién tocó qué crudo

chore: monorepo inicial con arje + minga + yahweh absorbidos 53dbdf0f1 · Sergio ·

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}