Skip to content

Commit e616375

Browse files
mmagicianclaude
andauthored
fix: lenient pre-release matching in accept header validation (#1755)
* fix: lenient pre-release matching in accept header validation Match on the pre-release label (e.g. "alpha") but ignore the numeric suffix, so a server at 0.14.0-alpha.3 accepts clients at alpha.1, alpha.2, etc. This allows client and node to evolve independently within the same pre-release phase. - Stable and pre-release remain incompatible - Different labels (alpha vs beta) remain incompatible - Patch versions remain flexible (0.14.0 and 0.14.1 both accepted) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: add changelog entry for lenient pre-release matching Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: reject different patch versions for pre-release Pre-release versions now require exact patch match in addition to matching the pre-release label. Patch flexibility only applies to stable versions. For example, a 0.14.0-alpha.3 server rejects 0.14.1-alpha.1 but a stable 0.14.0 server still accepts 0.14.1. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * lint --------- Co-authored-by: Claude (Opus) <noreply@anthropic.com>
1 parent f76566e commit e616375

File tree

2 files changed

+90
-4
lines changed

2 files changed

+90
-4
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
- Fixed `bundled start` panicking due to duplicate `data_directory` clap argument name between `BundledCommand::Start` and `NtxBuilderConfig` ([#1732](https://github.com/0xMiden/node/pull/1732)).
3333
- Fixed `bundled bootstrap` requiring `--validator.key.hex` or `--validator.key.kms-id` despite a default key being configured ([#1732](https://github.com/0xMiden/node/pull/1732)).
3434
- Fixed incorrectly classifying private notes with the network attachment as network notes ([#1378](https://github.com/0xMiden/node/pull/1738)).
35+
- Fixed accept header version negotiation rejecting all pre-release versions; pre-release label matching is now lenient, accepting any numeric suffix within the same label (e.g. `alpha.3` accepts `alpha.1`) ([#1755](https://github.com/0xMiden/node/pull/1755)).
3536

3637
## v0.13.7 (2026-02-25)
3738

crates/rpc/src/server/accept.rs

Lines changed: 89 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,13 @@ pub enum GenesisNegotiation {
4141
#[derive(Clone)]
4242
pub struct AcceptHeaderLayer {
4343
supported_versions: VersionReq,
44+
/// The pre-release label (e.g. `"alpha"` from `"alpha.3"`), or `None` for stable versions.
45+
/// Only the label is stored so that different pre-release numbers are accepted
46+
/// (e.g. a server at `alpha.3` accepts clients at `alpha.1`).
47+
expected_pre_label: Option<String>,
48+
/// The patch version of the server. Used to enforce exact patch matching for pre-release
49+
/// versions (patch flexibility only applies to stable versions).
50+
expected_patch: u64,
4451
genesis_commitment: Word,
4552
/// RPC method names for which the `genesis` parameter is mandatory.
4653
///
@@ -82,8 +89,12 @@ impl AcceptHeaderLayer {
8289
}],
8390
};
8491

92+
let expected_pre_label = pre_release_label(&rpc_version.pre);
93+
8594
AcceptHeaderLayer {
8695
supported_versions,
96+
expected_pre_label,
97+
expected_patch: rpc_version.patch,
8798
genesis_commitment,
8899
require_genesis_methods: Vec::new(),
89100
}
@@ -96,6 +107,23 @@ impl AcceptHeaderLayer {
96107
}
97108
}
98109

110+
/// Extracts the label portion of a semver pre-release identifier, stripping any trailing
111+
/// numeric segment. For example, `"alpha.3"` returns `Some("alpha")` and `"rc.1"` returns
112+
/// `Some("rc")`. Returns `None` for empty (stable) pre-release identifiers.
113+
fn pre_release_label(pre: &semver::Prerelease) -> Option<String> {
114+
if pre.is_empty() {
115+
return None;
116+
}
117+
let s = pre.as_str();
118+
// Strip the trailing `.N` numeric segment if present.
119+
match s.rsplit_once('.') {
120+
Some((label, suffix)) if suffix.bytes().all(|b| b.is_ascii_digit()) => {
121+
Some(label.to_string())
122+
},
123+
_ => Some(s.to_string()),
124+
}
125+
}
126+
99127
impl<S> Layer<S> for AcceptHeaderLayer {
100128
type Service = AcceptHeaderService<S>;
101129

@@ -168,15 +196,33 @@ impl AcceptHeaderLayer {
168196
}
169197

170198
// Skip those that don't match the version requirement.
199+
//
200+
// The VersionReq checks major.minor compatibility. Pre-release labels are
201+
// checked separately because semver's VersionReq matching rejects all
202+
// pre-release versions when the comparator has no pre-release component.
171203
let version = media_type
172204
.get_param(Self::VERSION)
173205
.map(|value| Version::parse(value.unquoted_str().as_ref()))
174206
.transpose()
175207
.map_err(AcceptHeaderError::InvalidVersion)?;
176-
if let Some(version) = version
177-
&& !self.supported_versions.matches(&version)
178-
{
179-
continue;
208+
if let Some(version) = &version {
209+
// Check major.minor match by stripping pre-release first.
210+
let stable_version = Version {
211+
pre: semver::Prerelease::EMPTY,
212+
..version.clone()
213+
};
214+
if !self.supported_versions.matches(&stable_version) {
215+
continue;
216+
}
217+
// Check the pre-release label matches (ignoring the numeric suffix).
218+
if pre_release_label(&version.pre) != self.expected_pre_label {
219+
continue;
220+
}
221+
// Pre-release versions must also match the patch version exactly
222+
// (patch flexibility only applies to stable versions).
223+
if self.expected_pre_label.is_some() && version.patch != self.expected_patch {
224+
continue;
225+
}
180226
}
181227

182228
// Skip if the genesis commitment does not match, or if it is required but missing.
@@ -413,6 +459,7 @@ mod tests {
413459
#[case::invalid_genesis("application/vnd.miden; genesis=aaa")]
414460
#[case::version_too_old("application/vnd.miden; version=0.1.0")]
415461
#[case::version_too_new("application/vnd.miden; version=0.3.0")]
462+
#[case::version_prerelease_rejected_by_stable("application/vnd.miden; version=0.2.3-alpha.1")]
416463
#[case::zero_weighting("application/vnd.miden; q=0.0")]
417464
#[case::wildcard_subtype("application/*")]
418465
#[test]
@@ -498,4 +545,42 @@ mod tests {
498545
fn qvalue_default_is_one() {
499546
assert_eq!(QValue::default(), QValue::new(1_000));
500547
}
548+
549+
mod prerelease {
550+
use semver::Version;
551+
552+
use super::*;
553+
554+
impl AcceptHeaderLayer {
555+
fn for_prerelease_tests() -> Self {
556+
let version = Version::parse("0.14.0-alpha.3").unwrap();
557+
Self::new(&version, Word::try_from(TEST_GENESIS_COMMITMENT).unwrap())
558+
}
559+
}
560+
561+
#[rstest::rstest]
562+
#[case::empty("")]
563+
#[case::wildcard("*/*")]
564+
#[case::media_type_only("application/vnd.miden")]
565+
#[case::exact_prerelease("application/vnd.miden; version=0.14.0-alpha.3")]
566+
#[case::different_prerelease_number("application/vnd.miden; version=0.14.0-alpha.1")]
567+
#[test]
568+
fn prerelease_should_pass(#[case] accept: &'static str) {
569+
AcceptHeaderLayer::for_prerelease_tests()
570+
.negotiate(accept, super::super::GenesisNegotiation::Optional)
571+
.unwrap();
572+
}
573+
574+
#[rstest::rstest]
575+
#[case::different_patch_same_prerelease("application/vnd.miden; version=0.14.1-alpha.3")]
576+
#[case::different_patch_different_number("application/vnd.miden; version=0.14.2-alpha.5")]
577+
#[case::different_prerelease_tag("application/vnd.miden; version=0.14.0-beta.3")]
578+
#[case::stable_version("application/vnd.miden; version=0.14.0")]
579+
#[test]
580+
fn prerelease_should_be_rejected(#[case] accept: &'static str) {
581+
AcceptHeaderLayer::for_prerelease_tests()
582+
.negotiate(accept, super::super::GenesisNegotiation::Optional)
583+
.unwrap_err();
584+
}
585+
}
501586
}

0 commit comments

Comments
 (0)