| 1 | //! Install identity and the constant half of the batch envelope. |
| 2 | |
| 3 | use std::path::Path; |
| 4 | |
| 5 | use anyhow::{Context, Result}; |
| 6 | use serde::{Deserialize, Serialize}; |
| 7 | |
| 8 | use crate::buffer; |
| 9 | use crate::event::{Arch, Libc, Os}; |
| 10 | |
| 11 | /// How long an install id may live before it is replaced. |
| 12 | /// |
| 13 | /// A never-rotating id plus one batch per session from the user's IP is a |
| 14 | /// longitudinal IP and travel trace. Rotation bounds that join. It costs |
| 15 | /// longitudinal accuracy, and the docs say so in those words: **no count derived |
| 16 | /// from `install_id` is a user count.** |
| 17 | pub const ROTATION_DAYS: i64 = 90; |
| 18 | |
| 19 | /// The on-disk install identity. |
| 20 | #[derive(Debug, Clone, Serialize, Deserialize)] |
| 21 | pub struct InstallId { |
| 22 | /// Format version of this file. |
| 23 | pub schema_version: u32, |
| 24 | /// A random v4 UUID. |
| 25 | /// |
| 26 | /// Never derived from hostname, MAC, `machine-id`, `$HOME`, username, or |
| 27 | /// executable path. A derived id is a device fingerprint: it survives |
| 28 | /// reinstall and re-identifies a user across their own opt-out, which is the |
| 29 | /// single thing an install id must never do. |
| 30 | pub install_id: String, |
| 31 | /// When this id was minted, RFC3339 UTC. |
| 32 | pub rotated_at: String, |
| 33 | } |
| 34 | |
| 35 | /// Per-machine telemetry bookkeeping. Never contains anything about the user's |
| 36 | /// work — only what this crate needs to avoid re-reporting an install and to |
| 37 | /// rate-limit its own flushes. |
| 38 | #[derive(Debug, Clone, Default, Serialize, Deserialize)] |
| 39 | pub struct TelemetryState { |
| 40 | /// Format version of this file. |
| 41 | #[serde(default)] |
| 42 | pub schema_version: u32, |
| 43 | /// The app version last seen on this machine. |
| 44 | #[serde(default)] |
| 45 | pub last_version: Option<String>, |
| 46 | /// When a flush was last *attempted*, RFC3339 UTC. Attempt, not success, so |
| 47 | /// a permanently offline machine tries at most once per interval. |
| 48 | #[serde(default)] |
| 49 | pub last_flush: Option<String>, |
| 50 | } |
| 51 | |
| 52 | /// Read the install id, minting a fresh one if it is missing, unreadable, |
| 53 | /// **not a UUID**, or older than [`ROTATION_DAYS`]. |
| 54 | /// |
| 55 | /// The UUID check is not a formatting nicety. `install_id` is the one |
| 56 | /// envelope field read verbatim off disk into a batch, so without it the file |
| 57 | /// is a free-form string slot on the wire for anything that can write |
| 58 | /// `$CODEWHALE_HOME/telemetry/install_id.json`. Minting a fresh random id is |
| 59 | /// always the safe direction — the cost is one rotation, and the docs already |
| 60 | /// say no count derived from `install_id` is a user count. |
| 61 | pub fn read_or_create_install_id(root: &Path) -> Result<InstallId> { |
| 62 | buffer::try_with_lock(root, || { |
| 63 | if buffer::tombstone_present(root) { |
| 64 | anyhow::bail!("telemetry is disabled"); |
| 65 | } |
| 66 | let path = buffer::install_id_path(root); |
| 67 | let existing = std::fs::read_to_string(&path) |
| 68 | .ok() |
| 69 | .and_then(|body| serde_json::from_str::<InstallId>(&body).ok()) |
| 70 | .filter(|record| uuid::Uuid::parse_str(record.install_id.trim()).is_ok()) |
| 71 | .filter(|record| !is_expired(&record.rotated_at)); |
| 72 | if let Some(record) = existing { |
| 73 | return Ok(record); |
| 74 | } |
| 75 | let record = InstallId { |
| 76 | schema_version: 1, |
| 77 | install_id: uuid::Uuid::new_v4().to_string(), |
| 78 | rotated_at: now_rfc3339(), |
| 79 | }; |
| 80 | codewhale_config::persistence::atomic_write_json(&path, &record) |
| 81 | .with_context(|| format!("failed to write {}", path.display()))?; |
| 82 | Ok(record) |
| 83 | })? |
| 84 | .ok_or_else(|| anyhow::anyhow!("telemetry privacy lock is held")) |
| 85 | } |
| 86 | |
| 87 | fn is_expired(rotated_at: &str) -> bool { |
| 88 | let Ok(parsed) = chrono::DateTime::parse_from_rfc3339(rotated_at) else { |
| 89 | // An unreadable timestamp is treated as expired: minting a fresh random |
| 90 | // id is always the safe direction. |
| 91 | return true; |
| 92 | }; |
| 93 | let age = chrono::Utc::now().signed_duration_since(parsed.with_timezone(&chrono::Utc)); |
| 94 | age.num_days() >= ROTATION_DAYS |
| 95 | } |
| 96 | |
| 97 | /// Read `state.json`, or a default when it is missing or unreadable. |
| 98 | #[must_use] |
| 99 | pub fn read_state(root: &Path) -> TelemetryState { |
| 100 | std::fs::read_to_string(buffer::state_path(root)) |
| 101 | .ok() |
| 102 | .and_then(|body| serde_json::from_str::<TelemetryState>(&body).ok()) |
| 103 | .unwrap_or_default() |
| 104 | } |
| 105 | |
| 106 | /// Write `state.json`. |
| 107 | pub fn write_state(root: &Path, state: &TelemetryState) -> Result<()> { |
| 108 | buffer::try_with_lock(root, || { |
| 109 | if buffer::tombstone_present(root) { |
| 110 | anyhow::bail!("telemetry is disabled"); |
| 111 | } |
| 112 | let path = buffer::state_path(root); |
| 113 | codewhale_config::persistence::atomic_write_json(&path, state) |
| 114 | .with_context(|| format!("failed to write {}", path.display())) |
| 115 | })? |
| 116 | .ok_or_else(|| anyhow::anyhow!("telemetry privacy lock is held")) |
| 117 | } |
| 118 | |
| 119 | /// RFC3339 UTC at second precision. The only timestamp this crate produces, and |
| 120 | /// it is per-**batch**: individual events carry no timestamps at all. |
| 121 | #[must_use] |
| 122 | pub fn now_rfc3339() -> String { |
| 123 | chrono::Utc::now() |
| 124 | .to_rfc3339_opts(chrono::SecondsFormat::Secs, true) |
| 125 | .to_string() |
| 126 | } |
| 127 | |
| 128 | /// The build sha of a release-CI binary, or `None`. |
| 129 | /// |
| 130 | /// Sourced from `CODEWHALE_RELEASE_BUILD_SHA`, a rustc-env this crate's build |
| 131 | /// script emits **only** when `CODEWHALE_BUILD_SHA`, its legacy build-only |
| 132 | /// alias, or `GITHUB_SHA` was present in the build environment. `null` for |
| 133 | /// every locally built binary, unconditionally, with no runtime lookup of any |
| 134 | /// kind. |
| 135 | /// |
| 136 | /// Never `CODEWHALE_BUILD_COMMIT` — that falls back to the builder's own `HEAD` |
| 137 | /// on a local build. Never `Thread.git_sha` — that is the *user's* workspace |
| 138 | /// commit and a red line, one identifier away by name. |
| 139 | #[must_use] |
| 140 | pub fn release_build_sha() -> Option<String> { |
| 141 | option_env!("CODEWHALE_RELEASE_BUILD_SHA").and_then(short_hex_sha) |
| 142 | } |
| 143 | |
| 144 | /// Reduce a full sha to the first 12 lowercase hex characters, rejecting |
| 145 | /// anything that is not a sha. |
| 146 | #[must_use] |
| 147 | pub fn short_hex_sha(value: &str) -> Option<String> { |
| 148 | let trimmed = value.trim().to_ascii_lowercase(); |
| 149 | if trimmed.len() < 12 || !trimmed.bytes().all(|b| b.is_ascii_hexdigit()) { |
| 150 | return None; |
| 151 | } |
| 152 | Some(trimmed.chars().take(12).collect()) |
| 153 | } |
| 154 | |
| 155 | /// The OS family this binary is running on, mapped onto the closed whitelist. |
| 156 | #[must_use] |
| 157 | pub fn current_os() -> Os { |
| 158 | match std::env::consts::OS { |
| 159 | "linux" => Os::Linux, |
| 160 | "macos" => Os::Macos, |
| 161 | "windows" => Os::Windows, |
| 162 | "freebsd" => Os::Freebsd, |
| 163 | "android" => Os::Android, |
| 164 | _ => Os::Other, |
| 165 | } |
| 166 | } |
| 167 | |
| 168 | /// The CPU family, mapped onto the closed whitelist. |
| 169 | #[must_use] |
| 170 | pub fn current_arch() -> Arch { |
| 171 | match std::env::consts::ARCH { |
| 172 | "x86_64" => Arch::X86_64, |
| 173 | "aarch64" => Arch::Aarch64, |
| 174 | _ => Arch::Other, |
| 175 | } |
| 176 | } |
| 177 | |
| 178 | /// The libc this binary was **compiled** against. |
| 179 | #[must_use] |
| 180 | pub fn current_libc() -> Libc { |
| 181 | if cfg!(target_env = "gnu") { |
| 182 | Libc::Gnu |
| 183 | } else if cfg!(target_env = "musl") { |
| 184 | Libc::Musl |
| 185 | } else { |
| 186 | Libc::None |
| 187 | } |
| 188 | } |
| 189 | |
| 190 | /// Whether both stdin and stdout are terminals. |
| 191 | /// |
| 192 | /// This varies because consent is machine-scoped: a decision recorded on a TTY |
| 193 | /// authorizes later headless runs on the same home. |
| 194 | #[must_use] |
| 195 | pub fn current_tty() -> bool { |
| 196 | use std::io::IsTerminal as _; |
| 197 | std::io::stdin().is_terminal() && std::io::stdout().is_terminal() |
| 198 | } |
| 199 | |
| 200 | /// Reduce a panic location to something that is safe to send. |
| 201 | /// |
| 202 | /// Emit a `crates/…` path verbatim; reduce **everything else** to the literal |
| 203 | /// `<dep>`. There is no `--remap-path-prefix` in this repo, so a panic inside a |
| 204 | /// registry dependency yields |
| 205 | /// `/Users/<builder>/.cargo/registry/src/…/ratatui-0.29.0/src/…` — the build |
| 206 | /// machine's username, shipped from every user's binary. |
| 207 | /// The allowlist itself lives in [`crate::event::is_reduced_panic_site`], and |
| 208 | /// this function is defined as "the candidate if the predicate accepts it". |
| 209 | /// Two copies of one charset would drift, and the drain path re-checks the |
| 210 | /// predicate against events read back off disk — a reducer that could emit |
| 211 | /// something the checker rejects would silently delete real panics. |
| 212 | #[must_use] |
| 213 | pub fn reduce_panic_site(file: &str, line: u32, column: u32) -> String { |
| 214 | let candidate = format!("{}:{line}:{column}", file.replace('\\', "/")); |
| 215 | if crate::event::is_reduced_panic_site(&candidate) { |
| 216 | candidate |
| 217 | } else { |
| 218 | "<dep>".to_string() |
| 219 | } |
| 220 | } |
| 221 |