Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion internal/data/dataexport/cmd/download/download.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ const (
cmdName = "download"
)

const (
itemTypeDir = "dir"
itemTypeFile = "file"
itemTypeLink = "link"
)

func cmdExamples() string {
resp := []string{
" # Start exporter + Download + Stop for Filesystem",
Expand Down Expand Up @@ -185,13 +191,20 @@ func recursiveDownload(ctx context.Context, sClient *safeClient.SafeClient, log

err = forRespItems(resp.Body, func(item *dirItem) error {
subPath := item.Name
if item.Type == "dir" {
switch item.Type {
case itemTypeDir:
err = os.MkdirAll(filepath.Join(dstPath, subPath), os.ModePerm)
if err != nil {
return fmt.Errorf("Create dir error: %s", err.Error())
}
subPath += "/"
case itemTypeFile, itemTypeLink:
// downloadable, proceed below
default:
log.Warn("Skipping unsupported entry during filesystem download", slog.String("path", item.Name), slog.String("type", item.Type))
return nil
}

// Run subtask in a goroutine when semaphore capacity is available;
// otherwise process inline to avoid blocking on sem (prevents deadlock on wide trees).
select {
Expand Down
124 changes: 124 additions & 0 deletions internal/data/dataexport/cmd/download/download_http_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,130 @@ func TestDownloadBlock_OK(t *testing.T) {
require.Equal(t, []byte("raw!"), data)
}

// Regression: when a directory listing contains an entry with type "other" (socket, FIFO, device),
// the client must skip it with a warning and NOT make an HTTP request for it.
// Before the fix, "other" entries were reported as "dir", causing the client to recurse into them
// and receive a 400 from the server, which aborted the entire download.
func TestDownloadFilesystem_SocketInDirIsSkipped(t *testing.T) {
requestedPaths := make([]string, 0)

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestedPaths = append(requestedPaths, r.URL.Path)
switch r.URL.Path {
case "/api/v1/files/queue/":
// Directory listing: one regular file + one socket (type "other")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"apiVersion":"v1","items":[` +
`{"name":"alerts.log","type":"file","uri":"queue/alerts.log","attributes":{"gid":0,"modtime":"2026-01-01T00:00:00Z","permissions":"0644","uid":0,"size":3}},` +
`{"name":"execq","type":"other","uri":"queue/execq","attributes":{"gid":999,"modtime":"2026-01-01T00:00:00Z","permissions":"0660","uid":0}}` +
`]}`))
case "/api/v1/files/queue/alerts.log":
w.Header().Set("Content-Length", "3")
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok\n"))
default:
// Fail loudly if client requests unexpected paths (e.g. the socket)
http.Error(w, "unexpected path: "+r.URL.Path, http.StatusInternalServerError)
}
}))
defer srv.Close()

origPrep := util.PrepareDownloadFunc
origCreate := util.CreateDataExporterIfNeededFunc
util.PrepareDownloadFunc = func(_ context.Context, _ *slog.Logger, _, _ string, _ bool, _ *safereq.SafeClient) (string, string, *safereq.SafeClient, error) {
return srv.URL + "/api/v1/files", "Filesystem", newNoAuthSafe(), nil
}
util.CreateDataExporterIfNeededFunc = func(_ context.Context, _ *slog.Logger, de, _ string, _ bool, _ string, _ ctrlclient.Client) (string, error) {
return de, nil
}
defer func() { util.PrepareDownloadFunc = origPrep; util.CreateDataExporterIfNeededFunc = origCreate }()

outDir := t.TempDir()
cmd := NewCommand(context.TODO(), slog.Default())
cmd.SetArgs([]string{"myexport", "queue/", "-o", outDir, "--publish=false"})
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)

require.NoError(t, cmd.Execute())

// Regular file must be downloaded
data, err := os.ReadFile(filepath.Join(outDir, "alerts.log"))
require.NoError(t, err)
require.Equal(t, []byte("ok\n"), data)

// Socket must NOT have been requested from the server
for _, p := range requestedPaths {
require.NotContains(t, p, "execq", "client must not request socket path, got requests: %v", requestedPaths)
}
}

// Regression: recursive download through a directory tree that includes sockets
// must complete successfully and download all regular files.
func TestDownloadFilesystem_RecursiveWithSocketsCompletes(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/files/root/":
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"apiVersion":"v1","items":[` +
`{"name":"subdir","type":"dir","uri":"root/subdir/","attributes":{"gid":0,"modtime":"2026-01-01T00:00:00Z","permissions":"0755","uid":0}},` +
`{"name":"top.txt","type":"file","uri":"root/top.txt","attributes":{"gid":0,"modtime":"2026-01-01T00:00:00Z","permissions":"0644","uid":0,"size":3}}` +
`]}`))
case "/api/v1/files/root/subdir/":
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
// subdir contains a socket and a regular file
w.Write([]byte(`{"apiVersion":"v1","items":[` +
`{"name":"cfgarq","type":"other","uri":"root/subdir/cfgarq","attributes":{"gid":999,"modtime":"2026-01-01T00:00:00Z","permissions":"0660","uid":0}},` +
`{"name":"data.txt","type":"file","uri":"root/subdir/data.txt","attributes":{"gid":0,"modtime":"2026-01-01T00:00:00Z","permissions":"0644","uid":0,"size":5}}` +
`]}`))
case "/api/v1/files/root/top.txt":
w.Header().Set("Content-Length", "3")
w.WriteHeader(http.StatusOK)
w.Write([]byte("top"))
case "/api/v1/files/root/subdir/data.txt":
w.Header().Set("Content-Length", "5")
w.WriteHeader(http.StatusOK)
w.Write([]byte("inner"))
default:
http.Error(w, "unexpected: "+r.URL.Path, http.StatusInternalServerError)
}
}))
defer srv.Close()

origPrep := util.PrepareDownloadFunc
origCreate := util.CreateDataExporterIfNeededFunc
util.PrepareDownloadFunc = func(_ context.Context, _ *slog.Logger, _, _ string, _ bool, _ *safereq.SafeClient) (string, string, *safereq.SafeClient, error) {
return srv.URL + "/api/v1/files", "Filesystem", newNoAuthSafe(), nil
}
util.CreateDataExporterIfNeededFunc = func(_ context.Context, _ *slog.Logger, de, _ string, _ bool, _ string, _ ctrlclient.Client) (string, error) {
return de, nil
}
defer func() { util.PrepareDownloadFunc = origPrep; util.CreateDataExporterIfNeededFunc = origCreate }()

outDir := t.TempDir()
cmd := NewCommand(context.TODO(), slog.Default())
cmd.SetArgs([]string{"myexport", "root/", "-o", outDir, "--publish=false"})
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)

require.NoError(t, cmd.Execute())

// Both regular files must be present
top, err := os.ReadFile(filepath.Join(outDir, "top.txt"))
require.NoError(t, err)
require.Equal(t, []byte("top"), top)

inner, err := os.ReadFile(filepath.Join(outDir, "subdir", "data.txt"))
require.NoError(t, err)
require.Equal(t, []byte("inner"), inner)

// Socket must NOT exist on disk
_, err = os.Stat(filepath.Join(outDir, "subdir", "cfgarq"))
require.True(t, os.IsNotExist(err), "socket must not be created on disk")
}

func TestDownloadBlock_WrongEndpoint(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "VolumeMode: Filesystem. Not supported downloading raw block.", http.StatusBadRequest)
Expand Down
Loading