Skip to main content

pf_world/
env.rs

1// SPDX-License-Identifier: MIT
2//! Environment-variable + cwd capture, with regex redaction.
3
4use pf_core::cas::BlobStore;
5use pf_core::digest::Digest256;
6use regex::Regex;
7use serde::{Deserialize, Serialize};
8use std::collections::BTreeMap;
9use std::sync::Arc;
10
11/// Wire format of a captured environment.
12#[derive(Clone, Debug, Serialize, Deserialize)]
13pub struct EnvSnapshot {
14    /// Schema discriminator. Always `"env.v1"`.
15    pub kind: String,
16    /// Captured working directory at snapshot time.
17    pub cwd: String,
18    /// Captured env vars; keys are sorted (BTreeMap) so the digest is
19    /// deterministic across hosts.
20    pub vars: BTreeMap<String, String>,
21}
22
23/// Captures env vars + cwd, optionally redacting matching keys before sealing.
24pub struct EnvCapture {
25    scrub: Vec<Regex>,
26}
27
28impl EnvCapture {
29    /// Construct a capturer with no redaction.
30    #[must_use]
31    pub fn new() -> Self {
32        Self { scrub: Vec::new() }
33    }
34
35    /// Add a regex; any env-var key matching this regex will be replaced with
36    /// `"<redacted>"` in the output. Common defaults: `(?i)(token|secret|key|password)`.
37    ///
38    /// # Errors
39    /// Returns the underlying [`regex::Error`] when `pattern` does not parse.
40    pub fn scrub(mut self, pattern: &str) -> Result<Self, regex::Error> {
41        self.scrub.push(Regex::new(pattern)?);
42        Ok(self)
43    }
44
45    /// Run the capture, store the resulting blob, and return its digest.
46    pub fn capture(&self, blobs: &Arc<dyn BlobStore>) -> pf_core::Result<Digest256> {
47        let cwd = match std::env::current_dir() {
48            Ok(p) => p.to_string_lossy().into_owned(),
49            Err(_) => String::new(),
50        };
51        let mut vars = BTreeMap::new();
52        for (k, v) in std::env::vars() {
53            let redacted = self.scrub.iter().any(|re| re.is_match(&k));
54            vars.insert(k, if redacted { "<redacted>".into() } else { v });
55        }
56        let snap = EnvSnapshot {
57            kind: "env.v1".into(),
58            cwd,
59            vars,
60        };
61        blobs.put(&serde_json::to_vec(&snap)?)
62    }
63}
64
65impl Default for EnvCapture {
66    fn default() -> Self {
67        Self::new()
68    }
69}
70
71#[cfg(test)]
72#[allow(unsafe_code)] // std::env::set_var is unsafe since Rust 1.85
73mod tests {
74    use super::*;
75    use pf_core::cas::MemBlobStore;
76    // SAFETY note: `std::env::set_var` is documented as racy with concurrent
77    // reads in multithreaded code. We single-thread these tests with a mutex
78    // so the env-var manipulation is sequential.
79    use std::sync::Mutex;
80    static ENV_LOCK: Mutex<()> = Mutex::new(());
81
82    #[test]
83    fn captures_vars_and_cwd() {
84        let _g = ENV_LOCK.lock().unwrap();
85        // SAFETY: env mutation is serialized via ENV_LOCK.
86        unsafe {
87            std::env::set_var("PF_TEST_VAR_PLAIN", "value123");
88        }
89        let blobs: Arc<dyn BlobStore> = Arc::new(MemBlobStore::new());
90        let cid = EnvCapture::new().capture(&blobs).unwrap();
91        let snap: EnvSnapshot = serde_json::from_slice(&blobs.get(&cid).unwrap()).unwrap();
92        assert_eq!(snap.kind, "env.v1");
93        assert!(!snap.cwd.is_empty());
94        assert_eq!(snap.vars.get("PF_TEST_VAR_PLAIN").unwrap(), "value123");
95    }
96
97    #[test]
98    fn scrub_redacts_matching_keys() {
99        let _g = ENV_LOCK.lock().unwrap();
100        // SAFETY: env mutation is serialized via ENV_LOCK.
101        unsafe {
102            std::env::set_var("PF_TEST_SECRET_TOKEN", "do-not-leak");
103            std::env::set_var("PF_TEST_PUBLIC_INFO", "ok-to-share");
104        }
105        let blobs: Arc<dyn BlobStore> = Arc::new(MemBlobStore::new());
106        let cap = EnvCapture::new().scrub("(?i)secret|token").unwrap();
107        let cid = cap.capture(&blobs).unwrap();
108        let snap: EnvSnapshot = serde_json::from_slice(&blobs.get(&cid).unwrap()).unwrap();
109        assert_eq!(snap.vars.get("PF_TEST_SECRET_TOKEN").unwrap(), "<redacted>");
110        assert_eq!(snap.vars.get("PF_TEST_PUBLIC_INFO").unwrap(), "ok-to-share");
111    }
112
113    #[test]
114    fn vars_are_sorted_so_digest_is_deterministic() {
115        let _g = ENV_LOCK.lock().unwrap();
116        let blobs: Arc<dyn BlobStore> = Arc::new(MemBlobStore::new());
117        let c1 = EnvCapture::new().capture(&blobs).unwrap();
118        let c2 = EnvCapture::new().capture(&blobs).unwrap();
119        // Same env → same capture → same digest.
120        assert_eq!(c1, c2);
121    }
122}