Skip to main content

hardware_enclave/internal/core/
bin_discovery.rs

1// Copyright 2026 Jay Gowdy
2// SPDX-License-Identifier: MIT
3
4//! Trusted-binary discovery for enclaveapp consumers.
5//!
6//! Each enclaveapp consumer (sshenc, awsenc, …) typically ships a
7//! small set of binaries that need to locate each other on disk —
8//! e.g. `sshenc` spawning `sshenc-agent`, or a similar helper-tool
9//! pairing in another app. Rather than each app reimplementing the
10//! "find a sibling binary in a trusted install location" search,
11//! this module centralizes the logic and parameterizes it by the
12//! consuming app's name so the per-platform install-dir conventions
13//! can flex:
14//!
15//! - Unix: `~/.local/bin`, `/opt/homebrew/bin`,
16//!   `/usr/local/bin`, `/usr/bin`, then current-exe sibling as fallback.
17//!   **`~/.cargo/bin` is deliberately excluded** — unsigned development
18//!   builds must not be trusted for operations accessing hardware security
19//!   modules (Secure Enclave, TPM).
20//! - Windows: current-exe sibling,
21//!   `%LOCALAPPDATA%\<app_name>\bin`,
22//!   `%ProgramFiles%\<app_name>`,
23//!   `%ProgramFiles%\<app_name>\bin`, and the 32-bit equivalents.
24//!
25//! PATH is **deliberately excluded** — an attacker who controls the
26//! user's PATH shouldn't be able to smuggle a fake daemon binary
27//! into a position where enclaveapp launches it.
28//!
29//! ## Stable vs. canonical paths
30//!
31//! Candidates are validated via `canonicalize()` (symlinks resolved) to
32//! confirm they are real executables, but the **original pre-canonicalized
33//! path is returned**. This ensures callers receive a stable, upgrade-
34//! surviving path (e.g. `/opt/homebrew/bin/sshenc`) rather than a
35//! version-pinned Cellar path (e.g. `.../Cellar/sshenc/0.6.74/…/sshenc`)
36//! that breaks when Homebrew removes the old version. Stable dirs are
37//! searched before the current-exe sibling so that production Homebrew
38//! or system installs are preferred over versioned side-by-side directories.
39#![allow(dead_code, unused_imports, unused_qualifications, unreachable_patterns)]
40
41use std::path::PathBuf;
42
43#[cfg(windows)]
44use std::io::Read;
45
46/// Inputs to [`find_trusted_binary_with_context`]. Parameterized so
47/// callers can inject synthetic paths in tests (and so the real
48/// call site stays a thin wrapper over [`std::env::current_exe`] +
49/// [`dirs::home_dir`]).
50#[derive(Debug, Clone, Default)]
51pub struct BinaryDiscoveryContext {
52    pub current_exe: Option<PathBuf>,
53    #[cfg(not(windows))]
54    pub home_dir: Option<PathBuf>,
55    #[cfg(windows)]
56    pub local_app_data: Option<PathBuf>,
57    #[cfg(windows)]
58    pub program_files: Option<PathBuf>,
59    #[cfg(windows)]
60    pub program_files_x86: Option<PathBuf>,
61}
62
63impl BinaryDiscoveryContext {
64    /// Capture the real process's discovery context at the moment
65    /// of the call. The returned value is a snapshot — subsequent
66    /// env changes don't affect it.
67    #[must_use]
68    pub fn current() -> Self {
69        Self {
70            current_exe: std::env::current_exe().ok(),
71            #[cfg(not(windows))]
72            home_dir: dirs::home_dir(),
73            #[cfg(windows)]
74            local_app_data: std::env::var_os("LOCALAPPDATA").map(PathBuf::from),
75            #[cfg(windows)]
76            program_files: std::env::var_os("ProgramFiles").map(PathBuf::from),
77            #[cfg(windows)]
78            program_files_x86: std::env::var_os("ProgramFiles(x86)").map(PathBuf::from),
79        }
80    }
81}
82
83fn candidate_dirs(context: &BinaryDiscoveryContext, app_name: &str) -> Vec<PathBuf> {
84    let mut dirs = Vec::new();
85
86    #[cfg(windows)]
87    {
88        // On Windows keep current-exe sibling first: Scoop's `current`
89        // junction and MSI installs put sibling binaries next to each other.
90        if let Some(current_exe) = context.current_exe.as_ref() {
91            if let Some(parent) = current_exe.parent() {
92                dirs.push(parent.to_path_buf());
93            }
94        }
95        if let Some(local_app_data) = context.local_app_data.as_ref() {
96            dirs.push(local_app_data.join(app_name).join("bin"));
97        }
98        if let Some(program_files) = context.program_files.as_ref() {
99            dirs.push(program_files.join(app_name));
100            dirs.push(program_files.join(app_name).join("bin"));
101        }
102        if let Some(program_files_x86) = context.program_files_x86.as_ref() {
103            dirs.push(program_files_x86.join(app_name));
104            dirs.push(program_files_x86.join(app_name).join("bin"));
105        }
106    }
107
108    #[cfg(not(windows))]
109    {
110        let _ = app_name;
111        // Stable dirs first so callers receive an upgrade-surviving symlink
112        // path (e.g. /opt/homebrew/bin/sshenc) rather than a versioned Cellar
113        // path that breaks when Homebrew removes the old version.
114        //
115        // SECURITY: ~/.cargo/bin is deliberately excluded. Development builds
116        // (cargo build, cargo install) must not be trusted for operations that
117        // access hardware security modules (Secure Enclave, TPM). Unsigned
118        // binaries from ~/.cargo/bin can poison keychain state and cause
119        // authentication failures. Production installs should use package
120        // managers (Homebrew, apt, etc.) or ~/.local/bin for manual installs.
121        if let Some(home_dir) = context.home_dir.as_ref() {
122            dirs.push(home_dir.join(".local").join("bin"));
123        }
124        dirs.push(PathBuf::from("/opt/homebrew/bin"));
125        dirs.push(PathBuf::from("/usr/local/bin"));
126        dirs.push(PathBuf::from("/usr/bin"));
127        // current-exe sibling last: fallback for dev builds or installs in
128        // non-standard locations not covered by the dirs above.
129        if let Some(current_exe) = context.current_exe.as_ref() {
130            if let Some(parent) = current_exe.parent() {
131                dirs.push(parent.to_path_buf());
132            }
133        }
134    }
135
136    let mut unique_dirs = Vec::new();
137    for dir in dirs {
138        if !unique_dirs.iter().any(|existing| existing == &dir) {
139            unique_dirs.push(dir);
140        }
141    }
142    unique_dirs
143}
144
145/// Look for `binary_name` inside the install directories of app
146/// `app_name`, in the order they're typically shipped. Returns the
147/// canonical path of the first match that exists and looks
148/// executable, or `None` if no candidate qualifies.
149///
150/// The `app_name` parameter only affects Windows paths
151/// (`%ProgramFiles%\<app_name>\…`); on Unix the search set is
152/// fixed to the common install locations and `app_name` is unused.
153#[must_use]
154pub fn find_trusted_binary_with_context(
155    binary_name: &str,
156    app_name: &str,
157    context: &BinaryDiscoveryContext,
158) -> Option<PathBuf> {
159    candidate_dirs(context, app_name)
160        .into_iter()
161        .map(|dir| dir.join(binary_name))
162        .find_map(|candidate| resolve_trusted_binary_candidate(&candidate))
163}
164
165/// Convenience wrapper: discover `binary_name` using the current
166/// process's environment. Every enclaveapp consumer should prefer
167/// this over PATH lookups — an attacker who controls the user's
168/// PATH should not be able to redirect enclaveapp's launch of its
169/// own daemons.
170#[must_use]
171pub fn find_trusted_binary(binary_name: &str, app_name: &str) -> Option<PathBuf> {
172    find_trusted_binary_with_context(binary_name, app_name, &BinaryDiscoveryContext::current())
173}
174
175/// Validate a candidate path by resolving it canonically, then return
176/// the original (pre-canonicalized) path if it passes. Validation uses
177/// the canonical form to confirm the binary really exists and is
178/// executable; returning the original path ensures callers get a
179/// stable, symlink-bearing path (e.g. `/opt/homebrew/bin/sshenc`)
180/// that survives Homebrew upgrades rather than a pinned Cellar path.
181fn resolve_trusted_binary_candidate(path: &std::path::Path) -> Option<PathBuf> {
182    let resolved = path.canonicalize().ok()?;
183    if resolved.is_file() && candidate_looks_executable(&resolved) {
184        Some(path.to_path_buf())
185    } else {
186        None
187    }
188}
189
190#[cfg(unix)]
191fn candidate_looks_executable(path: &std::path::Path) -> bool {
192    use std::os::unix::fs::PermissionsExt;
193    std::fs::metadata(path)
194        .map(|metadata| metadata.permissions().mode() & 0o111 != 0)
195        .unwrap_or(false)
196}
197
198#[cfg(windows)]
199fn candidate_looks_executable(path: &std::path::Path) -> bool {
200    path.extension()
201        .is_some_and(|extension| extension.eq_ignore_ascii_case("exe"))
202        && has_pe_header(path)
203}
204
205#[cfg(windows)]
206fn has_pe_header(path: &std::path::Path) -> bool {
207    let Ok(mut file) = std::fs::File::open(path) else {
208        return false;
209    };
210    let mut header = [0_u8; 2];
211    file.read_exact(&mut header).is_ok() && header == *b"MZ"
212}
213
214#[cfg(test)]
215#[allow(clippy::unwrap_used, clippy::panic)]
216mod tests {
217    use super::*;
218    use std::sync::atomic::{AtomicU64, Ordering};
219
220    #[cfg(unix)]
221    use std::os::unix::fs::PermissionsExt;
222
223    static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
224
225    fn test_dir(name: &str) -> PathBuf {
226        let id = TEST_COUNTER.fetch_add(1, Ordering::SeqCst);
227        let dir = std::env::temp_dir().join(format!(
228            "enclaveapp-bin-discovery-test-{}-{}-{name}",
229            std::process::id(),
230            id
231        ));
232        let _unused = std::fs::remove_dir_all(&dir);
233        std::fs::create_dir_all(&dir).unwrap();
234        dir
235    }
236
237    fn write_test_binary(path: &std::path::Path) {
238        #[cfg(unix)]
239        {
240            std::fs::write(path, b"#!/bin/sh\nexit 0\n").unwrap();
241            let mut permissions = std::fs::metadata(path).unwrap().permissions();
242            permissions.set_mode(0o755);
243            std::fs::set_permissions(path, permissions).unwrap();
244        }
245        #[cfg(windows)]
246        {
247            std::fs::write(path, b"MZtest-binary").unwrap();
248        }
249    }
250
251    #[test]
252    fn finds_current_exe_sibling_as_fallback() {
253        // When the binary only exists next to current_exe (not in any of
254        // the stable dirs), the current-exe sibling is found as a fallback
255        // and the original (non-canonicalized) path is returned.
256        let root = test_dir("sibling");
257        let bin_dir = root.join("bin");
258        std::fs::create_dir_all(&bin_dir).unwrap();
259        #[cfg(not(windows))]
260        let (current_exe, sibling) = (bin_dir.join("myapp"), bin_dir.join("myapp-helper"));
261        #[cfg(windows)]
262        let (current_exe, sibling) = (bin_dir.join("myapp.exe"), bin_dir.join("myapp-helper.exe"));
263        write_test_binary(&current_exe);
264        write_test_binary(&sibling);
265
266        let context = BinaryDiscoveryContext {
267            current_exe: Some(current_exe),
268            #[cfg(not(windows))]
269            home_dir: Some(root.join("home")),
270            #[cfg(windows)]
271            local_app_data: Some(root.join("LocalAppData")),
272            #[cfg(windows)]
273            program_files: Some(root.join("ProgramFiles")),
274            #[cfg(windows)]
275            program_files_x86: Some(root.join("ProgramFilesX86")),
276        };
277
278        #[cfg(not(windows))]
279        let binary_name = "myapp-helper";
280        #[cfg(windows)]
281        let binary_name = "myapp-helper.exe";
282
283        let found =
284            find_trusted_binary_with_context(binary_name, "myapp", &context).expect("found");
285        // Returns the original candidate path, not the canonicalized form.
286        assert_eq!(found, sibling);
287
288        std::fs::remove_dir_all(&root).unwrap();
289    }
290
291    #[test]
292    fn app_name_parameterizes_windows_install_dir() {
293        // On Windows, two apps can ship side-by-side under
294        // `%ProgramFiles%\<app_name>`. Verify lookup picks the one
295        // matching the supplied `app_name`.
296        #[cfg(windows)]
297        {
298            let root = test_dir("app-name");
299            let pf_a = root.join("pf").join("appA");
300            let pf_b = root.join("pf").join("appB");
301            std::fs::create_dir_all(&pf_a).unwrap();
302            std::fs::create_dir_all(&pf_b).unwrap();
303            let bin_a = pf_a.join("helper.exe");
304            let bin_b = pf_b.join("helper.exe");
305            write_test_binary(&bin_a);
306            write_test_binary(&bin_b);
307
308            let context = BinaryDiscoveryContext {
309                current_exe: None,
310                local_app_data: None,
311                program_files: Some(root.join("pf")),
312                program_files_x86: None,
313            };
314
315            let a_found = find_trusted_binary_with_context("helper.exe", "appA", &context)
316                .expect("find appA's helper");
317            assert_eq!(a_found, bin_a);
318
319            let b_found = find_trusted_binary_with_context("helper.exe", "appB", &context)
320                .expect("find appB's helper");
321            assert_eq!(b_found, bin_b);
322
323            std::fs::remove_dir_all(&root).unwrap();
324        }
325        // On Unix, `app_name` is unused — just confirm the call
326        // still succeeds with any value.
327        #[cfg(not(windows))]
328        {
329            drop(find_trusted_binary("some-binary-name", "my-app"));
330        }
331    }
332
333    #[cfg(not(windows))]
334    #[test]
335    fn candidate_dirs_no_home_no_exe_includes_fixed_unix_dirs() {
336        let ctx = BinaryDiscoveryContext {
337            current_exe: None,
338            home_dir: None,
339        };
340        let dirs = candidate_dirs(&ctx, "myapp");
341        assert!(dirs.contains(&PathBuf::from("/opt/homebrew/bin")));
342        assert!(dirs.contains(&PathBuf::from("/usr/local/bin")));
343        assert!(dirs.contains(&PathBuf::from("/usr/bin")));
344    }
345
346    #[cfg(not(windows))]
347    #[test]
348    fn candidate_dirs_with_home_prepends_user_dirs() {
349        let ctx = BinaryDiscoveryContext {
350            current_exe: None,
351            home_dir: Some(PathBuf::from("/home/user")),
352        };
353        let dirs = candidate_dirs(&ctx, "myapp");
354        assert!(dirs.contains(&PathBuf::from("/home/user/.local/bin")));
355        assert!(!dirs.contains(&PathBuf::from("/home/user/.cargo/bin")));
356        let local_bin_pos = dirs
357            .iter()
358            .position(|d| d == &PathBuf::from("/home/user/.local/bin"))
359            .unwrap();
360        let homebrew_pos = dirs
361            .iter()
362            .position(|d| d == &PathBuf::from("/opt/homebrew/bin"))
363            .unwrap();
364        assert!(local_bin_pos < homebrew_pos);
365    }
366
367    #[cfg(not(windows))]
368    #[test]
369    fn candidate_dirs_exe_sibling_appended_as_fallback() {
370        let ctx = BinaryDiscoveryContext {
371            current_exe: Some(PathBuf::from("/opt/myapp/bin/myapp")),
372            home_dir: None,
373        };
374        let dirs = candidate_dirs(&ctx, "myapp");
375        assert!(dirs.contains(&PathBuf::from("/opt/myapp/bin")));
376        let usr_bin_pos = dirs
377            .iter()
378            .position(|d| d == &PathBuf::from("/usr/bin"))
379            .unwrap();
380        let sibling_pos = dirs
381            .iter()
382            .position(|d| d == &PathBuf::from("/opt/myapp/bin"))
383            .unwrap();
384        assert!(sibling_pos > usr_bin_pos);
385    }
386
387    #[cfg(not(windows))]
388    #[test]
389    fn candidate_dirs_deduplicates_exe_sibling_matching_stable_dir() {
390        let ctx = BinaryDiscoveryContext {
391            current_exe: Some(PathBuf::from("/usr/local/bin/myapp")),
392            home_dir: None,
393        };
394        let dirs = candidate_dirs(&ctx, "myapp");
395        let count = dirs
396            .iter()
397            .filter(|d| *d == &PathBuf::from("/usr/local/bin"))
398            .count();
399        assert_eq!(count, 1);
400    }
401
402    #[cfg(not(windows))]
403    #[test]
404    fn find_trusted_binary_with_context_returns_none_for_absent_binary() {
405        let root = test_dir("no-match");
406        let ctx = BinaryDiscoveryContext {
407            current_exe: None,
408            home_dir: Some(root.clone()),
409        };
410        let result = find_trusted_binary_with_context("definitely-nonexistent-zz99", "myapp", &ctx);
411        assert!(result.is_none());
412        std::fs::remove_dir_all(&root).unwrap();
413    }
414
415    #[cfg(not(windows))]
416    #[test]
417    fn binary_discovery_context_default_all_none() {
418        let ctx = BinaryDiscoveryContext::default();
419        assert!(ctx.current_exe.is_none());
420        assert!(ctx.home_dir.is_none());
421    }
422
423    #[cfg(unix)]
424    #[test]
425    fn non_executable_file_is_not_discovered() {
426        let root = test_dir("non-exec");
427        let bin_dir = root.join("bin");
428        std::fs::create_dir_all(&bin_dir).unwrap();
429        let non_exec = bin_dir.join("myapp-helper");
430        // Write a regular file without execute permission
431        std::fs::write(&non_exec, b"#!/bin/sh\n").unwrap();
432        let mut perms = std::fs::metadata(&non_exec).unwrap().permissions();
433        perms.set_mode(0o644);
434        std::fs::set_permissions(&non_exec, perms).unwrap();
435
436        let current_exe = bin_dir.join("myapp");
437        write_test_binary(&current_exe);
438
439        let ctx = BinaryDiscoveryContext {
440            current_exe: Some(current_exe),
441            home_dir: Some(root.join("home")),
442        };
443        let result = find_trusted_binary_with_context("myapp-helper", "myapp", &ctx);
444        assert!(result.is_none(), "non-executable should not be found");
445        std::fs::remove_dir_all(&root).unwrap();
446    }
447
448    #[cfg(not(windows))]
449    #[test]
450    fn find_trusted_binary_with_context_prefers_stable_dir_over_exe_sibling() {
451        let root = test_dir("priority");
452        let stable = root.join(".local").join("bin");
453        let sibling_dir = root.join("sibling");
454        std::fs::create_dir_all(&stable).unwrap();
455        std::fs::create_dir_all(&sibling_dir).unwrap();
456
457        let stable_bin = stable.join("mytool");
458        let sibling_bin = sibling_dir.join("mytool");
459        write_test_binary(&stable_bin);
460        write_test_binary(&sibling_bin);
461
462        let ctx = BinaryDiscoveryContext {
463            current_exe: Some(sibling_dir.join("myapp")),
464            home_dir: Some(root.clone()),
465        };
466        let found = find_trusted_binary_with_context("mytool", "myapp", &ctx).unwrap();
467        assert_eq!(found, stable_bin);
468        std::fs::remove_dir_all(&root).unwrap();
469    }
470}