WIP: wasm compatible patcher?
This commit is contained in:
325
kobopatch-wasm/main.go
Normal file
325
kobopatch-wasm/main.go
Normal file
@@ -0,0 +1,325 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"crypto/sha1"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall/js"
|
||||
"time"
|
||||
|
||||
"github.com/pgaskin/kobopatch/patchfile"
|
||||
_ "github.com/pgaskin/kobopatch/patchfile/kobopatch"
|
||||
_ "github.com/pgaskin/kobopatch/patchfile/patch32lsb"
|
||||
"github.com/pgaskin/kobopatch/patchlib"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Config mirrors the kobopatch config structure, but only the fields we need.
|
||||
type Config struct {
|
||||
Version string
|
||||
In string // unused in WASM, but required by YAML schema
|
||||
Out string // unused in WASM
|
||||
Log string // unused in WASM
|
||||
Patches map[string]string
|
||||
Overrides map[string]map[string]bool
|
||||
}
|
||||
|
||||
// patchResult holds the output of a patching operation.
|
||||
type patchResult struct {
|
||||
tgzBytes []byte
|
||||
log string
|
||||
}
|
||||
|
||||
func main() {
|
||||
js.Global().Set("kobopatchVersion", js.ValueOf("wasm-1.0.0"))
|
||||
js.Global().Set("patchFirmware", js.FuncOf(jsPatchFirmware))
|
||||
|
||||
// Keep the Go runtime alive.
|
||||
select {}
|
||||
}
|
||||
|
||||
// jsPatchFirmware is the JS-callable wrapper.
|
||||
//
|
||||
// Arguments:
|
||||
//
|
||||
// args[0]: configYAML (string) - the kobopatch.yaml config content
|
||||
// args[1]: firmwareZip (Uint8Array) - the firmware zip file bytes
|
||||
// args[2]: patchFiles (Object) - map of filename -> Uint8Array patch file contents
|
||||
//
|
||||
// Returns: a Promise that resolves to { tgz: Uint8Array, log: string } or rejects with an error.
|
||||
func jsPatchFirmware(this js.Value, args []js.Value) interface{} {
|
||||
handler := js.FuncOf(func(this js.Value, promiseArgs []js.Value) interface{} {
|
||||
resolve := promiseArgs[0]
|
||||
reject := promiseArgs[1]
|
||||
|
||||
go func() {
|
||||
result, err := runPatch(args)
|
||||
if err != nil {
|
||||
reject.Invoke(js.Global().Get("Error").New(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// Create Uint8Array for the tgz output.
|
||||
tgzArray := js.Global().Get("Uint8Array").New(len(result.tgzBytes))
|
||||
js.CopyBytesToJS(tgzArray, result.tgzBytes)
|
||||
|
||||
// Return { tgz: Uint8Array, log: string }
|
||||
obj := js.Global().Get("Object").New()
|
||||
obj.Set("tgz", tgzArray)
|
||||
obj.Set("log", result.log)
|
||||
resolve.Invoke(obj)
|
||||
}()
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
return js.Global().Get("Promise").New(handler)
|
||||
}
|
||||
|
||||
func runPatch(args []js.Value) (*patchResult, error) {
|
||||
if len(args) < 3 {
|
||||
return nil, errors.New("patchFirmware requires 3 arguments: configYAML, firmwareZip, patchFiles")
|
||||
}
|
||||
|
||||
// Parse arguments.
|
||||
configYAML := args[0].String()
|
||||
|
||||
firmwareZipLen := args[1].Get("length").Int()
|
||||
firmwareZip := make([]byte, firmwareZipLen)
|
||||
js.CopyBytesToGo(firmwareZip, args[1])
|
||||
|
||||
patchFilesJS := args[2]
|
||||
patchFileKeys := js.Global().Get("Object").Call("keys", patchFilesJS)
|
||||
patchFiles := make(map[string][]byte)
|
||||
for i := 0; i < patchFileKeys.Length(); i++ {
|
||||
key := patchFileKeys.Index(i).String()
|
||||
val := patchFilesJS.Get(key)
|
||||
buf := make([]byte, val.Get("length").Int())
|
||||
js.CopyBytesToGo(buf, val)
|
||||
patchFiles[key] = buf
|
||||
}
|
||||
|
||||
return patchFirmware([]byte(configYAML), firmwareZip, patchFiles)
|
||||
}
|
||||
|
||||
// patchFirmware runs the kobopatch patching pipeline entirely in memory.
|
||||
func patchFirmware(configYAML []byte, firmwareZip []byte, patchFileContents map[string][]byte) (*patchResult, error) {
|
||||
var logBuf bytes.Buffer
|
||||
logf := func(format string, a ...interface{}) {
|
||||
fmt.Fprintf(&logBuf, format+"\n", a...)
|
||||
}
|
||||
|
||||
// Parse config.
|
||||
var config Config
|
||||
dec := yaml.NewDecoder(bytes.NewReader(configYAML))
|
||||
if err := dec.Decode(&config); err != nil {
|
||||
return nil, fmt.Errorf("could not parse config YAML: %w", err)
|
||||
}
|
||||
|
||||
if config.Version == "" || len(config.Patches) == 0 {
|
||||
return nil, errors.New("invalid config: version and patches are required")
|
||||
}
|
||||
|
||||
logf("kobopatch wasm")
|
||||
logf("Firmware version: %s", config.Version)
|
||||
|
||||
// Open the firmware zip from memory.
|
||||
zipReader, err := zip.NewReader(bytes.NewReader(firmwareZip), int64(len(firmwareZip)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not open firmware zip: %w", err)
|
||||
}
|
||||
|
||||
// Find and extract KoboRoot.tgz from the zip.
|
||||
var koboRootTgz io.ReadCloser
|
||||
for _, f := range zipReader.File {
|
||||
if f.Name == "KoboRoot.tgz" {
|
||||
koboRootTgz, err = f.Open()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not open KoboRoot.tgz in firmware zip: %w", err)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if koboRootTgz == nil {
|
||||
return nil, errors.New("could not find KoboRoot.tgz in firmware zip")
|
||||
}
|
||||
defer koboRootTgz.Close()
|
||||
|
||||
gzReader, err := gzip.NewReader(koboRootTgz)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not decompress KoboRoot.tgz: %w", err)
|
||||
}
|
||||
defer gzReader.Close()
|
||||
|
||||
tarReader := tar.NewReader(gzReader)
|
||||
|
||||
// Prepare the output tar.gz in memory.
|
||||
var outBuf bytes.Buffer
|
||||
outGZ := gzip.NewWriter(&outBuf)
|
||||
outTar := tar.NewWriter(outGZ)
|
||||
var outTarExpectedSize int64
|
||||
sums := map[string]string{}
|
||||
|
||||
// Iterate over firmware tar entries and apply patches.
|
||||
for {
|
||||
h, err := tarReader.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not read firmware tar entry: %w", err)
|
||||
}
|
||||
|
||||
// Find which patch files target this entry.
|
||||
var matchingPatchFiles []string
|
||||
for patchFileName, targetPath := range config.Patches {
|
||||
if h.Name == "./"+targetPath || h.Name == targetPath || filepath.Base(targetPath) == h.Name {
|
||||
matchingPatchFiles = append(matchingPatchFiles, patchFileName)
|
||||
}
|
||||
}
|
||||
|
||||
if len(matchingPatchFiles) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if h.Typeflag != tar.TypeReg {
|
||||
return nil, fmt.Errorf("could not patch '%s': not a regular file", h.Name)
|
||||
}
|
||||
|
||||
logf("Patching %s", h.Name)
|
||||
|
||||
entryBytes, err := io.ReadAll(tarReader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not read '%s' from firmware: %w", h.Name, err)
|
||||
}
|
||||
|
||||
pt := patchlib.NewPatcher(entryBytes)
|
||||
|
||||
for _, pfn := range matchingPatchFiles {
|
||||
logf(" Loading patch file: %s", pfn)
|
||||
|
||||
patchData, ok := patchFileContents[pfn]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("patch file '%s' not provided", pfn)
|
||||
}
|
||||
|
||||
format := detectFormat(pfn)
|
||||
formatFn, ok := patchfile.GetFormat(format)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unknown patch format '%s' for file '%s'", format, pfn)
|
||||
}
|
||||
|
||||
ps, err := formatFn(patchData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not parse patch file '%s': %w", pfn, err)
|
||||
}
|
||||
|
||||
// Apply overrides.
|
||||
if overrides, ok := config.Overrides[pfn]; ok {
|
||||
for name, enabled := range overrides {
|
||||
if err := ps.SetEnabled(name, enabled); err != nil {
|
||||
return nil, fmt.Errorf("could not set override '%s' in '%s': %w", name, pfn, err)
|
||||
}
|
||||
if enabled {
|
||||
logf(" ENABLE %s", name)
|
||||
} else {
|
||||
logf(" DISABLE %s", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := ps.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("invalid patch file '%s': %w", pfn, err)
|
||||
}
|
||||
|
||||
patchfile.Log = func(format string, a ...interface{}) {
|
||||
logf(" "+format, a...)
|
||||
}
|
||||
|
||||
if err := ps.ApplyTo(pt); err != nil {
|
||||
return nil, fmt.Errorf("error applying patches from '%s': %w", pfn, err)
|
||||
}
|
||||
}
|
||||
|
||||
patchedBytes := pt.GetBytes()
|
||||
outTarExpectedSize += h.Size
|
||||
|
||||
// Write patched file to output tar, preserving original attributes.
|
||||
if err := outTar.WriteHeader(&tar.Header{
|
||||
Typeflag: h.Typeflag,
|
||||
Name: h.Name,
|
||||
Mode: h.Mode,
|
||||
Uid: h.Uid,
|
||||
Gid: h.Gid,
|
||||
ModTime: time.Now(),
|
||||
Uname: h.Uname,
|
||||
Gname: h.Gname,
|
||||
PAXRecords: h.PAXRecords,
|
||||
Size: int64(len(patchedBytes)),
|
||||
Format: h.Format,
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("could not write header for '%s': %w", h.Name, err)
|
||||
}
|
||||
|
||||
if _, err := outTar.Write(patchedBytes); err != nil {
|
||||
return nil, fmt.Errorf("could not write patched '%s': %w", h.Name, err)
|
||||
}
|
||||
|
||||
sums[h.Name] = fmt.Sprintf("%x", sha1.Sum(patchedBytes))
|
||||
}
|
||||
|
||||
// Finalize the output tar.gz.
|
||||
if err := outTar.Close(); err != nil {
|
||||
return nil, fmt.Errorf("could not finalize output tar: %w", err)
|
||||
}
|
||||
if err := outGZ.Close(); err != nil {
|
||||
return nil, fmt.Errorf("could not finalize output gzip: %w", err)
|
||||
}
|
||||
|
||||
// Verify consistency.
|
||||
logf("\nVerifying output KoboRoot.tgz...")
|
||||
verifyReader, err := gzip.NewReader(bytes.NewReader(outBuf.Bytes()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not verify output: %w", err)
|
||||
}
|
||||
verifyTar := tar.NewReader(verifyReader)
|
||||
var verifySum int64
|
||||
for {
|
||||
vh, err := verifyTar.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("output verification failed: %w", err)
|
||||
}
|
||||
verifySum += vh.Size
|
||||
}
|
||||
if verifySum != outTarExpectedSize {
|
||||
return nil, fmt.Errorf("output size mismatch: expected %d, got %d", outTarExpectedSize, verifySum)
|
||||
}
|
||||
|
||||
logf("Output verified. Size: %d bytes", outBuf.Len())
|
||||
for f, s := range sums {
|
||||
logf(" sha1 %s %s", s, f)
|
||||
}
|
||||
|
||||
return &patchResult{
|
||||
tgzBytes: outBuf.Bytes(),
|
||||
log: logBuf.String(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func detectFormat(filename string) string {
|
||||
ext := strings.TrimLeft(filepath.Ext(filename), ".")
|
||||
ext = strings.ReplaceAll(ext, "patch", "patch32lsb")
|
||||
ext = strings.ReplaceAll(ext, "yaml", "kobopatch")
|
||||
return ext
|
||||
}
|
||||
Reference in New Issue
Block a user