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).map_err(|e| {
34        SshCliError::InvalidArgument(format!("invalid mTLS identity name: {e}"))
35    })?;
36    Ok(resolve_tls_root(config_override)?
37        .join(TLS_MTLS_DIR_NAME)
38        .join(safe.as_str()))
39}
40
41/// `…/tls/acme/account.json`
42pub fn acme_account_path(config_override: Option<&Path>) -> SshCliResult<PathBuf> {
43    Ok(resolve_tls_root(config_override)?
44        .join(TLS_ACME_DIR_NAME)
45        .join(TLS_ACME_ACCOUNT_FILE_NAME))
46}
47
48/// `…/tls/acme/<domain>/` — domain leaf is NFC-normalized validated name.
49pub fn acme_domain_dir(config_override: Option<&Path>, domain: &str) -> SshCliResult<PathBuf> {
50    // Domains may contain dots — use a conservative sanitizer (no traversal).
51    let leaf = sanitize_domain_leaf(domain)?;
52    Ok(resolve_tls_root(config_override)?
53        .join(TLS_ACME_DIR_NAME)
54        .join(leaf))
55}
56
57/// Certificate PEM path under a domain or identity directory.
58#[must_use]
59pub fn cert_pem_path(dir: &Path) -> PathBuf {
60    dir.join(TLS_CERT_FILE_NAME)
61}
62
63/// Private key PEM path under a domain or identity directory.
64#[must_use]
65pub fn key_pem_path(dir: &Path) -> PathBuf {
66    dir.join(TLS_KEY_FILE_NAME)
67}
68
69/// Pending ACME order JSON path.
70#[must_use]
71pub fn order_json_path(dir: &Path) -> PathBuf {
72    dir.join(TLS_ACME_ORDER_FILE_NAME)
73}
74
75/// Sanitizes a DNS name for use as a single path component.
76///
77/// Allows letters, digits, `.`, `-`, `_`. Rejects empty, `..`, separators.
78fn sanitize_domain_leaf(domain: &str) -> SshCliResult<String> {
79    let d = domain.trim().to_ascii_lowercase();
80    if d.is_empty() {
81        return Err(SshCliError::InvalidArgument("domain cannot be empty".into()));
82    }
83    if d.contains("..") || d.contains('/') || d.contains('\\') {
84        return Err(SshCliError::InvalidArgument(format!(
85            "invalid domain path leaf: {domain}"
86        )));
87    }
88    if !d
89        .chars()
90        .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_')
91    {
92        return Err(SshCliError::InvalidArgument(format!(
93            "domain contains forbidden characters: {domain}"
94        )));
95    }
96    Ok(d)
97}
98
99/// Ensures directory exists with restrictive permissions on Unix.
100pub(crate) fn ensure_dir(path: &Path) -> SshCliResult<()> {
101    std::fs::create_dir_all(path).map_err(|e| {
102        SshCliError::tls_msg(format!("create TLS dir {}: {e}", path.display()))
103    })?;
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-ish (tmp + rename) with 0o600 on Unix.
110pub(crate) fn write_secret_file(path: &Path, data: &[u8]) -> SshCliResult<()> {
111    if let Some(parent) = path.parent() {
112        ensure_dir(parent)?;
113    }
114    let tmp = path.with_extension("tmp");
115    std::fs::write(&tmp, data)
116        .map_err(|e| SshCliError::tls_msg(format!("write {}: {e}", tmp.display())))?;
117    // Best-effort secret file mode before/after rename.
118    let _ = crate::fs_perm::set_secret_file_mode(&tmp);
119    std::fs::rename(&tmp, path)
120        .map_err(|e| SshCliError::tls_msg(format!("rename {}: {e}", path.display())))?;
121    let _ = crate::fs_perm::set_secret_file_mode(path);
122    Ok(())
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128    use tempfile::TempDir;
129
130    #[test]
131    fn sanitize_domain_ok() {
132        assert_eq!(sanitize_domain_leaf("Example.COM").unwrap(), "example.com");
133    }
134
135    #[test]
136    fn sanitize_domain_rejects_traversal() {
137        assert!(sanitize_domain_leaf("../etc").is_err());
138        assert!(sanitize_domain_leaf("a/b").is_err());
139    }
140
141    #[test]
142    fn mtls_dir_layout() {
143        let t = TempDir::new().unwrap();
144        let d = mtls_identity_dir(Some(t.path()), "agent-1").unwrap();
145        assert!(d.ends_with("tls/mtls/agent-1") || d.ends_with(r"tls\mtls\agent-1"));
146    }
147}