forked from canonical/etrace
/
cmd_file.go
392 lines (350 loc) · 11.7 KB
/
cmd_file.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
/*
* Copyright (C) 2019-2021 Canonical Ltd
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 3 as
* published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
package main
import (
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"math"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/anonymouse64/etrace/internal/files"
"github.com/anonymouse64/etrace/internal/profiling"
"github.com/anonymouse64/etrace/internal/snaps"
"github.com/anonymouse64/etrace/internal/strace"
"github.com/anonymouse64/etrace/internal/xdotool"
"golang.org/x/net/context"
)
type cmdFile struct {
FileRegex string `long:"file-regex" description:"Regular expression of files to return, if empty all files are returned"`
ParentDirPaths []string `long:"parent-dirs" description:"List of parent directories matching files must be underneath to match"`
ProgramRegex string `long:"program-regex" description:"Regular expression of programs whose file accesses should be returned"`
IncludeSnapdPrograms bool `long:"include-snapd-programs" description:"Include snapd programs whose file accesses match in the list of files accessed"`
ShowPrograms bool `long:"show-programs" description:"Show programs that accessed the files"`
Args struct {
Cmd []string `description:"Command to run" required:"yes"`
} `positional-args:"yes" required:"yes"`
}
// FileOutputResult is the result of running a command with various information
// encoded in it
type FileOutputResult struct {
ExecvePaths *strace.ExecvePaths `json:",omitempty"`
TimeToDisplay time.Duration `json:",omitempty"`
Errors []string `json:",omitempty"`
}
func (x *cmdFile) Execute(args []string) error {
if currentCmd.RunThroughFlatpak {
return fmt.Errorf("file tracing with flatpak not yet supported")
}
if currentCmd.SilentProgram {
currentCmd.ProgramStderrLog = "/dev/null"
currentCmd.ProgramStdoutLog = "/dev/null"
}
if !currentCmd.NoWindowWait {
// check if we are running on X11, if not then bail because we don't
// support graphical window waiting on wayland yet
sessionType := os.Getenv("XDG_SESSION_TYPE")
if strings.TrimSpace(strings.ToLower(sessionType)) != "x11" {
return fmt.Errorf("error: graphical session type %s is unsupported, only x11 is supported", sessionType)
}
}
// check if the snap is installed first if --use-snap-run is specified
if currentCmd.RunThroughSnap {
if _, err := exec.Command("snap", "list", x.Args.Cmd[0]).CombinedOutput(); err != nil {
// then the snap is assumed to not be installed
return fmt.Errorf("snap %s is not installed", x.Args.Cmd[0])
}
}
// check the output file
w := os.Stdout
if currentCmd.OutputFile != "" {
// TODO: add option for appending?
// if the file already exists, delete it and open a new file
file, err := files.EnsureExistsAndOpen(currentCmd.OutputFile, true)
if err != nil {
return err
}
w = file
}
// run the prepare script if it's available
if currentCmd.PrepareScript != "" {
err := profiling.RunScript(currentCmd.PrepareScript, currentCmd.PrepareScriptArgs)
if err != nil {
logError(fmt.Errorf("running prepare script: %w", err))
}
}
// handle if the command should be run through `snap run`
targetCmd := x.Args.Cmd
if currentCmd.RunThroughSnap {
targetCmd = append([]string{"snap", "run"}, targetCmd...)
}
var cmd *exec.Cmd
// setup private tmp dir to use for strace logs
straceTmp, err := ioutil.TempDir("", "file-trace")
if err != nil {
return err
}
defer os.RemoveAll(straceTmp)
// make sure the file doesn't somehow already exist
straceLog := filepath.Join(straceTmp, "strace.log")
err = files.EnsureFileIsDeleted(straceLog)
if err != nil {
return err
}
cmd, err = strace.TraceFilesCommand(straceLog, targetCmd...)
if err != nil {
return err
}
// setup cmd's streams
cmd.Stdin = os.Stdin
// redirect all output from the child process to the log files if they exist
// otherwise just to this process's stdout, etc.
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if currentCmd.ProgramStdoutLog != "" {
f, err := files.EnsureExistsAndOpen(currentCmd.ProgramStdoutLog, false)
if err != nil {
return err
}
defer f.Close()
cmd.Stdout = f
}
if currentCmd.ProgramStderrLog != "" {
f, err := files.EnsureExistsAndOpen(currentCmd.ProgramStderrLog, false)
if err != nil {
return err
}
defer f.Close()
cmd.Stderr = f
}
if currentCmd.DiscardSnapNs {
if !currentCmd.RunThroughSnap {
return errors.New("cannot use --discard-snap-ns without --use-snap-run")
}
// the name of the snap in this case is the first argument
err := snaps.DiscardSnapNs(x.Args.Cmd[0])
if err != nil {
return err
}
}
// handle the file regex
var fileRegex *regexp.Regexp
switch {
case x.FileRegex != "" && len(x.ParentDirPaths) != 0:
return errors.New("cannot use --file-regex with --parent-dirs")
case x.FileRegex != "":
// check that what the user passed in is a correct regex
fileRegex, err = regexp.Compile(x.FileRegex)
if err != nil {
return fmt.Errorf("invalid setting for --file-regex (%q): %v", x.FileRegex, err)
}
case len(x.ParentDirPaths) != 0:
// build the regex to only match files rooted under the specified paths
// all of the paths are assumed to be directories
// the start of the capturing group
fileRegexStr := "("
for i, dir := range x.ParentDirPaths {
// escape the slash character since it is a special regexp char
s := strings.Replace(filepath.Clean(dir), "/", `\/`, -1)
// then add conditional ending, so that we both catch any files
// below this directory, as well as this directory itself
s += `/.*`
// add to the regex
fileRegexStr += s
// on all dirs except the last one, add a "|" to or the path
if i != len(x.ParentDirPaths)-1 {
fileRegexStr += "|"
}
}
fileRegexStr += ")"
fileRegex, err = regexp.Compile(fileRegexStr)
if err != nil {
return fmt.Errorf("internal error compiling regex for --parent-dirs setting (%v): %v", x.ParentDirPaths, err)
}
default:
// default case is to match all files, so use ".*" as the regexp
fileRegex = regexp.MustCompile(".*")
}
// now handle the executable program patterns
var programRegex *regexp.Regexp
if x.ProgramRegex != "" {
programRegex, err = regexp.Compile(x.ProgramRegex)
if err != nil {
return fmt.Errorf("invalid setting for --program-regex (%q): %v", x.ProgramRegex, err)
}
} else {
// include all programs
programRegex = regexp.MustCompile(".*")
}
// ideally we would use a negative lookahead regex to implement exclusion listing
// certain programs in the programRegex, but go doesn't support those and
// I'm not ready to jump ship to use a non-stdlib regex lib, so for now
// we will just use globs to match snap binaries from /usr/lib/snapd/
// /snap/core/*/<snap tool path> and /snap/snapd/*/<snap tool path>
excludeListProgramPatterns := []string{
// all installs
"/usr/bin/snap",
"/usr/lib/snapd/*",
"/sbin/apparmor_parser",
// core snap programs
"/snap/core/*/usr/bin/snap",
"/snap/core/*/usr/lib/snapd/*",
// snapd snap
"/snap/snapd/*/usr/bin/snap",
"/snap/snapd/*/usr/lib/snapd/*",
}
if x.IncludeSnapdPrograms {
excludeListProgramPatterns = []string{}
}
windowWaitTimeout := time.Duration(math.MaxInt64)
if currentCmd.WindowWaitGlobalTimeout != "" {
duration, err := time.ParseDuration(currentCmd.WindowWaitGlobalTimeout)
if err != nil {
return err
}
windowWaitTimeout = duration
}
xtool := xdotool.MakeXDoTool()
tryXToolClose := true
var wids []string
windowspec := xdotool.Window{}
// check which opts are defined
if currentCmd.WindowClass != "" {
// prefer window class from option
windowspec.Class = currentCmd.WindowClass
} else if currentCmd.WindowName != "" {
// then window name
windowspec.Name = currentCmd.WindowName
} else if currentCmd.WindowClassName != "" {
// then window class name
windowspec.ClassName = currentCmd.WindowClassName
} else {
// finally fall back to base cmd as the class
// note we use the original command and note the processed targetCmd
// because for example when measuring a snap, we invoke etrace like so:
// $ ./etrace run --use-snap chromium
// where targetCmd becomes []string{"snap","run","chromium"}
// but we still want to use "chromium" as the windowspec class
windowspec.Class = filepath.Base(x.Args.Cmd[0])
}
// before running the final command, free the caches to get most accurate
// timing
if !currentCmd.KeepVMCaches {
if err := profiling.FreeCaches(); err != nil {
return err
}
}
// start running the command
start := time.Now()
if err := cmd.Start(); err != nil {
return err
}
if currentCmd.NoWindowWait {
// if we aren't waiting on the window class, then just wait for the
// command to return
cmd.Wait()
} else {
ctx, cancel := context.WithTimeout(context.Background(), windowWaitTimeout)
defer cancel()
// now wait until the window appears
wids, err = xtool.WaitForWindow(ctx, windowspec)
if errors.Is(err, context.DeadlineExceeded) {
// we timed out waiting for the process, just kill the main
// command and return an error
if err := cmd.Process.Kill(); err != nil {
logError(err)
}
return err
} else if err != nil {
logError(fmt.Errorf("waiting for window appearance: %w", err))
// if we don't get the wid properly then we can't try closing
tryXToolClose = false
}
}
// save the startup time
startup := time.Since(start)
// now get the pids before closing the window so we can gracefully try
// closing the windows before forcibly killing them later
if tryXToolClose {
pids := make([]int, len(wids))
for i, wid := range wids {
pid, err := xtool.PidForWindowID(wid)
if err != nil {
logError(fmt.Errorf("getting pid for wid %s: %w", wid, err))
break
}
pids[i] = pid
}
// close the windows
for _, wid := range wids {
if err := xtool.CloseWindowID(wid); err != nil {
logError(fmt.Errorf("closing window: %w", err))
}
}
// kill the app pids in case x fails to close the window
for _, pid := range pids {
// FindProcess always succeeds on unix
proc, _ := os.FindProcess(pid)
if err := proc.Signal(os.Kill); err != nil {
// if the process already exited then try wmctrl
if !strings.Contains(err.Error(), "process already finished") {
logError(fmt.Errorf("killing window process pid %d: %w", pid, err))
}
}
}
}
// parse the strace log
execFiles, err := strace.TraceExecveWithFiles(
straceLog,
fileRegex,
programRegex,
excludeListProgramPatterns,
)
if err != nil {
logError(fmt.Errorf("cannot extract runtime data: %w", err))
}
if currentCmd.RestoreScript != "" {
err := profiling.RunScript(currentCmd.RestoreScript, currentCmd.RestoreScriptArgs)
if err != nil {
logError(fmt.Errorf("running restore script: %w", err))
}
}
// output the result either in JSON or using the execve files result
// Display() method
if currentCmd.JSONOutput {
outRes := FileOutputResult{
TimeToDisplay: startup,
Errors: errs,
ExecvePaths: execFiles,
}
json.NewEncoder(w).Encode(outRes)
} else {
// make a new tabwriter to stderr
wtab := tabWriterGeneric(w)
opts := &strace.DisplayOptions{}
if !x.ShowPrograms {
opts.NoDisplayPrograms = true
}
execFiles.Display(wtab, opts)
}
return nil
}