Skip to main content

sqlness_runner/cmd/
datanode_overlay.rs

1// Copyright 2023 Greptime 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
15use 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/// A dotted TOML path owned by the compatibility runner.
24#[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/// Runner-owned datanode configuration paths that an overlay cannot override.
44#[derive(Debug, Clone)]
45pub(crate) struct DatanodeProtectionPolicy {
46    protected_paths: Vec<DottedPath>,
47}
48
49impl DatanodeProtectionPolicy {
50    /// Builds the policy for the runner-selected WAL configuration.
51    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/// A parsed old-stage datanode sidecar, loaded from a confined case directory.
70#[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    /// Loads and parses a datanode sidecar referenced relative to `case_dir`.
80    ///
81    /// This is intended to prevent accidental case-directory escapes; it is not
82    /// an adversarial concurrent-filesystem security boundary.
83    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        // Check the type before opening so the error is identical on every
110        // platform: `std::fs::metadata` succeeds on directories on both Unix
111        // and Windows, whereas `File::open` on a directory fails up front on
112        // Windows (with a platform-specific message).
113        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    /// Validates runner-owned path conflicts and records overridden protected paths.
165    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/// A validated datanode overlay ready for application to a rendered baseline.
196#[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    /// Returns the canonical full SHA-256 profile key used for grouping.
208    pub(crate) fn profile_key(&self) -> &[u8; 32] {
209        &self.profile_key
210    }
211
212    /// Returns a truncated profile ID suitable only for diagnostics.
213    pub(crate) fn profile_id(&self) -> &str {
214        &self.profile_id
215    }
216
217    /// Returns the canonical sidecar path used for diagnostics.
218    pub(crate) fn source(&self) -> &Path {
219        &self.source
220    }
221
222    /// Returns protected paths declared by the sidecar, in dotted-path order.
223    pub(crate) fn touched_protected_paths(&self) -> &[DottedPath] {
224        &self.touched_protected_paths
225    }
226
227    /// Merges the sidecar into a rendered baseline and restores runner-owned fields.
228    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        // `load` owns the parsed TOML, so the temporary source need not outlive it.
414        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}