// 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)]