Skip to main content

eggress_pproxy_compat/
diagnostics.rs

1use std::fmt;
2
3use serde::Serialize;
4
5use crate::error::CompatError;
6use crate::warnings::CompatWarning;
7
8/// Stable diagnostic codes for the pproxy compatibility layer.
9///
10/// Each code corresponds to a class of translation issue. These codes are
11/// designed for JSON output, test assertions, and documentation cross-references.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
13#[serde(rename_all = "snake_case")]
14pub enum DiagnosticCode {
15    /// The pproxy protocol/scheme is not implemented in eggress.
16    UnsupportedProtocol,
17    /// A transport wrapper (e.g. `+tls`) is not supported for this context.
18    UnsupportedTransportWrapper,
19    /// A pproxy flag or option is not recognized or not mappable.
20    UnsupportedFlag,
21    /// The feature requires platform-specific capabilities not available.
22    UnsupportedPlatform,
23    /// A legacy or security-sensitive feature (e.g. SSR UDP, stream ciphers) is intentionally unsupported.
24    UnsupportedSecuritySensitiveLegacyFeature,
25    /// The pproxy URI or argument syntax is malformed.
26    InvalidUriSyntax,
27    /// The resulting chain composition is invalid (e.g. conflicting protocols).
28    InvalidChainComposition,
29    /// A required target address or endpoint is missing.
30    MissingTarget,
31    /// A required credential (password, key) is missing.
32    MissingCredential,
33    /// The specified cipher or encryption method is not supported.
34    InvalidCipherMethod,
35    /// Binding to the requested address failed.
36    BindFailure,
37    /// Required OS capabilities (e.g. `CAP_NET_ADMIN`) are missing.
38    PrivilegeCapabilityMissing,
39    /// An external dependency required for this feature is not available.
40    ExternalDependencyMissing,
41    /// A rule file could not be read or parsed.
42    RulefileError,
43    /// A regex pattern in a rule file or block rule is invalid.
44    InvalidRegexPattern,
45    /// A rule uses the fancy_regex compatibility backend (info-level).
46    FancyRegexBackend,
47    /// A URI contains a component that is preserved but not supported at runtime.
48    UriPreservedUnsupportedComponent,
49    H2HandshakeFailure,
50    H2ConnectRejected,
51    H2StreamReset,
52    H2GoawayReceived,
53    H2PoolExhausted,
54    H2FlowControlStall,
55    H2AuthFailure,
56    H2UnsupportedCleartext,
57    H2TlsAlpnMismatch,
58}
59
60impl fmt::Display for DiagnosticCode {
61    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
62        let label = match self {
63            Self::UnsupportedProtocol => "unsupported_protocol",
64            Self::UnsupportedTransportWrapper => "unsupported_transport_wrapper",
65            Self::UnsupportedFlag => "unsupported_flag",
66            Self::UnsupportedPlatform => "unsupported_platform",
67            Self::UnsupportedSecuritySensitiveLegacyFeature => {
68                "unsupported_security_sensitive_legacy_feature"
69            }
70            Self::InvalidUriSyntax => "invalid_uri_syntax",
71            Self::InvalidChainComposition => "invalid_chain_composition",
72            Self::MissingTarget => "missing_target",
73            Self::MissingCredential => "missing_credential",
74            Self::InvalidCipherMethod => "invalid_cipher_method",
75            Self::BindFailure => "bind_failure",
76            Self::PrivilegeCapabilityMissing => "privilege_capability_missing",
77            Self::ExternalDependencyMissing => "external_dependency_missing",
78            Self::RulefileError => "rulefile_error",
79            Self::InvalidRegexPattern => "invalid_regex_pattern",
80            Self::FancyRegexBackend => "fancy_regex_backend",
81            Self::UriPreservedUnsupportedComponent => "uri_preserved_unsupported_component",
82            Self::H2HandshakeFailure => "h2_handshake_failure",
83            Self::H2ConnectRejected => "h2_connect_rejected",
84            Self::H2StreamReset => "h2_stream_reset",
85            Self::H2GoawayReceived => "h2_goaway_received",
86            Self::H2PoolExhausted => "h2_pool_exhausted",
87            Self::H2FlowControlStall => "h2_flow_control_stall",
88            Self::H2AuthFailure => "h2_auth_failure",
89            Self::H2UnsupportedCleartext => "h2_unsupported_cleartext",
90            Self::H2TlsAlpnMismatch => "h2_tls_alpn_mismatch",
91        };
92        f.write_str(label)
93    }
94}
95
96/// A structured diagnostic produced by the pproxy compatibility layer.
97///
98/// Carries a stable [`DiagnosticCode`], optional manifest feature reference,
99/// optional compatibility tier, a human-readable message, and an optional
100/// suggestion for an eggress-native alternative.
101#[derive(Debug, Clone, Serialize)]
102pub struct StructuredDiagnostic {
103    /// Stable diagnostic code.
104    pub code: DiagnosticCode,
105    /// Manifest feature id, if this diagnostic maps to a known feature.
106    #[serde(skip_serializing_if = "Option::is_none")]
107    pub feature_id: Option<String>,
108    /// Compatibility tier: "drop_in", "compatible_with_warning", "native_equivalent",
109    /// "intentional_non_parity", or "unsupported".
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub tier: Option<String>,
112    /// Human-readable description of the issue.
113    pub message: String,
114    /// Suggested eggress-native alternative, if applicable.
115    #[serde(skip_serializing_if = "Option::is_none")]
116    pub suggestion: Option<String>,
117}
118
119impl fmt::Display for StructuredDiagnostic {
120    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
121        write!(f, "[{}] {}", self.code, self.message)?;
122        if let Some(ref tier) = self.tier {
123            write!(f, " (tier: {})", tier)?;
124        }
125        if let Some(ref suggestion) = self.suggestion {
126            write!(f, " — suggestion: {}", suggestion)?;
127        }
128        Ok(())
129    }
130}
131
132impl From<CompatError> for StructuredDiagnostic {
133    fn from(err: CompatError) -> Self {
134        match err {
135            CompatError::UnsupportedProtocol(proto) => StructuredDiagnostic {
136                code: DiagnosticCode::UnsupportedProtocol,
137                feature_id: None,
138                tier: Some("unsupported".to_string()),
139                message: format!("unsupported protocol: {}", proto),
140                suggestion: Some("use http, socks4, socks5, trojan, or ss".to_string()),
141            },
142            CompatError::UnsupportedFeature { feature, detail } => {
143                let (code, tier, suggestion) = classify_unsupported_feature(feature);
144                StructuredDiagnostic {
145                    code,
146                    feature_id: Some(feature.to_string()),
147                    tier: Some(tier.to_string()),
148                    message: detail,
149                    suggestion: suggestion.map(String::from),
150                }
151            }
152            CompatError::InvalidUri { message } => StructuredDiagnostic {
153                code: DiagnosticCode::InvalidUriSyntax,
154                feature_id: None,
155                tier: None,
156                message,
157                suggestion: None,
158            },
159            CompatError::InvalidArgs { message } => StructuredDiagnostic {
160                code: DiagnosticCode::InvalidUriSyntax,
161                feature_id: None,
162                tier: None,
163                message,
164                suggestion: None,
165            },
166            CompatError::ConfigValidation { message } => StructuredDiagnostic {
167                code: DiagnosticCode::InvalidChainComposition,
168                feature_id: None,
169                tier: None,
170                message,
171                suggestion: None,
172            },
173            CompatError::MissingArgument(flag) => StructuredDiagnostic {
174                code: DiagnosticCode::MissingTarget,
175                feature_id: None,
176                tier: None,
177                message: format!("missing required argument: {}", flag),
178                suggestion: None,
179            },
180        }
181    }
182}
183
184impl From<&CompatWarning> for StructuredDiagnostic {
185    fn from(warn: &CompatWarning) -> Self {
186        match warn.category {
187            "unknown-flag" => StructuredDiagnostic {
188                code: DiagnosticCode::UnsupportedFlag,
189                feature_id: None,
190                tier: Some("unsupported".to_string()),
191                message: warn.message.clone(),
192                suggestion: None,
193            },
194            "direct-mode" => StructuredDiagnostic {
195                code: DiagnosticCode::MissingTarget,
196                feature_id: None,
197                tier: Some("compatible_with_warning".to_string()),
198                message: warn.message.clone(),
199                suggestion: Some("add -r <upstream-uri> for proxied connections".to_string()),
200            },
201            "credential-in-toml" => StructuredDiagnostic {
202                code: DiagnosticCode::MissingCredential,
203                feature_id: None,
204                tier: Some("compatible_with_warning".to_string()),
205                message: warn.message.clone(),
206                suggestion: Some(
207                    "use secret sources or environment variables for credentials".to_string(),
208                ),
209            },
210            "verbose-mode" => StructuredDiagnostic {
211                code: DiagnosticCode::UnsupportedFlag,
212                feature_id: Some("verbose".to_string()),
213                tier: Some("compatible_with_warning".to_string()),
214                message: warn.message.clone(),
215                suggestion: Some("set RUST_LOG=debug".to_string()),
216            },
217            "debug-mode" => StructuredDiagnostic {
218                code: DiagnosticCode::UnsupportedFlag,
219                feature_id: Some("debug".to_string()),
220                tier: Some("compatible_with_warning".to_string()),
221                message: warn.message.clone(),
222                suggestion: Some(
223                    "Eggress selects debug-level default tracing; set RUST_LOG explicitly to override it"
224                        .to_string(),
225                ),
226            },
227            "scheduler" => StructuredDiagnostic {
228                code: DiagnosticCode::UnsupportedFlag,
229                feature_id: Some("scheduler".to_string()),
230                tier: Some("compatible_with_warning".to_string()),
231                message: warn.message.clone(),
232                suggestion: Some(
233                    "use first-available, round-robin, or least-connections".to_string(),
234                ),
235            },
236            "alive-check" => StructuredDiagnostic {
237                code: DiagnosticCode::UnsupportedFlag,
238                feature_id: Some("alive".to_string()),
239                tier: Some("native_equivalent".to_string()),
240                message: warn.message.clone(),
241                suggestion: Some("configure health probes in eggress TOML".to_string()),
242            },
243            "ul-no-listener" => StructuredDiagnostic {
244                code: DiagnosticCode::MissingTarget,
245                feature_id: None,
246                tier: Some("compatible_with_warning".to_string()),
247                message: warn.message.clone(),
248                suggestion: None,
249            },
250            "pac-serving" => StructuredDiagnostic {
251                code: DiagnosticCode::UnsupportedFlag,
252                feature_id: Some("pac".to_string()),
253                tier: Some("compatible_with_warning".to_string()),
254                message: warn.message.clone(),
255                suggestion: Some(
256                    "configure PAC serving in eggress TOML admin.pac block".to_string(),
257                ),
258            },
259            "test-mode" => StructuredDiagnostic {
260                code: DiagnosticCode::UnsupportedFlag,
261                feature_id: Some("test".to_string()),
262                tier: Some("native_equivalent".to_string()),
263                message: warn.message.clone(),
264                suggestion: Some("use 'eggress upstream test -c <config>'".to_string()),
265            },
266            "system-proxy" => StructuredDiagnostic {
267                code: DiagnosticCode::UnsupportedFlag,
268                feature_id: Some("sys".to_string()),
269                tier: Some("compatible_with_warning".to_string()),
270                message: warn.message.clone(),
271                suggestion: Some(
272                    "compatibility mode restores prior settings after --sys".to_string(),
273                ),
274            },
275            "auth-timeout" => StructuredDiagnostic {
276                code: DiagnosticCode::UnsupportedFlag,
277                feature_id: Some("auth".to_string()),
278                tier: Some("compatible_with_warning".to_string()),
279                message: warn.message.clone(),
280                suggestion: Some(
281                    "configure listener credentials to enable source-IP reuse".to_string(),
282                ),
283            },
284            "log-file" => StructuredDiagnostic {
285                code: DiagnosticCode::UnsupportedFlag,
286                feature_id: Some("log".to_string()),
287                tier: Some("compatible_with_warning".to_string()),
288                message: warn.message.clone(),
289                suggestion: Some(
290                    "redirect stderr with shell redirection for file logging".to_string(),
291                ),
292            },
293            "reuse-port" => StructuredDiagnostic {
294                code: DiagnosticCode::UnsupportedFlag,
295                feature_id: Some("reuse".to_string()),
296                tier: Some("native_equivalent".to_string()),
297                message: warn.message.clone(),
298                suggestion: Some("SO_REUSEPORT applied to listener sockets".to_string()),
299            },
300            "get-static-content" => StructuredDiagnostic {
301                code: DiagnosticCode::UnsupportedFlag,
302                feature_id: Some("get".to_string()),
303                tier: Some("native_equivalent".to_string()),
304                message: warn.message.clone(),
305                suggestion: Some(
306                    "configure the same PATH,FILE pair as static content in the Eggress admin server"
307                        .to_string(),
308                ),
309            },
310            "rulefile-read" | "rulefile-parse" | "rulefile-partial" => StructuredDiagnostic {
311                code: DiagnosticCode::RulefileError,
312                feature_id: Some("rulefile".to_string()),
313                tier: Some("compatible_with_warning".to_string()),
314                message: warn.message.clone(),
315                suggestion: Some(
316                    "configure rules in eggress TOML [[rules]] with structured matchers"
317                        .to_string(),
318                ),
319            },
320            "chain-unsupported-hop" => StructuredDiagnostic {
321                code: DiagnosticCode::UnsupportedProtocol,
322                feature_id: Some("chain".to_string()),
323                tier: Some("unsupported".to_string()),
324                message: warn.message.clone(),
325                suggestion: Some(
326                    "remove unsupported hops or use multi-r flag for alternatives".to_string(),
327                ),
328            },
329            "chain-backward-composition" => StructuredDiagnostic {
330                code: DiagnosticCode::UnsupportedProtocol,
331                feature_id: Some("chain".to_string()),
332                tier: Some("unsupported".to_string()),
333                message: warn.message.clone(),
334                suggestion: Some(
335                    "use single-hop backward (+in) or split into separate -r flags".to_string(),
336                ),
337            },
338            _ => StructuredDiagnostic {
339                code: DiagnosticCode::UnsupportedFlag,
340                feature_id: None,
341                tier: None,
342                message: warn.message.clone(),
343                suggestion: None,
344            },
345        }
346    }
347}
348
349pub fn h2_diagnostic(code: DiagnosticCode, message: impl Into<String>) -> StructuredDiagnostic {
350    StructuredDiagnostic {
351        code,
352        feature_id: Some("h2".to_string()),
353        tier: Some("drop_in".to_string()),
354        message: message.into(),
355        suggestion: None,
356    }
357}
358
359pub fn h2_diagnostic_with_suggestion(
360    code: DiagnosticCode,
361    message: impl Into<String>,
362    suggestion: impl Into<String>,
363) -> StructuredDiagnostic {
364    StructuredDiagnostic {
365        code,
366        feature_id: Some("h2".to_string()),
367        tier: Some("drop_in".to_string()),
368        message: message.into(),
369        suggestion: Some(suggestion.into()),
370    }
371}
372
373impl CompatWarning {
374    /// Return the [`DiagnosticCode`] that best classifies this warning.
375    pub fn diagnostic_code(&self) -> DiagnosticCode {
376        StructuredDiagnostic::from(self).code
377    }
378}
379
380/// Classify an unsupported feature string into a diagnostic code, tier, and
381/// optional suggestion.
382fn classify_unsupported_feature(
383    feature: &str,
384) -> (DiagnosticCode, &'static str, Option<&'static str>) {
385    match feature {
386        "daemon" | "backward-jump-chain" | "backward-tls" => (
387            DiagnosticCode::UnsupportedFlag,
388            "unsupported",
389            Some("configure this via eggress TOML"),
390        ),
391        "system-proxy" => (
392            DiagnosticCode::UnsupportedFlag,
393            "compatible_with_warning",
394            Some("compatibility mode restores prior settings after --sys"),
395        ),
396        "auth-timeout" => (
397            DiagnosticCode::UnsupportedFlag,
398            "compatible_with_warning",
399            Some("configure listener credentials to enable source-IP reuse"),
400        ),
401        "chain-unsupported-hop" | "chain-backward-composition" => (
402            DiagnosticCode::UnsupportedProtocol,
403            "unsupported",
404            Some("remove unsupported hops or use multi-r flag for alternatives"),
405        ),
406        // Retain the old SSR category aliases for callers that persisted
407        // diagnostics before Phase 3. New translation uses `ssr-udp` for the
408        // remaining unsupported SSR path.
409        "ssr-listener" | "ssr-upstream" | "ssr-udp" => (
410            DiagnosticCode::UnsupportedSecuritySensitiveLegacyFeature,
411            "intentional_non_parity",
412            Some("use standard Shadowsocks (ss://) with AEAD methods"),
413        ),
414        "trojan-listener" => (
415            DiagnosticCode::UnsupportedProtocol,
416            "unsupported",
417            Some("Trojan listeners require TLS material via --ssl cert,key"),
418        ),
419        "ssh-listener" => (
420            DiagnosticCode::UnsupportedProtocol,
421            "intentional_non_parity",
422            Some("SSH is upstream-only; enable the ssh feature for pproxy-compatible transport or use OpenSSH dynamic forwarding (ssh -D)"),
423        ),
424        "ssh-upstream" => (
425            DiagnosticCode::UnsupportedProtocol,
426            "intentional_non_parity",
427            Some("SSH upstream transport requires the optional ssh feature"),
428        ),
429        "unix-upstream" | "redir-upstream"
430        | "direct-listener" => (DiagnosticCode::UnsupportedProtocol, "unsupported", None),
431        "socks4-bind" => (
432            DiagnosticCode::UnsupportedProtocol,
433            "unsupported",
434            Some("SOCKS4 BIND is not implemented; pproxy also does not implement SOCKS4 BIND"),
435        ),
436        "socks5-bind" => (
437            DiagnosticCode::UnsupportedProtocol,
438            "unsupported",
439            Some("SOCKS5 BIND is not implemented; pproxy also does not implement SOCKS5 BIND"),
440        ),
441        "udp-http-transport" | "udp-https-transport" => (
442            DiagnosticCode::UnsupportedProtocol,
443            "unsupported",
444            Some("use direct://, socks5://, or ss:// for UDP upstreams"),
445        ),
446        "udp-socks4-transport" | "udp-socks4a-transport" => (
447            DiagnosticCode::UnsupportedProtocol,
448            "unsupported",
449            Some("SOCKS4 does not support UDP; use socks5:// for UDP upstreams"),
450        ),
451        "udp-trojan-transport" => (
452            DiagnosticCode::UnsupportedProtocol,
453            "unsupported",
454            Some("Trojan does not support UDP; use direct://, socks5://, or ss://"),
455        ),
456        "udp-multihop" => (
457            DiagnosticCode::UnsupportedProtocol,
458            "unsupported",
459            Some("UDP multi-hop chains are not supported; use single-hop upstreams"),
460        ),
461        "trojan-no-password" => (
462            DiagnosticCode::UnsupportedProtocol,
463            "unsupported",
464            Some("provide a password in the Trojan URI: trojan://password@host:port"),
465        ),
466        "scheme" => (
467            DiagnosticCode::UnsupportedProtocol,
468            "unsupported",
469            Some("use a recognized protocol scheme"),
470        ),
471        "legacy-cipher" => (
472            DiagnosticCode::InvalidCipherMethod,
473            "unsupported",
474            Some("enable the optional legacy-crypto feature or use an AEAD method: aes-128-gcm, aes-192-gcm, aes-256-gcm, chacha20-ietf-poly1305"),
475        ),
476        _ => (DiagnosticCode::UnsupportedFlag, "unsupported", None),
477    }
478}
479
480/// Return just the [`DiagnosticCode`] for an unsupported feature string.
481///
482/// Used by [`CompatError::code()`] to avoid exposing tier/suggestion details
483/// at the error level.
484pub fn classify_unsupported_feature_code(feature: &str) -> DiagnosticCode {
485    classify_unsupported_feature_inner(feature)
486}
487
488/// Return the manifest-aligned tier string for an unsupported feature.
489///
490/// This is the single source of truth for unsupported-feature tier
491/// classification. Python and the canonical manifest must agree with
492/// this function.
493pub fn classify_unsupported_feature_tier(feature: &str) -> &'static str {
494    let (_, tier, _) = classify_unsupported_feature(feature);
495    tier
496}
497
498fn classify_unsupported_feature_inner(feature: &str) -> DiagnosticCode {
499    classify_unsupported_feature(feature).0
500}
501
502#[cfg(test)]
503mod tests {
504    use super::*;
505    use crate::error::CompatError;
506    use crate::warnings::CompatWarning;
507
508    #[test]
509    fn diagnostic_code_display_is_snake_case() {
510        assert_eq!(
511            DiagnosticCode::UnsupportedProtocol.to_string(),
512            "unsupported_protocol"
513        );
514        assert_eq!(
515            DiagnosticCode::UnsupportedSecuritySensitiveLegacyFeature.to_string(),
516            "unsupported_security_sensitive_legacy_feature"
517        );
518        assert_eq!(
519            DiagnosticCode::InvalidUriSyntax.to_string(),
520            "invalid_uri_syntax"
521        );
522        assert_eq!(DiagnosticCode::MissingTarget.to_string(), "missing_target");
523        assert_eq!(
524            DiagnosticCode::MissingCredential.to_string(),
525            "missing_credential"
526        );
527        assert_eq!(
528            DiagnosticCode::InvalidCipherMethod.to_string(),
529            "invalid_cipher_method"
530        );
531        assert_eq!(DiagnosticCode::BindFailure.to_string(), "bind_failure");
532        assert_eq!(
533            DiagnosticCode::PrivilegeCapabilityMissing.to_string(),
534            "privilege_capability_missing"
535        );
536        assert_eq!(
537            DiagnosticCode::ExternalDependencyMissing.to_string(),
538            "external_dependency_missing"
539        );
540    }
541
542    #[test]
543    fn diagnostic_code_serializes_to_snake_case() {
544        let code = DiagnosticCode::UnsupportedProtocol;
545        let json = serde_json::to_string(&code).unwrap();
546        assert_eq!(json, "\"unsupported_protocol\"");
547    }
548
549    #[test]
550    fn structured_diagnostic_display_includes_code_and_message() {
551        let diag = StructuredDiagnostic {
552            code: DiagnosticCode::UnsupportedProtocol,
553            feature_id: None,
554            tier: Some("unsupported".to_string()),
555            message: "unsupported protocol: ftp".to_string(),
556            suggestion: None,
557        };
558        let s = diag.to_string();
559        assert!(s.contains("[unsupported_protocol]"));
560        assert!(s.contains("unsupported protocol: ftp"));
561        assert!(s.contains("tier: unsupported"));
562    }
563
564    #[test]
565    fn structured_diagnostic_display_includes_suggestion() {
566        let diag = StructuredDiagnostic {
567            code: DiagnosticCode::InvalidCipherMethod,
568            feature_id: None,
569            tier: Some("intentional_non_parity".to_string()),
570            message: "legacy cipher".to_string(),
571            suggestion: Some("use AEAD".to_string()),
572        };
573        let s = diag.to_string();
574        assert!(s.contains("suggestion: use AEAD"));
575    }
576
577    #[test]
578    fn from_unsupported_protocol_error() {
579        let err = CompatError::UnsupportedProtocol("ftp".to_string());
580        let diag = StructuredDiagnostic::from(err);
581        assert_eq!(diag.code, DiagnosticCode::UnsupportedProtocol);
582        assert_eq!(diag.tier.as_deref(), Some("unsupported"));
583        assert!(diag.suggestion.is_some());
584    }
585
586    #[test]
587    fn from_unsupported_feature_daemon() {
588        let err = CompatError::unsupported("daemon", "--daemon not supported");
589        let diag = StructuredDiagnostic::from(err);
590        assert_eq!(diag.code, DiagnosticCode::UnsupportedFlag);
591        assert_eq!(diag.feature_id.as_deref(), Some("daemon"));
592    }
593
594    #[test]
595    fn from_unsupported_feature_ssr() {
596        let err = CompatError::unsupported("ssr-listener", "SSR not supported");
597        let diag = StructuredDiagnostic::from(err);
598        assert_eq!(
599            diag.code,
600            DiagnosticCode::UnsupportedSecuritySensitiveLegacyFeature
601        );
602        assert_eq!(diag.tier.as_deref(), Some("intentional_non_parity"));
603    }
604
605    #[test]
606    fn from_unsupported_feature_legacy_cipher() {
607        let err = CompatError::unsupported("legacy-cipher", "aes-128-ctr not supported");
608        let diag = StructuredDiagnostic::from(err);
609        assert_eq!(diag.code, DiagnosticCode::InvalidCipherMethod);
610    }
611
612    #[test]
613    fn from_invalid_uri_error() {
614        let err = CompatError::InvalidUri {
615            message: "bad host".to_string(),
616        };
617        let diag = StructuredDiagnostic::from(err);
618        assert_eq!(diag.code, DiagnosticCode::InvalidUriSyntax);
619    }
620
621    #[test]
622    fn from_invalid_args_error() {
623        let err = CompatError::InvalidArgs {
624            message: "no listener".to_string(),
625        };
626        let diag = StructuredDiagnostic::from(err);
627        assert_eq!(diag.code, DiagnosticCode::InvalidUriSyntax);
628    }
629
630    #[test]
631    fn from_config_validation_error() {
632        let err = CompatError::ConfigValidation {
633            message: "conflict".to_string(),
634        };
635        let diag = StructuredDiagnostic::from(err);
636        assert_eq!(diag.code, DiagnosticCode::InvalidChainComposition);
637    }
638
639    #[test]
640    fn from_missing_argument_error() {
641        let err = CompatError::MissingArgument("-l".to_string());
642        let diag = StructuredDiagnostic::from(err);
643        assert_eq!(diag.code, DiagnosticCode::MissingTarget);
644    }
645
646    #[test]
647    fn from_unknown_flag_warning() {
648        let warn = CompatWarning {
649            category: "unknown-flag",
650            message: "unrecognized flag '--foo'".to_string(),
651        };
652        let diag = StructuredDiagnostic::from(&warn);
653        assert_eq!(diag.code, DiagnosticCode::UnsupportedFlag);
654    }
655
656    #[test]
657    fn from_direct_mode_warning() {
658        let warn = CompatWarning {
659            category: "direct-mode",
660            message: "no upstream".to_string(),
661        };
662        let diag = StructuredDiagnostic::from(&warn);
663        assert_eq!(diag.code, DiagnosticCode::MissingTarget);
664        assert!(diag.suggestion.is_some());
665    }
666
667    #[test]
668    fn from_credential_warning() {
669        let warn = CompatWarning {
670            category: "credential-in-toml",
671            message: "plaintext creds".to_string(),
672        };
673        let diag = StructuredDiagnostic::from(&warn);
674        assert_eq!(diag.code, DiagnosticCode::MissingCredential);
675    }
676
677    #[test]
678    fn from_verbose_warning() {
679        let warn = CompatWarning {
680            category: "verbose-mode",
681            message: "use RUST_LOG".to_string(),
682        };
683        let diag = StructuredDiagnostic::from(&warn);
684        assert_eq!(diag.code, DiagnosticCode::UnsupportedFlag);
685        assert_eq!(diag.feature_id.as_deref(), Some("verbose"));
686        assert_eq!(diag.tier.as_deref(), Some("compatible_with_warning"));
687    }
688
689    #[test]
690    fn from_debug_warning_uses_debug_feature_and_tier() {
691        let warn = CompatWarning {
692            category: "debug-mode",
693            message: "debug tracing difference".to_string(),
694        };
695        let diag = StructuredDiagnostic::from(&warn);
696        assert_eq!(diag.feature_id.as_deref(), Some("debug"));
697        assert_eq!(diag.tier.as_deref(), Some("compatible_with_warning"));
698    }
699
700    #[test]
701    fn touched_warning_categories_have_consistent_tiers() {
702        let categories = [
703            ("debug-mode", "debug"),
704            ("verbose-mode", "verbose"),
705            ("pac-serving", "pac"),
706            ("get-static-content", "get"),
707            ("test-mode", "test"),
708        ];
709        for (category, feature_id) in categories {
710            let warn = CompatWarning {
711                category,
712                message: "test diagnostic".to_string(),
713            };
714            let diagnostic = StructuredDiagnostic::from(&warn);
715            assert_eq!(diagnostic.feature_id.as_deref(), Some(feature_id));
716            assert_eq!(
717                diagnostic.tier.as_deref(),
718                Some(crate::manifest_tier_for_category(category).as_str()),
719                "tier mismatch for {category}"
720            );
721        }
722    }
723
724    #[test]
725    fn from_unknown_category_warning() {
726        let warn = CompatWarning {
727            category: "some-new-category",
728            message: "something happened".to_string(),
729        };
730        let diag = StructuredDiagnostic::from(&warn);
731        assert_eq!(diag.code, DiagnosticCode::UnsupportedFlag);
732    }
733
734    #[test]
735    fn warning_diagnostic_code_method() {
736        let warn = CompatWarning {
737            category: "direct-mode",
738            message: "no upstream".to_string(),
739        };
740        assert_eq!(warn.diagnostic_code(), DiagnosticCode::MissingTarget);
741    }
742
743    #[test]
744    fn structured_diagnostic_json_roundtrip() {
745        let diag = StructuredDiagnostic {
746            code: DiagnosticCode::UnsupportedProtocol,
747            feature_id: Some("ssh-upstream".to_string()),
748            tier: Some("unsupported".to_string()),
749            message: "SSH not supported".to_string(),
750            suggestion: None,
751        };
752        let json = serde_json::to_value(&diag).unwrap();
753        assert_eq!(json["code"], "unsupported_protocol");
754        assert_eq!(json["feature_id"], "ssh-upstream");
755        assert_eq!(json["tier"], "unsupported");
756        assert_eq!(json["message"], "SSH not supported");
757        assert!(json.get("suggestion").is_none());
758    }
759
760    #[test]
761    fn all_diagnostic_codes_serialize() {
762        let codes = [
763            DiagnosticCode::UnsupportedProtocol,
764            DiagnosticCode::UnsupportedTransportWrapper,
765            DiagnosticCode::UnsupportedFlag,
766            DiagnosticCode::UnsupportedPlatform,
767            DiagnosticCode::UnsupportedSecuritySensitiveLegacyFeature,
768            DiagnosticCode::InvalidUriSyntax,
769            DiagnosticCode::InvalidChainComposition,
770            DiagnosticCode::MissingTarget,
771            DiagnosticCode::MissingCredential,
772            DiagnosticCode::InvalidCipherMethod,
773            DiagnosticCode::BindFailure,
774            DiagnosticCode::PrivilegeCapabilityMissing,
775            DiagnosticCode::ExternalDependencyMissing,
776            DiagnosticCode::RulefileError,
777            DiagnosticCode::InvalidRegexPattern,
778            DiagnosticCode::FancyRegexBackend,
779            DiagnosticCode::UriPreservedUnsupportedComponent,
780            DiagnosticCode::H2HandshakeFailure,
781            DiagnosticCode::H2ConnectRejected,
782            DiagnosticCode::H2StreamReset,
783            DiagnosticCode::H2GoawayReceived,
784            DiagnosticCode::H2PoolExhausted,
785            DiagnosticCode::H2FlowControlStall,
786            DiagnosticCode::H2AuthFailure,
787            DiagnosticCode::H2UnsupportedCleartext,
788            DiagnosticCode::H2TlsAlpnMismatch,
789        ];
790        for code in &codes {
791            let json = serde_json::to_string(code).unwrap();
792            // Must be a valid JSON string
793            assert!(json.starts_with('"'));
794            assert!(json.ends_with('"'));
795        }
796    }
797
798    // --- Redaction tests (28.8) ---
799    //
800    // Redaction in eggress happens at the URI display level (PproxyUri::redacted_display)
801    // and in translate.rs warning messages. Error messages from CompatError are internal
802    // and don't carry credentials in production. These tests verify the structured
803    // diagnostic pipeline doesn't amplify credential leaks.
804
805    #[test]
806    fn structured_diagnostic_from_unsupported_protocol_never_leaks_credentials() {
807        let err = CompatError::UnsupportedProtocol("ssh".to_string());
808        let diag = StructuredDiagnostic::from(err);
809        assert!(!diag.message.contains("@"));
810        assert!(diag.suggestion.is_some());
811    }
812
813    #[test]
814    fn structured_diagnostic_from_unsupported_feature_never_leaks_credentials() {
815        let err = CompatError::unsupported("daemon", "--daemon not supported");
816        let diag = StructuredDiagnostic::from(err);
817        assert_eq!(diag.code, DiagnosticCode::UnsupportedFlag);
818        assert!(!diag.message.contains("@"));
819    }
820
821    #[test]
822    fn structured_diagnostic_from_invalid_uri_never_leaks_credentials() {
823        let err = CompatError::InvalidUri {
824            message: "missing port in endpoint".to_string(),
825        };
826        let diag = StructuredDiagnostic::from(err);
827        assert_eq!(diag.code, DiagnosticCode::InvalidUriSyntax);
828        assert!(!diag.message.contains("@"));
829    }
830
831    #[test]
832    fn structured_diagnostic_json_excludes_optional_none_fields() {
833        let diag = StructuredDiagnostic {
834            code: DiagnosticCode::UnsupportedProtocol,
835            feature_id: None,
836            tier: None,
837            message: "test".to_string(),
838            suggestion: None,
839        };
840        let json = serde_json::to_value(&diag).unwrap();
841        assert!(json.get("feature_id").is_none());
842        assert!(json.get("tier").is_none());
843        assert!(json.get("suggestion").is_none());
844    }
845
846    #[test]
847    fn compat_warning_display_never_leaks_credentials() {
848        let warn = CompatWarning {
849            category: "credential-in-toml",
850            message: "Listener 'pproxy-local-0' has plaintext credentials in generated TOML"
851                .to_string(),
852        };
853        let display = warn.to_string();
854        assert!(display.contains("[credential-in-toml]"));
855        assert!(!display.contains("@"));
856    }
857
858    #[test]
859    fn h2_diagnostic_code_display() {
860        assert_eq!(
861            DiagnosticCode::H2HandshakeFailure.to_string(),
862            "h2_handshake_failure"
863        );
864        assert_eq!(
865            DiagnosticCode::H2ConnectRejected.to_string(),
866            "h2_connect_rejected"
867        );
868        assert_eq!(DiagnosticCode::H2StreamReset.to_string(), "h2_stream_reset");
869        assert_eq!(
870            DiagnosticCode::H2GoawayReceived.to_string(),
871            "h2_goaway_received"
872        );
873        assert_eq!(
874            DiagnosticCode::H2PoolExhausted.to_string(),
875            "h2_pool_exhausted"
876        );
877        assert_eq!(
878            DiagnosticCode::H2FlowControlStall.to_string(),
879            "h2_flow_control_stall"
880        );
881        assert_eq!(DiagnosticCode::H2AuthFailure.to_string(), "h2_auth_failure");
882        assert_eq!(
883            DiagnosticCode::H2UnsupportedCleartext.to_string(),
884            "h2_unsupported_cleartext"
885        );
886        assert_eq!(
887            DiagnosticCode::H2TlsAlpnMismatch.to_string(),
888            "h2_tls_alpn_mismatch"
889        );
890    }
891
892    #[test]
893    fn h2_diagnostic_helper() {
894        let diag = h2_diagnostic(DiagnosticCode::H2HandshakeFailure, "handshake failed");
895        assert_eq!(diag.code, DiagnosticCode::H2HandshakeFailure);
896        assert_eq!(diag.feature_id.as_deref(), Some("h2"));
897        assert_eq!(diag.tier.as_deref(), Some("drop_in"));
898        assert_eq!(diag.message, "handshake failed");
899        assert!(diag.suggestion.is_none());
900    }
901
902    #[test]
903    fn h2_diagnostic_with_suggestion_helper() {
904        let diag = h2_diagnostic_with_suggestion(
905            DiagnosticCode::H2TlsAlpnMismatch,
906            "ALPN mismatch",
907            "use h2 ALPN",
908        );
909        assert_eq!(diag.code, DiagnosticCode::H2TlsAlpnMismatch);
910        assert_eq!(diag.feature_id.as_deref(), Some("h2"));
911        assert_eq!(diag.tier.as_deref(), Some("drop_in"));
912        assert_eq!(diag.message, "ALPN mismatch");
913        assert_eq!(diag.suggestion.as_deref(), Some("use h2 ALPN"));
914    }
915
916    #[test]
917    fn diagnostic_code_display_never_contains_credentials() {
918        let codes = [
919            DiagnosticCode::UnsupportedProtocol,
920            DiagnosticCode::UnsupportedFlag,
921            DiagnosticCode::InvalidUriSyntax,
922            DiagnosticCode::MissingTarget,
923            DiagnosticCode::MissingCredential,
924            DiagnosticCode::InvalidCipherMethod,
925            DiagnosticCode::BindFailure,
926        ];
927        for code in &codes {
928            let display = code.to_string();
929            assert!(
930                !display.contains("@"),
931                "code display contains @: {}",
932                display
933            );
934        }
935    }
936}