Skip to content
Merged
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
28 changes: 18 additions & 10 deletions pkg/postie/postie.go
Original file line number Diff line number Diff line change
Expand Up @@ -255,8 +255,7 @@ func (p *Postie) postInParallel(
var par2OutputDir string
if p.par2Cfg.MaintainPar2Files != nil && *p.par2Cfg.MaintainPar2Files {
// Generate PAR2 files directly in output directory
dirPath := filepath.Dir(f.Path)
relativePath := strings.TrimPrefix(dirPath, rootDir)
relativePath := relativePathFrom(rootDir, f.Path)
par2OutputDir = filepath.Join(outputDir, relativePath)

slog.DebugContext(ctx, "Generating PAR2 files directly in output directory",
Expand Down Expand Up @@ -309,11 +308,10 @@ func (p *Postie) postInParallel(
}

// Generate single NZB file for all files
dirPath := filepath.Dir(f.Path)
dirPath = strings.TrimPrefix(dirPath, rootDir)
relativePath := relativePathFrom(rootDir, f.Path)

// Use the original filename as input for NZB generation
nzbPath := filepath.Join(outputDir, dirPath, filepath.Base(f.Path))
nzbPath := filepath.Join(outputDir, relativePath, filepath.Base(f.Path))
finalPath, err := nzbGen.Generate(nzbPath)
if err != nil {
return "", fmt.Errorf("error generating NZB file: %w", err)
Expand Down Expand Up @@ -366,8 +364,7 @@ func (p *Postie) post(
var par2OutputDir string
if p.par2Cfg.MaintainPar2Files != nil && *p.par2Cfg.MaintainPar2Files {
// Generate PAR2 files directly in output directory
dirPath := filepath.Dir(f.Path)
relativePath := strings.TrimPrefix(dirPath, rootDir)
relativePath := relativePathFrom(rootDir, f.Path)
par2OutputDir = filepath.Join(outputDir, relativePath)

slog.DebugContext(ctx, "Generating PAR2 files directly in output directory",
Expand Down Expand Up @@ -401,11 +398,10 @@ func (p *Postie) post(
}

// Generate single NZB file for all files
dirPath := filepath.Dir(f.Path)
dirPath = strings.TrimPrefix(dirPath, rootDir)
relativePath := relativePathFrom(rootDir, f.Path)

// Use the original filename as input for NZB generation
nzbPath := filepath.Join(outputDir, dirPath, filepath.Base(f.Path))
nzbPath := filepath.Join(outputDir, relativePath, filepath.Base(f.Path))
finalPath, err := nzbGen.Generate(nzbPath)
if err != nil {
return "", fmt.Errorf("error generating NZB file: %w", err)
Expand Down Expand Up @@ -747,3 +743,15 @@ func deriveFolderName(rootDir string, files []fileinfo.FileInfo) string {
}
return name
}

// relativePathFrom computes the relative path of filePath's directory from rootDir.
// Falls back to empty string (placing output directly in outputDir) if paths
// cannot be made relative (e.g. cross-volume on Windows).
func relativePathFrom(rootDir, filePath string) string {
dirPath := filepath.Dir(filePath)
rel, err := filepath.Rel(rootDir, dirPath)
if err != nil || rel == "." {
return ""
}
return rel
}
127 changes: 127 additions & 0 deletions pkg/postie/postie_crossvolume_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package postie

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

"github.com/javi11/postie/pkg/fileinfo"
)

// withinDir checks whether path is inside dir.
func withinDir(path, dir string) bool {
cleanPath := filepath.Clean(path)
cleanDir := filepath.Clean(dir) + string(filepath.Separator)
return strings.HasPrefix(cleanPath, cleanDir)
}

// TestPostCrossVolumeNZBInOutputDir verifies that non-folder mode (post/postInParallel)
// places the NZB in the output directory, not the watch (source) directory, even when
// the two are on different volumes (no shared path prefix).
func TestPostCrossVolumeNZBInOutputDir(t *testing.T) {
watchDir := t.TempDir()
outputDir := t.TempDir()

// Create a source file in the watch directory
srcFile := filepath.Join(watchDir, "movie.mkv")
if err := os.WriteFile(srcFile, []byte("content"), 0644); err != nil {
t.Fatalf("write source file: %v", err)
}

par2mock := &mockPar2Executor{}
p := newTestPostie(par2mock, true, false)

// post() is the sequential (waitForPar2=true) non-folder path
nzbPath, err := p.post(context.Background(), fileinfo.FileInfo{
Path: srcFile,
Size: 7,
}, watchDir, outputDir)
if err != nil {
t.Fatalf("post() returned error: %v", err)
}

// NZB must be inside the output directory
if !withinDir(nzbPath, outputDir) {
t.Errorf("NZB placed outside output dir:\n nzbPath: %s\n outputDir: %s", nzbPath, outputDir)
}

// NZB must NOT be in the watch directory
if withinDir(nzbPath, watchDir) {
t.Errorf("NZB leaked into watch dir:\n nzbPath: %s\n watchDir: %s", nzbPath, watchDir)
}

// Verify the file actually exists
if _, err := os.Stat(nzbPath); os.IsNotExist(err) {
t.Errorf("NZB file does not exist at %q", nzbPath)
}
}

// TestPostInParallelCrossVolumeNZBInOutputDir does the same check for the parallel path.
func TestPostInParallelCrossVolumeNZBInOutputDir(t *testing.T) {
watchDir := t.TempDir()
outputDir := t.TempDir()

srcFile := filepath.Join(watchDir, "movie.mkv")
if err := os.WriteFile(srcFile, []byte("content"), 0644); err != nil {
t.Fatalf("write source file: %v", err)
}

par2mock := &mockPar2Executor{}
p := newTestPostie(par2mock, false, false)

nzbPath, err := p.postInParallel(context.Background(), fileinfo.FileInfo{
Path: srcFile,
Size: 7,
}, watchDir, outputDir)
if err != nil {
t.Fatalf("postInParallel() returned error: %v", err)
}

if !withinDir(nzbPath, outputDir) {
t.Errorf("NZB placed outside output dir:\n nzbPath: %s\n outputDir: %s", nzbPath, outputDir)
}

if withinDir(nzbPath, watchDir) {
t.Errorf("NZB leaked into watch dir:\n nzbPath: %s\n watchDir: %s", nzbPath, watchDir)
}

if _, err := os.Stat(nzbPath); os.IsNotExist(err) {
t.Errorf("NZB file does not exist at %q", nzbPath)
}
}

// TestPostCrossVolumeWithSubdirectory verifies that files in a subdirectory of the
// watch folder maintain their relative path structure in the output directory.
func TestPostCrossVolumeWithSubdirectory(t *testing.T) {
watchDir := t.TempDir()
outputDir := t.TempDir()

// Create a source file in a subdirectory of the watch folder
subDir := filepath.Join(watchDir, "subfolder")
if err := os.MkdirAll(subDir, 0755); err != nil {
t.Fatalf("mkdir subfolder: %v", err)
}
srcFile := filepath.Join(subDir, "movie.mkv")
if err := os.WriteFile(srcFile, []byte("content"), 0644); err != nil {
t.Fatalf("write source file: %v", err)
}

par2mock := &mockPar2Executor{}
p := newTestPostie(par2mock, true, false)

nzbPath, err := p.post(context.Background(), fileinfo.FileInfo{
Path: srcFile,
Size: 7,
}, watchDir, outputDir)
if err != nil {
t.Fatalf("post() returned error: %v", err)
}

// NZB should be in outputDir/subfolder/
expectedDir := filepath.Join(outputDir, "subfolder")
if filepath.Dir(nzbPath) != expectedDir {
t.Errorf("NZB not in expected subdirectory:\n got: %s\n want: %s/", filepath.Dir(nzbPath), expectedDir)
}
}
3 changes: 3 additions & 0 deletions tests/e2e/helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,9 @@ func newChromedpCtx(t *testing.T) (context.Context, context.CancelFunc) {
chromedp.Flag("headless", true),
chromedp.NoSandbox,
chromedp.Flag("disable-gpu", true),
// Workaround for Chromium ThreadCache crash on newer Linux kernels
// (FATAL:scheduler_loop_quarantine_support.h Check failed: ThreadCache::IsValid)
chromedp.Flag("disable-features", "PartitionAlloc"),
)
allocCtx, allocCancel := chromedp.NewExecAllocator(context.Background(), opts...)
ctx, cancel := chromedp.NewContext(allocCtx)
Expand Down
51 changes: 40 additions & 11 deletions tests/e2e/nntp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,15 @@ import (
"fmt"
"net"
"strings"
"sync"
"time"
)

type fakeNntpServer struct {
listener net.Listener
port int
listener net.Listener
port int
mu sync.Mutex
articleCount int
}

func startFakeNntpServer() (*fakeNntpServer, error) {
Expand Down Expand Up @@ -43,23 +46,49 @@ func (s *fakeNntpServer) handleConn(conn net.Conn) {
// RFC 3977 §5.1 greeting
fmt.Fprintf(conn, "200 Postie-test NNTP server ready\r\n")

scanner := bufio.NewScanner(conn)
for scanner.Scan() {
line := strings.ToUpper(strings.TrimSpace(scanner.Text()))
if line == "" {
reader := bufio.NewReader(conn)
for {
line, err := reader.ReadString('\n')
if err != nil {
return // connection closed
}
cmd := strings.ToUpper(strings.TrimSpace(line))
if cmd == "" {
continue
}
switch {
case strings.HasPrefix(line, "AUTHINFO USER"):
case strings.HasPrefix(cmd, "AUTHINFO USER"):
fmt.Fprintf(conn, "381 Enter password\r\n")
case strings.HasPrefix(line, "AUTHINFO PASS"):
case strings.HasPrefix(cmd, "AUTHINFO PASS"):
fmt.Fprintf(conn, "281 Authentication accepted\r\n")
case line == "CAPABILITIES":
case cmd == "CAPABILITIES":
fmt.Fprintf(conn, "101 Capability list:\r\nVERSION 2\r\nREADER\r\nPOST\r\nDATE\r\n.\r\n")
case line == "DATE":
case cmd == "DATE":
// nntppool sends DATE as its connectivity ping (RFC 3977 §7.1)
fmt.Fprintf(conn, "111 %s\r\n", time.Now().UTC().Format("20060102150405"))
case line == "QUIT":
case cmd == "POST":
// Phase 1: accept article
fmt.Fprintf(conn, "340 Send article\r\n")
// Phase 2: read article body until lone ".\r\n"
for {
bodyLine, err := reader.ReadString('\n')
if err != nil {
return
}
if strings.TrimRight(bodyLine, "\r\n") == "." {
break
}
}
// Extract Message-ID from the article for STAT verification
s.mu.Lock()
s.articleCount++
s.mu.Unlock()
fmt.Fprintf(conn, "240 Article posted\r\n")
case strings.HasPrefix(cmd, "STAT "):
// Post-check: always report article exists
msgID := strings.TrimSpace(line[5:])
fmt.Fprintf(conn, "223 0 %s\r\n", msgID)
case cmd == "QUIT":
fmt.Fprintf(conn, "205 closing connection\r\n")
return
default:
Expand Down
Loading
Loading