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<()> {
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}