You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

169 lines
5.4 KiB

// Package recoveryrequest implements the Rockchip non-Android FIT recovery
// request stored in the misc partition's bootloader_message (BCB) area.
package recoveryrequest
import (
"bytes"
"errors"
"fmt"
"io"
"os"
"regexp"
)
const (
// Offset is the BCB offset selected by the deployed Rockchip U-Boot for a
// non-Android FIT image. It is intentionally different from Android's
// traditional 2 KiB offset.
Offset int64 = 16 * 1024
Size = 2048
commandOffset = 0
commandSize = 32
statusOffset = commandOffset + commandSize
statusSize = 32
recoveryOffset = statusOffset + statusSize
recoverySize = 768
stageOffset = recoveryOffset + recoverySize
stageSize = 32
Command = "boot-recovery"
)
var safeOperationID = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
// Message is the target-owned subset of Android's bootloader_message. Unknown
// or unrelated recovery messages are rejected instead of being reinterpreted.
type Message struct {
OperationID string
}
func Encode(operationID string) ([]byte, error) {
if !safeOperationID.MatchString(operationID) {
return nil, errors.New("recovery operation id must be 1-64 safe characters")
}
encoded := make([]byte, Size)
copy(encoded[commandOffset:commandOffset+commandSize], Command)
recovery := fmt.Sprintf("recovery\n--tera-update\n--operation=%s\n", operationID)
if len(recovery) >= recoverySize {
return nil, errors.New("recovery request exceeds the BCB recovery field")
}
copy(encoded[recoveryOffset:recoveryOffset+recoverySize], recovery)
return encoded, nil
}
func Decode(encoded []byte) (Message, error) {
if len(encoded) != Size {
return Message{}, fmt.Errorf("recovery BCB must be exactly %d bytes", Size)
}
if field(encoded, commandOffset, commandSize) != Command {
return Message{}, errors.New("BCB does not contain a TERA recovery command")
}
recovery := field(encoded, recoveryOffset, recoverySize)
const prefix = "recovery\n--tera-update\n--operation="
if len(recovery) <= len(prefix)+1 || recovery[:len(prefix)] != prefix || recovery[len(recovery)-1] != '\n' {
return Message{}, errors.New("BCB recovery arguments do not match the TERA protocol")
}
operationID := recovery[len(prefix) : len(recovery)-1]
if !safeOperationID.MatchString(operationID) {
return Message{}, errors.New("BCB contains an invalid recovery operation id")
}
return Message{OperationID: operationID}, nil
}
func Clear() []byte { return make([]byte, Size) }
func IsClear(encoded []byte) bool {
return len(encoded) == Size && bytes.Equal(encoded, Clear())
}
func ReadRecordAt(reader io.ReaderAt) ([]byte, error) {
encoded := make([]byte, Size)
read, err := reader.ReadAt(encoded, Offset)
if err != nil || read != Size {
return nil, fmt.Errorf("read recovery BCB record: read %d of %d bytes: %w", read, Size, err)
}
return encoded, nil
}
// ReadAt reads and validates one BCB record without changing the surrounding
// misc partition bytes.
func ReadAt(reader io.ReaderAt) (Message, error) {
encoded, err := ReadRecordAt(reader)
if err != nil {
return Message{}, err
}
return Decode(encoded)
}
// WriteRequestFile arms recovery only when the current record is exactly
// cleared. Production callers must keep allowRegular false so only a block
// device can be used; tests may opt into a bounded regular-file fixture.
func WriteRequestFile(path, operationID string, allowRegular bool) error {
info, err := os.Lstat(path)
if err != nil {
return fmt.Errorf("inspect misc device: %w", err)
}
if info.Mode()&os.ModeSymlink != 0 || (!allowRegular && info.Mode()&os.ModeDevice == 0) ||
(allowRegular && !info.Mode().IsRegular() && info.Mode()&os.ModeDevice == 0) {
return errors.New("misc path is not an approved non-symlink block device")
}
file, err := os.OpenFile(path, os.O_RDWR, 0)
if err != nil {
return fmt.Errorf("open misc device: %w", err)
}
defer file.Close()
current, err := ReadRecordAt(file)
if err != nil {
return err
}
if !IsClear(current) {
return errors.New("misc recovery BCB is not cleared")
}
encoded, err := Encode(operationID)
if err != nil {
return err
}
return WriteAt(file, encoded)
}
// WriteAt writes exactly one BCB record and verifies the bytes by readback.
// Durability is the caller's responsibility when writer is not an *os.File.
func WriteAt(writer interface {
io.ReaderAt
io.WriterAt
}, encoded []byte) error {
if len(encoded) != Size {
return fmt.Errorf("recovery BCB must be exactly %d bytes", Size)
}
written, err := writer.WriteAt(encoded, Offset)
if err != nil || written != Size {
return fmt.Errorf("write recovery BCB: wrote %d of %d bytes: %w", written, Size, err)
}
if file, ok := writer.(*os.File); ok {
if err := file.Sync(); err != nil {
return fmt.Errorf("sync recovery BCB: %w", err)
}
}
readback := make([]byte, Size)
read, err := writer.ReadAt(readback, Offset)
if err != nil || read != Size {
return fmt.Errorf("read back recovery BCB: read %d of %d bytes: %w", read, Size, err)
}
if !bytes.Equal(encoded, readback) {
return errors.New("recovery BCB readback mismatch")
}
return nil
}
func field(encoded []byte, offset, size int) string {
value := encoded[offset : offset+size]
if index := bytes.IndexByte(value, 0); index >= 0 {
value = value[:index]
}
return string(value)
}
// Keep these offsets compile-time visible and guard accidental struct drift.
var _ = [1]struct{}{}[Size-(stageOffset+stageSize+1184)]