Skip to content

Commit 81c7916

Browse files
author
Antonio Salinas
committed
fix: Minor error in download-sources
1 parent c92ec8f commit 81c7916

File tree

6 files changed

+135
-74
lines changed

6 files changed

+135
-74
lines changed

internal/app/azldev/cmds/downloadsources/downloadsources.go

Lines changed: 44 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,11 @@ import (
1919

2020
// DownloadSourcesOptions holds the options for the download-sources command.
2121
type DownloadSourcesOptions struct {
22-
Directory string
23-
OutputDir string
24-
LookasideBaseURIs []string
25-
PackageName string
22+
Directory string
23+
OutputDir string
24+
LookasideBaseURIs []string
25+
PackageName string
26+
LookasideDownloader fedorasource.FedoraSourceDownloader
2627
}
2728

2829
// OnAppInit registers the download-sources command as a subcommand of the given parent.
@@ -102,28 +103,30 @@ func DownloadSources(env *azldev.Env, options *DownloadSourcesOptions) error {
102103
event := env.StartEvent("Downloading sources", "packageName", packageName)
103104
defer event.End()
104105

105-
// Build retry config from environment.
106-
retryCfg := retry.DefaultConfig()
107-
if env.NetworkRetries() > 0 {
108-
retryCfg.MaxAttempts = env.NetworkRetries()
106+
lookasideDownloader := options.LookasideDownloader
107+
if lookasideDownloader == nil {
108+
lookasideDownloader, err = createLookasideDownloader(env)
109+
if err != nil {
110+
return err
111+
}
109112
}
110113

111-
// Create the HTTP downloader and lookaside source downloader.
112-
httpDownloader, err := downloader.NewHTTPDownloader(env, env, env.FS())
113-
if err != nil {
114-
return fmt.Errorf("failed to create HTTP downloader:\n%w", err)
114+
// Build extract options.
115+
var extractOpts []fedorasource.ExtractOption
116+
if options.OutputDir != "" {
117+
extractOpts = append(extractOpts, fedorasource.WithOutputDir(options.OutputDir))
115118
}
116119

117-
lookasideDownloader, err := fedorasource.NewFedoraRepoExtractorImpl(
118-
env, env.FS(), httpDownloader, retryCfg,
119-
)
120+
// Verify the 'sources' file exists before attempting downloads.
121+
sourcesPath := filepath.Join(options.Directory, "sources")
122+
123+
sourcesExists, err := fileutils.Exists(env.FS(), sourcesPath)
120124
if err != nil {
121-
return fmt.Errorf("failed to create lookaside downloader:\n%w", err)
125+
return fmt.Errorf("failed to check for sources file at %#q:\n%w", sourcesPath, err)
122126
}
123127

124-
downloadDir, err := prepareDownloadDir(env, options)
125-
if err != nil {
126-
return err
128+
if !sourcesExists {
129+
return fmt.Errorf("no 'sources' file found in %#q", options.Directory)
127130
}
128131

129132
// Try each lookaside base URI until one succeeds.
@@ -133,7 +136,7 @@ func DownloadSources(env *azldev.Env, options *DownloadSourcesOptions) error {
133136
slog.Info("Trying lookaside base URI", "uri", uri)
134137

135138
downloadErr = lookasideDownloader.ExtractSourcesFromRepo(
136-
env, downloadDir, packageName, uri, nil,
139+
env, options.Directory, packageName, uri, nil, extractOpts...,
137140
)
138141
if downloadErr == nil {
139142
break
@@ -147,54 +150,38 @@ func DownloadSources(env *azldev.Env, options *DownloadSourcesOptions) error {
147150
return fmt.Errorf("failed to download sources from any lookaside URI:\n%w", downloadErr)
148151
}
149152

150-
slog.Info("Sources downloaded successfully", "outputDir", downloadDir)
153+
outputDir := options.Directory
154+
if options.OutputDir != "" {
155+
outputDir = options.OutputDir
156+
}
157+
158+
absOutputDir, _ := filepath.Abs(outputDir)
159+
slog.Info("Sources downloaded successfully", "outputDir", absOutputDir)
151160

152161
return nil
153162
}
154163

155-
// prepareDownloadDir resolves the download directory, verifies the 'sources' file exists,
156-
// and copies it to the output directory if needed.
157-
func prepareDownloadDir(env *azldev.Env, options *DownloadSourcesOptions) (string, error) {
158-
sourceDir, err := filepath.Abs(options.Directory)
159-
if err != nil {
160-
return "", fmt.Errorf("failed to resolve absolute path for source directory %#q:\n%w", options.Directory, err)
164+
// createLookasideDownloader builds the default [fedorasource.FedoraSourceDownloader]
165+
// from the environment's network and filesystem configuration.
166+
func createLookasideDownloader(env *azldev.Env) (fedorasource.FedoraSourceDownloader, error) {
167+
retryCfg := retry.DefaultConfig()
168+
if env.NetworkRetries() > 0 {
169+
retryCfg.MaxAttempts = env.NetworkRetries()
161170
}
162171

163-
// Verify the 'sources' file exists before attempting downloads.
164-
sourcesFilePath := filepath.Join(sourceDir, "sources")
165-
166-
sourcesExists, err := fileutils.Exists(env.FS(), sourcesFilePath)
172+
httpDownloader, err := downloader.NewHTTPDownloader(env, env, env.FS())
167173
if err != nil {
168-
return "", fmt.Errorf("failed to check for sources file at %#q:\n%w", sourcesFilePath, err)
174+
return nil, fmt.Errorf("failed to create HTTP downloader:\n%w", err)
169175
}
170176

171-
if !sourcesExists {
172-
return "", fmt.Errorf("no 'sources' file found in %#q", sourceDir)
173-
}
174-
175-
downloadDir := sourceDir
176-
177-
if options.OutputDir != "" {
178-
downloadDir, err = filepath.Abs(options.OutputDir)
179-
if err != nil {
180-
return "", fmt.Errorf("failed to resolve absolute path for output directory %#q:\n%w", options.OutputDir, err)
181-
}
182-
}
183-
184-
// If downloading to a different directory, copy the 'sources' file there.
185-
if downloadDir != sourceDir {
186-
dstPath := filepath.Join(downloadDir, "sources")
187-
188-
if err := fileutils.MkdirAll(env.FS(), downloadDir); err != nil {
189-
return "", fmt.Errorf("failed to create output directory %#q:\n%w", downloadDir, err)
190-
}
191-
192-
if err := fileutils.CopyFile(env, env.FS(), sourcesFilePath, dstPath, fileutils.CopyFileOptions{}); err != nil {
193-
return "", fmt.Errorf("failed to copy sources file to output directory:\n%w", err)
194-
}
177+
lookasideDownloader, err := fedorasource.NewFedoraRepoExtractorImpl(
178+
env, env.FS(), httpDownloader, retryCfg,
179+
)
180+
if err != nil {
181+
return nil, fmt.Errorf("failed to create lookaside downloader:\n%w", err)
195182
}
196183

197-
return downloadDir, nil
184+
return lookasideDownloader, nil
198185
}
199186

200187
// resolveDownloadParams determines the package name and lookaside URIs.

internal/app/azldev/cmds/downloadsources/downloadsources_test.go

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"github.com/microsoft/azure-linux-dev-tools/internal/app/azldev/core/testutils"
1212
"github.com/microsoft/azure-linux-dev-tools/internal/global/opctx/opctx_test"
1313
"github.com/microsoft/azure-linux-dev-tools/internal/projectconfig"
14+
"github.com/microsoft/azure-linux-dev-tools/internal/providers/sourceproviders/fedorasource/fedorasource_test"
1415
"github.com/microsoft/azure-linux-dev-tools/internal/utils/fileperms"
1516
"github.com/microsoft/azure-linux-dev-tools/internal/utils/fileutils"
1617
"github.com/spf13/cobra"
@@ -58,18 +59,22 @@ func TestNewDownloadSourcesCmd(t *testing.T) {
5859

5960
func TestResolveFromDirectory_DerivesPackageName(t *testing.T) {
6061
testEnv := testutils.NewTestEnv(t)
62+
ctrl := gomock.NewController(t)
6163

62-
// Create a directory named after the package with a sources file.
6364
pkgDir := "/project/curl"
6465
require.NoError(t, fileutils.MkdirAll(testEnv.TestFS, pkgDir))
6566
require.NoError(t, fileutils.WriteFile(testEnv.TestFS, pkgDir+"/sources", []byte(""), fileperms.PrivateFile))
6667

68+
mockDownloader := fedorasource_test.NewMockFedoraSourceDownloader(ctrl)
69+
mockDownloader.EXPECT().
70+
ExtractSourcesFromRepo(gomock.Any(), pkgDir, "curl", gomock.Any(), gomock.Any()).
71+
Return(nil)
72+
6773
options := &downloadsources.DownloadSourcesOptions{
68-
Directory: pkgDir,
74+
Directory: pkgDir,
75+
LookasideDownloader: mockDownloader,
6976
}
7077

71-
// With a valid directory, valid distro config, and an empty sources file,
72-
// the command should succeed and derive the package name from the directory.
7378
err := downloadsources.DownloadSources(testEnv.Env, options)
7479
require.NoError(t, err)
7580
}
@@ -104,6 +109,7 @@ func TestDownloadSources_NoSourcesFile(t *testing.T) {
104109

105110
func TestResolveLookasideURI_FollowsUpstreamDistro(t *testing.T) {
106111
testEnv := testutils.NewTestEnv(t)
112+
ctrl := gomock.NewController(t)
107113

108114
// Reconfigure: default distro has NO lookaside URI, but points to an upstream that does.
109115
testEnv.Config.Distros["test-distro"] = projectconfig.DistroDefinition{
@@ -121,23 +127,29 @@ func TestResolveLookasideURI_FollowsUpstreamDistro(t *testing.T) {
121127
},
122128
}
123129

130+
expectedURI := "https://upstream.example.com/lookaside/$pkg/$filename/$hashtype/$hash/$filename"
131+
124132
testEnv.Config.Distros["upstream-distro"] = projectconfig.DistroDefinition{
125-
LookasideBaseURI: "https://upstream.example.com/lookaside/$pkg/$filename/$hashtype/$hash/$filename",
133+
LookasideBaseURI: expectedURI,
126134
Versions: map[string]projectconfig.DistroVersionDefinition{
127135
"42": {},
128136
},
129137
}
130138

131-
specDir := "/project/testpkg"
132-
require.NoError(t, fileutils.MkdirAll(testEnv.TestFS, specDir))
133-
require.NoError(t, fileutils.WriteFile(testEnv.TestFS, specDir+"/sources", []byte(""), fileperms.PrivateFile))
139+
pkgDir := "/project/testpkg"
140+
require.NoError(t, fileutils.MkdirAll(testEnv.TestFS, pkgDir))
141+
require.NoError(t, fileutils.WriteFile(testEnv.TestFS, pkgDir+"/sources", []byte(""), fileperms.PrivateFile))
142+
143+
mockDownloader := fedorasource_test.NewMockFedoraSourceDownloader(ctrl)
144+
mockDownloader.EXPECT().
145+
ExtractSourcesFromRepo(gomock.Any(), pkgDir, "testpkg", expectedURI, gomock.Any()).
146+
Return(nil)
134147

135148
options := &downloadsources.DownloadSourcesOptions{
136-
Directory: specDir,
149+
Directory: pkgDir,
150+
LookasideDownloader: mockDownloader,
137151
}
138152

139-
// With the upstream distro providing the lookaside URI and an empty sources file,
140-
// the command should succeed.
141153
err := downloadsources.DownloadSources(testEnv.Env, options)
142154
require.NoError(t, err)
143155
}

internal/providers/sourceproviders/fedorasource/fedorasource.go

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,31 @@ type FedoraSourceDownloader interface {
2727
// ExtractSourcesFromRepo processes a git repository by downloading any required
2828
// lookaside cache files into the repository directory. Files whose names appear
2929
// in skipFilenames are not downloaded (e.g., files already fetched separately).
30+
// Optional [ExtractOption] values can override default behavior.
3031
ExtractSourcesFromRepo(
3132
ctx context.Context, repoDir string, packageName string,
3233
lookasideBaseURI string, skipFilenames []string,
34+
opts ...ExtractOption,
3335
) error
3436
}
3537

38+
// extractOptions holds optional configuration for [ExtractSourcesFromRepo].
39+
type extractOptions struct {
40+
outputDir string
41+
}
42+
43+
// ExtractOption is a functional option for [ExtractSourcesFromRepo].
44+
type ExtractOption func(*extractOptions)
45+
46+
// WithOutputDir specifies a separate directory for downloaded files.
47+
// When set, the sources file is read from repoDir but files are
48+
// downloaded into outputDir instead.
49+
func WithOutputDir(dir string) ExtractOption {
50+
return func(o *extractOptions) {
51+
o.outputDir = dir
52+
}
53+
}
54+
3655
// FedoraSourceDownloaderImpl is an implementation of GitRepoExtractor.
3756
type FedoraSourceDownloaderImpl struct {
3857
dryRunnable opctx.DryRunnable
@@ -123,6 +142,7 @@ func NewFedoraRepoExtractorImpl(
123142
// lookaside cache files into the repository directory.
124143
func (g *FedoraSourceDownloaderImpl) ExtractSourcesFromRepo(
125144
ctx context.Context, repoDir string, packageName string, lookasideBaseURI string, skipFileNames []string,
145+
opts ...ExtractOption,
126146
) error {
127147
if repoDir == "" {
128148
return errors.New("repository directory cannot be empty")
@@ -132,6 +152,12 @@ func (g *FedoraSourceDownloaderImpl) ExtractSourcesFromRepo(
132152
return errors.New("lookaside base URI cannot be empty")
133153
}
134154

155+
// Apply functional options.
156+
var options extractOptions
157+
for _, opt := range opts {
158+
opt(&options)
159+
}
160+
135161
repoDirExists, err := fileutils.Exists(g.fileSystem, repoDir)
136162
if err != nil {
137163
return fmt.Errorf("failed to check if repository directory exists at %#q:\n%w", repoDir, err)
@@ -168,7 +194,13 @@ func (g *FedoraSourceDownloaderImpl) ExtractSourcesFromRepo(
168194
skipSet[name] = true
169195
}
170196

171-
err = g.downloadAndVerifySources(ctx, sourceFiles, repoDir, skipSet)
197+
// Determine where to write downloaded files.
198+
destDir := repoDir
199+
if options.outputDir != "" {
200+
destDir = options.outputDir
201+
}
202+
203+
err = g.downloadAndVerifySources(ctx, sourceFiles, destDir, skipSet)
172204
if err != nil {
173205
return fmt.Errorf("failed to download sources:\n%w", err)
174206
}
@@ -271,6 +303,11 @@ func parseSourcesFile(content string, packageName string, lookasideBaseURI strin
271303
return nil, fmt.Errorf("failed to build lookaside URL for file %#q:\n%w", entry.Filename, err)
272304
}
273305

306+
// Validate the filename to prevent path traversal attacks from crafted sources entries.
307+
if err := fileutils.ValidateFilename(entry.Filename); err != nil {
308+
return nil, fmt.Errorf("unsafe filename in sources file %#q:\n%w", entry.Filename, err)
309+
}
310+
274311
sourceFiles = append(sourceFiles, sourceFileInfo{
275312
fileName: entry.Filename,
276313
uri: sourceURI,

internal/providers/sourceproviders/fedorasource/fedorasource_test.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,24 @@ func TestParseSourcesFile(t *testing.T) {
268268
require.NoError(t, err)
269269
assert.Empty(t, sources)
270270
})
271+
272+
t.Run("path traversal filename is rejected", func(t *testing.T) {
273+
content := "SHA512 (../../etc/passwd) = abc123\n"
274+
275+
_, err := parseSourcesFile(content, "pkg", "https://example.com/$hashtype/$hash/$pkg/$filename")
276+
277+
require.Error(t, err)
278+
assert.Contains(t, err.Error(), "unsafe filename")
279+
})
280+
281+
t.Run("absolute path filename is rejected", func(t *testing.T) {
282+
content := "SHA512 (/etc/passwd) = abc123\n"
283+
284+
_, err := parseSourcesFile(content, "pkg", "https://example.com/$hashtype/$hash/$pkg/$filename")
285+
286+
require.Error(t, err)
287+
assert.Contains(t, err.Error(), "unsafe filename")
288+
})
271289
}
272290

273291
func TestBuildLookasideURL(t *testing.T) {

internal/providers/sourceproviders/fedorasource/fedorasource_test/fedorasource_mocks.go

Lines changed: 10 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

internal/providers/sourceproviders/identityprovider_test.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"github.com/microsoft/azure-linux-dev-tools/internal/projectconfig"
1919
"github.com/microsoft/azure-linux-dev-tools/internal/providers/rpmprovider/rpmprovider_test"
2020
"github.com/microsoft/azure-linux-dev-tools/internal/providers/sourceproviders"
21+
"github.com/microsoft/azure-linux-dev-tools/internal/providers/sourceproviders/fedorasource"
2122
"github.com/microsoft/azure-linux-dev-tools/internal/rpm/rpm_test"
2223
"github.com/microsoft/azure-linux-dev-tools/internal/utils/fileperms"
2324
"github.com/microsoft/azure-linux-dev-tools/internal/utils/fileutils"
@@ -291,7 +292,7 @@ func newNoOpDownloader() *noOpDownloader {
291292
type noOpDownloader struct{}
292293

293294
func (d *noOpDownloader) ExtractSourcesFromRepo(
294-
_ context.Context, _, _, _ string, _ []string,
295+
_ context.Context, _, _, _ string, _ []string, _ ...fedorasource.ExtractOption,
295296
) error {
296297
return nil
297298
}

0 commit comments

Comments
 (0)