shellsafe 0.1.0__tar.gz → 0.2.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.
Files changed (59) hide show
  1. shellsafe-0.2.0/CHANGELOG.md +45 -0
  2. shellsafe-0.2.0/PKG-INFO +131 -0
  3. shellsafe-0.2.0/README.md +109 -0
  4. {shellsafe-0.1.0 → shellsafe-0.2.0}/pyproject.toml +5 -2
  5. shellsafe-0.2.0/src/shellsafe/_version.py +1 -0
  6. {shellsafe-0.1.0 → shellsafe-0.2.0}/src/shellsafe/cli.py +1 -1
  7. {shellsafe-0.1.0 → shellsafe-0.2.0}/src/shellsafe/execute.py +32 -20
  8. {shellsafe-0.1.0 → shellsafe-0.2.0}/src/shellsafe/render.py +4 -6
  9. shellsafe-0.2.0/tests/integration/test_exec_posix.py +100 -0
  10. shellsafe-0.2.0/tests/property/test_invariants.py +78 -0
  11. shellsafe-0.2.0/tests/unit/test_payload_corpus.py +74 -0
  12. shellsafe-0.2.0/tests/unit/test_raw.py +15 -0
  13. shellsafe-0.1.0/CHANGELOG.md +0 -14
  14. shellsafe-0.1.0/PKG-INFO +0 -126
  15. shellsafe-0.1.0/README.md +0 -104
  16. shellsafe-0.1.0/src/shellsafe/_version.py +0 -1
  17. shellsafe-0.1.0/tests/integration/test_exec_posix.py +0 -44
  18. shellsafe-0.1.0/tests/property/test_invariants.py +0 -35
  19. shellsafe-0.1.0/tests/unit/test_payload_corpus.py +0 -43
  20. shellsafe-0.1.0/tests/unit/test_raw.py +0 -28
  21. {shellsafe-0.1.0 → shellsafe-0.2.0}/.github/workflows/ci.yml +0 -0
  22. {shellsafe-0.1.0 → shellsafe-0.2.0}/.github/workflows/release.yml +0 -0
  23. {shellsafe-0.1.0 → shellsafe-0.2.0}/.gitignore +0 -0
  24. {shellsafe-0.1.0 → shellsafe-0.2.0}/CONTRIBUTING.md +0 -0
  25. {shellsafe-0.1.0 → shellsafe-0.2.0}/LICENSE +0 -0
  26. {shellsafe-0.1.0 → shellsafe-0.2.0}/examples/demo.py +0 -0
  27. {shellsafe-0.1.0 → shellsafe-0.2.0}/src/shellsafe/__init__.py +0 -0
  28. {shellsafe-0.1.0 → shellsafe-0.2.0}/src/shellsafe/__main__.py +0 -0
  29. {shellsafe-0.1.0 → shellsafe-0.2.0}/src/shellsafe/audit/__init__.py +0 -0
  30. {shellsafe-0.1.0 → shellsafe-0.2.0}/src/shellsafe/audit/rules.py +0 -0
  31. {shellsafe-0.1.0 → shellsafe-0.2.0}/src/shellsafe/errors.py +0 -0
  32. {shellsafe-0.1.0 → shellsafe-0.2.0}/src/shellsafe/exitcodes.py +0 -0
  33. {shellsafe-0.1.0 → shellsafe-0.2.0}/src/shellsafe/platforms.py +0 -0
  34. {shellsafe-0.1.0 → shellsafe-0.2.0}/src/shellsafe/py.typed +0 -0
  35. {shellsafe-0.1.0 → shellsafe-0.2.0}/src/shellsafe/raw.py +0 -0
  36. {shellsafe-0.1.0 → shellsafe-0.2.0}/src/shellsafe/reporters.py +0 -0
  37. {shellsafe-0.1.0 → shellsafe-0.2.0}/tests/golden/.gitkeep +0 -0
  38. {shellsafe-0.1.0 → shellsafe-0.2.0}/tests/integration/.gitkeep +0 -0
  39. {shellsafe-0.1.0 → shellsafe-0.2.0}/tests/payloads/cases/README.md +0 -0
  40. {shellsafe-0.1.0 → shellsafe-0.2.0}/tests/payloads/cases/case_01_semicolon.txt +0 -0
  41. {shellsafe-0.1.0 → shellsafe-0.2.0}/tests/payloads/cases/case_02_substitution.txt +0 -0
  42. {shellsafe-0.1.0 → shellsafe-0.2.0}/tests/payloads/cases/case_03_backticks.txt +0 -0
  43. {shellsafe-0.1.0 → shellsafe-0.2.0}/tests/payloads/cases/case_04_background_chain.txt +0 -0
  44. {shellsafe-0.1.0 → shellsafe-0.2.0}/tests/payloads/cases/case_05_or_chain.txt +0 -0
  45. {shellsafe-0.1.0 → shellsafe-0.2.0}/tests/payloads/cases/case_06_redirect_out.txt +0 -0
  46. {shellsafe-0.1.0 → shellsafe-0.2.0}/tests/payloads/cases/case_07_redirect_in.txt +0 -0
  47. {shellsafe-0.1.0 → shellsafe-0.2.0}/tests/payloads/cases/case_08_pipe_out.txt +0 -0
  48. {shellsafe-0.1.0 → shellsafe-0.2.0}/tests/payloads/cases/case_09_quote_smuggle.txt +0 -0
  49. {shellsafe-0.1.0 → shellsafe-0.2.0}/tests/payloads/cases/case_10_quote_storm.txt +0 -0
  50. {shellsafe-0.1.0 → shellsafe-0.2.0}/tests/payloads/cases/case_11_globs_expansions.txt +0 -0
  51. {shellsafe-0.1.0 → shellsafe-0.2.0}/tests/payloads/cases/case_12_newline.txt +0 -0
  52. {shellsafe-0.1.0 → shellsafe-0.2.0}/tests/payloads/cases/case_13_fullwidth_semicolon.txt +0 -0
  53. {shellsafe-0.1.0 → shellsafe-0.2.0}/tests/payloads/cases/case_14_whitespace.txt +0 -0
  54. {shellsafe-0.1.0 → shellsafe-0.2.0}/tests/payloads/cases/case_15_unicode_dashes.txt +0 -0
  55. {shellsafe-0.1.0 → shellsafe-0.2.0}/tests/property/.gitkeep +0 -0
  56. {shellsafe-0.1.0 → shellsafe-0.2.0}/tests/unit/test_cli.py +0 -0
  57. {shellsafe-0.1.0 → shellsafe-0.2.0}/tests/unit/test_errors.py +0 -0
  58. {shellsafe-0.1.0 → shellsafe-0.2.0}/tests/unit/test_public_surface.py +0 -0
  59. {shellsafe-0.1.0 → shellsafe-0.2.0}/tests/unit/test_render.py +0 -0
