Skip to main content

futu_core/
audit_log_writer.rs

1//! Shared secure audit log writer.
2//!
3//! The daemon and auth crate both need identical audit JSONL writer behavior:
4//! directory paths roll daily as `futu-audit.log*`, log directories are tightened
5//! to 0700, log files are tightened to 0600, and world-readable temp paths emit a
6//! visible warning. Keeping the implementation here avoids silent drift between
7//! `futu-core::log` and `futu-auth::audit`.
8
9use std::path::Path;
10
11/// Open an audit output path as a non-blocking writer.
12///
13/// - Existing directories, trailing-slash paths, and extension-less paths are
14///   treated as daily rolling directories.
15/// - Paths with extensions are treated as single append-only files.
16pub fn open_writer(
17    path: &Path,
18) -> std::io::Result<(
19    tracing_appender::non_blocking::NonBlocking,
20    tracing_appender::non_blocking::WorkerGuard,
21)> {
22    let is_dir_hint = path.is_dir()
23        || path.as_os_str().to_string_lossy().ends_with('/')
24        || path.extension().is_none();
25
26    warn_if_world_readable_path(path);
27
28    if is_dir_hint {
29        std::fs::create_dir_all(path)?;
30        tighten_dir_perms(path);
31        tighten_log_files_in_dir(path);
32        let appender = tracing_appender::rolling::daily(path, "futu-audit.log");
33        let wrapped = Mode0600Appender::new(appender, path.to_path_buf());
34        Ok(tracing_appender::non_blocking(wrapped))
35    } else {
36        if let Some(parent) = path.parent()
37            && !parent.as_os_str().is_empty()
38        {
39            std::fs::create_dir_all(parent)?;
40            tighten_dir_perms(parent);
41        }
42        let file = open_file_0600(path)?;
43        Ok(tracing_appender::non_blocking(file))
44    }
45}
46
47/// Best-effort tighten audit log directory permissions to 0700 on Unix.
48///
49/// Already stricter permissions are preserved; Windows and other platforms are
50/// no-ops.
51#[cfg(unix)]
52pub fn tighten_dir_perms(path: &Path) {
53    use std::os::unix::fs::PermissionsExt;
54    if let Ok(meta) = std::fs::metadata(path) {
55        let cur = meta.permissions().mode() & 0o777;
56        if cur & 0o077 != 0
57            && let Err(err) = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))
58        {
59            eprintln!(
60                "futu-opend: failed to tighten audit log directory permissions {}: {}",
61                path.display(),
62                err
63            );
64        }
65    }
66}
67
68#[cfg(not(unix))]
69pub fn tighten_dir_perms(_path: &Path) {}
70
71/// Open an audit log file with 0600 permissions on Unix.
72///
73/// Existing files are tightened if they are too loose. Already stricter files
74/// are preserved.
75pub fn open_file_0600(path: &Path) -> std::io::Result<std::fs::File> {
76    let mut opts = std::fs::OpenOptions::new();
77    opts.append(true).create(true);
78    #[cfg(unix)]
79    {
80        use std::os::unix::fs::OpenOptionsExt;
81        opts.mode(0o600);
82    }
83    let file = opts.open(path)?;
84    #[cfg(unix)]
85    {
86        use std::os::unix::fs::PermissionsExt;
87        if let Ok(meta) = file.metadata() {
88            let cur = meta.permissions().mode() & 0o777;
89            if cur & 0o077 != 0
90                && let Err(err) =
91                    std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
92            {
93                eprintln!(
94                    "futu-opend: failed to tighten audit log file permissions {}: {}",
95                    path.display(),
96                    err
97                );
98            }
99        }
100    }
101    Ok(file)
102}
103
104/// Emit a warning when the audit log path lives under a world-readable temp dir.
105pub fn warn_if_world_readable_path(path: &Path) {
106    let s = path.as_os_str().to_string_lossy();
107    let world_readable_prefixes = ["/tmp/", "/var/tmp/", "/private/tmp/", "/tmp"];
108    for prefix in &world_readable_prefixes {
109        if s == *prefix || s.starts_with(prefix) {
110            eprintln!(
111                "⚠️  audit log path {s:?} 位于 world-readable 目录 (mode 1777). \
112                 audit log 含账户 id + redacted token 占位 + timestamp, 同机其他\n\
113                 用户 (包括 agent skill) 可读. 建议改用 ~/.futu-opend-rs/logs/ \
114                 或 /var/log/futu/ 等 0700 目录."
115            );
116            break;
117        }
118    }
119}
120
121struct Mode0600Appender {
122    inner: tracing_appender::rolling::RollingFileAppender,
123    dir: std::path::PathBuf,
124    last_tighten: std::sync::atomic::AtomicU64,
125}
126
127impl Mode0600Appender {
128    fn new(inner: tracing_appender::rolling::RollingFileAppender, dir: std::path::PathBuf) -> Self {
129        Self {
130            inner,
131            dir,
132            last_tighten: std::sync::atomic::AtomicU64::new(0),
133        }
134    }
135
136    fn maybe_tighten(&self) {
137        let now = audit_log_unix_secs_or_zero();
138        let last = self.last_tighten.load(std::sync::atomic::Ordering::Relaxed);
139        if now > last
140            && self
141                .last_tighten
142                .compare_exchange(
143                    last,
144                    now,
145                    std::sync::atomic::Ordering::Relaxed,
146                    std::sync::atomic::Ordering::Relaxed,
147                )
148                .is_ok()
149        {
150            tighten_log_files_in_dir(&self.dir);
151        }
152    }
153}
154
155fn audit_log_unix_secs_or_zero() -> u64 {
156    match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
157        Ok(elapsed) => elapsed.as_secs(),
158        Err(err) => {
159            eprintln!(
160                "futu-opend: audit log wall clock is before UNIX_EPOCH; \
161                 using zero timestamp fallback: {err}"
162            );
163            0
164        }
165    }
166}
167
168impl std::io::Write for Mode0600Appender {
169    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
170        let n = self.inner.write(buf)?;
171        self.maybe_tighten();
172        Ok(n)
173    }
174
175    fn flush(&mut self) -> std::io::Result<()> {
176        self.inner.flush()
177    }
178}
179
180/// Best-effort chmod all `futu-audit.log*` files in a rolling audit directory.
181#[cfg(unix)]
182pub fn tighten_log_files_in_dir(dir: &Path) {
183    use std::os::unix::fs::PermissionsExt;
184    let Ok(rd) = std::fs::read_dir(dir) else {
185        return;
186    };
187    for entry in rd.flatten() {
188        let p = entry.path();
189        let name = match p.file_name().and_then(|n| n.to_str()) {
190            Some(n) => n,
191            None => continue,
192        };
193        if !name.starts_with("futu-audit.log") {
194            continue;
195        }
196        if let Ok(meta) = std::fs::metadata(&p) {
197            if !meta.is_file() {
198                continue;
199            }
200            let cur = meta.permissions().mode() & 0o777;
201            if cur & 0o077 != 0
202                && let Err(err) =
203                    std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o600))
204            {
205                eprintln!(
206                    "futu-opend: failed to tighten audit log file permissions {}: {}",
207                    p.display(),
208                    err
209                );
210            }
211        }
212    }
213}
214
215#[cfg(not(unix))]
216pub fn tighten_log_files_in_dir(_dir: &Path) {}
217
218#[cfg(test)]
219mod tests;