Skip to content

Commit 0b65369

Browse files
committed
fix: validate and remove dangling in-bindle symlinks
fixes #57 Signed-off-by: German Lashevich <german.lashevich@gmail.com>
1 parent ca7c1cb commit 0b65369

File tree

5 files changed

+156
-18
lines changed

5 files changed

+156
-18
lines changed

pkg/vendir/cmd/sync.go

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -128,12 +128,13 @@ func (o *SyncOptions) Run() error {
128128
return fmt.Errorf("Unable to create cache: %s", err)
129129
}
130130
syncOpts := ctldir.SyncOpts{
131-
RefFetcher: ctldir.NewNamedRefFetcher(secrets, configMaps),
132-
GithubAPIToken: os.Getenv("VENDIR_GITHUB_API_TOKEN"),
133-
HelmBinary: os.Getenv("VENDIR_HELM_BINARY"),
134-
Cache: cache,
135-
Lazy: o.Lazy,
136-
Partial: len(dirs) > 0,
131+
RefFetcher: ctldir.NewNamedRefFetcher(secrets, configMaps),
132+
GithubAPIToken: os.Getenv("VENDIR_GITHUB_API_TOKEN"),
133+
HelmBinary: os.Getenv("VENDIR_HELM_BINARY"),
134+
Cache: cache,
135+
Lazy: o.Lazy,
136+
Partial: len(dirs) > 0,
137+
AllowAllSymlinkDestinations: o.AllowAllSymlinkDestinations,
137138
}
138139
newLockConfig := ctlconf.NewLockConfig()
139140

@@ -145,12 +146,6 @@ func (o *SyncOptions) Run() error {
145146
if err != nil {
146147
return fmt.Errorf("Syncing directory '%s': %s", dirConf.Path, err)
147148
}
148-
if !o.AllowAllSymlinkDestinations {
149-
err = ctldir.ValidateSymlinks(dirConf.Path)
150-
if err != nil {
151-
return err
152-
}
153-
}
154149

155150
newLockConfig.Directories = append(newLockConfig.Directories, dirLockConf)
156151
}

pkg/vendir/directory/directory.go

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,13 @@ func NewDirectory(opts ctlconf.Directory, lockDirectory ctlconf.LockDirectory, u
3838
}
3939

4040
type SyncOpts struct {
41-
RefFetcher ctlfetch.RefFetcher
42-
GithubAPIToken string
43-
HelmBinary string
44-
Cache ctlcache.Cache
45-
Lazy bool
46-
Partial bool
41+
RefFetcher ctlfetch.RefFetcher
42+
GithubAPIToken string
43+
HelmBinary string
44+
Cache ctlcache.Cache
45+
Lazy bool
46+
Partial bool
47+
AllowAllSymlinkDestinations bool
4748
}
4849

