Skip to main content

rustfs_protos/
lib.rs

1// Copyright 2024 RustFS Team
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15pub mod compat_manifest;
16// SAFETY: `generated` is prost/tonic-generated protocol code. The allowance is
17// scoped to that module so generated internals do not relax lints elsewhere.
18#[allow(unsafe_code)]
19mod generated;
20pub mod heal_control;
21mod runtime_sources;
22
23use proto_gen::node_service::node_service_client::NodeServiceClient;
24use rustfs_common::{cache_connection, cached_connection, evict_connection_with_log_level};
25use std::{
26    collections::HashMap,
27    error::Error,
28    sync::LazyLock,
29    sync::atomic::{AtomicU8, AtomicUsize, Ordering},
30    time::{Duration, Instant},
31};
32use tokio::sync::Mutex;
33use tonic::{
34    Request, Status,
35    service::interceptor::InterceptedService,
36    transport::{Certificate, Channel, ClientTlsConfig, Endpoint},
37};
38use tracing::{debug, info, warn};
39use uuid::Uuid;
40
41// Type alias for the complex client type
42pub type NodeServiceClientType = NodeServiceClient<
43    InterceptedService<Channel, Box<dyn Fn(Request<()>) -> Result<Request<()>, Status> + Send + Sync + 'static>>,
44>;
45
46pub use generated::*;
47pub use rustfs_common::ConnectionEvictionLogLevel;
48
49// Default 100 MB
50pub const DEFAULT_GRPC_SERVER_MESSAGE_LEN: usize = 100 * 1024 * 1024;
51
52/// Default HTTPS prefix for rustfs
53/// This is the default HTTPS prefix for rustfs.
54/// It is used to identify HTTPS URLs.
55/// Default value: https://
56const RUSTFS_HTTPS_PREFIX: &str = "https://";
57const TLS_GENERATION_CACHE_MAX_SIZE: usize = 512;
58const INTERNODE_RPC_MSGPACK_ONLY_CACHE_UNSET: u8 = 0;
59const INTERNODE_RPC_MSGPACK_ONLY_CACHE_FALSE: u8 = 1;
60const INTERNODE_RPC_MSGPACK_ONLY_CACHE_TRUE: u8 = 2;
61static TLS_GENERATION_CACHE: LazyLock<Mutex<HashMap<String, u64>>> = LazyLock::new(|| Mutex::new(HashMap::new()));
62static INTERNODE_RPC_MSGPACK_ONLY_CACHE: AtomicU8 = AtomicU8::new(INTERNODE_RPC_MSGPACK_ONLY_CACHE_UNSET);
63
64fn enforce_tls_generation_cache_bound(generation_cache: &mut HashMap<String, u64>, generation: u64, addr: &str) {
65    if generation_cache.len() < TLS_GENERATION_CACHE_MAX_SIZE || generation_cache.contains_key(addr) {
66        return;
67    }
68
69    generation_cache.retain(|_, g| *g == generation);
70    if generation_cache.len() >= TLS_GENERATION_CACHE_MAX_SIZE
71        && let Some(victim) = generation_cache.keys().next().cloned()
72    {
73        generation_cache.remove(&victim);
74    }
75}
76
77fn internode_connect_timeout() -> Duration {
78    Duration::from_secs(rustfs_utils::get_env_u64(
79        rustfs_config::ENV_INTERNODE_CONNECT_TIMEOUT_SECS,
80        rustfs_config::DEFAULT_INTERNODE_CONNECT_TIMEOUT_SECS,
81    ))
82}
83
84fn internode_tcp_keepalive() -> Duration {
85    Duration::from_secs(rustfs_utils::get_env_u64(
86        rustfs_config::ENV_INTERNODE_TCP_KEEPALIVE_SECS,
87        rustfs_config::DEFAULT_INTERNODE_TCP_KEEPALIVE_SECS,
88    ))
89}
90
91fn internode_http2_keep_alive_interval() -> Duration {
92    Duration::from_secs(rustfs_utils::get_env_u64(
93        rustfs_config::ENV_INTERNODE_HTTP2_KEEPALIVE_INTERVAL_SECS,
94        rustfs_config::DEFAULT_INTERNODE_HTTP2_KEEPALIVE_INTERVAL_SECS,
95    ))
96}
97
98fn internode_http2_keep_alive_timeout() -> Duration {
99    Duration::from_secs(rustfs_utils::get_env_u64(
100        rustfs_config::ENV_INTERNODE_HTTP2_KEEPALIVE_TIMEOUT_SECS,
101        rustfs_config::DEFAULT_INTERNODE_HTTP2_KEEPALIVE_TIMEOUT_SECS,
102    ))
103}
104
105fn internode_rpc_timeout() -> Duration {
106    normalize_internode_rpc_timeout(Duration::from_secs(rustfs_utils::get_env_u64(
107        rustfs_config::ENV_INTERNODE_RPC_TIMEOUT_SECS,
108        rustfs_config::DEFAULT_INTERNODE_RPC_TIMEOUT_SECS,
109    )))
110}
111
112fn normalize_internode_rpc_timeout(timeout: Duration) -> Duration {
113    timeout.max(Duration::from_secs(1))
114}
115
116/// Budget for one heal-control execution, kept below the transport timeout so
117/// the coordinator stops waiting for admission before the caller gives up.
118pub fn heal_control_execution_timeout() -> Duration {
119    heal_control_execution_timeout_for(internode_rpc_timeout())
120}
121
122fn heal_control_execution_timeout_for(transport_timeout: Duration) -> Duration {
123    const TRANSPORT_GUARD: Duration = Duration::from_secs(1);
124    let transport_timeout = transport_timeout.max(Duration::from_secs(1));
125    transport_timeout
126        .saturating_sub(TRANSPORT_GUARD.min(transport_timeout / 2))
127        .max(Duration::from_millis(1))
128        .min(Duration::from_millis(
129            u64::try_from(heal_control::MAX_LIFETIME_MS).expect("positive heal control lifetime must fit u64"),
130        ))
131}
132
133fn internode_rpc_tcp_nodelay() -> bool {
134    rustfs_utils::get_env_bool(
135        rustfs_config::ENV_INTERNODE_RPC_TCP_NODELAY,
136        rustfs_config::DEFAULT_INTERNODE_RPC_TCP_NODELAY,
137    )
138}
139
140/// HTTP/2 initial stream window size for the client channel, or `None` to use the
141/// library default (a configured value of `0` opts out).
142fn internode_rpc_http2_stream_window() -> Option<u32> {
143    match rustfs_utils::get_env_u32(
144        rustfs_config::ENV_INTERNODE_RPC_HTTP2_STREAM_WINDOW_SIZE,
145        rustfs_config::DEFAULT_INTERNODE_RPC_HTTP2_STREAM_WINDOW_SIZE,
146    ) {
147        0 => None,
148        v => Some(v),
149    }
150}
151
152/// HTTP/2 initial connection window size for the client channel, or `None` to use the
153/// library default (a configured value of `0` opts out).
154fn internode_rpc_http2_conn_window() -> Option<u32> {
155    match rustfs_utils::get_env_u32(
156        rustfs_config::ENV_INTERNODE_RPC_HTTP2_CONN_WINDOW_SIZE,
157        rustfs_config::DEFAULT_INTERNODE_RPC_HTTP2_CONN_WINDOW_SIZE,
158    ) {
159        0 => None,
160        v => Some(v),
161    }
162}
163
164/// Maximum encoded/decoded internode gRPC message size (bytes), shared by the client
165/// `NodeServiceClient` and server `NodeServiceServer`. Defaults to
166/// [`DEFAULT_GRPC_SERVER_MESSAGE_LEN`] (100 MiB) when the env var is unset.
167pub fn internode_rpc_max_message_size() -> usize {
168    rustfs_utils::get_env_usize(rustfs_config::ENV_INTERNODE_RPC_MAX_MESSAGE_SIZE, DEFAULT_GRPC_SERVER_MESSAGE_LEN)
169}
170
171pub const HEAL_CONTROL_RPC_MAX_MESSAGE_SIZE: usize = heal_control::RESULT_MAX_SIZE + 1024;
172pub const HEAL_CONTROL_PROTOCOL_VERSION: u32 = 3;
173pub const DYNAMIC_CONFIG_PROTOCOL_VERSION: u32 = 1;
174pub const BACKGROUND_HEAL_STATUS_PROTOCOL_VERSION: u32 = 2;
175// v4 is an admission boundary, not a transport version bump. A peer that
176// cannot recognize this probe must fail the Admin Heal capability preflight;
177// accepting the older v3 probe would allow an upgraded node to silently lose
178// newly validated request semantics during a rolling upgrade.
179pub const HEAL_CONTROL_CAPABILITY_PROBE_PREFIX: &[u8] = b"rustfs-heal-control-capability-v4\0";
180pub const REMOTE_VERSION_STATE_CAPABILITY_PROBE_PREFIX: &[u8] = b"rustfs-tier-remote-version-state-capability-v1\0";
181pub const CROSS_POOL_FENCE_CAPABILITY_PROBE_PREFIX: &[u8] = b"rustfs-cross-pool-fence-capability-v1\0";
182pub const ILM_RECOVERY_EXPORT_CAPABILITY_PROBE_PREFIX: &[u8] = b"rustfs-ilm-recovery-export-capability-v1\0";
183pub const TRANSITION_TRANSACTION_COMPACTION_CAPABILITY_PROBE_PREFIX: &[u8] =
184    b"rustfs-transition-transaction-compaction-capability-v1\0";
185pub const TIER_MUTATION_RPC_MAX_PREPARE_PAYLOAD_SIZE: usize = 64 * 1024;
186pub const TIER_MUTATION_RPC_MAX_COMMIT_PAYLOAD_SIZE: usize = 1024;
187pub const TIER_MUTATION_RPC_MAX_ABORT_PAYLOAD_SIZE: usize = TIER_MUTATION_RPC_MAX_PREPARE_PAYLOAD_SIZE;
188pub const TIER_MUTATION_RPC_MAX_MESSAGE_SIZE: usize = TIER_MUTATION_RPC_MAX_PREPARE_PAYLOAD_SIZE + 4096;
189
190pub fn heal_control_coordinator_epoch(topology_fingerprint: &str) -> Result<u64, &'static str> {
191    let prefix = topology_fingerprint
192        .get(..16)
193        .ok_or("heal control topology fingerprint is too short")?;
194    let epoch = u64::from_str_radix(prefix, 16).map_err(|_| "heal control topology fingerprint is not hexadecimal")?;
195    if epoch == 0 {
196        return Err("heal control topology epoch is zero");
197    }
198    Ok(epoch)
199}
200
201pub fn heal_control_capability_probe(nonce: &[u8; 16]) -> Vec<u8> {
202    let mut probe = Vec::with_capacity(HEAL_CONTROL_CAPABILITY_PROBE_PREFIX.len() + nonce.len());
203    probe.extend_from_slice(HEAL_CONTROL_CAPABILITY_PROBE_PREFIX);
204    probe.extend_from_slice(nonce);
205    probe
206}
207
208pub fn is_heal_control_capability_probe(command: &[u8]) -> bool {
209    command.len() == HEAL_CONTROL_CAPABILITY_PROBE_PREFIX.len() + 16 && command.starts_with(HEAL_CONTROL_CAPABILITY_PROBE_PREFIX)
210}
211
212pub fn remote_version_state_capability_probe(nonce: &[u8; 16]) -> Vec<u8> {
213    let mut probe = Vec::with_capacity(REMOTE_VERSION_STATE_CAPABILITY_PROBE_PREFIX.len() + nonce.len());
214    probe.extend_from_slice(REMOTE_VERSION_STATE_CAPABILITY_PROBE_PREFIX);
215    probe.extend_from_slice(nonce);
216    probe
217}
218
219pub fn is_remote_version_state_capability_probe(command: &[u8]) -> bool {
220    command.len() == REMOTE_VERSION_STATE_CAPABILITY_PROBE_PREFIX.len() + 16
221        && command.starts_with(REMOTE_VERSION_STATE_CAPABILITY_PROBE_PREFIX)
222}
223
224pub fn is_cross_pool_fence_capability_probe(command: &[u8]) -> bool {
225    command.len() == CROSS_POOL_FENCE_CAPABILITY_PROBE_PREFIX.len() + 16
226        && command.starts_with(CROSS_POOL_FENCE_CAPABILITY_PROBE_PREFIX)
227}
228
229pub fn ilm_recovery_export_capability_probe(nonce: &[u8; 16]) -> Vec<u8> {
230    let mut probe = Vec::with_capacity(ILM_RECOVERY_EXPORT_CAPABILITY_PROBE_PREFIX.len() + nonce.len());
231    probe.extend_from_slice(ILM_RECOVERY_EXPORT_CAPABILITY_PROBE_PREFIX);
232    probe.extend_from_slice(nonce);
233    probe
234}
235
236pub fn is_ilm_recovery_export_capability_probe(command: &[u8]) -> bool {
237    command.len() == ILM_RECOVERY_EXPORT_CAPABILITY_PROBE_PREFIX.len() + 16
238        && command.starts_with(ILM_RECOVERY_EXPORT_CAPABILITY_PROBE_PREFIX)
239}
240
241pub fn transition_transaction_compaction_capability_probe(nonce: &[u8; 16]) -> Vec<u8> {
242    let mut probe = Vec::with_capacity(TRANSITION_TRANSACTION_COMPACTION_CAPABILITY_PROBE_PREFIX.len() + nonce.len());
243    probe.extend_from_slice(TRANSITION_TRANSACTION_COMPACTION_CAPABILITY_PROBE_PREFIX);
244    probe.extend_from_slice(nonce);
245    probe
246}
247
248pub fn is_transition_transaction_compaction_capability_probe(command: &[u8]) -> bool {
249    command.len() == TRANSITION_TRANSACTION_COMPACTION_CAPABILITY_PROBE_PREFIX.len() + 16
250        && command.starts_with(TRANSITION_TRANSACTION_COMPACTION_CAPABILITY_PROBE_PREFIX)
251}
252
253pub fn encode_remote_version_state_capability(
254    topology_member: &str,
255    process_epoch: &[u8; 16],
256) -> Result<Vec<u8>, std::num::TryFromIntError> {
257    let topology_member = topology_member.as_bytes();
258    let mut result = Vec::with_capacity(8 + topology_member.len() + process_epoch.len());
259    result.extend_from_slice(&u64::try_from(topology_member.len())?.to_be_bytes());
260    result.extend_from_slice(topology_member);
261    result.extend_from_slice(process_epoch);
262    Ok(result)
263}
264
265pub fn decode_remote_version_state_capability(result: &[u8]) -> Result<(&str, &[u8; 16]), &'static str> {
266    let member_len = result
267        .get(..8)
268        .and_then(|value| value.try_into().ok())
269        .map(u64::from_be_bytes)
270        .ok_or("remote version state capability is truncated")?;
271    let member_len = usize::try_from(member_len).map_err(|_| "remote version state member length cannot be represented")?;
272    let member_end = 8_usize
273        .checked_add(member_len)
274        .ok_or("remote version state member length overflow")?;
275    let topology_member = std::str::from_utf8(result.get(8..member_end).ok_or("remote version state member is truncated")?)
276        .map_err(|_| "remote version state member is not UTF-8")?;
277    let process_epoch = result
278        .get(member_end..)
279        .and_then(|value| value.try_into().ok())
280        .ok_or("remote version state process epoch has an invalid length")?;
281    Ok((topology_member, process_epoch))
282}
283
284pub fn encode_cross_pool_fence_capability(
285    supported_version: u32,
286    topology_member: &str,
287    process_epoch: &[u8; 16],
288) -> Result<Vec<u8>, std::num::TryFromIntError> {
289    let identity = encode_remote_version_state_capability(topology_member, process_epoch)?;
290    let mut result = Vec::with_capacity(4 + identity.len());
291    result.extend_from_slice(&supported_version.to_be_bytes());
292    result.extend_from_slice(&identity);
293    Ok(result)
294}
295
296/// Builds the stable byte representation authenticated for a heal-control request.
297///
298/// This deliberately does not reuse protobuf encoding: mixed-version peers may
299/// retain unknown protobuf fields, which must not change the signed contract.
300pub fn canonical_heal_control_request_body(
301    version: u32,
302    topology_fingerprint: &str,
303    command: &[u8],
304) -> Result<Vec<u8>, std::num::TryFromIntError> {
305    const DOMAIN: &[u8] = b"rustfs-heal-control-v3\0";
306
307    let fingerprint = topology_fingerprint.as_bytes();
308    let mut body = Vec::with_capacity(DOMAIN.len() + 4 + 8 + fingerprint.len() + 8 + command.len());
309    body.extend_from_slice(DOMAIN);
310    body.extend_from_slice(&version.to_be_bytes());
311    body.extend_from_slice(&u64::try_from(fingerprint.len())?.to_be_bytes());
312    body.extend_from_slice(fingerprint);
313    body.extend_from_slice(&u64::try_from(command.len())?.to_be_bytes());
314    body.extend_from_slice(command);
315    Ok(body)
316}
317
318/// Builds the exact acknowledgement expected from a peer that supports the
319/// heal-control protocol and agrees with the caller's storage topology.
320pub fn canonical_heal_control_capability_ack(
321    version: u32,
322    topology_fingerprint: &str,
323    probe: &[u8],
324) -> Result<Vec<u8>, std::num::TryFromIntError> {
325    const DOMAIN: &[u8] = b"rustfs-heal-control-capability-ack-v4\0";
326
327    let fingerprint = topology_fingerprint.as_bytes();
328    let mut body = Vec::with_capacity(DOMAIN.len() + 4 + 8 + fingerprint.len() + 8 + probe.len());
329    body.extend_from_slice(DOMAIN);
330    body.extend_from_slice(&version.to_be_bytes());
331    body.extend_from_slice(&u64::try_from(fingerprint.len())?.to_be_bytes());
332    body.extend_from_slice(fingerprint);
333    body.extend_from_slice(&u64::try_from(probe.len())?.to_be_bytes());
334    body.extend_from_slice(probe);
335    Ok(body)
336}
337
338pub fn canonical_heal_control_response_body(
339    version: u32,
340    topology_fingerprint: &str,
341    command: &[u8],
342    result: &[u8],
343) -> Result<Vec<u8>, std::num::TryFromIntError> {
344    const DOMAIN: &[u8] = b"rustfs-heal-control-response-v3\0";
345
346    let fingerprint = topology_fingerprint.as_bytes();
347    let mut body = Vec::with_capacity(DOMAIN.len() + 4 + 8 + fingerprint.len() + 8 + command.len() + 8 + result.len());
348    body.extend_from_slice(DOMAIN);
349    body.extend_from_slice(&version.to_be_bytes());
350    body.extend_from_slice(&u64::try_from(fingerprint.len())?.to_be_bytes());
351    body.extend_from_slice(fingerprint);
352    body.extend_from_slice(&u64::try_from(command.len())?.to_be_bytes());
353    body.extend_from_slice(command);
354    body.extend_from_slice(&u64::try_from(result.len())?.to_be_bytes());
355    body.extend_from_slice(result);
356    Ok(body)
357}
358
359#[derive(Clone, Copy, Debug, PartialEq, Eq)]
360#[non_exhaustive]
361pub enum TierMutationRpcPhase {
362    Prepare,
363    Commit,
364    Abort,
365}
366
367impl TierMutationRpcPhase {
368    fn as_wire_str(self) -> &'static str {
369        match self {
370            Self::Prepare => "prepare",
371            Self::Commit => "commit",
372            Self::Abort => "abort",
373        }
374    }
375}
376
377// Version 2 required peer Prepare to block new tier-reference creators and
378// drain their in-flight operation leases. Version 3 additionally binds Abort
379// to the canonical Prepare intent so a missing-record Abort can persist an
380// identity-bound tombstone and linearize against a delayed Prepare. Version 4
381// signs a typed failure classification while retaining the exact v3 proof
382// bytes for rolling compatibility.
383pub const TIER_MUTATION_RPC_PREVIOUS_PROTOCOL_VERSION: u32 = 3;
384pub const TIER_MUTATION_RPC_PROTOCOL_VERSION: u32 = 4;
385pub const TIER_MUTATION_RPC_MAX_ERROR_INFO_SIZE: usize = 1024;
386pub const TIER_MUTATION_RPC_MAX_RESPONSE_PROOF_SIZE: usize = 4096;
387
388pub fn canonical_tier_mutation_rpc_body(
389    version: u32,
390    phase: TierMutationRpcPhase,
391    mutation_id: Uuid,
392    canonical_payload: &[u8],
393) -> Result<Vec<u8>, std::num::TryFromIntError> {
394    const DOMAIN: &[u8] = b"rustfs-tier-mutation-rpc-v1\0";
395
396    let phase = phase.as_wire_str().as_bytes();
397    let mutation_id = mutation_id.as_bytes();
398    let mut body = Vec::with_capacity(DOMAIN.len() + 4 + 8 + phase.len() + mutation_id.len() + 8 + canonical_payload.len());
399    body.extend_from_slice(DOMAIN);
400    body.extend_from_slice(&version.to_be_bytes());
401    body.extend_from_slice(&u64::try_from(phase.len())?.to_be_bytes());
402    body.extend_from_slice(phase);
403    body.extend_from_slice(mutation_id);
404    body.extend_from_slice(&u64::try_from(canonical_payload.len())?.to_be_bytes());
405    body.extend_from_slice(canonical_payload);
406    Ok(body)
407}
408
409pub struct TierMutationRpcResponseProofInput<'a> {
410    pub version: u32,
411    pub phase: TierMutationRpcPhase,
412    pub mutation_id: Uuid,
413    pub canonical_payload: &'a [u8],
414    pub success: bool,
415    pub state: i32,
416    pub applied: bool,
417    pub error_info: Option<&'a str>,
418    pub failure_class: i32,
419}
420
421pub fn canonical_tier_mutation_rpc_response_body(
422    input: TierMutationRpcResponseProofInput<'_>,
423) -> Result<Vec<u8>, std::num::TryFromIntError> {
424    const V3_DOMAIN: &[u8] = b"rustfs-tier-mutation-rpc-response-v1\0";
425    const V4_DOMAIN: &[u8] = b"rustfs-tier-mutation-rpc-response-v2\0";
426
427    let phase = input.phase.as_wire_str().as_bytes();
428    let mutation_id = input.mutation_id.as_bytes();
429    let error_info = input.error_info.map(str::as_bytes);
430    let error_info_len = error_info.map_or(0, <[u8]>::len);
431    let is_v4 = input.version >= TIER_MUTATION_RPC_PROTOCOL_VERSION;
432    let domain = if is_v4 { V4_DOMAIN } else { V3_DOMAIN };
433    let mut body = Vec::with_capacity(
434        domain.len()
435            + 4
436            + 8
437            + phase.len()
438            + mutation_id.len()
439            + 8
440            + input.canonical_payload.len()
441            + 1
442            + 4
443            + 1
444            + 1
445            + 8
446            + error_info_len
447            + if is_v4 { 4 } else { 0 },
448    );
449    body.extend_from_slice(domain);
450    body.extend_from_slice(&input.version.to_be_bytes());
451    body.extend_from_slice(&u64::try_from(phase.len())?.to_be_bytes());
452    body.extend_from_slice(phase);
453    body.extend_from_slice(mutation_id);
454    body.extend_from_slice(&u64::try_from(input.canonical_payload.len())?.to_be_bytes());
455    body.extend_from_slice(input.canonical_payload);
456    body.push(u8::from(input.success));
457    body.extend_from_slice(&input.state.to_be_bytes());
458    body.push(u8::from(input.applied));
459    body.push(u8::from(error_info.is_some()));
460    body.extend_from_slice(&u64::try_from(error_info_len)?.to_be_bytes());
461    if let Some(error_info) = error_info {
462        body.extend_from_slice(error_info);
463    }
464    if is_v4 {
465        body.extend_from_slice(&input.failure_class.to_be_bytes());
466    }
467    Ok(body)
468}
469
470/// Builds the stable byte representation authenticated for a scanner activity request.
471pub fn canonical_scanner_activity_request_body(
472    request: &proto_gen::node_service::ScannerActivityRequest,
473) -> Result<Vec<u8>, std::num::TryFromIntError> {
474    const DOMAIN: &[u8] = b"rustfs-scanner-activity-request-v1\0";
475
476    let challenge = request.challenge.as_ref();
477    let acknowledge_instance_id = request.acknowledge_instance_id.as_bytes();
478    let mut body = Vec::with_capacity(DOMAIN.len() + challenge.len() + acknowledge_instance_id.len() + 4 + 8 * 3);
479    body.extend_from_slice(DOMAIN);
480    body.extend_from_slice(&request.protocol_version.to_be_bytes());
481    body.extend_from_slice(&u64::try_from(challenge.len())?.to_be_bytes());
482    body.extend_from_slice(challenge);
483    body.extend_from_slice(&u64::try_from(acknowledge_instance_id.len())?.to_be_bytes());
484    body.extend_from_slice(acknowledge_instance_id);
485    body.extend_from_slice(&request.acknowledge_dirty_usage_generation.to_be_bytes());
486    Ok(body)
487}
488
489/// Builds the protocol-v4 byte representation authenticated for a scanner activity response.
490pub fn canonical_scanner_activity_v4_response_body(
491    challenge: &[u8],
492    response: &proto_gen::node_service::ScannerActivityResponse,
493) -> Result<Vec<u8>, std::num::TryFromIntError> {
494    const DOMAIN: &[u8] = b"rustfs-scanner-activity-response-v1\0";
495
496    let instance_id = response.instance_id.as_bytes();
497    let topology_digest = response.topology_digest.as_ref();
498    let mut body = Vec::with_capacity(DOMAIN.len() + challenge.len() + instance_id.len() + topology_digest.len() + 4 + 8 * 5 + 1);
499    body.extend_from_slice(DOMAIN);
500    body.extend_from_slice(&u64::try_from(challenge.len())?.to_be_bytes());
501    body.extend_from_slice(challenge);
502    body.extend_from_slice(&u64::try_from(instance_id.len())?.to_be_bytes());
503    body.extend_from_slice(instance_id);
504    body.extend_from_slice(&response.namespace_generation.to_be_bytes());
505    body.extend_from_slice(&response.maintenance_generation.to_be_bytes());
506    body.extend_from_slice(&response.protocol_version.to_be_bytes());
507    body.extend_from_slice(&u64::try_from(topology_digest.len())?.to_be_bytes());
508    body.extend_from_slice(topology_digest);
509    body.push(u8::from(response.data_movement_active));
510    Ok(body)
511}
512
513/// Builds the stable byte representation authenticated for a scanner activity response.
514pub fn canonical_scanner_activity_response_body(
515    challenge: &[u8],
516    response: &proto_gen::node_service::ScannerActivityResponse,
517) -> Result<Vec<u8>, std::num::TryFromIntError> {
518    const DOMAIN: &[u8] = b"rustfs-scanner-activity-response-v2\0";
519
520    let instance_id = response.instance_id.as_bytes();
521    let topology_digest = response.topology_digest.as_ref();
522    let mut body = Vec::with_capacity(DOMAIN.len() + challenge.len() + instance_id.len() + topology_digest.len() + 4 + 8 * 6 + 2);
523    body.extend_from_slice(DOMAIN);
524    body.extend_from_slice(&u64::try_from(challenge.len())?.to_be_bytes());
525    body.extend_from_slice(challenge);
526    body.extend_from_slice(&u64::try_from(instance_id.len())?.to_be_bytes());
527    body.extend_from_slice(instance_id);
528    body.extend_from_slice(&response.namespace_generation.to_be_bytes());
529    body.extend_from_slice(&response.maintenance_generation.to_be_bytes());
530    body.extend_from_slice(&response.protocol_version.to_be_bytes());
531    body.extend_from_slice(&u64::try_from(topology_digest.len())?.to_be_bytes());
532    body.extend_from_slice(topology_digest);
533    body.push(u8::from(response.data_movement_active));
534    body.extend_from_slice(&response.dirty_usage_generation.to_be_bytes());
535    body.push(u8::from(response.dirty_usage_pending));
536    Ok(body)
537}
538
539/// Builds the protocol-v7 response body.  The optional movement fields are
540/// presence-bound so a missing terminal-generation proof cannot authenticate
541/// as the value zero.
542pub fn canonical_scanner_activity_v7_response_body(
543    challenge: &[u8],
544    response: &proto_gen::node_service::ScannerActivityResponse,
545) -> Result<Vec<u8>, std::num::TryFromIntError> {
546    const DOMAIN: &[u8] = b"rustfs-scanner-activity-response-v3\0";
547
548    let instance_id = response.instance_id.as_bytes();
549    let topology_digest = response.topology_digest.as_ref();
550    let mut body = Vec::with_capacity(DOMAIN.len() + challenge.len() + instance_id.len() + topology_digest.len() + 4 + 8 * 8 + 4);
551    body.extend_from_slice(DOMAIN);
552    body.extend_from_slice(&u64::try_from(challenge.len())?.to_be_bytes());
553    body.extend_from_slice(challenge);
554    body.extend_from_slice(&u64::try_from(instance_id.len())?.to_be_bytes());
555    body.extend_from_slice(instance_id);
556    body.extend_from_slice(&response.namespace_generation.to_be_bytes());
557    body.extend_from_slice(&response.maintenance_generation.to_be_bytes());
558    body.extend_from_slice(&response.protocol_version.to_be_bytes());
559    body.extend_from_slice(&u64::try_from(topology_digest.len())?.to_be_bytes());
560    body.extend_from_slice(topology_digest);
561    body.push(u8::from(response.data_movement_active));
562    body.extend_from_slice(&response.dirty_usage_generation.to_be_bytes());
563    body.push(u8::from(response.dirty_usage_pending));
564    body.push(u8::from(response.movement_generation.is_some()));
565    if let Some(generation) = response.movement_generation {
566        body.extend_from_slice(&generation.to_be_bytes());
567    }
568    body.push(u8::from(response.publication_blocked.is_some()));
569    if let Some(blocked) = response.publication_blocked {
570        body.push(u8::from(blocked));
571    }
572    Ok(body)
573}
574
575pub mod scoped_dirty_usage;
576
577pub fn canonical_scanner_dirty_usage_snapshot_request_body(
578    request: &proto_gen::node_service::ScannerDirtyUsageSnapshotRequest,
579) -> Result<Vec<u8>, std::num::TryFromIntError> {
580    let mut body = CanonicalBodyBuilder::new(b"rustfs-scanner-dirty-usage-snapshot-request-v1\0");
581    body.push_u32(request.protocol_version);
582    body.push_bytes(request.challenge.as_ref())?;
583    Ok(body.finish())
584}
585
586pub fn canonical_scanner_dirty_usage_snapshot_response_body(
587    challenge: &[u8],
588    response: &proto_gen::node_service::ScannerDirtyUsageSnapshotResponse,
589) -> Result<Vec<u8>, std::num::TryFromIntError> {
590    let mut body = CanonicalBodyBuilder::new(b"rustfs-scanner-dirty-usage-snapshot-response-v1\0");
591    body.push_bytes(challenge)?;
592    body.push_str(&response.instance_id)?;
593    body.push_u64(response.generation);
594    body.push_u64(response.pending_bucket_count);
595    body.push_u32(response.protocol_version);
596    body.push_str(&response.owner_id)?;
597    body.push_bool(response.complete);
598    body.push_count(response.buckets.len())?;
599    for bucket in &response.buckets {
600        body.push_str(&bucket.bucket)?;
601        body.push_u64(bucket.generation);
602        body.push_bytes(bucket.bucket_incarnation.as_ref())?;
603    }
604    Ok(body.finish())
605}
606
607/// Builds the body authenticated by the short-lived remote scanner publication
608/// lease request. This is a separate domain from ScannerActivity so v6/v7
609/// observation proofs remain byte-for-byte compatible.
610pub fn canonical_scanner_publication_lease_request_body(
611    request: &proto_gen::node_service::ScannerPublicationLeaseRequest,
612) -> Result<Vec<u8>, std::num::TryFromIntError> {
613    const DOMAIN: &[u8] = b"rustfs-scanner-publication-lease-request-v1\0";
614    let challenge = request.challenge.as_ref();
615    let session_id = request.expected_session_id.as_bytes();
616    let mut body = Vec::with_capacity(DOMAIN.len() + challenge.len() + session_id.len() + 40);
617    body.extend_from_slice(DOMAIN);
618    body.extend_from_slice(&u64::try_from(challenge.len())?.to_be_bytes());
619    body.extend_from_slice(challenge);
620    body.extend_from_slice(&request.expected_movement_generation.to_be_bytes());
621    body.extend_from_slice(&request.ttl_ms.to_be_bytes());
622    body.extend_from_slice(&u64::try_from(session_id.len())?.to_be_bytes());
623    body.extend_from_slice(session_id);
624    // Empty keeps the original acquire body byte-for-byte compatible.  A
625    // non-empty token is the authenticated validation form used immediately
626    // before a coordinator commits its final publication.
627    let token = request.token.as_ref();
628    if !token.is_empty() {
629        body.extend_from_slice(&u64::try_from(token.len())?.to_be_bytes());
630        body.extend_from_slice(token);
631    }
632    Ok(body)
633}
634
635/// Builds the body authenticated by a remote scanner publication lease
636/// release request.
637pub fn canonical_scanner_publication_lease_release_request_body(
638    request: &proto_gen::node_service::ScannerPublicationLeaseReleaseRequest,
639) -> Result<Vec<u8>, std::num::TryFromIntError> {
640    const DOMAIN: &[u8] = b"rustfs-scanner-publication-lease-release-request-v1\0";
641    let challenge = request.challenge.as_ref();
642    let token = request.token.as_ref();
643    let mut body = Vec::with_capacity(DOMAIN.len() + challenge.len() + token.len() + 16);
644    body.extend_from_slice(DOMAIN);
645    body.extend_from_slice(&u64::try_from(challenge.len())?.to_be_bytes());
646    body.extend_from_slice(challenge);
647    body.extend_from_slice(&u64::try_from(token.len())?.to_be_bytes());
648    body.extend_from_slice(token);
649    let owner_id = request.owner_id.as_bytes();
650    let session_id = request.session_id.as_bytes();
651    body.extend_from_slice(&u64::try_from(owner_id.len())?.to_be_bytes());
652    body.extend_from_slice(owner_id);
653    body.extend_from_slice(&u64::try_from(session_id.len())?.to_be_bytes());
654    body.extend_from_slice(session_id);
655    Ok(body)
656}
657
658pub fn canonical_scanner_publication_lease_response_body(
659    challenge: &[u8],
660    response: &proto_gen::node_service::ScannerPublicationLeaseResponse,
661) -> Result<Vec<u8>, std::num::TryFromIntError> {
662    const DOMAIN: &[u8] = b"rustfs-scanner-publication-lease-response-v1\0";
663    let token = response.token.as_ref();
664    let owner_id = response.owner_id.as_bytes();
665    let session_id = response.session_id.as_bytes();
666    let error_info = response.error.as_ref().map(|error| error.error_info.as_bytes());
667    let error_code = response.error.as_ref().map_or(0, |error| error.code);
668    let mut body = Vec::with_capacity(
669        DOMAIN.len() + challenge.len() + token.len() + owner_id.len() + session_id.len() + error_info.map_or(0, |v| v.len()) + 72,
670    );
671    body.extend_from_slice(DOMAIN);
672    body.extend_from_slice(&u64::try_from(challenge.len())?.to_be_bytes());
673    body.extend_from_slice(challenge);
674    body.push(u8::from(response.success));
675    body.extend_from_slice(&u64::try_from(token.len())?.to_be_bytes());
676    body.extend_from_slice(token);
677    body.extend_from_slice(&response.movement_generation.to_be_bytes());
678    body.extend_from_slice(&response.lease_ttl_ms.to_be_bytes());
679    body.extend_from_slice(&u64::try_from(owner_id.len())?.to_be_bytes());
680    body.extend_from_slice(owner_id);
681    body.extend_from_slice(&u64::try_from(session_id.len())?.to_be_bytes());
682    body.extend_from_slice(session_id);
683    body.push(u8::from(response.error.is_some()));
684    body.extend_from_slice(&error_code.to_be_bytes());
685    body.extend_from_slice(&u64::try_from(error_info.map_or(0, |value| value.len()))?.to_be_bytes());
686    if let Some(error_info) = error_info {
687        body.extend_from_slice(error_info);
688    }
689    Ok(body)
690}
691
692pub fn canonical_scanner_publication_lease_release_response_body(
693    challenge: &[u8],
694    request: &proto_gen::node_service::ScannerPublicationLeaseReleaseRequest,
695    response: &proto_gen::node_service::ScannerPublicationLeaseReleaseResponse,
696) -> Result<Vec<u8>, std::num::TryFromIntError> {
697    const DOMAIN: &[u8] = b"rustfs-scanner-publication-lease-release-response-v1\0";
698    let token = request.token.as_ref();
699    let owner_id = request.owner_id.as_bytes();
700    let session_id = request.session_id.as_bytes();
701    let error_info = response.error.as_ref().map(|error| error.error_info.as_bytes());
702    let error_code = response.error.as_ref().map_or(0, |error| error.code);
703    let mut body = Vec::with_capacity(
704        DOMAIN.len() + challenge.len() + token.len() + owner_id.len() + session_id.len() + error_info.map_or(0, |v| v.len()) + 56,
705    );
706    body.extend_from_slice(DOMAIN);
707    body.extend_from_slice(&u64::try_from(challenge.len())?.to_be_bytes());
708    body.extend_from_slice(challenge);
709    body.extend_from_slice(&u64::try_from(token.len())?.to_be_bytes());
710    body.extend_from_slice(token);
711    body.extend_from_slice(&u64::try_from(owner_id.len())?.to_be_bytes());
712    body.extend_from_slice(owner_id);
713    body.extend_from_slice(&u64::try_from(session_id.len())?.to_be_bytes());
714    body.extend_from_slice(session_id);
715    body.push(u8::from(response.success));
716    body.push(u8::from(response.error.is_some()));
717    body.extend_from_slice(&error_code.to_be_bytes());
718    body.extend_from_slice(&u64::try_from(error_info.map_or(0, |value| value.len()))?.to_be_bytes());
719    if let Some(error_info) = error_info {
720        body.extend_from_slice(error_info);
721    }
722    Ok(body)
723}
724
725/// Length-prefixed, domain-separated byte builder for the disk-mutation canonical bodies below.
726/// Every variable-length field is u64-length-prefixed and every list u64-count-prefixed, so
727/// distinct field values can never collide into the same canonical bytes.
728struct CanonicalBodyBuilder {
729    body: Vec<u8>,
730}
731
732impl CanonicalBodyBuilder {
733    fn new(domain: &'static [u8]) -> Self {
734        let mut body = Vec::with_capacity(domain.len() + 128);
735        body.extend_from_slice(domain);
736        Self { body }
737    }
738
739    fn push_bytes(&mut self, field: &[u8]) -> Result<(), std::num::TryFromIntError> {
740        self.body.extend_from_slice(&u64::try_from(field.len())?.to_be_bytes());
741        self.body.extend_from_slice(field);
742        Ok(())
743    }
744
745    fn push_str(&mut self, field: &str) -> Result<(), std::num::TryFromIntError> {
746        self.push_bytes(field.as_bytes())
747    }
748
749    fn push_optional_str(&mut self, field: Option<&str>) -> Result<(), std::num::TryFromIntError> {
750        self.body.push(u8::from(field.is_some()));
751        self.push_str(field.unwrap_or_default())
752    }
753
754    fn push_bool(&mut self, field: bool) {
755        self.body.push(u8::from(field));
756    }
757
758    fn push_u32(&mut self, field: u32) {
759        self.body.extend_from_slice(&field.to_be_bytes());
760    }
761
762    fn push_u64(&mut self, field: u64) {
763        self.body.extend_from_slice(&field.to_be_bytes());
764    }
765
766    fn push_count(&mut self, count: usize) -> Result<(), std::num::TryFromIntError> {
767        self.body.extend_from_slice(&u64::try_from(count)?.to_be_bytes());
768        Ok(())
769    }
770
771    fn finish(self) -> Vec<u8> {
772        self.body
773    }
774}
775
776pub const PEER_RESTSIGNAL: &str = "signal";
777pub const PEER_RESTSUB_SYS: &str = "sub-sys";
778pub const PEER_RESTDRY_RUN: &str = "dry-run";
779
780/// A stable semantic body for a side-effecting unary RPC.
781///
782/// Implementations deliberately enumerate handler-consumed fields instead of re-encoding the
783/// protobuf message, whose unknown fields and map order are not a mixed-version contract.
784pub trait CanonicalMutationBody {
785    fn canonical_body(&self) -> Result<Vec<u8>, std::num::TryFromIntError>;
786}
787
788macro_rules! impl_canonical_mutation_body {
789    ($request:ty, $domain:expr, |$value:ident, $body:ident| $fields:block) => {
790        impl CanonicalMutationBody for $request {
791            fn canonical_body(&self) -> Result<Vec<u8>, std::num::TryFromIntError> {
792                let $value = self;
793                let mut $body = CanonicalBodyBuilder::new($domain);
794                $fields
795                Ok($body.finish())
796            }
797        }
798    };
799    ($request:ty, $domain:expr) => {
800        impl CanonicalMutationBody for $request {
801            fn canonical_body(&self) -> Result<Vec<u8>, std::num::TryFromIntError> {
802                Ok(CanonicalBodyBuilder::new($domain).finish())
803            }
804        }
805    };
806}
807
808impl_canonical_mutation_body!(
809    proto_gen::node_service::SignalServiceRequest,
810    b"rustfs-signal-service-request-v1\0",
811    |request, body| {
812        let vars = request.vars.as_ref().map(|vars| &vars.value);
813        body.push_optional_str(vars.and_then(|vars| vars.get(PEER_RESTSIGNAL).map(String::as_str)))?;
814        body.push_optional_str(vars.and_then(|vars| vars.get(PEER_RESTSUB_SYS).map(String::as_str)))?;
815        body.push_optional_str(vars.and_then(|vars| vars.get(PEER_RESTDRY_RUN).map(String::as_str)))?;
816    }
817);
818impl_canonical_mutation_body!(
819    proto_gen::node_service::HealBucketRequest,
820    b"rustfs-heal-bucket-request-v1\0",
821    |request, body| {
822        body.push_str(&request.bucket)?;
823        body.push_str(&request.options)?;
824        if !request.bucket_incarnation_id.is_empty() {
825            body.push_bytes(&request.bucket_incarnation_id)?;
826        }
827    }
828);
829impl_canonical_mutation_body!(
830    proto_gen::node_service::MakeBucketRequest,
831    b"rustfs-make-bucket-request-v1\0",
832    |request, body| {
833        body.push_str(&request.name)?;
834        body.push_str(&request.options)?;
835    }
836);
837impl_canonical_mutation_body!(
838    proto_gen::node_service::DeleteBucketRequest,
839    b"rustfs-delete-bucket-request-v1\0",
840    |request, body| {
841        body.push_str(&request.bucket)?;
842        body.push_str(&request.options)?;
843    }
844);
845impl_canonical_mutation_body!(
846    proto_gen::node_service::GenerallyLockRequest,
847    b"rustfs-lock-request-v1\0",
848    |request, body| {
849        body.push_str(&request.args)?;
850    }
851);
852impl_canonical_mutation_body!(
853    proto_gen::node_service::BatchGenerallyLockRequest,
854    b"rustfs-lock-batch-request-v1\0",
855    |request, body| {
856        body.push_count(request.args.len())?;
857        for arg in &request.args {
858            body.push_str(arg)?;
859        }
860    }
861);
862impl_canonical_mutation_body!(
863    proto_gen::node_service::LoadBucketMetadataRequest,
864    b"rustfs-load-bucket-metadata-request-v1\0",
865    |request, body| {
866        body.push_str(&request.bucket)?;
867        body.push_bool(request.scanner_maintenance_change);
868    }
869);
870impl_canonical_mutation_body!(
871    proto_gen::node_service::DeleteBucketMetadataRequest,
872    b"rustfs-delete-bucket-metadata-request-v1\0",
873    |request, body| {
874        body.push_str(&request.bucket)?;
875    }
876);
877impl_canonical_mutation_body!(
878    proto_gen::node_service::DeletePolicyRequest,
879    b"rustfs-delete-policy-request-v1\0",
880    |request, body| {
881        body.push_str(&request.policy_name)?;
882    }
883);
884impl_canonical_mutation_body!(
885    proto_gen::node_service::LoadPolicyRequest,
886    b"rustfs-load-policy-request-v1\0",
887    |request, body| {
888        body.push_str(&request.policy_name)?;
889    }
890);
891impl_canonical_mutation_body!(
892    proto_gen::node_service::LoadPolicyMappingRequest,
893    b"rustfs-load-policy-mapping-request-v1\0",
894    |request, body| {
895        body.push_str(&request.user_or_group)?;
896        body.push_u64(request.user_type);
897        body.push_bool(request.is_group);
898    }
899);
900impl_canonical_mutation_body!(
901    proto_gen::node_service::DeleteUserRequest,
902    b"rustfs-delete-user-request-v1\0",
903    |request, body| {
904        body.push_str(&request.access_key)?;
905    }
906);
907impl_canonical_mutation_body!(
908    proto_gen::node_service::DeleteServiceAccountRequest,
909    b"rustfs-delete-service-account-request-v1\0",
910    |request, body| {
911        body.push_str(&request.access_key)?;
912    }
913);
914impl_canonical_mutation_body!(
915    proto_gen::node_service::LoadUserRequest,
916    b"rustfs-load-user-request-v1\0",
917    |request, body| {
918        body.push_str(&request.access_key)?;
919        body.push_bool(request.temp);
920    }
921);
922impl_canonical_mutation_body!(
923    proto_gen::node_service::LoadServiceAccountRequest,
924    b"rustfs-load-service-account-request-v1\0",
925    |request, body| {
926        body.push_str(&request.access_key)?;
927    }
928);
929impl_canonical_mutation_body!(
930    proto_gen::node_service::LoadGroupRequest,
931    b"rustfs-load-group-request-v1\0",
932    |request, body| {
933        body.push_str(&request.group)?;
934    }
935);
936impl_canonical_mutation_body!(
937    proto_gen::node_service::ReloadSiteReplicationConfigRequest,
938    b"rustfs-reload-site-replication-config-request-v1\0"
939);
940impl_canonical_mutation_body!(proto_gen::node_service::ReloadPoolMetaRequest, b"rustfs-reload-pool-meta-request-v1\0");
941impl_canonical_mutation_body!(
942    proto_gen::node_service::StopRebalanceRequest,
943    b"rustfs-stop-rebalance-request-v1\0",
944    |request, body| {
945        body.push_str(&request.expected_rebalance_id)?;
946    }
947);
948impl_canonical_mutation_body!(
949    proto_gen::node_service::LoadRebalanceMetaRequest,
950    b"rustfs-load-rebalance-meta-request-v1\0",
951    |request, body| {
952        body.push_bool(request.start_rebalance);
953    }
954);
955impl_canonical_mutation_body!(
956    proto_gen::node_service::StartDecommissionRequest,
957    b"rustfs-start-decommission-request-v1\0",
958    |request, body| {
959        body.push_count(request.pool_indices.len())?;
960        for pool_index in &request.pool_indices {
961            body.push_u32(*pool_index);
962        }
963    }
964);
965impl_canonical_mutation_body!(
966    proto_gen::node_service::CancelDecommissionRequest,
967    b"rustfs-cancel-decommission-request-v1\0",
968    |request, body| {
969        body.push_u32(request.pool_index);
970    }
971);
972impl_canonical_mutation_body!(
973    proto_gen::node_service::ClearDecommissionRequest,
974    b"rustfs-clear-decommission-request-v1\0",
975    |request, body| {
976        body.push_u32(request.pool_index);
977    }
978);
979impl_canonical_mutation_body!(
980    proto_gen::node_service::LoadTransitionTierConfigRequest,
981    b"rustfs-load-transition-tier-config-request-v1\0"
982);
983
984// Canonical request bodies for the mutating NodeService disk RPCs (backlog#1327 body-digest
985// binding). Each covers every semantic wire field — including both the msgpack `_bin` payload and
986// its JSON compatibility copy — so tampering with either encoding, or stripping `_bin` to force
987// the JSON fallback decode path, breaks the signature-bound digest.
988
989pub fn canonical_rename_data_request_body(
990    request: &proto_gen::node_service::RenameDataRequest,
991) -> Result<Vec<u8>, std::num::TryFromIntError> {
992    let mut body = CanonicalBodyBuilder::new(b"rustfs-rename-data-request-v1\0");
993    body.push_str(&request.disk)?;
994    body.push_str(&request.src_volume)?;
995    body.push_str(&request.src_path)?;
996    body.push_str(&request.file_info)?;
997    body.push_str(&request.dst_volume)?;
998    body.push_str(&request.dst_path)?;
999    body.push_bytes(&request.file_info_bin)?;
1000    // Keep legacy rename requests byte-for-byte compatible.  The optional
1001    // token is included only for the scanner's target-side lease fence.
1002    if !request.scanner_publication_lease_token.is_empty() {
1003        body.push_bytes(&request.scanner_publication_lease_token)?;
1004    }
1005    if !request.bucket_incarnation_id.is_empty() {
1006        body.push_str("bucket-incarnation-v1")?;
1007        body.push_bytes(&request.bucket_incarnation_id)?;
1008    }
1009    Ok(body.finish())
1010}
1011
1012pub fn canonical_delete_version_request_body(
1013    request: &proto_gen::node_service::DeleteVersionRequest,
1014) -> Result<Vec<u8>, std::num::TryFromIntError> {
1015    let mut body = CanonicalBodyBuilder::new(b"rustfs-delete-version-request-v1\0");
1016    body.push_str(&request.disk)?;
1017    body.push_str(&request.volume)?;
1018    body.push_str(&request.path)?;
1019    body.push_str(&request.file_info)?;
1020    body.push_bool(request.force_del_marker);
1021    body.push_str(&request.opts)?;
1022    body.push_bytes(&request.file_info_bin)?;
1023    body.push_bytes(&request.opts_bin)?;
1024    if !request.bucket_incarnation_id.is_empty() {
1025        body.push_bytes(&request.bucket_incarnation_id)?;
1026    }
1027    Ok(body.finish())
1028}
1029
1030pub fn canonical_delete_versions_request_body(
1031    request: &proto_gen::node_service::DeleteVersionsRequest,
1032) -> Result<Vec<u8>, std::num::TryFromIntError> {
1033    let mut body = CanonicalBodyBuilder::new(b"rustfs-delete-versions-request-v1\0");
1034    body.push_str(&request.disk)?;
1035    body.push_str(&request.volume)?;
1036    body.push_count(request.versions.len())?;
1037    for version in &request.versions {
1038        body.push_str(version)?;
1039    }
1040    body.push_str(&request.opts)?;
1041    body.push_count(request.versions_bin.len())?;
1042    for version_bin in &request.versions_bin {
1043        body.push_bytes(version_bin)?;
1044    }
1045    body.push_bytes(&request.opts_bin)?;
1046    Ok(body.finish())
1047}
1048
1049pub fn canonical_write_metadata_request_body(
1050    request: &proto_gen::node_service::WriteMetadataRequest,
1051) -> Result<Vec<u8>, std::num::TryFromIntError> {
1052    let mut body = CanonicalBodyBuilder::new(b"rustfs-write-metadata-request-v1\0");
1053    body.push_str(&request.disk)?;
1054    body.push_str(&request.volume)?;
1055    body.push_str(&request.path)?;
1056    body.push_str(&request.file_info)?;
1057    body.push_bytes(&request.file_info_bin)?;
1058    if !request.bucket_incarnation_id.is_empty() {
1059        body.push_str("bucket-incarnation-v1")?;
1060        body.push_bytes(&request.bucket_incarnation_id)?;
1061    }
1062    Ok(body.finish())
1063}
1064
1065pub fn canonical_update_metadata_request_body(
1066    request: &proto_gen::node_service::UpdateMetadataRequest,
1067) -> Result<Vec<u8>, std::num::TryFromIntError> {
1068    let mut body = CanonicalBodyBuilder::new(b"rustfs-update-metadata-request-v1\0");
1069    body.push_str(&request.disk)?;
1070    body.push_str(&request.volume)?;
1071    body.push_str(&request.path)?;
1072    body.push_str(&request.file_info)?;
1073    body.push_str(&request.opts)?;
1074    body.push_bytes(&request.file_info_bin)?;
1075    body.push_bytes(&request.opts_bin)?;
1076    Ok(body.finish())
1077}
1078
1079pub fn canonical_write_all_request_body(
1080    request: &proto_gen::node_service::WriteAllRequest,
1081) -> Result<Vec<u8>, std::num::TryFromIntError> {
1082    let mut body = CanonicalBodyBuilder::new(b"rustfs-write-all-request-v1\0");
1083    body.push_str(&request.disk)?;
1084    body.push_str(&request.volume)?;
1085    body.push_str(&request.path)?;
1086    body.push_bytes(&request.data)?;
1087    Ok(body.finish())
1088}
1089
1090pub fn canonical_compare_and_update_file_request_body(
1091    request: &proto_gen::node_service::CompareAndUpdateFileRequest,
1092) -> Result<Vec<u8>, std::num::TryFromIntError> {
1093    let mut body = CanonicalBodyBuilder::new(b"rustfs-compare-and-update-file-request-v1\0");
1094    body.push_str(&request.disk)?;
1095    body.push_str(&request.volume)?;
1096    body.push_str(&request.path)?;
1097    body.push_bool(request.expected.is_some());
1098    body.push_bytes(request.expected.as_deref().unwrap_or_default())?;
1099    body.push_bool(request.replacement.is_some());
1100    body.push_bytes(request.replacement.as_deref().unwrap_or_default())?;
1101    Ok(body.finish())
1102}
1103
1104pub fn canonical_delete_request_body(
1105    request: &proto_gen::node_service::DeleteRequest,
1106) -> Result<Vec<u8>, std::num::TryFromIntError> {
1107    let mut body = CanonicalBodyBuilder::new(b"rustfs-delete-request-v1\0");
1108    body.push_str(&request.disk)?;
1109    body.push_str(&request.volume)?;
1110    body.push_str(&request.path)?;
1111    body.push_str(&request.options)?;
1112    if !request.scanner_publication_lease_token.is_empty() {
1113        body.push_bytes(&request.scanner_publication_lease_token)?;
1114    }
1115    if !request.bucket_incarnation_id.is_empty() {
1116        body.push_str("bucket-incarnation-v1")?;
1117        body.push_bytes(&request.bucket_incarnation_id)?;
1118    }
1119    Ok(body.finish())
1120}
1121
1122pub fn canonical_delete_paths_request_body(
1123    request: &proto_gen::node_service::DeletePathsRequest,
1124) -> Result<Vec<u8>, std::num::TryFromIntError> {
1125    let mut body = CanonicalBodyBuilder::new(b"rustfs-delete-paths-request-v1\0");
1126    body.push_str(&request.disk)?;
1127    body.push_str(&request.volume)?;
1128    body.push_count(request.paths.len())?;
1129    for path in &request.paths {
1130        body.push_str(path)?;
1131    }
1132    Ok(body.finish())
1133}
1134
1135pub fn canonical_snapshot_lease_request_body(
1136    request: &proto_gen::node_service::SnapshotLeaseRequest,
1137) -> Result<Vec<u8>, std::num::TryFromIntError> {
1138    let mut body = CanonicalBodyBuilder::new(b"rustfs-snapshot-lease-request-v1\0");
1139    body.push_str(&request.disk)?;
1140    body.push_str(&request.volume)?;
1141    body.push_str(&request.path)?;
1142    body.push_u64(request.ttl_ms);
1143    Ok(body.finish())
1144}
1145
1146pub fn canonical_snapshot_lease_renew_request_body(
1147    request: &proto_gen::node_service::SnapshotLeaseRenewRequest,
1148) -> Result<Vec<u8>, std::num::TryFromIntError> {
1149    let mut body = CanonicalBodyBuilder::new(b"rustfs-snapshot-lease-renew-request-v1\0");
1150    body.push_str(&request.disk)?;
1151    body.push_str(&request.volume)?;
1152    body.push_str(&request.path)?;
1153    body.push_bytes(&request.token)?;
1154    body.push_u64(request.ttl_ms);
1155    Ok(body.finish())
1156}
1157
1158pub fn canonical_snapshot_lease_release_request_body(
1159    request: &proto_gen::node_service::SnapshotLeaseReleaseRequest,
1160) -> Result<Vec<u8>, std::num::TryFromIntError> {
1161    let mut body = CanonicalBodyBuilder::new(b"rustfs-snapshot-lease-release-request-v1\0");
1162    body.push_str(&request.disk)?;
1163    body.push_str(&request.volume)?;
1164    body.push_str(&request.path)?;
1165    body.push_bytes(&request.token)?;
1166    Ok(body.finish())
1167}
1168
1169pub fn canonical_rename_file_request_body(
1170    request: &proto_gen::node_service::RenameFileRequest,
1171) -> Result<Vec<u8>, std::num::TryFromIntError> {
1172    let mut body = CanonicalBodyBuilder::new(b"rustfs-rename-file-request-v1\0");
1173    body.push_str(&request.disk)?;
1174    body.push_str(&request.src_volume)?;
1175    body.push_str(&request.src_path)?;
1176    body.push_str(&request.dst_volume)?;
1177    body.push_str(&request.dst_path)?;
1178    // Preserve the release signature for ordinary renames. An older server
1179    // computes a different digest for a durable request and rejects mutation.
1180    if request.durable {
1181        body.push_bool(true);
1182    }
1183    Ok(body.finish())
1184}
1185
1186pub fn canonical_rename_part_request_body(
1187    request: &proto_gen::node_service::RenamePartRequest,
1188) -> Result<Vec<u8>, std::num::TryFromIntError> {
1189    let mut body = CanonicalBodyBuilder::new(b"rustfs-rename-part-request-v1\0");
1190    body.push_str(&request.disk)?;
1191    body.push_str(&request.src_volume)?;
1192    body.push_str(&request.src_path)?;
1193    body.push_str(&request.dst_volume)?;
1194    body.push_str(&request.dst_path)?;
1195    body.push_bytes(&request.meta)?;
1196    Ok(body.finish())
1197}
1198
1199pub fn canonical_prepare_part_transaction_request_body(
1200    request: &proto_gen::node_service::PreparePartTransactionRequest,
1201) -> Result<Vec<u8>, std::num::TryFromIntError> {
1202    let mut body = CanonicalBodyBuilder::new(b"rustfs-prepare-part-transaction-request-v1\0");
1203    body.push_str(&request.disk)?;
1204    body.push_str(&request.src_volume)?;
1205    body.push_str(&request.src_path)?;
1206    body.push_str(&request.dst_volume)?;
1207    body.push_str(&request.dst_path)?;
1208    body.push_bytes(&request.meta)?;
1209    Ok(body.finish())
1210}
1211
1212pub fn canonical_settle_part_transaction_request_body(
1213    request: &proto_gen::node_service::SettlePartTransactionRequest,
1214) -> Result<Vec<u8>, std::num::TryFromIntError> {
1215    let mut body = CanonicalBodyBuilder::new(b"rustfs-settle-part-transaction-request-v1\0");
1216    body.push_str(&request.disk)?;
1217    body.push_str(&request.volume)?;
1218    body.push_str(&request.path)?;
1219    body.push_bool(request.rollback);
1220    Ok(body.finish())
1221}
1222
1223pub fn canonical_delete_volume_request_body(
1224    request: &proto_gen::node_service::DeleteVolumeRequest,
1225) -> Result<Vec<u8>, std::num::TryFromIntError> {
1226    let mut body = CanonicalBodyBuilder::new(b"rustfs-delete-volume-request-v1\0");
1227    body.push_str(&request.disk)?;
1228    body.push_str(&request.volume)?;
1229    // Binding `force` is the point: an unbound recursive-delete flag lets an on-path attacker
1230    // turn a refuse-if-nonempty delete into a recursive volume wipe (backlog#1327).
1231    body.push_bool(request.force);
1232    Ok(body.finish())
1233}
1234
1235pub fn canonical_make_volume_request_body(
1236    request: &proto_gen::node_service::MakeVolumeRequest,
1237) -> Result<Vec<u8>, std::num::TryFromIntError> {
1238    let mut body = CanonicalBodyBuilder::new(b"rustfs-make-volume-request-v1\0");
1239    body.push_str(&request.disk)?;
1240    body.push_str(&request.volume)?;
1241    Ok(body.finish())
1242}
1243
1244pub fn canonical_make_volumes_request_body(
1245    request: &proto_gen::node_service::MakeVolumesRequest,
1246) -> Result<Vec<u8>, std::num::TryFromIntError> {
1247    let mut body = CanonicalBodyBuilder::new(b"rustfs-make-volumes-request-v1\0");
1248    body.push_str(&request.disk)?;
1249    body.push_count(request.volumes.len())?;
1250    for volume in &request.volumes {
1251        body.push_str(volume)?;
1252    }
1253    Ok(body.finish())
1254}
1255
1256#[cfg(test)]
1257mod disk_mutation_canonical_tests {
1258    use super::proto_gen::node_service::{
1259        CompareAndUpdateFileRequest, DeletePathsRequest, DeleteRequest, DeleteVersionRequest, DeleteVersionsRequest,
1260        DeleteVolumeRequest, MakeVolumeRequest, MakeVolumesRequest, PreparePartTransactionRequest, RenameDataRequest,
1261        RenameFileRequest, RenamePartRequest, SettlePartTransactionRequest, SnapshotLeaseReleaseRequest,
1262        SnapshotLeaseRenewRequest, SnapshotLeaseRequest, UpdateMetadataRequest, WriteAllRequest, WriteMetadataRequest,
1263    };
1264    use super::*;
1265
1266    fn assert_all_distinct(bodies: &[Vec<u8>]) {
1267        for (i, a) in bodies.iter().enumerate() {
1268            for (j, b) in bodies.iter().enumerate() {
1269                if i != j {
1270                    assert_ne!(a, b, "canonical bodies {i} and {j} must differ");
1271                }
1272            }
1273        }
1274    }
1275
1276    #[test]
1277    fn rename_data_canonical_body_binds_every_field() {
1278        let baseline = RenameDataRequest {
1279            bucket_incarnation_id: Default::default(),
1280            disk: "disk-a".into(),
1281            src_volume: "src-vol".into(),
1282            src_path: "src-path".into(),
1283            file_info: "{\"v\":1}".into(),
1284            dst_volume: "dst-vol".into(),
1285            dst_path: "dst-path".into(),
1286            file_info_bin: vec![0x81, 0x01].into(),
1287            scanner_publication_lease_token: Vec::new().into(),
1288        };
1289        let mut bodies = vec![canonical_rename_data_request_body(&baseline).unwrap()];
1290        for mutate in [
1291            |r: &mut RenameDataRequest| r.disk = "disk-b".into(),
1292            |r: &mut RenameDataRequest| r.src_volume = "src-vol2".into(),
1293            |r: &mut RenameDataRequest| r.src_path = "src-path2".into(),
1294            |r: &mut RenameDataRequest| r.file_info = "{\"v\":2}".into(),
1295            |r: &mut RenameDataRequest| r.dst_volume = "dst-vol2".into(),
1296            |r: &mut RenameDataRequest| r.dst_path = "dst-path2".into(),
1297            |r: &mut RenameDataRequest| r.file_info_bin = vec![0x81, 0x02].into(),
1298            |r: &mut RenameDataRequest| r.file_info_bin = Vec::new().into(),
1299            |r: &mut RenameDataRequest| r.scanner_publication_lease_token = vec![0x01; 16].into(),
1300            |r: &mut RenameDataRequest| r.bucket_incarnation_id = vec![0x01; 16].into(),
1301        ] {
1302            let mut request = baseline.clone();
1303            mutate(&mut request);
1304            bodies.push(canonical_rename_data_request_body(&request).unwrap());
1305        }
1306        assert_all_distinct(&bodies);
1307    }
1308
1309    #[test]
1310    fn rename_data_canonical_body_is_injective_across_field_boundaries() {
1311        // Length prefixes must prevent moving bytes between adjacent fields from colliding.
1312        let shifted_left = RenameDataRequest {
1313            src_volume: "ab".into(),
1314            src_path: "c".into(),
1315            ..Default::default()
1316        };
1317        let shifted_right = RenameDataRequest {
1318            src_volume: "a".into(),
1319            src_path: "bc".into(),
1320            ..Default::default()
1321        };
1322        assert_ne!(
1323            canonical_rename_data_request_body(&shifted_left).unwrap(),
1324            canonical_rename_data_request_body(&shifted_right).unwrap(),
1325        );
1326    }
1327
1328    #[test]
1329    fn delete_version_canonical_body_binds_every_field() {
1330        let baseline = DeleteVersionRequest {
1331            bucket_incarnation_id: Default::default(),
1332            disk: "disk-a".into(),
1333            volume: "vol".into(),
1334            path: "path".into(),
1335            file_info: "{\"v\":1}".into(),
1336            force_del_marker: false,
1337            opts: "{}".into(),
1338            file_info_bin: vec![0x81].into(),
1339            opts_bin: vec![0x80].into(),
1340        };
1341        let mut bodies = vec![canonical_delete_version_request_body(&baseline).unwrap()];
1342        for mutate in [
1343            |r: &mut DeleteVersionRequest| r.disk = "disk-b".into(),
1344            |r: &mut DeleteVersionRequest| r.volume = "vol2".into(),
1345            |r: &mut DeleteVersionRequest| r.path = "path2".into(),
1346            |r: &mut DeleteVersionRequest| r.file_info = "{\"v\":2}".into(),
1347            |r: &mut DeleteVersionRequest| r.force_del_marker = true,
1348            |r: &mut DeleteVersionRequest| r.opts = "{\"o\":1}".into(),
1349            |r: &mut DeleteVersionRequest| r.file_info_bin = vec![0x82].into(),
1350            |r: &mut DeleteVersionRequest| r.opts_bin = Vec::new().into(),
1351            |r: &mut DeleteVersionRequest| r.bucket_incarnation_id = vec![1; 16].into(),
1352        ] {
1353            let mut request = baseline.clone();
1354            mutate(&mut request);
1355            bodies.push(canonical_delete_version_request_body(&request).unwrap());
1356        }
1357        assert_all_distinct(&bodies);
1358    }
1359
1360    #[test]
1361    fn delete_versions_canonical_body_binds_lists_and_options() {
1362        let baseline = DeleteVersionsRequest {
1363            disk: "disk-a".into(),
1364            volume: "vol".into(),
1365            versions: vec!["v1".into(), "v2".into()],
1366            opts: "{}".into(),
1367            versions_bin: vec![vec![0x01].into(), vec![0x02].into()],
1368            opts_bin: vec![0x80].into(),
1369        };
1370        let mut bodies = vec![canonical_delete_versions_request_body(&baseline).unwrap()];
1371        for mutate in [
1372            |r: &mut DeleteVersionsRequest| r.disk = "disk-b".into(),
1373            |r: &mut DeleteVersionsRequest| r.volume = "vol2".into(),
1374            |r: &mut DeleteVersionsRequest| r.versions = vec!["v1".into(), "v3".into()],
1375            |r: &mut DeleteVersionsRequest| r.versions = vec!["v1v2".into()],
1376            |r: &mut DeleteVersionsRequest| r.opts = "{\"o\":1}".into(),
1377            |r: &mut DeleteVersionsRequest| r.versions_bin = vec![vec![0x01].into(), vec![0x03].into()],
1378            |r: &mut DeleteVersionsRequest| r.versions_bin = vec![vec![0x01, 0x02].into()],
1379            |r: &mut DeleteVersionsRequest| r.versions_bin = Vec::new(),
1380            |r: &mut DeleteVersionsRequest| r.opts_bin = vec![0x81].into(),
1381        ] {
1382            let mut request = baseline.clone();
1383            mutate(&mut request);
1384            bodies.push(canonical_delete_versions_request_body(&request).unwrap());
1385        }
1386        assert_all_distinct(&bodies);
1387    }
1388
1389    #[test]
1390    fn remaining_disk_mutation_canonical_bodies_bind_every_field() {
1391        // Mutating each field in turn and asserting all bodies differ catches a dropped or
1392        // duplicated `push_*` in these hand-written builders — an unbound field is tamperable.
1393        let write_metadata = WriteMetadataRequest {
1394            bucket_incarnation_id: Default::default(),
1395            disk: "d".into(),
1396            volume: "v".into(),
1397            path: "p".into(),
1398            file_info: "{\"a\":1}".into(),
1399            file_info_bin: vec![0x81].into(),
1400        };
1401        let mut bodies = vec![canonical_write_metadata_request_body(&write_metadata).unwrap()];
1402        for mutate in [
1403            |r: &mut WriteMetadataRequest| r.disk = "d2".into(),
1404            |r: &mut WriteMetadataRequest| r.volume = "v2".into(),
1405            |r: &mut WriteMetadataRequest| r.path = "p2".into(),
1406            |r: &mut WriteMetadataRequest| r.file_info = "{\"a\":2}".into(),
1407            |r: &mut WriteMetadataRequest| r.file_info_bin = vec![0x82].into(),
1408            |r: &mut WriteMetadataRequest| r.bucket_incarnation_id = vec![1; 16].into(),
1409        ] {
1410            let mut request = write_metadata.clone();
1411            mutate(&mut request);
1412            bodies.push(canonical_write_metadata_request_body(&request).unwrap());
1413        }
1414        assert_all_distinct(&bodies);
1415
1416        let update_metadata = UpdateMetadataRequest {
1417            disk: "d".into(),
1418            volume: "v".into(),
1419            path: "p".into(),
1420            file_info: "{\"a\":1}".into(),
1421            opts: "{\"o\":1}".into(),
1422            file_info_bin: vec![0x81].into(),
1423            opts_bin: vec![0x80].into(),
1424        };
1425        let mut bodies = vec![canonical_update_metadata_request_body(&update_metadata).unwrap()];
1426        for mutate in [
1427            |r: &mut UpdateMetadataRequest| r.disk = "d2".into(),
1428            |r: &mut UpdateMetadataRequest| r.volume = "v2".into(),
1429            |r: &mut UpdateMetadataRequest| r.path = "p2".into(),
1430            |r: &mut UpdateMetadataRequest| r.file_info = "{\"a\":2}".into(),
1431            |r: &mut UpdateMetadataRequest| r.opts = "{\"o\":2}".into(),
1432            |r: &mut UpdateMetadataRequest| r.file_info_bin = vec![0x82].into(),
1433            |r: &mut UpdateMetadataRequest| r.opts_bin = vec![0x81].into(),
1434        ] {
1435            let mut request = update_metadata.clone();
1436            mutate(&mut request);
1437            bodies.push(canonical_update_metadata_request_body(&request).unwrap());
1438        }
1439        assert_all_distinct(&bodies);
1440
1441        let write_all = WriteAllRequest {
1442            disk: "d".into(),
1443            volume: "v".into(),
1444            path: "p".into(),
1445            data: vec![0xAA, 0xBB].into(),
1446        };
1447        let mut bodies = vec![canonical_write_all_request_body(&write_all).unwrap()];
1448        for mutate in [
1449            |r: &mut WriteAllRequest| r.disk = "d2".into(),
1450            |r: &mut WriteAllRequest| r.volume = "v2".into(),
1451            |r: &mut WriteAllRequest| r.path = "p2".into(),
1452            |r: &mut WriteAllRequest| r.data = vec![0xAA, 0xBC].into(),
1453        ] {
1454            let mut request = write_all.clone();
1455            mutate(&mut request);
1456            bodies.push(canonical_write_all_request_body(&request).unwrap());
1457        }
1458        assert_all_distinct(&bodies);
1459
1460        let compare_and_update_file = CompareAndUpdateFileRequest {
1461            disk: "d".into(),
1462            volume: "v".into(),
1463            path: "p".into(),
1464            expected: Some(vec![0xAA, 0xBB].into()),
1465            replacement: Some(vec![0xCC, 0xDD].into()),
1466        };
1467        let mut bodies = vec![canonical_compare_and_update_file_request_body(&compare_and_update_file).unwrap()];
1468        for mutate in [
1469            |r: &mut CompareAndUpdateFileRequest| r.disk = "d2".into(),
1470            |r: &mut CompareAndUpdateFileRequest| r.volume = "v2".into(),
1471            |r: &mut CompareAndUpdateFileRequest| r.path = "p2".into(),
1472            |r: &mut CompareAndUpdateFileRequest| r.expected = None,
1473            |r: &mut CompareAndUpdateFileRequest| r.expected = Some(Vec::new().into()),
1474            |r: &mut CompareAndUpdateFileRequest| r.expected = Some(vec![0xAA, 0xBC].into()),
1475            |r: &mut CompareAndUpdateFileRequest| r.replacement = None,
1476            |r: &mut CompareAndUpdateFileRequest| r.replacement = Some(Vec::new().into()),
1477            |r: &mut CompareAndUpdateFileRequest| r.replacement = Some(vec![0xCC, 0xDE].into()),
1478        ] {
1479            let mut request = compare_and_update_file.clone();
1480            mutate(&mut request);
1481            bodies.push(canonical_compare_and_update_file_request_body(&request).unwrap());
1482        }
1483        assert_all_distinct(&bodies);
1484
1485        let delete = DeleteRequest {
1486            bucket_incarnation_id: Default::default(),
1487            disk: "d".into(),
1488            volume: "v".into(),
1489            path: "p".into(),
1490            options: "{\"o\":1}".into(),
1491            scanner_publication_lease_token: Vec::new().into(),
1492        };
1493        let mut bodies = vec![canonical_delete_request_body(&delete).unwrap()];
1494        for mutate in [
1495            |r: &mut DeleteRequest| r.disk = "d2".into(),
1496            |r: &mut DeleteRequest| r.volume = "v2".into(),
1497            |r: &mut DeleteRequest| r.path = "p2".into(),
1498            |r: &mut DeleteRequest| r.options = "{\"recursive\":true}".into(),
1499            |r: &mut DeleteRequest| r.bucket_incarnation_id = vec![1; 16].into(),
1500            |r: &mut DeleteRequest| r.scanner_publication_lease_token = vec![0x01; 16].into(),
1501        ] {
1502            let mut request = delete.clone();
1503            mutate(&mut request);
1504            bodies.push(canonical_delete_request_body(&request).unwrap());
1505        }
1506        assert_all_distinct(&bodies);
1507
1508        let delete_paths = DeletePathsRequest {
1509            disk: "d".into(),
1510            volume: "v".into(),
1511            paths: vec!["a".into(), "b".into()],
1512        };
1513        let mut bodies = vec![canonical_delete_paths_request_body(&delete_paths).unwrap()];
1514        for mutate in [
1515            |r: &mut DeletePathsRequest| r.disk = "d2".into(),
1516            |r: &mut DeletePathsRequest| r.volume = "v2".into(),
1517            |r: &mut DeletePathsRequest| r.paths = vec!["a".into(), "c".into()],
1518            |r: &mut DeletePathsRequest| r.paths = vec!["ab".into()],
1519        ] {
1520            let mut request = delete_paths.clone();
1521            mutate(&mut request);
1522            bodies.push(canonical_delete_paths_request_body(&request).unwrap());
1523        }
1524        assert_all_distinct(&bodies);
1525
1526        let rename_file = RenameFileRequest {
1527            durable: false,
1528            disk: "d".into(),
1529            src_volume: "sv".into(),
1530            src_path: "sp".into(),
1531            dst_volume: "dv".into(),
1532            dst_path: "dp".into(),
1533        };
1534        let mut bodies = vec![canonical_rename_file_request_body(&rename_file).unwrap()];
1535        for mutate in [
1536            |r: &mut RenameFileRequest| r.durable = true,
1537            |r: &mut RenameFileRequest| r.disk = "d2".into(),
1538            |r: &mut RenameFileRequest| r.src_volume = "sv2".into(),
1539            |r: &mut RenameFileRequest| r.src_path = "sp2".into(),
1540            |r: &mut RenameFileRequest| r.dst_volume = "dv2".into(),
1541            |r: &mut RenameFileRequest| r.dst_path = "dp2".into(),
1542        ] {
1543            let mut request = rename_file.clone();
1544            mutate(&mut request);
1545            bodies.push(canonical_rename_file_request_body(&request).unwrap());
1546        }
1547        assert_all_distinct(&bodies);
1548
1549        let prepare_part = PreparePartTransactionRequest {
1550            disk: "d".into(),
1551            src_volume: "sv".into(),
1552            src_path: "sp".into(),
1553            dst_volume: "dv".into(),
1554            dst_path: "dp".into(),
1555            meta: vec![0x01].into(),
1556        };
1557        let mut bodies = vec![canonical_prepare_part_transaction_request_body(&prepare_part).unwrap()];
1558        for mutate in [
1559            |r: &mut PreparePartTransactionRequest| r.disk = "d2".into(),
1560            |r: &mut PreparePartTransactionRequest| r.src_volume = "sv2".into(),
1561            |r: &mut PreparePartTransactionRequest| r.src_path = "sp2".into(),
1562            |r: &mut PreparePartTransactionRequest| r.dst_volume = "dv2".into(),
1563            |r: &mut PreparePartTransactionRequest| r.dst_path = "dp2".into(),
1564            |r: &mut PreparePartTransactionRequest| r.meta = vec![0x02].into(),
1565        ] {
1566            let mut request = prepare_part.clone();
1567            mutate(&mut request);
1568            bodies.push(canonical_prepare_part_transaction_request_body(&request).unwrap());
1569        }
1570        assert_all_distinct(&bodies);
1571
1572        let settle_part = SettlePartTransactionRequest {
1573            disk: "d".into(),
1574            volume: "v".into(),
1575            path: "p".into(),
1576            rollback: false,
1577        };
1578        let mut bodies = vec![canonical_settle_part_transaction_request_body(&settle_part).unwrap()];
1579        for mutate in [
1580            |r: &mut SettlePartTransactionRequest| r.disk = "d2".into(),
1581            |r: &mut SettlePartTransactionRequest| r.volume = "v2".into(),
1582            |r: &mut SettlePartTransactionRequest| r.path = "p2".into(),
1583            |r: &mut SettlePartTransactionRequest| r.rollback = true,
1584        ] {
1585            let mut request = settle_part.clone();
1586            mutate(&mut request);
1587            bodies.push(canonical_settle_part_transaction_request_body(&request).unwrap());
1588        }
1589        assert_all_distinct(&bodies);
1590
1591        let rename_part = RenamePartRequest {
1592            disk: "d".into(),
1593            src_volume: "sv".into(),
1594            src_path: "sp".into(),
1595            dst_volume: "dv".into(),
1596            dst_path: "dp".into(),
1597            meta: vec![0x01].into(),
1598        };
1599        let mut bodies = vec![canonical_rename_part_request_body(&rename_part).unwrap()];
1600        for mutate in [
1601            |r: &mut RenamePartRequest| r.disk = "d2".into(),
1602            |r: &mut RenamePartRequest| r.src_volume = "sv2".into(),
1603            |r: &mut RenamePartRequest| r.src_path = "sp2".into(),
1604            |r: &mut RenamePartRequest| r.dst_volume = "dv2".into(),
1605            |r: &mut RenamePartRequest| r.dst_path = "dp2".into(),
1606            |r: &mut RenamePartRequest| r.meta = vec![0x02].into(),
1607        ] {
1608            let mut request = rename_part.clone();
1609            mutate(&mut request);
1610            bodies.push(canonical_rename_part_request_body(&request).unwrap());
1611        }
1612        assert_all_distinct(&bodies);
1613    }
1614
1615    #[test]
1616    fn volume_mutation_canonical_bodies_bind_their_fields() {
1617        let delete_volume = DeleteVolumeRequest {
1618            disk: "http://node-a:9000/data/rustfs0".into(),
1619            volume: "bucket".into(),
1620            force: false,
1621        };
1622        // The force flag must be part of the signed body: false → true is a recursive wipe.
1623        let mut recursive = delete_volume.clone();
1624        recursive.force = true;
1625        assert_ne!(
1626            canonical_delete_volume_request_body(&delete_volume).unwrap(),
1627            canonical_delete_volume_request_body(&recursive).unwrap(),
1628        );
1629        let mut retargeted = delete_volume.clone();
1630        retargeted.volume = "victim".into();
1631        assert_ne!(
1632            canonical_delete_volume_request_body(&delete_volume).unwrap(),
1633            canonical_delete_volume_request_body(&retargeted).unwrap(),
1634        );
1635
1636        let make_volume = MakeVolumeRequest {
1637            disk: "d".into(),
1638            volume: "v".into(),
1639        };
1640        let mut tampered = make_volume.clone();
1641        tampered.volume = "v2".into();
1642        assert_ne!(
1643            canonical_make_volume_request_body(&make_volume).unwrap(),
1644            canonical_make_volume_request_body(&tampered).unwrap(),
1645        );
1646
1647        let make_volumes = MakeVolumesRequest {
1648            disk: "d".into(),
1649            volumes: vec!["a".into(), "b".into()],
1650        };
1651        let mut merged = make_volumes.clone();
1652        merged.volumes = vec!["ab".into()];
1653        assert_ne!(
1654            canonical_make_volumes_request_body(&make_volumes).unwrap(),
1655            canonical_make_volumes_request_body(&merged).unwrap(),
1656        );
1657    }
1658
1659    #[test]
1660    fn snapshot_lease_canonical_bodies_bind_every_field() {
1661        let acquire = SnapshotLeaseRequest {
1662            disk: "d".into(),
1663            volume: "v".into(),
1664            path: "p".into(),
1665            ttl_ms: 60_000,
1666        };
1667        let mut acquire_bodies = vec![canonical_snapshot_lease_request_body(&acquire).unwrap()];
1668        for mutate in [
1669            |r: &mut SnapshotLeaseRequest| r.disk = "d2".into(),
1670            |r: &mut SnapshotLeaseRequest| r.volume = "v2".into(),
1671            |r: &mut SnapshotLeaseRequest| r.path = "p2".into(),
1672            |r: &mut SnapshotLeaseRequest| r.ttl_ms = 60_001,
1673        ] {
1674            let mut request = acquire.clone();
1675            mutate(&mut request);
1676            acquire_bodies.push(canonical_snapshot_lease_request_body(&request).unwrap());
1677        }
1678        assert_all_distinct(&acquire_bodies);
1679
1680        let renew = SnapshotLeaseRenewRequest {
1681            disk: "d".into(),
1682            volume: "v".into(),
1683            path: "p".into(),
1684            token: vec![1; 16].into(),
1685            ttl_ms: 60_000,
1686        };
1687        let mut changed_token = renew.clone();
1688        changed_token.token = vec![2; 16].into();
1689        let mut changed_ttl = renew.clone();
1690        changed_ttl.ttl_ms += 1;
1691        assert_all_distinct(&[
1692            canonical_snapshot_lease_renew_request_body(&renew).unwrap(),
1693            canonical_snapshot_lease_renew_request_body(&changed_token).unwrap(),
1694            canonical_snapshot_lease_renew_request_body(&changed_ttl).unwrap(),
1695        ]);
1696
1697        let release = SnapshotLeaseReleaseRequest {
1698            disk: "d".into(),
1699            volume: "v".into(),
1700            path: "p".into(),
1701            token: vec![1; 16].into(),
1702        };
1703        let mut changed_release = release.clone();
1704        changed_release.token = vec![2; 16].into();
1705        assert_ne!(
1706            canonical_snapshot_lease_release_request_body(&release).unwrap(),
1707            canonical_snapshot_lease_release_request_body(&changed_release).unwrap()
1708        );
1709    }
1710
1711    #[test]
1712    fn durable_rename_extension_preserves_old_wire_defaults_and_signatures() {
1713        use crate::proto_gen::node_service::RenameFileResponse;
1714        use prost::Message;
1715        // Release wire messages omitted request tag 6 and response tag 3.
1716        let mut request =
1717            RenameFileRequest::decode(b"\x0a\x01d\x12\x01s\x1a\x01p\x22\x01v\x2a\x01q".as_slice()).expect("release request");
1718        assert!(!request.durable);
1719        let old_body = b"rustfs-rename-file-request-v1\0\0\0\0\0\0\0\0\x01d\0\0\0\0\0\0\0\x01s\0\0\0\0\0\0\0\x01p\0\0\0\0\0\0\0\x01v\0\0\0\0\0\0\0\x01q";
1720        assert_eq!(canonical_rename_file_request_body(&request).expect("body"), old_body);
1721        request.durable = true;
1722        assert_ne!(canonical_rename_file_request_body(&request).expect("durable body"), old_body);
1723        let old_success = RenameFileResponse::decode(b"\x08\x01".as_slice()).expect("release response");
1724        assert!(old_success.success);
1725        assert!(!old_success.durability_applied, "old success cannot certify durable publication");
1726    }
1727
1728    #[test]
1729    fn disk_mutation_canonical_domains_are_distinct_per_message() {
1730        // The same field values must never authenticate one RPC's request as another's.
1731        let rename_file = RenameFileRequest {
1732            durable: false,
1733            disk: "d".into(),
1734            src_volume: "sv".into(),
1735            src_path: "sp".into(),
1736            dst_volume: "dv".into(),
1737            dst_path: "dp".into(),
1738        };
1739        let rename_part = RenamePartRequest {
1740            disk: "d".into(),
1741            src_volume: "sv".into(),
1742            src_path: "sp".into(),
1743            dst_volume: "dv".into(),
1744            dst_path: "dp".into(),
1745            meta: Vec::new().into(),
1746        };
1747        assert_ne!(
1748            canonical_rename_file_request_body(&rename_file).unwrap(),
1749            canonical_rename_part_request_body(&rename_part).unwrap(),
1750        );
1751    }
1752}
1753
1754#[cfg(test)]
1755mod non_disk_mutation_canonical_tests {
1756    use super::*;
1757    use proto_gen::node_service::*;
1758    use std::collections::HashMap;
1759
1760    macro_rules! assert_fields_bound {
1761        ($request:ty, {$($field:ident: $value:expr),+ $(,)?}) => {{
1762            let baseline = <$request>::default();
1763            let expected = baseline.canonical_body().expect("baseline canonical body should encode");
1764            $(
1765                let mut variant = baseline.clone();
1766                variant.$field = $value;
1767                assert_ne!(
1768                    expected,
1769                    variant.canonical_body().expect("variant canonical body should encode"),
1770                    concat!(stringify!($request), " omitted field ", stringify!($field)),
1771                );
1772            )+
1773        }};
1774    }
1775
1776    fn signal_request(signal: Option<&str>, sub_system: Option<&str>, dry_run: Option<&str>) -> SignalServiceRequest {
1777        let mut value = HashMap::new();
1778        if let Some(signal) = signal {
1779            value.insert(PEER_RESTSIGNAL.to_string(), signal.to_string());
1780        }
1781        if let Some(sub_system) = sub_system {
1782            value.insert(PEER_RESTSUB_SYS.to_string(), sub_system.to_string());
1783        }
1784        if let Some(dry_run) = dry_run {
1785            value.insert(PEER_RESTDRY_RUN.to_string(), dry_run.to_string());
1786        }
1787        SignalServiceRequest {
1788            vars: Some(Mss { value }),
1789        }
1790    }
1791
1792    #[test]
1793    fn signal_service_canonical_body_is_versioned_and_binds_every_semantic_field() {
1794        let request = signal_request(Some("2"), Some("scanner"), Some("false"));
1795        let baseline = request.canonical_body().expect("small signal request should encode");
1796        assert!(baseline.starts_with(b"rustfs-signal-service-request-v1\0"));
1797
1798        for variant in [
1799            signal_request(Some("1"), Some("scanner"), Some("false")),
1800            signal_request(Some("2"), Some("heal"), Some("false")),
1801            signal_request(Some("2"), Some("scanner"), Some("true")),
1802            signal_request(None, Some("scanner"), Some("false")),
1803            signal_request(Some("2"), None, Some("false")),
1804            signal_request(Some("2"), Some("scanner"), None),
1805        ] {
1806            assert_ne!(baseline, variant.canonical_body().expect("small signal request should encode"));
1807        }
1808
1809        let mut ignored_field = request;
1810        ignored_field
1811            .vars
1812            .as_mut()
1813            .expect("signal vars should exist")
1814            .value
1815            .insert("future-field".to_string(), "ignored".to_string());
1816        assert_eq!(
1817            baseline,
1818            ignored_field
1819                .canonical_body()
1820                .expect("unknown signal field should not affect the semantic body"),
1821        );
1822    }
1823
1824    #[test]
1825    fn signal_service_canonical_body_distinguishes_missing_and_empty_fields() {
1826        assert_ne!(
1827            signal_request(None, Some("scanner"), Some("false"))
1828                .canonical_body()
1829                .expect("missing signal should encode"),
1830            signal_request(Some(""), Some("scanner"), Some("false"))
1831                .canonical_body()
1832                .expect("empty signal should encode"),
1833        );
1834    }
1835
1836    #[test]
1837    fn bucket_and_lock_canonical_bodies_bind_every_semantic_field() {
1838        assert_fields_bound!(HealBucketRequest, { bucket: "bucket".into(), options: "opts".into(), bucket_incarnation_id: vec![1; 16].into() });
1839        assert_fields_bound!(MakeBucketRequest, { name: "bucket".into(), options: "opts".into() });
1840        assert_fields_bound!(DeleteBucketRequest, { bucket: "bucket".into(), options: "opts".into() });
1841        assert_fields_bound!(GenerallyLockRequest, { args: "lock".into() });
1842        assert_fields_bound!(BatchGenerallyLockRequest, { args: vec!["first".into(), "second".into()] });
1843
1844        let first = BatchGenerallyLockRequest {
1845            args: vec!["first".into(), "second".into()],
1846        };
1847        let reversed = BatchGenerallyLockRequest {
1848            args: vec!["second".into(), "first".into()],
1849        };
1850        assert_ne!(first.canonical_body().unwrap(), reversed.canonical_body().unwrap());
1851    }
1852
1853    #[test]
1854    fn metadata_and_iam_canonical_bodies_bind_every_semantic_field() {
1855        assert_fields_bound!(LoadBucketMetadataRequest, {
1856            bucket: "bucket".into(),
1857            scanner_maintenance_change: true,
1858        });
1859        assert_fields_bound!(DeleteBucketMetadataRequest, { bucket: "bucket".into() });
1860        assert_fields_bound!(DeletePolicyRequest, { policy_name: "policy".into() });
1861        assert_fields_bound!(LoadPolicyRequest, { policy_name: "policy".into() });
1862        assert_fields_bound!(LoadPolicyMappingRequest, {
1863            user_or_group: "user".into(),
1864            user_type: 1,
1865            is_group: true,
1866        });
1867        assert_fields_bound!(DeleteUserRequest, { access_key: "user".into() });
1868        assert_fields_bound!(DeleteServiceAccountRequest, { access_key: "service".into() });
1869        assert_fields_bound!(LoadUserRequest, { access_key: "user".into(), temp: true });
1870        assert_fields_bound!(LoadServiceAccountRequest, { access_key: "service".into() });
1871        assert_fields_bound!(LoadGroupRequest, { group: "group".into() });
1872    }
1873
1874    #[test]
1875    fn control_plane_canonical_bodies_bind_every_semantic_field() {
1876        assert_fields_bound!(StopRebalanceRequest, { expected_rebalance_id: "rebalance".into() });
1877        assert_fields_bound!(LoadRebalanceMetaRequest, { start_rebalance: true });
1878        assert_fields_bound!(StartDecommissionRequest, { pool_indices: vec![1, 2] });
1879        assert_fields_bound!(CancelDecommissionRequest, { pool_index: 1 });
1880        assert_fields_bound!(ClearDecommissionRequest, { pool_index: 1 });
1881
1882        let first = StartDecommissionRequest {
1883            pool_indices: vec![1, 2],
1884        };
1885        let reversed = StartDecommissionRequest {
1886            pool_indices: vec![2, 1],
1887        };
1888        assert_ne!(first.canonical_body().unwrap(), reversed.canonical_body().unwrap());
1889
1890        let empty_domains = [
1891            ReloadSiteReplicationConfigRequest::default().canonical_body().unwrap(),
1892            ReloadPoolMetaRequest::default().canonical_body().unwrap(),
1893            LoadTransitionTierConfigRequest::default().canonical_body().unwrap(),
1894        ];
1895        assert!(empty_domains.iter().all(|body| !body.is_empty()));
1896        assert_ne!(empty_domains[0], empty_domains[1]);
1897        assert_ne!(empty_domains[0], empty_domains[2]);
1898        assert_ne!(empty_domains[1], empty_domains[2]);
1899    }
1900}
1901
1902#[cfg(test)]
1903mod scanner_activity_tests {
1904    use super::{
1905        canonical_scanner_activity_request_body, canonical_scanner_activity_response_body,
1906        canonical_scanner_activity_v4_response_body, canonical_scanner_activity_v7_response_body,
1907        canonical_scanner_dirty_usage_snapshot_request_body, canonical_scanner_dirty_usage_snapshot_response_body,
1908        canonical_scanner_publication_lease_release_request_body, canonical_scanner_publication_lease_request_body,
1909        canonical_scanner_publication_lease_response_body,
1910        proto_gen::node_service::{
1911            ScannerActivityRequest, ScannerActivityResponse, ScannerDirtyUsageBucket, ScannerDirtyUsageSnapshotRequest,
1912            ScannerDirtyUsageSnapshotResponse, ScannerPublicationLeaseRequest, ScannerPublicationLeaseResponse,
1913        },
1914    };
1915
1916    #[test]
1917    fn canonical_scanner_dirty_usage_snapshot_request_binds_every_field() {
1918        let request = ScannerDirtyUsageSnapshotRequest {
1919            challenge: vec![1; 16].into(),
1920            protocol_version: 1,
1921        };
1922        let baseline = canonical_scanner_dirty_usage_snapshot_request_body(&request)
1923            .expect("scanner dirty usage snapshot request should encode");
1924
1925        for variant in [
1926            ScannerDirtyUsageSnapshotRequest {
1927                challenge: vec![2; 16].into(),
1928                ..request.clone()
1929            },
1930            ScannerDirtyUsageSnapshotRequest {
1931                protocol_version: 2,
1932                ..request
1933            },
1934        ] {
1935            assert_ne!(
1936                baseline,
1937                canonical_scanner_dirty_usage_snapshot_request_body(&variant)
1938                    .expect("scanner dirty usage snapshot request variant should encode")
1939            );
1940        }
1941    }
1942
1943    #[test]
1944    fn canonical_scanner_dirty_usage_snapshot_response_binds_every_field() {
1945        let response = ScannerDirtyUsageSnapshotResponse {
1946            instance_id: "0123456789abcdef0123456789abcdef".to_string(),
1947            generation: 7,
1948            pending_bucket_count: 2,
1949            protocol_version: 1,
1950            complete: true,
1951            buckets: vec![
1952                ScannerDirtyUsageBucket {
1953                    bucket: "archive".to_string(),
1954                    generation: 3,
1955                    bucket_incarnation: vec![1; 16].into(),
1956                },
1957                ScannerDirtyUsageBucket {
1958                    bucket: "photos".to_string(),
1959                    generation: 7,
1960                    bucket_incarnation: vec![2; 16].into(),
1961                },
1962            ],
1963            response_proof: vec![9; 32].into(),
1964            owner_id: "11111111-1111-1111-1111-111111111111".to_string(),
1965        };
1966        let baseline = canonical_scanner_dirty_usage_snapshot_response_body(&[1; 16], &response)
1967            .expect("scanner dirty usage snapshot response should encode");
1968        let mut variants = Vec::new();
1969        let mut instance = response.clone();
1970        instance.instance_id = "fedcba9876543210fedcba9876543210".to_string();
1971        variants.push(instance);
1972        let mut generation = response.clone();
1973        generation.generation = 8;
1974        variants.push(generation);
1975        let mut count = response.clone();
1976        count.pending_bucket_count = 3;
1977        variants.push(count);
1978        let mut protocol = response.clone();
1979        protocol.protocol_version = 2;
1980        variants.push(protocol);
1981        let mut owner = response.clone();
1982        owner.owner_id = "22222222-2222-2222-2222-222222222222".to_string();
1983        variants.push(owner);
1984        let mut complete = response.clone();
1985        complete.complete = false;
1986        variants.push(complete);
1987        let mut bucket_name = response.clone();
1988        bucket_name.buckets[0].bucket = "backups".to_string();
1989        variants.push(bucket_name);
1990        let mut bucket_generation = response.clone();
1991        bucket_generation.buckets[0].generation = 4;
1992        variants.push(bucket_generation);
1993        let mut bucket_incarnation = response.clone();
1994        bucket_incarnation.buckets[0].bucket_incarnation = vec![3; 16].into();
1995        variants.push(bucket_incarnation);
1996        let mut bucket_order = response.clone();
1997        bucket_order.buckets.reverse();
1998        variants.push(bucket_order);
1999
2000        for variant in variants {
2001            assert_ne!(
2002                baseline,
2003                canonical_scanner_dirty_usage_snapshot_response_body(&[1; 16], &variant)
2004                    .expect("scanner dirty usage snapshot response variant should encode")
2005            );
2006        }
2007        assert_ne!(
2008            baseline,
2009            canonical_scanner_dirty_usage_snapshot_response_body(&[2; 16], &response)
2010                .expect("scanner dirty usage snapshot response challenge variant should encode")
2011        );
2012
2013        let mut proof_only = response;
2014        proof_only.response_proof = vec![8; 32].into();
2015        assert_eq!(
2016            baseline,
2017            canonical_scanner_dirty_usage_snapshot_response_body(&[1; 16], &proof_only)
2018                .expect("response proof must not authenticate itself")
2019        );
2020    }
2021
2022    #[test]
2023    fn canonical_scanner_activity_request_binds_every_field() {
2024        let request = ScannerActivityRequest {
2025            challenge: vec![1; 16].into(),
2026            protocol_version: 5,
2027            acknowledge_instance_id: "0123456789abcdef0123456789abcdef".to_string(),
2028            acknowledge_dirty_usage_generation: 11,
2029        };
2030        let baseline = canonical_scanner_activity_request_body(&request).expect("scanner activity request should encode");
2031
2032        let variants = [
2033            ScannerActivityRequest {
2034                challenge: vec![2; 16].into(),
2035                ..request.clone()
2036            },
2037            ScannerActivityRequest {
2038                protocol_version: 4,
2039                ..request.clone()
2040            },
2041            ScannerActivityRequest {
2042                acknowledge_instance_id: "1123456789abcdef0123456789abcdef".to_string(),
2043                ..request.clone()
2044            },
2045            ScannerActivityRequest {
2046                acknowledge_dirty_usage_generation: 12,
2047                ..request
2048            },
2049        ];
2050        for variant in variants {
2051            assert_ne!(
2052                baseline,
2053                canonical_scanner_activity_request_body(&variant).expect("scanner activity request variant should encode")
2054            );
2055        }
2056    }
2057
2058    #[test]
2059    fn canonical_scanner_activity_response_binds_challenge_and_every_status_field() {
2060        let response = ScannerActivityResponse {
2061            instance_id: "0123456789abcdef0123456789abcdef".to_string(),
2062            namespace_generation: 7,
2063            maintenance_generation: 3,
2064            protocol_version: 4,
2065            topology_digest: vec![9; 32].into(),
2066            data_movement_active: true,
2067            response_proof: Vec::new().into(),
2068            dirty_usage_generation: 11,
2069            dirty_usage_pending: true,
2070            movement_generation: Some(19),
2071            publication_blocked: Some(false),
2072        };
2073        let baseline =
2074            canonical_scanner_activity_response_body(&[1; 16], &response).expect("scanner activity response should encode");
2075
2076        let variants = [
2077            ScannerActivityResponse {
2078                instance_id: "1123456789abcdef0123456789abcdef".to_string(),
2079                ..response.clone()
2080            },
2081            ScannerActivityResponse {
2082                namespace_generation: 8,
2083                ..response.clone()
2084            },
2085            ScannerActivityResponse {
2086                maintenance_generation: 4,
2087                ..response.clone()
2088            },
2089            ScannerActivityResponse {
2090                protocol_version: 5,
2091                ..response.clone()
2092            },
2093            ScannerActivityResponse {
2094                topology_digest: vec![8; 32].into(),
2095                ..response.clone()
2096            },
2097            ScannerActivityResponse {
2098                data_movement_active: false,
2099                ..response.clone()
2100            },
2101            ScannerActivityResponse {
2102                dirty_usage_generation: 12,
2103                ..response.clone()
2104            },
2105            ScannerActivityResponse {
2106                dirty_usage_pending: false,
2107                ..response.clone()
2108            },
2109        ];
2110        for variant in variants {
2111            assert_ne!(
2112                baseline,
2113                canonical_scanner_activity_response_body(&[1; 16], &variant)
2114                    .expect("scanner activity response variant should encode")
2115            );
2116        }
2117        assert_ne!(
2118            baseline,
2119            canonical_scanner_activity_response_body(&[2; 16], &response)
2120                .expect("scanner activity response with a different challenge should encode")
2121        );
2122
2123        let v7_baseline =
2124            canonical_scanner_activity_v7_response_body(&[1; 16], &response).expect("scanner activity v7 response should encode");
2125        for variant in [
2126            ScannerActivityResponse {
2127                movement_generation: Some(20),
2128                ..response.clone()
2129            },
2130            ScannerActivityResponse {
2131                publication_blocked: Some(true),
2132                ..response
2133            },
2134        ] {
2135            assert_ne!(
2136                v7_baseline,
2137                canonical_scanner_activity_v7_response_body(&[1; 16], &variant)
2138                    .expect("scanner activity v7 response variant should encode")
2139            );
2140        }
2141    }
2142
2143    #[test]
2144    fn scanner_activity_v4_response_canonicalization_ignores_v5_fields() {
2145        let response = ScannerActivityResponse {
2146            instance_id: "0123456789abcdef0123456789abcdef".to_string(),
2147            namespace_generation: 7,
2148            maintenance_generation: 3,
2149            protocol_version: 4,
2150            topology_digest: vec![9; 32].into(),
2151            data_movement_active: true,
2152            response_proof: Vec::new().into(),
2153            dirty_usage_generation: 0,
2154            dirty_usage_pending: false,
2155            movement_generation: None,
2156            publication_blocked: None,
2157        };
2158        let baseline =
2159            canonical_scanner_activity_v4_response_body(&[1; 16], &response).expect("scanner activity v4 response should encode");
2160        let expected = [
2161            b"rustfs-scanner-activity-response-v1\0".as_slice(),
2162            16u64.to_be_bytes().as_slice(),
2163            [1u8; 16].as_slice(),
2164            32u64.to_be_bytes().as_slice(),
2165            b"0123456789abcdef0123456789abcdef".as_slice(),
2166            7u64.to_be_bytes().as_slice(),
2167            3u64.to_be_bytes().as_slice(),
2168            4u32.to_be_bytes().as_slice(),
2169            32u64.to_be_bytes().as_slice(),
2170            [9u8; 32].as_slice(),
2171            [1u8].as_slice(),
2172        ]
2173        .concat();
2174        assert_eq!(
2175            baseline, expected,
2176            "protocol v4 response bytes must remain stable during rolling upgrades"
2177        );
2178        let extended = ScannerActivityResponse {
2179            dirty_usage_generation: 11,
2180            dirty_usage_pending: true,
2181            ..response
2182        };
2183
2184        assert_eq!(
2185            baseline,
2186            canonical_scanner_activity_v4_response_body(&[1; 16], &extended)
2187                .expect("scanner activity v4 response should ignore v5 fields")
2188        );
2189    }
2190
2191    #[test]
2192    fn scanner_publication_lease_canonical_bodies_bind_session_owner_and_generation() {
2193        let request = ScannerPublicationLeaseRequest {
2194            challenge: vec![1; 16].into(),
2195            expected_movement_generation: 7,
2196            ttl_ms: 60_000,
2197            expected_session_id: "session-a".to_string(),
2198            token: Vec::new().into(),
2199        };
2200        let baseline = canonical_scanner_publication_lease_request_body(&request).unwrap();
2201        for variant in [
2202            ScannerPublicationLeaseRequest {
2203                expected_movement_generation: 8,
2204                ..request.clone()
2205            },
2206            ScannerPublicationLeaseRequest {
2207                expected_session_id: "session-b".to_string(),
2208                ..request.clone()
2209            },
2210            ScannerPublicationLeaseRequest {
2211                ttl_ms: 30_000,
2212                ..request.clone()
2213            },
2214            ScannerPublicationLeaseRequest {
2215                token: vec![3; 16].into(),
2216                ..request
2217            },
2218        ] {
2219            assert_ne!(baseline, canonical_scanner_publication_lease_request_body(&variant).unwrap());
2220        }
2221
2222        let release_a = crate::proto_gen::node_service::ScannerPublicationLeaseReleaseRequest {
2223            challenge: vec![2; 16].into(),
2224            token: vec![3; 16].into(),
2225            owner_id: "owner-a".to_string(),
2226            session_id: "session-a".to_string(),
2227        };
2228        let release_b = crate::proto_gen::node_service::ScannerPublicationLeaseReleaseRequest {
2229            owner_id: "owner-b".to_string(),
2230            ..release_a.clone()
2231        };
2232        assert_ne!(
2233            canonical_scanner_publication_lease_release_request_body(&release_a).unwrap(),
2234            canonical_scanner_publication_lease_release_request_body(&release_b).unwrap()
2235        );
2236
2237        let response = ScannerPublicationLeaseResponse {
2238            success: true,
2239            token: vec![4; 16].into(),
2240            movement_generation: 7,
2241            lease_ttl_ms: 60_000,
2242            error: None,
2243            response_proof: Vec::new().into(),
2244            owner_id: "owner-a".to_string(),
2245            session_id: "session-a".to_string(),
2246        };
2247        let response_changed = ScannerPublicationLeaseResponse {
2248            owner_id: "owner-b".to_string(),
2249            ..response.clone()
2250        };
2251        assert_ne!(
2252            canonical_scanner_publication_lease_response_body(&[1; 16], &response).unwrap(),
2253            canonical_scanner_publication_lease_response_body(&[1; 16], &response_changed).unwrap()
2254        );
2255    }
2256}
2257
2258#[cfg(test)]
2259mod heal_control_tests {
2260    use super::{
2261        CROSS_POOL_FENCE_CAPABILITY_PROBE_PREFIX, HEAL_CONTROL_CAPABILITY_PROBE_PREFIX, HEAL_CONTROL_PROTOCOL_VERSION,
2262        ILM_RECOVERY_EXPORT_CAPABILITY_PROBE_PREFIX, REMOTE_VERSION_STATE_CAPABILITY_PROBE_PREFIX,
2263        TRANSITION_TRANSACTION_COMPACTION_CAPABILITY_PROBE_PREFIX, canonical_heal_control_capability_ack,
2264        canonical_heal_control_request_body, canonical_heal_control_response_body, decode_remote_version_state_capability,
2265        encode_cross_pool_fence_capability, encode_remote_version_state_capability, heal_control_capability_probe,
2266        heal_control_coordinator_epoch, heal_control_execution_timeout, heal_control_execution_timeout_for,
2267        ilm_recovery_export_capability_probe, internode_rpc_timeout, is_cross_pool_fence_capability_probe,
2268        is_heal_control_capability_probe, is_ilm_recovery_export_capability_probe, is_remote_version_state_capability_probe,
2269        is_transition_transaction_compaction_capability_probe, normalize_internode_rpc_timeout,
2270        remote_version_state_capability_probe, transition_transaction_compaction_capability_probe,
2271    };
2272    use crate::heal_control;
2273    use std::time::Duration;
2274
2275    #[test]
2276    fn canonical_heal_control_body_binds_every_field_and_boundary() {
2277        let baseline = canonical_heal_control_request_body(1, "ab", b"c").expect("small request should encode");
2278        let mut golden = b"rustfs-heal-control-v3\0".to_vec();
2279        golden.extend_from_slice(&1_u32.to_be_bytes());
2280        golden.extend_from_slice(&2_u64.to_be_bytes());
2281        golden.extend_from_slice(b"ab");
2282        golden.extend_from_slice(&1_u64.to_be_bytes());
2283        golden.extend_from_slice(b"c");
2284        assert_eq!(baseline, golden);
2285
2286        assert_ne!(
2287            baseline,
2288            canonical_heal_control_request_body(2, "ab", b"c").expect("small request should encode")
2289        );
2290        assert_ne!(
2291            baseline,
2292            canonical_heal_control_request_body(1, "ac", b"c").expect("small request should encode")
2293        );
2294        assert_ne!(
2295            baseline,
2296            canonical_heal_control_request_body(1, "ab", b"d").expect("small request should encode")
2297        );
2298        assert_ne!(
2299            canonical_heal_control_request_body(1, "ab", b"c").expect("small request should encode"),
2300            canonical_heal_control_request_body(1, "a", b"bc").expect("small request should encode")
2301        );
2302    }
2303
2304    #[test]
2305    fn canonical_capability_ack_binds_version_and_topology() {
2306        assert_eq!(HEAL_CONTROL_PROTOCOL_VERSION, 3);
2307        assert!(HEAL_CONTROL_CAPABILITY_PROBE_PREFIX.starts_with(b"rustfs-heal-control-capability-v4"));
2308        let probe = heal_control_capability_probe(&[7; 16]);
2309        let ack = canonical_heal_control_capability_ack(1, "ab", &probe).expect("small acknowledgement should encode");
2310        let mut golden = b"rustfs-heal-control-capability-ack-v4\0".to_vec();
2311        golden.extend_from_slice(&1_u32.to_be_bytes());
2312        golden.extend_from_slice(&2_u64.to_be_bytes());
2313        golden.extend_from_slice(b"ab");
2314        golden.extend_from_slice(&u64::try_from(probe.len()).unwrap().to_be_bytes());
2315        golden.extend_from_slice(&probe);
2316        assert_eq!(ack, golden);
2317        assert_ne!(ack, canonical_heal_control_capability_ack(2, "ab", &probe).unwrap());
2318        assert_ne!(ack, canonical_heal_control_capability_ack(1, "ac", &probe).unwrap());
2319        assert_ne!(
2320            ack,
2321            canonical_heal_control_capability_ack(1, "ab", &heal_control_capability_probe(&[8; 16])).unwrap()
2322        );
2323        assert!(is_heal_control_capability_probe(&probe));
2324        assert!(!is_heal_control_capability_probe(HEAL_CONTROL_CAPABILITY_PROBE_PREFIX));
2325        let legacy_probe = [b"rustfs-heal-control-capability-v3\0".as_slice(), &[7; 16]].concat();
2326        assert!(!is_heal_control_capability_probe(&legacy_probe));
2327    }
2328
2329    #[test]
2330    fn remote_version_state_capability_probe_requires_exact_nonce() {
2331        let probe = remote_version_state_capability_probe(&[7; 16]);
2332        assert!(is_remote_version_state_capability_probe(&probe));
2333        assert!(!is_remote_version_state_capability_probe(REMOTE_VERSION_STATE_CAPABILITY_PROBE_PREFIX));
2334    }
2335
2336    #[test]
2337    fn ilm_recovery_export_capability_probe_requires_exact_prefix_and_nonce() {
2338        let probe = ilm_recovery_export_capability_probe(&[7; 16]);
2339        assert!(is_ilm_recovery_export_capability_probe(&probe));
2340        assert!(!is_ilm_recovery_export_capability_probe(ILM_RECOVERY_EXPORT_CAPABILITY_PROBE_PREFIX));
2341        let mut wrong_prefix = probe.clone();
2342        wrong_prefix[0] ^= 1;
2343        assert!(!is_ilm_recovery_export_capability_probe(&wrong_prefix));
2344        let mut extra = probe;
2345        extra.push(0);
2346        assert!(!is_ilm_recovery_export_capability_probe(&extra));
2347    }
2348
2349    #[test]
2350    fn transition_transaction_compaction_probe_requires_exact_prefix_and_nonce() {
2351        let probe = transition_transaction_compaction_capability_probe(&[7; 16]);
2352        assert!(is_transition_transaction_compaction_capability_probe(&probe));
2353        assert!(!is_transition_transaction_compaction_capability_probe(
2354            TRANSITION_TRANSACTION_COMPACTION_CAPABILITY_PROBE_PREFIX,
2355        ));
2356        let mut wrong_prefix = probe.clone();
2357        wrong_prefix[0] ^= 1;
2358        assert!(!is_transition_transaction_compaction_capability_probe(&wrong_prefix));
2359        let mut extra = probe;
2360        extra.push(0);
2361        assert!(!is_transition_transaction_compaction_capability_probe(&extra));
2362    }
2363
2364    #[test]
2365    fn remote_version_state_capability_binds_member_and_process_epoch() {
2366        let encoded =
2367            encode_remote_version_state_capability("node-a:9000", &[7; 16]).expect("small capability response should encode");
2368        assert_eq!(
2369            decode_remote_version_state_capability(&encoded).expect("capability response should decode"),
2370            ("node-a:9000", &[7; 16])
2371        );
2372        assert!(decode_remote_version_state_capability(&encoded[..encoded.len() - 1]).is_err());
2373
2374        let mut invalid_utf8 =
2375            encode_remote_version_state_capability("node-a", &[7; 16]).expect("small capability response should encode");
2376        invalid_utf8[8] = 0xff;
2377        assert!(decode_remote_version_state_capability(&invalid_utf8).is_err());
2378    }
2379
2380    #[test]
2381    fn cross_pool_fence_capability_binds_version_member_and_epoch() {
2382        assert_eq!(CROSS_POOL_FENCE_CAPABILITY_PROBE_PREFIX, b"rustfs-cross-pool-fence-capability-v1\0");
2383        let mut probe = b"rustfs-cross-pool-fence-capability-v1\0".to_vec();
2384        probe.extend_from_slice(&[7; 16]);
2385        assert!(is_cross_pool_fence_capability_probe(&probe));
2386        assert!(!is_cross_pool_fence_capability_probe(CROSS_POOL_FENCE_CAPABILITY_PROBE_PREFIX));
2387        let mut wrong_prefix = probe.clone();
2388        wrong_prefix[0] ^= 1;
2389        assert!(!is_cross_pool_fence_capability_probe(&wrong_prefix));
2390
2391        let encoded = encode_cross_pool_fence_capability(0x0102_0304, "node-a:9000", &[7; 16])
2392            .expect("small capability response should encode");
2393        let mut expected_response = vec![1, 2, 3, 4];
2394        expected_response.extend_from_slice(&11_u64.to_be_bytes());
2395        expected_response.extend_from_slice(b"node-a:9000");
2396        expected_response.extend_from_slice(&[7; 16]);
2397        assert_eq!(encoded, expected_response);
2398    }
2399
2400    #[test]
2401    fn canonical_response_binds_request_and_result() {
2402        let baseline = canonical_heal_control_response_body(2, "abcdef", b"query", b"result").unwrap();
2403        assert_ne!(baseline, canonical_heal_control_response_body(1, "abcdef", b"query", b"result").unwrap());
2404        assert_ne!(baseline, canonical_heal_control_response_body(2, "bbcdef", b"query", b"result").unwrap());
2405        assert_ne!(baseline, canonical_heal_control_response_body(2, "abcdef", b"cancel", b"result").unwrap());
2406        assert_ne!(
2407            baseline,
2408            canonical_heal_control_response_body(2, "abcdef", b"query", b"tampered").unwrap()
2409        );
2410    }
2411
2412    #[test]
2413    fn coordinator_epoch_is_stable_and_rejects_invalid_fingerprints() {
2414        assert_eq!(heal_control_coordinator_epoch("0123456789abcdefextra"), Ok(0x0123_4567_89ab_cdef));
2415        assert_eq!(
2416            heal_control_coordinator_epoch("0000000000000000"),
2417            Err("heal control topology epoch is zero")
2418        );
2419        assert_eq!(
2420            heal_control_coordinator_epoch("short"),
2421            Err("heal control topology fingerprint is too short")
2422        );
2423        assert_eq!(
2424            heal_control_coordinator_epoch("not-hex-value!!!!"),
2425            Err("heal control topology fingerprint is not hexadecimal")
2426        );
2427    }
2428
2429    #[test]
2430    fn execution_budget_precedes_transport_timeout() {
2431        let execution = heal_control_execution_timeout();
2432        assert!(!execution.is_zero());
2433        assert!(execution < internode_rpc_timeout());
2434        assert!(execution <= std::time::Duration::from_millis(heal_control::MAX_LIFETIME_MS as u64));
2435    }
2436
2437    #[test]
2438    fn execution_budget_is_nonzero_for_zero_transport_configuration() {
2439        let normalized_transport = Duration::from_secs(1);
2440        assert_eq!(normalize_internode_rpc_timeout(Duration::ZERO), normalized_transport);
2441        for configured_transport in [Duration::ZERO, normalized_transport] {
2442            let execution = heal_control_execution_timeout_for(configured_transport);
2443            assert!(execution > Duration::ZERO);
2444            assert!(execution < normalized_transport);
2445        }
2446    }
2447}
2448
2449#[cfg(test)]
2450mod tier_mutation_rpc_tests {
2451    use super::{
2452        TIER_MUTATION_RPC_PREVIOUS_PROTOCOL_VERSION, TIER_MUTATION_RPC_PROTOCOL_VERSION, TierMutationRpcPhase,
2453        TierMutationRpcResponseProofInput, canonical_tier_mutation_rpc_body, canonical_tier_mutation_rpc_response_body,
2454    };
2455    use crate::proto_gen::node_service::{TierMutationFailureClass, TierMutationPeerState};
2456    use uuid::uuid;
2457
2458    #[test]
2459    fn canonical_tier_mutation_body_binds_phase_id_and_payload() {
2460        let mutation_id = uuid!("12345678-1234-5678-9abc-def012345678");
2461        let payload = b"canonical-intent-record";
2462        let baseline = canonical_tier_mutation_rpc_body(
2463            TIER_MUTATION_RPC_PROTOCOL_VERSION,
2464            TierMutationRpcPhase::Prepare,
2465            mutation_id,
2466            payload,
2467        )
2468        .expect("small mutation body should encode");
2469        let mut golden = b"rustfs-tier-mutation-rpc-v1\0".to_vec();
2470        golden.extend_from_slice(&TIER_MUTATION_RPC_PROTOCOL_VERSION.to_be_bytes());
2471        golden.extend_from_slice(&7_u64.to_be_bytes());
2472        golden.extend_from_slice(b"prepare");
2473        golden.extend_from_slice(mutation_id.as_bytes());
2474        golden.extend_from_slice(&u64::try_from(payload.len()).expect("payload length should fit").to_be_bytes());
2475        golden.extend_from_slice(payload);
2476        assert_eq!(baseline, golden);
2477
2478        assert_ne!(
2479            baseline,
2480            canonical_tier_mutation_rpc_body(
2481                TIER_MUTATION_RPC_PROTOCOL_VERSION + 1,
2482                TierMutationRpcPhase::Prepare,
2483                mutation_id,
2484                payload,
2485            )
2486            .expect("small mutation body should encode")
2487        );
2488        assert_ne!(
2489            baseline,
2490            canonical_tier_mutation_rpc_body(
2491                TIER_MUTATION_RPC_PROTOCOL_VERSION,
2492                TierMutationRpcPhase::Commit,
2493                mutation_id,
2494                payload,
2495            )
2496            .expect("small mutation body should encode")
2497        );
2498        assert_ne!(
2499            baseline,
2500            canonical_tier_mutation_rpc_body(
2501                TIER_MUTATION_RPC_PROTOCOL_VERSION,
2502                TierMutationRpcPhase::Prepare,
2503                uuid!("22345678-1234-5678-9abc-def012345678"),
2504                payload,
2505            )
2506            .expect("small mutation body should encode")
2507        );
2508        assert_ne!(
2509            baseline,
2510            canonical_tier_mutation_rpc_body(
2511                TIER_MUTATION_RPC_PROTOCOL_VERSION,
2512                TierMutationRpcPhase::Prepare,
2513                mutation_id,
2514                b"canonical-intent-record-tampered",
2515            )
2516            .expect("small mutation body should encode")
2517        );
2518    }
2519
2520    #[test]
2521    fn tier_mutation_v3_request_and_response_golden_bytes_are_unchanged() {
2522        let mutation_id = uuid!("12345678-1234-5678-9abc-def012345678");
2523        let payload = b"canonical-intent-record";
2524        let request = canonical_tier_mutation_rpc_body(
2525            TIER_MUTATION_RPC_PREVIOUS_PROTOCOL_VERSION,
2526            TierMutationRpcPhase::Prepare,
2527            mutation_id,
2528            payload,
2529        )
2530        .expect("v3 request should encode");
2531        let mut request_golden = b"rustfs-tier-mutation-rpc-v1\0".to_vec();
2532        request_golden.extend_from_slice(&TIER_MUTATION_RPC_PREVIOUS_PROTOCOL_VERSION.to_be_bytes());
2533        request_golden.extend_from_slice(&7_u64.to_be_bytes());
2534        request_golden.extend_from_slice(b"prepare");
2535        request_golden.extend_from_slice(mutation_id.as_bytes());
2536        request_golden.extend_from_slice(&u64::try_from(payload.len()).expect("payload length should fit").to_be_bytes());
2537        request_golden.extend_from_slice(payload);
2538        assert_eq!(request, request_golden);
2539
2540        let response = canonical_tier_mutation_rpc_response_body(TierMutationRpcResponseProofInput {
2541            version: TIER_MUTATION_RPC_PREVIOUS_PROTOCOL_VERSION,
2542            phase: TierMutationRpcPhase::Prepare,
2543            mutation_id,
2544            canonical_payload: payload,
2545            success: true,
2546            state: TierMutationPeerState::Prepared as i32,
2547            applied: true,
2548            error_info: None,
2549            // v3 must ignore the field so its authenticated bytes stay exact.
2550            failure_class: TierMutationFailureClass::PreDispatchRejected as i32,
2551        })
2552        .expect("v3 response should encode");
2553        let mut response_golden = b"rustfs-tier-mutation-rpc-response-v1\0".to_vec();
2554        response_golden.extend_from_slice(&TIER_MUTATION_RPC_PREVIOUS_PROTOCOL_VERSION.to_be_bytes());
2555        response_golden.extend_from_slice(&7_u64.to_be_bytes());
2556        response_golden.extend_from_slice(b"prepare");
2557        response_golden.extend_from_slice(mutation_id.as_bytes());
2558        response_golden.extend_from_slice(&u64::try_from(payload.len()).expect("payload length should fit").to_be_bytes());
2559        response_golden.extend_from_slice(payload);
2560        response_golden.push(1);
2561        response_golden.extend_from_slice(&(TierMutationPeerState::Prepared as i32).to_be_bytes());
2562        response_golden.push(1);
2563        response_golden.push(0);
2564        response_golden.extend_from_slice(&0_u64.to_be_bytes());
2565        assert_eq!(response, response_golden);
2566    }
2567
2568    #[test]
2569    fn canonical_tier_mutation_v4_response_binds_request_result_and_failure_class() {
2570        let mutation_id = uuid!("12345678-1234-5678-9abc-def012345678");
2571        let payload = b"canonical-intent-record";
2572        let baseline = canonical_tier_mutation_rpc_response_body(TierMutationRpcResponseProofInput {
2573            version: TIER_MUTATION_RPC_PROTOCOL_VERSION,
2574            phase: TierMutationRpcPhase::Prepare,
2575            mutation_id,
2576            canonical_payload: payload,
2577            success: false,
2578            state: TierMutationPeerState::Unspecified as i32,
2579            applied: false,
2580            error_info: Some("failure"),
2581            failure_class: TierMutationFailureClass::Ambiguous as i32,
2582        })
2583        .expect("small v4 mutation response should encode");
2584
2585        let cases = [
2586            TierMutationRpcResponseProofInput {
2587                version: TIER_MUTATION_RPC_PREVIOUS_PROTOCOL_VERSION,
2588                phase: TierMutationRpcPhase::Prepare,
2589                mutation_id,
2590                canonical_payload: payload,
2591                success: false,
2592                state: TierMutationPeerState::Unspecified as i32,
2593                applied: false,
2594                error_info: Some("failure"),
2595                failure_class: TierMutationFailureClass::Ambiguous as i32,
2596            },
2597            TierMutationRpcResponseProofInput {
2598                version: TIER_MUTATION_RPC_PROTOCOL_VERSION,
2599                phase: TierMutationRpcPhase::Commit,
2600                mutation_id,
2601                canonical_payload: payload,
2602                success: false,
2603                state: TierMutationPeerState::Unspecified as i32,
2604                applied: false,
2605                error_info: Some("failure"),
2606                failure_class: TierMutationFailureClass::Ambiguous as i32,
2607            },
2608            TierMutationRpcResponseProofInput {
2609                version: TIER_MUTATION_RPC_PROTOCOL_VERSION,
2610                phase: TierMutationRpcPhase::Prepare,
2611                mutation_id: uuid!("22345678-1234-5678-9abc-def012345678"),
2612                canonical_payload: payload,
2613                success: false,
2614                state: TierMutationPeerState::Unspecified as i32,
2615                applied: false,
2616                error_info: Some("failure"),
2617                failure_class: TierMutationFailureClass::Ambiguous as i32,
2618            },
2619            TierMutationRpcResponseProofInput {
2620                version: TIER_MUTATION_RPC_PROTOCOL_VERSION,
2621                phase: TierMutationRpcPhase::Prepare,
2622                mutation_id,
2623                canonical_payload: b"tampered-intent-record",
2624                success: false,
2625                state: TierMutationPeerState::Unspecified as i32,
2626                applied: false,
2627                error_info: Some("failure"),
2628                failure_class: TierMutationFailureClass::Ambiguous as i32,
2629            },
2630            TierMutationRpcResponseProofInput {
2631                version: TIER_MUTATION_RPC_PROTOCOL_VERSION,
2632                phase: TierMutationRpcPhase::Prepare,
2633                mutation_id,
2634                canonical_payload: payload,
2635                success: true,
2636                state: TierMutationPeerState::Unspecified as i32,
2637                applied: false,
2638                error_info: Some("failure"),
2639                failure_class: TierMutationFailureClass::Ambiguous as i32,
2640            },
2641            TierMutationRpcResponseProofInput {
2642                version: TIER_MUTATION_RPC_PROTOCOL_VERSION,
2643                phase: TierMutationRpcPhase::Prepare,
2644                mutation_id,
2645                canonical_payload: payload,
2646                success: false,
2647                state: TierMutationPeerState::Committed as i32,
2648                applied: false,
2649                error_info: Some("failure"),
2650                failure_class: TierMutationFailureClass::Ambiguous as i32,
2651            },
2652            TierMutationRpcResponseProofInput {
2653                version: TIER_MUTATION_RPC_PROTOCOL_VERSION,
2654                phase: TierMutationRpcPhase::Prepare,
2655                mutation_id,
2656                canonical_payload: payload,
2657                success: false,
2658                state: TierMutationPeerState::Unspecified as i32,
2659                applied: true,
2660                error_info: Some("failure"),
2661                failure_class: TierMutationFailureClass::Ambiguous as i32,
2662            },
2663            TierMutationRpcResponseProofInput {
2664                version: TIER_MUTATION_RPC_PROTOCOL_VERSION,
2665                phase: TierMutationRpcPhase::Prepare,
2666                mutation_id,
2667                canonical_payload: payload,
2668                success: false,
2669                state: TierMutationPeerState::Unspecified as i32,
2670                applied: false,
2671                error_info: Some("other failure"),
2672                failure_class: TierMutationFailureClass::Ambiguous as i32,
2673            },
2674            TierMutationRpcResponseProofInput {
2675                version: TIER_MUTATION_RPC_PROTOCOL_VERSION,
2676                phase: TierMutationRpcPhase::Prepare,
2677                mutation_id,
2678                canonical_payload: payload,
2679                success: false,
2680                state: TierMutationPeerState::Unspecified as i32,
2681                applied: false,
2682                error_info: Some("failure"),
2683                failure_class: TierMutationFailureClass::PreDispatchRejected as i32,
2684            },
2685        ];
2686        for case in cases {
2687            assert_ne!(
2688                baseline,
2689                canonical_tier_mutation_rpc_response_body(case).expect("small mutation response should encode")
2690            );
2691        }
2692    }
2693}
2694
2695/// Whether internode metadata RPCs should send only the msgpack `_bin` payloads and leave the JSON
2696/// compatibility strings empty (grpc-optimization P2-1). Shared by the client (`remote_disk`) and
2697/// server (`node_service`) send paths.
2698///
2699/// The legacy flag alone is deliberately insufficient: emptying JSON breaks old peers that only
2700/// decode the compatibility field. Operators must also set the fleet-confirmed guard after the
2701/// convergence runbook proves every peer supports `_bin` and rollback.
2702pub fn internode_rpc_msgpack_only() -> bool {
2703    match INTERNODE_RPC_MSGPACK_ONLY_CACHE.load(Ordering::Acquire) {
2704        INTERNODE_RPC_MSGPACK_ONLY_CACHE_FALSE => return false,
2705        INTERNODE_RPC_MSGPACK_ONLY_CACHE_TRUE => return true,
2706        _ => {}
2707    }
2708
2709    let enabled = rustfs_utils::get_env_bool(
2710        rustfs_config::ENV_INTERNODE_RPC_MSGPACK_ONLY,
2711        rustfs_config::DEFAULT_INTERNODE_RPC_MSGPACK_ONLY,
2712    ) && rustfs_utils::get_env_bool(
2713        rustfs_config::ENV_INTERNODE_RPC_MSGPACK_ONLY_FLEET_CONFIRMED,
2714        rustfs_config::DEFAULT_INTERNODE_RPC_MSGPACK_ONLY_FLEET_CONFIRMED,
2715    );
2716    INTERNODE_RPC_MSGPACK_ONLY_CACHE.store(
2717        if enabled {
2718            INTERNODE_RPC_MSGPACK_ONLY_CACHE_TRUE
2719        } else {
2720            INTERNODE_RPC_MSGPACK_ONLY_CACHE_FALSE
2721        },
2722        Ordering::Release,
2723    );
2724    enabled
2725}
2726
2727#[doc(hidden)]
2728pub fn reset_internode_rpc_msgpack_only_cache() {
2729    INTERNODE_RPC_MSGPACK_ONLY_CACHE.store(INTERNODE_RPC_MSGPACK_ONLY_CACHE_UNSET, Ordering::Release);
2730}
2731
2732/// Consecutive-failure threshold after which an internode peer is marked offline (grpc-optimization
2733/// P3). Drives the `rustfs_cluster_servers_offline_total` gauge.
2734fn internode_offline_failure_threshold() -> u32 {
2735    rustfs_utils::get_env_u32(
2736        rustfs_config::ENV_INTERNODE_OFFLINE_FAILURE_THRESHOLD,
2737        rustfs_config::DEFAULT_INTERNODE_OFFLINE_FAILURE_THRESHOLD,
2738    )
2739}
2740
2741/// Class of internode gRPC channel used to route an RPC (grpc-optimization P1).
2742///
2743/// - [`ChannelClass::Control`]: latency-sensitive control-plane RPCs (locks, health, small
2744///   metadata). Always uses the per-peer control connection keyed by the bare address.
2745/// - [`ChannelClass::Bulk`]: large `bytes`-carrying unary RPCs
2746///   (`ReadAll`/`WriteAll`/`ReadMultiple`/`BatchReadVersion`). When channel isolation is enabled
2747///   these are routed onto a separate per-peer bulk connection pool so a large transfer cannot
2748///   head-of-line block a lock RPC on the shared HTTP/2 connection.
2749#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2750pub enum ChannelClass {
2751    Control,
2752    Bulk,
2753}
2754
2755// Keep multiplexed unary RPCs below h2's per-connection small-frame budget.
2756const INTERNODE_RPC_CONCURRENCY_LIMIT: usize = 64;
2757
2758/// Whether control/bulk channel isolation is enabled (env-gated, default off for safe rollout).
2759fn channel_isolation_enabled() -> bool {
2760    rustfs_utils::get_env_bool(
2761        rustfs_config::ENV_INTERNODE_CHANNEL_ISOLATION,
2762        rustfs_config::DEFAULT_INTERNODE_CHANNEL_ISOLATION,
2763    )
2764}
2765
2766/// Number of bulk channels maintained per peer (>= 1).
2767fn bulk_channel_pool_size() -> usize {
2768    rustfs_utils::get_env_usize(rustfs_config::ENV_INTERNODE_BULK_CHANNELS, rustfs_config::DEFAULT_INTERNODE_BULK_CHANNELS).max(1)
2769}
2770
2771/// Round-robin cursor for selecting a bulk channel within a peer's pool. A single global cursor
2772/// is sufficient: it advances once per bulk acquisition and `% pool_size` spreads consecutive
2773/// acquisitions across the pool.
2774static BULK_CHANNEL_CURSOR: AtomicUsize = AtomicUsize::new(0);
2775
2776/// Connection-cache key for the `idx`-th bulk channel to `addr`. The NUL separator cannot appear
2777/// in a URL, so a bulk key never collides with the control key (the bare `addr`).
2778fn bulk_cache_key(addr: &str, idx: usize) -> String {
2779    format!("{addr}\u{0}bulk\u{0}{idx}")
2780}
2781
2782/// Acquire a cached-or-newly-dialed channel for the given peer and channel class.
2783///
2784/// For [`ChannelClass::Control`] (or whenever isolation is disabled) this is exactly the legacy
2785/// behavior: reuse the cached control channel keyed by `addr`, else dial a new one. For
2786/// [`ChannelClass::Bulk`] with isolation enabled, a channel is round-robin selected from the
2787/// per-peer bulk pool and dialed on demand, physically isolating large transfers from
2788/// control-plane RPCs.
2789pub async fn get_channel_for_class(addr: &str, class: ChannelClass) -> Result<Channel, Box<dyn Error>> {
2790    if class == ChannelClass::Control || !channel_isolation_enabled() {
2791        if let Some(channel) = cached_connection(addr).await {
2792            debug!("Using cached control gRPC channel for: {}", addr);
2793            return Ok(channel);
2794        }
2795        return create_new_channel(addr).await;
2796    }
2797
2798    let pool_size = bulk_channel_pool_size();
2799    let idx = BULK_CHANNEL_CURSOR.fetch_add(1, Ordering::Relaxed) % pool_size;
2800    let cache_key = bulk_cache_key(addr, idx);
2801    if let Some(channel) = cached_connection(&cache_key).await {
2802        debug!("Using cached bulk gRPC channel {} for: {}", idx, addr);
2803        return Ok(channel);
2804    }
2805    build_channel(addr, &cache_key).await
2806}
2807
2808/// Creates a new gRPC channel with optimized keepalive settings for cluster resilience.
2809///
2810/// This function is designed to detect dead peers quickly using env-configurable
2811/// internode transport settings. Defaults come from `rustfs_config` constants:
2812/// - Connect timeout: `DEFAULT_INTERNODE_CONNECT_TIMEOUT_SECS` (3s)
2813/// - TCP keepalive: `DEFAULT_INTERNODE_TCP_KEEPALIVE_SECS` (10s)
2814/// - HTTP/2 keepalive interval: `DEFAULT_INTERNODE_HTTP2_KEEPALIVE_INTERVAL_SECS` (5s)
2815/// - HTTP/2 keepalive timeout: `DEFAULT_INTERNODE_HTTP2_KEEPALIVE_TIMEOUT_SECS` (3s)
2816/// - RPC timeout: `DEFAULT_INTERNODE_RPC_TIMEOUT_SECS` (10s)
2817pub async fn create_new_channel(addr: &str) -> Result<Channel, Box<dyn Error>> {
2818    // The control channel is cached under the bare address, preserving the legacy key.
2819    build_channel(addr, addr).await
2820}
2821
2822/// Dial a new gRPC channel to `dial_addr` and cache it under `cache_key`.
2823///
2824/// `dial_addr` is the real peer URL used for the TCP/TLS connection and hostname verification;
2825/// `cache_key` is the connection-cache key. They are identical for control channels and differ
2826/// for isolated bulk channels (see [`bulk_cache_key`]), letting several physically distinct
2827/// channels to the same peer be cached independently.
2828async fn build_channel(dial_addr: &str, cache_key: &str) -> Result<Channel, Box<dyn Error>> {
2829    debug!("Creating new gRPC channel to: {} (cache key: {})", dial_addr, cache_key);
2830    let dial_started_at = Instant::now();
2831    let connect_timeout = internode_connect_timeout();
2832    let tcp_keepalive = internode_tcp_keepalive();
2833    let http2_keepalive_interval = internode_http2_keep_alive_interval();
2834    let http2_keepalive_timeout = internode_http2_keep_alive_timeout();
2835    let rpc_timeout = internode_rpc_timeout();
2836
2837    let mut connector = Endpoint::from_shared(dial_addr.to_string())?
2838        // Fast connection timeout for dead peer detection
2839        .connect_timeout(connect_timeout)
2840        .concurrency_limit(INTERNODE_RPC_CONCURRENCY_LIMIT)
2841        // TCP-level keepalive - OS will probe connection
2842        .tcp_keepalive(Some(tcp_keepalive))
2843        // Disable Nagle so latency-sensitive control-plane RPCs (locks/health) are not batched
2844        .tcp_nodelay(internode_rpc_tcp_nodelay())
2845        // HTTP/2 PING frames for application-layer health check
2846        .http2_keep_alive_interval(http2_keepalive_interval)
2847        // How long to wait for PING ACK before considering connection dead
2848        .keep_alive_timeout(http2_keepalive_timeout)
2849        // Send PINGs even when no active streams (critical for idle connections)
2850        .keep_alive_while_idle(true)
2851        // Overall timeout for any RPC - fail fast on unresponsive peers
2852        .timeout(rpc_timeout);
2853
2854    // Raise HTTP/2 flow-control windows above the 64KiB library default so larger unary
2855    // responses (ReadMultiple/BatchReadVersion) are not throttled by the BDP. Mirrors the
2856    // server-side window tuning in `rustfs/src/server/http.rs`.
2857    if let Some(stream_window) = internode_rpc_http2_stream_window() {
2858        connector = connector.initial_stream_window_size(stream_window);
2859    }
2860    if let Some(conn_window) = internode_rpc_http2_conn_window() {
2861        connector = connector.initial_connection_window_size(conn_window);
2862    }
2863
2864    let outbound_tls = runtime_sources::outbound_tls_state().await;
2865    let generation = outbound_tls.generation.0;
2866    let mut stale_generation = false;
2867    {
2868        let generation_cache = TLS_GENERATION_CACHE.lock().await;
2869        if let Some(cached_generation) = generation_cache.get(cache_key)
2870            && *cached_generation != generation
2871        {
2872            stale_generation = true;
2873        }
2874    }
2875    if dial_addr.starts_with(RUSTFS_HTTPS_PREFIX) {
2876        if let Some(cert_pem) = outbound_tls.root_ca_pem.as_ref() {
2877            let ca = Certificate::from_pem(cert_pem);
2878            // Derive the hostname from the HTTPS URL for TLS hostname verification.
2879            let domain = dial_addr
2880                .trim_start_matches(RUSTFS_HTTPS_PREFIX)
2881                .split('/')
2882                .next()
2883                .unwrap_or("")
2884                .split(':')
2885                .next()
2886                .unwrap_or("");
2887            let tls = if !domain.is_empty() {
2888                let mut cfg = ClientTlsConfig::new().ca_certificate(ca).domain_name(domain);
2889                if let Some(id) = outbound_tls.mtls_identity.as_ref() {
2890                    let identity = tonic::transport::Identity::from_pem(id.cert_pem.clone(), id.key_pem.clone());
2891                    cfg = cfg.identity(identity);
2892                }
2893                cfg
2894            } else {
2895                // Fallback: configure TLS without explicit domain if parsing fails.
2896                ClientTlsConfig::new().ca_certificate(ca)
2897            };
2898            connector = connector.tls_config(tls)?;
2899            debug!("Configured TLS with custom root certificate for: {}", dial_addr);
2900        } else {
2901            // No custom root CA published — fall back to system roots.
2902            // This is the expected path when no TLS path is configured.
2903            debug!("No custom root certificate configured; using system roots for TLS: {}", dial_addr);
2904            connector = connector.tls_config(ClientTlsConfig::new())?;
2905        }
2906    }
2907
2908    let channel = match connector.connect().await {
2909        Ok(channel) => {
2910            runtime_sources::record_grpc_dial_result(dial_started_at.elapsed(), true);
2911            // A successful dial marks the peer online (grpc-optimization P3). Keyed by the real
2912            // peer address, so control and bulk channels to one peer share health state.
2913            runtime_sources::record_peer_reachable(dial_addr);
2914            channel
2915        }
2916        Err(err) => {
2917            runtime_sources::record_grpc_dial_result(dial_started_at.elapsed(), false);
2918            runtime_sources::record_peer_unreachable(dial_addr, internode_offline_failure_threshold());
2919            return Err(err.into());
2920        }
2921    };
2922
2923    cache_connection(cache_key.to_string(), channel.clone()).await;
2924    {
2925        let mut generation_cache = TLS_GENERATION_CACHE.lock().await;
2926        enforce_tls_generation_cache_bound(&mut generation_cache, generation, cache_key);
2927        generation_cache.insert(cache_key.to_string(), generation);
2928    }
2929    if stale_generation {
2930        runtime_sources::record_stale_grpc_channel_tls_generation();
2931    }
2932
2933    debug!(
2934        "Successfully created and cached gRPC channel to: {} (cache key: {})",
2935        dial_addr, cache_key
2936    );
2937    Ok(channel)
2938}
2939
2940/// Evict a connection from the cache after a failure.
2941/// This should be called when an RPC fails to ensure fresh connections are tried.
2942pub async fn evict_failed_connection(addr: &str) {
2943    evict_failed_connection_with_log_level(addr, ConnectionEvictionLogLevel::Warn).await;
2944}
2945
2946/// Evict a connection from the cache after a failure with an explicit log level.
2947pub async fn evict_failed_connection_with_log_level(addr: &str, log_level: ConnectionEvictionLogLevel) {
2948    match log_level {
2949        ConnectionEvictionLogLevel::Warn => {
2950            warn!(
2951                addr = %addr,
2952                "Evicting cached gRPC connection after RPC failure; the next request will attempt to reconnect automatically"
2953            );
2954        }
2955        ConnectionEvictionLogLevel::Info => {
2956            info!(
2957                addr = %addr,
2958                "Evicting cached gRPC connection after RPC failure; the next request will attempt to reconnect automatically"
2959            );
2960        }
2961        ConnectionEvictionLogLevel::Debug => {
2962            debug!(
2963                addr = %addr,
2964                "Evicting cached gRPC connection after RPC failure; the next request will attempt to reconnect automatically"
2965            );
2966        }
2967    }
2968
2969    let cache_log_level = match log_level {
2970        ConnectionEvictionLogLevel::Warn => ConnectionEvictionLogLevel::Info,
2971        level => level,
2972    };
2973    evict_connection_with_log_level(addr, cache_log_level).await;
2974    TLS_GENERATION_CACHE.lock().await.remove(addr);
2975
2976    // An RPC-triggered eviction is a peer-failure signal; feed it into the online/offline state so
2977    // the peer flips offline after enough consecutive failures (grpc-optimization P3).
2978    runtime_sources::record_peer_unreachable(addr, internode_offline_failure_threshold());
2979
2980    // A peer failure typically affects every channel to that peer, so also drop any isolated
2981    // bulk channels rather than leaving a half-dead connection cached. Round-robin selection
2982    // means the caller cannot know which bulk index it hit, so evict the whole pool.
2983    if channel_isolation_enabled() {
2984        for idx in 0..bulk_channel_pool_size() {
2985            let cache_key = bulk_cache_key(addr, idx);
2986            evict_connection_with_log_level(&cache_key, cache_log_level).await;
2987            TLS_GENERATION_CACHE.lock().await.remove(&cache_key);
2988        }
2989    }
2990}
2991
2992#[cfg(test)]
2993mod tests {
2994    use super::*;
2995    use prost::Message as _;
2996    use std::sync::Mutex;
2997
2998    static INTERNODE_RPC_MSGPACK_ONLY_ENV_LOCK: Mutex<()> = Mutex::new(());
2999
3000    #[derive(Clone, PartialEq, prost::Message)]
3001    struct BackgroundHealStatusRequestV1 {}
3002
3003    #[test]
3004    fn background_heal_status_request_remains_rolling_upgrade_compatible() {
3005        let current = proto_gen::node_service::BackgroundHealStatusRequest {
3006            protocol_version: BACKGROUND_HEAL_STATUS_PROTOCOL_VERSION,
3007        };
3008        let encoded = current.encode_to_vec();
3009        BackgroundHealStatusRequestV1::decode(encoded.as_slice()).expect("v1 server should ignore the version field");
3010
3011        let encoded = BackgroundHealStatusRequestV1 {}.encode_to_vec();
3012        let decoded = proto_gen::node_service::BackgroundHealStatusRequest::decode(encoded.as_slice())
3013            .expect("v2 server should accept a v1 request");
3014        assert_eq!(decoded.protocol_version, 0);
3015    }
3016
3017    use crate::compat_manifest::{CompatPayloadField, REQUEST_COMPAT_SEND_SITES, RESPONSE_COMPAT_SEND_SITES, RequestJsonPolicy};
3018
3019    fn proto_bin_json_fields(message_suffix: &str) -> Vec<CompatPayloadField> {
3020        let proto = include_str!("node.proto");
3021        let mut fields = Vec::new();
3022        let mut current_message = None;
3023
3024        for line in proto.lines() {
3025            let line = line.trim();
3026            if let Some(rest) = line.strip_prefix("message ") {
3027                current_message = rest.split_whitespace().next();
3028                continue;
3029            }
3030            if line == "}" {
3031                current_message = None;
3032                continue;
3033            }
3034            let Some(message) = current_message else {
3035                continue;
3036            };
3037            if line.starts_with("//") || !message.ends_with(message_suffix) || !line.contains("_bin") {
3038                continue;
3039            }
3040            let Some(bin_field) = line.split_whitespace().find(|part| part.ends_with("_bin")) else {
3041                continue;
3042            };
3043            let Some(json_field) = bin_field.strip_suffix("_bin") else {
3044                continue;
3045            };
3046            fields.push(CompatPayloadField {
3047                message,
3048                json_field,
3049                bin_field,
3050            });
3051        }
3052
3053        fields.sort();
3054        fields
3055    }
3056
3057    #[test]
3058    fn request_compat_send_site_manifest_covers_node_proto_bin_fields() {
3059        let mut manifest_fields = REQUEST_COMPAT_SEND_SITES
3060            .iter()
3061            .map(|send_site| send_site.field)
3062            .collect::<Vec<_>>();
3063        manifest_fields.sort();
3064        manifest_fields.dedup();
3065
3066        assert_eq!(
3067            manifest_fields.len(),
3068            REQUEST_COMPAT_SEND_SITES.len(),
3069            "duplicate request send-site manifest entry"
3070        );
3071        assert_eq!(manifest_fields, proto_bin_json_fields("Request"));
3072    }
3073
3074    #[test]
3075    fn request_compat_send_site_manifest_pins_exact_json_policies() {
3076        let mut policies = REQUEST_COMPAT_SEND_SITES
3077            .iter()
3078            .map(|send_site| (send_site.field.message, send_site.field.json_field, send_site.policy))
3079            .collect::<Vec<_>>();
3080        policies.sort_by_key(|(message, json_field, _)| (*message, *json_field));
3081
3082        assert_eq!(
3083            policies,
3084            [
3085                (
3086                    "BatchReadVersionRequest",
3087                    "batch_read_version_req",
3088                    RequestJsonPolicy::MsgpackOnlyEligible
3089                ),
3090                ("DeleteVersionRequest", "file_info", RequestJsonPolicy::AlwaysDualWriteUntilFallbackZero,),
3091                ("DeleteVersionRequest", "opts", RequestJsonPolicy::AlwaysDualWriteUntilFallbackZero),
3092                ("DeleteVersionsRequest", "opts", RequestJsonPolicy::AlwaysDualWriteUntilFallbackZero,),
3093                ("DeleteVersionsRequest", "versions", RequestJsonPolicy::AlwaysDualWriteUntilFallbackZero,),
3094                ("ReadMultipleRequest", "read_multiple_req", RequestJsonPolicy::MsgpackOnlyEligible),
3095                ("ReadVersionRequest", "opts", RequestJsonPolicy::MsgpackOnlyEligible),
3096                ("RenameDataRequest", "file_info", RequestJsonPolicy::MsgpackOnlyEligible),
3097                ("UpdateMetadataRequest", "file_info", RequestJsonPolicy::MsgpackOnlyEligible),
3098                ("UpdateMetadataRequest", "opts", RequestJsonPolicy::MsgpackOnlyEligible),
3099                ("WriteMetadataRequest", "file_info", RequestJsonPolicy::MsgpackOnlyEligible),
3100            ]
3101        );
3102    }
3103
3104    #[test]
3105    fn response_compat_send_site_manifest_covers_node_proto_bin_fields() {
3106        let mut manifest_fields = RESPONSE_COMPAT_SEND_SITES
3107            .iter()
3108            .map(|send_site| send_site.field)
3109            .collect::<Vec<_>>();
3110        manifest_fields.sort();
3111        manifest_fields.dedup();
3112
3113        assert_eq!(
3114            manifest_fields.len(),
3115            RESPONSE_COMPAT_SEND_SITES.len(),
3116            "duplicate response send-site manifest entry"
3117        );
3118        assert_eq!(manifest_fields, proto_bin_json_fields("Response"));
3119    }
3120
3121    #[test]
3122    fn enforce_tls_generation_cache_bound_evicts_when_retained_entries_still_full() {
3123        let mut cache = HashMap::new();
3124        for i in 0..TLS_GENERATION_CACHE_MAX_SIZE {
3125            cache.insert(format!("node-{i}"), 42);
3126        }
3127
3128        enforce_tls_generation_cache_bound(&mut cache, 42, "new-node");
3129        assert_eq!(cache.len(), TLS_GENERATION_CACHE_MAX_SIZE - 1);
3130    }
3131
3132    #[tokio::test]
3133    async fn evict_failed_connection_with_log_level_removes_cached_connection() {
3134        let addr = "http://evict-failed-connection-debug-test";
3135        let channel = Endpoint::from_static("http://127.0.0.1:1").connect_lazy();
3136        cache_connection(addr.to_string(), channel).await;
3137
3138        evict_failed_connection_with_log_level(addr, ConnectionEvictionLogLevel::Debug).await;
3139
3140        assert!(!rustfs_common::has_cached_connection(addr).await);
3141    }
3142
3143    #[test]
3144    fn bulk_cache_key_is_distinct_and_cannot_collide_with_control_key() {
3145        let addr = "https://node-a:9000";
3146        let k0 = bulk_cache_key(addr, 0);
3147        let k1 = bulk_cache_key(addr, 1);
3148        assert_ne!(k0, k1);
3149        assert_ne!(k0, addr);
3150        assert!(k0.starts_with(addr));
3151        // The NUL separator cannot appear in a URL, so a bulk key never equals a real address.
3152        assert!(k0.contains('\u{0}'));
3153    }
3154
3155    #[test]
3156    fn bulk_channel_pool_size_is_at_least_one() {
3157        // Even without env configuration the pool size is clamped to a usable minimum.
3158        assert!(bulk_channel_pool_size() >= 1);
3159    }
3160
3161    #[test]
3162    fn internode_rpc_msgpack_only_reuses_cached_env_until_reset() {
3163        let _guard = INTERNODE_RPC_MSGPACK_ONLY_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
3164
3165        reset_internode_rpc_msgpack_only_cache();
3166
3167        temp_env::with_vars(
3168            [
3169                (rustfs_config::ENV_INTERNODE_RPC_MSGPACK_ONLY, Some("true")),
3170                (rustfs_config::ENV_INTERNODE_RPC_MSGPACK_ONLY_FLEET_CONFIRMED, Some("true")),
3171            ],
3172            || {
3173                assert!(internode_rpc_msgpack_only());
3174            },
3175        );
3176
3177        temp_env::with_vars(
3178            [
3179                (rustfs_config::ENV_INTERNODE_RPC_MSGPACK_ONLY, None::<&str>),
3180                (rustfs_config::ENV_INTERNODE_RPC_MSGPACK_ONLY_FLEET_CONFIRMED, None::<&str>),
3181            ],
3182            || {
3183                assert!(internode_rpc_msgpack_only(), "cached value should avoid repeated env reads");
3184                reset_internode_rpc_msgpack_only_cache();
3185                assert!(!internode_rpc_msgpack_only(), "reset must reload current env values");
3186            },
3187        );
3188
3189        reset_internode_rpc_msgpack_only_cache();
3190    }
3191
3192    #[test]
3193    fn internode_rpc_msgpack_only_requires_request_and_fleet_confirmation() {
3194        let _guard = INTERNODE_RPC_MSGPACK_ONLY_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
3195
3196        for (requested, fleet_confirmed, expected) in [
3197            (None, None, false),
3198            (Some("true"), None, false),
3199            (None, Some("true"), false),
3200            (Some("true"), Some("false"), false),
3201            (Some("false"), Some("true"), false),
3202            (Some("true"), Some("true"), true),
3203        ] {
3204            reset_internode_rpc_msgpack_only_cache();
3205            temp_env::with_vars(
3206                [
3207                    (rustfs_config::ENV_INTERNODE_RPC_MSGPACK_ONLY, requested),
3208                    (rustfs_config::ENV_INTERNODE_RPC_MSGPACK_ONLY_FLEET_CONFIRMED, fleet_confirmed),
3209                ],
3210                || {
3211                    assert_eq!(internode_rpc_msgpack_only(), expected);
3212                },
3213            );
3214        }
3215
3216        reset_internode_rpc_msgpack_only_cache();
3217    }
3218
3219    #[tokio::test]
3220    async fn get_channel_for_class_bulk_reuses_control_cache_when_isolation_disabled() {
3221        // Isolation defaults off, so a Bulk request must reuse the control channel keyed by the
3222        // bare address and must NOT create a separate bulk-keyed entry (zero behavior change).
3223        let addr = "http://get-channel-isolation-off-test";
3224        let channel = Endpoint::from_static("http://127.0.0.1:1").connect_lazy();
3225        cache_connection(addr.to_string(), channel).await;
3226
3227        let acquired = get_channel_for_class(addr, ChannelClass::Bulk).await;
3228        assert!(acquired.is_ok());
3229        assert!(!rustfs_common::has_cached_connection(&bulk_cache_key(addr, 0)).await);
3230
3231        evict_failed_connection_with_log_level(addr, ConnectionEvictionLogLevel::Debug).await;
3232    }
3233}