Skip to content

Commit 7618d04

Browse files
committed
fix: only hand the Docutils source to a Python interpreter
Fixes #1137 Signed-off-by: Joseph Kato <joseph@jdkato.io>
1 parent b596b0d commit 7618d04

2 files changed

Lines changed: 165 additions & 24 deletions

File tree

‎internal/lint/rst.go‎

Lines changed: 55 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -82,12 +82,22 @@ var (
8282
rstDirect []string // <python> -c <server>, or nil
8383
)
8484

85+
// rePython matches the names Python is installed under -- `python`, `python3`,
86+
// `python3.12`, `pypy3` -- and nothing else.
87+
var rePython = regexp.MustCompile(`(?i)^(?:python|pypy)[0-9.]*(?:\.exe)?$`)
88+
8589
// rstInterpreter finds the Python that can import Docutils.
8690
//
8791
// It is not necessarily the `python3` on PATH: rst2html is installed with a
8892
// shebang naming the interpreter of the environment Docutils was installed
8993
// into, and on a machine with several Pythons the one on PATH often cannot
9094
// import it. So the script is asked which interpreter it runs on.
95+
//
96+
// The answer is only used when it names Python. rst2html is not always the
97+
// script it looks like: a version manager can put a shell wrapper on PATH under
98+
// that name, and the wrapper's shebang names its own shell. Passing the server
99+
// source to a shell runs its first line, `import sys`, as a command -- which is
100+
// a real program on a machine with ImageMagick. See #1137.
91101
func rstInterpreter(exe string) string {
92102
resolved, err := filepath.EvalSymlinks(exe)
93103
if err != nil {
@@ -111,46 +121,67 @@ func rstInterpreter(exe string) string {
111121
}
112122

113123
// `#!/usr/bin/env python3` names the interpreter in the second field.
114-
if filepath.Base(fields[0]) == "env" {
124+
python := fields[0]
125+
if filepath.Base(python) == "env" {
115126
if len(fields) < 2 {
116127
return ""
117128
}
118-
return system.Which([]string{fields[1]})
129+
python = fields[1]
130+
}
131+
132+
if !rePython.MatchString(filepath.Base(python)) {
133+
return ""
134+
} else if !filepath.IsAbs(python) {
135+
return system.Which([]string{python})
119136
}
120137

121-
return fields[0]
138+
return python
122139
}
123140

124141
// rstFastPath returns the argv prefix for converting through a long-lived
125142
// interpreter, or nil when that could not be established.
126143
func rstFastPath(exe string) []string {
127144
rstOnce.Do(func() {
128-
python := rstInterpreter(exe)
129-
if python == "" {
130-
return
131-
}
145+
rstDirect = rstProbe(exe)
146+
})
132147

133-
candidate := []string{python, "-c", rstServer}
148+
return rstDirect
149+
}
134150

135-
// Trust it only after a document has made the round trip. The probe
136-
// carries a non-ASCII character on purpose: a mismatched default
137-
// encoding is what broke the first version of the AsciiDoc pool, and
138-
// an ASCII-only probe would have passed anyway.
139-
probe, err := startExtProc(candidate, rstArgs)
140-
if err != nil {
141-
return
142-
}
143-
defer probe.close()
151+
// rstProbe establishes the argv for a long-lived interpreter, or nil.
152+
func rstProbe(exe string) []string {
153+
python := rstInterpreter(exe)
154+
if python == "" {
155+
// Either there was no shebang to read or it named something other than
156+
// Python. A Python on PATH is the next guess: where the script is a
157+
// wrapper, that wrapper forwards to the interpreter it would have used
158+
// anyway. The probe below is what decides whether the guess can do the
159+
// work, so a wrong one costs the pool, not correctness.
160+
python = system.Which([]string{"python3", "python"})
161+
}
144162

145-
got, err := probe.convert("naïve body\n")
146-
if err != nil || !strings.Contains(got, "naïve body") {
147-
return
148-
}
163+
if python == "" {
164+
return nil
165+
}
149166

150-
rstDirect = candidate
151-
})
167+
candidate := []string{python, "-c", rstServer}
152168

153-
return rstDirect
169+
// Trust it only after a document has made the round trip. The probe
170+
// carries a non-ASCII character on purpose: a mismatched default
171+
// encoding is what broke the first version of the AsciiDoc pool, and
172+
// an ASCII-only probe would have passed anyway.
173+
probe, err := startExtProc(candidate, rstArgs)
174+
if err != nil {
175+
return nil
176+
}
177+
defer probe.close()
178+
179+
got, err := probe.convert("naïve body\n")
180+
if err != nil || !strings.Contains(got, "naïve body") {
181+
return nil
182+
}
183+
184+
return candidate
154185
}
155186

