///|
/// Mutable counters and provenance collected within one `load` operation.
priv struct LoadState {
mut file_count : Int
mut total_bytes : Int
sources : Array[SourceFile]
diagnostics : Array[LoadDiagnostic]
active_identities : Array[String]
active_paths : Array[String]
}
///|
priv enum ItemTemplate {
Global
Host(@syntax.HostBlock)
Match(@syntax.MatchBlock)
}
///|
fn failure_chain(
state : LoadState,
attempted_path : String,
append_attempted : Bool,
) -> Array[String] {
let chain = state.active_paths.copy()
if append_attempted {
chain.push(attempted_path)
}
chain
}
///|
fn detailed_error(
kind : LoadFailureKind,
attempted_path : String,
include_location : @syntax.SourceLocation?,
state : LoadState,
message : String,
append_attempted? : Bool = false,
failure_location? : @syntax.SourceLocation,
) -> LoadError {
Detailed(failure={
kind,
attempted_path,
location: failure_location,
include_location,
chain: failure_chain(state, attempted_path, append_attempted),
message,
})
}
///|
fn validate_options(
options : LoadOptions,
path : String,
state : LoadState,
) -> Unit raise LoadError {
let valid = options.max_depth >= 0 &&
options.max_files > 0 &&
options.max_file_bytes > 0 &&
options.max_total_bytes > 0 &&
options.max_glob_matches > 0 &&
options.max_include_arguments > 0
if !valid {
raise detailed_error(
InvalidOptions,
path,
None,
state,
"all resource limits must be positive (max_depth may be zero)",
append_attempted=true,
)
}
}
///|
fn filesystem_failure(
kind : LoadFailureKind,
path : String,
included_from : @syntax.SourceLocation?,
state : LoadState,
error : FileSystemError,
append_attempted? : Bool = false,
) -> LoadError {
detailed_error(
kind,
path,
included_from,
state,
filesystem_error_message(error),
append_attempted~,
)
}
///|
fn canonical_identity(
fs : &FileSystem,
path : String,
included_from : @syntax.SourceLocation?,
state : LoadState,
) -> String raise LoadError {
match fs.canonical_identity(path) {
Ok(identity) => identity
Err(error) =>
raise filesystem_failure(
ReadFailed,
path,
included_from,
state,
error,
append_attempted=true,
)
}
}
///|
fn read_text(
fs : &FileSystem,
path : String,
included_from : @syntax.SourceLocation?,
state : LoadState,
limit : Int,
) -> String raise LoadError {
match fs.read_text(path, max_bytes=limit) {
Ok(text) => text
Err(ReadByteLimitExceeded(..)) =>
raise detailed_error(
FileTooLarge(limit~),
path,
included_from,
state,
"file exceeds byte limit \{limit}",
append_attempted=true,
)
Err(error) =>
raise filesystem_failure(
ReadFailed,
path,
included_from,
state,
error,
append_attempted=true,
)
}
}
///|
fn glob(
fs : &FileSystem,
pattern : String,
location : @syntax.SourceLocation,
state : LoadState,
limit : Int,
) -> Array[String] raise LoadError {
match fs.glob(pattern, max_matches=limit) {
Ok(paths) => paths
Err(GlobMatchLimitExceeded(..)) =>
raise detailed_error(
GlobLimitExceeded(limit~),
pattern,
Some(location),
state,
"glob exceeds match limit \{limit}",
)
Err(error) =>
raise filesystem_failure(
GlobFailed,
pattern,
Some(location),
state,
error,
)
}
}
///|
fn is_active(state : LoadState, identity : String) -> Bool {
for active in state.active_identities {
if active == identity {
return true
}
}
false
}
///|
fn syntax_error_location(error : @syntax.ParseError) -> @syntax.SourceLocation {
match error {
MissingArgument(location~, ..) => location
UnterminatedQuote(location~) => location
DanglingEscape(location~) => location
UnexpectedNul(location~) => location
LineTooLong(location~, ..) => location
SourceTooLarge(location~, ..) => location
InvalidBlockHeader(location~, ..) => location
InvalidDirective(location~, ..) => location
}
}
///|
fn syntax_error_message(error : @syntax.ParseError) -> String {
match error {
MissingArgument(..) => "directive requires at least one argument"
UnterminatedQuote(..) => "unterminated quoted argument"
DanglingEscape(..) => "escape has no following character"
UnexpectedNul(..) => "NUL is not valid in SSH configuration"
LineTooLong(..) => "configuration line exceeds parser limit"
SourceTooLarge(..) => "configuration source exceeds parser limit"
InvalidBlockHeader(..) => "invalid section header"
InvalidDirective(message~, ..) => message
}
}
///|
fn parse_config(
text : String,
display_path : String,
included_from : @syntax.SourceLocation?,
state : LoadState,
) -> @syntax.Config raise LoadError {
@syntax.parse(text, path=display_path) catch {
error => {
let location = syntax_error_location(error)
raise detailed_error(
ParseFailed,
display_path,
included_from,
state,
syntax_error_message(error),
failure_location=location,
)
}
}
}
///|
fn item_from(
template : ItemTemplate,
directives : Array[@syntax.Directive],
) -> @syntax.ConfigItem {
match template {
Global => Global(directives)
Host(block) =>
Host({
patterns: block.patterns,
directives,
location: block.location,
span: block.span,
})
Match(block) =>
Match({
conditions: block.conditions,
directives,
location: block.location,
span: block.span,
})
}
}
///|
/// Convert a Host pattern-list to one Match token. The resolver's Match parser
/// accepts a comma-separated pattern-list and ANDs repeated predicates.
fn host_guard(patterns : Array[String]) -> Array[String] {
["originalhost", patterns.join(",")]
}
///|
fn conditions_are_all(conditions : Array[String]) -> Bool {
if conditions.length() != 1 {
return false
}
let normalized = StringBuilder()
for char in conditions[0] {
normalized.write_char(char.to_ascii_lowercase())
}
normalized.to_string() == "all"
}
///|
fn template_guard(template : ItemTemplate) -> Array[String] {
match template {
Global => []
Host(block) => host_guard(block.patterns)
Match(block) =>
if conditions_are_all(block.conditions) {
[]
} else {
block.conditions.copy()
}
}
}
///|
/// Preserve the condition that made an Include directive active when an
/// included file introduces its own Host or Match block.
///
/// OpenSSH skips an Include in an inactive block. A flat syntax tree therefore
/// must retain both predicates. Repeated Match predicates are logical AND, so a
/// synthetic Match block represents the outer guard without changing the
/// included directives or their source location.
fn guard_included_item(
template : ItemTemplate,
item : @syntax.ConfigItem,
) -> @syntax.ConfigItem {
match template {
Global => item
Host(_) | Match(_) => {
let outer = template_guard(template)
match item {
Global(_) => item
Host(block) => {
let conditions = outer
for token in host_guard(block.patterns) {
conditions.push(token)
}
Match({
conditions,
directives: block.directives,
location: block.location,
span: block.span,
})
}
Match(block) => {
let conditions = outer
if !conditions_are_all(block.conditions) {
for token in block.conditions {
conditions.push(token)
}
}
if conditions.is_empty() {
Global(block.directives)
} else {
Match({
conditions,
directives: block.directives,
location: block.location,
span: block.span,
})
}
}
}
}
}
}
///|
fn blocks_from(items : Array[@syntax.ConfigItem]) -> Array[@syntax.HostBlock] {
let global_directives : Array[@syntax.Directive] = []
let global_location : @syntax.SourceLocation = {
path: "",
line: 1,
column: 1,
}
let global_span : @syntax.SourceSpan = {
start: global_location,
end_: global_location,
}
let blocks : Array[@syntax.HostBlock] = [
{
patterns: ["*"],
directives: global_directives,
location: global_location,
span: global_span,
},
]
for item in items {
match item {
Global(directives) =>
for directive in directives {
global_directives.push(directive)
}
Host(block) => blocks.push(block)
Match(_) => ()
}
}
blocks
}
///|
fn config_from_items(items : Array[@syntax.ConfigItem]) -> @syntax.Config {
{ items, blocks: blocks_from(items) }
}
///|
fn no_matches(
resolved_path : String,
directive : @syntax.Directive,
options : LoadOptions,
state : LoadState,
) -> Unit raise LoadError {
match options.missing_include {
Ignore => ()
Warn =>
state.diagnostics.push(
MissingInclude(
path=resolved_path,
included_from=Some(directive.location),
),
)
Error =>
raise detailed_error(
InvalidInclude,
resolved_path,
Some(directive.location),
state,
"Include matched no files: \{resolved_path}",
)
}
}
///|
fn resolve_config_include_path(
parent_path : String,
include_path : String,
home : String?,
relative_base : String?,
) -> Result[String, String] {
if path_has_home_prefix(include_path) || path_is_absolute(include_path) {
resolve_include_path(parent_path, include_path, home)
} else {
match relative_base {
Some(base) => Ok(join_path(base, include_path))
None => resolve_include_path(parent_path, include_path, home)
}
}
}
///|
fn expand_include(
fs : &FileSystem,
parent_path : String,
directive : @syntax.Directive,
depth : Int,
options : LoadOptions,
state : LoadState,
) -> Array[@syntax.ConfigItem] raise LoadError {
if directive.arguments.length() > options.max_include_arguments {
raise detailed_error(
IncludeArgumentLimitExceeded(limit=options.max_include_arguments),
parent_path,
Some(directive.location),
state,
"Include argument count exceeds limit \{options.max_include_arguments}",
)
}
let expanded : Array[@syntax.ConfigItem] = []
for include_argument in directive.arguments {
let resolved_path = match
resolve_config_include_path(
parent_path,
include_argument,
fs.home_dir(),
options.relative_include_base,
) {
Ok(path) => path
Err(message) =>
raise detailed_error(
InvalidInclude,
include_argument,
Some(directive.location),
state,
message,
)
}
let matches = glob(
fs,
resolved_path,
directive.location,
state,
options.max_glob_matches,
)
matches.sort()
if matches.length() > options.max_glob_matches {
raise detailed_error(
GlobLimitExceeded(limit=options.max_glob_matches),
resolved_path,
Some(directive.location),
state,
"glob exceeds match limit \{options.max_glob_matches}",
)
}
if matches.is_empty() {
no_matches(resolved_path, directive, options, state)
continue
}
for included_path in matches {
let included = load_file(
fs,
included_path,
Some(directive.location),
depth + 1,
options,
state,
)
for item in included.items {
expanded.push(item)
}
}
}
expanded
}
///|
fn expand_directives(
fs : &FileSystem,
parent_path : String,
template : ItemTemplate,
directives : Array[@syntax.Directive],
depth : Int,
options : LoadOptions,
state : LoadState,
) -> Array[@syntax.ConfigItem] raise LoadError {
let result : Array[@syntax.ConfigItem] = []
let current : Array[@syntax.Directive] = []
for directive in directives {
if directive.keyword == "include" {
let included = expand_include(
fs, parent_path, directive, depth, options, state,
)
for item in included {
match item {
Global(included_directives) =>
for included_directive in included_directives {
current.push(included_directive)
}
Host(_) | Match(_) => {
if !current.is_empty() {
result.push(item_from(template, current.copy()))
current.clear()
}
result.push(guard_included_item(template, item))
}
}
}
} else {
current.push(directive)
}
}
if !current.is_empty() || result.is_empty() {
result.push(item_from(template, current))
}
result
}
///|
fn expand_config(
fs : &FileSystem,
config : @syntax.Config,
display_path : String,
depth : Int,
options : LoadOptions,
state : LoadState,
) -> @syntax.Config raise LoadError {
let items : Array[@syntax.ConfigItem] = []
for item in config.items {
let (template, directives) = match item {
Global(directives) => (Global, directives)
Host(block) => (Host(block), block.directives)
Match(block) => (Match(block), block.directives)
}
let expanded = expand_directives(
fs, display_path, template, directives, depth, options, state,
)
for expanded_item in expanded {
items.push(expanded_item)
}
}
let normalized : Array[@syntax.ConfigItem] = []
if !config.items.is_empty() {
match config.items[0] {
Global(_) =>
if items.is_empty() {
normalized.push(Global([]))
} else {
match items[0] {
Global(_) => ()
Host(_) | Match(_) => normalized.push(Global([]))
}
}
Host(_) | Match(_) => ()
}
}
for item in items {
normalized.push(item)
}
config_from_items(normalized)
}
///|
fn load_file(
fs : &FileSystem,
display_path : String,
included_from : @syntax.SourceLocation?,
depth : Int,
options : LoadOptions,
state : LoadState,
) -> @syntax.Config raise LoadError {
if depth > options.max_depth {
raise detailed_error(
IncludeDepthExceeded(limit=options.max_depth),
display_path,
included_from,
state,
"Include depth exceeds limit \{options.max_depth}",
append_attempted=true,
)
}
if state.file_count >= options.max_files {
raise detailed_error(
FileLimitExceeded(limit=options.max_files),
display_path,
included_from,
state,
"loaded file count exceeds limit \{options.max_files}",
append_attempted=true,
)
}
let identity = canonical_identity(fs, display_path, included_from, state)
if is_active(state, identity) {
raise detailed_error(
IncludeCycle,
display_path,
included_from,
state,
"Include cycle detected",
append_attempted=true,
)
}
let text = read_text(
fs,
display_path,
included_from,
state,
options.max_file_bytes,
)
let byte_count = @utf8.encode(text).length()
if byte_count > options.max_file_bytes {
raise detailed_error(
FileTooLarge(limit=options.max_file_bytes),
display_path,
included_from,
state,
"file exceeds byte limit \{options.max_file_bytes}",
append_attempted=true,
)
}
if state.total_bytes > options.max_total_bytes - byte_count {
raise detailed_error(
TotalBytesExceeded(limit=options.max_total_bytes),
display_path,
included_from,
state,
"total loaded bytes exceed limit \{options.max_total_bytes}",
append_attempted=true,
)
}
state.file_count += 1
state.total_bytes += byte_count
state.active_identities.push(identity)
state.active_paths.push(display_path)
state.sources.push({ identity, display_path, included_from })
let config = parse_config(text, display_path, included_from, state)
let expanded = expand_config(fs, config, display_path, depth, options, state)
ignore(state.active_identities.pop())
ignore(state.active_paths.pop())
expanded
}
///|
/// Read, parse, and recursively expand an OpenSSH configuration root.
///
/// By default Include patterns are resolved relative to their including file.
/// Set `LoadOptions::relative_include_base` for a configuration domain such as
/// OpenSSH's user `~/.ssh` base. `~/` is expanded only through
/// `FileSystem::home_dir`, and every glob result is sorted before recursive
/// loading. The same file may be included twice from separate branches; only
/// the active recursion chain is considered a cycle.
pub fn load(
fs : &FileSystem,
path : String,
options? : LoadOptions = LoadOptions::default(),
) -> LoadedConfig raise LoadError {
let state : LoadState = {
file_count: 0,
total_bytes: 0,
sources: [],
diagnostics: [],
active_identities: [],
active_paths: [],
}
validate_options(options, path, state)
let config = load_file(fs, path, None, 0, options, state)
{ config, sources: state.sources, diagnostics: state.diagnostics }
}