bash-write-targets 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bash_write_targets-0.1.0/LICENSE +21 -0
- bash_write_targets-0.1.0/PKG-INFO +125 -0
- bash_write_targets-0.1.0/README.md +112 -0
- bash_write_targets-0.1.0/pyproject.toml +22 -0
- bash_write_targets-0.1.0/setup.cfg +4 -0
- bash_write_targets-0.1.0/src/bash_write_targets/__init__.py +3 -0
- bash_write_targets-0.1.0/src/bash_write_targets/cli.py +39 -0
- bash_write_targets-0.1.0/src/bash_write_targets/extract.py +340 -0
- bash_write_targets-0.1.0/src/bash_write_targets.egg-info/PKG-INFO +125 -0
- bash_write_targets-0.1.0/src/bash_write_targets.egg-info/SOURCES.txt +12 -0
- bash_write_targets-0.1.0/src/bash_write_targets.egg-info/dependency_links.txt +1 -0
- bash_write_targets-0.1.0/src/bash_write_targets.egg-info/entry_points.txt +2 -0
- bash_write_targets-0.1.0/src/bash_write_targets.egg-info/top_level.txt +1 -0
- bash_write_targets-0.1.0/tests/test_targets.py +138 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 hao li
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bash-write-targets
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Statically list the file-write targets of a bash command (redirects, tee, -o flags, cp/mv destinations)
|
|
5
|
+
Author: hao li
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/hahahahahahahahah6/bash-write-targets
|
|
8
|
+
Keywords: bash,shell,static-analysis,security,ai-agents,audit
|
|
9
|
+
Requires-Python: >=3.9
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Dynamic: license-file
|
|
13
|
+
|
|
14
|
+
# bash-write-targets
|
|
15
|
+
|
|
16
|
+
Give it a bash command string; get back the list of files it may write to —
|
|
17
|
+
before anything runs.
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
$ targets 'make 2>&1 | tee build.log; curl -s -o app.tar.gz https://example.com/app'
|
|
21
|
+
build.log
|
|
22
|
+
app.tar.gz
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Stdlib-only. Static analysis — the command is **never executed**.
|
|
26
|
+
|
|
27
|
+
## Why
|
|
28
|
+
|
|
29
|
+
AI coding agents run a lot of bash. Before a command executes, an auditor
|
|
30
|
+
(like a PreToolUse hook) wants to know: *which files could this touch?*
|
|
31
|
+
Grepping for `>` misses `tee`, `curl -o`, `sed -i`, `dd of=`, `cp`/`mv`
|
|
32
|
+
destinations, and writes hidden inside `$(...)`.
|
|
33
|
+
|
|
34
|
+
`bash-write-targets` answers that one question, for one command, with no
|
|
35
|
+
dependencies.
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install bash-write-targets
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Requires Python 3.9+.
|
|
44
|
+
|
|
45
|
+
## Usage
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
targets '<bash command>' # one target per line
|
|
49
|
+
targets --json '<bash command>' # JSON array, for hooks and scripts
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Examples:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
$ targets 'echo hello > out.txt'
|
|
56
|
+
out.txt
|
|
57
|
+
|
|
58
|
+
$ targets 'cat > note.txt <<EOF
|
|
59
|
+
hello > world
|
|
60
|
+
EOF'
|
|
61
|
+
note.txt
|
|
62
|
+
|
|
63
|
+
$ targets 'grep -r foo . | sort -o sorted.txt; mv sorted.txt final.txt'
|
|
64
|
+
sorted.txt
|
|
65
|
+
final.txt
|
|
66
|
+
|
|
67
|
+
$ targets 'sed -i "s/a/b/g" config.ini && dd if=/dev/zero of=disk.img bs=1M count=1'
|
|
68
|
+
config.ini
|
|
69
|
+
disk.img
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Covered: `>` `>>` `>|` `&>` `&>>` `2>` `2>>` `<>`, heredocs (bodies are
|
|
73
|
+
stripped, not parsed), `$(...)` and backticks (scanned recursively), `tee`
|
|
74
|
+
(including `-a`), `cp`/`mv`/`install` destinations, `sed -i`, `dd of=`,
|
|
75
|
+
`truncate`, `touch`, `shred`, `zip` archives, and `-o`/`--output`-style
|
|
76
|
+
flags for `curl`, `wget`, `gcc`/`cc`/`g++`/`clang`, `sort`, `pandoc`,
|
|
77
|
+
`psql`, and `tar -f`.
|
|
78
|
+
|
|
79
|
+
Deliberately skipped (not guessed at): `/dev/null`, fd duplications
|
|
80
|
+
(`2>&1`), unresolvable `$VAR` expansions, bare globs, and input-side `<`
|
|
81
|
+
redirections.
|
|
82
|
+
|
|
83
|
+
## Differentiation
|
|
84
|
+
|
|
85
|
+
This project exists because
|
|
86
|
+
[agent-guard](https://github.com/hahahahahahahahah6/agent-guard) says so —
|
|
87
|
+
literally. Its README's *Honest limitations* section documents the
|
|
88
|
+
situation:
|
|
89
|
+
|
|
90
|
+
> **Bash write-target extraction is static and approximate.** It is
|
|
91
|
+
> shlex-based, so heavy quoting, `eval`, command substitution building
|
|
92
|
+
> paths at runtime, and `python3 -c "open(...).write(...)"` are known gaps
|
|
93
|
+
> — the target list is conservative by design (a missed target is a miss,
|
|
94
|
+
> never a crash). ... **This is the documented frontier for a future
|
|
95
|
+
> version, not a finished parser.**
|
|
96
|
+
|
|
97
|
+
`bash-write-targets` is that future version, as a standalone tool:
|
|
98
|
+
|
|
99
|
+
- **Deeper than the inline heuristic.** agent-guard's hook extracts
|
|
100
|
+
targets inline with a shallow shlex pass, good enough for a hot-path
|
|
101
|
+
block decision. This package goes further: heredoc-aware stripping,
|
|
102
|
+
recursive command-substitution scanning, and command-aware operand
|
|
103
|
+
parsing (`tee`, `sed -i` script-vs-file disambiguation, `cp -t`,
|
|
104
|
+
`--output=` forms).
|
|
105
|
+
- **Reusable anywhere.** A PreToolUse hook, a CI audit step, or a human
|
|
106
|
+
double-checking a pasted command — one `targets` call, one line per
|
|
107
|
+
file, plus `--json` for machines.
|
|
108
|
+
- **Safe by construction.** Pure static parsing over the command string.
|
|
109
|
+
Nothing is executed, no subshell is spawned, no file is touched —
|
|
110
|
+
auditing a destructive command cannot itself be destructive.
|
|
111
|
+
|
|
112
|
+
## Honest limitations
|
|
113
|
+
|
|
114
|
+
- Static, not proof. `eval`, paths assembled at runtime, and
|
|
115
|
+
`python3 -c "open(p).write(x)"` are known gaps — the list is
|
|
116
|
+
conservative by design.
|
|
117
|
+
- `$VAR` and bare globs are skipped rather than resolved.
|
|
118
|
+
- Network sinks (e.g. `curl --data` POSTing somewhere) are out of scope:
|
|
119
|
+
this tool lists *file* write targets.
|
|
120
|
+
- A missed target is a miss, never a crash: malformed input yields fewer
|
|
121
|
+
results, not an exception.
|
|
122
|
+
|
|
123
|
+
## License
|
|
124
|
+
|
|
125
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# bash-write-targets
|
|
2
|
+
|
|
3
|
+
Give it a bash command string; get back the list of files it may write to —
|
|
4
|
+
before anything runs.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
$ targets 'make 2>&1 | tee build.log; curl -s -o app.tar.gz https://example.com/app'
|
|
8
|
+
build.log
|
|
9
|
+
app.tar.gz
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Stdlib-only. Static analysis — the command is **never executed**.
|
|
13
|
+
|
|
14
|
+
## Why
|
|
15
|
+
|
|
16
|
+
AI coding agents run a lot of bash. Before a command executes, an auditor
|
|
17
|
+
(like a PreToolUse hook) wants to know: *which files could this touch?*
|
|
18
|
+
Grepping for `>` misses `tee`, `curl -o`, `sed -i`, `dd of=`, `cp`/`mv`
|
|
19
|
+
destinations, and writes hidden inside `$(...)`.
|
|
20
|
+
|
|
21
|
+
`bash-write-targets` answers that one question, for one command, with no
|
|
22
|
+
dependencies.
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install bash-write-targets
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Requires Python 3.9+.
|
|
31
|
+
|
|
32
|
+
## Usage
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
targets '<bash command>' # one target per line
|
|
36
|
+
targets --json '<bash command>' # JSON array, for hooks and scripts
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Examples:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
$ targets 'echo hello > out.txt'
|
|
43
|
+
out.txt
|
|
44
|
+
|
|
45
|
+
$ targets 'cat > note.txt <<EOF
|
|
46
|
+
hello > world
|
|
47
|
+
EOF'
|
|
48
|
+
note.txt
|
|
49
|
+
|
|
50
|
+
$ targets 'grep -r foo . | sort -o sorted.txt; mv sorted.txt final.txt'
|
|
51
|
+
sorted.txt
|
|
52
|
+
final.txt
|
|
53
|
+
|
|
54
|
+
$ targets 'sed -i "s/a/b/g" config.ini && dd if=/dev/zero of=disk.img bs=1M count=1'
|
|
55
|
+
config.ini
|
|
56
|
+
disk.img
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Covered: `>` `>>` `>|` `&>` `&>>` `2>` `2>>` `<>`, heredocs (bodies are
|
|
60
|
+
stripped, not parsed), `$(...)` and backticks (scanned recursively), `tee`
|
|
61
|
+
(including `-a`), `cp`/`mv`/`install` destinations, `sed -i`, `dd of=`,
|
|
62
|
+
`truncate`, `touch`, `shred`, `zip` archives, and `-o`/`--output`-style
|
|
63
|
+
flags for `curl`, `wget`, `gcc`/`cc`/`g++`/`clang`, `sort`, `pandoc`,
|
|
64
|
+
`psql`, and `tar -f`.
|
|
65
|
+
|
|
66
|
+
Deliberately skipped (not guessed at): `/dev/null`, fd duplications
|
|
67
|
+
(`2>&1`), unresolvable `$VAR` expansions, bare globs, and input-side `<`
|
|
68
|
+
redirections.
|
|
69
|
+
|
|
70
|
+
## Differentiation
|
|
71
|
+
|
|
72
|
+
This project exists because
|
|
73
|
+
[agent-guard](https://github.com/hahahahahahahahah6/agent-guard) says so —
|
|
74
|
+
literally. Its README's *Honest limitations* section documents the
|
|
75
|
+
situation:
|
|
76
|
+
|
|
77
|
+
> **Bash write-target extraction is static and approximate.** It is
|
|
78
|
+
> shlex-based, so heavy quoting, `eval`, command substitution building
|
|
79
|
+
> paths at runtime, and `python3 -c "open(...).write(...)"` are known gaps
|
|
80
|
+
> — the target list is conservative by design (a missed target is a miss,
|
|
81
|
+
> never a crash). ... **This is the documented frontier for a future
|
|
82
|
+
> version, not a finished parser.**
|
|
83
|
+
|
|
84
|
+
`bash-write-targets` is that future version, as a standalone tool:
|
|
85
|
+
|
|
86
|
+
- **Deeper than the inline heuristic.** agent-guard's hook extracts
|
|
87
|
+
targets inline with a shallow shlex pass, good enough for a hot-path
|
|
88
|
+
block decision. This package goes further: heredoc-aware stripping,
|
|
89
|
+
recursive command-substitution scanning, and command-aware operand
|
|
90
|
+
parsing (`tee`, `sed -i` script-vs-file disambiguation, `cp -t`,
|
|
91
|
+
`--output=` forms).
|
|
92
|
+
- **Reusable anywhere.** A PreToolUse hook, a CI audit step, or a human
|
|
93
|
+
double-checking a pasted command — one `targets` call, one line per
|
|
94
|
+
file, plus `--json` for machines.
|
|
95
|
+
- **Safe by construction.** Pure static parsing over the command string.
|
|
96
|
+
Nothing is executed, no subshell is spawned, no file is touched —
|
|
97
|
+
auditing a destructive command cannot itself be destructive.
|
|
98
|
+
|
|
99
|
+
## Honest limitations
|
|
100
|
+
|
|
101
|
+
- Static, not proof. `eval`, paths assembled at runtime, and
|
|
102
|
+
`python3 -c "open(p).write(x)"` are known gaps — the list is
|
|
103
|
+
conservative by design.
|
|
104
|
+
- `$VAR` and bare globs are skipped rather than resolved.
|
|
105
|
+
- Network sinks (e.g. `curl --data` POSTing somewhere) are out of scope:
|
|
106
|
+
this tool lists *file* write targets.
|
|
107
|
+
- A missed target is a miss, never a crash: malformed input yields fewer
|
|
108
|
+
results, not an exception.
|
|
109
|
+
|
|
110
|
+
## License
|
|
111
|
+
|
|
112
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "bash-write-targets"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Statically list the file-write targets of a bash command (redirects, tee, -o flags, cp/mv destinations)"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "hao li" }]
|
|
13
|
+
keywords = ["bash", "shell", "static-analysis", "security", "ai-agents", "audit"]
|
|
14
|
+
|
|
15
|
+
[project.scripts]
|
|
16
|
+
targets = "bash_write_targets.cli:main"
|
|
17
|
+
|
|
18
|
+
[project.urls]
|
|
19
|
+
Homepage = "https://github.com/hahahahahahahahah6/bash-write-targets"
|
|
20
|
+
|
|
21
|
+
[tool.setuptools.packages.find]
|
|
22
|
+
where = ["src"]
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""CLI: targets '<bash command>' -> list the files the command may write to."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from . import __version__
|
|
8
|
+
from .extract import extract
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main(argv=None):
|
|
12
|
+
parser = argparse.ArgumentParser(
|
|
13
|
+
prog="targets",
|
|
14
|
+
description=(
|
|
15
|
+
"Statically list the file-write targets of a bash command "
|
|
16
|
+
"(redirections, tee, -o/--output flags, cp/mv destinations, ...). "
|
|
17
|
+
"The command is never executed."
|
|
18
|
+
),
|
|
19
|
+
)
|
|
20
|
+
parser.add_argument("command", help="bash command string to audit")
|
|
21
|
+
parser.add_argument(
|
|
22
|
+
"--json", action="store_true", help="print targets as a JSON array"
|
|
23
|
+
)
|
|
24
|
+
parser.add_argument(
|
|
25
|
+
"--version", action="version", version="%(prog)s " + __version__
|
|
26
|
+
)
|
|
27
|
+
args = parser.parse_args(argv)
|
|
28
|
+
|
|
29
|
+
targets = extract(args.command)
|
|
30
|
+
if args.json:
|
|
31
|
+
print(json.dumps(targets))
|
|
32
|
+
else:
|
|
33
|
+
for t in targets:
|
|
34
|
+
print(t)
|
|
35
|
+
return 0
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
if __name__ == "__main__":
|
|
39
|
+
sys.exit(main())
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
"""Static extraction of file-write targets from a bash command string.
|
|
2
|
+
|
|
3
|
+
Never executes anything. Conservative by design: a missed target is a miss,
|
|
4
|
+
never a crash.
|
|
5
|
+
|
|
6
|
+
Covered:
|
|
7
|
+
* redirections: >, >>, >|, &>, &>>, 2>, 2>>, <>
|
|
8
|
+
* heredocs (<<EOF): bodies are stripped so their contents are not mistaken
|
|
9
|
+
for targets
|
|
10
|
+
* command substitution: $(...) and `...` are scanned recursively
|
|
11
|
+
* tee (all operands, -a/--append aware)
|
|
12
|
+
* cp / mv / install (destination operand; --target-directory aware)
|
|
13
|
+
* sed -i (in-place operands)
|
|
14
|
+
* dd of=FILE, truncate, touch, shred, zip (archive operand)
|
|
15
|
+
* -o / --output style flags for curl, wget, gcc/cc/g++/clang, sort,
|
|
16
|
+
pandoc, psql, tar -f/--file
|
|
17
|
+
|
|
18
|
+
Known gaps (documented, not guessed at):
|
|
19
|
+
* eval and paths built at runtime
|
|
20
|
+
* unresolvable $VAR expansions (skipped, not guessed)
|
|
21
|
+
* bare globs (skipped)
|
|
22
|
+
* /dev/null (skipped)
|
|
23
|
+
* python3 -c "open(...).write(...)" style embedded writes
|
|
24
|
+
* network sinks (e.g. curl --data posting to a URL)
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
import re
|
|
28
|
+
import shlex
|
|
29
|
+
|
|
30
|
+
_SEPARATORS = {"|", "||", "&", "&&", ";", "(", ")", "\n"}
|
|
31
|
+
_PREFIX_COMMANDS = {"sudo", "env", "command", "time", "nohup"}
|
|
32
|
+
|
|
33
|
+
_FD_WORD = re.compile(r"^&?\d+$")
|
|
34
|
+
_ASSIGN_WORD = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=.*$")
|
|
35
|
+
_DD_OF = re.compile(r"^of=(.+)$")
|
|
36
|
+
_GLOB_CHARS = re.compile(r"[*?\[]")
|
|
37
|
+
_VAR_CHARS = re.compile(r"[$]")
|
|
38
|
+
|
|
39
|
+
# command -> output flags whose next token (or --flag=value) names a file
|
|
40
|
+
KNOWN_OUTPUT_FLAGS = {
|
|
41
|
+
"curl": ("-o", "--output"),
|
|
42
|
+
"wget": ("-O", "--output-document"),
|
|
43
|
+
"gcc": ("-o",),
|
|
44
|
+
"cc": ("-o",),
|
|
45
|
+
"g++": ("-o",),
|
|
46
|
+
"clang": ("-o",),
|
|
47
|
+
"clang++": ("-o",),
|
|
48
|
+
"sort": ("-o", "--output"),
|
|
49
|
+
"pandoc": ("-o", "--output"),
|
|
50
|
+
"psql": ("-o", "--output"),
|
|
51
|
+
"tar": ("-f", "--file"),
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _valid_target(word):
|
|
56
|
+
"""Decide whether a candidate word is a plausible file path."""
|
|
57
|
+
if not word:
|
|
58
|
+
return False
|
|
59
|
+
if word == "/dev/null":
|
|
60
|
+
return False
|
|
61
|
+
if word == "-": # stdout/stdin placeholder
|
|
62
|
+
return False
|
|
63
|
+
if _FD_WORD.match(word):
|
|
64
|
+
return False
|
|
65
|
+
if _VAR_CHARS.search(word):
|
|
66
|
+
return False # unresolvable at static time; skip, don't guess
|
|
67
|
+
if _GLOB_CHARS.search(word):
|
|
68
|
+
return False # bare globs are ambiguous; skip, don't guess
|
|
69
|
+
return True
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _strip_heredocs(text):
|
|
73
|
+
"""Remove heredoc bodies so their contents are not parsed as commands."""
|
|
74
|
+
pattern = re.compile(r"<<-?[ \t]*(?P<q>['\"]?)(?P<word>[A-Za-z0-9_]+)(?P=q)")
|
|
75
|
+
out = text
|
|
76
|
+
while True:
|
|
77
|
+
m = pattern.search(out)
|
|
78
|
+
if not m:
|
|
79
|
+
return out
|
|
80
|
+
word = m.group("word")
|
|
81
|
+
term = re.compile(r"^" + re.escape(word) + r"[ \t]*$", re.M)
|
|
82
|
+
tm = term.search(out, m.end())
|
|
83
|
+
end = tm.end() if tm else len(out)
|
|
84
|
+
out = out[: m.start()] + " " + out[end:]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _extract_substitutions(text):
|
|
88
|
+
"""Pull out $(...) and `...` spans; return (outer_text, [inner, ...])."""
|
|
89
|
+
inners = []
|
|
90
|
+
out = []
|
|
91
|
+
i, n = 0, len(text)
|
|
92
|
+
while i < n:
|
|
93
|
+
if text.startswith("$(", i):
|
|
94
|
+
depth, j = 1, i + 2
|
|
95
|
+
in_single = in_double = False
|
|
96
|
+
while j < n and depth:
|
|
97
|
+
c = text[j]
|
|
98
|
+
if in_single:
|
|
99
|
+
if c == "'":
|
|
100
|
+
in_single = False
|
|
101
|
+
elif in_double:
|
|
102
|
+
if c == '"':
|
|
103
|
+
in_double = False
|
|
104
|
+
elif c == "\\":
|
|
105
|
+
j += 1
|
|
106
|
+
else:
|
|
107
|
+
if c == "'":
|
|
108
|
+
in_single = True
|
|
109
|
+
elif c == '"':
|
|
110
|
+
in_double = True
|
|
111
|
+
elif c == "(":
|
|
112
|
+
depth += 1
|
|
113
|
+
elif c == ")":
|
|
114
|
+
depth -= 1
|
|
115
|
+
j += 1
|
|
116
|
+
if depth == 0:
|
|
117
|
+
inners.append(text[i + 2 : j - 1])
|
|
118
|
+
out.append(" ")
|
|
119
|
+
i = j
|
|
120
|
+
else: # unbalanced; keep scanning char by char
|
|
121
|
+
out.append(text[i])
|
|
122
|
+
i += 1
|
|
123
|
+
elif text[i] == "`":
|
|
124
|
+
j = i + 1
|
|
125
|
+
while j < n and text[j] != "`":
|
|
126
|
+
if text[j] == "\\":
|
|
127
|
+
j += 1
|
|
128
|
+
j += 1
|
|
129
|
+
if j < n:
|
|
130
|
+
inners.append(text[i + 1 : j])
|
|
131
|
+
out.append(" ")
|
|
132
|
+
i = j + 1
|
|
133
|
+
else:
|
|
134
|
+
out.append(text[i])
|
|
135
|
+
i += 1
|
|
136
|
+
else:
|
|
137
|
+
out.append(text[i])
|
|
138
|
+
i += 1
|
|
139
|
+
return "".join(out), inners
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _tokenize(text):
|
|
143
|
+
"""shlex tokenize with shell punctuation kept as separate tokens."""
|
|
144
|
+
try:
|
|
145
|
+
lexer = shlex.shlex(text, posix=True, punctuation_chars="|&;()<>\n")
|
|
146
|
+
except TypeError: # very old Python without punctuation_chars
|
|
147
|
+
lexer = shlex.shlex(text, posix=True)
|
|
148
|
+
lexer.whitespace_split = True
|
|
149
|
+
raw = list(lexer)
|
|
150
|
+
# merge doubled punctuation chars: >>, <<, &&, ||
|
|
151
|
+
merged = []
|
|
152
|
+
for tok in raw:
|
|
153
|
+
if merged and tok in (">", "<", "&", "|") and merged[-1] == tok:
|
|
154
|
+
merged[-1] = tok * 2
|
|
155
|
+
else:
|
|
156
|
+
merged.append(tok)
|
|
157
|
+
return merged
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _redir_op(tok):
|
|
161
|
+
"""Normalize a token to a redirection operator, or None.
|
|
162
|
+
|
|
163
|
+
Leading fd digits are stripped: '2>' -> '>'.
|
|
164
|
+
"""
|
|
165
|
+
m = re.match(r"^(\d*)(>>?|<<-?|>&|&>>?|>\||<>)$", tok)
|
|
166
|
+
if not m:
|
|
167
|
+
return None
|
|
168
|
+
return m.group(2)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _scan_tokens(tokens, add):
|
|
172
|
+
i, n = 0, len(tokens)
|
|
173
|
+
at_start = True
|
|
174
|
+
cmd = None
|
|
175
|
+
flags = []
|
|
176
|
+
operands = []
|
|
177
|
+
pending_value = None # flag waiting for its value (name, kind)
|
|
178
|
+
target_dir = None
|
|
179
|
+
|
|
180
|
+
def finish_segment():
|
|
181
|
+
nonlocal cmd, flags, operands, pending_value, target_dir
|
|
182
|
+
if cmd == "tee":
|
|
183
|
+
for op in operands:
|
|
184
|
+
add(op)
|
|
185
|
+
elif cmd in ("cp", "mv", "install"):
|
|
186
|
+
if target_dir is not None:
|
|
187
|
+
add(target_dir)
|
|
188
|
+
elif len(operands) >= 2:
|
|
189
|
+
add(operands[-1])
|
|
190
|
+
elif cmd == "sed":
|
|
191
|
+
if any(
|
|
192
|
+
f == "-i" or f.startswith("-i") or f == "--in-place"
|
|
193
|
+
for f in flags
|
|
194
|
+
):
|
|
195
|
+
files = operands
|
|
196
|
+
# without -e/-f the first operand is the sed script, not a file
|
|
197
|
+
if files and not any(
|
|
198
|
+
f == "-e" or f == "-f" or f.startswith("-e") or f.startswith("--expression")
|
|
199
|
+
for f in flags
|
|
200
|
+
):
|
|
201
|
+
files = files[1:]
|
|
202
|
+
for op in files:
|
|
203
|
+
add(op)
|
|
204
|
+
elif cmd == "dd":
|
|
205
|
+
for t in operands + flags:
|
|
206
|
+
m = _DD_OF.match(t)
|
|
207
|
+
if m:
|
|
208
|
+
add(m.group(1))
|
|
209
|
+
elif cmd in ("truncate", "touch", "shred"):
|
|
210
|
+
for op in operands:
|
|
211
|
+
add(op)
|
|
212
|
+
elif cmd == "zip":
|
|
213
|
+
if operands:
|
|
214
|
+
add(operands[0])
|
|
215
|
+
cmd, flags, operands, pending_value, target_dir = (
|
|
216
|
+
None, [], [], None, None,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
def consume_flag_value():
|
|
220
|
+
nonlocal i, pending_value, target_dir
|
|
221
|
+
name, kind = pending_value
|
|
222
|
+
pending_value = None
|
|
223
|
+
if i < n and tokens[i] not in _SEPARATORS and _redir_op(tokens[i]) is None:
|
|
224
|
+
if kind == "output":
|
|
225
|
+
add(tokens[i])
|
|
226
|
+
elif kind == "targetdir":
|
|
227
|
+
target_dir = tokens[i]
|
|
228
|
+
# kind == "skip" (e.g. truncate -s): value consumed, not a target
|
|
229
|
+
i += 1
|
|
230
|
+
|
|
231
|
+
while i < n:
|
|
232
|
+
tok = tokens[i]
|
|
233
|
+
|
|
234
|
+
if pending_value is not None:
|
|
235
|
+
consume_flag_value()
|
|
236
|
+
continue
|
|
237
|
+
|
|
238
|
+
if tok in _SEPARATORS:
|
|
239
|
+
finish_segment()
|
|
240
|
+
at_start = True
|
|
241
|
+
i += 1
|
|
242
|
+
continue
|
|
243
|
+
|
|
244
|
+
op = _redir_op(tok)
|
|
245
|
+
if op is not None:
|
|
246
|
+
nxt = tokens[i + 1] if i + 1 < n else None
|
|
247
|
+
if op in ("<<", "<<-"):
|
|
248
|
+
i += 2 # heredoc bodies already stripped; skip delimiter
|
|
249
|
+
continue
|
|
250
|
+
if op == "<":
|
|
251
|
+
i += 2 if nxt not in (None, "(") else 1 # input side, not a target
|
|
252
|
+
continue
|
|
253
|
+
# output redirections: >, >>, >|, &>, &>>, <>
|
|
254
|
+
if nxt == "(":
|
|
255
|
+
i += 1 # process substitution >(...); not a file target
|
|
256
|
+
continue
|
|
257
|
+
if nxt == "&":
|
|
258
|
+
# >&2 / 2>&1 style fd duplication; only a real path counts
|
|
259
|
+
tgt = tokens[i + 2] if i + 2 < n else None
|
|
260
|
+
if (
|
|
261
|
+
tgt is not None
|
|
262
|
+
and tgt not in _SEPARATORS
|
|
263
|
+
and _redir_op(tgt) is None
|
|
264
|
+
and not _FD_WORD.match(tgt)
|
|
265
|
+
):
|
|
266
|
+
add(tgt)
|
|
267
|
+
i += 3
|
|
268
|
+
continue
|
|
269
|
+
if nxt is None or nxt in _SEPARATORS or _redir_op(nxt) is not None:
|
|
270
|
+
i += 1 # malformed; skip
|
|
271
|
+
continue
|
|
272
|
+
add(nxt)
|
|
273
|
+
i += 2
|
|
274
|
+
continue
|
|
275
|
+
|
|
276
|
+
if at_start:
|
|
277
|
+
if _ASSIGN_WORD.match(tok):
|
|
278
|
+
i += 1 # FOO=bar env assignment
|
|
279
|
+
continue
|
|
280
|
+
if tok in _PREFIX_COMMANDS:
|
|
281
|
+
i += 1
|
|
282
|
+
continue
|
|
283
|
+
cmd = tok.split("/")[-1]
|
|
284
|
+
at_start = False
|
|
285
|
+
i += 1
|
|
286
|
+
continue
|
|
287
|
+
|
|
288
|
+
# operand / flag position for the current command
|
|
289
|
+
if tok.startswith("-") and tok != "-":
|
|
290
|
+
flags.append(tok)
|
|
291
|
+
if cmd in KNOWN_OUTPUT_FLAGS and tok in KNOWN_OUTPUT_FLAGS[cmd]:
|
|
292
|
+
if "=" in tok:
|
|
293
|
+
_, _, value = tok.partition("=")
|
|
294
|
+
add(value)
|
|
295
|
+
else:
|
|
296
|
+
pending_value = (tok, "output")
|
|
297
|
+
elif "=" in tok and tok.split("=", 1)[0] in (
|
|
298
|
+
"--target-directory",
|
|
299
|
+
"--output",
|
|
300
|
+
"--file",
|
|
301
|
+
"--output-document",
|
|
302
|
+
):
|
|
303
|
+
_, _, value = tok.partition("=")
|
|
304
|
+
if tok.startswith("--target-directory"):
|
|
305
|
+
target_dir = value
|
|
306
|
+
else:
|
|
307
|
+
add(value)
|
|
308
|
+
elif cmd == "truncate" and tok in ("-s", "--size"):
|
|
309
|
+
pending_value = (tok, "skip")
|
|
310
|
+
elif cmd in ("cp", "mv", "install") and tok in ("-t", "--target-directory"):
|
|
311
|
+
pending_value = (tok, "targetdir")
|
|
312
|
+
i += 1
|
|
313
|
+
continue
|
|
314
|
+
|
|
315
|
+
if _ASSIGN_WORD.match(tok) and cmd != "dd":
|
|
316
|
+
i += 1 # VAR=x in operand position is not a file
|
|
317
|
+
continue
|
|
318
|
+
operands.append(tok)
|
|
319
|
+
i += 1
|
|
320
|
+
|
|
321
|
+
finish_segment()
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def extract(command):
|
|
325
|
+
"""Return the ordered, deduplicated list of files the command may write.
|
|
326
|
+
|
|
327
|
+
Pure static analysis; the command is never executed.
|
|
328
|
+
"""
|
|
329
|
+
targets = []
|
|
330
|
+
|
|
331
|
+
def add(word):
|
|
332
|
+
if _valid_target(word) and word not in targets:
|
|
333
|
+
targets.append(word)
|
|
334
|
+
|
|
335
|
+
outer, inners = _extract_substitutions(_strip_heredocs(command))
|
|
336
|
+
for inner in inners:
|
|
337
|
+
for t in extract(inner):
|
|
338
|
+
add(t)
|
|
339
|
+
_scan_tokens(_tokenize(outer), add)
|
|
340
|
+
return targets
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bash-write-targets
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Statically list the file-write targets of a bash command (redirects, tee, -o flags, cp/mv destinations)
|
|
5
|
+
Author: hao li
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/hahahahahahahahah6/bash-write-targets
|
|
8
|
+
Keywords: bash,shell,static-analysis,security,ai-agents,audit
|
|
9
|
+
Requires-Python: >=3.9
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Dynamic: license-file
|
|
13
|
+
|
|
14
|
+
# bash-write-targets
|
|
15
|
+
|
|
16
|
+
Give it a bash command string; get back the list of files it may write to —
|
|
17
|
+
before anything runs.
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
$ targets 'make 2>&1 | tee build.log; curl -s -o app.tar.gz https://example.com/app'
|
|
21
|
+
build.log
|
|
22
|
+
app.tar.gz
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Stdlib-only. Static analysis — the command is **never executed**.
|
|
26
|
+
|
|
27
|
+
## Why
|
|
28
|
+
|
|
29
|
+
AI coding agents run a lot of bash. Before a command executes, an auditor
|
|
30
|
+
(like a PreToolUse hook) wants to know: *which files could this touch?*
|
|
31
|
+
Grepping for `>` misses `tee`, `curl -o`, `sed -i`, `dd of=`, `cp`/`mv`
|
|
32
|
+
destinations, and writes hidden inside `$(...)`.
|
|
33
|
+
|
|
34
|
+
`bash-write-targets` answers that one question, for one command, with no
|
|
35
|
+
dependencies.
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install bash-write-targets
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Requires Python 3.9+.
|
|
44
|
+
|
|
45
|
+
## Usage
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
targets '<bash command>' # one target per line
|
|
49
|
+
targets --json '<bash command>' # JSON array, for hooks and scripts
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Examples:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
$ targets 'echo hello > out.txt'
|
|
56
|
+
out.txt
|
|
57
|
+
|
|
58
|
+
$ targets 'cat > note.txt <<EOF
|
|
59
|
+
hello > world
|
|
60
|
+
EOF'
|
|
61
|
+
note.txt
|
|
62
|
+
|
|
63
|
+
$ targets 'grep -r foo . | sort -o sorted.txt; mv sorted.txt final.txt'
|
|
64
|
+
sorted.txt
|
|
65
|
+
final.txt
|
|
66
|
+
|
|
67
|
+
$ targets 'sed -i "s/a/b/g" config.ini && dd if=/dev/zero of=disk.img bs=1M count=1'
|
|
68
|
+
config.ini
|
|
69
|
+
disk.img
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Covered: `>` `>>` `>|` `&>` `&>>` `2>` `2>>` `<>`, heredocs (bodies are
|
|
73
|
+
stripped, not parsed), `$(...)` and backticks (scanned recursively), `tee`
|
|
74
|
+
(including `-a`), `cp`/`mv`/`install` destinations, `sed -i`, `dd of=`,
|
|
75
|
+
`truncate`, `touch`, `shred`, `zip` archives, and `-o`/`--output`-style
|
|
76
|
+
flags for `curl`, `wget`, `gcc`/`cc`/`g++`/`clang`, `sort`, `pandoc`,
|
|
77
|
+
`psql`, and `tar -f`.
|
|
78
|
+
|
|
79
|
+
Deliberately skipped (not guessed at): `/dev/null`, fd duplications
|
|
80
|
+
(`2>&1`), unresolvable `$VAR` expansions, bare globs, and input-side `<`
|
|
81
|
+
redirections.
|
|
82
|
+
|
|
83
|
+
## Differentiation
|
|
84
|
+
|
|
85
|
+
This project exists because
|
|
86
|
+
[agent-guard](https://github.com/hahahahahahahahah6/agent-guard) says so —
|
|
87
|
+
literally. Its README's *Honest limitations* section documents the
|
|
88
|
+
situation:
|
|
89
|
+
|
|
90
|
+
> **Bash write-target extraction is static and approximate.** It is
|
|
91
|
+
> shlex-based, so heavy quoting, `eval`, command substitution building
|
|
92
|
+
> paths at runtime, and `python3 -c "open(...).write(...)"` are known gaps
|
|
93
|
+
> — the target list is conservative by design (a missed target is a miss,
|
|
94
|
+
> never a crash). ... **This is the documented frontier for a future
|
|
95
|
+
> version, not a finished parser.**
|
|
96
|
+
|
|
97
|
+
`bash-write-targets` is that future version, as a standalone tool:
|
|
98
|
+
|
|
99
|
+
- **Deeper than the inline heuristic.** agent-guard's hook extracts
|
|
100
|
+
targets inline with a shallow shlex pass, good enough for a hot-path
|
|
101
|
+
block decision. This package goes further: heredoc-aware stripping,
|
|
102
|
+
recursive command-substitution scanning, and command-aware operand
|
|
103
|
+
parsing (`tee`, `sed -i` script-vs-file disambiguation, `cp -t`,
|
|
104
|
+
`--output=` forms).
|
|
105
|
+
- **Reusable anywhere.** A PreToolUse hook, a CI audit step, or a human
|
|
106
|
+
double-checking a pasted command — one `targets` call, one line per
|
|
107
|
+
file, plus `--json` for machines.
|
|
108
|
+
- **Safe by construction.** Pure static parsing over the command string.
|
|
109
|
+
Nothing is executed, no subshell is spawned, no file is touched —
|
|
110
|
+
auditing a destructive command cannot itself be destructive.
|
|
111
|
+
|
|
112
|
+
## Honest limitations
|
|
113
|
+
|
|
114
|
+
- Static, not proof. `eval`, paths assembled at runtime, and
|
|
115
|
+
`python3 -c "open(p).write(x)"` are known gaps — the list is
|
|
116
|
+
conservative by design.
|
|
117
|
+
- `$VAR` and bare globs are skipped rather than resolved.
|
|
118
|
+
- Network sinks (e.g. `curl --data` POSTing somewhere) are out of scope:
|
|
119
|
+
this tool lists *file* write targets.
|
|
120
|
+
- A missed target is a miss, never a crash: malformed input yields fewer
|
|
121
|
+
results, not an exception.
|
|
122
|
+
|
|
123
|
+
## License
|
|
124
|
+
|
|
125
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/bash_write_targets/__init__.py
|
|
5
|
+
src/bash_write_targets/cli.py
|
|
6
|
+
src/bash_write_targets/extract.py
|
|
7
|
+
src/bash_write_targets.egg-info/PKG-INFO
|
|
8
|
+
src/bash_write_targets.egg-info/SOURCES.txt
|
|
9
|
+
src/bash_write_targets.egg-info/dependency_links.txt
|
|
10
|
+
src/bash_write_targets.egg-info/entry_points.txt
|
|
11
|
+
src/bash_write_targets.egg-info/top_level.txt
|
|
12
|
+
tests/test_targets.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
bash_write_targets
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Smoke tests for bash-write-targets. Run: python3 tests/test_targets.py"""
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from contextlib import redirect_stdout
|
|
8
|
+
|
|
9
|
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
|
10
|
+
|
|
11
|
+
from bash_write_targets import cli # noqa: E402
|
|
12
|
+
from bash_write_targets.extract import extract # noqa: E402
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def check(name, command, expected):
|
|
16
|
+
got = extract(command)
|
|
17
|
+
assert got == expected, f"{name}: for {command!r} got {got!r}, want {expected!r}"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_simple_redirect():
|
|
21
|
+
check("redirect", "echo hello > out.txt", ["out.txt"])
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_append_redirect():
|
|
25
|
+
check("append", "echo hello >> log.txt", ["log.txt"])
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_stderr_redirect():
|
|
29
|
+
check("stderr", "cmd 2> err.log", ["err.log"])
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_combined_redirect():
|
|
33
|
+
check("combined", "cmd &> all.log", ["all.log"])
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_fd_duplication_skipped():
|
|
37
|
+
check("fddup", "cmd > out.txt 2>&1", ["out.txt"])
|
|
38
|
+
check("fddup2", "cmd >&2", [])
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_dev_null_skipped():
|
|
42
|
+
check("devnull", "cmd > /dev/null 2>&1", [])
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_tee():
|
|
46
|
+
check("tee", "make 2>&1 | tee build.log", ["build.log"])
|
|
47
|
+
check("tee-multi", "cmd | tee -a a.log b.log", ["a.log", "b.log"])
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_output_flags():
|
|
51
|
+
check("curl-o", "curl -s -o page.html https://example.com", ["page.html"])
|
|
52
|
+
check("curl-long", "curl --output=dl.bin https://example.com/x", ["dl.bin"])
|
|
53
|
+
check("wget", "wget -O data.zip https://example.com/d", ["data.zip"])
|
|
54
|
+
check("gcc", "gcc -O2 -o app main.c", ["app"])
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_copy_move():
|
|
58
|
+
check("cp", "cp -r src/ dst/", ["dst/"])
|
|
59
|
+
check("mv", "mv old.txt new.txt", ["new.txt"])
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_sed_inplace():
|
|
63
|
+
check("sed-i", "sed -i 's/a/b/' file.txt", ["file.txt"])
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_dd_truncate_touch():
|
|
67
|
+
check("dd", "dd if=/dev/zero of=disk.img bs=1M count=10", ["disk.img"])
|
|
68
|
+
check("truncate", "truncate -s 0 app.log", ["app.log"])
|
|
69
|
+
check("touch", "touch newfile.txt", ["newfile.txt"])
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_heredoc_body_not_parsed():
|
|
73
|
+
check(
|
|
74
|
+
"heredoc",
|
|
75
|
+
"cat > note.txt <<EOF\nsome > text >> here\nEOF",
|
|
76
|
+
["note.txt"],
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_command_substitution_recursive():
|
|
81
|
+
check(
|
|
82
|
+
"subst",
|
|
83
|
+
"echo $(date > inner.txt) > outer.txt",
|
|
84
|
+
["inner.txt", "outer.txt"],
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_var_and_glob_skipped():
|
|
89
|
+
check("var", "echo hi > $OUTFILE", [])
|
|
90
|
+
check("glob", "echo hi > *.log", [])
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_pipeline_segments():
|
|
94
|
+
check(
|
|
95
|
+
"pipeline",
|
|
96
|
+
"grep foo in.txt | sort -o sorted.txt; mv sorted.txt final.txt",
|
|
97
|
+
["sorted.txt", "final.txt"],
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_quoted_filename():
|
|
102
|
+
check("quoted", 'echo x > "my file.txt"', ["my file.txt"])
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_no_writes():
|
|
106
|
+
check("nowrite", "ls -la | grep foo", [])
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_cli_text_output():
|
|
110
|
+
buf = io.StringIO()
|
|
111
|
+
with redirect_stdout(buf):
|
|
112
|
+
rc = cli.main(["echo hi > cli.txt"])
|
|
113
|
+
assert rc == 0
|
|
114
|
+
assert buf.getvalue().strip() == "cli.txt"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_cli_json_output():
|
|
118
|
+
buf = io.StringIO()
|
|
119
|
+
with redirect_stdout(buf):
|
|
120
|
+
rc = cli.main(["--json", "cmd | tee j1.log j2.log"])
|
|
121
|
+
assert rc == 0
|
|
122
|
+
assert json.loads(buf.getvalue()) == ["j1.log", "j2.log"]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
if __name__ == "__main__":
|
|
126
|
+
tests = [
|
|
127
|
+
v for k, v in sorted(globals().items()) if k.startswith("test_")
|
|
128
|
+
]
|
|
129
|
+
failed = 0
|
|
130
|
+
for t in tests:
|
|
131
|
+
try:
|
|
132
|
+
t()
|
|
133
|
+
print(f"PASS {t.__name__}")
|
|
134
|
+
except AssertionError as e:
|
|
135
|
+
failed += 1
|
|
136
|
+
print(f"FAIL {t.__name__}: {e}")
|
|
137
|
+
print(f"{len(tests) - failed}/{len(tests)} passed")
|
|
138
|
+
sys.exit(1 if failed else 0)
|