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