mirror of
https://github.com/samuelncui/yatm.git
synced 2026-11-08 04:18:03 +00:00
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:
1 parent
2af707970b
commit
64e8a11029
66 files changed
+14760
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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:])
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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:
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
package tape
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/samuelncui/yatm/entity"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
// WrapEndpoint is a directory endpoint in the convention declared by its WrapMap.
|
||||
type WrapEndpoint struct {
|
||||
Partition uint32
|
||||
Wrap uint32
|
||||
Object uint64
|
||||
}
|
||||
|
||||
// WrapMap retains the documented response order and gaps. AllWraps describes the reporting form,
|
||||
// not H6 coverage; absent Fresh is unknown. Endpoint normalization and costs need separate evidence.
|
||||
type WrapMap struct {
|
||||
Endpoints []WrapEndpoint
|
||||
AllWraps bool
|
||||
ObservedAt time.Time
|
||||
Fresh *bool
|
||||
Source string
|
||||
EndpointConvention entity.TapePositionEndpointConvention
|
||||
valid *atomic.Bool
|
||||
}
|
||||
|
||||
// Valid tracks this opened session's snapshot lifetime independently of freshness or qualification.
|
||||
func (m *WrapMap) Valid() bool { return m != nil && m.valid != nil && m.valid.Load() }
|
||||
|
||||
// REOWPEndpointConvention retains vendor semantics except the qualified HP5 count-based response.
|
||||
func REOWPEndpointConvention(report *entity.InspectDeviceResponse) entity.TapePositionEndpointConvention {
|
||||
if (strings.TrimSpace(report.GetVendor()) == "HP" || strings.TrimSpace(report.GetVendor()) == "HPE") &&
|
||||
strings.TrimSpace(report.GetModel()) == "Ultrium 5-SCSI" && strings.TrimSpace(report.GetFirmware()) == "Z6ED" &&
|
||||
report.GetTape().GetDensityCode() == 0x58 {
|
||||
return entity.TapePositionEndpointConvention_TAPE_POSITION_ENDPOINT_CONVENTION_EXCLUSIVE
|
||||
}
|
||||
return entity.TapePositionEndpointConvention_TAPE_POSITION_ENDPOINT_CONVENTION_INCLUSIVE
|
||||
}
|
||||
|
||||
// Geometry returns a request-owned observation, trying CM once when REOWP has no complete directory.
|
||||
func (d *Device) Geometry(ctx context.Context) (*WrapMap, error) {
|
||||
// CM is an optional preparation query, never part of ordinary Inspect or a per-file operation.
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if d.cmPending && (!d.geometry.Valid() || !d.geometry.AllWraps || len(d.geometry.Endpoints) == 0) {
|
||||
d.cmPending = false
|
||||
if err := d.observeCartridgeDirectory(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Copies retain the same lifetime marker; invalidation cannot leave an earlier copy usable.
|
||||
if !d.geometry.Valid() {
|
||||
return nil, nil
|
||||
}
|
||||
copy := *d.geometry
|
||||
copy.Endpoints = append([]WrapEndpoint(nil), d.geometry.Endpoints...)
|
||||
if copy.Fresh != nil {
|
||||
copy.Fresh = proto.Bool(*copy.Fresh)
|
||||
}
|
||||
return ©, nil
|
||||
}
|
||||
|
||||
func (d *Device) invalidateGeometry() {
|
||||
d.cmPending = false
|
||||
d.actualPartitions = 0
|
||||
if d.geometry != nil {
|
||||
d.geometry.valid.Store(false)
|
||||
}
|
||||
}
|
||||
|
||||
func wrapCommand(all bool) []byte {
|
||||
cdb := []byte{0xa3, 0x1f, 0x45, 0, 0, 0, 0, 0, 0, 0, 0, 0}
|
||||
length := uint32(10)
|
||||
if all {
|
||||
cdb[3], length = 2, MaxQueryBytes
|
||||
}
|
||||
binary.BigEndian.PutUint32(cdb[6:10], length)
|
||||
return cdb
|
||||
}
|
||||
|
||||
func decodeWrapMap(data []byte, all bool) (*WrapMap, bool, error) {
|
||||
// IBM GA32-0928-08 §5.2.20 and HPE 20-STG-L7-TRMV3-ED5 independently document this framing.
|
||||
// HPE source: https://support.hpe.com/hpesc/public/docDisplay?docId=sd00006909en_us
|
||||
// HPE short/long response topics: GUID-D7147C7F-2016-0901-0922-00000000067B / 67E.
|
||||
if len(data) < 4 || data[2] != 0 || data[3] != 0 {
|
||||
return nil, false, ErrMalformed
|
||||
}
|
||||
end := 2 + int(binary.BigEndian.Uint16(data[:2]))
|
||||
if end < 4 || !all && end != 10 || all && (end-4)%12 != 0 {
|
||||
return nil, false, ErrMalformed
|
||||
}
|
||||
if end > len(data) {
|
||||
// Allocation truncation is a bounded partial observation, not proof of a complete populated-wrap map.
|
||||
if all && len(data) == MaxQueryBytes && end > MaxQueryBytes {
|
||||
return nil, false, nil
|
||||
}
|
||||
return nil, false, ErrMalformed
|
||||
}
|
||||
observed := &WrapMap{AllWraps: all, ObservedAt: time.Now(), valid: new(atomic.Bool),
|
||||
EndpointConvention: entity.TapePositionEndpointConvention_TAPE_POSITION_ENDPOINT_CONVENTION_INCLUSIVE}
|
||||
observed.valid.Store(true)
|
||||
if !all {
|
||||
// First-wrap reporting has no partition/wrap identifiers; do not fabricate wrap zero or an association.
|
||||
return observed, true, nil
|
||||
}
|
||||
seen := make(map[[2]uint32]bool)
|
||||
for offset := 4; offset < end; offset += 12 {
|
||||
descriptor := data[offset : offset+12]
|
||||
wrap, partition := uint32(binary.BigEndian.Uint16(descriptor[:2])), uint32(binary.BigEndian.Uint16(descriptor[2:4]))
|
||||
key := [2]uint32{partition, wrap}
|
||||
if descriptor[4] != 0 || descriptor[5] != 0 || seen[key] {
|
||||
return nil, false, ErrMalformed
|
||||
}
|
||||
seen[key] = true
|
||||
object := uint64(0)
|
||||
for _, value := range descriptor[6:12] {
|
||||
object = object<<8 | uint64(value)
|
||||
}
|
||||
observed.Endpoints = append(observed.Endpoints, WrapEndpoint{Partition: partition, Wrap: wrap, Object: object})
|
||||
}
|
||||
return observed, true, nil
|
||||
}
|
||||
|
||||
func (d *Device) inspectGeometry(ctx context.Context) error {
|
||||
// This maintenance-in service action is vendor-defined; only public documented families authorize its meaning.
|
||||
d.invalidateGeometry()
|
||||
d.geometry = nil
|
||||
d.cmPending = true
|
||||
f := feature(d.report, entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_END_OF_WRAP_POSITION)
|
||||
vendor := strings.TrimSpace(d.report.GetVendor())
|
||||
switch vendor {
|
||||
case "IBM":
|
||||
case "HP", "HPE":
|
||||
if !strings.HasPrefix(strings.TrimSpace(d.report.GetModel()), "Ultrium") {
|
||||
f.Detail = "No applicable documented REOWP product family is established"
|
||||
return nil
|
||||
}
|
||||
default:
|
||||
f.Detail = "No applicable documented REOWP protocol family is established"
|
||||
return nil
|
||||
}
|
||||
|
||||
// Preserve exact standard-form checks before considering the narrowly scoped vendor query policy.
|
||||
f.Detail = "Documented REOWP form, complete response and load history remain unobserved"
|
||||
f.Reason = entity.DeviceReason_DEVICE_REASON_NOT_READY
|
||||
action := uint16(0x1f)
|
||||
data, err := reportCommand(ctx, d.transport, 0xa3, &action)
|
||||
if err != nil {
|
||||
return d.geometryReportError(err)
|
||||
}
|
||||
all := true
|
||||
support, err := commandFormSupport(data, wrapCommand(true))
|
||||
if err != nil {
|
||||
return d.geometryReportError(err)
|
||||
}
|
||||
if data[1]&7 == 3 && (len(data) < 16 || data[5] != 0x1f) {
|
||||
return d.geometryReportError(ErrMalformed)
|
||||
}
|
||||
|
||||
// A documented HPE family may observe one exact form when RSOC reports vendor-specific support.
|
||||
// This is query admission, not standard support; unrelated CDB-form validation remains unchanged.
|
||||
vendorReport := vendor != "IBM" && data[1]&7 == 5
|
||||
if vendorReport {
|
||||
if len(data) < 16 || data[0] != 0 || data[1]&0x78 != 0 ||
|
||||
binary.BigEndian.Uint16(data[2:4]) != 12 || data[4] != 0xa3 || data[5] != 0x1f {
|
||||
return d.geometryReportError(ErrMalformed)
|
||||
}
|
||||
if data[1]&0x80 != 0 && (len(data) < 28 || binary.BigEndian.Uint16(data[16:18]) != 10) {
|
||||
return d.geometryReportError(ErrMalformed)
|
||||
}
|
||||
}
|
||||
if !vendorReport && support != entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_SUPPORTED {
|
||||
if support == entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_UNKNOWN {
|
||||
return nil
|
||||
}
|
||||
all = false
|
||||
support, err = commandFormSupport(data, wrapCommand(false))
|
||||
if err != nil {
|
||||
return d.geometryReportError(err)
|
||||
}
|
||||
if support != entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_SUPPORTED {
|
||||
f.Support = support
|
||||
f.Evidence = entity.DeviceFeatureEvidence_DEVICE_FEATURE_EVIDENCE_REPORTED
|
||||
if support == entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_UNSUPPORTED {
|
||||
f.Availability, f.Reason = entity.DeviceFeatureAvailability_DEVICE_FEATURE_AVAILABILITY_UNAVAILABLE, entity.DeviceReason_DEVICE_REASON_UNSUPPORTED
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Retain proven standard-form evidence without treating a vendor-specific report as an accepted query.
|
||||
limits := &entity.TapeGeometryLimits{AllWraps: proto.Bool(all), Source: proto.String("A3/1F/45")}
|
||||
if !vendorReport {
|
||||
f.Support, f.Evidence = entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_PARTIAL, entity.DeviceFeatureEvidence_DEVICE_FEATURE_EVIDENCE_REPORTED
|
||||
f.Limits = &entity.DeviceFeature_Geometry{Geometry: limits}
|
||||
}
|
||||
|
||||
// An absent/unready cartridge cannot produce endpoint facts; an issued failure never tries another form.
|
||||
if !d.report.GetTape().GetMediumPresent() || d.report.Reason == entity.DeviceReason_DEVICE_REASON_NOT_READY {
|
||||
return nil
|
||||
}
|
||||
length := MaxQueryBytes
|
||||
if !all {
|
||||
length = 10
|
||||
}
|
||||
data, err = query(ctx, d.transport, wrapCommand(all), length)
|
||||
if err != nil {
|
||||
return d.geometryReportError(err)
|
||||
}
|
||||
f.Support, f.Evidence = entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_PARTIAL, entity.DeviceFeatureEvidence_DEVICE_FEATURE_EVIDENCE_OBSERVED
|
||||
f.Limits = &entity.DeviceFeature_Geometry{Geometry: limits}
|
||||
|
||||
// Decode only reported objects and identities; observation does not normalize boundaries or establish freshness.
|
||||
observed, complete, err := decodeWrapMap(data, all)
|
||||
if err != nil {
|
||||
return d.geometryReportError(err)
|
||||
}
|
||||
if !complete {
|
||||
f.Detail = "REOWP response exceeds the bounded observation; complete coverage and freshness remain unknown"
|
||||
return nil
|
||||
}
|
||||
observed.Source, observed.EndpointConvention, d.geometry = "A3/1F/45", REOWPEndpointConvention(d.report), observed
|
||||
count := len(observed.Endpoints)
|
||||
if !all {
|
||||
count = 1
|
||||
} else {
|
||||
f.Support = entity.DeviceFeatureSupport_DEVICE_FEATURE_SUPPORT_SUPPORTED
|
||||
}
|
||||
limits.ReportedWraps = proto.Uint32(uint32(count))
|
||||
f.Availability, f.Reason = entity.DeviceFeatureAvailability_DEVICE_FEATURE_AVAILABILITY_READY, 0
|
||||
f.Detail = "REOWP reporting form observed; full-wrap coverage, freshness, geometry and timing remain unvalidated"
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Device) geometryReportError(err error) error {
|
||||
// Optional unsupported/malformed queries do not disable baseline I/O; readiness or ownership loss is attempt-wide.
|
||||
f := feature(d.report, entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_END_OF_WRAP_POSITION)
|
||||
d.invalidateGeometry()
|
||||
f.Availability, f.Reason, f.Detail = entity.DeviceFeatureAvailability_DEVICE_FEATURE_AVAILABILITY_UNKNOWN, Reason(err), err.Error()
|
||||
f.Limits, d.geometry = nil, nil
|
||||
if f.Reason == entity.DeviceReason_DEVICE_REASON_UNSUPPORTED {
|
||||
f.Availability = entity.DeviceFeatureAvailability_DEVICE_FEATURE_AVAILABILITY_UNAVAILABLE
|
||||
}
|
||||
if errors.Is(err, ErrUncertain) || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || f.Reason == entity.DeviceReason_DEVICE_REASON_NO_MEDIUM || f.Reason == entity.DeviceReason_DEVICE_REASON_NOT_READY {
|
||||
return err
|
||||
}
|
||||
d.cmPending = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Device) observeCartridgeDirectory(ctx context.Context) error {
|
||||
// This vendor READ BUFFER ID is documented for HP Ultrium; the decoder separately qualifies CM format.
|
||||
vendor, model := strings.TrimSpace(d.report.GetVendor()), strings.TrimSpace(d.report.GetModel())
|
||||
if vendor != "HP" && vendor != "HPE" || model != "Ultrium 5-SCSI" ||
|
||||
!d.report.GetTape().GetMediumPresent() || d.report.Reason == entity.DeviceReason_DEVICE_REASON_NOT_READY {
|
||||
return nil
|
||||
}
|
||||
// Configure already confirms actual partitions for tar. Pre-mount LTFS needs one MAM observation;
|
||||
// a pending MODE SELECT page or a retained CM EOD descriptor cannot supply this authority.
|
||||
partitions := d.actualPartitions
|
||||
var err error
|
||||
if partitions == 0 {
|
||||
partitions, err = d.partitionCount(ctx)
|
||||
}
|
||||
var data []byte
|
||||
if err == nil {
|
||||
if partitions != 1 && partitions != 2 {
|
||||
err = fmt.Errorf("actual cartridge partition count %d is unsupported: %w", partitions, ErrUnsupported)
|
||||
} else {
|
||||
d.actualPartitions = partitions
|
||||
data, err = readCartridgeMemory(ctx, d.transport)
|
||||
}
|
||||
}
|
||||
if err == nil && len(data) != 0 {
|
||||
var observed *WrapMap
|
||||
observed, err = decodeCartridgeDirectory(data, partitions)
|
||||
if err == nil {
|
||||
d.invalidateGeometry()
|
||||
d.geometry = observed
|
||||
d.actualPartitions = partitions
|
||||
feature(d.report, entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_SOFTWARE_READ_ORDER).Detail =
|
||||
"Cartridge memory directory v4/EOD v2 observed; interpolation and timing require separate qualification"
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// CM never grants REOWP command support. Only uncertain completion or lost ownership aborts preparation.
|
||||
optimization := feature(d.report, entity.DeviceFeatureKind_DEVICE_FEATURE_KIND_SOFTWARE_READ_ORDER)
|
||||
if err == nil {
|
||||
optimization.Detail = "Cartridge memory directory is unavailable"
|
||||
return nil
|
||||
}
|
||||
optimization.Detail = "Cartridge memory directory is unavailable: " + err.Error()
|
||||
reason := Reason(err)
|
||||
var failure *CommandError
|
||||
mediumChanged := errors.As(err, &failure) && failure.Sense.Key == 6 && failure.Sense.ASC == 0x28
|
||||
if errors.Is(err, ErrUncertain) || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) ||
|
||||
mediumChanged || reason == entity.DeviceReason_DEVICE_REASON_NO_MEDIUM || reason == entity.DeviceReason_DEVICE_REASON_NOT_READY {
|
||||
d.invalidateGeometry()
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
package tape
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"github.com/samuelncui/yatm/entity"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
type ltfsLabelCommand struct {
|
||||
cdb []byte
|
||||
bytes int
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
type ltfsLabelFixture struct {
|
||||
*serializedTransport
|
||||
base *deviceFixture
|
||||
commands []ltfsLabelCommand
|
||||
reserve []int
|
||||
reserveErr error
|
||||
onReserve func()
|
||||
shortReserve bool
|
||||
limit int
|
||||
partition int
|
||||
step int
|
||||
xml [2][]byte
|
||||
edit func(int, int, []byte) []byte
|
||||
fail func(Command) (Response, error, bool)
|
||||
}
|
||||
|
||||
func newLTFSLabelFixture(t *testing.T) (*Device, *ltfsLabelFixture) {
|
||||
t.Helper()
|
||||
// Reuse actual inspection and native response decoding; only the physical records are synthetic.
|
||||
f := <fsLabelFixture{base: newDeviceFixture(), limit: 32 << 10,
|
||||
xml: [2][]byte{[]byte("<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))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
//go:build !linux
|
||||
|
||||
package tape
|
||||
|
||||
func OpenTransport(string, bool) (Transport, error) { return nil, ErrUnsupported }
|
||||
@@ -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
@@ -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.
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user