use std::path::{Path, PathBuf};
use codewhale_config::{ResolvedRuntimeOptions, SetupState};
use crate::buffer;
use crate::event::Surface;
pub const TELEMETRY_DIR: &str = "telemetry";
#[derive(Debug)]
pub enum TelemetryDecision {
Enabled(TelemetryConsent),
OptedOut,
ForcedOff,
}
impl TelemetryDecision {
#[must_use]
pub fn is_enabled(&self) -> bool {
matches!(self, Self::Enabled(_))
}
#[must_use]
pub fn label(&self) -> &'static str {
match self {
Self::Enabled(_) => "enabled",
Self::OptedOut => "opted_out",
Self::ForcedOff => "forced_off",
}
}
}
#[derive(Debug)]
pub struct TelemetryConsent {
root: PathBuf,
endpoint: Option<String>,
surface: Surface,
config_path: Option<PathBuf>,
tombstone_generation: Option<buffer::TombstoneGeneration>,
}
impl TelemetryConsent {
#[must_use]
pub fn with_config_path(mut self, config_path: Option<PathBuf>) -> Self {
self.config_path = config_path;
self
}
#[must_use]
pub fn config_path(&self) -> Option<&Path> {
self.config_path.as_deref()
}
#[must_use]
pub fn root(&self) -> &Path {
&self.root
}
#[must_use]
pub fn endpoint(&self) -> Option<&str> {
self.endpoint.as_deref()
}
#[must_use]
pub fn surface(&self) -> Surface {
self.surface
}
pub(crate) fn tombstone_generation(&self) -> Option<&buffer::TombstoneGeneration> {
self.tombstone_generation.as_ref()
}
}
enum TelemetryEvaluation {
Enabled {
root: PathBuf,
endpoint: Option<String>,
tombstone_generation: Option<buffer::TombstoneGeneration>,
},
OptedOut(Option<PathBuf>),
ForcedOff,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EndpointError {
Unparseable,
InsecureScheme,
UnsupportedScheme,
}
impl EndpointError {
#[must_use]
pub fn label(self) -> &'static str {
match self {
Self::Unparseable => "unparseable",
Self::InsecureScheme => "plaintext http to a non-loopback host",
Self::UnsupportedScheme => "scheme is neither https nor http",
}
}
}
pub fn validate_endpoint(raw: &str) -> Result<String, EndpointError> {
let trimmed = raw.trim();
let url = reqwest::Url::parse(trimmed).map_err(|_| EndpointError::Unparseable)?;
match url.scheme() {
"https" => Ok(trimmed.to_string()),
"http" => {
if is_loopback_host(url.host_str()) {
Ok(trimmed.to_string())
} else {
Err(EndpointError::InsecureScheme)
}
}
_ => Err(EndpointError::UnsupportedScheme),
}
}
fn is_loopback_host(host: Option<&str>) -> bool {
let Some(host) = host else {
return false;
};
let bare = host.trim_start_matches('[').trim_end_matches(']');
match bare.parse::<std::net::IpAddr>() {
Ok(address) => address.is_loopback(),
Err(_) => bare.eq_ignore_ascii_case("localhost"),
}
}
pub fn decide(
resolved: &ResolvedRuntimeOptions,
setup: &SetupState,
surface: Surface,
) -> TelemetryDecision {
let home = codewhale_paths::codewhale_home().ok().flatten();
decide_in_home(home.as_deref(), resolved, setup, surface)
}
#[must_use]
pub fn load_setup_state_for_decision() -> Option<SetupState> {
let path = SetupState::path().ok()?;
load_setup_state_for_decision_at(&path)
}
#[must_use]
pub fn load_setup_state_for_decision_at(path: &Path) -> Option<SetupState> {
match path.try_exists() {
Ok(false) => Some(SetupState::default()),
Ok(true) => SetupState::load_from(path),
Err(_) => None,
}
}
pub fn decide_in_home(
home: Option<&Path>,
resolved: &ResolvedRuntimeOptions,
setup: &SetupState,
surface: Surface,
) -> TelemetryDecision {
match evaluate_in_home(home, resolved, setup) {
TelemetryEvaluation::Enabled {
root,
endpoint,
tombstone_generation,
} => TelemetryDecision::Enabled(TelemetryConsent {
root,
endpoint,
surface,
config_path: None,
tombstone_generation,
}),
TelemetryEvaluation::OptedOut(root) => opted_out(root.as_deref()),
TelemetryEvaluation::ForcedOff => TelemetryDecision::ForcedOff,
}
}
fn evaluate_in_home(
home: Option<&Path>,
resolved: &ResolvedRuntimeOptions,
setup: &SetupState,
) -> TelemetryEvaluation {
let root = home.map(|home| home.join(TELEMETRY_DIR));
if !resolved.telemetry {
if resolved.telemetry_explicit_off {
return TelemetryEvaluation::OptedOut(root);
}
return TelemetryEvaluation::ForcedOff;
}
if setup.telemetry_opted_out() {
return TelemetryEvaluation::OptedOut(root);
}
let Some(root) = root else {
return TelemetryEvaluation::ForcedOff;
};
let endpoint = match resolved.telemetry_endpoint.as_deref() {
Some(raw) if !raw.trim().is_empty() => match validate_endpoint(raw) {
Ok(endpoint) => Some(endpoint),
Err(error) => {
tracing::warn!(
"telemetry endpoint refused ({}); telemetry is off for this run",
error.label()
);
return TelemetryEvaluation::ForcedOff;
}
},
_ => None,
};
let Ok(tombstone_generation) = buffer::tombstone_generation(&root) else {
return TelemetryEvaluation::ForcedOff;
};
TelemetryEvaluation::Enabled {
root,
endpoint,
tombstone_generation,
}
}
pub(crate) fn permission_still_enabled(config_path: Option<&Path>, expected_root: &Path) -> bool {
let Ok(setup_path) = SetupState::path() else {
return false;
};
let home = codewhale_paths::codewhale_home().ok().flatten();
permission_still_enabled_in_home(config_path, &setup_path, home.as_deref(), expected_root)
}
pub(crate) fn permission_still_enabled_in_home(
config_path: Option<&Path>,
setup_path: &Path,
home: Option<&Path>,
expected_root: &Path,
) -> bool {
let Ok(store) = codewhale_config::ConfigStore::load(config_path.map(Path::to_path_buf)) else {
return false;
};
let resolved = store
.config
.resolve_runtime_options(&codewhale_config::CliRuntimeOverrides::default());
let Some(setup) = load_setup_state_for_decision_at(setup_path) else {
return false;
};
matches!(
evaluate_in_home(home, &resolved, &setup),
TelemetryEvaluation::Enabled { root, .. } if root == expected_root
)
}
#[must_use]
pub fn re_decide(config_path: Option<&Path>, surface: Surface) -> TelemetryDecision {
let Ok(setup_path) = SetupState::path() else {
return TelemetryDecision::ForcedOff;
};
re_decide_with_setup_path(config_path, &setup_path, surface)
}
pub(crate) fn re_decide_with_setup_path(
config_path: Option<&Path>,
setup_path: &Path,
surface: Surface,
) -> TelemetryDecision {
let Ok(store) = codewhale_config::ConfigStore::load(config_path.map(Path::to_path_buf)) else {
return TelemetryDecision::ForcedOff;
};
let resolved = store
.config
.resolve_runtime_options(&codewhale_config::CliRuntimeOverrides::default());
let Some(setup) = load_setup_state_for_decision_at(setup_path) else {
return TelemetryDecision::ForcedOff;
};
decide(&resolved, &setup, surface)
}
fn opted_out(root: Option<&Path>) -> TelemetryDecision {
if let Some(root) = root
&& root.is_dir()
&& let Err(error) = buffer::wipe(root)
{
tracing::warn!("telemetry opt-out wipe was incomplete: {error}");
}
TelemetryDecision::OptedOut
}