Skip to main content

zebrad/components/zcashd_compat/
datadir.rs

1//! `zcashd` datadir and config-file bootstrap for zcashd-compat mode.
2//!
3//! Spec:
4//! - Zebra may create the supervised `zcashd` datadir and a first-start config.
5//! - Zebra must never overwrite an operator-provided config file.
6//! - The datadir prepared here must match the datadir zcashd will actually use
7//!   after command-line overrides are applied.
8//! - Existing configs are audited for known zcashd-compat startup issues, then
9//!   left unchanged.
10//! - Missing configs are bootstrapped so `zcashd -zebra-compat` can start
11//!   without manual first-run setup.
12
13use std::{
14    fs::{self, OpenOptions},
15    io::{self, Write},
16    path::{Path, PathBuf},
17    time::{SystemTime, UNIX_EPOCH},
18};
19
20use color_eyre::eyre::{eyre, Report, WrapErr};
21use tracing::{info, warn};
22
23use super::Config;
24
25/// The default zcashd-compat datadir name under Zebra's state cache directory.
26const DEFAULT_ZCASHD_DATADIR: &str = "zcashd-compat-zcashd";
27
28/// The default zcashd config filename.
29const ZCASH_CONF_FILENAME: &str = "zcash.conf";
30
31/// Minimal config that lets zcashd start in zcashd-compat mode on first use.
32pub const BOOTSTRAP_ZCASH_CONF: &str = "\
33# zcashd-compat P2P sidecar: zcashd peers only with the local Zebra node.
34# Peer selection is pinned on the zcashd command line (-connect/-listen=0);
35# do not add bind=, connect=, addnode=, or listen=1 here -- these accumulate
36# and cannot be overridden by the supervisor's flags.
37";
38
39const OVERRIDDEN_P2P_BOOL_OPTIONS: &[&str] = &["listen", "p2p", "dnsseed", "listenonion"];
40const VALIDATION_ERROR_OPTIONS: &[&str] = &["bind", "whitebind", "connect", "addnode", "seednode"];
41
42/// Returns the effective datadir used by supervised `zcashd`.
43pub fn effective_zcashd_datadir(zcashd_compat: &Config, state_cache_dir: &Path) -> PathBuf {
44    zcashd_compat
45        .zcashd_datadir
46        .clone()
47        .unwrap_or_else(|| state_cache_dir.join(DEFAULT_ZCASHD_DATADIR))
48}
49
50/// Applies the last valid `-datadir=<path>` command-line override, like zcashd.
51pub fn resolve_zcashd_datadir_path(datadir: &Path, extra_args: &[String]) -> PathBuf {
52    find_datadir_arg(extra_args)
53        .map(PathBuf::from)
54        .unwrap_or_else(|| datadir.to_path_buf())
55}
56
57/// Ensures the supervised `zcashd` datadir and effective config file are ready.
58///
59/// Creates the datadir if missing, bootstraps a minimal config file if the
60/// effective `zcash.conf` is absent, and warns about existing config settings
61/// that are surprising or incompatible in zcashd-compat mode.
62///
63/// Spec: `extra_args` are passed to zcashd unchanged, so this only performs
64/// best-effort path inference for bootstrap and logs warnings for ambiguous
65/// path options.
66///
67/// # Errors
68///
69/// Returns an error if the datadir or config file cannot be created, or if an
70/// existing config file cannot be read.
71pub fn ensure_zcashd_datadir(datadir: &Path, extra_args: &[String]) -> Result<(), Report> {
72    let datadir = find_datadir_arg(extra_args)
73        .map(PathBuf::from)
74        .unwrap_or_else(|| datadir.to_path_buf());
75
76    fs::create_dir_all(&datadir)
77        .wrap_err_with(|| format!("failed to create zcashd datadir {}", datadir.display()))?;
78
79    let conf_path = resolve_zcashd_conf_path(&datadir, extra_args);
80    let parent = conf_path.parent().ok_or_else(|| {
81        eyre!(
82            "zcashd config path has no parent directory: {}",
83            conf_path.display()
84        )
85    })?;
86
87    fs::create_dir_all(parent).wrap_err_with(|| {
88        format!(
89            "failed to create zcashd config parent directory {}",
90            parent.display()
91        )
92    })?;
93
94    // Spec: once a config path exists, it is operator-owned. We only inspect it
95    // and warn about known zcashd-compat issues.
96    match fs::metadata(&conf_path) {
97        Ok(_) => {
98            audit_zcash_conf(&conf_path)?;
99            Ok(())
100        }
101        Err(error) if error.kind() == io::ErrorKind::NotFound => {
102            if write_bootstrap_zcash_conf(&conf_path)? {
103                info!(
104                    path = %conf_path.display(),
105                    "created minimal zcashd-compat zcash.conf"
106                );
107            } else {
108                audit_zcash_conf(&conf_path)?;
109            }
110
111            Ok(())
112        }
113        Err(error) => Err(error)
114            .wrap_err_with(|| format!("failed to inspect zcashd config {}", conf_path.display())),
115    }
116}
117
118/// Writes the bootstrap config without clobbering an operator-created file.
119///
120/// Returns `Ok(true)` when this process created `conf_path`, and `Ok(false)`
121/// when another process created it first.
122fn write_bootstrap_zcash_conf(conf_path: &Path) -> Result<bool, Report> {
123    let parent = conf_path.parent().ok_or_else(|| {
124        eyre!(
125            "zcashd config path has no parent directory: {}",
126            conf_path.display()
127        )
128    })?;
129
130    let temp_path = unique_temp_conf_path(parent);
131    let write_result = (|| -> Result<(), Report> {
132        let mut file = OpenOptions::new()
133            .write(true)
134            .create_new(true)
135            .open(&temp_path)
136            .wrap_err_with(|| {
137                format!(
138                    "failed to create temporary zcashd config {}",
139                    temp_path.display()
140                )
141            })?;
142
143        file.write_all(BOOTSTRAP_ZCASH_CONF.as_bytes())
144            .wrap_err_with(|| {
145                format!(
146                    "failed to write temporary zcashd config {}",
147                    temp_path.display()
148                )
149            })?;
150        file.sync_all().wrap_err_with(|| {
151            format!(
152                "failed to sync temporary zcashd config {}",
153                temp_path.display()
154            )
155        })?;
156
157        Ok(())
158    })();
159
160    if let Err(error) = write_result {
161        // Spec: a failed bootstrap write must not leave a partial `zcash.conf`
162        // that later runs mistake for an operator config.
163        let _ = fs::remove_file(&temp_path);
164        return Err(error);
165    }
166
167    // Spec: publish the fully-written temp file with no-overwrite semantics.
168    // `hard_link` fails with AlreadyExists if an operator or competing Zebra
169    // process won the race to create the config.
170    match fs::hard_link(&temp_path, conf_path) {
171        Ok(()) => {
172            fs::remove_file(&temp_path).wrap_err_with(|| {
173                format!(
174                    "failed to remove temporary zcashd config {}",
175                    temp_path.display()
176                )
177            })?;
178            Ok(true)
179        }
180        Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
181            fs::remove_file(&temp_path).wrap_err_with(|| {
182                format!(
183                    "failed to remove temporary zcashd config {}",
184                    temp_path.display()
185                )
186            })?;
187
188            Ok(false)
189        }
190        Err(error) => Err(error)
191            .wrap_err_with(|| format!("failed to create zcashd config {}", conf_path.display())),
192    }
193}
194
195fn unique_temp_conf_path(parent: &Path) -> PathBuf {
196    let nanos = SystemTime::now()
197        .duration_since(UNIX_EPOCH)
198        .map(|duration| duration.as_nanos())
199        .unwrap_or_default();
200
201    parent.join(format!(".zcash.conf.tmp.{}.{}", std::process::id(), nanos))
202}
203
204/// Extracts the last valid `-datadir=<path>` value from Zebra's extra args.
205fn find_datadir_arg(extra_args: &[String]) -> Option<&str> {
206    find_last_path_arg_value(extra_args, "datadir")
207}
208
209/// Resolves the last valid `-conf=<path>` we can infer from extra args.
210///
211/// Relative config paths are anchored under the selected datadir.
212pub(super) fn resolve_zcashd_conf_path(datadir: &Path, extra_args: &[String]) -> PathBuf {
213    let conf_path = find_conf_arg(extra_args)
214        .map(PathBuf::from)
215        .unwrap_or_else(|| PathBuf::from(ZCASH_CONF_FILENAME));
216
217    if conf_path.is_absolute() {
218        conf_path
219    } else {
220        datadir.join(conf_path)
221    }
222}
223
224/// Extracts the last valid `-conf=<path>` value from Zebra's extra args.
225fn find_conf_arg(extra_args: &[String]) -> Option<&str> {
226    find_last_path_arg_value(extra_args, "conf")
227}
228
229/// Extracts the last valid `-<name>=<path>` value from Zebra's extra args.
230///
231/// zcashd takes the *last* occurrence of a single-valued command-line option,
232/// so bootstrap and preflight must resolve duplicates the same way, or Zebra
233/// would prepare one path while zcashd starts in another.
234fn find_last_path_arg_value<'a>(extra_args: &'a [String], name: &str) -> Option<&'a str> {
235    let mut value_arg = None;
236    let short_equals = format!("-{name}=");
237    let long_equals = format!("--{name}=");
238    let short = format!("-{name}");
239    let long = format!("--{name}");
240
241    for arg in extra_args {
242        if arg == &short || arg == &long {
243            warn!(
244                option = %arg,
245                "zcashd-compat cannot infer a path from paired zcashd_extra_args; leaving argument unchanged for zcashd"
246            );
247            continue;
248        }
249
250        if let Some(value) = arg.strip_prefix(&short_equals) {
251            record_path_arg_value(name, value, &mut value_arg);
252            continue;
253        }
254
255        if let Some(value) = arg.strip_prefix(&long_equals) {
256            record_path_arg_value(name, value, &mut value_arg);
257        }
258    }
259
260    value_arg
261}
262
263fn record_path_arg_value<'a>(name: &str, value: &'a str, value_arg: &mut Option<&'a str>) {
264    if value.is_empty() {
265        warn!(
266            option = %format!("-{name}"),
267            "zcashd-compat cannot infer a path from an empty zcashd_extra_args value"
268        );
269        return;
270    }
271
272    if value_arg.is_some() {
273        // Matches zcashd's own behavior for repeated single-valued options.
274        warn!(
275            option = %format!("-{name}"),
276            "zcashd-compat found multiple path values in zcashd_extra_args; \
277             using the last value, like zcashd"
278        );
279    }
280
281    *value_arg = Some(value);
282}
283
284/// Audits existing configs without modifying them.
285///
286/// Spec: warnings are intentionally non-fatal here. Some options are forced off
287/// by supervisor CLI args, and others are left for zcashd startup validation to
288/// reject with its native error message.
289fn audit_zcash_conf(conf_path: &Path) -> Result<(), Report> {
290    let contents = fs::read_to_string(conf_path)
291        .wrap_err_with(|| format!("failed to read zcashd config {}", conf_path.display()))?;
292
293    let entries = parse_zcash_conf_entries(&contents);
294
295    for (key, value) in entries {
296        if OVERRIDDEN_P2P_BOOL_OPTIONS.contains(&key.as_str()) && is_truthy(&value) {
297            warn!(
298                path = %conf_path.display(),
299                option = %key,
300                value = %value,
301                "zcashd-compat config enables a P2P option that is overridden by supervisor CLI arguments; startup will continue with zcashd P2P disabled"
302            );
303        }
304
305        if VALIDATION_ERROR_OPTIONS.contains(&key.as_str()) && !value.is_empty() {
306            warn!(
307                path = %conf_path.display(),
308                option = %key,
309                value = %value,
310                "zcashd-compat config contains a P2P peer option incompatible with -zebra-compat; zcashd startup validation may reject this config"
311            );
312        }
313    }
314
315    Ok(())
316}
317
318fn parse_zcash_conf_entries(contents: &str) -> Vec<(String, String)> {
319    contents
320        .lines()
321        .filter_map(|line| {
322            let line = line.split('#').next().unwrap_or_default().trim();
323
324            if line.is_empty() {
325                return None;
326            }
327
328            let (key, value) = line.split_once('=').unwrap_or((line, ""));
329
330            Some((key.trim().to_ascii_lowercase(), value.trim().to_string()))
331        })
332        .collect()
333}
334
335fn is_truthy(value: &str) -> bool {
336    matches!(
337        value.trim().to_ascii_lowercase().as_str(),
338        "1" | "true" | "yes" | "on"
339    )
340}
341
342#[cfg(test)]
343mod tests {
344    use std::{
345        fs, io,
346        path::PathBuf,
347        sync::{Arc, Mutex},
348    };
349
350    use tempfile::TempDir;
351    use tracing::Dispatch;
352    use tracing_subscriber::fmt::MakeWriter;
353
354    use super::{
355        audit_zcash_conf, ensure_zcashd_datadir, resolve_zcashd_conf_path, BOOTSTRAP_ZCASH_CONF,
356    };
357
358    #[derive(Clone)]
359    struct CapturedLogs(Arc<Mutex<Vec<u8>>>);
360
361    struct CapturedLogWriter(Arc<Mutex<Vec<u8>>>);
362
363    impl io::Write for CapturedLogWriter {
364        fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
365            self.0
366                .lock()
367                .map_err(|_| io::Error::other("log buffer lock should not be poisoned"))?
368                .extend_from_slice(buf);
369            Ok(buf.len())
370        }
371
372        fn flush(&mut self) -> io::Result<()> {
373            Ok(())
374        }
375    }
376
377    impl<'writer> MakeWriter<'writer> for CapturedLogs {
378        type Writer = CapturedLogWriter;
379
380        fn make_writer(&'writer self) -> Self::Writer {
381            CapturedLogWriter(Arc::clone(&self.0))
382        }
383    }
384
385    fn capture_logs(function: impl FnOnce()) -> String {
386        let logs = Arc::new(Mutex::new(Vec::new()));
387        let subscriber = tracing_subscriber::fmt()
388            .with_writer(CapturedLogs(Arc::clone(&logs)))
389            .with_ansi(false)
390            .without_time()
391            .finish();
392
393        tracing::dispatcher::with_default(&Dispatch::new(subscriber), function);
394
395        let logs = logs
396            .lock()
397            .expect("log buffer lock should not be poisoned")
398            .clone();
399        String::from_utf8(logs).expect("logs should be valid UTF-8")
400    }
401
402    #[test]
403    fn creates_datadir_and_default_conf() {
404        let temp_dir = TempDir::new().expect("tempdir should be created");
405        let datadir = temp_dir.path().join("missing-zcashd-datadir");
406
407        ensure_zcashd_datadir(&datadir, &[]).expect("datadir should be ensured");
408
409        let conf_path = datadir.join("zcash.conf");
410        assert!(datadir.is_dir());
411        assert_eq!(
412            fs::read_to_string(conf_path).expect("bootstrap config should be readable"),
413            BOOTSTRAP_ZCASH_CONF
414        );
415    }
416
417    #[test]
418    fn does_not_overwrite_existing_conf() {
419        let temp_dir = TempDir::new().expect("tempdir should be created");
420        let datadir = temp_dir.path().join("zcashd-datadir");
421        fs::create_dir_all(&datadir).expect("datadir should be created");
422
423        let conf_path = datadir.join("zcash.conf");
424        let original_conf = "listen=1\n";
425        fs::write(&conf_path, original_conf).expect("config should be written");
426
427        ensure_zcashd_datadir(&datadir, &[]).expect("existing config should be accepted");
428
429        assert_eq!(
430            fs::read_to_string(conf_path).expect("config should be readable"),
431            original_conf
432        );
433    }
434
435    #[test]
436    fn bootstraps_custom_conf_path() {
437        let temp_dir = TempDir::new().expect("tempdir should be created");
438        let datadir = temp_dir.path().join("zcashd-datadir");
439        let extra_args = vec!["-conf=alt.conf".to_string()];
440
441        ensure_zcashd_datadir(&datadir, &extra_args).expect("custom config should be created");
442
443        let conf_path = datadir.join("alt.conf");
444        assert_eq!(
445            fs::read_to_string(conf_path).expect("bootstrap config should be readable"),
446            BOOTSTRAP_ZCASH_CONF
447        );
448    }
449
450    #[test]
451    fn bootstraps_absolute_conf_path() {
452        let temp_dir = TempDir::new().expect("tempdir should be created");
453        let datadir = temp_dir.path().join("zcashd-datadir");
454        let conf_path = temp_dir.path().join("custom").join("zcash.conf");
455        let extra_args = vec![format!("-conf={}", conf_path.display())];
456
457        ensure_zcashd_datadir(&datadir, &extra_args).expect("absolute config should be created");
458
459        assert_eq!(
460            fs::read_to_string(conf_path).expect("bootstrap config should be readable"),
461            BOOTSTRAP_ZCASH_CONF
462        );
463    }
464
465    #[test]
466    fn resolves_last_conf_arg_and_warns_on_duplicate() {
467        let datadir = PathBuf::from("/zcashd-datadir");
468        let extra_args = vec!["-conf=old.conf".to_string(), "--conf=new.conf".to_string()];
469
470        let logs = capture_logs(|| {
471            // zcashd takes the last occurrence of a single-valued option.
472            assert_eq!(
473                resolve_zcashd_conf_path(&datadir, &extra_args),
474                datadir.join("new.conf")
475            );
476        });
477
478        assert!(logs.contains("multiple path values"));
479    }
480
481    #[test]
482    fn ignores_paired_conf_arg_and_warns() {
483        let datadir = PathBuf::from("/zcashd-datadir");
484        let extra_args = vec!["-conf".to_string(), "custom.conf".to_string()];
485
486        let logs = capture_logs(|| {
487            assert_eq!(
488                resolve_zcashd_conf_path(&datadir, &extra_args),
489                datadir.join("zcash.conf")
490            );
491        });
492
493        assert!(logs.contains("paired zcashd_extra_args"));
494    }
495
496    #[test]
497    fn ignores_empty_conf_arg_and_warns() {
498        let datadir = PathBuf::from("/zcashd-datadir");
499        let extra_args = vec!["-conf=".to_string()];
500
501        let logs = capture_logs(|| {
502            assert_eq!(
503                resolve_zcashd_conf_path(&datadir, &extra_args),
504                datadir.join("zcash.conf")
505            );
506        });
507
508        assert!(logs.contains("empty zcashd_extra_args value"));
509    }
510
511    #[test]
512    fn bootstraps_datadir_extra_arg_override() {
513        let temp_dir = TempDir::new().expect("tempdir should be created");
514        let datadir = temp_dir.path().join("zcashd-datadir");
515        let override_datadir = temp_dir.path().join("operator-datadir");
516        let extra_args = vec![format!("-datadir={}", override_datadir.display())];
517
518        ensure_zcashd_datadir(&datadir, &extra_args).expect("datadir override should be prepared");
519
520        assert!(
521            !datadir.exists(),
522            "bootstrap should not prepare the overridden default datadir"
523        );
524        assert_eq!(
525            fs::read_to_string(override_datadir.join("zcash.conf"))
526                .expect("override config should be readable"),
527            BOOTSTRAP_ZCASH_CONF
528        );
529    }
530
531    #[test]
532    fn ignores_paired_datadir_extra_arg_override_and_warns() {
533        let temp_dir = TempDir::new().expect("tempdir should be created");
534        let datadir = temp_dir.path().join("zcashd-datadir");
535        let override_datadir = temp_dir.path().join("operator-datadir");
536        let extra_args = vec![
537            "--datadir".to_string(),
538            override_datadir.to_string_lossy().to_string(),
539        ];
540
541        let logs = capture_logs(|| {
542            ensure_zcashd_datadir(&datadir, &extra_args)
543                .expect("paired datadir override warning should be non-fatal");
544        });
545
546        assert_eq!(
547            fs::read_to_string(datadir.join("zcash.conf"))
548                .expect("default config should be readable"),
549            BOOTSTRAP_ZCASH_CONF
550        );
551        assert!(
552            !override_datadir.exists(),
553            "paired datadir should not be used for bootstrap inference"
554        );
555        assert!(logs.contains("paired zcashd_extra_args"));
556    }
557
558    #[test]
559    fn bootstraps_conf_relative_to_datadir_extra_arg_override() {
560        let temp_dir = TempDir::new().expect("tempdir should be created");
561        let datadir = temp_dir.path().join("zcashd-datadir");
562        let override_datadir = temp_dir.path().join("operator-datadir");
563        let extra_args = vec![
564            format!("--datadir={}", override_datadir.display()),
565            "-conf=custom.conf".to_string(),
566        ];
567
568        ensure_zcashd_datadir(&datadir, &extra_args).expect("datadir override should be prepared");
569
570        assert!(
571            !datadir.exists(),
572            "bootstrap should not prepare the overridden default datadir"
573        );
574        assert_eq!(
575            fs::read_to_string(override_datadir.join("custom.conf"))
576                .expect("override config should be readable"),
577            BOOTSTRAP_ZCASH_CONF
578        );
579    }
580
581    #[test]
582    fn warns_on_listen_but_continues() {
583        let temp_dir = TempDir::new().expect("tempdir should be created");
584        let conf_path = temp_dir.path().join("zcash.conf");
585        fs::write(&conf_path, "listen=1\n").expect("config should be written");
586
587        let logs = capture_logs(|| {
588            audit_zcash_conf(&conf_path).expect("config audit should continue");
589        });
590
591        assert!(logs.contains("listen"));
592        assert!(logs.contains("overridden by supervisor CLI arguments"));
593    }
594
595    #[test]
596    fn warns_on_bind() {
597        let temp_dir = TempDir::new().expect("tempdir should be created");
598        let conf_path = temp_dir.path().join("zcash.conf");
599        fs::write(&conf_path, "bind=127.0.0.1\n").expect("config should be written");
600
601        let logs = capture_logs(|| {
602            audit_zcash_conf(&conf_path).expect("config audit should continue");
603        });
604
605        assert!(logs.contains("bind"));
606        assert!(logs.contains("incompatible with -zebra-compat"));
607    }
608}