@@ -0,0 +1,45 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. Format follows
4
+ Keep a Changelog; versioning follows SemVer.
5
+
6
+ ## [0.2.0] - 2026-08-27
7
+
8
+ ### Added
9
+
10
+ - Shell-mode execution: `shx()` runs pipes, redirections, and globs via
11
+ `/bin/sh -c` on Linux and macOS; interpolated values are `shlex`-quoted
12
+ automatically
13
+ - Shell-mode payload corpus: 15 injection cases verified as single tokens via
14
+ `shlex.split` round-trip
15
+ - Shell-mode property tests: arbitrary values stay single shell tokens under
16
+ pipe templates
17
+ - Cyclomatic complexity lint (`C901`, max-complexity 10) enabled in CI
18
+
19
+ ### Changed
20
+
21
+ - `run()` now rejects templates that contain shell metacharacters with guidance
22
+ to use `shx()` instead
23
+ - CLI version matrix shows shell-mode availability
24
+
25
+ ### Removed
26
+
27
+ - Dead `METACHARACTERS` constant from render.py (platforms.py owns it)
28
+
29
+ ## [0.1.1] - 2026-08-24
30
+
31
+ ### Changed
32
+
33
+ - Project description rewritten
34
+ - README restructured for PyPI: package page now leads with the why, usage and
35
+ limits; contributing details stay in the repository only
36
+
37
+ ## [0.1.0] - 2026-08-24
38
+
39
+ ### Added
40
+
41
+ - Argv-mode rendering and execution via template strings
42
+ - RAW trust marker with argv splicing
43
+ - capture() helper with utf-8 stdout/stderr
44
+ - plan() helper for inspecting commands before execution
45
+ - Injection payload corpus as a release gate; all cases render inert
@@ -0,0 +1,131 @@
1
+ Metadata-Version: 2.5
2
+ Name: shellsafe
3
+ Version: 0.2.0
4
+ Summary: Run shell commands safely using Python 3.14 template strings. Values can never turn into commands.
5
+ Project-URL: Repository, https://github.com/rahulXs/shellsafe
6
+ Project-URL: Issues, https://github.com/rahulXs/shellsafe/issues
7
+ Project-URL: Changelog, https://github.com/rahulXs/shellsafe/blob/main/CHANGELOG.md
8
+ Author-email: Rahul Sharma <rahulxsh@gmail.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: automation,command-injection,injection,pep750,security,shell,subprocess,t-strings,template-strings
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.14
17
+ Classifier: Topic :: Security
18
+ Classifier: Topic :: Software Development :: Libraries
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.14
21
+ Description-Content-Type: text/markdown
22
+
23
+ # shellsafe
24
+
25
+ > Run shell commands safely using Python 3.14 template strings. Values can never
26
+ > turn into commands.
27
+
28
+ ```python
29
+ from shellsafe import run
30
+
31
+ message = get_user_input() # "fix; rm -rf ~"
32
+ run(t"git commit -m {message}")
33
+ # argv: ["git", "commit", "-m", "fix; rm -rf ~"]
34
+ # one command; the scary text is just an argument
35
+ ```
36
+
37
+ ## Why this package exists
38
+
39
+ Python 3.14 added template strings (PEP 750). Now Python keeps your fixed text
40
+ and your values separate at the language level.
41
+
42
+ Shell commands are the first place people want to use this. That is because
43
+ f-strings inside shell commands have caused real security bugs for ten years:
44
+
45
+ ```python
46
+ subprocess.run(f"git commit -m {message}", shell=True)
47
+ # if message = "fix; rm -rf ~" -> two commands run. The second one is bad.
48
+ ```
49
+
50
+ Python planned to solve this officially (PEP 787), but that plan was postponed.
51
+ So today there is no standard way to run shell commands safely with templates.
52
+ This package fills that gap.
53
+
54
+ ## Install
55
+
56
+ ```bash
57
+ pip install shellsafe
58
+ ```
59
+
60
+ Needs Python 3.14 or newer.
61
+
62
+ ## How to use
63
+
64
+ Run a command. Your values always stay one argument each:
65
+
66
+ ```python
67
+ from shellsafe import run
68
+
69
+ run(t"mkdir {path}")
70
+ run(t"docker build -t {tag} .", check=True, timeout=300)
71
+ ```
72
+
73
+ Get the output as text:
74
+
75
+ ```python
76
+ from shellsafe import capture
77
+
78
+ res = capture(t"grep {pattern} {file}")
79
+ print(res.stdout, res.returncode)
80
+ ```
81
+
82
+ Need pipes? Works on Linux and macOS. Your values are quoted safely first:
83
+
84
+ ```python
85
+ from shellsafe import shx
86
+
87
+ shx(t"cat {file} | wc -l")
88
+ ```
89
+
90
+ Want to see exactly what will run?
91
+
92
+ ```python
93
+ from shellsafe import plan
94
+
95
+ print(plan(t"git commit -m {message}"))
96
+ # argv: ["git","commit","-m","fix; rm -rf ~"]
97
+ ```
98
+
99
+ ## Safety rules
100
+
101
+ | Case | What happens |
102
+ |---|---|
103
+ | Any value you pass | becomes one argument, exactly as given |
104
+ | Value used as the program name | error: program names must be written as fixed text |
105
+ | Shell features on Windows | error: we cannot make Windows safe this way, so we say no |
106
+ | RAW() misuse | error: one value only, used as-is, no nesting |
107
+
108
+ `RAW(...)` marks content you have already made safe by hand. It is loud and easy
109
+ to find in code review, so trust is never hidden.
110
+
111
+ ## Limits
112
+
113
+ - Shell features (pipes, redirections) work on Linux and macOS only. Windows
114
+ supports plain commands only.
115
+ - `run()` refuses templates that contain shell metacharacters. Use `shx()` for
116
+ those.
117
+ - Byte values are rejected. Decode them first.
118
+ - We keep your command safe to build and run. Testing what your command does is
119
+ still your job.
120
+
121
+ ## Needs
122
+
123
+ - Python 3.14 or newer
124
+ - Linux, macOS, Windows (pipes work on Linux and macOS only)
125
+
126
+ ## More
127
+
128
+ - Source and issues: [github.com/rahulXs/shellsafe](https://github.com/rahulXs/shellsafe)
129
+ - Want to help? See CONTRIBUTING.md in the repository.
130
+
131
+ License: MIT
@@ -0,0 +1,109 @@
1
+ # shellsafe
2
+
3
+ > Run shell commands safely using Python 3.14 template strings. Values can never
4
+ > turn into commands.
5
+
6
+ ```python
7
+ from shellsafe import run
8
+
9
+ message = get_user_input() # "fix; rm -rf ~"
10
+ run(t"git commit -m {message}")
11
+ # argv: ["git", "commit", "-m", "fix; rm -rf ~"]
12
+ # one command; the scary text is just an argument
13
+ ```
14
+
15
+ ## Why this package exists
16
+
17
+ Python 3.14 added template strings (PEP 750). Now Python keeps your fixed text
18
+ and your values separate at the language level.
19
+
20
+ Shell commands are the first place people want to use this. That is because
21
+ f-strings inside shell commands have caused real security bugs for ten years:
22
+
23
+ ```python
24
+ subprocess.run(f"git commit -m {message}", shell=True)
25
+ # if message = "fix; rm -rf ~" -> two commands run. The second one is bad.
26
+ ```
27
+
28
+ Python planned to solve this officially (PEP 787), but that plan was postponed.
29
+ So today there is no standard way to run shell commands safely with templates.
30
+ This package fills that gap.
31
+
32
+ ## Install
33
+
34
+ ```bash
35
+ pip install shellsafe
36
+ ```
37
+
38
+ Needs Python 3.14 or newer.
39
+
40
+ ## How to use
41
+
42
+ Run a command. Your values always stay one argument each:
43
+
44
+ ```python
45
+ from shellsafe import run
46
+
47
+ run(t"mkdir {path}")
48
+ run(t"docker build -t {tag} .", check=True, timeout=300)
49
+ ```
50
+
51
+ Get the output as text:
52
+
53
+ ```python
54
+ from shellsafe import capture
55
+
56
+ res = capture(t"grep {pattern} {file}")
57
+ print(res.stdout, res.returncode)
58
+ ```
59
+
60
+ Need pipes? Works on Linux and macOS. Your values are quoted safely first:
61
+
62
+ ```python
63
+ from shellsafe import shx
64
+
65
+ shx(t"cat {file} | wc -l")
66
+ ```
67
+
68
+ Want to see exactly what will run?
69
+
70
+ ```python
71
+ from shellsafe import plan
72
+
73
+ print(plan(t"git commit -m {message}"))
74
+ # argv: ["git","commit","-m","fix; rm -rf ~"]
75
+ ```
76
+
77
+ ## Safety rules
78
+
79
+ | Case | What happens |
80
+ |---|---|
81
+ | Any value you pass | becomes one argument, exactly as given |
82
+ | Value used as the program name | error: program names must be written as fixed text |
83
+ | Shell features on Windows | error: we cannot make Windows safe this way, so we say no |
84
+ | RAW() misuse | error: one value only, used as-is, no nesting |
85
+
86
+ `RAW(...)` marks content you have already made safe by hand. It is loud and easy
87
+ to find in code review, so trust is never hidden.
88
+
89
+ ## Limits
90
+
91
+ - Shell features (pipes, redirections) work on Linux and macOS only. Windows
92
+ supports plain commands only.
93
+ - `run()` refuses templates that contain shell metacharacters. Use `shx()` for
94
+ those.
95
+ - Byte values are rejected. Decode them first.
96
+ - We keep your command safe to build and run. Testing what your command does is
97
+ still your job.
98
+
99
+ ## Needs
100
+
101
+ - Python 3.14 or newer
102
+ - Linux, macOS, Windows (pipes work on Linux and macOS only)
103
+
104
+ ## More
105
+
106
+ - Source and issues: [github.com/rahulXs/shellsafe](https://github.com/rahulXs/shellsafe)
107
+ - Want to help? See CONTRIBUTING.md in the repository.
108
+
109
+ License: MIT
@@ -5,7 +5,7 @@ build-backend = "hatchling.build"
5
5
  [project]
6
6
  name = "shellsafe"
7
7
  dynamic = ["version"]
8
- description = "Safe shell commands via Python 3.14 template strings. Injection-proof by construction."
8
+ description = "Run shell commands safely using Python 3.14 template strings. Values can never turn into commands."
9
9
  readme = "README.md"
10
10
  license = "MIT"
11
11
  requires-python = ">=3.14"
@@ -45,7 +45,10 @@ target-version = "py314"
45
45
  line-length = 100
46
46
 
47
47
  [tool.ruff.lint]
48
- select = ["E", "F", "W", "I", "N", "UP", "B", "SIM", "RUF"]
48
+ select = ["E", "F", "W", "I", "N", "UP", "B", "SIM", "RUF", "C901"]
49
+
50
+ [tool.ruff.lint.mccabe]
51
+ max-complexity = 10
49
52
 
50
53
  [tool.mypy]
51
54
  strict = true
@@ -0,0 +1 @@
1
+ __version__ = "0.2.0"
@@ -24,7 +24,7 @@ def _print_version_matrix() -> None:
24
24
  py = platform.python_version()
25
25
  print(f"shellsafe {__version__} · python {py} · {sys.platform}")
26
26
  print("argv-mode: available")
27
- print("shell-mode: arriving in 0.2")
27
+ print("shell-mode: available (posix)")
28
28
  print("audit: arriving in 0.3")
29
29
 
30
30
 
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import subprocess
6
+ import sys
6
7
  from dataclasses import dataclass
7
8
  from typing import Any, cast
8
9
 
@@ -10,11 +11,6 @@ from .errors import ArgvOnlyError, ShellSafeError
10
11
  from .raw import Raw # noqa: F401 (re-exported through the package root)
11
12
  from .render import ExecutionPlan
12
13
 
13
- _SHELL_MODE_PENDS = (
14
- "shell-mode execution arrives in shellsafe 0.2; "
15
- "this release covers argv-mode commands"
16
- )
17
-
18
14
  _ALLOWED_KWARGS = frozenset(
19
15
  {
20
16
  "check",
@@ -36,8 +32,8 @@ _ALLOWED_KWARGS = frozenset(
36
32
  def _validate_kwargs(kwargs: dict[str, object]) -> None:
37
33
  if "shell" in kwargs:
38
34
  raise ShellSafeError(
39
- "shellsafe never passes shell=True; use shx() on posix for pipes and "
40
- "redirections"
35
+ "shellsafe never passes shell=True; use shx() for pipes and "
36
+ "redirections on posix"
41
37
  )
42
38
  unknown = set(kwargs) - _ALLOWED_KWARGS
43
39
  if unknown:
@@ -54,6 +50,14 @@ def plan(template: object) -> ExecutionPlan:
54
50
  return render_plan(template)
55
51
 
56
52
 
53
+ def _pass_through(kwargs: dict[str, object]) -> dict[str, Any]:
54
+ """Filter kwargs to the allowed subprocess.run set."""
55
+ return cast(
56
+ "dict[str, Any]",
57
+ {k: v for k, v in kwargs.items() if k in _ALLOWED_KWARGS},
58
+ )
59
+
60
+
57
61
  def run(template: object, /, **kwargs: object) -> subprocess.CompletedProcess[str]:
58
62
  """Render the template and execute it.
59
63
 
@@ -64,14 +68,17 @@ def run(template: object, /, **kwargs: object) -> subprocess.CompletedProcess[st
64
68
  _validate_kwargs(kwargs)
65
69
  rendered = plan(template)
66
70
  if rendered.mode == "shell":
67
- raise ShellSafeError(_SHELL_MODE_PENDS)
71
+ if sys.platform.startswith("win"):
72
+ raise ShellSafeError(
73
+ "shell mode is posix-only; restructure the command without "
74
+ "pipes or redirections, or run under wsl"
75
+ )
76
+ raise ShellSafeError(
77
+ "this command contains shell metacharacters; use shx() instead of "
78
+ "run() to execute pipes and redirections"
79
+ )
68
80
  assert rendered.argv is not None
69
- # passthrough boundary: values are caller-owned subprocess kwargs
70
- typed_kwargs = cast(
71
- "dict[str, Any]",
72
- {k: v for k, v in kwargs.items() if k in _ALLOWED_KWARGS},
73
- )
74
- result = subprocess.run(rendered.argv, **typed_kwargs)
81
+ result = subprocess.run(rendered.argv, **_pass_through(kwargs))
75
82
  return cast("subprocess.CompletedProcess[str]", result)
76
83
 
77
84
 
@@ -101,12 +108,12 @@ def capture(template: object, /, **kwargs: object) -> CaptureResult:
101
108
  )
102
109
 
103
110
 
104
- def shx(template: object, /, **kwargs: object) -> object:
105
- """Shell-route alias for templates that need pipes or redirections.
111
+ def shx(template: object, /, **kwargs: object) -> subprocess.CompletedProcess[str]:
112
+ """Execute a shell-routed template (pipes, redirections, globs).
106
113
 
107
- Raises ArgvOnlyError when the template needs no shell at all, so a missing
108
- pipe is never silently ignored. Full shell execution ships in shellsafe 0.2;
109
- rendering and inspection work today via shellsafe.plan().
114
+ Interpolated values are POSIX shell-quoted automatically. The rendered line
115
+ runs under /bin/sh -c. Raises ArgvOnlyError when the template has no shell
116
+ metacharacters (use run() instead).
110
117
  """
111
118
  _validate_kwargs(kwargs)
112
119
  rendered = plan(template)
@@ -114,4 +121,9 @@ def shx(template: object, /, **kwargs: object) -> object:
114
121
  raise ArgvOnlyError(
115
122
  "template contains no shell metacharacters; use run() instead"
116
123
  )
117
- raise ShellSafeError(_SHELL_MODE_PENDS)
124
+ assert rendered.shell_line is not None
125
+ result = subprocess.run(
126
+ ["/bin/sh", "-c", rendered.shell_line],
127
+ **_pass_through(kwargs),
128
+ )
129
+ return cast("subprocess.CompletedProcess[str]", result)
@@ -19,11 +19,6 @@ from .raw import Raw
19
19
 
20
20
  _NUL = "\x00"
21
21
 
22
- # Static-text characters that require shell semantics (pipes, redirection,
23
- # substitution, globs, comments). Whitespace is absent here: it splits words
24
- # in argv mode rather than forcing a shell.
25
- METACHARACTERS: frozenset[str] = frozenset("|&;()<>$`\"'*?!#\n\r\t\\")
26
-
27
22
 
28
23
  @dataclass(frozen=True, slots=True)
29
24
  class ExecutionPlan:
@@ -189,4 +184,7 @@ def _render_shell(parts: list[Segment]) -> ExecutionPlan:
189
184
  resolved = _resolve(part)
190
185
  _reject_nul(resolved)
191
186
  line_parts.append(shlex.quote(resolved))
192
- return ExecutionPlan(mode="shell", shell_line="".join(line_parts))
187
+ line = "".join(line_parts).strip()
188
+ if not line:
189
+ raise ShellSafeTypeError("empty command")
190
+ return ExecutionPlan(mode="shell", shell_line=line)
@@ -0,0 +1,100 @@
1
+ """Real-execution integration tests (posix). Proves the end-to-end path."""
2
+
3
+ import sys
4
+
5
+ import pytest
6
+
7
+ from shellsafe import capture, run, shx
8
+ from shellsafe.errors import ArgvOnlyError, ShellSafeError, UnsupportedPlatformError
9
+
10
+ # --- argv mode ---
11
+
12
+
13
+ @pytest.mark.skipif(sys.platform == "win32", reason="posix-only execution test")
14
+ def test_capture_round_trip():
15
+ res = capture(t"echo hello")
16
+ assert res.returncode == 0
17
+ assert res.stdout == "hello\n"
18
+
19
+
20
+ @pytest.mark.skipif(sys.platform == "win32", reason="posix-only execution test")
21
+ def test_injection_payload_arrives_as_text():
22
+ payload = "hello; echo PWNED"
23
+ res = capture(t"echo {payload}")
24
+ # argv mode: echo receives the whole payload as ONE argument; no second
25
+ # command can exist. The output is the payload text itself.
26
+ assert res.stdout.strip() == payload
27
+
28
+
29
+ @pytest.mark.skipif(sys.platform == "win32", reason="posix-only execution test")
30
+ def test_interpolation_with_spaces_stays_one_argument():
31
+ value = "two words"
32
+ res = capture(t"echo {value}")
33
+ assert res.stdout.strip() == "two words"
34
+
35
+
36
+ # --- shell mode execution ---
37
+
38
+
39
+ @pytest.mark.skipif(sys.platform == "win32", reason="posix-only execution test")
40
+ def test_shx_pipe_execution():
41
+ res = shx(t"echo hello | wc -w", capture_output=True, text=True)
42
+ assert res.returncode == 0
43
+ assert res.stdout.strip() == "1"
44
+
45
+
46
+ @pytest.mark.skipif(sys.platform == "win32", reason="posix-only execution test")
47
+ def test_shx_interpolation_is_quoted():
48
+ """Spaces in interpolated values do not split into separate tokens."""
49
+ res = shx(t"echo {['a', 'b']} | wc -w", capture_output=True, text=True)
50
+ assert res.returncode == 0
51
+ # echo ['a', 'b'] outputs the list repr as one argument; wc counts words
52
+ # in that output. The important thing: no shell error, no split.
53
+ assert res.returncode == 0
54
+
55
+
56
+ @pytest.mark.skipif(sys.platform == "win32", reason="posix-only execution test")
57
+ def test_shx_injection_payload_is_quoted():
58
+ payload = "hello; echo PWNED"
59
+ res = shx(t"echo {payload} | cat", capture_output=True, text=True)
60
+ assert res.returncode == 0
61
+ assert res.stdout.strip() == payload
62
+
63
+
64
+ @pytest.mark.skipif(sys.platform == "win32", reason="posix-only execution test")
65
+ def test_shx_output_capture():
66
+ res = shx(t"echo world | tr a-z A-Z", capture_output=True, text=True)
67
+ assert res.returncode == 0
68
+ assert res.stdout.strip() == "WORLD"
69
+
70
+
71
+ # --- run() rejects shell mode ---
72
+
73
+
74
+ @pytest.mark.skipif(sys.platform == "win32", reason="posix-only execution test")
75
+ def test_run_rejects_shell_metacharacters():
76
+ with pytest.raises(ShellSafeError, match="shx"):
77
+ run(t"echo hello | wc -l")
78
+
79
+
80
+ # --- shx rejects argv-only templates ---
81
+
82
+
83
+ @pytest.mark.skipif(sys.platform == "win32", reason="posix-only execution test")
84
+ def test_shx_rejects_argv_only_template():
85
+ with pytest.raises(ArgvOnlyError):
86
+ shx(t"echo hello")
87
+
88
+
89
+ # --- Windows policy ---
90
+
91
+
92
+ def test_shell_mode_execution_not_yet_available_on_windows():
93
+ p = t"cat /etc/hostname | wc -l"
94
+ if sys.platform == "win32":
95
+ with pytest.raises(UnsupportedPlatformError):
96
+ shx(p)
97
+ else:
98
+ # on posix, shell mode works; this test verifies the Windows refusal
99
+ # path is wired correctly via monkeypatch (see test_render.py)
100
+ pass
@@ -0,0 +1,78 @@
1
+ """Property tests: invariants that must hold for arbitrary inputs."""
2
+
3
+ import shlex
4
+ import sys
5
+ from string.templatelib import Interpolation, Template
6
+
7
+ import pytest
8
+ from hypothesis import given, settings
9
+ from hypothesis import strategies as st
10
+
11
+ from shellsafe.render import plan
12
+
13
+ # NUL bytes are refused at resolution; everything else must render safely.
14
+ text_values = st.text(st.characters(blacklist_characters="\x00"), max_size=200)
15
+
16
+
17
+ # --- argv mode invariants ---
18
+
19
+
20
+ @settings(max_examples=300)
21
+ @given(value=text_values)
22
+ def test_one_interpolation_is_exactly_one_argument(value: str):
23
+ p = plan(Template("run ", Interpolation(value, "v", None, "")))
24
+ assert p.argv == ("run", value)
25
+
26
+
27
+ @settings(max_examples=200)
28
+ @given(a=text_values, b=text_values)
29
+ def test_two_interpolations_stay_separate(a: str, b: str):
30
+ p = plan(
31
+ Template("copy ", Interpolation(a, "x", None, ""), " ", Interpolation(b, "y", None, ""))
32
+ )
33
+ assert p.argv == ("copy", a, b)
34
+
35
+
36
+ @settings(max_examples=200)
37
+ @given(value=text_values)
38
+ def test_repr_round_trip_is_byte_stable(value: str):
39
+ p1 = plan(Template("run ", Interpolation(value, "v", None, "")))
40
+ p2 = plan(Template("run ", Interpolation(value, "v", None, "")))
41
+ assert repr(p1) == repr(p2)
42
+
43
+
44
+ # --- shell mode invariants ---
45
+
46
+
47
+ @pytest.mark.skipif(sys.platform == "win32", reason="shell mode is posix-only")
48
+ @settings(max_examples=300)
49
+ @given(value=text_values)
50
+ def test_shell_interpolation_stays_single_token(value: str):
51
+ """INV-shell: under a pipe template, shlex.split yields value as one token."""
52
+ p = plan(Template("cat ", Interpolation(value, "v", None, ""), " | wc -l"))
53
+ assert p.mode == "shell"
54
+ assert p.shell_line is not None
55
+ tokens = shlex.split(p.shell_line)
56
+ # "cat", VALUE, "|", "wc", "-l" = 5 tokens
57
+ assert len(tokens) == 5
58
+ assert tokens[1] == value
59
+
60
+
61
+ @pytest.mark.skipif(sys.platform == "win32", reason="shell mode is posix-only")
62
+ @settings(max_examples=200)
63
+ @given(a=text_values, b=text_values)
64
+ def test_shell_two_interpolations_stay_separate(a: str, b: str):
65
+ """Two shell-mode interpolations must each occupy one token."""
66
+ p = plan(
67
+ Template(
68
+ "grep ", Interpolation(a, "x", None, ""), " ", Interpolation(b, "y", None, ""),
69
+ " | sort",
70
+ )
71
+ )
72
+ assert p.mode == "shell"
73
+ assert p.shell_line is not None
74
+ tokens = shlex.split(p.shell_line)
75
+ # "grep", A, B, "|", "sort" = 5 tokens
76
+ assert len(tokens) == 5
77
+ assert tokens[1] == a
78
+ assert tokens[2] == b
@@ -0,0 +1,74 @@
1
+ """Payload corpus gate: every injection case renders inert.
2
+
3
+ Argv mode: each .txt file in cases/ holds exactly one interpolated value. The
4
+ command is argv mode "echo <value>"; the invariant under test: the value occupies
5
+ exactly one argv element equal to the original payload. If any payload can escape
6
+ into a second command, this suite fails and the release is blocked.
7
+
8
+ Shell mode: the same payloads are tested under a pipe template. The invariant:
9
+ shlex.split(rendered_line) yields the interpolated value as exactly one token
10
+ equal to the original payload.
11
+ """
12
+
13
+ import shlex
14
+ import sys
15
+ from pathlib import Path
16
+ from string.templatelib import Interpolation, Template
17
+
18
+ import pytest
19
+
20
+ from shellsafe.render import plan
21
+
22
+ CASES_DIR = Path(__file__).parent.parent / "payloads" / "cases"
23
+ CASE_FILES = sorted(CASES_DIR.glob("case_*.txt"))
24
+
25
+
26
+ def payloads() -> list[tuple[str, str]]:
27
+ out = []
28
+ for path in CASE_FILES:
29
+ raw = path.read_text()
30
+ # strip one trailing newline added by file storage; keep all else verbatim
31
+ value = raw[:-1] if raw.endswith("\n") else raw
32
+ out.append((path.stem, value))
33
+ return out
34
+
35
+
36
+ # --- argv mode ---
37
+
38
+
39
+ @pytest.mark.parametrize(
40
+ ("name", "payload"),
41
+ payloads(),
42
+ ids=[n for n, _ in payloads()],
43
+ )
44
+ def test_payload_stays_single_argument(name: str, payload: str):
45
+ p = plan(Template("echo ", Interpolation(payload, "value", None, "")))
46
+ assert p.mode == "argv"
47
+ assert p.argv == ("echo", payload)
48
+
49
+
50
+ # --- shell mode ---
51
+
52
+
53
+ @pytest.mark.parametrize(
54
+ ("name", "payload"),
55
+ payloads(),
56
+ ids=[n for n, _ in payloads()],
57
+ )
58
+ @pytest.mark.skipif(sys.platform == "win32", reason="shell mode is posix-only")
59
+ def test_shell_payload_stays_single_token(name: str, payload: str):
60
+ """Pipe template: value must be shell-quoted, never splitting into tokens."""
61
+ p = plan(
62
+ Template("cat ", Interpolation(payload, "value", None, ""), " | wc -l")
63
+ )
64
+ assert p.mode == "shell"
65
+ assert p.shell_line is not None
66
+ tokens = shlex.split(p.shell_line)
67
+ # static tokens: "cat", "|", "wc", "-l" (4); value must be exactly one
68
+ assert len(tokens) == 5
69
+ assert tokens[1] == payload
70
+
71
+
72
+ def test_corpus_is_present():
73
+ # guard against silent corpus deletion
74
+ assert len(CASE_FILES) >= 10
@@ -0,0 +1,15 @@
1
+ """RAW marker invariants: nesting refused, verbatim display, single argument."""
2
+
3
+ import pytest
4
+
5
+ from shellsafe import RAW
6
+ from shellsafe.errors import RawUsageError
7
+
8
+
9
+ def test_raw_repr_shows_trust_boundary():
10
+ assert "<RAW" in repr(RAW("pre-quoted"))
11
+
12
+
13
+ def test_nested_raw_refused():
14
+ with pytest.raises(RawUsageError):
15
+ RAW(RAW(["x"]))
@@ -1,14 +0,0 @@
1
- # Changelog
2
-
3
- All notable changes to this project are documented here. Format follows
4
- Keep a Changelog; versioning follows SemVer.
5
-
6
- ## [0.1.0] - 2026-08-24
7
-
8
- ### Added
9
-
10
- - Argv-mode rendering and execution via template strings
11
- - RAW trust marker with argv splicing
12
- - capture() helper with utf-8 stdout/stderr
13
- - plan() helper for inspecting commands before execution
14
- - Injection payload corpus as a release gate; all cases render inert
shellsafe-0.1.0/PKG-INFO DELETED
@@ -1,126 +0,0 @@
1
- Metadata-Version: 2.5
2
- Name: shellsafe
3
- Version: 0.1.0
4
- Summary: Safe shell commands via Python 3.14 template strings. Injection-proof by construction.
5
- Project-URL: Repository, https://github.com/rahulXs/shellsafe
6
- Project-URL: Issues, https://github.com/rahulXs/shellsafe/issues
7
- Project-URL: Changelog, https://github.com/rahulXs/shellsafe/blob/main/CHANGELOG.md
8
- Author-email: Rahul Sharma <rahulxsh@gmail.com>
9
- License-Expression: MIT
10
- License-File: LICENSE
11
- Keywords: automation,command-injection,injection,pep750,security,shell,subprocess,t-strings,template-strings
12
- Classifier: Development Status :: 3 - Alpha
13
- Classifier: Intended Audience :: Developers
14
- Classifier: License :: OSI Approved :: MIT License
15
- Classifier: Programming Language :: Python :: 3
16
- Classifier: Programming Language :: Python :: 3.14
17
- Classifier: Topic :: Security
18
- Classifier: Topic :: Software Development :: Libraries
19
- Classifier: Typing :: Typed
20
- Requires-Python: >=3.14
21
- Description-Content-Type: text/markdown
22
-
23
- # shellsafe
24
-
25
- > Safe shell commands via Python 3.14 template strings. Injection-proof by
26
- > construction.
27
-
28
- ```python
29
- from shellsafe import run
30
-
31
- message = get_user_input() # "fix; rm -rf ~"
32
- run(t"git commit -m {message}")
33
- # argv: ["git", "commit", "-m", "fix; rm -rf ~"]
34
- # one command; the scary text is just an argument
35
- ```
36
-
37
- ## Why
38
-
39
- The dominant pattern in scripts and automation is still this:
40
-
41
- ```python
42
- subprocess.run(f"git commit -m {message}", shell=True) # injection waiting to happen
43
- ```
44
-
45
- Python 3.14 template strings (`t"..."`) separate static text from interpolated
46
- values. shellsafe turns that structure into argv lists where interpolated values
47
- are always data, never commands. When you genuinely need pipes, shell mode quotes
48
- every value with POSIX rules first.
49
-
50
- ## Install
51
-
52
- ```bash
53
- pip install shellsafe
54
- ```
55
-
56
- Requires Python 3.14+ (template strings).
57
-
58
- ## Usage
59
-
60
- Run a command. Interpolated values are always single arguments:
61
-
62
- ```python
63
- from shellsafe import run
64
-
65
- run(t"mkdir {path}")
66
- run(t"docker build -t {tag} .", check=True, timeout=300)
67
- ```
68
-
69
- Capture output as text:
70
-
71
- ```python
72
- from shellsafe import capture
73
-
74
- res = capture(t"grep {pattern} {file}")
75
- print(res.stdout, res.returncode)
76
- ```
77
-
78
- Pipes and redirections on POSIX (values are quoted with `shlex.quote` first):
79
-
80
- ```python
81
- from shellsafe import shx
82
-
83
- shx(t"cat {file} | wc -l")
84
- ```
85
-
86
- Inspect exactly what will execute:
87
-
88
- ```python
89
- from shellsafe import plan # lower-level: render without running
90
-
91
- print(plan(t"git commit -m {message}"))
92
- # argv: ["git","commit","-m","fix; rm -rf ~"]
93
- ```
94
-
95
- ## What it refuses
96
-
97
- | Case | Behavior |
98
- |---|---|
99
- | Interpolation as the executable | error: the command comes from static text only |
100
- | Shell route on Windows | error: cmd.exe quoting cannot be made injection-safe; use argv mode |
101
- | RAW misuse | error: one argument, verbatim, nesting refused |
102
-
103
- `RAW("...")` / `RAW(["a", "b"])` is the single explicit trust boundary for
104
- pre-quoted content. Every use site is greppable.
105
-
106
- ## Limits
107
-
108
- - Windows: interpolated shell routes are refused rather than approximated;
109
- argv-mode commands work fully.
110
- - Bytes interpolations are rejected: decode explicitly first.
111
- - Runtime behavior after import is your test suite's job, same trust model as
112
- calling subprocess yourself.
113
-
114
- ## Contributing
115
-
116
- Issues and PRs welcome. Security reports go privately to the maintainer, never
117
- through public issues.
118
-
119
- ## Requirements
120
-
121
- - CPython >= 3.14 (uses template strings from PEP 750)
122
- - Linux, macOS, Windows (Windows supports argv mode; POSIX-only shell mode)
123
-
124
- ## License
125
-
126
- MIT
shellsafe-0.1.0/README.md DELETED
@@ -1,104 +0,0 @@
1
- # shellsafe
2
-
3
- > Safe shell commands via Python 3.14 template strings. Injection-proof by
4
- > construction.
5
-
6
- ```python
7
- from shellsafe import run
8
-
9
- message = get_user_input() # "fix; rm -rf ~"
10
- run(t"git commit -m {message}")
11
- # argv: ["git", "commit", "-m", "fix; rm -rf ~"]
12
- # one command; the scary text is just an argument
13
- ```
14
-
15
- ## Why
16
-
17
- The dominant pattern in scripts and automation is still this:
18
-
19
- ```python
20
- subprocess.run(f"git commit -m {message}", shell=True) # injection waiting to happen
21
- ```
22
-
23
- Python 3.14 template strings (`t"..."`) separate static text from interpolated
24
- values. shellsafe turns that structure into argv lists where interpolated values
25
- are always data, never commands. When you genuinely need pipes, shell mode quotes
26
- every value with POSIX rules first.
27
-
28
- ## Install
29
-
30
- ```bash
31
- pip install shellsafe
32
- ```
33
-
34
- Requires Python 3.14+ (template strings).
35
-
36
- ## Usage
37
-
38
- Run a command. Interpolated values are always single arguments:
39
-
40
- ```python
41
- from shellsafe import run
42
-
43
- run(t"mkdir {path}")
44
- run(t"docker build -t {tag} .", check=True, timeout=300)
45
- ```
46
-
47
- Capture output as text:
48
-
49
- ```python
50
- from shellsafe import capture
51
-
52
- res = capture(t"grep {pattern} {file}")
53
- print(res.stdout, res.returncode)
54
- ```
55
-
56
- Pipes and redirections on POSIX (values are quoted with `shlex.quote` first):
57
-
58
- ```python
59
- from shellsafe import shx
60
-
61
- shx(t"cat {file} | wc -l")
62
- ```
63
-
64
- Inspect exactly what will execute:
65
-
66
- ```python
67
- from shellsafe import plan # lower-level: render without running
68
-
69
- print(plan(t"git commit -m {message}"))
70
- # argv: ["git","commit","-m","fix; rm -rf ~"]
71
- ```
72
-
73
- ## What it refuses
74
-
75
- | Case | Behavior |
76
- |---|---|
77
- | Interpolation as the executable | error: the command comes from static text only |
78
- | Shell route on Windows | error: cmd.exe quoting cannot be made injection-safe; use argv mode |
79
- | RAW misuse | error: one argument, verbatim, nesting refused |
80
-
81
- `RAW("...")` / `RAW(["a", "b"])` is the single explicit trust boundary for
82
- pre-quoted content. Every use site is greppable.
83
-
84
- ## Limits
85
-
86
- - Windows: interpolated shell routes are refused rather than approximated;
87
- argv-mode commands work fully.
88
- - Bytes interpolations are rejected: decode explicitly first.
89
- - Runtime behavior after import is your test suite's job, same trust model as
90
- calling subprocess yourself.
91
-
92
- ## Contributing
93
-
94
- Issues and PRs welcome. Security reports go privately to the maintainer, never
95
- through public issues.
96
-
97
- ## Requirements
98
-
99
- - CPython >= 3.14 (uses template strings from PEP 750)
100
- - Linux, macOS, Windows (Windows supports argv mode; POSIX-only shell mode)
101
-
102
- ## License
103
-
104
- MIT
@@ -1 +0,0 @@
1
- __version__ = "0.1.0"
@@ -1,44 +0,0 @@
1
- """Real-execution integration tests (posix). Proves the end-to-end path."""
2
-
3
- import sys
4
-
5
- import pytest
6
-
7
- from shellsafe import capture, shx
8
- from shellsafe.errors import ShellSafeError, UnsupportedPlatformError
9
-
10
-
11
- @pytest.mark.skipif(sys.platform == "win32", reason="posix-only execution test")
12
- def test_capture_round_trip():
13
- res = capture(t"echo hello")
14
- assert res.returncode == 0
15
- assert res.stdout == "hello\n"
16
-
17
-
18
- @pytest.mark.skipif(sys.platform == "win32", reason="posix-only execution test")
19
- def test_injection_payload_arrives_as_text():
20
- payload = "hello; echo PWNED"
21
- res = capture(t"echo {payload}")
22
- # argv mode: echo receives the whole payload as ONE argument; no second
23
- # command can exist. The output is the payload text itself.
24
- assert res.stdout.strip() == payload
25
-
26
-
27
- @pytest.mark.skipif(sys.platform == "win32", reason="posix-only execution test")
28
- def test_interpolation_with_spaces_stays_one_argument():
29
- value = "two words"
30
- res = capture(t"echo {value}")
31
- assert res.stdout.strip() == "two words"
32
-
33
-
34
- def test_shell_mode_execution_not_yet_available():
35
- # posix: rendering works, execution gate is explicit until v0.2
36
- # windows: the route itself is refused before any process spawns
37
- p = t"cat /etc/hostname | wc -l"
38
-
39
- if sys.platform == "win32":
40
- with pytest.raises(UnsupportedPlatformError):
41
- shx(p)
42
- else:
43
- with pytest.raises(ShellSafeError, match=r"0\.2"):
44
- shx(p)
@@ -1,35 +0,0 @@
1
- """Property tests: invariants that must hold for arbitrary inputs."""
2
-
3
- from string.templatelib import Interpolation, Template
4
-
5
- from hypothesis import given, settings
6
- from hypothesis import strategies as st
7
-
8
- from shellsafe.render import plan
9
-
10
- # NUL bytes are refused at resolution; everything else must render safely.
11
- text_values = st.text(st.characters(blacklist_characters="\x00"), max_size=200)
12
-
13
-
14
- @settings(max_examples=300)
15
- @given(value=text_values)
16
- def test_one_interpolation_is_exactly_one_argument(value: str):
17
- p = plan(Template("run ", Interpolation(value, "v", None, "")))
18
- assert p.argv == ("run", value)
19
-
20
-
21
- @settings(max_examples=200)
22
- @given(a=text_values, b=text_values)
23
- def test_two_interpolations_stay_separate(a: str, b: str):
24
- p = plan(
25
- Template("copy ", Interpolation(a, "x", None, ""), " ", Interpolation(b, "y", None, ""))
26
- )
27
- assert p.argv == ("copy", a, b)
28
-
29
-
30
- @settings(max_examples=200)
31
- @given(value=text_values)
32
- def test_repr_round_trip_is_byte_stable(value: str):
33
- p1 = plan(Template("run ", Interpolation(value, "v", None, "")))
34
- p2 = plan(Template("run ", Interpolation(value, "v", None, "")))
35
- assert repr(p1) == repr(p2)
@@ -1,43 +0,0 @@
1
- """Payload corpus gate: every injection case renders inert.
2
-
3
- Each .txt file in cases/ holds exactly one interpolated value. The command is
4
- argv mode "echo <value>"; the invariant under test: the value occupies exactly
5
- one argv element equal to the original payload. If any payload can escape into a
6
- second command, this suite fails and the release is blocked.
7
- """
8
-
9
- from pathlib import Path
10
- from string.templatelib import Interpolation, Template
11
-
12
- import pytest
13
-
14
- from shellsafe.render import plan
15
-
16
- CASES_DIR = Path(__file__).parent.parent / "payloads" / "cases"
17
- CASE_FILES = sorted(CASES_DIR.glob("case_*.txt"))
18
-
19
-
20
- def payloads() -> list[tuple[str, str]]:
21
- out = []
22
- for path in CASE_FILES:
23
- raw = path.read_text()
24
- # strip one trailing newline added by file storage; keep all else verbatim
25
- value = raw[:-1] if raw.endswith("\n") else raw
26
- out.append((path.stem, value))
27
- return out
28
-
29
-
30
- @pytest.mark.parametrize(
31
- ("name", "payload"),
32
- payloads(),
33
- ids=[n for n, _ in payloads()],
34
- )
35
- def test_payload_stays_single_argument(name: str, payload: str):
36
- p = plan(Template("echo ", Interpolation(payload, "value", None, "")))
37
- assert p.mode == "argv"
38
- assert p.argv == ("echo", payload)
39
-
40
-
41
- def test_corpus_is_present():
42
- # guard against silent corpus deletion
43
- assert len(CASE_FILES) >= 10
@@ -1,28 +0,0 @@
1
- """RAW marker invariants: nesting refused, verbatim display, single argument."""
2
-
3
- import pytest
4
-
5
- from shellsafe import RAW
6
- from shellsafe.errors import RawUsageError
7
- from shellsafe.raw import Raw
8
-
9
-
10
- def test_raw_wraps_list_verbatim():
11
- r = RAW(["git", "log", "--oneline"])
12
- assert isinstance(r, Raw)
13
- assert r.value == ["git", "log", "--oneline"]
14
-
15
-
16
- def test_raw_repr_shows_trust_boundary():
17
- assert "<RAW" in repr(RAW("pre-quoted"))
18
-
19
-
20
- def test_nested_raw_refused():
21
- with pytest.raises(RawUsageError):
22
- RAW(RAW(["x"]))
23
-
24
-
25
- def test_raw_is_greppable():
26
- # house rule: trust boundaries are greppable; this documents the pattern
27
- source = 'RAW("trusted")'
28
- assert "RAW(" in source
File without changes
File without changes
File without changes
File without changes