Skip to main content

openjd_sessions/
tempdir.rs

1// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2// Copyright by contributors to this project.
3// SPDX-License-Identifier: (Apache-2.0 OR MIT)
4
5//! Secure temporary directory creation — mirrors Python `_tempdir.py`.
6
7use std::path::{Path, PathBuf};
8
9use crate::error::SessionError;
10use crate::session_user::SessionUser;
11
12/// Controls behavior when a parent directory is world-writable without the sticky bit.
13///
14/// On POSIX systems, a world-writable directory without the sticky bit allows any
15/// user to rename or delete files belonging to other users. This is a security risk
16/// for session working directories.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
18pub enum StickyBitPolicy {
19    /// Refuse to create the session if a parent directory is unsafe.
20    /// This is the default — fail-closed is the secure choice.
21    #[default]
22    Strict,
23    /// Log a warning but allow the session to proceed.
24    Warn,
25    /// Skip the check entirely.
26    Disabled,
27}
28
29/// Returns the OpenJD temp directory, creating it if needed.
30///
31/// `base_dir` is the OpenJD directory itself — the directory under which
32/// individual session and file temp directories will be created.
33///
34/// - `None` → derive from the environment:
35///   - Unix: `<std::env::temp_dir()>/OpenJD` (typically `/tmp/OpenJD`).
36///   - Windows: `%PROGRAMDATA%\Amazon\OpenJD` (with a warning and fallback to
37///     `C:\ProgramData\Amazon\OpenJD` if `PROGRAMDATA` is unset).
38/// - `Some(p)` → use `p` directly.
39///
40/// Tests should pass `Some(...)` to avoid mutating process-global environment
41/// variables, which races with parallel tests that read them.
42pub fn openjd_temp_dir(base_dir: Option<&Path>) -> Result<PathBuf, SessionError> {
43    let dir = match base_dir {
44        Some(p) => p.to_path_buf(),
45        None => default_openjd_dir(),
46    };
47
48    std::fs::create_dir_all(&dir).map_err(|e| SessionError::TempDir {
49        path: dir.clone(),
50        source: e,
51    })?;
52    Ok(dir)
53}
54
55/// Compute the default OpenJD directory from the environment.
56///
57/// Split out so the env-var inspection (and Windows warning) can be tested
58/// without `openjd_temp_dir` doing filesystem work, and without tests having
59/// to mutate process-global env vars.
60fn default_openjd_dir() -> PathBuf {
61    #[cfg(unix)]
62    {
63        std::env::temp_dir().join("OpenJD")
64    }
65
66    #[cfg(windows)]
67    {
68        openjd_dir_from_programdata(std::env::var("PROGRAMDATA").ok())
69    }
70}
71
72/// Build the Windows OpenJD directory from a `PROGRAMDATA` value.
73///
74/// Pure function: takes an explicit value (as if from the environment),
75/// performs no I/O, and is fully testable without touching real env vars.
76/// Logs a warning when `programdata` is `None` and falls back to
77/// `C:\ProgramData`.
78#[cfg(windows)]
79fn openjd_dir_from_programdata(programdata: Option<String>) -> PathBuf {
80    let program_data = programdata.unwrap_or_else(|| {
81        log::warn!(
82            target: "openjd.sessions",
83            "Environment variable \"PROGRAMDATA\" is not set. \
84             Creating session working directories under C:\\ProgramData"
85        );
86        r"C:\ProgramData".to_string()
87    });
88    PathBuf::from(program_data).join("Amazon").join("OpenJD")
89}
90
91/// Check parent directories for world-writable dirs missing the sticky bit (POSIX only).
92///
93/// Returns the first offending path, or `None` if all parents are safe.
94#[cfg(unix)]
95pub fn find_missing_sticky_bit(root_dir: &Path) -> Option<PathBuf> {
96    use std::os::unix::fs::MetadataExt;
97
98    const S_IWOTH: u32 = 0o002;
99    const S_ISVTX: u32 = 0o1000;
100
101    for parent in root_dir.ancestors().skip(1) {
102        if let Ok(meta) = std::fs::metadata(parent) {
103            let mode = meta.mode();
104            if (mode & S_IWOTH) != 0 && (mode & S_ISVTX) == 0 {
105                return Some(parent.to_path_buf());
106            }
107        }
108    }
109    None
110}
111
112/// A securely-created temporary directory.
113///
114/// Call [`cleanup()`](TempDir::cleanup) to remove the directory. If dropped
115/// without calling `cleanup()`, a best-effort removal is attempted.
116///
117/// ```
118/// use openjd_sessions::TempDir;
119///
120/// let dir = tempfile::tempdir().unwrap();
121/// let mut td = TempDir::new(Some(dir.path()), Some("test-"), None).unwrap();
122/// assert!(td.path().exists());
123/// td.cleanup().unwrap();
124/// assert!(!td.path().exists());
125/// ```
126pub struct TempDir {
127    path: PathBuf,
128    cleaned_up: bool,
129}
130
131impl std::fmt::Debug for TempDir {
132    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
133        f.debug_struct("TempDir")
134            .field("path", &self.path)
135            .field("cleaned_up", &self.cleaned_up)
136            .finish()
137    }
138}
139
140impl AsRef<std::path::Path> for TempDir {
141    fn as_ref(&self) -> &std::path::Path {
142        &self.path
143    }
144}
145
146impl TempDir {
147    /// Create a new secure temp directory.
148    ///
149    /// - `dir`: parent directory (defaults to `openjd_temp_dir(None)`)
150    /// - `prefix`: optional name prefix
151    /// - `user`: optional session user for cross-user ownership
152    pub fn new(
153        dir: Option<&Path>,
154        prefix: Option<&str>,
155        _user: Option<&dyn SessionUser>,
156    ) -> Result<Self, SessionError> {
157        let parent = match dir {
158            Some(d) => d.to_path_buf(),
159            None => openjd_temp_dir(None)?,
160        };
161
162        let prefix = prefix.unwrap_or("");
163        let suffix = random_hex();
164        let name = format!("{prefix}{suffix}");
165        let path = parent.join(name);
166
167        std::fs::create_dir(&path).map_err(|e| SessionError::TempDir {
168            path: path.clone(),
169            source: e,
170        })?;
171
172        #[cfg(unix)]
173        {
174            use std::os::unix::fs::PermissionsExt;
175            let mode = if let Some(u) = _user.filter(|u| !u.is_process_user()) {
176                // Cross-user: chown group then set 0o770
177                // chown before chmod — security: don't grant group access if chown fails
178                if let Ok(Some(grp)) = nix::unistd::Group::from_name(u.group()) {
179                    nix::unistd::chown(&path, None, Some(grp.gid)).map_err(|e| {
180                        SessionError::PathPermissions {
181                            path: path.display().to_string(),
182                            reason: format!(
183                                "Could not change ownership (error: {e}). Please ensure that uid {} is a member of group {}.",
184                                nix::unistd::geteuid(), u.group()
185                            ),
186                        }
187                    })?;
188                }
189                0o770
190            } else {
191                0o700
192            };
193            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode)).map_err(
194                |e| SessionError::TempDir {
195                    path: path.clone(),
196                    source: e,
197                },
198            )?;
199        }
200
201        // Windows: set DACL — full control for process user, modify for session user.
202        #[cfg(windows)]
203        {
204            if let Some(u) = _user.filter(|u| !u.is_process_user()) {
205                if let Ok(process_user) = crate::win32::get_process_user() {
206                    if let Err(e) = crate::win32_permissions::set_permissions(
207                        &path.to_string_lossy(),
208                        &[process_user.as_str()],
209                        &[u.user()],
210                        &[],
211                    ) {
212                        return Err(SessionError::PathPermissions {
213                            path: path.display().to_string(),
214                            reason: e.to_string(),
215                        });
216                    }
217                }
218            }
219        }
220
221        Ok(Self {
222            path,
223            cleaned_up: false,
224        })
225    }
226
227    pub fn path(&self) -> &Path {
228        &self.path
229    }
230
231    /// Remove the directory and all contents.
232    pub fn cleanup(&mut self) -> Result<(), SessionError> {
233        if self.cleaned_up {
234            return Ok(());
235        }
236        self.cleaned_up = true;
237        std::fs::remove_dir_all(&self.path).map_err(|e| SessionError::TempDir {
238            path: self.path.clone(),
239            source: e,
240        })
241    }
242}
243
244impl Drop for TempDir {
245    fn drop(&mut self) {
246        if !self.cleaned_up {
247            let _ = std::fs::remove_dir_all(&self.path);
248        }
249    }
250}
251
252fn random_hex() -> String {
253    uuid::Uuid::new_v4().simple().to_string()
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259
260    #[test]
261    fn tempdir_debug() {
262        let td = TempDir::new(None, None, None).unwrap();
263        let dbg = format!("{td:?}");
264        assert!(dbg.contains("TempDir"));
265        assert!(dbg.contains("cleaned_up: false"));
266    }
267
268    #[test]
269    fn tempdir_as_ref_path() {
270        let td = TempDir::new(None, None, None).unwrap();
271        let p: &std::path::Path = td.as_ref();
272        assert_eq!(p, td.path());
273    }
274
275    /// Mirrors Python TestSession::test_posix_permissions_warning.
276    /// Creates a world-writable dir without the sticky bit and verifies detection.
277    #[cfg(unix)]
278    #[test]
279    fn find_missing_sticky_bit_detects_world_writable_without_sticky() {
280        use std::os::unix::fs::PermissionsExt;
281
282        let tmp = tempfile::TempDir::new().unwrap();
283        let dir = tmp.path().join("world_writable");
284        std::fs::create_dir(&dir).unwrap();
285        std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o777)).unwrap();
286
287        let child = dir.join("child");
288        std::fs::create_dir(&child).unwrap();
289
290        let result = find_missing_sticky_bit(&child);
291        assert_eq!(result, Some(dir));
292    }
293
294    /// Mirrors Python TestSession::test_posix_permissions_no_warning.
295    /// A dir with the sticky bit set should not be flagged.
296    #[cfg(unix)]
297    #[test]
298    fn find_missing_sticky_bit_none_when_sticky_set() {
299        use std::os::unix::fs::PermissionsExt;
300
301        let tmp = tempfile::TempDir::new().unwrap();
302        let dir = tmp.path().join("sticky_dir");
303        std::fs::create_dir(&dir).unwrap();
304        std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o1777)).unwrap();
305
306        let child = dir.join("child");
307        std::fs::create_dir(&child).unwrap();
308
309        let result = find_missing_sticky_bit(&child);
310        assert_eq!(result, None);
311    }
312
313    /// Mirrors Python TestTempDirWindows::test_windows_temp_dir — verifies the
314    /// warning when `PROGRAMDATA` is unset.
315    ///
316    /// Tests the pure helper `openjd_dir_from_programdata` directly so we
317    /// don't have to mutate the real `PROGRAMDATA` env var, which would race
318    /// with parallel tests that read it.
319    #[cfg(windows)]
320    #[test]
321    fn openjd_dir_from_programdata_warns_when_unset() {
322        testing_logger::setup();
323        let dir = openjd_dir_from_programdata(None);
324        assert_eq!(dir, PathBuf::from(r"C:\ProgramData\Amazon\OpenJD"));
325        testing_logger::validate(|captured_logs| {
326            assert!(
327                captured_logs.iter().any(|log| {
328                    log.level == log::Level::Warn && log.body.contains("PROGRAMDATA")
329                }),
330                "Expected a warning about PROGRAMDATA not being set"
331            );
332        });
333    }
334
335    /// Confirms the helper composes the path correctly when `PROGRAMDATA` is
336    /// provided. No warning should be emitted.
337    #[cfg(windows)]
338    #[test]
339    fn openjd_dir_from_programdata_uses_provided_value() {
340        testing_logger::setup();
341        let dir = openjd_dir_from_programdata(Some(r"D:\ProgramData".to_string()));
342        assert_eq!(dir, PathBuf::from(r"D:\ProgramData\Amazon\OpenJD"));
343        testing_logger::validate(|captured_logs| {
344            assert!(
345                !captured_logs
346                    .iter()
347                    .any(|log| log.level == log::Level::Warn),
348                "Expected no warning when PROGRAMDATA is provided"
349            );
350        });
351    }
352
353    /// Mirrors Python TestTempDirWindows::test_windows_temp_dir — positive
354    /// path: when an explicit OpenJD directory is provided, `openjd_temp_dir`
355    /// uses it and creates it (and any missing parents) on disk.
356    ///
357    /// This test passes the directory directly via the `base_dir` parameter
358    /// instead of mutating `PROGRAMDATA`, which would race with parallel
359    /// tests that read it.
360    #[cfg(windows)]
361    #[test]
362    fn openjd_temp_dir_honors_base_dir_override() {
363        let custom_root = std::env::temp_dir().join("OpenJDBaseDirTest");
364        // Ensure a clean slate so create_dir_all does real work.
365        let _ = std::fs::remove_dir_all(&custom_root);
366
367        let openjd_dir = custom_root.join("Amazon").join("OpenJD");
368        let dir = openjd_temp_dir(Some(&openjd_dir))
369            .expect("openjd_temp_dir should succeed with override");
370        assert_eq!(
371            dir, openjd_dir,
372            "openjd_temp_dir should return the path it was given"
373        );
374        assert!(dir.exists(), "openjd_temp_dir should create the directory");
375        assert!(
376            custom_root.join("Amazon").exists(),
377            "missing parents should be created as a side effect"
378        );
379
380        // Clean up
381        let _ = std::fs::remove_dir_all(&custom_root);
382    }
383}