4950
func createConfigDigest(contents ctlconf.DirectoryContents) (string, error) {
@@ -230,11 +231,22 @@ func (d *Directory) Sync(syncOpts SyncOpts) (ctlconf.LockDirectory, error) {
230231
return lockConfig, fmt.Errorf("Unknown contents type for directory '%s'", contents.Path)
231232
}
232233

234+
if !syncOpts.AllowAllSymlinkDestinations {
235+
err = ValidateSymlinks(stagingDstPath)
236+
if err != nil {
237+
return lockConfig, fmt.Errorf("Validating symlinks in directory '%s': %s", contents.Path, err)
238+
}
239+
}
240+
233241
if !skipFileFilter {
234242
err = FileFilter{contents}.Apply(stagingDstPath)
235243
if err != nil {
236244
return lockConfig, fmt.Errorf("Filtering paths in directory '%s': %s", contents.Path, err)
237245
}
246+
err = RemoveDanglingSymlinks(stagingDstPath)
247+
if err != nil {
248+
return lockConfig, fmt.Errorf("Removing dangling symlinks in directory '%s': %s", contents.Path, err)
249+
}
238250
}
239251

240252
if !skipNewRootPath && len(contents.NewRootPath) > 0 {

pkg/vendir/directory/symlink.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,22 @@ import (
1111
"strings"
1212
)
1313

14+
// RemoveDanglingSymlinks removes symlinks within path whose targets do not exist.
15+
func RemoveDanglingSymlinks(path string) error {
16+
return filepath.WalkDir(path, func(entryPath string, info fs.DirEntry, err error) error {
17+
if err != nil {
18+
return err
19+
}
20+
if info.Type()&os.ModeSymlink == os.ModeSymlink {
21+
_, statErr := os.Stat(entryPath)
22+
if statErr != nil && os.IsNotExist(statErr) {
23+
return os.Remove(entryPath)
24+
}
25+
}
26+
return nil
27+
})
28+
}
29+
1430
// ValidateSymlinks enforces that symlinks inside the given path resolve to inside the path
1531
func ValidateSymlinks(path string) error {
1632
absRoot, err := filepath.Abs(path)

pkg/vendir/directory/symlink_test.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,52 @@ import (
99
"testing"
1010
)
1111

12+
func TestRemoveDanglingSymlinks(t *testing.T) {
13+
root, err := os.MkdirTemp("", "vendir-test")
14+
if err != nil {
15+
t.Fatalf("failed to create tmpdir: %v", err)
16+
}
17+
defer os.RemoveAll(root)
18+
19+
root, err = filepath.EvalSymlinks(root)
20+
if err != nil {
21+
t.Fatalf("failed to read link tmpdir: %v", err)
22+
}
23+
24+
// existing file that a valid symlink points to
25+
existingFile := filepath.Join(root, "existing.txt")
26+
f, err := os.Create(existingFile)
27+
if err != nil {
28+
t.Fatalf("failed to create file: %v", err)
29+
}
30+
f.Close()
31+
32+
// valid symlink: points to existing file
33+
validLink := filepath.Join(root, "valid_link")
34+
if err = os.Symlink(existingFile, validLink); err != nil {
35+
t.Fatalf("failed to create symlink: %v", err)
36+
}
37+
38+
// dangling symlink: points to non-existent file
39+
danglingLink := filepath.Join(root, "dangling_link")
40+
if err = os.Symlink(filepath.Join(root, "nonexistent.txt"), danglingLink); err != nil {
41+
t.Fatalf("failed to create symlink: %v", err)
42+
}
43+
44+
if err = RemoveDanglingSymlinks(root); err != nil {
45+
t.Fatalf("RemoveDanglingSymlinks() error = %v", err)
46+
}
47+
48+
// valid symlink must still exist
49+
if _, err = os.Lstat(validLink); err != nil {
50+
t.Errorf("valid symlink was unexpectedly removed: %v", err)
51+
}
52+
// dangling symlink must be gone
53+
if _, err = os.Lstat(danglingLink); err == nil {
54+
t.Errorf("dangling symlink was not removed")
55+
}
56+
}
57+
1258
func TestValidateSymlinks(t *testing.T) {
1359
root, err := os.MkdirTemp("", "vendir-test")
1460
if err != nil {

test/e2e/invalid_symlink_test.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,75 @@ import (
1313
"sigs.k8s.io/yaml"
1414
)
1515

16+
// TestSymlinkWithIncludePaths verifies that a sync with includePaths succeeds even when
17+
// the filtered content contains symlinks pointing to paths that are excluded by includePaths.
18+
// The dangling symlinks must be silently removed rather than causing an error.
19+
func TestSymlinkWithIncludePaths(t *testing.T) {
20+
env := BuildEnv(t)
21+
vendir := Vendir{t, env.BinaryPath, Logger{}}
22+
23+
tmpDir, err := os.MkdirTemp("", "vendir-test")
24+
require.NoError(t, err)
25+
defer os.RemoveAll(tmpDir)
26+
27+
tmpDir, err = filepath.EvalSymlinks(tmpDir)
28+
require.NoError(t, err)
29+
30+
// Source layout:
31+
// src/
32+
// dir1/
33+
// kept.txt <- included by includePaths
34+
// link_to_other -> ../dir2/other.txt (will dangle after filtering)
35+
// dir2/
36+
// other.txt <- NOT included by includePaths
37+
srcDir := filepath.Join(tmpDir, "src")
38+
dir1 := filepath.Join(srcDir, "dir1")
39+
dir2 := filepath.Join(srcDir, "dir2")
40+
require.NoError(t, os.MkdirAll(dir1, os.ModePerm))
41+
require.NoError(t, os.MkdirAll(dir2, os.ModePerm))
42+
43+
keptFile, err := os.Create(filepath.Join(dir1, "kept.txt"))
44+
require.NoError(t, err)
45+
keptFile.Close()
46+
47+
otherFile, err := os.Create(filepath.Join(dir2, "other.txt"))
48+
require.NoError(t, err)
49+
otherFile.Close()
50+
51+
// Symlink uses a relative path (../dir2/other.txt) so it is internal to the bundle
52+
require.NoError(t, os.Symlink("../dir2/other.txt", filepath.Join(dir1, "link_to_other")))
53+
54+
cfg := config.Config{
55+
APIVersion: "vendir.k14s.io/v1alpha1",
56+
Kind: "Config",
57+
Directories: []config.Directory{{
58+
Path: "result",
59+
Contents: []config.DirectoryContents{{
60+
Path: "out",
61+
Directory: &config.DirectoryContentsDirectory{
62+
Path: "src",
63+
},
64+
IncludePaths: []string{"dir1/**"},
65+
}},
66+
}},
67+
}
68+
69+
cfgBytes, err := yaml.Marshal(cfg)
70+
require.NoError(t, err)
71+
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "vendir.yml"), cfgBytes, 0666))
72+
73+
_, err = vendir.RunWithOpts([]string{"sync"}, RunOpts{Dir: tmpDir, AllowError: true})
74+
require.NoError(t, err, "sync should succeed even though includePaths causes a dangling symlink")
75+
76+
// The dangling symlink must have been removed
77+
_, err = os.Lstat(filepath.Join(tmpDir, "result", "out", "dir1", "link_to_other"))
78+
require.True(t, os.IsNotExist(err), "dangling symlink should have been removed after filtering")
79+
80+
// The kept file must still be present
81+
_, err = os.Stat(filepath.Join(tmpDir, "result", "out", "dir1", "kept.txt"))
82+
require.NoError(t, err, "kept.txt should be present after filtering")
83+
}
84+
1685
func TestInvalidSymlink(t *testing.T) {
1786
env := BuildEnv(t)
1887
vendir := Vendir{t, env.BinaryPath, Logger{}}

0 commit comments

Comments
 (0)