github.com/ethanhsieh/snapd@v0.0.0-20210615102523-3db9b8e4edc5/usersession/userd/privileged_desktop_launcher.go (about) 1 // -*- Mode: Go; indent-tabs-mode: t -*- 2 3 /* 4 * Copyright (C) 2021 Canonical Ltd 5 * 6 * This program is free software: you can redistribute it and/or modify 7 * it under the terms of the GNU General Public License version 3 as 8 * published by the Free Software Foundation. 9 * 10 * This program is distributed in the hope that it will be useful, 11 * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 * GNU General Public License for more details. 14 * 15 * You should have received a copy of the GNU General Public License 16 * along with this program. If not, see <http://www.gnu.org/licenses/>. 17 * 18 */ 19 20 package userd 21 22 import ( 23 "bufio" 24 "fmt" 25 "os" 26 "os/exec" 27 "path/filepath" 28 "regexp" 29 "strings" 30 31 "github.com/godbus/dbus" 32 33 "github.com/snapcore/snapd/dirs" 34 "github.com/snapcore/snapd/osutil" 35 "github.com/snapcore/snapd/strutil/shlex" 36 "github.com/snapcore/snapd/systemd" 37 ) 38 39 const privilegedLauncherIntrospectionXML = ` 40 <interface name="org.freedesktop.DBus.Peer"> 41 <method name='Ping'> 42 </method> 43 <method name='GetMachineId'> 44 <arg type='s' name='machine_uuid' direction='out'/> 45 </method> 46 </interface> 47 <interface name='io.snapcraft.PrivilegedDesktopLauncher'> 48 <method name='OpenDesktopEntry'> 49 <arg type='s' name='desktop_file_id' direction='in'/> 50 </method> 51 </interface>` 52 53 // PrivilegedDesktopLauncher implements the 'io.snapcraft.PrivilegedDesktopLauncher' DBus interface. 54 type PrivilegedDesktopLauncher struct { 55 conn *dbus.Conn 56 } 57 58 // Name returns the name of the interface this object implements 59 func (s *PrivilegedDesktopLauncher) Interface() string { 60 return "io.snapcraft.PrivilegedDesktopLauncher" 61 } 62 63 // BasePath returns the base path of the object 64 func (s *PrivilegedDesktopLauncher) ObjectPath() dbus.ObjectPath { 65 return "/io/snapcraft/PrivilegedDesktopLauncher" 66 } 67 68 // IntrospectionData gives the XML formatted introspection description 69 // of the DBus service. 70 func (s *PrivilegedDesktopLauncher) IntrospectionData() string { 71 return privilegedLauncherIntrospectionXML 72 } 73 74 // OpenDesktopEntry implements the 'OpenDesktopEntry' method of the 'io.snapcraft.DesktopLauncher' 75 // DBus interface. The desktopFileID is described here: 76 // https://standards.freedesktop.org/desktop-entry-spec/desktop-entry-spec-latest.html#desktop-file-id 77 func (s *PrivilegedDesktopLauncher) OpenDesktopEntry(desktopFileID string, sender dbus.Sender) *dbus.Error { 78 desktopFile, err := desktopFileIDToFilename(desktopFileID) 79 if err != nil { 80 return dbus.MakeFailedError(err) 81 } 82 83 err = verifyDesktopFileLocation(desktopFile) 84 if err != nil { 85 return dbus.MakeFailedError(err) 86 } 87 88 command, icon, err := readExecCommandFromDesktopFile(desktopFile) 89 if err != nil { 90 return dbus.MakeFailedError(err) 91 } 92 93 args, err := parseExecCommand(command, icon) 94 if err != nil { 95 return dbus.MakeFailedError(err) 96 } 97 98 ver, err := systemd.Version() 99 if err != nil { 100 return dbus.MakeFailedError(err) 101 } 102 // systemd 236 introduced the --collect option to systemd-run, 103 // which specifies that the unit should be garbage collected 104 // even if it fails. 105 // https://github.com/systemd/systemd/pull/7314 106 if ver >= 236 { 107 args = append([]string{"systemd-run", "--user", "--collect", "--"}, args...) 108 } else { 109 args = append([]string{"systemd-run", "--user", "--"}, args...) 110 } 111 112 cmd := exec.Command(args[0], args[1:]...) 113 114 if err := cmd.Run(); err != nil { 115 return dbus.MakeFailedError(fmt.Errorf("cannot run %q: %v", command, err)) 116 } 117 118 return nil 119 } 120 121 var regularFileExists = osutil.RegularFileExists 122 123 // desktopFileSearchPath returns the list of directories where desktop 124 // files may be located. It implements the lookup rules documented in 125 // the XDG Base Directory specification. 126 func desktopFileSearchPath() []string { 127 var desktopDirs []string 128 129 // First check $XDG_DATA_HOME, which defaults to $HOME/.local/share 130 dataHome := os.Getenv("XDG_DATA_HOME") 131 if dataHome == "" { 132 homeDir := os.Getenv("HOME") 133 if homeDir != "" { 134 dataHome = filepath.Join(homeDir, ".local/share") 135 } 136 } 137 if dataHome != "" { 138 desktopDirs = append(desktopDirs, filepath.Join(dataHome, "applications")) 139 } 140 141 // Next check $XDG_DATA_DIRS, with default from spec 142 dataDirs := os.Getenv("XDG_DATA_DIRS") 143 if dataDirs == "" { 144 dataDirs = "/usr/local/share/:/usr/share/" 145 } 146 for _, dir := range strings.Split(dataDirs, ":") { 147 if dir == "" { 148 continue 149 } 150 desktopDirs = append(desktopDirs, filepath.Join(dir, "applications")) 151 } 152 153 return desktopDirs 154 } 155 156 // findDesktopFile recursively tries each subdirectory that can be formed from the (split) desktop file ID. 157 // Per https://standards.freedesktop.org/desktop-entry-spec/desktop-entry-spec-latest.html#desktop-file-id, 158 // if desktop entries have dashes in the name ('-'), this could be an indication of subdirectories, so search 159 // for those too. Eg, given foo-bar_baz_norf.desktop the following are searched for: 160 // o .../foo-bar_baz-norf.desktop 161 // o .../foo/bar_baz-norf.desktop 162 // o .../foo/bar_baz/norf.desktop 163 // o .../foo-bar_baz/norf.desktop 164 // We're not required to diagnose multiple files matching the desktop file ID. 165 func findDesktopFile(baseDir string, splitFileId []string) (string, error) { 166 desktopFile := filepath.Join(baseDir, strings.Join(splitFileId, "-")) 167 168 exists, isReg, _ := regularFileExists(desktopFile) 169 if exists && isReg { 170 return desktopFile, nil 171 } 172 173 // Iterate through the potential subdirectories formed by the first i elements of the desktop file ID. 174 for i := 1; i != len(splitFileId); i++ { 175 prefix := strings.Join(splitFileId[:i], "-") 176 // Don't treat empty or "." components as directory 177 // prefixes. The ".." case is already filtered out by 178 // the isValidDesktopFileID regexp. 179 if prefix == "" || prefix == "." { 180 continue 181 } 182 desktopFile, err := findDesktopFile(filepath.Join(baseDir, prefix), splitFileId[i:]) 183 if err == nil { 184 return desktopFile, nil 185 } 186 } 187 188 return "", fmt.Errorf("could not find desktop file") 189 } 190 191 // isValidDesktopFileID is based on the "File naming" section of the 192 // Desktop Entry Specification, without the restriction on components 193 // not starting with a digit (which desktop files created by snapd may 194 // not satisfy). 195 var isValidDesktopFileID = regexp.MustCompile(`^[A-Za-z0-9-_]+(\.[A-Za-z0-9-_]+)*.desktop$`).MatchString 196 197 // desktopFileIDToFilename determines the path associated with a desktop file ID. 198 func desktopFileIDToFilename(desktopFileID string) (string, error) { 199 if !isValidDesktopFileID(desktopFileID) { 200 return "", fmt.Errorf("cannot find desktop file for %q", desktopFileID) 201 } 202 203 splitDesktopID := strings.Split(desktopFileID, "-") 204 for _, baseDir := range desktopFileSearchPath() { 205 if desktopFile, err := findDesktopFile(baseDir, splitDesktopID); err == nil { 206 return desktopFile, nil 207 } 208 } 209 210 return "", fmt.Errorf("cannot find desktop file for %q", desktopFileID) 211 } 212 213 // verifyDesktopFileLocation checks the desktop file location: 214 // we only consider desktop files in dirs.SnapDesktopFilesDir 215 func verifyDesktopFileLocation(desktopFile string) error { 216 if filepath.Clean(desktopFile) != desktopFile { 217 return fmt.Errorf("desktop file has unclean path: %q", desktopFile) 218 } 219 220 if !strings.HasPrefix(desktopFile, dirs.SnapDesktopFilesDir+"/") { 221 // We currently only support launching snap applications from desktop files in 222 // /var/lib/snapd/desktop/applications. 223 return fmt.Errorf("only launching snap applications from %s is supported", dirs.SnapDesktopFilesDir) 224 } 225 226 return nil 227 } 228 229 // readExecCommandFromDesktopFile parses the desktop file to get the Exec entry and 230 // checks that the BAMF_DESKTOP_FILE_HINT is present and refers to the desktop file. 231 func readExecCommandFromDesktopFile(desktopFile string) (exec string, icon string, err error) { 232 file, err := os.Open(desktopFile) 233 if err != nil { 234 return exec, icon, err 235 } 236 defer file.Close() 237 scanner := bufio.NewScanner(file) 238 239 var inDesktopSection, seenDesktopSection bool 240 for scanner.Scan() { 241 line := strings.TrimSpace(scanner.Text()) 242 243 if line == "[Desktop Entry]" { 244 if seenDesktopSection { 245 return "", "", fmt.Errorf("desktop file %q has multiple [Desktop Entry] sections", desktopFile) 246 } 247 seenDesktopSection = true 248 inDesktopSection = true 249 } else if strings.HasPrefix(line, "[Desktop Action ") { 250 // TODO: add support for desktop action sections 251 inDesktopSection = false 252 } else if strings.HasPrefix(line, "[") { 253 inDesktopSection = false 254 } else if inDesktopSection { 255 if strings.HasPrefix(line, "Exec=") { 256 exec = strings.TrimPrefix(line, "Exec=") 257 } else if strings.HasPrefix(line, "Icon=") { 258 icon = strings.TrimPrefix(line, "Icon=") 259 } 260 } 261 } 262 263 expectedPrefix := fmt.Sprintf("env BAMF_DESKTOP_FILE_HINT=%s %s", desktopFile, dirs.SnapBinariesDir) 264 if !strings.HasPrefix(exec, expectedPrefix) { 265 return "", "", fmt.Errorf("desktop file %q has an unsupported 'Exec' value: %q", desktopFile, exec) 266 } 267 268 return exec, icon, nil 269 } 270 271 // Parse the Exec command by stripping any exec variables. 272 // Passing exec variables (eg, %foo) between confined snaps is unsupported. Currently, 273 // we do not have support for passing them in the D-Bus API but there are security 274 // implications that must be thought through regarding the influence of the launching 275 // snap over the launcher wrt exec variables. For now we simply filter them out. 276 // https://standards.freedesktop.org/desktop-entry-spec/desktop-entry-spec-latest.html#exec-variables 277 func parseExecCommand(command string, icon string) ([]string, error) { 278 origArgs, err := shlex.Split(command) 279 if err != nil { 280 return nil, err 281 } 282 283 args := make([]string, 0, len(origArgs)) 284 for _, arg := range origArgs { 285 // We want to keep literal '%' (expressed as '%%') but filter our exec variables 286 // like '%foo' 287 if strings.HasPrefix(arg, "%%") { 288 arg = arg[1:] 289 } else if strings.HasPrefix(arg, "%") { 290 switch arg { 291 case "%f", "%F", "%u", "%U": 292 // If we were launching a file with 293 // the application, these variables 294 // would expand to file names or URIs. 295 // As we're not, they are simply 296 // removed from the argument list. 297 case "%i": 298 args = append(args, "--icon", icon) 299 default: 300 return nil, fmt.Errorf("cannot run %q due to use of %q", command, arg) 301 } 302 continue 303 } 304 args = append(args, arg) 305 } 306 return args, nil 307 }