Skip to main content

gephyr_lib/proxy/parity/
types.rs

1use serde::{Deserialize, Serialize};
2use std::collections::BTreeMap;
3
4pub const PARITY_SCHEMA_VERSION: &str = "v1";
5
6fn default_schema_version() -> String {
7    PARITY_SCHEMA_VERSION.to_string()
8}
9
10/// Source of a fingerprinted request.
11#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
12#[serde(rename_all = "snake_case")]
13pub enum RequestSource {
14    Gephyr,
15    KnownGood,
16    AntigravityExe,
17    LanguageServerWindowsX64,
18    Unknown,
19}
20
21impl RequestSource {
22    pub fn compare_bucket(&self) -> &'static str {
23        match self {
24            RequestSource::Gephyr => "gephyr",
25            RequestSource::KnownGood
26            | RequestSource::AntigravityExe
27            | RequestSource::LanguageServerWindowsX64 => "official",
28            RequestSource::Unknown => "unknown",
29        }
30    }
31
32    pub fn as_str(&self) -> &'static str {
33        match self {
34            RequestSource::Gephyr => "gephyr",
35            RequestSource::KnownGood => "known_good",
36            RequestSource::AntigravityExe => "antigravity_exe",
37            RequestSource::LanguageServerWindowsX64 => "language_server_windows_x64",
38            RequestSource::Unknown => "unknown",
39        }
40    }
41}
42
43/// Recursive JSON structural skeleton (keys and JSON shape, no values).
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(rename_all = "snake_case")]
46pub enum BodyShape {
47    Null,
48    Bool,
49    Number,
50    String,
51    Array(Box<BodyShape>),
52    Object(BTreeMap<String, BodyShape>),
53}
54
55impl BodyShape {
56    pub fn from_value(value: &serde_json::Value) -> Self {
57        match value {
58            serde_json::Value::Null => BodyShape::Null,
59            serde_json::Value::Bool(_) => BodyShape::Bool,
60            serde_json::Value::Number(_) => BodyShape::Number,
61            serde_json::Value::String(_) => BodyShape::String,
62            serde_json::Value::Array(arr) => {
63                let element = arr
64                    .first()
65                    .map(BodyShape::from_value)
66                    .unwrap_or(BodyShape::Null);
67                BodyShape::Array(Box::new(element))
68            }
69            serde_json::Value::Object(map) => {
70                let children: BTreeMap<String, BodyShape> = map
71                    .iter()
72                    .map(|(k, v)| (k.clone(), BodyShape::from_value(v)))
73                    .collect();
74                BodyShape::Object(children)
75            }
76        }
77    }
78}
79
80/// Canonical fingerprint schema v1.
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct RequestFingerprint {
83    #[serde(default = "default_schema_version")]
84    pub schema_version: String,
85    #[serde(default)]
86    pub capture_session_id: Option<String>,
87    pub source: RequestSource,
88    pub method: String,
89    pub url: String,
90    #[serde(default)]
91    pub normalized_endpoint: String,
92    /// Header names lowercased and sorted.
93    #[serde(default)]
94    pub headers: Vec<(String, String)>,
95    #[serde(default)]
96    pub body_shape: Option<BodyShape>,
97    #[serde(default)]
98    pub timestamp_ms: Option<u64>,
99    #[serde(default)]
100    pub latency_ms: Option<u64>,
101    #[serde(default)]
102    pub status_code: Option<u16>,
103}
104
105impl RequestFingerprint {
106    pub fn new(
107        source: RequestSource,
108        method: String,
109        url: String,
110        normalized_endpoint: String,
111        headers: Vec<(String, String)>,
112        body_shape: Option<BodyShape>,
113        timestamp_ms: Option<u64>,
114        latency_ms: Option<u64>,
115        status_code: Option<u16>,
116        capture_session_id: Option<String>,
117    ) -> Self {
118        Self {
119            schema_version: default_schema_version(),
120            capture_session_id,
121            source,
122            method,
123            url,
124            normalized_endpoint,
125            headers,
126            body_shape,
127            timestamp_ms,
128            latency_ms,
129            status_code,
130        }
131    }
132}
133
134#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
135#[serde(rename_all = "snake_case")]
136pub enum ParityRule {
137    MustMatch,
138    AllowedDrift { max_delta_ms: Option<u64> },
139    Ignore,
140}
141
142#[derive(Debug, Clone, Serialize, Deserialize)]
143pub struct EndpointRule {
144    pub endpoint_pattern: String,
145    #[serde(default)]
146    pub header_rules: BTreeMap<String, ParityRule>,
147    #[serde(default)]
148    pub default_header_rule: Option<ParityRule>,
149    #[serde(default)]
150    pub body_shape_rule: Option<ParityRule>,
151    #[serde(default)]
152    pub timing_rule: Option<ParityRule>,
153    #[serde(default)]
154    pub status_code_rule: Option<ParityRule>,
155}
156
157impl EndpointRule {
158    pub fn matches(&self, endpoint: &str) -> bool {
159        wildcard_match(
160            &self.endpoint_pattern.to_ascii_lowercase(),
161            &endpoint.to_ascii_lowercase(),
162        )
163    }
164}
165
166#[derive(Debug, Clone, Serialize, Deserialize)]
167pub struct CanonicalizationConfig {
168    #[serde(default = "default_true")]
169    pub collapse_daily_cloudcode_host: bool,
170    #[serde(default = "default_true")]
171    pub collapse_local_mock_google_hosts: bool,
172    #[serde(default = "default_true")]
173    pub normalize_antigravity_user_agent_version: bool,
174    #[serde(default = "default_true")]
175    pub normalize_header_keys: bool,
176    #[serde(default = "default_true")]
177    pub normalize_header_order: bool,
178    #[serde(default = "default_true")]
179    pub normalize_query_order: bool,
180    #[serde(default = "default_true")]
181    pub redact_sensitive_values: bool,
182    #[serde(default = "default_true")]
183    pub normalize_volatile_ids: bool,
184    #[serde(default = "default_true")]
185    pub treat_null_body_shape_as_missing: bool,
186    #[serde(default = "default_true")]
187    pub ignore_missing_body_shape: bool,
188    #[serde(default = "default_true")]
189    pub ignore_missing_status_code: bool,
190    #[serde(default = "default_true")]
191    pub ignore_missing_latency: bool,
192    #[serde(default = "default_timing_bucket_ms")]
193    pub timing_bucket_ms: Option<u64>,
194}
195
196impl Default for CanonicalizationConfig {
197    fn default() -> Self {
198        Self {
199            collapse_daily_cloudcode_host: true,
200            collapse_local_mock_google_hosts: true,
201            normalize_antigravity_user_agent_version: true,
202            normalize_header_keys: true,
203            normalize_header_order: true,
204            normalize_query_order: true,
205            redact_sensitive_values: true,
206            normalize_volatile_ids: true,
207            treat_null_body_shape_as_missing: true,
208            ignore_missing_body_shape: true,
209            ignore_missing_status_code: true,
210            ignore_missing_latency: true,
211            timing_bucket_ms: default_timing_bucket_ms(),
212        }
213    }
214}
215
216fn default_true() -> bool {
217    true
218}
219
220fn default_timing_bucket_ms() -> Option<u64> {
221    Some(100)
222}
223
224#[derive(Debug, Clone, Serialize, Deserialize)]
225pub struct ParityRuleSet {
226    #[serde(default)]
227    pub header_rules: BTreeMap<String, ParityRule>,
228    #[serde(default)]
229    pub endpoint_rules: Vec<EndpointRule>,
230    pub timing_rule: ParityRule,
231    pub body_shape_rule: ParityRule,
232    pub status_code_rule: ParityRule,
233    pub default_header_rule: ParityRule,
234    #[serde(default)]
235    pub canonicalization: CanonicalizationConfig,
236}
237
238impl Default for ParityRuleSet {
239    fn default() -> Self {
240        Self::with_standard_rules()
241    }
242}
243
244impl ParityRuleSet {
245    pub fn with_standard_rules() -> Self {
246        let mut header_rules = BTreeMap::new();
247
248        for name in &[
249            "authorization",
250            "user-agent",
251            "x-goog-api-client",
252            "content-type",
253            "accept-encoding",
254            "x-machine-id",
255            "x-mac-machine-id",
256            "x-dev-device-id",
257            "x-sqm-id",
258            "host",
259        ] {
260            header_rules.insert((*name).to_string(), ParityRule::MustMatch);
261        }
262
263        for name in &[
264            "content-length",
265            "connection",
266            "date",
267            "transfer-encoding",
268            "x-request-id",
269            "x-correlation-id",
270        ] {
271            header_rules.insert((*name).to_string(), ParityRule::Ignore);
272        }
273
274        Self {
275            header_rules,
276            endpoint_rules: Vec::new(),
277            timing_rule: ParityRule::AllowedDrift {
278                max_delta_ms: Some(5000),
279            },
280            body_shape_rule: ParityRule::MustMatch,
281            status_code_rule: ParityRule::MustMatch,
282            default_header_rule: ParityRule::MustMatch,
283            canonicalization: CanonicalizationConfig::default(),
284        }
285    }
286
287    fn endpoint_rule_for(&self, endpoint: &str) -> Option<&EndpointRule> {
288        self.endpoint_rules
289            .iter()
290            .find(|rule| rule.matches(endpoint))
291    }
292
293    pub fn rule_for_header(&self, endpoint: &str, name: &str) -> ParityRule {
294        let header_name = name.to_ascii_lowercase();
295
296        if let Some(endpoint_rule) = self.endpoint_rule_for(endpoint) {
297            if let Some(rule) = endpoint_rule.header_rules.get(&header_name) {
298                return rule.clone();
299            }
300            if let Some(rule) = endpoint_rule.default_header_rule.clone() {
301                return rule;
302            }
303        }
304
305        self.header_rules
306            .get(&header_name)
307            .cloned()
308            .unwrap_or_else(|| self.default_header_rule.clone())
309    }
310
311    pub fn body_shape_rule_for(&self, endpoint: &str) -> ParityRule {
312        self.endpoint_rule_for(endpoint)
313            .and_then(|rule| rule.body_shape_rule.clone())
314            .unwrap_or_else(|| self.body_shape_rule.clone())
315    }
316
317    pub fn timing_rule_for(&self, endpoint: &str) -> ParityRule {
318        self.endpoint_rule_for(endpoint)
319            .and_then(|rule| rule.timing_rule.clone())
320            .unwrap_or_else(|| self.timing_rule.clone())
321    }
322
323    pub fn status_code_rule_for(&self, endpoint: &str) -> ParityRule {
324        self.endpoint_rule_for(endpoint)
325            .and_then(|rule| rule.status_code_rule.clone())
326            .unwrap_or_else(|| self.status_code_rule.clone())
327    }
328}
329
330fn wildcard_match(pattern: &str, text: &str) -> bool {
331    if pattern.is_empty() {
332        return text.is_empty();
333    }
334    if pattern == "*" {
335        return true;
336    }
337
338    let parts: Vec<&str> = pattern.split('*').collect();
339    if parts.len() == 1 {
340        return pattern == text;
341    }
342
343    let mut cursor = 0usize;
344    let anchored_start = !pattern.starts_with('*');
345    let anchored_end = !pattern.ends_with('*');
346
347    for (idx, part) in parts.iter().enumerate() {
348        if part.is_empty() {
349            continue;
350        }
351
352        if idx == 0 && anchored_start {
353            if !text[cursor..].starts_with(part) {
354                return false;
355            }
356            cursor += part.len();
357            continue;
358        }
359
360        if let Some(found) = text[cursor..].find(*part) {
361            cursor += found + part.len();
362        } else {
363            return false;
364        }
365    }
366
367    if anchored_end {
368        if let Some(last) = parts.iter().rev().find(|part| !part.is_empty()) {
369            return text.ends_with(last);
370        }
371    }
372
373    true
374}
375
376#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
377#[serde(rename_all = "snake_case")]
378pub enum GatePolicy {
379    AnyDifferenceFails,
380}
381
382impl Default for GatePolicy {
383    fn default() -> Self {
384        Self::AnyDifferenceFails
385    }
386}
387
388#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
389#[serde(rename_all = "snake_case")]
390pub enum MismatchSeverity {
391    Fail,
392    Drift,
393    Info,
394}
395
396#[derive(Debug, Clone, Serialize, Deserialize)]
397pub struct FieldMismatch {
398    pub group: String,
399    pub field: String,
400    pub severity: MismatchSeverity,
401    pub rule: String,
402    pub gephyr_value: Option<String>,
403    pub known_good_value: Option<String>,
404    pub detail: String,
405}
406
407#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
408#[serde(rename_all = "snake_case")]
409pub enum Verdict {
410    Pass,
411    Drift,
412    Fail,
413}
414
415#[derive(Debug, Clone, Serialize, Deserialize)]
416pub struct EndpointVerdict {
417    pub endpoint: String,
418    pub method: String,
419    pub source_bucket: String,
420    pub verdict: Verdict,
421    pub gephyr_count: usize,
422    pub known_good_count: usize,
423    pub mismatches: Vec<FieldMismatch>,
424}
425
426#[derive(Debug, Clone, Serialize, Deserialize)]
427pub struct ParityDiffReport {
428    #[serde(default = "default_schema_version")]
429    pub schema_version: String,
430    pub generated_at: String,
431    pub gate_policy: GatePolicy,
432    pub gate_pass: bool,
433    pub gephyr_fingerprints_count: usize,
434    pub known_good_fingerprints_count: usize,
435    pub endpoint_count: usize,
436    pub endpoints: Vec<EndpointVerdict>,
437    pub overall_verdict: Verdict,
438    pub compliance_score: f64,
439}
440
441#[derive(Debug, Clone, Serialize, Deserialize)]
442pub struct ParityCaptureStatus {
443    pub enabled: bool,
444    pub session_id: Option<String>,
445    pub started_at: Option<String>,
446    pub captured_count: usize,
447    pub ring_limit: usize,
448}
449
450#[derive(Debug, Clone, Serialize, Deserialize)]
451pub struct ParityExportResult {
452    pub raw_path: String,
453    pub redacted_path: String,
454    pub count: usize,
455    pub session_id: Option<String>,
456}
457
458#[cfg(test)]
459mod tests {
460    use super::*;
461    use serde_json::json;
462
463    #[test]
464    fn body_shape_extracts_json_key_skeleton() {
465        let value = json!({
466            "project": "test",
467            "metadata": {
468                "ideType": "ANTIGRAVITY",
469                "platform": "PLATFORM_UNSPECIFIED"
470            }
471        });
472        let shape = BodyShape::from_value(&value);
473        match &shape {
474            BodyShape::Object(map) => {
475                assert!(map.contains_key("project"));
476                assert!(map.contains_key("metadata"));
477            }
478            other => panic!("expected Object, got {:?}", other),
479        }
480    }
481
482    #[test]
483    fn fingerprint_serializes_schema_v1() {
484        let fp = RequestFingerprint::new(
485            RequestSource::Gephyr,
486            "POST".to_string(),
487            "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist".to_string(),
488            "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist".to_string(),
489            vec![("content-type".to_string(), "application/json".to_string())],
490            Some(BodyShape::from_value(&json!({"project": "x"}))),
491            Some(1),
492            Some(2),
493            Some(200),
494            Some("session-1".to_string()),
495        );
496        let serialized = serde_json::to_string(&fp).expect("serialize");
497        assert!(serialized.contains(PARITY_SCHEMA_VERSION));
498        assert!(serialized.contains("loadCodeAssist"));
499    }
500
501    #[test]
502    fn wildcard_endpoint_rule_match_works() {
503        let rule = EndpointRule {
504            endpoint_pattern: "https://*.googleapis.com/*loadCodeAssist*".to_string(),
505            header_rules: BTreeMap::new(),
506            default_header_rule: None,
507            body_shape_rule: None,
508            timing_rule: None,
509            status_code_rule: None,
510        };
511        assert!(
512            rule.matches("https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist?alt=sse")
513        );
514        assert!(!rule.matches("https://example.com/a"));
515    }
516
517    #[test]
518    fn default_rules_match_expected_header_classes() {
519        let rules = ParityRuleSet::default();
520        assert_eq!(
521            rules.rule_for_header("https://x.googleapis.com", "user-agent"),
522            ParityRule::MustMatch
523        );
524        assert_eq!(
525            rules.rule_for_header("https://x.googleapis.com", "content-length"),
526            ParityRule::Ignore
527        );
528    }
529
530    #[test]
531    fn report_serializes_gate_policy() {
532        let report = ParityDiffReport {
533            schema_version: PARITY_SCHEMA_VERSION.to_string(),
534            generated_at: "2026-03-01T20:00:00Z".to_string(),
535            gate_policy: GatePolicy::AnyDifferenceFails,
536            gate_pass: true,
537            gephyr_fingerprints_count: 1,
538            known_good_fingerprints_count: 1,
539            endpoint_count: 1,
540            endpoints: vec![],
541            overall_verdict: Verdict::Pass,
542            compliance_score: 1.0,
543        };
544        let json = serde_json::to_string_pretty(&report).expect("serialize report");
545        assert!(json.contains("any_difference_fails"));
546    }
547}