1#![forbid(unsafe_code)]
4use crate::errors::{SshCliError, SshCliResult};
11use std::path::Path;
12use unicode_normalization::UnicodeNormalization;
13
14#[inline]
15fn path_err(msg: impl Into<String>) -> SshCliError {
16 SshCliError::InvalidArgument(msg.into())
17}
18
19const WINDOWS_RESERVED_NAMES: &[&str] = &[
21 "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
22 "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
23];
24
25const FORBIDDEN_CHARS: &[char] = &['/', '\\', ':', '*', '?', '"', '<', '>', '|', '\0'];
27
28pub const WINDOWS_MAX_PATH: usize = 260;
30
31pub const WINDOWS_MAX_COMPONENT: usize = 255;
33
34const _: () = assert!(!WINDOWS_RESERVED_NAMES.is_empty());
35const _: () = assert!(!FORBIDDEN_CHARS.is_empty());
36const _: () = assert!(WINDOWS_MAX_PATH > WINDOWS_MAX_COMPONENT);
37
38pub fn validate_name(name: &str) -> SshCliResult<()> {
61 if name.is_empty() {
62 return Err(path_err("file name cannot be empty"));
63 }
64
65 if name.contains("..") {
66 return Err(path_err(format!(
67 "file name contains path traversal component: '{name}'"
68 )));
69 }
70
71 for c in FORBIDDEN_CHARS {
72 if name.contains(*c) {
73 return Err(path_err(format!(
74 "file name contains forbidden character '{}': '{name}'",
75 c.escape_default()
76 )));
77 }
78 }
79
80 let name_upper = name.to_uppercase();
81 let root = name_upper.split('.').next().unwrap_or(&name_upper);
83 if WINDOWS_RESERVED_NAMES.contains(&root) {
84 return Err(path_err(format!(
85 "file name uses a Windows reserved name: '{name}'"
86 )));
87 }
88
89 if name.ends_with('.') || name.ends_with(' ') {
90 return Err(path_err(format!(
91 "file name cannot end with a dot or space: '{name}'"
92 )));
93 }
94
95 if name.chars().any(|c| c.is_whitespace()) {
98 return Err(path_err(format!(
99 "file name cannot contain whitespace: '{name}'"
100 )));
101 }
102
103 Ok(())
104}
105
106#[must_use]
121pub fn normalize_nfc(name: &str) -> String {
122 name.nfc().collect()
123}
124
125pub fn validate_and_normalize(name: &str) -> SshCliResult<crate::domain::VpsName> {
143 Ok(crate::domain::VpsName::try_new(name)?)
146}
147
148pub fn validate_no_traversal(path: &str) -> SshCliResult<()> {
165 if path.is_empty() {
166 return Err(path_err("path cannot be empty"));
167 }
168
169 let segments = path.split(['/', '\\']);
170 for segment in segments {
171 if segment == ".." {
172 return Err(path_err(format!(
173 "path contains path traversal component: '{path}'"
174 )));
175 }
176 }
177
178 Ok(())
179}
180
181pub const MAX_SECRETS_KEY_FILE_BYTES: u64 = 4_096;
183
184pub const MAX_CONFIG_TOML_BYTES: u64 = 4 * 1024 * 1024;
186
187pub const MAX_KNOWN_HOSTS_BYTES: u64 = 1_024 * 1024;
189
190pub const MAX_PEM_FILE_BYTES: u64 = 1_024 * 1024;
199
200const _: () = assert!(MAX_SECRETS_KEY_FILE_BYTES >= 64);
201const _: () = assert!(MAX_CONFIG_TOML_BYTES >= 4_096);
202const _: () = assert!(MAX_KNOWN_HOSTS_BYTES >= 256);
203
204pub fn xdg_config_dir() -> SshCliResult<std::path::PathBuf> {
214 directories::ProjectDirs::from(
215 crate::constants::PROJECT_QUALIFIER,
216 crate::constants::PROJECT_ORGANIZATION,
217 crate::constants::APP_NAME,
218 )
219 .map(|d| d.config_dir().to_path_buf())
220 .ok_or(SshCliError::XdgDirectory)
221}
222
223#[must_use]
225pub fn has_windows_long_path_prefix(path: &Path) -> bool {
226 let s = path.as_os_str().to_string_lossy();
227 s.starts_with(r"\\?\") || s.starts_with("//?/")
228}
229
230pub fn validate_local_path_length(path: &Path) -> SshCliResult<()> {
244 validate_local_path_length_inner(path, cfg!(windows))
245}
246
247fn validate_local_path_length_inner(path: &Path, enforce_windows_total: bool) -> SshCliResult<()> {
248 let raw = path.as_os_str().to_string_lossy();
251 let stripped = raw
252 .strip_prefix(r"\\?\")
253 .or_else(|| raw.strip_prefix("//?/"))
254 .unwrap_or(raw.as_ref());
255 for segment in stripped.split(['/', '\\']) {
256 if segment.is_empty() || segment == "." || segment == ".." {
257 continue;
258 }
259 if segment.len() == 2 && segment.as_bytes()[1] == b':' {
261 continue;
262 }
263 if segment.len() > WINDOWS_MAX_COMPONENT {
264 return Err(path_err(format!(
265 "path component exceeds {WINDOWS_MAX_COMPONENT} bytes (Windows limit): '{segment}'"
266 )));
267 }
268 }
269
270 if enforce_windows_total && !has_windows_long_path_prefix(path) {
271 let encoded_len = raw.len();
273 if encoded_len >= WINDOWS_MAX_PATH - 1 {
275 return Err(path_err(format!(
276 "local path length {encoded_len} approaches Windows MAX_PATH ({WINDOWS_MAX_PATH}); \
277 use a shorter path or the \\\\?\\ extended-length prefix"
278 )));
279 }
280 }
281
282 Ok(())
283}
284
285pub fn read_text_capped(path: &std::path::Path, max_bytes: u64) -> std::io::Result<String> {
293 use std::io::Read;
294
295 let meta = std::fs::metadata(path)?;
296 if meta.len() > max_bytes {
297 return Err(std::io::Error::new(
298 std::io::ErrorKind::InvalidData,
299 format!(
300 "file {} exceeds max size of {max_bytes} bytes",
301 path.display()
302 ),
303 ));
304 }
305
306 let file = std::fs::File::open(path)?;
307 let mut limited = file.take(max_bytes.saturating_add(1));
308 let mut buf = String::new();
309 limited.read_to_string(&mut buf)?;
310 if (buf.len() as u64) > max_bytes {
311 return Err(std::io::Error::new(
312 std::io::ErrorKind::InvalidData,
313 format!(
314 "file {} exceeds max size of {max_bytes} bytes",
315 path.display()
316 ),
317 ));
318 }
319 Ok(buf)
320}
321
322pub fn read_bytes_capped(path: &std::path::Path, max_bytes: u64) -> std::io::Result<Vec<u8>> {
337 use std::io::Read;
338
339 let meta = std::fs::metadata(path)?;
340 if meta.len() > max_bytes {
341 return Err(std::io::Error::new(
342 std::io::ErrorKind::InvalidData,
343 format!(
344 "file {} exceeds max size of {max_bytes} bytes",
345 path.display()
346 ),
347 ));
348 }
349
350 let file = std::fs::File::open(path)?;
351 let mut limited = file.take(max_bytes.saturating_add(1));
352 let mut buf = Vec::with_capacity(usize::try_from(meta.len()).unwrap_or(0));
355 limited.read_to_end(&mut buf)?;
356 if (buf.len() as u64) > max_bytes {
357 return Err(std::io::Error::new(
358 std::io::ErrorKind::InvalidData,
359 format!(
360 "file {} exceeds max size of {max_bytes} bytes",
361 path.display()
362 ),
363 ));
364 }
365 Ok(buf)
366}
367
368#[cfg(test)]
369mod tests {
370 use super::*;
371
372 #[test]
373 fn common_valid_name_passes() {
374 assert!(validate_name("meu-servidor").is_ok());
375 assert!(validate_name("vps_01").is_ok());
376 assert!(validate_name("servidor.produção").is_ok());
377 }
378
379 #[test]
380 fn empty_name_rejected() {
381 assert!(validate_name("").is_err());
382 }
383
384 #[test]
385 fn path_traversal_rejected() {
386 assert!(validate_name("..").is_err());
387 assert!(validate_name("../etc/passwd").is_err());
388 assert!(validate_name("foo/../bar").is_err());
389 }
390
391 #[test]
392 fn forbidden_chars_rejected() {
393 assert!(validate_name("foo/bar").is_err());
394 assert!(validate_name("foo\\bar").is_err());
395 assert!(validate_name("foo:bar").is_err());
396 assert!(validate_name("foo*bar").is_err());
397 assert!(validate_name("foo?bar").is_err());
398 }
399
400 #[test]
401 fn windows_reserved_names_rejected() {
402 assert!(validate_name("CON").is_err());
403 assert!(validate_name("con").is_err());
404 assert!(validate_name("NUL.txt").is_err());
405 assert!(validate_name("COM1").is_err());
406 assert!(validate_name("LPT9").is_err());
407 }
408
409 #[test]
410 fn name_ending_with_dot_rejected() {
411 assert!(validate_name("file.").is_err());
412 }
413
414 #[test]
415 fn name_with_internal_space_rejected() {
416 assert!(validate_name("a b").is_err());
417 assert!(validate_name("a\tb").is_err());
418 }
419
420 #[test]
421 fn name_ending_with_space_rejected() {
422 assert!(validate_name("file ").is_err());
423 }
424
425 #[test]
426 fn normalize_nfc_returns_string() {
427 let result = normalize_nfc("servidor");
428 assert_eq!(result, "servidor");
429 }
430
431 #[test]
432 fn validate_and_normalize_returns_valid_string() {
433 let result = validate_and_normalize("meu-servidor").unwrap();
434 assert_eq!(result.as_str(), "meu-servidor");
435 }
436
437 #[test]
438 fn validate_no_traversal_accepts_normal_path() {
439 assert!(validate_no_traversal("/home/usuario/file.txt").is_ok());
440 assert!(validate_no_traversal("relative/path/file.txt").is_ok());
441 }
442
443 #[test]
444 fn validate_no_traversal_rejects_traversal() {
445 assert!(validate_no_traversal("/home/../etc/passwd").is_err());
446 assert!(validate_no_traversal("../secreto").is_err());
447 }
448
449 #[test]
450 fn validate_no_traversal_rejects_empty() {
451 assert!(validate_no_traversal("").is_err());
452 }
453
454 #[test]
455 fn name_with_brazilian_accents_valid() {
456 assert!(validate_name("produção").is_ok());
457 assert!(validate_name("ação-configuração").is_ok());
458 }
459
460 #[test]
461 fn name_with_cjk_unicode_valid() {
462 assert!(validate_name("server-\u{4e16}\u{754c}").is_ok());
463 }
464
465 #[test]
466 fn name_with_emoji_valid() {
467 assert!(validate_name("server-\u{1f680}").is_ok());
468 }
469
470 #[test]
471 fn windows_reserved_mixed_case_rejected() {
472 assert!(validate_name("cOn").is_err());
473 assert!(validate_name("Nul").is_err());
474 assert!(validate_name("lPt1").is_err());
475 }
476
477 #[test]
478 fn normalize_nfc_converts_nfd_to_nfc() {
479 let nfd = "e\u{0301}"; let nfc = "\u{00e9}"; assert_eq!(normalize_nfc(nfd), nfc);
482 }
483
484 #[test]
485 fn normalize_nfc_preserves_nfc() {
486 let nfc = "\u{00e9}";
487 assert_eq!(normalize_nfc(nfc), nfc);
488 }
489
490 #[test]
491 fn normalize_nfc_idempotent() {
492 let input = "cafe\u{0301}";
493 let once = normalize_nfc(input);
494 let twice = normalize_nfc(&once);
495 assert_eq!(once, twice);
496 }
497
498 #[test]
499 fn validate_and_normalize_converts_nfd() {
500 let result = validate_and_normalize("cafe\u{0301}").unwrap();
501 assert_eq!(result.as_str(), "caf\u{00e9}");
502 }
503
504 #[test]
505 fn validate_no_traversal_rejects_backslash() {
506 assert!(validate_no_traversal("foo\\..\\bar").is_err());
507 }
508
509 #[test]
510 fn validate_no_traversal_accepts_dot_alone() {
511 assert!(validate_no_traversal("./file").is_ok());
512 }
513
514 #[test]
515 fn long_path_prefix_detected() {
516 assert!(has_windows_long_path_prefix(Path::new(r"\\?\C:\very\long")));
517 assert!(has_windows_long_path_prefix(Path::new("//?/C:/very/long")));
518 assert!(!has_windows_long_path_prefix(Path::new(r"C:\short")));
519 }
520
521 #[test]
522 fn component_over_255_rejected() {
523 let long = "a".repeat(WINDOWS_MAX_COMPONENT + 1);
524 let p = Path::new(&long);
525 let err = validate_local_path_length_inner(p, false).unwrap_err();
526 assert!(err.to_string().contains("255"));
527 }
528
529 #[test]
530 fn windows_total_path_limit_enforced() {
531 let mut s = String::from("C:");
533 while s.len() < WINDOWS_MAX_PATH - 1 {
534 s.push_str("\\seg");
535 }
536 let p = Path::new(&s);
537 assert!(validate_local_path_length_inner(p, true).is_err());
538 let extended = format!(r"\\?\{s}");
539 assert!(validate_local_path_length_inner(Path::new(&extended), true).is_ok());
540 }
541
542 #[test]
543 fn short_local_path_ok() {
544 assert!(
545 validate_local_path_length(Path::new("/home/user/.config/ssh-cli/config.toml")).is_ok()
546 );
547 }
548
549 #[test]
550 fn read_text_capped_accepts_small_file() {
551 let dir = tempfile::tempdir().unwrap();
552 let p = dir.path().join("k.txt");
553 std::fs::write(&p, "abc").unwrap();
554 let s = read_text_capped(&p, 64).unwrap();
555 assert_eq!(s, "abc");
556 }
557
558 #[test]
559 fn read_text_capped_rejects_oversize() {
560 let dir = tempfile::tempdir().unwrap();
561 let p = dir.path().join("big.txt");
562 std::fs::write(&p, "0123456789").unwrap();
563 let err = read_text_capped(&p, 4).unwrap_err();
564 assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
565 }
566}