Skip to content

Instantly share code, notes, and snippets.

@suchmememanyskill
Created September 25, 2026 20:29
Show Gist options
  • Select an option

  • Save suchmememanyskill/0ddf9d45bbe27e54d2cbc37cd59cbd3a to your computer and use it in GitHub Desktop.

Select an option

Save suchmememanyskill/0ddf9d45bbe27e54d2cbc37cd59cbd3a to your computer and use it in GitHub Desktop.
U1 bootlogo injector
#!/usr/bin/env python3
"""Safely replace the two Rockchip boot logos in a Snapmaker U1 boot slot.
The script uses only the Python standard library. By default it performs a
dry-run: the source boot image or selected boot partition is read, validated,
patched in memory, and written to a regular staging file. A block device is
written only when --apply is supplied.
"""
from __future__ import annotations
import argparse
import fcntl
import hashlib
import os
import shutil
import stat
import struct
import sys
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Iterable
EXPECTED_BMP_WIDTH = 480
EXPECTED_BMP_HEIGHT = 320
EXPECTED_BMP_BPP = 24
EXPECTED_BMP_DIB_SIZE = 40
EXPECTED_BMP_PIXEL_OFFSET = 54
EXPECTED_BMP_PIXEL_BYTES = EXPECTED_BMP_WIDTH * EXPECTED_BMP_HEIGHT * 3
EXPECTED_BMP_FILE_SIZE = EXPECTED_BMP_PIXEL_OFFSET + EXPECTED_BMP_PIXEL_BYTES
EXPECTED_BOOT_PARTITION_SIZE = 32 * 1024 * 1024
FDT_MAGIC = 0xD00DFEED
FDT_BEGIN_NODE = 1
FDT_END_NODE = 2
FDT_PROP = 3
FDT_NOP = 4
FDT_END = 9
RESOURCE_BLOCK_SIZE = 512
RESOURCE_HEADER_MAGIC = b"RSCE"
RESOURCE_ENTRY_MAGIC = b"ENTR"
RESOURCE_ENTRY_PATH_OFFSET = 4
RESOURCE_ENTRY_PATH_SIZE = 220
RESOURCE_ENTRY_HASH_OFFSET = 224
RESOURCE_ENTRY_HASH_CAPACITY = 32
RESOURCE_ENTRY_HASH_SIZE_OFFSET = 256
RESOURCE_ENTRY_CONTENT_OFFSET_OFFSET = 260
RESOURCE_ENTRY_CONTENT_SIZE_OFFSET = 264
BLKGETSIZE64 = 0x80081272
class InjectionError(RuntimeError):
"""A validation or safety check failed."""
@dataclass(frozen=True)
class FdtProperty:
name: str
value: bytes
value_offset: int
@dataclass
class FdtNode:
path: str
properties: dict[str, FdtProperty]
@dataclass(frozen=True)
class ResourceEntry:
path: str
entry_offset: int
hash_size: int
content_block: int
content_size: int
@property
def content_offset(self) -> int:
return self.content_block * RESOURCE_BLOCK_SIZE
@dataclass(frozen=True)
class Source:
path: Path
display_name: str
slot: str | None
is_block_device: bool
size: int
def fail(message: str) -> None:
raise InjectionError(message)
def align4(value: int) -> int:
return (value + 3) & ~3
def unpack_u32_be(data: bytes | bytearray, offset: int, description: str) -> int:
if offset < 0 or offset + 4 > len(data):
fail(f"Truncated {description}")
return struct.unpack_from(">I", data, offset)[0]
def decode_c_string(data: bytes, offset: int, description: str) -> str:
if offset < 0 or offset >= len(data):
fail(f"Invalid string offset for {description}")
end = data.find(b"\0", offset)
if end < 0:
fail(f"Unterminated string for {description}")
try:
return data[offset:end].decode("ascii")
except UnicodeDecodeError as exc:
raise InjectionError(f"Non-ASCII string for {description}") from exc
def validate_bmp(path: Path) -> bytes:
try:
data = path.read_bytes()
except OSError as exc:
raise InjectionError(f"Cannot read BMP {path}: {exc}") from exc
if len(data) < EXPECTED_BMP_PIXEL_OFFSET:
fail(f"BMP is truncated: {len(data)} bytes")
if data[:2] != b"BM":
fail("BMP signature is not 'BM'")
declared_size = struct.unpack_from("<I", data, 2)[0]
pixel_offset = struct.unpack_from("<I", data, 10)[0]
dib_size = struct.unpack_from("<I", data, 14)[0]
width = struct.unpack_from("<i", data, 18)[0]
height = struct.unpack_from("<i", data, 22)[0]
planes = struct.unpack_from("<H", data, 26)[0]
bits_per_pixel = struct.unpack_from("<H", data, 28)[0]
compression = struct.unpack_from("<I", data, 30)[0]
declared_pixel_size = struct.unpack_from("<I", data, 34)[0]
if dib_size != EXPECTED_BMP_DIB_SIZE:
fail(
f"BMP DIB header is {dib_size} bytes; expected "
f"{EXPECTED_BMP_DIB_SIZE}"
)
if pixel_offset != EXPECTED_BMP_PIXEL_OFFSET:
fail(
f"BMP pixel offset is {pixel_offset}; expected "
f"{EXPECTED_BMP_PIXEL_OFFSET}"
)
if width != EXPECTED_BMP_WIDTH or abs(height) != EXPECTED_BMP_HEIGHT:
fail(
f"BMP resolution is {width}x{abs(height)}; expected "
f"{EXPECTED_BMP_WIDTH}x{EXPECTED_BMP_HEIGHT}"
)
if planes != 1:
fail(f"BMP has {planes} color planes; expected 1")
if bits_per_pixel != EXPECTED_BMP_BPP:
fail(
f"BMP is {bits_per_pixel}-bit; expected "
f"{EXPECTED_BMP_BPP}-bit"
)
if compression != 0:
fail(f"BMP compression mode is {compression}; expected uncompressed")
if declared_pixel_size not in (0, EXPECTED_BMP_PIXEL_BYTES):
fail(
f"BMP declares {declared_pixel_size} pixel bytes; expected "
f"{EXPECTED_BMP_PIXEL_BYTES}"
)
if declared_size != len(data):
fail(
f"BMP header declares {declared_size} bytes, but the file is "
f"{len(data)} bytes"
)
if len(data) != EXPECTED_BMP_FILE_SIZE:
fail(
f"BMP file is {len(data)} bytes; expected exactly "
f"{EXPECTED_BMP_FILE_SIZE} bytes"
)
orientation = "top-down" if height < 0 else "bottom-up"
print(
f"BMP validated: {width}x{abs(height)}, {bits_per_pixel}-bit, "
f"uncompressed, {orientation}, {len(data)} bytes"
)
return data
def parse_fdt(data: bytes | bytearray) -> dict[str, FdtNode]:
if len(data) < 40:
fail("Boot image is too small to contain an FDT header")
(
magic,
total_size,
struct_offset,
strings_offset,
_reserve_map_offset,
version,
last_compatible_version,
_boot_cpu_id,
strings_size,
struct_size,
) = struct.unpack_from(">10I", data, 0)
if magic != FDT_MAGIC:
fail(f"Invalid FIT/FDT magic 0x{magic:08x}")
if version < 17 or last_compatible_version > 17:
fail(
f"Unsupported FDT version {version} "
f"(last compatible {last_compatible_version})"
)
if total_size > len(data):
fail(f"FDT total size {total_size} exceeds source size {len(data)}")
if struct_offset + struct_size > total_size:
fail("FDT structure block is outside the FDT")
if strings_offset + strings_size > total_size:
fail("FDT strings block is outside the FDT")
strings = bytes(data[strings_offset : strings_offset + strings_size])
nodes: dict[str, FdtNode] = {}
stack: list[str] = []
position = struct_offset
structure_end = struct_offset + struct_size
saw_end = False
while position < structure_end:
token = unpack_u32_be(data, position, "FDT token")
position += 4
if token == FDT_BEGIN_NODE:
name_end = bytes(data).find(b"\0", position, structure_end)
if name_end < 0:
fail("Unterminated FDT node name")
try:
name = bytes(data[position:name_end]).decode("ascii")
except UnicodeDecodeError as exc:
raise InjectionError("Non-ASCII FDT node name") from exc
position = align4(name_end + 1)
if not stack:
if name:
fail("FDT root node has a non-empty name")
path = "/"
else:
path = stack[-1].rstrip("/") + "/" + name
if path in nodes:
fail(f"Duplicate FDT node {path}")
nodes[path] = FdtNode(path=path, properties={})
stack.append(path)
elif token == FDT_END_NODE:
if not stack:
fail("Unexpected FDT_END_NODE")
stack.pop()
elif token == FDT_PROP:
if not stack:
fail("FDT property outside a node")
length = unpack_u32_be(data, position, "FDT property length")
name_offset = unpack_u32_be(
data, position + 4, "FDT property name offset"
)
position += 8
if position + length > structure_end:
fail("FDT property extends outside the structure block")
name = decode_c_string(strings, name_offset, "FDT property")
node = nodes[stack[-1]]
if name in node.properties:
fail(f"Duplicate property {name} in {node.path}")
node.properties[name] = FdtProperty(
name=name,
value=bytes(data[position : position + length]),
value_offset=position,
)
position = align4(position + length)
elif token == FDT_NOP:
continue
elif token == FDT_END:
if stack:
fail("FDT ended before all nodes were closed")
saw_end = True
break
else:
fail(f"Unknown FDT token 0x{token:08x}")
if not saw_end:
fail("FDT structure has no FDT_END token")
return nodes
def immediate_children(
nodes: dict[str, FdtNode], parent_path: str
) -> list[FdtNode]:
prefix = parent_path.rstrip("/") + "/"
return [
node
for path, node in nodes.items()
if path.startswith(prefix) and "/" not in path[len(prefix) :]
]
def required_property(node: FdtNode, name: str) -> FdtProperty:
prop = node.properties.get(name)
if prop is None:
fail(f"Required FIT property {node.path}:{name} is missing")
return prop
def property_u32(node: FdtNode, name: str) -> int:
prop = required_property(node, name)
if len(prop.value) != 4:
fail(f"FIT property {node.path}:{name} is not a 32-bit value")
return struct.unpack(">I", prop.value)[0]
def property_string(node: FdtNode, name: str) -> str:
prop = required_property(node, name)
if not prop.value.endswith(b"\0"):
fail(f"FIT property {node.path}:{name} is not NUL-terminated")
try:
return prop.value[:-1].decode("ascii")
except UnicodeDecodeError as exc:
raise InjectionError(
f"FIT property {node.path}:{name} is not ASCII"
) from exc
def find_sha256_hash_node(
nodes: dict[str, FdtNode], image_node: FdtNode
) -> FdtNode:
matching: list[FdtNode] = []
for child in immediate_children(nodes, image_node.path):
if not child.path.rsplit("/", 1)[-1].startswith("hash"):
continue
if property_string(child, "algo") == "sha256":
matching.append(child)
if len(matching) != 1:
fail(
f"Expected exactly one SHA-256 hash below {image_node.path}; "
f"found {len(matching)}"
)
value = required_property(matching[0], "value")
if len(value.value) != hashlib.sha256().digest_size:
fail(f"SHA-256 value below {image_node.path} is not 32 bytes")
return matching[0]
def validate_fit(
data: bytes | bytearray,
) -> tuple[dict[str, FdtNode], FdtNode, FdtProperty, int, int]:
nodes = parse_fdt(data)
images = nodes.get("/images")
configurations = nodes.get("/configurations")
if images is None or configurations is None:
fail("FIT does not contain /images and /configurations")
image_nodes = immediate_children(nodes, "/images")
image_names = {node.path.rsplit("/", 1)[-1] for node in image_nodes}
if image_names != {"fdt", "kernel", "resource"}:
fail(
"Unexpected FIT image set: "
+ ", ".join(sorted(image_names))
+ " (expected fdt, kernel, resource)"
)
for path, node in nodes.items():
if not path.startswith("/configurations/"):
continue
basename = path.rsplit("/", 1)[-1]
if basename.startswith("signature"):
fail(
f"Signed FIT configuration detected at {path}; refusing to "
"invalidate a signature"
)
algo = node.properties.get("algo")
if algo and b"rsa" in algo.value.lower():
fail(
f"Signed FIT configuration algorithm detected at {path}; "
"refusing to continue"
)
resource_node = nodes["/images/resource"]
if property_string(resource_node, "type") != "multi":
fail("FIT resource image type is not 'multi'")
if property_string(resource_node, "compression") != "none":
fail("FIT resource image is compressed")
occupied_ranges: list[tuple[int, int, str]] = []
resource_hash_node: FdtNode | None = None
resource_hash_property: FdtProperty | None = None
resource_position = 0
resource_size = 0
for image_node in image_nodes:
name = image_node.path.rsplit("/", 1)[-1]
position = property_u32(image_node, "data-position")
size = property_u32(image_node, "data-size")
end = position + size
if position % RESOURCE_BLOCK_SIZE:
fail(f"FIT image {name} position is not 512-byte aligned")
if size <= 0 or end > len(data):
fail(
f"FIT image {name} range 0x{position:x}-0x{end:x} is "
"outside the source"
)
for other_start, other_end, other_name in occupied_ranges:
if position < other_end and other_start < end:
fail(f"FIT images {name} and {other_name} overlap")
occupied_ranges.append((position, end, name))
hash_node = find_sha256_hash_node(nodes, image_node)
hash_property = required_property(hash_node, "value")
actual_hash = hashlib.sha256(data[position:end]).digest()
if actual_hash != hash_property.value:
fail(f"Stored SHA-256 does not match FIT image {name}")
if name == "resource":
resource_hash_node = hash_node
resource_hash_property = hash_property
resource_position = position
resource_size = size
if resource_hash_node is None or resource_hash_property is None:
fail("FIT resource SHA-256 node was not found")
return (
nodes,
resource_node,
resource_hash_property,
resource_position,
resource_size,
)
def parse_resource(data: bytes | bytearray) -> list[ResourceEntry]:
if len(data) < RESOURCE_BLOCK_SIZE:
fail("Rockchip resource image is truncated")
if data[:4] != RESOURCE_HEADER_MAGIC:
fail("Rockchip resource header magic is not RSCE")
resource_version, table_version = struct.unpack_from("<HH", data, 4)
header_blocks = data[8]
table_offset_blocks = data[9]
entry_blocks = data[10]
entry_count = struct.unpack_from("<I", data, 12)[0]
if (resource_version, table_version) != (0, 0):
fail(
f"Unsupported resource/table version "
f"{resource_version}.{table_version}"
)
if header_blocks != 1 or table_offset_blocks != 1 or entry_blocks != 1:
fail(
"Unsupported Rockchip resource header/table block layout "
f"({header_blocks}, {table_offset_blocks}, {entry_blocks})"
)
if entry_count <= 0 or entry_count > 64:
fail(f"Unreasonable Rockchip resource entry count {entry_count}")
entries: list[ResourceEntry] = []
seen_paths: set[str] = set()
table_start = table_offset_blocks * RESOURCE_BLOCK_SIZE
for index in range(entry_count):
entry_offset = table_start + index * entry_blocks * RESOURCE_BLOCK_SIZE
if entry_offset + RESOURCE_BLOCK_SIZE > len(data):
fail(f"Resource index entry {index} is truncated")
if data[entry_offset : entry_offset + 4] != RESOURCE_ENTRY_MAGIC:
fail(f"Resource index entry {index} magic is not ENTR")
path_bytes = bytes(
data[
entry_offset
+ RESOURCE_ENTRY_PATH_OFFSET : entry_offset
+ RESOURCE_ENTRY_PATH_OFFSET
+ RESOURCE_ENTRY_PATH_SIZE
]
)
nul = path_bytes.find(b"\0")
if nul < 0:
fail(f"Resource index entry {index} path is not terminated")
try:
path = path_bytes[:nul].decode("ascii")
except UnicodeDecodeError as exc:
raise InjectionError(
f"Resource index entry {index} path is not ASCII"
) from exc
if not path or path in seen_paths:
fail(f"Invalid or duplicate resource path {path!r}")
seen_paths.add(path)
hash_size = struct.unpack_from(
"<I", data, entry_offset + RESOURCE_ENTRY_HASH_SIZE_OFFSET
)[0]
content_block = struct.unpack_from(
"<I", data, entry_offset + RESOURCE_ENTRY_CONTENT_OFFSET_OFFSET
)[0]
content_size = struct.unpack_from(
"<I", data, entry_offset + RESOURCE_ENTRY_CONTENT_SIZE_OFFSET
)[0]
if hash_size not in (20, 32):
fail(f"Resource {path} has unsupported hash size {hash_size}")
content_offset = content_block * RESOURCE_BLOCK_SIZE
if (
content_size <= 0
or content_offset < table_start + entry_count * RESOURCE_BLOCK_SIZE
or content_offset + content_size > len(data)
):
fail(f"Resource {path} has an invalid content range")
entry = ResourceEntry(
path=path,
entry_offset=entry_offset,
hash_size=hash_size,
content_block=content_block,
content_size=content_size,
)
content = data[content_offset : content_offset + content_size]
hash_function = hashlib.sha1 if hash_size == 20 else hashlib.sha256
actual_hash = hash_function(content).digest()
stored_hash = bytes(
data[
entry_offset
+ RESOURCE_ENTRY_HASH_OFFSET : entry_offset
+ RESOURCE_ENTRY_HASH_OFFSET
+ hash_size
]
)
if actual_hash != stored_hash:
fail(f"Stored hash does not match resource {path}")
entries.append(entry)
ranges = sorted(
(
entry.content_offset,
entry.content_offset + entry.content_size,
entry.path,
)
for entry in entries
)
for index in range(1, len(ranges)):
if ranges[index][0] < ranges[index - 1][1]:
fail(
f"Resource entries {ranges[index - 1][2]} and "
f"{ranges[index][2]} overlap"
)
return entries
def entry_capacity(
entry: ResourceEntry, entries: Iterable[ResourceEntry], resource_size: int
) -> int:
later_offsets = [
candidate.content_offset
for candidate in entries
if candidate.content_offset > entry.content_offset
]
end = min(later_offsets) if later_offsets else resource_size
return end - entry.content_offset
def patch_resource(resource_data: bytes, bmp_data: bytes) -> bytes:
resource = bytearray(resource_data)
entries = parse_resource(resource)
by_path = {entry.path: entry for entry in entries}
if set(by_path) != {"rk-kernel.dtb", "logo.bmp", "logo_kernel.bmp"}:
fail(
"Unexpected resource set: "
+ ", ".join(sorted(by_path))
+ " (expected rk-kernel.dtb, logo.bmp, logo_kernel.bmp)"
)
for target in ("logo.bmp", "logo_kernel.bmp"):
entry = by_path[target]
if entry.hash_size != hashlib.sha1().digest_size:
fail(f"Resource {target} does not use the expected SHA-1 hash")
capacity = entry_capacity(entry, entries, len(resource))
if len(bmp_data) > capacity:
fail(
f"BMP is {len(bmp_data)} bytes but {target} has only "
f"{capacity} bytes of allocated space"
)
clear_size = max(entry.content_size, len(bmp_data))
start = entry.content_offset
resource[start : start + clear_size] = b"\0" * clear_size
resource[start : start + len(bmp_data)] = bmp_data
digest = hashlib.sha1(bmp_data).digest()
hash_offset = entry.entry_offset + RESOURCE_ENTRY_HASH_OFFSET
resource[hash_offset : hash_offset + len(digest)] = digest
struct.pack_into(
"<I",
resource,
entry.entry_offset + RESOURCE_ENTRY_CONTENT_SIZE_OFFSET,
len(bmp_data),
)
verified_entries = parse_resource(resource)
verified_by_path = {entry.path: entry for entry in verified_entries}
for target in ("logo.bmp", "logo_kernel.bmp"):
entry = verified_by_path[target]
embedded = bytes(
resource[
entry.content_offset : entry.content_offset + entry.content_size
]
)
if embedded != bmp_data:
fail(f"Post-patch verification failed for {target}")
return bytes(resource)
def patch_boot_image(source_data: bytes, bmp_data: bytes) -> bytes:
(
_nodes,
_resource_node,
resource_hash_property,
resource_position,
resource_size,
) = validate_fit(source_data)
resource_end = resource_position + resource_size
original_resource = source_data[resource_position:resource_end]
patched_resource = patch_resource(original_resource, bmp_data)
if len(patched_resource) != resource_size:
fail("Internal error: resource image size changed")
patched = bytearray(source_data)
patched[resource_position:resource_end] = patched_resource
patched_hash = hashlib.sha256(patched_resource).digest()
hash_offset = resource_hash_property.value_offset
patched[hash_offset : hash_offset + len(patched_hash)] = patched_hash
validate_fit(patched)
return bytes(patched)
def parse_running_slot(cmdline_path: Path = Path("/proc/cmdline")) -> str:
try:
tokens = cmdline_path.read_text(encoding="ascii").split()
except OSError as exc:
raise InjectionError(f"Cannot read {cmdline_path}: {exc}") from exc
accepted_keys = {"android_slotsufix", "androidboot.slot_suffix"}
discovered: list[tuple[str, str]] = []
for token in tokens:
if "=" not in token:
continue
key, value = token.split("=", 1)
if key in accepted_keys:
discovered.append((key, value))
if not discovered:
fail(
"No android_slotsufix or androidboot.slot_suffix was found in "
f"{cmdline_path}"
)
values = {value for _key, value in discovered}
if len(values) != 1:
fail(f"Conflicting slot values in {cmdline_path}: {discovered}")
value = values.pop()
if value not in {"_a", "_b"}:
fail(f"Unsupported active slot value {value!r}")
return value[1]
def resolve_slot(requested: str) -> tuple[str, str]:
active = parse_running_slot()
if requested == "active":
selected = active
elif requested == "inactive":
selected = "b" if active == "a" else "a"
else:
selected = requested
return active, selected
def block_device_size(path: Path) -> int:
resolved = Path(os.path.realpath(path))
sysfs_size = Path("/sys/class/block") / resolved.name / "size"
try:
sectors = int(sysfs_size.read_text(encoding="ascii").strip())
size = sectors * 512
if size > 0:
return size
except (OSError, ValueError):
pass
try:
with path.open("rb", buffering=0) as handle:
buffer = bytearray(8)
fcntl.ioctl(handle.fileno(), BLKGETSIZE64, buffer, True)
size = struct.unpack("=Q", buffer)[0]
except OSError as exc:
raise InjectionError(
f"Cannot determine block-device size for {path}: {exc}"
) from exc
if size <= 0:
fail(f"Block device {path} reports an invalid size {size}")
return size
def source_from_args(args: argparse.Namespace) -> Source:
if args.input is not None:
path = args.input.resolve()
try:
mode = path.stat().st_mode
except OSError as exc:
raise InjectionError(f"Cannot stat input {path}: {exc}") from exc
if not stat.S_ISREG(mode):
fail("--input must name a regular offline image file")
size = path.stat().st_size
return Source(
path=path,
display_name=str(path),
slot=None,
is_block_device=False,
size=size,
)
active, selected = resolve_slot(args.slot)
path = Path(f"/dev/block/by-name/boot_{selected}")
try:
mode = path.stat().st_mode
except OSError as exc:
raise InjectionError(f"Cannot stat boot device {path}: {exc}") from exc
if not stat.S_ISBLK(mode):
fail(f"Resolved boot source is not a block device: {path}")
size = block_device_size(path)
if size != EXPECTED_BOOT_PARTITION_SIZE:
fail(
f"Boot device {path} is {size} bytes; expected exactly "
f"{EXPECTED_BOOT_PARTITION_SIZE} bytes"
)
role = "active" if selected == active else "inactive"
print(
f"Running slot: {active.upper()}; selected slot: "
f"{selected.upper()} ({role})"
)
return Source(
path=path,
display_name=f"{path} ({os.path.realpath(path)})",
slot=selected,
is_block_device=True,
size=size,
)
def read_exact(path: Path, size: int) -> bytes:
chunks: list[bytes] = []
remaining = size
try:
with path.open("rb", buffering=0) as handle:
while remaining:
chunk = handle.read(min(1024 * 1024, remaining))
if not chunk:
fail(
f"Unexpected end of {path}: "
f"{remaining} bytes were still expected"
)
chunks.append(chunk)
remaining -= len(chunk)
except OSError as exc:
raise InjectionError(f"Cannot read {path}: {exc}") from exc
return b"".join(chunks)
def ensure_output_is_safe(output: Path, source: Source, force: bool) -> Path:
output = output.resolve()
if output == source.path.resolve():
fail("Output path must not be the source image or block device")
if output.exists():
mode = output.stat().st_mode
if not stat.S_ISREG(mode):
fail(f"Output exists and is not a regular file: {output}")
if not force:
fail(f"Output already exists: {output} (use --force-output)")
return output
def write_regular_file(path: Path, data: bytes, force: bool = False) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_name(f".{path.name}.tmp.{os.getpid()}")
if temporary.exists():
fail(f"Temporary output already exists: {temporary}")
try:
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
fd = os.open(temporary, flags, 0o600)
try:
view = memoryview(data)
while view:
written = os.write(fd, view)
if written <= 0:
fail(f"Failed while writing {temporary}")
view = view[written:]
os.fsync(fd)
finally:
os.close(fd)
if path.exists() and not force:
fail(f"Output appeared while staging: {path}")
os.replace(temporary, path)
directory_fd = os.open(path.parent, os.O_RDONLY)
try:
os.fsync(directory_fd)
finally:
os.close(directory_fd)
except Exception:
try:
temporary.unlink()
except OSError:
pass
raise
def default_output(source: Source) -> Path:
if source.is_block_device:
assert source.slot is not None
return Path(f"/tmp/boot_{source.slot}_logo_patched.img")
return source.path.with_name(source.path.name + ".logo-patched.img")
def check_not_mounted(source: Source) -> None:
if not source.is_block_device:
return
resolved_source = os.path.realpath(source.path)
try:
lines = Path("/proc/mounts").read_text(
encoding="utf-8", errors="replace"
).splitlines()
except OSError as exc:
raise InjectionError(f"Cannot inspect /proc/mounts: {exc}") from exc
for line in lines:
fields = line.split()
if fields and os.path.realpath(fields[0]) == resolved_source:
fail(f"Refusing to write mounted block device {source.path}")
def apply_to_device(
source: Source,
original: bytes,
patched: bytes,
backup_dir: Path,
) -> Path:
if not source.is_block_device:
fail("--apply requires an on-device boot-slot source, not --input")
if os.geteuid() != 0:
fail("--apply must be run as root")
check_not_mounted(source)
backup_dir = backup_dir.resolve()
backup_dir.mkdir(parents=True, exist_ok=True)
available = shutil.disk_usage(backup_dir).free
if available < len(original) + 16 * 1024 * 1024:
fail(
f"Insufficient backup space in {backup_dir}: "
f"{available} bytes free"
)
original_hash = hashlib.sha256(original).hexdigest()
timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
assert source.slot is not None
backup = backup_dir / (
f"boot_{source.slot}_{timestamp}_{original_hash[:12]}.img"
)
if backup.exists():
fail(f"Backup path already exists: {backup}")
write_regular_file(backup, original)
backup_data = read_exact(backup, len(original))
if hashlib.sha256(backup_data).digest() != hashlib.sha256(original).digest():
fail(f"Backup verification failed: {backup}")
print(f"Verified backup: {backup}")
current = read_exact(source.path, source.size)
if hashlib.sha256(current).digest() != hashlib.sha256(original).digest():
fail("Boot partition changed after staging; refusing to write")
print(f"Writing {len(patched)} bytes to {source.display_name}...")
try:
fd = os.open(source.path, os.O_WRONLY | os.O_SYNC)
try:
os.lseek(fd, 0, os.SEEK_SET)
view = memoryview(patched)
while view:
written = os.write(fd, view)
if written <= 0:
fail(f"Write failed for {source.path}")
view = view[written:]
os.fsync(fd)
finally:
os.close(fd)
except OSError as exc:
raise InjectionError(
f"Failed to write boot device {source.path}: {exc}. "
f"Backup is at {backup}"
) from exc
read_back = read_exact(source.path, source.size)
if hashlib.sha256(read_back).digest() != hashlib.sha256(patched).digest():
fail(
f"Read-back verification failed for {source.path}. "
f"Backup is at {backup}"
)
validate_fit(read_back)
print(f"Verified flashed boot partition: {source.path}")
return backup
def build_argument_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description=(
"Replace logo.bmp and logo_kernel.bmp in a Snapmaker U1 boot "
"image. Dry-run staging is the default; --apply is required to "
"write a boot partition."
)
)
parser.add_argument("bmp", type=Path, help="custom 480x320 24-bit BMP")
parser.add_argument(
"--input",
type=Path,
help="offline regular boot image instead of an on-device boot slot",
)
parser.add_argument(
"--slot",
choices=("active", "inactive", "a", "b"),
default="active",
help="on-device slot selection (default: active)",
)
parser.add_argument(
"--output",
type=Path,
help=(
"complete staged boot image path; defaults to /tmp on-device"
),
)
parser.add_argument(
"--force-output",
action="store_true",
help="replace an existing regular staging output",
)
mode = parser.add_mutually_exclusive_group()
mode.add_argument(
"--dry-run",
action="store_true",
help="stage and verify only (this is the default)",
)
mode.add_argument(
"--apply",
action="store_true",
help="back up and write the selected on-device boot partition",
)
parser.add_argument(
"--backup-dir",
type=Path,
default=Path("/userdata/boot-logo-backups"),
help="backup directory used with --apply",
)
return parser
def main() -> int:
args = build_argument_parser().parse_args()
if args.apply and args.input is not None:
fail("--apply cannot be combined with --input")
bmp_data = validate_bmp(args.bmp.resolve())
source = source_from_args(args)
print(f"Reading source: {source.display_name} ({source.size} bytes)")
original = read_exact(source.path, source.size)
original_hash = hashlib.sha256(original).hexdigest()
print(f"Source SHA-256: {original_hash}")
patched = patch_boot_image(original, bmp_data)
if len(patched) != len(original):
fail("Internal error: complete boot image size changed")
patched_hash = hashlib.sha256(patched).hexdigest()
print(f"Patched SHA-256: {patched_hash}")
output = args.output if args.output is not None else default_output(source)
output = ensure_output_is_safe(output, source, args.force_output)
write_regular_file(output, patched, force=args.force_output)
staged = read_exact(output, len(patched))
if hashlib.sha256(staged).digest() != hashlib.sha256(patched).digest():
fail(f"Staged output verification failed: {output}")
validate_fit(staged)
print(f"Verified complete staged image: {output}")
if args.apply:
backup = apply_to_device(source, original, staged, args.backup_dir)
print(f"Apply complete. Recovery backup: {backup}")
else:
print("Dry-run complete. No block device was written.")
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except (InjectionError, OSError) as exc:
print(f"ERROR: {exc}", file=sys.stderr)
raise SystemExit(1)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment