openlatch-client 0.3.3

OpenLatch runtime enforcement node — the capture-and-enforce adapter that evaluates every covered action against a coding agent's Autonomy Zone before it runs
//! `~/.openlatch/proxy-shape.json` — the memo that keeps `proxy_configured` rare.
//!
//! The contract says the daemon emits `proxy_configured` at start-up **when the resolved
//! shape differs from the last emitted one**. The memo has to be on disk for that sentence
//! to mean anything: the auto-update path restarts the daemon, so an in-memory memo would
//! re-emit fleet-wide every time a release rolled out, and the event would measure our
//! release cadence rather than the customer's network.
//!
//! # What is compared
//!
//! Exactly the shape properties. `discovery_attempts` is a property of the *event* and is
//! deliberately excluded from the memo: it varies between two runs that resolved the same
//! route, and comparing it would re-emit on every restart — the failure the memo exists to
//! prevent.
//!
//! # Privacy (D-15)
//!
//! Neither the memo nor the event carries a hostname, a port, a URL or a credential. An
//! enterprise's proxy address is internal topology, and this event lands in a third-party
//! analytics store. Every field below is a bounded enum, a bool, or a small integer, and
//! the test at the bottom of this file asserts no value can look like an address.
//!
//! # Failure posture (telemetry invariant I10)
//!
//! A read failure of any kind — missing, unreadable, corrupt — is treated as *absent*, so
//! the shape is emitted once and the memo rewritten. A **write** failure is a `debug!` and
//! nothing else: telemetry bookkeeping must never surface as an operator-facing error.

use std::path::{Path, PathBuf};

use serde::{Deserialize, Serialize};

use crate::core::error::{OlError, ERR_INVALID_CONFIG};

use super::state::EgressSnapshot;

/// The file name inside `~/.openlatch`.
const MEMO_FILE: &str = "proxy-shape.json";

/// The resolved egress shape, as the telemetry event reports it.
///
/// Every field is `Option`-free and `#[serde(default)]`, so a memo written by an older
/// release still parses and only the fields that actually moved trigger a re-emission.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "snake_case")]
pub struct ProxyShape {
    /// Whether a proxy is in the path at all.
    pub proxy_in_use: bool,
    /// `http` | `https` | `socks5` | `pac` | `direct`.
    pub proxy_type: String,
    /// The `proxy.source` value, absent until discovery or `proxy set` writes one.
    pub source: Option<String>,
    /// `none` | `basic` | `negotiate`.
    pub auth_scheme: String,
    /// `native` | `custom`.
    pub ca_source: String,
    /// `None` until a successful TLS handshake has been observed. Absence never means
    /// "not intercepted", which is why it stays an `Option` all the way to the wire.
    pub tls_intercepted: Option<bool>,
    /// `windows` | `macos` | `linux`.
    pub os: String,
}

impl ProxyShape {
    /// The shape a snapshot describes.
    pub fn of(snapshot: &EgressSnapshot) -> Self {
        Self {
            proxy_in_use: snapshot.proxy_in_use,
            proxy_type: snapshot.proxy_type().as_str().to_string(),
            source: snapshot.source.map(|s| s.as_str().to_string()),
            auth_scheme: snapshot.auth_scheme.as_str().to_string(),
            ca_source: snapshot.ca_source.as_str().to_string(),
            tls_intercepted: snapshot.tls_intercepted,
            os: std::env::consts::OS.to_string(),
        }
    }

    /// The memo path inside `dir`.
    pub fn path_in(dir: &Path) -> PathBuf {
        dir.join(MEMO_FILE)
    }

    /// Read the memo, or `None` when there is nothing usable to compare against.
    ///
    /// Missing, unreadable and corrupt all collapse to `None` on purpose: the worst
    /// outcome of treating a damaged memo as absent is one extra event, and the worst
    /// outcome of the alternative — refusing to emit — is a fleet that silently stops
    /// reporting its egress posture.
    pub fn load_from(path: &Path) -> Option<Self> {
        let raw = std::fs::read_to_string(path).ok()?;
        match serde_json::from_str::<Self>(&raw) {
            Ok(shape) => Some(shape),
            Err(e) => {
                tracing::debug!(
                    target: "egress",
                    error = %e,
                    path = %path.display(),
                    "proxy-shape.json is unreadable; treating the shape as never emitted"
                );
                None
            }
        }
    }

    /// Write the memo atomically — temp file then rename, the [`crate::install_state`]
    /// template — so a crash mid-write never leaves a truncated file that would parse as
    /// a different shape.
    pub fn save_to(&self, path: &Path) -> Result<(), OlError> {
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent).map_err(|e| {
                OlError::new(
                    ERR_INVALID_CONFIG,
                    format!("create proxy-shape dir {}: {e}", parent.display()),
                )
            })?;
        }
        let body = serde_json::to_string_pretty(self)
            .map_err(|e| OlError::new(ERR_INVALID_CONFIG, format!("serialise proxy-shape: {e}")))?;
        let tmp = path.with_extension("json.tmp");
        std::fs::write(&tmp, body).map_err(|e| {
            OlError::new(
                ERR_INVALID_CONFIG,
                format!("write proxy-shape tmp {}: {e}", tmp.display()),
            )
        })?;
        std::fs::rename(&tmp, path).map_err(|e| {
            OlError::new(
                ERR_INVALID_CONFIG,
                format!(
                    "rename proxy-shape {} -> {}: {e}",
                    tmp.display(),
                    path.display()
                ),
            )
        })
    }

    /// The `proxy_configured` event for this shape.
    ///
    /// `discovery_attempts` is passed in rather than stored: it belongs to the *emission*
    /// (zero at daemon start, the number of candidates a self-heal pass tried) and is
    /// excluded from the memo comparison for exactly that reason.
    pub fn event(&self, discovery_attempts: u32) -> crate::telemetry::Event {
        crate::telemetry::Event::proxy_configured(
            self.proxy_in_use,
            &self.proxy_type,
            self.source.as_deref(),
            &self.auth_scheme,
            &self.ca_source,
            self.tls_intercepted,
            discovery_attempts,
            &self.os,
        )
    }
}

