Skip to main content

ssh_cli/domain/
command.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Remote command payload and key path newtypes.
3#![forbid(unsafe_code)]
4
5use super::error::DomainError;
6use super::limits::CharLimit;
7use crate::errors::{SshCliError, SshCliResult};
8use serde::{Deserialize, Deserializer, Serialize, Serializer};
9use std::fmt;
10use std::path::{Path, PathBuf};
11
12/// Remote shell command payload (non-empty after trim, no NUL bytes).
13#[repr(transparent)]
14#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
15pub struct RemoteCommand(String);
16
17impl RemoteCommand {
18    /// Parses a remote command at the system boundary.
19    pub fn try_new(raw: impl AsRef<str>) -> Result<Self, DomainError> {
20        let s = raw.as_ref();
21        if s.trim().is_empty() {
22            return Err(DomainError::new("remote_command", "empty command"));
23        }
24        if s.as_bytes().contains(&0) {
25            return Err(DomainError::new(
26                "remote_command",
27                "command contains null byte",
28            ));
29        }
30        Ok(Self(s.to_owned()))
31    }
32
33    /// Borrows the command text.
34    #[must_use]
35    pub fn as_str(&self) -> &str {
36        &self.0
37    }
38
39    /// Consumes into inner `String`.
40    #[must_use]
41    pub fn into_inner(self) -> String {
42        self.0
43    }
44
45    /// Validates against a [`CharLimit`] (command length).
46    pub fn check_length(&self, max: CharLimit) -> SshCliResult<()> {
47        let lim = max.effective();
48        let len = self.0.chars().count();
49        if len > lim {
50            return Err(SshCliError::CommandTooLong {
51                max: max.wire(),
52                len,
53            });
54        }
55        Ok(())
56    }
57}
58
59impl AsRef<str> for RemoteCommand {
60    fn as_ref(&self) -> &str {
61        self.as_str()
62    }
63}
64
65impl fmt::Display for RemoteCommand {
66    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
67        f.write_str(self.as_str())
68    }
69}
70
71impl<'de> Deserialize<'de> for RemoteCommand {
72    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
73        let s = String::deserialize(d)?;
74        Self::try_new(s).map_err(serde::de::Error::custom)
75    }
76}
77
78/// Non-empty private key path.
79#[repr(transparent)]
80#[derive(Clone, Debug, PartialEq, Eq, Hash)]
81pub struct KeyPath(PathBuf);
82
83impl KeyPath {
84    /// Parses a non-empty key path.
85    pub fn try_new(raw: impl AsRef<Path>) -> Result<Self, DomainError> {
86        let p = raw.as_ref();
87        let s = p.to_string_lossy();
88        if s.trim().is_empty() {
89            return Err(DomainError::new("key_path", "key path must not be empty"));
90        }
91        Ok(Self(p.to_path_buf()))
92    }
93
94    /// From optional string (None / empty → None).
95    pub fn try_from_optional(raw: Option<impl AsRef<str>>) -> Result<Option<Self>, DomainError> {
96        match raw {
97            None => Ok(None),
98            Some(s) => {
99                let t = s.as_ref().trim();
100                if t.is_empty() {
101                    Ok(None)
102                } else {
103                    Self::try_new(t).map(Some)
104                }
105            }
106        }
107    }
108
109    /// Borrows as [`Path`].
110    #[must_use]
111    pub fn as_path(&self) -> &Path {
112        &self.0
113    }
114
115    /// Wire string (lossy if non-UTF8).
116    #[must_use]
117    pub fn to_string_lossy_owned(&self) -> String {
118        self.0.to_string_lossy().into_owned()
119    }
120
121    /// Consumes into [`PathBuf`].
122    #[must_use]
123    pub fn into_path_buf(self) -> PathBuf {
124        self.0
125    }
126}
127
128impl AsRef<Path> for KeyPath {
129    fn as_ref(&self) -> &Path {
130        self.as_path()
131    }
132}
133
134impl fmt::Display for KeyPath {
135    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
136        write!(f, "{}", self.0.display())
137    }
138}
139
140impl Serialize for KeyPath {
141    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
142        s.serialize_str(&self.to_string_lossy_owned())
143    }
144}
145
146impl<'de> Deserialize<'de> for KeyPath {
147    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
148        let s = String::deserialize(d)?;
149        Self::try_new(s).map_err(serde::de::Error::custom)
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156
157    #[test]
158    fn remote_command_and_key() {
159        assert!(RemoteCommand::try_new("  ").is_err());
160        assert!(RemoteCommand::try_new("echo\0x").is_err());
161        assert!(RemoteCommand::try_new("uptime").is_ok());
162        assert!(KeyPath::try_new("").is_err());
163        assert!(KeyPath::try_new("/home/u/.ssh/id_ed25519").is_ok());
164        assert!(KeyPath::try_from_optional(Some("")).unwrap().is_none());
165    }
166}