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