Skip to main content

mcp_airlock/
logging.rs

1//! # Log directory setup
2//!
3//! Log files can contain session ids and request metadata, so the directory must
4//! be private to the current user. On Unix it is created with mode `0o700`, and
5//! an existing directory is accepted only if it is not a symlink and we can
6//! restrict it to `0o700` (which fails unless we own it). This defeats a
7//! pre-created or symlinked directory planted by another local user.
8
9use crate::Result;
10use anyhow::{bail, Context};
11use std::path::Path;
12
13/// Creates `dir` if needed and makes sure only the current user can access it.
14pub fn prepare_log_dir(dir: &Path) -> Result<()> {
15    #[cfg(unix)]
16    {
17        use std::fs::DirBuilder;
18        use std::os::unix::fs::{DirBuilderExt, PermissionsExt};
19
20        DirBuilder::new()
21            .recursive(true)
22            .mode(0o700)
23            .create(dir)
24            .with_context(|| format!("Failed to create log directory {}", dir.display()))?;
25
26        let meta = std::fs::symlink_metadata(dir)
27            .with_context(|| format!("Failed to inspect log directory {}", dir.display()))?;
28        if meta.file_type().is_symlink() {
29            bail!(
30                "Log directory {} is a symlink; refusing to use it",
31                dir.display()
32            );
33        }
34        if !meta.is_dir() {
35            bail!("Log directory {} is not a directory", dir.display());
36        }
37        std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)).with_context(
38            || {
39                format!(
40                    "Log directory {} is not owned by the current user",
41                    dir.display()
42                )
43            },
44        )?;
45    }
46    #[cfg(not(unix))]
47    {
48        std::fs::create_dir_all(dir)
49            .with_context(|| format!("Failed to create log directory {}", dir.display()))?;
50    }
51    Ok(())
52}
53
54#[cfg(all(test, unix))]
55mod tests {
56    use super::*;
57    use std::os::unix::fs::PermissionsExt;
58
59    fn scratch() -> std::path::PathBuf {
60        let dir =
61            std::env::temp_dir().join(format!("mcp-airlock-logtest-{}", uuid::Uuid::new_v4()));
62        std::fs::create_dir_all(&dir).unwrap();
63        dir
64    }
65
66    #[test]
67    fn test_creates_private_directory() {
68        let base = scratch();
69        let dir = base.join("a/b/logs");
70        prepare_log_dir(&dir).unwrap();
71        let mode = std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777;
72        assert_eq!(mode, 0o700);
73        std::fs::remove_dir_all(base).unwrap();
74    }
75
76    #[test]
77    fn test_tightens_existing_directory() {
78        let base = scratch();
79        let dir = base.join("logs");
80        std::fs::create_dir(&dir).unwrap();
81        std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o777)).unwrap();
82        prepare_log_dir(&dir).unwrap();
83        let mode = std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777;
84        assert_eq!(mode, 0o700);
85        std::fs::remove_dir_all(base).unwrap();
86    }
87
88    #[test]
89    fn test_rejects_symlink() {
90        let base = scratch();
91        let target = base.join("elsewhere");
92        std::fs::create_dir(&target).unwrap();
93        let link = base.join("logs");
94        std::os::unix::fs::symlink(&target, &link).unwrap();
95        let err = prepare_log_dir(&link).unwrap_err().to_string();
96        assert!(err.contains("symlink"), "{err}");
97        std::fs::remove_dir_all(base).unwrap();
98    }
99
100    #[test]
101    fn test_rejects_file() {
102        let base = scratch();
103        let file = base.join("logs");
104        std::fs::write(&file, b"").unwrap();
105        assert!(prepare_log_dir(&file).is_err());
106        std::fs::remove_dir_all(base).unwrap();
107    }
108}