Own Tape device I/O, inspection and cartridge memory

Add the native SCSI transport with SSC record, navigation, capacity and early-warning observations, cartridge memory barcode read/write and directory decoding, and read-only encryption state. Inspection never positions, writes, loads keys or issues measurement commands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Samuel CuiandClaude Opus 5.5 committed 2026-10-30 00:01:37 +08:00
1 parent 2af707970b
commit 64e8a11029
66 files changed
+14760

No files matched your search

+90
View File
@@ -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
}
+279
View File
@@ -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)
}
}
+276
View File
@@ -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"
}
}
@@ -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)
}
}
}
+452
View File
@@ -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)
})
}
}
@@ -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)
}
})
}
}
+473
View File
@@ -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)
}
}
+212
View File
@@ -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
}
@@ -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)
}
+226
View File
@@ -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:])
}
+507
View File
@@ -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)
}
}
}
+249
View File
@@ -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))
}
}
+47
View File
@@ -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
}
+228
View File
@@ -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)
}
}
}
+224
View File
@@ -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
}
@@ -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)
}
}
+341
View File
@@ -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)
}
})
}
}
+359
View File
@@ -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
}
+174
View File
@@ -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:
}
}
+197
View File
@@ -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
}
+78
View File
@@ -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)
}
})
}
}
+321
View File
@@ -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)
}
+510
View File
@@ -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)
}
}
}
+58
View File
@@ -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
}
+110
View File
@@ -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)
}
+196
View File
@@ -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)
})
}
}
+313
View File
@@ -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 &copy, 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
}
@@ -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")
}
}
+423
View File
@@ -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)
}
})
}
}
+112
View File
@@ -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
}
+93
View File
@@ -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)
}
}
}
+461
View File
@@ -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
}
+198
View File
@@ -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")
}
}
+142
View File
@@ -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
}
+515
View File
@@ -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 := &ltfsLabelFixture{base: newDeviceFixture(), limit: 32 << 10,
xml: [2][]byte{[]byte("<label>zero</label>"), []byte("<label>one</label>")}}
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))
}
})
}
}
+208
View File
@@ -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
}
@@ -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)
}
})
}
}
+491
View File
@@ -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")
}
}
})
}
}
+176
View File
@@ -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
}
+254
View File
@@ -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)
}
}
+173
View File
@@ -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
}
@@ -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)
}
}
@@ -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)
})
}
}
+164
View File
@@ -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)
}
+439
View File
@@ -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
}
@@ -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)
}
}
+497
View File
@@ -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")
}
})
}
}
+223
View File
@@ -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)
}
+128
View File
@@ -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)
}
}
+452
View File
@@ -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))
}
}
}
+204
View File
@@ -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)
}
}
+150
View File
@@ -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
}
+213
View File
@@ -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)))
}
}
+205
View File
@@ -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)))
}
@@ -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)
}
+144
View File
@@ -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
}
+5
View File
@@ -0,0 +1,5 @@
//go:build !linux
package tape
func OpenTransport(string, bool) (Transport, error) { return nil, ErrUnsupported }
+232
View File
@@ -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)
}
}
+38
View File
@@ -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.
@@ -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
}
]
Binary file not shown.
@@ -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
}
]
Binary file not shown.
Binary file not shown.
+211
View File
@@ -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
}
+142
View File
@@ -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")
}
}