diff --git a/internal/media/tape/append.go b/internal/media/tape/append.go new file mode 100644 index 0000000..3b57c60 --- /dev/null +++ b/internal/media/tape/append.go @@ -0,0 +1,90 @@ +package tape + +import ( + "context" + "fmt" + "math" +) + +// VerifyAppendTail proves the terminal filemark and restores the exact post-mark write position. +// The caller must first validate its archive label under this Device's read configuration. +// SSC makes SPACE to EOD mandatory; a rejected command, missing long position evidence, failed +// motion or a contradictory tail returns an error and never authorizes APPEND. +func (d *Device) VerifyAppendTail(ctx context.Context) (_ uint64, err error) { + // Require the prepared long-position/LOCATE pair, because only long form reports file ordinals. + defer func() { d.records.geometryError(err) }() + if d.operation != Read { + return 0, fmt.Errorf("APPEND tail verification requires read preparation") + } + navigation, err := d.Navigation(ctx) + if err != nil { + return 0, err + } + if navigation == nil || !navigation.long { + return 0, fmt.Errorf("APPEND requires long READ POSITION and LOCATE support: %w", ErrUnsupported) + } + + // SPACE locates recorded EOD but does not by itself prove the preceding file is terminated. + if err := d.records.Space(ctx, 3, 0); err != nil { + return 0, fmt.Errorf("position APPEND at recorded EOD failed, %w", err) + } + end, err := navigation.Position(ctx) + if err != nil { + return 0, fmt.Errorf("observe APPEND EOD position failed, %w", err) + } + if end.Partition != 0 || end.ObjectKnown && navigation.bits == 32 && end.Object > math.MaxUint32 { + return 0, fmt.Errorf("APPEND EOD position is outside the qualified navigation range: %w", ErrMalformed) + } + if !end.ObjectKnown || !end.FileKnown { + return 0, fmt.Errorf("APPEND EOD position does not report object and file counters: %w", ErrMalformed) + } + if end.Object == 0 || end.File == 0 || end.File == math.MaxUint64 || end.Object == math.MaxUint64 { + return 0, fmt.Errorf("APPEND EOD counters cannot establish a following archive: %w", ErrMalformed) + } + + // Verify the exact preceding object and file ordinal before checking its boundary response. + if err := navigation.Locate(ctx, 0, end.Object-1); err != nil { + return 0, fmt.Errorf("locate APPEND terminating filemark failed, %w", err) + } + before, err := navigation.Position(ctx) + if err != nil { + return 0, fmt.Errorf("verify APPEND terminating filemark position failed, %w", err) + } + if !appendPositionMatches(before, end.Object-1, end.File-1) { + return 0, fmt.Errorf("APPEND terminating filemark position contradicts EOD: %w", ErrMalformed) + } + buffer := make([]byte, d.records.Mode.MaximumBytes) + mark, err := d.records.Read(ctx, buffer) + if err != nil { + return 0, fmt.Errorf("read APPEND terminating filemark failed, %w", err) + } + if !mark.AcceptedKnown || mark.Bytes != 0 || !mark.Filemark || mark.EOD || mark.Sense.EOM || mark.Sense.ILI { + return 0, fmt.Errorf("APPEND requires a proven terminating filemark without trailing data: %w", ErrMalformed) + } + tail, err := d.records.Read(ctx, buffer) + if err != nil { + return 0, fmt.Errorf("read APPEND recorded EOD failed, %w", err) + } + if !tail.AcceptedKnown || tail.Bytes != 0 || !tail.EOD || tail.Filemark || tail.Sense.Filemark || tail.Sense.ILI { + return 0, fmt.Errorf("APPEND requires recorded EOD immediately after the terminating filemark: %w", + ErrMalformed) + } + + // Reading EOD may move the host cursor; restore and reobserve the original post-mark point. + if err := navigation.Locate(ctx, 0, end.Object); err != nil { + return 0, fmt.Errorf("restore proven APPEND position failed, %w", err) + } + restored, err := navigation.Position(ctx) + if err != nil { + return 0, fmt.Errorf("verify restored APPEND position failed, %w", err) + } + if !appendPositionMatches(restored, end.Object, end.File) { + return 0, fmt.Errorf("restored APPEND position contradicts proven EOD: %w", ErrMalformed) + } + return end.File, nil +} + +func appendPositionMatches(position Position, object, file uint64) bool { + return position.Partition == 0 && position.ObjectKnown && position.FileKnown && + position.Object == object && position.File == file +} diff --git a/internal/media/tape/append_test.go b/internal/media/tape/append_test.go new file mode 100644 index 0000000..1e3886f --- /dev/null +++ b/internal/media/tape/append_test.go @@ -0,0 +1,279 @@ +package tape + +import ( + "bytes" + "context" + "encoding/binary" + "errors" + "fmt" + "math" + "testing" + "time" + + "github.com/samuelncui/yatm/entity" +) + +type appendTailFixture struct { + endObject, endFile uint64 + bits uint32 + fixed bool + commands [][]byte + edit func(int, Command, *Response) error +} + +func (f *appendTailFixture) device(t *testing.T) *Device { + t.Helper() + // Isolate this tail invariant from Configure's separately covered record/crypto admission. + mode := RecordMode{MaximumBytes: 512 << 10} + if f.fixed { + mode.FixedBytes = mode.MaximumBytes + } + transport := newSerializedTransport(func(command Command) (Response, error) { + step := len(f.commands) + f.commands = append(f.commands, bytes.Clone(command.CDB)) + response := Response{Completed: true} + switch step { + case 0: + if !bytes.Equal(command.CDB, []byte{0x11, 3, 0, 0, 0, 0}) || command.Direction != NoData { + t.Fatalf("unexpected EOD motion %x", command.CDB) + } + case 1, 3, 7: + if !bytes.Equal(command.CDB, []byte{0x34, 6, 0, 0, 0, 0, 0, 0, 0, 0}) || len(command.Data) != 32 { + t.Fatalf("expected exact long position, got %x", command.CDB) + } + object, file := f.endObject, f.endFile + if step == 3 { + object-- + file-- + } + binary.BigEndian.PutUint64(command.Data[8:16], object) + binary.BigEndian.PutUint64(command.Data[16:24], file) + case 2, 6: + object := f.endObject + if step == 2 { + object-- + } + opcode := byte(0x92) + if f.bits == 32 { + opcode = 0x2b + } + want, err := locateCDB(opcode, 0, object) + if err != nil || !bytes.Equal(command.CDB, want) || command.Direction != NoData { + t.Fatalf("unexpected LOCATE %x want %x err=%v", command.CDB, want, err) + } + case 4, 5: + want := []byte{8, 0, 8, 0, 0, 0} + information := int32(mode.MaximumBytes) + if f.fixed { + want = []byte{8, 1, 0, 0, 1, 0} + information = 1 + } + if !bytes.Equal(command.CDB, want) || len(command.Data) != mode.MaximumBytes { + t.Fatalf("unexpected tail READ %x bytes=%d", command.CDB, len(command.Data)) + } + response.Status, response.Residual = 2, int32(len(command.Data)) + if step == 4 { + response.Sense = fixedSense(0x70, 0, 0, 1, true, 0x80, information) + } else { + response.Sense = fixedSense(0x70, 8, 0, 5, true, 0, information) + } + default: + t.Fatalf("extra command after bounded tail: %x", command.CDB) + } + if f.edit != nil { + err := f.edit(step, command, &response) + return response, err + } + return response, nil + }, func() error { return nil }) + t.Cleanup(func() { _ = transport.Close() }) + records := Records{Transport: transport, Mode: mode, Timeout: 10 * time.Minute} + opcode := byte(0x92) + if f.bits == 32 { + opcode = 0x2b + } + device := &Device{transport: transport, operation: Read, records: records, + report: &entity.InspectDeviceResponse{ + Report: &entity.InspectDeviceResponse_Tape{Tape: &entity.TapeDeviceReport{}}, + }, + navigation: &Navigation{records: records, long: true, bits: f.bits, opcode: opcode}} + for _, kind := range []entity.DeviceFeatureKind{ + entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_READ_POSITION, entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_LOCATE, + } { + device.report.GetTape().Features = append(device.report.GetTape().Features, &entity.DeviceFeature{ + Feature: kind, Support: entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_SUPPORTED, + }) + } + return device +} + +func TestVerifyAppendTailBoundedProof(t *testing.T) { + for _, test := range []struct { + bits uint32 + object uint64 + fixed bool + }{ + {32, 5, false}, {32, math.MaxUint32, false}, {64, 1<<40 + 9, false}, {64, 12, true}, + } { + t.Run(fmt.Sprintf("%d/%d/fixed=%t", test.bits, test.object, test.fixed), func(t *testing.T) { + // Prefix length never changes the two boundary READs or the fresh filemark ordinal. + fixture := &appendTailFixture{bits: test.bits, endObject: test.object, endFile: 2, fixed: test.fixed} + file, err := fixture.device(t).VerifyAppendTail(t.Context()) + if err != nil || file != 2 || len(fixture.commands) != 8 { + t.Fatalf("file=%d calls=%d err=%v", file, len(fixture.commands), err) + } + }) + } +} + +func TestVerifyAppendTailMissingEvidenceRefusesAppend(t *testing.T) { + for _, test := range []struct { + name string + calls int + setup func(*Device) + edit func(int, Command, *Response) error + }{ + {name: "unconfigured", setup: func(d *Device) { d.operation = 0 }}, + {name: "no navigator", setup: func(d *Device) { d.navigation = nil }}, + {name: "short position only", setup: func(d *Device) { d.navigation.long = false }}, + {name: "unsupported EOD position", calls: 2, edit: func(step int, _ Command, r *Response) error { + if step == 1 { + *r = Response{Completed: true, Status: 2, Sense: fixedSense(0x70, 5, 0x24, 0, false, 0, 0)} + } + return nil + }}, + {name: "unknown EOD object", calls: 2, edit: func(step int, c Command, _ *Response) error { + if step == 1 { + c.Data[0] = 4 + } + return nil + }}, + {name: "unknown EOD file", calls: 2, edit: func(step int, c Command, _ *Response) error { + if step == 1 { + c.Data[0] = 8 + } + return nil + }}, + } { + t.Run(test.name, func(t *testing.T) { + // Missing evidence fails APPEND without a file ordinal, alternate motion or sequential walk. + fixture := &appendTailFixture{bits: 64, endObject: 20, endFile: 2, edit: test.edit} + device := fixture.device(t) + if test.setup != nil { + test.setup(device) + } + file, err := device.VerifyAppendTail(t.Context()) + if err == nil || file != 0 || len(fixture.commands) != test.calls { + t.Fatalf("file=%d calls=%d err=%v", file, len(fixture.commands), err) + } + }) + } +} + +func TestVerifyAppendTailContradictionsRefuseWrite(t *testing.T) { + for _, test := range []struct { + name string + step int + edit func(Command, *Response) + }{ + {"unsupported SPACE", 0, func(_ Command, r *Response) { + *r = Response{Completed: true, Status: 2, Sense: fixedSense(0x70, 5, 0x20, 0, false, 0, 0)} + }}, + {"SPACE unknown completion", 0, func(_ Command, r *Response) { *r = Response{} }}, + {"SPACE residual", 0, func(_ Command, r *Response) { r.Residual = 1 }}, + {"medium changed", 1, func(_ Command, r *Response) { + *r = Response{Completed: true, Status: 2, Sense: fixedSense(0x70, 6, 0x28, 0, false, 0, 0)} + }}, + {"wrong partition", 1, func(c Command, _ *Response) { c.Data[7] = 1 }}, + {"zero object", 1, func(c Command, _ *Response) { clear(c.Data[8:16]) }}, + {"zero file", 1, func(c Command, _ *Response) { clear(c.Data[16:24]) }}, + {"file greater than object", 1, func(c Command, _ *Response) { binary.BigEndian.PutUint64(c.Data[16:24], 31) }}, + {"maximum object", 1, func(c Command, _ *Response) { binary.BigEndian.PutUint64(c.Data[8:16], math.MaxUint64) }}, + {"both maximum counters", 1, func(c Command, _ *Response) { + binary.BigEndian.PutUint64(c.Data[8:16], math.MaxUint64) + binary.BigEndian.PutUint64(c.Data[16:24], math.MaxUint64) + }}, + {"last-object mismatch", 3, func(c Command, _ *Response) { c.Data[15]-- }}, + {"last-file mismatch", 3, func(c Command, _ *Response) { c.Data[23]++ }}, + {"unknown last-file", 3, func(c Command, _ *Response) { c.Data[0] = 8 }}, + {"data instead of filemark", 4, func(_ Command, r *Response) { *r = Response{Completed: true} }}, + {"EOD instead of filemark", 4, func(_ Command, r *Response) { + r.Sense = fixedSense(0x70, 8, 0, 5, true, 0, 512<<10) + }}, + {"filemark payload contradiction", 4, func(_ Command, r *Response) { r.Residual-- }}, + {"filemark instead of EOD", 5, func(_ Command, r *Response) { + r.Sense = fixedSense(0x70, 0, 0, 1, true, 0x80, 512<<10) + }}, + {"data instead of EOD", 5, func(_ Command, r *Response) { *r = Response{Completed: true} }}, + {"restore object mismatch", 7, func(c Command, _ *Response) { c.Data[15]++ }}, + {"restore file mismatch", 7, func(c Command, _ *Response) { c.Data[23]-- }}, + {"restore unknown", 7, func(c Command, _ *Response) { c.Data[0] = 4 }}, + } { + t.Run(test.name, func(t *testing.T) { + // A changed native fact stops at its first offending command; no fallback or later WRITE is admitted here. + fixture := &appendTailFixture{bits: 64, endObject: 30, endFile: 2, + edit: func(step int, c Command, r *Response) error { + if step == test.step { + test.edit(c, r) + } + return nil + }} + file, err := fixture.device(t).VerifyAppendTail(t.Context()) + if err == nil || file != 0 || len(fixture.commands) != test.step+1 { + t.Fatalf("file=%d calls=%d err=%v", file, len(fixture.commands), err) + } + }) + } +} + +func TestVerifyAppendTailFailuresAndCancellation(t *testing.T) { + for _, step := range []int{0, 1, 2, 3, 4, 5, 6, 7} { + t.Run(fmt.Sprint(step), func(t *testing.T) { + // Every completed boundary must propagate transport failures rather than silently choose another route. + failure := errors.New("injected append transport failure") + fixture := &appendTailFixture{bits: 64, endObject: 30, endFile: 2, + edit: func(at int, _ Command, _ *Response) error { + if at == step { + return failure + } + return nil + }} + file, err := fixture.device(t).VerifyAppendTail(t.Context()) + if !errors.Is(err, failure) || file != 0 || len(fixture.commands) != step+1 { + t.Fatalf("file=%d calls=%d err=%v", file, len(fixture.commands), err) + } + }) + } + + // Cancellation before admission or after successful SPACE cannot issue another physical command. + for _, afterSpace := range []bool{false, true} { + ctx, cancel := context.WithCancel(t.Context()) + fixture := &appendTailFixture{bits: 64, endObject: 30, endFile: 2} + if afterSpace { + fixture.edit = func(step int, _ Command, _ *Response) error { + if step == 0 { + cancel() + } + return nil + } + } else { + cancel() + } + _, err := fixture.device(t).VerifyAppendTail(ctx) + cancel() + wantCalls := 0 + if afterSpace { + wantCalls = 1 + } + if !errors.Is(err, context.Canceled) || len(fixture.commands) != wantCalls { + t.Fatalf("cancel afterSpace=%t calls=%d err=%v", afterSpace, len(fixture.commands), err) + } + } + + // Long READ POSITION cannot make a LOCATE(10) target wider than its native field. + fixture := &appendTailFixture{bits: 32, endObject: math.MaxUint32 + 1, endFile: 2} + _, err := fixture.device(t).VerifyAppendTail(t.Context()) + if err == nil || len(fixture.commands) != 2 { + t.Fatalf("32-bit range check calls=%d err=%v", len(fixture.commands), err) + } +} diff --git a/internal/media/tape/capacity.go b/internal/media/tape/capacity.go new file mode 100644 index 0000000..f79b386 --- /dev/null +++ b/internal/media/tape/capacity.go @@ -0,0 +1,276 @@ +package tape + +import ( + "context" + "encoding/binary" + "errors" + "fmt" + "math" + "syscall" + "time" + + "github.com/samuelncui/yatm/entity" + "google.golang.org/protobuf/proto" +) + +type CapacityQuality uint8 + +const ( + CapacityUnknown CapacityQuality = iota + CapacityEstimate + CapacityLowerBound +) + +// CapacitySample describes recorded-EOD-to-EW native bytes, never head position or nominal capacity. +// Missing remaining bytes or allowances are unknown; they cannot establish a bounded fit guarantee. +type CapacitySample struct { + Partition uint32 + IssuedAt, CompletedAt time.Time + RemainingBytes *uint64 + Quality CapacityQuality + ErrorBytes, UnreflectedBytes *uint64 +} + +// Capacity owns an attempt's optional query. Initial is immutable; the writer owns its cache. +type Capacity struct { + Initial *CapacitySample + transport Transport + partition uint32 +} + +// All capacity queries use the LTFS LTO LOG SENSE native timeout. +const capacityObservationTimeout = 60 * time.Second + +func capacityCommand() Command { + return Command{CDB: []byte{0x4d, 0, 0x4c, 0, 0, 0, 0, 0x10, 0, 0}, Direction: FromDevice, + Data: make([]byte, MaxQueryBytes), Timeout: capacityObservationTimeout} +} + +// capacityQueryError records a matched, settled read-only timeout or busy response. It says +// nothing about WRITE completion and never replaces another native or cleanup failure. +type capacityQueryError struct { + response Response + cause error +} + +func (e *capacityQueryError) Error() string { + return fmt.Sprintf("capacity query failed, status=%02x host=%x driver=%x: %v", + e.response.Status, e.response.HostStatus, e.response.DriverStatus, e.cause) +} + +func (e *capacityQueryError) Unwrap() error { return e.cause } + +func checkCommandResponse(command Command, response Response) error { + // Only the exact optional counter query can treat a clean completed timeout/busy as a miss. + // CheckResponse stays strict for WRITE, controls, and ambiguous native response envelopes. + if !exactCapacityCommand(command) || !response.Completed || response.MessageStatus != 0 || + len(response.Sense) != 0 || response.Residual < 0 || int(response.Residual) > len(command.Data) { + return CheckResponse(response) + } + switch { + case response.Status == 0 && (response.HostStatus == 0 || response.HostStatus == 3) && + (response.DriverStatus == 0 || response.DriverStatus == 6) && (response.HostStatus == 3 || response.DriverStatus == 6): + return &capacityQueryError{response: response, cause: context.DeadlineExceeded} + case response.Status == 0 && (response.HostStatus == 0 || response.HostStatus == 2) && + (response.DriverStatus == 0 || response.DriverStatus == 1) && (response.HostStatus == 2 || response.DriverStatus == 1): + return &capacityQueryError{response: response, cause: syscall.EBUSY} + case response.Status == 0x08 && response.HostStatus == 0 && response.DriverStatus == 0: + return &capacityQueryError{response: response, cause: syscall.EBUSY} + } + return CheckResponse(response) +} + +func onlyCapacityQueryError(err error) bool { + // Every joined cause must be query-local; a miss cannot hide a reset or loss. + if _, ok := err.(*capacityQueryError); ok { + return true + } + if wrapped, ok := err.(interface{ Unwrap() error }); ok { + return onlyCapacityQueryError(wrapped.Unwrap()) + } + if joined, ok := err.(interface{ Unwrap() []error }); ok { + causes := joined.Unwrap() + if len(causes) == 0 { + return false + } + for _, cause := range causes { + if !onlyCapacityQueryError(cause) { + return false + } + } + return true + } + return false +} + +func capacitySubmissionRejected(err error) bool { + // The native exchange wraps an errno. A joined cleanup/device failure is never optional. + if wrapped, ok := err.(interface{ Unwrap() error }); ok { + return capacitySubmissionRejected(wrapped.Unwrap()) + } + return err == syscall.EINVAL || err == syscall.ENOTTY || err == syscall.EACCES || err == syscall.EPERM +} + +// Observe never waits for lane admission; Linux writer transports own a private capacity lane. +// Its callback runs immediately before an issued query. Busy returns nil,false,nil; +// cancellation after issue still waits for native command settlement. +func (c *Capacity) Observe(ctx context.Context, onIssue func() error) (*CapacitySample, bool, error) { + // An opaque transport cannot promise the issue boundary or nonqueued optional admission. + transport, ok := c.transport.(interface { + TryExecute(context.Context, Command, func() error) (Response, bool, error) + }) + if !ok { + return nil, false, ErrUnsupported + } + command := capacityCommand() + var issuedAt time.Time + response, issued, err := transport.TryExecute(ctx, command, func() error { + if onIssue != nil { + if err := onIssue(); err != nil { + return err + } + } + issuedAt = time.Now() + return nil + }) + if err != nil || !issued { + return nil, issued, err + } + + return capacityResponse(command, response, c.partition, issuedAt, time.Now()) +} + +func preparationCapacity(ctx context.Context, transport Transport, partition uint32) (*CapacitySample, bool, error) { + // Exclusive preparation has no competing data; the interval conservatively includes Execute admission. + command := capacityCommand() + started := time.Now() + response, err := transport.Execute(ctx, command) + if err != nil { + return nil, response.Completed, err + } + return capacityResponse(command, response, partition, started, time.Now()) +} + +func capacityResponse(command Command, response Response, partition uint32, started, completed time.Time) (*CapacitySample, bool, error) { + // Host/sense/residual evidence remains authoritative before interpreting an optional value. + if err := checkCommandResponse(command, response); err != nil { + return nil, true, err + } + if response.Residual < 0 || int(response.Residual) > len(command.Data) || started.IsZero() { + return nil, true, &UncertainError{Cause: ErrMalformed} + } + remaining, complete, err := decodeCapacity(command.Data[:len(command.Data)-int(response.Residual)]) + if err != nil { + return nil, true, err + } + quality := CapacityUnknown + if complete { + quality = CapacityEstimate + } + return &CapacitySample{Partition: partition, IssuedAt: started, CompletedAt: completed, + RemainingBytes: remaining, Quality: quality}, true, nil +} + +func decodeCapacity(data []byte) (*uint64, bool, error) { + // SSC-3 rev03e §8.2.2 and IBM GA32-0928-08 §6.4.8.1 define optional current-partition fields. + // HP's documented four-byte layout is accepted; unknown/new layouts cannot invent an estimate. + if len(data) < 4 || data[0]&0x7f != 0x0c || data[1] != 0 { + return nil, false, ErrMalformed + } + end := 4 + int(binary.BigEndian.Uint16(data[2:4])) + if end > len(data) { + return nil, false, ErrMalformed + } + var eod, warning *uint32 + for offset := 4; offset < end; { + if end-offset < 4 { + return nil, false, ErrMalformed + } + code, size := binary.BigEndian.Uint16(data[offset:offset+2]), int(data[offset+3]) + if offset+4+size > end { + return nil, false, ErrMalformed + } + if code == 4 || code == 5 { + format := data[offset+2] & 3 + if size != 4 || format != 0 && format != 3 { + return nil, false, ErrMalformed + } + value := binary.BigEndian.Uint32(data[offset+4 : offset+8]) + field := &eod + if code == 5 { + field = &warning + } + if *field != nil { + return nil, false, ErrMalformed + } + *field = &value + } + offset += 4 + size + } + if eod == nil || warning == nil { + return nil, false, nil + } + if *eod == math.MaxUint32 || *warning == math.MaxUint32 { + return nil, true, nil + } + remaining := uint64(0) + if *warning > *eod { + remaining = uint64(*warning-*eod) * 1_000_000 + } + return &remaining, true, nil +} + +// Capacity obtains a single preparation observation after the configured baseline proves partition zero. +// Callers choose periodic observation cadence independently of preparation. +func (d *Device) Capacity(ctx context.Context) (*Capacity, error) { + if d.prepared == nil || d.records.Mode.MaximumBytes == 0 || d.report.GetTape().GetPartitionCount() != 1 { + return nil, fmt.Errorf("capacity observation requires a prepared single-partition Tape") + } + capacity := &Capacity{transport: d.transport} + sample, issued, err := preparationCapacity(ctx, d.transport, 0) + err = errors.Join(err, ctx.Err()) + d.reportCapacity(sample, issued, err) + if errors.Is(err, ErrUncertain) || ctx.Err() != nil || Reason(err) == entity.DeviceReason_DEVICE_REASON_NO_MEDIUM || Reason(err) == entity.DeviceReason_DEVICE_REASON_NOT_READY { + d.invalidateGeometry() + return nil, err + } + capacity.Initial = sample + return capacity, nil +} + +func (d *Device) reportCapacity(sample *CapacitySample, issued bool, err error) { + // Optional estimates never self-certify physical bounds or streaming qualification. + f := feature(d.report, entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_CAPACITY_TO_WARNING) + f.Validation = entity.DeviceFeatureValidation_DEVICE_FEATURE_VALIDATION_UNVALIDATED + f.Evidence = entity.DeviceFeatureEvidence_DEVICE_FEATURE_EVIDENCE_UNSPECIFIED + f.Limits = nil + f.Support, f.Availability, f.Reason = entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_UNKNOWN, entity.DeviceFeatureAvailability_DEVICE_FEATURE_AVAILABILITY_UNKNOWN, entity.DeviceReason_DEVICE_REASON_NOT_READY + f.Detail = "Capacity estimate unavailable" + if err != nil { + f.Reason, f.Detail = Reason(err), err.Error() + if f.Reason == entity.DeviceReason_DEVICE_REASON_UNSUPPORTED { + f.Support = entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_UNSUPPORTED + f.Availability = entity.DeviceFeatureAvailability_DEVICE_FEATURE_AVAILABILITY_UNAVAILABLE + } + if errors.Is(err, ErrUncertain) || f.Reason == entity.DeviceReason_DEVICE_REASON_NO_MEDIUM || f.Reason == entity.DeviceReason_DEVICE_REASON_NOT_READY { + f.Availability = entity.DeviceFeatureAvailability_DEVICE_FEATURE_AVAILABILITY_UNAVAILABLE + } + return + } + if !issued || sample == nil { + return + } + f.Evidence = entity.DeviceFeatureEvidence_DEVICE_FEATURE_EVIDENCE_REPORTED + f.Support = entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_PARTIAL + limits := &entity.TapeCapacityLimits{Partition: proto.Uint32(sample.Partition), RemainingBytes: sample.RemainingBytes, + IssuedAtNs: proto.Int64(sample.IssuedAt.UnixNano()), CompletedAtNs: proto.Int64(sample.CompletedAt.UnixNano())} + if sample.Quality == CapacityEstimate { + limits.Quality = entity.TapeCapacityQuality_TAPE_CAPACITY_QUALITY_ESTIMATE + f.Support = entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_SUPPORTED + } + f.Limits = &entity.DeviceFeature_Capacity{Capacity: limits} + if sample.RemainingBytes != nil { + f.Support, f.Availability, f.Reason = entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_SUPPORTED, entity.DeviceFeatureAvailability_DEVICE_FEATURE_AVAILABILITY_READY, 0 + f.Detail = "Recorded-EOD-to-EW estimate observed; error and unreflected-write allowances unknown" + } +} diff --git a/internal/media/tape/capacity_device_test.go b/internal/media/tape/capacity_device_test.go new file mode 100644 index 0000000..42216ba --- /dev/null +++ b/internal/media/tape/capacity_device_test.go @@ -0,0 +1,72 @@ +package tape + +import ( + "context" + "testing" + + "github.com/samuelncui/yatm/entity" +) + +func TestCapacityReadinessLossPreventsDirectDevicePreparation(t *testing.T) { + for _, noMedium := range []bool{false, true} { + // The direct device path must consume the readiness rejection, even though earlier TUR succeeded. + fixture := newDeviceFixture() + transport := newSerializedTransport(func(command Command) (Response, error) { + if command.CDB[0] == 0x4d { + asc := byte(4) + if noMedium { + asc = 0x3a + } + return Response{Completed: true, Status: 2, Sense: fixedSense(0x70, 2, asc, 0, false, 0, 0)}, nil + } + return fixture.execute(command) + }, func() error { fixture.closed++; return nil }) + device, err := NewDevice(context.Background(), Identity{}, transport) + if err != nil { + t.Fatal(err) + } + _, err = device.Configure(context.Background(), &entity.TarMediaProfile{RecordMode: entity.TapeRecordMode_TAPE_RECORD_MODE_VARIABLE, RecordBytes: 512}, Format) + if err == nil || fixture.selects != 0 || fixture.formats != 0 || fixture.motions != 0 || fixture.writes != 0 || fixture.closed != 1 { + t.Fatalf("readiness loss admitted mutation: err=%v fixture=%+v", err, fixture) + } + } +} + +func TestPreparationCapacityReadinessLossIsFatalButOptionalAbsenceIsNot(t *testing.T) { + for _, unsupported := range []bool{false, true} { + // After baseline configuration, a disappearing/not-ready cartridge invalidates preparation. + fixture := newDeviceFixture() + fail := false + transport := newSerializedTransport(func(command Command) (Response, error) { + if fail && command.CDB[0] == 0x4d { + key, asc := byte(2), byte(4) + if unsupported { + key, asc = 5, 0x20 + } + return Response{Completed: true, Status: 2, Sense: fixedSense(0x70, key, asc, 0, false, 0, 0)}, nil + } + return fixture.execute(command) + }, func() error { return nil }) + device, err := NewDevice(context.Background(), Identity{}, transport) + if err != nil { + t.Fatal(err) + } + if _, err := device.Configure(context.Background(), &entity.TarMediaProfile{RecordMode: entity.TapeRecordMode_TAPE_RECORD_MODE_FIXED, RecordBytes: 512}, Read); err != nil { + t.Fatal(err) + } + + // Optional unsupported counters retain unknown initial capacity; actual not-ready returns its cause. + fail = true + capacity, err := device.Capacity(context.Background()) + if unsupported { + if err != nil || capacity == nil || capacity.Initial != nil { + t.Fatalf("optional capacity absence invalidated preparation: capacity=%v err=%v", capacity, err) + } + } else if capacity != nil || Reason(err) != entity.DeviceReason_DEVICE_REASON_NOT_READY { + t.Fatalf("not-ready silently continued: capacity=%v err=%v", capacity, err) + } + if err := device.Cleanup(context.Background()); err != nil { + t.Fatal(err) + } + } +} diff --git a/internal/media/tape/capacity_grace_test.go b/internal/media/tape/capacity_grace_test.go new file mode 100644 index 0000000..68b4289 --- /dev/null +++ b/internal/media/tape/capacity_grace_test.go @@ -0,0 +1,452 @@ +package tape + +import ( + "context" + "errors" + "fmt" + "syscall" + "testing" + "testing/synctest" + "time" + "unsafe" + + "github.com/samuelncui/yatm/entity" + "github.com/stretchr/testify/require" +) + +func TestCapacityQueriesUseLTFSNativeBound(t *testing.T) { + // Preparation, passive inspection and recurring observation use the same LOG SENSE bound. + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + var timeouts []time.Duration + transport := newSerializedTransport(func(command Command) (Response, error) { + if command.CDB[0] != 0x4d { + return inspectionFixture(command) + } + timeouts = append(timeouts, command.Timeout) + return capacityTestResponse(command) + }, func() error { return nil }) + defer func() { require.NoError(t, transport.Close()) }() + _, err := inspectTransport(ctx, Identity{}, transport) + require.NoError(t, err) + sample, issued, err := preparationCapacity(ctx, transport, 0) + require.NoError(t, err) + require.True(t, issued) + require.NotNil(t, sample) + sample, issued, err = (&Capacity{transport: transport}).Observe(ctx, nil) + require.NoError(t, err) + require.True(t, issued) + require.NotNil(t, sample) + require.Equal(t, []time.Duration{60 * time.Second, 60 * time.Second, 60 * time.Second}, timeouts) +} + +func TestPrivateCapacityMalformedPayloadKeepsWriteAndRecoveryAvailable(t *testing.T) { + // A GOOD settled native response with undecodable counters cannot invalidate an independent WRITE. + writes, queries, opens := 0, 0, 0 + transport := capacityTestTransport(func(Command) (Response, error) { + writes++ + return Response{Completed: true}, nil + }, func() (*serializedTransport, error) { + opens++ + return newSerializedTransport(func(command Command) (Response, error) { + queries++ + if queries == 1 { + return Response{Completed: true}, nil // Valid native envelope, invalid all-zero page. + } + return capacityTestResponse(command) + }, func() error { return nil }), nil + }, func() error { return nil }) + provider := &Capacity{transport: transport} + sample, issued, err := provider.Observe(context.Background(), nil) + require.True(t, issued) + require.Nil(t, sample) + require.ErrorIs(t, err, ErrMalformed) + require.NotErrorIs(t, err, ErrUncertain) + _, err = transport.Execute(context.Background(), capacityTestWrite()) + require.NoError(t, err) + + // A later usable observation can recover on the same attachment; no local miss is published as a value. + sample, issued, err = provider.Observe(context.Background(), nil) + require.NoError(t, err) + require.True(t, issued) + require.Equal(t, uint64(1_000_000), *sample.RemainingBytes) + require.Equal(t, 1, writes) + require.Equal(t, 2, queries) + require.Equal(t, 1, opens) + require.NoError(t, transport.Close()) +} + +func TestCapacitySubmissionRejectionOnlyWithdrawsQuery(t *testing.T) { + for _, failure := range []error{syscall.EINVAL, syscall.ENOTTY, syscall.EACCES, syscall.EPERM, + fmt.Errorf("submit SG header failed, %w", syscall.EINVAL), fmt.Errorf("submit SG header failed, %w", syscall.ENOTTY), + fmt.Errorf("submit SG header failed, %w", syscall.EACCES), fmt.Errorf("submit SG header failed, %w", syscall.EPERM)} { + t.Run(failure.Error(), func(t *testing.T) { + for _, recurring := range []bool{false, true} { + // Both preparation and the private lane may discover unavailable optional native commands. + queries, writes := 0, 0 + native := &sgTransport{limit: MaxQueryBytes} + native.command = func(header *sgHeader) (bool, error) { + if *header.Command == 0x0a { + writes++ + return false, nil + } + queries++ + return false, failure + } + native.serializedTransport = newSerializedTransport(native.executeCommand, func() error { return nil }) + transport := native + if recurring { + transport = capacityTestTransport(func(Command) (Response, error) { + writes++ + return Response{Completed: true}, nil + }, func() (*serializedTransport, error) { return native.serializedTransport, nil }, func() error { return nil }) + } else { + native.capacity = newCapacityTransport(native.serializedTransport, func() (*serializedTransport, error) { + t.Fatal("preparation unexpectedly opened another descriptor") + return nil, nil + }) + } + + var err error + if recurring { + _, _, err = (&Capacity{transport: transport}).Observe(context.Background(), nil) + } else { + _, _, err = preparationCapacity(context.Background(), transport, 0) + } + require.True(t, CleanUnsupported(err)) + require.Contains(t, err.Error(), failure.Error()) + _, err = transport.Execute(context.Background(), capacityTestWrite()) + require.NoError(t, err) + require.Equal(t, 1, writes) + if recurring { + _, issued, err := (&Capacity{transport: transport}).Observe(context.Background(), nil) + require.False(t, issued) + require.True(t, CleanUnsupported(err)) + } + require.Equal(t, 1, queries) + require.NoError(t, transport.Close()) + } + }) + } +} + +func TestCapacitySubmissionRejectionCannotHideJoinedFailure(t *testing.T) { + // A permission rejection joined with another device/cleanup failure remains attempt-wide. + writes := 0 + query := &sgTransport{limit: MaxQueryBytes} + query.command = func(*sgHeader) (bool, error) { + return false, fmt.Errorf("submit: %w", errors.Join(syscall.EACCES, syscall.EIO)) + } + query.serializedTransport = newSerializedTransport(query.executeCommand, func() error { return nil }) + transport := capacityTestTransport(func(Command) (Response, error) { + writes++ + return Response{Completed: true}, nil + }, func() (*serializedTransport, error) { return query.serializedTransport, nil }, func() error { return nil }) + _, _, err := (&Capacity{transport: transport}).Observe(context.Background(), nil) + require.ErrorIs(t, err, syscall.EIO) + require.False(t, CleanUnsupported(err)) + _, err = transport.Execute(context.Background(), capacityTestWrite()) + require.ErrorIs(t, err, syscall.EIO) + require.Zero(t, writes) + require.NoError(t, transport.Close()) +} + +func TestPrivateCapacitySettledFailureKeepsWriteAndQueryAvailable(t *testing.T) { + for _, tc := range []struct { + name string + host, driver uint16 + status byte + want error + }{ + {"host timeout", 3, 0, 0, context.DeadlineExceeded}, + {"driver timeout", 0, 6, 0, context.DeadlineExceeded}, + {"host and driver timeout", 3, 6, 0, context.DeadlineExceeded}, + {"host busy", 2, 0, 0, syscall.EBUSY}, + {"driver busy", 0, 1, 0, syscall.EBUSY}, + {"host and driver busy", 2, 1, 0, syscall.EBUSY}, + {"SCSI busy", 0, 0, 0x08, syscall.EBUSY}, + } { + t.Run(tc.name, func(t *testing.T) { + // The generic checker stays strict; only the exact capacity command owns this exception. + require.Error(t, CheckResponse(Response{Completed: true, Status: tc.status, HostStatus: tc.host, DriverStatus: tc.driver})) + + // A matched read-only rejection discards the sample without poisoning the shared attempt. + queries, writes, opens, closes := 0, 0, 0, 0 + query := &sgTransport{limit: MaxQueryBytes} + query.command = func(header *sgHeader) (bool, error) { + queries++ + require.Equal(t, uint32(60_000), header.Timeout) + if queries == 1 { + header.HostStatus, header.DriverStatus = tc.host, tc.driver + header.Status = tc.status + return false, nil + } + data := capacityPage(1, 2) + copy(unsafe.Slice(header.Data, int(header.DataLength)), data) + header.Residual = int32(int(header.DataLength) - len(data)) + return false, nil + } + query.serializedTransport = newSerializedTransport(query.executeCommand, func() error { closes++; return nil }) + transport := capacityTestTransport(func(Command) (Response, error) { writes++; return Response{Completed: true}, nil }, + func() (*serializedTransport, error) { opens++; return query.serializedTransport, nil }, func() error { return nil }) + provider := &Capacity{transport: transport} + sample, issued, err := provider.Observe(context.Background(), nil) + require.True(t, issued) + require.Nil(t, sample) + require.ErrorIs(t, err, tc.want) + require.NotErrorIs(t, err, ErrUncertain) + + // Writing and a later successful query retain the same attachment, without command replay. + _, err = transport.Execute(context.Background(), capacityTestWrite()) + require.NoError(t, err) + sample, issued, err = provider.Observe(context.Background(), nil) + require.NoError(t, err) + require.True(t, issued) + require.Equal(t, uint64(1_000_000), *sample.RemainingBytes) + require.Equal(t, 1, writes) + require.Equal(t, 2, queries) + require.Equal(t, 1, opens) + require.NoError(t, transport.Close()) + require.Equal(t, 1, closes) + }) + } +} + +func TestPreparationCapacitySettledFailureKeepsWriteAvailable(t *testing.T) { + for _, tc := range []struct { + name string + host, driver uint16 + status byte + want error + }{ + {"host timeout", 3, 0, 0, context.DeadlineExceeded}, + {"driver timeout", 0, 6, 0, context.DeadlineExceeded}, + {"host busy", 2, 0, 0, syscall.EBUSY}, + {"driver busy", 0, 1, 0, syscall.EBUSY}, + {"SCSI busy", 0, 0, 0x08, syscall.EBUSY}, + } { + t.Run(tc.name, func(t *testing.T) { + // Preparation shares the WRITE lane and must retain it after a clean counter-query rejection. + queries, writes, closes := 0, 0, 0 + transport := &sgTransport{limit: MaxQueryBytes} + transport.command = func(header *sgHeader) (bool, error) { + if *header.Command == 0x0a { + writes++ + return false, nil + } + queries++ + require.Equal(t, uint32(60_000), header.Timeout) + if queries == 1 { + header.HostStatus, header.DriverStatus, header.Status = tc.host, tc.driver, tc.status + return false, nil + } + data := capacityPage(1, 2) + copy(unsafe.Slice(header.Data, int(header.DataLength)), data) + header.Residual = int32(int(header.DataLength) - len(data)) + return false, nil + } + transport.serializedTransport = newSerializedTransport(transport.executeCommand, func() error { closes++; return nil }) + transport.capacity = newCapacityTransport(transport.serializedTransport, func() (*serializedTransport, error) { + t.Error("preparation opened the recurring query attachment") + return nil, ErrUnsupported + }) + sample, issued, err := preparationCapacity(context.Background(), transport, 0) + require.True(t, issued) + require.Nil(t, sample) + require.ErrorIs(t, err, tc.want) + require.NotErrorIs(t, err, ErrUncertain) + + // The same primary native owner accepts WRITE and a later successful query without replay. + _, err = transport.Execute(context.Background(), capacityTestWrite()) + require.NoError(t, err) + sample, issued, err = preparationCapacity(context.Background(), transport, 0) + require.NoError(t, err) + require.True(t, issued) + require.NotNil(t, sample) + require.Equal(t, uint64(1_000_000), *sample.RemainingBytes) + require.Equal(t, 1, writes) + require.Equal(t, 2, queries) + require.NoError(t, transport.Close()) + require.Equal(t, 1, closes) + }) + } +} + +func TestCapacityInspectionSettledTimeoutKeepsBaselineAvailable(t *testing.T) { + // A settled optional timeout retains the independently observed drive and medium facts. + queries, writes, closes := 0, 0, 0 + transport := newSerializedTransport(func(command Command) (Response, error) { + if command.CDB[0] == 0x0a { + writes++ + return Response{Completed: true}, nil + } + if command.CDB[0] == 0x4d { + queries++ + if queries == 1 { + return Response{Completed: true, HostStatus: 3}, nil + } + return capacityTestResponse(command) + } + return inspectionFixture(command) + }, func() error { closes++; return nil }) + report, err := inspectTransport(context.Background(), Identity{}, transport) + require.NoError(t, err) + require.Equal(t, entity.DeviceReason_DEVICE_REASON_UNSPECIFIED, report.Reason) + require.True(t, report.GetTape().GetMediumPresent()) + f := feature(report, entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_CAPACITY_TO_WARNING) + require.Nil(t, f.GetCapacity()) + require.Equal(t, entity.DeviceFeatureEvidence_DEVICE_FEATURE_EVIDENCE_UNSPECIFIED, f.Evidence) + require.Contains(t, f.Detail, context.DeadlineExceeded.Error()) + + // The same inspected owner can WRITE and later observe capacity without reopening or replaying. + _, err = transport.Execute(context.Background(), capacityTestWrite()) + require.NoError(t, err) + sample, issued, err := preparationCapacity(context.Background(), transport, 0) + require.NoError(t, err) + require.True(t, issued) + require.NotNil(t, sample) + require.Equal(t, uint64(1_000_000), *sample.RemainingBytes) + require.Equal(t, 1, writes) + require.Equal(t, 2, queries) + require.NoError(t, transport.Close()) + require.Equal(t, 1, closes) +} + +func TestCapacityInspectionWaitsForNativeLifetimeAndRespectsCaller(t *testing.T) { + for _, timeout := range []bool{false, true} { + for _, cancelCaller := range []bool{false, true} { + t.Run(fmt.Sprintf("timeout=%t/caller_deadline=%t", timeout, cancelCaller), func(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + // An optional query may consume its full native timeout; only the caller can cancel inspection. + ctx := context.Background() + if cancelCaller { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, 10*time.Second) + defer cancel() + } + queries, closes := 0, 0 + transport := newSerializedTransport(func(command Command) (Response, error) { + if command.CDB[0] != 0x4d { + return inspectionFixture(command) + } + queries++ + require.Equal(t, 60*time.Second, command.Timeout) + time.Sleep(61 * time.Second) // Virtual time includes native error handling. + if timeout { + return Response{Completed: true, HostStatus: 3}, nil + } + return capacityTestResponse(command) + }, func() error { closes++; return nil }) + device, err := NewDevice(ctx, Identity{}, transport) + require.Equal(t, 1, queries) + if cancelCaller { + require.ErrorIs(t, err, context.DeadlineExceeded) + require.Nil(t, device) + require.Equal(t, 1, closes) + return + } + require.NoError(t, err) + require.NotNil(t, device) + require.Zero(t, device.Report().Reason) + require.True(t, device.Report().GetTape().GetMediumPresent()) + require.NoError(t, transport.Close()) + require.Equal(t, 1, closes) + }) + }) + } + } +} + +func TestPrivateCapacityTimeoutCannotHideOtherNativeFailure(t *testing.T) { + for _, tc := range []struct { + name string + change func(*sgHeader) + err error + }{ + {"reset", func(h *sgHeader) { h.HostStatus = 8 }, nil}, + {"check condition", func(h *sgHeader) { h.Status = 2 }, nil}, + {"deferred sense", func(h *sgHeader) { + h.Status = 2 + sense := fixedSense(0x71, 3, 0x11, 0, false, 0, 0) + copy(unsafe.Slice(h.Sense, int(h.SenseLength)), sense) + h.SenseWritten = uint8(len(sense)) + }, nil}, + {"medium loss", func(h *sgHeader) { + h.Status = 2 + sense := fixedSense(0x70, 2, 0x3a, 0, false, 0, 0) + copy(unsafe.Slice(h.Sense, int(h.SenseLength)), sense) + h.SenseWritten = uint8(len(sense)) + }, nil}, + {"negative residual", func(h *sgHeader) { h.Residual = -1 }, nil}, + {"excess residual", func(h *sgHeader) { h.Residual = int32(h.DataLength) + 1 }, nil}, + {"message status", func(h *sgHeader) { h.MessageStatus = 1 }, nil}, + {"driver suggestion", func(h *sgHeader) { h.DriverStatus = 0x16 }, nil}, + {"native info", func(h *sgHeader) { h.Info = 2 }, nil}, + {"joined transport failure", func(*sgHeader) {}, errors.Join(context.DeadlineExceeded, syscall.EIO)}, + } { + t.Run(tc.name, func(t *testing.T) { + // A timeout must not mask independently fatal native or transport evidence. + queries, writes := 0, 0 + query := &sgTransport{limit: MaxQueryBytes} + query.command = func(header *sgHeader) (bool, error) { + queries++ + header.HostStatus = 3 + tc.change(header) + return false, tc.err + } + query.serializedTransport = newSerializedTransport(query.executeCommand, func() error { return nil }) + transport := capacityTestTransport(func(Command) (Response, error) { writes++; return Response{Completed: true}, nil }, + func() (*serializedTransport, error) { return query.serializedTransport, nil }, func() error { return nil }) + sample, issued, err := (&Capacity{transport: transport}).Observe(context.Background(), nil) + require.True(t, issued) + require.Nil(t, sample) + require.ErrorIs(t, err, ErrUncertain) + if tc.err != nil { + require.ErrorIs(t, err, syscall.EIO) + } + + // Shared admission remains closed after the failed sample; no native command is replayed. + _, err = transport.Execute(context.Background(), capacityTestWrite()) + require.ErrorIs(t, err, ErrUncertain) + _, issued, err = (&Capacity{transport: transport}).Observe(context.Background(), nil) + require.False(t, issued) + require.ErrorIs(t, err, ErrUncertain) + require.Equal(t, 1, queries) + require.Zero(t, writes) + require.ErrorIs(t, transport.Close(), ErrUncertain) + }) + } +} + +func TestSettledTimeoutRemainsFatalOutsideExactCapacityQuery(t *testing.T) { + for _, tc := range []struct { + name string + command Command + }{ + {"WRITE", capacityTestWrite()}, + {"other LOG SENSE page", func() Command { c := capacityCommand(); c.CDB[2] = 0x71; return c }()}, + {"other LOG SENSE subpage", func() Command { c := capacityCommand(); c.CDB[3] = 1; return c }()}, + {"different transfer size", func() Command { c := capacityCommand(); c.Data = make([]byte, 16); return c }()}, + } { + t.Run(tc.name, func(t *testing.T) { + // Optional timeout policy never weakens WRITE or another command's completion contract. + calls := 0 + transport := &sgTransport{limit: MaxQueryBytes} + transport.command = func(header *sgHeader) (bool, error) { + calls++ + header.HostStatus = 3 + return false, nil + } + transport.serializedTransport = newSerializedTransport(transport.executeCommand, func() error { return nil }) + response, err := transport.Execute(context.Background(), tc.command) + require.True(t, response.Completed) + require.ErrorIs(t, err, ErrUncertain) + + // The generic lane keeps the failure and refuses a subsequent command before submission. + _, err = transport.Execute(context.Background(), capacityTestWrite()) + require.ErrorIs(t, err, ErrUncertain) + require.Equal(t, 1, calls) + require.ErrorIs(t, transport.Close(), ErrUncertain) + }) + } +} diff --git a/internal/media/tape/capacity_partition_test.go b/internal/media/tape/capacity_partition_test.go new file mode 100644 index 0000000..3fabb18 --- /dev/null +++ b/internal/media/tape/capacity_partition_test.go @@ -0,0 +1,176 @@ +package tape + +import ( + "context" + "encoding/binary" + "testing" + + "github.com/samuelncui/yatm/entity" +) + +func TestCapacityUsesCurrentModePartitionWithoutLogicalSeek(t *testing.T) { + for _, unsupported := range []bool{false, true} { + // Current Device Configuration page10 supplies partition independently of RP service support/exactness. + queries, modes := 0, 0 + transport := newSerializedTransport(func(command Command) (Response, error) { + if command.CDB[0] == 0x5a && command.CDB[2] != 0x7f { + modes++ + if command.CDB[2] != 0x3f { + t.Fatal("partition inferred from default/changeable mode values") + } + } + if command.CDB[0] == 0x34 && unsupported { + return Response{Completed: true, Status: 2, Sense: fixedSense(0x70, 5, 0x24, 0, false, 0, 0)}, nil + } + response, err := inspectionFixture(command) + if command.CDB[0] == 0x34 { + command.Data[0] |= 4 + binary.BigEndian.PutUint32(command.Data[4:8], 99) // LONU makes this reported address undefined. + } + if command.CDB[0] == 0x4d { + queries++ + } + return response, err + }, func() error { return nil }) + defer transport.Close() + report, err := inspectTransport(context.Background(), Identity{}, transport) + limits := feature(report, entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_CAPACITY_TO_WARNING).GetCapacity() + if err != nil || report.Reason != 0 || queries != 1 || modes != 1 || limits == nil || limits.Partition == nil || limits.GetPartition() != 0 || limits.GetRemainingBytes() != 100_000_000 { + t.Fatalf("unsupported=%t report=%v err=%v queries=%d modes=%d", unsupported, report, err, queries, modes) + } + } +} + +func TestCapacityPartitionMissingOrContradictoryNeverBecomesAValue(t *testing.T) { + for _, contradiction := range []bool{false, true} { + // No active-partition source means unknown; disagreement between two exact sources cannot select one. + queries := 0 + transport := newSerializedTransport(func(command Command) (Response, error) { + response, err := inspectionFixture(command) + if command.CDB[0] == 0x5a { + if contradiction { + command.Data[43] = 1 + } else { + binary.BigEndian.PutUint16(command.Data[:2], 38) // The valid envelope omits page10 entirely. + } + } + if command.CDB[0] == 0x34 && !contradiction { + command.Data[0] |= 4 + } + if command.CDB[0] == 0x4d { + queries++ + } + return response, err + }, func() error { return nil }) + defer transport.Close() + report, err := inspectTransport(context.Background(), Identity{}, transport) + f := feature(report, entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_CAPACITY_TO_WARNING) + want := entity.DeviceReason_DEVICE_REASON_NOT_READY + if contradiction { + want = entity.DeviceReason_DEVICE_REASON_MALFORMED_RESPONSE + } + if err != nil || report.Reason != 0 || queries != 0 || f.GetCapacity() != nil || f.Reason != want { + t.Fatalf("contradiction=%t feature=%v overall=%v err=%v queries=%d", contradiction, f, report.Reason, err, queries) + } + } +} + +func TestCapacityModePartitionLosesApplicabilityAfterReadinessLoss(t *testing.T) { + for _, noMedium := range []bool{false, true} { + // Later passive readiness rejection invalidates the previously successful current mode association. + queries := 0 + transport := newSerializedTransport(func(command Command) (Response, error) { + if command.CDB[0] == 0xa2 { + asc := byte(4) + if noMedium { + asc = 0x3a + } + return Response{Completed: true, Status: 2, Sense: fixedSense(0x70, 2, asc, 0, false, 0, 0)}, nil + } + if command.CDB[0] == 0x4d { + queries++ + } + return inspectionFixture(command) + }, func() error { return nil }) + defer transport.Close() + report, err := inspectTransport(context.Background(), Identity{}, transport) + f := feature(report, entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_CAPACITY_TO_WARNING) + want := entity.DeviceReason_DEVICE_REASON_NOT_READY + if noMedium { + want = entity.DeviceReason_DEVICE_REASON_NO_MEDIUM + } + if err != nil || report.Reason != want || queries != 0 || f.GetCapacity() != nil || f.Reason != want || report.GetTape().GetMediumPresent() == noMedium { + t.Fatalf("no-medium=%t feature=%v overall=%v err=%v queries=%d", noMedium, f, report.Reason, err, queries) + } + } +} + +func TestCapacityCrossChecksSelectedPositionForm(t *testing.T) { + for _, tc := range []struct { + name string + mode, initial, latest bool + partition byte + queries int + reason entity.DeviceReason + }{ + {"mode and initial conflict with latest", true, true, true, 1, 0, entity.DeviceReason_DEVICE_REASON_MALFORMED_RESPONSE}, + {"position forms conflict without mode", false, true, true, 1, 0, entity.DeviceReason_DEVICE_REASON_MALFORMED_RESPONSE}, + {"latest exact position supplies missing partition", false, false, true, 7, 1, 0}, + {"unknown latest partition is ignored", true, true, false, 99, 1, 0}, + } { + t.Run(tc.name, func(t *testing.T) { + // RSOC selects short position after the initial long observation, without issuing any motion. + queries, longQueries, shortQueries := 0, 0, 0 + transport := newSerializedTransport(func(command Command) (Response, error) { + if command.CDB[0] == 0xa3 && command.CDB[3] == 0x34 && command.CDB[5] == 6 { + command.Data[1] = 1 + return Response{Completed: true, Residual: int32(len(command.Data) - 2)}, nil + } + response, err := inspectionFixture(command) + if command.CDB[0] == 0x5a && !tc.mode { + binary.BigEndian.PutUint16(command.Data[:2], 38) + } + if command.CDB[0] == 0x34 { + if command.CDB[1] == 6 { + longQueries++ + if !tc.initial { + command.Data[0] |= 4 + } + } else { + shortQueries++ + command.Data[1] = tc.partition + if !tc.latest { + command.Data[0] |= 4 + } + } + } + if command.CDB[0] == 0x4d { + queries++ + } + return response, err + }, func() error { return nil }) + defer transport.Close() + + // Every exact source must agree; an unknown latest address cannot invent or invalidate a partition. + report, err := inspectTransport(context.Background(), Identity{}, transport) + f := feature(report, entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_CAPACITY_TO_WARNING) + if err != nil || report.Reason != 0 || queries != tc.queries || longQueries != 1 || shortQueries != 1 || f.Reason != tc.reason { + t.Fatalf("feature=%v overall=%v err=%v capacity=%d long=%d short=%d", f, report.Reason, err, queries, longQueries, shortQueries) + } + if tc.queries == 0 { + if f.GetCapacity() != nil { + t.Fatalf("conflicting partitions produced capacity: %v", f) + } + return + } + partition := uint32(0) + if tc.latest { + partition = uint32(tc.partition) + } + limits := f.GetCapacity() + if limits == nil || limits.Partition == nil || limits.GetPartition() != partition || limits.GetRemainingBytes() != 100_000_000 { + t.Fatalf("exact partition association missing: %v", f) + } + }) + } +} diff --git a/internal/media/tape/capacity_test.go b/internal/media/tape/capacity_test.go new file mode 100644 index 0000000..7a8d8d7 --- /dev/null +++ b/internal/media/tape/capacity_test.go @@ -0,0 +1,473 @@ +package tape + +import ( + "context" + "encoding/binary" + "errors" + "io" + "math" + "sync/atomic" + "testing" + "time" + + "github.com/samuelncui/yatm/entity" +) + +func capacityPage(values ...uint32) []byte { + data := make([]byte, 4+8*len(values)) + data[0] = 0x0c + binary.BigEndian.PutUint16(data[2:4], uint16(len(data)-4)) + for i, value := range values { + offset := 4 + i*8 + binary.BigEndian.PutUint16(data[offset:offset+2], uint16(i+4)) + data[offset+2], data[offset+3] = 0x60, 4 + binary.BigEndian.PutUint32(data[offset+4:offset+8], value) + } + return data +} + +func TestCapacityUsesRecordedEODDecimalMBAndPreservesUnknown(t *testing.T) { + for _, tc := range []struct { + name string + values []uint32 + complete bool + known bool + bytes uint64 + }{ + {"recorded EOD not head or reserve", []uint32{100, 200, 1000, 199, 50}, true, true, 100_000_000}, + {"beyond EW", []uint32{201, 200}, true, true, 0}, + {"zero distance", []uint32{0, 0}, true, true, 0}, + {"unknown EOD", []uint32{math.MaxUint32, 200}, true, false, 0}, + {"unknown EW", []uint32{100, math.MaxUint32}, true, false, 0}, + {"missing field", []uint32{100}, false, false, 0}, + {"missing both", nil, false, false, 0}, + {"wide multiplication", []uint32{0, math.MaxUint32 - 1}, true, true, uint64(math.MaxUint32-1) * 1_000_000}, + } { + t.Run(tc.name, func(t *testing.T) { + // Optional unknown fields never become zero, a head-distance sample or a lower bound. + remaining, complete, err := decodeCapacity(capacityPage(tc.values...)) + if err != nil || complete != tc.complete || (remaining != nil) != tc.known || remaining != nil && *remaining != tc.bytes { + t.Fatalf("remaining=%v complete=%t err=%v", remaining, complete, err) + } + }) + } +} + +func TestCapacityRefusesMalformedOrInapplicableLayouts(t *testing.T) { + for _, change := range []func([]byte) []byte{ + func(data []byte) []byte { return data[:3] }, + func(data []byte) []byte { data[0] = 0x31; return data }, + func(data []byte) []byte { data[0] |= 0x40; return data }, + func(data []byte) []byte { data[1] = 1; return data }, + func(data []byte) []byte { return data[:len(data)-1] }, + func(data []byte) []byte { data[7] = 8; return data }, + func(data []byte) []byte { data[6] = 1; return data }, + func(data []byte) []byte { data[13] = 4; return data }, + } { + // Page/subpage, envelopes, exact field length/format and uniqueness are checked before arithmetic. + if _, _, err := decodeCapacity(change(capacityPage(100, 200))); !errors.Is(err, ErrMalformed) { + t.Fatalf("inapplicable capacity layout accepted: %v", err) + } + } +} + +func TestCapacityAcceptsStandardBinaryAndDocumentedCounterFields(t *testing.T) { + for _, format := range []byte{0, 3} { + // SSC binary-list and documented HP counter envelopes carry the same four-byte native fields. + data := capacityPage(100, 200) + data[6], data[14] = 0x60|format, 0x60|format + remaining, complete, err := decodeCapacity(data) + if err != nil || !complete || remaining == nil || *remaining != 100_000_000 { + t.Fatalf("format=%d remaining=%v complete=%t err=%v", format, remaining, complete, err) + } + } +} + +func TestCapacityInspectionUsesExactPositionWhenModePartitionAbsent(t *testing.T) { + for _, known := range []bool{false, true} { + // The current-partition page carries no partition ID; the preceding passive exact observation owns it. + queries := 0 + transport := newSerializedTransport(func(command Command) (Response, error) { + response, err := inspectionFixture(command) + if command.CDB[0] == 0x5a { + binary.BigEndian.PutUint16(command.Data[:2], 38) // Omit page10 from the current-mode envelope. + } + if command.CDB[0] == 0x34 { + binary.BigEndian.PutUint32(command.Data[4:8], 17) + if !known { + command.Data[0] |= 4 + } + } + if command.CDB[0] == 0x4d { + queries++ + } + return response, err + }, func() error { return nil }) + defer transport.Close() + report, err := inspectTransport(context.Background(), Identity{}, transport) + if err != nil { + t.Fatal(err) + } + limits := feature(report, entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_CAPACITY_TO_WARNING).GetCapacity() + if known && (queries != 1 || limits.GetPartition() != 17) || !known && (queries != 0 || limits != nil) { + t.Fatalf("known=%t queries=%d capacity=%v", known, queries, limits) + } + } +} + +func TestOptionalCapacityRejectsMutationAndClosedOrCanceledAdmission(t *testing.T) { + // Optional priority applies only to the bounded passive form, never a data/control command. + calls := 0 + transport := newSerializedTransport(func(Command) (Response, error) { calls++; return Response{Completed: true}, nil }, func() error { return nil }) + command := capacityCommand() + for _, change := range []struct { + index int + value byte + }{{0, 0x4c}, {1, 1}, {2, 0x0c}, {2, 0x71}, {3, 1}, {5, 1}, {9, 1}} { + altered := command + altered.CDB = append([]byte(nil), command.CDB...) + altered.CDB[change.index] = change.value + if _, issued, err := transport.TryExecute(context.Background(), altered, nil); issued || err == nil || calls != 0 { + t.Fatalf("unsafe optional command=%x issued=%t err=%v", altered.CDB, issued, err) + } + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, issued, err := transport.TryExecute(ctx, command, func() error { t.Error("canceled callback ran"); return nil }); issued || !errors.Is(err, context.Canceled) || calls != 0 { + t.Fatalf("canceled issued=%t err=%v", issued, err) + } + if err := transport.Close(); err != nil { + t.Fatal(err) + } + if _, issued, err := transport.TryExecute(context.Background(), command, func() error { t.Error("closed callback ran"); return nil }); issued || !errors.Is(err, io.ErrClosedPipe) || calls != 0 { + t.Fatalf("closed issued=%t err=%v", issued, err) + } +} + +func TestOptionalCapacityPollDoesNotQueueAheadOfData(t *testing.T) { + // Keep the existing owner busy while an actual WRITE waits; optional polling must return immediately. + entered, release, written := make(chan struct{}), make(chan struct{}), make(chan error, 1) + var submitted atomic.Bool + var polls atomic.Int32 + transport := newSerializedTransport(func(command Command) (Response, error) { + switch command.CDB[0] { + case 0: + close(entered) + <-release + case 0x4d: + polls.Add(1) + case 0x0a: + if !submitted.Load() { + t.Error("WRITE accounting did not precede actual issue") + } + } + return Response{Completed: true}, nil + }, func() error { return nil }) + defer transport.Close() + active := make(chan error, 1) + go func() { + _, err := transport.Execute(context.Background(), Command{CDB: make([]byte, 6), Direction: NoData, Timeout: time.Second}) + active <- err + }() + <-entered + go func() { + records := Records{Transport: transport, Mode: RecordMode{MaximumBytes: 512}, Timeout: time.Second} + _, err := records.WriteObserved(context.Background(), make([]byte, 512), func() error { submitted.Store(true); return nil }) + written <- err + }() + + // Busy polls have neither a submission callback nor a queued command; WRITE progresses after release. + capacity := &Capacity{transport: transport} + for i := 0; i < 100; i++ { + _, issued, err := capacity.Observe(context.Background(), func() error { t.Error("busy poll callback ran"); return nil }) + if issued || err != nil { + t.Fatalf("busy issued=%t err=%v", issued, err) + } + } + if submitted.Load() { + t.Fatal("queued WRITE counted as issued") + } + close(release) + if err := <-active; err != nil { + t.Fatal(err) + } + select { + case err := <-written: + if err != nil || polls.Load() != 0 { + t.Fatalf("write=%v polls=%d", err, polls.Load()) + } + case <-time.After(time.Second): + t.Fatal("unissued poll blocked data progress") + } +} + +func TestIssuedCapacityPollSettlesBeforeWriteAndClose(t *testing.T) { + // An optional query that actually issued owns the same lane until it settles, even after cancellation. + entered, release := make(chan struct{}), make(chan struct{}) + var callback, writes, closes atomic.Int32 + transport := newSerializedTransport(func(command Command) (Response, error) { + if command.CDB[0] == 0x4d { + if callback.Load() != 1 { + t.Error("capacity query issued before its accounting callback") + } + close(entered) + <-release + return inspectionFixture(command) + } + writes.Add(1) + return Response{Completed: true}, nil + }, func() error { closes.Add(1); return nil }) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + observed := make(chan error, 1) + capacity := &Capacity{transport: transport, partition: 2} + go func() { + _, issued, err := capacity.Observe(ctx, func() error { callback.Add(1); return nil }) + if !issued { + t.Error("active query marked unissued") + } + observed <- err + }() + <-entered + cancel() + writeCtx, stopWrite := context.WithCancel(context.Background()) + defer stopWrite() + written := make(chan error, 1) + go func() { + _, err := (Records{Transport: transport, Mode: RecordMode{MaximumBytes: 512}, Timeout: time.Second}).Write(writeCtx, make([]byte, 512)) + written <- err + }() + closed := make(chan error, 1) + go func() { closed <- transport.Close() }() + + // Cancellation cannot close the descriptor or allow a concurrent WRITE beside the active poll. + select { + case <-observed: + t.Fatal("query returned before settlement") + case <-time.After(20 * time.Millisecond): + } + if writes.Load() != 0 || closes.Load() != 0 { + t.Fatal("active poll overlapped write/close") + } + stopWrite() + close(release) + if err := <-observed; !errors.Is(err, context.Canceled) { + t.Fatalf("settled cancellation=%v", err) + } + if err := <-written; !errors.Is(err, context.Canceled) { + t.Fatalf("queued write=%v", err) + } + if err := <-closed; err != nil || closes.Load() != 1 { + t.Fatalf("close=%v count=%d", err, closes.Load()) + } +} + +func TestIssuedCapacitySamplePrecedesActualQueuedWrite(t *testing.T) { + // Once issued, the observation's interval ends before the next WRITE accounting/command starts. + entered, release := make(chan struct{}), make(chan struct{}) + var phase atomic.Int32 + transport := newSerializedTransport(func(command Command) (Response, error) { + if command.CDB[0] == 0x4d { + if phase.Load() != 1 { + t.Error("query did not begin at its issue callback") + } + close(entered) + <-release + phase.Store(2) + return inspectionFixture(command) + } + if phase.Load() != 3 { + t.Error("WRITE interleaved with the issued query or missed its callback") + } + return Response{Completed: true}, nil + }, func() error { return nil }) + defer transport.Close() + observed := make(chan *CapacitySample, 1) + go func() { + sample, issued, err := (&Capacity{transport: transport, partition: 7}).Observe(context.Background(), func() error { phase.Store(1); return nil }) + if !issued || err != nil { + t.Errorf("issued=%t err=%v", issued, err) + } + observed <- sample + }() + <-entered + written := make(chan error, 1) + go func() { + _, err := (Records{Transport: transport, Mode: RecordMode{MaximumBytes: 512}, Timeout: time.Second}).WriteObserved(context.Background(), make([]byte, 512), func() error { + if phase.Load() != 2 { + t.Error("queued WRITE was counted before query settlement") + } + phase.Store(3) + return nil + }) + written <- err + }() + + // Releasing the native command produces an estimate and allows the queued record to progress. + close(release) + sample := <-observed + if sample == nil || sample.Partition != 7 || sample.Quality != CapacityEstimate || sample.RemainingBytes == nil || + *sample.RemainingBytes != 100_000_000 || sample.ErrorBytes != nil || sample.UnreflectedBytes != nil || sample.CompletedAt.Before(sample.IssuedAt) { + t.Fatalf("sample=%+v", sample) + } + if err := <-written; err != nil { + t.Fatal(err) + } +} + +func TestCapacityInspectionUncertaintyRemainsAttemptWide(t *testing.T) { + // Optional semantic absence is local, but an actual unknown command/reset cannot continue preparation. + closes := 0 + transport := newSerializedTransport(func(command Command) (Response, error) { + if command.CDB[0] == 0x4d { + return Response{Completed: true, HostStatus: 8}, nil // Linux DID_RESET. + } + return inspectionFixture(command) + }, func() error { closes++; return nil }) + device, err := NewDevice(context.Background(), Identity{}, transport) + if device != nil || !errors.Is(err, ErrUncertain) || closes != 1 { + t.Fatalf("device=%v err=%v closes=%d", device, err, closes) + } +} + +func TestCapacityAndWriteIssueCallbackAbortBeforeNativeCommand(t *testing.T) { + // Accounting rejection is actual pre-command evidence, unlike a queued operation or a failed ioctl. + calls := 0 + transport := newSerializedTransport(func(Command) (Response, error) { calls++; return Response{Completed: true}, nil }, func() error { return nil }) + defer transport.Close() + cause := errors.New("admission stopped") + capacity := &Capacity{transport: transport} + if sample, issued, err := capacity.Observe(context.Background(), func() error { return cause }); sample != nil || issued || !errors.Is(err, cause) || calls != 0 { + t.Fatalf("sample=%v issued=%t err=%v calls=%d", sample, issued, err, calls) + } + records := Records{Transport: transport, Mode: RecordMode{MaximumBytes: 512}, Timeout: time.Second} + result, err := records.WriteObserved(context.Background(), make([]byte, 512), func() error { return cause }) + if !result.AcceptedKnown || result.Bytes != 0 || !errors.Is(err, cause) || calls != 0 { + t.Fatalf("result=%+v err=%v calls=%d", result, err, calls) + } + if _, err := records.Write(context.Background(), make([]byte, 512)); err != nil || calls != 1 { + t.Fatalf("later WRITE=%v calls=%d", err, calls) + } +} + +func TestCapacityUnknownPhysicalOutcomePoisonsFollowingCommands(t *testing.T) { + // A sample error cannot erase native uncertainty or authorize another command/cleanup release. + calls := 0 + transport := newSerializedTransport(func(Command) (Response, error) { calls++; return Response{Completed: true, HostStatus: 8}, nil }, func() error { return nil }) + capacity := &Capacity{transport: transport} + if _, issued, err := capacity.Observe(context.Background(), nil); !issued || !errors.Is(err, ErrUncertain) { + t.Fatalf("issued=%t err=%v", issued, err) + } + if _, issued, err := capacity.Observe(context.Background(), nil); issued || !errors.Is(err, ErrUncertain) || calls != 1 { + t.Fatalf("later issued=%t err=%v calls=%d", issued, err, calls) + } + if err := transport.Close(); !errors.Is(err, ErrUncertain) { + t.Fatalf("cleanup dropped uncertainty: %v", err) + } +} + +func TestCapacityInspectionFailuresRemainFeatureLocal(t *testing.T) { + for _, malformed := range []bool{false, true} { + // Unknown optional capacity cannot revoke the independently observed baseline operation facts. + transport := newSerializedTransport(func(command Command) (Response, error) { + if command.CDB[0] == 0x4d { + if malformed { + return Response{Completed: true, Residual: int32(len(command.Data) - 1)}, nil + } + return Response{Completed: true, Status: 2, Sense: fixedSense(0x70, 5, 0x20, 0, false, 0, 0)}, nil + } + return inspectionFixture(command) + }, func() error { return nil }) + defer transport.Close() + report, err := inspectTransport(context.Background(), Identity{}, transport) + if err != nil || report.Reason != 0 { + t.Fatalf("optional capacity invalidated baseline: %v %v", report, err) + } + f := feature(report, entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_CAPACITY_TO_WARNING) + if f.Reason == 0 || f.GetCapacity() != nil || f.Evidence != entity.DeviceFeatureEvidence_DEVICE_FEATURE_EVIDENCE_UNSPECIFIED { + t.Fatalf("failed fresh sample invented evidence: %v", f) + } + } +} + +func TestCapacityInspectionReadinessLossOverridesEarlierReadyObservation(t *testing.T) { + for _, noMedium := range []bool{false, true} { + // A clean capacity rejection can reveal actual medium loss/not-ready after TUR initially succeeded. + transport := newSerializedTransport(func(command Command) (Response, error) { + if command.CDB[0] == 0x4d { + asc := byte(4) + if noMedium { + asc = 0x3a + } + return Response{Completed: true, Status: 2, Sense: fixedSense(0x70, 2, asc, 0, false, 0, 0)}, nil + } + return inspectionFixture(command) + }, func() error { return nil }) + defer transport.Close() + report, err := inspectTransport(context.Background(), Identity{}, transport) + want := entity.DeviceReason_DEVICE_REASON_NOT_READY + if noMedium { + want = entity.DeviceReason_DEVICE_REASON_NO_MEDIUM + } + if err != nil || report.Reason != want || report.GetTape().GetMediumPresent() == noMedium { + t.Fatalf("readiness loss no-medium=%t report=%v err=%v", noMedium, report, err) + } + for _, kind := range []entity.DeviceFeatureKind{entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_SEQUENTIAL_READ, entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_SEQUENTIAL_WRITE} { + f := feature(report, kind) + if f.Availability != entity.DeviceFeatureAvailability_DEVICE_FEATURE_AVAILABILITY_UNAVAILABLE || f.Reason != want { + t.Fatalf("baseline remained available after readiness loss: %v", f) + } + } + } +} + +func TestCapacityCounterSupportDoesNotDisappearWithUnknownValue(t *testing.T) { + for _, complete := range []bool{false, true} { + // Observed standardized counters remain supported when their present value is a valid unknown sentinel. + device := &Device{report: newReport(Identity{})} + sample := &CapacitySample{Partition: 7, IssuedAt: time.Now(), CompletedAt: time.Now(), Quality: CapacityUnknown} + support, quality := entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_PARTIAL, entity.TapeCapacityQuality_TAPE_CAPACITY_QUALITY_UNSPECIFIED + if complete { + sample.Quality = CapacityEstimate + support, quality = entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_SUPPORTED, entity.TapeCapacityQuality_TAPE_CAPACITY_QUALITY_ESTIMATE + } + device.reportCapacity(sample, true, nil) + f := feature(device.Report(), entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_CAPACITY_TO_WARNING) + if f.Support != support || f.Availability != entity.DeviceFeatureAvailability_DEVICE_FEATURE_AVAILABILITY_UNKNOWN || + f.GetCapacity().RemainingBytes != nil || f.GetCapacity().Quality != quality || f.GetCapacity().GetPartition() != 7 { + t.Fatalf("known counter support confused with unknown value: %v", f) + } + + // A failed fresh query clears this observation; no stale field or evidence becomes a new sample. + device.reportCapacity(nil, true, ErrMalformed) + f = feature(device.Report(), entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_CAPACITY_TO_WARNING) + if f.GetCapacity() != nil || f.Evidence != entity.DeviceFeatureEvidence_DEVICE_FEATURE_EVIDENCE_UNSPECIFIED { + t.Fatalf("failed query retained fresh evidence: %v", f) + } + } +} + +func TestCapacityPreparationKeepsEstimateAllowancesAndQualificationUnknown(t *testing.T) { + // A configured single partition permits one estimate without enabling continuous polling. + d := fixtureDevice(t, newDeviceFixture()) + if _, err := d.Configure(context.Background(), &entity.TarMediaProfile{RecordMode: entity.TapeRecordMode_TAPE_RECORD_MODE_FIXED, RecordBytes: 512}, Read); err != nil { + t.Fatal(err) + } + capacity, err := d.Capacity(context.Background()) + if err != nil || capacity == nil || capacity.Initial == nil { + t.Fatalf("capacity=%v err=%v", capacity, err) + } + sample := capacity.Initial + if sample.Partition != 0 || sample.Quality != CapacityEstimate || sample.RemainingBytes == nil || *sample.RemainingBytes != 100_000_000 || + sample.ErrorBytes != nil || sample.UnreflectedBytes != nil || sample.IssuedAt.IsZero() || sample.CompletedAt.Before(sample.IssuedAt) { + t.Fatalf("estimate=%+v", sample) + } + f := feature(d.Report(), entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_CAPACITY_TO_WARNING) + if f.Validation != entity.DeviceFeatureValidation_DEVICE_FEATURE_VALIDATION_UNVALIDATED || + f.GetCapacity().ObservationErrorBytes != nil || f.GetCapacity().UnreflectedBytes != nil { + t.Fatalf("sample self-qualified: %v", f) + } + if err := d.Cleanup(context.Background()); err != nil { + t.Fatal(err) + } +} diff --git a/internal/media/tape/capacity_transport.go b/internal/media/tape/capacity_transport.go new file mode 100644 index 0000000..e07707a --- /dev/null +++ b/internal/media/tape/capacity_transport.go @@ -0,0 +1,212 @@ +package tape + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "sync" + "syscall" + "time" + + "github.com/samuelncui/yatm/entity" +) + +// capacityTransport owns one lazy query attachment and a brief attempt-wide terminal gate. +// Primary and query lanes each bound one pregranted lifetime; the mutex never spans native I/O. +// The media session owns stopping its observer before completion/controls; Close joins raw lifetimes. +type capacityTransport struct { + mu sync.Mutex + closed bool + failure error + lane chan struct{} + open func() (*serializedTransport, error) + query *serializedTransport + withdrawn bool + closeOnce sync.Once + closeErr error +} + +func newCapacityTransport(primary *serializedTransport, open func() (*serializedTransport, error)) *capacityTransport { + lane := make(chan struct{}, 1) + lane <- struct{}{} + t := &capacityTransport{lane: lane, open: open} + primary.grant = t.grant + primary.finish = t.finishPrimary + return t +} + +func exactCapacityCommand(command Command) bool { + return command.Direction == FromDevice && len(command.Data) == MaxQueryBytes && + command.Timeout >= time.Millisecond && command.Timeout <= capacityObservationTimeout && + bytes.Equal(command.CDB, []byte{0x4d, 0, 0x4c, 0, 0, 0, 0, 0x10, 0, 0}) +} + +func (t *capacityTransport) grant() error { + t.mu.Lock() + defer t.mu.Unlock() + if t.failure != nil { + return t.failure + } + if t.closed { + return io.ErrClosedPipe + } + return nil +} + +func (t *capacityTransport) fail(err error) error { + t.mu.Lock() + defer t.mu.Unlock() + if t.failure == nil { + t.failure = err + } else { + // Each lane has at most one pregranted lifetime; retain a later uncertainty/cleanup cause too. + t.failure = errors.Join(t.failure, err) + } + return t.failure +} + +func (t *capacityTransport) finishPrimary(command Command, response Response, err error) error { + // Preparation uses the primary lane, but an optional settled query failure cannot poison WRITE. + if exactCapacityCommand(command) && (onlyCapacityQueryError(err) || CleanUnsupported(err)) { + return err + } + + // Existing known control rejections still permit restoration; loss of the medium does not. + if err == nil && response.Completed { + failure := CheckResponse(response) + reason := Reason(failure) + if reason == entity.DeviceReason_DEVICE_REASON_NO_MEDIUM || reason == entity.DeviceReason_DEVICE_REASON_NOT_READY { + // Explicit control flows can validate an expected settled readiness response. + if command.validate != nil { + failure = command.validate(response) + } + err = failure + } + } + if err != nil { + t.fail(err) + } + return err +} + +func (t *capacityTransport) finishQuery(command Command, response Response, err error) error { + // Settled query-local failures retain the attachment and writer for subsequent attempts. + if err == nil { + err = checkCommandResponse(command, response) + } + if onlyCapacityQueryError(err) { + return err + } + + // Native uncertainty closes shared admission; malformed counter data is only an unavailable observation. + if err == nil { + if response.Residual < 0 || int(response.Residual) > len(command.Data) { + err = &UncertainError{Cause: ErrMalformed} + } else { + _, _, err = decodeCapacity(command.Data[:len(command.Data)-int(response.Residual)]) + if err == ErrMalformed { + return err + } + } + } + if CleanUnsupported(err) { + t.withdrawn = true + } else if err != nil { + t.fail(err) + } + return err +} + +func (t *capacityTransport) tryExecute(ctx context.Context, command Command, onIssue func() error) (Response, bool, error) { + // Attachment belongs to the same nonqueued lifetime as the query; Close cannot overtake it. + if !exactCapacityCommand(command) { + return Response{}, false, fmt.Errorf("command is outside the private capacity allowlist") + } + select { + case <-ctx.Done(): + return Response{}, false, ctx.Err() + case <-t.lane: + default: + return Response{}, false, nil + } + defer func() { t.lane <- struct{}{} }() + if err := ctx.Err(); err != nil { + return Response{}, false, err + } + if err := t.grant(); err != nil { + return Response{}, false, err + } + if t.withdrawn { + return Response{}, false, ErrUnsupported + } + if t.query == nil { + query, err := t.open() + if err != nil { + if capacityAttachmentResourceMiss(err) { + return Response{}, false, err + } + t.withdrawn = true + if cleanCapacityAttachmentAbsence(err) { + return Response{}, false, fmt.Errorf("capacity attachment unavailable: %w", ErrUnsupported) + } + return Response{}, false, t.fail(err) + } + if query == nil { + return Response{}, false, t.fail(fmt.Errorf("capacity attachment is absent: %w", ErrMalformed)) + } + query.grant, query.finish = t.grant, t.finishQuery + t.query = query + } + + // This second grant is immediately before the callback; no gate spans open or native execution. + return t.query.TryExecute(ctx, command, onIssue) +} + +func capacityAttachmentResourceMiss(err error) bool { + // Only a clean local allocation failure permits another attachment attempt; cleanup is terminal. + if errors.Is(err, ErrUncertain) { + return false + } + if _, joined := err.(interface{ Unwrap() []error }); joined { + return false + } + if wrapped, ok := err.(interface{ Unwrap() error }); ok { + return capacityAttachmentResourceMiss(wrapped.Unwrap()) + } + return err == syscall.EMFILE || err == syscall.ENFILE || err == syscall.ENOMEM +} + +func cleanCapacityAttachmentAbsence(err error) bool { + // A joined cleanup cause is never optional, even if another cause was a permission rejection. + if _, joined := err.(interface{ Unwrap() []error }); joined { + return false + } + if wrapped, ok := err.(interface{ Unwrap() error }); ok { + return cleanCapacityAttachmentAbsence(wrapped.Unwrap()) + } + return err == ErrUnsupported || err == syscall.EACCES || err == syscall.EPERM +} + +func (t *capacityTransport) Close() error { + // Permanently close grants first, then join lazy attachment/native execution before closing its fd. + t.closeOnce.Do(func() { + t.mu.Lock() + t.closed = true + t.mu.Unlock() + <-t.lane + defer func() { t.lane <- struct{}{} }() + if t.query != nil { + if err := t.query.Close(); err != nil { + t.closeErr = &UncertainError{Cause: err} + } + } + t.mu.Lock() + if errors.Is(t.failure, ErrUncertain) { + t.closeErr = errors.Join(t.closeErr, t.failure) + } + t.mu.Unlock() + }) + return t.closeErr +} diff --git a/internal/media/tape/capacity_transport_test.go b/internal/media/tape/capacity_transport_test.go new file mode 100644 index 0000000..0590bea --- /dev/null +++ b/internal/media/tape/capacity_transport_test.go @@ -0,0 +1,547 @@ +package tape + +import ( + "context" + "errors" + "fmt" + "io" + "sync/atomic" + "syscall" + "testing" + "time" + "unsafe" + + "github.com/samuelncui/yatm/entity" + "github.com/stretchr/testify/require" +) + +func capacityTestTransport(execute func(Command) (Response, error), open func() (*serializedTransport, error), close func() error) *sgTransport { + t := &sgTransport{limit: MaxQueryBytes, serializedTransport: newSerializedTransport(execute, close)} + t.capacity = newCapacityTransport(t.serializedTransport, open) + return t +} + +func capacityTestResponse(command Command) (Response, error) { + data := capacityPage(1, 2) + copy(command.Data, data) + return Response{Completed: true, Residual: int32(len(command.Data) - len(data))}, nil +} + +func capacityTestWrite() Command { + return Command{CDB: []byte{0x0a, 0, 0, 0, 1, 0}, Direction: ToDevice, Data: []byte{7}, Timeout: time.Second} +} + +func capacityTestWait(t *testing.T, done <-chan error) error { + t.Helper() + select { + case err := <-done: + return err + case <-time.After(5 * time.Second): + t.Fatal("native lifetime did not finish") + return nil + } +} + +func TestPrivateCapacityNativeQueryDoesNotHoldWriteLane(t *testing.T) { + // Hold the query at the native abstraction, with a distinct header/buffer owner from WRITE. + entered, release := make(chan struct{}), make(chan struct{}) + var queryIssues, writes atomic.Int32 + query := &sgTransport{limit: MaxQueryBytes} + query.command = func(header *sgHeader) (bool, error) { + queryIssues.Add(1) + require.Equal(t, byte(0x4d), *header.Command) + close(entered) + <-release + data := capacityPage(1, 2) + copy(unsafe.Slice(header.Data, int(header.DataLength)), data) + header.Residual = int32(int(header.DataLength) - len(data)) + return false, nil + } + query.serializedTransport = newSerializedTransport(query.executeCommand, func() error { return nil }) + primary := &sgTransport{limit: MaxQueryBytes} + primary.command = func(header *sgHeader) (bool, error) { + require.Equal(t, byte(0x0a), *header.Command) + require.Equal(t, []byte{7}, unsafe.Slice(header.Data, int(header.DataLength))) + writes.Add(1) + return false, nil + } + primary.serializedTransport = newSerializedTransport(primary.executeCommand, func() error { return nil }) + primary.capacity = newCapacityTransport(primary.serializedTransport, func() (*serializedTransport, error) { return query.serializedTransport, nil }) + provider := &Capacity{transport: primary} + finished := make(chan error, 1) + go func() { + sample, issued, err := provider.Observe(context.Background(), nil) + if err == nil && (!issued || sample == nil || *sample.RemainingBytes != 1_000_000) { + err = fmt.Errorf("missing query sample") + } + finished <- err + }() + <-entered + + // The original optional route is still visible, but only its own busy query lane can skip. + _, issued, err := provider.Observe(context.Background(), nil) + require.NoError(t, err) + require.False(t, issued) + writeDone := make(chan error, 1) + go func() { _, err := primary.Execute(context.Background(), capacityTestWrite()); writeDone <- err }() + require.NoError(t, capacityTestWait(t, writeDone)) + require.Equal(t, int32(1), writes.Load()) + select { + case <-finished: + t.Fatal("query settled before explicit native release") + default: + } + close(release) + require.NoError(t, capacityTestWait(t, finished)) + require.NoError(t, primary.Close()) + require.Equal(t, int32(1), queryIssues.Load()) +} + +type capacityTestDeadlineContext struct{ context.Context } + +func (c capacityTestDeadlineContext) Err() error { + if c.Context.Err() != nil { + return context.DeadlineExceeded + } + return nil +} + +func TestPrivateCapacityGoodCompletionAfterDeadlineKeepsWriteAvailable(t *testing.T) { + // A local deadline expires while the synthetic native query still owns its buffers. + entered, release := make(chan struct{}), make(chan struct{}) + base, cancel := context.WithCancel(context.Background()) + ctx := capacityTestDeadlineContext{base} + defer cancel() + writes := 0 + transport := capacityTestTransport(func(Command) (Response, error) { + writes++ + return Response{Completed: true}, nil + }, func() (*serializedTransport, error) { + return newSerializedTransport(func(command Command) (Response, error) { + require.Equal(t, 60*time.Second, command.Timeout, "sample deadline must not shrink native settlement") + close(entered) + <-release + return capacityTestResponse(command) + }, func() error { return nil }), nil + }, func() error { return nil }) + done := make(chan error, 1) + go func() { + sample, issued, err := (&Capacity{transport: transport}).Observe(ctx, nil) + if sample != nil || !issued { + err = errors.Join(err, errors.New("late query must discard its sample after native settlement")) + } + done <- err + }() + select { + case <-entered: + case err := <-done: + t.Fatalf("query returned before controlled deadline, err=%v", err) + case <-time.After(time.Second): + close(release) + t.Fatal("query did not reach synthetic native submission") + } + cancel() + select { + case <-done: + t.Fatal("deadline returned before native settlement") + default: + } + + // GOOD native settlement plus a local deadline is not an unknown native timeout or reset. + close(release) + err := capacityTestWait(t, done) + require.ErrorIs(t, err, context.DeadlineExceeded) + require.NotErrorIs(t, err, ErrUncertain) + _, err = transport.Execute(context.Background(), capacityTestWrite()) + require.NoError(t, err) + require.Equal(t, 1, writes) + require.NoError(t, transport.Close()) +} + +func TestPrivateCapacityFatalAllowsOnlyOnePregrantedWrite(t *testing.T) { + // Freeze the writer after its shared grant; its native issue may follow the query's fatal receipt. + granted, release := make(chan struct{}), make(chan struct{}) + var writes atomic.Int32 + failure := errors.New("deferred query failure") + transport := capacityTestTransport(func(Command) (Response, error) { + writes.Add(1) + return Response{Completed: true}, nil + }, func() (*serializedTransport, error) { + return newSerializedTransport(func(Command) (Response, error) { return Response{Completed: true}, failure }, func() error { return nil }), nil + }, func() error { return nil }) + writeDone := make(chan error, 1) + go func() { + _, err := transport.ExecuteObserved(context.Background(), capacityTestWrite(), func() error { + close(granted) + <-release + return nil + }) + writeDone <- err + }() + <-granted + _, issued, err := transport.TryExecute(context.Background(), capacityCommand(), nil) + require.True(t, issued) + require.ErrorIs(t, err, failure) + close(release) + require.NoError(t, capacityTestWait(t, writeDone), "the pregranted WRITE must retain its actual result") + + // Every later primary/query grant fails before callback/native exposure; settled cleanup can succeed. + callbacks := 0 + _, err = transport.ExecuteObserved(context.Background(), capacityTestWrite(), func() error { callbacks++; return nil }) + require.ErrorIs(t, err, failure) + _, issued, err = transport.TryExecute(context.Background(), capacityCommand(), func() error { callbacks++; return nil }) + require.False(t, issued) + require.ErrorIs(t, err, failure) + require.Zero(t, callbacks) + require.Equal(t, int32(1), writes.Load()) + require.NoError(t, transport.Close()) + _, err = transport.Execute(context.Background(), capacityTestWrite()) + require.Error(t, err) +} + +func TestPrivateCapacityCompletionClassificationBeforeNextGrant(t *testing.T) { + for _, tc := range []struct { + name string + run func(Command) (Response, error) + optional, healthy bool + }{ + {"known unsupported", func(Command) (Response, error) { + return Response{Completed: true, Status: 2, Sense: fixedSense(0x70, 5, 0x24, 0, false, 0, 0)}, nil + }, true, false}, + {"invalid native residual", func(Command) (Response, error) { + return Response{Completed: true, Residual: -1}, nil + }, false, false}, + {"malformed native sense", func(Command) (Response, error) { + return Response{Completed: true, Status: 2, Sense: []byte{0x70}}, nil + }, false, false}, + {"deferred", func(Command) (Response, error) { + return Response{Completed: true, Status: 2, Sense: fixedSense(0x71, 3, 0x11, 0, false, 0, 0)}, nil + }, false, false}, + {"no medium", func(Command) (Response, error) { + return Response{Completed: true, Status: 2, Sense: fixedSense(0x70, 2, 0x3a, 0, false, 0, 0)}, nil + }, false, false}, + {"generic", func(Command) (Response, error) { return Response{Completed: true}, syscall.EIO }, false, false}, + {"joined unsupported fatal", func(Command) (Response, error) { + return Response{Completed: true}, errors.Join(ErrUnsupported, syscall.EIO) + }, false, false}, + {"missing optional fields", func(command Command) (Response, error) { + copy(command.Data, []byte{0x0c, 0, 0, 0}) + return Response{Completed: true, Residual: MaxQueryBytes - 4}, nil + }, false, true}, + {"unknown counter", func(command Command) (Response, error) { + data := capacityPage(^uint32(0), 2) + copy(command.Data, data) + return Response{Completed: true, Residual: int32(len(command.Data) - len(data))}, nil + }, false, true}, + } { + t.Run(tc.name, func(t *testing.T) { + // Decode at transport completion, before Observe's later payload interpretation can run. + writes, queries := 0, 0 + transport := capacityTestTransport(func(Command) (Response, error) { writes++; return Response{Completed: true}, nil }, func() (*serializedTransport, error) { + return newSerializedTransport(func(c Command) (Response, error) { queries++; return tc.run(c) }, func() error { return nil }), nil + }, func() error { return nil }) + _, issued, err := transport.TryExecute(context.Background(), capacityCommand(), nil) + require.True(t, issued) + require.Equal(t, tc.optional, CleanUnsupported(err)) + if tc.healthy { + require.NoError(t, err) + } else { + require.Error(t, err) + } + _, writeErr := transport.Execute(context.Background(), capacityTestWrite()) + if tc.optional || tc.healthy { + require.NoError(t, writeErr) + require.Equal(t, 1, writes) + require.NoError(t, transport.Close()) + } else { + require.Error(t, writeErr) + require.Zero(t, writes) + closeErr := transport.Close() + if errors.Is(err, ErrUncertain) { + require.ErrorIs(t, closeErr, ErrUncertain) + } else { + require.NoError(t, closeErr, "known settled attempt failure is not a cleanup failure") + } + } + require.Equal(t, 1, queries) + }) + } +} + +func TestPrivateCapacityAttachmentAbsenceAndCleanupFailure(t *testing.T) { + for _, tc := range []struct { + name string + err error + optional bool + }{ + {"permission", fmt.Errorf("open: %w", syscall.EACCES), true}, + {"denied", syscall.EPERM, true}, + {"unsupported", fmt.Errorf("attach: %w", ErrUnsupported), true}, + {"missing", syscall.ENOENT, false}, + {"generic", syscall.EIO, false}, + {"cleanup joined", errors.Join(syscall.EACCES, syscall.EIO), false}, + {"cleanup uncertain", &UncertainError{Cause: errors.Join(ErrUnsupported, syscall.EIO)}, false}, + {"resource cleanup joined", errors.Join(syscall.EMFILE, syscall.EIO), false}, + {"resource joined", errors.Join(syscall.EMFILE), false}, + {"resource wrapped cleanup", fmt.Errorf("attach: %w", errors.Join(syscall.ENFILE, syscall.EIO)), false}, + {"resource uncertain", &UncertainError{Cause: syscall.ENOMEM}, false}, + } { + t.Run(tc.name, func(t *testing.T) { + // No attachment failure retries or issues a query; only known clean absence leaves WRITE available. + opens, writes, callbacks := 0, 0, 0 + transport := capacityTestTransport(func(Command) (Response, error) { writes++; return Response{Completed: true}, nil }, func() (*serializedTransport, error) { opens++; return nil, tc.err }, func() error { return nil }) + for i := 0; i < 2; i++ { + _, issued, err := transport.TryExecute(context.Background(), capacityCommand(), func() error { callbacks++; return nil }) + require.False(t, issued) + require.Equal(t, tc.optional, CleanUnsupported(err)) + } + _, err := transport.Execute(context.Background(), capacityTestWrite()) + if tc.optional { + require.NoError(t, err) + require.Equal(t, 1, writes) + } else { + require.ErrorIs(t, err, tc.err) + require.Zero(t, writes) + } + require.Equal(t, 1, opens) + require.Zero(t, callbacks) + if errors.Is(tc.err, ErrUncertain) { + require.ErrorIs(t, transport.Close(), ErrUncertain) + } else { + require.NoError(t, transport.Close()) + } + }) + } +} + +func TestPrivateCapacityAttachmentResourceMissesKeepWriteAndRetry(t *testing.T) { + for _, cause := range []error{syscall.EMFILE, syscall.ENFILE, syscall.ENOMEM} { + for _, wrapped := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/wrapped=%t", cause, wrapped), func(t *testing.T) { + // Inject clean local allocation failures before any query command or issue callback. + failure := cause + if wrapped { + failure = fmt.Errorf("attach: %w", fmt.Errorf("allocate: %w", cause)) + } + opens, writes, queries, callbacks, primaryClosed, queryClosed := 0, 0, 0, 0, 0, 0 + transport := capacityTestTransport(func(Command) (Response, error) { + writes++ + return Response{Completed: true}, nil + }, func() (*serializedTransport, error) { + opens++ + if opens <= 2 { + return nil, failure + } + return newSerializedTransport(func(command Command) (Response, error) { + queries++ + return capacityTestResponse(command) + }, func() error { queryClosed++; return nil }), nil + }, func() error { primaryClosed++; return nil }) + t.Cleanup(func() { _ = transport.Close() }) + provider := &Capacity{transport: transport} + for i := 0; i < 2; i++ { + sample, issued, err := provider.Observe(context.Background(), func() error { callbacks++; return nil }) + require.ErrorIs(t, err, cause) + require.False(t, CleanUnsupported(err)) + require.Nil(t, sample) + require.False(t, issued) + require.Zero(t, callbacks) + require.Zero(t, queries) + _, err = transport.Execute(context.Background(), capacityTestWrite()) + require.NoError(t, err) + } + + // The next ordinary poll attaches successfully without reopening the healthy primary lane. + sample, issued, err := provider.Observe(context.Background(), func() error { callbacks++; return nil }) + require.NoError(t, err) + require.True(t, issued) + require.Equal(t, uint64(1_000_000), *sample.RemainingBytes) + require.Equal(t, 3, opens) + require.Equal(t, 2, writes) + require.Equal(t, 1, queries) + require.Equal(t, 1, callbacks) + require.NoError(t, transport.Close()) + require.NoError(t, transport.Close()) + require.Equal(t, 1, primaryClosed) + require.Equal(t, 1, queryClosed) + }) + } + } +} + +func TestPrivateCapacityCloseJoinsAttachmentAndCanceledNative(t *testing.T) { + for _, attaching := range []bool{false, true} { + t.Run(fmt.Sprintf("attachment=%t", attaching), func(t *testing.T) { + // Parent Close must join open/native even after cancellation; neither native owner closes early. + entered, release := make(chan struct{}), make(chan struct{}) + var queryClosed, primaryClosed atomic.Int32 + ctx, cancel := context.WithCancel(context.Background()) + transport := capacityTestTransport(func(Command) (Response, error) { return Response{Completed: true}, nil }, func() (*serializedTransport, error) { + if attaching { + close(entered) + <-release + } + return newSerializedTransport(func(command Command) (Response, error) { + if !attaching { + close(entered) + <-release + } + return capacityTestResponse(command) + }, func() error { queryClosed.Add(1); return nil }), nil + }, func() error { primaryClosed.Add(1); return nil }) + queryDone := make(chan error, 1) + go func() { _, _, err := transport.TryExecute(ctx, capacityCommand(), nil); queryDone <- err }() + <-entered + cancel() + closeDone := make(chan error, 1) + go func() { closeDone <- transport.Close() }() + select { + case <-closeDone: + t.Fatal("Close overtook active query lifetime") + case <-time.After(20 * time.Millisecond): + } + require.Zero(t, queryClosed.Load()) + require.Zero(t, primaryClosed.Load()) + + // Closing joins once, remembers closed admission, and cancellation does not poison healthy settlement. + close(release) + require.ErrorIs(t, capacityTestWait(t, queryDone), context.Canceled) + require.NoError(t, capacityTestWait(t, closeDone)) + require.NoError(t, transport.Close()) + require.Equal(t, int32(1), queryClosed.Load()) + require.Equal(t, int32(1), primaryClosed.Load()) + _, issued, err := transport.TryExecute(context.Background(), capacityCommand(), nil) + require.False(t, issued) + require.ErrorIs(t, err, io.ErrClosedPipe) + }) + } +} + +func TestPrivateCapacityIssueAbortAndCloseErrors(t *testing.T) { + // A known unissued callback abort does not poison admission; final cleanup failures remain terminal. + issues := 0 + cleanup := errors.New("query close failed") + transport := capacityTestTransport(func(Command) (Response, error) { return Response{Completed: true}, nil }, func() (*serializedTransport, error) { + return newSerializedTransport(func(command Command) (Response, error) { issues++; return capacityTestResponse(command) }, func() error { return cleanup }), nil + }, func() error { return syscall.EIO }) + abort := errors.New("window closed") + _, issued, err := transport.TryExecute(context.Background(), capacityCommand(), func() error { return abort }) + require.False(t, issued) + require.ErrorIs(t, err, abort) + require.Zero(t, issues) + _, err = transport.Execute(context.Background(), capacityTestWrite()) + require.NoError(t, err) + _, issued, err = transport.TryExecute(context.Background(), capacityCommand(), nil) + require.True(t, issued) + require.NoError(t, err) + require.Equal(t, 1, issues) + err = transport.Close() + require.ErrorIs(t, err, cleanup) + require.ErrorIs(t, err, syscall.EIO) + require.ErrorIs(t, err, ErrUncertain) + require.ErrorIs(t, transport.Close(), cleanup) +} + +func TestPrivateCapacityFactoryPreparationDoesNotOpenAttachment(t *testing.T) { + // Inspect includes the same LOG SENSE as preparation, followed by Configure mutations and label work. + fixture := newDeviceFixture() + opens := 0 + transport := capacityTestTransport(fixture.execute, func() (*serializedTransport, error) { + opens++ + return newSerializedTransport(capacityTestResponse, func() error { return nil }), nil + }, func() error { fixture.closed++; return nil }) + device, err := NewDevice(context.Background(), Identity{Configured: "fixture"}, transport) + require.NoError(t, err) + _, err = device.Configure(context.Background(), &entity.TarMediaProfile{RecordMode: entity.TapeRecordMode_TAPE_RECORD_MODE_VARIABLE, RecordBytes: 512}, Format) + require.NoError(t, err) + require.NoError(t, device.Initialize(context.Background())) + provider, err := device.Capacity(context.Background()) + require.NoError(t, err) + require.Zero(t, opens, "Initial-only/Off must not attach a recurring query descriptor") + + // Only the live optional route opens once; prior identical CDBs and controls cannot disable it. + for i := 0; i < 2; i++ { + sample, issued, err := provider.Observe(context.Background(), nil) + require.NoError(t, err) + require.True(t, issued) + require.Equal(t, uint64(1_000_000), *sample.RemainingBytes) + } + require.Equal(t, 1, opens) + require.NoError(t, device.Cleanup(context.Background())) + require.Equal(t, 1, fixture.closed) +} + +func TestPrivateCapacityPrimaryFailureClosesQueryAdmission(t *testing.T) { + for _, failure := range []Response{ + {}, + {Completed: true, Status: 2, Sense: fixedSense(0x70, 2, 0x3a, 0, false, 0, 0)}, + {Completed: true, Status: 2, Sense: fixedSense(0x70, 2, 4, 0, false, 0, 0)}, + } { + // A primary uncertainty/readiness failure closes lazy query admission before any attachment. + opens := 0 + transport := capacityTestTransport(func(Command) (Response, error) { return failure, nil }, func() (*serializedTransport, error) { opens++; return nil, ErrUnsupported }, func() error { return nil }) + _, err := transport.Execute(context.Background(), capacityTestWrite()) + require.Error(t, err) + _, issued, err := transport.TryExecute(context.Background(), capacityCommand(), nil) + require.Error(t, err) + require.False(t, issued) + require.Zero(t, opens) + if errors.Is(err, ErrUncertain) { + require.ErrorIs(t, transport.Close(), ErrUncertain) + } else { + require.NoError(t, transport.Close()) + } + } +} + +func TestPrivateCapacityAllowlistDoesNotExposeOtherCommands(t *testing.T) { + for _, change := range []func(*Command){ + func(c *Command) { c.CDB[2] = 0x4d }, + func(c *Command) { c.CDB[5] = 1 }, + func(c *Command) { c.Direction = ToDevice }, + func(c *Command) { c.Data = c.Data[:MaxQueryBytes-1] }, + func(c *Command) { c.Timeout = capacityObservationTimeout + time.Second }, + } { + // The private native owner has one exact software-passive form, not the broad Inspect allowlist. + opens := 0 + primary := newSerializedTransport(capacityTestResponse, func() error { return nil }) + owner := newCapacityTransport(primary, func() (*serializedTransport, error) { opens++; return nil, ErrUnsupported }) + command := capacityCommand() + change(&command) + _, issued, err := owner.tryExecute(context.Background(), command, nil) + require.Error(t, err) + require.False(t, issued) + require.Zero(t, opens) + require.NoError(t, owner.Close()) + require.NoError(t, primary.Close()) + } +} + +func TestPrivateCapacityRetainsAttachmentCleanupFailureAfterPrimaryFailure(t *testing.T) { + // Lazy open is pregranted before a primary fatal; its later failed cleanup cannot be hidden by that first cause. + entered, release := make(chan struct{}), make(chan struct{}) + primaryFailure := errors.New("known primary rejection") + cleanupFailure := errors.New("attachment cleanup failed") + transport := capacityTestTransport(func(Command) (Response, error) { return Response{Completed: true}, primaryFailure }, func() (*serializedTransport, error) { + close(entered) + <-release + return nil, &UncertainError{Cause: cleanupFailure} + }, func() error { return nil }) + queryDone := make(chan error, 1) + go func() { + _, _, err := transport.TryExecute(context.Background(), capacityCommand(), nil) + queryDone <- err + }() + <-entered + _, err := transport.Execute(context.Background(), capacityTestWrite()) + require.ErrorIs(t, err, primaryFailure) + close(release) + + // Both actual causes survive into Close; uncertainty retains resource unavailability despite a known first failure. + err = capacityTestWait(t, queryDone) + require.ErrorIs(t, err, primaryFailure) + require.ErrorIs(t, err, cleanupFailure) + err = transport.Close() + require.ErrorIs(t, err, ErrUncertain) + require.ErrorIs(t, err, cleanupFailure) +} diff --git a/internal/media/tape/cm_directory.go b/internal/media/tape/cm_directory.go new file mode 100644 index 0000000..256cf28 --- /dev/null +++ b/internal/media/tape/cm_directory.go @@ -0,0 +1,226 @@ +package tape + +import ( + "bytes" + "encoding/binary" + "fmt" + "sync/atomic" + "time" + + "github.com/samuelncui/yatm/entity" +) + +// decodeCartridgeDirectory accepts the studied LTO-5 v4 directory and v2 EOD pages. +// The format qualification and unresolved extension bits are described in docs/research/tape-capacity.md. +func decodeCartridgeDirectory(data []byte, partitions uint32) (*WrapMap, error) { + // Tables authenticate page identities and addresses before any page-specific interpretation. + if partitions != 1 && partitions != 2 { + return nil, fmt.Errorf("actual cartridge partition count is unavailable or unsupported: %w", ErrUnsupported) + } + pages, err := cartridgeDirectoryPages(data) + if err != nil { + return nil, err + } + directory := pages[0x103] + if len(directory) != 16+80*32 || binary.BigEndian.Uint16(directory[:2]) != 0x4103 { + return nil, fmt.Errorf("unsupported cartridge directory version or length: %w", ErrUnsupported) + } + if !cartridgeMemoryChecksum(directory[:16]) { + return nil, fmt.Errorf("cartridge directory header checksum: %w", ErrMalformed) + } + + // Invalid dataset markers delimit complete runs independently of totals or partition-start flags. + type entry struct { + pass, dataset uint32 + records, marks uint64 + } + entries := make([]entry, (len(directory)-16)/32) + type interval struct{ first, last int } + var runs []interval + inRun := false + for i := range entries { + raw := directory[16+i*32 : 48+i*32] + if !cartridgeMemoryChecksum(raw) { + return nil, fmt.Errorf("cartridge directory wrap %d checksum: %w", i, ErrMalformed) + } + entries[i] = entry{pass: binary.BigEndian.Uint32(raw[:4]), dataset: binary.BigEndian.Uint32(raw[4:8]), + records: uint64(binary.BigEndian.Uint32(raw[8:12])) + uint64(binary.BigEndian.Uint32(raw[12:16])), + marks: uint64(binary.BigEndian.Uint32(raw[16:20])) + uint64(binary.BigEndian.Uint32(raw[20:24]))} + if entries[i].dataset >= 0xfffffffe { + inRun = false + continue + } + if !inRun { + runs = append(runs, interval{first: i, last: i}) + inRun = true + } else { + runs[len(runs)-1].last = i + } + } + + // A physical single-partition FORMAT can retain an unused second EOD page in cartridge memory. + // Verify any retained page's representation without treating its old payload as an active partition. + pageIDs := [...]uint16{0x104, 0x10e} + for i, id := range pageIDs { + eod := pages[id] + if uint32(i) >= partitions && eod == nil { + continue + } + if len(eod) != 96 || binary.BigEndian.Uint16(eod[:2]) != 0x2000|id { + return nil, fmt.Errorf("unsupported cartridge EOD page %#x: %w", id, ErrUnsupported) + } + if !cartridgeMemoryChecksum(eod) { + return nil, fmt.Errorf("cartridge EOD page %#x checksum: %w", id, ErrMalformed) + } + } + + // Each active EOD owns exactly one whole run; no suffix may be selected to make low counters fit. + if len(runs) != int(partitions) { + return nil, fmt.Errorf("cartridge directory valid runs do not match EOD pages: %w", ErrMalformed) + } + observed := &WrapMap{AllWraps: true, Source: "LTO-CM directory v4/EOD v2", ObservedAt: time.Now(), + EndpointConvention: entity.TapePositionEndpointConvention_TAPE_POSITION_ENDPOINT_CONVENTION_EXCLUSIVE, + valid: new(atomic.Bool)} + used := make([]bool, len(runs)) + for partition, id := range pageIDs[:partitions] { + eod := pages[id] + if binary.BigEndian.Uint16(eod[32:34]) != 1 { + return nil, fmt.Errorf("cartridge EOD page %#x is not a valid completed directory: %w", id, ErrMalformed) + } + terminal, run := binary.BigEndian.Uint32(eod[28:32]), -1 + for i, candidate := range runs { + if uint32(candidate.last) == terminal && !used[i] { + run = i + break + } + } + if run < 0 || entries[terminal].pass != binary.BigEndian.Uint32(eod[4:8]) || + entries[terminal].dataset != binary.BigEndian.Uint32(eod[24:28]) { + return nil, fmt.Errorf("cartridge EOD page %#x disagrees with its directory terminal: %w", id, ErrMalformed) + } + used[run] = true + var records, marks uint64 + for i := runs[run].first; i <= runs[run].last; i++ { + records += entries[i].records + marks += entries[i].marks + observed.Endpoints = append(observed.Endpoints, WrapEndpoint{ + Partition: uint32(partition), Wrap: uint32(i), Object: records + marks}) + } + // Separate conservation proves zero extension contribution within this restricted low-word layout. + // Unknown packed bits are never decoded as zero, and an unexplained wider total is unavailable. + if records != cartridgeMemoryUint48(eod[12:18]) || marks != cartridgeMemoryUint48(eod[18:24]) { + return nil, fmt.Errorf("cartridge EOD page %#x has inconsistent or unsupported extended counters: %w", id, ErrMalformed) + } + } + observed.valid.Store(true) + return observed, nil +} + +func cartridgeDirectoryPages(data []byte) (map[uint16][]byte, error) { + // Both page tables must be complete inside the bounded READ BUFFER image. + if len(data) < 64 || len(data) > cartridgeMemoryMaxBytes { + return nil, ErrMalformed + } + _, start, err := cartridgeMemoryTable(data, 36, false) + if err != nil { + return nil, err + } + descriptors, end, err := cartridgeMemoryTable(data, start, true) + if err != nil { + return nil, err + } + + // Only the supported directory and EOD pages are interpreted; later partition forms stay unknown. + pages := make(map[uint16][]byte, 3) + for word, address := range descriptors { + id := word & 0xfff + if id == 0x10f || id == 0x110 { + return nil, fmt.Errorf("more than two cartridge EOD pages: %w", ErrUnsupported) + } + if id != 0x103 && id != 0x104 && id != 0x10e { + continue + } + if pages[id] != nil || address < start || address+4 > end { + return nil, ErrMalformed + } + length := int(binary.BigEndian.Uint16(data[address+2 : address+4])) + next := end + for other, at := range descriptors { + if other != word && at == address { + return nil, ErrMalformed + } + if at > address { + next = min(next, at) + } + } + if length < 4 || address+length > next || binary.BigEndian.Uint16(data[address:address+2]) != word { + return nil, ErrMalformed + } + pages[id] = data[address : address+length] + } + return pages, nil +} + +func cartridgeMemoryTable(data []byte, start int, unprotected bool) (map[uint16]int, int, error) { + // The later CM framing excludes its leading padding descriptor from the unprotected table checksum. + if start < 36 || start+8 > len(data) || start%4 != 0 { + return nil, 0, ErrMalformed + } + checksumStart := start + if unprotected && binary.BigEndian.Uint16(data[start:start+2]) == 0x0ffc { + checksumStart += 4 + } + descriptors := make(map[uint16]int) + firstPage := len(data) + for offset := start; offset+8 <= len(data) && offset+8 <= firstPage; offset += 4 { + word := binary.BigEndian.Uint16(data[offset : offset+2]) + address := int(binary.BigEndian.Uint16(data[offset+2 : offset+4])) + switch word { + case 0x0fff: + if address < offset+8 || address > len(data) || address%4 != 0 || + !cartridgeMemoryChecksum(data[checksumStart:offset+8]) { + return nil, 0, ErrMalformed + } + return descriptors, address, nil + case 0x0ffc, 0x0ffe: + continue + } + if _, exists := descriptors[word]; exists || address < offset+8 || address+4 > len(data) || address%4 != 0 { + return nil, 0, ErrMalformed + } + descriptors[word] = address + firstPage = min(firstPage, address) + } + return nil, 0, ErrMalformed +} + +func cartridgeMemoryUint48(data []byte) uint64 { + return uint64(binary.BigEndian.Uint16(data[:2]))<<32 | uint64(binary.BigEndian.Uint32(data[2:6])) +} + +func cartridgeMemoryChecksum(data []byte) bool { + if len(data) < 4 { + return false + } + // ECMA-319 13.2: GF(256), polynomial 0x11d, roots alpha^126 through alpha^129. + var registers [4]byte + for _, value := range data[:len(data)-4] { + feedback := value ^ registers[0] + registers = [4]byte{registers[1], registers[2], registers[3], 0} + for i, coefficient := range [...]byte{56, 207, 56, 1} { + var product byte + for a, b := feedback, coefficient; b != 0; b >>= 1 { + if b&1 != 0 { + product ^= a + } + high := a & 0x80 + a <<= 1 + if high != 0 { + a ^= 0x1d + } + } + registers[i] ^= product + } + } + return bytes.Equal(registers[:], data[len(data)-4:]) +} diff --git a/internal/media/tape/cm_directory_test.go b/internal/media/tape/cm_directory_test.go new file mode 100644 index 0000000..ea78e1c --- /dev/null +++ b/internal/media/tape/cm_directory_test.go @@ -0,0 +1,507 @@ +package tape + +import ( + "bytes" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "reflect" + "testing" + + "github.com/samuelncui/yatm/entity" +) + +// cmDirectoryTestSeal constructs the ECMA-319 generator rather than copying decoder coefficients. +func cmDirectoryTestSeal(data []byte) { + // Build field logarithms from the primitive polynomial for independent polynomial arithmetic. + var exp [510]byte + var log [256]int + value := 1 + for i := 0; i < 255; i++ { + exp[i], exp[i+255], log[value] = byte(value), byte(value), i + value <<= 1 + if value&0x100 != 0 { + value ^= 0x11d + } + } + multiply := func(a, b byte) byte { + if a == 0 || b == 0 { + return 0 + } + return exp[log[a]+log[b]] + } + + // Multiply the four root factors, then divide the padded message by that polynomial. + generator := []byte{1} + for power := 126; power <= 129; power++ { + next := make([]byte, len(generator)+1) + for i, coefficient := range generator { + next[i] ^= coefficient + next[i+1] ^= multiply(coefficient, exp[power]) + } + generator = next + } + work := bytes.Clone(data) + clear(work[len(work)-4:]) + for i := 0; i < len(work)-4; i++ { + factor := work[i] + for j, coefficient := range generator { + work[i+j] ^= multiply(factor, coefficient) + } + } + copy(data[len(data)-4:], work[len(work)-4:]) +} + +func cmDirectoryTestPut48(data []byte, value uint64) { + binary.BigEndian.PutUint16(data[:2], uint16(value>>32)) + binary.BigEndian.PutUint32(data[2:6], uint32(value)) +} + +type cmDirectoryTestRun struct { + first int + counts [][4]uint32 +} + +type cmDirectoryTestLayout struct { + data []byte + table, dir int + eods []int +} + +func cmDirectoryTestImage(t testing.TB, shift int, runs ...cmDirectoryTestRun) cmDirectoryTestLayout { + t.Helper() + // Two independently checksummed tables describe relocated pages, without production table helpers. + fixture := cmDirectoryTestLayout{data: make([]byte, 8160), table: 64 + shift, dir: 128 + 2*shift} + binary.BigEndian.PutUint16(fixture.data[36:38], 0x0fff) + binary.BigEndian.PutUint16(fixture.data[38:40], uint16(fixture.table)) + cmDirectoryTestSeal(fixture.data[36:44]) + table := fixture.data[fixture.table : fixture.table+32] + binary.BigEndian.PutUint16(table[:2], 0x0ffc) + binary.BigEndian.PutUint16(table[2:4], uint16(fixture.dir)) + binary.BigEndian.PutUint16(table[4:6], 0x4103) + binary.BigEndian.PutUint16(table[6:8], uint16(fixture.dir)) + for offset := 8; offset < 24; offset += 4 { + binary.BigEndian.PutUint16(table[offset:offset+2], 0x0ffe) + } + + // Invalid dataset sentinels delimit runs; flags and partition-start heuristics are unnecessary. + directory := fixture.data[fixture.dir : fixture.dir+2576] + binary.BigEndian.PutUint16(directory[:2], 0x4103) + binary.BigEndian.PutUint16(directory[2:4], uint16(len(directory))) + cmDirectoryTestSeal(directory[:16]) + for i := 0; i < 80; i++ { + entry := directory[16+i*32 : 48+i*32] + binary.BigEndian.PutUint32(entry[4:8], 0xffffffff) + cmDirectoryTestSeal(entry) + } + for partition, run := range runs { + if len(run.counts) == 0 || run.first < 0 || run.first+len(run.counts) > 80 || partition > 1 { + t.Fatal("invalid synthetic CM run") + } + var records, marks uint64 + for i, counts := range run.counts { + wrap := run.first + i + entry := directory[16+wrap*32 : 48+wrap*32] + binary.BigEndian.PutUint32(entry[:4], uint32(7+partition)) + binary.BigEndian.PutUint32(entry[4:8], uint32(100+wrap)) + for j, count := range counts { + binary.BigEndian.PutUint32(entry[8+j*4:12+j*4], count) + } + records += uint64(counts[0]) + uint64(counts[1]) + marks += uint64(counts[2]) + uint64(counts[3]) + cmDirectoryTestSeal(entry) + } + + // EOD totals independently bind the whole run and its terminal pass/dataset. + address := fixture.dir + 2576 + 64 + shift + partition*128 + fixture.eods = append(fixture.eods, address) + eod := fixture.data[address : address+96] + id := uint16(0x2104) + if partition == 1 { + id = 0x210e + } + binary.BigEndian.PutUint16(eod[:2], id) + binary.BigEndian.PutUint16(eod[2:4], 96) + binary.BigEndian.PutUint32(eod[4:8], uint32(7+partition)) + cmDirectoryTestPut48(eod[12:18], records) + cmDirectoryTestPut48(eod[18:24], marks) + terminal := uint32(run.first + len(run.counts) - 1) + binary.BigEndian.PutUint32(eod[24:28], 100+terminal) + binary.BigEndian.PutUint32(eod[28:32], terminal) + binary.BigEndian.PutUint16(eod[32:34], 1) + cmDirectoryTestSeal(eod) + binary.BigEndian.PutUint16(table[8+partition*4:10+partition*4], id) + binary.BigEndian.PutUint16(table[10+partition*4:12+partition*4], uint16(address)) + } + + // Close the address envelope after every referenced page, retaining zeroed unrelated storage. + end := fixture.dir + 2576 + if len(fixture.eods) != 0 { + end = fixture.eods[len(fixture.eods)-1] + 96 + } + binary.BigEndian.PutUint16(table[24:26], 0x0fff) + binary.BigEndian.PutUint16(table[26:28], uint16(end)) + cmDirectoryTestSeal(table[4:]) + return fixture +} + +func TestCartridgeDirectoryFixtureHashes(t *testing.T) { + // Sanitized hardware captures are frozen inputs; any byte change needs a new reviewed fixture. + for name, want := range map[string]string{ + "hp-lto5-directory-v4.bin": "2443c04238f06ff7a7b0790363ec50637a7ec6cb1b3536f4e170a8a8962f1d6c", + "hp-lto5-single-partition-v4.bin": "c1bea484e78ea69796c50d94651f12feeec2d79473a91f39c9788da07de6c7f3", + } { + data, err := os.ReadFile("testdata/cm/" + name) + if err != nil { + t.Fatal(err) + } + if sum := sha256.Sum256(data); hex.EncodeToString(sum[:]) != want { + t.Fatalf("%s sha256=%x", name, sum) + } + } +} + +func TestCartridgeDirectoryCapture(t *testing.T) { + // The sanitized hardware image and numeric REOWP answer are frozen independently of the decoder. + data, err := os.ReadFile("testdata/cm/hp-lto5-directory-v4.bin") + if err != nil { + t.Fatal(err) + } + encoded, err := os.ReadFile("testdata/cm/hp-lto5-directory-v4-endpoints.json") + if err != nil { + t.Fatal(err) + } + var want []WrapEndpoint + if err := json.Unmarshal(encoded, &want); err != nil { + t.Fatal(err) + } + before := bytes.Clone(data) + observed, err := decodeCartridgeDirectory(data, 2) + if err != nil || observed == nil { + t.Fatalf("decode captured CM: %v", err) + } + + // Exact exclusive endpoints include both filemark totals and the partial final wrap. + if len(want) != 23 || !reflect.DeepEqual(observed.Endpoints, want) || !observed.Valid() || !observed.AllWraps || + observed.EndpointConvention != entity.TapePositionEndpointConvention_TAPE_POSITION_ENDPOINT_CONVENTION_EXCLUSIVE || + observed.Fresh != nil || observed.ObservedAt.IsZero() || !bytes.Equal(before, data) { + t.Fatalf("captured CM map=%+v", observed) + } + + // The independent polynomial encoder agrees with every retained observed codeword. + ranges := [][2]int{{36, 64}, {260, 352}, {1968, 1984}, {4544, 4640}, {4640, 4736}} + for i := 0; i < 80; i++ { + ranges = append(ranges, [2]int{1984 + i*32, 2016 + i*32}) + } + for _, bounds := range ranges { + codeword := bytes.Clone(data[bounds[0]:bounds[1]]) + cmDirectoryTestSeal(codeword) + if !bytes.Equal(codeword, data[bounds[0]:bounds[1]]) { + t.Fatalf("reference RS differs at %d", bounds[0]) + } + } +} + +func TestCartridgeDirectoryActualSinglePartitionCapture(t *testing.T) { + // The real FORMAT retained a valid second EOD page with state zero despite one physical partition. + data, err := os.ReadFile("testdata/cm/hp-lto5-single-partition-v4.bin") + if err != nil { + t.Fatal(err) + } + encoded, err := os.ReadFile("testdata/cm/hp-lto5-single-partition-v4-endpoints.json") + if err != nil { + t.Fatal(err) + } + var want []WrapEndpoint + if err := json.Unmarshal(encoded, &want); err != nil { + t.Fatal(err) + } + observed, err := decodeCartridgeDirectory(data, 1) + if err != nil || observed == nil || len(want) != 35 || !reflect.DeepEqual(observed.Endpoints, want) { + t.Fatalf("single partition captured map=%+v err=%v", observed, err) + } + if binary.BigEndian.Uint16(data[4640:4642]) != 0x210e || binary.BigEndian.Uint16(data[4672:4674]) != 0 { + t.Fatal("fixture no longer retains the inactive EOD page") + } + + // Inactive payload is not a second partition even if it retains an old completed state and counters. + stale := bytes.Clone(data) + copy(stale[4644:4732], stale[4548:4636]) + cmDirectoryTestSeal(stale[4640:4736]) + accepted, err := decodeCartridgeDirectory(stale, 1) + if err != nil || accepted == nil || !reflect.DeepEqual(accepted.Endpoints, want) { + t.Fatalf("stale inactive EOD changed active endpoints: %+v %v", accepted, err) + } + for _, count := range []uint32{0, 2, 3, 0xffffffff} { + if got, err := decodeCartridgeDirectory(data, count); got != nil || err == nil { + t.Fatalf("unproved partition count %d accepted", count) + } + } + + // The inactive page is still structurally checked; ignoring it cannot conceal corrupt framing. + for _, change := range []func([]byte){ + func(value []byte) { value[4735] ^= 1 }, + func(value []byte) { value[4643] = 95 }, + } { + bad := bytes.Clone(data) + change(bad) + if got, err := decodeCartridgeDirectory(bad, 1); got != nil || err == nil { + t.Fatal("corrupt inactive page accepted") + } + } +} + +func TestCartridgeDirectoryLayouts(t *testing.T) { + for _, test := range []struct { + name string + shift int + runs []cmDirectoryTestRun + want []WrapEndpoint + }{ + {name: "single partition", runs: []cmDirectoryTestRun{{first: 0, counts: [][4]uint32{{10, 2, 3, 1}}}}, + want: []WrapEndpoint{{Wrap: 0, Object: 16}}}, + {name: "relocated tables and pages", shift: 512, + runs: []cmDirectoryTestRun{{first: 0, counts: [][4]uint32{{10, 2, 3, 1}}}}, + want: []WrapEndpoint{{Wrap: 0, Object: 16}}}, + {name: "alternate partition starts", runs: []cmDirectoryTestRun{ + {first: 3, counts: [][4]uint32{{10, 0, 0, 0}, {20, 0, 1, 0}}}, + {first: 12, counts: [][4]uint32{{30, 0, 0, 2}, {1, 0, 0, 0}}}}, + want: []WrapEndpoint{{Wrap: 3, Object: 10}, {Wrap: 4, Object: 31}, + {Partition: 1, Wrap: 12, Object: 32}, {Partition: 1, Wrap: 13, Object: 33}}}, + {name: "partition order differs from wrap order", runs: []cmDirectoryTestRun{ + {first: 12, counts: [][4]uint32{{5, 0, 0, 0}}}, {first: 3, counts: [][4]uint32{{7, 0, 0, 0}}}}, + want: []WrapEndpoint{{Wrap: 12, Object: 5}, {Partition: 1, Wrap: 3, Object: 7}}}, + {name: "counts cross uint32", runs: []cmDirectoryTestRun{{first: 0, + counts: [][4]uint32{{0xffffffff, 2, 0xffffffff, 3}, {4, 5, 6, 7}}}}, + want: []WrapEndpoint{{Wrap: 0, Object: 2*(1<<32) + 3}, {Wrap: 1, Object: 2*(1<<32) + 25}}}, + {name: "empty completed run", runs: []cmDirectoryTestRun{{first: 0, counts: [][4]uint32{{}}}}, + want: []WrapEndpoint{{Wrap: 0, Object: 0}}}, + {name: "filemarks only", runs: []cmDirectoryTestRun{{first: 2, counts: [][4]uint32{{0, 0, 7, 2}}}}, + want: []WrapEndpoint{{Wrap: 2, Object: 9}}}, + {name: "partial final wrap", runs: []cmDirectoryTestRun{{first: 78, + counts: [][4]uint32{{1000, 0, 0, 1}, {17, 0, 0, 1}}}}, + want: []WrapEndpoint{{Wrap: 78, Object: 1001}, {Wrap: 79, Object: 1019}}}, + } { + t.Run(test.name, func(t *testing.T) { + fixture := cmDirectoryTestImage(t, test.shift, test.runs...) + observed, err := decodeCartridgeDirectory(fixture.data, uint32(len(test.runs))) + if err != nil || observed == nil || !reflect.DeepEqual(observed.Endpoints, test.want) { + t.Fatalf("map=%+v err=%v want=%v", observed, err, test.want) + } + }) + } +} + +func TestCartridgeDirectoryMalformed(t *testing.T) { + for _, test := range []struct { + name string + edit func(*cmDirectoryTestLayout) + want error + }{ + {name: "protected table checksum", edit: func(f *cmDirectoryTestLayout) { f.data[40] ^= 1 }}, + {name: "unprotected table checksum", edit: func(f *cmDirectoryTestLayout) { f.data[f.table+28] ^= 1 }}, + {name: "directory header checksum", edit: func(f *cmDirectoryTestLayout) { f.data[f.dir+12] ^= 1 }}, + {name: "directory valid entry checksum", edit: func(f *cmDirectoryTestLayout) { f.data[f.dir+16+28] ^= 1 }}, + {name: "directory invalid entry checksum", edit: func(f *cmDirectoryTestLayout) { f.data[f.dir+16+79*32+28] ^= 1 }}, + {name: "EOD checksum", edit: func(f *cmDirectoryTestLayout) { f.data[f.eods[0]+92] ^= 1 }}, + {name: "directory header length", want: ErrUnsupported, edit: func(f *cmDirectoryTestLayout) { + binary.BigEndian.PutUint16(f.data[f.dir+2:f.dir+4], 2572) + cmDirectoryTestSeal(f.data[f.dir : f.dir+16]) + }}, + {name: "EOD header length", want: ErrUnsupported, edit: func(f *cmDirectoryTestLayout) { + binary.BigEndian.PutUint16(f.data[f.eods[0]+2:f.eods[0]+4], 92) + cmDirectoryTestSeal(f.data[f.eods[0] : f.eods[0]+96]) + }}, + {name: "directory version", want: ErrUnsupported, edit: func(f *cmDirectoryTestLayout) { + binary.BigEndian.PutUint16(f.data[f.table+4:f.table+6], 0x5103) + cmDirectoryTestSeal(f.data[f.table+4 : f.table+32]) + binary.BigEndian.PutUint16(f.data[f.dir:f.dir+2], 0x5103) + cmDirectoryTestSeal(f.data[f.dir : f.dir+16]) + }}, + {name: "EOD version", want: ErrUnsupported, edit: func(f *cmDirectoryTestLayout) { + binary.BigEndian.PutUint16(f.data[f.table+8:f.table+10], 0x3104) + cmDirectoryTestSeal(f.data[f.table+4 : f.table+32]) + binary.BigEndian.PutUint16(f.data[f.eods[0]:f.eods[0]+2], 0x3104) + cmDirectoryTestSeal(f.data[f.eods[0] : f.eods[0]+96]) + }}, + {name: "page identity mismatch", edit: func(f *cmDirectoryTestLayout) { + binary.BigEndian.PutUint16(f.data[f.table+8:f.table+10], 0x3104) + cmDirectoryTestSeal(f.data[f.table+4 : f.table+32]) + }}, + {name: "missing directory", want: ErrUnsupported, edit: func(f *cmDirectoryTestLayout) { + binary.BigEndian.PutUint16(f.data[f.table+4:f.table+6], 0x0ffe) + cmDirectoryTestSeal(f.data[f.table+4 : f.table+32]) + }}, + {name: "missing EOD", edit: func(f *cmDirectoryTestLayout) { + binary.BigEndian.PutUint16(f.data[f.table+8:f.table+10], 0x0ffe) + cmDirectoryTestSeal(f.data[f.table+4 : f.table+32]) + }}, + {name: "duplicate directory ID with another version", edit: func(f *cmDirectoryTestLayout) { + binary.BigEndian.PutUint16(f.data[f.table+16:f.table+18], 0x5103) + binary.BigEndian.PutUint16(f.data[f.table+18:f.table+20], uint16(f.dir)) + cmDirectoryTestSeal(f.data[f.table+4 : f.table+32]) + }}, + {name: "too many partitions", want: ErrUnsupported, edit: func(f *cmDirectoryTestLayout) { + binary.BigEndian.PutUint16(f.data[f.table+16:f.table+18], 0x210f) + binary.BigEndian.PutUint16(f.data[f.table+18:f.table+20], uint16(f.dir+2576)) + cmDirectoryTestSeal(f.data[f.table+4 : f.table+32]) + }}, + {name: "out of image EOD", edit: func(f *cmDirectoryTestLayout) { + binary.BigEndian.PutUint16(f.data[f.table+10:f.table+12], 0xfffc) + cmDirectoryTestSeal(f.data[f.table+4 : f.table+32]) + }}, + {name: "unaligned page", edit: func(f *cmDirectoryTestLayout) { + binary.BigEndian.PutUint16(f.data[f.table+10:f.table+12], uint16(f.eods[0]+1)) + cmDirectoryTestSeal(f.data[f.table+4 : f.table+32]) + }}, + {name: "EOD over declared end", edit: func(f *cmDirectoryTestLayout) { + binary.BigEndian.PutUint16(f.data[f.table+26:f.table+28], uint16(f.eods[0]+92)) + cmDirectoryTestSeal(f.data[f.table+4 : f.table+32]) + }}, + {name: "empty directory", edit: func(f *cmDirectoryTestLayout) { + for i := 0; i < 2; i++ { + entry := f.data[f.dir+16+i*32 : f.dir+48+i*32] + binary.BigEndian.PutUint32(entry[4:8], 0xfffffffe) + cmDirectoryTestSeal(entry) + } + }}, + {name: "interrupted writing EOD", edit: func(f *cmDirectoryTestLayout) { + binary.BigEndian.PutUint16(f.data[f.eods[0]+32:f.eods[0]+34], 2) + cmDirectoryTestSeal(f.data[f.eods[0] : f.eods[0]+96]) + }}, + {name: "unknown EOD state", edit: func(f *cmDirectoryTestLayout) { + binary.BigEndian.PutUint16(f.data[f.eods[0]+32:f.eods[0]+34], 0) + cmDirectoryTestSeal(f.data[f.eods[0] : f.eods[0]+96]) + }}, + {name: "failed EOD state", edit: func(f *cmDirectoryTestLayout) { + binary.BigEndian.PutUint16(f.data[f.eods[0]+32:f.eods[0]+34], 3) + cmDirectoryTestSeal(f.data[f.eods[0] : f.eods[0]+96]) + }}, + {name: "terminal outside directory", edit: func(f *cmDirectoryTestLayout) { + binary.BigEndian.PutUint32(f.data[f.eods[0]+28:f.eods[0]+32], 80) + cmDirectoryTestSeal(f.data[f.eods[0] : f.eods[0]+96]) + }}, + {name: "terminal inside run", edit: func(f *cmDirectoryTestLayout) { + binary.BigEndian.PutUint32(f.data[f.eods[0]+28:f.eods[0]+32], 0) + cmDirectoryTestSeal(f.data[f.eods[0] : f.eods[0]+96]) + }}, + {name: "terminal pass mismatch", edit: func(f *cmDirectoryTestLayout) { + f.data[f.eods[0]+7] ^= 1 + cmDirectoryTestSeal(f.data[f.eods[0] : f.eods[0]+96]) + }}, + {name: "terminal dataset mismatch", edit: func(f *cmDirectoryTestLayout) { + f.data[f.eods[0]+27] ^= 1 + cmDirectoryTestSeal(f.data[f.eods[0] : f.eods[0]+96]) + }}, + {name: "record and filemark swap preserves combined total", edit: func(f *cmDirectoryTestLayout) { + cmDirectoryTestPut48(f.data[f.eods[0]+12:f.eods[0]+18], 11) + cmDirectoryTestPut48(f.data[f.eods[0]+18:f.eods[0]+24], 4) + cmDirectoryTestSeal(f.data[f.eods[0] : f.eods[0]+96]) + }}, + {name: "unexplained record high word", edit: func(f *cmDirectoryTestLayout) { + cmDirectoryTestPut48(f.data[f.eods[0]+12:f.eods[0]+18], 1<<32+12) + cmDirectoryTestSeal(f.data[f.eods[0] : f.eods[0]+96]) + }}, + {name: "must not select suffix to fit EOD total", edit: func(f *cmDirectoryTestLayout) { + cmDirectoryTestPut48(f.data[f.eods[0]+12:f.eods[0]+18], 2) + cmDirectoryTestPut48(f.data[f.eods[0]+18:f.eods[0]+24], 0) + cmDirectoryTestSeal(f.data[f.eods[0] : f.eods[0]+96]) + }}, + {name: "unknown page overlaps directory", edit: func(f *cmDirectoryTestLayout) { + binary.BigEndian.PutUint16(f.data[f.table+16:f.table+18], 0x1200) + binary.BigEndian.PutUint16(f.data[f.table+18:f.table+20], uint16(f.dir+16)) + cmDirectoryTestSeal(f.data[f.table+4 : f.table+32]) + }}, + {name: "EOD page overlaps directory", edit: func(f *cmDirectoryTestLayout) { + copy(f.data[f.dir+16:f.dir+112], f.data[f.eods[0]:f.eods[0]+96]) + binary.BigEndian.PutUint16(f.data[f.table+10:f.table+12], uint16(f.dir+16)) + cmDirectoryTestSeal(f.data[f.table+4 : f.table+32]) + }}, + {name: "page lies inside page table", edit: func(f *cmDirectoryTestLayout) { + binary.BigEndian.PutUint16(f.data[f.table+10:f.table+12], uint16(f.table+24)) + cmDirectoryTestSeal(f.data[f.table+4 : f.table+32]) + }}, + } { + t.Run(test.name, func(t *testing.T) { + // Corrupt only the stated fact, resealing checksums when semantic validation is under test. + fixture := cmDirectoryTestImage(t, 0, cmDirectoryTestRun{first: 0, counts: [][4]uint32{{10, 0, 3, 0}, {2, 0, 0, 0}}}) + test.edit(&fixture) + observed, err := decodeCartridgeDirectory(fixture.data, 1) + if observed != nil || err == nil || test.want != nil && !errors.Is(err, test.want) { + t.Fatalf("invalid map=%+v err=%v want=%v", observed, err, test.want) + } + }) + } +} + +func TestCartridgeDirectoryRunOwnership(t *testing.T) { + for _, name := range []string{"unmapped run", "same terminal", "adjacent partitions", "interior invalid marker"} { + t.Run(name, func(t *testing.T) { + // Each partition must own one entire run, regardless of how tempting partial totals look. + fixture := cmDirectoryTestImage(t, 0, + cmDirectoryTestRun{first: 0, counts: [][4]uint32{{4, 0, 0, 0}}}, + cmDirectoryTestRun{first: 4, counts: [][4]uint32{{5, 0, 0, 0}, {2, 0, 0, 0}, {3, 0, 0, 0}}}) + switch name { + case "unmapped run": + entry := fixture.data[fixture.dir+16+12*32 : fixture.dir+48+12*32] + copy(entry, fixture.data[fixture.dir+16:fixture.dir+48]) + case "same terminal": + copy(fixture.data[fixture.eods[1]+4:fixture.eods[1]+92], + fixture.data[fixture.eods[0]+4:fixture.eods[0]+92]) + cmDirectoryTestSeal(fixture.data[fixture.eods[1] : fixture.eods[1]+96]) + case "adjacent partitions": + for i := 1; i < 4; i++ { + copy(fixture.data[fixture.dir+16+i*32:fixture.dir+48+i*32], + fixture.data[fixture.dir+16:fixture.dir+48]) + } + case "interior invalid marker": + entry := fixture.data[fixture.dir+16+5*32 : fixture.dir+48+5*32] + binary.BigEndian.PutUint32(entry[4:8], 0xfffffffe) + cmDirectoryTestSeal(entry) + } + if observed, err := decodeCartridgeDirectory(fixture.data, 2); observed != nil || !errors.Is(err, ErrMalformed) { + t.Fatalf("ambiguous run map=%+v err=%v", observed, err) + } + }) + } +} + +func TestCartridgeDirectoryTruncation(t *testing.T) { + // Every header/table/page boundary must stay within the actual received image. + fixture := cmDirectoryTestImage(t, 0, cmDirectoryTestRun{first: 0, counts: [][4]uint32{{10, 0, 3, 0}}}) + for _, size := range []int{0, 35, 36, 40, 43, 63, 64, 92, 95, 127, 128, 143, + fixture.dir + 2575, fixture.eods[0] + 95, 8193} { + t.Run(fmt.Sprint(size), func(t *testing.T) { + data := fixture.data + if size > len(data) { + data = append(bytes.Clone(data), make([]byte, size-len(data))...) + } else { + data = data[:size] + } + if observed, err := decodeCartridgeDirectory(data, 1); observed != nil || err == nil { + t.Fatalf("truncated image returned map=%+v err=%v", observed, err) + } + }) + } +} + +func BenchmarkCartridgeDirectory(b *testing.B) { + // Fixture reads and independent synthetic construction are excluded from parser measurements. + data, err := os.ReadFile("testdata/cm/hp-lto5-directory-v4.bin") + if err != nil { + b.Fatal(err) + } + b.SetBytes(int64(len(data))) + b.ReportAllocs() + b.ResetTimer() + for b.Loop() { + observed, err := decodeCartridgeDirectory(data, 2) + if err != nil || observed == nil || len(observed.Endpoints) != 23 { + b.Fatalf("map=%+v err=%v", observed, err) + } + } +} diff --git a/internal/media/tape/cm_geometry_test.go b/internal/media/tape/cm_geometry_test.go new file mode 100644 index 0000000..82e287a --- /dev/null +++ b/internal/media/tape/cm_geometry_test.go @@ -0,0 +1,249 @@ +package tape + +import ( + "context" + "errors" + "os" + "testing" + + "github.com/samuelncui/yatm/entity" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" +) + +func TestCartridgeDirectoryPreparationFallback(t *testing.T) { + for _, mode := range []string{"REOWP", "unsupported", "short", "malformed"} { + t.Run(mode, func(t *testing.T) { + // Real inspection sees the selected REOWP outcome; CM is only available to explicit Geometry. + data, err := os.ReadFile("testdata/cm/hp-lto5-directory-v4.bin") + require.NoError(t, err) + fixture := newGeometryFixture() + fixture.vendor = "HP" + fixture.actualPartitions = 2 + fixture.unsupported, fixture.shortOnly = mode == "unsupported", mode == "short" + if mode == "malformed" { + fixture.data = []byte{0, 3, 0, 0, 0} + } + calls, partitionCalls := 0, 0 + transport := newSerializedTransport(func(command Command) (Response, error) { + if command.CDB[0] == 0x8c && command.CDB[1] == 3 { + partitionCalls++ + } + if command.CDB[0] != 0x3c { + return fixture.execute(command) + } + calls++ + if command.CDB[1] == 3 { + copy(command.Data, []byte{0, 0, 0x1f, 0xe0}) + } else { + offset := int(command.CDB[3])<<16 | int(command.CDB[4])<<8 | int(command.CDB[5]) + copy(command.Data, data[offset:]) + } + return Response{Completed: true}, nil + }, func() error { return nil }) + device, err := NewDevice(t.Context(), Identity{}, transport) + require.NoError(t, err) + require.Zero(t, calls, "ordinary Inspect must not query CM") + require.Zero(t, partitionCalls, "ordinary Inspect must not add a CM partition query") + before := feature(device.Report(), entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_END_OF_WRAP_POSITION) + + // Repeated requests reuse the same complete observation without changing REOWP support evidence. + observed, err := device.Geometry(t.Context()) + require.NoError(t, err) + require.True(t, observed.Valid()) + wantCalls := 0 + if mode != "REOWP" { + wantCalls = 3 + require.Len(t, observed.Endpoints, 23) + require.Equal(t, entity.TapePositionEndpointConvention_TAPE_POSITION_ENDPOINT_CONVENTION_EXCLUSIVE, + observed.EndpointConvention) + require.Contains(t, observed.Source, "LTO-CM") + } + require.Equal(t, wantCalls, calls) + require.Equal(t, wantCalls/3, partitionCalls) + require.True(t, proto.Equal(before, feature(device.Report(), entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_END_OF_WRAP_POSITION))) + first := observed.Endpoints[0] + observed.Endpoints[0].Object = 999 + again, err := device.Geometry(t.Context()) + require.NoError(t, err) + require.Equal(t, first, again.Endpoints[0], "callers cannot mutate the cached endpoints") + require.Equal(t, wantCalls, calls) + require.Equal(t, wantCalls/3, partitionCalls, "CM snapshot reuses its partition authority") + + // Ownership invalidation prevents both stale use and implicit reacquisition within this session. + device.invalidateGeometry() + require.Zero(t, device.actualPartitions) + require.False(t, observed.Valid()) + require.False(t, again.Valid()) + withdrawn, err := device.Geometry(t.Context()) + require.NoError(t, err) + require.Nil(t, withdrawn) + require.Equal(t, wantCalls, calls) + require.NoError(t, device.Cleanup(t.Context())) + }) + } +} + +func TestCartridgeDirectoryReusesConfiguredPartitionCount(t *testing.T) { + // tar Configure already queries actual partitions; lazy geometry must not add another MAM command. + data, err := os.ReadFile("testdata/cm/hp-lto5-single-partition-v4.bin") + require.NoError(t, err) + fixture := newGeometryFixture() + fixture.vendor, fixture.unsupported, fixture.actualPartitions = "HP", true, 1 + cmCalls, partitionCalls := 0, 0 + transport := newSerializedTransport(func(command Command) (Response, error) { + if command.CDB[0] == 0x8c && command.CDB[1] == 3 { + partitionCalls++ + } + if command.CDB[0] != 0x3c { + return fixture.execute(command) + } + cmCalls++ + if command.CDB[1] == 3 { + copy(command.Data, []byte{0, 0, 0x1f, 0xe0}) + } else { + offset := int(command.CDB[3])<<16 | int(command.CDB[4])<<8 | int(command.CDB[5]) + copy(command.Data, data[offset:]) + } + return Response{Completed: true}, nil + }, func() error { return nil }) + device, err := NewDevice(t.Context(), Identity{}, transport) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, device.Cleanup(context.Background())) }) + _, err = device.Configure(t.Context(), &entity.TarMediaProfile{ + RecordMode: entity.TapeRecordMode_TAPE_RECORD_MODE_VARIABLE, RecordBytes: 512, + }, Read) + require.NoError(t, err) + require.Equal(t, uint32(1), device.actualPartitions) + require.Equal(t, 1, partitionCalls) + + // Inactive EOD page presence cannot revive a second partition or request another source observation. + observed, err := device.Geometry(t.Context()) + require.NoError(t, err) + require.Len(t, observed.Endpoints, 35) + require.Equal(t, 1, partitionCalls) + require.Equal(t, 3, cmCalls) + require.Equal(t, uint32(1), device.actualPartitions) + device.invalidateGeometry() + require.False(t, observed.Valid()) + require.Zero(t, device.actualPartitions) + withdrawn, err := device.Geometry(t.Context()) + require.NoError(t, err) + require.Nil(t, withdrawn) + require.Equal(t, 1, partitionCalls) + require.Equal(t, 3, cmCalls) +} + +func TestCartridgeDirectoryPartitionQueryFailure(t *testing.T) { + for _, test := range []struct { + name string + response Response + data []byte + err error + fatal bool + }{ + {name: "unsupported", response: Response{Completed: true, Status: 2, Sense: fixedSense(0x70, 5, 0x24, 0, false, 0, 0)}}, + {name: "unknown count", response: Response{Completed: true}, data: []byte{0, 2, 0, 0}}, + {name: "too many partitions", response: Response{Completed: true}, data: []byte{0, 2, 0, 3}}, + {name: "short response", response: Response{Completed: true, Residual: 1}, data: []byte{0, 2, 0, 1}}, + {name: "unknown completion", fatal: true}, + {name: "medium changed", response: Response{Completed: true, Status: 2, Sense: fixedSense(0x70, 6, 0x28, 0, false, 0, 0)}, fatal: true}, + {name: "medium absent", response: Response{Completed: true, Status: 2, Sense: fixedSense(0x70, 2, 0x3a, 0, false, 0, 0)}, fatal: true}, + {name: "canceled", err: context.Canceled, fatal: true}, + } { + t.Run(test.name, func(t *testing.T) { + // Unknown actual partition count cannot be supplied by MODE page or inferred from CM page presence. + fixture := newGeometryFixture() + fixture.vendor, fixture.unsupported = "HP", true + queries, cmCalls := 0, 0 + transport := newSerializedTransport(func(command Command) (Response, error) { + if command.CDB[0] == 0x8c && command.CDB[1] == 3 { + queries++ + copy(command.Data, test.data) + return test.response, test.err + } + if command.CDB[0] == 0x3c { + cmCalls++ + } + return fixture.execute(command) + }, func() error { return nil }) + device, err := NewDevice(t.Context(), Identity{}, transport) + require.NoError(t, err) + value, err := device.Geometry(t.Context()) + require.Nil(t, value) + require.Equal(t, test.fatal, err != nil) + require.Equal(t, 1, queries) + require.Zero(t, cmCalls) + + // Failed preparation does not retry the count or start an image read; terminal cleanup keeps uncertainty. + _, _ = device.Geometry(t.Context()) + require.Equal(t, 1, queries) + require.Zero(t, cmCalls) + cleanup := device.Cleanup(t.Context()) + require.Equal(t, errors.Is(err, ErrUncertain), errors.Is(cleanup, ErrUncertain)) + }) + } +} + +func TestCartridgeDirectoryFailureBoundary(t *testing.T) { + for _, test := range []struct { + name string + response Response + err error + fatal bool + }{ + {name: "unsupported", response: Response{Completed: true, Status: 2, Sense: fixedSense(0x70, 5, 0x24, 0, false, 0, 0)}}, + {name: "short", response: Response{Completed: true, Residual: 1}}, + {name: "no memory", response: Response{Completed: true}}, + {name: "unknown completion", fatal: true}, + {name: "medium lost", response: Response{Completed: true, Status: 2, Sense: fixedSense(0x70, 2, 0x3a, 0, false, 0, 0)}, fatal: true}, + {name: "medium changed", response: Response{Completed: true, Status: 2, Sense: fixedSense(0x70, 6, 0x28, 0, false, 0, 0)}, fatal: true}, + {name: "canceled", err: context.Canceled, fatal: true}, + } { + t.Run(test.name, func(t *testing.T) { + // Failure happens only at the optional descriptor; no extra query or device action follows it. + fixture := newGeometryFixture() + fixture.vendor, fixture.unsupported = "HP", true + calls := 0 + transport := newSerializedTransport(func(command Command) (Response, error) { + if command.CDB[0] != 0x3c { + return fixture.execute(command) + } + calls++ + return test.response, test.err + }, func() error { return nil }) + device, err := NewDevice(t.Context(), Identity{}, transport) + require.NoError(t, err) + observed, err := device.Geometry(t.Context()) + require.Nil(t, observed) + require.Equal(t, test.fatal, err != nil) + require.Equal(t, 1, calls) + require.Contains(t, feature(device.Report(), entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_SOFTWARE_READ_ORDER).Detail, + "Cartridge memory directory is unavailable") + + // Uncertain completion is retained by the real transport through cleanup; optional absence is not. + uncertain := errors.Is(err, ErrUncertain) + _, _ = device.Geometry(t.Context()) + require.Equal(t, 1, calls) + cleanup := device.Cleanup(t.Context()) + require.Equal(t, uncertain, errors.Is(cleanup, ErrUncertain)) + }) + } +} + +func TestREOWPEndpointConventionUsesOnlyQualifiedHP5Counts(t *testing.T) { + // Count semantics are qualified for this observed response, not all HP or IBM generations. + report := &entity.InspectDeviceResponse{Vendor: proto.String("HP"), Model: proto.String("Ultrium 5-SCSI"), + Firmware: proto.String("Z6ED"), Report: &entity.InspectDeviceResponse_Tape{Tape: &entity.TapeDeviceReport{DensityCode: proto.Uint32(0x58)}}} + require.Equal(t, entity.TapePositionEndpointConvention_TAPE_POSITION_ENDPOINT_CONVENTION_EXCLUSIVE, REOWPEndpointConvention(report)) + for _, change := range []func(*entity.InspectDeviceResponse){ + func(r *entity.InspectDeviceResponse) { r.Vendor = proto.String("IBM") }, + func(r *entity.InspectDeviceResponse) { r.Model = proto.String("Ultrium 6-SCSI") }, + func(r *entity.InspectDeviceResponse) { r.Firmware = proto.String("J38W") }, + func(r *entity.InspectDeviceResponse) { r.GetTape().DensityCode = nil }, + } { + other := proto.Clone(report).(*entity.InspectDeviceResponse) + change(other) + require.Equal(t, entity.TapePositionEndpointConvention_TAPE_POSITION_ENDPOINT_CONVENTION_INCLUSIVE, REOWPEndpointConvention(other)) + } +} diff --git a/internal/media/tape/cm_read.go b/internal/media/tape/cm_read.go new file mode 100644 index 0000000..60bb922 --- /dev/null +++ b/internal/media/tape/cm_read.go @@ -0,0 +1,47 @@ +package tape + +import ( + "context" + "fmt" +) + +const cartridgeMemoryMaxBytes = 8192 + +func readCartridgeMemory(ctx context.Context, transport Transport) ([]byte, error) { + // Read the descriptor before allocating cartridge-supplied capacity; zero means unavailable. + descriptor, err := query(ctx, transport, []byte{0x3c, 3, 0x10, 0, 0, 0, 0, 0, 4, 0}, 4) + if err != nil { + return nil, fmt.Errorf("read cartridge memory descriptor failed, %w", err) + } + if len(descriptor) != 4 { + return nil, fmt.Errorf("cartridge memory descriptor has %d bytes, want 4: %w", len(descriptor), ErrMalformed) + } + size := int(descriptor[1])<<16 | int(descriptor[2])<<8 | int(descriptor[3]) + if size == 0 { + return nil, nil + } + if size > cartridgeMemoryMaxBytes { + return nil, fmt.Errorf("cartridge memory capacity %d exceeds %d bytes: %w", + size, cartridgeMemoryMaxBytes, ErrMalformed) + } + + // Keep every transfer within the existing query limit and publish only a complete image. + data := make([]byte, size) + cdb := []byte{0x3c, 2, 0x10, 0, 0, 0, 0, 0, 0, 0} + for offset := 0; offset < size; { + length := min(size-offset, MaxQueryBytes) + cdb[3], cdb[4], cdb[5] = byte(offset>>16), byte(offset>>8), byte(offset) + cdb[6], cdb[7], cdb[8] = byte(length>>16), byte(length>>8), byte(length) + chunk, err := query(ctx, transport, cdb, length) + if err != nil { + return nil, fmt.Errorf("read cartridge memory at offset %d failed, %w", offset, err) + } + if len(chunk) != length { + return nil, fmt.Errorf("cartridge memory at offset %d has %d bytes, want %d: %w", + offset, len(chunk), length, ErrMalformed) + } + copy(data[offset:], chunk) + offset += length + } + return data, nil +} diff --git a/internal/media/tape/cm_read_test.go b/internal/media/tape/cm_read_test.go new file mode 100644 index 0000000..6684488 --- /dev/null +++ b/internal/media/tape/cm_read_test.go @@ -0,0 +1,228 @@ +package tape + +import ( + "bytes" + "context" + "errors" + "fmt" + "strings" + "testing" + "time" +) + +func TestReadCartridgeMemoryChunks(t *testing.T) { + // The documented 8,160-byte image requires a full query buffer and a 4,064-byte tail. + want := make([]byte, 8160) + for i := range want { + want[i] = byte(i*31 + 7) + } + cdbs := [][]byte{ + {0x3c, 3, 0x10, 0, 0, 0, 0, 0, 4, 0}, + {0x3c, 2, 0x10, 0, 0, 0, 0, 0x10, 0, 0}, + {0x3c, 2, 0x10, 0, 0x10, 0, 0, 0x0f, 0xe0, 0}, + } + responses := [][]byte{{0, 0, 0x1f, 0xe0}, want[:4096], want[4096:]} + calls := 0 + transport := newSerializedTransport(func(command Command) (Response, error) { + if calls >= len(cdbs) || !bytes.Equal(command.CDB, cdbs[calls]) || + len(command.Data) != len(responses[calls]) || command.Direction != FromDevice || + command.Timeout != 2*time.Second || !passiveCommand(command) { + t.Fatalf("command %d: CDB=%x bytes=%d timeout=%s", calls, command.CDB, len(command.Data), command.Timeout) + } + copy(command.Data, responses[calls]) + calls++ + return Response{Completed: true}, nil + }, func() error { return nil }) + defer transport.Close() + + // Real query validation and transport serialization produce one complete image, without motion. + got, err := readCartridgeMemory(t.Context(), transport) + if err != nil || calls != 3 || !bytes.Equal(got, want) { + t.Fatalf("bytes=%d calls=%d err=%v", len(got), calls, err) + } +} + +func TestReadCartridgeMemoryCapacity(t *testing.T) { + for _, test := range []struct { + capacity, calls int + malformed bool + }{ + {0, 1, false}, {1, 2, false}, {4096, 2, false}, {4097, 3, false}, {8192, 3, false}, + {8193, 1, true}, {65536, 1, true}, {0xffffff, 1, true}, + } { + t.Run(fmt.Sprint(test.capacity), func(t *testing.T) { + // Capacity occupies all three descriptor bytes; rejected sizes never request image data. + calls := 0 + transport := newSerializedTransport(func(command Command) (Response, error) { + calls++ + if calls == 1 { + copy(command.Data, []byte{0, byte(test.capacity >> 16), byte(test.capacity >> 8), byte(test.capacity)}) + } + return Response{Completed: true}, nil + }, func() error { return nil }) + defer transport.Close() + + // Unavailable memory remains absent; supported sizes include the exact 8 KiB ceiling. + data, err := readCartridgeMemory(t.Context(), transport) + if calls != test.calls || errors.Is(err, ErrMalformed) != test.malformed { + t.Fatalf("calls=%d err=%v", calls, err) + } + if test.malformed || test.capacity == 0 { + if data != nil || test.capacity == 0 && err != nil { + t.Fatalf("unavailable image returned %d bytes, err=%v", len(data), err) + } + } else if err != nil || len(data) != test.capacity { + t.Fatalf("bytes=%d err=%v", len(data), err) + } + }) + } +} + +func TestReadCartridgeMemoryFailures(t *testing.T) { + transportErr := errors.New("fixture transport error") + for _, test := range []struct { + name string + failAt int + response Response + err, want error + message string + unsupported bool + }{ + {name: "empty descriptor", failAt: 1, response: Response{Completed: true, Residual: 4}, + want: ErrMalformed, message: "has 0 bytes, want 4"}, + {name: "short descriptor", failAt: 1, response: Response{Completed: true, Residual: 1}, + want: ErrMalformed, message: "has 3 bytes, want 4"}, + {name: "short first chunk", failAt: 2, response: Response{Completed: true, Residual: 1}, + want: ErrMalformed, message: "offset 0 has 4095 bytes, want 4096"}, + {name: "short last chunk", failAt: 3, response: Response{Completed: true, Residual: 1}, + want: ErrMalformed, message: "offset 4096 has 4063 bytes, want 4064"}, + {name: "absent descriptor completion", failAt: 1, want: ErrUncertain}, + {name: "absent first chunk completion", failAt: 2, want: ErrUncertain}, + {name: "absent last chunk completion", failAt: 3, want: ErrUncertain}, + {name: "host completion error", failAt: 2, response: Response{Completed: true, HostStatus: 3}, want: ErrUncertain}, + {name: "invalid residual", failAt: 2, response: Response{Completed: true, Residual: -1}, want: ErrUncertain}, + {name: "transport error", failAt: 2, response: Response{Completed: true}, err: transportErr, want: transportErr}, + {name: "unsupported descriptor", failAt: 1, unsupported: true, + response: Response{Completed: true, Status: 2, Sense: fixedSense(0x70, 5, 0x20, 0, false, 0, 0)}}, + } { + t.Run(test.name, func(t *testing.T) { + // Faults at any boundary must return no partial image and must not retry another form. + calls := 0 + transport := newSerializedTransport(func(command Command) (Response, error) { + calls++ + if calls == test.failAt { + return test.response, test.err + } + if calls == 1 { + copy(command.Data, []byte{0, 0, 0x1f, 0xe0}) + } + return Response{Completed: true}, nil + }, func() error { return nil }) + defer transport.Close() + data, err := readCartridgeMemory(t.Context(), transport) + if data != nil || err == nil || calls != test.failAt || + test.want != nil && !errors.Is(err, test.want) || !strings.Contains(err.Error(), test.message) || + CleanUnsupported(err) != test.unsupported { + t.Fatalf("bytes=%d calls=%d err=%v", len(data), calls, err) + } + + // Unknown completion retains the transport's existing refusal of later device commands. + if errors.Is(err, ErrUncertain) { + _, nextErr := readCartridgeMemory(t.Context(), transport) + if !errors.Is(nextErr, ErrUncertain) || calls != test.failAt { + t.Fatalf("later read err=%v calls=%d", nextErr, calls) + } + } + }) + } +} + +func TestReadCartridgeMemoryCancellation(t *testing.T) { + for _, at := range []int{0, 1, 2} { + t.Run(fmt.Sprint(at), func(t *testing.T) { + // Cancellation before admission or during a settled command never starts the next chunk. + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + if at == 0 { + cancel() + } + calls := 0 + transport := newSerializedTransport(func(command Command) (Response, error) { + calls++ + if calls == at { + cancel() + } + if calls == 1 { + copy(command.Data, []byte{0, 0, 0x1f, 0xe0}) + } + return Response{Completed: true}, nil + }, func() error { return nil }) + defer transport.Close() + data, err := readCartridgeMemory(ctx, transport) + if data != nil || !errors.Is(err, context.Canceled) || calls != at { + t.Fatalf("bytes=%d calls=%d err=%v", len(data), calls, err) + } + }) + } +} + +func TestPassiveCartridgeMemoryCommands(t *testing.T) { + // Valid descriptor and data forms retain the existing 4 KiB individual query ceiling. + for _, test := range []struct { + cdb []byte + bytes int + }{ + {[]byte{0x3c, 3, 0x10, 0, 0, 0, 0, 0, 4, 0}, 4}, + {[]byte{0x3c, 2, 0x10, 0, 0, 0, 0, 0x10, 0, 0}, 4096}, + {[]byte{0x3c, 2, 0x10, 0, 0x10, 0, 0, 0x0f, 0xe0, 0}, 4064}, + {[]byte{0x3c, 2, 0x10, 0, 0x10, 0, 0, 0x10, 0, 0}, 4096}, + {[]byte{0x3c, 2, 0x10, 0, 0x1f, 0xff, 0, 0, 1, 0}, 1}, + } { + if !passiveCommand(Command{CDB: test.cdb, Data: make([]byte, test.bytes), Direction: FromDevice}) { + t.Fatalf("valid READ BUFFER rejected: %x", test.cdb) + } + } + + // Unsupported modes, reserved fields and mismatched or out-of-bounds ranges stay forbidden. + for _, test := range []struct { + name string + cdb []byte + bytes int + }{ + {"write buffer", []byte{0x3b, 2, 0x10, 0, 0, 0, 0, 0, 4, 0}, 4}, + {"combined mode", []byte{0x3c, 0, 0x10, 0, 0, 0, 0, 0, 4, 0}, 4}, + {"echo mode", []byte{0x3c, 0x0a, 0x10, 0, 0, 0, 0, 0, 4, 0}, 4}, + {"mode reserved bit", []byte{0x3c, 0x22, 0x10, 0, 0, 0, 0, 0, 4, 0}, 4}, + {"descriptor reserved bit", []byte{0x3c, 0x23, 0x10, 0, 0, 0, 0, 0, 4, 0}, 4}, + {"other buffer", []byte{0x3c, 2, 0x11, 0, 0, 0, 0, 0, 4, 0}, 4}, + {"control", []byte{0x3c, 2, 0x10, 0, 0, 0, 0, 0, 4, 1}, 4}, + {"descriptor high offset", []byte{0x3c, 3, 0x10, 1, 0, 0, 0, 0, 4, 0}, 4}, + {"descriptor middle offset", []byte{0x3c, 3, 0x10, 0, 1, 0, 0, 0, 4, 0}, 4}, + {"descriptor low offset", []byte{0x3c, 3, 0x10, 0, 0, 1, 0, 0, 4, 0}, 4}, + {"short descriptor", []byte{0x3c, 3, 0x10, 0, 0, 0, 0, 0, 3, 0}, 3}, + {"long descriptor", []byte{0x3c, 3, 0x10, 0, 0, 0, 0, 0, 5, 0}, 5}, + {"short CDB", []byte{0x3c, 3, 0x10, 0, 0, 0, 0, 0, 4}, 4}, + {"long CDB", []byte{0x3c, 3, 0x10, 0, 0, 0, 0, 0, 4, 0, 0}, 4}, + {"allocation mismatch", []byte{0x3c, 2, 0x10, 0, 0, 0, 0, 0, 4, 0}, 3}, + {"empty allocation", []byte{0x3c, 2, 0x10, 0, 0, 0, 0, 0, 0, 0}, 0}, + {"high allocation", []byte{0x3c, 2, 0x10, 0, 0, 0, 1, 0, 4, 0}, 4}, + {"transfer limit", []byte{0x3c, 2, 0x10, 0, 0, 0, 0, 0x10, 1, 0}, 4097}, + {"high offset", []byte{0x3c, 2, 0x10, 1, 0, 0, 0, 0, 4, 0}, 4}, + {"offset at end", []byte{0x3c, 2, 0x10, 0, 0x20, 0, 0, 0, 1, 0}, 1}, + {"range past end", []byte{0x3c, 2, 0x10, 0, 0x10, 1, 0, 0x10, 0, 0}, 4096}, + } { + t.Run(test.name, func(t *testing.T) { + if passiveCommand(Command{CDB: test.cdb, Data: make([]byte, test.bytes), Direction: FromDevice}) { + t.Fatalf("unsafe READ BUFFER admitted: %x", test.cdb) + } + }) + } + + // Changing direction cannot turn the same allowlisted fields into a write or no-data command. + for _, direction := range []Direction{ToDevice, NoData} { + command := Command{CDB: []byte{0x3c, 3, 0x10, 0, 0, 0, 0, 0, 4, 0}, Data: make([]byte, 4), Direction: direction} + if passiveCommand(command) { + t.Fatalf("unsafe READ BUFFER direction admitted: %d", direction) + } + } +} diff --git a/internal/media/tape/compression.go b/internal/media/tape/compression.go new file mode 100644 index 0000000..0d45bc8 --- /dev/null +++ b/internal/media/tape/compression.go @@ -0,0 +1,224 @@ +package tape + +import ( + "bytes" + "encoding/binary" + "errors" + "fmt" + + "github.com/samuelncui/yatm/entity" + "google.golang.org/protobuf/proto" +) + +func compressionState(pages map[byte][]byte) (*bool, *bool) { + // MP0F separates write compression from read decoding; the legacy selector must agree when present. + page, configuration := pages[0x0f], pages[0x10] + if len(page) >= 16 { + enabled := page[2]&0x80 != 0 + decode := proto.Bool(page[3]&0x80 != 0) + if enabled && page[2]&0x40 == 0 || len(configuration) >= 16 && (configuration[14] > 1 || (configuration[14] == 1) != enabled) { + return nil, decode + } + return proto.Bool(enabled), decode + } + if len(configuration) >= 16 && configuration[14] <= 1 { + return proto.Bool(configuration[14] == 1), nil + } + return nil, nil +} + +func (d *Device) refreshCompressionAlgorithms(pages map[byte][]byte) { + // Current-PC observations update IDs without replacing prior masks, support or physical validation. + for _, kind := range []entity.DeviceFeatureKind{entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_HARDWARE_COMPRESSION, entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_HARDWARE_DECOMPRESSION} { + limits := feature(d.report, kind).GetCompression() + if limits == nil { + continue + } + limits.CurrentAlgorithm = nil + if page := pages[0x0f]; len(page) >= 16 { + index := 4 + if kind == entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_HARDWARE_DECOMPRESSION { + index = 8 + } + limits.CurrentAlgorithm = proto.Uint32(binary.BigEndian.Uint32(page[index : index+4])) + } + } +} + +func (d *Device) reportCompression(pages, changes map[byte][]byte, changeErr error) { + // Current algorithm IDs are observations, not an inferred supported-algorithm list or qualification. + page := pages[0x0f] + for _, kind := range []entity.DeviceFeatureKind{entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_HARDWARE_COMPRESSION, entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_HARDWARE_DECOMPRESSION} { + f := feature(d.report, kind) + f.Support, f.Availability = entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_UNKNOWN, entity.DeviceFeatureAvailability_DEVICE_FEATURE_AVAILABILITY_UNKNOWN + f.Evidence, f.Validation = entity.DeviceFeatureEvidence_DEVICE_FEATURE_EVIDENCE_UNSPECIFIED, entity.DeviceFeatureValidation_DEVICE_FEATURE_VALIDATION_UNVALIDATED + f.Reason, f.Detail, f.Limits = entity.DeviceReason_DEVICE_REASON_NOT_READY, "Compression mode prerequisites are not established", nil + if len(page) < 16 { + continue + } + limits := new(entity.TapeCompressionLimits) + f.Limits = &entity.DeviceFeature_Compression{Compression: limits} + f.Evidence = entity.DeviceFeatureEvidence_DEVICE_FEATURE_EVIDENCE_REPORTED + index, mask, algorithm := 3, byte(0x80), 8 + if kind == entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_HARDWARE_COMPRESSION { + index, algorithm = 2, 4 + limits.CurrentAlgorithm = proto.Uint32(binary.BigEndian.Uint32(page[4:8])) + if page[2]&0x40 == 0 { + f.Support, f.Availability, f.Reason = entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_UNSUPPORTED, entity.DeviceFeatureAvailability_DEVICE_FEATURE_AVAILABILITY_UNAVAILABLE, entity.DeviceReason_DEVICE_REASON_UNSUPPORTED + f.Detail = "DCC reports no hardware write compression capability" + continue + } + } + limits.CurrentAlgorithm = proto.Uint32(binary.BigEndian.Uint32(page[algorithm : algorithm+4])) + f.Support = entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_SUPPORTED + if changeErr == nil && len(changes[0x0f]) >= 16 { + limits.EnableChangeable = proto.Bool(changes[0x0f][index]&mask != 0) + } + if page[index]&mask != 0 || limits.GetEnableChangeable() { + f.Availability, f.Reason = entity.DeviceFeatureAvailability_DEVICE_FEATURE_AVAILABILITY_READY, 0 + } + f.Detail = "Current standard compression controls observed; physical behavior and cleanup remain unvalidated" + if index == 2 && (d.report.GetTape().CompressionEnabled == nil || limits.GetCurrentAlgorithm() == 0) { + f.Availability, f.Reason = entity.DeviceFeatureAvailability_DEVICE_FEATURE_AVAILABILITY_UNKNOWN, entity.DeviceReason_DEVICE_REASON_NOT_READY + } + if index == 3 && page[3]&0x60 != 0 { + if page[3]&0x60 == 0x60 || len(changes[0x0f]) < 16 || page[3]&0x60&^changes[0x0f][3] != 0 { + f.Availability, f.Reason = entity.DeviceFeatureAvailability_DEVICE_FEATURE_AVAILABILITY_UNKNOWN, entity.DeviceReason_DEVICE_REASON_NOT_READY + } + } + if index == 2 && !d.report.GetTape().GetCompressionEnabled() && len(pages[0x10]) >= 16 && (len(changes[0x10]) < 16 || changes[0x10][14]&1 == 0) { + f.Availability, f.Reason = entity.DeviceFeatureAvailability_DEVICE_FEATURE_AVAILABILITY_UNKNOWN, entity.DeviceReason_DEVICE_REASON_NOT_READY + f.Detail = "Write enable-bit changeability is observed; required legacy-selector transition is not established" + } + if changeErr != nil { + f.Detail = fmt.Sprintf("Current standard compression controls observed; changeability query failed: %v", changeErr) + if errors.Is(changeErr, ErrUncertain) { + f.Availability, f.Reason = entity.DeviceFeatureAvailability_DEVICE_FEATURE_AVAILABILITY_UNAVAILABLE, Reason(changeErr) + } + } + } +} + +func compressionPolicy(pages map[byte][]byte, enabled bool, operation Operation) ([]byte, []byte, error) { + // READ needs only applicable decoding; FORMAT/APPEND must establish the explicit write policy. + page, configuration := pages[0x0f], pages[0x10] + if len(page) != 0 && len(page) < 16 || len(configuration) < 16 { + return nil, nil, ErrMalformed + } + compression := append([]byte(nil), page...) + if operation == Read { + if !enabled { + return nil, nil, nil + } + if len(compression) == 0 { + return nil, nil, fmt.Errorf("required hardware decompression controls are unavailable: %w", ErrUnsupported) + } + if compression[3]&0x60 == 0x60 { + return nil, nil, fmt.Errorf("reserved decompression exception policy: %w", ErrMalformed) + } + if compression[3]&0xe0 == 0x80 { + return nil, nil, nil + } + compression[3] = compression[3]&^0x60 | 0x80 + return compression, nil, nil + } + if configuration[14] > 1 { + return nil, nil, fmt.Errorf("active legacy compression policy is unknown") + } + if active, _ := compressionState(pages); active == nil { + return nil, nil, fmt.Errorf("active hardware compression state is unknown") + } + if enabled && (len(compression) == 0 || compression[2]&0x40 == 0 || binary.BigEndian.Uint32(compression[4:8]) == 0) { + return nil, nil, fmt.Errorf("required hardware compression algorithm is unavailable: %w", ErrUnsupported) + } + if !enabled && configuration[14] == 0 && (len(compression) == 0 || compression[2]&0xc0 == 0) { + return nil, nil, nil + } + configuration = append([]byte(nil), configuration...) + configuration[14] = 0 + if len(compression) != 0 { + compression[2] &^= 0x80 + } + if enabled { + configuration[14] = 1 + compression[2] |= 0x80 + } + return compression, configuration, nil +} + +func checkCompressionChanges(current, desired, changes map[byte][]byte) error { + // Only requested DCE/DDE/RED and legacy-selector differences require writable masks. + for _, code := range []byte{0x0f, 0x10} { + page := desired[code] + if len(page) == 0 { + continue + } + before, mask := current[code], changes[code] + if len(before) != len(page) { + return fmt.Errorf("required compression changeability is unknown") + } + indices := []int{2, 3} + if code == 0x10 { + indices = []int{14} + } + for _, index := range indices { + if before[index] == page[index] { + continue + } + if len(mask) != len(page) { + return fmt.Errorf("required compression changeability is unknown") + } + if (before[index]^page[index])&^mask[index] != 0 { + return fmt.Errorf("required compression controls are not changeable: %w", ErrUnsupported) + } + } + } + return nil +} + +func compressionControlsDiffer(current, desired map[byte][]byte) bool { + // Algorithm selection is never changed; only required enable and exception controls trigger a mask query. + if page := desired[0x0f]; len(page) != 0 && (current[0x0f][2] != page[2] || current[0x0f][3] != page[3]) { + return true + } + return len(desired[0x10]) != 0 && current[0x10][14] != desired[0x10][14] +} + +func checkCompressionReadback(pages, desired map[byte][]byte) error { + // The selected compression algorithm is preserved; last-read decompression IDs may change automatically. + if page := desired[0x0f]; len(page) != 0 { + actual := pages[0x0f] + if len(actual) < 16 || actual[2]&0xc0 != page[2]&0xc0 || actual[3]&0xe0 != page[3]&0xe0 || !bytes.Equal(actual[4:8], page[4:8]) { + return fmt.Errorf("Tape compression mode readback differs") + } + } + if page := desired[0x10]; len(page) != 0 { + if len(pages[0x10]) < 16 || pages[0x10][14] != page[14] { + return fmt.Errorf("Tape compression selector readback differs") + } + } + return nil +} + +func checkCapturedWriteAlgorithm(pages, original map[byte][]byte) error { + // CA never changes through our policy or a read; only DA is allowed to follow last-read detection. + if len(original[0x0f]) != 0 && (len(pages[0x0f]) < 16 || !bytes.Equal(pages[0x0f][4:8], original[0x0f][4:8])) { + return fmt.Errorf("captured write compression algorithm is not established") + } + return nil +} + +func checkUntouchedCompression(pages, original map[byte][]byte, changed uint16, selectorChanged bool) error { + // Unrequested enable/exception changes are not silently copied into a supposedly restored session. + if page := original[0x0f]; len(page) >= 16 && len(pages[0x0f]) >= 16 { + if (pages[0x0f][2]^page[2])&0x80&^byte(changed) != 0 || (pages[0x0f][3]^page[3])&0xe0&^byte(changed>>8) != 0 { + return fmt.Errorf("untouched compression controls changed") + } + } + if page := original[0x10]; len(page) >= 16 && !selectorChanged { + if len(pages[0x10]) < 16 || pages[0x10][14] != page[14] { + return fmt.Errorf("untouched compression selector changed") + } + } + return nil +} diff --git a/internal/media/tape/compression_inspect_test.go b/internal/media/tape/compression_inspect_test.go new file mode 100644 index 0000000..4eaa6d8 --- /dev/null +++ b/internal/media/tape/compression_inspect_test.go @@ -0,0 +1,143 @@ +package tape + +import ( + "context" + "encoding/binary" + "testing" + + "github.com/samuelncui/yatm/entity" +) + +func TestCompressionReportKeepsCurrentAlgorithmsAndChangeabilityDistinct(t *testing.T) { + // Current page values and PC1 bit masks have independent meanings, including observed algorithm zero. + f := newDeviceFixture() + f.mode[18], f.mode[46] = 0x40, 0 + binary.BigEndian.PutUint32(f.mode[20:24], 0xff) + binary.BigEndian.PutUint32(f.mode[24:28], 0) + transport := newSerializedTransport(func(command Command) (Response, error) { + response, err := f.execute(command) + if command.CDB[0] == 0x5a && command.CDB[2] == 0x7f { + command.Data[19] = 0 + binary.BigEndian.PutUint32(command.Data[20:24], 0) + binary.BigEndian.PutUint32(command.Data[24:28], 0xffffffff) + // A missing selector mask does not turn the separately observed DCE mask into false. + binary.BigEndian.PutUint16(command.Data[:2], 30) + } + return response, err + }, func() error { return nil }) + defer transport.Close() + report, err := inspectTransport(context.Background(), Identity{}, transport) + write := feature(report, entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_HARDWARE_COMPRESSION) + read := feature(report, entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_HARDWARE_DECOMPRESSION) + if err != nil || report.Reason != 0 || write.GetCompression().GetCurrentAlgorithm() != 0xff || write.GetCompression().EnableChangeable == nil || !write.GetCompression().GetEnableChangeable() || write.Availability != entity.DeviceFeatureAvailability_DEVICE_FEATURE_AVAILABILITY_UNKNOWN { + t.Fatalf("write=%v overall=%v err=%v", write, report.Reason, err) + } + if read.GetCompression().CurrentAlgorithm == nil || read.GetCompression().GetCurrentAlgorithm() != 0 || read.GetCompression().EnableChangeable == nil || read.GetCompression().GetEnableChangeable() || read.Availability != entity.DeviceFeatureAvailability_DEVICE_FEATURE_AVAILABILITY_READY { + t.Fatalf("read=%v", read) + } + if write.Validation != entity.DeviceFeatureValidation_DEVICE_FEATURE_VALIDATION_UNVALIDATED || read.Validation != entity.DeviceFeatureValidation_DEVICE_FEATURE_VALIDATION_UNVALIDATED { + t.Fatal("software observation fabricated physical qualification") + } +} + +func TestCompressionOptionalMaskFailuresDoNotBlockCapacityOrBaseline(t *testing.T) { + for _, malformed := range []bool{false, true} { + // A fresh optional mask failure leaves current controls observable and changeability absent. + queries := 0 + transport := newSerializedTransport(func(command Command) (Response, error) { + if command.CDB[0] == 0x5a && command.CDB[2] == 0x7f { + if malformed { + return Response{Completed: true, Residual: int32(len(command.Data) - 1)}, nil + } + return Response{Completed: true, Status: 2, Sense: fixedSense(0x70, 5, 0x24, 0, false, 0, 0)}, nil + } + if command.CDB[0] == 0x4d { + queries++ + } + return inspectionFixture(command) + }, func() error { return nil }) + defer transport.Close() + report, err := inspectTransport(context.Background(), Identity{}, transport) + for _, kind := range []entity.DeviceFeatureKind{entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_HARDWARE_COMPRESSION, entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_HARDWARE_DECOMPRESSION} { + f := feature(report, kind) + if err != nil || report.Reason != 0 || queries != 1 || f.Support != entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_SUPPORTED || f.GetCompression() == nil || f.GetCompression().EnableChangeable != nil || f.GetCompression().GetCurrentAlgorithm() != 1 { + t.Fatalf("malformed=%t feature=%v overall=%v err=%v queries=%d", malformed, f, report.Reason, err, queries) + } + } + } +} + +func TestCompressionDecoderDoesNotRequireWriteCapability(t *testing.T) { + // DCC zero conclusively rejects write compression while an enabled independent DDE remains available. + f := newDeviceFixture() + f.mode[18], f.mode[46] = 0, 0 + d := fixtureDevice(t, f) + write := feature(d.Report(), entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_HARDWARE_COMPRESSION) + read := feature(d.Report(), entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_HARDWARE_DECOMPRESSION) + if write.Support != entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_UNSUPPORTED || read.Support != entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_SUPPORTED || read.Availability != entity.DeviceFeatureAvailability_DEVICE_FEATURE_AVAILABILITY_READY { + t.Fatalf("write=%v read=%v", write, read) + } + _, err := d.Configure(context.Background(), &entity.TarMediaProfile{RecordMode: entity.TapeRecordMode_TAPE_RECORD_MODE_FIXED, RecordBytes: 512, HardwareCompression: true}, Read) + if err != nil || f.selects != 0 { + t.Fatalf("read=%v selects=%d", err, f.selects) + } + if err := d.Cleanup(context.Background()); err != nil { + t.Fatal(err) + } +} + +func TestCompressionReadinessLossBlocksDirectPreparation(t *testing.T) { + for _, noMedium := range []bool{false, true} { + // A late actual readiness rejection invalidates the medium, unlike optional unsupported/malformed masks. + f := newDeviceFixture() + transport := newSerializedTransport(func(command Command) (Response, error) { + if command.CDB[0] == 0x5a && command.CDB[2] == 0x7f { + asc := byte(4) + if noMedium { + asc = 0x3a + } + return Response{Completed: true, Status: 2, Sense: fixedSense(0x70, 2, asc, 0, false, 0, 0)}, nil + } + return f.execute(command) + }, func() error { f.closed++; return nil }) + d, err := NewDevice(context.Background(), Identity{}, transport) + if err != nil { + t.Fatal(err) + } + report := d.Report() + want := entity.DeviceReason_DEVICE_REASON_NOT_READY + if noMedium { + want = entity.DeviceReason_DEVICE_REASON_NO_MEDIUM + } + _, err = d.Configure(context.Background(), &entity.TarMediaProfile{RecordMode: entity.TapeRecordMode_TAPE_RECORD_MODE_VARIABLE, RecordBytes: 512}, Format) + failing := feature(report, entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_HARDWARE_COMPRESSION) + if err == nil || report.Reason != want || report.GetTape().GetMediumPresent() == noMedium || failing.Availability != entity.DeviceFeatureAvailability_DEVICE_FEATURE_AVAILABILITY_UNAVAILABLE || f.selects != 0 || f.writes != 0 { + t.Fatalf("preparation=%v report=%v selects=%d writes=%d", err, report, f.selects, f.writes) + } + } +} + +func TestCompressionCurrentModeRefreshDoesNotUseChangeableValues(t *testing.T) { + // An unchanged preparation still observes fresh current IDs; masks never replace those IDs. + f := newDeviceFixture() + d := fixtureDevice(t, f) + binary.BigEndian.PutUint32(f.mode[20:24], 0xff) + binary.BigEndian.PutUint32(f.mode[24:28], 0) + _, err := d.Configure(context.Background(), &entity.TarMediaProfile{RecordMode: entity.TapeRecordMode_TAPE_RECORD_MODE_FIXED, RecordBytes: 512, HardwareCompression: true}, Read) + if err != nil || f.selects != 0 { + t.Fatalf("read=%v selects=%d", err, f.selects) + } + binary.BigEndian.PutUint32(f.mode[20:24], 7) + binary.BigEndian.PutUint32(f.mode[24:28], 8) + if _, _, err := d.readMode(context.Background(), true); err != nil { + t.Fatal(err) + } + write := feature(d.Report(), entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_HARDWARE_COMPRESSION) + read := feature(d.Report(), entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_HARDWARE_DECOMPRESSION) + if write.GetCompression().GetCurrentAlgorithm() != 0xff || read.GetCompression().CurrentAlgorithm == nil || read.GetCompression().GetCurrentAlgorithm() != 0 || write.GetCompression().EnableChangeable == nil || read.GetCompression().EnableChangeable == nil || write.Support != entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_SUPPORTED || read.Validation != entity.DeviceFeatureValidation_DEVICE_FEATURE_VALIDATION_UNVALIDATED { + t.Fatalf("current IDs or prior qualification changed: write=%v read=%v", write, read) + } + if err := d.Cleanup(context.Background()); err != nil { + t.Fatal(err) + } +} diff --git a/internal/media/tape/compression_test.go b/internal/media/tape/compression_test.go new file mode 100644 index 0000000..2174c0f --- /dev/null +++ b/internal/media/tape/compression_test.go @@ -0,0 +1,341 @@ +package tape + +import ( + "context" + "encoding/binary" + "errors" + "testing" + + "github.com/samuelncui/yatm/entity" +) + +func TestCompressionWritesApplyCoherentPolicyAndRestore(t *testing.T) { + for _, algorithm := range []uint32{1, 0xff} { + for _, enabled := range []bool{false, true} { + // Actual standard IDs remain opaque; MP10 cannot override an explicit off request. + f := newDeviceFixture() + binary.BigEndian.PutUint32(f.mode[20:24], algorithm) + binary.BigEndian.PutUint32(f.mode[24:28], algorithm) + if enabled { + f.mode[18], f.mode[46] = 0x40, 0 + } + d := fixtureDevice(t, f) + _, err := d.Configure(context.Background(), &entity.TarMediaProfile{RecordMode: entity.TapeRecordMode_TAPE_RECORD_MODE_VARIABLE, RecordBytes: 512, HardwareCompression: enabled}, Append) + if err != nil || (f.mode[18]&0x80 != 0) != enabled || (f.mode[46] == 1) != enabled || binary.BigEndian.Uint32(f.mode[20:24]) != algorithm || f.writes != 0 || f.motions != 0 { + t.Fatalf("algorithm=%x enabled=%t err=%v mode=%x", algorithm, enabled, err, f.mode) + } + + // Cleanup restores the captured write policy and preserves fresh unrelated device configuration. + f.mode[35], f.mode[38], f.mode[42] = 7, 0x12, 0x18 + if err := d.Cleanup(context.Background()); err != nil || (f.mode[18]&0x80 != 0) == enabled || (f.mode[46] == 1) == enabled || f.mode[35] != 7 || f.mode[38] != 0x12 || f.mode[42] != 0x18 || f.selects != 2 || f.closed != 1 { + t.Fatalf("cleanup=%v mode=%x selects=%d", err, f.mode, f.selects) + } + } + } +} + +func TestKnownAbsentCompressionOffNeedsNoCompressionControl(t *testing.T) { + // A complete current-mode response establishes legacy off without requiring a missing MP0F page. + f := newDeviceFixture() + f.mode = append(f.mode[:16], f.mode[32:]...) + binary.BigEndian.PutUint16(f.mode, uint16(len(f.mode)-2)) + f.mode[30] = 0 + transport := newSerializedTransport(func(command Command) (Response, error) { + if command.CDB[0] == 0x55 { + pages, err := selectionPages(command.Data) + if err != nil || len(pages[0x0f]) != 0 || len(pages[0x10]) != 0 { + t.Fatalf("known off selected unsupported compression controls: pages=%v err=%v", pages, err) + } + } + return f.execute(command) + }, func() error { f.closed++; return nil }) + d, err := NewDevice(context.Background(), Identity{}, transport) + if err != nil { + t.Fatal(err) + } + _, err = d.Configure(context.Background(), &entity.TarMediaProfile{RecordMode: entity.TapeRecordMode_TAPE_RECORD_MODE_VARIABLE, RecordBytes: 512}, Append) + if err != nil || f.selects != 1 || f.writes != 0 { + t.Fatalf("off=%v selects=%d mode=%x", err, f.selects, f.mode) + } + if err := d.Cleanup(context.Background()); err != nil || blockLength(f.mode) != 512 || f.mode[30] != 0 { + t.Fatalf("cleanup=%v mode=%x", err, f.mode) + } +} + +func TestKnownDCCZeroOffNeedsNoCompressionSelection(t *testing.T) { + // DCC zero with current DCE/selector off permits record-only writes without unsupported compression controls. + f := newDeviceFixture() + f.mode[18], f.mode[46] = 0, 0 + transport := newSerializedTransport(func(command Command) (Response, error) { + if command.CDB[0] == 0x55 { + pages, err := selectionPages(command.Data) + if err != nil || len(pages[0x0f]) != 0 || len(pages[0x10]) != 0 { + t.Fatalf("DCC zero selected compression controls: pages=%v err=%v", pages, err) + } + } + return f.execute(command) + }, func() error { f.closed++; return nil }) + d, err := NewDevice(context.Background(), Identity{}, transport) + if err != nil { + t.Fatal(err) + } + _, err = d.Configure(context.Background(), &entity.TarMediaProfile{RecordMode: entity.TapeRecordMode_TAPE_RECORD_MODE_VARIABLE, RecordBytes: 512}, Append) + if err != nil || f.selects != 1 || f.writes != 0 { + t.Fatalf("off=%v selects=%d writes=%d", err, f.selects, f.writes) + } + if err := d.Cleanup(context.Background()); err != nil || f.selects != 2 || f.mode[18] != 0 || f.mode[46] != 0 { + t.Fatalf("cleanup=%v mode=%x selects=%d", err, f.mode, f.selects) + } +} + +func TestUntouchedCompressionDriftRetainsCleanupUncertainty(t *testing.T) { + for _, selector := range []bool{false, true} { + // An actual record-only selection must not change untouched controls and then release as restored. + f := newDeviceFixture() + transport := newSerializedTransport(func(command Command) (Response, error) { + response, err := f.execute(command) + if command.CDB[0] == 0x55 && f.selects == 1 { + if selector { + f.mode[46] = 0 + } else { + f.mode[19] = 0 + } + } + return response, err + }, func() error { f.closed++; return nil }) + d, err := NewDevice(context.Background(), Identity{}, transport) + if err != nil { + t.Fatal(err) + } + _, err = d.Configure(context.Background(), &entity.TarMediaProfile{RecordMode: entity.TapeRecordMode_TAPE_RECORD_MODE_VARIABLE, RecordBytes: 512, HardwareCompression: true}, Read) + if !errors.Is(err, ErrUncertain) || f.selects != 1 || f.writes != 0 || f.closed != 1 { + t.Fatalf("selector=%t failure=%v selects=%d writes=%d closed=%d", selector, err, f.selects, f.writes, f.closed) + } + } +} + +func TestReadDecoderRejectsReservedOrUnchangeableControls(t *testing.T) { + for _, reserved := range []bool{false, true} { + // Required decoding cannot normalize a reserved original or enable a nonchangeable disabled decoder. + f := newDeviceFixture() + f.mode[19] = 0 + if reserved { + f.mode[19] = 0x60 + } + transport := newSerializedTransport(func(command Command) (Response, error) { + response, err := f.execute(command) + if command.CDB[0] == 0x5a && command.CDB[2] == 0x7f { + command.Data[19] = 0 + } + return response, err + }, func() error { f.closed++; return nil }) + d, err := NewDevice(context.Background(), Identity{}, transport) + if err != nil { + t.Fatal(err) + } + _, err = d.Configure(context.Background(), &entity.TarMediaProfile{RecordMode: entity.TapeRecordMode_TAPE_RECORD_MODE_FIXED, RecordBytes: 512, HardwareCompression: true}, Read) + if err == nil || f.selects != 0 || f.writes != 0 || f.closed != 1 { + t.Fatalf("reserved=%t read=%v selects=%d writes=%d", reserved, err, f.selects, f.writes) + } + } +} + +func TestReadRecordModeChangeDoesNotSelectWriteControls(t *testing.T) { + // Established decoding permits record-only preparation even when unrelated write controls contradict. + f := newDeviceFixture() + f.mode[18], f.mode[46] = 0x80, 0x80 + selections := 0 + transport := newSerializedTransport(func(command Command) (Response, error) { + if command.CDB[0] == 0x55 { + selections++ + pages, err := selectionPages(command.Data) + if err != nil || len(pages[0x0f]) != 0 || len(pages[0x10]) != 0 { + t.Fatalf("READ selected write controls: pages=%v err=%v", pages, err) + } + } + return f.execute(command) + }, func() error { f.closed++; return nil }) + d, err := NewDevice(context.Background(), Identity{}, transport) + if err != nil { + t.Fatal(err) + } + _, err = d.Configure(context.Background(), &entity.TarMediaProfile{RecordMode: entity.TapeRecordMode_TAPE_RECORD_MODE_VARIABLE, RecordBytes: 512, HardwareCompression: true}, Read) + if err != nil || f.mode[18] != 0x80 || f.mode[46] != 0x80 { + t.Fatalf("read=%v mode=%x", err, f.mode) + } + if err := d.Cleanup(context.Background()); err != nil || selections != 2 || f.mode[18] != 0x80 || f.mode[46] != 0x80 { + t.Fatalf("cleanup=%v selections=%d mode=%x", err, selections, f.mode) + } +} + +func TestReadRecordOnlySelectionStillVerifiesRequiredDecoder(t *testing.T) { + // Required decoding is checked in existing readback even when the selection sends only a record descriptor. + f := newDeviceFixture() + corrupted := false + transport := newSerializedTransport(func(command Command) (Response, error) { + response, err := f.execute(command) + if command.CDB[0] == 0x5a && command.CDB[2] == 0x3f && f.selects == 1 && !corrupted { + command.Data[19] = 0 + corrupted = true + } + return response, err + }, func() error { f.closed++; return nil }) + d, err := NewDevice(context.Background(), Identity{}, transport) + if err != nil { + t.Fatal(err) + } + _, err = d.Configure(context.Background(), &entity.TarMediaProfile{RecordMode: entity.TapeRecordMode_TAPE_RECORD_MODE_VARIABLE, RecordBytes: 512, HardwareCompression: true}, Read) + if err == nil || f.selects != 2 || f.writes != 0 || f.closed != 1 { + t.Fatalf("read=%v selects=%d writes=%d closed=%d", err, f.selects, f.writes, f.closed) + } +} + +func TestCompressedReadChangesOnlyRequiredDecoderControls(t *testing.T) { + // A decoder is independent of DCC/write readiness, and RED exceptions cannot reach the tar parser. + f := newDeviceFixture() + f.mode[18], f.mode[19], f.mode[46] = 0, 0x40, 0 + f.mode[3], f.mode[42], f.mode[58] = 0x90, 0, 1 + binary.BigEndian.PutUint32(f.mode[20:24], 0) + binary.BigEndian.PutUint32(f.mode[24:28], 0) + var selections []map[byte][]byte + transport := newSerializedTransport(func(command Command) (Response, error) { + if command.CDB[0] == 0x55 { + pages, err := selectionPages(command.Data) + if err != nil { + t.Fatal(err) + } + selections = append(selections, pages) + } + return f.execute(command) + }, func() error { f.closed++; return nil }) + d, err := NewDevice(context.Background(), Identity{}, transport) + if err != nil { + t.Fatal(err) + } + _, err = d.Configure(context.Background(), &entity.TarMediaProfile{RecordMode: entity.TapeRecordMode_TAPE_RECORD_MODE_FIXED, RecordBytes: 512, HardwareCompression: true}, Read) + if err != nil || f.mode[18] != 0 || f.mode[19]&0xe0 != 0x80 || f.mode[46] != 0 || len(selections) != 1 || len(selections[0][0x10]) != 0 { + t.Fatalf("read=%v mode=%x selections=%v", err, f.mode, selections) + } + + // Last-read algorithm detection may update the ID; cleanup restores DDE/RED without replacing that ID. + binary.BigEndian.PutUint32(f.mode[24:28], 0xff) + if err := d.Cleanup(context.Background()); err != nil || f.mode[18] != 0 || f.mode[19]&0xe0 != 0x40 || binary.BigEndian.Uint32(f.mode[24:28]) != 0xff || f.writes != 0 || len(selections) != 2 || len(selections[1][0x10]) != 0 { + t.Fatalf("cleanup=%v mode=%x selections=%v", err, f.mode, selections) + } + if limits := feature(d.Report(), entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_HARDWARE_DECOMPRESSION).GetCompression(); limits.GetCurrentAlgorithm() != 0xff || limits.EnableChangeable == nil { + t.Fatalf("fresh last-read algorithm observation missing: %v", limits) + } +} + +func TestCompressionRefusesUnknownOrUnchangeableBeforeMutation(t *testing.T) { + for _, tc := range []struct { + name string + setup func(*deviceFixture) + mask bool + }{ + {"unknown selector", func(f *deviceFixture) { f.mode[46] = 0x80 }, false}, + {"contradictory state", func(f *deviceFixture) { f.mode[46] = 0 }, false}, + {"DCE with no capability", func(f *deviceFixture) { f.mode[18] = 0x80 }, false}, + {"unchangeable selector", func(*deviceFixture) {}, true}, + } { + t.Run(tc.name, func(t *testing.T) { + // Unknown active policy cannot be normalized and later claimed as a safely restored original. + f := newDeviceFixture() + tc.setup(f) + transport := newSerializedTransport(func(command Command) (Response, error) { + response, err := f.execute(command) + if tc.mask && command.CDB[0] == 0x5a && command.CDB[2] == 0x7f { + command.Data[46] = 0 + } + return response, err + }, func() error { f.closed++; return nil }) + d, err := NewDevice(context.Background(), Identity{}, transport) + if err != nil { + t.Fatal(err) + } + _, err = d.Configure(context.Background(), &entity.TarMediaProfile{RecordMode: entity.TapeRecordMode_TAPE_RECORD_MODE_VARIABLE, RecordBytes: 512}, Format) + if err == nil || f.selects != 0 || f.writes != 0 || f.motions != 0 || f.closed != 1 { + t.Fatalf("preparation=%v selects=%d writes=%d motions=%d closed=%d", err, f.selects, f.writes, f.motions, f.closed) + } + }) + } +} + +func TestCompressionReadThenAppendRetainsFirstCapturedControls(t *testing.T) { + // Label/tail reads can enable DDE before the immutable append policy changes DCE and the selector. + f := newDeviceFixture() + f.mode[19] = 0 + d := fixtureDevice(t, f) + read := &entity.TarMediaProfile{RecordMode: entity.TapeRecordMode_TAPE_RECORD_MODE_FIXED, RecordBytes: 512, HardwareCompression: true} + if _, err := d.Configure(context.Background(), read, Read); err != nil { + t.Fatal(err) + } + appendProfile := &entity.TarMediaProfile{RecordMode: entity.TapeRecordMode_TAPE_RECORD_MODE_FIXED, RecordBytes: 512} + if _, err := d.Configure(context.Background(), appendProfile, Append); err != nil || f.mode[18]&0x80 != 0 || f.mode[19]&0x80 == 0 || f.mode[46] != 0 { + t.Fatalf("append=%v mode=%x", err, f.mode) + } + if err := d.Cleanup(context.Background()); err != nil || f.mode[18]&0x80 == 0 || f.mode[19]&0x80 != 0 || f.mode[46] != 1 || f.selects != 3 { + t.Fatalf("cleanup=%v mode=%x selects=%d", err, f.mode, f.selects) + } +} + +func TestCompressionReadbackFailureRestoresAndNeverWrites(t *testing.T) { + // A settled selection with wrong algorithm readback fails preparation and still restores touched controls. + f := newDeviceFixture() + corrupted := false + transport := newSerializedTransport(func(command Command) (Response, error) { + response, err := f.execute(command) + if command.CDB[0] == 0x5a && command.CDB[2] == 0x3f && f.selects == 1 && !corrupted { + binary.BigEndian.PutUint32(command.Data[20:24], 0xff) + corrupted = true + } + return response, err + }, func() error { f.closed++; return nil }) + d, err := NewDevice(context.Background(), Identity{}, transport) + if err != nil { + t.Fatal(err) + } + _, err = d.Configure(context.Background(), &entity.TarMediaProfile{RecordMode: entity.TapeRecordMode_TAPE_RECORD_MODE_VARIABLE, RecordBytes: 512}, Format) + if err == nil || errors.Is(err, ErrUncertain) || f.selects != 2 || f.writes != 0 || f.closed != 1 || f.mode[18]&0x80 == 0 || f.mode[46] != 1 || binary.BigEndian.Uint32(f.mode[20:24]) != 1 { + t.Fatalf("failure=%v selects=%d writes=%d mode=%x", err, f.selects, f.writes, f.mode) + } +} + +func TestCompressionAlgorithmDriftCannotClaimVerifiedCleanup(t *testing.T) { + for _, tc := range []struct { + name string + operation Operation + fixed, decoderOff bool + }{ + {"write policy", Format, false, false}, + {"record-only read", Read, false, false}, + {"decoder-only read", Read, true, true}, + } { + t.Run(tc.name, func(t *testing.T) { + // Persistently changed CA is unavailable for cleanup regardless of which controls were selected. + f := newDeviceFixture() + if tc.decoderOff { + f.mode[19] = 0 + } + transport := newSerializedTransport(func(command Command) (Response, error) { + response, err := f.execute(command) + if command.CDB[0] == 0x5a && command.CDB[2] == 0x3f && f.selects == 1 { + binary.BigEndian.PutUint32(command.Data[20:24], 0xff) + } + return response, err + }, func() error { f.closed++; return nil }) + d, err := NewDevice(context.Background(), Identity{}, transport) + if err != nil { + t.Fatal(err) + } + mode := entity.TapeRecordMode_TAPE_RECORD_MODE_VARIABLE + if tc.fixed { + mode = entity.TapeRecordMode_TAPE_RECORD_MODE_FIXED + } + _, err = d.Configure(context.Background(), &entity.TarMediaProfile{RecordMode: mode, RecordBytes: 512, HardwareCompression: tc.operation == Read}, tc.operation) + if !errors.Is(err, ErrUncertain) || f.selects != 1 || f.writes != 0 || f.closed != 1 || !errors.Is(d.Cleanup(context.Background()), ErrUncertain) { + t.Fatalf("failure=%v selects=%d writes=%d closed=%d", err, f.selects, f.writes, f.closed) + } + }) + } +} diff --git a/internal/media/tape/device.go b/internal/media/tape/device.go new file mode 100644 index 0000000..c519a69 --- /dev/null +++ b/internal/media/tape/device.go @@ -0,0 +1,359 @@ +package tape + +import ( + "bytes" + "context" + "encoding/binary" + "errors" + "fmt" + "sync" + "time" + + "github.com/samuelncui/yatm/entity" + "google.golang.org/protobuf/proto" +) + +// Operation determines preparation; only explicit Format authorizes destructive initialization. +type Operation uint8 + +const ( + Read Operation = iota + 1 + Append + Format +) + +// Device owns one opened transport and captured session controls under its caller's attempt lease. +// SSC command/page semantics establish applicability; model names never authorize an operation. +type Device struct { + transport Transport + report *entity.InspectDeviceResponse + navigation *Navigation + records Records + operation Operation + original []byte + prepared []byte + partition []byte + partitionOriginal []byte + partitionChanged bool + changed bool + compressionChanged uint16 // Low byte tracks DCE changes; high byte tracks DDE/RED changes. + selectorChanged bool + cleanup sync.Once + cleanupErr error + geometry *WrapMap + cmPending bool + actualPartitions uint32 +} + +// NewDevice consumes an opened transport and performs the same passive inspection as the RPC. +func NewDevice(ctx context.Context, identity Identity, transport Transport) (*Device, error) { + // Inspection never positions or changes the opened drive; construction failure owns its release. + if transport == nil { + return nil, fmt.Errorf("Tape transport is missing") + } + device := &Device{transport: transport} + _, err := device.inspect(ctx, identity) + if err != nil { + if closeErr := transport.Close(); closeErr != nil { + return nil, errors.Join(err, &UncertainError{Cause: fmt.Errorf("close Tape transport after inspection failed, %w", closeErr)}) + } + return nil, err + } + return device, nil +} + +// Report returns a request-owned observation snapshot, never a durable write authorization. +func (d *Device) Report() *entity.InspectDeviceResponse { + return proto.Clone(d.report).(*entity.InspectDeviceResponse) +} + +// Configure establishes SSC record access and captures controls before mutation. +// FORMAT configures layout before the Tape script and writes no data; later access relies on that script's preparation. +func (d *Device) Configure(ctx context.Context, profile *entity.TarMediaProfile, operation Operation) (records Records, err error) { + // Every failed preparation owns cancellation-independent cleanup, including failed mode readback. + defer func() { + if err != nil { + cleanup, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + err = errors.Join(err, d.Cleanup(cleanup)) + } + }() + if profile == nil { + return Records{}, fmt.Errorf("Tape record policy is missing") + } + if err := entity.ValidateTarRecordPolicy(profile.RecordMode, profile.RecordBytes); err != nil { + return Records{}, err + } + if operation < Read || operation > Format || profile.RecordBytes > MaxTransferBytes { + return Records{}, fmt.Errorf("Tape operation or record size is unsupported") + } + if operation != Read { + d.invalidateGeometry() + } + if !d.report.GetTape().GetMediumPresent() || d.report.Reason == entity.DeviceReason_DEVICE_REASON_NOT_READY { + return Records{}, fmt.Errorf("Tape medium is not ready") + } + + // An SPC-3 sequential-access device supplies the SSC-2/3 baseline, not vendor-specific forms. + inquiry, err := query(ctx, d.transport, []byte{0x12, 0, 0, 0, 96, 0}, 96) + if err != nil { + return Records{}, err + } + if len(inquiry) < 36 || inquiry[0]&0x1f != 1 || inquiry[2] < 5 || inquiry[3]&0x0f != 2 { + return Records{}, fmt.Errorf("standard sequential-device command scope is not established") + } + mode := RecordMode{MaximumBytes: int(profile.RecordBytes), WarningCounts: true} + fixed := byte(0) + if profile.RecordMode == entity.TapeRecordMode_TAPE_RECORD_MODE_FIXED { + mode.FixedBytes = mode.MaximumBytes + fixed = 1 + } + limits := feature(d.report, entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_SEQUENTIAL_READ).GetRecords() + if limits == nil || limits.MinimumBytes == nil || limits.AlignmentBytes == nil || + profile.RecordBytes < limits.GetMinimumBytes() || limits.MaximumBytes != nil && profile.RecordBytes > limits.GetMaximumBytes() || + limits.GetAlignmentBytes() == 0 || (profile.RecordBytes-limits.GetMinimumBytes())%limits.GetAlignmentBytes() != 0 { + return Records{}, fmt.Errorf("selected record is outside observed block limits") + } + if mode.FixedBytes == 0 && (limits.Variable != nil && !limits.GetVariable() || limits.GetMinimumBytes() > 512 || limits.GetAlignmentBytes() > 512) { + return Records{}, fmt.Errorf("variable tar records are outside observed block limits") + } + if reserve, ok := d.transport.(interface { + EnsureTransfer(context.Context, int) error + }); ok { + if err := reserve.EnsureTransfer(ctx, mode.MaximumBytes); err != nil { + return Records{}, err + } + } else if bounded, ok := d.transport.(interface{ TransferLimit() int }); ok && bounded.TransferLimit() < mode.MaximumBytes { + return Records{}, fmt.Errorf("selected record exceeds the transport buffer") + } + for _, kind := range []entity.DeviceFeatureKind{entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_SEQUENTIAL_READ, entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_SEQUENTIAL_WRITE} { + if records := feature(d.report, kind).GetRecords(); records != nil { + records.TransportMaximumBytes = transportMaximumBytes(d.transport) + } + } + + // Read current non-secret controls and required medium facts before any MODE SELECT or write. + current, pages, err := d.readMode(ctx, false) + if err != nil { + return Records{}, err + } + if len(current) < 16 || binary.BigEndian.Uint16(current[6:8]) != 8 { + return Records{}, fmt.Errorf("record mode descriptor is unavailable") + } + configuration := pages[0x10] + if len(configuration) < 16 || operation != Format && configuration[3] != 0 || operation != Read && (configuration[10]>>5 > 2 || configuration[10]&0x10 == 0) { + return Records{}, fmt.Errorf("partition zero and EOD generation are not established") + } + compression := pages[0x0f] + if operation != Read { + if (current[3]&0x70)>>4 > 2 { + return Records{}, fmt.Errorf("buffered-write mode is outside the standard scope") + } + if len(compression) != 0 && len(compression) < 16 { + return Records{}, ErrMalformed + } + if current[3]&0x80 != 0 { + return Records{}, fmt.Errorf("Tape medium is write protected") + } + medium := pages[0x1d] + if len(medium) < 32 || medium[2]&1 != 0 { + return Records{}, fmt.Errorf("rewritable medium prerequisites are unavailable") + } + } + if err := d.requireCommand(ctx, []byte{0x08, fixed, 0xff, 0xff, 0xff, 0}, true); err != nil { + return Records{}, err + } + if err := d.requireCommand(ctx, []byte{1, 0, 0, 0, 0, 0}, true); err != nil { + return Records{}, err + } + if operation != Read { + for _, form := range [][]byte{{0x0a, fixed, 0xff, 0xff, 0xff, 0}, {0x10, 0, 0xff, 0xff, 0xff, 0}} { + if err := d.requireCommand(ctx, form, true); err != nil { + return Records{}, err + } + } + } + + // A multipartition FORMAT requires an explicit, supported delayed single-partition transition. + count, err := d.actualPartitionCount(ctx, pages[0x11]) + if err != nil { + return Records{}, err + } + d.report.GetTape().PartitionCount = proto.Uint32(count) + if operation == Format { + partition := pages[0x11] + if count != 1 && len(partition) < 8 { + return Records{}, fmt.Errorf("Tape partition layout is unknown") + } + if count != 1 { + if err := d.preparePartition(ctx, partition, current[3]); err != nil { + return Records{}, err + } + } + } else if count != 1 { + return Records{}, fmt.Errorf("tar access requires a known single partition") + } + + // FORMAT may leave an LTFS partition only through an explicitly reported partition-zero form. + if operation == Format && configuration[3] != 0 { + form := make([]byte, 16) + form[0], form[1] = 0x92, 2 + if err := d.requireCommand(ctx, form, false); err != nil { + return Records{}, err + } + if d.original == nil { + d.original = sessionMode(current, compression, configuration, blockLength(current)) + } + motion := Records{Transport: d.transport, Mode: mode, Timeout: 10 * time.Minute} + if err := motion.Locate(ctx, 0, 0); err != nil { + return Records{}, err + } + current, pages, err = d.readMode(ctx, false) + if err != nil { + return Records{}, err + } + if len(pages[0x10]) < 16 || pages[0x10][3] != 0 { + return Records{}, &UncertainError{Cause: fmt.Errorf("partition-zero motion was not confirmed")} + } + compression = pages[0x0f] + configuration = pages[0x10] + } + + // Resolve independent read decoding or the explicit write policy without replacing algorithm IDs. + selectedCompression, selectedConfiguration, err := compressionPolicy(pages, profile.HardwareCompression, operation) + if err != nil { + return Records{}, err + } + if d.original == nil { + d.original = sessionMode(current, compression, configuration, blockLength(current)) + } + desired := sessionMode(current, selectedCompression, selectedConfiguration, uint32(mode.FixedBytes)) + d.prepared = desired + var beforeCompression, beforeConfiguration []byte + if len(selectedCompression) != 0 { + beforeCompression = compression + } + if len(selectedConfiguration) != 0 { + beforeConfiguration = configuration + } + before := sessionMode(current, beforeCompression, beforeConfiguration, blockLength(current)) + if !bytes.Equal(desired, before) { + if err := d.requireCommand(ctx, []byte{0x55, 0x10, 0, 0, 0, 0, 0, 0xff, 0xff, 0}, true); err != nil { + return Records{}, err + } + selected := map[byte][]byte{0x0f: selectedCompression, 0x10: selectedConfiguration} + if compressionControlsDiffer(pages, selected) { + _, changes, err := d.readMode(ctx, true) + if err != nil { + return Records{}, err + } + if err := checkCompressionChanges(pages, selected, changes); err != nil { + return Records{}, err + } + } + d.changed = true + if len(selectedCompression) != 0 { + d.compressionChanged |= uint16((compression[2]^selectedCompression[2])&0x80) | uint16((compression[3]^selectedCompression[3])&0xe0)<<8 + } + d.selectorChanged = d.selectorChanged || len(selectedConfiguration) != 0 && configuration[14] != selectedConfiguration[14] + var writePolicy *bool + if operation != Read { + writePolicy = proto.Bool(profile.HardwareCompression) + } + if err := d.selectMode(ctx, desired, operation == Read && profile.HardwareCompression, writePolicy); err != nil { + return Records{}, err + } + } + d.records, d.operation = Records{Transport: d.transport, Mode: mode, Timeout: 10 * time.Minute}, operation + d.actualPartitions = count + d.records.invalidateGeometry = d.invalidateGeometry + if d.navigation != nil { + d.navigation.records.invalidateGeometry = d.invalidateGeometry + } + return d.records, nil +} + +// Initialize establishes partition zero/BOT; label completion establishes the new recorded EOD. +func (d *Device) Initialize(ctx context.Context) error { + // Only the explicitly configured FORMAT path may alter the physical cartridge layout. + if d.operation != Format { + return fmt.Errorf("Tape initialization requires FORMAT preparation") + } + d.invalidateGeometry() + if err := d.records.Rewind(ctx); err != nil { + return err + } + if d.partition == nil { + d.actualPartitions = 1 + return nil + } + + // POFM keeps the partition page pending until a non-immediate FORMAT commits the transition. + d.partitionChanged = true + if err := d.selectMode(ctx, d.partition, false, nil); err != nil { + return err + } + response, err := d.transport.Execute(ctx, Command{CDB: []byte{4, 0, 1, 0, 0, 0}, Direction: NoData, Timeout: 24 * time.Hour, validate: controlResponse}) + if err != nil { + return err + } + if err := CheckResponse(response); err != nil { + return err + } + count, err := d.partitionCount(ctx) + if err != nil || count != 1 { + return errors.Join(err, fmt.Errorf("formatted single-partition layout was not confirmed")) + } + d.report.GetTape().PartitionCount = proto.Uint32(1) + d.actualPartitions = 1 + d.partitionChanged = false + d.changed = true + pages, err := selectionPages(d.prepared) + if err != nil { + return err + } + enabled := len(pages[0x0f]) >= 16 && pages[0x0f][2]&0x80 != 0 + if err := d.selectMode(ctx, d.prepared, false, &enabled); err != nil { + return err + } + if err := d.records.Rewind(ctx); err != nil { + return err + } + return nil +} + +// Cleanup restores captured controls after commands settle and releases the opened transport. +func (d *Device) Cleanup(ctx context.Context) error { + return d.release(ctx, false) +} + +// Finish restores owned controls and unloads a normally completed Session before closing it. +func (d *Device) Finish(ctx context.Context) error { + return d.release(ctx, true) +} + +func (d *Device) release(ctx context.Context, unload bool) error { + d.cleanup.Do(func() { + // Withdraw every shared snapshot before cleanup can release or lose the opened session. + d.invalidateGeometry() + + // Restoration failure is physical unavailability even if its command had a known rejection. + if d.partitionChanged { + if err := d.selectMode(ctx, d.partitionOriginal, false, nil); err != nil { + d.cleanupErr = errors.Join(d.cleanupErr, &UncertainError{Cause: fmt.Errorf("restore pending partition controls failed, %w", err)}) + } + } + if d.changed { + if err := d.restoreMode(ctx); err != nil { + d.cleanupErr = errors.Join(d.cleanupErr, &UncertainError{Cause: fmt.Errorf("restore Tape session mode failed, %w", err)}) + } + } + if unload && d.cleanupErr == nil { + if err := d.records.Unload(ctx); err != nil { + d.cleanupErr = &UncertainError{Cause: fmt.Errorf("unload completed Tape session failed, %w", err)} + } + } + d.cleanupErr = errors.Join(d.cleanupErr, d.transport.Close()) + }) + return d.cleanupErr +} diff --git a/internal/media/tape/device_finish_test.go b/internal/media/tape/device_finish_test.go new file mode 100644 index 0000000..bae08af --- /dev/null +++ b/internal/media/tape/device_finish_test.go @@ -0,0 +1,174 @@ +package tape + +import ( + "bytes" + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestDeviceNormalFinishRestoresBeforeOneSynchronousUnload(t *testing.T) { + for _, finish := range []bool{true, false} { + t.Run(map[bool]string{true: "normal-finish", false: "cleanup-only"}[finish], func(t *testing.T) { + // A prepared reader owns changed record controls; key management remains outside this Device. + fixture := newDeviceFixture() + original := bytes.Clone(fixture.mode) + var commands []byte + unloads, closes := 0, 0 + transport := newSerializedTransport(func(command Command) (Response, error) { + commands = append(commands, command.CDB[0]) + if command.CDB[0] == 0x1b { + unloads++ + require.Equal(t, []byte{0x1b, 0, 0, 0, 0, 0}, command.CDB) + require.Equal(t, NoData, command.Direction) + require.Empty(t, command.Data) + require.Equal(t, blockLength(original), blockLength(fixture.mode), "record controls must be restored before unload") + return Response{Completed: true}, nil + } + return fixture.execute(command) + }, func() error { commands = append(commands, 0xff); closes++; return nil }) + device, err := NewDevice(context.Background(), Identity{}, transport) + require.NoError(t, err) + _, err = device.Configure(context.Background(), encryptionTestProfile(), Read) + require.NoError(t, err) + commands = nil + + // The first release choice owns all later calls; cleanup alone can never request motion. + if finish { + require.NoError(t, device.Finish(context.Background())) + } else { + require.NoError(t, device.Cleanup(context.Background())) + } + require.Equal(t, blockLength(original), blockLength(fixture.mode)) + require.NotEqual(t, -1, bytes.IndexByte(commands, 0x55)) + + require.NotContains(t, commands, byte(0xb5), "native cleanup never changes script keys") + require.NotContains(t, commands, byte(1), "LOAD UNLOAD already performs its required rewind") + require.Equal(t, map[bool]int{true: 1, false: 0}[finish], unloads) + require.Equal(t, byte(0xff), commands[len(commands)-1]) + if finish { + require.Equal(t, byte(0x1b), commands[len(commands)-2], "no medium-dependent query may follow unload") + } + before := bytes.Clone(commands) + require.NoError(t, device.Finish(context.Background())) + require.NoError(t, device.Cleanup(context.Background())) + require.Equal(t, before, commands) + require.Equal(t, 1, closes) + }) + } +} + +func TestDeviceFinishFailureStillClosesWithoutAnotherMotion(t *testing.T) { + for _, failure := range []string{"mode-restore", "unload", "close"} { + t.Run(failure, func(t *testing.T) { + // Each injected failure occurs after ordinary preparation has acquired its controls. + fixture := newDeviceFixture() + unloadErr, closeErr := errors.New("unload failed"), errors.New("close failed") + unloads, rewinds, closes := 0, 0, 0 + transport := newSerializedTransport(func(command Command) (Response, error) { + if command.CDB[0] == 1 { + rewinds++ + } + if command.CDB[0] == 0x1b { + unloads++ + if failure == "unload" { + return Response{Completed: true}, unloadErr + } + return Response{Completed: true}, nil + } + return fixture.execute(command) + }, func() error { + closes++ + if failure == "close" { + return closeErr + } + return nil + }) + device, err := NewDevice(context.Background(), Identity{}, transport) + require.NoError(t, err) + _, err = device.Configure(context.Background(), encryptionTestProfile(), Read) + require.NoError(t, err) + fixture.failureCleanup = failure == "mode-restore" + + // Failed restoration suppresses unload; failed unload is never retried after descriptor closure. + err = device.Finish(context.Background()) + require.Error(t, err) + if failure == "close" { + require.ErrorIs(t, err, closeErr) + } else { + require.ErrorIs(t, err, ErrUncertain) + } + if failure == "unload" { + require.ErrorIs(t, err, unloadErr) + } + wantUnloads := 1 + if failure == "mode-restore" { + wantUnloads = 0 + } + require.Equal(t, wantUnloads, unloads) + require.Zero(t, rewinds) + require.Equal(t, 1, closes) + require.Equal(t, err, device.Cleanup(context.Background())) + require.Equal(t, err, device.Finish(context.Background())) + require.Equal(t, wantUnloads, unloads) + require.Equal(t, 1, closes) + }) + } +} + +func TestDeviceFinishWaitsForUnloadBeforeClosing(t *testing.T) { + // The native synchronous command retains the opened transport until physical settlement. + issued, settle, finished := make(chan Command, 1), make(chan struct{}), make(chan error, 1) + closes := make(chan struct{}, 1) + t.Cleanup(func() { + select { + case <-settle: + default: + close(settle) + } + }) + transport := newSerializedTransport(func(command Command) (Response, error) { + issued <- command + <-settle + return Response{Completed: true}, nil + }, func() error { closes <- struct{}{}; return nil }) + device := &Device{transport: transport, records: Records{Transport: transport, Timeout: time.Minute}} + go func() { finished <- device.Finish(context.Background()) }() + var command Command + select { + case command = <-issued: + case err := <-finished: + t.Fatalf("Finish returned without issuing unload: %v", err) + case <-time.After(5 * time.Second): + t.Fatal("Finish did not issue unload") + } + require.Equal(t, []byte{0x1b, 0, 0, 0, 0, 0}, command.CDB) + select { + case <-finished: + t.Fatal("Finish returned before synchronous unload settled") + case <-closes: + t.Fatal("transport closed while unload was active") + default: + } + + // Settlement permits exactly one close; raw transport Close itself does not submit motion. + close(settle) + select { + case err := <-finished: + require.NoError(t, err) + case <-time.After(5 * time.Second): + t.Fatal("Finish did not return after unload settled") + } + <-closes + require.NoError(t, transport.Close()) + select { + case <-issued: + t.Fatal("Close issued a Tape command") + case <-closes: + t.Fatal("transport closed twice") + default: + } +} diff --git a/internal/media/tape/device_mode.go b/internal/media/tape/device_mode.go new file mode 100644 index 0000000..7ae6676 --- /dev/null +++ b/internal/media/tape/device_mode.go @@ -0,0 +1,197 @@ +package tape + +import ( + "context" + "encoding/binary" + "fmt" + "time" + + "github.com/samuelncui/yatm/entity" +) + +func blockLength(mode []byte) uint32 { + return uint32(mode[13])<<16 | uint32(mode[14])<<8 | uint32(mode[15]) +} + +func sessionMode(current, compression, configuration []byte, block uint32) []byte { + // MODE SELECT has zero mode-data length, no write-protect bit and SP clear in its CDB. + data := make([]byte, 16+len(compression)+len(configuration)) + data[3], data[7] = current[3]&0x7f, 8 + data[13], data[14], data[15] = byte(block>>16), byte(block>>8), byte(block) + copy(data[16:], compression) + if len(compression) != 0 { + data[16] &^= 0x80 + } + copy(data[16+len(compression):], configuration) + if len(configuration) != 0 { + data[16+len(compression)] &^= 0x80 + } + return data +} + +func selectionPages(data []byte) (map[byte][]byte, error) { + // Reuse the bounded mode parser for outgoing parameter lists whose mode-data length must be zero. + copy := append([]byte(nil), data...) + if len(copy) < 8 { + return nil, ErrMalformed + } + binary.BigEndian.PutUint16(copy[:2], uint16(len(copy)-2)) + _, pages, err := modeData(copy) + return pages, err +} + +func (d *Device) selectMode(ctx context.Context, data []byte, verifyDecoder bool, writePolicy *bool) error { + // Session MODE SELECT never saves defaults and owns its response before a readback can follow. + desired, err := selectionPages(data) + if err != nil { + return err + } + cdb := make([]byte, 10) + cdb[0], cdb[1] = 0x55, 0x10 + binary.BigEndian.PutUint16(cdb[7:9], uint16(len(data))) + response, err := d.transport.Execute(ctx, Command{CDB: cdb, Direction: ToDevice, Data: data, Timeout: 2 * time.Minute, + validate: func(response Response) error { + if err := controlResponse(response); err != nil { + return err + } + if response.Residual != 0 { + return &UncertainError{Cause: ErrMalformed} + } + return nil + }, + }) + if err != nil { + return err + } + if err := CheckResponse(response); err != nil { + return err + } + if response.Residual != 0 { + return &UncertainError{Cause: ErrMalformed} + } + + // Pending partition controls are verified without treating the mode-page count as physical layout. + current, pages, err := d.readMode(ctx, false) + if err != nil { + return err + } + if d.original != nil { + original, err := selectionPages(d.original) + if err != nil { + return err + } + if err := checkCapturedWriteAlgorithm(pages, original); err != nil { + return err + } + if err := checkUntouchedCompression(pages, original, d.compressionChanged, d.selectorChanged); err != nil { + return err + } + } + if binary.BigEndian.Uint16(data[6:8]) == 0 { + page := pages[0x11] + if len(page) < 8 || page[4] != data[12] || current[3]&0x7f != data[3]&0x7f { + return fmt.Errorf("partition control readback differs") + } + return nil + } + if len(current) < 16 || binary.BigEndian.Uint16(current[6:8]) != 8 || blockLength(current) != blockLength(data) || current[3]&0x7f != data[3]&0x7f { + return fmt.Errorf("Tape session mode readback differs") + } + if verifyDecoder && (len(pages[0x0f]) < 16 || pages[0x0f][3]&0xe0 != 0x80) { + return fmt.Errorf("required Tape decoder mode readback differs") + } + if writePolicy != nil { + active, _ := compressionState(pages) + if active == nil || *active != *writePolicy { + return fmt.Errorf("required Tape write compression readback differs") + } + } + return checkCompressionReadback(pages, desired) +} + +func (d *Device) restoreMode(ctx context.Context) error { + // Restore touched controls on fresh pages, retaining current partition/EOD and last-read algorithm facts. + current, pages, err := d.readMode(ctx, false) + if err != nil { + return err + } + if len(current) < 16 || binary.BigEndian.Uint16(current[6:8]) != 8 { + return fmt.Errorf("captured record mode is unavailable for restoration") + } + original, err := selectionPages(d.original) + if err != nil { + return err + } + if err := checkCapturedWriteAlgorithm(pages, original); err != nil { + return err + } + if err := checkUntouchedCompression(pages, original, d.compressionChanged, d.selectorChanged); err != nil { + return err + } + + // Rebuild only owned mutable fields on the current pages, retaining unrelated reported facts. + var compression, configuration []byte + if d.compressionChanged != 0 { + compression = append([]byte(nil), pages[0x0f]...) + if len(compression) < 16 || len(original[0x0f]) < 16 { + return fmt.Errorf("captured compression controls are unavailable") + } + writeMask, readMask := byte(d.compressionChanged), byte(d.compressionChanged>>8) + compression[2] = compression[2]&^writeMask | original[0x0f][2]&writeMask + compression[3] = compression[3]&^readMask | original[0x0f][3]&readMask + } + if d.selectorChanged { + configuration = append([]byte(nil), pages[0x10]...) + if len(configuration) < 16 || len(original[0x10]) < 16 { + return fmt.Errorf("captured compression selector is unavailable") + } + configuration[14] = original[0x10][14] + } + + // A settled rejection or unchanged reload needs no MODE SELECT; held/unready controls cannot be restored safely. + if blockLength(current) == blockLength(d.original) && current[3]&0x7f == d.original[3] && + !compressionControlsDiffer(pages, original) { + return nil + } + if d.report.Reason == entity.DeviceReason_DEVICE_REASON_NOT_READY || !d.report.GetTape().GetMediumPresent() { + return fmt.Errorf("Tape controls require a ready cartridge for restoration") + } + + // Restore the captured settings through the existing checked mode-selection boundary. + data := sessionMode(current, compression, configuration, blockLength(d.original)) + data[3] = d.original[3] + return d.selectMode(ctx, data, false, nil) +} + +func (d *Device) preparePartition(ctx context.Context, partition []byte, header byte) error { + // Resolve FORMAT and delayed partition controls before preparing the known required transition. + if partition[4]&4 == 0 { + return fmt.Errorf("delayed partitioning is unavailable") + } + if err := d.requireCommand(ctx, []byte{4, 0, 1, 0, 0, 0}, false); err != nil { + return err + } + _, changes, err := d.readMode(ctx, true) + if err != nil { + return err + } + changeable := changes[0x11] + if len(changeable) != len(partition) { + return fmt.Errorf("partition changeability is unknown") + } + page := append([]byte(nil), partition...) + page[0] &^= 0x80 + page[3], page[4] = 0, page[4]&0x1c|0x40 + for i := 2; i < len(page); i++ { + if (page[i]^partition[i])&^changeable[i] != 0 { + return fmt.Errorf("required single-partition transition is unsupported") + } + } + d.partition = append(make([]byte, 8), page...) + d.partition[3] = header & 0x7f + original := append([]byte(nil), partition...) + original[0] &^= 0x80 + d.partitionOriginal = append(make([]byte, 8), original...) + d.partitionOriginal[3] = header & 0x7f + return nil +} diff --git a/internal/media/tape/device_mode_test.go b/internal/media/tape/device_mode_test.go new file mode 100644 index 0000000..319ace0 --- /dev/null +++ b/internal/media/tape/device_mode_test.go @@ -0,0 +1,78 @@ +package tape + +import ( + "context" + "testing" + + "github.com/samuelncui/yatm/entity" +) + +func TestDevicePreservesBufferedModeAndSpeed(t *testing.T) { + // A record-mode change must preserve the whole non-WP header through preparation and cleanup. + f := newDeviceFixture() + f.mode[3] = 0x13 + d := fixtureDevice(t, f) + _, err := d.Configure(context.Background(), &entity.TarMediaProfile{ + RecordMode: entity.TapeRecordMode_TAPE_RECORD_MODE_VARIABLE, RecordBytes: 512, + }, Format) + if err != nil || f.mode[3] != 0x13 || f.selects != 1 { + t.Fatalf("preparation error=%v, mode=%02x, selects=%d", err, f.mode[3], f.selects) + } + + // Restoring block length and compression must not reset the original nonzero speed. + if err := d.Cleanup(context.Background()); err != nil || f.mode[3] != 0x13 || f.selects != 2 { + t.Fatalf("cleanup error=%v, mode=%02x, selects=%d", err, f.mode[3], f.selects) + } +} + +func TestDeviceFailedFormatPreservesHeaderWithoutOtherModeChanges(t *testing.T) { + // Matching record/compression controls leave partition restoration as the only cleanup mutation. + f := newDeviceFixture() + f.mode[3], f.mode[51], f.actualPartitions, f.failedFormat = 0x13, 1, 2, true + d := fixtureDevice(t, f) + _, err := d.Configure(context.Background(), &entity.TarMediaProfile{ + RecordMode: entity.TapeRecordMode_TAPE_RECORD_MODE_FIXED, RecordBytes: 512, HardwareCompression: true, + }, Format) + if err != nil || d.changed || f.selects != 0 { + t.Fatalf("preparation error=%v, changed=%t, selects=%d", err, d.changed, f.selects) + } + + // A rejected FORMAT still restores its pending partition controls with buffering and speed intact. + if err := d.Initialize(context.Background()); err == nil || f.mode[3] != 0x13 || f.selects != 1 { + t.Fatalf("initialization error=%v, mode=%02x, selects=%d", err, f.mode[3], f.selects) + } + if err := d.Cleanup(context.Background()); err != nil || f.mode[3] != 0x13 || f.mode[51] != 1 || f.selects != 2 { + t.Fatalf("cleanup error=%v, mode=%02x, additional=%d, selects=%d", err, f.mode[3], f.mode[51], f.selects) + } +} + +func TestDevicePendingPartitionCountNeedsPhysicalConfirmation(t *testing.T) { + for _, failure := range []string{"", "controls", "physical count"} { + t.Run(failure, func(t *testing.T) { + // A pending count may stay old; control bits and the committed physical layout remain authoritative. + f := newDeviceFixture() + f.mode[51], f.actualPartitions, f.deferredPartitionCount = 1, 2, true + f.wrongPartitionControls, f.wrongFormattedCount = failure == "controls", failure == "physical count" + d := fixtureDevice(t, f) + _, err := d.Configure(context.Background(), &entity.TarMediaProfile{ + RecordMode: entity.TapeRecordMode_TAPE_RECORD_MODE_VARIABLE, RecordBytes: 512, + }, Format) + if err != nil { + t.Fatal(err) + } + + // Accept only a successful FORMAT whose actual partition list confirms the requested single partition. + err = d.Initialize(context.Background()) + if (err == nil) != (failure == "") || failure == "controls" && f.formats != 0 { + t.Fatalf("failure=%q, initialization error=%v, formats=%d", failure, err, f.formats) + } + if failure == "" && (f.actualPartitions != 1 || d.Report().GetTape().GetPartitionCount() != 1) { + t.Fatal("successful initialization did not confirm one physical partition") + } + f.wrongPartitionControls = false + if err := d.Cleanup(context.Background()); err != nil { + t.Fatal(err) + } + }) + } +} diff --git a/internal/media/tape/device_queries.go b/internal/media/tape/device_queries.go new file mode 100644 index 0000000..18f915c --- /dev/null +++ b/internal/media/tape/device_queries.go @@ -0,0 +1,321 @@ +package tape + +import ( + "context" + "encoding/binary" + "fmt" + "strings" + "time" + "unicode/utf8" + + "github.com/samuelncui/yatm/entity" + "google.golang.org/protobuf/proto" +) + +func (d *Device) readMode(ctx context.Context, changeable bool) ([]byte, map[byte][]byte, error) { + page := byte(0x3f) + if changeable { + page = 0x7f + } + data, err := query(ctx, d.transport, []byte{0x5a, 0, page, 0, 0, 0, 0, 0x10, 0, 0}, MaxQueryBytes) + if err != nil { + return nil, nil, err + } + data, pages, err := modeData(data) + if err == nil && !changeable { + if page := pages[0x11]; len(page) >= 8 && page[3] > page[2] { + return nil, nil, ErrMalformed + } + observeMode(data, pages, d.report.GetTape()) + d.refreshCompressionAlgorithms(pages) + } + return data, pages, err +} + +func (d *Device) requireCommand(ctx context.Context, form []byte, mandatory bool) error { + // RSOC rejection does not revoke an applicable mandatory SSC command; explicit unsupported does. + data, err := reportCommand(ctx, d.transport, form[0], nil) + if Reason(err) == entity.DeviceReason_DEVICE_REASON_UNSUPPORTED && mandatory { + return nil + } + if err != nil { + return err + } + if len(data) < 2 { + return ErrMalformed + } + support := data[1] & 7 + if support == 0 && mandatory { + return nil + } + observed, err := commandFormSupport(data, form) + if err != nil { + return err + } + if observed == entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_UNSUPPORTED { + return fmt.Errorf("Tape opcode %02x required form is unsupported: %w", form[0], ErrUnsupported) + } + if observed != entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_SUPPORTED { + return fmt.Errorf("Tape opcode %02x has no applicable standard support", form[0]) + } + return nil +} + +func reportCommand(ctx context.Context, transport Transport, opcode byte, action *uint16) ([]byte, error) { + // A service-action-specific report establishes the actual READ POSITION form, not a bit-mask guess. + cdb := []byte{0xa3, 0x0c, 1, opcode, 0, 0, 0, 0, 0, 64, 0, 0} + if action != nil { + cdb[2] = 2 + binary.BigEndian.PutUint16(cdb[4:6], *action) + } + return query(ctx, transport, cdb, 64) +} + +func commandFormSupport(data, form []byte) (entity.DeviceFeatureSupport, error) { + // Only standard support and the required CDB field mask establish this form's applicability. + if len(data) < 2 { + return entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_UNKNOWN, ErrMalformed + } + switch data[1] & 7 { + case 0, 5: + return entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_UNKNOWN, nil + case 1: + return entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_UNSUPPORTED, nil + case 3: + default: + return entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_UNKNOWN, ErrMalformed + } + if len(data) < 4 { + return entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_UNKNOWN, ErrMalformed + } + length := int(binary.BigEndian.Uint16(data[2:4])) + if length != len(form) || length+4 > len(data) || data[4] != form[0] { + return entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_UNKNOWN, ErrMalformed + } + + // SPC usage maps evaluate or ignore an entire address field; partial maps are not smaller ranges. + start, end := 0, 0 + switch form[0] { + case 0x2b: + if len(form) != 10 { + return entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_UNKNOWN, ErrMalformed + } + start, end = 3, 7 + case 0x92: + if len(form) != 16 { + return entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_UNKNOWN, ErrMalformed + } + start, end = 4, 12 + } + if end != 0 { + value := data[start+4] + if value != 0 && value != 0xff { + return entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_UNKNOWN, ErrMalformed + } + for _, mask := range data[start+4 : end+4] { + if mask != value { + return entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_UNKNOWN, ErrMalformed + } + } + } + + // Only required nonzero values need evaluated bits; service actions are literal identifiers. + for i := 1; i < len(form)-1; i++ { + // RSOC service-action CDB usage contains the literal requested action, not a supported-bit mask. + if form[0] == 0x34 && i == 1 { + if data[i+4] != form[i] { + return entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_UNKNOWN, ErrMalformed + } + continue + } + if form[i]&^data[i+4] != 0 { + return entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_UNSUPPORTED, nil + } + } + return entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_SUPPORTED, nil +} + +func (d *Device) requireEncryptionOff(ctx context.Context) error { + // The status page exposes modes, not keys or encrypted-content identity. Unknown never means off. + data, err := encryptionStatus(ctx, d.transport, d.report.GetTape()) + if err != nil { + return fmt.Errorf("observe encryption/decryption state failed, %w", err) + } + if !encryptionOff(data) { + return fmt.Errorf("baseline requires encryption and decryption disabled without external control") + } + return nil +} + +func encryptionOff(data []byte) bool { + // A missing status is allowed only after the complete protocol list established absence. + return data == nil || len(data) >= 13 && data[5] == 0 && data[6] == 0 && data[4] == 0 && + (data[12]&0x70 == 0x10 || data[12]&0x70 == 0x20) +} + +func encryptionStatus(ctx context.Context, transport Transport, report *entity.TapeDeviceReport) ([]byte, error) { + // A complete standard protocol list distinguishes absent tape encryption from unknown status access. + data, err := query(ctx, transport, []byte{0xa2, 0, 0, 0, 0, 0, 0, 0, 0x10, 0, 0, 0}, MaxQueryBytes) + if err != nil { + return nil, err + } + if len(data) < 8 || int(binary.BigEndian.Uint16(data[6:8]))+8 > len(data) { + return nil, ErrMalformed + } + present, unknown := false, false + for _, protocol := range data[8 : 8+int(binary.BigEndian.Uint16(data[6:8]))] { + present = present || protocol == 0x20 + unknown = unknown || protocol != 0 && protocol != 0x20 + } + if !present { + if unknown { + return nil, fmt.Errorf("nonstandard encryption capability is unknown") + } + report.EncryptionEnabled = proto.Bool(false) + return nil, nil + } + + // The tape status page contains only non-secret current modes, never a recoverable key. + data, err = query(ctx, transport, []byte{0xa2, 0x20, 0, 0x20, 0, 0, 0, 0, 0x10, 0, 0, 0}, MaxQueryBytes) + if err != nil { + return nil, err + } + if err := decodeEncryption(data, report); err != nil { + return nil, err + } + return data, nil +} + +func decodeEncryption(data []byte, report *entity.TapeDeviceReport) error { + state, err := decodeEncryptionState(data) + if err != nil { + return err + } + report.EncryptionEnabled = proto.Bool(state.encrypt != 0) + return nil +} + +func (d *Device) attribute(ctx context.Context, identifier uint16) ([]byte, error) { + // MAM requests remain bounded even when the response advertises a longer list of attributes. + cdb := make([]byte, 16) + cdb[0] = 0x8c + binary.BigEndian.PutUint16(cdb[8:10], identifier) + binary.BigEndian.PutUint32(cdb[10:14], MaxQueryBytes) + data, err := query(ctx, d.transport, cdb, MaxQueryBytes) + if err != nil { + return nil, err + } + if len(data) < 4 { + return nil, ErrMalformed + } + available := uint64(binary.BigEndian.Uint32(data[:4])) + end := min(uint64(len(data)), available+4) + for offset := uint64(4); offset < end; { + if end-offset < 5 { + return nil, ErrMalformed + } + length := uint64(binary.BigEndian.Uint16(data[offset+3 : offset+5])) + if offset+5+length > end { + return nil, ErrMalformed + } + if binary.BigEndian.Uint16(data[offset:offset+2]) == identifier { + if data[offset+2]&3 != 1 { + return nil, ErrMalformed + } + return append([]byte(nil), data[offset+5:offset+5+length]...), nil + } + offset += 5 + length + } + if available+4 > uint64(len(data)) { + return nil, ErrMalformed + } + return nil, nil +} + +// Barcode reads the cartridge memory barcode identity; an unavailable attribute is reported as empty. +func (d *Device) Barcode(ctx context.Context) (string, error) { + data, err := d.attribute(ctx, 0x0806) + if Reason(err) == entity.DeviceReason_DEVICE_REASON_UNSUPPORTED { + return "", nil + } + if err != nil { + return "", err + } + value := strings.TrimSpace(string(data)) + if !utf8.ValidString(value) || strings.ContainsRune(value, 0) { + return "", ErrMalformed + } + return value, nil +} + +// WriteBarcode assigns the cartridge memory barcode during FORMAT, as LTFS mkltfs does. The SPC-4 host-type +// BARCODE attribute is a 32-byte ASCII value, left-aligned and space-padded. +func (d *Device) WriteBarcode(ctx context.Context, barcode string) error { + if d.operation != Format { + return fmt.Errorf("Tape barcode assignment requires FORMAT preparation") + } + cdb, data, err := writeBarcodeCommand(barcode) + if err != nil { + return err + } + response, err := d.transport.Execute(ctx, Command{CDB: cdb, Direction: ToDevice, Data: data, Timeout: 2 * time.Minute, validate: controlResponse}) + if err != nil { + return err + } + if err := CheckResponse(response); err != nil { + return err + } + if response.Residual != 0 { + return &UncertainError{Cause: ErrMalformed} + } + return nil +} + +// writeBarcodeCommand encodes WRITE ATTRIBUTE (8Dh) for partition zero with one BARCODE (0806h) attribute. +func writeBarcodeCommand(barcode string) ([]byte, []byte, error) { + if barcode == "" || len(barcode) > 32 { + return nil, nil, fmt.Errorf("Tape barcode attribute length is invalid") + } + for i := 0; i < len(barcode); i++ { + if barcode[i] < 0x20 || barcode[i] > 0x7e { + return nil, nil, fmt.Errorf("Tape barcode attribute is not printable ASCII") + } + } + data := make([]byte, 4+5+32) + binary.BigEndian.PutUint32(data[:4], uint32(len(data)-4)) + binary.BigEndian.PutUint16(data[4:6], 0x0806) + data[6] = 1 // ASCII format. + binary.BigEndian.PutUint16(data[7:9], 32) + copy(data[9:], barcode) + for i := 9 + len(barcode); i < len(data); i++ { + data[i] = ' ' + } + cdb := make([]byte, 16) + cdb[0] = 0x8d + binary.BigEndian.PutUint32(cdb[10:14], uint32(len(data))) + return cdb, data, nil +} + +func (d *Device) partitionCount(ctx context.Context) (uint32, error) { + // Partition lists have a two-byte available-data length, unlike attribute-value responses. + data, err := query(ctx, d.transport, []byte{0x8c, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 0, 0}, 4) + if err != nil { + return 0, err + } + if len(data) != 4 || binary.BigEndian.Uint16(data[:2]) != 2 || data[2] != 0 || data[3] == 0 { + return 0, ErrMalformed + } + return uint32(data[3]), nil +} + +func (d *Device) actualPartitionCount(ctx context.Context, partition []byte) (uint32, error) { + // POFM mode pages may contain a pending layout; read/append cannot treat it as physical evidence. + count, err := d.partitionCount(ctx) + if err == nil { + return count, nil + } + if Reason(err) == entity.DeviceReason_DEVICE_REASON_UNSUPPORTED && len(partition) >= 8 && partition[4]&4 == 0 && partition[3] <= partition[2] { + return uint32(partition[3]) + 1, nil + } + return 0, fmt.Errorf("actual Tape partition layout is unavailable, %w", err) +} diff --git a/internal/media/tape/device_test.go b/internal/media/tape/device_test.go new file mode 100644 index 0000000..12cca07 --- /dev/null +++ b/internal/media/tape/device_test.go @@ -0,0 +1,510 @@ +package tape + +import ( + "context" + "encoding/binary" + "errors" + "syscall" + "testing" + "unsafe" + + "github.com/samuelncui/yatm/entity" +) + +type deviceFixture struct { + noPartitionList bool + deferredPartitionCount, failedFormat, wrongPartitionControls, wrongFormattedCount bool + mode []byte + selects, motions, writes, closed, formats, security int + absentEncryption, failedReadback, failureCleanup bool + unsupported byte + actualPartitions byte + readResponse *Response + attributes []Command +} + +func newDeviceFixture() *deviceFixture { + // Synthetic SSC pages cover record controls, EOD generation, layout and rewritable medium facts. + mode := make([]byte, 88) + binary.BigEndian.PutUint16(mode, 86) + mode[3], mode[7], mode[14] = 0x10, 8, 2 + mode[16], mode[17], mode[18], mode[19] = 0x0f, 14, 0xc0, 0x80 + binary.BigEndian.PutUint32(mode[20:24], 1) + binary.BigEndian.PutUint32(mode[24:28], 1) + mode[32], mode[33], mode[42] = 0x10, 14, 0x10 + mode[46] = 1 + mode[48], mode[49], mode[50], mode[52] = 0x11, 6, 3, 0x24 + mode[56], mode[57] = 0x1d, 30 + return &deviceFixture{mode: mode, actualPartitions: 1} +} + +func (f *deviceFixture) execute(command Command) (Response, error) { + // Every device query is bounded; state-changing commands are counted and mutate only this fixture. + var data []byte + switch command.CDB[0] { + case 0x12: + response, err := inspectionFixture(command) + if command.CDB[1] == 0 { + command.Data[2], command.Data[3] = 5, 2 + } + return response, err + case 0x5a: + data = append([]byte(nil), f.mode...) + if command.CDB[2] == 0x7f { + data[18], data[19], data[46], data[51], data[52] = 0x80, 0xe0, 1, 0xff, 0xe0 + } + if f.failedReadback && f.selects == 1 { + data[14] = 2 + } + case 0x34: + response, err := inspectionFixture(command) + if command.CDB[1] == 6 { + binary.BigEndian.PutUint32(command.Data[4:8], uint32(f.mode[35])) + } else { + command.Data[1] = f.mode[35] + } + return response, err + case 0xa2: + f.security++ + if command.CDB[1] == 0 { + protocol := byte(0x20) + if f.absentEncryption { + protocol = 0 + } + data = []byte{0, 0, 0, 0, 0, 0, 0, 1, protocol} + } else { + return inspectionFixture(command) + } + case 0xa3: + opcode := command.CDB[3] + if opcode == f.unsupported { + data = []byte{0, 1} + break + } + length := 6 + if opcode == 0x55 { + length = 10 + } + if opcode == 0x34 || opcode == 0x2b { + length = 10 + } + if opcode == 0x92 { + length = 16 + } + data = make([]byte, 4+length) + data[1], data[3], data[4] = 3, byte(length), opcode + for i := 5; i < len(data); i++ { + data[i] = 0xff + } + if opcode == 0x34 { + data[5] = command.CDB[5] + } + case 0x55: + f.selects++ + if f.failureCleanup && f.selects > 1 { + return Response{Completed: true, Status: 2, Sense: fixedSense(0x70, 5, 0x26, 0, false, 0, 0)}, nil + } + if command.CDB[1] != 0x10 || command.Data[0] != 0 || command.Data[1] != 0 { + return Response{}, errors.New("saved or malformed mode selection") + } + f.mode[3] = f.mode[3]&0x80 | command.Data[3]&0x7f + if command.Data[7] == 0 { + count := f.mode[51] + copy(f.mode[48:56], command.Data[8:16]) + if f.deferredPartitionCount { + f.mode[51] = count + } + if f.wrongPartitionControls { + f.mode[52] ^= 0x40 + } + } else { + copy(f.mode[13:16], command.Data[13:16]) + pages, err := selectionPages(command.Data) + if err != nil { + return Response{}, err + } + _, current, err := modeData(f.mode) + if err != nil { + return Response{}, err + } + for _, code := range []byte{0x0f, 0x10} { + if page := pages[code]; len(page) != 0 { + copy(current[code], page) + } + } + } + case 4: + f.formats++ + if f.failedFormat { + return Response{Completed: true, Status: 2, Sense: fixedSense(0x70, 5, 0x24, 0, false, 0, 0)}, nil + } + if f.wrongFormattedCount { + break + } + f.actualPartitions = 1 + f.mode[51] = 0 + case 1: + f.motions++ + case 0x92: + f.motions++ + f.mode[35] = command.CDB[3] + case 8: + if f.readResponse != nil { + return *f.readResponse, nil + } + case 0x0a, 0x10: + f.writes++ + case 0x8d: + f.attributes = append(f.attributes, Command{CDB: append([]byte(nil), command.CDB...), Direction: command.Direction, Data: append([]byte(nil), command.Data...)}) + case 0x8c: + if command.CDB[1] == 3 { + if f.noPartitionList { + return Response{Completed: true, Status: 2, Sense: fixedSense(0x70, 5, 0x24, 0, false, 0, 0)}, nil + } + data = []byte{0, 2, 0, f.actualPartitions} + } else { + data = []byte{0, 0, 0, 11, 8, 6, 1, 0, 6, 'A', 'B', 'C', '1', '2', '3'} + } + default: + return inspectionFixture(command) + } + copy(command.Data, data) + residual := int32(0) + if command.Direction == FromDevice { + residual = int32(len(command.Data) - len(data)) + } + return Response{Completed: true, Residual: residual}, nil +} + +func fixtureDevice(t *testing.T, f *deviceFixture) *Device { + t.Helper() + transport := newSerializedTransport(f.execute, func() error { f.closed++; return nil }) + device, err := NewDevice(context.Background(), Identity{Configured: "fixture"}, transport) + if err != nil { + t.Fatal(err) + } + return device +} + +func TestDevicePartitionListResponse(t *testing.T) { + for _, tc := range []struct { + name string + data []byte + count uint32 + err error + }{ + {"single", []byte{0, 2, 0, 1}, 1, nil}, + {"multiple", []byte{0, 2, 0, 2}, 2, nil}, + {"short", []byte{0, 2, 0}, 0, ErrMalformed}, + {"wrong length", []byte{0, 3, 0, 2}, 0, ErrMalformed}, + {"nonzero first", []byte{0, 2, 1, 2}, 0, ErrMalformed}, + {"no partitions", []byte{0, 2, 0, 0}, 0, ErrMalformed}, + } { + t.Run(tc.name, func(t *testing.T) { + // Standard four-byte responses include their own two-byte length and partition range. + var observed Command + transport := newSerializedTransport(func(command Command) (Response, error) { + observed = command + copy(command.Data, tc.data) + return Response{Completed: true, Residual: int32(len(command.Data) - len(tc.data))}, nil + }, func() error { return nil }) + defer transport.Close() + + // The real query/decoder must retain malformed-layout refusal without guessing a count. + count, err := (&Device{transport: transport}).partitionCount(context.Background()) + if count != tc.count || !errors.Is(err, tc.err) { + t.Fatalf("partition count=%d, error=%v; want count=%d, error=%v", count, err, tc.count, tc.err) + } + if len(observed.CDB) != 16 || observed.CDB[0] != 0x8c || observed.CDB[1] != 3 || + binary.BigEndian.Uint32(observed.CDB[10:14]) != 4 || len(observed.Data) != 4 { + t.Fatalf("partition-list query CDB=%x, allocation=%d", observed.CDB, len(observed.Data)) + } + }) + } +} + +func TestNewDeviceInspectionFailurePreservesCloseUncertainty(t *testing.T) { + // Failed construction has no Device cleanup owner; failed closure must retain the attempt lease. + inspectionErr := errors.New("inspection failed before command acceptance") + for _, closeErr := range []error{nil, syscall.EIO} { + commands, closes := 0, 0 + transport := newSerializedTransport(func(Command) (Response, error) { + commands++ + return Response{}, inspectionErr + }, func() error { + closes++ + return closeErr + }) + device, err := NewDevice(context.Background(), Identity{Configured: "fixture"}, transport) + if device != nil || !errors.Is(err, inspectionErr) || commands != 1 || closes != 1 { + t.Fatalf("device=%v err=%v commands=%d closes=%d", device, err, commands, closes) + } + if errors.Is(err, ErrUncertain) != (closeErr != nil) || closeErr != nil && !errors.Is(err, closeErr) { + t.Fatalf("close=%v construction error=%v", closeErr, err) + } + } +} + +func TestDeviceConfigurationDoesNotAdvertiseGrownReservationAsMaximum(t *testing.T) { + for _, actual := range []int{512 << 10, 64 << 10} { + // Preparation requires exact reserve readback without turning the current allocation into a maximum. + f := newDeviceFixture() + transport := &sgTransport{limit: 32 << 10, serializedTransport: newSerializedTransport(f.execute, func() error { f.closed++; return nil })} + transport.ioctl = func(operation uintptr, pointer unsafe.Pointer) error { + value := (*int32)(pointer) + if operation == sgSetReservedSize { + if *value != 512<<10 { + t.Fatalf("selected reservation clamped: %d", *value) + } + } else if operation == sgReservedSize { + *value = int32(actual) + } else { + t.Fatalf("unexpected reservation ioctl: %x", operation) + } + return nil + } + d, err := NewDevice(context.Background(), Identity{Configured: "fixture"}, transport) + if err != nil { + t.Fatal(err) + } + _, err = d.Configure(context.Background(), &entity.TarMediaProfile{RecordMode: entity.TapeRecordMode_TAPE_RECORD_MODE_VARIABLE, RecordBytes: 512 << 10}, Read) + if (err == nil) != (actual == 512<<10) || transport.TransferLimit() != actual { + t.Fatalf("actual=%d reservation=%d preparation=%v", actual, transport.TransferLimit(), err) + } + for _, kind := range []entity.DeviceFeatureKind{entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_SEQUENTIAL_READ, entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_SEQUENTIAL_WRITE} { + if limits := feature(d.Report(), kind).GetRecords(); limits.TransportMaximumBytes != nil { + t.Fatalf("current allocation advertised as maximum: %v", limits) + } + } + if actual < 512<<10 && (f.selects != 0 || f.motions != 0 || f.writes != 0) { + t.Fatal("insufficient reservation admitted device mutation") + } + if err := d.Cleanup(context.Background()); err != nil { + t.Fatal(err) + } + } +} + +func TestDeviceCapturesRestoresModeAndAcceptsBootstrapPolicy(t *testing.T) { + // No initialization UUID is available before reading a label; transport policy alone is sufficient. + f := newDeviceFixture() + d := fixtureDevice(t, f) + records, err := d.Configure(context.Background(), &entity.TarMediaProfile{RecordMode: entity.TapeRecordMode_TAPE_RECORD_MODE_VARIABLE, RecordBytes: 512}, Format) + if err != nil || records.Mode.FixedBytes != 0 || !records.Mode.WarningCounts || blockLength(f.mode) != 0 || f.mode[18]&0x80 != 0 { + t.Fatalf("preparation=%+v err=%v mode=%x", records, err, f.mode) + } + if f.motions != 0 || f.writes != 0 { + t.Fatal("configuration moved or wrote Tape") + } + + // Initialization positions BOT; cleanup restores only captured controls and never emits a filemark. + if err := d.Initialize(context.Background()); err != nil { + t.Fatal(err) + } + if err := d.Cleanup(context.Background()); err != nil { + t.Fatal(err) + } + if blockLength(f.mode) != 512 || f.mode[18]&0x80 == 0 || f.mode[19]&0x80 == 0 || f.selects != 2 || f.writes != 0 || f.closed != 1 { + t.Fatalf("cleanup mode=%x selects=%d writes=%d closed=%d", f.mode, f.selects, f.writes, f.closed) + } + if err := d.Cleanup(context.Background()); err != nil || f.closed != 1 { + t.Fatal("cleanup repeated") + } +} + +func TestDeviceReadSkipsWriteOnlyPrerequisitesAndKnownAbsentControls(t *testing.T) { + // Missing compression and tape-encryption capabilities do not require unsupported control commands. + f := newDeviceFixture() + f.absentEncryption = true + f.mode = append(f.mode[:16], f.mode[32:]...) + binary.BigEndian.PutUint16(f.mode, uint16(len(f.mode)-2)) + f.mode[3] |= 0x80 + f.mode[26], f.mode[30], f.mode[42] = 0, 1, 1 + d := fixtureDevice(t, f) + _, err := d.Configure(context.Background(), &entity.TarMediaProfile{RecordMode: entity.TapeRecordMode_TAPE_RECORD_MODE_FIXED, RecordBytes: 512}, Read) + if err != nil || f.selects != 0 || d.Report().GetTape().EncryptionEnabled == nil || d.Report().GetTape().GetEncryptionEnabled() { + t.Fatalf("read preparation=%v selects=%d report=%v", err, f.selects, d.Report()) + } + if err := d.Cleanup(context.Background()); err != nil { + t.Fatal(err) + } +} + +func TestDevicePreparationFailureRestoresFirstAttemptedMutation(t *testing.T) { + for _, cleanupFails := range []bool{false, true} { + // A failed mode readback still owns restoration, even though no Session reached its caller. + f := newDeviceFixture() + f.failedReadback, f.failureCleanup = true, cleanupFails + d := fixtureDevice(t, f) + _, err := d.Configure(context.Background(), &entity.TarMediaProfile{RecordMode: entity.TapeRecordMode_TAPE_RECORD_MODE_VARIABLE, RecordBytes: 512}, Format) + if err == nil || errors.Is(err, ErrUncertain) != cleanupFails || f.selects != 2 || f.closed != 1 || f.writes != 0 { + t.Fatalf("failure=%v selects=%d closed=%d writes=%d", err, f.selects, f.closed, f.writes) + } + } +} + +func TestDeviceRefusesUnsupportedPartitionTransitionBeforeMutation(t *testing.T) { + // An explicit unsupported FORMAT command blocks reinitialization before any mode/data mutation. + f := newDeviceFixture() + f.mode[51], f.actualPartitions, f.unsupported = 1, 2, 4 + d := fixtureDevice(t, f) + _, err := d.Configure(context.Background(), &entity.TarMediaProfile{RecordMode: entity.TapeRecordMode_TAPE_RECORD_MODE_VARIABLE, RecordBytes: 512}, Format) + if !errors.Is(err, ErrUnsupported) || f.selects != 0 || f.writes != 0 || f.formats != 0 { + t.Fatalf("transition=%v selects=%d writes=%d formats=%d", err, f.selects, f.writes, f.formats) + } +} + +func TestDeviceFormatsActualSinglePartitionAndOptionalBarcode(t *testing.T) { + // A pending mode-page change is followed by FORMAT and an actual partition-list observation. + f := newDeviceFixture() + f.mode[51], f.actualPartitions = 1, 2 + d := fixtureDevice(t, f) + _, err := d.Configure(context.Background(), &entity.TarMediaProfile{RecordMode: entity.TapeRecordMode_TAPE_RECORD_MODE_VARIABLE, RecordBytes: 512}, Format) + if err != nil { + t.Fatal(err) + } + if err := d.Initialize(context.Background()); err != nil { + t.Fatal(err) + } + barcode, err := d.Barcode(context.Background()) + if err != nil || barcode != "ABC123" || f.formats != 1 || d.Report().GetTape().GetPartitionCount() != 1 { + t.Fatalf("barcode=%q err=%v", barcode, err) + } + if err := d.Cleanup(context.Background()); err != nil { + t.Fatal(err) + } +} + +func TestDeviceWriteBarcodeEncodesPaddedASCIIAttribute(t *testing.T) { + // Only FORMAT preparation may assign the cartridge memory barcode; the value is ASCII and space-padded to 32 bytes. + f := newDeviceFixture() + d := fixtureDevice(t, f) + profile := &entity.TarMediaProfile{RecordMode: entity.TapeRecordMode_TAPE_RECORD_MODE_VARIABLE, RecordBytes: 512} + if _, err := d.Configure(context.Background(), profile, Read); err != nil { + t.Fatal(err) + } + if err := d.WriteBarcode(context.Background(), "ABC123"); err == nil || len(f.attributes) != 0 { + t.Fatalf("read preparation assigned barcode, err=%v commands=%d", err, len(f.attributes)) + } + if err := d.Cleanup(context.Background()); err != nil { + t.Fatal(err) + } + + d = fixtureDevice(t, f) + if _, err := d.Configure(context.Background(), profile, Format); err != nil { + t.Fatal(err) + } + for _, invalid := range []string{"", "ABC\x00", "123456789012345678901234567890123"} { + if err := d.WriteBarcode(context.Background(), invalid); err == nil { + t.Fatalf("invalid barcode %q was accepted", invalid) + } + } + if err := d.WriteBarcode(context.Background(), "ABC123"); err != nil || len(f.attributes) != 1 { + t.Fatalf("err=%v commands=%d", err, len(f.attributes)) + } + wantCDB := []byte{0x8d, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 41, 0, 0} + wantData := append([]byte{0, 0, 0, 37, 8, 6, 1, 0, 32, 'A', 'B', 'C', '1', '2', '3'}, []byte(" ")...) + command := f.attributes[0] + if string(command.CDB) != string(wantCDB) || string(command.Data) != string(wantData) || command.Direction != ToDevice { + t.Fatalf("cdb=% x data=% x", command.CDB, command.Data) + } + if err := d.Cleanup(context.Background()); err != nil { + t.Fatal(err) + } +} + +func TestDeviceUncertainReadWithoutModeChangesRetainsCleanupError(t *testing.T) { + // A poisoned read must retain device unavailability even when there are no controls to restore. + f := newDeviceFixture() + d := fixtureDevice(t, f) + records, err := d.Configure(context.Background(), &entity.TarMediaProfile{RecordMode: entity.TapeRecordMode_TAPE_RECORD_MODE_FIXED, RecordBytes: 512}, Read) + if err != nil { + t.Fatal(err) + } + f.readResponse = &Response{Completed: true, HostStatus: 3} + if _, err := records.Read(context.Background(), make([]byte, 512)); !errors.Is(err, ErrUncertain) { + t.Fatal(err) + } + if err := d.Cleanup(context.Background()); !errors.Is(err, ErrUncertain) || f.selects != 0 || f.closed != 1 { + t.Fatalf("cleanup=%v", err) + } +} + +func TestDeviceFormatChangesActivePartitionOnlyWithReportedForm(t *testing.T) { + for _, unsupported := range []bool{false, true} { + // Former LTFS media may start in partition one; reported CP support enables structural preparation. + f := newDeviceFixture() + f.mode[35], f.mode[51], f.actualPartitions = 1, 1, 2 + if unsupported { + f.unsupported = 0x92 + } + d := fixtureDevice(t, f) + _, err := d.Configure(context.Background(), &entity.TarMediaProfile{RecordMode: entity.TapeRecordMode_TAPE_RECORD_MODE_VARIABLE, RecordBytes: 512}, Format) + if unsupported { + if !errors.Is(err, ErrUnsupported) || f.motions != 0 || f.selects != 0 || f.writes != 0 { + t.Fatalf("unsupported transition=%v motions=%d", err, f.motions) + } + continue + } + if err != nil || f.mode[35] != 0 || f.motions != 1 || f.writes != 0 { + t.Fatalf("partition preparation=%v motions=%d", err, f.motions) + } + if err := d.Initialize(context.Background()); err != nil { + t.Fatal(err) + } + if err := d.Cleanup(context.Background()); err != nil { + t.Fatal(err) + } + } +} + +func TestReadAndAppendUseActualLayoutInsteadOfPendingModeCount(t *testing.T) { + for _, operation := range []Operation{Read, Append} { + // The mode page claims a pending single partition while MAM reports two physical partitions. + f := newDeviceFixture() + f.actualPartitions = 2 + d := fixtureDevice(t, f) + _, err := d.Configure(context.Background(), &entity.TarMediaProfile{RecordMode: entity.TapeRecordMode_TAPE_RECORD_MODE_FIXED, RecordBytes: 512}, operation) + if err == nil || f.selects != 0 || f.writes != 0 || f.motions != 0 { + t.Fatalf("pending layout accepted: err=%v", err) + } + } +} + +func TestPartitionListFallbackRequiresNonPendingCurrentMode(t *testing.T) { + for _, pending := range []bool{false, true} { + // Only immediate-layout mode data remains authoritative when the optional partition list is absent. + f := newDeviceFixture() + f.noPartitionList = true + if !pending { + f.mode[52] &^= 4 + } + d := fixtureDevice(t, f) + _, err := d.Configure(context.Background(), &entity.TarMediaProfile{RecordMode: entity.TapeRecordMode_TAPE_RECORD_MODE_FIXED, RecordBytes: 512}, Read) + if (err != nil) != pending { + t.Fatalf("pending=%t read preparation=%v", pending, err) + } + if !pending { + if err := d.Cleanup(context.Background()); err != nil { + t.Fatal(err) + } + } + } +} + +func TestWritesRefuseUnknownEODAndBufferModesBeforeMutation(t *testing.T) { + for _, buffered := range []bool{false, true} { + // Reserved mode values cannot qualify synchronization or new recorded-EOD semantics. + f := newDeviceFixture() + if buffered { + f.mode[3] = 0x70 + } else { + f.mode[42] = 0x90 + } + d := fixtureDevice(t, f) + _, err := d.Configure(context.Background(), &entity.TarMediaProfile{RecordMode: entity.TapeRecordMode_TAPE_RECORD_MODE_FIXED, RecordBytes: 512}, Format) + if err == nil || f.selects != 0 || f.writes != 0 || f.motions != 0 { + t.Fatalf("unknown mode accepted: %v", err) + } + } +} diff --git a/internal/media/tape/encryption.go b/internal/media/tape/encryption.go new file mode 100644 index 0000000..645a71e --- /dev/null +++ b/internal/media/tape/encryption.go @@ -0,0 +1,58 @@ +package tape + +import ( + "context" + "errors" + + "github.com/samuelncui/yatm/entity" +) + +func (d *Device) inspectEncryption(ctx context.Context, status []byte) error { + // Diagnostics describe reported capabilities; the configured Tape script owns key management. + f := feature(d.report, entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_HARDWARE_ENCRYPTION) + if status == nil { + f.Support, f.Availability, f.Reason = entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_UNSUPPORTED, entity.DeviceFeatureAvailability_DEVICE_FEATURE_AVAILABILITY_UNAVAILABLE, entity.DeviceReason_DEVICE_REASON_UNSUPPORTED + f.Evidence, f.Detail = entity.DeviceFeatureEvidence_DEVICE_FEATURE_EVIDENCE_REPORTED, "Tape Data Encryption protocol is absent" + return nil + } + + // Current modes remain useful independently of optional named-algorithm observations. + state, err := decodeEncryptionState(status) + if err != nil { + return err + } + d.reportEncryptionState(state) + f.Support, f.Evidence = entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_PARTIAL, entity.DeviceFeatureEvidence_DEVICE_FEATURE_EVIDENCE_REPORTED + + // Query optional algorithm details while preserving the known protocol and status. + data, err := securityPage(ctx, d.transport, 0x10) + if err != nil { + f.Availability, f.Reason, f.Detail = entity.DeviceFeatureAvailability_DEVICE_FEATURE_AVAILABILITY_UNKNOWN, Reason(err), "Encryption algorithm observation is unavailable" + return errors.Join(err, ctx.Err()) + } + + // Malformed optional algorithms retain the known status and their own diagnostic reason. + algorithms, err := decodeEncryptionCapabilities(data) + if err != nil { + f.Availability, f.Reason, f.Detail = entity.DeviceFeatureAvailability_DEVICE_FEATURE_AVAILABILITY_UNKNOWN, Reason(err), err.Error() + return err + } + + // An unnamed algorithm cannot revoke the Tape Data Encryption protocol already observed. + limits := f.GetEncryption() + limits.Algorithms = nil + for _, algorithm := range algorithms { + if algorithm.encrypt != 2 && algorithm.decrypt != 2 { + continue + } + limits.Algorithms = append(limits.Algorithms, &entity.TapeEncryptionAlgorithm{Name: EncryptionAES256GCM, Index: uint32(algorithm.index), KeyBytes: 32}) + if algorithm.encrypt == 2 && algorithm.decrypt == 2 { + f.Support = entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_SUPPORTED + } else if f.Support != entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_SUPPORTED { + f.Support = entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_PARTIAL + } + } + f.Availability, f.Reason = entity.DeviceFeatureAvailability_DEVICE_FEATURE_AVAILABILITY_UNKNOWN, 0 + f.Detail = "Reported encryption algorithms and current modes; the configured Tape script owns preparation" + return nil +} diff --git a/internal/media/tape/encryption_pages.go b/internal/media/tape/encryption_pages.go new file mode 100644 index 0000000..1f4029a --- /dev/null +++ b/internal/media/tape/encryption_pages.go @@ -0,0 +1,110 @@ +package tape + +import ( + "context" + "encoding/binary" + + "github.com/samuelncui/yatm/entity" + "google.golang.org/protobuf/proto" +) + +// EncryptionAES256GCM identifies the public algorithm; its device index is discovered on each reader. +const EncryptionAES256GCM = "AES-256-GCM-128" + +type encryptionState struct { + nexus, scope, encrypt, decrypt, algorithm, control byte + counter uint32 +} + +type encryptionAlgorithm struct { + index, encrypt, decrypt byte +} + +func securityPage(ctx context.Context, transport Transport, page uint16) ([]byte, error) { + // Each public status/capability page is read independently with a bounded byte allocation. + cdb := []byte{0xa2, 0x20, byte(page >> 8), byte(page), 0, 0, 0, 0, 0x10, 0, 0, 0} + data, err := query(ctx, transport, cdb, MaxQueryBytes) + if err != nil { + return nil, err + } + return securityEnvelope(data, page, 4) +} + +func securityEnvelope(data []byte, page uint16, minimum int) ([]byte, error) { + if len(data) < minimum || binary.BigEndian.Uint16(data[:2]) != page { + return nil, ErrMalformed + } + length := int(binary.BigEndian.Uint16(data[2:4])) + 4 + if length < minimum || length > len(data) { + return nil, ErrMalformed + } + return data[:length], nil +} + +func decodeEncryptionState(data []byte) (encryptionState, error) { + // Key counters are opaque instances; neither zero nor nonzero reports whether key material is present. + data, err := securityEnvelope(data, 0x20, 24) + if err != nil { + return encryptionState{}, err + } + state := encryptionState{nexus: data[4] >> 5, scope: data[4] & 7, encrypt: data[5], decrypt: data[6], algorithm: data[7], counter: binary.BigEndian.Uint32(data[8:12]), control: data[12] >> 4 & 7} + if data[4]&0x18 != 0 || state.nexus > 2 || state.scope > 2 || state.encrypt > 2 || state.decrypt > 3 || data[12]&0x80 != 0 { + return encryptionState{}, ErrMalformed + } + if state.encrypt == 0 && state.decrypt == 0 && len(data) != 24 { + return encryptionState{}, ErrMalformed + } + for offset := 24; offset < len(data); { + if len(data)-offset < 4 { + return encryptionState{}, ErrMalformed + } + length := int(binary.BigEndian.Uint16(data[offset+2:offset+4])) + 4 + if length > len(data)-offset { + return encryptionState{}, ErrMalformed + } + offset += length + } + return state, nil +} + +func decodeEncryptionCapabilities(data []byte) ([]encryptionAlgorithm, error) { + // Preserve reader-local indexes and only name an algorithm with its reported public code and key length. + data, err := securityEnvelope(data, 0x10, 20) + if err != nil { + return nil, err + } + var algorithms []encryptionAlgorithm + seen := make(map[byte]bool) + for offset := 20; offset < len(data); { + if len(data)-offset < 4 { + return nil, ErrMalformed + } + length := int(binary.BigEndian.Uint16(data[offset+2:offset+4])) + 4 + if length < 24 || length > len(data)-offset || seen[data[offset]] { + return nil, ErrMalformed + } + page := data[offset : offset+length] + seen[page[0]] = true + if binary.BigEndian.Uint32(page[20:24]) == 0x00010014 && binary.BigEndian.Uint16(page[10:12]) == 32 { + algorithms = append(algorithms, encryptionAlgorithm{index: page[0], encrypt: page[4] & 3, decrypt: page[4] >> 2 & 3}) + } + offset += length + } + return algorithms, nil +} + +func (d *Device) reportEncryptionState(state encryptionState) { + // Status contains non-secret settings, not the recorded block's key or encrypted content identity. + limits := feature(d.report, entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_HARDWARE_ENCRYPTION).GetEncryption() + if limits == nil { + limits = new(entity.TapeEncryptionLimits) + feature(d.report, entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_HARDWARE_ENCRYPTION).Limits = &entity.DeviceFeature_Encryption{Encryption: limits} + } + limits.CurrentAlgorithmIndex = nil + if state.encrypt != 0 || state.decrypt != 0 { + limits.CurrentAlgorithmIndex = proto.Uint32(uint32(state.algorithm)) + } + limits.EncryptionMode, limits.DecryptionMode = proto.Uint32(uint32(state.encrypt)), proto.Uint32(uint32(state.decrypt)) + limits.ITNexusScope, limits.KeyScope = proto.Uint32(uint32(state.nexus)), proto.Uint32(uint32(state.scope)) + limits.ParametersControl, limits.KeyInstanceCounter = proto.Uint32(uint32(state.control)), proto.Uint32(state.counter) +} diff --git a/internal/media/tape/encryption_test.go b/internal/media/tape/encryption_test.go new file mode 100644 index 0000000..d955cdc --- /dev/null +++ b/internal/media/tape/encryption_test.go @@ -0,0 +1,196 @@ +package tape + +import ( + "context" + "encoding/binary" + "errors" + "testing" + + "github.com/samuelncui/yatm/entity" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" +) + +type encryptionFixture struct { + *deviceFixture + state encryptionState + index byte + sets, statusQueries int + capabilities []byte + unavailable bool +} + +func newEncryptionFixture() *encryptionFixture { + return &encryptionFixture{deviceFixture: newDeviceFixture(), state: encryptionState{control: 1}, index: 3} +} + +func encryptionPage(page uint16, length int) []byte { + data := make([]byte, length) + binary.BigEndian.PutUint16(data[:2], page) + binary.BigEndian.PutUint16(data[2:4], uint16(length-4)) + return data +} + +func encryptionTestProfile() *entity.TarMediaProfile { + return &entity.TarMediaProfile{RecordMode: entity.TapeRecordMode_TAPE_RECORD_MODE_VARIABLE, RecordBytes: 512} +} + +func (f *encryptionFixture) execute(command Command) (Response, error) { + // The fixture exposes script-installed state. Native security-out commands must never occur. + if command.CDB[0] == 0xb5 { + f.sets++ + return Response{}, errors.New("native key mutation is forbidden") + } + if command.CDB[0] != 0xa2 || command.CDB[1] != 0x20 { + return f.deviceFixture.execute(command) + } + var data []byte + switch binary.BigEndian.Uint16(command.CDB[2:4]) { + case 0x10: + if f.unavailable { + return Response{Completed: true, Status: 2, Sense: fixedSense(0x70, 5, 0x24, 0, false, 0, 0)}, nil + } + data = encryptionPage(0x10, 44) + data[20], data[23], data[24] = f.index, 20, 0xba + binary.BigEndian.PutUint16(data[30:32], 32) + binary.BigEndian.PutUint32(data[40:44], 0x00010014) + if f.capabilities != nil { + data = f.capabilities + } + case 0x20: + f.statusQueries++ + data = encryptionPage(0x20, 24) + data[4], data[5], data[6], data[7], data[12] = f.state.nexus<<5|f.state.scope, f.state.encrypt, f.state.decrypt, f.state.algorithm, f.state.control<<4 + binary.BigEndian.PutUint32(data[8:12], f.state.counter) + default: + return Response{}, errors.New("native key-management capability query is forbidden") + } + copy(command.Data, data) + return Response{Completed: true, Residual: int32(len(command.Data) - len(data))}, nil +} + +func fixtureEncryptionDevice(t *testing.T, f *encryptionFixture) *Device { + t.Helper() + d, err := NewDevice(t.Context(), Identity{}, newSerializedTransport(f.execute, func() error { f.closed++; return nil })) + require.NoError(t, err) + return d +} + +func TestEncryptionDiagnosticsDoNotOwnScriptKeys(t *testing.T) { + for _, state := range []encryptionState{{control: 1}, {nexus: 1, scope: 1, encrypt: 2, decrypt: 2, algorithm: 3, control: 1}, {nexus: 2, scope: 2, encrypt: 2, decrypt: 2, algorithm: 3, control: 2, counter: 17}} { + f := newEncryptionFixture() + f.state = state + d := fixtureEncryptionDevice(t, f) + report := feature(d.Report(), entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_HARDWARE_ENCRYPTION) + require.Equal(t, entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_SUPPORTED, report.Support) + require.Equal(t, entity.DeviceFeatureValidation_DEVICE_FEATURE_VALIDATION_UNVALIDATED, report.Validation) + require.Equal(t, entity.DeviceFeatureAvailability_DEVICE_FEATURE_AVAILABILITY_UNKNOWN, report.Availability) + require.Equal(t, uint32(state.encrypt), report.GetEncryption().GetEncryptionMode()) + require.Equal(t, uint32(state.decrypt), report.GetEncryption().GetDecryptionMode()) + require.Equal(t, uint32(state.counter), report.GetEncryption().GetKeyInstanceCounter()) + require.Equal(t, EncryptionAES256GCM, report.GetEncryption().Algorithms[0].Name) + require.NoError(t, d.Cleanup(context.Background())) + require.Equal(t, state, f.state) + require.Zero(t, f.sets) + } +} + +func TestScriptPreparedEncryptionUsesRecordsWithoutNativeKeyCommands(t *testing.T) { + for _, operation := range []Operation{Read, Append, Format} { + t.Run(map[Operation]string{Read: "read", Append: "append", Format: "format"}[operation], func(t *testing.T) { + f := newEncryptionFixture() + f.state = encryptionState{nexus: 2, scope: 2, encrypt: 2, decrypt: 2, algorithm: 3, control: 2} + d := fixtureEncryptionDevice(t, f) + before := f.state + profile := encryptionTestProfile() + // Legacy metadata is descriptive; a missing algorithm is equally usable after script preparation. + if operation == Read { + profile.EncryptionAlgorithm = proto.String("legacy-public-name") + } + records, err := d.Configure(t.Context(), profile, operation) + require.NoError(t, err) + queries := f.statusQueries + if operation == Format { + require.NoError(t, d.Initialize(t.Context())) + } + require.NoError(t, records.Rewind(t.Context())) + require.NoError(t, records.Filemarks(t.Context(), 0)) + require.NoError(t, d.Cleanup(context.Background())) + require.Equal(t, before, f.state) + require.Equal(t, queries, f.statusQueries, "record motion and cleanup do not recreate native key ownership checks") + require.Zero(t, f.sets) + require.Equal(t, 1, f.closed) + }) + } +} + +func TestUnknownEncryptionAlgorithmDoesNotBlockScriptPreparedRecords(t *testing.T) { + f := newEncryptionFixture() + f.unavailable = true + f.state = encryptionState{nexus: 2, scope: 2, encrypt: 2, decrypt: 2, algorithm: 9, control: 2} + d := fixtureEncryptionDevice(t, f) + require.Empty(t, feature(d.Report(), entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_HARDWARE_ENCRYPTION).GetEncryption().Algorithms) + _, err := d.Configure(t.Context(), encryptionTestProfile(), Read) + require.NoError(t, err) + require.NoError(t, d.Cleanup(context.Background())) + require.Zero(t, f.sets) +} + +func TestEncryptionAlgorithmObservationRejectsMalformedOrInventedNames(t *testing.T) { + valid := encryptionPage(0x10, 44) + valid[20], valid[23], valid[24] = 7, 20, 0xba + binary.BigEndian.PutUint16(valid[30:32], 32) + binary.BigEndian.PutUint32(valid[40:44], 0x00010014) + values, err := decodeEncryptionCapabilities(valid) + require.NoError(t, err) + require.Equal(t, []encryptionAlgorithm{{index: 7, encrypt: 2, decrypt: 2}}, values) + for _, data := range [][]byte{valid[:19], valid[:43]} { + _, err := decodeEncryptionCapabilities(data) + require.ErrorIs(t, err, ErrMalformed) + } + valid[43] = 0x15 + values, err = decodeEncryptionCapabilities(valid) + require.NoError(t, err) + require.Empty(t, values, "an unknown algorithm must remain unnamed") +} + +func TestEncryptionReportKeepsUnknownAndMalformedAlgorithmsOptional(t *testing.T) { + for _, malformed := range []bool{false, true} { + t.Run(map[bool]string{false: "unknown algorithm", true: "malformed descriptor"}[malformed], func(t *testing.T) { + // A script-installed algorithm remains usable even when native diagnostics cannot name it. + f := newEncryptionFixture() + f.state = encryptionState{nexus: 2, scope: 2, encrypt: 2, decrypt: 2, algorithm: 9, control: 2} + f.capabilities = encryptionPage(0x10, 44) + f.capabilities[20], f.capabilities[23], f.capabilities[24] = 9, 20, 0xba + binary.BigEndian.PutUint16(f.capabilities[30:32], 32) + binary.BigEndian.PutUint32(f.capabilities[40:44], 0x00010015) + if malformed { + f.capabilities[23] = 21 + } + d := fixtureEncryptionDevice(t, f) + t.Cleanup(func() { require.NoError(t, d.Cleanup(context.Background())) }) + + // The complete report retains known status, optional absence and the actual observation failure. + report := d.Report() + observed := feature(report, entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_HARDWARE_ENCRYPTION) + require.Equal(t, entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_PARTIAL, observed.Support) + require.Equal(t, entity.DeviceFeatureAvailability_DEVICE_FEATURE_AVAILABILITY_UNKNOWN, observed.Availability) + require.Equal(t, entity.DeviceFeatureValidation_DEVICE_FEATURE_VALIDATION_UNVALIDATED, observed.Validation) + require.Empty(t, observed.GetEncryption().Algorithms) + require.Equal(t, uint32(9), observed.GetEncryption().GetCurrentAlgorithmIndex()) + require.True(t, report.GetTape().GetEncryptionEnabled()) + require.Equal(t, entity.DeviceReason_DEVICE_REASON_UNSPECIFIED, report.Reason) + if malformed { + require.Equal(t, entity.DeviceReason_DEVICE_REASON_MALFORMED_RESPONSE, observed.Reason) + require.Contains(t, observed.Detail, ErrMalformed.Error()) + } else { + require.Equal(t, entity.DeviceReason_DEVICE_REASON_UNSPECIFIED, observed.Reason) + } + + // Optional diagnostics cannot revoke successful adapter preparation or cause native key commands. + _, err := d.Configure(t.Context(), encryptionTestProfile(), Read) + require.NoError(t, err) + require.Zero(t, f.sets) + }) + } +} diff --git a/internal/media/tape/geometry.go b/internal/media/tape/geometry.go new file mode 100644 index 0000000..b4c569a --- /dev/null +++ b/internal/media/tape/geometry.go @@ -0,0 +1,313 @@ +package tape + +import ( + "context" + "encoding/binary" + "errors" + "fmt" + "strings" + "sync/atomic" + "time" + + "github.com/samuelncui/yatm/entity" + "google.golang.org/protobuf/proto" +) + +// WrapEndpoint is a directory endpoint in the convention declared by its WrapMap. +type WrapEndpoint struct { + Partition uint32 + Wrap uint32 + Object uint64 +} + +// WrapMap retains the documented response order and gaps. AllWraps describes the reporting form, +// not H6 coverage; absent Fresh is unknown. Endpoint normalization and costs need separate evidence. +type WrapMap struct { + Endpoints []WrapEndpoint + AllWraps bool + ObservedAt time.Time + Fresh *bool + Source string + EndpointConvention entity.TapePositionEndpointConvention + valid *atomic.Bool +} + +// Valid tracks this opened session's snapshot lifetime independently of freshness or qualification. +func (m *WrapMap) Valid() bool { return m != nil && m.valid != nil && m.valid.Load() } + +// REOWPEndpointConvention retains vendor semantics except the qualified HP5 count-based response. +func REOWPEndpointConvention(report *entity.InspectDeviceResponse) entity.TapePositionEndpointConvention { + if (strings.TrimSpace(report.GetVendor()) == "HP" || strings.TrimSpace(report.GetVendor()) == "HPE") && + strings.TrimSpace(report.GetModel()) == "Ultrium 5-SCSI" && strings.TrimSpace(report.GetFirmware()) == "Z6ED" && + report.GetTape().GetDensityCode() == 0x58 { + return entity.TapePositionEndpointConvention_TAPE_POSITION_ENDPOINT_CONVENTION_EXCLUSIVE + } + return entity.TapePositionEndpointConvention_TAPE_POSITION_ENDPOINT_CONVENTION_INCLUSIVE +} + +// Geometry returns a request-owned observation, trying CM once when REOWP has no complete directory. +func (d *Device) Geometry(ctx context.Context) (*WrapMap, error) { + // CM is an optional preparation query, never part of ordinary Inspect or a per-file operation. + if err := ctx.Err(); err != nil { + return nil, err + } + if d.cmPending && (!d.geometry.Valid() || !d.geometry.AllWraps || len(d.geometry.Endpoints) == 0) { + d.cmPending = false + if err := d.observeCartridgeDirectory(ctx); err != nil { + return nil, err + } + } + + // Copies retain the same lifetime marker; invalidation cannot leave an earlier copy usable. + if !d.geometry.Valid() { + return nil, nil + } + copy := *d.geometry + copy.Endpoints = append([]WrapEndpoint(nil), d.geometry.Endpoints...) + if copy.Fresh != nil { + copy.Fresh = proto.Bool(*copy.Fresh) + } + return ©, nil +} + +func (d *Device) invalidateGeometry() { + d.cmPending = false + d.actualPartitions = 0 + if d.geometry != nil { + d.geometry.valid.Store(false) + } +} + +func wrapCommand(all bool) []byte { + cdb := []byte{0xa3, 0x1f, 0x45, 0, 0, 0, 0, 0, 0, 0, 0, 0} + length := uint32(10) + if all { + cdb[3], length = 2, MaxQueryBytes + } + binary.BigEndian.PutUint32(cdb[6:10], length) + return cdb +} + +func decodeWrapMap(data []byte, all bool) (*WrapMap, bool, error) { + // IBM GA32-0928-08 §5.2.20 and HPE 20-STG-L7-TRMV3-ED5 independently document this framing. + // HPE source: https://support.hpe.com/hpesc/public/docDisplay?docId=sd00006909en_us + // HPE short/long response topics: GUID-D7147C7F-2016-0901-0922-00000000067B / 67E. + if len(data) < 4 || data[2] != 0 || data[3] != 0 { + return nil, false, ErrMalformed + } + end := 2 + int(binary.BigEndian.Uint16(data[:2])) + if end < 4 || !all && end != 10 || all && (end-4)%12 != 0 { + return nil, false, ErrMalformed + } + if end > len(data) { + // Allocation truncation is a bounded partial observation, not proof of a complete populated-wrap map. + if all && len(data) == MaxQueryBytes && end > MaxQueryBytes { + return nil, false, nil + } + return nil, false, ErrMalformed + } + observed := &WrapMap{AllWraps: all, ObservedAt: time.Now(), valid: new(atomic.Bool), + EndpointConvention: entity.TapePositionEndpointConvention_TAPE_POSITION_ENDPOINT_CONVENTION_INCLUSIVE} + observed.valid.Store(true) + if !all { + // First-wrap reporting has no partition/wrap identifiers; do not fabricate wrap zero or an association. + return observed, true, nil + } + seen := make(map[[2]uint32]bool) + for offset := 4; offset < end; offset += 12 { + descriptor := data[offset : offset+12] + wrap, partition := uint32(binary.BigEndian.Uint16(descriptor[:2])), uint32(binary.BigEndian.Uint16(descriptor[2:4])) + key := [2]uint32{partition, wrap} + if descriptor[4] != 0 || descriptor[5] != 0 || seen[key] { + return nil, false, ErrMalformed + } + seen[key] = true + object := uint64(0) + for _, value := range descriptor[6:12] { + object = object<<8 | uint64(value) + } + observed.Endpoints = append(observed.Endpoints, WrapEndpoint{Partition: partition, Wrap: wrap, Object: object}) + } + return observed, true, nil +} + +func (d *Device) inspectGeometry(ctx context.Context) error { + // This maintenance-in service action is vendor-defined; only public documented families authorize its meaning. + d.invalidateGeometry() + d.geometry = nil + d.cmPending = true + f := feature(d.report, entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_END_OF_WRAP_POSITION) + vendor := strings.TrimSpace(d.report.GetVendor()) + switch vendor { + case "IBM": + case "HP", "HPE": + if !strings.HasPrefix(strings.TrimSpace(d.report.GetModel()), "Ultrium") { + f.Detail = "No applicable documented REOWP product family is established" + return nil + } + default: + f.Detail = "No applicable documented REOWP protocol family is established" + return nil + } + + // Preserve exact standard-form checks before considering the narrowly scoped vendor query policy. + f.Detail = "Documented REOWP form, complete response and load history remain unobserved" + f.Reason = entity.DeviceReason_DEVICE_REASON_NOT_READY + action := uint16(0x1f) + data, err := reportCommand(ctx, d.transport, 0xa3, &action) + if err != nil { + return d.geometryReportError(err) + } + all := true + support, err := commandFormSupport(data, wrapCommand(true)) + if err != nil { + return d.geometryReportError(err) + } + if data[1]&7 == 3 && (len(data) < 16 || data[5] != 0x1f) { + return d.geometryReportError(ErrMalformed) + } + + // A documented HPE family may observe one exact form when RSOC reports vendor-specific support. + // This is query admission, not standard support; unrelated CDB-form validation remains unchanged. + vendorReport := vendor != "IBM" && data[1]&7 == 5 + if vendorReport { + if len(data) < 16 || data[0] != 0 || data[1]&0x78 != 0 || + binary.BigEndian.Uint16(data[2:4]) != 12 || data[4] != 0xa3 || data[5] != 0x1f { + return d.geometryReportError(ErrMalformed) + } + if data[1]&0x80 != 0 && (len(data) < 28 || binary.BigEndian.Uint16(data[16:18]) != 10) { + return d.geometryReportError(ErrMalformed) + } + } + if !vendorReport && support != entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_SUPPORTED { + if support == entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_UNKNOWN { + return nil + } + all = false + support, err = commandFormSupport(data, wrapCommand(false)) + if err != nil { + return d.geometryReportError(err) + } + if support != entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_SUPPORTED { + f.Support = support + f.Evidence = entity.DeviceFeatureEvidence_DEVICE_FEATURE_EVIDENCE_REPORTED + if support == entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_UNSUPPORTED { + f.Availability, f.Reason = entity.DeviceFeatureAvailability_DEVICE_FEATURE_AVAILABILITY_UNAVAILABLE, entity.DeviceReason_DEVICE_REASON_UNSUPPORTED + } + return nil + } + } + + // Retain proven standard-form evidence without treating a vendor-specific report as an accepted query. + limits := &entity.TapeGeometryLimits{AllWraps: proto.Bool(all), Source: proto.String("A3/1F/45")} + if !vendorReport { + f.Support, f.Evidence = entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_PARTIAL, entity.DeviceFeatureEvidence_DEVICE_FEATURE_EVIDENCE_REPORTED + f.Limits = &entity.DeviceFeature_Geometry{Geometry: limits} + } + + // An absent/unready cartridge cannot produce endpoint facts; an issued failure never tries another form. + if !d.report.GetTape().GetMediumPresent() || d.report.Reason == entity.DeviceReason_DEVICE_REASON_NOT_READY { + return nil + } + length := MaxQueryBytes + if !all { + length = 10 + } + data, err = query(ctx, d.transport, wrapCommand(all), length) + if err != nil { + return d.geometryReportError(err) + } + f.Support, f.Evidence = entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_PARTIAL, entity.DeviceFeatureEvidence_DEVICE_FEATURE_EVIDENCE_OBSERVED + f.Limits = &entity.DeviceFeature_Geometry{Geometry: limits} + + // Decode only reported objects and identities; observation does not normalize boundaries or establish freshness. + observed, complete, err := decodeWrapMap(data, all) + if err != nil { + return d.geometryReportError(err) + } + if !complete { + f.Detail = "REOWP response exceeds the bounded observation; complete coverage and freshness remain unknown" + return nil + } + observed.Source, observed.EndpointConvention, d.geometry = "A3/1F/45", REOWPEndpointConvention(d.report), observed + count := len(observed.Endpoints) + if !all { + count = 1 + } else { + f.Support = entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_SUPPORTED + } + limits.ReportedWraps = proto.Uint32(uint32(count)) + f.Availability, f.Reason = entity.DeviceFeatureAvailability_DEVICE_FEATURE_AVAILABILITY_READY, 0 + f.Detail = "REOWP reporting form observed; full-wrap coverage, freshness, geometry and timing remain unvalidated" + return nil +} + +func (d *Device) geometryReportError(err error) error { + // Optional unsupported/malformed queries do not disable baseline I/O; readiness or ownership loss is attempt-wide. + f := feature(d.report, entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_END_OF_WRAP_POSITION) + d.invalidateGeometry() + f.Availability, f.Reason, f.Detail = entity.DeviceFeatureAvailability_DEVICE_FEATURE_AVAILABILITY_UNKNOWN, Reason(err), err.Error() + f.Limits, d.geometry = nil, nil + if f.Reason == entity.DeviceReason_DEVICE_REASON_UNSUPPORTED { + f.Availability = entity.DeviceFeatureAvailability_DEVICE_FEATURE_AVAILABILITY_UNAVAILABLE + } + if errors.Is(err, ErrUncertain) || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || f.Reason == entity.DeviceReason_DEVICE_REASON_NO_MEDIUM || f.Reason == entity.DeviceReason_DEVICE_REASON_NOT_READY { + return err + } + d.cmPending = true + return nil +} + +func (d *Device) observeCartridgeDirectory(ctx context.Context) error { + // This vendor READ BUFFER ID is documented for HP Ultrium; the decoder separately qualifies CM format. + vendor, model := strings.TrimSpace(d.report.GetVendor()), strings.TrimSpace(d.report.GetModel()) + if vendor != "HP" && vendor != "HPE" || model != "Ultrium 5-SCSI" || + !d.report.GetTape().GetMediumPresent() || d.report.Reason == entity.DeviceReason_DEVICE_REASON_NOT_READY { + return nil + } + // Configure already confirms actual partitions for tar. Pre-mount LTFS needs one MAM observation; + // a pending MODE SELECT page or a retained CM EOD descriptor cannot supply this authority. + partitions := d.actualPartitions + var err error + if partitions == 0 { + partitions, err = d.partitionCount(ctx) + } + var data []byte + if err == nil { + if partitions != 1 && partitions != 2 { + err = fmt.Errorf("actual cartridge partition count %d is unsupported: %w", partitions, ErrUnsupported) + } else { + d.actualPartitions = partitions + data, err = readCartridgeMemory(ctx, d.transport) + } + } + if err == nil && len(data) != 0 { + var observed *WrapMap + observed, err = decodeCartridgeDirectory(data, partitions) + if err == nil { + d.invalidateGeometry() + d.geometry = observed + d.actualPartitions = partitions + feature(d.report, entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_SOFTWARE_READ_ORDER).Detail = + "Cartridge memory directory v4/EOD v2 observed; interpolation and timing require separate qualification" + return nil + } + } + + // CM never grants REOWP command support. Only uncertain completion or lost ownership aborts preparation. + optimization := feature(d.report, entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_SOFTWARE_READ_ORDER) + if err == nil { + optimization.Detail = "Cartridge memory directory is unavailable" + return nil + } + optimization.Detail = "Cartridge memory directory is unavailable: " + err.Error() + reason := Reason(err) + var failure *CommandError + mediumChanged := errors.As(err, &failure) && failure.Sense.Key == 6 && failure.Sense.ASC == 0x28 + if errors.Is(err, ErrUncertain) || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || + mediumChanged || reason == entity.DeviceReason_DEVICE_REASON_NO_MEDIUM || reason == entity.DeviceReason_DEVICE_REASON_NOT_READY { + d.invalidateGeometry() + return err + } + return nil +} diff --git a/internal/media/tape/geometry_lifecycle_test.go b/internal/media/tape/geometry_lifecycle_test.go new file mode 100644 index 0000000..527fa70 --- /dev/null +++ b/internal/media/tape/geometry_lifecycle_test.go @@ -0,0 +1,217 @@ +package tape + +import ( + "context" + "encoding/binary" + "errors" + "strings" + "testing" + + "github.com/samuelncui/yatm/entity" +) + +func TestWrapFramesRejectMalformedIdentityWithoutGuessingBoundaries(t *testing.T) { + for _, tc := range []struct { + name string + data []byte + }{ + {"missing header", []byte{0, 2}}, + {"bad length", []byte{0, 1, 0, 0}}, + {"partial descriptor", []byte{0, 3, 0, 0, 0}}, + {"short form for all request", []byte{0, 8, 0, 0, 0, 0, 0, 0, 0, 77}}, + {"duplicate pair", wrapData(WrapEndpoint{Wrap: 3, Object: 7}, WrapEndpoint{Wrap: 3, Object: 9})}, + {"truncated transfer", wrapData(WrapEndpoint{Wrap: 3, Object: 7})[:8]}, + } { + t.Run(tc.name, func(t *testing.T) { + if observed, complete, err := decodeWrapMap(tc.data, true); observed != nil || complete || !errors.Is(err, ErrMalformed) { + t.Fatalf("map=%+v complete=%t error=%v", observed, complete, err) + } + }) + } + for _, offset := range []int{2, 3, 8, 9} { + data := wrapData(WrapEndpoint{Wrap: 3, Object: 7}) + data[offset] = 1 + if observed, _, err := decodeWrapMap(data, true); observed != nil || !errors.Is(err, ErrMalformed) { + t.Fatalf("reserved offset=%d map=%v error=%v", offset, observed, err) + } + } + // Forty-eight bits are preserved, including the maximum encoding without an invented unknown sentinel. + observed, complete, err := decodeWrapMap(wrapData(WrapEndpoint{Partition: 65535, Wrap: 65535, Object: 1<<48 - 1}), true) + if err != nil || !complete || observed.Endpoints[0].Object != 1<<48-1 || observed.Endpoints[0].Partition != 65535 { + t.Fatalf("wide map=%+v complete=%t error=%v", observed, complete, err) + } +} + +func TestWrapObservationRemainsBoundedAndNeverDeclaresFreshness(t *testing.T) { + for _, count := range []int{0, 341, 342} { + // The fixed query envelope is a resource bound, not an advertised hardware wrap maximum. + endpoints := make([]WrapEndpoint, count) + for i := range endpoints { + endpoints[i] = WrapEndpoint{Wrap: uint32(i), Object: uint64(i + 1)} + } + f := newGeometryFixture() + f.data = wrapData(endpoints...) + d := geometryDevice(t, f) + observed, err := d.Geometry(context.Background()) + limits := feature(d.Report(), entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_END_OF_WRAP_POSITION).GetGeometry() + if err != nil || limits.Fresh != nil || count <= 341 && (observed == nil || len(observed.Endpoints) != count || limits.ReportedWraps == nil || limits.GetReportedWraps() != uint32(count)) || count == 342 && (observed != nil || limits.ReportedWraps != nil) { + t.Fatalf("count=%d map=%+v limits=%v error=%v", count, observed, limits, err) + } + if err := d.Cleanup(context.Background()); err != nil { + t.Fatal(err) + } + } +} + +func TestOptionalGeometryFailurePreservesBaselineButReadinessLossBlocksControls(t *testing.T) { + for _, tc := range []struct { + name string + response *Response + data []byte + blocked bool + }{ + {"malformed response", nil, []byte{0, 3, 0, 0, 0}, false}, + {"clean unsupported query", &Response{Completed: true, Status: 2, Sense: fixedSense(0x70, 5, 0x24, 0, false, 0, 0)}, nil, false}, + {"medium loss", &Response{Completed: true, Status: 2, Sense: fixedSense(0x70, 2, 0x3a, 0, false, 0, 0)}, nil, true}, + {"not ready", &Response{Completed: true, Status: 2, Sense: fixedSense(0x70, 2, 4, 1, false, 0, 0)}, nil, true}, + } { + t.Run(tc.name, func(t *testing.T) { + f := newGeometryFixture() + f.response = tc.response + if tc.data != nil { + f.data = tc.data + } + d := geometryDevice(t, f) + report := d.Report() + profile := encryptionTestProfile() + profile.EncryptionAlgorithm = nil + _, err := d.Configure(context.Background(), profile, Read) + if (err != nil) != tc.blocked || !tc.blocked && report.Reason != 0 || tc.blocked && report.Reason == 0 || tc.blocked && (f.selects != 0 || f.motions != 0 || f.writes != 0) { + t.Fatalf("report=%v configure=%v mode=%d motion=%d write=%d", report.Reason, err, f.selects, f.motions, f.writes) + } + if tc.name == "medium loss" && report.GetTape().GetMediumPresent() || tc.name == "not ready" && !report.GetTape().GetMediumPresent() { + t.Fatalf("presence=%v", report.GetTape().MediumPresent) + } + if err := d.Cleanup(context.Background()); err != nil { + t.Fatal(err) + } + }) + } +} + +func TestGeometryCopiesInvalidateOnWritePreparationRecordWriteAndReset(t *testing.T) { + for _, operation := range []string{"write preparation", "record write", "filemark", "reset"} { + t.Run(operation, func(t *testing.T) { + f := newGeometryFixture() + d := geometryDevice(t, f) + snapshot, err := d.Geometry(context.Background()) + if err != nil || !snapshot.Valid() { + t.Fatalf("snapshot=%v error=%v", snapshot, err) + } + profile := encryptionTestProfile() + profile.EncryptionAlgorithm = nil + records, err := d.Configure(context.Background(), profile, Read) + if err != nil { + t.Fatal(err) + } + if err := records.Rewind(context.Background()); err != nil || !snapshot.Valid() { + t.Fatalf("ordinary motion=%v validity=%t", err, snapshot.Valid()) + } + switch operation { + case "write preparation": + _, err = d.Configure(context.Background(), profile, Append) + case "record write": + _, err = records.Write(context.Background(), make([]byte, 512)) + case "filemark": + err = records.Filemarks(context.Background(), 1) + case "reset": + f.readResponse = &Response{Completed: true, Status: 2, Sense: fixedSense(0x70, 6, 0x29, 0, false, 0, 0)} + _, err = records.Read(context.Background(), make([]byte, 512)) + } + if snapshot.Valid() || operation != "reset" && err != nil || operation == "reset" && !errors.Is(err, ErrUncertain) { + t.Fatalf("operation=%s error=%v validity=%t", operation, err, snapshot.Valid()) + } + if current, err := d.Geometry(context.Background()); current != nil || err != nil || f.wrapQueries != 1 { + t.Fatalf("withdrawn=%v error=%v queries=%d", current, err, f.wrapQueries) + } + if err := d.Cleanup(context.Background()); operation != "reset" && err != nil || operation == "reset" && !errors.Is(err, ErrUncertain) { + t.Fatal(err) + } + }) + } +} + +func TestGeometryUncertainInspectionPreservesUnavailableCleanupEvidence(t *testing.T) { + f := newGeometryFixture() + f.error = &UncertainError{Cause: errors.New("synthetic completion loss")} + d, err := NewDevice(context.Background(), Identity{}, newSerializedTransport(f.execute, func() error { f.closed++; return nil })) + if d != nil || !errors.Is(err, ErrUncertain) || f.closed != 1 || f.motions != 0 || f.selects != 0 || f.writes != 0 { + t.Fatalf("device=%v error=%v closed=%d motion=%d mode=%d write=%d", d, err, f.closed, f.motions, f.selects, f.writes) + } +} + +func TestGeometryReplacementWithdrawsPreviousRequestOwnedCopies(t *testing.T) { + // Re-observation replaces the owned snapshot; cleanup cannot leave an earlier clone usable. + f := newGeometryFixture() + d := geometryDevice(t, f) + before, err := d.Geometry(context.Background()) + if err != nil { + t.Fatal(err) + } + if err := d.inspectGeometry(context.Background()); err != nil { + t.Fatal(err) + } + after, err := d.Geometry(context.Background()) + if err != nil || before.Valid() || !after.Valid() || after.Fresh != nil { + t.Fatalf("before=%t after=%t error=%v", before.Valid(), after.Valid(), err) + } + if err := d.Cleanup(context.Background()); err != nil || after.Valid() { + t.Fatalf("cleanup=%v validity=%t", err, after.Valid()) + } +} + +func TestGeometryReadinessLossDistinguishesFormFromEndpointObservation(t *testing.T) { + for _, afterGeometry := range []bool{false, true} { + // A reported CDB form is retained; lost cartridge identity withdraws endpoint/count facts and snapshot use. + f := newGeometryFixture() + f.noMedium, f.capacityLoss = !afterGeometry, afterGeometry + d := geometryDevice(t, f) + geometry, err := d.Geometry(context.Background()) + feature := feature(d.Report(), entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_END_OF_WRAP_POSITION) + limits := feature.GetGeometry() + if err != nil || geometry != nil || limits == nil || limits.AllWraps == nil || !limits.GetAllWraps() || limits.ReportedWraps != nil || limits.Fresh != nil || feature.Reason != entity.DeviceReason_DEVICE_REASON_NO_MEDIUM || feature.Availability != entity.DeviceFeatureAvailability_DEVICE_FEATURE_AVAILABILITY_UNAVAILABLE { + t.Fatalf("afterGeometry=%t map=%v error=%v feature=%v", afterGeometry, geometry, err, feature) + } + wantQueries, detail := 0, "no endpoint response observed" + if afterGeometry { + wantQueries, detail = 1, "endpoint response invalidated" + } + if f.wrapQueries != wantQueries || !strings.Contains(feature.Detail, detail) { + t.Fatalf("afterGeometry=%t queries=%d detail=%s", afterGeometry, f.wrapQueries, feature.Detail) + } + if err := d.Cleanup(context.Background()); err != nil { + t.Fatal(err) + } + } +} + +func TestGeometryPassiveAllowlistRejectsOtherMaintenanceForms(t *testing.T) { + // No refresh, arbitrary qualifier, WNV or unsafe reserved bits pass the passive boundary. + for _, all := range []bool{false, true} { + command := Command{CDB: wrapCommand(all), Data: make([]byte, int(binary.BigEndian.Uint32(wrapCommand(all)[6:10]))), Direction: FromDevice} + if !passiveCommand(command) { + t.Fatalf("valid form rejected: %v", command.CDB) + } + for _, offset := range []int{1, 2, 3, 4, 5, 10, 11} { + changed := command + changed.CDB = append([]byte(nil), command.CDB...) + changed.CDB[offset] |= 0x80 + if passiveCommand(changed) { + t.Fatalf("unsafe byte%d accepted: %v", offset, changed.CDB) + } + } + } + if passiveCommand(Command{CDB: []byte{0x1b, 0, 0, 0, 8, 0}, Direction: NoData}) { + t.Fatal("HOLD motion was accepted as passive") + } +} diff --git a/internal/media/tape/geometry_test.go b/internal/media/tape/geometry_test.go new file mode 100644 index 0000000..cbd5480 --- /dev/null +++ b/internal/media/tape/geometry_test.go @@ -0,0 +1,423 @@ +package tape + +import ( + "context" + "encoding/binary" + "errors" + "reflect" + "testing" + + "github.com/samuelncui/yatm/entity" +) + +type geometryFixture struct { + *deviceFixture + vendor, model string + data, form []byte + formQueries, wrapQueries int + afterWrapQueries int + shortOnly, unsupported bool + unknown, wrongAction bool + response *Response + error error + noMedium, capacityLoss bool + notReady bool + onWrap func() +} + +func newGeometryFixture() *geometryFixture { + return &geometryFixture{ + deviceFixture: newDeviceFixture(), vendor: "IBM", model: "Ultrium 5-SCSI", + data: wrapData(WrapEndpoint{Wrap: 3, Object: 1 << 40}, WrapEndpoint{Wrap: 8, Object: 1 << 40}, + WrapEndpoint{Partition: 1, Wrap: 3, Object: 77}), + } +} + +func wrapData(endpoints ...WrapEndpoint) []byte { + data := make([]byte, 4+12*len(endpoints)) + binary.BigEndian.PutUint16(data[:2], uint16(len(data)-2)) + for i, endpoint := range endpoints { + offset := 4 + i*12 + binary.BigEndian.PutUint16(data[offset:offset+2], uint16(endpoint.Wrap)) + binary.BigEndian.PutUint16(data[offset+2:offset+4], uint16(endpoint.Partition)) + object := endpoint.Object + for j := 11; j >= 6; j-- { + data[offset+j], object = byte(object), object>>8 + } + } + return data +} + +func (f *geometryFixture) execute(command Command) (Response, error) { + // Independent synthetic maintenance frames never issue LOAD, measure geometry or claim freshness. + if f.wrapQueries != 0 { + f.afterWrapQueries++ + } + if command.CDB[0] == 0 && f.noMedium || command.CDB[0] == 0x4d && f.capacityLoss { + return Response{Completed: true, Status: 2, Sense: fixedSense(0x70, 2, 0x3a, 0, false, 0, 0)}, nil + } + if command.CDB[0] == 0 && f.notReady { + return Response{Completed: true, Status: 2, Sense: fixedSense(0x70, 2, 4, 1, false, 0, 0)}, nil + } + + // Supply a standard report or an explicit vendor report for the requested opcode/service action. + if command.CDB[0] == 0xa3 && command.CDB[1] == 0x0c && command.CDB[3] == 0xa3 { + f.formQueries++ + data := make([]byte, 16) + data[1], data[3], data[4], data[5] = 3, 12, 0xa3, 0x1f + for i := 6; i < len(data); i++ { + data[i] = 0xff + } + if f.shortOnly { + data[7] = 0 + } + if f.unsupported { + data = []byte{0, 1} + } + if f.unknown { + data = []byte{0, 0} + } + if f.wrongAction { + data[5] = 0xff + } + if f.form != nil { + data = f.form + } + copy(command.Data, data) + return Response{Completed: true, Residual: int32(len(command.Data) - len(data))}, nil + } + + // Record issue before injecting completion or cancellation, without manufacturing another submitted form. + if command.CDB[0] == 0xa3 && command.CDB[1] == 0x1f { + f.wrapQueries++ + if f.onWrap != nil { + f.onWrap() + } + if f.response != nil || f.error != nil { + if f.response != nil { + return *f.response, f.error + } + return Response{}, f.error + } + if !passiveCommand(command) || command.CDB[2] != 0x45 || command.CDB[3] != 0 && command.CDB[3] != 2 { + return Response{}, ErrMalformed + } + data := f.data + if command.CDB[3] == 0 { + data = []byte{0, 8, 0, 0, 0, 0, 0, 0, 0, 77} + } + copied := copy(command.Data, data) + return Response{Completed: true, Residual: int32(len(command.Data) - copied)}, nil + } + + // Identity selection uses a product family, independently of firmware or synthetic endpoint data. + response, err := f.deviceFixture.execute(command) + if command.CDB[0] == 0x12 && command.CDB[1] == 0 { + copy(command.Data[8:16], []byte(" ")) + copy(command.Data[8:16], f.vendor) + copy(command.Data[16:32], []byte(" ")) + copy(command.Data[16:32], f.model) + } + return response, err +} + +func geometryDevice(t *testing.T, f *geometryFixture) *Device { + t.Helper() + d, err := NewDevice(context.Background(), Identity{}, newSerializedTransport(f.execute, func() error { f.closed++; return nil })) + if err != nil { + t.Fatal(err) + } + return d +} + +func TestGeometryPreservesReportedPairsGapsObjectsAndUnknownFreshness(t *testing.T) { + // Equal objects and repeated wrap numbers across partitions are not fabricated boundaries or duplicate identities. + f := newGeometryFixture() + d := geometryDevice(t, f) + geometry, err := d.Geometry(context.Background()) + want := []WrapEndpoint{{Wrap: 3, Object: 1 << 40}, {Wrap: 8, Object: 1 << 40}, {Partition: 1, Wrap: 3, Object: 77}} + if err != nil || !geometry.Valid() || !geometry.AllWraps || geometry.Fresh != nil || geometry.Source != "A3/1F/45" || geometry.ObservedAt.IsZero() || !reflect.DeepEqual(geometry.Endpoints, want) { + t.Fatalf("geometry=%+v error=%v", geometry, err) + } + geometry.Endpoints[0].Object = 9 + again, err := d.Geometry(context.Background()) + if err != nil || !reflect.DeepEqual(again.Endpoints, want) || f.wrapQueries != 1 { + t.Fatalf("snapshot=%+v error=%v queries=%d", again, err, f.wrapQueries) + } + report := d.Report() + observedFeature := feature(report, entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_END_OF_WRAP_POSITION) + if observedFeature.GetGeometry().Fresh != nil || observedFeature.GetGeometry().GetReportedWraps() != 3 || !observedFeature.GetGeometry().GetAllWraps() || observedFeature.Validation != entity.DeviceFeatureValidation_DEVICE_FEATURE_VALIDATION_UNVALIDATED || report.GetTape().RecommendedWriteOptions != nil { + t.Fatalf("feature=%v", observedFeature) + } + order := feature(report, entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_SOFTWARE_READ_ORDER) + if order.Support != entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_UNKNOWN || order.Validation != entity.DeviceFeatureValidation_DEVICE_FEATURE_VALIDATION_UNVALIDATED || order.Reason != entity.DeviceReason_DEVICE_REASON_NOT_READY { + t.Fatalf("order=%v", order) + } + if err := d.Cleanup(context.Background()); err != nil || geometry.Valid() || again.Valid() { + t.Fatalf("cleanup=%v validity=%t/%t", err, geometry.Valid(), again.Valid()) + } +} + +func TestGeometryUnknownFamilyAndUnprovenFormsNeverIssueVendorQuery(t *testing.T) { + for _, tc := range []struct { + name string + vendor string + setup func(*geometryFixture) + wantForms int + wantSupport entity.DeviceFeatureSupport + }{ + {"unknown family", "OTHER", nil, 0, entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_UNKNOWN}, + {"other HP product", "HP", func(f *geometryFixture) { f.model = "DAT160" }, 0, entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_UNKNOWN}, + {"vendor reporting outside HPE", "IBM", func(f *geometryFixture) { f.form = vendorWrapReportData() }, 1, entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_UNKNOWN}, + {"unknown reporting", "HP", func(f *geometryFixture) { f.unknown = true }, 1, entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_UNKNOWN}, + {"clean unsupported", "HPE", func(f *geometryFixture) { f.unsupported = true }, 1, entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_UNSUPPORTED}, + {"wrong literal action", "IBM", func(f *geometryFixture) { f.wrongAction = true }, 1, entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_UNKNOWN}, + {"wrong HPE literal action", "HP", func(f *geometryFixture) { f.wrongAction = true }, 1, entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_UNKNOWN}, + {"HPE missing qualifier mask", "HPE", func(f *geometryFixture) { f.form = vendorWrapReportData(); f.form[1] = 3 }, 1, entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_UNSUPPORTED}, + } { + t.Run(tc.name, func(t *testing.T) { + f := newGeometryFixture() + f.vendor = tc.vendor + if tc.setup != nil { + tc.setup(f) + } + d := geometryDevice(t, f) + geometry, err := d.Geometry(context.Background()) + feature := feature(d.Report(), entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_END_OF_WRAP_POSITION) + if err != nil || geometry != nil || f.wrapQueries != 0 || f.formQueries != tc.wantForms || feature.Support != tc.wantSupport { + t.Fatalf("map=%v error=%v queries=%d forms=%d feature=%v", geometry, err, f.wrapQueries, f.formQueries, feature) + } + if err := d.Cleanup(context.Background()); err != nil { + t.Fatal(err) + } + }) + } +} + +func TestGeometryFirstOnlyHasNoInventedEndpointAssociation(t *testing.T) { + f := newGeometryFixture() + f.shortOnly = true + d := geometryDevice(t, f) + geometry, err := d.Geometry(context.Background()) + feature := feature(d.Report(), entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_END_OF_WRAP_POSITION) + if err != nil || geometry == nil || geometry.AllWraps || len(geometry.Endpoints) != 0 || geometry.Fresh != nil || feature.GetGeometry().GetAllWraps() || feature.GetGeometry().GetReportedWraps() != 1 || feature.Support != entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_PARTIAL { + t.Fatalf("map=%+v error=%v feature=%v", geometry, err, feature) + } + if err := d.Cleanup(context.Background()); err != nil { + t.Fatal(err) + } +} + +func TestGeometryRefreshRefusesBeforeQueriesOrMutation(t *testing.T) { + // An explicitly unsupported reload form leaves baseline inspection and access unchanged. + f := newGeometryFixture() + f.deviceFixture.unsupported = 0x1b + d := geometryDevice(t, f) + forms, queries, security := f.formQueries, f.wrapQueries, f.security + if err := d.RefreshPositionMap(context.Background()); !errors.Is(err, ErrUnsupported) || f.formQueries != forms || f.wrapQueries != queries || f.security != security || f.selects != 0 || f.formats != 0 || f.motions != 0 || f.writes != 0 { + t.Fatalf("refresh=%v forms=%d queries=%d mode=%d motion=%d write=%d", err, f.formQueries, f.wrapQueries, f.selects, f.motions, f.writes) + } + + // Unsupported refresh retains the ordinary cleanup boundary. + if err := d.Cleanup(context.Background()); err != nil { + t.Fatal(err) + } +} + +func vendorWrapReportData() []byte { + // The vendor-specific report supplies no standard field-mask evidence for qualifier 45h or RA. + return []byte{0, 5, 0, 12, 0xa3, 0x1f, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0} +} + +func TestHPEReportedFormsObserveEndpointShapeWithoutQualification(t *testing.T) { + for _, vendor := range []string{"HP", "HPE"} { + for _, tc := range []struct { + name string + short, vendor, ctdp bool + }{ + {name: "standard all"}, {name: "standard short", short: true}, + {name: "vendor all", vendor: true}, {name: "vendor all with timeouts", vendor: true, ctdp: true}, + } { + t.Run(vendor+"/"+tc.name, func(t *testing.T) { + // HPE's independently documented layout preserves six-byte objects and full pair identities. + f := newGeometryFixture() + f.vendor, f.shortOnly = vendor, tc.short + if vendor == "HPE" { + f.model = "Ultrium 9-SCSI" + } + f.data = []byte{0, 14, 0, 0, 0xff, 0xfe, 0xff, 0xff, 0, 0, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff} + if tc.vendor { + f.form = vendorWrapReportData() + if tc.ctdp { + f.form[1] |= 0x80 + f.form = append(f.form, 0, 10, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) + } + if support, err := commandFormSupport(f.form, wrapCommand(true)); err != nil || support != entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_UNKNOWN { + t.Fatalf("generic vendor report support=%v error=%v", support, err) + } + } + + // One query establishes only observed form/count; neither freshness nor AUTO is inferred. + d := geometryDevice(t, f) + observed, err := d.Geometry(context.Background()) + reported := feature(d.Report(), entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_END_OF_WRAP_POSITION) + limits := reported.GetGeometry() + if err != nil || !observed.Valid() || f.formQueries != 1 || f.wrapQueries != 1 || observed.AllWraps == tc.short || observed.Fresh != nil || + reported.Availability != entity.DeviceFeatureAvailability_DEVICE_FEATURE_AVAILABILITY_READY || reported.Evidence != entity.DeviceFeatureEvidence_DEVICE_FEATURE_EVIDENCE_OBSERVED || + reported.Validation != entity.DeviceFeatureValidation_DEVICE_FEATURE_VALIDATION_UNVALIDATED || limits.GetReportedWraps() != 1 || limits.Fresh != nil { + t.Fatalf("map=%v error=%v queries=%d feature=%v", observed, err, f.wrapQueries, reported) + } + want := []WrapEndpoint{{Partition: 65535, Wrap: 65534, Object: 1<<48 - 1}} + wantSupport := entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_SUPPORTED + if tc.short { + want, wantSupport = nil, entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_PARTIAL + } + if !reflect.DeepEqual(observed.Endpoints, want) || reported.Support != wantSupport || + limits.GetAllWraps() == tc.short || limits.GetSource() != "A3/1F/45" || observed.Source != "A3/1F/45" { + t.Fatalf("map=%+v feature=%v", observed, reported) + } + order := feature(d.Report(), entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_SOFTWARE_READ_ORDER) + if order.Support != entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_UNKNOWN || d.Report().GetTape().RecommendedWriteOptions != nil { + t.Fatalf("unqualified ordering=%v", order) + } + + // Closure invalidates every family snapshot without another device query or mutation. + if err := d.Cleanup(context.Background()); err != nil || observed.Valid() || f.wrapQueries != 1 || f.motions != 0 || f.selects != 0 || f.writes != 0 { + t.Fatalf("cleanup=%v valid=%t queries=%d", err, observed.Valid(), f.wrapQueries) + } + }) + } + } +} + +func TestHPEVendorReportRequiresCompleteExactEnvelopeBeforeProbe(t *testing.T) { + for _, tc := range []struct { + name string + change func([]byte) []byte + }{ + {"missing header", func(data []byte) []byte { return data[:1] }}, + {"truncated usage", func(data []byte) []byte { return data[:15] }}, + {"reserved header", func(data []byte) []byte { data[0] = 1; return data }}, + {"reserved support bits", func(data []byte) []byte { data[1] |= 0x08; return data }}, + {"short CDB", func(data []byte) []byte { data[3] = 11; return data }}, + {"long CDB", func(data []byte) []byte { data[3] = 13; return data }}, + {"wrong opcode", func(data []byte) []byte { data[4] = 0xa4; return data }}, + {"wrong literal action", func(data []byte) []byte { data[5] = 0xff; return data }}, + {"truncated timeout descriptor", func(data []byte) []byte { data[1] |= 0x80; return data }}, + {"wrong timeout descriptor length", func(data []byte) []byte { + data[1] |= 0x80 + return append(data, 0, 11, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) + }}, + } { + t.Run(tc.name, func(t *testing.T) { + // A vendor flag cannot turn a malformed capability report into permission to issue a CDB. + f := newGeometryFixture() + f.vendor, f.form = "HP", tc.change(vendorWrapReportData()) + d := geometryDevice(t, f) + reported := feature(d.Report(), entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_END_OF_WRAP_POSITION) + if f.wrapQueries != 0 || reported.Reason != entity.DeviceReason_DEVICE_REASON_MALFORMED_RESPONSE || reported.GetGeometry() != nil || d.Report().Reason != 0 { + t.Fatalf("queries=%d feature=%v report=%v", f.wrapQueries, reported, d.Report().Reason) + } + + // Optional discovery failure preserves the ordinary device lifecycle. + if err := d.Cleanup(context.Background()); err != nil || f.closed != 1 { + t.Fatalf("cleanup=%v closes=%d", err, f.closed) + } + }) + } +} + +func TestHPEVendorObservationFailureNeverIssuesAnotherForm(t *testing.T) { + for _, tc := range []struct { + name string + response *Response + data []byte + cancel bool + uncertain bool + blocked bool + }{ + {name: "clean rejected all", response: &Response{Completed: true, Status: 2, Sense: fixedSense(0x70, 5, 0x24, 0, false, 0, 0)}}, + {name: "malformed all", data: []byte{0, 3, 0, 0, 0}}, + {name: "medium loss", response: &Response{Completed: true, Status: 2, Sense: fixedSense(0x70, 2, 0x3a, 0, false, 0, 0)}, blocked: true}, + {name: "not ready", response: &Response{Completed: true, Status: 2, Sense: fixedSense(0x70, 2, 4, 1, false, 0, 0)}, blocked: true}, + {name: "deferred rejection", response: &Response{Completed: true, Status: 2, Sense: fixedSense(0x71, 5, 0x24, 0, false, 0, 0)}, uncertain: true}, + {name: "host failure", response: &Response{Completed: true, HostStatus: 1}, uncertain: true}, + {name: "canceled after issue", cancel: true}, + } { + t.Run(tc.name, func(t *testing.T) { + // Native completion, readiness and cancellation remain distinct from a clean optional rejection. + f := newGeometryFixture() + f.vendor, f.form, f.response = "HPE", vendorWrapReportData(), tc.response + if tc.data != nil { + f.data = tc.data + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + if tc.cancel { + f.onWrap = cancel + } + d, err := NewDevice(ctx, Identity{}, newSerializedTransport(f.execute, func() error { f.closed++; return nil })) + if f.wrapQueries != 1 || tc.uncertain != errors.Is(err, ErrUncertain) || tc.cancel != errors.Is(err, context.Canceled) { + t.Fatalf("device=%v error=%v queries=%d", d, err, f.wrapQueries) + } + if tc.uncertain || tc.cancel { + if d != nil || f.afterWrapQueries != 0 || f.closed != 1 { + t.Fatalf("device=%v later queries=%d closes=%d", d, f.afterWrapQueries, f.closed) + } + return + } + + // A rejected/malformed observation supplies no map; only readiness loss blocks baseline controls. + if err != nil || d == nil { + t.Fatalf("device=%v error=%v", d, err) + } + observed, err := d.Geometry(context.Background()) + reported := feature(d.Report(), entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_END_OF_WRAP_POSITION) + if err != nil || observed != nil || reported.GetGeometry() != nil || + reported.Support == entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_SUPPORTED || + reported.Support == entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_UNSUPPORTED || + reported.Validation != entity.DeviceFeatureValidation_DEVICE_FEATURE_VALIDATION_UNVALIDATED { + t.Fatalf("map=%v error=%v feature=%v", observed, err, reported) + } + profile := encryptionTestProfile() + profile.EncryptionAlgorithm = nil + _, err = d.Configure(context.Background(), profile, Read) + if (err != nil) != tc.blocked || f.wrapQueries != 1 || tc.blocked && (f.selects != 0 || f.motions != 0 || f.writes != 0) { + t.Fatalf("configure=%v queries=%d mode=%d motion=%d writes=%d", err, f.wrapQueries, f.selects, f.motions, f.writes) + } + if err := d.Cleanup(context.Background()); err != nil { + t.Fatal(err) + } + }) + } +} + +func TestHPEVendorReportWithoutReadyMediumDoesNotObserveAForm(t *testing.T) { + for _, tc := range []struct { + name string + noMedium bool + reason entity.DeviceReason + }{ + {"no medium", true, entity.DeviceReason_DEVICE_REASON_NO_MEDIUM}, + {"not ready", false, entity.DeviceReason_DEVICE_REASON_NOT_READY}, + } { + t.Run(tc.name, func(t *testing.T) { + // A vendor report does not supply cartridge-dependent query acceptance or endpoint facts. + f := newGeometryFixture() + f.vendor, f.form = "HP", vendorWrapReportData() + f.noMedium, f.notReady = tc.noMedium, !tc.noMedium + d := geometryDevice(t, f) + observed, err := d.Geometry(context.Background()) + reported := feature(d.Report(), entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_END_OF_WRAP_POSITION) + if err != nil || observed != nil || f.wrapQueries != 0 || reported.GetGeometry() != nil || + reported.Support != entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_UNKNOWN || reported.Reason != tc.reason { + t.Fatalf("map=%v error=%v queries=%d feature=%v", observed, err, f.wrapQueries, reported) + } + + // Passive discovery and closure never load, position or configure the cartridge. + if err := d.Cleanup(context.Background()); err != nil || f.motions != 0 || f.selects != 0 || f.writes != 0 { + t.Fatalf("cleanup=%v motion=%d mode=%d writes=%d", err, f.motions, f.selects, f.writes) + } + }) + } +} diff --git a/internal/media/tape/identity.go b/internal/media/tape/identity.go new file mode 100644 index 0000000..5302d2c --- /dev/null +++ b/internal/media/tape/identity.go @@ -0,0 +1,112 @@ +package tape + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "regexp" + "sort" + "strings" +) + +// Identity unifies configured st/nst/mode and SG aliases through their passive SCSI sysfs owner. +// Configured retains the exact script-facing name; Key is only an Executor resource identity. +type Identity struct { + Configured string + Canonical string + Key string + Sysfs string + SG string + Aliases []string +} + +// Resolver reads configuration/sysfs only. It never opens the drive or issues a SCSI command. +type Resolver struct { + SysfsRoot, DeviceRoot string + stat func(string) (fs.FileInfo, error) +} + +var tapeName = regexp.MustCompile(`^n?st([0-9]+)[alm]?$`) +var sgName = regexp.MustCompile(`^sg[0-9]+$`) + +func Resolve(path string) (Identity, error) { + return (Resolver{SysfsRoot: "/sys", DeviceRoot: "/dev"}).Resolve(path) +} + +func (r Resolver) Resolve(path string) (Identity, error) { + // Resolve configured symlinks once without changing the name passed to LTFS scripts. + identity := Identity{Configured: path} + canonical, err := filepath.EvalSymlinks(path) + if err != nil { + return identity, fmt.Errorf("resolve Tape device path failed, %w", err) + } + stat := r.stat + if stat == nil { + stat = os.Stat + } + info, err := stat(canonical) + if err != nil { + return identity, fmt.Errorf("stat Tape device failed, %w", err) + } + if info.Mode()&os.ModeCharDevice == 0 { + return identity, fmt.Errorf("Tape alias is not a character device: %w", ErrUnsupported) + } + identity.Canonical = canonical + name := filepath.Base(canonical) + var device string + if match := tapeName.FindStringSubmatch(name); match != nil { + device = filepath.Join(r.SysfsRoot, "class", "scsi_tape", "st"+match[1], "device") + } else if sgName.MatchString(name) { + device = filepath.Join(r.SysfsRoot, "class", "scsi_generic", name, "device") + } else { + return identity, fmt.Errorf("Tape device has no known native alias, path=%q", path) + } + + // SCSI type and one SG alias establish the same underlying owner without active discovery. + device, err = filepath.EvalSymlinks(device) + if err != nil { + return identity, fmt.Errorf("resolve Tape sysfs identity failed, %w", err) + } + kind, err := os.ReadFile(filepath.Join(device, "type")) + if err != nil { + return identity, fmt.Errorf("read Tape SCSI type failed, %w", err) + } + if strings.TrimSpace(string(kind)) != "1" { + return identity, fmt.Errorf("SCSI device is not sequential access") + } + identity.Sysfs, identity.Key = device, device + generic, err := os.ReadDir(filepath.Join(device, "scsi_generic")) + if err != nil { + return identity, fmt.Errorf("read Tape SG aliases failed, %w", err) + } + for _, entry := range generic { + if !sgName.MatchString(entry.Name()) { + continue + } + if identity.SG != "" { + return identity, fmt.Errorf("Tape has no unique SG alias") + } + identity.SG = filepath.Join(r.DeviceRoot, entry.Name()) + identity.Aliases = append(identity.Aliases, identity.SG) + } + if identity.SG == "" { + return identity, fmt.Errorf("Tape has no SG alias") + } + + // Include every kernel-reported mode alias for the external holder check owned by the Executor. + aliases, err := os.ReadDir(filepath.Join(device, "scsi_tape")) + if err != nil { + return identity, fmt.Errorf("read Tape native aliases failed, %w", err) + } + for _, entry := range aliases { + if tapeName.MatchString(entry.Name()) { + identity.Aliases = append(identity.Aliases, filepath.Join(r.DeviceRoot, entry.Name())) + } + } + if len(identity.Aliases) == 1 { + return identity, fmt.Errorf("Tape native aliases are unknown") + } + sort.Strings(identity.Aliases) + return identity, nil +} diff --git a/internal/media/tape/identity_test.go b/internal/media/tape/identity_test.go new file mode 100644 index 0000000..1fb04cb --- /dev/null +++ b/internal/media/tape/identity_test.go @@ -0,0 +1,93 @@ +package tape + +import ( + "context" + "errors" + "io/fs" + "os" + "os/exec" + "path/filepath" + "reflect" + "testing" +) + +type characterInfo struct{ fs.FileInfo } + +func (i characterInfo) Mode() fs.FileMode { return os.ModeDevice | os.ModeCharDevice | 0600 } + +func TestResolverUnifiesNativeAndSGAliasesPassively(t *testing.T) { + // Fake sysfs and native files reproduce published alias links without opening any device. + root := t.TempDir() + dev, sys, owner := filepath.Join(root, "dev"), filepath.Join(root, "sys"), filepath.Join(root, "sys", "devices", "1:0:0:0") + for _, path := range []string{dev, filepath.Join(sys, "class", "scsi_tape", "st0"), filepath.Join(sys, "class", "scsi_generic", "sg4"), filepath.Join(owner, "scsi_generic", "sg4"), filepath.Join(owner, "scsi_tape")} { + if err := os.MkdirAll(path, 0755); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(owner, "type"), []byte("1\n"), 0600); err != nil { + t.Fatal(err) + } + for _, class := range []string{"scsi_tape/st0", "scsi_generic/sg4"} { + if err := os.Symlink(owner, filepath.Join(sys, "class", class, "device")); err != nil { + t.Fatal(err) + } + } + for _, name := range []string{"st0", "nst0", "nst0a", "nst0l", "nst0m", "sg4"} { + if err := os.WriteFile(filepath.Join(dev, name), nil, 0600); err != nil { + t.Fatal(err) + } + if name != "sg4" { + if err := os.Mkdir(filepath.Join(owner, "scsi_tape", name), 0755); err != nil { + t.Fatal(err) + } + } + } + if err := os.Symlink(filepath.Join(dev, "nst0a"), filepath.Join(dev, "configured")); err != nil { + t.Fatal(err) + } + resolver := Resolver{SysfsRoot: sys, DeviceRoot: dev, stat: func(path string) (fs.FileInfo, error) { info, err := os.Stat(path); return characterInfo{info}, err }} + owner, err := filepath.EvalSymlinks(owner) + if err != nil { + t.Fatal(err) + } + + // Original configured names stay unchanged while every known alias shares the same resource key. + for _, name := range []string{"st0", "nst0", "nst0a", "sg4", "configured"} { + path := filepath.Join(dev, name) + identity, err := resolver.Resolve(path) + if err != nil || identity.Configured != path || identity.Key != owner || identity.SG != filepath.Join(dev, "sg4") || len(identity.Aliases) != 6 { + t.Fatalf("identity=%+v err=%v", identity, err) + } + } + resolver.stat = nil + if _, err := resolver.Resolve(filepath.Join(dev, "st0")); !errors.Is(err, ErrUnsupported) { + t.Fatalf("regular file accepted as alias: %v", err) + } +} + +func TestOwnershipReasonsAndAliases(t *testing.T) { + // Ownership queries name all known aliases and distinguish clean absence from incomplete evidence. + aliases := []string{"/dev/nst0", "/dev/sg4"} + if !mountedAlias("1 2 0:1 / /mnt rw - fuse.ltfs ltfs:/dev/sg4 rw\n", aliases) { + t.Fatal("known external mount missed") + } + for _, tc := range []struct { + err error + stdout, stderr []byte + want error + }{ + {nil, []byte("123"), nil, ErrBusy}, + {exec.ErrNotFound, nil, nil, ErrMissingTool}, + {os.ErrPermission, nil, nil, ErrOwnershipUnknown}, + } { + err := checkHolders(context.Background(), aliases, func(_ context.Context, args []string) ([]byte, []byte, error) { + if !reflect.DeepEqual(args, []string{"-v", "/dev/nst0", "/dev/sg4"}) { + t.Fatalf("holder args=%v", args) + } + return tc.stdout, tc.stderr, tc.err + }) + if !errors.Is(err, tc.want) { + t.Fatalf("ownership error=%v, want=%v", err, tc.want) + } + } +} diff --git a/internal/media/tape/inspect.go b/internal/media/tape/inspect.go new file mode 100644 index 0000000..39f0bab --- /dev/null +++ b/internal/media/tape/inspect.go @@ -0,0 +1,461 @@ +package tape + +import ( + "context" + "encoding/binary" + "errors" + "strings" + "time" + "unicode/utf8" + + "github.com/samuelncui/yatm/entity" + "google.golang.org/protobuf/proto" +) + +// Inspect issues passive queries under an existing lease. Observation failures retain a typed +// report; ErrUncertain additionally requires the Executor to retain its unavailable resource key. +func Inspect(ctx context.Context, identity Identity) (report *entity.InspectDeviceResponse, err error) { + transport, err := OpenTransport(identity.SG, true) + if err != nil { + report = newReport(identity) + report.Reason, report.Detail = Reason(err), err.Error() + return report, err + } + defer func() { + if closeErr := transport.Close(); closeErr != nil { + err = errors.Join(err, closeErr) + report.Reason, report.Detail = Reason(err), err.Error() + } + }() + return inspectTransport(ctx, identity, transport) +} + +func newReport(identity Identity) *entity.InspectDeviceResponse { + // Query evidence and fake conformance tests never establish physical validation. + tape := new(entity.TapeDeviceReport) + for kind := entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_INITIALIZE; kind <= entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_SOFTWARE_READ_ORDER; kind++ { + tape.Features = append(tape.Features, &entity.DeviceFeature{ + Feature: kind, Support: entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_UNKNOWN, + Availability: entity.DeviceFeatureAvailability_DEVICE_FEATURE_AVAILABILITY_UNKNOWN, + Validation: entity.DeviceFeatureValidation_DEVICE_FEATURE_VALIDATION_UNVALIDATED, + Reason: entity.DeviceReason_DEVICE_REASON_NOT_IMPLEMENTED, + Detail: "No applicable operation or cleanup qualification is established", + }) + } + report := &entity.InspectDeviceResponse{ + Device: &entity.DeviceSummary{Device: identity.Configured, Kind: entity.DeviceKind_DEVICE_KIND_TAPE}, + Report: &entity.InspectDeviceResponse_Tape{Tape: tape}, + } + for _, kind := range []entity.DeviceFeatureKind{entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_SOFTWARE_READ_ORDER, entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_NON_EJECTING_RELOAD} { + feature(report, kind).Reason = entity.DeviceReason_DEVICE_REASON_NOT_READY + } + feature(report, entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_SOFTWARE_READ_ORDER).Detail = "Software read ordering requires a prepared read session with eligible seek, applicable wrap geometry and matching measured costs; passive inspection does not determine AUTO availability" + feature(report, entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_NON_EJECTING_RELOAD).Detail = "Non-ejecting reload and REOWP refresh lifecycle remain unvalidated" + if identity.Key != "" { + report.AccessIdentity = proto.String(identity.Key) + } + return report +} + +func feature(report *entity.InspectDeviceResponse, kind entity.DeviceFeatureKind) *entity.DeviceFeature { + for _, feature := range report.GetTape().Features { + if feature.Feature == kind { + return feature + } + } + panic("missing Tape feature") +} + +func inspectTransport(ctx context.Context, identity Identity, transport Transport) (*entity.InspectDeviceResponse, error) { + return (&Device{transport: transport}).inspect(ctx, identity) +} + +func (d *Device) inspect(ctx context.Context, identity Identity) (*entity.InspectDeviceResponse, error) { + // Each query has a native timeout; caller cancellation stops later admission. + // An extra inspection deadline must not turn an optional capacity timeout into device failure. + report := newReport(identity) + d.report = report + transport := d.transport + report.Transport, report.ObservedAtNs = proto.String("Linux SG v3"), proto.Int64(time.Now().UnixNano()) + defer func() { + for _, f := range report.GetTape().Features { + f.Scope = &entity.DeviceFeatureScope{Model: report.Model, Firmware: report.Firmware, Transport: report.Transport, DensityCode: report.GetTape().DensityCode} + mediumDependent := f.Feature <= entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_EARLY_WARNING || f.Feature == entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_HARDWARE_COMPRESSION || f.Feature == entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_HARDWARE_DECOMPRESSION || f.Feature == entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_HARDWARE_ENCRYPTION || f.Feature == entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_END_OF_WRAP_POSITION || f.Feature == entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_NON_EJECTING_RELOAD || f.Feature == entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_SOFTWARE_READ_ORDER + if report.GetTape().MediumPresent != nil && !report.GetTape().GetMediumPresent() && mediumDependent { + f.Availability, f.Reason = entity.DeviceFeatureAvailability_DEVICE_FEATURE_AVAILABILITY_UNAVAILABLE, entity.DeviceReason_DEVICE_REASON_NO_MEDIUM + f.Detail = "No cartridge is loaded; operation prerequisites remain unqualified" + } else if report.Reason == entity.DeviceReason_DEVICE_REASON_NOT_READY && mediumDependent { + f.Availability, f.Reason = entity.DeviceFeatureAvailability_DEVICE_FEATURE_AVAILABILITY_UNAVAILABLE, report.Reason + f.Detail = "Cartridge readiness observation failed; operation prerequisites remain unavailable" + } + if f.Feature == entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_END_OF_WRAP_POSITION && f.GetGeometry() != nil && (f.Reason == entity.DeviceReason_DEVICE_REASON_NO_MEDIUM || f.Reason == entity.DeviceReason_DEVICE_REASON_NOT_READY) { + f.Detail = "REOWP CDB form reported; no endpoint response observed for an unavailable cartridge" + if d.geometry != nil { + f.Detail = "Previously observed REOWP endpoint response invalidated by cartridge readiness loss" + } + } + } + }() + observe := func(kind entity.DeviceFeatureKind, err error) bool { + if err == nil { + return true + } + reason := Reason(err) + if errors.Is(err, ErrUncertain) || ctx.Err() != nil || reason == entity.DeviceReason_DEVICE_REASON_NO_MEDIUM || reason == entity.DeviceReason_DEVICE_REASON_NOT_READY { + d.invalidateGeometry() + if limits := feature(report, entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_END_OF_WRAP_POSITION).GetGeometry(); limits != nil { + limits.ReportedWraps, limits.Fresh = nil, nil + } + } + if kind != 0 { + f := feature(report, kind) + f.Reason, f.Detail = reason, err.Error() + if reason == entity.DeviceReason_DEVICE_REASON_UNSUPPORTED { + f.Support = entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_UNSUPPORTED + } + } + if reason == entity.DeviceReason_DEVICE_REASON_NO_MEDIUM { + report.GetTape().MediumPresent = proto.Bool(false) + } + if report.Reason == 0 || errors.Is(err, ErrUncertain) || ctx.Err() != nil || + reason == entity.DeviceReason_DEVICE_REASON_NO_MEDIUM || reason == entity.DeviceReason_DEVICE_REASON_NOT_READY { + report.Reason, report.Detail = reason, err.Error() + } + return !errors.Is(err, ErrUncertain) && ctx.Err() == nil + } + + // Drive identity remains observable without a loaded cartridge. + data, err := query(ctx, transport, []byte{0x12, 0, 0, 0, 96, 0}, 96) + if err == nil { + err = decodeInquiry(data, report) + } + if !observe(0, err) || err != nil { + return report, err + } + data, err = query(ctx, transport, []byte{0x12, 1, 0x80, 0, 255, 0}, 255) + if err == nil { + if len(data) < 4 || data[1] != 0x80 || int(binary.BigEndian.Uint16(data[2:4]))+4 > len(data) { + err = ErrMalformed + } else { + serial := strings.TrimSpace(string(data[4 : 4+int(binary.BigEndian.Uint16(data[2:4]))])) + if !utf8.ValidString(serial) || strings.ContainsRune(serial, 0) { + err = ErrMalformed + } else if serial != "" { + report.SerialNumber = proto.String(serial) + } + } + } + if !observe(0, err) { + return report, err + } + + // Readiness does not prove writable media, a tar format, or physical operation qualification. + _, err = query(ctx, transport, []byte{0, 0, 0, 0, 0, 0}, 0) + if err == nil { + report.GetTape().MediumPresent = proto.Bool(true) + } + if Reason(err) == entity.DeviceReason_DEVICE_REASON_NO_MEDIUM { + report.GetTape().MediumPresent = proto.Bool(false) + report.Reason, report.Detail = Reason(err), err.Error() + } + if !observe(0, err) { + return report, err + } + + // Bounds are reported facts and leave transfer/completion eligibility explicitly unknown. + data, err = query(ctx, transport, []byte{5, 0, 0, 0, 0, 0}, 6) + if err == nil { + err = decodeBlockLimits(data, report, transport) + } + if !observe(0, err) { + return report, err + } + data, err = query(ctx, transport, []byte{0x5a, 0, 0x3f, 0, 0, 0, 0, 0x10, 0, 0}, MaxQueryBytes) + var modePartition *uint32 + var currentPages map[byte][]byte + if err == nil { + modePartition, currentPages, err = decodeMode(data, report.GetTape()) + } + if !observe(0, err) { + return report, err + } + + // Optional writable-mask discovery retains current controls while clean query failures remain feature-local. + if len(currentPages[0x0f]) >= 16 { + _, changes, changeErr := d.readMode(ctx, true) + changeErr = errors.Join(changeErr, ctx.Err()) + d.reportCompression(currentPages, changes, changeErr) + if errors.Is(changeErr, ErrUncertain) || ctx.Err() != nil || Reason(changeErr) == entity.DeviceReason_DEVICE_REASON_NO_MEDIUM || Reason(changeErr) == entity.DeviceReason_DEVICE_REASON_NOT_READY { + if !observe(0, changeErr) { + return report, changeErr + } + } + } else { + d.reportCompression(currentPages, nil, nil) + } + + // A non-secret encryption status query reports current hardware mode without changing keys. + data, err = encryptionStatus(ctx, transport, report.GetTape()) + cipherOff := err == nil && encryptionOff(data) + if !observe(0, err) { + return report, err + } + if err == nil { + encryptionErr := d.inspectEncryption(ctx, data) + if errors.Is(encryptionErr, ErrUncertain) || ctx.Err() != nil || Reason(encryptionErr) == entity.DeviceReason_DEVICE_REASON_NO_MEDIUM || Reason(encryptionErr) == entity.DeviceReason_DEVICE_REASON_NOT_READY { + if !observe(0, encryptionErr) { + return report, encryptionErr + } + } + } + + // A clean optional long-form rejection can use short form; neither qualifies LOCATE or reload. + data, err = query(ctx, transport, []byte{0x34, 6, 0, 0, 0, 0, 0, 0, 0, 0}, 32) + long := true + if Reason(err) == entity.DeviceReason_DEVICE_REASON_UNSUPPORTED { + long = false + data, err = query(ctx, transport, []byte{0x34, 0, 0, 0, 0, 0, 0, 0, 0, 0}, 20) + } + var position Position + if err == nil { + position, err = decodePosition(data, long) + if err == nil { + f := feature(report, entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_READ_POSITION) + f.Support, f.Evidence = entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_PARTIAL, entity.DeviceFeatureEvidence_DEVICE_FEATURE_EVIDENCE_REPORTED + f.Reason, f.Detail = 0, "Position form observed; motion, reload and locator semantics remain unqualified" + if position.ObjectKnown { + f.Availability = entity.DeviceFeatureAvailability_DEVICE_FEATURE_AVAILABILITY_READY + } else { + f.Reason, f.Detail = entity.DeviceReason_DEVICE_REASON_NOT_READY, "Position form observed; exact logical position is currently unknown" + } + limits := new(entity.TapeNavigationLimits) + if long { + limits.ReadPositionLong, limits.AddressBits = proto.Bool(true), proto.Uint32(64) + } else { + limits.ReadPositionShort, limits.AddressBits = proto.Bool(true), proto.Uint32(32) + } + f.Limits = &entity.DeviceFeature_Navigation{Navigation: limits} + } + } + if err != nil { + f := feature(report, entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_READ_POSITION) + f.Availability, f.Reason, f.Detail = entity.DeviceFeatureAvailability_DEVICE_FEATURE_AVAILABILITY_UNAVAILABLE, Reason(err), err.Error() + if f.Reason == entity.DeviceReason_DEVICE_REASON_UNSUPPORTED { + f.Support = entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_UNSUPPORTED + } + if errors.Is(err, ErrUncertain) || ctx.Err() != nil || f.Reason == entity.DeviceReason_DEVICE_REASON_NO_MEDIUM || f.Reason == entity.DeviceReason_DEVICE_REASON_NOT_READY { + if !observe(0, err) { + return report, err + } + } + } + var navigationErr error + var latestPosition Position + d.navigation, latestPosition, navigationErr = inspectNavigation(ctx, transport, report, position, long, err) + navigationErr = errors.Join(navigationErr, ctx.Err()) + if errors.Is(navigationErr, ErrUncertain) || ctx.Err() != nil || Reason(navigationErr) == entity.DeviceReason_DEVICE_REASON_NO_MEDIUM || Reason(navigationErr) == entity.DeviceReason_DEVICE_REASON_NOT_READY { + if !observe(0, navigationErr) { + return report, navigationErr + } + } + + // Geometry reporting is independent of logical seek and never proves freshness or a cost model. + if err := d.inspectGeometry(ctx); err != nil { + if !observe(0, err) { + return report, err + } + } + + // Current mode supplies partition independently of optional seek; every exact position cross-checks it. + if !report.GetTape().GetMediumPresent() || report.Reason == entity.DeviceReason_DEVICE_REASON_NOT_READY { + d.reportCapacity(nil, false, nil) + if report.Reason == entity.DeviceReason_DEVICE_REASON_NO_MEDIUM || report.Reason == entity.DeviceReason_DEVICE_REASON_NOT_READY { + f := feature(report, entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_CAPACITY_TO_WARNING) + f.Availability, f.Reason = entity.DeviceFeatureAvailability_DEVICE_FEATURE_AVAILABILITY_UNAVAILABLE, report.Reason + f.Detail = "Readiness loss invalidated the active partition observation" + } + return report, nil + } + partition := modePartition + for _, observed := range []Position{position, latestPosition} { + if !observed.ObjectKnown { + continue + } + if partition != nil && *partition != observed.Partition { + d.reportCapacity(nil, false, ErrMalformed) + feature(report, entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_CAPACITY_TO_WARNING).Detail = "Current mode or READ POSITION observations disagree on the active partition" + return report, nil + } + partition = proto.Uint32(observed.Partition) + } + if partition != nil { + sample, issued, capacityErr := preparationCapacity(ctx, transport, *partition) + capacityErr = errors.Join(capacityErr, ctx.Err()) + d.reportCapacity(sample, issued, capacityErr) + if errors.Is(capacityErr, ErrUncertain) || ctx.Err() != nil || Reason(capacityErr) == entity.DeviceReason_DEVICE_REASON_NO_MEDIUM || Reason(capacityErr) == entity.DeviceReason_DEVICE_REASON_NOT_READY { + if !observe(0, capacityErr) { + return report, capacityErr + } + } + } else { + d.reportCapacity(nil, false, nil) + } + + // Reload admission uses exact command forms and current observations, without moving the cartridge. + if err := d.inspectReload(ctx, cipherOff); err != nil { + if errors.Is(err, ErrUncertain) || ctx.Err() != nil || Reason(err) == entity.DeviceReason_DEVICE_REASON_NO_MEDIUM || Reason(err) == entity.DeviceReason_DEVICE_REASON_NOT_READY { + if !observe(0, err) { + return report, err + } + } + } + + return report, nil +} + +func decodeInquiry(data []byte, report *entity.InspectDeviceResponse) error { + // Additional length describes available data, including bytes beyond this bounded allocation. + if len(data) < 36 || int(data[4])+5 < 36 { + return ErrMalformed + } + if data[0]&0x1f != 1 { + return ErrUnsupported + } + for _, field := range []struct { + start, end int + target **string + }{{8, 16, &report.Vendor}, {16, 32, &report.Model}, {32, 36, &report.Firmware}} { + value := strings.TrimSpace(string(data[field.start:field.end])) + if !utf8.ValidString(value) || strings.ContainsRune(value, 0) { + return ErrMalformed + } + if value != "" { + *field.target = proto.String(value) + } + } + return nil +} + +func decodeBlockLimits(data []byte, report *entity.InspectDeviceResponse, transport Transport) error { + // SSC-3 §7.4 uses a zero maximum to mean unspecified, not a zero-size transfer envelope. + if len(data) != 6 || data[0]&0xe0 != 0 { + return ErrMalformed + } + minimum := uint32(binary.BigEndian.Uint16(data[4:6])) + maximum := uint32(data[1])<<16 | uint32(data[2])<<8 | uint32(data[3]) + if maximum != 0 && minimum > maximum { + return ErrMalformed + } + limits := &entity.TapeRecordLimits{MinimumBytes: proto.Uint32(minimum), AlignmentBytes: proto.Uint32(1 << uint(data[0]&0x1f))} + if maximum != 0 { + limits.MaximumBytes = proto.Uint32(maximum) + limits.Fixed, limits.Variable = proto.Bool(true), proto.Bool(minimum != maximum) + } + limits.TransportMaximumBytes = transportMaximumBytes(transport) + for _, kind := range []entity.DeviceFeatureKind{entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_SEQUENTIAL_READ, entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_SEQUENTIAL_WRITE} { + f := feature(report, kind) + f.Support = entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_PARTIAL + f.Limits, f.Evidence = &entity.DeviceFeature_Records{Records: proto.Clone(limits).(*entity.TapeRecordLimits)}, entity.DeviceFeatureEvidence_DEVICE_FEATURE_EVIDENCE_REPORTED + f.Reason, f.Detail = entity.DeviceReason_DEVICE_REASON_NOT_IMPLEMENTED, "Record bounds reported; acceptance and completion prerequisites remain unqualified" + } + return nil +} + +func transportMaximumBytes(transport Transport) *uint32 { + // A growable per-fd reservation is a current allocation, not an observed adapter maximum. + if _, growable := transport.(interface { + EnsureTransfer(context.Context, int) error + }); growable { + return nil + } + if bounded, ok := transport.(interface{ TransferLimit() int }); ok { + if limit := bounded.TransferLimit(); limit > 0 && limit <= MaxTransferBytes { + return proto.Uint32(uint32(limit)) + } + } + return nil +} + +func modeData(data []byte) ([]byte, map[byte][]byte, error) { + // Refuse truncated headers/pages before publishing current mode facts. + if len(data) < 8 || int(binary.BigEndian.Uint16(data[:2]))+2 > len(data) || data[4]&1 != 0 { + return nil, nil, ErrMalformed + } + data = data[:int(binary.BigEndian.Uint16(data[:2]))+2] + if len(data) < 8 { + return nil, nil, ErrMalformed + } + descriptors := int(binary.BigEndian.Uint16(data[6:8])) + if descriptors != 0 && descriptors != 8 || 8+descriptors > len(data) { + return nil, nil, ErrMalformed + } + pages := make(map[byte][]byte) + for offset := 8 + descriptors; offset < len(data); { + if len(data)-offset < 2 { + return nil, nil, ErrMalformed + } + header, length := 2, int(data[offset+1]) + if data[offset]&0x40 != 0 { + if len(data)-offset < 4 { + return nil, nil, ErrMalformed + } + header, length = 4, int(binary.BigEndian.Uint16(data[offset+2:offset+4])) + } + end := offset + header + length + if end > len(data) { + return nil, nil, ErrMalformed + } + if header == 2 { + if _, duplicate := pages[data[offset]&0x3f]; duplicate { + return nil, nil, ErrMalformed + } + pages[data[offset]&0x3f] = data[offset:end] + } + offset = end + } + if page, ok := pages[0x0f]; ok && len(page) < 16 { + return nil, nil, ErrMalformed + } + if page, ok := pages[0x11]; ok && len(page) < 8 { + return nil, nil, ErrMalformed + } + + return data, pages, nil +} + +func decodeMode(data []byte, report *entity.TapeDeviceReport) (*uint32, map[byte][]byte, error) { + data, pages, err := modeData(data) + if err != nil { + return nil, nil, err + } + if page, ok := pages[0x11]; ok && page[3] > page[2] { + return nil, nil, ErrMalformed + } + return observeMode(data, pages, report), pages, nil +} + +func observeMode(data []byte, pages map[byte][]byte, report *entity.TapeDeviceReport) *uint32 { + descriptors := int(binary.BigEndian.Uint16(data[6:8])) + + // Non-secret observed settings are distinct from changeability and cleanup qualification. + var partition *uint32 + if report.GetMediumPresent() { + report.WriteProtected = proto.Bool(data[3]&0x80 != 0) + if page := pages[0x10]; len(page) >= 16 { + partition = proto.Uint32(uint32(page[3])) + } + if page, ok := pages[0x11]; ok && len(page) >= 8 { + report.PartitionCount = proto.Uint32(uint32(page[3]) + 1) + } + } + report.BufferedWrites = proto.Bool(data[3]&0x70 != 0) + if descriptors == 8 { + if report.GetMediumPresent() { + report.DensityCode = proto.Uint32(uint32(data[8])) + } + block := uint32(data[13])<<16 | uint32(data[14])<<8 | uint32(data[15]) + report.RecordBytes, report.FixedRecords = proto.Uint32(block), proto.Bool(block != 0) + } + report.CompressionEnabled, report.DecompressionEnabled = compressionState(pages) + return partition +} diff --git a/internal/media/tape/inspect_test.go b/internal/media/tape/inspect_test.go new file mode 100644 index 0000000..fa605ab --- /dev/null +++ b/internal/media/tape/inspect_test.go @@ -0,0 +1,198 @@ +package tape + +import ( + "context" + "encoding/binary" + "errors" + "testing" + + "github.com/samuelncui/yatm/entity" +) + +func inspectionFixture(command Command) (Response, error) { + // Synthetic bounded SSC/SPC responses exercise report projection without a real drive. + var data []byte + switch command.CDB[0] { + case 0x12: + if command.CDB[1] == 1 { + data = []byte{1, 0x80, 0, 3, 'S', 'N', '1'} + } else { + data = make([]byte, 96) + data[0], data[4] = 1, 91 + copy(data[8:16], "IBM ") + copy(data[16:32], "ULT3580-TD8 ") + copy(data[32:36], "ABCD") + } + case 0: + case 5: + data = []byte{0, 0x08, 0, 0, 0, 1} + case 0x5a: + data = make([]byte, 56) + binary.BigEndian.PutUint16(data[:2], 54) + data[3], data[7], data[8] = 0x90, 8, 0x5e + data[16], data[17], data[18], data[19] = 0x0f, 14, 0xc0, 0x80 + binary.BigEndian.PutUint32(data[20:24], 1) + binary.BigEndian.PutUint32(data[24:28], 1) + data[32], data[33], data[34], data[35] = 0x11, 6, 3, 1 + data[40], data[41] = 0x10, 14 + data[54] = 1 + if command.CDB[2] == 0x7f { + data[18], data[19], data[54] = 0x80, 0xe0, 1 + } + case 0x34: + data = make([]byte, len(command.Data)) + if command.CDB[1] == 6 { + binary.BigEndian.PutUint64(data[8:16], 123) + binary.BigEndian.PutUint64(data[16:24], 4) + } else { + binary.BigEndian.PutUint32(data[4:8], 123) + } + case 0xa2: + if command.CDB[1] == 0 { + data = []byte{0, 0, 0, 0, 0, 0, 0, 1, 0x20} + break + } + data = make([]byte, 24) + data[1], data[3], data[12] = 0x20, 20, 0x10 + case 0xa3: + length := 10 + if command.CDB[3] == 0x92 { + length = 16 + } + data = make([]byte, 4+length) + data[1], data[3], data[4] = 3, byte(length), command.CDB[3] + for i := 5; i < len(data); i++ { + data[i] = 0xff + } + if command.CDB[3] == 0x34 { + data[5] = command.CDB[5] + } + case 0x4d: + data = []byte{0x0c, 0, 0, 16, 0, 4, 0x60, 4, 0, 0, 0, 100, 0, 5, 0x60, 4, 0, 0, 0, 200} + default: + return Response{}, errors.New("unexpected query") + } + copy(command.Data, data) + return Response{Completed: true, Residual: int32(len(command.Data) - len(data))}, nil +} + +type fixedTransferTransport struct { + *serializedTransport + limit int +} + +func (t *fixedTransferTransport) TransferLimit() int { return t.limit } + +func TestInspectionDoesNotAdvertiseGrowableReservationAsMaximum(t *testing.T) { + for _, growable := range []bool{false, true} { + // Both descriptors currently reserve 32 KiB; only an immutable bound establishes a maximum. + serialized := newSerializedTransport(inspectionFixture, func() error { return nil }) + var transport Transport = &fixedTransferTransport{serializedTransport: serialized, limit: 32 << 10} + if growable { + transport = &sgTransport{serializedTransport: serialized, limit: 32 << 10} + } + defer transport.Close() + report, err := inspectTransport(context.Background(), Identity{Configured: "fixture"}, transport) + if err != nil { + t.Fatal(err) + } + for _, kind := range []entity.DeviceFeatureKind{entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_SEQUENTIAL_READ, entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_SEQUENTIAL_WRITE} { + limits := feature(report, kind).GetRecords() + if limits.GetMaximumBytes() != 512<<10 || (limits.TransportMaximumBytes == nil) != growable || !growable && limits.GetTransportMaximumBytes() != 32<<10 { + t.Fatalf("growable=%t limits=%v", growable, limits) + } + } + } +} + +func TestInspectionUsesOnlyPassiveBoundedQueriesAndUnvalidatedFacts(t *testing.T) { + // Every command is checked at the production read-only allowlist before fake execution. + var calls int + transport := newSerializedTransport(func(command Command) (Response, error) { + calls++ + if !passiveCommand(command) || len(command.Data) > MaxQueryBytes { + t.Fatalf("unsafe inspection CDB=%x", command.CDB) + } + return inspectionFixture(command) + }, func() error { return nil }) + defer transport.Close() + report, err := inspectTransport(context.Background(), Identity{Configured: "/dev/nst0", Key: "/sys/device"}, transport) + if err != nil || calls != 15 || report.GetVendor() != "IBM" || report.GetSerialNumber() != "SN1" { + t.Fatalf("report=%v err=%v calls=%d", report, err, calls) + } + tape := report.GetTape() + if !tape.GetMediumPresent() || !tape.GetWriteProtected() || tape.GetPartitionCount() != 2 || !tape.GetCompressionEnabled() || !tape.GetDecompressionEnabled() { + t.Fatalf("mode facts=%v", tape) + } + for _, f := range tape.Features { + if f.Validation != entity.DeviceFeatureValidation_DEVICE_FEATURE_VALIDATION_UNVALIDATED { + t.Fatalf("passive query certified hardware: %v", f) + } + if f.Feature == entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_LOCATE && (f.Support != entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_SUPPORTED || f.GetNavigation().GetAddressBits() != 64) { + t.Fatal("reported navigation pair is missing") + } + } + limits := feature(report, entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_SEQUENTIAL_READ).GetRecords() + if limits.GetMaximumBytes() != 512<<10 || !limits.GetFixed() || !limits.GetVariable() { + t.Fatalf("record limits=%v", limits) + } +} + +func TestInspectionNoMediumKeepsDriveFactsAndOptionalFailuresAreUnknown(t *testing.T) { + for _, malformed := range []bool{false, true} { + // Missing optional metadata does not revoke a completed identity/readiness observation. + transport := newSerializedTransport(func(command Command) (Response, error) { + if command.CDB[0] == 0 { + return Response{Completed: true, Status: 2, Sense: fixedSense(0x70, 2, 0x3a, 0, false, 0, 0)}, nil + } + if command.CDB[0] == 5 || command.CDB[0] == 0x5a || command.CDB[0] == 0x34 { + if malformed { + clear(command.Data) + return Response{Completed: true, Residual: int32(len(command.Data) - 1)}, nil + } + return Response{Completed: true, Status: 2, Sense: fixedSense(0x70, 5, 0x20, 0, false, 0, 0)}, nil + } + return inspectionFixture(command) + }, func() error { return nil }) + report, err := inspectTransport(context.Background(), Identity{Configured: "drive"}, transport) + if err != nil || report.GetModel() != "ULT3580-TD8" || report.GetTape().MediumPresent == nil || report.GetTape().GetMediumPresent() { + t.Fatalf("report=%v err=%v", report, err) + } + if report.Reason != entity.DeviceReason_DEVICE_REASON_NO_MEDIUM { + t.Fatalf("no-medium reason=%v", report.Reason) + } + if report.GetTape().DensityCode != nil || report.GetTape().WriteProtected != nil || report.GetTape().PartitionCount != nil { + t.Fatalf("no-medium report invented cartridge facts=%v", report.GetTape()) + } + if limits := feature(report, entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_SEQUENTIAL_WRITE).GetRecords(); limits != nil { + t.Fatalf("optional failure invented limits=%v", limits) + } + if _, err := transport.Execute(context.Background(), Command{CDB: []byte{0, 0, 0, 0, 0, 0}, Direction: NoData, Timeout: 1e9}); err != nil { + t.Fatalf("optional body/rejection poisoned transport: %v", err) + } + _ = transport.Close() + } +} + +func TestInspectionStopsAfterUnknownTransportOutcome(t *testing.T) { + // An issued command whose completion is unknown ends all later inspection commands. + calls := 0 + transport := newSerializedTransport(func(Command) (Response, error) { calls++; return Response{Completed: true, HostStatus: 3}, nil }, func() error { return nil }) + defer transport.Close() + report, err := inspectTransport(context.Background(), Identity{Configured: "drive"}, transport) + if report == nil || !errors.Is(err, ErrUncertain) || calls != 1 || report.Reason != entity.DeviceReason_DEVICE_REASON_OWNERSHIP_UNCERTAIN { + t.Fatalf("report=%v error=%v calls=%d", report, err, calls) + } +} + +func TestPassiveAllowlistRejectsMotionMutationAndKeys(t *testing.T) { + for _, opcode := range []byte{1, 4, 8, 0x0a, 0x10, 0x11, 0x15, 0x1b, 0x2b, 0x55, 0x92, 0xb5} { + // Changing transfer direction cannot smuggle an active command into a passive SG open. + if passiveCommand(Command{CDB: []byte{opcode, 0, 0, 0, 0, 0}, Direction: FromDevice, Data: make([]byte, 6)}) { + t.Fatalf("active opcode allowed=%02x", opcode) + } + } + if passiveCommand(Command{}) { + t.Fatal("empty query accepted") + } +} diff --git a/internal/media/tape/ltfs_labels.go b/internal/media/tape/ltfs_labels.go new file mode 100644 index 0000000..945ea51 --- /dev/null +++ b/internal/media/tape/ltfs_labels.go @@ -0,0 +1,142 @@ +package tape + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "time" + + "github.com/samuelncui/yatm/entity" +) + +const ltfsLabelMaximumBytes = 512 << 10 + +// ReadLTFSLabels reads the two physical partition prefixes before LTFS owns the device. +// Returned XML records are owned copies; semantic Label validation belongs to the consumer. +// The implementation cap is not an LTFS format maximum. Cleanup remains the caller's obligation. +// Native LOCATE/READ timeouts follow HP LTO-5 Technical Reference Manual volume 2, page 22. +func (d *Device) ReadLTFSLabels(ctx context.Context) ([2][]byte, error) { + // Establish the bounded variable-record command envelope without changing drive controls or keys. + var labels [2][]byte + if err := ctx.Err(); err != nil { + return labels, err + } + if d == nil || d.transport == nil || d.report.GetTape() == nil || d.operation != 0 { + return labels, fmt.Errorf("LTFS Label observation requires an unconfigured opened device: %w", ErrUnsupported) + } + medium := d.report.GetTape() + if medium.MediumPresent != nil && !medium.GetMediumPresent() || + d.report.Reason == entity.DeviceReason_DEVICE_REASON_NOT_READY || d.report.Reason == entity.DeviceReason_DEVICE_REASON_NO_MEDIUM { + return labels, fmt.Errorf("LTFS Label medium is not ready") + } + if medium.MediumPresent == nil { + return labels, fmt.Errorf("LTFS Label medium presence is unknown: %w", ErrUnsupported) + } + limits := feature(d.report, entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_SEQUENTIAL_READ).GetRecords() + if limits == nil || limits.MinimumBytes == nil || limits.MaximumBytes == nil || + limits.GetMinimumBytes() > 80 || limits.GetMaximumBytes() < 80 || + limits.Variable != nil && !limits.GetVariable() { + return labels, fmt.Errorf("LTFS Label record bounds are unavailable: %w", ErrUnsupported) + } + maximum := min(ltfsLabelMaximumBytes, int(limits.GetMaximumBytes())) + if reserve, ok := d.transport.(interface { + EnsureTransfer(context.Context, int) error + }); ok { + // EnsureTransfer owns reservation/readback; verify its exposed current bound when available. + if err := reserve.EnsureTransfer(ctx, maximum); err != nil { + if ctx.Err() != nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || + errors.Is(err, ErrUncertain) { + return labels, fmt.Errorf("reserve LTFS Label buffer failed, %w", errors.Join(err, ctx.Err())) + } + return labels, fmt.Errorf("LTFS Label host buffer is unavailable (%v): %w", err, ErrUnsupported) + } + if bounded, ok := d.transport.(interface{ TransferLimit() int }); ok && bounded.TransferLimit() < maximum { + return labels, fmt.Errorf("LTFS Label buffer reservation readback is insufficient: %w", ErrUnsupported) + } + } else if bounded, ok := d.transport.(interface{ TransferLimit() int }); ok { + maximum = min(maximum, bounded.TransferLimit()) + } else { + return labels, fmt.Errorf("LTFS Label transport bound is unavailable: %w", ErrUnsupported) + } + if maximum < 80 { + return labels, fmt.Errorf("LTFS Label transport buffer is insufficient: %w", ErrUnsupported) + } + buffer := make([]byte, maximum) + records := Records{Transport: d.transport, Mode: RecordMode{MaximumBytes: maximum}, Timeout: 20 * time.Minute, + invalidateGeometry: d.invalidateGeometry} + + // Qualify CP and physical partition one explicitly; the tar navigator remains partition-zero scoped. + locate, _ := locateCDB(0x92, 1, 0) + read, err := records.command(0x08, buffer, FromDevice) + if err != nil { + return labels, err + } + for _, form := range [][]byte{locate, read.CDB} { + data, err := reportCommand(ctx, d.transport, form[0], nil) + if err != nil { + return labels, fmt.Errorf("observe LTFS Label command %02x failed, %w", form[0], err) + } + support, err := commandFormSupport(data, form) + if err != nil || support != entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_SUPPORTED { + return labels, fmt.Errorf("LTFS Label command %02x form is unavailable: %w", form[0], ErrUnsupported) + } + } + + // Each prefix is VOL1, filemark, one complete XML record, filemark; filemarks count as objects. + for partition := range labels { + if err := records.Locate(ctx, byte(partition), 0); err != nil { + return [2][]byte{}, fmt.Errorf("locate LTFS Label partition %d failed, %w", partition, err) + } + for step := range 4 { + result, err := records.Read(ctx, buffer) + if err != nil { + if errors.Is(err, io.ErrShortBuffer) && result.Sense.ILI && result.Sense.InformationValid && + result.Sense.Information < 0 && onlyLTFSLabelOversize(err) { + return [2][]byte{}, fmt.Errorf("LTFS Label record exceeds the observation bound: %w", ErrUnsupported) + } + return [2][]byte{}, fmt.Errorf("read LTFS Label partition %d record %d failed, %w", partition, step, err) + } + if !result.AcceptedKnown || result.EOD || step%2 == 1 && (!result.Filemark || result.Bytes != 0) || + step%2 == 0 && (result.Filemark || result.Bytes == 0) { + return [2][]byte{}, fmt.Errorf("LTFS Label prefix framing is unavailable: %w", ErrUnsupported) + } + if step == 0 && (result.Bytes != 80 || !bytes.Equal(buffer[:4], []byte("VOL1")) || + !bytes.Equal(buffer[24:28], []byte("LTFS"))) { + return [2][]byte{}, fmt.Errorf("LTFS VOL1 label is unavailable: %w", ErrUnsupported) + } + if step == 2 { + labels[partition] = bytes.Clone(buffer[:result.Bytes]) + } + } + } + return labels, nil +} + +func onlyLTFSLabelOversize(err error) bool { + if err == io.ErrShortBuffer { + return true + } + if failure, ok := err.(*CommandError); ok { + sense := failure.Sense + return currentCommand(failure) && sense.Key == 0 && sense.ASC == 0 && sense.ASCQ == 0 && + sense.ILI && !sense.Filemark && !sense.EOM && sense.InformationValid && sense.Information < 0 + } + if wrapped, ok := err.(interface{ Unwrap() error }); ok { + return onlyLTFSLabelOversize(wrapped.Unwrap()) + } + if joined, ok := err.(interface{ Unwrap() []error }); ok { + causes := joined.Unwrap() + if len(causes) == 0 { + return false + } + for _, cause := range causes { + if !onlyLTFSLabelOversize(cause) { + return false + } + } + return true + } + return false +} diff --git a/internal/media/tape/ltfs_labels_test.go b/internal/media/tape/ltfs_labels_test.go new file mode 100644 index 0000000..30c562e --- /dev/null +++ b/internal/media/tape/ltfs_labels_test.go @@ -0,0 +1,515 @@ +package tape + +import ( + "bytes" + "context" + "errors" + "io" + "reflect" + "strings" + "sync" + "testing" + "time" + "unsafe" + + "github.com/samuelncui/yatm/entity" + "google.golang.org/protobuf/proto" +) + +type ltfsLabelCommand struct { + cdb []byte + bytes int + timeout time.Duration +} + +type ltfsLabelFixture struct { + *serializedTransport + base *deviceFixture + commands []ltfsLabelCommand + reserve []int + reserveErr error + onReserve func() + shortReserve bool + limit int + partition int + step int + xml [2][]byte + edit func(int, int, []byte) []byte + fail func(Command) (Response, error, bool) +} + +func newLTFSLabelFixture(t *testing.T) (*Device, *ltfsLabelFixture) { + t.Helper() + // Reuse actual inspection and native response decoding; only the physical records are synthetic. + f := <fsLabelFixture{base: newDeviceFixture(), limit: 32 << 10, + xml: [2][]byte{[]byte(""), []byte("")}} + f.base.mode[51] = 1 + f.serializedTransport = newSerializedTransport(f.execute, func() error { f.base.closed++; return nil }) + d, err := NewDevice(t.Context(), Identity{Configured: "fixture"}, f) + if err != nil { + t.Fatal(err) + } + f.commands = nil + t.Cleanup(func() { _ = d.Cleanup(context.Background()) }) + return d, f +} + +func (f *ltfsLabelFixture) EnsureTransfer(ctx context.Context, size int) error { + f.reserve = append(f.reserve, size) + if err := ctx.Err(); err != nil { + return err + } + if f.reserveErr == nil && !f.shortReserve { + f.limit = max(f.limit, size) + } + if f.onReserve != nil { + f.onReserve() + } + return f.reserveErr +} + +func (f *ltfsLabelFixture) TransferLimit() int { return f.limit } + +func (f *ltfsLabelFixture) execute(command Command) (Response, error) { + // Capture command shapes without retaining the reusable read buffer or its contents. + f.commands = append(f.commands, ltfsLabelCommand{cdb: bytes.Clone(command.CDB), bytes: len(command.Data), timeout: command.Timeout}) + if f.fail != nil { + if response, err, handled := f.fail(command); handled { + return response, err + } + } + + // Report exact variable READ and CP LOCATE forms, then serve the four framed partition objects. + if command.CDB[0] == 0xa3 && command.CDB[3] == 8 { + clear(command.Data) + copy(command.Data, []byte{0, 3, 0, 6, 8, 0xff, 0xff, 0xff, 0xff, 0xff}) + return Response{Completed: true, Residual: int32(len(command.Data) - 10)}, nil + } + if command.CDB[0] == 0x92 { + f.partition, f.step = int(command.CDB[3]), 0 + return Response{Completed: true}, nil + } + if command.CDB[0] != 8 { + return f.base.execute(command) + } + var data []byte + switch f.step { + case 0: + data = bytes.Repeat([]byte{' '}, 80) + copy(data, "VOL1ABC001") + copy(data[24:], "LTFS") + case 2: + data = f.xml[f.partition] + } + if f.edit != nil { + data = f.edit(f.partition, f.step, data) + } + f.step++ + if data == nil { + return Response{Completed: true, Status: 2, Residual: int32(len(command.Data)), + Sense: fixedSense(0x70, 0, 0, 1, true, 0x80, int32(len(command.Data)))}, nil + } + copy(command.Data, data) + remaining := len(command.Data) - len(data) + if remaining == 0 { + return Response{Completed: true}, nil + } + residual := max(remaining, 0) + return Response{Completed: true, Status: 2, Residual: int32(residual), + Sense: fixedSense(0x70, 0, 0, 0, true, 0x20, int32(remaining))}, nil +} + +func TestLTFSLabelsReadBothPrefixesWithoutControlOrCleanupOwnership(t *testing.T) { + // A nonzero existing block descriptor and captured cipher state do not authorize control changes. + d, f := newLTFSLabelFixture(t) + if !d.report.GetTape().GetFixedRecords() || d.report.GetTape().GetRecordBytes() == 0 { + t.Fatal("fixture must retain its existing nonzero block descriptor") + } + originalMode := bytes.Clone(f.base.mode) + originalSecurity := f.base.security + labels, err := d.ReadLTFSLabels(t.Context()) + if err != nil || !reflect.DeepEqual(labels, f.xml) { + t.Fatalf("labels=%q err=%v", labels, err) + } + if !reflect.DeepEqual(f.reserve, []int{ltfsLabelMaximumBytes}) { + t.Fatalf("reservation=%v", f.reserve) + } + + // Qualification precedes exactly two CP LOCATEs and eight variable READs, with no extra motion. + var opcodes []byte + for _, command := range f.commands { + opcodes = append(opcodes, command.cdb[0]) + switch command.cdb[0] { + case 0xa3: + case 0x92: + if command.timeout != 20*time.Minute || command.cdb[1] != 2 || !bytes.Equal(command.cdb[4:12], make([]byte, 8)) { + t.Fatalf("LOCATE=%x timeout=%s", command.cdb, command.timeout) + } + case 8: + if command.timeout != 20*time.Minute || command.cdb[1] != 0 || command.bytes != ltfsLabelMaximumBytes { + t.Fatalf("READ=%x bytes=%d timeout=%s", command.cdb, command.bytes, command.timeout) + } + default: + t.Fatalf("unexpected command after inspection: %x", command.cdb) + } + } + if !reflect.DeepEqual(opcodes, []byte{0xa3, 0xa3, 0x92, 8, 8, 8, 8, 0x92, 8, 8, 8, 8}) || + f.commands[2].cdb[3] != 0 || f.commands[7].cdb[3] != 1 { + t.Fatalf("commands=%x", opcodes) + } + + // Both records are independent copies and cleanup remains with the existing Device owner. + f.xml[0][0] = '!' + labels[1][0] = '!' + if labels[0][0] != '<' || f.xml[1][0] != '<' || f.base.closed != 0 || f.base.security != originalSecurity || + !bytes.Equal(originalMode, f.base.mode) || f.base.selects != 0 || f.base.formats != 0 || f.base.writes != 0 { + t.Fatal("borrowed records, changed controls, or premature close") + } + if err := d.Cleanup(t.Context()); err != nil || f.base.closed != 1 || f.base.security != originalSecurity || !bytes.Equal(originalMode, f.base.mode) { + t.Fatalf("cleanup=%v closed=%d", err, f.base.closed) + } +} + +func TestLTFSLabelsRejectBoundsAndUnsupportedCPBeforeMotion(t *testing.T) { + for _, test := range []struct { + name string + edit func(*Device, *ltfsLabelFixture) + }{ + {"unknown maximum", func(d *Device, _ *ltfsLabelFixture) { + feature(d.report, entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_SEQUENTIAL_READ).GetRecords().MaximumBytes = nil + }}, + {"ANSI outside minimum", func(d *Device, _ *ltfsLabelFixture) { + feature(d.report, entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_SEQUENTIAL_READ).GetRecords().MinimumBytes = proto.Uint32(512) + }}, + {"fixed only", func(d *Device, _ *ltfsLabelFixture) { + feature(d.report, entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_SEQUENTIAL_READ).GetRecords().Variable = proto.Bool(false) + }}, + {"unknown presence", func(d *Device, _ *ltfsLabelFixture) { d.report.GetTape().MediumPresent = nil }}, + {"reservation insufficient", func(_ *Device, f *ltfsLabelFixture) { f.shortReserve = true }}, + {"reservation rejected", func(_ *Device, f *ltfsLabelFixture) { f.reserveErr = ErrUnsupported }}, + {"native reservation cap", func(_ *Device, f *ltfsLabelFixture) { + f.reserveErr = errors.New("SG reserved buffer 262144 is smaller than selected record 524288") + }}, + {"fixed transport insufficient", func(d *Device, f *ltfsLabelFixture) { + d.transport = &fixedTransferTransport{serializedTransport: f.serializedTransport, limit: 79} + }}, + {"unknown transport bound", func(d *Device, f *ltfsLabelFixture) { d.transport = f.serializedTransport }}, + {"partition byte unsupported", func(_ *Device, f *ltfsLabelFixture) { + f.fail = func(c Command) (Response, error, bool) { + if c.CDB[0] != 0xa3 { + return Response{}, nil, false + } + r, err := f.base.execute(c) + c.Data[7] = 0 // The partition-one bit is not supported by this usage map. + return r, err, true + } + }}, + {"unknown command form", func(_ *Device, f *ltfsLabelFixture) { + f.fail = func(c Command) (Response, error, bool) { + if c.CDB[0] != 0xa3 { + return Response{}, nil, false + } + clear(c.Data) + return Response{Completed: true, Residual: int32(len(c.Data) - 2)}, nil, true + } + }}, + {"malformed command usage", func(_ *Device, f *ltfsLabelFixture) { + f.fail = func(c Command) (Response, error, bool) { + if c.CDB[0] != 0xa3 { + return Response{}, nil, false + } + r, err := f.base.execute(c) + c.Data[8] = 0x0f // A partial logical-object field cannot establish a command form. + return r, err, true + } + }}, + } { + t.Run(test.name, func(t *testing.T) { + // Invalid bounds and an unsupported CP partition field cannot enter the physical read lane. + d, f := newLTFSLabelFixture(t) + test.edit(d, f) + labels, err := d.ReadLTFSLabels(t.Context()) + if !CleanUnsupported(err) || labels[0] != nil || labels[1] != nil { + t.Fatalf("labels=%q err=%v", labels, err) + } + for _, command := range f.commands { + if command.cdb[0] != 0xa3 { + t.Fatalf("active command=%x", command.cdb) + } + } + }) + } +} + +func TestLTFSLabelsRejectUnusableFramingAndOversizeWithoutRetry(t *testing.T) { + for _, test := range []struct { + name string + edit func(*ltfsLabelFixture) + reads int + }{ + {"bad VOL1", func(f *ltfsLabelFixture) { + f.edit = func(_ int, step int, data []byte) []byte { + if step == 0 { + data[0] = 'X' + } + return data + } + }, 1}, + {"missing first mark", func(f *ltfsLabelFixture) { + f.edit = func(_ int, step int, data []byte) []byte { + if step == 1 { + return []byte("data") + } + return data + } + }, 2}, + {"missing XML", func(f *ltfsLabelFixture) { f.xml[0] = nil }, 3}, + {"missing final mark", func(f *ltfsLabelFixture) { + f.edit = func(_ int, step int, data []byte) []byte { + if step == 3 { + return []byte("data") + } + return data + } + }, 4}, + {"oversized XML", func(f *ltfsLabelFixture) { f.xml[0] = make([]byte, ltfsLabelMaximumBytes+1) }, 3}, + } { + t.Run(test.name, func(t *testing.T) { + // A settled unusable prefix withdraws the entire pair; it cannot trigger another partition or retry. + d, f := newLTFSLabelFixture(t) + test.edit(f) + labels, err := d.ReadLTFSLabels(t.Context()) + if !CleanUnsupported(err) || labels[0] != nil || labels[1] != nil { + t.Fatalf("labels=%q err=%v", labels, err) + } + reads, moves := 0, 0 + for _, command := range f.commands { + if command.cdb[0] == 8 { + reads++ + } + if command.cdb[0] == 0x92 { + moves++ + } + } + if reads != test.reads || moves != 1 || f.base.closed != 0 { + t.Fatalf("reads=%d moves=%d closed=%d", reads, moves, f.base.closed) + } + }) + } +} + +func TestLTFSLabelsNativeReservationCapKeepsOrdinaryMountAvailable(t *testing.T) { + // Exercise the native reservation implementation, with only the host ioctl and physical transport stubbed. + d, f := newLTFSLabelFixture(t) + selected, readback, allocated := 0, 0, 0 + transport := &sgTransport{serializedTransport: f.serializedTransport, limit: 64 << 10} + transport.ioctl = func(command uintptr, data unsafe.Pointer) error { + switch command { + case sgSetReservedSize: + selected = int(*(*int32)(data)) + case sgReservedSize: + readback++ + *(*int32)(data) = 64 << 10 + default: + t.Fatalf("unexpected ioctl=%x", command) + } + return nil + } + transport.reserve = func(size int) error { allocated = size; return nil } + d.transport = transport + labels, err := d.ReadLTFSLabels(t.Context()) + if !CleanUnsupported(err) || labels[0] != nil || labels[1] != nil || len(f.commands) != 0 || + selected != ltfsLabelMaximumBytes || readback != 1 || allocated != 64<<10 || f.base.closed != 0 { + t.Fatalf("labels=%q commands=%v selected=%d readback=%d allocated=%d closed=%d err=%v", + labels, f.commands, selected, readback, allocated, f.base.closed, err) + } + + // No command owns the capped descriptor; successful ordinary cleanup releases it exactly once. + if err := d.Cleanup(t.Context()); err != nil || f.base.closed != 1 { + t.Fatalf("closed=%d err=%v", f.base.closed, err) + } +} + +func TestLTFSLabelsReservationRetainsCancellationAndUncertainty(t *testing.T) { + for _, failure := range []error{context.Canceled, context.DeadlineExceeded, + &UncertainError{Cause: io.ErrUnexpectedEOF}, errors.Join(io.ErrShortBuffer, context.Canceled)} { + // A local allocation miss is optional; loss of ownership or request cancellation is not. + d, f := newLTFSLabelFixture(t) + f.reserveErr = failure + labels, err := d.ReadLTFSLabels(t.Context()) + if !errors.Is(err, failure) || CleanUnsupported(err) || labels[0] != nil || labels[1] != nil || len(f.commands) != 0 { + t.Fatalf("failure=%v labels=%q commands=%v err=%v", failure, labels, f.commands, err) + } + } + + // Cancellation observed while a plain host allocation fails keeps both real causes. + d, f := newLTFSLabelFixture(t) + ctx, cancel := context.WithCancel(t.Context()) + f.reserveErr, f.onReserve = io.ErrShortBuffer, cancel + _, err := d.ReadLTFSLabels(ctx) + if !errors.Is(err, context.Canceled) || !errors.Is(err, io.ErrShortBuffer) || CleanUnsupported(err) || len(f.commands) != 0 { + t.Fatalf("commands=%v err=%v", f.commands, err) + } +} + +func TestLTFSLabelsPreserveRealErrorsAndJoinedOversizeCauses(t *testing.T) { + for _, failure := range []error{context.Canceled, &UncertainError{Cause: io.ErrUnexpectedEOF}, io.ErrUnexpectedEOF, io.ErrShortBuffer} { + // A real native/transport failure must remain distinguishable from optional observation absence. + d, f := newLTFSLabelFixture(t) + f.fail = func(c Command) (Response, error, bool) { + if c.CDB[0] == 8 { + return Response{}, failure, true + } + return Response{}, nil, false + } + _, err := d.ReadLTFSLabels(t.Context()) + if !errors.Is(err, failure) || CleanUnsupported(err) { + t.Fatalf("failure=%v err=%v", failure, err) + } + } + + // Oversize is optional only when every cause belongs to the exact settled overlong-record response. + oversize := errors.Join(io.ErrShortBuffer, &CommandError{Response: Response{Completed: true, Status: 2}, Sense: Sense{ILI: true, InformationValid: true, Information: -1}}) + for _, failure := range []error{context.Canceled, &UncertainError{Cause: io.ErrUnexpectedEOF}, io.ErrUnexpectedEOF} { + if onlyLTFSLabelOversize(errors.Join(oversize, failure)) { + t.Fatalf("joined real error was hidden: %v", failure) + } + } +} + +func TestLTFSLabelsCancellationWaitsForIssuedReadAndDoesNotClose(t *testing.T) { + // A submitted native READ retains its command/buffer owner until the transport returns. + d, f := newLTFSLabelFixture(t) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + issued, release := make(chan struct{}), make(chan struct{}) + releaseNative := sync.OnceFunc(func() { close(release) }) + defer releaseNative() + f.fail = func(c Command) (Response, error, bool) { + if c.CDB[0] == 8 { + close(issued) + <-release + return Response{Completed: true}, nil, true + } + return Response{}, nil, false + } + done := make(chan error, 1) + go func() { _, err := d.ReadLTFSLabels(ctx); done <- err }() + select { + case <-issued: + case err := <-done: + t.Fatalf("probe returned before issuing READ: %v", err) + } + cancel() + select { + case err := <-done: + t.Fatalf("issued READ returned early: %v", err) + default: + } + releaseNative() + if err := <-done; !errors.Is(err, context.Canceled) || CleanUnsupported(err) { + t.Fatalf("err=%v", err) + } + + // Cancellation ends the probe without another read, compensating motion or descriptor close. + reads, moves := 0, 0 + for _, command := range f.commands { + if command.cdb[0] == 8 { + reads++ + } + if command.cdb[0] == 0x92 { + moves++ + } + } + if reads != 1 || moves != 1 || f.base.closed != 0 { + t.Fatalf("reads=%d moves=%d closed=%d", reads, moves, f.base.closed) + } +} + +func TestLTFSLabelsUseSmallerObservedAndTransportBounds(t *testing.T) { + for _, fixed := range []bool{false, true} { + // A declared smaller envelope bounds all requests; no optional partition-list attribute is needed. + d, f := newLTFSLabelFixture(t) + d.report.GetTape().PartitionCount = nil + maximum := 64 << 10 + feature(d.report, entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_SEQUENTIAL_READ).GetRecords().MaximumBytes = proto.Uint32(uint32(maximum)) + if fixed { + maximum = 1024 + d.transport = &fixedTransferTransport{serializedTransport: f.serializedTransport, limit: maximum} + } + labels, err := d.ReadLTFSLabels(t.Context()) + if err != nil || !reflect.DeepEqual(labels, f.xml) { + t.Fatalf("fixed=%t labels=%q err=%v", fixed, labels, err) + } + for _, command := range f.commands { + if command.cdb[0] == 8 && command.bytes != maximum { + t.Fatalf("requested=%d bound=%d", command.bytes, maximum) + } + } + } +} + +func TestLTFSLabelsObservedNotReadyAndPreIssueCancellationDoNotMove(t *testing.T) { + for _, reason := range []entity.DeviceReason{entity.DeviceReason_DEVICE_REASON_NO_MEDIUM, entity.DeviceReason_DEVICE_REASON_NOT_READY} { + // An observed readiness failure is a real attempt error, not optional missing Label evidence. + d, f := newLTFSLabelFixture(t) + d.report.Reason = reason + _, err := d.ReadLTFSLabels(t.Context()) + if err == nil || CleanUnsupported(err) || len(f.commands) != 0 || len(f.reserve) != 0 { + t.Fatalf("reason=%s err=%v commands=%v", reason, err, f.commands) + } + } + + // Canceled admission and host reservation failures cannot issue even a qualification query. + d, f := newLTFSLabelFixture(t) + ctx, cancel := context.WithCancel(t.Context()) + cancel() + if _, err := d.ReadLTFSLabels(ctx); !errors.Is(err, context.Canceled) || len(f.commands) != 0 || len(f.reserve) != 0 { + t.Fatalf("err=%v commands=%v", err, f.commands) + } + f.reserveErr = io.ErrUnexpectedEOF + if _, err := d.ReadLTFSLabels(t.Context()); !CleanUnsupported(err) || !strings.Contains(err.Error(), io.ErrUnexpectedEOF.Error()) || len(f.commands) != 0 { + t.Fatalf("reservation err=%v", err) + } +} + +func TestLTFSLabelsNativeFailureClassification(t *testing.T) { + for _, test := range []struct { + name string + opcode byte + response func(int) Response + optional, uncertain bool + }{ + {"LOCATE unsupported", 0x92, func(_ int) Response { + return Response{Completed: true, Status: 2, Sense: fixedSense(0x70, 5, 0x20, 0, false, 0, 0)} + }, true, false}, + {"READ unsupported", 8, func(n int) Response { + return Response{Completed: true, Status: 2, Residual: int32(n), Sense: fixedSense(0x70, 5, 0x20, 0, false, 0, 0)} + }, true, false}, + {"EOD prefix", 8, func(n int) Response { + return Response{Completed: true, Status: 2, Residual: int32(n), Sense: fixedSense(0x70, 8, 0, 5, true, 0, int32(n))} + }, true, false}, + {"medium error", 8, func(_ int) Response { + return Response{Completed: true, Status: 2, Sense: fixedSense(0x70, 3, 0x11, 0, false, 0, 0)} + }, false, true}, + {"medium removed", 8, func(_ int) Response { + return Response{Completed: true, Status: 2, Sense: fixedSense(0x70, 2, 0x3a, 0, false, 0, 0)} + }, false, false}, + {"malformed GOOD residual", 8, func(_ int) Response { return Response{Completed: true, Residual: 1} }, false, true}, + } { + t.Run(test.name, func(t *testing.T) { + // Only known settled unsupported commands or unusable prefixes can become LOGICAL fallback. + d, f := newLTFSLabelFixture(t) + f.fail = func(c Command) (Response, error, bool) { + if c.CDB[0] == test.opcode { + return test.response(len(c.Data)), nil, true + } + return Response{}, nil, false + } + labels, err := d.ReadLTFSLabels(t.Context()) + if err == nil || CleanUnsupported(err) != test.optional || errors.Is(err, ErrUncertain) != test.uncertain || labels[0] != nil || labels[1] != nil { + t.Fatalf("labels=%q err=%v optional=%t uncertain=%t", labels, err, CleanUnsupported(err), errors.Is(err, ErrUncertain)) + } + }) + } +} diff --git a/internal/media/tape/navigation.go b/internal/media/tape/navigation.go new file mode 100644 index 0000000..86a236d --- /dev/null +++ b/internal/media/tape/navigation.go @@ -0,0 +1,208 @@ +package tape + +import ( + "context" + "encoding/binary" + "errors" + "fmt" + "math" + "time" + + "github.com/samuelncui/yatm/entity" + "google.golang.org/protobuf/proto" +) + +// Navigation exposes standard SSC logical-object addressing within the prepared record envelope. +// It does not establish physical qualification; consumers verify fresh positions and completion +// before publishing anchors and retain the attempt lease through every issued command. +type Navigation struct { + records Records + bits uint32 + long bool + opcode byte +} + +// Navigation returns an eligible pairing without another form probe or physical validation claim. +// Missing optional evidence leaves baseline sequential access available. +func (d *Device) Navigation(ctx context.Context) (*Navigation, error) { + // Reuse passive form evidence only while command admission remains available. + if err := ctx.Err(); err != nil { + return nil, err + } + if d.operation == 0 || d.navigation == nil { + return nil, nil + } + + // Configure owns the SSC record envelope; a reported contradiction or failed qualification removes seek. + for _, kind := range []entity.DeviceFeatureKind{entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_READ_POSITION, entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_LOCATE} { + f := feature(d.report, kind) + if f.Support != entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_SUPPORTED || + f.Validation == entity.DeviceFeatureValidation_DEVICE_FEATURE_VALIDATION_FAILED { + return nil, nil + } + if f.Reason != 0 && f.Reason != entity.DeviceReason_DEVICE_REASON_NOT_READY { + return nil, nil + } + } + return d.navigation, nil +} + +func (n *Navigation) AddressBits() uint32 { return n.bits } + +// Position preserves GOOD unknown flags separately from unsupported, malformed or uncertain errors. +func (n *Navigation) Position(ctx context.Context) (Position, error) { + return n.records.Position(ctx, n.long) +} + +// Locate chooses one observed form before motion and never retries another form after issuance. +// The consumer owns source-lane failure; Transport owns serialization and uncertain outcomes. +func (n *Navigation) Locate(ctx context.Context, partition uint32, object uint64) error { + // The consumed tar pairing is scoped to partition zero, not every representable partition field. + if partition != 0 { + return fmt.Errorf("Tape navigation pairing is scoped to partition zero: %w", ErrUnsupported) + } + + // Select and range-check the established form before entering the transport lane. + if n.bits == 32 && object > math.MaxUint32 { + return fmt.Errorf("Tape object exceeds navigation address width") + } + cdb, err := locateCDB(n.opcode, partition, object) + if err != nil { + return err + } + if err := ctx.Err(); err != nil { + return err + } + return n.records.locate(ctx, cdb) +} + +func locateCDB(opcode byte, partition uint32, object uint64) ([]byte, error) { + // SSC implicit object addressing uses CP=1, IMMED=0, and BT/DEST_TYPE/BAM=0. + if partition > math.MaxUint8 { + return nil, fmt.Errorf("Tape partition exceeds the LOCATE field") + } + switch opcode { + case 0x2b: + if object > math.MaxUint32 { + return nil, fmt.Errorf("Tape object exceeds LOCATE(10) address width") + } + cdb := make([]byte, 10) + cdb[0], cdb[1], cdb[8] = opcode, 2, byte(partition) + binary.BigEndian.PutUint32(cdb[3:7], uint32(object)) + return cdb, nil + case 0x92: + cdb := make([]byte, 16) + cdb[0], cdb[1], cdb[3] = opcode, 2, byte(partition) + binary.BigEndian.PutUint64(cdb[4:12], object) + return cdb, nil + default: + return nil, ErrUnsupported + } +} + +func inspectNavigation(ctx context.Context, transport Transport, report *entity.InspectDeviceResponse, observed Position, observedLong bool, positionErr error) (*Navigation, Position, error) { + // Select the reported host-next observation form independently from the motion command width. + unknown, positionSupported, long := false, false, false + for _, candidate := range []bool{true, false} { + // The initial short observation already means the actual long query was cleanly rejected. + if candidate && !observedLong { + continue + } + action := uint16(0) + if candidate { + action = 6 + } + form := make([]byte, 10) + form[0], form[1] = 0x34, byte(action) + data, err := reportCommand(ctx, transport, 0x34, &action) + if err != nil { + return nil, observed, navigationReportError(report, err) + } + support, err := commandFormSupport(data, form) + if err != nil { + return nil, observed, navigationReportError(report, err) + } + if support != entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_SUPPORTED { + unknown = unknown || support == entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_UNKNOWN + continue + } + long, positionSupported = candidate, true + break + } + + // The effective range is the intersection of the observed READ POSITION and supported LOCATE. + for _, opcode := range []byte{0x92, 0x2b} { + if !positionSupported { + break + } + bits := uint32(32) + object := uint64(math.MaxUint32) + if long && opcode == 0x92 { + bits = 64 + object = math.MaxUint64 + } + form, _ := locateCDB(opcode, 0, object) + data, err := reportCommand(ctx, transport, opcode, nil) + if err != nil { + return nil, observed, navigationReportError(report, err) + } + support, err := commandFormSupport(data, form) + if err != nil { + return nil, observed, navigationReportError(report, err) + } + if support != entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_SUPPORTED { + unknown = unknown || support == entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_UNKNOWN + continue + } + + // A different selected form needs its own passive observation; a failed same-form query is not retried. + if long != observedLong { + observed, positionErr = (Records{Transport: transport}).Position(ctx, long) + } + limits := &entity.TapeNavigationLimits{AddressBits: proto.Uint32(bits)} + if long { + limits.ReadPositionLong = proto.Bool(true) + } else { + limits.ReadPositionShort = proto.Bool(true) + } + for _, kind := range []entity.DeviceFeatureKind{entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_READ_POSITION, entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_LOCATE} { + f := feature(report, kind) + f.Support, f.Evidence = entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_SUPPORTED, entity.DeviceFeatureEvidence_DEVICE_FEATURE_EVIDENCE_REPORTED + f.Validation = entity.DeviceFeatureValidation_DEVICE_FEATURE_VALIDATION_UNVALIDATED + f.Limits = &entity.DeviceFeature_Navigation{Navigation: proto.Clone(limits).(*entity.TapeNavigationLimits)} + f.Availability, f.Reason = entity.DeviceFeatureAvailability_DEVICE_FEATURE_AVAILABILITY_UNKNOWN, entity.DeviceReason_DEVICE_REASON_NOT_READY + f.Detail = "Standard SSC logical-object pair reported; current logical position is unknown; physical reload qualification remains unvalidated" + if positionErr != nil { + f.Availability, f.Reason, f.Detail = entity.DeviceFeatureAvailability_DEVICE_FEATURE_AVAILABILITY_UNAVAILABLE, Reason(positionErr), positionErr.Error() + } else if observed.ObjectKnown && (observed.Partition != 0 || observed.Object > object) { + f.Availability = entity.DeviceFeatureAvailability_DEVICE_FEATURE_AVAILABILITY_UNAVAILABLE + f.Detail = "Observed position is outside the partition-zero navigation range; physical reload qualification remains unvalidated" + } else if observed.ObjectKnown && observed.Partition == 0 && observed.Object <= object { + f.Availability, f.Reason = entity.DeviceFeatureAvailability_DEVICE_FEATURE_AVAILABILITY_READY, 0 + f.Detail = "Standard SSC logical-object pair and exact current position observed; physical reload qualification remains unvalidated" + } + } + return &Navigation{records: Records{Transport: transport, Timeout: 10 * time.Minute}, bits: bits, long: long, opcode: opcode}, observed, positionErr + } + + // No complete pair means no motion capability; vendor/unknown support never becomes unsupported hardware. + f := feature(report, entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_LOCATE) + f.Evidence = entity.DeviceFeatureEvidence_DEVICE_FEATURE_EVIDENCE_REPORTED + f.Support, f.Availability, f.Reason = entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_UNSUPPORTED, entity.DeviceFeatureAvailability_DEVICE_FEATURE_AVAILABILITY_UNAVAILABLE, entity.DeviceReason_DEVICE_REASON_UNSUPPORTED + f.Detail = "No standard READ POSITION/LOCATE pair is reported" + if unknown { + f.Support, f.Availability, f.Reason = entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_UNKNOWN, entity.DeviceFeatureAvailability_DEVICE_FEATURE_AVAILABILITY_UNKNOWN, entity.DeviceReason_DEVICE_REASON_NOT_READY + f.Detail = "READ POSITION/LOCATE pair support is not established" + } + return nil, observed, nil +} + +func navigationReportError(report *entity.InspectDeviceResponse, err error) error { + // Unsupported optional reporting is unknown operation support, not a hardware capability conclusion. + f := feature(report, entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_LOCATE) + f.Availability, f.Reason, f.Detail = entity.DeviceFeatureAvailability_DEVICE_FEATURE_AVAILABILITY_UNKNOWN, Reason(err), err.Error() + if errors.Is(err, ErrUncertain) { + f.Availability = entity.DeviceFeatureAvailability_DEVICE_FEATURE_AVAILABILITY_UNAVAILABLE + } + return err +} diff --git a/internal/media/tape/navigation_eligibility_test.go b/internal/media/tape/navigation_eligibility_test.go new file mode 100644 index 0000000..39fdd8d --- /dev/null +++ b/internal/media/tape/navigation_eligibility_test.go @@ -0,0 +1,122 @@ +package tape + +import ( + "context" + "errors" + "testing" + + "github.com/samuelncui/yatm/entity" +) + +func TestNavigationRequiresPreparedEnvelopeAndApplicableEvidence(t *testing.T) { + for _, tc := range []struct { + name string + kind entity.DeviceFeatureKind + validation entity.DeviceFeatureValidation + support entity.DeviceFeatureSupport + reason entity.DeviceReason + eligible bool + }{ + { + name: "unvalidated", + kind: entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_LOCATE, + validation: entity.DeviceFeatureValidation_DEVICE_FEATURE_VALIDATION_UNVALIDATED, + support: entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_SUPPORTED, + reason: 0, + eligible: true, + }, + { + name: "validated", + kind: entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_LOCATE, + validation: entity.DeviceFeatureValidation_DEVICE_FEATURE_VALIDATION_VALIDATED, + support: entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_SUPPORTED, + reason: 0, + eligible: true, + }, + { + name: "unknown current position", + kind: entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_READ_POSITION, + validation: entity.DeviceFeatureValidation_DEVICE_FEATURE_VALIDATION_UNVALIDATED, + support: entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_SUPPORTED, + reason: entity.DeviceReason_DEVICE_REASON_NOT_READY, + eligible: true, + }, + { + name: "failed position", + kind: entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_READ_POSITION, + validation: entity.DeviceFeatureValidation_DEVICE_FEATURE_VALIDATION_FAILED, + support: entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_SUPPORTED, + reason: 0, + eligible: false, + }, + { + name: "failed locate", + kind: entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_LOCATE, + validation: entity.DeviceFeatureValidation_DEVICE_FEATURE_VALIDATION_FAILED, + support: entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_SUPPORTED, + reason: 0, + eligible: false, + }, + { + name: "unknown support", + kind: entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_LOCATE, + validation: entity.DeviceFeatureValidation_DEVICE_FEATURE_VALIDATION_UNVALIDATED, + support: entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_UNKNOWN, + reason: 0, + eligible: false, + }, + { + name: "unsupported", + kind: entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_READ_POSITION, + validation: entity.DeviceFeatureValidation_DEVICE_FEATURE_VALIDATION_UNVALIDATED, + support: entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_UNSUPPORTED, + reason: entity.DeviceReason_DEVICE_REASON_UNSUPPORTED, + eligible: false, + }, + { + name: "rejected position", + kind: entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_READ_POSITION, + validation: entity.DeviceFeatureValidation_DEVICE_FEATURE_VALIDATION_UNVALIDATED, + support: entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_SUPPORTED, + reason: entity.DeviceReason_DEVICE_REASON_UNSUPPORTED, + eligible: false, + }, + { + name: "malformed position", + kind: entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_READ_POSITION, + validation: entity.DeviceFeatureValidation_DEVICE_FEATURE_VALIDATION_UNVALIDATED, + support: entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_SUPPORTED, + reason: entity.DeviceReason_DEVICE_REASON_MALFORMED_RESPONSE, + eligible: false, + }, + } { + t.Run(tc.name, func(t *testing.T) { + // Passive command reports alone cannot bypass the configured standard record/partition envelope. + fixture := newDeviceFixture() + device := fixtureDevice(t, fixture) + defer device.transport.Close() + if navigator, err := device.Navigation(context.Background()); err != nil || navigator != nil { + t.Fatalf("unprepared navigator=%v err=%v", navigator, err) + } + if _, err := device.Configure(context.Background(), &entity.TarMediaProfile{ + RecordMode: entity.TapeRecordMode_TAPE_RECORD_MODE_VARIABLE, RecordBytes: 512, + }, Read); err != nil { + t.Fatal(err) + } + + // Current position readiness is refreshed by the consumer; failed or contradictory support is not. + f := feature(device.report, tc.kind) + f.Validation, f.Support, f.Reason = tc.validation, tc.support, tc.reason + f.Availability = entity.DeviceFeatureAvailability_DEVICE_FEATURE_AVAILABILITY_UNKNOWN + navigator, err := device.Navigation(context.Background()) + if err != nil || (navigator != nil) != tc.eligible || fixture.motions != 0 { + t.Fatalf("navigator=%v err=%v motions=%d", navigator, err, fixture.motions) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := device.Navigation(ctx); !errors.Is(err, context.Canceled) { + t.Fatalf("cancellation lost: %v", err) + } + }) + } +} diff --git a/internal/media/tape/navigation_test.go b/internal/media/tape/navigation_test.go new file mode 100644 index 0000000..d98a29a --- /dev/null +++ b/internal/media/tape/navigation_test.go @@ -0,0 +1,491 @@ +package tape + +import ( + "context" + "encoding/binary" + "errors" + "math" + "testing" + + "github.com/samuelncui/yatm/entity" +) + +// navigationFixtureDevice isolates command-form tests from the separately tested Configure envelope. +func navigationFixtureDevice(transport Transport) (*Device, error) { + device, err := NewDevice(context.Background(), Identity{Configured: "fixture"}, transport) + if device != nil { + device.operation = Read + } + return device, err +} + +func TestNavigationSelectsReportedPairBeforeMotion(t *testing.T) { + for _, short := range []bool{false, true} { + // Standard form usage proves the complete address field, while partition zero needs no mask bits. + var calls, motions int + var located []byte + transport := newSerializedTransport(func(command Command) (Response, error) { + calls++ + if command.CDB[0] == 0x92 || command.CDB[0] == 0x2b { + motions++ + located = append([]byte(nil), command.CDB...) + return Response{Completed: true}, nil + } + if !passiveCommand(command) { + t.Fatalf("active inspection command: %x", command.CDB) + } + if short && command.CDB[0] == 0xa3 && command.CDB[3] == 0x92 { + command.Data[1] = 1 + return Response{Completed: true, Residual: int32(len(command.Data) - 2)}, nil + } + response, err := inspectionFixture(command) + if command.CDB[0] == 0xa3 && command.CDB[3] == 0x92 { + command.Data[7] = 0 // LOCATE(16) partition field is not a required nonzero value. + } + if command.CDB[0] == 0xa3 && command.CDB[3] == 0x2b { + command.Data[12] = 0 // LOCATE(10) likewise needs only partition zero. + } + return response, err + }, func() error { return nil }) + defer transport.Close() + device, err := navigationFixtureDevice(transport) + if err != nil || motions != 0 { + t.Fatalf("inspection=%v motions=%d", err, motions) + } + + // Returning the selected navigator reuses observations instead of issuing a second probe. + before := calls + navigator, err := device.Navigation(context.Background()) + bits, object, opcode := uint32(64), uint64(math.MaxUint64), byte(0x92) + if short { + bits, object, opcode = 32, math.MaxUint32, 0x2b + } + if err != nil || navigator == nil || navigator.AddressBits() != bits || calls != before { + t.Fatalf("navigator=%v err=%v calls=%d/%d", navigator, err, calls, before) + } + for _, kind := range []entity.DeviceFeatureKind{entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_READ_POSITION, entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_LOCATE} { + f := feature(device.Report(), kind) + if f.Support != entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_SUPPORTED || f.Validation != entity.DeviceFeatureValidation_DEVICE_FEATURE_VALIDATION_UNVALIDATED || + f.Availability != entity.DeviceFeatureAvailability_DEVICE_FEATURE_AVAILABILITY_READY || f.GetNavigation().GetAddressBits() != bits { + t.Fatalf("physical qualification invented or pair missing: %v", f) + } + } + + // Width/partition failures issue no command; the chosen complete-width form is sent exactly once. + if short { + if err := navigator.Locate(context.Background(), 0, math.MaxUint32+1); err == nil || calls != before { + t.Fatal("32-bit overflow was issued or narrowed") + } + } + for _, partition := range []uint32{1, 256, math.MaxUint32} { + if err := navigator.Locate(context.Background(), partition, 1); !errors.Is(err, ErrUnsupported) || calls != before { + t.Fatalf("partition=%d err=%v calls=%d", partition, err, calls) + } + } + if err := navigator.Locate(context.Background(), 0, object); err != nil || motions != 1 || located[0] != opcode || located[1] != 2 { + t.Fatalf("locate=%x err=%v motions=%d", located, err, motions) + } + if short && binary.BigEndian.Uint32(located[3:7]) != math.MaxUint32 || !short && binary.BigEndian.Uint64(located[4:12]) != math.MaxUint64 { + t.Fatalf("address narrowed: %x", located) + } + } +} + +func TestNavigationReportingKeepsUnsupportedUnknownAndMalformedDistinct(t *testing.T) { + for _, tc := range []struct { + name string + code byte + reject bool + support entity.DeviceFeatureSupport + reason entity.DeviceReason + }{ + {"unsupported forms", 1, false, entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_UNSUPPORTED, entity.DeviceReason_DEVICE_REASON_UNSUPPORTED}, + {"unknown report", 0, false, entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_UNKNOWN, entity.DeviceReason_DEVICE_REASON_NOT_READY}, + {"vendor support", 5, false, entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_UNKNOWN, entity.DeviceReason_DEVICE_REASON_NOT_READY}, + {"reserved report", 2, false, entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_UNKNOWN, entity.DeviceReason_DEVICE_REASON_MALFORMED_RESPONSE}, + {"reporting unsupported", 0, true, entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_UNKNOWN, entity.DeviceReason_DEVICE_REASON_UNSUPPORTED}, + } { + t.Run(tc.name, func(t *testing.T) { + // Optional reporting rejection never claims that the drive lacks the underlying operation. + transport := newSerializedTransport(func(command Command) (Response, error) { + if command.CDB[0] != 0xa3 { + return inspectionFixture(command) + } + if tc.reject { + return Response{Completed: true, Status: 2, Sense: fixedSense(0x70, 5, 0x20, 0, false, 0, 0)}, nil + } + command.Data[1] = tc.code + return Response{Completed: true, Residual: int32(len(command.Data) - 2)}, nil + }, func() error { return nil }) + defer transport.Close() + device, err := navigationFixtureDevice(transport) + if err != nil { + t.Fatal(err) + } + f := feature(device.Report(), entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_LOCATE) + if f.Support != tc.support || f.Reason != tc.reason || f.GetNavigation() != nil || f.Validation != entity.DeviceFeatureValidation_DEVICE_FEATURE_VALIDATION_UNVALIDATED { + t.Fatalf("report=%v", f) + } + navigator, err := device.Navigation(context.Background()) + if navigator != nil || err != nil { + t.Fatalf("navigator=%v err=%v", navigator, err) + } + }) + } +} + +func TestNavigationDoesNotRetryActuallyRejectedLongPosition(t *testing.T) { + // A usage report cannot erase an observed clean long-form rejection or cause another long query. + var longQueries, shortQueries, longReports int + transport := newSerializedTransport(func(command Command) (Response, error) { + if command.CDB[0] == 0x34 { + if command.CDB[1] == 6 { + longQueries++ + return Response{Completed: true, Status: 2, Sense: fixedSense(0x70, 5, 0x24, 0, false, 0, 0)}, nil + } + shortQueries++ + } + if command.CDB[0] == 0xa3 && command.CDB[3] == 0x34 && command.CDB[5] == 6 { + longReports++ + } + return inspectionFixture(command) + }, func() error { return nil }) + defer transport.Close() + device, err := navigationFixtureDevice(transport) + if err != nil { + t.Fatal(err) + } + navigator, err := device.Navigation(context.Background()) + if err != nil || navigator == nil || navigator.AddressBits() != 32 || longQueries != 1 || shortQueries != 1 || longReports != 0 { + t.Fatalf("navigator=%v err=%v long=%d short=%d reports=%d", navigator, err, longQueries, shortQueries, longReports) + } +} + +func TestNavigationFormsHaveAnIndependentEffectiveAddressWidth(t *testing.T) { + for _, long := range []bool{false, true} { + for _, opcode := range []byte{0x2b, 0x92} { + // Both SSC observation forms identify the same implicit logical object used by both LOCATE forms. + var positionAction, locatedOpcode byte + transport := newSerializedTransport(func(command Command) (Response, error) { + if command.CDB[0] == 0x34 { + positionAction = command.CDB[1] + if !long && command.CDB[1] == 6 { + return Response{Completed: true, Status: 2, Sense: fixedSense(0x70, 5, 0x24, 0, false, 0, 0)}, nil + } + } + if command.CDB[0] == 0xa3 && (command.CDB[3] == 0x92 || command.CDB[3] == 0x2b) && command.CDB[3] != opcode { + command.Data[1] = 1 + return Response{Completed: true, Residual: int32(len(command.Data) - 2)}, nil + } + if command.CDB[0] == 0x92 || command.CDB[0] == 0x2b { + locatedOpcode = command.CDB[0] + return Response{Completed: true}, nil + } + return inspectionFixture(command) + }, func() error { return nil }) + defer transport.Close() + device, err := navigationFixtureDevice(transport) + if err != nil { + t.Fatal(err) + } + navigator, err := device.Navigation(context.Background()) + if err != nil || navigator == nil { + t.Fatalf("long=%t opcode=%02x navigator=%v err=%v", long, opcode, navigator, err) + } + + // Effective width is the intersection, while the selected observation decoder stays unchanged. + bits, action := uint32(32), byte(0) + if long { + action = 6 + if opcode == 0x92 { + bits = 64 + } + } + if navigator.AddressBits() != bits { + t.Fatalf("long=%t opcode=%02x width=%d", long, opcode, navigator.AddressBits()) + } + if _, err := navigator.Position(context.Background()); err != nil || positionAction != action { + t.Fatalf("position form changed: action=%d err=%v", positionAction, err) + } + if err := navigator.Locate(context.Background(), 0, math.MaxUint32); err != nil || locatedOpcode != opcode { + t.Fatalf("motion form changed: opcode=%02x err=%v", locatedOpcode, err) + } + if bits == 32 && navigator.Locate(context.Background(), 0, math.MaxUint32+1) == nil { + t.Fatal("effective observation/motion range exceeded") + } + f := feature(device.Report(), entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_READ_POSITION) + if f.GetNavigation().GetReadPositionLong() != long || f.GetNavigation().GetAddressBits() != bits { + t.Fatalf("effective range or native observation form misreported: %v", f) + } + } + } +} + +func TestReadPositionReportRequiresTheLiteralServiceAction(t *testing.T) { + for _, action := range []byte{0, 6} { + // SPC reports the literal action at its CDB position; it is not a field support mask. + form := []byte{0x34, action, 0, 0, 0, 0, 0, 0, 0, 0} + data := make([]byte, 14) + data[1], data[3], data[4], data[5] = 3, 10, 0x34, action + if support, err := commandFormSupport(data, form); err != nil || support != entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_SUPPORTED { + t.Fatalf("literal action=%d support=%v err=%v", action, support, err) + } + for _, invalid := range []byte{0, 6, 8, 0xff} { + if invalid == action { + continue + } + data[5] = invalid + if _, err := commandFormSupport(data, form); !errors.Is(err, ErrMalformed) { + t.Fatalf("requested=%d descriptor=%d accepted: %v", action, invalid, err) + } + } + } +} + +func TestNavigationReportsKnownPositionOutsideTheEffectiveRangeUnavailable(t *testing.T) { + // A long observation may be exact but outside a supported LOCATE(10)'s range; never call it READY. + transport := newSerializedTransport(func(command Command) (Response, error) { + if command.CDB[0] == 0xa3 && command.CDB[3] == 0x92 { + command.Data[1] = 1 + return Response{Completed: true, Residual: int32(len(command.Data) - 2)}, nil + } + response, err := inspectionFixture(command) + if command.CDB[0] == 0x34 { + binary.BigEndian.PutUint64(command.Data[8:16], math.MaxUint32+1) + } + return response, err + }, func() error { return nil }) + defer transport.Close() + device, err := navigationFixtureDevice(transport) + if err != nil { + t.Fatal(err) + } + for _, kind := range []entity.DeviceFeatureKind{entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_READ_POSITION, entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_LOCATE} { + f := feature(device.Report(), kind) + if f.Support != entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_SUPPORTED || f.Availability != entity.DeviceFeatureAvailability_DEVICE_FEATURE_AVAILABILITY_UNAVAILABLE || + f.GetNavigation().GetAddressBits() != 32 || !f.GetNavigation().GetReadPositionLong() { + t.Fatalf("exact observation outside shared range=%v", f) + } + } +} + +func TestNavigationPositionUnknownDoesNotBecomeAnExactAddress(t *testing.T) { + // LONU makes both object and partition undefined even when the file ordinal remains exact. + transport := newSerializedTransport(func(command Command) (Response, error) { + response, err := inspectionFixture(command) + if command.CDB[0] == 0x34 { + command.Data[0] = 4 + binary.BigEndian.PutUint32(command.Data[4:8], math.MaxUint32) + } + return response, err + }, func() error { return nil }) + defer transport.Close() + device, err := navigationFixtureDevice(transport) + if err != nil { + t.Fatal(err) + } + navigator, err := device.Navigation(context.Background()) + if err != nil || navigator == nil { + t.Fatal(err) + } + position, err := navigator.Position(context.Background()) + if err != nil || position.ObjectKnown || !position.FileKnown || position.File != 4 { + t.Fatalf("position=%+v err=%v", position, err) + } + for _, kind := range []entity.DeviceFeatureKind{entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_READ_POSITION, entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_LOCATE} { + if feature(device.Report(), kind).Availability != entity.DeviceFeatureAvailability_DEVICE_FEATURE_AVAILABILITY_UNKNOWN { + t.Fatal("unknown logical address reported ready") + } + } +} + +func TestNavigationIssuedFailureNeverTriesAnotherForm(t *testing.T) { + for _, uncertain := range []bool{false, true} { + // A failure on the already selected long form cannot trigger a short-form retry. + var motions, queries int + transport := newSerializedTransport(func(command Command) (Response, error) { + if command.CDB[0] == 0x92 || command.CDB[0] == 0x2b { + motions++ + if command.CDB[0] != 0x92 { + t.Fatal("alternate form issued") + } + if uncertain { + return Response{Completed: true, HostStatus: 3}, nil + } + return Response{Completed: true, Status: 2, Sense: fixedSense(0x70, 5, 0x24, 0, false, 0, 0)}, nil + } + queries++ + return inspectionFixture(command) + }, func() error { return nil }) + defer transport.Close() + device, err := navigationFixtureDevice(transport) + if err != nil { + t.Fatal(err) + } + navigator, err := device.Navigation(context.Background()) + if err != nil { + t.Fatal(err) + } + + // Pre-command cancellation leaves the device untouched; an issued failure preserves its cause. + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if err := navigator.Locate(ctx, 0, 1); !errors.Is(err, context.Canceled) || motions != 0 { + t.Fatalf("canceled locate=%v motions=%d", err, motions) + } + err = navigator.Locate(context.Background(), 0, 1) + if err == nil || errors.Is(err, ErrUncertain) != uncertain || motions != 1 { + t.Fatalf("locate=%v motions=%d", err, motions) + } + if !uncertain && Reason(err) != entity.DeviceReason_DEVICE_REASON_UNSUPPORTED { + t.Fatalf("rejection cause lost: %v", err) + } + if uncertain { + before := queries + if _, err := navigator.Position(context.Background()); !errors.Is(err, ErrUncertain) || queries != before { + t.Fatal("uncertain transport admitted another command") + } + } + } +} + +func TestPositionContradictionDiffersFromGoodUnknown(t *testing.T) { + for _, tc := range []struct { + flags byte + obj uint64 + file uint64 + bad bool + }{{0x80, 1, 0, true}, {0x80, 0, 1, true}, {0, 2, 3, true}, {4, 2, 3, false}, {8, 2, 3, false}, {0x80, 0, 0, false}} { + // Only counters whose validity bits permit interpretation participate in consistency checks. + data := make([]byte, 32) + data[0] = tc.flags + binary.BigEndian.PutUint64(data[8:16], tc.obj) + binary.BigEndian.PutUint64(data[16:24], tc.file) + _, err := decodePosition(data, true) + if errors.Is(err, ErrMalformed) != tc.bad { + t.Fatalf("flags=%02x object=%d file=%d err=%v", tc.flags, tc.obj, tc.file, err) + } + } +} + +func TestPassiveNavigationReportingIsServiceActionBounded(t *testing.T) { + for _, action := range []byte{0, 6} { + // Only the two consumed READ POSITION service actions may use reporting option two. + cdb := []byte{0xa3, 0x0c, 2, 0x34, 0, action, 0, 0, 0, 64, 0, 0} + if !passiveCommand(Command{CDB: cdb, Direction: FromDevice, Data: make([]byte, 64)}) { + t.Fatalf("required report rejected: %x", cdb) + } + for _, change := range []struct{ at, value int }{{2, 3}, {3, 0x92}, {4, 1}, {5, 8}, {10, 1}, {11, 1}} { + altered := append([]byte(nil), cdb...) + altered[change.at] = byte(change.value) + if passiveCommand(Command{CDB: altered, Direction: FromDevice, Data: make([]byte, 64)}) { + t.Fatalf("unused/reserved reporting form admitted: %x", altered) + } + } + } +} + +func TestNavigationFormRequiresTheWholeAddressAndExactCDBLength(t *testing.T) { + for _, short := range []bool{false, true} { + // An ignored object field cannot authorize navigation, and partial field masks are malformed. + opcode, object := byte(0x92), uint64(math.MaxUint64) + if short { + opcode, object = 0x2b, math.MaxUint32 + } + form, err := locateCDB(opcode, 0, object) + if err != nil { + t.Fatal(err) + } + data := make([]byte, 4+len(form)) + data[1], data[3], data[4] = 3, byte(len(form)), opcode + for i := 5; i < len(data); i++ { + data[i] = 0xff + } + if support, err := commandFormSupport(data, form); err != nil || support != entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_SUPPORTED { + t.Fatalf("complete form support=%v err=%v", support, err) + } + + // A partial address field is not a smaller supported range; truncation also invalidates its evidence. + data[8] &^= 0x80 + if short { + data[7] &^= 0x80 + } + if _, err := commandFormSupport(data, form); !errors.Is(err, ErrMalformed) { + t.Fatalf("partial address field=%v", err) + } + start, end := 8, 16 + if short { + start, end = 7, 11 + } + clear(data[start:end]) + if support, err := commandFormSupport(data, form); err != nil || support != entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_UNSUPPORTED { + t.Fatalf("ignored address support=%v err=%v", support, err) + } + if _, err := commandFormSupport(data[:len(data)-1], form); !errors.Is(err, ErrMalformed) { + t.Fatalf("truncated usage descriptor=%v", err) + } + } +} + +func TestNavigationPositionPreservesCommandAndSemanticFailures(t *testing.T) { + for _, tc := range []struct { + name string + sense []byte + truncated bool + badBOP bool + uncertain bool + reason entity.DeviceReason + }{ + {"clean unsupported", fixedSense(0x70, 5, 0x24, 0, false, 0, 0), false, false, false, entity.DeviceReason_DEVICE_REASON_UNSUPPORTED}, + {"reset", fixedSense(0x70, 6, 0x29, 0, false, 0, 0), false, false, true, entity.DeviceReason_DEVICE_REASON_OWNERSHIP_UNCERTAIN}, + {"deferred", fixedSense(0x71, 5, 0x24, 0, false, 0, 0), false, false, true, entity.DeviceReason_DEVICE_REASON_OWNERSHIP_UNCERTAIN}, + {"truncated GOOD", nil, true, false, false, entity.DeviceReason_DEVICE_REASON_MALFORMED_RESPONSE}, + {"contradictory GOOD", nil, false, true, false, entity.DeviceReason_DEVICE_REASON_MALFORMED_RESPONSE}, + } { + t.Run(tc.name, func(t *testing.T) { + // Discovery succeeds first; the later query must preserve the real failure without form fallback. + failed, queries := false, 0 + transport := newSerializedTransport(func(command Command) (Response, error) { + if failed && command.CDB[0] == 0x34 { + queries++ + if command.CDB[1] != 6 { + t.Fatal("position failure triggered another form") + } + if tc.sense != nil { + return Response{Completed: true, Status: 2, Sense: tc.sense}, nil + } + response, err := inspectionFixture(command) + if tc.truncated { + response.Residual = 1 + } + if tc.badBOP { + command.Data[0] = 0x80 + } + return response, err + } + return inspectionFixture(command) + }, func() error { return nil }) + defer transport.Close() + device, err := navigationFixtureDevice(transport) + if err != nil { + t.Fatal(err) + } + navigator, err := device.Navigation(context.Background()) + if err != nil { + t.Fatal(err) + } + + // Reset/deferred uncertainty poisons admission; a clean typed rejection remains distinguishable. + failed = true + _, err = navigator.Position(context.Background()) + if err == nil || errors.Is(err, ErrUncertain) != tc.uncertain || Reason(err) != tc.reason || queries != 1 { + t.Fatalf("position=%v queries=%d", err, queries) + } + if tc.uncertain { + if _, err := navigator.Position(context.Background()); !errors.Is(err, ErrUncertain) || queries != 1 { + t.Fatal("uncertain position admitted another command") + } + } + }) + } +} diff --git a/internal/media/tape/observations.go b/internal/media/tape/observations.go new file mode 100644 index 0000000..297b88d --- /dev/null +++ b/internal/media/tape/observations.go @@ -0,0 +1,176 @@ +package tape + +import ( + "context" + "encoding/binary" + "errors" + "sync" + "sync/atomic" + "time" +) + +// CommandObservation contains completed command facts only, never payload bytes or predictions. +// Sequence gaps mean observations were dropped; a consumer must withdraw its inferred cursor. +type CommandObservation struct { + Sequence uint64 + Opcode byte + StartedAt time.Time + Elapsed time.Duration + Known bool + Bytes int + Filemark, EOD bool + Discard bool + Position Position +} + +type readDiscardContextKey struct{} + +// WithReadDiscard labels an actual READ requested while skipping unselected content. A physical +// record can also contain useful bytes; the label describes its trigger, not an exact waste count. +func WithReadDiscard(ctx context.Context) context.Context { + return context.WithValue(ctx, readDiscardContextKey{}, true) +} + +// CommandObservations is one bounded, lossy subscription to a prepared transport. Recording +// never waits for its consumer; Stop waits only for an already-issued native command to settle. +type CommandObservations struct { + values chan CommandObservation + dropped atomic.Uint64 + sequence uint64 + mode RecordMode + transport *serializedTransport + once sync.Once +} + +func (o *CommandObservations) Values() <-chan CommandObservation { return o.values } +func (o *CommandObservations) Dropped() uint64 { return o.dropped.Load() } + +func (o *CommandObservations) Stop() { + o.once.Do(func() { + // Taking the existing lane joins the final emitter before closing its channel. + <-o.transport.lane + o.transport.observations.CompareAndSwap(o, nil) + close(o.values) + o.transport.lane <- struct{}{} + }) +} + +// ObserveCommands attaches after Configure and sends no device command. An opaque transport +// without native timing leaves learning unavailable without changing ordinary I/O. +func (d *Device) ObserveCommands() *CommandObservations { + if d.operation == 0 { + return nil + } + provider, ok := d.transport.(interface { + observeCommands(RecordMode) *CommandObservations + }) + if !ok { + return nil + } + return provider.observeCommands(d.records.Mode) +} + +func (t *serializedTransport) observeCommands(mode RecordMode) *CommandObservations { + // Exactly one prepared-session consumer shares this native owner's completed-command facts. + o := &CommandObservations{values: make(chan CommandObservation, 256), mode: mode, transport: t} + if !t.observations.CompareAndSwap(nil, o) { + return nil + } + return o +} + +func (o *CommandObservations) record(command Command, response Response, commandErr error, started time.Time, elapsed time.Duration) { + // Passive queries other than READ POSITION do not change a sequential cursor or train timings. + opcode := command.CDB[0] + if opcode != 0x34 && passiveCommand(command) { + return + } + o.sequence++ + value := CommandObservation{Sequence: o.sequence, Opcode: opcode, StartedAt: started, Elapsed: elapsed, Discard: command.readDiscard} + if response.Completed && commandErr == nil { + o.decode(&value, command, response) + } + select { + case o.values <- value: + default: + o.dropped.Add(1) + } +} + +func (o *CommandObservations) decode(value *CommandObservation, command Command, response Response) { + // Use the same record and position decoders as the physical caller, including short records and marks. + switch value.Opcode { + case 0x08, 0x0a: + writing := value.Opcode == 0x0a + result, err := decodeRecord(response, len(command.Data), o.mode, writing) + value.Known = result.AcceptedKnown && (err == nil || !writing && (result.Filemark || result.EOD) || + writing && result.EarlyWarning && WriteWarning(err, int64(len(command.Data)-result.Bytes), o.mode)) + value.Bytes, value.Filemark, value.EOD = result.Bytes, result.Filemark, result.EOD + case 0x34: + if CheckResponse(response) != nil || response.Residual < 0 || int(response.Residual) > len(command.Data) { + return + } + position, err := decodePosition(command.Data[:len(command.Data)-int(response.Residual)], command.CDB[1] == 6) + value.Known, value.Position = err == nil && position.ObjectKnown, position + case 0x2b, 0x92: + if controlResponse(response) != nil || command.CDB[1] != 2 { + return + } + value.Known, value.Position.ObjectKnown = true, true + if value.Opcode == 0x2b && len(command.CDB) == 10 { + value.Position.Partition = uint32(command.CDB[8]) + value.Position.Object = uint64(binary.BigEndian.Uint32(command.CDB[3:7])) + } else if value.Opcode == 0x92 && len(command.CDB) == 16 { + value.Position.Partition = uint32(command.CDB[3]) + value.Position.Object = binary.BigEndian.Uint64(command.CDB[4:12]) + } else { + value.Known = false + } + case 1: + value.Known = controlResponse(response) == nil && command.CDB[1]&1 == 0 + case 0x1b: + // Only normal synchronous UNLOAD supplies completion timing; LOAD/HOLD/IMMED do not. + value.Known = len(command.CDB) == 6 && command.Direction == NoData && len(command.Data) == 0 && + command.CDB[1] == 0 && command.CDB[2] == 0 && command.CDB[3] == 0 && command.CDB[4] == 0 && + command.CDB[5] == 0 && response.Residual == 0 && controlResponse(response) == nil + case 0x55: + // Successful restoration of owned record controls does not move the physical cursor. + value.Known = response.Residual == 0 && controlResponse(response) == nil && nonmovingRestoration(command) + case 0x10: + // A sync-only mark command changes no cursor; a real mark invalidates the next-object inference. + count := uint32(command.CDB[2])<<16 | uint32(command.CDB[3])<<8 | uint32(command.CDB[4]) + err := filemarkResponse(response, count, o.mode.WarningCounts) + value.Known = count == 0 && (err == nil || errors.Is(err, ErrEarlyWarning)) + } +} + +func nonmovingRestoration(command Command) bool { + // Recognize only the synchronous forms used by normal session record-control restoration. + cdb, data := command.CDB, command.Data + if command.Direction != ToDevice { + return false + } + switch cdb[0] { + case 0x55: + if len(cdb) != 10 || cdb[1] != 0x10 || cdb[2] != 0 || cdb[3] != 0 || cdb[4] != 0 || + cdb[5] != 0 || cdb[6] != 0 || int(binary.BigEndian.Uint16(cdb[7:9])) != len(data) || cdb[9] != 0 || + len(data) < 16 || data[0] != 0 || data[1] != 0 || data[4] != 0 || data[5] != 0 || + binary.BigEndian.Uint16(data[6:8]) != 8 { + return false + } + pages, err := selectionPages(data) + if err != nil { + return false + } + length := 16 + for kind, page := range pages { + if kind != 0x0f && kind != 0x10 { + return false + } + length += len(page) + } + return length == len(data) + + } + return false +} diff --git a/internal/media/tape/observations_test.go b/internal/media/tape/observations_test.go new file mode 100644 index 0000000..4cdcf5b --- /dev/null +++ b/internal/media/tape/observations_test.go @@ -0,0 +1,254 @@ +package tape + +import ( + "context" + "errors" + "sync/atomic" + "testing" + "time" +) + +func TestCommandObservationsAreBoundedAndIssueNoCommands(t *testing.T) { + // An unread telemetry channel cannot hold the device lane or send a measurement operation. + var calls atomic.Int64 + transport := newSerializedTransport(func(Command) (Response, error) { + calls.Add(1) + return Response{Completed: true}, nil + }, func() error { return nil }) + observations := transport.observeCommands(RecordMode{MaximumBytes: 512}) + if calls.Load() != 0 || transport.observeCommands(RecordMode{}) != nil { + t.Fatal("subscription changed I/O or allowed a second owner") + } + records := Records{Transport: transport, Mode: RecordMode{MaximumBytes: 512}, Timeout: time.Second} + for range 300 { + if _, err := records.Read(context.Background(), make([]byte, 512)); err != nil { + t.Fatal(err) + } + } + observations.Stop() + if calls.Load() != 300 || observations.Dropped() != 44 { + t.Fatalf("commands=%d dropped=%d", calls.Load(), observations.Dropped()) + } + var count uint64 + for value := range observations.Values() { + count++ + // An immediate fake syscall may finish within one clock tick; do not invent positive elapsed time. + if value.Sequence != count || !value.Known || value.Bytes != 512 || value.StartedAt.IsZero() || value.Elapsed < 0 { + t.Fatalf("invalid native observation: %+v", value) + } + } + if count != 256 { + t.Fatalf("unbounded/short queue: %d", count) + } + if err := records.Rewind(context.Background()); err != nil { + t.Fatal(err) + } + if calls.Load() != 301 { + t.Fatal("stopping observation prevented ordinary I/O") + } +} + +func TestCommandObservationsExcludeAdmissionWaitAndJoinNativeSettlement(t *testing.T) { + // Hold admission longer than the native operation and detach while a later native call is active. + started, release := make(chan struct{}), make(chan struct{}) + transport := newSerializedTransport(func(Command) (Response, error) { + close(started) + <-release + return Response{Completed: true}, nil + }, func() error { return nil }) + observations := transport.observeCommands(RecordMode{}) + <-transport.lane + completed := make(chan error, 1) + go func() { + _, err := transport.Execute(context.Background(), Command{CDB: []byte{1, 0, 0, 0, 0, 0}, Direction: NoData, Timeout: time.Second}) + completed <- err + }() + time.Sleep(40 * time.Millisecond) + released := time.Now() + transport.lane <- struct{}{} + <-started + stopped := make(chan struct{}) + go func() { observations.Stop(); close(stopped) }() + select { + case <-stopped: + t.Fatal("stopped before native owner settled") + default: + } + close(release) + if err := <-completed; err != nil { + t.Fatal(err) + } + <-stopped + value := <-observations.Values() + if value.StartedAt.Before(released) || value.Elapsed >= time.Since(released) { + t.Fatalf("timing includes command admission wait: %+v", value) + } +} + +func TestCommandObservationLogicalFacts(t *testing.T) { + // Reading a short record and a filemark must retain actual counts; an uncertain command never trains. + mode := RecordMode{MaximumBytes: 512} + observations := &CommandObservations{mode: mode} + read := Command{CDB: []byte{8, 0, 0, 2, 0, 0}, Data: make([]byte, 512)} + for _, test := range []struct { + name string + command Command + response Response + known bool + bytes int + }{ + {name: "full", command: read, response: Response{Completed: true}, known: true, bytes: 512}, + {name: "unexplained residual", command: read, response: Response{Completed: true, Residual: 1}}, + {name: "locate", command: Command{CDB: []byte{0x2b, 2, 0, 0, 0, 1, 3, 0, 0, 0}}, response: Response{Completed: true}, known: true}, + {name: "unqualified locate form", command: Command{CDB: []byte{0x2b, 1, 0, 0, 0, 1, 3, 0, 0, 0}}, response: Response{Completed: true}}, + } { + t.Run(test.name, func(t *testing.T) { + value := CommandObservation{Opcode: test.command.CDB[0]} + observations.decode(&value, test.command, test.response) + if value.Known != test.known || value.Bytes != test.bytes { + t.Fatalf("observation=%+v", value) + } + if test.name == "locate" && value.Position.Object != 259 { + t.Fatalf("LOCATE target=%+v", value.Position) + } + }) + } + + // Failed commands keep a sequence entry so the consumer cannot bridge over an unknown movement. + observations.values = make(chan CommandObservation, 1) + observations.record(read, Response{Completed: true}, errors.New("native failure"), time.Now(), time.Second) + if value := <-observations.Values(); value.Known || value.Sequence != 1 { + t.Fatalf("failed command became a learning sample: %+v", value) + } +} + +func TestCommandObservationDiscardRetainsNativeReadFacts(t *testing.T) { + transport := newSerializedTransport(func(Command) (Response, error) { + return Response{Completed: true}, nil + }, func() error { return nil }) + mode := RecordMode{MaximumBytes: 512} + observations := transport.observeCommands(mode) + records := Records{Transport: transport, Mode: mode, Timeout: time.Second} + buffer := make([]byte, 512) + for _, ctx := range []context.Context{context.Background(), WithReadDiscard(context.Background())} { + if _, err := records.Read(ctx, buffer); err != nil { + t.Fatal(err) + } + } + observations.Stop() + first, second := <-observations.Values(), <-observations.Values() + if !first.Known || !second.Known || first.Bytes != 512 || second.Bytes != 512 || first.Discard || !second.Discard { + t.Fatalf("discard labeling changed native read facts: first=%+v second=%+v", first, second) + } +} + +func BenchmarkCommandObservations(b *testing.B) { + // Reuse one physical record buffer. The full queue is the production overload case: it must + // add bounded bookkeeping without allocation or waiting for a telemetry consumer. + for _, enabled := range []bool{false, true} { + name := "disabled" + if enabled { + name = "full_queue" + } + b.Run(name, func(b *testing.B) { + transport := newSerializedTransport(func(Command) (Response, error) { + return Response{Completed: true}, nil + }, func() error { return nil }) + mode := RecordMode{MaximumBytes: 512 * 1024} + var observations *CommandObservations + if enabled { + observations = transport.observeCommands(mode) + defer observations.Stop() + } + records := Records{Transport: transport, Mode: mode, Timeout: time.Second} + buffer := make([]byte, mode.MaximumBytes) + b.ReportAllocs() + b.ResetTimer() + for b.Loop() { + if _, err := records.Read(context.Background(), buffer); err != nil { + b.Fatal(err) + } + } + }) + } +} + +func TestCommandObservationUnloadAndRestoration(t *testing.T) { + // Only exact synchronous unload and nonmoving owned-control forms preserve usable facts. + mode := sessionMode(make([]byte, 16), nil, nil, 512) + modeCDB := []byte{0x55, 0x10, 0, 0, 0, 0, 0, 0, byte(len(mode)), 0} + keyCDB := []byte{0xb5, 0x20, 0, 0x10, 0, 0, 0, 0, 0, 20, 0, 0} + for _, test := range []struct { + name string + command Command + response Response + err error + known bool + }{ + {"unload", Command{CDB: []byte{0x1b, 0, 0, 0, 0, 0}, Direction: NoData}, Response{Completed: true}, nil, true}, + {"immediate", Command{CDB: []byte{0x1b, 1, 0, 0, 0, 0}, Direction: NoData}, Response{Completed: true}, nil, false}, + {"load", Command{CDB: []byte{0x1b, 0, 0, 0, 1, 0}, Direction: NoData}, Response{Completed: true}, nil, false}, + {"hold", Command{CDB: []byte{0x1b, 0, 0, 0, 8, 0}, Direction: NoData}, Response{Completed: true}, nil, false}, + {"residual", Command{CDB: []byte{0x1b, 0, 0, 0, 0, 0}, Direction: NoData}, Response{Completed: true, Residual: 1}, nil, false}, + {"unsettled", Command{CDB: []byte{0x1b, 0, 0, 0, 0, 0}, Direction: NoData}, Response{}, nil, false}, + {"native failure", Command{CDB: []byte{0x1b, 0, 0, 0, 0, 0}, Direction: NoData}, Response{Completed: true}, errors.New("native failure"), false}, + {"mode restore", Command{CDB: modeCDB, Direction: ToDevice, Data: mode}, Response{Completed: true}, nil, true}, + {"mode residual", Command{CDB: modeCDB, Direction: ToDevice, Data: mode}, Response{Completed: true, Residual: 1}, nil, false}, + {"external key control", Command{CDB: keyCDB, Direction: ToDevice, Data: make([]byte, 20)}, Response{Completed: true}, nil, false}, + {"key residual", Command{CDB: keyCDB, Direction: ToDevice, Data: make([]byte, 20)}, Response{Completed: true, Residual: 1}, nil, false}, + } { + t.Run(test.name, func(t *testing.T) { + // Feed the same completed native response boundary used by the serialized owner. + observations := &CommandObservations{values: make(chan CommandObservation, 1)} + observations.record(test.command, test.response, test.err, time.Now(), 2*time.Second) + value := <-observations.Values() + if value.Known != test.known || value.Opcode != test.command.CDB[0] || value.Elapsed != 2*time.Second { + t.Fatalf("invalid control observation: %+v", value) + } + }) + } + + // Partition changes, malformed parameter lists and key installation are not nonmoving restoration evidence. + partition := append(append([]byte(nil), mode...), []byte{0x11, 6, 0, 0, 0, 0, 0, 0}...) + partitionCDB := append([]byte(nil), modeCDB...) + partitionCDB[8] = byte(len(partition)) + installed := make([]byte, 20) + installed[6] = 2 + for _, command := range []Command{ + {CDB: partitionCDB, Direction: ToDevice, Data: partition}, + {CDB: modeCDB, Direction: ToDevice, Data: mode[:15]}, + {CDB: keyCDB, Direction: ToDevice, Data: installed}, + } { + if nonmovingRestoration(command) { + t.Fatal("unknown control change preserved the cursor") + } + } +} + +func TestCommandObservationNormalUnloadUsesOnlyRequestedCommand(t *testing.T) { + // Passive completion telemetry observes the existing native unload without additional device I/O. + calls := 0 + transport := newSerializedTransport(func(command Command) (Response, error) { + calls++ + if len(command.CDB) != 6 || command.CDB[0] != 0x1b || command.CDB[1] != 0 || command.CDB[4] != 0 { + t.Fatalf("unexpected native command: %x", command.CDB) + } + return Response{Completed: true}, nil + }, func() error { return nil }) + observations := transport.observeCommands(RecordMode{}) + records := Records{Transport: transport, Timeout: time.Second} + if err := records.Unload(context.Background()); err != nil { + t.Fatal(err) + } + observations.Stop() + value := <-observations.Values() + if calls != 1 || value.Opcode != 0x1b || !value.Known || value.StartedAt.IsZero() || value.Elapsed < 0 { + t.Fatalf("invalid native unload observation: calls=%d value=%+v", calls, value) + } + if _, more := <-observations.Values(); more { + t.Fatal("unload telemetry issued an extra command") + } + if err := transport.Close(); err != nil { + t.Fatal(err) + } +} diff --git a/internal/media/tape/ownership.go b/internal/media/tape/ownership.go new file mode 100644 index 0000000..8cf3b06 --- /dev/null +++ b/internal/media/tape/ownership.go @@ -0,0 +1,173 @@ +package tape + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "time" + + "github.com/samuelncui/yatm/entity" +) + +var ( + ErrBusy = errors.New("Tape device has an external holder") + ErrOwnershipUnknown = errors.New("Tape access ownership is unknown") + ErrMissingTool = errors.New("Tape ownership tool is missing") +) + +// CheckOwnership observes known mounts and every resolved alias under the caller's lease. +// Missing visibility never becomes permission to use raw access or a restriction on native LTFS. +func CheckOwnership(ctx context.Context, identity Identity) error { + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + if runtime.GOOS != "linux" { + return ErrUnsupported + } + if identity.Key == "" || len(identity.Aliases) < 2 { + return ErrOwnershipUnknown + } + mounts, err := os.ReadFile("/proc/self/mountinfo") + if err != nil { + return errors.Join(ErrOwnershipUnknown, err) + } + if mountedAlias(string(mounts), identity.Aliases) { + return ErrBusy + } + if err := checkVisibility(ctx, "/proc", string(mounts)); err != nil { + return err + } + + // fuser alone can silently omit another user's unreadable file descriptor table. + return checkHolders(ctx, identity.Aliases, func(ctx context.Context, args []string) ([]byte, []byte, error) { + command := exec.CommandContext(ctx, "fuser", args...) + var stdout, stderr strings.Builder + command.Stdout, command.Stderr = &stdout, &stderr + err := command.Run() + return []byte(stdout.String()), []byte(stderr.String()), err + }) +} + +func checkVisibility(ctx context.Context, root, mounts string) error { + // Hidden process directories cannot establish absence even when visible fd tables are readable. + for _, line := range strings.Split(mounts, "\n") { + fields := strings.Fields(line) + if len(fields) < 6 || fields[4] != root { + continue + } + for _, field := range fields[5:] { + for _, option := range strings.Split(field, ",") { + if strings.HasPrefix(option, "hidepid=") && option != "hidepid=0" && option != "hidepid=off" { + return ErrOwnershipUnknown + } + } + } + } + + // Observe fd-table access in the existing same-host model; exiting processes are not holders. + entries, err := os.ReadDir(root) + if err != nil { + return errors.Join(ErrOwnershipUnknown, err) + } + for _, entry := range entries { + if err := ctx.Err(); err != nil { + return errors.Join(ErrOwnershipUnknown, err) + } + if _, err := strconv.ParseUint(entry.Name(), 10, 32); err != nil { + continue + } + directory := filepath.Join(root, entry.Name(), "fd") + files, err := os.ReadDir(directory) + if errors.Is(err, os.ErrNotExist) { + continue + } + if err != nil { + return errors.Join(ErrOwnershipUnknown, err) + } + for _, file := range files { + if err := ctx.Err(); err != nil { + return errors.Join(ErrOwnershipUnknown, err) + } + if _, err := os.Readlink(filepath.Join(directory, file.Name())); err != nil && !errors.Is(err, os.ErrNotExist) { + return errors.Join(ErrOwnershipUnknown, err) + } + } + } + return nil +} + +func mountedAlias(mounts string, aliases []string) bool { + for _, line := range strings.Split(mounts, "\n") { + fields := strings.Fields(line) + for i, field := range fields { + if field != "-" || i+2 >= len(fields) { + continue + } + source := strings.NewReplacer(`\040`, " ", `\011`, "\t", `\012`, "\n", `\134`, `\`).Replace(fields[i+2]) + for _, alias := range aliases { + if source == alias || source == "ltfs:"+alias { + return true + } + } + break + } + } + return false +} + +func checkHolders(ctx context.Context, aliases []string, run func(context.Context, []string) ([]byte, []byte, error)) error { + // Resolved aliases are absolute paths; fuser does not consistently accept an end-of-options marker. + // A clean fuser exit 1 with empty output is the only negative-holder evidence accepted. + args := append([]string{"-v"}, aliases...) + stdout, stderr, err := run(ctx, args) + if err == nil { + return ErrBusy + } + if errors.Is(err, exec.ErrNotFound) || errors.Is(err, os.ErrNotExist) { + return errors.Join(ErrMissingTool, err) + } + var exit *exec.ExitError + if errors.As(err, &exit) && exit.ExitCode() == 1 && len(stdout) == 0 && len(stderr) == 0 { + return nil + } + return errors.Join(ErrOwnershipUnknown, err) +} + +// Reason maps observation failures without requiring clients to parse provider error prose. +func Reason(err error) entity.DeviceReason { + if err == nil { + return entity.DeviceReason_DEVICE_REASON_UNSPECIFIED + } + switch { + case errors.Is(err, ErrBusy): + return entity.DeviceReason_DEVICE_REASON_BUSY + case errors.Is(err, ErrMissingTool): + return entity.DeviceReason_DEVICE_REASON_MISSING_TOOL + case errors.Is(err, ErrUncertain), errors.Is(err, ErrOwnershipUnknown): + return entity.DeviceReason_DEVICE_REASON_OWNERSHIP_UNCERTAIN + case errors.Is(err, os.ErrPermission): + return entity.DeviceReason_DEVICE_REASON_PERMISSION + case errors.Is(err, ErrMalformed): + return entity.DeviceReason_DEVICE_REASON_MALFORMED_RESPONSE + case errors.Is(err, ErrUnsupported): + return entity.DeviceReason_DEVICE_REASON_UNSUPPORTED + } + var command *CommandError + if errors.As(err, &command) { + if command.Sense.Key == 2 && command.Sense.ASC == 0x3a { + return entity.DeviceReason_DEVICE_REASON_NO_MEDIUM + } + if command.Sense.Key == 2 { + return entity.DeviceReason_DEVICE_REASON_NOT_READY + } + if command.Sense.Key == 5 && (command.Sense.ASC == 0x20 || command.Sense.ASC == 0x24) && command.Sense.ASCQ == 0 && + !command.Sense.Filemark && !command.Sense.EOM && !command.Sense.ILI { + return entity.DeviceReason_DEVICE_REASON_UNSUPPORTED + } + } + return entity.DeviceReason_DEVICE_REASON_TRANSPORT_ERROR +} diff --git a/internal/media/tape/ownership_linux_test.go b/internal/media/tape/ownership_linux_test.go new file mode 100644 index 0000000..428cbd5 --- /dev/null +++ b/internal/media/tape/ownership_linux_test.go @@ -0,0 +1,47 @@ +package tape + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "testing" +) + +func TestOwnershipWithInstalledFuser(t *testing.T) { + // Real process-table visibility is an integration prerequisite, not a mocked absence of holders. + if _, err := exec.LookPath("fuser"); err != nil { + t.Skipf("installed fuser is unavailable: %v", err) + } + mounts, err := os.ReadFile("/proc/self/mountinfo") + if err != nil { + t.Fatal(err) + } + if err := checkVisibility(context.Background(), "/proc", string(mounts)); err != nil { + t.Skipf("process descriptor tables are not fully visible: %v", err) + } + + // Ordinary isolated files exercise the actual fuser invocation without accessing a device. + root := t.TempDir() + aliases := []string{filepath.Join(root, "nst0"), filepath.Join(root, "sg0")} + for _, alias := range aliases { + if err := os.WriteFile(alias, nil, 0600); err != nil { + t.Fatal(err) + } + } + identity := Identity{Key: root, Aliases: aliases} + if err := CheckOwnership(context.Background(), identity); err != nil { + t.Fatalf("unused aliases were not accepted: %v", err) + } + + // A live descriptor must remain busy after accepting the clean negative result above. + holder, err := os.Open(aliases[0]) + if err != nil { + t.Fatal(err) + } + defer holder.Close() + if err := CheckOwnership(context.Background(), identity); !errors.Is(err, ErrBusy) { + t.Fatalf("held alias was not busy: %v", err) + } +} diff --git a/internal/media/tape/performance_query_test.go b/internal/media/tape/performance_query_test.go new file mode 100644 index 0000000..b4e287f --- /dev/null +++ b/internal/media/tape/performance_query_test.go @@ -0,0 +1,98 @@ +package tape + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func performanceQueryFixture() Command { + return Command{CDB: []byte{0x4d, 0, 0x74, 0, 0, 0, 0, 0x10, 0, 0}, Direction: FromDevice, + Data: make([]byte, MaxQueryBytes), Timeout: 2 * time.Second} +} + +func TestOptionalPerformanceQueryKeepsExactPassiveForm(t *testing.T) { + // The extra current-value page retains the existing bounded, data-in-only optional admission. + calls := 0 + transport := newSerializedTransport(func(command Command) (Response, error) { + calls++ + require.Equal(t, byte(0x74), command.CDB[2]) + return Response{Completed: true}, nil + }, func() error { return nil }) + t.Cleanup(func() { require.NoError(t, transport.Close()) }) + _, issued, err := transport.TryExecute(context.Background(), performanceQueryFixture(), nil) + require.NoError(t, err) + require.True(t, issued) + require.Equal(t, 1, calls) + + // Threshold/default values, read-clearing pages and every changed reserved/allocation field remain blocked. + for _, change := range []struct { + index int + value byte + }{{0, 0x4c}, {1, 1}, {2, 0x34}, {2, 0xb4}, {2, 0x6e}, {3, 1}, {4, 1}, {5, 1}, {6, 1}, {7, 1}, {8, 1}, {9, 1}} { + command := performanceQueryFixture() + command.CDB[change.index] = change.value + _, issued, err := transport.TryExecute(context.Background(), command, nil) + require.Error(t, err) + require.False(t, issued) + } + for _, direction := range []Direction{ToDevice, NoData} { + command := performanceQueryFixture() + command.Direction = direction + _, issued, err := transport.TryExecute(context.Background(), command, nil) + require.Error(t, err) + require.False(t, issued) + } + for _, size := range []int{0, MaxQueryBytes + 1} { + command := performanceQueryFixture() + command.Data = make([]byte, size) + _, issued, err := transport.TryExecute(context.Background(), command, nil) + require.Error(t, err) + require.False(t, issued) + } + require.Equal(t, 1, calls) +} + +func TestOptionalPerformanceUncertaintyBlocksLaterPhysicalWork(t *testing.T) { + for _, mode := range []string{"deferred sense", "host failure", "invalid residual", "missing completion"} { + t.Run(mode, func(t *testing.T) { + // Observe the actual serialized transport, including the terminal barrier before lane release. + calls, closes := 0, 0 + transport := newSerializedTransport(func(Command) (Response, error) { + calls++ + response := Response{Completed: true} + switch mode { + case "deferred sense": + response.Status = 2 + response.Sense = []byte{0x71, 0, 5, 0, 0, 0, 0, 10, 0, 0, 0, 0, 0x24, 0, 0, 0, 0, 0} + case "host failure": + response.HostStatus = 1 + case "invalid residual": + response.Residual = MaxQueryBytes + 1 + case "missing completion": + response.Completed = false + } + return response, nil + }, func() error { closes++; return nil }) + _, issued, err := transport.TryExecute(context.Background(), performanceQueryFixture(), nil) + require.ErrorIs(t, err, ErrUncertain) + require.True(t, issued) + + // Neither data, closure nor another optional query can reach the native executor afterward. + for _, opcode := range []byte{0x0a, 0x10} { + _, err := transport.Execute(context.Background(), Command{CDB: []byte{opcode, 0, 0, 0, 1, 0}, Timeout: time.Second, Direction: NoData}) + require.ErrorIs(t, err, ErrUncertain) + } + _, issued, err = transport.TryExecute(context.Background(), capacityCommand(), nil) + require.ErrorIs(t, err, ErrUncertain) + require.False(t, issued) + require.Equal(t, 1, calls) + require.True(t, errors.Is(transport.Close(), ErrUncertain)) + require.ErrorIs(t, transport.Close(), ErrUncertain) + require.Equal(t, 1, closes) + }) + } +} diff --git a/internal/media/tape/queries.go b/internal/media/tape/queries.go new file mode 100644 index 0000000..5d984b8 --- /dev/null +++ b/internal/media/tape/queries.go @@ -0,0 +1,164 @@ +package tape + +import ( + "context" + "encoding/binary" + "fmt" + "time" +) + +// passiveCommand is the read-only SG command/form allowlist. No load, motion, write, mode change, +// key loading, counter reset or measurement command can pass this boundary. +func passiveCommand(command Command) bool { + // TEST UNIT READY is the sole no-data observation admitted here. + if len(command.CDB) < 6 { + return false + } + if len(command.CDB) == 6 && command.CDB[0] == 0 && command.Direction == NoData { + for _, value := range command.CDB[1:] { + if value != 0 { + return false + } + } + return len(command.Data) == 0 + } + + // Data observations retain the shared per-command allocation ceiling. + if command.Direction != FromDevice || len(command.Data) == 0 || len(command.Data) > MaxQueryBytes { + return false + } + cdb := command.CDB + + // Each opcode permits only its documented passive field combinations. + switch cdb[0] { + case 0x12: + return len(cdb) == 6 && cdb[1]&0xfe == 0 && cdb[3] == 0 && int(cdb[4]) == len(command.Data) && cdb[5] == 0 && + (cdb[1] == 0 && cdb[2] == 0 || cdb[1] == 1 && (cdb[2] == 0x80 || cdb[2] == 0x83)) + case 5: + return len(cdb) == 6 && cdb[1] == 0 && cdb[2] == 0 && cdb[3] == 0 && cdb[4] == 0 && cdb[5] == 0 && len(command.Data) == 6 + case 0x5a: + return len(cdb) == 10 && cdb[1] == 0 && (cdb[2] == 0x3f || cdb[2] == 0x7f) && cdb[3] == 0 && + cdb[4] == 0 && cdb[5] == 0 && cdb[6] == 0 && int(binary.BigEndian.Uint16(cdb[7:9])) == len(command.Data) && cdb[9] == 0 + case 0x3c: + if len(cdb) != 10 || cdb[2] != 0x10 || cdb[9] != 0 { + return false + } + offset := int(cdb[3])<<16 | int(cdb[4])<<8 | int(cdb[5]) + length := int(cdb[6])<<16 | int(cdb[7])<<8 | int(cdb[8]) + if length != len(command.Data) { + return false + } + if cdb[1] == 3 { + return offset == 0 && length == 4 + } + return cdb[1] == 2 && offset+length <= cartridgeMemoryMaxBytes + case 0x34: + if len(cdb) != 10 || cdb[1] != 0 && cdb[1] != 6 { + return false + } + for _, value := range cdb[2:] { + if value != 0 { + return false + } + } + return cdb[1] == 0 && len(command.Data) == 20 || cdb[1] == 6 && len(command.Data) == 32 + case 0xa3: + if len(cdb) == 12 && cdb[1] == 0x1f && cdb[2] == 0x45 { + return (cdb[3] == 0 && len(command.Data) == 10 || cdb[3] == 2) && cdb[4] == 0 && cdb[5] == 0 && + int(binary.BigEndian.Uint32(cdb[6:10])) == len(command.Data) && cdb[10] == 0 && cdb[11] == 0 + } + return len(cdb) == 12 && cdb[1] == 0x0c && + (cdb[2] == 1 && cdb[4] == 0 && cdb[5] == 0 || cdb[2] == 2 && cdb[4] == 0 && (cdb[3] == 0x34 && (cdb[5] == 0 || cdb[5] == 6) || cdb[3] == 0xa3 && cdb[5] == 0x1f)) && + int(binary.BigEndian.Uint32(cdb[6:10])) == len(command.Data) && cdb[10] == 0 && cdb[11] == 0 + case 0x4d: + return len(cdb) == 10 && cdb[1] == 0 && (cdb[2] == 0x4c || cdb[2] == 0x74) && cdb[3] == 0 && cdb[4] == 0 && + cdb[5] == 0 && cdb[6] == 0 && int(binary.BigEndian.Uint16(cdb[7:9])) == len(command.Data) && cdb[9] == 0 + case 0x8c: + return len(cdb) == 16 && (cdb[1] == 0 || cdb[1] == 3) && cdb[2] == 0 && cdb[3] == 0 && + cdb[4] == 0 && cdb[5] == 0 && cdb[6] == 0 && cdb[7] == 0 && + int(binary.BigEndian.Uint32(cdb[10:14])) == len(command.Data) && cdb[14] == 0 && cdb[15] == 0 + case 0xa2: + page := binary.BigEndian.Uint16(cdb[2:4]) + return len(cdb) == 12 && (cdb[1] == 0x20 && (page == 0 || page == 1 || page == 0x10 || page == 0x11 || page == 0x12 || page == 0x20) || cdb[1] == 0 && page == 0) && cdb[4] == 0 && cdb[5] == 0 && + int(binary.BigEndian.Uint32(cdb[6:10])) == len(command.Data) && cdb[10] == 0 && cdb[11] == 0 + } + return false +} + +func query(ctx context.Context, transport Transport, cdb []byte, length int) ([]byte, error) { + // Allocation bounds belong here, before a malformed device can influence buffer size. + if length < 0 || length > MaxQueryBytes { + return nil, ErrMalformed + } + command := Command{CDB: cdb, Data: make([]byte, length), Direction: FromDevice, Timeout: 2 * time.Second} + if length == 0 { + command.Direction = NoData + } + if !passiveCommand(command) { + return nil, fmt.Errorf("query is outside passive command allowlist") + } + if deadline, ok := ctx.Deadline(); ok { + command.Timeout = min(command.Timeout, max(time.Millisecond, time.Until(deadline))) + } + response, err := transport.Execute(ctx, command) + if err != nil { + return nil, err + } + if err := CheckResponse(response); err != nil { + return nil, err + } + if response.Residual < 0 || int(response.Residual) > length { + return nil, &UncertainError{Cause: ErrMalformed} + } + return command.Data[:length-int(response.Residual)], nil +} + +// Position records exact logical position only when the applicable READ POSITION validity bits permit it. +type Position struct { + Partition uint32 + Object, File uint64 + ObjectKnown, FileKnown bool + Beginning, BeyondEarlyWarning bool +} + +func decodePosition(data []byte, long bool) (Position, error) { + var position Position + if long { + if len(data) != 32 { + return Position{}, ErrMalformed + } + position = Position{ + Partition: binary.BigEndian.Uint32(data[4:8]), Object: binary.BigEndian.Uint64(data[8:16]), + File: binary.BigEndian.Uint64(data[16:24]), ObjectKnown: data[0]&4 == 0, FileKnown: data[0]&8 == 0, + Beginning: data[0]&0x80 != 0, BeyondEarlyWarning: data[0]&0x40 != 0, + } + } else { + if len(data) != 20 { + return Position{}, ErrMalformed + } + position = Position{ + Partition: uint32(data[1]), Object: uint64(binary.BigEndian.Uint32(data[4:8])), ObjectKnown: data[0]&6 == 0, + Beginning: data[0]&0x80 != 0, BeyondEarlyWarning: data[0]&0x40 != 0, + } + } + + // Valid counters must agree with BOP and with filemarks being a subset of logical objects. + if position.Beginning && (position.ObjectKnown && position.Object != 0 || position.FileKnown && position.File != 0) || + position.ObjectKnown && position.FileKnown && position.File > position.Object { + return Position{}, ErrMalformed + } + return position, nil +} + +func (r Records) Position(ctx context.Context, long bool) (position Position, err error) { + defer func() { r.geometryError(err) }() + cdb, length := []byte{0x34, 0, 0, 0, 0, 0, 0, 0, 0, 0}, 20 + if long { + cdb[1], length = 6, 32 + } + data, err := query(ctx, r.Transport, cdb, length) + if err != nil { + return Position{}, err + } + return decodePosition(data, long) +} diff --git a/internal/media/tape/records.go b/internal/media/tape/records.go new file mode 100644 index 0000000..f0b094e --- /dev/null +++ b/internal/media/tape/records.go @@ -0,0 +1,439 @@ +package tape + +import ( + "context" + "errors" + "fmt" + "io" + "time" +) + +var ErrEarlyWarning = errors.New("Tape early warning") + +// RecordMode is the explicitly observed/frozen mode, not a request to change drive controls. +// WarningCounts qualifies current WRITE count forms (GA32-0928-08 §4.12) and synchronous +// FILEMARK completion under the established SSC baseline. The caller establishes applicability; +// fake transport tests do not qualify hardware. +type RecordMode struct { + FixedBytes int + MaximumBytes int + WarningCounts bool +} + +// RecordResult separates accepted-byte evidence from boundary/warning flags. A warning with an +// unknown count cannot acknowledge a record even when the SG residual is zero. +type RecordResult struct { + Bytes int + AcceptedKnown bool + Filemark, EOD, EarlyWarning bool + Sense Sense +} + +// Records uses one SCSI command per record, independent of ACP chunks. Commands remain serialized +// by its Transport; callers freeze mode before constructing this reader/writer. +type Records struct { + Transport Transport + Mode RecordMode + Timeout time.Duration + invalidateGeometry func() +} + +func (r Records) command(opcode byte, data []byte, direction Direction) (Command, error) { + // Fixed transfers count records; variable transfers count bytes. Neither silently truncates 24 bits. + if r.Mode.MaximumBytes <= 0 || r.Mode.MaximumBytes > MaxTransferBytes || len(data) == 0 || len(data) > r.Mode.MaximumBytes { + return Command{}, fmt.Errorf("record size is outside the supported envelope") + } + count, fixed := len(data), byte(0) + if r.Mode.FixedBytes != 0 { + if len(data) != r.Mode.FixedBytes { + return Command{}, fmt.Errorf("fixed transfer must contain exactly one record") + } + count, fixed = 1, 1 + } + cdb := []byte{opcode, fixed, byte(count >> 16), byte(count >> 8), byte(count), 0} + return Command{CDB: cdb, Direction: direction, Data: data, Timeout: r.Timeout}, nil +} + +func (r Records) Read(ctx context.Context, data []byte) (result RecordResult, err error) { + defer func() { r.geometryError(err) }() + // SILI is deliberately clear: short variable records must expose signed ILI evidence. + command, err := r.command(0x08, data, FromDevice) + if err != nil { + return RecordResult{}, err + } + command.readDiscard, _ = ctx.Value(readDiscardContextKey{}).(bool) + command.validate = func(response Response) error { _, err := decodeRecord(response, len(data), r.Mode, false); return err } + response, commandErr := r.Transport.Execute(ctx, command) + if !response.Completed && commandErr != nil { + return RecordResult{}, commandErr + } + result, err = decodeRecord(response, len(data), r.Mode, false) + if errors.Is(commandErr, ErrUncertain) { + result.Bytes, result.AcceptedKnown = 0, false + } + err = errors.Join(err, commandErr) + if (result.Filemark || result.EOD) && commandErr == nil { + return result, nil + } + return result, err +} + +func (r Records) Write(ctx context.Context, data []byte) (RecordResult, error) { + return r.write(ctx, data, nil, func(ctx context.Context, command Command) (Response, error) { + return r.Transport.Execute(ctx, command) + }) +} + +// WriteObserved records actual WRITE issue under the primary WRITE lane for its accounting callback. +// A callback rejection proves zero acceptance; no admission lock spans the native command. +func (r Records) WriteObserved(ctx context.Context, data []byte, onIssue func() error) (RecordResult, error) { + if onIssue == nil { + return r.Write(ctx, data) + } + + // Qualify actual issue accounting before allowing any WRITE onto an opaque transport. + transport, ok := r.Transport.(interface { + ExecuteObserved(context.Context, Command, func() error) (Response, error) + }) + if !ok { + return RecordResult{AcceptedKnown: true}, fmt.Errorf("WRITE issue observation is unavailable: %w", ErrUnsupported) + } + return r.write(ctx, data, onIssue, func(ctx context.Context, command Command) (Response, error) { + return transport.ExecuteObserved(ctx, command, command.onIssue) + }) +} + +func (r Records) write(ctx context.Context, data []byte, onIssue func() error, execute func(context.Context, Command) (Response, error)) (RecordResult, error) { + // Validate a complete record before admitting a state-changing command. + if r.invalidateGeometry != nil { + r.invalidateGeometry() + } + command, err := r.command(0x0a, data, ToDevice) + if err != nil { + return RecordResult{}, err + } + command.validate = func(response Response) error { _, err := decodeRecord(response, len(data), r.Mode, true); return err } + var issueErr error + if onIssue != nil { + command.onIssue = func() error { issueErr = onIssue(); return issueErr } + } + response, commandErr := execute(ctx, command) + if !response.Completed && commandErr != nil { + // The serialized transport returns plain cancellation only before command admission. + if commandErr == context.Canceled || commandErr == context.DeadlineExceeded || issueErr != nil && !errors.Is(commandErr, ErrUncertain) { + return RecordResult{AcceptedKnown: true}, commandErr + } + return RecordResult{}, commandErr + } + result, err := decodeRecord(response, len(data), r.Mode, true) + if errors.Is(commandErr, ErrUncertain) { + result.Bytes, result.AcceptedKnown = 0, false + } + return result, errors.Join(err, commandErr) +} + +func decodeRecord(response Response, size int, mode RecordMode, writing bool) (RecordResult, error) { + // Host transfer counts are bounded evidence; successful full records need consistent completion. + if response.Residual < 0 || int64(response.Residual) > int64(size) { + return RecordResult{}, &UncertainError{Cause: ErrMalformed} + } + err := CheckResponse(response) + if err == nil { + if response.Residual != 0 { + return RecordResult{}, &UncertainError{Cause: io.ErrUnexpectedEOF} + } + return RecordResult{Bytes: size, AcceptedKnown: true}, nil + } + var failure *CommandError + if !errors.As(err, &failure) { + return RecordResult{}, err + } + sense := failure.Sense + result := RecordResult{Sense: sense, EarlyWarning: writing && earlyWarningSense(sense)} + if sense.Deferred { + return result, &UncertainError{Cause: err} + } + + // Qualified current warnings preserve exactly the documented untransferred-unit count. + if writing { + if rejectedCommand(err) { + result.AcceptedKnown = true + return result, err + } + remaining, warningErr := writeWarningRemaining(failure, mode) + if warningErr != nil { + return result, &UncertainError{Cause: warningErr} + } + if remaining != 0 && remaining != int64(size) { + return result, &UncertainError{Cause: ErrMalformed} + } + result.Bytes, result.AcceptedKnown = size-int(remaining), true + return result, errors.Join(ErrEarlyWarning, err) + } + + // Standard read boundaries never substitute the requested length for the actual residual. + if sense.Filemark && sense.ILI || sense.ILI && sense.EOM { + return result, &UncertainError{Cause: ErrMalformed} + } + result.Filemark = sense.Filemark && sense.Key == 0 && sense.ASC == 0 && sense.ASCQ == 1 + result.EOD = sense.Key == 8 && sense.ASC == 0 && sense.ASCQ == 5 + if result.Filemark || result.EOD { + if response.Residual != int32(size) { + return RecordResult{Sense: sense}, &UncertainError{Cause: ErrMalformed} + } + remaining := int64(size) + if mode.FixedBytes != 0 { + remaining = 1 + } + if result.Filemark && !sense.InformationValid || sense.InformationValid && sense.Information != remaining { + return RecordResult{Sense: sense}, &UncertainError{Cause: ErrMalformed} + } + result.AcceptedKnown = true + return result, err + } + if sense.ILI && sense.Key == 0 && sense.ASC == 0 && sense.ASCQ == 0 && sense.InformationValid { + if sense.Information < 0 { + if mode.FixedBytes != 0 || response.Residual != 0 { + return result, &UncertainError{Cause: ErrMalformed} + } + return result, errors.Join(io.ErrShortBuffer, err) + } + if mode.FixedBytes != 0 || sense.Information > int64(size) || sense.Information != int64(response.Residual) { + return result, &UncertainError{Cause: ErrMalformed} + } + result.Bytes, result.AcceptedKnown = size-int(sense.Information), true + return result, nil + } + if rejectedCommand(err) { + return result, err + } + return result, &UncertainError{Cause: err} +} + +// WriteWarning reports whether every cause is compatible with a settled WRITE early warning. +// The caller separately requires known accepted bytes and an early-warning result. Settled caller +// cancellation is compatible; an independent transport failure never is. +func WriteWarning(err error, remaining int64, mode RecordMode) bool { + if err == ErrEarlyWarning || err == context.Canceled || err == context.DeadlineExceeded { + return true + } + if failure, ok := err.(*CommandError); ok { + observed, cause := writeWarningRemaining(failure, mode) + return cause == nil && observed == remaining + } + if wrapped, ok := err.(interface{ Unwrap() error }); ok { + return WriteWarning(wrapped.Unwrap(), remaining, mode) + } + if joined, ok := err.(interface{ Unwrap() []error }); ok { + causes := joined.Unwrap() + if len(causes) == 0 { + return false + } + for _, cause := range causes { + if !WriteWarning(cause, remaining, mode) { + return false + } + } + return true + } + return false +} + +func writeWarningRemaining(failure *CommandError, mode RecordMode) (int64, error) { + // One current-sense policy serves raw decoding and classification of joined completion errors. + if !currentWarning(failure, mode.WarningCounts) { + return 0, failure + } + remaining := failure.Sense.Information + if mode.FixedBytes != 0 { + if remaining < 0 || remaining > 1 { + return 0, ErrMalformed + } + remaining *= int64(mode.FixedBytes) + } + if int64(failure.Response.Residual) != remaining { + return 0, ErrMalformed + } + return remaining, nil +} + +func earlyWarningSense(sense Sense) bool { + return sense.EOM && sense.Key == 0 && sense.ASC == 0 && (sense.ASCQ == 0 || sense.ASCQ == 2) +} + +func currentWarning(failure *CommandError, qualified bool) bool { + sense := failure.Sense + return qualified && currentCommand(failure) && earlyWarningSense(sense) && + sense.InformationValid && !sense.ILI && !sense.Filemark +} + +func rejectedCommand(err error) bool { + var failure *CommandError + if !errors.As(err, &failure) || !currentCommand(failure) || failure.Sense.Filemark || failure.Sense.EOM || failure.Sense.ILI { + return false + } + return failure.Sense.Key == 2 || failure.Sense.Key == 5 +} + +func controlResponse(response Response) error { + err := CheckResponse(response) + if err == nil || rejectedCommand(err) { + return err + } + return &UncertainError{Cause: err} +} + +// Filemarks synchronizes previous buffered data with IMMED clear. Count zero is the SSC sync form; +// it writes no extra mark. Canonical ErrEarlyWarning proves synchronization and every requested mark +// completed; any other error leaves completion unusable even when it contains that warning. +func (r Records) Filemarks(ctx context.Context, count uint32) (err error) { + defer func() { r.geometryError(err) }() + if count != 0 && r.invalidateGeometry != nil { + r.invalidateGeometry() + } + // Completion failures without proven rejection leave the buffered data/mark boundary unknown. + if count > 0xffffff { + return fmt.Errorf("filemark count exceeds 24-bit command") + } + response, err := r.Transport.Execute(ctx, Command{ + CDB: []byte{0x10, 0, byte(count >> 16), byte(count >> 8), byte(count), 0}, Direction: NoData, Timeout: r.Timeout, + validate: func(response Response) error { return filemarkResponse(response, count, r.Mode.WarningCounts) }, + }) + if err != nil { + return err + } + if response.Residual != 0 { + return &UncertainError{Cause: ErrMalformed} + } + err = filemarkResponse(response, count, r.Mode.WarningCounts) + return err +} + +func filemarkResponse(response Response, count uint32, warningCounts bool) error { + // Native rejection retains its existing outcome; other responses need command-specific completion. + err := CheckResponse(response) + if err == nil || rejectedCommand(err) { + return err + } + var failure *CommandError + if errors.Is(err, ErrUncertain) || !errors.As(err, &failure) || count > 1 { + return &UncertainError{Cause: err} + } + + // SSC-3 §6.9 completes IMMED=0 marks and buffered objects before status. Its successful EW + // need not set VALID with SEW=0; ignore invalid information, but reject any valid remainder. + sense := failure.Sense + if warningCounts && currentCommand(failure) && earlyWarningSense(sense) && !sense.ILI && !sense.Filemark && + (!sense.InformationValid || sense.Information == 0) && response.Residual == 0 { + return ErrEarlyWarning + } + return &UncertainError{Cause: err} +} + +func (r Records) Sync(ctx context.Context) error { return r.Filemarks(ctx, 0) } + +// Unload uses the synchronous SSC LOAD UNLOAD form. The drive performs its own rewind; +// no separate REWIND or medium-dependent verification follows successful release. +func (r Records) Unload(ctx context.Context) (err error) { + if r.invalidateGeometry != nil { + r.invalidateGeometry() + } + defer func() { r.geometryError(err) }() + response, err := r.Transport.Execute(ctx, Command{ + CDB: []byte{0x1b, 0, 0, 0, 0, 0}, Direction: NoData, Timeout: r.Timeout, validate: controlResponse, + }) + if err != nil { + return err + } + return controlResponse(response) +} + +func (r Records) Rewind(ctx context.Context) (err error) { + defer func() { r.geometryError(err) }() + response, err := r.Transport.Execute(ctx, Command{CDB: []byte{1, 0, 0, 0, 0, 0}, Direction: NoData, Timeout: r.Timeout, validate: controlResponse}) + if err != nil { + return err + } + if err := CheckResponse(response); err != nil { + return err + } + return nil +} + +// Space uses SSC SPACE(6), including reverse counts; EOD ignores count and cannot prove a safe tail. +func (r Records) Space(ctx context.Context, code byte, count int32) (err error) { + defer func() { r.geometryError(err) }() + // Refuse unrepresentable motion before encoding its signed count. + if code != 0 && code != 1 && code != 3 || count < -0x800000 || count > 0x7fffff { + return fmt.Errorf("unsupported SPACE form") + } + if code == 3 { + count = 0 + } + + // Submit one non-immediate motion and resolve its cursor outcome under the command lane. + response, err := r.Transport.Execute(ctx, Command{ + CDB: []byte{0x11, code, byte(count >> 16), byte(count >> 8), byte(count), 0}, Direction: NoData, Timeout: r.Timeout, + validate: controlResponse, + }) + if err != nil { + return err + } + if err := CheckResponse(response); err != nil { + return err + } + return nil +} + +// Locate uses the full 64-bit logical object form with CP set and IMMED clear, without narrowing. +func (r Records) Locate(ctx context.Context, partition byte, object uint64) error { + // Encode the full logical object without a 32-bit fallback or silent narrowing. + cdb, err := locateCDB(0x92, uint32(partition), object) + if err != nil { + return err + } + return r.locate(ctx, cdb) +} + +func (r Records) locate(ctx context.Context, cdb []byte) (err error) { + defer func() { r.geometryError(err) }() + // A real LOCATE failure ends cursor use rather than attempting speculative recovery. + response, err := r.Transport.Execute(ctx, Command{CDB: cdb, Direction: NoData, Timeout: r.Timeout, validate: controlResponse}) + if err != nil { + return err + } + if err := CheckResponse(response); err != nil { + return err + } + return nil +} + +func (r Records) geometryError(err error) { + // Lost/unresolved source state withdraws estimates; pure cancellation alone does not claim physical mutation. + if err != nil && !onlyCancellation(err) && r.invalidateGeometry != nil { + r.invalidateGeometry() + } +} + +func onlyCancellation(err error) bool { + // Only cancellation leaves a known geometry observation intact; mixed physical failures invalidate it. + if err == context.Canceled || err == context.DeadlineExceeded { + return true + } + if joined, ok := err.(interface{ Unwrap() []error }); ok { + causes := joined.Unwrap() + if len(causes) == 0 { + return false + } + for _, cause := range causes { + if !onlyCancellation(cause) { + return false + } + } + return true + } + if wrapped, ok := err.(interface{ Unwrap() error }); ok { + return onlyCancellation(wrapped.Unwrap()) + } + return false +} diff --git a/internal/media/tape/records_observed_test.go b/internal/media/tape/records_observed_test.go new file mode 100644 index 0000000..7d07320 --- /dev/null +++ b/internal/media/tape/records_observed_test.go @@ -0,0 +1,41 @@ +package tape + +import ( + "context" + "errors" + "testing" + "time" +) + +type opaqueRecordTransport struct{ calls int } + +func (t *opaqueRecordTransport) Execute(context.Context, Command) (Response, error) { + // An external Execute-only implementation has no contract to invoke private Command fields. + t.calls++ + return Response{Completed: true}, nil +} + +func (*opaqueRecordTransport) Close() error { return nil } + +func TestObservedWriteRequiresAnExplicitTransportIssueBoundary(t *testing.T) { + // Method existence on Records cannot qualify an opaque Transport that silently ignores the hook. + transport := new(opaqueRecordTransport) + records := Records{Transport: transport, Mode: RecordMode{MaximumBytes: 512}, Timeout: time.Second} + callbacks := 0 + result, err := records.WriteObserved(context.Background(), make([]byte, 512), func() error { callbacks++; return nil }) + if !errors.Is(err, ErrUnsupported) || !result.AcceptedKnown || result.Bytes != 0 || transport.calls != 0 || callbacks != 0 { + t.Fatalf("opaque observed write=%+v err=%v calls=%d callbacks=%d", result, err, transport.calls, callbacks) + } + + // Ordinary WRITE remains valid on the established Execute contract and issues exactly one record. + result, err = records.Write(context.Background(), make([]byte, 512)) + if err != nil || !result.AcceptedKnown || result.Bytes != 512 || transport.calls != 1 || callbacks != 0 { + t.Fatalf("ordinary write=%+v err=%v calls=%d callbacks=%d", result, err, transport.calls, callbacks) + } + + // A nil hook asks for ordinary WRITE; it does not introduce an optional transport requirement. + result, err = records.WriteObserved(context.Background(), make([]byte, 512), nil) + if err != nil || !result.AcceptedKnown || result.Bytes != 512 || transport.calls != 2 || callbacks != 0 { + t.Fatalf("nil-hook write=%+v err=%v calls=%d callbacks=%d", result, err, transport.calls, callbacks) + } +} diff --git a/internal/media/tape/records_test.go b/internal/media/tape/records_test.go new file mode 100644 index 0000000..a3f7d08 --- /dev/null +++ b/internal/media/tape/records_test.go @@ -0,0 +1,497 @@ +package tape + +import ( + "context" + "encoding/binary" + "encoding/hex" + "errors" + "fmt" + "io" + "reflect" + "testing" + "time" +) + +func fixedSense(code, key, asc, ascq byte, valid bool, flags byte, information int32) []byte { + data := make([]byte, 18) + data[0], data[2], data[7], data[12], data[13] = code, key|flags, 10, asc, ascq + if valid { + data[0] |= 0x80 + } + binary.BigEndian.PutUint32(data[3:7], uint32(information)) + return data +} + +func TestRecordsPreserveAcceptedCountAndWarningEvidence(t *testing.T) { + for _, tc := range []struct { + name string + response Response + mode RecordMode + known bool + bytes int + err error + }{ + {"good", Response{Completed: true}, RecordMode{MaximumBytes: 512}, true, 512, nil}, + {"current warning", Response{Completed: true, Status: 2, Sense: fixedSense(0x70, 0, 0, 2, true, 0x40, 0)}, RecordMode{MaximumBytes: 512, WarningCounts: true}, true, 512, ErrEarlyWarning}, + {"fixed warning", Response{Completed: true, Status: 2, Residual: 512, Sense: fixedSense(0x70, 0, 0, 2, true, 0x40, 1)}, RecordMode{MaximumBytes: 512, FixedBytes: 512, WarningCounts: true}, true, 0, ErrEarlyWarning}, + {"variable zero warning", Response{Completed: true, Status: 2, Residual: 512, Sense: fixedSense(0x70, 0, 0, 2, true, 0x40, 512)}, RecordMode{MaximumBytes: 512, WarningCounts: true}, true, 0, ErrEarlyWarning}, + {"fixed full warning", Response{Completed: true, Status: 2, Sense: fixedSense(0x70, 0, 0, 2, true, 0x40, 0)}, RecordMode{MaximumBytes: 512, FixedBytes: 512, WarningCounts: true}, true, 512, ErrEarlyWarning}, + {"fixed count in bytes", Response{Completed: true, Status: 2, Residual: 512, Sense: fixedSense(0x70, 0, 0, 2, true, 0x40, 512)}, RecordMode{MaximumBytes: 512, FixedBytes: 512, WarningCounts: true}, false, 0, ErrUncertain}, + {"warning residual mismatch", Response{Completed: true, Status: 2, Residual: 512, Sense: fixedSense(0x70, 0, 0, 2, true, 0x40, 0)}, RecordMode{MaximumBytes: 512, WarningCounts: true}, false, 0, ErrUncertain}, + {"unqualified warning", Response{Completed: true, Status: 2, Sense: fixedSense(0x70, 0, 0, 2, true, 0x40, 0)}, RecordMode{MaximumBytes: 512}, false, 0, ErrUncertain}, + {"deferred warning", Response{Completed: true, Status: 2, Sense: fixedSense(0x71, 0, 0, 2, true, 0x40, 0)}, RecordMode{MaximumBytes: 512, WarningCounts: true}, false, 0, ErrUncertain}, + {"warning without count", Response{Completed: true, Status: 2, Sense: fixedSense(0x70, 0, 0, 2, false, 0x40, 0)}, RecordMode{MaximumBytes: 512, WarningCounts: true}, false, 0, ErrUncertain}, + {"good short transfer", Response{Completed: true, Residual: 1}, RecordMode{MaximumBytes: 512}, false, 0, ErrUncertain}, + {"clean rejected command", Response{Completed: true, Status: 2, Residual: 512, Sense: fixedSense(0x70, 5, 0x20, 0, false, 0, 0)}, RecordMode{MaximumBytes: 512}, true, 0, ErrUnsupported}, + } { + t.Run(tc.name, func(t *testing.T) { + // Host zero residual or a deferred warning alone cannot acknowledge a physical record. + calls := 0 + transport := newSerializedTransport(func(command Command) (Response, error) { calls++; return tc.response, nil }, func() error { return nil }) + defer transport.Close() + records := Records{Transport: transport, Mode: tc.mode, Timeout: time.Second} + result, err := records.Write(context.Background(), make([]byte, 512)) + matches := errors.Is(err, tc.err) + if tc.err == ErrUnsupported { + matches = Reason(err).String() == "DEVICE_REASON_UNSUPPORTED" + } + if result.AcceptedKnown != tc.known || result.Bytes != tc.bytes || !matches { + t.Fatalf("result=%+v, error=%v", result, err) + } + if errors.Is(err, ErrUncertain) { + if _, nextErr := records.Write(context.Background(), make([]byte, 512)); !errors.Is(nextErr, ErrUncertain) || calls != 1 { + t.Fatalf("uncertain lane reused: err=%v calls=%d", nextErr, calls) + } + } + }) + } +} + +func TestRecordsWriteCancellationPreservesAdmissionEvidence(t *testing.T) { + // Pre-command cancellation is known zero acceptance; settled GOOD retains a full accepted record. + for _, phase := range []string{"before cancel", "before deadline", "accepted cancel", "uncertain cancel"} { + t.Run(phase, func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + if phase == "before deadline" { + cancel() + ctx, cancel = context.WithDeadline(context.Background(), time.Now().Add(-time.Second)) + } + defer cancel() + calls := 0 + transport := newSerializedTransport(func(Command) (Response, error) { + calls++ + cancel() + response := Response{Completed: true} + if phase == "uncertain cancel" { + response.Residual = 1 + } + return response, nil + }, func() error { return nil }) + defer transport.Close() + if phase == "before cancel" { + cancel() + } + records := Records{Transport: transport, Mode: RecordMode{MaximumBytes: 512}, Timeout: time.Second} + result, err := records.Write(ctx, make([]byte, 512)) + wantCause := context.Canceled + if phase == "before deadline" { + wantCause = context.DeadlineExceeded + } + if !errors.Is(err, wantCause) { + t.Fatalf("cancellation missing: %v", err) + } + wantCalls, wantBytes := 0, 0 + if phase == "accepted cancel" || phase == "uncertain cancel" { + wantCalls = 1 + } + if phase == "accepted cancel" { + wantBytes = 512 + } + if result.AcceptedKnown != (phase != "uncertain cancel") || result.Bytes != wantBytes || calls != wantCalls { + t.Fatalf("result=%+v err=%v calls=%d", result, err, calls) + } + completionErr := records.Filemarks(context.Background(), 1) + if phase == "uncertain cancel" { + if !errors.Is(err, ErrUncertain) || !errors.Is(completionErr, ErrUncertain) || calls != 1 { + t.Fatalf("uncertain cancellation lane reused: write=%v completion=%v calls=%d", err, completionErr, calls) + } + } else if completionErr != nil || calls != wantCalls+1 { + t.Fatalf("safe completion refused: %v calls=%d", completionErr, calls) + } + }) + } +} + +func TestRecordsReadShortVariableAndBoundaryEvents(t *testing.T) { + for _, tc := range []struct { + name string + response Response + bytes int + filemark, eod bool + err error + }{ + {"short", Response{Completed: true, Status: 2, Residual: 12, Sense: fixedSense(0x70, 0, 0, 0, true, 0x20, 12)}, 500, false, false, nil}, + {"overlong", Response{Completed: true, Status: 2, Sense: fixedSense(0x70, 0, 0, 0, true, 0x20, -12)}, 0, false, false, io.ErrShortBuffer}, + {"filemark", Response{Completed: true, Status: 2, Residual: 512, Sense: fixedSense(0x70, 0, 0, 1, true, 0x80, 512)}, 0, true, false, nil}, + {"EOD", Response{Completed: true, Status: 2, Residual: 512, Sense: fixedSense(0x70, 8, 0, 5, true, 0x40, 512)}, 0, false, true, nil}, + } { + t.Run(tc.name, func(t *testing.T) { + transport := newSerializedTransport(func(Command) (Response, error) { return tc.response, nil }, func() error { return nil }) + defer transport.Close() + records := Records{Transport: transport, Mode: RecordMode{MaximumBytes: 512}, Timeout: time.Second} + result, err := records.Read(context.Background(), make([]byte, 512)) + if result.Bytes != tc.bytes || result.Filemark != tc.filemark || result.EOD != tc.eod || !errors.Is(err, tc.err) { + t.Fatalf("result=%+v err=%v", result, err) + } + }) + } +} + +func TestRecordCDBUnitsNavigationAndPhysicalCompletion(t *testing.T) { + // One fixed record uses count 1; variable count is bytes, and sync/filemark use explicit counts. + var commands [][]byte + transport := newSerializedTransport(func(command Command) (Response, error) { + commands = append(commands, append([]byte(nil), command.CDB...)) + return Response{Completed: true}, nil + }, func() error { return nil }) + defer transport.Close() + records := Records{Transport: transport, Mode: RecordMode{MaximumBytes: 1024}, Timeout: time.Second} + if _, err := records.Write(context.Background(), make([]byte, 512)); err != nil { + t.Fatal(err) + } + records.Mode.FixedBytes = 512 + if _, err := records.Write(context.Background(), make([]byte, 512)); err != nil { + t.Fatal(err) + } + if err := records.Sync(context.Background()); err != nil { + t.Fatal(err) + } + if err := records.Filemarks(context.Background(), 1); err != nil { + t.Fatal(err) + } + if err := records.Space(context.Background(), 1, -1); err != nil { + t.Fatal(err) + } + if err := records.Locate(context.Background(), 2, 1<<40); err != nil { + t.Fatal(err) + } + want := [][]byte{{0x0a, 0, 0, 2, 0, 0}, {0x0a, 1, 0, 0, 1, 0}, {0x10, 0, 0, 0, 0, 0}, {0x10, 0, 0, 0, 1, 0}, {0x11, 1, 0xff, 0xff, 0xff, 0}, {0x92, 2, 0, 2, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0}} + if !reflect.DeepEqual(commands, want) { + t.Fatalf("commands=%x, want=%x", commands, want) + } + + // Invalid lengths/counts are refused before the transport can observe a command. + for _, err := range []error{records.Filemarks(context.Background(), 1<<24), records.Space(context.Background(), 1, 1<<23)} { + if err == nil { + t.Fatal("overflowing command accepted") + } + } + if _, err := records.Write(context.Background(), make([]byte, 511)); err == nil { + t.Fatal("partial fixed record accepted") + } + if len(commands) != len(want) { + t.Fatal("invalid request issued a command") + } +} + +func TestDescriptorSensePreservesFlagsAndSignedInformation(t *testing.T) { + // A descriptor information field and stream descriptor carry distinct validity and boundary flags. + data := make([]byte, 24) + data[0], data[7], data[8], data[9], data[10] = 0x72, 16, 0, 10, 0x80 + binary.BigEndian.PutUint64(data[12:20], ^uint64(0)) + data[20], data[21], data[23] = 4, 2, 0xe0 + sense, err := DecodeSense(data) + if err != nil || !sense.InformationValid || sense.Information != -1 || !sense.Filemark || !sense.EOM || !sense.ILI { + t.Fatalf("sense=%+v error=%v", sense, err) + } + for _, malformed := range [][]byte{data[:23], {0x70}, {0x72, 0, 0, 0, 0, 0, 0, 1, 4}} { + if _, err := DecodeSense(malformed); err == nil { + t.Fatal("truncated sense accepted") + } + } +} + +func TestFilemarkCurrentEWWithoutInformationCompletesAndKeepsLane(t *testing.T) { + // Retain the real current-sense response from the failed non-immediate full-run filemark. + sense, err := hex.DecodeString("700040000000001000000000000200009436000000000000") + if err != nil { + t.Fatal(err) + } + for _, count := range []uint32{0, 1} { + t.Run(fmt.Sprintf("count=%d", count), func(t *testing.T) { + // A synthetic transport exposes exactly the captured completion and no native device. + calls := 0 + transport := newSerializedTransport(func(command Command) (Response, error) { + calls++ + if calls == 1 { + if !reflect.DeepEqual(command.CDB, []byte{0x10, 0, 0, 0, byte(count), 0}) || command.Direction != NoData { + t.Fatalf("filemark form changed: %x", command.CDB) + } + return Response{Completed: true, Status: 2, DriverStatus: 8, Sense: sense}, nil + } + if command.CDB[0] != 0x5a { + t.Fatalf("unexpected cleanup command: %x", command.CDB) + } + return Response{Completed: true}, nil + }, func() error { return nil }) + defer transport.Close() + records := Records{Transport: transport, Mode: RecordMode{WarningCounts: true}, Timeout: time.Second} + + // SSC synchronous completion permits ordinary control verification despite absent residual information. + if err := records.Filemarks(context.Background(), count); err != ErrEarlyWarning { + t.Fatalf("completed warning refused: %v", err) + } + if _, err := transport.Execute(context.Background(), Command{CDB: []byte{0x5a, 0, 0x3f, 0, 0, 0, 0, 0x10, 0, 0}, + Direction: FromDevice, Data: make([]byte, 4096), Timeout: time.Second}); err != nil { + t.Fatalf("settled warning blocked ordinary cleanup: %v", err) + } + if calls != 2 { + t.Fatalf("control verification did not run exactly once: calls=%d", calls) + } + if err := transport.Close(); err != nil { + t.Fatalf("known completion poisoned Close: %v", err) + } + }) + } +} + +func TestFilemarkWarningRequiresProvenFullCompletion(t *testing.T) { + for _, tc := range []struct { + name string + valid bool + remaining int32 + deferred bool + want bool + }{ + {"completed current warning", true, 0, false, true}, {"completed without count", false, 0, false, true}, + {"mark not completed", true, 1, false, false}, {"deferred", true, 0, true, false}, + } { + t.Run(tc.name, func(t *testing.T) { + // Synchronous command completion admits EW; valid remaining counts must still be zero. + code := byte(0x70) + if tc.deferred { + code = 0x71 + } + calls := 0 + transport := newSerializedTransport(func(Command) (Response, error) { + calls++ + return Response{Completed: true, Status: 2, Sense: fixedSense(code, 0, 0, 2, tc.valid, 0x40, tc.remaining)}, nil + }, func() error { return nil }) + records := Records{Transport: transport, Mode: RecordMode{WarningCounts: true}, Timeout: time.Second} + err := records.Filemarks(context.Background(), 1) + if (err == ErrEarlyWarning) != tc.want { + t.Fatalf("completion=%v", err) + } + + // Unknown mark acceptance poisons the lane before a cleanup/control command can follow. + if !tc.want { + if err := records.Rewind(context.Background()); !errors.Is(err, ErrUncertain) || calls != 1 { + t.Fatalf("unknown mark allowed command: %v", err) + } + } + _ = transport.Close() + }) + } +} + +func TestFilemarkSynchronousWarningDoesNotReadInvalidInformation(t *testing.T) { + for _, count := range []uint32{0, 1} { + for _, tc := range []struct { + name string + sense []byte + }{ + {"invalid positive information", fixedSense(0x70, 0, 0, 2, false, 0x40, 27)}, + {"invalid negative information", fixedSense(0x70, 0, 0, 2, false, 0x40, -1)}, + {"descriptor without information", []byte{0x72, 0, 0, 2, 0, 0, 0, 4, 4, 2, 0, 0x40}}, + {"existing zero additional sense", fixedSense(0x70, 0, 0, 0, false, 0x40, 27)}, + } { + t.Run(fmt.Sprintf("count=%d/%s", count, tc.name), func(t *testing.T) { + // Invalid information carries no count: successful command semantics provide the proof. + transport := newSerializedTransport(func(Command) (Response, error) { + return Response{Completed: true, Status: 2, DriverStatus: 8, Sense: tc.sense}, nil + }, func() error { return nil }) + defer transport.Close() + records := Records{Transport: transport, Mode: RecordMode{WarningCounts: true}, Timeout: time.Second} + if err := records.Filemarks(context.Background(), count); err != ErrEarlyWarning { + t.Fatalf("invalid information interpreted as a remaining count: %v", err) + } + }) + } + } +} + +func TestFilemarkWarningRejectsContradictoryCompletionAndPoisonsLane(t *testing.T) { + for _, tc := range []struct { + name string + change func(*Response) + }{ + {"unknown completion", func(r *Response) { r.Completed = false }}, + {"host failure", func(r *Response) { r.HostStatus = 1 }}, + {"message failure", func(r *Response) { r.MessageStatus = 1 }}, + {"driver failure", func(r *Response) { r.DriverStatus = 1 }}, + {"different status", func(r *Response) { r.Status = 8 }}, + {"deferred", func(r *Response) { r.Sense[0] = 0x71 }}, + {"filemark flag", func(r *Response) { r.Sense[2] |= 0x80 }}, + {"ILI flag", func(r *Response) { r.Sense[2] |= 0x20 }}, + {"missing EOM", func(r *Response) { r.Sense[2] = 0 }}, + {"volume overflow", func(r *Response) { r.Sense[2] |= 0x0d }}, + {"different sense key", func(r *Response) { r.Sense[2] |= 1 }}, + {"different ASC", func(r *Response) { r.Sense[12] = 1 }}, + {"different ASCQ", func(r *Response) { r.Sense[13] = 5 }}, + {"positive SG residual", func(r *Response) { r.Residual = 1 }}, + {"negative SG residual", func(r *Response) { r.Residual = -1 }}, + {"valid remaining mark", func(r *Response) { r.Sense = fixedSense(0x70, 0, 0, 2, true, 0x40, 1) }}, + {"valid negative information", func(r *Response) { r.Sense = fixedSense(0x70, 0, 0, 2, true, 0x40, -1) }}, + } { + for _, count := range []uint32{0, 1} { + t.Run(fmt.Sprintf("count=%d/%s", count, tc.name), func(t *testing.T) { + // A coherent current NO SENSE warning is the baseline; each row contradicts one required fact. + response := Response{Completed: true, Status: 2, DriverStatus: 8, + Sense: fixedSense(0x70, 0, 0, 2, false, 0x40, 0)} + tc.change(&response) + calls := 0 + transport := newSerializedTransport(func(Command) (Response, error) { calls++; return response, nil }, + func() error { return nil }) + defer transport.Close() + records := Records{Transport: transport, Mode: RecordMode{WarningCounts: true}, Timeout: time.Second} + + // Unknown mark completion poisons the same lane instead of authorizing cleanup by residual alone. + if err := records.Filemarks(context.Background(), count); !errors.Is(err, ErrUncertain) { + t.Fatalf("contradictory completion accepted: %v", err) + } + if err := records.Rewind(context.Background()); !errors.Is(err, ErrUncertain) || calls != 1 { + t.Fatalf("uncertain lane reused: %v calls=%d", err, calls) + } + }) + } + } +} + +func TestFilemarkWarningKeepsQualificationAndCountBounds(t *testing.T) { + for _, tc := range []struct { + count uint32 + qualified bool + }{ + {0, false}, {1, false}, {2, true}, + } { + t.Run(fmt.Sprintf("count=%d/qualified=%t", tc.count, tc.qualified), func(t *testing.T) { + // Successful synchronous semantics do not widen the existing admitted command/count scope. + transport := newSerializedTransport(func(Command) (Response, error) { + return Response{Completed: true, Status: 2, Sense: fixedSense(0x70, 0, 0, 2, false, 0x40, 0)}, nil + }, func() error { return nil }) + defer transport.Close() + records := Records{Transport: transport, Mode: RecordMode{WarningCounts: tc.qualified}, Timeout: time.Second} + if err := records.Filemarks(context.Background(), tc.count); !errors.Is(err, ErrUncertain) { + t.Fatalf("unqualified completion accepted: %v", err) + } + }) + } +} + +func TestFilemarkWarningIsCanonicalOnlyWithoutOtherFailure(t *testing.T) { + for _, tc := range []struct { + name string + count uint32 + cancel bool + failure error + warning, uncertain bool + }{ + {"synchronized count zero", 0, false, nil, true, false}, + {"completed count one", 1, false, nil, true, false}, + {"unqualified multiple marks", 2, false, nil, false, true}, + {"settled caller cancellation", 1, true, nil, false, false}, + {"mixed uncertain failure", 1, false, errors.Join(ErrEarlyWarning, ErrUncertain), false, true}, + } { + t.Run(tc.name, func(t *testing.T) { + // A canonical warning is a completion proof; extra transport/caller failures retain identity. + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + calls := 0 + transport := newSerializedTransport(func(command Command) (Response, error) { + calls++ + if command.CDB[0] != 0x10 || command.CDB[1] != 0 || command.CDB[4] != byte(tc.count) { + t.Fatalf("completion form changed: %x", command.CDB) + } + if tc.cancel { + cancel() + } + return Response{Completed: true, Status: 2, Sense: fixedSense(0x70, 0, 0, 2, true, 0x40, 0)}, tc.failure + }, func() error { return nil }) + defer transport.Close() + records := Records{Transport: transport, Mode: RecordMode{WarningCounts: true}, Timeout: time.Second} + err := records.Filemarks(ctx, tc.count) + if (err == ErrEarlyWarning) != tc.warning || errors.Is(err, ErrUncertain) != tc.uncertain || calls != 1 { + t.Fatalf("completion=%v warning=%t uncertain=%t calls=%d", err, tc.warning, tc.uncertain, calls) + } + if tc.cancel && !errors.Is(err, context.Canceled) || tc.failure != nil && !errors.Is(err, tc.failure) { + t.Fatalf("additional failure lost: %v", err) + } + if tc.uncertain { + if err := records.Rewind(context.Background()); !errors.Is(err, ErrUncertain) || calls != 1 { + t.Fatalf("unknown completion lane reused: %v calls=%d", err, calls) + } + } + }) + } +} + +func TestWriteWarningPreservesCountsAndRejectsMixedFailures(t *testing.T) { + for _, fixed := range []bool{false, true} { + for _, remaining := range []int64{0, 512} { + t.Run(fmt.Sprintf("fixed=%t/remaining=%d", fixed, remaining), func(t *testing.T) { + // Joined raw warning evidence retains fixed-record units and settled caller cancellation. + mode := RecordMode{MaximumBytes: 512, WarningCounts: true} + information := remaining + if fixed { + mode.FixedBytes = 512 + information /= 512 + } + failure := &CommandError{Response: Response{Completed: true, Status: 2, Residual: int32(remaining)}, + Sense: Sense{EOM: true, ASCQ: 2, InformationValid: true, Information: information}} + warning := errors.Join(ErrEarlyWarning, fmt.Errorf("WRITE failed, %w", failure), context.Canceled) + if !WriteWarning(warning, remaining, mode) || WriteWarning(warning, remaining+1, mode) { + t.Fatalf("inconsistent WRITE warning classification, remaining=%d fixed=%t", remaining, fixed) + } + + // One incompatible cause invalidates the whole joined result without hiding its safe leaves. + for _, cause := range []error{ErrUncertain, ErrMalformed, errors.New("hardware failure"), &UncertainError{Cause: failure}} { + if WriteWarning(errors.Join(warning, cause), remaining, mode) { + t.Fatalf("mixed warning accepted, cause=%v", cause) + } + } + mode.WarningCounts = false + if WriteWarning(warning, remaining, mode) { + t.Fatal("unqualified raw warning accepted") + } + }) + } + } +} + +func TestWriteWarningRequiresCurrentCoherentCommandEvidence(t *testing.T) { + for _, test := range []struct { + name string + change func(*CommandError) + }{ + {"unknown completion", func(e *CommandError) { e.Response.Completed = false }}, + {"host failure", func(e *CommandError) { e.Response.HostStatus = 1 }}, + {"message failure", func(e *CommandError) { e.Response.MessageStatus = 1 }}, + {"driver failure", func(e *CommandError) { e.Response.DriverStatus = 1 }}, + {"deferred", func(e *CommandError) { e.Sense.Deferred = true }}, + {"missing information", func(e *CommandError) { e.Sense.InformationValid = false }}, + {"filemark", func(e *CommandError) { e.Sense.Filemark = true }}, + {"ILI", func(e *CommandError) { e.Sense.ILI = true }}, + {"different count", func(e *CommandError) { e.Sense.Information = 1 }}, + {"different residual", func(e *CommandError) { e.Response.Residual = 1 }}, + } { + t.Run(test.name, func(t *testing.T) { + // A parsed warning flag alone cannot override contradictory physical completion evidence. + failure := &CommandError{Response: Response{Completed: true, Status: 2}, + Sense: Sense{EOM: true, ASCQ: 2, InformationValid: true}} + test.change(failure) + if WriteWarning(errors.Join(ErrEarlyWarning, failure), 0, RecordMode{MaximumBytes: 512, WarningCounts: true}) { + t.Fatal("incoherent command accepted as a settled warning") + } + }) + } +} diff --git a/internal/media/tape/reload.go b/internal/media/tape/reload.go new file mode 100644 index 0000000..c37a714 --- /dev/null +++ b/internal/media/tape/reload.go @@ -0,0 +1,223 @@ +package tape + +import ( + "context" + "encoding/binary" + "errors" + "fmt" + "strings" + "time" + + "github.com/samuelncui/yatm/entity" +) + +func reloadCommand(load bool) []byte { + value := byte(8) + if load { + value = 1 + } + return []byte{0x1b, 0, 0, 0, value, 0} +} + +func (d *Device) reloadApplicable() bool { + // Scoped public command semantics establish HOLD/no-eject and the held-state discriminator. + vendor, model := strings.TrimSpace(d.report.GetVendor()), strings.TrimSpace(d.report.GetModel()) + return (vendor == "HP" || vendor == "HPE") && strings.HasPrefix(model, "Ultrium") || + vendor == "IBM" && (strings.HasPrefix(model, "ULT3580") || strings.HasPrefix(model, "Ultrium")) +} + +func (d *Device) reloadPrerequisites() bool { + // Missing current identity or read observations cannot authorize a physical cycle. + if !d.reloadApplicable() || !d.report.GetTape().GetMediumPresent() || + d.report.Reason == entity.DeviceReason_DEVICE_REASON_NOT_READY || + d.report.Reason == entity.DeviceReason_DEVICE_REASON_NO_MEDIUM || + d.report.Reason == entity.DeviceReason_DEVICE_REASON_OWNERSHIP_UNCERTAIN || + !d.geometry.Valid() || !d.geometry.AllWraps || len(d.geometry.Endpoints) == 0 || d.navigation == nil { + return false + } + + // Current logical navigation is reobserved after load; it does not require physical geometry. + for _, kind := range []entity.DeviceFeatureKind{ + entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_READ_POSITION, entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_LOCATE, + entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_END_OF_WRAP_POSITION, + } { + f := feature(d.report, kind) + if f.Support != entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_SUPPORTED || + f.Availability != entity.DeviceFeatureAvailability_DEVICE_FEATURE_AVAILABILITY_READY || + f.Reason != 0 || f.Validation == entity.DeviceFeatureValidation_DEVICE_FEATURE_VALIDATION_FAILED { + return false + } + } + return true +} + +func (d *Device) inspectReload(ctx context.Context, cipherOff bool) error { + // No undocumented product family can derive HOLD semantics from an accepted bit mask alone. + f := feature(d.report, entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_NON_EJECTING_RELOAD) + if !d.reloadApplicable() { + f.Detail = "No applicable documented non-ejecting HOLD/LOAD protocol family is established" + return nil + } + + // One optional report must positively support both exact synchronous forms; no mandatory fallback applies. + data, err := reportCommand(ctx, d.transport, 0x1b, nil) + f.Detail = "Synchronous HOLD/LOAD support or current observation prerequisites are unavailable" + if err != nil { + f.Reason, f.Detail = Reason(err), err.Error() + return err + } + f.Evidence = entity.DeviceFeatureEvidence_DEVICE_FEATURE_EVIDENCE_REPORTED + f.Support = entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_SUPPORTED + for _, load := range []bool{false, true} { + support, err := commandFormSupport(data, reloadCommand(load)) + if err != nil { + f.Support, f.Reason, f.Detail = entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_UNKNOWN, Reason(err), err.Error() + return err + } + if support != entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_SUPPORTED { + f.Support = support + if support == entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_UNSUPPORTED { + f.Availability, f.Reason = entity.DeviceFeatureAvailability_DEVICE_FEATURE_AVAILABILITY_UNAVAILABLE, entity.DeviceReason_DEVICE_REASON_UNSUPPORTED + } + return nil + } + } + + // Eligibility is separate from physical validation, reconstruction, freshness and timing benefit. + if cipherOff && d.reloadPrerequisites() { + f.Availability, f.Reason = entity.DeviceFeatureAvailability_DEVICE_FEATURE_AVAILABILITY_READY, 0 + f.Detail = "Documented synchronous non-ejecting HOLD/LOAD forms and current prerequisites observed; physical validation and map freshness remain unknown" + } + return nil +} + +// RefreshPositionMap performs one eligible non-ejecting HOLD/LOAD and captures a new session observation. +// It neither establishes REOWP reconstruction nor physical freshness, coverage or ordering benefit. +func (d *Device) RefreshPositionMap(ctx context.Context) (err error) { + // Refresh precedes all session control/key preparation and cannot release or replace any foreign key. + if err := ctx.Err(); err != nil { + return err + } + f := feature(d.report, entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_NON_EJECTING_RELOAD) + if f.Support != entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_SUPPORTED || + f.Availability != entity.DeviceFeatureAvailability_DEVICE_FEATURE_AVAILABILITY_READY || f.Reason != 0 || + f.Validation == entity.DeviceFeatureValidation_DEVICE_FEATURE_VALIDATION_FAILED || !d.reloadPrerequisites() { + return fmt.Errorf("non-ejecting HOLD/LOAD prerequisites are unavailable: %w", ErrUnsupported) + } + if d.operation != 0 || d.original != nil || d.prepared != nil || d.partition != nil || + d.changed || d.partitionChanged || d.compressionChanged != 0 || d.selectorChanged { + return fmt.Errorf("position-map refresh must precede Tape control preparation") + } + if _, err := query(ctx, d.transport, []byte{0, 0, 0, 0, 0, 0}, 0); err != nil { + return fmt.Errorf("confirm loaded Tape readiness failed, %w", err) + } + if err := d.requireEncryptionOff(ctx); err != nil { + return err + } + // LOAD may reset session controls, so capture their original owner before any motion or later Configure. + current, pages, err := d.readMode(ctx, false) + if err != nil { + return fmt.Errorf("capture pre-reload Tape controls failed, %w", err) + } + if len(current) < 16 || binary.BigEndian.Uint16(current[6:8]) != 8 || len(pages[0x10]) < 16 { + return fmt.Errorf("pre-reload Tape controls are unavailable") + } + d.original = sessionMode(current, pages[0x0f], pages[0x10], blockLength(current)) + before := d.report + + // Withdraw every old map/navigation observation before the first possible motion. + d.invalidateGeometry() + d.geometry, d.navigation = nil, nil + for _, kind := range []entity.DeviceFeatureKind{ + entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_READ_POSITION, entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_LOCATE, + entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_END_OF_WRAP_POSITION, + } { + observed := feature(d.report, kind) + observed.Availability, observed.Reason = entity.DeviceFeatureAvailability_DEVICE_FEATURE_AVAILABILITY_UNKNOWN, entity.DeviceReason_DEVICE_REASON_NOT_READY + observed.Limits = nil + } + defer func() { + if err != nil { + d.invalidateGeometry() + d.geometry, d.navigation = nil, nil + for _, kind := range []entity.DeviceFeatureKind{ + entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_READ_POSITION, entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_LOCATE, + entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_END_OF_WRAP_POSITION, + } { + observed := feature(d.report, kind) + observed.Availability, observed.Reason, observed.Detail = entity.DeviceFeatureAvailability_DEVICE_FEATURE_AVAILABILITY_UNAVAILABLE, Reason(err), err.Error() + observed.Limits = nil + } + failed := feature(d.report, entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_NON_EJECTING_RELOAD) + failed.Availability, failed.Reason, failed.Detail = entity.DeviceFeatureAvailability_DEVICE_FEATURE_AVAILABILITY_UNAVAILABLE, Reason(err), err.Error() + } + }() + + // Each native command settles once; cancellation or failure never schedules compensating LOAD. + d.changed = true + if len(pages[0x0f]) >= 16 { + d.compressionChanged = 0xe080 + } + d.selectorChanged = true + d.report.Reason = entity.DeviceReason_DEVICE_REASON_NOT_READY + d.report.Detail = "Tape HOLD/LOAD has not yet reestablished ready session controls" + if err := d.reload(ctx, false); err != nil { + return fmt.Errorf("hold Tape failed, %w", err) + } + command := Command{CDB: []byte{0, 0, 0, 0, 0, 0}, Direction: NoData, Timeout: 2 * time.Second, validate: heldTapeResponse} + if deadline, ok := ctx.Deadline(); ok { + command.Timeout = min(command.Timeout, max(time.Millisecond, time.Until(deadline))) + } + response, err := d.transport.Execute(ctx, command) + if err != nil { + return fmt.Errorf("confirm held Tape state failed, %w", err) + } + if err := heldTapeResponse(response); err != nil { + return err + } + if err := d.reload(ctx, true); err != nil { + return fmt.Errorf("load held Tape failed, %w", err) + } + + // Reuse the full same-descriptor observation path; old readiness, controls, navigation and geometry never survive. + if _, err := d.inspect(ctx, Identity{Configured: before.GetDevice().GetDevice(), Key: before.GetAccessIdentity()}); err != nil { + return fmt.Errorf("observe reloaded Tape failed, %w", err) + } + for _, identity := range [][2]string{ + {before.GetVendor(), d.report.GetVendor()}, {before.GetModel(), d.report.GetModel()}, + {before.GetFirmware(), d.report.GetFirmware()}, {before.GetSerialNumber(), d.report.GetSerialNumber()}, + } { + if identity[0] != "" && identity[0] != identity[1] { + return fmt.Errorf("reloaded Tape drive identity changed") + } + } + f = feature(d.report, entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_NON_EJECTING_RELOAD) + if !d.reloadPrerequisites() || f.Support != entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_SUPPORTED || + f.Availability != entity.DeviceFeatureAvailability_DEVICE_FEATURE_AVAILABILITY_READY || f.Reason != 0 { + return fmt.Errorf("reloaded Tape observation prerequisites are unavailable") + } + if err := d.requireEncryptionOff(ctx); err != nil { + return err + } + return ctx.Err() +} + +func heldTapeResponse(response Response) error { + err := CheckResponse(response) + failure, ok := err.(*CommandError) + if ok && currentCommand(failure) && response.Residual == 0 && !failure.Sense.Filemark && !failure.Sense.EOM && + !failure.Sense.ILI && failure.Sense.Key == 2 && failure.Sense.ASC == 0x3a && failure.Sense.ASCQ == 4 { + return nil + } + return errors.Join(err, fmt.Errorf("settled held/unthreaded Tape state was not confirmed")) +} + +func (d *Device) reload(ctx context.Context, load bool) error { + response, err := d.transport.Execute(ctx, Command{ + CDB: reloadCommand(load), Direction: NoData, Timeout: 10 * time.Minute, validate: controlResponse, + }) + if err != nil { + return err + } + return controlResponse(response) +} diff --git a/internal/media/tape/reload_capacity_test.go b/internal/media/tape/reload_capacity_test.go new file mode 100644 index 0000000..9fabe23 --- /dev/null +++ b/internal/media/tape/reload_capacity_test.go @@ -0,0 +1,128 @@ +package tape + +import ( + "context" + "encoding/hex" + "errors" + "reflect" + "testing" + + "github.com/samuelncui/yatm/entity" +) + +func TestReloadCapacityTransportAcceptsExpectedHeldResponse(t *testing.T) { + // Replay the native held response through both production transport owners. + f := newReloadFixture() + sense, err := hex.DecodeString("7000020000000010000000003a040000944e00000000000000") + if err != nil { + t.Fatal(err) + } + f.heldResponse = &Response{Completed: true, Status: 2, DriverStatus: 8, Sense: sense} + transport := capacityTestTransport(f.execute, func() (*serializedTransport, error) { + t.Fatal("refresh opened the optional capacity attachment") + return nil, ErrUnsupported + }, func() error { f.closed++; return nil }) + d, err := NewDevice(context.Background(), Identity{Configured: "fixture"}, transport) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = d.Cleanup(context.Background()) }) + original := append([]byte(nil), f.mode...) + f.onMotion = func(load bool) { + if load { + f.mode[14] = 4 + } + } + + // The expected held state permits one LOAD and the usual same-descriptor observation. + if err := d.RefreshPositionMap(context.Background()); err != nil { + t.Fatalf("refresh=%v motions=%v", err, reloadMotions(f)) + } + if !reflect.DeepEqual(reloadMotions(f), [][]byte{reloadCommand(false), reloadCommand(true)}) || f.wrapQueries != 2 { + t.Fatalf("motions=%v wrap_queries=%d", reloadMotions(f), f.wrapQueries) + } + + // Accepted held evidence must leave the shared gate open for owned control restoration. + if err := d.Cleanup(context.Background()); err != nil || !reflect.DeepEqual(f.mode, original) || f.closed != 1 { + t.Fatalf("cleanup=%v mode=%x want=%x closed=%d", err, f.mode, original, f.closed) + } +} + +func TestReloadCapacityTransportRejectsUnconfirmedHeldState(t *testing.T) { + for _, tc := range []struct { + name string + change func(*Response) + cause error + cancel bool + }{ + {name: "medium absent", change: func(r *Response) { r.Sense[13] = 0 }}, + {name: "other not ready", change: func(r *Response) { r.Sense[12], r.Sense[13] = 4, 1 }}, + {name: "GOOD", change: func(r *Response) { r.Status, r.DriverStatus, r.Sense = 0, 0, nil }}, + {name: "missing completion", change: func(r *Response) { r.Completed = false }}, + {name: "deferred", change: func(r *Response) { r.Sense[0] = 0x71 }}, + {name: "host failure", change: func(r *Response) { r.HostStatus = 3 }}, + {name: "driver failure", change: func(r *Response) { r.DriverStatus = 4 }}, + {name: "filemark", change: func(r *Response) { r.Sense[2] |= 0x80 }}, + {name: "EOM", change: func(r *Response) { r.Sense[2] |= 0x40 }}, + {name: "ILI", change: func(r *Response) { r.Sense[2] |= 0x20 }}, + {name: "residual", change: func(r *Response) { r.Residual = 1 }}, + {name: "transport uncertainty", cause: &UncertainError{Cause: ErrMalformed}}, + {name: "joined cancellation", cause: errors.Join(context.Canceled, &CommandError{})}, + {name: "caller cancellation", cancel: true}, + } { + t.Run(tc.name, func(t *testing.T) { + // Only the explicit held TUR receives this altered native response or concurrent failure. + f := newReloadFixture() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + transport := capacityTestTransport(func(command Command) (Response, error) { + response, err := f.execute(command) + if command.CDB[0] == 0 && f.held { + response.DriverStatus = 8 + if tc.change != nil { + tc.change(&response) + } + if tc.cancel { + cancel() + } + err = errors.Join(err, tc.cause) + } + return response, err + }, func() (*serializedTransport, error) { return nil, ErrUnsupported }, func() error { f.closed++; return nil }) + d, err := NewDevice(ctx, Identity{Configured: "fixture"}, transport) + if err != nil { + t.Fatal(err) + } + + // Neither a misleading sense tuple nor a joined failure authorizes LOAD or map reuse. + if err := d.RefreshPositionMap(ctx); err == nil || len(reloadMotions(f)) != 1 || d.navigation != nil || d.geometry != nil { + t.Fatalf("refresh=%v motions=%v", err, reloadMotions(f)) + } + + // Cleanup never schedules compensating motion after the rejected discriminator. + _ = d.Cleanup(context.Background()) + if f.closed != 1 || len(reloadMotions(f)) != 1 { + t.Fatalf("closed=%d motions=%v", f.closed, reloadMotions(f)) + } + }) + } +} + +func TestCapacityTransportOrdinaryHeldTURStillClosesAdmission(t *testing.T) { + // An ordinary readiness query has no authority to reinterpret the held-state discriminator. + issues := 0 + transport := capacityTestTransport(func(Command) (Response, error) { + issues++ + return Response{Completed: true, Status: 2, DriverStatus: 8, + Sense: fixedSense(0x70, 2, 0x3a, 4, false, 0, 0)}, nil + }, func() (*serializedTransport, error) { return nil, ErrUnsupported }, func() error { return nil }) + t.Cleanup(func() { _ = transport.Close() }) + if _, err := query(context.Background(), transport, []byte{0, 0, 0, 0, 0, 0}, 0); Reason(err) != entity.DeviceReason_DEVICE_REASON_NO_MEDIUM { + t.Fatalf("ordinary TUR=%v", err) + } + + // The shared terminal gate still refuses later primary work before native submission. + if _, err := transport.Execute(context.Background(), capacityTestWrite()); Reason(err) != entity.DeviceReason_DEVICE_REASON_NO_MEDIUM || issues != 1 { + t.Fatalf("write=%v issues=%d", err, issues) + } +} diff --git a/internal/media/tape/reload_test.go b/internal/media/tape/reload_test.go new file mode 100644 index 0000000..33215b6 --- /dev/null +++ b/internal/media/tape/reload_test.go @@ -0,0 +1,452 @@ +package tape + +import ( + "context" + "errors" + "reflect" + "testing" + + "github.com/samuelncui/yatm/entity" +) + +type reloadFixture struct { + *geometryFixture + held bool + forms int + commands [][]byte + form []byte + formError error + heldResponse *Response + motionResponse *Response + failLoad bool + foreignKey bool + noSerial bool + noFirmware bool + noSerialQuery bool + onMotion func(bool) + onQuery func(Command) +} + +func newReloadFixture() *reloadFixture { + return &reloadFixture{geometryFixture: newGeometryFixture()} +} + +func (f *reloadFixture) execute(command Command) (Response, error) { + // Record native issues separately from the session's admission checks and completion handling. + f.commands = append(f.commands, append([]byte(nil), command.CDB...)) + if f.onQuery != nil { + f.onQuery(command) + } + if command.CDB[0] == 0xa3 && command.CDB[1] == 0x0c && command.CDB[3] == 0x1b { + f.forms++ + if f.formError != nil { + return Response{}, f.formError + } + if f.form != nil { + copy(command.Data, f.form) + return Response{Completed: true, Residual: int32(len(command.Data) - len(f.form))}, nil + } + } + + // Exact synchronous HOLD unthreads; only a successful LOAD changes the fixture back to ready. + if command.CDB[0] == 0x12 && command.CDB[1] == 1 && f.noSerialQuery { + return Response{Completed: true, Status: 2, Sense: fixedSense(0x70, 5, 0x20, 0, false, 0, 0)}, nil + } + if command.CDB[0] == 0x1b { + load := command.CDB[4] == 1 + if !reflect.DeepEqual(command.CDB, reloadCommand(load)) || command.Direction != NoData { + return Response{}, ErrMalformed + } + if f.onMotion != nil { + f.onMotion(load) + } + if load == f.failLoad && f.motionResponse != nil { + return *f.motionResponse, nil + } + f.held = !load + return Response{Completed: true}, nil + } + if command.CDB[0] == 0 && f.held { + if f.heldResponse != nil { + return *f.heldResponse, nil + } + return Response{Completed: true, Status: 2, Sense: fixedSense(0x70, 2, 0x3a, 4, false, 0, 0)}, nil + } + + // Preserve the ordinary observation fixture while allowing a foreign nexus/key refusal. + response, err := f.geometryFixture.execute(command) + if command.CDB[0] == 0x12 && f.noSerial && command.CDB[1] == 1 { + clear(command.Data[2:]) + response.Residual = int32(len(command.Data) - 4) + } + if command.CDB[0] == 0x12 && f.noFirmware && command.CDB[1] == 0 { + copy(command.Data[32:36], " ") + } + if f.foreignKey && command.CDB[0] == 0xa2 && command.CDB[1] == 0x20 && command.CDB[3] == 0x20 { + command.Data[4] = 1 + } + return response, err +} + +func reloadDevice(t *testing.T, f *reloadFixture) *Device { + t.Helper() + d, err := NewDevice(context.Background(), Identity{Configured: "fixture"}, newSerializedTransport(f.execute, func() error { f.closed++; return nil })) + if err != nil { + t.Fatal(err) + } + return d +} + +func reloadMotions(f *reloadFixture) [][]byte { + var motions [][]byte + for _, cdb := range f.commands { + if cdb[0] == 0x1b { + motions = append(motions, cdb) + } + } + return motions +} + +func TestReloadCapturesNewObservationAndKeepsFreshnessUnknown(t *testing.T) { + for _, vendor := range []string{"IBM", "HP", "HPE"} { + t.Run(vendor, func(t *testing.T) { + // Passive eligibility and synthetic command completion never establish hardware validation. + f := newReloadFixture() + f.vendor = vendor + d := reloadDevice(t, f) + before, _ := d.Geometry(context.Background()) + beforeNavigation := d.navigation + capability := feature(d.Report(), entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_NON_EJECTING_RELOAD) + if capability.Support != entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_SUPPORTED || capability.Availability != entity.DeviceFeatureAvailability_DEVICE_FEATURE_AVAILABILITY_READY || capability.Validation != entity.DeviceFeatureValidation_DEVICE_FEATURE_VALIDATION_UNVALIDATED || f.forms != 1 { + t.Fatalf("capability=%v forms=%d", capability, f.forms) + } + f.onMotion = func(load bool) { + if before.Valid() || d.navigation != nil { + t.Fatal("old observations survived motion admission") + } + if load { + f.data = wrapData(WrapEndpoint{Wrap: 9, Object: 456}) + } + } + + // The sequence must contain a held discriminator between one HOLD and one real LOAD. + start := len(f.commands) + if err := d.RefreshPositionMap(context.Background()); err != nil { + t.Fatal(err) + } + motions := reloadMotions(f) + if !reflect.DeepEqual(motions, [][]byte{reloadCommand(false), reloadCommand(true)}) { + t.Fatalf("motions=%v", motions) + } + for i, cdb := range f.commands[start:] { + if cdb[0] == 0x1b && cdb[4] == 8 && (f.commands[start+i+1][0] != 0 || !reflect.DeepEqual(f.commands[start+i+2], reloadCommand(true))) { + t.Fatal("HOLD was not immediately followed by the held check and LOAD") + } + } + after, err := d.Geometry(context.Background()) + if err != nil || before.Valid() || !after.Valid() || after.Fresh != nil || len(after.Endpoints) != 1 || after.Endpoints[0].Object != 456 || d.navigation == beforeNavigation || f.wrapQueries != 2 || f.forms != 2 || f.selects != 0 || f.writes != 0 { + t.Fatalf("before=%v after=%+v err=%v forms=%d wraps=%d", before.Valid(), after, err, f.forms, f.wrapQueries) + } + + // Cleanup withdraws the replacement and closes exactly once, without control/key mutation. + if err := d.Cleanup(context.Background()); err != nil || after.Valid() { + t.Fatalf("cleanup=%v valid=%t", err, after.Valid()) + } + if err := d.Cleanup(context.Background()); err != nil || f.closed != 1 || f.selects != 0 || f.writes != 0 { + t.Fatalf("cleanup=%v closed=%d", err, f.closed) + } + }) + } +} + +func TestReloadRefusesMissingPrerequisitesBeforeMotion(t *testing.T) { + for _, tc := range []struct { + name string + setup func(*reloadFixture) + alter func(*Device) + }{ + {name: "HOLD mask absent", setup: func(f *reloadFixture) { f.form = []byte{0, 3, 0, 6, 0x1b, 0, 0, 0, 1, 0} }}, + {name: "LOAD mask absent", setup: func(f *reloadFixture) { f.form = []byte{0, 3, 0, 6, 0x1b, 0, 0, 0, 8, 0} }}, + {name: "unknown command support", setup: func(f *reloadFixture) { f.form = []byte{0, 0} }}, + {name: "unsupported command", setup: func(f *reloadFixture) { f.unsupported = true }}, + {name: "unscoped family", setup: func(f *reloadFixture) { f.vendor = "OTHER" }}, + {name: "unscoped HP family", setup: func(f *reloadFixture) { f.vendor, f.model = "HP", "DAT160" }}, + {name: "unready", setup: func(f *reloadFixture) { f.notReady = true }}, + {name: "short map", setup: func(f *reloadFixture) { f.shortOnly = true }}, + {name: "empty map", setup: func(f *reloadFixture) { f.data = wrapData() }}, + {name: "foreign key during inspection", setup: func(f *reloadFixture) { f.foreignKey = true }}, + {name: "foreign key after inspection", alter: func(d *Device) {}}, + {name: "failed validation", alter: func(d *Device) { + feature(d.report, entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_NON_EJECTING_RELOAD).Validation = entity.DeviceFeatureValidation_DEVICE_FEATURE_VALIDATION_FAILED + }}, + {name: "configured", alter: func(d *Device) { d.operation = Read }}, + {name: "captured controls", alter: func(d *Device) { d.original = []byte{1} }}, + } { + t.Run(tc.name, func(t *testing.T) { + // Baseline inspection may retain optional diagnostics, but no absent prerequisite authorizes motion. + f := newReloadFixture() + if tc.setup != nil { + tc.setup(f) + } + d := reloadDevice(t, f) + if tc.alter != nil { + tc.alter(d) + } + if tc.name == "foreign key after inspection" { + f.foreignKey = true + } + if err := d.RefreshPositionMap(context.Background()); err == nil || len(reloadMotions(f)) != 0 || f.selects != 0 || f.writes != 0 { + t.Fatalf("refresh=%v motions=%v", err, reloadMotions(f)) + } + for _, cdb := range f.commands { + if cdb[0] == 0xb5 { + t.Fatal("reload changed encryption state") + } + } + + // These never-issued refreshes retain ordinary sole Close and do not clear a foreign key. + d.original = nil + if err := d.Cleanup(context.Background()); err != nil || f.closed != 1 { + t.Fatalf("cleanup=%v closed=%d", err, f.closed) + } + }) + } +} + +func TestReloadFailureNeverRetriesOrLoadsAfterUnconfirmedHold(t *testing.T) { + for _, load := range []bool{false, true} { + for _, uncertain := range []bool{false, true} { + t.Run(map[bool]string{false: "HOLD", true: "LOAD"}[load]+map[bool]string{false: " known", true: " uncertain"}[uncertain], func(t *testing.T) { + // Known rejected commands retain clean cleanup; missing native completion preserves transport poison. + f := newReloadFixture() + d := reloadDevice(t, f) + before, _ := d.Geometry(context.Background()) + f.failLoad = load + if uncertain { + f.motionResponse = &Response{Completed: true, HostStatus: 3} + } else { + f.motionResponse = &Response{Completed: true, Status: 2, Sense: fixedSense(0x70, 5, 0x24, 0, false, 0, 0)} + } + err := d.RefreshPositionMap(context.Background()) + wantMotions := 1 + if load { + wantMotions = 2 + } + if err == nil || errors.Is(err, ErrUncertain) != uncertain || len(reloadMotions(f)) != wantMotions || before.Valid() || d.navigation != nil || f.wrapQueries != 1 { + t.Fatalf("refresh=%v motions=%v", err, reloadMotions(f)) + } + + // Cleanup performs no recovery LOAD, retry, key command, rewind, eject or filemark. + if err := d.Cleanup(context.Background()); errors.Is(err, ErrUncertain) != uncertain || f.closed != 1 || len(reloadMotions(f)) != wantMotions { + t.Fatalf("cleanup=%v closed=%d motions=%v", err, f.closed, reloadMotions(f)) + } + }) + } + } +} + +func TestReloadHeldDiscriminatorRequiresCurrentNotReady3A04(t *testing.T) { + for _, response := range []Response{ + {Completed: true}, + {Completed: true, Status: 2, Sense: fixedSense(0x70, 2, 0x3a, 0, false, 0, 0)}, + {Completed: true, Status: 2, Sense: fixedSense(0x70, 2, 4, 1, false, 0, 0)}, + {Completed: true, Status: 2, Sense: fixedSense(0x70, 5, 0x3a, 4, false, 0, 0)}, + } { + // A GOOD no-op or any other readiness code cannot authorize the LOAD step. + f := newReloadFixture() + d := reloadDevice(t, f) + f.heldResponse = &response + if err := d.RefreshPositionMap(context.Background()); err == nil || len(reloadMotions(f)) != 1 { + t.Fatalf("held=%v refresh=%v motions=%v", response, err, reloadMotions(f)) + } + if err := d.Cleanup(context.Background()); err != nil || f.closed != 1 { + t.Fatalf("cleanup=%v closed=%d", err, f.closed) + } + } +} + +func TestReloadCancellationStopsNewWorkAfterIssuedCommandSettles(t *testing.T) { + for _, stage := range []string{"before", "HOLD", "held check", "LOAD"} { + t.Run(stage, func(t *testing.T) { + // Cancellation is injected inside native execution so that issued work must return before cleanup. + f := newReloadFixture() + d := reloadDevice(t, f) + before, _ := d.Geometry(context.Background()) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + if stage == "before" { + cancel() + } + f.onMotion = func(load bool) { + if stage == "HOLD" && !load || stage == "LOAD" && load { + cancel() + } + } + f.onQuery = func(command Command) { + if stage == "held check" && command.CDB[0] == 0 && f.held { + cancel() + } + } + err := d.RefreshPositionMap(ctx) + want := 1 + if stage == "before" { + want = 0 + } else if stage == "LOAD" { + want = 2 + } + if !errors.Is(err, context.Canceled) || len(reloadMotions(f)) != want || before.Valid() != (stage == "before") || f.wrapQueries != 1 { + t.Fatalf("refresh=%v motions=%v old=%t", err, reloadMotions(f), before.Valid()) + } + + // Known settled cancellation does not manufacture uncertain cleanup or an automatic LOAD. + if err := d.Cleanup(context.Background()); err != nil || f.closed != 1 || len(reloadMotions(f)) != want { + t.Fatalf("cleanup=%v closed=%d", err, f.closed) + } + }) + } +} + +func TestReloadPostLoadObservationFailuresWithdrawReplacement(t *testing.T) { + for _, change := range []string{"identity", "unready", "query failure", "malformed", "truncation", "navigation", "foreign key"} { + t.Run(change, func(t *testing.T) { + // The post-load path reobserves actual facts instead of promoting stale map/readiness evidence. + f := newReloadFixture() + d := reloadDevice(t, f) + before, _ := d.Geometry(context.Background()) + f.onMotion = func(load bool) { + if !load { + return + } + switch change { + case "identity": + f.model = "Ultrium changed" + case "unready": + f.notReady = true + case "query failure": + f.response = &Response{Completed: true, Status: 2, Sense: fixedSense(0x70, 5, 0x24, 0, false, 0, 0)} + case "malformed": + f.data = []byte{0, 3, 0, 0, 0} + case "truncation": + f.data = make([]byte, MaxQueryBytes) + f.data[0], f.data[1] = 0x10, 0x0a + case "navigation": + f.deviceFixture.unsupported = 0x34 + case "foreign key": + f.foreignKey = true + } + } + err := d.RefreshPositionMap(context.Background()) + after, geometryErr := d.Geometry(context.Background()) + if err == nil || geometryErr != nil || after != nil || before.Valid() || d.navigation != nil || len(reloadMotions(f)) != 2 { + t.Fatalf("refresh=%v geometry=%v old=%t motions=%v", err, after, before.Valid(), reloadMotions(f)) + } + + // Failed required observation stays a clean known failure and never causes a second cycle. + if err := d.Cleanup(context.Background()); err != nil || f.closed != 1 || f.selects != 0 || f.writes != 0 || len(reloadMotions(f)) != 2 { + t.Fatalf("cleanup=%v closed=%d motions=%v", err, f.closed, reloadMotions(f)) + } + }) + } +} + +func TestReloadOptionalDiscoveryFailurePreservesBaseline(t *testing.T) { + for _, malformed := range []bool{false, true} { + // Optional reporting failure remains local to reload and does not revoke ordinary sequential access. + f := newReloadFixture() + if malformed { + f.form = []byte{0, 3} + } else { + f.formError = ErrUnsupported + } + d := reloadDevice(t, f) + if d.Report().Reason != 0 || len(reloadMotions(f)) != 0 { + t.Fatalf("report=%v motions=%v", d.Report(), reloadMotions(f)) + } + if _, err := d.Configure(context.Background(), &entity.TarMediaProfile{ + RecordMode: entity.TapeRecordMode_TAPE_RECORD_MODE_FIXED, RecordBytes: 512, + }, Read); err != nil { + t.Fatal(err) + } + + // Baseline preparation and cleanup never consume the unavailable reload capability. + if err := d.Cleanup(context.Background()); err != nil || f.closed != 1 || len(reloadMotions(f)) != 0 { + t.Fatalf("cleanup=%v closed=%d motions=%v", err, f.closed, reloadMotions(f)) + } + } +} + +func TestReloadOptionalDriveIdentityMayRemainAbsent(t *testing.T) { + for _, absent := range []string{"serial", "firmware", "serial query unsupported"} { + // Initial absence remains unknown; only an already known identity field must survive the cycle. + f := newReloadFixture() + f.noSerial, f.noFirmware = absent == "serial", absent == "firmware" + f.noSerialQuery = absent == "serial query unsupported" + d := reloadDevice(t, f) + if err := d.RefreshPositionMap(context.Background()); err != nil { + t.Fatal(err) + } + if err := d.Cleanup(context.Background()); err != nil || f.closed != 1 { + t.Fatalf("cleanup=%v closed=%d", err, f.closed) + } + } +} + +func TestReloadCleanupRetainsPreCycleControlsAcrossConfigure(t *testing.T) { + for _, configure := range []bool{false, true} { + // LOAD can reset non-secret controls; the later record preparation must not replace their original owner. + f := newReloadFixture() + d := reloadDevice(t, f) + original := append([]byte(nil), f.mode...) + f.onMotion = func(load bool) { + if load { + f.mode[3], f.mode[14], f.mode[19], f.mode[46] = 0, 4, 0, 0 + f.mode[18] &^= 0x80 + } + } + if err := d.RefreshPositionMap(context.Background()); err != nil { + t.Fatal(err) + } + if configure { + if _, err := d.Configure(context.Background(), &entity.TarMediaProfile{ + RecordMode: entity.TapeRecordMode_TAPE_RECORD_MODE_FIXED, RecordBytes: 512, HardwareCompression: true, + }, Read); err != nil { + t.Fatal(err) + } + } + + // Existing mode cleanup restores only owned controls while preserving algorithms, partition and EOD facts. + if err := d.Cleanup(context.Background()); err != nil || f.closed != 1 || !reflect.DeepEqual(f.mode, original) || len(reloadMotions(f)) != 2 { + t.Fatalf("cleanup=%v closed=%d mode=%x want=%x motions=%v", err, f.closed, f.mode, original, reloadMotions(f)) + } + } +} + +func TestReloadHeldCleanupNeverSelectsModeOrPretendsRestoration(t *testing.T) { + for _, readable := range []bool{false, true} { + // Cancellation leaves the cartridge held; unreadable or changed controls must remain a cleanup failure. + f := newReloadFixture() + d := reloadDevice(t, f) + ctx, cancel := context.WithCancel(context.Background()) + f.onMotion = func(load bool) { + if !load { + f.mode[14] = 4 + cancel() + } + } + if err := d.RefreshPositionMap(ctx); !errors.Is(err, context.Canceled) { + t.Fatal(err) + } + if !readable { + f.onQuery = func(command Command) { + if command.CDB[0] == 0x5a { + f.mode = []byte{0, 6, 0, 0, 0, 0, 0, 0} + } + } + } + + // Cleanup uses its existing uncertain-failure boundary and sole Close without speculative control commands. + if err := d.Cleanup(context.Background()); !errors.Is(err, ErrUncertain) || f.selects != 0 || f.closed != 1 || len(reloadMotions(f)) != 1 { + t.Fatalf("cleanup=%v selects=%d closed=%d motions=%v", err, f.selects, f.closed, reloadMotions(f)) + } + } +} diff --git a/internal/media/tape/safety_test.go b/internal/media/tape/safety_test.go new file mode 100644 index 0000000..27415cd --- /dev/null +++ b/internal/media/tape/safety_test.go @@ -0,0 +1,204 @@ +package tape + +import ( + "context" + "encoding/binary" + "errors" + "os" + "path/filepath" + "testing" + "time" + + "github.com/samuelncui/yatm/entity" +) + +func TestVariableWriteCannotAcknowledgePartialRecord(t *testing.T) { + // A partial variable transfer is not a shorter accepted physical record. + response := Response{Completed: true, Status: 2, Residual: 1, Sense: fixedSense(0x70, 0, 0, 2, true, 0x40, 1)} + calls := 0 + transport := newSerializedTransport(func(Command) (Response, error) { calls++; return response, nil }, func() error { return nil }) + defer transport.Close() + records := Records{Transport: transport, Mode: RecordMode{MaximumBytes: 512, WarningCounts: true}, Timeout: time.Second} + result, err := records.Write(context.Background(), make([]byte, 512)) + if !errors.Is(err, ErrUncertain) || result.AcceptedKnown || result.Bytes != 0 { + t.Fatalf("partial record result=%+v, error=%v", result, err) + } + + // Semantic uncertainty blocks the following command before it reaches the transport. + if err := records.Sync(context.Background()); !errors.Is(err, ErrUncertain) || calls != 1 { + t.Fatalf("partial record allowed sync: err=%v, calls=%d", err, calls) + } +} + +func TestCommandCancellationPreservesAcceptedRecord(t *testing.T) { + // Cancellation after admission retains the accepted record when its command settles successfully. + ctx, cancel := context.WithCancel(context.Background()) + transport := newSerializedTransport(func(Command) (Response, error) { cancel(); return Response{Completed: true}, nil }, func() error { return nil }) + defer transport.Close() + records := Records{Transport: transport, Mode: RecordMode{MaximumBytes: 512}, Timeout: time.Second} + result, err := records.Write(ctx, make([]byte, 512)) + if !errors.Is(err, context.Canceled) || !result.AcceptedKnown || result.Bytes != 512 { + t.Fatalf("settled cancellation result=%+v, err=%v", result, err) + } + + // Pre-command cancellation proves that zero records were physically accepted. + result, err = records.Write(ctx, make([]byte, 512)) + if !errors.Is(err, context.Canceled) || !result.AcceptedKnown || result.Bytes != 0 { + t.Fatalf("pre-command cancellation result=%+v, err=%v", result, err) + } +} + +func TestUnknownCompletionAndCursorBlockLaterCommands(t *testing.T) { + for _, operation := range []string{"sync", "filemark", "rewind", "space", "locate", "read"} { + t.Run(operation, func(t *testing.T) { + // A real command failure is not a clean optional command rejection or a known cursor. + calls := 0 + transport := newSerializedTransport(func(Command) (Response, error) { + calls++ + return Response{Completed: true, Status: 2, Sense: fixedSense(0x70, 3, 0x11, 0, false, 0, 0)}, nil + }, func() error { return nil }) + defer transport.Close() + records := Records{Transport: transport, Mode: RecordMode{MaximumBytes: 512}, Timeout: time.Second} + var err error + switch operation { + case "sync": + err = records.Sync(context.Background()) + case "filemark": + err = records.Filemarks(context.Background(), 1) + case "rewind": + err = records.Rewind(context.Background()) + case "space": + err = records.Space(context.Background(), 1, 1) + case "locate": + err = records.Locate(context.Background(), 0, 123) + case "read": + _, err = records.Read(context.Background(), make([]byte, 512)) + } + if !errors.Is(err, ErrUncertain) { + t.Fatalf("physical failure=%v", err) + } + + // No speculative navigation or completion follows an unknown outcome. + if err := records.Rewind(context.Background()); !errors.Is(err, ErrUncertain) || calls != 1 { + t.Fatalf("unknown outcome reused lane: err=%v, calls=%d", err, calls) + } + }) + } +} + +func TestInspectionResponseBoundaries(t *testing.T) { + // An INQUIRY response may advertise more bytes than a bounded allocation retrieves. + report := newReport(Identity{}) + data := make([]byte, 36) + data[0], data[4] = 1, 255 + copy(data[8:36], "OTHER GENERIC MODEL 1234") + if err := decodeInquiry(data, report); err != nil { + t.Fatalf("bounded inquiry=%v", err) + } + + // A zero maximum remains absent, while actual equality identifies a fixed-only envelope. + for _, tc := range []struct { + data []byte + maximum uint32 + variable bool + }{{[]byte{0, 0, 0, 0, 0, 1}, 0, false}, {[]byte{0, 0, 2, 0, 2, 0}, 512, false}} { + report := newReport(Identity{}) + if err := decodeBlockLimits(tc.data, report, nil); err != nil { + t.Fatal(err) + } + limits := feature(report, entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_SEQUENTIAL_READ).GetRecords() + if (limits.MaximumBytes == nil) != (tc.maximum == 0) || limits.GetVariable() != tc.variable { + t.Fatalf("limits=%v", limits) + } + if limits.GetMaximumBytes() != tc.maximum { + t.Fatalf("maximum=%v", limits.MaximumBytes) + } + } +} + +func TestPositionValidityFlags(t *testing.T) { + // Short overflow and location-unknown differ from long object/file unknown flags. + for _, tc := range []struct { + long bool + flags byte + object, file bool + }{{false, 0, true, false}, {false, 2, false, false}, {false, 4, false, false}, + {true, 0, true, true}, {true, 4, false, true}, {true, 8, true, false}} { + length := 20 + if tc.long { + length = 32 + } + data := make([]byte, length) + data[0] = tc.flags | 0x40 + position, err := decodePosition(data, tc.long) + if err != nil || position.ObjectKnown != tc.object || position.FileKnown != tc.file || !position.BeyondEarlyWarning { + t.Fatalf("flags=%02x long=%t position=%+v err=%v", tc.flags, tc.long, position, err) + } + } +} + +func TestPartialInspectionFailureOverridesEarlierOptionalReason(t *testing.T) { + // An optional serial body failure cannot hide a later unknown physical outcome. + transport := newSerializedTransport(func(command Command) (Response, error) { + if command.CDB[0] == 0x12 && command.CDB[1] == 1 { + return Response{Completed: true, Residual: int32(len(command.Data))}, nil + } + if command.CDB[0] == 0x34 { + return Response{Completed: true, HostStatus: 3}, nil + } + return inspectionFixture(command) + }, func() error { return nil }) + defer transport.Close() + report, err := inspectTransport(context.Background(), Identity{}, transport) + if !errors.Is(err, ErrUncertain) || report.Reason != entity.DeviceReason_DEVICE_REASON_OWNERSHIP_UNCERTAIN || report.ObservedAtNs == nil { + t.Fatalf("report=%v, err=%v", report, err) + } + if feature(report, entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_SEQUENTIAL_READ).Scope.GetModel() != "ULT3580-TD8" { + t.Fatal("partial report lost observed drive scope") + } +} + +func TestHolderVisibilityRejectsHiddenOrUnreadableTables(t *testing.T) { + // Process exit races leave no holder; hidden directories and broken fd observations stay unknown. + root := t.TempDir() + for _, path := range []string{filepath.Join(root, "1", "fd"), filepath.Join(root, "2")} { + if err := os.MkdirAll(path, 0700); err != nil { + t.Fatal(err) + } + } + if err := os.Symlink("/dev/null", filepath.Join(root, "1", "fd", "0")); err != nil { + t.Fatal(err) + } + if err := checkVisibility(context.Background(), root, ""); err != nil { + t.Fatal(err) + } + + // Even apparently clean fuser absence cannot authorize an invisible process table. + if err := checkVisibility(context.Background(), root, "1 2 0:1 / "+root+" rw - proc proc rw,hidepid=2"); !errors.Is(err, ErrOwnershipUnknown) { + t.Fatalf("hidden process table=%v", err) + } + if err := os.WriteFile(filepath.Join(root, "1", "fd", "1"), nil, 0600); err != nil { + t.Fatal(err) + } + if err := checkVisibility(context.Background(), root, ""); !errors.Is(err, ErrOwnershipUnknown) { + t.Fatalf("unreadable fd observation=%v", err) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if err := checkVisibility(ctx, root, ""); !errors.Is(err, context.Canceled) { + t.Fatalf("visibility cancellation=%v", err) + } +} + +func TestDescriptorSenseRejectsDuplicateEvidence(t *testing.T) { + // Conflicting repeated information descriptors cannot choose an accepted count. + data := make([]byte, 32) + data[0], data[7] = 0x72, 24 + for _, start := range []int{8, 20} { + data[start+1], data[start+2] = 10, 0x80 + binary.BigEndian.PutUint64(data[start+4:start+12], uint64(start)) + } + if _, err := DecodeSense(data); !errors.Is(err, ErrMalformed) { + t.Fatalf("duplicate descriptors=%v", err) + } +} diff --git a/internal/media/tape/sense.go b/internal/media/tape/sense.go new file mode 100644 index 0000000..77ef936 --- /dev/null +++ b/internal/media/tape/sense.go @@ -0,0 +1,150 @@ +package tape + +import ( + "encoding/binary" + "fmt" + + "github.com/samuelncui/yatm/entity" +) + +// Sense preserves current/deferred and sequential flags. Information is signed for ILI residuals. +type Sense struct { + Deferred bool + Key, ASC, ASCQ byte + Filemark, EOM, ILI bool + Information int64 + InformationValid bool +} + +// DecodeSense accepts complete SPC fixed/descriptor responses and refuses truncated descriptors. +func DecodeSense(data []byte) (Sense, error) { + // Validate the declared envelope before reading fields or walking descriptors. + if len(data) < 8 || int(data[7])+8 > len(data) { + return Sense{}, ErrMalformed + } + data = data[:int(data[7])+8] + var sense Sense + switch data[0] & 0x7f { + case 0x70, 0x71: + if len(data) < 14 { + return Sense{}, ErrMalformed + } + sense.Deferred = data[0]&0x7f == 0x71 + sense.Key, sense.ASC, sense.ASCQ = data[2]&0xf, data[12], data[13] + sense.Filemark, sense.EOM, sense.ILI = data[2]&0x80 != 0, data[2]&0x40 != 0, data[2]&0x20 != 0 + sense.InformationValid = data[0]&0x80 != 0 + sense.Information = int64(int32(binary.BigEndian.Uint32(data[3:7]))) + case 0x72, 0x73: + if data[0]&0x80 != 0 { + return Sense{}, ErrMalformed + } + sense.Deferred = data[0] == 0x73 + sense.Key, sense.ASC, sense.ASCQ = data[1]&0xf, data[2], data[3] + information, stream := false, false + for offset := 8; offset < len(data); { + if len(data)-offset < 2 { + return Sense{}, ErrMalformed + } + end := offset + 2 + int(data[offset+1]) + if end > len(data) { + return Sense{}, ErrMalformed + } + descriptor := data[offset:end] + switch descriptor[0] { + case 0: + if len(descriptor) != 12 || information { + return Sense{}, ErrMalformed + } + sense.InformationValid = descriptor[2]&0x80 != 0 + sense.Information = int64(binary.BigEndian.Uint64(descriptor[4:12])) + information = true + case 4: + if len(descriptor) != 4 || stream { + return Sense{}, ErrMalformed + } + sense.Filemark, sense.EOM, sense.ILI = descriptor[3]&0x80 != 0, descriptor[3]&0x40 != 0, descriptor[3]&0x20 != 0 + stream = true + } + offset = end + } + default: + return Sense{}, fmt.Errorf("unknown Tape sense response: %w", ErrMalformed) + } + return sense, nil +} + +// CommandError preserves the exact command status and sense without pretending it transferred bytes. +type CommandError struct { + Response Response + Sense Sense +} + +func (e *CommandError) Error() string { + return fmt.Sprintf("Tape command failed, status=%02x sense=%x/%02x%02x deferred=%t", e.Response.Status, e.Sense.Key, e.Sense.ASC, e.Sense.ASCQ, e.Sense.Deferred) +} + +// CheckResponse distinguishes known command rejection from missing or invalid completion evidence. +func CheckResponse(response Response) error { + if !response.Completed { + return &UncertainError{Cause: fmt.Errorf("Tape completion status is absent")} + } + if response.MessageStatus != 0 || response.HostStatus != 0 || response.DriverStatus&0xf != 0 && response.DriverStatus&0xf != 8 { + return &UncertainError{Cause: fmt.Errorf("Tape host/driver status failed")} + } + if response.DriverStatus&0xf == 8 && len(response.Sense) == 0 { + return &UncertainError{Cause: ErrMalformed} + } + if response.Status == 0 { + if len(response.Sense) != 0 { + return &UncertainError{Cause: ErrMalformed} + } + return nil + } + if response.Status != 2 { + if response.Status == 0x18 { + return &UncertainError{Cause: &CommandError{Response: response}} + } + return &CommandError{Response: response} + } + sense, err := DecodeSense(response.Sense) + if err != nil { + return &UncertainError{Cause: err} + } + if sense.Deferred || sense.Key == 6 && sense.ASC == 0x29 { + return &UncertainError{Cause: &CommandError{Response: response, Sense: sense}} + } + return &CommandError{Response: response, Sense: sense} +} + +// CleanUnsupported reports only known optional-command rejection, without another joined cause. +// Cancellation, malformed/deferred responses and unknown completion must remain real failures. +func CleanUnsupported(err error) bool { + if err == ErrUnsupported { + return true + } + if failure, ok := err.(*CommandError); ok { + return rejectedCommand(failure) && Reason(failure) == entity.DeviceReason_DEVICE_REASON_UNSUPPORTED + } + if wrapped, ok := err.(interface{ Unwrap() error }); ok { + return CleanUnsupported(wrapped.Unwrap()) + } + if joined, ok := err.(interface{ Unwrap() []error }); ok { + causes := joined.Unwrap() + if len(causes) == 0 { + return false + } + for _, cause := range causes { + if !CleanUnsupported(cause) { + return false + } + } + return true + } + return false +} + +func currentCommand(failure *CommandError) bool { + response := failure.Response + return response.Completed && response.Status == 2 && response.HostStatus == 0 && response.MessageStatus == 0 && + (response.DriverStatus&0xf == 0 || response.DriverStatus&0xf == 8) && !failure.Sense.Deferred +} diff --git a/internal/media/tape/sg.go b/internal/media/tape/sg.go new file mode 100644 index 0000000..1b4a85c --- /dev/null +++ b/internal/media/tape/sg.go @@ -0,0 +1,213 @@ +package tape + +import ( + "context" + "errors" + "fmt" + "runtime" + "syscall" + "time" + "unsafe" +) + +// These structures follow Linux v6.12 include/scsi/sg.h with native pointer alignment. +// https://github.com/torvalds/linux/blob/v6.12/include/scsi/sg.h +// No SG direct or device-mapped I/O is requested. +type sgHeader struct { + InterfaceID, Direction int32 + CommandLength, SenseLength uint8 + IovecCount uint16 + DataLength uint32 + Data, Command, Sense *byte + Timeout, Flags uint32 + PackID int32 + User *byte + Status, MaskedStatus, MessageStatus, SenseWritten uint8 + HostStatus, DriverStatus uint16 + Residual int32 + Duration, Info uint32 +} + +type sgRequest struct { + State, Orphan, Owned, Problem byte + PackID int32 + User *byte + Duration uint32 + Unused int32 +} + +const ( + sgIO = 0x2285 + sgVersion = 0x2282 + sgReservedSize = 0x2272 + sgSetReservedSize = 0x2275 + sgForcePackID = 0x227b + sgRequestTable = 0x2286 +) + +type sgTransport struct { + *serializedTransport + fd int + limit int + unsettled bool + ioctl func(uintptr, unsafe.Pointer) error + command func(*sgHeader) (bool, error) + reserve func(int) error + capacity *capacityTransport +} + +// TryExecute keeps optional observation visible through the ordinary transport decorators. +// Only the exact capacity command uses the independently owned native query lane. +func (t *sgTransport) TryExecute(ctx context.Context, command Command, onIssue func() error) (Response, bool, error) { + if t.capacity != nil && exactCapacityCommand(command) { + return t.capacity.tryExecute(ctx, command, onIssue) + } + return t.serializedTransport.TryExecute(ctx, command, onIssue) +} + +func (t *sgTransport) Close() error { + // Stop optional admission before waiting for either native owner to settle and release resources. + var queryErr error + if t.capacity != nil { + queryErr = t.capacity.Close() + } + return errors.Join(queryErr, t.serializedTransport.Close()) +} + +// TransferLimit is this descriptor's current reserved buffer, not the hardware's maximum transfer. +func (t *sgTransport) TransferLimit() int { return t.limit } + +// EnsureTransfer reserves the explicitly selected size and refuses an insufficient readback. +// Linux caps this per-descriptor buffer by the adapter; Close releases it without device controls. +func (t *sgTransport) EnsureTransfer(ctx context.Context, size int) error { + // Buffer changes share command admission and never run beside an active command. + if size <= 0 || size > MaxTransferBytes { + return fmt.Errorf("Tape transfer reservation is outside the supported envelope") + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-t.lane: + } + defer func() { t.lane <- struct{}{} }() + if err := ctx.Err(); err != nil { + return err + } + if t.closed || t.uncertain != nil { + return errors.Join(t.uncertain, fmt.Errorf("SG transport is unavailable")) + } + if size <= t.limit { + return nil + } + + // SG_SET_RESERVED_SIZE may round or cap allocation; actual size remains the only usable bound. + value := int32(size) + if err := t.ioctl(sgSetReservedSize, unsafe.Pointer(&value)); err != nil { + return fmt.Errorf("reserve selected SG transfer failed, %w", err) + } + if err := t.ioctl(sgReservedSize, unsafe.Pointer(&value)); err != nil { + return fmt.Errorf("read SG reservation failed, %w", err) + } + limit := min(int(value), MaxTransferBytes) + if t.reserve != nil { + if err := t.reserve(limit); err != nil { + return fmt.Errorf("reserve native command buffers failed, %w", err) + } + } + t.limit = limit + if t.limit < size { + return fmt.Errorf("SG reserved buffer %d is smaller than selected record %d", t.limit, size) + } + return nil +} + +func (t *sgTransport) executeCommand(command Command) (Response, error) { + // Keep caller buffers alive until the synchronous exchange and any settlement finish. + if len(command.Data) > t.limit { + return Response{}, fmt.Errorf("Tape transfer exceeds SG buffer limit") + } + sense := make([]byte, 252) + header := sgHeader{ + InterfaceID: 'S', Direction: int32(command.Direction), CommandLength: uint8(len(command.CDB)), + SenseLength: uint8(len(sense)), DataLength: uint32(len(command.Data)), + Command: &command.CDB[0], Sense: &sense[0], Timeout: uint32(command.Timeout.Milliseconds()), + } + if len(command.Data) != 0 { + header.Data = &command.Data[0] + } + deadline := time.Now().Add(command.Timeout) + var err error + var pending bool + if t.command != nil { + pending, err = t.command(&header) + } else { + err = t.ioctl(sgIO, unsafe.Pointer(&header)) + pending = errors.Is(err, syscall.EINTR) + } + defer runtime.KeepAlive(command) + defer runtime.KeepAlive(sense) + defer runtime.KeepAlive(header) + if err != nil { + // A failed submission or receive cannot authorize reuse before the existing request settles. + if pending { + if settleErr := t.settlePending(deadline); settleErr != nil { + t.unsettled = true + return Response{}, &UncertainError{Cause: errors.Join(err, settleErr)} + } + return Response{}, &UncertainError{Cause: err} + } + if exactCapacityCommand(command) && capacitySubmissionRejected(err) { + // A proven pre-submission rejection disables only the optional query. + return Response{}, fmt.Errorf("capacity query rejected before submission (%v): %w", err, ErrUnsupported) + } + if errors.Is(err, syscall.EINVAL) || errors.Is(err, syscall.ENOTTY) || errors.Is(err, syscall.EACCES) || errors.Is(err, syscall.EPERM) { + return Response{}, fmt.Errorf("SG command rejected, %w", err) + } + return Response{}, &UncertainError{Cause: err} + } + + // Native completion statuses remain evidence even when command acceptance is unknown. + if int(header.SenseWritten) > len(sense) { + return Response{}, &UncertainError{Cause: ErrMalformed} + } + response := Response{ + Completed: true, + Status: header.Status, MessageStatus: header.MessageStatus, HostStatus: header.HostStatus, DriverStatus: header.DriverStatus, + Residual: header.Residual, Sense: append([]byte(nil), sense[:header.SenseWritten]...), + } + if header.Info&6 != 0 { + return response, &UncertainError{Cause: fmt.Errorf("SG completion used an unexpected transfer mode, info=%x", header.Info)} + } + if response.MessageStatus != 0 || response.HostStatus != 0 || response.DriverStatus&0xf != 0 && response.DriverStatus&0xf != 8 { + if err := checkCommandResponse(command, response); onlyCapacityQueryError(err) { + return response, err + } + return response, &UncertainError{Cause: fmt.Errorf("SG host/driver completion failed, host=%x driver=%x", response.HostStatus, response.DriverStatus)} + } + return response, nil +} + +func (t *sgTransport) settlePending(deadline time.Time) error { + // Linux v6.12 sg.c exposes active requests without submitting another command. + // https://github.com/torvalds/linux/blob/v6.12/drivers/scsi/sg.c + for { + var requests [16]sgRequest + if err := t.ioctl(sgRequestTable, unsafe.Pointer(&requests[0])); err != nil { + return fmt.Errorf("observe pending SG command failed, %w", err) + } + active := false + for _, request := range requests { + if request.State > 3 { + return ErrMalformed + } + active = active || request.State == 1 + } + if !active { + return nil + } + if !time.Now().Before(deadline) { + return fmt.Errorf("pending SG command did not settle before its timeout") + } + time.Sleep(min(10*time.Millisecond, time.Until(deadline))) + } +} diff --git a/internal/media/tape/sg_exchange_linux.go b/internal/media/tape/sg_exchange_linux.go new file mode 100644 index 0000000..9d065da --- /dev/null +++ b/internal/media/tape/sg_exchange_linux.go @@ -0,0 +1,205 @@ +//go:build linux + +package tape + +import ( + "errors" + "fmt" + "io" + "math" + "unsafe" + + "golang.org/x/sys/unix" +) + +const sgExchangeSenseBytes = 252 + +// Linux include/scsi/sg.h: SG_FLAG_Q_AT_TAIL requests tail placement, without a priority guarantee. +const sgFlagQueueAtTail = 0x10 + +// sgExchange owns address-stable buffers on one private, force-PackID SG-v3 descriptor. +// Its caller owns the command lane and must establish settlement before releasing the mapping. +type sgExchange struct { + buffer []byte + packID int32 + pending bool + flags uint32 + write func([]byte) (int, error) + read func([]byte) (int, error) + wait func() error +} + +func newSGExchange(fd int) *sgExchange { + return &sgExchange{ + write: func(data []byte) (int, error) { return unix.Write(fd, data) }, + read: func(data []byte) (int, error) { return unix.Read(fd, data) }, + wait: func() error { + // Readiness only permits receiving the existing request; the kernel retains its command timeout. + fds := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}} + if _, err := unix.Poll(fds, -1); err != nil { + return err + } + if fds[0].Revents&unix.POLLIN == 0 { + return fmt.Errorf("SG receive poll has no readable response, events=%x: %w", fds[0].Revents, unix.EIO) + } + return nil + }, + } +} + +func (x *sgExchange) reserve(limit int) error { + // Only a healthy idle lane can replace memory that a submitted request might still reference. + if x.pending { + return &UncertainError{Cause: errors.New("SG request still owns its native buffers")} + } + if limit <= 0 || limit > MaxTransferBytes { + return fmt.Errorf("SG native transfer reservation is outside the supported envelope") + } + size := limit + sgExchangeSenseBytes + 16 + if len(x.buffer) == size { + return nil + } + + // Install a complete new mapping before clearing and releasing the previous idle reservation. + buffer, err := unix.Mmap(-1, 0, size, unix.PROT_READ|unix.PROT_WRITE, unix.MAP_PRIVATE|unix.MAP_ANON) + if err != nil { + return fmt.Errorf("allocate SG native buffers failed, %w", err) + } + previous := x.buffer + x.buffer = buffer + if previous != nil { + clear(previous) + if err := unix.Munmap(previous); err != nil { + return fmt.Errorf("release previous SG native buffers failed, %w", err) + } + } + return nil +} + +func (x *sgExchange) execute(header *sgHeader) error { + // Keep all kernel-retained pointers outside Go memory, without replacing the caller's owned buffers. + if x.pending { + return &UncertainError{Cause: errors.New("SG request has not been received")} + } + limit := len(x.buffer) - sgExchangeSenseBytes - 16 + if limit <= 0 || header.InterfaceID != 'S' || header.CommandLength < 6 || header.CommandLength > 16 || + header.IovecCount != 0 || header.Flags != 0 || header.User != nil || header.Command == nil || + int(header.SenseLength) > sgExchangeSenseBytes || header.SenseLength != 0 && header.Sense == nil || + uint64(header.DataLength) > uint64(limit) || header.DataLength != 0 && header.Data == nil || + header.Direction != int32(NoData) && header.Direction != int32(FromDevice) && header.Direction != int32(ToDevice) || + header.Direction == int32(NoData) && header.DataLength != 0 { + return fmt.Errorf("SG native header or buffer envelope is invalid: %w", ErrMalformed) + } + original := *header + data := x.buffer[:int(original.DataLength)] + sense := x.buffer[limit : limit+sgExchangeSenseBytes] + cdb := x.buffer[limit+sgExchangeSenseBytes:] + clear(data) + clear(sense) + clear(cdb) + copy(cdb, unsafe.Slice(original.Command, int(original.CommandLength))) + if original.Direction == int32(ToDevice) { + copy(data, unsafe.Slice(original.Data, len(data))) + } + defer func() { + if !x.pending { + clear(data) + clear(sense) + clear(cdb) + } + }() + + // PackID is bounded correlation for one request, never a token that permits command resubmission. + if x.packID == math.MaxInt32 { + x.packID = 0 + } + x.packID++ + submitted := sgHeader{InterfaceID: original.InterfaceID, Direction: original.Direction, + CommandLength: original.CommandLength, SenseLength: original.SenseLength, DataLength: original.DataLength, + Command: &cdb[0], Timeout: original.Timeout, PackID: x.packID, Flags: x.flags} + if len(data) != 0 { + submitted.Data = &data[0] + } + if original.SenseLength != 0 { + submitted.Sense = &sense[0] + } + x.pending = true + count, err := x.write(sgHeaderBytes(&submitted)) + if err != nil { + if count <= 0 && (errors.Is(err, unix.EINVAL) || errors.Is(err, unix.ENOTTY) || + errors.Is(err, unix.EACCES) || errors.Is(err, unix.EPERM)) { + x.pending = false + } + return fmt.Errorf("submit SG header failed, %w", err) + } + if count != int(unsafe.Sizeof(submitted)) { + return fmt.Errorf("submit SG header count=%d: %w", count, io.ErrShortWrite) + } + + // Only receives and readiness waits repeat; there is no second write after any submission outcome. + for { + received := sgHeader{InterfaceID: 'S', Direction: submitted.Direction, PackID: submitted.PackID} + count, err := x.read(sgHeaderBytes(&received)) + if count <= 0 && errors.Is(err, unix.EINTR) { + continue + } + if count <= 0 && errors.Is(err, unix.EAGAIN) { + for { + err := x.wait() + if errors.Is(err, unix.EINTR) { + continue + } + if err != nil { + return fmt.Errorf("wait for SG response failed, %w", err) + } + break + } + continue + } + if err != nil { + return fmt.Errorf("receive SG header failed, %w", err) + } + if count != int(unsafe.Sizeof(received)) { + return fmt.Errorf("receive SG header count=%d: %w", count, ErrMalformed) + } + + // A full response must preserve every submitted input field, including its native pointer identities. + input := received + input.Status, input.MaskedStatus, input.MessageStatus, input.SenseWritten = 0, 0, 0, 0 + input.HostStatus, input.DriverStatus, input.Residual, input.Duration, input.Info = 0, 0, 0, 0, 0 + if input != submitted { + return fmt.Errorf("received SG header differs from submitted request: %w", ErrMalformed) + } + x.pending = false + *header = received + header.Data, header.Command, header.Sense, header.User = original.Data, original.Command, original.Sense, original.User + if received.Residual < 0 || uint64(received.Residual) > uint64(original.DataLength) || + received.SenseWritten > original.SenseLength { + return fmt.Errorf("received SG residual or sense count is invalid: %w", ErrMalformed) + } + if original.Direction == int32(FromDevice) { + count := int(original.DataLength) - int(received.Residual) + copy(unsafe.Slice(original.Data, count), data[:count]) + } + copy(unsafe.Slice(original.Sense, int(received.SenseWritten)), sense[:received.SenseWritten]) + return nil + } +} + +func (x *sgExchange) closeBuffer() error { + // The owner has already established settlement; pending alone cannot disprove that external evidence. + if x.buffer == nil { + return nil + } + clear(x.buffer) + if err := unix.Munmap(x.buffer); err != nil { + return fmt.Errorf("release SG native buffers failed, %w", err) + } + x.buffer = nil + x.pending = false + return nil +} + +func sgHeaderBytes(header *sgHeader) []byte { + return unsafe.Slice((*byte)(unsafe.Pointer(header)), int(unsafe.Sizeof(*header))) +} diff --git a/internal/media/tape/sg_exchange_linux_test.go b/internal/media/tape/sg_exchange_linux_test.go new file mode 100644 index 0000000..c2e32a1 --- /dev/null +++ b/internal/media/tape/sg_exchange_linux_test.go @@ -0,0 +1,390 @@ +//go:build linux + +package tape + +import ( + "bytes" + "io" + "math" + "testing" + "unsafe" + + "github.com/stretchr/testify/require" + "golang.org/x/sys/unix" +) + +func sgExchangeTestHeader(data, sense, cdb []byte, direction Direction) sgHeader { + header := sgHeader{InterfaceID: 'S', Direction: int32(direction), CommandLength: uint8(len(cdb)), + SenseLength: uint8(len(sense)), DataLength: uint32(len(data)), Timeout: 600000, Command: &cdb[0]} + if len(data) != 0 { + header.Data = &data[0] + } + if len(sense) != 0 { + header.Sense = &sense[0] + } + return header +} + +func TestSGExchangeReceiveInterruptionsSubmitOnce(t *testing.T) { + // The kernel can retain only native pointers, and the caller's buffers remain unchanged until matched receive. + x := newSGExchange(-1) + require.NoError(t, x.reserve(512)) + t.Cleanup(func() { require.NoError(t, x.closeBuffer()) }) + data, sense, cdb := bytes.Repeat([]byte{9}, 8), bytes.Repeat([]byte{9}, sgExchangeSenseBytes), []byte{8, 0, 0, 0, 8, 0} + header := sgExchangeTestHeader(data, sense, cdb, FromDevice) + original := header + writes, reads, waits := 0, 0, 0 + var submitted sgHeader + x.write = func(raw []byte) (int, error) { + writes++ + submitted = *(*sgHeader)(unsafe.Pointer(&raw[0])) + require.Equal(t, uint32(600000), submitted.Timeout) + require.Equal(t, int32(1), submitted.PackID) + require.NotSame(t, original.Data, submitted.Data) + require.NotSame(t, original.Sense, submitted.Sense) + require.NotSame(t, original.Command, submitted.Command) + for _, pointer := range []*byte{submitted.Data, submitted.Sense, submitted.Command} { + address, base := uintptr(unsafe.Pointer(pointer)), uintptr(unsafe.Pointer(&x.buffer[0])) + require.True(t, address >= base && address < base+uintptr(len(x.buffer))) + } + require.Equal(t, cdb, unsafe.Slice(submitted.Command, int(submitted.CommandLength))) + require.Equal(t, make([]byte, len(data)), unsafe.Slice(submitted.Data, len(data))) + require.Equal(t, make([]byte, len(sense)), unsafe.Slice(submitted.Sense, len(sense))) + return len(raw), nil + } + x.read = func(raw []byte) (int, error) { + reads++ + selector := *(*sgHeader)(unsafe.Pointer(&raw[0])) + require.Equal(t, int32('S'), selector.InterfaceID) + require.Equal(t, submitted.Direction, selector.Direction) + require.Negative(t, selector.Direction, "kernel must recognize the v3 receive selector") + require.Equal(t, submitted.PackID, selector.PackID) + legacyPackID := *(*int32)(unsafe.Pointer(&raw[8])) + require.Zero(t, legacyPackID, "legacy pack_id overlaps the zero command/sense lengths and iovec count") + if reads == 1 { + return -1, unix.EINTR + } + if reads == 2 { + return -1, unix.EAGAIN + } + copy(unsafe.Slice(submitted.Data, len(data)), "abcdeXYZ") + copy(unsafe.Slice(submitted.Sense, len(sense)), []byte{0x70, 0, 5}) + reply := submitted + reply.Status, reply.MaskedStatus, reply.DriverStatus = 2, 1, 8 + reply.SenseWritten, reply.Residual, reply.Duration, reply.Info = 3, 3, 17, 1 + copy(raw, sgHeaderBytes(&reply)) + return len(raw), nil + } + x.wait = func() error { + waits++ + if waits == 1 { + return unix.EINTR + } + return nil + } + + // Repeated receives/poll preserve one submission, raw response fields and bounded copyback. + require.NoError(t, x.execute(&header)) + require.Equal(t, 1, writes) + require.Equal(t, 3, reads) + require.Equal(t, 2, waits) + require.False(t, x.pending) + require.Equal(t, []byte{'a', 'b', 'c', 'd', 'e', 9, 9, 9}, data) + require.Equal(t, []byte{0x70, 0, 5}, sense[:3]) + require.Equal(t, byte(9), sense[3]) + require.Same(t, original.Data, header.Data) + require.Same(t, original.Sense, header.Sense) + require.Same(t, original.Command, header.Command) + require.Equal(t, original.User, header.User) + require.Equal(t, uint8(2), header.Status) + require.Equal(t, uint8(1), header.MaskedStatus) + require.Equal(t, uint16(8), header.DriverStatus) + require.Equal(t, int32(3), header.Residual) + require.Equal(t, uint8(3), header.SenseWritten) + require.Equal(t, uint32(17), header.Duration) + require.Equal(t, uint32(1), header.Info) + require.Equal(t, make([]byte, len(x.buffer)), x.buffer) +} + +func TestSGExchangeSubmissionNeverRetries(t *testing.T) { + for _, tc := range []struct { + name string + count int + err error + pending bool + }{ + {"short", int(unsafe.Sizeof(sgHeader{})) - 1, nil, true}, + {"zero", 0, nil, true}, + {"interrupted", -1, unix.EINTR, true}, + {"busy", -1, unix.EAGAIN, true}, + {"I/O error", -1, unix.EIO, true}, + {"full count plus error", int(unsafe.Sizeof(sgHeader{})), unix.EIO, true}, + {"partial plus rejection", 1, unix.EINVAL, true}, + {"invalid before submit", -1, unix.EINVAL, false}, + {"unsupported before submit", -1, unix.ENOTTY, false}, + {"access before submit", -1, unix.EACCES, false}, + {"permission before submit", -1, unix.EPERM, false}, + } { + t.Run(tc.name, func(t *testing.T) { + // A failed submission never appends or receives; unknown outcomes retain the native WRITE bytes. + x := newSGExchange(-1) + require.NoError(t, x.reserve(512)) + t.Cleanup(func() { require.NoError(t, x.closeBuffer()) }) // The mock has no live kernel request. + data, sense, cdb := []byte("owned secret"), make([]byte, sgExchangeSenseBytes), []byte{0x0a, 0, 0, 0, 12, 0} + header := sgExchangeTestHeader(data, sense, cdb, ToDevice) + original := header + writes := 0 + x.write = func(raw []byte) (int, error) { + writes++ + native := (*sgHeader)(unsafe.Pointer(&raw[0])) + require.Equal(t, data, unsafe.Slice(native.Data, len(data))) + return tc.count, tc.err + } + x.read = func([]byte) (int, error) { t.Fatal("submission retried through receive"); return 0, nil } + x.wait = func() error { t.Fatal("failed submission entered poll"); return nil } + err := x.execute(&header) + if tc.err == nil { + require.ErrorIs(t, err, io.ErrShortWrite) + } else { + require.ErrorIs(t, err, tc.err) + } + require.Equal(t, 1, writes) + require.Equal(t, tc.pending, x.pending) + require.Equal(t, original, header) + + // Pending ownership forbids reuse or replacement; proven pre-submit failure clears copied secrets. + if tc.pending { + require.Equal(t, data, x.buffer[:len(data)]) + require.ErrorIs(t, x.execute(&header), ErrUncertain) + require.ErrorIs(t, x.reserve(1024), ErrUncertain) + require.Equal(t, 1, writes) + } else { + require.Equal(t, make([]byte, len(x.buffer)), x.buffer) + } + }) + } +} + +func TestSGExchangeReceiveRequiresMatchingFullHeader(t *testing.T) { + for _, tc := range []struct { + name string + change func(*sgHeader) + short bool + readErr error + pollErr error + pending bool + }{ + {name: "short header", short: true, pending: true}, + {name: "wrong PackID", change: func(h *sgHeader) { h.PackID++ }, pending: true}, + {name: "wrong layout", change: func(h *sgHeader) { h.InterfaceID = 0 }, pending: true}, + {name: "wrong pointer", change: func(h *sgHeader) { h.Data = h.Command }, pending: true}, + {name: "wrong timeout", change: func(h *sgHeader) { h.Timeout++ }, pending: true}, + {name: "wrong length", change: func(h *sgHeader) { h.DataLength++ }, pending: true}, + {name: "receive error", readErr: unix.EIO, pending: true}, + {name: "receive validation errno", readErr: unix.EINVAL, pending: true}, + {name: "receive permission errno", readErr: unix.EPERM, pending: true}, + {name: "poll failure", pollErr: unix.EIO, pending: true}, + {name: "negative residual", change: func(h *sgHeader) { h.Residual = -1 }}, + {name: "oversized residual", change: func(h *sgHeader) { h.Residual = int32(h.DataLength) + 1 }}, + {name: "oversized sense", change: func(h *sgHeader) { h.SenseWritten = h.SenseLength + 1 }}, + } { + t.Run(tc.name, func(t *testing.T) { + // Invalid correlation retains ownership; matched dequeue settles even when response counts are malformed. + x := newSGExchange(-1) + require.NoError(t, x.reserve(512)) + t.Cleanup(func() { require.NoError(t, x.closeBuffer()) }) // All mock kernel work is settled here. + data, sense, cdb := bytes.Repeat([]byte{9}, 8), bytes.Repeat([]byte{9}, sgExchangeSenseBytes), []byte{8, 0, 0, 0, 8, 0} + header := sgExchangeTestHeader(data, sense, cdb, FromDevice) + original := header + writes, reads := 0, 0 + var submitted sgHeader + x.write = func(raw []byte) (int, error) { + writes++ + submitted = *(*sgHeader)(unsafe.Pointer(&raw[0])) + copy(unsafe.Slice(submitted.Data, len(data)), "new data") + copy(unsafe.Slice(submitted.Sense, len(sense)), "sense") + return len(raw), nil + } + x.read = func(raw []byte) (int, error) { + reads++ + if tc.readErr != nil { + return -1, tc.readErr + } + if tc.pollErr != nil { + return -1, unix.EAGAIN + } + reply := submitted + reply.SenseWritten = 5 + if tc.change != nil { + tc.change(&reply) + } + copy(raw, sgHeaderBytes(&reply)) + if tc.short { + return len(raw) - 1, nil + } + return len(raw), nil + } + x.wait = func() error { return tc.pollErr } + err := x.execute(&header) + if tc.readErr != nil { + require.ErrorIs(t, err, tc.readErr) + } else if tc.pollErr != nil { + require.ErrorIs(t, err, tc.pollErr) + } else { + require.ErrorIs(t, err, ErrMalformed) + } + require.Equal(t, 1, writes) + require.Equal(t, 1, reads) + require.Equal(t, tc.pending, x.pending) + require.Equal(t, bytes.Repeat([]byte{9}, len(data)), data) + require.Equal(t, bytes.Repeat([]byte{9}, len(sense)), sense) + require.Same(t, original.Data, header.Data) + require.Same(t, original.Command, header.Command) + require.Same(t, original.Sense, header.Sense) + if tc.pending { + require.Equal(t, []byte("new data"), x.buffer[:len(data)]) + } else { + require.Equal(t, make([]byte, len(x.buffer)), x.buffer) + } + }) + } +} + +func TestSGExchangeReservationAndNativeReuse(t *testing.T) { + // Reservations replace only idle native mappings and retain their original full slices for unmap. + x := newSGExchange(-1) + require.NoError(t, x.reserve(512)) + t.Cleanup(func() { require.NoError(t, x.closeBuffer()) }) + first := uintptr(unsafe.Pointer(&x.buffer[0])) + require.NoError(t, x.reserve(512)) + require.Equal(t, first, uintptr(unsafe.Pointer(&x.buffer[0]))) + x.buffer[0] = 9 + require.NoError(t, x.reserve(1024)) + require.NotEqual(t, first, uintptr(unsafe.Pointer(&x.buffer[0])), "new mapping is installed while the old address is still reserved") + require.Len(t, x.buffer, 1024+sgExchangeSenseBytes+16) + require.Equal(t, make([]byte, len(x.buffer)), x.buffer) + for _, invalid := range []int{0, -1, MaxTransferBytes + 1} { + require.Error(t, x.reserve(invalid)) + require.Len(t, x.buffer, 1024+sgExchangeSenseBytes+16) + } + + // Native CDB/sense state is cleared between commands and bounded PackIDs wrap only after settled dequeue. + x.packID = math.MaxInt32 - 1 + var submitted sgHeader + var ids []int32 + x.write = func(raw []byte) (int, error) { + submitted = *(*sgHeader)(unsafe.Pointer(&raw[0])) + ids = append(ids, submitted.PackID) + require.Equal(t, uint32(600000), submitted.Timeout) + require.Equal(t, make([]byte, sgExchangeSenseBytes), unsafe.Slice(submitted.Sense, sgExchangeSenseBytes)) + native := unsafe.Slice(submitted.Command, 16) + require.Equal(t, make([]byte, 16-int(submitted.CommandLength)), native[submitted.CommandLength:]) + return len(raw), nil + } + x.read = func(raw []byte) (int, error) { copy(raw, sgHeaderBytes(&submitted)); return len(raw), nil } + for _, length := range []int{16, 6} { + cdb, sense := bytes.Repeat([]byte{9}, length), make([]byte, sgExchangeSenseBytes) + header := sgExchangeTestHeader(nil, sense, cdb, NoData) + require.NoError(t, x.execute(&header)) + require.Same(t, &cdb[0], header.Command) + require.Equal(t, make([]byte, len(x.buffer)), x.buffer) + } + require.Equal(t, []int32{math.MaxInt32, 1}, ids) + require.NoError(t, x.closeBuffer()) + require.Nil(t, x.buffer) + require.NoError(t, x.closeBuffer()) +} + +func BenchmarkSGExchangeReadCopy(b *testing.B) { + for _, size := range []int{256 << 10, 1 << 20} { + b.Run(map[int]string{256 << 10: "256KiB", 1 << 20: "1MiB"}[size], func(b *testing.B) { + // This measures absolute host copying/clearing with mocked native fill, not hardware throughput. + x := newSGExchange(-1) + if err := x.reserve(size); err != nil { + b.Fatal(err) + } + b.Cleanup(func() { + if err := x.closeBuffer(); err != nil { + b.Fatal(err) + } + }) + payload, data, sense := bytes.Repeat([]byte{7}, size), make([]byte, size), make([]byte, sgExchangeSenseBytes) + cdb := []byte{8, 0, byte(size >> 16), byte(size >> 8), byte(size), 0} + header := sgExchangeTestHeader(data, sense, cdb, FromDevice) + var submitted sgHeader + x.write = func(raw []byte) (int, error) { + submitted = *(*sgHeader)(unsafe.Pointer(&raw[0])) + return len(raw), nil + } + x.read = func(raw []byte) (int, error) { + copy(unsafe.Slice(submitted.Data, size), payload) + copy(raw, sgHeaderBytes(&submitted)) + return len(raw), nil + } + b.SetBytes(int64(size)) + b.ReportAllocs() + b.ResetTimer() + for b.Loop() { + if err := x.execute(&header); err != nil { + b.Fatal(err) + } + } + }) + } +} + +func TestSGCapacityExchangeOwnsBuffersAndRequestsTailPlacement(t *testing.T) { + // A held tail-placed query cannot own or overwrite the primary exchange's mapping/PackID. + query, primary := newSGExchange(-1), newSGExchange(-1) + require.NoError(t, query.reserve(MaxQueryBytes)) + require.NoError(t, primary.reserve(512)) + t.Cleanup(func() { require.NoError(t, query.closeBuffer()); require.NoError(t, primary.closeBuffer()) }) + query.flags = sgFlagQueueAtTail + query.packID, primary.packID = 40, 8 + queryEntered, queryRelease := make(chan struct{}), make(chan struct{}) + var queryHeader, primaryHeader sgHeader + query.write = func(raw []byte) (int, error) { + queryHeader = *(*sgHeader)(unsafe.Pointer(&raw[0])) + require.Equal(t, uint32(0x10), queryHeader.Flags) + require.Equal(t, int32(41), queryHeader.PackID) + require.Equal(t, capacityCommand().CDB, unsafe.Slice(queryHeader.Command, int(queryHeader.CommandLength))) + close(queryEntered) + return len(raw), nil + } + query.read = func(raw []byte) (int, error) { + <-queryRelease + copy(unsafe.Slice(queryHeader.Data, int(queryHeader.DataLength)), capacityPage(1, 2)) + queryHeader.Residual = MaxQueryBytes - 20 + copy(raw, sgHeaderBytes(&queryHeader)) + return len(raw), nil + } + primary.write = func(raw []byte) (int, error) { + primaryHeader = *(*sgHeader)(unsafe.Pointer(&raw[0])) + require.Zero(t, primaryHeader.Flags) + require.Equal(t, int32(9), primaryHeader.PackID) + require.NotSame(t, queryHeader.Command, primaryHeader.Command) + require.NotSame(t, queryHeader.Data, primaryHeader.Data) + require.NotSame(t, queryHeader.Sense, primaryHeader.Sense) + require.Equal(t, []byte{7}, unsafe.Slice(primaryHeader.Data, 1)) + return len(raw), nil + } + primary.read = func(raw []byte) (int, error) { copy(raw, sgHeaderBytes(&primaryHeader)); return len(raw), nil } + command := capacityCommand() + header := sgExchangeTestHeader(command.Data, make([]byte, sgExchangeSenseBytes), command.CDB, FromDevice) + queryDone := make(chan error, 1) + go func() { queryDone <- query.execute(&header) }() + <-queryEntered + + // WRITE submits/receives while query is still pending, and query input correlation stays intact. + write := capacityTestWrite() + writeHeader := sgExchangeTestHeader(write.Data, make([]byte, sgExchangeSenseBytes), write.CDB, ToDevice) + require.NoError(t, primary.execute(&writeHeader)) + require.True(t, query.pending) + require.Equal(t, command.CDB, unsafe.Slice(queryHeader.Command, int(queryHeader.CommandLength))) + close(queryRelease) + require.NoError(t, capacityTestWait(t, queryDone)) + require.Equal(t, capacityPage(1, 2), command.Data[:20]) + require.False(t, query.pending) + require.Equal(t, make([]byte, len(primary.buffer)), primary.buffer) + require.Equal(t, make([]byte, len(query.buffer)), query.buffer) +} diff --git a/internal/media/tape/sg_linux.go b/internal/media/tape/sg_linux.go new file mode 100644 index 0000000..adb5727 --- /dev/null +++ b/internal/media/tape/sg_linux.go @@ -0,0 +1,144 @@ +//go:build linux + +package tape + +import ( + "errors" + "fmt" + "unsafe" + + "golang.org/x/sys/unix" +) + +// OpenTransport opens a leased SG character device. Read-only opens accept passive query forms +// only. Its current kernel reserve bounds transfer buffers conservatively; it is not drive capacity. +func OpenTransport(path string, readOnly bool) (Transport, error) { + t, err := openSGTransport(path, readOnly, nil) + if err != nil { + return nil, err + } + if !readOnly { + t.capacity = newCapacityTransport(t.serializedTransport, func() (*serializedTransport, error) { + // Associate the sibling with the still-owned original node, without another device probe. + var original unix.Stat_t + if err := unix.Fstat(t.fd, &original); err != nil { + return nil, fmt.Errorf("stat original SG transport failed, %w", err) + } + query, err := openSGTransport(path, false, &original) + if err != nil { + return nil, err + } + return query.serializedTransport, nil + }) + } + return t, nil +} + +func openSGTransport(path string, readOnly bool, original *unix.Stat_t) (_ *sgTransport, resultErr error) { + // Validate the native access path without issuing a command or opening an st alias. + flags := unix.O_RDWR | unix.O_CLOEXEC | unix.O_NONBLOCK + if readOnly { + flags = unix.O_RDONLY | unix.O_CLOEXEC | unix.O_NONBLOCK + } + fd, err := unix.Open(path, flags, 0) + if err != nil { + return nil, fmt.Errorf("open SG transport failed, %w", err) + } + owned := true + var exchange *sgExchange + defer func() { + if owned { + cleanupErr := unix.Close(fd) + if exchange != nil { + cleanupErr = errors.Join(cleanupErr, exchange.closeBuffer()) + } + if cleanupErr != nil { + resultErr = &UncertainError{Cause: errors.Join(resultErr, cleanupErr)} + } + } + }() + var info unix.Stat_t + if err := unix.Fstat(fd, &info); err != nil { + return nil, fmt.Errorf("stat SG transport failed, %w", err) + } + if original != nil && (info.Dev != original.Dev || info.Rdev != original.Rdev || info.Ino != original.Ino) { + return nil, fmt.Errorf("capacity attachment SG identity differs from primary") + } + if info.Mode&unix.S_IFMT != unix.S_IFCHR { + return nil, fmt.Errorf("SG transport is not a character device: %w", ErrUnsupported) + } + version, err := unix.IoctlGetInt(fd, sgVersion) + if err != nil { + if errors.Is(err, unix.ENOTTY) { + if original == nil { + return nil, fmt.Errorf("SG v3 transport unavailable: %w", errors.Join(ErrUnsupported, err)) + } + return nil, fmt.Errorf("SG v3 transport unavailable (%v): %w", err, ErrUnsupported) + } + return nil, fmt.Errorf("query SG version failed, %w", err) + } + if version < 30000 { + return nil, fmt.Errorf("SG v3 transport unavailable: %w", ErrUnsupported) + } + limit, err := unix.IoctlGetInt(fd, sgReservedSize) + if err != nil { + return nil, fmt.Errorf("query SG buffer limit failed, %w", err) + } + if limit < MaxQueryBytes { + return nil, fmt.Errorf("SG buffer does not fit bounded queries: %w", ErrUnsupported) + } + + // Passive inspection keeps read-only access; its allowlist contains idempotent queries only. + t := &sgTransport{fd: fd, limit: min(limit, MaxTransferBytes)} + t.ioctl = func(command uintptr, pointer unsafe.Pointer) error { + _, _, errno := unix.Syscall(unix.SYS_IOCTL, uintptr(t.fd), command, uintptr(pointer)) + if errno != 0 { + return errno + } + return nil + } + + // State-changing commands separate submission from receipt so signal restart cannot replay them. + if original != nil { + t.limit = MaxQueryBytes + } + if !readOnly { + if err := unix.IoctlSetPointerInt(fd, sgForcePackID, 1); err != nil { + return nil, fmt.Errorf("enable SG response matching failed, %w", err) + } + exchange = newSGExchange(fd) + if original != nil { + exchange.flags = sgFlagQueueAtTail + } + if err := exchange.reserve(t.limit); err != nil { + return nil, fmt.Errorf("reserve native command buffers failed, %w", err) + } + t.reserve = exchange.reserve + t.command = func(header *sgHeader) (bool, error) { + err := exchange.execute(header) + return exchange.pending, err + } + } + + // The command owner also owns settlement, native buffers and final descriptor release. + t.serializedTransport = newSerializedTransport(func(command Command) (Response, error) { + if original != nil && !exactCapacityCommand(command) { + return Response{}, fmt.Errorf("command is outside the private capacity allowlist") + } + if readOnly && !passiveCommand(command) { + return Response{}, fmt.Errorf("command is not a passive Tape query") + } + return t.executeCommand(command) + }, func() error { + if t.unsettled { + return &UncertainError{Cause: fmt.Errorf("SG descriptor retained while command settlement is unknown")} + } + err := unix.Close(t.fd) + if exchange != nil { + err = errors.Join(err, exchange.closeBuffer()) + } + return err + }) + owned = false + return t, nil +} diff --git a/internal/media/tape/sg_other.go b/internal/media/tape/sg_other.go new file mode 100644 index 0000000..9518e19 --- /dev/null +++ b/internal/media/tape/sg_other.go @@ -0,0 +1,5 @@ +//go:build !linux + +package tape + +func OpenTransport(string, bool) (Transport, error) { return nil, ErrUnsupported } diff --git a/internal/media/tape/sg_test.go b/internal/media/tape/sg_test.go new file mode 100644 index 0000000..cde4eac --- /dev/null +++ b/internal/media/tape/sg_test.go @@ -0,0 +1,232 @@ +package tape + +import ( + "context" + "errors" + "io" + "syscall" + "testing" + "time" + "unsafe" +) + +func TestSGHeaderAndRequestLayout(t *testing.T) { + // Native pointer alignment must match Linux sg.h on both 32-bit and 64-bit targets. + header, request := sgHeader{}, sgRequest{} + wantHeader, wantRequest, wantStatus := uintptr(64), uintptr(20), uintptr(44) + if unsafe.Sizeof(uintptr(0)) == 8 { + wantHeader, wantRequest, wantStatus = 88, 24, 64 + } + if unsafe.Sizeof(header) != wantHeader || unsafe.Sizeof(request) != wantRequest || unsafe.Offsetof(header.Status) != wantStatus { + t.Fatalf("SG layout: header=%d request=%d status=%d", unsafe.Sizeof(header), unsafe.Sizeof(request), unsafe.Offsetof(header.Status)) + } +} + +func TestSGInterruptedCommandSettlesWithoutResubmission(t *testing.T) { + // A simulated ioctl signal admits one command and observes its existing request until settlement. + commands, queries := 0, 0 + transport := &sgTransport{limit: MaxQueryBytes} + transport.ioctl = func(operation uintptr, pointer unsafe.Pointer) error { + switch operation { + case sgIO: + commands++ + header := (*sgHeader)(pointer) + if header.Flags != 0 || header.Direction != int32(NoData) || header.Timeout != 1000 || *header.Command != 0 { + t.Errorf("unexpected SG request=%+v", header) + } + return syscall.EINTR + case sgRequestTable: + queries++ + if queries == 1 { + (*[16]sgRequest)(pointer)[0].State = 1 + } + return nil + default: + t.Fatalf("unexpected ioctl=%x", operation) + return nil + } + } + transport.serializedTransport = newSerializedTransport(transport.executeCommand, func() error { return nil }) + defer transport.Close() + command := Command{CDB: make([]byte, 6), Direction: NoData, Timeout: time.Second} + response, err := transport.Execute(context.Background(), command) + if !errors.Is(err, ErrUncertain) || response.Completed || commands != 1 || queries != 2 || transport.unsettled { + t.Fatalf("interruption response=%v err=%v commands=%d queries=%d unsettled=%t", response, err, commands, queries, transport.unsettled) + } + + // Even a settled orphan has no accepted-count evidence and cannot authorize another command. + if _, err := transport.Execute(context.Background(), command); !errors.Is(err, ErrUncertain) || commands != 1 { + t.Fatalf("interrupted command resubmitted: err=%v commands=%d", err, commands) + } +} + +func TestSGInterruptedSettlementBoundAndFailure(t *testing.T) { + for _, tc := range []struct { + name string + state byte + err error + }{{"deadline", 1, nil}, {"query failure", 0, syscall.EIO}, {"malformed table", 4, nil}} { + t.Run(tc.name, func(t *testing.T) { + // Missing settlement evidence preserves unavailability rather than closing an active fd. + queries := 0 + transport := &sgTransport{limit: MaxQueryBytes} + transport.ioctl = func(operation uintptr, pointer unsafe.Pointer) error { + if operation == sgIO { + return syscall.EINTR + } + queries++ + (*[16]sgRequest)(pointer)[0].State = tc.state + return tc.err + } + transport.serializedTransport = newSerializedTransport(transport.executeCommand, func() error { + if !transport.unsettled { + t.Error("unsettled descriptor was releasable") + } + return &UncertainError{Cause: errors.New("descriptor retained")} + }) + command := Command{CDB: make([]byte, 6), Direction: NoData, Timeout: time.Millisecond} + if _, err := transport.Execute(context.Background(), command); !errors.Is(err, ErrUncertain) || !transport.unsettled { + t.Fatalf("settlement error=%v unsettled=%t", err, transport.unsettled) + } + + // The observation loop is bounded; Close returns a truthful error without releasing ownership. + if err := transport.Close(); !errors.Is(err, ErrUncertain) || queries > 3 { + t.Fatalf("close=%v, queries=%d", err, queries) + } + }) + } +} + +func TestSGResponseEvidenceAndPreCommandRejection(t *testing.T) { + // Native completion keeps sense/status/residual fields for the shared decoder. + transport := &sgTransport{limit: MaxQueryBytes} + transport.ioctl = func(_ uintptr, pointer unsafe.Pointer) error { + header := (*sgHeader)(pointer) + header.Status, header.DriverStatus, header.Residual = 2, 8, 5 + data := unsafe.Slice(header.Sense, int(header.SenseLength)) + sense := fixedSense(0x70, 5, 0x20, 0, false, 0, 0) + copy(data, sense) + header.SenseWritten = uint8(len(sense)) + return nil + } + command := Command{CDB: []byte{0x12, 0, 0, 0, 96, 0}, Direction: FromDevice, Data: make([]byte, 96), Timeout: time.Second} + response, err := transport.executeCommand(command) + if err != nil || !response.Completed || response.Residual != 5 || Reason(CheckResponse(response)).String() != "DEVICE_REASON_UNSUPPORTED" { + t.Fatalf("SG evidence=%v, err=%v", response, err) + } + + // A clean ioctl validation/permission rejection leaves no accepted physical command to settle. + for _, rejection := range []error{syscall.EINVAL, syscall.ENOTTY, syscall.EACCES, syscall.EPERM} { + transport.ioctl = func(uintptr, unsafe.Pointer) error { return rejection } + response, err := transport.executeCommand(command) + if !errors.Is(err, rejection) || errors.Is(err, ErrUncertain) || response.Completed { + t.Fatalf("pre-command rejection: response=%v err=%v", response, err) + } + } +} + +func TestSGReservationReadsActualBoundWithoutClampingRequest(t *testing.T) { + for _, actual := range []int{512 << 10, 64 << 10} { + // The initial reserve is not a hardware maximum; larger selected records request per-fd allocation. + calls := 0 + transport := &sgTransport{limit: 32 << 10} + transport.serializedTransport = newSerializedTransport(nil, func() error { return nil }) + reserved := 0 + transport.reserve = func(limit int) error { + reserved = limit + return nil + } + transport.ioctl = func(operation uintptr, pointer unsafe.Pointer) error { + calls++ + value := (*int32)(pointer) + switch operation { + case sgSetReservedSize: + if *value != 512<<10 { + t.Fatalf("selected reservation was clamped: %d", *value) + } + case sgReservedSize: + *value = int32(actual) + default: + t.Fatalf("unexpected reservation ioctl=%x", operation) + } + return nil + } + err := transport.EnsureTransfer(context.Background(), 512<<10) + if (err == nil) != (actual == 512<<10) || calls != 2 || transport.TransferLimit() != actual || reserved != actual { + t.Fatalf("reservation=%v actual=%d native=%d", err, transport.TransferLimit(), reserved) + } + if err := transport.Close(); err != nil { + t.Fatal(err) + } + } +} + +func TestSGSplitFailureSettlesBeforeReuse(t *testing.T) { + for _, cause := range []error{syscall.EINTR, syscall.EINVAL, syscall.EPERM, io.ErrShortWrite} { + t.Run(cause.Error(), func(t *testing.T) { + // Receive errno cannot be mistaken for a clean pre-submission rejection. + calls, queries, closes := 0, 0, 0 + transport := &sgTransport{limit: MaxQueryBytes} + transport.command = func(*sgHeader) (bool, error) { + calls++ + return true, cause + } + transport.ioctl = func(operation uintptr, pointer unsafe.Pointer) error { + if operation != sgRequestTable { + t.Fatalf("unexpected native operation=%x", operation) + } + queries++ + if queries == 1 { + (*[16]sgRequest)(pointer)[0].State = 1 + } + return nil + } + transport.serializedTransport = newSerializedTransport(transport.executeCommand, func() error { + closes++ + return nil + }) + + // Settlement observes the existing request; it cannot replay or publish the command. + command := Command{CDB: make([]byte, 6), Direction: NoData, Timeout: time.Second} + response, err := transport.Execute(context.Background(), command) + if !errors.Is(err, ErrUncertain) || !errors.Is(err, cause) || response.Completed || + calls != 1 || queries != 2 || transport.unsettled { + t.Fatalf("response=%v err=%v calls=%d queries=%d unsettled=%t", + response, err, calls, queries, transport.unsettled) + } + if _, err := transport.Execute(context.Background(), command); !errors.Is(err, ErrUncertain) || calls != 1 { + t.Fatalf("uncertain command replayed: err=%v calls=%d", err, calls) + } + if err := transport.Close(); !errors.Is(err, ErrUncertain) || closes != 1 { + t.Fatalf("settled close=%v closes=%d", err, closes) + } + }) + } +} + +func TestSGSplitUnknownSettlementRetainsOwner(t *testing.T) { + // A native submission error keeps its buffers and descriptor when settlement cannot be observed. + transport := &sgTransport{limit: MaxQueryBytes} + transport.command = func(*sgHeader) (bool, error) { return true, syscall.EIO } + transport.ioctl = func(operation uintptr, _ unsafe.Pointer) error { + if operation != sgRequestTable { + t.Fatalf("unexpected native operation=%x", operation) + } + return syscall.EIO + } + released := false + transport.serializedTransport = newSerializedTransport(transport.executeCommand, func() error { + if transport.unsettled { + return &UncertainError{Cause: errors.New("native owner retained")} + } + released = true + return nil + }) + command := Command{CDB: make([]byte, 6), Direction: NoData, Timeout: time.Second} + if _, err := transport.Execute(context.Background(), command); !errors.Is(err, ErrUncertain) || !transport.unsettled { + t.Fatalf("execute=%v unsettled=%t", err, transport.unsettled) + } + if err := transport.Close(); !errors.Is(err, ErrUncertain) || released { + t.Fatalf("unknown close=%v released=%t", err, released) + } +} diff --git a/internal/media/tape/testdata/README.md b/internal/media/tape/testdata/README.md new file mode 100644 index 0000000..f7d598e --- /dev/null +++ b/internal/media/tape/testdata/README.md @@ -0,0 +1,38 @@ +# Cartridge memory decoder fixtures + +## Sanitization + +The hardware captures below keep the exact protected table (bytes 36–63), unprotected table +(256–351) and v4 directory including all 80 entry checksums (1968–4543). In each v2 EOD page, +bytes 0–39 retain the original framing and numeric fields; only bytes 40–91 containing +identity/vendor information are cleared, and bytes 92–95 are recomputed with the ECMA-319 §13.2 +Reed-Solomon remainder. All other image bytes, including unused pages and CM identity, are zero. +The original page table values and addresses are unchanged; unused page bodies are intentionally +not a fixture for their own decoders. `TestCartridgeDirectoryFixtureHashes` freezes the sanitized +SHA-256 values. + +Each `*-endpoints.json` file contains the numeric endpoints independently returned by READ END OF +WRAP POSITION for the same observation, using the exclusive convention, and no cartridge or device +identity. Images and endpoints are fixed test input, not data generated from the production CM +decoder. They are same-cartridge consistency fixtures, not an independent hardware qualification set. + +## Fixtures + +`cm/hp-lto5-directory-v4.bin` is an 8,160-byte sanitized CM image captured from one HP LTO-5 +configuration. Its v2 EOD pages start at 4544 and 4640, and its endpoints file has 23 entries. + +`cm/hp-lto5-single-partition-v4.bin` is a later single-partition FORMAT on the assigned HP LTO-5. The +independent MAM partition list was `00020001`, while the CM still contains a structurally valid +version-2 EOD page `0x10e` with state zero. The active directory has 35 reported wraps, ending at +logical object 1,285,289. It proves that EOD page presence is not the active partition count; the +decoder receives that count from the device owner. + +`cm/single-partition-v4.bin` is fully synthetic content in the same sanitized table framing. It has +only EOD page 0x104, three valid wraps 0–2, record counts 5/3/89 and filemark counts 1/1/1, giving +exclusive endpoints 6, 10 and 100. Its completed EOD totals are 97 records and 3 filemarks. All +other directory entries carry the invalid dataset marker; no observed hardware count is claimed. + +Synthetic tests separately relocate tables/pages, vary partition starts and counters, and inject +malformed framing. Their checksum reference constructs the generator from roots α^126 through +α^129 over GF(256) with polynomial 0x11d and performs polynomial long division, independently of +the production shift-register implementation. diff --git a/internal/media/tape/testdata/cm/hp-lto5-directory-v4-endpoints.json b/internal/media/tape/testdata/cm/hp-lto5-directory-v4-endpoints.json new file mode 100644 index 0000000..ffed06e --- /dev/null +++ b/internal/media/tape/testdata/cm/hp-lto5-directory-v4-endpoints.json @@ -0,0 +1,117 @@ +[ + { + "wrap": 0, + "partition": 0, + "object": 14 + }, + { + "wrap": 4, + "partition": 1, + "object": 36765 + }, + { + "wrap": 5, + "partition": 1, + "object": 73539 + }, + { + "wrap": 6, + "partition": 1, + "object": 110310 + }, + { + "wrap": 7, + "partition": 1, + "object": 147090 + }, + { + "wrap": 8, + "partition": 1, + "object": 183861 + }, + { + "wrap": 9, + "partition": 1, + "object": 220641 + }, + { + "wrap": 10, + "partition": 1, + "object": 257412 + }, + { + "wrap": 11, + "partition": 1, + "object": 294192 + }, + { + "wrap": 12, + "partition": 1, + "object": 330968 + }, + { + "wrap": 13, + "partition": 1, + "object": 367743 + }, + { + "wrap": 14, + "partition": 1, + "object": 404519 + }, + { + "wrap": 15, + "partition": 1, + "object": 441299 + }, + { + "wrap": 16, + "partition": 1, + "object": 478074 + }, + { + "wrap": 17, + "partition": 1, + "object": 514850 + }, + { + "wrap": 18, + "partition": 1, + "object": 551625 + }, + { + "wrap": 19, + "partition": 1, + "object": 588406 + }, + { + "wrap": 20, + "partition": 1, + "object": 625176 + }, + { + "wrap": 21, + "partition": 1, + "object": 661952 + }, + { + "wrap": 22, + "partition": 1, + "object": 698727 + }, + { + "wrap": 23, + "partition": 1, + "object": 735503 + }, + { + "wrap": 24, + "partition": 1, + "object": 772278 + }, + { + "wrap": 25, + "partition": 1, + "object": 786468 + } +] diff --git a/internal/media/tape/testdata/cm/hp-lto5-directory-v4.bin b/internal/media/tape/testdata/cm/hp-lto5-directory-v4.bin new file mode 100644 index 0000000..e002588 Binary files /dev/null and b/internal/media/tape/testdata/cm/hp-lto5-directory-v4.bin differ diff --git a/internal/media/tape/testdata/cm/hp-lto5-single-partition-v4-endpoints.json b/internal/media/tape/testdata/cm/hp-lto5-single-partition-v4-endpoints.json new file mode 100644 index 0000000..54ac65f --- /dev/null +++ b/internal/media/tape/testdata/cm/hp-lto5-single-partition-v4-endpoints.json @@ -0,0 +1,177 @@ +[ + { + "Partition": 0, + "Wrap": 0, + "Object": 36803 + }, + { + "Partition": 0, + "Wrap": 1, + "Object": 73622 + }, + { + "Partition": 0, + "Wrap": 2, + "Object": 110441 + }, + { + "Partition": 0, + "Wrap": 3, + "Object": 147265 + }, + { + "Partition": 0, + "Wrap": 4, + "Object": 184084 + }, + { + "Partition": 0, + "Wrap": 5, + "Object": 220908 + }, + { + "Partition": 0, + "Wrap": 6, + "Object": 257727 + }, + { + "Partition": 0, + "Wrap": 7, + "Object": 294551 + }, + { + "Partition": 0, + "Wrap": 8, + "Object": 331366 + }, + { + "Partition": 0, + "Wrap": 9, + "Object": 368190 + }, + { + "Partition": 0, + "Wrap": 10, + "Object": 405004 + }, + { + "Partition": 0, + "Wrap": 11, + "Object": 441828 + }, + { + "Partition": 0, + "Wrap": 12, + "Object": 478643 + }, + { + "Partition": 0, + "Wrap": 13, + "Object": 515339 + }, + { + "Partition": 0, + "Wrap": 14, + "Object": 552154 + }, + { + "Partition": 0, + "Wrap": 15, + "Object": 588978 + }, + { + "Partition": 0, + "Wrap": 16, + "Object": 625792 + }, + { + "Partition": 0, + "Wrap": 17, + "Object": 662616 + }, + { + "Partition": 0, + "Wrap": 18, + "Object": 699431 + }, + { + "Partition": 0, + "Wrap": 19, + "Object": 736255 + }, + { + "Partition": 0, + "Wrap": 20, + "Object": 773069 + }, + { + "Partition": 0, + "Wrap": 21, + "Object": 809888 + }, + { + "Partition": 0, + "Wrap": 22, + "Object": 846703 + }, + { + "Partition": 0, + "Wrap": 23, + "Object": 883522 + }, + { + "Partition": 0, + "Wrap": 24, + "Object": 920336 + }, + { + "Partition": 0, + "Wrap": 25, + "Object": 957156 + }, + { + "Partition": 0, + "Wrap": 26, + "Object": 993970 + }, + { + "Partition": 0, + "Wrap": 27, + "Object": 1030789 + }, + { + "Partition": 0, + "Wrap": 28, + "Object": 1067604 + }, + { + "Partition": 0, + "Wrap": 29, + "Object": 1104414 + }, + { + "Partition": 0, + "Wrap": 30, + "Object": 1141228 + }, + { + "Partition": 0, + "Wrap": 31, + "Object": 1178047 + }, + { + "Partition": 0, + "Wrap": 32, + "Object": 1214862 + }, + { + "Partition": 0, + "Wrap": 33, + "Object": 1251681 + }, + { + "Partition": 0, + "Wrap": 34, + "Object": 1285289 + } +] diff --git a/internal/media/tape/testdata/cm/hp-lto5-single-partition-v4.bin b/internal/media/tape/testdata/cm/hp-lto5-single-partition-v4.bin new file mode 100644 index 0000000..9143581 Binary files /dev/null and b/internal/media/tape/testdata/cm/hp-lto5-single-partition-v4.bin differ diff --git a/internal/media/tape/testdata/cm/single-partition-v4.bin b/internal/media/tape/testdata/cm/single-partition-v4.bin new file mode 100644 index 0000000..adf30ec Binary files /dev/null and b/internal/media/tape/testdata/cm/single-partition-v4.bin differ diff --git a/internal/media/tape/transport.go b/internal/media/tape/transport.go new file mode 100644 index 0000000..0deb945 --- /dev/null +++ b/internal/media/tape/transport.go @@ -0,0 +1,211 @@ +// Package tape owns raw sequential-device transport and passive observations. Callers must hold +// the Executor's device lease before opening a transport; configured LTFS adapters remain separate. +package tape + +import ( + "context" + "errors" + "fmt" + "io" + "sync" + "sync/atomic" + "time" +) + +// Direction uses SG's one-way transfer forms. No command transfers in both directions. +type Direction int32 + +const ( + NoData Direction = -1 + ToDevice Direction = -2 + FromDevice Direction = -3 + MaxTransferBytes = 0xffffff + MaxQueryBytes = 4096 +) + +// Command owns its buffers until Execute returns, including after context cancellation. +type Command struct { + CDB []byte + Direction Direction + Data []byte + Timeout time.Duration + validate func(Response) error + onIssue func() error + readDiscard bool +} + +// Response preserves native status and residual evidence; residuals alone do not prove write acceptance. +type Response struct { + Completed bool + Status byte + MessageStatus byte + HostStatus, DriverStatus uint16 + Residual int32 + Sense []byte +} + +// Transport serializes primary commands. An in-flight command settles before cancellation returns; +// unknown physical outcomes remain errors and cannot authorize a later command or publication. +type Transport interface { + Execute(context.Context, Command) (Response, error) + Close() error +} + +var ( + ErrUnsupported = errors.New("raw Tape transport is unsupported") + ErrUncertain = errors.New("Tape command outcome is unknown") + ErrMalformed = errors.New("malformed Tape command response") +) + +// UncertainError retains the underlying transport failure and signals retained device unavailability. +type UncertainError struct{ Cause error } + +func (e *UncertainError) Error() string { return fmt.Sprintf("%s: %v", ErrUncertain, e.Cause) } +func (e *UncertainError) Unwrap() []error { return []error{ErrUncertain, e.Cause} } + +// serializedTransport owns admission, synchronous command lifetime and terminal cleanup. +// The syscall remains synchronous: no cancellation goroutine can release an active descriptor. +type serializedTransport struct { + lane chan struct{} + execute func(Command) (Response, error) + close func() error + closed bool + uncertain error + closeOnce sync.Once + closeErr error + grant func() error + finish func(Command, Response, error) error + observations atomic.Pointer[CommandObservations] +} + +func newSerializedTransport(execute func(Command) (Response, error), close func() error) *serializedTransport { + lane := make(chan struct{}, 1) + lane <- struct{}{} + return &serializedTransport{lane: lane, execute: execute, close: close} +} + +func (t *serializedTransport) Execute(ctx context.Context, command Command) (Response, error) { + response, _, err := t.executeCommand(ctx, command, true) + return response, err +} + +// ExecuteObserved explicitly guarantees the callback's issue boundary and abort semantics. +// Opaque Execute-only transports cannot establish this optional accounting contract. +func (t *serializedTransport) ExecuteObserved(ctx context.Context, command Command, onIssue func() error) (Response, error) { + command.onIssue = onIssue + response, _, err := t.executeCommand(ctx, command, true) + return response, err +} + +// TryExecute admits a passive observation only when the existing command lane is immediately free. +// It never queues ahead of data; an admitted command retains the lane until physical settlement. +func (t *serializedTransport) TryExecute(ctx context.Context, command Command, onIssue func() error) (Response, bool, error) { + if !passiveCommand(command) { + return Response{}, false, fmt.Errorf("optional command is outside passive allowlist") + } + command.onIssue = onIssue + return t.executeCommand(ctx, command, false) +} + +func (t *serializedTransport) executeCommand(ctx context.Context, command Command, wait bool) (Response, bool, error) { + // Reject invalid bounds before taking the command lane or exposing buffers to the kernel. + if len(command.CDB) < 6 || len(command.CDB) > 16 || len(command.Data) > MaxTransferBytes { + return Response{}, false, fmt.Errorf("invalid Tape command or transfer size") + } + if command.Timeout < time.Millisecond || command.Timeout > 24*time.Hour { + return Response{}, false, fmt.Errorf("invalid Tape command timeout") + } + if command.Direction != NoData && command.Direction != ToDevice && command.Direction != FromDevice { + return Response{}, false, fmt.Errorf("invalid Tape transfer direction") + } + if command.Direction == NoData && len(command.Data) != 0 { + return Response{}, false, fmt.Errorf("no-data Tape command has a transfer buffer") + } + + // Cancellation applies to admission; once admitted, this owner waits for the ioctl to settle. + if wait { + select { + case <-ctx.Done(): + return Response{}, false, ctx.Err() + case <-t.lane: + } + } else { + select { + case <-ctx.Done(): + return Response{}, false, ctx.Err() + case <-t.lane: + default: + return Response{}, false, nil + } + } + defer func() { t.lane <- struct{}{} }() + if err := ctx.Err(); err != nil { + return Response{}, false, err + } + if t.closed { + return Response{}, false, io.ErrClosedPipe + } + if t.uncertain != nil { + return Response{}, false, t.uncertain + } + if t.grant != nil { + if err := t.grant(); err != nil { + return Response{}, false, err + } + } + + // Submission/accounting observes the actual issue boundary, never enqueue or command completion. + if command.onIssue != nil { + if err := command.onIssue(); err != nil { + return Response{}, false, err + } + } + observations := t.observations.Load() + var started time.Time + if observations != nil { + started = time.Now() + } + response, err := t.execute(command) + var elapsed time.Duration + if observations != nil { + elapsed = time.Since(started) + } + if !response.Completed && err == nil { + err = &UncertainError{Cause: fmt.Errorf("Tape completion status is absent")} + } + + // Validate physical outcome before releasing the lane to a subsequent command. + if response.Completed { + validationErr := checkCommandResponse(command, response) + if response.Residual < 0 || int64(response.Residual) > int64(len(command.Data)) { + validationErr = &UncertainError{Cause: ErrMalformed} + } + if command.validate != nil { + validationErr = errors.Join(validationErr, command.validate(response)) + } + if errors.Is(validationErr, ErrUncertain) || onlyCapacityQueryError(validationErr) { + err = errors.Join(err, validationErr) + } + } + if t.finish != nil { + err = t.finish(command, response, err) + } + if errors.Is(err, ErrUncertain) { + t.uncertain = err + } + if observations != nil { + observations.record(command, response, err, started, elapsed) + } + return response, true, errors.Join(err, ctx.Err()) +} + +func (t *serializedTransport) Close() error { + // Closing waits behind every active command and never sends a reset, unload or filemark. + t.closeOnce.Do(func() { + <-t.lane + defer func() { t.lane <- struct{}{} }() + t.closed = true + t.closeErr = errors.Join(t.close(), t.uncertain) + }) + return t.closeErr +} diff --git a/internal/media/tape/transport_test.go b/internal/media/tape/transport_test.go new file mode 100644 index 0000000..7b2d6ac --- /dev/null +++ b/internal/media/tape/transport_test.go @@ -0,0 +1,142 @@ +package tape + +import ( + "context" + "errors" + "fmt" + "io" + "sync/atomic" + "testing" + "time" +) + +func TestTransportSerializesAndRetainsActiveCommandThroughCancellation(t *testing.T) { + // A cancelled caller cannot release an active ioctl or let Close race its descriptor. + entered, settle := make(chan struct{}), make(chan struct{}) + var active, closed atomic.Int32 + transport := newSerializedTransport(func(Command) (Response, error) { + if active.Add(1) != 1 { + t.Error("commands overlapped") + } + close(entered) + <-settle + active.Add(-1) + return Response{Completed: true}, nil + }, func() error { closed.Add(1); return nil }) + ctx, cancel := context.WithCancel(context.Background()) + command := Command{CDB: []byte{0, 0, 0, 0, 0, 0}, Direction: NoData, Timeout: time.Second} + finished := make(chan error, 1) + go func() { _, err := transport.Execute(ctx, command); finished <- err }() + <-entered + cancel() + closeDone := make(chan error, 1) + go func() { closeDone <- transport.Close() }() + + // Admission cancellation ends without issuing a second command; active cancellation must settle. + if _, err := transport.Execute(ctx, command); !errors.Is(err, context.Canceled) { + t.Fatalf("queued cancellation=%v", err) + } + select { + case <-finished: + t.Fatal("active command returned before settlement") + case <-time.After(20 * time.Millisecond): + } + if closed.Load() != 0 { + t.Fatal("active descriptor closed") + } + close(settle) + if err := <-finished; !errors.Is(err, context.Canceled) { + t.Fatalf("active cancellation=%v", err) + } + if err := <-closeDone; err != nil { + t.Fatal(err) + } + if _, err := transport.Execute(context.Background(), command); !errors.Is(err, io.ErrClosedPipe) { + t.Fatalf("closed Execute=%v", err) + } + if err := transport.Close(); err != nil || closed.Load() != 1 { + t.Fatalf("Close=%v, count=%d", err, closed.Load()) + } +} + +func TestSemanticUncertaintyStopsFutureCommands(t *testing.T) { + for _, response := range []Response{ + {}, + {Completed: true, HostStatus: 3}, + {Completed: true, Residual: -1}, + {Completed: true, Status: 2, Sense: fixedSense(0x71, 0, 0, 2, true, 0x40, 0)}, + {Completed: true, Status: 2, Sense: []byte{0x70}}, + {Completed: true, Status: 2, Sense: fixedSense(0x70, 6, 0x29, 0, false, 0, 0)}, + } { + // Transport and sense/residual failures poison the serialized lane before its release. + calls := 0 + transport := newSerializedTransport(func(Command) (Response, error) { calls++; return response, nil }, func() error { return nil }) + command := Command{CDB: []byte{0, 0, 0, 0, 0, 0}, Direction: NoData, Timeout: time.Second} + if _, err := transport.Execute(context.Background(), command); !errors.Is(err, ErrUncertain) { + t.Fatalf("uncertain response=%v", err) + } + if _, err := transport.Execute(context.Background(), command); !errors.Is(err, ErrUncertain) || calls != 1 { + t.Fatalf("next Execute=%v, calls=%d", err, calls) + } + _ = transport.Close() + } +} + +func TestCleanUnsupportedOptionalQueryDoesNotPoisonTransport(t *testing.T) { + // An optional query rejection has a known outcome and leaves safe query admission available. + calls := 0 + transport := newSerializedTransport(func(Command) (Response, error) { + calls++ + if calls == 1 { + return Response{Completed: true, Status: 2, Sense: fixedSense(0x70, 5, 0x20, 0, false, 0, 0)}, nil + } + return Response{Completed: true}, nil + }, func() error { return nil }) + defer transport.Close() + command := Command{CDB: []byte{0, 0, 0, 0, 0, 0}, Direction: NoData, Timeout: time.Second} + response, err := transport.Execute(context.Background(), command) + if err != nil || Reason(CheckResponse(response)).String() != "DEVICE_REASON_UNSUPPORTED" { + t.Fatalf("optional rejection=%v, response=%v", err, response) + } + if _, err := transport.Execute(context.Background(), command); err != nil || calls != 2 { + t.Fatalf("later query=%v, calls=%d", err, calls) + } +} + +func TestCleanUnsupportedRequiresEveryCauseToBeKnownRejection(t *testing.T) { + for _, asc := range []byte{0x20, 0x24} { + // Wrapped and joined clean rejections remain optional; any other cause remains a failure. + failure := &CommandError{Response: Response{Completed: true, Status: 2}, Sense: Sense{Key: 5, ASC: asc}} + for _, cause := range []error{ErrUnsupported, failure, fmt.Errorf("query failed, %w", failure), errors.Join(ErrUnsupported, failure)} { + if !CleanUnsupported(cause) { + t.Fatalf("clean rejection refused, cause=%v", cause) + } + } + for _, cause := range []error{context.Canceled, context.DeadlineExceeded, ErrUncertain, ErrMalformed, errors.New("hardware failure"), &UncertainError{Cause: failure}} { + if CleanUnsupported(errors.Join(failure, cause)) { + t.Fatalf("mixed rejection accepted, cause=%v", cause) + } + } + } + + // Unsupported-opcode sense does not excuse deferred, boundary or transport evidence. + for _, change := range []func(*CommandError){ + func(e *CommandError) { e.Response.Completed = false }, + func(e *CommandError) { e.Response.HostStatus = 1 }, + func(e *CommandError) { e.Response.MessageStatus = 1 }, + func(e *CommandError) { e.Response.DriverStatus = 1 }, + func(e *CommandError) { e.Sense.Deferred = true }, + func(e *CommandError) { e.Sense.Filemark = true }, + func(e *CommandError) { e.Sense.EOM = true }, + func(e *CommandError) { e.Sense.ILI = true }, + } { + failure := &CommandError{Response: Response{Completed: true, Status: 2}, Sense: Sense{Key: 5, ASC: 0x20}} + change(failure) + if CleanUnsupported(failure) { + t.Fatalf("incoherent unsupported response accepted: %+v", failure) + } + } + if CleanUnsupported(nil) { + t.Fatal("successful command classified as unsupported") + } +}