1#![forbid(unsafe_code)]
3use 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
14pub 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
26pub fn tls_root_dir(config_override: Option<&Path>) -> SshCliResult<PathBuf> {
28 resolve_tls_root(config_override)
29}
30
31pub 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
40pub 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
47pub fn acme_domain_dir(config_override: Option<&Path>, domain: &str) -> SshCliResult<PathBuf> {
49 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#[must_use]
58pub fn cert_pem_path(dir: &Path) -> PathBuf {
59 dir.join(TLS_CERT_FILE_NAME)
60}
61
62#[must_use]
64pub fn key_pem_path(dir: &Path) -> PathBuf {
65 dir.join(TLS_KEY_FILE_NAME)
66}
67
68#[must_use]
70pub fn order_json_path(dir: &Path) -> PathBuf {
71 dir.join(TLS_ACME_ORDER_FILE_NAME)
72}
73
74fn 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
100pub(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 let _ = crate::fs_perm::set_secret_dir_mode(path);
106 Ok(())
107}
108
109pub(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 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}