@@ -2,6 +2,7 @@ use std::time::Duration;
22
33use error_stack:: { Report , ResultExt as _} ;
44use fastly:: backend:: Backend ;
5+ use sha2:: { Digest as _, Sha256 } ;
56use url:: Url ;
67
78use trusted_server_core:: error:: TrustedServerError ;
@@ -47,6 +48,34 @@ fn sanitize_backend_name_component(value: &str) -> String {
4748 . collect ( )
4849}
4950
51+ /// Fastly's documented maximum length for a dynamic backend name.
52+ const MAX_BACKEND_NAME_LEN : usize = 255 ;
53+ /// Maximum length of the human-readable prefix folded into a backend name.
54+ ///
55+ /// Bounds the name so that `backend_<prefix>_<digest>` can never exceed
56+ /// [`MAX_BACKEND_NAME_LEN`]: 8 (`backend_`) + 200 + 1 (`_`) +
57+ /// [`SPEC_DIGEST_HEX_LEN`] = 241 ≤ 255.
58+ const MAX_READABLE_PREFIX_LEN : usize = 200 ;
59+ /// Width of the hex digest suffix — the first 128 bits of a SHA-256 over the
60+ /// full backend spec, which is collision-resistant at the handful-of-hundreds
61+ /// scale of a service's dynamic backends.
62+ const SPEC_DIGEST_HEX_LEN : usize = 32 ;
63+
64+ /// Hex-encode the first 128 bits of a SHA-256 digest of `canonical`.
65+ ///
66+ /// Used to make a backend name a collision-resistant function of the complete
67+ /// backend spec (see [`BackendConfig::canonical_spec_string`]).
68+ fn spec_digest_hex ( canonical : & str ) -> String {
69+ let mut hasher = Sha256 :: new ( ) ;
70+ hasher. update ( canonical. as_bytes ( ) ) ;
71+ let digest = hasher. finalize ( ) ;
72+ digest
73+ . iter ( )
74+ . take ( SPEC_DIGEST_HEX_LEN / 2 )
75+ . map ( |byte| format ! ( "{byte:02x}" ) )
76+ . collect ( )
77+ }
78+
5079/// Default first-byte timeout for backends (15 seconds).
5180pub ( crate ) const DEFAULT_FIRST_BYTE_TIMEOUT : Duration = Duration :: from_secs ( 15 ) ;
5281/// Default timeout between response body bytes for backends (10 seconds).
@@ -143,17 +172,61 @@ impl<'a> BackendConfig<'a> {
143172 self
144173 }
145174
175+ /// Build an unambiguous, length-prefixed encoding of the complete backend
176+ /// spec for digesting.
177+ ///
178+ /// Every field is prefixed with its byte length so that no two distinct
179+ /// specs can encode to the same string (a lossy substitution like
180+ /// `sanitize_backend_name_component` cannot guarantee this). `Option` fields
181+ /// are presence-tagged so a `None` never aliases a `Some("")`. The result is
182+ /// fed to [`spec_digest_hex`]; it is never parsed, only hashed.
183+ fn canonical_spec_string ( & self , target_port : u16 ) -> String {
184+ fn push_field ( buf : & mut String , field : & str ) {
185+ buf. push_str ( & field. len ( ) . to_string ( ) ) ;
186+ buf. push ( ':' ) ;
187+ buf. push_str ( field) ;
188+ }
189+
190+ let mut buf = String :: new ( ) ;
191+ push_field ( & mut buf, self . scheme ) ;
192+ push_field ( & mut buf, self . host ) ;
193+ push_field ( & mut buf, & target_port. to_string ( ) ) ;
194+ push_field ( & mut buf, if self . certificate_check { "1" } else { "0" } ) ;
195+ match self . host_header_override {
196+ Some ( value) => {
197+ buf. push ( 's' ) ;
198+ push_field ( & mut buf, value) ;
199+ }
200+ None => buf. push ( 'n' ) ,
201+ }
202+ match self . discriminator {
203+ Some ( value) => {
204+ buf. push ( 's' ) ;
205+ push_field ( & mut buf, value) ;
206+ }
207+ None => buf. push ( 'n' ) ,
208+ }
209+ push_field ( & mut buf, & self . first_byte_timeout . as_millis ( ) . to_string ( ) ) ;
210+ push_field ( & mut buf, & self . between_bytes_timeout . as_millis ( ) . to_string ( ) ) ;
211+ buf
212+ }
213+
146214 /// Compute the deterministic backend name and resolved port without
147215 /// registering anything.
148216 ///
149- /// The name encodes scheme, host, port, certificate setting, optional
150- /// discriminator, and the first-byte/between-bytes timeouts so that
151- /// backends with different configurations never collide. Including the
152- /// timeout prevents "first-registration-wins" poisoning where a later
153- /// request for the same origin with a tighter timeout would silently
154- /// inherit the original registration's value. Including the discriminator
155- /// keeps two callers that target the same origin with the same timeout
156- /// (e.g. two auction providers behind one gateway) on distinct backends.
217+ /// The name is `backend_<readable>_<digest>`, where `<digest>` is a
218+ /// collision-resistant SHA-256 over an unambiguous encoding of the
219+ /// *complete* backend spec — scheme, host, port, certificate setting, Host
220+ /// override, provider discriminator, and the first-byte/between-bytes
221+ /// timeouts (see [`canonical_spec_string`](Self::canonical_spec_string)).
222+ /// Because distinct specs yield distinct digests, name equality implies spec
223+ /// equality: that is what makes reusing a `NameInUse` backend provably safe,
224+ /// and it prevents "first-registration-wins" poisoning where a later request
225+ /// with a tighter timeout would inherit an earlier registration's value. The
226+ /// `<readable>` half is a lossy, bounded slug carried only for logs — any
227+ /// collision there is harmless because uniqueness comes from the digest. The
228+ /// whole name is bounded to [`MAX_BACKEND_NAME_LEN`] so a long host or
229+ /// discriminator can never produce a name Fastly rejects at registration.
157230 fn compute_name ( & self ) -> Result < ( String , u16 ) , Report < TrustedServerError > > {
158231 if self . host . is_empty ( ) {
159232 return Err ( Report :: new ( TrustedServerError :: Proxy {
@@ -198,15 +271,37 @@ impl<'a> BackendConfig<'a> {
198271 . unwrap_or_default ( ) ;
199272 let first_byte_timeout_ms = self . first_byte_timeout . as_millis ( ) ;
200273 let between_bytes_timeout_ms = self . between_bytes_timeout . as_millis ( ) ;
201- let backend_name = format ! (
202- "backend_{}{}{}{}_fb{}_bb{}" ,
274+
275+ // Lossy, human-readable slug for logs. Correctness does not depend on
276+ // it — uniqueness comes from the digest below — so it is bounded to a
277+ // fixed length. Sanitization only emits ASCII, so a char-boundary take
278+ // is byte-exact.
279+ let readable_full = format ! (
280+ "{}{}{}{}_fb{}_bb{}" ,
203281 sanitize_backend_name_component( & name_base) ,
204282 host_override_suffix,
205283 cert_suffix,
206284 discriminator_suffix,
207285 first_byte_timeout_ms,
208286 between_bytes_timeout_ms
209287 ) ;
288+ let readable: String = readable_full. chars ( ) . take ( MAX_READABLE_PREFIX_LEN ) . collect ( ) ;
289+
290+ // Collision-resistant over the *complete* spec, so name equality implies
291+ // spec equality and `NameInUse` reuse is safe.
292+ let digest = spec_digest_hex ( & self . canonical_spec_string ( target_port) ) ;
293+ let backend_name = format ! ( "backend_{readable}_{digest}" ) ;
294+
295+ // Bounded by construction; assert it so any future format change fails
296+ // attributably during prediction rather than at Fastly registration.
297+ if backend_name. len ( ) > MAX_BACKEND_NAME_LEN {
298+ return Err ( Report :: new ( TrustedServerError :: Proxy {
299+ message : format ! (
300+ "backend name exceeds {MAX_BACKEND_NAME_LEN}-char limit ({} chars)" ,
301+ backend_name. len( )
302+ ) ,
303+ } ) ) ;
304+ }
210305
211306 Ok ( ( backend_name, target_port) )
212307 }
@@ -373,7 +468,35 @@ impl<'a> BackendConfig<'a> {
373468
374469#[ cfg( test) ]
375470mod tests {
376- use super :: { compute_host_header, BackendConfig } ;
471+ use super :: {
472+ compute_host_header, BackendConfig , MAX_BACKEND_NAME_LEN , SPEC_DIGEST_HEX_LEN ,
473+ } ;
474+
475+ /// Assert a computed name is `backend_<body>_<hex digest>` and stays within
476+ /// Fastly's length limit. The digest is what makes the name injective, so
477+ /// checking its presence and width guards the collision-safety property.
478+ fn assert_backend_name_shape ( name : & str , expected_body : & str ) {
479+ let prefix = format ! ( "backend_{expected_body}_" ) ;
480+ assert ! (
481+ name. starts_with( & prefix) ,
482+ "name should start with the readable body `{prefix}`, got {name}"
483+ ) ;
484+ let digest = & name[ prefix. len ( ) ..] ;
485+ assert_eq ! (
486+ digest. len( ) ,
487+ SPEC_DIGEST_HEX_LEN ,
488+ "digest suffix should be {SPEC_DIGEST_HEX_LEN} hex chars, got {digest}"
489+ ) ;
490+ assert ! (
491+ digest. bytes( ) . all( |byte| byte. is_ascii_hexdigit( ) ) ,
492+ "digest suffix should be hex, got {digest}"
493+ ) ;
494+ assert ! (
495+ name. len( ) <= MAX_BACKEND_NAME_LEN ,
496+ "name should stay within the {MAX_BACKEND_NAME_LEN}-char limit, got {}" ,
497+ name. len( )
498+ ) ;
499+ }
377500
378501 // Tests for compute_host_header - the fix for port preservation in Host header
379502 #[ test]
@@ -422,7 +545,7 @@ mod tests {
422545 let name = BackendConfig :: new ( "https" , "origin.example.com" )
423546 . ensure ( )
424547 . expect ( "should create backend for valid HTTPS origin" ) ;
425- assert_eq ! ( name, "backend_https_origin_example_com_443_fb15000_bb10000 " ) ;
548+ assert_backend_name_shape ( & name, "https_origin_example_com_443_fb15000_bb10000 " ) ;
426549 }
427550
428551 #[ test]
@@ -431,10 +554,7 @@ mod tests {
431554 . certificate_check ( false )
432555 . ensure ( )
433556 . expect ( "should create backend with cert check disabled" ) ;
434- assert_eq ! (
435- name,
436- "backend_https_origin_example_com_443_nocert_fb15000_bb10000"
437- ) ;
557+ assert_backend_name_shape ( & name, "https_origin_example_com_443_nocert_fb15000_bb10000" ) ;
438558 }
439559
440560 #[ test]
@@ -443,15 +563,15 @@ mod tests {
443563 . port ( Some ( 8080 ) )
444564 . ensure ( )
445565 . expect ( "should create backend for HTTP origin with explicit port" ) ;
446- assert_eq ! ( name, "backend_http_api_test -site_org_8080_fb15000_bb10000" ) ;
566+ assert_backend_name_shape ( & name, "http_api_test -site_org_8080_fb15000_bb10000" ) ;
447567 }
448568
449569 #[ test]
450570 fn returns_name_for_http_without_port_defaults_to_80 ( ) {
451571 let name = BackendConfig :: new ( "http" , "example.org" )
452572 . ensure ( )
453573 . expect ( "should create backend defaulting to port 80 for HTTP" ) ;
454- assert_eq ! ( name, "backend_http_example_org_80_fb15000_bb10000 " ) ;
574+ assert_backend_name_shape ( & name, "http_example_org_80_fb15000_bb10000 " ) ;
455575 }
456576
457577 #[ test]
@@ -506,13 +626,13 @@ mod tests {
506626 name_a, name_b,
507627 "backends with different host header overrides should have different names"
508628 ) ;
509- assert_eq ! (
510- name_a,
511- "backend_https_origin_example_com_443_oh_www_example_com_fb15000_bb10000"
629+ assert_backend_name_shape (
630+ & name_a,
631+ "https_origin_example_com_443_oh_www_example_com_fb15000_bb10000" ,
512632 ) ;
513- assert_eq ! (
514- name_b,
515- "backend_https_origin_example_com_443_oh_m_example_com_fb15000_bb10000"
633+ assert_backend_name_shape (
634+ & name_b,
635+ "https_origin_example_com_443_oh_m_example_com_fb15000_bb10000" ,
516636 ) ;
517637 }
518638
@@ -567,12 +687,12 @@ mod tests {
567687 "backends with different timeouts should have different names"
568688 ) ;
569689 assert ! (
570- name_a. ends_with ( "_fb2000_bb10000 ") ,
571- "name should include first-byte and between-bytes timeout suffix "
690+ name_a. contains ( "_fb2000_bb10000_ ") ,
691+ "name should include first-byte and between-bytes timeout in the readable body "
572692 ) ;
573693 assert ! (
574- name_b. ends_with ( "_fb500_bb10000 ") ,
575- "name should include first-byte and between-bytes timeout suffix "
694+ name_b. contains ( "_fb500_bb10000_ ") ,
695+ "name should include first-byte and between-bytes timeout in the readable body "
576696 ) ;
577697 }
578698
@@ -594,12 +714,89 @@ mod tests {
594714 "backends with different between-bytes timeouts should have different names"
595715 ) ;
596716 assert ! (
597- name_a. ends_with ( "_fb15000_bb2000 ") ,
598- "name should include first-byte and between-bytes timeout suffix "
717+ name_a. contains ( "_fb15000_bb2000_ ") ,
718+ "name should include first-byte and between-bytes timeout in the readable body "
599719 ) ;
600720 assert ! (
601- name_b. ends_with( "_fb15000_bb500" ) ,
602- "name should include first-byte and between-bytes timeout suffix"
721+ name_b. contains( "_fb15000_bb500_" ) ,
722+ "name should include first-byte and between-bytes timeout in the readable body"
723+ ) ;
724+ }
725+
726+ #[ test]
727+ fn discriminators_that_sanitize_alike_produce_distinct_names ( ) {
728+ // `provider.a` and `provider_a` both sanitize to the same readable slug
729+ // (`.` maps to `_`). Before the spec digest they collided to one backend
730+ // name, so the second registration silently reused the first — routing
731+ // one provider's auction traffic through another's backend. The digest
732+ // over the raw spec must keep them distinct.
733+ let dotted = BackendConfig :: new ( "https" , "gateway.example.com" )
734+ . discriminator ( Some ( "provider.a" ) )
735+ . predict_name ( )
736+ . expect ( "should predict name for dotted discriminator" ) ;
737+ let underscored = BackendConfig :: new ( "https" , "gateway.example.com" )
738+ . discriminator ( Some ( "provider_a" ) )
739+ . predict_name ( )
740+ . expect ( "should predict name for underscored discriminator" ) ;
741+ assert_ne ! (
742+ dotted, underscored,
743+ "discriminators differing only by a sanitized character must not collide"
744+ ) ;
745+ }
746+
747+ #[ test]
748+ fn host_overrides_that_sanitize_alike_produce_distinct_names ( ) {
749+ // `host.example.com:8443` (host+port) and `host.example.com.8443` (DNS
750+ // label) are both valid overrides that sanitize to the same readable
751+ // slug (`:` and `.` both map to `_`). The digest over the raw value must
752+ // keep the two backends — with different Host routing — distinct.
753+ let with_port = BackendConfig :: new ( "https" , "origin.example.com" )
754+ . host_header_override ( Some ( "host.example.com:8443" ) )
755+ . predict_name ( )
756+ . expect ( "should predict name for host:port override" ) ;
757+ let with_label = BackendConfig :: new ( "https" , "origin.example.com" )
758+ . host_header_override ( Some ( "host.example.com.8443" ) )
759+ . predict_name ( )
760+ . expect ( "should predict name for dotted-label override" ) ;
761+ assert_ne ! (
762+ with_port, with_label,
763+ "host overrides differing only by a sanitized character must not collide"
764+ ) ;
765+ }
766+
767+ #[ test]
768+ fn long_host_and_discriminator_stay_within_the_length_limit ( ) {
769+ // A syntactically valid maximum-length DNS host plus a discriminator
770+ // previously pushed the name past Fastly's 255-char limit, so
771+ // `predict_name` succeeded while `ensure` failed at registration. The
772+ // bounded prefix + fixed-width digest must keep prediction, and the name
773+ // it predicts, within the limit.
774+ let label = "a" . repeat ( 63 ) ;
775+ let long_host = format ! ( "{label}.{label}.{label}.{label}.example.com" ) ;
776+ assert ! (
777+ long_host. len( ) > 200 ,
778+ "should exercise a host longer than the readable-prefix bound"
779+ ) ;
780+ let name = BackendConfig :: new ( "https" , & long_host)
781+ . discriminator ( Some ( "prebid" ) )
782+ . predict_name ( )
783+ . expect ( "should predict a bounded name for a long host and discriminator" ) ;
784+ assert ! (
785+ name. len( ) <= MAX_BACKEND_NAME_LEN ,
786+ "name should stay within the {MAX_BACKEND_NAME_LEN}-char limit, got {}" ,
787+ name. len( )
788+ ) ;
789+
790+ // Two long hosts sharing the truncated prefix must still resolve to
791+ // different backends via the digest.
792+ let other_host = format ! ( "{label}.{label}.{label}.{label}.example.net" ) ;
793+ let other = BackendConfig :: new ( "https" , & other_host)
794+ . discriminator ( Some ( "prebid" ) )
795+ . predict_name ( )
796+ . expect ( "should predict a bounded name for the sibling host" ) ;
797+ assert_ne ! (
798+ name, other,
799+ "hosts sharing a truncated prefix must stay distinct via the digest"
603800 ) ;
604801 }
605802}
0 commit comments