///|
/// The context required to evaluate OpenSSH `Match` conditions without a
/// dependency on the resolver's own context type.
pub(all) struct MatchContext {
host : String
original_host : String
remote_user : String?
local_user : String?
tags : Array[String]
} derive(Debug, Eq)
///|
/// Construct a Match context. `original_host` defaults to `host` because the
/// common non-canonicalized path preserves the original user input.
pub fn MatchContext::new(
host : String,
original_host? : String = host,
remote_user? : String,
local_user? : String,
tags? : Array[String] = [],
) -> MatchContext {
{ host, original_host, remote_user, local_user, tags }
}
///|
/// P0 Match predicates. Unsupported OpenSSH conditions are rejected by the
/// parser instead of being represented here, so this enum is always safe to
/// evaluate without shelling out or canonicalizing a hostname.
pub(all) enum MatchPredicate {
All
Not(MatchPredicate)
Host(PatternList)
OriginalHost(PatternList)
User(PatternList)
LocalUser(PatternList)
Tagged(PatternList)
} derive(Debug, Eq)
///|
fn predicate_list(
condition : String,
arguments : Array[String],
) -> PatternList raise PatternError {
if arguments.length() == 0 {
raise MissingMatchArgument(condition~)
}
compile_pattern_list(arguments)
}
///|
/// Parse one normalized Match condition and its pattern-list arguments.
/// `exec`, `canonical`, `final`, and unknown conditions deliberately produce
/// `UnsupportedMatchCondition`: P0 never executes a command or guesses an
/// OpenSSH canonicalization phase.
pub fn parse_match_predicate(
condition : String,
arguments : Array[String],
) -> MatchPredicate raise PatternError {
let normalized = lower_ascii(condition)
let (name, negated) = if normalized.has_prefix("!") {
(normalized[1:].to_owned(), true)
} else {
(normalized, false)
}
let predicate = match name {
"all" => {
if !arguments.is_empty() {
raise InvalidAllCondition
}
All
}
"host" => Host(predicate_list(name, arguments))
"originalhost" => OriginalHost(predicate_list(name, arguments))
"user" => User(predicate_list(name, arguments))
"localuser" => LocalUser(predicate_list(name, arguments))
"tagged" => Tagged(predicate_list(name, arguments))
_ => raise UnsupportedMatchCondition(name=condition)
}
if negated {
Not(predicate)
} else {
predicate
}
}
///|
fn split_comma_patterns(value : String) -> Array[String] {
let patterns : Array[String] = []
for part in value.split(",") {
patterns.push(part.to_owned())
}
patterns
}
///|
/// Parse interleaved Match tokens such as
/// `["host", "*.corp", "user", "deploy,admin"]`. Every condition other
/// than `all` consumes its following comma-separated pattern-list token.
pub fn parse_match_predicates(
tokens : Array[String],
) -> Array[MatchPredicate] raise PatternError {
let predicates : Array[MatchPredicate] = []
let mut index = 0
while index < tokens.length() {
let condition = tokens[index]
let normalized = lower_ascii(condition)
let name = if normalized.has_prefix("!") {
normalized[1:].to_owned()
} else {
normalized
}
if name == "all" {
if tokens.length() != 1 {
raise InvalidAllCondition
}
predicates.push(parse_match_predicate(condition, []))
index = index + 1
} else if index + 1 >= tokens.length() {
raise MissingMatchArgument(condition~)
} else {
predicates.push(
parse_match_predicate(
condition,
split_comma_patterns(tokens[index + 1]),
),
)
index = index + 2
}
}
predicates
}
///|
fn optional_decision(
patterns : PatternList,
value : String?,
) -> PatternDecision {
match value {
Some(actual) => patterns.decide(actual)
None => { matched: false, positive_index: None, negative_index: None }
}
}
///|
/// Evaluate a Match predicate. `Tagged` succeeds if any tag matches its
/// pattern-list; it does not require every tag to match.
pub fn MatchPredicate::decide(
self : MatchPredicate,
context : MatchContext,
) -> PatternDecision {
match self {
All => { matched: true, positive_index: None, negative_index: None }
Not(predicate) => {
let decision = predicate.decide(context)
{ ..decision, matched: !decision.matched }
}
Host(patterns) => patterns.decide(context.host)
OriginalHost(patterns) => patterns.decide(context.original_host)
User(patterns) => optional_decision(patterns, context.remote_user)
LocalUser(patterns) => optional_decision(patterns, context.local_user)
Tagged(patterns) => {
let mut first_positive : Int? = None
let mut first_negative : Int? = None
for tag in context.tags {
let decision = patterns.decide(tag)
if decision.negative_index is Some(index) && first_negative is None {
first_negative = Some(index)
}
if decision.positive_index is Some(index) && first_positive is None {
first_positive = Some(index)
}
}
{
matched: first_positive is Some(_) && first_negative is None,
positive_index: first_positive,
negative_index: first_negative,
}
}
}
}
///|
/// Evaluate a Match predicate without recording explain information.
pub fn MatchPredicate::matches(
self : MatchPredicate,
context : MatchContext,
) -> Bool {
self.decide(context).matched
}