Skip to main content

zebrad/components/zcashd_compat/
preflight.rs

1//! Linux hardware preflight checks for zcashd-compat mode.
2
3#[cfg(target_os = "linux")]
4use std::{
5    collections::{BTreeMap, HashMap},
6    fs, io,
7    num::NonZeroUsize,
8    path::{Path, PathBuf},
9    thread::available_parallelism,
10};
11
12#[cfg(target_os = "linux")]
13use color_eyre::eyre::{eyre, Report};
14#[cfg(target_os = "linux")]
15use nix::unistd::{access, AccessFlags};
16#[cfg(target_os = "linux")]
17use std::os::unix::fs::MetadataExt;
18#[cfg(target_os = "linux")]
19use tracing::warn;
20#[cfg(target_os = "linux")]
21use zebra_chain::parameters::{Network, NetworkKind};
22
23#[cfg(target_os = "linux")]
24use super::{
25    datadir::resolve_zcashd_conf_path,
26    effective_zcashd_datadir, effective_zcashd_source, ensure_zcashd_datadir,
27    is_command_resolvable,
28    managed::{cached_managed_zcashd_binary_is_present, managed_zcashd_binary_path},
29    resolve_zcashd_datadir_path, ZcashdBinarySource,
30};
31use crate::config::ZebradConfig;
32
33#[cfg(target_os = "linux")]
34const GIB: u64 = 1024 * 1024 * 1024;
35#[cfg(target_os = "linux")]
36const TIB: u64 = 1024 * GIB;
37
38#[cfg(target_os = "linux")]
39const MIN_CPU_LOGICAL: usize = 4;
40#[cfg(target_os = "linux")]
41const RECOMMENDED_CPU_LOGICAL: usize = 8;
42
43#[cfg(target_os = "linux")]
44const MIN_RAM_BYTES: u64 = 16 * GIB;
45#[cfg(target_os = "linux")]
46const RECOMMENDED_RAM_BYTES: u64 = 32 * GIB;
47
48#[cfg(target_os = "linux")]
49const MAINNET_MIN_ZEBRA_PROVISIONED_BYTES: u64 = 275 * GIB;
50#[cfg(target_os = "linux")]
51const MAINNET_MIN_ZCASHD_PROVISIONED_BYTES: u64 = 275 * GIB;
52#[cfg(target_os = "linux")]
53const MAINNET_RECOMMENDED_COMBINED_TOTAL_BYTES: u64 = TIB;
54
55#[cfg(target_os = "linux")]
56const TESTNET_MIN_ZEBRA_PROVISIONED_BYTES: u64 = 30 * GIB;
57#[cfg(target_os = "linux")]
58const TESTNET_MIN_ZCASHD_PROVISIONED_BYTES: u64 = 30 * GIB;
59#[cfg(target_os = "linux")]
60const TESTNET_RECOMMENDED_COMBINED_TOTAL_BYTES: u64 = 100 * GIB;
61
62/// Runs zcashd-compat hardware preflight checks.
63///
64/// On Linux, checks CPU, effective memory and mount-aware disk provisioning.
65/// On non-Linux, startup fails unless `unsafe_low_specs` is explicitly set.
66pub fn run_preflight(
67    config: &ZebradConfig,
68    unsafe_low_specs: bool,
69) -> Result<(), color_eyre::eyre::Report> {
70    #[cfg(target_os = "linux")]
71    {
72        run_linux_preflight(config, unsafe_low_specs)
73    }
74
75    #[cfg(not(target_os = "linux"))]
76    {
77        let _ = (config, unsafe_low_specs);
78        let message = "zcashd-compat mode is supported on Linux only";
79
80        if unsafe_low_specs {
81            tracing::warn!(
82                "{message}. continuing because --unsafe-low-specs was explicitly provided"
83            );
84            Ok(())
85        } else {
86            Err(color_eyre::eyre::eyre!(message))
87        }
88    }
89}
90
91#[cfg(target_os = "linux")]
92#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)]
93enum DiskRole {
94    ZebraState,
95    ZcashdData,
96}
97
98#[cfg(target_os = "linux")]
99impl DiskRole {
100    fn label(self) -> &'static str {
101        match self {
102            DiskRole::ZebraState => "zebra state",
103            DiskRole::ZcashdData => "zcashd datadir",
104        }
105    }
106}
107
108#[cfg(target_os = "linux")]
109#[derive(Clone, Debug)]
110struct PathRequirement {
111    role: DiskRole,
112    target_path: PathBuf,
113    min_provisioned_bytes: u64,
114}
115
116#[cfg(target_os = "linux")]
117#[derive(Copy, Clone, Debug, Eq, PartialEq)]
118struct DiskThresholds {
119    min_zebra_bytes: u64,
120    min_zcashd_bytes: u64,
121    recommended_combined_bytes: u64,
122}
123
124#[cfg(target_os = "linux")]
125#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)]
126enum PermissionRole {
127    ZebraState,
128    ZcashdDatadir,
129    ZcashdConf,
130    ManagedZcashdCache,
131}
132
133#[cfg(target_os = "linux")]
134impl PermissionRole {
135    fn label(self) -> &'static str {
136        match self {
137            PermissionRole::ZebraState => "zebra state directory",
138            PermissionRole::ZcashdDatadir => "zcashd datadir",
139            PermissionRole::ZcashdConf => "zcashd config directory",
140            PermissionRole::ManagedZcashdCache => "embedded zcashd cache directory",
141        }
142    }
143}
144
145#[cfg(target_os = "linux")]
146#[derive(Clone, Debug)]
147struct WriteRequirement {
148    role: PermissionRole,
149    target_path: PathBuf,
150}
151
152#[cfg(target_os = "linux")]
153#[derive(Clone, Debug, Default)]
154struct PermissionRequirements {
155    roles: Vec<PermissionRole>,
156    target_paths: Vec<PathBuf>,
157}
158
159#[cfg(target_os = "linux")]
160#[derive(Clone, Debug, Default)]
161struct FilesystemRequirements {
162    roles: Vec<DiskRole>,
163    target_paths: Vec<PathBuf>,
164    min_provisioned_sum_bytes: u64,
165    provisioned_bytes: u64,
166}
167
168#[cfg(target_os = "linux")]
169#[derive(Clone, Debug, Default)]
170struct PreflightSummary {
171    errors: Vec<String>,
172    warnings: Vec<String>,
173}
174
175#[cfg(target_os = "linux")]
176fn run_linux_preflight(config: &ZebradConfig, unsafe_low_specs: bool) -> Result<(), Report> {
177    let mut zcashd_datadir =
178        effective_zcashd_datadir(&config.zcashd_compat, &config.state.cache_dir);
179
180    if config.zcashd_compat.manage_zcashd {
181        zcashd_datadir =
182            resolve_zcashd_datadir_path(&zcashd_datadir, &config.zcashd_compat.zcashd_extra_args);
183    }
184
185    // Only check zcashd disk capacity against a datadir Zebra actually knows:
186    // the resolved managed datadir, or an explicitly configured external one.
187    // An unmanaged sidecar without `zcashd_datadir` set can keep its data on a
188    // filesystem (or host) Zebra never uses.
189    let zcashd_datadir_for_disk_check = (config.zcashd_compat.manage_zcashd
190        || config.zcashd_compat.zcashd_datadir.is_some())
191    .then_some(zcashd_datadir.as_path());
192
193    let mut summary = PreflightSummary::default();
194
195    // A check that errors at the I/O level (unreadable cgroup file, statvfs
196    // failure on a network mount, a datadir path Zebra cannot probe) must be
197    // overridable by `--unsafe-low-specs`, just like a failed minimum. Collect
198    // these errors into the summary instead of propagating them, so the bypass
199    // flag can downgrade them to warnings.
200    let check_results = [
201        check_permissions(&mut summary, config, &zcashd_datadir),
202        check_cpu(&mut summary),
203        check_memory(&mut summary),
204        check_disk(
205            &mut summary,
206            &config.network.network,
207            &config.state.cache_dir,
208            zcashd_datadir_for_disk_check,
209        ),
210    ];
211    for error in check_results.into_iter().filter_map(Result::err) {
212        summary
213            .errors
214            .push(format!("preflight check could not run: {error}"));
215    }
216
217    for warning in finalize_preflight(summary, unsafe_low_specs)? {
218        warn!("{warning}");
219    }
220
221    // Filesystem bootstrap happens only after preflight has reported every
222    // blocking issue, unless the operator explicitly bypassed the checks.
223    if config.zcashd_compat.manage_zcashd {
224        ensure_zcashd_datadir(&zcashd_datadir, &config.zcashd_compat.zcashd_extra_args)?;
225    }
226
227    Ok(())
228}
229
230#[cfg(target_os = "linux")]
231fn disk_thresholds(network: &Network) -> DiskThresholds {
232    match network.kind() {
233        NetworkKind::Mainnet => DiskThresholds {
234            min_zebra_bytes: MAINNET_MIN_ZEBRA_PROVISIONED_BYTES,
235            min_zcashd_bytes: MAINNET_MIN_ZCASHD_PROVISIONED_BYTES,
236            recommended_combined_bytes: MAINNET_RECOMMENDED_COMBINED_TOTAL_BYTES,
237        },
238        NetworkKind::Testnet | NetworkKind::Regtest => DiskThresholds {
239            min_zebra_bytes: TESTNET_MIN_ZEBRA_PROVISIONED_BYTES,
240            min_zcashd_bytes: TESTNET_MIN_ZCASHD_PROVISIONED_BYTES,
241            recommended_combined_bytes: TESTNET_RECOMMENDED_COMBINED_TOTAL_BYTES,
242        },
243    }
244}
245
246#[cfg(target_os = "linux")]
247fn finalize_preflight(
248    mut summary: PreflightSummary,
249    unsafe_low_specs: bool,
250) -> Result<Vec<String>, Report> {
251    if !summary.errors.is_empty() {
252        if unsafe_low_specs {
253            summary
254                .warnings
255                .extend(summary.errors.into_iter().map(|error| {
256                    format!(
257                        "{error}. continuing because --unsafe-low-specs was explicitly provided"
258                    )
259                }));
260        } else {
261            return Err(eyre!(
262                "zcashd-compat preflight failed:\n- {}",
263                summary.errors.join("\n- ")
264            ));
265        }
266    }
267
268    Ok(summary.warnings)
269}
270
271#[cfg(target_os = "linux")]
272fn check_permissions(
273    summary: &mut PreflightSummary,
274    config: &ZebradConfig,
275    zcashd_datadir: &Path,
276) -> Result<(), Report> {
277    let mut requirements = vec![WriteRequirement {
278        role: PermissionRole::ZebraState,
279        target_path: config.state.cache_dir.clone(),
280    }];
281
282    if config.zcashd_compat.manage_zcashd {
283        requirements.push(WriteRequirement {
284            role: PermissionRole::ZcashdDatadir,
285            target_path: zcashd_datadir.to_path_buf(),
286        });
287
288        let conf_path =
289            resolve_zcashd_conf_path(zcashd_datadir, &config.zcashd_compat.zcashd_extra_args);
290        check_conf_access(summary, &mut requirements, &conf_path)?;
291        check_zcashd_binary(
292            summary,
293            &mut requirements,
294            &config.zcashd_compat,
295            &config.state.cache_dir,
296        );
297    }
298
299    check_write_requirements(summary, &requirements)
300}
301
302#[cfg(target_os = "linux")]
303fn check_conf_access(
304    summary: &mut PreflightSummary,
305    requirements: &mut Vec<WriteRequirement>,
306    conf_path: &Path,
307) -> Result<(), Report> {
308    let metadata = match fs::symlink_metadata(conf_path) {
309        Ok(metadata) => {
310            if metadata.file_type().is_symlink() {
311                match fs::metadata(conf_path) {
312                    Ok(target_metadata) => target_metadata,
313                    Err(error) if error.kind() == io::ErrorKind::NotFound => {
314                        summary.errors.push(format!(
315                            "zcashd config {} is a symlink to a missing target",
316                            conf_path.display()
317                        ));
318                        return Ok(());
319                    }
320                    Err(error) => {
321                        summary.errors.push(format!(
322                            "failed to inspect zcashd config symlink target {}: {error}",
323                            conf_path.display()
324                        ));
325                        return Ok(());
326                    }
327                }
328            } else {
329                metadata
330            }
331        }
332        Err(error) if error.kind() == io::ErrorKind::NotFound => {
333            let parent = conf_path.parent().ok_or_else(|| {
334                eyre!(
335                    "zcashd config path has no parent directory: {}",
336                    conf_path.display()
337                )
338            })?;
339
340            requirements.push(WriteRequirement {
341                role: PermissionRole::ZcashdConf,
342                target_path: parent.to_path_buf(),
343            });
344
345            return Ok(());
346        }
347        Err(error) => {
348            summary.errors.push(format!(
349                "failed to inspect zcashd config {}: {error}",
350                conf_path.display()
351            ));
352            return Ok(());
353        }
354    };
355
356    if metadata.is_dir() {
357        summary.errors.push(format!(
358            "zcashd config path {} is a directory, expected a file",
359            conf_path.display()
360        ));
361        return Ok(());
362    }
363
364    if access(conf_path, AccessFlags::R_OK).is_err() {
365        summary.errors.push(format!(
366            "zcashd config {} exists but is not readable by the current user",
367            conf_path.display()
368        ));
369    }
370
371    Ok(())
372}
373
374#[cfg(target_os = "linux")]
375fn check_zcashd_binary(
376    summary: &mut PreflightSummary,
377    requirements: &mut Vec<WriteRequirement>,
378    zcashd_compat_config: &super::Config,
379    state_cache_dir: &Path,
380) {
381    match effective_zcashd_source(zcashd_compat_config) {
382        Ok(ZcashdBinarySource::Path(path)) => {
383            if !is_command_resolvable(&path) {
384                summary.errors.push(format!(
385                    "zcashd binary {} does not exist or is not executable by the current user",
386                    path.display()
387                ));
388            }
389        }
390        Ok(ZcashdBinarySource::Embedded) => {
391            let Some(binary_path) = managed_zcashd_binary_path(state_cache_dir) else {
392                return;
393            };
394            let cache_is_current =
395                cached_managed_zcashd_binary_is_present(state_cache_dir).unwrap_or(false);
396
397            if binary_path.exists() && !is_command_resolvable(&binary_path) {
398                summary.errors.push(format!(
399                    "zcashd binary {} does not exist or is not executable by the current user",
400                    binary_path.display()
401                ));
402            }
403
404            if !cache_is_current {
405                let Some(parent) = binary_path.parent() else {
406                    return;
407                };
408
409                requirements.push(WriteRequirement {
410                    role: PermissionRole::ManagedZcashdCache,
411                    target_path: parent.to_path_buf(),
412                });
413            }
414        }
415        Err(error) => summary.errors.push(error.to_string()),
416    }
417}
418
419#[cfg(target_os = "linux")]
420fn check_write_requirements(
421    summary: &mut PreflightSummary,
422    requirements: &[WriteRequirement],
423) -> Result<(), Report> {
424    let mut grouped = BTreeMap::<PathBuf, PermissionRequirements>::new();
425
426    for requirement in requirements {
427        let probed_path = nearest_existing_ancestor(&requirement.target_path)?;
428        let entry = grouped.entry(probed_path).or_default();
429
430        if !entry.roles.contains(&requirement.role) {
431            entry.roles.push(requirement.role);
432        }
433
434        if !entry.target_paths.contains(&requirement.target_path) {
435            entry.target_paths.push(requirement.target_path.clone());
436        }
437    }
438
439    for (probed_path, requirements) in grouped {
440        let metadata = fs::metadata(&probed_path).map_err(|error| {
441            eyre!(
442                "failed to read metadata for {}: {error}",
443                probed_path.display()
444            )
445        })?;
446
447        if !metadata.is_dir() {
448            summary.errors.push(format!(
449                "{} (paths: {}) requires a directory at {}, which exists but is not a directory",
450                permission_role_labels(&requirements.roles),
451                display_paths(&requirements.target_paths),
452                probed_path.display()
453            ));
454            continue;
455        }
456
457        if path_is_writable_dir(&probed_path) {
458            continue;
459        }
460
461        if requirements
462            .target_paths
463            .iter()
464            .all(|target_path| target_path == &probed_path)
465        {
466            summary.errors.push(format!(
467                "{} (paths: {}) is not writable by the current user",
468                permission_role_labels(&requirements.roles),
469                display_paths(&requirements.target_paths)
470            ));
471        } else {
472            summary.errors.push(format!(
473                "{} (paths: {}) cannot be created: nearest existing ancestor {} is not writable by the current user",
474                permission_role_labels(&requirements.roles),
475                display_paths(&requirements.target_paths),
476                probed_path.display()
477            ));
478        }
479    }
480
481    Ok(())
482}
483
484#[cfg(target_os = "linux")]
485fn path_is_writable_dir(path: &Path) -> bool {
486    access(path, AccessFlags::W_OK | AccessFlags::X_OK).is_ok()
487}
488
489#[cfg(target_os = "linux")]
490fn check_cpu(summary: &mut PreflightSummary) -> Result<(), Report> {
491    let cpu_count = available_parallelism()
492        .map(NonZeroUsize::get)
493        .map_err(|error| eyre!("failed to read available logical CPU count: {error}"))?;
494
495    if cpu_count < MIN_CPU_LOGICAL {
496        summary.errors.push(format!(
497            "detected {cpu_count} logical CPUs, minimum required is {MIN_CPU_LOGICAL}"
498        ));
499    } else if cpu_count < RECOMMENDED_CPU_LOGICAL {
500        summary.warnings.push(format!(
501            "detected {cpu_count} logical CPUs, recommended is {RECOMMENDED_CPU_LOGICAL}"
502        ));
503    }
504
505    Ok(())
506}
507
508#[cfg(target_os = "linux")]
509fn check_memory(summary: &mut PreflightSummary) -> Result<(), Report> {
510    let mem_total = meminfo_total_bytes()?;
511    let cgroup_limit = cgroup_memory_limit_bytes()?;
512    let effective_memory = cgroup_limit.map_or(mem_total, |limit| limit.min(mem_total));
513
514    if effective_memory < MIN_RAM_BYTES {
515        summary.errors.push(format!(
516            "detected effective memory {}, minimum required is {}",
517            human_gib(effective_memory),
518            human_gib(MIN_RAM_BYTES)
519        ));
520    } else if effective_memory < RECOMMENDED_RAM_BYTES {
521        summary.warnings.push(format!(
522            "detected effective memory {}, recommended is {}",
523            human_gib(effective_memory),
524            human_gib(RECOMMENDED_RAM_BYTES)
525        ));
526    }
527
528    Ok(())
529}
530
531#[cfg(target_os = "linux")]
532fn check_disk(
533    summary: &mut PreflightSummary,
534    network: &Network,
535    zebra_cache_dir: &Path,
536    zcashd_datadir: Option<&Path>,
537) -> Result<(), Report> {
538    let thresholds = disk_thresholds(network);
539    let mut requirements = vec![PathRequirement {
540        role: DiskRole::ZebraState,
541        target_path: zebra_cache_dir.to_path_buf(),
542        min_provisioned_bytes: thresholds.min_zebra_bytes,
543    }];
544
545    // In externally managed mode without a configured `zcashd_datadir`, the
546    // sidecar's datadir location is unknown to Zebra (it can be on another
547    // host or filesystem), so there is no path to check capacity against.
548    if let Some(zcashd_datadir) = zcashd_datadir {
549        requirements.push(PathRequirement {
550            role: DiskRole::ZcashdData,
551            target_path: zcashd_datadir.to_path_buf(),
552            min_provisioned_bytes: thresholds.min_zcashd_bytes,
553        });
554    }
555
556    let grouped_filesystems = grouped_requirements_by_filesystem(&requirements)?;
557    evaluate_disk_thresholds(
558        summary,
559        &grouped_filesystems,
560        thresholds.recommended_combined_bytes,
561    );
562
563    Ok(())
564}
565
566#[cfg(target_os = "linux")]
567fn evaluate_disk_thresholds(
568    summary: &mut PreflightSummary,
569    grouped_filesystems: &HashMap<u64, FilesystemRequirements>,
570    recommended_combined_bytes: u64,
571) {
572    // Saturate rather than risk a debug-build overflow panic on a broken
573    // statvfs that reports a near-`u64::MAX` capacity, matching the per-entry
574    // accumulation elsewhere in this module.
575    let combined_provisioned_capacity = grouped_filesystems
576        .values()
577        .map(|filesystem| filesystem.provisioned_bytes)
578        .fold(0u64, u64::saturating_add);
579
580    for filesystem in grouped_filesystems.values() {
581        if filesystem.provisioned_bytes < filesystem.min_provisioned_sum_bytes {
582            summary.errors.push(format!(
583                "{} mount (paths: {}) has provisioned capacity {}, minimum required is {}",
584                role_labels(&filesystem.roles),
585                display_paths(&filesystem.target_paths),
586                human_gib(filesystem.provisioned_bytes),
587                human_gib(filesystem.min_provisioned_sum_bytes),
588            ));
589        }
590    }
591
592    if combined_provisioned_capacity < recommended_combined_bytes {
593        summary.warnings.push(format!(
594            "combined zcashd-compat filesystem capacity is {}, recommended is {}",
595            human_gib(combined_provisioned_capacity),
596            human_gib(recommended_combined_bytes)
597        ));
598    }
599}
600
601#[cfg(target_os = "linux")]
602fn grouped_requirements_by_filesystem(
603    requirements: &[PathRequirement],
604) -> Result<HashMap<u64, FilesystemRequirements>, Report> {
605    let mut grouped = HashMap::new();
606
607    for requirement in requirements {
608        let probed_path = nearest_existing_ancestor(&requirement.target_path)?;
609        let metadata = fs::metadata(&probed_path).map_err(|error| {
610            eyre!(
611                "failed to read metadata for {}: {error}",
612                probed_path.display()
613            )
614        })?;
615        let device_id = metadata.dev();
616        let provisioned_bytes = statvfs_provisioned_bytes(&probed_path)?;
617
618        let entry = grouped
619            .entry(device_id)
620            .or_insert_with(|| FilesystemRequirements {
621                provisioned_bytes,
622                ..FilesystemRequirements::default()
623            });
624
625        if !entry.roles.contains(&requirement.role) {
626            entry.roles.push(requirement.role);
627        }
628        entry.target_paths.push(requirement.target_path.clone());
629        entry.min_provisioned_sum_bytes = entry
630            .min_provisioned_sum_bytes
631            .saturating_add(requirement.min_provisioned_bytes);
632    }
633
634    Ok(grouped)
635}
636
637#[cfg(target_os = "linux")]
638fn nearest_existing_ancestor(path: &Path) -> Result<PathBuf, Report> {
639    let mut current = path.to_path_buf();
640
641    loop {
642        if current.exists() {
643            return Ok(current);
644        }
645
646        if let Some(parent) = current.parent() {
647            current = parent.to_path_buf();
648            continue;
649        }
650
651        return Err(eyre!(
652            "no existing ancestor path found for {}",
653            path.display()
654        ));
655    }
656}
657
658#[cfg(target_os = "linux")]
659fn statvfs_provisioned_bytes(path: &Path) -> Result<u64, Report> {
660    let stats = nix::sys::statvfs::statvfs(path).map_err(|error| {
661        eyre!(
662            "failed to get filesystem stats for {}: {error}",
663            path.display()
664        )
665    })?;
666
667    let fragment_size = stats.fragment_size();
668
669    Ok(stats.blocks().saturating_mul(fragment_size))
670}
671
672#[cfg(target_os = "linux")]
673fn meminfo_total_bytes() -> Result<u64, Report> {
674    let meminfo = fs::read_to_string("/proc/meminfo")
675        .map_err(|error| eyre!("failed to read /proc/meminfo: {error}"))?;
676    let mem_total_kib = meminfo
677        .lines()
678        .find_map(|line| line.strip_prefix("MemTotal:"))
679        .and_then(|line| line.split_whitespace().next())
680        .ok_or_else(|| eyre!("MemTotal field missing in /proc/meminfo"))?
681        .parse::<u64>()
682        .map_err(|error| eyre!("failed to parse MemTotal from /proc/meminfo: {error}"))?;
683
684    Ok(mem_total_kib.saturating_mul(1024))
685}
686
687#[cfg(target_os = "linux")]
688/// Returns the effective cgroup memory limit for the current process, in bytes.
689///
690/// Specification:
691/// - Read `/proc/self/cgroup` and extract:
692///   - the cgroup v2 relative path (`0::...`) when present, and
693///   - the cgroup v1 `memory` controller relative path (`...:memory:...`) when present.
694/// - Probe candidate limit files in this order:
695///   - v2: process-specific `/sys/fs/cgroup/<path>/memory.max`, then root fallback
696///     `/sys/fs/cgroup/memory.max`;
697///   - v1: process-specific
698///     `/sys/fs/cgroup/memory/<path>/memory.limit_in_bytes`, then root fallback
699///     `/sys/fs/cgroup/memory/memory.limit_in_bytes`.
700/// - Missing files are treated as unavailable and skipped.
701/// - Unlimited values (`max` in v2, very large sentinel in v1) are treated as `None`.
702/// - If both v1 and v2 limits are available, return the tighter (`min`) limit.
703/// - If only one limit is available, return that limit; if neither is available, return `None`.
704fn cgroup_memory_limit_bytes() -> Result<Option<u64>, Report> {
705    let self_cgroup = fs::read_to_string("/proc/self/cgroup")
706        .map_err(|error| eyre!("failed to read /proc/self/cgroup: {error}"))?;
707    let (v2_relative_path, v1_memory_relative_path) = parse_self_cgroup_paths(&self_cgroup);
708
709    let v2_limit = parse_first_cgroup_limit(
710        &v2_relative_path
711            .iter()
712            .map(|relative_path| {
713                cgroup_limit_path(Path::new("/sys/fs/cgroup"), relative_path, "memory.max")
714            })
715            .chain(std::iter::once(PathBuf::from("/sys/fs/cgroup/memory.max")))
716            .collect::<Vec<_>>(),
717    )?;
718
719    let v1_limit = parse_first_cgroup_limit(
720        &v1_memory_relative_path
721            .iter()
722            .map(|relative_path| {
723                cgroup_limit_path(
724                    Path::new("/sys/fs/cgroup/memory"),
725                    relative_path,
726                    "memory.limit_in_bytes",
727                )
728            })
729            .chain(std::iter::once(PathBuf::from(
730                "/sys/fs/cgroup/memory/memory.limit_in_bytes",
731            )))
732            .collect::<Vec<_>>(),
733    )?;
734
735    Ok(select_cgroup_memory_limit(v2_limit, v1_limit))
736}
737
738#[cfg(target_os = "linux")]
739fn parse_self_cgroup_paths(self_cgroup: &str) -> (Option<String>, Option<String>) {
740    let mut v2_relative_path = None;
741    let mut v1_memory_relative_path = None;
742
743    for line in self_cgroup.lines() {
744        let mut fields = line.splitn(3, ':');
745        let _hierarchy_id = fields.next();
746        let controllers = fields.next();
747        let relative_path = fields.next();
748
749        let (Some(controllers), Some(relative_path)) = (controllers, relative_path) else {
750            continue;
751        };
752
753        if controllers.is_empty() {
754            v2_relative_path = Some(relative_path.to_string());
755        } else if controllers
756            .split(',')
757            .any(|controller| controller == "memory")
758        {
759            v1_memory_relative_path = Some(relative_path.to_string());
760        }
761    }
762
763    (v2_relative_path, v1_memory_relative_path)
764}
765
766#[cfg(target_os = "linux")]
767fn cgroup_limit_path(base_path: &Path, relative_path: &str, file_name: &str) -> PathBuf {
768    let normalized_relative_path = relative_path.trim_start_matches('/');
769
770    if normalized_relative_path.is_empty() {
771        base_path.join(file_name)
772    } else {
773        base_path.join(normalized_relative_path).join(file_name)
774    }
775}
776
777#[cfg(target_os = "linux")]
778fn parse_first_cgroup_limit(candidate_paths: &[PathBuf]) -> Result<Option<u64>, Report> {
779    for path in candidate_paths {
780        if let Some(limit) = parse_cgroup_limit(path)? {
781            return Ok(Some(limit));
782        }
783    }
784
785    Ok(None)
786}
787
788#[cfg(target_os = "linux")]
789fn parse_cgroup_limit(path: &Path) -> Result<Option<u64>, Report> {
790    let raw_limit = match fs::read_to_string(path) {
791        Ok(raw_limit) => raw_limit,
792        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
793        Err(error) => return Err(eyre!("failed to read {}: {error}", path.display())),
794    };
795
796    let trimmed = raw_limit.trim();
797    if trimmed.eq_ignore_ascii_case("max") {
798        return Ok(None);
799    }
800
801    let parsed_limit = trimmed.parse::<u64>().map_err(|error| {
802        eyre!(
803            "failed to parse cgroup memory limit from {}: {error}",
804            path.display()
805        )
806    })?;
807
808    // cgroup v1 can report very large sentinel values for "unlimited".
809    if parsed_limit >= 0x7fff_ffff_ffff_f000 {
810        return Ok(None);
811    }
812
813    Ok(Some(parsed_limit))
814}
815
816#[cfg(target_os = "linux")]
817fn select_cgroup_memory_limit(v2_limit: Option<u64>, v1_limit: Option<u64>) -> Option<u64> {
818    match (v2_limit, v1_limit) {
819        (Some(v2), Some(v1)) => Some(v2.min(v1)),
820        (Some(v2), None) => Some(v2),
821        (None, Some(v1)) => Some(v1),
822        (None, None) => None,
823    }
824}
825
826#[cfg(target_os = "linux")]
827fn role_labels(roles: &[DiskRole]) -> String {
828    roles
829        .iter()
830        .map(|role| role.label())
831        .collect::<Vec<_>>()
832        .join(" + ")
833}
834
835#[cfg(target_os = "linux")]
836fn permission_role_labels(roles: &[PermissionRole]) -> String {
837    roles
838        .iter()
839        .map(|role| role.label())
840        .collect::<Vec<_>>()
841        .join(" + ")
842}
843
844#[cfg(target_os = "linux")]
845fn display_paths(paths: &[PathBuf]) -> String {
846    paths
847        .iter()
848        .map(|path| path.display().to_string())
849        .collect::<Vec<_>>()
850        .join(", ")
851}
852
853#[cfg(target_os = "linux")]
854fn human_gib(bytes: u64) -> String {
855    // Cast is safe: these are display-only values, and f64 represents all
856    // disk/memory sizes below 2^53 bytes (8 PiB) exactly enough for one
857    // decimal place of GiB.
858    format!("{:.1} GiB", bytes as f64 / GIB as f64)
859}
860
861#[cfg(test)]
862mod tests {
863    #[cfg(target_os = "linux")]
864    use super::*;
865    #[cfg(target_os = "linux")]
866    use std::{
867        fs as std_fs,
868        os::unix::fs::{symlink, PermissionsExt},
869        path::Path,
870        process::Command,
871    };
872    #[cfg(target_os = "linux")]
873    use tempfile::TempDir;
874
875    #[cfg(target_os = "linux")]
876    use crate::components::zcashd_compat::{zcashd_target_triple, ConfigZcashdBinarySource};
877
878    #[cfg(target_os = "linux")]
879    #[test]
880    fn disk_thresholds_mainnet_returns_existing_limits() {
881        assert_eq!(
882            disk_thresholds(&Network::Mainnet),
883            DiskThresholds {
884                min_zebra_bytes: 275 * GIB,
885                min_zcashd_bytes: 275 * GIB,
886                recommended_combined_bytes: TIB,
887            }
888        );
889    }
890
891    #[cfg(target_os = "linux")]
892    #[test]
893    fn disk_thresholds_testnet_returns_lower_limits() {
894        assert_eq!(
895            disk_thresholds(&Network::new_default_testnet()),
896            DiskThresholds {
897                min_zebra_bytes: 30 * GIB,
898                min_zcashd_bytes: 30 * GIB,
899                recommended_combined_bytes: 100 * GIB,
900            }
901        );
902    }
903
904    #[cfg(target_os = "linux")]
905    #[test]
906    fn merges_provisioned_requirement_when_paths_share_filesystem() {
907        let requirements = vec![
908            PathRequirement {
909                role: DiskRole::ZebraState,
910                target_path: PathBuf::from("/tmp"),
911                min_provisioned_bytes: MAINNET_MIN_ZEBRA_PROVISIONED_BYTES,
912            },
913            PathRequirement {
914                role: DiskRole::ZcashdData,
915                target_path: PathBuf::from("/tmp"),
916                min_provisioned_bytes: MAINNET_MIN_ZCASHD_PROVISIONED_BYTES,
917            },
918        ];
919
920        let grouped = grouped_requirements_by_filesystem(&requirements)
921            .expect("filesystem grouping should succeed");
922        let filesystem = grouped.values().next().expect("group should not be empty");
923
924        assert_eq!(
925            filesystem.min_provisioned_sum_bytes,
926            MAINNET_MIN_ZEBRA_PROVISIONED_BYTES + MAINNET_MIN_ZCASHD_PROVISIONED_BYTES
927        );
928    }
929
930    #[cfg(target_os = "linux")]
931    #[test]
932    fn parses_cgroup_max_as_unlimited() {
933        assert_eq!(parse_cgroup_value("max").expect("valid cgroup value"), None);
934    }
935
936    #[cfg(target_os = "linux")]
937    #[test]
938    fn parses_cgroup_numeric_value() {
939        assert_eq!(
940            parse_cgroup_value("17179869184").expect("valid cgroup value"),
941            Some(17_179_869_184)
942        );
943    }
944
945    #[cfg(target_os = "linux")]
946    #[test]
947    fn prefers_v1_when_v2_is_unavailable() {
948        assert_eq!(select_cgroup_memory_limit(None, Some(16)), Some(16));
949    }
950
951    #[cfg(target_os = "linux")]
952    #[test]
953    fn chooses_tighter_limit_when_both_are_available() {
954        assert_eq!(select_cgroup_memory_limit(Some(32), Some(16)), Some(16));
955    }
956
957    #[cfg(target_os = "linux")]
958    #[test]
959    fn parses_v2_and_v1_process_cgroup_paths() {
960        let cgroup = "0::/user.slice/user-1000.slice/session-2.scope\n2:memory:/docker/abcdef";
961        let (v2_path, v1_path) = parse_self_cgroup_paths(cgroup);
962
963        assert_eq!(
964            v2_path,
965            Some("/user.slice/user-1000.slice/session-2.scope".to_string())
966        );
967        assert_eq!(v1_path, Some("/docker/abcdef".to_string()));
968    }
969
970    #[cfg(target_os = "linux")]
971    #[test]
972    fn builds_cgroup_limit_paths_with_root_and_nested_relative_paths() {
973        assert_eq!(
974            cgroup_limit_path(Path::new("/sys/fs/cgroup"), "/", "memory.max"),
975            PathBuf::from("/sys/fs/cgroup/memory.max")
976        );
977        assert_eq!(
978            cgroup_limit_path(
979                Path::new("/sys/fs/cgroup/memory"),
980                "/docker/abcdef",
981                "memory.limit_in_bytes"
982            ),
983            PathBuf::from("/sys/fs/cgroup/memory/docker/abcdef/memory.limit_in_bytes")
984        );
985    }
986
987    #[cfg(target_os = "linux")]
988    #[test]
989    fn reports_disk_failure_when_below_minimum_provisioned_capacity() {
990        let mut summary = PreflightSummary::default();
991        let mut grouped = HashMap::new();
992        grouped.insert(
993            1,
994            FilesystemRequirements {
995                roles: vec![DiskRole::ZebraState, DiskRole::ZcashdData],
996                target_paths: vec!["/zebra".into(), "/zcashd".into()],
997                min_provisioned_sum_bytes: MAINNET_MIN_ZEBRA_PROVISIONED_BYTES
998                    + MAINNET_MIN_ZCASHD_PROVISIONED_BYTES,
999                provisioned_bytes: 400 * GIB,
1000            },
1001        );
1002
1003        evaluate_disk_thresholds(
1004            &mut summary,
1005            &grouped,
1006            MAINNET_RECOMMENDED_COMBINED_TOTAL_BYTES,
1007        );
1008
1009        assert_eq!(summary.errors.len(), 1);
1010        assert!(
1011            summary
1012                .errors
1013                .iter()
1014                .any(|error| error.contains("provisioned capacity")),
1015            "expected provisioned capacity error: {:?}",
1016            summary.errors
1017        );
1018    }
1019
1020    #[cfg(target_os = "linux")]
1021    #[test]
1022    fn evaluate_disk_thresholds_testnet_fails_below_60_gib_combined() {
1023        let mut summary = PreflightSummary::default();
1024        let mut grouped = HashMap::new();
1025        grouped.insert(
1026            1,
1027            FilesystemRequirements {
1028                roles: vec![DiskRole::ZebraState, DiskRole::ZcashdData],
1029                target_paths: vec!["/zebra".into(), "/zcashd".into()],
1030                min_provisioned_sum_bytes: TESTNET_MIN_ZEBRA_PROVISIONED_BYTES
1031                    + TESTNET_MIN_ZCASHD_PROVISIONED_BYTES,
1032                provisioned_bytes: 50 * GIB,
1033            },
1034        );
1035
1036        evaluate_disk_thresholds(
1037            &mut summary,
1038            &grouped,
1039            TESTNET_RECOMMENDED_COMBINED_TOTAL_BYTES,
1040        );
1041
1042        assert_eq!(summary.errors.len(), 1);
1043        assert!(
1044            summary
1045                .errors
1046                .iter()
1047                .any(|error| error.contains("minimum required is 60.0 GiB")),
1048            "expected testnet minimum error: {:?}",
1049            summary.errors
1050        );
1051    }
1052
1053    #[cfg(target_os = "linux")]
1054    #[test]
1055    fn evaluate_disk_thresholds_testnet_warns_between_60_and_100_gib() {
1056        let mut summary = PreflightSummary::default();
1057        let mut grouped = HashMap::new();
1058        grouped.insert(
1059            1,
1060            FilesystemRequirements {
1061                roles: vec![DiskRole::ZebraState, DiskRole::ZcashdData],
1062                target_paths: vec!["/zebra".into(), "/zcashd".into()],
1063                min_provisioned_sum_bytes: TESTNET_MIN_ZEBRA_PROVISIONED_BYTES
1064                    + TESTNET_MIN_ZCASHD_PROVISIONED_BYTES,
1065                provisioned_bytes: 80 * GIB,
1066            },
1067        );
1068
1069        evaluate_disk_thresholds(
1070            &mut summary,
1071            &grouped,
1072            TESTNET_RECOMMENDED_COMBINED_TOTAL_BYTES,
1073        );
1074
1075        assert_eq!(summary.errors, Vec::<String>::new());
1076        assert_eq!(summary.warnings.len(), 1);
1077        assert!(
1078            summary
1079                .warnings
1080                .iter()
1081                .any(|warning| warning.contains("recommended is 100.0 GiB")),
1082            "expected testnet recommendation warning: {:?}",
1083            summary.warnings
1084        );
1085    }
1086
1087    #[cfg(target_os = "linux")]
1088    #[test]
1089    fn bypass_turns_failures_into_warnings() {
1090        let summary = PreflightSummary {
1091            errors: vec!["cpu below minimum".to_string()],
1092            warnings: vec!["disk below recommendation".to_string()],
1093        };
1094
1095        let warnings = finalize_preflight(summary, true).expect("unsafe bypass should continue");
1096
1097        assert_eq!(warnings.len(), 2);
1098        assert!(
1099            warnings
1100                .iter()
1101                .any(|warning| warning.contains("--unsafe-low-specs")),
1102            "expected unsafe bypass warning message: {warnings:?}"
1103        );
1104    }
1105
1106    #[cfg(target_os = "linux")]
1107    #[test]
1108    fn fails_when_below_minimum_without_bypass() {
1109        let summary = PreflightSummary {
1110            errors: vec!["ram below minimum".to_string()],
1111            warnings: Vec::new(),
1112        };
1113
1114        let error = finalize_preflight(summary, false)
1115            .expect_err("preflight should fail without unsafe bypass");
1116        assert!(
1117            error.to_string().contains("preflight failed"),
1118            "unexpected error: {error}"
1119        );
1120    }
1121
1122    #[cfg(target_os = "linux")]
1123    #[test]
1124    fn permission_checks_pass_in_writable_tempdir() {
1125        let temp_dir = TempDir::new().expect("tempdir should be created");
1126        let config = permission_test_config(&temp_dir);
1127        let mut summary = PreflightSummary::default();
1128
1129        check_permissions(
1130            &mut summary,
1131            &config,
1132            &permission_test_zcashd_datadir(&config),
1133        )
1134        .expect("permission checks should succeed");
1135
1136        assert_eq!(summary.errors, Vec::<String>::new());
1137    }
1138
1139    #[cfg(target_os = "linux")]
1140    #[test]
1141    fn reports_unwritable_datadir_ancestor() {
1142        if running_as_root() {
1143            return;
1144        }
1145
1146        let temp_dir = TempDir::new().expect("tempdir should be created");
1147        let protected = temp_dir.path().join("protected");
1148        std_fs::create_dir(&protected).expect("protected dir should be created");
1149
1150        let mut config = permission_test_config(&temp_dir);
1151        config.zcashd_compat.zcashd_datadir =
1152            Some(protected.join("missing").join("zcashd-datadir"));
1153
1154        set_mode(&protected, 0o555);
1155        let mut summary = PreflightSummary::default();
1156        let result = check_permissions(
1157            &mut summary,
1158            &config,
1159            &permission_test_zcashd_datadir(&config),
1160        );
1161        set_mode(&protected, 0o755);
1162
1163        result.expect("permission checks should complete");
1164        assert!(
1165            summary.errors.iter().any(|error| {
1166                error.contains("nearest existing ancestor") && error.contains("zcashd datadir")
1167            }),
1168            "expected datadir ancestor error: {:?}",
1169            summary.errors
1170        );
1171    }
1172
1173    #[cfg(target_os = "linux")]
1174    #[test]
1175    fn dedups_roles_sharing_one_unwritable_directory() {
1176        if running_as_root() {
1177            return;
1178        }
1179
1180        let temp_dir = TempDir::new().expect("tempdir should be created");
1181        let shared = temp_dir.path().join("shared");
1182        std_fs::create_dir(&shared).expect("shared dir should be created");
1183
1184        let mut config = ZebradConfig::default();
1185        config.state.cache_dir = shared.clone();
1186        config.zcashd_compat.manage_zcashd = false;
1187
1188        set_mode(&shared, 0o555);
1189        let mut summary = PreflightSummary::default();
1190        let result = check_permissions(&mut summary, &config, temp_dir.path());
1191        set_mode(&shared, 0o755);
1192
1193        result.expect("permission checks should complete");
1194        assert_eq!(summary.errors.len(), 1, "errors: {:?}", summary.errors);
1195        assert!(
1196            summary.errors[0].contains("zebra state directory"),
1197            "expected state-dir error: {:?}",
1198            summary.errors
1199        );
1200    }
1201
1202    #[cfg(target_os = "linux")]
1203    #[test]
1204    fn reports_unreadable_existing_conf() {
1205        if running_as_root() {
1206            return;
1207        }
1208
1209        let temp_dir = TempDir::new().expect("tempdir should be created");
1210        let config = permission_test_config(&temp_dir);
1211        let zcashd_datadir = permission_test_zcashd_datadir(&config);
1212        std_fs::create_dir_all(&zcashd_datadir).expect("datadir should be created");
1213        let conf_path = zcashd_datadir.join("zcash.conf");
1214        std_fs::write(&conf_path, "").expect("conf should be written");
1215
1216        set_mode(&conf_path, 0o000);
1217        let mut summary = PreflightSummary::default();
1218        let result = check_permissions(&mut summary, &config, &zcashd_datadir);
1219        set_mode(&conf_path, 0o644);
1220
1221        result.expect("permission checks should complete");
1222        assert!(
1223            summary
1224                .errors
1225                .iter()
1226                .any(|error| error.contains("not readable")),
1227            "expected unreadable conf error: {:?}",
1228            summary.errors
1229        );
1230    }
1231
1232    #[cfg(target_os = "linux")]
1233    #[test]
1234    fn reports_conf_path_that_is_a_directory() {
1235        let temp_dir = TempDir::new().expect("tempdir should be created");
1236        let config = permission_test_config(&temp_dir);
1237        let zcashd_datadir = permission_test_zcashd_datadir(&config);
1238        std_fs::create_dir_all(zcashd_datadir.join("zcash.conf"))
1239            .expect("conf directory should be created");
1240        let mut summary = PreflightSummary::default();
1241
1242        check_permissions(&mut summary, &config, &zcashd_datadir)
1243            .expect("permission checks should complete");
1244
1245        assert!(
1246            summary
1247                .errors
1248                .iter()
1249                .any(|error| error.contains("is a directory")),
1250            "expected directory conf error: {:?}",
1251            summary.errors
1252        );
1253    }
1254
1255    #[cfg(target_os = "linux")]
1256    #[test]
1257    fn reports_dangling_conf_symlink() {
1258        let temp_dir = TempDir::new().expect("tempdir should be created");
1259        let config = permission_test_config(&temp_dir);
1260        let zcashd_datadir = permission_test_zcashd_datadir(&config);
1261        std_fs::create_dir_all(&zcashd_datadir).expect("datadir should be created");
1262        symlink("missing.conf", zcashd_datadir.join("zcash.conf"))
1263            .expect("dangling symlink should be created");
1264        let mut summary = PreflightSummary::default();
1265
1266        check_permissions(&mut summary, &config, &zcashd_datadir)
1267            .expect("permission checks should complete");
1268
1269        assert!(
1270            summary
1271                .errors
1272                .iter()
1273                .any(|error| error.contains("symlink to a missing target")),
1274            "expected dangling symlink error: {:?}",
1275            summary.errors
1276        );
1277    }
1278
1279    #[cfg(target_os = "linux")]
1280    #[test]
1281    fn reports_nonexecutable_zcashd_path() {
1282        let temp_dir = TempDir::new().expect("tempdir should be created");
1283        let mut config = permission_test_config(&temp_dir);
1284        let zcashd_path = temp_dir.path().join("non-executable-zcashd");
1285        std_fs::write(&zcashd_path, "#!/bin/sh\n").expect("zcashd file should be written");
1286        set_mode(&zcashd_path, 0o644);
1287        config.zcashd_compat.zcashd_path = Some(zcashd_path);
1288        let mut summary = PreflightSummary::default();
1289
1290        check_permissions(
1291            &mut summary,
1292            &config,
1293            &permission_test_zcashd_datadir(&config),
1294        )
1295        .expect("permission checks should complete");
1296
1297        assert!(
1298            summary
1299                .errors
1300                .iter()
1301                .any(|error| error.contains("not executable")),
1302            "expected non-executable zcashd error: {:?}",
1303            summary.errors
1304        );
1305    }
1306
1307    #[cfg(target_os = "linux")]
1308    #[test]
1309    fn missing_conf_requires_writable_parent() {
1310        let temp_dir = TempDir::new().expect("tempdir should be created");
1311        let config = permission_test_config(&temp_dir);
1312        let zcashd_datadir = permission_test_zcashd_datadir(&config);
1313        std_fs::create_dir_all(&zcashd_datadir).expect("datadir should be created");
1314        let mut summary = PreflightSummary::default();
1315
1316        check_permissions(&mut summary, &config, &zcashd_datadir)
1317            .expect("permission checks should complete");
1318
1319        assert_eq!(summary.errors, Vec::<String>::new());
1320    }
1321
1322    #[cfg(target_os = "linux")]
1323    #[test]
1324    fn embedded_source_checks_cache_dir_writability() {
1325        if running_as_root() {
1326            return;
1327        }
1328
1329        if zcashd_target_triple().is_none() {
1330            return;
1331        }
1332
1333        let temp_dir = TempDir::new().expect("tempdir should be created");
1334        let protected = temp_dir.path().join("protected");
1335        std_fs::create_dir(&protected).expect("protected dir should be created");
1336
1337        let mut config = permission_test_config(&temp_dir);
1338        config.state.cache_dir = protected.join("zebra-cache");
1339        config.zcashd_compat.zcashd_datadir = Some(temp_dir.path().join("zcashd-datadir"));
1340        config.zcashd_compat.zcashd_source = ConfigZcashdBinarySource::Embedded;
1341        config.zcashd_compat.zcashd_path = None;
1342
1343        set_mode(&protected, 0o555);
1344        let mut summary = PreflightSummary::default();
1345        let result = check_permissions(
1346            &mut summary,
1347            &config,
1348            &permission_test_zcashd_datadir(&config),
1349        );
1350        set_mode(&protected, 0o755);
1351
1352        result.expect("permission checks should complete");
1353        assert!(
1354            summary.errors.iter().any(|error| {
1355                error.contains("embedded zcashd cache directory")
1356                    && error.contains("nearest existing ancestor")
1357            }),
1358            "expected embedded cache writability error: {:?}",
1359            summary.errors
1360        );
1361    }
1362
1363    #[cfg(target_os = "linux")]
1364    #[test]
1365    fn stale_embedded_cache_checks_cache_dir_writability() {
1366        if running_as_root() {
1367            return;
1368        }
1369
1370        if zcashd_target_triple().is_none() {
1371            return;
1372        }
1373
1374        let temp_dir = TempDir::new().expect("tempdir should be created");
1375        let mut config = permission_test_config(&temp_dir);
1376        config.zcashd_compat.zcashd_source = ConfigZcashdBinarySource::Embedded;
1377        config.zcashd_compat.zcashd_path = None;
1378
1379        let binary_path =
1380            managed_zcashd_binary_path(&config.state.cache_dir).expect("managed path should exist");
1381        let cache_dir = binary_path.parent().expect("binary should have parent");
1382        std_fs::create_dir_all(cache_dir).expect("managed cache dir should be created");
1383        std_fs::write(&binary_path, "#!/bin/sh\n").expect("cached zcashd should be written");
1384        std_fs::write(cache_dir.join("zcashd.sha256"), "stale\n")
1385            .expect("stale provenance should be written");
1386        set_mode(&binary_path, 0o755);
1387
1388        set_mode(cache_dir, 0o555);
1389        let mut summary = PreflightSummary::default();
1390        let result = check_permissions(
1391            &mut summary,
1392            &config,
1393            &permission_test_zcashd_datadir(&config),
1394        );
1395        set_mode(cache_dir, 0o755);
1396
1397        result.expect("permission checks should complete");
1398        assert!(
1399            summary.errors.iter().any(|error| {
1400                error.contains("embedded zcashd cache directory") && error.contains("not writable")
1401            }),
1402            "expected stale embedded cache writability error: {:?}",
1403            summary.errors
1404        );
1405    }
1406
1407    #[cfg(target_os = "linux")]
1408    fn permission_test_config(temp_dir: &TempDir) -> ZebradConfig {
1409        let mut config = ZebradConfig::default();
1410        config.state.cache_dir = temp_dir.path().join("zebra-cache");
1411        config.zcashd_compat.manage_zcashd = true;
1412        config.zcashd_compat.zcashd_source = ConfigZcashdBinarySource::Path;
1413        config.zcashd_compat.zcashd_datadir = Some(temp_dir.path().join("zcashd-datadir"));
1414
1415        let zcashd_path = temp_dir.path().join("zcashd");
1416        std_fs::write(&zcashd_path, "#!/bin/sh\n").expect("zcashd file should be written");
1417        set_mode(&zcashd_path, 0o755);
1418        config.zcashd_compat.zcashd_path = Some(zcashd_path);
1419
1420        config
1421    }
1422
1423    #[cfg(target_os = "linux")]
1424    fn permission_test_zcashd_datadir(config: &ZebradConfig) -> PathBuf {
1425        resolve_zcashd_datadir_path(
1426            &effective_zcashd_datadir(&config.zcashd_compat, &config.state.cache_dir),
1427            &config.zcashd_compat.zcashd_extra_args,
1428        )
1429    }
1430
1431    #[cfg(target_os = "linux")]
1432    fn set_mode(path: &Path, mode: u32) {
1433        std_fs::set_permissions(path, std_fs::Permissions::from_mode(mode))
1434            .expect("permissions should be set");
1435    }
1436
1437    #[cfg(target_os = "linux")]
1438    fn running_as_root() -> bool {
1439        uid_command_reports_root(&["-u"]) || uid_command_reports_root(&["-r", "-u"])
1440    }
1441
1442    #[cfg(target_os = "linux")]
1443    fn uid_command_reports_root(args: &[&str]) -> bool {
1444        Command::new("id")
1445            .args(args)
1446            .output()
1447            .ok()
1448            .and_then(|output| String::from_utf8(output.stdout).ok())
1449            .is_some_and(|uid| uid.trim() == "0")
1450    }
1451
1452    #[cfg(target_os = "linux")]
1453    fn parse_cgroup_value(value: &str) -> Result<Option<u64>, Report> {
1454        if value.trim().eq_ignore_ascii_case("max") {
1455            return Ok(None);
1456        }
1457
1458        let parsed_limit = value
1459            .trim()
1460            .parse::<u64>()
1461            .map_err(|error| eyre!("failed to parse cgroup memory limit: {error}"))?;
1462
1463        if parsed_limit >= 0x7fff_ffff_ffff_f000 {
1464            return Ok(None);
1465        }
1466
1467        Ok(Some(parsed_limit))
1468    }
1469}