mirror of
https://github.com/samuelncui/yatm.git
synced 2026-11-08 12:26:33 +00:00
Unify file queries and Scan execution, retain indexed Identical results, improve Job and Preview workflows, and verify release packaging.
177 lines
6.9 KiB
Go
177 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) SourcePath(name string) (string, error) {
|
|
return "", &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)
|
|
}
|