diff --git a/.cargo_vcs_info.json b/.cargo_vcs_info.json new file mode 100644 index 0000000..acc772a --- /dev/null +++ b/.cargo_vcs_info.json @@ -0,0 +1,6 @@ +{ + "git": { + "sha1": "ef037151a20b566fe8c9c4adc9849b4503a1f1a3" + }, + "path_in_vcs": "" +} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..0b19c56 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,154 @@ +# Changelog + +- [Changelog](#changelog) + - [0.5.4](#054) + - [0.5.1](#051) + - [0.5.0](#050) + - [0.4.0](#040) + - [0.3.0](#030) + - [0.2.3](#023) + - [0.2.2](#022) + - [0.2.1](#021) + - [0.2.0](#020) + - [0.1.6](#016) + - [0.1.5](#015) + - [0.1.4](#014) + - [0.1.3](#013) + - [0.1.2](#012) + - [0.1.1](#011) + - [0.1.0](#010) + +--- + +## 0.5.4 + +Released on 27/03/2025 + +- on docsrs DON'T build algos. It's not allowed by docs.rs +- added `RELOAD_SSH_ALGO` env variable to rebuild algos. + +## 0.5.1 + +Released on 27/03/2025 + +- build was not included in the package. Fixed that. + +## 0.5.0 + +Released on 27/03/2025 + +- [issue 22](https://github.com/veeso/ssh2-config/issues/22): should parse tokens with `=` and quotes (`"`) +- [issue 21](https://github.com/veeso/ssh2-config/issues/21): Finally fixed how parameters are applied to host patterns +- Replaced algorithms `Vec` with `Algorithms` type. + - The new type is a variant with `Append`, `Head`, `Exclude` and `Set`. + - This allows to **ACTUALLY** handle algorithms correctly. + - To pass to ssh options, use `algorithms()` method + - Beware that when accessing the internal vec, you MUST care of what it means for that variant. +- Replaced `HostParams::merge` with `HostParams::overwrite_if_none` to avoid overwriting existing values. +- Added default Algorithms to the SshConfig structure. See readme for details on how to use it. + +## 0.4.0 + +Released on 15/03/2025 + +- Added support for `Include` directive. +- Fixed ordering in appliance of options. **It's always top-bottom**. +- Added logging to parser. You can now disable logging by using `nolog` feature. +- `parse_default_file` is now available to Windows users +- Added `Display` and `ToString` traits for `SshConfig` which serializes the configuration into ssh2 format + +## 0.3.0 + +Released on 19/12/2024 + +- thiserror `2.0` +- ‼️ **BREAKING CHANGE**: Added support for unsupported fields: + + `AddressFamily, BatchMode, CanonicalDomains, CanonicalizeFallbackLock, CanonicalizeHostname, CanonicalizeMaxDots, CanonicalizePermittedCNAMEs, CheckHostIP, ClearAllForwardings, ControlMaster, ControlPath, ControlPersist, DynamicForward, EnableSSHKeysign, EscapeChar, ExitOnForwardFailure, FingerprintHash, ForkAfterAuthentication, ForwardAgent, ForwardX11, ForwardX11Timeout, ForwardX11Trusted, GatewayPorts, GlobalKnownHostsFile, GSSAPIAuthentication, GSSAPIDelegateCredentials, HashKnownHosts, HostbasedAcceptedAlgorithms, HostbasedAuthentication, HostKeyAlias, HostbasedKeyTypes, IdentitiesOnly, IdentityAgent, Include, IPQoS, KbdInteractiveAuthentication, KbdInteractiveDevices, KnownHostsCommand, LocalCommand, LocalForward, LogLevel, LogVerbose, NoHostAuthenticationForLocalhost, NumberOfPasswordPrompts, PasswordAuthentication, PermitLocalCommand, PermitRemoteOpen, PKCS11Provider, PreferredAuthentications, ProxyCommand, ProxyJump, ProxyUseFdpass, PubkeyAcceptedKeyTypes, RekeyLimit, RequestTTY, RevokedHostKeys, SecruityKeyProvider, SendEnv, ServerAliveCountMax, SessionType, SetEnv, StdinNull, StreamLocalBindMask, StrictHostKeyChecking, SyslogFacility, UpdateHostKeys, UserKnownHostsFile, VerifyHostKeyDNS, VisualHostKey, XAuthLocation` + + If you want to keep the behaviour as-is, use `ParseRule::STRICT | ParseRule::ALLOW_UNSUPPORTED_FIELDS` when calling `parse()` if you were using `ParseRule::STRICT` before. + + Otherwise you can now access unsupported fields by using the `unsupported_fields` field on the `HostParams` structure like this: + + ```rust + use ssh2_config::{ParseRule, SshConfig}; + use std::fs::File; + use std::io::BufReader; + + let mut reader = BufReader::new(File::open(config_path).expect("Could not open configuration file")); + let config = SshConfig::default().parse(&mut reader, ParseRule::ALLOW_UNSUPPORTED_FIELDS).expect("Failed to parse configuration"); + + // Query attributes for a certain host + let params = config.query("192.168.1.2"); + let forwards = params.unsupported_fields.get("dynamicforward"); + ``` + +## 0.2.3 + +Released on 05/12/2023 + +- Fixed the order of appliance of configuration argument when overriding occurred. Thanks @LeoniePhiline + +## 0.2.2 + +Released on 31/07/2023 + +- Exposed `ignored_fields` as `Map>` (KeyName => Args) for `HostParams` + +## 0.2.1 + +Released on 28/07/2023 + +- Added `parse_default_file` to parse directly the default ssh config file at `$HOME/.ssh/config` +- Added `get_hosts` to retrieve current configuration's hosts + +## 0.2.0 + +Released on 09/05/2023 + +- Added `ParseRule` field to `parse()` method to specify some rules for parsing. ❗ To keep the behaviour as-is use `ParseRule::STRICT` + +## 0.1.6 + +Released on 03/03/2023 + +- Added legacy field support + - HostbasedKeyTypes + - PubkeyAcceptedKeyTypes + +## 0.1.5 + +Released on 27/02/2023 + +- Fixed comments not being properly stripped + +## 0.1.4 + +Released on 02/02/2023 + +- Fixed [issue 2](https://github.com/veeso/ssh2-config/issues/2) hosts not being sorted by priority in host query + +## 0.1.3 + +Released on 29/01/2022 + +- Added missing `ForwardX11Trusted` field to known fields + +## 0.1.2 + +Released on 11/01/2022 + +- Implemented `IgnoreUnknown` parameter +- Added `UseKeychain` support for MacOS + +## 0.1.1 + +Released on 02/01/2022 + +- Added `IdentityFile` parameter + +## 0.1.0 + +Released on 04/12/2021 + +- First release diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..269ab2a --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1127 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "0.6.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" + +[[package]] +name = "anstyle-parse" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3534e77181a9cc07539ad51f2141fe32f6c3ffd4df76db8ad92346b003ae4e" +dependencies = [ + "anstyle", + "once_cell", + "windows-sys 0.59.0", +] + +[[package]] +name = "anyhow" +version = "1.0.97" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcfed56ad506cb2c684a14971b8861fdc3baaaae314b9e5f9bb532cbe3ba7a4f" + +[[package]] +name = "autocfg" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" + +[[package]] +name = "bitflags" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" + +[[package]] +name = "cc" +version = "1.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be714c154be609ec7f5dad223a33bf1482fff90472de28f7362806e6d4832b8c" +dependencies = [ + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "colorchoice" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" + +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.59.0", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "env_filter" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186e05a59d4c50738528153b83b0b0194d3a29507dfec16eccd4b342903397d0" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3716d7a920fb4fac5d84e9d4bce8ceb321e9414b4409da61b07b75c1e3d0697" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + +[[package]] +name = "errno" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "form_urlencoded" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "getrandom" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.11.0+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a49c392881ce6d5c3b8cb70f98717b7c07aabbdff06687b9030dbfbe2725f8" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.13.3+wasi-0.2.2", + "windows-targets 0.52.6", +] + +[[package]] +name = "git2" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5220b8ba44c68a9a7f7a7659e864dd73692e417ef0211bea133c7b74e031eeb9" +dependencies = [ + "bitflags", + "libc", + "libgit2-sys", + "log", + "openssl-probe", + "openssl-sys", + "url", +] + +[[package]] +name = "glob" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" + +[[package]] +name = "icu_collections" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locid" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_locid_transform" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_locid_transform_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locid_transform_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdc8ff3388f852bede6b579ad4e978ab004f139284d7b28715f773507b946f6e" + +[[package]] +name = "icu_normalizer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "utf16_iter", + "utf8_iter", + "write16", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8cafbf7aa791e9b22bec55a167906f9e1215fd475cd22adfcf660e03e989516" + +[[package]] +name = "icu_properties" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locid_transform", + "icu_properties_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a8effbc3dd3e4ba1afa8ad918d5684b8868b3b26500753effea8d2eed19569" + +[[package]] +name = "icu_provider" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_provider_macros", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_provider_macros" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "idna" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + +[[package]] +name = "jiff" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d699bc6dfc879fb1bf9bdff0d4c56f0884fc6f0d0eb0fba397a6d00cd9a6b85e" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde", +] + +[[package]] +name = "jiff-static" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d16e75759ee0aa64c57a56acbf43916987b20c77373cb7e808979e02b93c9f9" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "jobserver" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48d1dbcbbeb6a7fec7e059840aa538bd62aaccf972c7346c4d9d2059312853d0" +dependencies = [ + "libc", +] + +[[package]] +name = "libc" +version = "0.2.171" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c19937216e9d3aa9956d9bb8dfc0b0c8beb6058fc4f7a4dc4d850edf86a237d6" + +[[package]] +name = "libgit2-sys" +version = "0.18.1+1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1dcb20f84ffcdd825c7a311ae347cce604a6f084a767dec4a4929829645290e" +dependencies = [ + "cc", + "libc", + "libssh2-sys", + "libz-sys", + "openssl-sys", + "pkg-config", +] + +[[package]] +name = "libredox" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +dependencies = [ + "bitflags", + "libc", +] + +[[package]] +name = "libssh2-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "220e4f05ad4a218192533b300327f5150e809b54c4ec83b5a1d91833601811b9" +dependencies = [ + "cc", + "libc", + "libz-sys", + "openssl-sys", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "libz-sys" +version = "1.1.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b70e7a7df205e92a1a4cd9aaae7898dac0aa555503cc0a649494d0d60e7651d" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe7db12097d22ec582439daf8618b8fdd1a7bef6270e9af3b1ebcd30893cf413" + +[[package]] +name = "litemap" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23fb14cb19457329c82206317a5663005a4d404783dc74f4252769b0d5f42856" + +[[package]] +name = "lock_api" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30bde2b3dc3671ae49d8e2e9f044c7c005836e7a023ee57cffa25ab82764bb9e" + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "once_cell" +version = "1.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d75b0bedcc4fe52caa0e03d9f1151a323e4aa5e2d78ba3580400cd3c9e2bc4bc" + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-sys" +version = "0.9.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bb61ea9811cc39e3c2069f40b8b8e2e70d8569b361f879786cc7ed48b777cdd" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "parking_lot" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets 0.52.6", +] + +[[package]] +name = "percent-encoding" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "portable-atomic" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "350e9b48cbc6b0e028b0473b114454c6316e57336ee184ceab6e53f72c178b3e" + +[[package]] +name = "portable-atomic-util" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + +[[package]] +name = "proc-macro2" +version = "1.0.94" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31971752e70b8b2686d7e46ec17fb38dad4051d94024c88df49b667caea9c84" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b8c0c260b63a8219631167be35e6a988e9554dbd323f8bd08439c8ed1302bd1" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b" +dependencies = [ + "getrandom 0.2.15", + "libredox", + "thiserror", +] + +[[package]] +name = "regex" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + +[[package]] +name = "rpassword" +version = "7.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80472be3c897911d0137b2d2b9055faf6eeac5b14e324073d83bc17b191d7e3f" +dependencies = [ + "libc", + "rtoolbox", + "windows-sys 0.48.0", +] + +[[package]] +name = "rtoolbox" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c247d24e63230cdb56463ae328478bd5eac8b8faa8c69461a77e8e323afac90e" +dependencies = [ + "libc", + "windows-sys 0.48.0", +] + +[[package]] +name = "rustix" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7178faa4b75a30e269c71e61c353ce2748cf3d76f0c44c393f4e60abf49b825" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "smallvec" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fcf8323ef1faaee30a44a340193b1ac6814fd9b7b4e88e9d4519a3e4abe1cfd" + +[[package]] +name = "ssh2" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f84d13b3b8a0d4e91a2629911e951db1bb8671512f5c09d7d4ba34500ba68c8" +dependencies = [ + "bitflags", + "libc", + "libssh2-sys", + "parking_lot", +] + +[[package]] +name = "ssh2-config" +version = "0.5.4" +dependencies = [ + "anyhow", + "bitflags", + "dirs", + "env_logger", + "git2", + "glob", + "log", + "pretty_assertions", + "rpassword", + "ssh2", + "tempfile", + "thiserror", + "wildmatch", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + +[[package]] +name = "syn" +version = "2.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09a44accad81e1ba1cd74a32461ba89dee89095ba17b32f5d03683b1b1fc2a0" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tempfile" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488960f40a3fd53d72c2a29a58722561dee8afdd175bd88e3db4677d7b2ba600" +dependencies = [ + "fastrand", + "getrandom 0.3.1", + "once_cell", + "rustix", + "windows-sys 0.59.0", +] + +[[package]] +name = "thiserror" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "url" +version = "2.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", +] + +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasi" +version = "0.13.3+wasi-0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26816d2e1a4a36a2940b96c5296ce403917633dff8f3440e9b236ed6f6bacad2" +dependencies = [ + "wit-bindgen-rt", +] + +[[package]] +name = "wildmatch" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ce1ab1f8c62655ebe1350f589c61e505cf94d385bc6a12899442d9081e71fd" + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen-rt" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3268f3d866458b787f390cf61f4bbb563b922d091359f9608842999eaee3943c" +dependencies = [ + "bitflags", +] + +[[package]] +name = "write16" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" + +[[package]] +name = "writeable" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[package]] +name = "yoke" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerovec" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..7662dee --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,102 @@ +# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO +# +# When uploading crates to the registry Cargo will automatically +# "normalize" Cargo.toml files for maximal compatibility +# with all versions of Cargo and also rewrite `path` dependencies +# to registry (e.g., crates.io) dependencies. +# +# If you are reading this file be aware that the original Cargo.toml +# will likely look very different (and much more reasonable). +# See Cargo.toml.orig for the original contents. + +[package] +edition = "2024" +name = "ssh2-config" +version = "0.5.4" +authors = ["Christian Visintin "] +build = "build/main.rs" +include = [ + "build/**/*", + "examples/**/*", + "src/**/*", + "LICENSE", + "README.md", + "CHANGELOG.md", +] +autolib = false +autobins = false +autoexamples = false +autotests = false +autobenches = false +description = "an ssh configuration parser for ssh2-rs" +homepage = "https://veeso.github.io/ssh2-config/" +documentation = "https://docs.rs/ssh2-config" +readme = "README.md" +keywords = [ + "ssh2", + "ssh", + "ssh-config", + "ssh-config-parser", +] +categories = ["network-programming"] +license = "MIT" +repository = "https://github.com/veeso/ssh2-config" + +[features] +default = [] +nolog = ["log/max_level_off"] + +[lib] +name = "ssh2_config" +path = "src/lib.rs" + +[[example]] +name = "client" +path = "examples/client.rs" + +[[example]] +name = "print" +path = "examples/print.rs" + +[[example]] +name = "query" +path = "examples/query.rs" + +[dependencies.bitflags] +version = "^2" + +[dependencies.dirs] +version = "^6" + +[dependencies.glob] +version = "0.3" + +[dependencies.log] +version = "^0.4" + +[dependencies.thiserror] +version = "^2" + +[dependencies.wildmatch] +version = "^2" + +[dev-dependencies.env_logger] +version = "^0.11" + +[dev-dependencies.pretty_assertions] +version = "^1" + +[dev-dependencies.rpassword] +version = "^7" + +[dev-dependencies.ssh2] +version = "^0.9" + +[dev-dependencies.tempfile] +version = "^3" + +[build-dependencies.anyhow] +version = "1" + +[build-dependencies.git2] +version = "0.20" diff --git a/Cargo.toml.orig b/Cargo.toml.orig new file mode 100644 index 0000000..795bb4b --- /dev/null +++ b/Cargo.toml.orig @@ -0,0 +1,59 @@ +[package] +authors = ["Christian Visintin "] +categories = ["network-programming"] +description = "an ssh configuration parser for ssh2-rs" +documentation = "https://docs.rs/ssh2-config" +edition = "2024" +homepage = "https://veeso.github.io/ssh2-config/" +include = [ + "build/**/*", + "examples/**/*", + "src/**/*", + "LICENSE", + "README.md", + "CHANGELOG.md", +] +keywords = ["ssh2", "ssh", "ssh-config", "ssh-config-parser"] +license = "MIT" +name = "ssh2-config" +readme = "README.md" +repository = "https://github.com/veeso/ssh2-config" +version = "0.5.4" +build = "build/main.rs" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +bitflags = "^2" +dirs = "^6" +log = "^0.4" +glob = "0.3" +thiserror = "^2" +wildmatch = "^2" + +[dev-dependencies] +env_logger = "^0.11" +pretty_assertions = "^1" +rpassword = "^7" +ssh2 = "^0.9" +tempfile = "^3" + +[build-dependencies] +anyhow = "1" +git2 = "0.20" + +[features] +default = [] +nolog = ["log/max_level_off"] + +[[example]] +name = "client" +path = "examples/client.rs" + +[[example]] +name = "query" +path = "examples/query.rs" + +[[example]] +name = "print" +path = "examples/print.rs" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..328ae48 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021-2025 Christian Visintin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..00c42fd --- /dev/null +++ b/README.md @@ -0,0 +1,459 @@ +# ssh2-config + +

+ Changelog + · + Get started + · + Documentation +

+ +

Developed by @veeso

+

Current version: 0.5.4 (27/03/2025)

+ +

+ License-MIT + Repo stars + Downloads counter + Latest version + + Ko-fi +

+

+ Build + Coveralls + Docs +

