codewhale_telemetry/
buffer.rs1use std::fs::{self, DirBuilder, File, OpenOptions};
26use std::io::{Read as _, Write as _};
27use std::path::{Path, PathBuf};
28
29use anyhow::{Context, Result};
30
31pub const MAX_EVENTS: usize = 512;
33pub const MAX_BYTES: u64 = 256 * 1024;
35pub const MAX_LINE_BYTES: usize = 4096;
37
38#[derive(Debug, Clone, PartialEq, Eq)]
44pub(crate) struct TombstoneGeneration(Vec<u8>);
45
46const MAX_TOMBSTONE_BYTES: u64 = 128;
47
48const PROBE_BYTES: u64 = 4096;
53
54#[must_use]
56pub fn buffer_path(root: &Path) -> PathBuf {
57 root.join("buffer.jsonl")
58}
59
60#[must_use]
65pub fn dryrun_path(root: &Path) -> PathBuf {
66 root.join("dryrun.jsonl")
67}
68
69#[must_use]
73pub fn lock_path(root: &Path) -> PathBuf {
74 root.join("buffer.jsonl.lock")
75}
76
77#[must_use]
79pub fn tombstone_path(root: &Path) -> PathBuf {
80 root.join("disabled")
81}
82
83#[must_use]
85pub fn install_id_path(root: &Path) -> PathBuf {
86 root.join("install_id.json")
87}
88
89#[must_use]
91pub fn state_path(root: &Path) -> PathBuf {
92 root.join("state.json")
93}
94
95#[must_use]
101pub fn tombstone_present(root: &Path) -> bool {
102 tombstone_path(root).exists()
103}
104
105pub(crate) fn tombstone_generation(root: &Path) -> Result<Option<TombstoneGeneration>> {
108 let path = tombstone_path(root);
109 let file = match File::open(&path) {
110 Ok(file) => file,
111 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
112 Err(error) => {
113 return Err(
114 anyhow::Error::new(error).context(format!("failed to open {}", path.display()))
115 );
116 }
117 };
118 let mut bytes = Vec::new();
119 file.take(MAX_TOMBSTONE_BYTES + 1)
120 .read_to_end(&mut bytes)
121 .with_context(|| format!("failed to read {}", path.display()))?;
122 if bytes.len() as u64 > MAX_TOMBSTONE_BYTES {
123 anyhow::bail!("{} exceeds the tombstone size limit", path.display());
124 }
125 Ok(Some(TombstoneGeneration(bytes)))
126}
127
128pub fn ensure_dir(root: &Path) -> Result<()> {
130 if root.is_dir() {
131 return Ok(());
132 }
133 let mut builder = DirBuilder::new();
134 builder.recursive(true);
135 #[cfg(unix)]
136 {
137 use std::os::unix::fs::DirBuilderExt as _;
138 builder.mode(0o700);
139 }
140 builder
141 .create(root)
142 .with_context(|| format!("failed to create {}", root.display()))
143}
144
145#[cfg(unix)]
146fn secure(file: &File) -> Result<()> {
147 use std::os::unix::fs::PermissionsExt as _;
148 file.set_permissions(fs::Permissions::from_mode(0o600))
149 .context("failed to restrict telemetry file permissions")
150}
151
152#[cfg(not(unix))]
153fn secure(_file: &File) -> Result<()> {
154 Ok(())
155}
156
157pub fn append(root: &Path, path: &Path, line: &str) -> Option<()> {
164 append_with_limit(root, path, line, MAX_LINE_BYTES)
165}
166
167pub fn append_locked(root: &Path, path: &Path, line: &str) -> Option<()> {
175 append_with_limit(root, path, line, MAX_BYTES as usize)
176}
177
178fn append_with_limit(root: &Path, path: &Path, line: &str, limit: usize) -> Option<()> {
179 let bytes = line.as_bytes();
180 if bytes.is_empty() || bytes.len() + 1 > limit {
181 return None;
182 }
183
184 let mut buf = Vec::with_capacity(bytes.len() + 1);
185 buf.extend_from_slice(bytes);
186 buf.push(b'\n');
187
188 let wrote = try_with_lock(root, || append_under_lock(root, path, &buf))
189 .ok()
190 .flatten()
191 .unwrap_or(false);
192 if !wrote {
193 return None;
194 }
195
196 enforce_ring(root, path);
197 Some(())
198}
199
200fn append_under_lock(root: &Path, path: &Path, buf: &[u8]) -> Result<bool> {
202 if tombstone_present(root) {
203 return Ok(false);
204 }
205 let file = OpenOptions::new()
206 .create(true)
207 .append(true)
208 .open(path)
209 .with_context(|| format!("failed to open {}", path.display()))?;
210 secure(&file)?;
211 (&file)
214 .write_all(buf)
215 .with_context(|| format!("failed to append to {}", path.display()))?;
216 file.sync_data()
217 .with_context(|| format!("failed to sync {}", path.display()))?;
218 Ok(true)
219}
220
221fn enforce_ring(root: &Path, path: &Path) {
226 let Ok(meta) = fs::metadata(path) else {
227 return;
228 };
229 let len = meta.len();
230 if len < PROBE_BYTES {
231 return;
232 }
233 let _ = try_with_lock(root, || {
234 if tombstone_present(root) {
237 return Ok(());
238 }
239 let Ok(meta) = fs::metadata(path) else {
240 return Ok(());
241 };
242 let len = meta.len();
243 if len < PROBE_BYTES {
244 return Ok(());
245 }
246 let contents = fs::read_to_string(path)
247 .with_context(|| format!("failed to read {}", path.display()))?;
248 let lines: Vec<&str> = contents.lines().filter(|l| !l.trim().is_empty()).collect();
249 if lines.len() <= MAX_EVENTS && len <= MAX_BYTES {
250 return Ok(());
251 }
252 let mut kept: Vec<&str> = lines
253 .iter()
254 .rev()
255 .take(MAX_EVENTS)
256 .rev()
257 .copied()
258 .collect::<Vec<_>>();
259 while kept.len() > 1 && byte_len(&kept) > MAX_BYTES {
261 kept.remove(0);
262 }
263 let mut body = kept.join("\n");
264 if !body.is_empty() {
265 body.push('\n');
266 }
267 rewrite(path, body.as_bytes())
268 });
269}
270
271fn byte_len(lines: &[&str]) -> u64 {
272 lines.iter().map(|l| l.len() as u64 + 1).sum()
273}
274
275fn rewrite(path: &Path, bytes: &[u8]) -> Result<()> {
277 let dir = path.parent().unwrap_or_else(|| Path::new("."));
278 let mut tmp = tempfile::NamedTempFile::new_in(dir)
279 .with_context(|| format!("failed to stage a rewrite of {}", path.display()))?;
280 tmp.write_all(bytes)
281 .with_context(|| format!("failed to write a rewrite of {}", path.display()))?;
282 tmp.flush()
283 .with_context(|| format!("failed to flush a rewrite of {}", path.display()))?;
284 secure(tmp.as_file())?;
285 tmp.persist(path)
286 .map_err(|error| error.error)
287 .with_context(|| format!("failed to persist {}", path.display()))?;
288 Ok(())
289}
290
291fn open_lock(root: &Path) -> Result<File> {
293 ensure_dir(root)?;
294 let path = lock_path(root);
295 let file = OpenOptions::new()
296 .create(true)
297 .read(true)
298 .write(true)
299 .truncate(false)
302 .open(&path)
303 .with_context(|| format!("failed to open {}", path.display()))?;
304 secure(&file)?;
305 Ok(file)
306}
307
308pub fn with_lock<T>(root: &Path, operation: impl FnOnce() -> Result<T>) -> Result<T> {
313 let file = open_lock(root)?;
314 let mut lock = fd_lock::RwLock::new(file);
315 let _guard = lock.write().context("failed to take the telemetry lock")?;
316 operation()
317}
318
319pub fn try_with_lock<T>(root: &Path, operation: impl FnOnce() -> Result<T>) -> Result<Option<T>> {
323 let file = open_lock(root)?;
324 let mut lock = fd_lock::RwLock::new(file);
325 match lock.try_write() {
326 Ok(_guard) => operation().map(Some),
327 Err(_) => Ok(None),
328 }
329}
330
331#[must_use]
337pub fn read_lines(path: &Path) -> Vec<String> {
338 let Ok(contents) = fs::read_to_string(path) else {
339 return Vec::new();
340 };
341 contents
342 .lines()
343 .filter(|line| !line.trim().is_empty())
344 .map(str::to_string)
345 .collect()
346}
347
348#[must_use]
355pub fn drain(root: &Path) -> Vec<String> {
356 if tombstone_present(root) {
357 return Vec::new();
358 }
359 let path = buffer_path(root);
360 let drained = try_with_lock(root, || {
361 if tombstone_present(root) {
364 return Ok(Vec::new());
365 }
366 let lines = read_lines(&path);
367 if !lines.is_empty() {
368 truncate(&path)?;
369 }
370 Ok(lines)
371 });
372 drained.ok().flatten().unwrap_or_default()
373}
374
375pub fn truncate(path: &Path) -> Result<()> {
378 if !path.exists() {
379 return Ok(());
380 }
381 let file = OpenOptions::new()
382 .write(true)
383 .truncate(true)
384 .open(path)
385 .with_context(|| format!("failed to truncate {}", path.display()))?;
386 secure(&file)?;
387 Ok(())
388}
389
390pub fn wipe(root: &Path) -> Result<()> {
404 with_lock(root, || {
405 let tombstone = tombstone_path(root);
406 if tombstone_generation(root).ok().flatten().is_none() {
413 let mut file = OpenOptions::new()
414 .create(true)
415 .write(true)
416 .truncate(true)
417 .open(&tombstone)
418 .with_context(|| format!("failed to write {}", tombstone.display()))?;
419 secure(&file)?;
420 file.write_all(uuid::Uuid::new_v4().to_string().as_bytes())
421 .with_context(|| format!("failed to write {}", tombstone.display()))?;
422 file.sync_data()
423 .with_context(|| format!("failed to sync {}", tombstone.display()))?;
424 drop(file);
425 }
426
427 let mut failure: Option<anyhow::Error> = None;
428 for path in [buffer_path(root), dryrun_path(root)] {
429 if let Err(error) = truncate(&path) {
430 failure.get_or_insert(error);
431 }
432 }
433 for path in [install_id_path(root), state_path(root)] {
434 if path.exists()
435 && let Err(error) = fs::remove_file(&path)
436 {
437 failure.get_or_insert(
438 anyhow::Error::new(error)
439 .context(format!("failed to remove {}", path.display())),
440 );
441 }
442 }
443 match failure {
444 Some(error) => Err(error),
445 None => Ok(()),
446 }
447 })
448}
449
450pub(crate) fn arm(
458 root: &Path,
459 observed_generation: Option<&TombstoneGeneration>,
460 permission_still_enabled: impl FnOnce() -> bool,
461) -> Result<()> {
462 ensure_dir(root)?;
463 with_lock(root, || {
464 let current_generation = tombstone_generation(root)?;
465 if current_generation.as_ref() != observed_generation {
466 anyhow::bail!("telemetry permission changed before arming");
467 }
468 if !permission_still_enabled() {
469 anyhow::bail!("telemetry permission is no longer enabled");
470 }
471 let tombstone = tombstone_path(root);
472 if tombstone.exists() {
473 fs::remove_file(&tombstone)
474 .with_context(|| format!("failed to remove {}", tombstone.display()))?;
475 }
476 truncate(&buffer_path(root))
477 })
478}