Skip to main content

via/
daemon.rs

1#[derive(Clone, serde::Deserialize, serde::Serialize)]
2pub struct AllowedOnePasswordRef {
3    pub id: String,
4    pub reference: String,
5}
6
7#[derive(Clone, Copy, serde::Deserialize, serde::Serialize)]
8#[serde(rename_all = "snake_case")]
9pub enum OAuthTokenMode {
10    Cached,
11    Refresh,
12}
13
14#[cfg(unix)]
15mod imp {
16    use std::collections::HashMap;
17    use std::env;
18    use std::fs;
19    use std::io::{self, BufRead, BufReader, Write};
20    #[cfg(target_os = "macos")]
21    use std::os::unix::ffi::OsStringExt;
22    #[cfg(any(target_os = "linux", target_os = "macos"))]
23    use std::os::unix::fs::MetadataExt;
24    use std::os::unix::fs::PermissionsExt;
25    #[cfg(any(target_os = "linux", target_os = "macos"))]
26    use std::os::unix::io::AsRawFd;
27    use std::os::unix::net::{UnixListener, UnixStream};
28    use std::os::unix::process::CommandExt;
29    use std::path::{Path, PathBuf};
30    use std::process::{Command, Output, Stdio};
31    use std::thread;
32    use std::time::{Duration, Instant};
33
34    use reqwest::blocking::Client;
35    use serde::{Deserialize, Serialize};
36
37    use crate::error::ViaError;
38    use crate::redaction::Redactor;
39    use crate::secrets::SecretValue;
40
41    const CONNECT_WAIT: Duration = Duration::from_secs(5);
42    const CONNECT_POLL: Duration = Duration::from_millis(50);
43    const IDLE_TIMEOUT: Duration = Duration::from_secs(15 * 60);
44
45    pub fn resolve_onepassword_secret(
46        config_hash: &str,
47        ref_id: &str,
48        ttl_seconds: u64,
49    ) -> Result<SecretValue, ViaError> {
50        let span = crate::timing::span("1password daemon resolve");
51        let response = match request_with_autostart(DaemonRequest::Resolve {
52            config_hash: config_hash.to_owned(),
53            ref_id: ref_id.to_owned(),
54            ttl_seconds,
55        }) {
56            Ok(response) => {
57                span.finish(format!(
58                    "cache={}",
59                    response.cache.as_deref().unwrap_or("unknown")
60                ));
61                response
62            }
63            Err(error) => {
64                span.finish("failed");
65                return Err(error);
66            }
67        };
68
69        if response.ok {
70            return response
71                .value
72                .ok_or_else(|| ViaError::InvalidConfig("daemon returned no secret".to_owned()));
73        }
74
75        Err(ViaError::ExternalCommandFailed {
76            program: "via daemon".to_owned(),
77            status: None,
78            stderr: response
79                .error
80                .unwrap_or_else(|| "failed to resolve secret".to_owned()),
81        })
82    }
83
84    pub fn register_onepassword_refs(
85        config_hash: &str,
86        account: Option<&str>,
87        refs: Vec<super::AllowedOnePasswordRef>,
88    ) -> Result<(), ViaError> {
89        let response = request_with_autostart(DaemonRequest::Register {
90            config_hash: config_hash.to_owned(),
91            account: account.map(str::to_owned),
92            refs,
93        })?;
94        if response.ok {
95            Ok(())
96        } else {
97            Err(daemon_response_error(
98                response,
99                "failed to register 1Password references",
100            ))
101        }
102    }
103
104    pub fn oauth_access_token(
105        credential: &str,
106        mode: super::OAuthTokenMode,
107    ) -> Result<SecretValue, ViaError> {
108        let span = crate::timing::span("oauth daemon access token");
109        let response = match request_with_autostart(DaemonRequest::OAuthAccessToken {
110            credential: credential.to_owned(),
111            mode,
112        }) {
113            Ok(response) => response,
114            Err(error) => {
115                span.finish("failed");
116                return Err(error);
117            }
118        };
119        span.finish(format!(
120            "cache={}",
121            response.cache.as_deref().unwrap_or("unknown")
122        ));
123
124        oauth_access_token_from_response(response)
125    }
126
127    fn oauth_access_token_from_response(
128        response: ClientDaemonResponse,
129    ) -> Result<SecretValue, ViaError> {
130        if response.ok {
131            return response.value.ok_or_else(|| {
132                ViaError::InvalidConfig("daemon returned no OAuth access token".to_owned())
133            });
134        }
135
136        Err(daemon_response_error(
137            response,
138            "failed to resolve OAuth access token",
139        ))
140    }
141
142    pub fn serve() -> Result<(), ViaError> {
143        let path = socket_path()?;
144        let listener = bind_listener(&path)?;
145        run_server(listener, &path)
146    }
147
148    fn bind_listener(path: &Path) -> Result<UnixListener, ViaError> {
149        prepare_socket_parent(path)?;
150        remove_stale_socket(path)?;
151
152        let listener = UnixListener::bind(path)?;
153        fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
154        listener.set_nonblocking(true)?;
155        Ok(listener)
156    }
157
158    fn remove_stale_socket(path: &Path) -> Result<(), ViaError> {
159        if path.exists() {
160            if UnixStream::connect(path).is_ok() {
161                return Err(ViaError::InvalidConfig(
162                    "via daemon is already running".to_owned(),
163                ));
164            }
165            fs::remove_file(path)?;
166        }
167
168        Ok(())
169    }
170
171    fn run_server(listener: UnixListener, path: &Path) -> Result<(), ViaError> {
172        let mut state = DaemonState::default();
173        let expected_client = daemon_executable_identity()?;
174        let mut last_activity = Instant::now();
175        loop {
176            match next_server_event(&listener, &mut last_activity)? {
177                ServerEvent::Connection(stream) => {
178                    let action = handle_stream(stream, &mut state, expected_client.as_ref());
179                    if action == DaemonAction::Stop {
180                        break;
181                    }
182                }
183                ServerEvent::NoConnection => {}
184                ServerEvent::IdleTimeout => break,
185            }
186        }
187
188        let _ = fs::remove_file(path);
189        Ok(())
190    }
191
192    fn next_server_event(
193        listener: &UnixListener,
194        last_activity: &mut Instant,
195    ) -> Result<ServerEvent, ViaError> {
196        match listener.accept() {
197            Ok((stream, _)) => {
198                *last_activity = Instant::now();
199                Ok(ServerEvent::Connection(stream))
200            }
201            Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
202                wait_for_connection(last_activity)
203            }
204            Err(error) => Err(error.into()),
205        }
206    }
207
208    fn wait_for_connection(last_activity: &Instant) -> Result<ServerEvent, ViaError> {
209        if last_activity.elapsed() >= IDLE_TIMEOUT {
210            Ok(ServerEvent::IdleTimeout)
211        } else {
212            thread::sleep(CONNECT_POLL);
213            Ok(ServerEvent::NoConnection)
214        }
215    }
216
217    pub fn status() -> Result<(), ViaError> {
218        control_request(DaemonRequest::Status, print_status, "status failed")
219    }
220
221    pub fn clear() -> Result<(), ViaError> {
222        control_request(
223            DaemonRequest::Clear,
224            |_| println!("via daemon: cache cleared"),
225            "clear failed",
226        )
227    }
228
229    pub fn stop() -> Result<(), ViaError> {
230        control_request(
231            DaemonRequest::Stop,
232            |_| println!("via daemon: stopped"),
233            "stop failed",
234        )
235    }
236
237    fn control_request(
238        daemon_request: DaemonRequest,
239        print_success: impl FnOnce(&ClientDaemonResponse),
240        fallback_error: &str,
241    ) -> Result<(), ViaError> {
242        match request(daemon_request) {
243            Ok(response) if response.ok => {
244                print_success(&response);
245                Ok(())
246            }
247            Ok(response) => Err(daemon_response_error(response, fallback_error)),
248            Err(error) if daemon_unavailable(&error) => {
249                println!("via daemon: stopped");
250                Ok(())
251            }
252            Err(error) => Err(error),
253        }
254    }
255
256    fn print_status(response: &ClientDaemonResponse) {
257        println!("via daemon: running");
258        println!("cached entries: {}", response.entries.unwrap_or(0));
259    }
260
261    fn daemon_response_error(response: ClientDaemonResponse, fallback: &str) -> ViaError {
262        ViaError::ExternalCommandFailed {
263            program: "via daemon".to_owned(),
264            status: None,
265            stderr: response.error.unwrap_or_else(|| fallback.to_owned()),
266        }
267    }
268
269    fn request_with_autostart(
270        daemon_request: DaemonRequest,
271    ) -> Result<ClientDaemonResponse, ViaError> {
272        match request(daemon_request.clone()) {
273            Ok(response) => Ok(response),
274            Err(error) if daemon_unavailable(&error) => {
275                start_daemon()?;
276                request(daemon_request)
277            }
278            Err(error) => Err(error),
279        }
280    }
281
282    fn request(request: DaemonRequest) -> Result<ClientDaemonResponse, ViaError> {
283        let path = socket_path()?;
284        let mut stream = UnixStream::connect(path)?;
285        let raw = SecretValue::new(serde_json::to_string(&request)?);
286        stream.write_all(raw.expose().as_bytes())?;
287        stream.write_all(b"\n")?;
288
289        let mut line = String::new();
290        BufReader::new(stream).read_line(&mut line)?;
291        if line.trim().is_empty() {
292            return Err(ViaError::InvalidConfig(
293                "daemon returned an empty response".to_owned(),
294            ));
295        }
296        let line = SecretValue::new(line);
297
298        serde_json::from_str(line.expose()).map_err(Into::into)
299    }
300
301    fn start_daemon() -> Result<(), ViaError> {
302        let exe = env::current_exe()?;
303        let path = socket_path()?;
304        let mut attempts = Vec::new();
305
306        match start_daemon_with_service_manager(&exe, &path) {
307            Ok(()) if wait_for_daemon(&path) => return Ok(()),
308            Ok(()) => attempts.push(StartAttempt {
309                name: service_manager_name(),
310                error: "started but socket did not become ready".to_owned(),
311            }),
312            Err(error) => attempts.push(error),
313        }
314
315        match start_daemon_with_direct_spawn(&exe, &path) {
316            Ok(()) if wait_for_daemon(&path) => return Ok(()),
317            Ok(()) => attempts.push(StartAttempt {
318                name: "direct spawn",
319                error: "started but socket did not become ready".to_owned(),
320            }),
321            Err(error) => attempts.push(error),
322        }
323
324        Err(ViaError::InvalidConfig(format!(
325            "timed out waiting for via daemon to start ({})",
326            format_start_attempts(&attempts)
327        )))
328    }
329
330    fn wait_for_daemon(path: &Path) -> bool {
331        let started = Instant::now();
332        while started.elapsed() < CONNECT_WAIT {
333            if UnixStream::connect(path).is_ok() {
334                return true;
335            }
336            thread::sleep(CONNECT_POLL);
337        }
338
339        false
340    }
341
342    fn start_daemon_with_direct_spawn(exe: &Path, path: &Path) -> Result<(), StartAttempt> {
343        let mut command = daemon_command(exe, path);
344        command.process_group(0);
345        command.spawn().map(|_| ()).map_err(|error| StartAttempt {
346            name: "direct spawn",
347            error: error.to_string(),
348        })
349    }
350
351    fn daemon_command(exe: &Path, path: &Path) -> Command {
352        let mut command = Command::new(exe);
353        command
354            .arg("daemon")
355            .arg("serve")
356            .stdin(Stdio::null())
357            .stdout(Stdio::null());
358        if crate::timing::enabled() {
359            command.stderr(Stdio::inherit());
360        } else {
361            command.stderr(Stdio::null());
362        }
363        for (name, value) in daemon_environment(path) {
364            command.env(name, value);
365        }
366        command
367    }
368
369    fn daemon_environment(path: &Path) -> Vec<(String, String)> {
370        let mut values = vec![
371            (
372                "VIA_DAEMON_SOCKET".to_owned(),
373                path.to_string_lossy().into_owned(),
374            ),
375            (
376                "OP_BIOMETRIC_UNLOCK_ENABLED".to_owned(),
377                env::var("OP_BIOMETRIC_UNLOCK_ENABLED").unwrap_or_else(|_| "true".to_owned()),
378            ),
379        ];
380        for name in [
381            "PATH",
382            "HOME",
383            "XDG_RUNTIME_DIR",
384            "DBUS_SESSION_BUS_ADDRESS",
385            "DISPLAY",
386            "WAYLAND_DISPLAY",
387            "XAUTHORITY",
388        ] {
389            if let Ok(value) = env::var(name) {
390                if !value.is_empty() {
391                    values.push((name.to_owned(), value));
392                }
393            }
394        }
395        values
396    }
397
398    #[cfg(target_os = "linux")]
399    fn service_manager_name() -> &'static str {
400        "systemd user service"
401    }
402
403    #[cfg(target_os = "macos")]
404    fn service_manager_name() -> &'static str {
405        "launchd user agent"
406    }
407
408    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
409    fn service_manager_name() -> &'static str {
410        "user service manager"
411    }
412
413    #[cfg(target_os = "linux")]
414    fn start_daemon_with_service_manager(exe: &Path, path: &Path) -> Result<(), StartAttempt> {
415        let unit = format!("via-daemon-{}", socket_path_id(path));
416        let mut command = Command::new("systemd-run");
417        command
418            .arg("--user")
419            .arg("--collect")
420            .arg("--quiet")
421            .arg("--unit")
422            .arg(unit)
423            .arg("--description")
424            .arg("via daemon");
425        for (name, value) in daemon_environment(path) {
426            command.arg("--setenv").arg(format!("{name}={value}"));
427        }
428        command.arg(exe).arg("daemon").arg("serve");
429
430        run_start_command("systemd-run", command).map_err(|error| StartAttempt {
431            name: service_manager_name(),
432            error,
433        })
434    }
435
436    #[cfg(target_os = "macos")]
437    fn start_daemon_with_service_manager(exe: &Path, path: &Path) -> Result<(), StartAttempt> {
438        let label = format!("dev.tee8z.via.daemon.{}", socket_path_id(path));
439        let plist_path = launch_agent_path(&label).map_err(|error| StartAttempt {
440            name: service_manager_name(),
441            error,
442        })?;
443        write_launch_agent_plist(&plist_path, &label, exe, path).map_err(|error| StartAttempt {
444            name: service_manager_name(),
445            error,
446        })?;
447
448        let domain = format!("gui/{}", effective_user_id());
449        let target = format!("{domain}/{label}");
450        let mut bootstrap = Command::new("launchctl");
451        bootstrap.arg("bootstrap").arg(&domain).arg(&plist_path);
452        match run_start_command("launchctl bootstrap", bootstrap) {
453            Ok(()) => Ok(()),
454            Err(bootstrap_error) => {
455                let mut kickstart = Command::new("launchctl");
456                kickstart.arg("kickstart").arg("-k").arg(&target);
457                run_start_command("launchctl kickstart", kickstart).map_err(|kickstart_error| {
458                    StartAttempt {
459                        name: service_manager_name(),
460                        error: format!("{bootstrap_error}; {kickstart_error}"),
461                    }
462                })
463            }
464        }
465    }
466
467    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
468    fn start_daemon_with_service_manager(_exe: &Path, _path: &Path) -> Result<(), StartAttempt> {
469        Err(StartAttempt {
470            name: service_manager_name(),
471            error: "not supported on this platform".to_owned(),
472        })
473    }
474
475    #[cfg(target_os = "macos")]
476    fn launch_agent_path(label: &str) -> Result<PathBuf, String> {
477        let home = env_path("HOME").ok_or_else(|| "HOME is not set".to_owned())?;
478        let directory = home.join("Library").join("LaunchAgents");
479        fs::create_dir_all(&directory).map_err(|error| error.to_string())?;
480        Ok(directory.join(format!("{label}.plist")))
481    }
482
483    #[cfg(target_os = "macos")]
484    fn write_launch_agent_plist(
485        path: &Path,
486        label: &str,
487        exe: &Path,
488        socket_path: &Path,
489    ) -> Result<(), String> {
490        let mut environment = String::new();
491        for (name, value) in daemon_environment(socket_path) {
492            environment.push_str(&format!(
493                "<key>{}</key><string>{}</string>\n",
494                xml_escape(&name),
495                xml_escape(&value)
496            ));
497        }
498        let plist = format!(
499            r#"<?xml version="1.0" encoding="UTF-8"?>
500<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "https://www.apple.com/DTDs/PropertyList-1.0.dtd">
501<plist version="1.0">
502<dict>
503  <key>Label</key><string>{}</string>
504  <key>ProgramArguments</key>
505  <array>
506    <string>{}</string>
507    <string>daemon</string>
508    <string>serve</string>
509  </array>
510  <key>EnvironmentVariables</key>
511  <dict>
512    {}
513  </dict>
514  <key>RunAtLoad</key><true/>
515  <key>StandardOutPath</key><string>/dev/null</string>
516  <key>StandardErrorPath</key><string>/dev/null</string>
517</dict>
518</plist>
519"#,
520            xml_escape(label),
521            xml_escape(&exe.to_string_lossy()),
522            environment
523        );
524        fs::write(path, plist).map_err(|error| error.to_string())
525    }
526
527    #[cfg(target_os = "macos")]
528    fn xml_escape(value: &str) -> String {
529        value
530            .replace('&', "&amp;")
531            .replace('<', "&lt;")
532            .replace('>', "&gt;")
533            .replace('"', "&quot;")
534            .replace('\'', "&apos;")
535    }
536
537    #[cfg(target_os = "macos")]
538    fn effective_user_id() -> libc::uid_t {
539        unsafe { libc::geteuid() }
540    }
541
542    fn run_start_command(name: &'static str, mut command: Command) -> Result<(), String> {
543        let output = command.output().map_err(|error| {
544            if error.kind() == io::ErrorKind::NotFound {
545                format!("{name} not found")
546            } else {
547                format!("failed to start {name}: {error}")
548            }
549        })?;
550        if output.status.success() {
551            Ok(())
552        } else {
553            Err(format_command_failure(name, &output))
554        }
555    }
556
557    fn format_command_failure(name: &str, output: &Output) -> String {
558        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
559        if stderr.is_empty() {
560            format!("{name} exited with status {:?}", output.status.code())
561        } else {
562            format!(
563                "{name} exited with status {:?}: {stderr}",
564                output.status.code()
565            )
566        }
567    }
568
569    fn socket_path_id(path: &Path) -> String {
570        let mut hash = 0xcbf29ce484222325_u64;
571        for byte in path.to_string_lossy().as_bytes() {
572            hash ^= u64::from(*byte);
573            hash = hash.wrapping_mul(0x100000001b3);
574        }
575        format!("{hash:016x}")
576    }
577
578    struct StartAttempt {
579        name: &'static str,
580        error: String,
581    }
582
583    fn format_start_attempts(attempts: &[StartAttempt]) -> String {
584        attempts
585            .iter()
586            .map(|attempt| format!("{}: {}", attempt.name, attempt.error))
587            .collect::<Vec<_>>()
588            .join("; ")
589    }
590
591    fn handle_stream(
592        mut stream: UnixStream,
593        state: &mut DaemonState,
594        expected_client: Option<&ExecutableIdentity>,
595    ) -> DaemonAction {
596        let response = match verify_peer_executable(&stream, expected_client) {
597            Ok(()) => handle_verified_stream(&mut stream, state),
598            Err(error) => {
599                DaemonResponseInternal::error(format!("daemon client verification failed: {error}"))
600            }
601        };
602        let action = if response.stop {
603            DaemonAction::Stop
604        } else {
605            DaemonAction::Continue
606        };
607
608        write_daemon_response(&mut stream, response);
609
610        action
611    }
612
613    fn handle_verified_stream(
614        stream: &mut UnixStream,
615        state: &mut DaemonState,
616    ) -> DaemonResponseInternal {
617        let mut line = String::new();
618        let mut reader = BufReader::new(stream);
619        match reader.read_line(&mut line) {
620            Ok(_) => {
621                let line = SecretValue::new(line);
622                match serde_json::from_str(line.expose()) {
623                    Ok(request) => state.handle(request),
624                    Err(error) => {
625                        DaemonResponseInternal::error(format!("invalid daemon request: {error}"))
626                    }
627                }
628            }
629            Err(error) => {
630                DaemonResponseInternal::error(format!("failed to read daemon request: {error}"))
631            }
632        }
633    }
634
635    fn write_daemon_response(stream: &mut UnixStream, response: DaemonResponseInternal) {
636        if let Ok(raw) = serde_json::to_string(&response.into_public()) {
637            let raw = SecretValue::new(raw);
638            let _ = stream.write_all(raw.expose().as_bytes());
639            let _ = stream.write_all(b"\n");
640        }
641    }
642
643    #[derive(Clone, Deserialize, Serialize)]
644    #[serde(tag = "type", rename_all = "snake_case")]
645    enum DaemonRequest {
646        Register {
647            config_hash: String,
648            account: Option<String>,
649            refs: Vec<super::AllowedOnePasswordRef>,
650        },
651        Resolve {
652            config_hash: String,
653            ref_id: String,
654            ttl_seconds: u64,
655        },
656        OAuthAccessToken {
657            credential: String,
658            #[serde(default = "default_oauth_token_mode")]
659            mode: super::OAuthTokenMode,
660        },
661        Clear,
662        Status,
663        Stop,
664    }
665
666    fn default_oauth_token_mode() -> super::OAuthTokenMode {
667        super::OAuthTokenMode::Cached
668    }
669
670    #[derive(Default)]
671    struct DaemonState {
672        cache: HashMap<SecretCacheKey, SecretCacheEntry>,
673        oauth_cache: HashMap<String, crate::auth::oauth::CachedOAuthToken>,
674        registrations: HashMap<String, RegisteredConfig>,
675    }
676
677    impl DaemonState {
678        fn handle(&mut self, request: DaemonRequest) -> DaemonResponseInternal {
679            self.prune_expired();
680
681            match request {
682                DaemonRequest::Register {
683                    config_hash,
684                    account,
685                    refs,
686                } => self.register(config_hash, account, refs),
687                DaemonRequest::Resolve {
688                    config_hash,
689                    ref_id,
690                    ttl_seconds,
691                } => self.resolve(config_hash, ref_id, ttl_seconds),
692                DaemonRequest::OAuthAccessToken { credential, mode } => {
693                    self.oauth_access_token(&credential, mode)
694                }
695                DaemonRequest::Clear => {
696                    self.cache.clear();
697                    self.oauth_cache.clear();
698                    self.registrations.clear();
699                    DaemonResponseInternal::ok()
700                }
701                DaemonRequest::Status => {
702                    let mut response = DaemonResponseInternal::ok();
703                    response.entries = Some(self.cache.len() + self.oauth_cache.len());
704                    response
705                }
706                DaemonRequest::Stop => {
707                    let mut response = DaemonResponseInternal::ok();
708                    response.stop = true;
709                    response
710                }
711            }
712        }
713
714        fn register(
715            &mut self,
716            config_hash: String,
717            account: Option<String>,
718            refs: Vec<super::AllowedOnePasswordRef>,
719        ) -> DaemonResponseInternal {
720            if config_hash.trim().is_empty() {
721                return DaemonResponseInternal::error("config hash must not be empty");
722            }
723
724            let refs = match normalize_allowed_refs(refs) {
725                Ok(refs) => refs,
726                Err(error) => return DaemonResponseInternal::error(error),
727            };
728            self.registrations
729                .insert(config_hash, RegisteredConfig { account, refs });
730            DaemonResponseInternal::ok()
731        }
732
733        fn resolve(
734            &mut self,
735            config_hash: String,
736            ref_id: String,
737            ttl_seconds: u64,
738        ) -> DaemonResponseInternal {
739            let Some(secret) = self.allowed_secret(&config_hash, &ref_id) else {
740                return DaemonResponseInternal::error(
741                    "secret reference is not registered for this config",
742                );
743            };
744            let key = SecretCacheKey {
745                config_hash,
746                ref_id,
747            };
748            if let Some(entry) = self.cache.get(&key) {
749                let mut response = DaemonResponseInternal::ok();
750                response.value = Some(entry.value.clone());
751                response.cache = Some("hit".to_owned());
752                return response;
753            }
754
755            match op_read(secret.account.as_deref(), &secret.reference) {
756                Ok(value) => {
757                    let ttl = Duration::from_secs(ttl_seconds.max(1));
758                    let response_value = value.clone();
759                    self.cache.insert(
760                        key,
761                        SecretCacheEntry {
762                            value,
763                            expires_at: Instant::now() + ttl,
764                        },
765                    );
766                    let mut response = DaemonResponseInternal::ok();
767                    response.value = Some(response_value);
768                    response.cache = Some("miss".to_owned());
769                    response
770                }
771                Err(error) => DaemonResponseInternal::error(error),
772            }
773        }
774
775        fn allowed_secret(&self, config_hash: &str, ref_id: &str) -> Option<AllowedSecret> {
776            let registration = self.registrations.get(config_hash)?;
777            let reference = registration.refs.get(ref_id)?;
778            Some(AllowedSecret {
779                account: registration.account.clone(),
780                reference: reference.clone(),
781            })
782        }
783
784        fn oauth_access_token(
785            &mut self,
786            credential: &str,
787            mode: super::OAuthTokenMode,
788        ) -> DaemonResponseInternal {
789            let bundle = match crate::auth::oauth::CredentialBundle::parse(credential) {
790                Ok(bundle) => bundle,
791                Err(error) => return DaemonResponseInternal::error(error.to_string()),
792            };
793            let key = crate::auth::oauth::cache_key(&bundle);
794            let now = match crate::auth::oauth::unix_timestamp() {
795                Ok(now) => now,
796                Err(error) => return DaemonResponseInternal::error(error.to_string()),
797            };
798
799            if matches!(mode, super::OAuthTokenMode::Cached) {
800                if let Some(access_token) =
801                    crate::auth::oauth::cached_access_token(self.oauth_cache.get(&key), now)
802                {
803                    let mut response = DaemonResponseInternal::ok();
804                    response.value = Some(SecretValue::new(access_token));
805                    response.cache = Some("hit".to_owned());
806                    return response;
807                }
808            }
809
810            let cached = self.oauth_cache.get(&key).cloned();
811            let mut redactor = Redactor::new();
812            redactor.add(credential);
813            crate::auth::oauth::register_bundle_secrets(&bundle, &mut redactor);
814            crate::auth::oauth::register_cached_secrets(cached.as_ref(), &mut redactor);
815
816            let client = Client::new();
817            match crate::auth::oauth::exchange_access_token(
818                &client,
819                &bundle,
820                cached.as_ref(),
821                &mut redactor,
822            ) {
823                Ok(token) => {
824                    self.oauth_cache.insert(
825                        key,
826                        crate::auth::oauth::CachedOAuthToken {
827                            access_token: token.access_token.clone(),
828                            expires_at: token.expires_at,
829                            refresh_token: token.refresh_token.clone(),
830                        },
831                    );
832                    let mut response = DaemonResponseInternal::ok();
833                    response.value = Some(SecretValue::new(token.access_token));
834                    response.cache = Some("miss".to_owned());
835                    response
836                }
837                Err(error) => DaemonResponseInternal::error(redactor.redact(&error.to_string())),
838            }
839        }
840
841        fn prune_expired(&mut self) {
842            let now = Instant::now();
843            self.cache.retain(|_, entry| entry.expires_at > now);
844            if let Ok(now) = crate::auth::oauth::unix_timestamp() {
845                self.oauth_cache.retain(|_, entry| {
846                    entry.refresh_token.is_some()
847                        || crate::auth::oauth::cached_access_token(Some(entry), now).is_some()
848                });
849            }
850        }
851    }
852
853    #[derive(Hash, Eq, PartialEq)]
854    struct SecretCacheKey {
855        config_hash: String,
856        ref_id: String,
857    }
858
859    struct RegisteredConfig {
860        account: Option<String>,
861        refs: HashMap<String, String>,
862    }
863
864    struct AllowedSecret {
865        account: Option<String>,
866        reference: String,
867    }
868
869    struct SecretCacheEntry {
870        value: SecretValue,
871        expires_at: Instant,
872    }
873
874    fn normalize_allowed_refs(
875        refs: Vec<super::AllowedOnePasswordRef>,
876    ) -> Result<HashMap<String, String>, String> {
877        let mut normalized = HashMap::new();
878        for allowed_ref in refs {
879            if allowed_ref.id.trim().is_empty() {
880                return Err("registered secret reference id must not be empty".to_owned());
881            }
882            if !allowed_ref.reference.starts_with("op://") {
883                return Err("registered secret reference must start with op://".to_owned());
884            }
885            normalized.insert(allowed_ref.id, allowed_ref.reference);
886        }
887        Ok(normalized)
888    }
889
890    #[derive(Serialize)]
891    struct WireDaemonResponse {
892        ok: bool,
893        #[serde(
894            skip_serializing_if = "Option::is_none",
895            serialize_with = "serialize_secret_value_option"
896        )]
897        value: Option<SecretValue>,
898        #[serde(skip_serializing_if = "Option::is_none")]
899        cache: Option<String>,
900        #[serde(skip_serializing_if = "Option::is_none")]
901        entries: Option<usize>,
902        #[serde(skip_serializing_if = "Option::is_none")]
903        error: Option<String>,
904    }
905
906    #[derive(Deserialize)]
907    struct ClientDaemonResponse {
908        ok: bool,
909        value: Option<SecretValue>,
910        cache: Option<String>,
911        entries: Option<usize>,
912        error: Option<String>,
913    }
914
915    struct DaemonResponseInternal {
916        ok: bool,
917        value: Option<SecretValue>,
918        cache: Option<String>,
919        entries: Option<usize>,
920        error: Option<String>,
921        stop: bool,
922    }
923
924    impl DaemonResponseInternal {
925        fn ok() -> Self {
926            Self {
927                ok: true,
928                value: None,
929                cache: None,
930                entries: None,
931                error: None,
932                stop: false,
933            }
934        }
935
936        fn error(error: impl Into<String>) -> Self {
937            Self {
938                ok: false,
939                value: None,
940                cache: None,
941                entries: None,
942                error: Some(error.into()),
943                stop: false,
944            }
945        }
946
947        fn into_public(self) -> WireDaemonResponse {
948            WireDaemonResponse {
949                ok: self.ok,
950                value: self.value,
951                cache: self.cache,
952                entries: self.entries,
953                error: self.error,
954            }
955        }
956    }
957
958    fn serialize_secret_value_option<S>(
959        value: &Option<SecretValue>,
960        serializer: S,
961    ) -> Result<S::Ok, S::Error>
962    where
963        S: serde::Serializer,
964    {
965        match value {
966            Some(value) => serializer.serialize_some(value.expose()),
967            None => serializer.serialize_none(),
968        }
969    }
970
971    fn op_read(account: Option<&str>, reference: &str) -> Result<SecretValue, String> {
972        let mut command = Command::new("op");
973        command.arg("read").arg(reference);
974        if let Some(account) = account {
975            command.arg("--account").arg(account);
976        }
977
978        let output = command
979            .output()
980            .map_err(|source| format!("program `op` was not found: {source}"))?;
981
982        if !output.status.success() {
983            return Err(format!(
984                "program `op` failed with status {:?}: {}",
985                output.status.code(),
986                String::from_utf8_lossy(&output.stderr).trim()
987            ));
988        }
989
990        Ok(SecretValue::from_utf8_lossy_trimmed(output.stdout))
991    }
992
993    fn socket_path() -> Result<PathBuf, ViaError> {
994        if let Some(path) = env_path("VIA_DAEMON_SOCKET") {
995            return Ok(path);
996        }
997
998        if let Some(runtime) = env_path("XDG_RUNTIME_DIR") {
999            return Ok(runtime.join("via").join("daemon.sock"));
1000        }
1001
1002        Ok(env::temp_dir()
1003            .join(format!("via-{}", user_id()))
1004            .join("daemon.sock"))
1005    }
1006
1007    fn prepare_socket_parent(path: &Path) -> Result<(), ViaError> {
1008        let parent = path.parent().ok_or_else(|| {
1009            ViaError::InvalidConfig("daemon socket path has no parent".to_owned())
1010        })?;
1011        fs::create_dir_all(parent)?;
1012        fs::set_permissions(parent, fs::Permissions::from_mode(0o700))?;
1013        Ok(())
1014    }
1015
1016    fn env_path(name: &str) -> Option<PathBuf> {
1017        env::var_os(name)
1018            .filter(|value| !value.as_os_str().is_empty())
1019            .map(PathBuf::from)
1020    }
1021
1022    fn user_id() -> String {
1023        env::var("UID")
1024            .ok()
1025            .filter(|value| !value.trim().is_empty())
1026            .unwrap_or_else(|| {
1027                env::var("USER")
1028                    .ok()
1029                    .map(|value| sanitize_path_part(&value))
1030                    .filter(|value| !value.is_empty())
1031                    .unwrap_or_else(|| "unknown".to_owned())
1032            })
1033    }
1034
1035    fn sanitize_path_part(value: &str) -> String {
1036        value
1037            .chars()
1038            .filter(|character| character.is_ascii_alphanumeric() || *character == '_')
1039            .collect()
1040    }
1041
1042    fn daemon_unavailable(error: &ViaError) -> bool {
1043        matches!(error, ViaError::Io(source) if matches!(
1044            source.kind(),
1045            io::ErrorKind::NotFound
1046                | io::ErrorKind::ConnectionRefused
1047                | io::ErrorKind::ConnectionReset
1048                | io::ErrorKind::BrokenPipe
1049        ))
1050    }
1051
1052    #[derive(Clone)]
1053    struct ExecutableIdentity {
1054        path: PathBuf,
1055        device: u64,
1056        inode: u64,
1057    }
1058
1059    impl ExecutableIdentity {
1060        fn matches(&self, other: &Self) -> bool {
1061            self.path == other.path || (self.device == other.device && self.inode == other.inode)
1062        }
1063    }
1064
1065    #[cfg(any(target_os = "linux", target_os = "macos"))]
1066    fn daemon_executable_identity() -> Result<Option<ExecutableIdentity>, ViaError> {
1067        Ok(Some(executable_identity_from_path(&env::current_exe()?)?))
1068    }
1069
1070    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
1071    fn daemon_executable_identity() -> Result<Option<ExecutableIdentity>, ViaError> {
1072        Ok(None)
1073    }
1074
1075    #[cfg(any(target_os = "linux", target_os = "macos"))]
1076    fn verify_peer_executable(
1077        stream: &UnixStream,
1078        expected: Option<&ExecutableIdentity>,
1079    ) -> Result<(), ViaError> {
1080        let Some(expected) = expected else {
1081            return Ok(());
1082        };
1083        let peer = peer_executable_identity(stream)?;
1084        if expected.matches(&peer) {
1085            Ok(())
1086        } else {
1087            Err(ViaError::InvalidConfig(
1088                "daemon refused connection from executable other than via".to_owned(),
1089            ))
1090        }
1091    }
1092
1093    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
1094    fn verify_peer_executable(
1095        _stream: &UnixStream,
1096        _expected: Option<&ExecutableIdentity>,
1097    ) -> Result<(), ViaError> {
1098        Ok(())
1099    }
1100
1101    #[cfg(any(target_os = "linux", target_os = "macos"))]
1102    fn executable_identity_from_path(path: &Path) -> Result<ExecutableIdentity, ViaError> {
1103        let metadata = fs::metadata(path)?;
1104        Ok(executable_identity_from_parts(path.to_path_buf(), metadata))
1105    }
1106
1107    #[cfg(any(target_os = "linux", target_os = "macos"))]
1108    fn executable_identity_from_parts(path: PathBuf, metadata: fs::Metadata) -> ExecutableIdentity {
1109        let path = fs::canonicalize(&path).unwrap_or(path);
1110        ExecutableIdentity {
1111            path,
1112            device: metadata.dev(),
1113            inode: metadata.ino(),
1114        }
1115    }
1116
1117    #[cfg(target_os = "linux")]
1118    fn peer_executable_identity(stream: &UnixStream) -> Result<ExecutableIdentity, ViaError> {
1119        let pid = linux_peer_pid(stream)?;
1120        let proc_exe = PathBuf::from(format!("/proc/{pid}/exe"));
1121        let metadata = fs::metadata(&proc_exe)?;
1122        let path = fs::read_link(&proc_exe).unwrap_or_else(|_| proc_exe.clone());
1123        Ok(executable_identity_from_parts(path, metadata))
1124    }
1125
1126    #[cfg(target_os = "linux")]
1127    fn linux_peer_pid(stream: &UnixStream) -> Result<libc::pid_t, ViaError> {
1128        let mut credentials = std::mem::MaybeUninit::<libc::ucred>::uninit();
1129        let mut length = std::mem::size_of::<libc::ucred>() as libc::socklen_t;
1130        // SAFETY: `credentials` points to valid writable memory for `length` bytes,
1131        // and `stream.as_raw_fd()` is a live Unix socket file descriptor.
1132        let result = unsafe {
1133            libc::getsockopt(
1134                stream.as_raw_fd(),
1135                libc::SOL_SOCKET,
1136                libc::SO_PEERCRED,
1137                credentials.as_mut_ptr().cast(),
1138                &mut length,
1139            )
1140        };
1141        if result != 0 {
1142            return Err(io::Error::last_os_error().into());
1143        }
1144        if length as usize != std::mem::size_of::<libc::ucred>() {
1145            return Err(ViaError::InvalidConfig(
1146                "daemon could not read peer process credentials".to_owned(),
1147            ));
1148        }
1149
1150        // SAFETY: `getsockopt` succeeded and wrote a complete `ucred` value.
1151        Ok(unsafe { credentials.assume_init() }.pid)
1152    }
1153
1154    #[cfg(target_os = "macos")]
1155    fn peer_executable_identity(stream: &UnixStream) -> Result<ExecutableIdentity, ViaError> {
1156        let pid = macos_peer_pid(stream)?;
1157        let mut buffer = vec![0_u8; libc::PROC_PIDPATHINFO_MAXSIZE as usize];
1158        // SAFETY: `buffer` is valid writable memory for `buffer.len()` bytes.
1159        let length =
1160            unsafe { libc::proc_pidpath(pid, buffer.as_mut_ptr().cast(), buffer.len() as u32) };
1161        if length <= 0 {
1162            return Err(io::Error::last_os_error().into());
1163        }
1164        buffer.truncate(length as usize);
1165        let path = PathBuf::from(std::ffi::OsString::from_vec(buffer));
1166        executable_identity_from_path(&path)
1167    }
1168
1169    #[cfg(target_os = "macos")]
1170    fn macos_peer_pid(stream: &UnixStream) -> Result<libc::pid_t, ViaError> {
1171        let mut pid = std::mem::MaybeUninit::<libc::pid_t>::uninit();
1172        let mut length = std::mem::size_of::<libc::pid_t>() as libc::socklen_t;
1173        // SAFETY: `pid` points to valid writable memory for `length` bytes,
1174        // and `stream.as_raw_fd()` is a live Unix socket file descriptor.
1175        let result = unsafe {
1176            libc::getsockopt(
1177                stream.as_raw_fd(),
1178                libc::SOL_LOCAL,
1179                libc::LOCAL_PEERPID,
1180                pid.as_mut_ptr().cast(),
1181                &mut length,
1182            )
1183        };
1184        if result != 0 {
1185            return Err(io::Error::last_os_error().into());
1186        }
1187        if length as usize != std::mem::size_of::<libc::pid_t>() {
1188            return Err(ViaError::InvalidConfig(
1189                "daemon could not read peer process id".to_owned(),
1190            ));
1191        }
1192
1193        // SAFETY: `getsockopt` succeeded and wrote a complete `pid_t` value.
1194        Ok(unsafe { pid.assume_init() })
1195    }
1196
1197    #[derive(PartialEq, Eq)]
1198    enum DaemonAction {
1199        Continue,
1200        Stop,
1201    }
1202
1203    enum ServerEvent {
1204        Connection(UnixStream),
1205        NoConnection,
1206        IdleTimeout,
1207    }
1208
1209    #[cfg(test)]
1210    mod tests {
1211        use super::*;
1212        use std::io::{Read, Write};
1213        use std::net::TcpListener;
1214        use std::thread;
1215
1216        #[test]
1217        fn rejects_unregistered_resolve_request() {
1218            let mut state = DaemonState::default();
1219
1220            let response = state.handle(DaemonRequest::Resolve {
1221                config_hash: "config".to_owned(),
1222                ref_id: "secret".to_owned(),
1223                ttl_seconds: 300,
1224            });
1225
1226            assert!(!response.ok);
1227            assert!(response
1228                .error
1229                .as_deref()
1230                .unwrap()
1231                .contains("not registered"));
1232        }
1233
1234        #[test]
1235        fn rejects_registered_non_op_reference() {
1236            let mut state = DaemonState::default();
1237
1238            let response = state.handle(DaemonRequest::Register {
1239                config_hash: "config".to_owned(),
1240                account: None,
1241                refs: vec![super::super::AllowedOnePasswordRef {
1242                    id: "secret".to_owned(),
1243                    reference: "plaintext".to_owned(),
1244                }],
1245            });
1246
1247            assert!(!response.ok);
1248            assert!(response
1249                .error
1250                .as_deref()
1251                .unwrap()
1252                .contains("must start with op://"));
1253        }
1254
1255        #[test]
1256        fn resolves_registered_ref_id_from_cache() {
1257            let mut state = DaemonState::default();
1258            let register = state.handle(DaemonRequest::Register {
1259                config_hash: "config".to_owned(),
1260                account: None,
1261                refs: vec![super::super::AllowedOnePasswordRef {
1262                    id: "secret".to_owned(),
1263                    reference: "op://Private/Example/token".to_owned(),
1264                }],
1265            });
1266            assert!(register.ok);
1267            state.cache.insert(
1268                SecretCacheKey {
1269                    config_hash: "config".to_owned(),
1270                    ref_id: "secret".to_owned(),
1271                },
1272                SecretCacheEntry {
1273                    value: SecretValue::new("cached-secret".to_owned()),
1274                    expires_at: Instant::now() + Duration::from_secs(300),
1275                },
1276            );
1277
1278            let response = state.handle(DaemonRequest::Resolve {
1279                config_hash: "config".to_owned(),
1280                ref_id: "secret".to_owned(),
1281                ttl_seconds: 300,
1282            });
1283
1284            assert!(response.ok);
1285            assert_eq!(response.cache.as_deref(), Some("hit"));
1286            assert_eq!(
1287                response.value.as_ref().map(SecretValue::expose),
1288                Some("cached-secret")
1289            );
1290        }
1291
1292        #[test]
1293        fn clear_drops_cached_values_and_registered_refs() {
1294            let mut state = DaemonState::default();
1295            let register = state.handle(DaemonRequest::Register {
1296                config_hash: "config".to_owned(),
1297                account: None,
1298                refs: vec![super::super::AllowedOnePasswordRef {
1299                    id: "secret".to_owned(),
1300                    reference: "op://Private/Example/token".to_owned(),
1301                }],
1302            });
1303            assert!(register.ok);
1304            state.cache.insert(
1305                SecretCacheKey {
1306                    config_hash: "config".to_owned(),
1307                    ref_id: "secret".to_owned(),
1308                },
1309                SecretCacheEntry {
1310                    value: SecretValue::new("cached-secret".to_owned()),
1311                    expires_at: Instant::now() + Duration::from_secs(300),
1312                },
1313            );
1314
1315            let clear = state.handle(DaemonRequest::Clear);
1316            assert!(clear.ok);
1317            let response = state.handle(DaemonRequest::Resolve {
1318                config_hash: "config".to_owned(),
1319                ref_id: "secret".to_owned(),
1320                ttl_seconds: 300,
1321            });
1322
1323            assert!(!response.ok);
1324            assert!(state.cache.is_empty());
1325            assert!(state.oauth_cache.is_empty());
1326            assert!(state.registrations.is_empty());
1327        }
1328
1329        #[test]
1330        fn oauth_access_token_is_cached_in_daemon_memory() {
1331            let response_body = serde_json::json!({
1332                "access_token": "fresh-access-token",
1333                "token_type": "Bearer",
1334                "expires_in": 3600,
1335                "refresh_token": "rotated-refresh-token",
1336            })
1337            .to_string();
1338            let (token_url, server) = token_server(response_body);
1339            let mut state = DaemonState::default();
1340            let credential = serde_json::json!({
1341                "type": "service_oauth",
1342                "token_url": token_url,
1343                "grant_type": "refresh_token",
1344                "client_id": "client-id",
1345                "client_secret": "client-secret",
1346                "refresh_token": "configured-refresh-token",
1347            })
1348            .to_string();
1349
1350            let response = state.handle(DaemonRequest::OAuthAccessToken {
1351                credential: credential.clone(),
1352                mode: crate::daemon::OAuthTokenMode::Cached,
1353            });
1354            let request = server.join().unwrap();
1355            let cached_response = state.handle(DaemonRequest::OAuthAccessToken {
1356                credential,
1357                mode: crate::daemon::OAuthTokenMode::Cached,
1358            });
1359
1360            assert!(response.ok);
1361            assert_eq!(response.cache.as_deref(), Some("miss"));
1362            assert_eq!(
1363                response.value.as_ref().map(SecretValue::expose),
1364                Some("fresh-access-token")
1365            );
1366            assert!(request.contains("grant_type=refresh_token"));
1367            assert!(request.contains("refresh_token=configured-refresh-token"));
1368            assert_eq!(state.oauth_cache.len(), 1);
1369            assert!(cached_response.ok);
1370            assert_eq!(cached_response.cache.as_deref(), Some("hit"));
1371            assert_eq!(
1372                cached_response.value.as_ref().map(SecretValue::expose),
1373                Some("fresh-access-token")
1374            );
1375        }
1376
1377        #[test]
1378        fn oauth_access_token_refresh_mode_skips_unexpired_cache() {
1379            let response_body = serde_json::json!({
1380                "access_token": "fresh-access-token",
1381                "token_type": "Bearer",
1382                "expires_in": 3600,
1383            })
1384            .to_string();
1385            let (token_url, server) = token_server(response_body);
1386            let mut state = DaemonState::default();
1387            let credential = serde_json::json!({
1388                "type": "service_oauth",
1389                "token_url": token_url,
1390                "grant_type": "client_credentials",
1391                "client_id": "client-id",
1392                "client_secret": "client-secret",
1393                "scope": "read,issues:create",
1394            })
1395            .to_string();
1396            let bundle = crate::auth::oauth::CredentialBundle::parse(&credential).unwrap();
1397            state.oauth_cache.insert(
1398                crate::auth::oauth::cache_key(&bundle),
1399                crate::auth::oauth::CachedOAuthToken {
1400                    access_token: "cached-access-token".to_owned(),
1401                    expires_at: crate::auth::oauth::unix_timestamp().unwrap() + 3_600,
1402                    refresh_token: None,
1403                },
1404            );
1405
1406            let response = state.handle(DaemonRequest::OAuthAccessToken {
1407                credential,
1408                mode: crate::daemon::OAuthTokenMode::Refresh,
1409            });
1410            let request = server.join().unwrap();
1411
1412            assert!(response.ok);
1413            assert_eq!(response.cache.as_deref(), Some("miss"));
1414            assert_eq!(
1415                response.value.as_ref().map(SecretValue::expose),
1416                Some("fresh-access-token")
1417            );
1418            assert!(request.contains("grant_type=client_credentials"));
1419        }
1420
1421        #[test]
1422        fn prune_expired_keeps_rotated_oauth_refresh_tokens() {
1423            let mut state = DaemonState::default();
1424            state.oauth_cache.insert(
1425                "oauth".to_owned(),
1426                crate::auth::oauth::CachedOAuthToken {
1427                    access_token: "expired-access-token".to_owned(),
1428                    expires_at: 0,
1429                    refresh_token: Some("rotated-refresh-token".to_owned()),
1430                },
1431            );
1432
1433            state.prune_expired();
1434
1435            assert_eq!(state.oauth_cache.len(), 1);
1436            assert_eq!(
1437                state
1438                    .oauth_cache
1439                    .values()
1440                    .next()
1441                    .and_then(|entry| entry.refresh_token.as_deref()),
1442                Some("rotated-refresh-token")
1443            );
1444        }
1445
1446        fn token_server(response_body: String) -> (String, thread::JoinHandle<String>) {
1447            let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1448            let address = listener.local_addr().unwrap();
1449            let handle = thread::spawn(move || {
1450                let (mut stream, _) = listener.accept().unwrap();
1451                let mut buffer = [0_u8; 8192];
1452                let read = stream.read(&mut buffer).unwrap();
1453                let request = String::from_utf8_lossy(&buffer[..read]).to_string();
1454                let response = format!(
1455                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
1456                    response_body.len(),
1457                    response_body
1458                );
1459                stream.write_all(response.as_bytes()).unwrap();
1460                request
1461            });
1462
1463            (format!("http://{address}/oauth/token"), handle)
1464        }
1465    }
1466}
1467
1468#[cfg(not(unix))]
1469mod imp {
1470    use crate::error::ViaError;
1471    use crate::secrets::SecretValue;
1472
1473    pub fn resolve_onepassword_secret(
1474        _config_hash: &str,
1475        _ref_id: &str,
1476        _ttl_seconds: u64,
1477    ) -> Result<SecretValue, ViaError> {
1478        Err(ViaError::InvalidConfig(
1479            "via daemon cache is only supported on Unix-like platforms".to_owned(),
1480        ))
1481    }
1482
1483    pub fn register_onepassword_refs(
1484        _config_hash: &str,
1485        _account: Option<&str>,
1486        _refs: Vec<super::AllowedOnePasswordRef>,
1487    ) -> Result<(), ViaError> {
1488        Err(ViaError::InvalidConfig(
1489            "via daemon cache is only supported on Unix-like platforms".to_owned(),
1490        ))
1491    }
1492
1493    pub fn oauth_access_token(
1494        _credential: &str,
1495        _mode: super::OAuthTokenMode,
1496    ) -> Result<SecretValue, ViaError> {
1497        Err(ViaError::InvalidConfig(
1498            "OAuth auth requires the via daemon, which is only supported on Unix-like platforms"
1499                .to_owned(),
1500        ))
1501    }
1502
1503    pub fn serve() -> Result<(), ViaError> {
1504        Err(ViaError::InvalidConfig(
1505            "via daemon cache is only supported on Unix-like platforms".to_owned(),
1506        ))
1507    }
1508
1509    pub fn status() -> Result<(), ViaError> {
1510        println!("via daemon: unsupported");
1511        Ok(())
1512    }
1513
1514    pub fn clear() -> Result<(), ViaError> {
1515        println!("via daemon: unsupported");
1516        Ok(())
1517    }
1518
1519    pub fn stop() -> Result<(), ViaError> {
1520        println!("via daemon: unsupported");
1521        Ok(())
1522    }
1523}
1524
1525pub use imp::{
1526    clear, oauth_access_token, register_onepassword_refs, resolve_onepassword_secret, serve,
1527    status, stop,
1528};