Skip to main content

appcore_log/
sink.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: sink.rs
4//    ##   ## ##   ##    P: AppCore-Runtime
5//         ## ##
6//                       C: unknown by dnettoRaw
7//    ##   ## ##   ##    U: working-tree by dnettoRaw
8//      ###########      S: 1.0.2-rc
9// =============================================================================
10
11//! Concrete bounded sinks. Sink failures return to the dispatcher without recursion.
12
13use crate::LogEvent;
14use appcore_contracts::ApplicationId;
15use appcore_dnt::{
16    write_atomic, BytesCodec, ContentType, DntKeyProvider, DntOpenOptions, DntSealOptions, KeyId,
17};
18use parking_lot::Mutex;
19use std::collections::VecDeque;
20use std::fs::{self, OpenOptions};
21use std::io::Write;
22use std::path::PathBuf;
23
24/// Logging sink error with no sensitive source text.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum LogError {
27    /// Output failed or was unavailable.
28    Io,
29    /// A bounded sink rejected the event.
30    Capacity,
31    /// DNT sealing or validation failed.
32    Encryption,
33    /// Event serialization failed.
34    Serialization,
35}
36
37/// A destination receiving a sanitized event from [`crate::LogDispatcher`].
38pub trait LogSink: Send + Sync {
39    /// Writes one event or reports a controlled failure.
40    fn emit(&self, event: &LogEvent) -> Result<(), LogError>;
41
42    /// Reports whether this sink is an explicitly encrypted sensitive boundary.
43    ///
44    /// Ordinary sinks deliberately retain the default `false`, so a sensitive
45    /// policy can never disclose an event to stdout, JSONL or memory by mistake.
46    fn accepts_sensitive(&self) -> bool {
47        false
48    }
49
50    /// Stable bounded metric label for this sink implementation.
51    fn name(&self) -> &'static str {
52        "custom"
53    }
54}
55
56/// Human-oriented stdout sink, with no terminal-color assumptions.
57#[derive(Debug, Default)]
58pub struct ConsoleSink;
59
60impl ConsoleSink {
61    /// Creates a plain console sink.
62    pub fn new() -> Self {
63        Self
64    }
65}
66
67impl LogSink for ConsoleSink {
68    fn emit(&self, event: &LogEvent) -> Result<(), LogError> {
69        writeln!(
70            std::io::stdout().lock(),
71            "[{:?}][{}][V{}] {}",
72            event.severity,
73            event.component,
74            event.verbosity.value(),
75            event.message
76        )
77        .map_err(|_| LogError::Io)
78    }
79
80    fn name(&self) -> &'static str {
81        "console"
82    }
83}
84
85/// Bounded structured JSONL file configuration.
86#[derive(Debug, Clone)]
87pub struct FileSinkConfig {
88    /// Target JSONL file.
89    pub path: PathBuf,
90    /// Maximum file bytes before rotation.
91    pub max_bytes: u64,
92    /// Flush each event to storage; disable to favor throughput and OS buffering.
93    pub sync_each_write: bool,
94    /// Number of old files retained.
95    pub retention: u8,
96    /// Optional bounded year/month archive for files leaving active retention.
97    pub archive: Option<FileArchiveConfig>,
98}
99
100/// Bounded archive configuration for rotated JSONL files.
101#[derive(Debug, Clone)]
102pub struct FileArchiveConfig {
103    /// Archive root containing `YYYY/MM` subdirectories.
104    pub directory: PathBuf,
105    /// Maximum archived files across the complete archive root.
106    pub max_files: usize,
107}
108
109/// Synchronous bounded JSONL sink; callers choose it only outside hot paths.
110pub struct FileSink {
111    config: FileSinkConfig,
112    lock: Mutex<()>,
113}
114
115impl FileSink {
116    /// Creates a sink after validating nonzero size and bounded retention.
117    pub fn new(config: FileSinkConfig) -> Result<Self, LogError> {
118        if config.max_bytes == 0
119            || config.retention > 32
120            || config
121                .archive
122                .as_ref()
123                .is_some_and(|archive| archive.max_files == 0 || archive.max_files > 10_000)
124        {
125            return Err(LogError::Capacity);
126        }
127        Ok(Self {
128            config,
129            lock: Mutex::new(()),
130        })
131    }
132
133    fn rotate(&self, incoming_bytes: usize, timestamp_ms: u64) -> Result<(), LogError> {
134        let path = &self.config.path;
135        ensure_regular_or_missing(path)?;
136        let incoming_bytes = u64::try_from(incoming_bytes).map_err(|_| LogError::Capacity)?;
137        let requires_rotation = fs::metadata(path)
138            .map(|metadata| metadata.len().saturating_add(incoming_bytes) > self.config.max_bytes)
139            .unwrap_or(false);
140        if !requires_rotation {
141            return Ok(());
142        }
143        if self.config.retention == 0 {
144            if let Some(archive) = &self.config.archive {
145                archive_file(path, path, timestamp_ms, archive)?;
146            } else {
147                fs::remove_file(path).map_err(|_| LogError::Io)?;
148            }
149            return Ok(());
150        }
151        let oldest = path.with_extension(format!("jsonl.{}", self.config.retention));
152        ensure_regular_or_missing(&oldest)?;
153        if oldest.exists() {
154            if let Some(archive) = &self.config.archive {
155                archive_file(&oldest, path, timestamp_ms, archive)?;
156            } else {
157                fs::remove_file(oldest).map_err(|_| LogError::Io)?;
158            }
159        }
160        for index in (1..self.config.retention).rev() {
161            let from = path.with_extension(format!("jsonl.{index}"));
162            let to = path.with_extension(format!("jsonl.{}", index + 1));
163            ensure_regular_or_missing(&from)?;
164            ensure_regular_or_missing(&to)?;
165            if from.exists() {
166                fs::rename(from, to).map_err(|_| LogError::Io)?;
167            }
168        }
169        if path.exists() {
170            fs::rename(path, path.with_extension("jsonl.1")).map_err(|_| LogError::Io)?;
171        }
172        Ok(())
173    }
174}
175
176impl LogSink for FileSink {
177    fn emit(&self, event: &LogEvent) -> Result<(), LogError> {
178        let _guard = self.lock.lock();
179        let line = serde_json::to_vec(event).map_err(|_| LogError::Serialization)?;
180        let line_bytes = line.len().saturating_add(1);
181        if u64::try_from(line_bytes).map_err(|_| LogError::Capacity)? > self.config.max_bytes {
182            return Err(LogError::Capacity);
183        }
184        self.rotate(line_bytes, event.timestamp_ms)?;
185        ensure_regular_or_missing(&self.config.path)?;
186        let mut file = OpenOptions::new()
187            .create(true)
188            .append(true)
189            .open(&self.config.path)
190            .map_err(|_| LogError::Io)?;
191        file.write_all(&line)
192            .and_then(|_| file.write_all(b"\n"))
193            .map_err(|_| LogError::Io)?;
194        if self.config.sync_each_write {
195            file.sync_data().map_err(|_| LogError::Io)?;
196        }
197        Ok(())
198    }
199
200    fn name(&self) -> &'static str {
201        "file"
202    }
203}
204
205fn archive_file(
206    source: &std::path::Path,
207    active_path: &std::path::Path,
208    timestamp_ms: u64,
209    config: &FileArchiveConfig,
210) -> Result<(), LogError> {
211    let (year, month) = year_month(timestamp_ms);
212    let year_dir = config.directory.join(format!("{year:04}"));
213    let destination_dir = year_dir.join(format!("{month:02}"));
214    ensure_directory_or_missing(&config.directory)?;
215    ensure_directory_or_missing(&year_dir)?;
216    ensure_directory_or_missing(&destination_dir)?;
217    fs::create_dir_all(&destination_dir).map_err(|_| LogError::Io)?;
218    ensure_directory(&config.directory)?;
219    ensure_directory(&year_dir)?;
220    ensure_directory(&destination_dir)?;
221    prune_archive(&config.directory, config.max_files.saturating_sub(1))?;
222
223    let stem = active_path
224        .file_stem()
225        .and_then(|value| value.to_str())
226        .unwrap_or("log");
227    let extension = active_path
228        .extension()
229        .and_then(|value| value.to_str())
230        .unwrap_or("jsonl");
231    for sequence in 0_u16..=u16::MAX {
232        let destination = destination_dir.join(format!(
233            "{stem}-{timestamp_ms:020}-{sequence:04}.{extension}"
234        ));
235        ensure_regular_or_missing(&destination)?;
236        if !destination.exists() {
237            return fs::rename(source, destination).map_err(|_| LogError::Io);
238        }
239    }
240    Err(LogError::Capacity)
241}
242
243fn prune_archive(root: &std::path::Path, keep: usize) -> Result<(), LogError> {
244    let mut files = Vec::new();
245    if root.exists() {
246        ensure_directory(root)?;
247        for year in fs::read_dir(root).map_err(|_| LogError::Io)? {
248            let year = year.map_err(|_| LogError::Io)?.path();
249            let year_metadata = fs::symlink_metadata(&year).map_err(|_| LogError::Io)?;
250            if !year_metadata.file_type().is_dir() {
251                continue;
252            }
253            for month in fs::read_dir(year).map_err(|_| LogError::Io)? {
254                let month = month.map_err(|_| LogError::Io)?.path();
255                let month_metadata = fs::symlink_metadata(&month).map_err(|_| LogError::Io)?;
256                if !month_metadata.file_type().is_dir() {
257                    continue;
258                }
259                for file in fs::read_dir(month).map_err(|_| LogError::Io)? {
260                    let file = file.map_err(|_| LogError::Io)?.path();
261                    let metadata = fs::symlink_metadata(&file).map_err(|_| LogError::Io)?;
262                    if metadata.file_type().is_file() {
263                        files.push(file);
264                    }
265                }
266            }
267        }
268    }
269    files.sort();
270    let remove = files.len().saturating_sub(keep);
271    for file in files.into_iter().take(remove) {
272        fs::remove_file(file).map_err(|_| LogError::Io)?;
273    }
274    Ok(())
275}
276
277fn year_month(timestamp_ms: u64) -> (i64, i64) {
278    let days = i64::try_from(timestamp_ms / 86_400_000).unwrap_or(i64::MAX);
279    let shifted = days.saturating_add(719_468);
280    let era = shifted.div_euclid(146_097);
281    let day_of_era = shifted - era * 146_097;
282    let year_of_era =
283        (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
284    let mut year = year_of_era + era * 400;
285    let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
286    let month_prime = (5 * day_of_year + 2) / 153;
287    let month = month_prime + if month_prime < 10 { 3 } else { -9 };
288    if month <= 2 {
289        year += 1;
290    }
291    (year, month)
292}
293
294/// In-memory bounded diagnostic sink.
295pub struct RingBufferSink {
296    state: Mutex<(VecDeque<(LogEvent, usize)>, usize)>,
297    max_events: usize,
298    max_bytes: usize,
299}
300
301impl RingBufferSink {
302    /// Creates a ring bounded by events and estimated event bytes.
303    pub fn new(max_events: usize, max_bytes: usize) -> Result<Self, LogError> {
304        if max_events == 0 || max_bytes == 0 {
305            return Err(LogError::Capacity);
306        }
307        Ok(Self {
308            state: Mutex::new((VecDeque::new(), 0)),
309            max_events,
310            max_bytes,
311        })
312    }
313
314    /// Returns a sanitized diagnostic snapshot.
315    pub fn snapshot(&self) -> Vec<LogEvent> {
316        self.state
317            .lock()
318            .0
319            .iter()
320            .map(|(event, _)| event.clone())
321            .collect()
322    }
323}
324
325impl LogSink for RingBufferSink {
326    fn emit(&self, event: &LogEvent) -> Result<(), LogError> {
327        let size = event.retained_bytes();
328        if size > self.max_bytes {
329            return Err(LogError::Capacity);
330        }
331        let mut state = self.state.lock();
332        while state.0.len() >= self.max_events || state.1.saturating_add(size) > self.max_bytes {
333            let Some((_, old_size)) = state.0.pop_front() else {
334                break;
335            };
336            state.1 = state.1.saturating_sub(old_size);
337        }
338        state.1 = state.1.saturating_add(size);
339        state.0.push_back((event.clone(), size));
340        Ok(())
341    }
342
343    fn name(&self) -> &'static str {
344        "ring"
345    }
346}
347
348/// Explicit encrypted sensitive-output configuration.
349#[derive(Debug, Clone)]
350pub struct SensitiveDntSinkConfig {
351    /// DNT output file.
352    pub path: PathBuf,
353    /// Owning application identity.
354    pub application_id: ApplicationId,
355    /// Rotation-aware DNT key identity.
356    pub key_id: KeyId,
357    /// Maximum plaintext snapshot bytes.
358    pub max_bytes: u64,
359    /// Maximum events retained in the encrypted snapshot.
360    pub max_events: usize,
361    /// Number of prior encrypted snapshots retained beside the current file.
362    pub retention: u8,
363}
364
365/// Encrypted DNT sink. It has no plaintext fallback and must be explicitly wired.
366pub struct SensitiveDntSink<P: DntKeyProvider> {
367    config: SensitiveDntSinkConfig,
368    key_provider: P,
369    state: Mutex<(VecDeque<(LogEvent, usize)>, usize)>,
370}
371
372impl<P: DntKeyProvider> SensitiveDntSink<P> {
373    /// Creates the sink; enabling it is an explicit sensitive-logging decision.
374    pub fn new(config: SensitiveDntSinkConfig, key_provider: P) -> Result<Self, LogError> {
375        if config.max_bytes == 0 || config.max_events == 0 || config.retention > 32 {
376            return Err(LogError::Capacity);
377        }
378        Ok(Self {
379            config,
380            key_provider,
381            state: Mutex::new((VecDeque::new(), 0)),
382        })
383    }
384}
385
386impl<P: DntKeyProvider> LogSink for SensitiveDntSink<P> {
387    fn emit(&self, event: &LogEvent) -> Result<(), LogError> {
388        let event_size = event.retained_bytes();
389        if u64::try_from(event_size).map_err(|_| LogError::Capacity)? > self.config.max_bytes {
390            return Err(LogError::Capacity);
391        }
392        let mut state = self.state.lock();
393        let max_bytes = usize::try_from(self.config.max_bytes).map_err(|_| LogError::Capacity)?;
394        while state.0.len() >= self.config.max_events
395            || state.1.saturating_add(event_size) > max_bytes
396        {
397            let Some((_, old_size)) = state.0.pop_front() else {
398                break;
399            };
400            state.1 = state.1.saturating_sub(old_size);
401        }
402        state.1 = state.1.saturating_add(event_size);
403        state.0.push_back((event.clone(), event_size));
404        let events = state.0.iter().map(|(stored, _)| stored).collect::<Vec<_>>();
405        let payload = serde_json::to_vec(&events).map_err(|_| LogError::Serialization)?;
406        if u64::try_from(payload.len()).map_err(|_| LogError::Capacity)? > self.config.max_bytes {
407            let _ = state.0.pop_back();
408            state.1 = state.1.saturating_sub(event_size);
409            return Err(LogError::Capacity);
410        }
411        let content =
412            ContentType::new("appcore.log.sensitive").map_err(|_| LogError::Encryption)?;
413        let seal = DntSealOptions {
414            application_id: self.config.application_id.clone(),
415            tenant_id: None,
416            content_type: content.clone(),
417            schema_version: 1,
418            key_id: self.config.key_id.clone(),
419            created_at_ms: event.timestamp_ms,
420            public_metadata:
421                b"sensitivity=sensitive;contains_secrets=true;encrypted=true;format=dnt".to_vec(),
422            encrypted_metadata: Vec::new(),
423            flags: 0,
424            max_payload_bytes: Some(self.config.max_bytes),
425        };
426        let open = DntOpenOptions {
427            application_id: self.config.application_id.clone(),
428            tenant_id: None,
429            content_type: content,
430            max_payload_bytes: Some(self.config.max_bytes),
431        };
432        rotate_sensitive_snapshots(&self.config.path, self.config.retention)?;
433        write_atomic(
434            &self.config.path,
435            &payload,
436            &self.key_provider,
437            &BytesCodec,
438            seal,
439            &open,
440        )
441        .map_err(|_| LogError::Encryption)
442    }
443
444    fn accepts_sensitive(&self) -> bool {
445        true
446    }
447
448    fn name(&self) -> &'static str {
449        "sensitive_dnt"
450    }
451}
452
453fn rotate_sensitive_snapshots(path: &std::path::Path, retention: u8) -> Result<(), LogError> {
454    ensure_regular_or_missing(path)?;
455    if !path.exists() {
456        return Ok(());
457    }
458    if retention == 0 {
459        fs::remove_file(path).map_err(|_| LogError::Io)?;
460        return Ok(());
461    }
462    let oldest = path.with_extension(format!("dnt.{retention}"));
463    ensure_regular_or_missing(&oldest)?;
464    if oldest.exists() {
465        fs::remove_file(&oldest).map_err(|_| LogError::Io)?;
466    }
467    for index in (1..retention).rev() {
468        let from = path.with_extension(format!("dnt.{index}"));
469        let to = path.with_extension(format!("dnt.{}", index + 1));
470        ensure_regular_or_missing(&from)?;
471        ensure_regular_or_missing(&to)?;
472        if from.exists() {
473            fs::rename(from, to).map_err(|_| LogError::Io)?;
474        }
475    }
476    fs::rename(path, path.with_extension("dnt.1")).map_err(|_| LogError::Io)
477}
478
479fn ensure_regular_or_missing(path: &std::path::Path) -> Result<(), LogError> {
480    match fs::symlink_metadata(path) {
481        Ok(metadata) if metadata.file_type().is_symlink() || !metadata.file_type().is_file() => {
482            Err(LogError::Io)
483        }
484        Ok(_) => Ok(()),
485        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
486        Err(_) => Err(LogError::Io),
487    }
488}
489
490fn ensure_directory_or_missing(path: &std::path::Path) -> Result<(), LogError> {
491    match fs::symlink_metadata(path) {
492        Ok(metadata) if metadata.file_type().is_dir() => Ok(()),
493        Ok(_) => Err(LogError::Io),
494        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
495        Err(_) => Err(LogError::Io),
496    }
497}
498
499fn ensure_directory(path: &std::path::Path) -> Result<(), LogError> {
500    let metadata = fs::symlink_metadata(path).map_err(|_| LogError::Io)?;
501    if metadata.file_type().is_dir() {
502        Ok(())
503    } else {
504        Err(LogError::Io)
505    }
506}