/// Emit `proxy_configured` if and only if the shape moved, then refresh the memo.
///
/// Returns whether an event was emitted, which is what the tests assert on.
pub fn emit_if_changed(dir: &Path, snapshot: &EgressSnapshot, discovery_attempts: u32) -> bool {
    let path = ProxyShape::path_in(dir);
    let current = ProxyShape::of(snapshot);
    if ProxyShape::load_from(&path).as_ref() == Some(&current) {
        tracing::debug!(
            target: "egress",
            "egress shape is unchanged since the last emission; no proxy_configured"
        );
        return false;
    }

    crate::telemetry::capture_global(current.event(discovery_attempts));

    // Telemetry invariant I10: bookkeeping never becomes an operator-facing failure. The
    // cost of a failed write is one duplicate event on the next start, which is strictly
    // better than a warning about a file nobody asked for.
    if let Err(e) = current.save_to(&path) {
        tracing::debug!(
            target: "egress",
            error = %e.message,
            "could not write proxy-shape.json; the shape may be re-emitted on the next start"
        );
    }
    true
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::core::egress::config::{EgressConfig, EnvSource, ProxyToml};
    use crate::core::egress::state::EgressStatus;

    struct NoEnv;
    impl EnvSource for NoEnv {
        fn var(&self, _key: &str) -> Option<String> {
            None
        }
    }

    fn snapshot_of(toml: ProxyToml) -> EgressSnapshot {
        let cfg = EgressConfig::resolve(Some(&toml), &NoEnv, 7443, 7444).expect("resolve");
        EgressSnapshot::from_config(&cfg)
    }

    fn proxied_snapshot() -> EgressSnapshot {
        snapshot_of(ProxyToml {
            mode: Some("manual".into()),
            url: Some("http://proxy.corp:8080".into()),
            source: Some("windows".into()),
            ..Default::default()
        })
    }

    #[test]
    fn a_direct_shape_reports_direct() {
        let shape = ProxyShape::of(&EgressSnapshot::from_config(&EgressConfig::direct()));
        assert!(!shape.proxy_in_use);
        assert_eq!(shape.proxy_type, "direct");
        assert_eq!(shape.source, None);
        assert_eq!(shape.tls_intercepted, None);
    }

    #[test]
    fn a_pac_source_reports_pac_whatever_the_scheme_is() {
        // The contract's sharpest edge: a PAC file that answers `PROXY host:8080` still
        // produced a PAC-sourced route.
        let shape = ProxyShape::of(&snapshot_of(ProxyToml {
            mode: Some("manual".into()),
            url: Some("http://proxy.corp:8080".into()),
            source: Some("pac".into()),
            ..Default::default()
        }));
        assert_eq!(shape.proxy_type, "pac");
    }

    #[test]
    fn socks_and_https_proxies_keep_their_own_type() {
        for (url, want) in [
            ("socks5://proxy.corp:1080", "socks5"),
            ("socks5h://proxy.corp:1080", "socks5"),
            ("https://proxy.corp:8443", "https"),
            ("http://proxy.corp:8080", "http"),
        ] {
            let shape = ProxyShape::of(&snapshot_of(ProxyToml {
                mode: Some("manual".into()),
                url: Some(url.into()),
                ..Default::default()
            }));
            assert_eq!(shape.proxy_type, want, "{url}");
        }
    }

    #[test]
    fn an_absent_memo_emits_once_and_then_stays_quiet() {
        let dir = tempfile::tempdir().expect("tempdir");
        let snapshot = proxied_snapshot();

        assert!(
            emit_if_changed(dir.path(), &snapshot, 0),
            "the first ever start must emit"
        );
        assert!(
            !emit_if_changed(dir.path(), &snapshot, 0),
            "a restart with an unchanged config must NOT re-emit"
        );
    }

    #[test]
    fn a_corrupt_memo_is_treated_as_absent() {
        let dir = tempfile::tempdir().expect("tempdir");
        std::fs::write(ProxyShape::path_in(dir.path()), "{ not json").expect("write");
        assert!(
            emit_if_changed(dir.path(), &proxied_snapshot(), 0),
            "an unreadable memo must not silence the event"
        );
        // ...and the corrupt file is replaced, so the next start is quiet again.
        assert!(!emit_if_changed(dir.path(), &proxied_snapshot(), 0));
    }

    #[test]
    fn a_changed_shape_emits_again() {
        let dir = tempfile::tempdir().expect("tempdir");
        assert!(emit_if_changed(dir.path(), &proxied_snapshot(), 0));

        let moved = snapshot_of(ProxyToml {
            mode: Some("manual".into()),
            url: Some("socks5://proxy.corp:1080".into()),
            source: Some("gnome".into()),
            ..Default::default()
        });
        assert!(
            emit_if_changed(dir.path(), &moved, 3),
            "a different proxy_type + source is a different shape"
        );
    }

    #[test]
    fn the_status_field_is_not_part_of_the_shape() {
        // `status` moves with every transient failure. If it were compared, a flapping
        // link would emit `proxy_configured` on every restart during the flap.
        let dir = tempfile::tempdir().expect("tempdir");
        let mut snapshot = proxied_snapshot();
        assert!(emit_if_changed(dir.path(), &snapshot, 0));

        snapshot.status = EgressStatus::Failed;
        assert!(
            !emit_if_changed(dir.path(), &snapshot, 0),
            "health is not shape"
        );
    }

    #[test]
    fn discovery_attempts_never_re_emits_on_its_own() {
        let dir = tempfile::tempdir().expect("tempdir");
        let snapshot = proxied_snapshot();
        assert!(emit_if_changed(dir.path(), &snapshot, 0));
        assert!(
            !emit_if_changed(dir.path(), &snapshot, 7),
            "discovery_attempts is excluded from the comparison"
        );
    }

    /// D-15, asserted rather than trusted: no property of the event may look like an
    /// address. The proxy URL is in scope for the snapshot and must not survive into the
    /// payload.
    #[test]
    fn no_event_property_can_carry_an_address() {
        let snapshot = snapshot_of(ProxyToml {
            mode: Some("manual".into()),
            url: Some("http://proxy.corp:8080".into()),
            username: Some("alice".into()),
            auth: Some("basic".into()),
            source: Some("windows".into()),
            ..Default::default()
        });
        let event = ProxyShape::of(&snapshot).event(2);
        let payload = serde_json::to_string(&event.properties).expect("serialise");

        for forbidden in ["proxy.corp", "8080", "://", "alice", "@"] {
            assert!(
                !payload.contains(forbidden),
                "proxy_configured leaked {forbidden:?}: {payload}"
            );
        }
        assert_eq!(event.name, "proxy_configured");
    }

    #[test]
    fn the_tls_property_is_omitted_until_an_observation_exists() {
        let mut snapshot = proxied_snapshot();
        assert!(!ProxyShape::of(&snapshot)
            .event(0)
            .properties
            .contains_key("tls_intercepted"));

        snapshot.tls_intercepted = Some(true);
        let event = ProxyShape::of(&snapshot).event(0);
        assert_eq!(
            event.properties.get("tls_intercepted"),
            Some(&serde_json::json!(true))
        );
    }

    #[test]
    fn the_memo_round_trips_through_disk() {
        let dir = tempfile::tempdir().expect("tempdir");
        let path = ProxyShape::path_in(dir.path());
        let shape = ProxyShape::of(&proxied_snapshot());
        shape.save_to(&path).expect("save");
        assert_eq!(ProxyShape::load_from(&path).as_ref(), Some(&shape));
        // The atomic write leaves nothing behind.
        assert!(!path.with_extension("json.tmp").exists());
    }
}