1use std::fs::File;
16use std::io::Read;
17use std::path::{Component, Path, PathBuf};
18
19use sha2::{Digest, Sha256};
20use toml::value::Table;
21
22use crate::env::bare::WalConfig;
23#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
25pub(crate) struct DottedPath(Vec<String>);
26
27impl DottedPath {
28 fn new(parts: &[&str]) -> Self {
29 Self(parts.iter().map(|part| (*part).to_string()).collect())
30 }
31
32 fn parts(&self) -> impl Iterator<Item = &str> {
33 self.0.iter().map(String::as_str)
34 }
35}
36
37impl std::fmt::Display for DottedPath {
38 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39 f.write_str(&self.0.join("."))
40 }
41}
42
43#[derive(Debug, Clone)]
45pub(crate) struct DatanodeProtectionPolicy {
46 protected_paths: Vec<DottedPath>,
47}
48
49impl DatanodeProtectionPolicy {
50 pub(crate) fn for_wal(wal: &WalConfig) -> Self {
52 let mut protected_paths = vec![
53 DottedPath::new(&["mode"]),
54 DottedPath::new(&["node_id"]),
55 DottedPath::new(&["storage", "data_home"]),
56 DottedPath::new(&["meta_client_options", "metasrv_addrs"]),
57 DottedPath::new(&["wal", "provider"]),
58 ];
59 protected_paths.push(match wal {
60 WalConfig::RaftEngine => DottedPath::new(&["wal", "dir"]),
61 WalConfig::Kafka { .. } => DottedPath::new(&["wal", "broker_endpoints"]),
62 });
63 protected_paths.sort();
64
65 Self { protected_paths }
66 }
67}
68
69#[derive(Debug, Clone)]
71pub(crate) struct DatanodeOverlay {
72 source: PathBuf,
73 value: toml::Value,
74 profile_key: [u8; 32],
75 profile_id: String,
76}
77
78impl DatanodeOverlay {
79 pub(crate) fn load(case_dir: &Path, relative_ref: &Path) -> Result<Self, String> {
84 validate_relative_reference(relative_ref)?;
85
86 let canonical_case_dir = case_dir.canonicalize().map_err(|error| {
87 format!(
88 "Failed to canonicalize compatibility case directory {}: {error}",
89 case_dir.display()
90 )
91 })?;
92 let requested_path = case_dir.join(relative_ref);
93 let canonical_target = requested_path.canonicalize().map_err(|error| {
94 format!(
95 "Failed to resolve datanode overlay {} relative to case directory {}: {error}",
96 relative_ref.display(),
97 case_dir.display()
98 )
99 })?;
100
101 if !canonical_target.starts_with(&canonical_case_dir) {
102 return Err(format!(
103 "Datanode overlay {} resolves outside compatibility case directory {}",
104 relative_ref.display(),
105 canonical_case_dir.display()
106 ));
107 }
108
109 let mut file = File::open(&canonical_target).map_err(|error| {
110 format!(
111 "Failed to open datanode overlay {}: {error}",
112 canonical_target.display()
113 )
114 })?;
115 let metadata = file.metadata().map_err(|error| {
116 format!(
117 "Failed to inspect datanode overlay {}: {error}",
118 canonical_target.display()
119 )
120 })?;
121 if !metadata.is_file() {
122 return Err(format!(
123 "Datanode overlay {} must be a regular file",
124 canonical_target.display()
125 ));
126 }
127
128 let mut content = String::new();
129 file.read_to_string(&mut content).map_err(|error| {
130 format!(
131 "Failed to read datanode overlay {}: {error}",
132 canonical_target.display()
133 )
134 })?;
135 let value: toml::Value = toml::from_str(&content).map_err(|error| {
136 format!(
137 "Failed to parse datanode overlay {}: {error}",
138 canonical_target.display()
139 )
140 })?;
141 if !value.is_table() {
142 return Err(format!(
143 "Datanode overlay {} must have a TOML table at its root",
144 canonical_target.display()
145 ));
146 }
147
148 let profile_key = semantic_profile_key(&value);
149 let profile_id = hex::encode(profile_key)[..12].to_string();
150
151 Ok(Self {
152 source: canonical_target,
153 value,
154 profile_key,
155 profile_id,
156 })
157 }
158
159 pub(crate) fn prepare(
161 self,
162 protection: &DatanodeProtectionPolicy,
163 ) -> Result<PreparedDatanodeOverlay, String> {
164 let mut touched_protected_paths = Vec::new();
165 for path in &protection.protected_paths {
166 validate_protected_ancestors(&self.value, path).map_err(|error| {
167 format!(
168 "Datanode overlay {} (profile {}): {error}",
169 self.source.display(),
170 self.profile_id
171 )
172 })?;
173 if value_at_path(&self.value, path).is_some() {
174 touched_protected_paths.push(path.clone());
175 }
176 }
177 touched_protected_paths.sort();
178
179 Ok(PreparedDatanodeOverlay {
180 source: self.source,
181 value: self.value,
182 profile_key: self.profile_key,
183 profile_id: self.profile_id,
184 protected_paths: protection.protected_paths.clone(),
185 touched_protected_paths,
186 })
187 }
188}
189
190#[derive(Debug, Clone)]
192pub(crate) struct PreparedDatanodeOverlay {
193 source: PathBuf,
194 value: toml::Value,
195 profile_key: [u8; 32],
196 profile_id: String,
197 protected_paths: Vec<DottedPath>,
198 touched_protected_paths: Vec<DottedPath>,
199}
200
201impl PreparedDatanodeOverlay {
202 pub(crate) fn profile_key(&self) -> &[u8; 32] {
204 &self.profile_key
205 }
206
207 pub(crate) fn profile_id(&self) -> &str {
209 &self.profile_id
210 }
211
212 pub(crate) fn source(&self) -> &Path {
214 &self.source
215 }
216
217 pub(crate) fn touched_protected_paths(&self) -> &[DottedPath] {
219 &self.touched_protected_paths
220 }
221
222 pub(crate) fn apply_to_rendered_baseline(&self, baseline: &str) -> Result<String, String> {
224 let baseline_value: toml::Value = toml::from_str(baseline)
225 .map_err(|error| format!("Failed to parse rendered datanode baseline: {error}"))?;
226 if !baseline_value.is_table() {
227 return Err(
228 "Rendered datanode baseline must have a TOML table at its root".to_string(),
229 );
230 }
231
232 let mut merged = baseline_value.clone();
233 merge_toml_values(&mut merged, &self.value);
234
235 for path in &self.protected_paths {
236 restore_protected_path(&mut merged, &baseline_value, path)?;
237 }
238
239 toml::to_string(&merged)
240 .map_err(|error| format!("Failed to serialize merged datanode configuration: {error}"))
241 }
242}
243
244fn validate_relative_reference(relative_ref: &Path) -> Result<(), String> {
245 if relative_ref.as_os_str().is_empty()
246 || relative_ref
247 .components()
248 .any(|component| !matches!(component, Component::Normal(_)))
249 {
250 return Err(format!(
251 "Datanode overlay reference {} must be a non-empty relative path with normal components only",
252 relative_ref.display()
253 ));
254 }
255 Ok(())
256}
257
258fn validate_protected_ancestors(value: &toml::Value, path: &DottedPath) -> Result<(), String> {
259 let mut current = value;
260 let parts: Vec<_> = path.parts().collect();
261 for (index, part) in parts.iter().enumerate().take(parts.len().saturating_sub(1)) {
262 let Some(table) = current.as_table() else {
263 return Err(format!(
264 "Datanode overlay makes ancestor {} of protected path {} non-table",
265 parts[..index].join("."),
266 path
267 ));
268 };
269 let Some(next) = table.get(*part) else {
270 return Ok(());
271 };
272 if !next.is_table() {
273 return Err(format!(
274 "Datanode overlay makes ancestor {} of protected path {} non-table",
275 parts[..=index].join("."),
276 path
277 ));
278 }
279 current = next;
280 }
281 Ok(())
282}
283
284fn value_at_path<'a>(value: &'a toml::Value, path: &DottedPath) -> Option<&'a toml::Value> {
285 let mut current = value;
286 for part in path.parts() {
287 current = current.as_table()?.get(part)?;
288 }
289 Some(current)
290}
291
292fn merge_toml_values(baseline: &mut toml::Value, overlay: &toml::Value) {
293 match (baseline, overlay) {
294 (toml::Value::Table(baseline), toml::Value::Table(overlay)) => {
295 for (key, overlay_value) in overlay {
296 match baseline.get_mut(key) {
297 Some(baseline_value) => merge_toml_values(baseline_value, overlay_value),
298 None => {
299 baseline.insert(key.clone(), overlay_value.clone());
300 }
301 }
302 }
303 }
304 (baseline, overlay) => *baseline = overlay.clone(),
305 }
306}
307
308fn restore_protected_path(
309 merged: &mut toml::Value,
310 baseline: &toml::Value,
311 path: &DottedPath,
312) -> Result<(), String> {
313 let Some(merged_table) = merged.as_table_mut() else {
314 return Err("Merged datanode configuration must have a TOML table at its root".to_string());
315 };
316 let baseline_value = value_at_path(baseline, path).cloned();
317 restore_path_in_table(merged_table, &path.0, baseline_value);
318 Ok(())
319}
320
321fn restore_path_in_table(table: &mut Table, parts: &[String], baseline_value: Option<toml::Value>) {
322 let Some((part, remaining)) = parts.split_first() else {
323 return;
324 };
325 if remaining.is_empty() {
326 match baseline_value {
327 Some(value) => {
328 table.insert(part.clone(), value);
329 }
330 None => {
331 table.remove(part);
332 }
333 }
334 return;
335 }
336
337 let Some(value) = table.get_mut(part) else {
338 return;
339 };
340 if let Some(table) = value.as_table_mut() {
341 restore_path_in_table(table, remaining, baseline_value);
342 }
343}
344
345fn semantic_profile_key(value: &toml::Value) -> [u8; 32] {
346 let mut encoded = Vec::new();
347 encode_semantic_value(&mut encoded, value);
348 Sha256::digest(encoded).into()
349}
350
351fn encode_semantic_value(output: &mut Vec<u8>, value: &toml::Value) {
352 match value {
353 toml::Value::String(value) => encode_bytes(output, b"string", value.as_bytes()),
354 toml::Value::Integer(value) => {
355 encode_bytes(output, b"integer", &value.to_be_bytes());
356 }
357 toml::Value::Float(value) => {
358 encode_bytes(output, b"float", &value.to_bits().to_be_bytes());
359 }
360 toml::Value::Boolean(value) => {
361 encode_bytes(output, b"boolean", &[u8::from(*value)]);
362 }
363 toml::Value::Datetime(value) => {
364 encode_bytes(output, b"datetime", value.to_string().as_bytes())
365 }
366 toml::Value::Array(values) => {
367 let mut payload = Vec::new();
368 encode_length(&mut payload, values.len());
369 for value in values {
370 encode_semantic_value(&mut payload, value);
371 }
372 encode_bytes(output, b"array", &payload);
373 }
374 toml::Value::Table(table) => {
375 let mut payload = Vec::new();
376 encode_length(&mut payload, table.len());
377 let mut entries: Vec<_> = table.iter().collect();
378 entries.sort_unstable_by(|(left, _), (right, _)| left.as_bytes().cmp(right.as_bytes()));
379 for (key, value) in entries {
380 encode_bytes(&mut payload, b"key", key.as_bytes());
381 encode_semantic_value(&mut payload, value);
382 }
383 encode_bytes(output, b"table", &payload);
384 }
385 }
386}
387
388fn encode_bytes(output: &mut Vec<u8>, tag: &[u8], value: &[u8]) {
389 encode_length(output, tag.len());
390 output.extend_from_slice(tag);
391 encode_length(output, value.len());
392 output.extend_from_slice(value);
393}
394
395fn encode_length(output: &mut Vec<u8>, length: usize) {
396 output.extend_from_slice(&(length as u64).to_be_bytes());
397}
398
399#[cfg(test)]
400mod tests {
401 use super::*;
402
403 fn load_overlay(content: &str) -> DatanodeOverlay {
404 let temp_dir = tempfile::tempdir().unwrap();
405 let path = temp_dir.path().join("overlay.toml");
406 std::fs::write(&path, content).unwrap();
407 let overlay = DatanodeOverlay::load(temp_dir.path(), Path::new("overlay.toml")).unwrap();
408 overlay
410 }
411
412 fn prepared_overlay(content: &str, wal: &WalConfig) -> PreparedDatanodeOverlay {
413 load_overlay(content)
414 .prepare(&DatanodeProtectionPolicy::for_wal(wal))
415 .unwrap()
416 }
417
418 #[test]
419 fn merges_recursive_tables_and_replaces_non_tables_atomically() {
420 let prepared = prepared_overlay(
421 r#"
422 scalar = "replacement"
423 primitive_array = [3, 2, 1]
424 array_of_tables = [{ name = "new" }]
425 [nested]
426 new_key = true
427 old_key = "replacement"
428 "#,
429 &WalConfig::RaftEngine,
430 );
431 let merged = prepared
432 .apply_to_rendered_baseline(
433 r#"
434 scalar = 1
435 primitive_array = [1, 2]
436 array_of_tables = [{ name = "old" }, { name = "older" }]
437 untouched = "kept"
438 [nested]
439 old_key = 1
440 retained = "kept"
441 "#,
442 )
443 .unwrap();
444 let value: toml::Value = toml::from_str(&merged).unwrap();
445
446 assert_eq!(value["scalar"].as_str(), Some("replacement"));
447 assert_eq!(value["primitive_array"].as_array().unwrap().len(), 3);
448 assert_eq!(value["array_of_tables"].as_array().unwrap().len(), 1);
449 assert_eq!(value["nested"]["old_key"].as_str(), Some("replacement"));
450 assert_eq!(value["nested"]["new_key"].as_bool(), Some(true));
451 assert_eq!(value["nested"]["retained"].as_str(), Some("kept"));
452 assert_eq!(value["untouched"].as_str(), Some("kept"));
453 }
454
455 #[test]
456 fn restores_protected_paths_and_deletes_absent_raft_wal_dir() {
457 let prepared = prepared_overlay(
458 r#"
459 mode = "standalone"
460 node_id = 99
461 [storage]
462 data_home = "/overlay/data"
463 [meta_client_options]
464 metasrv_addrs = ["overlay:3002"]
465 [wal]
466 provider = "kafka"
467 dir = "/overlay/wal"
468 tuning = 42
469 "#,
470 &WalConfig::RaftEngine,
471 );
472 let merged = prepared
473 .apply_to_rendered_baseline(
474 r#"
475 mode = "distributed"
476 node_id = 1
477 [storage]
478 data_home = "/runner/data"
479 [meta_client_options]
480 metasrv_addrs = ["runner:3002"]
481 [wal]
482 provider = "raft_engine"
483 "#,
484 )
485 .unwrap();
486 let value: toml::Value = toml::from_str(&merged).unwrap();
487
488 assert_eq!(value["mode"].as_str(), Some("distributed"));
489 assert_eq!(value["node_id"].as_integer(), Some(1));
490 assert_eq!(value["storage"]["data_home"].as_str(), Some("/runner/data"));
491 assert_eq!(
492 value["meta_client_options"]["metasrv_addrs"][0].as_str(),
493 Some("runner:3002")
494 );
495 assert_eq!(value["wal"]["provider"].as_str(), Some("raft_engine"));
496 assert!(value["wal"].get("dir").is_none());
497 assert_eq!(value["wal"]["tuning"].as_integer(), Some(42));
498 }
499
500 #[test]
501 fn kafka_policy_restores_broker_endpoints() {
502 let prepared = prepared_overlay(
503 r#"
504 [wal]
505 broker_endpoints = ["overlay:9092"]
506 provider = "raft_engine"
507 "#,
508 &WalConfig::Kafka {
509 needs_kafka_cluster: false,
510 broker_endpoints: vec![],
511 },
512 );
513 let merged = prepared
514 .apply_to_rendered_baseline(
515 r#"
516 [wal]
517 provider = "kafka"
518 broker_endpoints = ["runner:9092"]
519 "#,
520 )
521 .unwrap();
522 let value: toml::Value = toml::from_str(&merged).unwrap();
523
524 assert_eq!(value["wal"]["provider"].as_str(), Some("kafka"));
525 assert_eq!(
526 value["wal"]["broker_endpoints"][0].as_str(),
527 Some("runner:9092")
528 );
529 }
530
531 #[test]
532 fn rejects_non_table_protected_ancestor() {
533 let overlay = load_overlay("wal = \"not a table\"");
534 let error = overlay
535 .prepare(&DatanodeProtectionPolicy::for_wal(&WalConfig::RaftEngine))
536 .unwrap_err();
537
538 assert!(error.contains("ancestor wal"));
539 assert!(error.contains("profile"));
540 assert!(error.contains("wal.dir") || error.contains("wal.provider"));
541 }
542
543 #[test]
544 fn collects_sorted_touched_protected_paths() {
545 let prepared = prepared_overlay(
546 r#"
547 node_id = 4
548 [wal]
549 dir = "/overlay/wal"
550 provider = "kafka"
551 "#,
552 &WalConfig::RaftEngine,
553 );
554
555 let paths: Vec<_> = prepared
556 .touched_protected_paths()
557 .iter()
558 .map(ToString::to_string)
559 .collect();
560 assert_eq!(paths, ["node_id", "wal.dir", "wal.provider"]);
561 }
562
563 #[test]
564 fn semantic_identity_ignores_formatting_and_table_order() {
565 let first = load_overlay("[settings]\nb = 2\na = \"value\"\n");
566 let second = load_overlay("# comment\n[settings]\na = \"value\"\nb = 2\n");
567
568 assert_eq!(first.profile_key, second.profile_key);
569 assert_eq!(first.profile_id, second.profile_id);
570 }
571
572 #[test]
573 fn semantic_identity_preserves_scalar_types_and_array_order() {
574 let integer = load_overlay("value = 1");
575 let float = load_overlay("value = 1.0");
576 let first_order = load_overlay("values = [1, 2]");
577 let second_order = load_overlay("values = [2, 1]");
578
579 assert_ne!(integer.profile_key, float.profile_key);
580 assert_ne!(first_order.profile_key, second_order.profile_key);
581 }
582
583 #[test]
584 fn prepared_overlay_exposes_profile_and_source_for_runner_grouping() {
585 let overlay = load_overlay("value = 1");
586 let expected_key = overlay.profile_key;
587 let expected_id = overlay.profile_id.clone();
588 let prepared = overlay
589 .prepare(&DatanodeProtectionPolicy::for_wal(&WalConfig::RaftEngine))
590 .unwrap();
591
592 assert_eq!(prepared.profile_key(), &expected_key);
593 assert_eq!(prepared.profile_id(), expected_id);
594 assert!(prepared.source().ends_with("overlay.toml"));
595 }
596
597 #[test]
598 fn rejects_absolute_and_traversal_references() {
599 let temp_dir = tempfile::tempdir().unwrap();
600 let absolute = temp_dir.path().join("overlay.toml");
601 std::fs::write(&absolute, "value = 1").unwrap();
602
603 assert!(DatanodeOverlay::load(temp_dir.path(), &absolute).is_err());
604 assert!(DatanodeOverlay::load(temp_dir.path(), Path::new("../overlay.toml")).is_err());
605 assert!(DatanodeOverlay::load(temp_dir.path(), Path::new("./overlay.toml")).is_err());
606 }
607
608 #[test]
609 fn rejects_directories_and_parse_errors() {
610 let temp_dir = tempfile::tempdir().unwrap();
611 std::fs::create_dir(temp_dir.path().join("directory.toml")).unwrap();
612 std::fs::write(temp_dir.path().join("broken.toml"), "[broken").unwrap();
613
614 let directory_error =
615 DatanodeOverlay::load(temp_dir.path(), Path::new("directory.toml")).unwrap_err();
616 assert!(directory_error.contains("regular file"));
617 let parse_error =
618 DatanodeOverlay::load(temp_dir.path(), Path::new("broken.toml")).unwrap_err();
619 assert!(parse_error.contains("Failed to parse datanode overlay"));
620 assert!(parse_error.contains("broken.toml"));
621 }
622
623 #[cfg(unix)]
624 #[test]
625 fn rejects_symlink_escape() {
626 use std::os::unix::fs::symlink;
627
628 let case_dir = tempfile::tempdir().unwrap();
629 let outside_dir = tempfile::tempdir().unwrap();
630 let outside_file = outside_dir.path().join("outside.toml");
631 std::fs::write(&outside_file, "value = 1").unwrap();
632 symlink(&outside_file, case_dir.path().join("escape.toml")).unwrap();
633
634 let error = DatanodeOverlay::load(case_dir.path(), Path::new("escape.toml")).unwrap_err();
635 assert!(error.contains("outside compatibility case directory"));
636 }
637}