Skip to content

Commit aaea78d

Browse files
Add literal twins of Bash loop values (#242)
* Add literal twins of Bash loop values Add BashParser.TryProjectLiteralTwins. For each occurrence whose changeable argument words have a proved finite set of values, the projection writes one literal twin for each combination of values. A twin is the full source with each such word written as one literal word. The parser parses it again, so the twin occurrence has the facts of the typed literal: command words, path facts, and expansion facts. An occurrence gets no twins when it is incomplete, when a verb word is not static, when a word has no complete finite value set, when an unquoted value can split or glob, when a redirect depends on a value, or when the combinations pass the limit (32 for one occurrence, 128 in total). An unquoted word uses its authored values only under FreshNonInteractiveNoStartup, and only when each value is nonempty and has no space, tab, newline, glob character, or backslash. The rules use general shell facts only. A twin grants no authority. Parse output does not change. Prepare 0.4.0-beta.22. * Close review round 1 gaps in literal twins Give no twins for text that the lexer does not model as Bash does: - a source with a backslash-newline or a carriage return; - an occurrence with `=~` or `:~` in a word; - an occurrence in a source with `~` or `HOME` when no live launch HOME proves the home directory; - an unquoted value with a parenthesis, which an extglob pattern needs. Count every twin parse, also a failed one, against one budget for a call: 128 parses and 32,768 source characters. An occurrence whose parses do not fit gets no twins and costs no parse. Run the cheap checks for all occurrences first. Add a Linux test that runs each source and each twin in GNU Bash and compares the argv. Document the consumer warnings. Move the release to 0.4.0-beta.23, after the beta.22 lexer fix. * Parse literal twins as single commands and guard list tildes Check `=~` and `:~` in the full source, as the backslash-newline and carriage-return guards do. Bash expands a tilde in a loop list item such as `for f in if=~/x`, and the parser keeps it as text. Parse each twin text as one command, not as the full source. The twin text is the authored text from the first to the last word of the command. The parser parses it in the exact directory of the command, with the launch facts that are live there. A twin parse then costs about its own length. Compute the length of every twin text before any build, and check the budget first: 128 twin parses and 2,048 + 8 x (source length) characters. The projection now costs about one Parse. On an 80 KB source with 8,000 commands it takes about as long as Parse (before: 23 s and 80 GB). A command gets no twins when its directory is not exact, when it has an assignment prefix, or when it has a heredoc. BashLiteralTwin gets a WorkingDirectory property. * Give no twins under an unknown state or an unproved home directory Under BashInitialStateMode.Unknown, the parser can report an exact value for a word that Bash expands: `-o"$n"` gives `-o$n`, but Bash passes `-oa`. The projection now gives no twins in that mode. Parse is not changed here. A cd without an operand goes to $HOME. Without a live launch HOME, the parser takes that directory from HomeDirectory, but Bash uses its real HOME. The projection now parses the source once more with another HomeDirectory. A command whose exact directory changes gets no twins. The check does not read the cd forms, so it also covers `cd --`, `builtin cd`, `command cd`, and `if cd; then`. Add the inputs as tests and to the real-Bash comparison, which now also compares the working directory. Fix the stale parser comment. * Prepare 0.4.0-beta.23 on top of the beta.22 lexer fix Add the 0.4.0-beta.23 release notes above 0.4.0-beta.22, and set the README version line to 0.4.0-beta.23. Move the beta.22 plan section to Completed. Since 0.4.0-beta.22, Parse gives Unknown or the Bash value for a loop list item with `=~` or `:~`, never the text. Update the real-Bash test to that rule. Keep the line-text guards as defense in depth and say so in the code, the SPEC, and the consumer guide. Add a carriage return in single quotes as a guard test, because a carriage return outside quotes is now unparseable.
1 parent 210adf1 commit aaea78d

12 files changed

Lines changed: 2047 additions & 7 deletions

‎Directory.Build.props‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
<Nullable>enable</Nullable>
99
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
1010
<VersionPrefix>0.4.0</VersionPrefix>
11-
<VersionSuffix>beta.22</VersionSuffix>
11+
<VersionSuffix>beta.23</VersionSuffix>
1212
</PropertyGroup>
1313
<PropertyGroup>
1414
<!-- Target framework matrix -->

‎IMPLEMENTATION_PLAN.md‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,25 @@ priorities.
1010
1111
---
1212

13-
## NOW (0.4.0-beta.22 Bash line continuations, security)
13+
## NOW (0.4.0-beta.23 literal twins of loop values)
14+
15+
- [x] Add `BashParser.TryProjectLiteralTwins`. Write each changeable argument word as one proved literal value.
16+
- [x] Give no twins for an incomplete occurrence, a verb word that is not static, a word without a complete finite value set, an unquoted value that can split or glob, a redirect that depends on a value, and combinations over the limit. Kill a hand mutant of each gate with a test.
17+
- [x] Review round 1: guard a backslash-newline, a carriage return, `=~` and `:~`, an unproved home directory, and `extglob` patterns. Count every twin parse against one budget of parses and source characters. Compare twins with GNU Bash argv on Linux.
18+
- [x] Review round 2: check `=~` and `:~` in the full source. Parse each twin as one command in its exact directory, and check the budget before a twin is built.
19+
- [x] Verification round: give no twins under the Unknown initial state, and for a directory that comes from an unproved `HOME`.
20+
- [x] Rebase on 0.4.0-beta.22 (#243). Keep the line-text guards. Run the twin tests, the real-Bash comparison, and the twin probe again.
21+
- [ ] Merge the PR after Linux and Windows CI. Tag `0.4.0-beta.23` and verify NuGet publication.
22+
23+
## Completed (0.4.0-beta.22 Bash line continuations, security)
1424

1525
- [x] Remove a Bash line continuation before the lexer reads the next character of an expansion or an operator (#243). `echo "$\⏎(touch /tmp/x)"` gives the `touch` occurrence. Fail closed on a split `$((` marker.
1626
- [x] Fail closed on a CR in Bash code and on `\` + CR (owner decision). Comments end only at LF.
1727
- [x] Take a Bash `--name=value` value from the decoded word (owner decision).
1828
- [x] Review fixes: `#` after a word part is word text; reserved words, `((`, and `;;` read across a continuation; a `~` after `=` or `:` of an assignment-shaped word expands from HOME.
1929
- [x] Pin each case against real Bash. Prove the general rule: a continuation at any point gives the same facts or fails closed. Run manual mutants and an output comparison against beta.21.
20-
- [ ] Merge the PR after Linux and Windows CI. Tag `0.4.0-beta.22` and verify NuGet publication.
21-
- [ ] Move the literal twins of #242 to 0.4.0-beta.23.
30+
- [x] Merge the PR after Linux and Windows CI. Tag `0.4.0-beta.22`.
31+
- [x] Move the literal twins of #242 to 0.4.0-beta.23.
2232

2333
## Completed (0.4.0-beta.21 restore the beta.19 verb-slot rule)
2434

‎README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,10 @@ Hand-rolled, AOT-trim friendly, zero native dependencies. Multi-targets
1313

1414
```bash
1515
# Current package
16-
dotnet add package ShellSyntaxTree --version 0.4.0-beta.22
16+
dotnet add package ShellSyntaxTree --version 0.4.0-beta.23
1717
```
1818

19-
Version `0.4.0-beta.22` includes the Bash line-continuation, carriage-return, and word-`#` security fixes, exact Bash inline option values and assignment-word tildes, the restored beta.19 command-word verb-slot rule, Bash ANSI-C quote decoding, per-argument pathname-expansion and field-splitting facts, bounded Bash arithmetic expansion, bounded loop `break` and `continue`, unresolved brace words, effective values and binding command words under the fresh-process mode, bounded Bash `export` and `set --`, unknown variable reads under the fresh-process mode, heredocs in command substitutions, Bash background lists, Bash control-flow statements, wider Bash assignment facts, pathname-expansion facts, launcher-proved environment facts, command-word facts, bounded Bash and PowerShell assignment facts,
19+
Version `0.4.0-beta.23` includes literal twins of Bash loop values, the Bash line-continuation, carriage-return, and word-`#` security fixes, exact Bash inline option values and assignment-word tildes, the restored beta.19 command-word verb-slot rule, Bash ANSI-C quote decoding, per-argument pathname-expansion and field-splitting facts, bounded Bash arithmetic expansion, bounded loop `break` and `continue`, unresolved brace words, effective values and binding command words under the fresh-process mode, bounded Bash `export` and `set --`, unknown variable reads under the fresh-process mode, heredocs in command substitutions, Bash background lists, Bash control-flow statements, wider Bash assignment facts, pathname-expansion facts, launcher-proved environment facts, command-word facts, bounded Bash and PowerShell assignment facts,
2020
bounded PowerShell collection facts, shell-neutral filesystem tree-access
2121
effects, and fail-closed approval diagnostics alongside the typed syntax tree,
2222
command-occurrence API, bounded authored-value analysis, audited filesystem and

‎RELEASE_NOTES.md‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,29 @@
1+
#### 0.4.0-beta.23 2026-10-06 ####
2+
3+
This prerelease adds literal twins of Bash commands whose argument words have a proved finite set of values.
4+
5+
## Added
6+
7+
- `BashParser.TryProjectLiteralTwins` and the records `BashLiteralTwinProjection`, `BashLiteralTwinCommand`, `BashLiteralTwin`, and `BashLiteralTwinWord`.
8+
- A twin writes each changeable argument word of one command (an expansion or a tilde word) as one literal value. The parser parses the text of that one command in the exact directory of the command, with the launch facts that are live there. The twin occurrence has the normal facts of the typed literal: command words, path facts, and expansion facts. `BashLiteralTwin.WorkingDirectory` names the directory.
9+
- The projection gives one twin for each combination of values, up to 32 for one command.
10+
- Example: `for n in 8250 8244; do gh api repos/o/r/issues/$n; done` gives the twins `gh api repos/o/r/issues/8250` and `gh api repos/o/r/issues/8244`. `for f in a.txt b.txt; do cat "$f"; done` gives twins with the command words `cat`.
11+
12+
## Security and compatibility
13+
14+
- A command gets no twins when it is incomplete, when its directory is not exact, when it has an assignment prefix, when a verb word is not static, when a word has no complete finite value set, when an unquoted value can split or glob, when a redirect depends on a value, when it has a heredoc, or when its twins do not fit in the budget. The caller keeps its current behavior for such a command.
15+
- An unquoted word uses its authored values only under `FreshNonInteractiveNoStartup`, and only when each value is nonempty and has no space, tab, newline, `*`, `?`, `[`, parenthesis, or backslash. The parenthesis rule covers a Bash build with `extglob` on by default.
16+
- A source with a backslash-newline, a carriage return, `=~`, or `:~` anywhere gets no twins. The lexer does not model this text as Bash does. The `=~` and `:~` rule covers a loop list item such as `for f in if=~/x`, and it also stops `[[ $x =~ re ]]`.
17+
- When the source has `~` or `HOME`, a command gets twins only with a live launch `HOME`. Without it, the home directory is the caller assumption `HomeDirectory`.
18+
- A command whose exact directory comes from `HOME` (after `cd`, `cd --`, `builtin cd`, or `if cd; then`) gets twins only with a live launch `HOME`. The projection finds such a command with one more parse that uses another `HomeDirectory`.
19+
- The projection gives no twins under `BashInitialStateMode.Unknown`. In that mode the parser can report `-o"$n"` as the exact value `-o$n`.
20+
- Budget: one call parses at most 128 twins and at most 2,048 + 8 x (source length) characters of twin text, plus at most one more parse of the source for the `HOME` check. The projection checks the budget before it builds a twin, so its cost stays a small constant factor of one `Parse`. On an 80 KB source with 8,000 commands, the projection takes about as long as `Parse`.
21+
- Values of different words combine independently. The twin set can hold combinations that never run, so a check of every twin is stricter, not weaker.
22+
- A Linux test runs each source and each twin in GNU Bash and compares the argv and the working directory.
23+
- With the 0.4.0-beta.22 lexer, a backslash-newline, a carriage return, and an assignment-shaped `=~` or `:~` give correct `Parse` facts or fail closed. The projection keeps its guards for this text as defense in depth. Twins of a source without such text do not change.
24+
- A twin grants no authority. Existing facts do not change: `Parse` gives the same output as 0.4.0-beta.22.
25+
- Public API: additive.
26+
127
#### 0.4.0-beta.22 2026-10-06 ####
228

329
This prerelease fixes security bugs. A Bash line continuation, a carriage return, or a `#` after a quoted part could hide a command from `Parse` (#243). In the examples, `⏎` is a real newline and CR is a carriage return.

‎SPEC.md‎

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,9 @@ public sealed class BashParser : IShellParser
111111
public bool TryProjectFiniteScopes(
112112
string command,
113113
out BashFiniteScopeProjection? projection);
114+
public bool TryProjectLiteralTwins(
115+
string command,
116+
out BashLiteralTwinProjection? projection);
114117
}
115118

116119
/// <summary>PowerShell implementation of IShellParser (v0.2.0). The
@@ -276,6 +279,12 @@ public enum ShellTreeTraversalMode { ... }
276279
public sealed record BashFiniteScopeProjection { ... }
277280
public sealed record BashScopedCommand { ... }
278281

282+
// v0.4.0-beta.23 literal twin evidence — see §3.
283+
public sealed record BashLiteralTwinProjection { ... }
284+
public sealed record BashLiteralTwinCommand { ... }
285+
public sealed record BashLiteralTwin { ... }
286+
public sealed record BashLiteralTwinWord { ... }
287+
279288
// v0.4.0-beta.7 command-word evidence — see §3.
280289
public abstract record ShellCommandWords { ... }
281290
```
@@ -367,6 +376,37 @@ public sealed record BashScopedCommand
367376
}
368377
```
369378

379+
`TryProjectLiteralTwins` (v0.4.0-beta.23) returns these parser-owned records:
380+
381+
```csharp
382+
public sealed record BashLiteralTwinProjection
383+
{
384+
public ParsedCommand Parsed { get; internal init; }
385+
public IReadOnlyList<BashLiteralTwinCommand> Commands { get; internal init; }
386+
}
387+
388+
public sealed record BashLiteralTwinCommand
389+
{
390+
public CommandOccurrence SourceOccurrence { get; internal init; }
391+
public int SourceOccurrenceIndex { get; internal init; }
392+
public IReadOnlyList<BashLiteralTwin> Twins { get; internal init; }
393+
}
394+
395+
public sealed record BashLiteralTwin
396+
{
397+
public string Source { get; internal init; }
398+
public string WorkingDirectory { get; internal init; }
399+
public CommandOccurrence Occurrence { get; internal init; }
400+
public IReadOnlyList<BashLiteralTwinWord> Words { get; internal init; }
401+
}
402+
403+
public sealed record BashLiteralTwinWord
404+
{
405+
public int ClauseElementIndex { get; internal init; }
406+
public string Value { get; internal init; }
407+
}
408+
```
409+
370410
`WindowsPowerShell51` uses separately oracle-pinned grammar and metadata. Its
371411
command-owned execution-region catalog is limited to Windows PowerShell
372412
`5.1.19041.6456` `ForEach-Object` Begin/Process/RemainingScripts/End and
@@ -1488,6 +1528,137 @@ For example, `cd /work/sub && true; touch marker.txt` from `/work` yields
14881528
files. By contrast, `cd "$target" && touch marker.txt` yields no projection.
14891529
The caller owns filesystem checks, policy, grant matching, and process launch.
14901530

1531+
#### Literal twin projection (v0.4.0-beta.23)
1532+
1533+
`BashParser.TryProjectLiteralTwins` gives a consumer the facts of each literal
1534+
command that a loop or a bounded variable can run. A changeable argument word
1535+
is a word that the shell can change before the program runs: an expansion
1536+
(`$n`, `"$f"`, `repos/x/$n`) or a tilde word. For each occurrence whose
1537+
changeable words all have a proved finite set of values, the projection
1538+
writes one literal twin for each combination of values. A twin text is the
1539+
authored text of that one command, from its first word to its last word, with
1540+
each changeable word written as one literal word. The parser parses the twin
1541+
text in the exact directory of the occurrence, with the launch facts that are
1542+
live there. The twin occurrence then has its own normal facts: `CommandWords`,
1543+
verb tokens, path facts, `AuthoredFileSystemValue`, and the expansion facts of
1544+
a typed literal. Its structure (role, ancestry) is that of one top-level
1545+
command, so read the structure from `SourceOccurrence`.
1546+
1547+
The projection uses this schematic flow:
1548+
1549+
```text
1550+
give nothing when the initial state mode is Unknown
1551+
give nothing when the source has a backslash-newline, a carriage return,
1552+
`=~`, or `:~`
1553+
for each occurrence of the full parse:
1554+
give nothing unless the occurrence is complete
1555+
give nothing unless its directory is exact
1556+
without a live launch HOME, give nothing when its directory changes
1557+
in one more parse with another HomeDirectory
1558+
give nothing when it has an assignment prefix (`X=1 cmd`)
1559+
give nothing when the source has `~` or `HOME` and no live launch HOME
1560+
give nothing unless each word has a span in the submitted source
1561+
give nothing unless each redirect is fixed
1562+
for each word that is not static:
1563+
give nothing unless it is one argument with an analyzed value
1564+
values = effective Exact or FiniteSet value
1565+
if the word can split or glob:
1566+
values = authored Exact or FiniteSet value when the effective value is Unknown
1567+
give nothing unless the mode is FreshNonInteractiveNoStartup
1568+
give nothing unless each value is nonempty and has no
1569+
space, tab, newline, *, ?, [, (, ), or backslash
1570+
give nothing when the combinations pass 32
1571+
compute the length of every twin text before any build
1572+
for each occurrence that passed, in order:
1573+
give nothing when its twins do not fit in the rest of the budget:
1574+
128 twin parses, and 2,048 + 8 x (source length) twin characters
1575+
charge its twin count and twin characters
1576+
for each combination:
1577+
write each value as one literal word (plain text, else single quotes)
1578+
parse the twin text in the directory of the occurrence
1579+
give nothing unless the twin is one complete command in that
1580+
directory, and each written word is one literal word with that
1581+
exact value
1582+
```
1583+
1584+
A word is static when it has no expansion, glob character, brace list, or
1585+
leading tilde. A twin keeps every static word as authored. A verb word must be
1586+
static. A fixed redirect is an exact file target, a descriptor operation, or a
1587+
here-string with exact data. A heredoc body is outside the words of the
1588+
command, so the twin text cannot hold it, and a command with a heredoc gets no
1589+
twins.
1590+
1591+
The effective value of an unquoted expansion stays `Unknown`, because the
1592+
shell can split or glob it. Under `FreshNonInteractiveNoStartup`, the caller
1593+
removes an inherited `IFS`, and the parser rejects source that changes `IFS`
1594+
or a glob option. Bash then splits only on space, tab, and newline. It globs
1595+
on `*`, `?`, and `[`, and, when a Bash build turns `extglob` on by default,
1596+
also on a pattern such as `@(a)` or `+(a)`. A nonempty value without these
1597+
characters, without a parenthesis, and without a backslash stays one
1598+
unchanged word. Only then does the projection use the authored value of an
1599+
unquoted word. An empty value can remove the word, so it gives no twin. The
1600+
authored value needs `PublishAuthoredSourceFacts`.
1601+
1602+
Some guards are wider than Bash, because the twin facts are only as good as
1603+
the `Parse` value facts. A value can come from any part of the source, such as
1604+
a loop list, so these guards read the full source. The first two guards cover
1605+
text that `Parse` modeled wrongly before v0.4.0-beta.22. Since then `Parse`
1606+
gives correct facts for it or fails closed, and the guards stay as defense in
1607+
depth:
1608+
1609+
- Bash removes a backslash-newline before it reads a word, also inside an
1610+
expansion. Bash reads a carriage return as a word character. A source with
1611+
either text gets no twins.
1612+
- Bash expands a tilde after `=` and after `:` in an argument or a loop list
1613+
item that looks like an assignment (`dd if=~/x`, `for f in if=~/x`). A source
1614+
with `=~` or `:~` anywhere gets no twins. This also stops `[[ $x =~ re ]]`.
1615+
- Without a live launch `HOME`, the home directory is the caller assumption
1616+
`HomeDirectory`. When the source has `~` or `HOME`, an occurrence gets twins
1617+
only with a live launch `HOME`.
1618+
- A `cd` without an operand (also `cd --`, `builtin cd`, `if cd; then`) goes
1619+
to `$HOME`. Without a live launch `HOME`, the parser takes that directory
1620+
from `HomeDirectory`. The projection does not read the `cd` forms. It parses
1621+
the source once more with another `HomeDirectory`, and an occurrence whose
1622+
exact directory changes gets no twins. When the launch facts export `HOME`
1623+
but `HOME` is not live at the occurrence, the occurrence gets no twins.
1624+
- Under `BashInitialStateMode.Unknown`, the parser can report an exact value
1625+
for a word that Bash expands (`-o"$n"` gives `-o$n`). The projection gives
1626+
no twins in that mode.
1627+
1628+
The budget is checked before a twin text is built. A twin text is one simple
1629+
command with no loop, so its parse costs about as much as its length. The
1630+
character budget grows with the source, so the twin work of one call stays a
1631+
small constant factor of one `Parse`. An occurrence that does not fit gets no
1632+
twins and costs no build and no parse. A charged occurrence keeps its charge
1633+
when a twin fails.
1634+
1635+
The values of different words combine independently. Two words that read one
1636+
loop variable can give a twin with two different values. That combination
1637+
does not run, but each of its values does. The twin set is a superset of the
1638+
runs, so a consumer that checks every twin checks every run.
1639+
1640+
An occurrence that is not in `Commands` has no twins. The method returns
1641+
`false` and a null projection when no occurrence has twins. Bash runs only
1642+
the authored source, never `twin.Source`. A literal value can move a word
1643+
between the verb and argument roles, as the typed literal does: the twins of
1644+
`for v in push fetch; do git $v origin; done` have the command words
1645+
`git push origin` and `git fetch origin`.
1646+
1647+
Positive example: from `/work`,
1648+
`for n in 8250 8244; do gh api repos/o/r/issues/$n >/dev/null; done` gives two
1649+
twins, `gh api repos/o/r/issues/8250 >/dev/null` and
1650+
`gh api repos/o/r/issues/8244 >/dev/null`. Each twin occurrence has the
1651+
command words `gh api` and the path fact of its literal word.
1652+
1653+
Negative examples: `for n in $(gh issue list); do gh api repos/o/r/issues/$n; done`
1654+
gives no twins, because the list has no proved values. `for f in 'a b' c; do cat $f; done`
1655+
gives no twins, because `a b` splits. `for d in a b; do ~/bin/tool "$d"; done`
1656+
gives no twins, because the program word is not static. `for f in if=~/x; do dd "$f"; done`
1657+
gives no twins, because Bash expands the tilde in the list item.
1658+
1659+
A twin is syntax evidence. It does not grant authority. The caller owns path
1660+
policy, grant matching, and process launch, and it must check every twin.
1661+
14911662
Effects join per authored occurrence. Two `Unchanged` visits remain
14921663
`Unchanged`; two success-only changes join their bounded targets. Unknown,
14931664
mixed unchanged/change visits, missing visits, or lost correlation join to

0 commit comments

Comments
 (0)