github.com/meulengracht/snapd@v0.0.0-20210719210640-8bde69bcc84e/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  }