Skip to content

Commit 9d1ed78

Browse files
committed
launchd: replace plist template with XML encoder
Replace the template-based creation of a 'plist' file (used to configure a launchd daemon) with XML marshalling. There are a couple of benefits to configuring the 'plist' with Go's XML infrastructure, mainly built-in character escaping and avoiding the fragility of defining the structured XML format as a plaintext string. As part of this change, add more tests of 'plist' content, and make the tests more thoroughly verify file contents ensuring: - that the generated plist has all the expected options - that the plist doesn't include more options than it should - that characters are properly escaped Signed-off-by: Victoria Dye <[email protected]>
1 parent 6b3b588 commit 9d1ed78

File tree

2 files changed

+178
-64
lines changed

2 files changed

+178
-64
lines changed

internal/daemon/launchd.go

Lines changed: 51 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,24 +2,44 @@ package daemon
22

33
import (
44
"bytes"
5+
"encoding/xml"
56
"fmt"
67
"path/filepath"
7-
"text/template"
88

99
"github.com/github/git-bundle-server/internal/common"
1010
)
1111

12-
const launchTemplate string = `<?xml version="1.0" encoding="UTF-8"?>
13-
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
14-
<plist version="1.0">
15-
<dict>
16-
<key>Label</key><string>{{.Label}}</string>
17-
<key>Program</key><string>{{.Program}}</string>
18-
<key>StandardOutPath</key><string>{{.StdOut}}</string>
19-
<key>StandardErrorPath</key><string>{{.StdErr}}</string>
20-
</dict>
21-
</plist>
22-
`
12+
type xmlItem struct {
13+
XMLName xml.Name
14+
Value string `xml:",chardata"`
15+
}
16+
17+
type xmlArray struct {
18+
XMLName xml.Name
19+
Elements []interface{} `xml:",any"`
20+
}
21+
22+
type plist struct {
23+
XMLName xml.Name `xml:"plist"`
24+
Version string `xml:"version,attr"`
25+
Config xmlArray `xml:"dict"`
26+
}
27+
28+
const plistHeader string = `<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">`
29+
30+
func xmlName(name string) xml.Name {
31+
return xml.Name{Local: name}
32+
}
33+
34+
func (p *plist) addKeyValue(key string, value any) {
35+
p.Config.Elements = append(p.Config.Elements, xmlItem{XMLName: xmlName("key"), Value: key})
36+
switch value := value.(type) {
37+
case string:
38+
p.Config.Elements = append(p.Config.Elements, xmlItem{XMLName: xmlName("string"), Value: value})
39+
default:
40+
panic("Invalid value type in 'addKeyValue'")
41+
}
42+
}
2343

2444
const domainFormat string = "gui/%s"
2545

@@ -31,6 +51,19 @@ type launchdConfig struct {
3151
StdErr string
3252
}
3353