156187
func (l *Linter) lintRST(f *core.File) error {

‎internal/lint/rst_interp_test.go‎

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
package lint
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"runtime"
7+
"testing"
8+
9+
"github.com/errata-ai/vale/v3/internal/system"
10+
)
11+
12+
// write creates an executable script and returns its path.
13+
func write(t *testing.T, dir, name, body string) string {
14+
t.Helper()
15+
16+
path := filepath.Join(dir, name)
17+
if err := os.WriteFile(path, []byte(body), 0o755); err != nil { //nolint:gosec
18+
t.Fatal(err)
19+
}
20+
return path
21+
}
22+
23+
// The interpreter is read out of a shebang, which is a claim the file makes
24+
// about itself rather than something Vale can verify. Anything but Python has
25+
// to come back empty: the caller's next move is to hand it the Docutils server
26+
// source, and a shell reads that as a script. See #1137.
27+
func TestRSTInterpreterOnlyNamesPython(t *testing.T) {
28+
dir := t.TempDir()
29+
30+
tests := []struct {
31+
name string
32+
shebang string
33+
python bool
34+
}{
35+
{"pyenv shim", "#!/usr/bin/env bash\nset -e\nexec pyenv exec rst2html \"$@\"\n", false},
36+
{"sh wrapper", "#!/bin/sh\nexec /usr/bin/rst2html \"$@\"\n", false},
37+
{"env perl", "#!/usr/bin/env perl\n", false},
38+
{"env with no argument", "#!/usr/bin/env\n", false},
39+
{"no shebang", "not a script\n", false},
40+
{"empty file", "", false},
41+
{"absolute python", "#!/usr/bin/python3\n", true},
42+
{"versioned python", "#!/usr/local/bin/python3.12\n", true},
43+
{"pypy", "#!/usr/bin/pypy3\n", true},
44+
}
45+
46+
for _, tt := range tests {
47+
t.Run(tt.name, func(t *testing.T) {
48+
got := rstInterpreter(write(t, dir, "rst2html", tt.shebang))
49+
50+
if !tt.python {
51+
if got != "" {
52+
t.Errorf("rstInterpreter = %q; want \"\"", got)
53+
}
54+
return
55+
}
56+
if !rePython.MatchString(filepath.Base(got)) {
57+
t.Errorf("rstInterpreter = %q; want a Python", got)
58+
}
59+
})
60+
}
61+
}
62+
63+
// `#!/usr/bin/env python3` names the interpreter in the second field, and that
64+
// field is a bare name -- it has to be resolved on PATH, not returned as-is.
65+
func TestRSTInterpreterResolvesEnvPython(t *testing.T) {
66+
if system.Which([]string{"python3", "python"}) == "" {
67+
t.Skip("no python on PATH")
68+
}
69+
70+
got := rstInterpreter(write(t, t.TempDir(), "rst2html", "#!/usr/bin/env python3\n"))
71+
if got != "" && !filepath.IsAbs(got) {
72+
t.Errorf("rstInterpreter = %q; want an absolute path", got)
73+
}
74+
}
75+
76+
// The failure in #1137 was not a wrong answer -- the probe caught that and Vale
77+
// fell back to spawning rst2html per file, so the linting was correct. It was
78+
// what running the probe did on the way: `bash -c` on the Docutils source runs
79+
// its first line, `import sys`, as a shell command, and `import` is
80+
// ImageMagick's screenshot tool.
81+
//
82+
// So this asserts on the side effect, not the return value. `import` here
83+
// records that it ran.
84+
func TestRSTProbeRunsNothingThroughAShell(t *testing.T) {
85+
if runtime.GOOS == "windows" {
86+
t.Skip("no shebang handling on Windows")
87+
} else if system.Which([]string{"bash"}) == "" {
88+
t.Skip("bash not installed")
89+
}
90+
91+
dir := t.TempDir()
92+
sentinel := filepath.Join(dir, "ran")
93+
94+
// A wrapper of the shape a version manager installs: a Bourne-Again shell
95+
// script under the name of the tool it forwards to.
96+
shim := write(t, dir, "rst2html", "#!/usr/bin/env bash\nset -e\nexit 0\n")
97+
98+
// Every word on the source's first line is a command a shell would try.
99+
for _, name := range []string{"import", "sys"} {
100+
write(t, dir, name, "#!/bin/sh\necho ran > "+sentinel+"\n")
101+
}
102+
103+
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
104+
105+
rstProbe(shim)
106+
107+
if _, err := os.Stat(sentinel); err == nil {
108+
t.Error("the Docutils source was executed by a shell")
109+
}
110+
}

0 commit comments

Comments
 (0)