1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum LogError {
27 Io,
29 Capacity,
31 Encryption,
33 Serialization,
35}
36
37pub trait LogSink: Send + Sync {
39 fn emit(&self, event: &LogEvent) -> Result<(), LogError>;
41
42 fn accepts_sensitive(&self) -> bool {
47 false
48 }
49
50 fn name(&self) -> &'static str {
52 "custom"
53 }
54}
55
56#[derive(Debug, Default)]
58pub struct ConsoleSink;
59
60impl ConsoleSink {
61 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#[derive(Debug, Clone)]
87pub struct FileSinkConfig {
88 pub path: PathBuf,
90 pub max_bytes: u64,
92 pub sync_each_write: bool,
94 pub retention: u8,
96 pub archive: Option<FileArchiveConfig>,
98}
99
100#[derive(Debug, Clone)]
102pub struct FileArchiveConfig {
103 pub directory: PathBuf,
105 pub max_files: usize,
107}
108
109pub 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 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
371pub struct RingBufferSink {
373 state: Mutex<(VecDeque<(LogEvent, usize)>, usize)>,
374 max_events: usize,
375 max_bytes: usize,
376}
377
378impl RingBufferSink {
379 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 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#[derive(Debug, Clone)]
427pub struct SensitiveDntSinkConfig {
428 pub path: PathBuf,
430 pub application_id: ApplicationId,
432 pub key_id: KeyId,
434 pub max_bytes: u64,
436 pub max_events: usize,
438 pub retention: u8,
440}
441
442pub 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 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}