Skip to main content

zebrad/components/zcashd_compat/
managed.rs

1use std::{
2    fs::{self, OpenOptions},
3    io::{BufReader, Read, Write},
4    path::{Path, PathBuf},
5    thread::sleep,
6    time::{Duration, Instant},
7};
8
9use color_eyre::eyre::{eyre, Report};
10use flate2::read::GzDecoder;
11use reqwest::{blocking::Client, redirect::Policy, Url};
12use sha2::{Digest, Sha256};
13use tar::Archive;
14use tempfile::NamedTempFile;
15
16use super::{
17    Config, ConfigZcashdBinarySource, ZcashdReleaseManifest, EMBEDDED_ZCASHD_RELEASE_MANIFEST,
18};
19use crate::components::zcashd_compat::supervisor::is_command_resolvable;
20
21/// Maximum time a managed archive download is allowed to take.
22const MANAGED_DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(10 * 60);
23
24/// Upper bound on a managed zcashd archive download, in bytes.
25///
26/// The SHA256 pin only detects bad content *after* the whole body is written,
27/// so without a cap a compromised or malfunctioning endpoint (for example one
28/// that omits `Content-Length` and streams indefinitely) could fill Zebra's
29/// state filesystem before verification runs and take the node down. Pinned
30/// release archives are far smaller than this; the bound exists only to fail
31/// fast on a runaway transfer.
32const MANAGED_DOWNLOAD_MAX_BYTES: u64 = 1024 * 1024 * 1024;
33/// Maximum time a process waits for another live process to finish a managed install.
34const INSTALL_LOCK_WAIT_TIMEOUT: Duration = Duration::from_secs(15 * 60);
35/// Stale age for legacy lock files that do not contain owner metadata.
36const LEGACY_INSTALL_LOCK_STALE_AFTER: Duration = Duration::from_secs(30);
37const INSTALL_LOCK_RETRY_DELAY: Duration = Duration::from_millis(250);
38
39/// Effective `zcashd` source after local-path overrides are applied.
40#[derive(Clone, Debug, Eq, PartialEq)]
41pub enum ZcashdBinarySource {
42    /// Explicit local executable path.
43    Path(PathBuf),
44    /// Embedded release download and cache.
45    Embedded,
46}
47
48/// Returns the current platform target triple used for managed release lookups.
49pub fn zcashd_target_triple() -> Option<&'static str> {
50    let target = match (std::env::consts::OS, std::env::consts::ARCH) {
51        ("linux", "x86_64") => Some("x86_64-pc-linux-gnu"),
52        _ => None,
53    }?;
54
55    EMBEDDED_ZCASHD_RELEASE_MANIFEST
56        .artifact_for_target(target)
57        .map(|_| target)
58}
59
60/// Resolves the effective source for `zcashd`.
61pub fn effective_zcashd_source(config: &Config) -> Result<ZcashdBinarySource, Report> {
62    if let Some(path) = config.zcashd_path.clone() {
63        return Ok(ZcashdBinarySource::Path(path));
64    }
65
66    match config.zcashd_source {
67        ConfigZcashdBinarySource::Embedded => Ok(ZcashdBinarySource::Embedded),
68        ConfigZcashdBinarySource::Path => Err(eyre!(
69            "zcashd_compat.zcashd_source=path requires zcashd_compat.zcashd_path to be set"
70        )),
71    }
72}
73
74/// Resolves and validates the `zcashd` executable path.
75pub fn resolve_zcashd_binary_path(
76    config: &Config,
77    state_cache_dir: &Path,
78) -> Result<PathBuf, Report> {
79    match effective_zcashd_source(config)? {
80        ZcashdBinarySource::Path(path) => {
81            if !is_command_resolvable(&path) {
82                return Err(eyre!(
83                    "zcashd-compat mode could not resolve zcashd_path={}",
84                    path.display()
85                ));
86            }
87            Ok(path)
88        }
89        ZcashdBinarySource::Embedded => resolve_managed_zcashd_binary(state_cache_dir),
90    }
91}
92
93/// Resolves the managed zcashd binary from the embedded release manifest.
94pub fn resolve_managed_zcashd_binary(state_cache_dir: &Path) -> Result<PathBuf, Report> {
95    resolve_managed_zcashd_binary_from_manifest(&EMBEDDED_ZCASHD_RELEASE_MANIFEST, state_cache_dir)
96}
97
98/// Returns the managed zcashd binary cache path without creating directories,
99/// or `None` when managed downloads are unsupported for this target.
100///
101/// Only called from Linux-gated preflight code, so it is dead code on other
102/// targets.
103#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
104pub(super) fn managed_zcashd_binary_path(state_cache_dir: &Path) -> Option<PathBuf> {
105    let target = zcashd_target_triple()?;
106
107    Some(
108        managed_zcashd_cache_dir(
109            state_cache_dir,
110            &EMBEDDED_ZCASHD_RELEASE_MANIFEST.release_tag,
111            target,
112        )
113        .join("zcashd"),
114    )
115}
116
117/// Returns whether a cached managed zcashd binary exists for this target with
118/// a provenance marker recording the manifest-pinned archive digest, or `None`
119/// when managed downloads are unsupported for this target.
120///
121/// Preflight uses this to decide whether a download is likely needed, so it
122/// compares the marker's recorded archive digest (a cheap string check that
123/// catches a bumped release pin) but deliberately skips re-hashing the whole
124/// binary: the security-critical digest verification happens in
125/// [`resolve_managed_zcashd_binary`] before the binary is used.
126///
127/// Only called from Linux-gated preflight code, so it is dead code on other
128/// targets.
129#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
130pub(super) fn cached_managed_zcashd_binary_is_present(state_cache_dir: &Path) -> Option<bool> {
131    let target = zcashd_target_triple()?;
132    let artifact = EMBEDDED_ZCASHD_RELEASE_MANIFEST.artifact_for_target(target)?;
133    let binary_path = managed_zcashd_binary_path(state_cache_dir)?;
134    let provenance_path = binary_path.with_file_name("zcashd.sha256");
135
136    if !binary_path.is_file() {
137        return Some(false);
138    }
139
140    let content = fs::read_to_string(provenance_path).unwrap_or_default();
141    let recorded_archive = content
142        .lines()
143        .find_map(|line| line.strip_prefix("archive_sha256:"))
144        .map(str::trim);
145
146    Some(
147        recorded_archive.is_some_and(|recorded| {
148            recorded.eq_ignore_ascii_case(&artifact.runtime_archive_sha256)
149        }),
150    )
151}
152
153fn resolve_managed_zcashd_binary_from_manifest(
154    manifest: &ZcashdReleaseManifest,
155    state_cache_dir: &Path,
156) -> Result<PathBuf, Report> {
157    let target = zcashd_target_triple().ok_or_else(|| {
158        eyre!(
159            "zcashd-compat managed downloads are unsupported for this platform ({}/{})",
160            std::env::consts::OS,
161            std::env::consts::ARCH
162        )
163    })?;
164
165    let artifact = manifest.artifact_for_target(target).ok_or_else(|| {
166        eyre!(
167            "no managed zcashd release is configured for target {target}; \
168                 set zcashd_compat.zcashd_path to a local zcashd binary"
169        )
170    })?;
171
172    let cache_dir = managed_zcashd_cache_dir(state_cache_dir, &manifest.release_tag, target);
173    fs::create_dir_all(&cache_dir)?;
174
175    let binary_path = cache_dir.join("zcashd");
176    let provenance_path = cache_dir.join("zcashd.sha256");
177    if provenance_matches(
178        &provenance_path,
179        &artifact.runtime_archive_sha256,
180        &binary_path,
181    )? {
182        return Ok(binary_path);
183    }
184
185    let _lock = acquire_lock(
186        &cache_dir.join(".install.lock"),
187        INSTALL_LOCK_WAIT_TIMEOUT,
188        LEGACY_INSTALL_LOCK_STALE_AFTER,
189    )?;
190
191    // Re-check after acquiring the lock.
192    if provenance_matches(
193        &provenance_path,
194        &artifact.runtime_archive_sha256,
195        &binary_path,
196    )? {
197        return Ok(binary_path);
198    }
199
200    let mut archive_temp = NamedTempFile::new_in(&cache_dir)?;
201    download_archive(&artifact.runtime_archive_url, archive_temp.as_file_mut())?;
202    archive_temp.as_file_mut().sync_all()?;
203
204    let archive_sha256 = sha256_hex_file(archive_temp.path())?;
205    if !archive_sha256.eq_ignore_ascii_case(&artifact.runtime_archive_sha256) {
206        return Err(eyre!(
207            "managed zcashd archive hash mismatch for target {target}: expected {}, got {archive_sha256}",
208            artifact.runtime_archive_sha256
209        ));
210    }
211
212    let extracted_temp = NamedTempFile::new_in(&cache_dir)?;
213    extract_archive_member_to_path(
214        archive_temp.path(),
215        &artifact.runtime_archive_member_binary_path,
216        extracted_temp.path(),
217    )?;
218    make_executable(extracted_temp.path())?;
219    let binary_sha256 = sha256_hex_file(extracted_temp.path())?;
220    extracted_temp.persist(&binary_path).map_err(|err| {
221        eyre!(
222            "failed to persist managed zcashd binary {}: {}",
223            binary_path.display(),
224            err.error
225        )
226    })?;
227    fs::write(
228        &provenance_path,
229        provenance_content(&artifact.runtime_archive_sha256, &binary_sha256),
230    )?;
231
232    Ok(binary_path)
233}
234
235fn managed_zcashd_cache_dir(state_cache_dir: &Path, release_tag: &str, target: &str) -> PathBuf {
236    state_cache_dir
237        .join("zcashd-compat")
238        .join("bin")
239        .join(release_tag)
240        .join(target)
241}
242
243/// Downloads one managed release archive to `out`.
244///
245/// Security constraints:
246/// - production code requires HTTPS URLs only;
247/// - redirects must remain HTTPS;
248/// - tests may use localhost HTTP endpoints.
249fn download_archive(url: &str, out: &mut fs::File) -> Result<(), Report> {
250    let parsed =
251        Url::parse(url).map_err(|err| eyre!("invalid managed zcashd URL '{url}': {err}"))?;
252    if parsed.scheme() != "https" {
253        #[cfg(test)]
254        let localhost_http = parsed.scheme() == "http"
255            && parsed
256                .host_str()
257                .map(|host| host == "127.0.0.1" || host == "localhost")
258                .unwrap_or(false);
259        #[cfg(not(test))]
260        let localhost_http = false;
261
262        if !localhost_http {
263            return Err(eyre!("managed zcashd URL must use https: {url}"));
264        }
265    }
266
267    // Enforce "redirects must remain HTTPS" on every hop, not just the final
268    // URL: an https→http→https chain would otherwise expose the transfer to a
269    // plaintext man-in-the-middle. (Tests use direct localhost HTTP with no
270    // redirects, so they never reach this policy.)
271    let https_only_redirects = Policy::custom(|attempt| {
272        if attempt.previous().len() > 5 {
273            attempt.error("too many redirects")
274        } else if attempt.url().scheme() != "https" {
275            attempt.error("managed zcashd download redirected to a non-https URL")
276        } else {
277            attempt.follow()
278        }
279    });
280    let client = Client::builder()
281        .redirect(https_only_redirects)
282        .timeout(MANAGED_DOWNLOAD_TIMEOUT)
283        .build()
284        .map_err(|err| eyre!("failed building managed zcashd HTTP client: {err}"))?;
285    let mut response = client
286        .get(parsed)
287        .send()
288        .map_err(|err| eyre!("failed downloading managed zcashd archive: {err}"))?;
289    if !response.status().is_success() {
290        return Err(eyre!(
291            "managed zcashd download failed with HTTP status {}",
292            response.status()
293        ));
294    }
295    if response.url().scheme() != "https" {
296        #[cfg(test)]
297        let localhost_http = response.url().scheme() == "http"
298            && response
299                .url()
300                .host_str()
301                .map(|host| host == "127.0.0.1" || host == "localhost")
302                .unwrap_or(false);
303        #[cfg(not(test))]
304        let localhost_http = false;
305
306        if localhost_http {
307            // allowed only in tests
308        } else {
309            return Err(eyre!(
310                "managed zcashd download redirected to non-https URL: {}",
311                response.url()
312            ));
313        }
314    }
315
316    // Bound the write so a compromised or malfunctioning endpoint cannot fill
317    // the state filesystem before the SHA256 check runs. Reading one byte past
318    // the cap distinguishes "exactly at the limit" from "over the limit". On
319    // the error path the caller's `NamedTempFile` removes the partial file.
320    let mut limited = (&mut response).take(MANAGED_DOWNLOAD_MAX_BYTES.saturating_add(1));
321    let written = std::io::copy(&mut limited, out)
322        .map_err(|err| eyre!("failed writing managed zcashd archive to cache: {err}"))?;
323    if written > MANAGED_DOWNLOAD_MAX_BYTES {
324        return Err(eyre!(
325            "managed zcashd archive exceeds the maximum allowed size of \
326             {MANAGED_DOWNLOAD_MAX_BYTES} bytes; aborting download"
327        ));
328    }
329    Ok(())
330}
331
332/// Extracts a single archive member into `destination`.
333///
334/// This is used to pull only the configured `zcashd` binary from a release
335/// archive, rather than unpacking all members.
336fn extract_archive_member_to_path(
337    archive_path: &Path,
338    member_path: &str,
339    destination: &Path,
340) -> Result<(), Report> {
341    let file = fs::File::open(archive_path)?;
342    let reader = BufReader::new(file);
343    let decoder = GzDecoder::new(reader);
344    let mut archive = Archive::new(decoder);
345    let requested = normalize_member_path(member_path);
346
347    for entry in archive.entries()? {
348        let mut entry = entry?;
349        let entry_path = entry.path()?;
350        let candidate = normalize_member_path(entry_path.to_string_lossy().as_ref());
351        if candidate == requested {
352            entry
353                .unpack(destination)
354                .map_err(|err| eyre!("failed extracting managed zcashd binary: {err}"))?;
355            return Ok(());
356        }
357    }
358
359    Err(eyre!(
360        "managed zcashd archive does not contain expected member path '{}'",
361        member_path
362    ))
363}
364
365/// Normalizes archive entry paths for stable matching.
366///
367/// Tar archives can contain either `./bin/zcashd` or `bin/zcashd`; this helper
368/// canonicalizes both into the same form.
369fn normalize_member_path(path: &str) -> String {
370    path.trim_start_matches("./").to_string()
371}
372
373/// Returns the provenance marker content for a verified extraction:
374/// the manifest-pinned archive digest and the digest of the extracted binary.
375fn provenance_content(archive_sha256: &str, binary_sha256: &str) -> String {
376    format!("archive_sha256:{archive_sha256}\nbinary_sha256:{binary_sha256}\n")
377}
378
379/// Returns `true` if the provenance marker at `path` records exactly the
380/// manifest-pinned `expected_archive_sha256`, and the binary at `binary_path`
381/// still hashes to the recorded binary digest.
382///
383/// # Security
384///
385/// The cached executable is re-hashed on every reuse: a marker file alone
386/// cannot vouch for a binary that was modified or corrupted after extraction.
387fn provenance_matches(
388    path: &Path,
389    expected_archive_sha256: &str,
390    binary_path: &Path,
391) -> Result<bool, Report> {
392    if !path.is_file() || !binary_path.is_file() {
393        return Ok(false);
394    }
395
396    let content = fs::read_to_string(path).unwrap_or_default();
397    let mut recorded_archive = None;
398    let mut recorded_binary = None;
399    for line in content.lines() {
400        if let Some(value) = line.strip_prefix("archive_sha256:") {
401            recorded_archive = Some(value.trim().to_string());
402        } else if let Some(value) = line.strip_prefix("binary_sha256:") {
403            recorded_binary = Some(value.trim().to_string());
404        }
405    }
406
407    let (Some(recorded_archive), Some(recorded_binary)) = (recorded_archive, recorded_binary)
408    else {
409        // Markers from older formats do not record the binary digest, so they
410        // cannot be trusted: re-download and re-verify.
411        return Ok(false);
412    };
413
414    if !recorded_archive.eq_ignore_ascii_case(expected_archive_sha256) {
415        return Ok(false);
416    }
417
418    Ok(sha256_hex_file(binary_path)?.eq_ignore_ascii_case(&recorded_binary))
419}
420
421/// Computes the lowercase hex SHA256 digest for `path`.
422fn sha256_hex_file(path: &Path) -> Result<String, Report> {
423    let mut file = fs::File::open(path)?;
424    let mut hasher = Sha256::new();
425    let mut buf = [0u8; 16 * 1024];
426
427    loop {
428        let n = file.read(&mut buf)?;
429        if n == 0 {
430            break;
431        }
432        hasher.update(&buf[..n]);
433    }
434
435    let digest = hasher.finalize();
436    Ok(hex::encode(digest))
437}
438
439/// Makes `path` executable on Unix targets.
440///
441/// Non-Unix targets currently no-op because managed release targets are Linux.
442fn make_executable(path: &Path) -> Result<(), Report> {
443    #[cfg(unix)]
444    {
445        use std::os::unix::fs::PermissionsExt;
446
447        let mut permissions = fs::metadata(path)?.permissions();
448        permissions.set_mode(0o755);
449        fs::set_permissions(path, permissions)?;
450    }
451
452    Ok(())
453}
454
455struct InstallLock {
456    path: PathBuf,
457}
458
459impl Drop for InstallLock {
460    fn drop(&mut self) {
461        let _ = fs::remove_file(&self.path);
462    }
463}
464
465/// Acquires an exclusive lock file, retrying until `timeout`.
466///
467/// This prevents concurrent zebrad processes from racing archive downloads and
468/// replacing the same cached binary simultaneously.
469fn acquire_lock(
470    lock_path: &Path,
471    wait_timeout: Duration,
472    stale_after: Duration,
473) -> Result<InstallLock, Report> {
474    let started = Instant::now();
475    loop {
476        match OpenOptions::new()
477            .create_new(true)
478            .write(true)
479            .open(lock_path)
480        {
481            Ok(mut file) => {
482                if let Err(error) = write_install_lock_owner(&mut file, lock_path) {
483                    let _ = fs::remove_file(lock_path);
484                    return Err(error);
485                }
486
487                return Ok(InstallLock {
488                    path: lock_path.to_path_buf(),
489                });
490            }
491            Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
492                if remove_stale_lock(lock_path, stale_after)? {
493                    continue;
494                }
495
496                if started.elapsed() >= wait_timeout {
497                    return Err(eyre!(
498                        "timed out after {} seconds waiting for managed zcashd installation lock: {}",
499                        wait_timeout.as_secs(),
500                        lock_path.display(),
501                    ));
502                }
503
504                sleep(INSTALL_LOCK_RETRY_DELAY);
505            }
506            Err(err) => {
507                return Err(eyre!(
508                    "failed to create managed zcashd installation lock {}: {err}",
509                    lock_path.display()
510                ))
511            }
512        }
513    }
514}
515
516fn write_install_lock_owner(file: &mut fs::File, lock_path: &Path) -> Result<(), Report> {
517    writeln!(file, "pid={}", std::process::id()).map_err(|err| {
518        eyre!(
519            "failed to write managed zcashd installation lock {}: {err}",
520            lock_path.display()
521        )
522    })?;
523    file.sync_all().map_err(|err| {
524        eyre!(
525            "failed to sync managed zcashd installation lock {}: {err}",
526            lock_path.display()
527        )
528    })
529}
530
531fn remove_stale_lock(lock_path: &Path, stale_after: Duration) -> Result<bool, Report> {
532    let content = match fs::read_to_string(lock_path) {
533        Ok(content) => content,
534        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(true),
535        Err(err) => {
536            return Err(eyre!(
537                "failed to read managed zcashd installation lock {}: {err}",
538                lock_path.display()
539            ))
540        }
541    };
542
543    if let Some(pid) = lock_owner_pid(&content) {
544        if process_is_running(pid) {
545            return Ok(false);
546        }
547    } else if !lock_file_is_older_than(lock_path, stale_after)? {
548        return Ok(false);
549    }
550
551    match fs::remove_file(lock_path) {
552        Ok(()) => Ok(true),
553        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(true),
554        Err(err) => Err(eyre!(
555            "failed to remove stale managed zcashd installation lock {}: {err}",
556            lock_path.display()
557        )),
558    }
559}
560
561fn lock_owner_pid(content: &str) -> Option<u32> {
562    content.lines().find_map(|line| {
563        line.strip_prefix("pid=")
564            .and_then(|pid| pid.trim().parse().ok())
565    })
566}
567
568fn lock_file_is_older_than(lock_path: &Path, age: Duration) -> Result<bool, Report> {
569    let metadata = match fs::metadata(lock_path) {
570        Ok(metadata) => metadata,
571        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(true),
572        Err(err) => {
573            return Err(eyre!(
574                "failed to inspect managed zcashd installation lock {}: {err}",
575                lock_path.display()
576            ))
577        }
578    };
579
580    let Ok(modified_age) = metadata.modified()?.elapsed() else {
581        return Ok(false);
582    };
583
584    Ok(modified_age >= age)
585}
586
587#[cfg(unix)]
588fn process_is_running(pid: u32) -> bool {
589    use nix::{errno::Errno, sys::signal::kill, unistd::Pid};
590
591    let Ok(pid) = i32::try_from(pid) else {
592        return false;
593    };
594
595    match kill(Pid::from_raw(pid), None) {
596        Ok(()) => true,
597        Err(Errno::EPERM) => true,
598        Err(Errno::ESRCH) => false,
599        Err(_) => false,
600    }
601}
602
603#[cfg(not(unix))]
604fn process_is_running(_pid: u32) -> bool {
605    true
606}
607
608#[cfg(test)]
609mod tests {
610    use std::{
611        io::{Read, Write},
612        net::TcpListener,
613        thread,
614        time::Duration,
615    };
616
617    use flate2::{write::GzEncoder, Compression};
618    use tar::Builder;
619    use tempfile::tempdir;
620
621    use super::{
622        acquire_lock, effective_zcashd_source, normalize_member_path, provenance_content,
623        provenance_matches, resolve_managed_zcashd_binary_from_manifest, sha256_hex_file,
624        zcashd_target_triple, Config, ZcashdBinarySource,
625    };
626    use crate::components::zcashd_compat::{
627        ConfigZcashdBinarySource, ZcashdReleaseArtifact, ZcashdReleaseManifest,
628        EMBEDDED_ZCASHD_RELEASE_MANIFEST,
629    };
630
631    #[test]
632    fn explicit_zcashd_path_overrides_embedded_source() {
633        let config = Config {
634            zcashd_source: ConfigZcashdBinarySource::Embedded,
635            zcashd_path: Some("/usr/local/bin/zcashd".into()),
636            ..Default::default()
637        };
638
639        assert_eq!(
640            effective_zcashd_source(&config).expect("source should resolve"),
641            ZcashdBinarySource::Path("/usr/local/bin/zcashd".into())
642        );
643    }
644
645    #[test]
646    fn path_source_requires_explicit_path() {
647        let config = Config {
648            zcashd_source: ConfigZcashdBinarySource::Path,
649            zcashd_path: None,
650            ..Default::default()
651        };
652
653        let error = effective_zcashd_source(&config).expect_err("path source should fail");
654        assert!(error.to_string().contains("zcashd_source=path"));
655    }
656
657    #[test]
658    fn target_triple_is_configured_or_none() {
659        if let Some(target) = zcashd_target_triple() {
660            assert_eq!(
661                target, "x86_64-pc-linux-gnu",
662                "managed zcashd downloads are only published for x86_64"
663            );
664            assert!(
665                EMBEDDED_ZCASHD_RELEASE_MANIFEST
666                    .artifact_for_target(target)
667                    .is_some(),
668                "managed target triple is not configured in embedded manifest: {target}"
669            );
670        }
671    }
672
673    #[test]
674    fn normalize_member_path_strips_dot_prefix() {
675        assert_eq!(normalize_member_path("./bin/zcashd"), "bin/zcashd");
676        assert_eq!(normalize_member_path("bin/zcashd"), "bin/zcashd");
677    }
678
679    #[test]
680    fn provenance_matches_verifies_marker_and_binary_digest() {
681        let temp = tempdir().expect("tempdir should exist");
682        let path = temp.path().join("sha.txt");
683        let binary_path = temp.path().join("zcashd");
684
685        assert!(
686            !provenance_matches(&path, "abc", &binary_path).expect("missing file should not match"),
687            "missing provenance should not match"
688        );
689
690        std::fs::write(&binary_path, b"binary contents").expect("binary file should write");
691        let binary_sha256 =
692            sha256_hex_file(&binary_path).expect("binary digest should be computable");
693
694        std::fs::write(&path, provenance_content("abc", &binary_sha256))
695            .expect("provenance file should write");
696        assert!(
697            provenance_matches(&path, "abc", &binary_path)
698                .expect("matching provenance should succeed"),
699            "written provenance should match"
700        );
701        assert!(
702            !provenance_matches(&path, "def", &binary_path)
703                .expect("mismatched provenance should succeed"),
704            "different expected archive hash should not match"
705        );
706
707        // A legacy archive-only marker cannot vouch for the binary.
708        std::fs::write(&path, "abc\n").expect("provenance file should write");
709        assert!(
710            !provenance_matches(&path, "abc", &binary_path)
711                .expect("legacy provenance should be readable"),
712            "legacy archive-only provenance should not match"
713        );
714
715        // A modified binary must not be reused, even with an intact marker.
716        std::fs::write(&path, provenance_content("abc", &binary_sha256))
717            .expect("provenance file should write");
718        std::fs::write(&binary_path, b"tampered contents").expect("binary file should write");
719        assert!(
720            !provenance_matches(&path, "abc", &binary_path)
721                .expect("tampered binary check should succeed"),
722            "a tampered cached binary should not match"
723        );
724    }
725
726    #[cfg(unix)]
727    #[test]
728    fn acquire_lock_replaces_dead_owner_lock() {
729        let temp = tempdir().expect("tempdir should exist");
730        let lock_path = temp.path().join(".install.lock");
731        std::fs::write(&lock_path, "pid=4294967295\n").expect("lock file should write");
732
733        let lock = acquire_lock(&lock_path, Duration::ZERO, Duration::ZERO)
734            .expect("dead owner lock should recover");
735        let content = std::fs::read_to_string(&lock_path).expect("lock file should be readable");
736
737        assert!(
738            content.contains(&format!("pid={}\n", std::process::id())),
739            "lock file should contain current process owner: {content}"
740        );
741
742        drop(lock);
743        assert!(
744            !lock_path.exists(),
745            "dropping the recovered lock should remove the lock file"
746        );
747    }
748
749    #[test]
750    fn acquire_lock_replaces_legacy_stale_lock() {
751        let temp = tempdir().expect("tempdir should exist");
752        let lock_path = temp.path().join(".install.lock");
753        std::fs::write(&lock_path, "").expect("legacy lock file should write");
754
755        let lock = acquire_lock(&lock_path, Duration::ZERO, Duration::ZERO)
756            .expect("legacy stale lock should recover");
757        let content = std::fs::read_to_string(&lock_path).expect("lock file should be readable");
758
759        assert!(
760            content.contains(&format!("pid={}\n", std::process::id())),
761            "lock file should contain current process owner: {content}"
762        );
763
764        drop(lock);
765        assert!(
766            !lock_path.exists(),
767            "dropping the recovered lock should remove the lock file"
768        );
769    }
770
771    #[cfg(unix)]
772    #[test]
773    fn acquire_lock_keeps_live_owner_lock() {
774        let temp = tempdir().expect("tempdir should exist");
775        let lock_path = temp.path().join(".install.lock");
776        std::fs::write(&lock_path, format!("pid={}\n", std::process::id()))
777            .expect("lock file should write");
778
779        let error = match acquire_lock(&lock_path, Duration::ZERO, Duration::ZERO) {
780            Ok(_) => panic!("live owner lock should not be replaced"),
781            Err(error) => error,
782        };
783
784        assert!(
785            error.to_string().contains("timed out"),
786            "unexpected error: {error}"
787        );
788        assert!(
789            lock_path.exists(),
790            "live owner lock should remain for its owner"
791        );
792    }
793
794    #[test]
795    fn managed_download_rejects_non_https_non_localhost_urls() {
796        let Some(target) = zcashd_target_triple() else {
797            return;
798        };
799        let temp = tempdir().expect("tempdir should exist");
800        let manifest = ZcashdReleaseManifest {
801            schema_version: 1,
802            release_tag: "test-release".to_string(),
803            artifacts: vec![ZcashdReleaseArtifact {
804                target_triple: target.to_string(),
805                runtime_archive_url: "http://example.com/zcashd.tar.gz".to_string(),
806                runtime_archive_sha256:
807                    "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(),
808                runtime_archive_member_binary_path: "./bin/zcashd".to_string(),
809            }],
810        };
811
812        let error = resolve_managed_zcashd_binary_from_manifest(&manifest, temp.path())
813            .expect_err("non-https URL should be rejected");
814        assert!(
815            error.to_string().contains("https"),
816            "unexpected error: {error}"
817        );
818    }
819
820    #[test]
821    fn managed_resolver_downloads_and_installs_from_http_archive() {
822        let Some(target) = zcashd_target_triple() else {
823            return;
824        };
825
826        let temp = tempdir().expect("tempdir should exist");
827        let archive_path = temp.path().join("zcashd-compat.tar.gz");
828        let binary_contents = b"#!/bin/sh\necho zcashd-compat-test\n";
829
830        {
831            let file =
832                std::fs::File::create(&archive_path).expect("archive file should be created");
833            let encoder = GzEncoder::new(file, Compression::default());
834            let mut tar = Builder::new(encoder);
835
836            let mut header = tar::Header::new_gnu();
837            header.set_path("./bin/zcashd").expect("path should be set");
838            header.set_size(binary_contents.len() as u64);
839            header.set_mode(0o755);
840            header.set_cksum();
841            tar.append(&header, &binary_contents[..])
842                .expect("archive member should be appended");
843            tar.finish().expect("archive should finish");
844        }
845
846        let archive_sha256 = sha256_hex_file(&archive_path).expect("archive hash should compute");
847        let archive_bytes = std::fs::read(&archive_path).expect("archive bytes should be readable");
848
849        let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
850        let address = listener
851            .local_addr()
852            .expect("listener should have local address");
853        let payload = archive_bytes.clone();
854        let server = thread::spawn(move || {
855            let (mut stream, _) = listener.accept().expect("one client should connect");
856            let mut request_buf = [0u8; 1024];
857            let _ = stream.read(&mut request_buf);
858            let response = format!(
859                "HTTP/1.1 200 OK\r\nContent-Type: application/gzip\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
860                payload.len()
861            );
862            stream
863                .write_all(response.as_bytes())
864                .expect("response headers should write");
865            stream
866                .write_all(&payload)
867                .expect("response body should write");
868        });
869
870        let local_http_url = format!("http://{address}/zcashd-compat.tar.gz");
871        let artifact = ZcashdReleaseArtifact {
872            target_triple: target.to_string(),
873            runtime_archive_url: local_http_url,
874            runtime_archive_sha256: archive_sha256,
875            runtime_archive_member_binary_path: "./bin/zcashd".to_string(),
876        };
877        let manifest = ZcashdReleaseManifest {
878            schema_version: 1,
879            release_tag: "test-release".to_string(),
880            artifacts: vec![artifact],
881        };
882        let resolved = resolve_managed_zcashd_binary_from_manifest(&manifest, temp.path())
883            .expect("managed resolver should install zcashd");
884        let installed = std::fs::read(&resolved).expect("installed zcashd should be readable");
885        assert_eq!(installed, binary_contents);
886
887        server.join().expect("server thread should exit cleanly");
888    }
889}