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.
437 lines
15 KiB
437 lines
15 KiB
|
2 months ago
|
package update
|
||
|
|
|
||
|
|
import (
|
||
|
|
"archive/tar"
|
||
|
|
"bytes"
|
||
|
|
"compress/gzip"
|
||
|
|
"crypto/ed25519"
|
||
|
|
"crypto/sha256"
|
||
|
|
"encoding/base64"
|
||
|
|
"encoding/hex"
|
||
|
|
"encoding/json"
|
||
|
|
"errors"
|
||
|
|
"fmt"
|
||
|
|
"io"
|
||
|
|
"os"
|
||
|
|
"path"
|
||
|
|
"path/filepath"
|
||
|
|
"regexp"
|
||
|
|
"strconv"
|
||
|
|
"strings"
|
||
|
|
)
|
||
|
|
|
||
|
|
const (
|
||
|
|
manifestPath = "manifest.json"
|
||
|
|
signaturePath = "manifest.sig"
|
||
|
|
maxEntries = 32
|
||
|
|
maxManifest = 64 * 1024
|
||
|
|
maxSignature = 4 * 1024
|
||
|
|
)
|
||
|
|
|
||
|
|
var safeIdentifier = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`)
|
||
|
|
|
||
|
|
type trustAnchor struct {
|
||
|
|
FormatVersion int `json:"format_version"`
|
||
|
|
KeyID string `json:"key_id"`
|
||
|
|
Algorithm string `json:"algorithm"`
|
||
|
|
PublicKey string `json:"public_key"`
|
||
|
|
AllowedProducts []string `json:"allowed_products"`
|
||
|
|
AllowedProfiles []string `json:"allowed_profiles"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type signatureEnvelope struct {
|
||
|
|
Algorithm string `json:"algorithm"`
|
||
|
|
KeyID string `json:"key_id"`
|
||
|
|
Signature string `json:"signature"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type scannedEntry struct {
|
||
|
|
Size int64
|
||
|
|
SHA256 string
|
||
|
|
}
|
||
|
|
|
||
|
|
func VerifyPackage(packagePath, trustDirectory, product, profile, currentVersion string) (Manifest, string, error) {
|
||
|
|
entries, manifestBytes, signatureBytes, err := scanPackage(packagePath)
|
||
|
|
if err != nil {
|
||
|
|
return Manifest{}, "", err
|
||
|
|
}
|
||
|
|
var manifest Manifest
|
||
|
|
if err := decodeSingleJSON(manifestBytes, &manifest); err != nil {
|
||
|
|
return Manifest{}, "", fmt.Errorf("manifest is invalid: %w", err)
|
||
|
|
}
|
||
|
|
var envelope signatureEnvelope
|
||
|
|
if err := decodeSingleJSON(signatureBytes, &envelope); err != nil {
|
||
|
|
return Manifest{}, "", fmt.Errorf("signature envelope is invalid: %w", err)
|
||
|
|
}
|
||
|
|
if err := validateManifest(manifest, entries, product, profile, currentVersion); err != nil {
|
||
|
|
return Manifest{}, "", err
|
||
|
|
}
|
||
|
|
if envelope.Algorithm != "ed25519" || envelope.KeyID == "" || envelope.KeyID != manifest.SigningKeyID {
|
||
|
|
return Manifest{}, "", errors.New("package signer metadata does not match the manifest")
|
||
|
|
}
|
||
|
|
anchor, publicKey, err := loadTrustAnchor(trustDirectory, envelope.KeyID, product, profile)
|
||
|
|
if err != nil {
|
||
|
|
return Manifest{}, "", err
|
||
|
|
}
|
||
|
|
signature, err := base64.StdEncoding.DecodeString(envelope.Signature)
|
||
|
|
if err != nil || len(signature) != ed25519.SignatureSize || !ed25519.Verify(publicKey, manifestBytes, signature) {
|
||
|
|
return Manifest{}, "", errors.New("package signature verification failed")
|
||
|
|
}
|
||
|
|
return manifest, anchor.KeyID, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func scanPackage(packagePath string) (map[string]scannedEntry, []byte, []byte, error) {
|
||
|
|
file, err := os.Open(packagePath)
|
||
|
|
if err != nil {
|
||
|
|
return nil, nil, nil, fmt.Errorf("open staged package: %w", err)
|
||
|
|
}
|
||
|
|
defer file.Close()
|
||
|
|
reader := tar.NewReader(file)
|
||
|
|
entries := make(map[string]scannedEntry)
|
||
|
|
var manifestBytes, signatureBytes []byte
|
||
|
|
for count := 0; ; count++ {
|
||
|
|
header, err := reader.Next()
|
||
|
|
if errors.Is(err, io.EOF) {
|
||
|
|
break
|
||
|
|
}
|
||
|
|
if err != nil {
|
||
|
|
return nil, nil, nil, fmt.Errorf("read package archive: %w", err)
|
||
|
|
}
|
||
|
|
if count >= maxEntries {
|
||
|
|
return nil, nil, nil, errors.New("package contains too many entries")
|
||
|
|
}
|
||
|
|
name := path.Clean(strings.ReplaceAll(header.Name, "\\", "/"))
|
||
|
|
if header.Typeflag != tar.TypeReg || name != header.Name || name == "." || strings.HasPrefix(name, "/") || strings.HasPrefix(name, "../") || strings.Contains(name, "/../") {
|
||
|
|
return nil, nil, nil, fmt.Errorf("unsafe package entry %q", header.Name)
|
||
|
|
}
|
||
|
|
if _, exists := entries[name]; exists {
|
||
|
|
return nil, nil, nil, fmt.Errorf("duplicate package entry %q", name)
|
||
|
|
}
|
||
|
|
if header.Size < 0 {
|
||
|
|
return nil, nil, nil, fmt.Errorf("invalid package entry size for %q", name)
|
||
|
|
}
|
||
|
|
limit := header.Size
|
||
|
|
if name == manifestPath && limit > maxManifest || name == signaturePath && limit > maxSignature {
|
||
|
|
return nil, nil, nil, fmt.Errorf("package metadata entry %q is too large", name)
|
||
|
|
}
|
||
|
|
hash := sha256.New()
|
||
|
|
var metadata bytes.Buffer
|
||
|
|
writer := io.Writer(hash)
|
||
|
|
if name == manifestPath || name == signaturePath {
|
||
|
|
writer = io.MultiWriter(hash, &metadata)
|
||
|
|
}
|
||
|
|
written, err := io.CopyN(writer, reader, limit)
|
||
|
|
if err != nil || written != limit {
|
||
|
|
return nil, nil, nil, fmt.Errorf("read package entry %q: %w", name, err)
|
||
|
|
}
|
||
|
|
entries[name] = scannedEntry{Size: header.Size, SHA256: hex.EncodeToString(hash.Sum(nil))}
|
||
|
|
if name == manifestPath {
|
||
|
|
manifestBytes = append([]byte(nil), metadata.Bytes()...)
|
||
|
|
} else if name == signaturePath {
|
||
|
|
signatureBytes = append([]byte(nil), metadata.Bytes()...)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if len(manifestBytes) == 0 || len(signatureBytes) == 0 {
|
||
|
|
return nil, nil, nil, errors.New("package must contain manifest.json and manifest.sig")
|
||
|
|
}
|
||
|
|
return entries, manifestBytes, signatureBytes, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func validateManifest(manifest Manifest, entries map[string]scannedEntry, product, profile, currentVersion string) error {
|
||
|
|
if manifest.FormatVersion != 1 || !safeIdentifier.MatchString(manifest.PackageID) || !safeIdentifier.MatchString(manifest.SigningKeyID) {
|
||
|
|
return errors.New("manifest identity is invalid")
|
||
|
|
}
|
||
|
|
if manifest.Product != product || manifest.HardwareProfile != profile {
|
||
|
|
return fmt.Errorf("package compatibility mismatch: product=%q profile=%q", manifest.Product, manifest.HardwareProfile)
|
||
|
|
}
|
||
|
|
if manifest.CreatedAt.IsZero() || len(manifest.Payloads) == 0 || len(manifest.Payloads) > 16 {
|
||
|
|
return errors.New("manifest payload metadata is invalid")
|
||
|
|
}
|
||
|
|
if manifest.Recovery.Capability == "" || strings.TrimSpace(manifest.Recovery.Instructions) == "" || len(manifest.Recovery.Instructions) > 512 {
|
||
|
|
return errors.New("manifest recovery guidance is required")
|
||
|
|
}
|
||
|
|
switch manifest.Apply.Capability {
|
||
|
|
case ApplyCapabilityValidationOnly:
|
||
|
|
if manifest.Apply.RebootRequired {
|
||
|
|
return errors.New("validation-only package must not request a reboot")
|
||
|
|
}
|
||
|
|
case ApplyCapabilityRecoveryV1:
|
||
|
|
if !manifest.Apply.RebootRequired || manifest.Recovery.Capability != RecoveryCapabilityOfflineV1 {
|
||
|
|
return errors.New("recovery-v1 package must request reboot and declare offline-resumable-v1 recovery")
|
||
|
|
}
|
||
|
|
default:
|
||
|
|
return fmt.Errorf("unsupported apply capability %q", manifest.Apply.Capability)
|
||
|
|
}
|
||
|
|
packageVersion, err := parseVersion(manifest.Version)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("package version is invalid: %w", err)
|
||
|
|
}
|
||
|
|
current, err := parseVersion(currentVersion)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("device version is invalid: %w", err)
|
||
|
|
}
|
||
|
|
if !manifest.AllowDowngrade && compareVersion(packageVersion, current) < 0 {
|
||
|
|
return errors.New("package downgrade is not allowed")
|
||
|
|
}
|
||
|
|
if manifest.MinimumCurrentVersion != "" {
|
||
|
|
minimum, err := parseVersion(manifest.MinimumCurrentVersion)
|
||
|
|
if err != nil || compareVersion(current, minimum) < 0 {
|
||
|
|
return errors.New("device version is below the package minimum")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
wanted := make(map[string]struct{}, len(manifest.Payloads)+2)
|
||
|
|
wanted[manifestPath] = struct{}{}
|
||
|
|
wanted[signaturePath] = struct{}{}
|
||
|
|
recoveryTargets := make(map[string]struct{})
|
||
|
|
for _, payload := range manifest.Payloads {
|
||
|
|
if !safeIdentifier.MatchString(payload.Name) || !strings.HasPrefix(payload.Path, "payload/") || path.Clean(payload.Path) != payload.Path {
|
||
|
|
return errors.New("manifest payload path is invalid")
|
||
|
|
}
|
||
|
|
if payload.Class != "config" && payload.Class != "application" && payload.Class != "boot" && payload.Class != "full-rootfs" {
|
||
|
|
return fmt.Errorf("unsupported payload class %q", payload.Class)
|
||
|
|
}
|
||
|
|
if payload.Size <= 0 {
|
||
|
|
return fmt.Errorf("payload %q has an invalid size", payload.Path)
|
||
|
|
}
|
||
|
|
if manifest.Apply.Capability == ApplyCapabilityRecoveryV1 {
|
||
|
|
if err := validateRecoveryPayload(payload, recoveryTargets); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
} else if payload.TargetPartition != "" || payload.Encoding != "" || payload.ExpandedSize != 0 || payload.ExpandedSHA256 != "" {
|
||
|
|
return fmt.Errorf("validation-only payload %q must not declare recovery write metadata", payload.Path)
|
||
|
|
}
|
||
|
|
if _, duplicate := wanted[payload.Path]; duplicate {
|
||
|
|
return fmt.Errorf("duplicate manifest payload %q", payload.Path)
|
||
|
|
}
|
||
|
|
entry, found := entries[payload.Path]
|
||
|
|
if !found || entry.Size != payload.Size || !strings.EqualFold(entry.SHA256, payload.SHA256) {
|
||
|
|
return fmt.Errorf("payload digest or size mismatch for %q", payload.Path)
|
||
|
|
}
|
||
|
|
wanted[payload.Path] = struct{}{}
|
||
|
|
}
|
||
|
|
if manifest.Apply.Capability == ApplyCapabilityRecoveryV1 {
|
||
|
|
if _, found := recoveryTargets["rootfs"]; !found {
|
||
|
|
return errors.New("recovery-v1 package must contain one rootfs payload")
|
||
|
|
}
|
||
|
|
if len(recoveryTargets) != len(manifest.Payloads) || len(recoveryTargets) > 2 {
|
||
|
|
return errors.New("recovery-v1 package may contain only one rootfs and one optional boot payload")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if len(entries) != len(wanted) {
|
||
|
|
return errors.New("package contains unlisted entries")
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func validateRecoveryPayload(payload Payload, targets map[string]struct{}) error {
|
||
|
|
wantedTarget := ""
|
||
|
|
switch payload.Class {
|
||
|
|
case "full-rootfs":
|
||
|
|
wantedTarget = "rootfs"
|
||
|
|
case "boot":
|
||
|
|
wantedTarget = "boot"
|
||
|
|
default:
|
||
|
|
return fmt.Errorf("recovery-v1 payload class %q is not allowlisted", payload.Class)
|
||
|
|
}
|
||
|
|
if payload.TargetPartition != wantedTarget {
|
||
|
|
return fmt.Errorf("payload %q must target partition %q", payload.Path, wantedTarget)
|
||
|
|
}
|
||
|
|
if _, duplicate := targets[wantedTarget]; duplicate {
|
||
|
|
return fmt.Errorf("duplicate recovery target partition %q", wantedTarget)
|
||
|
|
}
|
||
|
|
if payload.Encoding != "raw" && payload.Encoding != "gzip" {
|
||
|
|
return fmt.Errorf("payload %q has unsupported recovery encoding %q", payload.Path, payload.Encoding)
|
||
|
|
}
|
||
|
|
if payload.ExpandedSize <= 0 || !validSHA256(payload.ExpandedSHA256) {
|
||
|
|
return fmt.Errorf("payload %q has invalid expanded size or SHA-256", payload.Path)
|
||
|
|
}
|
||
|
|
if payload.Encoding == "raw" && (payload.ExpandedSize != payload.Size || !strings.EqualFold(payload.ExpandedSHA256, payload.SHA256)) {
|
||
|
|
return fmt.Errorf("raw payload %q expanded metadata must match its archive metadata", payload.Path)
|
||
|
|
}
|
||
|
|
targets[wantedTarget] = struct{}{}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func validSHA256(value string) bool {
|
||
|
|
if len(value) != sha256.Size*2 {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
_, err := hex.DecodeString(value)
|
||
|
|
return err == nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// StreamPayload rechecks both the archived and expanded payload digests while
|
||
|
|
// streaming exactly one manifest-approved payload to writer. It never extracts
|
||
|
|
// a package entry to the recovery filesystem.
|
||
|
|
func StreamPayload(packagePath string, payload Payload, writer io.Writer) error {
|
||
|
|
file, err := os.Open(packagePath)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("open validated package payload: %w", err)
|
||
|
|
}
|
||
|
|
defer file.Close()
|
||
|
|
reader := tar.NewReader(file)
|
||
|
|
for {
|
||
|
|
header, err := reader.Next()
|
||
|
|
if errors.Is(err, io.EOF) {
|
||
|
|
return fmt.Errorf("package payload %q is missing", payload.Path)
|
||
|
|
}
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("read validated package payload: %w", err)
|
||
|
|
}
|
||
|
|
if header.Name != payload.Path {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
if header.Typeflag != tar.TypeReg || header.Size != payload.Size {
|
||
|
|
return fmt.Errorf("package payload %q type or size changed", payload.Path)
|
||
|
|
}
|
||
|
|
archivedHash := sha256.New()
|
||
|
|
limited := &io.LimitedReader{R: reader, N: payload.Size}
|
||
|
|
archivedReader := io.TeeReader(limited, archivedHash)
|
||
|
|
expandedHash := sha256.New()
|
||
|
|
counter := &countingWriter{writer: io.MultiWriter(writer, expandedHash)}
|
||
|
|
|
||
|
|
var copyErr error
|
||
|
|
if payload.Encoding == "gzip" {
|
||
|
|
compressed, err := gzip.NewReader(archivedReader)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("open gzip payload %q: %w", payload.Path, err)
|
||
|
|
}
|
||
|
|
_, copyErr = io.CopyN(counter, compressed, payload.ExpandedSize+1)
|
||
|
|
if errors.Is(copyErr, io.EOF) && counter.written == payload.ExpandedSize {
|
||
|
|
copyErr = nil
|
||
|
|
}
|
||
|
|
closeErr := compressed.Close()
|
||
|
|
if copyErr == nil && closeErr != nil {
|
||
|
|
copyErr = closeErr
|
||
|
|
}
|
||
|
|
if _, err := io.Copy(io.Discard, archivedReader); copyErr == nil && err != nil {
|
||
|
|
copyErr = err
|
||
|
|
}
|
||
|
|
} else if payload.Encoding == "raw" {
|
||
|
|
_, copyErr = io.Copy(counter, archivedReader)
|
||
|
|
} else {
|
||
|
|
return fmt.Errorf("unsupported payload encoding %q", payload.Encoding)
|
||
|
|
}
|
||
|
|
if copyErr != nil {
|
||
|
|
return fmt.Errorf("stream payload %q: %w", payload.Path, copyErr)
|
||
|
|
}
|
||
|
|
if limited.N != 0 || counter.written != payload.ExpandedSize ||
|
||
|
|
!strings.EqualFold(hex.EncodeToString(archivedHash.Sum(nil)), payload.SHA256) ||
|
||
|
|
!strings.EqualFold(hex.EncodeToString(expandedHash.Sum(nil)), payload.ExpandedSHA256) {
|
||
|
|
return fmt.Errorf("payload %q changed during recovery streaming", payload.Path)
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
type countingWriter struct {
|
||
|
|
writer io.Writer
|
||
|
|
written int64
|
||
|
|
}
|
||
|
|
|
||
|
|
func (w *countingWriter) Write(value []byte) (int, error) {
|
||
|
|
n, err := w.writer.Write(value)
|
||
|
|
w.written += int64(n)
|
||
|
|
return n, err
|
||
|
|
}
|
||
|
|
|
||
|
|
func loadTrustAnchor(directory, keyID, product, profile string) (trustAnchor, ed25519.PublicKey, error) {
|
||
|
|
if !safeIdentifier.MatchString(keyID) {
|
||
|
|
return trustAnchor{}, nil, errors.New("invalid signer key id")
|
||
|
|
}
|
||
|
|
anchorPath := filepath.Join(directory, keyID+".json")
|
||
|
|
info, err := os.Lstat(anchorPath)
|
||
|
|
if err != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
|
||
|
|
return trustAnchor{}, nil, errors.New("trusted signer is not installed")
|
||
|
|
}
|
||
|
|
data, err := os.ReadFile(anchorPath)
|
||
|
|
if err != nil {
|
||
|
|
return trustAnchor{}, nil, errors.New("trusted signer cannot be read")
|
||
|
|
}
|
||
|
|
var anchor trustAnchor
|
||
|
|
if err := decodeSingleJSON(data, &anchor); err != nil || anchor.FormatVersion != 1 || anchor.KeyID != keyID || anchor.Algorithm != "ed25519" || !contains(anchor.AllowedProducts, product) || !contains(anchor.AllowedProfiles, profile) {
|
||
|
|
return trustAnchor{}, nil, errors.New("trusted signer policy is invalid for this device")
|
||
|
|
}
|
||
|
|
publicKey, err := base64.StdEncoding.DecodeString(anchor.PublicKey)
|
||
|
|
if err != nil || len(publicKey) != ed25519.PublicKeySize {
|
||
|
|
return trustAnchor{}, nil, errors.New("trusted signer public key is invalid")
|
||
|
|
}
|
||
|
|
return anchor, ed25519.PublicKey(publicKey), nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func decodeSingleJSON(data []byte, target any) error {
|
||
|
|
decoder := json.NewDecoder(bytes.NewReader(data))
|
||
|
|
decoder.DisallowUnknownFields()
|
||
|
|
if err := decoder.Decode(target); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
var extra any
|
||
|
|
if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) {
|
||
|
|
return errors.New("must contain one JSON value")
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func contains(values []string, wanted string) bool {
|
||
|
|
for _, value := range values {
|
||
|
|
if value == wanted {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
|
||
|
|
type semanticVersion struct {
|
||
|
|
major, minor, patch int
|
||
|
|
prerelease string
|
||
|
|
}
|
||
|
|
|
||
|
|
func parseVersion(value string) (semanticVersion, error) {
|
||
|
|
core := strings.SplitN(value, "+", 2)[0]
|
||
|
|
parts := strings.SplitN(core, "-", 2)
|
||
|
|
numbers := strings.Split(parts[0], ".")
|
||
|
|
if len(numbers) != 3 {
|
||
|
|
return semanticVersion{}, errors.New("expected MAJOR.MINOR.PATCH")
|
||
|
|
}
|
||
|
|
parsed := semanticVersion{}
|
||
|
|
destinations := []*int{&parsed.major, &parsed.minor, &parsed.patch}
|
||
|
|
for index, number := range numbers {
|
||
|
|
if number == "" || len(number) > 1 && number[0] == '0' {
|
||
|
|
return semanticVersion{}, errors.New("invalid numeric component")
|
||
|
|
}
|
||
|
|
value, err := strconv.Atoi(number)
|
||
|
|
if err != nil || value < 0 {
|
||
|
|
return semanticVersion{}, errors.New("invalid numeric component")
|
||
|
|
}
|
||
|
|
*destinations[index] = value
|
||
|
|
}
|
||
|
|
if len(parts) == 2 {
|
||
|
|
if parts[1] == "" || strings.ContainsAny(parts[1], " /\\") {
|
||
|
|
return semanticVersion{}, errors.New("invalid prerelease")
|
||
|
|
}
|
||
|
|
parsed.prerelease = parts[1]
|
||
|
|
}
|
||
|
|
return parsed, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func compareVersion(left, right semanticVersion) int {
|
||
|
|
for _, pair := range [][2]int{{left.major, right.major}, {left.minor, right.minor}, {left.patch, right.patch}} {
|
||
|
|
if pair[0] < pair[1] {
|
||
|
|
return -1
|
||
|
|
}
|
||
|
|
if pair[0] > pair[1] {
|
||
|
|
return 1
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if left.prerelease == right.prerelease {
|
||
|
|
return 0
|
||
|
|
}
|
||
|
|
if left.prerelease == "" {
|
||
|
|
return 1
|
||
|
|
}
|
||
|
|
if right.prerelease == "" {
|
||
|
|
return -1
|
||
|
|
}
|
||
|
|
return strings.Compare(left.prerelease, right.prerelease)
|
||
|
|
}
|