1use std::fmt;
2
3use serde::Serialize;
4
5use crate::error::CompatError;
6use crate::warnings::CompatWarning;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
13#[serde(rename_all = "snake_case")]
14pub enum DiagnosticCode {
15 UnsupportedProtocol,
17 UnsupportedTransportWrapper,
19 UnsupportedFlag,
21 UnsupportedPlatform,
23 UnsupportedSecuritySensitiveLegacyFeature,
25 InvalidUriSyntax,
27 InvalidChainComposition,
29 MissingTarget,
31 MissingCredential,
33 InvalidCipherMethod,
35 BindFailure,
37 PrivilegeCapabilityMissing,
39 ExternalDependencyMissing,
41 RulefileError,
43 InvalidRegexPattern,
45 FancyRegexBackend,
47 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#[derive(Debug, Clone, Serialize)]
102pub struct StructuredDiagnostic {
103 pub code: DiagnosticCode,
105 #[serde(skip_serializing_if = "Option::is_none")]
107 pub feature_id: Option<String>,
108 #[serde(skip_serializing_if = "Option::is_none")]
111 pub tier: Option<String>,
112 pub message: String,
114 #[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 pub fn diagnostic_code(&self) -> DiagnosticCode {
376 StructuredDiagnostic::from(self).code
377 }
378}
379
380fn 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 "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
475pub fn classify_unsupported_feature_code(feature: &str) -> DiagnosticCode {
480 classify_unsupported_feature_inner(feature)
481}
482
483pub 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 assert!(json.starts_with('"'));
810 assert!(json.ends_with('"'));
811 }
812 }
813
814 #[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}