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: &'static 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        "ssh-listener" => (
415            DiagnosticCode::UnsupportedProtocol,
416            "intentional_non_parity",
417            Some("SSH is upstream-only; enable the ssh feature for pproxy-compatible transport or use OpenSSH dynamic forwarding (ssh -D)"),
418        ),
419        "ssh-upstream" => (
420            DiagnosticCode::UnsupportedProtocol,
421            "intentional_non_parity",
422            Some("SSH upstream transport requires the optional ssh feature"),
423        ),
424        "unix-upstream" | "redir-upstream"
425        | "direct-listener" => (DiagnosticCode::UnsupportedProtocol, "unsupported", None),
426        "socks4-bind" => (
427            DiagnosticCode::UnsupportedProtocol,
428            "unsupported",
429            Some("SOCKS4 BIND is not implemented; pproxy also does not implement SOCKS4 BIND"),
430        ),
431        "socks5-bind" => (
432            DiagnosticCode::UnsupportedProtocol,
433            "unsupported",
434            Some("SOCKS5 BIND is not implemented; pproxy also does not implement SOCKS5 BIND"),
435        ),
436        "udp-http-transport" | "udp-https-transport" => (
437            DiagnosticCode::UnsupportedProtocol,
438            "unsupported",
439            Some("use direct://, socks5://, or ss:// for UDP upstreams"),
440        ),
441        "udp-socks4-transport" | "udp-socks4a-transport" => (
442            DiagnosticCode::UnsupportedProtocol,
443            "unsupported",
444            Some("SOCKS4 does not support UDP; use socks5:// for UDP upstreams"),
445        ),
446        "udp-trojan-transport" => (
447            DiagnosticCode::UnsupportedProtocol,
448            "unsupported",
449            Some("Trojan does not support UDP; use direct://, socks5://, or ss://"),
450        ),
451        "udp-multihop" => (
452            DiagnosticCode::UnsupportedProtocol,
453            "unsupported",
454            Some("UDP multi-hop chains are not supported; use single-hop upstreams"),
455        ),
456        "trojan-no-password" => (
457            DiagnosticCode::UnsupportedProtocol,
458            "unsupported",
459            Some("provide a password in the Trojan URI: trojan://password@host:port"),
460        ),
461        "scheme" => (
462            DiagnosticCode::UnsupportedProtocol,
463            "unsupported",
464            Some("use a recognized protocol scheme"),
465        ),
466        "legacy-cipher" => (
467            DiagnosticCode::InvalidCipherMethod,
468            "unsupported",
469            Some("enable the optional legacy-crypto feature or use an AEAD method: aes-128-gcm, aes-192-gcm, aes-256-gcm, chacha20-ietf-poly1305"),
470        ),
471        _ => (DiagnosticCode::UnsupportedFlag, "unsupported", None),
472    }
473}
474
475/// Return just the [`DiagnosticCode`] for an unsupported feature string.
476///
477/// Used by [`CompatError::code()`] to avoid exposing tier/suggestion details
478/// at the error level.
479pub fn classify_unsupported_feature_code(feature: &str) -> DiagnosticCode {
480    classify_unsupported_feature_inner(feature)
481}
482
483/// Return the manifest-aligned tier string for an unsupported feature.
484///
485/// This is the single source of truth for unsupported-feature tier
486/// classification. Python and the canonical manifest must agree with
487/// this function.
488pub fn classify_unsupported_feature_tier(feature: &'static str) -> &'static str {
489    let (_, tier, _) = classify_unsupported_feature(feature);
490    tier
491}
492
493fn classify_unsupported_feature_inner(feature: &str) -> DiagnosticCode {
494    match feature {
495        "daemon" | "backward-jump-chain" | "backward-tls" => DiagnosticCode::UnsupportedFlag,
496        "system-proxy" | "auth-timeout" => DiagnosticCode::UnsupportedFlag,
497        "chain-unsupported-hop" | "chain-backward-composition" => {
498            DiagnosticCode::UnsupportedProtocol
499        }
500        "ssr-listener" | "ssr-upstream" | "ssr-udp" => {
501            DiagnosticCode::UnsupportedSecuritySensitiveLegacyFeature
502        }
503        "trojan-listener" | "ssh-listener" | "ssh-upstream" | "unix-upstream"
504        | "redir-upstream" | "direct-listener" => DiagnosticCode::UnsupportedProtocol,
505        "socks4-bind" | "socks5-bind" => DiagnosticCode::UnsupportedProtocol,
506        "udp-http-transport"
507        | "udp-https-transport"
508        | "udp-socks4-transport"
509        | "udp-socks4a-transport"
510        | "udp-trojan-transport"
511        | "udp-multihop" => DiagnosticCode::UnsupportedProtocol,
512        "scheme" => DiagnosticCode::UnsupportedProtocol,
513        "legacy-cipher" => DiagnosticCode::InvalidCipherMethod,
514        _ => DiagnosticCode::UnsupportedFlag,
515    }
516}
517
518#[cfg(test)]
519mod tests {
520    use super::*;
521    use crate::error::CompatError;
522    use crate::warnings::CompatWarning;
523
524    #[test]
525    fn diagnostic_code_display_is_snake_case() {
526        assert_eq!(
527            DiagnosticCode::UnsupportedProtocol.to_string(),
528            "unsupported_protocol"
529        );
530        assert_eq!(
531            DiagnosticCode::UnsupportedSecuritySensitiveLegacyFeature.to_string(),
532            "unsupported_security_sensitive_legacy_feature"
533        );
534        assert_eq!(
535            DiagnosticCode::InvalidUriSyntax.to_string(),
536            "invalid_uri_syntax"
537        );
538        assert_eq!(DiagnosticCode::MissingTarget.to_string(), "missing_target");
539        assert_eq!(
540            DiagnosticCode::MissingCredential.to_string(),
541            "missing_credential"
542        );
543        assert_eq!(
544            DiagnosticCode::InvalidCipherMethod.to_string(),
545            "invalid_cipher_method"
546        );
547        assert_eq!(DiagnosticCode::BindFailure.to_string(), "bind_failure");
548        assert_eq!(
549            DiagnosticCode::PrivilegeCapabilityMissing.to_string(),
550            "privilege_capability_missing"
551        );
552        assert_eq!(
553            DiagnosticCode::ExternalDependencyMissing.to_string(),
554            "external_dependency_missing"
555        );
556    }
557
558    #[test]
559    fn diagnostic_code_serializes_to_snake_case() {
560        let code = DiagnosticCode::UnsupportedProtocol;
561        let json = serde_json::to_string(&code).unwrap();
562        assert_eq!(json, "\"unsupported_protocol\"");
563    }
564
565    #[test]
566    fn structured_diagnostic_display_includes_code_and_message() {
567        let diag = StructuredDiagnostic {
568            code: DiagnosticCode::UnsupportedProtocol,
569            feature_id: None,
570            tier: Some("unsupported".to_string()),
571            message: "unsupported protocol: ftp".to_string(),
572            suggestion: None,
573        };
574        let s = diag.to_string();
575        assert!(s.contains("[unsupported_protocol]"));
576        assert!(s.contains("unsupported protocol: ftp"));
577        assert!(s.contains("tier: unsupported"));
578    }
579
580    #[test]
581    fn structured_diagnostic_display_includes_suggestion() {
582        let diag = StructuredDiagnostic {
583            code: DiagnosticCode::InvalidCipherMethod,
584            feature_id: None,
585            tier: Some("intentional_non_parity".to_string()),
586            message: "legacy cipher".to_string(),
587            suggestion: Some("use AEAD".to_string()),
588        };
589        let s = diag.to_string();
590        assert!(s.contains("suggestion: use AEAD"));
591    }
592
593    #[test]
594    fn from_unsupported_protocol_error() {
595        let err = CompatError::UnsupportedProtocol("ftp".to_string());
596        let diag = StructuredDiagnostic::from(err);
597        assert_eq!(diag.code, DiagnosticCode::UnsupportedProtocol);
598        assert_eq!(diag.tier.as_deref(), Some("unsupported"));
599        assert!(diag.suggestion.is_some());
600    }
601
602    #[test]
603    fn from_unsupported_feature_daemon() {
604        let err = CompatError::unsupported("daemon", "--daemon not supported");
605        let diag = StructuredDiagnostic::from(err);
606        assert_eq!(diag.code, DiagnosticCode::UnsupportedFlag);
607        assert_eq!(diag.feature_id.as_deref(), Some("daemon"));
608    }
609
610    #[test]
611    fn from_unsupported_feature_ssr() {
612        let err = CompatError::unsupported("ssr-listener", "SSR not supported");
613        let diag = StructuredDiagnostic::from(err);
614        assert_eq!(
615            diag.code,
616            DiagnosticCode::UnsupportedSecuritySensitiveLegacyFeature
617        );
618        assert_eq!(diag.tier.as_deref(), Some("intentional_non_parity"));
619    }
620
621    #[test]
622    fn from_unsupported_feature_legacy_cipher() {
623        let err = CompatError::unsupported("legacy-cipher", "aes-128-ctr not supported");
624        let diag = StructuredDiagnostic::from(err);
625        assert_eq!(diag.code, DiagnosticCode::InvalidCipherMethod);
626    }
627
628    #[test]
629    fn from_invalid_uri_error() {
630        let err = CompatError::InvalidUri {
631            message: "bad host".to_string(),
632        };
633        let diag = StructuredDiagnostic::from(err);
634        assert_eq!(diag.code, DiagnosticCode::InvalidUriSyntax);
635    }
636
637    #[test]
638    fn from_invalid_args_error() {
639        let err = CompatError::InvalidArgs {
640            message: "no listener".to_string(),
641        };
642        let diag = StructuredDiagnostic::from(err);
643        assert_eq!(diag.code, DiagnosticCode::InvalidUriSyntax);
644    }
645
646    #[test]
647    fn from_config_validation_error() {
648        let err = CompatError::ConfigValidation {
649            message: "conflict".to_string(),
650        };
651        let diag = StructuredDiagnostic::from(err);
652        assert_eq!(diag.code, DiagnosticCode::InvalidChainComposition);
653    }
654
655    #[test]
656    fn from_missing_argument_error() {
657        let err = CompatError::MissingArgument("-l".to_string());
658        let diag = StructuredDiagnostic::from(err);
659        assert_eq!(diag.code, DiagnosticCode::MissingTarget);
660    }
661
662    #[test]
663    fn from_unknown_flag_warning() {
664        let warn = CompatWarning {
665            category: "unknown-flag",
666            message: "unrecognized flag '--foo'".to_string(),
667        };
668        let diag = StructuredDiagnostic::from(&warn);
669        assert_eq!(diag.code, DiagnosticCode::UnsupportedFlag);
670    }
671
672    #[test]
673    fn from_direct_mode_warning() {
674        let warn = CompatWarning {
675            category: "direct-mode",
676            message: "no upstream".to_string(),
677        };
678        let diag = StructuredDiagnostic::from(&warn);
679        assert_eq!(diag.code, DiagnosticCode::MissingTarget);
680        assert!(diag.suggestion.is_some());
681    }
682
683    #[test]
684    fn from_credential_warning() {
685        let warn = CompatWarning {
686            category: "credential-in-toml",
687            message: "plaintext creds".to_string(),
688        };
689        let diag = StructuredDiagnostic::from(&warn);
690        assert_eq!(diag.code, DiagnosticCode::MissingCredential);
691    }
692
693    #[test]
694    fn from_verbose_warning() {
695        let warn = CompatWarning {
696            category: "verbose-mode",
697            message: "use RUST_LOG".to_string(),
698        };
699        let diag = StructuredDiagnostic::from(&warn);
700        assert_eq!(diag.code, DiagnosticCode::UnsupportedFlag);
701        assert_eq!(diag.feature_id.as_deref(), Some("verbose"));
702        assert_eq!(diag.tier.as_deref(), Some("compatible_with_warning"));
703    }
704
705    #[test]
706    fn from_debug_warning_uses_debug_feature_and_tier() {
707        let warn = CompatWarning {
708            category: "debug-mode",
709            message: "debug tracing difference".to_string(),
710        };
711        let diag = StructuredDiagnostic::from(&warn);
712        assert_eq!(diag.feature_id.as_deref(), Some("debug"));
713        assert_eq!(diag.tier.as_deref(), Some("compatible_with_warning"));
714    }
715
716    #[test]
717    fn touched_warning_categories_have_consistent_tiers() {
718        let categories = [
719            ("debug-mode", "debug"),
720            ("verbose-mode", "verbose"),
721            ("pac-serving", "pac"),
722            ("get-static-content", "get"),
723            ("test-mode", "test"),
724        ];
725        for (category, feature_id) in categories {
726            let warn = CompatWarning {
727                category,
728                message: "test diagnostic".to_string(),
729            };
730            let diagnostic = StructuredDiagnostic::from(&warn);
731            assert_eq!(diagnostic.feature_id.as_deref(), Some(feature_id));
732            assert_eq!(
733                diagnostic.tier.as_deref(),
734                Some(crate::manifest_tier_for_category(category).as_str()),
735                "tier mismatch for {category}"
736            );
737        }
738    }
739
740    #[test]
741    fn from_unknown_category_warning() {
742        let warn = CompatWarning {
743            category: "some-new-category",
744            message: "something happened".to_string(),
745        };
746        let diag = StructuredDiagnostic::from(&warn);
747        assert_eq!(diag.code, DiagnosticCode::UnsupportedFlag);
748    }
749
750    #[test]
751    fn warning_diagnostic_code_method() {
752        let warn = CompatWarning {
753            category: "direct-mode",
754            message: "no upstream".to_string(),
755        };
756        assert_eq!(warn.diagnostic_code(), DiagnosticCode::MissingTarget);
757    }
758
759    #[test]
760    fn structured_diagnostic_json_roundtrip() {
761        let diag = StructuredDiagnostic {
762            code: DiagnosticCode::UnsupportedProtocol,
763            feature_id: Some("ssh-upstream".to_string()),
764            tier: Some("unsupported".to_string()),
765            message: "SSH not supported".to_string(),
766            suggestion: None,
767        };
768        let json = serde_json::to_value(&diag).unwrap();
769        assert_eq!(json["code"], "unsupported_protocol");
770        assert_eq!(json["feature_id"], "ssh-upstream");
771        assert_eq!(json["tier"], "unsupported");
772        assert_eq!(json["message"], "SSH not supported");
773        assert!(json.get("suggestion").is_none());
774    }
775
776    #[test]
777    fn all_diagnostic_codes_serialize() {
778        let codes = [
779            DiagnosticCode::UnsupportedProtocol,
780            DiagnosticCode::UnsupportedTransportWrapper,
781            DiagnosticCode::UnsupportedFlag,
782            DiagnosticCode::UnsupportedPlatform,
783            DiagnosticCode::UnsupportedSecuritySensitiveLegacyFeature,
784            DiagnosticCode::InvalidUriSyntax,
785            DiagnosticCode::InvalidChainComposition,
786            DiagnosticCode::MissingTarget,
787            DiagnosticCode::MissingCredential,
788            DiagnosticCode::InvalidCipherMethod,
789            DiagnosticCode::BindFailure,
790            DiagnosticCode::PrivilegeCapabilityMissing,
791            DiagnosticCode::ExternalDependencyMissing,
792            DiagnosticCode::RulefileError,
793            DiagnosticCode::InvalidRegexPattern,
794            DiagnosticCode::FancyRegexBackend,
795            DiagnosticCode::UriPreservedUnsupportedComponent,
796            DiagnosticCode::H2HandshakeFailure,
797            DiagnosticCode::H2ConnectRejected,
798            DiagnosticCode::H2StreamReset,
799            DiagnosticCode::H2GoawayReceived,
800            DiagnosticCode::H2PoolExhausted,
801            DiagnosticCode::H2FlowControlStall,
802            DiagnosticCode::H2AuthFailure,
803            DiagnosticCode::H2UnsupportedCleartext,
804            DiagnosticCode::H2TlsAlpnMismatch,
805        ];
806        for code in &codes {
807            let json = serde_json::to_string(code).unwrap();
808            // Must be a valid JSON string
809            assert!(json.starts_with('"'));
810            assert!(json.ends_with('"'));
811        }
812    }
813
814    // --- Redaction tests (28.8) ---
815    //
816    // Redaction in eggress happens at the URI display level (PproxyUri::redacted_display)
817    // and in translate.rs warning messages. Error messages from CompatError are internal
818    // and don't carry credentials in production. These tests verify the structured
819    // diagnostic pipeline doesn't amplify credential leaks.
820
821    #[test]
822    fn structured_diagnostic_from_unsupported_protocol_never_leaks_credentials() {
823        let err = CompatError::UnsupportedProtocol("ssh".to_string());
824        let diag = StructuredDiagnostic::from(err);
825        assert!(!diag.message.contains("@"));
826        assert!(diag.suggestion.is_some());
827    }
828
829    #[test]
830    fn structured_diagnostic_from_unsupported_feature_never_leaks_credentials() {
831        let err = CompatError::unsupported("daemon", "--daemon not supported");
832        let diag = StructuredDiagnostic::from(err);
833        assert_eq!(diag.code, DiagnosticCode::UnsupportedFlag);
834        assert!(!diag.message.contains("@"));
835    }
836
837    #[test]
838    fn structured_diagnostic_from_invalid_uri_never_leaks_credentials() {
839        let err = CompatError::InvalidUri {
840            message: "missing port in endpoint".to_string(),
841        };
842        let diag = StructuredDiagnostic::from(err);
843        assert_eq!(diag.code, DiagnosticCode::InvalidUriSyntax);
844        assert!(!diag.message.contains("@"));
845    }
846
847    #[test]
848    fn structured_diagnostic_json_excludes_optional_none_fields() {
849        let diag = StructuredDiagnostic {
850            code: DiagnosticCode::UnsupportedProtocol,
851            feature_id: None,
852            tier: None,
853            message: "test".to_string(),
854            suggestion: None,
855        };
856        let json = serde_json::to_value(&diag).unwrap();
857        assert!(json.get("feature_id").is_none());
858        assert!(json.get("tier").is_none());
859        assert!(json.get("suggestion").is_none());
860    }
861
862    #[test]
863    fn compat_warning_display_never_leaks_credentials() {
864        let warn = CompatWarning {
865            category: "credential-in-toml",
866            message: "Listener 'pproxy-local-0' has plaintext credentials in generated TOML"
867                .to_string(),
868        };
869        let display = warn.to_string();
870        assert!(display.contains("[credential-in-toml]"));
871        assert!(!display.contains("@"));
872    }
873
874    #[test]
875    fn h2_diagnostic_code_display() {
876        assert_eq!(
877            DiagnosticCode::H2HandshakeFailure.to_string(),
878            "h2_handshake_failure"
879        );
880        assert_eq!(
881            DiagnosticCode::H2ConnectRejected.to_string(),
882            "h2_connect_rejected"
883        );
884        assert_eq!(DiagnosticCode::H2StreamReset.to_string(), "h2_stream_reset");
885        assert_eq!(
886            DiagnosticCode::H2GoawayReceived.to_string(),
887            "h2_goaway_received"
888        );
889        assert_eq!(
890            DiagnosticCode::H2PoolExhausted.to_string(),
891            "h2_pool_exhausted"
892        );
893        assert_eq!(
894            DiagnosticCode::H2FlowControlStall.to_string(),
895            "h2_flow_control_stall"
896        );
897        assert_eq!(DiagnosticCode::H2AuthFailure.to_string(), "h2_auth_failure");
898        assert_eq!(
899            DiagnosticCode::H2UnsupportedCleartext.to_string(),
900            "h2_unsupported_cleartext"
901        );
902        assert_eq!(
903            DiagnosticCode::H2TlsAlpnMismatch.to_string(),
904            "h2_tls_alpn_mismatch"
905        );
906    }
907
908    #[test]
909    fn h2_diagnostic_helper() {
910        let diag = h2_diagnostic(DiagnosticCode::H2HandshakeFailure, "handshake failed");
911        assert_eq!(diag.code, DiagnosticCode::H2HandshakeFailure);
912        assert_eq!(diag.feature_id.as_deref(), Some("h2"));
913        assert_eq!(diag.tier.as_deref(), Some("drop_in"));
914        assert_eq!(diag.message, "handshake failed");
915        assert!(diag.suggestion.is_none());
916    }
917
918    #[test]
919    fn h2_diagnostic_with_suggestion_helper() {
920        let diag = h2_diagnostic_with_suggestion(
921            DiagnosticCode::H2TlsAlpnMismatch,
922            "ALPN mismatch",
923            "use h2 ALPN",
924        );
925        assert_eq!(diag.code, DiagnosticCode::H2TlsAlpnMismatch);
926        assert_eq!(diag.feature_id.as_deref(), Some("h2"));
927        assert_eq!(diag.tier.as_deref(), Some("drop_in"));
928        assert_eq!(diag.message, "ALPN mismatch");
929        assert_eq!(diag.suggestion.as_deref(), Some("use h2 ALPN"));
930    }
931
932    #[test]
933    fn diagnostic_code_display_never_contains_credentials() {
934        let codes = [
935            DiagnosticCode::UnsupportedProtocol,
936            DiagnosticCode::UnsupportedFlag,
937            DiagnosticCode::InvalidUriSyntax,
938            DiagnosticCode::MissingTarget,
939            DiagnosticCode::MissingCredential,
940            DiagnosticCode::InvalidCipherMethod,
941            DiagnosticCode::BindFailure,
942        ];
943        for code in &codes {
944            let display = code.to_string();
945            assert!(
946                !display.contains("@"),
947                "code display contains @: {}",
948                display
949            );
950        }
951    }
952}