use std::fs::{self, DirBuilder, File, OpenOptions};
use std::io::{Read as _, Write as _};
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
pub const MAX_EVENTS: usize = 512;
pub const MAX_BYTES: u64 = 256 * 1024;
pub const MAX_LINE_BYTES: usize = 4096;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TombstoneGeneration(Vec<u8>);
const MAX_TOMBSTONE_BYTES: u64 = 128;
const PROBE_BYTES: u64 = 4096;
#[must_use]
pub fn buffer_path(root: &Path) -> PathBuf {
root.join("buffer.jsonl")
}
#[must_use]
pub fn dryrun_path(root: &Path) -> PathBuf {
root.join("dryrun.jsonl")
}
#[must_use]
pub fn lock_path(root: &Path) -> PathBuf {
root.join("buffer.jsonl.lock")
}
#[must_use]
pub fn tombstone_path(root: &Path) -> PathBuf {
root.join("disabled")
}
#[must_use]
pub fn install_id_path(root: &Path) -> PathBuf {
root.join("install_id.json")
}
#[must_use]
pub fn state_path(root: &Path) -> PathBuf {
root.join("state.json")
}
#[must_use]
pub fn tombstone_present(root: &Path) -> bool {
tombstone_path(root).exists()
}
pub(crate) fn tombstone_generation(root: &Path) -> Result<Option<TombstoneGeneration>> {
let path = tombstone_path(root);
let file = match File::open(&path) {
Ok(file) => file,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => {
return Err(
anyhow::Error::new(error).context(format!("failed to open {}", path.display()))
);
}
};
let mut bytes = Vec::new();
file.take(MAX_TOMBSTONE_BYTES + 1)
.read_to_end(&mut bytes)
.with_context(|| format!("failed to read {}", path.display()))?;
if bytes.len() as u64 > MAX_TOMBSTONE_BYTES {
anyhow::bail!("{} exceeds the tombstone size limit", path.display());
}
Ok(Some(TombstoneGeneration(bytes)))
}
pub fn ensure_dir(root: &Path) -> Result<()> {
if root.is_dir() {
return Ok(());
}
let mut builder = DirBuilder::new();
builder.recursive(true);
#[cfg(unix)]
{
use std::os::unix::fs::DirBuilderExt as _;
builder.mode(0o700);
}
builder
.create(root)
.with_context(|| format!("failed to create {}", root.display()))
}
#[cfg(unix)]
fn secure(file: &File) -> Result<()> {
use std::os::unix::fs::PermissionsExt as _;
file.set_permissions(fs::Permissions::from_mode(0o600))
.context("failed to restrict telemetry file permissions")
}
#[cfg(not(unix))]
fn secure(_file: &File) -> Result<()> {
Ok(())
}
pub fn append(root: &Path, path: &Path, line: &str) -> Option<()> {
append_with_limit(root, path, line, MAX_LINE_BYTES)
}
pub fn append_locked(root: &Path, path: &Path, line: &str) -> Option<()> {
append_with_limit(root, path, line, MAX_BYTES as usize)
}
fn append_with_limit(root: &Path, path: &Path, line: &str, limit: usize) -> Option<()> {
let bytes = line.as_bytes();
if bytes.is_empty() || bytes.len() + 1 > limit {
return None;
}
let mut buf = Vec::with_capacity(bytes.len() + 1);
buf.extend_from_slice(bytes);
buf.push(b'\n');
let wrote = try_with_lock(root, || append_under_lock(root, path, &buf))
.ok()
.flatten()
.unwrap_or(false);
if !wrote {
return None;
}
enforce_ring(root, path);
Some(())
}
fn append_under_lock(root: &Path, path: &Path, buf: &[u8]) -> Result<bool> {
if tombstone_present(root) {
return Ok(false);
}
let file = OpenOptions::new()
.create(true)
.append(true)
.open(path)
.with_context(|| format!("failed to open {}", path.display()))?;
secure(&file)?;
(&file)
.write_all(buf)
.with_context(|| format!("failed to append to {}", path.display()))?;
file.sync_data()
.with_context(|| format!("failed to sync {}", path.display()))?;
Ok(true)
}
fn enforce_ring(root: &Path, path: &Path) {
let Ok(meta) = fs::metadata(path) else {
return;
};
let len = meta.len();
if len < PROBE_BYTES {
return;
}
let _ = try_with_lock(root, || {
if tombstone_present(root) {
return Ok(());
}
let Ok(meta) = fs::metadata(path) else {
return Ok(());
};
let len = meta.len();
if len < PROBE_BYTES {
return Ok(());
}
let contents = fs::read_to_string(path)
.with_context(|| format!("failed to read {}", path.display()))?;
let lines: Vec<&str> = contents.lines().filter(|l| !l.trim().is_empty()).collect();
if lines.len() <= MAX_EVENTS && len <= MAX_BYTES {
return Ok(());
}
let mut kept: Vec<&str> = lines
.iter()
.rev()
.take(MAX_EVENTS)
.rev()
.copied()
.collect::<Vec<_>>();
while kept.len() > 1 && byte_len(&kept) > MAX_BYTES {
kept.remove(0);
}
let mut body = kept.join("\n");
if !body.is_empty() {
body.push('\n');
}
rewrite(path, body.as_bytes())
});
}
fn byte_len(lines: &[&str]) -> u64 {
lines.iter().map(|l| l.len() as u64 + 1).sum()
}
fn rewrite(path: &Path, bytes: &[u8]) -> Result<()> {
let dir = path.parent().unwrap_or_else(|| Path::new("."));
let mut tmp = tempfile::NamedTempFile::new_in(dir)
.with_context(|| format!("failed to stage a rewrite of {}", path.display()))?;
tmp.write_all(bytes)
.with_context(|| format!("failed to write a rewrite of {}", path.display()))?;
tmp.flush()
.with_context(|| format!("failed to flush a rewrite of {}", path.display()))?;
secure(tmp.as_file())?;
tmp.persist(path)
.map_err(|error| error.error)
.with_context(|| format!("failed to persist {}", path.display()))?;
Ok(())
}
fn open_lock(root: &Path) -> Result<File> {
ensure_dir(root)?;
let path = lock_path(root);
let file = OpenOptions::new()
.create(true)
.read(true)
.write(true)
.truncate(false)
.open(&path)
.with_context(|| format!("failed to open {}", path.display()))?;
secure(&file)?;
Ok(file)
}
pub fn with_lock<T>(root: &Path, operation: impl FnOnce() -> Result<T>) -> Result<T> {
let file = open_lock(root)?;
let mut lock = fd_lock::RwLock::new(file);
let _guard = lock.write().context("failed to take the telemetry lock")?;
operation()
}
pub fn try_with_lock<T>(root: &Path, operation: impl FnOnce() -> Result<T>) -> Result<Option<T>> {
let file = open_lock(root)?;
let mut lock = fd_lock::RwLock::new(file);
match lock.try_write() {
Ok(_guard) => operation().map(Some),
Err(_) => Ok(None),
}
}
#[must_use]
pub fn read_lines(path: &Path) -> Vec<String> {
let Ok(contents) = fs::read_to_string(path) else {
return Vec::new();
};
contents
.lines()
.filter(|line| !line.trim().is_empty())
.map(str::to_string)
.collect()
}
#[must_use]
pub fn drain(root: &Path) -> Vec<String> {
if tombstone_present(root) {
return Vec::new();
}
let path = buffer_path(root);
let drained = try_with_lock(root, || {
if tombstone_present(root) {
return Ok(Vec::new());
}
let lines = read_lines(&path);
if !lines.is_empty() {
truncate(&path)?;
}
Ok(lines)
});
drained.ok().flatten().unwrap_or_default()
}
pub fn truncate(path: &Path) -> Result<()> {
if !path.exists() {
return Ok(());
}
let file = OpenOptions::new()
.write(true)
.truncate(true)
.open(path)
.with_context(|| format!("failed to truncate {}", path.display()))?;
secure(&file)?;
Ok(())
}
pub fn wipe(root: &Path) -> Result<()> {
with_lock(root, || {
let tombstone = tombstone_path(root);
if tombstone_generation(root).ok().flatten().is_none() {
let mut file = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&tombstone)
.with_context(|| format!("failed to write {}", tombstone.display()))?;
secure(&file)?;
file.write_all(uuid::Uuid::new_v4().to_string().as_bytes())
.with_context(|| format!("failed to write {}", tombstone.display()))?;
file.sync_data()
.with_context(|| format!("failed to sync {}", tombstone.display()))?;
drop(file);
}
let mut failure: Option<anyhow::Error> = None;
for path in [buffer_path(root), dryrun_path(root)] {
if let Err(error) = truncate(&path) {
failure.get_or_insert(error);
}
}
for path in [install_id_path(root), state_path(root)] {
if path.exists()
&& let Err(error) = fs::remove_file(&path)
{
failure.get_or_insert(
anyhow::Error::new(error)
.context(format!("failed to remove {}", path.display())),
);
}
}
match failure {
Some(error) => Err(error),
None => Ok(()),
}
})
}
pub(crate) fn arm(
root: &Path,
observed_generation: Option<&TombstoneGeneration>,
permission_still_enabled: impl FnOnce() -> bool,
) -> Result<()> {
ensure_dir(root)?;
with_lock(root, || {
let current_generation = tombstone_generation(root)?;
if current_generation.as_ref() != observed_generation {
anyhow::bail!("telemetry permission changed before arming");
}
if !permission_still_enabled() {
anyhow::bail!("telemetry permission is no longer enabled");
}
let tombstone = tombstone_path(root);
if tombstone.exists() {
fs::remove_file(&tombstone)
.with_context(|| format!("failed to remove {}", tombstone.display()))?;
}
truncate(&buffer_path(root))
})
}