Files
yatm/internal/executor/scan/read_test.go
T
Samuel CuiandClaude Opus 5.5 47b525b747 Write, read and publish encrypted tar Media
Compose tar FORMAT/APPEND, Restore, Scan and Verify through complete Media sessions and the configured encrypt adapter. Publish only complete members at normal or capacity/EW completion, maintain position maps, apply Logical or Auto read order, record passive read learning observations in each Job and refit learned read parameters after drive release. LTFS keeps its adapters and gains the shared read order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 00:01:44 +08:00

178 lines
6.9 KiB
Go

package scan
import (
"context"
"encoding/binary"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"syscall"
"testing"
"github.com/samuelncui/acp"
"github.com/samuelncui/yatm/entity"
"github.com/samuelncui/yatm/internal/executor"
"github.com/samuelncui/yatm/internal/library"
mediapkg "github.com/samuelncui/yatm/internal/media"
"github.com/stretchr/testify/require"
)
func TestVerifyReadPreparationErrorCompletesAsUnreadable(t *testing.T) {
// Permission errors during ACP preparation do not produce a sink result but remain per-file findings.
exe, volume, media := setupVerifyExecutor(t)
position := saveVerifyFile(t, exe, volume, media, "restricted", []byte("content"))
saveVerifyFile(t, exe, volume, media, "z-readable", []byte("saved"))
filename := filepath.Join(volume.Root, position.Path)
require.NoError(t, os.Chmod(filename, 0))
t.Cleanup(func() { _ = os.Chmod(filename, 0644) })
opened, err := os.Open(filename)
if err == nil {
require.NoError(t, opened.Close())
t.Skip("current user can read files without permission bits")
}
id, run := createVerifyJob(t, exe, media.ID, volume)
run()
waitVerifyIdle(t, exe, id, entity.JobStatus_JOB_STATUS_COMPLETED)
// Finalized results retain the independent expected baseline and publish only an unreadable observation.
stored, err := exe.Lib().GetPosition(context.Background(), position.ID)
require.NoError(t, err)
require.Equal(t, entity.PositionHealth_POSITION_HEALTH_UNREADABLE, stored.Health)
require.Equal(t, position.Hash, stored.Hash)
progress, err := (&service{exe: exe}).GetProgress(context.Background(), &entity.GetScanJobProgressRequest{Id: id})
require.NoError(t, err)
require.EqualValues(t, 1, progress.UnreadableCount)
require.EqualValues(t, 1, progress.MatchedCount)
require.Zero(t, progress.Progress.TotalFileCount-progress.MatchedCount-progress.UnreadableCount)
}
type sequentialCheckSession struct{ checkSession }
func (*sequentialCheckSession) Capabilities() mediapkg.Capabilities {
return mediapkg.Capabilities{Read: mediapkg.AccessSequential}
}
func TestVerifySequentialManifestCursorUsesStorageOrder(t *testing.T) {
ctx := context.Background()
exe, volume, media := setupVerifyExecutor(t)
var expected []*library.Position
for index := 0; index < batchSize+5; index++ {
position := saveVerifyFile(t, exe, volume, media, fmt.Sprintf("file-%04d", index), nil)
position.StorageOrder = make([]byte, 8)
binary.BigEndian.PutUint64(position.StorageOrder, uint64((batchSize+6-index)/2))
require.NoError(t, exe.Lib().SavePosition(ctx, position))
expected = append(expected, position)
}
sort.Slice(expected, func(i, j int) bool {
first, second := binary.BigEndian.Uint64(expected[i].StorageOrder), binary.BigEndian.Uint64(expected[j].StorageOrder)
if first != second {
return first < second
}
return expected[i].Path < expected[j].Path
})
id, _ := createVerifyJob(t, exe, media.ID, volume)
value, err := exe.GetJobRunner(ctx, id)
require.NoError(t, err)
r := value.(*runner)
var config Config
require.NoError(t, r.db.First(&config, 1).Error)
session := &sequentialCheckSession{checkSession{root: volume.Root, media: media}}
require.NoError(t, r.enumerateMedia(ctx, &config, &Scope{}, session))
stream, err := openTestContentStream(t, ctx, r, &config, &Scope{}, session)
require.NoError(t, err)
defer func() { _ = stream.writer.Close() }()
var sources []string
for {
batch, err := stream.nextPage(ctx)
if errors.Is(err, io.EOF) {
break
}
require.NoError(t, err)
for _, item := range batch {
sources = append(sources, item.Source())
}
}
ordered := make([]string, 0, len(expected))
for _, position := range expected {
ordered = append(ordered, filepath.Join(volume.Root, position.Path))
}
require.Equal(t, ordered, sources)
}
func TestVerifyCancellationFinalizesWithoutInventingFindings(t *testing.T) {
// A canceled attempt must release its physical Session while leaving unseen inventory untouched.
exe, volume, media := setupVerifyExecutor(t)
position := saveVerifyFile(t, exe, volume, media, "file", []byte("content"))
id, _ := createVerifyJob(t, exe, media.ID, volume)
value, err := exe.GetJobRunner(context.Background(), id)
require.NoError(t, err)
r := value.(*runner)
var config Config
err = r.db.First(&config, 1).Error
require.NoError(t, err)
ctx, cancel := context.WithCancel(context.Background())
cancel()
finalized := 0
err = r.runPipeline(ctx, &config, &Scope{}, nil, &checkSession{root: volume.Root, media: media, finalize: func(ctx context.Context) error {
finalized++
return ctx.Err()
}})
require.ErrorIs(t, err, context.Canceled)
require.Equal(t, 1, finalized)
// Cancellation is a Job lifecycle event, not a missing or unreadable observation for each remaining file.
stored, err := exe.Lib().GetPosition(context.Background(), position.ID)
require.NoError(t, err)
require.Equal(t, entity.PositionHealth_POSITION_HEALTH_UNKNOWN, stored.Health)
var entry Entry
require.NoError(t, r.db.First(&entry, position.ID).Error)
require.Equal(t, entity.ScanFinding_SCAN_FINDING_NOT_CHECKED, entry.Finding)
require.False(t, entry.Published)
}
type failedDeviceSession struct{ checkSession }
func (*failedDeviceSession) Source(request mediapkg.ReadRequest) (mediapkg.SourceRef, error) {
name := request.Path
return mediapkg.SourceRef{}, &os.PathError{Op: "stat", Path: name, Err: syscall.EIO}
}
func TestVerifyDeviceFailureKeepsRemainingEntriesUnchecked(t *testing.T) {
// A device-level failure is not an independent unreadable observation of every remaining path.
exe, volume, media := setupVerifyExecutor(t)
position := saveVerifyFile(t, exe, volume, media, "file", []byte("content"))
id, _ := createVerifyJob(t, exe, media.ID, volume)
value, err := exe.GetJobRunner(context.Background(), id)
require.NoError(t, err)
r := value.(*runner)
var config Config
err = r.db.First(&config, 1).Error
require.NoError(t, err)
finalized := 0
err = r.runPipeline(testAttemptContext(context.Background()), &config, &Scope{}, nil, &failedDeviceSession{checkSession{media: media,
finalize: func(context.Context) error { finalized++; return nil }}})
require.ErrorIs(t, err, syscall.EIO)
require.Equal(t, 1, finalized)
// Finalization releases resources, but neither Job findings nor Library health are fabricated.
var entry Entry
require.NoError(t, r.db.First(&entry, position.ID).Error)
require.Equal(t, entity.ScanFinding_SCAN_FINDING_NOT_CHECKED, entry.Finding)
stored, err := exe.Lib().GetPosition(context.Background(), position.ID)
require.NoError(t, err)
require.Equal(t, entity.PositionHealth_POSITION_HEALTH_UNKNOWN, stored.Health)
}
func TestVerifyByteProgressDoesNotRequireCompletedFile(t *testing.T) {
progress := executor.NewProgress()
progress.SetGlobalCopied(100, 1)
stream := &contentStream{runner: &runner{progress: progress}, processed: 0}
handler := stream.eventHandler()
handler(&acp.EventUpdateProgress{Bytes: 50})
require.EqualValues(t, 150, progress.ToEntity().CopiedBytes)
require.EqualValues(t, 1, progress.ToEntity().CopiedFileCount)
}