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