返回 CodeWhale
envelope.rs
根目录 / crates / telemetry / src / envelope.rs
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| is_canonical_random_uuid(&record.install_id))
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 /// Exactly what [`read_or_create_install_id`] mints: a random (v4, RFC 4122
88 /// variant) UUID in lowercase hyphenated form, byte for byte.
89 ///
90 /// `Uuid::parse_str` alone also accepts surrounding whitespace after a trim,
91 /// braced/URN/simple spellings (each a different wire string for one id),
92 /// the nil UUID, and v1 ids, which embed a MAC address and a clock — a device
93 /// fingerprint, the one thing an install id must never be.
94 fn is_canonical_random_uuid(value: &str) -> bool {
95 uuid::Uuid::parse_str(value).is_ok_and(|id| {
96 id.get_version() == Some(uuid::Version::Random)
97 && id.get_variant() == uuid::Variant::RFC4122
98 && id.hyphenated().to_string() == value
99 })
100 }
101
102 fn is_expired(rotated_at: &str) -> bool {
103 let Ok(parsed) = chrono::DateTime::parse_from_rfc3339(rotated_at) else {
104 // An unreadable timestamp is treated as expired: minting a fresh random
105 // id is always the safe direction.
106 return true;
107 };
108 let age = chrono::Utc::now().signed_duration_since(parsed.with_timezone(&chrono::Utc));
109 // A future `rotated_at` has a negative age that would never reach the
110 // rotation window, pinning the id forever. It is expired, not fresh.
111 age < chrono::TimeDelta::zero() || age.num_days() >= ROTATION_DAYS
112 }
113
114 /// Read `state.json`, or a default when it is missing or unreadable.
115 #[must_use]
116 pub fn read_state(root: &Path) -> TelemetryState {
117 std::fs::read_to_string(buffer::state_path(root))
118 .ok()
119 .and_then(|body| serde_json::from_str::<TelemetryState>(&body).ok())
120 .unwrap_or_default()
121 }
122
123 /// Write `state.json`.
124 pub fn write_state(root: &Path, state: &TelemetryState) -> Result<()> {
125 buffer::try_with_lock(root, || {
126 if buffer::tombstone_present(root) {
127 anyhow::bail!("telemetry is disabled");
128 }
129 let path = buffer::state_path(root);
130 codewhale_config::persistence::atomic_write_json(&path, state)
131 .with_context(|| format!("failed to write {}", path.display()))
132 })?
133 .ok_or_else(|| anyhow::anyhow!("telemetry privacy lock is held"))
134 }
135
136 /// RFC3339 UTC at second precision. The only timestamp this crate produces, and
137 /// it is per-**batch**: individual events carry no timestamps at all.
138 #[must_use]
139 pub fn now_rfc3339() -> String {
140 chrono::Utc::now()
141 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
142 .to_string()
143 }
144
145 /// The build sha of a release-CI binary, or `None`.
146 ///
147 /// Sourced from `CODEWHALE_RELEASE_BUILD_SHA`, a rustc-env this crate's build
148 /// script emits **only** when `CODEWHALE_BUILD_SHA` or its legacy build-only
149 /// alias was present in the build environment (never the ambient
150 /// `GITHUB_SHA` every Actions job carries). `null` for
151 /// every locally built binary, unconditionally, with no runtime lookup of any
152 /// kind.
153 ///
154 /// Never `CODEWHALE_BUILD_COMMIT` — that falls back to the builder's own `HEAD`
155 /// on a local build. Never `Thread.git_sha` — that is the *user's* workspace
156 /// commit and a red line, one identifier away by name.
157 #[must_use]
158 pub fn release_build_sha() -> Option<String> {
159 option_env!("CODEWHALE_RELEASE_BUILD_SHA").and_then(short_hex_sha)
160 }
161
162 /// Reduce a full sha to the first 12 lowercase hex characters, rejecting
163 /// anything that is not a sha.
164 #[must_use]
165 pub fn short_hex_sha(value: &str) -> Option<String> {
166 let trimmed = value.trim().to_ascii_lowercase();
167 if trimmed.len() < 12 || !trimmed.bytes().all(|b| b.is_ascii_hexdigit()) {
168 return None;
169 }
170 Some(trimmed.chars().take(12).collect())
171 }
172
173 /// The OS family this binary is running on, mapped onto the closed whitelist.
174 #[must_use]
175 pub fn current_os() -> Os {
176 match std::env::consts::OS {
177 "linux" => Os::Linux,
178 "macos" => Os::Macos,
179 "windows" => Os::Windows,
180 "freebsd" => Os::Freebsd,
181 "android" => Os::Android,
182 _ => Os::Other,
183 }
184 }
185
186 /// The CPU family, mapped onto the closed whitelist.
187 #[must_use]
188 pub fn current_arch() -> Arch {
189 match std::env::consts::ARCH {
190 "x86_64" => Arch::X86_64,
191 "aarch64" => Arch::Aarch64,
192 _ => Arch::Other,
193 }
194 }
195
196 /// The libc this binary was **compiled** against.
197 #[must_use]
198 pub fn current_libc() -> Libc {
199 if cfg!(target_env = "gnu") {
200 Libc::Gnu
201 } else if cfg!(target_env = "musl") {
202 Libc::Musl
203 } else {
204 Libc::None
205 }
206 }
207
208 /// Whether both stdin and stdout are terminals.
209 ///
210 /// This varies because consent is machine-scoped: a decision recorded on a TTY
211 /// authorizes later headless runs on the same home.
212 #[must_use]
213 pub fn current_tty() -> bool {
214 use std::io::IsTerminal as _;
215 std::io::stdin().is_terminal() && std::io::stdout().is_terminal()
216 }
217
218 /// Reduce a panic location to something that is safe to send.
219 ///
220 /// Emit a `crates/…` path verbatim; reduce **everything else** to the literal
221 /// `<dep>`. There is no `--remap-path-prefix` in this repo, so a panic inside a
222 /// registry dependency yields
223 /// `/Users/<builder>/.cargo/registry/src/…/ratatui-0.29.0/src/…` — the build
224 /// machine's username, shipped from every user's binary.
225 /// The allowlist itself lives in [`crate::event::is_reduced_panic_site`], and
226 /// this function is defined as "the candidate if the predicate accepts it".
227 /// Two copies of one charset would drift, and the drain path re-checks the
228 /// predicate against events read back off disk — a reducer that could emit
229 /// something the checker rejects would silently delete real panics.
230 #[must_use]
231 pub fn reduce_panic_site(file: &str, line: u32, column: u32) -> String {
232 let candidate = format!("{}:{line}:{column}", file.replace('\\', "/"));
233 if crate::event::is_reduced_panic_site(&candidate) {
234 candidate
235 } else {
236 "<dep>".to_string()
237 }
238 }
239
239 lines RUST