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#[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#[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#[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 #[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}