shellsafe 0.3.4__tar.gz → 0.3.5__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.
- {shellsafe-0.3.4 → shellsafe-0.3.5}/CHANGELOG.md +18 -0
- {shellsafe-0.3.4 → shellsafe-0.3.5}/PKG-INFO +6 -4
- {shellsafe-0.3.4 → shellsafe-0.3.5}/README.md +4 -3
- {shellsafe-0.3.4 → shellsafe-0.3.5}/pyproject.toml +1 -0
- shellsafe-0.3.5/src/shellsafe/_version.py +1 -0
- {shellsafe-0.3.4 → shellsafe-0.3.5}/src/shellsafe/audit/scanner.py +56 -49
- {shellsafe-0.3.4 → shellsafe-0.3.5}/src/shellsafe/execute.py +27 -10
- {shellsafe-0.3.4 → shellsafe-0.3.5}/src/shellsafe/render.py +14 -4
- shellsafe-0.3.4/src/shellsafe/_version.py +0 -1
- {shellsafe-0.3.4 → shellsafe-0.3.5}/.github/workflows/ci.yml +0 -0
- {shellsafe-0.3.4 → shellsafe-0.3.5}/.github/workflows/release.yml +0 -0
- {shellsafe-0.3.4 → shellsafe-0.3.5}/.gitignore +0 -0
- {shellsafe-0.3.4 → shellsafe-0.3.5}/CONTRIBUTING.md +0 -0
- {shellsafe-0.3.4 → shellsafe-0.3.5}/LICENSE +0 -0
- {shellsafe-0.3.4 → shellsafe-0.3.5}/examples/demo.py +0 -0
- {shellsafe-0.3.4 → shellsafe-0.3.5}/src/shellsafe/__init__.py +0 -0
- {shellsafe-0.3.4 → shellsafe-0.3.5}/src/shellsafe/__main__.py +0 -0
- {shellsafe-0.3.4 → shellsafe-0.3.5}/src/shellsafe/audit/__init__.py +0 -0
- {shellsafe-0.3.4 → shellsafe-0.3.5}/src/shellsafe/cli.py +0 -0
- {shellsafe-0.3.4 → shellsafe-0.3.5}/src/shellsafe/errors.py +0 -0
- {shellsafe-0.3.4 → shellsafe-0.3.5}/src/shellsafe/exitcodes.py +0 -0
- {shellsafe-0.3.4 → shellsafe-0.3.5}/src/shellsafe/platforms.py +0 -0
- {shellsafe-0.3.4 → shellsafe-0.3.5}/src/shellsafe/py.typed +0 -0
- {shellsafe-0.3.4 → shellsafe-0.3.5}/src/shellsafe/raw.py +0 -0
- {shellsafe-0.3.4 → shellsafe-0.3.5}/tests/fixtures/audit/au001_sample.py +0 -0
- {shellsafe-0.3.4 → shellsafe-0.3.5}/tests/fixtures/audit/au002_sample.py +0 -0
- {shellsafe-0.3.4 → shellsafe-0.3.5}/tests/fixtures/audit/au003_sample.py +0 -0
- {shellsafe-0.3.4 → shellsafe-0.3.5}/tests/fixtures/audit/au004_sample.py +0 -0
- {shellsafe-0.3.4 → shellsafe-0.3.5}/tests/fixtures/audit/suppress_sample.py +0 -0
- {shellsafe-0.3.4 → shellsafe-0.3.5}/tests/golden/.gitkeep +0 -0
- {shellsafe-0.3.4 → shellsafe-0.3.5}/tests/integration/.gitkeep +0 -0
- {shellsafe-0.3.4 → shellsafe-0.3.5}/tests/integration/test_cli_audit.py +0 -0
- {shellsafe-0.3.4 → shellsafe-0.3.5}/tests/integration/test_exec_posix.py +0 -0
- {shellsafe-0.3.4 → shellsafe-0.3.5}/tests/payloads/cases/README.md +0 -0
- {shellsafe-0.3.4 → shellsafe-0.3.5}/tests/payloads/cases/case_01_semicolon.txt +0 -0
- {shellsafe-0.3.4 → shellsafe-0.3.5}/tests/payloads/cases/case_02_substitution.txt +0 -0
- {shellsafe-0.3.4 → shellsafe-0.3.5}/tests/payloads/cases/case_03_backticks.txt +0 -0
- {shellsafe-0.3.4 → shellsafe-0.3.5}/tests/payloads/cases/case_04_background_chain.txt +0 -0
- {shellsafe-0.3.4 → shellsafe-0.3.5}/tests/payloads/cases/case_05_or_chain.txt +0 -0
- {shellsafe-0.3.4 → shellsafe-0.3.5}/tests/payloads/cases/case_06_redirect_out.txt +0 -0
- {shellsafe-0.3.4 → shellsafe-0.3.5}/tests/payloads/cases/case_07_redirect_in.txt +0 -0
- {shellsafe-0.3.4 → shellsafe-0.3.5}/tests/payloads/cases/case_08_pipe_out.txt +0 -0
- {shellsafe-0.3.4 → shellsafe-0.3.5}/tests/payloads/cases/case_09_quote_smuggle.txt +0 -0
- {shellsafe-0.3.4 → shellsafe-0.3.5}/tests/payloads/cases/case_10_quote_storm.txt +0 -0
- {shellsafe-0.3.4 → shellsafe-0.3.5}/tests/payloads/cases/case_11_globs_expansions.txt +0 -0
- {shellsafe-0.3.4 → shellsafe-0.3.5}/tests/payloads/cases/case_12_newline.txt +0 -0
- {shellsafe-0.3.4 → shellsafe-0.3.5}/tests/payloads/cases/case_13_fullwidth_semicolon.txt +0 -0
- {shellsafe-0.3.4 → shellsafe-0.3.5}/tests/payloads/cases/case_14_whitespace.txt +0 -0
- {shellsafe-0.3.4 → shellsafe-0.3.5}/tests/payloads/cases/case_15_unicode_dashes.txt +0 -0
- {shellsafe-0.3.4 → shellsafe-0.3.5}/tests/property/.gitkeep +0 -0
- {shellsafe-0.3.4 → shellsafe-0.3.5}/tests/property/test_invariants.py +0 -0
- {shellsafe-0.3.4 → shellsafe-0.3.5}/tests/unit/test_audit.py +0 -0
- {shellsafe-0.3.4 → shellsafe-0.3.5}/tests/unit/test_cli.py +0 -0
- {shellsafe-0.3.4 → shellsafe-0.3.5}/tests/unit/test_errors.py +0 -0
- {shellsafe-0.3.4 → shellsafe-0.3.5}/tests/unit/test_payload_corpus.py +0 -0
- {shellsafe-0.3.4 → shellsafe-0.3.5}/tests/unit/test_public_surface.py +0 -0
- {shellsafe-0.3.4 → shellsafe-0.3.5}/tests/unit/test_raw.py +0 -0
- {shellsafe-0.3.4 → shellsafe-0.3.5}/tests/unit/test_render.py +0 -0
|
@@ -3,6 +3,24 @@
|
|
|
3
3
|
All notable changes to this project are documented here. Format follows
|
|
4
4
|
Keep a Changelog; versioning follows SemVer.
|
|
5
5
|
|
|
6
|
+
## [0.3.5] - 2026-09-11
|
|
7
|
+
|
|
8
|
+
### Fixed
|
|
9
|
+
|
|
10
|
+
- `capture()` no longer renders template twice
|
|
11
|
+
- `_resolve` always returns `str` (defense-in-depth)
|
|
12
|
+
- Suppression comment check extended to 5 lines back (was 1)
|
|
13
|
+
- AU010 now flags file-level suppressions without reasons
|
|
14
|
+
- Audit scanner follows symlinks
|
|
15
|
+
- Walrus operator (`:=`) tracked by AU003
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- Shell line length guard (128KB max)
|
|
20
|
+
- Suppression `reason:` is now case-insensitive
|
|
21
|
+
- `_apply_suppression` uses `dataclasses.replace` (internal cleanup)
|
|
22
|
+
- PEP 787 status updated to reflect ongoing deferral
|
|
23
|
+
|
|
6
24
|
## [0.3.4] - 2026-09-03
|
|
7
25
|
|
|
8
26
|
### Fixed
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: shellsafe
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.5
|
|
4
4
|
Summary: Run shell commands safely using Python 3.14 template strings. Values can never turn into commands.
|
|
5
5
|
Project-URL: Homepage, https://github.com/rahulXs/shellsafe
|
|
6
6
|
Project-URL: Repository, https://github.com/rahulXs/shellsafe
|
|
7
7
|
Project-URL: Issues, https://github.com/rahulXs/shellsafe/issues
|
|
8
8
|
Project-URL: Changelog, https://github.com/rahulXs/shellsafe/blob/main/CHANGELOG.md
|
|
9
|
+
Project-URL: Blog, https://rahulxs.github.io/shellsafe/
|
|
9
10
|
Author-email: Rahul Sharma <rahulxsh@gmail.com>
|
|
10
11
|
License-Expression: MIT
|
|
11
12
|
License-File: LICENSE
|
|
@@ -48,9 +49,9 @@ subprocess.run(f"git commit -m {message}", shell=True)
|
|
|
48
49
|
# if message = "fix; rm -rf ~" -> two commands run. The second one is bad.
|
|
49
50
|
```
|
|
50
51
|
|
|
51
|
-
Python planned to solve this officially (PEP 787), but that PEP
|
|
52
|
-
|
|
53
|
-
safely with templates. This package fills that gap.
|
|
52
|
+
Python planned to solve this officially (PEP 787), but that PEP is still deferred.
|
|
53
|
+
It is not in the Python 3.15 release candidates. So today there is no standard
|
|
54
|
+
way to run shell commands safely with templates. This package fills that gap.
|
|
54
55
|
|
|
55
56
|
## Install
|
|
56
57
|
|
|
@@ -162,6 +163,7 @@ shellsafe audit src/ --ignore AU004
|
|
|
162
163
|
## More
|
|
163
164
|
|
|
164
165
|
- Source and issues: [github.com/rahulXs/shellsafe](https://github.com/rahulXs/shellsafe)
|
|
166
|
+
- Blog: [rahulxs.github.io/shellsafe](https://rahulxs.github.io/shellsafe/)
|
|
165
167
|
- Want to help? See CONTRIBUTING.md in the repository.
|
|
166
168
|
|
|
167
169
|
License: MIT
|
|
@@ -25,9 +25,9 @@ subprocess.run(f"git commit -m {message}", shell=True)
|
|
|
25
25
|
# if message = "fix; rm -rf ~" -> two commands run. The second one is bad.
|
|
26
26
|
```
|
|
27
27
|
|
|
28
|
-
Python planned to solve this officially (PEP 787), but that PEP
|
|
29
|
-
|
|
30
|
-
safely with templates. This package fills that gap.
|
|
28
|
+
Python planned to solve this officially (PEP 787), but that PEP is still deferred.
|
|
29
|
+
It is not in the Python 3.15 release candidates. So today there is no standard
|
|
30
|
+
way to run shell commands safely with templates. This package fills that gap.
|
|
31
31
|
|
|
32
32
|
## Install
|
|
33
33
|
|
|
@@ -139,6 +139,7 @@ shellsafe audit src/ --ignore AU004
|
|
|
139
139
|
## More
|
|
140
140
|
|
|
141
141
|
- Source and issues: [github.com/rahulXs/shellsafe](https://github.com/rahulXs/shellsafe)
|
|
142
|
+
- Blog: [rahulxs.github.io/shellsafe](https://rahulxs.github.io/shellsafe/)
|
|
142
143
|
- Want to help? See CONTRIBUTING.md in the repository.
|
|
143
144
|
|
|
144
145
|
License: MIT
|
|
@@ -34,6 +34,7 @@ Homepage = "https://github.com/rahulXs/shellsafe"
|
|
|
34
34
|
Repository = "https://github.com/rahulXs/shellsafe"
|
|
35
35
|
Issues = "https://github.com/rahulXs/shellsafe/issues"
|
|
36
36
|
Changelog = "https://github.com/rahulXs/shellsafe/blob/main/CHANGELOG.md"
|
|
37
|
+
Blog = "https://rahulxs.github.io/shellsafe/"
|
|
37
38
|
|
|
38
39
|
[tool.hatch.version]
|
|
39
40
|
path = "src/shellsafe/_version.py"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.5"
|
|
@@ -7,7 +7,7 @@ import ast
|
|
|
7
7
|
import json
|
|
8
8
|
import sys
|
|
9
9
|
import time
|
|
10
|
-
from dataclasses import dataclass, field
|
|
10
|
+
from dataclasses import dataclass, field, replace
|
|
11
11
|
from pathlib import Path
|
|
12
12
|
from typing import Any
|
|
13
13
|
|
|
@@ -158,13 +158,15 @@ def _track_dynamic_vars(tree: ast.Module):
|
|
|
158
158
|
"""Find variables assigned dynamic string expressions."""
|
|
159
159
|
dynamic = set()
|
|
160
160
|
for node in ast.walk(tree):
|
|
161
|
-
if
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
161
|
+
if isinstance(node, ast.Assign):
|
|
162
|
+
if not _is_dynamic_string(node.value):
|
|
163
|
+
continue
|
|
164
|
+
for target in node.targets:
|
|
165
|
+
if isinstance(target, ast.Name):
|
|
166
|
+
dynamic.add(target.id)
|
|
167
|
+
elif isinstance(node, ast.NamedExpr):
|
|
168
|
+
if _is_dynamic_string(node.value) and isinstance(node.target, ast.Name):
|
|
169
|
+
dynamic.add(node.target.id)
|
|
168
170
|
return dynamic
|
|
169
171
|
|
|
170
172
|
|
|
@@ -300,11 +302,17 @@ def _discover(paths: list[str]):
|
|
|
300
302
|
if p.is_file() and p.suffix == ".py":
|
|
301
303
|
files.append(p)
|
|
302
304
|
elif p.is_dir():
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
305
|
+
import os
|
|
306
|
+
|
|
307
|
+
for dirpath, dirnames, filenames in os.walk(p, followlinks=True):
|
|
308
|
+
dirnames[:] = [
|
|
309
|
+
d for d in dirnames
|
|
310
|
+
if not d.startswith(".") and d != "__pycache__"
|
|
311
|
+
]
|
|
312
|
+
for name in filenames:
|
|
313
|
+
if name.endswith(".py"):
|
|
314
|
+
files.append(Path(dirpath) / name)
|
|
315
|
+
files.sort()
|
|
308
316
|
return files
|
|
309
317
|
|
|
310
318
|
|
|
@@ -341,10 +349,11 @@ def _parse_suppression_comment(comment: str) -> tuple[list[str], str | None]:
|
|
|
341
349
|
return [], None
|
|
342
350
|
rest = directive[len("ignore "):]
|
|
343
351
|
reason = None
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
reason =
|
|
352
|
+
reason_lower = rest.lower()
|
|
353
|
+
reason_idx = reason_lower.find(" reason:")
|
|
354
|
+
if reason_idx != -1:
|
|
355
|
+
reason = rest[reason_idx + 8:].strip() or None
|
|
356
|
+
rest = rest[:reason_idx]
|
|
348
357
|
rule_ids = [r.strip().upper() for r in rest.split(",") if r.strip()]
|
|
349
358
|
return rule_ids, reason
|
|
350
359
|
|
|
@@ -386,11 +395,14 @@ def _find_suppressed_line(
|
|
|
386
395
|
) -> tuple[set[str], str | None] | None:
|
|
387
396
|
"""Find which rules suppress findings at target_line.
|
|
388
397
|
|
|
389
|
-
Checks previous non-blank
|
|
398
|
+
Checks up to 5 previous non-blank lines, then the target line itself.
|
|
390
399
|
"""
|
|
391
|
-
for
|
|
400
|
+
for offset in range(1, 6):
|
|
401
|
+
candidate = target_line - offset
|
|
392
402
|
if candidate in line_suppressions:
|
|
393
403
|
return line_suppressions[candidate]
|
|
404
|
+
if target_line in line_suppressions:
|
|
405
|
+
return line_suppressions[target_line]
|
|
394
406
|
return None
|
|
395
407
|
|
|
396
408
|
|
|
@@ -402,24 +414,13 @@ def _apply_suppression(
|
|
|
402
414
|
) -> Finding:
|
|
403
415
|
"""Apply suppression rules to a finding. Returns new Finding with ignored flag."""
|
|
404
416
|
rule_id = result.rule_id
|
|
417
|
+
|
|
418
|
+
if ignore and rule_id in ignore:
|
|
419
|
+
return replace(result, ignored=True, ignore_reason="CLI --ignore")
|
|
420
|
+
|
|
405
421
|
suppressed = False
|
|
406
422
|
ignore_reason = None
|
|
407
423
|
|
|
408
|
-
if ignore and rule_id in ignore:
|
|
409
|
-
return Finding(
|
|
410
|
-
rule_id=result.rule_id,
|
|
411
|
-
title=result.title,
|
|
412
|
-
severity=result.severity,
|
|
413
|
-
confidence=result.confidence,
|
|
414
|
-
path=result.path,
|
|
415
|
-
lineno=result.lineno,
|
|
416
|
-
col=result.col,
|
|
417
|
-
message=result.message,
|
|
418
|
-
evidence=result.evidence,
|
|
419
|
-
fix_hint=result.fix_hint,
|
|
420
|
-
ignored=True,
|
|
421
|
-
ignore_reason="CLI --ignore",
|
|
422
|
-
)
|
|
423
424
|
if rule_id in file_rules or "*" in file_rules:
|
|
424
425
|
suppressed = True
|
|
425
426
|
ignore_reason = "file-level suppression"
|
|
@@ -433,24 +434,12 @@ def _apply_suppression(
|
|
|
433
434
|
|
|
434
435
|
if not suppressed:
|
|
435
436
|
return result
|
|
436
|
-
return
|
|
437
|
-
rule_id=result.rule_id,
|
|
438
|
-
title=result.title,
|
|
439
|
-
severity=result.severity,
|
|
440
|
-
confidence=result.confidence,
|
|
441
|
-
path=result.path,
|
|
442
|
-
lineno=result.lineno,
|
|
443
|
-
col=result.col,
|
|
444
|
-
message=result.message,
|
|
445
|
-
evidence=result.evidence,
|
|
446
|
-
fix_hint=result.fix_hint,
|
|
447
|
-
ignored=True,
|
|
448
|
-
ignore_reason=ignore_reason,
|
|
449
|
-
)
|
|
437
|
+
return replace(result, ignored=True, ignore_reason=ignore_reason)
|
|
450
438
|
|
|
451
439
|
|
|
452
440
|
def _emit_au010(
|
|
453
441
|
line_suppressions: dict[int, tuple[set[str], str | None]],
|
|
442
|
+
file_rules: set[str],
|
|
454
443
|
rel: str,
|
|
455
444
|
) -> list[Finding]:
|
|
456
445
|
"""Emit AU010 warnings for suppression comments without reasons."""
|
|
@@ -477,6 +466,24 @@ def _emit_au010(
|
|
|
477
466
|
evidence={"rules": sorted(suppressed_rules)},
|
|
478
467
|
)
|
|
479
468
|
)
|
|
469
|
+
if file_rules and "*" not in file_rules:
|
|
470
|
+
for rule in sorted(file_rules):
|
|
471
|
+
results.append(
|
|
472
|
+
Finding(
|
|
473
|
+
rule_id="AU010",
|
|
474
|
+
title="suppression comment without reason",
|
|
475
|
+
severity="info",
|
|
476
|
+
confidence=1.0,
|
|
477
|
+
path=rel,
|
|
478
|
+
lineno=1,
|
|
479
|
+
col=0,
|
|
480
|
+
message=(
|
|
481
|
+
f"File-level suppression for {rule} "
|
|
482
|
+
f"has no reason. Add: reason: <why>"
|
|
483
|
+
),
|
|
484
|
+
evidence={"rules": [rule], "file_level": True},
|
|
485
|
+
)
|
|
486
|
+
)
|
|
480
487
|
return results
|
|
481
488
|
|
|
482
489
|
|
|
@@ -506,7 +513,7 @@ def scan(paths: list[str], ignore: set[str] | None = None) -> list[Finding]:
|
|
|
506
513
|
findings.append(
|
|
507
514
|
_apply_suppression(result, ignore, file_rules, line_suppressions)
|
|
508
515
|
)
|
|
509
|
-
findings.extend(_emit_au010(line_suppressions, rel))
|
|
516
|
+
findings.extend(_emit_au010(line_suppressions, file_rules, rel))
|
|
510
517
|
|
|
511
518
|
findings.sort(key=lambda f: (f.severity != "error", f.path, f.lineno))
|
|
512
519
|
return findings
|
|
@@ -51,15 +51,14 @@ def _pass_through(kwargs: dict[str, object]):
|
|
|
51
51
|
return {k: v for k, v in kwargs.items() if k in _ALLOWED_KWARGS}
|
|
52
52
|
|
|
53
53
|
|
|
54
|
-
def
|
|
55
|
-
|
|
54
|
+
def _render(
|
|
55
|
+
template: object, kwargs: dict[str, object]
|
|
56
|
+
) -> tuple[ExecutionPlan, subprocess.CompletedProcess[str]]:
|
|
57
|
+
"""Render template and execute, returning both plan and result."""
|
|
58
|
+
from .render import plan as render_plan
|
|
56
59
|
|
|
57
|
-
Interpolated values always arrive as single argv elements. Keyword arguments
|
|
58
|
-
pass through to subprocess.run with one exception: shell is rejected by
|
|
59
|
-
design.
|
|
60
|
-
"""
|
|
61
60
|
_validate_kwargs(kwargs)
|
|
62
|
-
rendered =
|
|
61
|
+
rendered = render_plan(template)
|
|
63
62
|
if rendered.mode == "shell":
|
|
64
63
|
if sys.platform.startswith("win"):
|
|
65
64
|
raise ShellSafeError(
|
|
@@ -71,7 +70,19 @@ def run(template: object, /, **kwargs: object) -> subprocess.CompletedProcess[st
|
|
|
71
70
|
"run() to execute pipes and redirections"
|
|
72
71
|
)
|
|
73
72
|
assert rendered.argv is not None
|
|
74
|
-
|
|
73
|
+
completed = subprocess.run(rendered.argv, **_pass_through(kwargs))
|
|
74
|
+
return rendered, completed
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def run(template: object, /, **kwargs: object) -> subprocess.CompletedProcess[str]:
|
|
78
|
+
"""Render the template and execute it.
|
|
79
|
+
|
|
80
|
+
Interpolated values always arrive as single argv elements. Keyword arguments
|
|
81
|
+
pass through to subprocess.run with one exception: shell is rejected by
|
|
82
|
+
design.
|
|
83
|
+
"""
|
|
84
|
+
_, completed = _render(template, kwargs)
|
|
85
|
+
return completed
|
|
75
86
|
|
|
76
87
|
|
|
77
88
|
@dataclass(frozen=True, slots=True)
|
|
@@ -83,18 +94,24 @@ class CaptureResult:
|
|
|
83
94
|
returncode: int
|
|
84
95
|
plan: ExecutionPlan
|
|
85
96
|
|
|
97
|
+
def __repr__(self) -> str:
|
|
98
|
+
return (
|
|
99
|
+
f"CaptureResult(returncode={self.returncode}, "
|
|
100
|
+
f"stdout={self.stdout!r}, stderr={self.stderr!r})"
|
|
101
|
+
)
|
|
102
|
+
|
|
86
103
|
|
|
87
104
|
def capture(template: object, /, **kwargs: object) -> CaptureResult:
|
|
88
105
|
"""Run with captured utf-8 stdout/stderr and return a CaptureResult."""
|
|
89
106
|
kwargs["capture_output"] = True
|
|
90
107
|
kwargs["text"] = True
|
|
91
108
|
kwargs["encoding"] = "utf-8"
|
|
92
|
-
completed =
|
|
109
|
+
rendered, completed = _render(template, kwargs)
|
|
93
110
|
return CaptureResult(
|
|
94
111
|
stdout=completed.stdout,
|
|
95
112
|
stderr=completed.stderr,
|
|
96
113
|
returncode=completed.returncode,
|
|
97
|
-
plan=
|
|
114
|
+
plan=rendered,
|
|
98
115
|
)
|
|
99
116
|
|
|
100
117
|
|
|
@@ -83,10 +83,15 @@ def _resolve(interpolation: Interpolation[str]) -> str:
|
|
|
83
83
|
)
|
|
84
84
|
|
|
85
85
|
if format_spec:
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
86
|
+
result = format(converted, format_spec)
|
|
87
|
+
elif conversion is None:
|
|
88
|
+
result = str(converted)
|
|
89
|
+
else:
|
|
90
|
+
result = converted
|
|
91
|
+
|
|
92
|
+
if not isinstance(result, str):
|
|
93
|
+
result = str(result)
|
|
94
|
+
return result
|
|
90
95
|
|
|
91
96
|
|
|
92
97
|
def _reject_nul(resolved: str):
|
|
@@ -176,4 +181,9 @@ def _render_shell(parts: list[Segment]) -> ExecutionPlan:
|
|
|
176
181
|
line = "".join(line_parts).strip()
|
|
177
182
|
if not line:
|
|
178
183
|
raise ShellSafeTypeError("empty command")
|
|
184
|
+
if len(line) > 131072:
|
|
185
|
+
raise ShellSafeTypeError(
|
|
186
|
+
f"shell line exceeds 128KB ({len(line)} bytes); "
|
|
187
|
+
"use argv mode or shorten the command"
|
|
188
|
+
)
|
|
179
189
|
return ExecutionPlan(mode="shell", shell_line=line)
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.3.4"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|