Skip to content

Commit 5e5a860

Browse files
authored
Merge pull request #14 from ben-grande/stprint
Add safe terminal printing program
2 parents 238249e + 6756278 commit 5e5a860

File tree

18 files changed

+1233
-11
lines changed

18 files changed

+1233
-11
lines changed

.github/workflows/lint.yml

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
---
2+
name: Lint scripts
3+
on:
4+
push:
5+
paths:
6+
- usr/lib/python3/dist-packages/stdisplay/
7+
- .github/workflows/lint.yml
8+
- run-tests
9+
pull_request:
10+
paths:
11+
- usr/lib/python3/dist-packages/stdisplay/
12+
- .github/workflows/lint.yml
13+
- run-tests
14+
15+
jobs:
16+
lint:
17+
runs-on: ubuntu-24.04
18+
19+
strategy:
20+
fail-fast: false
21+
matrix:
22+
include:
23+
- image: debian:stable
24+
- image: debian:testing
25+
- image: debian:unstable
26+
- image: ubuntu:latest
27+
- image: ubuntu:rolling
28+
29+
container:
30+
image: ${{ matrix.image }}
31+
32+
steps:
33+
- name: Install linters
34+
run: |
35+
apt-get update --error-on=any
36+
apt-get dist-upgrade -y
37+
apt-get install -y git python3-pytest pylint mypy black ncurses-term \
38+
build-essential debhelper dh-python dh-apparmor
39+
git config --global --add safe.directory "$GITHUB_WORKSPACE"
40+
- uses: actions/checkout@v4
41+
42+
- name: Test build
43+
run: |
44+
dpkg-buildpackage -b -i -us -uc
45+
apt-get install -y ../helper-scripts*.deb
46+
- name: Run linters
47+
run: |
48+
set -o xtrace
49+
./run-tests

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
__pycache__/
2+
.mypy_cache/
3+
debian/helper-scripts-tests/
4+
debian/helper-scripts/

debian/control

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,10 @@ Description: Helper scripts useful for Linux Distributions
4444
.
4545
Provides the dummy-dependency script for quickly creating and installing
4646
dummy packages for working around package dependencies.
47+
48+
Package: helper-scripts-tests
49+
Architecture: all
50+
Depends: helper-scripts, git, python3-pytest, pylint, mypy, black,
51+
ncurses-base, ncurses-term, ${misc:Depends}
52+
Description: Helper scripts test packages
53+
This is a dependency package for tests.

