Skip to main content

ssh_cli/tls/
paths.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2#![forbid(unsafe_code)]
3//! XDG layout for TLS material (no product env for cert storage).
4
5use std::path::{Path, PathBuf};
6
7use crate::constants::{
8    TLS_ACME_ACCOUNT_FILE_NAME, TLS_ACME_DIR_NAME, TLS_ACME_ORDER_FILE_NAME, TLS_CERT_FILE_NAME,
9    TLS_DIR_NAME, TLS_KEY_FILE_NAME, TLS_MTLS_DIR_NAME,
10};
11use crate::errors::{SshCliError, SshCliResult};
12use crate::paths::{validate_and_normalize, xdg_config_dir};
13
14/// Resolves the TLS root directory under the active config dir.
15///
16/// Priority: explicit `config_override` parent → XDG config dir for the app.
17pub fn resolve_tls_root(config_override: Option<&Path>) -> SshCliResult<PathBuf> {
18    let base = if let Some(dir) = config_override {
19        dir.to_path_buf()
20    } else {
21        xdg_config_dir()?
22    };
23    Ok(base.join(TLS_DIR_NAME))
24}
25
26/// Same as [`resolve_tls_root`] (alias for call-site clarity).
27pub fn tls_root_dir(config_override: Option<&Path>) -> SshCliResult<PathBuf> {
28    resolve_tls_root(config_override)
29}
30
31/// `…/tls/mtls/<name>/`
32pub fn mtls_identity_dir(config_override: Option<&Path>, name: &str) -> SshCliResult<PathBuf> {
33    let safe = validate_and_normalize(name)
34        .map_err(|e| SshCliError::InvalidArgument(format!("invalid mTLS identity name: {e}")))?;
35    Ok(resolve_tls_root(config_override)?
36        .join(TLS_MTLS_DIR_NAME)
37        .join(safe.as_str()))
38}
39
40/// `…/tls/acme/account.json`
41pub fn acme_account_path(config_override: Option<&Path>) -> SshCliResult<PathBuf> {
42    Ok(resolve_tls_root(config_override)?
43        .join(TLS_ACME_DIR_NAME)
44        .join(TLS_ACME_ACCOUNT_FILE_NAME))
45}
46
47/// `…/tls/acme/<domain>/` — domain leaf is NFC-normalized validated name.
48pub fn acme_domain_dir(config_override: Option<&Path>, domain: &str) -> SshCliResult<PathBuf> {
49    // Domains may contain dots — use a conservative sanitizer (no traversal).
50    let leaf = sanitize_domain_leaf(domain)?;
51    Ok(resolve_tls_root(config_override)?
52        .join(TLS_ACME_DIR_NAME)
53        .join(leaf))
54}
55
56/// Certificate PEM path under a domain or identity directory.
57#[must_use]
58pub fn cert_pem_path(dir: &Path) -> PathBuf {
59    dir.join(TLS_CERT_FILE_NAME)
60}
61
62/// Private key PEM path under a domain or identity directory.
63#[must_use]
64pub fn key_pem_path(dir: &Path) -> PathBuf {
65    dir.join(TLS_KEY_FILE_NAME)
66}
67
68/// Pending ACME order JSON path.
69#[must_use]
70pub fn order_json_path(dir: &Path) -> PathBuf {
71    dir.join(TLS_ACME_ORDER_FILE_NAME)
72}
73
74/// Sanitizes a DNS name for use as a single path component.
75///
76/// Allows letters, digits, `.`, `-`, `_`. Rejects empty, `..`, separators.
77fn sanitize_domain_leaf(domain: &str) -> SshCliResult<String> {
78    let d = domain.trim().to_ascii_lowercase();
79    if d.is_empty() {
80        return Err(SshCliError::InvalidArgument(
81            "domain cannot be empty".into(),
82        ));
83    }
84    if d.contains("..") || d.contains('/') || d.contains('\\') {
85        return Err(SshCliError::InvalidArgument(format!(
86            "invalid domain path leaf: {domain}"
87        )));
88    }
89    if !d
90        .chars()
91        .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_')
92    {
93        return Err(SshCliError::InvalidArgument(format!(
94            "domain contains forbidden characters: {domain}"
95        )));
96    }
97    Ok(d)
98}
99
100/// Ensures directory exists with restrictive permissions on Unix.
101pub(crate) fn ensure_dir(path: &Path) -> SshCliResult<()> {
102    std::fs::create_dir_all(path)
103        .map_err(|e| SshCliError::tls_msg(format!("create TLS dir {}: {e}", path.display())))?;
104    // Best-effort secret dir mode (matches prior ignore-on-error chmod).
105    let _ = crate::fs_perm::set_secret_dir_mode(path);
106    Ok(())
107}
108
109/// Writes bytes atomically with `0o600` on Unix (ACME / mTLS private keys).
110///
111/// A2: the previous implementation used [`std::fs::write`], which creates the temp
112/// file at `0644` under the default umask and only chmod'ed afterwards — with the
113/// error discarded. That left a real window in which an ACME or mTLS private key was
114/// readable by any local user. Delegates to the shared helper that creates at `0600`
115/// via `O_EXCL` and propagates permission failures instead of swallowing them.
116pub(crate) fn write_secret_file(path: &Path, data: &[u8]) -> SshCliResult<()> {
117    if let Some(parent) = path.parent() {
118        ensure_dir(parent)?;
119    }
120    crate::fs_perm::write_secret_file_atomic(path, data)
121        .map_err(|e| SshCliError::tls_msg(format!("write {}: {e}", path.display())))
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127    use tempfile::TempDir;
128
129    #[test]
130    fn sanitize_domain_ok() {
131        assert_eq!(sanitize_domain_leaf("Example.COM").unwrap(), "example.com");
132    }
133
134    #[test]
135    fn sanitize_domain_rejects_traversal() {
136        assert!(sanitize_domain_leaf("../etc").is_err());
137        assert!(sanitize_domain_leaf("a/b").is_err());
138    }
139
140    #[test]
141    fn mtls_dir_layout() {
142        let t = TempDir::new().unwrap();
143        let d = mtls_identity_dir(Some(t.path()), "agent-1").unwrap();
144        assert!(d.ends_with("tls/mtls/agent-1") || d.ends_with(r"tls\mtls\agent-1"));
145    }
146}