Skip to content
50 changes: 7 additions & 43 deletions 7z.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package xtractr
import (
"fmt"
"path/filepath"
"sync"

"github.com/bodgit/sevenzip"
)
Expand All @@ -23,14 +22,12 @@ func Extract7z(xFile *XFile) (size uint64, filesList, archiveList []string, err
}

for idx, password := range passwords {
size, files, archives, err := extract7z(&XFile{
FilePath: xFile.FilePath,
OutputDir: xFile.OutputDir,
FileMode: xFile.FileMode,
DirMode: xFile.DirMode,
Password: password,
FileWorkers: xFile.FileWorkers,
})
// Copy the input so the retry keeps the logger, progress callbacks,
// SquashRoot and the rest of the caller-provided configuration.
attempt := *xFile
attempt.Password = password

size, files, archives, err := extract7z(&attempt)
if err != nil && idx == len(passwords)-1 {
return size, files, archives, fmt.Errorf("used password %d of %d: %w", idx+1, len(passwords), err)
} else if err == nil {
Expand Down Expand Up @@ -107,7 +104,7 @@ func (x *XFile) extract7zParallel(sevenZip *sevenzip.ReadCloser) (uint64, []stri
return x.prog.Wrote, files, normalizeVolumes(sevenZip.Volumes(), x.FilePath), err
}

workerErr := x.sevenZipDispatchWorkers(entries)
workerErr := dispatchWorkers(x.FileWorkers, entries, x.extract7zEntry)
if workerErr != nil {
return x.prog.Wrote, files, normalizeVolumes(sevenZip.Volumes(), x.FilePath), workerErr
}
Expand Down Expand Up @@ -148,39 +145,6 @@ func (x *XFile) sevenZipPrepareEntries(sevenZip *sevenzip.ReadCloser) ([]sevenZi
return entries, files, nil
}

// sevenZipDispatchWorkers sends file entries to a bounded worker pool for extraction.
func (x *XFile) sevenZipDispatchWorkers(entries []sevenZipEntry) error {
var (
waitGroup sync.WaitGroup
firstErr error
errOnce sync.Once
semaphore = make(chan struct{}, x.FileWorkers)
)

for idx := range entries {
entry := entries[idx]

if firstErr != nil {
break
}

semaphore <- struct{}{} // acquire worker slot

waitGroup.Go(func() {
defer func() { <-semaphore }() // release worker slot

err := x.extract7zEntry(entry)
if err != nil {
errOnce.Do(func() { firstErr = err })
}
})
}

waitGroup.Wait()

return firstErr
}

// extract7zEntry extracts a single 7z file entry (used by parallel workers).
func (x *XFile) extract7zEntry(entry sevenZipEntry) error {
zFile, err := entry.sevenZipFile.Open()
Expand Down
8 changes: 7 additions & 1 deletion cpio.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,13 @@ func (x *XFile) uncpioFile(cpioFile *cpio.Header, cpioReader *cpio.Reader) (uint

// This turns hard links into symlinks.
if cpioFile.Linkname != "" {
err := x.createSymlink(file.Path, cpioFile.Linkname)
// The link's parent folder may not have its own entry in the archive.
err := x.mkDir(filepath.Dir(file.Path), x.DirMode, cpioFile.ModTime)
if err != nil {
return 0, fmt.Errorf("making cpio link parent dir: %w", err)
}

err = x.createSymlink(file.Path, cpioFile.Linkname)
if err != nil {
return 0, fmt.Errorf("%s: %w", cpioFile.FileInfo().Name(), err)
}
Expand Down
16 changes: 15 additions & 1 deletion cue.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,9 +106,15 @@ func ExtractCUE(xFile *XFile) (size uint64, files, archives []string, err error)

// Write the CUE sheet into the output directory so the folder is self-contained
// (tracks, art, and the exact split definition for archival and re-rip verification).
// filepath.Base strips any directory components, and the join is verified to
// stay inside the output folder.
cueBase := filepath.Base(xFile.FilePath)
cueDest := filepath.Join(xFile.OutputDir, cueBase)

if !xFile.pathWithinOutput(cueDest) {
return 0, nil, nil, fmt.Errorf("%s: %w: %s", xFile.FilePath, ErrInvalidPath, cueDest)
}

writeErr := copyCueToOutput(xFile.FilePath, cueDest, xFile.FileMode)
if writeErr != nil {
xFile.Debugf("Copying CUE sheet to output: %s", writeErr)
Expand Down Expand Up @@ -307,6 +313,12 @@ func parseCueSheet(reader io.Reader) (*CueSheet, []cueTimestamp, error) { //noli
func resolveCueAudioPath(cueDir, cueFile, cueFilePath string) (string, error) {
path := filepath.Join(cueDir, cueFile)

// A CUE sheet must not reference audio outside its own folder; a crafted
// FILE entry like "../../secret.flac" would read and copy that file.
if !pathWithin(cueDir, path) {
return "", fmt.Errorf("%w: %s", ErrInvalidPath, cueFile)
}

_, err := os.Stat(path)
if err == nil {
return path, nil
Expand Down Expand Up @@ -995,7 +1007,9 @@ func copyCueToOutput(srcPath, destPath string, fileMode os.FileMode) error {
return fmt.Errorf("reading cue sheet: %w", err)
}

err = os.WriteFile(destPath, data, fileMode) //nolint:gosec // ffs.
// destPath is built from filepath.Base(srcPath) joined onto the output
// folder and verified to stay inside it at the call site; it cannot traverse.
err = os.WriteFile(destPath, data, fileMode) //nolint:gosec // G703: destPath is output-folder-contained (see above).
if err != nil {
return fmt.Errorf("writing cue sheet: %w", err)
}
Expand Down
47 changes: 47 additions & 0 deletions cue_internal_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package xtractr

import (
"os"
"path/filepath"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// TestResolveCueAudioPathTraversal ensures a CUE sheet cannot reference an
// audio file outside the folder the CUE sheet lives in.
func TestResolveCueAudioPathTraversal(t *testing.T) {
t.Parallel()

cueDir := t.TempDir()
outside := filepath.Join(cueDir, "..", "outside.flac")
require.NoError(t, os.WriteFile(outside, []byte("not audio"), 0o600))

for _, cueFile := range []string{
"../outside.flac",
"../../outside.flac",
"sub/../../outside.flac",
"..",
} {
_, err := resolveCueAudioPath(cueDir, cueFile, filepath.Join(cueDir, "disc.cue"))
require.Error(t, err, cueFile)
assert.ErrorIs(t, err, ErrInvalidPath, cueFile)
}
}

// TestResolveCueAudioPathNested ensures audio in a subfolder of the CUE sheet
// still resolves (a legitimate layout some rips use).
func TestResolveCueAudioPathNested(t *testing.T) {
t.Parallel()

cueDir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(cueDir, "disc1"), 0o750))

audioPath := filepath.Join(cueDir, "disc1", "album.flac")
require.NoError(t, os.WriteFile(audioPath, []byte("not audio"), 0o600))

resolved, err := resolveCueAudioPath(cueDir, "disc1/album.flac", filepath.Join(cueDir, "disc.cue"))
require.NoError(t, err)
assert.Equal(t, audioPath, resolved)
}
Loading
Loading