zebrad/components/zcashd_compat/
datadir.rs1use 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
25const DEFAULT_ZCASHD_DATADIR: &str = "zcashd-compat-zcashd";
27
28const ZCASH_CONF_FILENAME: &str = "zcash.conf";
30
31pub 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
42pub 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
50pub 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
57pub 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 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
118fn 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 let _ = fs::remove_file(&temp_path);
164 return Err(error);
165 }
166
167 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
204fn find_datadir_arg(extra_args: &[String]) -> Option<&str> {
206 find_last_path_arg_value(extra_args, "datadir")
207}
208
209pub(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
224fn find_conf_arg(extra_args: &[String]) -> Option<&str> {
226 find_last_path_arg_value(extra_args, "conf")
227}
228
229fn 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 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
284fn 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 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}