+ +--- + +- [ssh2-config](#ssh2-config) + - [About ssh2-config](#about-ssh2-config) + - [Exposed attributes](#exposed-attributes) + - [Missing features](#missing-features) + - [Get started 🚀](#get-started-) + - [Reading unsupported fields](#reading-unsupported-fields) + - [How host parameters are resolved](#how-host-parameters-are-resolved) + - [Resolvers examples](#resolvers-examples) + - [Configuring default algorithms](#configuring-default-algorithms) + - [Examples](#examples) + - [Support the developer ☕](#support-the-developer-) + - [Contributing and issues 🤝🏻](#contributing-and-issues-) + - [Changelog ⏳](#changelog-) + - [License 📃](#license-) + +--- + +## About ssh2-config + +ssh2-config a library which provides a parser for the SSH configuration file, to be used in pair with the [ssh2](https://github.com/alexcrichton/ssh2-rs) crate. + +This library provides a method to parse the configuration file and returns the configuration parsed into a structure. +The `SshConfig` structure provides all the attributes which **can** be used to configure the **ssh2 Session** and to resolve +the host, port and username. + +Once the configuration has been parsed you can use the `query(&str)` method to query configuration for a certain host, based on the configured patterns. + +Even if many attributes are not exposed, since not supported, there is anyway a validation of the configuration, so invalid configuration will result in a parsing error. + +### Exposed attributes + +- **BindAddress**: you can use this attribute to bind the socket to a certain address +- **BindInterface**: you can use this attribute to bind the socket to a certain network interface +- **CASignatureAlgorithms**: you can use this attribute to handle CA certificates +- **CertificateFile**: you can use this attribute to parse the certificate file in case is necessary +- **Ciphers**: you can use this attribute to set preferred methods with the session method `session.method_pref(MethodType::CryptCs, ...)` and `session.method_pref(MethodType::CryptSc, ...)` +- **Compression**: you can use this attribute to set whether compression is enabled with `session.set_compress(value)` +- **ConnectionAttempts**: you can use this attribute to cycle over connect in order to retry +- **ConnectTimeout**: you can use this attribute to set the connection timeout for the socket +- **HostName**: you can use this attribute to get the real name of the host to connect to +- **IdentityFile**: you can use this attribute to set the keys to try when connecting to remote host. +- **KexAlgorithms**: you can use this attribute to configure Key exchange methods with `session.method_pref(MethodType::Kex, algos.to_string().as_str())` +- **MACs**: you can use this attribute to configure the MAC algos with `session.method_pref(MethodType::MacCs, algos..to_string().as_str())` and `session.method_pref(MethodType::MacSc, algos..to_string().as_str())` +- **Port**: you can use this attribute to resolve the port to connect to +- **PubkeyAuthentication**: you can use this attribute to set whether to use the pubkey authentication +- **RemoteForward**: you can use this method to implement port forwarding with `session.channel_forward_listen()` +- **ServerAliveInterval**: you can use this method to implement keep alive message interval +- **TcpKeepAlive**: you can use this method to tell whether to send keep alive message +- **UseKeychain**: (macos only) used to tell whether to use keychain to decrypt ssh keys +- **User**: you can use this method to resolve the user to use to log in as + +### Missing features + +- [Match patterns](http://man.openbsd.org/OpenBSD-current/man5/ssh_config.5#Match) (Host patterns are supported!!!) +- [Tokens](http://man.openbsd.org/OpenBSD-current/man5/ssh_config.5#TOKENS) + +--- + +## Get started 🚀 + +First of all, add ssh2-config to your dependencies + +```toml +[dependencies] +ssh2-config = "^0.5" +``` + +then parse the configuration + +```rust +use ssh2_config::{ParseRule, SshConfig}; +use std::fs::File; +use std::io::BufReader; + +let mut reader = BufReader::new(File::open(config_path).expect("Could not open configuration file")); +let config = SshConfig::default().parse(&mut reader, ParseRule::STRICT).expect("Failed to parse configuration"); + +// Query attributes for a certain host +let params = config.query("192.168.1.2"); +``` + +then you can use the parsed parameters to configure the session: + +```rust +use ssh2::Session; +use ssh2_config::{HostParams}; + +fn configure_session(session: &mut Session, params: &HostParams) { + if let Some(compress) = params.compression { + session.set_compress(compress); + } + if params.tcp_keep_alive.unwrap_or(false) && params.server_alive_interval.is_some() { + let interval = params.server_alive_interval.unwrap().as_secs() as u32; + session.set_keepalive(true, interval); + } + // KEX + if let Err(err) = session.method_pref( + MethodType::Kex, + params.kex_algorithms.algorithms().join(",").as_str(), + ) { + panic!("Could not set KEX algorithms: {}", err); + } + + // host key + if let Err(err) = session.method_pref( + MethodType::HostKey, + params.host_key_algorithms.algorithms().join(",").as_str(), + ) { + panic!("Could not set host key algorithms: {}", err); + } + + // ciphers + if let Err(err) = session.method_pref( + MethodType::CryptCs, + params.ciphers.algorithms().join(",").as_str(), + ) { + panic!("Could not set crypt algorithms (client-server): {}", err); + } + if let Err(err) = session.method_pref( + MethodType::CryptSc, + params.ciphers.algorithms().join(",").as_str(), + ) { + panic!("Could not set crypt algorithms (server-client): {}", err); + } + + // mac + if let Err(err) = session.method_pref( + MethodType::MacCs, + params.mac.algorithms().join(",").as_str(), + ) { + panic!("Could not set MAC algorithms (client-server): {}", err); + } + if let Err(err) = session.method_pref( + MethodType::MacSc, + params.mac.algorithms().join(",").as_str(), + ) { + panic!("Could not set MAC algorithms (server-client): {}", err); + } +} + +fn auth_with_rsakey( + session: &mut Session, + params: &HostParams, + username: &str, + password: Option<&str> +) { + for identity_file in params.identity_file.unwrap_or_default().iter() { + if let Ok(_) = session.userauth_pubkey_file(username, None, identity_file, password) { + break; + } + } +} + +``` + +### Reading unsupported fields + +As outlined above, ssh2-config does not support all parameters available in the man page of the SSH configuration file. + +If you require these fields you may still access them through the `unsupported_fields` field on the `HostParams` structure like this: + +```rust +use ssh2_config::{ParseRule, SshConfig}; +use std::fs::File; +use std::io::BufReader; + +let mut reader = BufReader::new(File::open(config_path).expect("Could not open configuration file")); +let config = SshConfig::default().parse(&mut reader, ParseRule::ALLOW_UNSUPPORTED_FIELDS).expect("Failed to parse configuration"); + +// Query attributes for a certain host +let params = config.query("192.168.1.2"); +let forwards = params.unsupported_fields.get("dynamicforward"); +``` + +--- + +## How host parameters are resolved + +This topic has been debated a lot over the years, so finally since 0.5 this has been fixed to follow the official ssh configuration file rules, as described in the MAN . + +> Unless noted otherwise, for each parameter, the first obtained value will be used. The configuration files contain sections separated by Host specifications, and that section is only applied for hosts that match one of the patterns given in the specification. The matched host name is usually the one given on the command line (see the CanonicalizeHostname option for exceptions). +> +> Since the first obtained value for each parameter is used, more host-specific declarations should be given near the beginning of the file, and general defaults at the end. + +This means that: + +1. The first obtained value parsing the configuration top-down will be used +2. Host specific rules ARE not overriding default ones if they are not the first obtained value +3. If you want to achieve default values to be less specific than host specific ones, you should put the default values at the end of the configuration file using `Host *`. +4. Algorithms, so `KexAlgorithms`, `Ciphers`, `MACs` and `HostKeyAlgorithms` use a different resolvers which supports appending, excluding and heading insertions, as described in the man page at ciphers: . They are in case appended to default algorithms, which are either fetched from the openssh source code or set with a constructor. See [configuring default algorithms](#configuring-default-algorithms) for more information. + +### Resolvers examples + +```ssh +Compression yes + +Host 192.168.1.1 + Compression no +``` + +If we get rules for `192.168.1.1`, compression will be `yes`, because it's the first obtained value. + +```ssh +Host 192.168.1.1 + Compression no + +Host * + Compression yes +``` + +If we get rules for `192.168.1.1`, compression will be `no`, because it's the first obtained value. + +If we get rules for `172.168.1.1`, compression will be `yes`, because it's the first obtained value MATCHING the host rule. + +```ssh +Host 192.168.1.1 + Ciphers +c +``` + +If we get rules for `192.168.1.1`, ciphers will be `a,b,c`, because default is set to `a,b` and `+c` means append `c` to the list. + +--- + +## Configuring default algorithms + +To reload algos, build ssh2-config with `RELOAD_SSH_ALGO` env variable set. + +When you invoke `SshConfig::default`, the default algorithms are set from openssh source code, which are the following: + +```txt +ca_signature_algorithms: + "ssh-ed25519", + "ecdsa-sha2-nistp256", + "ecdsa-sha2-nistp384", + "ecdsa-sha2-nistp521", + "sk-ssh-ed25519@openssh.com", + "sk-ecdsa-sha2-nistp256@openssh.com", + "rsa-sha2-512", + "rsa-sha2-256", + +ciphers: + "chacha20-poly1305@openssh.com", + "aes128-ctr,aes192-ctr,aes256-ctr", + "aes128-gcm@openssh.com,aes256-gcm@openssh.com", + +host_key_algorithms: + "ssh-ed25519-cert-v01@openssh.com", + "ecdsa-sha2-nistp256-cert-v01@openssh.com", + "ecdsa-sha2-nistp384-cert-v01@openssh.com", + "ecdsa-sha2-nistp521-cert-v01@openssh.com", + "sk-ssh-ed25519-cert-v01@openssh.com", + "sk-ecdsa-sha2-nistp256-cert-v01@openssh.com", + "rsa-sha2-512-cert-v01@openssh.com", + "rsa-sha2-256-cert-v01@openssh.com", + "ssh-ed25519", + "ecdsa-sha2-nistp256", + "ecdsa-sha2-nistp384", + "ecdsa-sha2-nistp521", + "sk-ssh-ed25519@openssh.com", + "sk-ecdsa-sha2-nistp256@openssh.com", + "rsa-sha2-512", + "rsa-sha2-256", + +kex_algorithms: + "sntrup761x25519-sha512", + "sntrup761x25519-sha512@openssh.com", + "mlkem768x25519-sha256", + "curve25519-sha256", + "curve25519-sha256@libssh.org", + "ecdh-sha2-nistp256", + "ecdh-sha2-nistp384", + "ecdh-sha2-nistp521", + "diffie-hellman-group-exchange-sha256", + "diffie-hellman-group16-sha512", + "diffie-hellman-group18-sha512", + "diffie-hellman-group14-sha256", + "ssh-ed25519-cert-v01@openssh.com", + "ecdsa-sha2-nistp256-cert-v01@openssh.com", + "ecdsa-sha2-nistp384-cert-v01@openssh.com", + "ecdsa-sha2-nistp521-cert-v01@openssh.com", + "sk-ssh-ed25519-cert-v01@openssh.com", + "sk-ecdsa-sha2-nistp256-cert-v01@openssh.com", + "rsa-sha2-512-cert-v01@openssh.com", + "rsa-sha2-256-cert-v01@openssh.com", + "ssh-ed25519", + "ecdsa-sha2-nistp256", + "ecdsa-sha2-nistp384", + "ecdsa-sha2-nistp521", + "sk-ssh-ed25519@openssh.com", + "sk-ecdsa-sha2-nistp256@openssh.com", + "rsa-sha2-512", + "rsa-sha2-256", + "chacha20-poly1305@openssh.com", + "aes128-ctr,aes192-ctr,aes256-ctr", + "aes128-gcm@openssh.com,aes256-gcm@openssh.com", + "chacha20-poly1305@openssh.com", + "aes128-ctr,aes192-ctr,aes256-ctr", + "aes128-gcm@openssh.com,aes256-gcm@openssh.com", + "umac-64-etm@openssh.com", + "umac-128-etm@openssh.com", + "hmac-sha2-256-etm@openssh.com", + "hmac-sha2-512-etm@openssh.com", + "hmac-sha1-etm@openssh.com", + "umac-64@openssh.com", + "umac-128@openssh.com", + "hmac-sha2-256", + "hmac-sha2-512", + "hmac-sha1", + "umac-64-etm@openssh.com", + "umac-128-etm@openssh.com", + "hmac-sha2-256-etm@openssh.com", + "hmac-sha2-512-etm@openssh.com", + "hmac-sha1-etm@openssh.com", + "umac-64@openssh.com", + "umac-128@openssh.com", + "hmac-sha2-256", + "hmac-sha2-512", + "hmac-sha1", + "none,zlib@openssh.com", + "none,zlib@openssh.com", + +mac: + "umac-64-etm@openssh.com", + "umac-128-etm@openssh.com", + "hmac-sha2-256-etm@openssh.com", + "hmac-sha2-512-etm@openssh.com", + "hmac-sha1-etm@openssh.com", + "umac-64@openssh.com", + "umac-128@openssh.com", + "hmac-sha2-256", + "hmac-sha2-512", + "hmac-sha1", + +pubkey_accepted_algorithms: + "ssh-ed25519-cert-v01@openssh.com", + "ecdsa-sha2-nistp256-cert-v01@openssh.com", + "ecdsa-sha2-nistp384-cert-v01@openssh.com", + "ecdsa-sha2-nistp521-cert-v01@openssh.com", + "sk-ssh-ed25519-cert-v01@openssh.com", + "sk-ecdsa-sha2-nistp256-cert-v01@openssh.com", + "rsa-sha2-512-cert-v01@openssh.com", + "rsa-sha2-256-cert-v01@openssh.com", + "ssh-ed25519", + "ecdsa-sha2-nistp256", + "ecdsa-sha2-nistp384", + "ecdsa-sha2-nistp521", + "sk-ssh-ed25519@openssh.com", + "sk-ecdsa-sha2-nistp256@openssh.com", + "rsa-sha2-512", + "rsa-sha2-256", +``` + +If you want you can use a custom constructor `SshConfig::default().default_algorithms(prefs)` to set your own default algorithms. + +--- + +### Examples + +You can view a working examples of an implementation of ssh2-config with ssh2 in the examples folder at [client.rs](examples/client.rs). + +You can run the example with + +```sh +cargo run --example client -- [config-file-path] +``` + +--- + +## Support the developer ☕ + +If you like ssh2-config and you're grateful for the work I've done, please consider a little donation 🥳 + +You can make a donation with one of these platforms: + +[![ko-fi](https://img.shields.io/badge/Ko--fi-F16061?style=for-the-badge&logo=ko-fi&logoColor=white)](https://ko-fi.com/veeso) +[![PayPal](https://img.shields.io/badge/PayPal-00457C?style=for-the-badge&logo=paypal&logoColor=white)](https://www.paypal.me/chrisintin) + +--- + +## Contributing and issues 🤝🏻 + +Contributions, bug reports, new features and questions are welcome! 😉 +If you have any question or concern, or you want to suggest a new feature, or you want just want to improve ssh2-config, feel free to open an issue or a PR. + +Please follow [our contributing guidelines](CONTRIBUTING.md) + +--- + +## Changelog ⏳ + +View ssh2-config's changelog [HERE](CHANGELOG.md) + +--- + +## License 📃 + +ssh2-config is licensed under the MIT license. + +You can read the entire license [HERE](LICENSE) diff --git a/build/define_parser.rs b/build/define_parser.rs new file mode 100644 index 0000000..b7550f0 --- /dev/null +++ b/build/define_parser.rs @@ -0,0 +1,130 @@ +use std::collections::HashMap; +use std::io::BufRead; + +struct Scope { + name: String, + tokens: Vec, +} + +pub fn parse_defines(reader: impl BufRead) -> anyhow::Result> { + let mut defines = HashMap::new(); + + // iterate over each line in the reader + let mut scope: Option = None; + + for line in reader.lines() { + let line = line?; + // check if the line is a define + if line.trim().starts_with("#define") { + if let Some(prev_scope) = scope.take() { + // if we have a previous scope, store it + defines.insert(prev_scope.name, prev_scope.tokens.join(" ")); + } + // start a new scope + let mut tokens = line.split_whitespace(); + let name = tokens + .nth(1) + .ok_or_else(|| anyhow::anyhow!("Expected a name after #define"))? + .to_string(); + + let mut tokens = tokens.collect::>(); + let mut single_line = true; + + // if last token is a \; remove it + if let Some(last) = tokens.last() { + if *last == "\\" { + tokens.pop(); + single_line = false; + } + } + + // get tokens after the name + let mut parsed_tokens: Vec = vec![]; + for token in tokens { + let parsed = parse_token(&defines, token, false)?; + parsed_tokens.extend(parsed); + } + + scope = Some(Scope { + name, + tokens: parsed_tokens, + }); + + // if is single line, push to defines and set scope to None + if single_line { + if let Some(scope) = scope.take() { + defines.insert(scope.name, scope.tokens.join(" ")); + } + } + } else { + // if we are in a scope, add the line to the tokens + let Some(inner_scope) = scope.as_mut() else { + continue; + }; + + let tokens = line.split_whitespace(); + let mut tokens: Vec = tokens.map(|s| s.to_string()).collect(); + + // check if it ends with a \, if so, remove it + let mut last_line = true; + if let Some(last) = tokens.last() { + if last == "\\" { + tokens.pop(); + last_line = false; + } + } + + // parse tokens + for token in tokens { + let parsed = parse_token(&defines, &token, false)?; + inner_scope.tokens.extend(parsed); + } + + // if last line, push to defines and set scope to None + if last_line { + if let Some(scope) = scope.take() { + defines.insert(scope.name, scope.tokens.join(" ")); + } + } + } + } + + // put last scope + if let Some(scope) = scope { + defines.insert(scope.name, scope.tokens.join(" ")); + } + + Ok(defines) +} + +/// Parse token +fn parse_token( + defines: &HashMap, + token: &str, + nested: bool, +) -> anyhow::Result> { + let token = token.trim().trim_end_matches(','); + + // if token is a define, parse it + if let Some(value) = defines.get(token) { + return parse_token(defines, value, true); + } + + // otherwise, check if it is a string + if token.starts_with('"') && token.ends_with('"') { + return Ok(vec![ + token[1..token.len() - 1].trim_end_matches(',').to_string(), + ]); + } + + // check if it is a number + if token.parse::().is_ok() { + return Ok(vec![token.to_string()]); + } + + if nested { + return Ok(vec![token.to_string()]); + } + + anyhow::bail!("Unknown token: {token}; defines: {defines:#?}",) +} diff --git a/build/main.rs b/build/main.rs new file mode 100644 index 0000000..48e0e71 --- /dev/null +++ b/build/main.rs @@ -0,0 +1,15 @@ +mod define_parser; +mod openssh; +mod src_writer; + +fn main() -> anyhow::Result<()> { + // If reload SSH ALGO is not set, we don't need to do anything + if std::env::var("RELOAD_SSH_ALGO").is_err() { + return Ok(()); + } + + let prefs = openssh::get_my_prefs()?; + src_writer::write_source(prefs)?; + + Ok(()) +} diff --git a/build/openssh.rs b/build/openssh.rs new file mode 100644 index 0000000..6ac4a21 --- /dev/null +++ b/build/openssh.rs @@ -0,0 +1,88 @@ +use std::path::{Path, PathBuf}; + +use crate::define_parser::parse_defines; + +const OPENSSH_TAG: &str = "V_9_9_P2"; + +/// Default algorithms for ssh. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct MyPrefs { + pub ca_signature_algorithms: Vec, + pub ciphers: Vec, + pub host_key_algorithms: Vec, + pub kex_algorithms: Vec, + pub mac: Vec, + pub pubkey_accepted_algorithms: Vec, +} + +pub fn get_my_prefs() -> anyhow::Result { + let out_dir = std::env::var_os("OUT_DIR") + .map(|s| PathBuf::from(s).join("openssh")) + .ok_or_else(|| anyhow::anyhow!("OUT_DIR not set"))?; + let build_dir = out_dir.join("build"); + let inner_dir = build_dir.join("src"); + + std::fs::remove_dir_all(&build_dir).ok(); + std::fs::create_dir_all(&inner_dir).ok(); + + clone_openssh(&inner_dir)?; + + let my_proposal_path = inner_dir.join("myproposal.h"); + + let reader = std::io::BufReader::new(std::fs::File::open(my_proposal_path)?); + let defines = parse_defines(reader)?; + + let ca_signature_algorithms = defines + .get("SSH_ALLOWED_CA_SIGALGS") + .map(|s| s.split_whitespace().map(|s| format!(r#""{s}""#)).collect()) + .unwrap_or_default(); + + let ciphers = defines + .get("KEX_CLIENT_ENCRYPT") + .map(|s| s.split_whitespace().map(|s| format!(r#""{s}""#)).collect()) + .unwrap_or_default(); + + let host_key_algorithms = defines + .get("KEX_DEFAULT_PK_ALG") + .map(|s| s.split_whitespace().map(|s| format!(r#""{s}""#)).collect()) + .unwrap_or_default(); + + let kex_algorithms = defines + .get("KEX_CLIENT") + .map(|s| s.split_whitespace().map(|s| format!(r#""{s}""#)).collect()) + .unwrap_or_default(); + + let mac = defines + .get("KEX_CLIENT_MAC") + .map(|s| s.split_whitespace().map(|s| format!(r#""{s}""#)).collect()) + .unwrap_or_default(); + + let pubkey_accepted_algorithms = defines + .get("KEX_DEFAULT_PK_ALG") + .map(|s| s.split_whitespace().map(|s| format!(r#""{s}""#)).collect()) + .unwrap_or_default(); + + Ok(MyPrefs { + ca_signature_algorithms, + ciphers, + host_key_algorithms, + kex_algorithms, + mac, + pubkey_accepted_algorithms, + }) +} + +fn clone_openssh(path: &Path) -> anyhow::Result<()> { + let repo_url = "https://github.com/openssh/openssh-portable.git"; + let repo = git2::Repository::clone(repo_url, path)?; + + let obj = repo.revparse_single(OPENSSH_TAG)?; + + let commit = obj.peel_to_commit()?; + + repo.checkout_tree(&obj, None)?; + + repo.set_head_detached(commit.id())?; + + Ok(()) +} diff --git a/build/src_writer.rs b/build/src_writer.rs new file mode 100644 index 0000000..3dbdb05 --- /dev/null +++ b/build/src_writer.rs @@ -0,0 +1,70 @@ +use std::io::Write as _; +use std::path::PathBuf; + +use crate::openssh::MyPrefs; + +pub fn write_source(prefs: MyPrefs) -> anyhow::Result<()> { + let SrcPaths { src_dir, src_path } = src_path(); + + // create dir + if !src_dir.exists() { + std::fs::create_dir_all(&src_dir)?; + } + + // open file + let mut file = std::fs::File::create(src_path)?; + + writeln!( + file, + r#"//! This file is autogenerated at build-time when `RELOAD_SSH_ALGO` is set to environment."# + )?; + writeln!(file)?; + + writeln!(file, "use crate::DefaultAlgorithms;")?; + writeln!(file,)?; + + writeln!(file, r#"/// Default algorithms for ssh."#)?; + writeln!(file, r#"pub fn defaults() -> DefaultAlgorithms {{"#)?; + writeln!(file, r#" DefaultAlgorithms {{"#)?; + write_vec( + &mut file, + "ca_signature_algorithms", + &prefs.ca_signature_algorithms, + )?; + write_vec(&mut file, "ciphers", &prefs.ciphers)?; + write_vec(&mut file, "host_key_algorithms", &prefs.host_key_algorithms)?; + write_vec(&mut file, "kex_algorithms", &prefs.kex_algorithms)?; + write_vec(&mut file, "mac", &prefs.mac)?; + write_vec( + &mut file, + "pubkey_accepted_algorithms", + &prefs.pubkey_accepted_algorithms, + )?; + writeln!(file, r#" }}"#)?; + writeln!(file, r#"}}"#)?; + + Ok(()) +} + +fn write_vec(file: &mut std::fs::File, name: &str, vec: &[String]) -> anyhow::Result<()> { + writeln!(file, r#" {name}: vec!["#)?; + for item in vec { + writeln!(file, r#" {item}.to_string(),"#,)?; + } + writeln!(file, r#" ],"#)?; + Ok(()) +} + +struct SrcPaths { + src_dir: PathBuf, + src_path: PathBuf, +} + +fn src_path() -> SrcPaths { + let src_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("src") + .join("default_algorithms"); + let src_path = src_dir.join("openssh.rs"); + + SrcPaths { src_dir, src_path } +} diff --git a/examples/client.rs b/examples/client.rs new file mode 100644 index 0000000..6127608 --- /dev/null +++ b/examples/client.rs @@ -0,0 +1,190 @@ +//! # client +//! +//! Ssh2-config implementation with a ssh2 client + +use std::env::args; +use std::fs::File; +use std::io::BufReader; +use std::net::{SocketAddr, TcpStream, ToSocketAddrs}; +use std::path::{Path, PathBuf}; +use std::process::exit; +use std::time::Duration; + +use dirs::home_dir; +use ssh2::{MethodType, Session}; +use ssh2_config::{HostParams, ParseRule, SshConfig}; + +fn main() { + // get args + let args: Vec = args().collect(); + let address = match args.get(1) { + Some(addr) => addr.to_string(), + None => { + usage(); + exit(255) + } + }; + // check path + let config_path = match args.get(2) { + Some(p) => PathBuf::from(p), + None => { + let mut p = home_dir().expect("Failed to get home_dir for guest OS"); + p.extend(Path::new(".ssh/config")); + p + } + }; + // Open config file + let config = read_config(config_path.as_path()); + let params = config.query(address.as_str()); + connect(address.as_str(), ¶ms); +} + +fn usage() { + eprintln!("Usage: cargo run --example client -- [config-path]"); +} + +fn read_config(p: &Path) -> SshConfig { + let mut reader = match File::open(p) { + Ok(f) => BufReader::new(f), + Err(err) => panic!("Could not open file '{}': {}", p.display(), err), + }; + match SshConfig::default().parse(&mut reader, ParseRule::STRICT) { + Ok(config) => config, + Err(err) => panic!("Failed to parse configuration: {}", err), + } +} + +fn connect(host: &str, params: &HostParams) { + // Resolve host + let host = match params.host_name.as_deref() { + Some(h) => h, + None => host, + }; + let port = params.port.unwrap_or(22); + let host = match host.contains(':') { + true => host.to_string(), + false => format!("{}:{}", host, port), + }; + println!("Connecting to host {}...", host); + let socket_addresses: Vec = match host.to_socket_addrs() { + Ok(s) => s.collect(), + Err(err) => { + panic!("Could not parse host: {}", err); + } + }; + let mut tcp: Option = None; + // Try addresses + for socket_addr in socket_addresses.iter() { + match TcpStream::connect_timeout( + socket_addr, + params.connect_timeout.unwrap_or(Duration::from_secs(30)), + ) { + Ok(stream) => { + println!("Established connection with {}", socket_addr); + tcp = Some(stream); + break; + } + Err(_) => continue, + } + } + // If stream is None, return connection timeout + let stream: TcpStream = match tcp { + Some(t) => t, + None => { + panic!("No suitable socket address found; connection timeout"); + } + }; + let mut session: Session = match Session::new() { + Ok(s) => s, + Err(err) => { + panic!("Could not create session: {}", err); + } + }; + // Configure session + configure_session(&mut session, params); + // Connect + session.set_tcp_stream(stream); + if let Err(err) = session.handshake() { + panic!("Handshake failed: {}", err); + } + // Get username + let username = match params.user.as_ref() { + Some(u) => { + println!("Using username '{}'", u); + u.clone() + } + None => read_secret("Username: "), + }; + let password = read_secret("Password: "); + if let Err(err) = session.userauth_password(username.as_str(), password.as_str()) { + panic!("Authentication failed: {}", err); + } + if let Some(banner) = session.banner() { + println!("{}", banner); + } + println!("Connection OK!"); + if let Err(err) = session.disconnect(None, "mandi mandi!", None) { + panic!("Disconnection failed: {}", err); + } +} + +fn configure_session(session: &mut Session, params: &HostParams) { + println!("Configuring session..."); + if let Some(compress) = params.compression { + println!("compression: {}", compress); + session.set_compress(compress); + } + if params.tcp_keep_alive.unwrap_or(false) && params.server_alive_interval.is_some() { + let interval = params.server_alive_interval.unwrap().as_secs() as u32; + println!("keepalive interval: {} seconds", interval); + session.set_keepalive(true, interval); + } + + // KEX + if let Err(err) = session.method_pref( + MethodType::Kex, + params.kex_algorithms.algorithms().join(",").as_str(), + ) { + panic!("Could not set KEX algorithms: {}", err); + } + + // host key + if let Err(err) = session.method_pref( + MethodType::HostKey, + params.host_key_algorithms.algorithms().join(",").as_str(), + ) { + panic!("Could not set host key algorithms: {}", err); + } + + // ciphers + if let Err(err) = session.method_pref( + MethodType::CryptCs, + params.ciphers.algorithms().join(",").as_str(), + ) { + panic!("Could not set crypt algorithms (client-server): {}", err); + } + if let Err(err) = session.method_pref( + MethodType::CryptSc, + params.ciphers.algorithms().join(",").as_str(), + ) { + panic!("Could not set crypt algorithms (server-client): {}", err); + } + + // mac + if let Err(err) = session.method_pref( + MethodType::MacCs, + params.mac.algorithms().join(",").as_str(), + ) { + panic!("Could not set MAC algorithms (client-server): {}", err); + } + if let Err(err) = session.method_pref( + MethodType::MacSc, + params.mac.algorithms().join(",").as_str(), + ) { + panic!("Could not set MAC algorithms (server-client): {}", err); + } +} + +fn read_secret(prompt: &str) -> String { + rpassword::prompt_password(prompt).expect("Failed to read from stdin") +} diff --git a/examples/print.rs b/examples/print.rs new file mode 100644 index 0000000..9ea769a --- /dev/null +++ b/examples/print.rs @@ -0,0 +1,36 @@ +use std::env::args; +use std::fs::File; +use std::io::BufReader; +use std::path::{Path, PathBuf}; + +use dirs::home_dir; +use ssh2_config::{ParseRule, SshConfig}; + +fn main() { + // get args + let args: Vec = args().collect(); + // check path + let config_path = match args.get(1) { + Some(p) => PathBuf::from(p), + None => { + let mut p = home_dir().expect("Failed to get home_dir for guest OS"); + p.extend(Path::new(".ssh/config")); + p + } + }; + // Open config file + let config = read_config(config_path.as_path()); + + println!("{config}"); +} + +fn read_config(p: &Path) -> SshConfig { + let mut reader = match File::open(p) { + Ok(f) => BufReader::new(f), + Err(err) => panic!("Could not open file '{}': {}", p.display(), err), + }; + match SshConfig::default().parse(&mut reader, ParseRule::STRICT) { + Ok(config) => config, + Err(err) => panic!("Failed to parse configuration: {}", err), + } +} diff --git a/examples/query.rs b/examples/query.rs new file mode 100644 index 0000000..d43ab5c --- /dev/null +++ b/examples/query.rs @@ -0,0 +1,48 @@ +use std::env::args; +use std::fs::File; +use std::io::BufReader; +use std::path::{Path, PathBuf}; +use std::process::exit; + +use dirs::home_dir; +use ssh2_config::{ParseRule, SshConfig}; + +fn main() { + // get args + let args: Vec = args().collect(); + let address = match args.get(1) { + Some(addr) => addr.to_string(), + None => { + usage(); + exit(255) + } + }; + // check path + let config_path = match args.get(2) { + Some(p) => PathBuf::from(p), + None => { + let mut p = home_dir().expect("Failed to get home_dir for guest OS"); + p.extend(Path::new(".ssh/config")); + p + } + }; + // Open config file + let config = read_config(config_path.as_path()); + let params = config.query(address.as_str()); + println!("Configuration for {}: {:?}", address, params); +} + +fn usage() { + eprintln!("Usage: cargo run --example query --
[config-path]"); +} + +fn read_config(p: &Path) -> SshConfig { + let mut reader = match File::open(p) { + Ok(f) => BufReader::new(f), + Err(err) => panic!("Could not open file '{}': {}", p.display(), err), + }; + match SshConfig::default().parse(&mut reader, ParseRule::STRICT) { + Ok(config) => config, + Err(err) => panic!("Failed to parse configuration: {}", err), + } +} diff --git a/src/default_algorithms.rs b/src/default_algorithms.rs new file mode 100644 index 0000000..a43e960 --- /dev/null +++ b/src/default_algorithms.rs @@ -0,0 +1,32 @@ +mod openssh; + +/// Default algorithms for ssh. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DefaultAlgorithms { + pub ca_signature_algorithms: Vec, + pub ciphers: Vec, + pub host_key_algorithms: Vec, + pub kex_algorithms: Vec, + pub mac: Vec, + pub pubkey_accepted_algorithms: Vec, +} + +impl Default for DefaultAlgorithms { + fn default() -> Self { + self::openssh::defaults() + } +} + +impl DefaultAlgorithms { + /// Create a new instance of [`DefaultAlgorithms`] with empty fields. + pub fn empty() -> Self { + Self { + ca_signature_algorithms: vec![], + ciphers: vec![], + host_key_algorithms: vec![], + kex_algorithms: vec![], + mac: vec![], + pubkey_accepted_algorithms: vec![], + } + } +} diff --git a/src/default_algorithms/openssh.rs b/src/default_algorithms/openssh.rs new file mode 100644 index 0000000..7af9da5 --- /dev/null +++ b/src/default_algorithms/openssh.rs @@ -0,0 +1,130 @@ +//! This file is autogenerated at build-time when `RELOAD_SSH_ALGO` is set to environment. + +use crate::DefaultAlgorithms; + +/// Default algorithms for ssh. +pub fn defaults() -> DefaultAlgorithms { + DefaultAlgorithms { + ca_signature_algorithms: vec![ + "ssh-ed25519".to_string(), + "ecdsa-sha2-nistp256".to_string(), + "ecdsa-sha2-nistp384".to_string(), + "ecdsa-sha2-nistp521".to_string(), + "sk-ssh-ed25519@openssh.com".to_string(), + "sk-ecdsa-sha2-nistp256@openssh.com".to_string(), + "rsa-sha2-512".to_string(), + "rsa-sha2-256".to_string(), + ], + ciphers: vec![ + "chacha20-poly1305@openssh.com".to_string(), + "aes128-ctr,aes192-ctr,aes256-ctr".to_string(), + "aes128-gcm@openssh.com,aes256-gcm@openssh.com".to_string(), + ], + host_key_algorithms: vec![ + "ssh-ed25519-cert-v01@openssh.com".to_string(), + "ecdsa-sha2-nistp256-cert-v01@openssh.com".to_string(), + "ecdsa-sha2-nistp384-cert-v01@openssh.com".to_string(), + "ecdsa-sha2-nistp521-cert-v01@openssh.com".to_string(), + "sk-ssh-ed25519-cert-v01@openssh.com".to_string(), + "sk-ecdsa-sha2-nistp256-cert-v01@openssh.com".to_string(), + "rsa-sha2-512-cert-v01@openssh.com".to_string(), + "rsa-sha2-256-cert-v01@openssh.com".to_string(), + "ssh-ed25519".to_string(), + "ecdsa-sha2-nistp256".to_string(), + "ecdsa-sha2-nistp384".to_string(), + "ecdsa-sha2-nistp521".to_string(), + "sk-ssh-ed25519@openssh.com".to_string(), + "sk-ecdsa-sha2-nistp256@openssh.com".to_string(), + "rsa-sha2-512".to_string(), + "rsa-sha2-256".to_string(), + ], + kex_algorithms: vec![ + "sntrup761x25519-sha512".to_string(), + "sntrup761x25519-sha512@openssh.com".to_string(), + "mlkem768x25519-sha256".to_string(), + "curve25519-sha256".to_string(), + "curve25519-sha256@libssh.org".to_string(), + "ecdh-sha2-nistp256".to_string(), + "ecdh-sha2-nistp384".to_string(), + "ecdh-sha2-nistp521".to_string(), + "diffie-hellman-group-exchange-sha256".to_string(), + "diffie-hellman-group16-sha512".to_string(), + "diffie-hellman-group18-sha512".to_string(), + "diffie-hellman-group14-sha256".to_string(), + "ssh-ed25519-cert-v01@openssh.com".to_string(), + "ecdsa-sha2-nistp256-cert-v01@openssh.com".to_string(), + "ecdsa-sha2-nistp384-cert-v01@openssh.com".to_string(), + "ecdsa-sha2-nistp521-cert-v01@openssh.com".to_string(), + "sk-ssh-ed25519-cert-v01@openssh.com".to_string(), + "sk-ecdsa-sha2-nistp256-cert-v01@openssh.com".to_string(), + "rsa-sha2-512-cert-v01@openssh.com".to_string(), + "rsa-sha2-256-cert-v01@openssh.com".to_string(), + "ssh-ed25519".to_string(), + "ecdsa-sha2-nistp256".to_string(), + "ecdsa-sha2-nistp384".to_string(), + "ecdsa-sha2-nistp521".to_string(), + "sk-ssh-ed25519@openssh.com".to_string(), + "sk-ecdsa-sha2-nistp256@openssh.com".to_string(), + "rsa-sha2-512".to_string(), + "rsa-sha2-256".to_string(), + "chacha20-poly1305@openssh.com".to_string(), + "aes128-ctr,aes192-ctr,aes256-ctr".to_string(), + "aes128-gcm@openssh.com,aes256-gcm@openssh.com".to_string(), + "chacha20-poly1305@openssh.com".to_string(), + "aes128-ctr,aes192-ctr,aes256-ctr".to_string(), + "aes128-gcm@openssh.com,aes256-gcm@openssh.com".to_string(), + "umac-64-etm@openssh.com".to_string(), + "umac-128-etm@openssh.com".to_string(), + "hmac-sha2-256-etm@openssh.com".to_string(), + "hmac-sha2-512-etm@openssh.com".to_string(), + "hmac-sha1-etm@openssh.com".to_string(), + "umac-64@openssh.com".to_string(), + "umac-128@openssh.com".to_string(), + "hmac-sha2-256".to_string(), + "hmac-sha2-512".to_string(), + "hmac-sha1".to_string(), + "umac-64-etm@openssh.com".to_string(), + "umac-128-etm@openssh.com".to_string(), + "hmac-sha2-256-etm@openssh.com".to_string(), + "hmac-sha2-512-etm@openssh.com".to_string(), + "hmac-sha1-etm@openssh.com".to_string(), + "umac-64@openssh.com".to_string(), + "umac-128@openssh.com".to_string(), + "hmac-sha2-256".to_string(), + "hmac-sha2-512".to_string(), + "hmac-sha1".to_string(), + "none,zlib@openssh.com".to_string(), + "none,zlib@openssh.com".to_string(), + ], + mac: vec![ + "umac-64-etm@openssh.com".to_string(), + "umac-128-etm@openssh.com".to_string(), + "hmac-sha2-256-etm@openssh.com".to_string(), + "hmac-sha2-512-etm@openssh.com".to_string(), + "hmac-sha1-etm@openssh.com".to_string(), + "umac-64@openssh.com".to_string(), + "umac-128@openssh.com".to_string(), + "hmac-sha2-256".to_string(), + "hmac-sha2-512".to_string(), + "hmac-sha1".to_string(), + ], + pubkey_accepted_algorithms: vec![ + "ssh-ed25519-cert-v01@openssh.com".to_string(), + "ecdsa-sha2-nistp256-cert-v01@openssh.com".to_string(), + "ecdsa-sha2-nistp384-cert-v01@openssh.com".to_string(), + "ecdsa-sha2-nistp521-cert-v01@openssh.com".to_string(), + "sk-ssh-ed25519-cert-v01@openssh.com".to_string(), + "sk-ecdsa-sha2-nistp256-cert-v01@openssh.com".to_string(), + "rsa-sha2-512-cert-v01@openssh.com".to_string(), + "rsa-sha2-256-cert-v01@openssh.com".to_string(), + "ssh-ed25519".to_string(), + "ecdsa-sha2-nistp256".to_string(), + "ecdsa-sha2-nistp384".to_string(), + "ecdsa-sha2-nistp521".to_string(), + "sk-ssh-ed25519@openssh.com".to_string(), + "sk-ecdsa-sha2-nistp256@openssh.com".to_string(), + "rsa-sha2-512".to_string(), + "rsa-sha2-256".to_string(), + ], + } +} diff --git a/src/host.rs b/src/host.rs new file mode 100644 index 0000000..bda9f73 --- /dev/null +++ b/src/host.rs @@ -0,0 +1,138 @@ +//! # host +//! +//! Ssh host type + +use std::fmt; + +use wildmatch::WildMatch; + +use super::HostParams; + +/// Describes the rules to be used for a certain host +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Host { + /// List of hosts for which params are valid. String is string pattern, bool is whether condition is negated + pub pattern: Vec, + pub params: HostParams, +} + +impl Host { + pub fn new(pattern: Vec, params: HostParams) -> Self { + Self { pattern, params } + } + + /// Returns whether `host` argument intersects the host clauses + pub fn intersects(&self, host: &str) -> bool { + let mut has_matched = false; + for entry in self.pattern.iter() { + let matches = entry.intersects(host); + // If the entry is negated and it matches we can stop searching + if matches && entry.negated { + return false; + } + has_matched |= matches; + } + has_matched + } +} + +/// Describes a single clause to match host +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HostClause { + pub pattern: String, + pub negated: bool, +} + +impl fmt::Display for HostClause { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.negated { + write!(f, "!{}", self.pattern) + } else { + write!(f, "{}", self.pattern) + } + } +} + +impl HostClause { + /// Creates a new `HostClause` from arguments + pub fn new(pattern: String, negated: bool) -> Self { + Self { pattern, negated } + } + + /// Returns whether `host` argument intersects the clause + pub fn intersects(&self, host: &str) -> bool { + WildMatch::new(self.pattern.as_str()).matches(host) + } +} + +#[cfg(test)] +mod test { + + use pretty_assertions::assert_eq; + + use super::*; + use crate::DefaultAlgorithms; + + #[test] + fn should_build_host_clause() { + let clause = HostClause::new("192.168.1.1".to_string(), false); + assert_eq!(clause.pattern.as_str(), "192.168.1.1"); + assert_eq!(clause.negated, false); + } + + #[test] + fn should_intersect_host_clause() { + let clause = HostClause::new("192.168.*.*".to_string(), false); + assert!(clause.intersects("192.168.2.30")); + let clause = HostClause::new("192.168.?0.*".to_string(), false); + assert!(clause.intersects("192.168.40.28")); + } + + #[test] + fn should_not_intersect_host_clause() { + let clause = HostClause::new("192.168.*.*".to_string(), false); + assert_eq!(clause.intersects("172.26.104.4"), false); + } + + #[test] + fn should_init_host() { + let host = Host::new( + vec![HostClause::new("192.168.*.*".to_string(), false)], + HostParams::new(&DefaultAlgorithms::default()), + ); + assert_eq!(host.pattern.len(), 1); + } + + #[test] + fn should_intersect_clause() { + let host = Host::new( + vec![ + HostClause::new("192.168.*.*".to_string(), false), + HostClause::new("172.26.*.*".to_string(), false), + HostClause::new("10.8.*.*".to_string(), false), + HostClause::new("10.8.0.8".to_string(), true), + ], + HostParams::new(&DefaultAlgorithms::default()), + ); + assert!(host.intersects("192.168.1.32")); + assert!(host.intersects("172.26.104.4")); + assert!(host.intersects("10.8.0.10")); + } + + #[test] + fn should_not_intersect_clause() { + let host = Host::new( + vec![ + HostClause::new("192.168.*.*".to_string(), false), + HostClause::new("172.26.*.*".to_string(), false), + HostClause::new("10.8.*.*".to_string(), false), + HostClause::new("10.8.0.8".to_string(), true), + ], + HostParams::new(&DefaultAlgorithms::default()), + ); + assert_eq!(host.intersects("192.169.1.32"), false); + assert_eq!(host.intersects("172.28.104.4"), false); + assert_eq!(host.intersects("10.9.0.8"), false); + assert_eq!(host.intersects("10.8.0.8"), false); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..e9efb82 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,445 @@ +#![crate_name = "ssh2_config"] +#![crate_type = "lib"] + +//! # ssh2-config +//! +//! ssh2-config a library which provides a parser for the SSH configuration file, +//! to be used in pair with the [ssh2](https://github.com/alexcrichton/ssh2-rs) crate. +//! +//! This library provides a method to parse the configuration file and returns the +//! configuration parsed into a structure. +//! The `SshConfig` structure provides all the attributes which **can** be used to configure the **ssh2 Session** +//! and to resolve the host, port and username. +//! +//! Once the configuration has been parsed you can use the `query(&str)` +//! method to query configuration for a certain host, based on the configured patterns. +//! Even if many attributes are not exposed, since not supported, there is anyway a validation of the configuration, +//! so invalid configuration will result in a parsing error. +//! +//! ## Get started +//! +//! First of you need to add **ssh2-config** to your project dependencies: +//! +//! ```toml +//! ssh2-config = "^0.5" +//! ``` +//! +//! ## Example +//! +//! Here is a basic example: +//! +//! ```rust +//! +//! use ssh2::Session; +//! use ssh2_config::{HostParams, ParseRule, SshConfig}; +//! use std::fs::File; +//! use std::io::BufReader; +//! use std::path::Path; +//! +//! let mut reader = BufReader::new( +//! File::open(Path::new("./assets/ssh.config")) +//! .expect("Could not open configuration file") +//! ); +//! +//! let config = SshConfig::default().parse(&mut reader, ParseRule::STRICT).expect("Failed to parse configuration"); +//! +//! // Query parameters for your host +//! // If there's no rule for your host, default params are returned +//! let params = config.query("192.168.1.2"); +//! +//! // ... +//! +//! // serialize configuration to string +//! let s = config.to_string(); +//! +//! ``` +//! +//! --- +//! +//! ## How host parameters are resolved +//! +//! This topic has been debated a lot over the years, so finally since 0.5 this has been fixed to follow the official ssh configuration file rules, as described in the MAN . +//! +//! > Unless noted otherwise, for each parameter, the first obtained value will be used. The configuration files contain sections separated by Host specifications, and that section is only applied for hosts that match one of the patterns given in the specification. The matched host name is usually the one given on the command line (see the CanonicalizeHostname option for exceptions). +//! > +//! > Since the first obtained value for each parameter is used, more host-specific declarations should be given near the beginning of the file, and general defaults at the end. +//! +//! This means that: +//! +//! 1. The first obtained value parsing the configuration top-down will be used +//! 2. Host specific rules ARE not overriding default ones if they are not the first obtained value +//! 3. If you want to achieve default values to be less specific than host specific ones, you should put the default values at the end of the configuration file using `Host *`. +//! 4. Algorithms, so `KexAlgorithms`, `Ciphers`, `MACs` and `HostKeyAlgorithms` use a different resolvers which supports appending, excluding and heading insertions, as described in the man page at ciphers: . +//! +//! ### Resolvers examples +//! +//! ```ssh +//! Compression yes +//! +//! Host 192.168.1.1 +//! Compression no +//! ``` +//! +//! If we get rules for `192.168.1.1`, compression will be `yes`, because it's the first obtained value. +//! +//! ```ssh +//! Host 192.168.1.1 +//! Compression no +//! +//! Host * +//! Compression yes +//! ``` +//! +//! If we get rules for `192.168.1.1`, compression will be `no`, because it's the first obtained value. +//! +//! If we get rules for `172.168.1.1`, compression will be `yes`, because it's the first obtained value MATCHING the host rule. +//! +//! ```ssh +//! +//! Host 192.168.1.1 +//! Ciphers +c +//! ``` +//! +//! If we get rules for `192.168.1.1`, ciphers will be `c` appended to default algorithms, which can be specified in the [`SshConfig`] constructor. +//! +//! ## Configuring default algorithms +//! +//! When you invoke [`SshConfig::default`], the default algorithms are set from openssh source code, which are the following: +//! +//! ```txt +//! ca_signature_algorithms: +//! "ssh-ed25519", +//! "ecdsa-sha2-nistp256", +//! "ecdsa-sha2-nistp384", +//! "ecdsa-sha2-nistp521", +//! "sk-ssh-ed25519@openssh.com", +//! "sk-ecdsa-sha2-nistp256@openssh.com", +//! "rsa-sha2-512", +//! "rsa-sha2-256", +//! +//! ciphers: +//! "chacha20-poly1305@openssh.com", +//! "aes128-ctr,aes192-ctr,aes256-ctr", +//! "aes128-gcm@openssh.com,aes256-gcm@openssh.com", +//! +//! host_key_algorithms: +//! "ssh-ed25519-cert-v01@openssh.com", +//! "ecdsa-sha2-nistp256-cert-v01@openssh.com", +//! "ecdsa-sha2-nistp384-cert-v01@openssh.com", +//! "ecdsa-sha2-nistp521-cert-v01@openssh.com", +//! "sk-ssh-ed25519-cert-v01@openssh.com", +//! "sk-ecdsa-sha2-nistp256-cert-v01@openssh.com", +//! "rsa-sha2-512-cert-v01@openssh.com", +//! "rsa-sha2-256-cert-v01@openssh.com", +//! "ssh-ed25519", +//! "ecdsa-sha2-nistp256", +//! "ecdsa-sha2-nistp384", +//! "ecdsa-sha2-nistp521", +//! "sk-ssh-ed25519@openssh.com", +//! "sk-ecdsa-sha2-nistp256@openssh.com", +//! "rsa-sha2-512", +//! "rsa-sha2-256", +//! +//! kex_algorithms: +//! "sntrup761x25519-sha512", +//! "sntrup761x25519-sha512@openssh.com", +//! "mlkem768x25519-sha256", +//! "curve25519-sha256", +//! "curve25519-sha256@libssh.org", +//! "ecdh-sha2-nistp256", +//! "ecdh-sha2-nistp384", +//! "ecdh-sha2-nistp521", +//! "diffie-hellman-group-exchange-sha256", +//! "diffie-hellman-group16-sha512", +//! "diffie-hellman-group18-sha512", +//! "diffie-hellman-group14-sha256", +//! "ssh-ed25519-cert-v01@openssh.com", +//! "ecdsa-sha2-nistp256-cert-v01@openssh.com", +//! "ecdsa-sha2-nistp384-cert-v01@openssh.com", +//! "ecdsa-sha2-nistp521-cert-v01@openssh.com", +//! "sk-ssh-ed25519-cert-v01@openssh.com", +//! "sk-ecdsa-sha2-nistp256-cert-v01@openssh.com", +//! "rsa-sha2-512-cert-v01@openssh.com", +//! "rsa-sha2-256-cert-v01@openssh.com", +//! "ssh-ed25519", +//! "ecdsa-sha2-nistp256", +//! "ecdsa-sha2-nistp384", +//! "ecdsa-sha2-nistp521", +//! "sk-ssh-ed25519@openssh.com", +//! "sk-ecdsa-sha2-nistp256@openssh.com", +//! "rsa-sha2-512", +//! "rsa-sha2-256", +//! "chacha20-poly1305@openssh.com", +//! "aes128-ctr,aes192-ctr,aes256-ctr", +//! "aes128-gcm@openssh.com,aes256-gcm@openssh.com", +//! "chacha20-poly1305@openssh.com", +//! "aes128-ctr,aes192-ctr,aes256-ctr", +//! "aes128-gcm@openssh.com,aes256-gcm@openssh.com", +//! "umac-64-etm@openssh.com", +//! "umac-128-etm@openssh.com", +//! "hmac-sha2-256-etm@openssh.com", +//! "hmac-sha2-512-etm@openssh.com", +//! "hmac-sha1-etm@openssh.com", +//! "umac-64@openssh.com", +//! "umac-128@openssh.com", +//! "hmac-sha2-256", +//! "hmac-sha2-512", +//! "hmac-sha1", +//! "umac-64-etm@openssh.com", +//! "umac-128-etm@openssh.com", +//! "hmac-sha2-256-etm@openssh.com", +//! "hmac-sha2-512-etm@openssh.com", +//! "hmac-sha1-etm@openssh.com", +//! "umac-64@openssh.com", +//! "umac-128@openssh.com", +//! "hmac-sha2-256", +//! "hmac-sha2-512", +//! "hmac-sha1", +//! "none,zlib@openssh.com", +//! "none,zlib@openssh.com", +//! +//! mac: +//! "umac-64-etm@openssh.com", +//! "umac-128-etm@openssh.com", +//! "hmac-sha2-256-etm@openssh.com", +//! "hmac-sha2-512-etm@openssh.com", +//! "hmac-sha1-etm@openssh.com", +//! "umac-64@openssh.com", +//! "umac-128@openssh.com", +//! "hmac-sha2-256", +//! "hmac-sha2-512", +//! "hmac-sha1", +//! +//! pubkey_accepted_algorithms: +//! "ssh-ed25519-cert-v01@openssh.com", +//! "ecdsa-sha2-nistp256-cert-v01@openssh.com", +//! "ecdsa-sha2-nistp384-cert-v01@openssh.com", +//! "ecdsa-sha2-nistp521-cert-v01@openssh.com", +//! "sk-ssh-ed25519-cert-v01@openssh.com", +//! "sk-ecdsa-sha2-nistp256-cert-v01@openssh.com", +//! "rsa-sha2-512-cert-v01@openssh.com", +//! "rsa-sha2-256-cert-v01@openssh.com", +//! "ssh-ed25519", +//! "ecdsa-sha2-nistp256", +//! "ecdsa-sha2-nistp384", +//! "ecdsa-sha2-nistp521", +//! "sk-ssh-ed25519@openssh.com", +//! "sk-ecdsa-sha2-nistp256@openssh.com", +//! "rsa-sha2-512", +//! "rsa-sha2-256", +//! ``` +//! +//! If you want you can use a custom constructor [`SshConfig::default_algorithms`] to set your own default algorithms. + +#![doc(html_playground_url = "https://play.rust-lang.org")] + +#[macro_use] +extern crate log; + +use std::fmt; +use std::fs::File; +use std::io::{self, BufRead, BufReader}; +use std::path::PathBuf; +use std::time::Duration; +// -- modules +mod default_algorithms; +mod host; +mod params; +mod parser; +mod serializer; + +// -- export +pub use self::default_algorithms::DefaultAlgorithms; +pub use self::host::{Host, HostClause}; +pub use self::params::{Algorithms, HostParams}; +pub use self::parser::{ParseRule, SshParserError, SshParserResult}; + +/// Describes the ssh configuration. +/// Configuration is described in this document: +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct SshConfig { + /// Default algorithms for ssh. + default_algorithms: DefaultAlgorithms, + /// Rulesets for hosts. + /// Default config will be stored with key `*` + hosts: Vec, +} + +impl fmt::Display for SshConfig { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + serializer::SshConfigSerializer::from(self).serialize(f) + } +} + +impl SshConfig { + /// Query params for a certain host. Returns [`HostParams`] for the host. + pub fn query>(&self, pattern: S) -> HostParams { + let mut params = HostParams::new(&self.default_algorithms); + // iter keys, overwrite if None top-down + for host in self.hosts.iter() { + if host.intersects(pattern.as_ref()) { + debug!( + "Merging params for host: {:?} into params {params:?}", + host.pattern + ); + params.overwrite_if_none(&host.params); + trace!("Params after merge: {params:?}"); + } + } + // return calculated params + params + } + + /// Get an iterator over the [`Host`]s which intersect with the given host pattern + pub fn intersecting_hosts(&self, pattern: &str) -> impl Iterator { + self.hosts.iter().filter(|host| host.intersects(pattern)) + } + + /// Set default algorithms for ssh. + /// + /// If you want to use the default algorithms from the system, you can use the `Default::default()` method. + pub fn default_algorithms(mut self, algos: DefaultAlgorithms) -> Self { + self.default_algorithms = algos; + + self + } + + /// Parse [`SshConfig`] from stream which implements [`BufRead`] and return parsed configuration or parser error + /// + /// ## Example + /// + /// ```rust,ignore + /// let mut reader = BufReader::new( + /// File::open(Path::new("./assets/ssh.config")) + /// .expect("Could not open configuration file") + /// ); + /// + /// let config = SshConfig::default().parse(&mut reader, ParseRule::STRICT).expect("Failed to parse configuration"); + /// ``` + pub fn parse(mut self, reader: &mut impl BufRead, rules: ParseRule) -> SshParserResult { + parser::SshConfigParser::parse(&mut self, reader, rules).map(|_| self) + } + + /// Parse `~/.ssh/config`` file and return parsed configuration [`SshConfig`] or parser error + pub fn parse_default_file(rules: ParseRule) -> SshParserResult { + let ssh_folder = dirs::home_dir() + .ok_or_else(|| { + SshParserError::Io(io::Error::new( + io::ErrorKind::NotFound, + "Home folder not found", + )) + })? + .join(".ssh"); + + let mut reader = + BufReader::new(File::open(ssh_folder.join("config")).map_err(SshParserError::Io)?); + + Self::default().parse(&mut reader, rules) + } + + /// Get list of [`Host`]s in the configuration + pub fn get_hosts(&self) -> &Vec { + &self.hosts + } +} + +#[cfg(test)] +fn test_log() { + use std::sync::Once; + + static INIT: Once = Once::new(); + + INIT.call_once(|| { + let _ = env_logger::builder() + .filter_level(log::LevelFilter::Trace) + .is_test(true) + .try_init(); + }); +} + +#[cfg(test)] +mod test { + + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn should_init_ssh_config() { + test_log(); + + let config = SshConfig::default(); + assert_eq!(config.hosts.len(), 0); + assert_eq!( + config.query("192.168.1.2"), + HostParams::new(&DefaultAlgorithms::default()) + ); + } + + #[test] + fn should_parse_default_config() -> Result<(), parser::SshParserError> { + test_log(); + + let _config = SshConfig::parse_default_file(ParseRule::ALLOW_UNKNOWN_FIELDS)?; + Ok(()) + } + + #[test] + fn should_parse_config() -> Result<(), parser::SshParserError> { + test_log(); + + use std::fs::File; + use std::io::BufReader; + use std::path::Path; + + let mut reader = BufReader::new( + File::open(Path::new("./assets/ssh.config")) + .expect("Could not open configuration file"), + ); + + SshConfig::default().parse(&mut reader, ParseRule::STRICT)?; + + Ok(()) + } + + #[test] + fn should_query_ssh_config() { + test_log(); + + let mut config = SshConfig::default(); + // add config + let mut params1 = HostParams::new(&DefaultAlgorithms::default()); + params1.bind_address = Some("0.0.0.0".to_string()); + config.hosts.push(Host::new( + vec![HostClause::new(String::from("192.168.*.*"), false)], + params1.clone(), + )); + let mut params2 = HostParams::new(&DefaultAlgorithms::default()); + params2.bind_interface = Some(String::from("tun0")); + config.hosts.push(Host::new( + vec![HostClause::new(String::from("192.168.10.*"), false)], + params2.clone(), + )); + + let mut params3 = HostParams::new(&DefaultAlgorithms::default()); + params3.host_name = Some("172.26.104.4".to_string()); + config.hosts.push(Host::new( + vec![ + HostClause::new(String::from("172.26.*.*"), false), + HostClause::new(String::from("172.26.104.4"), true), + ], + params3.clone(), + )); + // Query + assert_eq!(config.query("192.168.1.32"), params1); + // merged case + params1.overwrite_if_none(¶ms2); + assert_eq!(config.query("192.168.10.1"), params1); + // Negated case + assert_eq!(config.query("172.26.254.1"), params3); + assert_eq!( + config.query("172.26.104.4"), + HostParams::new(&DefaultAlgorithms::default()) + ); + } +} diff --git a/src/params.rs b/src/params.rs new file mode 100644 index 0000000..556d0f8 --- /dev/null +++ b/src/params.rs @@ -0,0 +1,257 @@ +//! # params +//! +//! Ssh config params for host rule + +mod algos; + +use std::collections::HashMap; + +pub use self::algos::Algorithms; +pub(crate) use self::algos::AlgorithmsRule; +use super::{Duration, PathBuf}; +use crate::DefaultAlgorithms; + +/// Describes the ssh configuration. +/// Configuration is describes in this document: +/// Only arguments supported by libssh2 are implemented +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HostParams { + /// Specifies to use the specified address on the local machine as the source address of the connection + pub bind_address: Option, + /// Use the specified address on the local machine as the source address of the connection + pub bind_interface: Option, + /// Specifies which algorithms are allowed for signing of certificates by certificate authorities + pub ca_signature_algorithms: Algorithms, + /// Specifies a file from which the user's certificate is read + pub certificate_file: Option, + /// Specifies the ciphers allowed for protocol version 2 in order of preference + pub ciphers: Algorithms, + /// Specifies whether to use compression + pub compression: Option, + /// Specifies the number of attempts to make before exiting + pub connection_attempts: Option, + /// Specifies the timeout used when connecting to the SSH server + pub connect_timeout: Option, + /// Specifies the host key signature algorithms that the client wants to use in order of preference + pub host_key_algorithms: Algorithms, + /// Specifies the real host name to log into + pub host_name: Option, + /// Specifies the path of the identity file to be used when authenticating. + /// More than one file can be specified. + /// If more than one file is specified, they will be read in order + pub identity_file: Option>, + /// Specifies a pattern-list of unknown options to be ignored if they are encountered in configuration parsing + pub ignore_unknown: Option>, + /// Specifies the available KEX (Key Exchange) algorithms + pub kex_algorithms: Algorithms, + /// Specifies the MAC (message authentication code) algorithms in order of preference + pub mac: Algorithms, + /// Specifies the port number to connect on the remote host. + pub port: Option, + /// Specifies the signature algorithms that will be used for public key authentication + pub pubkey_accepted_algorithms: Algorithms, + /// Specifies whether to try public key authentication using SSH keys + pub pubkey_authentication: Option, + /// Specifies that a TCP port on the remote machine be forwarded over the secure channel + pub remote_forward: Option, + /// Sets a timeout interval in seconds after which if no data has been received from the server, keep alive will be sent + pub server_alive_interval: Option, + /// Specifies whether to send TCP keepalives to the other side + pub tcp_keep_alive: Option, + #[cfg(target_os = "macos")] + /// specifies whether the system should search for passphrases in the user's keychain when attempting to use a particular key + pub use_keychain: Option, + /// Specifies the user to log in as. + pub user: Option, + /// fields that the parser wasn't able to parse + pub ignored_fields: HashMap>, + /// fields that the parser was able to parse but ignored + pub unsupported_fields: HashMap>, +} + +impl HostParams { + /// Create a new [`HostParams`] object with the [`DefaultAlgorithms`] + pub fn new(default_algorithms: &DefaultAlgorithms) -> Self { + Self { + bind_address: None, + bind_interface: None, + ca_signature_algorithms: Algorithms::new(&default_algorithms.ca_signature_algorithms), + certificate_file: None, + ciphers: Algorithms::new(&default_algorithms.ciphers), + compression: None, + connection_attempts: None, + connect_timeout: None, + host_key_algorithms: Algorithms::new(&default_algorithms.host_key_algorithms), + host_name: None, + identity_file: None, + ignore_unknown: None, + kex_algorithms: Algorithms::new(&default_algorithms.kex_algorithms), + mac: Algorithms::new(&default_algorithms.mac), + port: None, + pubkey_accepted_algorithms: Algorithms::new( + &default_algorithms.pubkey_accepted_algorithms, + ), + pubkey_authentication: None, + remote_forward: None, + server_alive_interval: None, + tcp_keep_alive: None, + #[cfg(target_os = "macos")] + use_keychain: None, + user: None, + ignored_fields: HashMap::new(), + unsupported_fields: HashMap::new(), + } + } + + /// Return whether a certain `param` is in the ignored list + pub(crate) fn ignored(&self, param: &str) -> bool { + self.ignore_unknown + .as_ref() + .map(|x| x.iter().any(|x| x.as_str() == param)) + .unwrap_or(false) + } + + /// Given a [`HostParams`] object `b`, it will overwrite all the params from `self` only if they are [`None`] + pub fn overwrite_if_none(&mut self, b: &Self) { + self.bind_address = self.bind_address.clone().or_else(|| b.bind_address.clone()); + self.bind_interface = self + .bind_interface + .clone() + .or_else(|| b.bind_interface.clone()); + self.certificate_file = self + .certificate_file + .clone() + .or_else(|| b.certificate_file.clone()); + self.compression = self.compression.or(b.compression); + self.connection_attempts = self.connection_attempts.or(b.connection_attempts); + self.connect_timeout = self.connect_timeout.or(b.connect_timeout); + self.host_name = self.host_name.clone().or_else(|| b.host_name.clone()); + self.identity_file = self + .identity_file + .clone() + .or_else(|| b.identity_file.clone()); + self.ignore_unknown = self + .ignore_unknown + .clone() + .or_else(|| b.ignore_unknown.clone()); + self.port = self.port.or(b.port); + self.pubkey_authentication = self.pubkey_authentication.or(b.pubkey_authentication); + self.remote_forward = self.remote_forward.or(b.remote_forward); + self.server_alive_interval = self.server_alive_interval.or(b.server_alive_interval); + #[cfg(target_os = "macos")] + { + self.use_keychain = self.use_keychain.or(b.use_keychain); + } + self.tcp_keep_alive = self.tcp_keep_alive.or(b.tcp_keep_alive); + self.user = self.user.clone().or_else(|| b.user.clone()); + for (ignored_field, args) in &b.ignored_fields { + if !self.ignored_fields.contains_key(ignored_field) { + self.ignored_fields + .insert(ignored_field.to_owned(), args.to_owned()); + } + } + for (unsupported_field, args) in &b.unsupported_fields { + if !self.unsupported_fields.contains_key(unsupported_field) { + self.unsupported_fields + .insert(unsupported_field.to_owned(), args.to_owned()); + } + } + + // merge algos if default and b is not default + if self.ca_signature_algorithms.is_default() && !b.ca_signature_algorithms.is_default() { + self.ca_signature_algorithms = b.ca_signature_algorithms.clone(); + } + if self.ciphers.is_default() && !b.ciphers.is_default() { + self.ciphers = b.ciphers.clone(); + } + if self.host_key_algorithms.is_default() && !b.host_key_algorithms.is_default() { + self.host_key_algorithms = b.host_key_algorithms.clone(); + } + if self.kex_algorithms.is_default() && !b.kex_algorithms.is_default() { + self.kex_algorithms = b.kex_algorithms.clone(); + } + if self.mac.is_default() && !b.mac.is_default() { + self.mac = b.mac.clone(); + } + if self.pubkey_accepted_algorithms.is_default() + && !b.pubkey_accepted_algorithms.is_default() + { + self.pubkey_accepted_algorithms = b.pubkey_accepted_algorithms.clone(); + } + } +} + +#[cfg(test)] +mod test { + + use std::str::FromStr; + + use pretty_assertions::assert_eq; + + use super::*; + use crate::params::algos::AlgorithmsRule; + + #[test] + fn should_initialize_params() { + let params = HostParams::new(&DefaultAlgorithms::default()); + assert!(params.bind_address.is_none()); + assert!(params.bind_interface.is_none()); + assert_eq!( + params.ca_signature_algorithms.algorithms(), + DefaultAlgorithms::default().ca_signature_algorithms + ); + assert!(params.certificate_file.is_none()); + assert_eq!( + params.ciphers.algorithms(), + DefaultAlgorithms::default().ciphers + ); + assert!(params.compression.is_none()); + assert!(params.connection_attempts.is_none()); + assert!(params.connect_timeout.is_none()); + assert_eq!( + params.host_key_algorithms.algorithms(), + DefaultAlgorithms::default().host_key_algorithms + ); + assert!(params.host_name.is_none()); + assert!(params.identity_file.is_none()); + assert!(params.ignore_unknown.is_none()); + assert_eq!( + params.kex_algorithms.algorithms(), + DefaultAlgorithms::default().kex_algorithms + ); + assert_eq!(params.mac.algorithms(), DefaultAlgorithms::default().mac); + assert!(params.port.is_none()); + assert_eq!( + params.pubkey_accepted_algorithms.algorithms(), + DefaultAlgorithms::default().pubkey_accepted_algorithms + ); + assert!(params.pubkey_authentication.is_none()); + assert!(params.remote_forward.is_none()); + assert!(params.server_alive_interval.is_none()); + #[cfg(target_os = "macos")] + assert!(params.use_keychain.is_none()); + assert!(params.tcp_keep_alive.is_none()); + } + + #[test] + fn test_should_overwrite_if_none() { + let mut params = HostParams::new(&DefaultAlgorithms::default()); + params.bind_address = Some(String::from("pippo")); + + let mut b = HostParams::new(&DefaultAlgorithms::default()); + b.bind_address = Some(String::from("pluto")); + b.bind_interface = Some(String::from("tun0")); + b.ciphers + .apply(AlgorithmsRule::from_str("c,d").expect("parse error")); + + params.overwrite_if_none(&b); + assert_eq!(params.bind_address.unwrap(), "pippo"); + assert_eq!(params.bind_interface.unwrap(), "tun0"); + + // algos + assert_eq!( + params.ciphers.algorithms(), + vec!["c".to_string(), "d".to_string()] + ); + } +} diff --git a/src/params/algos.rs b/src/params/algos.rs new file mode 100644 index 0000000..ecb6ff8 --- /dev/null +++ b/src/params/algos.rs @@ -0,0 +1,366 @@ +use std::fmt; +use std::str::FromStr; + +use crate::SshParserError; + +const ID_APPEND: char = '+'; +const ID_HEAD: char = '^'; +const ID_EXCLUDE: char = '-'; + +/// List of algorithms to be used. +/// The algorithms can be appended to the default set, placed at the head of the list, +/// excluded from the default set, or set as the default set. +/// +/// # Configuring SSH Algorithms +/// +/// In order to configure ssh you should use the `to_string()` method to get the string representation +/// with the correct format for ssh2. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Algorithms { + /// Algorithms to be used. + algos: Vec, + /// whether the default algorithms have been overridden + overridden: bool, + /// applied rule + rule: Option, +} + +impl Algorithms { + /// Create a new instance of [`Algorithms`] with the given default algorithms. + /// + /// ## Example + /// + /// ```rust + /// use ssh2_config::Algorithms; + /// + /// let algos = Algorithms::new(&["aes128-ctr", "aes192-ctr"]); + /// ``` + pub fn new(default: I) -> Self + where + I: IntoIterator, + S: AsRef, + { + Self { + algos: default + .into_iter() + .map(|s| s.as_ref().to_string()) + .collect(), + overridden: false, + rule: None, + } + } +} + +/// List of algorithms to be used. +/// The algorithms can be appended to the default set, placed at the head of the list, +/// excluded from the default set, or set as the default set. +/// +/// # Configuring SSH Algorithms +/// +/// In order to configure ssh you should use the `to_string()` method to get the string representation +/// with the correct format for ssh2. +/// +/// # Algorithms vector +/// +/// Otherwise you can access the inner [`Vec`] of algorithms with the [`Algorithms::algos`] method. +/// +/// Beware though, that you must **TAKE CARE of the current variant**. +/// +/// For instance in case the variant is [`Algorithms::Exclude`] the algos contained in the vec are the ones **to be excluded**. +/// +/// While in case of [`Algorithms::Append`] the algos contained in the vec are the ones to be appended to the default ones. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum AlgorithmsRule { + /// Append the given algorithms to the default set. + Append(Vec), + /// Place the given algorithms at the head of the list. + Head(Vec), + /// Exclude the given algorithms from the default set. + Exclude(Vec), + /// Set the given algorithms as the default set. + Set(Vec), +} + +/// Rule applied; used to format algorithms +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum AlgorithmsOp { + Append, + Head, + Exclude, + Set, +} + +impl Algorithms { + /// Returns whether the default algorithms are being used. + pub fn is_default(&self) -> bool { + !self.overridden + } + + /// Returns algorithms to be used. + pub fn algorithms(&self) -> &[String] { + &self.algos + } + + /// Apply an [`AlgorithmsRule`] to the [`Algorithms`] instance. + /// + /// If defaults haven't been overridden, apply changes from incoming rule; + /// otherwise keep as-is. + pub fn apply(&mut self, rule: AlgorithmsRule) { + if self.overridden { + // don't apply changes if defaults have been overridden + return; + } + + let mut current_algos = self.algos.clone(); + + match rule.clone() { + AlgorithmsRule::Append(algos) => { + // append but exclude duplicates + for algo in algos { + if !current_algos.iter().any(|s| s == &algo) { + current_algos.push(algo); + } + } + } + AlgorithmsRule::Head(algos) => { + current_algos = algos; + current_algos.extend(self.algorithms().iter().map(|s| s.to_string())); + } + AlgorithmsRule::Exclude(exclude) => { + current_algos = current_algos + .iter() + .filter(|algo| !exclude.contains(algo)) + .map(|s| s.to_string()) + .collect(); + } + AlgorithmsRule::Set(algos) => { + // override default with new set + current_algos = algos; + } + } + + // apply changes + self.rule = Some(rule); + self.algos = current_algos; + self.overridden = true; + } +} + +impl AlgorithmsRule { + fn op(&self) -> AlgorithmsOp { + match self { + Self::Append(_) => AlgorithmsOp::Append, + Self::Head(_) => AlgorithmsOp::Head, + Self::Exclude(_) => AlgorithmsOp::Exclude, + Self::Set(_) => AlgorithmsOp::Set, + } + } +} + +impl FromStr for AlgorithmsRule { + type Err = SshParserError; + + fn from_str(s: &str) -> Result { + if s.is_empty() { + return Err(SshParserError::ExpectedAlgorithms); + } + + // get first char + let (op, start) = match s.chars().next().expect("can't be empty") { + ID_APPEND => (AlgorithmsOp::Append, 1), + ID_HEAD => (AlgorithmsOp::Head, 1), + ID_EXCLUDE => (AlgorithmsOp::Exclude, 1), + _ => (AlgorithmsOp::Set, 0), + }; + + let algos = s[start..] + .split(',') + .map(|s| s.trim().to_string()) + .collect::>(); + + match op { + AlgorithmsOp::Append => Ok(Self::Append(algos)), + AlgorithmsOp::Head => Ok(Self::Head(algos)), + AlgorithmsOp::Exclude => Ok(Self::Exclude(algos)), + AlgorithmsOp::Set => Ok(Self::Set(algos)), + } + } +} + +impl fmt::Display for AlgorithmsRule { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let op = self.op(); + write!(f, "{op}") + } +} + +impl fmt::Display for AlgorithmsOp { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match &self { + Self::Append => write!(f, "{ID_APPEND}"), + Self::Head => write!(f, "{ID_HEAD}"), + Self::Exclude => write!(f, "{ID_EXCLUDE}"), + Self::Set => write!(f, ""), + } + } +} + +impl fmt::Display for Algorithms { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if let Some(rule) = self.rule.as_ref() { + write!(f, "{rule}",) + } else { + write!(f, "{}", self.algos.join(",")) + } + } +} + +#[cfg(test)] +mod test { + + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn test_should_parse_algos_set() { + let algo = + AlgorithmsRule::from_str("aes128-ctr,aes192-ctr,aes256-ctr").expect("failed to parse"); + assert_eq!( + algo, + AlgorithmsRule::Set(vec![ + "aes128-ctr".to_string(), + "aes192-ctr".to_string(), + "aes256-ctr".to_string() + ]) + ); + } + + #[test] + fn test_should_parse_algos_append() { + let algo = + AlgorithmsRule::from_str("+aes128-ctr,aes192-ctr,aes256-ctr").expect("failed to parse"); + assert_eq!( + algo, + AlgorithmsRule::Append(vec![ + "aes128-ctr".to_string(), + "aes192-ctr".to_string(), + "aes256-ctr".to_string() + ]) + ); + } + + #[test] + fn test_should_parse_algos_head() { + let algo = + AlgorithmsRule::from_str("^aes128-ctr,aes192-ctr,aes256-ctr").expect("failed to parse"); + assert_eq!( + algo, + AlgorithmsRule::Head(vec![ + "aes128-ctr".to_string(), + "aes192-ctr".to_string(), + "aes256-ctr".to_string() + ]) + ); + } + + #[test] + fn test_should_parse_algos_exclude() { + let algo = + AlgorithmsRule::from_str("-aes128-ctr,aes192-ctr,aes256-ctr").expect("failed to parse"); + assert_eq!( + algo, + AlgorithmsRule::Exclude(vec![ + "aes128-ctr".to_string(), + "aes192-ctr".to_string(), + "aes256-ctr".to_string() + ]) + ); + } + + #[test] + fn test_should_apply_append() { + let mut algo1 = Algorithms::new(&["aes128-ctr", "aes192-ctr"]); + let algo2 = AlgorithmsRule::from_str("+aes256-ctr").expect("failed to parse"); + algo1.apply(algo2); + assert_eq!( + algo1.algorithms(), + vec![ + "aes128-ctr".to_string(), + "aes192-ctr".to_string(), + "aes256-ctr".to_string() + ] + ); + } + + #[test] + fn test_should_merge_append_if_undefined() { + let algos: Vec = vec![]; + let mut algo1 = Algorithms::new(algos); + let algo2 = AlgorithmsRule::from_str("+aes256-ctr").expect("failed to parse"); + algo1.apply(algo2); + assert_eq!(algo1.algorithms(), vec!["aes256-ctr".to_string()]); + } + + #[test] + fn test_should_merge_head() { + let mut algo1 = Algorithms::new(&["aes128-ctr", "aes192-ctr"]); + let algo2 = AlgorithmsRule::from_str("^aes256-ctr").expect("failed to parse"); + algo1.apply(algo2); + assert_eq!( + algo1.algorithms(), + vec![ + "aes256-ctr".to_string(), + "aes128-ctr".to_string(), + "aes192-ctr".to_string() + ] + ); + } + + #[test] + fn test_should_apply_head() { + let mut algo1 = Algorithms::new(&["aes128-ctr", "aes192-ctr"]); + let algo2 = AlgorithmsRule::from_str("^aes256-ctr").expect("failed to parse"); + algo1.apply(algo2); + assert_eq!( + algo1.algorithms(), + vec![ + "aes256-ctr".to_string(), + "aes128-ctr".to_string(), + "aes192-ctr".to_string() + ] + ); + } + + #[test] + fn test_should_merge_exclude() { + let mut algo1 = Algorithms::new(&["aes128-ctr", "aes192-ctr", "aes256-ctr"]); + let algo2 = AlgorithmsRule::from_str("-aes192-ctr").expect("failed to parse"); + algo1.apply(algo2); + assert_eq!( + algo1.algorithms(), + vec!["aes128-ctr".to_string(), "aes256-ctr".to_string()] + ); + } + + #[test] + fn test_should_merge_set() { + let mut algo1 = Algorithms::new(&["aes128-ctr", "aes192-ctr"]); + let algo2 = AlgorithmsRule::from_str("aes256-ctr").expect("failed to parse"); + algo1.apply(algo2); + assert_eq!(algo1.algorithms(), vec!["aes256-ctr".to_string()]); + } + + #[test] + fn test_should_not_apply_twice() { + let mut algo1 = Algorithms::new(&["aes128-ctr", "aes192-ctr"]); + let algo2 = AlgorithmsRule::from_str("aes256-ctr").expect("failed to parse"); + algo1.apply(algo2); + assert_eq!(algo1.algorithms(), vec!["aes256-ctr".to_string(),]); + + let algo3 = AlgorithmsRule::from_str("aes128-ctr").expect("failed to parse"); + algo1.apply(algo3); + assert_eq!(algo1.algorithms(), vec!["aes256-ctr".to_string()]); + assert_eq!(algo1.overridden, true); + } +} diff --git a/src/parser.rs b/src/parser.rs new file mode 100644 index 0000000..2ffbd77 --- /dev/null +++ b/src/parser.rs @@ -0,0 +1,1814 @@ +//! # parser +//! +//! Ssh config parser + +use std::fs::File; +use std::io::{BufRead, BufReader, Error as IoError}; +use std::path::PathBuf; +use std::str::FromStr; +use std::time::Duration; + +use bitflags::bitflags; +use glob::glob; +use thiserror::Error; + +use super::{Host, HostClause, HostParams, SshConfig}; +use crate::DefaultAlgorithms; +use crate::params::AlgorithmsRule; + +// modules +mod field; +use field::Field; + +pub type SshParserResult = Result; + +/// Ssh config parser error +#[derive(Debug, Error)] +pub enum SshParserError { + #[error("expected boolean value ('yes', 'no')")] + ExpectedBoolean, + #[error("expected port number")] + ExpectedPort, + #[error("expected unsigned value")] + ExpectedUnsigned, + #[error("expected algorithms")] + ExpectedAlgorithms, + #[error("expected path")] + ExpectedPath, + #[error("IO error: {0}")] + Io(#[from] IoError), + #[error("glob error: {0}")] + Glob(#[from] glob::GlobError), + #[error("missing argument")] + MissingArgument, + #[error("pattern error: {0}")] + PatternError(#[from] glob::PatternError), + #[error("unknown field: {0}")] + UnknownField(String, Vec), + #[error("unknown field: {0}")] + UnsupportedField(String, Vec), +} + +bitflags! { + /// The parsing mode + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] + pub struct ParseRule: u8 { + /// Don't allow any invalid field or value + const STRICT = 0b00000000; + /// Allow unknown field + const ALLOW_UNKNOWN_FIELDS = 0b00000001; + /// Allow unsupported fields + const ALLOW_UNSUPPORTED_FIELDS = 0b00000010; + } +} + +// -- parser + +/// Ssh config parser +pub struct SshConfigParser; + +impl SshConfigParser { + /// Parse reader lines and apply parameters to configuration + pub fn parse( + config: &mut SshConfig, + reader: &mut impl BufRead, + rules: ParseRule, + ) -> SshParserResult<()> { + // Options preceding the first `Host` section + // are parsed as command line options; + // overriding all following host-specific options. + // + // See https://github.com/openssh/openssh-portable/blob/master/readconf.c#L1051-L1054 + config.hosts.push(Host::new( + vec![HostClause::new(String::from("*"), false)], + HostParams::new(&config.default_algorithms), + )); + + // Current host pointer + let mut current_host = config.hosts.last_mut().unwrap(); + + let mut lines = reader.lines(); + // iter lines + loop { + let line = match lines.next() { + None => break, + Some(Err(err)) => return Err(SshParserError::Io(err)), + Some(Ok(line)) => Self::strip_comments(line.trim()), + }; + if line.is_empty() { + continue; + } + // tokenize + let (field, args) = match Self::tokenize_line(&line) { + Ok((field, args)) => (field, args), + Err(SshParserError::UnknownField(field, args)) + if rules.intersects(ParseRule::ALLOW_UNKNOWN_FIELDS) + || current_host.params.ignored(&field) => + { + current_host.params.ignored_fields.insert(field, args); + continue; + } + Err(SshParserError::UnknownField(field, args)) => { + return Err(SshParserError::UnknownField(field, args)); + } + Err(err) => return Err(err), + }; + // If field is block, init a new block + if field == Field::Host { + // Pass `ignore_unknown` from global overrides down into the tokenizer. + let mut params = HostParams::new(&config.default_algorithms); + params.ignore_unknown = config.hosts[0].params.ignore_unknown.clone(); + let pattern = Self::parse_host(args)?; + trace!("Adding new host: {pattern:?}",); + + // Add a new host + config.hosts.push(Host::new(pattern, params)); + // Update current host pointer + current_host = config.hosts.last_mut().unwrap(); + } else { + // Update field + match Self::update_host( + field, + args, + current_host, + rules, + &config.default_algorithms, + ) { + Ok(()) => Ok(()), + // If we're allowing unsupported fields to be parsed, add them to the map + Err(SshParserError::UnsupportedField(field, args)) + if rules.intersects(ParseRule::ALLOW_UNSUPPORTED_FIELDS) => + { + current_host.params.unsupported_fields.insert(field, args); + Ok(()) + } + // Eat the error here to not break the API with this change + // Also it'd be weird to error on correct ssh_config's just because they're + // not supported by this library + Err(SshParserError::UnsupportedField(_, _)) => Ok(()), + e => e, + }?; + } + } + + Ok(()) + } + + /// Strip comments from line + fn strip_comments(s: &str) -> String { + if let Some(pos) = s.find('#') { + s[..pos].to_string() + } else { + s.to_string() + } + } + + /// Update current given host with field argument + fn update_host( + field: Field, + args: Vec, + host: &mut Host, + rules: ParseRule, + default_algos: &DefaultAlgorithms, + ) -> SshParserResult<()> { + trace!("parsing field {field:?} with args {args:?}",); + let params = &mut host.params; + match field { + Field::BindAddress => { + let value = Self::parse_string(args)?; + trace!("bind_address: {value}",); + params.bind_address = Some(value); + } + Field::BindInterface => { + let value = Self::parse_string(args)?; + trace!("bind_interface: {value}",); + params.bind_interface = Some(value); + } + Field::CaSignatureAlgorithms => { + let rule = Self::parse_algos(args)?; + trace!("ca_signature_algorithms: {rule:?}",); + params.ca_signature_algorithms.apply(rule); + } + Field::CertificateFile => { + let value = Self::parse_path(args)?; + trace!("certificate_file: {value:?}",); + params.certificate_file = Some(value); + } + Field::Ciphers => { + let rule = Self::parse_algos(args)?; + trace!("ciphers: {rule:?}",); + params.ciphers.apply(rule); + } + Field::Compression => { + let value = Self::parse_boolean(args)?; + trace!("compression: {value}",); + params.compression = Some(value); + } + Field::ConnectTimeout => { + let value = Self::parse_duration(args)?; + trace!("connect_timeout: {value:?}",); + params.connect_timeout = Some(value); + } + Field::ConnectionAttempts => { + let value = Self::parse_unsigned(args)?; + trace!("connection_attempts: {value}",); + params.connection_attempts = Some(value); + } + Field::Host => { /* already handled before */ } + Field::HostKeyAlgorithms => { + let rule = Self::parse_algos(args)?; + trace!("host_key_algorithm: {rule:?}",); + params.host_key_algorithms.apply(rule); + } + Field::HostName => { + let value = Self::parse_string(args)?; + trace!("host_name: {value}",); + params.host_name = Some(value); + } + Field::Include => { + Self::include_files(args, host, rules, default_algos)?; + } + Field::IdentityFile => { + let value = Self::parse_path_list(args)?; + trace!("identity_file: {value:?}",); + params.identity_file = Some(value); + } + Field::IgnoreUnknown => { + let value = Self::parse_comma_separated_list(args)?; + trace!("ignore_unknown: {value:?}",); + params.ignore_unknown = Some(value); + } + Field::KexAlgorithms => { + let rule = Self::parse_algos(args)?; + trace!("kex_algorithms: {rule:?}",); + params.kex_algorithms.apply(rule); + } + Field::Mac => { + let rule = Self::parse_algos(args)?; + trace!("mac: {rule:?}",); + params.mac.apply(rule); + } + Field::Port => { + let value = Self::parse_port(args)?; + trace!("port: {value}",); + params.port = Some(value); + } + Field::PubkeyAcceptedAlgorithms => { + let rule = Self::parse_algos(args)?; + trace!("pubkey_accepted_algorithms: {rule:?}",); + params.pubkey_accepted_algorithms.apply(rule); + } + Field::PubkeyAuthentication => { + let value = Self::parse_boolean(args)?; + trace!("pubkey_authentication: {value}",); + params.pubkey_authentication = Some(value); + } + Field::RemoteForward => { + let value = Self::parse_port(args)?; + trace!("remote_forward: {value}",); + params.remote_forward = Some(value); + } + Field::ServerAliveInterval => { + let value = Self::parse_duration(args)?; + trace!("server_alive_interval: {value:?}",); + params.server_alive_interval = Some(value); + } + Field::TcpKeepAlive => { + let value = Self::parse_boolean(args)?; + trace!("tcp_keep_alive: {value}",); + params.tcp_keep_alive = Some(value); + } + #[cfg(target_os = "macos")] + Field::UseKeychain => { + let value = Self::parse_boolean(args)?; + trace!("use_keychain: {value}",); + params.use_keychain = Some(value); + } + Field::User => { + let value = Self::parse_string(args)?; + trace!("user: {value}",); + params.user = Some(value); + } + // -- unimplemented fields + Field::AddKeysToAgent + | Field::AddressFamily + | Field::BatchMode + | Field::CanonicalDomains + | Field::CanonicalizeFallbackLock + | Field::CanonicalizeHostname + | Field::CanonicalizeMaxDots + | Field::CanonicalizePermittedCNAMEs + | Field::CheckHostIP + | Field::ClearAllForwardings + | Field::ControlMaster + | Field::ControlPath + | Field::ControlPersist + | Field::DynamicForward + | Field::EnableSSHKeysign + | Field::EscapeChar + | Field::ExitOnForwardFailure + | Field::FingerprintHash + | Field::ForkAfterAuthentication + | Field::ForwardAgent + | Field::ForwardX11 + | Field::ForwardX11Timeout + | Field::ForwardX11Trusted + | Field::GatewayPorts + | Field::GlobalKnownHostsFile + | Field::GSSAPIAuthentication + | Field::GSSAPIDelegateCredentials + | Field::HashKnownHosts + | Field::HostbasedAcceptedAlgorithms + | Field::HostbasedAuthentication + | Field::HostKeyAlias + | Field::HostbasedKeyTypes + | Field::IdentitiesOnly + | Field::IdentityAgent + | Field::IPQoS + | Field::KbdInteractiveAuthentication + | Field::KbdInteractiveDevices + | Field::KnownHostsCommand + | Field::LocalCommand + | Field::LocalForward + | Field::LogLevel + | Field::LogVerbose + | Field::NoHostAuthenticationForLocalhost + | Field::NumberOfPasswordPrompts + | Field::PasswordAuthentication + | Field::PermitLocalCommand + | Field::PermitRemoteOpen + | Field::PKCS11Provider + | Field::PreferredAuthentications + | Field::ProxyCommand + | Field::ProxyJump + | Field::ProxyUseFdpass + | Field::PubkeyAcceptedKeyTypes + | Field::RekeyLimit + | Field::RequestTTY + | Field::RevokedHostKeys + | Field::SecruityKeyProvider + | Field::SendEnv + | Field::ServerAliveCountMax + | Field::SessionType + | Field::SetEnv + | Field::StdinNull + | Field::StreamLocalBindMask + | Field::StrictHostKeyChecking + | Field::SyslogFacility + | Field::UpdateHostKeys + | Field::UserKnownHostsFile + | Field::VerifyHostKeyDNS + | Field::VisualHostKey + | Field::XAuthLocation => { + return Err(SshParserError::UnsupportedField(field.to_string(), args)); + } + } + Ok(()) + } + + /// include a file by parsing it and updating host rules by merging the read config to the current one for the host + fn include_files( + args: Vec, + host: &mut Host, + rules: ParseRule, + default_algos: &DefaultAlgorithms, + ) -> SshParserResult<()> { + let path_match = Self::parse_string(args)?; + trace!("include files: {path_match}",); + let files = glob(&path_match)?; + + for file in files { + let file = file?; + trace!("including file: {}", file.display()); + let mut reader = BufReader::new(File::open(file)?); + let mut sub_config = SshConfig::default().default_algorithms(default_algos.clone()); + Self::parse(&mut sub_config, &mut reader, rules)?; + + // merge sub-config into host + for pattern in &host.pattern { + if pattern.negated { + trace!("excluding sub-config for pattern: {pattern:?}",); + continue; + } + trace!("merging sub-config for pattern: {pattern:?}",); + let params = sub_config.query(&pattern.pattern); + host.params.overwrite_if_none(¶ms); + } + } + + Ok(()) + } + + /// Tokenize line if possible. Returns [`Field`] name and args as a [`Vec`] of [`String`]. + /// + /// All of these lines are valid for tokenization + /// + /// ```txt + /// IgnoreUnknown=Pippo,Pluto + /// ConnectTimeout = 15 + /// Ciphers "Pepperoni Pizza,Margherita Pizza,Hawaiian Pizza" + /// Macs="Pasta Carbonara,Pasta con tonno" + /// ``` + /// + /// So lines have syntax `field args...`, `field=args...`, `field "args"`, `field="args"` + fn tokenize_line(line: &str) -> SshParserResult<(Field, Vec)> { + // check what comes first, space or =? + let trimmed_line = line.trim(); + // first token is the field, and it may be separated either by a space or by '=' + let (field, other_tokens) = if trimmed_line.find('=').unwrap_or(usize::MAX) + < trimmed_line.find(char::is_whitespace).unwrap_or(usize::MAX) + { + trimmed_line + .split_once('=') + .ok_or(SshParserError::MissingArgument)? + } else { + trimmed_line + .split_once(char::is_whitespace) + .ok_or(SshParserError::MissingArgument)? + }; + + trace!("tokenized line '{line}' - field '{field}' with args '{other_tokens}'",); + + // other tokens should trim = and whitespace + let other_tokens = other_tokens.trim().trim_start_matches('=').trim(); + trace!("other tokens trimmed: '{other_tokens}'",); + + // if args is quoted, don't split it + let args = if other_tokens.starts_with('"') && other_tokens.ends_with('"') { + trace!("quoted args: '{other_tokens}'",); + vec![other_tokens[1..other_tokens.len() - 1].to_string()] + } else { + trace!("splitting args (non-quoted): '{other_tokens}'",); + // split by whitespace + let tokens = other_tokens.split_whitespace(); + + tokens + .map(|x| x.trim().to_string()) + .filter(|x| !x.is_empty()) + .collect() + }; + + match Field::from_str(field) { + Ok(field) => Ok((field, args)), + Err(_) => Err(SshParserError::UnknownField(field.to_string(), args)), + } + } + + // -- value parsers + + /// parse boolean value + fn parse_boolean(args: Vec) -> SshParserResult { + match args.first().map(|x| x.as_str()) { + Some("yes") => Ok(true), + Some("no") => Ok(false), + Some(_) => Err(SshParserError::ExpectedBoolean), + None => Err(SshParserError::MissingArgument), + } + } + + /// Parse algorithms argument + fn parse_algos(args: Vec) -> SshParserResult { + let first = args.first().ok_or(SshParserError::MissingArgument)?; + + AlgorithmsRule::from_str(first) + } + + /// Parse comma separated list arguments + fn parse_comma_separated_list(args: Vec) -> SshParserResult> { + match args + .first() + .map(|x| x.split(',').map(|x| x.to_string()).collect()) + { + Some(args) => Ok(args), + _ => Err(SshParserError::MissingArgument), + } + } + + /// Parse duration argument + fn parse_duration(args: Vec) -> SshParserResult { + let value = Self::parse_unsigned(args)?; + Ok(Duration::from_secs(value as u64)) + } + + /// Parse host argument + fn parse_host(args: Vec) -> SshParserResult> { + if args.is_empty() { + return Err(SshParserError::MissingArgument); + } + // Collect hosts + Ok(args + .into_iter() + .map(|x| { + let tokens: Vec<&str> = x.split('!').collect(); + if tokens.len() == 2 { + HostClause::new(tokens[1].to_string(), true) + } else { + HostClause::new(tokens[0].to_string(), false) + } + }) + .collect()) + } + + /// Parse a list of paths + fn parse_path_list(args: Vec) -> SshParserResult> { + if args.is_empty() { + return Err(SshParserError::MissingArgument); + } + args.iter() + .map(|x| Self::parse_path_arg(x.as_str())) + .collect() + } + + /// Parse path argument + fn parse_path(args: Vec) -> SshParserResult { + if let Some(s) = args.first() { + Self::parse_path_arg(s) + } else { + Err(SshParserError::MissingArgument) + } + } + + /// Parse path argument + fn parse_path_arg(s: &str) -> SshParserResult { + // Remove tilde + let s = if s.starts_with('~') { + let home_dir = dirs::home_dir() + .unwrap_or_else(|| PathBuf::from("~")) + .to_string_lossy() + .to_string(); + s.replacen('~', &home_dir, 1) + } else { + s.to_string() + }; + Ok(PathBuf::from(s)) + } + + /// Parse port number argument + fn parse_port(args: Vec) -> SshParserResult { + match args.first().map(|x| u16::from_str(x)) { + Some(Ok(val)) => Ok(val), + Some(Err(_)) => Err(SshParserError::ExpectedPort), + None => Err(SshParserError::MissingArgument), + } + } + + /// Parse string argument + fn parse_string(args: Vec) -> SshParserResult { + if let Some(s) = args.into_iter().next() { + Ok(s) + } else { + Err(SshParserError::MissingArgument) + } + } + + /// Parse unsigned argument + fn parse_unsigned(args: Vec) -> SshParserResult { + match args.first().map(|x| usize::from_str(x)) { + Some(Ok(val)) => Ok(val), + Some(Err(_)) => Err(SshParserError::ExpectedUnsigned), + None => Err(SshParserError::MissingArgument), + } + } +} + +#[cfg(test)] +mod test { + + use std::fs::File; + use std::io::{BufReader, Write}; + use std::path::Path; + + use pretty_assertions::assert_eq; + use tempfile::NamedTempFile; + + use super::*; + use crate::DefaultAlgorithms; + + #[test] + fn should_parse_configuration() -> Result<(), SshParserError> { + crate::test_log(); + let temp = create_ssh_config(); + let file = File::open(temp.path()).expect("Failed to open tempfile"); + let mut reader = BufReader::new(file); + let config = SshConfig::default() + .default_algorithms(DefaultAlgorithms { + ca_signature_algorithms: vec![], + ciphers: vec![], + host_key_algorithms: vec![], + kex_algorithms: vec![], + mac: vec![], + pubkey_accepted_algorithms: vec!["omar-crypt".to_string()], + }) + .parse(&mut reader, ParseRule::STRICT)?; + + // Query openssh cmdline overrides (options preceding the first `Host` section, + // overriding all following options) + let params = config.query("*"); + assert_eq!( + params.ignore_unknown.as_deref().unwrap(), + &["Pippo", "Pluto"] + ); + assert_eq!(params.compression.unwrap(), true); + assert_eq!(params.connection_attempts.unwrap(), 10); + assert_eq!(params.connect_timeout.unwrap(), Duration::from_secs(60)); + assert_eq!( + params.server_alive_interval.unwrap(), + Duration::from_secs(40) + ); + assert_eq!(params.tcp_keep_alive.unwrap(), true); + assert_eq!(params.ciphers.algorithms(), &["a-manella", "blowfish"]); + assert_eq!( + params.pubkey_accepted_algorithms.algorithms(), + &["desu", "omar-crypt", "fast-omar-crypt"] + ); + + // Query explicit all-hosts fallback options (`Host *`) + assert_eq!(params.ca_signature_algorithms.algorithms(), &["random"]); + assert_eq!( + params.host_key_algorithms.algorithms(), + &["luigi", "mario",] + ); + assert_eq!(params.kex_algorithms.algorithms(), &["desu", "gigi",]); + assert_eq!(params.mac.algorithms(), &["concorde"]); + assert!(params.bind_address.is_none()); + + // Query 172.26.104.4, yielding cmdline overrides, + // explicit `Host 192.168.*.* 172.26.*.* !192.168.1.30` options, + // and all-hosts fallback options. + let params_172_26_104_4 = config.query("172.26.104.4"); + + // cmdline overrides + assert_eq!(params_172_26_104_4.compression.unwrap(), true); + assert_eq!(params_172_26_104_4.connection_attempts.unwrap(), 10); + assert_eq!( + params_172_26_104_4.connect_timeout.unwrap(), + Duration::from_secs(60) + ); + assert_eq!(params_172_26_104_4.tcp_keep_alive.unwrap(), true); + + // all-hosts fallback options, merged with host-specific options + assert_eq!( + params_172_26_104_4.ca_signature_algorithms.algorithms(), + &["random"] + ); + assert_eq!( + params_172_26_104_4.ciphers.algorithms(), + &["a-manella", "blowfish",] + ); + assert_eq!(params_172_26_104_4.mac.algorithms(), &["spyro", "deoxys"]); // use subconfig; defined before * macs + assert_eq!( + params_172_26_104_4 + .pubkey_accepted_algorithms + .algorithms() + .is_empty(), // should have removed omar-crypt + true + ); + assert_eq!( + params_172_26_104_4.bind_address.as_deref().unwrap(), + "10.8.0.10" + ); + assert_eq!( + params_172_26_104_4.bind_interface.as_deref().unwrap(), + "tun0" + ); + assert_eq!(params_172_26_104_4.port.unwrap(), 2222); + assert_eq!( + params_172_26_104_4.identity_file.as_deref().unwrap(), + vec![ + Path::new("/home/root/.ssh/pippo.key"), + Path::new("/home/root/.ssh/pluto.key") + ] + ); + assert_eq!(params_172_26_104_4.user.as_deref().unwrap(), "omar"); + + // Query tostapane + let params_tostapane = config.query("tostapane"); + assert_eq!(params_tostapane.compression.unwrap(), true); // it takes the first value defined, which is `yes` + assert_eq!(params_tostapane.connection_attempts.unwrap(), 10); + assert_eq!( + params_tostapane.connect_timeout.unwrap(), + Duration::from_secs(60) + ); + assert_eq!(params_tostapane.tcp_keep_alive.unwrap(), true); + assert_eq!(params_tostapane.remote_forward.unwrap(), 88); + assert_eq!(params_tostapane.user.as_deref().unwrap(), "ciro-esposito"); + + // all-hosts fallback options + assert_eq!( + params_tostapane.ca_signature_algorithms.algorithms(), + &["random"] + ); + assert_eq!( + params_tostapane.ciphers.algorithms(), + &["a-manella", "blowfish",] + ); + assert_eq!( + params_tostapane.mac.algorithms(), + vec!["spyro".to_string(), "deoxys".to_string(),] + ); + assert_eq!( + params_tostapane.pubkey_accepted_algorithms.algorithms(), + &["desu", "omar-crypt", "fast-omar-crypt"] + ); + + // query 192.168.1.30 + let params_192_168_1_30 = config.query("192.168.1.30"); + + // host-specific options + assert_eq!(params_192_168_1_30.user.as_deref().unwrap(), "nutellaro"); + assert_eq!(params_192_168_1_30.remote_forward.unwrap(), 123); + + // cmdline overrides + assert_eq!(params_192_168_1_30.compression.unwrap(), true); + assert_eq!(params_192_168_1_30.connection_attempts.unwrap(), 10); + assert_eq!( + params_192_168_1_30.connect_timeout.unwrap(), + Duration::from_secs(60) + ); + assert_eq!(params_192_168_1_30.tcp_keep_alive.unwrap(), true); + + // all-hosts fallback options + assert_eq!( + params_192_168_1_30.ca_signature_algorithms.algorithms(), + &["random"] + ); + assert_eq!( + params_192_168_1_30.ciphers.algorithms(), + &["a-manella", "blowfish"] + ); + assert_eq!(params_192_168_1_30.mac.algorithms(), &["concorde"]); + assert_eq!( + params_192_168_1_30.pubkey_accepted_algorithms.algorithms(), + &["desu", "omar-crypt", "fast-omar-crypt"] + ); + + Ok(()) + } + + #[test] + fn should_allow_unknown_field() -> Result<(), SshParserError> { + crate::test_log(); + let temp = create_ssh_config_with_unknown_fields(); + let file = File::open(temp.path()).expect("Failed to open tempfile"); + let mut reader = BufReader::new(file); + let _config = SshConfig::default() + .default_algorithms(DefaultAlgorithms::empty()) + .parse(&mut reader, ParseRule::ALLOW_UNKNOWN_FIELDS)?; + + Ok(()) + } + + #[test] + fn should_not_allow_unknown_field() { + crate::test_log(); + let temp = create_ssh_config_with_unknown_fields(); + let file = File::open(temp.path()).expect("Failed to open tempfile"); + let mut reader = BufReader::new(file); + assert!(matches!( + SshConfig::default() + .default_algorithms(DefaultAlgorithms::empty()) + .parse(&mut reader, ParseRule::STRICT) + .unwrap_err(), + SshParserError::UnknownField(..) + )); + } + + #[test] + fn should_store_unknown_fields() { + crate::test_log(); + let temp = create_ssh_config_with_unknown_fields(); + let file = File::open(temp.path()).expect("Failed to open tempfile"); + let mut reader = BufReader::new(file); + let config = SshConfig::default() + .default_algorithms(DefaultAlgorithms::empty()) + .parse(&mut reader, ParseRule::ALLOW_UNKNOWN_FIELDS) + .unwrap(); + + let host = config.query("cross-platform"); + assert_eq!( + host.ignored_fields.get("Piropero").unwrap(), + &vec![String::from("yes")] + ); + } + + #[test] + fn should_parse_inversed_ssh_config() { + crate::test_log(); + let temp = create_inverted_ssh_config(); + let file = File::open(temp.path()).expect("Failed to open tempfile"); + let mut reader = BufReader::new(file); + let config = SshConfig::default() + .default_algorithms(DefaultAlgorithms::empty()) + .parse(&mut reader, ParseRule::STRICT) + .unwrap(); + + let home_dir = dirs::home_dir() + .unwrap_or_else(|| PathBuf::from("~")) + .to_string_lossy() + .to_string(); + + let remote_host = config.query("remote-host"); + + // From `*-host` + assert_eq!( + remote_host.identity_file.unwrap()[0].as_path(), + Path::new(format!("{home_dir}/.ssh/id_rsa_good").as_str()) // because it's the first in the file + ); + + // From `remote-*` + assert_eq!(remote_host.host_name.unwrap(), "hostname.com"); + assert_eq!(remote_host.user.unwrap(), "user"); + + // From `*` + assert_eq!( + remote_host.connect_timeout.unwrap(), + Duration::from_secs(15) + ); + } + + #[test] + fn should_parse_configuration_with_hosts() { + crate::test_log(); + let temp = create_ssh_config_with_comments(); + + let file = File::open(temp.path()).expect("Failed to open tempfile"); + let mut reader = BufReader::new(file); + let config = SshConfig::default() + .default_algorithms(DefaultAlgorithms::empty()) + .parse(&mut reader, ParseRule::STRICT) + .unwrap(); + + let hostname = config.query("cross-platform").host_name.unwrap(); + assert_eq!(&hostname, "hostname.com"); + + assert!(config.query("this").host_name.is_none()); + } + + #[test] + fn should_update_host_bind_address() -> Result<(), SshParserError> { + crate::test_log(); + let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty())); + SshConfigParser::update_host( + Field::BindAddress, + vec![String::from("127.0.0.1")], + &mut host, + ParseRule::ALLOW_UNKNOWN_FIELDS, + &DefaultAlgorithms::empty(), + )?; + assert_eq!(host.params.bind_address.as_deref().unwrap(), "127.0.0.1"); + Ok(()) + } + + #[test] + fn should_update_host_bind_interface() -> Result<(), SshParserError> { + crate::test_log(); + let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty())); + SshConfigParser::update_host( + Field::BindInterface, + vec![String::from("aaa")], + &mut host, + ParseRule::ALLOW_UNKNOWN_FIELDS, + &DefaultAlgorithms::empty(), + )?; + assert_eq!(host.params.bind_interface.as_deref().unwrap(), "aaa"); + Ok(()) + } + + #[test] + fn should_update_host_ca_signature_algos() -> Result<(), SshParserError> { + crate::test_log(); + let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty())); + SshConfigParser::update_host( + Field::CaSignatureAlgorithms, + vec![String::from("a,b,c")], + &mut host, + ParseRule::ALLOW_UNKNOWN_FIELDS, + &DefaultAlgorithms::empty(), + )?; + assert_eq!( + host.params.ca_signature_algorithms.algorithms(), + &["a", "b", "c"] + ); + Ok(()) + } + + #[test] + fn should_update_host_certificate_file() -> Result<(), SshParserError> { + crate::test_log(); + let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty())); + SshConfigParser::update_host( + Field::CertificateFile, + vec![String::from("/tmp/a.crt")], + &mut host, + ParseRule::ALLOW_UNKNOWN_FIELDS, + &DefaultAlgorithms::empty(), + )?; + assert_eq!( + host.params.certificate_file.as_deref().unwrap(), + Path::new("/tmp/a.crt") + ); + Ok(()) + } + + #[test] + fn should_update_host_ciphers() -> Result<(), SshParserError> { + crate::test_log(); + let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty())); + SshConfigParser::update_host( + Field::Ciphers, + vec![String::from("a,b,c")], + &mut host, + ParseRule::ALLOW_UNKNOWN_FIELDS, + &DefaultAlgorithms::empty(), + )?; + assert_eq!(host.params.ciphers.algorithms(), &["a", "b", "c"]); + Ok(()) + } + + #[test] + fn should_update_host_compression() -> Result<(), SshParserError> { + crate::test_log(); + let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty())); + SshConfigParser::update_host( + Field::Compression, + vec![String::from("yes")], + &mut host, + ParseRule::ALLOW_UNKNOWN_FIELDS, + &DefaultAlgorithms::empty(), + )?; + assert_eq!(host.params.compression.unwrap(), true); + Ok(()) + } + + #[test] + fn should_update_host_connection_attempts() -> Result<(), SshParserError> { + crate::test_log(); + let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty())); + SshConfigParser::update_host( + Field::ConnectionAttempts, + vec![String::from("4")], + &mut host, + ParseRule::ALLOW_UNKNOWN_FIELDS, + &DefaultAlgorithms::empty(), + )?; + assert_eq!(host.params.connection_attempts.unwrap(), 4); + Ok(()) + } + + #[test] + fn should_update_host_connection_timeout() -> Result<(), SshParserError> { + crate::test_log(); + let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty())); + SshConfigParser::update_host( + Field::ConnectTimeout, + vec![String::from("10")], + &mut host, + ParseRule::ALLOW_UNKNOWN_FIELDS, + &DefaultAlgorithms::empty(), + )?; + assert_eq!( + host.params.connect_timeout.unwrap(), + Duration::from_secs(10) + ); + Ok(()) + } + + #[test] + fn should_update_host_key_algorithms() -> Result<(), SshParserError> { + crate::test_log(); + let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty())); + SshConfigParser::update_host( + Field::HostKeyAlgorithms, + vec![String::from("a,b,c")], + &mut host, + ParseRule::ALLOW_UNKNOWN_FIELDS, + &DefaultAlgorithms::empty(), + )?; + assert_eq!( + host.params.host_key_algorithms.algorithms(), + &["a", "b", "c"] + ); + Ok(()) + } + + #[test] + fn should_update_host_host_name() -> Result<(), SshParserError> { + crate::test_log(); + let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty())); + SshConfigParser::update_host( + Field::HostName, + vec![String::from("192.168.1.1")], + &mut host, + ParseRule::ALLOW_UNKNOWN_FIELDS, + &DefaultAlgorithms::empty(), + )?; + assert_eq!(host.params.host_name.as_deref().unwrap(), "192.168.1.1"); + Ok(()) + } + + #[test] + fn should_update_host_ignore_unknown() -> Result<(), SshParserError> { + crate::test_log(); + let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty())); + SshConfigParser::update_host( + Field::IgnoreUnknown, + vec![String::from("a,b,c")], + &mut host, + ParseRule::ALLOW_UNKNOWN_FIELDS, + &DefaultAlgorithms::empty(), + )?; + assert_eq!( + host.params.ignore_unknown.as_deref().unwrap(), + &["a", "b", "c"] + ); + Ok(()) + } + + #[test] + fn should_update_kex_algorithms() -> Result<(), SshParserError> { + crate::test_log(); + let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty())); + SshConfigParser::update_host( + Field::KexAlgorithms, + vec![String::from("a,b,c")], + &mut host, + ParseRule::ALLOW_UNKNOWN_FIELDS, + &DefaultAlgorithms::empty(), + )?; + assert_eq!(host.params.kex_algorithms.algorithms(), &["a", "b", "c"]); + Ok(()) + } + + #[test] + fn should_update_host_mac() -> Result<(), SshParserError> { + crate::test_log(); + let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty())); + SshConfigParser::update_host( + Field::Mac, + vec![String::from("a,b,c")], + &mut host, + ParseRule::ALLOW_UNKNOWN_FIELDS, + &DefaultAlgorithms::empty(), + )?; + assert_eq!(host.params.mac.algorithms(), &["a", "b", "c"]); + Ok(()) + } + + #[test] + fn should_update_host_port() -> Result<(), SshParserError> { + crate::test_log(); + let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty())); + SshConfigParser::update_host( + Field::Port, + vec![String::from("2222")], + &mut host, + ParseRule::ALLOW_UNKNOWN_FIELDS, + &DefaultAlgorithms::empty(), + )?; + assert_eq!(host.params.port.unwrap(), 2222); + Ok(()) + } + + #[test] + fn should_update_host_pubkey_accepted_algos() -> Result<(), SshParserError> { + crate::test_log(); + let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty())); + SshConfigParser::update_host( + Field::PubkeyAcceptedAlgorithms, + vec![String::from("a,b,c")], + &mut host, + ParseRule::ALLOW_UNKNOWN_FIELDS, + &DefaultAlgorithms::empty(), + )?; + assert_eq!( + host.params.pubkey_accepted_algorithms.algorithms(), + &["a", "b", "c"] + ); + Ok(()) + } + + #[test] + fn should_update_host_pubkey_authentication() -> Result<(), SshParserError> { + crate::test_log(); + let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty())); + SshConfigParser::update_host( + Field::PubkeyAuthentication, + vec![String::from("yes")], + &mut host, + ParseRule::ALLOW_UNKNOWN_FIELDS, + &DefaultAlgorithms::empty(), + )?; + assert_eq!(host.params.pubkey_authentication.unwrap(), true); + Ok(()) + } + + #[test] + fn should_update_host_remote_forward() -> Result<(), SshParserError> { + crate::test_log(); + let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty())); + SshConfigParser::update_host( + Field::RemoteForward, + vec![String::from("3005")], + &mut host, + ParseRule::ALLOW_UNKNOWN_FIELDS, + &DefaultAlgorithms::empty(), + )?; + assert_eq!(host.params.remote_forward.unwrap(), 3005); + Ok(()) + } + + #[test] + fn should_update_host_server_alive_interval() -> Result<(), SshParserError> { + crate::test_log(); + let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty())); + SshConfigParser::update_host( + Field::ServerAliveInterval, + vec![String::from("40")], + &mut host, + ParseRule::ALLOW_UNKNOWN_FIELDS, + &DefaultAlgorithms::empty(), + )?; + assert_eq!( + host.params.server_alive_interval.unwrap(), + Duration::from_secs(40) + ); + Ok(()) + } + + #[test] + fn should_update_host_tcp_keep_alive() -> Result<(), SshParserError> { + crate::test_log(); + let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty())); + SshConfigParser::update_host( + Field::TcpKeepAlive, + vec![String::from("no")], + &mut host, + ParseRule::ALLOW_UNKNOWN_FIELDS, + &DefaultAlgorithms::empty(), + )?; + assert_eq!(host.params.tcp_keep_alive.unwrap(), false); + Ok(()) + } + + #[test] + fn should_update_host_user() -> Result<(), SshParserError> { + crate::test_log(); + let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty())); + SshConfigParser::update_host( + Field::User, + vec![String::from("pippo")], + &mut host, + ParseRule::ALLOW_UNKNOWN_FIELDS, + &DefaultAlgorithms::empty(), + )?; + assert_eq!(host.params.user.as_deref().unwrap(), "pippo"); + Ok(()) + } + + #[test] + fn should_not_update_host_if_unknown() -> Result<(), SshParserError> { + crate::test_log(); + let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty())); + let result = SshConfigParser::update_host( + Field::AddKeysToAgent, + vec![String::from("yes")], + &mut host, + ParseRule::ALLOW_UNKNOWN_FIELDS, + &DefaultAlgorithms::empty(), + ); + + match result { + Ok(()) | Err(SshParserError::UnsupportedField(_, _)) => Ok(()), + e => e, + }?; + + assert_eq!(host.params, HostParams::new(&DefaultAlgorithms::empty())); + Ok(()) + } + + #[test] + fn should_update_host_if_unsupported() -> Result<(), SshParserError> { + crate::test_log(); + let mut host = Host::new(vec![], HostParams::new(&DefaultAlgorithms::empty())); + let result = SshConfigParser::update_host( + Field::AddKeysToAgent, + vec![String::from("yes")], + &mut host, + ParseRule::ALLOW_UNKNOWN_FIELDS, + &DefaultAlgorithms::empty(), + ); + + match result { + Err(SshParserError::UnsupportedField(field, _)) => { + assert_eq!(field, "addkeystoagent"); + Ok(()) + } + e => e, + }?; + + assert_eq!(host.params, HostParams::new(&DefaultAlgorithms::empty())); + Ok(()) + } + + #[test] + fn should_tokenize_line() -> Result<(), SshParserError> { + crate::test_log(); + assert_eq!( + SshConfigParser::tokenize_line("HostName 192.168.*.* 172.26.*.*")?, + ( + Field::HostName, + vec![String::from("192.168.*.*"), String::from("172.26.*.*")] + ) + ); + // Tokenize line with spaces + assert_eq!( + SshConfigParser::tokenize_line( + " HostName 192.168.*.* 172.26.*.* " + )?, + ( + Field::HostName, + vec![String::from("192.168.*.*"), String::from("172.26.*.*")] + ) + ); + Ok(()) + } + + #[test] + fn should_not_tokenize_line() { + crate::test_log(); + assert!(matches!( + SshConfigParser::tokenize_line("Omar yes").unwrap_err(), + SshParserError::UnknownField(..) + )); + } + + #[test] + fn should_fail_parsing_field() { + crate::test_log(); + + assert!(matches!( + SshConfigParser::tokenize_line(" ").unwrap_err(), + SshParserError::MissingArgument + )); + } + + #[test] + fn should_parse_boolean() -> Result<(), SshParserError> { + crate::test_log(); + assert_eq!( + SshConfigParser::parse_boolean(vec![String::from("yes")])?, + true + ); + assert_eq!( + SshConfigParser::parse_boolean(vec![String::from("no")])?, + false + ); + Ok(()) + } + + #[test] + fn should_fail_parsing_boolean() { + crate::test_log(); + assert!(matches!( + SshConfigParser::parse_boolean(vec!["boh".to_string()]).unwrap_err(), + SshParserError::ExpectedBoolean + )); + assert!(matches!( + SshConfigParser::parse_boolean(vec![]).unwrap_err(), + SshParserError::MissingArgument + )); + } + + #[test] + fn should_parse_algos() -> Result<(), SshParserError> { + crate::test_log(); + assert_eq!( + SshConfigParser::parse_algos(vec![String::from("a,b,c,d")])?, + AlgorithmsRule::Set(vec![ + "a".to_string(), + "b".to_string(), + "c".to_string(), + "d".to_string(), + ]) + ); + + assert_eq!( + SshConfigParser::parse_algos(vec![String::from("a")])?, + AlgorithmsRule::Set(vec!["a".to_string()]) + ); + + assert_eq!( + SshConfigParser::parse_algos(vec![String::from("+a,b")])?, + AlgorithmsRule::Append(vec!["a".to_string(), "b".to_string()]) + ); + + Ok(()) + } + + #[test] + fn should_parse_comma_separated_list() -> Result<(), SshParserError> { + crate::test_log(); + assert_eq!( + SshConfigParser::parse_comma_separated_list(vec![String::from("a,b,c,d")])?, + vec![ + "a".to_string(), + "b".to_string(), + "c".to_string(), + "d".to_string(), + ] + ); + assert_eq!( + SshConfigParser::parse_comma_separated_list(vec![String::from("a")])?, + vec!["a".to_string()] + ); + Ok(()) + } + + #[test] + fn should_fail_parsing_comma_separated_list() { + crate::test_log(); + assert!(matches!( + SshConfigParser::parse_comma_separated_list(vec![]).unwrap_err(), + SshParserError::MissingArgument + )); + } + + #[test] + fn should_parse_duration() -> Result<(), SshParserError> { + crate::test_log(); + assert_eq!( + SshConfigParser::parse_duration(vec![String::from("60")])?, + Duration::from_secs(60) + ); + Ok(()) + } + + #[test] + fn should_fail_parsing_duration() { + crate::test_log(); + assert!(matches!( + SshConfigParser::parse_duration(vec![String::from("AAA")]).unwrap_err(), + SshParserError::ExpectedUnsigned + )); + assert!(matches!( + SshConfigParser::parse_duration(vec![]).unwrap_err(), + SshParserError::MissingArgument + )); + } + + #[test] + fn should_parse_host() -> Result<(), SshParserError> { + crate::test_log(); + assert_eq!( + SshConfigParser::parse_host(vec![ + String::from("192.168.*.*"), + String::from("!192.168.1.1"), + String::from("172.26.104.*"), + String::from("!172.26.104.10"), + ])?, + vec![ + HostClause::new(String::from("192.168.*.*"), false), + HostClause::new(String::from("192.168.1.1"), true), + HostClause::new(String::from("172.26.104.*"), false), + HostClause::new(String::from("172.26.104.10"), true), + ] + ); + Ok(()) + } + + #[test] + fn should_fail_parsing_host() { + crate::test_log(); + assert!(matches!( + SshConfigParser::parse_host(vec![]).unwrap_err(), + SshParserError::MissingArgument + )); + } + + #[test] + fn should_parse_path() -> Result<(), SshParserError> { + crate::test_log(); + assert_eq!( + SshConfigParser::parse_path(vec![String::from("/tmp/a.txt")])?, + PathBuf::from("/tmp/a.txt") + ); + Ok(()) + } + + #[test] + fn should_parse_path_and_resolve_tilde() -> Result<(), SshParserError> { + crate::test_log(); + let mut expected = dirs::home_dir().unwrap(); + expected.push(".ssh/id_dsa"); + assert_eq!( + SshConfigParser::parse_path(vec![String::from("~/.ssh/id_dsa")])?, + expected + ); + Ok(()) + } + + #[test] + fn should_parse_path_list() -> Result<(), SshParserError> { + crate::test_log(); + assert_eq!( + SshConfigParser::parse_path_list(vec![ + String::from("/tmp/a.txt"), + String::from("/tmp/b.txt") + ])?, + vec![PathBuf::from("/tmp/a.txt"), PathBuf::from("/tmp/b.txt")] + ); + Ok(()) + } + + #[test] + fn should_fail_parse_path_list() { + crate::test_log(); + assert!(matches!( + SshConfigParser::parse_path_list(vec![]).unwrap_err(), + SshParserError::MissingArgument + )); + } + + #[test] + fn should_fail_parsing_path() { + crate::test_log(); + assert!(matches!( + SshConfigParser::parse_path(vec![]).unwrap_err(), + SshParserError::MissingArgument + )); + } + + #[test] + fn should_parse_port() -> Result<(), SshParserError> { + crate::test_log(); + assert_eq!(SshConfigParser::parse_port(vec![String::from("22")])?, 22); + Ok(()) + } + + #[test] + fn should_fail_parsing_port() { + crate::test_log(); + assert!(matches!( + SshConfigParser::parse_port(vec![String::from("1234567")]).unwrap_err(), + SshParserError::ExpectedPort + )); + assert!(matches!( + SshConfigParser::parse_port(vec![]).unwrap_err(), + SshParserError::MissingArgument + )); + } + + #[test] + fn should_parse_string() -> Result<(), SshParserError> { + crate::test_log(); + assert_eq!( + SshConfigParser::parse_string(vec![String::from("foobar")])?, + String::from("foobar") + ); + Ok(()) + } + + #[test] + fn should_fail_parsing_string() { + crate::test_log(); + assert!(matches!( + SshConfigParser::parse_string(vec![]).unwrap_err(), + SshParserError::MissingArgument + )); + } + + #[test] + fn should_parse_unsigned() -> Result<(), SshParserError> { + crate::test_log(); + assert_eq!( + SshConfigParser::parse_unsigned(vec![String::from("43")])?, + 43 + ); + Ok(()) + } + + #[test] + fn should_fail_parsing_unsigned() { + crate::test_log(); + assert!(matches!( + SshConfigParser::parse_unsigned(vec![String::from("abc")]).unwrap_err(), + SshParserError::ExpectedUnsigned + )); + assert!(matches!( + SshConfigParser::parse_unsigned(vec![]).unwrap_err(), + SshParserError::MissingArgument + )); + } + + #[test] + fn should_strip_comments() { + crate::test_log(); + + assert_eq!( + SshConfigParser::strip_comments("host my_host # this is my fav host").as_str(), + "host my_host " + ); + assert_eq!( + SshConfigParser::strip_comments("# this is a comment").as_str(), + "" + ); + } + + #[test] + fn test_should_parse_config_with_quotes_and_eq() { + crate::test_log(); + + let config = create_ssh_config_with_quotes_and_eq(); + let file = File::open(config.path()).expect("Failed to open tempfile"); + let mut reader = BufReader::new(file); + + let config = SshConfig::default() + .default_algorithms(DefaultAlgorithms::empty()) + .parse(&mut reader, ParseRule::STRICT) + .expect("Failed to parse config"); + + let params = config.query("foo"); + + // connect timeout is 15 + assert_eq!( + params.connect_timeout.expect("unspec connect timeout"), + Duration::from_secs(15) + ); + assert_eq!( + params + .ignore_unknown + .as_deref() + .expect("unspec ignore unknown"), + &["Pippo", "Pluto"] + ); + assert_eq!( + params + .ciphers + .algorithms() + .iter() + .map(|x| x.as_str()) + .collect::>(), + &["Pepperoni Pizza", "Margherita Pizza", "Hawaiian Pizza"] + ); + assert_eq!( + params + .mac + .algorithms() + .iter() + .map(|x| x.as_str()) + .collect::>(), + &["Pasta Carbonara", "Pasta con tonno"] + ); + } + + fn create_ssh_config_with_quotes_and_eq() -> NamedTempFile { + let mut tmpfile: tempfile::NamedTempFile = + tempfile::NamedTempFile::new().expect("Failed to create tempfile"); + let config = r##" +# ssh config +# written by veeso + + +# I put a comment here just to annoy + +IgnoreUnknown=Pippo,Pluto +ConnectTimeout = 15 +Ciphers "Pepperoni Pizza,Margherita Pizza,Hawaiian Pizza" +Macs="Pasta Carbonara,Pasta con tonno" +"##; + tmpfile.write_all(config.as_bytes()).unwrap(); + tmpfile + } + + fn create_ssh_config() -> NamedTempFile { + let mut tmpfile: tempfile::NamedTempFile = + tempfile::NamedTempFile::new().expect("Failed to create tempfile"); + let config = r##" +# ssh config +# written by veeso + + + # I put a comment here just to annoy + +IgnoreUnknown Pippo,Pluto + +Compression yes +ConnectionAttempts 10 +ConnectTimeout 60 +ServerAliveInterval 40 +TcpKeepAlive yes +Ciphers +a-manella,blowfish + +# Let's start defining some hosts + +Host 192.168.*.* 172.26.*.* !192.168.1.30 + User omar + # Forward agent is actually not supported; I just want to see that it wont' fail parsing + ForwardAgent yes + BindAddress 10.8.0.10 + BindInterface tun0 + Ciphers +coi-piedi,cazdecan,triestin-stretto + IdentityFile /home/root/.ssh/pippo.key /home/root/.ssh/pluto.key + Macs spyro,deoxys + Port 2222 + PubkeyAcceptedAlgorithms -omar-crypt + +Host tostapane + User ciro-esposito + HostName 192.168.24.32 + RemoteForward 88 + Compression no + Pippo yes + Pluto 56 + Macs +spyro,deoxys + +Host 192.168.1.30 + User nutellaro + RemoteForward 123 + +Host * + CaSignatureAlgorithms random + HostKeyAlgorithms luigi,mario + KexAlgorithms desu,gigi + Macs concorde + PubkeyAcceptedAlgorithms desu,omar-crypt,fast-omar-crypt +"##; + tmpfile.write_all(config.as_bytes()).unwrap(); + tmpfile + } + + fn create_inverted_ssh_config() -> NamedTempFile { + let mut tmpfile: tempfile::NamedTempFile = + tempfile::NamedTempFile::new().expect("Failed to create tempfile"); + let config = r##" +Host *-host + IdentityFile ~/.ssh/id_rsa_good + +Host remote-* + HostName hostname.com + User user + IdentityFile ~/.ssh/id_rsa_bad + +Host * + ConnectTimeout 15 + IdentityFile ~/.ssh/id_rsa_ugly + "##; + tmpfile.write_all(config.as_bytes()).unwrap(); + tmpfile + } + + fn create_ssh_config_with_comments() -> NamedTempFile { + let mut tmpfile: tempfile::NamedTempFile = + tempfile::NamedTempFile::new().expect("Failed to create tempfile"); + let config = r##" +Host cross-platform # this is my fav host + HostName hostname.com + User user + IdentityFile ~/.ssh/id_rsa_good + +Host * + AddKeysToAgent yes + IdentityFile ~/.ssh/id_rsa_bad + "##; + tmpfile.write_all(config.as_bytes()).unwrap(); + tmpfile + } + + fn create_ssh_config_with_unknown_fields() -> NamedTempFile { + let mut tmpfile: tempfile::NamedTempFile = + tempfile::NamedTempFile::new().expect("Failed to create tempfile"); + let config = r##" +Host cross-platform # this is my fav host + HostName hostname.com + User user + IdentityFile ~/.ssh/id_rsa_good + Piropero yes + +Host * + AddKeysToAgent yes + IdentityFile ~/.ssh/id_rsa_bad + "##; + tmpfile.write_all(config.as_bytes()).unwrap(); + tmpfile + } + + #[test] + fn test_should_parse_config_with_include() { + crate::test_log(); + + let config = create_include_config(); + let file = File::open(config.config.path()).expect("Failed to open tempfile"); + let mut reader = BufReader::new(file); + + let config = SshConfig::default() + .default_algorithms(DefaultAlgorithms::empty()) + .parse(&mut reader, ParseRule::STRICT) + .expect("Failed to parse config"); + + // verify include 1 overwrites the default value + let glob_params = config.query("192.168.1.1"); + assert_eq!( + glob_params.connect_timeout.unwrap(), + Duration::from_secs(60) + ); + assert_eq!( + glob_params.server_alive_interval.unwrap(), + Duration::from_secs(40) // first read + ); + assert_eq!(glob_params.tcp_keep_alive.unwrap(), true); + assert_eq!(glob_params.ciphers.algorithms().is_empty(), true); + + // verify tostapane + let tostapane_params = config.query("tostapane"); + assert_eq!( + tostapane_params.connect_timeout.unwrap(), + Duration::from_secs(60) // first read + ); + assert_eq!( + tostapane_params.server_alive_interval.unwrap(), + Duration::from_secs(40) // first read + ); + assert_eq!(tostapane_params.tcp_keep_alive.unwrap(), true); + // verify ciphers + assert_eq!( + tostapane_params.ciphers.algorithms(), + &[ + "a-manella", + "blowfish", + "coi-piedi", + "cazdecan", + "triestin-stretto" + ] + ); + } + + #[allow(dead_code)] + struct ConfigWithInclude { + config: NamedTempFile, + inc1: NamedTempFile, + inc2: NamedTempFile, + } + + fn create_include_config() -> ConfigWithInclude { + let mut config_file: tempfile::NamedTempFile = + tempfile::NamedTempFile::new().expect("Failed to create tempfile"); + let mut inc1_file: tempfile::NamedTempFile = + tempfile::NamedTempFile::new().expect("Failed to create tempfile"); + let mut inc2_file: tempfile::NamedTempFile = + tempfile::NamedTempFile::new().expect("Failed to create tempfile"); + + let config = format!( + r##" +# ssh config +# written by veeso + + + # I put a comment here just to annoy + +IgnoreUnknown Pippo,Pluto + +Compression yes +ConnectionAttempts 10 +ConnectTimeout 60 +ServerAliveInterval 40 +Include {inc1} + +# Let's start defining some hosts + +Host tostapane + User ciro-esposito + HostName 192.168.24.32 + RemoteForward 88 + Compression no + Pippo yes + Pluto 56 + Include {inc2} +"##, + inc1 = inc1_file.path().display(), + inc2 = inc2_file.path().display() + ); + config_file.write_all(config.as_bytes()).unwrap(); + + // write include 1 + let inc1 = r##" + ConnectTimeout 60 + ServerAliveInterval 60 + TcpKeepAlive yes + "##; + inc1_file.write_all(inc1.as_bytes()).unwrap(); + + // write include 2 + let inc2 = r##" + ConnectTimeout 180 + ServerAliveInterval 180 + Ciphers +a-manella,blowfish,coi-piedi,cazdecan,triestin-stretto + "##; + inc2_file.write_all(inc2.as_bytes()).unwrap(); + + ConfigWithInclude { + config: config_file, + inc1: inc1_file, + inc2: inc2_file, + } + } +} diff --git a/src/parser/field.rs b/src/parser/field.rs new file mode 100644 index 0000000..a427f3f --- /dev/null +++ b/src/parser/field.rs @@ -0,0 +1,673 @@ +//! # field +//! +//! Ssh config fields + +use std::fmt; +use std::str::FromStr; + +/// Configuration field. +/// This enum defines ALL THE SUPPORTED fields in ssh config, +/// as described at . +/// Only a few of them are implemented, as described in `HostParams` struct. +#[derive(Debug, Eq, PartialEq)] +pub enum Field { + Host, + BindAddress, + BindInterface, + CaSignatureAlgorithms, + CertificateFile, + Ciphers, + Compression, + ConnectionAttempts, + ConnectTimeout, + HostKeyAlgorithms, + HostName, + IdentityFile, + IgnoreUnknown, + KexAlgorithms, + Mac, + Port, + PubkeyAcceptedAlgorithms, + PubkeyAuthentication, + RemoteForward, + ServerAliveInterval, + TcpKeepAlive, + #[cfg(target_os = "macos")] + UseKeychain, + User, + // -- not implemented + AddKeysToAgent, + AddressFamily, + BatchMode, + CanonicalDomains, + CanonicalizeFallbackLock, + CanonicalizeHostname, + CanonicalizeMaxDots, + CanonicalizePermittedCNAMEs, + CheckHostIP, + ClearAllForwardings, + ControlMaster, + ControlPath, + ControlPersist, + DynamicForward, + EnableSSHKeysign, + EscapeChar, + ExitOnForwardFailure, + FingerprintHash, + ForkAfterAuthentication, + ForwardAgent, + ForwardX11, + ForwardX11Timeout, + ForwardX11Trusted, + GatewayPorts, + GlobalKnownHostsFile, + GSSAPIAuthentication, + GSSAPIDelegateCredentials, + HashKnownHosts, + HostbasedAcceptedAlgorithms, + HostbasedAuthentication, + HostbasedKeyTypes, + HostKeyAlias, + IdentitiesOnly, + IdentityAgent, + Include, + IPQoS, + KbdInteractiveAuthentication, + KbdInteractiveDevices, + KnownHostsCommand, + LocalCommand, + LocalForward, + LogLevel, + LogVerbose, + NoHostAuthenticationForLocalhost, + NumberOfPasswordPrompts, + PasswordAuthentication, + PermitLocalCommand, + PermitRemoteOpen, + PKCS11Provider, + PreferredAuthentications, + ProxyCommand, + ProxyJump, + ProxyUseFdpass, + PubkeyAcceptedKeyTypes, + RekeyLimit, + RequestTTY, + RevokedHostKeys, + SecruityKeyProvider, + SendEnv, + ServerAliveCountMax, + SessionType, + SetEnv, + StdinNull, + StreamLocalBindMask, + StrictHostKeyChecking, + SyslogFacility, + UpdateHostKeys, + UserKnownHostsFile, + VerifyHostKeyDNS, + VisualHostKey, + XAuthLocation, +} + +impl FromStr for Field { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "host" => Ok(Self::Host), + "bindaddress" => Ok(Self::BindAddress), + "bindinterface" => Ok(Self::BindInterface), + "casignaturealgorithms" => Ok(Self::CaSignatureAlgorithms), + "certificatefile" => Ok(Self::CertificateFile), + "ciphers" => Ok(Self::Ciphers), + "compression" => Ok(Self::Compression), + "connectionattempts" => Ok(Self::ConnectionAttempts), + "connecttimeout" => Ok(Self::ConnectTimeout), + "hostkeyalgorithms" => Ok(Self::HostKeyAlgorithms), + "hostname" => Ok(Self::HostName), + "identityfile" => Ok(Self::IdentityFile), + "ignoreunknown" => Ok(Self::IgnoreUnknown), + "kexalgorithms" => Ok(Self::KexAlgorithms), + "macs" => Ok(Self::Mac), + "port" => Ok(Self::Port), + "pubkeyacceptedalgorithms" => Ok(Self::PubkeyAcceptedAlgorithms), + "pubkeyauthentication" => Ok(Self::PubkeyAuthentication), + "remoteforward" => Ok(Self::RemoteForward), + "serveraliveinterval" => Ok(Self::ServerAliveInterval), + "tcpkeepalive" => Ok(Self::TcpKeepAlive), + #[cfg(target_os = "macos")] + "usekeychain" => Ok(Self::UseKeychain), + "user" => Ok(Self::User), + // -- not implemented fields + "addkeystoagent" => Ok(Self::AddKeysToAgent), + "addressfamily" => Ok(Self::AddressFamily), + "batchmode" => Ok(Self::BatchMode), + "canonicaldomains" => Ok(Self::CanonicalDomains), + "canonicalizefallbacklock" => Ok(Self::CanonicalizeFallbackLock), + "canonicalizehostname" => Ok(Self::CanonicalizeHostname), + "canonicalizemaxdots" => Ok(Self::CanonicalizeMaxDots), + "canonicalizepermittedcnames" => Ok(Self::CanonicalizePermittedCNAMEs), + "checkhostip" => Ok(Self::CheckHostIP), + "clearallforwardings" => Ok(Self::ClearAllForwardings), + "controlmaster" => Ok(Self::ControlMaster), + "controlpath" => Ok(Self::ControlPath), + "controlpersist" => Ok(Self::ControlPersist), + "dynamicforward" => Ok(Self::DynamicForward), + "enablesshkeysign" => Ok(Self::EnableSSHKeysign), + "escapechar" => Ok(Self::EscapeChar), + "exitonforwardfailure" => Ok(Self::ExitOnForwardFailure), + "fingerprinthash" => Ok(Self::FingerprintHash), + "forkafterauthentication" => Ok(Self::ForkAfterAuthentication), + "forwardagent" => Ok(Self::ForwardAgent), + "forwardx11" => Ok(Self::ForwardX11), + "forwardx11timeout" => Ok(Self::ForwardX11Timeout), + "forwardx11trusted" => Ok(Self::ForwardX11Trusted), + "gatewayports" => Ok(Self::GatewayPorts), + "globalknownhostsfile" => Ok(Self::GlobalKnownHostsFile), + "gssapiauthentication" => Ok(Self::GSSAPIAuthentication), + "gssapidelegatecredentials" => Ok(Self::GSSAPIDelegateCredentials), + "hashknownhosts" => Ok(Self::HashKnownHosts), + "hostbasedacceptedalgorithms" => Ok(Self::HostbasedAcceptedAlgorithms), + "hostbasedauthentication" => Ok(Self::HostbasedAuthentication), + "hostkeyalias" => Ok(Self::HostKeyAlias), + "hostbasedkeytypes" => Ok(Self::HostbasedKeyTypes), + "identitiesonly" => Ok(Self::IdentitiesOnly), + "identityagent" => Ok(Self::IdentityAgent), + "include" => Ok(Self::Include), + "ipqos" => Ok(Self::IPQoS), + "kbdinteractiveauthentication" => Ok(Self::KbdInteractiveAuthentication), + "kbdinteractivedevices" => Ok(Self::KbdInteractiveDevices), + "knownhostscommand" => Ok(Self::KnownHostsCommand), + "localcommand" => Ok(Self::LocalCommand), + "localforward" => Ok(Self::LocalForward), + "loglevel" => Ok(Self::LogLevel), + "logverbose" => Ok(Self::LogVerbose), + "nohostauthenticationforlocalhost" => Ok(Self::NoHostAuthenticationForLocalhost), + "numberofpasswordprompts" => Ok(Self::NumberOfPasswordPrompts), + "passwordauthentication" => Ok(Self::PasswordAuthentication), + "permitlocalcommand" => Ok(Self::PermitLocalCommand), + "permitremoteopen" => Ok(Self::PermitRemoteOpen), + "pkcs11provider" => Ok(Self::PKCS11Provider), + "preferredauthentications" => Ok(Self::PreferredAuthentications), + "proxycommand" => Ok(Self::ProxyCommand), + "proxyjump" => Ok(Self::ProxyJump), + "proxyusefdpass" => Ok(Self::ProxyUseFdpass), + "pubkeyacceptedkeytypes" => Ok(Self::PubkeyAcceptedKeyTypes), + "rekeylimit" => Ok(Self::RekeyLimit), + "requesttty" => Ok(Self::RequestTTY), + "revokedhostkeys" => Ok(Self::RevokedHostKeys), + "secruitykeyprovider" => Ok(Self::SecruityKeyProvider), + "sendenv" => Ok(Self::SendEnv), + "serveralivecountmax" => Ok(Self::ServerAliveCountMax), + "sessiontype" => Ok(Self::SessionType), + "setenv" => Ok(Self::SetEnv), + "stdinnull" => Ok(Self::StdinNull), + "streamlocalbindmask" => Ok(Self::StreamLocalBindMask), + "stricthostkeychecking" => Ok(Self::StrictHostKeyChecking), + "syslogfacility" => Ok(Self::SyslogFacility), + "updatehostkeys" => Ok(Self::UpdateHostKeys), + "userknownhostsfile" => Ok(Self::UserKnownHostsFile), + "verifyhostkeydns" => Ok(Self::VerifyHostKeyDNS), + "visualhostkey" => Ok(Self::VisualHostKey), + "xauthlocation" => Ok(Self::XAuthLocation), + // -- unknwon field + _ => Err(s.to_string()), + } + } +} + +impl fmt::Display for Field { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + Self::Host => "host", + Self::BindAddress => "bindaddress", + Self::BindInterface => "bindinterface", + Self::CaSignatureAlgorithms => "casignaturealgorithms", + Self::CertificateFile => "certificatefile", + Self::Ciphers => "ciphers", + Self::Compression => "compression", + Self::ConnectionAttempts => "connectionattempts", + Self::ConnectTimeout => "connecttimeout", + Self::HostKeyAlgorithms => "hostkeyalgorithms", + Self::HostName => "hostname", + Self::IdentityFile => "identityfile", + Self::IgnoreUnknown => "ignoreunknown", + Self::KexAlgorithms => "kexalgorithms", + Self::Mac => "macs", + Self::Port => "port", + Self::PubkeyAcceptedAlgorithms => "pubkeyacceptedalgorithms", + Self::PubkeyAuthentication => "pubkeyauthentication", + Self::RemoteForward => "remoteforward", + Self::ServerAliveInterval => "serveraliveinterval", + Self::TcpKeepAlive => "tcpkeepalive", + #[cfg(target_os = "macos")] + Self::UseKeychain => "usekeychain", + Self::User => "user", + // Continuation of the rest of the enum variants + Self::AddKeysToAgent => "addkeystoagent", + Self::AddressFamily => "addressfamily", + Self::BatchMode => "batchmode", + Self::CanonicalDomains => "canonicaldomains", + Self::CanonicalizeFallbackLock => "canonicalizefallbacklock", + Self::CanonicalizeHostname => "canonicalizehostname", + Self::CanonicalizeMaxDots => "canonicalizemaxdots", + Self::CanonicalizePermittedCNAMEs => "canonicalizepermittedcnames", + Self::CheckHostIP => "checkhostip", + Self::ClearAllForwardings => "clearallforwardings", + Self::ControlMaster => "controlmaster", + Self::ControlPath => "controlpath", + Self::ControlPersist => "controlpersist", + Self::DynamicForward => "dynamicforward", + Self::EnableSSHKeysign => "enablesshkeysign", + Self::EscapeChar => "escapechar", + Self::ExitOnForwardFailure => "exitonforwardfailure", + Self::FingerprintHash => "fingerprinthash", + Self::ForkAfterAuthentication => "forkafterauthentication", + Self::ForwardAgent => "forwardagent", + Self::ForwardX11 => "forwardx11", + Self::ForwardX11Timeout => "forwardx11timeout", + Self::ForwardX11Trusted => "forwardx11trusted", + Self::GatewayPorts => "gatewayports", + Self::GlobalKnownHostsFile => "globalknownhostsfile", + Self::GSSAPIAuthentication => "gssapiauthentication", + Self::GSSAPIDelegateCredentials => "gssapidelegatecredentials", + Self::HashKnownHosts => "hashknownhosts", + Self::HostbasedAcceptedAlgorithms => "hostbasedacceptedalgorithms", + Self::HostbasedAuthentication => "hostbasedauthentication", + Self::HostKeyAlias => "hostkeyalias", + Self::HostbasedKeyTypes => "hostbasedkeytypes", + Self::IdentitiesOnly => "identitiesonly", + Self::IdentityAgent => "identityagent", + Self::Include => "include", + Self::IPQoS => "ipqos", + Self::KbdInteractiveAuthentication => "kbdinteractiveauthentication", + Self::KbdInteractiveDevices => "kbdinteractivedevices", + Self::KnownHostsCommand => "knownhostscommand", + Self::LocalCommand => "localcommand", + Self::LocalForward => "localforward", + Self::LogLevel => "loglevel", + Self::LogVerbose => "logverbose", + Self::NoHostAuthenticationForLocalhost => "nohostauthenticationforlocalhost", + Self::NumberOfPasswordPrompts => "numberofpasswordprompts", + Self::PasswordAuthentication => "passwordauthentication", + Self::PermitLocalCommand => "permitlocalcommand", + Self::PermitRemoteOpen => "permitremoteopen", + Self::PKCS11Provider => "pkcs11provider", + Self::PreferredAuthentications => "preferredauthentications", + Self::ProxyCommand => "proxycommand", + Self::ProxyJump => "proxyjump", + Self::ProxyUseFdpass => "proxyusefdpass", + Self::PubkeyAcceptedKeyTypes => "pubkeyacceptedkeytypes", + Self::RekeyLimit => "rekeylimit", + Self::RequestTTY => "requesttty", + Self::RevokedHostKeys => "revokedhostkeys", + Self::SecruityKeyProvider => "secruitykeyprovider", + Self::SendEnv => "sendenv", + Self::ServerAliveCountMax => "serveralivecountmax", + Self::SessionType => "sessiontype", + Self::SetEnv => "setenv", + Self::StdinNull => "stdinnull", + Self::StreamLocalBindMask => "streamlocalbindmask", + Self::StrictHostKeyChecking => "stricthostkeychecking", + Self::SyslogFacility => "syslogfacility", + Self::UpdateHostKeys => "updatehostkeys", + Self::UserKnownHostsFile => "userknownhostsfile", + Self::VerifyHostKeyDNS => "verifyhostkeydns", + Self::VisualHostKey => "visualhostkey", + Self::XAuthLocation => "xauthlocation", + }; + write!(f, "{}", s) + } +} + +#[cfg(test)] +mod test { + + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn should_parse_field_from_string() { + assert_eq!(Field::from_str("Host").ok().unwrap(), Field::Host); + assert_eq!( + Field::from_str("BindAddress").ok().unwrap(), + Field::BindAddress + ); + assert_eq!( + Field::from_str("BindInterface").ok().unwrap(), + Field::BindInterface + ); + assert_eq!( + Field::from_str("CaSignatureAlgorithms").ok().unwrap(), + Field::CaSignatureAlgorithms + ); + assert_eq!( + Field::from_str("CertificateFile").ok().unwrap(), + Field::CertificateFile + ); + assert_eq!(Field::from_str("Ciphers").ok().unwrap(), Field::Ciphers); + assert_eq!( + Field::from_str("Compression").ok().unwrap(), + Field::Compression + ); + assert_eq!( + Field::from_str("ConnectionAttempts").ok().unwrap(), + Field::ConnectionAttempts + ); + assert_eq!( + Field::from_str("ConnectTimeout").ok().unwrap(), + Field::ConnectTimeout + ); + assert_eq!(Field::from_str("HostName").ok().unwrap(), Field::HostName); + assert_eq!( + Field::from_str("IdentityFile").ok().unwrap(), + Field::IdentityFile + ); + assert_eq!( + Field::from_str("IgnoreUnknown").ok().unwrap(), + Field::IgnoreUnknown + ); + assert_eq!(Field::from_str("Macs").ok().unwrap(), Field::Mac); + assert_eq!( + Field::from_str("PubkeyAcceptedAlgorithms").ok().unwrap(), + Field::PubkeyAcceptedAlgorithms + ); + assert_eq!( + Field::from_str("PubkeyAuthentication").ok().unwrap(), + Field::PubkeyAuthentication + ); + assert_eq!( + Field::from_str("RemoteForward").ok().unwrap(), + Field::RemoteForward + ); + assert_eq!( + Field::from_str("TcpKeepAlive").ok().unwrap(), + Field::TcpKeepAlive + ); + #[cfg(target_os = "macos")] + assert_eq!( + Field::from_str("UseKeychain").ok().unwrap(), + Field::UseKeychain + ); + assert_eq!(Field::from_str("User").ok().unwrap(), Field::User); + assert_eq!( + Field::from_str("AddKeysToAgent").ok().unwrap(), + Field::AddKeysToAgent + ); + assert_eq!( + Field::from_str("AddressFamily").ok().unwrap(), + Field::AddressFamily + ); + assert_eq!(Field::from_str("BatchMode").ok().unwrap(), Field::BatchMode); + assert_eq!( + Field::from_str("CanonicalDomains").ok().unwrap(), + Field::CanonicalDomains + ); + assert_eq!( + Field::from_str("CanonicalizeFallbackLock").ok().unwrap(), + Field::CanonicalizeFallbackLock + ); + assert_eq!( + Field::from_str("CanonicalizeHostname").ok().unwrap(), + Field::CanonicalizeHostname + ); + assert_eq!( + Field::from_str("CanonicalizeMaxDots").ok().unwrap(), + Field::CanonicalizeMaxDots + ); + assert_eq!( + Field::from_str("CanonicalizePermittedCNAMEs").ok().unwrap(), + Field::CanonicalizePermittedCNAMEs + ); + assert_eq!( + Field::from_str("CheckHostIP").ok().unwrap(), + Field::CheckHostIP + ); + assert_eq!( + Field::from_str("ClearAllForwardings").ok().unwrap(), + Field::ClearAllForwardings + ); + assert_eq!( + Field::from_str("ControlMaster").ok().unwrap(), + Field::ControlMaster + ); + assert_eq!( + Field::from_str("ControlPath").ok().unwrap(), + Field::ControlPath + ); + assert_eq!( + Field::from_str("ControlPersist").ok().unwrap(), + Field::ControlPersist + ); + assert_eq!( + Field::from_str("DynamicForward").ok().unwrap(), + Field::DynamicForward + ); + assert_eq!( + Field::from_str("EnableSSHKeysign").ok().unwrap(), + Field::EnableSSHKeysign + ); + assert_eq!( + Field::from_str("EscapeChar").ok().unwrap(), + Field::EscapeChar + ); + assert_eq!( + Field::from_str("ExitOnForwardFailure").ok().unwrap(), + Field::ExitOnForwardFailure + ); + assert_eq!( + Field::from_str("FingerprintHash").ok().unwrap(), + Field::FingerprintHash + ); + assert_eq!( + Field::from_str("ForkAfterAuthentication").ok().unwrap(), + Field::ForkAfterAuthentication + ); + assert_eq!( + Field::from_str("ForwardAgent").ok().unwrap(), + Field::ForwardAgent + ); + assert_eq!( + Field::from_str("ForwardX11").ok().unwrap(), + Field::ForwardX11 + ); + assert_eq!( + Field::from_str("ForwardX11Timeout").ok().unwrap(), + Field::ForwardX11Timeout + ); + assert_eq!( + Field::from_str("ForwardX11Trusted").ok().unwrap(), + Field::ForwardX11Trusted, + ); + assert_eq!( + Field::from_str("GatewayPorts").ok().unwrap(), + Field::GatewayPorts + ); + assert_eq!( + Field::from_str("GlobalKnownHostsFile").ok().unwrap(), + Field::GlobalKnownHostsFile + ); + assert_eq!( + Field::from_str("GSSAPIAuthentication").ok().unwrap(), + Field::GSSAPIAuthentication + ); + assert_eq!( + Field::from_str("GSSAPIDelegateCredentials").ok().unwrap(), + Field::GSSAPIDelegateCredentials + ); + assert_eq!( + Field::from_str("HashKnownHosts").ok().unwrap(), + Field::HashKnownHosts + ); + assert_eq!( + Field::from_str("HostbasedAcceptedAlgorithms").ok().unwrap(), + Field::HostbasedAcceptedAlgorithms + ); + assert_eq!( + Field::from_str("HostbasedAuthentication").ok().unwrap(), + Field::HostbasedAuthentication + ); + assert_eq!( + Field::from_str("HostKeyAlgorithms").ok().unwrap(), + Field::HostKeyAlgorithms + ); + assert_eq!( + Field::from_str("HostKeyAlias").ok().unwrap(), + Field::HostKeyAlias + ); + assert_eq!( + Field::from_str("HostbasedKeyTypes").ok().unwrap(), + Field::HostbasedKeyTypes + ); + assert_eq!( + Field::from_str("IdentitiesOnly").ok().unwrap(), + Field::IdentitiesOnly + ); + assert_eq!( + Field::from_str("IdentityAgent").ok().unwrap(), + Field::IdentityAgent + ); + assert_eq!(Field::from_str("Include").ok().unwrap(), Field::Include); + assert_eq!(Field::from_str("IPQoS").ok().unwrap(), Field::IPQoS); + assert_eq!( + Field::from_str("KbdInteractiveAuthentication") + .ok() + .unwrap(), + Field::KbdInteractiveAuthentication + ); + assert_eq!( + Field::from_str("KbdInteractiveDevices").ok().unwrap(), + Field::KbdInteractiveDevices + ); + assert_eq!( + Field::from_str("KnownHostsCommand").ok().unwrap(), + Field::KnownHostsCommand + ); + assert_eq!( + Field::from_str("LocalCommand").ok().unwrap(), + Field::LocalCommand + ); + assert_eq!( + Field::from_str("LocalForward").ok().unwrap(), + Field::LocalForward + ); + assert_eq!(Field::from_str("LogLevel").ok().unwrap(), Field::LogLevel); + assert_eq!( + Field::from_str("LogVerbose").ok().unwrap(), + Field::LogVerbose + ); + assert_eq!( + Field::from_str("NoHostAuthenticationForLocalhost") + .ok() + .unwrap(), + Field::NoHostAuthenticationForLocalhost + ); + assert_eq!( + Field::from_str("NumberOfPasswordPrompts").ok().unwrap(), + Field::NumberOfPasswordPrompts + ); + assert_eq!( + Field::from_str("PasswordAuthentication").ok().unwrap(), + Field::PasswordAuthentication + ); + assert_eq!( + Field::from_str("PermitLocalCommand").ok().unwrap(), + Field::PermitLocalCommand + ); + assert_eq!( + Field::from_str("PermitRemoteOpen").ok().unwrap(), + Field::PermitRemoteOpen + ); + assert_eq!( + Field::from_str("PKCS11Provider").ok().unwrap(), + Field::PKCS11Provider + ); + assert_eq!(Field::from_str("Port").ok().unwrap(), Field::Port); + assert_eq!( + Field::from_str("PreferredAuthentications").ok().unwrap(), + Field::PreferredAuthentications + ); + assert_eq!( + Field::from_str("ProxyCommand").ok().unwrap(), + Field::ProxyCommand + ); + assert_eq!(Field::from_str("ProxyJump").ok().unwrap(), Field::ProxyJump); + assert_eq!( + Field::from_str("ProxyUseFdpass").ok().unwrap(), + Field::ProxyUseFdpass + ); + assert_eq!( + Field::from_str("PubkeyAcceptedKeyTypes").ok().unwrap(), + Field::PubkeyAcceptedKeyTypes + ); + assert_eq!( + Field::from_str("RekeyLimit").ok().unwrap(), + Field::RekeyLimit + ); + assert_eq!( + Field::from_str("RequestTTY").ok().unwrap(), + Field::RequestTTY + ); + assert_eq!( + Field::from_str("RevokedHostKeys").ok().unwrap(), + Field::RevokedHostKeys + ); + assert_eq!( + Field::from_str("SecruityKeyProvider").ok().unwrap(), + Field::SecruityKeyProvider + ); + assert_eq!(Field::from_str("SendEnv").ok().unwrap(), Field::SendEnv); + assert_eq!( + Field::from_str("ServerAliveCountMax").ok().unwrap(), + Field::ServerAliveCountMax + ); + assert_eq!( + Field::from_str("ServerAliveInterval").ok().unwrap(), + Field::ServerAliveInterval + ); + assert_eq!( + Field::from_str("SessionType").ok().unwrap(), + Field::SessionType + ); + assert_eq!(Field::from_str("SetEnv").ok().unwrap(), Field::SetEnv); + assert_eq!(Field::from_str("StdinNull").ok().unwrap(), Field::StdinNull); + assert_eq!( + Field::from_str("StreamLocalBindMask").ok().unwrap(), + Field::StreamLocalBindMask + ); + assert_eq!( + Field::from_str("StrictHostKeyChecking").ok().unwrap(), + Field::StrictHostKeyChecking + ); + assert_eq!( + Field::from_str("SyslogFacility").ok().unwrap(), + Field::SyslogFacility + ); + assert_eq!( + Field::from_str("UpdateHostKeys").ok().unwrap(), + Field::UpdateHostKeys + ); + assert_eq!( + Field::from_str("UserKnownHostsFile").ok().unwrap(), + Field::UserKnownHostsFile + ); + assert_eq!( + Field::from_str("VerifyHostKeyDNS").ok().unwrap(), + Field::VerifyHostKeyDNS + ); + assert_eq!( + Field::from_str("VisualHostKey").ok().unwrap(), + Field::VisualHostKey + ); + assert_eq!( + Field::from_str("XAuthLocation").ok().unwrap(), + Field::XAuthLocation + ); + } + + #[test] + fn should_fail_parsing_field() { + assert!(Field::from_str("CristinaDavena").is_err()); + } +} diff --git a/src/serializer.rs b/src/serializer.rs new file mode 100644 index 0000000..fd01c58 --- /dev/null +++ b/src/serializer.rs @@ -0,0 +1,211 @@ +//! SSH Config serializer + +use std::fmt; + +use crate::{Host, HostParams, SshConfig}; + +pub struct SshConfigSerializer<'a>(&'a SshConfig); + +impl SshConfigSerializer<'_> { + pub fn serialize(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.0.hosts.is_empty() { + return Ok(()); + } + + // serialize default host + let root = self.0.hosts.first().unwrap(); + Self::serialize_host_params(f, &root.params, false)?; + + // serialize other hosts + for host in self.0.hosts.iter().skip(1) { + Self::serialize_host(f, host)?; + } + + Ok(()) + } + + fn serialize_host(f: &mut fmt::Formatter<'_>, host: &Host) -> fmt::Result { + for pattern in &host.pattern { + writeln!(f, "Host {pattern}",)?; + + Self::serialize_host_params(f, &host.params, true)?; + writeln!(f,)?; + } + + Ok(()) + } + + fn serialize_host_params( + f: &mut fmt::Formatter<'_>, + params: &HostParams, + nested: bool, + ) -> fmt::Result { + let padding = if nested { " " } else { "" }; + + if let Some(value) = params.bind_address.as_ref() { + writeln!(f, "{padding}Hostname {value}",)?; + } + if let Some(value) = params.bind_interface.as_ref() { + writeln!(f, "{padding}BindAddress {value}",)?; + } + if !params.ca_signature_algorithms.is_default() { + writeln!( + f, + "{padding}CASignatureAlgorithms {ca_signature_algorithms}", + padding = padding, + ca_signature_algorithms = params.ca_signature_algorithms + )?; + } + if let Some(certificate_file) = params.certificate_file.as_ref() { + writeln!(f, "{padding}CertificateFile {}", certificate_file.display())?; + } + if !params.ciphers.is_default() { + writeln!( + f, + "{padding}Ciphers {ciphers}", + padding = padding, + ciphers = params.ciphers + )?; + } + if let Some(value) = params.compression.as_ref() { + writeln!( + f, + "{padding}Compression {}", + if *value { "yes" } else { "no" } + )?; + } + if let Some(connection_attempts) = params.connection_attempts { + writeln!(f, "{padding}ConnectionAttempts {connection_attempts}",)?; + } + if let Some(connect_timeout) = params.connect_timeout { + writeln!(f, "{padding}ConnectTimeout {}", connect_timeout.as_secs())?; + } + if !params.host_key_algorithms.is_default() { + writeln!( + f, + "{padding}HostKeyAlgorithms {host_key_algorithms}", + padding = padding, + host_key_algorithms = params.host_key_algorithms + )?; + } + if let Some(host_name) = params.host_name.as_ref() { + writeln!(f, "{padding}HostName {host_name}",)?; + } + if let Some(identity_file) = params.identity_file.as_ref() { + writeln!( + f, + "{padding}IdentityFile {}", + identity_file + .iter() + .map(|p| p.display().to_string()) + .collect::>() + .join(",") + )?; + } + if let Some(ignore_unknown) = params.ignore_unknown.as_ref() { + writeln!( + f, + "{padding}IgnoreUnknown {}", + ignore_unknown + .iter() + .map(|p| p.to_string()) + .collect::>() + .join(",") + )?; + } + if !params.kex_algorithms.is_default() { + writeln!( + f, + "{padding}KexAlgorithms {kex_algorithms}", + padding = padding, + kex_algorithms = params.kex_algorithms + )?; + } + if !params.mac.is_default() { + writeln!( + f, + "{padding}MACs {mac}", + padding = padding, + mac = params.mac + )?; + } + if let Some(port) = params.port { + writeln!(f, "{padding}Port {port}", port = port)?; + } + if !params.pubkey_accepted_algorithms.is_default() { + writeln!( + f, + "{padding}PubkeyAcceptedAlgorithms {pubkey_accepted_algorithms}", + padding = padding, + pubkey_accepted_algorithms = params.pubkey_accepted_algorithms + )?; + } + if let Some(pubkey_authentication) = params.pubkey_authentication.as_ref() { + writeln!( + f, + "{padding}PubkeyAuthentication {}", + if *pubkey_authentication { "yes" } else { "no" } + )?; + } + if let Some(remote_forward) = params.remote_forward.as_ref() { + writeln!(f, "{padding}RemoteForward {remote_forward}",)?; + } + if let Some(server_alive_interval) = params.server_alive_interval { + writeln!( + f, + "{padding}ServerAliveInterval {}", + server_alive_interval.as_secs() + )?; + } + if let Some(tcp_keep_alive) = params.tcp_keep_alive.as_ref() { + writeln!( + f, + "{padding}TCPKeepAlive {}", + if *tcp_keep_alive { "yes" } else { "no" } + )?; + } + #[cfg(target_os = "macos")] + if let Some(use_keychain) = params.use_keychain.as_ref() { + writeln!( + f, + "{padding}UseKeychain {}", + if *use_keychain { "yes" } else { "no" } + )?; + } + if let Some(user) = params.user.as_ref() { + writeln!(f, "{padding}User {user}",)?; + } + for (field, value) in ¶ms.ignored_fields { + writeln!( + f, + "{padding}{field} {value}", + field = field, + value = value + .iter() + .map(|v| v.to_string()) + .collect::>() + .join(" ") + )?; + } + for (field, value) in ¶ms.unsupported_fields { + writeln!( + f, + "{padding}{field} {value}", + field = field, + value = value + .iter() + .map(|v| v.to_string()) + .collect::>() + .join(" ") + )?; + } + + Ok(()) + } +} + +impl<'a> From<&'a SshConfig> for SshConfigSerializer<'a> { + fn from(config: &'a SshConfig) -> Self { + SshConfigSerializer(config) + } +}