///|
/// The cardinality and merge rule used for a supported OpenSSH directive.
pub(all) enum MergeKind {
FirstSet
Append
SetEnv
} derive(Debug, Eq)
///|
/// The validation class for a directive value. The resolver deliberately
/// keeps this small: values that it cannot validate are never executed.
pub(all) enum ValueKind {
Text
Boolean
Port
NonNegativeInt
Duration
StrictHostKeyChecking
AddressFamily
LogLevel
AddKeysToAgent
} derive(Debug, Eq)
///|
pub(all) struct DirectiveSpec {
keyword : String
merge : MergeKind
value_kind : ValueKind
allowed_tokens : Array[String]
sensitive : Bool
} derive(Debug, Eq)
///|
fn lower_ascii(value : String) -> String {
let output = StringBuilder()
for char in value {
output.write_char(char.to_ascii_lowercase())
}
output.to_string()
}
///|
/// Canonical OpenSSH directive spelling used by all resolver maps and lookup
/// APIs. OpenSSH directive names are ASCII case-insensitive.
pub fn canonical_keyword(value : String) -> String {
lower_ascii(value)
}
///|
fn spec(
keyword : String,
merge : MergeKind,
value_kind : ValueKind,
allowed_tokens? : Array[String] = [],
sensitive? : Bool = false,
) -> DirectiveSpec {
{ keyword, merge, value_kind, allowed_tokens, sensitive }
}
///|
/// Return the P0 specification for `keyword`. Unknown directives are kept in
/// the syntax tree and appear as an `Unsupported` trace event instead of being
/// silently treated as a scalar setting.
pub fn directive_spec(keyword : String) -> DirectiveSpec? {
match canonical_keyword(keyword) {
// OpenSSH accepts only %% and %h in HostName. `%%` is handled by the
// common expander, so it is intentionally not listed here.
"hostname" => Some(spec("hostname", FirstSet, Text, allowed_tokens=["h"]))
// Unlike path/command directives, User does not accept OpenSSH tokens.
"user" => Some(spec("user", FirstSet, Text))
"port" => Some(spec("port", FirstSet, Port))
"identitiesonly" => Some(spec("identitiesonly", FirstSet, Boolean))
"proxyjump" =>
Some(
spec("proxyjump", FirstSet, Text, allowed_tokens=["h", "n", "p", "r"]),
)
"proxycommand" =>
Some(
spec(
"proxycommand",
FirstSet,
Text,
allowed_tokens=["h", "n", "p", "r"],
sensitive=true,
),
)
"connecttimeout" => Some(spec("connecttimeout", FirstSet, Duration))
"serveraliveinterval" =>
Some(spec("serveraliveinterval", FirstSet, Duration))
"serveralivecountmax" =>
Some(spec("serveralivecountmax", FirstSet, NonNegativeInt))
"stricthostkeychecking" =>
Some(spec("stricthostkeychecking", FirstSet, StrictHostKeyChecking))
"forwardagent" => Some(spec("forwardagent", FirstSet, Boolean))
"addkeystoagent" => Some(spec("addkeystoagent", FirstSet, AddKeysToAgent))
"compression" => Some(spec("compression", FirstSet, Boolean))
"addressfamily" => Some(spec("addressfamily", FirstSet, AddressFamily))
"loglevel" => Some(spec("loglevel", FirstSet, LogLevel))
"identityfile" =>
Some(
spec("identityfile", Append, Text, allowed_tokens=[
"h", "n", "p", "r", "u", "d",
]),
)
"certificatefile" =>
Some(
spec("certificatefile", Append, Text, allowed_tokens=[
"h", "n", "p", "r", "u", "d",
]),
)
"userknownhostsfile" =>
Some(
spec("userknownhostsfile", FirstSet, Text, allowed_tokens=[
"h", "n", "p", "r", "u", "d",
]),
)
"localforward" =>
Some(
spec("localforward", Append, Text, allowed_tokens=[
"h", "n", "p", "r", "u", "d",
]),
)
"remoteforward" =>
Some(
spec("remoteforward", Append, Text, allowed_tokens=[
"h", "n", "p", "r", "u", "d",
]),
)
"dynamicforward" => Some(spec("dynamicforward", Append, Text))
"sendenv" => Some(spec("sendenv", Append, Text))
"setenv" => Some(spec("setenv", SetEnv, Text, sensitive=true))
_ => None
}
}
///|
fn is_member(value : String, allowed : Array[String]) -> Bool {
for item in allowed {
if value == item {
return true
}
}
false
}
///|
fn validates_enum(value : String, allowed : Array[String]) -> Bool {
is_member(lower_ascii(value), allowed)
}
///|
let maximum_duration_seconds : Int = 2_147_483_647
///|
fn duration_multiplier(code : Int) -> Int? {
match code {
115 => Some(1) // s
109 => Some(60) // m
104 => Some(60 * 60) // h
100 => Some(24 * 60 * 60) // d
119 => Some(7 * 24 * 60 * 60) // w
_ => None
}
}
///|
/// Parse the OpenSSH time format used by ssh_config(5): an unsigned number of
/// seconds, or a sequence such as `1h30m`. Arithmetic is checked before every
/// multiply/add so malformed configuration cannot wrap an Int.
fn parse_duration_seconds(value : String) -> Int? {
if value.is_empty() {
return None
}
let mut index = 0
let mut total = 0
while index < value.length() {
let start = index
let mut number = 0
while index < value.length() {
let code = value[index].to_int()
if code < 48 || code > 57 {
break
}
let digit = code - 48
if number > (maximum_duration_seconds - digit) / 10 {
return None
}
number = number * 10 + digit
index += 1
}
if index == start {
return None
}
let multiplier = if index < value.length() {
match duration_multiplier(value[index].to_int()) {
Some(unit) => {
index += 1
unit
}
None => return None
}
} else {
1
}
if number > maximum_duration_seconds / multiplier {
return None
}
let seconds = number * multiplier
if total > maximum_duration_seconds - seconds {
return None
}
total += seconds
}
Some(total)
}
///|
fn normalize_add_keys_to_agent(value : String) -> Result[String, String] {
let parts = [ for part in value.split(" ") => part.to_owned() ]
if parts.length() == 1 {
let token = lower_ascii(parts[0])
if is_member(token, ["yes", "no", "confirm", "ask"]) {
return Ok(token)
}
match parse_duration_seconds(token) {
Some(seconds) => Ok(seconds.to_string())
None =>
Err(
"expected yes, no, confirm, ask, a duration, or confirm plus a duration",
)
}
} else if parts.length() == 2 && lower_ascii(parts[0]) == "confirm" {
match parse_duration_seconds(parts[1]) {
Some(seconds) => Ok("confirm \{seconds}")
None => Err("expected a valid duration after confirm")
}
} else {
Err(
"expected yes, no, confirm, ask, a duration, or confirm plus a duration",
)
}
}
///|
fn normalize_value(
specification : DirectiveSpec,
value : String,
) -> Result[String, String] {
match specification.value_kind {
Text => Ok(value)
Boolean => {
let token = lower_ascii(value)
match token {
"yes" => Ok("yes")
"no" => Ok("no")
"true" if specification.keyword != "compression" => Ok("yes")
"false" if specification.keyword != "compression" => Ok("no")
_ => Err("expected an OpenSSH boolean value")
}
}
Port => {
let parsed = @strconv.from_str(value) catch { _ => -1 }
if parsed >= 1 && parsed <= 65535 {
Ok(parsed.to_string())
} else {
Err("expected an integer in 1..65535")
}
}
NonNegativeInt => {
let parsed = @strconv.from_str(value) catch { _ => -1 }
if parsed >= 0 {
Ok(parsed.to_string())
} else {
Err("expected a non-negative integer")
}
}
Duration => {
let token = lower_ascii(value)
if token == "none" && specification.keyword == "connecttimeout" {
Ok("none")
} else if token == "none" &&
specification.keyword == "serveraliveinterval" {
Ok("0")
} else {
match parse_duration_seconds(token) {
Some(seconds) => Ok(seconds.to_string())
None => Err("expected a non-negative OpenSSH time value")
}
}
}
StrictHostKeyChecking =>
match lower_ascii(value) {
"yes" | "true" => Ok("true")
"no" | "false" | "off" => Ok("false")
"ask" => Ok("ask")
"accept-new" => Ok("accept-new")
_ => Err("expected yes, ask, no, accept-new, or off")
}
AddressFamily =>
if validates_enum(value, ["any", "inet", "inet6"]) {
Ok(lower_ascii(value))
} else {
Err("expected any, inet, or inet6")
}
LogLevel =>
match lower_ascii(value) {
"quiet" => Ok("SILENT")
"fatal" => Ok("FATAL")
"error" => Ok("ERROR")
"info" => Ok("INFO")
"verbose" => Ok("VERBOSE")
"debug" | "debug1" => Ok("DEBUG")
"debug2" => Ok("DEBUG2")
"debug3" => Ok("DEBUG3")
_ => Err("expected an OpenSSH log level")
}
AddKeysToAgent => normalize_add_keys_to_agent(value)
}
}
///|
/// Validate a directive's joined textual representation.
pub fn validate_value(specification : DirectiveSpec, value : String) -> String? {
match normalize_value(specification, value) {
Ok(_) => None
Err(message) => Some(message)
}
}
///|
/// Normalize a value that has already passed `validate_value`. Duration-bearing
/// directives are rendered as seconds to match `ssh -G`; all other classes
/// preserve their parsed spelling.
fn normalized_value(specification : DirectiveSpec, value : String) -> String {
match normalize_value(specification, value) {
Ok(normalized) => normalized
Err(_) => value
}
}
///|
fn valid_port_text(value : String) -> Bool {
let parsed : Int = @strconv.from_str(value) catch { _ => -1 }
parsed >= 1 && parsed <= 65535
}
///|
fn valid_remote_port_text(value : String) -> Bool {
let parsed : Int = @strconv.from_str(value) catch { _ => -1 }
parsed >= 0 && parsed <= 65535
}
///|
fn valid_listen_endpoint(value : String) -> Bool {
if value.contains("%") {
return true
}
if valid_port_text(value) {
return true
}
match value.rev_find(":") {
Some(index) if index > 0 && index + 1 < value.length() =>
valid_port_text(value[index + 1:].to_owned())
_ => false
}
}
///|
fn valid_remote_listen_endpoint(value : String) -> Bool {
if value.contains("%") {
return true
}
if valid_remote_port_text(value) {
return true
}
match value.rev_find(":") {
Some(index) if index > 0 && index + 1 < value.length() =>
valid_remote_port_text(value[index + 1:].to_owned())
_ => false
}
}
///|
fn valid_socket_path(value : String) -> Bool {
value.contains("/") && value != "/"
}
///|
fn valid_forward_target(value : String) -> Bool {
if value.contains("%") || valid_socket_path(value) {
return true
}
match value.rev_find(":") {
Some(index) if index > 0 && index + 1 < value.length() =>
valid_port_text(value[index + 1:].to_owned())
_ => false
}
}
///|
fn setenv_argument_error(arguments : Array[String]) -> String? {
for argument in arguments {
match argument.find("=") {
Some(index) if index > 0 => ()
_ => return Some("SetEnv requires NAME=value with a non-empty name")
}
}
None
}
///|
fn forward_argument_error(
keyword : String,
arguments : Array[String],
) -> String? {
match keyword {
"localforward" =>
if arguments.length() != 2 {
Some("LocalForward requires a listen endpoint and a target")
} else if !valid_listen_endpoint(arguments[0]) &&
!valid_socket_path(arguments[0]) {
Some("invalid LocalForward listen endpoint")
} else if !valid_forward_target(arguments[1]) {
Some("invalid LocalForward target")
} else {
None
}
"remoteforward" =>
if arguments.length() < 1 || arguments.length() > 2 {
Some("RemoteForward requires a listen endpoint and optional target")
} else if !valid_remote_listen_endpoint(arguments[0]) &&
!(arguments.length() == 2 && valid_socket_path(arguments[0])) {
Some("invalid RemoteForward listen endpoint")
} else if arguments.length() == 2 && !valid_forward_target(arguments[1]) {
Some("invalid RemoteForward target")
} else {
None
}
"dynamicforward" =>
if arguments.length() != 1 {
Some("DynamicForward requires exactly one listen endpoint")
} else if !valid_listen_endpoint(arguments[0]) {
Some("invalid DynamicForward listen endpoint")
} else {
None
}
_ => None
}
}
///|
/// Validate argument cardinality and directive-specific grammar independently
/// of Host/Match selection. This is shared by the resolver and static lint so
/// invalid values cannot hide in an unselected block.
pub fn validate_directive_arguments(
keyword : String,
arguments : Array[String],
) -> String? {
let normalized = canonical_keyword(keyword)
guard directive_spec(normalized) is Some(specification) else { return None }
if arguments.is_empty() {
return Some("directive requires at least one argument")
}
match normalized {
"proxycommand" | "sendenv" | "userknownhostsfile" => ()
"setenv" => return setenv_argument_error(arguments)
"localforward" | "remoteforward" | "dynamicforward" =>
match forward_argument_error(normalized, arguments) {
Some(message) => return Some(message)
None => ()
}
"addkeystoagent" =>
if arguments.length() > 2 {
return Some("AddKeysToAgent accepts at most two arguments")
}
_ =>
if arguments.length() != 1 {
return Some("directive accepts exactly one argument")
}
}
validate_value(specification, arguments.join(" "))
}
///|
fn normalized_directive_value(
specification : DirectiveSpec,
arguments : Array[String],
) -> String {
normalized_value(specification, arguments.join(" "))
}