From 530b2d57491caaaf87f196c0080776b3a7709bf5 Mon Sep 17 00:00:00 2001 From: Timothy O'Brien Date: Thu, 18 Jun 2026 11:28:13 -0700 Subject: [PATCH] Fix | copyDir follows directory symlinks instead of failing with EISDIR When a local content-source directory (a Helm chart streamed for a same-repo source) contains a symlink to a directory, copyDir passed the link to copyFile. filepath.Walk reports the link via Lstat, so info.IsDir() is false; copyFile then os.Open()s the link, follows it to the target directory, and io.Copy() fails with EISDIR -- surfaced as 'failed to copy content source dir ...: ... is a directory' (Linux: copy_file_range). The whole render then aborts with 'Failed to extract resources'. Resolve symlinks in copyDir: when one targets a directory, recurse into the resolved path so its contents are materialized at the destination, matching how Argo CD's repo-server reads a chart's on-disk content. File symlinks are unchanged (copyFile already follows them). Common in charts that vendor assets via directory symlinks, e.g. files/sql -> ../../sql. --- pkg/reposerverextract/extract.go | 21 ++++++++++++++++++++ pkg/reposerverextract/extract_test.go | 28 +++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/pkg/reposerverextract/extract.go b/pkg/reposerverextract/extract.go index b8c1de47..f7696849 100644 --- a/pkg/reposerverextract/extract.go +++ b/pkg/reposerverextract/extract.go @@ -867,6 +867,27 @@ func copyDir(src, dst string) error { if info.IsDir() { return os.MkdirAll(dstPath, 0o755) } + // filepath.Walk does not follow symlinks, so info describes the link + // itself (IsDir() is false even when it points at a directory). Passing + // such a link to copyFile would os.Open() the target directory and + // io.Copy() it, which fails with EISDIR ("is a directory"). Resolve the + // link and, when it targets a directory, recurse so the directory's + // contents are materialized at the destination — matching how Argo CD's + // repo-server reads a chart's on-disk content. This is common in charts + // that vendor assets via directory symlinks (e.g. files/sql -> ../../sql). + if info.Mode()&os.ModeSymlink != 0 { + target, err := os.Stat(srcPath) + if err != nil { + return err + } + if target.IsDir() { + resolved, err := filepath.EvalSymlinks(srcPath) + if err != nil { + return err + } + return copyDir(resolved, dstPath) + } + } return copyFile(srcPath, dstPath) }) } diff --git a/pkg/reposerverextract/extract_test.go b/pkg/reposerverextract/extract_test.go index e15759ad..91dc81b4 100644 --- a/pkg/reposerverextract/extract_test.go +++ b/pkg/reposerverextract/extract_test.go @@ -1354,3 +1354,31 @@ func TestCollectRepoURLs_Empty(t *testing.T) { urls := collectRepoURLs([]argoapplication.ArgoResource{}) assert.Empty(t, urls) } + +// copyDir must follow a symlink that points at a directory and materialize its +// contents at the destination. Before the fix it byte-copied the link, which +// os.Open()-ed the target directory and failed with EISDIR ("is a directory"). +// Charts commonly vendor assets this way (e.g. files/sql -> ../../farm-api/sql). +func TestCopyDir_FollowsDirectorySymlink(t *testing.T) { + root := t.TempDir() + + // The symlink target: a real directory (outside the chart) with a file in it. + external := filepath.Join(root, "external", "sql") + require.NoError(t, os.MkdirAll(external, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(external, "V1__init.sql"), []byte("SELECT 1;"), 0o644)) + + // The chart dir: a regular file plus files/sql -> the external directory. + chart := filepath.Join(root, "chart") + require.NoError(t, os.MkdirAll(filepath.Join(chart, "files"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(chart, "Chart.yaml"), []byte("name: c\n"), 0o644)) + require.NoError(t, os.Symlink(external, filepath.Join(chart, "files", "sql"))) + + dst := filepath.Join(root, "dst") + require.NoError(t, copyDir(chart, dst)) + + // The regular file and the symlinked directory's contents are both present. + assert.FileExists(t, filepath.Join(dst, "Chart.yaml")) + got, err := os.ReadFile(filepath.Join(dst, "files", "sql", "V1__init.sql")) + require.NoError(t, err) + assert.Equal(t, "SELECT 1;", string(got)) +}