Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

wrappers: allow snaps to install icon theme icons #6767

Merged
merged 15 commits into from Sep 17, 2019
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
5 changes: 5 additions & 0 deletions dirs/dirs.go
Expand Up @@ -88,6 +88,7 @@ var (
SnapUserServicesDir string
SnapSystemdConfDir string
SnapDesktopFilesDir string
SnapDesktopIconsDir string
SnapBusPolicyDir string

SystemApparmorDir string
Expand Down Expand Up @@ -242,7 +243,11 @@ func SetRootDir(rootdir string) {
SnapMountPolicyDir = filepath.Join(rootdir, snappyDir, "mount")
SnapMetaDir = filepath.Join(rootdir, snappyDir, "meta")
SnapBlobDir = SnapBlobDirUnder(rootdir)
// ${snappyDir}/desktop is added to $XDG_DATA_DIRS.
// Subdirectories are interpreted according to the relevant
// freedesktop.org specifications
SnapDesktopFilesDir = filepath.Join(rootdir, snappyDir, "desktop", "applications")
zyga marked this conversation as resolved.
Show resolved Hide resolved
SnapDesktopIconsDir = filepath.Join(rootdir, snappyDir, "desktop", "icons")
SnapRunDir = filepath.Join(rootdir, "/run/snapd")
SnapRunNsDir = filepath.Join(SnapRunDir, "/ns")
SnapRunLockDir = filepath.Join(SnapRunDir, "/lock")
Expand Down
40 changes: 32 additions & 8 deletions overlord/snapstate/backend/link.go
Expand Up @@ -143,22 +143,41 @@ func (b Backend) StopServices(apps []*snap.AppInfo, reason snap.ServiceStopReaso
return wrappers.StopServices(apps, reason, meter, tm)
}

func generateWrappers(s *snap.Info) error {
func generateWrappers(s *snap.Info) (err error) {
var cleanupFuncs []func(*snap.Info) error
defer func() {
if err != nil {
for _, cleanup := range cleanupFuncs {
cleanup(s)
}
}
}()

// add the CLI apps from the snap.yaml
if err := wrappers.AddSnapBinaries(s); err != nil {
if err = wrappers.AddSnapBinaries(s); err != nil {
return err
}
cleanupFuncs = append(cleanupFuncs, wrappers.RemoveSnapBinaries)

// add the daemons from the snap.yaml
if err := wrappers.AddSnapServices(s, progress.Null); err != nil {
wrappers.RemoveSnapBinaries(s)
if err = wrappers.AddSnapServices(s, progress.Null); err != nil {
return err
}
cleanupFuncs = append(cleanupFuncs, func(s *snap.Info) error {
return wrappers.RemoveSnapServices(s, progress.Null)
})

// add the desktop files
if err := wrappers.AddSnapDesktopFiles(s); err != nil {
wrappers.RemoveSnapServices(s, progress.Null)
wrappers.RemoveSnapBinaries(s)
if err = wrappers.AddSnapDesktopFiles(s); err != nil {
zyga marked this conversation as resolved.
Show resolved Hide resolved
return err
}
cleanupFuncs = append(cleanupFuncs, wrappers.RemoveSnapDesktopFiles)

// add the desktop icons
if err = wrappers.AddSnapIcons(s); err != nil {
return err
}
cleanupFuncs = append(cleanupFuncs, wrappers.RemoveSnapIcons)

return nil
}
Expand All @@ -179,7 +198,12 @@ func removeGeneratedWrappers(s *snap.Info, meter progress.Meter) error {
logger.Noticef("Cannot remove desktop files for %q: %v", s.InstanceName(), err3)
}

return firstErr(err1, err2, err3)
err4 := wrappers.RemoveSnapIcons(s)
if err4 != nil {
logger.Noticef("Cannot remove desktop icons for %q: %v", s.InstanceName(), err4)
}

return firstErr(err1, err2, err3, err4)
}

// UnlinkSnap makes the snap unavailable to the system removing wrappers and symlinks.
Expand Down
2 changes: 2 additions & 0 deletions tests/lib/snaps/test-snapd-icon-theme/bin/echo
@@ -0,0 +1,2 @@
#!/bin/sh
echo "From test-snapd-icon-theme snap"
5 changes: 5 additions & 0 deletions tests/lib/snaps/test-snapd-icon-theme/meta/gui/echo.desktop
@@ -0,0 +1,5 @@
[Desktop Entry]
Type=Application
Name=Echo
Exec=test-snapd-icon-theme.echo
Icon=snap.test-snapd-icon-theme.foo
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions tests/lib/snaps/test-snapd-icon-theme/meta/snap.yaml
@@ -0,0 +1,6 @@
name: test-snapd-icon-theme
version: 1.0

apps:
echo:
command: bin/echo
37 changes: 37 additions & 0 deletions tests/main/parallel-install-snap-icons/task.yaml
@@ -0,0 +1,37 @@
summary: Parallel installed snaps have non-conflicting icons
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not related to icons but I think it would be neat if our parallel installed desktop files had a way to name the instance automatically.


restore: |
snap unset system experimental.parallel-instances

execute: |
#shellcheck source=tests/lib/snaps.sh
. "$TESTSLIB"/snaps.sh

echo "Install a snap providing icons"
install_local test-snapd-icon-theme

echo "Install additional instances of the snap"
snap set system experimental.parallel-instances=true
install_local_as test-snapd-icon-theme test-snapd-icon-theme_longname
install_local_as test-snapd-icon-theme test-snapd-icon-theme_foo

echo "Each instance provides its own icons"
icondir=/var/lib/snapd/desktop/icons/hicolor/scalable/apps
[ -f "$icondir/snap.test-snapd-icon-theme.foo.svg" ]
[ -f "$icondir/snap.test-snapd-icon-theme_longname.foo.svg" ]
[ -f "$icondir/snap.test-snapd-icon-theme_foo.foo.svg" ]

echo "Each instance's desktop file references its own icon"
desktopdir=/var/lib/snapd/desktop/applications
MATCH '^Icon=snap.test-snapd-icon-theme.foo$' < "$desktopdir/test-snapd-icon-theme_echo.desktop"
MATCH '^Icon=snap.test-snapd-icon-theme_longname.foo$' < "$desktopdir/test-snapd-icon-theme+longname_echo.desktop"
MATCH '^Icon=snap.test-snapd-icon-theme_foo.foo$' < "$desktopdir/test-snapd-icon-theme+foo_echo.desktop"

echo "Removing once instance does not remove the other instances' icons"
snap remove test-snapd-icon-theme_foo
[ -f "$icondir/snap.test-snapd-icon-theme.foo.svg" ]
[ -f "$icondir/snap.test-snapd-icon-theme_longname.foo.svg" ]
[ ! -f "$icondir/snap.test-snapd-icon-theme_foo.foo.svg" ]

snap remove test-snapd-icon-theme
[ -f "$icondir/snap.test-snapd-icon-theme_longname.foo.svg" ]
29 changes: 29 additions & 0 deletions tests/main/snap-icons/task.yaml
@@ -0,0 +1,29 @@
summary: Snaps can install icon theme icons

execute: |
#shellcheck source=tests/lib/snaps.sh
. "$TESTSLIB"/snaps.sh

echo "Install a snap providing icons"
install_local test-snapd-icon-theme

echo "Icons provided by the snap are installed to a shared location"
iconfile=/var/lib/snapd/desktop/icons/hicolor/scalable/apps/snap.test-snapd-icon-theme.foo.svg
[ -f "$iconfile" ]
MATCH "icon from test-snapd-icon-theme" < "$iconfile"

echo "Desktop files can reference installed icons"
desktopfile=/var/lib/snapd/desktop/applications/test-snapd-icon-theme_echo.desktop
MATCH '^Icon=snap.test-snapd-icon-theme.foo$' < "$desktopfile"

echo "Remove the snap"
snap remove test-snapd-icon-theme

echo "The icon has been removed"
[ ! -f "$iconfile" ]

echo "The empty icon theme subdirectories have also been removed"
[ ! -d /var/lib/snapd/desktop/icons/hicolor ]

echo "But the base icons directory remains"
[ -d /var/lib/snapd/desktop/icons ]
40 changes: 40 additions & 0 deletions wrappers/desktop.go
Expand Up @@ -142,6 +142,36 @@ func rewriteExecLine(s *snap.Info, desktopFile, line string) (string, error) {
return "", fmt.Errorf("invalid exec command: %q", cmd)
}

func rewriteIconLine(s *snap.Info, line string) (string, error) {
icon := strings.SplitN(line, "=", 2)[1]
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if line has no =? Then this will panic.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The calling code reads as:

if bytes.HasPrefix(bline, []byte("Icon=")) {
	line, err := rewriteIconLine(s, string(bline))

So there will always be an equals sign. I followed the pattern of rewriteExecLine here.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is something for a follow-up, CC @mvo5 but I'd like to refactor it so that the caller splits and passes key, value arguments so that the code is both correct and obviously correct.


// If there is a path separator, assume the icon is a path name
if strings.ContainsRune(icon, filepath.Separator) {
if !strings.HasPrefix(icon, "${SNAP}/") {
return "", fmt.Errorf("icon path %q is not part of the snap", icon)
}
if filepath.Clean(icon) != icon {
return "", fmt.Errorf("icon path %q is not canonicalized", icon)
}
return line, nil
}
Copy link

@jdstrand jdstrand Aug 13, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This means that the desktop file can specify Icon=/path/to/anything. This seems overly broad; why wouldn't we limit this in some manner?

I just checked and it is snapcraft that is doing the munging of the Icon file, not snapd. Eg, I used Icon=/etc/passwd, installed the snap and then found it was passed all the way through to /var/lib/snapd/desktop/applications/*.desktop after install. I'll add a review-tools test to make sure it is compliant with this PR, but it seems snapd could do more here. I suspect before the variable expansion if it starts with '/' or if the normalized path is different from the specified path, then failing would be sufficient.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've added some additional checks to RewriteIconLine that should cover this:

  1. ensure that the icon begins with ${SNAP}/
  2. ensure that filepath.Clean(icon) == icon

The second part catches escapes like ${SNAP}/../other/icon.png. It won't cover the case of symlinks within the snap that point outside. Presumably that's something the review tools already check for?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And just so there's no confusion: errors in this function are logged and cause the Icon= line to be omitted from the sanitised desktop file. The change @jdstrand requested will not prevent the install or upgrade of a snap.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've added some additional checks to RewriteIconLine that should cover this:

1. ensure that the icon begins with `${SNAP}/`

2. ensure that `filepath.Clean(icon) == icon`

The second part catches escapes like ${SNAP}/../other/icon.png. It won't cover the case of symlinks within the snap that point outside. Presumably that's something the review tools already check for?

Thanks! I've adjusted the review-tools to be inline with this PR and so if '/' is in the filename. As such, if the Icon contains '/', it must start with ${SNAP}/ (and end with .png and .svg and the path is the same as the normalized path, but that isn't relevant for what I'm describing), so the existing review-tools check for external symlinks would catch this.


// If the icon is prefixed with "snap.${SNAP_NAME}.", rewrite
// to the instance name.
snapIconPrefix := fmt.Sprintf("snap.%s.", s.SnapName())
if strings.HasPrefix(icon, snapIconPrefix) {
return fmt.Sprintf("Icon=snap.%s.%s", s.InstanceName(), icon[len(snapIconPrefix):]), nil
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

silly question, what happens if the app refers to the icons from code, or is that not expected? or would it in that case use them from the snap, and not the system?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are (currently) no interfaces that grant a snap access to /var/lib/snapd/desktop/icons, so the naming should be irrelevant to the snap. As with the exported desktop files, this is for the benefit of the host system.

From within the snap sandbox, the app can access un-munged versions of its own icons in ${SNAP}/meta/gui/icons.

}

// If the icon has any other "snap." prefix, treat this as an error.
if strings.HasPrefix(icon, "snap.") {
return "", fmt.Errorf("invalid icon name: %q, must start with %q", icon, snapIconPrefix)
}

// Allow other icons names through unchanged.
return line, nil
}

func sanitizeDesktopFile(s *snap.Info, desktopFile string, rawcontent []byte) []byte {
var newContent bytes.Buffer
mountDir := []byte(s.MountDir())
Expand All @@ -165,6 +195,16 @@ func sanitizeDesktopFile(s *snap.Info, desktopFile string, rawcontent []byte) []
bline = []byte(line)
}

// rewrite icon line if it references an icon theme icon
if bytes.HasPrefix(bline, []byte("Icon=")) {
line, err := rewriteIconLine(s, string(bline))
if err != nil {
logger.Debugf("ignoring icon in source desktop file %q: %s", filepath.Base(desktopFile), err)
zyga marked this conversation as resolved.
Show resolved Hide resolved
continue
}
bline = []byte(line)
}

// do variable substitution
bline = bytes.Replace(bline, []byte("${SNAP}"), mountDir, -1)

Expand Down
59 changes: 59 additions & 0 deletions wrappers/desktop_test.go
Expand Up @@ -482,6 +482,65 @@ func (s *sanitizeDesktopFileSuite) TestLangLang(c *C) {
}
}

func (s *sanitizeDesktopFileSuite) TestRewriteIconLine(c *C) {
snap, err := snap.InfoFromSnapYaml([]byte(`
name: snap
version: 1.0
`))
c.Assert(err, IsNil)

newl, err := wrappers.RewriteIconLine(snap, "Icon=${SNAP}/icon.png")
c.Check(err, IsNil)
c.Check(newl, Equals, "Icon=${SNAP}/icon.png")

newl, err = wrappers.RewriteIconLine(snap, "Icon=snap.snap.icon")
c.Check(err, IsNil)
c.Check(newl, Equals, "Icon=snap.snap.icon")
zyga marked this conversation as resolved.
Show resolved Hide resolved

newl, err = wrappers.RewriteIconLine(snap, "Icon=other-icon")
c.Check(err, IsNil)
c.Check(newl, Equals, "Icon=other-icon")

snap.InstanceKey = "bar"
newl, err = wrappers.RewriteIconLine(snap, "Icon=snap.snap.icon")
c.Check(err, IsNil)
c.Check(newl, Equals, "Icon=snap.snap_bar.icon")

_, err = wrappers.RewriteIconLine(snap, "Icon=snap.othersnap.icon")
c.Check(err, ErrorMatches, `invalid icon name: "snap.othersnap.icon", must start with "snap.snap."`)

_, err = wrappers.RewriteIconLine(snap, "Icon=/etc/passwd")
c.Check(err, ErrorMatches, `icon path "/etc/passwd" is not part of the snap`)

_, err = wrappers.RewriteIconLine(snap, "Icon=${SNAP}/../outside/icon.png")
c.Check(err, ErrorMatches, `icon path "\${SNAP}/../outside/icon.png" is not canonicalized`)
}

func (s *sanitizeDesktopFileSuite) TestSanitizeParallelInstancesIconName(c *C) {
snap, err := snap.InfoFromSnapYaml([]byte(`
name: snap
version: 1.0
apps:
app:
command: cmd
`))
snap.InstanceKey = "bar"
c.Assert(err, IsNil)
desktopContent := []byte(`[Desktop Entry]
Name=foo
Icon=snap.snap.icon
Exec=snap.app
`)
df := filepath.Base(snap.Apps["app"].DesktopFile())
e := wrappers.SanitizeDesktopFile(snap, df, desktopContent)
c.Assert(string(e), Equals, fmt.Sprintf(`[Desktop Entry]
X-SnapInstanceName=snap_bar
Name=foo
Icon=snap.snap_bar.icon
Exec=env BAMF_DESKTOP_FILE_HINT=snap_bar_app.desktop %s/bin/snap_bar.app
`, dirs.SnapMountDir))
}

func (s *desktopSuite) TestAddRemoveDesktopFiles(c *C) {
var tests = []struct {
instance string
Expand Down
4 changes: 4 additions & 0 deletions wrappers/export_test.go
Expand Up @@ -33,10 +33,14 @@ var (
// desktop
SanitizeDesktopFile = sanitizeDesktopFile
RewriteExecLine = rewriteExecLine
RewriteIconLine = rewriteIconLine
IsValidDesktopFileLine = isValidDesktopFileLine

// timers
GenerateOnCalendarSchedules = generateOnCalendarSchedules

// icons
FindIconFiles = findIconFiles
)

func MockKillWait(wait time.Duration) (restore func()) {
Expand Down