54+
func (c *launchdConfig) toPlist() *plist {
55+
p := &plist{
56+
Version: "1.0",
57+
Config: xmlArray{Elements: []interface{}{}},
58+
}
59+
p.addKeyValue("Label", c.Label)
60+
p.addKeyValue("Program", c.Program)
61+
p.addKeyValue("StandardOutPath", c.StdOut)
62+
p.addKeyValue("StandardErrorPath", c.StdErr)
63+
64+
return p
65+
}
66+
3467
type launchd struct {
3568
user common.UserProvider
3669
cmdExec common.CommandExecutor
@@ -104,11 +137,14 @@ func (l *launchd) Create(config *DaemonConfig, force bool) error {
104137

105138
// Generate the configuration
106139
var newPlist bytes.Buffer
107-
t, err := template.New(config.Label).Parse(launchTemplate)
140+
newPlist.WriteString(xml.Header)
141+
newPlist.WriteString(plistHeader)
142+
encoder := xml.NewEncoder(&newPlist)
143+
encoder.Indent("", " ")
144+
err := encoder.Encode(lConfig.toPlist())
108145
if err != nil {
109-
return fmt.Errorf("unable to generate launchd configuration: %w", err)
146+
return fmt.Errorf("could not encode plist: %w", err)
110147
}
111-
t.Execute(&newPlist, lConfig)
112148

113149
// Check the existing file - if it's the same as the new content, do not overwrite
114150
user, err := l.user.CurrentUser()

internal/daemon/launchd_test.go

Lines changed: 127 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,16 @@ import (
55
"fmt"
66
"os/user"
77
"path/filepath"
8+
"regexp"
9+
"strings"
810
"testing"
911

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

15-
var launchdCreateTests = []struct {
17+
var launchdCreateBehaviorTests = []struct {
1618
title string
1719

1820
// Inputs
@@ -97,6 +99,74 @@ var launchdCreateTests = []struct {
9799
},
98100
}
99101

102+
var launchdCreatePlistTests = []struct {
103+
title string
104+
105+
// Inputs
106+
config *daemon.DaemonConfig
107+
108+
// Expected values
109+
expectedPlistLines []string
110+
}{
111+
{
112+
title: "Created plist is correct",
113+
config: &basicDaemonConfig,
114+
expectedPlistLines: []string{
115+
`<?xml version="1.0" encoding="UTF-8"?>`,
116+
`<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">`,
117+
`<plist version="1.0">`,
118+
"<dict>",
119+
120+
"<key>Label</key>",
121+
fmt.Sprintf("<string>%s</string>", basicDaemonConfig.Label),
122+
123+
"<key>Program</key>",
124+
fmt.Sprintf("<string>%s</string>", basicDaemonConfig.Program),
125+
126+
"<key>StandardOutPath</key>",
127+
"<string>/dev/null</string>",
128+
129+
"<key>StandardErrorPath</key>",
130+
"<string>/dev/null</string>",
131+
132+
"</dict>",
133+
"</plist>",
134+
},
135+
},
136+
{
137+
title: "Plist contents are escaped",
138+
config: &daemon.DaemonConfig{
139+
// All of <'&"\t> should be replaced by the associated escape code
140+
// 🤔 is in-range for XML (no replacement), but ￿ (\uFFFF) is
141+
// out-of-range and replaced with � (\uFFFD)
142+
// See https://www.w3.org/TR/xml11/Overview.html#charsets for details
143+
Label: "test-escape<'&\" 🤔￿>",
144+
Program: "/path/to/the/program with a space",
145+
},
146+
expectedPlistLines: []string{
147+
`<?xml version="1.0" encoding="UTF-8"?>`,
148+
`<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">`,
149+
`<plist version="1.0">`,
150+
"<dict>",
151+
152+
"<key>Label</key>",
153+
"<string>test-escape&lt;&#39;&amp;&#34;&#x9;🤔�&gt;</string>",
154+
155+
"<key>Program</key>",
156+
"<string>/path/to/the/program with a space</string>",
157+
158+
"<key>StandardOutPath</key>",
159+
"<string>/dev/null</string>",
160+
161+
"<key>StandardErrorPath</key>",
162+
"<string>/dev/null</string>",
163+
164+
"</dict>",
165+
"</plist>",
166+
},
167+
},
168+
}
169+
100170
func TestLaunchd_Create(t *testing.T) {
101171
// Set up mocks
102172
testUser := &user.User{
@@ -114,7 +184,7 @@ func TestLaunchd_Create(t *testing.T) {
114184
launchd := daemon.NewLaunchdProvider(testUserProvider, testCommandExecutor, testFileSystem)
115185

116186
// Verify launchd commands called
117-
for _, tt := range launchdCreateTests {
187+
for _, tt := range launchdCreateBehaviorTests {
118188
forceArg := tt.force.toBoolList()
119189
for _, force := range forceArg {
120190
t.Run(fmt.Sprintf("%s (force='%t')", tt.title, force), func(t *testing.T) {
@@ -168,53 +238,61 @@ func TestLaunchd_Create(t *testing.T) {
168238
}
169239

170240
// Verify content of created file
171-
t.Run("Created file content and path are correct", func(t *testing.T) {
172-
var actualFilename string
173-
var actualFileBytes []byte
174-
175-
// Mock responses for successful fresh write
176-
testCommandExecutor.On("Run",
177-
"launchctl",
178-
mock.MatchedBy(func(args []string) bool { return args[0] == "print" }),
179-
).Return(daemon.LaunchdServiceNotFoundErrorCode, nil).Once()
180-
testCommandExecutor.On("Run",
181-
"launchctl",
182-
mock.MatchedBy(func(args []string) bool { return args[0] == "bootstrap" }),
183-
).Return(0, nil).Once()
184-
testFileSystem.On("FileExists",
185-
mock.AnythingOfType("string"),
186-
).Return(false, nil).Once()
187-
188-
// Use mock to save off input args
189-
testFileSystem.On("WriteFile",
190-
mock.MatchedBy(func(filename string) bool {
191-
actualFilename = filename
192-
return true
193-
}),
194-
mock.MatchedBy(func(fileBytes any) bool {
195-
// Save off value and always match
196-
actualFileBytes = fileBytes.([]byte)
197-
return true
198-
}),
199-
).Return(nil).Once()
200-
201-
err := launchd.Create(&basicDaemonConfig, false)
202-
assert.Nil(t, err)
203-
mock.AssertExpectationsForObjects(t, testCommandExecutor, testFileSystem)
204-
205-
// Check filename
206-
expectedFilename := filepath.Clean(fmt.Sprintf("/my/test/dir/Library/LaunchAgents/%s.plist", basicDaemonConfig.Label))
207-
assert.Equal(t, expectedFilename, actualFilename)
208-
209-
// Check file contents
210-
err = xml.Unmarshal(actualFileBytes, new(interface{}))
211-
if err != nil {
212-
assert.Fail(t, "plist XML is malformed")
213-
}
214-
fileContents := string(actualFileBytes)
215-
assert.Contains(t, fileContents, fmt.Sprintf("<key>Label</key><string>%s</string>", basicDaemonConfig.Label))
216-
assert.Contains(t, fileContents, fmt.Sprintf("<key>Program</key><string>%s</string>", basicDaemonConfig.Program))
217-
})
241+
for _, tt := range launchdCreatePlistTests {
242+
t.Run(tt.title, func(t *testing.T) {
243+
var actualFilename string
244+
var actualFileBytes []byte
245+
246+
// Mock responses for successful fresh write
247+
testCommandExecutor.On("Run",
248+
"launchctl",
249+
mock.MatchedBy(func(args []string) bool { return args[0] == "print" }),
250+
).Return(daemon.LaunchdServiceNotFoundErrorCode, nil).Once()
251+
testCommandExecutor.On("Run",
252+
"launchctl",
253+
mock.MatchedBy(func(args []string) bool { return args[0] == "bootstrap" }),
254+
).Return(0, nil).Once()
255+
testFileSystem.On("FileExists",
256+
mock.AnythingOfType("string"),
257+
).Return(false, nil).Once()
258+
259+
// Use mock to save off input args
260+
testFileSystem.On("WriteFile",
261+
mock.MatchedBy(func(filename string) bool {
262+
actualFilename = filename
263+
return true
264+
}),
265+
mock.MatchedBy(func(fileBytes any) bool {
266+
// Save off value and always match
267+
actualFileBytes = fileBytes.([]byte)
268+
return true
269+
}),
270+
).Return(nil).Once()
271+
272+
err := launchd.Create(tt.config, false)
273+
assert.Nil(t, err)
274+
mock.AssertExpectationsForObjects(t, testCommandExecutor, testFileSystem)
275+
276+
// Check filename
277+
expectedFilename := filepath.Clean(fmt.Sprintf("/my/test/dir/Library/LaunchAgents/%s.plist", tt.config.Label))
278+
assert.Equal(t, expectedFilename, actualFilename)
279+
280+
// Check XML
281+
err = xml.Unmarshal(actualFileBytes, new(interface{}))
282+
if err != nil {
283+
assert.Fail(t, "plist XML is malformed")
284+
}
285+
fileContents := strings.TrimSpace(string(actualFileBytes))
286+
plistLines := strings.Split(
287+
regexp.MustCompile(`>\s*<`).ReplaceAllString(fileContents, ">\n<"), "\n")
288+
289+
assert.ElementsMatch(t, tt.expectedPlistLines, plistLines)
290+
291+
// Reset mocks
292+
testCommandExecutor.Mock = mock.Mock{}
293+
testFileSystem.Mock = mock.Mock{}
294+
})
295+
}
218296
}
219297

220298
func TestLaunchd_Start(t *testing.T) {

0 commit comments

Comments
 (0)