From 5fb7dbc51f279a49b8006eafef375f66b06e41f5 Mon Sep 17 00:00:00 2001 From: Maciej Borzecki Date: Fri, 10 Apr 2026 11:57:30 +0200 Subject: [PATCH 1/4] snap/pack: ensure layout paths presence for snaps using bare or core26 and later Ensure that layout paths - sources and tmpfs targets, located under $SNAP are actually found inside the snap directory tree. This is an extension of validation introduced for content interface targets, and follows the exact same rules - i.e. snaps using bare or core26+ bases are expected to carry relevant paths. Related: SNAPDENG-36626 Signed-off-by: Maciej Borzecki --- snap/pack/pack.go | 115 ++++++++++++++++++++++++++++-------- snap/pack/pack_test.go | 130 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 222 insertions(+), 23 deletions(-) diff --git a/snap/pack/pack.go b/snap/pack/pack.go index 942d94477b7..b6cfc458604 100644 --- a/snap/pack/pack.go +++ b/snap/pack/pack.go @@ -24,6 +24,7 @@ import ( "io" "os" "path/filepath" + "sort" "strings" "github.com/snapcore/snapd/gadget" @@ -95,23 +96,8 @@ func debArchitecture(info *snap.Info) string { } // validateContentPlugTargets checks that content interface plug target -// directories exist in the snap source tree. This check only applies to -// snaps with base core26 or later. +// directories exist in the snap directory tree. func validateContentPlugTargets(container snap.Container, info *snap.Info) error { - // An empty base is equivalent to "core". - base := info.Base - if base == "" { - base = "core" - } - // Content plug target validation does not apply to bases - // before core26. - excluded := []string{ - "core", "core18", "core20", "core22", "core24", - } - if strutil.ListContains(excluded, base) { - return nil - } - for plugName, plug := range info.Plugs { if plug.Interface != "content" { continue @@ -120,9 +106,6 @@ func validateContentPlugTargets(container snap.Container, info *snap.Info) error if err := plug.Attr("target", &target); err != nil || target == "" { continue } - // Only $SNAP paths (or paths with no variable prefix) can be checked at - // pack time; $SNAP_DATA and $SNAP_COMMON are read-write runtime - // directories and mount points can be created as needed. if strings.HasPrefix(target, "$SNAP_DATA") || strings.HasPrefix(target, "$SNAP_COMMON") { continue } @@ -146,6 +129,74 @@ func validateContentPlugTargets(container snap.Container, info *snap.Info) error return nil } +// validateLayoutPaths verifies that layout paths located under $SNAP, which are +// used for bind, bind-file as well as a tmpfs target, are present in the snap's +// directory tree. +func validateLayoutPaths(container snap.Container, info *snap.Info) error { + // Sort layout paths for deterministic error reporting. + layoutPaths := make([]string, 0, len(info.Layout)) + for p := range info.Layout { + layoutPaths = append(layoutPaths, p) + } + sort.Strings(layoutPaths) + + for _, layoutPath := range layoutPaths { + layout := info.Layout[layoutPath] + + if layout.Type == "tmpfs" { + // For tmpfs layouts under $SNAP, the mount target directory must + // exist in the snap directory tree to avoid needlessly creating a + // writable mimic at runtime. + if !strings.HasPrefix(layoutPath, "$SNAP") || + strings.HasPrefix(layoutPath, "$SNAP_DATA") || + strings.HasPrefix(layoutPath, "$SNAP_COMMON") { + continue + } + relPath := strings.TrimPrefix(layoutPath, "$SNAP") + relPath = strings.TrimPrefix(relPath, "/") + if relPath == "" { + continue + } + fi, err := container.Lstat(relPath) + if err != nil || !fi.IsDir() { + return fmt.Errorf("layout %q must exist as a directory in the snap, ensure it is present or created before packing", layoutPath) + } + continue + } + + // TODO: validate symlink targets within $SNAP + + // Determine the source path. Only check bind and bind-file. Entries of + // type "tmpfs" were already checked. + source := layout.Bind + if source == "" { + source = layout.BindFile + } + if source == "" { + continue + } + + // Only $SNAP paths can be checked at pack time. + if strings.HasPrefix(source, "$SNAP_DATA") || strings.HasPrefix(source, "$SNAP_COMMON") { + continue + } + relPath := strings.TrimPrefix(source, "$SNAP") + relPath = strings.TrimPrefix(relPath, "/") + if relPath == "" { + continue + } + + fi, err := container.Lstat(relPath) + if layout.Bind != "" && (err != nil || !fi.IsDir()) { + return fmt.Errorf("layout %q source %q must exist and be a directory, ensure it is present in the snap or created before packing", layoutPath, source) + } + if layout.BindFile != "" && (err != nil || fi.IsDir()) { + return fmt.Errorf("layout %q source %q must exist and be a file, ensure it is present in the snap or created before packing", layoutPath, source) + } + } + return nil +} + // CheckSkeleton attempts to validate snap data in source directory func CheckSkeleton(w io.Writer, sourceDir string) error { yaml, err := os.ReadFile(filepath.Join(sourceDir, "meta", "snap.yaml")) @@ -162,6 +213,20 @@ func CheckSkeleton(w io.Writer, sourceDir string) error { return err } +func needsStrictLayoutOrContentValidation(info *snap.Info) bool { + // An empty base is equivalent to "core". + base := info.Base + if base == "" { + base = "core" + } + // Strict content plug target or layout paths validation does not apply to + // bases before core26, with the exception of the 'bare' base. + excluded := []string{ + "core", "core18", "core20", "core22", "core24", + } + return !strutil.ListContains(excluded, base) +} + func loadAndValidate(sourceDir string, yaml []byte) (*snap.Info, error) { container := snapdir.New(sourceDir) @@ -178,11 +243,15 @@ func loadAndValidate(sourceDir string, yaml []byte) (*snap.Info, error) { if err := snap.ValidateSnapContainer(container, info, logger.Noticef); err != nil { return nil, err } - if err := validateContentPlugTargets(container, info); err != nil { - return nil, err + + if needsStrictLayoutOrContentValidation(info) { + if err := validateContentPlugTargets(container, info); err != nil { + return nil, err + } + if err := validateLayoutPaths(container, info); err != nil { + return nil, err + } } - // TODO: validate content interface slot source (read/write) paths - // exist in the snap source tree, see validateContentPlugTargets. if _, err := snap.ReadSnapshotYamlFromSnapFile(container); err != nil { return nil, err diff --git a/snap/pack/pack_test.go b/snap/pack/pack_test.go index 95d7a9a664c..c5f098813ff 100644 --- a/snap/pack/pack_test.go +++ b/snap/pack/pack_test.go @@ -790,3 +790,133 @@ plugs: err := pack.CheckSkeleton(&buf, sourceDir) c.Assert(err, ErrorMatches, `content interface plug "plug-missing" target \$SNAP/missing must exist and must be a directory, ensure it is present in the snap or created before packing`) } + +type layoutSourceTestCase struct { + summary string + base string + layout string // layout fragment (indented, under "layout:" key) + plugs string // plugs fragment (indented, under "plugs:" key) + create []string // paths to create: trailing "/" = dir, otherwise file + errMatch string // expected error regex, "" = no error expected +} + +func (s *packSuite) checkSkeletonLayoutPath(c *C, tc layoutSourceTestCase) { + c.Logf("tc: %+v", tc) + + yamlStr := fmt.Sprintf("name: hello\nversion: 0\nbase: %s\n", tc.base) + if tc.plugs != "" { + yamlStr += "plugs:\n" + tc.plugs + } + yamlStr += "layout:\n" + tc.layout + + sourceDir := makeExampleSnapSourceDir(c, yamlStr) + for _, f := range tc.create { + path := filepath.Join(sourceDir, f) + if strings.HasSuffix(f, "/") { + c.Assert(os.MkdirAll(path, 0755), IsNil) + } else { + c.Assert(os.MkdirAll(filepath.Dir(path), 0755), IsNil) + c.Assert(os.WriteFile(path, []byte(""), 0644), IsNil) + } + } + var buf bytes.Buffer + err := pack.CheckSkeleton(&buf, sourceDir) + if tc.errMatch == "" { + c.Assert(err, IsNil, Commentf("test: %s", tc.summary)) + } else { + c.Assert(err, ErrorMatches, tc.errMatch, Commentf("test: %s", tc.summary)) + } +} + +func (s *packSuite) TestCheckSkeletonLayoutSourceValid(c *C) { + for _, tc := range []layoutSourceTestCase{ + { + summary: "bind source directory exists", + base: "core26", + layout: " /opt/lib:\n bind: $SNAP/lib\n", + create: []string{"lib/"}, + }, { + summary: "bind-file source file exists", + base: "core26", + layout: " /opt/foo.conf:\n bind-file: $SNAP/foo.conf\n", + create: []string{"foo.conf"}, + }, { + // setup similar to what snapcraft desktop extension injects during build + summary: "source under content target with full path present", + base: "core26", + plugs: " gnome:\n interface: content\n target: $SNAP/gnome-platform\n", + layout: " /usr/lib/webkit:\n bind: $SNAP/gnome-platform/usr/lib/webkit\n", + create: []string{"gnome-platform/usr/lib/webkit/"}, + }, { + // symlink target in $SNAP is not required to exist + summary: "symlink layout not checked", + base: "core26", + layout: " /opt/data:\n symlink: $SNAP/data\n", + }, { + summary: "$SNAP_DATA source skipped", + base: "core26", + layout: " /opt/lib:\n bind: $SNAP_DATA/lib\n", + }, { + summary: "$SNAP_COMMON source skipped", + base: "core26", + layout: " /opt/lib:\n bind: $SNAP_COMMON/lib\n", + }, { + summary: "tmpfs under $SNAP with directory present", + base: "core26", + layout: " $SNAP/tmpdir:\n type: tmpfs\n", + create: []string{"tmpdir/"}, + }, { + summary: "tmpfs at system path not checked", + base: "core26", + layout: " /usr/share/foo:\n type: tmpfs\n", + }, + } { + s.checkSkeletonLayoutPath(c, tc) + } +} + +func (s *packSuite) TestCheckSkeletonLayoutSourceInvalid(c *C) { + for _, tc := range []layoutSourceTestCase{ + { + summary: "bind source missing", + base: "core26", + layout: " /opt/lib:\n bind: $SNAP/lib\n", + errMatch: `layout "/opt/lib" source "\$SNAP/lib" must exist and be a directory, ensure it is present in the snap or created before packing`, + }, { + summary: "bind source is a file not directory", + base: "core26", + layout: " /opt/lib:\n bind: $SNAP/lib\n", + create: []string{"lib"}, + errMatch: `layout "/opt/lib" source "\$SNAP/lib" must exist and be a directory, ensure it is present in the snap or created before packing`, + }, { + summary: "bind-file source is a directory not file", + base: "core26", + layout: " /opt/foo.conf:\n bind-file: $SNAP/foo.conf\n", + create: []string{"foo.conf/"}, + errMatch: `layout "/opt/foo.conf" source "\$SNAP/foo.conf" must exist and be a file, ensure it is present in the snap or created before packing`, + }, { + summary: "tmpfs under $SNAP directory missing", + base: "core26", + layout: " $SNAP/missing:\n type: tmpfs\n", + errMatch: `layout "\$SNAP/missing" must exist as a directory in the snap, ensure it is present or created before packing`, + }, { + summary: "tmpfs under $SNAP target is a file not directory", + base: "core26", + layout: " $SNAP/notadir:\n type: tmpfs\n", + create: []string{"notadir"}, + errMatch: `layout "\$SNAP/notadir" must exist as a directory in the snap, ensure it is present or created before packing`, + }, + } { + s.checkSkeletonLayoutPath(c, tc) + } +} + +func (s *packSuite) TestCheckSkeletonLayoutSourceOldBaseSkipped(c *C) { + for _, base := range []string{"core", "core18", "core20", "core22", "core24"} { + s.checkSkeletonLayoutPath(c, layoutSourceTestCase{ + summary: "old base " + base, + base: base, + layout: " /opt/lib:\n bind: $SNAP/lib\n", + }) + } +} From 7a0d4cc949b26b6b610ef212fb89cc5c0ba7e1dc Mon Sep 17 00:00:00 2001 From: Maciej Borzecki Date: Wed, 29 Apr 2026 15:47:21 +0200 Subject: [PATCH 2/4] fixup! snap/pack: ensure layout paths presence for snaps using bare or core26 and later --- snap/pack/pack.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/snap/pack/pack.go b/snap/pack/pack.go index b6cfc458604..9b837d05f45 100644 --- a/snap/pack/pack.go +++ b/snap/pack/pack.go @@ -106,6 +106,9 @@ func validateContentPlugTargets(container snap.Container, info *snap.Info) error if err := plug.Attr("target", &target); err != nil || target == "" { continue } + // Only $SNAP paths (or paths with no variable prefix) can be checked at + // pack time; $SNAP_DATA and $SNAP_COMMON are read-write runtime + // directories and mount points can be created as needed. if strings.HasPrefix(target, "$SNAP_DATA") || strings.HasPrefix(target, "$SNAP_COMMON") { continue } From 6e53e4739049f8da03aac6866b4fd3135009a16a Mon Sep 17 00:00:00 2001 From: Maciej Borzecki Date: Wed, 29 Apr 2026 16:09:22 +0200 Subject: [PATCH 3/4] fixup! snap/pack: ensure layout paths presence for snaps using bare or core26 and later --- snap/pack/pack.go | 2 +- snap/pack/pack_test.go | 18 +++++++++++++++--- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/snap/pack/pack.go b/snap/pack/pack.go index 9b837d05f45..99edf9cedf0 100644 --- a/snap/pack/pack.go +++ b/snap/pack/pack.go @@ -193,7 +193,7 @@ func validateLayoutPaths(container snap.Container, info *snap.Info) error { if layout.Bind != "" && (err != nil || !fi.IsDir()) { return fmt.Errorf("layout %q source %q must exist and be a directory, ensure it is present in the snap or created before packing", layoutPath, source) } - if layout.BindFile != "" && (err != nil || fi.IsDir()) { + if layout.BindFile != "" && (err != nil || !fi.Mode().IsRegular()) { return fmt.Errorf("layout %q source %q must exist and be a file, ensure it is present in the snap or created before packing", layoutPath, source) } } diff --git a/snap/pack/pack_test.go b/snap/pack/pack_test.go index c5f098813ff..58a56756f39 100644 --- a/snap/pack/pack_test.go +++ b/snap/pack/pack_test.go @@ -796,7 +796,7 @@ type layoutSourceTestCase struct { base string layout string // layout fragment (indented, under "layout:" key) plugs string // plugs fragment (indented, under "plugs:" key) - create []string // paths to create: trailing "/" = dir, otherwise file + create []string // paths to create: trailing "/" = dir, 'A -> B' = symlink A pointing to B, otherwise a file errMatch string // expected error regex, "" = no error expected } @@ -811,10 +811,16 @@ func (s *packSuite) checkSkeletonLayoutPath(c *C, tc layoutSourceTestCase) { sourceDir := makeExampleSnapSourceDir(c, yamlStr) for _, f := range tc.create { - path := filepath.Join(sourceDir, f) - if strings.HasSuffix(f, "/") { + if before, after, ok := strings.Cut(f, " -> "); ok { + // "name -> target" creates a symlink + path := filepath.Join(sourceDir, before) + c.Assert(os.MkdirAll(filepath.Dir(path), 0755), IsNil) + c.Assert(os.Symlink(after, path), IsNil) + } else if strings.HasSuffix(f, "/") { + path := filepath.Join(sourceDir, f) c.Assert(os.MkdirAll(path, 0755), IsNil) } else { + path := filepath.Join(sourceDir, f) c.Assert(os.MkdirAll(filepath.Dir(path), 0755), IsNil) c.Assert(os.WriteFile(path, []byte(""), 0644), IsNil) } @@ -894,6 +900,12 @@ func (s *packSuite) TestCheckSkeletonLayoutSourceInvalid(c *C) { layout: " /opt/foo.conf:\n bind-file: $SNAP/foo.conf\n", create: []string{"foo.conf/"}, errMatch: `layout "/opt/foo.conf" source "\$SNAP/foo.conf" must exist and be a file, ensure it is present in the snap or created before packing`, + }, { + summary: "bind-file source is a symlink not file", + base: "core26", + layout: " /opt/foo.conf:\n bind-file: $SNAP/foo.conf\n", + create: []string{"foo.conf -> some-target"}, + errMatch: `layout "/opt/foo.conf" source "\$SNAP/foo.conf" must exist and be a file, ensure it is present in the snap or created before packing`, }, { summary: "tmpfs under $SNAP directory missing", base: "core26", From 8426d83eed2bb622c73ee691c4b7b3398f3e0053 Mon Sep 17 00:00:00 2001 From: Maciej Borzecki Date: Wed, 29 Apr 2026 16:17:00 +0200 Subject: [PATCH 4/4] fixup! snap/pack: ensure layout paths presence for snaps using bare or core26 and later --- snap/pack/pack.go | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/snap/pack/pack.go b/snap/pack/pack.go index 99edf9cedf0..801f0791dfc 100644 --- a/snap/pack/pack.go +++ b/snap/pack/pack.go @@ -95,6 +95,20 @@ func debArchitecture(info *snap.Info) string { } } +// isDir checks whether relPath exists and is a directory inside the snap +// container. +func isDir(container snap.Container, relPath string) bool { + fi, err := container.Lstat(relPath) + return err == nil && fi.IsDir() +} + +// isRegularFile checks whether relPath exists and is a regular file inside the +// snap container. +func isRegularFile(container snap.Container, relPath string) bool { + fi, err := container.Lstat(relPath) + return err == nil && fi.Mode().IsRegular() +} + // validateContentPlugTargets checks that content interface plug target // directories exist in the snap directory tree. func validateContentPlugTargets(container snap.Container, info *snap.Info) error { @@ -124,8 +138,7 @@ func validateContentPlugTargets(container snap.Container, info *snap.Info) error // only the $SNAP and/or / combination prefix was present continue } - fi, err := container.Lstat(relPath) - if err != nil || !fi.IsDir() { + if !isDir(container, relPath) { return fmt.Errorf("content interface plug %q target %v must exist and must be a directory, ensure it is present in the snap or created before packing", plugName, target) } } @@ -160,8 +173,7 @@ func validateLayoutPaths(container snap.Container, info *snap.Info) error { if relPath == "" { continue } - fi, err := container.Lstat(relPath) - if err != nil || !fi.IsDir() { + if !isDir(container, relPath) { return fmt.Errorf("layout %q must exist as a directory in the snap, ensure it is present or created before packing", layoutPath) } continue @@ -189,11 +201,10 @@ func validateLayoutPaths(container snap.Container, info *snap.Info) error { continue } - fi, err := container.Lstat(relPath) - if layout.Bind != "" && (err != nil || !fi.IsDir()) { + if layout.Bind != "" && !isDir(container, relPath) { return fmt.Errorf("layout %q source %q must exist and be a directory, ensure it is present in the snap or created before packing", layoutPath, source) } - if layout.BindFile != "" && (err != nil || !fi.Mode().IsRegular()) { + if layout.BindFile != "" && !isRegularFile(container, relPath) { return fmt.Errorf("layout %q source %q must exist and be a file, ensure it is present in the snap or created before packing", layoutPath, source) } }