use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use tracing_appender::non_blocking::{NonBlocking, WorkerGuard};
use tracing_appender::rolling::{RollingFileAppender, Rotation};
use crate::loader::LoggingConfig;
pub struct PastaLogger {
log_path: PathBuf,
writer: Mutex<NonBlocking>,
_guard: WorkerGuard,
}
impl PastaLogger {
pub fn new(base_dir: &Path, config: Option<&LoggingConfig>) -> io::Result<Self> {
let config = config.cloned().unwrap_or_default();
let log_path = base_dir.join(&config.file_path);
Self::validate_path(base_dir, &log_path)?;
if let Some(parent) = log_path.parent() {
std::fs::create_dir_all(parent)?;
}
let log_dir = log_path
.parent()
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "Invalid log path"))?;
let log_file_name = log_path
.file_name()
.and_then(|s| s.to_str())
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "Invalid log filename"))?;
let appender = RollingFileAppender::builder()
.rotation(Rotation::NEVER)
.filename_prefix(log_file_name)
.build(log_dir)
.map_err(io::Error::other)?;
let (writer, guard) = tracing_appender::non_blocking(appender);
Ok(Self {
log_path,
writer: Mutex::new(writer),
_guard: guard,
})
}
fn validate_path(base_dir: &Path, log_path: &Path) -> io::Result<()> {
let relative = log_path.strip_prefix(base_dir).map_err(|_| {
io::Error::new(
io::ErrorKind::PermissionDenied,
format!("Log path must be relative to base_dir: {:?}", log_path),
)
})?;
let relative_str = relative.to_string_lossy();
if !relative_str.starts_with("profile") {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
format!("Log path must be within profile/ directory: {:?}", log_path),
));
}
if relative_str.contains("..") {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"Path traversal not allowed in log path",
));
}
Ok(())
}
pub fn log_path(&self) -> &Path {
&self.log_path
}
pub fn write(&self, buf: &[u8]) -> io::Result<usize> {
let mut writer = self
.writer
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
writer.write(buf)
}
pub fn flush(&self) -> io::Result<()> {
let mut writer = self
.writer
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
writer.flush()
}
pub fn is_enabled(&self) -> bool {
true
}
}
impl Drop for PastaLogger {
fn drop(&mut self) {
let _ = self.flush();
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_logger_creation() {
let temp_dir = TempDir::new().unwrap();
let base_dir = temp_dir.path();
std::fs::create_dir_all(base_dir.join("profile/pasta/logs")).unwrap();
let config = LoggingConfig::default();
let logger = PastaLogger::new(base_dir, Some(&config)).unwrap();
assert!(logger.is_enabled());
assert!(logger.log_path().to_string_lossy().contains("pasta.log"));
}
#[test]
fn test_path_validation_rejects_traversal() {
let temp_dir = TempDir::new().unwrap();
let base_dir = temp_dir.path();
let config = LoggingConfig {
file_path: "../outside.log".to_string(),
rotation_days: 7,
level: "debug".to_string(),
filter: None,
};
let result = PastaLogger::new(base_dir, Some(&config));
assert!(result.is_err());
}
#[test]
fn test_path_validation_rejects_non_profile() {
let temp_dir = TempDir::new().unwrap();
let base_dir = temp_dir.path();
let config = LoggingConfig {
file_path: "other/logs/pasta.log".to_string(),
rotation_days: 7,
level: "debug".to_string(),
filter: None,
};
let result = PastaLogger::new(base_dir, Some(&config));
assert!(result.is_err());
}
#[test]
fn test_log_file_is_fixed_name_no_date_suffix() {
let temp_dir = TempDir::new().unwrap();
let base_dir = temp_dir.path();
std::fs::create_dir_all(base_dir.join("profile/pasta/logs")).unwrap();
let logger = PastaLogger::new(base_dir, None).unwrap();
logger.write(b"test log line\n").unwrap();
logger.flush().unwrap();
let log_dir = base_dir.join("profile/pasta/logs");
let entries: Vec<_> = std::fs::read_dir(&log_dir)
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().to_string())
.collect();
assert!(
entries.contains(&"pasta.log".to_string()),
"Log file should be exactly 'pasta.log', found: {:?}",
entries
);
let date_files: Vec<_> = entries
.iter()
.filter(|n| n.starts_with("pasta.log.") && n.len() > "pasta.log".len())
.collect();
assert!(
date_files.is_empty(),
"No date-suffixed log files should exist, found: {:?}",
date_files
);
}
}