From 643a49aa0e31a4d2adb8e768568861af2ab6d42e Mon Sep 17 00:00:00 2001 From: drondeseries Date: Mon, 6 Jul 2026 11:31:23 -0400 Subject: [PATCH 1/2] fix(importer): normalize multi-volume archives with duplicate obfuscated names --- internal/importer/archive/rar/processor.go | 19 ++++++--- internal/importer/archive/rar/utils.go | 2 +- internal/importer/archive/rar/utils_test.go | 41 +++++++++++++++++++ .../importer/archive/sevenzip/processor.go | 19 ++++++--- internal/importer/archive/sevenzip/utils.go | 3 +- 5 files changed, 72 insertions(+), 12 deletions(-) diff --git a/internal/importer/archive/rar/processor.go b/internal/importer/archive/rar/processor.go index 6f6843e2b..b0e9dff29 100644 --- a/internal/importer/archive/rar/processor.go +++ b/internal/importer/archive/rar/processor.go @@ -71,13 +71,19 @@ func (rh *rarProcessor) AnalyzeRarContentFromNzb(ctx context.Context, rarFiles [ } // Normalize RAR part filenames (e.g., part010 -> part10) for consistent processing - // Check if ALL files have no extension - if so, we'll add .partXX.rar extensions - allFilesNoExt := true + // Check if any files have no extension or if there are duplicate filenames - if so, we'll treat it as allFilesNoExt and add extensions + allFilesNoExt := false + seenNames := make(map[string]struct{}) for _, file := range rarFiles { - if archive.HasExtension(file.Filename) { - allFilesNoExt = false + if !archive.HasExtension(file.Filename) { + allFilesNoExt = true break } + if _, exists := seenNames[file.Filename]; exists { + allFilesNoExt = true + break + } + seenNames[file.Filename] = struct{}{} } // Get base filename from first file if all files have no extension @@ -86,9 +92,12 @@ func (rh *rarProcessor) AnalyzeRarContentFromNzb(ctx context.Context, rarFiles [ slices.SortFunc(rarFiles, func(a, b parser.ParsedFile) int { return strings.Compare(a.Filename, b.Filename) }) - // Use the first file's name as the base for all parts + // Use the first file's name as the base for all parts (stripping any added extension) if len(rarFiles) > 0 { baseFilename = rarFiles[0].Filename + if ext := filepath.Ext(baseFilename); ext != "" { + baseFilename = strings.TrimSuffix(baseFilename, ext) + } } } diff --git a/internal/importer/archive/rar/utils.go b/internal/importer/archive/rar/utils.go index 17189e008..67d6a1692 100644 --- a/internal/importer/archive/rar/utils.go +++ b/internal/importer/archive/rar/utils.go @@ -483,7 +483,7 @@ func normalizeRarPartFilename(filename string, index int, allFilesNoExt bool, to // If all files have no extension, use baseFilename with .rXX extension // This ensures all parts of the same archive have the same base filename // Using RAR multi-volume convention: .r00, .r01, .r02, etc. (0-based) - if allFilesNoExt && !archive.HasExtension(filename) && baseFilename != "" { + if allFilesNoExt && (!archive.HasExtension(filename) || strings.HasSuffix(filename, ".rar")) && baseFilename != "" { // Calculate padding width based on total number of files (0-based, so totalFiles-1) width := len(strconv.Itoa(totalFiles - 1)) // Format with zero-padding (index is already 0-based from OriginalIndex) diff --git a/internal/importer/archive/rar/utils_test.go b/internal/importer/archive/rar/utils_test.go index 9c6e8ab2e..74a4a93ec 100644 --- a/internal/importer/archive/rar/utils_test.go +++ b/internal/importer/archive/rar/utils_test.go @@ -231,3 +231,44 @@ func oldRollSetSkip(base string, sCount int, skip string) []string { } return out } + +func TestNormalizeRarPartFilenameMix(t *testing.T) { + tests := []struct { + name string + filename string + index int + allNoExt bool + totalFiles int + baseFilename string + want string + }{ + { + name: "first file with .rar extension gets normalized to .r00", + filename: "my_show.rar", + index: 0, + allNoExt: true, + totalFiles: 46, + baseFilename: "my_show", + want: "my_show.r00", + }, + { + name: "continuation file with no extension gets normalized to .r01", + filename: "my_show", + index: 1, + allNoExt: true, + totalFiles: 46, + baseFilename: "my_show", + want: "my_show.r01", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := normalizeRarPartFilename(tt.filename, tt.index, tt.allNoExt, tt.totalFiles, tt.baseFilename) + if got != tt.want { + t.Errorf("normalizeRarPartFilename(%q) = %q; want %q", tt.filename, got, tt.want) + } + }) + } +} + diff --git a/internal/importer/archive/sevenzip/processor.go b/internal/importer/archive/sevenzip/processor.go index 34fb240b1..0a997ce28 100644 --- a/internal/importer/archive/sevenzip/processor.go +++ b/internal/importer/archive/sevenzip/processor.go @@ -533,13 +533,19 @@ func extractBaseFilenameSevenZip(filename string) string { // renameSevenZipFilesAndSort renames all 7z files to have the same base name and sorts them func renameSevenZipFilesAndSort(sevenZipFiles []parser.ParsedFile) []parser.ParsedFile { - // Check if ALL files have no extension - if so, we'll add .XXX extensions - allFilesNoExt := true + // Check if any files have no extension or if there are duplicate filenames - if so, we'll treat it as allFilesNoExt and add extensions + allFilesNoExt := false + seenNames := make(map[string]struct{}) for _, file := range sevenZipFiles { - if archive.HasExtension(file.Filename) { - allFilesNoExt = false + if !archive.HasExtension(file.Filename) { + allFilesNoExt = true break } + if _, exists := seenNames[file.Filename]; exists { + allFilesNoExt = true + break + } + seenNames[file.Filename] = struct{}{} } // Get base filename from first file if all files have no extension @@ -549,9 +555,12 @@ func renameSevenZipFilesAndSort(sevenZipFiles []parser.ParsedFile) []parser.Pars sort.Slice(sevenZipFiles, func(i, j int) bool { return sevenZipFiles[i].Filename < sevenZipFiles[j].Filename }) - // Use the first file's name as the base for all parts + // Use the first file's name as the base for all parts (stripping any added extension) if len(sevenZipFiles) > 0 { baseFilename = sevenZipFiles[0].Filename + if ext := filepath.Ext(baseFilename); ext != "" { + baseFilename = strings.TrimSuffix(baseFilename, ext) + } } } else { // Sort files by part number BEFORE extracting base filename diff --git a/internal/importer/archive/sevenzip/utils.go b/internal/importer/archive/sevenzip/utils.go index 7ff8d5837..30e260166 100644 --- a/internal/importer/archive/sevenzip/utils.go +++ b/internal/importer/archive/sevenzip/utils.go @@ -4,6 +4,7 @@ import ( "fmt" "regexp" "strconv" + "strings" "github.com/javi11/altmount/internal/importer/archive" ) @@ -30,7 +31,7 @@ func normalize7zPartFilename(filename string, index int, allFilesNoExt bool, tot // If all files have no extension, use baseFilename with .XXX extension // This ensures all parts of the same archive have the same base filename // Using 7zip multi-volume convention: .001, .002, .003, etc. (1-based) - if allFilesNoExt && !archive.HasExtension(filename) && baseFilename != "" { + if allFilesNoExt && (!archive.HasExtension(filename) || strings.HasSuffix(filename, ".7z")) && baseFilename != "" { // Calculate padding width based on total number of files (1-based, so totalFiles) width := len(strconv.Itoa(totalFiles)) // Format with zero-padding (convert 0-based index to 1-based: index+1) From c404426c4b5b8989d946730a03f373524e4c5a12 Mon Sep 17 00:00:00 2001 From: drondeseries Date: Mon, 6 Jul 2026 15:06:12 -0400 Subject: [PATCH 2/2] docs(rar): add clarifying comment to seenNames duplicate detection --- internal/importer/archive/rar/processor.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/internal/importer/archive/rar/processor.go b/internal/importer/archive/rar/processor.go index b0e9dff29..48bc727c2 100644 --- a/internal/importer/archive/rar/processor.go +++ b/internal/importer/archive/rar/processor.go @@ -72,6 +72,8 @@ func (rh *rarProcessor) AnalyzeRarContentFromNzb(ctx context.Context, rarFiles [ // Normalize RAR part filenames (e.g., part010 -> part10) for consistent processing // Check if any files have no extension or if there are duplicate filenames - if so, we'll treat it as allFilesNoExt and add extensions + // seenNames detects obfuscated archives where every volume has the same name (or no extension). + // We break on the first duplicate found — that's sufficient to identify the obfuscated case. allFilesNoExt := false seenNames := make(map[string]struct{}) for _, file := range rarFiles { @@ -79,6 +81,9 @@ func (rh *rarProcessor) AnalyzeRarContentFromNzb(ctx context.Context, rarFiles [ allFilesNoExt = true break } + // Duplicate filename detected: this is an obfuscated multi-volume set where + // all parts share the same name. Treat as no-extension so we assign synthetic + // partXX.rar names to distinguish each volume. if _, exists := seenNames[file.Filename]; exists { allFilesNoExt = true break