futu_core/
audit_log_writer.rs1use std::path::Path;
10
11pub 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#[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
71pub 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
104pub 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#[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;