Skip to main content

rustferry_ssh/
config.rs

1use std::{
2    fs::{self, File, OpenOptions},
3    io::Read,
4    net::IpAddr,
5};
6
7use base64::{Engine as _, engine::general_purpose};
8use camino::{Utf8Path, Utf8PathBuf};
9use sha2::{Digest as _, Sha256};
10use thiserror::Error;
11
12const MAX_REMOTE_NAME_BYTES: usize = 64;
13const MAX_HOST_BYTES: usize = 253;
14const MAX_USER_BYTES: usize = 64;
15const MAX_KNOWN_HOSTS_BYTES: u64 = 16 * 1024;
16const MAX_HOST_KEY_BLOB_BYTES: usize = 16 * 1024;
17
18/// Invalid named SSH endpoint configuration.
19#[derive(Clone, Debug, Eq, Error, PartialEq)]
20pub enum SshConfigError {
21    /// A stable text field did not match its conservative grammar.
22    #[error("SSH endpoint field `{field}` is invalid: {reason}")]
23    InvalidField {
24        /// Stable field name.
25        field: &'static str,
26        /// Stable public reason.
27        reason: &'static str,
28    },
29    /// Port zero is never a valid SSH destination.
30    #[error("SSH endpoint port must be between 1 and 65535")]
31    InvalidPort,
32    /// A security-sensitive path was not absolute.
33    #[error("SSH endpoint field `{field}` must be an absolute UTF-8 path")]
34    PathNotAbsolute {
35        /// Stable field name.
36        field: &'static str,
37    },
38    /// A security-sensitive path contained control data or OpenSSH expansion syntax.
39    #[error("SSH endpoint field `{field}` contains unsafe path syntax")]
40    UnsafePath {
41        /// Stable field name.
42        field: &'static str,
43    },
44    /// A security-sensitive path could not be inspected.
45    #[error("SSH endpoint field `{field}` is not readable")]
46    PathUnreadable {
47        /// Stable field name.
48        field: &'static str,
49    },
50    /// A security-sensitive path was a symlink or another non-regular object.
51    #[error("SSH endpoint field `{field}` must name a regular file, not a symlink")]
52    PathNotRegularFile {
53        /// Stable field name.
54        field: &'static str,
55    },
56    /// An empty trust or identity file cannot authenticate a worker.
57    #[error("SSH endpoint field `{field}` must not be empty")]
58    EmptyFile {
59        /// Stable field name.
60        field: &'static str,
61    },
62    /// An identity file is readable by another local user.
63    #[error("SSH identity file permissions must not allow group or other access")]
64    IdentityFilePermissions,
65    /// An identity path traverses a directory another local user can replace.
66    #[error("SSH identity path traverses an untrusted directory")]
67    IdentityPathPermissions,
68    /// The trust file could be replaced by another local user.
69    #[error("SSH known-hosts file permissions must not allow group or other writes")]
70    KnownHostsFilePermissions,
71    /// The pinned fingerprint was not canonical OpenSSH SHA-256 syntax.
72    #[error("SSH host-key fingerprint must be canonical SHA256 base64 without padding")]
73    InvalidHostKeyFingerprint,
74    /// The dedicated trust file did not contain exactly the pinned endpoint key.
75    #[error(
76        "SSH known-hosts file must contain exactly one supported key matching the endpoint and pinned fingerprint"
77    )]
78    InvalidKnownHosts,
79    /// A private operation-owned trust snapshot could not be created.
80    #[error("SSH known-hosts snapshot could not be created securely")]
81    KnownHostsSnapshotFailed,
82}
83
84/// Canonical OpenSSH `SHA256:` host-key fingerprint.
85#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
86pub struct SshHostKeySha256(String);
87
88impl SshHostKeySha256 {
89    /// Validate a canonical SHA-256 fingerprint without base64 padding.
90    ///
91    /// # Errors
92    ///
93    /// Returns [`SshConfigError::InvalidHostKeyFingerprint`] for any other syntax.
94    pub fn new(value: impl Into<String>) -> Result<Self, SshConfigError> {
95        let value = value.into();
96        let encoded = value
97            .strip_prefix("SHA256:")
98            .ok_or(SshConfigError::InvalidHostKeyFingerprint)?;
99        let digest = general_purpose::STANDARD_NO_PAD
100            .decode(encoded)
101            .map_err(|_| SshConfigError::InvalidHostKeyFingerprint)?;
102        if digest.len() != 32 || general_purpose::STANDARD_NO_PAD.encode(&digest) != encoded {
103            return Err(SshConfigError::InvalidHostKeyFingerprint);
104        }
105        Ok(Self(value))
106    }
107
108    /// Return the canonical fingerprint.
109    pub fn as_str(&self) -> &str {
110        &self.0
111    }
112}
113
114/// Validated stable name used to select one configured remote Mac.
115#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
116pub struct SshRemoteName(String);
117
118impl SshRemoteName {
119    /// Validate a stable endpoint name.
120    ///
121    /// # Errors
122    ///
123    /// Returns [`SshConfigError::InvalidField`] for empty, oversized, or unsafe names.
124    pub fn new(value: impl Into<String>) -> Result<Self, SshConfigError> {
125        let value = value.into();
126        validate_atom("remote_name", &value, MAX_REMOTE_NAME_BYTES, true)?;
127        Ok(Self(value))
128    }
129
130    /// Return the validated name.
131    pub fn as_str(&self) -> &str {
132        &self.0
133    }
134}
135
136/// Validated SSH host name or IP literal.
137#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
138pub struct SshHost(String);
139
140impl SshHost {
141    /// Validate an ASCII DNS name or an IPv4/IPv6 literal.
142    ///
143    /// # Errors
144    ///
145    /// Returns [`SshConfigError::InvalidField`] for a non-canonical or unsafe host.
146    pub fn new(value: impl Into<String>) -> Result<Self, SshConfigError> {
147        let value = value.into();
148        if value.is_empty() || value.len() > MAX_HOST_BYTES {
149            return Err(invalid("host", "value length is outside 1..=253 bytes"));
150        }
151        if value.parse::<IpAddr>().is_ok() || valid_dns_name(&value) {
152            Ok(Self(value))
153        } else {
154            Err(invalid(
155                "host",
156                "value must be an ASCII DNS name or an IP literal",
157            ))
158        }
159    }
160
161    /// Return the validated host.
162    pub fn as_str(&self) -> &str {
163        &self.0
164    }
165}
166
167/// Validated SSH login user.
168#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
169pub struct SshUser(String);
170
171impl SshUser {
172    /// Validate a conservative portable SSH login name.
173    ///
174    /// # Errors
175    ///
176    /// Returns [`SshConfigError::InvalidField`] for empty, oversized, or unsafe names.
177    pub fn new(value: impl Into<String>) -> Result<Self, SshConfigError> {
178        let value = value.into();
179        validate_atom("user", &value, MAX_USER_BYTES, false)?;
180        Ok(Self(value))
181    }
182
183    /// Return the validated user.
184    pub fn as_str(&self) -> &str {
185        &self.0
186    }
187}
188
189/// Validated connection details for one named macOS worker.
190#[derive(Clone, Debug, Eq, PartialEq)]
191pub struct SshEndpointConfig {
192    remote_name: SshRemoteName,
193    host: SshHost,
194    user: SshUser,
195    port: u16,
196    known_hosts_file: Utf8PathBuf,
197    host_key_sha256: SshHostKeySha256,
198    identity_file: Option<Utf8PathBuf>,
199}
200
201#[derive(Debug)]
202pub(crate) struct SshIdentityFileGuard {
203    path: Utf8PathBuf,
204    file: File,
205}
206
207impl SshIdentityFileGuard {
208    pub(crate) fn revalidate(&self) -> Result<(), SshConfigError> {
209        let opened_metadata = self
210            .file
211            .metadata()
212            .map_err(|_| SshConfigError::PathUnreadable {
213                field: "identity_file",
214            })?;
215        validate_identity_metadata(&opened_metadata)?;
216        recheck_open_file_identity(&self.path, &opened_metadata, "identity_file")?;
217        #[cfg(unix)]
218        validate_unix_identity_path(&self.path, &opened_metadata)?;
219        Ok(())
220    }
221}
222
223impl SshEndpointConfig {
224    /// Create a named endpoint backed by one dedicated known-hosts file.
225    ///
226    /// The dedicated trust file is parsed and fingerprinted. The identity value
227    /// remains an absolute path reference; its private-key bytes are never read.
228    ///
229    /// # Errors
230    ///
231    /// Returns a typed error when a port or security-sensitive file is unsafe.
232    pub fn new(
233        remote_name: SshRemoteName,
234        host: SshHost,
235        user: SshUser,
236        port: u16,
237        known_hosts_file: Utf8PathBuf,
238        host_key_sha256: SshHostKeySha256,
239        identity_file: Option<Utf8PathBuf>,
240    ) -> Result<Self, SshConfigError> {
241        if port == 0 {
242            return Err(SshConfigError::InvalidPort);
243        }
244        let config = Self {
245            remote_name,
246            host,
247            user,
248            port,
249            known_hosts_file,
250            host_key_sha256,
251            identity_file,
252        };
253        config.validate_files()?;
254        Ok(config)
255    }
256
257    /// Re-check mutable filesystem boundaries before every connection.
258    ///
259    /// # Errors
260    ///
261    /// Returns a typed error if a trusted path was replaced or removed.
262    pub fn validate_files(&self) -> Result<(), SshConfigError> {
263        self.validated_known_hosts_bytes()?;
264        drop(self.open_identity_file_guard()?);
265        Ok(())
266    }
267
268    pub(crate) fn open_identity_file_guard(
269        &self,
270    ) -> Result<Option<SshIdentityFileGuard>, SshConfigError> {
271        self.identity_file
272            .as_ref()
273            .map(|path| {
274                let file = open_identity_no_follow(path)?;
275                let guard = SshIdentityFileGuard {
276                    path: path.clone(),
277                    file,
278                };
279                guard.revalidate()?;
280                Ok(guard)
281            })
282            .transpose()
283    }
284
285    pub(crate) fn validated_known_hosts_bytes(&self) -> Result<Vec<u8>, SshConfigError> {
286        let bytes = read_known_hosts_no_follow(&self.known_hosts_file)?;
287        validate_known_hosts_bytes(
288            &bytes,
289            &expected_host_token(&self.host, self.port),
290            &self.host_key_sha256,
291        )
292    }
293
294    /// Stable configured endpoint name.
295    pub fn remote_name(&self) -> &SshRemoteName {
296        &self.remote_name
297    }
298
299    /// Validated destination host.
300    pub fn host(&self) -> &SshHost {
301        &self.host
302    }
303
304    /// Validated login user.
305    pub fn user(&self) -> &SshUser {
306        &self.user
307    }
308
309    /// Validated TCP port.
310    pub const fn port(&self) -> u16 {
311        self.port
312    }
313
314    /// Dedicated known-hosts file.
315    pub fn known_hosts_file(&self) -> &Utf8Path {
316        &self.known_hosts_file
317    }
318
319    /// Exact host-key fingerprint required from the dedicated trust file.
320    pub fn host_key_sha256(&self) -> &SshHostKeySha256 {
321        &self.host_key_sha256
322    }
323
324    /// Optional private-key path reference. File bytes are never exposed.
325    pub fn identity_file(&self) -> Option<&Utf8Path> {
326        self.identity_file.as_deref()
327    }
328}
329
330fn expected_host_token(host: &SshHost, port: u16) -> String {
331    if port == 22 {
332        host.as_str().to_owned()
333    } else {
334        format!("[{}]:{port}", host.as_str())
335    }
336}
337
338fn read_known_hosts_no_follow(path: &Utf8Path) -> Result<Vec<u8>, SshConfigError> {
339    validate_sensitive_path(path, "known_hosts_file")?;
340    let mut file = open_no_follow(path, "known_hosts_file")?;
341    let opened_metadata = file
342        .metadata()
343        .map_err(|_| SshConfigError::PathUnreadable {
344            field: "known_hosts_file",
345        })?;
346    validate_known_hosts_metadata(&opened_metadata)?;
347    recheck_open_file_identity(path, &opened_metadata, "known_hosts_file")?;
348    let limit = MAX_KNOWN_HOSTS_BYTES.saturating_add(1);
349    let maximum_capacity = usize::try_from(MAX_KNOWN_HOSTS_BYTES).unwrap_or(usize::MAX);
350    let mut bytes = Vec::with_capacity(
351        usize::try_from(opened_metadata.len())
352            .unwrap_or(usize::MAX)
353            .min(maximum_capacity),
354    );
355    file.by_ref()
356        .take(limit)
357        .read_to_end(&mut bytes)
358        .map_err(|_| SshConfigError::PathUnreadable {
359            field: "known_hosts_file",
360        })?;
361    if u64::try_from(bytes.len()).unwrap_or(u64::MAX) > MAX_KNOWN_HOSTS_BYTES {
362        return Err(SshConfigError::InvalidKnownHosts);
363    }
364    recheck_open_file_identity(path, &opened_metadata, "known_hosts_file")?;
365    Ok(bytes)
366}
367
368fn open_identity_no_follow(path: &Utf8Path) -> Result<File, SshConfigError> {
369    validate_sensitive_path(path, "identity_file")?;
370    let file = open_no_follow(path, "identity_file")?;
371    let metadata = file
372        .metadata()
373        .map_err(|_| SshConfigError::PathUnreadable {
374            field: "identity_file",
375        })?;
376    validate_identity_metadata(&metadata)?;
377    recheck_open_file_identity(path, &metadata, "identity_file")?;
378    #[cfg(unix)]
379    validate_unix_identity_path(path, &metadata)?;
380    Ok(file)
381}
382
383fn recheck_open_file_identity(
384    path: &Utf8Path,
385    opened_metadata: &fs::Metadata,
386    field: &'static str,
387) -> Result<(), SshConfigError> {
388    let path_metadata =
389        fs::symlink_metadata(path).map_err(|_| SshConfigError::PathUnreadable { field })?;
390    if path_metadata.file_type().is_symlink() || !path_metadata.file_type().is_file() {
391        return Err(SshConfigError::PathNotRegularFile { field });
392    }
393    if !same_open_file_identity(opened_metadata, &path_metadata) {
394        return Err(SshConfigError::PathUnreadable { field });
395    }
396    Ok(())
397}
398
399fn open_no_follow(path: &Utf8Path, field: &'static str) -> Result<File, SshConfigError> {
400    let preflight =
401        fs::symlink_metadata(path).map_err(|_| SshConfigError::PathUnreadable { field })?;
402    if preflight.file_type().is_symlink() || !preflight.file_type().is_file() {
403        return Err(SshConfigError::PathNotRegularFile { field });
404    }
405    let mut options = OpenOptions::new();
406    options.read(true);
407    #[cfg(unix)]
408    {
409        use std::os::unix::fs::OpenOptionsExt as _;
410        options.custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_NONBLOCK);
411    }
412    #[cfg(windows)]
413    {
414        use std::os::windows::fs::OpenOptionsExt as _;
415        const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
416        const FILE_SHARE_READ: u32 = 0x0000_0001;
417        options
418            .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT)
419            .share_mode(FILE_SHARE_READ);
420    }
421    options
422        .open(path)
423        .map_err(|_| SshConfigError::PathUnreadable { field })
424}
425
426fn validate_known_hosts_metadata(metadata: &fs::Metadata) -> Result<(), SshConfigError> {
427    if !metadata.file_type().is_file() {
428        return Err(SshConfigError::PathNotRegularFile {
429            field: "known_hosts_file",
430        });
431    }
432    if metadata.len() == 0 {
433        return Err(SshConfigError::EmptyFile {
434            field: "known_hosts_file",
435        });
436    }
437    if metadata.len() > MAX_KNOWN_HOSTS_BYTES {
438        return Err(SshConfigError::InvalidKnownHosts);
439    }
440    #[cfg(unix)]
441    {
442        use std::os::unix::fs::MetadataExt as _;
443        if metadata.mode() & 0o022 != 0 {
444            return Err(SshConfigError::KnownHostsFilePermissions);
445        }
446    }
447    Ok(())
448}
449
450#[cfg(unix)]
451fn same_open_file_identity(opened: &fs::Metadata, path: &fs::Metadata) -> bool {
452    use std::os::unix::fs::MetadataExt as _;
453    opened.dev() == path.dev() && opened.ino() == path.ino()
454}
455
456#[cfg(not(unix))]
457fn same_open_file_identity(opened: &fs::Metadata, path: &fs::Metadata) -> bool {
458    opened.file_type().is_file() && path.file_type().is_file() && opened.len() == path.len()
459}
460
461fn validate_known_hosts_bytes(
462    bytes: &[u8],
463    expected_host: &str,
464    expected_fingerprint: &SshHostKeySha256,
465) -> Result<Vec<u8>, SshConfigError> {
466    let text = std::str::from_utf8(bytes).map_err(|_| SshConfigError::InvalidKnownHosts)?;
467    let entries = text
468        .lines()
469        .map(str::trim)
470        .filter(|line| !line.is_empty() && !line.starts_with('#'))
471        .collect::<Vec<_>>();
472    let [entry] = entries.as_slice() else {
473        return Err(SshConfigError::InvalidKnownHosts);
474    };
475    let mut fields = entry.split_ascii_whitespace();
476    let host = fields.next().ok_or(SshConfigError::InvalidKnownHosts)?;
477    let key_type = fields.next().ok_or(SshConfigError::InvalidKnownHosts)?;
478    let encoded_key = fields.next().ok_or(SshConfigError::InvalidKnownHosts)?;
479    if host != expected_host || !supported_host_key_type(key_type) {
480        return Err(SshConfigError::InvalidKnownHosts);
481    }
482    let key_blob = general_purpose::STANDARD
483        .decode(encoded_key)
484        .map_err(|_| SshConfigError::InvalidKnownHosts)?;
485    if key_blob.is_empty()
486        || key_blob.len() > MAX_HOST_KEY_BLOB_BYTES
487        || !key_blob_type_matches(&key_blob, key_type)
488    {
489        return Err(SshConfigError::InvalidKnownHosts);
490    }
491    let digest = Sha256::digest(&key_blob);
492    let fingerprint = format!("SHA256:{}", general_purpose::STANDARD_NO_PAD.encode(digest));
493    if fingerprint != expected_fingerprint.as_str() {
494        return Err(SshConfigError::InvalidKnownHosts);
495    }
496    Ok(format!(
497        "{host} {key_type} {}\n",
498        general_purpose::STANDARD.encode(key_blob)
499    )
500    .into_bytes())
501}
502
503fn supported_host_key_type(key_type: &str) -> bool {
504    matches!(
505        key_type,
506        "ssh-ed25519"
507            | "ecdsa-sha2-nistp256"
508            | "ecdsa-sha2-nistp384"
509            | "ecdsa-sha2-nistp521"
510            | "ssh-rsa"
511            | "sk-ssh-ed25519@openssh.com"
512            | "sk-ecdsa-sha2-nistp256@openssh.com"
513    )
514}
515
516fn key_blob_type_matches(blob: &[u8], expected_type: &str) -> bool {
517    let Some(length_bytes) = blob.get(..4) else {
518        return false;
519    };
520    let length = u32::from_be_bytes(
521        length_bytes
522            .try_into()
523            .expect("four-byte slice has exact array length"),
524    ) as usize;
525    let Some(key_type) = blob.get(4..4_usize.saturating_add(length)) else {
526        return false;
527    };
528    key_type == expected_type.as_bytes() && blob.len() > 4_usize.saturating_add(length)
529}
530
531fn validate_atom(
532    field: &'static str,
533    value: &str,
534    maximum: usize,
535    require_alphanumeric_first: bool,
536) -> Result<(), SshConfigError> {
537    if value.is_empty() || value.len() > maximum {
538        return Err(invalid(field, "value length is outside the accepted bound"));
539    }
540    let mut bytes = value.bytes();
541    let first = bytes.next().expect("non-empty value has a first byte");
542    if !(first.is_ascii_alphanumeric() || (!require_alphanumeric_first && first == b'_')) {
543        return Err(invalid(field, "value has an unsafe leading byte"));
544    }
545    if !bytes.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) {
546        return Err(invalid(
547            field,
548            "value may contain only ASCII letters, digits, dot, underscore, and hyphen",
549        ));
550    }
551    Ok(())
552}
553
554fn valid_dns_name(value: &str) -> bool {
555    if !value.is_ascii() || value.bytes().any(|byte| byte.is_ascii_control()) {
556        return false;
557    }
558    let name = value.strip_suffix('.').unwrap_or(value);
559    !name.is_empty()
560        && name.split('.').all(|label| {
561            !label.is_empty()
562                && label.len() <= 63
563                && label
564                    .bytes()
565                    .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
566                && label
567                    .as_bytes()
568                    .first()
569                    .is_some_and(u8::is_ascii_alphanumeric)
570                && label
571                    .as_bytes()
572                    .last()
573                    .is_some_and(u8::is_ascii_alphanumeric)
574        })
575}
576
577fn validate_identity_metadata(metadata: &fs::Metadata) -> Result<(), SshConfigError> {
578    if !metadata.file_type().is_file() {
579        return Err(SshConfigError::PathNotRegularFile {
580            field: "identity_file",
581        });
582    }
583    if metadata.len() == 0 {
584        return Err(SshConfigError::EmptyFile {
585            field: "identity_file",
586        });
587    }
588    #[cfg(unix)]
589    {
590        use std::os::unix::fs::MetadataExt as _;
591        if metadata.mode() & 0o077 != 0 {
592            return Err(SshConfigError::IdentityFilePermissions);
593        }
594        if metadata.nlink() != 1 {
595            return Err(SshConfigError::IdentityPathPermissions);
596        }
597    }
598    Ok(())
599}
600
601#[cfg(unix)]
602fn validate_unix_identity_path(
603    path: &Utf8Path,
604    opened_metadata: &fs::Metadata,
605) -> Result<(), SshConfigError> {
606    use std::os::unix::fs::MetadataExt as _;
607
608    let canonical = fs::canonicalize(path).map_err(|_| SshConfigError::PathUnreadable {
609        field: "identity_file",
610    })?;
611    let canonical_metadata =
612        fs::symlink_metadata(&canonical).map_err(|_| SshConfigError::PathUnreadable {
613            field: "identity_file",
614        })?;
615    if canonical_metadata.dev() != opened_metadata.dev()
616        || canonical_metadata.ino() != opened_metadata.ino()
617    {
618        return Err(SshConfigError::PathUnreadable {
619            field: "identity_file",
620        });
621    }
622    validate_unix_identity_ancestors(path.as_std_path(), opened_metadata.uid())?;
623    validate_unix_identity_ancestors(&canonical, opened_metadata.uid())
624}
625
626#[cfg(unix)]
627fn validate_unix_identity_ancestors(
628    path: &std::path::Path,
629    key_uid: u32,
630) -> Result<(), SshConfigError> {
631    use std::os::unix::fs::MetadataExt as _;
632
633    for ancestor in path.ancestors().skip(1) {
634        let metadata =
635            fs::symlink_metadata(ancestor).map_err(|_| SshConfigError::IdentityPathPermissions)?;
636        let trusted_owner = metadata.uid() == key_uid || metadata.uid() == 0;
637        if metadata.file_type().is_symlink() {
638            if !trusted_owner {
639                return Err(SshConfigError::IdentityPathPermissions);
640            }
641            continue;
642        }
643        let mode = metadata.mode();
644        let writable_by_another_principal = mode & 0o022 != 0;
645        #[allow(clippy::useless_conversion)]
646        let sticky = mode & u32::from(libc::S_ISVTX) != 0;
647        if !metadata.file_type().is_dir()
648            || !trusted_owner
649            || (writable_by_another_principal && !sticky)
650        {
651            return Err(SshConfigError::IdentityPathPermissions);
652        }
653    }
654    Ok(())
655}
656
657fn validate_sensitive_path(path: &Utf8Path, field: &'static str) -> Result<(), SshConfigError> {
658    if !path.is_absolute() {
659        return Err(SshConfigError::PathNotAbsolute { field });
660    }
661    if path
662        .as_str()
663        .chars()
664        .any(|character| character.is_control() || matches!(character, '%' | '$'))
665    {
666        return Err(SshConfigError::UnsafePath { field });
667    }
668    Ok(())
669}
670
671const fn invalid(field: &'static str, reason: &'static str) -> SshConfigError {
672    SshConfigError::InvalidField { field, reason }
673}