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")
+ }
+}