codewhale_telemetry/
buffer.rs1use std::fs::{self, DirBuilder, File, OpenOptions};
28use std::io::Write as _;
29use std::path::{Path, PathBuf};
30
31use anyhow::{Context, Result};
32
33pub const MAX_EVENTS: usize = 512;
35pub const MAX_BYTES: u64 = 256 * 1024;
37pub const MAX_LINE_BYTES: usize = 4096;
39
40const PROBE_BYTES: u64 = 4096;
45
46#[must_use]
48pub fn buffer_path(root: &Path) -> PathBuf {
49 root.join("buffer.jsonl")
50}
51
52#[must_use]
57pub fn dryrun_path(root: &Path) -> PathBuf {
58 root.join("dryrun.jsonl")
59}
60
61#[must_use]
65pub fn lock_path(root: &Path) -> PathBuf {
66 root.join("buffer.jsonl.lock")
67}
68
69#[must_use]
71pub fn tombstone_path(root: &Path) -> PathBuf {
72 root.join("disabled")
73}
74
75#[must_use]
77pub fn install_id_path(root: &Path) -> PathBuf {
78 root.join("install_id.json")
79}
80
81#[must_use]
83pub fn state_path(root: &Path) -> PathBuf {
84 root.join("state.json")
85}
86
87#[must_use]
93pub fn tombstone_present(root: &Path) -> bool {
94 tombstone_path(root).exists()
95}
96
97pub fn ensure_dir(root: &Path) -> Result<()> {
99 if root.is_dir() {
100 return Ok(());
101 }
102 let mut builder = DirBuilder::new();
103 builder.recursive(true);
104 #[cfg(unix)]
105 {
106 use std::os::unix::fs::DirBuilderExt as _;
107 builder.mode(0o700);
108 }
109 builder
110 .create(root)
111 .with_context(|| format!("failed to create {}", root.display()))
112}
113
114#[cfg(unix)]
115fn secure(file: &File) -> Result<()> {
116 use std::os::unix::fs::PermissionsExt as _;
117 file.set_permissions(fs::Permissions::from_mode(0o600))
118 .context("failed to restrict telemetry file permissions")
119}
120
121#[cfg(not(unix))]
122fn secure(_file: &File) -> Result<()> {
123 Ok(())
124}
125
126pub fn append(root: &Path, path: &Path, line: &str) -> Option<()> {
133 if tombstone_present(root) {
134 return None;
135 }
136 let bytes = line.as_bytes();
137 if bytes.is_empty() || bytes.len() + 1 > MAX_LINE_BYTES {
138 return None;
139 }
140 ensure_dir(root).ok()?;
141
142 let mut buf = Vec::with_capacity(bytes.len() + 1);
143 buf.extend_from_slice(bytes);
144 buf.push(b'\n');
145
146 let file = OpenOptions::new()
147 .create(true)
148 .append(true)
149 .open(path)
150 .ok()?;
151 secure(&file).ok()?;
152 (&file).write_all(&buf).ok()?;
155 file.sync_data().ok()?;
156 drop(file);
157
158 enforce_ring(root, path);
159 Some(())
160}
161
162pub fn append_locked(root: &Path, path: &Path, line: &str) -> Option<()> {
170 if tombstone_present(root) {
171 return None;
172 }
173 let bytes = line.as_bytes();
174 if bytes.is_empty() || bytes.len() as u64 + 1 > MAX_BYTES {
175 return None;
176 }
177 ensure_dir(root).ok()?;
178
179 let mut buf = Vec::with_capacity(bytes.len() + 1);
180 buf.extend_from_slice(bytes);
181 buf.push(b'\n');
182
183 let wrote = try_with_lock(root, || {
184 if tombstone_present(root) {
185 return Ok(false);
186 }
187 let file = OpenOptions::new()
188 .create(true)
189 .append(true)
190 .open(path)
191 .with_context(|| format!("failed to open {}", path.display()))?;
192 secure(&file)?;
193 (&file)
194 .write_all(&buf)
195 .with_context(|| format!("failed to append to {}", path.display()))?;
196 file.sync_data()
197 .with_context(|| format!("failed to sync {}", path.display()))?;
198 Ok(true)
199 })
200 .ok()
201 .flatten()
202 .unwrap_or(false);
203
204 if !wrote {
205 return None;
206 }
207 enforce_ring(root, path);
208 Some(())
209}
210
211fn enforce_ring(root: &Path, path: &Path) {
216 let Ok(meta) = fs::metadata(path) else {
217 return;
218 };
219 let len = meta.len();
220 if len < PROBE_BYTES {
221 return;
222 }
223 let Ok(contents) = fs::read_to_string(path) else {
224 return;
225 };
226 let lines: Vec<&str> = contents.lines().filter(|l| !l.trim().is_empty()).collect();
227 if lines.len() <= MAX_EVENTS && len <= MAX_BYTES {
228 return;
229 }
230
231 let _ = try_with_lock(root, || {
232 let mut kept: Vec<&str> = lines
233 .iter()
234 .rev()
235 .take(MAX_EVENTS)
236 .rev()
237 .copied()
238 .collect::<Vec<_>>();
239 while kept.len() > 1 && byte_len(&kept) > MAX_BYTES {
241 kept.remove(0);
242 }
243 let mut body = kept.join("\n");
244 if !body.is_empty() {
245 body.push('\n');
246 }
247 rewrite(path, body.as_bytes())
248 });
249}
250
251fn byte_len(lines: &[&str]) -> u64 {
252 lines.iter().map(|l| l.len() as u64 + 1).sum()
253}
254
255fn rewrite(path: &Path, bytes: &[u8]) -> Result<()> {
257 let dir = path.parent().unwrap_or_else(|| Path::new("."));
258 let mut tmp = tempfile::NamedTempFile::new_in(dir)
259 .with_context(|| format!("failed to stage a rewrite of {}", path.display()))?;
260 tmp.write_all(bytes)
261 .with_context(|| format!("failed to write a rewrite of {}", path.display()))?;
262 tmp.flush()
263 .with_context(|| format!("failed to flush a rewrite of {}", path.display()))?;
264 secure(tmp.as_file())?;
265 tmp.persist(path)
266 .map_err(|error| error.error)
267 .with_context(|| format!("failed to persist {}", path.display()))?;
268 Ok(())
269}
270
271fn open_lock(root: &Path) -> Result<File> {
273 ensure_dir(root)?;
274 let path = lock_path(root);
275 let file = OpenOptions::new()
276 .create(true)
277 .read(true)
278 .write(true)
279 .truncate(false)
282 .open(&path)
283 .with_context(|| format!("failed to open {}", path.display()))?;
284 secure(&file)?;
285 Ok(file)
286}
287
288pub fn with_lock<T>(root: &Path, operation: impl FnOnce() -> Result<T>) -> Result<T> {
293 let file = open_lock(root)?;
294 let mut lock = fd_lock::RwLock::new(file);
295 let _guard = lock.write().context("failed to take the telemetry lock")?;
296 operation()
297}
298
299pub fn try_with_lock<T>(root: &Path, operation: impl FnOnce() -> Result<T>) -> Result<Option<T>> {
303 let file = open_lock(root)?;
304 let mut lock = fd_lock::RwLock::new(file);
305 match lock.try_write() {
306 Ok(_guard) => operation().map(Some),
307 Err(_) => Ok(None),
308 }
309}
310
311#[must_use]
317pub fn read_lines(path: &Path) -> Vec<String> {
318 let Ok(contents) = fs::read_to_string(path) else {
319 return Vec::new();
320 };
321 contents
322 .lines()
323 .filter(|line| !line.trim().is_empty())
324 .map(str::to_string)
325 .collect()
326}
327
328#[must_use]
335pub fn drain(root: &Path) -> Vec<String> {
336 if tombstone_present(root) {
337 return Vec::new();
338 }
339 let path = buffer_path(root);
340 let drained = try_with_lock(root, || {
341 if tombstone_present(root) {
344 return Ok(Vec::new());
345 }
346 let lines = read_lines(&path);
347 if !lines.is_empty() {
348 truncate(&path)?;
349 }
350 Ok(lines)
351 });
352 drained.ok().flatten().unwrap_or_default()
353}
354
355pub fn truncate(path: &Path) -> Result<()> {
358 if !path.exists() {
359 return Ok(());
360 }
361 let file = OpenOptions::new()
362 .write(true)
363 .truncate(true)
364 .open(path)
365 .with_context(|| format!("failed to truncate {}", path.display()))?;
366 secure(&file)?;
367 Ok(())
368}
369
370pub fn wipe(root: &Path) -> Result<()> {
384 with_lock(root, || {
385 let tombstone = tombstone_path(root);
386 let file = OpenOptions::new()
387 .create(true)
388 .write(true)
389 .truncate(true)
390 .open(&tombstone)
391 .with_context(|| format!("failed to write {}", tombstone.display()))?;
392 secure(&file)?;
393 drop(file);
394
395 let mut failure: Option<anyhow::Error> = None;
396 for path in [buffer_path(root), dryrun_path(root)] {
397 if let Err(error) = truncate(&path) {
398 failure.get_or_insert(error);
399 }
400 }
401 for path in [install_id_path(root), state_path(root)] {
402 if path.exists()
403 && let Err(error) = fs::remove_file(&path)
404 {
405 failure.get_or_insert(
406 anyhow::Error::new(error)
407 .context(format!("failed to remove {}", path.display())),
408 );
409 }
410 }
411 match failure {
412 Some(error) => Err(error),
413 None => Ok(()),
414 }
415 })
416}
417
418pub fn arm(root: &Path) -> Result<()> {
424 ensure_dir(root)?;
425 with_lock(root, || {
426 let tombstone = tombstone_path(root);
427 if tombstone.exists() {
428 fs::remove_file(&tombstone)
429 .with_context(|| format!("failed to remove {}", tombstone.display()))?;
430 }
431 truncate(&buffer_path(root))
432 })
433}