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).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
41pub 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
48pub fn acme_domain_dir(config_override: Option<&Path>, domain: &str) -> SshCliResult<PathBuf> {
50 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#[must_use]
59pub fn cert_pem_path(dir: &Path) -> PathBuf {
60 dir.join(TLS_CERT_FILE_NAME)
61}
62
63#[must_use]
65pub fn key_pem_path(dir: &Path) -> PathBuf {
66 dir.join(TLS_KEY_FILE_NAME)
67}
68
69#[must_use]
71pub fn order_json_path(dir: &Path) -> PathBuf {
72 dir.join(TLS_ACME_ORDER_FILE_NAME)
73}
74
75fn 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
99pub(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 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}