Summary
The russh-config parser silently ignores Match directives, which causes two concrete bugs depending on where the Match block appears in the file:
HostNotFound error — when Match appears before any Host block
- Silent semantic corruption — when
Match appears after a Host block, its subordinate directives (e.g. User, IdentityFile) bleed into that preceding Host block
Reproduction
Case 1: HostNotFound
Minimal config:
Match host * exec "gpg-connect-agent UPDATESTARTUPTTY /bye"
Host my-server
HostName 10.0.0.1
User admin
let config = russh_config::parse(
r#"
Match host * exec "gpg-connect-agent UPDATESTARTUPTTY /bye"
Host my-server
HostName 10.0.0.1
User admin
"#,
"my-server",
);
// → Err(Error::HostNotFound)
The error message is misleading: the host exists, but the parser hits the Match line before any Host block, treats it as an orphaned parameter, and then returns HostNotFound when the later Host line is reached.
Case 2: parameter bleed
Host my-server
HostName 10.0.0.1
User alice
Match host *.internal
User bob
IdentityFile ~/.ssh/internal_key
Host other-server
HostName 10.0.0.2
When querying "my-server", User will be set to "bob" and IdentityFile will include ~/.ssh/internal_key, even though Match host *.internal should not apply to my-server.
This happens because the parser ignores the Match line itself, but keeps parsing its subordinate directives into the current mutable config. When the next Host line is reached, the accumulated config is flushed into the preceding Host my-server entry and then reset for other-server.
Root Cause
In parse_ssh_config (russh-config/src/lib.rs, line 200):
match lower.as_str() {
"host" => { /* starts a new HostEntry */ }
"user" => { ... }
"hostname" => { ... }
// ... other recognized keywords ...
key => {
debug!("{key:?}"); // ← "match" lands here, silently dropped
}
}
The parser:
- Does not recognize
Match as a block-starting keyword (like Host)
- Treats
Match as an unknown parameter → sets found_params = true
- Its subsequent lines (e.g.
User bob) are applied to the current mutable config, corrupting state
Impact on downstream projects
Similar Match parsing failures have been reported in downstream SSH-config consumers:
While the same bug is also reported when using other ssh config parsing libraries:
Common workarounds include removing/commenting out Match stanzas or moving them after all relevant Host blocks, but users should not have to restructure valid SSH configs for a parser limitation.
Proposed Solutions
Approach A: Minimal — skip Match blocks gracefully (recommended first step)
Add a "match" arm that treats Match as a block delimiter and prevents directives inside the unsupported Match block from mutating the active HostConfig.
What it does:
- Recognizes
Match ... as a block-starting keyword, like Host
- Flushes any preceding
Host entry before entering Match-skipping mode
- Skips subsequent directives until the next
Host or Match line (or EOF)
- Does not set
found_params = true for the Match line itself
- Does not apply unsupported
Match directives to the previous Host entry
Pros:
- Small, targeted change
- Fixes
HostNotFound for leading Match blocks
- Fixes state corruption from unsupported
Match blocks
Cons:
- Match conditions are not evaluated; directives inside Match blocks are simply dropped
- Users who rely on
Match to apply host-specific overrides won't get them
Approach B: Full Match support (long-term goal)
Implement full Match semantics per ssh_config(5):
- Parse the
Match condition list (supporting all, canonical, final, exec, host, originalhost, user, localuser)
- Store
Match blocks as a new variant in the config AST
- Evaluate conditions at query time, merging matched blocks into the final
HostConfig
Design questions to discuss:
- Should Match evaluation happen at parse time or query time?
- Parse time: simpler but loses context (current user, environment)
- Query time: correct for
exec, user, localuser conditions but requires more restructuring
- How should Match blocks interact with
Host block merging order?
- The
exec condition requires running a shell command — what's the right API for that in an async context?
Approach C: Hybrid
Start with Approach A (minimal skip) as an immediate fix, then iterate toward Approach B in a follow-up PR. The minimal fix prevents misleading parse failures and state corruption while leaving full Match semantics for a more deliberate design.
Questions for the Maintainers
- Is there appetite for full Match support (Approach B), or would a minimal graceful-skip (Approach A) be preferred for now?
- If full support: should condition evaluation happen at parse time or query time?
- Are there concerns about the
exec condition requiring process spawning? Should it be gated behind a feature flag?
- Would you prefer a single PR or incremental delivery?
Related
- OpenSSH man page: ssh_config(5) — Match
- All Match conditions are documented under the
Match keyword in the same man page
- The
ssh2-config and ssh2-config-rs crates explicitly list Match patterns as a missing feature, which is useful context if comparing Rust SSH-config parser behavior
Summary
The
russh-configparser silently ignoresMatchdirectives, which causes two concrete bugs depending on where theMatchblock appears in the file:HostNotFounderror — whenMatchappears before anyHostblockMatchappears after aHostblock, its subordinate directives (e.g.User,IdentityFile) bleed into that precedingHostblockReproduction
Case 1: HostNotFound
Minimal config:
The error message is misleading: the host exists, but the parser hits the
Matchline before anyHostblock, treats it as an orphaned parameter, and then returnsHostNotFoundwhen the laterHostline is reached.Case 2: parameter bleed
When querying
"my-server",Userwill be set to"bob"andIdentityFilewill include~/.ssh/internal_key, even thoughMatch host *.internalshould not apply tomy-server.This happens because the parser ignores the
Matchline itself, but keeps parsing its subordinate directives into the current mutableconfig. When the nextHostline is reached, the accumulated config is flushed into the precedingHost my-serverentry and then reset forother-server.Root Cause
In
parse_ssh_config(russh-config/src/lib.rs, line 200):The parser:
Matchas a block-starting keyword (likeHost)Matchas an unknown parameter → setsfound_params = trueUser bob) are applied to the current mutableconfig, corrupting stateImpact on downstream projects
Similar
Matchparsing failures have been reported in downstream SSH-config consumers:While the same bug is also reported when using other ssh config parsing libraries:
Common workarounds include removing/commenting out
Matchstanzas or moving them after all relevantHostblocks, but users should not have to restructure valid SSH configs for a parser limitation.Proposed Solutions
Approach A: Minimal — skip Match blocks gracefully (recommended first step)
Add a
"match"arm that treatsMatchas a block delimiter and prevents directives inside the unsupportedMatchblock from mutating the activeHostConfig.What it does:
Match ...as a block-starting keyword, likeHostHostentry before entering Match-skipping modeHostorMatchline (or EOF)found_params = truefor theMatchline itselfMatchdirectives to the previousHostentryPros:
HostNotFoundfor leadingMatchblocksMatchblocksCons:
Matchto apply host-specific overrides won't get themApproach B: Full Match support (long-term goal)
Implement full
Matchsemantics per ssh_config(5):Matchcondition list (supportingall,canonical,final,exec,host,originalhost,user,localuser)Matchblocks as a new variant in the config ASTHostConfigDesign questions to discuss:
exec,user,localuserconditions but requires more restructuringHostblock merging order?execcondition requires running a shell command — what's the right API for that in an async context?Approach C: Hybrid
Start with Approach A (minimal skip) as an immediate fix, then iterate toward Approach B in a follow-up PR. The minimal fix prevents misleading parse failures and state corruption while leaving full
Matchsemantics for a more deliberate design.Questions for the Maintainers
execcondition requiring process spawning? Should it be gated behind a feature flag?Related
Matchkeyword in the same man pagessh2-configandssh2-config-rscrates explicitly listMatchpatterns as a missing feature, which is useful context if comparing Rust SSH-config parser behavior