Skip to content

Commit 5246137

Browse files
committed
systemd: escape and quote command
Add single-quoting and escaping of single quotes in the specified program in a systemd daemon's "ExecStart". This allows the created daemon to properly run a command with spaces in the path, as well as being generally safer. Update unit tests to more thoroughly test service unit file contents. Signed-off-by: Victoria Dye <[email protected]>
1 parent 9d1ed78 commit 5246137

File tree

2 files changed

+96
-43
lines changed

2 files changed

+96
-43
lines changed

internal/daemon/systemd.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"bytes"
55
"fmt"
66
"path/filepath"
7+
"strings"
78
"text/template"
89

910
"github.com/github/git-bundle-server/internal/common"
@@ -14,7 +15,7 @@ Description={{.Description}}
1415
1516
[Service]
1617
Type=simple
17-
ExecStart={{.Program}}
18+
ExecStart={{sq_escape .Program}}
1819
`
1920

2021
type systemd struct {
@@ -43,7 +44,11 @@ func (s *systemd) Create(config *DaemonConfig, force bool) error {
4344

4445
// Generate the configuration
4546
var newServiceUnit bytes.Buffer
46-
t, err := template.New(config.Label).Parse(serviceTemplate)
47+
t, err := template.New(config.Label).Funcs(template.FuncMap{
48+
"sq_escape": func(str string) string {
49+
return fmt.Sprintf("'%s'", strings.ReplaceAll(str, "'", "\\'"))
50+
},
51+
}).Parse(serviceTemplate)
4752
if err != nil {
4853
return fmt.Errorf("unable to generate systemd configuration: %w", err)
4954
}

internal/daemon/systemd_test.go

Lines changed: 89 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,16 @@ import (
44
"fmt"
55
"os/user"
66
"path/filepath"
7+
"regexp"
8+
"strings"
79
"testing"
810

911
"github.com/github/git-bundle-server/internal/daemon"
1012
"github.com/stretchr/testify/assert"
1113
"github.com/stretchr/testify/mock"
1214
)
1315

14-
var systemdCreateTests = []struct {
16+
var systemdCreateBehaviorTests = []struct {
1517
title string
1618

1719
// Inputs
@@ -64,6 +66,43 @@ var systemdCreateTests = []struct {
6466
},
6567
}
6668

69+
var systemdCreateServiceUnitTests = []struct {
70+
title string
71+
72+
// Inputs
73+
config *daemon.DaemonConfig
74+
75+
// Expected values
76+
expectedServiceUnitLines []string
77+
}{
78+
{
79+
title: "Created service unit contents are correct",
80+
config: &basicDaemonConfig,
81+
expectedServiceUnitLines: []string{
82+
"[Unit]",
83+
fmt.Sprintf("Description=%s", basicDaemonConfig.Description),
84+
"[Service]",
85+
"Type=simple",
86+
fmt.Sprintf("ExecStart='%s'", basicDaemonConfig.Program),
87+
},
88+
},
89+
{
90+
title: "Service unit ExecStart with space is quoted",
91+
config: &daemon.DaemonConfig{
92+
Label: "test-quoting",
93+
Description: "My program's description (double quotes \" are ok too)",
94+
Program: "/path/to/the/program with a space",
95+
},
96+
expectedServiceUnitLines: []string{
97+
"[Unit]",
98+
"Description=My program's description (double quotes \" are ok too)",
99+
"[Service]",
100+
"Type=simple",
101+
"ExecStart='/path/to/the/program with a space'",
102+
},
103+
},
104+
}
105+
67106
func TestSystemd_Create(t *testing.T) {
68107
// Set up mocks
69108
testUser := &user.User{
@@ -80,7 +119,7 @@ func TestSystemd_Create(t *testing.T) {
80119

81120
systemd := daemon.NewSystemdProvider(testUserProvider, testCommandExecutor, testFileSystem)
82121

83-
for _, tt := range systemdCreateTests {
122+
for _, tt := range systemdCreateBehaviorTests {
84123
forceArg := tt.force.toBoolList()
85124
for _, force := range forceArg {
86125
t.Run(fmt.Sprintf("%s (force='%t')", tt.title, force), func(t *testing.T) {
@@ -122,45 +161,54 @@ func TestSystemd_Create(t *testing.T) {
122161
}
123162

124163
// Verify content of created file
125-
t.Run("Created file content and path are correct", func(t *testing.T) {
126-
var actualFilename string
127-
var actualFileBytes []byte
128-
129-
// Mock responses for successful fresh write
130-
testCommandExecutor.On("Run",
131-
"systemctl",
132-
[]string{"--user", "daemon-reload"},
133-
).Return(0, nil).Once()
134-
testFileSystem.On("FileExists",
135-
mock.AnythingOfType("string"),
136-
).Return(false, nil).Once()
137-
138-
// Use mock to save off input args
139-
testFileSystem.On("WriteFile",
140-
mock.MatchedBy(func(filename string) bool {
141-
actualFilename = filename
142-
return true
143-
}),
144-
mock.MatchedBy(func(fileBytes any) bool {
145-
// Save off value and always match
146-
actualFileBytes = fileBytes.([]byte)
147-
return true
148-
}),
149-
).Return(nil).Once()
150-
151-
err := systemd.Create(&basicDaemonConfig, false)
152-
assert.Nil(t, err)
153-
mock.AssertExpectationsForObjects(t, testCommandExecutor, testFileSystem)
154-
155-
// Check filename
156-
expectedFilename := filepath.Clean(fmt.Sprintf("/my/test/dir/.config/systemd/user/%s.service", basicDaemonConfig.Label))
157-
assert.Equal(t, expectedFilename, actualFilename)
158-
159-
// Check file contents
160-
fileContents := string(actualFileBytes)
161-
assert.Contains(t, fileContents, fmt.Sprintf("ExecStart=%s", basicDaemonConfig.Program))
162-
assert.Contains(t, fileContents, fmt.Sprintf("Description=%s", basicDaemonConfig.Description))
163-
})
164+
for _, tt := range systemdCreateServiceUnitTests {
165+
t.Run(tt.title, func(t *testing.T) {
166+
var actualFilename string
167+
var actualFileBytes []byte
168+
169+
// Mock responses for successful fresh write
170+
testCommandExecutor.On("Run",
171+
"systemctl",
172+
[]string{"--user", "daemon-reload"},
173+
).Return(0, nil).Once()
174+
testFileSystem.On("FileExists",
175+
mock.AnythingOfType("string"),
176+
).Return(false, nil).Once()
177+
178+
// Use mock to save off input args
179+
testFileSystem.On("WriteFile",
180+
mock.MatchedBy(func(filename string) bool {
181+
actualFilename = filename
182+
return true
183+
}),
184+
mock.MatchedBy(func(fileBytes any) bool {
185+
// Save off value and always match
186+
actualFileBytes = fileBytes.([]byte)
187+
return true
188+
}),
189+
).Return(nil).Once()
190+
191+
err := systemd.Create(tt.config, false)
192+
assert.Nil(t, err)
193+
mock.AssertExpectationsForObjects(t, testCommandExecutor, testFileSystem)
194+
195+
// Check filename
196+
expectedFilename := filepath.Clean(fmt.Sprintf("/my/test/dir/.config/systemd/user/%s.service", tt.config.Label))
197+
assert.Equal(t, expectedFilename, actualFilename)
198+
199+
// Check file contents
200+
// Ensure there's no more than one newline between each line
201+
// before splitting the file.
202+
fileContents := strings.TrimSpace(string(actualFileBytes))
203+
serviceUnitLines := strings.Split(
204+
regexp.MustCompile(`\n+`).ReplaceAllString(fileContents, "\n"), "\n")
205+
assert.ElementsMatch(t, tt.expectedServiceUnitLines, serviceUnitLines)
206+
207+
// Reset mocks
208+
testCommandExecutor.Mock = mock.Mock{}
209+
testFileSystem.Mock = mock.Mock{}
210+
})
211+
}
164212
}
165213

166214
func TestSystemd_Start(t *testing.T) {

0 commit comments

Comments
 (0)