man/stdisplay.1.ronn

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
stdisplay(1) -- Sanitize text to be safely printed to the terminal
2+
=================================================================
3+
4+
<!--
5+
# Copyright (C) 2025 Benjamin Grande M. S. <ben.grande.b@gmail.com>
6+
# Copyright (C) 2025 ENCRYPTED SUPPORT LLC <adrelanos@whonix.org>
7+
# See the file COPYING for copying conditions.
8+
-->
9+
10+
## SYNOPSIS
11+
12+
`stprint [TEXT...]`<br>
13+
`stecho [TEXT...]`<br>
14+
`stcat [FILE...]`<br>
15+
`sttee [FILE...]`<br>
16+
`stsponge [FILE]`<br>
17+
18+
## DESCRIPTION
19+
20+
`stdisplay` is a Python library used to safely print text from untrusted
21+
sources sanitizing non-ASCII characters and dangerous ANSI escape
22+
sequenes, from the latter, only a strict subset of SGR (Select Graphic
23+
Rendition) attributes, line feeds (`\n`) and horizontal tabs (`\t`) are
24+
allowed).
25+
26+
The following tests are done in order to verify if SGR should be enabled
27+
and how large it's set should be:
28+
29+
1. If the environment variable `$NO_COLOR` is set to a non-empty value,
30+
SGR is disabled.
31+
2. If the environment variable `$COLORTERM` is set to `truecolor`,
32+
24-bit SGR is enabled.
33+
3. If none of the above matches, the terminal referenced by the
34+
environment variable `$TERM` is queried for its `colors` capability,
35+
which returns how many colors the terminal supports.
36+
37+
Tools based on this library have no option parameters, everything is
38+
treated either as text or file, depending on the tool used, therefore,
39+
`--` is interpreted as text and not end of options.
40+
41+
Each tool behaves as if their shell utility counterpart was used without
42+
any options:
43+
44+
| Tool | Sanitizer |
45+
| -------- | --------- |
46+
| strint | printf |
47+
| stecho | echo |
48+
| stcat | cat |
49+
| sttee | tee |
50+
| stsponge | sponge |
51+
52+
## RETURN VALUES
53+
54+
* `0` Successfully printed text.
55+
* Any other return value is an error.
56+
57+
## EXAMPLE: SGR CONTROL
58+
59+
Enable 24-bit SGR if the terminfo database is outdated:
60+
61+
<code>
62+
COLORTERM=truecolor stprint "$(printf '%b' "\033[38;2;0;0;255mSome color\033[m")"<br>
63+
</code>
64+
65+
Disable SGR:
66+
67+
<code>
68+
NO_COLOR=1 stprint "$(printf '%b' "\033[31mNo color\033[m")"<br>
69+
TERM=dumb stprint "$(printf '%b' "\033[31mNo color\033[m")"<br>
70+
</code>
71+
72+
## EXAMPLE: STCAT
73+
74+
Copy standard input to standard output:
75+
76+
<code>
77+
printf '%s' "${untrusted_string}" | stcat<br>
78+
stcat < /untrusted/file<br>
79+
.<br>
80+
untrusted-cmd 2>&1 | stcat<br>
81+
# Or with Bash/Zsh syntax:<br>
82+
stcat &lt; &lt;(untrusted-cmd 2>&1)
83+
</code>
84+
85+
Concatenate files:
86+
87+
<code>
88+
stcat /untrusted/file /untrusted/log
89+
</code>
90+
91+
Piping to a pager (can also be `sttee`):
92+
93+
<code>
94+
data | stcat | less -R<br>
95+
GIT_PAGER="stcat | less -R" git log
96+
</code>
97+
98+
Print a ownership restricted file with external programs:
99+
100+
<code>
101+
sudo -- stcat /untrusted/log<br>
102+
</code>
103+
104+
## EXAMPLE: STTEE/STSPONGE
105+
106+
The tools `sttee` and `stsponge` have the same usage but differ when
107+
writing. While `sttee` always writes to standrd output as soon as
108+
it is read, `stsponge` only writes to standard output if no file is
109+
provided and the write is atomic.
110+
111+
Copy standrd input to standard output and optionally to a file:
112+
113+
<code>
114+
printf '%s' "${untrusted_string}" | sttee<br>
115+
printf '%s' "${untrusted_string}" | sttee /trusted/file<br>
116+
sttee /trusted/file < /untrusted/file</br>
117+
</code>
118+
119+
Only `stsponge` can sanitize a file in-place:
120+
121+
<code>
122+
stsponge /untrusted/file < /untrusted/file
123+
</code>
124+
125+
## EXAMPLE: STPRINT/STECHO
126+
127+
The tools `stprint` and `stecho` have the same usage but differ in
128+
formatting. While `stprint` does print text as is, `stecho` adds a space
129+
between each argument and a newline at the end of the string.
130+
131+
Print a variable value is simple:
132+
133+
<code>
134+
stprint "${untrusted_string}"
135+
</code>
136+
137+
Note that items are joined without word-splitting (no space separation):
138+
139+
<code>
140+
stprint "${untrusted_string}" "${another_untrusted_string}"
141+
</code>
142+
143+
To have space separated items, simply add a space between them:
144+
145+
<code>
146+
stprint "${untrusted_string} ${another_untrusted_string}"<br>
147+
stprint "${untrusted_string}" " ${another_untrusted_string}"
148+
</code>
149+
150+
Print with heredoc to avoid quoting problems:
151+
152+
<code>
153+
stprint &lt;&lt;EOF<br>
154+
${untrusted_string}<br>
155+
EOF
156+
</code>
157+
158+
### EXAMPLE: STPRINT WITH VARIABLES
159+
160+
Print a variable as is:
161+
162+
<code>
163+
var="$(stprint "${untrusted_string}")"<br>
164+
## Or Bash/Zsh syntax:<br>
165+
printf -v var '%s' "$(stprint "${red}Hey${nocolor}: ${untrusted_string}")"<br>
166+
.<br>
167+
## Raw print:<br>
168+
printf '%s' "${var}"
169+
</code>
170+
171+
Interpret wanted escapes before passing them:
172+
173+
<code>
174+
red="$(printf '%b' "\033[31m")"<br>
175+
nocolor="$(printf '%b' "\033[m")"<br>
176+
## Or Bash/Zsh syntax:<br>
177+
red=$"\033[31m"<br>
178+
nocolor=$"\033[m"<br>
179+
.<br>
180+
## Raw assignment:<br>
181+
var="$(stprint "${red}Hey${nocolor}: ${untrusted_string}")"
182+
</code>
183+
184+
### EXAMPLE: STPRINT MISUSE
185+
186+
*Warning*: Reinterpreting the escapes from the data returned from
187+
`stprint` is insecure. Stack of escaped escape sequences not interpreted
188+
before will be evaluated.
189+
190+
Do *NOT* reinterpret the escape sequences on variable assignment (dangerous
191+
when printing to the terminal later:
192+
193+
<code>
194+
var="$(stprint "${untrusted_string}")" # OK<br>
195+
# Or with Bash/Zsh syntax:<br>
196+
printf -v var "$(stprint "${untrusted_string}")" # DANGER (format is '%b')<br>
197+
printf -v var '%b' "$(stprint "${untrusted_string}")" # DANGER
198+
</code>
199+
200+
Do *NOT* reinterpret the escape sequences when printing a variable, one
201+
more layer of escapes will be interpreted:
202+
203+
<code>
204+
var="$(stprint "${untrusted_string}")" # OK<br>
205+
printf "${var}" # DANGER (format is '%b')<br>
206+
printf '%b' "${var}" # DANGER<br>
207+
echo -e "${var}" # DANGER<br>
208+
echo "${var}" # DANGER (may default to use '-e')<br>
209+
echo -E "${var}" # DANGER (var may have '-e' prefix)
210+
</code>
211+
212+
## AUTHOR
213+
214+
This man page has been written by Benjamin Grand M. S.
215+
(ben.grande.b@gmail.com).

pyproject.toml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
[tool.black]
2+
line-length = 79
3+
[tool.mypy]
4+
strict="yes"
5+
[tool.pylint.'FORMAT']
6+
expected-line-ending-format="LF"
7+
max-line-length=79
8+
[tool.pylint.'LOGGING']
9+
logging-format-style="new"
10+
[tool.pylint.'MESSAGES CONTROL']
11+
confidence=""
12+
[tool.pylint.'REPORTS']
13+
output-format="colorized"
14+
reports="no"
15+
[tool.pylint.'STRING']
16+
check-quote-consistency="yes"
17+
check-str-concat-over-line-jumps="yes"

run-tests

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
#!/bin/bash
2+
set -eux
3+
git_toplevel="$(git rev-parse --show-toplevel)"
4+
pyrc="${git_toplevel}/pyproject.toml"
5+
pythonpath="${git_toplevel}/usr/lib/python3/dist-packages"
6+
export PYTHONPATH="${pythonpath}${PYTHONPATH+":${PYTHONPATH}"}"
7+
8+
pytest=(python3 -m pytest -o 'python_files=*.py')
9+
black=(black --config="${pyrc}" --color --diff --check)
10+
pylint=(pylint --rcfile="${pyrc}")
11+
mypy=(mypy --config-file="${pyrc}")
12+
13+
cd "${pythonpath}/stdisplay/"
14+
"${pytest[@]}" "${@}"
15+
"${black[@]}" .
16+
find . -type f -name "*.py" -print0 | xargs -0 "${pylint[@]}"
17+
"${mypy[@]}" .
18+
19+
utils=(stprint stecho stcat sttee stsponge)
20+
cd "${git_toplevel}/usr/bin"
21+
"${black[@]}" -- "${utils[@]}"
22+
"${pylint[@]}" -- "${utils[@]}"
23+
for file in "${utils[@]}"; do
24+
"${mypy[@]}" -- "${file}"
25+
done

usr/bin/stcat

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
#!/usr/bin/env python3
2+
3+
## SPDX-FileCopyrightText: 2025 Benjamin Grande M. S. <ben.grande.b@gmail.com>
4+
## SPDX-FileCopyrightText: 2025 ENCRYPTED SUPPORT LLC <adrelanos@whonix.org>
5+
##
6+
## SPDX-License-Identifier: AGPL-3.0-or-later
7+
8+
"""Safely print stdin or file to stdout."""
9+
10+
from fileinput import input as file_input
11+
from sys import stdin, stdout
12+
from stdisplay.stdisplay import stdisplay
13+
14+
15+
def main() -> None:
16+
"""Safely print stdin or file to stdout."""
17+
stdin.reconfigure(errors="ignore") # type: ignore
18+
## File input reads stdin when no file is provided or file is '-'.
19+
for untrusted_text in file_input(encoding="ascii"):
20+
stdout.write(stdisplay(untrusted_text))
21+
stdout.flush()
22+
23+
24+
if __name__ == "__main__":
25+
main()

usr/bin/stecho

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
#!/usr/bin/env python3
2+
3+
## SPDX-FileCopyrightText: 2025 Benjamin Grande M. S. <ben.grande.b@gmail.com>
4+
## SPDX-FileCopyrightText: 2025 ENCRYPTED SUPPORT LLC <adrelanos@whonix.org>
5+
##
6+
## SPDX-License-Identifier: AGPL-3.0-or-later
7+
8+
"""Safely print argument to stdout with echo's formatting."""
9+
10+
from sys import argv, stdout
11+
from stdisplay.stdisplay import stdisplay
12+
13+
14+
def main() -> None:
15+
"""Safely print argument to stdout with echo's formatting."""
16+
if len(argv) > 1:
17+
untrusted_text = " ".join(argv[1:])
18+
stdout.write(stdisplay(untrusted_text))
19+
stdout.write("\n")
20+
stdout.flush()
21+
22+
23+
if __name__ == "__main__":
24+
main()

0 commit comments

Comments
 (0)