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, File, 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    state: Mutex<FileSinkState>,
113}
114
115struct FileSinkState {
116    file: Option<File>,
117    bytes: u64,
118}
119
120impl FileSink {
121    /// Creates a sink after validating nonzero size and bounded retention.
122    pub fn new(config: FileSinkConfig) -> Result<Self, LogError> {
123        if config.max_bytes == 0
124            || config.retention > 32
125            || config
126                .archive
127                .as_ref()
128                .is_some_and(|archive| archive.max_files == 0 || archive.max_files > 10_000)
129        {
130            return Err(LogError::Capacity);
131        }
132        Ok(Self {
133            config,
134            state: Mutex::new(FileSinkState {
135                file: None,
136                bytes: 0,
137            }),
138        })
139    }
140
141    fn rotate(&self, incoming_bytes: usize, timestamp_ms: u64) -> Result<(), LogError> {
142        let path = &self.config.path;
143        ensure_regular_or_missing(path)?;
144        let incoming_bytes = u64::try_from(incoming_bytes).map_err(|_| LogError::Capacity)?;
145        let requires_rotation = fs::metadata(path)
146            .map(|metadata| metadata.len().saturating_add(incoming_bytes) > self.config.max_bytes)
147            .unwrap_or(false);
148        if !requires_rotation {
149            return Ok(());
150        }
151        if self.config.retention == 0 {
152            if let Some(archive) = &self.config.archive {
153                archive_file(path, path, timestamp_ms, archive)?;
154            } else {
155                fs::remove_file(path).map_err(|_| LogError::Io)?;
156            }
157            return Ok(());
158        }
159        let oldest = path.with_extension(format!("jsonl.{}", self.config.retention));
160        ensure_regular_or_missing(&oldest)?;
161        if oldest.exists() {
162            if let Some(archive) = &self.config.archive {
163                archive_file(&oldest, path, timestamp_ms, archive)?;
164            } else {
165                fs::remove_file(oldest).map_err(|_| LogError::Io)?;
166            }
167        }
168        for index in (1..self.config.retention).rev() {
169            let from = path.with_extension(format!("jsonl.{index}"));
170            let to = path.with_extension(format!("jsonl.{}", index + 1));
171            ensure_regular_or_missing(&from)?;
172            ensure_regular_or_missing(&to)?;
173            if from.exists() {
174                fs::rename(from, to).map_err(|_| LogError::Io)?;
175            }
176        }
177        if path.exists() {
178            fs::rename(path, path.with_extension("jsonl.1")).map_err(|_| LogError::Io)?;
179        }
180        Ok(())
181    }
182}
183
184impl LogSink for FileSink {
185    fn emit(&self, event: &LogEvent) -> Result<(), LogError> {
186        let mut state = self.state.lock();
187        let line = crate::json_line::encode(event, self.config.max_bytes)?;
188        ensure_regular_or_missing(&self.config.path)?;
189        reconcile_file_state(&self.config.path, &mut state)?;
190        let incoming = u64::try_from(line.len()).map_err(|_| LogError::Capacity)?;
191        let mut next_bytes = state
192            .bytes
193            .checked_add(incoming)
194            .ok_or(LogError::Capacity)?;
195        if next_bytes > self.config.max_bytes {
196            state.file.take();
197            self.rotate(line.len(), event.timestamp_ms)?;
198            state.bytes = 0;
199            next_bytes = incoming;
200        }
201        if state.file.is_none() {
202            state.file = Some(open_append_regular(&self.config.path)?);
203            state.bytes = state
204                .file
205                .as_ref()
206                .ok_or(LogError::Io)?
207                .metadata()
208                .map_err(|_| LogError::Io)?
209                .len();
210        }
211        let Some(file) = state.file.as_mut() else {
212            return Err(LogError::Io);
213        };
214        if file.write_all(&line).is_err() {
215            state.file = None;
216            return Err(LogError::Io);
217        }
218        state.bytes = next_bytes;
219        if self.config.sync_each_write
220            && state
221                .file
222                .as_ref()
223                .ok_or(LogError::Io)?
224                .sync_data()
225                .is_err()
226        {
227            state.file = None;
228            return Err(LogError::Io);
229        }
230        Ok(())
231    }
232
233    fn name(&self) -> &'static str {
234        "file"
235    }
236}
237
238fn reconcile_file_state(path: &std::path::Path, state: &mut FileSinkState) -> Result<(), LogError> {
239    let Some(file) = state.file.as_ref() else {
240        state.bytes = fs::metadata(path).map_or(0, |metadata| metadata.len());
241        return Ok(());
242    };
243    let Ok(path_metadata) = fs::metadata(path) else {
244        state.file = None;
245        state.bytes = 0;
246        return Ok(());
247    };
248    let file_metadata = file.metadata().map_err(|_| LogError::Io)?;
249    if !same_file(&file_metadata, &path_metadata) || path_metadata.len() != state.bytes {
250        state.file = None;
251        state.bytes = path_metadata.len();
252    }
253    Ok(())
254}
255
256#[cfg(unix)]
257fn same_file(left: &fs::Metadata, right: &fs::Metadata) -> bool {
258    use std::os::unix::fs::MetadataExt;
259    left.dev() == right.dev() && left.ino() == right.ino()
260}
261
262#[cfg(not(unix))]
263fn same_file(_left: &fs::Metadata, _right: &fs::Metadata) -> bool {
264    true
265}
266
267fn open_append_regular(path: &std::path::Path) -> Result<File, LogError> {
268    let mut options = OpenOptions::new();
269    options.create(true).append(true);
270    let file = options.open(path).map_err(|_| LogError::Io)?;
271    let path_metadata = fs::symlink_metadata(path).map_err(|_| LogError::Io)?;
272    let file_metadata = file.metadata().map_err(|_| LogError::Io)?;
273    if path_metadata.file_type().is_symlink()
274        || !file_metadata.is_file()
275        || !same_file(&file_metadata, &path_metadata)
276    {
277        return Err(LogError::Io);
278    }
279    Ok(file)
280}
281
282fn archive_file(
283    source: &std::path::Path,
284    active_path: &std::path::Path,
285    timestamp_ms: u64,
286    config: &FileArchiveConfig,
287) -> Result<(), LogError> {
288    let (year, month) = year_month(timestamp_ms);
289    let year_dir = config.directory.join(format!("{year:04}"));
290    let destination_dir = year_dir.join(format!("{month:02}"));
291    ensure_directory_or_missing(&config.directory)?;
292    ensure_directory_or_missing(&year_dir)?;
293    ensure_directory_or_missing(&destination_dir)?;
294    fs::create_dir_all(&destination_dir).map_err(|_| LogError::Io)?;
295    ensure_directory(&config.directory)?;
296    ensure_directory(&year_dir)?;
297    ensure_directory(&destination_dir)?;
298    prune_archive(&config.directory, config.max_files.saturating_sub(1))?;
299
300    let stem = active_path
301        .file_stem()
302        .and_then(|value| value.to_str())
303        .unwrap_or("log");
304    let extension = active_path
305        .extension()
306        .and_then(|value| value.to_str())
307        .unwrap_or("jsonl");
308    for sequence in 0_u16..=u16::MAX {
309        let destination = destination_dir.join(format!(
310            "{stem}-{timestamp_ms:020}-{sequence:04}.{extension}"
311        ));
312        ensure_regular_or_missing(&destination)?;
313        if !destination.exists() {
314            return fs::rename(source, destination).map_err(|_| LogError::Io);
315        }
316    }
317    Err(LogError::Capacity)
318}
319
320fn prune_archive(root: &std::path::Path, keep: usize) -> Result<(), LogError> {
321    let mut files = Vec::new();
322    if root.exists() {
323        ensure_directory(root)?;
324        for year in fs::read_dir(root).map_err(|_| LogError::Io)? {
325            let year = year.map_err(|_| LogError::Io)?.path();
326            let year_metadata = fs::symlink_metadata(&year).map_err(|_| LogError::Io)?;
327            if !year_metadata.file_type().is_dir() {
328                continue;
329            }
330            for month in fs::read_dir(year).map_err(|_| LogError::Io)? {
331                let month = month.map_err(|_| LogError::Io)?.path();
332                let month_metadata = fs::symlink_metadata(&month).map_err(|_| LogError::Io)?;
333                if !month_metadata.file_type().is_dir() {
334                    continue;
335                }
336                for file in fs::read_dir(month).map_err(|_| LogError::Io)? {
337                    let file = file.map_err(|_| LogError::Io)?.path();
338                    let metadata = fs::symlink_metadata(&file).map_err(|_| LogError::Io)?;
339                    if metadata.file_type().is_file() {
340                        files.push(file);
341                    }
342                }
343            }
344        }
345    }
346    files.sort();
347    let remove = files.len().saturating_sub(keep);
348    for file in files.into_iter().take(remove) {
349        fs::remove_file(file).map_err(|_| LogError::Io)?;
350    }
351    Ok(())
352}
353
354fn year_month(timestamp_ms: u64) -> (i64, i64) {
355    let days = i64::try_from(timestamp_ms / 86_400_000).unwrap_or(i64::MAX);
356    let shifted = days.saturating_add(719_468);
357    let era = shifted.div_euclid(146_097);
358    let day_of_era = shifted - era * 146_097;
359    let year_of_era =
360        (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
361    let mut year = year_of_era + era * 400;
362    let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
363    let month_prime = (5 * day_of_year + 2) / 153;
364    let month = month_prime + if month_prime < 10 { 3 } else { -9 };
365    if month <= 2 {
366        year += 1;
367    }
368    (year, month)
369}
370
371/// In-memory bounded diagnostic sink.
372pub struct RingBufferSink {
373    state: Mutex<(VecDeque<(LogEvent, usize)>, usize)>,
374    max_events: usize,
375    max_bytes: usize,
376}
377
378impl RingBufferSink {
379    /// Creates a ring bounded by events and estimated event bytes.
380    pub fn new(max_events: usize, max_bytes: usize) -> Result<Self, LogError> {
381        if max_events == 0 || max_bytes == 0 {
382            return Err(LogError::Capacity);
383        }
384        Ok(Self {
385            state: Mutex::new((VecDeque::new(), 0)),
386            max_events,
387            max_bytes,
388        })
389    }
390
391    /// Returns a sanitized diagnostic snapshot.
392    pub fn snapshot(&self) -> Vec<LogEvent> {
393        self.state
394            .lock()
395            .0
396            .iter()
397            .map(|(event, _)| event.clone())
398            .collect()
399    }
400}
401
402impl LogSink for RingBufferSink {
403    fn emit(&self, event: &LogEvent) -> Result<(), LogError> {
404        let size = event.retained_bytes();
405        if size > self.max_bytes {
406            return Err(LogError::Capacity);
407        }
408        let mut state = self.state.lock();
409        while state.0.len() >= self.max_events || state.1.saturating_add(size) > self.max_bytes {
410            let Some((_, old_size)) = state.0.pop_front() else {
411                break;
412            };
413            state.1 = state.1.saturating_sub(old_size);
414        }
415        state.1 = state.1.saturating_add(size);
416        state.0.push_back((event.clone(), size));
417        Ok(())
418    }
419
420    fn name(&self) -> &'static str {
421        "ring"
422    }
423}
424
425/// Explicit encrypted sensitive-output configuration.
426#[derive(Debug, Clone)]
427pub struct SensitiveDntSinkConfig {
428    /// DNT output file.
429    pub path: PathBuf,
430    /// Owning application identity.
431    pub application_id: ApplicationId,
432    /// Rotation-aware DNT key identity.
433    pub key_id: KeyId,
434    /// Maximum plaintext snapshot bytes.
435    pub max_bytes: u64,
436    /// Maximum events retained in the encrypted snapshot.
437    pub max_events: usize,
438    /// Number of prior encrypted snapshots retained beside the current file.
439    pub retention: u8,
440}
441
442/// Encrypted DNT sink. It has no plaintext fallback and must be explicitly wired.
443pub struct SensitiveDntSink<P: DntKeyProvider> {
444    config: SensitiveDntSinkConfig,
445    key_provider: P,
446    state: Mutex<(VecDeque<(LogEvent, usize)>, usize)>,
447}
448
449impl<P: DntKeyProvider> SensitiveDntSink<P> {
450    /// Creates the sink; enabling it is an explicit sensitive-logging decision.
451    pub fn new(config: SensitiveDntSinkConfig, key_provider: P) -> Result<Self, LogError> {
452        if config.max_bytes == 0 || config.max_events == 0 || config.retention > 32 {
453            return Err(LogError::Capacity);
454        }
455        Ok(Self {
456            config,
457            key_provider,
458            state: Mutex::new((VecDeque::new(), 0)),
459        })
460    }
461}
462
463impl<P: DntKeyProvider> LogSink for SensitiveDntSink<P> {
464    fn emit(&self, event: &LogEvent) -> Result<(), LogError> {
465        let event_size = event.retained_bytes();
466        if u64::try_from(event_size).map_err(|_| LogError::Capacity)? > self.config.max_bytes {
467            return Err(LogError::Capacity);
468        }
469        let mut state = self.state.lock();
470        let max_bytes = usize::try_from(self.config.max_bytes).map_err(|_| LogError::Capacity)?;
471        while state.0.len() >= self.config.max_events
472            || state.1.saturating_add(event_size) > max_bytes
473        {
474            let Some((_, old_size)) = state.0.pop_front() else {
475                break;
476            };
477            state.1 = state.1.saturating_sub(old_size);
478        }
479        state.1 = state.1.saturating_add(event_size);
480        state.0.push_back((event.clone(), event_size));
481        let events = state.0.iter().map(|(stored, _)| stored).collect::<Vec<_>>();
482        let payload = serde_json::to_vec(&events).map_err(|_| LogError::Serialization)?;
483        if u64::try_from(payload.len()).map_err(|_| LogError::Capacity)? > self.config.max_bytes {
484            let _ = state.0.pop_back();
485            state.1 = state.1.saturating_sub(event_size);
486            return Err(LogError::Capacity);
487        }
488        let content =
489            ContentType::new("appcore.log.sensitive").map_err(|_| LogError::Encryption)?;
490        let seal = DntSealOptions {
491            application_id: self.config.application_id.clone(),
492            tenant_id: None,
493            content_type: content.clone(),
494            schema_version: 1,
495            key_id: self.config.key_id.clone(),
496            created_at_ms: event.timestamp_ms,
497            public_metadata:
498                b"sensitivity=sensitive;contains_secrets=true;encrypted=true;format=dnt".to_vec(),
499            encrypted_metadata: Vec::new(),
500            flags: 0,
501            max_payload_bytes: Some(self.config.max_bytes),
502        };
503        let open = DntOpenOptions {
504            application_id: self.config.application_id.clone(),
505            tenant_id: None,
506            content_type: content,
507            max_payload_bytes: Some(self.config.max_bytes),
508        };
509        rotate_sensitive_snapshots(&self.config.path, self.config.retention)?;
510        write_atomic(
511            &self.config.path,
512            &payload,
513            &self.key_provider,
514            &BytesCodec,
515            seal,
516            &open,
517        )
518        .map_err(|_| LogError::Encryption)
519    }
520
521    fn accepts_sensitive(&self) -> bool {
522        true
523    }
524
525    fn name(&self) -> &'static str {
526        "sensitive_dnt"
527    }
528}
529
530fn rotate_sensitive_snapshots(path: &std::path::Path, retention: u8) -> Result<(), LogError> {
531    ensure_regular_or_missing(path)?;
532    if !path.exists() {
533        return Ok(());
534    }
535    if retention == 0 {
536        fs::remove_file(path).map_err(|_| LogError::Io)?;
537        return Ok(());
538    }
539    let oldest = path.with_extension(format!("dnt.{retention}"));
540    ensure_regular_or_missing(&oldest)?;
541    if oldest.exists() {
542        fs::remove_file(&oldest).map_err(|_| LogError::Io)?;
543    }
544    for index in (1..retention).rev() {
545        let from = path.with_extension(format!("dnt.{index}"));
546        let to = path.with_extension(format!("dnt.{}", index + 1));
547        ensure_regular_or_missing(&from)?;
548        ensure_regular_or_missing(&to)?;
549        if from.exists() {
550            fs::rename(from, to).map_err(|_| LogError::Io)?;
551        }
552    }
553    fs::rename(path, path.with_extension("dnt.1")).map_err(|_| LogError::Io)
554}
555
556fn ensure_regular_or_missing(path: &std::path::Path) -> Result<(), LogError> {
557    match fs::symlink_metadata(path) {
558        Ok(metadata) if metadata.file_type().is_symlink() || !metadata.file_type().is_file() => {
559            Err(LogError::Io)
560        }
561        Ok(_) => Ok(()),
562        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
563        Err(_) => Err(LogError::Io),
564    }
565}
566
567fn ensure_directory_or_missing(path: &std::path::Path) -> Result<(), LogError> {
568    match fs::symlink_metadata(path) {
569        Ok(metadata) if metadata.file_type().is_dir() => Ok(()),
570        Ok(_) => Err(LogError::Io),
571        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
572        Err(_) => Err(LogError::Io),
573    }
574}
575
576fn ensure_directory(path: &std::path::Path) -> Result<(), LogError> {
577    let metadata = fs::symlink_metadata(path).map_err(|_| LogError::Io)?;
578    if metadata.file_type().is_dir() {
579        Ok(())
580    } else {
581        Err(LogError::Io)
582    }
583}