futu_core/
audit_log_writer.rs1use std::path::Path;
10
11pub const DEFAULT_AUDIT_LOG_MAX_FILES: usize = 90;
14
15pub 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#[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
80pub 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
113pub 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#[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;